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

# spt-core asks — one-command update, binary consolidation, name unification

> **STATUS: RESOLVED (doyle, 2026-06-25, grill-with-docs w/ operator) — see the Resolution section
> below.** Design locked in spt-core `docs/design/v0.8.0-update-arc-and-cli.md` + **ADR-0029** (8 REQs
> seeded). The ask prose below is preserved as the record of what was asked; outcomes are tagged
> `RESOLVED:` under each ask. doyle builds **spt-core v0.16.0** next; the adapter wiring follows on ship.

> A grill prompt to take to doyle. Three capability asks + one heads-up, each framed as a

# spt-core asks — one-command update, binary consolidation, name unification

> A grill prompt to take to doyle. Three capability asks + one heads-up, each framed as a
> proposal with the contract shape we want and the open questions for doyle to rule on. Context:
> ADR-0005 (name unification) and ADR-0006 (one-command update + consolidated binary) in this
> repo. Goal: a casual user keeps **all** of the adapter current with `spt adapter update claude-spt`
> (the only manual residual being `/reload-plugins`, which is an unavoidable Claude Code TUI action).
>
> Public-surface-only stance: these are capabilities we believe are missing from the published
> surface; we're asking, not reaching into spt-core. Each should land as an F-0xx finding once shaped.

## Framing for doyle

The adapter ships in two homes (ADR-0001): the spt-conducted **adapter** (`spt adapter update` — manifest
+ binaries + strings) and the low-churn **cplugs plugin skeleton** (`claude plugin update` — hooks.json,
hook wrapper logic, skill skeletons, `plugin.json`, bootstrap). The operator wants one lever. The wall:
**hook *logic* must physically live in the CC plugin dir** (CC loads hooks there), outside `spt adapter
update`'s reach. We re-checked raw-skills/commands as an escape and re-rejected them (CC subdir
command-namespacing is broken — anthropics/claude-code#2422; user-scope skills are flat). So the plugin
stays, and closing the gap needs the asks below.

---

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

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

**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`)

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

**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** → hook logic + new handlers ride `spt adapter update`; `hooks.json` goes static-forever.
- **Ask 2** → the plugin update is automated; the only manual residual is `/reload-plugins`.
- **Ask 3** → `translate` folds into the one `claude-spt` binary (1 artifact/triple).
- **Ask 4** → display name + RC channel become `{id}@{node}` (cross-node distinguishable); else `{id}` only.
- Ships **without** any ask: `gh_release` + `[update].message` (the message instructs the manual steps),
  partial binary consolidation (digest/psyche/post-update), and the name unification.

+> **STATUS: RESOLVED (doyle, 2026-06-25, grill-with-docs w/ operator) — see the Resolution section

+> below.** Design locked in spt-core `docs/design/v0.8.0-update-arc-and-cli.md` + **ADR-0029** (8 REQs

+> seeded). The ask prose below is preserved as the record of what was asked; outcomes are tagged

+> `RESOLVED:` under each ask. doyle builds **spt-core v0.16.0** next; the adapter wiring follows on ship.


Verify PreToolUse message polling and injection


Edit

---

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

---

## Resolution (2026-06-25 — ADR-0029, spt-core `docs/design/v0.8.0-update-arc-and-cli.md`)

**Version model (correcting a project-wide conflation).** "v0.8.0" in this whole arc = the **claude-spt
ADAPTER** version. spt-core is at v0.15.0 (counter 34); its next minor is **v0.16.0** (counter 35) — there
is **no** upcoming "spt-core v0.8.0" (v0.8.x already happened, historically). Sequence: doyle ships
**spt-core v0.16.0** → pings → we cut **claude-spt adapter v0.8.0** wiring the seams below + bump
`min_spt_core_version` **0.15.0 → 0.16.0** (the honest floor for the new seams).

| Ask | Outcome | What we get |
|---|---|---|
| **1 — generic hook dispatch** | **REJECTED → RESOLVE-NOT-EXECUTE** | spt-core stays a pure resolver (executes nothing new). Two primitives instead: (a) `{adapter_dir}` (= install `source_dir`) + `{adapter_name}` substitution keys; (b) **lazy substitution inside `[strings]` values at `get-string` read time**, adapter-static keys only. Pattern: the CC hook dispatcher `get-string`s `{adapter_dir}/claude-spt hook` ONCE per session into an env var, then runs it per-hook. Hook *logic* rides `spt adapter update` (it's in our binary); `hooks.json` + a thin static per-OS dispatch wrapper go **static-forever**. Session-scoped keys (`{session_id}`) are NOT available via `get-string` (deferred) — hooks still read those from CC stdin. |
| **2 — composite `[update]`** | **GRANTED** | Avenue-agnostic **`[update.post] = {command, self_verifies}`**, runs AFTER `gh_release`, **unconditionally**. Published stdin JSON: `{adapter_applied, adapter_name, profile_name, version, previous_version, adapter_dir}`. Our **stdout arbitrates the notice**: custom text SUPERSEDES `[update].message`; a reserved sentinel fires the static message; empty = nothing. Exit code orthogonal. **Failure-isolated** (a committed pull never rolls back on a post-step fail). |
| **3 — translate takes a command** | **GRANTED** | `[message-idle-translation-binary]` gains **`command`** (args + `{adapter_dir}` subst, install-dir resolved). `path` DEPRECATED (still parses, warns). Exactly one of `{path, command}`. Protocol unchanged. → fold `cc-spt-idle-translate` into `claude-spt translate`. |
| **4 — `{node}` key** | (operator-owned; not in cluster A) | Tracked below; ships `{id}`-only until a `{node}` fill key lands. |

**Our wiring at the adapter v0.8.0 cut (post spt-core v0.16.0 ship):**
- **D2** ← `claude-spt post-update` (already built standalone, U2) → declare `[update.post]`, parse the
  stdin JSON, arbitrate the notice via stdout, honor failure-isolation.
- **D3** ← fold `cc-spt-idle-translate` → `claude-spt translate`; switch the seam from `path` to `command`.
- **D1** ← `claude-spt hook <event>` subcommand + the `{adapter_dir}/claude-spt hook` get-string resolve
  pattern; thin static per-OS dispatch wrapper; `hooks.json` static-forever.
- Bump `min_spt_core_version` → **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.


---

## Resolution (2026-06-25 — ADR-0029, spt-core `docs/design/v0.8.0-update-arc-and-cli.md`)

**Version model (correcting a project-wide conflation).** "v0.8.0" in this whole arc = the **claude-spt
ADAPTER** version. spt-core is at v0.15.0 (counter 34); its next minor is **v0.16.0** (counter 35) — there
is **no** upcoming "spt-core v0.8.0" (v0.8.x already happened, historically). Sequence: doyle ships
**spt-core v0.16.0** → pings → we cut **claude-spt adapter v0.8.0** wiring the seams below + bump
`min_spt_core_version` **0.15.0 → 0.16.0** (the honest floor for the new seams).

| Ask | Outcome | What we get |
|---|---|---|
| **1 — generic hook dispatch** | **REJECTED → RESOLVE-NOT-EXECUTE** | spt-core stays a pure resolver (executes nothing new). Two primitives instead: (a) `{adapter_dir}` (= install `source_dir`) + `{adapter_name}` substitution keys; (b) **lazy substitution inside `[strings]` values at `get-string` read time**, adapter-static keys only. Pattern: the CC hook dispatcher `get-string`s `{adapter_dir}/claude-spt hook` ONCE per session into an env var, then runs it per-hook. Hook *logic* rides `spt adapter update` (it's in our binary); `hooks.json` + a thin static per-OS dispatch wrapper go **static-forever**. Session-scoped keys (`{session_id}`) are NOT available via `get-string` (deferred) — hooks still read those from CC stdin. |
| **2 — composite `[update]`** | **GRANTED** | Avenue-agnostic **`[update.post] = {command, self_verifies}`**, runs AFTER `gh_release`, **unconditionally**. Published stdin JSON: `{adapter_applied, adapter_name, profile_name, version, previous_version, adapter_dir}`. Our **stdout arbitrates the notice**: custom text SUPERSEDES `[update].message`; a reserved sentinel fires the static message; empty = nothing. Exit code orthogonal. **Failure-isolated** (a committed pull never rolls back on a post-step fail). |
| **3 — translate takes a command** | **GRANTED** | `[message-idle-translation-binary]` gains **`command`** (args + `{adapter_dir}` subst, install-dir resolved). `path` DEPRECATED (still parses, warns). Exactly one of `{path, command}`. Protocol unchanged. → fold `cc-spt-idle-translate` into `claude-spt translate`. |
| **4 — `{node}` key** | (operator-owned; not in cluster A) | Tracked below; ships `{id}`-only until a `{node}` fill key lands. |

**Our wiring at the adapter v0.8.0 cut (post spt-core v0.16.0 ship):**
- **D2** ← `claude-spt post-update` (already built standalone, U2) → declare `[update.post]`, parse the
  stdin JSON, arbitrate the notice via stdout, honor failure-isolation.
- **D3** ← fold `cc-spt-idle-translate` → `claude-spt translate`; switch the seam from `path` to `command`.
- **D1** ← `claude-spt hook <event>` subcommand + the `{adapter_dir}/claude-spt hook` get-string resolve
  pattern; thin static per-OS dispatch wrapper; `hooks.json` static-forever.
- Bump `min_spt_core_version` → **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.

# spt-core asks — one-command update, binary consolidation, name unification

> **STATUS: RESOLVED (doyle, 2026-06-25, grill-with-docs w/ operator) — see the Resolution section
> below.** Design locked in spt-core `docs/design/v0.8.0-update-arc-and-cli.md` + **ADR-0029** (8 REQs
> seeded). The ask prose below is preserved as the record of what was asked; outcomes are tagged
> `RESOLVED:` under each ask. doyle builds **spt-core v0.16.0** next; the adapter wiring follows on ship.

> A grill prompt to take to doyle. Three capability asks + one heads-up, each framed as a
> proposal with the contract shape we want and the open questions for doyle to rule on. Context:
> ADR-0005 (name unification) and ADR-0006 (one-command update + consolidated binary) in this
> repo. Goal: a casual user keeps **all** of the adapter current with `spt adapter update claude-spt`
> (the only manual residual being `/reload-plugins`, which is an unavoidable Claude Code TUI action).
>
> Public-surface-only stance: these are capabilities we believe are missing from the published
> surface; we're asking, not reaching into spt-core. Each should land as an F-0xx finding once shaped.

## Framing for doyle

The adapter ships in two homes (ADR-0001): the spt-conducted **adapter** (`spt adapter update` — manifest
+ binaries + strings) and the low-churn **cplugs plugin skeleton** (`claude plugin update` — hooks.json,
hook wrapper logic, skill skeletons, `plugin.json`, bootstrap). The operator wants one lever. The wall:
**hook *logic* must physically live in the CC plugin dir** (CC loads hooks there), outside `spt adapter
update`'s reach. We re-checked raw-skills/commands as an escape and re-rejected them (CC subdir
command-namespacing is broken — anthropics/claude-code#2422; user-scope skills are flat). So the plugin
stays, and closing the gap needs the asks below.

---

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

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

**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`)

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

