1180	# ───────────────────────────── v0.12.1 CLI polish (help markdown render) ──────────────────
1181	[[requirements]]
1182	id = "REQ-CLI-HELP-MARKDOWN"
1183	title = "`spt --help` (and every subcommand --help) renders the inline Markdown authored in the clap doc-comments as terminal styling, never as literal markers: `**bold**` → ANSI bold, `` `code` `` → ANSI cyan, `[text](url)` → `text`. The markers are STRIPPED either way — a raw `**` or backtick must NEVER reach the user (the operator-reported v0.12.0 defect: help text reads `**ctrl-b**` and stray backticks verbatim). Color/bold escapes are emitted ONLY when the help is going to a real terminal AND color is not suppressed (NO_COLOR unset · CLICOLOR != 0 · CLICOLOR_FORCE forces on); a pipe / redirect / CI / NO_COLOR falls back to strip-only (clean plaintext, zero escapes) so machine-readable help is byte-identical regardless of marker syntax. Pure transform over the clap-rendered help string at the single run()/bare_invocation chokepoint; preserves pre-existing ANSI (CSI sequences passed through untouched), never spans markers across a newline, leaves unmatched/empty markers literal, and does not alter the help layout. (v0.12.1)"
1184	required_stages = ["impl", "unit"]  # activated v0.12.1: impl = spt/src/helpfmt.rs render() (pure marker transform: bold/code/link, ANSI-CSI passthrough, line-bounded, unmatched-literal) + stdout_color() decision (IsTerminal && NO_COLOR unset && CLICOLOR!=0, CLICOLOR_FORCE overrides) + emit_clap_error() intercepting clap ErrorKind::DisplayHelp / DisplayHelpOnMissingArgumentOrSubcommand at cli::run try_get_matches_mut, plus bare_invocation render_help() path. unit = bold→ansi & code→ansi (color) vs strip (no-color); link→text; empty/unmatched markers left literal; newline never spanned; pre-existing CSI escape passed through byte-exact; color_enabled truth table (NO_COLOR / CLICOLOR=0 / CLICOLOR_FORCE / tty)
1185
1186	# ───────────────────────────── v0.12.2 daemon-start no-window regression ──────────────────
1187	[[requirements]]
1188	id = "REQ-HAZARD-WMI-DAEMON-WINDOW"
1189	title = "`spt daemon start` launches the daemon with NO visible console window. REGRESSION (v0.12.1 L1.5): the WMI job-neutral launch (spawn_daemon_via_wmi) set CREATE_NO_WINDOW on the launching powershell but NOT on the Win32_Process.Create call — Win32_Process.Create does not inherit it, so the spawned cmd.exe env-forwarding wrapper popped a console window on every cold-start (violating REQ-INSTALL-10's v0.7.4 no-persistent-window invariant; the old detached_no_inherit path set DETACHED_PROCESS|CREATE_NO_WINDOW). FIX: pass a Win32_ProcessStartup with CreateFlags=DETACHED_PROCESS (0x8 — no console so no window; CREATE_NO_WINDOW 0x08000000 is NOT a valid Win32_ProcessStartup flag → ReturnValue 21 invalid-param, which is why the naive port fails) + ShowWindow=SW_HIDE(0) belt, via the ProcessStartupInformation argument. (v0.12.2)"
1190	required_stages = ["impl", "unit", "int"]  # activated v0.12.2: impl = wmi_create_script builds the Win32_ProcessStartup no-window spec (CreateFlags=8 DETACHED + ShowWindow=0) passed as ProcessStartupInformation to Win32_Process.Create. unit = wmi_create_script_carries_the_no_window_startup_spec (spec present + passed + DETACHED, never the invalid CREATE_NO_WINDOW). WMI launch VALIDITY (a bad spec → ReturnValue!=0 → no daemon) is independently guarded by job_escape_e2e (the REQ-HAZARD-VIEWER-CLOSE-DETACH int red-flagged the invalid CREATE_NO_WINDOW during the fix). REOPENED v0.13.0 (todlando 2026-06-19, doyle /diagnose): the v0.12.2 fix was INSUFFICIENT — it set DETACHED_PROCESS on the cmd WRAPPER, but the WMI/schtasks rungs then run `start "" /b spt daemon run`, launching the inner BROKER (a console-subsystem exe) with no console to inherit → the broker ALLOCATES ITS OWN conhost = a window (deterministic 3/3: stop → any spt cmd auto-starts via ensure_running → WMI rung → window). FIX = broker self-detach: the auto-start ladder appends a hidden `--detached` flag (spawn_detached args, all rungs) and cmd_daemon_run calls FreeConsole() at startup when set (launch-agnostic; no-op on the already-windowless breakaway/injob rungs; a human foreground `spt daemon run` omits the flag → keeps its console). int ADDED now: a real WMI-cold-start int (job_escape_e2e wmi_daemon_run_has_no_conhost_window) asserts the WMI-launched broker pid has NO conhost.exe child (RED pre-fix: found 1 conhost; GREEN post-fix), with full daemon-tree reap hygiene. impl = spawn_detached --detached arg + daemon::detach_console (FreeConsole) + cmd_daemon_run gate; unit = daemon_subcommands_parse extended (run ⇒ detached:false, run --detached ⇒ true — flag routing); int = no-conhost-child of the WMI broker.
1191
1192	# ───────────────────────────── v0.12.2 CLI polish (command-output markdown render) ──────────
1193	[[requirements]]
1194	id = "REQ-CLI-OUTPUT-MARKDOWN"
1195	title = "Human-prose COMMAND OUTPUT (not just `--help`) renders the inline Markdown authored in its source strings as terminal styling, never literal markers: `` `code` `` → ANSI cyan, `**bold**` → ANSI bold, `[text](url)` → `text`, markers STRIPPED either way. REQ-CLI-HELP-MARKDOWN only hooked the clap `--help` chokepoint, so command output still printed raw Markdown (audit: `spt how-to` topic text showed `# headers`/backticks, `spt subnet`/`subnet status` hint footers showed stray backticks, the daemon-status `not running` line, the `ENDPOINT_RUN_STARTED` attach hint, and the daemon's `SUBNET_DETACHED` startup line — 13 prose surfaces). The same line-bounded pure `helpfmt::render` is applied at each emit site, color-gated by the OUTPUT STREAM's own tty (`stdout_color` for print/println, the new `stderr_color` for eprintln). HARNESS-SAFETY (binding): color is tty-gated, so an adapter (piped / non-tty / NO_COLOR) gets STRIP mode = zero ANSI + markers removed; every dual-contract MACHINE token on a rendered line (`ENDPOINT_RUN_STARTED:`, `NO_SUCH_TOPIC:`, `SUBNET_DETACHED:`) carries NO Markdown markers, so it survives strip byte-intact — the adapter parse is never perturbed. Pure-machine output (the `<EVENT …>` envelope, bringup parse-tokens SEEDED/BOUND/READY/NO_SEED, `--json`, QR) is NEVER routed through the renderer. The one spt-daemon source string (`SUBNET_DETACHED`, the bin-local renderer is unreachable from the daemon crate) is authored marker-free instead. (v0.12.2)"
1196	required_stages = ["impl", "unit"]  # activated v0.12.2: impl = helpfmt::stderr_color() (stderr-tty mirror of stdout_color) + render() wrapped at each human-prose emit site in cli.rs (cmd_how_to topics-header/topic-text/NO_SUCH_TOPIC, render_subnet_status print! callers covering HINT_FOOTER/standalone_text/pump_stall_warning/daemon_offline_hint/inbound_block_hint, daemon-status "not running" line, ENDPOINT_RUN_STARTED hint) + daemon.rs SUBNET_DETACHED authored marker-free. unit = render applied at the sites + a strip-mode dual-contract proof: a machine token line (ENDPOINT_RUN_STARTED:/NO_SUCH_TOPIC:) under color=false emits the token byte-intact with zero ANSI while the prose backticks are stripped; stderr_color mirrors the stdout_color decision table.
1197
1198	# ═════════════════════════════ v0.13.0 — spt-hosted message delivery + control/wedge bug cluster ═════
1199	# Milestone JIT: V0.13.0-DELIVERY-CONTROL-JIT.md. Design: ADR-0022 (delivery redesign). Spine invariant
1200	# = injected keystrokes + a live `spt rc` controller coexist on ONE broker PTY with no control-loss,
1201	# no ONLINE+CONTROLLED latch, no wedge. SEED (stages=[]) — each wave activates its REQ on build; EVERY
1202	# fix gated against the REAL dummy-harness fixture + a real daemon (no mocks/theory — v0.12.0/.1 lesson).
1203
1204	# ── W1 KEYSTONE: injection coexists with the controller (PTY-write coordination) ──
1205	[[requirements]]
1206	id = "REQ-HAZARD-INJECT-CONTROL-COEXIST"
1207	title = "SPINE INVARIANT (v0.13.0 keystone): the broker must accept INJECTED keystrokes into an spt-hosted PTY (the v0.11.0 raw direct-inject today; the ADR-0022 translation-binary choreography tomorrow) WHILE a live `spt rc` controller is attached to the SAME PTY, without (a) the operator losing control, (b) the endpoint latching ONLINE+CONTROLLED, or (c) the broker wedging. The injection inlet is PERMANENT — spt-claude-code requires keystroke injection — so this is root-caused + fixed at the PTY-injection layer, IN STEP with the ADR-0022 delivery redesign that formalizes the inlet. REOPENS the wedge facet of REQ-HAZARD-ATTACH-WEDGE: the v0.12.1 prove-don't-change covered only DEAD-CHILD backpressure, NOT the injection trigger (operator's signal — one injected keystroke succeeds, the next wedges → the single-threaded broker parks on a blocking PTY/loopback write after injection-induced harness output). REPRO-FIRST on the real dummy-harness fixture (NO theory): instrument to nail the exact blocking call before any fix. Fix candidates: non-blocking/fail-fast PTY write, split input/output, bounded-evicting. Mechanism shared with W2 — spt-core owns EVERY PTY write and applies an injected sequence ATOMICALLY (controller input buffered during the sequence, flushed after) so a stash/restore can't be clobbered. CONFIRMED ROOT (doyle /diagnose 2026-06-19, code-grounded): Broker::append (broker.rs:205-227) fans each live output chunk to the CONTROLLER on a SYNCHRONOUS BLOCKING write_frame held inline in the session's drain thread (the 'authoritative, advances delivered_through' path, D4-1), while VIEWERS use a dedicated writer thread + bounded evicting sync_channel (add_viewer:273 / viewer_writer) that can never stall the drain. So a slow/backed-up controller socket — or the full 64KB loopback duplex (the ATTACH-WEDGE buffer) — BLOCKS the drain thread → output stalls → keystroke echoes stall (PERCEIVED input lag) → unrecoverable wedge when the consumer never drains. TRIGGERS ON NORMAL INTERACTIVE rc USE under heavy harness output (TUI redraw), NOT only message injection — same root, wider repro. FIX DIRECTION: move controller delivery off the drain thread onto a dedicated writer (the viewer_writer pattern) BUT preserve the authoritative cursor — block the WRITER thread (not the drain), bound the wedge (deadline → detach/mark-gone, never park forever), never silently evict the operator's authoritative view. (v0.13.0)"
1208	required_stages = ["impl", "unit", "int"]  # ACTIVATED v0.13.0 W1 (todlando). ROOT confirmed repro-first on a REAL broker + REAL PTY (inject_control_wedge.rs): park (a) = OutputLog::append fanned each chunk to the controller via a SYNCHRONOUS inline write_frame on the session drain thread while holding Mutex<OutputLog> → a backed-up controller socket parked the drain WITH THE LOCK HELD → permanent per-session wedge (KIND_SESSIONS hung; net_status answered = process alive). Parks (b)/(c) (write_input write_all on a full input buffer + DSR-answer writer-mutex contention) confirmed BENIGN on Windows ConPTY (absorbs a 4MiB inject), real only on Unix forkpty. FIX: controller delivery moved OFF the drain thread onto a dedicated writer thread + bounded channel (controller_writer, the viewer_writer pattern) but AUTHORITATIVE — the writer advances delivered_through (now Arc<AtomicU64>, advance_delivered/advanced_cursor monotonic); the drain hands each chunk off via a bounded off-lock send (ControllerJob::deliver, CONTROLLER_WRITE_DEADLINE=5s → detach/clear-driven-by/unlatch via the shared clear_controller, never park forever); displaced/exit notices ride the writer channel (CtrlMsg::Control). impl = broker.rs append/controller_writer/become_controller/clear_controller. unit = controller_deliver_is_bounded_never_a_permanent_park + advance_delivered_is_monotonic. int = inject_control_wedge.rs (a_backed_up_controller_does_not_wedge_the_session: a fully-backed-up controller keeps KIND_SESSIONS answering = no wedge). W1<->W5 SYNERGY: clear_controller IS the lost-controller unlatch W5's reconcile reuses.
1209
1210	# ── W1 COMPLETION (b4, todlando+doyle 2026-06-21): controller backpressure must not THROTTLE THE DRAIN / starve a concurrent viewer ──
1211	[[requirements]]
1212	id = "REQ-HAZARD-VIEWER-STARVE-UNDER-CONTROLLER-BACKPRESSURE"
1213	title = "A SLOW controller must not starve a concurrent `rc --view` VIEWER. W1 (REQ-HAZARD-INJECT-CONTROL-COEXIST) moved the controller SOCKET WRITE off the drain thread onto controller_writer, but left the bounded HANDOFF (ControllerJob::deliver) as an INLINE try_send SLEEP-POLL on the drain (broker.rs:1450-1457 → deliver:669-685, up to CONTROLLER_WRITE_DEADLINE=5s). So when a controller drains slower than the PTY floods, its CONTROLLER_CHANNEL_DEPTH(4096) channel fills, deliver() polls inline, and the DRAIN THREAD is throttled to the controller's read rate → OutputLog::append's viewer fan-out (try_send) stops running → a concurrent VIEWER receives only the initial replay then nothing (root 'b4', warm forkpty: a_journaled c1=0/EVICT=0/got_output=FALSE; steady-state-near-full = no recovery; forkpty-only, floods harder than Windows ConPTY). The viewer-not-starved-by-a-busy-session property is legitimate (rc --view of a noisy session must show LIVE output). FIX: the controller becomes a SINGLE NON-BLOCKING try_send (like a viewer), done IN append() under the log lock; deliver()'s sleep-poll DELETED; the drain NEVER sleeps. ControllerSink gains a stateful last_ok deadline → a TRULY-stalled controller (continuous-Full past CONTROLLER_WRITE_DEADLINE) is evicted (bounded-wedge preserved); a slow-but-alive controller DROPS frames + falls behind the ring (resume-from-floor, the existing reconnect case). B2 GAPLESS-HANDOFF PRESERVED via a CONTIGUOUS delivered_through: controller_writer advances the cursor ONLY when the written seq == cursor (next expected); a gap from a drop FREEZES the cursor at last-contiguous so a re-attaching brain's resume_seq never skips a dropped chunk (a high-watermark advance past the gap would be a not-exactly-once resume = B2 violation, doyle's gate). (v0.13.0)"
1214	required_stages = ["impl", "unit", "int"]  # ACTIVATED v0.13.0 impl+unit+INT (todlando, doyle-gated; int activated at the fold). impl = broker.rs OutputLog::append (single non-blocking controller try_send + ControllerSink.last_ok staleness, returns Option<evict-epoch>), drain closure (mark_controller_gone on signal, never sleeps), controller_writer live-path CONTIGUOUS cursor advance (contiguous_advance: compare_exchange seq→seq+1, freeze on gap), ControllerJob::deliver sleep-poll DELETED. unit = a_full_controller_does_not_starve_a_viewer_or_block_the_drain (in-process OutputLog, no PTY) + contiguous_advance_freezes_on_a_gap kernel. INT ACTIVATED at the v0.13.0 fold (todlando, doyle-gated): int = inject_control_wedge.rs a_journaled got_output — GREEN on delivery-control WITH pump-carrier-fix (the carrier kills the subscribed=false measurement artifact; b4 unthrottles the drain; the keystone snaps the pre-eviction ring-roll gap). doyle gates the full matrix ×3 both runners.
1215
1216	# ── b4 follow-up: an evicted VIEWER skips to live instead of dying silently ──
1217	[[requirements]]
1218	id = "REQ-VIEWER-SKIP-TO-LIVE-ON-EVICT"
1219	title = "A `rc --view` VIEWER that overflows its broker subscription queue and is EVICTED (OutputLog::append try_send Full → viewers.remove, REQ-HAZARD-VIEWER-ISOLATION session-protection) must SKIP TO LIVE, not die silently. ROOT (v0.13.0, b4 JIT item 2 = p0_paste + post-b4 a_journaled-Linux, ONE root): serve_attach forwards each frame (read_event→b64decode→re-encode AttachRecord→net_stream_send) SLOWER than the drain fans out under flood → its VIEWER_CHANNEL_DEPTH(256) channel overflows → the drain evicts (viewers.remove drops the ViewerSink → drops tx → viewer_writer's rx.recv() Err → the writer returns WRITING NOTHING) → serve_attach's brain.read_event() just STOPS getting Output (no EOF, no error) → serve_attach blocks forever → the operator receives nothing (attach_received_output=FALSE). Eviction-of-a-hopelessly-behind-viewer is CORRECT session-protection (keep it); SILENT+PERMANENT eviction is the bug. VIEWER-only → B2-SAFE (a viewer never advances delivered_through / is not authoritative / exposes no resume cursor). FIX (doyle-gated, skip-to-live = tail -f reconnect): (1) explicit broker→viewer EVICTION SIGNAL (KIND_VIEWER_EVICTED, written in the viewer_writer thread OFF the log lock, DISTINCT from session-exit EOF so serve must NOT tear down on it); (2) serve_attach re-subscribes from the CURRENT ring floor (skip-to-live, replays nothing, sees the next live burst) — resetting the cold serve-brain's next_seq so the post-eviction forward-jump replay is accepted (the legacy reject-gap path, brain.rs:618-626, would otherwise FATAL the forward jump); (3) HARD constraint NO evict→resubscribe busy-loop: serve_attach rate-limits re-subscribes (RESUBSCRIBE_INTERVAL) so under max-flood the operator sees intermittent LIVE bursts, never a CPU spin. (v0.13.0)"
1220	required_stages = ["doc", "impl", "unit", "int"]  # ACTIVATED v0.13.0 doc+impl+unit+INT (todlando, doyle-gated; int activated at the fold). doc = V0.13.0-SKIP-TO-LIVE-JIT.md. impl = broker.rs (ViewerSink.evicted notifier + append evict arm sets it + viewer_writer emits KIND_VIEWER_EVICTED on an evicted rx-close), msg.rs (KIND_VIEWER_EVICTED + evicted_envelope), brain.rs (BrokerEvent::ViewerEvicted + decode arm + cold-cursor reset), attach.rs (serve_attach ViewerEvicted handling: re-subscribe-from-floor + rate-limit, no teardown). unit = viewer_writer emits the marker on evicted-close + nothing on normal close; serve re-subscribes from floor + does not tear down + rate-limits; cold-brain next_seq reset accepts the post-eviction forward jump. INT ACTIVATED at the v0.13.0 fold (todlando, doyle-gated): int = inject_control_wedge.rs p0_paste got_output (the evicted viewer skips to live → keeps receiving under the flood) — GREEN on delivery-control WITH pump-carrier-fix. doyle gates the full matrix ×3 both runners.
1221
1222	# ── b4 follow-up #2 (todlando+doyle 2026-06-21): a VIEWER tolerates a PRE-eviction ring-roll forward gap (snap, don't fatal) ──
1223	[[requirements]]
1224	id = "REQ-HAZARD-VIEWER-RING-ROLL-SNAP"
1225	title = "A read-only rc --view VIEWER whose serving brain falls behind the live ring under a hard flood and receives a FORWARD Output seq gap (the ring rolled frames out between reads, BEFORE any channel-overflow eviction → NO KIND_VIEWER_EVICTED marker) must SNAP TO LIVE (accept-and-advance via dedup-below + snap-above), NOT fatal with output gap (brain.rs:624/628 legacy reject-gap). ROOT (v0.13.0 forkpty, post-b4+skip-to-live): serve_attach subscribes a viewer via brain.attach_as(Viewer) leaving session_cursors EMPTY → the viewer serve-brain uses the LEGACY reject-gap → a PRE-eviction ring-roll forward-gap FATALS read_event → serve_attach returns → forwarding stops → attach_received_pty_output=FALSE (a_journaled / p0_paste / attach.rs:1071 wedged_viewer, Linux forkpty; Windows ConPTY floods slower → MASKED false-green). DISTINCT from REQ-VIEWER-SKIP-TO-LIVE-ON-EVICT (the POST-eviction re-subscribe-from-floor): this is PRE-eviction gap-tolerance while STILL subscribed. VIEWER-only → B2-SAFE (a viewer never advances delivered_through / is not authoritative); the CONTROLLER keeps strict reject-gap (exactly-once resume). FIX: arm snap-above at initial viewer attach (attach_as_viewer_snap = attach_as(Viewer) + session_cursors.insert(session_id, from_seq)); the two viewer-survival mechanisms COMPOSE — this tolerates pre-eviction ring-roll gaps, REQ-VIEWER-SKIP-TO-LIVE-ON-EVICT recovers post-eviction. (v0.13.0)"
1226	required_stages = ["doc", "impl", "unit", "int"]  # ACTIVATED v0.13.0 doc+impl+unit+INT (todlando, doyle-gated; int activated at the fold). doc = CONTEXT.md viewer paragraph (a viewer snaps a forward ring-roll gap, not just on eviction). impl = brain.attach_as_viewer_snap (attach_as(Viewer) + session_cursors.insert(from_seq)) + serve_attach wiring at intent==Viewer (attach.rs). unit = a viewer-armed brain ACCEPTS a forward Output gap via snap-above (vs the legacy reject-gap a non-viewer cold brain keeps). INT ACTIVATED at the v0.13.0 fold (todlando, doyle-gated): int = inject_control_wedge.rs a_journaled + p0_paste got_output (both viewers SNAP the pre-eviction ring-roll forward gap instead of fataling) — GREEN on delivery-control WITH pump-carrier-fix. doyle gates the full matrix ×3 both runners.
1227
1228	# ── b4 follow-up #3 (todlando+doyle 2026-06-21): the CONTROLLER counterpart — a controller RESUMES-FROM-FLOOR on a b4 drop-don't-block output-gap (RE-FETCH, not snap) ──
1229	[[requirements]]
1230	id = "REQ-HAZARD-CONTROLLER-GAP-RESUME"
1231	title = "A serving CONTROLLER whose serve-brain hits a b4 drop-don't-block FORWARD output gap must RESUME-FROM-FLOOR (re-subscribe from delivered_through and re-fetch the dropped frames from the ring), NOT snap-above and NOT fatal. ROOT (v0.13.0 forkpty re-run, post-keystone): b4 made the controller a non-blocking try_send that DROPS frames when its bounded channel fills (a controller that falls behind its OWN echo under a hard flood), so the next read is a forward gap the strict reject-gap (brain.rs:624/628, B2 exactly-once) FATALS — wedged_viewer_does_not_stall_controller (attach.rs:1048) drove ctrl.read_event() raw and fataled on `output gap got 6134 want 4643`. Pre-b4 the inline sleep-poll BLOCKED the drain to the controller's rate (no drops, no gaps); this is a b4 SIDE-EFFECT, not a new class. A controller CANNOT snap (it is authoritative — advances delivered_through; skipping rolled frames = not-exactly-once = B2 violation), so REQ-HAZARD-VIEWER-RING-ROLL-SNAP does NOT apply. B2 INVARIANT (doyle, broker.rs:327-330): the ring trim is delivered_through-BLIND (`while ring.len() > cap_chunks { pop_front() }`), so re-fetch is exactly-once IFF tail - delivered_through <= cap_chunks (4096) — NOT guaranteed in general, but the common case (burst < ring; wedged_viewer ~1492 < 4096) holds. FIX: serve_attach catches the output-gap on the controller path (does not ?-propagate) and re-subscribes from Brain::controller_resume_floor (= delivered_through = the gap's `want`; NO mid-stream KIND_SESSIONS round-trip — sessions() loops on read_event and would re-fatal on the same gap + discard Output); the broker replays the dropped frames. The IRRECOVERABLE edge (floor unchanged across two resumes = ring rolled past delivered_through = frames gone) surfaces a MARKED truncation to the operator (never silent-skip = B2 lie, never spin) and ends cleanly — full graceful handling deferred to REQ-HAZARD-CONTROLLER-IRRECOVERABLE-BEHIND. Do NOT make the ring trim delivered_through-aware (that risks an unbounded ring under a stuck controller; the 5s eviction + 4096 ring is the practical bound). (v0.13.0)"
1232	required_stages = ["impl", "unit", "int"]  # ACTIVATED v0.13.0 (todlando, doyle-gated). impl = attach.rs read_controller_event_resuming (resume-from-floor on output-gap + ControllerIrrecoverablyBehind detect + swallow own resume-ack) + serve_attach controller-path wiring (operator truncation marker + clean end on irrecoverable) + brain.controller_resume_floor accessor. unit = a controller brain re-subscribed from its resume floor ACCEPTS the contiguous re-fetch, and a still-higher ring-floor RE-gaps (the irrecoverable signal). int = wedged_viewer_does_not_stall_controller (tests/attach.rs) drives the controller via the resume-capable helper read_controller_event_resuming instead of raw ctrl.read_event(). It exercises the gap-resume PATH end-to-end (detect gap → re-subscribe-from-floor → re-fetch-attempt → irrecoverable-boundary → clean marker) + the irrecoverable clean-surface under real forkpty, asserting non-stall by accepting EITHER a rendered ISOLATED marker (recoverable re-fetch, regimes 1-2) OR a clean ControllerIrrecoverablyBehind (ring-exceeding 200KB burst, regime 3). The RECOVERABLE re-fetch SUCCESS is unit-proven (controller_resume_from_floor_refetches_after_a_gap, in-process deterministic — the regime-2 burst is too timing-fragile to stage deterministically on forkpty). doyle gates the forkpty re-run (both runners ×3).
1233
1234	# ── deferred EDGE of REQ-HAZARD-CONTROLLER-GAP-RESUME: the controller fell behind FURTHER than the ring holds (dropped frames gone) ──
1235	[[requirements]]
1236	id = "REQ-HAZARD-CONTROLLER-IRRECOVERABLE-BEHIND"
1237	title = "DEFERRED EDGE of REQ-HAZARD-CONTROLLER-GAP-RESUME: when a serving controller falls behind the live ring FURTHER than the ring holds (tail - delivered_through > cap_chunks, the ring trim being delivered_through-blind, broker.rs:327-330), the dropped frames have rolled OUT of the ring and an exactly-once re-fetch is IMPOSSIBLE. v0.13.0 DETECTS this (resume floor unchanged across two consecutive resumes) and surfaces a MARKED truncation notice to the operator then ends the attach cleanly — it never silent-skips (a B2 lie) and never spins. FULL graceful handling (a clearly-marked snap-with-data-loss that keeps the operator on the live tail, or a structured truncation record the rc renders distinctly, plus the controller-too-slow + ring-too-small backpressure/sizing policy) is DEFERRED — staging it needs a netsplit / deep-behind harness (the in-process loopback rig keeps up; wedged_viewer's gap is recoverable at ~1492 < 4096). (v0.13.0+)"
1238	required_stages = []  # SEED (rule 5, activate-don't-pre-fail): the deferred irrecoverable-behind edge; needs a netsplit/deep-behind harness to stage. Activate real stages when that harness exists.
1239

