The file C:\Users\decid\Documents\projects\spt-claude-code\docs\adr\0002-hook-wiring-hand-written-hooks-json-shells-spt-api.md has been updated successfully. (file state is current in your context — no need to Read it back)

<!-- [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 (ADR-0020, 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 (ADR-0020 §1). **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 `ADR-0009`, Psyche routing `ADR-0012`), 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).** ADR-0020 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.

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

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


Line 60: `deliberate poll-surface contract (ADR-0020 §1).` — pure citation parenthetical → delete whole parenthetical incl leading space.


Edit

> deliberate poll-surface contract (ADR-0020 §1). **Transitional:** the current 0.6.0 binary still

> deliberate poll-surface contract. **Transitional:** the current 0.6.0 binary still


<!-- [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 (ADR-0020 §1). **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 `ADR-0009`, Psyche routing `ADR-0012`), 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).** ADR-0020 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.

-> deliberate poll-surface contract (ADR-0020 §1). **Transitional:** the current 0.6.0 binary still

+> deliberate poll-surface contract. **Transitional:** the current 0.6.0 binary still


Line 79: `(reply-correlation: access gate `ADR-0009`, Psyche routing `ADR-0012`)` → multiple in one parenthetical → `(reply-correlation: access gate, Psyche routing)`. Note these are backtick-wrapped.


unescape `< > "` then `&` **last**). **Sender preserved** (reply-correlation: access
gate `ADR-0009`, Psyche routing `ADR-0012`), never silently stripped. Covered by

unescape `< > "` then `&` **last**). **Sender preserved** (reply-correlation: access
gate, Psyche routing), never silently stripped. Covered by


unescape `< > "` then `&` **last**). **Sender preserved** (reply-correlation: access
gate, Psyche routing), never silently stripped. Covered by

<!-- [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 `ADR-0009`, Psyche routing `ADR-0012`), 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).** ADR-0020 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.

-gate `ADR-0009`, Psyche routing `ADR-0012`), never silently stripped. Covered by

+gate, Psyche routing), never silently stripped. Covered by


Line 129: `**`api poll` → `additionalContext` round-trip — RESOLVED (v0.7.1, 2026-06-15).** ADR-0020 shipped in v0.7.1;` — inline. `ADR-0020 shipped` → "the canonical poll envelope shipped".


round-trip — RESOLVED (v0.7.1, 2026-06-15).** ADR-0020 shipped
   in v0.7.1; a throwaway byte-capture

round-trip — RESOLVED (v0.7.1, 2026-06-15).** The canonical poll envelope shipped
   in v0.7.1; a throwaway byte-capture


round-trip — RESOLVED (v0.7.1, 2026-06-15).** The canonical poll envelope shipped
   in v0.7.1; a throwaway byte-capture

<!-- [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).** ADR-0020 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.

-1. **`api poll` → `additionalContext` round-trip — RESOLVED (v0.7.1, 2026-06-15).** ADR-0020 shipped

+1. **`api poll` → `additionalContext` round-trip — RESOLVED (v0.7.1, 2026-06-15).** The canonical poll envelope shipped


Now docs/PARITY.md line 87.


86	"fire-at-target-time" timer still lives in legacy owl in-memory, and an in-daemon durable scheduler
87	is a deliberate deferral (DEFERRED.md:43, ADR-0018 Q4/V3 — deferred because the daemon had no
88	one-shot consumer; building pre-consumer = untested dead code). The **operator has a superseding

"fire-at-target-time" timer still lives in legacy owl in-memory, and an in-daemon durable scheduler
is a deliberate deferral (DEFERRED.md:43, ADR-0018 Q4/V3 — deferred because the daemon had no
one-shot consumer; building pre-consumer = untested dead code). The **operator has a superseding


`(DEFERRED.md:43, ADR-0018 Q4/V3 — deferred because...` — parenthetical with other content. Drop ADR token + trailing connector. The remaining content: `DEFERRED.md:43` and `Q4/V3 — deferred...`. The `ADR-0018 Q4/V3` is a single noun reference; "Q4/V3" is part of the ADR citation. I'll drop `ADR-0018 Q4/V3, ` connector. Result: `(DEFERRED.md:43 — deferred because...`.