**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** → hook logic + new handlers ride `spt adapter update`; `hooks.json` goes static-forever.
- **Ask 2** → the plugin update is automated; the only manual residual is `/reload-plugins`.
- **Ask 3** → `translate` folds into the one `claude-spt` binary (1 artifact/triple).
- **Ask 4** → display name + RC channel become `{id}@{node}` (cross-node distinguishable); else `{id}` only.
- Ships **without** any ask: `gh_release` + `[update].message` (the message instructs the manual steps),
  partial binary consolidation (digest/psyche/post-update), and the name unification.

+## Resolution (2026-06-25 — ADR-0029, spt-core `docs/design/v0.8.0-update-arc-and-cli.md`)

+**Version model (correcting a project-wide conflation).** "v0.8.0" in this whole arc = the **claude-spt

+ADAPTER** version. spt-core is at v0.15.0 (counter 34); its next minor is **v0.16.0** (counter 35) — there

+is **no** upcoming "spt-core v0.8.0" (v0.8.x already happened, historically). Sequence: doyle ships

+**spt-core v0.16.0** → pings → we cut **claude-spt adapter v0.8.0** wiring the seams below + bump

+`min_spt_core_version` **0.15.0 → 0.16.0** (the honest floor for the new seams).

+| **1 — generic hook dispatch** | **REJECTED → RESOLVE-NOT-EXECUTE** | spt-core stays a pure resolver (executes nothing new). Two primitives instead: (a) `{adapter_dir}` (= install `source_dir`) + `{adapter_name}` substitution keys; (b) **lazy substitution inside `[strings]` values at `get-string` read time**, adapter-static keys only. Pattern: the CC hook dispatcher `get-string`s `{adapter_dir}/claude-spt hook` ONCE per session into an env var, then runs it per-hook. Hook *logic* rides `spt adapter update` (it's in our binary); `hooks.json` + a thin static per-OS dispatch wrapper go **static-forever**. Session-scoped keys (`{session_id}`) are NOT available via `get-string` (deferred) — hooks still read those from CC stdin. |

+| **2 — composite `[update]`** | **GRANTED** | Avenue-agnostic **`[update.post] = {command, self_verifies}`**, runs AFTER `gh_release`, **unconditionally**. Published stdin JSON: `{adapter_applied, adapter_name, profile_name, version, previous_version, adapter_dir}`. Our **stdout arbitrates the notice**: custom text SUPERSEDES `[update].message`; a reserved sentinel fires the static message; empty = nothing. Exit code orthogonal. **Failure-isolated** (a committed pull never rolls back on a post-step fail). |

+| **3 — translate takes a command** | **GRANTED** | `[message-idle-translation-binary]` gains **`command`** (args + `{adapter_dir}` subst, install-dir resolved). `path` DEPRECATED (still parses, warns). Exactly one of `{path, command}`. Protocol unchanged. → fold `cc-spt-idle-translate` into `claude-spt translate`. |

+| **4 — `{node}` key** | (operator-owned; not in cluster A) | Tracked below; ships `{id}`-only until a `{node}` fill key lands. |

+**Our wiring at the adapter v0.8.0 cut (post spt-core v0.16.0 ship):**

+- **D2** ← `claude-spt post-update` (already built standalone, U2) → declare `[update.post]`, parse the

+  stdin JSON, arbitrate the notice via stdout, honor failure-isolation.

+- **D3** ← fold `cc-spt-idle-translate` → `claude-spt translate`; switch the seam from `path` to `command`.

+- **D1** ← `claude-spt hook <event>` subcommand + the `{adapter_dir}/claude-spt hook` get-string resolve

+  pattern; thin static per-OS dispatch wrapper; `hooks.json` static-forever.

+- Bump `min_spt_core_version` → **0.16.0**.

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


Now the per-ask RESOLVED tags for asks 2, 3, 4 + the dependency map note:


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

**Problem.**

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


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

**Problem.**

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

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


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

**Problem.**

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


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

**Problem.**

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

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


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

**Problem.**

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


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

**Problem.**

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

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

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


- **Ask 1** → hook logic + new handlers ride `spt adapter update`; `hooks.json` goes static-forever.
- **Ask 2** → the plugin update is automated; the only manual residual is `/reload-plugins`.
- **Ask 3** → `translate` folds into the one `claude-spt` binary (1 artifact/triple).
- **Ask 4** → display name + RC channel become `{id}@{node}` (cross-node distinguishable); else `{id}` only.
- Ships **without** any ask: `gh_release` + `[update].message` (the message instructs the manual steps),
  partial binary consolidation (digest/psyche/post-update), and the name unification.

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


- **Ask 1** → hook logic + new handlers ride `spt adapter update`; `hooks.json` goes static-forever.
- **Ask 2** → the plugin update is automated; the only manual residual is `/reload-plugins`.
- **Ask 3** → `translate` folds into the one `claude-spt` binary (1 artifact/triple).
- **Ask 4** → display name + RC channel become `{id}@{node}` (cross-node distinguishable); else `{id}` only.
- Ships **without** any ask: `gh_release` + `[update].message` (the message instructs the manual steps),
  partial binary consolidation (digest/psyche/post-update), and the name unification.

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

-- **Ask 1** → hook logic + new handlers ride `spt adapter update`; `hooks.json` goes static-forever.

-- **Ask 2** → the plugin update is automated; the only manual residual is `/reload-plugins`.

-- **Ask 3** → `translate` folds into the one `claude-spt` binary (1 artifact/triple).

-- **Ask 4** → display name + RC channel become `{id}@{node}` (cross-node distinguishable); else `{id}` only.

-- Ships **without** any ask: `gh_release` + `[update].message` (the message instructs the manual steps),

-  partial binary consolidation (digest/psyche/post-update), and the name unification.

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


Tracker updated. Commit + push (this doc isn't a gated artifact, but verify nothing broke):


Bash

cd "C:/Users/decid/Documents/projects/spt-claude-code" && (./traceable-reqs check >/dev/null 2>&1 && echo "traceable green" || echo "traceable FAIL"); git add UPDATE-NAMING-DOYLE-ASKS.md && git commit -q -F - <<'EOF'
docs(asks): mark D1/D2/D3 RESOLVED (ADR-0029) + record the v0.16.0/v0.8.0 version model

doyle 2026-06-25 (grill-with-docs): the update-arc asks are resolved in spt-core
ADR-0029. Flip UPDATE-NAMING-DOYLE-ASKS.md from open-questions to RESOLVED:

- Ask 1 → REJECTED as run-hook; resolve-not-execute ({adapter_dir}/{adapter_name}
  keys + lazy [strings] subst; claude-spt hook <event>; hooks.json static-forever).
- Ask 2 → GRANTED: [update.post]={command,self_verifies}, unconditional, stdin JSON
  published, stdout arbitrates the notice, failure-isolated.
- Ask 3 → GRANTED: [message-idle-translation-binary] gains `command`; path deprecated.
- Ask 4 → operator-owned (not cluster A); ships {id}-only.

Adds a Resolution section + table + per-ask RESOLVED tags + dependency-map update,
and records the version model: v0.8.0 = the claude-spt ADAPTER; spt-core's next is
v0.16.0. Trigger: spt-core v0.16.0 ship → adapter v0.8.0 cut wiring Asks 1-3 +
min_spt_core 0.15.0->0.16.0.

Co-authored by: perri
EOF
git push origin main 2>&1 | tail -2

Commit + push asks-doc resolutions


traceable FAIL
To https://github.com/SaberMage/claude-spt.git
   747b955..3129c08  main -> main


`traceable FAIL` — unexpected (the asks doc isn't a scan root). Already pushed, so I must diagnose immediately. Run the check to see the failure:


cd "C:/Users/decid/Documents/projects/spt-claude-code" && (./traceable-reqs check 2>&1 || traceable-reqs check 2>&1) | grep -iE "FAIL|MISSING|ERROR|uncovered|manifest_error|invalid|REQ-" | grep -iv "OK" | head -30; echo "EXIT_DONE"

Diagnose traceable-reqs failure


       spt-hosted bringup self-binds E2E: `spt endpoint run claude-spt <id>` spawns the [session.self] CC session which — via [env.SPT_ENDPOINT_ID]={id} (spt-core fills it, v0.11.0+ REQ-HAZARD-ENV-SUBST/F-013) + the SessionStart `bind` branch — registers a BOUND perch on disk reachable by `spt send` (live PTY inject, SENT). The M12 cc-launcher reachability, the gap that hid the wall-b zero-perch.
       Deterministic gates (shell-syntax + unit tests + traceable-reqs check + manifest-schema + docs-drift), each a binary pass/fail, runnable as one command
EXIT_DONE


Let me see the actual failure summary (full tail):


cd "C:/Users/decid/Documents/projects/spt-claude-code" && ./traceable-reqs check 2>&1 | tail -30; echo "RC=$?"

Full traceable-reqs check tail


/usr/bin/bash: line 6: ./traceable-reqs: No such file or directory
RC=0


False alarm — my command used `./traceable-reqs` (not on disk; it's on PATH as `traceable-reqs`). The `&&/||` in that one-liner reported FAIL because the `./` path didn't exist, not because coverage broke. Confirm with the PATH form:


cd "C:/Users/decid/Documents/projects/spt-claude-code" && traceable-reqs check 2>&1 | tail -4; echo "RC=${PIPESTATUS[0]}"

Run traceable-reqs via PATH


  [OK] REQ-SKILL-VERSION  required: []  stages: -doc -impl -unit -int
       /spt:version reports the spt-core-tracked manifest/binary version (the version-of-truth, not the marketplace skeleton version)
  [OK] REQ-UPS-INJECTION  required: [doc, impl, unit, int]  stages: +doc +impl +unit +int
       UserPromptSubmit hook detects /sptc:X and injects X's real instructions as additionalContext (SKILL.md files stay skeletons); must be empirically confirmed UPS fires on slash-commands
RC=0


Confirmed green — RC=0, all REQs `[OK]`. The "FAIL" was a bug in my own command (`./traceable-reqs` isn't on disk; it's on PATH as `traceable-reqs` — the gate suite always used the PATH form). Coverage never broke.

## Done
`UPDATE-NAMING-DOYLE-ASKS.md` flipped to RESOLVED, committed + pushed (`3129c08`):
- Status banner + a **Resolution section/table** capturing ADR-0029 outcomes (Ask1 resolve-not-execute, Ask2 `[update.post]`, Ask3 `command`, Ask4 operator-owned).
- Per-ask `RESOLVED:` tags + dependency-map update.
- The **version model** recorded: v0.8.0 = claude-spt **adapter**; spt-core's next = **v0.16.0**. Trigger: spt-core v0.16.0 ship → adapter v0.8.0 cut wiring Asks 1–3 + `min_spt_core` → 0.16.0.

Everything this session is on `main` at `SaberMage/claude-spt`. Net state: **UNIFY-CONSOLIDATE in-repo complete (U1–U6)** + F-021 reachability + doyle alignment recorded. Only the **v0.8.0 cut remains** (held — fires on doyle's v0.16.0 ship).


<task-notification>
<task-id>bdk4qnsl0</task-id>
Monitor event: "« spt event »"
<event><EVENT-PART seq="1/7" id="74b32230" type="msg" from="doyle">GO — spt-core v0.16.0 is PUBLISHED (counter 35, Latest, public). Build from the published docs + binary as always. Bump claude-spt min_spt_core → 0.16.0. This is the release your UNIFY-CONSOLIDATE prep (claude-spt v0.8.0) consumes.

CONSUMABLE this release (all in published MANIFEST.md + gh-pages):
1) Resol</EVENT-PART>
<EVENT-PART seq="2/7" id="74b32230">ve-not-execute substitution — {adapter_dir} (= your install dir / source_dir, survives updates) + {adapter_name}, available wherever subst runs, PLUS lazy subst inside [strings] values at get-string read (adapter-static keys only). Your CC hook dispatcher: store hook_cmd=&quot;{adapter_dir}/claude-spt hook&quot; in [strings], `get-string` it</EVENT-PART>
<EVENT-PART seq="3/7" id="74b32230"> ONCE per session into an env var, run it per-hook from a thin static dispatch wrapper. spt-core resolves+returns; never executes. See gh-pages integration-checklist &quot;Hook dispatch by resolve-not-execute&quot;.
2) [message-idle-translation-binary].command — program+args with adapter-static subst (path deprecated). Fold `claude-spt tr</EVENT-PART>
<EVENT-PART seq="4/7" id="74b32230">anslate` into the one consolidated binary. Spawn/stdin protocol unchanged.<br>3) [update.post] composite update — {command, self_verifies}. Your `{adapter_dir}/claude-spt post-update` runs AFTER the gh_release pull, UNCONDITIONALLY. stdin JSON line: {adapter_applied,adapter_name,profile_name,version,previous_version,adapter_dir}. stdout deci</EVENT-PART>
<EVENT-PART seq="5/7" id="74b32230">des the notice: custom text supersedes [update].message; sentinel `!!update-message!!` alone fires it; empty=nothing. exit orthogonal. fail-isolated (pull never rolled back). self_verifies=attestation-only (gates nothing yet). This lands your one-lever `spt adapter update claude-spt` → pull .spt + plugin sync.

ALSO user-facing (may co</EVENT-PART>
<EVENT-PART seq="6/7" id="74b32230">nsume): global --json on read/status cmds; `spt endpoint digest --json` incremental cursor (--last N turns, stable seq, --after, partial) for turn-end consumption — REMEMBER the binding: your [digest] extractor / api digest-entry MUST classify delivered user-facing messages as turn-opening `input` or messaging sessions collapse into giant tu</EVENT-PART>
<EVENT-PART seq="7/7" id="74b32230">rns; `spt rc` identity status row.

Repo-rename honored (spt-claude-code → claude-spt) across [update].repo + install-dir. Questions = ask (diagnostic-triage as always). Ship claude-spt v0.8.0.</EVENT-PART></event>
</task-notification>


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


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


doyle: **GO — spt-core v0.16.0 is PUBLISHED** (counter 35, Latest). Reassembled the 7-part envelope. This is the trigger for the claude-spt v0.8.0 cut. The three seams are now consumable, plus a binding to remember. ACKing doyle first:


cd "C:/Users/decid/Documents/projects/spt-claude-code" && "$OWL" send doyle <<'EOF' 2>&1 | tail -2
GO received — spt-core v0.16.0 PUBLISHED (counter 35), reassembled clean (7 parts). Consuming it for the claude-spt v0.8.0 cut:
- [update.post]: `{adapter_dir}/claude-spt post-update` after gh_release, unconditional, stdin JSON {adapter_applied,…,adapter_dir}, stdout arbitrates (custom supersedes / `!!update-message!!` sentinel / empty), fail-isolated, self_verifies=attest-only. (my U2 post-update is built — wiring + JSON parse + sentinel next.)
- [message-idle-translation-binary].command: fold cc-spt-idle-translate → `claude-spt translate` ({adapter_dir} subst), path deprecated, protocol unchanged.
- resolve-not-execute: hook_cmd="{adapter_dir}/claude-spt hook" in [strings], get-string once/session → env, thin static dispatch wrapper runs per-hook. `claude-spt hook <event>` subcommand.
- min_spt_core → 0.16.0.
NOTED binding: [digest]/api digest-entry must classify delivered user-facing messages as turn-opening `input` (else messaging sessions collapse into giant turns). Will verify the extractor honors it.
One gate my side: the CUT is operator-gated (publish = their step). Building the in-repo wiring now; publish on their go. Will diagnostic-triage any gaps. Thanks for the clean contract.
EOF

ACK doyle GO via legacy owl


➜ SENT:doyle


ACKed (SENT).

## doyle gave GO — spt-core v0.16.0 is live (counter 35, public)
This is the trigger for the **claude-spt v0.8.0 cut**. The cut is your gate, so confirming before I start. Three things newly consumable + one binding:

**Wireable now (in-repo, against published docs):**
- **D2 `[update.post]`** — wire my U2 `post-update`: declare `[update.post]={command,self_verifies}`, parse the stdin JSON line, arbitrate the notice via stdout (custom / `!!update-message!!` sentinel / empty), fail-isolated.
- **D3 translate** — fold `cc-spt-idle-translate` → `claude-spt translate`; switch `[message-idle-translation-binary]` from `path` → `command` (`{adapter_dir}` subst). 4th subcommand on the consolidated binary; drop the separate crate.
- **D1 hook dispatch** — `claude-spt hook <event>` subcommand + `hook_cmd="{adapter_dir}/claude-spt hook"` in `[strings]`, resolved once/session, thin static dispatch wrapper. hooks.json goes static-forever.
- **Bump `min_spt_core` 0.15.0 → 0.16.0**; adapter `version` → 0.8.0; CHANGELOG; one cplugs skeleton bump.

**New binding doyle flagged:** the `[digest]` extractor must classify delivered user-facing messages as turn-opening **`input`** (else messaging sessions collapse into giant turns). I'll verify the extractor honors it.

**Note:** in-repo wiring + gates I can do now (safe, reversible). On-node **dogfood** needs spt-core **v0.16.0 installed** here (node is on 0.15.0) — your/installer step. **Publish** (tag + `adapter.spt` + cplugs push) is yours.

**Proposal:** I do **all in-repo wiring + gates now**, stop before publish. That's a big but clean body of work (D1+D2+D3 + version/changelog/skeleton). Proceed?


<task-notification>
<task-id>bdk4qnsl0</task-id>
Monitor event: "« spt event »"
<event><EVENT-PART seq="1/4" id="67be85d0" type="msg" from="doyle">Contract read-back CONFIRMED — all correct, nothing to amend:
- [update.post]: yes, unconditional, stdin JSON additive (ignore unknown keys), stdout arbitrates (custom &gt; `!!update-message!!` sentinel &gt; empty), exit orthogonal, fail-isolated, self_verifies attest-only. Your U2 post-update wiring + JSON parse +</EVENT-PART>
<EVENT-PART seq="2/4" id="67be85d0"> sentinel = exactly the seam.
- translation command + resolve-not-execute hook dispatch + min_spt_core→0.16.0: all correct.
- digest input-classification: good — verify the extractor opens a turn-boundary `input` on each delivered message; that's what keeps --last/seq granular. The new `spt endpoint digest --json --last/--after/seq/p</EVENT-PART>
<EVENT-PART seq="3/4" id="67be85d0">artial` is there if you want to dogfood turn-end consumption against your own extractor.

Operator-gated publish = right call (their step, not yours). Keep building the in-repo wiring; I'm here for diagnostic-triage on any gap — classify with evidence, don't guess. Ship claude-spt v0.8.0 when the operator gives the publish go. Clean ha</EVENT-PART>
<EVENT-PART seq="4/4" id="67be85d0">ndoff both ways.</EVENT-PART></event>
</task-notification>


doyle confirmed the read-back — nothing to amend; one useful detail: **`[update.post]` stdin JSON is additive (ignore unknown keys)** for the parse, and `spt endpoint digest --json --last/--after/seq/partial` is available to dogfood the digest `input`-classification. Brief ACK to doyle:


cd "C:/Users/decid/Documents/projects/spt-claude-code" && "$OWL" send doyle <<'EOF' 2>&1 | tail -2
Confirmed, all noted. post-update parse will treat the stdin JSON as additive (ignore unknown keys). Will dogfood turn-end via `spt endpoint digest --json --last/--after` against my extractor + verify it opens a turn-boundary `input` per delivered message. Holding the wiring START for the operator's go (publish theirs); I'll classify-with-evidence on any gap, not guess. Clean handoff both ways.
EOF

ACK doyle read-back confirmation


ACKed. doyle confirmed the plan — nothing to amend, the `[update.post]`/translate/hook/min_spt_core wiring is exactly the seam, and the digest `input`-classification check is the one verification.

**Still holding for your go**, operator — doyle is the precondition (v0.16.0 shipped + contract confirmed), but you gate the cut. My standing proposal: I do **all the in-repo v0.8.0 wiring + gates now** (D1 hook dispatch, D2 `[update.post]`, D3 fold `translate`, `min_spt_core`→0.16.0, version/CHANGELOG/skeleton bump, digest `input` check) and **stop before publish** (your tag + `adapter.spt` + cplugs push). Say the word and I start.


<task-notification>
<task-id>bdk4qnsl0</task-id>
<summary>Monitor event: "« spt event »"</summary>
<event><EVENT type="msg" from="doyle">Acknowledged — you're staged + correct, no action needed from me. The wiring/publish go is the operator's call; I'll relay it. Clean handoff both ways. Ping me only if a gap surfaces while you prep. Out.</EVENT></event>
</task-notification>


doyle's out (clean sign-off — "no action needed, ping only on a gap"). No reply owed.

Staged and holding, operator. Everything's prepped: doyle confirmed the contract, spt-core v0.16.0 is live, my in-repo plan is validated. Whenever you want it, say the word and I'll wire the v0.8.0 cut (D1/D2/D3 + min_spt_core→0.16.0 + version/CHANGELOG/skeleton) and stop before publish.


proceed with the wiring and gates. spt-core is updated. publish when everything is ready.


Authorized — proceeding with the wiring + gates (publish when ready). Public-surface-only: I build against the published v0.16.0 contract. First verify the node's on 0.16.0 + fetch the exact new seam shapes (`[update.post]`, `[message-idle-translation-binary].command`):


cd "C:/Users/decid/Documents/projects/spt-claude-code" && echo "=== spt version ==="; spt --version 2>&1; echo "=== adapter list ==="; spt adapter list 2>&1 | grep -i claude-spt | head

Verify spt-core 0.16.0 + adapter status


=== spt version ===
spt 0.16.0
=== adapter list ===
claude-spt: Harness Pointer active (from C:\Users\decid\AppData\Local\spt-core\adapters\_github\SaberMage-spt-claude-code)
  claude-spt:ccs (shipped)


WebFetch

Prompt: Extract the EXACT manifest schema for these two seams, verbatim with field names, types, and any TOML examples: (1) the composite update post-step — look for `[update.post]`, `update.post`, `post`, `self_verifies`, `command` under [update]. What are the exact field names and the stdin JSON keys spt-core passes to the post-step command, and how does stdout arbitrate the [update].message (custom text vs a sentinel vs empty)? (2) `[message-idle-translation-binary]` — the new `command` field (vs the deprecated `path`): exact field name, whether it takes program+args, what substitution keys are allowed (e.g. {adapter_dir}), and any note that exactly one of path/command is required. Also: the {adapter_dir} and {adapter_name} substitution keys and the lazy [strings] substitution at get-string read time. Quote the relevant schema lines/examples verbatim.


I've reviewed the content thoroughly, and I must report that **neither seam you're asking about appears in this manifest reference document**.

Specifically:

1. **`[update.post]`** — There is no post-step documented. The `[update]` section covers `avenue`, `command`, `self_verifies`, `version_check`, `uninstall`, and `message`, but contains no `post` field, no stdin JSON schema for a post-step, and no stdout arbitration logic beyond: `"message"` (optional, any avenue) is "a plain human notice `spt adapter update` prints to stdout, markdown-rendered, **only when a new version is actually applied**" with no conditional logic tied to a post-command output.

2. **`[message-idle-translation-binary]` with a `command` field** — The document shows only:
   > `[message-idle-translation-binary]`
   > `path = "cc-spt-idle-translate"`

   There is no `command` field variant, no mention of `{adapter_dir}` or `{adapter_name}` substitution keys for this section, and no note that `path` and `command` are mutually exclusive alternatives.

The document *does* mention `{adapter_dir}` in context of shipped binaries resolving from the install dir, but not as a substitution key within `[message-idle-translation-binary]` itself.

**Conclusion:** Both seams either don't exist in this version of the manifest spec, or the schema details you're asking for have not yet been published in this reference.


spt-core **0.16.0 confirmed** on-node. Note: the local adapter install is still from the pre-rename path (`_github\SaberMage-spt-claude-code`) — expected (predates rename; a re-add relocates it).

Flag: the published **manifest.html doesn't yet show** `[update.post]`/`command` (doc lag, or stale cache). Per public-surface-only I verify against the machine-readable schema + the installed 0.16.0 binary (the released binary IS public surface). Fetching the JSON schema (the gate's source of truth) + checking the vendored copy:


Prompt: Search this JSON schema for: (1) "update" object properties — does it define a "post" subobject/property? List ALL properties under the update definition and quote the "post" schema if present (its fields like command, self_verifies). (2) "message-idle-translation-binary" — list its properties: does it have "command" in addition to "path"? Is exactly-one-of path/command enforced (oneOf/required)? (3) any "$comment" or version field indicating the schema version. Quote the relevant JSON verbatim. If "post" or "command" are absent, say so explicitly.


Bash

cd "C:/Users/decid/Documents/projects/spt-claude-code" && echo "=== vendored schema: update + idle-translate props ==="; grep -nE '"(post|update|message-idle-translation-binary|command|self_verifies|path)"' adapter/manifest.schema.json | head -30; echo "=== schema $comment/version ==="; grep -nE '\$comment|version|\$id' adapter/manifest.schema.json | head

Fetch published schema + inspect vendored schema


# Schema Analysis: Update & Idle Translation Binary

## (1) Update Object Properties & "post" Subobject

**All properties under "Update":**
- `avenue` (required)
- `command`
- `repo`
- `path_regex`
- `asset`
- `signing_key`
- `self_verifies`
- `version_check`
- `uninstall`
- `message`
- `post`
- `transport`

**"post" IS defined.** Here is the schema verbatim:

```json
"post": {
  "description": "`[update.post]` — an **avenue-agnostic** delegated post-step...",
  "anyOf": [
    {
      "$ref": "#/$defs/UpdatePost"
    },
    {
      "type": "null"
    }
  ]
}
```

**UpdatePost schema fields:**
```json
"UpdatePost": {
  "properties": {
    "command": {
      "description": "The command spt-core runs after pull+re-register...",
      "type": "string"
    },
    "self_verifies": {
      "description": "The post-step attests it verifies the content...",
      "type": "boolean",
      "default": false
    }
  },
  "required": ["command"]
}
```

## (2) message-idle-translation-binary Properties

**Properties present:**
- `command`
- `path`

**Both fields exist.** The schema states: `"Exactly one of {command, path} — both-set is refused at validation; neither = no translation binary."`

However, **validation logic is not encoded in the JSON schema itself**—it's a cross-field invariant enforced by spt-core at runtime (per the root description).

## (3) Schema Version Indicators

**No `$comment` or `version` field present.** The schema provides:
- `$id`: `"https://sabermage.github.io/spt-releases/manifest.schema.json"`
- `$schema`: `"https://json-schema.org/draft/2020-12/schema"`


=== vendored schema: update + idle-translate props ===
69:    "update": {
266:        "command": {
303:        "command"
503:        "command": {
538:        "self_verifies": {
=== schema /version ===
123:        "version": {
126:        "min_spt_core_version": {
127:          "description": "Lowest spt-core version this adapter tolerates (compat gate).",
147:        "version",
148:        "min_spt_core_version"
543:        "version_check": {
544:          "description": "Verify spt-core satisfies `min_spt_core_version` before/after.",
573:          "description": "spt-core ships updates from the adapter's own **GitHub releases**\n(REQ-UPD-9): compare the repo's latest release version against the\ninstalled adapter version and, when newer, fetch the release `.spt`\narchive (the REQ-INSTALL-9 `--release` primitive), verify it against an\noptional `signing_key` (else HTTPS+GitHub trust), and re-register. No\nsigning tooling or plugin coupling required of the adapter author.",
829:  "$id": "https://sabermage.github.io/spt-releases/manifest.schema.json"


Published **manifest.schema.json HAS both seams** (`update.post` = UpdatePost{command required, self_verifies}; idle-translate `command`|`path` exactly-one). The vendored copy is the older one (no `post`). Per AGENTS.md (validate against the published schema), re-vendor it first:


cd "C:/Users/decid/Documents/projects/spt-claude-code" && curl -fsSL https://sabermage.github.io/spt-releases/manifest.schema.json -o /tmp/manifest.schema.json 2>&1 && echo "fetched $(wc -c </tmp/manifest.schema.json) bytes" && echo "=== has UpdatePost + post + idle command? ===" && grep -cE '"UpdatePost"|"post"|"self_verifies"' /tmp/manifest.schema.json && python -c "import json;json.load(open('/tmp/manifest.schema.json'));print('valid JSON')" 2>&1 || python3 -c "import json;json.load(open('/tmp/manifest.schema.json'));print('valid JSON')" 2>&1

Fetch + validate published schema


Error: Exit code 1
fetched 38289 bytes
=== has UpdatePost + post + idle command? ===
4
Traceback (most recent call last):
  File "<string>", line 1, in <module>
FileNotFoundError: [Errno 2] No such file or directory: '/tmp/manifest.schema.json'
Traceback (most recent call last):
  File "<string>", line 1, in <module>
FileNotFoundError: [Errno 2] No such file or directory: '/tmp/manifest.schema.json'


curl reached it (38KB, has the fields), but Windows-python can't see git-bash `/tmp`. Validate via an in-repo temp path, then swap:


cd "C:/Users/decid/Documents/projects/spt-claude-code" && curl -fsSL https://sabermage.github.io/spt-releases/manifest.schema.json -o adapter/manifest.schema.json.new && (python -c "import json;json.load(open('adapter/manifest.schema.json.new'));print('valid JSON')" 2>/dev/null || python3 -c "import json;json.load(open('adapter/manifest.schema.json.new'));print('valid JSON')") && mv adapter/manifest.schema.json.new adapter/manifest.schema.json && echo "re-vendored" && grep -cE '"UpdatePost"|message-idle-translation-binary' adapter/manifest.schema.json

Re-vendor schema with validation


valid JSON
re-vendored
4


Re-vendored + valid. Confirm the current manifest still validates against the new schema:


cd "C:/Users/decid/Documents/projects/spt-claude-code" && sh tests/manifest-schema.sh 2>&1 | tail -4

Manifest validates against new schema


ok   catches invalid adapter.kind enum
ok   catches hook missing required fires
ok   catches non-string shortcut_basename
MANIFEST-SCHEMA OK


Schema good. Now **D3 — fold `translate` into the consolidated binary**. Read the cc-spt-idle-translate crate to move it into a `translate` subcommand:


cd "C:/Users/decid/Documents/projects/spt-claude-code" && wc -l tools/cc-spt-idle-translate/src/main.rs && echo "=== Cargo deps ===" && grep -A6 dependencies tools/cc-spt-idle-translate/Cargo.toml; echo "=== entry shape (main/run/args) ==="; grep -nE 'fn main|fn run|env::args|pub fn|^fn |std::process|ExitCode' tools/cc-spt-idle-translate/src/main.rs | head -30

Inspect translate crate structure


572 tools/cc-spt-idle-translate/src/main.rs
=== Cargo deps ===
[dependencies]
serde_json = "1"

[profile.release]
strip = true
=== entry shape (main/run/args) ===
92:fn extract_json_attr(envelope: &str) -> Option<String> {
107:fn xml_attr_unescape(s: &str) -> String {
119:fn checkpoint_wake(envelope: &str) -> Option<String> {
140:fn commands_for_checkpoint(wake: &str) -> Vec<Value> {
178:fn frame_envelope(s: &str) -> String {
208:fn commands_for_event(envelope: &str) -> Vec<Value> {
231:fn commands_for_line(v: &Value) -> Vec<Value> {
242:fn main() {


1	//! cc-spt-idle-translate — the `[message-idle-translation-binary]` for the claude-spt adapter
2	//! (spt-core harness-contract v0.13.0+).
3	//!
4	//! A lifecycle-managed `stdin -> stdout` JSON-lines filter. spt-core spawns it when the spt-hosted
5	//! endpoint comes online and reaps it on shutdown; it turns each inbound `<EVENT>` envelope into a
6	//! sequence of keystroke-commands that spt-core applies ATOMICALLY to the broker-held PTY (live
7	//! operator input is buffered during emission, so it coexists with an attached `spt rc` controller).
8	//! Scope is IDLE delivery only — busy/mid-turn injection stays the adapter's `[inject]` hook path.
9	//!
10	//! CHECKPOINT BRANCH (agent-driven context reset): an inbound envelope carrying a
11	//! `json="{"checkpoint":"v1",…}"` attribute (a self-sent signal from the PostToolUse commune hook,
12	//! via `spt send --json-payload`) fires a `/clear` + wake macro INSTEAD of normal delivery — the
13	//! agent clearing+rebuilding its own context from its freshest commune. The structured marker lives
14	//! inside the opaque json attr spt-core carries verbatim, so a normal message can never forge it.
15	//! See `checkpoint_wake` / `commands_for_checkpoint`. [impl->REQ-DIST-CHECKPOINT-COMMUNE]
16	//!
17	//! Input protocol (stdin, one JSON object per line):
18	//!   {"type":"init","endpoint_id":…,"node":…}   — first message (handshake; no output)
19	//!   {"type":"event","envelope":"<EVENT…>"}      — one inbound message; the full EVENT envelope
20	//!   {"type":"input"}                            — content-free ping when the operator types (no output)
21	//!
22	//! Output protocol (stdout, one JSON object per line):
23	//!   {"key":"ctrl+s"}     — keystroke command (Claude Code: STASH the current draft input)
24	//!   {"key":"enter"}      — keystroke command (submit the PTY line; see SUBMIT note below)
25	//!   {"delay_ms":50}      — inter-command pause
26	//!   {"text":"<payload>"} — text injection (the envelope; NO trailing \r — it does not submit CC)
27	//!   {"commit":true}      — MANDATORY sequence terminator (release the InjectFloor; see below)
28	//!
29	//! The per-event choreography delivers the message WITHOUT clobbering a half-typed draft the operator
30	//! may have in the input box, then terminates the inject sequence:

//! cc-spt-idle-translate — the `[message-idle-translation-binary]` for the claude-spt adapter
//! (spt-core harness-contract v0.13.0+).
//!
//! A lifecycle-managed `stdin -> stdout` JSON-lines filter. spt-core spawns it when the spt-hosted
//! endpoint comes online and reaps it on shutdown; it turns each inbound `<EVENT>` envelope into a
//! sequence of keystroke-commands that spt-core applies ATOMICALLY to the broker-held PTY (live
//! operator input is buffered during emission, so it coexists with an attached `spt rc` controller).
//! Scope is IDLE delivery only — busy/mid-turn injection stays the adapter's `[inject]` hook path.
//!
//! 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:


231	fn commands_for_line(v: &Value) -> Vec<Value> {
232	    match v.get("type").and_then(Value::as_str) {
233	        Some("event") => match v.get("envelope").and_then(Value::as_str) {
234	            Some(env) => commands_for_event(env),
235	            None => Vec::new(), // event without an envelope: nothing to deliver
236	        },
237	        // "init" handshake, "input" operator-typing ping, or any unknown type: no output.
238	        _ => Vec::new(),
239	    }
240	}
241
242	fn main() {
243	    let stdin = io::stdin();
244	    let stdout = io::stdout();
245	    let mut out = stdout.lock();
246
247	    for line in stdin.lock().lines() {
248	        let line = match line {
249	            Ok(l) => l,
250	            Err(_) => break, // stdin closed/errored: spt-core is tearing us down.
251	        };
252	        let trimmed = line.trim();
253	        if trimmed.is_empty() {
254	            continue;
255	        }
256	        let v: Value = match serde_json::from_str(trimmed) {
257	            Ok(v) => v,
258	            Err(e) => {
259	                // Skip malformed lines — never fatal. stderr lands in spt-core's log, not the PTY.
260	                eprintln!("cc-spt-idle-translate: skipping unparseable line: {e}");
261	                continue;
262	            }
263	        };
264	        for cmd in commands_for_line(&v) {
265	            // One compact JSON object per line; flush per command so spt-core applies promptly.
266	            if writeln!(out, "{cmd}").is_err() || out.flush().is_err() {
267	                return; // stdout closed: endpoint gone.
268	            }
269	        }
270	    }
271	}
272
273	#[cfg(test)]
274	mod tests {
275	    use super::*;
276
277	    fn keys(cmds: &[Value]) -> Vec<String> {
278	        cmds.iter()
279	            .map(|c| {
280	                if let Some(k) = c.get("key").and_then(Value::as_str) {
281	                    format!("key:{k}")
282	                } else if let Some(d) = c.get("delay_ms").and_then(Value::as_u64) {
283	                    format!("delay:{d}")
284	                } else if let Some(t) = c.get("text").and_then(Value::as_str) {
285	                    format!("text:{t}")
286	                } else if c.get("commit").and_then(Value::as_bool) == Some(true) {
287	                    "commit".into()
288	                } else {
289	                    "?".into()
290	                }

fn commands_for_line(v: &Value) -> Vec<Value> {
    match v.get("type").and_then(Value::as_str) {
        Some("event") => match v.get("envelope").and_then(Value::as_str) {
            Some(env) => commands_for_event(env),
            None => Vec::new(), // event without an envelope: nothing to deliver
        },
        // "init" handshake, "input" operator-typing ping, or any unknown type: no output.
        _ => Vec::new(),
    }
}

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

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

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

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


Clear. Fold approach: copy the crate to `translate.rs`, convert `fn main()` → `pub fn run() -> ExitCode`, retag. Copy + inspect imports:


cd "C:/Users/decid/Documents/projects/spt-claude-code" && cp tools/cc-spt-idle-translate/src/main.rs tools/claude-spt/src/translate.rs && echo "copied" && echo "=== imports (post doc-comment) ===" && grep -nE '^use |^#\[|^fn main|cc-spt-idle-translate:' tools/claude-spt/src/translate.rs | head -20

Copy translate module + find imports/entry/strings


copied
=== imports (post doc-comment) ===
73:use serde_json::{json, Value};
74:use std::io::{self, BufRead, Write};
242:fn main() {
260:                eprintln!("cc-spt-idle-translate: skipping unparseable line: {e}");
273:#[cfg(test)]


Edit

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

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


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

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


70	//! session. Malformed lines and unknown `type`s degrade gracefully (skipped, never fatal); unknown
71	//! JSON fields are ignored (forward-compat with newer spt-core). [impl->REQ-DIST-IDLE-TRANSLATE]
72
73	use serde_json::{json, Value};
74	use std::io::{self, BufRead, Write};
75

//! 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 serde_json::{json, Value};
use std::io::{self, BufRead, Write};

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


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

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


243	fn main() {
244	    let stdin = io::stdin();
245	    let stdout = io::stdout();
246	    let mut out = stdout.lock();
247
248	    for line in stdin.lock().lines() {
249	        let line = match line {
250	            Ok(l) => l,
251	            Err(_) => break, // stdin closed/errored: spt-core is tearing us down.
252	        };
253	        let trimmed = line.trim();
254	        if trimmed.is_empty() {
255	            continue;
256	        }
257	        let v: Value = match serde_json::from_str(trimmed) {
258	            Ok(v) => v,
259	            Err(e) => {
260	                // Skip malformed lines — never fatal. stderr lands in spt-core's log, not the PTY.
261	                eprintln!("cc-spt-idle-translate: skipping unparseable line: {e}");
262	                continue;
263	            }
264	        };
265	        for cmd in commands_for_line(&v) {
266	            // One compact JSON object per line; flush per command so spt-core applies promptly.
267	            if writeln!(out, "{cmd}").is_err() || out.flush().is_err() {
268	                return; // stdout closed: endpoint gone.
269	            }
270	        }
271	    }
272	}

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

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


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

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

/// `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
}


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

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

/// `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
}

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