# ───────────────────────────── v0.12.1 CLI polish (help markdown render) ──────────────────
[[requirements]]
id = "REQ-CLI-HELP-MARKDOWN"
title = "`spt --help` (and every subcommand --help) renders the inline Markdown authored in the clap doc-comments as terminal styling, never as literal markers: `**bold**` → ANSI bold, `` `code` `` → ANSI cyan, `[text](url)` → `text`. The markers are STRIPPED either way — a raw `**` or backtick must NEVER reach the user (the operator-reported v0.12.0 defect: help text reads `**ctrl-b**` and stray backticks verbatim). Color/bold escapes are emitted ONLY when the help is going to a real terminal AND color is not suppressed (NO_COLOR unset · CLICOLOR != 0 · CLICOLOR_FORCE forces on); a pipe / redirect / CI / NO_COLOR falls back to strip-only (clean plaintext, zero escapes) so machine-readable help is byte-identical regardless of marker syntax. Pure transform over the clap-rendered help string at the single run()/bare_invocation chokepoint; preserves pre-existing ANSI (CSI sequences passed through untouched), never spans markers across a newline, leaves unmatched/empty markers literal, and does not alter the help layout. (v0.12.1)"
required_stages = ["impl", "unit"]  # activated v0.12.1: impl = spt/src/helpfmt.rs render() (pure marker transform: bold/code/link, ANSI-CSI passthrough, line-bounded, unmatched-literal) + stdout_color() decision (IsTerminal && NO_COLOR unset && CLICOLOR!=0, CLICOLOR_FORCE overrides) + emit_clap_error() intercepting clap ErrorKind::DisplayHelp / DisplayHelpOnMissingArgumentOrSubcommand at cli::run try_get_matches_mut, plus bare_invocation render_help() path. unit = bold→ansi & code→ansi (color) vs strip (no-color); link→text; empty/unmatched markers left literal; newline never spanned; pre-existing CSI escape passed through byte-exact; color_enabled truth table (NO_COLOR / CLICOLOR=0 / CLICOLOR_FORCE / tty)