is a deliberate deferral (DEFERRED.md:43, ADR-0018 Q4/V3 — deferred because the daemon had no

is a deliberate deferral (DEFERRED.md:43 — deferred because the daemon had no


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

# Parity audit — claude-spt vs the legacy owl surface

> The reconciliation record: the adapter's operative surface measured against the **LOCKED
> parity-trim** in `SCOPE.md` (KEEP / ADD / DROP / TRANSFORM). This is the **"proven parity"**
> gate the `sptc`→`spt` succession flip depends on (see `docs/RELEASE-RUNBOOK.md` +
> `docs/KNOWN-HAZARDS.md`): the name flip happens only once this audit shows no unresolved
> divergence AND legacy owl is retired. Audited 2026-06-15 against published spt v0.7.2.
>
> Public-surface-only binds (AGENTS.md): a capability missing from the published `spt` surface is
> a **finding**, never a reason to reach into spt-core source.

<!-- [doc->REQ-PARITY-AUDIT] -->

## Disposition matrix

| Surface | SCOPE bucket | Adapter today | Status |
|---|---|---|---|
| `/live` | KEEP | operative — `skills/live.md` (base-manifest `[session.psyche_init]` + bare `api listen` + Monitor relay; Option A, no `:live` profile) | ✅ parity |
| `/commune` | KEEP | operative — `skills/commune.md` (file-drop) | ✅ parity |
| `/ready` | KEEP | operative — `skills/ready.md` | ✅ parity |
| `/send` | KEEP | operative — `skills/send.md` | ✅ parity |
| `/list-agents` | KEEP (→ `spt endpoint list`) | operative — `skills/list-agents.md` | ✅ parity |
| `/signoff` | KEEP (harness-hosted v1) | operative — `skills/signoff.md` | ✅ parity |
| `/force-stop` | KEEP (topology-aware) | operative — `skills/force-stop.md` | ✅ parity |
| `/spt:setup` | ADD | operative — `skills/setup.md` | ✅ parity |
| `/spt:version` | ADD | operative — `skills/version.md` | ✅ parity |
| profiles + strings + hints | ADD | wired (`:ccs`, `[strings]`, `[[hints]]`; live-capability is in base, not a profile) | ✅ parity |
| subnet skill (status/create/show-code/join) | ADD (LOCKED) | operative — `skills/subnet.md` (wraps `spt subnet`) | ✅ parity (REQ-SKILL-SUBNET) |
| ccs profile (`claude-spt:ccs`) | ADD (LOCKED) | operative — `[profiles.ccs]` overlay + CLAUDE_CONFIG_DIR-aware extractor | ✅ parity (REQ-CCS-PROFILES) |
| `/whoami` skill | **DROP** (core `spt whoami` stays → M12 `endpoint list` alias) | removed (was carry-over) | ✅ dropped |
| `/new-alarm` | accepted gap | dead stub, no manifest entry | ⏸ DEFER + remove stub |
| `/fork` | DROP | absent | ✅ confirmed absent |
| `/amend-signoff` | DROP | absent | ✅ confirmed absent |
| `/revive` | DROP (daemon owns liveness) | absent | ✅ confirmed absent |
| `/clear-psyche` | DROP (= delete+recreate endpoint) | absent | ✅ confirmed absent |

TRANSFORM bucket (already realised, not skills): capsule→spt-hosted+`cc` (`[session.self]` +
`shortcut_basename="cc"`); echo-commune→`[digest]` extractor (`claude-spt-digest`); psyche-sync→
`/spt:setup`; doctor→setup-verify; working-perches→`api worker-start/stop` hooks. ✅

## Open divergences and dispositions

### subnet skills — LOCKED-ADD gap → BUILD
The published surface carries the verbs (`spt subnet {create,join,show-code,status,…}`), and v1
topology scope is **mandatory** (operator vetoed harness-hosted-only — cross-subnet/PTY proof is
spt-core's central value prop, SCOPE). So this is a real gap to fill, not defer. → `REQ-SKILL-SUBNET`.

### ccs profile — LOCKED-ADD gap → BUILT ✅
<!-- [doc->REQ-CCS-PROFILES] -->
SCOPE §ccs (LOCKED): ccs ships as a **profile template** under the adapter that retargets the spawn
command template through `ccs`, with ccs's relocated log dir honored. Templates only — the user
supplies their own ccs config/keys. **Operator ruling (2026-06-15):** the shipped profile is
`claude-spt:ccs` invoking **bare `ccs`** (the account set via `ccs auth default <name>`), NOT
`glm`/`kimi` — those were SCOPE *examples*; this profile IS the worked example of adapter-profile
authoring **and** the operator's own SPT-ecosystem hook. Built + validated against the known-good
sister project **claude_skill_owl**:
- **Spawn seam.** `[profiles.ccs.session.self].command = "ccs"` leaf-replaces the base `claude`.
  owl proves ccs is a drop-in for the `claude` binary on the same argv (`live/wrapper/claude.rs`
  Tier-2 PULSE recovery latches `cli_binary = "ccs"`); `SPT_ENDPOINT_ID` rides inherited env through
  the wrapper unchanged.
- **Log-dir seam → in the extractor, not a manifest leaf.** ccs relocates CC's whole state tree
  (incl. `projects/`) via the **`CLAUDE_CONFIG_DIR`** env var (`~/.ccs/instances/<account>/.claude`),
  a per-account runtime value with no static catalog path. SCOPE's "per-profile `~/.ccs` log dir"
  is therefore honored **in `claude-spt-digest`** (dir-locate branch prefers `$CLAUDE_CONFIG_DIR/
  projects` over the `--in` root) — the owl-validated `owlery::claude_projects_root` pattern. The
  base `[digest]` config thus serves base **and** ccs sessions transparently; no `[profiles.ccs.
  digest]` leaf. This env-aware resolver is REQ-CCS-PROFILES's `impl`/`unit` evidence (a `.toml`
  profile leaf alone does not register as `impl`).
- **Finding (refines SCOPE's model).** SCOPE conflated ccs *profiles* (glm/kimi = API-provider
  env-swap, NO transcript relocation) with ccs *accounts* (isolated instances that DO relocate via
  `CLAUDE_CONFIG_DIR`). The operator runs the account/default path, which relocates — so the digest
  seam is real, but its mechanism is `CLAUDE_CONFIG_DIR`, not a fixed `~/.ccs` path. → `REQ-CCS-PROFILES`.

### `/whoami` skill — DROP (honoring LOCKED)
SCOPE DROPs the whoami **skill**; core `spt whoami` stays in the hot path (and becomes the
`endpoint list` alias in M12). The adapter currently still ships a whoami skill (a carry-over from
an early slice). Operator confirmed the drop 2026-06-15. Action: remove `[strings.skills].whoami` +
the file body + the plugin stub; redirect the "who am i" hint to core `spt whoami`. No REQ rides
whoami evidence (the file-backed-string + UPS-injection int examples repoint to `skills.ready`;
the hook-parser unit examples repoint to a surviving skill name). Core `spt whoami` calls in the
hooks (`session-start.sh`, `_common.sh`) are the CLI, not the skill — they STAY.

### `/new-alarm` — ACCEPTED GAP (deferred-pending-arrangement-system)
**Not missing — deliberately deferred.** doyle triage (2026-06-15): spt-core carries only the
alarm *event shape* (spt-proto) + relay handling (psyrelay.rs) + a test fixture; the durable
"fire-at-target-time" timer still lives in legacy owl in-memory, and an in-daemon durable scheduler
is a deliberate deferral (DEFERRED.md:43, ADR-0018 Q4/V3 — deferred because the daemon had no
one-shot consumer; building pre-consumer = untested dead code). The **operator has a superseding
"arrangement" system** in mind that would replace the alarm port. Disposition: **do not build an
adapter-side stopgap timer** (throwaway vs the coming model); **no REQ minted**; **remove the dead
`/sptc:new-alarm` stub** from the published surface (deferred ≠ shipping a no-op skill). doyle loops
me when the arrangement design surfaces.

## Parity verdict
**PROVEN (adapter surface).** All three open divergences are closed this slice: subnet skill added
(`skills/subnet.md`, REQ-SKILL-SUBNET), ccs profile added (`[profiles.ccs]` + CLAUDE_CONFIG_DIR-aware
extractor, REQ-CCS-PROFILES), whoami skill dropped. The full KEEP/ADD set is ✅ and the DROP set is
confirmed absent. `/new-alarm` is an accepted, documented gap (deferred-pending-arrangement-system)
and does **not** block the verdict.

The `sptc`→`spt` succession flip remains separately gated (NOT unblocked by this verdict alone):
it also requires **legacy owl retired** (KNOWN-HAZARDS / RELEASE-RUNBOOK) and rides the live-relay
int held for spt-core M11 counter-15. This audit clears only the parity precondition.

-is a deliberate deferral (DEFERRED.md:43, ADR-0018 Q4/V3 — deferred because the daemon had no

+is a deliberate deferral (DEFERRED.md:43 — deferred because the daemon had no


Now docs/SPT-CORE-FINDINGS.md — 5 refs.


144	  than a parser guessing boundaries.
145
146	**Resolution — RESOLVED-BY-DESIGN (ADR-0020, operator-ruled 2026-06-15):** the framing question is
147	settled, more cleanly than a delimiter patch. The **canonical format at every surface — including
148	`api poll` — is the `<EVENT type="msg" from="<sender>">body</EVENT>` envelope** (`spt-proto::event`,
149	the ADR-0001 grammar the live listener already emits). `__REPLY_TO__` was a **mis-elevated relic**
150	(wrongly frozen as the "stable wire format" during the clean-room port) and is being **deleted from
151	spt-core**. The `<EVENT>` envelope is **self-delimiting**, so multi-message drains split cleanly on
152	`</EVENT>` — no delimiter needed, and the F-002 multi-frame hold is **lifted**.
153
154	- **Our parser** now targets `<EVENT>` (the `from` attr → `<sptc_messages from="…">`, `<br>` →
155	  newline, entity unescape with `&` last) — exactly the live-agent body-parsing rule. See
156	  `render_frames` in `plugin/sptc/hooks/_common.sh` + `tests/hooks-parse.sh` (multi-message covered).
157	- **VERIFIED ON THE PUBLISHED SURFACE (v0.7.1, 2026-06-15).** ADR-0020 shipped in **v0.7.1**
158	  (counter 13); `__REPLY_TO__` relic gone. A throwaway byte-capture against the live 0.7.1 `spt api
159	  poll` drain (`od`-verified) confirms the canonical envelope end-to-end:
160	  - single msg: `<EVENT type="msg" from="probe-sender">hello from probe
second <line>
161	    & "stuff"</EVENT>\n` — body escaping (`
` + `</>/"` + `&` last)
162	    is the **exact** `render_frames` decode rule.
163	  - whole envelopes, self-delimiting **and** `\n`-framed; **no `<EVENT-PART>`** on a normal hook
164	    drain; **no `__REPLY_TO__`**. Multi-drain splits cleanly on `</EVENT>` → **F-002 dissolved on the
165	    published binary**.
166	  - raw drain piped through our `render_frames` → correct `<sptc_messages from=…>` (parser
167	    confirm-match PASS). `notify` events ride the same envelope.
168	  Locked in by `ci/hooks/poll-int.sh` (SPTC_ACCEPTANCE-gated, ≥0.7.1, 5/5): bind→send→`api
169	  poll`→`render_frames` assert. **`REQ-DIST-HOOKS-API` + `REQ-UPS-INJECTION` `int` flipped GREEN.**
170	  ADR-0020 loop closed: design→impl→gate→ship→real-surface-verify.
171
172	---
173

than a parser guessing boundaries.

**Resolution — RESOLVED-BY-DESIGN (ADR-0020, operator-ruled 2026-06-15):** the framing question is
settled, more cleanly than a delimiter patch. The **canonical format at every surface — including
`api poll` — is the `<EVENT type="msg" from="<sender>">body</EVENT>` envelope** (`spt-proto::event`,
the ADR-0001 grammar the live listener already emits). `__REPLY_TO__` was a **mis-elevated relic**
(wrongly frozen as the "stable wire format" during the clean-room port) and is being **deleted from
spt-core**. The `<EVENT>` envelope is **self-delimiting**, so multi-message drains split cleanly on
`</EVENT>` — no delimiter needed, and the F-002 multi-frame hold is **lifted**.

- **Our parser** now targets `<EVENT>` (the `from` attr → `<sptc_messages from="…">`, `<br>` →
  newline, entity unescape with `&` last) — exactly the live-agent body-parsing rule. See
  `render_frames` in `plugin/sptc/hooks/_common.sh` + `tests/hooks-parse.sh` (multi-message covered).
- **VERIFIED ON THE PUBLISHED SURFACE (v0.7.1, 2026-06-15).** ADR-0020 shipped in **v0.7.1**
  (counter 13); `__REPLY_TO__` relic gone. A throwaway byte-capture against the live 0.7.1 `spt api
  poll` drain (`od`-verified) confirms the canonical envelope end-to-end:
  - single msg: `<EVENT type="msg" from="probe-sender">hello from probe
second <line>
    & "stuff"</EVENT>\n` — body escaping (`
` + `</>/"` + `&` last)
    is the **exact** `render_frames` decode rule.
  - whole envelopes, self-delimiting **and** `\n`-framed; **no `<EVENT-PART>`** on a normal hook
    drain; **no `__REPLY_TO__`**. Multi-drain splits cleanly on `</EVENT>` → **F-002 dissolved on the
    published binary**.
  - raw drain piped through our `render_frames` → correct `<sptc_messages from=…>` (parser
    confirm-match PASS). `notify` events ride the same envelope.
  Locked in by `ci/hooks/poll-int.sh` (SPTC_ACCEPTANCE-gated, ≥0.7.1, 5/5): bind→send→`api
  poll`→`render_frames` assert. **`REQ-DIST-HOOKS-API` + `REQ-UPS-INJECTION` `int` flipped GREEN.**
  ADR-0020 loop closed: design→impl→gate→ship→real-surface-verify.

---


Line 146: `(ADR-0020, operator-ruled 2026-06-15)` → `(operator-ruled 2026-06-15)`. Line 157: `ADR-0020 shipped in **v0.7.1**` inline → "The canonical poll envelope shipped". Line 170: `ADR-0020 loop closed:` inline → "The canonical poll envelope loop closed:".


**Resolution — RESOLVED-BY-DESIGN (ADR-0020, operator-ruled 2026-06-15):** the framing question is

**Resolution — RESOLVED-BY-DESIGN (operator-ruled 2026-06-15):** the framing question is


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

formatted; a (rare) concatenated multi-drain degrades to a surfaced-but-unattributed blob rather

-**Resolution — RESOLVED-BY-DESIGN (ADR-0020, operator-ruled 2026-06-15):** the framing question is

+**Resolution — RESOLVED-BY-DESIGN (operator-ruled 2026-06-15):** the framing question is

settled, more cleanly than a delimiter patch. The **canonical format at every surface — including

`api poll` — is the `<EVENT type="msg" from="<sender>">body</EVENT>` envelope** (`spt-proto::event`,

the ADR-0001 grammar the live listener already emits). `__REPLY_TO__` was a **mis-elevated relic**


- **VERIFIED ON THE PUBLISHED SURFACE (v0.7.1, 2026-06-15).** ADR-0020 shipped in **v0.7.1**

- **VERIFIED ON THE PUBLISHED SURFACE (v0.7.1, 2026-06-15).** The canonical poll envelope shipped in **v0.7.1**


- **Our parser** now targets `<EVENT>` (the `from` attr → `<sptc_messages from="…">`, `<br>` →

newline, entity unescape with `&` last) — exactly the live-agent body-parsing rule. See

`render_frames` in `plugin/sptc/hooks/_common.sh` + `tests/hooks-parse.sh` (multi-message covered).

-- **VERIFIED ON THE PUBLISHED SURFACE (v0.7.1, 2026-06-15).** ADR-0020 shipped in **v0.7.1**

+- **VERIFIED ON THE PUBLISHED SURFACE (v0.7.1, 2026-06-15).** The canonical poll envelope shipped in **v0.7.1**

(counter 13); `__REPLY_TO__` relic gone. A throwaway byte-capture against the live 0.7.1 `spt api

poll` drain (`od`-verified) confirms the canonical envelope end-to-end:

- single msg: `<EVENT type="msg" from="probe-sender">hello from probe<br>second <line>


ADR-0020 loop closed: design→impl→gate→ship→real-surface-verify.

Canonical poll envelope loop closed: design→impl→gate→ship→real-surface-verify.


confirm-match PASS). `notify` events ride the same envelope.

Locked in by `ci/hooks/poll-int.sh` (SPTC_ACCEPTANCE-gated, ≥0.7.1, 5/5): bind→send→`api

poll`→`render_frames` assert. **`REQ-DIST-HOOKS-API` + `REQ-UPS-INJECTION` `int` flipped GREEN.**

-  ADR-0020 loop closed: design→impl→gate→ship→real-surface-verify.

+  Canonical poll envelope loop closed: design→impl→gate→ship→real-surface-verify.


Now lines 266 and 296 (ADR-0019 x2).


264	## F-004 — `spt adapter digest-proof --sample` does not fill `{session_id}` (false-fails the published extractor shape)
265
266	**Surfaced:** 2026-06-15, authoring the claude-spt `[digest]` extractor (ADR-0019).
267
268	**Symptom.** `spt adapter digest-proof --sample <log>` substitutes only `{source}` (= the sample

## F-004 — `spt adapter digest-proof --sample` does not fill `{session_id}` (false-fails the published extractor shape)

**Surfaced:** 2026-06-15, authoring the claude-spt `[digest]` extractor (ADR-0019).

**Symptom.** `spt adapter digest-proof --sample <log>` substitutes only `{source}` (= the sample


294	will fail"), whereas the runtime daemon path fills `{id}`+`{session_id}` before running the same
295	extractor (`digest.rs:208-210`). digest-proof is meant to be the author-time half of the *same*
296	ADR-0019 engine (§diagnostics), so it must supply the same keys. It doesn't → it is infidelitous to
297	runtime and false-fails any `{session_id}`-templated extractor, including the published example.
298

will fail"), whereas the runtime daemon path fills `{id}`+`{session_id}` before running the same
extractor (`digest.rs:208-210`). digest-proof is meant to be the author-time half of the *same*
ADR-0019 engine (§diagnostics), so it must supply the same keys. It doesn't → it is infidelitous to
runtime and false-fails any `{session_id}`-templated extractor, including the published example.


Line 266: `(ADR-0019).` pure citation → delete parenthetical incl leading space. Line 296: `the ADR-0019 engine (§diagnostics)` → per policy `the ADR-0019 engine` → "spt-core's digest engine". So "the *same* spt-core digest engine (§diagnostics)".


**Surfaced:** 2026-06-15, authoring the claude-spt `[digest]` extractor (ADR-0019).

**Surfaced:** 2026-06-15, authoring the claude-spt `[digest]` extractor.


## F-004 — `spt adapter digest-proof --sample` does not fill `{session_id}` (false-fails the published extractor shape)

-**Surfaced:** 2026-06-15, authoring the claude-spt `[digest]` extractor (ADR-0019).

+**Surfaced:** 2026-06-15, authoring the claude-spt `[digest]` extractor.

**Symptom.** `spt adapter digest-proof --sample <log>` substitutes only `{source}` (= the sample

path) into the extractor command and hard-fails on any other key. The claude-spt extractor command


extractor (`digest.rs:208-210`). digest-proof is meant to be the author-time half of the *same*
ADR-0019 engine (§diagnostics), so it must supply the same keys.

extractor (`digest.rs:208-210`). digest-proof is meant to be the author-time half of the *same*
spt-core digest engine (§diagnostics), so it must supply the same keys.


extractor (`digest.rs:208-210`). digest-proof is meant to be the author-time half of the *same*
ADR-0019 engine (§diagnostics), so it must supply the same keys.

extractor (`digest.rs:208-210`). digest-proof is meant to be the author-time half of the *same*
spt-core digest engine (§diagnostics), so it must supply the same keys.

(`cli.rs:5135`, `let keys = BTreeMap::new()` — comment admits "placeholders like `{session_id}`

will fail"), whereas the runtime daemon path fills `{id}`+`{session_id}` before running the same

-ADR-0019 engine (§diagnostics), so it must supply the same keys. It doesn't → it is infidelitous to

+spt-core digest engine (§diagnostics), so it must supply the same keys. It doesn't → it is infidelitous to

runtime and false-fails any `{session_id}`-templated extractor, including the published example.

Aside: `{home}` is **not** a catalog key — hard-failing on it is correct. Use `~` for home


Now the plan docs: MANIFEST-PARITY-PLAN.md (lines 100, 111), PARITY-PUBLISH-PLAN.md (line 13), SKELETON-PLAN.md (line 65).


99	- `sptc-<id>` shortcut EMISSION — spt-core picker wave (not in 0.7.0) + needs `[session.self]`.
100	- REQ-MSG-ENVELOPE poll→additionalContext bus-delivery int — **DONE (v0.7.1, 2026-06-15).** ADR-0020
101	  published in v0.7.1; byte-capture confirmed `<EVENT>` on the live `api poll` drain (no `__REPLY_TO__`,
102	  F-002 dissolved), `render_frames` confirm-match. Locked by `ci/hooks/poll-int.sh` (5/5);
103	  REQ-DIST-HOOKS-API + REQ-UPS-INJECTION `int` flipped green.
104
105	### Note: claude-spt is now a REGISTERED adapter artifact on this node (the v1 acceptance proof).
106	The int test soft-removes it each run; re-add with `spt adapter add adapter/claude-spt.toml`.
107	Local `spt` is **0.7.0** (operator-upgraded 2026-06-15).
108
109	## Still gated on spt-core (non-blocking)
110
111	poll→additionalContext bus-delivery `int` — **DONE (v0.7.1).** ADR-0020 published; byte-capture
112	confirmed the `<EVENT>` envelope on the live `api poll` drain + `render_frames` confirm-match;

- `sptc-<id>` shortcut EMISSION — spt-core picker wave (not in 0.7.0) + needs `[session.self]`.
- REQ-MSG-ENVELOPE poll→additionalContext bus-delivery int — **DONE (v0.7.1, 2026-06-15).** ADR-0020
  published in v0.7.1; byte-capture confirmed `<EVENT>` on the live `api poll` drain (no `__REPLY_TO__`,
  F-002 dissolved), `render_frames` confirm-match. Locked by `ci/hooks/poll-int.sh` (5/5);
  REQ-DIST-HOOKS-API + REQ-UPS-INJECTION `int` flipped green.

### Note: claude-spt is now a REGISTERED adapter artifact on this node (the v1 acceptance proof).
The int test soft-removes it each run; re-add with `spt adapter add adapter/claude-spt.toml`.
Local `spt` is **0.7.0** (operator-upgraded 2026-06-15).

## Still gated on spt-core (non-blocking)

poll→additionalContext bus-delivery `int` — **DONE (v0.7.1).** ADR-0020 published; byte-capture
confirmed the `<EVENT>` envelope on the live `api poll` drain + `render_frames` confirm-match;


12	   system", NOT missing**: spt-core has only the alarm event shape + relay handling + a test
13	   fixture; the durable timer is a deliberate deferral (DEFERRED.md:43, ADR-0018 Q4/V3), and the
14	   operator has a superseding "arrangement" system in mind. **Do NOT build an adapter-side stopgap

   system", NOT missing**: spt-core has only the alarm event shape + relay handling + a test
   fixture; the durable timer is a deliberate deferral (DEFERRED.md:43, ADR-0018 Q4/V3), and the
   operator has a superseding "arrangement" system in mind. **Do NOT build an adapter-side stopgap


64	      `REQ-DIST-HOOKS-API` + `REQ-UPS-INJECTION` activated to `[doc,impl,unit]` (`int` held).
65	- [x] **F-002 resolved-by-design (ADR-0020):** canonical poll format = self-delimiting `<EVENT>`
66	      envelope (`__REPLY_TO__` relic deleted). Parser retargeted to `<EVENT>` (split on `</EVENT>`,

      `REQ-DIST-HOOKS-API` + `REQ-UPS-INJECTION` activated to `[doc,impl,unit]` (`int` held).
- [x] **F-002 resolved-by-design (ADR-0020):** canonical poll format = self-delimiting `<EVENT>`
      envelope (`__REPLY_TO__` relic deleted). Parser retargeted to `<EVENT>` (split on `</EVENT>`,


Lines 100, 111: `ADR-0020 published` inline → "The canonical poll envelope published". Line 13: `(DEFERRED.md:43, ADR-0018 Q4/V3)` → `(DEFERRED.md:43)`. Line 65: `(ADR-0020):` parenthetical with trailing colon — it's a pure citation tag; the `:` is outside. `**F-002 resolved-by-design (ADR-0020):**` → delete parenthetical incl leading space → `**F-002 resolved-by-design:**`.


- REQ-MSG-ENVELOPE poll→additionalContext bus-delivery int — **DONE (v0.7.1, 2026-06-15).** ADR-0020
  published in v0.7.1; byte-capture confirmed

- REQ-MSG-ENVELOPE poll→additionalContext bus-delivery int — **DONE (v0.7.1, 2026-06-15).** The canonical poll envelope
  published in v0.7.1; byte-capture confirmed


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

- REQ-MSG-ENVELOPE poll→additionalContext bus-delivery int — **DONE (v0.7.1, 2026-06-15).** The canonical poll envelope
  published in v0.7.1; byte-capture confirmed

# Adapter-manifest + M12 parity — JIT plan (next milestone)

> Fresh milestone after the CI/distribution slices. Authors the CC adapter **manifest** (publish
> target #2, the spt-core registry side) against the REAL v0.7.0 published surface, then the M12
> parity surfaces. NEEDS public-docs research first — run in clean context.

## Public-surface-only (hard constraint — re-read)

Build ONLY from `SaberMage/spt-releases` (v0.7.0) + `https://sabermage.github.io/spt-releases`
(harness-contract, CLI reference, manifest.schema.json, rustdoc, llms-full.txt). NEVER read
spt-core source. A missing capability = a FINDING for doyle/todlando, not a workaround. (AGENTS.md)

## Research first (before authoring)

1. Pull v0.7.0 `manifest.schema.json` from spt-releases — the authoritative shape the adapter
   manifest must validate against. Note required fields, `[adapter]` block, `[strings]` (file-backed
   large bodies), `shortcut_basename`, profiles/hints/`[digest]` extractor.
2. Read the published harness-contract + CLI reference for: `spt adapter` registration verbs,
   `spt endpoint run`, `shortcut_basename` semantics (emits `sptc-<id>` shortcuts), whoami-as-alias,
   adapter:profile fallback addressing.
3. Confirm where the manifest physically lives (spt-core-conducted registry, NOT plugin files —
   ADR-0001/SCOPE) and how it's validated locally.

## Slices (sequence; each its own commit + green gate)

1. **DONE (bd16644)** — **Adapter manifest authoring**. `adapter/claude-spt.toml` ([adapter]+
   shortcut_basename, [hooks.*], [identity], [inject], [digest], [profiles.deep], [strings],
   [[hints]]); vendored `adapter/manifest.schema.json`; manifest-schema gate
   (`ci/manifest/{validate_manifest.py,check-manifest.sh}`, py tomllib|tomli + jsonschema) wired
   into `run-gates.sh`. **REQ-DIST-MANIFEST-SCHEMA** → `[doc,impl,unit]` (int held: real
   `spt adapter add` registration-validation = 2nd layer). Deferred in-manifest with notes:
   `[session]`/roles, `[history]`, `[update]` (file_pull needs a signing key + registry repo not
   on the public surface — a finding).
2. **DONE (c93757d)** — **shortcut_basename** guard. `tests/manifest-shortcut.sh` asserts the
   `sptc` brand (succession seam). **REQ-DIST-SHORTCUT-BASENAME** → `[doc,unit]` (the field is
   declarative config, not impl — the scanner classifies impl by file type; launcher impl +
   real-picker int deferred to the endpoint-run/cc-launcher slice, gated on the v0.7.0 binary).
3. **M12 parity surfaces** (seed REQs per surface as each starts — activate-don't-pre-fail):
   - **DONE (8cd8372)** — `/sptc:whoami` + `/sptc:setup` skill skeletons (the manifest advertised
     both; skeletons now exist, covered by REQ-DIST-PLUGIN-SKELETON + skeleton-validate).
   - **DONE (435e1be)** — **manifest proven against live v0.7.0**. Local spt upgraded to 0.7.0;
     `ci/manifest/registration-int.sh` (SPTC_ACCEPTANCE-gated) green 6/6: `spt adapter add`
     accepted (2nd-layer cross-field validation), shipped `:deep` profile resolves, `[strings]`
     read + overlay observable via `get-string`, clean soft-remove. **REQ-DIST-MANIFEST-SCHEMA →
     [doc,impl,unit,int]**. Cross-field discovery fixed: `[digest]` needs `source` →
     `source="{home}/.claude/projects"`. Findings logged (key catalog + digest rule undocumented).
   - **IN PROGRESS — capability confirmed (F-003 resolved 2026-06-15)** — file-backed `[strings]`.
     Research conclusion REVERSED by live byte-test + doyle/todlando ruling: the mechanism **is
     shipped** in v0.7.0 as a value-position table pointer `key = { file = "rel" }` over
     `adapters/<adapter>/strings/` (lazy-resolved at `get-string`, containment-checked, copied on
     `adapter add`). My avenue search missed it (it's a value-table shape, not a CLI verb / `@file`).
     ADR-0001 dependency **SATISFIED**; residual is docs-only (doyle publishing, PR #13).
     - **DONE (this session)** — pattern proven end-to-end + first two bodies authored:
       `adapter/strings/skills/{whoami,setup}.md` + `[strings.skills].{whoami,setup} = { file = ... }`;
       `registration-int.sh` extended (8/8) — asserts a `{ file }` pointer resolves to FILE CONTENTS
       and an inline sibling prints as-is, vs live v0.7.0. `strings/` resolves next to the manifest
       FILE on `adapter add`. Bodies grounded in the public surface (`spt whoami`; shipped bootstrap).
     - **DONE (this session)** — ready, send, version authored as file-backed bodies + pointers; stubs
       refreshed. ready/send **delegate to `spt how-to ready|send`** (stay current vs published surface);
       version reports the version-of-truth via `spt --version` + `spt adapter list` (ADR-0001).
     - **DONE (this session, triaged vs the v0.7.1 surface)** — signoff (`spt endpoint shutdown` —
       graceful + final context save, exact match) + list-agents (`spt endpoint list` — the roster).
       7/11 skills now operative. (force-stop/list-agents/new-alarm had no `[strings.skills]` entry;
       list-agents now added.)
     - **TODO — remaining 4, classified (don't invent):**
       - **live** — `spt endpoint run` EXISTS but spawns the manifest `[session.self]` command, which
         is unauthored (deferred slice). GATED on `[session.self]` authoring + the run bringup.
       - **force-stop** — core spt has only soft `spt endpoint stop` (perch, spool kept) + graceful
         `endpoint shutdown`; NO force-kill / Psyche-teardown equivalent (the legacy `$LIVE stop`
         3-step). SEMANTICS MISMATCH → defer; candidate finding if force-kill is required.
       - **commune / new-alarm** — ABSENT from the core `spt` v0.7.1 surface (live-agent/Psyche layer,
         the owl/`$LIVE` binary, not core). Not authorable as core-spt skills → defer; likely a
         scope/finding question (does claude-spt reach the Psyche layer, or are these owl-only?).
   - **DONE (this session) — UPS skill-injection branch BUILT.** UPS-fires-on-slash was already
     proven (ADR-0002 validation 2026-06-15: `/sptc:X` fires with token intact on CC 2.1.177) — my
     earlier "still-open" framing was stale. Built: `_common.sh` `sptc_skill_key` (pure, detect
     `/sptc:<skill>` leading-only) + `sptc_inject_skill` (resolve body via `get-string skills.<x>`,
     wrap `<sptc_skill name>`); `user-prompt-submit.sh` now injects BEFORE the perch check (whoami/
     setup need no perch) then drains. Tests: hooks-parse unit (skill_key, 8 cases) + registration-int
     4c (end-to-end `/sptc:whoami` → wrapped body, 9/9 vs live v0.7.0). `setup` SKILL.md is
     self-contained (runs when spt may be absent → injection no-ops; stub is the floor). whoami/setup
     stubs refreshed (stale "not yet operative" removed). `REQ-UPS-INJECTION` `[doc,impl,unit]` stays
     covered; `int` still held — but ONLY on the message-DRAIN half (`api poll` → additionalContext
     round-trip, gated on REQ-MSG-ENVELOPE publish). The skill-injection int is satisfied (4c).
   - **DONE (d1f14ab)** — `[session.self]` + bind path. Authored `[session.self]` (spawns `claude`)
     + `[env.SPT_ENDPOINT_ID]={id}` + the SessionStart bind/seed/boundary branch (`sptc_register_verb`
     → `api bind`, spt-hosted self-register; bind is establishing/intrinsic-auth, --set-session-id
     only, doyle-confirmed). registration-int 10/10. **REQ-DIST-SHORTCUT-BASENAME → [doc,impl,unit]**.
     int deferred: cc-<id> picker still waved + needs a real-claude acceptance spawn (now contract-
     unblocked). The standalone `cc`/`cc-<id>` launcher SCRIPT still TODO (bakes a non-interactive
     `spt endpoint run`); the `[session.self]` it targets is now authored.
   - **DONE (39c05d1)** — digest extractor `claude-spt-digest` (Rust). CC JSONL → digest NDJSON;
     9 cargo tests; ci/digest/build.sh gate. **REQ-DIST-DIGEST-EXTRACTOR → [impl,unit]**. digest-proof
     int deferred behind **F-004** (doyle-confirmed spt-core impl bug: digest-proof --sample passes an
     empty key map, cli.rs:5135; fix in progress). Proven via cargo + source-only Variant A
     (DIGEST_PROOF_OK 5/0). Flips green on doyle's carrying release.

### Still held on spt-core (non-blocking)
- `sptc-<id>` shortcut EMISSION — spt-core picker wave (not in 0.7.0) + needs `[session.self]`.
- REQ-MSG-ENVELOPE poll→additionalContext bus-delivery int — **DONE (v0.7.1, 2026-06-15).** ADR-0020
  published in v0.7.1; byte-capture confirmed `<EVENT>` on the live `api poll` drain (no `__REPLY_TO__`,
  F-002 dissolved), `render_frames` confirm-match. Locked by `ci/hooks/poll-int.sh` (5/5);
  REQ-DIST-HOOKS-API + REQ-UPS-INJECTION `int` flipped green.

### Note: claude-spt is now a REGISTERED adapter artifact on this node (the v1 acceptance proof).
The int test soft-removes it each run; re-add with `spt adapter add adapter/claude-spt.toml`.
Local `spt` is **0.7.0** (operator-upgraded 2026-06-15).

## Still gated on spt-core (non-blocking)

poll→additionalContext bus-delivery `int` — **DONE (v0.7.1).** ADR-0020 published; byte-capture
confirmed the `<EVENT>` envelope on the live `api poll` drain + `render_frames` confirm-match;
`ci/hooks/poll-int.sh` (5/5) locks it; HOOKS-API + UPS-INJECTION `int` green. (Remaining open: the
~10k additionalContext large-drain truncate/spill — lower priority, ADR-0002 Open #2.)

## Gate (every slice)

`sh ci/run-gates.sh` ⇒ PASS · `traceable-reqs check` green. Atomic commits, `Co-authored by: perri`.

## Session state at handoff (2026-06-15)

Shipped this session (all green, gates PASS, 16/16 traceable): CI ACCEPTANCE slice 1
(73677fd/d834611 — real-claude SUT + perch-collision guard, REQ-CI-ACCEPTANCE [doc,impl,int] +
REQ-HAZARD-PERCH-COLLISION); cplugs publish-prep (e28db92 — validate/package skeleton + untracked
a real perch-state leak, REQ-DIST-PLUGIN-SKELETON [doc,impl,unit]); mdBook docs-site
(d999c60/905c62c — drift-gated book + llms.txt, REQ-DOCS-SITE/REQ-DOCS-DRIFT). Collaborators:
doyle (design/gate) + todlando (builds spt-core) over the spt bus. v0.7.0/M12 is PUBLIC.

-- REQ-MSG-ENVELOPE poll→additionalContext bus-delivery int — **DONE (v0.7.1, 2026-06-15).** ADR-0020

+- REQ-MSG-ENVELOPE poll→additionalContext bus-delivery int — **DONE (v0.7.1, 2026-06-15).** The canonical poll envelope


poll→additionalContext bus-delivery `int` — **DONE (v0.7.1).** ADR-0020 published; byte-capture

poll→additionalContext bus-delivery `int` — **DONE (v0.7.1).** The canonical poll envelope published; byte-capture


## Still gated on spt-core (non-blocking)

-poll→additionalContext bus-delivery `int` — **DONE (v0.7.1).** ADR-0020 published; byte-capture

+poll→additionalContext bus-delivery `int` — **DONE (v0.7.1).** The canonical poll envelope published; byte-capture

confirmed the `<EVENT>` envelope on the live `api poll` drain + `render_frames` confirm-match;

`ci/hooks/poll-int.sh` (5/5) locks it; HOOKS-API + UPS-INJECTION `int` green. (Remaining open: the

~10k additionalContext large-drain truncate/spill — lower priority, ADR-0002 Open #2.)


fixture; the durable timer is a deliberate deferral (DEFERRED.md:43, ADR-0018 Q4/V3), and the

fixture; the durable timer is a deliberate deferral (DEFERRED.md:43), and the


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

# JIT plan — parity audit → fill LOCKED-ADD gaps → cplugs publish (as sptc)

> Next slice after the live sub-slice (4b042cc) + F-004 flip (692b67c). Goal: PROVE parity
> against the SCOPE.md LOCKED parity-trim, fill the remaining LOCKED-ADD gaps, then publish the
> thin skeleton to cplugs **as `sptc`** (NOT `spt` — succession flip stays gated, see
> [[succession-flip-gated]] / RELEASE-RUNBOOK). Public-surface-only still binds (AGENTS.md).

## Operator decisions (locked this session, 2026-06-15)
1. **Slice = audit + fill gaps + publish.** Build the absent LOCKED-ADD surface (subnet skills,
   ccs profiles), reconcile whoami, THEN publish as sptc. `new-alarm` stays **deferred** — doyle
   TRIAGED it (high-prio 2026-06-15) as an **ACCEPTED parity gap = "deferred-pending-arrangement-
   system", NOT missing**: spt-core has only the alarm event shape + relay handling + a test
   fixture; the durable timer is a deliberate deferral (DEFERRED.md:43, ADR-0018 Q4/V3), and the
   operator has a superseding "arrangement" system in mind. **Do NOT build an adapter-side stopgap
   timer** (throwaway vs the coming model), **no REQ minted**. Remove the dead stub (deferred ≠
   shipping a no-op skill); doyle loops me when the arrangement design surfaces.
2. **Drop the `/whoami` SKILL.** Honor the LOCKED SCOPE decision — core `spt whoami` stays
   (becomes the `endpoint list` alias in M12); the adapter ships no whoami skill.

## Parity matrix (current vs SCOPE LOCKED parity-trim)

| Legacy / target surface        | SCOPE disposition        | Adapter today          | Action (this slice)            |
|--------------------------------|--------------------------|------------------------|--------------------------------|
| /live /commune /ready /send    | KEEP                     | operative (file-backed)| ✓ none                         |
| /list-agents /signoff /force-stop | KEEP                  | operative              | ✓ none                         |
| /new-alarm                     | ACCEPTED GAP (deferred-pending-arrangement-system) | dead stub, no manifest | **remove stub**; no REQ, no build |
| /spt:setup /spt:version        | ADD                      | operative              | ✓ none                         |
| profiles + strings + hints     | ADD                      | wired (deep/live)      | ✓ none                         |
| **subnet skills (create/join/show-code)** | ADD (LOCKED)  | **ABSENT**             | **BUILD** (Phase C)            |
| **ccs profiles (claude-spt:glm …)** | ADD (LOCKED)        | **ABSENT**             | **BUILD** (Phase D)            |
| /whoami SKILL                  | DROP skill               | **operative (violation)** | **REMOVE** (Phase B)        |
| /fork /amend-signoff /revive /clear-psyche | DROP         | absent                 | ✓ none (confirm absent)        |

Public surface confirms the ADD gaps are buildable: `spt subnet {create,join,show-code,status}`
exists; ccs = profile overlays retargeting command templates (SCOPE §ccs, profile *templates*
only — user supplies their own ccs config/keys).

## Progress (this session)
- ✅ **Phase A** (b1db67f) — docs/PARITY.md matrix + REQ seed (REQ-PARITY-AUDIT [doc] satisfied;
  REQ-SKILL-SUBNET / REQ-CCS-PROFILES seeded). DROP set confirmed absent.
- ✅ **Phase B** (0595910) — whoami skill DROPPED (manifest + body + stub + hint + test repoints);
  dead new-alarm stub removed. Surface 11→9.
- ✅ **Phase C** (e3382a0) — /sptc:subnet skill added (one skill wrapping `spt subnet`).
  REQ-SKILL-SUBNET [doc,int] green on v0.7.2. how-to-subnet finding logged. Surface 9→10.
- ✅ **Phase D** (b15c1bf) — `claude-spt:ccs` profile. Scoped to bare `ccs` (default account, NOT
  glm/kimi — operator ruling); validated vs sister project claude_skill_owl. Spawn seam
  (`[profiles.ccs.session.self].command = "ccs"`, a drop-in for `claude`) + observability string
  leaf. Log-dir seam landed IN the extractor: claude-spt-digest now honors `CLAUDE_CONFIG_DIR`
  (owl `owlery::claude_projects_root` parity) — ccs relocates CC's transcript tree per-account, a
  runtime value with no static path, so no `[profiles.ccs.digest]` leaf. REQ-CCS-PROFILES
  [doc,impl,unit,int] green. Finding logged (SCOPE conflated ccs profiles vs accounts).
  **docs/PARITY.md verdict → PROVEN (adapter surface).** Gate PASS; registration-int OK on v0.7.2.
- ✅ **Phase E** (publish prep as sptc) — CHANGELOG.md created (`## [0.1.0] - 2026-06-15`,
  user-facing UX only); plugin.json bumped 0.0.1→0.1.0 (name stays `sptc`; version independent of
  the adapter-manifest version per RUNBOOK). validate-skeleton INSTALLABLE + package-skeleton
  dry-run green; llms.txt regen = no drift. Gate PASS + traceable green.
- ✅ **PUBLISHED (2026-06-15, operator-authorized)** — `package-skeleton.sh --apply` staged the
  skeleton into the cplugs clone; `sptc` registered in `marketplace.json` (first publish — added
  alongside legacy `spt`/screen-timelapse/everything); cplugs commit **b1e2895** ("sptc: first
  publish — spt adapter skeleton v0.1.0") pushed to `SaberMage/cplugs` main (33927ca..b1e2895).
  **Remaining = pointer flip only:** `claude plugin install sptc@cplugs` + `/reload-plugins`
  (session-affecting — needs CC restart; held for operator).

## Phases (sequence; one commit + green gate each)

### Phase A — Parity audit artifact + REQ seed
- Write `docs/PARITY.md` (the matrix above + per-row evidence/finding) — the "proven parity"
  record the succession flip gates on. Tag `<!-- doc->REQ-PARITY-AUDIT -->`.
- Seed REQs in `traceable-reqs.toml` FIRST (before building): `REQ-SKILL-SUBNET`,
  `REQ-CCS-PROFILES`, `REQ-PARITY-AUDIT`. Activate stages as each phase lands.
- Confirm the DROP set is truly absent (grep no fork/revive/clear-psyche/amend-signoff residue).

### Phase B — Drop the whoami skill (honor LOCKED)
- Remove `whoami = { file = "skills/whoami.md" }` from manifest `[strings.skills]`.
- Delete `adapter/strings/skills/whoami.md` + `plugin/sptc/skills/whoami/`.
- **registration-int.sh** uses `skills.whoami` as the canonical file-backed + UPS-injection
  example (4b/4c) — repoint to a surviving skill (`skills.ready`).
- Redirect the `who am i` **hint** (currently → /sptc:whoami) to core `spt whoami` guidance.
- Audit REQ evidence: nothing is tagged `REQ-*` on whoami.md directly, but check
  `tests/hooks-parse.sh` / manifest-shortcut for whoami coupling before deleting.

### Phase C — Subnet skills (LOCKED ADD; public verbs exist)
- **Open shape decision (resolve at top of phase):** three skills
  (`/sptc:subnet-create|join|show-code`) vs one routing `/sptc:subnet` skill. Lean: match SCOPE's
  enumeration but as ONE `subnet` skill body that documents create/join/show-code (fewer
  surfaces, cleaner UX) — confirm before authoring.
- File-backed body(ies) `adapter/strings/skills/subnet*.md` (run `spt how-to subnet` / wrap the
  real `spt subnet …` verbs); manifest `[strings.skills]` entries; thin SKILL.md stub(s); hint(s).
- `REQ-SKILL-SUBNET` [doc,impl] (+ int via registration-int file-backed-resolve assertion).

### Phase D — ccs profiles (LOCKED ADD)
- **Research first** (mcp/context7 not needed — read SCOPE §ccs + the existing `[profiles.*]`
  command seams). ccs profile = a `[profiles.<glm|kimi>]` overlay that leaf-replaces the spawn /
  psyche / echo command templates to route through `ccs <profile> …`, with a per-profile `~/.ccs`
  log dir. Author profile *templates* only (no user keys).
- Add `[profiles.glm]` (+ maybe `[profiles.kimi]`) overlays; observable via `spt adapter
  get-string claude-spt:glm …` + resolves in registration-int (mirror the :deep/:live assertions).
- `REQ-CCS-PROFILES` [doc,impl] (+ int).

### Phase E — Publish prep (as sptc; NO name flip)
- **Create `CHANGELOG.md`** with a `## [<version>] - 2026-06-15` section (Added/Changed/Fixed,
  user-facing UX only, no internal lingo) — release fails loudly without it.
- **Bump `plugin.json`** `version` 0.0.1 → first real (structural first publish). Keep `name=sptc`.
- Run `sh ci/publish/validate-skeleton.sh` + `sh ci/publish/package-skeleton.sh` (DRY-RUN) green.
- Regenerate docs (mdBook + llms.txt) — drift gate green.
- The actual cplugs commit/push + `claude plugin install sptc@cplugs` stays the **operator's**
  step (credentials + pointer flip) — plan stops at a validated dry-run + a publish checklist.

### Gate (every phase)
`sh ci/run-gates.sh` PASS + `traceable-reqs check` green. Commit `Co-authored by: perri`.

## Open questions (resolve in-phase, don't block planning)
- Subnet skill shape: 3 skills vs 1 routing skill (Phase C top).
- ccs: which profiles ship as templates (glm only, or glm+kimi)? SCOPE names `claude-spt:glm`.
- plugin.json first-publish version number (0.1.0? matches adapter manifest 0.1.0, but RUNBOOK
  says keep them DISTINCT — pick independently).

## Findings carried (not ours to fix)
- new-alarm core alarm/deferred-pulse primitive (blocks new-alarm parity) — doyle.
- hard/no-grace force-stop primitive — doyle.
- Live relay int held for spt-core M11 counter-15 release ([[live-int-gated-m11-counter15]]).

## Done immediately before (context)
- 4b042cc live sub-slice (claude-spt-psyche runner + /sptc:live body, REQ-SKILL-LIVE [doc,impl,unit]).
- 692b67c F-004 flip (digest-proof int GREEN on v0.7.2, REQ-DIST-DIGEST-EXTRACTOR full).
- spt upgraded 0.7.1 → 0.7.2 (operator-approved daemon bounce).

-   fixture; the durable timer is a deliberate deferral (DEFERRED.md:43, ADR-0018 Q4/V3), and the

+   fixture; the durable timer is a deliberate deferral (DEFERRED.md:43), and the


- [x] **F-002 resolved-by-design (ADR-0020):** canonical poll format = self-delimiting `<EVENT>`

- [x] **F-002 resolved-by-design:** canonical poll format = self-delimiting `<EVENT>`


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

# SKELETON milestone — JIT plan

> First body of work for `spt-claude-code`, on the **v0.6.0 (M10) public surface**. JIT: this
> plans the skeleton milestone only — M12-dependent pieces are explicitly out of scope until
> M12 publishes (doyle pings when its surface is live). Authoritative why/what: `SCOPE.md`.

## Goal

Stand up the **thin cplugs skeleton plugin** — the low-churn marketplace artifact — and the
traceability + release foundation around it. No spt-core logic lives here; the plugin is
skill skeletons + `hooks.json` (→ `spt api`) + a SessionStart bootstrap + `plugin.json`.

## Scope (v0.6.0-supported, startable now)

In:
- Traceability gate green + skeleton-milestone REQs seeded (DONE this body — see below).
- cplugs publish mechanics captured in `docs/RELEASE-RUNBOOK.md` (DONE this body).
- The plugin skeleton itself: `plugin.json`, `hooks.json` (delegating to `spt api`),
  SessionStart bootstrap that installs spt-core when absent, namespaced `/spt:*` skill
  skeletons for the KEEP set, `/spt:version`, the UPS-injection hook.
- ADR-0001 (distribution split: skeleton plugin vs spt-conducted manifest+binary).
- Empirical confirmation: does `UserPromptSubmit` fire on `/spt:X` slash-commands? (gates the
  UPS-injection design — REQ-UPS-INJECTION). Fallback = in-SKILL.md instructions if it doesn't.

Out (deferred to post-M12 — sequence after doyle's M12-live ping):
- File-backed `[strings]`, `adapter:profile` fallback, `[adapter] shortcut_basename`,
  `spt endpoint run` bringup picker, `spt rc` cross-node attach, `cc` launcher, `/spt:setup`,
  whoami→alias. (M12 public-surface deps — `SCOPE.md` §"spt-core upstream deps".)
- CI fleet wiring (own milestone) and docs-site mdBook build (own milestone) — both startable
  on v0.6.0 but not part of THIS skeleton body; planned next.

## Open design questions

- ~~cplugs plugin name~~ **RULED (operator, 2026-06-14): `sptc`** → skills `/sptc:*` (CC ties the
  prefix to `plugin.json` name, no override). Succession: flip `sptc`→`spt` on parity-proof,
  retiring legacy `spt` in the same move. `sptc` is the single-substitution rename seam.
- **UPS-on-slash-command** — design assumes UPS fires; confirm empirically (above).
- `/spt:setup` #9 verify fold-inline vs deferred (`SCOPE.md` §8 open item) — not skeleton-blocking.

## Tasks

- [x] Green `traceable-reqs check` (fix seed scan-root manifest_error + 4 template tag-leaks).
- [x] Seed skeleton-milestone REQs: DIST-PLUGIN-SKELETON, DIST-BOOTSTRAP-INSTALL, DIST-HOOKS-API,
      DIST-MANIFEST-SCHEMA, UPS-INJECTION, SKILL-VERSION.
- [x] Capture cplugs skeleton-publish mechanics in `docs/RELEASE-RUNBOOK.md`.
- [x] Write ADR-0001 (distribution split) → `doc` evidence (PLUGIN-SKELETON, HOOKS-API,
      BOOTSTRAP-INSTALL, UPS-INJECTION activated to `doc`).
- [x] `plugin/sptc/plugin.json` (name=sptc), `plugin/sptc/bootstrap.{sh,ps1}` (published
      install-on-demand verbatim; BOOTSTRAP-INSTALL → `doc,impl`), 9 skill skeletons (KEEP set +
      `/sptc:version`, thin stubs). Added `plugin` as a `[scan].root`.
- [x] Record spt-core public-surface finding **F-001**; **converged + re-scoped 2026-06-15** with
      doyle (grounded in `CONTEXT.md`): the hook wiring is adapter-authoring (ours), not spt-core
      gaps. Residual spt-core item = publish `api poll` emit-format + sub-key catalog (doyle, non-
      blocking). See `docs/SPT-CORE-FINDINGS.md`.
- [x] **ADR-0002** — hook wiring: hand-written CC `hooks.json` shells `spt api --adapter claude-spt`;
      full CC-event→api mapping; lifecycle (SessionStart seeds, `listen` runs from `/sptc:ready|live`
      not a hook); UPS→`poll`→stdout→`additionalContext`. Grounded in observed 0.6.0 binary + CC
      hook-input schema.
- [x] **Author the hook glue:** `plugin/sptc/hooks/hooks.json` + wrappers (`_common.sh`,
      session-start, user-prompt-submit, stop, session-end, subagent-start/stop) per ADR-0002.
      All `sh -n` clean; hooks.json valid JSON.
- [x] **Hook-side id-resolution** (ADR-0002 Open#1) resolved via `spt whoami`.
- [x] **Parser unit-tested** (`tests/hooks-parse.sh`: `json_str` + `render_frames`, 7/7 pass).
      `REQ-DIST-HOOKS-API` + `REQ-UPS-INJECTION` activated to `[doc,impl,unit]` (`int` held).
- [x] **F-002 resolved-by-design (ADR-0020):** canonical poll format = self-delimiting `<EVENT>`
      envelope (`__REPLY_TO__` relic deleted). Parser retargeted to `<EVENT>` (split on `</EVENT>`,
      `from` attr + `<br>`/entity unescape) — **multi-message built + unit-tested** (10/10). No
      `<EVENT-PART>` reassembly at poll (listener-stream-only). Follow-up: CC `additionalContext`
      10k cap is adapter-side (truncate/spill) — `int` item.
- [x] **`int` validation — CC-side confirmed** (throwaway `claude -p` rig, 2026-06-15):
      `UPS-fires-on-/sptc:X` ✅ (literal `/send hi` in `prompt`, hook fired, skill ran);
      Windows `shell:bash` hooks ✅ (`$CLAUDE_PROJECT_DIR` resolves); stdin schema ✅. Surfaced the
      MSYS `/`-arg hazard → KNOWN-HAZARDS 1.1 + `REQ-HAZARD-MSYS-PATHCONV` (tests/msys-hazard.sh).
- [ ] **`int` remaining — gated on spt-core `REQ-MSG-ENVELOPE`:** the `api poll`→`additionalContext`
      round-trip against a real spt perch (capture `<EVENT>` bytes → confirm-match to doyle → flip
      `int` for HOOKS-API/UPS-INJECTION). Plus large-drain injection-size handling (10k cap).
- [ ] `/sptc:version` operative body + adapter manifest → validate against published
      `manifest.schema.json` (`reference/schema.md`); activate MANIFEST-SCHEMA, SKILL-VERSION.
- [ ] Tag `impl`/`unit` + activate as each surface lands; `traceable-reqs check` EXIT=0.

## Gate

`traceable-reqs check` EXIT=0 with the skeleton milestone's reqs activated and covered, the
skeleton plugin installable from a local marketplace clone, and the manifest validating against
the published schema. Build/CI wiring is a separate milestone.

-- [x] **F-002 resolved-by-design (ADR-0020):** canonical poll format = self-delimiting `<EVENT>`

+- [x] **F-002 resolved-by-design:** canonical poll format = self-delimiting `<EVENT>`


Now plugin hooks and tests.


27	  if [ -n "$frames" ]; then
28	    # Format for CC, preserving the sender (reply-correlation: ADR-0009/0012). render_frames parses
29	    # the self-delimiting <EVENT> envelope (ADR-0020) — multi-message drains split on </EVENT>.
30	    rendered=$(render_frames "$frames")

  if [ -n "$frames" ]; then
    # Format for CC, preserving the sender (reply-correlation: ADR-0009/0012). render_frames parses
    # the self-delimiting <EVENT> envelope (ADR-0020) — multi-message drains split on </EVENT>.
    rendered=$(render_frames "$frames")


59	}
60
61	# Render an `api poll` drain ($1) for CC. Canonical format (ADR-0020): 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 (ADR-0020) — finalize/validate against poll only post-refactor. [unit->REQ-UPS-INJECTION]
67	render_frames() {

}

# Render an `api poll` drain ($1) for CC. Canonical format (ADR-0020): 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 (ADR-0020) — finalize/validate against poll only post-refactor. [unit->REQ-UPS-INJECTION]
render_frames() {


24
25	# --- render_frames: canonical <EVENT> envelope (ADR-0020) --- [unit->REQ-UPS-INJECTION]
26	# Named single, <br>-escaped multiline body, sender preserved.

# --- render_frames: canonical <EVENT> envelope (ADR-0020) --- [unit->REQ-UPS-INJECTION]
# Named single, <br>-escaped multiline body, sender preserved.


Line 28: `(reply-correlation: ADR-0009/0012).` — parenthetical with other content. `ADR-0009/0012` is the citation. Drop it: `(reply-correlation).`. Line 29: `(ADR-0020) —` pure citation → delete parenthetical incl leading space → `envelope — multi-message`. Line 61: `Canonical format (ADR-0020):` → `Canonical format:`. Line 66: `refactor lands (ADR-0020) —` → `refactor lands —`. Test line 25: `(ADR-0020)` → delete.


    # Format for CC, preserving the sender (reply-correlation: ADR-0009/0012). render_frames parses
    # the self-delimiting <EVENT> envelope (ADR-0020) — multi-message drains split on </EVENT>.

    # Format for CC, preserving the sender (reply-correlation). render_frames parses
    # the self-delimiting <EVENT> envelope — multi-message drains split on </EVENT>.


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

    # Format for CC, preserving the sender (reply-correlation). render_frames parses
    # the self-delimiting <EVENT> envelope — multi-message drains split on </EVENT>.

#!/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)
  frames=$("$SPT" api --adapter "$ADAPTER" poll "$id" --session-id "$sid" 2>/dev/null)
  if [ -n "$frames" ]; then
    # Format for CC, preserving the sender (reply-correlation: ADR-0009/0012). render_frames parses
    # the self-delimiting <EVENT> envelope (ADR-0020) — 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

-    # Format for CC, preserving the sender (reply-correlation: ADR-0009/0012). render_frames parses

-    # the self-delimiting <EVENT> envelope (ADR-0020) — multi-message drains split on </EVENT>.

+    # Format for CC, preserving the sender (reply-correlation). render_frames parses

+    # the self-delimiting <EVENT> envelope — multi-message drains split on </EVENT>.


# Render an `api poll` drain ($1) for CC. Canonical format (ADR-0020): every message is a

# Render an `api poll` drain ($1) for CC. Canonical format: every message is a