64-required_stages = ["doc", "impl", "unit"]  # doc: ADR-0001 + RELEASE-RUNBOOK. impl: ci/publish/{validate,package}-skeleton.sh (installability gate + publish mechanic). unit: tests/skeleton-validate.sh (pass real + fail tampered).
65-
66-[[requirements]]
67-id = "REQ-DIST-BOOTSTRAP-INSTALL"
68-title = "SessionStart bootstrap installs spt-core when absent (invisible-installer pattern)"
69-required_stages = ["doc", "impl"]  # doc: ADR-0001. impl: plugin/sptc/bootstrap.{sh,ps1}.
70-
71-[[requirements]]
72:id = "REQ-DIST-HOOKS-API"
73-title = "hooks.json delegates to `spt api` rather than carrying adapter logic"
74-required_stages = ["doc", "impl", "unit", "int"]  # doc: ADR-0001/0002. impl: hooks.json + wrappers. unit: tests/hooks-parse.sh. int: ci/hooks/poll-int.sh — `spt api poll` on live v0.7.1 emits the canonical <EVENT> envelope, confirm-matched through render_frames; __REPLY_TO__ relic gone, multi-drain splits on </EVENT>. 5/5 green 2026-06-15 (throwaway perch, torn down).
75-
76-[[requirements]]
77-id = "REQ-DIST-MANIFEST-SCHEMA"
78-title = "CC adapter manifest validates against spt-core's published manifest.schema.json (from spt-releases)"
79-[Omitted long context line]
80-
81-[[requirements]]
82:id = "REQ-DIST-SHORTCUT-BASENAME"
83-title = "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)"
84-[Omitted long context line]
85-
86-[[requirements]]
87-id = "REQ-CC-LAUNCHER-BIND"
88-title = "spt-hosted bringup self-binds E2E: `spt endpoint run claude-spt <id>` spawns the [session.self] CC session which — via [env.SPT_ENDPOINT_ID]={id} (spt-core fills it, v0.11.0+ REQ-HAZARD-ENV-SUBST/F-013) + the SessionStart `bind` branch — registers a BOUND perch on disk reachable by `spt send` (live PTY inject, SENT). The M12 cc-launcher reachability, the gap that hid the wall-b zero-perch."
89:[Omitted long matching line]
90-
91-[[requirements]]
92-id = "REQ-DIST-DIGEST-EXTRACTOR"
93-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"
94-[Omitted long context line]
95-
96-[[requirements]]
97-id = "REQ-SKILL-LIVE"
98-[Omitted long context line]
99-[Omitted long context line]
100-
101-[[requirements]]
102:id = "REQ-UPS-INJECTION"
103-title = "UserPromptSubmit hook detects \sptc:X and injects X's real instructions as additionalContext (SKILL.md files stay skeletons); must be empirically confirmed UPS fires on slash-commands"
104-required_stages = ["doc", "impl", "unit", "int"]  # doc: ADR-0002. impl: user-prompt-submit.sh (skill-injection + drain) + _common.sh. unit: render_frames + sptc_skill_key tests. int: ci/hooks/poll-int.sh — the poll->additionalContext drain round-trip vs live v0.7.1 (<EVENT> envelope, render_frames confirm-match, multi-message). UPS-fires-on-slash empirical = ADR-0002 validation (CC 2.1.177); skill-injection resolve = registration-int 4c.
105-
106-# ── Milestone: CI (autonomous fleet) — minted on-start. Deterministic gates + git push-trigger +
107-# spt reporting bus. Seeded from SCOPE.md "CI model" + docs/CI.md. Real-claude acceptance is a
108-# later slice (doc-only now). All harness-agnostic shell; runs on the Win+Linux fleet.
109-
110-[[requirements]]


SessionStart identity injection for bind topology


Got the format + activation model (add inactive `[]`, flip stages when evidence lands — keeps `check` green). Registering the REQ and writing the JIT plan.


Edit

# ── Milestone: CI (autonomous fleet) — minted on-start. Deterministic gates + git push-trigger +

[[requirements]]
id = "REQ-DIST-SESSIONSTART-BRIEF"
title = "SessionStart injects agent-facing briefs as additionalContext: an identity brief (who + perch-live/don't-re-arm + send/reply/roster) for perched sessions (bind+boundary), and a peer-gated ring brief (spt ring + roster) for no-perch seed sessions; all prose is adapter-string-backed ([strings.briefs]), the hook only composes + {id}-substitutes, never authors prose; subagent (agent_type) sessions are skipped"
required_stages = []  # ACTIVATE to ["doc","impl","unit","int"] when SESSIONSTART-BRIEF-PLAN.md lands evidence. doc: docs-site harness-contract + SESSIONSTART-BRIEF-PLAN.md. impl: session-start.sh emit + _common.sh compose/encode + adapter/strings/briefs/*.md + [strings.briefs] in claude-spt.toml. unit: tests for brief composition + {id} subst + peer-gate line-count + agent_type skip. int: SessionStart bind/boundary emits the resolved brief vs a live perch. NOTE: live-vs-ready flavor DEFERRED (public-surface liveness-query gap, logged w/ doyle, post-v0.13.0 spt whoami --json).

# ── Milestone: CI (autonomous fleet) — minted on-start. Deterministic gates + git push-trigger +


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

[[requirements]]
id = "REQ-DIST-SESSIONSTART-BRIEF"
title = "SessionStart injects agent-facing briefs as additionalContext: an identity brief (who + perch-live/don't-re-arm + send/reply/roster) for perched sessions (bind+boundary), and a peer-gated ring brief (spt ring + roster) for no-perch seed sessions; all prose is adapter-string-backed ([strings.briefs]), the hook only composes + {id}-substitutes, never authors prose; subagent (agent_type) sessions are skipped"
required_stages = []  # ACTIVATE to ["doc","impl","unit","int"] when SESSIONSTART-BRIEF-PLAN.md lands evidence. doc: docs-site harness-contract + SESSIONSTART-BRIEF-PLAN.md. impl: session-start.sh emit + _common.sh compose/encode + adapter/strings/briefs/*.md + [strings.briefs] in claude-spt.toml. unit: tests for brief composition + {id} subst + peer-gate line-count + agent_type skip. int: SessionStart bind/boundary emits the resolved brief vs a live perch. NOTE: live-vs-ready flavor DEFERRED (public-surface liveness-query gap, logged w/ doyle, post-v0.13.0 spt whoami --json).

# ── Milestone: CI (autonomous fleet) — minted on-start. Deterministic gates + git push-trigger +

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.

+title = "SessionStart injects agent-facing briefs as additionalContext: an identity brief (who + perch-live/don't-re-arm + send/reply/roster) for perched sessions (bind+boundary), and a peer-gated ring brief (spt ring + roster) for no-perch seed sessions; all prose is adapter-string-backed ([strings.briefs]), the hook only composes + {id}-substitutes, never authors prose; subagent (agent_type) sessions are skipped"

+required_stages = []  # ACTIVATE to ["doc","impl","unit","int"] when SESSIONSTART-BRIEF-PLAN.md lands evidence. doc: docs-site harness-contract + SESSIONSTART-BRIEF-PLAN.md. impl: session-start.sh emit + _common.sh compose/encode + adapter/strings/briefs/*.md + [strings.briefs] in claude-spt.toml. unit: tests for brief composition + {id} subst + peer-gate line-count + agent_type skip. int: SessionStart bind/boundary emits the resolved brief vs a live perch. NOTE: live-vs-ready flavor DEFERRED (public-surface liveness-query gap, logged w/ doyle, post-v0.13.0 spt whoami --json).

# spt reporting bus. Seeded from SCOPE.md "CI model" + docs/CI.md. Real-claude acceptance is a

# later slice (doc-only now). All harness-agnostic shell; runs on the Win+Linux fleet.


REQ registered (inactive `[]`, flips when evidence lands). Now the JIT plan.


Write

# SESSIONSTART-BRIEF-PLAN — agent-facing SessionStart relay

> JIT plan. Closes the parity gap: legacy owl injects identity at SessionStart
> (`<owl-active-perch>` / `<spacetime-reorientation>`); the sptc adapter injects
> nothing. Grilled 2026-06-19 (perri ↔ operator). REQ: `REQ-DIST-SESSIONSTART-BRIEF`.

## Scope

`session-start.sh` (today: silent `exit 0`) emits agent-facing briefs as
`hookSpecificOutput.additionalContext`, by session state:

| state | trigger | brief |
|---|---|---|
| has perch | `bind` (`$SPT_ENDPOINT_ID`) + `boundary` (clear/compact) | **identity**: who (`{id}`) + perch-live/don't-re-arm + send + reply + roster |
| no perch | `seed`/fresh `startup`, **peer-gated** | **ring**: `spt ring … --timeout 60` + roster |
| seed w/o peers · subagent (`agent_type` set) | — | nothing |

Not in scope: live-vs-ready flavoring (deferred — public liveness-query gap, logged
with doyle, post-v0.13.0 `spt whoami --json`); boundary psyche resume (separate path:
active `echo_commune`, daemon-fired).

## Locked decisions (grill ledger)

1. **Scope = bind + boundary for identity; seed for ring.** Seed needs no identity
   (the agent ran the CLI → already knows its id). No-perch ring brief is net-new
   (no legacy precedent).
2. **Altitude = lean.** Identity preamble + curated messaging only. No full command
   card. Perched agents get send + reply (NOT ring — they have an id). No-perch gets
   ring (the no-id reach path, per send SKILL.md gating).
3. **Discriminator = id-present vs id-absent**, not live-vs-ready. Ready agents get
   the identity brief (send/reply), not ring.
4. **Roster line** (`spt endpoint list`) in **both** blocks.
5. **Sourcing = adapter strings, unified.** All prose → `[strings.briefs]` (mirrors
   `[strings.skills]`); hook composes + `{id}`-substitutes, never authors prose.
   One-liners inline in the manifest; multi-line → `adapter/strings/briefs/*.md`.
   Composable (no duplication):
   - `briefs/identity.md` (file, `{id}`) — who + perch-live + don't-re-arm
   - `briefs/messaging-perch.md` (file) — send + reply
   - `briefs/messaging-no-perch.md` (file) — ring
   - `briefs.endpoint-list` (inline one-liner) — shared roster line
6. **id resolution** (bind & boundary): `$SPT_ENDPOINT_ID` first, then `sptc_self_id`
   fallback.
