<!-- [doc->REQ-DIST-HOOKS-API] -->
<!-- [doc->REQ-UPS-INJECTION] -->
# Hook wiring: the sptc plugin hand-writes a CC `hooks.json` that shells `spt api`

## Status

accepted (2026-06-15) · grounds the F-001 resolution (`docs/SPT-CORE-FINDINGS.md`)

## Context

spt-core is **harness-independent** (`CONTEXT.md` L52/L181): it supplies the agnostic `spt api`
primitives + their I/O format; the **adapter** authors the harness-specific wiring and output
formatting (L112). Claude Code drives hooks via a `hooks/hooks.json` whose handlers receive a JSON
payload on **stdin** and may return `additionalContext`. `spt api` primitives are observable on the
public 0.6.0 binary (flags confirmed via `--help`); every call requires `--adapter claude-spt`
(the adapter_name — distinct from the plugin name `sptc`).

Two hard constraints shape the wiring:

1. **`api listen` blocks** ("consume a seed and hold the perch + relay loop (blocks)"). It therefore
   **cannot** run from `SessionStart` — that would hang CC startup. `listen` belongs to an explicit,
   backgrounded `/sptc:ready` / `/sptc:live` invocation (as legacy did).
2. **spt-core must not materialize a CC `hooks.json`** — doing so would be CC-catering, violating
   L52/L181. The plugin hand-writes it.

## Decision

**The `sptc` plugin ships a hand-written `plugin/sptc/hooks/hooks.json` whose handlers shell out to
`spt api --adapter claude-spt <subcommand>`.** Hook params are sourced from CC's native hook input
(stdin JSON / process), mapped by a thin adapter wrapper — *not* by spt-core `{placeholder}`
substitution (that is the spt-hosted-bringup path, where spt-core itself spawns the command).

### CC-event → `spt api` mapping (authored adapter glue)

| Claude Code hook | `spt api` (all prefixed `--adapter claude-spt`) | Notes |
|---|---|---|
| SessionStart (`startup`/`resume`) | `seed --pid <pid> --session-id <sid>` + write env aliases via `$CLAUDE_ENV_FILE` | non-blocking; records the pid↔session seed |
| SessionStart (`clear`/`compact`) | `boundary <clear\|compact> <id> --to-session-id <sid>` | rebind perch, preserve identity |
| UserPromptSubmit | `poll <id>` → stdout → `additionalContext` | the message-delivery + UPS-injection path (L149) |
| Stop | `state idle <id>` | arms echo-gate fallback (L137) since Stop can't inject |
| (activity) | `state busy <id>` | on prompt/tool start |
| SubagentStart / SubagentStop | `worker-start <parent> <id>` / `worker-stop <id>` | nested worker perches |
| SessionEnd | `session-end <id>` | soft teardown; `shutdown <id>` on graceful signoff |
| PreToolUse | — | **out of scope v1** (UPS covers delivery) |

`api listen <id>` is launched by `/sptc:ready` / `/sptc:live` as a backgrounded blocking poll loop,
**not** from a hook.

### Skill instructions (UPS-injection) ride the same UserPromptSubmit hook

The `UserPromptSubmit` hook's stdout becomes CC `additionalContext`. The same channel that delivers
queued messages (`api poll`) is where `/sptc:X` skill-instruction injection lands (from the adapter
`[strings]`, M12-dep). `api poll` emitting to stdout is by design (L149); formatting it for CC is
ours. PTY/relay inject methods are M3 roadmap (M2a = stdout/hook only) — not a gap.

### `api poll` parse contract — the `<EVENT>` envelope (operator-ruled 2026-06-15)

> **Supersedes** the earlier `__REPLY_TO__` framing (a mis-elevated relic, now deleted from
> spt-core — see `docs/SPT-CORE-FINDINGS.md` F-002, resolved-by-design). Confirmed by doyle as the
> deliberate poll-surface contract. **Transitional:** the current 0.6.0 binary still
> emits the `__REPLY_TO__` relic at poll until `REQ-MSG-ENVELOPE` ships; build to `<EVENT>` but
> validate against poll only post-refactor.

The **canonical format at every surface, including `api poll`, is the `spt-proto::event` envelope**
(the ADR-0001 grammar the live listener already emits):

- **One whole, single-line `<EVENT type="msg" from="<sender>">body</EVENT>` per message.** Interior
  newlines are `<br>`-escaped, so the envelope never breaks across lines.
- **Self-delimiting** → a multi-message drain splits cleanly on `</EVENT>` (no delimiter, no
  `F-002` ambiguity).
