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

adapter add accepting the [session.self]/[env] blocks is covered by registration-int.sh step 4d (REQ-DIST-MANIFEST-SCHEMA int).

[[requirements]]
id = "REQ-UPS-INJECTION"

adapter add accepting the [session.self]/[env] blocks is covered by registration-int.sh step 4d (REQ-DIST-MANIFEST-SCHEMA int).

[[requirements]]
id = "REQ-DIST-DIGEST-EXTRACTOR"
title = "The claude-spt [digest] extractor (claude-spt-digest) maps Claude Code's JSONL transcript to the published digest-record NDJSON contract (role∈{input,agent,tool}; text/tool/ts), emitting RAW records for spt-core's renderer (ADR-0019)"
required_stages = ["impl", "unit"]  # impl: tools/claude-spt-digest (Rust — CC JSONL -> digest NDJSON; per-session locate of <slug>/<session_id>.jsonl under the projects root via the {session_id} the command receives; native UTF-8 stdout; defensive ~ expansion) + ci/digest/build.sh gate. unit: the crate's cargo tests (9: input/agent/tool mapping, arg-key preference, thinking+tool_result+noise+bad-json drops, UTF-8 round-trip, contract invariants, tilde expansion) run by ci/digest/build.sh. doc deferred (the mapping is documented in the module + manifest [digest] comment; a docs-site section lands with the doc slice). int deferred behind F-004: `spt adapter digest-proof --sample` passes an empty substitution-key map (doyle-confirmed spt-core impl bug, cli.rs:5135; runtime fills {id}+{session_id}) so it false-fails the {session_id}-templated command; ci/digest/digest-proof-int.sh skips on that exact error. Extractor proven meanwhile: cargo tests + a source-only digest-proof run returned DIGEST_PROOF_OK (5 parsed / 0 dropped, correct render — F-004 Variant A). Flips green on doyle's carrying release.

[[requirements]]
id = "REQ-UPS-INJECTION"

required_stages = ["doc", "impl", "unit"]  # doc: ADR-0001 (cc launcher brand, decoupled from the s/sptc/spt/ plugin-name succession). impl: the cc-launcher BRINGUP machinery — adapter [session.self] (spawns `claude`) + [env.SPT_ENDPOINT_ID] (broker injects the endpoint id) + the SessionStart bind branch (sptc_register_verb -> `api bind`, the spt-hosted self-register path). unit: tests/manifest-shortcut.sh (brand-value guard) + tests/hooks-parse.sh sptc_register_verb (bind/seed/boundary branch). int (real `spt endpoint run` picker emits cc-<id> + real-claude bringup-bind) deferred — the shortcut-emitting picker "lands in a later spt-core wave" (per `endpoint run --help`); the bringup-bind is now contract-unblocked (doyle confirmed 2026-06-15: bind is establishing/intrinsic-auth, --set-session-id only, no token) but still needs a real-claude acceptance spawn (heavier tier). adapter add accepting the [session.self]/[env] blocks is covered by registration-int.sh step 4d (REQ-DIST-MANIFEST-SCHEMA int).

+title = "The claude-spt [digest] extractor (claude-spt-digest) maps Claude Code's JSONL transcript to the published digest-record NDJSON contract (role∈{input,agent,tool}; text/tool/ts), emitting RAW records for spt-core's renderer (ADR-0019)"

+required_stages = ["impl", "unit"]  # impl: tools/claude-spt-digest (Rust — CC JSONL -> digest NDJSON; per-session locate of <slug>/<session_id>.jsonl under the projects root via the {session_id} the command receives; native UTF-8 stdout; defensive ~ expansion) + ci/digest/build.sh gate. unit: the crate's cargo tests (9: input/agent/tool mapping, arg-key preference, thinking+tool_result+noise+bad-json drops, UTF-8 round-trip, contract invariants, tilde expansion) run by ci/digest/build.sh. doc deferred (the mapping is documented in the module + manifest [digest] comment; a docs-site section lands with the doc slice). int deferred behind F-004: `spt adapter digest-proof --sample` passes an empty substitution-key map (doyle-confirmed spt-core impl bug, cli.rs:5135; runtime fills {id}+{session_id}) so it false-fails the {session_id}-templated command; ci/digest/digest-proof-int.sh skips on that exact error. Extractor proven meanwhile: cargo tests + a source-only digest-proof run returned DIGEST_PROOF_OK (5 parsed / 0 dropped, correct render — F-004 Variant A). Flips green on doyle's carrying release.

title = "UserPromptSubmit hook detects /sptc:X and injects X's real instructions as additionalContext (SKILL.md files stay skeletons); must be empirically confirmed UPS fires on slash-commands"

required_stages = ["doc", "impl", "unit", "int"]  # doc: ADR-0002. impl: user-prompt-submit.sh (skill-injection + drain) + _common.sh. unit: render_frames + sptc_skill_key tests. int: ci/hooks/poll-int.sh — the poll->additionalContext drain round-trip vs live v0.7.1 (<EVENT> envelope, render_frames confirm-match, multi-message). UPS-fires-on-slash empirical = ADR-0002 validation (CC 2.1.177); skill-injection resolve = registration-int 4c.


Now F-004 finding + gitignore. Reading findings doc header + gitignore:


1	# spt-core public-surface findings
2
3	> Per the public-surface-only constraint (`AGENTS.md`, `HANDOFF.md` §1): if a capability needed
4	> to build the adapter is missing/ambiguous in spt-core's **published** surface (`spt-releases`
5	> releases + GH Pages docs), that is a **finding** — `spt-core`'s published contract has a gap.
6	> We report it to the spt-core owner (**doyle**) and do **not** reverse-engineer from legacy
7	> `claude_skill_owl` or reach into spt-core source. This file is the in-repo log of those findings.
8
9	| # | Date | Status | Summary |
10	|---|------|--------|---------|
11	| F-001 | 2026-06-14 | **re-scoped 2026-06-15** — most = adapter-authoring (closed); 1 residual spt-core docs item open | Hook-wiring for a CC adapter — boundary clarified |
12	| F-002 | 2026-06-15 | **RESOLVED-SHIPPED (v0.7.1)** — `<EVENT>` verified on the published `api poll` surface; F-002 dissolved; int flipped | `api poll` agent path has no inter-frame delimiter → multi-message drains are unsplittable |
13	| F-003 | 2026-06-15 | **RESOLVED + docs CLOSED (v0.7.1)** — capability shipped; the file-pointer syntax is now on the published surface | File-backed `[strings]` IS shipped (value-position table pointer `key = { file = "rel" }`) but was **undocumented** on the published surface |
14
15	---
16

# spt-core public-surface findings

> Per the public-surface-only constraint (`AGENTS.md`, `HANDOFF.md` §1): if a capability needed
> to build the adapter is missing/ambiguous in spt-core's **published** surface (`spt-releases`
> releases + GH Pages docs), that is a **finding** — `spt-core`'s published contract has a gap.
> We report it to the spt-core owner (**doyle**) and do **not** reverse-engineer from legacy
> `claude_skill_owl` or reach into spt-core source. This file is the in-repo log of those findings.

| # | Date | Status | Summary |
|---|------|--------|---------|
| F-001 | 2026-06-14 | **re-scoped 2026-06-15** — most = adapter-authoring (closed); 1 residual spt-core docs item open | Hook-wiring for a CC adapter — boundary clarified |
| F-002 | 2026-06-15 | **RESOLVED-SHIPPED (v0.7.1)** — `<EVENT>` verified on the published `api poll` surface; F-002 dissolved; int flipped | `api poll` agent path has no inter-frame delimiter → multi-message drains are unsplittable |
| F-003 | 2026-06-15 | **RESOLVED + docs CLOSED (v0.7.1)** — capability shipped; the file-pointer syntax is now on the published surface | File-backed `[strings]` IS shipped (value-position table pointer `key = { file = "rel" }`) but was **undocumented** on the published surface |

---


if you aren't too far into building the extractor, you might want to switch to rust. fewer opportunities for latent bugs + it's what the legacy spt (which this project is based on) used. so it would j…


Consider switching extractor implementation to Rust


Edit

| F-003 | 2026-06-15 | **RESOLVED + docs CLOSED (v0.7.1)** — capability shipped; the file-pointer syntax is now on the published surface | File-backed `[strings]` IS shipped (value-position table pointer `key = { file = "rel" }`) but was **undocumented** on the published surface |

---

