29:# audit this whenever a new evidence dir appears. ADD `plugin`, `skills`, `hooks`, `tests`
63:title = "Marketplace artifact on cplugs is a thin skeleton: namespaced /sptc:* skill stubs + hooks.json + plugin.json, no embedded logic"
72:id = "REQ-DIST-HOOKS-API"
73:title = "hooks.json delegates to `spt api` rather than carrying adapter logic"
74:required_stages = ["doc", "impl", "unit", "int"]  # doc: ADR-0001/0002. impl: hooks.json + wrappers. unit: tests/hooks-parse.sh. int: ci/hooks/poll-int.sh — `spt api poll` on live v0.7.1 emits the canonical <EVENT> envelope, confirm-matched through render_frames; __REPLY_TO__ relic gone, multi-drain splits on </EVENT>. 5/5 green 2026-06-15 (throwaway perch, torn down).
84:required_stages = ["doc", "impl", "unit"]  # doc: ADR-0001 (cc launcher brand, decoupled from the s/sptc/spt/ plugin-name succession). impl: the cc-launcher BRINGUP machinery — adapter [session.self] (spawns `claude`) + [env.SPT_ENDPOINT_ID] (broker injects the endpoint id) + the SessionStart bind branch (sptc_register_verb -> `api bind`, the spt-hosted self-register path). unit: tests/manifest-shortcut.sh (brand-value guard) + tests/hooks-parse.sh sptc_register_verb (bind/seed/boundary branch). int (real `spt endpoint run` picker emits cc-<id> + real-claude bringup-bind) deferred — the shortcut-emitting picker "lands in a later spt-core wave" (per `endpoint run --help`); the bringup-bind is now contract-unblocked (doyle confirmed 2026-06-15: bind is establishing/intrinsic-auth, --set-session-id only, no token) but still needs a real-claude acceptance spawn (heavier tier). adapter add accepting the [session.self]/[env] blocks is covered by registration-int.sh step 4d (REQ-DIST-MANIFEST-SCHEMA int).
89:required_stages = ["int"]  # doc (ADR-0001 cc-launcher) + impl (adapter [session.self] + [env.SPT_ENDPOINT_ID] + session-start.sh `bind` branch) + unit (tests/hooks-parse.sh sptc_register_verb bind) are owned by REQ-DIST-SHORTCUT-BASENAME; this REQ owns the now-unblocked real-claude bringup-bind INT (its sibling, the picker `cc-<id>` emission, stays deferred under REQ-DIST-SHORTCUT-BASENAME — a later spt-core wave). int: ci/launcher/bind-int.sh — real `spt endpoint run --adapter claude-spt --id <disp> --start` on spt >=0.11.0 → SessionStart fills SPT_ENDPOINT_ID + BINDS → owlery/<disp> bound perch on disk (info.json + ready) → `spt send <disp>` reaches it (SENT live PTY inject, REQ-SEND-SPT-HOSTED; QUEUED accepted) → full teardown. Was F-013-blocked (endpoint run did not substitute [env].value="{id}" → seed-not-bind → zero perch = the operator's wall-b NO_PERCH); doyle's v0.11.0 REQ-HAZARD-ENV-SUBST fix unblocked it. GREEN 2026-06-17 (disposable id, REQ-HAZARD-PERCH-COLLISION; box left clean). Self-gates SKIP on spt <0.11.0 / no SPTC_ACCEPTANCE. NB the spt-hosted bind path requires spt >=0.11.0 to bind (silently seeds on older) — manifest min_spt_core stays 0.9.0 for the ready/live flows; 0.11.0-floor-bump for the endpoint-run path is a flagged scope question, not yet taken.
103:title = "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"
104:required_stages = ["doc", "impl", "unit", "int"]  # doc: ADR-0002. impl: user-prompt-submit.sh (skill-injection + drain) + _common.sh. unit: render_frames + sptc_skill_key tests. int: ci/hooks/poll-int.sh — the poll->additionalContext drain round-trip vs live v0.7.1 (<EVENT> envelope, render_frames confirm-match, multi-message). UPS-fires-on-slash empirical = ADR-0002 validation (CC 2.1.177); skill-injection resolve = registration-int 4c.
108:title = "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"
109:required_stages = ["doc", "impl", "unit"]  # doc: docs-site harness-contract §SessionStart briefs. impl: session-start.sh emit-block + _common.sh (sptc_brief/assemble_perch/assemble_noperch/perch_brief/noperch_brief/is_subagent/has_peers_lines/node_has_peers/json_escape/emit_additional_context) + adapter/strings/briefs/*.md + [strings.briefs] in claude-spt.toml. unit: tests/hooks-parse.sh (assemble structure + {id} global subst + is_subagent + peer-gate line-count + json_escape + emit shape/empty). int PENDING: committed brief round-trip script (get-string file-backed resolve -> compose -> valid JSON) — manually proven 2026-06-19 via a renamed local probe adapter (probe-sptc), add "int" when the script lands + the published claude-spt adapter carries [strings.briefs]. NOTE: live-vs-ready flavor DEFERRED (public liveness-query gap, logged w/ doyle, post-v0.13.0 spt whoami --json).
127:title = "A git push hook fires the gates: pings a fleet runner-agent over spt rather than polling"
143:required_stages = ["doc", "impl", "int"]  # doc: docs/CI.md. impl: ci/acceptance/{lib,run-acceptance}.sh + harness unit. int: real `claude -p` spawn asserted UPS-hook-fires green 2026-06-15 under disposable id sptc-ci-40322 (operator perch perri survived — guard proven e2e).
251:title = "The adapter ships the [message-idle-translation-binary] (command=\"{adapter_dir}/claude-spt translate\", spt-core v0.16.0 seam — path deprecated; D3 fold into the consolidated binary) — a lifecycle-managed stdin→stdout JSON-lines filter that turns each idle inbound <EVENT> envelope into the keystroke choreography spt-core applies ATOMICALLY to the broker PTY: ctrl+s (stash any draft) · 50ms · <envelope> (type the text, no trailing CR) · 50ms · {key:enter} (submit the PTY line) · {commit:true} (terminate the inject sequence) — so an inbound message never clobbers a half-typed operator draft (CC auto-restores the stashed draft after submit; no trailing restore keystroke). The submit is a DISCRETE {key:enter}, NOT a trailing \\r in the text: a \\r byte does NOT submit a Claude Code message (corrected 2026-06-23). The trailing {commit} is MANDATORY: spt-core's run_inject_worker (broker.rs:1075-1090) ends a sequence only on {commit}; without it the broker FAULTs at the 5s INJECT_COMMIT_DEADLINE on every delivery (the enter key submits the line, {commit} releases the InjectFloor — two distinct signals). Idle delivery ONLY (the spt-hosted complement to the busy/mid-turn [inject] hook path); coexists with a live `spt rc` controller."
264:title = "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."
265:required_stages = ["doc", "impl", "unit", "int"]  # Feature 2 / task T2 — ACTIVATED 2026-06-24. WIRE-SENTINEL is now spt-core v0.15.0 `spt send --json-payload '{"checkpoint":"v1","wake":…}'` (doyle, grounded in the gated W4 code) — a structured json ATTR on the <EVENT> envelope, NOT the old string-prefix sniff (collision-proof: the marker lives inside the opaque attr, can't forge from/type). doc: full-fat /sptc:commune SKILL.md (--checkpoint: single trigger=default wake, paired triggers bracket a custom wake) + ADR-0004 + the checkpoint [[hints]] entry. impl: cc-spt-idle-translate checkpoint branch (extract json attr -> xml-attr-unescape (& LAST) -> serde parse -> checkpoint=="v1" -> clear+wake macro ctrl+s·50ms·/clear·enter·500ms·wake·enter·commit, default "Proceed with next steps") + NEW PostToolUse hook (manifest [hooks.PostToolUse] + hooks.json Write-matcher + post-tool-use.sh detector: !!checkpoint!! scan of tool_input content -> api state idle -> self-send --json-payload) + _common.sh pure helpers (sptc_has_checkpoint/sptc_checkpoint_wake/sptc_is_commune_write). unit: hooks-parse.sh checkpoint tests + the crate's checkpoint macro/parse/unescape tests. int: ci/idle-translate/translate-proof-int.sh checkpoint block (envelope w/ json attr fires the clear+wake macro, custom + default wake, normal delivery never clears) — the deterministic EMIT half. The LIVE self-send loopback (real `spt send --from <id> <id> --json-payload '{"checkpoint":"v1","wake":…}'` -> json envelope attr -> binary -> /clear+wake) was VALIDATED on-node 2026-06-24 on spt 0.15.0: a spt-hosted CC transcript showed the macro fire E2E (user turn `/clear`, next user turn = the wake). DEFERRED dogfood (NOT CI, logged in CHECKPOINT-COMMUNE-PLAN.md §Deferred validations): the PostToolUse WRITE-DETECT E2E — the hook detecting `!!checkpoint!!` in a Write + self-sending — needs the NEW plugin installed + real CC. The write-side !!checkpoint!! marker is one-shot (spt-core v0.15.0 strips it from durable+pending on ingest). min_spt_core BUMPED to 0.15.0 (manifest v0.7.0). Self-send loopback PROVEN 2026-06-24.
270:required_stages = ["impl", "unit", "int"]  # Parity wiring / task T3 — impl+unit ACTIVATED 2026-06-24 (coded against FROZEN contract B); int ACTIVATED 2026-06-24 when spt-core v0.15.0 (counter 34) PUBLISHED + verified on-node (doyle gate ruling: the CORE-CONTRACT half is deterministic CI, the SessionStart INJECTION E2E is logged dogfood). impl: session-start.sh bind|boundary brief branch -> sptc_psyche_download (mirrors `api poll` id-scoped auth: --session-id, no --project — project resolves from the bound cwd) + sptc_append_resume injects stdout VERBATIM below the identity brief, skip on NO-CONTEXT/empty (pre-v0.15.0 the verb is absent -> empty -> graceful skip). Fires on bind AND boundary — boundary IS the checkpoint re-seed path (post-/clear rebuild from the latest commune); the `api boundary` rotation itself is unchanged (additive pull). unit: tests/hooks-parse.sh sptc_append_resume skip-on-NO-CONTEXT tests. int: ci/psyche-download/psyche-download-int.sh — deterministic capability-detect CI against real spt 0.15.0 (NO real CC: a throwaway perch is bind-created): asserts verb-present + NO-CONTEXT (fresh perch -> exit 0, empty stdout, NO-CONTEXT:<id> stderr) + AUTH (wrong --session-id rejected) + POSITIVE (a not-yet-synthesized commune drop returned VERBATIM as <pending-commune>). GREEN on-node 2026-06-24. DEFERRED dogfood (NOT CI, logged in CHECKPOINT-COMMUNE-PLAN.md §Deferred validations): the SessionStart INJECTION E2E — session-start.sh actually consuming the pull + injecting into a live CC — needs the NEW plugin installed + real CC. No adapter-side raw-drop handling (core owns the race-free append).
274:title = "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."
275:required_stages = ["impl", "unit"]  # Reachability-parity item (F-021) — ACTIVATED 2026-06-25. Two coupled halves: (a) honest TURN-STATE lifecycle — UserPromptSubmit marks `api state busy` at turn-start, Stop marks `api state idle` at turn-end (spt-core never infers activity from quiescence — harness-contract/integration-checklist), so inbound DEFERS during a busy turn instead of idle-PTY-injecting; (b) the NEW PreToolUse hook drains those deferred messages MID-turn (`api poll {session_id} --include-deferred` -> additionalContext), the legacy spt poll+inject-on-PreToolUse reachability. UPS drain also gains --include-deferred. PreToolUse ALSO marks `api state busy` (idempotent) — the ONLY busy mark for a turn that starts WITHOUT a UserPromptSubmit (a Monitor-delivered message triggers a new turn with no user prompt; the first tool call is the earliest turn-active signal). Residual: a non-user text-only turn marks neither (low risk — no tool/PTY interleave; Stop re-idles). impl: plugin/sptc/hooks/pre-tool-use.sh (state busy before the drain) + the `api state busy` add in user-prompt-submit.sh + hooks.json PreToolUse entry + manifest [hooks.PreToolUse] / [hooks.UserPromptSubmit] busy+include-deferred. unit: tests/hooks-parse.sh PreToolUse-reachability wiring block (PreToolUse declared + routed, deferred-drain shape, UPS busy + include-deferred, Stop idle, manifest [hooks.PreToolUse]). int DEFERRED (same bucket as the other harness-glue E2Es — needs the NEW plugin installed + a real live CC): a mid-turn `spt send` to a busy live agent surfaces as additionalContext on the next tool call. `api state` + `api poll --include-deferred` are already on the public surface — adapter wiring, NOT an spt-core gap. Shapes verified against the published integration-checklist 2026-06-25.
305:required_stages = ["doc", "impl", "unit"]  # U4 — ACTIVATED 2026-06-25. Design Q RESOLVED: /sptc:live upgrades the session IN-PLACE (bare `spt api listen`), no Claude Code restart -> SessionStart does NOT re-fire; so the go-live guidance rides the /sptc:live UPS body, the perched brief only fires on bind/boundary. Both carry commune/signoff now. doc: thinned plugin/sptc/skills/{commune,send,signoff}/SKILL.md stubs ([doc->REQ-DIST-SKELETON-THIN]) + new adapter/strings/briefs/live-ops.md (commune incl --checkpoint + signoff) + the expanded go-live body adapter/strings/skills/live.md. impl: _common.sh sptc_perch_brief composes the live-ops brief into the perched SessionStart brief (appended to the messaging block — keeps sptc_assemble_perch + its test unchanged) + [strings.briefs].live-ops registration. unit: tests/hooks-parse.sh U4 block (live-ops carries commune+checkpoint+signoff, perch-brief composes it, manifest registers it, go-live body inlines it, the 3 SKILL.md are thin stubs, the pre-U4 full bodies removed). int DEFERRED: the perched brief carrying live-ops resolves through the live registry — folds into the REQ-DIST-SESSIONSTART-BRIEF brief round-trip int (still PENDING) once the published adapter carries briefs.live-ops. send's core (send/reply) already rode the messaging-perch brief; commune/signoff were full-fat in the plugin SKILL.md (the 2026-06-19 decision U4 supersedes). NB: SKILL.md + hooks (PreToolUse) changes are structural -> ONE cplugs skeleton bump at the v0.8.0 cut.