- **No `<EVENT-PART>` chunking at the poll surface** (doyle Q2): `poll`/`worker-poll` emit whole
  `<EVENT>`s. `<EVENT-PART seq="N/M">` exists only for the *listener stream* (the `« spt event »`
  Monitor's ~500-char `EVENT_LINE_THRESHOLD`); the hook-drain injects via `additionalContext` which
  has no per-line cap. **No id+seq reassembly on the poll path.**

Parser (`render_frames`, `plugin/sptc/hooks/_common.sh`): split on `</EVENT>`; per envelope, read
the `from` attr → `<sptc_messages from="<sender>">`, decode the body (`<br>` → newline, then entity
unescape `< > "` then `&` **last**). **Sender preserved** (reply-correlation: access
gate, Psyche routing), never silently stripped. Covered by
`tests/hooks-parse.sh` (named / entity / multi-message / no-from / empty).

**Harness injection-size limit is ours** (harness-agnostic boundary — spt-core emits whole
`<EVENT>`s regardless): CC `additionalContext` caps at **10,000 chars** (larger output is spilled to
a file by CC). A large multi-message drain can exceed it → adapter-side follow-up: truncate with a
marker or spill. (`REQ-UPS-INJECTION` `int` item.)

### Portability

Handlers must map CC stdin JSON → `api` flags. The plugin ships **no binary**, so the mapping is a
thin portable wrapper (POSIX `sh` + PowerShell), selected per-platform. Exact wrapper packaging is
settled during impl (see Open).

### Hook-side id-resolution — RESOLVED (observed on the 0.6.0 binary)

`spt whoami` "Print this session's own perch id. **Resolved from `$OWL_SESSION_ID` /
`$SPT_AGENT_ID`.**" That is the id-resolver: the per-prompt hooks do not need a positional `<id>`
threaded in — they resolve it from the session env. Wiring:

- **SessionStart** writes the session env via `$CLAUDE_ENV_FILE`: `OWL_SESSION_ID=<session_id>`
  (from the hook stdin) + `SPT_ADAPTER=claude-spt`. (It also runs the bootstrap and `api seed`.)
- **Per-prompt hooks** (UserPromptSubmit/Stop/SessionEnd) resolve `id="$(spt whoami)"`; if empty
  (session never readied → no perch), they no-op. Auth: pass `--session-id "$OWL_SESSION_ID"`
  (`poll`/`state`/`session-end` all accept `--session-id` as the association proof — observed) so
  no capability token is needed from the hook.

The frame contract is corroborated at the binary level too: `spt send --from <FROM>` = "Sender id
**written into `__REPLY_TO__`**" — matching the `spool.rs` frame doyle provided.

## Validation results (throwaway `claude -p` session, 2026-06-15)

Ran an isolated temp-project rig (UserPromptSubmit marker hook + a registered `/send` project
skill) on the real CC 2.1.177 binary:

- ✅ **UPS fires on a `/`-slash-command.** `MSYS_NO_PATHCONV=1 claude -p "/send hi"` → the hook
  received `prompt:"/send hi"` (literal) **and** fired **and** the skill ran. Answers the
  SCOPE-flagged question: `UserPromptSubmit` fires on a `/sptc:X` invocation with the token intact,
  so the wrapper can detect `/sptc:X` in `prompt` and inject. (`REQ-UPS-INJECTION`.)
- ✅ **Windows `shell:"bash"` command hooks work** — the hook executed via Git-Bash,
  `$CLAUDE_PROJECT_DIR` resolved, exit-0 stdout honored. Resolves Open#2 for the POSIX-wrapper
  packaging on Windows (no per-OS branch needed for the hook to run).
- ✅ **Hook stdin schema confirmed**: `{session_id, transcript_path, cwd, permission_mode,
  hook_event_name, prompt}` — `json_str` targets the right fields; `session_id` sourcing confirmed.
- ⚠ **MSYS `/`-arg mangling** observed (run A): a `/send` passed as a Git-Bash *argument* became
  `C:/Program Files/Git/send`. Test artifact (wrappers read stdin, not argv) but a real Windows
  hazard → `docs/KNOWN-HAZARDS.md` 1.1 + `REQ-HAZARD-MSYS-PATHCONV` (test: `tests/msys-hazard.sh`).

## Open / resolved `int`

1. **`api poll` → `additionalContext` round-trip — RESOLVED (v0.7.1, 2026-06-15).** The canonical poll envelope shipped
   in v0.7.1; a throwaway byte-capture against the live `spt api poll` drain (`od`-verified) confirmed
   the canonical `<EVENT type="msg" from=…>body</EVENT>\n` envelope (no `__REPLY_TO__`, no
   `<EVENT-PART>` on normal drains, multi-drain splits on `</EVENT>`), and `render_frames` confirm-
   matched it. Locked by `ci/hooks/poll-int.sh` (5/5); `REQ-DIST-HOOKS-API` + `REQ-UPS-INJECTION`
   `int` flipped green. See `docs/SPT-CORE-FINDINGS.md` F-002.
2. **Large-drain injection size — RESOLVED (2026-06-15).** CC spills `additionalContext` over ~10k
   chars to a file (evicting it from the inline context the agent sees), silently losing messages.
   The UPS wrapper now pre-empts: `sptc_cap_output` (`_common.sh`) buffers the combined skill-body +
   drain, and over `SPTC_CTX_CAP` (9000, margin under 10k) spills the FULL text to an agent-readable
   file (`~/.claude/sptc-drain-<sid>.txt`) and emits only a concise `<sptc_overflow>` pointer — never
   a head-cut that would split a `<sptc_messages>`/`<EVENT>` block and drop a message. Under the cap
   it is verbatim passthrough (no behaviour change for normal drains). Unit: `tests/hooks-parse.sh`
   `sptc_cap_output` cases (passthrough, no-spill, overflow marker, full-body spill, no-inline-leak).

## Consequences

- `REQ-DIST-HOOKS-API` / `REQ-UPS-INJECTION` gain a concrete, grounded `doc` design here; their
  `impl` activates when the validated `hooks.json` + wrappers land (gated on Open #1).
- Supersedes the `ADR-0001` "hooks.json delegates to `spt api`" stub with the full wiring; ADR-0001's
  UPS-injection open item (UPS-fires empirical) is carried here as Open #2.
- No spt-core dependency for the wiring itself — only the (non-blocking) residual docs publish of the
  `api poll` frame format, which we can substitute with observed behavior until it lands.
===0006===
# 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`.


Bash

echo "===HOOKS DIR==="; ls -R plugin/ 2>/dev/null | head -60; echo "===hooks.json==="; find . -name "hooks.json" -not -path "*/node_modules/*" 2>/dev/null

List plugin tree + find hooks.json


===HOOKS DIR===
plugin/:
sptc

plugin/sptc:
bootstrap.ps1
bootstrap.sh
hooks
skills

plugin/sptc/hooks:
_common.sh
hooks.json
post-tool-use.sh
pre-tool-use.sh
session-end.sh
session-start.sh
stop.sh
subagent-start.sh
subagent-stop.sh
user-prompt-submit.sh

plugin/sptc/skills:
commune
force-stop
list-agents
live
ready
send
setup
signoff
subnet
version

plugin/sptc/skills/commune:
SKILL.md

plugin/sptc/skills/force-stop:
SKILL.md

plugin/sptc/skills/list-agents:
SKILL.md

plugin/sptc/skills/live:
SKILL.md

plugin/sptc/skills/ready:
SKILL.md

plugin/sptc/skills/send:
SKILL.md

plugin/sptc/skills/setup:
SKILL.md

plugin/sptc/skills/signoff:
SKILL.md

plugin/sptc/skills/subnet:
SKILL.md

===hooks.json===
./plugin/sptc/hooks/hooks.json


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


D1 = port 8 shell hooks + _common.sh → `claude-spt hook <event>` Rust binary + static hooks.json via `{adapter_dir}` resolve. Read adapter source + hooks.json + D1 deferral notes.


cat plugin/sptc/hooks/hooks.json; echo "===ADAPTER TREE==="; ls -R adapter/ 2>/dev/null | head -80

Read hooks.json + adapter tree


{
  "hooks": {
    "SessionStart": [
      { "hooks": [ { "type": "command", "command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/session-start.sh\"", "shell": "bash" } ] }
    ],
    "UserPromptSubmit": [
      { "hooks": [ { "type": "command", "command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/user-prompt-submit.sh\"", "shell": "bash" } ] }
    ],
    "PreToolUse": [
      { "hooks": [ { "type": "command", "command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/pre-tool-use.sh\"", "shell": "bash" } ] }
    ],
    "Stop": [
      { "hooks": [ { "type": "command", "command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/stop.sh\"", "shell": "bash" } ] }
    ],
    "SessionEnd": [
      { "hooks": [ { "type": "command", "command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/session-end.sh\"", "shell": "bash" } ] }
    ],
    "SubagentStart": [
      { "hooks": [ { "type": "command", "command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/subagent-start.sh\"", "shell": "bash" } ] }
    ],
    "SubagentStop": [
      { "hooks": [ { "type": "command", "command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/subagent-stop.sh\"", "shell": "bash" } ] }
    ],
    "PostToolUse": [
      { "matcher": "Write", "hooks": [ { "type": "command", "command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/post-tool-use.sh\"", "shell": "bash" } ] }
    ]
  }
}
===ADAPTER TREE===
adapter/:
claude-spt.toml
manifest.schema.json
strings

adapter/strings:
briefs
skills

adapter/strings/briefs:
identity.md
live-ops.md
messaging-no-perch.md
messaging-perch.md

adapter/strings/skills:
force-stop.md
list-agents.md
live.md
ready.md
setup.md
subnet.md
version.md


grep -n "D1" UNIFY-CONSOLIDATE-PLAN.md | head -40

Grep D1 mentions in unify plan


37:- **DEFERRED — D1** (hook logic → `claude-spt hook <event>` binary + static-forever hooks.json via the
48:binaries, so that when the asks land the remaining wiring (D1–D3) is small. The plugin `sptc`→`spt`
124:- **D1** generic `hooks.json` + `claude-spt hook <event>` subcommand ← ask #1 (`spt api run-hook`).


Where Rust crate? adapter/ has no src. Find it.


find . -name "Cargo.toml" -not -path "*/target/*" 2>/dev/null; echo "===tools==="; ls -R tools/ 2>/dev/null | head -60

Find Cargo.toml + tools tree


./tools/claude-spt/Cargo.toml
===tools===
tools/:
claude-spt

tools/claude-spt:
Cargo.lock
Cargo.toml
src
target

tools/claude-spt/src:
digest.rs
main.rs
post_update.rs
psyche.rs
translate.rs

tools/claude-spt/target:
CACHEDIR.TAG
debug
release
x86_64-unknown-linux-gnu

tools/claude-spt/target/debug:
build
deps
examples
incremental

tools/claude-spt/target/debug/build:
serde_core-7ad57868b8ba7d4d
serde_core-ea1d3e3bb6a88daa
serde_json-0b84aa2edb2a546c
serde_json-396445d2685854af
zmij-a9ec217145cb16c5
zmij-e4adaa3795e0fe20

tools/claude-spt/target/debug/build/serde_core-7ad57868b8ba7d4d:
invoked.timestamp
out
output
root-output
stderr

tools/claude-spt/target/debug/build/serde_core-7ad57868b8ba7d4d/out:
private.rs

tools/claude-spt/target/debug/build/serde_core-ea1d3e3bb6a88daa:
build-script-build.exe
build_script_build-ea1d3e3bb6a88daa.d
build_script_build-ea1d3e3bb6a88daa.exe
build_script_build-ea1d3e3bb6a88daa.pdb
build_script_build.pdb

tools/claude-spt/target/debug/build/serde_json-0b84aa2edb2a546c:
invoked.timestamp
out
output
root-output
stderr


1	//! claude-spt — consolidated tool binary for the claude-spt adapter (ADR-0006, U2).
2	//!
3	//! ONE binary, three subcommands (was three crates → fewer artifacts per triple in adapter.spt):
4	//!   claude-spt digest      — the [digest] extractor (was claude-spt-digest)
5	//!   claude-spt psyche      — the [session.psyche_init] Psyche runner (was claude-spt-psyche)
6	//!   claude-spt post-update — reconcile the cplugs plugin after `spt adapter update` (ADR-0006)
7	//!   claude-spt translate   — the [message-idle-translation-binary] idle filter (was cc-spt-idle-translate)
8	//!
9	//! Dispatch is a bare `argv[1]` match (no clap — keep the dependency-light ethos the predecessor
10	//! crates were built on). Each subcommand owns its remaining argv via `std::env::args().skip(2)`
11	//! (skip the binary name + the subcommand token) and its own hand-written flag parser. `translate`
12	//! reads no argv (pure stdin/stdout protocol).
13	//!
14	//! `translate` folded in at the v0.8.0 cut once spt-core v0.16.0 gave `[message-idle-translation-
15	//! binary]` a `command` field (D3) — so claude-spt is now the SINGLE tool binary (one artifact per
16	//! triple in adapter.spt). [impl->REQ-DIST-BINARY-CONSOLIDATE] [impl->REQ-DIST-IDLE-TRANSLATE]
17
18	mod digest;
19	mod post_update;
20	mod psyche;
21	mod translate;
22
23	use std::process::ExitCode;
24
25	/// The resolved subcommand. Pure classification of `argv[1]` so dispatch routing is unit-testable
26	/// without spawning the binary. [impl->REQ-DIST-BINARY-CONSOLIDATE]
27	#[derive(Debug, PartialEq)]
28	enum Sub {
29	    Digest,
30	    Psyche,
31	    PostUpdate,
32	    Translate,
33	    Help,
34	    Unknown(String),
35	}
36
37	fn classify(sub: Option<&str>) -> Sub {
38	    match sub {
39	        Some("digest") => Sub::Digest,
40	        Some("psyche") => Sub::Psyche,
41	        Some("post-update") => Sub::PostUpdate,
42	        Some("translate") => Sub::Translate,
43	        None | Some("-h") | Some("--help") => Sub::Help,
44	        Some(other) => Sub::Unknown(other.to_string()),
45	    }
46	}
47
48	fn usage() {
49	    eprintln!(
50	        "claude-spt <subcommand> [args]\n\
51	         \n\
52	         subcommands:\n\
53	         \x20 digest       map a Claude Code JSONL transcript to digest NDJSON ([digest] extractor)\n\
54	         \x20 psyche       run the LiveAgent Psyche companion ([session.psyche_init] runner)\n\
55	         \x20 post-update  reconcile the cplugs plugin after `spt adapter update`\n\
56	         \x20 translate    idle-message translation filter (stdin->stdout JSON lines)"
57	    );
58	}
59
60	fn main() -> ExitCode {
61	    let sub = std::env::args().nth(1);
62	    match classify(sub.as_deref()) {
63	        Sub::Digest => digest::run(),
64	        Sub::Psyche => psyche::run(),
65	        Sub::PostUpdate => post_update::run(),
66	        Sub::Translate => translate::run(),
67	        Sub::Help => {
68	            usage();
69	            ExitCode::SUCCESS
70	        }
71	        Sub::Unknown(other) => {
72	            eprintln!("claude-spt: unknown subcommand: {other}");
73	            usage();
74	            ExitCode::from(2)
75	        }
76	    }
77	}
78
79	// [unit->REQ-DIST-BINARY-CONSOLIDATE]
80	#[cfg(test)]
81	mod tests {
82	    use super::*;
83
84	    #[test]
85	    fn classifies_each_real_subcommand() {
86	        assert_eq!(classify(Some("digest")), Sub::Digest);
87	        assert_eq!(classify(Some("psyche")), Sub::Psyche);
88	        assert_eq!(classify(Some("post-update")), Sub::PostUpdate);
89	        assert_eq!(classify(Some("translate")), Sub::Translate);
90	    }
91
92	    #[test]
93	    fn no_subcommand_and_help_flags_are_help() {
94	        assert_eq!(classify(None), Sub::Help);
95	        assert_eq!(classify(Some("-h")), Sub::Help);
96	        assert_eq!(classify(Some("--help")), Sub::Help);
97	    }
98
99	    #[test]
100	    fn unrecognized_subcommand_is_unknown_not_misrouted() {
101	        // A typo must NOT silently fall through to a real subcommand (it exits 2 in main).
102	        assert_eq!(classify(Some("digset")), Sub::Unknown("digset".into()));
103	    }
104	}
105

//! claude-spt — consolidated tool binary for the claude-spt adapter (ADR-0006, U2).
//!
//! ONE binary, three subcommands (was three crates → fewer artifacts per triple in adapter.spt):
//!   claude-spt digest      — the [digest] extractor (was claude-spt-digest)
//!   claude-spt psyche      — the [session.psyche_init] Psyche runner (was claude-spt-psyche)
//!   claude-spt post-update — reconcile the cplugs plugin after `spt adapter update` (ADR-0006)
//!   claude-spt translate   — the [message-idle-translation-binary] idle filter (was cc-spt-idle-translate)
//!
//! Dispatch is a bare `argv[1]` match (no clap — keep the dependency-light ethos the predecessor
//! crates were built on). Each subcommand owns its remaining argv via `std::env::args().skip(2)`
//! (skip the binary name + the subcommand token) and its own hand-written flag parser. `translate`
//! reads no argv (pure stdin/stdout protocol).
//!
//! `translate` folded in at the v0.8.0 cut once spt-core v0.16.0 gave `[message-idle-translation-
//! binary]` a `command` field (D3) — so claude-spt is now the SINGLE tool binary (one artifact per
//! triple in adapter.spt). [impl->REQ-DIST-BINARY-CONSOLIDATE] [impl->REQ-DIST-IDLE-TRANSLATE]

mod digest;
mod post_update;
mod psyche;
mod translate;

use std::process::ExitCode;

/// The resolved subcommand. Pure classification of `argv[1]` so dispatch routing is unit-testable
/// without spawning the binary. [impl->REQ-DIST-BINARY-CONSOLIDATE]
#[derive(Debug, PartialEq)]
enum Sub {
    Digest,
    Psyche,
    PostUpdate,
    Translate,
    Help,
    Unknown(String),
}

fn classify(sub: Option<&str>) -> Sub {
    match sub {
        Some("digest") => Sub::Digest,
        Some("psyche") => Sub::Psyche,
        Some("post-update") => Sub::PostUpdate,
        Some("translate") => Sub::Translate,
        None | Some("-h") | Some("--help") => Sub::Help,
        Some(other) => Sub::Unknown(other.to_string()),
    }
}

fn usage() {
    eprintln!(
        "claude-spt <subcommand> [args]\n\
         \n\
         subcommands:\n\
         \x20 digest       map a Claude Code JSONL transcript to digest NDJSON ([digest] extractor)\n\
         \x20 psyche       run the LiveAgent Psyche companion ([session.psyche_init] runner)\n\
         \x20 post-update  reconcile the cplugs plugin after `spt adapter update`\n\
         \x20 translate    idle-message translation filter (stdin->stdout JSON lines)"
    );
}

fn main() -> ExitCode {
    let sub = std::env::args().nth(1);
    match classify(sub.as_deref()) {
        Sub::Digest => digest::run(),
        Sub::Psyche => psyche::run(),
        Sub::PostUpdate => post_update::run(),
        Sub::Translate => translate::run(),
        Sub::Help => {
            usage();
            ExitCode::SUCCESS
        }
        Sub::Unknown(other) => {
            eprintln!("claude-spt: unknown subcommand: {other}");
            usage();
            ExitCode::from(2)
        }
    }
}

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

    #[test]
    fn classifies_each_real_subcommand() {
        assert_eq!(classify(Some("digest")), Sub::Digest);
        assert_eq!(classify(Some("psyche")), Sub::Psyche);
        assert_eq!(classify(Some("post-update")), Sub::PostUpdate);
        assert_eq!(classify(Some("translate")), Sub::Translate);
    }

    #[test]
    fn no_subcommand_and_help_flags_are_help() {
        assert_eq!(classify(None), Sub::Help);
        assert_eq!(classify(Some("-h")), Sub::Help);
        assert_eq!(classify(Some("--help")), Sub::Help);
    }

    #[test]
    fn unrecognized_subcommand_is_unknown_not_misrouted() {
        // A typo must NOT silently fall through to a real subcommand (it exits 2 in main).
        assert_eq!(classify(Some("digset")), Sub::Unknown("digset".into()));
    }
}


1	# claude-spt — the consolidated tool binary for the claude-spt adapter (ADR-0006, U2).
2	# Merges the former claude-spt-digest + claude-spt-psyche crates into ONE binary with subcommands
3	# (`claude-spt digest` / `claude-spt psyche` / `claude-spt post-update`), so the fat adapter.spt
4	# carries fewer artifacts per triple and the seams share one dep tree. Rust for the same reasons as
5	# its predecessors: a compiled, dependency-light binary the spt-core daemon can spawn BARE on
6	# Win+Linux with no interpreter-on-PATH or locale-encoding hazard (stdout is always UTF-8).
7	#
8	# Subcommand dispatch is hand-rolled (a bare argv[1] match), NOT clap: both predecessor crates were
9	# deliberately dependency-light (psyche was std-only; digest carried serde_json alone), and a flat
10	# three-way dispatch needs no arg-parsing framework. Each subcommand keeps its own hand-written flag
11	# parser (verbatim from its origin crate). [impl->REQ-DIST-BINARY-CONSOLIDATE]
12	#
13	# Builds via ci/digest/build.sh + ci/psyche/build.sh (both now build THIS crate), gated in
14	# ci/run-gates.sh; packed into adapter.spt by ci/publish/package-adapter.sh.
15	[package]
16	name = "claude-spt"
17	version = "0.1.0"
18	edition = "2021"
19	publish = false
20
21	[[bin]]
22	name = "claude-spt"
23	path = "src/main.rs"
24
25	[dependencies]
26	serde_json = "1"
27
28	[profile.release]
29	strip = true
30

# claude-spt — the consolidated tool binary for the claude-spt adapter (ADR-0006, U2).
# Merges the former claude-spt-digest + claude-spt-psyche crates into ONE binary with subcommands
# (`claude-spt digest` / `claude-spt psyche` / `claude-spt post-update`), so the fat adapter.spt
# carries fewer artifacts per triple and the seams share one dep tree. Rust for the same reasons as
# its predecessors: a compiled, dependency-light binary the spt-core daemon can spawn BARE on
# Win+Linux with no interpreter-on-PATH or locale-encoding hazard (stdout is always UTF-8).
#
# Subcommand dispatch is hand-rolled (a bare argv[1] match), NOT clap: both predecessor crates were
# deliberately dependency-light (psyche was std-only; digest carried serde_json alone), and a flat
# three-way dispatch needs no arg-parsing framework. Each subcommand keeps its own hand-written flag
# parser (verbatim from its origin crate). [impl->REQ-DIST-BINARY-CONSOLIDATE]
#
# Builds via ci/digest/build.sh + ci/psyche/build.sh (both now build THIS crate), gated in
# ci/run-gates.sh; packed into adapter.spt by ci/publish/package-adapter.sh.
[package]
name = "claude-spt"
version = "0.1.0"
edition = "2021"
publish = false

[[bin]]
name = "claude-spt"
path = "src/main.rs"

[dependencies]
serde_json = "1"

[profile.release]
strip = true


Read all shell hooks + _common.sh — these are what I port to Rust.


1	# shellcheck shell=sh
2	# sptc hook common helpers — sourced by each hook wrapper.
3	# DRAFT (pending throwaway-CC validation: Windows-shell, env-file timing, whoami-in-hook).
4	# spt-core is harness-agnostic; this is the CC adapter glue that maps CC hook input -> `spt api`.
5
6	ADAPTER=claude-spt
7
8	# CC spills additionalContext over ~10,000 chars to a file (dropping it from the inline context the
9	# agent sees). We pre-empt below this with margin for the marker + CC's own framing. Override-able
10	# for tests. (ADR-0002 Open #2.)
11	SPTC_CTX_CAP="${SPTC_CTX_CAP:-9000}"
12
13	# Resolve the spt binary: PATH first (post-bootstrap), then known install locations.
14	spt_bin() {
15	  if command -v spt >/dev/null 2>&1; then printf 'spt'; return 0; fi
16	  for p in \
17	    "$HOME/.local/bin/spt" \
18	    "$LOCALAPPDATA/spt-core/bin/spt.exe" \
19	    "$HOME/AppData/Local/spt-core/bin/spt.exe"; do
20	    [ -x "$p" ] && { printf '%s' "$p"; return 0; }
21	  done
22	  printf 'spt'  # last resort; caller tolerates failure
23	}
24
25	# Extract a top-level string field from a flat JSON object on stdin payload ($1=json, $2=key).
26	# CC hook input is a flat object for the fields we need (session_id, source, prompt, agent_id).
27	json_str() {
28	  printf '%s' "$1" | sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -n1
29	}
30
31	# Decide the SessionStart registration verb for this spawn ($1 = CC hook `source`; reads env
32	# $SPT_ENDPOINT_ID). Pure — no spt/CC. [impl->REQ-DIST-SHORTCUT-BASENAME]
33	#   boundary — a /clear or /compact within a live session (rebind the perch to the new session id)
34	#   bind     — spt-hosted: `spt endpoint run` spawned us via [session.self] + injected the endpoint
35	#              id into $SPT_ENDPOINT_ID; the perch already exists, self-register post-spawn
36	#   seed     — harness-hosted: user-launched CC; record an ephemeral seed for /sptc:ready|live
37	sptc_register_verb() {
38	  case "$1" in
39	    clear|compact) printf boundary ;;
40	    *) if [ -n "${SPT_ENDPOINT_ID:-}" ]; then printf bind; else printf seed; fi ;;
41	  esac
42	}
43
44	# Resolve this session's perch id via spt whoami (off $OWL_SESSION_ID / $SPT_AGENT_ID).
45	# Empty => no perch yet (session never readied) => caller no-ops.
46	sptc_self_id() {
47	  _sid="$1"; _spt="$(spt_bin)"
48	  OWL_SESSION_ID="${OWL_SESSION_ID:-$_sid}" "$_spt" whoami 2>/dev/null | head -n1
49	}
50
51	# Decode an spt envelope body to plain text: literal `<br>` -> newline, then HTML entities
52	# (< > " then & LAST, to avoid double-decoding). Exactly the live-agent
53	# body-parsing rule (spt-proto::event, ADR-0001 grammar).
54	sptc_unescape() {
55	  printf '%s' "$1" | sed \
56	    -e 's/
/\
57	/g' \
58	    -e 's/</</g' -e 's/>/>/g' -e 's/"/"/g' -e 's/&/\&/g'
59	}
60
61	# Render an `api poll` drain ($1) for CC. Canonical format: every message is a
62	# self-delimiting `<EVENT type="msg" from="<sender>">body</EVENT>` envelope (spt-proto::event) —
63	# the same grammar the live listener emits. Multi-message drains split cleanly on `</EVENT>`.
64	# Sender is preserved as `from=` (reply-correlation). NOTE: targets canonical <EVENT>; the current
65	# 0.6.0 binary still emits a `__REPLY_TO__` relic at the poll surface until the REQ-MSG-ENVELOPE
66	# refactor lands — finalize/validate against poll only post-refactor. [unit->REQ-UPS-INJECTION]
67	render_frames() {
68	  _in="$1"
69	  [ -z "$_in" ] && return 0
70	  # Normalise to one <EVENT>…</EVENT> per line (body newlines are
-escaped, so each envelope
71	  # is single-line), then parse each.
72	  printf '%s' "$_in" | sed 's#</EVENT>#</EVENT>\
73	#g' | while IFS= read -r _ev; do
74	    case "$_ev" in
75	      *"<EVENT"*"</EVENT>"*) ;;
76	      *) continue ;;
77	    esac
78	    _sender=$(printf '%s' "$_ev" | sed -n 's/.*<EVENT[^>]* from="\([^"]*\)".*/\1/p')
79	    _raw=$(printf '%s' "$_ev" | sed -n 's#.*<EVENT[^>]*>\(.*\)</EVENT>.*#\1#p')
80	    _body=$(sptc_unescape "$_raw")
81	    if [ -n "$_sender" ]; then
82	      printf '<sptc_messages from="%s">\n%s\n</sptc_messages>\n' "$_sender" "$_body"
83	    else
84	      printf '<sptc_messages>\n%s\n</sptc_messages>\n' "$_body"
85	    fi
86	  done
87	}
88
89	# Extract the skill name from a `/sptc:<skill>` slash-command prompt ($1=prompt). UPS fires on a
90	# slash-command with the token intact (ADR-0002 validation 2026-06-15: `prompt:"/sptc:send doyle"`),
91	# so the wrapper detects the command here. Leading-only (after optional whitespace) to avoid firing
92	# on prose that merely mentions `/sptc:x` mid-sentence. Empty => not a sptc slash-command.
93	# [unit->REQ-UPS-INJECTION]
94	sptc_skill_key() {
95	  printf '%s' "$1" | sed -n 's#^[[:space:]]*/sptc:\([a-z][a-z0-9-]*\).*#\1#p' | head -n1
96	}
97
98	# Inject a skill's operative instructions ($1=skill name) as additionalContext. Resolves the
99	# file-backed/inline body from the adapter `[strings.skills].<skill>` via `spt adapter get-string`
100	# (F-003: pointer values resolve to file contents at read time). No-op (silent) if spt is absent,
101	# the adapter is unregistered, or the key is unset — the thin SKILL.md skeleton still loaded by CC
102	# is the floor. The injected body is what the agent follows for this `/sptc:<skill>` invocation.
103	# [impl->REQ-UPS-INJECTION]
104	sptc_inject_skill() {
105	  _skill="$1"; [ -z "$_skill" ] && return 0
106	  _spt="$(spt_bin)"
107	  _body=$("$_spt" adapter get-string "$ADAPTER" "skills.$_skill" 2>/dev/null) || return 0
108	  [ -z "$_body" ] && return 0
109	  printf '<sptc_skill name="%s">\n%s\n</sptc_skill>\n' "$_skill" "$_body"
110	}
111
112	# Emit additionalContext under CC's ~10k spill threshold ($1=text, $2=cap, $3=spill_path). Under the
113	# cap -> emit as-is. Over -> spill the FULL text to a file the agent can Read and emit ONLY a concise
114	# pointer marker (never a partial inline — a head-cut would split a <sptc_messages>/<EVENT> block and
115	# lose a message silently). The agent reads the spill file to see everything; nothing is dropped. CC
116	# would otherwise spill oversized additionalContext itself, evicting it from the inline context.
117	# [impl->REQ-UPS-INJECTION]
118	sptc_cap_output() {
119	  _text="$1"; _cap="$2"; _spill="$3"
120	  [ -z "$_text" ] && return 0
121	  _len=$(printf '%s' "$_text" | wc -c | tr -d ' ')
122	  if [ "$_len" -le "$_cap" ]; then
123	    printf '%s' "$_text"
124	    return 0
125	  fi
126	  printf '%s' "$_text" > "$_spill" 2>/dev/null || _spill="(spill failed; content too large to inline)"
127	  printf '<sptc_overflow bytes="%s" cap="%s" spilled_to="%s">\nDelivery exceeded CC'\''s additionalContext cap. The full content (skill instructions and/or %s message bytes) was written to the file above — read it now to see everything; it is NOT inlined here to avoid silently dropping a message.\n</sptc_overflow>\n' \
128	    "$_len" "$_cap" "$_spill" "$_len"
129	}
130
131	# ── Checkpoint detection (REQ-DIST-CHECKPOINT-COMMUNE) ──────────────────────────────────────────
132	# A live agent requests a CHECKPOINT (self-initiated context reset) by embedding the literal
133	# `!!checkpoint!!` trigger in its commune. The PostToolUse hook detects it at Write-time and self-sends
134	# a structured signal that loops back through the translation binary's clear+wake macro. spt-core
135	# v0.15.0 strips the marker from the durable/pending context on ingest, so it is one-shot. The pure
136	# predicates below run on a single physical line (the raw hook JSON, whose content newlines are `\n`
137	# escapes) so the paired-marker scan works even for a multi-line commune body.
138
139	# PURE predicate: does this commune content ($1) carry the checkpoint trigger? exit 0 iff present.
140	# [unit->REQ-DIST-CHECKPOINT-COMMUNE]
141	sptc_has_checkpoint() {
142	  case "$1" in *'!!checkpoint!!'*) return 0 ;; *) return 1 ;; esac
143	}
144
145	# PURE: extract a CUSTOM wake directive from commune content ($1) — the text between the FIRST pair of
146	# `!!checkpoint!!` markers, trimmed. Prints nothing when there is only one marker (single = default
147	# wake; the translation binary supplies the default) or none. The pair must be inline (same physical
148	# line); a lone or unmatched marker yields the default. [unit->REQ-DIST-CHECKPOINT-COMMUNE]
149	sptc_checkpoint_wake() {
150	  _n=$(printf '%s' "$1" | grep -o '!!checkpoint!!' | wc -l | tr -d ' ')
151	  [ "${_n:-0}" -ge 2 ] || return 0
152	  printf '%s' "$1" | sed -n 's/.*!!checkpoint!!\(.*\)!!checkpoint!!.*/\1/p' | head -n1 \
153	    | sed 's/^[[:space:]]*//; s/[[:space:]]*$//'
154	}
155
156	# PURE predicate: is this tool call ($1=tool_name, $2=file_path, $3=endpoint id) a Write to THIS
157	# agent's own commune file `<id>-commune.md`? Suffix match tolerates the JSON-escaped Windows path
158	# (doubled backslashes) and either separator. exit 0 iff it is. [unit->REQ-DIST-CHECKPOINT-COMMUNE]
159	sptc_is_commune_write() {
160	  [ "$1" = "Write" ] || return 1
161	  [ -n "$3" ] || return 1
162	  case "$2" in *"$3-commune.md") return 0 ;; *) return 1 ;; esac
163	}
164
165	# ── SessionStart agent-facing briefs (REQ-DIST-SESSIONSTART-BRIEF) ──────────────────────────────
166	# The hook COMPOSES adapter-string briefs into a SessionStart additionalContext block; it never
167	# authors agent-facing prose (single-source throughline — bodies live in [strings.briefs]). Pure
168	# assemblers below carry the only composition logic ({id} substitution, concat, wrapping) so they
169	# unit-test without spt; the impure resolvers wrap them with `spt adapter get-string`.
170
171	# Resolve a brief body by key from the adapter `[strings.briefs].<key>` (file-backed or inline).
172	# Empty on absent key / unregistered adapter / missing spt — caller tolerates. [impl->REQ-DIST-SESSIONSTART-BRIEF]
173	sptc_brief() {
174	  _key="$1"; [ -z "$_key" ] && return 0
175	  _spt="$(spt_bin)"
176	  "$_spt" adapter get-string "$ADAPTER" "briefs.$_key" 2>/dev/null
177	}
178
179	# PURE: assemble the identity (perched) block. $1=id $2=identity-body $3=messaging-body $4=roster.
180	# Substitutes {id} in the identity body (agent ids are [a-z0-9-] — sed-safe, no metachar risk).
181	# [impl->REQ-DIST-SESSIONSTART-BRIEF]
182	sptc_assemble_perch() {
183	  _id="$1"
184	  printf '<sptc-active-perch id="%s">\n%s\n\n%s\n\n%s\n</sptc-active-perch>' \
185	    "$_id" "$(printf '%s' "$2" | sed "s/{id}/$_id/g")" "$3" "$4"
186	}
187
188	# PURE: assemble the ring (no-perch) block. $1=messaging-body $2=roster. [impl->REQ-DIST-SESSIONSTART-BRIEF]
189	sptc_assemble_noperch() {
190	  printf '<sptc-reach>\n%s\n\n%s\n</sptc-reach>' "$1" "$2"
191	}
192
193	# Impure: resolve + assemble the perched-session identity brief for id $1. The live-ops block
194	# (commune incl --checkpoint, signoff — REQ-DIST-SKELETON-THIN/U4) is appended to the messaging block
195	# so a perched/live agent is briefed on upkeep proactively (the reactive /sptc:commune|signoff SKILL.md
196	# are now thin stubs). Composed into messaging (not a new positional) so sptc_assemble_perch + its unit
197	# test stay unchanged; skipped cleanly if the live-ops string is absent (older adapter).
198	# [impl->REQ-DIST-SESSIONSTART-BRIEF] [impl->REQ-DIST-SKELETON-THIN]
199	sptc_perch_brief() {
200	  _msg="$(sptc_brief messaging-perch)"
201	  _ops="$(sptc_brief live-ops)"
202	  [ -n "$_ops" ] && _msg="$_msg
203
204	$_ops"
205	  sptc_assemble_perch "$1" "$(sptc_brief identity)" "$_msg" "$(sptc_brief endpoint-list)"
206	}
207
208	# Impure: resolve + assemble the no-perch ring brief. [impl->REQ-DIST-SESSIONSTART-BRIEF]
209	sptc_noperch_brief() {
210	  sptc_assemble_noperch "$(sptc_brief messaging-no-perch)" "$(sptc_brief endpoint-list)"
211	}
212
213	# Impure: pull the live-agent durable RESUME context for endpoint id $1 (optional session id $2) via
214	# `spt api psyche-download` — the spt-core v0.15.0 W5 verb that closes F-020 (claude-spt rehydrates NO
215	# durable context today). stdout = durable role/live/project tiers + the freshest not-yet-synthesized
216	# <pending-commune>/<pending-signoff> (trigger stripped core-side), to inject VERBATIM. Mirrors the
217	# id-scoped `api poll` auth shape (--session-id proves association; project resolves from the bound
218	# cwd, no --project). Empty on NO-CONTEXT (stderr) / the verb absent (pre-v0.15.0 node) / unregistered
219	# — caller appends nothing. Also the checkpoint re-seed fast-path. [impl->REQ-DIST-RESUME-CONTEXT]
220	sptc_psyche_download() {
221	  _id="$1"; [ -z "$_id" ] && return 0
222	  _psid="$2"; _spt="$(spt_bin)"
223	  if [ -n "$_psid" ]; then
224	    "$_spt" api --adapter "$ADAPTER" psyche-download "$_id" --session-id "$_psid" 2>/dev/null
225	  else
226	    "$_spt" api --adapter "$ADAPTER" psyche-download "$_id" 2>/dev/null
227	  fi
228	}
229
230	# PURE: append resume context ($2) below a SessionStart brief ($1), skipping cleanly when the resume
231	# is empty (NO-CONTEXT). Newline-joined so the verbatim XML resume sits below the identity brief.
232	# [impl->REQ-DIST-RESUME-CONTEXT]
233	sptc_append_resume() {
234	  if [ -n "$2" ]; then printf '%s\n%s' "$1" "$2"; else printf '%s' "$1"; fi
235	}
236
237	# PURE predicate: should this SessionStart inject a brief? Subagent sessions (agent_type set) get
238	# none. $1 = the stdin agent_type value (empty = a real user session). [impl->REQ-DIST-SESSIONSTART-BRIEF]
239	sptc_is_subagent() { [ -n "$1" ]; }
240
241	# PURE predicate: peer-presence gate for the ring brief. Reads `spt subnet status` output on stdin;
242	# exit 0 (peers) iff >1 non-empty line (header + >=1 subnet row). Line-count only — NEVER parses a
243	# column value (the columnar layout is human-formatted, not a hook contract). [impl->REQ-DIST-SESSIONSTART-BRIEF]
244	sptc_has_peers_lines() {
245	  _n=$(grep -c . 2>/dev/null)
246	  [ "${_n:-0}" -gt 1 ]
247	}
248
249	# Impure: does this node participate in any subnet (has reachable peers)? [impl->REQ-DIST-SESSIONSTART-BRIEF]
250	sptc_node_has_peers() {
251	  _spt="$(spt_bin)"
252	  "$_spt" subnet status 2>/dev/null | sptc_has_peers_lines
253	}
254
255	# PURE: JSON-escape stdin into a string value (no surrounding quotes). Escapes \ then " , tabs, CR;
256	# newlines become literal \n. awk so multi-line bodies survive. [impl->REQ-DIST-SESSIONSTART-BRIEF]
257	sptc_json_escape() {
258	  awk '
259	    BEGIN { ORS="" }
260	    {
261	      gsub(/\\/, "\\\\"); gsub(/"/, "\\\""); gsub(/\t/, "\\t"); gsub(/\r/, "\\r");
262	      if (NR > 1) printf "%s", "\\n";
263	      printf "%s", $0;
264	    }
265	  '
266	}
267
268	# Emit a SessionStart hookSpecificOutput JSON line carrying $1 as additionalContext. No-op on empty
269	# ($1 unset/blank → nothing to stdout, hook stays silent). [impl->REQ-DIST-SESSIONSTART-BRIEF]
270	sptc_emit_additional_context() {
271	  [ -z "$1" ] && return 0
272	  _esc=$(printf '%s' "$1" | sptc_json_escape)
273	  printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"%s"}}\n' "$_esc"
274	}
275