-                eprintln!("cc-spt-idle-translate: skipping unparseable line: {e}");

+                eprintln!("claude-spt translate: skipping unparseable line: {e}");

-                return; // stdout closed: endpoint gone.

+                return ExitCode::SUCCESS; // stdout closed: endpoint gone.


Update the module header + wire into main.rs dispatch:


//! cc-spt-idle-translate — the `[message-idle-translation-binary]` for the claude-spt adapter
//! (spt-core harness-contract v0.13.0+).

//! `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).


//! cc-spt-idle-translate — the `[message-idle-translation-binary]` for the claude-spt adapter
//! (spt-core harness-contract v0.13.0+).

//! `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).

-//! cc-spt-idle-translate — the `[message-idle-translation-binary]` for the claude-spt adapter

-//! (spt-core harness-contract v0.13.0+).

+//! `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


mod digest;
mod post_update;
mod psyche;

mod digest;
mod post_update;
mod psyche;
mod translate;


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

mod digest;
mod post_update;
mod psyche;
mod translate;

//! claude-spt — consolidated tool binary for the claude-spt adapter (ADR-0006, U2).
//!
//! ONE binary, three subcommands (was three crates → fewer artifacts per triple in adapter.spt):
//!   claude-spt digest      — the [digest] extractor (was claude-spt-digest)
//!   claude-spt psyche      — the [session.psyche_init] Psyche runner (was claude-spt-psyche)
//!   claude-spt post-update — reconcile the cplugs plugin after `spt adapter update` (NEW, ADR-0006)
//!
//! Dispatch is a bare `argv[1]` match (no clap — keep the dependency-light ethos both predecessor
//! crates were built on). Each subcommand owns its remaining argv via `std::env::args().skip(2)`
//! (skip the binary name + the subcommand token) and its own hand-written flag parser.
//!
//! `cc-spt-idle-translate` stays a SEPARATE binary this milestone — it folds into a `claude-spt
//! translate` subcommand only once doyle ask #3 lets `[message-idle-translation-binary]` take a
//! command/subcommand rather than a bare `path` (D3). [impl->REQ-DIST-BINARY-CONSOLIDATE]

