54	## Ask 1 — generic hook dispatch: `spt api run-hook <adapter> <event>`
55
56	**RESOLVED:** REJECTED as `run-hook` → **resolve-not-execute** (`{adapter_dir}`/`{adapter_name}` keys +
57	lazy `[strings]` substitution at `get-string`; binary carries `claude-spt hook <event>`; `hooks.json`
58	+ thin per-OS dispatch static-forever). See the Resolution table above.
59
60
61	**Problem.** Every hook the adapter adds (this cycle's `PostToolUse` checkpoint detector) forces a cplugs
62	republish + `claude plugin update` + `/reload-plugins`, because the wrapper *logic* lives in the plugin.
63	The manifest `[hooks.<event>]` table is declaration-only today — the plugin's `hooks.json` + wrapper `.sh`
64	are what actually run.
65
66	**Proposal.** spt-core executes the adapter's handler for a hook event. The plugin's `hooks.json` pre-wires
67	**all** CC hook events to one generic stub:
68
69	```
70	# hooks.json (static-forever — every event, one line each)
71	<event> -> spt api run-hook claude-spt <event>      # stdin = the CC hook payload, verbatim
72	```
73
74	spt-core resolves the adapter's handler for that event and runs it, passing the hook stdin through and
75	surfacing any `additionalContext` back. The handler is adapter-conducted — ideally a subcommand of our
76	consolidated binary (`claude-spt hook <event>`, see Ask 3 / ADR-0006), so hook *logic* then rides
77	`spt adapter update` and `hooks.json` never changes again.
78
79	**Open questions for doyle:**
80	- Where does the handler live — the manifest `[hooks.<event>].fires` command (made executable, not just
81	  declarative), or a declared binary/subcommand spt-core invokes? Our preference: a binary subcommand.
82	- What's passed in (the raw CC hook JSON on stdin?) and how does `additionalContext` / the `can_inject`
83	  contract flow back out through `api run-hook`?
84	- Auth/session threading — same shape as the other id-scoped `api` calls (`--session-id`)?
85	- Does spt-core stay harness-agnostic here (it runs an opaque adapter-declared handler; it needn't know
86	  the event semantics)?
87
88	---
89
90	## Ask 2 — composite `[update]`: `gh_release` pull **+** a delegated post-step
91
92	**RESOLVED:** GRANTED as avenue-agnostic `[update.post] = {command, self_verifies}` — runs after
93	`gh_release` unconditionally; stdin JSON published; our stdout arbitrates the notice; failure-isolated.
94	(Answers 2a/2b: the post-step runs even on an adapter no-op, and its stdout — not just a "changed"
95	flag — drives the message.) See the Resolution table above.
96
97	**Problem.** `[update]` takes one `avenue` (`gh_release` XOR `delegated`). We need `gh_release` (to pull the
98	`.spt` = manifest + binaries + strings) **and** a delegated step (to run our cross-platform plugin-sync:
99	`claude plugin add|update <plugin>`) in the **same** `spt adapter update`. Today they're mutually exclusive.
100
101	**Proposal.** Let `gh_release` carry a post-update delegated command spt-core runs after the pull/re-register,
102	in the same invocation. Two sub-requirements:
103
104	- **2a — runs unconditionally.** The post-step must run **even when the adapter `.spt` version did not
105	  change** — the cplugs plugin can have a new version when the adapter didn't, and the user still wants one
106	  command to reconcile it.
107	- **2b — a "changed" return signals the message.** The post-step's return value can flag *applied/changed*
108	  so `spt adapter update` still prints `[update].message` (the `/reload-plugins` notice) even on an adapter
109	  no-op. (`message` today prints only when a new adapter version is applied — we need the post-step able to
110	  trigger it too.)
111
112	**Already confirmed (no ask):** `[update].message` exists (markdown, prints only on a real apply, no `{key}`
113	subst) and *"install is the first update"* — so this same flow makes `spt adapter add` bootstrap the plugin.
114
115	**Open questions for doyle:**
116	- Field shape — a `post_update` command on `gh_release`? a `[update.post]` sub-table? an ordered avenue list?
117	- The "changed" return convention — exit code? a sentinel on stdout?
118	- No-op suppression — if *neither* the adapter nor the post-step changed, `message` still suppresses?
119	- Trust — does the post-step inherit the `self_verifies`/attest model the `delegated` avenue already uses?
120
121	---
122
123	## Ask 3 — `[message-idle-translation-binary]` accepts a command/subcommand (not a bare `path`)
124
125	**RESOLVED:** GRANTED — the seam gains `command` (args + `{adapter_dir}` subst); `path` deprecated
126	(parses + warns); exactly one of `{path, command}`; protocol unchanged. → `claude-spt translate`.
127
128	**Problem.** `[digest]`, `[session.*]`, and `[update]` take **command strings** (so they consolidate trivially
129	into `claude-spt <subcommand>`). `[message-idle-translation-binary]` takes a bare **`path`** — `path =
130	"claude-spt"` would spawn the binary with no args, so it can't tell it's in translate mode. This is the one
131	seam blocking us from folding the translation filter into the single consolidated binary (ADR-0006).
132
133	**Proposal.** Let the seam take a command (with args / `{key}` substitution) like the other seams — e.g.
134	`command = "claude-spt translate"` — keeping the existing stdin JSON-lines protocol unchanged. (A documented
135	"invoked with no args ⇒ translate mode" convention would also unblock us, but an explicit command is cleaner
136	and consistent with the other seams.)
137
138	**Open questions for doyle:**
139	- Preferred shape — promote `path` to an optional `command`, or add a sibling `command` field?
140	- Backward-compat for existing bare-`path` adapters (keep `path` working)?
141	- Does the spawn/stdin-protocol contract change at all, or is it purely how the executable is located?
142
143	---
144
145	## Ask 4 — a `{node}` substitution key for `[session.<role>]` roles  *(operator raising directly)*
146
147	**STATUS:** operator-owned, NOT in the cluster-A (ADR-0029) resolution. U6 shipped `{id}`-only
148	(`-n {id}` + `--remote-control {id}` on both bringup paths); upgrades to `{id}@{node}` if/when a
149	`{node}` fill key lands.
150
151	**Problem.** We want the spawned CC session's display name and RC channel to be `{id}@{node}` (so a same-id
152	endpoint on different machines is distinguishable) — `claude -n {id}@{node} --remote-control {id}@{node}`.
153	The substitution-key catalog is now **published**
154	([harness-contract/manifest.html#substitution-keys](https://sabermage.github.io/spt-releases/harness-contract/manifest.html#substitution-keys)),
155	and **`{node}` is confirmed absent.** Closest existing keys: `{id}`, `{session_id}`, `{session_name}`
156	(the *supplied* display name — circular for our use), `{adapter_name}`, `{agent_type}`, `{agents_json}`.
157
158	**Proposal.** Expose the current node name as a `{node}` (or `{node_name}` / `{host}`) fill key for the
159	`[session.<role>]` templates, declarable in `keys`. *(The operator is raising this in their ongoing
160	spt-core conversation with doyle — tracked here for completeness, not a separate filing.)*
161
162	**Open questions for doyle:**
163	- Key name — `{node}`, `{node_name}`, or `{host}`? Match the internal model's term.
164	- Available to `[session.<role>]` (where we need it), and optionally the SessionStart hooks / `[session.notif]`?
165
166	---
167
168	## Heads-up (not an ask) — name unification
169
170	We're collapsing three names to two (ADR-0005): **`claude-spt`** = the spt-core-facing identity (we're
171	renaming the GitHub repo `spt-claude-code` → `claude-spt`, so `--release SaberMage/claude-spt` matches
172	`spt adapter update claude-spt`, and the consolidated binary is `claude-spt`); **`spt`** = the Claude-Code
173	plugin + skills (`/spt:*`, via the planned `sptc`→`spt` succession). The repo rename changes the derived
174	install dir (`adapters/_github/SaberMage-claude-spt`), so the `[update].repo` and any path assumptions on
175	your side should expect the new repo slug. One end user today, so the re-add cost is a non-issue.
176
177	---
178
179	## Dependency map (what unblocks what)
180
181	- **Ask 1** [RESOLVED→resolve-not-execute] → hook logic rides `spt adapter update` via `claude-spt hook`;
182	  `hooks.json` + thin per-OS dispatch static-forever (no `run-hook`; `{adapter_dir}` + lazy `[strings]`).
183	- **Ask 2** [GRANTED `[update.post]`] → the plugin update is automated; only manual residual is `/reload-plugins`.
184	- **Ask 3** [GRANTED `command`] → `translate` folds into the one `claude-spt` binary (1 artifact/triple).
185	- **Ask 4** [operator-owned] → display name + RC channel become `{id}@{node}` if a `{node}` key lands; else `{id}` only.
186	- Shipped **without** any ask (DONE, on `main`): `gh_release` + `[update].message`, partial binary
187	  consolidation (digest/psyche/post-update), and the name unification (repo renamed `SaberMage/claude-spt`).
188	- **Trigger:** doyle ships **spt-core v0.16.0** → adapter **v0.8.0** cut wires Asks 1–3 + bumps `min_spt_core` → 0.16.0.

## Ask 1 — generic hook dispatch: `spt api run-hook <adapter> <event>`

**RESOLVED:** REJECTED as `run-hook` → **resolve-not-execute** (`{adapter_dir}`/`{adapter_name}` keys +
lazy `[strings]` substitution at `get-string`; binary carries `claude-spt hook <event>`; `hooks.json`
+ thin per-OS dispatch static-forever). See the Resolution table above.

**Problem.** Every hook the adapter adds (this cycle's `PostToolUse` checkpoint detector) forces a cplugs
republish + `claude plugin update` + `/reload-plugins`, because the wrapper *logic* lives in the plugin.
The manifest `[hooks.<event>]` table is declaration-only today — the plugin's `hooks.json` + wrapper `.sh`
are what actually run.

**Proposal.** spt-core executes the adapter's handler for a hook event. The plugin's `hooks.json` pre-wires
**all** CC hook events to one generic stub:

```
# hooks.json (static-forever — every event, one line each)
<event> -> spt api run-hook claude-spt <event>      # stdin = the CC hook payload, verbatim
```

spt-core resolves the adapter's handler for that event and runs it, passing the hook stdin through and
surfacing any `additionalContext` back. The handler is adapter-conducted — ideally a subcommand of our
consolidated binary (`claude-spt hook <event>`, see Ask 3 / ADR-0006), so hook *logic* then rides
`spt adapter update` and `hooks.json` never changes again.

**Open questions for doyle:**
- Where does the handler live — the manifest `[hooks.<event>].fires` command (made executable, not just
  declarative), or a declared binary/subcommand spt-core invokes? Our preference: a binary subcommand.
- What's passed in (the raw CC hook JSON on stdin?) and how does `additionalContext` / the `can_inject`
  contract flow back out through `api run-hook`?
- Auth/session threading — same shape as the other id-scoped `api` calls (`--session-id`)?
- Does spt-core stay harness-agnostic here (it runs an opaque adapter-declared handler; it needn't know
  the event semantics)?

---

## Ask 2 — composite `[update]`: `gh_release` pull **+** a delegated post-step

**RESOLVED:** GRANTED as avenue-agnostic `[update.post] = {command, self_verifies}` — runs after
`gh_release` unconditionally; stdin JSON published; our stdout arbitrates the notice; failure-isolated.
(Answers 2a/2b: the post-step runs even on an adapter no-op, and its stdout — not just a "changed"
flag — drives the message.) See the Resolution table above.

**Problem.** `[update]` takes one `avenue` (`gh_release` XOR `delegated`). We need `gh_release` (to pull the
`.spt` = manifest + binaries + strings) **and** a delegated step (to run our cross-platform plugin-sync:
`claude plugin add|update <plugin>`) in the **same** `spt adapter update`. Today they're mutually exclusive.

**Proposal.** Let `gh_release` carry a post-update delegated command spt-core runs after the pull/re-register,
in the same invocation. Two sub-requirements:

- **2a — runs unconditionally.** The post-step must run **even when the adapter `.spt` version did not
  change** — the cplugs plugin can have a new version when the adapter didn't, and the user still wants one
  command to reconcile it.
- **2b — a "changed" return signals the message.** The post-step's return value can flag *applied/changed*
  so `spt adapter update` still prints `[update].message` (the `/reload-plugins` notice) even on an adapter
  no-op. (`message` today prints only when a new adapter version is applied — we need the post-step able to
  trigger it too.)

**Already confirmed (no ask):** `[update].message` exists (markdown, prints only on a real apply, no `{key}`
subst) and *"install is the first update"* — so this same flow makes `spt adapter add` bootstrap the plugin.

**Open questions for doyle:**
- Field shape — a `post_update` command on `gh_release`? a `[update.post]` sub-table? an ordered avenue list?
- The "changed" return convention — exit code? a sentinel on stdout?
- No-op suppression — if *neither* the adapter nor the post-step changed, `message` still suppresses?
- Trust — does the post-step inherit the `self_verifies`/attest model the `delegated` avenue already uses?

---

## Ask 3 — `[message-idle-translation-binary]` accepts a command/subcommand (not a bare `path`)

**RESOLVED:** GRANTED — the seam gains `command` (args + `{adapter_dir}` subst); `path` deprecated
(parses + warns); exactly one of `{path, command}`; protocol unchanged. → `claude-spt translate`.

**Problem.** `[digest]`, `[session.*]`, and `[update]` take **command strings** (so they consolidate trivially
into `claude-spt <subcommand>`). `[message-idle-translation-binary]` takes a bare **`path`** — `path =
"claude-spt"` would spawn the binary with no args, so it can't tell it's in translate mode. This is the one
seam blocking us from folding the translation filter into the single consolidated binary (ADR-0006).

**Proposal.** Let the seam take a command (with args / `{key}` substitution) like the other seams — e.g.
`command = "claude-spt translate"` — keeping the existing stdin JSON-lines protocol unchanged. (A documented
"invoked with no args ⇒ translate mode" convention would also unblock us, but an explicit command is cleaner
and consistent with the other seams.)

**Open questions for doyle:**
- Preferred shape — promote `path` to an optional `command`, or add a sibling `command` field?
- Backward-compat for existing bare-`path` adapters (keep `path` working)?
- Does the spawn/stdin-protocol contract change at all, or is it purely how the executable is located?

---

## Ask 4 — a `{node}` substitution key for `[session.<role>]` roles  *(operator raising directly)*

**STATUS:** operator-owned, NOT in the cluster-A (ADR-0029) resolution. U6 shipped `{id}`-only
(`-n {id}` + `--remote-control {id}` on both bringup paths); upgrades to `{id}@{node}` if/when a
`{node}` fill key lands.

**Problem.** We want the spawned CC session's display name and RC channel to be `{id}@{node}` (so a same-id
endpoint on different machines is distinguishable) — `claude -n {id}@{node} --remote-control {id}@{node}`.
The substitution-key catalog is now **published**
([harness-contract/manifest.html#substitution-keys](https://sabermage.github.io/spt-releases/harness-contract/manifest.html#substitution-keys)),
and **`{node}` is confirmed absent.** Closest existing keys: `{id}`, `{session_id}`, `{session_name}`
(the *supplied* display name — circular for our use), `{adapter_name}`, `{agent_type}`, `{agents_json}`.

**Proposal.** Expose the current node name as a `{node}` (or `{node_name}` / `{host}`) fill key for the
`[session.<role>]` templates, declarable in `keys`. *(The operator is raising this in their ongoing
spt-core conversation with doyle — tracked here for completeness, not a separate filing.)*

**Open questions for doyle:**
- Key name — `{node}`, `{node_name}`, or `{host}`? Match the internal model's term.
- Available to `[session.<role>]` (where we need it), and optionally the SessionStart hooks / `[session.notif]`?

---

## Heads-up (not an ask) — name unification

We're collapsing three names to two (ADR-0005): **`claude-spt`** = the spt-core-facing identity (we're
renaming the GitHub repo `spt-claude-code` → `claude-spt`, so `--release SaberMage/claude-spt` matches
`spt adapter update claude-spt`, and the consolidated binary is `claude-spt`); **`spt`** = the Claude-Code
plugin + skills (`/spt:*`, via the planned `sptc`→`spt` succession). The repo rename changes the derived
install dir (`adapters/_github/SaberMage-claude-spt`), so the `[update].repo` and any path assumptions on
your side should expect the new repo slug. One end user today, so the re-add cost is a non-issue.

---

## Dependency map (what unblocks what)

- **Ask 1** [RESOLVED→resolve-not-execute] → hook logic rides `spt adapter update` via `claude-spt hook`;
  `hooks.json` + thin per-OS dispatch static-forever (no `run-hook`; `{adapter_dir}` + lazy `[strings]`).
- **Ask 2** [GRANTED `[update.post]`] → the plugin update is automated; only manual residual is `/reload-plugins`.
- **Ask 3** [GRANTED `command`] → `translate` folds into the one `claude-spt` binary (1 artifact/triple).
- **Ask 4** [operator-owned] → display name + RC channel become `{id}@{node}` if a `{node}` key lands; else `{id}` only.
- Shipped **without** any ask (DONE, on `main`): `gh_release` + `[update].message`, partial binary
  consolidation (digest/psyche/post-update), and the name unification (repo renamed `SaberMage/claude-spt`).
- **Trigger:** doyle ships **spt-core v0.16.0** → adapter **v0.8.0** cut wires Asks 1–3 + bumps `min_spt_core` → 0.16.0.


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


Verify the two D1 primitives against live spt 0.16.0. Check adapter registration + test lazy `{adapter_dir}` substitution in get-string.


Bash

echo "===registered adapters==="; spt adapter list 2>&1 | head -20; echo "===substitution keys docs check==="; spt adapter get-string claude-spt adapter_label 2>&1 | head -5

List adapters + probe get-string


===registered adapters===
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)
probe-sptc: Harness Pointer deregistered (from C:/Users/decid/AppData/Local/Temp/sptc-brief-probe)
===substitution keys docs check===
Claude Code (spt)


claude-spt registered (pointer to a github clone). Now empirically verify primitive (b) — lazy `{adapter_dir}` substitution in `[strings]` at get-string time. Throwaway adapter probe.


PROBE="$LOCALAPPDATA/Temp/sptc-d1-probe"
mkdir -p "$PROBE"
cat > "$PROBE/manifest.toml" <<'EOF'
[adapter]
name = "probe-d1"
kind = "harness"
version = "0.0.1"
min_spt_core_version = "0.16.0"
hostable_types = ["ReadyAgent"]

[identity]
session_id_source = "post_spawn"
parent_ancestor_name = "claude"

[strings]
hook_cmd = "{adapter_dir}/claude-spt hook"
adapter_name_echo = "{adapter_name}"
EOF
spt adapter add "$PROBE" 2>&1 | head -5
echo "=== get-string hook_cmd ==="
spt adapter get-string probe-d1 hook_cmd 2>&1
echo "=== get-string adapter_name_echo ==="
spt adapter get-string probe-d1 adapter_name_echo 2>&1

Probe lazy adapter_dir/adapter_name substitution in strings


ADAPTER_ADD:probe-d1:Harness:Copy (registered)
ADAPTER_INSTALL_SKIP: no [update] avenue (manifest-only adapter)
=== get-string hook_cmd ===
C:\Users\decid\AppData\Local/Temp/sptc-d1-probe/claude-spt hook
=== get-string adapter_name_echo ===
probe-d1


Both D1 primitives GREEN on live 0.16.0: `{adapter_dir}`→install dir, `{adapter_name}`→name, both lazily substituted at get-string. D1 buildable. Clean probe + study existing Rust + CI.


spt adapter remove probe-d1 --force >/dev/null 2>&1; rm -rf "$LOCALAPPDATA/Temp/sptc-d1-probe" 2>/dev/null
echo "=== ci tree ==="; ls -R ci/ 2>/dev/null | head -60
echo "=== tests tree ==="; ls tests/ 2>/dev/null
echo "=== run-gates ==="; cat ci/run-gates.sh 2>/dev/null | head -60

Clean probe + survey ci/tests structure


=== ci tree ===
ci/:
acceptance
digest
docs
git-hooks
hooks
idle-translate
install-hooks.sh
launcher
lib
manifest
psyche
psyche-download
publish
run-gates.sh
setup
subnet

ci/acceptance:
lib.sh
run-acceptance.sh

ci/digest:
build.sh
digest-proof-int.sh
sample.jsonl

ci/docs:
check-docs.sh
gen-llms.sh

ci/git-hooks:
pre-push

ci/hooks:
poll-int.sh

ci/idle-translate:
build.sh
translate-proof-int.sh

ci/launcher:
bind-int.sh

ci/lib:
spt-bus.sh

ci/manifest:
check-manifest.sh
registration-int.sh
validate_manifest.py

ci/psyche:
build.sh
live-relay-int.sh

ci/psyche-download:
psyche-download-int.sh

ci/publish:
=== tests tree ===
acceptance-harness.sh
adapter-archive.sh
ci-gates.sh
docs-gen.sh
hooks-parse.sh
manifest-schema.sh
manifest-shortcut.sh
msys-hazard.sh
skeleton-validate.sh
=== run-gates ===
#!/bin/sh
# Deterministic CI gates for spt-claude-code — each a binary pass/fail, no model in the loop.
# Runnable by hand on any fleet host: this IS the manual fallback. [impl->REQ-CI-GATES]
# [impl->REQ-CI-MANUAL]
# Gates whose artifact doesn't exist yet SKIP with a logged note (no silent caps — a skip is
# always announced so "green" never hides un-run coverage).
set -u
ROOT=$(CDPATH= cd "$(dirname "$0")/.." && pwd)
cd "$ROOT" || exit 2
rc=0
gate() { printf '\n=== GATE: %s ===\n' "$1"; }
fail() { printf 'FAIL: %s\n' "$1"; rc=1; }

gate "shell-syntax (sh -n)"
for f in $(find plugin tests ci -name '*.sh' 2>/dev/null | sort); do
  if sh -n "$f"; then printf 'ok  %s\n' "$f"; else fail "syntax: $f"; fi
done

gate "unit tests (tests/*.sh)"
for t in tests/*.sh; do
  [ -e "$t" ] || continue
  if sh "$t" >/dev/null 2>&1; then printf 'ok  %s\n' "$t"; else fail "unit: $t"; sh "$t" || true; fi
done

gate "digest-extractor (cargo build + test)"
if [ -f "$ROOT/ci/digest/build.sh" ]; then
  if sh "$ROOT/ci/digest/build.sh"; then :; else fail "digest-extractor"; fi
else
  echo "SKIP: no digest extractor build yet"
fi

gate "psyche-runner (cargo build + test)"
if [ -f "$ROOT/ci/psyche/build.sh" ]; then
  if sh "$ROOT/ci/psyche/build.sh"; then :; else fail "psyche-runner"; fi
else
  echo "SKIP: no psyche runner build yet"
fi

gate "idle-translate (cargo build + test)"
if [ -f "$ROOT/ci/idle-translate/build.sh" ]; then
  if sh "$ROOT/ci/idle-translate/build.sh"; then :; else fail "idle-translate"; fi
else
  echo "SKIP: no idle-translate build yet"
fi

gate "traceable-reqs check (requirement coverage)"
if command -v traceable-reqs >/dev/null 2>&1; then
  if traceable-reqs check >/dev/null 2>&1; then echo "ok  coverage green"; else fail "traceable-reqs check"; traceable-reqs check || true; fi
else
  echo "SKIP: traceable-reqs not on PATH (install per docs/TRACEABILITY.md)"
fi

gate "skeleton-validate (cplugs installability)"
if [ -x "$ROOT/ci/publish/validate-skeleton.sh" ] || [ -f "$ROOT/ci/publish/validate-skeleton.sh" ]; then
  if sh "$ROOT/ci/publish/validate-skeleton.sh" >/dev/null 2>&1; then echo "ok  skeleton installable"; else fail "skeleton-validate"; sh "$ROOT/ci/publish/validate-skeleton.sh" || true; fi
else
  echo "SKIP: no skeleton validator yet"
fi

gate "manifest-schema"


Read the closest Rust analog (translate.rs) + current hook tests to match style and migration surface.


1	//! `claude-spt translate` — the `[message-idle-translation-binary]` for the claude-spt adapter
2	//! (spt-core harness-contract v0.13.0+; folded from the standalone cc-spt-idle-translate crate into
3	//! this subcommand via the v0.16.0 `[message-idle-translation-binary].command` seam — ADR-0006/D3).
4	//!
5	//! A lifecycle-managed `stdin -> stdout` JSON-lines filter. spt-core spawns it when the spt-hosted
6	//! endpoint comes online and reaps it on shutdown; it turns each inbound `<EVENT>` envelope into a
7	//! sequence of keystroke-commands that spt-core applies ATOMICALLY to the broker-held PTY (live
8	//! operator input is buffered during emission, so it coexists with an attached `spt rc` controller).
9	//! Scope is IDLE delivery only — busy/mid-turn injection stays the adapter's `[inject]` hook path.
10	//!
11	//! CHECKPOINT BRANCH (agent-driven context reset): an inbound envelope carrying a
12	//! `json="{"checkpoint":"v1",…}"` attribute (a self-sent signal from the PostToolUse commune hook,
13	//! via `spt send --json-payload`) fires a `/clear` + wake macro INSTEAD of normal delivery — the
14	//! agent clearing+rebuilding its own context from its freshest commune. The structured marker lives
15	//! inside the opaque json attr spt-core carries verbatim, so a normal message can never forge it.
16	//! See `checkpoint_wake` / `commands_for_checkpoint`. [impl->REQ-DIST-CHECKPOINT-COMMUNE]
17	//!
18	//! Input protocol (stdin, one JSON object per line):
19	//!   {"type":"init","endpoint_id":…,"node":…}   — first message (handshake; no output)
20	//!   {"type":"event","envelope":"<EVENT…>"}      — one inbound message; the full EVENT envelope
21	//!   {"type":"input"}                            — content-free ping when the operator types (no output)
22	//!
23	//! Output protocol (stdout, one JSON object per line):
24	//!   {"key":"ctrl+s"}     — keystroke command (Claude Code: STASH the current draft input)
25	//!   {"key":"enter"}      — keystroke command (submit the PTY line; see SUBMIT note below)
26	//!   {"delay_ms":50}      — inter-command pause
27	//!   {"text":"<payload>"} — text injection (the envelope; NO trailing \r — it does not submit CC)
28	//!   {"commit":true}      — MANDATORY sequence terminator (release the InjectFloor; see below)
29	//!
30	//! The per-event choreography delivers the message WITHOUT clobbering a half-typed draft the operator
31	//! may have in the input box, then terminates the inject sequence:
32	//!   1. ctrl+s              stash any existing draft
33	//!   2. delay 50ms          let the stash settle
34	//!   3. <envelope>          type the envelope (multi-line: a raw \n after the opening tag and before
35	//!                          the closing </EVENT> — CC soft-newlines a bare \n, so it frames across
36	//!                          lines without submitting; no trailing CR)
37	//!   4. delay 50ms          let the text land in CC's input box before the submit key
38	//!   5. {"key":"enter"}     submit the line
39	//!   6. {"commit":true}     terminate the inject sequence
40	//! No trailing restore keystroke: Claude Code AUTO-RESTORES the stashed draft after the submit, so a
41	//! second ctrl+s would be redundant (it would re-stash, not restore).
42	//!
43	//! SUBMIT IS A REAL ENTER KEY, NOT A TRAILING `\r` (corrected 2026-06-23): we previously rode the
44	//! submit IN the text as a trailing `\r`, on the assumption that spt-core applies `{"text"}` VERBATIM
45	//! to the PTY (broker.rs:1066, :1016-1017) and a `\r` byte == `{"key":"enter"}`
46	//! (`key_to_bytes("enter")->b"\r"`). Empirically that is NOT enough: a `\r` byte in injected text does
47	//! NOT trigger Claude Code's message submission — CC needs the discrete Enter KEY event. So step 5 is
48	//! now a separate `{"key":"enter"}` after the text lands. The verbatim-text application still REQUIRES
49	//! `commands_for_event` to neutralize the envelope's INTERNAL CR/LF (an un-neutralized `\n`/`\r` would
50	//! reach the PTY and could split or corrupt the injection) — only the discrete Enter submits.
51	//!
52	//! TWO DISTINCT SIGNALS — do not conflate them:
53	//!  - The `{"key":"enter"}` in step 5 is the HARNESS-level submit (the discrete keypress CC needs).
54	//!  - The `{"commit":true}` in step 6 is the PROTOCOL-level terminator: `run_inject_worker`
55	//!    (broker.rs:1075-1090) ends an inject sequence ONLY on an explicit `{commit}`. Text/Key/Delay
56	//!    just enqueue; a sequence that emits no `{commit}` hits `INJECT_COMMIT_DEADLINE` (5s,
57	//!    broker.rs:151-169) and FAULTS on every delivery (reverts to raw inject — input isn't lost but
58	//!    each delivery stalls to the deadline). `{commit}` flushes the live controller's buffered input
59	//!    and releases the InjectFloor race-free. Enter submits the line; `{commit}` ends the sequence.
60	//!
61	//! Degenerate fallback (per the published contract): `{"text":payload}{"key":"enter"}{"commit":true}`
62	//! with no choreography — even the bare form MUST commit. (The published manifest doc ORIGINALLY
63	//! omitted `{commit}` from the vocabulary and its degenerate example — a public-surface defect this
64	//! adapter's blind-build caught, logged F-016. The contract is now CORRECTED + live:
65	//! harness-contract/manifest.html documents `{commit}` as the mandatory terminator + the 5s
66	//! commit-deadline + inject-floor release + raw-inject fallback, since v0.13.0.)
67	//! We choreograph because Claude Code's input box supports the ctrl+s draft stash/restore, so an
68	//! inbound message never eats an in-progress draft.
69	//!
70	//! Robustness: this is lifecycle-critical — a panic would drop the idle-delivery pipe for the whole
71	//! session. Malformed lines and unknown `type`s degrade gracefully (skipped, never fatal); unknown
72	//! JSON fields are ignored (forward-compat with newer spt-core). [impl->REQ-DIST-IDLE-TRANSLATE]
73
74	use serde_json::{json, Value};
75	use std::io::{self, BufRead, Write};
76	use std::process::ExitCode;
77
78	/// Inter-command pause spt-core honors between emitted keystroke/text commands (operator spec).
79	const DELAY_MS: u64 = 50;
80
81	/// Pause after `/clear` submits, before the wake text is typed — `/clear` rebuilds the session and
82	/// needs longer to settle than a normal inter-command gap (operator spec, checkpoint macro).
83	const CLEAR_DELAY_MS: u64 = 500;
84
85	/// Wake directive used when a checkpoint carries no explicit `wake` (operator-specified default).
86	const DEFAULT_WAKE: &str = "Proceed with next steps";
87
88	/// Extract the raw (still XML-attr-escaped) value of the `json="…"` attribute from an EVENT
89	/// envelope's opening tag, if present. Returns None when there is no such attribute. The attr value
90	/// is the structured checkpoint payload spt-core carried verbatim from `spt send --json-payload`
91	/// (doyle 2026-06-24): spt-core never parses it — it rides as an opaque envelope attribute. We only
92	/// look inside the opening tag (before the first `>`), and an attr value is XML-escaped so it cannot
93	/// contain a raw `"`, so the first `"` after `json="` terminates it. [impl->REQ-DIST-CHECKPOINT-COMMUNE]
94	fn extract_json_attr(envelope: &str) -> Option<String> {
95	    let gt = envelope.find('>')?; // opening tag ends at the first '>'
96	    let opening = &envelope[..gt];
97	    // Leading space ensures we match a real attribute, not a substring of some other name.
98	    let key = " json=\"";
99	    let ki = opening.find(key)?;
100	    let rest = &opening[ki + key.len()..];
101	    let end = rest.find('"')?;
102	    Some(rest[..end].to_string())
103	}
104
105	/// Reverse XML attribute escaping. spt-core attr-escapes the `json` value; we must unescape it BEFORE
106	/// JSON-parsing. `&` is decoded LAST so an embedded `&quot;` does not double-decode (doyle's
107	/// caveat). This is a SEPARATE escape layer from the message body's `<br>`/HTML-unescape handling.
108	/// [impl->REQ-DIST-CHECKPOINT-COMMUNE]
109	fn xml_attr_unescape(s: &str) -> String {
110	    s.replace(""", "\"")
111	        .replace("<", "<")
112	        .replace(">", ">")
113	        .replace("&", "&") // LAST
114	}
115
116	/// If this envelope carries a `{"checkpoint":"v1", ...}` json attr, return the wake directive (the
117	/// `wake` field, or DEFAULT_WAKE when absent/blank). Any other shape — no json attr, malformed JSON,
118	/// a different/absent `checkpoint` marker — returns None so delivery falls through to the normal
119	/// multi-line path. Collision-proof: the marker lives INSIDE the structured attr value, so a normal
120	/// message body can never forge it. [impl->REQ-DIST-CHECKPOINT-COMMUNE]
121	fn checkpoint_wake(envelope: &str) -> Option<String> {
122	    let raw = extract_json_attr(envelope)?;
123	    let v: Value = serde_json::from_str(&xml_attr_unescape(&raw)).ok()?;
124	    if v.get("checkpoint").and_then(Value::as_str) != Some("v1") {
125	        return None;
126	    }
127	    let wake = v
128	        .get("wake")
129	        .and_then(Value::as_str)
130	        .map(str::trim)
131	        .filter(|s| !s.is_empty())
132	        .unwrap_or(DEFAULT_WAKE);
133	    Some(wake.to_string())
134	}
135
136	/// The checkpoint clear+wake macro: stash any lingering draft, submit `/clear` to reset the session,
137	/// wait for it to settle, then type + submit the wake directive as the first post-clear turn. This is
138	/// the agent-driven context reset (the operator's manual `/clear` done by the agent itself) — fired
139	/// when a self-sent checkpoint signal loops back through this binary. Stray CR/LF in the wake collapse
140	/// to spaces so the wake submits as one turn. Terminated by the mandatory `{commit}` like every
141	/// sequence. [impl->REQ-DIST-CHECKPOINT-COMMUNE]
142	fn commands_for_checkpoint(wake: &str) -> Vec<Value> {
143	    let wake_clean: String = wake
144	        .chars()
145	        .map(|c| if c == '\r' || c == '\n' { ' ' } else { c })
146	        .collect();
147	    vec![
148	        json!({ "key": "ctrl+s" }),            // 1. stash any lingering input
149	        json!({ "delay_ms": DELAY_MS }),       // 2. let the stash settle
150	        json!({ "text": "/clear" }),           // 3. type the clear command
151	        json!({ "key": "enter" }),             // 4. submit /clear (resets the session)
152	        json!({ "delay_ms": CLEAR_DELAY_MS }), // 5. let /clear rebuild before typing the wake
153	        json!({ "text": wake_clean }),         // 6. type the wake directive
154	        json!({ "key": "enter" }),             // 7. submit the wake — the first post-checkpoint turn
155	        json!({ "commit": true }),             // 8. terminate the inject sequence
156	    ]
157	}
158
159	/// Frame a (CR/LF-sanitized) EVENT envelope across MULTIPLE LINES for visual distinction in CC's
160	/// input box: a raw `\n` after the opening `<EVENT …>` tag and a raw `\n` before the closing
161	/// `</EVENT>`, so an inbound idle message renders as
162	///
163	/// ```text
164	/// <EVENT type="msg" from="doyle">
165	/// body
166	/// </EVENT>
167	/// ```
168	///
169	/// instead of one dense line. This is safe because spt-core writes `{"text"}` byte-verbatim
170	/// INCLUDING `\n` (broker.rs:1016-1017, doyle 2026-06-20) and Claude Code SOFT-NEWLINES on a bare
171	/// `\n` in the input box rather than submitting — empirically gated 2026-06-24 (a two-line `{text}`
172	/// landed as ONE user turn with the `\n` preserved, no early submit). Cyan/SGR distinction is
173	/// impossible (CC's input handling eats SGR bytes; user-turns are theme-fixed), so a multi-line
174	/// plain-text frame is the only viable visual distinction.
175	///
176	/// `s` must already have its stray CR/LF neutralized (see `commands_for_event`) — the only newlines
177	/// in the result are the two we deliberately insert at the structural seams. Degenerate inputs that
178	/// don't match `<…>…</EVENT>` (e.g. an `<EVENT-PART …>` chunk, or a non-envelope payload) fall back
179	/// to the single-line form unchanged — never panic, never corrupt. [impl->REQ-DIST-IDLE-MULTILINE]
180	fn frame_envelope(s: &str) -> String {
181	    // Opening tag ends at the first '>' (EVENT bodies HTML-escape '>' as >, and attribute values
182	    // carry no raw '>', so the first '>' is the opening tag's close).
183	    let Some(gt) = s.find('>') else { return s.to_string() };
184	    // Closing tag is the last literal "</EVENT>" ("</EVENT-PART>" does not contain it, so EVENT-PART
185	    // chunks correctly fall through to the single-line form).
186	    let Some(close) = s.rfind("</EVENT>") else { return s.to_string() };
187	    // The body sits strictly between the opening '>' and the closing tag.
188	    if close < gt + 1 {
189	        return s.to_string();
190	    }
191	    let opening = &s[..=gt];
192	    let body = &s[gt + 1..close];
193	    let closing = &s[close..];
194	    format!("{opening}\n{body}\n{closing}")
195	}
196
197	/// Build the keystroke-command sequence for one inbound EVENT envelope.
198	///
199	/// `envelope` is the full `<EVENT…>…</EVENT>` string. The envelope is single-line by contract (it
200	/// encodes newlines as the literal `<br>` token), but we defensively strip any raw CR/LF so a stray
201	/// newline can never split or corrupt the injection. (Necessary because spt-core applies `{"text"}`
202	/// verbatim — broker.rs:1066/:1016-1017, doyle 2026-06-20 — so any internal CR/LF would otherwise
203	/// reach the PTY.) After sanitizing, `frame_envelope` re-inserts exactly two deliberate `\n`s at the
204	/// opening-tag / body / closing-tag seams to render the message across multiple lines (CC soft-newlines
205	/// a bare `\n`; empirically gated 2026-06-24). The submit is a DISCRETE `{"key":"enter"}` AFTER the
206	/// text — a trailing `\r` byte in the text does NOT submit a Claude Code message (corrected
207	/// 2026-06-23), and the deliberate `\n`s soft-newline rather than submit. The closing `{"commit"}`
208	/// is the MANDATORY inject-sequence terminator (broker.rs:1075-1090; no-commit FAULTs at the 5s
209	/// INJECT_COMMIT_DEADLINE). [impl->REQ-DIST-IDLE-TRANSLATE] [impl->REQ-DIST-IDLE-MULTILINE]
210	fn commands_for_event(envelope: &str) -> Vec<Value> {
211	    // Checkpoint branch FIRST: a self-sent {"checkpoint":"v1",…} json attr fires the clear+wake macro
212	    // instead of normal delivery (the agent-driven context reset). Any other envelope falls through.
213	    if let Some(wake) = checkpoint_wake(envelope) {
214	        return commands_for_checkpoint(&wake);
215	    }
216	    let sanitized: String = envelope
217	        .chars()
218	        .map(|c| if c == '\r' || c == '\n' { ' ' } else { c })
219	        .collect();
220	    let framed = frame_envelope(&sanitized); // re-insert the two deliberate structural newlines
221	    vec![
222	        json!({ "key": "ctrl+s" }),      // 1. stash any existing draft
223	        json!({ "delay_ms": DELAY_MS }), // 2. let the stash settle
224	        json!({ "text": framed }),       // 3. type the multi-line envelope — only the framing \n's; NO trailing CR
225	        json!({ "delay_ms": DELAY_MS }), // 4. let the text land in CC's input box before the submit key
226	        json!({ "key": "enter" }),       // 5. submit — a real Enter keypress (CC needs the key event, not a \r byte)
227	        json!({ "commit": true }),       // 6. terminate the inject sequence (release the InjectFloor)
228	    ]
229	}
230
231	/// Dispatch one parsed input line to its output commands. `init`/`input` (and any unknown `type`)
232	/// produce nothing; only `event` emits the choreography. Returns an empty vec for no-output lines.
233	fn commands_for_line(v: &Value) -> Vec<Value> {
234	    match v.get("type").and_then(Value::as_str) {
235	        Some("event") => match v.get("envelope").and_then(Value::as_str) {
236	            Some(env) => commands_for_event(env),
237	            None => Vec::new(), // event without an envelope: nothing to deliver
238	        },
239	        // "init" handshake, "input" operator-typing ping, or any unknown type: no output.
240	        _ => Vec::new(),
241	    }
242	}
243
244	/// `claude-spt translate` entry — the [message-idle-translation-binary] stdin->stdout JSON-lines
245	/// filter (was the cc-spt-idle-translate crate; folded in via the v0.16.0 `command` seam, ADR-0006/D3).
246	/// Reads no argv: the protocol is entirely stdin/stdout, unchanged from the standalone binary.
247	pub fn run() -> ExitCode {
248	    let stdin = io::stdin();
249	    let stdout = io::stdout();
250	    let mut out = stdout.lock();
251
252	    for line in stdin.lock().lines() {
253	        let line = match line {
254	            Ok(l) => l,
255	            Err(_) => break, // stdin closed/errored: spt-core is tearing us down.
256	        };
257	        let trimmed = line.trim();
258	        if trimmed.is_empty() {
259	            continue;
260	        }
261	        let v: Value = match serde_json::from_str(trimmed) {
262	            Ok(v) => v,
263	            Err(e) => {
264	                // Skip malformed lines — never fatal. stderr lands in spt-core's log, not the PTY.
265	                eprintln!("claude-spt translate: skipping unparseable line: {e}");
266	                continue;
267	            }
268	        };
269	        for cmd in commands_for_line(&v) {
270	            // One compact JSON object per line; flush per command so spt-core applies promptly.
271	            if writeln!(out, "{cmd}").is_err() || out.flush().is_err() {
272	                return ExitCode::SUCCESS; // stdout closed: endpoint gone.
273	            }
274	        }
275	    }
276	    ExitCode::SUCCESS
277	}
278
279	#[cfg(test)]
280	mod tests {
281	    use super::*;
282
283	    fn keys(cmds: &[Value]) -> Vec<String> {
284	        cmds.iter()
285	            .map(|c| {
286	                if let Some(k) = c.get("key").and_then(Value::as_str) {
287	                    format!("key:{k}")
288	                } else if let Some(d) = c.get("delay_ms").and_then(Value::as_u64) {
289	                    format!("delay:{d}")
290	                } else if let Some(t) = c.get("text").and_then(Value::as_str) {
291	                    format!("text:{t}")
292	                } else if c.get("commit").and_then(Value::as_bool) == Some(true) {
293	                    "commit".into()
294	                } else {
295	                    "?".into()
296	                }
297	            })
298	            .collect()
299	    }
300
301	    #[test]
302	    fn event_emits_the_stash_submit_commit_choreography() {
303	        let cmds = commands_for_event("<EVENT type=\"msg\" from=\"doyle\">hi</EVENT>");
304	        assert_eq!(
305	            keys(&cmds),
306	            vec![
307	                "key:ctrl+s".to_string(),
308	                "delay:50".to_string(),
309	                // The envelope is framed across lines: \n after the opening tag, \n before </EVENT>.
310	                "text:<EVENT type=\"msg\" from=\"doyle\">\nhi\n</EVENT>".to_string(),
311	                "delay:50".to_string(),
312	                "key:enter".to_string(),
313	                "commit".to_string(),
314	            ]
315	        );
316	    }
317
318	    // [unit->REQ-DIST-IDLE-MULTILINE]
319	    #[test]
320	    fn envelope_is_framed_across_three_lines() {
321	        // The deliberate visual-distinction frame: opening tag · \n · body · \n · closing tag.
322	        // CC soft-newlines a bare \n (empirically gated 2026-06-24), so this renders as one user
323	        // turn spanning three lines, not an early submit.
324	        let cmds = commands_for_event("<EVENT type=\"msg\" from=\"doyle\">hello there</EVENT>");
325	        let text = cmds[2].get("text").and_then(Value::as_str).unwrap();
326	        assert_eq!(
327	            text,
328	            "<EVENT type=\"msg\" from=\"doyle\">\nhello there\n</EVENT>"
329	        );
330	        // Exactly two deliberate newlines (the structural seams) and zero CR.
331	        assert_eq!(text.matches('\n').count(), 2, "exactly two framing newlines");
332	        assert_eq!(text.matches('\r').count(), 0, "no CR");
333	        // The three lines are: opening tag, body, closing tag.
334	        let lines: Vec<&str> = text.split('\n').collect();
335	        assert_eq!(lines[0], "<EVENT type=\"msg\" from=\"doyle\">");
336	        assert_eq!(lines[1], "hello there");
337	        assert_eq!(lines[2], "</EVENT>");
338	    }
339
340	    #[test]
341	    fn frame_envelope_falls_back_on_non_envelope_payloads() {
342	        // No opening '>' or no closing </EVENT>: leave the text single-line (never panic/corrupt).
343	        assert_eq!(frame_envelope("plain payload, no tags"), "plain payload, no tags");
344	        assert_eq!(frame_envelope("<EVENT no close"), "<EVENT no close");
345	        // An EVENT-PART chunk has no literal "</EVENT>" (it ends "</EVENT-PART>"), so it falls back.
346	        let part = "<EVENT-PART seq=\"1/2\" id=\"abcd1234\">chunk</EVENT-PART>";
347	        assert_eq!(frame_envelope(part), part);
348	        assert!(!frame_envelope(part).contains('\n'), "EVENT-PART stays single-line");
349	    }
350
351	    #[test]
352	    fn frame_envelope_handles_attrless_and_empty_body() {
353	        // Attribute-less opening tag still frames on the first '>'.
354	        assert_eq!(frame_envelope("<EVENT>body</EVENT>"), "<EVENT>\nbody\n</EVENT>");
355	        // Empty body yields an empty middle line — harmless.
356	        assert_eq!(frame_envelope("<EVENT></EVENT>"), "<EVENT>\n\n</EVENT>");
357	    }
358
359	    #[test]
360	    fn sequence_terminates_with_a_mandatory_commit() {
361	        // broker.rs run_inject_worker ends a sequence ONLY on {commit:true}; without it the broker
362	        // FAULTs at the 5s INJECT_COMMIT_DEADLINE on every delivery. The terminator MUST be last.
363	        let cmds = commands_for_event("m");
364	        assert_eq!(cmds.last().unwrap().get("commit").and_then(Value::as_bool), Some(true));
365	        // Exactly one commit, and it is the final command (nothing emits after the terminator).
366	        let commits = cmds.iter().filter(|c| c.get("commit").is_some()).count();
367	        assert_eq!(commits, 1, "exactly one terminating commit");
368	    }
369
370	    #[test]
371	    fn no_trailing_restore_keystroke() {
372	        // CC auto-restores the stashed draft after the submit, so there is EXACTLY ONE ctrl+s (the
373	        // stash) — never a trailing restore. (The sequence ends with the {commit} terminator.)
374	        let cmds = commands_for_event("m");
375	        let ctrl_s = cmds.iter().filter(|c| c.get("key").and_then(Value::as_str) == Some("ctrl+s")).count();
376	        assert_eq!(ctrl_s, 1, "exactly one ctrl+s (stash only); CC auto-restores");
377	    }
378
379	    #[test]
380	    fn submit_is_a_discrete_enter_key_not_a_trailing_cr() {
381	        let cmds = commands_for_event("payload");
382	        // The text carries NO trailing \r — a \r byte does not submit a Claude Code message.
383	        let text = cmds[2].get("text").and_then(Value::as_str).unwrap();
384	        assert_eq!(text, "payload");
385	        assert!(!text.contains('\r'), "text must NOT carry a submit \\r");
386	        // The submit is a discrete enter keypress, after the text.
387	        let enters = cmds.iter().filter(|c| c.get("key").and_then(Value::as_str) == Some("enter")).count();
388	        assert_eq!(enters, 1, "exactly one enter key (the submit)");
389	        // ...and it comes AFTER the text command.
390	        let text_idx = cmds.iter().position(|c| c.get("text").is_some()).unwrap();
391	        let enter_idx = cmds.iter().position(|c| c.get("key").and_then(Value::as_str) == Some("enter")).unwrap();
392	        assert!(enter_idx > text_idx, "the enter submit must follow the text");
393	    }
394
395	    #[test]
396	    fn stash_precedes_the_submit() {
397	        let cmds = commands_for_event("x");
398	        // ctrl+s (stash) is FIRST; the text is cmds[2]; enter submits; the {commit} terminator is last.
399	        assert_eq!(cmds.first().unwrap().get("key").and_then(Value::as_str), Some("ctrl+s"));
400	        assert!(cmds[2].get("text").is_some());
401	        assert_eq!(cmds.len(), 6);
402	    }
403
404	    #[test]
405	    fn raw_newlines_in_envelope_are_neutralized() {
406	        // A stray CR/LF must not split or corrupt the injection — only the discrete enter submits.
407	        // A non-envelope payload collapses embedded newlines to spaces and stays single-line.
408	        let cmds = commands_for_event("a\nb\r\nc");
409	        let text = cmds[2].get("text").and_then(Value::as_str).unwrap();
410	        assert_eq!(text, "a b  c");
411	        assert_eq!(text.matches('\r').count(), 0, "no raw CR survives");
412	        assert_eq!(text.matches('\n').count(), 0, "no raw LF survives");
413	    }
414
415	    #[test]
416	    fn stray_newlines_neutralized_then_only_framing_newlines_remain() {
417	        // STRAY CR/LF inside a real envelope collapse to spaces FIRST; then framing re-inserts
418	        // EXACTLY the two deliberate structural newlines. No stray newline can split the injection.
419	        let cmds = commands_for_event("<EVENT type=\"msg\">a\nb\r\nc</EVENT>");
420	        let text = cmds[2].get("text").and_then(Value::as_str).unwrap();
421	        assert_eq!(text, "<EVENT type=\"msg\">\na b  c\n</EVENT>");
422	        assert_eq!(text.matches('\r').count(), 0, "no raw CR survives");
423	        assert_eq!(text.matches('\n').count(), 2, "only the two framing newlines survive");
424	    }
425
426	    /// Build an EVENT envelope carrying a `json` attr whose value is the XML-attr-escaped form of
427	    /// `obj` — mirrors what spt-core composes from `spt send --json-payload`.
428	    fn envelope_with_json(obj: &str) -> String {
429	        let escaped = obj
430	            .replace('&', "&")
431	            .replace('<', "<")
432	            .replace('>', ">")
433	            .replace('"', """);
434	        format!("<EVENT type=\"msg\" from=\"self\" json=\"{escaped}\">checkpoint</EVENT>")
435	    }
436
437	    // [unit->REQ-DIST-CHECKPOINT-COMMUNE]
438	    #[test]
439	    fn checkpoint_json_attr_fires_the_clear_wake_macro() {
440	        let env = envelope_with_json(r#"{"checkpoint":"v1","wake":"Resume T2c now"}"#);
441	        let cmds = commands_for_event(&env);
442	        assert_eq!(
443	            keys(&cmds),
444	            vec![
445	                "key:ctrl+s".to_string(),
446	                "delay:50".to_string(),
447	                "text:/clear".to_string(),
448	                "key:enter".to_string(),
449	                "delay:500".to_string(),
450	                "text:Resume T2c now".to_string(),
451	                "key:enter".to_string(),
452	                "commit".to_string(),
453	            ]
454	        );
455	    }
456
457	    #[test]
458	    fn checkpoint_without_wake_uses_the_default() {
459	        let env = envelope_with_json(r#"{"checkpoint":"v1"}"#);
460	        let cmds = commands_for_event(&env);
461	        let wake = cmds[5].get("text").and_then(Value::as_str).unwrap();
462	        assert_eq!(wake, "Proceed with next steps");
463	        // Blank wake also falls back to the default.
464	        let env2 = envelope_with_json(r#"{"checkpoint":"v1","wake":"   "}"#);
465	        let wake2 = commands_for_event(&env2)[5].get("text").and_then(Value::as_str).unwrap().to_string();
466	        assert_eq!(wake2, "Proceed with next steps");
467	    }
468
469	    #[test]
470	    fn checkpoint_macro_submits_clear_then_wake_and_commits() {
471	        let cmds = commands_for_checkpoint("go");
472	        // /clear is typed+submitted BEFORE the wake text+submit; the 500ms settle sits between them.
473	        let texts: Vec<&str> = cmds.iter().filter_map(|c| c.get("text").and_then(Value::as_str)).collect();
474	        assert_eq!(texts, vec!["/clear", "go"]);
475	        // Two discrete enter submits (clear, wake) and exactly one terminating commit, last.
476	        let enters = cmds.iter().filter(|c| c.get("key").and_then(Value::as_str) == Some("enter")).count();
477	        assert_eq!(enters, 2, "two submits: /clear and the wake");
478	        assert_eq!(cmds.last().unwrap().get("commit").and_then(Value::as_bool), Some(true));
479	        // The longer post-/clear settle is present.
480	        assert!(cmds.iter().any(|c| c.get("delay_ms").and_then(Value::as_u64) == Some(500)));
481	    }
482
483	    #[test]
484	    fn non_checkpoint_envelopes_deliver_normally() {
485	        // A different marker, a plain message, and a body that merely MENTIONS the token all deliver
486	        // normally (collision-proof: the marker only counts inside a structured json attr).
487	        for env in [
488	            envelope_with_json(r#"{"checkpoint":"v2"}"#),
489	            envelope_with_json(r#"{"note":"hi"}"#),
490	            "<EVENT type=\"msg\" from=\"a\">please run a checkpoint v1</EVENT>".to_string(),
491	            "<EVENT type=\"msg\" from=\"a\">hi</EVENT>".to_string(),
492	        ] {
493	            let cmds = commands_for_event(&env);
494	            // Normal delivery is the 6-step choreography ending text-then-enter-then-commit — NOT the
495	            // 8-step macro; in particular it never types "/clear".
496	            assert_eq!(cmds.len(), 6, "normal delivery for: {env}");
497	            assert!(
498	                !cmds.iter().any(|c| c.get("text").and_then(Value::as_str) == Some("/clear")),
499	                "must NOT clear for: {env}"
500	            );
501	        }
502	    }
503
504	    #[test]
505	    fn malformed_checkpoint_json_falls_through_to_normal_delivery() {
506	        // A json attr that isn't valid JSON must not panic or macro — degrade to normal delivery.
507	        let env = "<EVENT type=\"msg\" from=\"a\" json=\"{not valid json\">b</EVENT>";
508	        let cmds = commands_for_event(env);
509	        assert_eq!(cmds.len(), 6);
510	        assert!(!cmds.iter().any(|c| c.get("text").and_then(Value::as_str) == Some("/clear")));
511	    }
512
513	    #[test]
514	    fn xml_attr_unescape_decodes_amp_last() {
515	        // &quot; must decode to " (one level), not "" — & is applied LAST.
516	        assert_eq!(xml_attr_unescape("&quot;"), """);
517	        assert_eq!(xml_attr_unescape(""<>&"), "\"<>&");
518	    }
519
520	    #[test]
521	    fn extract_json_attr_pulls_the_attr_value() {
522	        let env = "<EVENT type=\"msg\" from=\"a\" json=\""v"\">b</EVENT>";
523	        assert_eq!(extract_json_attr(env).as_deref(), Some(""v""));
524	        // No json attr → None.
525	        assert_eq!(extract_json_attr("<EVENT type=\"msg\">b</EVENT>"), None);
526	    }
527
528	    #[test]
529	    fn event_dispatch_via_line() {
530	        let v: Value = serde_json::from_str(
531	            r#"{"type":"event","envelope":"<EVENT type=\"msg\" from=\"a\">b</EVENT>"}"#,
532	        )
533	        .unwrap();
534	        let cmds = commands_for_line(&v);
535	        assert_eq!(cmds.len(), 6);
536	        assert_eq!(cmds[0].get("key").and_then(Value::as_str), Some("ctrl+s"));
537	    }
538
539	    #[test]
540	    fn init_and_input_and_unknown_types_emit_nothing() {
541	        for line in [
542	            r#"{"type":"init","endpoint_id":"perri","node":"HFENDULEAM"}"#,
543	            r#"{"type":"input"}"#,
544	            r#"{"type":"future-thing","x":1}"#,
545	            r#"{"no_type":true}"#,
546	        ] {
547	            let v: Value = serde_json::from_str(line).unwrap();
548	            assert!(commands_for_line(&v).is_empty(), "no output for: {line}");
549	        }
550	    }
551
552	    #[test]
553	    fn event_without_envelope_emits_nothing() {
554	        let v: Value = serde_json::from_str(r#"{"type":"event"}"#).unwrap();
555	        assert!(commands_for_line(&v).is_empty());
556	    }
557
558	    #[test]
559	    fn unknown_fields_are_ignored_forward_compat() {
560	        // A newer spt-core adding fields must not break dispatch (unknown keys ignored).
561	        let v: Value = serde_json::from_str(
562	            r#"{"type":"event","envelope":"<EVENT>z</EVENT>","priority":9,"trace_id":"t"}"#,
563	        )
564	        .unwrap();
565	        assert_eq!(commands_for_line(&v).len(), 6);
566	    }
567
568	    #[test]
569	    fn output_commands_are_single_field_objects() {
570	        // Each emitted command is exactly one of the contract shapes (key | delay_ms | text | commit).
571	        for c in commands_for_event("m") {
572	            let obj = c.as_object().unwrap();
573	            assert_eq!(obj.len(), 1, "each command carries exactly one field: {c}");
574	            let k = obj.keys().next().unwrap().as_str();
575	            assert!(matches!(k, "key" | "delay_ms" | "text" | "commit"), "unexpected field {k}");
576	        }
577	    }
578	}
579

//! `claude-spt translate` — the `[message-idle-translation-binary]` for the claude-spt adapter
//! (spt-core harness-contract v0.13.0+; folded from the standalone cc-spt-idle-translate crate into
//! this subcommand via the v0.16.0 `[message-idle-translation-binary].command` seam — ADR-0006/D3).
//!
//! 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.
//!
//! CHECKPOINT BRANCH (agent-driven context reset): an inbound envelope carrying a
//! `json="{"checkpoint":"v1",…}"` attribute (a self-sent signal from the PostToolUse commune hook,
//! via `spt send --json-payload`) fires a `/clear` + wake macro INSTEAD of normal delivery — the
//! agent clearing+rebuilding its own context from its freshest commune. The structured marker lives
//! inside the opaque json attr spt-core carries verbatim, so a normal message can never forge it.
//! See `checkpoint_wake` / `commands_for_checkpoint`. [impl->REQ-DIST-CHECKPOINT-COMMUNE]
//!
//! 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 (multi-line: a raw \n after the opening tag and before
//!                          the closing </EVENT> — CC soft-newlines a bare \n, so it frames across
//!                          lines without submitting; 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};
use std::process::ExitCode;

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

/// Pause after `/clear` submits, before the wake text is typed — `/clear` rebuilds the session and
/// needs longer to settle than a normal inter-command gap (operator spec, checkpoint macro).
const CLEAR_DELAY_MS: u64 = 500;

/// Wake directive used when a checkpoint carries no explicit `wake` (operator-specified default).
const DEFAULT_WAKE: &str = "Proceed with next steps";

/// Extract the raw (still XML-attr-escaped) value of the `json="…"` attribute from an EVENT
/// envelope's opening tag, if present. Returns None when there is no such attribute. The attr value
/// is the structured checkpoint payload spt-core carried verbatim from `spt send --json-payload`
/// (doyle 2026-06-24): spt-core never parses it — it rides as an opaque envelope attribute. We only
/// look inside the opening tag (before the first `>`), and an attr value is XML-escaped so it cannot
/// contain a raw `"`, so the first `"` after `json="` terminates it. [impl->REQ-DIST-CHECKPOINT-COMMUNE]
fn extract_json_attr(envelope: &str) -> Option<String> {
    let gt = envelope.find('>')?; // opening tag ends at the first '>'
    let opening = &envelope[..gt];
    // Leading space ensures we match a real attribute, not a substring of some other name.
    let key = " json=\"";
    let ki = opening.find(key)?;
    let rest = &opening[ki + key.len()..];
    let end = rest.find('"')?;
    Some(rest[..end].to_string())
}

/// Reverse XML attribute escaping. spt-core attr-escapes the `json` value; we must unescape it BEFORE
/// JSON-parsing. `&` is decoded LAST so an embedded `&quot;` does not double-decode (doyle's
/// caveat). This is a SEPARATE escape layer from the message body's `<br>`/HTML-unescape handling.
/// [impl->REQ-DIST-CHECKPOINT-COMMUNE]
fn xml_attr_unescape(s: &str) -> String {
    s.replace(""", "\"")
        .replace("<", "<")
        .replace(">", ">")
        .replace("&", "&") // LAST
}

/// If this envelope carries a `{"checkpoint":"v1", ...}` json attr, return the wake directive (the
/// `wake` field, or DEFAULT_WAKE when absent/blank). Any other shape — no json attr, malformed JSON,
/// a different/absent `checkpoint` marker — returns None so delivery falls through to the normal
/// multi-line path. Collision-proof: the marker lives INSIDE the structured attr value, so a normal
/// message body can never forge it. [impl->REQ-DIST-CHECKPOINT-COMMUNE]
fn checkpoint_wake(envelope: &str) -> Option<String> {
    let raw = extract_json_attr(envelope)?;
    let v: Value = serde_json::from_str(&xml_attr_unescape(&raw)).ok()?;
    if v.get("checkpoint").and_then(Value::as_str) != Some("v1") {
        return None;
    }
    let wake = v
        .get("wake")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .unwrap_or(DEFAULT_WAKE);
    Some(wake.to_string())
}

/// The checkpoint clear+wake macro: stash any lingering draft, submit `/clear` to reset the session,
/// wait for it to settle, then type + submit the wake directive as the first post-clear turn. This is
/// the agent-driven context reset (the operator's manual `/clear` done by the agent itself) — fired
/// when a self-sent checkpoint signal loops back through this binary. Stray CR/LF in the wake collapse
/// to spaces so the wake submits as one turn. Terminated by the mandatory `{commit}` like every
/// sequence. [impl->REQ-DIST-CHECKPOINT-COMMUNE]
fn commands_for_checkpoint(wake: &str) -> Vec<Value> {
    let wake_clean: String = wake
        .chars()
        .map(|c| if c == '\r' || c == '\n' { ' ' } else { c })
        .collect();
    vec![
        json!({ "key": "ctrl+s" }),            // 1. stash any lingering input
        json!({ "delay_ms": DELAY_MS }),       // 2. let the stash settle
        json!({ "text": "/clear" }),           // 3. type the clear command
        json!({ "key": "enter" }),             // 4. submit /clear (resets the session)
        json!({ "delay_ms": CLEAR_DELAY_MS }), // 5. let /clear rebuild before typing the wake
        json!({ "text": wake_clean }),         // 6. type the wake directive
        json!({ "key": "enter" }),             // 7. submit the wake — the first post-checkpoint turn
        json!({ "commit": true }),             // 8. terminate the inject sequence
    ]
}

/// Frame a (CR/LF-sanitized) EVENT envelope across MULTIPLE LINES for visual distinction in CC's
/// input box: a raw `\n` after the opening `<EVENT …>` tag and a raw `\n` before the closing
/// `</EVENT>`, so an inbound idle message renders as
///
/// ```text
/// <EVENT type="msg" from="doyle">
/// body
/// </EVENT>
/// ```
///
/// instead of one dense line. This is safe because spt-core writes `{"text"}` byte-verbatim
/// INCLUDING `\n` (broker.rs:1016-1017, doyle 2026-06-20) and Claude Code SOFT-NEWLINES on a bare
/// `\n` in the input box rather than submitting — empirically gated 2026-06-24 (a two-line `{text}`
/// landed as ONE user turn with the `\n` preserved, no early submit). Cyan/SGR distinction is
/// impossible (CC's input handling eats SGR bytes; user-turns are theme-fixed), so a multi-line
/// plain-text frame is the only viable visual distinction.
///
/// `s` must already have its stray CR/LF neutralized (see `commands_for_event`) — the only newlines
/// in the result are the two we deliberately insert at the structural seams. Degenerate inputs that
/// don't match `<…>…</EVENT>` (e.g. an `<EVENT-PART …>` chunk, or a non-envelope payload) fall back
/// to the single-line form unchanged — never panic, never corrupt. [impl->REQ-DIST-IDLE-MULTILINE]
fn frame_envelope(s: &str) -> String {
    // Opening tag ends at the first '>' (EVENT bodies HTML-escape '>' as >, and attribute values
    // carry no raw '>', so the first '>' is the opening tag's close).
    let Some(gt) = s.find('>') else { return s.to_string() };
    // Closing tag is the last literal "</EVENT>" ("</EVENT-PART>" does not contain it, so EVENT-PART
    // chunks correctly fall through to the single-line form).
    let Some(close) = s.rfind("</EVENT>") else { return s.to_string() };
    // The body sits strictly between the opening '>' and the closing tag.
    if close < gt + 1 {
        return s.to_string();
    }
    let opening = &s[..=gt];
    let body = &s[gt + 1..close];
    let closing = &s[close..];
    format!("{opening}\n{body}\n{closing}")
}

/// 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.) After sanitizing, `frame_envelope` re-inserts exactly two deliberate `\n`s at the
/// opening-tag / body / closing-tag seams to render the message across multiple lines (CC soft-newlines
/// a bare `\n`; empirically gated 2026-06-24). 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), and the deliberate `\n`s soft-newline rather than submit. 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] [impl->REQ-DIST-IDLE-MULTILINE]
fn commands_for_event(envelope: &str) -> Vec<Value> {
    // Checkpoint branch FIRST: a self-sent {"checkpoint":"v1",…} json attr fires the clear+wake macro
    // instead of normal delivery (the agent-driven context reset). Any other envelope falls through.
    if let Some(wake) = checkpoint_wake(envelope) {
        return commands_for_checkpoint(&wake);
    }
    let sanitized: String = envelope
        .chars()
        .map(|c| if c == '\r' || c == '\n' { ' ' } else { c })
        .collect();
    let framed = frame_envelope(&sanitized); // re-insert the two deliberate structural newlines
    vec![
        json!({ "key": "ctrl+s" }),      // 1. stash any existing draft
        json!({ "delay_ms": DELAY_MS }), // 2. let the stash settle
        json!({ "text": framed }),       // 3. type the multi-line envelope — only the framing \n's; NO trailing CR
        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(),
    }
}

/// `claude-spt translate` entry — the [message-idle-translation-binary] stdin->stdout JSON-lines
/// filter (was the cc-spt-idle-translate crate; folded in via the v0.16.0 `command` seam, ADR-0006/D3).
/// Reads no argv: the protocol is entirely stdin/stdout, unchanged from the standalone binary.
pub fn run() -> ExitCode {
    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!("claude-spt 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 ExitCode::SUCCESS; // stdout closed: endpoint gone.
            }
        }
    }
    ExitCode::SUCCESS
}

#[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(),
                // The envelope is framed across lines: \n after the opening tag, \n before </EVENT>.
                "text:<EVENT type=\"msg\" from=\"doyle\">\nhi\n</EVENT>".to_string(),
                "delay:50".to_string(),
                "key:enter".to_string(),
                "commit".to_string(),
            ]
        );
    }

    // [unit->REQ-DIST-IDLE-MULTILINE]
    #[test]
    fn envelope_is_framed_across_three_lines() {
        // The deliberate visual-distinction frame: opening tag · \n · body · \n · closing tag.
        // CC soft-newlines a bare \n (empirically gated 2026-06-24), so this renders as one user
        // turn spanning three lines, not an early submit.
        let cmds = commands_for_event("<EVENT type=\"msg\" from=\"doyle\">hello there</EVENT>");
        let text = cmds[2].get("text").and_then(Value::as_str).unwrap();
        assert_eq!(
            text,
            "<EVENT type=\"msg\" from=\"doyle\">\nhello there\n</EVENT>"
        );
        // Exactly two deliberate newlines (the structural seams) and zero CR.
        assert_eq!(text.matches('\n').count(), 2, "exactly two framing newlines");
        assert_eq!(text.matches('\r').count(), 0, "no CR");
        // The three lines are: opening tag, body, closing tag.
        let lines: Vec<&str> = text.split('\n').collect();
        assert_eq!(lines[0], "<EVENT type=\"msg\" from=\"doyle\">");
        assert_eq!(lines[1], "hello there");
        assert_eq!(lines[2], "</EVENT>");
    }

    #[test]
    fn frame_envelope_falls_back_on_non_envelope_payloads() {
        // No opening '>' or no closing </EVENT>: leave the text single-line (never panic/corrupt).
        assert_eq!(frame_envelope("plain payload, no tags"), "plain payload, no tags");
        assert_eq!(frame_envelope("<EVENT no close"), "<EVENT no close");
        // An EVENT-PART chunk has no literal "</EVENT>" (it ends "</EVENT-PART>"), so it falls back.
        let part = "<EVENT-PART seq=\"1/2\" id=\"abcd1234\">chunk</EVENT-PART>";
        assert_eq!(frame_envelope(part), part);
        assert!(!frame_envelope(part).contains('\n'), "EVENT-PART stays single-line");
    }

    #[test]
    fn frame_envelope_handles_attrless_and_empty_body() {
        // Attribute-less opening tag still frames on the first '>'.
        assert_eq!(frame_envelope("<EVENT>body</EVENT>"), "<EVENT>\nbody\n</EVENT>");
        // Empty body yields an empty middle line — harmless.
        assert_eq!(frame_envelope("<EVENT></EVENT>"), "<EVENT>\n\n</EVENT>");
    }

    #[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.
        // A non-envelope payload collapses embedded newlines to spaces and stays single-line.
        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 stray_newlines_neutralized_then_only_framing_newlines_remain() {
        // STRAY CR/LF inside a real envelope collapse to spaces FIRST; then framing re-inserts
        // EXACTLY the two deliberate structural newlines. No stray newline can split the injection.
        let cmds = commands_for_event("<EVENT type=\"msg\">a\nb\r\nc</EVENT>");
        let text = cmds[2].get("text").and_then(Value::as_str).unwrap();
        assert_eq!(text, "<EVENT type=\"msg\">\na b  c\n</EVENT>");
        assert_eq!(text.matches('\r').count(), 0, "no raw CR survives");
        assert_eq!(text.matches('\n').count(), 2, "only the two framing newlines survive");
    }

    /// Build an EVENT envelope carrying a `json` attr whose value is the XML-attr-escaped form of
    /// `obj` — mirrors what spt-core composes from `spt send --json-payload`.
    fn envelope_with_json(obj: &str) -> String {
        let escaped = obj
            .replace('&', "&")
            .replace('<', "<")
            .replace('>', ">")
            .replace('"', """);
        format!("<EVENT type=\"msg\" from=\"self\" json=\"{escaped}\">checkpoint</EVENT>")
    }

    // [unit->REQ-DIST-CHECKPOINT-COMMUNE]
    #[test]
    fn checkpoint_json_attr_fires_the_clear_wake_macro() {
        let env = envelope_with_json(r#"{"checkpoint":"v1","wake":"Resume T2c now"}"#);
        let cmds = commands_for_event(&env);
        assert_eq!(
            keys(&cmds),
            vec![
                "key:ctrl+s".to_string(),
                "delay:50".to_string(),
                "text:/clear".to_string(),
                "key:enter".to_string(),
                "delay:500".to_string(),
                "text:Resume T2c now".to_string(),
                "key:enter".to_string(),
                "commit".to_string(),
            ]
        );
    }

    #[test]
    fn checkpoint_without_wake_uses_the_default() {
        let env = envelope_with_json(r#"{"checkpoint":"v1"}"#);
        let cmds = commands_for_event(&env);
        let wake = cmds[5].get("text").and_then(Value::as_str).unwrap();
        assert_eq!(wake, "Proceed with next steps");
        // Blank wake also falls back to the default.
        let env2 = envelope_with_json(r#"{"checkpoint":"v1","wake":"   "}"#);
        let wake2 = commands_for_event(&env2)[5].get("text").and_then(Value::as_str).unwrap().to_string();
        assert_eq!(wake2, "Proceed with next steps");
    }

    #[test]
    fn checkpoint_macro_submits_clear_then_wake_and_commits() {
        let cmds = commands_for_checkpoint("go");
        // /clear is typed+submitted BEFORE the wake text+submit; the 500ms settle sits between them.
        let texts: Vec<&str> = cmds.iter().filter_map(|c| c.get("text").and_then(Value::as_str)).collect();
        assert_eq!(texts, vec!["/clear", "go"]);
        // Two discrete enter submits (clear, wake) and exactly one terminating commit, last.
        let enters = cmds.iter().filter(|c| c.get("key").and_then(Value::as_str) == Some("enter")).count();
        assert_eq!(enters, 2, "two submits: /clear and the wake");
        assert_eq!(cmds.last().unwrap().get("commit").and_then(Value::as_bool), Some(true));
        // The longer post-/clear settle is present.
        assert!(cmds.iter().any(|c| c.get("delay_ms").and_then(Value::as_u64) == Some(500)));
    }

    #[test]
    fn non_checkpoint_envelopes_deliver_normally() {
        // A different marker, a plain message, and a body that merely MENTIONS the token all deliver
        // normally (collision-proof: the marker only counts inside a structured json attr).
        for env in [
            envelope_with_json(r#"{"checkpoint":"v2"}"#),
            envelope_with_json(r#"{"note":"hi"}"#),
            "<EVENT type=\"msg\" from=\"a\">please run a checkpoint v1</EVENT>".to_string(),
            "<EVENT type=\"msg\" from=\"a\">hi</EVENT>".to_string(),
        ] {
            let cmds = commands_for_event(&env);
            // Normal delivery is the 6-step choreography ending text-then-enter-then-commit — NOT the
            // 8-step macro; in particular it never types "/clear".
            assert_eq!(cmds.len(), 6, "normal delivery for: {env}");
            assert!(
                !cmds.iter().any(|c| c.get("text").and_then(Value::as_str) == Some("/clear")),
                "must NOT clear for: {env}"
            );
        }
    }

    #[test]
    fn malformed_checkpoint_json_falls_through_to_normal_delivery() {
        // A json attr that isn't valid JSON must not panic or macro — degrade to normal delivery.
        let env = "<EVENT type=\"msg\" from=\"a\" json=\"{not valid json\">b</EVENT>";
        let cmds = commands_for_event(env);
        assert_eq!(cmds.len(), 6);
        assert!(!cmds.iter().any(|c| c.get("text").and_then(Value::as_str) == Some("/clear")));
    }

    #[test]
    fn xml_attr_unescape_decodes_amp_last() {
        // &quot; must decode to " (one level), not "" — & is applied LAST.
        assert_eq!(xml_attr_unescape("&quot;"), """);
        assert_eq!(xml_attr_unescape(""<>&"), "\"<>&");
    }

    #[test]
    fn extract_json_attr_pulls_the_attr_value() {
        let env = "<EVENT type=\"msg\" from=\"a\" json=\""v"\">b</EVENT>";
        assert_eq!(extract_json_attr(env).as_deref(), Some(""v""));
        // No json attr → None.
        assert_eq!(extract_json_attr("<EVENT type=\"msg\">b</EVENT>"), None);
    }

    #[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}");
        }
    }
}


Critical bootstrap detail: if spt-core/adapter absent, `{adapter_dir}/claude-spt` doesn't exist — so bootstrap + graceful no-op must stay in the plugin shell. Read bootstrap.sh.


1	#!/bin/sh
2	# sptc SessionStart bootstrap (POSIX) — install spt-core on demand if absent.
3	# Verbatim against the published contract: spt-releases harness-contract/install-on-demand.md.
4	# The invisible-installer pattern: a user who installs the sptc plugin gets spt-core for free.
5	# [impl->REQ-DIST-BOOTSTRAP-INSTALL]
6	set -eu
7
8	if ! command -v spt >/dev/null 2>&1; then
9	  echo "spt-core not found - installing..." >&2
10	  curl -fsSL https://sabermage.github.io/spt-releases/install.sh | sh
11	  # PATH is not yet reloaded in this shell — use the absolute install path for the first call.
12	  SPT="$HOME/.local/bin/spt"
13	else
14	  SPT="spt"
15	fi
16
17	"$SPT" --version
18	# After this initial bootstrap, `spt update` handles signed self-updates automatically.
19

#!/bin/sh
# sptc SessionStart bootstrap (POSIX) — install spt-core on demand if absent.
# Verbatim against the published contract: spt-releases harness-contract/install-on-demand.md.
# The invisible-installer pattern: a user who installs the sptc plugin gets spt-core for free.
# [impl->REQ-DIST-BOOTSTRAP-INSTALL]
set -eu

if ! command -v spt >/dev/null 2>&1; then
  echo "spt-core not found - installing..." >&2
  curl -fsSL https://sabermage.github.io/spt-releases/install.sh | sh
  # PATH is not yet reloaded in this shell — use the absolute install path for the first call.
  SPT="$HOME/.local/bin/spt"
else
  SPT="spt"
fi

"$SPT" --version
# After this initial bootstrap, `spt update` handles signed self-updates automatically.


Architecture clear. Bootstrap stays in plugin (binary can't exist before adapter installed); dispatch resolves binary via get-string + caches to env; host pid passed explicitly (Windows has no portable getppid). Now check publish mechanics + gh auth + the D1 REQ.


echo "=== gh auth ==="; gh auth status 2>&1 | head -8
echo "=== release runbook (publish steps) ==="; sed -n '1,60p' docs/RELEASE-RUNBOOK.md

Check gh auth + release runbook head


grep -n "REQ-DIST-HOOKS-API\|REQ-DIST-HOOK-BINARY\|hook" traceable-reqs.toml | head -30

Find hook-related REQs in registry


=== gh auth ===
github.com
  ✓ Logged in to github.com account SaberMage (keyring)
  - Active account: true
  - Git operations protocol: https
  - Token: gho_************************************
  - Token scopes: 'admin:org_hook', 'admin:repo_hook', 'gist', 'project', 'read:audit_log', 'read:discussion', 'read:user', 'repo', 'workflow', 'write:org'
=== release runbook (publish steps) ===
# Release runbook

> How an spt-claude-code release ships. The spine is fixed (changelog · bump · regenerate
> docs · tag · publish · docs publish). **Release here is LIGHT: signing is DELEGATED to the
> `spt` binary — there is NO two-key signing ceremony in this project.** spt-claude-code is a
> thin adapter; the heavy signed-release machinery lives in spt-core, not here.

<!-- [<doc>->REQ-EXAMPLE-REL] (illustration; escaped per docs/TRACEABILITY.md) -->

## Publish targets

An spt-claude-code release lands in **two places**, by volatility:

1. **The `SaberMage/cplugs` marketplace** — the thin **skeleton plugin** (namespaced
   `/spt:*` skill skeletons, `hooks.json`, the SessionStart bootstrap, `plugin.json`). This
   is low-churn: it only changes on **structural** plugin changes, not on logic/instruction
   updates.
2. **The spt-core adapter registry** — the volatile bulk: the CC adapter **manifest**
   (`[digest]` extractor, profiles, strings, hints) **+ binary**. This publish is
   **spt-core-conducted** (the same framework that conducts adapter updates), not a plugin
   file push.

## One-time setup

No signing-key ceremony in this project. **Signing is delegated to the `spt` binary**, which
handles adapter manifest/binary integrity through spt-core's own signed-update framework. Keep
no release keys here. The only setup is marketplace/registry publish credentials for the two
targets above (a `cplugs` push token + whatever the spt-core adapter-registry publish step
requires).

## Per release

1. **Bump** the version (the plugin `plugin.json` version and/or the adapter manifest version —
   note these are distinct numbers; see Notes) if needed; **regenerate generated docs**
   (the mdBook `docs-site/` build + `llms.txt` / schema / CLI-help exports); land everything
   green.
2. **Write the user-facing changelog** — add a `## [<version>] - <date>` section at the top of
   `CHANGELOG.md` with **Added / Changed / Fixed** subsections. This section becomes the public
   release body verbatim, so it is the changelog every user reads. Rules:
   - **User-facing UX only.** What a person using the product notices or does differently. Name
     the actual commands / flags / surfaces they touch (`/spt:*` skills, `cc`, `/spt:setup`).
   - **No internal lingo** — no requirement ids, internal module names, commit hashes, or
     milestone / hazard codes. A reader who has never seen the source must understand every line.
   - **Flag breaking changes** prominently under Changed.
   - The release **fails loudly** if the tagged version has no `## [<version>]` section — the
     changelog is not optional.
3. **Tag**: `git tag <vX.Y.Z> && git push origin <vX.Y.Z>`. This triggers:
   - the **docs-publish workflow** — build `docs-site/` and publish to GitHub Pages (drift-gated;
     see `docs/DOCS-STRATEGY.md`).
4. **Publish** (light — signing delegated to `spt`):
   - **Skeleton plugin** → push to the `SaberMage/cplugs` marketplace **only when the plugin
     skeleton structurally changed** (new/removed skill skeleton, hook wiring, bootstrap, or
     `plugin.json`). Most releases do not bump the marketplace.
   - **Manifest + binary** → publish through the **spt-core adapter registry**; the `spt`
     binary performs signing/integrity as part of spt-core's adapter-update framework. You do
     **not** run a separate signing step.

## cplugs skeleton publish — concrete mechanics

<!-- [doc->REQ-DIST-PLUGIN-SKELETON] -->