The file C:\Users\decid\Documents\projects\spt-claude-code\tests\manifest-shortcut.sh has been updated successfully. (file state is current in your context — no need to Read it back)

# [unit->REQ-DIST-IDLE-TRANSLATE]
# [message-idle-translation-binary] declares the idle-delivery filter via `command` (spt-core v0.16.0
# seam; `path` deprecated) = the `translate` subcommand of the consolidated binary, resolved from the
# install dir via {adapter_dir} (D3 fold). MUST be `command` (not the deprecated `path`) and name the
# claude-spt translate subcommand.
idle=$(grep -E '^[[:space:]]*command[[:space:]]*=[[:space:]]*"\{adapter_dir\}/claude-spt translate"' "$MANIFEST")
if [ -n "$idle" ]; then echo "ok   [message-idle-translation-binary] command = \"{adapter_dir}/claude-spt translate\""; else echo "FAIL [message-idle-translation-binary] command != \"{adapter_dir}/claude-spt translate\""; fail=1; fi
# Regression guard: the deprecated bare `path = "cc-spt-idle-translate"` must be GONE (exactly one of path/command).
if grep -Eq '^[[:space:]]*path[[:space:]]*=[[:space:]]*"cc-spt-idle-translate"' "$MANIFEST"; then echo "FAIL deprecated [message-idle-translation-binary].path still present (both-set is refused)"; fail=1; else echo "ok   no deprecated idle-translate path (command-only)"; fi

#!/bin/sh
# Brand-value regression guard for the `spt endpoint run` shortcut: the manifest MUST declare
# adapter.shortcut_basename = "cc" (→ cc-<id>, the M12 cc launcher). A drift to "sptc"/"spt"/default
# is caught here, distinct from the structural manifest-schema check. (The launcher brand is
# decoupled from the plugin name `sptc`; the s/sptc/spt/ succession renames the plugin, not this.)
# Run: sh tests/manifest-shortcut.sh (exit 0 = pass).
# [unit->REQ-DIST-SHORTCUT-BASENAME]
ROOT=$(CDPATH= cd "$(dirname "$0")/.." && pwd)
MANIFEST="$ROOT/adapter/claude-spt.toml"
fail=0

# The active assignment line (not a comment): a TOML key at column 0. grep -E, exclude `#`-leading.
line=$(grep -E '^[[:space:]]*shortcut_basename[[:space:]]*=' "$MANIFEST")
if [ -z "$line" ]; then echo "FAIL no shortcut_basename assignment in manifest"; exit 1; fi

case "$line" in
  *'"cc"'*) echo "ok   shortcut_basename = \"cc\" (cc-<id> brand intact)" ;;
  *) echo "FAIL shortcut_basename is not \"cc\": $line"; fail=1 ;;
esac

# Defensive: exactly one active assignment (a stray second would make the picker value ambiguous).
n=$(grep -Ec '^[[:space:]]*shortcut_basename[[:space:]]*=' "$MANIFEST")
if [ "$n" -eq 1 ]; then echo "ok   single shortcut_basename assignment"; else echo "FAIL $n shortcut_basename assignments (want 1)"; fail=1; fi

# [unit->REQ-HAZARD-PSYCHE-PERMS-DEADLOCK]
# Non-interactive bringup: ALL THREE CC spawn commands — the two [session.self] (base `claude`, the
# `ccs` profile) AND [session.resume] (`claude -r … --remote-control …`) — MUST carry
# --dangerously-skip-permissions. The broker spawns CC into a PTY with no operator, so an interactive
# permission prompt would deadlock the launch (docs/KNOWN-HAZARDS.md §2.2). Match the active
# `command =` lines whose value is a bare claude/ccs CC spawn — NOT `claude-spt psyche` (the runner;
# `claude-spt` has `-`/no-space after `claude`, so the (claude|ccs)(space|") match correctly excludes
# it — its turns are asserted in the tools/claude-spt crate's psyche unit tests).
spawn=$(grep -E '^[[:space:]]*command[[:space:]]*=[[:space:]]*"(claude|ccs)([[:space:]]|")' "$MANIFEST")
nspawn=$(printf '%s' "$spawn" | grep -c .)
if [ "$nspawn" -eq 3 ]; then echo "ok   3 CC spawn commands ([session.self] base claude + ccs, [session.resume])"; else echo "FAIL expected 3 CC spawn commands, found $nspawn"; fail=1; fi
nomiss=$(printf '%s\n' "$spawn" | grep -c -v -- '--dangerously-skip-permissions')
if [ "$nspawn" -gt 0 ] && [ "$nomiss" -eq 0 ]; then echo "ok   every CC spawn command carries --dangerously-skip-permissions"; else echo "FAIL a CC spawn command lacks --dangerously-skip-permissions ($nomiss without it)"; fail=1; fi

# [unit->REQ-DIST-SESSION-RESUME]
# [session.resume] declares CC's native-resume verb — `-r {session_id}` (reload the real transcript,
# else a resume re-runs [session.self] → blank) and `--remote-control {id}` (thread the endpoint id
# as the RC session name). A drift that drops either fill silently breaks native resume / RC drive.
resume=$(grep -E '^[[:space:]]*command[[:space:]]*=[[:space:]]*"claude -r ' "$MANIFEST")
if [ -z "$resume" ]; then echo 'FAIL no [session.resume] claude -r … command in manifest'; fail=1; else
  case "$resume" in
    *'-r {session_id}'*) echo "ok   [session.resume] reloads by {session_id} (-r)";;
    *) echo "FAIL [session.resume] missing -r {session_id}: $resume"; fail=1;;
  esac
  case "$resume" in
    *'--remote-control {id}'*) echo "ok   [session.resume] threads {id} as the remote-control name";;
    *) echo "FAIL [session.resume] missing --remote-control {id}: $resume"; fail=1;;
  esac
fi

# [unit->REQ-DIST-NAME-UNIFY]
# U3: the repo was renamed spt-claude-code -> claude-spt. [update].repo MUST read the new slug (else
# `spt adapter update` pulls from the redirected old slug), and NO owner-qualified old slug may linger
# in the manifest (regression guard for the ref-flip).
if grep -Eq '^[[:space:]]*repo[[:space:]]*=[[:space:]]*"SaberMage/claude-spt"' "$MANIFEST"; then echo 'ok   [update].repo = "SaberMage/claude-spt"'; else echo "FAIL [update].repo is not SaberMage/claude-spt"; fail=1; fi
if grep -q 'SaberMage/spt-claude-code' "$MANIFEST"; then echo "FAIL stale SaberMage/spt-claude-code ref lingers in manifest"; fail=1; else echo "ok   no stale spt-claude-code repo ref in manifest"; fi

# [unit->REQ-DIST-UPDATE-MESSAGE]
# U1: [update] carries a `message` field (markdown spt-core prints on a real apply). It MUST mention
# the unavoidable /reload-plugins manual residual — that is the field's whole reason to exist. Match
# the active `message =` assignment and the reload-plugins notice anywhere in the manifest body.
if grep -Eq '^[[:space:]]*message[[:space:]]*=' "$MANIFEST"; then echo "ok   [update].message present"; else echo "FAIL [update] has no message field"; fail=1; fi
if grep -q 'reload-plugins' "$MANIFEST"; then echo "ok   [update].message points at /reload-plugins"; else echo "FAIL [update].message lacks the /reload-plugins notice"; fail=1; fi

# [unit->REQ-DIST-RC-STARTUP]
# U6: {id} threads the DISPLAY name (`-n {id}`) on BOTH bringup paths and the REMOTE-CONTROL channel
# (`--remote-control {id}`) on BOTH, so a FRESH ([session.self]) and a RESUMED ([session.resume])
# endpoint are identified + RC-controlled identically. self currently `claude -n {id} …`, resume
# `claude -r {session_id} -n {id} …`. A drift that drops -n on either, or --remote-control on self,
# silently breaks the {id} display/RC parity. ({id}-only now; -> {id}@{node} when a {node} key lands.)
self_cmd=$(grep -E '^[[:space:]]*command[[:space:]]*=[[:space:]]*"claude -n ' "$MANIFEST")
if [ -z "$self_cmd" ]; then echo 'FAIL no [session.self] "claude -n {id} …" command in manifest'; fail=1; else
  case "$self_cmd" in
    *'-n {id}'*) echo "ok   [session.self] sets {id} as the display name (-n)";;
    *) echo "FAIL [session.self] missing -n {id}: $self_cmd"; fail=1;;
  esac
  case "$self_cmd" in
    *'--remote-control {id}'*) echo "ok   [session.self] threads {id} as the remote-control name";;
    *) echo "FAIL [session.self] missing --remote-control {id}: $self_cmd"; fail=1;;
  esac
fi
# resume carries -n {id} too (the U6 addition — parity with self; --remote-control {id} already
# asserted by the REQ-DIST-SESSION-RESUME block above, $resume reused).
case "$resume" in
  *'-n {id}'*) echo "ok   [session.resume] sets {id} as the display name (-n, U6 parity)";;
  *) echo "FAIL [session.resume] missing -n {id} (U6 display-name parity): $resume"; fail=1;;
esac

# [unit->REQ-DIST-IDLE-TRANSLATE]
# [message-idle-translation-binary] declares the idle-delivery filter by its bare binary name; the
# value MUST match the packed binary (tools/cc-spt-idle-translate) so spt-core can resolve+spawn it.
idle=$(grep -E '^[[:space:]]*path[[:space:]]*=[[:space:]]*"cc-spt-idle-translate"' "$MANIFEST")
if [ -n "$idle" ]; then echo "ok   [message-idle-translation-binary] path = \"cc-spt-idle-translate\""; else echo "FAIL [message-idle-translation-binary] path != \"cc-spt-idle-translate\""; fail=1; fi

[ "$fail" -eq 0 ] && { echo "MANIFEST-SHORTCUT OK"; exit 0; } || { echo "MANIFEST-SHORTCUT FAIL"; exit 1; }

-# [message-idle-translation-binary] declares the idle-delivery filter by its bare binary name; the

-# value MUST match the packed binary (tools/cc-spt-idle-translate) so spt-core can resolve+spawn it.

-idle=$(grep -E '^[[:space:]]*path[[:space:]]*=[[:space:]]*"cc-spt-idle-translate"' "$MANIFEST")

-if [ -n "$idle" ]; then echo "ok   [message-idle-translation-binary] path = \"cc-spt-idle-translate\""; else echo "FAIL [message-idle-translation-binary] path != \"cc-spt-idle-translate\""; fail=1; fi

+# [message-idle-translation-binary] declares the idle-delivery filter via `command` (spt-core v0.16.0

+# seam; `path` deprecated) = the `translate` subcommand of the consolidated binary, resolved from the

+# install dir via {adapter_dir} (D3 fold). MUST be `command` (not the deprecated `path`) and name the

+idle=$(grep -E '^[[:space:]]*command[[:space:]]*=[[:space:]]*"\{adapter_dir\}/claude-spt translate"' "$MANIFEST")

+if [ -n "$idle" ]; then echo "ok   [message-idle-translation-binary] command = \"{adapter_dir}/claude-spt translate\""; else echo "FAIL [message-idle-translation-binary] command != \"{adapter_dir}/claude-spt translate\""; fail=1; fi

+# Regression guard: the deprecated bare `path = "cc-spt-idle-translate"` must be GONE (exactly one of path/command).

+if grep -Eq '^[[:space:]]*path[[:space:]]*=[[:space:]]*"cc-spt-idle-translate"' "$MANIFEST"; then echo "FAIL deprecated [message-idle-translation-binary].path still present (both-set is refused)"; fail=1; else echo "ok   no deprecated idle-translate path (command-only)"; fi


proceed with the wiring and gates. spt-core is updated. publish when everything is ready.


Verify PreToolUse message polling and injection


Check remaining operative `cc-spt-idle-translate` refs + run the D3-affected gates:


Bash

cd "C:/Users/decid/Documents/projects/spt-claude-code" && echo "=== remaining cc-spt-idle-translate refs (operative only) ==="; grep -rln "cc-spt-idle-translate" --include=*.sh --include=*.toml --include=*.md . 2>/dev/null | grep -vE "PLAN.md|SPT-CORE-FINDINGS|CHANGELOG|adr/000" ; echo "=== manifest-schema + shortcut + archive ==="; sh tests/manifest-schema.sh >/dev/null 2>&1 && echo "schema OK" || echo "schema FAIL"; sh tests/manifest-shortcut.sh 2>&1 | grep -iE "idle|translate|FAIL|OK$"; echo "=== consolidated build (incl translate) ==="; sh ci/digest/build.sh 2>&1 | tail -2

Remaining refs + D3 gates


=== remaining cc-spt-idle-translate refs (operative only) ===
./adapter/claude-spt.toml
./adapter/strings/skills/setup.md
./ci/idle-translate/build.sh
./docs/RELEASE-RUNBOOK.md
./tests/manifest-shortcut.sh
./traceable-reqs.toml
./UPDATE-NAMING-DOYLE-ASKS.md
=== manifest-schema + shortcut + archive ===
schema OK
ok   [message-idle-translation-binary] command = "{adapter_dir}/claude-spt translate"
ok   no deprecated idle-translate path (command-only)
MANIFEST-SHORTCUT OK
=== consolidated build (incl translate) ===