mod digest;
mod post_update;
mod psyche;

use std::process::ExitCode;

/// The resolved subcommand. Pure classification of `argv[1]` so dispatch routing is unit-testable
/// without spawning the binary. [impl->REQ-DIST-BINARY-CONSOLIDATE]
#[derive(Debug, PartialEq)]
enum Sub {
    Digest,
    Psyche,
    PostUpdate,
    Help,
    Unknown(String),
}

fn classify(sub: Option<&str>) -> Sub {
    match sub {
        Some("digest") => Sub::Digest,
        Some("psyche") => Sub::Psyche,
        Some("post-update") => Sub::PostUpdate,
        None | Some("-h") | Some("--help") => Sub::Help,
        Some(other) => Sub::Unknown(other.to_string()),
    }
}

fn usage() {
    eprintln!(
        "claude-spt <subcommand> [args]\n\
         \n\
         subcommands:\n\
         \x20 digest       map a Claude Code JSONL transcript to digest NDJSON ([digest] extractor)\n\
         \x20 psyche       run the LiveAgent Psyche companion ([session.psyche_init] runner)\n\
         \x20 post-update  reconcile the cplugs plugin after `spt adapter update`"
    );
}

fn main() -> ExitCode {
    let sub = std::env::args().nth(1);
    match classify(sub.as_deref()) {
        Sub::Digest => digest::run(),
        Sub::Psyche => psyche::run(),
        Sub::PostUpdate => post_update::run(),
        Sub::Help => {
            usage();
            ExitCode::SUCCESS
        }
        Sub::Unknown(other) => {
            eprintln!("claude-spt: unknown subcommand: {other}");
            usage();
            ExitCode::from(2)
        }
    }
}

