Edit

# `claude-spt translate` ([message-idle-translation-binary].command, path deprecated) → ONE tool binary
# per triple. Also F-021 reachability (PreToolUse mid-turn poll + honest busy/idle turn-state) + U4
# reactive-skill thinning (commune/signoff prose → the live-ops brief). REQUIRES spt-core v0.16.0 (the
# update.post + idle command + {adapter_dir} seams), so the floor BUMPS to 0.16.0 below.
version = "0.8.0"

# `claude-spt translate` ([message-idle-translation-binary].command, path deprecated) → ONE tool binary
# per triple. Also F-021 reachability (PreToolUse mid-turn poll + honest busy/idle turn-state) + U4
# reactive-skill thinning (commune/signoff prose → the live-ops brief). REQUIRES spt-core v0.16.0 (the
# update.post + idle command + {adapter_dir} seams), so the floor BUMPS to 0.16.0 below.
# 0.9.0 (2026-06-28): D1 — HOOK LOGIC FOLDED INTO THE BINARY. All eight CC hook wrappers + their shared
# _common.sh moved out of the cplugs plugin shell into `claude-spt hook <event>` (the binary's new
# subcommand), so hook behaviour now rides `spt adapter update` instead of forcing a cplugs republish.
# The plugin ships only a STATIC-FOREVER hooks.json + a thin dispatch.sh that resolves the binary via
# the lazily-substituted [strings].hook_cmd = "{adapter_dir}/claude-spt hook" (ADR-0006 ask #1 →
# resolve-not-execute; both the {adapter_dir} subst + lazy [strings] read-time subst are spt-core
# v0.16.0 primitives, already on the current floor). Behaviour-preserving refactor — same `api …`
# command lines, same per-event additionalContext contract. min_spt_core stays 0.16.0 (no new seam).
version = "0.9.0"


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

# `claude-spt translate` ([message-idle-translation-binary].command, path deprecated) → ONE tool binary
# per triple. Also F-021 reachability (PreToolUse mid-turn poll + honest busy/idle turn-state) + U4
# reactive-skill thinning (commune/signoff prose → the live-ops brief). REQUIRES spt-core v0.16.0 (the
# update.post + idle command + {adapter_dir} seams), so the floor BUMPS to 0.16.0 below.
version = "0.8.0"

# `claude-spt translate` ([message-idle-translation-binary].command, path deprecated) → ONE tool binary
# per triple. Also F-021 reachability (PreToolUse mid-turn poll + honest busy/idle turn-state) + U4
# reactive-skill thinning (commune/signoff prose → the live-ops brief). REQUIRES spt-core v0.16.0 (the
# update.post + idle command + {adapter_dir} seams), so the floor BUMPS to 0.16.0 below.
# 0.9.0 (2026-06-28): D1 — HOOK LOGIC FOLDED INTO THE BINARY. All eight CC hook wrappers + their shared
# _common.sh moved out of the cplugs plugin shell into `claude-spt hook <event>` (the binary's new
# subcommand), so hook behaviour now rides `spt adapter update` instead of forcing a cplugs republish.
# The plugin ships only a STATIC-FOREVER hooks.json + a thin dispatch.sh that resolves the binary via
# the lazily-substituted [strings].hook_cmd = "{adapter_dir}/claude-spt hook" (ADR-0006 ask #1 →
# resolve-not-execute; both the {adapter_dir} subst + lazy [strings] read-time subst are spt-core
# v0.16.0 primitives, already on the current floor). Behaviour-preserving refactor — same `api …`
# command lines, same per-event additionalContext contract. min_spt_core stays 0.16.0 (no new seam).
version = "0.9.0"

+# 0.9.0 (2026-06-28): D1 — HOOK LOGIC FOLDED INTO THE BINARY. All eight CC hook wrappers + their shared

+# _common.sh moved out of the cplugs plugin shell into `claude-spt hook <event>` (the binary's new

+# subcommand), so hook behaviour now rides `spt adapter update` instead of forcing a cplugs republish.

+# The plugin ships only a STATIC-FOREVER hooks.json + a thin dispatch.sh that resolves the binary via

+# the lazily-substituted [strings].hook_cmd = "{adapter_dir}/claude-spt hook" (ADR-0006 ask #1 →

+# resolve-not-execute; both the {adapter_dir} subst + lazy [strings] read-time subst are spt-core

+# v0.16.0 primitives, already on the current floor). Behaviour-preserving refactor — same `api …`

+# command lines, same per-event additionalContext contract. min_spt_core stays 0.16.0 (no new seam).

# Floor BUMPED to 0.13.2 (2026-06-22, C / F-014 drop): the release packaging moved from the per-OS

# stopgap (`adapter.spt` = a windows-binary copy — F-014; broke on a linux host) to ONE fat .spt that

# bundles both x86_64 triples beside a shared manifest. spt-core extracts a fat archive (classify


[strings]
adapter_label = "Claude Code (spt)"

[strings.notice]
not_installed = "spt-core not found on PATH — run /sptc:setup to install it."

[strings]
adapter_label = "Claude Code (spt)"
# hook_cmd (D1, REQ-DIST-HOOK-BINARY): the resolve-not-execute primitive that lets the plugin's
# hooks.json + dispatch.sh go static-forever. `{adapter_dir}` is lazily substituted at
# `spt adapter get-string` read time (spt-core v0.16.0) to the absolute install dir, so the value
# resolves to `<install_dir>/claude-spt hook` — the consolidated binary's hook subcommand, which
# survives adapter updates. dispatch.sh get-strings this ONCE per session (caches it in
# $SPTC_HOOK_BIN), then runs `<resolved> <CCEventName> --host-pid <pid>` per hook, piping the CC hook
# payload on stdin. Session-scoped keys are NOT available here (get-string is adapter-static) — the
# binary reads session_id/prompt/tool_input from stdin. [impl->REQ-DIST-HOOK-BINARY]
hook_cmd = "{adapter_dir}/claude-spt hook"

[strings.notice]
not_installed = "spt-core not found on PATH — run /sptc:setup to install it."


[strings]
adapter_label = "Claude Code (spt)"

[strings.notice]
not_installed = "spt-core not found on PATH — run /sptc:setup to install it."

[strings]
adapter_label = "Claude Code (spt)"
# hook_cmd (D1, REQ-DIST-HOOK-BINARY): the resolve-not-execute primitive that lets the plugin's
# hooks.json + dispatch.sh go static-forever. `{adapter_dir}` is lazily substituted at
# `spt adapter get-string` read time (spt-core v0.16.0) to the absolute install dir, so the value
# resolves to `<install_dir>/claude-spt hook` — the consolidated binary's hook subcommand, which
# survives adapter updates. dispatch.sh get-strings this ONCE per session (caches it in
# $SPTC_HOOK_BIN), then runs `<resolved> <CCEventName> --host-pid <pid>` per hook, piping the CC hook
# payload on stdin. Session-scoped keys are NOT available here (get-string is adapter-static) — the
# binary reads session_id/prompt/tool_input from stdin. [impl->REQ-DIST-HOOK-BINARY]
hook_cmd = "{adapter_dir}/claude-spt hook"

[strings.notice]
not_installed = "spt-core not found on PATH — run /sptc:setup to install it."

# the M12 file-backed-strings parity slice. A profile may leaf-replace any key here.

+# hook_cmd (D1, REQ-DIST-HOOK-BINARY): the resolve-not-execute primitive that lets the plugin's

+# hooks.json + dispatch.sh go static-forever. `{adapter_dir}` is lazily substituted at

+# `spt adapter get-string` read time (spt-core v0.16.0) to the absolute install dir, so the value

+# resolves to `<install_dir>/claude-spt hook` — the consolidated binary's hook subcommand, which

+# survives adapter updates. dispatch.sh get-strings this ONCE per session (caches it in

+# $SPTC_HOOK_BIN), then runs `<resolved> <CCEventName> --host-pid <pid>` per hook, piping the CC hook

+# payload on stdin. Session-scoped keys are NOT available here (get-string is adapter-static) — the

+# binary reads session_id/prompt/tool_input from stdin. [impl->REQ-DIST-HOOK-BINARY]

+hook_cmd = "{adapter_dir}/claude-spt hook"


Update the `[hooks.<event>]` section header to reflect the D1 handler change.


# ── [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.

# ── [hooks.<event>] ──────────────────────────────────────────────────────────────────────────
# CC hook event → the opaque `api …` command it fires (the representative action; some events do
# more — e.g. UserPromptSubmit also marks busy + injects skills, PostToolUse self-sends a checkpoint).
# 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.
# D1 (v0.9.0): the actual handler is now `claude-spt hook <event>` (the binary), not a per-event plugin
# .sh — the plugin's static-forever hooks.json routes every event through dispatch.sh → the binary
# (resolved via [strings].hook_cmd above). These declarations stay one-for-one with that wiring.


proceed to build v0.9.0. see it through to release publish


# ── [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.

# ── [hooks.<event>] ──────────────────────────────────────────────────────────────────────────
# CC hook event → the opaque `api …` command it fires (the representative action; some events do
# more — e.g. UserPromptSubmit also marks busy + injects skills, PostToolUse self-sends a checkpoint).
# 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.
# D1 (v0.9.0): the actual handler is now `claude-spt hook <event>` (the binary), not a per-event plugin
# .sh — the plugin's static-forever hooks.json routes every event through dispatch.sh → the binary
# (resolved via [strings].hook_cmd above). These declarations stay one-for-one with that wiring.

-# 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

+# CC hook event → the opaque `api …` command it fires (the representative action; some events do

+# more — e.g. UserPromptSubmit also marks busy + injects skills, PostToolUse self-sends a checkpoint).

+# This table is the registry-side declaration (ADR-0002, F-001 resolution). `reads` = the CC hook-stdin

+# D1 (v0.9.0): the actual handler is now `claude-spt hook <event>` (the binary), not a per-event plugin

+# .sh — the plugin's static-forever hooks.json routes every event through dispatch.sh → the binary

+# (resolved via [strings].hook_cmd above). These declarations stay one-for-one with that wiring.

# Seed the endpoint — ADAPTER-AGNOSTIC since spt-core v0.9.0 (PREP-4): NO --adapter. The owning


Manifest updated. Now run traceable-reqs to see exactly what evidence the deletions dropped.


Bash

cd /c/Users/decid/Documents/projects/spt-claude-code
command -v traceable-reqs && traceable-reqs check 2>&1 | head -60 || echo "NOT ON PATH"

Run traceable-reqs check


