# Runtime manifest schema

> 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.

## Principle

**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.

## What is NOT in the manifest (spt-core-owned)

- **Sentinels** (`.idle`-equiv, echo-gate) — managed by `api state`/`api echo-gate`; the adapter only *calls* them.
- **Spool / registry / perch `info.json` / daemon-state schemas** — core.
- **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.
- **File-drop filenames** — statically `<endpoint_id>-commune.md` / `<endpoint_id>-signoff.md`. Only the *watched directory* is declared (in `[session]`).
- **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.
- **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.
- **Plugin-cache / version-trampoline layout** — none of SPT's concern. A CC-plugin adapter updates via `claude plugin update` (delegated command).

<!-- [doc->REQ-MANIFEST-1] The sections below are the authoritative schema; the
typed form in `crates/spt-runtime/src/manifest.rs` is kept in lockstep. -->

## Manifest sections

### `[adapter]` — header
```toml
[adapter]
name = "claude-spt"
version = "2.0.0"
min_spt_core_version = "1.0.0"     # readable before any update; compat gate
hostable_types = ["LiveAgent", "ReadyAgent", "Psyche", "Worker"]
shortcut_basename = "cc"           # optional; brands the picker's launcher → cc-<id> (default "spt")
host_binaries = ["claude"]         # optional; the harness exe(s) this adapter hosts agents inside
```

<!-- [doc->REQ-ADAPTER-VERSION-CMD] -->
`version` is **mandatory**. It is the single declared adapter version — read
before any update (the `gh_release` compare point), surfaced by `spt adapter
version <name>` (which prints this `[adapter].version` of a registered adapter;
exit 1 if unregistered), and the value the post-update `message` gate keys on.
There is no second version source — no `[strings].version`, no `get-string`
convention.

<!-- [doc->REQ-ADAPTER-FLOOR-ENFORCE] -->
`min_spt_core_version` is the **enforced** compatibility floor. Both acquisition
verbs — `spt adapter add` and `spt adapter update` — REFUSE when the spt-core being
judged is below this version, naming that core, the floor, and the next action
(update spt-core first). **The core being judged is the RUNNING one for a standalone
add or update, and the core the run will ACTIVATE under a composite `spt update`**
(which stages a new core and updates adapters in the same run) — so an adapter whose
floor the incoming core satisfies installs in that run instead of being refused
against a core on its way out.
<!-- [doc->REQ-ADAPTER-FLOOR-VS-STAGED-CORE] --> The check is a numeric per-component compare (so
`0.9.0 < 0.25.0`), and it fires **before** anything is written: a refused add leaves
the registry untouched, and a refused update leaves the live install byte-untouched.
The enforcement is unconditional — there is **no** opt-in flag (the former
`[update].version_check` knob was retired; it was never read).

<!-- [doc->REQ-MANIFEST-7] -->
`shortcut_basename` *(optional, default `spt`)* — the basename the `spt endpoint
run` picker's `s` keybind bakes into the generated `<basename>-<id>` launcher
shortcut at the project root (REQ-MANIFEST-7). Absent ⇒ the harness-agnostic
`spt` (→ `spt-<id>`); an adapter sets it to brand its shortcuts — `claude-spt`
uses `cc`, giving `cc-doyle`. spt-core never hardcodes a harness name; the
picker reads this from the **resolved** manifest of the selected adapter. The
launcher is the current OS's native form (`.cmd` on Windows — `.ps1` is excluded
by the default `PATHEXT`; a POSIX `sh` `+chmod +x` on Unix) and overwriting is
guarded by a generated-by sentinel (a foreign same-named file is never clobbered).