Publish is doable (gh as SaberMage, repo+workflow scopes). Need new REQ for the hook-binary port. Read publish mechanics (rest of runbook + ci/publish).


Bash

sed -n '60,210p' docs/RELEASE-RUNBOOK.md

Read runbook publish mechanics


<!-- [doc->REQ-DIST-PLUGIN-SKELETON] -->
The concrete cplugs marketplace steps, captured from the sister project's
`claude_skill_owl/docs/DEPLOY.md`. **Take only the SKELETON SUBSET.** spt-claude-code's cplugs
target is the **thin skeleton** — `/spt:*` skill skeletons, `hooks.json`, the SessionStart
bootstrap, `plugin.json`. **No binary, no manifest in cplugs** — the binary + adapter manifest
ride the **spt-core adapter registry** (spt-conducted; the *other* publish target above), so
none of owl's binary-lifecycle machinery applies here.

> **Now scripted (`ci/publish/`).** The validate + stage mechanics below are codified:
> `ci/publish/validate-skeleton.sh` is a binary installability gate (valid `plugin.json` with
> `name=sptc`; valid `hooks.json` whose referenced wrappers all exist; every skill has a
> `SKILL.md`; **no runtime-state / binary / manifest leak in the published surface**) — it runs in
> `ci/run-gates.sh` (the `skeleton-validate` gate) and is unit-tested by `tests/skeleton-validate.sh`.
> `ci/publish/package-skeleton.sh` codifies the per-bump copy below — validates first, **dry-run by
> default**, stages only the skeleton subset, never pushes (the marketplace commit/push stays the
> operator's step).

**One-time marketplace setup:**

```bash
# Clone the marketplace repo
git clone https://github.com/SaberMage/cplugs.git ~/.claude/plugins/marketplaces/cplugs
# Register a "cplugs" entry in ~/.claude/plugins/known_marketplaces.json
#   source: { source: "github", repo: "SaberMage/cplugs" }
```

**Per skeleton bump** (only when the skeleton *structurally* changes — see "Per release"):

```bash
# 1. Bump plugin.json version. The plugin manager compares this to the cached copy and
#    SKIPS the update entirely if they match — a stale version = a silent no-update.
# 2. Copy skeleton files (NO binary) into the marketplace clone:
MARKET=~/.claude/plugins/marketplaces/cplugs/plugins/sptc
cp -r skills/*  "$MARKET/skills/"
cp -r hooks/*   "$MARKET/hooks/"
cp .claude-plugin/plugin.json "$MARKET/.claude-plugin/"
# 3. Commit + push the cplugs repo:
cd ~/.claude/plugins/marketplaces/cplugs && git add plugins/sptc/ \
  && git commit -m "sptc: <change>" && git push
```

**Pointer flip** (the authoritative install-state update):

```bash
claude plugin install sptc@cplugs   # un-orphans + rewrites installed_plugins.json atomically
# then, inside Claude Code:
/reload-plugins
```

**Gotchas (these DO apply to the skeleton):**
- **Never hand-patch `installed_plugins.json`** (`jq`/`sed` are brittle; CC may silently reject).
  Use the `claude plugin install` CLI — it's the atomic pointer flip + orphan-marker cleanup.
- **No legacy manual-install dir alongside the marketplace install.** A `~/.claude/plugins/<name>/`
  regular dir coexisting with the marketplace cache install makes `/plugin` report the plugin
  **not installed** (CC enumerates both; the untracked one wins the conflict). Keep the two exclusive.
- **Restart the Claude Code session after install** so the SessionStart hook re-runs and repopulates
  the plugin's env (`$OWL`/`$LIVE` equivalents) in Bash subprocesses.

**Explicitly NOT ours** (owl ships a live self-migrating binary; the thin skeleton has none, so
skip all of it): `owl.exe` binary sync · the cache targeted-prune + keep-PREVIOUS-version logic ·
the seamless binary handoff (owl Phase 18.4/18.5) · `DEPLOY.ps1`'s build/handoff orchestration.

> **RULED (operator, 2026-06-14) — cplugs plugin name = `sptc`.** CC ties the skill prefix to the
> `plugin.json` name (no override), so skills surface as `/sptc:*`. **Succession:** once parity is
> proven, flip `plugin.json` name `sptc`→`spt` (skills → `/spt:*`) and retire/rename legacy owl's
> cplugs `spt` plugin in the same coordinated move — two plugins cannot share the `spt` name. The
> flip is a single substitution `s/sptc/spt/` (plugin dir, `plugin.json` name, marketplace path).

## adapter manifest publish — the `adapter.spt` release asset

<!-- [doc->REQ-DIST-ADAPTER-RELEASE] -->
The CC adapter manifest (publish target #2) ships to end users as an **`adapter.spt`** GitHub
**release asset** on this monorepo, acquired with **`spt adapter add --release SaberMage/claude-spt`**
(doyle's `--release` source; needs **spt v0.7.3+ / counter 15** — not in 0.7.2). No dedicated
adapter repo: the asset is packed straight from `adapter/`.

Since v0.6.1 the asset is **ONE multi-platform fat `adapter.spt`** (ADR-0024 W1, spt-core ≥ 0.13.2):
a single archive bundles every supported platform's binaries, and install auto-resolves the host's.
This RETIRED the F-014 per-OS stopgap — there is no longer a default-vs-per-OS-asset split.

- **Pack:** `sh ci/publish/package-adapter.sh` (DRY-RUN) → `--apply` writes the single
  `dist/adapter.spt`. It validates the manifest, refuses a `min_spt_core_version < 0.13.2` (a fat
  archive needs it), requires **both** platforms' built tool binaries, and tars the archive **ROOT** =
  `manifest.toml` (renamed from `claude-spt.toml` — `adapter add` is root-only + exact-name) +
  `strings/` **shared at root**, plus each recognized target-triple's binaries under a `<triple>/` dir.
  Never uploads (operator's step).
<!-- [doc->REQ-DIST-ADAPTER-PEROS] -->
- **Fat layout (REQ-DIST-ADAPTER-PEROS).** Recognized triples = **`x86_64-pc-windows-msvc`** (win
  `.exe`) + **`x86_64-unknown-linux-gnu`** (linux ELF) — the ONLY two spt-core classifies in a fat
  archive. On install it places the shared root + **flattens this node's `<triple>/*` into the install
  dir**, so the bare-name command token (`claude-spt` — its digest/psyche/post-update/translate subcommands) still resolves at `<install_dir>/`
  (REQ-INSTALL-11). **Footgun:** an unrecognized top-level dir is silently treated as a shared-root
  entry and lands flat — the packer guards this (refuses any stray top-level dir); for a platform
  beyond the two triples, ship a *separate* single-triple asset via `--asset`, never a third dir here.
- **Build both platforms first** (the packer needs both binary sets present):
  - **Windows (native):** `sh ci/digest/build.sh && sh ci/idle-translate/build.sh` →
    `tools/*/target/release/*.exe`. (`ci/digest/build.sh` builds the consolidated `claude-spt` crate
    — digest/psyche/post-update; `ci/psyche/build.sh` is a shim that defers to it — ADR-0006/U2.)
  - **Linux cross-built FROM Windows (proven 2026-06-16):** bare `cargo build --target
    x86_64-unknown-linux-gnu` fails (`error: linker 'cc' not found` — the crate compiles, only the
    link needs a Linux linker), so use **`cargo-zigbuild`** (zig supplies the cross-linker):
    ```sh
    # one-time: install zig (a self-contained binary) + cargo-zigbuild
    cargo install cargo-zigbuild
    # download zig (e.g. 0.14.1) to a dir OUTSIDE the repo (do NOT keep it under tools/ — it's a
    # traceable-reqs scan root and zig's bundled libc C-headers trip the scanner), put zig.exe on PATH:
    #   e.g. ~/.sptc-zig/zig-x86_64-windows-0.14.1/zig.exe  →  export PATH="$HOME/.sptc-zig/...:$PATH"
    rustup target add x86_64-unknown-linux-gnu
    cargo zigbuild --release --target x86_64-unknown-linux-gnu --manifest-path tools/claude-spt/Cargo.toml
    ```
    → `tools/claude-spt/target/x86_64-unknown-linux-gnu/release/claude-spt` carrying a real
    `ELF x86-64 GNU/Linux` binary (verified). Then `sh ci/publish/package-adapter.sh --apply` packs the
    win + linux binaries into one `dist/adapter.spt` (ONE tool binary per triple since the D3 fold).
- **Publish:** attach the single `dist/adapter.spt` to the GitHub release on `SaberMage/claude-spt`.
  End users `spt adapter add --release SaberMage/claude-spt` (default asset `adapter.spt`, or
  `--tag <ver>` to pin) — the fat archive auto-resolves the host's binaries, no `--asset` needed. The
  `[update] avenue = "gh_release"` self-update fetches the same default `adapter.spt`, host-agnostic.
  - **F-015 note (Windows):** a binary-changing update still can't overwrite a binary a running live
    agent locks — but spt-core ≥ 0.13.2's W3 fix runs the live psyche from a `<perch>/.live-bin`
    own-copy, so the install-dir binary stays lock-free (validated 2026-06-22). Strings/hook-only
    releases always land fine.
  - **Local dogfood before upload (recommended):** extract `dist/adapter.spt`, flatten this node's
    triple into a dir (`cp <triple>/* .`), then `spt adapter {translate,digest}-proof claude-spt --dir
    <dir> --manifest <dir>/manifest.toml` — proves the flattened binaries resolve + run without any
    upload or registry mutation.
  - **`/sptc:setup` coupling.** The fat default is host-agnostic, so setup needs no os-detection /
    `--asset` — a bare `adapter add --release` suffices. (The current setup body still detects os/arch
    + passes `--asset`; harmless, simplifying it to the bare default is a follow-on.)
- **Binary (install-dir resolution — REQ-INSTALL-11).** A `--release`/`--github` acquisition extracts
  the `.spt` archive's `manifest.toml` + `strings/` **+ the tool binary** into the adapter install dir
  (`…/adapters/_github/<safe>/`). The command templates reference `claude-spt` (digest/psyche/post-update
  via the command strings; translate via `{adapter_dir}/claude-spt translate`), which spt-core resolves
  **from that install dir** (v0.8.0 Feature B / REQ-INSTALL-11) — **no PATH placement needed**
  (dogfood-proven on v0.8.1; F-006 RESOLVED, interim retired). The pack MUST carry the binary (the
  resolution source). Note:
  a plain local-dev `spt adapter add <manifest.toml>` copies manifest + strings only (no binaries in the
  install dir), so local-dev runtime still relies on the built binary being on PATH (e.g. the CI ints
  prepend `target/release`); the install-dir path is the end-user `--release` story.
- **Acquisition only:** `--release` does **not** establish an `[update]` self-update route (our
  manifest declares no `[update]` → COPY-mode registration). Re-acquire a newer version by re-running
  `--release --tag <newer>`. The signed `file_pull` self-update channel is a separate, later concern.

## Update path (dual, kept in sync)

Users receive updates two ways, and both must stay consistent:
- **File-pull (the real channel)** — spt-core conducts manifest + binary updates seamlessly;
  this is how logic and instruction churn reaches users. Version-of-truth = the
  manifest/binary version spt-core tracks (`spt adapter list` / `/spt:version`).
- **`claude plugin update` (cautionary, skeleton sync)** — only resyncs the low-churn
  marketplace skeleton on the rare structural bump. It is NOT the version-of-truth.


List ci/publish scripts


total 32
drwxr-xr-x 1 decid 197609    0 Jun 26 03:00 .
drwxr-xr-x 1 decid 197609    0 Jun 24 05:00 ..
-rwxr-xr-x 1 decid 197609 7327 Jun 26 03:00 package-adapter.sh
-rwxr-xr-x 1 decid 197609 2427 Jun 15 01:49 package-skeleton.sh
-rwxr-xr-x 1 decid 197609 3775 Jun 25 04:51 release-acquire-int.sh
-rwxr-xr-x 1 decid 197609 4600 Jun 15 01:50 validate-skeleton.sh


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


Full build+publish path understood. Let me check the build gate (does it run cargo test?) + current shell-test surface I'm replacing.


echo "=== ci/digest/build.sh ==="; cat ci/digest/build.sh; echo "=== hooks-parse.sh (structure) ==="; grep -n "^[a-z_]*()\|^t_\|^check\|sptc_\|render_frames\|json_str\|describe\|^echo\|PASS\|assert" tests/hooks-parse.sh | head -40; echo "=== line count ==="; wc -l tests/hooks-parse.sh

Read build gate + hooks-parse test structure


=== ci/digest/build.sh ===
#!/bin/sh
# Build + unit-test the CONSOLIDATED claude-spt tool crate (Rust) — the single binary carrying the
# `digest` / `psyche` / `post-update` subcommands (ADR-0006, U2; was three crates). The crate's cargo
# tests ARE the unit coverage for all three: the digest CC-JSONL->NDJSON mapping (drop cases, UTF-8,
# contract invariants), the psyche arg/command construction, the post-update CLI detection + plugin/
# marketplace reconciliation, and the subcommand dispatch. This is the canonical build of the binary;
# ci/psyche/build.sh defers to it (no redundant cargo run). SKIP if cargo is absent so a host without
# the Rust toolchain announces it rather than silently passing (no silent caps).
# [impl->REQ-DIST-BINARY-CONSOLIDATE]
# [impl->REQ-DIST-DIGEST-EXTRACTOR]
# [impl->REQ-SKILL-LIVE]
set -u
ROOT=$(CDPATH= cd "$(dirname "$0")/../.." && pwd)
CRATE="$ROOT/tools/claude-spt/Cargo.toml"

if ! command -v cargo >/dev/null 2>&1; then
  echo "SKIP: cargo not on PATH (Rust toolchain needed to build/test claude-spt)"
  exit 0
fi

cargo test  --quiet --manifest-path "$CRATE" || { echo "FAIL: cargo test"; exit 1; }
cargo build --release --quiet --manifest-path "$CRATE" || { echo "FAIL: cargo build --release"; exit 1; }
echo "ok  claude-spt: cargo test + release build (digest/psyche/post-update subcommands)"
=== hooks-parse.sh (structure) ===
7:check() { # check  <expected> <actual>
12:# --- json_str: top-level CC hook-input fields ---  [unit->REQ-DIST-HOOKS-API]
14:check "json_str session_id" "sess-abc123" "$(json_str "$J" session_id)"
15:check "json_str source"     "startup"     "$(json_str "$J" source)"
16:check "json_str prompt"      "/sptc:send doyle" \
17:  "$(json_str '{"session_id":"s1","prompt":"/sptc:send doyle"}' prompt)"
18:check "json_str missing key" "" "$(json_str "$J" nope)"
20:# --- sptc_unescape:
 -> newline, entities, & LAST --- [unit->REQ-UPS-INJECTION]
21:check "unescape br+entities" \
23:  "$(sptc_unescape 'a <b>
"c" & &lt;')"
25:# --- render_frames: canonical <EVENT> envelope --- [unit->REQ-UPS-INJECTION]
27:named=$(render_frames '<EVENT type="msg" from="doyle">hello
world</EVENT>')
28:check "render named <EVENT>" \
29:  "$(printf '<sptc_messages from="doyle">\nhello\nworld\n</sptc_messages>')" "$named"
32:ent=$(render_frames '<EVENT type="msg" from="kit">a <tag> & b</EVENT>')
33:check "render entity body" \
34:  "$(printf '<sptc_messages from="kit">\na <tag> & b\n</sptc_messages>')" "$ent"
37:multi=$(render_frames '<EVENT type="msg" from="a">one</EVENT><EVENT type="msg" from="b">two</EVENT>')
38:check "render multi-message" \
39:  "$(printf '<sptc_messages from="a">\none\n</sptc_messages>\n<sptc_messages from="b">\ntwo\n</sptc_messages>')" \
43:anon=$(render_frames '<EVENT type="msg">sys note</EVENT>')
44:check "render no-from" "$(printf '<sptc_messages>\nsys note\n</sptc_messages>')" "$anon"
47:check "render empty" "" "$(render_frames '')"
49:# --- sptc_skill_key: detect /sptc:<skill> in a slash-command prompt --- [unit->REQ-UPS-INJECTION]
50:check "skill_key bare"        "ready"  "$(sptc_skill_key '/sptc:ready')"
51:check "skill_key with args"   "send"   "$(sptc_skill_key '/sptc:send doyle hi')"
52:check "skill_key leading ws"  "setup"  "$(sptc_skill_key '   /sptc:setup')"
53:check "skill_key hyphenated"  "list-agents" "$(sptc_skill_key '/sptc:list-agents')"
54:check "skill_key mid-prose"   ""       "$(sptc_skill_key 'please run /sptc:ready for me')"
55:check "skill_key other ns"    ""       "$(sptc_skill_key '/other:ready')"
56:check "skill_key not a cmd"   ""       "$(sptc_skill_key 'ready set go')"
57:check "skill_key empty"       ""       "$(sptc_skill_key '')"
59:# --- sptc_register_verb: SessionStart bind/seed/boundary branch --- [unit->REQ-DIST-SHORTCUT-BASENAME]
62:check "verb harness-hosted startup"   "seed"     "$(sptc_register_verb startup)"
63:check "verb harness-hosted empty src" "seed"     "$(sptc_register_verb '')"
64:check "verb boundary clear"           "boundary" "$(sptc_register_verb clear)"
65:check "verb boundary compact"         "boundary" "$(sptc_register_verb compact)"
67:check "verb spt-hosted startup -> bind" "bind"     "$(sptc_register_verb startup)"
69:check "verb spt-hosted clear -> boundary" "boundary" "$(sptc_register_verb clear)"
72:# --- sptc_cap_output: additionalContext spill guard (ADR-0002 Open #2) --- [unit->REQ-UPS-INJECTION]
=== line count ===
218 tests/hooks-parse.sh


Have everything needed. Writing JIT plan, then building. This is a large but fully-specified milestone — design locked by ADR-0006 + doyle ask #1 resolution; both v0.16.0 primitives verified live.


Write

# v0.9.0 — D1: hook logic → `claude-spt hook <event>` (static-forever hooks.json)

> JIT plan. The DEFERRED-D1 milestone (UNIFY-CONSOLIDATE-PLAN §D1 / ADR-0006 ask #1).
> Goal: move all hook **logic** out of the cplugs plugin shell and into the consolidated
> `claude-spt` binary, so hook churn rides `spt adapter update` (NOT a cplugs republish).
> The plugin `hooks.json` + a thin dispatch wrapper become **static-forever**.

## Design (locked — ADR-0006, UPDATE-NAMING-DOYLE-ASKS Ask 1 → RESOLVE-NOT-EXECUTE)

spt-core stays a pure resolver (executes no new thing). Two v0.16.0 primitives carry D1 — BOTH
verified live on this node's spt 0.16.0 (throwaway probe-d1 adapter, 2026-06-28):
- **(a) `{adapter_dir}` / `{adapter_name}` substitution keys.** `get-string` of a `[strings]` value
  containing `{adapter_dir}` returns the install dir path. Confirmed: `hook_cmd = "{adapter_dir}/claude-spt hook"`
  → `<install_dir>/claude-spt hook`.
- **(b) lazy substitution inside `[strings]` at `get-string` read time** (adapter-static keys only;
  session-scoped `{session_id}` NOT available there — hooks still read those from CC stdin).

### Components

1. **`tools/claude-spt/src/hook.rs`** (NEW) — `claude-spt hook <event>` subcommand. Reads the event
   name from argv + the CC hook payload JSON from stdin; shells `spt api …`; emits additionalContext.
   Faithful port of all 8 hook wrappers + `_common.sh` (pure helpers → pure fns with unit tests;
   impure resolvers → `spt`-spawning fns). **Behaviour-preserving refactor**: same `spt api` command
   lines, same stdout contract per event (SessionStart = `hookSpecificOutput` JSON; UPS/PreToolUse =
   raw rendered text), same no-op-when-no-perch.
2. **`tools/claude-spt/src/main.rs`** — add `Sub::Hook`; dispatch `hook` → `hook::run()`.
3. **`plugin/sptc/hooks/dispatch.sh`** (NEW, static-forever) — the only plugin-resident hook shell.
   - SessionStart only: run `bootstrap.sh` first (invisible installer — the binary CANNOT exist before
     spt-core + the adapter are installed), then cache the resolved bin to `$CLAUDE_ENV_FILE`.
   - Resolve bin: `$SPTC_HOOK_BIN` (cached) → else `spt adapter get-string claude-spt hook_cmd`.
   - No-op (exit 0) if unresolvable (adapter not registered yet = pre-/sptc:setup window).
   - Pass `--host-pid "$PPID"` (the seed pid — Rust std has no portable getppid on Windows) and the
     event; the binary reads CC stdin directly (dispatch does NOT cat, so stdin passes through clean).
4. **`plugin/sptc/hooks/hooks.json`** — static-forever: every event → `sh dispatch.sh <event>`.
5. **`adapter/claude-spt.toml`** — add `[strings] hook_cmd = "{adapter_dir}/claude-spt hook"`;
   version → 0.9.0; min_spt_core stays 0.16.0 (primitives already shipped there); `[hooks.*]` comments
   note the binary handler.
6. **Delete** the 8 `*.sh` hook wrappers + `_common.sh` (logic now in the binary). **Keep** `bootstrap.sh`.
7. **Tests** — port `tests/hooks-parse.sh` coverage into `hook.rs` `#[cfg(test)]` (runs under
   `cargo test`, the digest-extractor gate). Replace `tests/hooks-parse.sh` with a thin
   `tests/hooks-dispatch.sh` (hooks.json static shape + dispatch wrapper sh -n + referenced files exist).
8. **traceable-reqs.toml** — add `REQ-DIST-HOOK-BINARY` (doc/impl/unit), activate; retag the migrated
   REQ evidence (REQ-DIST-HOOKS-API / REQ-UPS-INJECTION / REQ-DIST-* unit tags now point at hook.rs).
9. **cplugs skeleton** — STRUCTURAL change (hooks.json rewrite + .sh removal + dispatch.sh add) →
   `plugin.json` sptc 0.1.7 → 0.1.8.

### Bootstrap / no-op invariant (the one behavioural nuance)

A session that ran the spt-core bootstrap but is BEFORE `/sptc:setup` (adapter not yet registered)
has no `{adapter_dir}/claude-spt` → dispatch no-ops. Today's shell `api seed` would run there; the
binary path won't. Acceptable: a seed for an UNregistered adapter is unusable (ready/live resolve the
adapter by host_binaries from the registered set). Next session post-setup seeds normally. No real-world
regression (the perch was already a no-op pre-readiness today).

## Gate

`sh ci/run-gates.sh` green (shell-syntax, cargo test+build incl. new hook tests, traceable-reqs check,
skeleton-validate, manifest-schema) + a local idle/digest-proof dogfood of the repacked binary.

## Release (see it through — docs/RELEASE-RUNBOOK.md)

CHANGELOG `## [0.9.0]` · build win + linux (zigbuild) · `package-adapter.sh --apply` · gh release
v0.9.0 on SaberMage/claude-spt with `dist/adapter.spt` · cplugs sptc 0.1.8 push. gh = SaberMage
(repo+workflow scopes) — publish is doable from this node.

## Status: IN PROGRESS (started 2026-06-28, perri)


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

# v0.9.0 — D1: hook logic → `claude-spt hook <event>` (static-forever hooks.json)

> JIT plan. The DEFERRED-D1 milestone (UNIFY-CONSOLIDATE-PLAN §D1 / ADR-0006 ask #1).
> Goal: move all hook **logic** out of the cplugs plugin shell and into the consolidated
> `claude-spt` binary, so hook churn rides `spt adapter update` (NOT a cplugs republish).
> The plugin `hooks.json` + a thin dispatch wrapper become **static-forever**.

## Design (locked — ADR-0006, UPDATE-NAMING-DOYLE-ASKS Ask 1 → RESOLVE-NOT-EXECUTE)

spt-core stays a pure resolver (executes no new thing). Two v0.16.0 primitives carry D1 — BOTH
verified live on this node's spt 0.16.0 (throwaway probe-d1 adapter, 2026-06-28):
- **(a) `{adapter_dir}` / `{adapter_name}` substitution keys.** `get-string` of a `[strings]` value
  containing `{adapter_dir}` returns the install dir path. Confirmed: `hook_cmd = "{adapter_dir}/claude-spt hook"`
  → `<install_dir>/claude-spt hook`.
- **(b) lazy substitution inside `[strings]` at `get-string` read time** (adapter-static keys only;
  session-scoped `{session_id}` NOT available there — hooks still read those from CC stdin).

### Components

1. **`tools/claude-spt/src/hook.rs`** (NEW) — `claude-spt hook <event>` subcommand. Reads the event
   name from argv + the CC hook payload JSON from stdin; shells `spt api …`; emits additionalContext.
   Faithful port of all 8 hook wrappers + `_common.sh` (pure helpers → pure fns with unit tests;
   impure resolvers → `spt`-spawning fns). **Behaviour-preserving refactor**: same `spt api` command
   lines, same stdout contract per event (SessionStart = `hookSpecificOutput` JSON; UPS/PreToolUse =
   raw rendered text), same no-op-when-no-perch.
2. **`tools/claude-spt/src/main.rs`** — add `Sub::Hook`; dispatch `hook` → `hook::run()`.
3. **`plugin/sptc/hooks/dispatch.sh`** (NEW, static-forever) — the only plugin-resident hook shell.
   - SessionStart only: run `bootstrap.sh` first (invisible installer — the binary CANNOT exist before
     spt-core + the adapter are installed), then cache the resolved bin to `$CLAUDE_ENV_FILE`.
   - Resolve bin: `$SPTC_HOOK_BIN` (cached) → else `spt adapter get-string claude-spt hook_cmd`.
   - No-op (exit 0) if unresolvable (adapter not registered yet = pre-/sptc:setup window).
   - Pass `--host-pid "$PPID"` (the seed pid — Rust std has no portable getppid on Windows) and the
     event; the binary reads CC stdin directly (dispatch does NOT cat, so stdin passes through clean).
4. **`plugin/sptc/hooks/hooks.json`** — static-forever: every event → `sh dispatch.sh <event>`.
5. **`adapter/claude-spt.toml`** — add `[strings] hook_cmd = "{adapter_dir}/claude-spt hook"`;
   version → 0.9.0; min_spt_core stays 0.16.0 (primitives already shipped there); `[hooks.*]` comments
   note the binary handler.
6. **Delete** the 8 `*.sh` hook wrappers + `_common.sh` (logic now in the binary). **Keep** `bootstrap.sh`.
7. **Tests** — port `tests/hooks-parse.sh` coverage into `hook.rs` `#[cfg(test)]` (runs under
   `cargo test`, the digest-extractor gate). Replace `tests/hooks-parse.sh` with a thin
   `tests/hooks-dispatch.sh` (hooks.json static shape + dispatch wrapper sh -n + referenced files exist).
8. **traceable-reqs.toml** — add `REQ-DIST-HOOK-BINARY` (doc/impl/unit), activate; retag the migrated
   REQ evidence (REQ-DIST-HOOKS-API / REQ-UPS-INJECTION / REQ-DIST-* unit tags now point at hook.rs).
9. **cplugs skeleton** — STRUCTURAL change (hooks.json rewrite + .sh removal + dispatch.sh add) →
   `plugin.json` sptc 0.1.7 → 0.1.8.

### Bootstrap / no-op invariant (the one behavioural nuance)

A session that ran the spt-core bootstrap but is BEFORE `/sptc:setup` (adapter not yet registered)
has no `{adapter_dir}/claude-spt` → dispatch no-ops. Today's shell `api seed` would run there; the
binary path won't. Acceptable: a seed for an UNregistered adapter is unusable (ready/live resolve the
adapter by host_binaries from the registered set). Next session post-setup seeds normally. No real-world
regression (the perch was already a no-op pre-readiness today).

## Gate

`sh ci/run-gates.sh` green (shell-syntax, cargo test+build incl. new hook tests, traceable-reqs check,
skeleton-validate, manifest-schema) + a local idle/digest-proof dogfood of the repacked binary.

## Release (see it through — docs/RELEASE-RUNBOOK.md)

CHANGELOG `## [0.9.0]` · build win + linux (zigbuild) · `package-adapter.sh --apply` · gh release
v0.9.0 on SaberMage/claude-spt with `dist/adapter.spt` · cplugs sptc 0.1.8 push. gh = SaberMage
(repo+workflow scopes) — publish is doable from this node.

## Status: IN PROGRESS (started 2026-06-28, perri)


Plan written. Now add the REQ to the registry. Read the REQ-DIST-HOOKS-API block + format.


1	# traceable-reqs manifest for spt-claude-code — SEED.
2	#
3	# Authoritative requirement registry: every requirement and every KNOWN-HAZARDS
4	# invariant lives here as a REQ-* id (see docs/TRACEABILITY.md for the full contract).
5	#
6	# SEED YOUR REQs FROM SCOPE.md "Parity-trim" — the DROP / TRANSFORM / KEEP / ADD buckets are
7	# the parity reference. Mint a REQ-* per surface you port/transform/add as you START each
8	# milestone (NOT up front). The registry below is intentionally EMPTY (commented shapes only).
9	#
10	# ACQUIRE the CLI (it is a released binary, not a crate dependency):
11	#   gh release download --repo BigscreenVR/traceable-reqs \
12	#     --pattern '*<your-platform>' --output traceable-reqs   # then chmod +x
13	# (see INSTANTIATE.md for per-platform patterns + CI wiring.) Validate this seed against
14	# the installed CLI before relying on it: `traceable-reqs check`.
15	#
16	# THE FOUR-STAGE CONTRACT: every requirement is traced from
17	#   doc  (prose/design)  ->  impl (production code)  ->  unit (unit test)  ->  int (cross-process/node)
18	# Tag evidence in the SAME commit, on/above the real evidence:
19	#   // [impl->REQ-FOO]   // [unit->REQ-FOO]   <!-- [doc->REQ-FOO] -->
20	#
21	# ACTIVATION MODEL: every requirement starts inactive (`required_stages = []`) so `check`
22	# stays green pre-code. A milestone ACTIVATES its requirements by setting their real
23	# `required_stages` when work on them begins. Deferred items stay [] until promoted.
24	# Never declare the full end-state stage list up front — activate incrementally as evidence
25	# lands (add "int" at the final integrating wave).
26
27	[scan]
28	# Every evidence location. A missing root makes evidence SILENTLY vanish from the trace —
29	# audit this whenever a new evidence dir appears. ADD `plugin`, `skills`, `hooks`, `tests`
30	# the moment those dirs are created (skeleton milestone, body #2) — until they exist a
31	# nonexistent root is a hard manifest_error, so only list what is on disk now.
32	roots = ["plugin", "ci", "ci/acceptance", "ci/publish", "ci/docs", "ci/manifest", "ci/digest", "ci/idle-translate", "adapter", "tools", "tests", "docs", "docs-site", "CONTEXT.md", "SCOPE.md", "AGENTS.md", "HANDOFF.md", "README.md"]
33
34	[policy]
35	# Default policy applied at activation: doc + impl + unit. Networking/lifecycle/cross-node
36	# reqs add "int". Until a req is activated, its per-req `required_stages = []` keeps it out
37	# of the coverage gate.
38	required_stages = []
39
40	# ───────────────────────────── Requirements ─────────────────────────────────
41	# Add requirements as you scope milestones, seeded from SCOPE.md "Parity-trim". Shape:
42	#
43	# [[requirements]]
44	# id = "REQ-EXAMPLE-1"
45	# title = "One-line statement of the requirement"
46	# required_stages = []  # activate (e.g. ["doc","impl","unit"]) when the milestone starts
47	#
48	# KNOWN-HAZARDS invariants are first-class requirements — one REQ-HAZARD-* per invariant,
49	# each requiring at least "unit" (add "int" where cross-process/cross-node):
50	#
51	# [[requirements]]
52	# id = "REQ-HAZARD-EXAMPLE"
53	# title = "The invariant, stated so a test can prove it"
54	# required_stages = []
55
56	# ── Milestone: SKELETON (thin cplugs plugin) — minted at kickoff, per-milestone (not
57	# the whole registry up front). All inactive (required_stages = []) so `check` stays
58	# green until body #2 lands evidence and flips each to ["doc"] / ["doc","impl"] etc.
59	# Seeded from SCOPE.md "Parity-trim" (Distribution + ADD buckets).
60
61	[[requirements]]
62	id = "REQ-DIST-PLUGIN-SKELETON"
63	title = "Marketplace artifact on cplugs is a thin skeleton: namespaced /sptc:* skill stubs + hooks.json + plugin.json, no embedded logic"
64	required_stages = ["doc", "impl", "unit"]  # doc: ADR-0001 + RELEASE-RUNBOOK. impl: ci/publish/{validate,package}-skeleton.sh (installability gate + publish mechanic). unit: tests/skeleton-validate.sh (pass real + fail tampered).
65
66	[[requirements]]
67	id = "REQ-DIST-BOOTSTRAP-INSTALL"
68	title = "SessionStart bootstrap installs spt-core when absent (invisible-installer pattern)"
69	required_stages = ["doc", "impl"]  # doc: ADR-0001. impl: plugin/sptc/bootstrap.{sh,ps1}.
70
71	[[requirements]]
72	id = "REQ-DIST-HOOKS-API"
73	title = "hooks.json delegates to `spt api` rather than carrying adapter logic"
74	required_stages = ["doc", "impl", "unit", "int"]  # doc: ADR-0001/0002. impl: hooks.json + wrappers. unit: tests/hooks-parse.sh. int: ci/hooks/poll-int.sh — `spt api poll` on live v0.7.1 emits the canonical <EVENT> envelope, confirm-matched through render_frames; __REPLY_TO__ relic gone, multi-drain splits on </EVENT>. 5/5 green 2026-06-15 (throwaway perch, torn down).
75
76	[[requirements]]
77	id = "REQ-DIST-MANIFEST-SCHEMA"
78	title = "CC adapter manifest validates against spt-core's published manifest.schema.json (from spt-releases)"
79	required_stages = ["doc", "impl", "unit", "int"]  # doc: ADR-0001 (manifest layer + version-of-truth). impl: adapter/claude-spt.toml + vendored adapter/manifest.schema.json + ci/manifest/{validate_manifest.py,check-manifest.sh} (gate). unit: tests/manifest-schema.sh (pass real + fail tampered). int: ci/manifest/registration-int.sh — real `spt adapter add` (v0.7.0) cross-field registration-validation accepted the manifest, shipped profile resolved, [strings] read + overlay observable; 6/6 green 2026-06-15 (registry left clean via soft-remove). Cross-field discovery: [digest] requires source-or-[history].locate_template (not in JSON schema) — fixed with source="{home}/.claude/projects".
80
81	[[requirements]]
82	id = "REQ-DIST-SHORTCUT-BASENAME"

# traceable-reqs manifest for spt-claude-code — SEED.
#
# Authoritative requirement registry: every requirement and every KNOWN-HAZARDS
# invariant lives here as a REQ-* id (see docs/TRACEABILITY.md for the full contract).
#
# SEED YOUR REQs FROM SCOPE.md "Parity-trim" — the DROP / TRANSFORM / KEEP / ADD buckets are
# the parity reference. Mint a REQ-* per surface you port/transform/add as you START each
# milestone (NOT up front). The registry below is intentionally EMPTY (commented shapes only).
#
# ACQUIRE the CLI (it is a released binary, not a crate dependency):
#   gh release download --repo BigscreenVR/traceable-reqs \
#     --pattern '*<your-platform>' --output traceable-reqs   # then chmod +x
# (see INSTANTIATE.md for per-platform patterns + CI wiring.) Validate this seed against
# the installed CLI before relying on it: `traceable-reqs check`.
#
# THE FOUR-STAGE CONTRACT: every requirement is traced from
#   doc  (prose/design)  ->  impl (production code)  ->  unit (unit test)  ->  int (cross-process/node)
# Tag evidence in the SAME commit, on/above the real evidence:
#   // [impl->REQ-FOO]   // [unit->REQ-FOO]   <!-- [doc->REQ-FOO] -->
#
# ACTIVATION MODEL: every requirement starts inactive (`required_stages = []`) so `check`
# stays green pre-code. A milestone ACTIVATES its requirements by setting their real
# `required_stages` when work on them begins. Deferred items stay [] until promoted.
# Never declare the full end-state stage list up front — activate incrementally as evidence
# lands (add "int" at the final integrating wave).

[scan]
# Every evidence location. A missing root makes evidence SILENTLY vanish from the trace —
# audit this whenever a new evidence dir appears. ADD `plugin`, `skills`, `hooks`, `tests`
# the moment those dirs are created (skeleton milestone, body #2) — until they exist a
# nonexistent root is a hard manifest_error, so only list what is on disk now.
roots = ["plugin", "ci", "ci/acceptance", "ci/publish", "ci/docs", "ci/manifest", "ci/digest", "ci/idle-translate", "adapter", "tools", "tests", "docs", "docs-site", "CONTEXT.md", "SCOPE.md", "AGENTS.md", "HANDOFF.md", "README.md"]

[policy]
# Default policy applied at activation: doc + impl + unit. Networking/lifecycle/cross-node
# reqs add "int". Until a req is activated, its per-req `required_stages = []` keeps it out
# of the coverage gate.
required_stages = []

# ───────────────────────────── Requirements ─────────────────────────────────
# Add requirements as you scope milestones, seeded from SCOPE.md "Parity-trim". Shape:
#
# [[requirements]]
# id = "REQ-EXAMPLE-1"
# title = "One-line statement of the requirement"
# required_stages = []  # activate (e.g. ["doc","impl","unit"]) when the milestone starts
#
# KNOWN-HAZARDS invariants are first-class requirements — one REQ-HAZARD-* per invariant,
# each requiring at least "unit" (add "int" where cross-process/cross-node):
#
# [[requirements]]
# id = "REQ-HAZARD-EXAMPLE"
# title = "The invariant, stated so a test can prove it"
# required_stages = []

# ── Milestone: SKELETON (thin cplugs plugin) — minted at kickoff, per-milestone (not
# the whole registry up front). All inactive (required_stages = []) so `check` stays
# green until body #2 lands evidence and flips each to ["doc"] / ["doc","impl"] etc.
# Seeded from SCOPE.md "Parity-trim" (Distribution + ADD buckets).

[[requirements]]
id = "REQ-DIST-PLUGIN-SKELETON"
title = "Marketplace artifact on cplugs is a thin skeleton: namespaced /sptc:* skill stubs + hooks.json + plugin.json, no embedded logic"
required_stages = ["doc", "impl", "unit"]  # doc: ADR-0001 + RELEASE-RUNBOOK. impl: ci/publish/{validate,package}-skeleton.sh (installability gate + publish mechanic). unit: tests/skeleton-validate.sh (pass real + fail tampered).

[[requirements]]
id = "REQ-DIST-BOOTSTRAP-INSTALL"
title = "SessionStart bootstrap installs spt-core when absent (invisible-installer pattern)"
required_stages = ["doc", "impl"]  # doc: ADR-0001. impl: plugin/sptc/bootstrap.{sh,ps1}.

[[requirements]]
id = "REQ-DIST-HOOKS-API"
title = "hooks.json delegates to `spt api` rather than carrying adapter logic"
required_stages = ["doc", "impl", "unit", "int"]  # doc: ADR-0001/0002. impl: hooks.json + wrappers. unit: tests/hooks-parse.sh. int: ci/hooks/poll-int.sh — `spt api poll` on live v0.7.1 emits the canonical <EVENT> envelope, confirm-matched through render_frames; __REPLY_TO__ relic gone, multi-drain splits on </EVENT>. 5/5 green 2026-06-15 (throwaway perch, torn down).

[[requirements]]
id = "REQ-DIST-MANIFEST-SCHEMA"
title = "CC adapter manifest validates against spt-core's published manifest.schema.json (from spt-releases)"
required_stages = ["doc", "impl", "unit", "int"]  # doc: ADR-0001 (manifest layer + version-of-truth). impl: adapter/claude-spt.toml + vendored adapter/manifest.schema.json + ci/manifest/{validate_manifest.py,check-manifest.sh} (gate). unit: tests/manifest-schema.sh (pass real + fail tampered). int: ci/manifest/registration-int.sh — real `spt adapter add` (v0.7.0) cross-field registration-validation accepted the manifest, shipped profile resolved, [strings] read + overlay observable; 6/6 green 2026-06-15 (registry left clean via soft-remove). Cross-field discovery: [digest] requires source-or-[history].locate_template (not in JSON schema) — fixed with source="{home}/.claude/projects".

[[requirements]]
id = "REQ-DIST-SHORTCUT-BASENAME"


Add the REQ, then map what sources the hook `.sh` files (so deletions don't break int tests).


Edit

[[requirements]]
id = "REQ-DIST-MANIFEST-SCHEMA"

[[requirements]]
id = "REQ-DIST-HOOK-BINARY"
title = "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."
required_stages = ["doc", "impl", "unit"]  # D1 — ACTIVATED 2026-06-28. doc: ADR-0006 (binary consolidation + ask#1 resolution) + UPDATE-NAMING-DOYLE-ASKS Ask 1 + V09-PLAN. impl: tools/claude-spt/src/hook.rs (the `hook <event>` subcommand — faithful port of the 8 wrappers + _common.sh) + main.rs Sub::Hook dispatch + plugin/sptc/hooks/dispatch.sh (static-forever resolve+exec, bootstrap-on-session-start, $SPTC_HOOK_BIN cache, --host-pid) + plugin/sptc/hooks/hooks.json (every event -> dispatch.sh <event>) + adapter/claude-spt.toml [strings].hook_cmd. unit: hook.rs #[cfg(test)] (json field parse, render_frames <EVENT>, unescape &-last, skill_key, register_verb, cap/spill, checkpoint detect/wake, brief assemble + {id} subst + is_subagent + peer-gate + json_escape + emit shape; per-event command-line + stdout-contract construction) + tests/hooks-dispatch.sh (hooks.json static shape: every CC event routed to dispatch.sh; dispatch sh -n; referenced wrappers exist). Both {adapter_dir} + lazy-[strings] primitives empirically verified on live spt 0.16.0 (probe-d1, 2026-06-28). int DEFERRED (same harness-glue E2E bucket as the other deferred ints): a real CC session with the v0.9.0 plugin + registered adapter resolving `hook_cmd` and the binary firing each hook — needs the new plugin installed on a real-CC node.

[[requirements]]
id = "REQ-DIST-MANIFEST-SCHEMA"


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

[[requirements]]
id = "REQ-DIST-HOOK-BINARY"
title = "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."
required_stages = ["doc", "impl", "unit"]  # D1 — ACTIVATED 2026-06-28. doc: ADR-0006 (binary consolidation + ask#1 resolution) + UPDATE-NAMING-DOYLE-ASKS Ask 1 + V09-PLAN. impl: tools/claude-spt/src/hook.rs (the `hook <event>` subcommand — faithful port of the 8 wrappers + _common.sh) + main.rs Sub::Hook dispatch + plugin/sptc/hooks/dispatch.sh (static-forever resolve+exec, bootstrap-on-session-start, $SPTC_HOOK_BIN cache, --host-pid) + plugin/sptc/hooks/hooks.json (every event -> dispatch.sh <event>) + adapter/claude-spt.toml [strings].hook_cmd. unit: hook.rs #[cfg(test)] (json field parse, render_frames <EVENT>, unescape &-last, skill_key, register_verb, cap/spill, checkpoint detect/wake, brief assemble + {id} subst + is_subagent + peer-gate + json_escape + emit shape; per-event command-line + stdout-contract construction) + tests/hooks-dispatch.sh (hooks.json static shape: every CC event routed to dispatch.sh; dispatch sh -n; referenced wrappers exist). Both {adapter_dir} + lazy-[strings] primitives empirically verified on live spt 0.16.0 (probe-d1, 2026-06-28). int DEFERRED (same harness-glue E2E bucket as the other deferred ints): a real CC session with the v0.9.0 plugin + registered adapter resolving `hook_cmd` and the binary firing each hook — needs the new plugin installed on a real-CC node.

[[requirements]]
id = "REQ-DIST-MANIFEST-SCHEMA"

required_stages = ["doc", "impl", "unit", "int"]  # doc: ADR-0001/0002. impl: hooks.json + wrappers. unit: tests/hooks-parse.sh. int: ci/hooks/poll-int.sh — `spt api poll` on live v0.7.1 emits the canonical <EVENT> envelope, confirm-matched through render_frames; __REPLY_TO__ relic gone, multi-drain splits on </EVENT>. 5/5 green 2026-06-15 (throwaway perch, torn down).

+title = "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."

+required_stages = ["doc", "impl", "unit"]  # D1 — ACTIVATED 2026-06-28. doc: ADR-0006 (binary consolidation + ask#1 resolution) + UPDATE-NAMING-DOYLE-ASKS Ask 1 + V09-PLAN. impl: tools/claude-spt/src/hook.rs (the `hook <event>` subcommand — faithful port of the 8 wrappers + _common.sh) + main.rs Sub::Hook dispatch + plugin/sptc/hooks/dispatch.sh (static-forever resolve+exec, bootstrap-on-session-start, $SPTC_HOOK_BIN cache, --host-pid) + plugin/sptc/hooks/hooks.json (every event -> dispatch.sh <event>) + adapter/claude-spt.toml [strings].hook_cmd. unit: hook.rs #[cfg(test)] (json field parse, render_frames <EVENT>, unescape &-last, skill_key, register_verb, cap/spill, checkpoint detect/wake, brief assemble + {id} subst + is_subagent + peer-gate + json_escape + emit shape; per-event command-line + stdout-contract construction) + tests/hooks-dispatch.sh (hooks.json static shape: every CC event routed to dispatch.sh; dispatch sh -n; referenced wrappers exist). Both {adapter_dir} + lazy-[strings] primitives empirically verified on live spt 0.16.0 (probe-d1, 2026-06-28). int DEFERRED (same harness-glue E2E bucket as the other deferred ints): a real CC session with the v0.9.0 plugin + registered adapter resolving `hook_cmd` and the binary firing each hook — needs the new plugin installed on a real-CC node.

title = "CC adapter manifest validates against spt-core's published manifest.schema.json (from spt-releases)"

required_stages = ["doc", "impl", "unit", "int"]  # doc: ADR-0001 (manifest layer + version-of-truth). impl: adapter/claude-spt.toml + vendored adapter/manifest.schema.json + ci/manifest/{validate_manifest.py,check-manifest.sh} (gate). unit: tests/manifest-schema.sh (pass real + fail tampered). int: ci/manifest/registration-int.sh — real `spt adapter add` (v0.7.0) cross-field registration-validation accepted the manifest, shipped profile resolved, [strings] read + overlay observable; 6/6 green 2026-06-15 (registry left clean via soft-remove). Cross-field discovery: [digest] requires source-or-[history].locate_template (not in JSON schema) — fixed with source="{home}/.claude/projects".


Found 6 files
ci\launcher\bind-int.sh
ci\manifest\registration-int.sh
ci\hooks\poll-int.sh
ci\publish\validate-skeleton.sh
ci\acceptance\run-acceptance.sh
ci\acceptance\lib.sh


Bash

Read poll-int.sh to see if it sources _common.sh


#!/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; }


poll-int.sh sources `_common.sh` for render_frames. Check which int/acceptance tests actually *source* the hook scripts (vs just mention).


grep -rn '\. .*hooks/\|source .*hooks/\|hooks/_common\|hooks/[a-z-]*\.sh' ci/ tests/ 2>/dev/null

Find files that source the hook scripts


ci/acceptance/lib.sh:41:  cat > "$_proj/.claude/hooks/ups-digest.sh" <<HOOK
ci/acceptance/lib.sh:49:  chmod +x "$_proj/.claude/hooks/ups-digest.sh" 2>/dev/null || true
ci/acceptance/lib.sh:54:      { "hooks": [ { "type": "command", "command": "sh \"$_proj/.claude/hooks/ups-digest.sh\"" } ] }
ci/hooks/poll-int.sh:12:# Run: SPTC_ACCEPTANCE=1 sh ci/hooks/poll-int.sh   (exit 0 = pass).
ci/hooks/poll-int.sh:53:rendered=$( . "$ROOT/plugin/sptc/hooks/_common.sh"; render_frames "$raw" )
ci/manifest/registration-int.sh:70:( . "$ROOT/plugin/sptc/hooks/_common.sh"
tests/acceptance-harness.sh:28:[ -f "$proj/.claude/hooks/ups-digest.sh" ] && echo "ok   scaffold writes UPS hook" || { echo "FAIL no UPS hook"; fail=1; }
tests/acceptance-harness.sh:31:grep -q 'input=$(cat)' "$proj/.claude/hooks/ups-digest.sh" && echo "ok   fixture reads stdin (MSYS-safe)" || { echo "FAIL fixture not stdin-driven"; fail=1; }
tests/acceptance-harness.sh:34:printf '%s' '{"session_id":"x","prompt":"hello-probe"}' | sh "$proj/.claude/hooks/ups-digest.sh"
tests/hooks-parse.sh:4:. "$(dirname "$0")/../plugin/sptc/hooks/_common.sh"
tests/hooks-parse.sh:200:CM="$(dirname "$0")/../plugin/sptc/hooks/_common.sh"
tests/skeleton-validate.sh:26:# 2. Runtime state leaked INTO a published path (hooks/) must FAIL — this is the real publish leak


sed -n '60,95p' ci/manifest/registration-int.sh

Read registration-int.sh _common.sh usage


# `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; }


Need the full unit-test surface I must preserve. Read the rest of hooks-parse.sh.


72	# --- sptc_cap_output: additionalContext spill guard (ADR-0002 Open #2) --- [unit->REQ-UPS-INJECTION]
73	spill="${TMPDIR:-/tmp}/sptc-cap-$$.txt"
74	rm -f "$spill"
75	check "cap under -> passthrough verbatim" "small body" "$(sptc_cap_output 'small body' 9000 "$spill")"
76	check "cap under -> no spill written"     "absent"     "$([ -f "$spill" ] && echo present || echo absent)"
77
78	over=$(sptc_cap_output 'abcdefghij' 5 "$spill")   # 10 bytes > cap 5
79	case "$over" in *'<sptc_overflow'*) m=ok ;; *) m=no ;; esac
80	check "cap over -> emits overflow marker"  "ok"         "$m"
81	check "cap over -> full body spilled"      "abcdefghij" "$(cat "$spill" 2>/dev/null)"
82	case "$over" in *abcdefghij*) leak=leaked ;; *) leak=clean ;; esac
83	check "cap over -> body not inlined"       "clean"      "$leak"
84	check "cap empty -> no output"             ""           "$(sptc_cap_output '' 5 "$spill")"
85	rm -f "$spill"
86
87	# --- SessionStart briefs: pure composition --- [unit->REQ-DIST-SESSIONSTART-BRIEF]
88	# assemble_perch: {id} substitution + block structure.
89	pb=$(sptc_assemble_perch "perri" 'you are {id}.' 'send body' 'roster line')
90	check "assemble_perch structure+subst" \
91	  "$(printf '<sptc-active-perch id="perri">\nyou are perri.\n\nsend body\n\nroster line\n</sptc-active-perch>')" \
92	  "$pb"
93	case "$pb" in *'{id}'*) leftover=present ;; *) leftover=clean ;; esac
94	check "assemble_perch no {id} leftover" "clean" "$leftover"
95	# Every {id} occurrence is substituted (global).
96	multi_id=$(sptc_assemble_perch "kit" 'a {id} b {id}' 'm' 'r')
97	case "$multi_id" in *'a kit b kit'*) g=ok ;; *) g=no ;; esac
98	check "assemble_perch global subst" "ok" "$g"
99
100	# assemble_noperch: ring block structure (no id).
101	nb=$(sptc_assemble_noperch 'ring body' 'roster line')
102	check "assemble_noperch structure" \
103	  "$(printf '<sptc-reach>\nring body\n\nroster line\n</sptc-reach>')" "$nb"
104
105	# is_subagent: empty -> real session (inject); set -> subagent (skip).
106	check "is_subagent empty -> no"   "no"  "$(sptc_is_subagent ''               && echo yes || echo no)"
107	check "is_subagent set -> yes"    "yes" "$(sptc_is_subagent 'general-purpose' && echo yes || echo no)"
108
109	# has_peers_lines: >1 non-empty line on `subnet status` stdin = peers present.
110	check "peers: header+row -> yes" "yes" "$(printf 'SUBNET NODES ENDPOINTS\nSPT_DEV 3 4\n' | sptc_has_peers_lines && echo yes || echo no)"
111	check "peers: single line -> no" "no"  "$(printf 'no subnets\n'                            | sptc_has_peers_lines && echo yes || echo no)"
112	check "peers: empty -> no"       "no"  "$(printf ''                                          | sptc_has_peers_lines && echo yes || echo no)"
113
114	# --- SessionStart briefs: JSON encode + emit --- [unit->REQ-DIST-SESSIONSTART-BRIEF]
115	check "json_escape quotes"    'say \"hi\"' "$(printf '%s' 'say "hi"' | sptc_json_escape)"
116	check "json_escape backslash" 'a\\b'       "$(printf '%s' 'a\b'      | sptc_json_escape)"
117	check "json_escape newline"   'a\nb'       "$(printf 'a\nb'          | sptc_json_escape)"
118
119	check "emit empty -> silent" "" "$(sptc_emit_additional_context '')"
120	check "emit json shape" \
121	  '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"hi \"x\""}}' \
122	  "$(sptc_emit_additional_context 'hi "x"')"
123	check "emit json newline-escaped" \
124	  '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"a\nb"}}' \
125	  "$(sptc_emit_additional_context "$(printf 'a\nb')")"
126
127	# --- checkpoint detection: trigger presence, custom-wake extraction, commune-write guard ---
128	# [unit->REQ-DIST-CHECKPOINT-COMMUNE]
129	check "has_checkpoint single"  "yes" "$(sptc_has_checkpoint 'delta ... !!checkpoint!!'        && echo yes || echo no)"
130	check "has_checkpoint pair"    "yes" "$(sptc_has_checkpoint '!!checkpoint!! wake !!checkpoint!!' && echo yes || echo no)"
131	check "has_checkpoint absent"  "no"  "$(sptc_has_checkpoint 'an ordinary commune delta'        && echo yes || echo no)"
132	check "has_checkpoint empty"   "no"  "$(sptc_has_checkpoint ''                                  && echo yes || echo no)"
133
134	# Single marker -> no custom wake (binary applies the default).
135	check "wake single -> default(empty)" "" "$(sptc_checkpoint_wake 'work ... !!checkpoint!!')"
136	# Paired markers -> the trimmed inner text is the custom wake.
137	check "wake pair -> custom" "Resume T2c now" \
138	  "$(sptc_checkpoint_wake 'body !!checkpoint!! Resume T2c now !!checkpoint!! more')"
139	# Pair embedded in a realistic single-line JSON content blob.
140	check "wake pair in json blob" "wire the hook" \
141	  "$(sptc_checkpoint_wake '{"content":"delta\n!!checkpoint!! wire the hook !!checkpoint!!\nend"}')"
142	check "wake none -> empty"     "" "$(sptc_checkpoint_wake 'no markers here')"
143
144	# commune-write guard: Write to <id>-commune.md only (tolerates JSON-escaped Windows path).
145	check "commune_write match unix"  "yes" \
146	  "$(sptc_is_commune_write Write '/home/x/.claude/perri-commune.md' perri && echo yes || echo no)"
147	check "commune_write match win"   "yes" \
148	  "$(sptc_is_commune_write Write 'C:\\\\Users\\\\d\\\\.claude\\\\perri-commune.md' perri && echo yes || echo no)"
149	check "commune_write wrong file"  "no"  \
150	  "$(sptc_is_commune_write Write '/home/x/.claude/notes.md' perri && echo yes || echo no)"
151	check "commune_write wrong id"    "no"  \
152	  "$(sptc_is_commune_write Write '/home/x/.claude/doyle-commune.md' perri && echo yes || echo no)"
153	check "commune_write not Write"   "no"  \
154	  "$(sptc_is_commune_write Edit '/home/x/.claude/perri-commune.md' perri && echo yes || echo no)"
155	check "commune_write empty id"    "no"  \
156	  "$(sptc_is_commune_write Write '/home/x/.claude/perri-commune.md' '' && echo yes || echo no)"
157
158	# --- resume-context append: skip cleanly on NO-CONTEXT (REQ-DIST-RESUME-CONTEXT) ---
159	# [unit->REQ-DIST-RESUME-CONTEXT]
160	# Non-empty resume -> appended below the brief, newline-joined (verbatim XML sits under the identity).
161	check "append resume present" \
162	  "$(printf '<brief/>\n<live-context>ctx</live-context>')" \
163	  "$(sptc_append_resume '<brief/>' '<live-context>ctx</live-context>')"
164	# Empty resume (NO-CONTEXT) -> brief unchanged, no trailing newline injected.
165	check "append resume empty -> brief verbatim" "<brief/>" "$(sptc_append_resume '<brief/>' '')"
166	# Both empty -> empty.
167	check "append resume both empty" "" "$(sptc_append_resume '' '')"
168
169	# --- PreToolUse mid-turn reachability wiring (F-021) --- [unit->REQ-DIST-PRETOOL-POLL]
170	# The busy/idle turn-state lifecycle + the PreToolUse deferred-drain. Structural (the hook files +
171	# manifest declaration are the artifacts; the api commands run against a live perch at the int tier).
172	HK="$(dirname "$0")/../plugin/sptc/hooks"
173	MAN="$(dirname "$0")/../adapter/claude-spt.toml"
174	grep -q '"PreToolUse"' "$HK/hooks.json" && r=yes || r=no
175	check "hooks.json declares PreToolUse" "yes" "$r"
176	grep -q 'pre-tool-use.sh' "$HK/hooks.json" && r=yes || r=no
177	check "PreToolUse routes to pre-tool-use.sh" "yes" "$r"
178	grep -q 'poll .*--include-deferred' "$HK/pre-tool-use.sh" && r=yes || r=no
179	check "pre-tool-use drains deferred (api poll --include-deferred)" "yes" "$r"
180	# PreToolUse also marks busy (covers Monitor-triggered turns with no UserPromptSubmit).
181	grep -q 'state busy' "$HK/pre-tool-use.sh" && r=yes || r=no
182	check "pre-tool-use marks busy (non-user turn-start fallback)" "yes" "$r"
183	# And it sets busy BEFORE draining (so a message landing mid-drain also defers).
184	awk '/state busy/{b=NR} /poll .*--include-deferred/{p=NR} END{exit !(b && p && b<p)}' "$HK/pre-tool-use.sh" && r=yes || r=no
185	check "pre-tool-use sets busy before the drain" "yes" "$r"
186	grep -q 'state busy' "$HK/user-prompt-submit.sh" && r=yes || r=no
187	check "UPS marks perch busy at turn-start (api state busy)" "yes" "$r"
188	grep -q 'poll .*--include-deferred' "$HK/user-prompt-submit.sh" && r=yes || r=no
189	check "UPS drain includes deferred" "yes" "$r"
190	grep -q 'state idle' "$HK/stop.sh" && r=yes || r=no
191	check "Stop marks perch idle at turn-end (api state idle)" "yes" "$r"
192	grep -q '^\[hooks\.PreToolUse\]' "$MAN" && r=yes || r=no
193	check "manifest declares [hooks.PreToolUse]" "yes" "$r"
194
195	# --- U4: reactive-skill thinning + live-ops brief --- [unit->REQ-DIST-SKELETON-THIN]
196	# commune/send/signoff prose moved OUT of the plugin SKILL.md INTO adapter strings (the live-ops brief
197	# + the go-live body), so it rides `spt adapter update`; the SKILL.md are now thin stubs.
198	BR="$(dirname "$0")/../adapter/strings/briefs"
199	SK="$(dirname "$0")/../plugin/sptc/skills"
200	CM="$(dirname "$0")/../plugin/sptc/hooks/_common.sh"
201	LV="$(dirname "$0")/../adapter/strings/skills/live.md"
202	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
203	check "live-ops brief carries commune+checkpoint+signoff" "yes" "$r"
204	grep -q 'live-ops' "$CM" && r=yes || r=no
205	check "sptc_perch_brief composes live-ops" "yes" "$r"
206	grep -q 'live-ops = { file' "$MAN" && r=yes || r=no
207	check "manifest registers briefs.live-ops" "yes" "$r"
208	grep -q 'commune.md' "$LV" && grep -q '!!checkpoint!!' "$LV" && r=yes || r=no
209	check "go-live body inlines commune+checkpoint (no SessionStart re-fire in-session)" "yes" "$r"
210	for s in commune signoff send; do
211	  grep -q 'thin skeleton' "$SK/$s/SKILL.md" && r=yes || r=no
212	  check "$s SKILL.md is a thin stub (prose rides adapter)" "yes" "$r"
213	done
214	# The pre-U4 full step-by-step bodies must be gone from the stubs (not just supplemented).
215	grep -q 'Normal commune' "$SK/commune/SKILL.md" && r=present || r=absent
216	check "commune SKILL.md full body removed" "absent" "$r"
217
218	[ "$fail" -eq 0 ] && { echo "ALL PASS"; exit 0; } || { echo "FAILURES"; exit 1; }