# shellcheck shell=sh
# sptc hook common helpers — sourced by each hook wrapper.
# DRAFT (pending throwaway-CC validation: Windows-shell, env-file timing, whoami-in-hook).
# spt-core is harness-agnostic; this is the CC adapter glue that maps CC hook input -> `spt api`.

ADAPTER=claude-spt

# CC spills additionalContext over ~10,000 chars to a file (dropping it from the inline context the
# agent sees). We pre-empt below this with margin for the marker + CC's own framing. Override-able
# for tests. (ADR-0002 Open #2.)
SPTC_CTX_CAP="${SPTC_CTX_CAP:-9000}"

# Resolve the spt binary: PATH first (post-bootstrap), then known install locations.
spt_bin() {
  if command -v spt >/dev/null 2>&1; then printf 'spt'; return 0; fi
  for p in \
    "$HOME/.local/bin/spt" \
    "$LOCALAPPDATA/spt-core/bin/spt.exe" \
    "$HOME/AppData/Local/spt-core/bin/spt.exe"; do
    [ -x "$p" ] && { printf '%s' "$p"; return 0; }
  done
  printf 'spt'  # last resort; caller tolerates failure
}

# Extract a top-level string field from a flat JSON object on stdin payload ($1=json, $2=key).
# CC hook input is a flat object for the fields we need (session_id, source, prompt, agent_id).
json_str() {
  printf '%s' "$1" | sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -n1
}