<!-- [doc->REQ-MANIFEST-8] -->
`host_binaries` *(optional, since v0.9.0)* — the harness executable basenames a
`kind="harness"` adapter hosts agents inside (e.g. `["claude"]`). This is the
bind-time **match-key**: a harness-hosted session's parent process maps to its exe
basename, which selects the adapter(s) that declare it. **Match rule:** both the
declared entry and the live exe basename are normalized to **lowercase + the stem
before the first dot**, then compared — so `claude` matches `claude`, `claude.exe`,
`claude.cmd`, *and* the renamed-in-use form `claude.exe.old.<timestamp>` (a harness
self-update can rename its still-running exe, e.g. Claude Code's updater, so a
long-lived session's exe basename is `claude.exe.old.<ts>`). **A declared
host-binary name must therefore not itself contain a dot** (real harness exe names
don't). With `host_binaries`, `spt api seed` / `listen` need **no `--adapter`** —
the owning adapter is resolved at bind. Declare it once per harness binary you host;
an adapter author then runs `spt adapter use <adapter>[:profile]` to pick the active
default when more than one adapter hosts the same binary (without a pick, the
freshest-registered hosting adapter wins). Omitted ⇒ the adapter is reachable only
via an explicit `--adapter`.

### `[hooks.<event>]` — inbound hook table
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).

```toml
[hooks.SessionStart]
fires = "api seed --pid {parent_pid} --session-id {session_id}"   # adapter-agnostic since v0.9.0; no --adapter
reads = ["session_id", "source", "agent_type"]
can_inject = true                  # via additionalContext

[hooks.Stop]
fires = "api state idle"
reads = []
can_inject = false                 # no hookSpecificOutput schema → sentinel/relay fallback
```

### `[session.<role>]` — outbound session role templates
One opaque command template per role: `self`, `resume`, `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.

<!-- [doc->REQ-INSTALL-11] -->
**Install-dir program resolution (since v0.8.0).** A command template's bare program token (its first whitespace-split token, e.g. `claude-spt-digest …`) resolves against the **adapter's install dir** before PATH, so a `.spt` that ships its own binaries is self-contained — no PATH placement needed. spt-core probes `<install_dir>/<program>` (on Windows also the `.exe` suffix); when that file exists it runs that absolute path, otherwise it leaves the bare token for PATH to resolve. The install dir is the registry record's `source_dir` — `adapters/_github/<safe>/` for a `--release`/`--github` adapter, the copy-mode `source_dir` otherwise. This covers the `[digest]` extractor, the `[session.psyche_resume]` runner (the per-event Psyche turn — `psyche_init` is a go-live gate spt-core never spawns; see below), the `[message-idle-translation-binary]`, the `[session.notif]` command, and the `adapter digest-proof` tool. (The `spt` binary itself still resolves from PATH the usual way.)

```toml
# GO-LIVE GATE ONLY. Declaring [session.psyche_init] is the single signal that
# promotes the endpoint to a LiveAgent — but spt-core NEVER spawns it (there is no
# resident Psyche to "init"). The per-event turn drives [session.psyche_resume]
# EXCLUSIVELY (below). The command is opaque and, in the ephemeral model, unexecuted;
# its presence is the whole contract, so keep it minimal.
[session.psyche_init]
command = "claude -p --agent owl-psyche --model sonnet --output-format json"

# THE DRIVEN ROLE. spt-core runs ONE bounded psyche_resume turn per Psyche event
# (a pulse fire / commune drop / custody transition), stdin-fed + stdout-captured,
# then it exits — no resident process, no detach.
[session.psyche_resume]
command = "claude -p --resume {session_id} --agent owl-psyche --model sonnet --fallback-model opus --output-format json"
env_remove = ["OWL_SESSION_ID"]
recursion_guard_env = "SPT_ECHO_COMMUNE"   # set on summarizer children so their own hooks bail
keys = ["session_id", "parent_session_id", "psyche_context_file", "subnet"]   # the psyche keys spt-core fills
```
The keys spt-core fills into `psyche_resume`: **`{session_id}`** = the Psyche's **own** custody sid (its own thread — never the parent's), **`{parent_session_id}`** = the parent's sid (explicit key), **`{psyche_context_file}`** = the path to the composed-mind file, **`{subnet}`** = the endpoint's anchor-subnet label. The adapter-static/node keys (`{id}` = the **parent endpoint id**, `{adapter_dir}`, `{adapter_name}`, `{node}`) are also available. (`signoff` is a `psyche_resume`-style variant firing a final context-save; `echo_commune` is a cheap-model template consuming history.)

<!-- [doc->REQ-PSYCHE-EPHEMERAL-DRIVER] -->
**Psyche contract — a bounded per-event turn (since v0.25.0).** A Psyche is **not** a resident process the daemon keeps alive between events. Declaring `[session.psyche_init]` is a **go-live gate only** — its presence promotes the endpoint to a LiveAgent, but spt-core **never spawns** it. Each psyche-relevant event (a pulse fire, a commune/signoff drop, a session-custody transition) instead runs **exactly one bounded turn** through **`[session.psyche_resume]` — the sole driven psyche role** — spawned by the daemon, which exits at turn end. **Liveness = turns succeed** — there is no resident psyche pid to poll; a Self perch is online-and-hosted whether or not any psyche turn is in flight. Consecutive turn failures consume a bounded failure budget → a `psyche_host_error` stamp on the Self perch (a psyche failure of any shape never removes or alters the parent's ready/hosted state); a clean turn clears it. The daemon fills **every** substitution key from its own known context, so the psyche child never has to resolve its own home/subnet/perch.

<!-- [doc->REQ-PSYCHE-SID-CUSTODY] -->
**Custody sid — `{session_id}` is the Psyche's OWN id.** In a psyche role template `{session_id}` is the **Psyche's own minted session id**, kept in its nested `<parent>-psyche` perch record — **not** the parent's. A parent boundary (`/clear`, `/compact`) rotates the *parent's* sid but does **NOT** rotate the psyche sid: the Psyche's conversational thread survives parent resets (that is its job). When a template still needs the parent's sid it takes the **explicit** `{parent_session_id}` key — never an alias of `{session_id}`.

<!-- [doc->REQ-PSYCHE-CONTEXT-FILE-INDIRECTION] -->
**Psyche-download — `{psyche_context_file}` (file-always, replaces `{psyche_context}`).** The composed Psyche mind rides a **file**, never the command argv: before each turn spt-core writes the mind into the nested psyche perch dir and fills a single **`{psyche_context_file}` = that path** (argv-cap-immune — a real ~20 KB mind exceeds the win32 command-line cap and would brick the spawn). The soft **fresh-vs-continue** discriminator is the file's **content**, not key presence: a **fresh** (first / reseeded) turn writes the composed mind **non-empty** (a never-empty `<fresh-psyche/>` marker when there is no stored mind), a **continue** turn writes the file **truly 0-byte**. The adapter reads the file (trim-empty tolerated) and prefixes its context; it never writes or deletes the file. The old `{psyche_context}` body key is **retired** — do not template it.

<!-- [doc->REQ-PSYCHE-NESTED-RESOLUTION] -->
**`{subnet}` — the endpoint's anchor-subnet label.** A single `{subnet}` key fills to this endpoint's anchor-subnet label (`local` when unanchored), supplied whenever it is known so a psyche turn need never resolve a `--subnet` it cannot know; when no subnet is known the key is left unfilled so a referencing template fails **loud** (the `{node}` precedent). There is deliberately **no `{home}` key** — the endpoint's anchor subnet is one concept, and `home:` is already the subnet-name qualifier position (a subnet may simply be *named* `home`), so a `{home}` template key would only invite meaning-drift.

<!-- [doc->REQ-PSYCHE-SID-CUSTODY] -->
**Reseed — reserved exit code 95.** When the Psyche's own session is lost or invalid (first host, store wipe, harness-side expiry) the adapter signals it with the **reserved exit code 95**; spt-core clears the stored custody sid and drives the next turn as a fresh preload (a non-empty `{psyche_context_file}`), emitting a loud `PSYCHE_RESEED:<id>` marker so a custody-loss loop is visible — the failure budget above bounds it.

<!-- [doc->REQ-SESSION-RESUME-TEMPLATE] -->
**`[session.resume]` — the agent's own-session NATIVE resume.** `self` is the
agent's own session; `resume` is its native-resume sibling (mirroring
`psyche_init`→`psyche_resume`). spt-core selects `[session.resume]` over
`[session.self]` **only** when a bringup carries a prior session (`spt endpoint
run --resume <session>`, or the picker's *Resume from history*) **and** the
adapter declares the role. An adapter with **no** `[session.resume]` falls back
to `[session.self]` (full back-compat) — but then a resume re-runs the *fresh*
command (e.g. `claude --session-id …`) and the harness starts a blank
transcript. A harness with a native-resume verb (Claude Code: `claude -r
<session_id> …`) must declare `[session.resume]` to resume into existing
history. Keys spt-core fills are the SAME catalog as `self`: `{id}`,
`{session_id}` (the **resumed** id), `{session_name}`, `{adapter_name}`. The
harness resolves a transcript by `session_id` **+ cwd**, so spt-core also lands
the resumed PTY in the session's recorded **project cwd** — the per-session
ledger row's `cwd` → else the perch `info.cwd` → else the current dir (so a
single-project endpoint and pre-migration ledger rows still resume correctly).

```toml
# Claude Code: native resume into an existing transcript by id, in its project cwd.
[session.resume]
command = "claude -r {session_id} --remote-control {id} --dangerously-skip-permissions"
keys = ["session_id", "id"]
```

**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}`, `{adapter_dir}`, `{session_id}`, `{parent_session_id}`, `{session_name}`, `{node}`, `{subnet}`, `{parent_pid}`, `{agent_type}`, `{psyche_context_file}`, `{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).

<!-- [doc->REQ-MANIFEST-SUBST] -->
**Adapter-static keys — `{adapter_dir}` and `{adapter_name}` (since v0.16.0).** Two of the catalog keys are *adapter-static* — they depend only on the resolved adapter, never on a session or event, so they are available **wherever** command/string substitution runs (every `[session.*]` template, the `[digest]` extractor, the `[message-idle-translation-binary].command`, and — uniquely — inside `[strings]` values at `get-string` read time):

| Key | Fills to | Notes |
|---|---|---|
| `{adapter_dir}` | the registry record's `source_dir` — the adapter's **install dir** (`adapters/_github/<safe>/` for a `--release`/`--github` adapter; the copy-mode `source_dir` otherwise) | the same dir bare-program resolution already uses (REQ-INSTALL-11); **survives updates** (a `gh_release` re-extract re-registers in place), so a path built from it stays valid across `spt adapter update` |
| `{adapter_name}` | the resolved `[adapter].name` | identical to the catalog `{adapter_name}` above |

Because they carry no session/event context, an adapter can build a path to its **own packed binary** and have spt-core *resolve* it without spt-core ever executing it — spt-core substitutes and **returns** the string; the adapter's own wrapper runs the result (resolve-not-execute; see ADR-0029). The canonical use is hook dispatch: an adapter stores a command template in `[strings]` (below), reads it once per session with `spt adapter get-string`, and runs the resolved command per-hook from a thin static dispatch wrapper.

**Node-static key — `{node}` (since v0.20.0).** Like the adapter-static keys, `{node}` needs no session/event context, so it is available wherever the session-scoped keys populate (both spawn topologies' `[session.*]` templates) **and** in lazy `[strings]` resolution at `get-string` read time. It fills to **this node's advertised label** — the same value the registry advertises and `spt endpoint list` / the picker render (`LABEL (keyprefix…)`), i.e. the node's OS hostname (read into the label store once at daemon startup), **never the Ed25519 pubkey**.

| Key | Fills to | Notes |
|---|---|---|
| `{node}` | this node's **advertised label** — the label store value (OS-hostname default, re-checked at daemon startup), OS-hostname fallback when no store label is known | **SINGLE-TOKEN fill only** — tokenize-then-fill cannot produce a space-carrying argv element, so a composite display name like `<id> @ <node>` stays adapter-shim territory (`{node}` complements the shim for tokenizable args, e.g. `--remote-control {id}--{node}`, it does not replace it). **Caveat:** the daemon-side lifecycle resolves it once at brain startup (so fill == advertised); the CLI-originated self-spawn resolves a fresh OS hostname at spawn time (the same source the CLI uses for its own self-label) — these differ **only** if the OS hostname changes mid-daemon-life (before the next endpoint spawn). When no label is known at all, `{node}` is left unfilled so a referencing template fails LOUD (never a silent empty token). |

<!-- [doc->REQ-MANIFEST-NODE-KEY] -->
```toml
[session.self]
# {node} fills as one argv token — the node's advertised label (its hostname).
command = "claude --session-id {session_id} --remote-control {id}--{node}"
keys = ["session_id", "id", "node"]
```

<!-- [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}`.

```toml
[session.notif]
command = "powershell -Command New-BurntToastNotification -Text '{notif_from}','{notif_body}'"
keys = ["notif_id", "notif_from", "notif_subnet", "notif_body"]
```

### `[env]` — env-var table
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.

```toml
[env.OWL_SESSION_ID]
direction = "inject"
value = "{session_id}"
channel = "CLAUDE_ENV_FILE"        # harness-hosted only; spt-hosted inherits from broker
```

**`direction = "read"` — capture a launch-env var for template substitution.**
<!-- [doc->REQ-DIGEST-PROFILE-ENV] -->
A `read` directive names an environment variable spt-core **captures from the
session's launch environment at bind** and then exposes as a `{VAR}` substitution
key in `[digest].source` and `[history].locate_template`. This is how an adapter
whose harness stores its transcript under a **relocatable root** (e.g. Claude
Code's `CLAUDE_CONFIG_DIR`, which a profile like `ccs` repoints) makes that root
resolve at digest time — the on-demand digest runs later in the daemon context
where the launch env is gone, so the value must be captured at bind and persisted.

```toml
[env.CLAUDE_CONFIG_DIR]
direction = "read"
value = "~/.claude"                # FALLBACK when the var is unset (the base harness)
```

- **Allowlist, not the whole env** — only the vars you declare `direction = "read"`
  are captured (privacy); an undeclared var is never persisted.
- **Resolution order at digest time**: the value **captured** from the ambient env
  at bind (the relocating profile) → the directive's **`value`** fallback (the base
  harness) → **omitted**, so the `{VAR}` fails to fill and the digest diagnoses
  "not set" rather than resolving a wrong path (the same skip-diagnose posture as a
  missing `[digest]` source).
- **`~` expansion** — a leading `~/` (or bare `~`) in the captured value or the
  `value` fallback expands to the OS home (`USERPROFILE` on Windows, `HOME` on
  Unix). Any other `~` passes through verbatim.
- spt-core names **no** harness variable and bakes **no** default root — the
  adapter declares which var to capture and how its transcript path consumes it
  (see CONTEXT.md, *"Profile-relocated transcript roots — env-read capture"*).

### `[history]` — transcript access
Three strategies; pick one. Pull-based (spt-core asks) or push-based (adapter sends).
```toml
[history]
strategy = "fetcher"               # fetcher | locate_normalize | native
fetcher = "claude-spt-history --session {session_id}"   # adapter binary emits normalized history
# locate_normalize: locate_template + normalize_command
# native: adapter pushes via `api history-log`; spt-core stores (also used for Shells)
```

### `[digest]` — session-digest extractor (ADR-0019)
The session digest's own seam — **distinct from `[history]`** (which stays opaque
and single-session, feeding the echo-commune verbatim). Declares an **imperative
extractor** that maps the harness's native log → the digest-record contract.
```toml
[digest]
extractor = "claude-spt-digest --session {session_id} --in {source}"  # native log → contract JSONL
source = "{CLAUDE_CONFIG_DIR}/projects/{session_id}.jsonl"   # optional; defaults to [history].locate_template
window_turns = 5                   # optional presentation defaults the adapter declares…
arg_truncation = 40                # …any consumer may override at pull/subscribe
sprint_collapse = true             # collapse consecutive tool records into one sprint
```
- **`extractor`** (required) — an opaque command spt-core never parses; its stdout
  is contract JSONL (one record per line). Under `locate_normalize` (the default)
  spt-core resolves the `source` file, fills `{source}` with its path, and pipes
  the bytes on stdin. Under `fetcher` (below) the extractor locates + reads itself
  and spt-core just runs it and reads stdout. Why imperative, not a declarative
  DSL: real harness logs are nested (one line → many entries, mixed block lists,
  types to filter); a flat map can't express them and a map powerful enough is a
  reinvented language.