# --- sptc_cap_output: additionalContext spill guard (ADR-0002 Open #2) --- [unit->REQ-UPS-INJECTION]
spill="${TMPDIR:-/tmp}/sptc-cap-$$.txt"
rm -f "$spill"
check "cap under -> passthrough verbatim" "small body" "$(sptc_cap_output 'small body' 9000 "$spill")"
check "cap under -> no spill written"     "absent"     "$([ -f "$spill" ] && echo present || echo absent)"

over=$(sptc_cap_output 'abcdefghij' 5 "$spill")   # 10 bytes > cap 5
case "$over" in *'<sptc_overflow'*) m=ok ;; *) m=no ;; esac
check "cap over -> emits overflow marker"  "ok"         "$m"
check "cap over -> full body spilled"      "abcdefghij" "$(cat "$spill" 2>/dev/null)"
case "$over" in *abcdefghij*) leak=leaked ;; *) leak=clean ;; esac
check "cap over -> body not inlined"       "clean"      "$leak"
check "cap empty -> no output"             ""           "$(sptc_cap_output '' 5 "$spill")"
rm -f "$spill"

# --- SessionStart briefs: pure composition --- [unit->REQ-DIST-SESSIONSTART-BRIEF]
# assemble_perch: {id} substitution + block structure.
pb=$(sptc_assemble_perch "perri" 'you are {id}.' 'send body' 'roster line')
check "assemble_perch structure+subst" \
  "$(printf '<sptc-active-perch id="perri">\nyou are perri.\n\nsend body\n\nroster line\n</sptc-active-perch>')" \
  "$pb"
