1:# Manifest reference
12:## The principle
16:command line and spt-core runs it with `{key}` substitution placeholders
30:## `[adapter]` — header (required)
52:## `[hooks.<event>]` — inbound hook table
60:fires = "api seed --pid {parent_pid} --session-id {session_id} --adapter {adapter_name}"
78:## `[session]` — watched dirs + role templates
92:### `[session.<role>]` — outbound templates
115:| `cwd` | no | Working directory (substitutable) |
119:| `keys` | no | The substitution keys spt-core fills for this role |
132:## `[env.<VAR>]` — env-var table
142:value = "{session_id}"      # required for inject
146:## `[history]` — transcript access
154:fetcher = "my-harness-history --session {session_id}"
160:| `locate_normalize` | `locate_template`, `normalize_command` | spt-core locates the raw transcript, then runs your normalizer over it |
166:## `[digest]` — session-digest extractor
169:opaque and single-session for the echo-commune. `[digest]` declares an
174:[digest]
175:extractor = "my-harness-digest --session {session_id} --in {source}"
176:source = "~/.my-harness/{session_id}.jsonl"   # optional; defaults to [history].locate_template
184:| `extractor` | yes | Opaque command: native log → contract JSONL (one record/line). spt-core fills `{source}` with the resolved path and pipes the bytes on stdin. |
185:| `source` | no | Own-source log path; absent, reuse `[history].locate_template`. |
190:A **log-less** adapter declares no `[digest]` and pushes via `spt api
194:## `[inject]` — input-injection methods
205:## `[identity]` — session identity
219:## Session digest — the digest-record contract
222:endpoint's session logs**, not a parse of the PTY byte stream. Your `[digest]`
242:is spt-core's, defaulted by your `[digest]` and consumer-overridable; extraction is
245:## `[update]` — adapter self-update
267:## Shell adapters (`kind = "shell"`)
304:## Cross-field rules (`spt adapter add` enforces these)
312:  `locate_normalize` requires both `locate_template` and `normalize_command`.


Reading the two target regions — substitution intro + [digest]/cross-field sections — to place the additions precisely:


12	## The principle
13
14	**SPT is not a harness.** Command templates are opaque strings — spt-core
15	never parses out a model, tool list, or flag; the adapter writes the full
16	command line and spt-core runs it with `{key}` substitution placeholders
17	filled. Anything spt-core owns is *not* in the manifest:
18
19	- **Sentinels** (idle markers, the echo gate) — managed via `spt api state` /
20	  `spt api echo-gate`; adapters only call them.
21	- **Spool, registry, perch, and daemon-state schemas.**
22	- **The event-block vocabulary** — the tags spt-core surfaces to agents are a
23	  fixed, documented constant. Adapters pass spt-core's output through
24	  unchanged.
25	- **File-drop filenames** — statically `<endpoint_id>-commune.md` /
26	  `<endpoint_id>-signoff.md`; only the watched *directory* is declared.
27	- **Config knobs** (pulse period, summarizer windows, …) — global spt-core
28	  settings with per-endpoint overrides, never per-adapter.
29
30	## `[adapter]` — header (required)
31
32	The only mandatory section, and it must be readable *before* any install or
33	update — `min_spt_core_version` is the compatibility gate.
34
35	```toml
36	[adapter]
37	name = "my-harness"                # the adapter_name every api call carries
38	kind = "harness"                   # "harness" (default) | "shell"
39	version = "1.0.0"
40	min_spt_core_version = "1.0.0"     # lowest spt-core this adapter tolerates
41	hostable_types = ["LiveAgent", "ReadyAgent", "Worker"]

## The principle

**SPT is not a harness.** Command templates are opaque strings — spt-core
never parses out a model, tool list, or flag; the adapter writes the full
command line and spt-core runs it with `{key}` substitution placeholders
filled. Anything spt-core owns is *not* in the manifest:

- **Sentinels** (idle markers, the echo gate) — managed via `spt api state` /
  `spt api echo-gate`; adapters only call them.
- **Spool, registry, perch, and daemon-state schemas.**
- **The event-block vocabulary** — the tags spt-core surfaces to agents are a
  fixed, documented constant. Adapters pass spt-core's output through
  unchanged.
- **File-drop filenames** — statically `<endpoint_id>-commune.md` /
  `<endpoint_id>-signoff.md`; only the watched *directory* is declared.
- **Config knobs** (pulse period, summarizer windows, …) — global spt-core
  settings with per-endpoint overrides, never per-adapter.

## `[adapter]` — header (required)

The only mandatory section, and it must be readable *before* any install or
update — `min_spt_core_version` is the compatibility gate.