/c/Users/decid/AppData/Local/Programs/traceable-reqs/traceable-reqs
44 requirements: 43 complete, 1 incomplete, 2 findings
  [OK] REQ-CC-LAUNCHER-BIND  required: [int]  stages: -doc -impl -unit +int
       spt-hosted bringup self-binds E2E: `spt endpoint run claude-spt <id>` spawns the [session.self] CC session which — via [env.SPT_ENDPOINT_ID]={id} (spt-core fills it, v0.11.0+ REQ-HAZARD-ENV-SUBST/F-013) + the SessionStart `bind` branch — registers a BOUND perch on disk reachable by `spt send` (live PTY inject, SENT). The M12 cc-launcher reachability, the gap that hid the wall-b zero-perch.
  [OK] REQ-CCS-PROFILES  required: [doc, impl, unit, int]  stages: +doc +impl +unit +int
       The adapter ships a ccs profile template (claude-spt:ccs) — LOCKED-ADD overlay that retargets the spawn command template through `ccs` (a drop-in for the `claude` binary) and honors ccs's relocated CLAUDE_CONFIG_DIR transcript root in the digest extractor; templates only (user supplies their own ccs config/keys)
  [OK] REQ-CI-ACCEPTANCE  required: [doc, impl, int]  stages: +doc +impl -unit +int
       Acceptance = scripted orchestration spawning real claude/headless sessions as the system-under-test, asserting spt-state/digest output (LLM is SUT, never the runner)
  [OK] REQ-CI-BUS  required: [doc, impl]  stages: +doc +impl -unit -int
       CI progress/results report over spt messaging (dogfood the product as its own CI nervous system)
  [OK] REQ-CI-GATES  required: [doc, impl, unit]  stages: +doc +impl +unit -int
       Deterministic gates (shell-syntax + unit tests + traceable-reqs check + manifest-schema + docs-drift), each a binary pass/fail, runnable as one command
  [OK] REQ-CI-MANUAL  required: [doc, impl]  stages: +doc +impl -unit -int
       A manual 'run gates' command always exists — the same gate scripts runnable by hand on any fleet host
  [OK] REQ-CI-OWL-DISCOVERY  required: [doc, impl, unit]  stages: +doc +impl +unit -int
       The spt messaging (bus) binary is located robustly at run time (per-version plugins path / PATH / configured) — never a hard-coded versioned path
  [OK] REQ-CI-TRIGGER  required: [doc, impl]  stages: +doc +impl -unit -int
       A git push hook fires the gates: pings a fleet runner-agent over spt rather than polling
  [OK] REQ-DIST-ADAPTER-PEROS  required: [doc, impl, unit]  stages: +doc +impl +unit -int
       The adapter ships ONE host-agnostic MULTI-PLATFORM `adapter.spt` (ADR-0024 W1, spt-core >= 0.13.2): a single fat archive bundles every recognized target-triple's tool binaries under a `<triple>/` dir beside the SHARED manifest.toml + strings/ at the archive root; install classifies the triple dirs and flattens THIS node's triple into the install dir, so a bare-name command token resolves (REQ-INSTALL-11). `spt adapter add --release` (default asset adapter.spt) + the `[update] gh_release` avenue auto-resolve the host's binaries from the one asset — RETIRING the F-014 per-OS stopgap (the old single-OS adapter.spt-by-default that broke on a foreign host). v1 triples = x86_64-pc-windows-msvc + x86_64-unknown-linux-gnu (the only two spt-core recognizes in a fat archive; platforms beyond these ship a separate single-triple asset via `--asset`).
  [OK] REQ-DIST-ADAPTER-RELEASE  required: [doc, impl, unit, int]  stages: +doc +impl +unit +int
       The adapter ships to end users as an `adapter.spt` GitHub release asset (tar ROOT = manifest.toml + strings/ + the tool binaries), acquired via `spt adapter add --release SaberMage/claude-spt` — distribution straight from the monorepo, no dedicated repo (doyle's --release acquisition source, spt v0.7.3/counter-15)
  [OK] REQ-DIST-BINARY-CONSOLIDATE  required: [impl, unit, int]  stages: -doc +impl +unit +int
       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.
  [OK] REQ-DIST-BOOTSTRAP-INSTALL  required: [doc, impl]  stages: +doc +impl -unit -int
       SessionStart bootstrap installs spt-core when absent (invisible-installer pattern)
  [OK] REQ-DIST-CHECKPOINT-COMMUNE  required: [doc, impl, unit, int]  stages: +doc +impl +unit +int
       Agent-driven checkpoint: a live agent flags a commune with the literal trigger !!checkpoint!! (one = default wake; a pair brackets a custom wake directive); a PostToolUse hook detects the trigger in the Write tool_input.content for .claude/<id>-commune.md, sets the perch idle (api state idle), and self-sends a reserved wire-sentinel (spt send --from <id> <id>); the message loops back through the endpoint's own translation binary, which fires the clear+wake macro (ctrl+s · 50ms · /clear · enter · 500ms · wake-text · enter · commit). spt-hosted-only. INVARIANT: the commune is authored INLINE pre-clear (ADR-0004, Shape 1), never via a post-clear resume-Self refresh.
  [OK] REQ-DIST-DIGEST-EXTRACTOR  required: [impl, unit, int]  stages: -doc +impl +unit +int
       The claude-spt [digest] extractor (claude-spt-digest) maps Claude Code's JSONL transcript to the published digest-record NDJSON contract (role∈{input,agent,tool}; text/tool/ts), emitting RAW records for spt-core's renderer
  [--] REQ-DIST-HOOK-BINARY  required: [doc, impl, unit]  stages: -doc +impl -unit -int
       Hook LOGIC lives in the consolidated `claude-spt hook <event>` binary (so it rides `spt adapter update`, not a cplugs republish); the plugin ships only a static-forever hooks.json + dispatch wrapper that resolves the binary via the lazily-substituted `[strings].hook_cmd = {adapter_dir}/claude-spt hook` (ADR-0006 ask #1 → resolve-not-execute; spt-core v0.16.0 {adapter_dir} + lazy [strings] subst). D1 / v0.9.0.
  [OK] REQ-DIST-HOOKS-API  required: [doc, impl, unit, int]  stages: +doc +impl +unit +int
       hooks.json delegates to `spt api` rather than carrying adapter logic
  [OK] REQ-DIST-IDLE-MULTILINE  required: [impl, unit, int]  stages: -doc +impl +unit +int
       The idle-translation binary renders each inbound <EVENT> envelope across MULTIPLE LINES in CC's input box for visual distinction — an embedded raw newline byte in the {text} payload after the opening <EVENT ...> tag and before the closing </EVENT> (spt-core writes {text} byte-verbatim including newlines, doyle-confirmed; CC soft-newlines on a bare newline, empirically gated). Cyan color is impossible (SGR bytes eaten by CC input handling; user-turns theme-fixed).
  [OK] REQ-DIST-IDLE-TRANSLATE  required: [impl, unit, int]  stages: -doc +impl +unit +int
       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.
  [OK] REQ-DIST-INSTALL-UX  required: [doc]  stages: +doc -impl -unit -int
       README ships platform-specific install chains (cmd / PowerShell / bash) — check-for/install spt-core (call the spt-releases per-platform install script; claude-spt may be a user's first spt-core exposure) then `spt adapter add --release SaberMage/claude-spt` — plus a copy-paste agent prompt (the casual-user skin) running the chain in one sequenced Bash call, symmetric with the update lever. U5.
  [OK] REQ-DIST-MANIFEST-SCHEMA  required: [doc, impl, unit, int]  stages: +doc +impl +unit +int
       CC adapter manifest validates against spt-core's published manifest.schema.json (from spt-releases)
  [OK] REQ-DIST-NAME-UNIFY  required: [doc, impl, unit]  stages: +doc +impl +unit -int
       GitHub repo renamed spt-claude-code -> claude-spt; [update].repo, README, CI, package scripts, every SaberMage/claude-spt reference + install-dir test assumptions (_github/SaberMage-claude-spt) updated. Adapter name claude-spt UNCHANGED; plugin sptc UNCHANGED this milestone (succession = D4, owl-gated). U3.
  [OK] REQ-DIST-PLUGIN-SKELETON  required: [doc, impl, unit]  stages: +doc +impl +unit -int
       Marketplace artifact on cplugs is a thin skeleton: namespaced /sptc:* skill stubs + hooks.json + plugin.json, no embedded logic
  [OK] REQ-DIST-PRETOOL-POLL  required: [impl, unit]  stages: -doc +impl +unit -int
       claude-spt wires a PreToolUse hook firing `api poll` so a live agent receives messages MID-TURN (legacy-parity reachability; today claude-spt drains only on UserPromptSubmit = between turns). F-021.
  [OK] REQ-DIST-RC-STARTUP  required: [unit]  stages: -doc -impl +unit -int
       {id} threads into BOTH the session display name (-n {id}, --name) AND the RC channel (--remote-control {id}) on BOTH [session.self] (fresh bringup) and [session.resume], so a fresh and a resumed endpoint are identified + controlled identically. Two real CC flags (verified via claude --help). STRETCH (doyle ask #4, operator-raised): upgrade to {id}@{node} once a {node} fill key lands. U6.
  [OK] REQ-DIST-RESUME-CONTEXT  required: [impl, unit, int]  stages: -doc +impl +unit +int
       claude-spt SessionStart pulls the live agent resume context via the v0.15.0 `spt api psyche-download <self-id> [--session-id <sid>]` verb and injects stdout as additionalContext (skip on NO-CONTEXT) — closing a pre-existing parity gap (claude-spt rehydrates NO durable context today; F-020). One call returns durable role/live/project tiers + the freshest not-yet-synthesized <pending-commune>/<pending-signoff> (trigger stripped core-side); it is also the checkpoint re-seed fast-path.
  [OK] REQ-DIST-SESSION-RESUME  required: [unit]  stages: -doc -impl +unit -int
       The adapter manifest declares [session.resume] — Claude Code's NATIVE-RESUME verb (`claude -r {session_id} --remote-control {id} --dangerously-skip-permissions`, keys=["session_id","id"]) — so a spt-hosted resume (`spt endpoint run --resume` / picker Resume-from-history) reloads the REAL transcript by id instead of silently re-running [session.self] = a fresh blank session. {session_id} reloads the transcript (-r); {id} threads the endpoint as the remote-control session name (the RC channel native-resume drives, the same one `spt rc` / the idle-translation-binary use); skip-permissions for the non-interactive broker PTY (REQ-HAZARD-PSYCHE-PERMS-DEADLOCK). The PTY lands in the resumed session's recorded project cwd; CC resolves the transcript by {session_id}+cwd.
  [OK] REQ-DIST-SESSIONSTART-BRIEF  required: [doc, impl, unit]  stages: +doc +impl +unit -int
       SessionStart injects agent-facing briefs as additionalContext: an identity brief (who + perch-live/don't-re-arm + send/reply/roster) for perched sessions (bind+boundary), and a peer-gated ring brief (spt ring + roster) for no-perch seed sessions; all prose is adapter-string-backed ([strings.briefs]), the hook only composes + {id}-substitutes, never authors prose; subagent (agent_type) sessions are skipped
  [OK] REQ-DIST-SHORTCUT-BASENAME  required: [doc, impl, unit]  stages: +doc +impl +unit -int
       The adapter manifest brands the `spt endpoint run` launcher shortcut as `cc-<id>` via adapter.shortcut_basename = "cc" (the M12 cc launcher; decoupled from the sptc plugin name, ADR-0001)
  [OK] REQ-DIST-SKELETON-THIN  required: [doc, impl, unit]  stages: +doc +impl +unit -int
       commune/send/signoff skill bodies move OUT of the plugin SKILL.md (stubs: frontmatter + 'live agents only — /sptc:live first') and INTO adapter strings: a new perched SessionStart brief string (commune incl. --checkpoint, signoff) + the existing /sptc:live UPS body for the go-live moment, so reactive-skill prose rides `spt adapter update`. U4.
  [OK] REQ-DIST-UPDATE-MESSAGE  required: [impl, unit]  stages: -doc +impl +unit -int


43/44 complete; migrated REQs all still OK (hook.rs tags held). REQ-DIST-HOOK-BINARY needs doc + unit. Let me see the 2 findings + remaining output.


cd /c/Users/decid/Documents/projects/spt-claude-code
traceable-reqs check 2>&1 | tail -25

Tail of traceable-reqs check (findings)


       Hook wrappers read the CC payload from stdin, never from a /-leading positional argv (Git-Bash/MSYS path-mangles those on Windows)
  [OK] REQ-HAZARD-PERCH-COLLISION  required: [unit]  stages: +doc +impl +unit +int
       The acceptance harness spawns every nested claude SUT under a DISPOSABLE identity (SPT_AGENT_ID=sptc-ci-<n>), never a live agent's perch id — a colliding id tears down the live agent's perch + poll stream (name-keyed, last-establish-wins)
  [OK] REQ-HAZARD-PSYCHE-PERMS-DEADLOCK  required: [doc, unit]  stages: +doc -impl +unit -int
       Every CC process spt-core spawns NON-INTERACTIVELY carries --dangerously-skip-permissions: each claude-spt-psyche turn (seed + every pulse, detached/null-stdin), both [session.self] bringup commands (base claude + ccs profile, broker PTY), AND [session.resume] (native-resume into the broker PTY). An interactive permission prompt with no operator deadlocks the turn (silently, for the detached Psyche). The Psyche additionally runs inside a Read/Edit/Write tool sandbox so auto-approve is bounded.
  [OK] REQ-PARITY-AUDIT  required: [doc]  stages: +doc -impl -unit -int
       The adapter's operative skill/profile surface is reconciled against the SCOPE.md LOCKED parity-trim (KEEP/ADD/DROP/TRANSFORM): every divergence is either resolved or logged as an accepted gap in docs/PARITY.md — the 'proven parity' record the sptc->spt succession flip gates on
  [OK] REQ-SETUP-ACTIVATE  required: [doc, int]  stages: +doc -impl -unit +int
       /sptc:setup, after confirming the spt binary is present, ACTIVATES the claude-spt adapter (`spt adapter add` — the manifest file in local dev; `--github <published-adapter-repo>` for end-users) so profiles/strings/hints/[digest] go live, then verifies (adapter no longer `deregistered`). Binary-present is NOT a no-op — this is the F-005 post-install activation bridge.
  [OK] REQ-SETUP-CCS  required: [doc]  stages: +doc -impl -unit -int
       /sptc:setup wires the ccs integration (SCOPE LOCKED setup #7): if `~/.ccs` is present, point the user at the shipped `claude-spt:ccs` profile (routes live/ready sessions through `ccs`, a drop-in for `claude`, via `--adapter claude-spt:ccs`) + sanity-check `ccs` on PATH; if absent, offer to install ccs with a one-sentence value prop (optional — base claude-spt is unaffected).
  [OK] REQ-SETUP-SUBNET  required: [doc]  stages: +doc -impl -unit -int
       /sptc:setup offers subnet onboarding (SCOPE LOCKED setup #3/#4): detect membership (`spt subnet status`); not-in-a-subnet -> offer create (`spt subnet create`, prints code/URI/QR) or join (`spt subnet join`); in-a-subnet -> show-code to invite / add-this-machine. Delegates the verb mechanics to /sptc:subnet and surfaces the OS-elevation requirement (create/join/show-code are seed-reveal/enroll-gated) with the per-context elevation paths; automated context-aware elevation is a deeper wave.
  [OK] REQ-SKILL-LIVE  required: [doc, impl, unit, int]  stages: +doc +impl +unit +int
       /sptc:live upgrades THIS CC session to a LiveAgent: base claude-spt is live-capable ([session.psyche_init] in the BASE manifest, Option A) so ready-vs-live is the COMMAND not a profile — bare `spt api listen <id>` stamps the perch state=live_agent and the daemon hosts the Psyche (poll stays ready, livehost.rs:282 gate); the claude-spt-psyche runner keeps the Psyche claude alive (one --continue turn per pulse, NOT one-shot `claude -p`); a resident Monitor relay is the single delivery pipe; zero --adapter (host_binaries resolution, spt-core v0.9.0+)
  [OK] REQ-SKILL-SUBNET  required: [doc, int]  stages: +doc -impl -unit +int
       The adapter ships subnet membership skill(s) (create/join/show-code) wrapping the published `spt subnet <verb>` surface — the LOCKED-ADD cross-subnet entrypoint (v1 topology is mandatory, SCOPE)
  [OK] REQ-SKILL-VERSION  required: []  stages: -doc -impl -unit -int
       /spt:version reports the spt-core-tracked manifest/binary version (the version-of-truth, not the marketplace skeleton version)
  [OK] REQ-UPS-INJECTION  required: [doc, impl, unit, int]  stages: +doc +impl +unit +int
       UserPromptSubmit hook detects /sptc:X and injects X's real instructions as additionalContext (SKILL.md files stay skeletons); must be empirically confirmed UPS fires on slash-commands

Findings:
  [must] missing_stage REQ-DIST-HOOK-BINARY stage=doc — Requirement is missing documentation evidence
  [must] missing_stage REQ-DIST-HOOK-BINARY stage=unit — Requirement is missing unit test evidence


Only findings are HOOK-BINARY's doc + unit. Add unit tag in hook.rs, write the new dispatch shell test, and tag ADR-0006 for doc.


Edit

    #[test]
    fn dispatch_unknown_event_is_noop() {

    // [unit->REQ-DIST-HOOK-BINARY]
    #[test]
    fn dispatch_routes_known_events_and_noops_unknown() {
        // Every CC event the static hooks.json wires routes to a handler; an event the binary does
        // not yet know is a silent no-op (forward-compat with a newer plugin wiring).
        let mut env = Recorder::new(|args| match args {
            ["whoami"] => Some("perri".into()),
            _ => Some(String::new()),
        });
        dispatch(&mut env, "Stop", &json!({"session_id":"s"}));
        assert!(env.call_lines().iter().any(|l| l.contains("state idle")), "Stop routed");
    }

    #[test]
    fn dispatch_unknown_event_is_noop() {


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

    // [unit->REQ-DIST-HOOK-BINARY]
    #[test]
    fn dispatch_routes_known_events_and_noops_unknown() {
        // Every CC event the static hooks.json wires routes to a handler; an event the binary does
        // not yet know is a silent no-op (forward-compat with a newer plugin wiring).
        let mut env = Recorder::new(|args| match args {
            ["whoami"] => Some("perri".into()),
            _ => Some(String::new()),
        });
        dispatch(&mut env, "Stop", &json!({"session_id":"s"}));
        assert!(env.call_lines().iter().any(|l| l.contains("state idle")), "Stop routed");
    }

    #[test]
    fn dispatch_unknown_event_is_noop() {

assert!(!out.contains("abcdefghij"), "body never inlined in the marker");

+    fn dispatch_routes_known_events_and_noops_unknown() {

+        // Every CC event the static hooks.json wires routes to a handler; an event the binary does

+        // not yet know is a silent no-op (forward-compat with a newer plugin wiring).

+        let mut env = Recorder::new(|args| match args {

+            ["whoami"] => Some("perri".into()),

+        dispatch(&mut env, "Stop", &json!({"session_id":"s"}));

+        assert!(env.call_lines().iter().any(|l| l.contains("state idle")), "Stop routed");

let mut env = Recorder::new(|_| Some("x".into()));

dispatch(&mut env, "FutureEvent", &json!({"session_id":"s"}));


Write the new `tests/hooks-dispatch.sh` (static-shape + U4 structural, carrying the unit tags hooks-parse.sh held), then delete hooks-parse.sh.


Write

#!/bin/sh
# Unit coverage for the STATIC-FOREVER plugin hook surface (D1). The hook LOGIC now lives in the
# `claude-spt` binary and is unit-tested there (tools/claude-spt/src/hook.rs cargo tests — the port of
# the old tests/hooks-parse.sh helper coverage). What remains in the PLUGIN is static-forever wiring:
# hooks.json routes every CC event to dispatch.sh, and dispatch.sh resolves+execs the binary. This
# test pins that wiring + the adapter-side declarations it depends on.
# [unit->REQ-DIST-HOOK-BINARY] [unit->REQ-DIST-HOOKS-API] [unit->REQ-DIST-SKELETON-THIN]
set -u
fail=0
check() { # check <label> <expected> <actual>
  if [ "$2" = "$3" ]; then echo "ok   $1"; else echo "FAIL $1: expected [$2] got [$3]"; fail=1; fi
}

ROOT="$(CDPATH= cd "$(dirname "$0")/.." && pwd)"
HK="$ROOT/plugin/sptc/hooks"
MAN="$ROOT/adapter/claude-spt.toml"
BR="$ROOT/adapter/strings/briefs"
SK="$ROOT/plugin/sptc/skills"
LV="$ROOT/adapter/strings/skills/live.md"

# --- hooks.json: every CC event routes to dispatch.sh with its own event token (static-forever) ---
for ev in SessionStart UserPromptSubmit PreToolUse Stop SessionEnd SubagentStart SubagentStop PostToolUse; do
  grep -q "\"$ev\"" "$HK/hooks.json" && r=yes || r=no
  check "hooks.json declares $ev" "yes" "$r"
  grep -q "dispatch.sh\\\\\" $ev" "$HK/hooks.json" && r=yes || r=no
  check "$ev routes to dispatch.sh $ev" "yes" "$r"
done
# No per-event logic .sh remain (the eight wrappers + _common.sh were folded into the binary).
n=$(ls "$HK"/*.sh 2>/dev/null | grep -Ec 'session-start|user-prompt-submit|pre-tool-use|stop|session-end|subagent-start|subagent-stop|post-tool-use|_common')
check "old per-hook wrappers removed" "0" "$n"
# PostToolUse keeps its Write matcher (the checkpoint detector scope).
grep -q '"matcher": "Write"' "$HK/hooks.json" && r=yes || r=no
check "PostToolUse keeps the Write matcher" "yes" "$r"

# --- dispatch.sh: static resolver shape ---
sh -n "$HK/dispatch.sh" && r=ok || r=bad
check "dispatch.sh is valid POSIX sh" "ok" "$r"
grep -q 'get-string claude-spt hook_cmd' "$HK/dispatch.sh" && r=yes || r=no
check "dispatch resolves the binary via [strings].hook_cmd" "yes" "$r"
grep -q 'SPTC_HOOK_BIN' "$HK/dispatch.sh" && r=yes || r=no
check "dispatch caches the resolved bin (SPTC_HOOK_BIN)" "yes" "$r"
grep -q 'bootstrap.sh' "$HK/dispatch.sh" && r=yes || r=no
check "dispatch runs the SessionStart bootstrap (invisible installer)" "yes" "$r"
# bootstrap only on SessionStart (the binary cannot exist before spt-core + the adapter install).
awk '/if \[ "\$event" = "SessionStart" \]/{s=1} /bootstrap.sh/{if(s)b=1} END{exit !b}' "$HK/dispatch.sh" && r=yes || r=no
check "bootstrap gated to SessionStart" "yes" "$r"
grep -q -- '--host-pid' "$HK/dispatch.sh" && r=yes || r=no
check "dispatch passes the seed pid (--host-pid)" "yes" "$r"
# No-op (exit 0) when the binary is unresolvable (adapter not registered yet / pre-/sptc:setup).
grep -q '\[ -z "\$bin" \] && exit 0' "$HK/dispatch.sh" && r=yes || r=no
check "dispatch no-ops when the adapter is not yet registered" "yes" "$r"

# --- manifest: the declarations the wiring depends on ---
grep -q 'hook_cmd = "{adapter_dir}/claude-spt hook"' "$MAN" && r=yes || r=no
check "manifest [strings].hook_cmd = {adapter_dir}/claude-spt hook" "yes" "$r"
grep -q '^\[hooks\.PreToolUse\]' "$MAN" && r=yes || r=no
check "manifest declares [hooks.PreToolUse]" "yes" "$r"
grep -q 'live-ops = { file' "$MAN" && r=yes || r=no
check "manifest registers briefs.live-ops" "yes" "$r"

# --- U4: reactive-skill thinning (prose rides adapter strings, SKILL.md are thin stubs) ---
grep -q 'commune' "$BR/live-ops.md" && grep -q '!!checkpoint!!' "$BR/live-ops.md" && grep -q 'endpoint shutdown' "$BR/live-ops.md" && r=yes || r=no
check "live-ops brief carries commune+checkpoint+signoff" "yes" "$r"
grep -q 'commune.md' "$LV" && grep -q '!!checkpoint!!' "$LV" && r=yes || r=no
check "go-live body inlines commune+checkpoint" "yes" "$r"
for s in commune signoff send; do
  grep -q 'thin skeleton' "$SK/$s/SKILL.md" && r=yes || r=no
  check "$s SKILL.md is a thin stub" "yes" "$r"
done
grep -q 'Normal commune' "$SK/commune/SKILL.md" && r=present || r=absent
check "commune SKILL.md full body removed" "absent" "$r"

[ "$fail" -eq 0 ] && { echo "ALL PASS"; exit 0; } || { echo "FAILURES"; exit 1; }


File created successfully at: C:\Users\decid\Documents\projects\spt-claude-code\tests\hooks-dispatch.sh (file state is current in your context — no need to Read it back)

#!/bin/sh
# Unit coverage for the STATIC-FOREVER plugin hook surface (D1). The hook LOGIC now lives in the
# `claude-spt` binary and is unit-tested there (tools/claude-spt/src/hook.rs cargo tests — the port of
# the old tests/hooks-parse.sh helper coverage). What remains in the PLUGIN is static-forever wiring:
# hooks.json routes every CC event to dispatch.sh, and dispatch.sh resolves+execs the binary. This
# test pins that wiring + the adapter-side declarations it depends on.
# [unit->REQ-DIST-HOOK-BINARY] [unit->REQ-DIST-HOOKS-API] [unit->REQ-DIST-SKELETON-THIN]
set -u
fail=0
check() { # check <label> <expected> <actual>
  if [ "$2" = "$3" ]; then echo "ok   $1"; else echo "FAIL $1: expected [$2] got [$3]"; fail=1; fi
}

ROOT="$(CDPATH= cd "$(dirname "$0")/.." && pwd)"
HK="$ROOT/plugin/sptc/hooks"
MAN="$ROOT/adapter/claude-spt.toml"
BR="$ROOT/adapter/strings/briefs"
SK="$ROOT/plugin/sptc/skills"
LV="$ROOT/adapter/strings/skills/live.md"

# --- hooks.json: every CC event routes to dispatch.sh with its own event token (static-forever) ---
for ev in SessionStart UserPromptSubmit PreToolUse Stop SessionEnd SubagentStart SubagentStop PostToolUse; do
  grep -q "\"$ev\"" "$HK/hooks.json" && r=yes || r=no
  check "hooks.json declares $ev" "yes" "$r"
  grep -q "dispatch.sh\\\\\" $ev" "$HK/hooks.json" && r=yes || r=no
  check "$ev routes to dispatch.sh $ev" "yes" "$r"
done
# No per-event logic .sh remain (the eight wrappers + _common.sh were folded into the binary).
n=$(ls "$HK"/*.sh 2>/dev/null | grep -Ec 'session-start|user-prompt-submit|pre-tool-use|stop|session-end|subagent-start|subagent-stop|post-tool-use|_common')
check "old per-hook wrappers removed" "0" "$n"
# PostToolUse keeps its Write matcher (the checkpoint detector scope).
grep -q '"matcher": "Write"' "$HK/hooks.json" && r=yes || r=no
check "PostToolUse keeps the Write matcher" "yes" "$r"

# --- dispatch.sh: static resolver shape ---
sh -n "$HK/dispatch.sh" && r=ok || r=bad
check "dispatch.sh is valid POSIX sh" "ok" "$r"
grep -q 'get-string claude-spt hook_cmd' "$HK/dispatch.sh" && r=yes || r=no
check "dispatch resolves the binary via [strings].hook_cmd" "yes" "$r"
grep -q 'SPTC_HOOK_BIN' "$HK/dispatch.sh" && r=yes || r=no
check "dispatch caches the resolved bin (SPTC_HOOK_BIN)" "yes" "$r"
grep -q 'bootstrap.sh' "$HK/dispatch.sh" && r=yes || r=no
check "dispatch runs the SessionStart bootstrap (invisible installer)" "yes" "$r"
# bootstrap only on SessionStart (the binary cannot exist before spt-core + the adapter install).
awk '/if \[ "\$event" = "SessionStart" \]/{s=1} /bootstrap.sh/{if(s)b=1} END{exit !b}' "$HK/dispatch.sh" && r=yes || r=no
check "bootstrap gated to SessionStart" "yes" "$r"
grep -q -- '--host-pid' "$HK/dispatch.sh" && r=yes || r=no
check "dispatch passes the seed pid (--host-pid)" "yes" "$r"
# No-op (exit 0) when the binary is unresolvable (adapter not registered yet / pre-/sptc:setup).
grep -q '\[ -z "\$bin" \] && exit 0' "$HK/dispatch.sh" && r=yes || r=no
check "dispatch no-ops when the adapter is not yet registered" "yes" "$r"

# --- manifest: the declarations the wiring depends on ---
grep -q 'hook_cmd = "{adapter_dir}/claude-spt hook"' "$MAN" && r=yes || r=no
check "manifest [strings].hook_cmd = {adapter_dir}/claude-spt hook" "yes" "$r"
grep -q '^\[hooks\.PreToolUse\]' "$MAN" && r=yes || r=no
check "manifest declares [hooks.PreToolUse]" "yes" "$r"
grep -q 'live-ops = { file' "$MAN" && r=yes || r=no
check "manifest registers briefs.live-ops" "yes" "$r"

# --- U4: reactive-skill thinning (prose rides adapter strings, SKILL.md are thin stubs) ---
grep -q 'commune' "$BR/live-ops.md" && grep -q '!!checkpoint!!' "$BR/live-ops.md" && grep -q 'endpoint shutdown' "$BR/live-ops.md" && r=yes || r=no
check "live-ops brief carries commune+checkpoint+signoff" "yes" "$r"
grep -q 'commune.md' "$LV" && grep -q '!!checkpoint!!' "$LV" && r=yes || r=no
check "go-live body inlines commune+checkpoint" "yes" "$r"
for s in commune signoff send; do
  grep -q 'thin skeleton' "$SK/$s/SKILL.md" && r=yes || r=no
  check "$s SKILL.md is a thin stub" "yes" "$r"
done
grep -q 'Normal commune' "$SK/commune/SKILL.md" && r=present || r=absent
check "commune SKILL.md full body removed" "absent" "$r"

[ "$fail" -eq 0 ] && { echo "ALL PASS"; exit 0; } || { echo "FAILURES"; exit 1; }


Bash

cd /c/Users/decid/Documents/projects/spt-claude-code
rm -f tests/hooks-parse.sh
echo "=== run hooks-dispatch.sh ==="
sh tests/hooks-dispatch.sh

Delete hooks-parse.sh + run new dispatch test


=== run hooks-dispatch.sh ===
ok   hooks.json declares SessionStart
ok   SessionStart routes to dispatch.sh SessionStart
ok   hooks.json declares UserPromptSubmit
ok   UserPromptSubmit routes to dispatch.sh UserPromptSubmit
ok   hooks.json declares PreToolUse
ok   PreToolUse routes to dispatch.sh PreToolUse
ok   hooks.json declares Stop
ok   Stop routes to dispatch.sh Stop
ok   hooks.json declares SessionEnd
ok   SessionEnd routes to dispatch.sh SessionEnd
ok   hooks.json declares SubagentStart
ok   SubagentStart routes to dispatch.sh SubagentStart
ok   hooks.json declares SubagentStop
ok   SubagentStop routes to dispatch.sh SubagentStop
ok   hooks.json declares PostToolUse
ok   PostToolUse routes to dispatch.sh PostToolUse
ok   old per-hook wrappers removed
ok   PostToolUse keeps the Write matcher
ok   dispatch.sh is valid POSIX sh
ok   dispatch resolves the binary via [strings].hook_cmd
ok   dispatch caches the resolved bin (SPTC_HOOK_BIN)
ok   dispatch runs the SessionStart bootstrap (invisible installer)
ok   bootstrap gated to SessionStart
ok   dispatch passes the seed pid (--host-pid)
ok   dispatch no-ops when the adapter is not yet registered
ok   manifest [strings].hook_cmd = {adapter_dir}/claude-spt hook
ok   manifest declares [hooks.PreToolUse]
ok   manifest registers briefs.live-ops
ok   live-ops brief carries commune+checkpoint+signoff
ok   go-live body inlines commune+checkpoint
ok   commune SKILL.md is a thin stub
ok   signoff SKILL.md is a thin stub
ok   send SKILL.md is a thin stub
ok   commune SKILL.md full body removed
ALL PASS


Now add the doc tag for REQ-DIST-HOOK-BINARY to ADR-0006 with a "D1 realized" note.


1	# One-command-ish update + a single consolidated adapter binary
2
3	## Status
4
5	accepted (2026-06-24) — extends ADR-0001 (distribution splits by volatility). Some pieces depend on spt-core capabilities not yet shipped (the "doyle asks" below); those parts are decided in shape but gated on delivery.
6
7	## Context
8

# One-command-ish update + a single consolidated adapter binary

## Status

accepted (2026-06-24) — extends ADR-0001 (distribution splits by volatility). Some pieces depend on spt-core capabilities not yet shipped (the "doyle asks" below); those parts are decided in shape but gated on delivery.

## Context


Edit

## Status

accepted (2026-06-24) — extends ADR-0001 (distribution splits by volatility). Some pieces depend on spt-core capabilities not yet shipped (the "doyle asks" below); those parts are decided in shape but gated on delivery.

## Status

accepted (2026-06-24) — extends ADR-0001 (distribution splits by volatility). Some pieces depend on spt-core capabilities not yet shipped (the "doyle asks" below); those parts are decided in shape but gated on delivery.

**D1 realized (v0.9.0, 2026-06-28).** Ask #1 ("fold in `claude-spt hook <event>` if generic hook
dispatch lands") shipped — but NOT as the proposed `spt api run-hook`. doyle RESOLVED it as
**resolve-not-execute** (spt-core stays a pure resolver): two v0.16.0 primitives — `{adapter_dir}` /
`{adapter_name}` substitution keys + **lazy `[strings]` substitution at `get-string` read time** —
let the plugin resolve the binary itself. The hook LOGIC (all eight wrappers + `_common.sh`) now lives
in `claude-spt hook <event>` and rides `spt adapter update`; the plugin ships only a STATIC-FOREVER
`hooks.json` + a thin `dispatch.sh` that `get-string`s `[strings].hook_cmd = "{adapter_dir}/claude-spt
hook"` once per session and execs the binary. This is the realized form of the "Later: `claude-spt
hook <event>` if generic hook dispatch (ask #1) lands" line in Binary consolidation below.
<!-- [doc->REQ-DIST-HOOK-BINARY] -->
See `UPDATE-NAMING-DOYLE-ASKS.md` Ask 1 (the full resolve-not-execute resolution) and `V09-PLAN.md`.


The file C:\Users\decid\Documents\projects\spt-claude-code\docs\adr\0006-one-command-update-and-consolidated-binary.md has been updated successfully. (file state is current in your context — no need to Read it back)

## Status

accepted (2026-06-24) — extends ADR-0001 (distribution splits by volatility). Some pieces depend on spt-core capabilities not yet shipped (the "doyle asks" below); those parts are decided in shape but gated on delivery.

**D1 realized (v0.9.0, 2026-06-28).** Ask #1 ("fold in `claude-spt hook <event>` if generic hook
dispatch lands") shipped — but NOT as the proposed `spt api run-hook`. doyle RESOLVED it as
**resolve-not-execute** (spt-core stays a pure resolver): two v0.16.0 primitives — `{adapter_dir}` /
`{adapter_name}` substitution keys + **lazy `[strings]` substitution at `get-string` read time** —
let the plugin resolve the binary itself. The hook LOGIC (all eight wrappers + `_common.sh`) now lives
in `claude-spt hook <event>` and rides `spt adapter update`; the plugin ships only a STATIC-FOREVER
`hooks.json` + a thin `dispatch.sh` that `get-string`s `[strings].hook_cmd = "{adapter_dir}/claude-spt
hook"` once per session and execs the binary. This is the realized form of the "Later: `claude-spt
hook <event>` if generic hook dispatch (ask #1) lands" line in Binary consolidation below.
<!-- [doc->REQ-DIST-HOOK-BINARY] -->
See `UPDATE-NAMING-DOYLE-ASKS.md` Ask 1 (the full resolve-not-execute resolution) and `V09-PLAN.md`.

# One-command-ish update + a single consolidated adapter binary

## Status

accepted (2026-06-24) — extends ADR-0001 (distribution splits by volatility). Some pieces depend on spt-core capabilities not yet shipped (the "doyle asks" below); those parts are decided in shape but gated on delivery.

## Context

ADR-0001 split distribution into a low-churn **cplugs skeleton** (hooks, skill skeletons, `plugin.json`, bootstrap — updated by `claude plugin update`) and an spt-conducted **adapter** (manifest + binaries + strings — updated by `spt adapter update`). The operator wants the whole thing kept current with effectively **one lever**, and wants install and update to feel **harmonious** (no hunting for the update command after install) and to work from the **CLI** (not only inside an endpoint session).

Two facts bound the design:

1. **Hook *logic* must live in the CC plugin dir** (CC loads hooks from there), outside `spt adapter update`'s reach. So "one command updates everything" is unreachable purely inside this repo — it needs spt-core capabilities → findings, not workarounds (public-surface-only).
2. **Raw skills/commands can't reliably replace the plugin.** Reopened and re-rejected: user-scope skills are flat (no namespace), and CC slash-command subdirectory namespacing is broken ([anthropics/claude-code#2422] — subdirs show in the description but don't namespace the invocation). The plugin is the only reliable `/spt:*` + the only clean hooks bundle. The eloquent thing to borrow from suite installers (e.g. gsd-core) is their **installer/onboarding UX**, not going raw.

## Decision

**Keep the plugin** (reaffirms ADR-0001). Make the update path one-lever-shaped and consolidate the adapter's binaries.

### Update mechanics

- **`avenue = "gh_release"`** pulls the adapter `.spt` (manifest + binaries + strings — all the high-churn). Ships against the published surface today.
- **`[update].message`** carries the residual manual step. This is a real, docs-confirmed field: a markdown-rendered notice `spt adapter update` prints to stdout **only when a new version is actually applied** (never on a no-op), no `{key}` substitution. It tells the user to run **`/reload-plugins`** — which is a **Claude Code TUI action and cannot be automated** — and mentions the `spt` CLI (alias for `spt endpoint run`) as the more powerful way to create an endpoint, alongside `/spt:live`.
- **"Install is the first update"** is docs-confirmed, so `spt adapter add` conducts the same `[update]` flow — one acquisition bootstraps the adapter.
- **Symmetric levers:** install = `spt adapter add --release SaberMage/claude-spt`, update = `spt adapter update claude-spt` (the verbs mirror). A README **copy-paste agent prompt** is an optional skin over the install command (the casual-user onboarding), with **platform-specific chains** (cmd / PowerShell / bash) that check-for / install spt-core (claude-spt may be a user's first exposure to spt-core) then `adapter add`.

### Binary consolidation

Collapse the adapter's executables into **one `claude-spt <subcommand>`** binary (name per ADR-0005), **partially now, fully later**:

- **Now** (no spt-core dependency — these seams take a command string): `claude-spt digest` (`[digest].extractor`), `claude-spt psyche` (`[session.psyche_init]`), `claude-spt post-update` (the delegated update step). Drops the fat `.spt` from 3 binaries/triple toward 1, dedupes deps, and retires the odd-one-out `cc-spt-idle-translate` name.
- **Later** (gated on doyle ask #3): fold in `claude-spt translate` once `[message-idle-translation-binary]` accepts a command/subcommand rather than a bare `path`; and `claude-spt hook <event>` if generic hook dispatch (ask #1) lands.

The **post-update step is cross-platform** because it is a compiled subcommand of this binary, **not** a `.sh` (Windows has no bash; a stray script would also stress spt-core's archive handling). It runs `claude plugin add|update <plugin>` + prints the notice; it cannot run `/reload-plugins` (TUI).

### The spt-core asks (findings to doyle — decided in shape, gated on delivery)

1. **Generic hook dispatch** — `spt api run-hook <adapter> <event>`: spt-core executes the adapter's handler for a hook event (from the manifest), so the plugin's `hooks.json` can pre-wire all events to one generic stub and hook *logic* rides `spt adapter update`. Eliminates the plugin-channel churn that adding this wave's PostToolUse hook forced.
2. **Composite `[update]`** — `gh_release` (pull the `.spt`) **plus** a delegated post-step in the same `spt adapter update`, so the plugin update is automated (the only manual residual becomes `/reload-plugins`). Two sub-requirements: the post-step must run **unconditionally** (the plugin can change when the adapter version did not), and its return value must be able to flag **"changed"** so `spt adapter update` still prints `[update].message` even on an adapter no-op.
3. **Translation seam takes a command** — `[message-idle-translation-binary]` accept a command/subcommand (or a default-on-no-args convention) instead of a bare `path`, so `translate` can fold into the consolidated binary.

## Consequences

- The thin-skeleton goal advances even before the asks land: skill bodies already ride strings; the **reactive skills** (`commune`/`send`/`signoff`, full-fat in the plugin because they are invoked without a typed slash-command) can be thinned to stubs by moving their bodies into the perched SessionStart brief (adapter strings) plus the `/spt:live` UPS body — with a known **delivery-timing wrinkle** (the perched brief fires on bind|boundary, not the instant a seed session goes live), tracked as a plan item, not blocking.
- Until asks #1/#3 land, hook logic and the translate binary stay on their current channels; the design degrades gracefully (the `message` field bridges the manual steps).
- One artifact per OS simplifies packaging but couples all seams' build/release — acceptable, they already ship in one `.spt`.

+**D1 realized (v0.9.0, 2026-06-28).** Ask #1 ("fold in `claude-spt hook <event>` if generic hook

+dispatch lands") shipped — but NOT as the proposed `spt api run-hook`. doyle RESOLVED it as

+**resolve-not-execute** (spt-core stays a pure resolver): two v0.16.0 primitives — `{adapter_dir}` /

+`{adapter_name}` substitution keys + **lazy `[strings]` substitution at `get-string` read time** —

+let the plugin resolve the binary itself. The hook LOGIC (all eight wrappers + `_common.sh`) now lives

+in `claude-spt hook <event>` and rides `spt adapter update`; the plugin ships only a STATIC-FOREVER

+`hooks.json` + a thin `dispatch.sh` that `get-string`s `[strings].hook_cmd = "{adapter_dir}/claude-spt

+hook"` once per session and execs the binary. This is the realized form of the "Later: `claude-spt

+hook <event>` if generic hook dispatch (ask #1) lands" line in Binary consolidation below.

+See `UPDATE-NAMING-DOYLE-ASKS.md` Ask 1 (the full resolve-not-execute resolution) and `V09-PLAN.md`.


Now fix the two int tests that sourced the deleted `_common.sh` (poll-int.sh + registration-int.sh) to use the binary, then re-run the full check.


1	#!/bin/sh
2	# Integration proof for the claude-spt adapter manifest against a REAL spt-core (>= v0.7.0): the
3	# 2nd validation layer beyond JSON Schema — spt-core's `spt adapter add` cross-field registration —
4	# accepts the manifest, the shipped profile resolves, [strings] read through the registry, and the
5	# profile overlay is observable (base vs :ccs differ). This is the v1 acceptance proof for the
6	# adapter manifest (LLM never in the loop; pure CLI assertions). [int->REQ-DIST-MANIFEST-SCHEMA]
7	#
8	# Mutates the node-local adapter registry (add + soft-remove), so it is gated behind SPTC_ACCEPTANCE=1
9	# and a present spt>=0.7.0 — never runs in the default `tests/*.sh` unit sweep. Idempotent: removes
10	# what it adds. Run: SPTC_ACCEPTANCE=1 sh ci/manifest/registration-int.sh   (exit 0 = pass).
11	set -u
12	ROOT=$(CDPATH= cd "$(dirname "$0")/../.." && pwd)
13	MANIFEST="$ROOT/adapter/claude-spt.toml"
14
15	if [ "${SPTC_ACCEPTANCE:-0}" != "1" ]; then echo "SKIP: set SPTC_ACCEPTANCE=1 to run (mutates the adapter registry)"; exit 0; fi
16	command -v spt >/dev/null 2>&1 || { echo "SKIP: no spt on PATH"; exit 0; }
17	ver=$(spt --version 2>/dev/null | awk '{print $NF}')
18	case "$ver" in
19	  0.6.*|0.5.*|0.4.*|0.3.*|0.2.*|0.1.*|0.0.*) echo "SKIP: spt $ver < 0.7.0 (shortcut_basename + adapter add are v0.7.0)"; exit 0 ;;
20	esac
21
22	fail=0
23	ok()  { echo "ok   $1"; }
24	bad() { echo "FAIL $1"; fail=1; }
25	# Clean up registry residue however we exit.
26	trap 'spt adapter remove claude-spt >/dev/null 2>&1 || true' EXIT INT TERM
27
28	# 1. Registration cross-field validation accepts the manifest.
29	out=$(spt adapter add "$MANIFEST" 2>&1)
30	case "$out" in *registered*) ok "spt adapter add: registered" ;; *) bad "adapter add rejected: $out" ;; esac
31
32	# 2. Listed, with the shipped profile resolved as a composite option.
33	list=$(spt adapter list 2>&1)
34	case "$list" in *claude-spt*) ok "listed: claude-spt active" ;; *) bad "claude-spt not listed" ;; esac
35	# NOTE: there is no `:live` profile (Option A, PREP-4) — base claude-spt is live-capable

#!/bin/sh
# Integration proof for the claude-spt adapter manifest against a REAL spt-core (>= v0.7.0): the
# 2nd validation layer beyond JSON Schema — spt-core's `spt adapter add` cross-field registration —
# accepts the manifest, the shipped profile resolves, [strings] read through the registry, and the
# profile overlay is observable (base vs :ccs differ). This is the v1 acceptance proof for the
# adapter manifest (LLM never in the loop; pure CLI assertions). [int->REQ-DIST-MANIFEST-SCHEMA]
#
# Mutates the node-local adapter registry (add + soft-remove), so it is gated behind SPTC_ACCEPTANCE=1
# and a present spt>=0.7.0 — never runs in the default `tests/*.sh` unit sweep. Idempotent: removes
# what it adds. Run: SPTC_ACCEPTANCE=1 sh ci/manifest/registration-int.sh   (exit 0 = pass).
set -u
ROOT=$(CDPATH= cd "$(dirname "$0")/../.." && pwd)
MANIFEST="$ROOT/adapter/claude-spt.toml"

if [ "${SPTC_ACCEPTANCE:-0}" != "1" ]; then echo "SKIP: set SPTC_ACCEPTANCE=1 to run (mutates the adapter registry)"; exit 0; fi
command -v spt >/dev/null 2>&1 || { echo "SKIP: no spt on PATH"; exit 0; }
ver=$(spt --version 2>/dev/null | awk '{print $NF}')
case "$ver" in
  0.6.*|0.5.*|0.4.*|0.3.*|0.2.*|0.1.*|0.0.*) echo "SKIP: spt $ver < 0.7.0 (shortcut_basename + adapter add are v0.7.0)"; exit 0 ;;
esac

fail=0
ok()  { echo "ok   $1"; }
bad() { echo "FAIL $1"; fail=1; }
# Clean up registry residue however we exit.
trap 'spt adapter remove claude-spt >/dev/null 2>&1 || true' EXIT INT TERM

# 1. Registration cross-field validation accepts the manifest.
out=$(spt adapter add "$MANIFEST" 2>&1)
case "$out" in *registered*) ok "spt adapter add: registered" ;; *) bad "adapter add rejected: $out" ;; esac

# 2. Listed, with the shipped profile resolved as a composite option.
list=$(spt adapter list 2>&1)
case "$list" in *claude-spt*) ok "listed: claude-spt active" ;; *) bad "claude-spt not listed" ;; esac
# NOTE: there is no `:live` profile (Option A, PREP-4) — base claude-spt is live-capable


# 4c. UPS skill-injection end-to-end: the hook helper resolves a /sptc:<skill> prompt to the wrapped
#     operative body via get-string (REQ-UPS-INJECTION impl, on the registered adapter).
( . "$ROOT/plugin/sptc/hooks/_common.sh"
  inj=$(sptc_inject_skill "$(sptc_skill_key '/sptc:ready listen up')")
  case "$inj" in
    '<sptc_skill name="ready">'*'# /sptc:ready'*'</sptc_skill>'*) exit 0 ;;
    *) printf 'inject got: %.60s\n' "$inj"; exit 1 ;;
  esac ) && ok "UPS skill-injection: /sptc:ready -> wrapped body" || bad "skill-injection did not emit wrapped body"

# 4c. UPS skill-injection end-to-end via the BINARY (D1): `claude-spt hook UserPromptSubmit` resolves a
#     /sptc:<skill> prompt to the wrapped operative body via get-string on the registered adapter. No
#     perch (whoami empty) → only skill-injection emits, no drain. (REQ-UPS-INJECTION impl now in the
#     binary.) Prefer the release build; the dev `adapter add` above registered the manifest, so
#     get-string resolves the file-backed skill body. [int->REQ-DIST-HOOK-BINARY]
HOOKBIN="$ROOT/tools/claude-spt/target/release/claude-spt.exe"
[ -x "$HOOKBIN" ] || HOOKBIN="$ROOT/tools/claude-spt/target/release/claude-spt"
if [ -x "$HOOKBIN" ]; then
  inj=$(printf '%s' '{"session_id":"reg-int-nosession","prompt":"/sptc:ready listen up"}' | "$HOOKBIN" hook UserPromptSubmit --host-pid $$ 2>/dev/null)
  case "$inj" in
    '<sptc_skill name="ready">'*'# /sptc:ready'*'</sptc_skill>'*) ok "UPS skill-injection (binary): /sptc:ready -> wrapped body" ;;
    *) bad "skill-injection did not emit wrapped body: $(printf %.60s "$inj")" ;;
  esac
else
  echo "SKIP: claude-spt binary not built (cargo build --release) — skill-injection int needs it"
fi


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

# 4c. UPS skill-injection end-to-end via the BINARY (D1): `claude-spt hook UserPromptSubmit` resolves a
#     /sptc:<skill> prompt to the wrapped operative body via get-string on the registered adapter. No
#     perch (whoami empty) → only skill-injection emits, no drain. (REQ-UPS-INJECTION impl now in the
#     binary.) Prefer the release build; the dev `adapter add` above registered the manifest, so
#     get-string resolves the file-backed skill body. [int->REQ-DIST-HOOK-BINARY]
HOOKBIN="$ROOT/tools/claude-spt/target/release/claude-spt.exe"
[ -x "$HOOKBIN" ] || HOOKBIN="$ROOT/tools/claude-spt/target/release/claude-spt"
if [ -x "$HOOKBIN" ]; then
  inj=$(printf '%s' '{"session_id":"reg-int-nosession","prompt":"/sptc:ready listen up"}' | "$HOOKBIN" hook UserPromptSubmit --host-pid $$ 2>/dev/null)
  case "$inj" in
    '<sptc_skill name="ready">'*'# /sptc:ready'*'</sptc_skill>'*) ok "UPS skill-injection (binary): /sptc:ready -> wrapped body" ;;
    *) bad "skill-injection did not emit wrapped body: $(printf %.60s "$inj")" ;;
  esac
else
  echo "SKIP: claude-spt binary not built (cargo build --release) — skill-injection int needs it"
fi

#!/bin/sh
# Integration proof for the claude-spt adapter manifest against a REAL spt-core (>= v0.7.0): the
# 2nd validation layer beyond JSON Schema — spt-core's `spt adapter add` cross-field registration —
# accepts the manifest, the shipped profile resolves, [strings] read through the registry, and the
# profile overlay is observable (base vs :ccs differ). This is the v1 acceptance proof for the
# adapter manifest (LLM never in the loop; pure CLI assertions). [int->REQ-DIST-MANIFEST-SCHEMA]
#
# Mutates the node-local adapter registry (add + soft-remove), so it is gated behind SPTC_ACCEPTANCE=1
# and a present spt>=0.7.0 — never runs in the default `tests/*.sh` unit sweep. Idempotent: removes
# what it adds. Run: SPTC_ACCEPTANCE=1 sh ci/manifest/registration-int.sh   (exit 0 = pass).
set -u
ROOT=$(CDPATH= cd "$(dirname "$0")/../.." && pwd)
MANIFEST="$ROOT/adapter/claude-spt.toml"

if [ "${SPTC_ACCEPTANCE:-0}" != "1" ]; then echo "SKIP: set SPTC_ACCEPTANCE=1 to run (mutates the adapter registry)"; exit 0; fi
command -v spt >/dev/null 2>&1 || { echo "SKIP: no spt on PATH"; exit 0; }
ver=$(spt --version 2>/dev/null | awk '{print $NF}')
case "$ver" in
  0.6.*|0.5.*|0.4.*|0.3.*|0.2.*|0.1.*|0.0.*) echo "SKIP: spt $ver < 0.7.0 (shortcut_basename + adapter add are v0.7.0)"; exit 0 ;;
esac

fail=0
ok()  { echo "ok   $1"; }
bad() { echo "FAIL $1"; fail=1; }
# Clean up registry residue however we exit.
trap 'spt adapter remove claude-spt >/dev/null 2>&1 || true' EXIT INT TERM

# 1. Registration cross-field validation accepts the manifest.
out=$(spt adapter add "$MANIFEST" 2>&1)
case "$out" in *registered*) ok "spt adapter add: registered" ;; *) bad "adapter add rejected: $out" ;; esac

# 2. Listed, with the shipped profile resolved as a composite option.
list=$(spt adapter list 2>&1)
case "$list" in *claude-spt*) ok "listed: claude-spt active" ;; *) bad "claude-spt not listed" ;; esac
# NOTE: there is no `:live` profile (Option A, PREP-4) — base claude-spt is live-capable
# ([session.psyche_init] in base); the LiveAgent capability is asserted via `spt api capability` at
# step 4d below, not a composite resolve.
# The ccs overlay (:ccs leaf-replaces [session.self].command -> `ccs`, a drop-in for `claude`) —
# the LOCKED-ADD ccs profile template (REQ-CCS-PROFILES; validated vs sister project claude_skill_owl).
case "$list" in *claude-spt:ccs*) ok "shipped profile resolves: claude-spt:ccs (ccs overlay)" ;; *) bad "ccs profile not resolved" ;; esac

# 3. [strings] read through the registry (base value).
base=$(spt adapter get-string claude-spt adapter_label 2>&1)
[ "$base" = "Claude Code (spt)" ] && ok "strings resolve: adapter_label (base)" || bad "base adapter_label='$base'"

# 4. Profile overlay is observable: :ccs leaf-replaces the base string. (The placeholder :deep
#    profile was removed 2026-06-18; :ccs is the sole shipped overlay and proves the same seam.)
# :ccs overlay observable (leaf-replaced adapter_label). [int->REQ-CCS-PROFILES]
ccs=$(spt adapter get-string claude-spt:ccs adapter_label 2>&1)
[ "$ccs" = "Claude Code (spt, ccs)" ] && ok "overlay observable: :ccs adapter_label differs" || bad "ccs adapter_label='$ccs'"

# 4b. File-backed [strings] pointer resolves to FILE CONTENTS (not the table, not raw); an inline
#     sibling still prints as-is. Proves `{ file = "skills/<x>.md" }` over adapter/strings/ (F-003).
body=$(spt adapter get-string claude-spt skills.ready 2>&1)
case "$body" in "# /sptc:ready"*) ok "file-backed string resolves: skills.ready -> body" ;; *) bad "skills.ready not resolved to file body: '$(printf %.40s "$body")'" ;; esac
# force-stop = endpoint shutdown body. (send/commune/signoff are NOT injected — full-fat in the
# plugin SKILL.md, no [strings.skills] entry — so they are intentionally absent from this resolve set.)
fbody=$(spt adapter get-string claude-spt "skills.force-stop" 2>&1)
case "$fbody" in "# /sptc:force-stop"*) ok "file-backed string resolves: skills.force-stop -> body" ;; *) bad "skills.force-stop not resolved to file body: '$(printf %.40s "$fbody")'" ;; esac
# `live` is file-backed too (the LiveAgent bringup body — base [session.psyche_init] + bare
# `api listen`; REQ-SKILL-LIVE).
lbody=$(spt adapter get-string claude-spt skills.live 2>&1)
case "$lbody" in "# /sptc:live"*) ok "file-backed string resolves: skills.live -> body" ;; *) bad "skills.live not resolved to file body: '$(printf %.40s "$lbody")'" ;; esac
# subnet (LOCKED-ADD cross-machine membership skill — wraps `spt subnet`). [int->REQ-SKILL-SUBNET]
sbody=$(spt adapter get-string claude-spt skills.subnet 2>&1)
case "$sbody" in "# /sptc:subnet"*) ok "file-backed string resolves: skills.subnet -> body" ;; *) bad "skills.subnet not resolved to file body: '$(printf %.40s "$sbody")'" ;; esac

# 4c. UPS skill-injection end-to-end: the hook helper resolves a /sptc:<skill> prompt to the wrapped
#     operative body via get-string (REQ-UPS-INJECTION impl, on the registered adapter).
( . "$ROOT/plugin/sptc/hooks/_common.sh"
  inj=$(sptc_inject_skill "$(sptc_skill_key '/sptc:ready listen up')")
  case "$inj" in
    '<sptc_skill name="ready">'*'# /sptc:ready'*'</sptc_skill>'*) exit 0 ;;
    *) printf 'inject got: %.60s\n' "$inj"; exit 1 ;;
  esac ) && ok "UPS skill-injection: /sptc:ready -> wrapped body" || bad "skill-injection did not emit wrapped body"

# 4d. The spt-hosted bringup blocks ([session.self] + [env.SPT_ENDPOINT_ID]) cross-field-validate:
#     `adapter add` (step 1) is manifest-first ("an invalid manifest registers nothing"), so the
#     manifest carrying these blocks registering at all proves spt-core accepted their shape. Confirm
#     the registered adapter advertises a hostable harness — the thing `spt endpoint run` spawns via
#     [session.self] (the M12 cc-launcher target). [int->REQ-DIST-MANIFEST-SCHEMA]
cap=$(spt api --adapter claude-spt --manifest "$MANIFEST" capability 2>&1)
case "$cap" in *LiveAgent*) ok "bringup blocks accepted; capability reports hostable harness" ;; *) bad "capability missing LiveAgent: $cap" ;; esac

# 5. Soft-deregister cleanly.
rm=$(spt adapter remove claude-spt 2>&1)
list2=$(spt adapter list 2>&1)
case "$list2" in *"claude-spt"*"active"*) bad "claude-spt still active after remove" ;; *) ok "removed (soft-deregistered)" ;; esac

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

-# 4c. UPS skill-injection end-to-end: the hook helper resolves a /sptc:<skill> prompt to the wrapped

-#     operative body via get-string (REQ-UPS-INJECTION impl, on the registered adapter).

-  inj=$(sptc_inject_skill "$(sptc_skill_key '/sptc:ready listen up')")

+# 4c. UPS skill-injection end-to-end via the BINARY (D1): `claude-spt hook UserPromptSubmit` resolves a

+#     /sptc:<skill> prompt to the wrapped operative body via get-string on the registered adapter. No

+#     perch (whoami empty) → only skill-injection emits, no drain. (REQ-UPS-INJECTION impl now in the

+#     binary.) Prefer the release build; the dev `adapter add` above registered the manifest, so

+#     get-string resolves the file-backed skill body. [int->REQ-DIST-HOOK-BINARY]

+[ -x "$HOOKBIN" ] || HOOKBIN="$ROOT/tools/claude-spt/target/release/claude-spt"

+  inj=$(printf '%s' '{"session_id":"reg-int-nosession","prompt":"/sptc:ready listen up"}' | "$HOOKBIN" hook UserPromptSubmit --host-pid $$ 2>/dev/null)

-    '<sptc_skill name="ready">'*'# /sptc:ready'*'</sptc_skill>'*) exit 0 ;;

-    *) printf 'inject got: %.60s\n' "$inj"; exit 1 ;;

-  esac ) && ok "UPS skill-injection: /sptc:ready -> wrapped body" || bad "skill-injection did not emit wrapped body"

+    '<sptc_skill name="ready">'*'# /sptc:ready'*'</sptc_skill>'*) ok "UPS skill-injection (binary): /sptc:ready -> wrapped body" ;;

+    *) bad "skill-injection did not emit wrapped body: $(printf %.60s "$inj")" ;;

+  echo "SKIP: claude-spt binary not built (cargo build --release) — skill-injection int needs it"


Now fix poll-int.sh step 2 to confirm-match through the binary (the parser moved to Rust). I'll make it drive the real UPS drain, then re-idle so the multi-message step stays clean.


44	/' | spt send "$BID" --from probe-int >/dev/null 2>&1
45	raw=$(spt api --adapter "$ADAPTER" poll "$BID" --session-id "$BSID" 2>&1)
46
47	# 1. Canonical <EVENT type="msg" from=…> envelope with correct body escaping; no relic.
48	expected='<EVENT type="msg" from="probe-int">hello from probe<br>second <line> & "stuff"</EVENT>'
49	case "$raw" in *"$expected"*) ok "api poll emits canonical <EVENT> msg envelope (escaped body)" ;; *) bad "envelope mismatch; raw=[$raw]" ;; esac
50	case "$raw" in *__REPLY_TO__*) bad "raw drain still carries the __REPLY_TO__ relic" ;; *) ok "no __REPLY_TO__ relic (canonical poll envelope shipped)" ;; esac
51
52	# 2. Hook parser confirm-match: render_frames decodes the drain to our additionalContext shape.
53	rendered=$( . "$ROOT/plugin/sptc/hooks/_common.sh"; render_frames "$raw" )
54	want=$(printf '<sptc_messages from="probe-int">\nhello from probe\nsecond <line> & "stuff"\n</sptc_messages>')
55	case "$rendered" in *"$want"*) ok "render_frames confirm-match: drain -> <sptc_messages>" ;; *) bad "render mismatch; got=[$rendered]" ;; esac
56
57	# 3. Multi-message: two sends drain as two whole envelopes (self-delimiting; F-002 dissolved).
58	printf 'one' | spt send "$BID" --from alice >/dev/null 2>&1
59	printf 'two' | spt send "$BID" --from bob >/dev/null 2>&1
60	multi=$(spt api --adapter "$ADAPTER" poll "$BID" --session-id "$BSID" 2>&1)
61	n=$(printf '%s\n' "$multi" | grep -c 'type="msg"')
62	[ "$n" -eq 2 ] && ok "multi-message: 2 whole <EVENT> envelopes split on </EVENT>" || bad "expected 2 msg envelopes, got $n"
63

/' | spt send "$BID" --from probe-int >/dev/null 2>&1
raw=$(spt api --adapter "$ADAPTER" poll "$BID" --session-id "$BSID" 2>&1)

# 1. Canonical <EVENT type="msg" from=…> envelope with correct body escaping; no relic.
expected='<EVENT type="msg" from="probe-int">hello from probe<br>second <line> & "stuff"</EVENT>'
case "$raw" in *"$expected"*) ok "api poll emits canonical <EVENT> msg envelope (escaped body)" ;; *) bad "envelope mismatch; raw=[$raw]" ;; esac
case "$raw" in *__REPLY_TO__*) bad "raw drain still carries the __REPLY_TO__ relic" ;; *) ok "no __REPLY_TO__ relic (canonical poll envelope shipped)" ;; esac

# 2. Hook parser confirm-match: render_frames decodes the drain to our additionalContext shape.
rendered=$( . "$ROOT/plugin/sptc/hooks/_common.sh"; render_frames "$raw" )
want=$(printf '<sptc_messages from="probe-int">\nhello from probe\nsecond <line> & "stuff"\n</sptc_messages>')
case "$rendered" in *"$want"*) ok "render_frames confirm-match: drain -> <sptc_messages>" ;; *) bad "render mismatch; got=[$rendered]" ;; esac

# 3. Multi-message: two sends drain as two whole envelopes (self-delimiting; F-002 dissolved).
printf 'one' | spt send "$BID" --from alice >/dev/null 2>&1
printf 'two' | spt send "$BID" --from bob >/dev/null 2>&1
multi=$(spt api --adapter "$ADAPTER" poll "$BID" --session-id "$BSID" 2>&1)
n=$(printf '%s\n' "$multi" | grep -c 'type="msg"')
[ "$n" -eq 2 ] && ok "multi-message: 2 whole <EVENT> envelopes split on </EVENT>" || bad "expected 2 msg envelopes, got $n"


# 2. Hook parser confirm-match: render_frames decodes the drain to our additionalContext shape.
rendered=$( . "$ROOT/plugin/sptc/hooks/_common.sh"; render_frames "$raw" )
want=$(printf '<sptc_messages from="probe-int">\nhello from probe\nsecond <line> & "stuff"\n</sptc_messages>')
case "$rendered" in *"$want"*) ok "render_frames confirm-match: drain -> <sptc_messages>" ;; *) bad "render mismatch; got=[$rendered]" ;; esac

# 2. Hook BINARY confirm-match (D1): the real `claude-spt hook UserPromptSubmit` drains the live perch
#    and renders the canonical <EVENT> drain to our <sptc_messages> additionalContext shape — the same
#    parser, now in the binary (was render_frames in _common.sh). Send a fresh message with body
#    specials, run the hook (it resolves the perch via whoami off OWL_SESSION_ID, marks busy, polls
#    --include-deferred, renders), then re-idle + drain residue so step 3's plain poll stays clean.
#    [int->REQ-DIST-HOOK-BINARY] [int->REQ-UPS-INJECTION]
HOOKBIN="$ROOT/tools/claude-spt/target/release/claude-spt.exe"
[ -x "$HOOKBIN" ] || HOOKBIN="$ROOT/tools/claude-spt/target/release/claude-spt"
if [ -x "$HOOKBIN" ]; then
  printf 'hello from probe<NL>second <line> & "stuff"' | sed 's/<NL>/\
/' | spt send "$BID" --from probe-int >/dev/null 2>&1
  rendered=$(OWL_SESSION_ID="$BSID" printf '%s' "{\"session_id\":\"$BSID\",\"prompt\":\"\"}" | OWL_SESSION_ID="$BSID" "$HOOKBIN" hook UserPromptSubmit --host-pid $$ 2>/dev/null)
  want=$(printf '<sptc_messages from="probe-int">\nhello from probe\nsecond <line> & "stuff"\n</sptc_messages>')
  case "$rendered" in *"$want"*) ok "binary hook confirm-match: live drain -> <sptc_messages>" ;; *) bad "binary render mismatch; got=[$rendered]" ;; esac
  # Re-idle the perch (the hook marked it busy) and drain any residue so step 3 is unaffected.
  spt api --adapter "$ADAPTER" state idle "$BID" --session-id "$BSID" >/dev/null 2>&1
  spt api --adapter "$ADAPTER" poll "$BID" --session-id "$BSID" --include-deferred >/dev/null 2>&1
else
  echo "SKIP: claude-spt binary not built (cargo build --release) — binary hook confirm-match needs it"
fi


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

# 2. Hook BINARY confirm-match (D1): the real `claude-spt hook UserPromptSubmit` drains the live perch
#    and renders the canonical <EVENT> drain to our <sptc_messages> additionalContext shape — the same
#    parser, now in the binary (was render_frames in _common.sh). Send a fresh message with body
#    specials, run the hook (it resolves the perch via whoami off OWL_SESSION_ID, marks busy, polls
#    --include-deferred, renders), then re-idle + drain residue so step 3's plain poll stays clean.
#    [int->REQ-DIST-HOOK-BINARY] [int->REQ-UPS-INJECTION]
HOOKBIN="$ROOT/tools/claude-spt/target/release/claude-spt.exe"
[ -x "$HOOKBIN" ] || HOOKBIN="$ROOT/tools/claude-spt/target/release/claude-spt"
if [ -x "$HOOKBIN" ]; then
  printf 'hello from probe<NL>second <line> & "stuff"' | sed 's/<NL>/\
/' | spt send "$BID" --from probe-int >/dev/null 2>&1
  rendered=$(OWL_SESSION_ID="$BSID" printf '%s' "{\"session_id\":\"$BSID\",\"prompt\":\"\"}" | OWL_SESSION_ID="$BSID" "$HOOKBIN" hook UserPromptSubmit --host-pid $$ 2>/dev/null)
  want=$(printf '<sptc_messages from="probe-int">\nhello from probe\nsecond <line> & "stuff"\n</sptc_messages>')
  case "$rendered" in *"$want"*) ok "binary hook confirm-match: live drain -> <sptc_messages>" ;; *) bad "binary render mismatch; got=[$rendered]" ;; esac
  # Re-idle the perch (the hook marked it busy) and drain any residue so step 3 is unaffected.
  spt api --adapter "$ADAPTER" state idle "$BID" --session-id "$BSID" >/dev/null 2>&1
  spt api --adapter "$ADAPTER" poll "$BID" --session-id "$BSID" --include-deferred >/dev/null 2>&1
else
  echo "SKIP: claude-spt binary not built (cargo build --release) — binary hook confirm-match needs it"
fi

#!/bin/sh
# Integration proof for the UserPromptSubmit message-drain path against a REAL spt-core (>= v0.7.1):
# the published `api poll` surface emits the canonical self-delimiting <EVENT> envelope,
# and our hook parser (render_frames) decodes it correctly. This is the confirm-match that closes
# REQ-MSG-ENVELOPE end-to-end: design -> impl -> ship -> real-surface-verify. F-002 (no inter-frame
# delimiter) is dissolved — multi-message drains split cleanly on </EVENT>, no __REPLY_TO__ relic.
# [int->REQ-DIST-HOOKS-API] [int->REQ-UPS-INJECTION]
#
# v0.7.1 is the floor: the <EVENT> poll surface ships in 0.7.1; 0.7.0 still emits the __REPLY_TO__
# relic, so this SKIPs there. Mutates node-local perch/spool state (a throwaway perch, torn down on
# exit), so it is gated behind SPTC_ACCEPTANCE=1. Idempotent.
# Run: SPTC_ACCEPTANCE=1 sh ci/hooks/poll-int.sh   (exit 0 = pass).
set -u
ROOT=$(CDPATH= cd "$(dirname "$0")/../.." && pwd)
ADAPTER=claude-spt
# Disposable perch id — NEVER a live agent's id (REQ-HAZARD-PERCH-COLLISION: a colliding id tears
# down the live agent's perch + poll stream, name-keyed last-establish-wins).
BID=sptc-poll-int
BSID=sptc-poll-int-sess

if [ "${SPTC_ACCEPTANCE:-0}" != "1" ]; then echo "SKIP: set SPTC_ACCEPTANCE=1 to run (mutates perch/spool state)"; exit 0; fi
command -v spt >/dev/null 2>&1 || { echo "SKIP: no spt on PATH"; exit 0; }
ver=$(spt --version 2>/dev/null | awk '{print $NF}')
case "$ver" in
  0.7.0|0.6.*|0.5.*|0.4.*|0.3.*|0.2.*|0.1.*|0.0.*) echo "SKIP: spt $ver < 0.7.1 (<EVENT> poll surface ships in 0.7.1; older emits the __REPLY_TO__ relic)"; exit 0 ;;
esac

fail=0
ok()  { echo "ok   $1"; }
bad() { echo "FAIL $1"; fail=1; }
# Tear down the throwaway perch however we exit.
trap 'spt endpoint shutdown "$BID" >/dev/null 2>&1 || true; spt endpoint stop "$BID" >/dev/null 2>&1 || true' EXIT INT TERM

# Establish a pollable perch (non-blocking: seed + bind, no listener -> sends spool).
spt api --adapter "$ADAPTER" seed --pid $$ --session-id "$BSID" >/dev/null 2>&1
bound=$(spt api --adapter "$ADAPTER" bind "$BID" --set-session-id "$BSID" --session-id "$BSID" 2>&1)
case "$bound" in *BOUND:"$BID"*) ok "perch bound (non-blocking)" ;; *) bad "bind failed: $bound"; echo "MSG-ENVELOPE-INT FAIL"; exit 1 ;; esac

# Drain & discard any resurfaced notifications so the assertion sees only our test message.
spt api --adapter "$ADAPTER" poll "$BID" --session-id "$BSID" >/dev/null 2>&1

# Send one message with body specials (newline + < > & ") from a known sender, then drain raw.
printf 'hello from probe<NL>second <line> & "stuff"' | sed 's/<NL>/\
/' | spt send "$BID" --from probe-int >/dev/null 2>&1
raw=$(spt api --adapter "$ADAPTER" poll "$BID" --session-id "$BSID" 2>&1)

# 1. Canonical <EVENT type="msg" from=…> envelope with correct body escaping; no relic.
expected='<EVENT type="msg" from="probe-int">hello from probe<br>second <line> & "stuff"</EVENT>'
case "$raw" in *"$expected"*) ok "api poll emits canonical <EVENT> msg envelope (escaped body)" ;; *) bad "envelope mismatch; raw=[$raw]" ;; esac
case "$raw" in *__REPLY_TO__*) bad "raw drain still carries the __REPLY_TO__ relic" ;; *) ok "no __REPLY_TO__ relic (canonical poll envelope shipped)" ;; esac

# 2. Hook parser confirm-match: render_frames decodes the drain to our additionalContext shape.
rendered=$( . "$ROOT/plugin/sptc/hooks/_common.sh"; render_frames "$raw" )
want=$(printf '<sptc_messages from="probe-int">\nhello from probe\nsecond <line> & "stuff"\n</sptc_messages>')
case "$rendered" in *"$want"*) ok "render_frames confirm-match: drain -> <sptc_messages>" ;; *) bad "render mismatch; got=[$rendered]" ;; esac

# 3. Multi-message: two sends drain as two whole envelopes (self-delimiting; F-002 dissolved).
printf 'one' | spt send "$BID" --from alice >/dev/null 2>&1
printf 'two' | spt send "$BID" --from bob >/dev/null 2>&1
multi=$(spt api --adapter "$ADAPTER" poll "$BID" --session-id "$BSID" 2>&1)
n=$(printf '%s\n' "$multi" | grep -c 'type="msg"')
[ "$n" -eq 2 ] && ok "multi-message: 2 whole <EVENT> envelopes split on </EVENT>" || bad "expected 2 msg envelopes, got $n"

[ "$fail" -eq 0 ] && { echo "MSG-ENVELOPE-INT OK"; exit 0; } || { echo "MSG-ENVELOPE-INT FAIL"; exit 1; }

-# 2. Hook parser confirm-match: render_frames decodes the drain to our additionalContext shape.

-rendered=$( . "$ROOT/plugin/sptc/hooks/_common.sh"; render_frames "$raw" )

-want=$(printf '<sptc_messages from="probe-int">\nhello from probe\nsecond <line> & "stuff"\n</sptc_messages>')

-case "$rendered" in *"$want"*) ok "render_frames confirm-match: drain -> <sptc_messages>" ;; *) bad "render mismatch; got=[$rendered]" ;; esac

+# 2. Hook BINARY confirm-match (D1): the real `claude-spt hook UserPromptSubmit` drains the live perch

+#    and renders the canonical <EVENT> drain to our <sptc_messages> additionalContext shape — the same

+#    parser, now in the binary (was render_frames in _common.sh). Send a fresh message with body

+#    specials, run the hook (it resolves the perch via whoami off OWL_SESSION_ID, marks busy, polls

+#    --include-deferred, renders), then re-idle + drain residue so step 3's plain poll stays clean.

+#    [int->REQ-DIST-HOOK-BINARY] [int->REQ-UPS-INJECTION]

+[ -x "$HOOKBIN" ] || HOOKBIN="$ROOT/tools/claude-spt/target/release/claude-spt"

+  printf 'hello from probe<NL>second <line> & "stuff"' | sed 's/<NL>/\

+/' | spt send "$BID" --from probe-int >/dev/null 2>&1

+  rendered=$(OWL_SESSION_ID="$BSID" printf '%s' "{\"session_id\":\"$BSID\",\"prompt\":\"\"}" | OWL_SESSION_ID="$BSID" "$HOOKBIN" hook UserPromptSubmit --host-pid $$ 2>/dev/null)

+  want=$(printf '<sptc_messages from="probe-int">\nhello from probe\nsecond <line> & "stuff"\n</sptc_messages>')

+  case "$rendered" in *"$want"*) ok "binary hook confirm-match: live drain -> <sptc_messages>" ;; *) bad "binary render mismatch; got=[$rendered]" ;; esac

+  # Re-idle the perch (the hook marked it busy) and drain any residue so step 3 is unaffected.

+  spt api --adapter "$ADAPTER" state idle "$BID" --session-id "$BSID" >/dev/null 2>&1

+  spt api --adapter "$ADAPTER" poll "$BID" --session-id "$BSID" --include-deferred >/dev/null 2>&1

+  echo "SKIP: claude-spt binary not built (cargo build --release) — binary hook confirm-match needs it"


rendered=$(OWL_SESSION_ID="$BSID" printf '%s' "{\"session_id\":\"$BSID\",\"prompt\":\"\"}" | OWL_SESSION_ID="$BSID" "$HOOKBIN" hook UserPromptSubmit --host-pid $$ 2>/dev/null)

  # The binary resolves the perch via `spt whoami` with OWL_SESSION_ID set from the stdin session_id.
  rendered=$(printf '%s' "{\"session_id\":\"$BSID\",\"prompt\":\"\"}" | "$HOOKBIN" hook UserPromptSubmit --host-pid $$ 2>/dev/null)


  # The binary resolves the perch via `spt whoami` with OWL_SESSION_ID set from the stdin session_id.
  rendered=$(printf '%s' "{\"session_id\":\"$BSID\",\"prompt\":\"\"}" | "$HOOKBIN" hook UserPromptSubmit --host-pid $$ 2>/dev/null)

#!/bin/sh
# Integration proof for the UserPromptSubmit message-drain path against a REAL spt-core (>= v0.7.1):
# the published `api poll` surface emits the canonical self-delimiting <EVENT> envelope,
# and our hook parser (render_frames) decodes it correctly. This is the confirm-match that closes
# REQ-MSG-ENVELOPE end-to-end: design -> impl -> ship -> real-surface-verify. F-002 (no inter-frame
# delimiter) is dissolved — multi-message drains split cleanly on </EVENT>, no __REPLY_TO__ relic.
# [int->REQ-DIST-HOOKS-API] [int->REQ-UPS-INJECTION]
#
# v0.7.1 is the floor: the <EVENT> poll surface ships in 0.7.1; 0.7.0 still emits the __REPLY_TO__
# relic, so this SKIPs there. Mutates node-local perch/spool state (a throwaway perch, torn down on
# exit), so it is gated behind SPTC_ACCEPTANCE=1. Idempotent.
# Run: SPTC_ACCEPTANCE=1 sh ci/hooks/poll-int.sh   (exit 0 = pass).
set -u
ROOT=$(CDPATH= cd "$(dirname "$0")/../.." && pwd)
ADAPTER=claude-spt
# Disposable perch id — NEVER a live agent's id (REQ-HAZARD-PERCH-COLLISION: a colliding id tears
# down the live agent's perch + poll stream, name-keyed last-establish-wins).
BID=sptc-poll-int
BSID=sptc-poll-int-sess

if [ "${SPTC_ACCEPTANCE:-0}" != "1" ]; then echo "SKIP: set SPTC_ACCEPTANCE=1 to run (mutates perch/spool state)"; exit 0; fi
command -v spt >/dev/null 2>&1 || { echo "SKIP: no spt on PATH"; exit 0; }
ver=$(spt --version 2>/dev/null | awk '{print $NF}')
case "$ver" in
  0.7.0|0.6.*|0.5.*|0.4.*|0.3.*|0.2.*|0.1.*|0.0.*) echo "SKIP: spt $ver < 0.7.1 (<EVENT> poll surface ships in 0.7.1; older emits the __REPLY_TO__ relic)"; exit 0 ;;
esac

fail=0
ok()  { echo "ok   $1"; }
bad() { echo "FAIL $1"; fail=1; }
# Tear down the throwaway perch however we exit.
trap 'spt endpoint shutdown "$BID" >/dev/null 2>&1 || true; spt endpoint stop "$BID" >/dev/null 2>&1 || true' EXIT INT TERM

# Establish a pollable perch (non-blocking: seed + bind, no listener -> sends spool).
spt api --adapter "$ADAPTER" seed --pid $$ --session-id "$BSID" >/dev/null 2>&1
bound=$(spt api --adapter "$ADAPTER" bind "$BID" --set-session-id "$BSID" --session-id "$BSID" 2>&1)
case "$bound" in *BOUND:"$BID"*) ok "perch bound (non-blocking)" ;; *) bad "bind failed: $bound"; echo "MSG-ENVELOPE-INT FAIL"; exit 1 ;; esac

# Drain & discard any resurfaced notifications so the assertion sees only our test message.
spt api --adapter "$ADAPTER" poll "$BID" --session-id "$BSID" >/dev/null 2>&1

# Send one message with body specials (newline + < > & ") from a known sender, then drain raw.
printf 'hello from probe<NL>second <line> & "stuff"' | sed 's/<NL>/\
/' | spt send "$BID" --from probe-int >/dev/null 2>&1
raw=$(spt api --adapter "$ADAPTER" poll "$BID" --session-id "$BSID" 2>&1)

# 1. Canonical <EVENT type="msg" from=…> envelope with correct body escaping; no relic.
expected='<EVENT type="msg" from="probe-int">hello from probe<br>second <line> & "stuff"</EVENT>'
case "$raw" in *"$expected"*) ok "api poll emits canonical <EVENT> msg envelope (escaped body)" ;; *) bad "envelope mismatch; raw=[$raw]" ;; esac
case "$raw" in *__REPLY_TO__*) bad "raw drain still carries the __REPLY_TO__ relic" ;; *) ok "no __REPLY_TO__ relic (canonical poll envelope shipped)" ;; esac

# 2. Hook BINARY confirm-match (D1): the real `claude-spt hook UserPromptSubmit` drains the live perch
#    and renders the canonical <EVENT> drain to our <sptc_messages> additionalContext shape — the same
#    parser, now in the binary (was render_frames in _common.sh). Send a fresh message with body
#    specials, run the hook (it resolves the perch via whoami off OWL_SESSION_ID, marks busy, polls
#    --include-deferred, renders), then re-idle + drain residue so step 3's plain poll stays clean.
#    [int->REQ-DIST-HOOK-BINARY] [int->REQ-UPS-INJECTION]
HOOKBIN="$ROOT/tools/claude-spt/target/release/claude-spt.exe"
[ -x "$HOOKBIN" ] || HOOKBIN="$ROOT/tools/claude-spt/target/release/claude-spt"
if [ -x "$HOOKBIN" ]; then
  printf 'hello from probe<NL>second <line> & "stuff"' | sed 's/<NL>/\
/' | spt send "$BID" --from probe-int >/dev/null 2>&1
  rendered=$(OWL_SESSION_ID="$BSID" printf '%s' "{\"session_id\":\"$BSID\",\"prompt\":\"\"}" | OWL_SESSION_ID="$BSID" "$HOOKBIN" hook UserPromptSubmit --host-pid $$ 2>/dev/null)
  want=$(printf '<sptc_messages from="probe-int">\nhello from probe\nsecond <line> & "stuff"\n</sptc_messages>')
  case "$rendered" in *"$want"*) ok "binary hook confirm-match: live drain -> <sptc_messages>" ;; *) bad "binary render mismatch; got=[$rendered]" ;; esac
  # Re-idle the perch (the hook marked it busy) and drain any residue so step 3 is unaffected.
  spt api --adapter "$ADAPTER" state idle "$BID" --session-id "$BSID" >/dev/null 2>&1
  spt api --adapter "$ADAPTER" poll "$BID" --session-id "$BSID" --include-deferred >/dev/null 2>&1
else
  echo "SKIP: claude-spt binary not built (cargo build --release) — binary hook confirm-match needs it"
fi

# 3. Multi-message: two sends drain as two whole envelopes (self-delimiting; F-002 dissolved).
printf 'one' | spt send "$BID" --from alice >/dev/null 2>&1
printf 'two' | spt send "$BID" --from bob >/dev/null 2>&1
multi=$(spt api --adapter "$ADAPTER" poll "$BID" --session-id "$BSID" 2>&1)
n=$(printf '%s\n' "$multi" | grep -c 'type="msg"')
[ "$n" -eq 2 ] && ok "multi-message: 2 whole <EVENT> envelopes split on </EVENT>" || bad "expected 2 msg envelopes, got $n"

[ "$fail" -eq 0 ] && { echo "MSG-ENVELOPE-INT OK"; exit 0; } || { echo "MSG-ENVELOPE-INT FAIL"; exit 1; }

-  rendered=$(OWL_SESSION_ID="$BSID" printf '%s' "{\"session_id\":\"$BSID\",\"prompt\":\"\"}" | OWL_SESSION_ID="$BSID" "$HOOKBIN" hook UserPromptSubmit --host-pid $$ 2>/dev/null)

+  # The binary resolves the perch via `spt whoami` with OWL_SESSION_ID set from the stdin session_id.

+  rendered=$(printf '%s' "{\"session_id\":\"$BSID\",\"prompt\":\"\"}" | "$HOOKBIN" hook UserPromptSubmit --host-pid $$ 2>/dev/null)


Run the full default gate suite.


Bash

cd /c/Users/decid/Documents/projects/spt-claude-code
sh ci/run-gates.sh 2>&1 | tail -50

Run all default CI gates


FAIL session-start: does not read the hook payload from stdin
grep: tests/../plugin/sptc/hooks/session-start.sh: No such file or directory
grep: tests/../plugin/sptc/hooks/user-prompt-submit.sh: No such file or directory
FAIL user-prompt-submit: does not read the hook payload from stdin
grep: tests/../plugin/sptc/hooks/user-prompt-submit.sh: No such file or directory
grep: tests/../plugin/sptc/hooks/stop.sh: No such file or directory
FAIL stop: does not read the hook payload from stdin
grep: tests/../plugin/sptc/hooks/stop.sh: No such file or directory
grep: tests/../plugin/sptc/hooks/session-end.sh: No such file or directory
FAIL session-end: does not read the hook payload from stdin
grep: tests/../plugin/sptc/hooks/session-end.sh: No such file or directory
grep: tests/../plugin/sptc/hooks/subagent-start.sh: No such file or directory
FAIL subagent-start: does not read the hook payload from stdin
grep: tests/../plugin/sptc/hooks/subagent-start.sh: No such file or directory
grep: tests/../plugin/sptc/hooks/subagent-stop.sh: No such file or directory
FAIL subagent-stop: does not read the hook payload from stdin
grep: tests/../plugin/sptc/hooks/subagent-stop.sh: No such file or directory
MSYS-HAZARD FAIL
ok  tests/skeleton-validate.sh

=== GATE: digest-extractor (cargo build + test) ===

running 89 tests
....................................................................................... 87/89
..
test result: ok. 89 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

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

=== GATE: psyche-runner (cargo build + test) ===
ok  claude-spt-psyche: folded into the consolidated claude-spt crate (built/tested by ci/digest/build.sh)

=== 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)

=== GATE: traceable-reqs check (requirement coverage) ===
ok  coverage green

=== GATE: skeleton-validate (cplugs installability) ===
ok  skeleton installable

=== GATE: manifest-schema ===
ok   C:/Users/decid/Documents/projects/spt-claude-code/adapter/claude-spt.toml
ok   manifest validates against published manifest.schema.json

=== GATE: docs-drift ===
ok   mdbook build
ok   llms.txt in sync

=== RESULT: FAIL ===


traceable-reqs green, cargo green. Only `tests/msys-hazard.sh` fails — it greps the deleted wrappers. Read + rewrite it for the new architecture.


1	#!/bin/sh
2	# REQ-HAZARD-MSYS-PATHCONV conformance: every hook wrapper must read the CC hook payload from
3	# STDIN (immune to MSYS), never reconstruct message/prompt content from a /-leading positional
4	# argv (Git-Bash path-mangles those on Windows — see docs/KNOWN-HAZARDS.md 1.1).
5	# Run: sh tests/msys-hazard.sh   (exit 0 = pass).   [unit->REQ-HAZARD-MSYS-PATHCONV]
6	H="$(dirname "$0")/../plugin/sptc/hooks"
7	fail=0
8	for w in session-start user-prompt-submit stop session-end subagent-start subagent-stop; do
9	  f="$H/$w.sh"
10	  # Must consume the payload from stdin.
11	  if ! grep -q 'input=$(cat)' "$f"; then
12	    printf 'FAIL %s: does not read the hook payload from stdin\n' "$w"; fail=1
13	  fi
14	  # Must NOT parse prompt/message CONTENT from a positional argument.
15	  if grep -Eq '(prompt|msg|body|message)=("?\$[1-9])' "$f"; then
16	    printf 'FAIL %s: parses content from a positional argv (MSYS-unsafe)\n' "$w"; fail=1
17	  fi
18	done
19	[ "$fail" -eq 0 ] && { echo "MSYS-HAZARD OK"; exit 0; } || { echo "MSYS-HAZARD FAIL"; exit 1; }
20

#!/bin/sh
# REQ-HAZARD-MSYS-PATHCONV conformance: every hook wrapper must read the CC hook payload from
# STDIN (immune to MSYS), never reconstruct message/prompt content from a /-leading positional
# argv (Git-Bash path-mangles those on Windows — see docs/KNOWN-HAZARDS.md 1.1).
# Run: sh tests/msys-hazard.sh   (exit 0 = pass).   [unit->REQ-HAZARD-MSYS-PATHCONV]
H="$(dirname "$0")/../plugin/sptc/hooks"
fail=0
for w in session-start user-prompt-submit stop session-end subagent-start subagent-stop; do
  f="$H/$w.sh"
  # Must consume the payload from stdin.
  if ! grep -q 'input=$(cat)' "$f"; then
    printf 'FAIL %s: does not read the hook payload from stdin\n' "$w"; fail=1
  fi
  # Must NOT parse prompt/message CONTENT from a positional argument.
  if grep -Eq '(prompt|msg|body|message)=("?\$[1-9])' "$f"; then
    printf 'FAIL %s: parses content from a positional argv (MSYS-unsafe)\n' "$w"; fail=1
  fi
done
[ "$fail" -eq 0 ] && { echo "MSYS-HAZARD OK"; exit 0; } || { echo "MSYS-HAZARD FAIL"; exit 1; }


Rewrite `msys-hazard.sh` for the D1 architecture: payload rides stdin into the binary; dispatch passes only the non-`/`-leading event name as argv.


Write

#!/bin/sh
# REQ-HAZARD-MSYS-PATHCONV conformance (D1 architecture). The CC hook payload — which can contain
# /-leading content (e.g. a prompt "/sptc:send doyle") — must reach the handler via STDIN (immune to
# MSYS), never as a positional argv (Git-Bash path-mangles a /-leading argv on Windows — see
# docs/KNOWN-HAZARDS.md 1.1). After D1 the only argv passed anywhere is the CC EVENT NAME (SessionStart,
# …) + the numeric --host-pid — never message/prompt content, and never /-leading.
# Run: sh tests/msys-hazard.sh   (exit 0 = pass).   [unit->REQ-HAZARD-MSYS-PATHCONV]
ROOT="$(CDPATH= cd "$(dirname "$0")/.." && pwd)"
HK="$ROOT/plugin/sptc/hooks"
HOOKRS="$ROOT/tools/claude-spt/src/hook.rs"
fail=0
yn() { [ "$1" -eq 0 ] && echo yes || echo no; }

# 1. hooks.json passes dispatch.sh only a bare, non-/-leading CC event-name token (no payload content).
#    Every `dispatch.sh <token>` token must be alphabetic (an event name), never /-leading.
bad_tokens=$(grep -oE 'dispatch\.sh\\?" [^"]+' "$HK/hooks.json" | sed 's#.*dispatch\.sh\\\?" ##' | grep -Ec '^[A-Za-z]' )
tot_tokens=$(grep -oE 'dispatch\.sh\\?" [^"]+' "$HK/hooks.json" | wc -l | tr -d ' ')
if [ "$bad_tokens" -ne "$tot_tokens" ] || [ "$tot_tokens" -eq 0 ]; then
  printf 'FAIL hooks.json: a dispatch token is not a bare event name (%s/%s alphabetic)\n' "$bad_tokens" "$tot_tokens"; fail=1
fi
if grep -qE 'dispatch\.sh\\?" +/' "$HK/hooks.json"; then
  printf 'FAIL hooks.json: a /-leading positional token is passed to dispatch.sh (MSYS-unsafe)\n'; fail=1
fi

# 2. dispatch.sh must NOT read the payload (no `cat`) — it inherits stdin straight through to the
#    binary — and must NOT reconstruct content from a positional argv ($1 is the event name only).
if grep -qE '\$\(\s*cat\b' "$HK/dispatch.sh"; then
  printf 'FAIL dispatch.sh: reads stdin itself (must pass it through to the binary)\n'; fail=1
fi
if grep -Eq '(prompt|msg|body|message|content|file_path)=("?\$[1-9])' "$HK/dispatch.sh"; then
  printf 'FAIL dispatch.sh: parses payload content from a positional argv (MSYS-unsafe)\n'; fail=1
fi

# 3. The binary reads the CC payload from STDIN and parses every field off that parsed object — never
#    from argv (which only carries the event name + --host-pid).
grep -q 'stdin().read_to_string' "$HOOKRS" && r=0 || r=1
[ "$r" -eq 0 ] || { printf 'FAIL hook.rs: does not read the CC payload from stdin\n'; fail=1; }
# The argv loop handles only --host-pid; the payload fields come from the parsed stdin Value (field/nested).
grep -q 'serde_json::from_str' "$HOOKRS" && r=0 || r=1
[ "$r" -eq 0 ] || { printf 'FAIL hook.rs: does not parse the stdin payload as JSON\n'; fail=1; }

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