case "$pb" in *'{id}'*) leftover=present ;; *) leftover=clean ;; esac
check "assemble_perch no {id} leftover" "clean" "$leftover"
# Every {id} occurrence is substituted (global).
multi_id=$(sptc_assemble_perch "kit" 'a {id} b {id}' 'm' 'r')
case "$multi_id" in *'a kit b kit'*) g=ok ;; *) g=no ;; esac
check "assemble_perch global subst" "ok" "$g"

# assemble_noperch: ring block structure (no id).
nb=$(sptc_assemble_noperch 'ring body' 'roster line')
check "assemble_noperch structure" \
  "$(printf '<sptc-reach>\nring body\n\nroster line\n</sptc-reach>')" "$nb"

# is_subagent: empty -> real session (inject); set -> subagent (skip).
check "is_subagent empty -> no"   "no"  "$(sptc_is_subagent ''               && echo yes || echo no)"
check "is_subagent set -> yes"    "yes" "$(sptc_is_subagent 'general-purpose' && echo yes || echo no)"

# has_peers_lines: >1 non-empty line on `subnet status` stdin = peers present.
check "peers: header+row -> yes" "yes" "$(printf 'SUBNET NODES ENDPOINTS\nSPT_DEV 3 4\n' | sptc_has_peers_lines && echo yes || echo no)"
check "peers: single line -> no" "no"  "$(printf 'no subnets\n'                            | sptc_has_peers_lines && echo yes || echo no)"
check "peers: empty -> no"       "no"  "$(printf ''                                          | sptc_has_peers_lines && echo yes || echo no)"

