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

_Avoid_: treating strings as config knobs for spt-core itself (those are global settings); "adapter KV store" as a separate registry; putting user files in the adapter-shipped `strings/` dir (clobbered by updates — use a local profile).

**manifest substitution in `[strings]`** (ratified 2026-06-25, v0.8.0 update-arc grill):
`get-string` resolves a set of **adapter-static** substitution keys inside a returned string value at **read time** (lazily, like file-backed strings): **`{adapter_dir}`** — the registry record's precise `source_dir` (the install dir; survives updates; the same dir bare-program resolution already uses) — and **`{adapter_name}`**. Session-scoped keys (`{id}`/`{session_id}`/…) are **not** available: `get-string` carries no session context today, and a `get-string --session-id` for session-scoped substitution is a deferred, larger change. The load-bearing invariant is preserved: **spt-core still never executes a string** — it substitutes and returns; the *adapter's own wrapper* executes the result. Canonical use: a harness hook dispatcher resolves its own packed binary via `get-string` (e.g. value `"{adapter_dir}/claude-spt hook"`) **once per session into an env var**, then runs it per-hook — so hook *logic* rides `spt adapter update` (it lives in the adapter binary) and the plugin's `hooks.json` + a thin static dispatch wrapper go static-forever. This **supersedes a rejected `spt api run-hook`** (which would have made spt-core itself execute an adapter handler — a new execution surface; rejected in favor of resolve-not-execute).
_Avoid_: session-scoped substitution through bare `get-string`; reading this as spt-core executing a string (it never does — the adapter wrapper executes the resolved value).

**File-backed strings** (M12-W3): a `[strings]` value MAY be a **file pointer** instead of an inline literal — a value-position table with **exactly one** key `file`: `skill = { file = "skill.md" }`. `get-string` resolves it to the file's **contents** (so large bodies — skill-instructions, hint text — stay out of the manifest). The exactly-one-key rule is the disambiguation: any other table shape stays an opaque nested strings tree (existing trees untouched), and `{ file = … }` is reserved as the pointer form (it can't double as inline data). Files live in the adapter's per-adapter aux dir **`adapters/<adapter>/strings/`** (sibling of `profiles/`), referenced by a path relative to it that **must stay inside that dir** (HAZARD-class containment: `..` traversal and absolute paths are refused at registration). A **local** profile's own file pointers resolve against the user-owned local-profile dir, not the adapter-shipped `strings/` (which adapter updates overwrite) — so a local override survives updates; equivalently a local profile may just inline a literal. Pointers are **validated at registration** (fail-fast on an escaping/missing pointer — records nothing) and **read lazily** at `get-string` (live file edits reflect without re-register); a missing/unreadable file at read time **skip-diagnoses** (emits a diagnostic, returns "not set" — never a silent drop or hard error, mirroring `[digest]`).

+**manifest substitution in `[strings]`** (ratified 2026-06-25, v0.8.0 update-arc grill):

+`get-string` resolves a set of **adapter-static** substitution keys inside a returned string value at **read time** (lazily, like file-backed strings): **`{adapter_dir}`** — the registry record's precise `source_dir` (the install dir; survives updates; the same dir bare-program resolution already uses) — and **`{adapter_name}`**. Session-scoped keys (`{id}`/`{session_id}`/…) are **not** available: `get-string` carries no session context today, and a `get-string --session-id` for session-scoped substitution is a deferred, larger change. The load-bearing invariant is preserved: **spt-core still never executes a string** — it substitutes and returns; the *adapter's own wrapper* executes the result. Canonical use: a harness hook dispatcher resolves its own packed binary via `get-string` (e.g. value `"{adapter_dir}/claude-spt hook"`) **once per session into an env var**, then runs it per-hook — so hook *logic* rides `spt adapter update` (it lives in the adapter binary) and the plugin's `hooks.json` + a thin static dispatch wrapper go static-forever. This **supersedes a rejected `spt api run-hook`** (which would have made spt-core itself execute an adapter handler — a new execution surface; rejected in favor of resolve-not-execute).

+_Avoid_: session-scoped substitution through bare `get-string`; reading this as spt-core executing a string (it never does — the adapter wrapper executes the resolved value).

**keyword hints** (ratified 2026-06-12 — core milestone A):

Once-per-session usage/syntax hints, a first-class adapter feature: the manifest's `[hints]` section declares entries of `{keywords (literal default, regex opt-in), text}`; the adapter's user-prompt hook pipes the **full user message** to `spt api hint --session <id>` (stdin) and receives matched hint lines (`keyword hint for SPT adapter <name>: "<kw>"-->{text}`) for its context-injection channel. The daemon keeps a per-session seen-set — each hint fires **once per session** (a `/clear` mints a new session, naturally re-arming) — and emits at most **one hint per message**. **Tiebreak when a message matches multiple hints:** scan in declaration order and emit the FIRST match whose hint this session has not yet seen; if the declaration-order-first matching hint is already seen, fall through to the next unseen matching hint (the once-per-session and ≤1-per-message invariants both still hold). Emit nothing only when every matching hint is already seen. Employing the hook is the adapter dev's choice; profiles override/extend `[hints]` by leaf-replace like any section.


**Q1 locked + recorded** in CONTEXT.md (new glossary entry: `[strings]` substitution, adapter-static keys, resolve-not-execute, run-hook superseded). Two F-findings to file later: `{adapter_dir}`/`{adapter_name}` keys + lazy `[strings]` substitution. ADR queued (resolve-not-execute over run-hook) — I'll draft once the whole update-arc is shaped.

---

**Q2 — Ask 3: `[message-idle-translation-binary]` takes a command, not a bare `path`.**