- **`strategy`** (optional; `locate_normalize` default) — which side LOCATES the
  transcript, mirroring `[history]`'s split:
  <!-- [doc->REQ-DIGEST-FETCHER-STRATEGY] -->
  - `locate_normalize` — **spt-core** locates the single `source` file, reads it,
    pipes the bytes to the extractor. Correct for a trivial, fully-templatable
    single-file layout.
  - `fetcher` — the **adapter's** extractor locates + reads + emits; spt-core runs
    it bounded and consumes stdout (no `source`, no pre-read). Required when the
    transcript lives in a **partitioned** layout spt-core cannot name with one
    template — e.g. Claude Code's `projects/<munge(cwd)>/<session_id>.jsonl` or a
    date-globbed rollout tree. spt-core feeds the extractor only the
    harness-**neutral** inputs it owns — `{session_id}`, the perch-bound `{cwd}`,
    and captured `[env] direction = "read"` vars — never a harness-specific project
    slug; the extractor globs the unique `{session_id}` under the root itself.
    ```toml
    [digest]
    strategy = "fetcher"
    extractor = "claude-spt-digest --session {session_id} --config-dir {CLAUDE_CONFIG_DIR} --cwd {cwd}"
    # no `source` — the extractor locates the file
    ```
- **`source`** (optional) — own-source escape hatch; absent, reuse
  `[history].locate_template` (DRY). A **log-less** adapter declares no `[digest]`
  and pushes via `api digest-entry` instead. It must resolve to a **single session
  file** (spt-core reads that file and pipes the bytes on stdin). Available
  substitution keys: `{id}`, `{session_id}`, `{adapter_dir}`, `{adapter_name}`, and
  any `[env] direction = "read"` var (e.g. `{CLAUDE_CONFIG_DIR}` above) — the seam
  that lets a **profile-relocated transcript root** resolve.
  <!-- [doc->REQ-DIGEST-PROFILE-ENV] -->
  A leading `~` in the resolved path expands to the OS home.