# --- SessionStart briefs: JSON encode + emit --- [unit->REQ-DIST-SESSIONSTART-BRIEF]
check "json_escape quotes"    'say \"hi\"' "$(printf '%s' 'say "hi"' | sptc_json_escape)"
check "json_escape backslash" 'a\\b'       "$(printf '%s' 'a\b'      | sptc_json_escape)"
check "json_escape newline"   'a\nb'       "$(printf 'a\nb'          | sptc_json_escape)"

check "emit empty -> silent" "" "$(sptc_emit_additional_context '')"
check "emit json shape" \
  '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"hi \"x\""}}' \
  "$(sptc_emit_additional_context 'hi "x"')"
check "emit json newline-escaped" \
  '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"a\nb"}}' \
  "$(sptc_emit_additional_context "$(printf 'a\nb')")"

# --- checkpoint detection: trigger presence, custom-wake extraction, commune-write guard ---
# [unit->REQ-DIST-CHECKPOINT-COMMUNE]
check "has_checkpoint single"  "yes" "$(sptc_has_checkpoint 'delta ... !!checkpoint!!'        && echo yes || echo no)"
check "has_checkpoint pair"    "yes" "$(sptc_has_checkpoint '!!checkpoint!! wake !!checkpoint!!' && echo yes || echo no)"
check "has_checkpoint absent"  "no"  "$(sptc_has_checkpoint 'an ordinary commune delta'        && echo yes || echo no)"
check "has_checkpoint empty"   "no"  "$(sptc_has_checkpoint ''                                  && echo yes || echo no)"