| F-003 | 2026-06-15 | **RESOLVED + docs CLOSED (v0.7.1)** — capability shipped; the file-pointer syntax is now on the published surface | File-backed `[strings]` IS shipped (value-position table pointer `key = { file = "rel" }`) but was **undocumented** on the published surface |
| F-004 | 2026-06-15 | **CONFIRMED-IMPL-BUG (doyle); fix in progress** — `digest-proof` will fill `{id}`+`{session_id}` matching runtime; int deferred until the carrying release | `spt adapter digest-proof --sample` passes an empty substitution-key map → false-fails any extractor whose command uses `{session_id}` (incl. the published example) |

---


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)

| F-003 | 2026-06-15 | **RESOLVED + docs CLOSED (v0.7.1)** — capability shipped; the file-pointer syntax is now on the published surface | File-backed `[strings]` IS shipped (value-position table pointer `key = { file = "rel" }`) but was **undocumented** on the published surface |

---

| F-003 | 2026-06-15 | **RESOLVED + docs CLOSED (v0.7.1)** — capability shipped; the file-pointer syntax is now on the published surface | File-backed `[strings]` IS shipped (value-position table pointer `key = { file = "rel" }`) but was **undocumented** on the published surface |
| F-004 | 2026-06-15 | **CONFIRMED-IMPL-BUG (doyle); fix in progress** — `digest-proof` will fill `{id}`+`{session_id}` matching runtime; int deferred until the carrying release | `spt adapter digest-proof --sample` passes an empty substitution-key map → false-fails any extractor whose command uses `{session_id}` (incl. the published example) |

---

| F-001 | 2026-06-14 | **re-scoped 2026-06-15** — most = adapter-authoring (closed); 1 residual spt-core docs item open | Hook-wiring for a CC adapter — boundary clarified |

| F-002 | 2026-06-15 | **RESOLVED-SHIPPED (v0.7.1)** — `<EVENT>` verified on the published `api poll` surface; F-002 dissolved; int flipped | `api poll` agent path has no inter-frame delimiter → multi-message drains are unsplittable |

+| F-004 | 2026-06-15 | **CONFIRMED-IMPL-BUG (doyle); fix in progress** — `digest-proof` will fill `{id}`+`{session_id}` matching runtime; int deferred until the carrying release | `spt adapter digest-proof --sample` passes an empty substitution-key map → false-fails any extractor whose command uses `{session_id}` (incl. the published example) |


Adding the F-004 detail section at the end of the findings doc:


244	"skills/<x>.md" }` over `adapter/strings/skills/<x>.md`. **Residual = published-docs visibility only:**
245	the pointer syntax was undocumented on the GH-Pages surface (it lived in spt-core-internal
246	`CONTEXT.md §adapter strings`) — same class as the `{key}` catalog + `[digest]` cross-field rule.
247	**CLOSED in v0.7.1 (2026-06-15):** doyle published the `[strings]` file-pointer syntax (plus the
248	`{key}` substitution catalog and the `[digest]` register rule) to the docs-site manifest reference +
249	`MANIFEST.md`. The next adapter author discovers the pointer form from the published surface alone —
250	F-003 fully resolved.
251

"skills/<x>.md" }` over `adapter/strings/skills/<x>.md`. **Residual = published-docs visibility only:**
the pointer syntax was undocumented on the GH-Pages surface (it lived in spt-core-internal
`CONTEXT.md §adapter strings`) — same class as the `{key}` catalog + `[digest]` cross-field rule.
**CLOSED in v0.7.1 (2026-06-15):** doyle published the `[strings]` file-pointer syntax (plus the
`{key}` substitution catalog and the `[digest]` register rule) to the docs-site manifest reference +
`MANIFEST.md`. The next adapter author discovers the pointer form from the published surface alone —
F-003 fully resolved.


`MANIFEST.md`. The next adapter author discovers the pointer form from the published surface alone —
F-003 fully resolved.

`MANIFEST.md`. The next adapter author discovers the pointer form from the published surface alone —
F-003 fully resolved.

---

## 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
path) into the extractor command and hard-fails on any other key. The claude-spt extractor command
is the production-correct, published-example shape:

```
extractor = "claude-spt-digest --session {session_id} --in {source}"
```

→ `DIGEST_PROOF_EXTRACT_FAIL:claude-spt: digest extractor failed: no value for substitution key {session_id}`.

**Isolation (same adapter, only the command line differs):**
- **Variant A** — `<exe> --in {source}` (source-only) → `DIGEST_PROOF_OK`, **parsed 5 / dropped 0**,
  rendered digest correct (incl. sprint-collapse `used: Write(src/a.rs), Bash(cargo build)`). Proves
  the extractor + the digest-proof render pipeline are correct.
- **Variant B** — `<exe> --session {session_id} --in {source}` → fails on the unfilled `{session_id}`.