# ───────────────────────────── v0.12.2 daemon-start no-window regression ──────────────────
[[requirements]]
id = "REQ-HAZARD-WMI-DAEMON-WINDOW"
title = "`spt daemon start` launches the daemon with NO visible console window. REGRESSION (v0.12.1 L1.5): the WMI job-neutral launch (spawn_daemon_via_wmi) set CREATE_NO_WINDOW on the launching powershell but NOT on the Win32_Process.Create call — Win32_Process.Create does not inherit it, so the spawned cmd.exe env-forwarding wrapper popped a console window on every cold-start (violating REQ-INSTALL-10's v0.7.4 no-persistent-window invariant; the old detached_no_inherit path set DETACHED_PROCESS|CREATE_NO_WINDOW). FIX: pass a Win32_ProcessStartup with CreateFlags=DETACHED_PROCESS (0x8 — no console so no window; CREATE_NO_WINDOW 0x08000000 is NOT a valid Win32_ProcessStartup flag → ReturnValue 21 invalid-param, which is why the naive port fails) + ShowWindow=SW_HIDE(0) belt, via the ProcessStartupInformation argument. (v0.12.2)"
required_stages = ["impl", "unit", "int"]  # activated v0.12.2: impl = wmi_create_script builds the Win32_ProcessStartup no-window spec (CreateFlags=8 DETACHED + ShowWindow=0) passed as ProcessStartupInformation to Win32_Process.Create. unit = wmi_create_script_carries_the_no_window_startup_spec (spec present + passed + DETACHED, never the invalid CREATE_NO_WINDOW). WMI launch VALIDITY (a bad spec → ReturnValue!=0 → no daemon) is independently guarded by job_escape_e2e (the REQ-HAZARD-VIEWER-CLOSE-DETACH int red-flagged the invalid CREATE_NO_WINDOW during the fix). REOPENED v0.13.0 (todlando 2026-06-19, doyle /diagnose): the v0.12.2 fix was INSUFFICIENT — it set DETACHED_PROCESS on the cmd WRAPPER, but the WMI/schtasks rungs then run `start "" /b spt daemon run`, launching the inner BROKER (a console-subsystem exe) with no console to inherit → the broker ALLOCATES ITS OWN conhost = a window (deterministic 3/3: stop → any spt cmd auto-starts via ensure_running → WMI rung → window). FIX = broker self-detach: the auto-start ladder appends a hidden `--detached` flag (spawn_detached args, all rungs) and cmd_daemon_run calls FreeConsole() at startup when set (launch-agnostic; no-op on the already-windowless breakaway/injob rungs; a human foreground `spt daemon run` omits the flag → keeps its console). int ADDED now: a real WMI-cold-start int (job_escape_e2e wmi_daemon_run_has_no_conhost_window) asserts the WMI-launched broker pid has NO conhost.exe child (RED pre-fix: found 1 conhost; GREEN post-fix), with full daemon-tree reap hygiene. impl = spawn_detached --detached arg + daemon::detach_console (FreeConsole) + cmd_daemon_run gate; unit = daemon_subcommands_parse extended (run ⇒ detached:false, run --detached ⇒ true — flag routing); int = no-conhost-child of the WMI broker.

# ───────────────────────────── v0.12.2 CLI polish (command-output markdown render) ──────────
[[requirements]]
id = "REQ-CLI-OUTPUT-MARKDOWN"
title = "Human-prose COMMAND OUTPUT (not just `--help`) renders the inline Markdown authored in its source strings as terminal styling, never literal markers: `` `code` `` → ANSI cyan, `**bold**` → ANSI bold, `[text](url)` → `text`, markers STRIPPED either way. REQ-CLI-HELP-MARKDOWN only hooked the clap `--help` chokepoint, so command output still printed raw Markdown (audit: `spt how-to` topic text showed `# headers`/backticks, `spt subnet`/`subnet status` hint footers showed stray backticks, the daemon-status `not running` line, the `ENDPOINT_RUN_STARTED` attach hint, and the daemon's `SUBNET_DETACHED` startup line — 13 prose surfaces). The same line-bounded pure `helpfmt::render` is applied at each emit site, color-gated by the OUTPUT STREAM's own tty (`stdout_color` for print/println, the new `stderr_color` for eprintln). HARNESS-SAFETY (binding): color is tty-gated, so an adapter (piped / non-tty / NO_COLOR) gets STRIP mode = zero ANSI + markers removed; every dual-contract MACHINE token on a rendered line (`ENDPOINT_RUN_STARTED:`, `NO_SUCH_TOPIC:`, `SUBNET_DETACHED:`) carries NO Markdown markers, so it survives strip byte-intact — the adapter parse is never perturbed. Pure-machine output (the `<EVENT …>` envelope, bringup parse-tokens SEEDED/BOUND/READY/NO_SEED, `--json`, QR) is NEVER routed through the renderer. The one spt-daemon source string (`SUBNET_DETACHED`, the bin-local renderer is unreachable from the daemon crate) is authored marker-free instead. (v0.12.2)"
required_stages = ["impl", "unit"]  # activated v0.12.2: impl = helpfmt::stderr_color() (stderr-tty mirror of stdout_color) + render() wrapped at each human-prose emit site in cli.rs (cmd_how_to topics-header/topic-text/NO_SUCH_TOPIC, render_subnet_status print! callers covering HINT_FOOTER/standalone_text/pump_stall_warning/daemon_offline_hint/inbound_block_hint, daemon-status "not running" line, ENDPOINT_RUN_STARTED hint) + daemon.rs SUBNET_DETACHED authored marker-free. unit = render applied at the sites + a strip-mode dual-contract proof: a machine token line (ENDPOINT_RUN_STARTED:/NO_SUCH_TOPIC:) under color=false emits the token byte-intact with zero ANSI while the prose backticks are stripped; stderr_color mirrors the stdout_color decision table.

# ═════════════════════════════ v0.13.0 — spt-hosted message delivery + control/wedge bug cluster ═════
# Milestone JIT: V0.13.0-DELIVERY-CONTROL-JIT.md. Design: ADR-0022 (delivery redesign). Spine invariant
# = injected keystrokes + a live `spt rc` controller coexist on ONE broker PTY with no control-loss,
# no ONLINE+CONTROLLED latch, no wedge. SEED (stages=[]) — each wave activates its REQ on build; EVERY
# fix gated against the REAL dummy-harness fixture + a real daemon (no mocks/theory — v0.12.0/.1 lesson).