# Single marker -> no custom wake (binary applies the default).
check "wake single -> default(empty)" "" "$(sptc_checkpoint_wake 'work ... !!checkpoint!!')"
# Paired markers -> the trimmed inner text is the custom wake.
check "wake pair -> custom" "Resume T2c now" \
  "$(sptc_checkpoint_wake 'body !!checkpoint!! Resume T2c now !!checkpoint!! more')"
# Pair embedded in a realistic single-line JSON content blob.
check "wake pair in json blob" "wire the hook" \
  "$(sptc_checkpoint_wake '{"content":"delta\n!!checkpoint!! wire the hook !!checkpoint!!\nend"}')"
check "wake none -> empty"     "" "$(sptc_checkpoint_wake 'no markers here')"

# commune-write guard: Write to <id>-commune.md only (tolerates JSON-escaped Windows path).
check "commune_write match unix"  "yes" \
  "$(sptc_is_commune_write Write '/home/x/.claude/perri-commune.md' perri && echo yes || echo no)"
check "commune_write match win"   "yes" \
  "$(sptc_is_commune_write Write 'C:\\\\Users\\\\d\\\\.claude\\\\perri-commune.md' perri && echo yes || echo no)"
check "commune_write wrong file"  "no"  \
  "$(sptc_is_commune_write Write '/home/x/.claude/notes.md' perri && echo yes || echo no)"