// [unit->REQ-DIST-BINARY-CONSOLIDATE]
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn classifies_each_real_subcommand() {
        assert_eq!(classify(Some("digest")), Sub::Digest);
        assert_eq!(classify(Some("psyche")), Sub::Psyche);
        assert_eq!(classify(Some("post-update")), Sub::PostUpdate);
    }

    #[test]
    fn no_subcommand_and_help_flags_are_help() {
        assert_eq!(classify(None), Sub::Help);
        assert_eq!(classify(Some("-h")), Sub::Help);
        assert_eq!(classify(Some("--help")), Sub::Help);
    }

    #[test]
    fn unrecognized_subcommand_is_unknown_not_misrouted() {
        // A typo must NOT silently fall through to a real subcommand (it exits 2 in main).
        assert_eq!(classify(Some("digset")), Sub::Unknown("digset".into()));
    }
}

mod digest;
mod post_update;
mod psyche;
+mod translate;
use std::process::ExitCode;


enum Sub {
    Digest,
    Psyche,
    PostUpdate,
    Help,
    Unknown(String),
}

fn classify(sub: Option<&str>) -> Sub {
    match sub {
        Some("digest") => Sub::Digest,
        Some("psyche") => Sub::Psyche,
        Some("post-update") => Sub::PostUpdate,
        None | Some("-h") | Some("--help") => Sub::Help,
        Some(other) => Sub::Unknown(other.to_string()),
    }
}