# Decide the SessionStart registration verb for this spawn ($1 = CC hook `source`; reads env
# $SPT_ENDPOINT_ID). Pure — no spt/CC. [impl->REQ-DIST-SHORTCUT-BASENAME]
#   boundary — a /clear or /compact within a live session (rebind the perch to the new session id)
#   bind     — spt-hosted: `spt endpoint run` spawned us via [session.self] + injected the endpoint
#              id into $SPT_ENDPOINT_ID; the perch already exists, self-register post-spawn
#   seed     — harness-hosted: user-launched CC; record an ephemeral seed for /sptc:ready|live
sptc_register_verb() {
  case "$1" in
    clear|compact) printf boundary ;;
    *) if [ -n "${SPT_ENDPOINT_ID:-}" ]; then printf bind; else printf seed; fi ;;
  esac
}

# Resolve this session's perch id via spt whoami (off $OWL_SESSION_ID / $SPT_AGENT_ID).
# Empty => no perch yet (session never readied) => caller no-ops.
sptc_self_id() {
  _sid="$1"; _spt="$(spt_bin)"
  OWL_SESSION_ID="${OWL_SESSION_ID:-$_sid}" "$_spt" whoami 2>/dev/null | head -n1
}

# Decode an spt envelope body to plain text: literal `<br>` -> newline, then HTML entities
# (< > " then & LAST, to avoid double-decoding). Exactly the live-agent
# body-parsing rule (spt-proto::event, ADR-0001 grammar).
sptc_unescape() {
  printf '%s' "$1" | sed \
    -e 's/
/\
/g' \
    -e 's/</</g' -e 's/>/>/g' -e 's/"/"/g' -e 's/&/\&/g'
}