check "commune_write wrong id"    "no"  \
  "$(sptc_is_commune_write Write '/home/x/.claude/doyle-commune.md' perri && echo yes || echo no)"
check "commune_write not Write"   "no"  \
  "$(sptc_is_commune_write Edit '/home/x/.claude/perri-commune.md' perri && echo yes || echo no)"
check "commune_write empty id"    "no"  \
  "$(sptc_is_commune_write Write '/home/x/.claude/perri-commune.md' '' && echo yes || echo no)"

# --- resume-context append: skip cleanly on NO-CONTEXT (REQ-DIST-RESUME-CONTEXT) ---
# [unit->REQ-DIST-RESUME-CONTEXT]
# Non-empty resume -> appended below the brief, newline-joined (verbatim XML sits under the identity).
check "append resume present" \
  "$(printf '<brief/>\n<live-context>ctx</live-context>')" \
  "$(sptc_append_resume '<brief/>' '<live-context>ctx</live-context>')"
# Empty resume (NO-CONTEXT) -> brief unchanged, no trailing newline injected.
check "append resume empty -> brief verbatim" "<brief/>" "$(sptc_append_resume '<brief/>' '')"
# Both empty -> empty.
check "append resume both empty" "" "$(sptc_append_resume '' '')"

# --- PreToolUse mid-turn reachability wiring (F-021) --- [unit->REQ-DIST-PRETOOL-POLL]
# The busy/idle turn-state lifecycle + the PreToolUse deferred-drain. Structural (the hook files +
# manifest declaration are the artifacts; the api commands run against a live perch at the int tier).
HK="$(dirname "$0")/../plugin/sptc/hooks"
MAN="$(dirname "$0")/../adapter/claude-spt.toml"
grep -q '"PreToolUse"' "$HK/hooks.json" && r=yes || r=no
check "hooks.json declares PreToolUse" "yes" "$r"
grep -q 'pre-tool-use.sh' "$HK/hooks.json" && r=yes || r=no
check "PreToolUse routes to pre-tool-use.sh" "yes" "$r"
grep -q 'poll .*--include-deferred' "$HK/pre-tool-use.sh" && r=yes || r=no
check "pre-tool-use drains deferred (api poll --include-deferred)" "yes" "$r"
# PreToolUse also marks busy (covers Monitor-triggered turns with no UserPromptSubmit).
grep -q 'state busy' "$HK/pre-tool-use.sh" && r=yes || r=no
check "pre-tool-use marks busy (non-user turn-start fallback)" "yes" "$r"
# And it sets busy BEFORE draining (so a message landing mid-drain also defers).
awk '/state busy/{b=NR} /poll .*--include-deferred/{p=NR} END{exit !(b && p && b<p)}' "$HK/pre-tool-use.sh" && r=yes || r=no
check "pre-tool-use sets busy before the drain" "yes" "$r"
grep -q 'state busy' "$HK/user-prompt-submit.sh" && r=yes || r=no
check "UPS marks perch busy at turn-start (api state busy)" "yes" "$r"
grep -q 'poll .*--include-deferred' "$HK/user-prompt-submit.sh" && r=yes || r=no
check "UPS drain includes deferred" "yes" "$r"
grep -q 'state idle' "$HK/stop.sh" && r=yes || r=no
check "Stop marks perch idle at turn-end (api state idle)" "yes" "$r"
grep -q '^\[hooks\.PreToolUse\]' "$MAN" && r=yes || r=no
check "manifest declares [hooks.PreToolUse]" "yes" "$r"