ok  claude-spt: cargo test + release build (digest/psyche/post-update subcommands)


Gates green. Fix the operative `cc-spt-idle-translate` refs (setup string, RELEASE-RUNBOOK, REQ note) — leaving version-history/regression-guard/asks-narrative ones. Check exact lines:


cd "C:/Users/decid/Documents/projects/spt-claude-code" && grep -nE 'cc-spt-idle-translate' adapter/strings/skills/setup.md docs/RELEASE-RUNBOOK.md traceable-reqs.toml | grep -v "deprecated\|path ="

Exact operative idle-translate refs


adapter/strings/skills/setup.md:35:   `claude-spt` tool (its `digest` / `psyche` / `post-update` subcommands) and `cc-spt-idle-translate`
docs/RELEASE-RUNBOOK.md:150:  dir**, so the bare-name command tokens (`claude-spt`, `cc-spt-idle-translate`) still resolve at `<install_dir>/`
docs/RELEASE-RUNBOOK.md:169:    cargo zigbuild --release --target x86_64-unknown-linux-gnu --manifest-path tools/cc-spt-idle-translate/Cargo.toml
docs/RELEASE-RUNBOOK.md:192:  `cc-spt-idle-translate`), which spt-core resolves **from that install dir** (v0.8.0 Feature B /
traceable-reqs.toml:228:required_stages = ["doc", "impl", "unit", "int"]  # doc: SETUP-SLICE-PLAN.md Wave C' + docs/RELEASE-RUNBOOK.md (the adapter.spt release-asset publish target). impl: ci/publish/package-adapter.sh (validate manifest + pack manifest.toml[renamed from claude-spt.toml]+strings/+release tool binaries at archive ROOT; dry-run default, --apply writes dist/adapter.spt; never uploads — the release/tag/upload stays the operator's step). unit: tests/adapter-archive.sh (the packed .spt root holds manifest.toml named exactly + strings/ + the three binaries [digest, psyche, cc-spt-idle-translate]; reject a malformed archive). int: ci/publish/release-acquire-int.sh — real `adapter add --release SaberMage/claude-spt` acquire on a clean target: claude-spt active, sourced from the _github install dir, profiles/strings from the .spt resolve (slow lane, SPTC_ACCEPTANCE=1, spt>=0.7.3, network); dogfood-proven on spt 0.7.3.
traceable-reqs.toml:251:title = "The adapter ships the [message-idle-translation-binary] (path=\"cc-spt-idle-translate\") — a lifecycle-managed stdin→stdout JSON-lines filter that turns each idle inbound <EVENT> envelope into the keystroke choreography spt-core applies ATOMICALLY to the broker PTY: ctrl+s (stash any draft) · 50ms · <envelope> (type the text, no trailing CR) · 50ms · {key:enter} (submit the PTY line) · {commit:true} (terminate the inject sequence) — so an inbound message never clobbers a half-typed operator draft (CC auto-restores the stashed draft after submit; no trailing restore keystroke). The submit is a DISCRETE {key:enter}, NOT a trailing \\r in the text: a \\r byte does NOT submit a Claude Code message (corrected 2026-06-23). The trailing {commit} is MANDATORY: spt-core's run_inject_worker (broker.rs:1075-1090) ends a sequence only on {commit}; without it the broker FAULTs at the 5s INJECT_COMMIT_DEADLINE on every delivery (the enter key submits the line, {commit} releases the InjectFloor — two distinct signals). Idle delivery ONLY (the spt-hosted complement to the busy/mid-turn [inject] hook path); coexists with a live `spt rc` controller."
traceable-reqs.toml:252:required_stages = ["impl", "unit", "int"]  # impl: tools/cc-spt-idle-translate (Rust JSON-lines filter — event→6-step choreography [stash · 50ms · <envelope> text · 50ms · {key:enter} submit · {commit:true} terminator; CC auto-restores the draft so NO trailing restore keystroke], init/input/unknown-type emit nothing, CR/LF in the envelope neutralized so the injection can't split — only the discrete enter submits (a \r byte does NOT submit a CC message, corrected 2026-06-23), forward-compat unknown fields ignored) + [message-idle-translation-binary] path="cc-spt-idle-translate" in adapter/claude-spt.toml + ci/idle-translate/build.sh gate (wired into ci/run-gates.sh) + packed into adapter.spt (ci/publish/package-adapter.sh, the 3rd binary). unit: the crate's 11 cargo tests (run by ci/idle-translate/build.sh: the choreography order, submit-is-discrete-enter-key-not-trailing-CR, mandatory terminating commit, stash-precedes-submit, single-ctrl+s no-trailing-restore, CR/LF neutralization, dispatch, no-output types, envelope-less event, unknown fields, single-field command shape) + tests/{manifest-shortcut.sh (manifest path), adapter-archive.sh (binary packs at root)}. int: ci/idle-translate/translate-proof-int.sh — `spt adapter translate-proof claude-spt --event …` on real spt >=0.13.1 (counter 28) spawns+feeds the binary daemon-identically and asserts TRANSLATE_PROOF_OK + "commit: yes" + the discrete {key:enter} submit (and that the text carries NO trailing \r). GREEN 2026-06-22 (disposable install-dir staged manifest.toml+strings+binary — the GhReleaseManaged resolver wants the extracted shape; soft-removed on teardown). This is the EMIT half ONLY; the daemon's atomic PTY APPLY + spawn-on-up/reap-on-down lifecycle stay covered by a real-claude bringup int (heavier tier, still deferred) — translate-proof explicitly does not exercise apply (ADR-0022). The no-{commit} FAULT (F-016) is exactly what the proof's no-commit gate catches. doc DEFERRED to the docs-site harness-contract RUN-group slice (documented in the manifest + module comment meanwhile). Public-surface-complete: the contract page (harness-contract/manifest.html, message-idle-translation-binary since v0.13.0) is the authority; the operator-specified ctrl+s choreography is the adapter's own UX layer atop the generic key/text/delay_ms protocol. SOURCE-CONFIRMED (doyle 2026-06-20): spt-core applies a {text} command VERBATIM (broker.rs:1016-1017, no control-char strip), so the envelope-internal CR/LF neutralization is required (an un-neutralized \n/\r would reach the PTY and could split the injection). CORRECTION (2026-06-23): the submit is now a DISCRETE {key:enter} after the text, NOT a trailing \r — empirically a \r byte in injected text does NOT trigger Claude Code's message submission (CC needs the discrete Enter key event; the earlier assumption that a verbatim \r == {key:enter} held at the byte level but not for CC's input handling). The published contract example always showed {text}{key:enter}; this adapter now follows it literally.
traceable-reqs.toml:260:required_stages = ["impl", "unit", "int"]  # Feature 1 / task T1 — ACTIVATED 2026-06-24 after the empirical soft-newline gate PASSED (probe binary on a live spt-hosted endpoint: a two-line {text} landed as ONE user turn with the \n preserved, no early submit). impl: cc-spt-idle-translate frame_envelope splits the envelope opening-tag/\n/body/\n/closing-tag, embedding two deliberate \n's AFTER the stray-CR/LF-neutralize (degenerate fallback for non-<EVENT>…</EVENT> shapes incl. EVENT-PART). unit: the crate's framing tests (envelope_is_framed_across_three_lines + fallback/attrless/empty-body + stray-then-framing). int: ci/idle-translate/translate-proof-int.sh asserts the emitted {text} frames across lines (>\n … \n</EVENT>) — the EMIT half; the soft-newline APPLY half was the empirical probe (heavier tier, like the real-claude bringup int). Contract A frozen (doyle: {text} raw-verbatim; key-vocab + {text}-verbatim doc folds into spt-core v0.15.0 W4).
traceable-reqs.toml:265:required_stages = ["doc", "impl", "unit", "int"]  # Feature 2 / task T2 — ACTIVATED 2026-06-24. WIRE-SENTINEL is now spt-core v0.15.0 `spt send --json-payload '{"checkpoint":"v1","wake":…}'` (doyle, grounded in the gated W4 code) — a structured json ATTR on the <EVENT> envelope, NOT the old string-prefix sniff (collision-proof: the marker lives inside the opaque attr, can't forge from/type). doc: full-fat /sptc:commune SKILL.md (--checkpoint: single trigger=default wake, paired triggers bracket a custom wake) + ADR-0004 + the checkpoint [[hints]] entry. impl: cc-spt-idle-translate checkpoint branch (extract json attr -> xml-attr-unescape (& LAST) -> serde parse -> checkpoint=="v1" -> clear+wake macro ctrl+s·50ms·/clear·enter·500ms·wake·enter·commit, default "Proceed with next steps") + NEW PostToolUse hook (manifest [hooks.PostToolUse] + hooks.json Write-matcher + post-tool-use.sh detector: !!checkpoint!! scan of tool_input content -> api state idle -> self-send --json-payload) + _common.sh pure helpers (sptc_has_checkpoint/sptc_checkpoint_wake/sptc_is_commune_write). unit: hooks-parse.sh checkpoint tests + the crate's checkpoint macro/parse/unescape tests. int: ci/idle-translate/translate-proof-int.sh checkpoint block (envelope w/ json attr fires the clear+wake macro, custom + default wake, normal delivery never clears) — the deterministic EMIT half. The LIVE self-send loopback (real `spt send --from <id> <id> --json-payload '{"checkpoint":"v1","wake":…}'` -> json envelope attr -> binary -> /clear+wake) was VALIDATED on-node 2026-06-24 on spt 0.15.0: a spt-hosted CC transcript showed the macro fire E2E (user turn `/clear`, next user turn = the wake). DEFERRED dogfood (NOT CI, logged in CHECKPOINT-COMMUNE-PLAN.md §Deferred validations): the PostToolUse WRITE-DETECT E2E — the hook detecting `!!checkpoint!!` in a Write + self-sending — needs the NEW plugin installed + real CC. The write-side !!checkpoint!! marker is one-shot (spt-core v0.15.0 strips it from durable+pending on ingest). min_spt_core BUMPED to 0.15.0 (manifest v0.7.0). Self-send loopback PROVEN 2026-06-24.
traceable-reqs.toml:294:title = "claude-spt-digest + claude-spt-psyche merge into ONE `claude-spt` crate with clap subcommands (digest / psyche / post-update); post-update = the plugin-sync logic (detect claude|ccs CLI -> ensure cplugs marketplace -> claude plugin add|update -> print notice; does NOT run /reload-plugins). cc-spt-idle-translate stays separate (folds at D3). U2."
traceable-reqs.toml:295:required_stages = ["impl", "unit", "int"]  # U2 (the big refactor; preps post-update for D2) — ACTIVATED 2026-06-25, int promoted same day (proofs ran GREEN on the consolidated binary). impl: tools/claude-spt crate (main.rs subcommand dispatch [classify] + post_update.rs CLI-detect/marketplace/plugin reconciliation; digest+psyche folded in as subcommands) + manifest [digest].extractor `claude-spt digest …` + [session.psyche_init].command `claude-spt psyche …` + ci/digest/build.sh (canonical consolidated build) + ci/psyche/build.sh (shim, one build) + package-adapter.sh binary-set (claude-spt + cc-spt-idle-translate per triple). unit: the crate's cargo tests (37: subcommand classify dispatch + the ported digest/psyche tests + post-update CLI-pick / marketplace-registered / plugin-installed / pathext / notice). int: ci/digest/digest-proof-int.sh — `spt adapter digest-proof claude-spt --dir tools/claude-spt …` ran the consolidated binary's `digest` subcommand end-to-end through real spt (DIGEST_PROOF_OK, 5 parsed/0 dropped) — GREEN on-node 2026-06-25 on spt 0.15.0; the separate cc-spt-idle-translate stays green via translate-proof-int. tests/adapter-archive.sh [unit] asserts the archive packs the 2 binaries per triple. Old claude-spt-digest/claude-spt-psyche crates DELETED (evidence moved to tools/claude-spt). NB hand-rolled argv[1] dispatch, NOT clap (deviation from the JIT plan's "clap subcommands" — honors both predecessor crates' deliberate dependency-light ethos; a flat 3-way dispatch needs no framework). post-update is standalone-runnable + tested now but [update] can't invoke it until doyle ask #2 (D2).


Many of those are descriptive REQ-note prose (not stage-tags) — coverage rests on the tags in `translate.rs`/shim/proof-int. Run traceable + full gates to confirm D3 coverage holds after the crate move:


cd "C:/Users/decid/Documents/projects/spt-claude-code" && traceable-reqs check 2>&1 | grep -E "IDLE-TRANSLATE|BINARY-CONSOLIDATE|FAIL|uncovered" | head; echo "RC=${PIPESTATUS[0]}"; echo "=== full gates ==="; sh ci/run-gates.sh 2>&1 | grep -E "RESULT|FAIL|coverage green|idle-translate|claude-spt:"

traceable + gates after D3


  [OK] REQ-DIST-BINARY-CONSOLIDATE  required: [impl, unit, int]  stages: -doc +impl +unit +int
  [OK] REQ-DIST-IDLE-TRANSLATE  required: [impl, unit, int]  stages: -doc +impl +unit +int
RC=0
=== full gates ===
ok  ci/idle-translate/build.sh
ok  ci/idle-translate/translate-proof-int.sh
ok  claude-spt: cargo test + release build (digest/psyche/post-update subcommands)
=== GATE: idle-translate (cargo build + test) ===
ok  cc-spt-idle-translate: folded into the consolidated claude-spt crate as the translate subcommand (built/tested by ci/digest/build.sh)
ok  coverage green
=== RESULT: PASS ===


D3 fully green (BINARY-CONSOLIDATE + IDLE-TRANSLATE both +impl+unit+int; one binary now). Tidy the descriptive refs — setup string + RELEASE-RUNBOOK build commands:


32	3. **Verify + set active.** Re-run `spt adapter list` — `claude-spt` must read **active** (no
33	   `deregistered`). Then `spt adapter use claude-spt` so bare resolution lands here for the `claude`
34	   host (the legacy-parity bare flow — `/sptc:live`/`/sptc:ready` need no `--adapter`). The
35	   `claude-spt` tool (its `digest` / `psyche` / `post-update` subcommands) and `cc-spt-idle-translate`
36	   are invoked by bare name and resolve **from the adapter install dir** (the `from …` path in
37	   `spt adapter list`), where activation extracted them — no PATH copy needed. If either fails to
38	   start, confirm both are present in that dir (a packaging defect, not a PATH problem).
39

3. **Verify + set active.** Re-run `spt adapter list` — `claude-spt` must read **active** (no
   `deregistered`). Then `spt adapter use claude-spt` so bare resolution lands here for the `claude`
   host (the legacy-parity bare flow — `/sptc:live`/`/sptc:ready` need no `--adapter`). The
   `claude-spt` tool (its `digest` / `psyche` / `post-update` subcommands) and `cc-spt-idle-translate`
   are invoked by bare name and resolve **from the adapter install dir** (the `from …` path in
   `spt adapter list`), where activation extracted them — no PATH copy needed. If either fails to
   start, confirm both are present in that dir (a packaging defect, not a PATH problem).


Edit

   `claude-spt` tool (its `digest` / `psyche` / `post-update` subcommands) and `cc-spt-idle-translate`
   are invoked by bare name and resolve **from the adapter install dir** (the `from …` path in
   `spt adapter list`), where activation extracted them — no PATH copy needed. If either fails to
   start, confirm both are present in that dir (a packaging defect, not a PATH problem).

   ONE `claude-spt` tool binary (its `digest` / `psyche` / `post-update` / `translate` subcommands) is
   resolved **from the adapter install dir** (the `from …` path in `spt adapter list`), where
   activation extracted it — no PATH copy needed. If it fails to start, confirm it is present in that
   dir (a packaging defect, not a PATH problem).


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

   ONE `claude-spt` tool binary (its `digest` / `psyche` / `post-update` / `translate` subcommands) is
   resolved **from the adapter install dir** (the `from …` path in `spt adapter list`), where
   activation extracted it — no PATH copy needed. If it fails to start, confirm it is present in that
   dir (a packaging defect, not a PATH problem).

# /sptc:setup — operative instructions

**Goal:** install or repair spt-core for this session **and activate the claude-spt adapter** — the
mid-session installer covering the gap when no SessionStart bootstrap has fired. Installing the
binary is only half the job: a present binary with a **deregistered** adapter has no
profiles/strings/hints/`[digest]`, so the `/sptc:*` surface is inert until activation.

<!-- [doc->REQ-SETUP-ACTIVATE] -->

**Do this:**

1. **Binary.** If spt-core is absent (`command -v spt && spt --version` reports nothing), run the
   published install-on-demand bootstrap (spt-releases `harness-contract/install-on-demand.md`):
   - **POSIX:** `curl -fsSL https://sabermage.github.io/spt-releases/install.sh | sh`
   - **Windows (PowerShell):** `irm https://sabermage.github.io/spt-releases/install.ps1 | iex`

   `PATH` is not reloaded in this shell after a fresh install — verify with the absolute path:
   `"$HOME/.local/bin/spt" --version`. After this, `spt update` handles signed self-updates.

2. **Activate the adapter.** Run `spt adapter list` and find `claude-spt`:
   - Listed and **not** `deregistered` → already active; report it and skip to step 3.
   - Missing or `deregistered` → activate it:
     - **Local dev / dogfooding a repo checkout** (an `adapter/claude-spt.toml` near cwd):
       `spt adapter add ./adapter/claude-spt.toml`.
     - **End-user (plugin only):** `spt adapter add --release SaberMage/claude-spt` — fetches the
       single multi-platform `adapter.spt` (one archive bundling every supported platform's binaries
       beside a shared manifest; install auto-resolves the host's), extracts to the durable home,
       registers. No `--asset` / os-detection needed — the fat archive is host-agnostic (ADR-0024 W1).
       `--tag <ver>` pins a version. Needs spt **v0.13.2+** (the version that reads a fat archive).
       <!-- [doc->REQ-DIST-ADAPTER-PEROS] -->

3. **Verify + set active.** Re-run `spt adapter list` — `claude-spt` must read **active** (no
   `deregistered`). Then `spt adapter use claude-spt` so bare resolution lands here for the `claude`
   host (the legacy-parity bare flow — `/sptc:live`/`/sptc:ready` need no `--adapter`). The
   `claude-spt` tool (its `digest` / `psyche` / `post-update` subcommands) and `cc-spt-idle-translate`
   are invoked by bare name and resolve **from the adapter install dir** (the `from …` path in
   `spt adapter list`), where activation extracted them — no PATH copy needed. If either fails to
   start, confirm both are present in that dir (a packaging defect, not a PATH problem).

4. **ccs wiring (optional).** Detect `~/.ccs`:
   - Present → the shipped `claude-spt:ccs` profile leaf-replaces the session command with `ccs` (a
     drop-in for `claude`), so live/ready agents can run on ccs backends via `--adapter
     claude-spt:ccs`. Check `command -v ccs`; if `~/.ccs` exists but `ccs` isn't on PATH, point the
     user at their ccs bin dir.
   - Absent → ccs is an optional CLI router for alternate model backends in place of `claude`. To
     enable: install ccs, then re-run `/sptc:setup`. Skip if unwanted.
   <!-- [doc->REQ-SETUP-CCS] -->

5. **Subnet onboarding (optional).** A subnet is the private group of paired machines that makes
   `/sptc:send`, `/sptc:ready`, and live agents work cross-machine (local use needs none). Check
   `spt subnet status`:
   - In a subnet → invite a machine with `spt subnet show-code`; on the joiner, `spt subnet join`.
   - Not in one → offer **create** (`spt subnet create` — this node becomes seed-holder) or **join**
     (`spt subnet join`). Full verb guidance → **/sptc:subnet**.
   - **Elevation:** create/join/show-code are OS-elevation-gated — Windows: elevated (UAC) shell;
     Linux desktop: pkexec/polkit or sudo terminal; Linux TTY: inline sudo; headless: print the exact
     command for the user to run elevated.
   <!-- [doc->REQ-SETUP-SUBNET] -->

Idempotent and safe to re-run — the same bootstrap + activation the SessionStart hook performs.

-   `claude-spt` tool (its `digest` / `psyche` / `post-update` subcommands) and `cc-spt-idle-translate`

-   are invoked by bare name and resolve **from the adapter install dir** (the `from …` path in

-   `spt adapter list`), where activation extracted them — no PATH copy needed. If either fails to

-   start, confirm both are present in that dir (a packaging defect, not a PATH problem).

+   ONE `claude-spt` tool binary (its `digest` / `psyche` / `post-update` / `translate` subcommands) is

+   resolved **from the adapter install dir** (the `from …` path in `spt adapter list`), where

+   activation extracted it — no PATH copy needed. If it fails to start, confirm it is present in that

+   dir (a packaging defect, not a PATH problem).


dir**, so the bare-name command tokens (`claude-spt`, `cc-spt-idle-translate`) still resolve at `<install_dir>/`

dir**, so the bare-name command token (`claude-spt` — its digest/psyche/post-update/translate subcommands) still resolves at `<install_dir>/`


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

- **Fat layout (REQ-DIST-ADAPTER-PEROS).** Recognized triples = **`x86_64-pc-windows-msvc`** (win

`.exe`) + **`x86_64-unknown-linux-gnu`** (linux ELF) — the ONLY two spt-core classifies in a fat

archive. On install it places the shared root + **flattens this node's `<triple>/*` into the install

-  dir**, so the bare-name command tokens (`claude-spt`, `cc-spt-idle-translate`) still resolve at `<install_dir>/`

+  dir**, so the bare-name command token (`claude-spt` — its digest/psyche/post-update/translate subcommands) still resolves at `<install_dir>/`

(REQ-INSTALL-11). **Footgun:** an unrecognized top-level dir is silently treated as a shared-root

entry and lands flat — the packer guards this (refuses any stray top-level dir); for a platform

beyond the two triples, ship a *separate* single-triple asset via `--asset`, never a third dir here.


166	    #   e.g. ~/.sptc-zig/zig-x86_64-windows-0.14.1/zig.exe  →  export PATH="$HOME/.sptc-zig/...:$PATH"
167	    rustup target add x86_64-unknown-linux-gnu
168	    cargo zigbuild --release --target x86_64-unknown-linux-gnu --manifest-path tools/claude-spt/Cargo.toml
169	    cargo zigbuild --release --target x86_64-unknown-linux-gnu --manifest-path tools/cc-spt-idle-translate/Cargo.toml
170	    ```
171	    → `tools/*/target/x86_64-unknown-linux-gnu/release/*` carrying real `ELF x86-64 GNU/Linux`
172	    binaries (verified). Then `sh ci/publish/package-adapter.sh --apply` packs BOTH into one
173	    `dist/adapter.spt`.

    #   e.g. ~/.sptc-zig/zig-x86_64-windows-0.14.1/zig.exe  →  export PATH="$HOME/.sptc-zig/...:$PATH"
    rustup target add x86_64-unknown-linux-gnu
    cargo zigbuild --release --target x86_64-unknown-linux-gnu --manifest-path tools/claude-spt/Cargo.toml
    cargo zigbuild --release --target x86_64-unknown-linux-gnu --manifest-path tools/cc-spt-idle-translate/Cargo.toml
    ```
    → `tools/*/target/x86_64-unknown-linux-gnu/release/*` carrying real `ELF x86-64 GNU/Linux`
    binaries (verified). Then `sh ci/publish/package-adapter.sh --apply` packs BOTH into one
    `dist/adapter.spt`.


    cargo zigbuild --release --target x86_64-unknown-linux-gnu --manifest-path tools/claude-spt/Cargo.toml
    cargo zigbuild --release --target x86_64-unknown-linux-gnu --manifest-path tools/cc-spt-idle-translate/Cargo.toml
    ```
    → `tools/*/target/x86_64-unknown-linux-gnu/release/*` carrying real `ELF x86-64 GNU/Linux`
    binaries (verified). Then `sh ci/publish/package-adapter.sh --apply` packs BOTH into one
    `dist/adapter.spt`.

    cargo zigbuild --release --target x86_64-unknown-linux-gnu --manifest-path tools/claude-spt/Cargo.toml
    ```
    → `tools/claude-spt/target/x86_64-unknown-linux-gnu/release/claude-spt` carrying a real
    `ELF x86-64 GNU/Linux` binary (verified). Then `sh ci/publish/package-adapter.sh --apply` packs the
    win + linux binaries into one `dist/adapter.spt` (ONE tool binary per triple since the D3 fold).


    cargo zigbuild --release --target x86_64-unknown-linux-gnu --manifest-path tools/claude-spt/Cargo.toml
    cargo zigbuild --release --target x86_64-unknown-linux-gnu --manifest-path tools/cc-spt-idle-translate/Cargo.toml
    ```
    → `tools/*/target/x86_64-unknown-linux-gnu/release/*` carrying real `ELF x86-64 GNU/Linux`
    binaries (verified). Then `sh ci/publish/package-adapter.sh --apply` packs BOTH into one
    `dist/adapter.spt`.

    cargo zigbuild --release --target x86_64-unknown-linux-gnu --manifest-path tools/claude-spt/Cargo.toml
    ```
    → `tools/claude-spt/target/x86_64-unknown-linux-gnu/release/claude-spt` carrying a real
    `ELF x86-64 GNU/Linux` binary (verified). Then `sh ci/publish/package-adapter.sh --apply` packs the
    win + linux binaries into one `dist/adapter.spt` (ONE tool binary per triple since the D3 fold).

#   e.g. ~/.sptc-zig/zig-x86_64-windows-0.14.1/zig.exe  →  export PATH="$HOME/.sptc-zig/...:$PATH"

rustup target add x86_64-unknown-linux-gnu

-    cargo zigbuild --release --target x86_64-unknown-linux-gnu --manifest-path tools/cc-spt-idle-translate/Cargo.toml

-    → `tools/*/target/x86_64-unknown-linux-gnu/release/*` carrying real `ELF x86-64 GNU/Linux`

-    binaries (verified). Then `sh ci/publish/package-adapter.sh --apply` packs BOTH into one

+    → `tools/claude-spt/target/x86_64-unknown-linux-gnu/release/claude-spt` carrying a real

+    `ELF x86-64 GNU/Linux` binary (verified). Then `sh ci/publish/package-adapter.sh --apply` packs the

+    win + linux binaries into one `dist/adapter.spt` (ONE tool binary per triple since the D3 fold).

- **Publish:** attach the single `dist/adapter.spt` to the GitHub release on `SaberMage/claude-spt`.

End users `spt adapter add --release SaberMage/claude-spt` (default asset `adapter.spt`, or

`--tag <ver>` to pin) — the fat archive auto-resolves the host's binaries, no `--asset` needed. The


188	- **Binaries (install-dir resolution — REQ-INSTALL-11).** A `--release`/`--github` acquisition extracts
189	  the `.spt` archive's `manifest.toml` + `strings/` **+ both tool binaries** into the adapter install
190	  dir (`…/adapters/_github/<safe>/`). The command templates use **bare names** (`claude-spt`,
191	  `cc-spt-idle-translate`), which spt-core resolves **from that install dir** (v0.8.0 Feature B /
192	  REQ-INSTALL-11) — **no PATH placement needed** (dogfood-proven on v0.8.1 for both binaries; F-006
193	  RESOLVED, interim retired). The pack therefore MUST carry both binaries (the resolution source). Note:
194	  a plain local-dev `spt adapter add <manifest.toml>` copies manifest + strings only (no binaries in the
195	  install dir), so local-dev runtime still relies on the built binary being on PATH (e.g. the CI ints

- **Binaries (install-dir resolution — REQ-INSTALL-11).** A `--release`/`--github` acquisition extracts
  the `.spt` archive's `manifest.toml` + `strings/` **+ both tool binaries** into the adapter install
  dir (`…/adapters/_github/<safe>/`). The command templates use **bare names** (`claude-spt`,
  `cc-spt-idle-translate`), which spt-core resolves **from that install dir** (v0.8.0 Feature B /
  REQ-INSTALL-11) — **no PATH placement needed** (dogfood-proven on v0.8.1 for both binaries; F-006
  RESOLVED, interim retired). The pack therefore MUST carry both binaries (the resolution source). Note:
  a plain local-dev `spt adapter add <manifest.toml>` copies manifest + strings only (no binaries in the
  install dir), so local-dev runtime still relies on the built binary being on PATH (e.g. the CI ints


- **Binaries (install-dir resolution — REQ-INSTALL-11).** A `--release`/`--github` acquisition extracts
  the `.spt` archive's `manifest.toml` + `strings/` **+ both tool binaries** into the adapter install
  dir (`…/adapters/_github/<safe>/`). The command templates use **bare names** (`claude-spt`,
  `cc-spt-idle-translate`), which spt-core resolves **from that install dir** (v0.8.0 Feature B /
  REQ-INSTALL-11) — **no PATH placement needed** (dogfood-proven on v0.8.1 for both binaries; F-006
  RESOLVED, interim retired). The pack therefore MUST carry both binaries (the resolution source). Note:

- **Binary (install-dir resolution — REQ-INSTALL-11).** A `--release`/`--github` acquisition extracts
  the `.spt` archive's `manifest.toml` + `strings/` **+ the tool binary** into the adapter install dir
  (`…/adapters/_github/<safe>/`). The command templates reference `claude-spt` (digest/psyche/post-update
  via the command strings; translate via `{adapter_dir}/claude-spt translate`), which spt-core resolves
  **from that install dir** (v0.8.0 Feature B / REQ-INSTALL-11) — **no PATH placement needed**
  (dogfood-proven on v0.8.1; F-006 RESOLVED, interim retired). The pack MUST carry the binary (the
  resolution source). Note:


- **Binaries (install-dir resolution — REQ-INSTALL-11).** A `--release`/`--github` acquisition extracts
  the `.spt` archive's `manifest.toml` + `strings/` **+ both tool binaries** into the adapter install
  dir (`…/adapters/_github/<safe>/`). The command templates use **bare names** (`claude-spt`,
  `cc-spt-idle-translate`), which spt-core resolves **from that install dir** (v0.8.0 Feature B /
  REQ-INSTALL-11) — **no PATH placement needed** (dogfood-proven on v0.8.1 for both binaries; F-006
  RESOLVED, interim retired). The pack therefore MUST carry both binaries (the resolution source). Note:

- **Binary (install-dir resolution — REQ-INSTALL-11).** A `--release`/`--github` acquisition extracts
  the `.spt` archive's `manifest.toml` + `strings/` **+ the tool binary** into the adapter install dir
  (`…/adapters/_github/<safe>/`). The command templates reference `claude-spt` (digest/psyche/post-update
  via the command strings; translate via `{adapter_dir}/claude-spt translate`), which spt-core resolves
  **from that install dir** (v0.8.0 Feature B / REQ-INSTALL-11) — **no PATH placement needed**
  (dogfood-proven on v0.8.1; F-006 RESOLVED, interim retired). The pack MUST carry the binary (the
  resolution source). Note:

- **`/sptc:setup` coupling.** The fat default is host-agnostic, so setup needs no os-detection /

`--asset` — a bare `adapter add --release` suffices. (The current setup body still detects os/arch

+ passes `--asset`; harmless, simplifying it to the bare default is a follow-on.)

-- **Binaries (install-dir resolution — REQ-INSTALL-11).** A `--release`/`--github` acquisition extracts

-  the `.spt` archive's `manifest.toml` + `strings/` **+ both tool binaries** into the adapter install

-  dir (`…/adapters/_github/<safe>/`). The command templates use **bare names** (`claude-spt`,

-  `cc-spt-idle-translate`), which spt-core resolves **from that install dir** (v0.8.0 Feature B /

-  REQ-INSTALL-11) — **no PATH placement needed** (dogfood-proven on v0.8.1 for both binaries; F-006

-  RESOLVED, interim retired). The pack therefore MUST carry both binaries (the resolution source). Note:

+- **Binary (install-dir resolution — REQ-INSTALL-11).** A `--release`/`--github` acquisition extracts

+  the `.spt` archive's `manifest.toml` + `strings/` **+ the tool binary** into the adapter install dir

+  (`…/adapters/_github/<safe>/`). The command templates reference `claude-spt` (digest/psyche/post-update

+  via the command strings; translate via `{adapter_dir}/claude-spt translate`), which spt-core resolves

+  **from that install dir** (v0.8.0 Feature B / REQ-INSTALL-11) — **no PATH placement needed**

+  (dogfood-proven on v0.8.1; F-006 RESOLVED, interim retired). The pack MUST carry the binary (the

a plain local-dev `spt adapter add <manifest.toml>` copies manifest + strings only (no binaries in the

install dir), so local-dev runtime still relies on the built binary being on PATH (e.g. the CI ints

prepend `target/release`); the install-dir path is the end-user `--release` story.


Update the REQ-DIST-IDLE-TRANSLATE registry note (path→command, impl path, D3 fold). Title first:


title = "The adapter ships the [message-idle-translation-binary] (path=\"cc-spt-idle-translate\") — a lifecycle-managed stdin→stdout JSON-lines filter

title = "The adapter ships the [message-idle-translation-binary] (command=\"{adapter_dir}/claude-spt translate\", spt-core v0.16.0 seam — path deprecated; D3 fold into the consolidated binary) — a lifecycle-managed stdin→stdout JSON-lines filter


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

-title = "The adapter ships the [message-idle-translation-binary] (path=\"cc-spt-idle-translate\") — a lifecycle-managed stdin→stdout JSON-lines filter that turns each idle inbound <EVENT> envelope into the keystroke choreography spt-core applies ATOMICALLY to the broker PTY: ctrl+s (stash any draft) · 50ms · <envelope> (type the text, no trailing CR) · 50ms · {key:enter} (submit the PTY line) · {commit:true} (terminate the inject sequence) — so an inbound message never clobbers a half-typed operator draft (CC auto-restores the stashed draft after submit; no trailing restore keystroke). The submit is a DISCRETE {key:enter}, NOT a trailing \\r in the text: a \\r byte does NOT submit a Claude Code message (corrected 2026-06-23). The trailing {commit} is MANDATORY: spt-core's run_inject_worker (broker.rs:1075-1090) ends a sequence only on {commit}; without it the broker FAULTs at the 5s INJECT_COMMIT_DEADLINE on every delivery (the enter key submits the line, {commit} releases the InjectFloor — two distinct signals). Idle delivery ONLY (the spt-hosted complement to the busy/mid-turn [inject] hook path); coexists with a live `spt rc` controller."

+title = "The adapter ships the [message-idle-translation-binary] (command=\"{adapter_dir}/claude-spt translate\", spt-core v0.16.0 seam — path deprecated; D3 fold into the consolidated binary) — a lifecycle-managed stdin→stdout JSON-lines filter that turns each idle inbound <EVENT> envelope into the keystroke choreography spt-core applies ATOMICALLY to the broker PTY: ctrl+s (stash any draft) · 50ms · <envelope> (type the text, no trailing CR) · 50ms · {key:enter} (submit the PTY line) · {commit:true} (terminate the inject sequence) — so an inbound message never clobbers a half-typed operator draft (CC auto-restores the stashed draft after submit; no trailing restore keystroke). The submit is a DISCRETE {key:enter}, NOT a trailing \\r in the text: a \\r byte does NOT submit a Claude Code message (corrected 2026-06-23). The trailing {commit} is MANDATORY: spt-core's run_inject_worker (broker.rs:1075-1090) ends a sequence only on {commit}; without it the broker FAULTs at the 5s INJECT_COMMIT_DEADLINE on every delivery (the enter key submits the line, {commit} releases the InjectFloor — two distinct signals). Idle delivery ONLY (the spt-hosted complement to the busy/mid-turn [inject] hook path); coexists with a live `spt rc` controller."

required_stages = ["impl", "unit", "int"]  # impl: tools/cc-spt-idle-translate (Rust JSON-lines filter — event→6-step choreography [stash · 50ms · <envelope> text · 50ms · {key:enter} submit · {commit:true} terminator; CC auto-restores the draft so NO trailing restore keystroke], init/input/unknown-type emit nothing, CR/LF in the envelope neutralized so the injection can't split — only the discrete enter submits (a \r byte does NOT submit a CC message, corrected 2026-06-23), forward-compat unknown fields ignored) + [message-idle-translation-binary] path="cc-spt-idle-translate" in adapter/claude-spt.toml + ci/idle-translate/build.sh gate (wired into ci/run-gates.sh) + packed into adapter.spt (ci/publish/package-adapter.sh, the 3rd binary). unit: the crate's 11 cargo tests (run by ci/idle-translate/build.sh: the choreography order, submit-is-discrete-enter-key-not-trailing-CR, mandatory terminating commit, stash-precedes-submit, single-ctrl+s no-trailing-restore, CR/LF neutralization, dispatch, no-output types, envelope-less event, unknown fields, single-field command shape) + tests/{manifest-shortcut.sh (manifest path), adapter-archive.sh (binary packs at root)}. int: ci/idle-translate/translate-proof-int.sh — `spt adapter translate-proof claude-spt --event …` on real spt >=0.13.1 (counter 28) spawns+feeds the binary daemon-identically and asserts TRANSLATE_PROOF_OK + "commit: yes" + the discrete {key:enter} submit (and that the text carries NO trailing \r). GREEN 2026-06-22 (disposable install-dir staged manifest.toml+strings+binary — the GhReleaseManaged resolver wants the extracted shape; soft-removed on teardown). This is the EMIT half ONLY; the daemon's atomic PTY APPLY + spawn-on-up/reap-on-down lifecycle stay covered by a real-claude bringup int (heavier tier, still deferred) — translate-proof explicitly does not exercise apply (ADR-0022). The no-{commit} FAULT (F-016) is exactly what the proof's no-commit gate catches. doc DEFERRED to the docs-site harness-contract RUN-group slice (documented in the manifest + module comment meanwhile). Public-surface-complete: the contract page (harness-contract/manifest.html, message-idle-translation-binary since v0.13.0) is the authority; the operator-specified ctrl+s choreography is the adapter's own UX layer atop the generic key/text/delay_ms protocol. SOURCE-CONFIRMED (doyle 2026-06-20): spt-core applies a {text} command VERBATIM (broker.rs:1016-1017, no control-char strip), so the envelope-internal CR/LF neutralization is required (an un-neutralized \n/\r would reach the PTY and could split the injection). CORRECTION (2026-06-23): the submit is now a DISCRETE {key:enter} after the text, NOT a trailing \r — empirically a \r byte in injected text does NOT trigger Claude Code's message submission (CC needs the discrete Enter key event; the earlier assumption that a verbatim \r == {key:enter} held at the byte level but not for CC's input handling). The published contract example always showed {text}{key:enter}; this adapter now follows it literally.

# ─────────────── Checkpoint-commune wave (planned 2026-06-24, CHECKPOINT-COMMUNE-PLAN.md) ───────────────


required_stages = ["impl", "unit", "int"]  # impl: tools/cc-spt-idle-translate (Rust JSON-lines filter

required_stages = ["impl", "unit", "int"]  # D3 FOLD 2026-06-26: now the `translate` subcommand of tools/claude-spt (src/translate.rs) — seam is [message-idle-translation-binary].command="{adapter_dir}/claude-spt translate" (spt-core v0.16.0; path deprecated); built+tested by ci/digest/build.sh (ci/idle-translate/build.sh is a shim); packed as the ONE tool binary; translate-proof-int --dir points at tools/claude-spt. ORIGINAL (pre-fold): impl: tools/cc-spt-idle-translate (Rust JSON-lines filter


title = "The adapter ships the [message-idle-translation-binary] (command=\"{adapter_dir}/claude-spt translate\", spt-core v0.16.0 seam — path deprecated; D3 fold into the consolidated binary) — a lifecycle-managed stdin→stdout JSON-lines filter that turns each idle inbound <EVENT> envelope into the keystroke choreography spt-core applies ATOMICALLY to the broker PTY: ctrl+s (stash any draft) · 50ms · <envelope> (type the text, no trailing CR) · 50ms · {key:enter} (submit the PTY line) · {commit:true} (terminate the inject sequence) — so an inbound message never clobbers a half-typed operator draft (CC auto-restores the stashed draft after submit; no trailing restore keystroke). The submit is a DISCRETE {key:enter}, NOT a trailing \\r in the text: a \\r byte does NOT submit a Claude Code message (corrected 2026-06-23). The trailing {commit} is MANDATORY: spt-core's run_inject_worker (broker.rs:1075-1090) ends a sequence only on {commit}; without it the broker FAULTs at the 5s INJECT_COMMIT_DEADLINE on every delivery (the enter key submits the line, {commit} releases the InjectFloor — two distinct signals). Idle delivery ONLY (the spt-hosted complement to the busy/mid-turn [inject] hook path); coexists with a live `spt rc` controller."

-required_stages = ["impl", "unit", "int"]  # impl: tools/cc-spt-idle-translate (Rust JSON-lines filter — event→6-step choreography [stash · 50ms · <envelope> text · 50ms · {key:enter} submit · {commit:true} terminator; CC auto-restores the draft so NO trailing restore keystroke], init/input/unknown-type emit nothing, CR/LF in the envelope neutralized so the injection can't split — only the discrete enter submits (a \r byte does NOT submit a CC message, corrected 2026-06-23), forward-compat unknown fields ignored) + [message-idle-translation-binary] path="cc-spt-idle-translate" in adapter/claude-spt.toml + ci/idle-translate/build.sh gate (wired into ci/run-gates.sh) + packed into adapter.spt (ci/publish/package-adapter.sh, the 3rd binary). unit: the crate's 11 cargo tests (run by ci/idle-translate/build.sh: the choreography order, submit-is-discrete-enter-key-not-trailing-CR, mandatory terminating commit, stash-precedes-submit, single-ctrl+s no-trailing-restore, CR/LF neutralization, dispatch, no-output types, envelope-less event, unknown fields, single-field command shape) + tests/{manifest-shortcut.sh (manifest path), adapter-archive.sh (binary packs at root)}. int: ci/idle-translate/translate-proof-int.sh — `spt adapter translate-proof claude-spt --event …` on real spt >=0.13.1 (counter 28) spawns+feeds the binary daemon-identically and asserts TRANSLATE_PROOF_OK + "commit: yes" + the discrete {key:enter} submit (and that the text carries NO trailing \r). GREEN 2026-06-22 (disposable install-dir staged manifest.toml+strings+binary — the GhReleaseManaged resolver wants the extracted shape; soft-removed on teardown). This is the EMIT half ONLY; the daemon's atomic PTY APPLY + spawn-on-up/reap-on-down lifecycle stay covered by a real-claude bringup int (heavier tier, still deferred) — translate-proof explicitly does not exercise apply (ADR-0022). The no-{commit} FAULT (F-016) is exactly what the proof's no-commit gate catches. doc DEFERRED to the docs-site harness-contract RUN-group slice (documented in the manifest + module comment meanwhile). Public-surface-complete: the contract page (harness-contract/manifest.html, message-idle-translation-binary since v0.13.0) is the authority; the operator-specified ctrl+s choreography is the adapter's own UX layer atop the generic key/text/delay_ms protocol. SOURCE-CONFIRMED (doyle 2026-06-20): spt-core applies a {text} command VERBATIM (broker.rs:1016-1017, no control-char strip), so the envelope-internal CR/LF neutralization is required (an un-neutralized \n/\r would reach the PTY and could split the injection). CORRECTION (2026-06-23): the submit is now a DISCRETE {key:enter} after the text, NOT a trailing \r — empirically a \r byte in injected text does NOT trigger Claude Code's message submission (CC needs the discrete Enter key event; the earlier assumption that a verbatim \r == {key:enter} held at the byte level but not for CC's input handling). The published contract example always showed {text}{key:enter}; this adapter now follows it literally.

+required_stages = ["impl", "unit", "int"]  # D3 FOLD 2026-06-26: now the `translate` subcommand of tools/claude-spt (src/translate.rs) — seam is [message-idle-translation-binary].command="{adapter_dir}/claude-spt translate" (spt-core v0.16.0; path deprecated); built+tested by ci/digest/build.sh (ci/idle-translate/build.sh is a shim); packed as the ONE tool binary; translate-proof-int --dir points at tools/claude-spt. ORIGINAL (pre-fold): impl: tools/cc-spt-idle-translate (Rust JSON-lines filter — event→6-step choreography [stash · 50ms · <envelope> text · 50ms · {key:enter} submit · {commit:true} terminator; CC auto-restores the draft so NO trailing restore keystroke], init/input/unknown-type emit nothing, CR/LF in the envelope neutralized so the injection can't split — only the discrete enter submits (a \r byte does NOT submit a CC message, corrected 2026-06-23), forward-compat unknown fields ignored) + [message-idle-translation-binary] path="cc-spt-idle-translate" in adapter/claude-spt.toml + ci/idle-translate/build.sh gate (wired into ci/run-gates.sh) + packed into adapter.spt (ci/publish/package-adapter.sh, the 3rd binary). unit: the crate's 11 cargo tests (run by ci/idle-translate/build.sh: the choreography order, submit-is-discrete-enter-key-not-trailing-CR, mandatory terminating commit, stash-precedes-submit, single-ctrl+s no-trailing-restore, CR/LF neutralization, dispatch, no-output types, envelope-less event, unknown fields, single-field command shape) + tests/{manifest-shortcut.sh (manifest path), adapter-archive.sh (binary packs at root)}. int: ci/idle-translate/translate-proof-int.sh — `spt adapter translate-proof claude-spt --event …` on real spt >=0.13.1 (counter 28) spawns+feeds the binary daemon-identically and asserts TRANSLATE_PROOF_OK + "commit: yes" + the discrete {key:enter} submit (and that the text carries NO trailing \r). GREEN 2026-06-22 (disposable install-dir staged manifest.toml+strings+binary — the GhReleaseManaged resolver wants the extracted shape; soft-removed on teardown). This is the EMIT half ONLY; the daemon's atomic PTY APPLY + spawn-on-up/reap-on-down lifecycle stay covered by a real-claude bringup int (heavier tier, still deferred) — translate-proof explicitly does not exercise apply (ADR-0022). The no-{commit} FAULT (F-016) is exactly what the proof's no-commit gate catches. doc DEFERRED to the docs-site harness-contract RUN-group slice (documented in the manifest + module comment meanwhile). Public-surface-complete: the contract page (harness-contract/manifest.html, message-idle-translation-binary since v0.13.0) is the authority; the operator-specified ctrl+s choreography is the adapter's own UX layer atop the generic key/text/delay_ms protocol. SOURCE-CONFIRMED (doyle 2026-06-20): spt-core applies a {text} command VERBATIM (broker.rs:1016-1017, no control-char strip), so the envelope-internal CR/LF neutralization is required (an un-neutralized \n/\r would reach the PTY and could split the injection). CORRECTION (2026-06-23): the submit is now a DISCRETE {key:enter} after the text, NOT a trailing \r — empirically a \r byte in injected text does NOT trigger Claude Code's message submission (CC needs the discrete Enter key event; the earlier assumption that a verbatim \r == {key:enter} held at the byte level but not for CC's input handling). The published contract example always showed {text}{key:enter}; this adapter now follows it literally.

# All inactive (required_stages = []) — activate-don't-pre-fail. Activate per-task as the build starts.


D3 complete. Run full gates, then commit D3:


Bash

cd "C:/Users/decid/Documents/projects/spt-claude-code" && sh ci/run-gates.sh 2>&1 | grep -E "RESULT|FAIL|coverage green|docs-drift|llms"

Full gates after D3 + doc tidy


ok  ci/docs/gen-llms.sh
ok  coverage green
=== GATE: docs-drift ===
ok   llms.txt in sync
=== RESULT: PASS ===


cd "C:/Users/decid/Documents/projects/spt-claude-code" && git add -A && git commit -q -F - <<'EOF'
feat(v0.8.0): D3 — fold translate into claude-spt; one tool binary (spt-core v0.16.0)

REQ-DIST-IDLE-TRANSLATE (+impl+unit+int, kept) + REQ-DIST-BINARY-CONSOLIDATE.
spt-core v0.16.0 gave [message-idle-translation-binary] a `command` field — fold
the standalone cc-spt-idle-translate crate into `claude-spt translate`:

- tools/claude-spt/src/translate.rs (was cc-spt-idle-translate/src/main.rs; fn main
  -> pub fn run() -> ExitCode; protocol/tests unchanged — 22 tests). main.rs dispatch
  gains the `translate` subcommand. Crate now: digest/psyche/post-update/translate
  (59 cargo tests green). cc-spt-idle-translate crate DELETED.
- manifest [message-idle-translation-binary]: path="cc-spt-idle-translate" ->
  command="{adapter_dir}/claude-spt translate" (path deprecated; exactly one of
  path/command). Re-vendored adapter/manifest.schema.json from the published v0.16.0
  schema (now defines update.post + idle command).
- packaging: BINS -> "claude-spt" (ONE binary per triple now); package-adapter.sh +
  tests/adapter-archive.sh + tests/manifest-shortcut.sh (command assertion + deprecated-
  path regression guard) + translate-proof-int (--dir -> tools/claude-spt) updated;
  ci/idle-translate/build.sh -> shim (built by ci/digest/build.sh). Operative refs
  swept (setup string, RELEASE-RUNBOOK, REQ note).

Gates green; traceable green; translate-proof + digest-proof resolve the one binary.

Co-authored by: perri
EOF
git log --oneline -1

Commit D3


warning: in the working copy of 'tools/claude-spt/src/main.rs', LF will be replaced by CRLF the next time Git touches it
d0ab354 feat(v0.8.0): D3 — fold translate into claude-spt; one tool binary (spt-core v0.16.0)


D3 committed. Now **D2 — wire `[update.post]`** + make `post-update` honor the contract (stdin JSON, stdout arbitration via `!!update-message!!` sentinel). Re-read the post-update run() to modify:


1	//! `claude-spt post-update` — reconcile the cplugs plugin after `spt adapter update` (NEW, ADR-0006).
2	//!
3	//! `spt adapter update` pulls the adapter `.spt` (manifest + binaries + strings — the high-churn
4	//! surface) but it CANNOT touch the cplugs plugin (the thin skeleton: hooks.json + skill stubs +
5	//! plugin.json), which lives in Claude Code's marketplace registry. This subcommand closes that gap:
6	//! detect the host CLI (`claude`, or `ccs` — a drop-in), ensure the `cplugs` marketplace is
7	//! registered, then install/update the plugin. It then PRINTS a notice — it CANNOT run
8	//! `/reload-plugins` (a TUI-only action), so the manual residual is the user's `/reload-plugins`.
9	//!
10	//! WIRING STATUS: standalone-runnable + unit-tested NOW, but `[update]` cannot invoke it until doyle
11	//! ask #2 (composite `[update]` with a delegated post-step) lands — see ADR-0006 + UPDATE-NAMING-
12	//! DOYLE-ASKS.md ask 2 (D2). Until then it is run by hand / by /sptc:setup. `--dry-run` prints the
13	//! intended actions without spawning the CLI (the deterministic surface a future int asserts on).
14	//! [impl->REQ-DIST-BINARY-CONSOLIDATE]
15
16	use std::path::PathBuf;
17	use std::process::{Command, ExitCode, Stdio};
18
19	const MARKETPLACE: &str = "cplugs";
20	const MARKETPLACE_REPO: &str = "SaberMage/cplugs";
21	/// Plugin reference Claude Code keys by: `<plugin.json name>@<marketplace>`. This milestone the
22	/// plugin stays `sptc` (the `sptc`→`spt` succession is D4, owl-gated) so the ref is `sptc@cplugs`.
23	const PLUGIN_REF: &str = "sptc@cplugs";
24

//! `claude-spt post-update` — reconcile the cplugs plugin after `spt adapter update` (NEW, ADR-0006).
//!
//! `spt adapter update` pulls the adapter `.spt` (manifest + binaries + strings — the high-churn
//! surface) but it CANNOT touch the cplugs plugin (the thin skeleton: hooks.json + skill stubs +
//! plugin.json), which lives in Claude Code's marketplace registry. This subcommand closes that gap:
//! detect the host CLI (`claude`, or `ccs` — a drop-in), ensure the `cplugs` marketplace is
//! registered, then install/update the plugin. It then PRINTS a notice — it CANNOT run
//! `/reload-plugins` (a TUI-only action), so the manual residual is the user's `/reload-plugins`.
//!
//! WIRING STATUS: standalone-runnable + unit-tested NOW, but `[update]` cannot invoke it until doyle
//! ask #2 (composite `[update]` with a delegated post-step) lands — see ADR-0006 + UPDATE-NAMING-
//! DOYLE-ASKS.md ask 2 (D2). Until then it is run by hand / by /sptc:setup. `--dry-run` prints the
//! intended actions without spawning the CLI (the deterministic surface a future int asserts on).
//! [impl->REQ-DIST-BINARY-CONSOLIDATE]

use std::path::PathBuf;
use std::process::{Command, ExitCode, Stdio};

const MARKETPLACE: &str = "cplugs";
const MARKETPLACE_REPO: &str = "SaberMage/cplugs";
/// Plugin reference Claude Code keys by: `<plugin.json name>@<marketplace>`. This milestone the
/// plugin stays `sptc` (the `sptc`→`spt` succession is D4, owl-gated) so the ref is `sptc@cplugs`.
const PLUGIN_REF: &str = "sptc@cplugs";


Edit

//! WIRING STATUS: standalone-runnable + unit-tested NOW, but `[update]` cannot invoke it until doyle
//! ask #2 (composite `[update]` with a delegated post-step) lands — see ADR-0006 + UPDATE-NAMING-
//! DOYLE-ASKS.md ask 2 (D2). Until then it is run by hand / by /sptc:setup. `--dry-run` prints the
//! intended actions without spawning the CLI (the deterministic surface a future int asserts on).
//! [impl->REQ-DIST-BINARY-CONSOLIDATE]

use std::path::PathBuf;
use std::process::{Command, ExitCode, Stdio};

const MARKETPLACE: &str = "cplugs";
const MARKETPLACE_REPO: &str = "SaberMage/cplugs";

//! WIRED (spt-core v0.16.0, D2): the manifest `[update.post] = {command = "{adapter_dir}/claude-spt
//! post-update", self_verifies = false}` runs this AFTER the `gh_release` pull, UNCONDITIONALLY.
//! spt-core pipes ONE JSON line on stdin — `{adapter_applied, adapter_name, profile_name, version,
//! previous_version, adapter_dir}` (additive; we ignore unknown keys) — and reads our STDOUT to
//! arbitrate the update notice: custom text SUPERSEDES `[update].message`; the reserved sentinel
//! `!!update-message!!` (alone) FIRES the static `[update].message`; empty = nothing. Exit code is
//! orthogonal (the pull is never rolled back on our failure — fail-isolated). `self_verifies` is
//! attestation-only (gates nothing yet).
//!
//! We use the SENTINEL strategy: on a successful real reconcile we print `!!update-message!!` so the
//! single user-facing copy lives once in `[update].message` (the /reload-plugins + go-live notice,
//! REQ-DIST-UPDATE-MESSAGE). Diagnostics go to STDERR (never stdout — stdout is the arbiter channel).
//!
//! THREE invocation modes:
//!   * `[update.post]` (stdin is a piped JSON line) — reconcile, then stdout = sentinel on success / empty.
//!   * standalone by hand / `/sptc:setup` (stdin is a TTY) — reconcile, then print the human notice to stdout.
//!   * `--dry-run` / `-n` — print the intended actions (no spawn); the deterministic surface the int asserts.
//! [impl->REQ-DIST-BINARY-CONSOLIDATE] [impl->REQ-DIST-UPDATE-MESSAGE]

use std::io::{IsTerminal, Read};
use std::path::PathBuf;
use std::process::{Command, ExitCode, Stdio};

/// Reserved stdout sentinel: printing it (alone) tells spt-core to fire the static `[update].message`.
const UPDATE_MESSAGE_SENTINEL: &str = "!!update-message!!";

const MARKETPLACE: &str = "cplugs";
const MARKETPLACE_REPO: &str = "SaberMage/cplugs";


The file C:\Users\decid\Documents\projects\spt-claude-code\tools\claude-spt\src\post_update.rs has been updated successfully. (file state is current in your context — no need to Read it back)

//! WIRING STATUS: standalone-runnable + unit-tested NOW, but `[update]` cannot invoke it until doyle
//! ask #2 (composite `[update]` with a delegated post-step) lands — see ADR-0006 + UPDATE-NAMING-
//! DOYLE-ASKS.md ask 2 (D2). Until then it is run by hand / by /sptc:setup. `--dry-run` prints the
//! intended actions without spawning the CLI (the deterministic surface a future int asserts on).
//! [impl->REQ-DIST-BINARY-CONSOLIDATE]

use std::path::PathBuf;
use std::process::{Command, ExitCode, Stdio};

const MARKETPLACE: &str = "cplugs";
const MARKETPLACE_REPO: &str = "SaberMage/cplugs";

//! WIRED (spt-core v0.16.0, D2): the manifest `[update.post] = {command = "{adapter_dir}/claude-spt
//! post-update", self_verifies = false}` runs this AFTER the `gh_release` pull, UNCONDITIONALLY.
//! spt-core pipes ONE JSON line on stdin — `{adapter_applied, adapter_name, profile_name, version,
//! previous_version, adapter_dir}` (additive; we ignore unknown keys) — and reads our STDOUT to
//! arbitrate the update notice: custom text SUPERSEDES `[update].message`; the reserved sentinel
//! `!!update-message!!` (alone) FIRES the static `[update].message`; empty = nothing. Exit code is
//! orthogonal (the pull is never rolled back on our failure — fail-isolated). `self_verifies` is
//! attestation-only (gates nothing yet).
//!
//! We use the SENTINEL strategy: on a successful real reconcile we print `!!update-message!!` so the
//! single user-facing copy lives once in `[update].message` (the /reload-plugins + go-live notice,
//! REQ-DIST-UPDATE-MESSAGE). Diagnostics go to STDERR (never stdout — stdout is the arbiter channel).
//!
//! THREE invocation modes:
//!   * `[update.post]` (stdin is a piped JSON line) — reconcile, then stdout = sentinel on success / empty.
//!   * standalone by hand / `/sptc:setup` (stdin is a TTY) — reconcile, then print the human notice to stdout.
//!   * `--dry-run` / `-n` — print the intended actions (no spawn); the deterministic surface the int asserts.
//! [impl->REQ-DIST-BINARY-CONSOLIDATE] [impl->REQ-DIST-UPDATE-MESSAGE]

use std::io::{IsTerminal, Read};
use std::path::PathBuf;
use std::process::{Command, ExitCode, Stdio};

/// Reserved stdout sentinel: printing it (alone) tells spt-core to fire the static `[update].message`.
const UPDATE_MESSAGE_SENTINEL: &str = "!!update-message!!";

const MARKETPLACE: &str = "cplugs";
const MARKETPLACE_REPO: &str = "SaberMage/cplugs";

//! registered, then install/update the plugin. It then PRINTS a notice — it CANNOT run

//! `/reload-plugins` (a TUI-only action), so the manual residual is the user's `/reload-plugins`.

-//! WIRING STATUS: standalone-runnable + unit-tested NOW, but `[update]` cannot invoke it until doyle

-//! ask #2 (composite `[update]` with a delegated post-step) lands — see ADR-0006 + UPDATE-NAMING-

-//! DOYLE-ASKS.md ask 2 (D2). Until then it is run by hand / by /sptc:setup. `--dry-run` prints the

-//! intended actions without spawning the CLI (the deterministic surface a future int asserts on).

+//! WIRED (spt-core v0.16.0, D2): the manifest `[update.post] = {command = "{adapter_dir}/claude-spt

+//! post-update", self_verifies = false}` runs this AFTER the `gh_release` pull, UNCONDITIONALLY.

+//! spt-core pipes ONE JSON line on stdin — `{adapter_applied, adapter_name, profile_name, version,

+//! previous_version, adapter_dir}` (additive; we ignore unknown keys) — and reads our STDOUT to

+//! arbitrate the update notice: custom text SUPERSEDES `[update].message`; the reserved sentinel

+//! `!!update-message!!` (alone) FIRES the static `[update].message`; empty = nothing. Exit code is

+//! orthogonal (the pull is never rolled back on our failure — fail-isolated). `self_verifies` is

+//! attestation-only (gates nothing yet).

+//! We use the SENTINEL strategy: on a successful real reconcile we print `!!update-message!!` so the

+//! single user-facing copy lives once in `[update].message` (the /reload-plugins + go-live notice,

+//! REQ-DIST-UPDATE-MESSAGE). Diagnostics go to STDERR (never stdout — stdout is the arbiter channel).

+//!   * `[update.post]` (stdin is a piped JSON line) — reconcile, then stdout = sentinel on success / empty.

+//!   * standalone by hand / `/sptc:setup` (stdin is a TTY) — reconcile, then print the human notice to stdout.

+//!   * `--dry-run` / `-n` — print the intended actions (no spawn); the deterministic surface the int asserts.

+/// Reserved stdout sentinel: printing it (alone) tells spt-core to fire the static `[update].message`.

+const UPDATE_MESSAGE_SENTINEL: &str = "!!update-message!!";

/// Plugin reference Claude Code keys by: `<plugin.json name>@<marketplace>`. This milestone the


Now read the current `run()` + add the context parser, then rewrite run() for the three modes:


115	            .iter()
116	            .map(|ext| format!("{name}{ext}"))
117	            .collect()
118	    } else {
119	        vec![name.to_string()]
120	    }
121	}
122
123	/// The post-update notice. It states what was reconciled and the ONE manual residual
124	/// (`/reload-plugins`) — it deliberately does NOT run that itself (TUI-only). Kept aligned with the
125	/// manifest `[update].message` copy (REQ-DIST-UPDATE-MESSAGE) so both levers say the same thing.
126	fn notice() -> &'static str {
127	    "claude-spt: plugin reconciled. Run /reload-plugins in Claude Code (or restart it) to load the refreshed skills/hooks."
128	}
129
130	/// Claude Code's config root: `$CLAUDE_CONFIG_DIR` (set by `ccs` per-account) else `~/.claude`.
131	fn claude_config_dir() -> Option<PathBuf> {
132	    if let Some(cfg) = std::env::var_os("CLAUDE_CONFIG_DIR") {
133	        if !cfg.is_empty() {
134	            return Some(PathBuf::from(cfg));
135	        }
136	    }
137	    std::env::var_os("HOME")
138	        .or_else(|| std::env::var_os("USERPROFILE"))
139	        .map(|home| PathBuf::from(home).join(".claude"))
140	}
141
142	/// True if `name` (or a PATHEXT variant) resolves to a file on any PATH dir.
143	fn which(name: &str) -> bool {
144	    let path = match std::env::var_os("PATH") {
145	        Some(p) => p,
146	        None => return false,
147	    };
148	    let cands = program_candidates(name);
149	    std::env::split_paths(&path).any(|dir| cands.iter().any(|c| dir.join(c).is_file()))
150	}
151
152	/// `claude-spt post-update` entry. Reads its own argv (binary name + `post-update` token consumed).
153	pub fn run() -> ExitCode {
154	    let dry_run = std::env::args()
155	        .skip(2)
156	        .any(|a| a == "--dry-run" || a == "-n");
157
158	    let cli = match choose_cli(which("claude"), which("ccs")) {
159	        Some(c) => c,
160	        None => {
161	            eprintln!("claude-spt post-update: no `claude` or `ccs` CLI on PATH — cannot reconcile the plugin");
162	            return ExitCode::from(3);
163	        }
164	    };
165
166	    let plugins_dir = claude_config_dir().map(|d| d.join("plugins"));
167	    let read = |name: &str| -> String {
168	        plugins_dir
169	            .as_ref()
170	            .and_then(|d| std::fs::read_to_string(d.join(name)).ok())
171	            .unwrap_or_default()
172	    };
173	    let registered = marketplace_registered_in(&read("known_marketplaces.json"));
174	    let installed = plugin_installed_in(&read("installed_plugins.json"));
175
176	    let mut steps: Vec<Vec<String>> = Vec::new();
177	    if !registered {
178	        steps.push(marketplace_add_cmd());
179	    }
180	    steps.push(plugin_sync_cmd(installed));
181
182	    for argv in &steps {
183	        let shown = format!("{cli} {}", argv.join(" "));
184	        if dry_run {
185	            println!("would run: {shown}");
186	            continue;
187	        }
188	        eprintln!("claude-spt post-update: {shown}");
189	        let status = Command::new(cli).args(argv).stdin(Stdio::null()).status();

            .iter()
            .map(|ext| format!("{name}{ext}"))
            .collect()
    } else {
        vec![name.to_string()]
    }
}

/// The post-update notice. It states what was reconciled and the ONE manual residual
/// (`/reload-plugins`) — it deliberately does NOT run that itself (TUI-only). Kept aligned with the
/// manifest `[update].message` copy (REQ-DIST-UPDATE-MESSAGE) so both levers say the same thing.
fn notice() -> &'static str {
    "claude-spt: plugin reconciled. Run /reload-plugins in Claude Code (or restart it) to load the refreshed skills/hooks."
}

/// Claude Code's config root: `$CLAUDE_CONFIG_DIR` (set by `ccs` per-account) else `~/.claude`.
fn claude_config_dir() -> Option<PathBuf> {
    if let Some(cfg) = std::env::var_os("CLAUDE_CONFIG_DIR") {
        if !cfg.is_empty() {
            return Some(PathBuf::from(cfg));
        }
    }
    std::env::var_os("HOME")
        .or_else(|| std::env::var_os("USERPROFILE"))
        .map(|home| PathBuf::from(home).join(".claude"))
}

/// True if `name` (or a PATHEXT variant) resolves to a file on any PATH dir.
fn which(name: &str) -> bool {
    let path = match std::env::var_os("PATH") {
        Some(p) => p,
        None => return false,
    };
    let cands = program_candidates(name);
    std::env::split_paths(&path).any(|dir| cands.iter().any(|c| dir.join(c).is_file()))
}

/// `claude-spt post-update` entry. Reads its own argv (binary name + `post-update` token consumed).
pub fn run() -> ExitCode {
    let dry_run = std::env::args()
        .skip(2)
        .any(|a| a == "--dry-run" || a == "-n");

    let cli = match choose_cli(which("claude"), which("ccs")) {
        Some(c) => c,
        None => {
            eprintln!("claude-spt post-update: no `claude` or `ccs` CLI on PATH — cannot reconcile the plugin");
            return ExitCode::from(3);
        }
    };

    let plugins_dir = claude_config_dir().map(|d| d.join("plugins"));
    let read = |name: &str| -> String {
        plugins_dir
            .as_ref()
            .and_then(|d| std::fs::read_to_string(d.join(name)).ok())
            .unwrap_or_default()
    };
    let registered = marketplace_registered_in(&read("known_marketplaces.json"));
    let installed = plugin_installed_in(&read("installed_plugins.json"));

    let mut steps: Vec<Vec<String>> = Vec::new();
    if !registered {
        steps.push(marketplace_add_cmd());
    }
    steps.push(plugin_sync_cmd(installed));

    for argv in &steps {
        let shown = format!("{cli} {}", argv.join(" "));
        if dry_run {
            println!("would run: {shown}");
            continue;
        }
        eprintln!("claude-spt post-update: {shown}");
        let status = Command::new(cli).args(argv).stdin(Stdio::null()).status();


189	        let status = Command::new(cli).args(argv).stdin(Stdio::null()).status();
190	        match status {
191	            Ok(s) if s.success() => {}
192	            Ok(s) => {
193	                eprintln!("claude-spt post-update: `{shown}` exited {s}");
194	                return ExitCode::from(1);
195	            }
196	            Err(e) => {
197	                eprintln!("claude-spt post-update: failed to spawn `{shown}`: {e}");
198	                return ExitCode::from(1);
199	            }
200	        }
201	    }
202
203	    println!("{}", notice());
204	    ExitCode::SUCCESS
205	}
206
207	// [unit->REQ-DIST-BINARY-CONSOLIDATE]
208	#[cfg(test)]
209	mod tests {
210	    use super::*;

        let status = Command::new(cli).args(argv).stdin(Stdio::null()).status();
        match status {
            Ok(s) if s.success() => {}
            Ok(s) => {
                eprintln!("claude-spt post-update: `{shown}` exited {s}");
                return ExitCode::from(1);
            }
            Err(e) => {
                eprintln!("claude-spt post-update: failed to spawn `{shown}`: {e}");
                return ExitCode::from(1);
            }
        }
    }

    println!("{}", notice());
    ExitCode::SUCCESS
}

// [unit->REQ-DIST-BINARY-CONSOLIDATE]
#[cfg(test)]
mod tests {
    use super::*;


Now rewrite `run()` for the three modes + add the pure context-parser & arbiter helpers:


/// `claude-spt post-update` entry. Reads its own argv (binary name + `post-update` token consumed).
pub fn run() -> ExitCode {
    let dry_run = std::env::args()
        .skip(2)
        .any(|a| a == "--dry-run" || a == "-n");

    let cli = match choose_cli(which("claude"), which("ccs")) {
        Some(c) => c,
        None => {
            eprintln!("claude-spt post-update: no `claude` or `ccs` CLI on PATH — cannot reconcile the plugin");
            return ExitCode::from(3);
        }
    };

    let plugins_dir = claude_config_dir().map(|d| d.join("plugins"));
    let read = |name: &str| -> String {
        plugins_dir
            .as_ref()
            .and_then(|d| std::fs::read_to_string(d.join(name)).ok())
            .unwrap_or_default()
    };
    let registered = marketplace_registered_in(&read("known_marketplaces.json"));
    let installed = plugin_installed_in(&read("installed_plugins.json"));

    let mut steps: Vec<Vec<String>> = Vec::new();
    if !registered {
        steps.push(marketplace_add_cmd());
    }
    steps.push(plugin_sync_cmd(installed));

    for argv in &steps {
        let shown = format!("{cli} {}", argv.join(" "));
        if dry_run {
            println!("would run: {shown}");
            continue;
        }
        eprintln!("claude-spt post-update: {shown}");
        let status = Command::new(cli).args(argv).stdin(Stdio::null()).status();
        match status {
            Ok(s) if s.success() => {}
            Ok(s) => {
                eprintln!("claude-spt post-update: `{shown}` exited {s}");
                return ExitCode::from(1);
            }
            Err(e) => {
                eprintln!("claude-spt post-update: failed to spawn `{shown}`: {e}");
                return ExitCode::from(1);
            }
        }
    }

    println!("{}", notice());
    ExitCode::SUCCESS
}

/// Parse the [update.post] stdin JSON line (ADDITIVE — ignore unknown keys, per doyle). Returns the
/// `adapter_applied` flag (whether the adapter version was applied this run); false on absent/bad
/// JSON. Pure so the stdin read stays at the edge. [impl->REQ-DIST-UPDATE-MESSAGE]
fn update_applied_in(json: &str) -> bool {
    serde_json::from_str::<serde_json::Value>(json)
        .ok()
        .and_then(|v| v.get("adapter_applied").and_then(|b| b.as_bool()))
        .unwrap_or(false)
}

/// The stdout ARBITER line for the [update.post] channel: the reserved sentinel (→ spt-core fires the
/// static `[update].message`) when the reconcile ran OR the adapter was applied, else empty (nothing).
/// We fire on any successful real reconcile so the one-lever `spt adapter update` always surfaces the
/// /reload-plugins reminder (the user-facing copy lives once in `[update].message`). Pure.
/// [impl->REQ-DIST-UPDATE-MESSAGE]
fn arbiter_line(reconciled: bool, adapter_applied: bool) -> &'static str {
    if reconciled || adapter_applied {
        UPDATE_MESSAGE_SENTINEL
    } else {
        ""
    }
}

/// `claude-spt post-update` entry. Reads its own argv (binary name + `post-update` token consumed).
pub fn run() -> ExitCode {
    let dry_run = std::env::args()
        .skip(2)
        .any(|a| a == "--dry-run" || a == "-n");

    // [update.post] mode iff stdin is a piped JSON line (spt-core pipes one line then closes); a TTY
    // stdin = standalone-by-hand. --dry-run forces the standalone-diagnostic path regardless.
    let post_mode = !dry_run && !std::io::stdin().is_terminal();
    let mut adapter_applied = false;
    if post_mode {
        let mut buf = String::new();
        let _ = std::io::stdin().read_to_string(&mut buf);
        adapter_applied = update_applied_in(buf.trim());
    }

    let cli = match choose_cli(which("claude"), which("ccs")) {
        Some(c) => c,
        None => {
            // fail-isolated: spt-core never rolls back the pull on our exit code. stderr, not stdout.
            eprintln!("claude-spt post-update: no `claude` or `ccs` CLI on PATH — cannot reconcile the plugin");
            return ExitCode::from(3);
        }
    };

    let plugins_dir = claude_config_dir().map(|d| d.join("plugins"));
    let read = |name: &str| -> String {
        plugins_dir
            .as_ref()
            .and_then(|d| std::fs::read_to_string(d.join(name)).ok())
            .unwrap_or_default()
    };
    let registered = marketplace_registered_in(&read("known_marketplaces.json"));
    let installed = plugin_installed_in(&read("installed_plugins.json"));

    let mut steps: Vec<Vec<String>> = Vec::new();
    if !registered {
        steps.push(marketplace_add_cmd());
    }
    steps.push(plugin_sync_cmd(installed));

    for argv in &steps {
        let shown = format!("{cli} {}", argv.join(" "));
        if dry_run {
            println!("would run: {shown}"); // diagnostic preview to stdout (no spt-core arbiter present)
            continue;
        }
        // Diagnostics → STDERR: stdout is the [update.post] arbiter channel (sentinel/custom/empty).
        eprintln!("claude-spt post-update: {shown}");
        match Command::new(cli).args(argv).stdin(Stdio::null()).status() {
            Ok(s) if s.success() => {}
            Ok(s) => {
                eprintln!("claude-spt post-update: `{shown}` exited {s}");
                return ExitCode::from(1); // fail-isolated; stdout stays empty so no notice fires
            }
            Err(e) => {
                eprintln!("claude-spt post-update: failed to spawn `{shown}`: {e}");
                return ExitCode::from(1);
            }
        }
    }

    if dry_run {
        return ExitCode::SUCCESS; // diagnostics already printed; no arbiter output
    }
    if post_mode {
        // spt-core reads stdout to arbitrate: the sentinel fires the static [update].message.
        print!("{}", arbiter_line(true, adapter_applied));
    } else {
        // standalone-by-hand: no spt-core arbiter, so show the human notice directly.
        println!("{}", notice());
    }
    ExitCode::SUCCESS
}


/// `claude-spt post-update` entry. Reads its own argv (binary name + `post-update` token consumed).
pub fn run() -> ExitCode {
    let dry_run = std::env::args()
        .skip(2)
        .any(|a| a == "--dry-run" || a == "-n");

    let cli = match choose_cli(which("claude"), which("ccs")) {
        Some(c) => c,
        None => {
            eprintln!("claude-spt post-update: no `claude` or `ccs` CLI on PATH — cannot reconcile the plugin");
            return ExitCode::from(3);
        }
    };

    let plugins_dir = claude_config_dir().map(|d| d.join("plugins"));
    let read = |name: &str| -> String {
        plugins_dir
            .as_ref()
            .and_then(|d| std::fs::read_to_string(d.join(name)).ok())
            .unwrap_or_default()
    };
    let registered = marketplace_registered_in(&read("known_marketplaces.json"));
    let installed = plugin_installed_in(&read("installed_plugins.json"));

    let mut steps: Vec<Vec<String>> = Vec::new();
    if !registered {
        steps.push(marketplace_add_cmd());
    }
    steps.push(plugin_sync_cmd(installed));

    for argv in &steps {
        let shown = format!("{cli} {}", argv.join(" "));
        if dry_run {
            println!("would run: {shown}");
            continue;
        }
        eprintln!("claude-spt post-update: {shown}");
        let status = Command::new(cli).args(argv).stdin(Stdio::null()).status();
        match status {
            Ok(s) if s.success() => {}
            Ok(s) => {
                eprintln!("claude-spt post-update: `{shown}` exited {s}");
                return ExitCode::from(1);
            }
            Err(e) => {
                eprintln!("claude-spt post-update: failed to spawn `{shown}`: {e}");
                return ExitCode::from(1);
            }
        }
    }

    println!("{}", notice());
    ExitCode::SUCCESS
}

/// Parse the [update.post] stdin JSON line (ADDITIVE — ignore unknown keys, per doyle). Returns the
/// `adapter_applied` flag (whether the adapter version was applied this run); false on absent/bad
/// JSON. Pure so the stdin read stays at the edge. [impl->REQ-DIST-UPDATE-MESSAGE]
fn update_applied_in(json: &str) -> bool {
    serde_json::from_str::<serde_json::Value>(json)
        .ok()
        .and_then(|v| v.get("adapter_applied").and_then(|b| b.as_bool()))
        .unwrap_or(false)
}

/// The stdout ARBITER line for the [update.post] channel: the reserved sentinel (→ spt-core fires the
/// static `[update].message`) when the reconcile ran OR the adapter was applied, else empty (nothing).
/// We fire on any successful real reconcile so the one-lever `spt adapter update` always surfaces the
/// /reload-plugins reminder (the user-facing copy lives once in `[update].message`). Pure.
/// [impl->REQ-DIST-UPDATE-MESSAGE]
fn arbiter_line(reconciled: bool, adapter_applied: bool) -> &'static str {
    if reconciled || adapter_applied {
        UPDATE_MESSAGE_SENTINEL
    } else {
        ""
    }
}

/// `claude-spt post-update` entry. Reads its own argv (binary name + `post-update` token consumed).
pub fn run() -> ExitCode {
    let dry_run = std::env::args()
        .skip(2)
        .any(|a| a == "--dry-run" || a == "-n");

    // [update.post] mode iff stdin is a piped JSON line (spt-core pipes one line then closes); a TTY
    // stdin = standalone-by-hand. --dry-run forces the standalone-diagnostic path regardless.
    let post_mode = !dry_run && !std::io::stdin().is_terminal();
    let mut adapter_applied = false;
    if post_mode {
        let mut buf = String::new();
        let _ = std::io::stdin().read_to_string(&mut buf);
        adapter_applied = update_applied_in(buf.trim());
    }

    let cli = match choose_cli(which("claude"), which("ccs")) {
        Some(c) => c,
        None => {
            // fail-isolated: spt-core never rolls back the pull on our exit code. stderr, not stdout.
            eprintln!("claude-spt post-update: no `claude` or `ccs` CLI on PATH — cannot reconcile the plugin");
            return ExitCode::from(3);
        }
    };

    let plugins_dir = claude_config_dir().map(|d| d.join("plugins"));
    let read = |name: &str| -> String {
        plugins_dir
            .as_ref()
            .and_then(|d| std::fs::read_to_string(d.join(name)).ok())
            .unwrap_or_default()
    };
    let registered = marketplace_registered_in(&read("known_marketplaces.json"));
    let installed = plugin_installed_in(&read("installed_plugins.json"));

    let mut steps: Vec<Vec<String>> = Vec::new();
    if !registered {
        steps.push(marketplace_add_cmd());
    }
    steps.push(plugin_sync_cmd(installed));

    for argv in &steps {
        let shown = format!("{cli} {}", argv.join(" "));
        if dry_run {
            println!("would run: {shown}"); // diagnostic preview to stdout (no spt-core arbiter present)
            continue;
        }
        // Diagnostics → STDERR: stdout is the [update.post] arbiter channel (sentinel/custom/empty).
        eprintln!("claude-spt post-update: {shown}");
        match Command::new(cli).args(argv).stdin(Stdio::null()).status() {
            Ok(s) if s.success() => {}
            Ok(s) => {
                eprintln!("claude-spt post-update: `{shown}` exited {s}");
                return ExitCode::from(1); // fail-isolated; stdout stays empty so no notice fires
            }
            Err(e) => {
                eprintln!("claude-spt post-update: failed to spawn `{shown}`: {e}");
                return ExitCode::from(1);
            }
        }
    }

    if dry_run {
        return ExitCode::SUCCESS; // diagnostics already printed; no arbiter output
    }
    if post_mode {
        // spt-core reads stdout to arbitrate: the sentinel fires the static [update].message.
        print!("{}", arbiter_line(true, adapter_applied));
    } else {
        // standalone-by-hand: no spt-core arbiter, so show the human notice directly.
        println!("{}", notice());
    }
    ExitCode::SUCCESS
}

+/// Parse the [update.post] stdin JSON line (ADDITIVE — ignore unknown keys, per doyle). Returns the

+/// `adapter_applied` flag (whether the adapter version was applied this run); false on absent/bad

+/// JSON. Pure so the stdin read stays at the edge. [impl->REQ-DIST-UPDATE-MESSAGE]

+fn update_applied_in(json: &str) -> bool {

+    serde_json::from_str::<serde_json::Value>(json)

+        .and_then(|v| v.get("adapter_applied").and_then(|b| b.as_bool()))

+/// The stdout ARBITER line for the [update.post] channel: the reserved sentinel (→ spt-core fires the

+/// static `[update].message`) when the reconcile ran OR the adapter was applied, else empty (nothing).

+/// We fire on any successful real reconcile so the one-lever `spt adapter update` always surfaces the

+/// /reload-plugins reminder (the user-facing copy lives once in `[update].message`). Pure.

+fn arbiter_line(reconciled: bool, adapter_applied: bool) -> &'static str {

+    // [update.post] mode iff stdin is a piped JSON line (spt-core pipes one line then closes); a TTY

+    // stdin = standalone-by-hand. --dry-run forces the standalone-diagnostic path regardless.

+    let post_mode = !dry_run && !std::io::stdin().is_terminal();

+        let _ = std::io::stdin().read_to_string(&mut buf);

+        adapter_applied = update_applied_in(buf.trim());

+            // fail-isolated: spt-core never rolls back the pull on our exit code. stderr, not stdout.

-            println!("would run: {shown}");

+            println!("would run: {shown}"); // diagnostic preview to stdout (no spt-core arbiter present)

+        // Diagnostics → STDERR: stdout is the [update.post] arbiter channel (sentinel/custom/empty).

-        let status = Command::new(cli).args(argv).stdin(Stdio::null()).status();

+        match Command::new(cli).args(argv).stdin(Stdio::null()).status() {

-                return ExitCode::from(1);

+                return ExitCode::from(1); // fail-isolated; stdout stays empty so no notice fires

+        return ExitCode::SUCCESS; // diagnostics already printed; no arbiter output

+        // spt-core reads stdout to arbitrate: the sentinel fires the static [update].message.

+        print!("{}", arbiter_line(true, adapter_applied));

+        // standalone-by-hand: no spt-core arbiter, so show the human notice directly.


Add unit tests for the new contract helpers:


    #[test]
    fn notice_names_reload_plugins_but_does_not_claim_to_run_it() {
        let n = notice();
        assert!(n.contains("/reload-plugins"), "notice must point at the manual residual");
        // The notice tells the user to run it — the subcommand never runs the TUI action itself.
        assert!(n.contains("Run /reload-plugins"));
    }
}

    #[test]
    fn notice_names_reload_plugins_but_does_not_claim_to_run_it() {
        let n = notice();
        assert!(n.contains("/reload-plugins"), "notice must point at the manual residual");
        // The notice tells the user to run it — the subcommand never runs the TUI action itself.
        assert!(n.contains("Run /reload-plugins"));
    }

    // [unit->REQ-DIST-UPDATE-MESSAGE] the [update.post] stdin/stdout contract (D2).
    #[test]
    fn update_context_reads_adapter_applied_additively() {
        // Additive: unknown future keys ignored; only adapter_applied read.
        assert!(update_applied_in(
            r#"{"adapter_applied":true,"adapter_name":"claude-spt","version":"0.8.0","unknown_future_key":42}"#
        ));
        assert!(!update_applied_in(r#"{"adapter_applied":false,"adapter_dir":"/x"}"#));
        assert!(!update_applied_in(r#"{"adapter_name":"claude-spt"}"#)); // key absent -> false
        assert!(!update_applied_in("not json"));
        assert!(!update_applied_in(""));
    }

    #[test]
    fn arbiter_fires_sentinel_on_reconcile_or_applied_else_empty() {
        assert_eq!(arbiter_line(true, false), "!!update-message!!"); // reconcile ran
        assert_eq!(arbiter_line(false, true), "!!update-message!!"); // adapter applied
        assert_eq!(arbiter_line(true, true), "!!update-message!!");
        assert_eq!(arbiter_line(false, false), ""); // true no-op -> nothing
    }
}


    #[test]
    fn notice_names_reload_plugins_but_does_not_claim_to_run_it() {
        let n = notice();
        assert!(n.contains("/reload-plugins"), "notice must point at the manual residual");
        // The notice tells the user to run it — the subcommand never runs the TUI action itself.
        assert!(n.contains("Run /reload-plugins"));
    }
}

    #[test]
    fn notice_names_reload_plugins_but_does_not_claim_to_run_it() {
        let n = notice();
        assert!(n.contains("/reload-plugins"), "notice must point at the manual residual");
        // The notice tells the user to run it — the subcommand never runs the TUI action itself.
        assert!(n.contains("Run /reload-plugins"));
    }

    // [unit->REQ-DIST-UPDATE-MESSAGE] the [update.post] stdin/stdout contract (D2).
    #[test]
    fn update_context_reads_adapter_applied_additively() {
        // Additive: unknown future keys ignored; only adapter_applied read.
        assert!(update_applied_in(
            r#"{"adapter_applied":true,"adapter_name":"claude-spt","version":"0.8.0","unknown_future_key":42}"#
        ));
        assert!(!update_applied_in(r#"{"adapter_applied":false,"adapter_dir":"/x"}"#));
        assert!(!update_applied_in(r#"{"adapter_name":"claude-spt"}"#)); // key absent -> false
        assert!(!update_applied_in("not json"));
        assert!(!update_applied_in(""));
    }

    #[test]
    fn arbiter_fires_sentinel_on_reconcile_or_applied_else_empty() {
        assert_eq!(arbiter_line(true, false), "!!update-message!!"); // reconcile ran
        assert_eq!(arbiter_line(false, true), "!!update-message!!"); // adapter applied
        assert_eq!(arbiter_line(true, true), "!!update-message!!");
        assert_eq!(arbiter_line(false, false), ""); // true no-op -> nothing
    }
}

+    // [unit->REQ-DIST-UPDATE-MESSAGE] the [update.post] stdin/stdout contract (D2).

+    fn update_context_reads_adapter_applied_additively() {

+        // Additive: unknown future keys ignored; only adapter_applied read.

+            r#"{"adapter_applied":true,"adapter_name":"claude-spt","version":"0.8.0","unknown_future_key":42}"#

+        assert!(!update_applied_in(r#"{"adapter_applied":false,"adapter_dir":"/x"}"#));

+        assert!(!update_applied_in(r#"{"adapter_name":"claude-spt"}"#)); // key absent -> false

+        assert!(!update_applied_in("not json"));

+        assert!(!update_applied_in(""));

+    fn arbiter_fires_sentinel_on_reconcile_or_applied_else_empty() {

+        assert_eq!(arbiter_line(true, false), "!!update-message!!"); // reconcile ran

+        assert_eq!(arbiter_line(false, true), "!!update-message!!"); // adapter applied

+        assert_eq!(arbiter_line(true, true), "!!update-message!!");

+        assert_eq!(arbiter_line(false, false), ""); // true no-op -> nothing


Now declare `[update.post]` in the manifest. Find the end of the `[update]` block:


96	# this print on a plugin-only change; copy stays valid.)
97	# [impl->REQ-DIST-ADAPTER-RELEASE]
98	[update]
99	avenue = "gh_release"
100	repo = "SaberMage/claude-spt"
101	message = """
102	**claude-spt updated.** One step left — Claude Code can't reload a plugin from inside a running session:
103
104	- Run **`/reload-plugins`** now (or restart Claude Code) so the refreshed skills/hooks take effect.
105
106	Then you're current. Two ways to go live:
107
108	- **`/sptc:live <id>`** — upgrade *this* session to a live agent.
109	- **`spt endpoint run`** — the `spt` CLI route: spawn/attach endpoints, drive them over Remote Control, and bring up sessions headlessly. The more powerful lever once you're comfortable.
110	"""
111
112	# ── [hooks.<event>] ──────────────────────────────────────────────────────────────────────────
113	# CC hook event → the opaque `api …` command it fires. These mirror the hand-written plugin
114	# hooks.json (plugin/sptc/hooks/hooks.json) one-for-one; the plugin shells `spt api`, this table
115	# is the registry-side declaration (ADR-0002, F-001 resolution). `reads` = the CC hook-stdin
116	# fields we map; `can_inject` = whether the event can surface additionalContext back to the agent.
117

# this print on a plugin-only change; copy stays valid.)
# [impl->REQ-DIST-ADAPTER-RELEASE]
[update]
avenue = "gh_release"
repo = "SaberMage/claude-spt"
message = """
**claude-spt updated.** One step left — Claude Code can't reload a plugin from inside a running session:

- Run **`/reload-plugins`** now (or restart Claude Code) so the refreshed skills/hooks take effect.

Then you're current. Two ways to go live:

- **`/sptc:live <id>`** — upgrade *this* session to a live agent.
- **`spt endpoint run`** — the `spt` CLI route: spawn/attach endpoints, drive them over Remote Control, and bring up sessions headlessly. The more powerful lever once you're comfortable.
"""

# ── [hooks.<event>] ──────────────────────────────────────────────────────────────────────────
# CC hook event → the opaque `api …` command it fires. These mirror the hand-written plugin
# hooks.json (plugin/sptc/hooks/hooks.json) one-for-one; the plugin shells `spt api`, this table
# is the registry-side declaration (ADR-0002, F-001 resolution). `reads` = the CC hook-stdin
# fields we map; `can_inject` = whether the event can surface additionalContext back to the agent.