# v0.16.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.16.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 + **adapter-static** `{adapter_dir}`/`{adapter_name}`
subst only, resolved against `install_dir` like `[digest].extractor`/`[session.psyche_init]`). **Subst
scope ratified adapter-static (v0.16.0 W1), not session `{key}`:** the translation binary is a persistent
process serving every session on the endpoint (session/event ctx arrives per-message over the stdin
Init/Event protocol, never the spawn argv), and the live-update respawn site carries no session ctx (a
`{id}`-bearing command would `MissingKey`→spool). `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 }` (`command` required/non-empty; `self_verifies`
attestation-only, defaults false — parallel to `[update].self_verifies`, records self-verification
but gates nothing; inert at post-step runtime in v0.16.0, reserved). The reserved notice sentinel is
**`!!update-message!!`** (house `!!x!!` style; published seam, emitted verbatim+alone). 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.
  **Basis ruling (W5 doyle, seq-stability crux):** anchor `seq` to the session's **absolute position
  in the full append-only `sessions.log` ledger**, NOT the last-K-sessions slice (slice-relative
  renumbers when the window rolls — rejected) and NOT a raw `(session_id, line)` composite (not a
  single comparable int — breaks `--after`). Older/pruned sessions' line-counts aren't persisted, so
  don't sum a true global cumulative cheaply; either persist per-session counts for a true cumulative
  line-index, **or** encode `(absolute_ledger_ordinal, per_session_line_idx)` as **one monotonic int**
  (ordinal high bits, line low; low field wide enough no session overflows it) — total order
  preserved, consumer only does `seq > after`. The **log-less `digest.log`** path uses the absolute
  append line index directly. Ledger **prune** below a consumer's `seq` → `--after` full-window +
  predates-signal; survivors are **not** renumbered.
  **Granularity:** per-entry `seq` = the entry's **last contributing source-record idx**; a collapsed
  `ToolSprint` takes its **last** collapsed record's idx (so `--after` skips a consumed sprint and a
  *grown* sprint re-surfaces via a higher last-idx; first-idx would wrongly skip a grown sprint).
- **`--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:** **turn-level partial** (W5 ruling) — the trailing **open** turn (not closed
  by a following user-input) is `partial: true` and its entries carry **no stable seq**; all prior
  input-closed turns are committed with stable seqs. The turn is the commit unit (no per-entry commit
  signal mid-turn; entries can still re-collapse while open). Consumer reprocesses `partial` until it
  closes, else skips `<= seq`. Also emit `ts` per entry where present (`seq` is the authoritative
  dedup+cursor key). **Crux test:** across two projections where a window slide drops the oldest turn,
  every committed entry keeps the same `seq` (re-projection idempotence).
- **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.16.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.16.0 release** (VERSION numbers, no internal codes): MANIFEST.md +
  reference.md (xtask gen) + gh-pages (integration-checklist, messaging, digest) via
  docs-publish.yml.