# ── W1 KEYSTONE: injection coexists with the controller (PTY-write coordination) ──
[[requirements]]
id = "REQ-HAZARD-INJECT-CONTROL-COEXIST"
title = "SPINE INVARIANT (v0.13.0 keystone): the broker must accept INJECTED keystrokes into an spt-hosted PTY (the v0.11.0 raw direct-inject today; the ADR-0022 translation-binary choreography tomorrow) WHILE a live `spt rc` controller is attached to the SAME PTY, without (a) the operator losing control, (b) the endpoint latching ONLINE+CONTROLLED, or (c) the broker wedging. The injection inlet is PERMANENT — spt-claude-code requires keystroke injection — so this is root-caused + fixed at the PTY-injection layer, IN STEP with the ADR-0022 delivery redesign that formalizes the inlet. REOPENS the wedge facet of REQ-HAZARD-ATTACH-WEDGE: the v0.12.1 prove-don't-change covered only DEAD-CHILD backpressure, NOT the injection trigger (operator's signal — one injected keystroke succeeds, the next wedges → the single-threaded broker parks on a blocking PTY/loopback write after injection-induced harness output). REPRO-FIRST on the real dummy-harness fixture (NO theory): instrument to nail the exact blocking call before any fix. Fix candidates: non-blocking/fail-fast PTY write, split input/output, bounded-evicting. Mechanism shared with W2 — spt-core owns EVERY PTY write and applies an injected sequence ATOMICALLY (controller input buffered during the sequence, flushed after) so a stash/restore can't be clobbered. CONFIRMED ROOT (doyle /diagnose 2026-06-19, code-grounded): Broker::append (broker.rs:205-227) fans each live output chunk to the CONTROLLER on a SYNCHRONOUS BLOCKING write_frame held inline in the session's drain thread (the 'authoritative, advances delivered_through' path, D4-1), while VIEWERS use a dedicated writer thread + bounded evicting sync_channel (add_viewer:273 / viewer_writer) that can never stall the drain. So a slow/backed-up controller socket — or the full 64KB loopback duplex (the ATTACH-WEDGE buffer) — BLOCKS the drain thread → output stalls → keystroke echoes stall (PERCEIVED input lag) → unrecoverable wedge when the consumer never drains. TRIGGERS ON NORMAL INTERACTIVE rc USE under heavy harness output (TUI redraw), NOT only message injection — same root, wider repro. FIX DIRECTION: move controller delivery off the drain thread onto a dedicated writer (the viewer_writer pattern) BUT preserve the authoritative cursor — block the WRITER thread (not the drain), bound the wedge (deadline → detach/mark-gone, never park forever), never silently evict the operator's authoritative view. (v0.13.0)"
required_stages = ["impl", "unit", "int"]  # ACTIVATED v0.13.0 W1 (todlando). ROOT confirmed repro-first on a REAL broker + REAL PTY (inject_control_wedge.rs): park (a) = OutputLog::append fanned each chunk to the controller via a SYNCHRONOUS inline write_frame on the session drain thread while holding Mutex<OutputLog> → a backed-up controller socket parked the drain WITH THE LOCK HELD → permanent per-session wedge (KIND_SESSIONS hung; net_status answered = process alive). Parks (b)/(c) (write_input write_all on a full input buffer + DSR-answer writer-mutex contention) confirmed BENIGN on Windows ConPTY (absorbs a 4MiB inject), real only on Unix forkpty. FIX: controller delivery moved OFF the drain thread onto a dedicated writer thread + bounded channel (controller_writer, the viewer_writer pattern) but AUTHORITATIVE — the writer advances delivered_through (now Arc<AtomicU64>, advance_delivered/advanced_cursor monotonic); the drain hands each chunk off via a bounded off-lock send (ControllerJob::deliver, CONTROLLER_WRITE_DEADLINE=5s → detach/clear-driven-by/unlatch via the shared clear_controller, never park forever); displaced/exit notices ride the writer channel (CtrlMsg::Control). impl = broker.rs append/controller_writer/become_controller/clear_controller. unit = controller_deliver_is_bounded_never_a_permanent_park + advance_delivered_is_monotonic. int = inject_control_wedge.rs (a_backed_up_controller_does_not_wedge_the_session: a fully-backed-up controller keeps KIND_SESSIONS answering = no wedge). W1<->W5 SYNERGY: clear_controller IS the lost-controller unlatch W5's reconcile reuses.

# ── W1 COMPLETION (b4, todlando+doyle 2026-06-21): controller backpressure must not THROTTLE THE DRAIN / starve a concurrent viewer ──
[[requirements]]
id = "REQ-HAZARD-VIEWER-STARVE-UNDER-CONTROLLER-BACKPRESSURE"
title = "A SLOW controller must not starve a concurrent `rc --view` VIEWER. W1 (REQ-HAZARD-INJECT-CONTROL-COEXIST) moved the controller SOCKET WRITE off the drain thread onto controller_writer, but left the bounded HANDOFF (ControllerJob::deliver) as an INLINE try_send SLEEP-POLL on the drain (broker.rs:1450-1457 → deliver:669-685, up to CONTROLLER_WRITE_DEADLINE=5s). So when a controller drains slower than the PTY floods, its CONTROLLER_CHANNEL_DEPTH(4096) channel fills, deliver() polls inline, and the DRAIN THREAD is throttled to the controller's read rate → OutputLog::append's viewer fan-out (try_send) stops running → a concurrent VIEWER receives only the initial replay then nothing (root 'b4', warm forkpty: a_journaled c1=0/EVICT=0/got_output=FALSE; steady-state-near-full = no recovery; forkpty-only, floods harder than Windows ConPTY). The viewer-not-starved-by-a-busy-session property is legitimate (rc --view of a noisy session must show LIVE output). FIX: the controller becomes a SINGLE NON-BLOCKING try_send (like a viewer), done IN append() under the log lock; deliver()'s sleep-poll DELETED; the drain NEVER sleeps. ControllerSink gains a stateful last_ok deadline → a TRULY-stalled controller (continuous-Full past CONTROLLER_WRITE_DEADLINE) is evicted (bounded-wedge preserved); a slow-but-alive controller DROPS frames + falls behind the ring (resume-from-floor, the existing reconnect case). B2 GAPLESS-HANDOFF PRESERVED via a CONTIGUOUS delivered_through: controller_writer advances the cursor ONLY when the written seq == cursor (next expected); a gap from a drop FREEZES the cursor at last-contiguous so a re-attaching brain's resume_seq never skips a dropped chunk (a high-watermark advance past the gap would be a not-exactly-once resume = B2 violation, doyle's gate). (v0.13.0)"
required_stages = ["impl", "unit", "int"]  # ACTIVATED v0.13.0 impl+unit+INT (todlando, doyle-gated; int activated at the fold). impl = broker.rs OutputLog::append (single non-blocking controller try_send + ControllerSink.last_ok staleness, returns Option<evict-epoch>), drain closure (mark_controller_gone on signal, never sleeps), controller_writer live-path CONTIGUOUS cursor advance (contiguous_advance: compare_exchange seq→seq+1, freeze on gap), ControllerJob::deliver sleep-poll DELETED. unit = a_full_controller_does_not_starve_a_viewer_or_block_the_drain (in-process OutputLog, no PTY) + contiguous_advance_freezes_on_a_gap kernel. INT ACTIVATED at the v0.13.0 fold (todlando, doyle-gated): int = inject_control_wedge.rs a_journaled got_output — GREEN on delivery-control WITH pump-carrier-fix (the carrier kills the subscribed=false measurement artifact; b4 unthrottles the drain; the keystone snaps the pre-eviction ring-roll gap). doyle gates the full matrix ×3 both runners.

# ── b4 follow-up: an evicted VIEWER skips to live instead of dying silently ──
[[requirements]]
id = "REQ-VIEWER-SKIP-TO-LIVE-ON-EVICT"
title = "A `rc --view` VIEWER that overflows its broker subscription queue and is EVICTED (OutputLog::append try_send Full → viewers.remove, REQ-HAZARD-VIEWER-ISOLATION session-protection) must SKIP TO LIVE, not die silently. ROOT (v0.13.0, b4 JIT item 2 = p0_paste + post-b4 a_journaled-Linux, ONE root): serve_attach forwards each frame (read_event→b64decode→re-encode AttachRecord→net_stream_send) SLOWER than the drain fans out under flood → its VIEWER_CHANNEL_DEPTH(256) channel overflows → the drain evicts (viewers.remove drops the ViewerSink → drops tx → viewer_writer's rx.recv() Err → the writer returns WRITING NOTHING) → serve_attach's brain.read_event() just STOPS getting Output (no EOF, no error) → serve_attach blocks forever → the operator receives nothing (attach_received_output=FALSE). Eviction-of-a-hopelessly-behind-viewer is CORRECT session-protection (keep it); SILENT+PERMANENT eviction is the bug. VIEWER-only → B2-SAFE (a viewer never advances delivered_through / is not authoritative / exposes no resume cursor). FIX (doyle-gated, skip-to-live = tail -f reconnect): (1) explicit broker→viewer EVICTION SIGNAL (KIND_VIEWER_EVICTED, written in the viewer_writer thread OFF the log lock, DISTINCT from session-exit EOF so serve must NOT tear down on it); (2) serve_attach re-subscribes from the CURRENT ring floor (skip-to-live, replays nothing, sees the next live burst) — resetting the cold serve-brain's next_seq so the post-eviction forward-jump replay is accepted (the legacy reject-gap path, brain.rs:618-626, would otherwise FATAL the forward jump); (3) HARD constraint NO evict→resubscribe busy-loop: serve_attach rate-limits re-subscribes (RESUBSCRIBE_INTERVAL) so under max-flood the operator sees intermittent LIVE bursts, never a CPU spin. (v0.13.0)"
required_stages = ["doc", "impl", "unit", "int"]  # ACTIVATED v0.13.0 doc+impl+unit+INT (todlando, doyle-gated; int activated at the fold). doc = V0.13.0-SKIP-TO-LIVE-JIT.md. impl = broker.rs (ViewerSink.evicted notifier + append evict arm sets it + viewer_writer emits KIND_VIEWER_EVICTED on an evicted rx-close), msg.rs (KIND_VIEWER_EVICTED + evicted_envelope), brain.rs (BrokerEvent::ViewerEvicted + decode arm + cold-cursor reset), attach.rs (serve_attach ViewerEvicted handling: re-subscribe-from-floor + rate-limit, no teardown). unit = viewer_writer emits the marker on evicted-close + nothing on normal close; serve re-subscribes from floor + does not tear down + rate-limits; cold-brain next_seq reset accepts the post-eviction forward jump. INT ACTIVATED at the v0.13.0 fold (todlando, doyle-gated): int = inject_control_wedge.rs p0_paste got_output (the evicted viewer skips to live → keeps receiving under the flood) — GREEN on delivery-control WITH pump-carrier-fix. doyle gates the full matrix ×3 both runners.

# ── b4 follow-up #2 (todlando+doyle 2026-06-21): a VIEWER tolerates a PRE-eviction ring-roll forward gap (snap, don't fatal) ──
[[requirements]]
id = "REQ-HAZARD-VIEWER-RING-ROLL-SNAP"
title = "A read-only rc --view VIEWER whose serving brain falls behind the live ring under a hard flood and receives a FORWARD Output seq gap (the ring rolled frames out between reads, BEFORE any channel-overflow eviction → NO KIND_VIEWER_EVICTED marker) must SNAP TO LIVE (accept-and-advance via dedup-below + snap-above), NOT fatal with output gap (brain.rs:624/628 legacy reject-gap). ROOT (v0.13.0 forkpty, post-b4+skip-to-live): serve_attach subscribes a viewer via brain.attach_as(Viewer) leaving session_cursors EMPTY → the viewer serve-brain uses the LEGACY reject-gap → a PRE-eviction ring-roll forward-gap FATALS read_event → serve_attach returns → forwarding stops → attach_received_pty_output=FALSE (a_journaled / p0_paste / attach.rs:1071 wedged_viewer, Linux forkpty; Windows ConPTY floods slower → MASKED false-green). DISTINCT from REQ-VIEWER-SKIP-TO-LIVE-ON-EVICT (the POST-eviction re-subscribe-from-floor): this is PRE-eviction gap-tolerance while STILL subscribed. VIEWER-only → B2-SAFE (a viewer never advances delivered_through / is not authoritative); the CONTROLLER keeps strict reject-gap (exactly-once resume). FIX: arm snap-above at initial viewer attach (attach_as_viewer_snap = attach_as(Viewer) + session_cursors.insert(session_id, from_seq)); the two viewer-survival mechanisms COMPOSE — this tolerates pre-eviction ring-roll gaps, REQ-VIEWER-SKIP-TO-LIVE-ON-EVICT recovers post-eviction. (v0.13.0)"
required_stages = ["doc", "impl", "unit", "int"]  # ACTIVATED v0.13.0 doc+impl+unit+INT (todlando, doyle-gated; int activated at the fold). doc = CONTEXT.md viewer paragraph (a viewer snaps a forward ring-roll gap, not just on eviction). impl = brain.attach_as_viewer_snap (attach_as(Viewer) + session_cursors.insert(from_seq)) + serve_attach wiring at intent==Viewer (attach.rs). unit = a viewer-armed brain ACCEPTS a forward Output gap via snap-above (vs the legacy reject-gap a non-viewer cold brain keeps). INT ACTIVATED at the v0.13.0 fold (todlando, doyle-gated): int = inject_control_wedge.rs a_journaled + p0_paste got_output (both viewers SNAP the pre-eviction ring-roll forward gap instead of fataling) — GREEN on delivery-control WITH pump-carrier-fix. doyle gates the full matrix ×3 both runners.