# Render an `api poll` drain ($1) for CC. Canonical format: every message is a
# self-delimiting `<EVENT type="msg" from="<sender>">body</EVENT>` envelope (spt-proto::event) —
# the same grammar the live listener emits. Multi-message drains split cleanly on `</EVENT>`.
# Sender is preserved as `from=` (reply-correlation). NOTE: targets canonical <EVENT>; the current
# 0.6.0 binary still emits a `__REPLY_TO__` relic at the poll surface until the REQ-MSG-ENVELOPE
# refactor lands — finalize/validate against poll only post-refactor. [unit->REQ-UPS-INJECTION]
render_frames() {
  _in="$1"
  [ -z "$_in" ] && return 0
  # Normalise to one <EVENT>…</EVENT> per line (body newlines are
-escaped, so each envelope
  # is single-line), then parse each.
  printf '%s' "$_in" | sed 's#</EVENT>#</EVENT>\
#g' | while IFS= read -r _ev; do
    case "$_ev" in
      *"<EVENT"*"</EVENT>"*) ;;
      *) continue ;;
    esac
    _sender=$(printf '%s' "$_ev" | sed -n 's/.*<EVENT[^>]* from="\([^"]*\)".*/\1/p')
    _raw=$(printf '%s' "$_ev" | sed -n 's#.*<EVENT[^>]*>\(.*\)</EVENT>.*#\1#p')
    _body=$(sptc_unescape "$_raw")
    if [ -n "$_sender" ]; then
      printf '<sptc_messages from="%s">\n%s\n</sptc_messages>\n' "$_sender" "$_body"
    else
      printf '<sptc_messages>\n%s\n</sptc_messages>\n' "$_body"
    fi
  done
}