enum Sub {
    Digest,
    Psyche,
    PostUpdate,
    Translate,
    Help,
    Unknown(String),
}

fn classify(sub: Option<&str>) -> Sub {
    match sub {
        Some("digest") => Sub::Digest,
        Some("psyche") => Sub::Psyche,
        Some("post-update") => Sub::PostUpdate,
        Some("translate") => Sub::Translate,
        None | Some("-h") | Some("--help") => Sub::Help,
        Some(other) => Sub::Unknown(other.to_string()),
    }
}


enum Sub {
    Digest,
    Psyche,
    PostUpdate,
    Translate,
    Help,
    Unknown(String),
}

fn classify(sub: Option<&str>) -> Sub {
    match sub {
        Some("digest") => Sub::Digest,
        Some("psyche") => Sub::Psyche,
        Some("post-update") => Sub::PostUpdate,
        Some("translate") => Sub::Translate,
        None | Some("-h") | Some("--help") => Sub::Help,
        Some(other) => Sub::Unknown(other.to_string()),
    }
}

//! claude-spt — consolidated tool binary for the claude-spt adapter (ADR-0006, U2).
//!
//! ONE binary, three subcommands (was three crates → fewer artifacts per triple in adapter.spt):
//!   claude-spt digest      — the [digest] extractor (was claude-spt-digest)
//!   claude-spt psyche      — the [session.psyche_init] Psyche runner (was claude-spt-psyche)
//!   claude-spt post-update — reconcile the cplugs plugin after `spt adapter update` (NEW, ADR-0006)
//!
//! Dispatch is a bare `argv[1]` match (no clap — keep the dependency-light ethos both predecessor
//! crates were built on). Each subcommand owns its remaining argv via `std::env::args().skip(2)`
//! (skip the binary name + the subcommand token) and its own hand-written flag parser.
//!
//! `cc-spt-idle-translate` stays a SEPARATE binary this milestone — it folds into a `claude-spt
//! translate` subcommand only once doyle ask #3 lets `[message-idle-translation-binary]` take a
//! command/subcommand rather than a bare `path` (D3). [impl->REQ-DIST-BINARY-CONSOLIDATE]