- **presentation knobs** (`window_turns`, `arg_truncation`, `sprint_collapse`) —
  adapter-declared **defaults** any consumer may override; spt-core ships
  fallbacks (`3` / `25` / collapse-on).

Prove your extractor before shipping: `spt adapter digest-proof <adapter>
--sample <real-log>` runs it and prints the parsed records, the rendered digest,
and **every dropped line with its reason** — no silent empty.

<!-- [doc->REQ-ADAPTER-PROOF-DIR-OVERRIDE] -->
To proof a **DEV build that is not registered** (a freshly built binary beside a
hand-written `manifest.toml`, or a bare-file `gh_release` adapter never staged
into an extracted install), pass `--dir <install-dir>` (binaries resolve there;
manifest defaults to `<dir>/manifest.toml`) or `--manifest <file>` (pins the
manifest; its parent is the install dir) to either `digest-proof` or
`translate-proof`. With neither, the command resolves the registered adapter.

### `[strings]` — adapter string values (M9 / file-backed M12-W3)
An adapter-authored KV tree, dot-path-readable by anything on the node via `spt adapter get-string <adapter[:profile]> <key.path>` (data only — never executed; node-local). Resolves through the same leaf-replace profile overlay as the rest of the manifest. A value MAY be an inline literal **or** a file pointer — a value-position table with **exactly one** key `file`:
```toml
[strings]
greeting = "hello"                       # inline
skills.whoami = { file = "whoami.md" }   # pointer → get-string returns the file's CONTENTS
```
Pointer files live in `adapters/<adapter>/strings/`, referenced by a path that **must stay inside** that dir (`..`/absolute refused at registration). Validated at register (fail-fast), read lazily at get-string (live edits reflect), skip-diagnoses a missing file at read (mirrors `[digest]`). A **local** profile's pointers resolve against the user-owned local-profile dir (update-safe). Published reference: [manifest.md `[strings]`](../docs-site/src/harness-contract/manifest.md#strings--adapter-string-values--profiles).

<!-- [doc->REQ-MANIFEST-SUBST] -->
**Lazy substitution inside `[strings]` values (since v0.16.0).** A `[strings]` value (inline literal or the read-back contents of a pointer file) may contain the **adapter-static** placeholders `{adapter_dir}` / `{adapter_name}`; `get-string` substitutes them at read time and returns the resolved string. This is scoped to **those two keys only** — `get-string` carries no session context, so session-scoped keys (`{id}`, `{session_id}`, …) are **not** available here and an attempt to use one is left untouched / errors rather than silently emptying. The data is still never executed (node-local, data-only): spt-core resolves the path and hands it back; the caller decides what to do with it.

```toml
[strings]
# adapter stores its own hook-dispatch command, resolved at get-string time
hook_cmd = "{adapter_dir}/claude-spt hook"
```

A harness hook dispatcher runs `spt adapter get-string claude-spt hook_cmd` **once per session** (e.g. memoized into an env var for a hot-path hook like PostToolUse), getting back the fully-resolved `"<install_dir>/claude-spt hook"`, then executes that itself per-hook. spt-core never grows a hook-execution surface — it only resolves (ADR-0029).

### `[inject]` — inject-input methods
Per activity-state, any combination: `pty`, `hook`, `relay`, `http`.
```toml
[inject]
activity = ["hook"]                # non-disruptive during work
idle     = ["pty", "hook"]
```

<!-- [doc->REQ-MSG-IDLE-TRANSLATION-BINARY] -->
### `[message-idle-translation-binary]` — spt-hosted idle delivery (ADR-0022)
Opt-in. The adapter's **idle-delivery translation binary**: a pure stdin→stdout
JSON-lines filter spt-core lifecycle-manages (spawned when the spt-hosted endpoint
comes up, terminated when it goes down). spt-core feeds it the inbound `<EVENT>`
feed and reads back keystroke-commands, which spt-core applies to the broker-held
PTY **atomically** — controller input is buffered during the emitted sequence and
flushed after, so injection coexists with a live `spt rc` controller (spt-core owns
every PTY write). Idle-only; busy/mid-turn delivery stays adapter hook-injection.
A **table** carrying a `command` (or the deprecated bare `path`) scalar (not a bare top-level scalar — a table can't be
silently absorbed by a preceding section, and it stays extensible).
```toml
[message-idle-translation-binary]
command = "claude-spt translate"   # program token + args; spt-core spawns + drives this
# path = "cc-spt-idle-translate"   # DEPRECATED bare-program form (still parses; warns at registration)
```