# --- U4: reactive-skill thinning + live-ops brief --- [unit->REQ-DIST-SKELETON-THIN]
# commune/send/signoff prose moved OUT of the plugin SKILL.md INTO adapter strings (the live-ops brief
# + the go-live body), so it rides `spt adapter update`; the SKILL.md are now thin stubs.
BR="$(dirname "$0")/../adapter/strings/briefs"
SK="$(dirname "$0")/../plugin/sptc/skills"
CM="$(dirname "$0")/../plugin/sptc/hooks/_common.sh"
LV="$(dirname "$0")/../adapter/strings/skills/live.md"
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 'live-ops' "$CM" && r=yes || r=no
check "sptc_perch_brief composes live-ops" "yes" "$r"
grep -q 'live-ops = { file' "$MAN" && r=yes || r=no
check "manifest registers briefs.live-ops" "yes" "$r"
grep -q 'commune.md' "$LV" && grep -q '!!checkpoint!!' "$LV" && r=yes || r=no
check "go-live body inlines commune+checkpoint (no SessionStart re-fire in-session)" "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 (prose rides adapter)" "yes" "$r"
done
# The pre-U4 full step-by-step bodies must be gone from the stubs (not just supplemented).
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; }


Complete unit-test surface mapped. Now writing the core: `hook.rs` — faithful Rust port of all 8 wrappers + `_common.sh`, with a `HookEnv` side-effect trait so handlers are unit-testable (ordering, no-op, emit) and all pure helpers tested directly.