# 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-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_init]` runner, 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
[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-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}`, `{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).

<!-- [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.

<!-- [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
```

### `[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/projects/{project}/{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. spt-core
  resolves the `source` file, fills `{source}` with its path, pipes the bytes on
  stdin, and runs the command bounded; its stdout is contract JSONL (one record
  per line). 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.
- **`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.
- **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_init]`: 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"}`, … (extensible vocabulary).
- **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.
- The v0.11.0 raw `payload+\r` inject is the **degenerate** case (a binary that emits `{"text":payload}{"key":"enter"}` with no choreography).

### `[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
version_check = true               # verify spt-core satisfies min_spt_core_version before/after
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}"   # broker-launched; opaque command template
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
wake_command = "gamerobot-waker --link {link_token}"  # long-running wake-watcher run WHILE offline; 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 (+ `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).

**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 init | `claude -p --agents … --model sonnet --fallback-model opus …` | `[session.psyche_init].command` (opaque) |
| Psyche resume | `claude -p --resume <uuid> --model sonnet …` | `[session.psyche_resume]` + `{session_id}` |
| 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).