# Extract the skill name from a `/sptc:<skill>` slash-command prompt ($1=prompt). UPS fires on a
# slash-command with the token intact (ADR-0002 validation 2026-06-15: `prompt:"/sptc:send doyle"`),
# so the wrapper detects the command here. Leading-only (after optional whitespace) to avoid firing
# on prose that merely mentions `/sptc:x` mid-sentence. Empty => not a sptc slash-command.
# [unit->REQ-UPS-INJECTION]
sptc_skill_key() {
  printf '%s' "$1" | sed -n 's#^[[:space:]]*/sptc:\([a-z][a-z0-9-]*\).*#\1#p' | head -n1
}

# Inject a skill's operative instructions ($1=skill name) as additionalContext. Resolves the
# file-backed/inline body from the adapter `[strings.skills].<skill>` via `spt adapter get-string`
# (F-003: pointer values resolve to file contents at read time). No-op (silent) if spt is absent,
# the adapter is unregistered, or the key is unset — the thin SKILL.md skeleton still loaded by CC
# is the floor. The injected body is what the agent follows for this `/sptc:<skill>` invocation.
# [impl->REQ-UPS-INJECTION]
sptc_inject_skill() {
  _skill="$1"; [ -z "$_skill" ] && return 0
  _spt="$(spt_bin)"
  _body=$("$_spt" adapter get-string "$ADAPTER" "skills.$_skill" 2>/dev/null) || return 0
  [ -z "$_body" ] && return 0
  printf '<sptc_skill name="%s">\n%s\n</sptc_skill>\n' "$_skill" "$_body"
}

# Emit additionalContext under CC's ~10k spill threshold ($1=text, $2=cap, $3=spill_path). Under the
# cap -> emit as-is. Over -> spill the FULL text to a file the agent can Read and emit ONLY a concise
# pointer marker (never a partial inline — a head-cut would split a <sptc_messages>/<EVENT> block and
# lose a message silently). The agent reads the spill file to see everything; nothing is dropped. CC
# would otherwise spill oversized additionalContext itself, evicting it from the inline context.
# [impl->REQ-UPS-INJECTION]
sptc_cap_output() {
  _text="$1"; _cap="$2"; _spill="$3"
  [ -z "$_text" ] && return 0
  _len=$(printf '%s' "$_text" | wc -c | tr -d ' ')
  if [ "$_len" -le "$_cap" ]; then
    printf '%s' "$_text"
    return 0
  fi
  printf '%s' "$_text" > "$_spill" 2>/dev/null || _spill="(spill failed; content too large to inline)"
  printf '<sptc_overflow bytes="%s" cap="%s" spilled_to="%s">\nDelivery exceeded CC'\''s additionalContext cap. The full content (skill instructions and/or %s message bytes) was written to the file above — read it now to see everything; it is NOT inlined here to avoid silently dropping a message.\n</sptc_overflow>\n' \
    "$_len" "$_cap" "$_spill" "$_len"
}

# ── Checkpoint detection (REQ-DIST-CHECKPOINT-COMMUNE) ──────────────────────────────────────────
# A live agent requests a CHECKPOINT (self-initiated context reset) by embedding the literal
# `!!checkpoint!!` trigger in its commune. The PostToolUse hook detects it at Write-time and self-sends
# a structured signal that loops back through the translation binary's clear+wake macro. spt-core
# v0.15.0 strips the marker from the durable/pending context on ingest, so it is one-shot. The pure
# predicates below run on a single physical line (the raw hook JSON, whose content newlines are `\n`
# escapes) so the paired-marker scan works even for a multi-line commune body.

# PURE predicate: does this commune content ($1) carry the checkpoint trigger? exit 0 iff present.
# [unit->REQ-DIST-CHECKPOINT-COMMUNE]
sptc_has_checkpoint() {
  case "$1" in *'!!checkpoint!!'*) return 0 ;; *) return 1 ;; esac
}

# PURE: extract a CUSTOM wake directive from commune content ($1) — the text between the FIRST pair of
# `!!checkpoint!!` markers, trimmed. Prints nothing when there is only one marker (single = default
# wake; the translation binary supplies the default) or none. The pair must be inline (same physical
# line); a lone or unmatched marker yields the default. [unit->REQ-DIST-CHECKPOINT-COMMUNE]
sptc_checkpoint_wake() {
  _n=$(printf '%s' "$1" | grep -o '!!checkpoint!!' | wc -l | tr -d ' ')
  [ "${_n:-0}" -ge 2 ] || return 0
  printf '%s' "$1" | sed -n 's/.*!!checkpoint!!\(.*\)!!checkpoint!!.*/\1/p' | head -n1 \
    | sed 's/^[[:space:]]*//; s/[[:space:]]*$//'
}

# PURE predicate: is this tool call ($1=tool_name, $2=file_path, $3=endpoint id) a Write to THIS
# agent's own commune file `<id>-commune.md`? Suffix match tolerates the JSON-escaped Windows path
# (doubled backslashes) and either separator. exit 0 iff it is. [unit->REQ-DIST-CHECKPOINT-COMMUNE]
sptc_is_commune_write() {
  [ "$1" = "Write" ] || return 1
  [ -n "$3" ] || return 1
  case "$2" in *"$3-commune.md") return 0 ;; *) return 1 ;; esac
}

# ── SessionStart agent-facing briefs (REQ-DIST-SESSIONSTART-BRIEF) ──────────────────────────────
# The hook COMPOSES adapter-string briefs into a SessionStart additionalContext block; it never
# authors agent-facing prose (single-source throughline — bodies live in [strings.briefs]). Pure
# assemblers below carry the only composition logic ({id} substitution, concat, wrapping) so they
# unit-test without spt; the impure resolvers wrap them with `spt adapter get-string`.

# Resolve a brief body by key from the adapter `[strings.briefs].<key>` (file-backed or inline).
# Empty on absent key / unregistered adapter / missing spt — caller tolerates. [impl->REQ-DIST-SESSIONSTART-BRIEF]
sptc_brief() {
  _key="$1"; [ -z "$_key" ] && return 0
  _spt="$(spt_bin)"
  "$_spt" adapter get-string "$ADAPTER" "briefs.$_key" 2>/dev/null
}

# PURE: assemble the identity (perched) block. $1=id $2=identity-body $3=messaging-body $4=roster.
# Substitutes {id} in the identity body (agent ids are [a-z0-9-] — sed-safe, no metachar risk).
# [impl->REQ-DIST-SESSIONSTART-BRIEF]
sptc_assemble_perch() {
  _id="$1"
  printf '<sptc-active-perch id="%s">\n%s\n\n%s\n\n%s\n</sptc-active-perch>' \
    "$_id" "$(printf '%s' "$2" | sed "s/{id}/$_id/g")" "$3" "$4"
}

# PURE: assemble the ring (no-perch) block. $1=messaging-body $2=roster. [impl->REQ-DIST-SESSIONSTART-BRIEF]
sptc_assemble_noperch() {
  printf '<sptc-reach>\n%s\n\n%s\n</sptc-reach>' "$1" "$2"
}

# Impure: resolve + assemble the perched-session identity brief for id $1. The live-ops block
# (commune incl --checkpoint, signoff — REQ-DIST-SKELETON-THIN/U4) is appended to the messaging block
# so a perched/live agent is briefed on upkeep proactively (the reactive /sptc:commune|signoff SKILL.md
# are now thin stubs). Composed into messaging (not a new positional) so sptc_assemble_perch + its unit
# test stay unchanged; skipped cleanly if the live-ops string is absent (older adapter).
# [impl->REQ-DIST-SESSIONSTART-BRIEF] [impl->REQ-DIST-SKELETON-THIN]
sptc_perch_brief() {
  _msg="$(sptc_brief messaging-perch)"
  _ops="$(sptc_brief live-ops)"
  [ -n "$_ops" ] && _msg="$_msg

$_ops"
  sptc_assemble_perch "$1" "$(sptc_brief identity)" "$_msg" "$(sptc_brief endpoint-list)"
}

# Impure: resolve + assemble the no-perch ring brief. [impl->REQ-DIST-SESSIONSTART-BRIEF]
sptc_noperch_brief() {
  sptc_assemble_noperch "$(sptc_brief messaging-no-perch)" "$(sptc_brief endpoint-list)"
}