```toml
[adapter]
name = "my-harness"                # the adapter_name every api call carries
kind = "harness"                   # "harness" (default) | "shell"
version = "1.0.0"
min_spt_core_version = "1.0.0"     # lowest spt-core this adapter tolerates
hostable_types = ["LiveAgent", "ReadyAgent", "Worker"]


166	## `[digest]` — session-digest extractor
167
168	The session digest's own seam (ADR-0019) — separate from `[history]`, which stays
169	opaque and single-session for the echo-commune. `[digest]` declares an
170	**imperative extractor** that maps your harness's native log to the digest-record
171	contract:
172
173	```toml
174	[digest]
175	extractor = "my-harness-digest --session {session_id} --in {source}"
176	source = "~/.my-harness/{session_id}.jsonl"   # optional; defaults to [history].locate_template
177	window_turns = 5         # optional presentation defaults you declare…
178	arg_truncation = 40      # …any consumer may override at pull/subscribe
179	sprint_collapse = true
180	```
181
182	| Field | Required | Meaning |
183	|---|---|---|
184	| `extractor` | yes | Opaque command: native log → contract JSONL (one record/line). spt-core fills `{source}` with the resolved path and pipes the bytes on stdin. |
185	| `source` | no | Own-source log path; absent, reuse `[history].locate_template`. |
186	| `window_turns` / `arg_truncation` / `sprint_collapse` | no | Adapter-declared presentation **defaults**; any consumer may override. spt-core fallback: `3` / `25` / collapse-on. |
187
188	Why a command, not a declarative map: real harness logs are nested (one line →
189	many entries, mixed block lists, types to filter); a flat map can't express them.
190	A **log-less** adapter declares no `[digest]` and pushes via `spt api
191	digest-entry` instead. Validate before shipping with `spt adapter digest-proof
192	<adapter> --sample <real-log>`.
193

## `[digest]` — session-digest extractor

The session digest's own seam (ADR-0019) — separate from `[history]`, which stays
opaque and single-session for the echo-commune. `[digest]` declares an
**imperative extractor** that maps your harness's native log to the digest-record
contract:

```toml
[digest]
extractor = "my-harness-digest --session {session_id} --in {source}"
source = "~/.my-harness/{session_id}.jsonl"   # optional; defaults to [history].locate_template
window_turns = 5         # optional presentation defaults you declare…
arg_truncation = 40      # …any consumer may override at pull/subscribe
sprint_collapse = true
```

| Field | Required | Meaning |
|---|---|---|
| `extractor` | yes | Opaque command: native log → contract JSONL (one record/line). spt-core fills `{source}` with the resolved path and pipes the bytes on stdin. |
| `source` | no | Own-source log path; absent, reuse `[history].locate_template`. |
| `window_turns` / `arg_truncation` / `sprint_collapse` | no | Adapter-declared presentation **defaults**; any consumer may override. spt-core fallback: `3` / `25` / collapse-on. |

Why a command, not a declarative map: real harness logs are nested (one line →
many entries, mixed block lists, types to filter); a flat map can't express them.
A **log-less** adapter declares no `[digest]` and pushes via `spt api
digest-entry` instead. Validate before shipping with `spt adapter digest-proof
<adapter> --sample <real-log>`.


304	## Cross-field rules (`spt adapter add` enforces these)
305
306	The schema validates structure; registration additionally enforces:
307
308	- `adapter.name` and `adapter.version` must be non-empty.
309	- `kind = "shell"` **requires** a `[shell]` section; `kind = "harness"`
310	  **must not** have one.
311	- `[history] strategy = "fetcher"` requires `fetcher`;
312	  `locate_normalize` requires both `locate_template` and `normalize_command`.
313	- `[env.*] direction = "inject"` requires a `value`.
314	- `[update] avenue = "delegated"` requires `command`; `file_pull` requires
315	  `repo` **and** `signing_key`.
316
317	A violation is a one-line error naming the field — fix and re-add.
318

## Cross-field rules (`spt adapter add` enforces these)

The schema validates structure; registration additionally enforces:

- `adapter.name` and `adapter.version` must be non-empty.
- `kind = "shell"` **requires** a `[shell]` section; `kind = "harness"`
  **must not** have one.
- `[history] strategy = "fetcher"` requires `fetcher`;
  `locate_normalize` requires both `locate_template` and `normalize_command`.
- `[env.*] direction = "inject"` requires a `value`.
- `[update] avenue = "delegated"` requires `command`; `file_pull` requires
  `repo` **and** `signing_key`.

A violation is a one-line error naming the field — fix and re-add.


Have both targets + the full catalog. Before writing, ground each key's meaning/fill-context from where they're inserted (don't guess semantics):


Bash

cd C:/Users/decid/Documents/projects/spt-core; grep -rnoE "(insert|to_string)\(\"?(id|adapter_name|session_id|session_name|parent_pid|psyche_dir|psyche_prompt|psyche_context|agents_json|link_token|source|agent_type)\"" crates --include=*.rs | grep -v test | head -40; echo "=== fallback: keys.insert sites ==="; grep -rnE "\.insert\(" crates/spt-daemon/src/harnesshost.rs crates/spt-daemon/src/shellhost.rs crates/spt-live/src crates/spt/src/api --include=*.rs | grep -iE "psyche|agents_json|link_token|agent_type|session_name|\"id\"|adapter_name|parent_pid|session_id|source" | head -30

Find where each substitution key is filled


crates/spt-daemon/src/digest.rs:209:insert("id"
crates/spt-daemon/src/digest.rs:210:insert("session_id"
crates/spt-daemon/src/lifecycle.rs:116:insert("id"
crates/spt-daemon/src/lifecycle.rs:118:insert("session_id"
crates/spt-live/src/digest.rs:95:insert("source"
crates/spt-live/src/psyche.rs:122:insert("id"
=== fallback: keys.insert sites ===
crates/spt-live/src/digest.rs:95:    ekeys.insert("source".to_string(), path);
crates/spt-live/src/psyche.rs:122:    k.insert("id".to_string(), psyche_id.clone());
crates/spt-live/src/psyche.rs:123:    k.insert(
crates/spt-live/src/psyche.rs:127:    k.insert(


please pack in the docs-completeness fixes perri raised for your `Update-msg fix`, since it's a smaller change anyway


108	    /// The configured pulse period (config-driven, not a constant).
109	    pub fn pulse_period(&self) -> Duration {
110	        self.cfg.pulse_period
111	    }
112
113	    /// Base substitution keys spt-core fills for the lifecycle templates.
114	    fn base_keys(&self, session_id: Option<&str>) -> BTreeMap<String, String> {
115	        let mut k = BTreeMap::new();
116	        k.insert("id".to_string(), self.id.clone());
117	        if let Some(sid) = session_id {
118	            k.insert("session_id".to_string(), sid.to_string());
119	        }
120	        k
121	    }
122
123	    /// The unique watched drop dirs (commune + signoff; usually the same dir).
124	    fn drop_dirs(&self) -> Vec<PathBuf> {
125	        let mut dirs: Vec<PathBuf> = Vec::new();
126	        for d in [self.commune_dir.as_ref(), self.signoff_dir.as_ref()]
127	            .into_iter()
128	            .flatten()
129	        {
130	            if !dirs.contains(d) {
131	                dirs.push(d.clone());
132	            }
133	        }
134	        dirs
135	    }
136
137	    /// Sweep a stale signoff sentinel on start (3.2). No-op without a signoff dir.
138	    pub fn sweep_on_start(&self) {
139	        if let Some(dir) = &self.signoff_dir {
140	            let _ = sweep_stale_signoff(dir, &self.id);
141	        }
142	    }

    /// The configured pulse period (config-driven, not a constant).
    pub fn pulse_period(&self) -> Duration {
        self.cfg.pulse_period
    }

    /// Base substitution keys spt-core fills for the lifecycle templates.
    fn base_keys(&self, session_id: Option<&str>) -> BTreeMap<String, String> {
        let mut k = BTreeMap::new();
        k.insert("id".to_string(), self.id.clone());
        if let Some(sid) = session_id {
            k.insert("session_id".to_string(), sid.to_string());
        }
        k
    }

    /// The unique watched drop dirs (commune + signoff; usually the same dir).
    fn drop_dirs(&self) -> Vec<PathBuf> {
        let mut dirs: Vec<PathBuf> = Vec::new();
        for d in [self.commune_dir.as_ref(), self.signoff_dir.as_ref()]
            .into_iter()
            .flatten()
        {
            if !dirs.contains(d) {
                dirs.push(d.clone());
            }
        }
        dirs
    }

    /// Sweep a stale signoff sentinel on start (3.2). No-op without a signoff dir.
    pub fn sweep_on_start(&self) {
        if let Some(dir) = &self.signoff_dir {
            let _ = sweep_stale_signoff(dir, &self.id);
        }
    }


118	    // The nested perch dir must exist before spawn — the role's `cwd` is it.
119	    std::fs::create_dir_all(&perch_path).map_err(PsycheError::Bind)?;
120
121	    let mut k = keys.clone();
122	    k.insert("id".to_string(), psyche_id.clone());
123	    k.insert(
124	        "psyche_dir".to_string(),
125	        perch_path.to_string_lossy().to_string(),
126	    );
127	    k.insert(
128	        "psyche_prompt".to_string(),
129	        compose_psyche_prompt(now, event),
130	    );
131
132	    let spawned = runtime.spawn_session(role, &k)?;
133
134	    // Bind the nested perch: spt-core writes the Psyche's identity/status record.
135	    // Interim per-pid liveness (M3's daemon makes `status` authoritative).
136	    let session_id = keys.get("session_id").map(String::as_str).unwrap_or("");
137	    let mut rec = InfoJson::new(
138	        &psyche_id,
139	        now,

    // The nested perch dir must exist before spawn — the role's `cwd` is it.
    std::fs::create_dir_all(&perch_path).map_err(PsycheError::Bind)?;

    let mut k = keys.clone();
    k.insert("id".to_string(), psyche_id.clone());
    k.insert(
        "psyche_dir".to_string(),
        perch_path.to_string_lossy().to_string(),
    );
    k.insert(
        "psyche_prompt".to_string(),
        compose_psyche_prompt(now, event),
    );

    let spawned = runtime.spawn_session(role, &k)?;

    // Bind the nested perch: spt-core writes the Psyche's identity/status record.
    // Interim per-pid liveness (M3's daemon makes `status` authoritative).
    let session_id = keys.get("session_id").map(String::as_str).unwrap_or("");
    let mut rec = InfoJson::new(
        &psyche_id,
        now,


psyche keys confirmed. Have enough for accurate docs. Verifying the exact `[digest]` rejection text so the prose matches the real error:


cd C:/Users/decid/Documents/projects/spt-core; grep -rnE "needs source|own-source|locate_template" crates --include=*.rs | grep -iE "digest|needs|require" | head

Find exact [digest] rejection error text


crates/spt-live/src/digest.rs:17://! it reuses `[history]`'s `locate_template` (DRY — the same per-session log).
crates/spt-live/src/digest.rs:36:    /// `locate_template` (a log-less adapter pushes via `api digest-entry`).
crates/spt-live/src/digest.rs:53:                "[digest] has no resolvable source (no `source` and no [history] locate_template)"
crates/spt-live/src/digest.rs:67:/// The `[digest]` `source` template, or `[history]`'s `locate_template` as the
crates/spt-live/src/digest.rs:73:        .or_else(|| history.and_then(|h| h.locate_template.as_deref()))
crates/spt-live/src/digest.rs:170:    // locate_template (the DRY default).
crates/spt-live/src/digest.rs:172:    fn falls_back_to_history_locate_template() {
crates/spt-live/src/digest.rs:178:            locate_template: Some(raw.path().display().to_string()),
crates/spt-live/src/digest.rs:184:            Some(history.locate_template.as_deref().unwrap())
crates/spt-runtime/src/manifest.rs:321:/// own-source escape hatch). `api digest-entry` push remains the always-available


cd C:/Users/decid/Documents/projects/spt-core; grep -nE "digest|needs source|locate_template|own-source|fn validate|resolvable" crates/spt-runtime/src/manifest.rs | grep -iE "digest|validate|needs|resolvable|locate" | head

Find register-time digest validation in manifest.rs


42:    /// `[digest]` — the adapter-declared session-digest extractor seam (ADR-0019).
44:    pub digest: Option<Digest>,
289:    pub locate_template: Option<String>,
306:/// `[digest]` — the session-digest extractor seam (ADR-0019). Reverses M9's
307:/// "no manifest seam" stance: the digest gets its **own** adapter-declared
310:/// log → the published `{role, text, tool, ts}` digest-record contract
320:/// (the `locate_template`; DRY). An adapter may override with `source` (the
321:/// own-source escape hatch). `api digest-entry` push remains the always-available
322:/// fallback for a log-less adapter (which declares no `[digest]` at all).
334:    /// Own-source escape hatch: a `locate_template` for the log file(s) the


cd C:/Users/decid/Documents/projects/spt-core; grep -rnE "digest|resolvable source|locate_template" crates/spt-runtime/src/registry.rs | grep -iE "digest|resolvable|locate|needs|source" | head; echo "=== broad: any 'needs source' or digest register reject ==="; grep -rnE "needs source|no resolvable source|\[digest\]" crates --include=*.rs | grep -ivE "tests?/|test |fn .*test|//|^\s*//" | grep -iE "needs|resolvable|reject|require|return Err|bail" | head

Confirm register-time digest rule location


186:/// mirrors the `[digest]` stance. A containment violation here is also a skip
1145:    // error — mirrors the [digest] stance.
=== broad: any 'needs source' or digest register reject ===
crates/spt-live/src/digest.rs:53:                "[digest] has no resolvable source (no `source` and no [history] locate_template)"
crates/spt-runtime/src/manifest.rs:699:                return err("[digest] requires a non-empty `extractor` command".into());
crates/spt-runtime/src/manifest.rs:707:                    "[digest] needs `source` (own-source) or a [history] `locate_template` to read from \
crates/spt-runtime/src/manifest.rs:713:                return err("[digest] `window_turns` must be greater than 0".into());


Exact register-time rule confirmed (`manifest.rs:707`: "[digest] needs `source` (own-source) or a [history] `locate_template`"). Now editing in the worktree. Reading the worktree's actual file first (required before edit):


26	  `<endpoint_id>-signoff.md`; only the watched *directory* is declared.
27	- **Config knobs** (pulse period, summarizer windows, …) — global spt-core
28	  settings with per-endpoint overrides, never per-adapter.
29
30	## `[adapter]` — header (required)
31

  `<endpoint_id>-signoff.md`; only the watched *directory* is declared.
- **Config knobs** (pulse period, summarizer windows, …) — global spt-core
  settings with per-endpoint overrides, never per-adapter.

## `[adapter]` — header (required)


Worktree matches. Adding the substitution-key catalog after "The principle":


Edit

- **Config knobs** (pulse period, summarizer windows, …) — global spt-core
  settings with per-endpoint overrides, never per-adapter.

## `[adapter]` — header (required)

- **Config knobs** (pulse period, summarizer windows, …) — global spt-core
  settings with per-endpoint overrides, never per-adapter.

## Substitution keys

The full `{key}` vocabulary spt-core fills into command templates. A role's
`keys` list must be a subset of this catalog, and every `{placeholder}` in a
`command` (or `cwd`/`source`/`fires`/…) must resolve to a value spt-core
supplies for that spawn — an unknown or unprovided key fails with a one-line
error. Not every key exists in every context: spt-core fills only those
relevant to the spawn (e.g. `{psyche_*}` only for a live agent's Psyche role,
`{source}` only for a `[digest]`/`[history]` extractor).

| Key | spt-core fills it with |
|---|---|
| `{id}` | The endpoint id being hosted. |
| `{adapter_name}` | The adapter's declared `name` (the value every `api` call carries). |
| `{session_id}` | The harness session id (minted at spawn; reported back via `api seed`). |
| `{session_name}` | The session's display name, when one is supplied. |
| `{parent_pid}` | The harness parent process pid — the SessionStart `api seed` anchor. |
| `{agent_type}` | The hosted agent type. |
| `{agents_json}` | The resolved agents roster for the session. |
| `{psyche_dir}` | A live agent's Psyche perch directory (its working dir). |
| `{psyche_prompt}` | The composed Psyche turn prompt for this spawn. |
| `{psyche_context}` | The Psyche's carried context block. |
| `{link_token}` | A shell-link capability token (shell adapters). |
| `{source}` | The transcript/log path spt-core resolves for a `[digest]`/`[history]` extractor. |

## `[adapter]` — header (required)


The file C:\Users\decid\Documents\projects\spt-core-wt-updapply\docs-site\src\harness-contract\manifest.md has been updated successfully. (file state is current in your context — no need to Read it back)

- **Config knobs** (pulse period, summarizer windows, …) — global spt-core
  settings with per-endpoint overrides, never per-adapter.

## `[adapter]` — header (required)

- **Config knobs** (pulse period, summarizer windows, …) — global spt-core
  settings with per-endpoint overrides, never per-adapter.

## Substitution keys

The full `{key}` vocabulary spt-core fills into command templates. A role's
`keys` list must be a subset of this catalog, and every `{placeholder}` in a
`command` (or `cwd`/`source`/`fires`/…) must resolve to a value spt-core
supplies for that spawn — an unknown or unprovided key fails with a one-line
error. Not every key exists in every context: spt-core fills only those
relevant to the spawn (e.g. `{psyche_*}` only for a live agent's Psyche role,
`{source}` only for a `[digest]`/`[history]` extractor).

| Key | spt-core fills it with |
|---|---|
| `{id}` | The endpoint id being hosted. |
| `{adapter_name}` | The adapter's declared `name` (the value every `api` call carries). |
| `{session_id}` | The harness session id (minted at spawn; reported back via `api seed`). |
| `{session_name}` | The session's display name, when one is supplied. |
| `{parent_pid}` | The harness parent process pid — the SessionStart `api seed` anchor. |
| `{agent_type}` | The hosted agent type. |
| `{agents_json}` | The resolved agents roster for the session. |
| `{psyche_dir}` | A live agent's Psyche perch directory (its working dir). |
| `{psyche_prompt}` | The composed Psyche turn prompt for this spawn. |
| `{psyche_context}` | The Psyche's carried context block. |
| `{link_token}` | A shell-link capability token (shell adapters). |
| `{source}` | The transcript/log path spt-core resolves for a `[digest]`/`[history]` extractor. |

## `[adapter]` — header (required)

+The full `{key}` vocabulary spt-core fills into command templates. A role's

+`keys` list must be a subset of this catalog, and every `{placeholder}` in a

+`command` (or `cwd`/`source`/`fires`/…) must resolve to a value spt-core

+supplies for that spawn — an unknown or unprovided key fails with a one-line

+error. Not every key exists in every context: spt-core fills only those

+relevant to the spawn (e.g. `{psyche_*}` only for a live agent's Psyche role,

+`{source}` only for a `[digest]`/`[history]` extractor).

+| `{id}` | The endpoint id being hosted. |

+| `{adapter_name}` | The adapter's declared `name` (the value every `api` call carries). |

+| `{session_id}` | The harness session id (minted at spawn; reported back via `api seed`). |

+| `{session_name}` | The session's display name, when one is supplied. |

+| `{parent_pid}` | The harness parent process pid — the SessionStart `api seed` anchor. |

+| `{agent_type}` | The hosted agent type. |

+| `{agents_json}` | The resolved agents roster for the session. |

+| `{psyche_dir}` | A live agent's Psyche perch directory (its working dir). |

+| `{psyche_prompt}` | The composed Psyche turn prompt for this spawn. |

+| `{psyche_context}` | The Psyche's carried context block. |

+| `{link_token}` | A shell-link capability token (shell adapters). |

+| `{source}` | The transcript/log path spt-core resolves for a `[digest]`/`[history]` extractor. |

The only mandatory section, and it must be readable *before* any install or


Now the `[digest]` cross-field rule. Adding it to the "Cross-field rules (`spt adapter add` enforces these)" section — exactly where perri said it was missing:


- `[history] strategy = "fetcher"` requires `fetcher`;
  `locate_normalize` requires both `locate_template` and `normalize_command`.
- `[env.*] direction = "inject"` requires a `value`.

- `[history] strategy = "fetcher"` requires `fetcher`;
  `locate_normalize` requires both `locate_template` and `normalize_command`.
- `[digest]` requires a non-empty `extractor`, **and** a resolvable source:
  either its own `source` or a `[history] locate_template` to fall back to.
  Absent both, registration rejects (*"[digest] needs `source` (own-source) or
  a [history] `locate_template`"*) — the JSON schema alone accepts a bare
  `extractor`, so this only surfaces at `spt adapter add`.
- `[env.*] direction = "inject"` requires a `value`.


- `[history] strategy = "fetcher"` requires `fetcher`;
  `locate_normalize` requires both `locate_template` and `normalize_command`.
- `[env.*] direction = "inject"` requires a `value`.

- `[history] strategy = "fetcher"` requires `fetcher`;
  `locate_normalize` requires both `locate_template` and `normalize_command`.
- `[digest]` requires a non-empty `extractor`, **and** a resolvable source:
  either its own `source` or a `[history] locate_template` to fall back to.
  Absent both, registration rejects (*"[digest] needs `source` (own-source) or
  a [history] `locate_template`"*) — the JSON schema alone accepts a bare
  `extractor`, so this only surfaces at `spt adapter add`.
- `[env.*] direction = "inject"` requires a `value`.

+- `[digest]` requires a non-empty `extractor`, **and** a resolvable source:

+  either its own `source` or a `[history] locate_template` to fall back to.

+  Absent both, registration rejects (*"[digest] needs `source` (own-source) or

+  a [history] `locate_template`"*) — the JSON schema alone accepts a bare

+  `extractor`, so this only surfaces at `spt adapter add`.

- `[update] avenue = "delegated"` requires `command`; `file_pull` requires


Cross-referencing it in the `[digest]` `source` row for discoverability:


| `source` | no | Own-source log path; absent, reuse `[history].locate_template`. |

| `source` | no | Own-source log path; absent, reuse `[history].locate_template`. One of the two **must** resolve, else `spt adapter add` rejects (see [Cross-field rules](#cross-field-rules-spt-adapter-add-enforces-these)). |


| `extractor` | yes | Opaque command: native log → contract JSONL (one record/line). spt-core fills `{source}` with the resolved path and pipes the bytes on stdin. |

-| `source` | no | Own-source log path; absent, reuse `[history].locate_template`. |

+| `source` | no | Own-source log path; absent, reuse `[history].locate_template`. One of the two **must** resolve, else `spt adapter add` rejects (see [Cross-field rules](#cross-field-rules-spt-adapter-add-enforces-these)). |

| `window_turns` / `arg_truncation` / `sprint_collapse` | no | Adapter-declared presentation **defaults**; any consumer may override. spt-core fallback: `3` / `25` / collapse-on. |

Why a command, not a declarative map: real harness logs are nested (one line →


Two checks before committing: (1) is `docs-site` hand-written or generated (drift-gate risk), and (2) `docs/MANIFEST.md` flagged this catalog as an "Open" item — resolve it there too so the docs agree:


Bash

cd C:/Users/decid/Documents/projects/spt-core; echo "=== is manifest.md generated? (look for gen markers / xtask doc gen) ==="; grep -rnE "manifest.md|harness-contract|GENERATED|do not edit|autogen" crates/xtask/src 2>/dev/null | head; echo "=== docs/MANIFEST.md: substitution / Open catalog ==="; grep -nE "substitut|Open|catalog|\{session_id\}|\{adapter_name\}|SUBSTITUTION_KEYS" docs/MANIFEST.md | head

Check doc-gen coupling + MANIFEST.md open item


=== is manifest.md generated? (look for gen markers / xtask doc gen) ===
=== docs/MANIFEST.md: substitution / Open catalog ===
7:**SPT is not a harness.** The manifest declares only what *varies per harness*. Command templates are **opaque strings** — spt-core never parses out a model, tool list, or flag; the adapter writes the full command line and spt-core just runs it with substitution keys filled. Anything spt-core owns (sentinels, spool/registry/perch schemas, the event-block vocabulary, the version-trampoline mechanism, config knobs) is **not** in the manifest.
50:fires = "api seed --pid {parent_pid} --session-id {session_id} --adapter {adapter_name}"
70:keys = ["agents_json", "session_name", "psyche_dir"]   # substitution keys spt-core fills
72:(`psyche_resume` adds `{session_id}`; both carry the spt-core-owned `{psyche_prompt}`. `signoff` is a `psyche_resume` variant firing a final context-save. `echo_commune` is a cheap-model template consuming history.)
89:value = "{session_id}"
98:fetcher = "claude-spt-history --session {session_id}"   # adapter binary emits normalized history
109:extractor = "claude-spt-digest --session {session_id} --in {source}"  # native log → contract JSONL
110:source = "~/.claude/projects/{project}/{session_id}.jsonl"   # optional; defaults to [history].locate_template
248:**Sleep/wake:** when offline (+ `wake_command` set), spt-core runs the wake-watcher (mutually exclusive with the shell binary); `exit(wake-opcode)` → wake resolution; crash-exit → respawn with exponential backoff + give-up (until the next relink/launch). **The wake opcode is exit code 86.** The wake-watcher template shares the spawn template's substitution catalog (`{id}`, `{adapter_name}`, `{link_token}`), but its `{link_token}` is minted *unparked* — an offline link has no live credential (the close retired it); a waker wakes by exit code, never by driving the link. `persistent` auto-onlines the shell with its owner. `can_shutdown` authorizes `api owner-shutdown`. Cross-node *fresh-spawn* to wake an owner requires the owner's `shell_wake_spawn_anywhere` settings flag (pre-consent).
261:| Psyche resume | `claude -p --resume <uuid> --model sonnet …` | `[session.psyche_resume]` + `{session_id}` |


`manifest.md` is hand-written (no gen-coupling — safe). The source doc `docs/MANIFEST.md` has the same partial gap (line 70) and the code const cites "MANIFEST §Open: exact substitution-key catalog" — checking for that open-item to close it at the source too:


cd C:/Users/decid/Documents/projects/spt-core; grep -niE "open|§open|substitution.key catalog|exact substitution|deferred|TODO|to be documented" docs/MANIFEST.md | head

Find open-item / catalog reference in MANIFEST.md


194:pulls / `api digest-entry` pushes). Autonomous file-watch freshness is deferred to
272:## Open sub-items
273:- Exact substitution-key catalog (which keys spt-core guarantees to fill per role).


`docs/MANIFEST.md:272-273` lists it as an open sub-item — I'll close it. Reading the open-items section + the keys example to place the catalog:


268	| Identity | `OWL_SESSION_ID` + `claude.exe` ancestor walk | `[identity]` |
269	| Update | (today: cplugs) | `[update] avenue="delegated" command="claude plugin update"` |
270	| period / echo-window / route-guard / 24h refresh | hardcoded/flags | spt-core **global knobs** (per-endpoint override), not manifest |
271
272	## Open sub-items
273	- Exact substitution-key catalog (which keys spt-core guarantees to fill per role).
274	- TOML vs YAML final choice; schema-validation + helpful errors for adapter authors.
275	- The fixed event-block vocabulary's final tag names (`<SPT_EVENT>` etc.).
276	- Capability-declaration shape beyond `hostable_types` (per-type operation advertisement).
277

| Identity | `OWL_SESSION_ID` + `claude.exe` ancestor walk | `[identity]` |
| Update | (today: cplugs) | `[update] avenue="delegated" command="claude plugin update"` |
| period / echo-window / route-guard / 24h refresh | hardcoded/flags | spt-core **global knobs** (per-endpoint override), not manifest |

## Open sub-items
- Exact substitution-key catalog (which keys spt-core guarantees to fill per role).
- TOML vs YAML final choice; schema-validation + helpful errors for adapter authors.
- The fixed event-block vocabulary's final tag names (`<SPT_EVENT>` etc.).
- Capability-declaration shape beyond `hostable_types` (per-type operation advertisement).


I'll close the open sub-item and add the catalog to the source doc. Reading the `keys` example area for placement:


<owl_messages>
<owl_message from="todlando" priority="high">PUSHED — caaf797 on docs/msg-envelope-adr-0020 (PR #12). Notify fix + scope carve-out, exactly as gated:
- cmd_poll_shell → raw passthrough (println m.body, no <EVENT>); agent-perch poll/worker/listen keep <EVENT>.
- Boundary documented 4 places: load-bearing comment at cmd_poll_shell + ADR-0020 clause + REQ-MSG-ENVELOPE title + CONTEXT:685 — "<EVENT>-everywhere" scoped to harness arriving-message surfaces on AGENT perches; shell-link relay exempt; notify_shell_e2e named guard.
VERIFY (real adapter, SPT_NOTIFY_SHELL_BIN=built spt-shell-notify): RED→GREEN confirmed (30.28s timeout → 1.32s render). Full sweep: nextest --workspace 1054, clippy -D clean, traceable EXIT 0, xtask OK.
Pre-push sweep updated: now includes `cargo test -p spt --test notify_shell_e2e` (with the adapter built) + the env-gated E2Es nextest skips — that gap won't bite a 3rd time.
CI re-running; re-gate (tests + doc boundary) + merge when green → I ping perri. update-msg branch: still hands-off, yours.</owl_message>
</owl_messages>

[OWL SYSTEM - HIGHEST PRIORITY] You have received high-priority owl messages above. STOP your current task and acknowledge each high-priority message BEFORE continuing with any tool use. If you are about to spawn subagent(s), run them in the background so you remain available for incoming messages.


64	[session.psyche_init]
65	command = "claude -p --agents {agents_json} --agent owl-psyche --name {session_name} --model sonnet --fallback-model opus --effort medium --dangerously-skip-permissions --disable-slash-commands --tools Read,Edit,Write --output-format json"
66	cwd = "{psyche_dir}"
67	env_remove = ["OWL_SESSION_ID"]
68	recursion_guard_env = "SPT_ECHO_COMMUNE"   # set on summarizer children so their own hooks bail
69	detach = true
70	keys = ["agents_json", "session_name", "psyche_dir"]   # substitution keys spt-core fills
71	```
72	(`psyche_resume` adds `{session_id}`; both carry the spt-core-owned `{psyche_prompt}`. `signoff` is a `psyche_resume` variant firing a final context-save. `echo_commune` is a cheap-model template consuming history.)
73
74	<!-- [doc->REQ-NOTIF-2] -->
75	`notif` is the **endpoint-native notification render** (ADR-0007's `notif_command` seam): an OS toast, a Shell `alert-symbol`, anything the adapter can run. Declared on harness-adapter **and** shell-adapter manifests; spawned **detached** when a notif surfaces at this endpoint, combinable with the agent-surface delivery (the typed `notify` envelope). Keys spt-core fills: `{notif_id}`, `{notif_from}`, `{notif_subnet}`, `{notif_body}`.

[session.psyche_init]
command = "claude -p --agents {agents_json} --agent owl-psyche --name {session_name} --model sonnet --fallback-model opus --effort medium --dangerously-skip-permissions --disable-slash-commands --tools Read,Edit,Write --output-format json"
cwd = "{psyche_dir}"
env_remove = ["OWL_SESSION_ID"]
recursion_guard_env = "SPT_ECHO_COMMUNE"   # set on summarizer children so their own hooks bail
detach = true
keys = ["agents_json", "session_name", "psyche_dir"]   # substitution keys spt-core fills
```
(`psyche_resume` adds `{session_id}`; both carry the spt-core-owned `{psyche_prompt}`. `signoff` is a `psyche_resume` variant firing a final context-save. `echo_commune` is a cheap-model template consuming history.)

<!-- [doc->REQ-NOTIF-2] -->
`notif` is the **endpoint-native notification render** (ADR-0007's `notif_command` seam): an OS toast, a Shell `alert-symbol`, anything the adapter can run. Declared on harness-adapter **and** shell-adapter manifests; spawned **detached** when a notif surfaces at this endpoint, combinable with the agent-surface delivery (the typed `notify` envelope). Keys spt-core fills: `{notif_id}`, `{notif_from}`, `{notif_subnet}`, `{notif_body}`.


todlando pushed PR #12 fix caaf797 (real-adapter RED→GREEN, full sweep green, doc boundary 4 places, pre-push sweep hardened). ACK + re-gate watcher, and finishing the MANIFEST.md source-doc close-out in the same turn:


Edit

(`psyche_resume` adds `{session_id}`; both carry the spt-core-owned `{psyche_prompt}`. `signoff` is a `psyche_resume` variant firing a final context-save. `echo_commune` is a cheap-model template consuming history.)

(`psyche_resume` adds `{session_id}`; both carry the spt-core-owned `{psyche_prompt}`. `signoff` is a `psyche_resume` variant firing a final context-save. `echo_commune` is a cheap-model template consuming history.)

**Substitution-key catalog.** The full `{key}` vocabulary spt-core guarantees it can fill — a role's `keys` must be a subset (source of truth: `spt_runtime::SUBSTITUTION_KEYS`): `{id}`, `{adapter_name}`, `{session_id}`, `{session_name}`, `{parent_pid}`, `{agent_type}`, `{agents_json}`, `{psyche_dir}`, `{psyche_prompt}`, `{psyche_context}`, `{link_token}`, `{source}`. spt-core fills only the keys relevant to a given spawn (`{psyche_*}` for a live agent's Psyche role, `{source}` for a `[digest]`/`[history]` extractor, …); an unknown or unprovided `{placeholder}` fails with a one-line error. Per-key meanings: the published [manifest reference](../docs-site/src/harness-contract/manifest.md#substitution-keys).


The file C:\Users\decid\Documents\projects\spt-core\docs\MANIFEST.md has been updated successfully. (file state is current in your context — no need to Read it back)

(`psyche_resume` adds `{session_id}`; both carry the spt-core-owned `{psyche_prompt}`. `signoff` is a `psyche_resume` variant firing a final context-save. `echo_commune` is a cheap-model template consuming history.)

**Substitution-key catalog.** The full `{key}` vocabulary spt-core guarantees it can fill — a role's `keys` must be a subset (source of truth: `spt_runtime::SUBSTITUTION_KEYS`): `{id}`, `{adapter_name}`, `{session_id}`, `{session_name}`, `{parent_pid}`, `{agent_type}`, `{agents_json}`, `{psyche_dir}`, `{psyche_prompt}`, `{psyche_context}`, `{link_token}`, `{source}`. spt-core fills only the keys relevant to a given spawn (`{psyche_*}` for a live agent's Psyche role, `{source}` for a `[digest]`/`[history]` extractor, …); an unknown or unprovided `{placeholder}` fails with a one-line error. Per-key meanings: the published [manifest reference](../docs-site/src/harness-contract/manifest.md#substitution-keys).

+**Substitution-key catalog.** The full `{key}` vocabulary spt-core guarantees it can fill — a role's `keys` must be a subset (source of truth: `spt_runtime::SUBSTITUTION_KEYS`): `{id}`, `{adapter_name}`, `{session_id}`, `{session_name}`, `{parent_pid}`, `{agent_type}`, `{agents_json}`, `{psyche_dir}`, `{psyche_prompt}`, `{psyche_context}`, `{link_token}`, `{source}`. spt-core fills only the keys relevant to a given spawn (`{psyche_*}` for a live agent's Psyche role, `{source}` for a `[digest]`/`[history]` extractor, …); an unknown or unprovided `{placeholder}` fails with a one-line error. Per-key meanings: the published [manifest reference](../docs-site/src/harness-contract/manifest.md#substitution-keys).

`notif` is the **endpoint-native notification render** (ADR-0007's `notif_command` seam): an OS toast, a Shell `alert-symbol`, anything the adapter can run. Declared on harness-adapter **and** shell-adapter manifests; spawned **detached** when a notif surfaces at this endpoint, combinable with the agent-surface delivery (the typed `notify` envelope). Keys spt-core fills: `{notif_id}`, `{notif_from}`, `{notif_subnet}`, `{notif_body}`.


Wait — I made that `docs/MANIFEST.md` edit in the **main repo** (docs branch @ todlando's caaf797), not the update-msg worktree. That's the wrong branch and risks colliding with todlando's PR #12 tree. Need to move it. First ACK todlando, launch the CI watcher, and check the main-repo state:


Bash

cd C:/Users/decid/Documents/projects/spt-core; $OWL send todlando doyle <<'EOF'
ACK caaf797 — real-adapter RED→GREEN (30.28s→1.32s), full sweep green, boundary documented 4 places, pre-push sweep now runs notify_shell_e2e + the env-gated E2Es. Exactly as gated. CI re-running; I re-gate (both tests + the doc carve-out lands) + merge when green, then you ping perri. Standby.
EOF
echo "=== main repo HEAD + status ==="; git log --oneline -1; git status --short; echo "=== launching CI watcher (caaf797) ==="

ACK todlando + check main repo state


➜ SENT:todlando
=== main repo HEAD + status ===
caaf797 fix(msg-envelope): shell-link relay is EXEMPT from <EVENT> — raw MAC'd frame passthrough
 M docs/MANIFEST.md
=== launching CI watcher (caaf797) ===


Confirmed — HEAD is caaf797 with my stray `docs/MANIFEST.md` edit on top. Restoring the main-repo file (discard the misplaced edit) + launching the CI watcher:


cd C:/Users/decid/Documents/projects/spt-core; git restore docs/MANIFEST.md; echo "restored, status:"; git status --short; echo "(clean = good)"

Restore misplaced MANIFEST.md edit in main repo


restored, status:
(clean = good)


cd C:/Users/decid/Documents/projects/spt-core; for i in $(seq 1 60); do s=$(gh pr checks 12 2>&1); if ! echo "$s" | grep -q "pending"; then echo "=== CI SETTLED (poll $i) ==="; echo "$s"; exit 0; fi; sleep 15; done; echo "=== TIMEOUT 15min ==="; gh pr checks 12

Watch PR#12 (caaf797) CI until settled


Command running in background with ID: bxxon03u4. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\13c96a50-2b78-4c16-a995-1da2b1836fd8\tasks\bxxon03u4.output. You will be notified when it completes. To check interim output, use Read on that file path.


1	# Runtime manifest schema
2
3	> Draft design doc (2026-05-31). Resolves PRD §17.2. Implementation detail — see [`../CONTEXT.md`](../CONTEXT.md) for the model. Schema grounded in the sister project's actual functional surface.
4
5	## Principle
6
7	**SPT is not a harness.** The manifest declares only what *varies per harness*. Command templates are **opaque strings** — spt-core never parses out a model, tool list, or flag; the adapter writes the full command line and spt-core just runs it with substitution keys filled. Anything spt-core owns (sentinels, spool/registry/perch schemas, the event-block vocabulary, the version-trampoline mechanism, config knobs) is **not** in the manifest.
8
9	## What is NOT in the manifest (spt-core-owned)
10
11	- **Sentinels** (`.idle`-equiv, echo-gate) — managed by `api state`/`api echo-gate`; the adapter only *calls* them.
12	- **Spool / registry / perch `info.json` / daemon-state schemas** — core.
13	- **Event-block vocabulary** — the XML tags spt-core surfaces to agents (e.g. `<SPT_EVENT>`, `<owl_messages>`-equivalent) are a **fixed, documented constant**, not configurable. Adapters pass spt-core's output through unchanged.
14	- **File-drop filenames** — statically `<endpoint_id>-commune.md` / `<endpoint_id>-signoff.md`. Only the *watched directory* is declared (in `[session]`).
15	- **Config knobs** (pulse period, echo-commune window, route-guard window, daily-refresh) — spt-core **global settings**, with optional **per-endpoint override** for endpoints with unique needs. Not per-adapter.
16	- **Cross-adapter fallback** (psyche/echo invocation falls back to another adapter on rate-limit) — a **node-wide setting**, not a manifest field, because it references *multiple* adapters (under spt-core, `ccs` is its own adapter, not a binary-swap within one). Adapter-agnostic IDs make this safe — an endpoint's Psyche can run under a fallback adapter temporarily.
17	- **Plugin-cache / version-trampoline layout** — none of SPT's concern. A CC-plugin adapter updates via `claude plugin update` (delegated command).
18
19	<!-- [doc->REQ-MANIFEST-1] The sections below are the authoritative schema; the
20	typed form in `crates/spt-runtime/src/manifest.rs` is kept in lockstep. -->
21
22	## Manifest sections
23
24	### `[adapter]` — header
25	```toml
26	[adapter]
27	name = "claude-spt"
28	version = "2.0.0"
29	min_spt_core_version = "1.0.0"     # readable before any update; compat gate
30	hostable_types = ["LiveAgent", "ReadyAgent", "Psyche", "Worker"]
31	shortcut_basename = "cc"           # optional; brands the picker's launcher → cc-<id> (default "spt")
32	```
33
34	<!-- [doc->REQ-MANIFEST-7] -->
35	`shortcut_basename` *(optional, default `spt`)* — the basename the `spt endpoint
36	run` picker's `s` keybind bakes into the generated `<basename>-<id>` launcher
37	shortcut at the project root (REQ-MANIFEST-7). Absent ⇒ the harness-agnostic
38	`spt` (→ `spt-<id>`); an adapter sets it to brand its shortcuts — `claude-spt`
39	uses `cc`, giving `cc-doyle`. spt-core never hardcodes a harness name; the
40	picker reads this from the **resolved** manifest of the selected adapter. The
41	launcher is the current OS's native form (`.cmd` on Windows — `.ps1` is excluded
42	by the default `PATHEXT`; a POSIX `sh` `+chmod +x` on Unix) and overwriting is
43	guarded by a generated-by sentinel (a foreign same-named file is never clobbered).
44
45	### `[hooks.<event>]` — inbound hook table
46	Per harness event: the `api` command it fires, the stdin fields it maps in, and its **output capability** — `can_inject` (whether this hook can surface context to the agent). When `can_inject = false`, spt-core falls back to a sentinel + relay/poll path instead of expecting injection. This is the load-bearing harness-varying fact (CC's Stop hook can't inject).
47
48	```toml
49	[hooks.SessionStart]
50	fires = "api seed --pid {parent_pid} --session-id {session_id} --adapter {adapter_name}"
51	reads = ["session_id", "source", "agent_type"]
52	can_inject = true                  # via additionalContext
53
54	[hooks.Stop]
55	fires = "api state idle"
56	reads = []
57	can_inject = false                 # no hookSpecificOutput schema → sentinel/relay fallback
58	```
59
60	### `[session.<role>]` — outbound session role templates
61	One opaque command template per role: `self`, `psyche_init`, `psyche_resume`, `echo_commune`, `signoff`, `notif`. Plus `cwd`, env inject/remove, detachment, and the recursion-guard env var. **Model, tools, flags, dir-grants all live inside `command`** — not as fields.
62
63	```toml
64	[session.psyche_init]
65	command = "claude -p --agents {agents_json} --agent owl-psyche --name {session_name} --model sonnet --fallback-model opus --effort medium --dangerously-skip-permissions --disable-slash-commands --tools Read,Edit,Write --output-format json"
66	cwd = "{psyche_dir}"
67	env_remove = ["OWL_SESSION_ID"]
68	recursion_guard_env = "SPT_ECHO_COMMUNE"   # set on summarizer children so their own hooks bail
69	detach = true
70	keys = ["agents_json", "session_name", "psyche_dir"]   # substitution keys spt-core fills
71	```
72	(`psyche_resume` adds `{session_id}`; both carry the spt-core-owned `{psyche_prompt}`. `signoff` is a `psyche_resume` variant firing a final context-save. `echo_commune` is a cheap-model template consuming history.)
73
74	<!-- [doc->REQ-NOTIF-2] -->
75	`notif` is the **endpoint-native notification render** (ADR-0007's `notif_command` seam): an OS toast, a Shell `alert-symbol`, anything the adapter can run. Declared on harness-adapter **and** shell-adapter manifests; spawned **detached** when a notif surfaces at this endpoint, combinable with the agent-surface delivery (the typed `notify` envelope). Keys spt-core fills: `{notif_id}`, `{notif_from}`, `{notif_subnet}`, `{notif_body}`.
76
77	```toml
78	[session.notif]
79	command = "powershell -Command New-BurntToastNotification -Text '{notif_from}','{notif_body}'"
80	keys = ["notif_id", "notif_from", "notif_subnet", "notif_body"]
81	```
82
83	### `[env]` — env-var table
84	Vars to inject/read, and **how**. With `spt` on PATH the table is small. The injection *channel* is asymmetric: **spt-hosted** sessions inherit env from the broker that spawns them (no channel needed); **harness-hosted** sessions need the harness's declared channel (the broker didn't spawn them). File-bridge-only-when-not-launcher, applied to env.
85
86	```toml
87	[env.OWL_SESSION_ID]
88	direction = "inject"
89	value = "{session_id}"
90	channel = "CLAUDE_ENV_FILE"        # harness-hosted only; spt-hosted inherits from broker
91	```
92
93	### `[history]` — transcript access
94	Three strategies; pick one. Pull-based (spt-core asks) or push-based (adapter sends).
95	```toml
96	[history]
97	strategy = "fetcher"               # fetcher | locate_normalize | native
98	fetcher = "claude-spt-history --session {session_id}"   # adapter binary emits normalized history
99	# locate_normalize: locate_template + normalize_command
100	# native: adapter pushes via `api history-log`; spt-core stores (also used for Shells)
101	```
102
103	### `[digest]` — session-digest extractor (ADR-0019)
104	The session digest's own seam — **distinct from `[history]`** (which stays opaque
105	and single-session, feeding the echo-commune verbatim). Declares an **imperative
106	extractor** that maps the harness's native log → the digest-record contract.
107	```toml
108	[digest]
109	extractor = "claude-spt-digest --session {session_id} --in {source}"  # native log → contract JSONL
110	source = "~/.claude/projects/{project}/{session_id}.jsonl"   # optional; defaults to [history].locate_template
111	window_turns = 5                   # optional presentation defaults the adapter declares…
112	arg_truncation = 40                # …any consumer may override at pull/subscribe
113	sprint_collapse = true             # collapse consecutive tool records into one sprint
114	```
115	- **`extractor`** (required) — an opaque command spt-core never parses. spt-core
116	  resolves the `source` file, fills `{source}` with its path, pipes the bytes on
117	  stdin, and runs the command bounded; its stdout is contract JSONL (one record

... [159 lines truncated] ...


Main repo restored clean (the note shows caaf797's version, no stray edit). Now applying the catalog to the **update-msg worktree's** `docs/MANIFEST.md`. Reading its regions first:


<owl_messages>
<owl_message from="perri" priority="high">FINDING F-003 (filed docs/SPT-CORE-FINDINGS.md). File-backed [strings] is ABSENT from the published M12/v0.7.0 surface — but ADR-0001 names it a hard M12 dependency (UPS-injection sources /sptc:X skill bodies from [strings], "file-backed so the manifest does not bloat"). Confirmed against BOTH the live 0.7.0 binary and the docs-site (llms-full.txt): [strings] is inline-only. No externalization avenue exists: schema is plain object additionalProperties; get-string prints raw inline; set-string takes a literal <VALUE> (no --from-file); create-profile --from ingests a whole inline overlay; docs document no @file/include syntax. ([update] file_pull ships files but needs a signing key+registry we lack, and does not make a [strings] VALUE resolve from a file — not a substitute.) Non-blocking: per ADR-0001 fallback the whoami/setup skeletons keep interim inline bodies; REQ-UPS-INJECTION stays [doc,impl,unit] (externalization half held, not failed). ASK: does M12 intend file-backed [strings] and it is unshipped/unpublished, or has the design moved (bodies belong in a file-pull layer, not [strings])? A capability OR an ADR-0001 amendment closes this. doyle=docs/contract, todlando=spt-core build.</owl_message>
</owl_messages>

[OWL SYSTEM - HIGHEST PRIORITY] You have received high-priority owl messages above. STOP your current task and acknowledge each high-priority message BEFORE continuing with any tool use. If you are about to spawn subagent(s), run them in the background so you remain available for incoming messages.


70	keys = ["agents_json", "session_name", "psyche_dir"]   # substitution keys spt-core fills
71	```
72	(`psyche_resume` adds `{session_id}`; both carry the spt-core-owned `{psyche_prompt}`. `signoff` is a `psyche_resume` variant firing a final context-save. `echo_commune` is a cheap-model template consuming history.)

keys = ["agents_json", "session_name", "psyche_dir"]   # substitution keys spt-core fills
```
(`psyche_resume` adds `{session_id}`; both carry the spt-core-owned `{psyche_prompt}`. `signoff` is a `psyche_resume` variant firing a final context-save. `echo_commune` is a cheap-model template consuming history.)


271
272	## Open sub-items
273	- Exact substitution-key catalog (which keys spt-core guarantees to fill per role).
274	- TOML vs YAML final choice; schema-validation + helpful errors for adapter authors.
275	- The fixed event-block vocabulary's final tag names (`<SPT_EVENT>` etc.).
276	- Capability-declaration shape beyond `hostable_types` (per-type operation advertisement).

## Open sub-items
- Exact substitution-key catalog (which keys spt-core guarantees to fill per role).
- TOML vs YAML final choice; schema-validation + helpful errors for adapter authors.
- The fixed event-block vocabulary's final tag names (`<SPT_EVENT>` etc.).
- Capability-declaration shape beyond `hostable_types` (per-type operation advertisement).


perri has another potential docs update needed, coming thru the pipeline.


perri's F-003 is high-priority — but I recall the traceable registry already shows a **file-backed [strings]** requirement (M12-W3-T3.1): the pointer form is an inline table `key = { file = "rel/path" }`, not `@file`/`--from-file` — which is likely why her avenue-search missed it. Need to verify ship status before ruling. ACK perri with the lead, then finish the MANIFEST edits, then investigate F-003 properly:


cd C:/Users/decid/Documents/projects/spt-core; $OWL send perri doyle <<'EOF'
ACK F-003 — logged, good catch, and you're unblocked (interim inline per ADR-0001 fallback, REQ-UPS-INJECTION stays [doc,impl,unit], correct). Holding a full ruling until I verify, but a strong lead: file-backed [strings] IS specified — M12-W3-T3.1 in the registry defines a VALUE-position FILE POINTER as an inline TABLE: `key = { file = "rel/path" }`, resolved at get-string time, per-adapter aux storage adapters/<adapter>/strings/, containment-checked, lazy-read. That's NOT @file/--from-file/include syntax — which is exactly why your avenue search (set-string --from-file, create-profile --from, @file) came up empty. The question I'm resolving now: is that pointer form SHIPPED in v0.7.0, or specified-but-pending a later M12 wave? Checking the REQ stages + the live binary's strings resolver. Ruling + (capability-exists vs unshipped vs ADR amendment) within the next bit. Keep rolling on inline.
EOF

ACK perri F-003 with the file-pointer lead


➜ SENT:perri


Prompt: perri has another potential docs update needed, coming thru the pipeline.


[Request interrupted by user]


ignore, proceed. she messaged you already


<owl_reachability_notice>
You have an active owl perch. When spawning subagents or doing work, use run_in_background: true so you stay reachable for incoming messages.
</owl_reachability_notice>


CAVEMAN MODE ACTIVE (full). Drop articles/filler/pleasantries/hedging. Fragments OK. Code/commits/security: write normal.


Understood — F-003 ruling rides perri's pipeline; she has another docs item incoming. Proceeding with the current task (packing the catalog into the update-msg branch). Finishing the worktree `MANIFEST.md` edits:


Edit

(`psyche_resume` adds `{session_id}`; both carry the spt-core-owned `{psyche_prompt}`. `signoff` is a `psyche_resume` variant firing a final context-save. `echo_commune` is a cheap-model template consuming history.)

(`psyche_resume` adds `{session_id}`; both carry the spt-core-owned `{psyche_prompt}`. `signoff` is a `psyche_resume` variant firing a final context-save. `echo_commune` is a cheap-model template consuming history.)

**Substitution-key catalog.** The full `{key}` vocabulary spt-core guarantees it can fill — a role's `keys` must be a subset (source of truth: `spt_runtime::SUBSTITUTION_KEYS`): `{id}`, `{adapter_name}`, `{session_id}`, `{session_name}`, `{parent_pid}`, `{agent_type}`, `{agents_json}`, `{psyche_dir}`, `{psyche_prompt}`, `{psyche_context}`, `{link_token}`, `{source}`. spt-core fills only the keys relevant to a given spawn (`{psyche_*}` for a live agent's Psyche role, `{source}` for a `[digest]`/`[history]` extractor, …); an unknown or unprovided `{placeholder}` fails with a one-line error. Per-key meanings: the published [manifest reference](../docs-site/src/harness-contract/manifest.md#substitution-keys).


The file C:\Users\decid\Documents\projects\spt-core-wt-updapply\docs\MANIFEST.md has been updated successfully. (file state is current in your context — no need to Read it back)


## Open sub-items
- Exact substitution-key catalog (which keys spt-core guarantees to fill per role).
- TOML vs YAML final choice; schema-validation + helpful errors for adapter authors.

## Open sub-items
- TOML vs YAML final choice; schema-validation + helpful errors for adapter authors.


## Open sub-items
- Exact substitution-key catalog (which keys spt-core guarantees to fill per role).
- TOML vs YAML final choice; schema-validation + helpful errors for adapter authors.

## Open sub-items
- TOML vs YAML final choice; schema-validation + helpful errors for adapter authors.

| period / echo-window / route-guard / 24h refresh | hardcoded/flags | spt-core **global knobs** (per-endpoint override), not manifest |

-- Exact substitution-key catalog (which keys spt-core guarantees to fill per role).

- The fixed event-block vocabulary's final tag names (`<SPT_EVENT>` etc.).

- Capability-declaration shape beyond `hostable_types` (per-type operation advertisement).


Both MANIFEST.md edits done. Committing the docs-completeness fixes on the update-msg branch (separate commit from the message fix):


Bash

cd C:/Users/decid/Documents/projects/spt-core-wt-updapply; echo "=== status ==="; git status --short; echo "=== commit ==="; git add -A && git commit -q -F - <<'EOF'
docs(manifest): publish the full substitution-key catalog + the [digest] register rule

Closes two docs-completeness gaps perri surfaced building the claude-spt adapter
against the published v0.7.0 surface (docs/SPT-CORE-FINDINGS.md):

1. The full {key} substitution catalog was code-only (spt_runtime::SUBSTITUTION_
   KEYS) — the published manifest reference listed only a partial set, so
   {parent_pid}/{adapter_name} and the rest were undiscoverable without the mock
   source. Now a "Substitution keys" table on the docs-site manifest reference +
   the same catalog in docs/MANIFEST.md; closes that doc's "Open sub-items" entry.
2. The [digest] cross-field rule (schema accepts a bare `extractor`, but
   `spt adapter add` REJECTS without an own-source `source` or a [history]
   locate_template to fall back to) was register-time-only, absent from the prose.
   Now a bullet in the "Cross-field rules (spt adapter add enforces these)"
   section + a cross-ref on the `source` field row.

Docs-only; no code change. Bundled onto the update-apply message fix branch (both
ship the next release).

Co-authored by: doyle
EOF
echo "---"; git log --oneline -3

Commit docs-completeness fixes on update-msg branch


=== status ===
 M docs-site/src/harness-contract/manifest.md
 M docs/MANIFEST.md
=== commit ===
---
0ff1e5a docs(manifest): publish the full substitution-key catalog + the [digest] register rule
03630ee fix(update): confident "Updated" post-apply message, not internal "trial" terminology
e08cea0 fix(clippy): clear 4 Linux clippy lints (first Linux clippy run on M12 code)
Shell cwd was reset to C:\Users\decid\Documents\projects\spt-core


Committed `0ff1e5a` on the update-msg branch (stack: msg-fix → docs). Pushing + opening its PR so CI validates both (note: `cli.rs` is touched by both this branch and PR #12 — independent regions, but I'll merge PR #12 first):


cd C:/Users/decid/Documents/projects/spt-core-wt-updapply; git push -u origin fix/update-apply-confident-message 2>&1 | tail -4; echo "=== PR ==="; gh pr create --base main --head fix/update-apply-confident-message --title "fix(update): confident \"Updated\" apply message + publish substitution-key catalog & [digest] rule" --body "$(cat <<'EOF'
Two small changes off main, shipping next release together (operator-bundled).

## 1. Confident post-apply message (03630ee) — operator ruling 2026-06-15
Public `spt update apply` printed internal trial/promotion mechanics ("trial started", "on trial; watch for a rollback notif") and never resolved a successful install to a success line (the notif only fires on failure). A CI-passed signed release is a trusted rollout, not a provisional trial.
- `render_pending_message` → `render_applied_message`: "Updated spt-core to v{pv}." / "(release {n})" + changelog; drops trial/rollback/daemon-status wording. Realigns impl to the REQ-UPD-8 registry contract.
- `RELEASES_URL`: `www.github.com` → canonical `github.com`.
- Unit test inverted: asserts "Updated", never trial/rollback, + non-www guard.
- Untouched: readiness gate + auto-rollback machinery (failures still surface via the loud notif) + Quarantined/Refused/Rejected error surfaces.

## 2. Docs-completeness (0ff1e5a) — perri findings (SPT-CORE-FINDINGS.md)
- Full `{key}` substitution catalog was code-only; published the table on the manifest reference + `docs/MANIFEST.md` (closes that doc's Open sub-item).
- `[digest]` register rule (needs own-source `source` or `[history] locate_template`) was register-time-only; documented in the Cross-field-rules section + `source` row.

## Gate (doyle)
Message fix: `applied_message` unit test green + traceable EXIT 0 (isolated worktree off e08cea0). Docs-only commit on top. CI validates the full sweep + doc build.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)" 2>&1 | tail -3

Push update-msg branch + open PR


<owl_messages>
<owl_message from="perri" priority="high">Got it — that table-pointer form (key = { file = "rel/path" }) is exactly the shape my avenue search could not have surfaced (I probed CLI verbs + @file/include, not a VALUE-position table). Thanks. I have live 0.7.0 here — running the byte-test NOW: register a throwaway adapter with a [strings] key = { file = "probe.txt" } + aux strings/probe.txt, then get-string and observe resolve-vs-raw-vs-reject. That answers shipped-vs-unshipped from the public surface directly. Reporting result shortly; will fold into F-003 either way (capability-confirmed -> I author file-backed bodies, or specified-but-unshipped -> stays held on the wave).</owl_message>
</owl_messages>

[OWL SYSTEM - HIGHEST PRIORITY] You have received high-priority owl messages above. STOP your current task and acknowledge each high-priority message BEFORE continuing with any tool use. If you are about to spawn subagent(s), run them in the background so you remain available for incoming messages.


branch 'fix/update-apply-confident-message' set up to track 'origin/fix/update-apply-confident-message'.
To https://github.com/SaberMage/spt-core.git
   03630ee..0ff1e5a  fix/update-apply-confident-message -> fix/update-apply-confident-message
=== PR ===
https://github.com/SaberMage/spt-core/pull/13
Shell cwd was reset to C:\Users\decid\Documents\projects\spt-core


perri's byte-testing F-003 empirically on live 0.7.0 — that settles shipped-vs-unshipped directly. ACK + verify the REQ stage from my side so I can rule the instant her result lands:


cd C:/Users/decid/Documents/projects/spt-core; $OWL send perri doyle <<'EOF'
Good — that byte-test is the cleanest possible answer (live public surface, resolve-vs-raw-vs-reject). Awaiting your result; I'm checking the REQ stage + the strings resolver code from my side in parallel so I can rule the contract immediately either way. One tip: the pointer resolves relative to per-adapter aux storage adapters/<adapter>/strings/ with CONTAINMENT (no ../ or absolute escaping the dir) — so put probe.txt at adapters/<your-adapter>/strings/probe.txt and use file = "probe.txt" (bare relative). A containment reject ≠ unshipped. Report what you see.
EOF
echo "=== file-backed strings REQ stage ==="; grep -nE "id = \"REQ-(STRINGS|MANIFEST)|file.backed|FILE POINTER|required_stages" traceable-reqs.toml | grep -iE "string|file|pointer" ; echo "--- locate the strings-file req block ---"; grep -niE "file pointer|file-backed|key = \{ file|strings/" traceable-reqs.toml | head

ACK perri + find file-backed strings REQ stage


➜ SENT:perri
=== file-backed strings REQ stage ===
78:required_stages = ["doc", "impl", "unit"]  # activated 2026-06-12: M9-T2 (CONTEXT.md §adapter strings, adapter-string get/set CLI + overlay resolution)
85:title = "File-backed adapter [strings] (M12-W3-T3.1): a [strings] dot-path value MAY be an inline-table FILE POINTER `key = { file = \"rel/path\" }` resolved to the file's contents at get-string time, keeping large bodies (skill-instructions, hint text) out of the manifest. A value-position table with a `file` key IS the pointer form (reserved — cannot double as data). Per-adapter aux storage `adapters/<adapter>/strings/`; pointers resolve relative to it with CONTAINMENT (reject `..`/absolute escaping the dir). UPDATE-SAFETY: a LOCAL profile's file-pointers resolve relative to the user-owned local-profile dir (NOT adapter-shipped strings/, which adapter updates overwrite), or the local profile inlines. Validate-at-register (fail-fast on a bad/escaping/missing pointer) + LAZY read at get-string (live file edits reflect, no re-register) + skip-diagnostics on missing-at-read (no hard-crash, mirrors [digest]). Rides the same leaf-replace profile overlay as the rest of [strings]."
130:required_stages = ["impl", "unit"]  # activated M3c-C3: manifest [update] gains signing_key (file_pull content key) + self_verifies (delegated attest); adapter_update::plan_adapter_update dispatches each avenue (file_pull verified against the adapter key, delegated delegated-or-skipped)
192:required_stages = ["doc", "impl", "unit"]  # activated 2026-06-13: M9-T6 (CONTEXT.md §live role; contextstore live_role_file/live_role_path seam; resume::download_psyche_context role-first injection order; spt endpoint role print/--overwrite sole writer; injection-order + no-automated-writer tests; M9-WAVE2-ROLE-WRITERS.md survey)
202:required_stages = ["impl", "unit"]  # activated M4-D6a: the mind now lives in the tracked/ two-tier store (spt-store::contextstore over BranchStore), physically separate from never-synced node-local perches/ — spt_live::ingest::route_slices routes commune drops' <live-context>/<project-context> slices into a-<id>/p-<project_id> branch files (untagged body → live, the parser fallback; precedence-guarded 6.5; checkpoint commit per written tier); signoff::write_resume_commune stamps Self provenance per slice through the same router; resume::download_psyche_context composes the two tiers back into the two-slice envelope (pure read, never mints store state; other-project download sees live only). Daemon lifecycle derives project_id from the commune dir's parent (cwd fallback). The cross-node SYNC of these branches = D6c (REQ-NET-3/REQ-INST-5); int = D9 two-host. unit = per-tier routing/no-leak + untagged→live + suppressed-consumed + resume-commune both-tiers + download compose/cross-project-isolation + e2e listen→ingest→live-tier
214:required_stages = ["impl", "unit", "int"]  # activated M4-D6c-2: scoping is the pull model + the server-side gate (ADR-0013 — requester names a-<id>/p-<project> refs, no new registry fields). spt-daemon::sync::SyncPolicy.allows: a-<id> served only when ∃ subnet S with the handshake-proven origin pinned in the trust store AND synced(id,S) (the D3e gate finally consumed — membership list + visibility, hidden ⟹ not synced, fail-closed on unconfigured lists); p-<project> only to a trusted origin for a hosted project (honest residual: p- tier is branch-granular, a hidden endpoint's per-project slice rides a hosted project's branch — per-file p- filtering post-v1); machinery refs never served. select_refs mirrors the gate requester-side (symmetric scoping). Server re-checks EVERY requested ref — naming is not entitlement. int activated M4-D9-6: two-host rig E2E green (tests/twohost.rs — both directions bootstrap-pull a mind the puller never held, registry-derived want-refs, gate served remotely, HFENDULEAM↔gravity). unit = gate decision table + select_refs scoping + loopback two-daemon two-tier E2E (unsynced/hidden refused server-side, tests/sync.rs)
226:required_stages = ["impl", "unit", "int"]  # activated M4-D5b: AttachRecord wire protocol (spt-net::net::attach) + spt-daemon::attach serve/request/input — a byte-stream viewport onto a remote session over broker QUIC streams; compute+files stay on the target node. tests/attach.rs drives a real PTY child on daemon A from daemon B and proves restart survival (worst-case seq-0 re-serve, all journals dedup). int activated M4-D9-6: two-host rig E2E green (tests/twohost.rs — A types into B's live PTY across the rig and reads the echo back)
251:required_stages = ["doc", "impl", "unit"]  # activated M4-D9-4: resources: Option<String> on the registry Instance (additive serde-default, rides RegistryUpdate replication + the epoch lease — a blurb edit is an ordinary epoch-bumped update, NO new record kind/store/merge surface) + resource_projection (the (id,node,status,blurb) yellow-pages view over VISIBLE+routable rows — same exclusion closure as resolution, ADR-0006 §6 leak-free by construction) + both-authored sourcing (info.json resources via spt resources set wins; daemon.json resources_blurb node seed fills the gap) + spt resources set/show/list CLI off the stale-tolerant registry snapshots. The whitelist leg of the gate (viewer-node filtering) rides the deferred consent/whitelist generalization — visibility is the v1 gate exactly as CONTEXT scopes it. doc = CONTEXT §resource advertisement; unit = lease-ordered blurb + pre-D9-4 row parses + hidden-never-listed/offline-skipped/absent-renders-clean + both-authored advertisement + runtime refine epoch-bumps
259:required_stages = ["impl", "unit", "int"]  # int activated M4-D9-6: two-host rig E2E green (tests/twohost.rs — file fetch off B + push back to B across the rig; chunked xfer through the dispatcher gate)
310:required_stages = ["impl"]  # activated M4-D1: RelayPolicy {N0Default | SelfHosted(RelayMap) | Disabled} maps onto iroh RelayMode (n0 public relays are the zero-config default; self-host is the escape hatch; disabled = LAN/air-gapped + hermetic tests), plus the RELAY_DISCLOSURE plain-language string. Config plumbing — no unit/int stage required
314:required_stages = ["impl", "unit"]  # activated M4-D6c: git-native bundle sync (ADR-0013). D6c-1 store core: BranchStore bundle plumbing (create_bundle ^have incremental w/ unknown-have full fallback + up-to-date skip; fetch_bundle quarantines tips under refs/spt-sync/ — incoming can never clobber refs/heads before the driver rules; merge_commit_in_worktree two-parent join, FF for all-accepted propagation) + spt_store::syncmerge::apply_fetched — per-file vector verdicts (dominate→accept verbatim/dominated→drop/concurrent→record_conflict, hazard 6.6), replicated .conflicts artifacts with resurrection guard (local-dominates-or-equal ⟹ stale echo dropped), dominating accept clears pending artifacts (the only legal clear), drop-only passes still join the DAG (re-pull short-circuits on ancestry). D6c-2 = wire serve/request over broker QUIC + synced/visibility server gate. int = D9 two-host E2E. unit = adopt/incremental-FF/idempotent-repull + dominated-drop-joins-DAG + concurrent-surfaces-both-nodes + reconciled-write-propagates-clears + p-tier-union + legacy-⊥⊥-surfaces
401:required_stages = ["impl", "unit"]  # activated M3c-C3: adapter_update::conduct_ripple conducts self-then-adapters in registration order; file_pull payloads get adapter content signing (verify_signature against the per-adapter manifest key + verify_artifact digest), delegated requires a self_verifies attest
530:required_stages = ["impl", "int"]  # activated M6-D2: idempotent re-run is construction — binary replaced atomically, PATH registration added at most once (marker-guarded .profile block / HKCU entry-set check); interactive-optional = the scripts never prompt at all (REQ-INSTALL-5's stronger non-interactive contract subsumes it; first-run identity gen + daemon start were already unattended). int = oneliner_e2e re-run rung (second run green, single PATH block)
534:required_stages = ["impl", "unit"]  # activated M5-D2: spt-runtime::registry (validate-first register; copy-for-file_pull/avenue-less vs pointer-for-delegated under {SPT_HOME}/adapters/; soft-deregister retains record+copy, surfaces the manifest uninstall template; re-add re-activates) + spt adapter add/--github/remove/list CLI (install-is-first-update conducted through plan_adapter_update; bounded clone/conduct) + spt-daemon::adapter_update::ripple_registered (the registered set IS the R-UPD-5 ripple source). int = D3 shell-spawn consumes a registered shell adapter E2E
562:required_stages = ["impl", "unit"]  # activated M6-D6: BUILTIN_RELEASE_KEYS compiled into every binary (release.rs) with release-keys.json as a pure OVERLAY (file adds/shadows keys; file revocations void builtin ids — per-node compromise recovery without a rebuild; empty builtin set stays fail-closed); ceremony tooling = xtask release-keygen (mints via the spt-proto identity primitive, prints to stdout only, seeds never touch disk/repo/CI) + release-sign (manual local signing off SPT_RELEASE_SEED env). unit = merge/overlay/revoke-builtin + recovery-key-signed-release-verifies + shipped-table-well-formed. The ceremony itself (mint 2 keypairs, embed pubkeys, back up seeds) = maintainer action before the v0.1.0 tag
728:required_stages = ["impl", "unit"]  # activated M4-D6b: ContextStore::record_conflict (tracked .conflicts/ artifacts, SHA-256-prefix named — idempotent across nodes, replicate like context; local working file untouched) + list_conflicts + clear_conflicts (dominating-write-only). unit = both-versions-preserved + idempotent-recording + multi-version-artifacts + resolve-clears-exactly-this-file + clean-no-op
757:required_stages = ["unit"]  # activated M12-W4 (privilege-escalation feature → mandatory hazard REQ, doyle M12-W4-RULING.md Q6). unit conformance: (1) every launcher's argv uses the absolute exe path + verbatim args (Win runas / pkexec / terminal-emulator / sudo); (2) decide_elevation_path → AlreadyElevated when elevated, every os (loop-safety); (3) the constructed launch argv is an array, never a shell string (no `sh -c`, no interpolation — assert the argv vector). REQ-HAZARD-SUDO-SECURE-PATH covers the Unix abs-path-under-sudo facet; this covers the cross-platform verbatim / no-widen / no-shell-injection / loop-safe facets. The actual elevated launch is manual-verify (real UAC/polkit/sudo).
782:required_stages = ["doc", "impl", "unit"]  # doc activated restoration D1 (KNOWN-HAZARDS 6.8 + ADR-0018 + design doc V1). impl/unit activated restoration D6-3 (the one D6 toml activation): impl = rollback_compat.rs (the pre-ready durable-file registry PRE_READY_DURABLE_FILES — the enumerated guard surface) + the two-phase AppliedRecord (relcache.rs, additive durable state); unit = the rollback_compat tripwire (every current pre-ready file shape is additive / N-1-readable; a non-additive pre-ready change trips the assert). The two-phase record + the generation-stamped brain.ready are additive → N-1-safe by construction (KH 6.8 D6 guard note)
880:required_stages = ["impl", "unit"]  # activated M8-D2: standalone_text(daemon_running) keyed on the live socket ping; hints flag threaded from the dispatch (bare subnet = true, explicit status = false); pump_stall_warning over read_pump_heartbeat (3-cadence staleness; absent file = pre-D4, no false alarm) appended to both status views. unit = daemon-up/down standalone variants + hints-only-on-bare + bare-vs-status dispatch
900:required_stages = ["impl", "unit"]  # activated M8-D4: registryhost advertise-now marker (request/take, the resting.rs PULL_MARKER idiom — file-based because transitions happen in other processes) consumed by the pump tick as a forced registry round; dropped at ready-listener start/stop (cli run_listen + endpoint stop) and on every real rest edge (both transition hosts). unit = marker taken-exactly-once + forced-round wiring. Peer-visible flip timing = acceptance 6
915:required_stages = ["impl", "unit"]  # activate at daemon-lifecycle execution. impl = service.rs DaemonService trait (SystemdUserService detect-by-unit-file + systemctl control; Windows none-controllable) + pure plan_start/plan_stop routers + start_daemon/stop_daemon outcome APIs wired into cmd_daemon_start/cmd_daemon_stop; DaemonCmd::Start split from Run (alias removed). unit = plan_start/plan_stop truth tables (running×detected, running×detected×active) + `daemon start` parses as its own verb + systemd unit-path derivation. int (systemd rig) deferred to fleet rollout follow-up (rule 5).
954:required_stages = ["impl", "unit"]  # Mesh-D6 activated: impl = TrustStore + peers.json + every call site DELETED (spt-store mod, perch::trust_file, pairing wire trust write, peerloop push, CLI status/list/prune/leave, xtask); the serve/inbound gates authorize on roster membership (propagate serve_update → RosterStore::is_member_any; dispatch caller); warn-on-change re-homed onto machine_id as a non-blocking RekeyNotice at the repair_evict_superseded event. unit = roster::is_member_any spans subnets + honors tombstones; repair_evict emits the machine_id-anchored rekey notice without blocking, and absent machine_id raises no false notice; the deletion is compile-enforced (no TrustStore symbol remains).
--- locate the strings-file req block ---
85:title = "File-backed adapter [strings] (M12-W3-T3.1): a [strings] dot-path value MAY be an inline-table FILE POINTER `key = { file = \"rel/path\" }` resolved to the file's contents at get-string time, keeping large bodies (skill-instructions, hint text) out of the manifest. A value-position table with a `file` key IS the pointer form (reserved — cannot double as data). Per-adapter aux storage `adapters/<adapter>/strings/`; pointers resolve relative to it with CONTAINMENT (reject `..`/absolute escaping the dir). UPDATE-SAFETY: a LOCAL profile's file-pointers resolve relative to the user-owned local-profile dir (NOT adapter-shipped strings/, which adapter updates overwrite), or the local profile inlines. Validate-at-register (fail-fast on a bad/escaping/missing pointer) + LAZY read at get-string (live file edits reflect, no re-register) + skip-diagnostics on missing-at-read (no hard-crash, mirrors [digest]). Rides the same leaf-replace profile overlay as the rest of [strings]."


Need the REQ id + its stages line (title is line 85):


83	[[requirements]]
84	id = "REQ-MANIFEST-5"
85	title = "File-backed adapter [strings] (M12-W3-T3.1): a [strings] dot-path value MAY be an inline-table FILE POINTER `key = { file = \"rel/path\" }` resolved to the file's contents at get-string time, keeping large bodies (skill-instructions, hint text) out of the manifest. A value-position table with a `file` key IS the pointer form (reserved — cannot double as data). Per-adapter aux storage `adapters/<adapter>/strings/`; pointers resolve relative to it with CONTAINMENT (reject `..`/absolute escaping the dir). UPDATE-SAFETY: a LOCAL profile's file-pointers resolve relative to the user-owned local-profile dir (NOT adapter-shipped strings/, which adapter updates overwrite), or the local profile inlines. Validate-at-register (fail-fast on a bad/escaping/missing pointer) + LAZY read at get-string (live file edits reflect, no re-register) + skip-diagnostics on missing-at-read (no hard-crash, mirrors [digest]). Rides the same leaf-replace profile overlay as the rest of [strings]."
86	required_stages = ["doc", "impl", "unit"]  # activated M12-W3
87	[[requirements]]

[[requirements]]
id = "REQ-MANIFEST-5"
title = "File-backed adapter [strings] (M12-W3-T3.1): a [strings] dot-path value MAY be an inline-table FILE POINTER `key = { file = \"rel/path\" }` resolved to the file's contents at get-string time, keeping large bodies (skill-instructions, hint text) out of the manifest. A value-position table with a `file` key IS the pointer form (reserved — cannot double as data). Per-adapter aux storage `adapters/<adapter>/strings/`; pointers resolve relative to it with CONTAINMENT (reject `..`/absolute escaping the dir). UPDATE-SAFETY: a LOCAL profile's file-pointers resolve relative to the user-owned local-profile dir (NOT adapter-shipped strings/, which adapter updates overwrite), or the local profile inlines. Validate-at-register (fail-fast on a bad/escaping/missing pointer) + LAZY read at get-string (live file edits reflect, no re-register) + skip-diagnostics on missing-at-read (no hard-crash, mirrors [digest]). Rides the same leaf-replace profile overlay as the rest of [strings]."
required_stages = ["doc", "impl", "unit"]  # activated M12-W3
[[requirements]]


REQ-side confirms it: **REQ-MANIFEST-5** (file-backed [strings], the `key = { file = "rel/path" }` pointer form) = `["doc","impl","unit"]` **all evidenced**, activated M12-W3 → shipped in v0.7.0. So F-003 disposition = **capability-confirmed**, not unshipped, no ADR amendment. Sending perri the corroboration so she reads her byte-test right:


cd C:/Users/decid/Documents/projects/spt-core; $OWL send perri doyle <<'EOF'
RULING (corroborates your byte-test before it lands): file-backed [strings] IS SHIPPED. REQ-MANIFEST-5 = ["doc","impl","unit"] ALL evidenced, activated M12-W3 → it's in M12 = v0.7.0. So your byte-test should show RESOLVE (file contents), not raw, not reject — if you see resolve, that's confirmation, not surprise. F-003 disposition = CAPABILITY-CONFIRMED: no ADR-0001 amendment, design held, you author file-backed bodies via the pointer form (`key = { file = "probe.txt" }` + adapters/<adapter>/strings/probe.txt).

The REAL residual in F-003 is a DOCS gap, not a contract gap: the `{ file = ... }` pointer form is shipped but NOT on the published surface (you couldn't find it because it's undocumented, same class as the {key} catalog + [digest] rule I just fixed). If your byte-test confirms resolve, I'll document the file-pointer [strings] form (docs-site manifest ref + MANIFEST.md) — tell me what you observe (does get-string print the file contents? does a ../ pointer reject at register?) and I'll fold the exact behavior into the doc. Send the result.
EOF

Send perri the REQ-MANIFEST-5 shipped ruling


<owl_messages>
<owl_message from="todlando" priority="high">F-003 (perri, FYI — non-blocking, parallel to PR #12): RESOLVED as a PUBLISHED-DOCS gap, NOT a capability gap. I verified the spt-core build — file-backed [strings] IS shipped in v0.7.0 (REQ-MANIFEST-5, M12-W3 @ e08cea0): profile.rs::as_file_pointer + registry.rs get_string lazy-resolve of `key = { file = "rel/path" }`, validate-at-register containment guard, skip-diagnostics, local-profile-dir update-safety; [impl]+[unit] tagged, get-string wired. ADR-0001's "file-backed so the manifest does not bloat" dependency is SATISFIED — no amendment, not unshipped.

THE GAP (your docs/contract leg): the published docs-site (GH-Pages/llms-full.txt) does NOT surface the `{ file = ... }` pointer syntax — grep-confirmed zero mentions. It's documented ONLY in CONTEXT.md §adapter strings (the [doc->REQ-MANIFEST-5] evidence); docs/MANIFEST.md is thin on it too. perri (building from the published surface) couldn't discover it → filed F-003 "absent."

I gave perri the mechanism + exact usage so she's unblocked NOW (build-side needs nothing). Your call: surface the file-pointer syntax on docs-site (+ docs/MANIFEST.md [strings]) — rides a docs-publish or next release, your contract call. Continuing standby on PR #12 re-gate.</owl_message>
<owl_message from="perri" priority="high">RESULT — file-backed [strings] is SHIPPED in v0.7.0. Byte-test (throwaway adapter, live binary, now cleaned up):
* `filebacked = { file = "probe.txt" }` + aux strings/probe.txt -> `spt adapter get-string <adapter> filebacked` printed the FILE CONTENT (not the table, not raw) = resolved. Confirms your M12-W3-T3.1 read: value-position table pointer, lazy-read at get-string, relative to adapters/<adapter>/strings/.
* CONTAINMENT enforced exactly as you said: `{ file = "../outside.txt" }` -> ADAPTER_ADD_FAIL: "invalid [strings] file pointer: pointer ../outside.txt must be a relative path inside the strings/ dir (no absolute paths, no `..` traversal)". Manifest-first: the whole add registered nothing.
* On add spt COPIES the adapter dir into the registry (adapters/<adapter>/{manifest.toml,record.toml,strings/probe.txt}) — ADAPTER_ADD:...:Copy.
So F-003 = RESOLVED, capability-confirmed-shipped (not a gap; my avenue search just probed the wrong shape). I am unblocking the externalization half of REQ-UPS-INJECTION: ship adapter/strings/<skill>.md, reference bodies as [strings.skills].<x> = { file = "<x>.md" }. Folding this into F-003 + the plan now. No ruling needed from you — the live surface answered it. Thanks for the M12-W3-T3.1 pointer; that was the unlock.</owl_message>
</owl_messages>

[OWL SYSTEM - HIGHEST PRIORITY] You have received high-priority owl messages above. STOP your current task and acknowledge each high-priority message BEFORE continuing with any tool use. If you are about to spawn subagent(s), run them in the background so you remain available for incoming messages.


➜ SENT:perri


Both confirm F-003 = **capability-confirmed-shipped**, residual is purely the **published-docs gap** (file-pointer `[strings]` syntax documented only in CONTEXT.md, absent from docs-site + thin in MANIFEST.md). This is the "another docs update" — same batch. I now have the exact behavior (perri's byte-test + todlando's code symbols). ACK both + locate the `[strings]` doc sections to extend:


cd C:/Users/decid/Documents/projects/spt-core; $OWL send perri doyle <<'EOF'
RESULT received — clean confirmation. File content resolved, containment reject verbatim, copy-on-add. F-003 = capability-confirmed-shipped, you're unblocked on the externalization half (ship adapter/strings/<skill>.md, [strings.skills].<x> = { file = "<x>.md" }) — correct pattern.

I'm taking the residual: the file-pointer syntax IS undocumented on the published surface — that's a real docs-completeness gap (your finding stands as a DOCS finding). Documenting it now on docs-site manifest ref + MANIFEST.md [strings], folding into the same docs batch I'm shipping (the {key} catalog + [digest] rule), so the next adapter author discovers it from the published surface alone. Your byte-test behavior (resolve at get-string, ../ reject text, copy-on-add) goes straight into the doc. Thanks for the empirical confirm — that closed it. Keep rolling.
EOF
$OWL send todlando doyle <<'EOF'
ACK — agreed, F-003 = docs leg, not capability/contract. Your code confirm (profile.rs::as_file_pointer + registry.rs get_string lazy-resolve + containment guard + local-profile update-safety, [impl]+[unit] tagged) + perri's live byte-test both land it: shipped in v0.7.0, no amendment. I'm documenting the file-pointer [strings] syntax on docs-site + MANIFEST.md, folding into PR #13 (my docs batch: confident apply-msg + {key} catalog + [digest] rule + now the strings pointer). Your build needs nothing. Stay on PR #12 re-gate — CI watcher running my side, I merge when green then you ping perri.
EOF
echo "=== [strings] section in docs-site manifest.md ==="; grep -nE "\[strings\]|## .*[Ss]trings|get-string|set-string" docs-site/src/harness-contract/manifest.md | head; echo "=== [strings] in docs/MANIFEST.md ==="; grep -nE "\[strings\]|## .*[Ss]trings|get-string" docs/MANIFEST.md | head

ACK perri+todlando + find [strings] doc sections


<owl_messages>
<owl_message from="perri" priority="high">EXACT byte observations for your doc (live spt 0.7.0, throwaway adapter, now removed):

RESOLVE (positive): manifest `filebacked = { file = "probe.txt" }`, aux file adapters/<adapter>/strings/probe.txt containing "RESOLVED-FROM-FILE-OK\n".
  $ spt adapter add <dir>   -> ADAPTER_ADD:sptc-probe-f003:Harness:Copy (registered)
  $ spt adapter get-string sptc-probe-f003 filebacked   -> RESOLVED-FROM-FILE-OK
  (prints FILE CONTENTS, not the table, not raw. Inline sibling `inline_key = "INLINE-VALUE"` -> INLINE-VALUE. So get-string resolves pointer-tables, prints inline strings as-is.)

REJECT (containment, at register): manifest `escape = { file = "../outside.txt" }`.
  $ spt adapter add <dir>   -> ADAPTER_ADD_FAIL: invalid [strings] file pointer: pointer ../outside.txt must be a relative path inside the strings/ dir (no absolute paths, no `..` traversal)
  Manifest-first: nothing registered (subsequent get-string -> ADAPTER_STRING_FAIL: adapter not registered).

COPY: on add the adapter dir is copied into the registry: adapters/<adapter>/{manifest.toml, record.toml, strings/probe.txt}.

For your doc, todlando added two non-surface facts worth including: (a) a LOCAL profile pointer resolves to the user-owned local-profile dir, NOT adapter-shipped strings/ (update-safe overrides); (b) a value-table carrying a `file` key is RESERVED as the pointer form (cannot double as inline data). F-003 reframed in my repo: capability-confirmed-shipped; residual = docs-site visibility (your leg). Authoring my UPS skill bodies as { file=... } pointers now.</owl_message>
</owl_messages>

[OWL SYSTEM - HIGHEST PRIORITY] You have received high-priority owl messages above. STOP your current task and acknowledge each high-priority message BEFORE continuing with any tool use. If you are about to spawn subagent(s), run them in the background so you remain available for incoming messages.


➜ SENT:perri
➜ SENT:todlando
=== [strings] section in docs-site manifest.md ===
=== [strings] in docs/MANIFEST.md ===


Perfect byte-level detail from perri — exactly what the doc needs. But both greps came back empty: `[strings]` isn't in `manifest.md` or `MANIFEST.md` at all. Need to find where `[strings]` is actually documented. ACK perri + locate it:


cd C:/Users/decid/Documents/projects/spt-core; $OWL send perri doyle <<'EOF'
Got the byte detail — exactly what I need (resolve prints file contents, inline prints as-is, ../ reject text verbatim, copy-on-add, local-profile-dir update-safety, file-key reserved-as-pointer). Documenting now. Carry on authoring your UPS bodies as { file=... } pointers. ACK.
EOF
echo "=== where is [strings] documented across docs-site + docs? ==="; grep -rlnE "\[strings\]|get-string|set-string|adapter strings" docs-site/src docs 2>/dev/null; echo "=== headings mentioning strings/profile ==="; grep -rnE "^#+ .*([Ss]tring|[Pp]rofile)" docs-site/src 2>/dev/null | head

ACK perri + find where [strings] is documented


➜ SENT:perri
=== where is [strings] documented across docs-site + docs? ===
docs-site/src/cli/reference.md
=== headings mentioning strings/profile ===
docs-site/src/cli/reference.md:135:### spt adapter create-profile
docs-site/src/cli/reference.md:155:### spt adapter delete-profile
docs-site/src/cli/reference.md:171:### spt adapter get-string
docs-site/src/cli/reference.md:206:### spt adapter set-string


`[strings]` as a manifest section is entirely undocumented in the manifest reference (only the CLI verbs in generated `reference.md`). So the fix is to add a `[strings]` section to `manifest.md` that documents the section + the file-pointer form. Grounding the section's purpose from CONTEXT.md §adapter strings before writing:


cd C:/Users/decid/Documents/projects/spt-core; grep -niE "adapter string|\[strings\]|profile.*string|string.*overlay|get-string|file pointer|as_file_pointer" CONTEXT.md | head -15

Find adapter-strings section in CONTEXT.md


77:A named **sparse overlay** on its parent adapter manifest. Merge semantics are **leaf-replace**: a profile key replaces the whole value at that path (arrays included — never spliced or appended). The merged result is a complete manifest, and the profile behaves as a distinct adapter option everywhere: canonical addressing is the composite **`<adapter>:<profile>`** (`claude-spt:work`, `spt-usbip-driver:hid-only`) in every place a bare `adapter_name` rides today (perch `info.json`, capability resolution, `api` invocations, `spt adapter list`); the bare name = the parent unmodified. **Two sources, one semantics:** a **shipped profile** is declared inside the parent manifest by the adapter dev and updates as one unit with it; a **local profile** is a node-local overlay file registered beside the adapter — user-authored, **surviving adapter updates** (the safe space for nodewise customization; the manifest file itself is adapter-owned and overwritten by updates). A local profile may not shadow a shipped profile's name (refused at registration). Profiles are never independently versioned (fork-drift, rejected). **Consent floors are tighten-only**: a profile may demand approval where the parent does not, never loosen a parent's `require_approval` floor — a loosening overlay is invalid at registration. CLI: `spt adapter create-profile` / `delete-profile` / `set-string` operate on **local** profiles only. Use cases: per-account variants, extra hook wiring, trust-narrowed shell profiles.
80:**adapter strings** (ratified 2026-06-11, Gateway grill):
82:A `[strings]` manifest section — an adapter-authored JSON/TOML KV tree, dot-path-readable by anything on the node via `spt adapter get-string <adapter-option> <key.path>` (e.g. a harness hook fetching per-profile `additionalContext` — one hook script serves every profile, only the data differs). Resolution rides the **same leaf-replace profile overlay** as the rest of the manifest: a shipped or local profile may override base strings; `get-string` returns the merged view for the named adapter option. **Strings are data only** — nothing in spt-core ever executes a string (command templates live in manifest sections behind registration, never in the KV). Node-local like the registration itself; no cross-node sync. `set-string` is sugar that edits a **local** profile's `[strings]` (never adapter-shipped files).
84:**File-backed strings** (M12-W3): a `[strings]` value MAY be a **file pointer** instead of an inline literal — a value-position table with **exactly one** key `file`: `skill = { file = "skill.md" }`. `get-string` resolves it to the file's **contents** (so large bodies — skill-instructions, hint text — stay out of the manifest). The exactly-one-key rule is the disambiguation: any other table shape stays an opaque nested strings tree (existing trees untouched), and `{ file = … }` is reserved as the pointer form (it can't double as inline data). Files live in the adapter's per-adapter aux dir **`adapters/<adapter>/strings/`** (sibling of `profiles/`), referenced by a path relative to it that **must stay inside that dir** (HAZARD-class containment: `..` traversal and absolute paths are refused at registration). A **local** profile's own file pointers resolve against the user-owned local-profile dir, not the adapter-shipped `strings/` (which adapter updates overwrite) — so a local override survives updates; equivalently a local profile may just inline a literal. Pointers are **validated at registration** (fail-fast on an escaping/missing pointer — records nothing) and **read lazily** at `get-string` (live file edits reflect without re-register); a missing/unreadable file at read time **skip-diagnoses** (emits a diagnostic, returns "not set" — never a silent drop or hard error, mirroring `[digest]`).


Have the full authoritative model (CONTEXT.md §77-84). Adding a focused `[strings]` section to the worktree manifest reference. Finding the insertion anchor:


cd C:/Users/decid/Documents/projects/spt-core-wt-updapply; grep -nE "^## " docs-site/src/harness-contract/manifest.md

List section headers in worktree manifest.md


<owl_messages>
<owl_message from="perri" priority="high">Ack both — F-003 closed: capability shipped+confirmed, doyle owns the docs-visibility residual (folding my byte behavior into the manifest-ref + MANIFEST.md batch). Reframing F-003 in my repo as a DOCS finding (resolved-pending-publish), updating ADR-0001 (dependency SATISFIED) + the plan, then authoring the file-backed skill bodies. todlando: noted PR #12 (<EVENT> everywhere) — standing by for your merge+publish ping to run the poll->additionalContext byte-capture (REQ-MSG-ENVELOPE) and flip HOOKS-API/UPS-INJECTION int. Rolling.</owl_message>
</owl_messages>

[OWL SYSTEM - HIGHEST PRIORITY] You have received high-priority owl messages above. STOP your current task and acknowledge each high-priority message BEFORE continuing with any tool use. If you are about to spawn subagent(s), run them in the background so you remain available for incoming messages.


12:## The principle
30:## Substitution keys
55:## `[adapter]` — header (required)
77:## `[hooks.<event>]` — inbound hook table
103:## `[session]` — watched dirs + role templates
157:## `[env.<VAR>]` — env-var table
171:## `[history]` — transcript access
191:## `[digest]` — session-digest extractor
219:## `[inject]` — input-injection methods
230:## `[identity]` — session identity
244:## Session digest — the digest-record contract
270:## `[update]` — adapter self-update
292:## Shell adapters (`kind = "shell"`)
329:## Cross-field rules (`spt adapter add` enforces these)
Shell cwd was reset to C:\Users\decid\Documents\projects\spt-core


perri's rolling — quick ACK + placing the `[strings]` section (absent entirely from the reference). Reading the `[update]` anchor to insert before it:


cd C:/Users/decid/Documents/projects/spt-core; $OWL send perri doyle <<'EOF'
ACK — F-003 reframe (DOCS finding, resolved-pending-publish) + ADR-0001 dependency-SATISFIED note + file-backed skill bodies = all correct. I'm landing the [strings] file-pointer docs now (the section wasn't even on the published manifest ref — adding it whole, incl your ../ reject text + copy-on-add + local-profile update-safety). Rides PR #13 docs batch. You're clear to author. Will ping you for the poll→additionalContext byte-capture the moment PR #12 (<EVENT>-everywhere) merges+publishes — that's the REQ-MSG-ENVELOPE flip you're staged for.
EOF

ACK perri


266	what dropped and why. Presentation (window depth, arg truncation, sprint collapse)
267	is spt-core's, defaulted by your `[digest]` and consumer-overridable; extraction is
268	yours.
269
270	## `[update]` — adapter self-update
271

what dropped and why. Presentation (window depth, arg truncation, sprint collapse)
is spt-core's, defaulted by your `[digest]` and consumer-overridable; extraction is
yours.

## `[update]` — adapter self-update


Inserting the `[strings]` section before `[update]`: