1	<psyche-context id="perri">
2	<current machine="HFENDULEAM" project="spt-claude-code" branch="main" head_sha="4954293569af0e0657bff4e1805efcde7b66497f" head_subject="docs(findings): F-019 RESOLVED — translation binary never spawned (relat…" commits_since="0" commits_unpulled="0"/>
3	**2026-06-24 — checkpoint-commune wave: doc-capture phase COMPLETE; resume = THE BUILD.**
4
5	Delta since last download: findings + REQ stubs are now LANDED (the "outstanding item #1" is done):
6	- `docs/SPT-CORE-FINDINGS.md`: F-020 (psyche-download verb gap, RESOLVED-PENDING v0.15.0 W5) + F-021/F-022 note (adapter parity, tracked as REQs).
7	- `traceable-reqs.toml`: 5 stubs minted, all `required_stages = []` — REQ-DIST-IDLE-MULTILINE, -CHECKPOINT-COMMUNE, -RESUME-CONTEXT, -PRETOOL-POLL, -WORKER-PERCH-REACH. `traceable-reqs check` GREEN (exit 0).
8
9	**IMMEDIATE NEXT STEPS (resume here, in order — this is the build):**
10	1. **T1 (Feature 1, multi-line envelope).** EMPIRICAL GATE FIRST: throwaway probe binary on a fresh spt-hosted endpoint → confirm CC soft-newlines on a bare `\n` in `{text}` (one user turn, two lines, no early submit). If yes → `cc-spt-idle-translate` splits the envelope opening-tag / `\n` / body / `\n` / closing-tag; relax the CR/LF→space sanitizer FOR CC only. Activate REQ-DIST-IDLE-MULTILINE; update the 11 binary tests.
11	2. **T2 (Feature 2 mechanics — ZERO spt-core dep, fully buildable now).** Full-fat `/sptc:commune` SKILL.md (drop fetch-stub) + `--checkpoint` flag + `[[hints]]`. NEW PostToolUse hook (manifest `[hooks.PostToolUse]` + hooks.json + detector): trigger `!!checkpoint!!` in Write `tool_input.content` for `.claude/<id>-commune.md` → `spt api state idle` → `spt send --from <id> <id> <wire-sentinel[+wake]>`. Binary checkpoint branch: filter the wire-sentinel → macro `ctrl+s · 50ms · /clear · enter · 500ms · <wake-text> · enter · commit`; parse double-`!!checkpoint!!` custom wake. Activate REQ-DIST-CHECKPOINT-COMMUNE. Verify commune body format (two-slice envelope vs freeform).
12	3. **T3 (parity wiring — code-now / validate-on-W5).** `session-start.sh` bind|boundary → `spt api psyche-download <self-id>` → inject stdout as additionalContext, skip on NO-CONTEXT (stderr). boundary stays rotation-only. Activate REQ-DIST-RESUME-CONTEXT. Validate E2E on doyle's W5 ping.
13
14	**WAITING ON spt-core v0.15.0 (doyle/todlando):** W5 ships `spt api psyche-download <id>` (FROZEN Tier-1, committed @20bfc1f, after W4) — doyle PINGS when gated+built → then do T3 validation + todlando's `!!checkpoint!!` pending-strip follow-up lands. W4 = key-vocab + `{text}`-verbatim doc.
15
16	**RECIPE NOTES:** this session = LEGACY owl (perri). Reach doyle/todlando via `$OWL send <target> perri` (new-spt `spt send doyle` = NO_PERCH). `$LIVE` = `owl.exe live` — run UNQUOTED so it word-splits. PowerShell has NO printf → use the Bash tool for spt/owl sends. Self-send loopback PROVEN (`spt send --from <id> <id>` → own translation binary → user turn). Probe recipe: `spt endpoint run --adapter claude-spt --id <id> --create --start --subnet SPT_DEV`; spawned-CC transcript at `~/.claude/projects/<slug>/<cc-session-uuid>.jsonl` (NOT spt's session id; grep a unique token). Cleanup: `spt endpoint shutdown <id>` + reap `cc-spt-idle-translate.exe`.
17
18	**echo_commune log:**
19	- 2026-06-24T03:49:53Z — HFENDULEAM / spt-claude-code / main @ 4954293
20	- 2026-06-24T07:30:16Z–10:25:58Z — 4 more pulses, same SHA
21	- 2026-06-24T10:53:33Z — DIRECT commune (source=direct), same SHA — doc phase done, build phase opened
22
23	---
24
25	**F-019**: FULLY GATED. #1 CC-on-Windows blocker DEAD.
26	**spt-core v0.14.3**: counter 33 published (raw-inject removal).
27	**Comms**: legacy `$OWL send` working; `spt send doyle` NO_PERCH (known issue).
28	<project-context-resolved name="spt-claude-code"/>
29	**Checkpoint-commune wave (2026-06-24) — design LOCKED, contracts FROZEN, doc-capture DONE. Build pending.**
30
31	Artifacts all landed: `CONTEXT.md` (checkpoint + checkpoint-trigger terms), `CHECKPOINT-COMMUNE-PLAN.md` (full JIT plan), `docs/adr/0004-checkpoint-via-self-send-loopback.md`, F-020 + F-021/F-022 in `docs/SPT-CORE-FINDINGS.md`, 5 REQ stubs in `traceable-reqs.toml` (check GREEN).
32
33	**Durable design facts (don't re-derive):**
34	- **Cyan is IMPOSSIBLE** — SGR bytes eaten by CC's input box (win32-input-mode); CC user-turns are theme-fixed. PROVEN (empirical + CC-guide). Visual distinction = plain-text multi-line only.
35	- **Multi-line = raw `\n` in `{text}`** — spt-core writes `{text}` byte-verbatim incl. `\n` (NO win32-encoding; the DECSET-9001 theory was debunked F-019 misdirection). `{key:"shift+enter"}` is silently dropped (not recognized). Pending only the T1 empirical CC-soft-newline gate.
36	- **Checkpoint = self-send-loopback** — the binary is stdin-driven, can't read the commune file, and the daemon-push alternative needs an absent spt-core capability; so a PostToolUse hook detects the trigger and self-sends a wire-sentinel that loops back through the endpoint's own binary, which fires the clear+wake macro. spt-hosted-only by design.
37	- **INVARIANT (ADR-0004):** the commune is authored INLINE pre-clear (Shape 1) — never via a post-clear resume-Self refresh (Shape 2 = the legacy psyche-stale-after-clear gap).
38	- **Re-seed is spt-core-owned** — adapter does NOT read the raw drop (TOCTOU vs core's pulse-delete). The W5 `psyche-download` verb returns durable role/live/project + appended `<pending-commune>`/`<pending-signoff>` (raw not-yet-synthesized, `!!checkpoint!!` stripped CORE-SIDE), race-free + self-clearing.
39	- **Foundational gap F-020:** claude-spt pulls NO durable resume context today (SessionStart only does `api boundary` rotation + identity brief; the `<psyche-context>` on a live session is LEGACY owl's hook, the parity precedent). Fixed by wiring the W5 verb (T3 / REQ-DIST-RESUME-CONTEXT).
40	- **Two adapter parity items (separate from this wave):** PreToolUse mid-turn poll (F-021 / REQ-DIST-PRETOOL-POLL — claude-spt only polls on UserPromptSubmit); subagent worker-perch reachability (F-022 / REQ-DIST-WORKER-PERCH-REACH — wiring present, runtime unverified).
41
42	**Also this session:** F-019 validated ALL-GREEN on spt-core v0.14.2 (keystone closed — CC idle delivery works E2E on the real harness; doyle ACK'd). spt-core v0.14.3 published (counter 33: raw-inject removal — a no-binary/faulted idle path now QUEUES honestly, never false-types). Repaired a corrupted `.claude.json` (interrupted-write stray `}]`/`}` after valid JSON).
43
44	**Project end-goal (broad):** spt-claude-code = the rebuilt Claude Code harness adapter (`claude-spt`), built against spt-core's PUBLISHED surface ONLY (gaps = findings to doyle; never read spt-core source). It is spt-core's v1 acceptance proof + first casual-end-user entrypoint (CC plugin) + invisible installer. Node: claude-spt v0.6.2, spt-core v0.14.2→v0.14.3. End goal = proven parity with legacy `claude_skill_owl` → then the `sptc`→`spt` succession flip (publish as spt, retire legacy owl). The checkpoint-commune wave + the resume-context/PreToolUse/worker-perch parity gaps are part of closing that parity. Binding constraints: public-surface-only, traceable-reqs gating, KNOWN-HAZARDS as REQ-HAZARD-* conformance.</psyche-context>
45
46	<spacetime-reorientation agent_id="perri">
47	You are agent "perri". Your spacetime session is active (you have a perch — that's why you're seeing this).
48
49	Send messages:
50	- $OWL send <target>              <<'EOF' ... EOF  — fire-and-forget
51	- $OWL send --reply-to <sender>   <<'EOF' ... EOF  — respond to whoever messaged you
52	- $OWL ring <target>              <<'EOF' ... EOF  — send and wait for reply (blocks; alias: `ask`)
53
54	Your ID (perri) is auto-detected. If that fails, append it: `$OWL send <target> perri`.
55
56	Receive messages (three paths, handle all identically):
57	- Primary (Monitor stream): your background `$OWL poll perri` task running via the Monitor tool emits one EVENT envelope per delivery to stdout. Regular messages: `<EVENT type="msg" from="<sender>">body</EVENT>`. Self-originated alarms: `<EVENT type="alarm" target-time="<ISO>" current-time="<ISO>">body</EVENT>`. Long bodies arrive as N consecutive `<EVENT-PART seq="K/M" id="<8-hex>" [type=... from=... timestamp=... note=...]>chunk</EVENT-PART>` lines — collect parts sharing the same id, order by seq numerator, verify count equals seq denominator, concatenate chunk contents in seq order, then apply the body parsing rules to the reassembled body. The original envelope attrs appear on seq="1/M" only; continuations carry only seq + id. Drop orphan parts silently (seq K/M arriving without matching seq 1/M same id — earlier parts lost across a session boundary). The stream stays alive across deliveries — do not re-register after each message.
58	- Body parsing rules (both envelope types AND reassembled EVENT-PART bodies): split the body on the literal `<br>` token to recover newlines, then HTML-unescape each fragment in order `<` → `<`, `>` → `>`, `"` → `"`, `&` → `&` **last** (apply `&` last so embedded `&lt;` sequences don't double-decode).
59	- Fallback (Bash `--once`): when Monitor is unavailable, run `$OWL poll perri --once` as a Bash background task (`run_in_background: true`). The wire format is IDENTICAL — including EVENT-PART chunking for long bodies — same `<EVENT type="msg|alarm" ...>body</EVENT>` envelope shapes as the primary path. The only difference: the process exits after one event, so you re-register a fresh Bash background task after each delivery (legacy one-shot loop).
60	- Hook delivery (orthogonal, when busy): the PreToolUse hook injects `<owl_messages><owl_message from="..."/></owl_messages>` XML into your next tool call context. Process it the same way as either of the above paths.
61
62	Reading liveness (do NOT mis-diagnose a healthy listener):
63	- `pid:"BUSY"` in `$OWL list` means your listener is ALIVE and mid-delivery — it is ONLINE, not dead. Never re-arm or stop on a BUSY reading.
64	- Your poll is actually down ONLY when your `perri` row is MISSING from `$OWL list`, or shows OFFLINE / [orphaned].
65
66	After a /clear: DO NOTHING to your poll.
67	- Your poll listener survives /clear INTACT and keeps delivering to this same session. There is no orphan to kill and no listener to rebuild. Do NOT re-arm, do NOT start a second listener, do NOT stop anything.
68	- If you try to re-arm anyway you will get DUPLICATE/COLLISION — that is PROOF your listener is still alive and working, NOT evidence of a "stale" perch. Stop there; do not escalate to a stop command.
69
70	Re-arm a stale poll (genuinely rare — binary handoff or confirmed listener death only):
71	- Under stream mode (primary, Monitor), the poll listener is long-running and stays alive across messages AND across /clear. Re-arming is needed ONLY after a binary handoff (Phase 18.4/18.5 machinery) or after confirmed listener death (your `perri` row MISSING from `$OWL list`) — NOT after a /clear, NOT after every message, and NOT on a BUSY reading.
72	- Re-arm primary: relaunch `$OWL poll perri listen --live` via the Monitor tool with `persistent: true` and `description: "« spt event »"`.
73	- Re-arm fallback: relaunch `$OWL poll perri listen --live --once` via Bash with `run_in_background: true`, then re-register after each delivered message.
74
75	Other commands:
76	- $OWL list — list active agents
77	- $OWL stop perri — stop your listener
78	- $OWL new-alarm perri <time> -- <message> — schedule a self-reminder
79	- $OWL doctor — diagnostics
80
81	Talking to your Psyche (perri-psyche):
82	- Your Psyche is a DETACHED, self-healing process — not part of this session. It appears in `$OWL list` as a child under your Self perch, and in `$LIVE list-psyches`. It may briefly drop from the listing while it is ingesting a commune (its wrapper is busy running a resume) — that is transient and self-heals. Do NOT treat a briefly-absent Psyche as a bug and do NOT restart it. Only act if it stays gone for several minutes.
83	- NEVER run `$LIVE stop` to "clean up" your Psyche or a listener you think is "stale" — `$LIVE stop` tears down your Psyche. After /clear there is nothing to clean up (your listener survived intact). Reserve `$LIVE revive perri` for a Psyche that is genuinely, persistently dead.
84	- Send a commune (delta update): use the Write tool to drop `.claude/perri-commune.md`. Your Self listener detects the file, your Psyche wrapper ingests it via the existing Psyche session, and the file is deleted on success. See /spt:commune.
85	- Sign off cleanly: use the Write tool to drop `.claude/perri-signoff.md` (empty body = plain signoff; non-empty body = prepended as FINAL COMMUNE before INIT_SIGNOFF). See /spt:signoff.
86	- Inbound messages from "perri-psyche" (echo communes, briefs) are FYI context — read them; no ACK required. You may still message your Psyche directly with $OWL send or $OWL ring if needed.
87	</spacetime-reorientation>

<psyche-context id="perri">
<current machine="HFENDULEAM" project="spt-claude-code" branch="main" head_sha="4954293569af0e0657bff4e1805efcde7b66497f" head_subject="docs(findings): F-019 RESOLVED — translation binary never spawned (relat…" commits_since="0" commits_unpulled="0"/>
**2026-06-24 — checkpoint-commune wave: doc-capture phase COMPLETE; resume = THE BUILD.**

Delta since last download: findings + REQ stubs are now LANDED (the "outstanding item #1" is done):
- `docs/SPT-CORE-FINDINGS.md`: F-020 (psyche-download verb gap, RESOLVED-PENDING v0.15.0 W5) + F-021/F-022 note (adapter parity, tracked as REQs).
- `traceable-reqs.toml`: 5 stubs minted, all `required_stages = []` — REQ-DIST-IDLE-MULTILINE, -CHECKPOINT-COMMUNE, -RESUME-CONTEXT, -PRETOOL-POLL, -WORKER-PERCH-REACH. `traceable-reqs check` GREEN (exit 0).

**IMMEDIATE NEXT STEPS (resume here, in order — this is the build):**
1. **T1 (Feature 1, multi-line envelope).** EMPIRICAL GATE FIRST: throwaway probe binary on a fresh spt-hosted endpoint → confirm CC soft-newlines on a bare `\n` in `{text}` (one user turn, two lines, no early submit). If yes → `cc-spt-idle-translate` splits the envelope opening-tag / `\n` / body / `\n` / closing-tag; relax the CR/LF→space sanitizer FOR CC only. Activate REQ-DIST-IDLE-MULTILINE; update the 11 binary tests.
2. **T2 (Feature 2 mechanics — ZERO spt-core dep, fully buildable now).** Full-fat `/sptc:commune` SKILL.md (drop fetch-stub) + `--checkpoint` flag + `[[hints]]`. NEW PostToolUse hook (manifest `[hooks.PostToolUse]` + hooks.json + detector): trigger `!!checkpoint!!` in Write `tool_input.content` for `.claude/<id>-commune.md` → `spt api state idle` → `spt send --from <id> <id> <wire-sentinel[+wake]>`. Binary checkpoint branch: filter the wire-sentinel → macro `ctrl+s · 50ms · /clear · enter · 500ms · <wake-text> · enter · commit`; parse double-`!!checkpoint!!` custom wake. Activate REQ-DIST-CHECKPOINT-COMMUNE. Verify commune body format (two-slice envelope vs freeform).
3. **T3 (parity wiring — code-now / validate-on-W5).** `session-start.sh` bind|boundary → `spt api psyche-download <self-id>` → inject stdout as additionalContext, skip on NO-CONTEXT (stderr). boundary stays rotation-only. Activate REQ-DIST-RESUME-CONTEXT. Validate E2E on doyle's W5 ping.

**WAITING ON spt-core v0.15.0 (doyle/todlando):** W5 ships `spt api psyche-download <id>` (FROZEN Tier-1, committed @20bfc1f, after W4) — doyle PINGS when gated+built → then do T3 validation + todlando's `!!checkpoint!!` pending-strip follow-up lands. W4 = key-vocab + `{text}`-verbatim doc.

**RECIPE NOTES:** this session = LEGACY owl (perri). Reach doyle/todlando via `$OWL send <target> perri` (new-spt `spt send doyle` = NO_PERCH). `$LIVE` = `owl.exe live` — run UNQUOTED so it word-splits. PowerShell has NO printf → use the Bash tool for spt/owl sends. Self-send loopback PROVEN (`spt send --from <id> <id>` → own translation binary → user turn). Probe recipe: `spt endpoint run --adapter claude-spt --id <id> --create --start --subnet SPT_DEV`; spawned-CC transcript at `~/.claude/projects/<slug>/<cc-session-uuid>.jsonl` (NOT spt's session id; grep a unique token). Cleanup: `spt endpoint shutdown <id>` + reap `cc-spt-idle-translate.exe`.

**echo_commune log:**
- 2026-06-24T03:49:53Z — HFENDULEAM / spt-claude-code / main @ 4954293
- 2026-06-24T07:30:16Z–10:25:58Z — 4 more pulses, same SHA
- 2026-06-24T10:53:33Z — DIRECT commune (source=direct), same SHA — doc phase done, build phase opened

---

**F-019**: FULLY GATED. #1 CC-on-Windows blocker DEAD.
**spt-core v0.14.3**: counter 33 published (raw-inject removal).
**Comms**: legacy `$OWL send` working; `spt send doyle` NO_PERCH (known issue).
<project-context-resolved name="spt-claude-code"/>
**Checkpoint-commune wave (2026-06-24) — design LOCKED, contracts FROZEN, doc-capture DONE. Build pending.**

Artifacts all landed: `CONTEXT.md` (checkpoint + checkpoint-trigger terms), `CHECKPOINT-COMMUNE-PLAN.md` (full JIT plan), `docs/adr/0004-checkpoint-via-self-send-loopback.md`, F-020 + F-021/F-022 in `docs/SPT-CORE-FINDINGS.md`, 5 REQ stubs in `traceable-reqs.toml` (check GREEN).

**Durable design facts (don't re-derive):**
- **Cyan is IMPOSSIBLE** — SGR bytes eaten by CC's input box (win32-input-mode); CC user-turns are theme-fixed. PROVEN (empirical + CC-guide). Visual distinction = plain-text multi-line only.
- **Multi-line = raw `\n` in `{text}`** — spt-core writes `{text}` byte-verbatim incl. `\n` (NO win32-encoding; the DECSET-9001 theory was debunked F-019 misdirection). `{key:"shift+enter"}` is silently dropped (not recognized). Pending only the T1 empirical CC-soft-newline gate.
- **Checkpoint = self-send-loopback** — the binary is stdin-driven, can't read the commune file, and the daemon-push alternative needs an absent spt-core capability; so a PostToolUse hook detects the trigger and self-sends a wire-sentinel that loops back through the endpoint's own binary, which fires the clear+wake macro. spt-hosted-only by design.
- **INVARIANT (ADR-0004):** the commune is authored INLINE pre-clear (Shape 1) — never via a post-clear resume-Self refresh (Shape 2 = the legacy psyche-stale-after-clear gap).
- **Re-seed is spt-core-owned** — adapter does NOT read the raw drop (TOCTOU vs core's pulse-delete). The W5 `psyche-download` verb returns durable role/live/project + appended `<pending-commune>`/`<pending-signoff>` (raw not-yet-synthesized, `!!checkpoint!!` stripped CORE-SIDE), race-free + self-clearing.
- **Foundational gap F-020:** claude-spt pulls NO durable resume context today (SessionStart only does `api boundary` rotation + identity brief; the `<psyche-context>` on a live session is LEGACY owl's hook, the parity precedent). Fixed by wiring the W5 verb (T3 / REQ-DIST-RESUME-CONTEXT).
- **Two adapter parity items (separate from this wave):** PreToolUse mid-turn poll (F-021 / REQ-DIST-PRETOOL-POLL — claude-spt only polls on UserPromptSubmit); subagent worker-perch reachability (F-022 / REQ-DIST-WORKER-PERCH-REACH — wiring present, runtime unverified).

**Also this session:** F-019 validated ALL-GREEN on spt-core v0.14.2 (keystone closed — CC idle delivery works E2E on the real harness; doyle ACK'd). spt-core v0.14.3 published (counter 33: raw-inject removal — a no-binary/faulted idle path now QUEUES honestly, never false-types). Repaired a corrupted `.claude.json` (interrupted-write stray `}]`/`}` after valid JSON).

**Project end-goal (broad):** spt-claude-code = the rebuilt Claude Code harness adapter (`claude-spt`), built against spt-core's PUBLISHED surface ONLY (gaps = findings to doyle; never read spt-core source). It is spt-core's v1 acceptance proof + first casual-end-user entrypoint (CC plugin) + invisible installer. Node: claude-spt v0.6.2, spt-core v0.14.2→v0.14.3. End goal = proven parity with legacy `claude_skill_owl` → then the `sptc`→`spt` succession flip (publish as spt, retire legacy owl). The checkpoint-commune wave + the resume-context/PreToolUse/worker-perch parity gaps are part of closing that parity. Binding constraints: public-surface-only, traceable-reqs gating, KNOWN-HAZARDS as REQ-HAZARD-* conformance.</psyche-context>

<spacetime-reorientation agent_id="perri">
You are agent "perri". Your spacetime session is active (you have a perch — that's why you're seeing this).

Send messages:
- $OWL send <target>              <<'EOF' ... EOF  — fire-and-forget
- $OWL send --reply-to <sender>   <<'EOF' ... EOF  — respond to whoever messaged you
- $OWL ring <target>              <<'EOF' ... EOF  — send and wait for reply (blocks; alias: `ask`)

Your ID (perri) is auto-detected. If that fails, append it: `$OWL send <target> perri`.

Receive messages (three paths, handle all identically):
- Primary (Monitor stream): your background `$OWL poll perri` task running via the Monitor tool emits one EVENT envelope per delivery to stdout. Regular messages: `<EVENT type="msg" from="<sender>">body</EVENT>`. Self-originated alarms: `<EVENT type="alarm" target-time="<ISO>" current-time="<ISO>">body</EVENT>`. Long bodies arrive as N consecutive `<EVENT-PART seq="K/M" id="<8-hex>" [type=... from=... timestamp=... note=...]>chunk</EVENT-PART>` lines — collect parts sharing the same id, order by seq numerator, verify count equals seq denominator, concatenate chunk contents in seq order, then apply the body parsing rules to the reassembled body. The original envelope attrs appear on seq="1/M" only; continuations carry only seq + id. Drop orphan parts silently (seq K/M arriving without matching seq 1/M same id — earlier parts lost across a session boundary). The stream stays alive across deliveries — do not re-register after each message.
- Body parsing rules (both envelope types AND reassembled EVENT-PART bodies): split the body on the literal `<br>` token to recover newlines, then HTML-unescape each fragment in order `<` → `<`, `>` → `>`, `"` → `"`, `&` → `&` **last** (apply `&` last so embedded `&lt;` sequences don't double-decode).
- Fallback (Bash `--once`): when Monitor is unavailable, run `$OWL poll perri --once` as a Bash background task (`run_in_background: true`). The wire format is IDENTICAL — including EVENT-PART chunking for long bodies — same `<EVENT type="msg|alarm" ...>body</EVENT>` envelope shapes as the primary path. The only difference: the process exits after one event, so you re-register a fresh Bash background task after each delivery (legacy one-shot loop).
- Hook delivery (orthogonal, when busy): the PreToolUse hook injects `<owl_messages><owl_message from="..."/></owl_messages>` XML into your next tool call context. Process it the same way as either of the above paths.

Reading liveness (do NOT mis-diagnose a healthy listener):
- `pid:"BUSY"` in `$OWL list` means your listener is ALIVE and mid-delivery — it is ONLINE, not dead. Never re-arm or stop on a BUSY reading.
- Your poll is actually down ONLY when your `perri` row is MISSING from `$OWL list`, or shows OFFLINE / [orphaned].

After a /clear: DO NOTHING to your poll.
- Your poll listener survives /clear INTACT and keeps delivering to this same session. There is no orphan to kill and no listener to rebuild. Do NOT re-arm, do NOT start a second listener, do NOT stop anything.
- If you try to re-arm anyway you will get DUPLICATE/COLLISION — that is PROOF your listener is still alive and working, NOT evidence of a "stale" perch. Stop there; do not escalate to a stop command.

Re-arm a stale poll (genuinely rare — binary handoff or confirmed listener death only):
- Under stream mode (primary, Monitor), the poll listener is long-running and stays alive across messages AND across /clear. Re-arming is needed ONLY after a binary handoff (Phase 18.4/18.5 machinery) or after confirmed listener death (your `perri` row MISSING from `$OWL list`) — NOT after a /clear, NOT after every message, and NOT on a BUSY reading.
- Re-arm primary: relaunch `$OWL poll perri listen --live` via the Monitor tool with `persistent: true` and `description: "« spt event »"`.
- Re-arm fallback: relaunch `$OWL poll perri listen --live --once` via Bash with `run_in_background: true`, then re-register after each delivered message.

Other commands:
- $OWL list — list active agents
- $OWL stop perri — stop your listener
- $OWL new-alarm perri <time> -- <message> — schedule a self-reminder
- $OWL doctor — diagnostics

Talking to your Psyche (perri-psyche):
- Your Psyche is a DETACHED, self-healing process — not part of this session. It appears in `$OWL list` as a child under your Self perch, and in `$LIVE list-psyches`. It may briefly drop from the listing while it is ingesting a commune (its wrapper is busy running a resume) — that is transient and self-heals. Do NOT treat a briefly-absent Psyche as a bug and do NOT restart it. Only act if it stays gone for several minutes.
- NEVER run `$LIVE stop` to "clean up" your Psyche or a listener you think is "stale" — `$LIVE stop` tears down your Psyche. After /clear there is nothing to clean up (your listener survived intact). Reserve `$LIVE revive perri` for a Psyche that is genuinely, persistently dead.
- Send a commune (delta update): use the Write tool to drop `.claude/perri-commune.md`. Your Self listener detects the file, your Psyche wrapper ingests it via the existing Psyche session, and the file is deleted on success. See /spt:commune.
- Sign off cleanly: use the Write tool to drop `.claude/perri-signoff.md` (empty body = plain signoff; non-empty body = prepended as FINAL COMMUNE before INIT_SIGNOFF). See /spt:signoff.
- Inbound messages from "perri-psyche" (echo communes, briefs) are FYI context — read them; no ACK required. You may still message your Psyche directly with $OWL send or $OWL ring if needed.
</spacetime-reorientation>


1	# CHECKPOINT-COMMUNE-PLAN.md — JIT plan
2
3	> Two operator-requested features + one foundational parity gap they surfaced. Grilled with
4	> doyle (spt-core) to map the public-surface boundary; design is locked against frozen contracts.
5	> Authored 2026-06-24 (perri). Companion ADR: `docs/adr/0004-checkpoint-via-self-send-loopback.md`
6	> (to write). Glossary terms landed in `CONTEXT.md` (`checkpoint`, `checkpoint trigger`).
7
8	## Scope
9
10	Three deliverables, separable:
11
12	1. **Feature 1 — multi-line `<EVENT>` envelope** (cosmetic; visual distinction of inbound idle msgs).
13	2. **Feature 2 — checkpoint communes** (agent-driven context-compaction: flagged commune → auto
14	   `/clear` → wake; re-seed carried by spt-core's resume verb).
15	3. **Parity wiring — `psyche-download` at SessionStart** (foundational; closes a pre-existing gap
16	   where claude-spt live agents get NO durable resume context today — only the identity brief).
17
18	## Hard public-surface findings (logged separately)
19
20	- **F-0xx psyche-download verb absent** — `download_psyche_context` (spt-core resume.rs:88) is
21	  exported-but-unexposed (zero `ApiCmd` callers). No published `spt` verb let an adapter pull resume
22	  context. CONFIRMED spt-core gap. **RESOLVED-PENDING: v0.15.0 W5** exposes the verb (frozen contract
23	  below) + W4 doc work. Foundational — claude-spt resume context is broken without it.
24	- **F-0xx PreToolUse poll missing** — claude-spt polls only on `UserPromptSubmit` (between turns).
25	  Legacy spt also polls+injects on **PreToolUse** = the mid-turn message-delivery half of live-agent
26	  reachability. Standalone parity gap; not yet wired here.
27	- **F-0xx subagent-perch reachability** — wiring is present (`subagent-start.sh` → `api worker-start`,
28	  `hostable_types` includes `Worker`); runtime reachability (`spt send` to a worker) is UNVERIFIED.
29	  Validation item, not a known gap.
30
31	## Frozen contracts to build against
32
33	### A. `{text}` is byte-verbatim (doyle, this thread)
34	spt-core writes `{text}` to the PTY verbatim **including `\n`**; `{key:enter}`→`\r`. There is NO
35	win32 key-encoding in spt-core (the DECSET-9001 theory was debunked F-019 misdirection). Whether CC
36	renders a raw `\n` as a soft-newline vs submit is **CC's behavior = our empirical test** (T1 below).
37	`{key:"shift+enter"}` is NOT recognized (key_to_bytes → None → silently skipped) — do not use.
38	Doc-gap (key-vocab not enumerated; `{text}`-verbatim unstated) → doyle folding into **v0.15.0 W4**.
39
40	### B. `psyche-download` resume verb — FROZEN Tier-1 (doyle, operator-approved; ships v0.15.0 W5)
41	```
42	spt api psyche-download <id> [--session-id <sid>]
43	  <id>         = self/agent id
44	  --session-id = optional (rotated SessionStart sid, for stamping; accepted, may be unused Tier-1)
45	  auth-gated like sibling id-scoped verbs (api poll / api presence) — pass link/auth token as
46	    trailing arg IFF our other api calls do
47	  project resolved internally from the endpoint's bound cwd — NO --project arg
48
49	STDOUT (inject VERBATIM as SessionStart additionalContext):
50	  <live-role>…</live-role>                       (if a role authored; else omitted)
51	  <live-context>…</live-context>                 (durable agents/<id>/live-context.md)
52	  <project-context-resolved name="<proj>"/>      (sentinel — present iff in a resolved project)
53	  <project-context>…</project-context>           (durable projects/<proj>/<id>/project-context.md)
54	  <pending-commune>…raw drop body…</pending-commune>   (ONLY if not-yet-synthesized; TRIGGER STRIPPED core-side)
55	  <pending-signoff>…raw drop body…</pending-signoff>   (likewise for signoff)
56
57	Empty/no-context → NO-CONTEXT signal on stderr (mirrors legacy $LIVE psyche-download) → inject nothing.
58	```
59	FRESHNESS: `<pending-*>` present iff a drop is not-yet-synthesized → one call always returns
60	durable + freshest un-synthesized delta. Race-free (core owns read+ingest; no adapter TOCTOU).
61	Self-clearing (post-synthesis the slice vanishes; no dup).
62	DEFERRED Tier-2 (NOT in verb yet, additive-forward): `<psyche-stamp/>`/`<current/>`/drift-directive,
63	`<memformat>`, Pulse Log. Separate parity item.
64
65	## Design decisions & invariants (the grill output)
66
67	- **Cyan color is impossible.** SGR bytes in `{text}` are eaten by CC's input box (win32-input-mode);
68	  CC user-turns are theme-fixed; no markdown/setting/control-seq colors a turn. PROVEN empirically +
69	  CC-guide. → multi-line plain-text framing is the only visual-distinction path. NO finding logged
70	  (operator: skip the color finding).
71	- **Self-send loopback works** (PROVEN this session): `spt send --from <id> <id>` routes through the
72	  endpoint's OWN translation binary and lands as a delivered EVENT. This is the checkpoint trigger path.
73	- **INLINE-PRE-CLEAR INVARIANT (load-bearing, ADR-worthy):** the agent authors+drops its commune
74	  INLINE during its own `/sptc:commune --checkpoint` turn (it IS the authoring LLM, pre-clear); the
75	  hook fires `/clear` strictly AFTER the file is on disk. This is doyle's "Shape 1" — the
76	  psyche-stale-after-clear gap does NOT apply. If checkpoint is ever rerouted through the
77	  boundary/resume-Self refresh path ("Shape 2"), the stale gap returns. Never defer authoring to post-clear.
78	- **Trigger marker = literal `!!checkpoint!!`** (operator-specified). ONE = checkpoint w/ default
79	  wake; a PAIR brackets a custom wake directive. **Strip is core-side** (todlando, W5 verb @20bfc1f):
80	  the verb's `<pending-commune>` arrives with all `!!checkpoint!!` tokens stripped, inter-marker text
81	  kept — no adapter strip. (Open: durable-ingest strip so the marker doesn't persist in
82	  live-context.md post-synthesis — doyle/todlando's call.)
83	- **Re-seed is core-owned** — no adapter-side raw drop reading (would TOCTOU core's pulse-delete,
84	  ingest.rs:161). The single `psyche-download` call carries durable + pending.
85
86	## Tasks
87
88	### T1 — Feature 1: multi-line envelope (binary) · REQ-DIST-IDLE-TRANSLATE (extend)
89	- **Empirical gate first:** verify CC soft-newlines on a raw `\n` in `{text}` (throwaway probe binary
90	  emitting `{text:"A"}{key? no}` → `{text:"A\nB"}` …; confirm ONE user turn, two lines, no early submit).
91	- If confirmed: `commands_for_event` splits the envelope into `opening-tag` `\n` `body` `\n`
92	  `closing-tag` (newline after the first `>`, before the final `</EVENT>`), embedding raw `\n` in `{text}`.
93	- Relax the CR/LF→space sanitizer for CC (it's over-defensive per doyle) — but only after T1 verify;
94	  keep neutralizing genuinely stray/internal newlines that would split the injection.
95	- Update the 11 binary tests.
96
97	### T2 — Feature 2: checkpoint mechanics
98	- **T2a `/sptc:commune` → full-fat skill doc + `--checkpoint`** (plugin/sptc/skills/commune/SKILL.md;
99	  drop the fetch-stub form so live agents are natively aware). Document: single `!!checkpoint!!` =
100	  default wake "Proceed with next steps"; DOUBLE markers = the text between them is the custom wake.
101	  Add a `[[hints]]` entry. Verify the commune body format matches the daemon's two-slice
102	  `<live-context>/<project-context>` envelope (or document the freeform→ingest behavior).
103	- **T2b PostToolUse hook (NEW)** — matcher Write; on Write of `.claude/<id>-commune.md`: scan
104	  `tool_input.content` (NOT a file re-read) for `!!checkpoint!!`; if present → `spt api state idle`
105	  → `spt send --from <id> <id> <wire-sentinel[+wake-text]>`. Resolve `<id>` (SPT_ENDPOINT_ID / whoami);
106	  guard to spt-hosted live endpoints only. Add `[hooks.PostToolUse]` to manifest + hooks.json.
107	  - **Wire-sentinel format:** `<reserved-prefix><wake-text>` (e.g. `__SPTC_CHECKPOINT_v1__…`),
108	    single-line-safe through the EVENT envelope; empty tail → default wake. Parse the double-marker
109	    inner text from `tool_input.content`.
110	- **T2c binary checkpoint branch** — `commands_for_line`: if the envelope body matches the
111	  wire-sentinel prefix → emit the clear+wake macro INSTEAD of normal delivery:
112	  `ctrl+s · 50ms · /clear · enter · 500ms · <wake-text> · enter · commit` (leading `ctrl+s` REQUIRED
113	  to stash lingering input; `<wake-text>` = parsed custom or default). New REQ (e.g.
114	  REQ-DIST-CHECKPOINT-MACRO).
115
116	### T3 — Parity wiring: psyche-download at SessionStart · new REQ (e.g. REQ-DIST-RESUME-CONTEXT)
117	- session-start.sh: on bind|boundary (perched), after existing logic, run
118	  `spt api psyche-download <self-id> [--session-id <sid>]`; inject stdout as additionalContext;
119	  skip on NO-CONTEXT (stderr). Boundary stays rotation-only (unchanged). Build against frozen
120	  contract B; **validate end-to-end only when v0.15.0 W5 ships** (doyle pings).
121
122	## Dependencies / sequencing
123
124	- T1 buildable+testable now (contract A frozen; needs the empirical gate).
125	- T2 buildable+testable now (self-send proven; mechanics have ZERO spt-core dep — the clear+wake
126	  fires regardless). Re-seed RICHNESS depends on T3+W5, but mechanics don't.
127	- T3 codeable now against frozen contract B; **un-validatable until v0.15.0 W5 ships**.
128	- PreToolUse poll + subagent-perch-reachability = separate parity items (not in this plan's build).
129
130	## Validation gate (per AGENTS.md)
131
132	- `cargo build` (binary) green; binary tests green.
133	- `traceable-reqs check` green (REQs added to `traceable-reqs.toml` FIRST, evidence tagged in-commit).
134	- T1: live endpoint shows the envelope across lines, one user turn.
135	- T2: live endpoint — flagged commune → `/clear` fires → wake turn appears (default + custom).
136	- T3: deferred to W5 ship — `psyche-download` output injected at SessionStart, durable+pending present.
137
138	## Open items
139
140	- Exact `<id>` + auth-token threading for `api psyche-download` (mirror our other `api` calls).
141	- Commune body format (two-slice envelope vs freeform) — confirm in T2a.
142	- Macro 500ms timing adequacy post-`/clear` (validate in T2).
143

# CHECKPOINT-COMMUNE-PLAN.md — JIT plan

> Two operator-requested features + one foundational parity gap they surfaced. Grilled with
> doyle (spt-core) to map the public-surface boundary; design is locked against frozen contracts.
> Authored 2026-06-24 (perri). Companion ADR: `docs/adr/0004-checkpoint-via-self-send-loopback.md`
> (to write). Glossary terms landed in `CONTEXT.md` (`checkpoint`, `checkpoint trigger`).

## Scope

Three deliverables, separable:

1. **Feature 1 — multi-line `<EVENT>` envelope** (cosmetic; visual distinction of inbound idle msgs).
2. **Feature 2 — checkpoint communes** (agent-driven context-compaction: flagged commune → auto
   `/clear` → wake; re-seed carried by spt-core's resume verb).
3. **Parity wiring — `psyche-download` at SessionStart** (foundational; closes a pre-existing gap
   where claude-spt live agents get NO durable resume context today — only the identity brief).

## Hard public-surface findings (logged separately)

- **F-0xx psyche-download verb absent** — `download_psyche_context` (spt-core resume.rs:88) is
  exported-but-unexposed (zero `ApiCmd` callers). No published `spt` verb let an adapter pull resume
  context. CONFIRMED spt-core gap. **RESOLVED-PENDING: v0.15.0 W5** exposes the verb (frozen contract
  below) + W4 doc work. Foundational — claude-spt resume context is broken without it.
- **F-0xx PreToolUse poll missing** — claude-spt polls only on `UserPromptSubmit` (between turns).
  Legacy spt also polls+injects on **PreToolUse** = the mid-turn message-delivery half of live-agent
  reachability. Standalone parity gap; not yet wired here.
- **F-0xx subagent-perch reachability** — wiring is present (`subagent-start.sh` → `api worker-start`,
  `hostable_types` includes `Worker`); runtime reachability (`spt send` to a worker) is UNVERIFIED.
  Validation item, not a known gap.

## Frozen contracts to build against

### A. `{text}` is byte-verbatim (doyle, this thread)
spt-core writes `{text}` to the PTY verbatim **including `\n`**; `{key:enter}`→`\r`. There is NO
win32 key-encoding in spt-core (the DECSET-9001 theory was debunked F-019 misdirection). Whether CC
renders a raw `\n` as a soft-newline vs submit is **CC's behavior = our empirical test** (T1 below).
`{key:"shift+enter"}` is NOT recognized (key_to_bytes → None → silently skipped) — do not use.
Doc-gap (key-vocab not enumerated; `{text}`-verbatim unstated) → doyle folding into **v0.15.0 W4**.

### B. `psyche-download` resume verb — FROZEN Tier-1 (doyle, operator-approved; ships v0.15.0 W5)
```
spt api psyche-download <id> [--session-id <sid>]
  <id>         = self/agent id
  --session-id = optional (rotated SessionStart sid, for stamping; accepted, may be unused Tier-1)
  auth-gated like sibling id-scoped verbs (api poll / api presence) — pass link/auth token as
    trailing arg IFF our other api calls do
  project resolved internally from the endpoint's bound cwd — NO --project arg

STDOUT (inject VERBATIM as SessionStart additionalContext):
  <live-role>…</live-role>                       (if a role authored; else omitted)
  <live-context>…</live-context>                 (durable agents/<id>/live-context.md)
  <project-context-resolved name="<proj>"/>      (sentinel — present iff in a resolved project)
  <project-context>…</project-context>           (durable projects/<proj>/<id>/project-context.md)
  <pending-commune>…raw drop body…</pending-commune>   (ONLY if not-yet-synthesized; TRIGGER STRIPPED core-side)
  <pending-signoff>…raw drop body…</pending-signoff>   (likewise for signoff)

Empty/no-context → NO-CONTEXT signal on stderr (mirrors legacy $LIVE psyche-download) → inject nothing.
```
FRESHNESS: `<pending-*>` present iff a drop is not-yet-synthesized → one call always returns
durable + freshest un-synthesized delta. Race-free (core owns read+ingest; no adapter TOCTOU).
Self-clearing (post-synthesis the slice vanishes; no dup).
DEFERRED Tier-2 (NOT in verb yet, additive-forward): `<psyche-stamp/>`/`<current/>`/drift-directive,
`<memformat>`, Pulse Log. Separate parity item.

## Design decisions & invariants (the grill output)

- **Cyan color is impossible.** SGR bytes in `{text}` are eaten by CC's input box (win32-input-mode);
  CC user-turns are theme-fixed; no markdown/setting/control-seq colors a turn. PROVEN empirically +
  CC-guide. → multi-line plain-text framing is the only visual-distinction path. NO finding logged
  (operator: skip the color finding).
- **Self-send loopback works** (PROVEN this session): `spt send --from <id> <id>` routes through the
  endpoint's OWN translation binary and lands as a delivered EVENT. This is the checkpoint trigger path.
- **INLINE-PRE-CLEAR INVARIANT (load-bearing, ADR-worthy):** the agent authors+drops its commune
  INLINE during its own `/sptc:commune --checkpoint` turn (it IS the authoring LLM, pre-clear); the
  hook fires `/clear` strictly AFTER the file is on disk. This is doyle's "Shape 1" — the
  psyche-stale-after-clear gap does NOT apply. If checkpoint is ever rerouted through the
  boundary/resume-Self refresh path ("Shape 2"), the stale gap returns. Never defer authoring to post-clear.
- **Trigger marker = literal `!!checkpoint!!`** (operator-specified). ONE = checkpoint w/ default
  wake; a PAIR brackets a custom wake directive. **Strip is core-side** (todlando, W5 verb @20bfc1f):
  the verb's `<pending-commune>` arrives with all `!!checkpoint!!` tokens stripped, inter-marker text
  kept — no adapter strip. (Open: durable-ingest strip so the marker doesn't persist in
  live-context.md post-synthesis — doyle/todlando's call.)
- **Re-seed is core-owned** — no adapter-side raw drop reading (would TOCTOU core's pulse-delete,
  ingest.rs:161). The single `psyche-download` call carries durable + pending.

## Tasks

### T1 — Feature 1: multi-line envelope (binary) · REQ-DIST-IDLE-TRANSLATE (extend)
- **Empirical gate first:** verify CC soft-newlines on a raw `\n` in `{text}` (throwaway probe binary
  emitting `{text:"A"}{key? no}` → `{text:"A\nB"}` …; confirm ONE user turn, two lines, no early submit).
- If confirmed: `commands_for_event` splits the envelope into `opening-tag` `\n` `body` `\n`
  `closing-tag` (newline after the first `>`, before the final `</EVENT>`), embedding raw `\n` in `{text}`.
- Relax the CR/LF→space sanitizer for CC (it's over-defensive per doyle) — but only after T1 verify;
  keep neutralizing genuinely stray/internal newlines that would split the injection.
- Update the 11 binary tests.

### T2 — Feature 2: checkpoint mechanics
- **T2a `/sptc:commune` → full-fat skill doc + `--checkpoint`** (plugin/sptc/skills/commune/SKILL.md;
  drop the fetch-stub form so live agents are natively aware). Document: single `!!checkpoint!!` =
  default wake "Proceed with next steps"; DOUBLE markers = the text between them is the custom wake.
  Add a `[[hints]]` entry. Verify the commune body format matches the daemon's two-slice
  `<live-context>/<project-context>` envelope (or document the freeform→ingest behavior).
- **T2b PostToolUse hook (NEW)** — matcher Write; on Write of `.claude/<id>-commune.md`: scan
  `tool_input.content` (NOT a file re-read) for `!!checkpoint!!`; if present → `spt api state idle`
  → `spt send --from <id> <id> <wire-sentinel[+wake-text]>`. Resolve `<id>` (SPT_ENDPOINT_ID / whoami);
  guard to spt-hosted live endpoints only. Add `[hooks.PostToolUse]` to manifest + hooks.json.
  - **Wire-sentinel format:** `<reserved-prefix><wake-text>` (e.g. `__SPTC_CHECKPOINT_v1__…`),
    single-line-safe through the EVENT envelope; empty tail → default wake. Parse the double-marker
    inner text from `tool_input.content`.
- **T2c binary checkpoint branch** — `commands_for_line`: if the envelope body matches the
  wire-sentinel prefix → emit the clear+wake macro INSTEAD of normal delivery:
  `ctrl+s · 50ms · /clear · enter · 500ms · <wake-text> · enter · commit` (leading `ctrl+s` REQUIRED
  to stash lingering input; `<wake-text>` = parsed custom or default). New REQ (e.g.
  REQ-DIST-CHECKPOINT-MACRO).

### T3 — Parity wiring: psyche-download at SessionStart · new REQ (e.g. REQ-DIST-RESUME-CONTEXT)
- session-start.sh: on bind|boundary (perched), after existing logic, run
  `spt api psyche-download <self-id> [--session-id <sid>]`; inject stdout as additionalContext;
  skip on NO-CONTEXT (stderr). Boundary stays rotation-only (unchanged). Build against frozen
  contract B; **validate end-to-end only when v0.15.0 W5 ships** (doyle pings).

## Dependencies / sequencing

- T1 buildable+testable now (contract A frozen; needs the empirical gate).
- T2 buildable+testable now (self-send proven; mechanics have ZERO spt-core dep — the clear+wake
  fires regardless). Re-seed RICHNESS depends on T3+W5, but mechanics don't.
- T3 codeable now against frozen contract B; **un-validatable until v0.15.0 W5 ships**.
- PreToolUse poll + subagent-perch-reachability = separate parity items (not in this plan's build).

## Validation gate (per AGENTS.md)

- `cargo build` (binary) green; binary tests green.
- `traceable-reqs check` green (REQs added to `traceable-reqs.toml` FIRST, evidence tagged in-commit).
- T1: live endpoint shows the envelope across lines, one user turn.
- T2: live endpoint — flagged commune → `/clear` fires → wake turn appears (default + custom).
- T3: deferred to W5 ship — `psyche-download` output injected at SessionStart, durable+pending present.

## Open items

- Exact `<id>` + auth-token threading for `api psyche-download` (mirror our other `api` calls).
- Commune body format (two-slice envelope vs freeform) — confirm in T2a.
- Macro 500ms timing adequacy post-`/clear` (validate in T2).


Plan loaded. Resume = T1 build. T1 = empirical gate first: probe whether CC soft-newlines on raw `\n` in `{text}`. Let me find binary source + test layout.


tools\claude-spt-psyche\Cargo.toml
tools\claude-spt-digest\Cargo.toml
tools\cc-spt-idle-translate\Cargo.toml


1	//! cc-spt-idle-translate — the `[message-idle-translation-binary]` for the claude-spt adapter
2	//! (spt-core harness-contract v0.13.0+).
3	//!
4	//! A lifecycle-managed `stdin -> stdout` JSON-lines filter. spt-core spawns it when the spt-hosted
5	//! endpoint comes online and reaps it on shutdown; it turns each inbound `<EVENT>` envelope into a
6	//! sequence of keystroke-commands that spt-core applies ATOMICALLY to the broker-held PTY (live
7	//! operator input is buffered during emission, so it coexists with an attached `spt rc` controller).
8	//! Scope is IDLE delivery only — busy/mid-turn injection stays the adapter's `[inject]` hook path.
9	//!
10	//! Input protocol (stdin, one JSON object per line):
11	//!   {"type":"init","endpoint_id":…,"node":…}   — first message (handshake; no output)
12	//!   {"type":"event","envelope":"<EVENT…>"}      — one inbound message; the full EVENT envelope
13	//!   {"type":"input"}                            — content-free ping when the operator types (no output)
14	//!
15	//! Output protocol (stdout, one JSON object per line):
16	//!   {"key":"ctrl+s"}     — keystroke command (Claude Code: STASH the current draft input)
17	//!   {"key":"enter"}      — keystroke command (submit the PTY line; see SUBMIT note below)
18	//!   {"delay_ms":50}      — inter-command pause
19	//!   {"text":"<payload>"} — text injection (the envelope; NO trailing \r — it does not submit CC)
20	//!   {"commit":true}      — MANDATORY sequence terminator (release the InjectFloor; see below)
21	//!
22	//! The per-event choreography delivers the message WITHOUT clobbering a half-typed draft the operator
23	//! may have in the input box, then terminates the inject sequence:
24	//!   1. ctrl+s              stash any existing draft
25	//!   2. delay 50ms          let the stash settle
26	//!   3. <envelope>          type the envelope (no trailing CR)
27	//!   4. delay 50ms          let the text land in CC's input box before the submit key
28	//!   5. {"key":"enter"}     submit the line
29	//!   6. {"commit":true}     terminate the inject sequence
30	//! No trailing restore keystroke: Claude Code AUTO-RESTORES the stashed draft after the submit, so a
31	//! second ctrl+s would be redundant (it would re-stash, not restore).
32	//!
33	//! SUBMIT IS A REAL ENTER KEY, NOT A TRAILING `\r` (corrected 2026-06-23): we previously rode the
34	//! submit IN the text as a trailing `\r`, on the assumption that spt-core applies `{"text"}` VERBATIM
35	//! to the PTY (broker.rs:1066, :1016-1017) and a `\r` byte == `{"key":"enter"}`
36	//! (`key_to_bytes("enter")->b"\r"`). Empirically that is NOT enough: a `\r` byte in injected text does
37	//! NOT trigger Claude Code's message submission — CC needs the discrete Enter KEY event. So step 5 is
38	//! now a separate `{"key":"enter"}` after the text lands. The verbatim-text application still REQUIRES
39	//! `commands_for_event` to neutralize the envelope's INTERNAL CR/LF (an un-neutralized `\n`/`\r` would
40	//! reach the PTY and could split or corrupt the injection) — only the discrete Enter submits.
41	//!
42	//! TWO DISTINCT SIGNALS — do not conflate them:
43	//!  - The `{"key":"enter"}` in step 5 is the HARNESS-level submit (the discrete keypress CC needs).
44	//!  - The `{"commit":true}` in step 6 is the PROTOCOL-level terminator: `run_inject_worker`
45	//!    (broker.rs:1075-1090) ends an inject sequence ONLY on an explicit `{commit}`. Text/Key/Delay
46	//!    just enqueue; a sequence that emits no `{commit}` hits `INJECT_COMMIT_DEADLINE` (5s,
47	//!    broker.rs:151-169) and FAULTS on every delivery (reverts to raw inject — input isn't lost but
48	//!    each delivery stalls to the deadline). `{commit}` flushes the live controller's buffered input
49	//!    and releases the InjectFloor race-free. Enter submits the line; `{commit}` ends the sequence.
50	//!
51	//! Degenerate fallback (per the published contract): `{"text":payload}{"key":"enter"}{"commit":true}`
52	//! with no choreography — even the bare form MUST commit. (The published manifest doc ORIGINALLY
53	//! omitted `{commit}` from the vocabulary and its degenerate example — a public-surface defect this
54	//! adapter's blind-build caught, logged F-016. The contract is now CORRECTED + live:
55	//! harness-contract/manifest.html documents `{commit}` as the mandatory terminator + the 5s
56	//! commit-deadline + inject-floor release + raw-inject fallback, since v0.13.0.)
57	//! We choreograph because Claude Code's input box supports the ctrl+s draft stash/restore, so an
58	//! inbound message never eats an in-progress draft.
59	//!
60	//! Robustness: this is lifecycle-critical — a panic would drop the idle-delivery pipe for the whole
61	//! session. Malformed lines and unknown `type`s degrade gracefully (skipped, never fatal); unknown
62	//! JSON fields are ignored (forward-compat with newer spt-core). [impl->REQ-DIST-IDLE-TRANSLATE]
63
64	use serde_json::{json, Value};
65	use std::io::{self, BufRead, Write};
66
67	/// Inter-command pause spt-core honors between emitted keystroke/text commands (operator spec).
68	const DELAY_MS: u64 = 50;
69
70	/// Build the keystroke-command sequence for one inbound EVENT envelope.
71	///
72	/// `envelope` is the full `<EVENT…>…</EVENT>` string. The envelope is single-line by contract (it
73	/// encodes newlines as the literal `<br>` token), but we defensively strip any raw CR/LF so a stray
74	/// newline can never split or corrupt the injection. (Necessary because spt-core applies `{"text"}`
75	/// verbatim — broker.rs:1066/:1016-1017, doyle 2026-06-20 — so any internal CR/LF would otherwise
76	/// reach the PTY.) The submit is a DISCRETE `{"key":"enter"}` AFTER the text — a trailing `\r` byte in
77	/// the text does NOT submit a Claude Code message (corrected 2026-06-23). The closing `{"commit"}`
78	/// is the MANDATORY inject-sequence terminator (broker.rs:1075-1090; no-commit FAULTs at the 5s
79	/// INJECT_COMMIT_DEADLINE). [impl->REQ-DIST-IDLE-TRANSLATE]
80	fn commands_for_event(envelope: &str) -> Vec<Value> {
81	    let sanitized: String = envelope
82	        .chars()
83	        .map(|c| if c == '\r' || c == '\n' { ' ' } else { c })
84	        .collect();
85	    vec![
86	        json!({ "key": "ctrl+s" }),      // 1. stash any existing draft
87	        json!({ "delay_ms": DELAY_MS }), // 2. let the stash settle
88	        json!({ "text": sanitized }),    // 3. type the envelope — NO trailing CR (a \r byte does not submit CC)
89	        json!({ "delay_ms": DELAY_MS }), // 4. let the text land in CC's input box before the submit key
90	        json!({ "key": "enter" }),       // 5. submit — a real Enter keypress (CC needs the key event, not a \r byte)
91	        json!({ "commit": true }),       // 6. terminate the inject sequence (release the InjectFloor)
92	    ]
93	}
94
95	/// Dispatch one parsed input line to its output commands. `init`/`input` (and any unknown `type`)
96	/// produce nothing; only `event` emits the choreography. Returns an empty vec for no-output lines.
97	fn commands_for_line(v: &Value) -> Vec<Value> {
98	    match v.get("type").and_then(Value::as_str) {
99	        Some("event") => match v.get("envelope").and_then(Value::as_str) {
100	            Some(env) => commands_for_event(env),
101	            None => Vec::new(), // event without an envelope: nothing to deliver
102	        },
103	        // "init" handshake, "input" operator-typing ping, or any unknown type: no output.
104	        _ => Vec::new(),
105	    }
106	}
107
108	fn main() {
109	    let stdin = io::stdin();
110	    let stdout = io::stdout();
111	    let mut out = stdout.lock();
112
113	    for line in stdin.lock().lines() {
114	        let line = match line {
115	            Ok(l) => l,
116	            Err(_) => break, // stdin closed/errored: spt-core is tearing us down.
117	        };
118	        let trimmed = line.trim();
119	        if trimmed.is_empty() {
120	            continue;
121	        }
122	        let v: Value = match serde_json::from_str(trimmed) {
123	            Ok(v) => v,
124	            Err(e) => {
125	                // Skip malformed lines — never fatal. stderr lands in spt-core's log, not the PTY.
126	                eprintln!("cc-spt-idle-translate: skipping unparseable line: {e}");
127	                continue;
128	            }
129	        };
130	        for cmd in commands_for_line(&v) {
131	            // One compact JSON object per line; flush per command so spt-core applies promptly.
132	            if writeln!(out, "{cmd}").is_err() || out.flush().is_err() {
133	                return; // stdout closed: endpoint gone.
134	            }
135	        }
136	    }
137	}
138
139	#[cfg(test)]
140	mod tests {
141	    use super::*;
142
143	    fn keys(cmds: &[Value]) -> Vec<String> {
144	        cmds.iter()
145	            .map(|c| {
146	                if let Some(k) = c.get("key").and_then(Value::as_str) {
147	                    format!("key:{k}")
148	                } else if let Some(d) = c.get("delay_ms").and_then(Value::as_u64) {
149	                    format!("delay:{d}")
150	                } else if let Some(t) = c.get("text").and_then(Value::as_str) {
151	                    format!("text:{t}")
152	                } else if c.get("commit").and_then(Value::as_bool) == Some(true) {
153	                    "commit".into()
154	                } else {
155	                    "?".into()
156	                }
157	            })
158	            .collect()
159	    }
160
161	    #[test]
162	    fn event_emits_the_stash_submit_commit_choreography() {
163	        let cmds = commands_for_event("<EVENT type=\"msg\" from=\"doyle\">hi</EVENT>");
164	        assert_eq!(
165	            keys(&cmds),
166	            vec![
167	                "key:ctrl+s".to_string(),
168	                "delay:50".to_string(),
169	                "text:<EVENT type=\"msg\" from=\"doyle\">hi</EVENT>".to_string(),
170	                "delay:50".to_string(),
171	                "key:enter".to_string(),
172	                "commit".to_string(),
173	            ]
174	        );
175	    }
176
177	    #[test]
178	    fn sequence_terminates_with_a_mandatory_commit() {
179	        // broker.rs run_inject_worker ends a sequence ONLY on {commit:true}; without it the broker
180	        // FAULTs at the 5s INJECT_COMMIT_DEADLINE on every delivery. The terminator MUST be last.
181	        let cmds = commands_for_event("m");
182	        assert_eq!(cmds.last().unwrap().get("commit").and_then(Value::as_bool), Some(true));
183	        // Exactly one commit, and it is the final command (nothing emits after the terminator).
184	        let commits = cmds.iter().filter(|c| c.get("commit").is_some()).count();
185	        assert_eq!(commits, 1, "exactly one terminating commit");
186	    }
187
188	    #[test]
189	    fn no_trailing_restore_keystroke() {
190	        // CC auto-restores the stashed draft after the submit, so there is EXACTLY ONE ctrl+s (the
191	        // stash) — never a trailing restore. (The sequence ends with the {commit} terminator.)
192	        let cmds = commands_for_event("m");
193	        let ctrl_s = cmds.iter().filter(|c| c.get("key").and_then(Value::as_str) == Some("ctrl+s")).count();
194	        assert_eq!(ctrl_s, 1, "exactly one ctrl+s (stash only); CC auto-restores");
195	    }
196
197	    #[test]
198	    fn submit_is_a_discrete_enter_key_not_a_trailing_cr() {
199	        let cmds = commands_for_event("payload");
200	        // The text carries NO trailing \r — a \r byte does not submit a Claude Code message.
201	        let text = cmds[2].get("text").and_then(Value::as_str).unwrap();
202	        assert_eq!(text, "payload");
203	        assert!(!text.contains('\r'), "text must NOT carry a submit \\r");
204	        // The submit is a discrete enter keypress, after the text.
205	        let enters = cmds.iter().filter(|c| c.get("key").and_then(Value::as_str) == Some("enter")).count();
206	        assert_eq!(enters, 1, "exactly one enter key (the submit)");
207	        // ...and it comes AFTER the text command.
208	        let text_idx = cmds.iter().position(|c| c.get("text").is_some()).unwrap();
209	        let enter_idx = cmds.iter().position(|c| c.get("key").and_then(Value::as_str) == Some("enter")).unwrap();
210	        assert!(enter_idx > text_idx, "the enter submit must follow the text");
211	    }
212
213	    #[test]
214	    fn stash_precedes_the_submit() {
215	        let cmds = commands_for_event("x");
216	        // ctrl+s (stash) is FIRST; the text is cmds[2]; enter submits; the {commit} terminator is last.
217	        assert_eq!(cmds.first().unwrap().get("key").and_then(Value::as_str), Some("ctrl+s"));
218	        assert!(cmds[2].get("text").is_some());
219	        assert_eq!(cmds.len(), 6);
220	    }
221
222	    #[test]
223	    fn raw_newlines_in_envelope_are_neutralized() {
224	        // A stray CR/LF must not split or corrupt the injection — only the discrete enter submits.
225	        // Embedded newlines collapse to spaces; NO \r survives in the text (the submit is enter).
226	        let cmds = commands_for_event("a\nb\r\nc");
227	        let text = cmds[2].get("text").and_then(Value::as_str).unwrap();
228	        assert_eq!(text, "a b  c");
229	        assert_eq!(text.matches('\r').count(), 0, "no raw CR survives");
230	        assert_eq!(text.matches('\n').count(), 0, "no raw LF survives");
231	    }
232
233	    #[test]
234	    fn event_dispatch_via_line() {
235	        let v: Value = serde_json::from_str(
236	            r#"{"type":"event","envelope":"<EVENT type=\"msg\" from=\"a\">b</EVENT>"}"#,
237	        )
238	        .unwrap();
239	        let cmds = commands_for_line(&v);
240	        assert_eq!(cmds.len(), 6);
241	        assert_eq!(cmds[0].get("key").and_then(Value::as_str), Some("ctrl+s"));
242	    }
243
244	    #[test]
245	    fn init_and_input_and_unknown_types_emit_nothing() {
246	        for line in [
247	            r#"{"type":"init","endpoint_id":"perri","node":"HFENDULEAM"}"#,
248	            r#"{"type":"input"}"#,
249	            r#"{"type":"future-thing","x":1}"#,
250	            r#"{"no_type":true}"#,
251	        ] {
252	            let v: Value = serde_json::from_str(line).unwrap();
253	            assert!(commands_for_line(&v).is_empty(), "no output for: {line}");
254	        }
255	    }
256
257	    #[test]
258	    fn event_without_envelope_emits_nothing() {
259	        let v: Value = serde_json::from_str(r#"{"type":"event"}"#).unwrap();
260	        assert!(commands_for_line(&v).is_empty());
261	    }
262
263	    #[test]
264	    fn unknown_fields_are_ignored_forward_compat() {
265	        // A newer spt-core adding fields must not break dispatch (unknown keys ignored).
266	        let v: Value = serde_json::from_str(
267	            r#"{"type":"event","envelope":"<EVENT>z</EVENT>","priority":9,"trace_id":"t"}"#,
268	        )
269	        .unwrap();
270	        assert_eq!(commands_for_line(&v).len(), 6);
271	    }
272
273	    #[test]
274	    fn output_commands_are_single_field_objects() {
275	        // Each emitted command is exactly one of the contract shapes (key | delay_ms | text | commit).
276	        for c in commands_for_event("m") {
277	            let obj = c.as_object().unwrap();
278	            assert_eq!(obj.len(), 1, "each command carries exactly one field: {c}");
279	            let k = obj.keys().next().unwrap().as_str();
280	            assert!(matches!(k, "key" | "delay_ms" | "text" | "commit"), "unexpected field {k}");
281	        }
282	    }
283	}
284

//! cc-spt-idle-translate — the `[message-idle-translation-binary]` for the claude-spt adapter
//! (spt-core harness-contract v0.13.0+).
//!
//! A lifecycle-managed `stdin -> stdout` JSON-lines filter. spt-core spawns it when the spt-hosted
//! endpoint comes online and reaps it on shutdown; it turns each inbound `<EVENT>` envelope into a
//! sequence of keystroke-commands that spt-core applies ATOMICALLY to the broker-held PTY (live
//! operator input is buffered during emission, so it coexists with an attached `spt rc` controller).
//! Scope is IDLE delivery only — busy/mid-turn injection stays the adapter's `[inject]` hook path.
//!
//! Input protocol (stdin, one JSON object per line):
//!   {"type":"init","endpoint_id":…,"node":…}   — first message (handshake; no output)
//!   {"type":"event","envelope":"<EVENT…>"}      — one inbound message; the full EVENT envelope
//!   {"type":"input"}                            — content-free ping when the operator types (no output)
//!
//! Output protocol (stdout, one JSON object per line):
//!   {"key":"ctrl+s"}     — keystroke command (Claude Code: STASH the current draft input)
//!   {"key":"enter"}      — keystroke command (submit the PTY line; see SUBMIT note below)
//!   {"delay_ms":50}      — inter-command pause
//!   {"text":"<payload>"} — text injection (the envelope; NO trailing \r — it does not submit CC)
//!   {"commit":true}      — MANDATORY sequence terminator (release the InjectFloor; see below)
//!
//! The per-event choreography delivers the message WITHOUT clobbering a half-typed draft the operator
//! may have in the input box, then terminates the inject sequence:
//!   1. ctrl+s              stash any existing draft
//!   2. delay 50ms          let the stash settle
//!   3. <envelope>          type the envelope (no trailing CR)
//!   4. delay 50ms          let the text land in CC's input box before the submit key
//!   5. {"key":"enter"}     submit the line
//!   6. {"commit":true}     terminate the inject sequence
//! No trailing restore keystroke: Claude Code AUTO-RESTORES the stashed draft after the submit, so a
//! second ctrl+s would be redundant (it would re-stash, not restore).
//!
//! SUBMIT IS A REAL ENTER KEY, NOT A TRAILING `\r` (corrected 2026-06-23): we previously rode the
//! submit IN the text as a trailing `\r`, on the assumption that spt-core applies `{"text"}` VERBATIM
//! to the PTY (broker.rs:1066, :1016-1017) and a `\r` byte == `{"key":"enter"}`
//! (`key_to_bytes("enter")->b"\r"`). Empirically that is NOT enough: a `\r` byte in injected text does
//! NOT trigger Claude Code's message submission — CC needs the discrete Enter KEY event. So step 5 is
//! now a separate `{"key":"enter"}` after the text lands. The verbatim-text application still REQUIRES
//! `commands_for_event` to neutralize the envelope's INTERNAL CR/LF (an un-neutralized `\n`/`\r` would
//! reach the PTY and could split or corrupt the injection) — only the discrete Enter submits.
//!
//! TWO DISTINCT SIGNALS — do not conflate them:
//!  - The `{"key":"enter"}` in step 5 is the HARNESS-level submit (the discrete keypress CC needs).
//!  - The `{"commit":true}` in step 6 is the PROTOCOL-level terminator: `run_inject_worker`
//!    (broker.rs:1075-1090) ends an inject sequence ONLY on an explicit `{commit}`. Text/Key/Delay
//!    just enqueue; a sequence that emits no `{commit}` hits `INJECT_COMMIT_DEADLINE` (5s,
//!    broker.rs:151-169) and FAULTS on every delivery (reverts to raw inject — input isn't lost but
//!    each delivery stalls to the deadline). `{commit}` flushes the live controller's buffered input
//!    and releases the InjectFloor race-free. Enter submits the line; `{commit}` ends the sequence.
//!
//! Degenerate fallback (per the published contract): `{"text":payload}{"key":"enter"}{"commit":true}`
//! with no choreography — even the bare form MUST commit. (The published manifest doc ORIGINALLY
//! omitted `{commit}` from the vocabulary and its degenerate example — a public-surface defect this
//! adapter's blind-build caught, logged F-016. The contract is now CORRECTED + live:
//! harness-contract/manifest.html documents `{commit}` as the mandatory terminator + the 5s
//! commit-deadline + inject-floor release + raw-inject fallback, since v0.13.0.)
//! We choreograph because Claude Code's input box supports the ctrl+s draft stash/restore, so an
//! inbound message never eats an in-progress draft.
//!
//! Robustness: this is lifecycle-critical — a panic would drop the idle-delivery pipe for the whole
//! session. Malformed lines and unknown `type`s degrade gracefully (skipped, never fatal); unknown
//! JSON fields are ignored (forward-compat with newer spt-core). [impl->REQ-DIST-IDLE-TRANSLATE]

use serde_json::{json, Value};
use std::io::{self, BufRead, Write};

/// Inter-command pause spt-core honors between emitted keystroke/text commands (operator spec).
const DELAY_MS: u64 = 50;

/// Build the keystroke-command sequence for one inbound EVENT envelope.
///
/// `envelope` is the full `<EVENT…>…</EVENT>` string. The envelope is single-line by contract (it
/// encodes newlines as the literal `<br>` token), but we defensively strip any raw CR/LF so a stray
/// newline can never split or corrupt the injection. (Necessary because spt-core applies `{"text"}`
/// verbatim — broker.rs:1066/:1016-1017, doyle 2026-06-20 — so any internal CR/LF would otherwise
/// reach the PTY.) The submit is a DISCRETE `{"key":"enter"}` AFTER the text — a trailing `\r` byte in
/// the text does NOT submit a Claude Code message (corrected 2026-06-23). The closing `{"commit"}`
/// is the MANDATORY inject-sequence terminator (broker.rs:1075-1090; no-commit FAULTs at the 5s
/// INJECT_COMMIT_DEADLINE). [impl->REQ-DIST-IDLE-TRANSLATE]
fn commands_for_event(envelope: &str) -> Vec<Value> {
    let sanitized: String = envelope
        .chars()
        .map(|c| if c == '\r' || c == '\n' { ' ' } else { c })
        .collect();
    vec![
        json!({ "key": "ctrl+s" }),      // 1. stash any existing draft
        json!({ "delay_ms": DELAY_MS }), // 2. let the stash settle
        json!({ "text": sanitized }),    // 3. type the envelope — NO trailing CR (a \r byte does not submit CC)
        json!({ "delay_ms": DELAY_MS }), // 4. let the text land in CC's input box before the submit key
        json!({ "key": "enter" }),       // 5. submit — a real Enter keypress (CC needs the key event, not a \r byte)
        json!({ "commit": true }),       // 6. terminate the inject sequence (release the InjectFloor)
    ]
}

/// Dispatch one parsed input line to its output commands. `init`/`input` (and any unknown `type`)
/// produce nothing; only `event` emits the choreography. Returns an empty vec for no-output lines.
fn commands_for_line(v: &Value) -> Vec<Value> {
    match v.get("type").and_then(Value::as_str) {
        Some("event") => match v.get("envelope").and_then(Value::as_str) {
            Some(env) => commands_for_event(env),
            None => Vec::new(), // event without an envelope: nothing to deliver
        },
        // "init" handshake, "input" operator-typing ping, or any unknown type: no output.
        _ => Vec::new(),
    }
}

fn main() {
    let stdin = io::stdin();
    let stdout = io::stdout();
    let mut out = stdout.lock();

    for line in stdin.lock().lines() {
        let line = match line {
            Ok(l) => l,
            Err(_) => break, // stdin closed/errored: spt-core is tearing us down.
        };
        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }
        let v: Value = match serde_json::from_str(trimmed) {
            Ok(v) => v,
            Err(e) => {
                // Skip malformed lines — never fatal. stderr lands in spt-core's log, not the PTY.
                eprintln!("cc-spt-idle-translate: skipping unparseable line: {e}");
                continue;
            }
        };
        for cmd in commands_for_line(&v) {
            // One compact JSON object per line; flush per command so spt-core applies promptly.
            if writeln!(out, "{cmd}").is_err() || out.flush().is_err() {
                return; // stdout closed: endpoint gone.
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn keys(cmds: &[Value]) -> Vec<String> {
        cmds.iter()
            .map(|c| {
                if let Some(k) = c.get("key").and_then(Value::as_str) {
                    format!("key:{k}")
                } else if let Some(d) = c.get("delay_ms").and_then(Value::as_u64) {
                    format!("delay:{d}")
                } else if let Some(t) = c.get("text").and_then(Value::as_str) {
                    format!("text:{t}")
                } else if c.get("commit").and_then(Value::as_bool) == Some(true) {
                    "commit".into()
                } else {
                    "?".into()
                }
            })
            .collect()
    }

    #[test]
    fn event_emits_the_stash_submit_commit_choreography() {
        let cmds = commands_for_event("<EVENT type=\"msg\" from=\"doyle\">hi</EVENT>");
        assert_eq!(
            keys(&cmds),
            vec![
                "key:ctrl+s".to_string(),
                "delay:50".to_string(),
                "text:<EVENT type=\"msg\" from=\"doyle\">hi</EVENT>".to_string(),
                "delay:50".to_string(),
                "key:enter".to_string(),
                "commit".to_string(),
            ]
        );
    }

    #[test]
    fn sequence_terminates_with_a_mandatory_commit() {
        // broker.rs run_inject_worker ends a sequence ONLY on {commit:true}; without it the broker
        // FAULTs at the 5s INJECT_COMMIT_DEADLINE on every delivery. The terminator MUST be last.
        let cmds = commands_for_event("m");
        assert_eq!(cmds.last().unwrap().get("commit").and_then(Value::as_bool), Some(true));
        // Exactly one commit, and it is the final command (nothing emits after the terminator).
        let commits = cmds.iter().filter(|c| c.get("commit").is_some()).count();
        assert_eq!(commits, 1, "exactly one terminating commit");
    }

    #[test]
    fn no_trailing_restore_keystroke() {
        // CC auto-restores the stashed draft after the submit, so there is EXACTLY ONE ctrl+s (the
        // stash) — never a trailing restore. (The sequence ends with the {commit} terminator.)
        let cmds = commands_for_event("m");
        let ctrl_s = cmds.iter().filter(|c| c.get("key").and_then(Value::as_str) == Some("ctrl+s")).count();
        assert_eq!(ctrl_s, 1, "exactly one ctrl+s (stash only); CC auto-restores");
    }

    #[test]
    fn submit_is_a_discrete_enter_key_not_a_trailing_cr() {
        let cmds = commands_for_event("payload");
        // The text carries NO trailing \r — a \r byte does not submit a Claude Code message.
        let text = cmds[2].get("text").and_then(Value::as_str).unwrap();
        assert_eq!(text, "payload");
        assert!(!text.contains('\r'), "text must NOT carry a submit \\r");
        // The submit is a discrete enter keypress, after the text.
        let enters = cmds.iter().filter(|c| c.get("key").and_then(Value::as_str) == Some("enter")).count();
        assert_eq!(enters, 1, "exactly one enter key (the submit)");
        // ...and it comes AFTER the text command.
        let text_idx = cmds.iter().position(|c| c.get("text").is_some()).unwrap();
        let enter_idx = cmds.iter().position(|c| c.get("key").and_then(Value::as_str) == Some("enter")).unwrap();
        assert!(enter_idx > text_idx, "the enter submit must follow the text");
    }

    #[test]
    fn stash_precedes_the_submit() {
        let cmds = commands_for_event("x");
        // ctrl+s (stash) is FIRST; the text is cmds[2]; enter submits; the {commit} terminator is last.
        assert_eq!(cmds.first().unwrap().get("key").and_then(Value::as_str), Some("ctrl+s"));
        assert!(cmds[2].get("text").is_some());
        assert_eq!(cmds.len(), 6);
    }

    #[test]
    fn raw_newlines_in_envelope_are_neutralized() {
        // A stray CR/LF must not split or corrupt the injection — only the discrete enter submits.
        // Embedded newlines collapse to spaces; NO \r survives in the text (the submit is enter).
        let cmds = commands_for_event("a\nb\r\nc");
        let text = cmds[2].get("text").and_then(Value::as_str).unwrap();
        assert_eq!(text, "a b  c");
        assert_eq!(text.matches('\r').count(), 0, "no raw CR survives");
        assert_eq!(text.matches('\n').count(), 0, "no raw LF survives");
    }

    #[test]
    fn event_dispatch_via_line() {
        let v: Value = serde_json::from_str(
            r#"{"type":"event","envelope":"<EVENT type=\"msg\" from=\"a\">b</EVENT>"}"#,
        )
        .unwrap();
        let cmds = commands_for_line(&v);
        assert_eq!(cmds.len(), 6);
        assert_eq!(cmds[0].get("key").and_then(Value::as_str), Some("ctrl+s"));
    }

    #[test]
    fn init_and_input_and_unknown_types_emit_nothing() {
        for line in [
            r#"{"type":"init","endpoint_id":"perri","node":"HFENDULEAM"}"#,
            r#"{"type":"input"}"#,
            r#"{"type":"future-thing","x":1}"#,
            r#"{"no_type":true}"#,
        ] {
            let v: Value = serde_json::from_str(line).unwrap();
            assert!(commands_for_line(&v).is_empty(), "no output for: {line}");
        }
    }

    #[test]
    fn event_without_envelope_emits_nothing() {
        let v: Value = serde_json::from_str(r#"{"type":"event"}"#).unwrap();
        assert!(commands_for_line(&v).is_empty());
    }

    #[test]
    fn unknown_fields_are_ignored_forward_compat() {
        // A newer spt-core adding fields must not break dispatch (unknown keys ignored).
        let v: Value = serde_json::from_str(
            r#"{"type":"event","envelope":"<EVENT>z</EVENT>","priority":9,"trace_id":"t"}"#,
        )
        .unwrap();
        assert_eq!(commands_for_line(&v).len(), 6);
    }

    #[test]
    fn output_commands_are_single_field_objects() {
        // Each emitted command is exactly one of the contract shapes (key | delay_ms | text | commit).
        for c in commands_for_event("m") {
            let obj = c.as_object().unwrap();
            assert_eq!(obj.len(), 1, "each command carries exactly one field: {c}");
            let k = obj.keys().next().unwrap().as_str();
            assert!(matches!(k, "key" | "delay_ms" | "text" | "commit"), "unexpected field {k}");
        }
    }
}


Binary understood. T1 needs empirical gate: does CC soft-newline on raw `\n` in `{text}`. Current binary sanitizes `\n`→space (line 83). Need probe binary that emits multi-line text, wire into fresh endpoint, send msg, read spawned-CC transcript.

Let me check spt tooling + manifest layout first.


Bash

echo "=== spt ===" && which spt owl 2>&1; echo "=== OWL/LIVE env ==="; echo "OWL=$OWL LIVE=$LIVE"; echo "=== spt version ==="; spt --version 2>&1 | head -3


=== spt ===
which: no owl in (/c/Users/decid/bin:/mingw64/bin:/usr/local/bin:/usr/bin:/bin:/mingw64/bin:/usr/bin:/c/Users/decid/bin:/c/Program Files/PowerShell/7:/c/Program Files/Eclipse Adoptium/jdk-17.0.19.10-hotspot/bin:/c/Program Files/PlasticSCM5/server:/c/Program Files/PlasticSCM5/client:/c/program files/python312/scripts:/c/program files/python312:/c/program files/eclipse adoptium/jre-8.0.392.8-hotspot/bin:/c/WINDOWS/system32:/c/WINDOWS:/c/WINDOWS/System32/Wbem:/c/WINDOWS/system32/windowspowershell/v1.0:/c/WINDOWS/system32/openssh:/c/program files/putty:/c/program files/microsoft vs code/bin:/c/program files/dotnet:/c/programdata/chocolatey/bin:/c/program files/git/cmd:/c/Program Files/Microsoft VS Code/bin:/c/Program Files/NVIDIA Corporation/NVIDIA app/NvDLISR:/c/WINDOWS/system32:/c/WINDOWS:/c/WINDOWS/System32/Wbem:/c/WINDOWS/System32/WindowsPowerShell/v1.0:/c/WINDOWS/System32/OpenSSH:/c/Program Files (x86)/NVIDIA Corporation/PhysX/Common:/c/Program Files (x86)/Windows Kits/10/Windows Performance Toolkit:/c/Program Files/USBip:/c/Program Files/PowerToys/DSCModules:/c/Program Files/PowerShell/7:/c/Users/decid/AppData/Local/nvm:/c/nvm4w/nodejs:/c/Program Files/GitHub CLI:/c/Users/decid/.local/bin:/c/dev/flutter/bin:/c/Program Files/Tailscale:/c/Users/decid/AppData/Roaming/Python/Python310/Scripts::/Users/decid/AppData/Roaming/Python/Python312/Scripts:/c/Program Files/CMake/bin:/c/Program Files/Arduino CLI:/c/Users/decid/AppData/Local/Programs/traceable-reqs:/c/Users/decid/.cargo/bin:/c/Users/decid/scoop/apps/mpv/current:/c/Users/decid/scoop/shims:/c/Users/decid/AppData/Local/Microsoft/WindowsApps:/c/Users/decid/AppData/Local/GitHubDesktop/bin:/c/Users/decid/.dotnet/tools:/c/Users/decid/AppData/Local/Microsoft/WindowsApps:/c/Users/decid/AppData/Local/nvm:/c/nvm4w/nodejs:/c/Users/decid/AppData/Local/Microsoft/WinGet/Links:/c/Users/decid/AppData/Local/Microsoft/WinGet/Packages/ggml.llamacpp_Microsoft.Winget.Source_8wekyb3d8bbwe:/c/Users/decid/.fly/bin:/c/Users/decid/AppData/Local/spt-core/bin:/usr/bin/vendor_perl:/usr/bin/core_perl:/c/Users/decid/.claude/plugins/cache/claude-plugins-official/frontend-design/unknown/bin:/c/Users/decid/.claude/plugins/cache/claude-plugins-official/rust-analyzer-lsp/1.0.0/bin:/c/Users/decid/.claude/plugins/cache/cplugs/screen-timelapse/1.2.2/bin:/c/Users/decid/.claude/plugins/cache/cplugs/spt/1.11.25/bin:/c/Users/decid/.claude/plugins/cache/caveman/caveman/84cc3c14fa1e/bin:/c/Users/decid/.claude/plugins/cache/cplugs/everything/0.2.0/bin:/c/Users/decid/.claude/plugins/cache/cplugs/sptc/0.1.4/bin)
/c/Users/decid/AppData/Local/spt-core/bin/spt
=== OWL/LIVE env ===
OWL=C:/Users/decid/.claude/plugins/cache/cplugs/spt/1.11.25/owl.exe LIVE=C:/Users/decid/.claude/plugins/cache/cplugs/spt/1.11.25/owl.exe live
=== spt version ===
spt 0.14.2


adapter\manifest.schema.json


spt 0.14.2 live. Let me find the actual adapter.spt manifest + how the translation binary is referenced (to wire a probe variant).


dist\adapter-windows-x86_64.spt
dist\adapter-linux-x86_64.spt
dist\adapter.spt


adapter\manifest.schema.json
adapter\strings\skills\live.md
adapter\strings\skills\ready.md
adapter\strings\skills\subnet.md
adapter\strings\skills\list-agents.md
adapter\strings\skills\version.md
adapter\strings\skills\force-stop.md
adapter\strings\briefs\identity.md
adapter\strings\briefs\messaging-perch.md
adapter\strings\briefs\messaging-no-perch.md
adapter\strings\skills\setup.md
adapter\claude-spt.toml