**Why claude-spt needs `{session_id}` on the command (not a workaround — the only correct shape).**
CC's transcript path is `~/.claude/projects/<cwd-slug>/<session_id>.jsonl`. The `<cwd-slug>` subdir
is CC-internal and has **no published key** — and per doyle, *should* not (spt-core stays
harness-agnostic; it must not bake a harness's directory scheme into the key catalog). So `source`
must be the projects **root** and the extractor receives `{session_id}` on the command, resolving
`<slug>/<session_id>.jsonl` itself (the slug is the harness's business). This matches the published
example and what `spt endpoint digest` fills at runtime.

**Root cause (doyle, spt-core).** `digest-proof --sample` builds an **empty** substitution-key map
(`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
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.

Aside: `{home}` is **not** a catalog key — hard-failing on it is correct. Use `~` for home
expansion in `source` (we switched `source` to `~/.claude/projects`; the extractor also expands a
leading `~/` defensively).

**Status — CONFIRMED-IMPL-BUG; fix in progress (doyle's worktree).** digest-proof will fill
`{id}`+`{session_id}` matching runtime exactly (fidelity: *"passes proof" ⟺ "works at runtime"*),
plus an optional `--session <id>` override (default placeholder). The published contract is right;
the tool is catching up. **Our posture:** the extractor is built to the published contract and
proven (cargo unit tests + Variant A `DIGEST_PROOF_OK`); the digest-proof `int`
(`ci/digest/digest-proof-int.sh`, `REQ-DIST-DIGEST-EXTRACTOR`) **skips on the exact substitution
error** until doyle's fix ships, then flips green on the carrying release.


`MANIFEST.md`. The next adapter author discovers the pointer form from the published surface alone —
F-003 fully resolved.

---

## 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
path) into the extractor command and hard-fails on any other key. The claude-spt extractor command
is the production-correct, published-example shape:

```
extractor = "claude-spt-digest --session {session_id} --in {source}"
```

→ `DIGEST_PROOF_EXTRACT_FAIL:claude-spt: digest extractor failed: no value for substitution key {session_id}`.

**Isolation (same adapter, only the command line differs):**
- **Variant A** — `<exe> --in {source}` (source-only) → `DIGEST_PROOF_OK`, **parsed 5 / dropped 0**,
  rendered digest correct (incl. sprint-collapse `used: Write(src/a.rs), Bash(cargo build)`). Proves
  the extractor + the digest-proof render pipeline are correct.
- **Variant B** — `<exe> --session {session_id} --in {source}` → fails on the unfilled `{session_id}`.

**Why claude-spt needs `{session_id}` on the command (not a workaround — the only correct shape).**
CC's transcript path is `~/.claude/projects/<cwd-slug>/<session_id>.jsonl`. The `<cwd-slug>` subdir
is CC-internal and has **no published key** — and per doyle, *should* not (spt-core stays
harness-agnostic; it must not bake a harness's directory scheme into the key catalog). So `source`
must be the projects **root** and the extractor receives `{session_id}` on the command, resolving
`<slug>/<session_id>.jsonl` itself (the slug is the harness's business). This matches the published
example and what `spt endpoint digest` fills at runtime.

**Root cause (doyle, spt-core).** `digest-proof --sample` builds an **empty** substitution-key map
(`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
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.

Aside: `{home}` is **not** a catalog key — hard-failing on it is correct. Use `~` for home
expansion in `source` (we switched `source` to `~/.claude/projects`; the extractor also expands a
leading `~/` defensively).

**Status — CONFIRMED-IMPL-BUG; fix in progress (doyle's worktree).** digest-proof will fill
`{id}`+`{session_id}` matching runtime exactly (fidelity: *"passes proof" ⟺ "works at runtime"*),
plus an optional `--session <id>` override (default placeholder). The published contract is right;
the tool is catching up. **Our posture:** the extractor is built to the published contract and
proven (cargo unit tests + Variant A `DIGEST_PROOF_OK`); the digest-proof `int`
(`ci/digest/digest-proof-int.sh`, `REQ-DIST-DIGEST-EXTRACTOR`) **skips on the exact substitution
error** until doyle's fix ships, then flips green on the carrying release.

`{key}` substitution catalog and the `[digest]` register rule) to the docs-site manifest reference +

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

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

+is the production-correct, published-example shape:

+extractor = "claude-spt-digest --session {session_id} --in {source}"

+→ `DIGEST_PROOF_EXTRACT_FAIL:claude-spt: digest extractor failed: no value for substitution key {session_id}`.

+**Isolation (same adapter, only the command line differs):**

+- **Variant A** — `<exe> --in {source}` (source-only) → `DIGEST_PROOF_OK`, **parsed 5 / dropped 0**,

+  rendered digest correct (incl. sprint-collapse `used: Write(src/a.rs), Bash(cargo build)`). Proves

+  the extractor + the digest-proof render pipeline are correct.

+- **Variant B** — `<exe> --session {session_id} --in {source}` → fails on the unfilled `{session_id}`.

+**Why claude-spt needs `{session_id}` on the command (not a workaround — the only correct shape).**

+CC's transcript path is `~/.claude/projects/<cwd-slug>/<session_id>.jsonl`. The `<cwd-slug>` subdir

+is CC-internal and has **no published key** — and per doyle, *should* not (spt-core stays

+harness-agnostic; it must not bake a harness's directory scheme into the key catalog). So `source`

+must be the projects **root** and the extractor receives `{session_id}` on the command, resolving

+`<slug>/<session_id>.jsonl` itself (the slug is the harness's business). This matches the published

+example and what `spt endpoint digest` fills at runtime.

+**Root cause (doyle, spt-core).** `digest-proof --sample` builds an **empty** substitution-key map

+(`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

+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.

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

+expansion in `source` (we switched `source` to `~/.claude/projects`; the extractor also expands a

+**Status — CONFIRMED-IMPL-BUG; fix in progress (doyle's worktree).** digest-proof will fill

+`{id}`+`{session_id}` matching runtime exactly (fidelity: *"passes proof" ⟺ "works at runtime"*),

+plus an optional `--session <id>` override (default placeholder). The published contract is right;

+the tool is catching up. **Our posture:** the extractor is built to the published contract and

+proven (cargo unit tests + Variant A `DIGEST_PROOF_OK`); the digest-proof `int`

+(`ci/digest/digest-proof-int.sh`, `REQ-DIST-DIGEST-EXTRACTOR`) **skips on the exact substitution

+error** until doyle's fix ships, then flips green on the carrying release.


Now gitignore for the Rust build artifacts:


Bash

cd "C:/Users/decid/Documents/projects/spt-claude-code"; cat .gitignore 2>/dev/null; echo "=== git check target tracked? ==="; git status --short tools/ 2>&1 | head

Check gitignore + tools status


# spt runtime perch state (not source) — depth-safe (** ), so it's ignored under any nested
# .claude/ too (e.g. plugin/sptc/.claude/ when this repo is loaded as a live plugin). A
# root-anchored pattern previously leaked plugin/sptc/.claude/LIVE_AGENT_IDS.json into the tree.
**/.claude/LIVE_AGENT_IDS.json
**/.claude/*-commune.md
**/.claude/*-signoff.md

# mdBook build artifact (generated; source is docs-site/src)
docs-site/book/

# generated launchers (capsule-style cc / cc-<id>)
/cc
/cc.bat
/cc.sh
/cc-*
=== git check target tracked? ===
?? tools/


Edit

# mdBook build artifact (generated; source is docs-site/src)
docs-site/book/

# mdBook build artifact (generated; source is docs-site/src)
docs-site/book/

# Rust build artifacts (claude-spt-digest extractor crate); Cargo.lock tracked for the bin crate
tools/**/target/


<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>

Error: File has not been read yet. Read it first before writing to it.


1	# spt runtime perch state (not source) — depth-safe (** ), so it's ignored under any nested
2	# .claude/ too (e.g. plugin/sptc/.claude/ when this repo is loaded as a live plugin). A
3	# root-anchored pattern previously leaked plugin/sptc/.claude/LIVE_AGENT_IDS.json into the tree.
4	**/.claude/LIVE_AGENT_IDS.json
5	**/.claude/*-commune.md
6	**/.claude/*-signoff.md
7
8	# mdBook build artifact (generated; source is docs-site/src)
9	docs-site/book/
10
11	# generated launchers (capsule-style cc / cc-<id>)
12	/cc
13	/cc.bat
14	/cc.sh
15	/cc-*
16

# spt runtime perch state (not source) — depth-safe (** ), so it's ignored under any nested
# .claude/ too (e.g. plugin/sptc/.claude/ when this repo is loaded as a live plugin). A
# root-anchored pattern previously leaked plugin/sptc/.claude/LIVE_AGENT_IDS.json into the tree.
**/.claude/LIVE_AGENT_IDS.json
**/.claude/*-commune.md
**/.claude/*-signoff.md

# mdBook build artifact (generated; source is docs-site/src)
docs-site/book/

# generated launchers (capsule-style cc / cc-<id>)
/cc
/cc.bat
/cc.sh
/cc-*


# mdBook build artifact (generated; source is docs-site/src)
docs-site/book/

# mdBook build artifact (generated; source is docs-site/src)
docs-site/book/

# Rust build artifacts for the claude-spt-digest extractor crate (Cargo.lock is tracked, bin crate)
tools/**/target/


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

# mdBook build artifact (generated; source is docs-site/src)
docs-site/book/

# Rust build artifacts for the claude-spt-digest extractor crate (Cargo.lock is tracked, bin crate)
tools/**/target/

# spt runtime perch state (not source) — depth-safe (** ), so it's ignored under any nested
# .claude/ too (e.g. plugin/sptc/.claude/ when this repo is loaded as a live plugin). A
# root-anchored pattern previously leaked plugin/sptc/.claude/LIVE_AGENT_IDS.json into the tree.
**/.claude/LIVE_AGENT_IDS.json
**/.claude/*-commune.md
**/.claude/*-signoff.md

# mdBook build artifact (generated; source is docs-site/src)
docs-site/book/

# generated launchers (capsule-style cc / cc-<id>)
/cc
/cc.bat
/cc.sh
/cc-*

+# Rust build artifacts for the claude-spt-digest extractor crate (Cargo.lock is tracked, bin crate)


Running the full gate:


Bash

cd "C:/Users/decid/Documents/projects/spt-claude-code" && sh ci/run-gates.sh 2>&1 | tail -35

Full gate run


       hooks.json delegates to `spt api` rather than carrying adapter logic
  [OK] REQ-DIST-MANIFEST-SCHEMA  required: [doc, impl, unit, int]  stages: +doc +impl +unit +int
       CC adapter manifest validates against spt-core's published manifest.schema.json (from spt-releases)
  [OK] REQ-DIST-PLUGIN-SKELETON  required: [doc, impl, unit]  stages: +doc +impl +unit -int
       Marketplace artifact on cplugs is a thin skeleton: namespaced /sptc:* skill stubs + hooks.json + plugin.json, no embedded logic
  [OK] REQ-DIST-SHORTCUT-BASENAME  required: [doc, impl, unit]  stages: +doc +impl +unit -int
       The adapter manifest brands the `spt endpoint run` launcher shortcut as `cc-<id>` via adapter.shortcut_basename = "cc" (the M12 cc launcher; decoupled from the sptc plugin name, ADR-0001)
  [OK] REQ-DOCS-DRIFT  required: [doc, impl, unit]  stages: +doc +impl +unit -int
       Generated docs (llms.txt) are CI-gated against drift: a deterministic generator's output must match what's checked in, and the book must build
  [OK] REQ-DOCS-SITE  required: [doc, impl]  stages: +doc +impl -unit -int
       docs-site/ builds with mdBook from src/ (themed, Diátaxis-shaped, all-real-content — no placeholders)
  [OK] REQ-HAZARD-MSYS-PATHCONV  required: [unit]  stages: -doc -impl +unit -int
       Hook wrappers read the CC payload from stdin, never from a /-leading positional argv (Git-Bash/MSYS path-mangles those on Windows)
  [OK] REQ-HAZARD-PERCH-COLLISION  required: [unit]  stages: +doc +impl +unit +int
       The acceptance harness spawns every nested claude SUT under a DISPOSABLE identity (SPT_AGENT_ID=sptc-ci-<n>), never a live agent's perch id — a colliding id tears down the live agent's perch + poll stream (name-keyed, last-establish-wins)
  [OK] REQ-SKILL-VERSION  required: []  stages: -doc -impl -unit -int
       /spt:version reports the spt-core-tracked manifest/binary version (the version-of-truth, not the marketplace skeleton version)
  [OK] REQ-UPS-INJECTION  required: [doc, impl, unit, int]  stages: +doc +impl +unit +int
       UserPromptSubmit hook detects /sptc:X and injects X's real instructions as additionalContext (SKILL.md files stay skeletons); must be empirically confirmed UPS fires on slash-commands

Findings:
  [must] missing_stage REQ-DIST-DIGEST-EXTRACTOR stage=unit — Requirement is missing unit test evidence

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

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

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

=== RESULT: FAIL ===


traceable-reqs didn't see the unit tag in the Rust crate — likely `tools/` isn't in its scan paths. Checking the config:


cd "C:/Users/decid/Documents/projects/spt-claude-code"
grep -n "path\|glob\|src\|scan\|include\|root\|dir" traceable-reqs.toml | head -20
echo "=== does scanner see other tags in main.rs? grep impl tag ==="
grep -rn "REQ-DIST-DIGEST-EXTRACTOR" tools/ ci/ traceable-reqs.toml | head

Inspect traceable scan config


27:[scan]
28:# Every evidence location. A missing root makes evidence SILENTLY vanish from the trace —
29:# audit this whenever a new evidence dir appears. ADD `plugin`, `skills`, `hooks`, `tests`
30:# the moment those dirs are created (skeleton milestone, body #2) — until they exist a
31:# nonexistent root is a hard manifest_error, so only list what is on disk now.
32:roots = ["plugin", "ci", "ci/acceptance", "ci/publish", "ci/docs", "ci/manifest", "adapter", "tests", "docs", "docs-site", "CONTEXT.md", "SCOPE.md", "AGENTS.md", "HANDOFF.md"]
84:required_stages = ["doc", "impl", "unit"]  # doc: ADR-0001 (cc launcher brand, decoupled from the s/sptc/spt/ plugin-name succession). impl: the cc-launcher BRINGUP machinery — adapter [session.self] (spawns `claude`) + [env.SPT_ENDPOINT_ID] (broker injects the endpoint id) + the SessionStart bind branch (sptc_register_verb -> `api bind`, the spt-hosted self-register path). unit: tests/manifest-shortcut.sh (brand-value guard) + tests/hooks-parse.sh sptc_register_verb (bind/seed/boundary branch). int (real `spt endpoint run` picker emits cc-<id> + real-claude bringup-bind) deferred — the shortcut-emitting picker "lands in a later spt-core wave" (per `endpoint run --help`); the bringup-bind is now contract-unblocked (doyle confirmed 2026-06-15: bind is establishing/intrinsic-auth, --set-session-id only, no token) but still needs a real-claude acceptance spawn (heavier tier). adapter add accepting the [session.self]/[env] blocks is covered by registration-int.sh step 4d (REQ-DIST-MANIFEST-SCHEMA int).
89:required_stages = ["impl", "unit"]  # impl: tools/claude-spt-digest (Rust — CC JSONL -> digest NDJSON; per-session locate of <slug>/<session_id>.jsonl under the projects root via the {session_id} the command receives; native UTF-8 stdout; defensive ~ expansion) + ci/digest/build.sh gate. unit: the crate's cargo tests (9: input/agent/tool mapping, arg-key preference, thinking+tool_result+noise+bad-json drops, UTF-8 round-trip, contract invariants, tilde expansion) run by ci/digest/build.sh. doc deferred (the mapping is documented in the module + manifest [digest] comment; a docs-site section lands with the doc slice). int deferred behind F-004: `spt adapter digest-proof --sample` passes an empty substitution-key map (doyle-confirmed spt-core impl bug, cli.rs:5135; runtime fills {id}+{session_id}) so it false-fails the {session_id}-templated command; ci/digest/digest-proof-int.sh skips on that exact error. Extractor proven meanwhile: cargo tests + a source-only digest-proof run returned DIGEST_PROOF_OK (5 parsed / 0 dropped, correct render — F-004 Variant A). Flips green on doyle's carrying release.
122:title = "The spt messaging (bus) binary is located robustly at run time (per-version plugins path / PATH / configured) — never a hard-coded versioned path"
137:title = "Hook wrappers read the CC payload from stdin, never from a /-leading positional argv (Git-Bash/MSYS path-mangles those on Windows)"
145:title = "docs-site/ builds with mdBook from src/ (themed, Diátaxis-shaped, all-real-content — no placeholders)"
146:required_stages = ["doc", "impl"]  # doc: DOCS-STRATEGY. impl: docs-site/book.toml + src/ pages.
=== does scanner see other tags in main.rs? grep impl tag ===
tools/claude-spt-digest/src/main.rs:25://! [impl->REQ-DIST-DIGEST-EXTRACTOR]
tools/claude-spt-digest/src/main.rs:269:// [unit->REQ-DIST-DIGEST-EXTRACTOR]
ci/digest/build.sh:5:# silently passing (no silent caps). [impl->REQ-DIST-DIGEST-EXTRACTOR]
ci/digest/digest-proof-int.sh:4:# forward for spt-claude-code). [int->REQ-DIST-DIGEST-EXTRACTOR]
traceable-reqs.toml:87:id = "REQ-DIST-DIGEST-EXTRACTOR"


`tools` isn't in `[scan].roots` — the crate's unit tag is invisible. Adding it:


Edit

roots = ["plugin", "ci", "ci/acceptance", "ci/publish", "ci/docs", "ci/manifest", "adapter", "tests", "docs", "docs-site", "CONTEXT.md", "SCOPE.md", "AGENTS.md", "HANDOFF.md"]

roots = ["plugin", "ci", "ci/acceptance", "ci/publish", "ci/docs", "ci/manifest", "ci/digest", "adapter", "tools", "tests", "docs", "docs-site", "CONTEXT.md", "SCOPE.md", "AGENTS.md", "HANDOFF.md"]


# audit this whenever a new evidence dir appears. ADD `plugin`, `skills`, `hooks`, `tests`

# the moment those dirs are created (skeleton milestone, body #2) — until they exist a

# nonexistent root is a hard manifest_error, so only list what is on disk now.

-roots = ["plugin", "ci", "ci/acceptance", "ci/publish", "ci/docs", "ci/manifest", "adapter", "tests", "docs", "docs-site", "CONTEXT.md", "SCOPE.md", "AGENTS.md", "HANDOFF.md"]

+roots = ["plugin", "ci", "ci/acceptance", "ci/publish", "ci/docs", "ci/manifest", "ci/digest", "adapter", "tools", "tests", "docs", "docs-site", "CONTEXT.md", "SCOPE.md", "AGENTS.md", "HANDOFF.md"]

# Default policy applied at activation: doc + impl + unit. Networking/lifecycle/cross-node


Bash

cd "C:/Users/decid/Documents/projects/spt-claude-code" && traceable-reqs check 2>&1 | grep -iE "DIGEST-EXTRACTOR|FAIL|must|error|PASS|OK\b" | head; echo "rc=$?"; traceable-reqs check >/dev/null 2>&1 && echo "TRACEABLE GREEN" || echo "TRACEABLE FAIL"

Re-check traceability


  [OK] REQ-CI-ACCEPTANCE  required: [doc, impl, int]  stages: +doc +impl -unit +int
  [OK] REQ-CI-BUS  required: [doc, impl]  stages: +doc +impl -unit -int
  [OK] REQ-CI-GATES  required: [doc, impl, unit]  stages: +doc +impl +unit -int
       Deterministic gates (shell-syntax + unit tests + traceable-reqs check + manifest-schema + docs-drift), each a binary pass/fail, runnable as one command
  [OK] REQ-CI-MANUAL  required: [doc, impl]  stages: +doc +impl -unit -int
  [OK] REQ-CI-OWL-DISCOVERY  required: [doc, impl, unit]  stages: +doc +impl +unit -int
  [OK] REQ-CI-TRIGGER  required: [doc, impl]  stages: +doc +impl -unit -int
       A git push hook fires the gates: pings a fleet runner-agent over spt rather than polling
  [OK] REQ-DIST-BOOTSTRAP-INSTALL  required: [doc, impl]  stages: +doc +impl -unit -int
  [OK] REQ-DIST-DIGEST-EXTRACTOR  required: [impl, unit]  stages: -doc +impl +unit +int
rc=0
TRACEABLE GREEN


Traceable green (`+impl +unit`). Full gate:


cd "C:/Users/decid/Documents/projects/spt-claude-code" && sh ci/run-gates.sh 2>&1 | grep -E "GATE:|^ok|FAIL|SKIP|RESULT"

Full gate, condensed


=== GATE: shell-syntax (sh -n) ===
ok  ci/acceptance/lib.sh
ok  ci/acceptance/run-acceptance.sh
ok  ci/digest/build.sh
ok  ci/digest/digest-proof-int.sh
ok  ci/docs/check-docs.sh
ok  ci/docs/gen-llms.sh
ok  ci/hooks/poll-int.sh
ok  ci/install-hooks.sh
ok  ci/lib/spt-bus.sh
ok  ci/manifest/check-manifest.sh
ok  ci/manifest/registration-int.sh
ok  ci/publish/package-skeleton.sh
ok  ci/publish/validate-skeleton.sh
ok  ci/run-gates.sh
ok  plugin/sptc/bootstrap.sh
ok  plugin/sptc/hooks/_common.sh
ok  plugin/sptc/hooks/session-end.sh
ok  plugin/sptc/hooks/session-start.sh
ok  plugin/sptc/hooks/stop.sh
ok  plugin/sptc/hooks/subagent-start.sh
ok  plugin/sptc/hooks/subagent-stop.sh
ok  plugin/sptc/hooks/user-prompt-submit.sh
ok  tests/acceptance-harness.sh
ok  tests/ci-gates.sh
ok  tests/docs-gen.sh
ok  tests/hooks-parse.sh
ok  tests/manifest-schema.sh
ok  tests/manifest-shortcut.sh
ok  tests/msys-hazard.sh
ok  tests/skeleton-validate.sh
=== GATE: unit tests (tests/*.sh) ===
ok  tests/acceptance-harness.sh
ok  tests/ci-gates.sh
ok  tests/docs-gen.sh
ok  tests/hooks-parse.sh
ok  tests/manifest-schema.sh
ok  tests/manifest-shortcut.sh
ok  tests/msys-hazard.sh
ok  tests/skeleton-validate.sh
=== GATE: digest-extractor (cargo build + test) ===
ok  claude-spt-digest: cargo test + release build
=== GATE: traceable-reqs check (requirement coverage) ===
ok  coverage green
=== GATE: skeleton-validate (cplugs installability) ===
ok  skeleton installable
=== GATE: manifest-schema ===
ok   C:/Users/decid/Documents/projects/spt-claude-code/adapter/claude-spt.toml
ok   manifest validates against published manifest.schema.json
=== GATE: docs-drift ===
ok   mdbook build
ok   llms.txt in sync
=== RESULT: PASS ===


Gate PASS. Quick check the `~` source still passes live `adapter add` (cross-field validation), since I changed it:


cd "C:/Users/decid/Documents/projects/spt-claude-code"
spt adapter remove claude-spt >/dev/null 2>&1
spt adapter add adapter/claude-spt.toml 2>&1 | head -1
spt adapter remove claude-spt >/dev/null 2>&1
echo "=== git: new/modified ==="
git status --short 2>&1

Verify ~ source registers + git status


ADAPTER_ADD:claude-spt:Harness:Copy (registered)
=== git: new/modified ===
 M .gitignore
 M adapter/claude-spt.toml
 M ci/run-gates.sh
 M docs/SPT-CORE-FINDINGS.md
 M traceable-reqs.toml
?? ci/digest/
?? tools/


`~` registers clean, target/ ignored. Running registration-int once (I touched the manifest) to confirm the [strings] int still green, then committing:


cd "C:/Users/decid/Documents/projects/spt-claude-code"
PATH="$(pwd)/tools/claude-spt-digest/target/release:$PATH"
SPTC_ACCEPTANCE=1 sh ci/manifest/registration-int.sh 2>&1 | tail -4
echo "=== digest-proof int (expect SKIP on F-004) ==="
SPTC_ACCEPTANCE=1 sh ci/digest/digest-proof-int.sh 2>&1 | tail -3

Run registration int + digest-proof int


ok   UPS skill-injection: /sptc:whoami -> wrapped body
ok   bringup blocks accepted; capability reports hostable harness
ok   removed (soft-deregistered)
MANIFEST-REGISTRATION-INT OK
=== digest-proof int (expect SKIP on F-004) ===
SKIP: blocked on F-004 — digest-proof --sample does not yet fill {session_id} (doyle's spt-core fix pending).
      extractor proven via cargo tests + source-only Variant A (DIGEST_PROOF_OK). Flip on the carrying release.


Both as expected — registration-int 10/10, digest-proof int skips cleanly on F-004. Committing the digest slice:


cd "C:/Users/decid/Documents/projects/spt-claude-code"
git add .gitignore adapter/claude-spt.toml ci/run-gates.sh docs/SPT-CORE-FINDINGS.md traceable-reqs.toml ci/digest tools
echo "=== staged (confirm Cargo.lock in, target out) ==="
git diff --cached --name-only | grep -E "tools/|Cargo|target"
git commit -q -F - <<'EOF'
feat(digest): claude-spt-digest extractor (Rust) — CC JSONL -> digest NDJSON

The [digest] extractor slice (ADR-0019, the M10 acceptance criterion carried
forward). Maps Claude Code's native JSONL transcript to the published digest-
record contract: NDJSON, one {role∈input|agent|tool, text?, tool?, ts?} per line.
Emits RAW records — spt-core's renderer applies window_turns/arg_truncation/
sprint_collapse.

- tools/claude-spt-digest (Rust, per todlando/legacy-spt consistency): user-str ->
  input; assistant text -> agent, tool_use -> tool{name,arg}; thinking/tool_result/
  noise/bad-json skipped. Per-session locate of <slug>/<session_id>.jsonl under the
  projects root (the cwd-slug is CC-internal, no published key — by design, doyle).
  Native UTF-8 stdout (Rust) — fixes a real cp1252 Windows-corruption bug the first
  Python cut had. Defensive leading-~ expansion.
- unit: 9 cargo tests (mapping, arg-key preference, all drop cases, UTF-8 round-trip,
  contract invariants, tilde). Gate: ci/digest/build.sh (cargo test + release build),
  wired into run-gates.sh; SKIPs without cargo.
- manifest [digest]: source {home} -> ~/.claude/projects ({home} is not a catalog
  key — doyle); extractor command keeps --session {session_id} (production-correct).
- int (ci/digest/digest-proof-int.sh): deferred behind F-004 — a doyle-confirmed
  spt-core impl bug (digest-proof --sample passes an empty key map, cli.rs:5135, vs
  runtime filling {id}+{session_id}). Skips on the exact substitution error; proven
  meanwhile via cargo tests + a source-only digest-proof run (DIGEST_PROOF_OK, 5/0,
  correct render). Flips green on doyle's carrying release.

REQ-DIST-DIGEST-EXTRACTOR -> [impl,unit]. F-004 logged. tools added to scan roots.

Co-authored by: perri
EOF
echo "rc=$?"; git log --oneline -1

Commit digest extractor slice


warning: in the working copy of '.gitignore', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'ci/digest/sample.jsonl', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'tools/claude-spt-digest/Cargo.lock', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'tools/claude-spt-digest/src/main.rs', LF will be replaced by CRLF the next time Git touches it
=== staged (confirm Cargo.lock in, target out) ===
tools/claude-spt-digest/Cargo.lock
tools/claude-spt-digest/Cargo.toml
tools/claude-spt-digest/src/main.rs
rc=0
39c05d1 feat(digest): claude-spt-digest extractor (Rust) — CC JSONL -> digest NDJSON


Committed `39c05d1` (Cargo.lock in, target ignored). Notifying doyle + raising the gating scope question for the remaining deferred skills.


$OWL send doyle <<'EOF'
[perri → doyle] DIGEST SLICE LANDED — 39c05d1 `feat(digest): claude-spt-digest extractor (Rust)`. Gate PASS, traceable green. Rust extractor (CC JSONL -> digest NDJSON, native UTF-8), 9 cargo tests, ci/digest/build.sh gate wired. source -> ~/.claude/projects (dropped {home}). digest-proof int skips on F-004 until your fix. REQ-DIST-DIGEST-EXTRACTOR -> [impl,unit].

NEXT — the gating SCOPE DECISION (plan item 3, needs your ruling). 4 skills remain, all on the live-agent/Psyche layer. Does claude-spt reach the owl/$LIVE Psyche layer at all, or are these owl-only (out of scope for the CC adapter v1)?
  • live      — maps to `spt endpoint run` (core, shipped). But invoked FROM INSIDE a CC session, what should /sptc:live do — bring up a SEPARATE broker-PTY session? Spawn-nested feels wrong. What's the intended claude-spt `live` semantics?
  • commune   — NO core-spt command (Psyche-layer, $LIVE). In scope for claude-spt or owl-only?
  • new-alarm — NO core-spt command (same).
  • force-stop— core has only soft `endpoint stop` + graceful `endpoint shutdown`; no force-kill/Psyche-teardown ($LIVE stop 3-step) equiv. Author against soft-stop, or is force-kill owl-only?

Your call sets whether these are authorable claude-spt skills, findings, or explicit out-of-scope. Meanwhile picking up the fully-unblocked large-drain truncate/spill (ADR-0002 Open #2). Ping when you've ruled.
EOF
echo "rc=$?"

Notify doyle digest + scope question


➜ SENT:doyle
rc=0


Updating the plan to reflect both landed slices:


Edit

   - **TODO** `[session.self]` + `spt endpoint run` bringup + `cc`/`sptc` launcher → unblocks the
     held **REQ-DIST-SHORTCUT-BASENAME** impl+int (the `<basename>-<id>` picker "lands in a later
     spt-core wave" per `endpoint run --help`; `endpoint run` spawns `[session.self]`, still deferred).
   - **TODO** digest extractor binary `claude-spt-digest` → unblocks `spt adapter digest-proof`
     (the deeper [digest] int; the seam is declared + registration-accepted, extractor unbuilt).

   - **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.


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)

   - **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.

# 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).
   - **TODO** `[session.self]` + `spt endpoint run` bringup + `cc`/`sptc` launcher → unblocks the
     held **REQ-DIST-SHORTCUT-BASENAME** impl+int (the `<basename>-<id>` picker "lands in a later
     spt-core wave" per `endpoint run --help`; `endpoint run` spawns `[session.self]`, still deferred).
   - **TODO** digest extractor binary `claude-spt-digest` → unblocks `spt adapter digest-proof`
     (the deeper [digest] int; the seam is declared + registration-accepted, extractor unbuilt).

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

-   - **TODO** `[session.self]` + `spt endpoint run` bringup + `cc`/`sptc` launcher → unblocks the

-     held **REQ-DIST-SHORTCUT-BASENAME** impl+int (the `<basename>-<id>` picker "lands in a later

-     spt-core wave" per `endpoint run --help`; `endpoint run` spawns `[session.self]`, still deferred).

-   - **TODO** digest extractor binary `claude-spt-digest` → unblocks `spt adapter digest-proof`

-     (the deeper [digest] int; the seam is declared + registration-accepted, extractor unbuilt).

+   - **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.


Committing the plan update, then starting the unblocked large-drain slice (reading ADR-0002 to scope it):


Bash

cd "C:/Users/decid/Documents/projects/spt-claude-code"
git add MANIFEST-PARITY-PLAN.md && git commit -q -m "docs(plan): mark session.self + digest slices done (d1f14ab, 39c05d1)

Co-authored by: perri" && git log --oneline -3

Commit plan update


ce44c91 docs(plan): mark session.self + digest slices done (d1f14ab, 39c05d1)
39c05d1 feat(digest): claude-spt-digest extractor (Rust) — CC JSONL -> digest NDJSON
d1f14ab feat(session): author [session.self] + bind path — spt-hosted cc bringup


cd "C:/Users/decid/Documents/projects/spt-claude-code"
ls docs/adr/
echo "=== ADR-0002 Open items / large-drain ==="
grep -n "Open\|10k\|10,000\|truncat\|spill\|additionalContext\|cap\b\|large" docs/adr/0002*.md | head -30

Find ADR-0002 + large-drain spec


0000-template.md
0001-distribution-splits-by-volatility.md
0002-hook-wiring-hand-written-hooks-json-shells-spt-api.md
=== ADR-0002 Open items / large-drain ===
14:payload on **stdin** and may return `additionalContext`. `spt api` primitives are observable on the
39:| UserPromptSubmit | `poll <id>` → stdout → `additionalContext` | the message-delivery + UPS-injection path (L149) |
51:The `UserPromptSubmit` hook's stdout becomes CC `additionalContext`. The same channel that delivers
73:  Monitor's ~500-char `EVENT_LINE_THRESHOLD`); the hook-drain injects via `additionalContext` which
74:  has no per-line cap. **No id+seq reassembly on the poll path.**
83:`<EVENT>`s regardless): CC `additionalContext` caps at **10,000 chars** (larger output is spilled to
84:a file by CC). A large multi-message drain can exceed it → adapter-side follow-up: truncate with a
85:marker or spill. (`REQ-UPS-INJECTION` `int` item.)
91:settled during impl (see Open).
119:  `$CLAUDE_PROJECT_DIR` resolved, exit-0 stdout honored. Resolves Open#2 for the POSIX-wrapper
127:## Open / resolved `int`
129:1. **`api poll` → `additionalContext` round-trip — RESOLVED (v0.7.1, 2026-06-15).** ADR-0020 shipped
135:2. **Large-drain injection size** — CC `additionalContext` caps at ~10k chars; add truncate/spill in
136:   the UPS wrapper (adapter-side; still open, lower priority — normal drains are well under the cap).
141:  `impl` activates when the validated `hooks.json` + wrappers land (gated on Open #1).
143:  UPS-injection open item (UPS-fires empirical) is carried here as Open #2.


Open #2 scoped: CC `additionalContext` caps ~10k chars (CC spills overflow to a file → agent misses inline messages). Adapter should truncate-with-marker proactively. Reading the UPS hook:


1	#!/bin/sh
2	# UserPromptSubmit: two jobs on the same hook (ADR-0002 — UPS fires on a `/sptc:X` slash-command
3	# with the token intact, validated 2026-06-15). Both write to stdout = CC additionalContext:
4	#   1. SKILL-INJECTION — detect `/sptc:<skill>` in the prompt and inject that skill's operative
5	#      instructions from the adapter `[strings.skills].<skill>` (the thin SKILL.md stays a stub).
6	#   2. MESSAGE-DRAIN — `api poll` the perch and surface delivered <EVENT> messages.
7	# [impl->REQ-UPS-INJECTION]
8	. "$CLAUDE_PLUGIN_ROOT/hooks/_common.sh"
9
10	input=$(cat)
11	sid=$(json_str "$input" session_id)
12
13	# 1. Skill-injection runs BEFORE the perch check — skills like /sptc:whoami and /sptc:setup are
14	#    valid without a readied perch (setup even runs before spt exists). No-op if not a sptc command.
15	prompt=$(json_str "$input" prompt)
16	sptc_inject_skill "$(sptc_skill_key "$prompt")"
17
18	# 2. Message-drain needs a perch. No perch (session not readied) -> nothing to deliver.
19	id=$(sptc_self_id "$sid")
20	[ -z "$id" ] && exit 0
21
22	SPT=$(spt_bin)
23	frames=$("$SPT" api --adapter "$ADAPTER" poll "$id" --session-id "$sid" 2>/dev/null)
24	[ -z "$frames" ] && exit 0
25
26	# Format for CC, preserving the sender (reply-correlation: ADR-0009/0012). render_frames parses
27	# the self-delimiting <EVENT> envelope (ADR-0020) — multi-message drains split on </EVENT>.
28	render_frames "$frames"
29	exit 0
30

#!/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)

# 1. Skill-injection runs BEFORE the perch check — skills like /sptc:whoami 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)
sptc_inject_skill "$(sptc_skill_key "$prompt")"

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

SPT=$(spt_bin)
frames=$("$SPT" api --adapter "$ADAPTER" poll "$id" --session-id "$sid" 2>/dev/null)
[ -z "$frames" ] && 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>.
render_frames "$frames"
exit 0


Open #2 clear: pre-empt CC's ~10k spill (which drops messages from inline context) by capping adapter-side. Design: under cap → emit as-is; over → spill full drain to a file + emit a concise pointer marker (no mid-`<EVENT>` split). Adding the helper to `_common.sh`:


Edit

ADAPTER=claude-spt

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


<owl_messages>
<owl_message from="doyle" priority="high">[doyle → perri] ACK — 39c05d1 logged (digest extractor landed, gate PASS, traceable green; Rust + native UTF-8, source ~/.claude/projects). Good slice. F-004 fix is in my worktree now, building/testing — will ping with the carrying release so you flip the digest-proof int.

On the 4-skill SCOPE ruling: holding a snap answer — this one I ground against CONTEXT (the Psyche layer + the adapter boundary), not memory, because it decides what claude-spt v1 even contains. ONE thing already firm: `commune` is NOT "no core command" — spt-core's contract is that commune/signoff are FILE-DROPS, not api verbs (published api.md: an agent writes `<endpoint_id>-commune.md` into the manifest's watched dir; spt-core's watcher ingests it — "there is deliberately no api commune"). So commune is authorable against spt-core as a file-drop. I'll rule live/new-alarm/force-stop the same way (in-scope-as-X / finding / explicit-out-of-scope) once I've checked each against the core primitives. Ruling inbound shortly. Proceed on the large-drain truncate/spill meanwhile.</owl_message>
</owl_messages>

[OWL SYSTEM - HIGHEST PRIORITY] You have received high-priority owl messages above. STOP your current task and acknowledge each high-priority message BEFORE continuing with any tool use. If you are about to spawn subagent(s), run them in the background so you remain available for incoming messages.


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

ADAPTER=claude-spt

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

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

ADAPTER=claude-spt

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

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

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

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

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

# Render an `api poll` drain ($1) for CC. Canonical format (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() {
  _in="$1"
  [ -z "$_in" ] && return 0
  # Normalise to one <EVENT>…</EVENT> per line (body newlines are
-escaped, so each envelope
  # is single-line), then parse each.
  printf '%s' "$_in" | sed 's#</EVENT>#</EVENT>\
#g' | while IFS= read -r _ev; do
    case "$_ev" in
      *"<EVENT"*"</EVENT>"*) ;;
      *) continue ;;
    esac
    _sender=$(printf '%s' "$_ev" | sed -n 's/.*<EVENT[^>]* from="\([^"]*\)".*/\1/p')
    _raw=$(printf '%s' "$_ev" | sed -n 's#.*<EVENT[^>]*>\(.*\)</EVENT>.*#\1#p')
    _body=$(sptc_unescape "$_raw")
    if [ -n "$_sender" ]; then
      printf '<sptc_messages from="%s">\n%s\n</sptc_messages>\n' "$_sender" "$_body"
    else
      printf '<sptc_messages>\n%s\n</sptc_messages>\n' "$_body"
    fi
  done
}

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

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

+# CC spills additionalContext over ~10,000 chars to a file (dropping it from the inline context the

+# agent sees). We pre-empt below this with margin for the marker + CC's own framing. Override-able


doyle: F-004 fix in his worktree (will ping release); scope ruling inbound. Key correction noted: `commune` IS authorable — it's a file-drop (`<endpoint_id>-commune.md` → watched dir, ties to the `[session].commune_dir`/`signoff_dir` schema keys I saw). Continuing large-drain. Adding the cap helper:


  _body=$("$_spt" adapter get-string "$ADAPTER" "skills.$_skill" 2>/dev/null) || return 0
  [ -z "$_body" ] && return 0
  printf '<sptc_skill name="%s">\n%s\n</sptc_skill>\n' "$_skill" "$_body"
}

  _body=$("$_spt" adapter get-string "$ADAPTER" "skills.$_skill" 2>/dev/null) || return 0
  [ -z "$_body" ] && return 0
  printf '<sptc_skill name="%s">\n%s\n</sptc_skill>\n' "$_skill" "$_body"
}

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


  _body=$("$_spt" adapter get-string "$ADAPTER" "skills.$_skill" 2>/dev/null) || return 0
  [ -z "$_body" ] && return 0
  printf '<sptc_skill name="%s">\n%s\n</sptc_skill>\n' "$_skill" "$_body"
}

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

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

ADAPTER=claude-spt

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

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

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

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

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

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

# Render an `api poll` drain ($1) for CC. Canonical format (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() {
  _in="$1"
  [ -z "$_in" ] && return 0
  # Normalise to one <EVENT>…</EVENT> per line (body newlines are
-escaped, so each envelope
  # is single-line), then parse each.
  printf '%s' "$_in" | sed 's#</EVENT>#</EVENT>\
#g' | while IFS= read -r _ev; do
    case "$_ev" in
      *"<EVENT"*"</EVENT>"*) ;;
      *) continue ;;
    esac
    _sender=$(printf '%s' "$_ev" | sed -n 's/.*<EVENT[^>]* from="\([^"]*\)".*/\1/p')
    _raw=$(printf '%s' "$_ev" | sed -n 's#.*<EVENT[^>]*>\(.*\)</EVENT>.*#\1#p')
    _body=$(sptc_unescape "$_raw")
    if [ -n "$_sender" ]; then
      printf '<sptc_messages from="%s">\n%s\n</sptc_messages>\n' "$_sender" "$_body"
    else
      printf '<sptc_messages>\n%s\n</sptc_messages>\n' "$_body"
    fi
  done
}

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

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

+# Emit additionalContext under CC's ~10k spill threshold ($1=text, $2=cap, $3=spill_path). Under the

+# cap -> emit as-is. Over -> spill the FULL text to a file the agent can Read and emit ONLY a concise

+# pointer marker (never a partial inline — a head-cut would split a <sptc_messages>/<EVENT> block and

+# lose a message silently). The agent reads the spill file to see everything; nothing is dropped. CC

+# would otherwise spill oversized additionalContext itself, evicting it from the inline context.

+  _len=$(printf '%s' "$_text" | wc -c | tr -d ' ')

+  printf '%s' "$_text" > "$_spill" 2>/dev/null || _spill="(spill failed; content too large to inline)"

+  printf '<sptc_overflow bytes="%s" cap="%s" spilled_to="%s">\nDelivery exceeded CC'\''s additionalContext cap. The full content (skill instructions and/or %s message bytes) was written to the file above — read it now to see everything; it is NOT inlined here to avoid silently dropping a message.\n</sptc_overflow>\n' \


Now restructure the UPS hook to buffer both outputs and cap-emit:


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

# 1. Skill-injection runs BEFORE the perch check — skills like /sptc:whoami 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)
sptc_inject_skill "$(sptc_skill_key "$prompt")"

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

SPT=$(spt_bin)
frames=$("$SPT" api --adapter "$ADAPTER" poll "$id" --session-id "$sid" 2>/dev/null)
[ -z "$frames" ] && 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>.
render_frames "$frames"
exit 0

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:whoami 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


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)

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:whoami 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

#!/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)

# 1. Skill-injection runs BEFORE the perch check — skills like /sptc:whoami 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)
sptc_inject_skill "$(sptc_skill_key "$prompt")"

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

SPT=$(spt_bin)
frames=$("$SPT" api --adapter "$ADAPTER" poll "$id" --session-id "$sid" 2>/dev/null)
[ -z "$frames" ] && 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>.
render_frames "$frames"
exit 0

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

-# 2. Message-drain needs a perch. No perch (session not readied) -> nothing to deliver.

+# 2. Message-drain needs a perch. No perch (session not readied) -> nothing more to deliver.

+  frames=$("$SPT" api --adapter "$ADAPTER" poll "$id" --session-id "$sid" 2>/dev/null)

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

-frames=$("$SPT" api --adapter "$ADAPTER" poll "$id" --session-id "$sid" 2>/dev/null)

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

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

+sptc_cap_output "$out" "$SPTC_CTX_CAP" "$spill"


Adding unit tests for the cap helper:


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

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

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

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


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

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

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

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

#!/bin/sh
# Unit tests for the sptc hook parsing glue (pure functions; no spt/CC needed).
# Run: sh tests/hooks-parse.sh   (exit 0 = pass)
. "$(dirname "$0")/../plugin/sptc/hooks/_common.sh"

fail=0
check() { # check  <expected> <actual>
  if [ "$2" = "$3" ]; then printf 'ok   %s\n' "$1"
  else printf 'FAIL %s\n  expected: [%s]\n  actual:   [%s]\n' "$1" "$2" "$3"; fail=1; fi
}

# --- json_str: top-level CC hook-input fields ---  [unit->REQ-DIST-HOOKS-API]
J='{"session_id":"sess-abc123","transcript_path":"/x","source":"startup","model":"m"}'
check "json_str session_id" "sess-abc123" "$(json_str "$J" session_id)"
check "json_str source"     "startup"     "$(json_str "$J" source)"
check "json_str prompt"      "/sptc:send doyle" \
  "$(json_str '{"session_id":"s1","prompt":"/sptc:send doyle"}' prompt)"
check "json_str missing key" "" "$(json_str "$J" nope)"

# --- sptc_unescape:
 -> newline, entities, & LAST --- [unit->REQ-UPS-INJECTION]
check "unescape br+entities" \
  "$(printf 'a \n"c" & <')" \
  "$(sptc_unescape 'a <b>
"c" & &lt;')"

# --- render_frames: canonical <EVENT> envelope (ADR-0020) --- [unit->REQ-UPS-INJECTION]
# Named single,
-escaped multiline body, sender preserved.
named=$(render_frames '<EVENT type="msg" from="doyle">hello
world</EVENT>')
check "render named <EVENT>" \
  "$(printf '<sptc_messages from="doyle">\nhello\nworld\n</sptc_messages>')" "$named"

# Entity-escaped body decodes.
ent=$(render_frames '<EVENT type="msg" from="kit">a <tag> & b</EVENT>')
check "render entity body" \
  "$(printf '<sptc_messages from="kit">\na <tag> & b\n</sptc_messages>')" "$ent"

# Multi-message drain: two self-delimiting envelopes -> two rendered blocks.
multi=$(render_frames '<EVENT type="msg" from="a">one</EVENT><EVENT type="msg" from="b">two</EVENT>')
check "render multi-message" \
  "$(printf '<sptc_messages from="a">\none\n</sptc_messages>\n<sptc_messages from="b">\ntwo\n</sptc_messages>')" \
  "$multi"

# Envelope without a from attr -> anonymous rendering.
anon=$(render_frames '<EVENT type="msg">sys note</EVENT>')
check "render no-from" "$(printf '<sptc_messages>\nsys note\n</sptc_messages>')" "$anon"

# Empty drain -> no output.
check "render empty" "" "$(render_frames '')"

# --- sptc_skill_key: detect /sptc:<skill> in a slash-command prompt --- [unit->REQ-UPS-INJECTION]
check "skill_key bare"        "whoami" "$(sptc_skill_key '/sptc:whoami')"
check "skill_key with args"   "send"   "$(sptc_skill_key '/sptc:send doyle hi')"
check "skill_key leading ws"  "setup"  "$(sptc_skill_key '   /sptc:setup')"
check "skill_key hyphenated"  "list-agents" "$(sptc_skill_key '/sptc:list-agents')"
check "skill_key mid-prose"   ""       "$(sptc_skill_key 'please run /sptc:whoami for me')"
check "skill_key other ns"    ""       "$(sptc_skill_key '/other:whoami')"
check "skill_key not a cmd"   ""       "$(sptc_skill_key 'whoami am i')"
check "skill_key empty"       ""       "$(sptc_skill_key '')"

# --- sptc_register_verb: SessionStart bind/seed/boundary branch --- [unit->REQ-DIST-SHORTCUT-BASENAME]
# spt-hosted (endpoint id injected) -> bind; harness-hosted (no id) -> seed; clear/compact -> boundary.
unset SPT_ENDPOINT_ID
check "verb harness-hosted startup"   "seed"     "$(sptc_register_verb startup)"
check "verb harness-hosted empty src" "seed"     "$(sptc_register_verb '')"
check "verb boundary clear"           "boundary" "$(sptc_register_verb clear)"
check "verb boundary compact"         "boundary" "$(sptc_register_verb compact)"
SPT_ENDPOINT_ID=cc-7; export SPT_ENDPOINT_ID
check "verb spt-hosted startup -> bind" "bind"     "$(sptc_register_verb startup)"
# A /clear inside an spt-hosted session still rebinds via boundary (never re-binds fresh).
check "verb spt-hosted clear -> boundary" "boundary" "$(sptc_register_verb clear)"
unset SPT_ENDPOINT_ID

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

+# --- sptc_cap_output: additionalContext spill guard (ADR-0002 Open #2) --- [unit->REQ-UPS-INJECTION]

+check "cap under -> passthrough verbatim" "small body" "$(sptc_cap_output 'small body' 9000 "$spill")"

+check "cap under -> no spill written"     "absent"     "$([ -f "$spill" ] && echo present || echo absent)"

+over=$(sptc_cap_output 'abcdefghij' 5 "$spill")   # 10 bytes > cap 5

+case "$over" in *'<sptc_overflow'*) m=ok ;; *) m=no ;; esac

+check "cap over -> emits overflow marker"  "ok"         "$m"

+check "cap over -> full body spilled"      "abcdefghij" "$(cat "$spill" 2>/dev/null)"

+case "$over" in *abcdefghij*) leak=leaked ;; *) leak=clean ;; esac

+check "cap over -> body not inlined"       "clean"      "$leak"

+check "cap empty -> no output"             ""           "$(sptc_cap_output '' 5 "$spill")"


Bash

cd "C:/Users/decid/Documents/projects/spt-claude-code" && sh tests/hooks-parse.sh 2>&1 | tail -10

Run hooks-parse with cap tests


ok   verb boundary compact
ok   verb spt-hosted startup -> bind
ok   verb spt-hosted clear -> boundary
ok   cap under -> passthrough verbatim
ok   cap under -> no spill written
ok   cap over -> emits overflow marker
ok   cap over -> full body spilled
ok   cap over -> body not inlined
ok   cap empty -> no output
ALL PASS


All cap tests pass; under-cap path is verbatim passthrough (no regression to skill-injection/drain). Running the full gate:


cd "C:/Users/decid/Documents/projects/spt-claude-code" && sh ci/run-gates.sh 2>&1 | grep -E "GATE:|FAIL|SKIP|coverage|RESULT|cargo|hooks-parse"

Full gate condensed


=== GATE: shell-syntax (sh -n) ===
ok  tests/hooks-parse.sh
=== GATE: unit tests (tests/*.sh) ===
ok  tests/hooks-parse.sh
=== GATE: digest-extractor (cargo build + test) ===
ok  claude-spt-digest: cargo test + release build
=== GATE: traceable-reqs check (requirement coverage) ===
ok  coverage green
=== GATE: skeleton-validate (cplugs installability) ===
=== GATE: manifest-schema ===
=== GATE: docs-drift ===
=== RESULT: PASS ===


Gate PASS. Updating ADR-0002 Open #2 to resolved:


127	## Open / resolved `int`
128
129	1. **`api poll` → `additionalContext` round-trip — RESOLVED (v0.7.1, 2026-06-15).** ADR-0020 shipped
130	   in v0.7.1; a throwaway byte-capture against the live `spt api poll` drain (`od`-verified) confirmed
131	   the canonical `<EVENT type="msg" from=…>body</EVENT>\n` envelope (no `__REPLY_TO__`, no
132	   `<EVENT-PART>` on normal drains, multi-drain splits on `</EVENT>`), and `render_frames` confirm-
133	   matched it. Locked by `ci/hooks/poll-int.sh` (5/5); `REQ-DIST-HOOKS-API` + `REQ-UPS-INJECTION`
134	   `int` flipped green. See `docs/SPT-CORE-FINDINGS.md` F-002.
135	2. **Large-drain injection size** — CC `additionalContext` caps at ~10k chars; add truncate/spill in
136	   the UPS wrapper (adapter-side; still open, lower priority — normal drains are well under the cap).
137
138	## Consequences
139
140	- `REQ-DIST-HOOKS-API` / `REQ-UPS-INJECTION` gain a concrete, grounded `doc` design here; their
141	  `impl` activates when the validated `hooks.json` + wrappers land (gated on Open #1).
142	- Supersedes the `ADR-0001` "hooks.json delegates to `spt api`" stub with the full wiring; ADR-0001's
143	  UPS-injection open item (UPS-fires empirical) is carried here as Open #2.
144	- No spt-core dependency for the wiring itself — only the (non-blocking) residual docs publish of the

## 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** — CC `additionalContext` caps at ~10k chars; add truncate/spill in
   the UPS wrapper (adapter-side; still open, lower priority — normal drains are well under the cap).

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


Edit

2. **Large-drain injection size** — CC `additionalContext` caps at ~10k chars; add truncate/spill in
   the UPS wrapper (adapter-side; still open, lower priority — normal drains are well under the cap).

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