mod digest;
mod post_update;
mod psyche;
mod translate;

use std::process::ExitCode;

/// The resolved subcommand. Pure classification of `argv[1]` so dispatch routing is unit-testable
/// without spawning the binary. [impl->REQ-DIST-BINARY-CONSOLIDATE]
#[derive(Debug, PartialEq)]
enum Sub {
    Digest,
    Psyche,
    PostUpdate,
    Help,
    Unknown(String),
}

fn classify(sub: Option<&str>) -> Sub {
    match sub {
        Some("digest") => Sub::Digest,
        Some("psyche") => Sub::Psyche,
        Some("post-update") => Sub::PostUpdate,
        None | Some("-h") | Some("--help") => Sub::Help,
        Some(other) => Sub::Unknown(other.to_string()),
    }
}

fn usage() {
    eprintln!(
        "claude-spt <subcommand> [args]\n\
         \n\
         subcommands:\n\
         \x20 digest       map a Claude Code JSONL transcript to digest NDJSON ([digest] extractor)\n\
         \x20 psyche       run the LiveAgent Psyche companion ([session.psyche_init] runner)\n\
         \x20 post-update  reconcile the cplugs plugin after `spt adapter update`"
    );
}

fn main() -> ExitCode {
    let sub = std::env::args().nth(1);
    match classify(sub.as_deref()) {
        Sub::Digest => digest::run(),
        Sub::Psyche => psyche::run(),
        Sub::PostUpdate => post_update::run(),
        Sub::Help => {
            usage();
            ExitCode::SUCCESS
        }
        Sub::Unknown(other) => {
            eprintln!("claude-spt: unknown subcommand: {other}");
            usage();
            ExitCode::from(2)
        }
    }
}