Survey confirms: today `IdleTranslationBinary { path: String }` — spawned with **no args**, resolved against `install_dir`, stdin JSON-lines protocol. `[digest].extractor` and `[session.psyche_init]` already take `command` strings. Ask 3 wants the same so `command = "claude-spt translate"` can fold translate into the consolidated binary (the bare `path` can't carry the `translate` subcommand arg). Note: Q1's `{adapter_dir}` doesn't solve this — the blocker is **args**, not path resolution.

**My recommendation:**
- **Add a sibling optional `command` field; keep `path`.** Don't overload `path` to mean either — a distinct `command` mirrors `[digest]`/`[session.*]` exactly and reads unambiguously. The struct already doesn't `deny_unknown_fields` (forward-compat by design), so old/new spt-core both parse.
- **Exactly one of `{path, command}`** — both-set is refused at registration (validation), neither-set is "no translation binary" (today's absence).
- **Spawn/stdin contract UNCHANGED** — still the `Init`/`Event`/`Input` stdin JSON-lines + `{key}`/`{text}`/`{delay_ms}`/`{commit}` stdout. `command` changes *only* how the executable+args are located/launched: the program token resolves against `install_dir` first (REQ-INSTALL-11, same as digest/psyche), and `{adapter_dir}`/`{adapter_name}` subst is available for free (Q1).
- Resident-binary lifecycle (spawn-at-bringup, REAP_GRACE drop, W3d live-update respawn) is identical — `read_translation_path` becomes `read_translation_command`.

**Confirm:** sibling `command` (not overloading `path`), `path` retained back-compat, both-set refused, protocol untouched? Or do you want `path` formally **deprecated** (warn at registration) since the goal is one consolidated binary and bare-path is the thing you're retiring?


given there are no other adapters developed, we can deprecate `path`. we just need to make sure we update the public-facing docs, incl. gh pages/integration checklist.


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


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


**Q2 locked:** sibling `command` field becomes canonical; `path` **deprecated** — keeps parsing (manifest forward/back-compat hygiene — don't hard-remove) but emits a registration warning steering to `command`. Both-set refused, protocol untouched, `read_translation_path`→`read_translation_command`. Doc surfaces to update in the same change: **`docs/MANIFEST.md`** (the seam), **`reference.md`** (xtask gen), and **gh-pages prose** (integration-checklist + messaging pages reference the translation binary). No CONTEXT.md change — it's a field-shape, not a glossary term. F-finding will carry the doc-drift gate.

---

**Q3 — Ask 2: composite `[update]` = `gh_release` pull **+** a delegated post-step.**

Today `avenue` is exactly one of `{gh_release, delegated, file_pull}` — mutually exclusive. claude-spt needs the `.spt` pull **and** `claude plugin add|update` in one `spt adapter update`. Survey confirms: `adapter_update_notice()` prints `message` only on a real version-changed apply; the CLI owns the gh_release path.

**My recommendation — four sub-rulings:**

1. **Shape: an additive `[update.post]` sub-table, avenue-agnostic** (runs after the primary avenue resolves, regardless of which). Not a `post_update` field bolted onto `gh_release` (too coupled), not an ordered-avenue list (over-general). `[update.post] = { command, self_verifies }`. For claude-spt: `avenue = "gh_release"` + `[update.post].command = "{adapter_dir}/claude-spt post-update"` (and `{adapter_dir}` subst is free from Q1). Mirrors that `message` is already avenue-agnostic.

2. **2a — post-step runs UNCONDITIONALLY.** Decouple it from the version-changed gate: every `spt adapter update` runs the post-step even when the `.spt` was a no-op. Safe because the post-step (`claude plugin update`) does its *own* idempotent version check — the plugin channel decides if there's anything to do.

3. **2b — changed-signal = a reserved stdout sentinel, exit code = success/failure (orthogonal).** The post-step prints e.g. `POST_UPDATE_CHANGED` to stdout when it applied something; exit 0 = ran OK, nonzero = errored. Keeps "did it change?" (stdout, drives the message) separate from "did it fail?" (exit) — matches how spt-core already uses stdout markers + exit codes. Then **message gate = (adapter_applied OR post_changed)**; neither ⇒ suppress (preserves today's no-noise-on-no-op). The `message` text still comes from the manifest, never the post-step.

4. **Trust + failure isolation.** Post-step inherits the `delegated` posture — carries its own `self_verifies` (true here: `claude plugin update` is CC's own verified channel; spt-core just runs the declared command). **Failure is isolated:** if the gh_release pull committed but the post-step fails, do **not** roll back the adapter — they're independent channels; surface a loud warning (and the `message` still guides the manual plugin step). Adapter-update success ≠ post-step success.

Sequence: pull+re-register (if newer) → run post-step (always) → `changed = adapter_applied || post_changed` → if changed, print `message`.

**Push-back points:** (a) `[update.post]` sub-table vs you preferring it scoped under `gh_release`; (b) stdout-sentinel-for-changed vs a dedicated exit code (e.g. exit 10 = changed); (c) failure isolation — agree the adapter update should stand even if the plugin post-step fails, or do you want all-or-nothing?


Enable adapter string substitution with adapter_dir key


1. agree 2. agree 3. suggestion: let it be a sentinel OR dynamic update message via stdout, where the stdout message, if there is one, supercedes `[update.message]`. if the sentinel is there instead of any custom message, that's when `[update.message]` fires. -another suggestion: pass in `adapter_applied` via stdin, publish the seam so the `[update.post]` binary can choose to formulate its output based on that result 4. agree -


Both refinements are good — the second one (`adapter_applied` on stdin) makes the post-step a *fully-informed* arbiter, which lets me simplify the whole message-precedence. Consolidated 2b contract:

**The `[update.post]` seam (published, forward-extensible):**
- spt-core runs the post-step with a **single JSON line on stdin**:
  ```json
  {"adapter_applied": true, "adapter_name": "claude-spt", "version": "0.8.0", "previous_version": "0.7.0", "adapter_dir": "<source_dir>"}
  ```
  Additive keys only (post-step ignores unknown) — documented in `MANIFEST.md` + the integration checklist.
- **post-step stdout decides the notice** (it knows `adapter_applied`, so it owns the decision):
  - **custom text** → print it, **supersedes** `[update].message` (dynamic notice — e.g. "plugin → 0.1.7, run `/reload-plugins`" with the real version).
  - **reserved sentinel** (alone) → fire the static manifest `[update].message`.
  - **empty** → no notice.
- **exit code orthogonal**: 0 = ran OK, nonzero = failed.

**Failure / no-post-step fallbacks (preserve today's behavior):**
- **No `[update.post]` declared** → exactly today: `adapter_applied` → `[update].message`. Zero change for existing adapters.
- **Post-step present but FAILS** (nonzero) → loud warning **+** fall back to today's rule (`adapter_applied` → `[update].message`), so a plugin-sync failure never swallows the adapter-update notice. Adapter update still stands (#4 failure-isolation).

Net: with a post-step, it's the message arbiter **on success**; on failure or absence, spt-core's existing `adapter_applied`→message rule carries. This is impl/seam detail → goes to `MANIFEST.md` + the F-finding, not CONTEXT.md (no new glossary term).

**Ratify the precedence** (dynamic stdout > sentinel/manifest-message > nothing; failure ⇒ warn + fallback to manifest-on-applied)? Then I'll move to the standalone CLI asks.


-include `profile_name` in the stdin JSON line. -agree with all


**Q3 ratified** — `profile_name` added to the stdin JSON (post-step can branch per-profile; `null` for base). Locked: dynamic-stdout > sentinel/manifest-message > nothing; failure ⇒ warn + fallback to manifest-on-applied; no-post-step ⇒ today's behavior. → `MANIFEST.md` + F-finding.

Doc-asks 1–3 done. ADR-0005 rename honored throughout. Now the **standalone CLI asks** — two quick ones first (low-controversy):

**Q4a — Ask A: remove `--reply-to`.** Survey: it only sets the target-fallback + the "REPLIED" label; no wire effect (ADR-0020 already structural). **Recommendation: hard-remove, not deprecate** — a flag that "confuses agents" shouldn't linger behind a warning that still mentions it. Touches `cli.rs` (flag + `is_reply` label → always SENT/QUEUED), the `send` how-to string, and the `REQ-DOCS-6` title. One user, so the breaking-CLI cost is nil.

**Q4b — Ask D: empty scope → creation flow.** Survey: the picker **always** opens on `PickExisting` (`picker/model.rs:477`); empty just renders a blank list. **Recommendation:** in `PickerModel::new`, when the endpoint set is empty, open on `Screen::CreateAdapter` instead. The one judgment is *which* emptiness counts — I recommend **`gather_endpoints()` totally empty** (nothing attachable anywhere — local *or* subnet), which matches your "subnet (or node) has no endpoints." A node *with* subnet endpoints but no local ones still has things to pick → stays on the picker.

**Confirm:** (a) hard-remove `--reply-to` (vs deprecate); (b) trigger the create flow on **total** emptiness (vs only the focused category, e.g. project-scope empty even when a subnet has endpoints)?


Q4a: agree. Q4b: agree


Q4 done. Now the meaty one.

**Q5 — Ask B: `spt endpoint digest --last <N> --json --after <line>`.**

Here your mental model and the code's model diverge, so let me reconcile before shaping flags. The digest is **not** a line-addressable log — it's a **rolling semantic `Turn`-window** (default 3 turns, oldest→newest). Turns carry **no line numbers** (line metadata is dropped at projection, `spt-term/projection.rs`). `--json` **already exists** (emits the `Digest` struct). And there's already a monotonic **`version`** cursor for `--follow`.

So "line number" / `--after <line>` assume an addressing model that isn't there. The precise concept you want is **a monotonic cursor to fetch "what's new since I last looked"** — which the system can give, just not as file-lines.

**My recommendation:**
- **`--last <N>` = last N turns** (the digest's natural unit). `--last 1` = the latest turn = the agent's most recent turn-end output. Use case served cleanly.
- Keep `--json`; **add a stable monotonic `seq`** to each turn/entry in the JSON output (call it `seq`, **not** "line" — there are no lines).
- **`--after <seq>`** = entries newer than `<seq>` still in the window. If `<seq>` predates the window (consumer fell behind), return the full window + signal it (same semantics as the existing version-slide full-refresh). This is your `--after <line>`, renamed to match reality.

**Three things to settle:**
1. **`--last` unit — turns** (recommend) or flattened entries (agent/tool/context items)? Turns map to "turn-end output"; flattened is finer but noisier.
2. **Cursor — a new per-entry `seq`** (recommend) vs. reusing the coarser per-update `version` vs. genuinely threading line numbers through projection (I'd reject — fragile, no real source).
3. **Fidelity — the big one.** The digest is a *lossy presentation*: tool args truncate at 25 chars, tool sprints collapse. Agent *prose* text looks preserved, but confirm: for "process the endpoint's output on turn end," is the digest's agent text enough? Or do you actually want the **raw, un-truncated turn output** — which would be a tail of the underlying history (`digest.log` / the `[digest]` extractor output), a *different* source than the presentation digest? This decides whether Ask B augments `digest` or needs a raw-output surface.


question: will digests surface a partial turn as it's being built? i.e. agent is busy and has output some text during it, but hasn't ended the turn yet. -agree with recs --- 1. rec ok 2. rec ok 3. yes, digest's agent text is enough.


**On your question — yes, the digest surfaces a partial, in-progress turn.** The projection's "turn" is bounded by **user-input** boundaries (input opens it, the *next* input closes it), **not** the harness Stop event. So the latest `Turn` accumulates agent/tool entries as they're produced, and a snapshot re-projects live on each call (`project_endpoint_digest` runs the extractor on-demand; `--follow` streams deltas). So mid-turn — agent busy, some text emitted, no Stop yet — a pull returns that partial turn.

Granularity caveat: it reflects whatever the **source** has flushed at that instant — Path A (CC `[digest]` extractor) sees what the harness has written to its transcript (≈ per-assistant-message, not per-token); Path B sees what's been `api digest-entry`-pushed. Not spt-core's concern which, but the honest answer is "partial yes, at message-ish granularity."

**This actually reinforces Q5's rec #2:** since the open turn *grows* between polls, a **per-entry `seq`** (not the per-update `version`) is the right cursor — `--after <last-seq>` cleanly returns just the newly-appended entries of the still-open turn. Good.

**Q5 locked:** `--last N` = turns; per-entry `seq`; `--after <seq>`; digest fidelity sufficient; partial turns surface (existing behavior, documented in the F-finding). → augments `digest`, no new source needed.

---

**Q6 — Ask C: `--json` for all status queries.**

Survey: ~14 read/status commands, only `endpoint digest` has `--json` today, **no shared formatter**, most row structs aren't `Serialize` yet.

**My recommendation:**
- **A global `--json` flag** (clap `global = true`) rather than 14 per-command flags — uniform, discoverable, and a new query command inherits it. **Scoped to the read/status set** (`endpoint list`/`whoami`, `daemon status`, `subnet status`/`show-code`, `endpoint description`/`role`, `adapter list`/`version`, `notif list`, `grant list`, `access list`, `shell list`, `how-to`). Action commands don't honor it.
- **A shared `print_json()` helper + a coverage test** asserting every command in that set emits valid JSON under `--json` — that's the guard against silent drift the missing-formatter invites.
- **The big one — JSON output is a *consumed contract*** (CONTEXT.md's wire-parity concern: adapters will parse it). So **don't serialize internal structs verbatim** (`AdapterRecord`, `SubnetStatusRow`, …) — their fields would become a public API and internal refactors would silently break consumers. Define explicit, minimal **output DTOs** per command with committed field names. More work, but it's the difference between a stable surface and accidental breakage.

**Settle:** (1) global `--json` vs per-command; (2) confirm the read/status scope above (anything to add/drop?); (3) **explicit output DTOs** (recommend) vs. just serializing the existing structs and accepting churn risk?


question: will digests surface a partial turn as it's being built? i.e. agent is busy and has output some text during it, but hasn't ended the turn yet. -agree with recs --- 1. rec ok 2. rec ok 3. yes…


for the public-facing digest docs--which likely includes the integration checklist--we should advise that any adapter's `digest extractor` or `api digest-entry` disambiguates user-input thoroughly. for example, "spt messages from the user" would be easy to accidentally treat as non-turn-boundaries, but should ideally be deemed equivalent to normal user-input. else sessions driven by user messaging (and not direct PTY entry) would contain very few turns that are very long. --- Q6: agree on all recs.


Good catch — that's a real digest-quality trap. **Captured as binding doc-guidance for the Ask B finding + integration checklist:** an adapter's `[digest]` extractor / `api digest-entry` must classify **delivered user-facing messages as turn-opening `input`**, equivalent to direct PTY user-input. Otherwise a messaging-driven session (no direct typing) collapses into a few giant turns and `--last 1` / the `seq` cursor lose all granularity. The projection treats `role:input` as the boundary; *what becomes `input`* is the adapter's call — so the docs must teach it explicitly.

**Q6 locked:** global `--json`, read/status scope, shared `print_json` + coverage test, explicit output DTOs (stable contract).

---

**Q7 — Ask E: `spt rc` top-right identity marker `SUBNET : ENDPOINT_ID @ NODE`.** The last ask, and technically the hardest — so let me be straight about the cost before recommending.

**The reality:** `rc` is a **raw byte pump with no grid model**. A *floating, always-visible, rounded-rectangle corner box* over a stream we don't parse is fundamentally fragile: the harness inside (CC uses the **alt-screen** + its own cursor moves/repaints) will overwrite the box on its next paint, and our un-bracketed writes can corrupt its display. Doing it *correctly* means `rc` parses the PTY into a screen grid and composites the overlay every render — i.e. **becoming a terminal multiplexer** (a vt100 core). That's a major feature, not a v0.8.0 line item (and the survey already flagged grid-model clip/pad as deferred).

**So the goal — "always grok which endpoint you control" — is achievable cheaply; the literal rounded-corner-box isn't.** Options:

1. **Reserved status row via scroll-region (my primary rec).** The tmux/screen trick — *no* grid model: set `DECSTBM` to reserve the **top row**, shrink the PTY's reported rows by 1, and own that row with a right-aligned `SUBNET : ID @ NODE`. Bounded work: re-assert the margin + repaint when we detect alt-screen enter / `DECSTBM` resets / resize (same output-scanning pattern the existing `mouse_scanner` already uses). Robust enough; always visible.
2. **Window title via `OSC 2` (near-free complement).** Emit `\e]2;SUBNET:ID@NODE\a` on attach → shows in the terminal's title/tab. Zero grid, zero repaint race. Caveat: a harness that sets its own title overwrites it (re-assert periodically). Cheapest "always know" win.
3. **Full floating rounded-rect box** — defer to a future grid-model `rc` (the same lift that'd give viewers real clip/pad).

Plus an **identity-resolution** sub-point: subnet/node/id aren't at the pump callsite today — resolve once at attach (cheap perch/registry read) and thread in. And **which subnet** when an endpoint is in several? I'd show its **home/primary subnet**, `"local"` if none.

**Decisions:** (1) accept deferring the literal floating box and meet the goal with **reserved status row (#1) + window title (#2)**? Or do you specifically want the boxed-corner look enough to fund the grid-model `rc`? (2) status row **top** (matches "top-right") or **bottom** (more conventional, less likely to fight an app's header)? (3) subnet display = home/primary (vs the subnet you reached it through)?


re-Q6: could `digest --json` assign UUIDs to each individual entry, such that if a digest parser sees the same entry twice in a seq that has otherwise changed, it can know that it already processed that line before? or maybe just providing timestamps for each entry is sufficient, knowing that an agent output entry's timestamp can't change after it's committed-->parser tracks the last entry timestamp it processed, never processes any entry with the same-or-earlier timestamp. --- Q7: 1. rec ok + cyan status text color. 2. shouldn't overwrite window title as harnesses like CC use it to indicate agent busyness 3. sounds good. am not very eager to try the grid-model rc/terminal multiplexer. at that point, you're better off just building the more "beautiful" web-based spt-core GUI that likely has some mention in CONTEXT.md -agree with your "which subnet" rec.


Both land cleanly. Locking the last two:

**Re-Q6 — entry idempotency.** Your instinct is right; let me make it one field instead of two. Don't add UUIDs (they give identity but not *order* — and `--after` needs order), and don't lean on timestamps (the survey shows `ts` is **optional/absent** on agent/tool records and can ms-collide). Instead, **define the `seq` from Q5 as a stable, *source-derived*, committed-only id** — and it serves *both* the cursor and dedup:
- **Source-derived, not window-position-derived** — `seq` is a deterministic function of the entry's append position in the source (transcript record index across the session ledger / `digest.log` append index), so a re-projection (Path A re-extracts every pull) yields the **same** `seq` for the same entry. This is the key: a position-in-window seq would renumber on slide (exactly your "same entry, changed seq" fear); a source-derived one never does.
- **Committed-only.** A still-growing in-flight entry is flagged `partial: true` with **no stable seq**; it gets its seq when finalized. Parser: track last-processed `seq`, skip `<= seq`, always reprocess `partial` until it commits.
- Also emit `ts` per entry where present (handy for humans/ordering), but **`seq` is the authoritative dedup+cursor key.**

So: parser tracks last `seq`, never reprocesses `<= seq` — your dedup goal, with a guaranteed-present, guaranteed-monotonic, re-projection-stable integer.

**Q7 locked:** reserved **top** status row via `DECSTBM` (right-aligned `SUBNET : ID @ NODE`, **cyan**), re-asserted on alt-screen/`DECSTBM`-reset/resize; **no window title** (CC owns it for busyness — dropped OSC entirely); subnet = home/primary (`"local"` if none); floating rounded-box **deferred to the future web GUI** (CONTEXT.md's GUI), not a grid-model `rc`.

---

That's **every ask resolved.** Writing the consolidated spec the build will follow:


Write

# v0.8.0 — adapter-update arc + CLI/UX features (design)

> Source: grill-with-docs session 2026-06-25 (operator + doyle), against
> `../spt-claude-code/UPDATE-NAMING-DOYLE-ASKS.md` (ADR-0005/0006 over there) plus five
> operator-direct CLI asks. This is the spec the v0.8.0 build follows. Each item lands as a
> tagged `REQ-*` in `traceable-reqs.toml`; public-doc surfaces noted per item.

## Decision cluster A — the one-lever adapter-update arc (claude-spt's asks 1–3)

### A1. Hook logic rides `adapter update` via resolve-not-execute (REJECTS `spt api run-hook`)

The proposed `spt api run-hook <adapter> <event>` (spt-core *executes* an adapter hook handler) is
**rejected** — it adds a new spt-core execution surface. Instead, two general primitives let the
adapter resolve+run its **own** packed binary, with spt-core staying a pure resolver:

1. **`{adapter_dir}` + `{adapter_name}` substitution keys.** `{adapter_dir}` = the registry
   record's precise `source_dir` (install dir; survives updates; the dir bare-program resolution
   already uses). Generally available wherever command/string substitution runs.
2. **Lazy substitution inside `[strings]` values at `get-string` read time** (like file-backed
   strings resolve lazily), scoped to **adapter-static keys only** (`{adapter_dir}`,
   `{adapter_name}`). Session-scoped keys (`{id}`/`{session_id}`/…) are **not** available —
   `get-string` carries no session context; a `get-string --session-id` is a deferred, larger
   change (open the door only when a real use case needs it).

**Invariant preserved:** spt-core never executes a string — it substitutes and returns; the
adapter's own wrapper executes the result. Canonical use: a CC hook dispatcher does
`get-string claude-spt hook_cmd` → `"{adapter_dir}/claude-spt hook"` **once per session into an
env var**, then runs it per-hook. Hook *logic* lives in the adapter binary (rides
`spt adapter update`); the plugin's `hooks.json` + a thin static per-OS dispatch wrapper go
static-forever. Honest residuals: (a) ~2 spawns per hook unless the resolved path is memoized
per session (it should be, for the PostToolUse hot path); (b) the dispatch wrapper is the one
plugin-resident piece — generic, static, but still a per-OS `.sh`/`.cmd`.

→ `REQ-MANIFEST-SUBST` (the two keys + `[strings]` substitution). ADR: resolve-not-execute.
Docs: MANIFEST.md (strings + substitution-key table), integration checklist (the hook-dispatch
pattern), CONTEXT.md (done — `[strings]` substitution glossary entry).

### A2. `[message-idle-translation-binary]` takes a `command`; `path` deprecated

Add a sibling optional **`command`** field (opaque, args + `{key}`/`{adapter_dir}` subst,
resolved against `install_dir` like `[digest].extractor`/`[session.psyche_init]`). `path` is
**deprecated** — keeps parsing (manifest forward/back-compat hygiene) but warns at registration
steering to `command`. **Exactly one of `{path, command}`** (both-set refused; neither =
no translation binary). Spawn/stdin contract unchanged (`Init`/`Event`/`Input` JSON-lines in;
`{key}`/`{text}`/`{delay_ms}`/`{commit}` out). `read_translation_path` → `read_translation_command`.
Unblocks folding `claude-spt translate` into the one consolidated binary.

→ extend the translation-binary REQ (REQ-HOST-* / ADR-0022 lineage) or `REQ-TRANSLATE-COMMAND`.
Docs: MANIFEST.md (seam), reference.md (xtask gen), gh-pages integration-checklist + messaging.

### A3. Composite `[update]` — `gh_release` pull + a delegated post-step

Add an **avenue-agnostic `[update.post]` sub-table** (runs after the primary avenue resolves,
regardless of which) = `{ command, self_verifies }`. For claude-spt: `avenue = "gh_release"` +
`[update.post].command = "{adapter_dir}/claude-spt post-update"`.

- **Runs unconditionally** (even when the `.spt` was a no-op) — the post-step does its own
  idempotent check (`claude plugin update`).
- **Published stdin seam** — one JSON line in:
  ```json
  {"adapter_applied": true, "adapter_name": "claude-spt", "profile_name": null,
   "version": "0.8.0", "previous_version": "0.7.0", "adapter_dir": "<source_dir>"}
  ```
  Additive keys only (ignore unknown). Documented in MANIFEST.md + integration checklist.
- **stdout decides the notice** (post-step knows `adapter_applied`, owns the call):
  - **custom text** → print it, **supersedes** `[update].message` (dynamic notice).
  - **reserved sentinel** alone → fire the static `[update].message`.
  - **empty** → no notice.
- **exit code orthogonal**: 0 = ran OK, nonzero = failed.
- **Message gate / fallbacks:** dynamic-stdout > sentinel/manifest-message > nothing. With a
  post-step it's the arbiter **on success**. **No `[update.post]`** ⇒ today's behavior exactly
  (`adapter_applied` → `[update].message`). **Post-step fails** ⇒ loud warning **+** fall back to
  `adapter_applied` → `[update].message` (a plugin-sync failure never swallows the adapter notice).
- **Failure isolation:** a committed `gh_release` pull is **not** rolled back if the post-step
  fails — independent channels.

Sequence: pull+re-register (if newer) → run post-step (always) → `changed = adapter_applied ||
post_changed` → if changed, emit the resolved notice.

→ `REQ-ADAPTER-UPDATE-POST`. ADR: same update-arc ADR. Docs: MANIFEST.md (`[update.post]` +
stdin seam), integration checklist.

## Decision cluster B — standalone CLI/UX asks

### B1. Remove `--reply-to` from `spt send` (hard remove)

Hard-remove (not deprecate) — a flag that confuses agents shouldn't linger behind a warning.
Only touches `cli.rs` (flag + the `is_reply`→"REPLIED" label → always SENT/QUEUED), the `send`
how-to string, and the `REQ-DOCS-6` title. No wire impact (ADR-0020 already structural). One user
→ breaking-CLI cost nil.

→ amend REQ-DOCS-6 + a removal note (no new REQ, or a tiny `REQ-SEND-REPLYTO-REMOVE`). Docs:
how-to, reference.md.

### B2. `spt endpoint digest` — `--last <N>`, `--json` seq, `--after <seq>`

The digest is a **rolling semantic Turn-window** (default 3, oldest→newest), re-projected live on
each call; **partial in-progress turns surface** (turn bounded by user-input, not the harness Stop;
message-ish granularity). Augment the existing `--json`:
- **`--last <N>` = last N turns** (the natural unit; `--last 1` = latest turn = turn-end output).
- **Per-entry `seq`** in the JSON: **stable, source-derived** (deterministic from the entry's
  append position in the source — transcript record index across the session ledger / `digest.log`
  index), so re-projection yields the same `seq`. **NOT** window-position-derived.
- **`--after <seq>`** = entries newer than `seq` still in the window (full window + signal if `seq`
  predates it — consumer fell behind; mirrors the existing version-slide full-refresh).
- **Committed-only seq:** an in-flight entry is `partial: true` with no stable seq until finalized;
  consumer reprocesses `partial` until commit, else skips `<= seq`. Also emit `ts` per entry where
  present (`seq` is the authoritative dedup+cursor key).
- **Fidelity:** the digest's agent text is sufficient for turn-end processing (no raw-source mode
  needed).

**Binding doc-guidance (integration checklist + digest docs):** an adapter's `[digest]` extractor /
`api digest-entry` MUST classify **delivered user-facing messages as turn-opening `input`**
(equivalent to direct PTY user-input). Else messaging-driven sessions collapse into a few giant
turns and `--last`/`seq` lose granularity. The projection treats `role:input` as the boundary;
*what becomes `input`* is the adapter's call.

→ `REQ-DIGEST-CURSOR` (extends REQ-TERM-4/5). Docs: reference.md, gh-pages digest/integration.

### B3. Global `--json` for status queries + stable output DTOs

A **global `--json` flag** (`global = true`), honored by the **read/status set**: `endpoint list`/
`whoami`, `daemon status`, `subnet status`/`show-code`, `endpoint description`/`role`,
`adapter list`/`version`, `notif list`, `grant list`, `access list`, `shell list`, `how-to`.
Action commands don't honor it. Add a shared **`print_json()`** helper + a **coverage test**
asserting every command in the set emits valid JSON (guards the missing-formatter drift). **Define
explicit output DTOs** per command (committed field names) — do **not** serialize internal structs
verbatim (their fields would become a public contract; refactors would break consumers). JSON is a
consumed wire-parity surface.

→ `REQ-CLI-JSON`. Docs: reference.md per command, gh-pages.

### B4. `spt endpoint run` / bare `spt` — empty scope → creation flow

In `PickerModel::new`, when `gather_endpoints()` is **totally empty** (nothing attachable
local *or* subnet), open on `Screen::CreateAdapter` instead of `PickExisting`. A node with subnet
endpoints but no local ones still has things to pick → stays on the picker.

→ `REQ-RUN-EMPTY-CREATE` (extends REQ-RUN-PICKER). Docs: reference.md.

### B5. `spt rc` top-right identity marker

A reserved **top status row** via `DECSTBM` scroll-region (shrink the PTY's reported rows by 1,
own the row), right-aligned **`SUBNET : ENDPOINT_ID @ NODE`**, **cyan** text. Re-assert the margin
+ repaint on alt-screen enter / `DECSTBM` reset / resize (scan output like the existing
`mouse_scanner`). **No window title** (CC owns it for busyness — OSC dropped). Resolve
subnet/node/id once at attach (perch/registry read), thread into the pump; subnet = the endpoint's
**home/primary** (`"local"` if none). The literal floating rounded-rect box is **deferred to the
future web GUI** (not a grid-model `rc` — that lift is better spent on the GUI).

→ `REQ-RC-IDENTITY`. Docs: reference.md, gh-pages.

## Cross-cutting

- **ADR-0005 repo rename** (`spt-claude-code` → `claude-spt`) is honored: `[update].repo`,
  install-dir derivation (`adapters/_github/SaberMage-claude-spt`), README/CI/package scripts
  expect the new slug. Heads-up only — not re-litigated.
- **Traceability:** add all `REQ-*` above to `traceable-reqs.toml` with `required_stages = []`
  (not pre-failed); the v0.8.0 build wave activates stages incrementally (int at the final wave).
- **One ADR** for cluster A (resolve-not-execute over run-hook + composite `[update.post]` +
  translate-command consolidation) — hard-to-reverse + surprising-without-context. Cluster B is
  not ADR-level (reversible, unsurprising).
- **Public docs ride the v0.8.0 release** (VERSION numbers, no internal codes): MANIFEST.md +
  reference.md (xtask gen) + gh-pages (integration-checklist, messaging, digest) via
  docs-publish.yml.


File created successfully at: C:\Users\decid\Documents\projects\spt-core\docs\design\v0.8.0-update-arc-and-cli.md (file state is current in your context — no need to Read it back)

# v0.8.0 — adapter-update arc + CLI/UX features (design)

> Source: grill-with-docs session 2026-06-25 (operator + doyle), against
> `../spt-claude-code/UPDATE-NAMING-DOYLE-ASKS.md` (ADR-0005/0006 over there) plus five
> operator-direct CLI asks. This is the spec the v0.8.0 build follows. Each item lands as a
> tagged `REQ-*` in `traceable-reqs.toml`; public-doc surfaces noted per item.

## Decision cluster A — the one-lever adapter-update arc (claude-spt's asks 1–3)

### A1. Hook logic rides `adapter update` via resolve-not-execute (REJECTS `spt api run-hook`)

The proposed `spt api run-hook <adapter> <event>` (spt-core *executes* an adapter hook handler) is
**rejected** — it adds a new spt-core execution surface. Instead, two general primitives let the
adapter resolve+run its **own** packed binary, with spt-core staying a pure resolver:

1. **`{adapter_dir}` + `{adapter_name}` substitution keys.** `{adapter_dir}` = the registry
   record's precise `source_dir` (install dir; survives updates; the dir bare-program resolution
   already uses). Generally available wherever command/string substitution runs.
2. **Lazy substitution inside `[strings]` values at `get-string` read time** (like file-backed
   strings resolve lazily), scoped to **adapter-static keys only** (`{adapter_dir}`,
   `{adapter_name}`). Session-scoped keys (`{id}`/`{session_id}`/…) are **not** available —
   `get-string` carries no session context; a `get-string --session-id` is a deferred, larger
   change (open the door only when a real use case needs it).

**Invariant preserved:** spt-core never executes a string — it substitutes and returns; the
adapter's own wrapper executes the result. Canonical use: a CC hook dispatcher does
`get-string claude-spt hook_cmd` → `"{adapter_dir}/claude-spt hook"` **once per session into an
env var**, then runs it per-hook. Hook *logic* lives in the adapter binary (rides
`spt adapter update`); the plugin's `hooks.json` + a thin static per-OS dispatch wrapper go
static-forever. Honest residuals: (a) ~2 spawns per hook unless the resolved path is memoized
per session (it should be, for the PostToolUse hot path); (b) the dispatch wrapper is the one
plugin-resident piece — generic, static, but still a per-OS `.sh`/`.cmd`.

→ `REQ-MANIFEST-SUBST` (the two keys + `[strings]` substitution). ADR: resolve-not-execute.
Docs: MANIFEST.md (strings + substitution-key table), integration checklist (the hook-dispatch
pattern), CONTEXT.md (done — `[strings]` substitution glossary entry).

### A2. `[message-idle-translation-binary]` takes a `command`; `path` deprecated

Add a sibling optional **`command`** field (opaque, args + `{key}`/`{adapter_dir}` subst,
resolved against `install_dir` like `[digest].extractor`/`[session.psyche_init]`). `path` is
**deprecated** — keeps parsing (manifest forward/back-compat hygiene) but warns at registration
steering to `command`. **Exactly one of `{path, command}`** (both-set refused; neither =
no translation binary). Spawn/stdin contract unchanged (`Init`/`Event`/`Input` JSON-lines in;
`{key}`/`{text}`/`{delay_ms}`/`{commit}` out). `read_translation_path` → `read_translation_command`.
Unblocks folding `claude-spt translate` into the one consolidated binary.

→ extend the translation-binary REQ (REQ-HOST-* / ADR-0022 lineage) or `REQ-TRANSLATE-COMMAND`.
Docs: MANIFEST.md (seam), reference.md (xtask gen), gh-pages integration-checklist + messaging.

### A3. Composite `[update]` — `gh_release` pull + a delegated post-step

Add an **avenue-agnostic `[update.post]` sub-table** (runs after the primary avenue resolves,
regardless of which) = `{ command, self_verifies }`. For claude-spt: `avenue = "gh_release"` +
`[update.post].command = "{adapter_dir}/claude-spt post-update"`.

- **Runs unconditionally** (even when the `.spt` was a no-op) — the post-step does its own
  idempotent check (`claude plugin update`).
- **Published stdin seam** — one JSON line in:
  ```json
  {"adapter_applied": true, "adapter_name": "claude-spt", "profile_name": null,
   "version": "0.8.0", "previous_version": "0.7.0", "adapter_dir": "<source_dir>"}
  ```
  Additive keys only (ignore unknown). Documented in MANIFEST.md + integration checklist.
- **stdout decides the notice** (post-step knows `adapter_applied`, owns the call):
  - **custom text** → print it, **supersedes** `[update].message` (dynamic notice).
  - **reserved sentinel** alone → fire the static `[update].message`.
  - **empty** → no notice.
- **exit code orthogonal**: 0 = ran OK, nonzero = failed.
- **Message gate / fallbacks:** dynamic-stdout > sentinel/manifest-message > nothing. With a
  post-step it's the arbiter **on success**. **No `[update.post]`** ⇒ today's behavior exactly
  (`adapter_applied` → `[update].message`). **Post-step fails** ⇒ loud warning **+** fall back to
  `adapter_applied` → `[update].message` (a plugin-sync failure never swallows the adapter notice).
- **Failure isolation:** a committed `gh_release` pull is **not** rolled back if the post-step
  fails — independent channels.

Sequence: pull+re-register (if newer) → run post-step (always) → `changed = adapter_applied ||
post_changed` → if changed, emit the resolved notice.

→ `REQ-ADAPTER-UPDATE-POST`. ADR: same update-arc ADR. Docs: MANIFEST.md (`[update.post]` +
stdin seam), integration checklist.

## Decision cluster B — standalone CLI/UX asks

### B1. Remove `--reply-to` from `spt send` (hard remove)

Hard-remove (not deprecate) — a flag that confuses agents shouldn't linger behind a warning.
Only touches `cli.rs` (flag + the `is_reply`→"REPLIED" label → always SENT/QUEUED), the `send`
how-to string, and the `REQ-DOCS-6` title. No wire impact (ADR-0020 already structural). One user
→ breaking-CLI cost nil.

→ amend REQ-DOCS-6 + a removal note (no new REQ, or a tiny `REQ-SEND-REPLYTO-REMOVE`). Docs:
how-to, reference.md.

### B2. `spt endpoint digest` — `--last <N>`, `--json` seq, `--after <seq>`

The digest is a **rolling semantic Turn-window** (default 3, oldest→newest), re-projected live on
each call; **partial in-progress turns surface** (turn bounded by user-input, not the harness Stop;
message-ish granularity). Augment the existing `--json`:
- **`--last <N>` = last N turns** (the natural unit; `--last 1` = latest turn = turn-end output).
- **Per-entry `seq`** in the JSON: **stable, source-derived** (deterministic from the entry's
  append position in the source — transcript record index across the session ledger / `digest.log`
  index), so re-projection yields the same `seq`. **NOT** window-position-derived.
- **`--after <seq>`** = entries newer than `seq` still in the window (full window + signal if `seq`
  predates it — consumer fell behind; mirrors the existing version-slide full-refresh).
- **Committed-only seq:** an in-flight entry is `partial: true` with no stable seq until finalized;
  consumer reprocesses `partial` until commit, else skips `<= seq`. Also emit `ts` per entry where
  present (`seq` is the authoritative dedup+cursor key).
- **Fidelity:** the digest's agent text is sufficient for turn-end processing (no raw-source mode
  needed).

**Binding doc-guidance (integration checklist + digest docs):** an adapter's `[digest]` extractor /
`api digest-entry` MUST classify **delivered user-facing messages as turn-opening `input`**
(equivalent to direct PTY user-input). Else messaging-driven sessions collapse into a few giant
turns and `--last`/`seq` lose granularity. The projection treats `role:input` as the boundary;
*what becomes `input`* is the adapter's call.

→ `REQ-DIGEST-CURSOR` (extends REQ-TERM-4/5). Docs: reference.md, gh-pages digest/integration.

### B3. Global `--json` for status queries + stable output DTOs

A **global `--json` flag** (`global = true`), honored by the **read/status set**: `endpoint list`/
`whoami`, `daemon status`, `subnet status`/`show-code`, `endpoint description`/`role`,
`adapter list`/`version`, `notif list`, `grant list`, `access list`, `shell list`, `how-to`.
Action commands don't honor it. Add a shared **`print_json()`** helper + a **coverage test**
asserting every command in the set emits valid JSON (guards the missing-formatter drift). **Define
explicit output DTOs** per command (committed field names) — do **not** serialize internal structs
verbatim (their fields would become a public contract; refactors would break consumers). JSON is a
consumed wire-parity surface.

→ `REQ-CLI-JSON`. Docs: reference.md per command, gh-pages.

### B4. `spt endpoint run` / bare `spt` — empty scope → creation flow

In `PickerModel::new`, when `gather_endpoints()` is **totally empty** (nothing attachable
local *or* subnet), open on `Screen::CreateAdapter` instead of `PickExisting`. A node with subnet
endpoints but no local ones still has things to pick → stays on the picker.

→ `REQ-RUN-EMPTY-CREATE` (extends REQ-RUN-PICKER). Docs: reference.md.

### B5. `spt rc` top-right identity marker

A reserved **top status row** via `DECSTBM` scroll-region (shrink the PTY's reported rows by 1,
own the row), right-aligned **`SUBNET : ENDPOINT_ID @ NODE`**, **cyan** text. Re-assert the margin
+ repaint on alt-screen enter / `DECSTBM` reset / resize (scan output like the existing
`mouse_scanner`). **No window title** (CC owns it for busyness — OSC dropped). Resolve
subnet/node/id once at attach (perch/registry read), thread into the pump; subnet = the endpoint's
**home/primary** (`"local"` if none). The literal floating rounded-rect box is **deferred to the
future web GUI** (not a grid-model `rc` — that lift is better spent on the GUI).

→ `REQ-RC-IDENTITY`. Docs: reference.md, gh-pages.

## Cross-cutting

- **ADR-0005 repo rename** (`spt-claude-code` → `claude-spt`) is honored: `[update].repo`,
  install-dir derivation (`adapters/_github/SaberMage-claude-spt`), README/CI/package scripts
  expect the new slug. Heads-up only — not re-litigated.
- **Traceability:** add all `REQ-*` above to `traceable-reqs.toml` with `required_stages = []`
  (not pre-failed); the v0.8.0 build wave activates stages incrementally (int at the final wave).
- **One ADR** for cluster A (resolve-not-execute over run-hook + composite `[update.post]` +
  translate-command consolidation) — hard-to-reverse + surprising-without-context. Cluster B is
  not ADR-level (reversible, unsurprising).
- **Public docs ride the v0.8.0 release** (VERSION numbers, no internal codes): MANIFEST.md +
  reference.md (xtask gen) + gh-pages (integration-checklist, messaging, digest) via
  docs-publish.yml.


re-Q6: could `digest --json` assign UUIDs to each individual entry, such that if a digest parser sees the same entry twice in a seq that has otherwise changed, it can know that it already processed th…


Spec written → `docs/design/v0.8.0-update-arc-and-cli.md`. Full resolved set, one-liners:

**Cluster A (claude-spt update arc):**
- **A1** — `run-hook` **rejected**; instead `{adapter_dir}`/`{adapter_name}` keys + lazy `[strings]` substitution (adapter-static only). Resolve-not-execute; spt-core executes no string.
- **A2** — translation seam gains `command`; `path` deprecated (warns, still parses); both-set refused; protocol untouched.
- **A3** — avenue-agnostic `[update.post]` sub-table; runs unconditionally; published stdin JSON (incl `adapter_applied`, `profile_name`); stdout: custom-text-supersedes / sentinel-fires-manifest / empty-nothing; exit orthogonal; failure-isolated.

**Cluster B (CLI/UX):**
- **B1** — `--reply-to` hard-removed.
- **B2** — `digest --last N` (turns) + per-entry **stable source-derived `seq`** + `--after <seq>`; partial turns flagged; +binding doc-guidance (delivered messages = turn-opening input).
- **B3** — global `--json` over the read/status set; shared helper + coverage test; **explicit DTOs**.
- **B4** — empty `gather_endpoints()` → create screen.
- **B5** — `rc` reserved top status row (`DECSTBM`, cyan, `SUBNET:ID@NODE`); no window title; box deferred to web GUI.

**To cement now** (proceeding unless you redirect): (1) one **ADR** for cluster A; (2) the **`REQ-*`** stubs into `traceable-reqs.toml` at `required_stages = []` (activated by the build wave); (3) a brief `[update.post]` note in CONTEXT.md; (4) **queue a message to perri** with the resolved cluster-A contract (their asks drove it; they're signed off, it'll spool).

Anything in the spec you want changed before I lock the ADR + traceable? Otherwise I write them.


proceed as recommended, then write the JIT plan. once that's done, /commune with immediate next steps and broad summary of the project's status + end goal. we'll resume in the next session


Proceeding. Reading formats first (ADR house style + traceable entry schema + insertion point).


1	# Activity-gated message delivery + send-modifier axes
2
3	<!-- [doc->REQ-MSG-DELIVERY-AXES] -->
4
5	## Status
6
7	accepted (2026-06-23; design grilled with operator + `doyle`, `/grill-with-docs`). Targets **v0.15.0** (the milestone immediately after v0.14.3). Builds on ADR-0020 (the `<EVENT>` envelope as the sole arriving format), ADR-0022 + its v0.14.2 amendment (translation-binary idle delivery; raw-inject removed), and the activity/idle sentinel already shipped (`api state <busy|idle>`). CONTEXT.md "activity-gated delivery" / "message delivery axes" / "message metadata (`json`)" mint this vocabulary.
8
9	## Context
10
11	Activity-gated inbound delivery is a **legacy-SPT parity gap that was never written down** (absent from CONTEXT.md and the roadmap). The pieces are half-built: `api state busy|idle` writes the `.idle` sentinel, `delivery::is_idle(id)` reads it, and `resolve_inject_methods(manifest, idle)` maps idle-state → inject methods — but the result is discarded (`let _methods = …`), and `broker::dispatch_endpoint_input` injects unconditionally, its own comment calling activity-gated routing "a deferred follow wave." v0.14.3 (the ADR-0022 amendment) just removed the silent raw-inject fallback, so a missing/failed translation binary now spools loudly — sharpening the question of *when* and *through what* a message should reach an endpoint.
12
13	The operator needs per-message control over that delivery, beyond the single `--deferred` (spool-only, hook-channel) flag that exists today. The risk is modelling these as one flat list of mutually-exclusive "message types" — which conflates independent concerns (*when* vs *through what* vs *how long*) and forbids coherent combinations.
14
15	## Decision
16
17	**Complete the activity-gated delivery model, and expose per-message control as three orthogonal send-modifier axes plus an opaque metadata attribute** — not a flat "type" enum.
18
19	### Activity-gated delivery (the substrate v0.15.0 wires)
20

# Activity-gated message delivery + send-modifier axes

<!-- [doc->REQ-MSG-DELIVERY-AXES] -->

## Status

accepted (2026-06-23; design grilled with operator + `doyle`, `/grill-with-docs`). Targets **v0.15.0** (the milestone immediately after v0.14.3). Builds on ADR-0020 (the `<EVENT>` envelope as the sole arriving format), ADR-0022 + its v0.14.2 amendment (translation-binary idle delivery; raw-inject removed), and the activity/idle sentinel already shipped (`api state <busy|idle>`). CONTEXT.md "activity-gated delivery" / "message delivery axes" / "message metadata (`json`)" mint this vocabulary.

## Context

Activity-gated inbound delivery is a **legacy-SPT parity gap that was never written down** (absent from CONTEXT.md and the roadmap). The pieces are half-built: `api state busy|idle` writes the `.idle` sentinel, `delivery::is_idle(id)` reads it, and `resolve_inject_methods(manifest, idle)` maps idle-state → inject methods — but the result is discarded (`let _methods = …`), and `broker::dispatch_endpoint_input` injects unconditionally, its own comment calling activity-gated routing "a deferred follow wave." v0.14.3 (the ADR-0022 amendment) just removed the silent raw-inject fallback, so a missing/failed translation binary now spools loudly — sharpening the question of *when* and *through what* a message should reach an endpoint.

The operator needs per-message control over that delivery, beyond the single `--deferred` (spool-only, hook-channel) flag that exists today. The risk is modelling these as one flat list of mutually-exclusive "message types" — which conflates independent concerns (*when* vs *through what* vs *how long*) and forbids coherent combinations.

## Decision

**Complete the activity-gated delivery model, and expose per-message control as three orthogonal send-modifier axes plus an opaque metadata attribute** — not a flat "type" enum.

### Activity-gated delivery (the substrate v0.15.0 wires)


1004	# Planned activations: REQ-CLI-* at D1, REQ-SUBNET-5..8 at D2, REQ-INSTALL-6..8
1005	# at D3, REQ-CONV-*/REQ-PAIR-8/REQ-DAEMON-5 at D4 (D4 also adds REQ-UPD-6 int
1006	# per decision 19), REQ-HAZARD-EPOCH-RESET stays inactive by decision 24.) ---
1007	[[requirements]]
1008	id = "REQ-CLI-1"
1009	title = "spt endpoint noun namespace: absorbs fork/suspend/wake/shutdown/rename/stop/digest + access (ported 1:1: allow|revoke|open|list, decision 21) + description (ex-resources blurb; bare=show, set=author); merged endpoint list [--local|--subnet <name>] grouped by subnet with SELF pinned, --detail adding the ex-resources yellow-pages blurb projection; bare spt endpoint = the list (M8 decisions 1-2, 25)"
1010	required_stages = ["impl", "unit"]  # activated M8-D1: cli.rs EndpointCmd noun (verbs ride pre-M8 arg shapes verbatim — location-only break), cmd_endpoint_list (SELF pin + subnet grouping over resource_projection's visibility closure, --local = the old roster, --detail = the blurb projection), cmd_description set/show. unit = endpoint_subcommands_parse + access 1:1 + digest defaults + moved-verbs-gone
1011	[[requirements]]
1012	id = "REQ-CLI-2"
1013	title = "spt daemon noun: run|stop|status (hidden daemon verb becomes daemon run; agent-endpoint shutdown keeps its name under endpoint); daemon status renders the pump heartbeat (last-tick recency) so a half-dead daemon is never rendered implied-healthy (M8 decisions 5, 23)"
1014	required_stages = ["impl", "unit"]  # activated M8-D1: DaemonCmd run|stop|status (bare = the node status view, decision 25b); graceful stop = seedmap KIND_STOP (ack, latch, throwaway-dial wakes the accept loop — serve returns, no kill) + daemon::request_stop; status renders daemon state + pump heartbeat (read_pump_heartbeat; the D4 pump writes it — absent renders 'no heartbeat recorded', never implied-healthy) + subnets + local endpoints; every daemon spawn site passes `daemon run`. unit = daemon_subcommands_parse + stop_op_acks_then_serve_returns
1015	[[requirements]]
1016	id = "REQ-CLI-3"
1017	title = "Agent hot path stays flat across the M8 reorg: send/ring/ready/whoami/how-to unchanged; notify moves to subnet notify while notif stays top-level; breaking renames land clean with no deprecation shims (zero external CLI consumers pre-spt-claude-code) (M8 decisions 3-4, 9)"
1018	required_stages = ["impl", "unit"]  # activated M8-D1: the hot path parses untouched at top level; SubnetCmd::Notify [BODY] [--target] with home-subnet default (decision 25a — resolve_notify_subnet pure decision table + issuer_home_subnet shell→owner ride); every moved verb's old top-level shape is an unknown subcommand. unit = hot_path_flat_and_moved_verbs_gone + notify_surface_parses + notify_subnet_resolution_home_default
1019	[[requirements]]
1020	id = "REQ-CLI-4"
1021	title = "User-facing CLI output is human-readable: DIRECT-USER commands (e.g. adapter update/list/use) render friendly prose instead of raw CODE:RESULT markers — \"claude-spt is up to date (0.2.0).\" not \"ADAPTER_UPDATE_UPTODATE:claude-spt: installed 0.2.0, latest 0.2.0\". Strictly bounded to the direct-user surface: the adapter-PARSED bringup tokens (SEEDED/BOUND/READY/NO_SEED on seed/listen, which adapters grep) stay machine-parseable — humanization is additive (a human line beside the marker, or a --porcelain/--quiet split), never a silent rename of a dual-contract marker. The user-facing bringup composition belongs to the adapter (perri); this REQ owns only the direct-user CLI surface. (v0.9.0)"
1022	required_stages = []  # registry-first (v0.9.0-harness-resolution base commit); activate per-wave: doc+impl+unit at W4 (humanize adapter update/list/use direct-user output; bringup parse-tokens untouched)
1023	[[requirements]]
1024	id = "REQ-SUBNET-5"
1025	title = "Per-subnet serve-state: spt subnet detach <NAME> [--save] / attach <NAME> [--save] — daemon keeps running, stops/starts advertising + connecting for that subnet (peer pump + responder selective); --save persists the startup default in daemon config; the all-attached banner gains per-subnet states (M8 decision 6, --save renamed from --auto per decision 25 session)"
1026	required_stages = ["impl", "unit", "int"]  # activated M8-D2: spt_store::attachment::AttachmentStore (identity/attachment.json live state, all-attached default, corrupt-degrades-attached) + daemon boot reset from the new daemon.json detached_subnets knob (--save writes it; unsaved flips don't survive a restart by design); gating at all three serve points re-read per round — pump per-subnet skip (peerloop), pairing responder filter_serving view, meet-rotation name filter; CLI cmd_subnet_attachment (ungated — no trust mutation) + per-subnet banner states (detached / attached / no connection). unit = store flip/roundtrip/corrupt + boot-reset/filter + config knob + arg shapes. int ADDED post-M8-accept (2026-06-08): the subnet-scoped liveness probe closes the criterion-5 detach-liveness gap — `status --nodes` showed a DETACHED-but-reachable peer as online because the probe was a raw subnet-blind dial (one ALPN serves all subnets). New ServeProbeRecord (spt-net/serveprobe) + daemon serveprobe handler/requester answer "serving subnet X?" from the peer's own AttachmentStore (the same is_detached signal the pump/responder gate on); the dispatcher classifies it by the `serve_probe` field; wansend::probe_node_serving dials (proves reachable) THEN asks. int = dispatcher_serves_a_subnet_serve_probe (loopback E2E: attached→serving, non-member→false, detach flips to false while reachable, re-attach restores)
1027	[[requirements]]

# Planned activations: REQ-CLI-* at D1, REQ-SUBNET-5..8 at D2, REQ-INSTALL-6..8
# at D3, REQ-CONV-*/REQ-PAIR-8/REQ-DAEMON-5 at D4 (D4 also adds REQ-UPD-6 int
# per decision 19), REQ-HAZARD-EPOCH-RESET stays inactive by decision 24.) ---
[[requirements]]
id = "REQ-CLI-1"
title = "spt endpoint noun namespace: absorbs fork/suspend/wake/shutdown/rename/stop/digest + access (ported 1:1: allow|revoke|open|list, decision 21) + description (ex-resources blurb; bare=show, set=author); merged endpoint list [--local|--subnet <name>] grouped by subnet with SELF pinned, --detail adding the ex-resources yellow-pages blurb projection; bare spt endpoint = the list (M8 decisions 1-2, 25)"
required_stages = ["impl", "unit"]  # activated M8-D1: cli.rs EndpointCmd noun (verbs ride pre-M8 arg shapes verbatim — location-only break), cmd_endpoint_list (SELF pin + subnet grouping over resource_projection's visibility closure, --local = the old roster, --detail = the blurb projection), cmd_description set/show. unit = endpoint_subcommands_parse + access 1:1 + digest defaults + moved-verbs-gone
[[requirements]]
id = "REQ-CLI-2"
title = "spt daemon noun: run|stop|status (hidden daemon verb becomes daemon run; agent-endpoint shutdown keeps its name under endpoint); daemon status renders the pump heartbeat (last-tick recency) so a half-dead daemon is never rendered implied-healthy (M8 decisions 5, 23)"
required_stages = ["impl", "unit"]  # activated M8-D1: DaemonCmd run|stop|status (bare = the node status view, decision 25b); graceful stop = seedmap KIND_STOP (ack, latch, throwaway-dial wakes the accept loop — serve returns, no kill) + daemon::request_stop; status renders daemon state + pump heartbeat (read_pump_heartbeat; the D4 pump writes it — absent renders 'no heartbeat recorded', never implied-healthy) + subnets + local endpoints; every daemon spawn site passes `daemon run`. unit = daemon_subcommands_parse + stop_op_acks_then_serve_returns
[[requirements]]
id = "REQ-CLI-3"
title = "Agent hot path stays flat across the M8 reorg: send/ring/ready/whoami/how-to unchanged; notify moves to subnet notify while notif stays top-level; breaking renames land clean with no deprecation shims (zero external CLI consumers pre-spt-claude-code) (M8 decisions 3-4, 9)"
required_stages = ["impl", "unit"]  # activated M8-D1: the hot path parses untouched at top level; SubnetCmd::Notify [BODY] [--target] with home-subnet default (decision 25a — resolve_notify_subnet pure decision table + issuer_home_subnet shell→owner ride); every moved verb's old top-level shape is an unknown subcommand. unit = hot_path_flat_and_moved_verbs_gone + notify_surface_parses + notify_subnet_resolution_home_default
[[requirements]]
id = "REQ-CLI-4"
title = "User-facing CLI output is human-readable: DIRECT-USER commands (e.g. adapter update/list/use) render friendly prose instead of raw CODE:RESULT markers — \"claude-spt is up to date (0.2.0).\" not \"ADAPTER_UPDATE_UPTODATE:claude-spt: installed 0.2.0, latest 0.2.0\". Strictly bounded to the direct-user surface: the adapter-PARSED bringup tokens (SEEDED/BOUND/READY/NO_SEED on seed/listen, which adapters grep) stay machine-parseable — humanization is additive (a human line beside the marker, or a --porcelain/--quiet split), never a silent rename of a dual-contract marker. The user-facing bringup composition belongs to the adapter (perri); this REQ owns only the direct-user CLI surface. (v0.9.0)"
required_stages = []  # registry-first (v0.9.0-harness-resolution base commit); activate per-wave: doc+impl+unit at W4 (humanize adapter update/list/use direct-user output; bringup parse-tokens untouched)
[[requirements]]
id = "REQ-SUBNET-5"
title = "Per-subnet serve-state: spt subnet detach <NAME> [--save] / attach <NAME> [--save] — daemon keeps running, stops/starts advertising + connecting for that subnet (peer pump + responder selective); --save persists the startup default in daemon config; the all-attached banner gains per-subnet states (M8 decision 6, --save renamed from --auto per decision 25 session)"
required_stages = ["impl", "unit", "int"]  # activated M8-D2: spt_store::attachment::AttachmentStore (identity/attachment.json live state, all-attached default, corrupt-degrades-attached) + daemon boot reset from the new daemon.json detached_subnets knob (--save writes it; unsaved flips don't survive a restart by design); gating at all three serve points re-read per round — pump per-subnet skip (peerloop), pairing responder filter_serving view, meet-rotation name filter; CLI cmd_subnet_attachment (ungated — no trust mutation) + per-subnet banner states (detached / attached / no connection). unit = store flip/roundtrip/corrupt + boot-reset/filter + config knob + arg shapes. int ADDED post-M8-accept (2026-06-08): the subnet-scoped liveness probe closes the criterion-5 detach-liveness gap — `status --nodes` showed a DETACHED-but-reachable peer as online because the probe was a raw subnet-blind dial (one ALPN serves all subnets). New ServeProbeRecord (spt-net/serveprobe) + daemon serveprobe handler/requester answer "serving subnet X?" from the peer's own AttachmentStore (the same is_detached signal the pump/responder gate on); the dispatcher classifies it by the `serve_probe` field; wansend::probe_node_serving dials (proves reachable) THEN asks. int = dispatcher_serves_a_subnet_serve_probe (loopback E2E: attached→serving, non-member→false, detach flips to false while reachable, re-attach restores)
[[requirements]]