# Impure: pull the live-agent durable RESUME context for endpoint id $1 (optional session id $2) via
# `spt api psyche-download` — the spt-core v0.15.0 W5 verb that closes F-020 (claude-spt rehydrates NO
# durable context today). stdout = durable role/live/project tiers + the freshest not-yet-synthesized
# <pending-commune>/<pending-signoff> (trigger stripped core-side), to inject VERBATIM. Mirrors the
# id-scoped `api poll` auth shape (--session-id proves association; project resolves from the bound
# cwd, no --project). Empty on NO-CONTEXT (stderr) / the verb absent (pre-v0.15.0 node) / unregistered
# — caller appends nothing. Also the checkpoint re-seed fast-path. [impl->REQ-DIST-RESUME-CONTEXT]
sptc_psyche_download() {
  _id="$1"; [ -z "$_id" ] && return 0
  _psid="$2"; _spt="$(spt_bin)"
  if [ -n "$_psid" ]; then
    "$_spt" api --adapter "$ADAPTER" psyche-download "$_id" --session-id "$_psid" 2>/dev/null
  else
    "$_spt" api --adapter "$ADAPTER" psyche-download "$_id" 2>/dev/null
  fi
}

# PURE: append resume context ($2) below a SessionStart brief ($1), skipping cleanly when the resume
# is empty (NO-CONTEXT). Newline-joined so the verbatim XML resume sits below the identity brief.
# [impl->REQ-DIST-RESUME-CONTEXT]
sptc_append_resume() {
  if [ -n "$2" ]; then printf '%s\n%s' "$1" "$2"; else printf '%s' "$1"; fi
}

# PURE predicate: should this SessionStart inject a brief? Subagent sessions (agent_type set) get
# none. $1 = the stdin agent_type value (empty = a real user session). [impl->REQ-DIST-SESSIONSTART-BRIEF]
sptc_is_subagent() { [ -n "$1" ]; }

# PURE predicate: peer-presence gate for the ring brief. Reads `spt subnet status` output on stdin;
# exit 0 (peers) iff >1 non-empty line (header + >=1 subnet row). Line-count only — NEVER parses a
# column value (the columnar layout is human-formatted, not a hook contract). [impl->REQ-DIST-SESSIONSTART-BRIEF]
sptc_has_peers_lines() {
  _n=$(grep -c . 2>/dev/null)
  [ "${_n:-0}" -gt 1 ]
}

# Impure: does this node participate in any subnet (has reachable peers)? [impl->REQ-DIST-SESSIONSTART-BRIEF]
sptc_node_has_peers() {
  _spt="$(spt_bin)"
  "$_spt" subnet status 2>/dev/null | sptc_has_peers_lines
}

# PURE: JSON-escape stdin into a string value (no surrounding quotes). Escapes \ then " , tabs, CR;
# newlines become literal \n. awk so multi-line bodies survive. [impl->REQ-DIST-SESSIONSTART-BRIEF]
sptc_json_escape() {
  awk '
    BEGIN { ORS="" }
    {
      gsub(/\\/, "\\\\"); gsub(/"/, "\\\""); gsub(/\t/, "\\t"); gsub(/\r/, "\\r");
      if (NR > 1) printf "%s", "\\n";
      printf "%s", $0;
    }
  '
}

# Emit a SessionStart hookSpecificOutput JSON line carrying $1 as additionalContext. No-op on empty
# ($1 unset/blank → nothing to stdout, hook stays silent). [impl->REQ-DIST-SESSIONSTART-BRIEF]
sptc_emit_additional_context() {
  [ -z "$1" ] && return 0
  _esc=$(printf '%s' "$1" | sptc_json_escape)
  printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"%s"}}\n' "$_esc"
}


Read all 8 hook wrappers.


for f in session-start user-prompt-submit pre-tool-use stop session-end subagent-start subagent-stop post-tool-use; do echo "===== $f.sh ====="; cat "plugin/sptc/hooks/$f.sh"; done

Cat all 8 hook wrappers


===== session-start.sh =====
#!/bin/sh
# SessionStart: ensure spt-core installed, then register the perch — bind (spt-hosted, broker
# spawned us via [session.self] + injected $SPT_ENDPOINT_ID), seed (harness-hosted, user-launched
# CC), or boundary-rebind (clear/compact) — and persist session env so whoami + per-prompt hooks
# resolve. Non-blocking (never `listen`).
# [impl->REQ-DIST-HOOKS-API]
. "$CLAUDE_PLUGIN_ROOT/hooks/_common.sh"

input=$(cat)
sid=$(json_str "$input" session_id)
src=$(json_str "$input" source)

# Invisible-installer: bootstrap spt-core if absent (no-op when present).
sh "$CLAUDE_PLUGIN_ROOT/bootstrap.sh" >/dev/null 2>&1 || true
SPT=$(spt_bin)

case "$(sptc_register_verb "$src")" in
  boundary)
    id=$(sptc_self_id "$sid")
    [ -n "$id" ] && "$SPT" api --adapter "$ADAPTER" boundary "$src" "$id" \
      --to-session-id "$sid" >/dev/null 2>&1 || true
    ;;
  bind)
    # spt-hosted: `spt endpoint run` spawned us into the broker PTY and injected the endpoint id via
    # [env.SPT_ENDPOINT_ID]. The perch already exists — self-register post-spawn by binding our
    # discovered CC session id, NOT seed (no seed file in this path). `bind` is an ESTABLISHING call:
    # the broker parentage IS the credential (auth intrinsic), so --set-session-id alone, no proof
    # token — confirmed by doyle 2026-06-15 (harness-contract/api.md "Auth is intrinsic"). Later
    # mutating calls prove association with --session-id "$sid" against the record bind wrote.
    "$SPT" api --adapter "$ADAPTER" bind "$SPT_ENDPOINT_ID" \
      --set-session-id "$sid" >/dev/null 2>&1 || true
    ;;
  seed)
    # harness-hosted (seed→listen path): user-launched CC. Record the seed for /sptc:ready|live.
    # ADAPTER-AGNOSTIC since spt-core v0.9.0 (PREP-4): NO --adapter — `api listen`/`poll` resolve the
    # owning adapter from this seed's parent pid via [adapter] host_binaries = ["claude"] (the legacy-
    # parity bare flow). $PPID is the CC host process (basename `claude`), the host_binaries match-key.
    # (bind/boundary above keep their explicit --adapter override — sanctioned, valid on 0.9.0.)
    "$SPT" api seed --pid "$PPID" --session-id "$sid" >/dev/null 2>&1 || true
    ;;
esac

# Persist for the rest of the session (whoami + per-prompt hooks read these).
if [ -n "$CLAUDE_ENV_FILE" ]; then
  {
    printf 'OWL_SESSION_ID=%s\n' "$sid"
    printf 'SPT_ADAPTER=%s\n' "$ADAPTER"
  } >> "$CLAUDE_ENV_FILE"
fi

# Relay an agent-facing brief as additionalContext (REQ-DIST-SESSIONSTART-BRIEF). Perched sessions
# (bind/boundary — an id already exists) get the identity brief: who they are + perch-is-live +
# how to message. No-perch seeds on a node WITH subnet peers get the ring brief (how to reach others
# without a perch). Seeds with no peers, and subagent (agent_type) sessions, get nothing. All prose
# is adapter-string-backed; this hook only selects + composes. [impl->REQ-DIST-SESSIONSTART-BRIEF]
if ! sptc_is_subagent "$(json_str "$input" agent_type)"; then
  brief=""
  case "$(sptc_register_verb "$src")" in
    bind|boundary)
      # id: the spt-hosted endpoint id if injected, else resolve our perch via whoami.
      bid="${SPT_ENDPOINT_ID:-$(sptc_self_id "$sid")}"
      if [ -n "$bid" ]; then
        brief=$(sptc_perch_brief "$bid")
        # Durable RESUME context (F-020 fix, REQ-DIST-RESUME-CONTEXT): pull the agent's role/live/
        # project tiers + freshest pending commune/signoff and inject it VERBATIM below the identity
        # brief. Fires on a fresh spt-hosted bind AND on a post-/clear boundary — boundary IS the
        # checkpoint re-seed path, where the just-cleared agent rebuilds from its latest commune. The
        # `api boundary` rotation above is unchanged; this is an additive context pull. Empty on
        # NO-CONTEXT or a pre-v0.15.0 node (verb absent) -> nothing appended.
        brief=$(sptc_append_resume "$brief" "$(sptc_psyche_download "$bid" "$sid")")
      fi
      ;;
    seed)
      sptc_node_has_peers && brief=$(sptc_noperch_brief)
      ;;
  esac
  sptc_emit_additional_context "$brief"
fi
exit 0
===== user-prompt-submit.sh =====
#!/bin/sh
# UserPromptSubmit: two jobs on the same hook (ADR-0002 — UPS fires on a `/sptc:X` slash-command
# with the token intact, validated 2026-06-15). Both write to stdout = CC additionalContext:
#   1. SKILL-INJECTION — detect `/sptc:<skill>` in the prompt and inject that skill's operative
#      instructions from the adapter `[strings.skills].<skill>` (the thin SKILL.md stays a stub).
#   2. MESSAGE-DRAIN — `api poll` the perch and surface delivered <EVENT> messages.
# [impl->REQ-UPS-INJECTION]
. "$CLAUDE_PLUGIN_ROOT/hooks/_common.sh"

input=$(cat)
sid=$(json_str "$input" session_id)

# Buffer both outputs so the COMBINED additionalContext is capped once (ADR-0002 Open #2) — a large
# skill body + a big drain could jointly exceed CC's spill threshold.
out=""

# 1. Skill-injection runs BEFORE the perch check — skills like /sptc:version and /sptc:setup are
#    valid without a readied perch (setup even runs before spt exists). No-op if not a sptc command.
prompt=$(json_str "$input" prompt)
out="$(sptc_inject_skill "$(sptc_skill_key "$prompt")")"