<!-- [doc->REQ-TRANSLATE-COMMAND] -->
- **`command` (preferred, since v0.16.0)** — an **opaque** command string (a program token plus args), exactly like the other command seams. Its program token resolves against the adapter **install dir** (REQ-INSTALL-11), like `[digest].extractor` / `[session.psyche_resume]`: a bare/relative program (e.g. `claude-spt`) resolves to `<install_dir>/<program>(.exe)` before PATH. Args support **adapter-static `{adapter_dir}` / `{adapter_name}` substitution only** — **not** session keys. The translation binary is a *persistent* process serving **every** session on the endpoint: session/event context arrives per-message over the stdin Init/Event protocol (below), never on the spawn argv, and the live-update respawn site likewise carries no session context — so a `{id}`-bearing command would be both redundant and unsafe (it would `MissingKey` → spool at respawn). Letting the binary be invoked as a subcommand of one consolidated adapter binary (`claude-spt translate`) is the reason for the form.
- **`path` (deprecated)** — the original bare-program form. It still **parses** (forward/back-compat) but **warns at registration**, steering authors to `command`. **Exactly one of `{path, command}`** may be set: both-set is **refused** at registration; neither set = **no translation binary** (the endpoint falls back to its `[inject]` hook path).
- Whichever form is used, the resolved program is spawned at the initial spt-hosted bringup **and** at a live-update respawn. (A declared binary that fails to spawn is logged `TRANSLATION_SPAWN_FAILED` on the daemon's stderr; before diagnosing idle-delivery behavior, confirm the binary actually spawned.) <!-- [doc->REQ-INSTALL-11] -->
- **stdin** (spt-core → binary, one JSON object per line): `{"type":"init","endpoint_id":…,"node":…}` first · `{"type":"event","envelope":"<EVENT…>"}` per inbound message (the ADR-0020 envelope) · `{"type":"input"}` a **content-free** ping each time the operator types (so the binary can track user-idle; the PTY input content is **never** duplicated to the binary).
- **stdout** (binary → spt-core, one per line): `{"key":"ctrl+s"}` · `{"delay_ms":50}` · `{"text":"<payload>"}` · `{"key":"enter"}` · **`{"commit":true}`**, … (extensible vocabulary).
- **`{"commit":true}` is the mandatory sequence TERMINATOR, required for EVERY `{"type":"event"}`.** While a sequence is in flight spt-core buffers the live `spt rc` controller's keystrokes (the *inject floor*) and applies the emitted commands atomically; the trailing `{"commit":true}` releases the floor and flushes the buffered controller input *after* the sequence. The submit keystroke is NOT the terminator (a choreography may keep typing after `{"key":"enter"}`). **An empty response is a protocol violation:** even an event with nothing to inject (nothing armed / no envelope) MUST answer with at least a bare `{"commit":true}` — zero records reads as a missed commit.
- **Commit deadline + miss tolerance (F029 C-1, ADR-0022 amendment).** If no `{"commit":true}` arrives within the **commit deadline (5 s, `INJECT_COMMIT_DEADLINE`)**, spt-core flushes the buffered operator input (never stranded) and re-spools that message once — but a SINGLE miss does NOT fault a healthy binary (it is preserved; the next event delivers through it). Only **3 consecutive** missed commits, or a real binary death, faults the binary — and a real fault gets a **bounded eager respawn** (a healthy commit resets the budget) plus a perch-visible `translation_fault` status stamp, not permanent death. (Supersedes the pre-v0.14.3 raw-inject-fallback: raw inject was removed; a missed commit never types the payload raw — it spools.)
- **Known keys today:** `command` (preferred) and the deprecated `path`. spt-core does **not** `deny_unknown_fields` here — a newer adapter declaring a future key against an older spt-core parses fine (the unknown key is ignored), so the lifecycle-binary contract degrades gracefully.
- A minimal binary just emits `{"text":payload}{"key":"enter"}{"commit":true}` with no choreography (spt-hosted idle delivery is translation-binary-only since v0.14.3 — no raw-inject fallback).

### `[service]` — a daemon-supervised resident service (ADR-0049)
Opt-in. Declares a **ResidentService**: a binary the **daemon** supervises on the
adapter's behalf, core-owned from birth. It is spawned **job-neutrally by the
daemon**, so it is never a shell's child (a `/T` tree-kill of the shell cannot
reach it) and never inside a launching terminal's Job Object (closing the
terminal cannot sweep it). It has **no perch, no identity, and no address** — a
service needing a two-way agent-facing surface has one at its adapter's
endpoint/shell layer.

```toml
[service]
command = "{adapter_dir}/gw-hub serve"   # opaque; program token + args
start = "boot"                            # "boot" | "bind" — REQUIRED
stop_grace_ms = 30000                     # optional; defaults to 30s
```

<!-- [doc->REQ-RESIDENT-SERVICE] -->
- **`command`** — an **opaque** command string (program token plus args), like
  every other command seam. Its program token resolves against the adapter
  **install dir** before PATH (REQ-INSTALL-11), and args support adapter-static
  `{adapter_dir}` / `{adapter_name}` substitution only. Must be non-empty: a
  declared service means spt-core owns and supervises a process.
- **`start`** — **required**, no default. `"boot"` is **desired-state-running,
  not an event**: the supervisor reconciles the service toward running at daemon
  boot, at **adapter registration against a live daemon** (installing or
  registering an adapter never requires restarting spt to bring its service up),
  at update-hold release, and at first shell bind as a defensive ensure.
  `"bind"` starts lazily at the adapter's first shell bind. Both are supervised
  identically once running. The key is required rather than defaulted because
  the choice decides whether a least-trusted third-party binary rises with the
  daemon itself — an adapter author says so explicitly.
- **Registration reports what it did, and never fails over the service.** After
  a successful `spt adapter add` or `spt adapter update`, the CLI asks the daemon
  to reconcile that adapter and prints one line per option — started, already
  running, held, deferred to first bind, or not started with the reason. If the
  daemon is **not running**, the registration still succeeds and the CLI says so:
  the service is declared and starts at the next daemon boot. A registration is
  never refused because a service could not be started, and the notice is not
  optional — without it a `start = "boot"` service would silently not exist until
  something else restarted the daemon.
- **`stop_grace_ms`** — how long a cooperative exit has before the supervisor
  force-kills. Defaults to `30000`. Must be `> 0`: a zero grace would leave no
  window to exit in.
- **Cardinality** is one supervised instance per registered adapter-option
  (`<adapter>[:profile]`). The supervisor threads the option name and a
  per-option runtime dir into the service's spawn environment, so an adapter can
  scope its own guards and config per option. An adapter may keep its own file
  lock as a private double-start guard; spt-core neither reads nor depends on it.
- **The spawn contract, by name.** A supervised service is started with these
  environment variables, and they are the whole interface:

  | variable | value |
  |---|---|
  | `SPT_SERVICE_OPTION` | the adapter-option this instance serves, e.g. `hub` or `hub:staging` |
  | `SPT_SERVICE_DIR` | this instance's private runtime directory, created before the spawn |
  | `SPT_BIN` | absolute path to the `spt` executable to call (see below) |
  | `SPT_HOME` | the state root that `spt` must be run against |

  The option is the raw string an operator types, not a path encoding. The
  runtime dir is per-option, so two options of one adapter never share a
  directory — scope any private lock or config file inside it.
- **Calling the `spt` CLI from a supervised service.** Invoke `$SPT_BIN`, not a
  bare `spt`. A service is spawned by the daemon, not by a login shell, so its
  `PATH` is whatever the daemon inherited — a daemon started by the platform
  service manager or a scheduler has no reason to carry the install dir, and a
  bare `spt` would work on your box and be silently missing in the field.
  `SPT_BIN` also names the *running daemon's own image*, so the CLI you call is
  never a different build than the supervisor that owns you. `SPT_HOME` is
  likewise pinned to the home the daemon resolved rather than left for the child
  to re-derive from a per-account platform default. Both are absolute. `SPT_BIN`
  is the one variable here that can be **absent** — if core cannot read its own
  image path it says nothing rather than handing you a guess, so treat an
  unset `SPT_BIN` as "the CLI is not available to me" and report it.
- **A supervised service has no session identity.** Core *removes*
  `SPT_ENDPOINT_ID`, `OWL_SESSION_ID` and `SPT_AGENT_ID` from the spawn: a
  service is a node-scoped process with no session and no perch, so its `spt
  send` presents as the anonymous `cli@<node>` origin. Without the scrub a daemon
  that happened to be started from inside an agent's shell would hand that
  agent's identity to every service on the node, and their messages would claim
  to be from that agent — with the replies routed back to it. If your service
  needs a perch identity, it registers one; it never inherits one.
- **The runtime dir's core-owned filenames, also by name.** Three names inside
  `SPT_SERVICE_DIR` are contract; everything else in there is the adapter's own.

  | file | written by | meaning |
  |---|---|---|
  | `stop-requested` | spt-core | the quiesce request — its *existence* is the whole message (see below) |
  | `status-advisory` | **the service** (optional) | one **advisory** line surfaced by `spt adapter service status` |
  | `startup.capture` | spt-core | the service's own stdout+stderr from its startup window — the evidence a `STARTUP_FAULT` carries |

  `status-advisory` is the one file in this contract an adapter writes and core
  reads. It is **display-only and never consulted for a decision** — no
  reconcile, hold, kill or adoption reads it — because core deciding on it would
  put a least-trusted binary's self-report in the control path, and a service
  that stopped updating it would silently become whatever it last claimed. The
  read is **bounded** (the first line, capped) and an unreadable file is simply
  no advisory: nothing here ever requires reading a file the service holds an
  exclusive OS lock on. Write it however you like — a whole-file rewrite each
  cycle is the intended shape.

  `startup.capture` holds **both** streams on one handle (an adapter reporting
  its fault on stdout must not produce a diagnostic-free `STARTUP_FAULT`). Core
  truncates it at every spawn and again once a run outlives the startup
  threshold, so it is evidence and never a log to accumulate in — a service that
  wants a durable log writes its own file beside it.