7. **Peer gate** (ring brief): line-count presence on `spt subnet status` (>1 line ⇒
   peers ⇒ emit). No column-value parsing (doyle: don't parse columnar stdout).
8. **Liveness flavor dropped** — single liveness-agnostic identity brief. No Psyche
   line. Finding logged with doyle.

## Tasks

- [ ] `adapter/claude-spt.toml`: add `[strings.briefs]` (file pointers + inline
      `endpoint-list`); register on `adapter add`/`update`.
- [ ] `adapter/strings/briefs/{identity,messaging-perch,messaging-no-perch}.md`.
- [ ] `_common.sh`: `sptc_compose_brief` (select by state, resolve strings via
      `spt adapter get-string`, `{id}` sed-subst, concat) + a JSON-string encoder
      (`"` `\` newline→`\n`) for additionalContext + `sptc_node_has_peers`
      (line-count gate).
- [ ] `session-start.sh`: per verb — `bind`/`boundary` → identity (id resolved,
      `$SPT_ENDPOINT_ID` then whoami); `seed`+`startup`+peers → ring. Skip
      `agent_type`. Emit `hookSpecificOutput` JSON. Keep registration side-effects.
- [ ] `docs-site` harness-contract: document the SessionStart brief surface.
- [ ] Tests: brief composition, `{id}` subst, peer-gate line-count, `agent_type`
      skip, JSON-encode round-trip; int — bind/boundary emits resolved brief vs a
      live perch.
- [ ] Tag evidence `[doc|impl|unit|int->REQ-DIST-SESSIONSTART-BRIEF]`; **activate**
      the REQ (`required_stages`) as stages land.

## Gate

Build green · `traceable-reqs check` green (REQ activated + all stages tagged) ·
hooks-parse + new unit tests pass · no hardcoded agent-facing prose in `_common.sh`
(overflow-fallback excepted).

## Future-nicety (non-blocking)

- When doyle ships `spt whoami --json {id,kind,...}`: add live/ready identity flavor
  (Psyche/commune line for live).
- Cleaner peer signal (`spt subnet status --json`/count) would retire the line-count
  gate — fold into the same doyle ask if cheap.


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

# SESSIONSTART-BRIEF-PLAN — agent-facing SessionStart relay

> JIT plan. Closes the parity gap: legacy owl injects identity at SessionStart
> (`<owl-active-perch>` / `<spacetime-reorientation>`); the sptc adapter injects
> nothing. Grilled 2026-06-19 (perri ↔ operator). REQ: `REQ-DIST-SESSIONSTART-BRIEF`.

## Scope

`session-start.sh` (today: silent `exit 0`) emits agent-facing briefs as
`hookSpecificOutput.additionalContext`, by session state:

| state | trigger | brief |
|---|---|---|
| has perch | `bind` (`$SPT_ENDPOINT_ID`) + `boundary` (clear/compact) | **identity**: who (`{id}`) + perch-live/don't-re-arm + send + reply + roster |
| no perch | `seed`/fresh `startup`, **peer-gated** | **ring**: `spt ring … --timeout 60` + roster |
| seed w/o peers · subagent (`agent_type` set) | — | nothing |

Not in scope: live-vs-ready flavoring (deferred — public liveness-query gap, logged
with doyle, post-v0.13.0 `spt whoami --json`); boundary psyche resume (separate path:
active `echo_commune`, daemon-fired).

## Locked decisions (grill ledger)

1. **Scope = bind + boundary for identity; seed for ring.** Seed needs no identity
   (the agent ran the CLI → already knows its id). No-perch ring brief is net-new
   (no legacy precedent).
2. **Altitude = lean.** Identity preamble + curated messaging only. No full command
   card. Perched agents get send + reply (NOT ring — they have an id). No-perch gets
   ring (the no-id reach path, per send SKILL.md gating).
3. **Discriminator = id-present vs id-absent**, not live-vs-ready. Ready agents get
   the identity brief (send/reply), not ring.
4. **Roster line** (`spt endpoint list`) in **both** blocks.
5. **Sourcing = adapter strings, unified.** All prose → `[strings.briefs]` (mirrors
   `[strings.skills]`); hook composes + `{id}`-substitutes, never authors prose.
   One-liners inline in the manifest; multi-line → `adapter/strings/briefs/*.md`.
   Composable (no duplication):
   - `briefs/identity.md` (file, `{id}`) — who + perch-live + don't-re-arm
   - `briefs/messaging-perch.md` (file) — send + reply
   - `briefs/messaging-no-perch.md` (file) — ring
   - `briefs.endpoint-list` (inline one-liner) — shared roster line
6. **id resolution** (bind & boundary): `$SPT_ENDPOINT_ID` first, then `sptc_self_id`
   fallback.
7. **Peer gate** (ring brief): line-count presence on `spt subnet status` (>1 line ⇒
   peers ⇒ emit). No column-value parsing (doyle: don't parse columnar stdout).
8. **Liveness flavor dropped** — single liveness-agnostic identity brief. No Psyche
   line. Finding logged with doyle.

## Tasks

- [ ] `adapter/claude-spt.toml`: add `[strings.briefs]` (file pointers + inline
      `endpoint-list`); register on `adapter add`/`update`.
- [ ] `adapter/strings/briefs/{identity,messaging-perch,messaging-no-perch}.md`.
- [ ] `_common.sh`: `sptc_compose_brief` (select by state, resolve strings via
      `spt adapter get-string`, `{id}` sed-subst, concat) + a JSON-string encoder
      (`"` `\` newline→`\n`) for additionalContext + `sptc_node_has_peers`
      (line-count gate).
- [ ] `session-start.sh`: per verb — `bind`/`boundary` → identity (id resolved,
      `$SPT_ENDPOINT_ID` then whoami); `seed`+`startup`+peers → ring. Skip
      `agent_type`. Emit `hookSpecificOutput` JSON. Keep registration side-effects.
- [ ] `docs-site` harness-contract: document the SessionStart brief surface.
- [ ] Tests: brief composition, `{id}` subst, peer-gate line-count, `agent_type`
      skip, JSON-encode round-trip; int — bind/boundary emits resolved brief vs a
      live perch.
- [ ] Tag evidence `[doc|impl|unit|int->REQ-DIST-SESSIONSTART-BRIEF]`; **activate**
      the REQ (`required_stages`) as stages land.

## Gate

Build green · `traceable-reqs check` green (REQ activated + all stages tagged) ·
hooks-parse + new unit tests pass · no hardcoded agent-facing prose in `_common.sh`
(overflow-fallback excepted).

## Future-nicety (non-blocking)

- When doyle ships `spt whoami --json {id,kind,...}`: add live/ready identity flavor
  (Psyche/commune line for live).
- Cleaner peer signal (`spt subnet status --json`/count) would retire the line-count
  gate — fold into the same doyle ask if cheap.


Plan written, REQ registered, CONTEXT.md updated. Verifying `traceable-reqs check` stays green (REQ inactive, but confirm the toml edit didn't break parse).


Bash

(./traceable-reqs check 2>&1 || traceable-reqs check 2>&1) | tail -25

Run traceable-reqs check


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


`traceable-reqs check` green — all `[OK]`, toml parsed clean (new REQ enumerated, inactive).

## Grill complete — shared understanding reached

**Landed this session:**
- **CONTEXT.md** — glossary terms `identity brief` + `ring brief`.
- **traceable-reqs.toml** — `REQ-DIST-SESSIONSTART-BRIEF` registered (inactive `[]`; flips as evidence lands).
- **SESSIONSTART-BRIEF-PLAN.md** — JIT plan: scope table, 8 locked decisions (grill ledger), task list, gate, future-nicety.
- **Finding logged with doyle** — public liveness-query gap, tracked post-v0.13.0 (`spt whoami --json`).

**Core resolution:** the gap was real + untracked — legacy owl injects identity at SessionStart; sptc injects nothing. Fix = adapter-string-backed briefs (`[strings.briefs]`), hook composes + `{id}`-substitutes, never authors prose. Identity brief (bind+boundary, has-id) + peer-gated ring brief (seed, no-id). Liveness flavor deferred.

**Next body of work** = implement per the plan (no code yet — design only, as instructed). When you greenlight, first task is `[strings.briefs]` + the three `briefs/*.md`, then `_common.sh` compose/encode/peer-gate, then `session-start.sh` emit.

Psyche still holds Q1–Q3 state from the last echo_commune — want me to commune the full locked design so it's current, or leave it?


proceed to implement


<owl_reachability_notice>
You have an active owl perch. When spawning subagents or doing work, use run_in_background: true so you stay reachable for incoming messages.
</owl_reachability_notice>


CAVEMAN MODE ACTIVE (full). Drop articles/filler/pleasantries/hedging. Fragments OK. Code/commits/security: write normal.


Implementing per plan. First read the toml strings region + an existing brief-style skill + test style to match conventions.


1	#!/bin/sh
2	# Unit tests for the sptc hook parsing glue (pure functions; no spt/CC needed).
3	# Run: sh tests/hooks-parse.sh   (exit 0 = pass)
4	. "$(dirname "$0")/../plugin/sptc/hooks/_common.sh"
5
6	fail=0
7	check() { # check  <expected> <actual>
8	  if [ "$2" = "$3" ]; then printf 'ok   %s\n' "$1"
9	  else printf 'FAIL %s\n  expected: [%s]\n  actual:   [%s]\n' "$1" "$2" "$3"; fail=1; fi
10	}
11
12	# --- json_str: top-level CC hook-input fields ---  [unit->REQ-DIST-HOOKS-API]
13	J='{"session_id":"sess-abc123","transcript_path":"/x","source":"startup","model":"m"}'
14	check "json_str session_id" "sess-abc123" "$(json_str "$J" session_id)"
15	check "json_str source"     "startup"     "$(json_str "$J" source)"
16	check "json_str prompt"      "/sptc:send doyle" \
17	  "$(json_str '{"session_id":"s1","prompt":"/sptc:send doyle"}' prompt)"
18	check "json_str missing key" "" "$(json_str "$J" nope)"
19
20	# --- sptc_unescape:
 -> newline, entities, & LAST --- [unit->REQ-UPS-INJECTION]
21	check "unescape br+entities" \
22	  "$(printf 'a \n"c" & <')" \
23	  "$(sptc_unescape 'a <b>
"c" & &lt;')"
24
25	# --- render_frames: canonical <EVENT> envelope --- [unit->REQ-UPS-INJECTION]
26	# Named single,
-escaped multiline body, sender preserved.
27	named=$(render_frames '<EVENT type="msg" from="doyle">hello
world</EVENT>')
28	check "render named <EVENT>" \
29	  "$(printf '<sptc_messages from="doyle">\nhello\nworld\n</sptc_messages>')" "$named"
30
31	# Entity-escaped body decodes.
32	ent=$(render_frames '<EVENT type="msg" from="kit">a <tag> & b</EVENT>')
33	check "render entity body" \
34	  "$(printf '<sptc_messages from="kit">\na <tag> & b\n</sptc_messages>')" "$ent"
35
36	# Multi-message drain: two self-delimiting envelopes -> two rendered blocks.
37	multi=$(render_frames '<EVENT type="msg" from="a">one</EVENT><EVENT type="msg" from="b">two</EVENT>')
38	check "render multi-message" \
39	  "$(printf '<sptc_messages from="a">\none\n</sptc_messages>\n<sptc_messages from="b">\ntwo\n</sptc_messages>')" \
40	  "$multi"
41
42	# Envelope without a from attr -> anonymous rendering.
43	anon=$(render_frames '<EVENT type="msg">sys note</EVENT>')
44	check "render no-from" "$(printf '<sptc_messages>\nsys note\n</sptc_messages>')" "$anon"
45
46	# Empty drain -> no output.
47	check "render empty" "" "$(render_frames '')"
48
49	# --- sptc_skill_key: detect /sptc:<skill> in a slash-command prompt --- [unit->REQ-UPS-INJECTION]
50	check "skill_key bare"        "ready"  "$(sptc_skill_key '/sptc:ready')"
51	check "skill_key with args"   "send"   "$(sptc_skill_key '/sptc:send doyle hi')"
52	check "skill_key leading ws"  "setup"  "$(sptc_skill_key '   /sptc:setup')"
53	check "skill_key hyphenated"  "list-agents" "$(sptc_skill_key '/sptc:list-agents')"
54	check "skill_key mid-prose"   ""       "$(sptc_skill_key 'please run /sptc:ready for me')"
55	check "skill_key other ns"    ""       "$(sptc_skill_key '/other:ready')"
56	check "skill_key not a cmd"   ""       "$(sptc_skill_key 'ready set go')"
57	check "skill_key empty"       ""       "$(sptc_skill_key '')"
58
59	# --- sptc_register_verb: SessionStart bind/seed/boundary branch --- [unit->REQ-DIST-SHORTCUT-BASENAME]
60	# spt-hosted (endpoint id injected) -> bind; harness-hosted (no id) -> seed; clear/compact -> boundary.
61	unset SPT_ENDPOINT_ID
62	check "verb harness-hosted startup"   "seed"     "$(sptc_register_verb startup)"
63	check "verb harness-hosted empty src" "seed"     "$(sptc_register_verb '')"
64	check "verb boundary clear"           "boundary" "$(sptc_register_verb clear)"
65	check "verb boundary compact"         "boundary" "$(sptc_register_verb compact)"
66	SPT_ENDPOINT_ID=cc-7; export SPT_ENDPOINT_ID
67	check "verb spt-hosted startup -> bind" "bind"     "$(sptc_register_verb startup)"
68	# A /clear inside an spt-hosted session still rebinds via boundary (never re-binds fresh).
69	check "verb spt-hosted clear -> boundary" "boundary" "$(sptc_register_verb clear)"
70	unset SPT_ENDPOINT_ID
71
72	# --- sptc_cap_output: additionalContext spill guard (ADR-0002 Open #2) --- [unit->REQ-UPS-INJECTION]
73	spill="${TMPDIR:-/tmp}/sptc-cap-$$.txt"
74	rm -f "$spill"
75	check "cap under -> passthrough verbatim" "small body" "$(sptc_cap_output 'small body' 9000 "$spill")"
76	check "cap under -> no spill written"     "absent"     "$([ -f "$spill" ] && echo present || echo absent)"
77
78	over=$(sptc_cap_output 'abcdefghij' 5 "$spill")   # 10 bytes > cap 5
79	case "$over" in *'<sptc_overflow'*) m=ok ;; *) m=no ;; esac
80	check "cap over -> emits overflow marker"  "ok"         "$m"
81	check "cap over -> full body spilled"      "abcdefghij" "$(cat "$spill" 2>/dev/null)"
82	case "$over" in *abcdefghij*) leak=leaked ;; *) leak=clean ;; esac
83	check "cap over -> body not inlined"       "clean"      "$leak"
84	check "cap empty -> no output"             ""           "$(sptc_cap_output '' 5 "$spill")"
85	rm -f "$spill"
86
87	[ "$fail" -eq 0 ] && { echo "ALL PASS"; exit 0; } || { echo "FAILURES"; exit 1; }
88

#!/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 --- [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"        "ready"  "$(sptc_skill_key '/sptc:ready')"
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:ready for me')"
check "skill_key other ns"    ""       "$(sptc_skill_key '/other:ready')"
check "skill_key not a cmd"   ""       "$(sptc_skill_key 'ready set go')"
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

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


257	# ── [strings] ──────────────────────────────────────────────────────────────────────────────
258	# Opaque adapter data (spt-core never executes it), dot-path-readable via `spt adapter get-string`.
259	# Seed only this slice — the file-backed skill-instruction BODIES (UPS-injection source) land in
260	# the M12 file-backed-strings parity slice. A profile may leaf-replace any key here.
261	[strings]
262	adapter_label = "Claude Code (spt)"
263
264	[strings.notice]
265	not_installed = "spt-core not found on PATH — run /sptc:setup to install it."
266
267	[strings.skills]
268	# Operative UPS-injection skill bodies. File-backed values (`{ file = "skills/<x>.md" }`) live in the
269	# adapter aux dir `adapter/strings/skills/` (copied to `adapters/<adapter>/strings/` on `adapter add`,
270	# resolved lazily at `spt adapter get-string` — proven vs live v0.7.0, F-003). File-backed so the
271	# manifest does not bloat (ADR-0001). Skills not yet authored keep a one-line inline summary until
272	# their body lands; converting an inline summary to a `{ file }` pointer is the per-skill increment.
273	# whoami SKILL dropped (SCOPE LOCKED parity-trim; operator-confirmed 2026-06-15): core `spt whoami`
274	# stays in the hot path (-> M12 `endpoint list` alias), so the adapter ships no whoami skill. The
275	# "who am i" hint below points at the core CLI. (docs/PARITY.md.)
276	setup = { file = "skills/setup.md" }
277	ready = { file = "skills/ready.md" }
278	version = { file = "skills/version.md" }
279	list-agents = { file = "skills/list-agents.md" } # -> spt endpoint list (the roster)
280	force-stop = { file = "skills/force-stop.md" } # -> spt endpoint shutdown (graceful, tears down Psyche)
281	subnet = { file = "skills/subnet.md" }         # -> spt subnet {status,create,show-code,join} (cross-machine); REQ-SKILL-SUBNET
282	# `send` / `commune` / `signoff` are DELIBERATELY NOT injected here (2026-06-19). A live agent uses
283	# them REACTIVELY — replying to a message, briefing its Psyche, signing off — WITHOUT the user typing
284	# `/sptc:<skill>`, so UPS-injection (which fires only on that slash-command in the prompt) is the wrong
285	# delivery. Their full operative bodies live in the plugin SKILL.md (full-fat, always model-loadable);
286	# there is no `[strings.skills]` entry and no adapter aux body for them. (sptc_inject_skill no-ops
287	# silently on the absent key — the full SKILL.md is the single source.)
288	# `live` -> upgrade THIS session to a LiveAgent (Option A, 2026-06-17): base claude-spt is live-capable
289	# ([session.psyche_init] in base), so going live is just the COMMAND — bare `spt api listen <id>` under
290	# a Monitor relay. The live listen path stamps the perch state=live_agent → the daemon livehost hosts
291	# the Psyche (livehost.rs:282 gates on live_agent state, doyle). No `:live` composite, no --adapter.
292	live = { file = "skills/live.md" }             # -> bare `spt api listen <id>` + Monitor relay (LiveAgent)
293	# `new-alarm` -> EXPLICIT OUT OF SCOPE for v1: no core `spt` alarm/timed-pulse primitive exists
294	# (legacy $LIVE TIMED PULSE is owl/Psyche-layer, not lifted into core; adapters don't add core
295	# features). doyle carries a parity-gap finding to mint a core deferred-pulse primitive; the skill
296	# lands only after that ships. No `[strings.skills].new-alarm` entry by design (visible-not-silent).
297
298	# ── [[hints]] ──────────────────────────────────────────────────────────────────────────────
299	# Once-per-session keyword hints (order significant, first match wins; case-insensitive substrings).
300	[[hints]]
301	keywords = ["go live", "live agent", "start live"]
302	text = "Tip: /sptc:live starts or resumes a live agent session."
303
304	[[hints]]
305	keywords = ["who am i", "my owl name", "my agent name", "my endpoint id"]
306	text = "Tip: run `spt whoami` to see this session's spt endpoint id."
307
308	[[hints]]
309	keywords = ["message another agent", "send to agent", "tell the other agent"]
310	text = "Tip: /sptc:send messages another spt agent; /sptc:ready first to receive replies."
311
312	[[hints]]
313	keywords = ["another machine", "pair machine", "across machines", "subnet", "join my network"]
314	text = "Tip: /sptc:subnet pairs machines into a private network so agents reach each other across nodes."
315

# ── [strings] ──────────────────────────────────────────────────────────────────────────────
# Opaque adapter data (spt-core never executes it), dot-path-readable via `spt adapter get-string`.
# Seed only this slice — the file-backed skill-instruction BODIES (UPS-injection source) land in
# the M12 file-backed-strings parity slice. A profile may leaf-replace any key here.
[strings]
adapter_label = "Claude Code (spt)"

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

[strings.skills]
# Operative UPS-injection skill bodies. File-backed values (`{ file = "skills/<x>.md" }`) live in the
# adapter aux dir `adapter/strings/skills/` (copied to `adapters/<adapter>/strings/` on `adapter add`,
# resolved lazily at `spt adapter get-string` — proven vs live v0.7.0, F-003). File-backed so the
# manifest does not bloat (ADR-0001). Skills not yet authored keep a one-line inline summary until
# their body lands; converting an inline summary to a `{ file }` pointer is the per-skill increment.
# whoami SKILL dropped (SCOPE LOCKED parity-trim; operator-confirmed 2026-06-15): core `spt whoami`
# stays in the hot path (-> M12 `endpoint list` alias), so the adapter ships no whoami skill. The
# "who am i" hint below points at the core CLI. (docs/PARITY.md.)
setup = { file = "skills/setup.md" }
ready = { file = "skills/ready.md" }
version = { file = "skills/version.md" }
list-agents = { file = "skills/list-agents.md" } # -> spt endpoint list (the roster)
force-stop = { file = "skills/force-stop.md" } # -> spt endpoint shutdown (graceful, tears down Psyche)
subnet = { file = "skills/subnet.md" }         # -> spt subnet {status,create,show-code,join} (cross-machine); REQ-SKILL-SUBNET
# `send` / `commune` / `signoff` are DELIBERATELY NOT injected here (2026-06-19). A live agent uses
# them REACTIVELY — replying to a message, briefing its Psyche, signing off — WITHOUT the user typing
# `/sptc:<skill>`, so UPS-injection (which fires only on that slash-command in the prompt) is the wrong
# delivery. Their full operative bodies live in the plugin SKILL.md (full-fat, always model-loadable);
# there is no `[strings.skills]` entry and no adapter aux body for them. (sptc_inject_skill no-ops
# silently on the absent key — the full SKILL.md is the single source.)
# `live` -> upgrade THIS session to a LiveAgent (Option A, 2026-06-17): base claude-spt is live-capable
# ([session.psyche_init] in base), so going live is just the COMMAND — bare `spt api listen <id>` under
# a Monitor relay. The live listen path stamps the perch state=live_agent → the daemon livehost hosts
# the Psyche (livehost.rs:282 gates on live_agent state, doyle). No `:live` composite, no --adapter.
live = { file = "skills/live.md" }             # -> bare `spt api listen <id>` + Monitor relay (LiveAgent)
# `new-alarm` -> EXPLICIT OUT OF SCOPE for v1: no core `spt` alarm/timed-pulse primitive exists
# (legacy $LIVE TIMED PULSE is owl/Psyche-layer, not lifted into core; adapters don't add core
# features). doyle carries a parity-gap finding to mint a core deferred-pulse primitive; the skill
# lands only after that ships. No `[strings.skills].new-alarm` entry by design (visible-not-silent).

# ── [[hints]] ──────────────────────────────────────────────────────────────────────────────
# Once-per-session keyword hints (order significant, first match wins; case-insensitive substrings).
[[hints]]
keywords = ["go live", "live agent", "start live"]
text = "Tip: /sptc:live starts or resumes a live agent session."

[[hints]]
keywords = ["who am i", "my owl name", "my agent name", "my endpoint id"]
text = "Tip: run `spt whoami` to see this session's spt endpoint id."

[[hints]]
keywords = ["message another agent", "send to agent", "tell the other agent"]
text = "Tip: /sptc:send messages another spt agent; /sptc:ready first to receive replies."

[[hints]]
keywords = ["another machine", "pair machine", "across machines", "subnet", "join my network"]
text = "Tip: /sptc:subnet pairs machines into a private network so agents reach each other across nodes."


1	# /sptc:ready — operative instructions
2
3	Make this session reachable for inter-agent messages. Fewest steps — the user is waiting on bringup.
4
5	1. Pick the id: the one the user gave, else `spt whoami`. Call it `<id>`.
6	2. Run the listener as a single PERSISTENT background task via the **Monitor** tool:
7	   - `command: "spt ready <id>"`
8	   - `persistent: true`
9	   - `description: "« spt event »"`
10
11	   It blocks for the session's life, emitting one `<EVENT type="msg" from="<sender>">body</EVENT>` per
12	   delivery. Announce reachable — don't wait on markers.
13	3. To reply, pipe the message body to `spt send <sender>` (sender = the `from` on the EVENT).
14
15	Full guidance: `spt how-to ready`. (No long-running tasks available? `spt ready <id> --once` drains the
16	backlog, waits for one delivery, then exits — re-run to stay reachable.)
17

# /sptc:ready — operative instructions

Make this session reachable for inter-agent messages. Fewest steps — the user is waiting on bringup.

1. Pick the id: the one the user gave, else `spt whoami`. Call it `<id>`.
2. Run the listener as a single PERSISTENT background task via the **Monitor** tool:
   - `command: "spt ready <id>"`
   - `persistent: true`
   - `description: "« spt event »"`

   It blocks for the session's life, emitting one `<EVENT type="msg" from="<sender>">body</EVENT>` per
   delivery. Announce reachable — don't wait on markers.
3. To reply, pipe the message body to `spt send <sender>` (sender = the `from` on the EVENT).

Full guidance: `spt how-to ready`. (No long-running tasks available? `spt ready <id> --once` drains the
backlog, waits for one delivery, then exits — re-run to stay reachable.)


Have all conventions. Building. Three brief files first.


Write

You are spt agent "{id}". This Claude Code session already owns a live perch — you are reachable right now.

- Messages arrive on your Monitor EVENT stream (`<EVENT type="msg" from="<sender>">body</EVENT>`) or, mid-tool-call, as `<sptc_messages>` the hook injects. Process them and reply.
- Do NOT run /sptc:ready or /sptc:live — your perch is already up. Re-arming returns COLLISION (that is proof it is alive, not a stale perch).


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

You are spt agent "{id}". This Claude Code session already owns a live perch — you are reachable right now.

- Messages arrive on your Monitor EVENT stream (`<EVENT type="msg" from="<sender>">body</EVENT>`) or, mid-tool-call, as `<sptc_messages>` the hook injects. Process them and reply.
- Do NOT run /sptc:ready or /sptc:live — your perch is already up. Re-arming returns COLLISION (that is proof it is alive, not a stale perch).


Reach another agent (the body is read from stdin):

- Send: `printf '%s' "<body>" | spt send <target>`. `SENT` = delivered live; `QUEUED` = target offline, spooled for its next listen — QUEUED is success, do not retry.
- Reply: `printf '%s' "<body>" | spt send <sender>` (sender = the `from` on the EVENT you received).


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

Reach another agent (the body is read from stdin):

- Send: `printf '%s' "<body>" | spt send <target>`. `SENT` = delivered live; `QUEUED` = target offline, spooled for its next listen — QUEUED is success, do not retry.
- Reply: `printf '%s' "<body>" | spt send <sender>` (sender = the `from` on the EVENT you received).


You can reach other spt agents on this node's subnet without readying a perch (the body is read from stdin):

- Send and wait for a reply: `printf '%s' "<body>" | spt ring <target> --timeout 60` (the reply prints to stdout; `TIMEOUT` is exit 0 — the message still landed).
- To RECEIVE messages yourself, run /sptc:ready (or /sptc:live for a live session).


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

You can reach other spt agents on this node's subnet without readying a perch (the body is read from stdin):

- Send and wait for a reply: `printf '%s' "<body>" | spt ring <target> --timeout 60` (the reply prints to stdout; `TIMEOUT` is exit 0 — the message still landed).
- To RECEIVE messages yourself, run /sptc:ready (or /sptc:live for a live session).


Adapter `[strings.briefs]` block.


Edit

# ── [[hints]] ──────────────────────────────────────────────────────────────────────────────
# Once-per-session keyword hints (order significant, first match wins; case-insensitive substrings).

# ── [strings.briefs] ─────────────────────────────────────────────────────────────────────────
# SessionStart agent-facing briefs. Same file-backed/inline machinery as [strings.skills] (resolved
# via `spt adapter get-string claude-spt briefs.<key>`). The hook COMPOSES + {id}-substitutes these;
# it never authors agent-facing prose (single-source throughline). Composable so the shared roster +
# messaging text live once: the identity brief (perched sessions — bind+boundary) = identity +
# messaging-perch + endpoint-list; the ring brief (no-perch seed, peer-gated) = messaging-no-perch +
# endpoint-list. Liveness-agnostic (no live/ready split — deferred, public liveness-query gap, logged
# with doyle). One-liners inline; multi-line bodies file-backed under adapter/strings/briefs/.
# [impl->REQ-DIST-SESSIONSTART-BRIEF]
[strings.briefs]
identity = { file = "briefs/identity.md" }                      # {id}-templated: who + perch-live + don't-re-arm
messaging-perch = { file = "briefs/messaging-perch.md" }        # has-id reach: spt send + reply
messaging-no-perch = { file = "briefs/messaging-no-perch.md" }  # no-id reach: spt ring
endpoint-list = "Show the roster of spt endpoints reachable from this node: `spt endpoint list`."  # shared one-liner (inline)

# ── [[hints]] ──────────────────────────────────────────────────────────────────────────────
# Once-per-session keyword hints (order significant, first match wins; case-insensitive substrings).


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

# ── [strings.briefs] ─────────────────────────────────────────────────────────────────────────
# SessionStart agent-facing briefs. Same file-backed/inline machinery as [strings.skills] (resolved
# via `spt adapter get-string claude-spt briefs.<key>`). The hook COMPOSES + {id}-substitutes these;
# it never authors agent-facing prose (single-source throughline). Composable so the shared roster +
# messaging text live once: the identity brief (perched sessions — bind+boundary) = identity +
# messaging-perch + endpoint-list; the ring brief (no-perch seed, peer-gated) = messaging-no-perch +
# endpoint-list. Liveness-agnostic (no live/ready split — deferred, public liveness-query gap, logged
# with doyle). One-liners inline; multi-line bodies file-backed under adapter/strings/briefs/.
# [impl->REQ-DIST-SESSIONSTART-BRIEF]
[strings.briefs]
identity = { file = "briefs/identity.md" }                      # {id}-templated: who + perch-live + don't-re-arm
messaging-perch = { file = "briefs/messaging-perch.md" }        # has-id reach: spt send + reply
messaging-no-perch = { file = "briefs/messaging-no-perch.md" }  # no-id reach: spt ring
endpoint-list = "Show the roster of spt endpoints reachable from this node: `spt endpoint list`."  # shared one-liner (inline)

# ── [[hints]] ──────────────────────────────────────────────────────────────────────────────
# Once-per-session keyword hints (order significant, first match wins; case-insensitive substrings).

# features). doyle carries a parity-gap finding to mint a core deferred-pulse primitive; the skill

# lands only after that ships. No `[strings.skills].new-alarm` entry by design (visible-not-silent).

+# ── [strings.briefs] ─────────────────────────────────────────────────────────────────────────

+# SessionStart agent-facing briefs. Same file-backed/inline machinery as [strings.skills] (resolved

+# via `spt adapter get-string claude-spt briefs.<key>`). The hook COMPOSES + {id}-substitutes these;

+# it never authors agent-facing prose (single-source throughline). Composable so the shared roster +

+# messaging text live once: the identity brief (perched sessions — bind+boundary) = identity +

+# messaging-perch + endpoint-list; the ring brief (no-perch seed, peer-gated) = messaging-no-perch +

+# endpoint-list. Liveness-agnostic (no live/ready split — deferred, public liveness-query gap, logged

+# with doyle). One-liners inline; multi-line bodies file-backed under adapter/strings/briefs/.

+identity = { file = "briefs/identity.md" }                      # {id}-templated: who + perch-live + don't-re-arm

+messaging-perch = { file = "briefs/messaging-perch.md" }        # has-id reach: spt send + reply

+messaging-no-perch = { file = "briefs/messaging-no-perch.md" }  # no-id reach: spt ring

+endpoint-list = "Show the roster of spt endpoints reachable from this node: `spt endpoint list`."  # shared one-liner (inline)


Now `_common.sh` — pure assemblers + impure resolvers + JSON encoder + peer gate.


  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"
}

  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"
}

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

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

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

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

# Impure: resolve + assemble the perched-session identity brief for id $1. [impl->REQ-DIST-SESSIONSTART-BRIEF]
sptc_perch_brief() {
  sptc_assemble_perch "$1" "$(sptc_brief identity)" "$(sptc_brief messaging-perch)" "$(sptc_brief endpoint-list)"
}

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

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

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

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

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

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


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)

  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"
}

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

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

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

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

# Impure: resolve + assemble the perched-session identity brief for id $1. [impl->REQ-DIST-SESSIONSTART-BRIEF]
sptc_perch_brief() {
  sptc_assemble_perch "$1" "$(sptc_brief identity)" "$(sptc_brief messaging-perch)" "$(sptc_brief endpoint-list)"
}

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

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

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

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

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

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

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

ADAPTER=claude-spt

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

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

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

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

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

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

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

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

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

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

+# ── SessionStart agent-facing briefs (REQ-DIST-SESSIONSTART-BRIEF) ──────────────────────────────

+# The hook COMPOSES adapter-string briefs into a SessionStart additionalContext block; it never

+# authors agent-facing prose (single-source throughline — bodies live in [strings.briefs]). Pure

+# assemblers below carry the only composition logic ({id} substitution, concat, wrapping) so they

+# unit-test without spt; the impure resolvers wrap them with `spt adapter get-string`.

+# Resolve a brief body by key from the adapter `[strings.briefs].<key>` (file-backed or inline).

+# Empty on absent key / unregistered adapter / missing spt — caller tolerates. [impl->REQ-DIST-SESSIONSTART-BRIEF]

+  "$_spt" adapter get-string "$ADAPTER" "briefs.$_key" 2>/dev/null

+# PURE: assemble the identity (perched) block. $1=id $2=identity-body $3=messaging-body $4=roster.

+# Substitutes {id} in the identity body (agent ids are [a-z0-9-] — sed-safe, no metachar risk).

+  printf '<sptc-active-perch id="%s">\n%s\n\n%s\n\n%s\n</sptc-active-perch>' \

+# PURE: assemble the ring (no-perch) block. $1=messaging-body $2=roster. [impl->REQ-DIST-SESSIONSTART-BRIEF]

+  printf '<sptc-reach>\n%s\n\n%s\n</sptc-reach>' "$1" "$2"

+# Impure: resolve + assemble the perched-session identity brief for id $1. [impl->REQ-DIST-SESSIONSTART-BRIEF]

+  sptc_assemble_perch "$1" "$(sptc_brief identity)" "$(sptc_brief messaging-perch)" "$(sptc_brief endpoint-list)"

+# Impure: resolve + assemble the no-perch ring brief. [impl->REQ-DIST-SESSIONSTART-BRIEF]

+  sptc_assemble_noperch "$(sptc_brief messaging-no-perch)" "$(sptc_brief endpoint-list)"

+# PURE predicate: should this SessionStart inject a brief? Subagent sessions (agent_type set) get

+# none. $1 = the stdin agent_type value (empty = a real user session). [impl->REQ-DIST-SESSIONSTART-BRIEF]

+# PURE predicate: peer-presence gate for the ring brief. Reads `spt subnet status` output on stdin;

+# exit 0 (peers) iff >1 non-empty line (header + >=1 subnet row). Line-count only — NEVER parses a

+# column value (the columnar layout is human-formatted, not a hook contract). [impl->REQ-DIST-SESSIONSTART-BRIEF]

+# Impure: does this node participate in any subnet (has reachable peers)? [impl->REQ-DIST-SESSIONSTART-BRIEF]

+  "$_spt" subnet status 2>/dev/null | sptc_has_peers_lines

+# PURE: JSON-escape stdin into a string value (no surrounding quotes). Escapes \ then " , tabs, CR;

+# newlines become literal \n. awk so multi-line bodies survive. [impl->REQ-DIST-SESSIONSTART-BRIEF]

+# Emit a SessionStart hookSpecificOutput JSON line carrying $1 as additionalContext. No-op on empty

+# ($1 unset/blank → nothing to stdout, hook stays silent). [impl->REQ-DIST-SESSIONSTART-BRIEF]

+  _esc=$(printf '%s' "$1" | sptc_json_escape)

+  printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"%s"}}\n' "$_esc"


Now wire `session-start.sh` to emit the brief.


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

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

# Relay an agent-facing brief as additionalContext (REQ-DIST-SESSIONSTART-BRIEF). Perched sessions
# (bind/boundary — an id already exists) get the identity brief: who they are + perch-is-live +
# how to message. No-perch seeds on a node WITH subnet peers get the ring brief (how to reach others
# without a perch). Seeds with no peers, and subagent (agent_type) sessions, get nothing. All prose
# is adapter-string-backed; this hook only selects + composes. [impl->REQ-DIST-SESSIONSTART-BRIEF]
if ! sptc_is_subagent "$(json_str "$input" agent_type)"; then
  brief=""
  case "$(sptc_register_verb "$src")" in
    bind|boundary)
      # id: the spt-hosted endpoint id if injected, else resolve our perch via whoami.
      bid="${SPT_ENDPOINT_ID:-$(sptc_self_id "$sid")}"
      [ -n "$bid" ] && brief=$(sptc_perch_brief "$bid")
      ;;
    seed)
      sptc_node_has_peers && brief=$(sptc_noperch_brief)
      ;;
  esac
  sptc_emit_additional_context "$brief"
fi
exit 0


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

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

# Relay an agent-facing brief as additionalContext (REQ-DIST-SESSIONSTART-BRIEF). Perched sessions
# (bind/boundary — an id already exists) get the identity brief: who they are + perch-is-live +
# how to message. No-perch seeds on a node WITH subnet peers get the ring brief (how to reach others
# without a perch). Seeds with no peers, and subagent (agent_type) sessions, get nothing. All prose
# is adapter-string-backed; this hook only selects + composes. [impl->REQ-DIST-SESSIONSTART-BRIEF]
if ! sptc_is_subagent "$(json_str "$input" agent_type)"; then
  brief=""
  case "$(sptc_register_verb "$src")" in
    bind|boundary)
      # id: the spt-hosted endpoint id if injected, else resolve our perch via whoami.
      bid="${SPT_ENDPOINT_ID:-$(sptc_self_id "$sid")}"
      [ -n "$bid" ] && brief=$(sptc_perch_brief "$bid")
      ;;
    seed)
      sptc_node_has_peers && brief=$(sptc_noperch_brief)
      ;;
  esac
  sptc_emit_additional_context "$brief"
fi
exit 0

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

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

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

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

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

+# Relay an agent-facing brief as additionalContext (REQ-DIST-SESSIONSTART-BRIEF). Perched sessions

+# (bind/boundary — an id already exists) get the identity brief: who they are + perch-is-live +

+# how to message. No-perch seeds on a node WITH subnet peers get the ring brief (how to reach others

+# without a perch). Seeds with no peers, and subagent (agent_type) sessions, get nothing. All prose

+# is adapter-string-backed; this hook only selects + composes. [impl->REQ-DIST-SESSIONSTART-BRIEF]

+if ! sptc_is_subagent "$(json_str "$input" agent_type)"; then

+  case "$(sptc_register_verb "$src")" in

+      # id: the spt-hosted endpoint id if injected, else resolve our perch via whoami.

+      bid="${SPT_ENDPOINT_ID:-$(sptc_self_id "$sid")}"

+      [ -n "$bid" ] && brief=$(sptc_perch_brief "$bid")

+      sptc_node_has_peers && brief=$(sptc_noperch_brief)

+  sptc_emit_additional_context "$brief"


Unit tests for the new pure functions.


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

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

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

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

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

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

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

[ "$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)

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

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

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

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

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

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

[ "$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 --- [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"        "ready"  "$(sptc_skill_key '/sptc:ready')"
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:ready for me')"
check "skill_key other ns"    ""       "$(sptc_skill_key '/other:ready')"
check "skill_key not a cmd"   ""       "$(sptc_skill_key 'ready set go')"
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

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

+# --- SessionStart briefs: pure composition --- [unit->REQ-DIST-SESSIONSTART-BRIEF]

+# assemble_perch: {id} substitution + block structure.

+pb=$(sptc_assemble_perch "perri" 'you are {id}.' 'send body' 'roster line')

+check "assemble_perch structure+subst" \

+  "$(printf '<sptc-active-perch id="perri">\nyou are perri.\n\nsend body\n\nroster line\n</sptc-active-perch>')" \

+case "$pb" in *'{id}'*) leftover=present ;; *) leftover=clean ;; esac

+check "assemble_perch no {id} leftover" "clean" "$leftover"

+# Every {id} occurrence is substituted (global).

+multi_id=$(sptc_assemble_perch "kit" 'a {id} b {id}' 'm' 'r')

+case "$multi_id" in *'a kit b kit'*) g=ok ;; *) g=no ;; esac

+check "assemble_perch global subst" "ok" "$g"

+# assemble_noperch: ring block structure (no id).

+nb=$(sptc_assemble_noperch 'ring body' 'roster line')

+  "$(printf '<sptc-reach>\nring body\n\nroster line\n</sptc-reach>')" "$nb"

+# is_subagent: empty -> real session (inject); set -> subagent (skip).

+check "is_subagent empty -> no"   "no"  "$(sptc_is_subagent ''               && echo yes || echo no)"

+check "is_subagent set -> yes"    "yes" "$(sptc_is_subagent 'general-purpose' && echo yes || echo no)"

+# has_peers_lines: >1 non-empty line on `subnet status` stdin = peers present.

+check "peers: header+row -> yes" "yes" "$(printf 'SUBNET NODES ENDPOINTS\nSPT_DEV 3 4\n' | sptc_has_peers_lines && echo yes || echo no)"

+check "peers: single line -> no" "no"  "$(printf 'no subnets\n'                            | sptc_has_peers_lines && echo yes || echo no)"

+check "peers: empty -> no"       "no"  "$(printf ''                                          | sptc_has_peers_lines && echo yes || echo no)"

+# --- SessionStart briefs: JSON encode + emit --- [unit->REQ-DIST-SESSIONSTART-BRIEF]

+check "json_escape quotes"    'say \"hi\"' "$(printf '%s' 'say "hi"' | sptc_json_escape)"

+check "json_escape backslash" 'a\\b'       "$(printf '%s' 'a\b'      | sptc_json_escape)"

+check "json_escape newline"   'a\nb'       "$(printf 'a\nb'          | sptc_json_escape)"

+check "emit empty -> silent" "" "$(sptc_emit_additional_context '')"

+  '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"hi \"x\""}}' \

+  "$(sptc_emit_additional_context 'hi "x"')"

+  '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"a\nb"}}' \

+  "$(sptc_emit_additional_context "$(printf 'a\nb')")"


Run the unit tests.


Bash

Run hook unit tests


ok   json_str session_id
ok   json_str source
ok   json_str prompt
ok   json_str missing key
ok   unescape br+entities
ok   render named <EVENT>
ok   render entity body
ok   render multi-message
ok   render no-from
ok   render empty
ok   skill_key bare
ok   skill_key with args
ok   skill_key leading ws
ok   skill_key hyphenated
ok   skill_key mid-prose
ok   skill_key other ns
ok   skill_key not a cmd
ok   skill_key empty
ok   verb harness-hosted startup
ok   verb harness-hosted empty src
ok   verb boundary clear
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
ok   assemble_perch structure+subst
ok   assemble_perch no {id} leftover
ok   assemble_perch global subst
ok   assemble_noperch structure
ok   is_subagent empty -> no
ok   is_subagent set -> yes
ok   peers: header+row -> yes
ok   peers: single line -> no
ok   peers: empty -> no
ok   json_escape quotes
ok   json_escape backslash
ok   json_escape newline
ok   emit empty -> silent
ok   emit json shape
ok   emit json newline-escaped
ALL PASS


45/45 pass. Now end-to-end: re-register the adapter so `[strings.briefs]` + the new aux files land, then verify `get-string` resolves and the full compose pipe works.


cd /c/Users/decid/Documents/projects/spt-claude-code || exit 1
echo "== adapter list =="; spt adapter list 2>&1 | head -20
echo "== update adapter =="; spt adapter update claude-spt ./adapter/claude-spt.toml 2>&1 | head -8 || spt adapter add ./adapter/claude-spt.toml 2>&1 | head -8
echo "== get-string briefs.identity =="; spt adapter get-string claude-spt briefs.identity 2>&1
echo "== get-string briefs.endpoint-list =="; spt adapter get-string claude-spt briefs.endpoint-list 2>&1

Re-register adapter and verify brief strings resolve


Error: Exit code 1
== adapter list ==
claude-spt: Harness Pointer active (from C:\Users\decid\AppData\Local\spt-core\adapters\_github\SaberMage-spt-claude-code)
  claude-spt:ccs (shipped)
probe-spt: Harness Copy deregistered (from C:/Users/decid/AppData/Local/Temp/sptc-probe)
== update adapter ==
error: unexpected argument './adapter/claude-spt.toml' found

Usage: spt.exe adapter update [NAME]

For more information, try '--help'.
== get-string briefs.identity ==
ADAPTER_STRING_UNSET:claude-spt: no value at 'briefs.identity'
== get-string briefs.endpoint-list ==
ADAPTER_STRING_UNSET:claude-spt: no value at 'briefs.endpoint-list'


Registered `claude-spt` is a **github pointer** (synced copy), not my local tree — so `get-string` can't see my edits yet. I'll prove the mechanism with a throwaway local copy adapter, then clean up.


cd /c/Users/decid/Documents/projects/spt-claude-code || exit 1
spt adapter add --help 2>&1 | head -30

Check adapter add invocation


Register an adapter from a local path (a dir holding manifest.toml, or the manifest file itself)
or from GitHub (--github user/repo, cloned under adapters/_github/). Manifest-first: an invalid
manifest registers nothing. Install is the first update — the declared [update] avenue is
conducted once after recording

Usage: spt.exe adapter add [OPTIONS] [PATH]

Arguments:
  [PATH]  Local manifest source (omit when using --github or --release)

Options:
      --github <GITHUB>    GitHub source user/repo — shallow-clone the repo and register the clone
                           root. Manifest-first, then install via the declared [update] avenue
      --release <RELEASE>  GitHub release source user/repo — fetch the adapter archive asset from
                           the release and register it: ships built binaries, source-free and
                           versioned (the pattern for a monorepo whose adapter is a subdir)
      --tag <TAG>          Release tag for --release (default: the latest release)
      --asset <ASSET>      Release asset name for --release (default: adapter.spt — a tar archive
                           whose root holds manifest.toml + strings/ + binaries)
  -h, --help               Print help


`add` would collide with the registered `claude-spt`. I'll stage a renamed local copy under a probe name for a faithful end-to-end, then remove it. Checking the name field + manifest deps first.


cd /c/Users/decid/Documents/projects/spt-claude-code || exit 1
grep -nE 'adapter_name|^name|schema|manifest_schema|aux|strings_dir' adapter/claude-spt.toml | head -20
echo "== files in adapter/ =="; ls adapter/

Find adapter name + schema refs in manifest


9:# spt-releases) — `manifest.schema.json` is vendored beside this file (adapter/manifest.schema.json,
10:# provenance: https://sabermage.github.io/spt-releases/manifest.schema.json). Validated by the
11:# manifest-schema CI gate (ci/manifest/). NEVER copy spt-core internals (AGENTS.md).
29:name = "claude-spt"
42:# vendored v0.7.0 schema's [adapter] allows additional props), live once v0.9.0 + `spt adapter use`
46:# the M12 `cc` launcher, and matches the schema's own example (spt-claude-code → `cc`). The launcher
269:# adapter aux dir `adapter/strings/skills/` (copied to `adapters/<adapter>/strings/` on `adapter add`,
286:# there is no `[strings.skills]` entry and no adapter aux body for them. (sptc_inject_skill no-ops
== files in adapter/ ==
claude-spt.toml
manifest.schema.json
strings


cd /c/Users/decid/Documents/projects/spt-claude-code || exit 1
T="${TMPDIR:-/tmp}/sptc-brief-probe"
rm -rf "$T"; mkdir -p "$T"
cp adapter/manifest.schema.json "$T/" 2>/dev/null
cp -r adapter/strings "$T/strings"
# manifest.toml = the working manifest with name -> probe-sptc (avoid collision with the live pointer)
sed 's/^name = "claude-spt"/name = "probe-sptc"/' adapter/claude-spt.toml > "$T/manifest.toml"
echo "== add probe =="; spt adapter add "$T" 2>&1 | head -6
echo "== get-string identity =="; spt adapter get-string probe-sptc briefs.identity 2>&1
echo "== get-string endpoint-list =="; spt adapter get-string probe-sptc briefs.endpoint-list 2>&1
echo "== get-string messaging-no-perch =="; spt adapter get-string probe-sptc briefs.messaging-no-perch 2>&1

Stage renamed probe adapter, verify brief strings resolve


== add probe ==
ADAPTER_ADD:probe-sptc:Harness:Pointer (registered)
ADAPTER_INSTALL_DEFERRED: GhReleaseManaged (rides the update engine)
== get-string identity ==
You are spt agent "{id}". This Claude Code session already owns a live perch — you are reachable right now.

- Messages arrive on your Monitor EVENT stream (`<EVENT type="msg" from="<sender>">body</EVENT>`) or, mid-tool-call, as `<sptc_messages>` the hook injects. Process them and reply.
- Do NOT run /sptc:ready or /sptc:live — your perch is already up. Re-arming returns COLLISION (that is proof it is alive, not a stale perch).

== get-string endpoint-list ==
Show the roster of spt endpoints reachable from this node: `spt endpoint list`.
== get-string messaging-no-perch ==
You can reach other spt agents on this node's subnet without readying a perch (the body is read from stdin):

- Send and wait for a reply: `printf '%s' "<body>" | spt ring <target> --timeout 60` (the reply prints to stdout; `TIMEOUT` is exit 0 — the message still landed).
- To RECEIVE messages yourself, run /sptc:ready (or /sptc:live for a live session).


All brief keys resolve (file-backed + inline). Now the full compose+emit pipe with `ADAPTER=probe-sptc`, and validate the emitted JSON parses.


cd /c/Users/decid/Documents/projects/spt-claude-code || exit 1
. plugin/sptc/hooks/_common.sh
ADAPTER=probe-sptc   # override the hardcoded claude-spt for this probe

echo "===== PERCH BRIEF (id=perri) ====="
sptc_perch_brief perri

echo; echo "===== EMITTED JSON (perch) ====="
emit=$(sptc_emit_additional_context "$(sptc_perch_brief perri)")
printf '%s\n' "$emit"

echo; echo "===== JSON VALIDITY + roundtrip additionalContext ====="
printf '%s' "$emit" | python -c 'import sys,json; d=json.load(sys.stdin); ac=d["hookSpecificOutput"]["additionalContext"]; print("PARSE_OK hookEventName=",d["hookSpecificOutput"]["hookEventName"]); assert "{id}" not in ac, "LEFTOVER {id}"; assert "perri" in ac; print("NO_LEFTOVER_ID  contains_perri=OK"); print("---- decoded additionalContext ----"); print(ac)'

echo; echo "===== NO-PERCH RING BRIEF + emit validity ====="
nemit=$(sptc_emit_additional_context "$(sptc_noperch_brief)")
printf '%s' "$nemit" | python -c 'import sys,json; d=json.load(sys.stdin); print("PARSE_OK"); print(d["hookSpecificOutput"]["additionalContext"])'

End-to-end compose + emit + JSON validation