// [unit->REQ-DIST-BINARY-CONSOLIDATE]
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn classifies_each_real_subcommand() {
        assert_eq!(classify(Some("digest")), Sub::Digest);
        assert_eq!(classify(Some("psyche")), Sub::Psyche);
        assert_eq!(classify(Some("post-update")), Sub::PostUpdate);
    }

    #[test]
    fn no_subcommand_and_help_flags_are_help() {
        assert_eq!(classify(None), Sub::Help);
        assert_eq!(classify(Some("-h")), Sub::Help);
        assert_eq!(classify(Some("--help")), Sub::Help);
    }

    #[test]
    fn unrecognized_subcommand_is_unknown_not_misrouted() {
        // A typo must NOT silently fall through to a real subcommand (it exits 2 in main).
        assert_eq!(classify(Some("digset")), Sub::Unknown("digset".into()));
    }
}

Digest,
Psyche,
PostUpdate,
+    Translate,
Help,
Unknown(String),
}

+        Some("translate") => Sub::Translate,


        Sub::Digest => digest::run(),
        Sub::Psyche => psyche::run(),
        Sub::PostUpdate => post_update::run(),

        Sub::Digest => digest::run(),
        Sub::Psyche => psyche::run(),
        Sub::PostUpdate => post_update::run(),
        Sub::Translate => translate::run(),


        Sub::Digest => digest::run(),
        Sub::Psyche => psyche::run(),
        Sub::PostUpdate => post_update::run(),
        Sub::Translate => translate::run(),

//! claude-spt — consolidated tool binary for the claude-spt adapter (ADR-0006, U2).
//!
//! ONE binary, three subcommands (was three crates → fewer artifacts per triple in adapter.spt):
//!   claude-spt digest      — the [digest] extractor (was claude-spt-digest)
//!   claude-spt psyche      — the [session.psyche_init] Psyche runner (was claude-spt-psyche)
//!   claude-spt post-update — reconcile the cplugs plugin after `spt adapter update` (NEW, ADR-0006)
//!
//! Dispatch is a bare `argv[1]` match (no clap — keep the dependency-light ethos both predecessor
//! crates were built on). Each subcommand owns its remaining argv via `std::env::args().skip(2)`
//! (skip the binary name + the subcommand token) and its own hand-written flag parser.
//!
//! `cc-spt-idle-translate` stays a SEPARATE binary this milestone — it folds into a `claude-spt
//! translate` subcommand only once doyle ask #3 lets `[message-idle-translation-binary]` take a
//! command/subcommand rather than a bare `path` (D3). [impl->REQ-DIST-BINARY-CONSOLIDATE]

mod digest;
mod post_update;
mod psyche;
mod translate;

use std::process::ExitCode;

/// The resolved subcommand. Pure classification of `argv[1]` so dispatch routing is unit-testable
/// without spawning the binary. [impl->REQ-DIST-BINARY-CONSOLIDATE]
#[derive(Debug, PartialEq)]
enum Sub {
    Digest,
    Psyche,
    PostUpdate,
    Translate,
    Help,
    Unknown(String),
}

fn classify(sub: Option<&str>) -> Sub {
    match sub {
        Some("digest") => Sub::Digest,
        Some("psyche") => Sub::Psyche,
        Some("post-update") => Sub::PostUpdate,
        Some("translate") => Sub::Translate,
        None | Some("-h") | Some("--help") => Sub::Help,
        Some(other) => Sub::Unknown(other.to_string()),
    }
}

fn usage() {
    eprintln!(
        "claude-spt <subcommand> [args]\n\
         \n\
         subcommands:\n\
         \x20 digest       map a Claude Code JSONL transcript to digest NDJSON ([digest] extractor)\n\
         \x20 psyche       run the LiveAgent Psyche companion ([session.psyche_init] runner)\n\
         \x20 post-update  reconcile the cplugs plugin after `spt adapter update`"
    );
}

fn main() -> ExitCode {
    let sub = std::env::args().nth(1);
    match classify(sub.as_deref()) {
        Sub::Digest => digest::run(),
        Sub::Psyche => psyche::run(),
        Sub::PostUpdate => post_update::run(),
        Sub::Help => {
            usage();
            ExitCode::SUCCESS
        }
        Sub::Unknown(other) => {
            eprintln!("claude-spt: unknown subcommand: {other}");
            usage();
            ExitCode::from(2)
        }
    }
}

// [unit->REQ-DIST-BINARY-CONSOLIDATE]
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn classifies_each_real_subcommand() {
        assert_eq!(classify(Some("digest")), Sub::Digest);
        assert_eq!(classify(Some("psyche")), Sub::Psyche);
        assert_eq!(classify(Some("post-update")), Sub::PostUpdate);
    }

    #[test]
    fn no_subcommand_and_help_flags_are_help() {
        assert_eq!(classify(None), Sub::Help);
        assert_eq!(classify(Some("-h")), Sub::Help);
        assert_eq!(classify(Some("--help")), Sub::Help);
    }

    #[test]
    fn unrecognized_subcommand_is_unknown_not_misrouted() {
        // A typo must NOT silently fall through to a real subcommand (it exits 2 in main).
        assert_eq!(classify(Some("digset")), Sub::Unknown("digset".into()));
    }
}

+        Sub::Translate => translate::run(),


Update main.rs header note + usage + the classify test:


//!   claude-spt digest      — the [digest] extractor (was claude-spt-digest)
//!   claude-spt psyche      — the [session.psyche_init] Psyche runner (was claude-spt-psyche)
//!   claude-spt post-update — reconcile the cplugs plugin after `spt adapter update` (NEW, ADR-0006)
//!
//! Dispatch is a bare `argv[1]` match (no clap — keep the dependency-light ethos both predecessor
//! crates were built on). Each subcommand owns its remaining argv via `std::env::args().skip(2)`
//! (skip the binary name + the subcommand token) and its own hand-written flag parser.
//!
//! `cc-spt-idle-translate` stays a SEPARATE binary this milestone — it folds into a `claude-spt
//! translate` subcommand only once doyle ask #3 lets `[message-idle-translation-binary]` take a
//! command/subcommand rather than a bare `path` (D3). [impl->REQ-DIST-BINARY-CONSOLIDATE]

//!   claude-spt digest      — the [digest] extractor (was claude-spt-digest)
//!   claude-spt psyche      — the [session.psyche_init] Psyche runner (was claude-spt-psyche)
//!   claude-spt post-update — reconcile the cplugs plugin after `spt adapter update` (ADR-0006)
//!   claude-spt translate   — the [message-idle-translation-binary] idle filter (was cc-spt-idle-translate)
//!
//! Dispatch is a bare `argv[1]` match (no clap — keep the dependency-light ethos the predecessor
//! crates were built on). Each subcommand owns its remaining argv via `std::env::args().skip(2)`
//! (skip the binary name + the subcommand token) and its own hand-written flag parser. `translate`
//! reads no argv (pure stdin/stdout protocol).
//!
//! `translate` folded in at the v0.8.0 cut once spt-core v0.16.0 gave `[message-idle-translation-
//! binary]` a `command` field (D3) — so claude-spt is now the SINGLE tool binary (one artifact per
//! triple in adapter.spt). [impl->REQ-DIST-BINARY-CONSOLIDATE] [impl->REQ-DIST-IDLE-TRANSLATE]