- **Quiesce is cooperative exit plus a deadline.** The supervisor creates the
  file **`stop-requested`** inside `SPT_SERVICE_DIR`. Its *existence* is the
  entire message: there is no content to parse, no reply to send, and no
  channel to open — a service that already has a loop just stats that one path
  each cycle and exits when it is safe to. The kernel-observed exit **is** the
  acknowledgement, so "not ready yet" is expressed by not-yet-exiting and no
  busy record exists to go stale in either direction. Past `stop_grace_ms` the
  supervisor force-kills. **Delay is possible; veto is not.**
- **Update holds the service.** An adapter update is an ordered supervisor
  operation: quiesce → **hold** (stopped, and never relaunched while held) →
  swap bits → start the new bits → release. Crash-relaunch applies only when not
  held, because a supervisor that eagerly relaunched mid-swap would re-pin the
  old executable and turn a diagnosable failure into an unwinnable race.
- **Liveness is derived, never recorded.** The supervisor is the parent and
  holds the child handle, so exit is kernel-observed and no "running" record
  exists to go stale.
- **Consecutive fast exits are a configuration fault, not a crash.** A service
  that exits repeatedly within the startup threshold trips a loud
  `STARTUP_FAULT` diagnostic carrying its captured startup output, rather than
  being ground silently through relaunch backoff. A run that outlives the
  startup threshold resets that counter.
- **The service can invoke the spt CLI** (`spt send` and friends) from its
  supervised environment, under the usual identityless `cli@<node>` from-label.
- Operator surface: `spt adapter service list` and
  `spt adapter service status <adapter[:profile]>`. (These live under `spt
  adapter` — the resident service belongs to the adapter, whereas `spt daemon`'s
  "service" wording refers to the OS service manager hosting the daemon itself.)
  Both are **read-only** and both are **answered by the daemon or not at all**:
  the supervisor holds the child handles, so service state exists only in the
  daemon's memory. With no daemon running the CLI says exactly that — it never
  derives "running" from the pid file in the runtime dir, which exists so the
  *next* daemon can kill an orphan and is not a liveness record. Each row reports
  the declared start trigger, whether the service is running, whether an update
  is holding it, any relaunch suppression **with the captured startup output
  behind it**, and the service's own advisory line when it wrote one.

### `[session]` watched dirs (file-drop)
```toml
[session]
commune_dir = ".claude"            # watched for <id>-commune.md (filename fixed by spt-core)
signoff_dir = ".claude"            # watched for <id>-signoff.md
```

### `[identity]`
```toml
[identity]
session_id_source = "post_spawn"   # post_spawn | uuid_inject
parent_ancestor_name = "claude"    # process-tree anchor when session_id absent
```

### Session digest — the published digest-record contract (ADR-0019)

<!-- [doc->REQ-TERM-5] The session digest is a PROJECTION of the endpoint's
session logs, never a PTY-byte parse (the superseded source mechanism). ADR-0019
gives it its OWN manifest seam — the `[digest]` extractor above — distinct from
`[history]` (which stays opaque + single-session, feeding the echo-commune
verbatim). The M9 "no manifest seam / rides `[history]`" stance is REVERSED: one
`[history]` normalizer cannot serve both the opaque echo consumer and the
contract-typed digest. What is published here is the digest-record CONTRACT: the
small, fixed, spt-core-owned shape the `[digest]` extractor emits (or that
`api digest-entry` pushes). -->

The `[digest]` extractor (or an `api digest-entry` push) emits the digest-record
contract — a JSON object per record:

```json
{"role": "input", "text": "add a file", "ts": "2026-06-13T21:00:00Z"}
{"role": "agent", "text": "on it"}
{"role": "tool",  "tool": {"name": "Write", "arg": "src/a.rs"}}
```

- `role` — the closed set `input` | `agent` | `tool` (the source tag).
- `text` — the input / agent span (omitted/empty for a `tool` record).
- `tool` — `{name, arg}`, present **iff** `role == "tool"`. Consecutive `tool`
  records collapse into one sprint (unless `sprint_collapse = false`).
- `ts` — optional RFC3339-UTC ordering key; the two-origin merge interleaves
  extracted activity with spt's own injected-context entries by it.

**Forward-compat:** unknown object fields are ignored; a line that is not a valid
record (malformed JSON, missing/unknown `role`, a `tool` record without its `tool`
object) is **dropped with a counted reason** — never silently, never fatal. Run
`spt adapter digest-proof` to see exactly what dropped and why.

**Presentation is spt-core's** (window depth + arg-truncation + sprint-collapse,
adapter-defaulted via `[digest]`, consumer-overridable); **extraction is the
adapter's** (the `[digest]` extractor → the record contract).

<!-- [doc->REQ-DIGEST-CURSOR] -->
**Turn boundaries — classify delivered messages as `input` (binding).** The
projection treats a `role: "input"` record as the **turn boundary** (the unit
`--last`/`seq` count). An adapter's `[digest]` extractor / `api digest-entry`
therefore **MUST classify a delivered user-facing message as a turn-opening
`input`** record (equivalent to a direct PTY user-input) — not as `agent`/`tool`
output. If messaging-delivered turns are not opened as `input`, a messaging-driven
session collapses into a few giant turns and `--last <N>` / `seq` lose their
granularity. *What* becomes an `input` is the adapter's call; that it opens a turn
is the contract.

Two access modes over the digest control channel: a **snapshot pull** (`spt
endpoint digest <id>` — projected on demand) and a **structured-delta stream**
(`spt endpoint digest <id> --follow` — changed turns pushed; deltas driven by
pulls / `api digest-entry` pushes). Autonomous file-watch freshness is deferred to
the consuming frontend milestone. Local addressing only.

<!-- [doc->REQ-DIGEST-CURSOR] -->
**Incremental turn-end consumption — the JSON cursor (since v0.16.0).** The digest
is a rolling semantic **turn** window (a turn is bounded by a user-input). `spt
endpoint digest <id> --json` adds an incremental cursor so a consumer can process
**turn-ends** as they commit, without reprocessing the whole window each call:

- **`--last <N>`** — the last `N` turns (the digest's natural unit). `--last 1` is
  the latest turn — the turn-end output.
- **Per-entry `seq`** — every JSON entry carries a **stable, source-derived** `seq`:
  it is deterministic from the entry's position in the session ledger, so
  **re-projection yields the SAME `seq`** for the same committed entry. It does
  **not** renumber when the rolling window slides (a window slide that drops the
  oldest turn leaves every surviving entry's `seq` unchanged). `seq` is the
  **authoritative dedup + cursor key**.
- **`--after <seq>`** — entries newer than `seq` that are **still in the window**. If
  `seq` has fallen **out** of the window (the consumer fell behind a slide), the
  response is a **full-window refresh plus a predates signal** (mirrors the
  version-slide full-refresh).
- **`partial: true`** — the trailing **in-progress** turn (not yet closed by a
  following user-input) is flagged `partial`, and its entries carry **no stable
  `seq`** until the turn closes (the turn is the commit unit; entries can still
  re-collapse while the turn is open). A consumer **reprocesses** a `partial` turn
  each call and **skips** entries `<= seq` once they commit with a stable seq.
- **`ts`** — emitted per entry where present; `seq` (not `ts`) is the authoritative
  dedup/cursor key.

### `[update]` — adapter self-update
```toml
[update]
avenue = "delegated"               # delegated | file_pull | gh_release
command = "claude plugin update spt"          # delegated
# file_pull: repo + path_regex
uninstall = "claude plugin uninstall spt"     # optional; mirror of install — runs on `adapter remove` once quiesced
```

<!-- [doc->REQ-UPD-9] -->
The **`gh_release`** avenue (since v0.8.0) ships the adapter's updates from its own GitHub releases — no signing tooling and no plugin coupling required of the author:

```toml
[update]
avenue = "gh_release"              # ship updates from this repo's GitHub releases
repo = "user/repo"                 # REQUIRED: whose releases ship updates
asset = "adapter.spt"              # optional; the release asset to fetch (default adapter.spt)
signing_key = "deadbeef…"          # optional Ed25519 (64 hex) — enables fail-closed verify
transport = "auto"                 # optional; https | gh | auto (default auto)
message = "Run `/reload-plugins` in any ongoing sessions."   # optional; shown only on an APPLIED update
```

`spt adapter update [name]` (with no name, every registered `gh_release` adapter; with a name, just that one) compares the repo's latest GitHub release version against the installed adapter version and, when newer, fetches the release `.spt` (the same archive primitive as `spt adapter add --release`), then re-extracts and re-registers it in the adapter's durable `_github/<safe>` home (pointer-mode, re-read live). The network fetch lives in the `spt` CLI, never the daemon. **`repo` is required**; `asset` defaults to `adapter.spt`; `signing_key` is **optional**.

<!-- [doc->REQ-ADAPTER-GH-TRANSPORT] -->
**Transport — public HTTPS or private `gh`.** The optional **`transport`** selects how the asset bytes + the latest-release version are fetched: `https` (direct, the public-repo path), `gh` (shell the pre-authorized [`gh` CLI](https://cli.github.com/) — `gh release download` for the asset, `gh api` for the version — the **private-repo** path), or **`auto`** (the default: prefer `gh` when it is installed and authenticated, else fall back to HTTPS). Because `gh` honors OAuth + `GH_TOKEN`, an adapter shipping from a **private** repo updates with no token in spt-core's hands — spt never reads or stores a credential. `spt adapter add --release` takes `--gh` / `--https` to force the choice (default auto). A signed private adapter's `<asset>.sig` is fetched over the **same** transport, so verification still works. Transport is additive — the verify→extract→register path is unchanged.

<!-- [doc->REQ-ADAPTER-UPDATE-MESSAGE] -->
**Post-update notice — `message`.** The optional **`message`** is a plain (multi-line) human notice the adapter surfaces to stdout **only when `spt adapter update` actually applies an update** (the version changed) — never on a no-op / up-to-date run. It is read from the **newly-installed** manifest and rendered through the same inline-Markdown prose path as `spt`'s help (`**bold**`, `` `code` ``, `[text](url)`); there is no `{key}` substitution. It is avenue-agnostic (`gh_release` / `delegated` / `file_pull`). Use it to tell the operator a required post-update action — e.g. an in-harness adapter announcing "run `/reload-plugins` in any ongoing sessions" so already-running sessions pick up the new plugin.

<!-- [doc->REQ-ADAPTER-UPDATE-POST] -->
**Composite update — `[update.post]` (since v0.16.0).** An optional **avenue-agnostic** sub-table that runs a delegated **post-step** *after* the primary update avenue resolves, in the same `spt adapter update`. It lets an adapter pull its `.spt` from `gh_release` **and** run a second, adapter-owned step (e.g. an in-harness plugin sync) under one lever.

```toml
[update]
avenue = "gh_release"
repo = "user/repo"
message = "Run `/reload-plugins` in any ongoing sessions."   # the static fallback notice

[update.post]
command = "{adapter_dir}/claude-spt post-update"   # REQUIRED, non-empty; install-dir + adapter-static subst
self_verifies = false                              # optional; attestation-only metadata (default false)
```

- **Runs unconditionally** — the post-step fires **even when the primary avenue was a no-op** (already up to date). Its own idempotent check (e.g. `claude plugin update`) decides whether anything changes. `command` is **required** and **non-empty** (an empty `command` is refused at registration). `self_verifies` is **attestation-only metadata** (defaults `false`) — parallel to `[update].self_verifies`; it records that the post-step verifies its own outcome but **gates nothing** (the post-step is already failure-isolated and its exit code is orthogonal), reserved for a future verification lever and inert at the post-step runtime in v0.16.0.
- **Published stdin seam** — spt-core feeds the post-step **one JSON line** on stdin describing the just-resolved update:
  ```json
  {"adapter_applied": true, "adapter_name": "claude-spt", "profile_name": null,
   "version": "0.8.0", "previous_version": "0.7.0", "adapter_dir": "<source_dir>"}
  ```
  - `adapter_applied` — whether the primary avenue applied a new version (false on a no-op).
  - `adapter_name` / `profile_name` — the adapter (and active profile, or `null`).
  - `version` / `previous_version` — the now-installed and prior `[adapter].version`.
  - `adapter_dir` — the resolved `source_dir` (install dir) — the same value `{adapter_dir}` substitutes to.
  - **Additive keys only** — the post-step **must ignore unknown keys** (newer spt-core may add fields).
- **stdout decides the notice** (the post-step knows `adapter_applied` and owns the call):
  - **non-empty custom text** → printed verbatim, and it **supersedes** `[update].message` (a dynamic notice).
  - the **reserved sentinel `!!update-message!!`** alone → fires the static `[update].message` (house `!!x!!` style; a published seam — emit it verbatim and alone).
  - **empty** → **no notice**.
- **exit code is orthogonal to the notice**: `0` = ran OK, nonzero = failed (it does not by itself suppress or change the notice).
- **Notice precedence:** dynamic stdout text > sentinel / `[update].message` > nothing. With **no `[update.post]`**, behavior is unchanged from today: an applied update fires `[update].message`. If the **post-step fails** (nonzero exit / spawn error), spt-core emits a **loud warning** and **falls back** to the today behavior (`adapter_applied` → `[update].message`) — a plugin-sync failure never swallows the adapter notice.
- **Failure-isolated:** a committed `gh_release` pull is **never rolled back** if the post-step fails — the pull and the post-step are independent channels.

Sequence: resolve the primary avenue (pull + re-register if newer) → run the post-step (**always**) → `changed = adapter_applied || post_changed` → if changed, emit the resolved notice.

**Trust — optional signing, fail-closed.** With no `signing_key`, the fetched `.spt` is trusted on HTTPS + GitHub (the same first-acquisition trust `spt adapter add --release` and the installer's first binary fetch place). With a `signing_key`, the fetched `.spt` is verified against a **detached signature** the author publishes as a sibling release asset named `<asset>.sig` — a lowercase-hex Ed25519 signature over the raw archive bytes. Verification runs **after** the bytes are staged and **before** they are extracted/registered, against the **installed** manifest's key (key continuity: a new `.spt` must verify against the key already on the node). A bad, mismatched, or missing signature **refuses** the update — the staged bytes are deleted and never extracted. The author signs their own releases with their own key; spt-core's release key stays scoped to spt-core.

Validation requires `repo` for `gh_release` (`asset` and `signing_key` optional). `spt adapter add` reuses this section as the **install** mechanism (install is the first update; `--github <user/repo>` fetches the manifest first, then installs via the declared `avenue`). `uninstall` (optional) is its inverse, fired by `spt adapter remove` (soft-deregister; runs when no instance is live under the adapter, or immediately with `--force`). Absent `uninstall` ⇒ spt-core's default cleanup (drop its copy for `file_pull`; for `delegated`, just deregister and leave the harness's own files).

---

## Shell adapters (`kind = "shell"`)

A second kind of adapter. Provides a `Shell` endpoint (a driven surface) rather than hosting agents. Same file location (`adapters/`), different manifest body.

```toml
[adapter]
name = "GameRobot"
kind = "shell"                     # vs "harness"
version = "1.0.0"
min_spt_core_version = "1.0.0"

[shell]
spawn = "gamerobot-shell --link {link_token} --root {perch_dir}"   # broker-launched; opaque command template — {perch_dir} = the instance's perch dir (REQ-SHELL-PERCH-DIR: lets the binary resolve a shell_file frame's perch-relative path; opt-in, spawn-only). Program token resolves against the adapter's install dir before PATH, and {adapter_dir} fills — see below.
ephemeral = false                  # manifest property, NOT an agent choice; ephemeral ⇒ no offline perch + no history retention
broadcast = "subnet"               # subnet | same-node | none
command_receipt = "http"           # http | stdin | relay   (how it receives agent commands)
pre_close = "park-and-save"        # optional instruction sent to the binary on link-break
close_timeout_ms = 3000            # graceful termination window before force-close
persistent = true                  # auto-online whenever the owner endpoint is online; a node restart is covered by the boot sweep (see Sleep/wake)
wake_command = "gamerobot-waker --link {link_token}"  # long-running wake-watcher run WHILE offline AND eligible; exit(wake-opcode) ⇒ revive
can_shutdown = false               # if true, the shell may fire `api owner-shutdown` to suspend its owner directly
require_approval = "none"          # none | remembered | always — per-spawn user-approval gate (floor; node/endpoint may tighten)
max_instances_per_owner = 4        # optional cap on existing instances per owner (online+offline both count); omit ⇒ unlimited
over_cap = "reject"                # reject | approve — at the cap: refuse, or require per-spawn approval beyond it (doesn't raise the cap)

[shell.capabilities]               # the command vocabulary (agent→shell) — the toolset
move      = { args = ["direction", "distance"] }
gesture   = { args = ["name"] }
alert     = { args = ["symbol"] }
screenshot = {}

[shell.sensory]                    # the sensory vocabulary (shell→agent, REST-only)
types = ["image", "sound", "event"]   # "event" = arbitrary descriptive payloads
```

The capability + sensory vocabularies live **here**, not on the perch — spt-core resolves them by `adapter_name`. Command-delivery (`command_receipt`) reuses the agent inject-input modes. The shell binary binds via `api bind` (type=Shell, owner from the link) and pushes sensory via `api emit --type <t>`.

**Sleep/wake:** when offline **and eligible** (+ `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 fills `{id}`, `{adapter_name}`, `{link_token}`, `{adapter_dir}` (NOT `{perch_dir}` — that key is spawn-only; the waker fill site carries no perch context), and 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. **Eligibility (BAROMETER W2, releases#78):** a watcher is armed for an instance recorded `offline` that either carries no corpse (cleanly closed, or never launched) or whose corpse's recorded launch **predates the current boot** — a restart casualty. An instance whose binary died **during this boot** (force-kill, crash) arms no watcher and returns only on demand (`relink`, or a waking `shell cmd`), because killing a shell to overwrite its exe is a routine deploy step and a watcher armed underneath would re-lock the file; an instance whose corpse cannot be dated (no launch stamp, or no boot oracle on the platform) is treated the same way rather than guessed at. `persistent` auto-onlines the shell with its owner, and across a **node restart** — where nothing breaks the link, so the instance's record survives the reboot still claiming it is up — the daemon heals that record and a **once-per-daemon-generation boot sweep** relaunches the instance if its adapter is `persistent`, its owner endpoint is online, it is down in fact, and its recorded launch predates the boot instant. `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).

**Instantiation governance:** `require_approval` gates `shell spawn` (reusing the consent grant store — `remembered` persists a grant via allow-always, `always` prompts every spawn); `max_instances_per_owner` + `over_cap` cap how many instances one owner may hold (online + offline both count toward the cap). An instance's **alias** is **not** a manifest field — it is per-instance runtime state on the shell perch's `info.json` (set at `shell spawn --alias` / `shell rename`), since the manifest describes the *adapter*, not individual instances.

## Worked example: `claude-spt` (today's behavior, expressed)

Maps the sister project's actual surface onto the schema.

| Concern | Today (modern SPT) | Manifest expression |
|---|---|---|
| 7 CC hooks | hooks.json → `owl.exe <subcmd>` | `[hooks.*]` table; `Stop.can_inject=false` drives the echo-gate fallback |
| `cc`/`cc <id>` launcher | hand-written shell wrappers | `[adapter] shortcut_basename = "cc"` → the picker bakes `cc-<id>` (spt-core default is `spt-<id>`) |
| Psyche go-live gate | *(declaration only — never spawned)* | `[session.psyche_init]` present ⇒ LiveAgent |
| Psyche turn (the driven role) | `claude -p --resume <psyche-own-uuid> --model sonnet …` | `[session.psyche_resume]` + `{session_id}`/`{parent_session_id}`/`{psyche_context_file}`/`{subnet}` |
| Own-session native resume | `claude -r <uuid> --remote-control <id> …` (resume into existing transcript) | `[session.resume]` + `{session_id}` (resumed id) + resumed-row cwd |
| Echo-commune | `claude -p --model haiku --effort low …`, `OWL_ECHO_COMMUNE=1` guard | `[session.echo_commune]` + `recursion_guard_env` |
| `ccs` fallback | tier-2 binary swap | **node-wide cross-adapter fallback** (not manifest; `ccs` is its own adapter) |
| Transcript read | `.claude/projects/<enc-cwd>/<uuid>.jsonl` | `[history]` (fetcher or locate_normalize) |
| Session digest | extract `.claude/…/<uuid>.jsonl` → `{role,text,tool,ts}` | `[digest].extractor` (own seam, ADR-0019; not `[history]`) |
| Commune/signoff | `.claude/<id>-commune.md` watched | `[session].commune_dir/signoff_dir`; filenames fixed |
| Env | `$OWL`/`$LIVE`/`OWL_SESSION_ID` via `CLAUDE_ENV_FILE` | `[env]`; PATH removes most; channel only harness-hosted |
| 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
- 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).