# 2. Message-drain needs a perch. No perch (session not readied) -> nothing more to deliver.
id=$(sptc_self_id "$sid")
if [ -n "$id" ]; then
  SPT=$(spt_bin)
  # TURN-START state: mark the perch BUSY so inbound during this turn DEFERS (queues) instead of
  # idle-PTY-injecting — the precondition for mid-turn PreToolUse delivery (F-021). spt-core never
  # infers activity from terminal quiescence (integration-checklist: honest busy/idle). Stop marks
  # idle again at turn-end. Set busy BEFORE the drain so a message arriving mid-drain also defers.
  "$SPT" api --adapter "$ADAPTER" state busy "$id" --session-id "$sid" >/dev/null 2>&1 || true
  # --include-deferred: also pull anything deferred during the previous busy turn not yet drained.
  frames=$("$SPT" api --adapter "$ADAPTER" poll "$id" --session-id "$sid" --include-deferred 2>/dev/null)
  if [ -n "$frames" ]; then
    # Format for CC, preserving the sender (reply-correlation). render_frames parses
    # the self-delimiting <EVENT> envelope — multi-message drains split on </EVENT>.
    rendered=$(render_frames "$frames")
    [ -n "$rendered" ] && out="${out:+$out
}$rendered"
  fi
fi

# Emit under CC's additionalContext cap; oversized drains spill to a file the agent reads (no
# silent message loss). Spill path is an absolute, agent-readable location keyed by session.
spill="${HOME:-${USERPROFILE:-.}}/.claude/sptc-drain-${sid:-unknown}.txt"
sptc_cap_output "$out" "$SPTC_CTX_CAP" "$spill"
exit 0
===== pre-tool-use.sh =====
#!/bin/sh
# PreToolUse: MID-TURN message delivery (F-021 reachability parity). A live agent marked BUSY at
# turn-start (UserPromptSubmit) has its inbound messages DEFERRED by spt-core rather than
# idle-PTY-injected; this hook fires before each tool call and DRAINS those deferred messages with
# `api poll <id> --include-deferred` (the checklist pull path — harness-contract/integration-checklist),
# surfacing them as additionalContext so the agent sees a message WHILE working. It is the mid-turn
# complement of the UserPromptSubmit drain (between-turns) — together they cover the legacy spt
# poll+inject-on-PreToolUse reachability. No skill-injection here (no prompt on this event) and NO
# state change (the turn stays busy until Stop). [impl->REQ-DIST-PRETOOL-POLL]
. "$CLAUDE_PLUGIN_ROOT/hooks/_common.sh"

input=$(cat)
sid=$(json_str "$input" session_id)

# Needs a perch. No perch (session not readied) -> nothing to deliver.
id=$(sptc_self_id "$sid")
[ -z "$id" ] && exit 0

SPT=$(spt_bin)
# Mark BUSY here too (idempotent). UserPromptSubmit covers USER turns, but a turn can also start with
# NO user prompt — a Monitor-delivered message (the live-agent relay pipe) triggers a new turn without
# firing UserPromptSubmit, so the perch would still read idle from the prior Stop and a mid-turn
# inbound would idle-PTY-inject (clobber) instead of deferring. PreToolUse fires before every tool
# call, so the FIRST tool call of ANY turn (user, Monitor-triggered, auto-continue) marks busy — the
# earliest reliable turn-active signal when UPS didn't fire. Set BEFORE the drain so inbound landing
# mid-drain also defers. (Residual: a non-user text-only turn marks neither — low risk, no tool/PTY
# interleave; Stop re-idles after.) [impl->REQ-DIST-PRETOOL-POLL]
"$SPT" api --adapter "$ADAPTER" state busy "$id" --session-id "$sid" >/dev/null 2>&1 || true
# --include-deferred: pull messages queued while the perch was busy (the whole point — between-turns
# the UPS drain catches delivered msgs; mid-turn the busy turn defers them, so we must include them).
frames=$("$SPT" api --adapter "$ADAPTER" poll "$id" --session-id "$sid" --include-deferred 2>/dev/null)
[ -z "$frames" ] && exit 0

# Same canonical <EVENT> rendering + spill cap as the UPS drain (sender preserved for reply-correlation).
rendered=$(render_frames "$frames")
spill="${HOME:-${USERPROFILE:-.}}/.claude/sptc-drain-${sid:-unknown}.txt"
sptc_cap_output "$rendered" "$SPTC_CTX_CAP" "$spill"
exit 0
===== stop.sh =====
#!/bin/sh
# Stop: Claude finished responding -> mark the perch idle (also arms the echo-gate fallback,
# L137, since the Stop hook can't inject). [impl->REQ-DIST-HOOKS-API]
. "$CLAUDE_PLUGIN_ROOT/hooks/_common.sh"

input=$(cat)
sid=$(json_str "$input" session_id)
id=$(sptc_self_id "$sid")
[ -z "$id" ] && exit 0

SPT=$(spt_bin)
"$SPT" api --adapter "$ADAPTER" state idle "$id" --session-id "$sid" >/dev/null 2>&1 || true
exit 0
===== session-end.sh =====
#!/bin/sh
# SessionEnd: soft teardown of the perch (spool/history preserved). Graceful signoff uses
# `api shutdown` instead (driven by the /sptc:signoff skill, not this hook). [impl->REQ-DIST-HOOKS-API]
. "$CLAUDE_PLUGIN_ROOT/hooks/_common.sh"

input=$(cat)
sid=$(json_str "$input" session_id)
id=$(sptc_self_id "$sid")
[ -z "$id" ] && exit 0

SPT=$(spt_bin)
"$SPT" api --adapter "$ADAPTER" session-end "$id" --session-id "$sid" >/dev/null 2>&1 || true
exit 0
===== subagent-start.sh =====
#!/bin/sh
# SubagentStart (fires in the PARENT's context): create a nested worker perch under the parent.
# [impl->REQ-DIST-HOOKS-API]
. "$CLAUDE_PLUGIN_ROOT/hooks/_common.sh"

input=$(cat)
sid=$(json_str "$input" session_id)
agent_id=$(json_str "$input" agent_id)
parent=$(sptc_self_id "$sid")
{ [ -z "$parent" ] || [ -z "$agent_id" ]; } && exit 0

SPT=$(spt_bin)
"$SPT" api --adapter "$ADAPTER" worker-start "$parent" "$agent_id" --session-id "$sid" >/dev/null 2>&1 || true
exit 0
===== subagent-stop.sh =====
#!/bin/sh
# SubagentStop (fires in the PARENT's context): tear down the worker perch. [impl->REQ-DIST-HOOKS-API]
. "$CLAUDE_PLUGIN_ROOT/hooks/_common.sh"

input=$(cat)
sid=$(json_str "$input" session_id)
agent_id=$(json_str "$input" agent_id)
[ -z "$agent_id" ] && exit 0

SPT=$(spt_bin)
"$SPT" api --adapter "$ADAPTER" worker-stop "$agent_id" --session-id "$sid" >/dev/null 2>&1 || true
exit 0
===== post-tool-use.sh =====
#!/bin/sh
# PostToolUse (Write matcher): the CHECKPOINT detector (Feature 2). On a Write to THIS spt-hosted live
# agent's own .claude/<id>-commune.md whose content carries the literal !!checkpoint!! trigger, mark
# the perch idle and self-send a reserved {"checkpoint":"v1",...} signal that loops back through the
# endpoint's own [message-idle-translation-binary], firing the clear+wake macro (the agent-driven
# context reset — the operator's manual /clear, done by the agent itself). spt-hosted live sessions
# ONLY (the loopback needs the broker-held PTY + the spawned translation binary). The !!checkpoint!!
# file-content marker is the WRITE-side trigger; spt-core v0.15.0 strips it from the durable + pending
# context on ingest so it is one-shot. The --json-payload self-send is the DELIVERY-side trigger.
# [impl->REQ-DIST-CHECKPOINT-COMMUNE]
. "$CLAUDE_PLUGIN_ROOT/hooks/_common.sh"

input=$(cat)

# spt-hosted only: the endpoint id rides $SPT_ENDPOINT_ID (injected by [env.SPT_ENDPOINT_ID] on the
# broker-PTY bringup). A harness-hosted session has no translation binary, so checkpoint is inert there.
id="${SPT_ENDPOINT_ID:-}"
[ -z "$id" ] && exit 0

# Only a Write to this agent's own commune file.
sptc_is_commune_write "$(json_str "$input" tool_name)" "$(json_str "$input" file_path)" "$id" || exit 0

# Trigger present in the written content? (Scanned from the hook payload, not a file re-read.)
sptc_has_checkpoint "$input" || exit 0

sid=$(json_str "$input" session_id)
SPT=$(spt_bin)

# Mark idle so the loopback delivery lands on an idle input box, not mid-turn.
"$SPT" api --adapter "$ADAPTER" state idle "$id" --session-id "$sid" >/dev/null 2>&1 || true

# Build the structured checkpoint payload. A custom wake (text between a !!checkpoint!! pair) rides as
# "wake"; otherwise omit it and let the translation binary apply its default ("Proceed with next
# steps") — the default lives in ONE place (the binary). JSON-escape the wake value.
wake=$(sptc_checkpoint_wake "$input")
if [ -n "$wake" ]; then
  payload=$(printf '{"checkpoint":"v1","wake":"%s"}' "$(printf '%s' "$wake" | sptc_json_escape)")
else
  payload='{"checkpoint":"v1"}'
fi

# Self-send the signal back through our own endpoint's translation binary (the proven loopback). The
# body is a harmless human-readable note; the structured trigger lives in the json attr (collision-
# proof). QUEUED and SENT are both success. --json-payload needs spt-core v0.15.0; on an older node the
# send fails and is swallowed (the commune is still saved — checkpoint just won't auto-fire).
printf '%s' "checkpoint requested" | "$SPT" send --from "$id" "$id" --json-payload "$payload" >/dev/null 2>&1 || true
exit 0


Now read manifest + how `{adapter_dir}` v0.16.0 primitive works (the D1 enabler).