# ── b4 follow-up #3 (todlando+doyle 2026-06-21): the CONTROLLER counterpart — a controller RESUMES-FROM-FLOOR on a b4 drop-don't-block output-gap (RE-FETCH, not snap) ──
[[requirements]]
id = "REQ-HAZARD-CONTROLLER-GAP-RESUME"
title = "A serving CONTROLLER whose serve-brain hits a b4 drop-don't-block FORWARD output gap must RESUME-FROM-FLOOR (re-subscribe from delivered_through and re-fetch the dropped frames from the ring), NOT snap-above and NOT fatal. ROOT (v0.13.0 forkpty re-run, post-keystone): b4 made the controller a non-blocking try_send that DROPS frames when its bounded channel fills (a controller that falls behind its OWN echo under a hard flood), so the next read is a forward gap the strict reject-gap (brain.rs:624/628, B2 exactly-once) FATALS — wedged_viewer_does_not_stall_controller (attach.rs:1048) drove ctrl.read_event() raw and fataled on `output gap got 6134 want 4643`. Pre-b4 the inline sleep-poll BLOCKED the drain to the controller's rate (no drops, no gaps); this is a b4 SIDE-EFFECT, not a new class. A controller CANNOT snap (it is authoritative — advances delivered_through; skipping rolled frames = not-exactly-once = B2 violation), so REQ-HAZARD-VIEWER-RING-ROLL-SNAP does NOT apply. B2 INVARIANT (doyle, broker.rs:327-330): the ring trim is delivered_through-BLIND (`while ring.len() > cap_chunks { pop_front() }`), so re-fetch is exactly-once IFF tail - delivered_through <= cap_chunks (4096) — NOT guaranteed in general, but the common case (burst < ring; wedged_viewer ~1492 < 4096) holds. FIX: serve_attach catches the output-gap on the controller path (does not ?-propagate) and re-subscribes from Brain::controller_resume_floor (= delivered_through = the gap's `want`; NO mid-stream KIND_SESSIONS round-trip — sessions() loops on read_event and would re-fatal on the same gap + discard Output); the broker replays the dropped frames. The IRRECOVERABLE edge (floor unchanged across two resumes = ring rolled past delivered_through = frames gone) surfaces a MARKED truncation to the operator (never silent-skip = B2 lie, never spin) and ends cleanly — full graceful handling deferred to REQ-HAZARD-CONTROLLER-IRRECOVERABLE-BEHIND. Do NOT make the ring trim delivered_through-aware (that risks an unbounded ring under a stuck controller; the 5s eviction + 4096 ring is the practical bound). (v0.13.0)"
required_stages = ["impl", "unit", "int"]  # ACTIVATED v0.13.0 (todlando, doyle-gated). impl = attach.rs read_controller_event_resuming (resume-from-floor on output-gap + ControllerIrrecoverablyBehind detect + swallow own resume-ack) + serve_attach controller-path wiring (operator truncation marker + clean end on irrecoverable) + brain.controller_resume_floor accessor. unit = a controller brain re-subscribed from its resume floor ACCEPTS the contiguous re-fetch, and a still-higher ring-floor RE-gaps (the irrecoverable signal). int = wedged_viewer_does_not_stall_controller (tests/attach.rs) drives the controller via the resume-capable helper read_controller_event_resuming instead of raw ctrl.read_event(). It exercises the gap-resume PATH end-to-end (detect gap → re-subscribe-from-floor → re-fetch-attempt → irrecoverable-boundary → clean marker) + the irrecoverable clean-surface under real forkpty, asserting non-stall by accepting EITHER a rendered ISOLATED marker (recoverable re-fetch, regimes 1-2) OR a clean ControllerIrrecoverablyBehind (ring-exceeding 200KB burst, regime 3). The RECOVERABLE re-fetch SUCCESS is unit-proven (controller_resume_from_floor_refetches_after_a_gap, in-process deterministic — the regime-2 burst is too timing-fragile to stage deterministically on forkpty). doyle gates the forkpty re-run (both runners ×3).

# ── deferred EDGE of REQ-HAZARD-CONTROLLER-GAP-RESUME: the controller fell behind FURTHER than the ring holds (dropped frames gone) ──
[[requirements]]
id = "REQ-HAZARD-CONTROLLER-IRRECOVERABLE-BEHIND"
title = "DEFERRED EDGE of REQ-HAZARD-CONTROLLER-GAP-RESUME: when a serving controller falls behind the live ring FURTHER than the ring holds (tail - delivered_through > cap_chunks, the ring trim being delivered_through-blind, broker.rs:327-330), the dropped frames have rolled OUT of the ring and an exactly-once re-fetch is IMPOSSIBLE. v0.13.0 DETECTS this (resume floor unchanged across two consecutive resumes) and surfaces a MARKED truncation notice to the operator then ends the attach cleanly — it never silent-skips (a B2 lie) and never spins. FULL graceful handling (a clearly-marked snap-with-data-loss that keeps the operator on the live tail, or a structured truncation record the rc renders distinctly, plus the controller-too-slow + ring-too-small backpressure/sizing policy) is DEFERRED — staging it needs a netsplit / deep-behind harness (the in-process loopback rig keeps up; wedged_viewer's gap is recoverable at ~1492 < 4096). (v0.13.0+)"
required_stages = []  # SEED (rule 5, activate-don't-pre-fail): the deferred irrecoverable-behind edge; needs a netsplit/deep-behind harness to stage. Activate real stages when that harness exists.


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


Enable adapter string substitution with adapter_dir key


Bash

wc -l traceable-reqs.toml && echo "---LAST 12 LINES---" && tail -12 traceable-reqs.toml

Find traceable-reqs.toml end + tail


1407 traceable-reqs.toml
---LAST 12 LINES---
required_stages = ["doc", "impl", "unit"]  # v0.13.2 W6. doc = DOCS-STRATEGY public-help no-internal-codes rule. impl = (a) the forbidden-token scan in xtask check over reference.md; (b) the sweep of all existing clap-/// leak sites. unit = xtask test: a planted forbidden token FAILS the scan, a clean reference PASSES (non-vacuous).

# ── Endpoint-creation-flow milestone (operator grill 2026-06-22; ADR-0026/0027) ──
# doc stage ACTIVE (the ADR IS the doc evidence); impl/unit/int activate at the milestone build (rule 5).
[[requirements]]
id = "REQ-RUN-MULTISUBNET-HOME"
title = "`spt endpoint run` resolves the home subnet at the skeleton-create step and pre-creates the skeleton perch carrying it, so the harness `bind` inherits home via establish_perch's immutable prior-branch (no hook change, no env injection). Resolution: sole-subnet auto; multi-subnet + no --subnet + NON-interactive terminal -> refuse early with MRU-ordered --subnet guidance (never the silent 25s online-timeout); multi-subnet + no --subnet + INTERACTIVE -> print proposed config (id/project/adapter[:profile]/home=MRU-default) + 'Ok to proceed? Y/n', n -> --subnet guidance; --subnet overrides + validates membership. MRU = ordered move-to-front LISTs at two levels (per-project + always-updated node-global fallback). Home stays IMMUTABLE (ADR-0010). Fixes the LATENT multi-subnet bringup gap (perri, not a regression — HOME_REFUSED established >=0.11.0; exposed by the node crossing 1->2 subnets). (ADR-0026)"
required_stages = ["doc", "impl", "unit", "int"]  # doc = ADR-0026 + CONTEXT home-subnet. impl+unit activated at W2 (resolve + UNBOUND skeleton-with-home; the node-global MRU default). int activated at W6 — multi_subnet_bringup_e2e on a >=2-subnet node (single-subnet auto-homes + hides the gap) + bind-inherit + no-double-seed.
[[requirements]]
id = "REQ-ENDPOINT-UNBOUND-ATTACH"
title = "An spt-hosted endpoint is ATTACHABLE between spawn and bind: gate the attach on the broker SESSION being attachable (session+PTY+OutputLog exist at spawn, before bind), not on perch STATUS_ONLINE (bind). cmd_endpoint_run + `spt rc <id>` attach to a live broker session regardless of perch status (headless bringups too; lets an operator clear a bind-gating prompt) -- replaces await_endpoint_online; preserves REQ-HAZARD-RC-ATTACH-ONLINE-RACE's 'no attach before a session' intent at the earlier session-exists point; source = the broker sessions map (ADR-0025 W3a); local-only. New on-disk status STATUS_UNBOUND (spawn->unbound, bind->online, death->offline); lifecycle reuses the existing exit-waiter/reconcile (session death->offline); unbound is attachable but NOT message-addressable (messaging stays online/bound-gated). EpDisplay gains Unbound = HOLLOW (+ hollow-controlled variant) -- amber=HarnessOnly is taken + means not-controllable (the opposite of attachable). (ADR-0027)"
required_stages = ["doc", "impl", "unit", "int"]  # doc active at design. impl+unit activated at W1. int activated at W6 — multi_subnet_bringup_e2e (fresh hold-unbound rc-attach pre-bind) + picker gather_renders_live_unbound (real render seam).


Format confirmed. Writing the artifacts — ADR, traceable seeds (stages `[]` per rule 5), CONTEXT note.


Write

# Adapter-update arc: resolve-not-execute hooks, composite `[update.post]`, translation-binary command

<!-- [doc->REQ-MANIFEST-SUBST] [doc->REQ-ADAPTER-UPDATE-POST] [doc->REQ-TRANSLATE-COMMAND] -->

## Status

accepted (2026-06-25; design grilled with operator + `doyle`, `/grill-with-docs`). Targets **v0.8.0**. Pairs with the downstream `spt-claude-code` ADR-0005 (name unification) + ADR-0006 (one-command update + consolidated binary) — this ADR is spt-core's half of the asks those raised. Builds on ADR-0022 (translation-binary idle delivery), ADR-0024/0025 (multi-platform `.spt` packaging + live daemon-coordinated adapter update), and the `[strings]` / `get-string` surface (REQ-MANIFEST-3/5). Full per-item spec: `docs/design/v0.8.0-update-arc-and-cli.md`.

## Context

The operator wants the whole claude-spt adapter kept current with effectively **one lever** (`spt adapter update claude-spt`). Two facts (ADR-0006) bound it: hook **logic must physically live in the CC plugin dir** (CC loads hooks there), outside `spt adapter update`'s reach; and raw skills/commands can't replace the plugin. Closing the gap surfaced three spt-core asks. The first — *generic hook dispatch* — was proposed as `spt api run-hook <adapter> <event>`: spt-core would **execute** the adapter's hook handler. That inverts the hooks contract (today `[hooks.<event>]` is purely outbound — the harness fires `fires`, spt-core never executes a hook) and adds a new spt-core execution surface, in tension with the harness-agnostic boundary ("spt-core itself contains zero Claude Code conventions" / "nothing in spt-core ever executes a string").

## Decision

Keep spt-core a **pure resolver** — it never grows a hook-execution surface. Three changes:

### 1. Resolve-not-execute (rejects `run-hook`)

Add two general substitution primitives so an adapter resolves+runs its **own** packed binary:
- **`{adapter_dir}`** (the registry record's precise `source_dir` — install dir, survives updates, the dir bare-program resolution already uses) and **`{adapter_name}`**, available wherever command/string substitution runs.
- **Lazy substitution inside `[strings]` values at `get-string` read time**, 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.

The invariant holds: spt-core substitutes and **returns** a string; the adapter's own wrapper executes it. A CC hook dispatcher does `get-string claude-spt hook_cmd` → `"{adapter_dir}/claude-spt hook"` **once per session** (memoized into an env var, for the PostToolUse hot path), then runs that per-hook. Hook *logic* rides `spt adapter update` (it's in the adapter binary); the plugin's `hooks.json` + a thin static per-OS dispatch wrapper go static-forever.

### 2. `[message-idle-translation-binary]` takes a `command`

Add a sibling optional **`command`** (opaque; args + `{key}`/`{adapter_dir}` substitution; program token resolved against `install_dir` like `[digest].extractor`/`[session.psyche_init]`). **`path` deprecated** — keeps parsing (forward/back-compat), warns at registration. Exactly one of `{path, command}`. Spawn/stdin protocol unchanged. Folds `claude-spt translate` into the one consolidated binary (ADR-0006).

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

Add an **avenue-agnostic `[update.post]`** sub-table `{ command, self_verifies }`, run after the primary avenue resolves. Runs **unconditionally** (even on an adapter no-op — its own idempotent check decides). Published **stdin JSON** seam (`adapter_applied`, `adapter_name`, `profile_name`, `version`, `previous_version`, `adapter_dir`; additive keys). **stdout decides the notice:** custom text **supersedes** `[update].message`; a reserved sentinel fires the static `[update].message`; empty = nothing. exit code orthogonal (0 ok / nonzero failed). Precedence: dynamic-stdout > sentinel/manifest-message > nothing; **no `[update.post]`** ⇒ today's `adapter_applied`→message; **post-step fails** ⇒ warn + fall back to `adapter_applied`→message. **Failure-isolated** — a committed `gh_release` pull is not rolled back if the post-step fails (independent channels).

## Considered and rejected

- **`spt api run-hook <adapter> <event>`** (spt-core executes the adapter's hook handler). Would work and keep `hooks.json` static-forever with no plugin-resident shell, but adds a new spt-core *execution* surface and inverts the outbound-only hooks contract. Rejected for **resolve-not-execute**: two reusable primitives (`{adapter_dir}` + `[strings]` substitution) over one narrow execution trick, and spt-core executes nothing. Cost accepted: a thin static per-OS dispatch wrapper stays plugin-resident, and hook dispatch is ~2 spawns unless the resolved path is memoized per session (it is).
- **Overloading `[message-idle-translation-binary].path` to mean "path or command".** Ambiguous; a distinct `command` mirrors the other seams. Rejected.
- **`post_update` field bolted onto `gh_release`, or an ordered-avenue list.** The first is over-coupled, the second over-general. An avenue-agnostic `[update.post]` (like `message` is avenue-agnostic) is the minimal shape.

## Consequences

- spt-core stays a pure resolver — strongest harness-agnostic posture; no new execution surface.
- `{adapter_dir}`/`{adapter_name}` + `[strings]` substitution are reusable beyond hooks (any string pointing at a packed binary/resource).
- The one-lever goal lands: `spt adapter update claude-spt` pulls the `.spt` **and** runs the plugin post-step; the only manual residual is `/reload-plugins` (an unautomatable CC TUI action), carried by `[update].message` (or the post-step's dynamic notice).
- ADR-0005's repo rename (`spt-claude-code`→`claude-spt`) is honored across `[update].repo` + install-dir derivation.
- Public docs (MANIFEST.md, reference.md via xtask, gh-pages integration-checklist/messaging) ride the v0.8.0 release.


File created successfully at: C:\Users\decid\Documents\projects\spt-core\docs\adr\0029-adapter-update-arc-resolve-not-execute.md (file state is current in your context — no need to Read it back)

# Adapter-update arc: resolve-not-execute hooks, composite `[update.post]`, translation-binary command

<!-- [doc->REQ-MANIFEST-SUBST] [doc->REQ-ADAPTER-UPDATE-POST] [doc->REQ-TRANSLATE-COMMAND] -->

## Status

accepted (2026-06-25; design grilled with operator + `doyle`, `/grill-with-docs`). Targets **v0.8.0**. Pairs with the downstream `spt-claude-code` ADR-0005 (name unification) + ADR-0006 (one-command update + consolidated binary) — this ADR is spt-core's half of the asks those raised. Builds on ADR-0022 (translation-binary idle delivery), ADR-0024/0025 (multi-platform `.spt` packaging + live daemon-coordinated adapter update), and the `[strings]` / `get-string` surface (REQ-MANIFEST-3/5). Full per-item spec: `docs/design/v0.8.0-update-arc-and-cli.md`.

## Context

The operator wants the whole claude-spt adapter kept current with effectively **one lever** (`spt adapter update claude-spt`). Two facts (ADR-0006) bound it: hook **logic must physically live in the CC plugin dir** (CC loads hooks there), outside `spt adapter update`'s reach; and raw skills/commands can't replace the plugin. Closing the gap surfaced three spt-core asks. The first — *generic hook dispatch* — was proposed as `spt api run-hook <adapter> <event>`: spt-core would **execute** the adapter's hook handler. That inverts the hooks contract (today `[hooks.<event>]` is purely outbound — the harness fires `fires`, spt-core never executes a hook) and adds a new spt-core execution surface, in tension with the harness-agnostic boundary ("spt-core itself contains zero Claude Code conventions" / "nothing in spt-core ever executes a string").

## Decision

Keep spt-core a **pure resolver** — it never grows a hook-execution surface. Three changes:

### 1. Resolve-not-execute (rejects `run-hook`)

Add two general substitution primitives so an adapter resolves+runs its **own** packed binary:
- **`{adapter_dir}`** (the registry record's precise `source_dir` — install dir, survives updates, the dir bare-program resolution already uses) and **`{adapter_name}`**, available wherever command/string substitution runs.
- **Lazy substitution inside `[strings]` values at `get-string` read time**, 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.

The invariant holds: spt-core substitutes and **returns** a string; the adapter's own wrapper executes it. A CC hook dispatcher does `get-string claude-spt hook_cmd` → `"{adapter_dir}/claude-spt hook"` **once per session** (memoized into an env var, for the PostToolUse hot path), then runs that per-hook. Hook *logic* rides `spt adapter update` (it's in the adapter binary); the plugin's `hooks.json` + a thin static per-OS dispatch wrapper go static-forever.

### 2. `[message-idle-translation-binary]` takes a `command`

Add a sibling optional **`command`** (opaque; args + `{key}`/`{adapter_dir}` substitution; program token resolved against `install_dir` like `[digest].extractor`/`[session.psyche_init]`). **`path` deprecated** — keeps parsing (forward/back-compat), warns at registration. Exactly one of `{path, command}`. Spawn/stdin protocol unchanged. Folds `claude-spt translate` into the one consolidated binary (ADR-0006).

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

Add an **avenue-agnostic `[update.post]`** sub-table `{ command, self_verifies }`, run after the primary avenue resolves. Runs **unconditionally** (even on an adapter no-op — its own idempotent check decides). Published **stdin JSON** seam (`adapter_applied`, `adapter_name`, `profile_name`, `version`, `previous_version`, `adapter_dir`; additive keys). **stdout decides the notice:** custom text **supersedes** `[update].message`; a reserved sentinel fires the static `[update].message`; empty = nothing. exit code orthogonal (0 ok / nonzero failed). Precedence: dynamic-stdout > sentinel/manifest-message > nothing; **no `[update.post]`** ⇒ today's `adapter_applied`→message; **post-step fails** ⇒ warn + fall back to `adapter_applied`→message. **Failure-isolated** — a committed `gh_release` pull is not rolled back if the post-step fails (independent channels).

## Considered and rejected

- **`spt api run-hook <adapter> <event>`** (spt-core executes the adapter's hook handler). Would work and keep `hooks.json` static-forever with no plugin-resident shell, but adds a new spt-core *execution* surface and inverts the outbound-only hooks contract. Rejected for **resolve-not-execute**: two reusable primitives (`{adapter_dir}` + `[strings]` substitution) over one narrow execution trick, and spt-core executes nothing. Cost accepted: a thin static per-OS dispatch wrapper stays plugin-resident, and hook dispatch is ~2 spawns unless the resolved path is memoized per session (it is).
- **Overloading `[message-idle-translation-binary].path` to mean "path or command".** Ambiguous; a distinct `command` mirrors the other seams. Rejected.
- **`post_update` field bolted onto `gh_release`, or an ordered-avenue list.** The first is over-coupled, the second over-general. An avenue-agnostic `[update.post]` (like `message` is avenue-agnostic) is the minimal shape.

## Consequences

- spt-core stays a pure resolver — strongest harness-agnostic posture; no new execution surface.
- `{adapter_dir}`/`{adapter_name}` + `[strings]` substitution are reusable beyond hooks (any string pointing at a packed binary/resource).
- The one-lever goal lands: `spt adapter update claude-spt` pulls the `.spt` **and** runs the plugin post-step; the only manual residual is `/reload-plugins` (an unautomatable CC TUI action), carried by `[update].message` (or the post-step's dynamic notice).
- ADR-0005's repo rename (`spt-claude-code`→`claude-spt`) is honored across `[update].repo` + install-dir derivation.
- Public docs (MANIFEST.md, reference.md via xtask, gh-pages integration-checklist/messaging) ride the v0.8.0 release.


Appending the REQ seeds (cluster A doc-ratified by the ADR; cluster B + impl/unit/int seeded for the build wave per rule 5):


Edit

required_stages = ["doc", "impl", "unit", "int"]  # doc active at design. impl+unit activated at W1. int activated at W6 — multi_subnet_bringup_e2e (fresh hold-unbound rc-attach pre-bind) + picker gather_renders_live_unbound (real render seam).

required_stages = ["doc", "impl", "unit", "int"]  # doc active at design. impl+unit activated at W1. int activated at W6 — multi_subnet_bringup_e2e (fresh hold-unbound rc-attach pre-bind) + picker gather_renders_live_unbound (real render seam).

# ═════════════════════════════ v0.8.0 — adapter-update arc + CLI/UX features ═════════════════════════════
# Grill 2026-06-25 (operator + doyle, /grill-with-docs). Design: docs/design/v0.8.0-update-arc-and-cli.md.
# Cluster A (the claude-spt one-lever-update asks) is doc-ratified by ADR-0029; impl/unit/int + all of
# cluster B (standalone CLI/UX) activate at the v0.8.0 build wave (rule 5: activate-don't-pre-fail).

# ── Cluster A: the adapter-update arc (ADR-0029) ──
[[requirements]]
id = "REQ-MANIFEST-SUBST"
title = "Manifest substitution primitives for resolve-not-execute (ADR-0029, supersedes a rejected `spt api run-hook`): (1) two adapter-static substitution keys `{adapter_dir}` (the registry record's precise source_dir — install dir, survives updates, the dir bare-program resolution uses) and `{adapter_name}`, available wherever command/string substitution runs; (2) lazy substitution INSIDE `[strings]` values at `get-string` read time, scoped to those adapter-static keys ONLY (session-scoped {id}/{session_id}/… are NOT available — get-string carries no session; a get-string --session-id is a deferred larger change). Invariant preserved: spt-core never executes a string — it substitutes and returns; the adapter's own wrapper executes the result (e.g. a CC hook dispatcher get-strings its packed binary once per session into an env var, then runs it per-hook, so hook logic rides `spt adapter update`). (v0.8.0)"
required_stages = ["doc"]  # doc = ADR-0029 + docs/design/v0.8.0-update-arc-and-cli.md. impl+unit activate at the v0.8.0 build: {adapter_dir}/{adapter_name} resolution against source_dir + [strings] value substitution (adapter-static keys, lazy at get-string, file-backed-string parity).
[[requirements]]
id = "REQ-ADAPTER-UPDATE-POST"
title = "Composite adapter update — an avenue-agnostic `[update.post]` sub-table `{ command, self_verifies }` run AFTER the primary avenue (gh_release/file_pull/delegated) resolves, in the same `spt adapter update` (ADR-0029). Runs UNCONDITIONALLY (even on an adapter version no-op — the post-step's own idempotent check decides). PUBLISHED stdin JSON seam: one line `{adapter_applied, adapter_name, profile_name, version, previous_version, adapter_dir}` (additive keys; post-step ignores unknown). stdout decides the notice: custom text SUPERSEDES [update].message; a reserved sentinel fires the static [update].message; empty = no notice. exit code orthogonal (0 ok / nonzero failed). Precedence: dynamic-stdout > sentinel/manifest-message > nothing. NO [update.post] declared ⇒ today's adapter_applied→[update].message unchanged; post-step FAILS ⇒ loud warning + fall back to adapter_applied→message. FAILURE-ISOLATED: a committed gh_release pull is never rolled back if the post-step fails (independent channels). (v0.8.0)"
required_stages = ["doc"]  # doc = ADR-0029. impl+unit+int activate at the v0.8.0 build: [update.post] parse + post-run after re-register + stdin-seam compose + stdout/exit notice-arbitration + failure isolation; int = a delegated post-step fires + supersede/sentinel/empty + no-op-unconditional-run + fail-isolated.
[[requirements]]
id = "REQ-TRANSLATE-COMMAND"
title = "`[message-idle-translation-binary]` accepts a `command` (opaque; args + {key}/{adapter_dir} substitution; program token resolved against install_dir like [digest].extractor/[session.psyche_init]) in addition to the bare `path`. `path` is DEPRECATED — keeps parsing (manifest forward/back-compat) but emits a registration warning steering to command. Exactly one of {path, command} (both-set refused at registration; neither = no translation binary). The spawn lifecycle + stdin/stdout JSON-lines protocol (Init/Event/Input → key/text/delay_ms/commit) are UNCHANGED — command alters only how the executable+args are located/launched (read_translation_path → read_translation_command). Unblocks folding `claude-spt translate` into the one consolidated binary (downstream ADR-0006). (v0.8.0)"
required_stages = ["doc"]  # doc = ADR-0029. impl+unit activate at the v0.8.0 build: IdleTranslationBinary.command (Option) + path-deprecation warn + both-set/neither validation + resolve+spawn with args; unit = parse (command/path/both-refused) + spawn carries the subcommand arg.

# ── Cluster B: standalone CLI/UX asks (design doc; seeded, activate at v0.8.0 build) ──
[[requirements]]
id = "REQ-SEND-REPLYTO-REMOVE"
title = "Remove `--reply-to` from `spt send` — a target-fallback + REPLIED-label nicety that confuses agents, with no wire effect (ADR-0020 already made messages structural (from,body), no __REPLY_TO__). Hard-remove (no deprecation shim): the clap flag, the is_reply/REPLIED label branch (always SENT/QUEUED), the `send` how-to --reply-to example, and the reply-to mention in REQ-DOCS-6's send topic. Reply-correlation stays on the structural `from` attribute. (v0.8.0)"
required_stages = []  # SEED (rule 5). v0.8.0 build: hard-remove across cli.rs + how-to + reference.md regen; amend REQ-DOCS-6 title.
[[requirements]]
id = "REQ-DIGEST-CURSOR"
title = "`spt endpoint digest` gains incremental turn-end consumption (extends REQ-TERM-4/5): `--last <N>` = the last N TURNS (the digest's natural unit; --last 1 = the latest turn = turn-end output); a per-entry STABLE SOURCE-DERIVED `seq` in the --json output (deterministic from the entry's append position in the source — transcript record index across the session ledger / digest.log index — so a live re-projection yields the same seq for the same committed entry; NOT a window-position index that renumbers on slide); `--after <seq>` = entries newer than seq still in the window (full window + signal if seq predates it, mirroring the version-slide full-refresh). An in-flight (still-growing) entry is flagged `partial: true` with NO stable seq until finalized (consumer reprocesses partial, skips <= seq). Also emit per-entry `ts` where present (seq is the authoritative dedup+cursor key). The digest's agent text is sufficient fidelity (no raw-source mode). BINDING doc-guidance: an adapter's [digest] extractor / api digest-entry MUST classify delivered user-facing messages as turn-opening `input` (equiv to direct PTY user-input), else messaging-driven sessions collapse into a few giant turns and --last/seq lose granularity. (v0.8.0)"
required_stages = []  # SEED (rule 5). v0.8.0 build: --last/--after flags + source-derived seq + partial flag through projection; doc-guidance into MANIFEST.md/integration-checklist; reference.md.
[[requirements]]
id = "REQ-CLI-JSON"
title = "A global `--json` flag (clap global=true) honored by the READ/STATUS command 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 (endpoint digest already has it). Action commands do not honor it. A shared print_json() helper + a coverage TEST asserting every command in the set emits valid JSON (guards against the missing-shared-formatter drift). Output uses explicit per-command output DTOs with committed field names — internal structs are NOT serialized verbatim (their fields would become a public contract; JSON is a consumed wire-parity surface). (v0.8.0)"
required_stages = []  # SEED (rule 5). v0.8.0 build: global --json + print_json + DTOs per query command + Serialize derives + coverage test; reference.md per command.
[[requirements]]
id = "REQ-RUN-EMPTY-CREATE"
title = "`spt endpoint run` / bare `spt` routes a TOTALLY-EMPTY scope straight to the endpoint-creation flow: when gather_endpoints() is empty (nothing attachable, local OR subnet) PickerModel::new opens on Screen::CreateAdapter instead of PickExisting (today it always opens PickExisting + renders a blank list). A node WITH subnet endpoints but no local ones still has things to pick → stays on the picker. Extends REQ-RUN-PICKER. (v0.8.0)"
required_stages = []  # SEED (rule 5). v0.8.0 build: one branch in PickerModel::new (empty endpoint set → create screen) + picker test (empty → create, non-empty → pick).
[[requirements]]
id = "REQ-RC-IDENTITY"
title = "`spt rc` overlays a persistent endpoint-identity marker so the operator always groks which endpoint they control: a reserved TOP status row via a 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 (output-scanning, like the existing mouse_scanner). NO window title (the harness, e.g. CC, owns it for busyness — OSC dropped). Resolve subnet/node/id once at attach (perch/registry read) and thread into the pump; subnet = the endpoint's home/primary (\"local\" if none). The literal floating rounded-rectangle corner box is DEFERRED to the future web-based GUI (not a grid-model rc — that lift is better spent on the GUI). (v0.8.0)"
required_stages = []  # SEED (rule 5). v0.8.0 build: identity resolve at attach + DECSTBM reserved-row paint + alt-screen/resize re-assert in rc.rs pump; the floating box stays deferred.


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

required_stages = ["doc", "impl", "unit", "int"]  # doc active at design. impl+unit activated at W1. int activated at W6 — multi_subnet_bringup_e2e (fresh hold-unbound rc-attach pre-bind) + picker gather_renders_live_unbound (real render seam).

# ═════════════════════════════ v0.8.0 — adapter-update arc + CLI/UX features ═════════════════════════════
# Grill 2026-06-25 (operator + doyle, /grill-with-docs). Design: docs/design/v0.8.0-update-arc-and-cli.md.
# Cluster A (the claude-spt one-lever-update asks) is doc-ratified by ADR-0029; impl/unit/int + all of
# cluster B (standalone CLI/UX) activate at the v0.8.0 build wave (rule 5: activate-don't-pre-fail).

# ── Cluster A: the adapter-update arc (ADR-0029) ──
[[requirements]]
id = "REQ-MANIFEST-SUBST"
title = "Manifest substitution primitives for resolve-not-execute (ADR-0029, supersedes a rejected `spt api run-hook`): (1) two adapter-static substitution keys `{adapter_dir}` (the registry record's precise source_dir — install dir, survives updates, the dir bare-program resolution uses) and `{adapter_name}`, available wherever command/string substitution runs; (2) lazy substitution INSIDE `[strings]` values at `get-string` read time, scoped to those adapter-static keys ONLY (session-scoped {id}/{session_id}/… are NOT available — get-string carries no session; a get-string --session-id is a deferred larger change). Invariant preserved: spt-core never executes a string — it substitutes and returns; the adapter's own wrapper executes the result (e.g. a CC hook dispatcher get-strings its packed binary once per session into an env var, then runs it per-hook, so hook logic rides `spt adapter update`). (v0.8.0)"
required_stages = ["doc"]  # doc = ADR-0029 + docs/design/v0.8.0-update-arc-and-cli.md. impl+unit activate at the v0.8.0 build: {adapter_dir}/{adapter_name} resolution against source_dir + [strings] value substitution (adapter-static keys, lazy at get-string, file-backed-string parity).
[[requirements]]
id = "REQ-ADAPTER-UPDATE-POST"
title = "Composite adapter update — an avenue-agnostic `[update.post]` sub-table `{ command, self_verifies }` run AFTER the primary avenue (gh_release/file_pull/delegated) resolves, in the same `spt adapter update` (ADR-0029). Runs UNCONDITIONALLY (even on an adapter version no-op — the post-step's own idempotent check decides). PUBLISHED stdin JSON seam: one line `{adapter_applied, adapter_name, profile_name, version, previous_version, adapter_dir}` (additive keys; post-step ignores unknown). stdout decides the notice: custom text SUPERSEDES [update].message; a reserved sentinel fires the static [update].message; empty = no notice. exit code orthogonal (0 ok / nonzero failed). Precedence: dynamic-stdout > sentinel/manifest-message > nothing. NO [update.post] declared ⇒ today's adapter_applied→[update].message unchanged; post-step FAILS ⇒ loud warning + fall back to adapter_applied→message. FAILURE-ISOLATED: a committed gh_release pull is never rolled back if the post-step fails (independent channels). (v0.8.0)"
required_stages = ["doc"]  # doc = ADR-0029. impl+unit+int activate at the v0.8.0 build: [update.post] parse + post-run after re-register + stdin-seam compose + stdout/exit notice-arbitration + failure isolation; int = a delegated post-step fires + supersede/sentinel/empty + no-op-unconditional-run + fail-isolated.
[[requirements]]
id = "REQ-TRANSLATE-COMMAND"
title = "`[message-idle-translation-binary]` accepts a `command` (opaque; args + {key}/{adapter_dir} substitution; program token resolved against install_dir like [digest].extractor/[session.psyche_init]) in addition to the bare `path`. `path` is DEPRECATED — keeps parsing (manifest forward/back-compat) but emits a registration warning steering to command. Exactly one of {path, command} (both-set refused at registration; neither = no translation binary). The spawn lifecycle + stdin/stdout JSON-lines protocol (Init/Event/Input → key/text/delay_ms/commit) are UNCHANGED — command alters only how the executable+args are located/launched (read_translation_path → read_translation_command). Unblocks folding `claude-spt translate` into the one consolidated binary (downstream ADR-0006). (v0.8.0)"
required_stages = ["doc"]  # doc = ADR-0029. impl+unit activate at the v0.8.0 build: IdleTranslationBinary.command (Option) + path-deprecation warn + both-set/neither validation + resolve+spawn with args; unit = parse (command/path/both-refused) + spawn carries the subcommand arg.

# ── Cluster B: standalone CLI/UX asks (design doc; seeded, activate at v0.8.0 build) ──
[[requirements]]
id = "REQ-SEND-REPLYTO-REMOVE"
title = "Remove `--reply-to` from `spt send` — a target-fallback + REPLIED-label nicety that confuses agents, with no wire effect (ADR-0020 already made messages structural (from,body), no __REPLY_TO__). Hard-remove (no deprecation shim): the clap flag, the is_reply/REPLIED label branch (always SENT/QUEUED), the `send` how-to --reply-to example, and the reply-to mention in REQ-DOCS-6's send topic. Reply-correlation stays on the structural `from` attribute. (v0.8.0)"
required_stages = []  # SEED (rule 5). v0.8.0 build: hard-remove across cli.rs + how-to + reference.md regen; amend REQ-DOCS-6 title.
[[requirements]]
id = "REQ-DIGEST-CURSOR"
title = "`spt endpoint digest` gains incremental turn-end consumption (extends REQ-TERM-4/5): `--last <N>` = the last N TURNS (the digest's natural unit; --last 1 = the latest turn = turn-end output); a per-entry STABLE SOURCE-DERIVED `seq` in the --json output (deterministic from the entry's append position in the source — transcript record index across the session ledger / digest.log index — so a live re-projection yields the same seq for the same committed entry; NOT a window-position index that renumbers on slide); `--after <seq>` = entries newer than seq still in the window (full window + signal if seq predates it, mirroring the version-slide full-refresh). An in-flight (still-growing) entry is flagged `partial: true` with NO stable seq until finalized (consumer reprocesses partial, skips <= seq). Also emit per-entry `ts` where present (seq is the authoritative dedup+cursor key). The digest's agent text is sufficient fidelity (no raw-source mode). BINDING doc-guidance: an adapter's [digest] extractor / api digest-entry MUST classify delivered user-facing messages as turn-opening `input` (equiv to direct PTY user-input), else messaging-driven sessions collapse into a few giant turns and --last/seq lose granularity. (v0.8.0)"
required_stages = []  # SEED (rule 5). v0.8.0 build: --last/--after flags + source-derived seq + partial flag through projection; doc-guidance into MANIFEST.md/integration-checklist; reference.md.
[[requirements]]
id = "REQ-CLI-JSON"
title = "A global `--json` flag (clap global=true) honored by the READ/STATUS command 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 (endpoint digest already has it). Action commands do not honor it. A shared print_json() helper + a coverage TEST asserting every command in the set emits valid JSON (guards against the missing-shared-formatter drift). Output uses explicit per-command output DTOs with committed field names — internal structs are NOT serialized verbatim (their fields would become a public contract; JSON is a consumed wire-parity surface). (v0.8.0)"
required_stages = []  # SEED (rule 5). v0.8.0 build: global --json + print_json + DTOs per query command + Serialize derives + coverage test; reference.md per command.
[[requirements]]
id = "REQ-RUN-EMPTY-CREATE"
title = "`spt endpoint run` / bare `spt` routes a TOTALLY-EMPTY scope straight to the endpoint-creation flow: when gather_endpoints() is empty (nothing attachable, local OR subnet) PickerModel::new opens on Screen::CreateAdapter instead of PickExisting (today it always opens PickExisting + renders a blank list). A node WITH subnet endpoints but no local ones still has things to pick → stays on the picker. Extends REQ-RUN-PICKER. (v0.8.0)"
required_stages = []  # SEED (rule 5). v0.8.0 build: one branch in PickerModel::new (empty endpoint set → create screen) + picker test (empty → create, non-empty → pick).
[[requirements]]
id = "REQ-RC-IDENTITY"
title = "`spt rc` overlays a persistent endpoint-identity marker so the operator always groks which endpoint they control: a reserved TOP status row via a 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 (output-scanning, like the existing mouse_scanner). NO window title (the harness, e.g. CC, owns it for busyness — OSC dropped). Resolve subnet/node/id once at attach (perch/registry read) and thread into the pump; subnet = the endpoint's home/primary (\"local\" if none). The literal floating rounded-rectangle corner box is DEFERRED to the future web-based GUI (not a grid-model rc — that lift is better spent on the GUI). (v0.8.0)"
required_stages = []  # SEED (rule 5). v0.8.0 build: identity resolve at attach + DECSTBM reserved-row paint + alt-screen/resize re-assert in rc.rs pump; the floating box stays deferred.

title = "An spt-hosted endpoint is ATTACHABLE between spawn and bind: gate the attach on the broker SESSION being attachable (session+PTY+OutputLog exist at spawn, before bind), not on perch STATUS_ONLINE (bind). cmd_endpoint_run + `spt rc <id>` attach to a live broker session regardless of perch status (headless bringups too; lets an operator clear a bind-gating prompt) -- replaces await_endpoint_online; preserves REQ-HAZARD-RC-ATTACH-ONLINE-RACE's 'no attach before a session' intent at the earlier session-exists point; source = the broker sessions map (ADR-0025 W3a); local-only. New on-disk status STATUS_UNBOUND (spawn->unbound, bind->online, death->offline); lifecycle reuses the existing exit-waiter/reconcile (session death->offline); unbound is attachable but NOT message-addressable (messaging stays online/bound-gated). EpDisplay gains Unbound = HOLLOW (+ hollow-controlled variant) -- amber=HarnessOnly is taken + means not-controllable (the opposite of attachable). (ADR-0027)"

+# ═════════════════════════════ v0.8.0 — adapter-update arc + CLI/UX features ═════════════════════════════

+# Grill 2026-06-25 (operator + doyle, /grill-with-docs). Design: docs/design/v0.8.0-update-arc-and-cli.md.

+# Cluster A (the claude-spt one-lever-update asks) is doc-ratified by ADR-0029; impl/unit/int + all of

+# cluster B (standalone CLI/UX) activate at the v0.8.0 build wave (rule 5: activate-don't-pre-fail).

+# ── Cluster A: the adapter-update arc (ADR-0029) ──

+title = "Manifest substitution primitives for resolve-not-execute (ADR-0029, supersedes a rejected `spt api run-hook`): (1) two adapter-static substitution keys `{adapter_dir}` (the registry record's precise source_dir — install dir, survives updates, the dir bare-program resolution uses) and `{adapter_name}`, available wherever command/string substitution runs; (2) lazy substitution INSIDE `[strings]` values at `get-string` read time, scoped to those adapter-static keys ONLY (session-scoped {id}/{session_id}/… are NOT available — get-string carries no session; a get-string --session-id is a deferred larger change). Invariant preserved: spt-core never executes a string — it substitutes and returns; the adapter's own wrapper executes the result (e.g. a CC hook dispatcher get-strings its packed binary once per session into an env var, then runs it per-hook, so hook logic rides `spt adapter update`). (v0.8.0)"

+required_stages = ["doc"]  # doc = ADR-0029 + docs/design/v0.8.0-update-arc-and-cli.md. impl+unit activate at the v0.8.0 build: {adapter_dir}/{adapter_name} resolution against source_dir + [strings] value substitution (adapter-static keys, lazy at get-string, file-backed-string parity).

+title = "Composite adapter update — an avenue-agnostic `[update.post]` sub-table `{ command, self_verifies }` run AFTER the primary avenue (gh_release/file_pull/delegated) resolves, in the same `spt adapter update` (ADR-0029). Runs UNCONDITIONALLY (even on an adapter version no-op — the post-step's own idempotent check decides). PUBLISHED stdin JSON seam: one line `{adapter_applied, adapter_name, profile_name, version, previous_version, adapter_dir}` (additive keys; post-step ignores unknown). stdout decides the notice: custom text SUPERSEDES [update].message; a reserved sentinel fires the static [update].message; empty = no notice. exit code orthogonal (0 ok / nonzero failed). Precedence: dynamic-stdout > sentinel/manifest-message > nothing. NO [update.post] declared ⇒ today's adapter_applied→[update].message unchanged; post-step FAILS ⇒ loud warning + fall back to adapter_applied→message. FAILURE-ISOLATED: a committed gh_release pull is never rolled back if the post-step fails (independent channels). (v0.8.0)"

+required_stages = ["doc"]  # doc = ADR-0029. impl+unit+int activate at the v0.8.0 build: [update.post] parse + post-run after re-register + stdin-seam compose + stdout/exit notice-arbitration + failure isolation; int = a delegated post-step fires + supersede/sentinel/empty + no-op-unconditional-run + fail-isolated.

+title = "`[message-idle-translation-binary]` accepts a `command` (opaque; args + {key}/{adapter_dir} substitution; program token resolved against install_dir like [digest].extractor/[session.psyche_init]) in addition to the bare `path`. `path` is DEPRECATED — keeps parsing (manifest forward/back-compat) but emits a registration warning steering to command. Exactly one of {path, command} (both-set refused at registration; neither = no translation binary). The spawn lifecycle + stdin/stdout JSON-lines protocol (Init/Event/Input → key/text/delay_ms/commit) are UNCHANGED — command alters only how the executable+args are located/launched (read_translation_path → read_translation_command). Unblocks folding `claude-spt translate` into the one consolidated binary (downstream ADR-0006). (v0.8.0)"

+required_stages = ["doc"]  # doc = ADR-0029. impl+unit activate at the v0.8.0 build: IdleTranslationBinary.command (Option) + path-deprecation warn + both-set/neither validation + resolve+spawn with args; unit = parse (command/path/both-refused) + spawn carries the subcommand arg.

+# ── Cluster B: standalone CLI/UX asks (design doc; seeded, activate at v0.8.0 build) ──

+title = "Remove `--reply-to` from `spt send` — a target-fallback + REPLIED-label nicety that confuses agents, with no wire effect (ADR-0020 already made messages structural (from,body), no __REPLY_TO__). Hard-remove (no deprecation shim): the clap flag, the is_reply/REPLIED label branch (always SENT/QUEUED), the `send` how-to --reply-to example, and the reply-to mention in REQ-DOCS-6's send topic. Reply-correlation stays on the structural `from` attribute. (v0.8.0)"

+required_stages = []  # SEED (rule 5). v0.8.0 build: hard-remove across cli.rs + how-to + reference.md regen; amend REQ-DOCS-6 title.

+title = "`spt endpoint digest` gains incremental turn-end consumption (extends REQ-TERM-4/5): `--last <N>` = the last N TURNS (the digest's natural unit; --last 1 = the latest turn = turn-end output); a per-entry STABLE SOURCE-DERIVED `seq` in the --json output (deterministic from the entry's append position in the source — transcript record index across the session ledger / digest.log index — so a live re-projection yields the same seq for the same committed entry; NOT a window-position index that renumbers on slide); `--after <seq>` = entries newer than seq still in the window (full window + signal if seq predates it, mirroring the version-slide full-refresh). An in-flight (still-growing) entry is flagged `partial: true` with NO stable seq until finalized (consumer reprocesses partial, skips <= seq). Also emit per-entry `ts` where present (seq is the authoritative dedup+cursor key). The digest's agent text is sufficient fidelity (no raw-source mode). BINDING doc-guidance: an adapter's [digest] extractor / api digest-entry MUST classify delivered user-facing messages as turn-opening `input` (equiv to direct PTY user-input), else messaging-driven sessions collapse into a few giant turns and --last/seq lose granularity. (v0.8.0)"

+required_stages = []  # SEED (rule 5). v0.8.0 build: --last/--after flags + source-derived seq + partial flag through projection; doc-guidance into MANIFEST.md/integration-checklist; reference.md.

+title = "A global `--json` flag (clap global=true) honored by the READ/STATUS command 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 (endpoint digest already has it). Action commands do not honor it. A shared print_json() helper + a coverage TEST asserting every command in the set emits valid JSON (guards against the missing-shared-formatter drift). Output uses explicit per-command output DTOs with committed field names — internal structs are NOT serialized verbatim (their fields would become a public contract; JSON is a consumed wire-parity surface). (v0.8.0)"

+required_stages = []  # SEED (rule 5). v0.8.0 build: global --json + print_json + DTOs per query command + Serialize derives + coverage test; reference.md per command.

+title = "`spt endpoint run` / bare `spt` routes a TOTALLY-EMPTY scope straight to the endpoint-creation flow: when gather_endpoints() is empty (nothing attachable, local OR subnet) PickerModel::new opens on Screen::CreateAdapter instead of PickExisting (today it always opens PickExisting + renders a blank list). A node WITH subnet endpoints but no local ones still has things to pick → stays on the picker. Extends REQ-RUN-PICKER. (v0.8.0)"

+required_stages = []  # SEED (rule 5). v0.8.0 build: one branch in PickerModel::new (empty endpoint set → create screen) + picker test (empty → create, non-empty → pick).

+title = "`spt rc` overlays a persistent endpoint-identity marker so the operator always groks which endpoint they control: a reserved TOP status row via a 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 (output-scanning, like the existing mouse_scanner). NO window title (the harness, e.g. CC, owns it for busyness — OSC dropped). Resolve subnet/node/id once at attach (perch/registry read) and thread into the pump; subnet = the endpoint's home/primary (\"local\" if none). The literal floating rounded-rectangle corner box is DEFERRED to the future web-based GUI (not a grid-model rc — that lift is better spent on the GUI). (v0.8.0)"

+required_stages = []  # SEED (rule 5). v0.8.0 build: identity resolve at attach + DECSTBM reserved-row paint + alt-screen/resize re-assert in rc.rs pump; the floating box stays deferred.


Adding the `[update.post]` glossary note to CONTEXT.md:


99
100	**adapter packaging & live update** (v0.13.2; ADR-0024, ADR-0025):
101	<!-- [doc->REQ-ADAPTER-GH-TRANSPORT] -->
102	A `.spt` may be **multi-platform**: shared `manifest.toml` + `strings/` at the root, role binaries under per-target-triple subdirectories (`x86_64-pc-windows-msvc/`, …); install/update extracts the shared root plus only the current node's triple, flattened into `install_dir`, so flat `<install_dir>/<program>` resolution is unchanged. It stays one signed asset (`adapter.spt`, plain-tar or gzip); a multi-platform archive missing the recipient's triple is a typed `NoArtifactForPlatform`. Large adapters may still split per-platform. The `gh_release` fetch transport is **`auto`** by default — the pre-authorized `gh` CLI when available (the path for **private** adapter repos: `gh` honors both OAuth and `GH_TOKEN`, so spt never custodies a token), else direct HTTPS (public). An adapter update is **live and daemon-coordinated**, the adapter analog of brain self-update: for an endpoint with a running **resident adapter binary**, the daemon stops it (releasing the OS file lock that otherwise fails an overwrite on Windows), swaps **only files whose CRC changed**, **refreshes the endpoint's in-memory manifest** (binaries and manifest stay on the same page), then restarts it — the endpoint itself never restarts.
103	<!-- [doc->REQ-ADAPTER-UPDATE-MESSAGE] -->

**adapter packaging & live update** (v0.13.2; ADR-0024, ADR-0025):
<!-- [doc->REQ-ADAPTER-GH-TRANSPORT] -->
A `.spt` may be **multi-platform**: shared `manifest.toml` + `strings/` at the root, role binaries under per-target-triple subdirectories (`x86_64-pc-windows-msvc/`, …); install/update extracts the shared root plus only the current node's triple, flattened into `install_dir`, so flat `<install_dir>/<program>` resolution is unchanged. It stays one signed asset (`adapter.spt`, plain-tar or gzip); a multi-platform archive missing the recipient's triple is a typed `NoArtifactForPlatform`. Large adapters may still split per-platform. The `gh_release` fetch transport is **`auto`** by default — the pre-authorized `gh` CLI when available (the path for **private** adapter repos: `gh` honors both OAuth and `GH_TOKEN`, so spt never custodies a token), else direct HTTPS (public). An adapter update is **live and daemon-coordinated**, the adapter analog of brain self-update: for an endpoint with a running **resident adapter binary**, the daemon stops it (releasing the OS file lock that otherwise fails an overwrite on Windows), swaps **only files whose CRC changed**, **refreshes the endpoint's in-memory manifest** (binaries and manifest stay on the same page), then restarts it — the endpoint itself never restarts.
<!-- [doc->REQ-ADAPTER-UPDATE-MESSAGE] -->


103	<!-- [doc->REQ-ADAPTER-UPDATE-MESSAGE] -->
104	An optional **`[update].message`** (avenue-agnostic) is a plain multi-line operator notice surfaced to stdout, markdown-rendered (the helpfmt prose path), **only when an update is actually applied** (the version changed) — never on a no-op. It is read from the newly-installed manifest with no `{key}` substitution; its use is to announce a post-update action (e.g. "run `/reload-plugins` in any ongoing sessions").
105

<!-- [doc->REQ-ADAPTER-UPDATE-MESSAGE] -->
An optional **`[update].message`** (avenue-agnostic) is a plain multi-line operator notice surfaced to stdout, markdown-rendered (the helpfmt prose path), **only when an update is actually applied** (the version changed) — never on a no-op. It is read from the newly-installed manifest with no `{key}` substitution; its use is to announce a post-update action (e.g. "run `/reload-plugins` in any ongoing sessions").