UNIFY-CONSOLIDATE: 3 of 6 U-tasks done, committed, all gates green.

**Done this session**
- **U1** `cfe27fd` — `[update].message` field (→ `/reload-plugins` + `spt endpoint run` route). +unit
- **U6** `cfe27fd` — `-n {id}` display name on both bringup paths + `--remote-control {id}` on `[session.self]`. `{id}`-only (no `{node}` key yet). +unit
- **U2** `2cadada` — merged digest+psyche → one `tools/claude-spt` crate (`digest`/`psyche`/`post-update` subcommands, hand-rolled dispatch not clap). New `post-update` = plugin reconcile (claude|ccs detect → cplugs marketplace → `sptc@cplugs` install/update → notice; never runs `/reload-plugins`). Old crates deleted, all refs updated. **+impl+unit+int** — digest-proof (5/0) + translate-proof GREEN on-node spt 0.15.0.

6 REQ stubs minted; 3 activated. run-gates PASS each commit. Commune dropped, plan progress committed `0009cfd`.

**Remaining — 2 need your call:**

1. **U4** (thin commune/send/signoff skills → adapter strings). Blocked on one empirical question: does `/sptc:live` re-fire a SessionStart brief, or is the UPS body the only go-live delivery? Need a live perched session to test. Fully buildable once answered.
2. **U5** (README per-OS install chains + agent-prompt). Zero decisions — autonomous.
3. **U3 + cut v0.8.0**. Repo rename `spt-claude-code`→`claude-spt` is an **out-of-repo GitHub op that breaks the one existing install** → your action + confirm. In-repo ref-flip batches with it (rename-then-cut).

Recommend: I do **U5** next (no blockers), you run the `/sptc:live` brief-timing check for U4, and we coordinate U3+cut together. Want me to proceed with U5?


proceed. after U5, please confirm the following: ensure PreToolUse message polling + injection was built. it's not in the release notes, but we definitely specced and scoped it before the last release build. /trace for it if necessary.


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


Verify PreToolUse message polling and injection


Proceeding with U5. Scope the README + install surface + docs-gen:


Bash

cd "C:/Users/decid/Documents/projects/spt-claude-code" && echo "=== README ==="; wc -l README.md 2>/dev/null; echo "=== install refs in README ==="; grep -niE "install|adapter add|spt-releases|spt adapter|getting started|quickstart" README.md 2>/dev/null | head; echo "=== docs-site install pages ==="; find docs-site -iname '*install*' -o -iname '*quickstart*' -o -iname '*getting*' 2>/dev/null; echo "=== llms gen ==="; ls ci/docs/

Scope U5 README + docs surface


=== README ===
=== install refs in README ===
=== docs-site install pages ===
docs-site/book/quickstart.html
docs-site/src/quickstart.md
=== llms gen ===
check-docs.sh
gen-llms.sh


No README.md exists — U5 creates it. Read the quickstart + llms gen to source the install chain and keep docs in sync:


cd "C:/Users/decid/Documents/projects/spt-claude-code" && ls README* 2>/dev/null || echo "NO README at root"; echo "=== root md files ==="; ls *.md | head -30

Confirm no README + list root docs


NO README at root
=== root md files ===
AGENTS.md
CC-LAUNCHER-BIND-PLAN.md
CHANGELOG.md
CHECKPOINT-COMMUNE-PLAN.md
CI-ACCEPTANCE-PLAN.md
CLAUDE.md
CONTEXT.md
CPLUGS-PREP-PLAN.md
HANDOFF.md
MANIFEST-PARITY-PLAN.md
MDBOOK-PLAN.md
PARITY-PUBLISH-PLAN.md
PEROS-PLAN.md
PEROS-RERELEASE-PLAN.md
PREP4-PLAN.md
SCOPE.md
SESSIONSTART-BRIEF-PLAN.md
SETUP-SLICE-PLAN.md
SKELETON-PLAN.md
SKILLS-SLICE-PLAN.md
SPT_HARNESS_ADAPTER_TIPS.md
UNIFY-CONSOLIDATE-PLAN.md
UPDATE-NAMING-DOYLE-ASKS.md
V13-ADAPTER-PLAN.md


1	<!-- Sub-10-min, deterministic, no placeholders (DOCS-STRATEGY #1/#3). Every command is real and
2	     runs as written. Skeleton-honest: it only claims what is operative today. [doc->REQ-DOCS-SITE] -->
3	# Quickstart
4
5	Goal: install the `sptc` plugin, watch it bring up `spt-core` for you, and confirm your session is
6	reachable — in under ten minutes. You need Claude Code and a shell (`bash` on macOS/Linux, Git Bash
7	on Windows).
8
9	## 1. Add the marketplace and install the plugin
10
11	From Claude Code:
12
13	```text
14	/plugin marketplace add SaberMage/cplugs
15	/plugin install sptc@cplugs
16	```
17
18	Then **restart the Claude Code session** so the plugin's `SessionStart` hook runs and populates the
19	session environment.
20
21	## 2. Let it install spt-core for you
22
23	On that first start, the plugin's bootstrap installs `spt-core` if it is not already present —
24	this is the invisible-installer step, nothing for you to run. Confirm it landed:
25
26	```bash
27	spt --version
28	```
29
30	If `spt` is not yet on your `PATH` in this shell, it was installed to `~/.local/bin/spt` (macOS /
31	Linux) or `%LOCALAPPDATA%\spt-core\bin\spt.exe` (Windows); open a fresh shell and try again.
32
33	## 3. Confirm your session is reachable
34
35	Each Claude Code session resolves its own agent identity. Check it:
36
37	```bash
38	spt whoami
39	```
40
41	That prints this session's perch id (resolved from `$OWL_SESSION_ID` / `$SPT_AGENT_ID`). A printed
42	id means the adapter wired your session into Spacetime's messaging fabric.
43
44	## What works today, and what is coming
45
46	This is an early build. The plumbing above — install, invisible `spt-core` bootstrap, identity —
47	is operative now. The `/sptc:*` skills (send, ready, live, commune, and the rest) ship as
48	**skeletons**: their operative instructions are delivered by the adapter at invocation time rather
49	than baked into the plugin, and some are still being wired. Each skill says in place whether it is
50	operative yet.
51
52	Next, read [Harness contract](./reference/harness-contract.md) to see exactly how Claude Code hook
53	events map onto the `spt` binary.
54

<!-- Sub-10-min, deterministic, no placeholders (DOCS-STRATEGY #1/#3). Every command is real and
     runs as written. Skeleton-honest: it only claims what is operative today. [doc->REQ-DOCS-SITE] -->
# Quickstart

Goal: install the `sptc` plugin, watch it bring up `spt-core` for you, and confirm your session is
reachable — in under ten minutes. You need Claude Code and a shell (`bash` on macOS/Linux, Git Bash
on Windows).

## 1. Add the marketplace and install the plugin

From Claude Code:

```text
/plugin marketplace add SaberMage/cplugs
/plugin install sptc@cplugs
```

Then **restart the Claude Code session** so the plugin's `SessionStart` hook runs and populates the
session environment.

## 2. Let it install spt-core for you

On that first start, the plugin's bootstrap installs `spt-core` if it is not already present —
this is the invisible-installer step, nothing for you to run. Confirm it landed:

```bash
spt --version
```

If `spt` is not yet on your `PATH` in this shell, it was installed to `~/.local/bin/spt` (macOS /
Linux) or `%LOCALAPPDATA%\spt-core\bin\spt.exe` (Windows); open a fresh shell and try again.

## 3. Confirm your session is reachable

Each Claude Code session resolves its own agent identity. Check it:

```bash
spt whoami
```

That prints this session's perch id (resolved from `$OWL_SESSION_ID` / `$SPT_AGENT_ID`). A printed
id means the adapter wired your session into Spacetime's messaging fabric.

## What works today, and what is coming

This is an early build. The plumbing above — install, invisible `spt-core` bootstrap, identity —
is operative now. The `/sptc:*` skills (send, ready, live, commune, and the rest) ship as
**skeletons**: their operative instructions are delivered by the adapter at invocation time rather
than baked into the plugin, and some are still being wired. Each skill says in place whether it is
operative yet.

Next, read [Harness contract](./reference/harness-contract.md) to see exactly how Claude Code hook
events map onto the `spt` binary.


proceed. after U5, please confirm the following: ensure PreToolUse message polling + injection was built. it's not in the release notes, but we definitely specced and scoped it before the last release…


U5 = create README. Repo slug stays `SaberMage/spt-claude-code` now (U3 ref-flip handles the rename atomically). Need the spt-releases per-platform install command — check the bootstrap + docs for the canonical install script:


cd "C:/Users/decid/Documents/projects/spt-claude-code" && echo "=== bootstrap files ==="; find plugin -iname '*bootstrap*'; echo "=== install script refs ==="; grep -rniE "spt-releases|install\.(sh|ps1)|githubusercontent|curl .*spt|iwr |irm |raw\.github" plugin docs adapter 2>/dev/null | grep -iv "traceable\|reqs" | head -20

Find spt-core install script command


=== bootstrap files ===
plugin/sptc/bootstrap.ps1
plugin/sptc/bootstrap.sh
=== install script refs ===
plugin/sptc/bootstrap.ps1:2:# Verbatim against the published contract: spt-releases harness-contract/install-on-demand.md.
plugin/sptc/bootstrap.ps1:7:    irm https://sabermage.github.io/spt-releases/install.ps1 | iex
plugin/sptc/bootstrap.sh:3:# Verbatim against the published contract: spt-releases harness-contract/install-on-demand.md.
plugin/sptc/bootstrap.sh:10:  curl -fsSL https://sabermage.github.io/spt-releases/install.sh | sh
plugin/sptc/skills/setup/SKILL.md:25:   published install-on-demand bootstrap (spt-releases `harness-contract/install-on-demand.md`):
plugin/sptc/skills/setup/SKILL.md:26:   - **POSIX:** `curl -fsSL https://sabermage.github.io/spt-releases/install.sh | sh`
plugin/sptc/skills/setup/SKILL.md:27:   - **Windows (PowerShell):** `irm https://sabermage.github.io/spt-releases/install.ps1 | iex`
plugin/sptc/skills/setup/SKILL.md:50:   dir). If either ever fails to start, confirm both `.exe`s are present in that install dir — their
plugin/sptc/skills/signoff/SKILL.md:16:3. Confirm to the user. You are no longer reachable; `/sptc:ready` or `/sptc:live` brings you back.
docs/CI.md:26:   `manifest.schema.json` (from `SaberMage/spt-releases`).
docs/SPT-CORE-FINDINGS.md:4:> to build the adapter is missing/ambiguous in spt-core's **published** surface (`spt-releases`
docs/SPT-CORE-FINDINGS.md:23:| F-016 | 2026-06-22 | **RESOLVED — both fixes SHIPPED** (doyle, broker.rs-confirmed). The published `[message-idle-translation-binary]` doc omitted `{commit}` from the stdout vocabulary AND its degenerate baseline `{text}{key:enter}` would itself FAULT. `{commit:true}` is the MANDATORY inject-sequence terminator (`run_inject_worker` broker.rs:1075-1090; no-commit → 5s `INJECT_COMMIT_DEADLINE` FAULT, broker.rs:151-169; reference `{text}{key:enter}{commit:true}` translation.rs:74-78). **(i) adapter binary appends trailing `{commit:true}` — DONE (cc-spt-idle-translate, 11 tests); (ii) contract republished + LIVE on gh-pages 2026-06-22 — `{commit}` in the vocabulary + commit-deadline/inject-floor semantics + corrected degenerate; re-verified by re-fetch.** Residual: end-to-end `translate-proof` re-confirm pending v0.13.1 (counter 28+, runner-load delay, not code). Caught by the blind-build BEFORE a live FAULT | The published harness-contract documented the idle-translation binary's stdout vocabulary as `{key}`/`{delay_ms}`/`{text}` only, with a `{text}{key:enter}` degenerate example — but the broker requires a trailing `{commit:true}` terminator or every delivery FAULTs at the 5s commit deadline. A harness author building from the public surface alone ships a binary that faults live |
docs/SPT-CORE-FINDINGS.md:25:| F-018 | 2026-06-22 | **REPORTED to doyle (perri) — destructive footgun, os-2 family (F-011 sibling)**. Surfaced while constructing the F-015 brick repro on v0.13.2. `spt adapter add --github <user/repo>` on an **already-registered, already-extracted gh_release Pointer adapter** is destructive-then-cryptic: it git-clones the SOURCE repo over the extracted install dir (wiping the root `manifest.toml` + the 3 runtime `.exe` binaries the Pointer resolves), then the post-add first-`[update]` conduct fails bare **`ADAPTER_ADD_FAIL: io: ... (os error 2)`** — leaving the adapter `manifest-not-present` (same broken state as F-011, but operator-induced). No confirm prompt, no "already registered — did you mean `adapter update`?" guard, no rollback. Recovered by extracting `dist/adapter-windows-x86_64.spt` over the install dir (manifest+binaries restored, version 0.6.0, translate-proof OK). **Needs (doyle):** for an already-registered gh_release adapter, `add --github` should refuse/route to `adapter update` (or at least not wipe the working install before the avenue succeeds), and the `os error 2` should carry path+cause (the F-011 diagnostic ask, same resolver). | Re-running `spt adapter add --github user/repo` to "reinstall" a healthy gh_release Pointer adapter silently destroys the working install (clones raw source over the extracted runtime, drops manifest+binaries) and fails `os error 2`, requiring a manual `.spt` re-extract to recover |
docs/SPT-CORE-FINDINGS.md:193:ADR-0001's own Open/to-confirm names it: *"File-backed `[strings]` is an M12 spt-core dependency —
docs/SPT-CORE-FINDINGS.md:915:**Recommended spt-core fix.** Treat a non-resident host as a host failure: e.g. confirm the child is
docs/SPT-CORE-FINDINGS.md:939:(`sabermage.github.io/spt-releases/harness-contract/manifest.html`, `[message-idle-translation-binary]`)
docs/SPT-CORE-FINDINGS.md:969:corrected + live). The only residual is an end-to-end re-confirm via `translate-proof`, pending the
docs/SPT-CORE-FINDINGS.md:973:author time; runs post-release to confirm the fixed binary green. Caught here BEFORE any live FAULT —
docs/SPT-CORE-FINDINGS.md:1130:the FULL post-DA handshake, and confirm the exact encoding; perri to verify on a fresh endpoint.
docs/SPT-CORE-FINDINGS.md:1190:to verify on real CC-on-Windows when it lands (revert the abs-path interim, confirm the bare name resolves


Install scripts confirmed. Check whether `gen-llms.sh` ingests README (docs-drift gate sync):


1	#!/bin/sh
2	# Generate docs-site/llms.txt — a slim, curated agent index (DOCS-STRATEGY #4) derived
3	# DETERMINISTICALLY from SUMMARY.md (page order) + each page's first H1. No timestamps, no
4	# randomness: same sources => byte-identical output, so it can be drift-gated. [impl->REQ-DOCS-DRIFT]
5	#
6	# Usage: gen-llms.sh [--check]
7	#   (default) write docs-site/llms.txt
8	#   --check   print to stdout only (the drift gate diffs this against the committed file)
9	set -u
10	ROOT=$(CDPATH= cd "$(dirname "$0")/../.." && pwd)
11	SITE="$ROOT/docs-site"
12	SRC="$SITE/src"
13	SUMMARY="$SRC/SUMMARY.md"
14	OUT="$SITE/llms.txt"
15	[ -f "$SUMMARY" ] || { echo "FATAL: no $SUMMARY" >&2; exit 2; }
16
17	# First H1 ("# ...") of a page, trimmed. Falls back to the file stem.
18	page_title() {
19	  _t=$(sed -n 's/^#[[:space:]]\{1,\}\(.*\)$/\1/p' "$1" 2>/dev/null | head -n1)
20	  [ -n "$_t" ] && printf '%s' "$_t" || basename "$1" .md
21	}
22
23	emit() {
24	  printf '# claude-spt\n\n'
25	  printf '> Spacetime (spt) adapter for Claude Code: agent messaging, live agents, and an invisible spt-core installer, delivered as a Claude Code plugin. Built against spt-core'\''s published public surface only.\n\n'
26	  printf '## Docs\n\n'
27	  # Walk SUMMARY.md in order; for each `[label](./path.md)` link, emit a real title + relative link.
28	  # site-relative .html link (stable URLs; DOCS-STRATEGY #9). Markdown links only, in file order.
29	  grep -o '(\./[A-Za-z0-9_./-]*\.md)' "$SUMMARY" | sed 's/^(\.\///; s/)$//' | while IFS= read -r rel; do
30	    [ -f "$SRC/$rel" ] || continue
31	    _title=$(page_title "$SRC/$rel")
32	    _html=$(printf '%s' "$rel" | sed 's/\.md$/.html/')
33	    printf -- '- [%s](%s)\n' "$_title" "$_html"
34	  done
35	}
36
37	if [ "${1:-}" = "--check" ]; then
38	  emit
39	else
40	  emit > "$OUT"
41	  echo "wrote $OUT"
42	fi
43

#!/bin/sh
# Generate docs-site/llms.txt — a slim, curated agent index (DOCS-STRATEGY #4) derived
# DETERMINISTICALLY from SUMMARY.md (page order) + each page's first H1. No timestamps, no
# randomness: same sources => byte-identical output, so it can be drift-gated. [impl->REQ-DOCS-DRIFT]
#
# Usage: gen-llms.sh [--check]
#   (default) write docs-site/llms.txt
#   --check   print to stdout only (the drift gate diffs this against the committed file)
set -u
ROOT=$(CDPATH= cd "$(dirname "$0")/../.." && pwd)
SITE="$ROOT/docs-site"
SRC="$SITE/src"
SUMMARY="$SRC/SUMMARY.md"
OUT="$SITE/llms.txt"
[ -f "$SUMMARY" ] || { echo "FATAL: no $SUMMARY" >&2; exit 2; }

# First H1 ("# ...") of a page, trimmed. Falls back to the file stem.
page_title() {
  _t=$(sed -n 's/^#[[:space:]]\{1,\}\(.*\)$/\1/p' "$1" 2>/dev/null | head -n1)
  [ -n "$_t" ] && printf '%s' "$_t" || basename "$1" .md
}

emit() {
  printf '# claude-spt\n\n'
  printf '> Spacetime (spt) adapter for Claude Code: agent messaging, live agents, and an invisible spt-core installer, delivered as a Claude Code plugin. Built against spt-core'\''s published public surface only.\n\n'
  printf '## Docs\n\n'
  # Walk SUMMARY.md in order; for each `[label](./path.md)` link, emit a real title + relative link.
  # site-relative .html link (stable URLs; DOCS-STRATEGY #9). Markdown links only, in file order.
  grep -o '(\./[A-Za-z0-9_./-]*\.md)' "$SUMMARY" | sed 's/^(\.\///; s/)$//' | while IFS= read -r rel; do
    [ -f "$SRC/$rel" ] || continue
    _title=$(page_title "$SRC/$rel")
    _html=$(printf '%s' "$rel" | sed 's/\.md$/.html/')
    printf -- '- [%s](%s)\n' "$_title" "$_html"
  done
}

if [ "${1:-}" = "--check" ]; then
  emit
else
  emit > "$OUT"
  echo "wrote $OUT"
fi


llms.txt derives only from docs-site/SUMMARY pages — a root README won't break docs-drift. README is the U5 deliverable. I'll create it, add `README.md` to scan roots, and tag the doc evidence there. Writing the README:


Write

<!-- Install UX — every command real and copy-paste-correct per OS (DOCS-STRATEGY #1/#3).
     The repo slug SaberMage/spt-claude-code is current until the claude-spt rename (U3); the rename
     flips every reference here in one move. [doc->REQ-DIST-INSTALL-UX] -->
# claude-spt

The **Spacetime (`spt`) adapter for Claude Code** — agent-to-agent messaging, live agents, and an
invisible `spt-core` installer, delivered as a Claude Code plugin. Built against `spt-core`'s
**published public surface only** (`SaberMage/spt-releases` + the GitHub Pages docs).

Two names, by domain: **`claude-spt`** is the spt-core-facing identity (this repo, the adapter, the
tool binary); **`sptc`** is the Claude Code plugin you install (its skills are `/sptc:*`). See
[`docs/adr/0005-name-unification.md`](docs/adr/0005-name-unification.md).

## Install

### Recommended — the plugin (Claude Code does the rest)

From inside Claude Code:

```text
/plugin marketplace add SaberMage/cplugs
/plugin install sptc@cplugs
```

Restart the session. On first start the plugin **bootstraps `spt-core` for you** if it is absent,
then `/sptc:setup` activates the adapter. Nothing else to run. (See the
[Quickstart](docs-site/src/quickstart.md).)

### Manual — install `spt-core`, then activate the adapter

If `claude-spt` is your first exposure to `spt-core`, install the binary first (the published
`spt-releases` per-platform installer), then add the adapter straight from this repo's releases. Pick
your shell:

**bash (macOS / Linux / Git Bash on Windows)**

```bash
command -v spt >/dev/null 2>&1 || curl -fsSL https://sabermage.github.io/spt-releases/install.sh | sh
spt adapter add --release SaberMage/spt-claude-code
```

**PowerShell (Windows)**

```powershell
if (-not (Get-Command spt -ErrorAction SilentlyContinue)) { irm https://sabermage.github.io/spt-releases/install.ps1 | iex }
spt adapter add --release SaberMage/spt-claude-code
```

**cmd (Windows)**

```bat
where spt >nul 2>nul || powershell -NoProfile -Command "irm https://sabermage.github.io/spt-releases/install.ps1 | iex"
spt adapter add --release SaberMage/spt-claude-code
```

The `adapter add --release` pulls the single multi-platform `adapter.spt` (manifest + strings + the
host's tool binaries) — no per-OS asset to choose. If `spt` is not yet on `PATH` after the install
script, open a fresh shell (it lands at `~/.local/bin/spt` on macOS/Linux, or
`%LOCALAPPDATA%\spt-core\bin\spt.exe` on Windows).

### Copy-paste agent prompt

Hand this to a coding agent (Claude Code, etc.) to install everything in one go:

> Install the claude-spt adapter. Run this in one bash call, in order, and report the final
> `spt adapter list` line for `claude-spt`:
>
> ```bash
> command -v spt >/dev/null 2>&1 || curl -fsSL https://sabermage.github.io/spt-releases/install.sh | sh
> spt adapter add --release SaberMage/spt-claude-code
> spt adapter list | grep claude-spt
> ```

## Updating

One lever keeps the adapter current — symmetric with install:

```bash
spt adapter update claude-spt
```

On a real version apply spt-core prints the next step: run **`/reload-plugins`** in Claude Code (it
cannot reload a plugin from inside a running session) to pick up refreshed skills/hooks. That is the
only manual residual.

## Docs

- [Quickstart](docs-site/src/quickstart.md) — install → bring-up → reachable, under ten minutes.
- [Harness contract](docs-site/src/reference/harness-contract.md) — how Claude Code hook events map onto `spt`.
- [`SCOPE.md`](SCOPE.md) · [`CONTEXT.md`](CONTEXT.md) · [`docs/adr/`](docs/adr/) — decisions and the domain model.

Public-surface-only is the whole point of this project: if a capability is missing from `spt-core`'s
published surface, that is a finding to report — never a reason to reach into `spt-core` source.


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

<!-- Install UX — every command real and copy-paste-correct per OS (DOCS-STRATEGY #1/#3).
     The repo slug SaberMage/spt-claude-code is current until the claude-spt rename (U3); the rename
     flips every reference here in one move. [doc->REQ-DIST-INSTALL-UX] -->
# claude-spt

The **Spacetime (`spt`) adapter for Claude Code** — agent-to-agent messaging, live agents, and an
invisible `spt-core` installer, delivered as a Claude Code plugin. Built against `spt-core`'s
**published public surface only** (`SaberMage/spt-releases` + the GitHub Pages docs).

Two names, by domain: **`claude-spt`** is the spt-core-facing identity (this repo, the adapter, the
tool binary); **`sptc`** is the Claude Code plugin you install (its skills are `/sptc:*`). See
[`docs/adr/0005-name-unification.md`](docs/adr/0005-name-unification.md).

## Install

### Recommended — the plugin (Claude Code does the rest)

From inside Claude Code:

```text
/plugin marketplace add SaberMage/cplugs
/plugin install sptc@cplugs
```

Restart the session. On first start the plugin **bootstraps `spt-core` for you** if it is absent,
then `/sptc:setup` activates the adapter. Nothing else to run. (See the
[Quickstart](docs-site/src/quickstart.md).)

### Manual — install `spt-core`, then activate the adapter

If `claude-spt` is your first exposure to `spt-core`, install the binary first (the published
`spt-releases` per-platform installer), then add the adapter straight from this repo's releases. Pick
your shell:

**bash (macOS / Linux / Git Bash on Windows)**

```bash
command -v spt >/dev/null 2>&1 || curl -fsSL https://sabermage.github.io/spt-releases/install.sh | sh
spt adapter add --release SaberMage/spt-claude-code
```

**PowerShell (Windows)**

```powershell
if (-not (Get-Command spt -ErrorAction SilentlyContinue)) { irm https://sabermage.github.io/spt-releases/install.ps1 | iex }
spt adapter add --release SaberMage/spt-claude-code
```

**cmd (Windows)**

```bat
where spt >nul 2>nul || powershell -NoProfile -Command "irm https://sabermage.github.io/spt-releases/install.ps1 | iex"
spt adapter add --release SaberMage/spt-claude-code
```

The `adapter add --release` pulls the single multi-platform `adapter.spt` (manifest + strings + the
host's tool binaries) — no per-OS asset to choose. If `spt` is not yet on `PATH` after the install
script, open a fresh shell (it lands at `~/.local/bin/spt` on macOS/Linux, or
`%LOCALAPPDATA%\spt-core\bin\spt.exe` on Windows).

### Copy-paste agent prompt

Hand this to a coding agent (Claude Code, etc.) to install everything in one go:

> Install the claude-spt adapter. Run this in one bash call, in order, and report the final
> `spt adapter list` line for `claude-spt`:
>
> ```bash
> command -v spt >/dev/null 2>&1 || curl -fsSL https://sabermage.github.io/spt-releases/install.sh | sh
> spt adapter add --release SaberMage/spt-claude-code
> spt adapter list | grep claude-spt
> ```

## Updating

One lever keeps the adapter current — symmetric with install:

```bash
spt adapter update claude-spt
```

On a real version apply spt-core prints the next step: run **`/reload-plugins`** in Claude Code (it
cannot reload a plugin from inside a running session) to pick up refreshed skills/hooks. That is the
only manual residual.

## Docs

- [Quickstart](docs-site/src/quickstart.md) — install → bring-up → reachable, under ten minutes.
- [Harness contract](docs-site/src/reference/harness-contract.md) — how Claude Code hook events map onto `spt`.
- [`SCOPE.md`](SCOPE.md) · [`CONTEXT.md`](CONTEXT.md) · [`docs/adr/`](docs/adr/) — decisions and the domain model.

Public-surface-only is the whole point of this project: if a capability is missing from `spt-core`'s
published surface, that is a finding to report — never a reason to reach into `spt-core` source.


Add README.md to scan roots + activate REQ-DIST-INSTALL-UX:


Edit

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

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


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

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

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

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

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

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

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


required_stages = []  # U5 (parallel-able with U2/U3). doc: README install-UX section + the agent-prompt skin + docs-site/llms.txt sync. impl: the per-OS install chains + the one-shot agent prompt. unit: ci/docs drift gate keeps llms.txt in sync. ACTIVATE at U5 start.

required_stages = ["doc"]  # U5 — ACTIVATED 2026-06-25. doc-class (install instructions, like the REQ-SETUP-* skills — no adapter-side code of its own): README.md (added to [scan] roots) carries the per-OS install chains (bash / PowerShell / cmd: install spt-core via the published spt-releases script, then `spt adapter add --release SaberMage/spt-claude-code`) + the copy-paste agent-prompt skin + the symmetric `spt adapter update claude-spt` lever. doc tag rides README.md. llms.txt sync is unaffected (it derives only from docs-site/SUMMARY pages; gen-llms.sh) — the docs-drift gate stays green. Repo slug stays SaberMage/spt-claude-code until the U3 rename flips every reference at the v0.8.0 cut.


title = "README ships platform-specific install chains (cmd / PowerShell / bash) — check-for/install spt-core (call the spt-releases per-platform install script; claude-spt may be a user's first spt-core exposure) then `spt adapter add --release SaberMage/claude-spt` — plus a copy-paste agent prompt (the casual-user skin) running the chain in one sequenced Bash call, symmetric with the update lever. U5."

-required_stages = []  # U5 (parallel-able with U2/U3). doc: README install-UX section + the agent-prompt skin + docs-site/llms.txt sync. impl: the per-OS install chains + the one-shot agent prompt. unit: ci/docs drift gate keeps llms.txt in sync. ACTIVATE at U5 start.

+required_stages = ["doc"]  # U5 — ACTIVATED 2026-06-25. doc-class (install instructions, like the REQ-SETUP-* skills — no adapter-side code of its own): README.md (added to [scan] roots) carries the per-OS install chains (bash / PowerShell / cmd: install spt-core via the published spt-releases script, then `spt adapter add --release SaberMage/spt-claude-code`) + the copy-paste agent-prompt skin + the symmetric `spt adapter update claude-spt` lever. doc tag rides README.md. llms.txt sync is unaffected (it derives only from docs-site/SUMMARY pages; gen-llms.sh) — the docs-drift gate stays green. Repo slug stays SaberMage/spt-claude-code until the U3 rename flips every reference at the v0.8.0 cut.


Verify the README's doc links resolve + run gates:


Bash

cd "C:/Users/decid/Documents/projects/spt-claude-code" && echo "=== link targets exist? ==="; for f in docs-site/src/quickstart.md docs-site/src/reference/harness-contract.md docs/adr/0005-name-unification.md SCOPE.md CONTEXT.md; do [ -f "$f" ] && echo "ok  $f" || echo "MISSING  $f"; done; echo "=== gates ==="; sh ci/run-gates.sh 2>&1 | grep -E "GATE|FAIL|RESULT|coverage green|INSTALL-UX|docs-drift|llms"; echo "=== INSTALL-UX stage ==="; (./traceable-reqs check 2>&1 || traceable-reqs check 2>&1) | grep "INSTALL-UX"

Verify README links + run gates + REQ coverage


=== link targets exist? ===
ok  docs-site/src/quickstart.md
ok  docs-site/src/reference/harness-contract.md
ok  docs/adr/0005-name-unification.md
ok  SCOPE.md
ok  CONTEXT.md
=== gates ===
=== GATE: shell-syntax (sh -n) ===
ok  ci/docs/gen-llms.sh
=== GATE: unit tests (tests/*.sh) ===
=== GATE: digest-extractor (cargo build + test) ===
=== GATE: psyche-runner (cargo build + test) ===
=== GATE: idle-translate (cargo build + test) ===
=== GATE: traceable-reqs check (requirement coverage) ===
ok  coverage green
=== GATE: skeleton-validate (cplugs installability) ===
=== GATE: manifest-schema ===
=== GATE: docs-drift ===
ok   llms.txt in sync
=== RESULT: PASS ===
=== INSTALL-UX stage ===
  [OK] REQ-DIST-INSTALL-UX  required: [doc]  stages: +doc -impl -unit -int


All links resolve, gates PASS, REQ-DIST-INSTALL-UX covered. Commit U5:


cd "C:/Users/decid/Documents/projects/spt-claude-code" && git add -A && git commit -q -F - <<'EOF'
feat(unify): U5 — README install UX (per-OS chains + agent-prompt skin)

REQ-DIST-INSTALL-UX (activated +doc). Create the repo README: per-OS install
chains (bash / PowerShell / cmd) that install spt-core via the published
spt-releases script then `spt adapter add --release SaberMage/spt-claude-code`,
a copy-paste agent prompt that runs the chain in one sequenced bash call, and
the symmetric `spt adapter update claude-spt` lever (with the /reload-plugins
residual). Plus the recommended plugin route (cplugs) mirroring the quickstart.

README.md added to traceable-reqs [scan] roots (carries the doc tag). llms.txt
unaffected (derives only from docs-site/SUMMARY) — docs-drift stays green. Repo
slug stays SaberMage/spt-claude-code until the U3 rename flips all refs at the cut.

Co-authored by: perri
EOF
git log --oneline -3

Commit U5


bd7c96c feat(unify): U5 — README install UX (per-OS chains + agent-prompt skin)
0009cfd docs(plan): UNIFY-CONSOLIDATE progress — U1/U6/U2 DONE, U4/U5/U3+cut remain
2cadada feat(unify): U2 — consolidate digest+psyche into one claude-spt binary + post-update


U5 committed. Now confirming the PreToolUse polling + injection. From the registry I already saw REQ-DIST-PRETOOL-POLL (F-021) sits at `required_stages = []`. Verifying against the actual hooks/manifest/scripts:


cd "C:/Users/decid/Documents/projects/spt-claude-code" && echo "=== hook scripts present ==="; ls plugin/sptc/hooks/ 2>/dev/null; echo "=== hooks.json events ==="; grep -oE '"(PreToolUse|PostToolUse|UserPromptSubmit|SessionStart|SubagentStart|SubagentStop|Stop)"' plugin/sptc/hooks/hooks.json 2>/dev/null | sort -u; echo "=== manifest [hooks.*] ==="; grep -nE '^\[hooks\.' adapter/claude-spt.toml; echo "=== any PreToolUse anywhere ==="; grep -rniE "pretooluse|pre-tool-use" plugin adapter --include=*.json --include=*.sh --include=*.toml 2>/dev/null

Check for PreToolUse hook implementation


=== hook scripts present ===
_common.sh
hooks.json
post-tool-use.sh
session-end.sh
session-start.sh
stop.sh
subagent-start.sh
subagent-stop.sh
user-prompt-submit.sh
=== hooks.json events ===
"PostToolUse"
"SessionStart"
"Stop"
"SubagentStart"
"SubagentStop"
"UserPromptSubmit"
=== manifest [hooks.*] ===
116:[hooks.SessionStart]
126:[hooks.UserPromptSubmit]
132:[hooks.Stop]
138:[hooks.SessionEnd]
143:[hooks.SubagentStart]
148:[hooks.SubagentStop]
165:[hooks.PostToolUse]
=== any PreToolUse anywhere ===


Confirmed — **PreToolUse polling/injection was NOT built.** No `pre-tool-use.sh`, no `PreToolUse` in hooks.json, no `[hooks.PreToolUse]` in the manifest, zero references in code. Let me pull the F-021 context to confirm it was a deliberate defer (not dropped) rather than lost:


cd "C:/Users/decid/Documents/projects/spt-claude-code" && echo "=== F-021 in findings ==="; grep -niE "F-021" docs/SPT-CORE-FINDINGS.md docs/*.md *.md 2>/dev/null | head; echo "=== F-021 / PRETOOL-POLL in git history ==="; git log --oneline --all -S "REQ-DIST-PRETOOL-POLL" 2>/dev/null | head; git log --oneline --all -S "F-021" 2>/dev/null | head

Trace F-021 scoping + history


=== F-021 in findings ===
docs/SPT-CORE-FINDINGS.md:28:> **F-021 / F-022 (NOT spt-core findings — claude-spt parity items, tracked as REQs; surfaced during the 2026-06-24 checkpoint-commune grill)** — (F-021) claude-spt polls only on `UserPromptSubmit` (between turns); legacy spt also polls+injects on **PreToolUse** = the mid-turn half of live-agent reachability (the main path by which a live agent receives a message WHILE working). `api poll` already exists on the public surface → adapter wiring, not an spt-core gap → **`REQ-DIST-PRETOOL-POLL`**. (F-022) subagent worker-perch reachability: wiring is present (`subagent-start.sh` → `api worker-start`; `hostable_types` includes `Worker`) but runtime `spt send`-to-a-worker is UNVERIFIED → validation item **`REQ-DIST-WORKER-PERCH-REACH`**. Neither is an spt-core public-surface gap.
docs/SPT-CORE-FINDINGS.md:28:> **F-021 / F-022 (NOT spt-core findings — claude-spt parity items, tracked as REQs; surfaced during the 2026-06-24 checkpoint-commune grill)** — (F-021) claude-spt polls only on `UserPromptSubmit` (between turns); legacy spt also polls+injects on **PreToolUse** = the mid-turn half of live-agent reachability (the main path by which a live agent receives a message WHILE working). `api poll` already exists on the public surface → adapter wiring, not an spt-core gap → **`REQ-DIST-PRETOOL-POLL`**. (F-022) subagent worker-perch reachability: wiring is present (`subagent-start.sh` → `api worker-start`; `hostable_types` includes `Worker`) but runtime `spt send`-to-a-worker is UNVERIFIED → validation item **`REQ-DIST-WORKER-PERCH-REACH`**. Neither is an spt-core public-surface gap.
=== F-021 / PRETOOL-POLL in git history ===
211a5cc docs(checkpoint-commune): land design — ADR-0004, F-020/21/22, 5 REQ stubs
211a5cc docs(checkpoint-commune): land design — ADR-0004, F-020/21/22, 5 REQ stubs


## U5 done + PreToolUse confirmation

**U5 shipped** `bd7c96c` — README with per-OS install chains (bash/PowerShell/cmd) + copy-paste agent prompt + update lever. REQ-DIST-INSTALL-UX +doc. Gates green.

**UNIFY-CONSOLIDATE now: 4 of 6 done** (U1, U6, U2, U5). Remaining: U4 (brief-timing Q) + U3+cut (operator-gated).

---

**PreToolUse polling + injection: SPECCED, NOT BUILT.** Your memory is right that it was scoped — but it was deliberately deferred, never implemented. Confirmed three ways:

- **Code:** zero PreToolUse anywhere — no `pre-tool-use.sh`, no `PreToolUse` in `hooks.json`, no `[hooks.PreToolUse]` in the manifest.
- **REQ:** `REQ-DIST-PRETOOL-POLL` exists but `required_stages = []` (inactive — "ACTIVATE at the reachability-parity milestone").
- **Trace:** surfaced as **F-021** during the 2026-06-24 checkpoint-commune grill, landed @ `211a5cc` as a stub alongside F-022. The v0.7.0 release (T1/T2/T3 checkpoint-commune wave) shipped without it by design.

Correctly absent from release notes — it was never built.

**What the gap is:** claude-spt drains messages only on `UserPromptSubmit` = **between turns**. Legacy spt also polls+injects on `PreToolUse` = **mid-turn** — the main path a live agent receives a message *while working*. So a claude-spt live agent currently can't be reached mid-turn.

**Cost to build:** small, adapter-only (NOT an spt-core gap — `api poll` is already public). Mirror the UPS drain: new `[hooks.PreToolUse]` + hooks.json entry + `pre-tool-use.sh` firing `api poll {session_id}` → additionalContext. Plus a unit test + (deferred) live int.

Want me to build it now? It's a clean standalone REQ — I can land it before/after finishing UNIFY-CONSOLIDATE, your call on ordering.


yes, build it now. also need to confirm that `spt api state` is being called on UserPromptSubmit (busy) and Stop (idle) hooks, as PreToolUse polling won't work properly if the endpoint state isn't set to busy during a turn.


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


Building PreToolUse polling. First confirming the busy/idle state-setting (it gates whether PreToolUse polling works). Read the hooks wiring:


1	{
2	  "hooks": {
3	    "SessionStart": [
4	      { "hooks": [ { "type": "command", "command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/session-start.sh\"", "shell": "bash" } ] }
5	    ],
6	    "UserPromptSubmit": [
7	      { "hooks": [ { "type": "command", "command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/user-prompt-submit.sh\"", "shell": "bash" } ] }
8	    ],
9	    "Stop": [
10	      { "hooks": [ { "type": "command", "command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/stop.sh\"", "shell": "bash" } ] }
11	    ],
12	    "SessionEnd": [
13	      { "hooks": [ { "type": "command", "command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/session-end.sh\"", "shell": "bash" } ] }
14	    ],
15	    "SubagentStart": [
16	      { "hooks": [ { "type": "command", "command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/subagent-start.sh\"", "shell": "bash" } ] }
17	    ],
18	    "SubagentStop": [
19	      { "hooks": [ { "type": "command", "command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/subagent-stop.sh\"", "shell": "bash" } ] }
20	    ],
21	    "PostToolUse": [
22	      { "matcher": "Write", "hooks": [ { "type": "command", "command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/post-tool-use.sh\"", "shell": "bash" } ] }
23	    ]
24	  }
25	}
26


95	# [impl->REQ-DIST-ADAPTER-RELEASE]
96	[update]
97	avenue = "gh_release"
98	repo = "SaberMage/spt-claude-code"
99	message = """
100	**claude-spt updated.** One step left — Claude Code can't reload a plugin from inside a running session:
101
102	- Run **`/reload-plugins`** now (or restart Claude Code) so the refreshed skills/hooks take effect.
103
104	Then you're current. Two ways to go live:
105
106	- **`/sptc:live <id>`** — upgrade *this* session to a live agent.
107	- **`spt endpoint run`** — the `spt` CLI route: spawn/attach endpoints, drive them over Remote Control, and bring up sessions headlessly. The more powerful lever once you're comfortable.
108	"""
109
110	# ── [hooks.<event>] ──────────────────────────────────────────────────────────────────────────
111	# CC hook event → the opaque `api …` command it fires. These mirror the hand-written plugin
112	# hooks.json (plugin/sptc/hooks/hooks.json) one-for-one; the plugin shells `spt api`, this table
113	# is the registry-side declaration (ADR-0002, F-001 resolution). `reads` = the CC hook-stdin
114	# fields we map; `can_inject` = whether the event can surface additionalContext back to the agent.
115
116	[hooks.SessionStart]
117	# Seed the endpoint — ADAPTER-AGNOSTIC since spt-core v0.9.0 (PREP-4): NO --adapter. The owning
118	# adapter resolves at bind time from this seed's {parent_pid} via [adapter] host_binaries (the
119	# legacy-parity bare flow). {parent_pid} is a spt-filled key; session_id is a CC stdin field. NOT the
120	# blocking poll/listen loop — that is /sptc:ready|live, never SessionStart. (Mirrors the hand-written
121	# plugin hook session-start.sh, whose bind/boundary branches keep an explicit --adapter override.)
122	fires = "api seed --session-id {session_id} --pid {parent_pid}"
123	reads = ["session_id", "parent_pid"]
124	can_inject = true
125
126	[hooks.UserPromptSubmit]
127	# Drain the inbox (api poll → additionalContext) and run keyword hints (api hint over the prompt).
128	fires = "api poll {session_id}"
129	reads = ["session_id", "prompt"]
130	can_inject = true
131
132	[hooks.Stop]
133	# Idle marker on turn-end. Stop cannot inject → relay/sentinel fallback (echo-gate, F-001 §Gap3).
134	fires = "api state idle"
135	reads = ["session_id"]
136	can_inject = false
137
138	[hooks.SessionEnd]
139	fires = "api session-end {session_id}"
140	reads = ["session_id"]
141	can_inject = false
142
143	[hooks.SubagentStart]
144	fires = "api worker-start {session_id}"
145	reads = ["session_id"]
146	can_inject = false
147
148	[hooks.SubagentStop]
149	fires = "api worker-stop {session_id}"
150	reads = ["session_id"]
151	can_inject = false
152
153	# PostToolUse — the CHECKPOINT detector (Feature 2). On a Write to the live agent's own
154	# `.claude/<id>-commune.md` whose content carries the literal `!!checkpoint!!` trigger, the hook
155	# marks the perch idle (`api state idle`) then SELF-SENDS a reserved structured signal
156	# (`spt send --from <id> <id> --json-payload {"checkpoint":"v1","wake":…}`) that loops back through
157	# the endpoint's own [message-idle-translation-binary], which fires the clear+wake macro — an
158	# agent-driven context reset (the operator's manual `/clear`, done by the agent). The `fires` line
159	# below names the representative `api` action (the idle mark); the conditional self-send is the
160	# wrapper's specialized behavior (plugin/sptc/hooks/post-tool-use.sh). `can_inject=false`: delivery
161	# is the self-send loopback, not additionalContext. spt-hosted live sessions only. The `!!checkpoint!!`
162	# file-content marker is the WRITE-side trigger; spt-core v0.15.0 strips it from the durable + pending
163	# context on ingest, so it is one-shot (never persists in the resumed mind, never re-fires). The
164	# `--json-payload` self-send is the DELIVERY-side trigger. [impl->REQ-DIST-CHECKPOINT-COMMUNE]
165	[hooks.PostToolUse]
166	fires = "api state idle {session_id}"
167	reads = ["session_id", "tool_name", "tool_input"]
168	can_inject = false
169
170	# ── [identity] ─────────────────────────────────────────────────────────────────────────────
171	[identity]
172	# CC hands the session_id on hook stdin once running (discovered post-spawn, not injected).
173	session_id_source = "post_spawn"
174	# Process-tree anchor when session_id is absent: the Claude Code binary.
175	parent_ancestor_name = "claude"
176
177	# ── [session] ──────────────────────────────────────────────────────────────────────────────
178	# Watched-dir seams for the file-drop lifecycle (doyle 2026-06-15: commune/signoff are FILE-DROPS,
179	# not api verbs — "there is deliberately no api commune"). The agent writes `<endpoint_id>-commune.md`
180	# / `<endpoint_id>-signoff.md` into this dir; spt-core's daemon watcher ingests it into the endpoint's
181	# tracked mind, then deletes it (daemon = single writer). The filenames are fixed by the contract;
182	# only the dir is adapter-declared. `.claude` is CC's per-project state dir — where a live CC session
183	# already drops these (matches the live-agent reorientation), resolved per-endpoint against its cwd.
184	# /sptc:commune writes the commune file here; /sptc:signoff's graceful path is `endpoint shutdown`
185	# (the signoff file-drop is the watcher's equivalent). [impl->REQ-DIST-MANIFEST-SCHEMA]
186	[session]
187	commune_dir = ".claude"
188	signoff_dir = ".claude"
189
190	# ── [session.psyche_init] — base is LIVE-CAPABLE (Option A, doyle-ruled + code-confirmed 2026-06-17) ─
191	# The Psyche-spawn seam lives in the BASE manifest, not a `:live` profile overlay. ready-vs-live is the
192	# COMMAND, not the profile: `/sptc:live` brings the perch up via `spt api listen` → the live path stamps
193	# the perch state=live_agent → the daemon livehost hosts the Psyche off that online status (it checks
194	# psyche_init on the resolved manifest). `/sptc:ready` brings it up as state=ready_agent → livehost
195	# SKIPS the Psyche regardless of base psyche_init (livehost.rs:282 gate — doyle). So declaring
196	# psyche_init in base is harmless for ready agents and removes the need for a separate `:live` profile
197	# (legacy parity: zero --adapter on the default path; the daemon resolves claude-spt by host_binaries).
198	#
199	# Psyche keys the daemon fills are EXACTLY {id, session_id, psyche_dir, psyche_prompt} — NOT
200	# {session_name} (a [session.self] fill) and NOT {psyche_context} (the resume-preload seam, not first
201	# spawn). `detach=true` + cwd={psyche_dir}: the Psyche is fire-and-forget detached (Stdio::null,
202	# unsupervised), owns its `<parent>-psyche` perch, authors commune-drops only, exits at session end.
203	# The runner (`claude-spt psyche`, the psyche subcommand of the consolidated tools/claude-spt crate —
204	# ADR-0006/U2) is a resident wrapper — `claude -p` is one-shot, so it seeds
205	# the Psyche `claude` session once from {psyche_prompt} then drives one `claude --continue` turn per
206	# daemon pulse (NOT a bare `claude -p`), the claude authoring commune drops. spt-core fills each {key}
207	# as ONE argv element (tokenize-then-fill, v0.8.2+); the runner parses --prompt greedily as defense.
208	# [impl->REQ-SKILL-LIVE]
209	[session.psyche_init]
210	command = "claude-spt psyche --id {id} --session-id {session_id} --prompt {psyche_prompt}"
211	cwd = "{psyche_dir}"
212	detach = true
213	keys = ["id", "session_id", "psyche_dir", "psyche_prompt"]
214
215	# ── [session.self] ─────────────────────────────────────────────────────────────────────────
216	# The spt-hosted bringup template (publish target #2's launcher seam). `spt endpoint run --adapter
217	# claude-spt --id <id>` spawns this command into a broker-held PTY; CC self-registers its perch
218	# post-spawn (`api bind <id>`, the spt-hosted path — NOT the seed→listen path /sptc:ready|live uses).
219	# This is what the `cc-<id>` shortcut (shortcut_basename="cc") bakes into a non-interactive launch.
220	#
221	# command is `claude` (+ `--dangerously-skip-permissions`): CC mints its OWN session id internally
222	# (identity.session_id_source = post_spawn), so spt's {session_id} is not threaded on argv. The
223	# endpoint {id} is threaded THREE ways now (U6, REQ-DIST-RC-STARTUP): it rides the ENV for the BIND
224	# (CC has no id flag, so SessionStart reads $SPT_ENDPOINT_ID and binds — see [env.SPT_ENDPOINT_ID]
225	# below, the sanctioned flagless-harness pattern), AND it sets the DISPLAY name (`-n {id}`, shown in
226	# the prompt box / `/resume`) AND the REMOTE-CONTROL channel (`--remote-control {id}`) — so a FRESH
227	# spt-hosted endpoint is `spt rc <id>`-attachable + idle/checkpoint-injectable identically to a
228	# RESUMED one ([session.resume]). The env-bind and the -n/RC flags are non-conflicting: env names the
229	# bind id at SessionStart, -n/RC name the display + control channel (distinct CC mechanisms).
230	# `--dangerously-skip-permissions` is required for the spt-hosted bringup: the broker spawns CC
231	# non-interactively into a PTY, so the permission-prompt gate would deadlock the launch with no
232	# operator to approve it. spt tokenizes the command on whitespace then fills {id} as a clean argv
233	# element each (tokenize-then-fill, v0.8.2+). STRETCH (doyle ask #4, operator-raised): -n/RC upgrade
234	# to `{id}@{node}` (cross-node-distinguishable) once a `{node}` fill key lands; ships `{id}`-only now.
235	# [impl->REQ-DIST-SHORTCUT-BASENAME]
236	[session.self]
237	command = "claude -n {id} --remote-control {id} --dangerously-skip-permissions"
238	keys = ["id"]
239
240	# ── [session.resume] — native-resume sibling of [session.self] (harness-contract v0.13.0+) ─────
241	# spt-core selects THIS over [session.self] when a bringup carries a prior session (`spt endpoint run
242	# --resume <session>` or the picker's "Resume from history") AND this role is declared. OMITTING it
243	# silently falls back to [session.self] → a fresh session with a BLANK transcript (the documented
244	# footgun). The PTY lands in the resumed session's recorded project cwd; CC resolves the transcript by
245	# {session_id} + cwd.
246	#
247	# Command = Claude Code's NATIVE-RESUME verb: `claude -r {session_id}` (-r/--resume reloads the real
248	# transcript by id). The endpoint {id} is threaded TWO ways: as the DISPLAY name (`-n {id}`, U6 —
249	# shown in the prompt box / `/resume`, matching [session.self] so a fresh and a resumed endpoint
250	# present identically) AND as the REMOTE-CONTROL name (`--remote-control {id}`, a real CC flag):
251	# native-resume drives the resumed PTY over Remote Control, the same channel a live `spt rc`
252	# controller / the [message-idle-translation-binary] use, so the endpoint id IS the RC session name.
253	# (A resumed session has no $SPT_ENDPOINT_ID env-bind path — -n/RC are how {id} attaches here.)
254	# `--dangerously-skip-permissions` for the same non-interactive broker-PTY reason as [session.self]
255	# (REQ-HAZARD-PSYCHE-PERMS-DEADLOCK). keys = the two fills spt-core substitutes: {session_id} (the
256	# resumed id) + {id} (the endpoint). spt tokenizes on whitespace then fills (tokenize-then-fill,
257	# v0.8.2+), so each flag is a clean argv element. Satisfies REQ-DIST-SESSION-RESUME (native-resume
258	# seam) + REQ-DIST-RC-STARTUP (the {id} display+RC parity with [session.self]); declaration-only
259	# (.toml is unscanned, so the formal evidence is the unit assertions in tests/manifest-shortcut.sh).
260	[session.resume]
261	command = "claude -r {session_id} -n {id} --remote-control {id} --dangerously-skip-permissions"
262	keys = ["session_id", "id"]
263
264	# ── [env.<VAR>] ────────────────────────────────────────────────────────────────────────────
265	# Broker-injected env for spt-hosted (bind-path) sessions. The endpoint {id} is injected so CC's
266	# SessionStart hook can self-register the right perch via `api bind $SPT_ENDPOINT_ID`. spt-hosted
267	# sessions inherit env from the spawning broker, so no `channel` is needed (that field is for the
268	# harness-hosted injection path). [impl->REQ-DIST-SHORTCUT-BASENAME]
269	#

# [impl->REQ-DIST-ADAPTER-RELEASE]
[update]
avenue = "gh_release"
repo = "SaberMage/spt-claude-code"
message = """
**claude-spt updated.** One step left — Claude Code can't reload a plugin from inside a running session:

- Run **`/reload-plugins`** now (or restart Claude Code) so the refreshed skills/hooks take effect.

Then you're current. Two ways to go live:

- **`/sptc:live <id>`** — upgrade *this* session to a live agent.
- **`spt endpoint run`** — the `spt` CLI route: spawn/attach endpoints, drive them over Remote Control, and bring up sessions headlessly. The more powerful lever once you're comfortable.
"""

# ── [hooks.<event>] ──────────────────────────────────────────────────────────────────────────
# CC hook event → the opaque `api …` command it fires. These mirror the hand-written plugin
# hooks.json (plugin/sptc/hooks/hooks.json) one-for-one; the plugin shells `spt api`, this table
# is the registry-side declaration (ADR-0002, F-001 resolution). `reads` = the CC hook-stdin
# fields we map; `can_inject` = whether the event can surface additionalContext back to the agent.

[hooks.SessionStart]
# Seed the endpoint — ADAPTER-AGNOSTIC since spt-core v0.9.0 (PREP-4): NO --adapter. The owning
# adapter resolves at bind time from this seed's {parent_pid} via [adapter] host_binaries (the
# legacy-parity bare flow). {parent_pid} is a spt-filled key; session_id is a CC stdin field. NOT the
# blocking poll/listen loop — that is /sptc:ready|live, never SessionStart. (Mirrors the hand-written
# plugin hook session-start.sh, whose bind/boundary branches keep an explicit --adapter override.)
fires = "api seed --session-id {session_id} --pid {parent_pid}"
reads = ["session_id", "parent_pid"]
can_inject = true

[hooks.UserPromptSubmit]
# Drain the inbox (api poll → additionalContext) and run keyword hints (api hint over the prompt).
fires = "api poll {session_id}"
reads = ["session_id", "prompt"]
can_inject = true

[hooks.Stop]
# Idle marker on turn-end. Stop cannot inject → relay/sentinel fallback (echo-gate, F-001 §Gap3).
fires = "api state idle"
reads = ["session_id"]
can_inject = false

[hooks.SessionEnd]
fires = "api session-end {session_id}"
reads = ["session_id"]
can_inject = false

[hooks.SubagentStart]
fires = "api worker-start {session_id}"
reads = ["session_id"]
can_inject = false

[hooks.SubagentStop]
fires = "api worker-stop {session_id}"
reads = ["session_id"]
can_inject = false

# PostToolUse — the CHECKPOINT detector (Feature 2). On a Write to the live agent's own
# `.claude/<id>-commune.md` whose content carries the literal `!!checkpoint!!` trigger, the hook
# marks the perch idle (`api state idle`) then SELF-SENDS a reserved structured signal
# (`spt send --from <id> <id> --json-payload {"checkpoint":"v1","wake":…}`) that loops back through
# the endpoint's own [message-idle-translation-binary], which fires the clear+wake macro — an
# agent-driven context reset (the operator's manual `/clear`, done by the agent). The `fires` line
# below names the representative `api` action (the idle mark); the conditional self-send is the
# wrapper's specialized behavior (plugin/sptc/hooks/post-tool-use.sh). `can_inject=false`: delivery
# is the self-send loopback, not additionalContext. spt-hosted live sessions only. The `!!checkpoint!!`
# file-content marker is the WRITE-side trigger; spt-core v0.15.0 strips it from the durable + pending
# context on ingest, so it is one-shot (never persists in the resumed mind, never re-fires). The
# `--json-payload` self-send is the DELIVERY-side trigger. [impl->REQ-DIST-CHECKPOINT-COMMUNE]
[hooks.PostToolUse]
fires = "api state idle {session_id}"
reads = ["session_id", "tool_name", "tool_input"]
can_inject = false

# ── [identity] ─────────────────────────────────────────────────────────────────────────────
[identity]
# CC hands the session_id on hook stdin once running (discovered post-spawn, not injected).
session_id_source = "post_spawn"
# Process-tree anchor when session_id is absent: the Claude Code binary.
parent_ancestor_name = "claude"

# ── [session] ──────────────────────────────────────────────────────────────────────────────
# Watched-dir seams for the file-drop lifecycle (doyle 2026-06-15: commune/signoff are FILE-DROPS,
# not api verbs — "there is deliberately no api commune"). The agent writes `<endpoint_id>-commune.md`
# / `<endpoint_id>-signoff.md` into this dir; spt-core's daemon watcher ingests it into the endpoint's
# tracked mind, then deletes it (daemon = single writer). The filenames are fixed by the contract;
# only the dir is adapter-declared. `.claude` is CC's per-project state dir — where a live CC session
# already drops these (matches the live-agent reorientation), resolved per-endpoint against its cwd.
# /sptc:commune writes the commune file here; /sptc:signoff's graceful path is `endpoint shutdown`
# (the signoff file-drop is the watcher's equivalent). [impl->REQ-DIST-MANIFEST-SCHEMA]
[session]
commune_dir = ".claude"
signoff_dir = ".claude"

# ── [session.psyche_init] — base is LIVE-CAPABLE (Option A, doyle-ruled + code-confirmed 2026-06-17) ─
# The Psyche-spawn seam lives in the BASE manifest, not a `:live` profile overlay. ready-vs-live is the
# COMMAND, not the profile: `/sptc:live` brings the perch up via `spt api listen` → the live path stamps
# the perch state=live_agent → the daemon livehost hosts the Psyche off that online status (it checks
# psyche_init on the resolved manifest). `/sptc:ready` brings it up as state=ready_agent → livehost
# SKIPS the Psyche regardless of base psyche_init (livehost.rs:282 gate — doyle). So declaring
# psyche_init in base is harmless for ready agents and removes the need for a separate `:live` profile
# (legacy parity: zero --adapter on the default path; the daemon resolves claude-spt by host_binaries).
#
# Psyche keys the daemon fills are EXACTLY {id, session_id, psyche_dir, psyche_prompt} — NOT
# {session_name} (a [session.self] fill) and NOT {psyche_context} (the resume-preload seam, not first
# spawn). `detach=true` + cwd={psyche_dir}: the Psyche is fire-and-forget detached (Stdio::null,
# unsupervised), owns its `<parent>-psyche` perch, authors commune-drops only, exits at session end.
# The runner (`claude-spt psyche`, the psyche subcommand of the consolidated tools/claude-spt crate —
# ADR-0006/U2) is a resident wrapper — `claude -p` is one-shot, so it seeds
# the Psyche `claude` session once from {psyche_prompt} then drives one `claude --continue` turn per
# daemon pulse (NOT a bare `claude -p`), the claude authoring commune drops. spt-core fills each {key}
# as ONE argv element (tokenize-then-fill, v0.8.2+); the runner parses --prompt greedily as defense.
# [impl->REQ-SKILL-LIVE]
[session.psyche_init]
command = "claude-spt psyche --id {id} --session-id {session_id} --prompt {psyche_prompt}"
cwd = "{psyche_dir}"
detach = true
keys = ["id", "session_id", "psyche_dir", "psyche_prompt"]

# ── [session.self] ─────────────────────────────────────────────────────────────────────────
# The spt-hosted bringup template (publish target #2's launcher seam). `spt endpoint run --adapter
# claude-spt --id <id>` spawns this command into a broker-held PTY; CC self-registers its perch
# post-spawn (`api bind <id>`, the spt-hosted path — NOT the seed→listen path /sptc:ready|live uses).
# This is what the `cc-<id>` shortcut (shortcut_basename="cc") bakes into a non-interactive launch.
#
# command is `claude` (+ `--dangerously-skip-permissions`): CC mints its OWN session id internally
# (identity.session_id_source = post_spawn), so spt's {session_id} is not threaded on argv. The
# endpoint {id} is threaded THREE ways now (U6, REQ-DIST-RC-STARTUP): it rides the ENV for the BIND
# (CC has no id flag, so SessionStart reads $SPT_ENDPOINT_ID and binds — see [env.SPT_ENDPOINT_ID]
# below, the sanctioned flagless-harness pattern), AND it sets the DISPLAY name (`-n {id}`, shown in
# the prompt box / `/resume`) AND the REMOTE-CONTROL channel (`--remote-control {id}`) — so a FRESH
# spt-hosted endpoint is `spt rc <id>`-attachable + idle/checkpoint-injectable identically to a
# RESUMED one ([session.resume]). The env-bind and the -n/RC flags are non-conflicting: env names the
# bind id at SessionStart, -n/RC name the display + control channel (distinct CC mechanisms).
# `--dangerously-skip-permissions` is required for the spt-hosted bringup: the broker spawns CC
# non-interactively into a PTY, so the permission-prompt gate would deadlock the launch with no
# operator to approve it. spt tokenizes the command on whitespace then fills {id} as a clean argv
# element each (tokenize-then-fill, v0.8.2+). STRETCH (doyle ask #4, operator-raised): -n/RC upgrade
# to `{id}@{node}` (cross-node-distinguishable) once a `{node}` fill key lands; ships `{id}`-only now.
# [impl->REQ-DIST-SHORTCUT-BASENAME]
[session.self]
command = "claude -n {id} --remote-control {id} --dangerously-skip-permissions"
keys = ["id"]

# ── [session.resume] — native-resume sibling of [session.self] (harness-contract v0.13.0+) ─────
# spt-core selects THIS over [session.self] when a bringup carries a prior session (`spt endpoint run
# --resume <session>` or the picker's "Resume from history") AND this role is declared. OMITTING it
# silently falls back to [session.self] → a fresh session with a BLANK transcript (the documented
# footgun). The PTY lands in the resumed session's recorded project cwd; CC resolves the transcript by
# {session_id} + cwd.
#
# Command = Claude Code's NATIVE-RESUME verb: `claude -r {session_id}` (-r/--resume reloads the real
# transcript by id). The endpoint {id} is threaded TWO ways: as the DISPLAY name (`-n {id}`, U6 —
# shown in the prompt box / `/resume`, matching [session.self] so a fresh and a resumed endpoint
# present identically) AND as the REMOTE-CONTROL name (`--remote-control {id}`, a real CC flag):
# native-resume drives the resumed PTY over Remote Control, the same channel a live `spt rc`
# controller / the [message-idle-translation-binary] use, so the endpoint id IS the RC session name.
# (A resumed session has no $SPT_ENDPOINT_ID env-bind path — -n/RC are how {id} attaches here.)
# `--dangerously-skip-permissions` for the same non-interactive broker-PTY reason as [session.self]
# (REQ-HAZARD-PSYCHE-PERMS-DEADLOCK). keys = the two fills spt-core substitutes: {session_id} (the
# resumed id) + {id} (the endpoint). spt tokenizes on whitespace then fills (tokenize-then-fill,
# v0.8.2+), so each flag is a clean argv element. Satisfies REQ-DIST-SESSION-RESUME (native-resume
# seam) + REQ-DIST-RC-STARTUP (the {id} display+RC parity with [session.self]); declaration-only
# (.toml is unscanned, so the formal evidence is the unit assertions in tests/manifest-shortcut.sh).
[session.resume]
command = "claude -r {session_id} -n {id} --remote-control {id} --dangerously-skip-permissions"
keys = ["session_id", "id"]

# ── [env.<VAR>] ────────────────────────────────────────────────────────────────────────────
# Broker-injected env for spt-hosted (bind-path) sessions. The endpoint {id} is injected so CC's
# SessionStart hook can self-register the right perch via `api bind $SPT_ENDPOINT_ID`. spt-hosted
# sessions inherit env from the spawning broker, so no `channel` is needed (that field is for the
# harness-hosted injection path). [impl->REQ-DIST-SHORTCUT-BASENAME]
#


yes, build it now. also need to confirm that `spt api state` is being called on UserPromptSubmit (busy) and Stop (idle) hooks, as PreToolUse polling won't work properly if the endpoint state isn't set…


UserPromptSubmit's manifest `fires` = `api poll` (drain only) — no `api state busy` visible. But the plugin script is authoritative for what runs. Read the actual hook scripts + _common.sh:


1	#!/bin/sh
2	# UserPromptSubmit: two jobs on the same hook (ADR-0002 — UPS fires on a `/sptc:X` slash-command
3	# with the token intact, validated 2026-06-15). Both write to stdout = CC additionalContext:
4	#   1. SKILL-INJECTION — detect `/sptc:<skill>` in the prompt and inject that skill's operative
5	#      instructions from the adapter `[strings.skills].<skill>` (the thin SKILL.md stays a stub).
6	#   2. MESSAGE-DRAIN — `api poll` the perch and surface delivered <EVENT> messages.
7	# [impl->REQ-UPS-INJECTION]
8	. "$CLAUDE_PLUGIN_ROOT/hooks/_common.sh"
9
10	input=$(cat)
11	sid=$(json_str "$input" session_id)
12
13	# Buffer both outputs so the COMBINED additionalContext is capped once (ADR-0002 Open #2) — a large
14	# skill body + a big drain could jointly exceed CC's spill threshold.
15	out=""
16
17	# 1. Skill-injection runs BEFORE the perch check — skills like /sptc:version and /sptc:setup are
18	#    valid without a readied perch (setup even runs before spt exists). No-op if not a sptc command.
19	prompt=$(json_str "$input" prompt)
20	out="$(sptc_inject_skill "$(sptc_skill_key "$prompt")")"
21
22	# 2. Message-drain needs a perch. No perch (session not readied) -> nothing more to deliver.
23	id=$(sptc_self_id "$sid")
24	if [ -n "$id" ]; then
25	  SPT=$(spt_bin)
26	  frames=$("$SPT" api --adapter "$ADAPTER" poll "$id" --session-id "$sid" 2>/dev/null)
27	  if [ -n "$frames" ]; then
28	    # Format for CC, preserving the sender (reply-correlation). render_frames parses
29	    # the self-delimiting <EVENT> envelope — multi-message drains split on </EVENT>.
30	    rendered=$(render_frames "$frames")
31	    [ -n "$rendered" ] && out="${out:+$out
32	}$rendered"
33	  fi
34	fi
35
36	# Emit under CC's additionalContext cap; oversized drains spill to a file the agent reads (no
37	# silent message loss). Spill path is an absolute, agent-readable location keyed by session.
38	spill="${HOME:-${USERPROFILE:-.}}/.claude/sptc-drain-${sid:-unknown}.txt"
39	sptc_cap_output "$out" "$SPTC_CTX_CAP" "$spill"
40	exit 0
41

#!/bin/sh
# UserPromptSubmit: two jobs on the same hook (ADR-0002 — UPS fires on a `/sptc:X` slash-command
# with the token intact, validated 2026-06-15). Both write to stdout = CC additionalContext:
#   1. SKILL-INJECTION — detect `/sptc:<skill>` in the prompt and inject that skill's operative
#      instructions from the adapter `[strings.skills].<skill>` (the thin SKILL.md stays a stub).
#   2. MESSAGE-DRAIN — `api poll` the perch and surface delivered <EVENT> messages.
# [impl->REQ-UPS-INJECTION]
. "$CLAUDE_PLUGIN_ROOT/hooks/_common.sh"

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

# Buffer both outputs so the COMBINED additionalContext is capped once (ADR-0002 Open #2) — a large
# skill body + a big drain could jointly exceed CC's spill threshold.
out=""

# 1. Skill-injection runs BEFORE the perch check — skills like /sptc:version and /sptc:setup are
#    valid without a readied perch (setup even runs before spt exists). No-op if not a sptc command.
prompt=$(json_str "$input" prompt)
out="$(sptc_inject_skill "$(sptc_skill_key "$prompt")")"

# 2. Message-drain needs a perch. No perch (session not readied) -> nothing more to deliver.
id=$(sptc_self_id "$sid")
if [ -n "$id" ]; then
  SPT=$(spt_bin)
  frames=$("$SPT" api --adapter "$ADAPTER" poll "$id" --session-id "$sid" 2>/dev/null)
  if [ -n "$frames" ]; then
    # Format for CC, preserving the sender (reply-correlation). render_frames parses
    # the self-delimiting <EVENT> envelope — multi-message drains split on </EVENT>.
    rendered=$(render_frames "$frames")
    [ -n "$rendered" ] && out="${out:+$out
}$rendered"
  fi
fi

# Emit under CC's additionalContext cap; oversized drains spill to a file the agent reads (no
# silent message loss). Spill path is an absolute, agent-readable location keyed by session.
spill="${HOME:-${USERPROFILE:-.}}/.claude/sptc-drain-${sid:-unknown}.txt"
sptc_cap_output "$out" "$SPTC_CTX_CAP" "$spill"
exit 0


1	#!/bin/sh
2	# Stop: Claude finished responding -> mark the perch idle (also arms the echo-gate fallback,
3	# L137, since the Stop hook can't inject). [impl->REQ-DIST-HOOKS-API]
4	. "$CLAUDE_PLUGIN_ROOT/hooks/_common.sh"
5
6	input=$(cat)
7	sid=$(json_str "$input" session_id)
8	id=$(sptc_self_id "$sid")
9	[ -z "$id" ] && exit 0
10
11	SPT=$(spt_bin)
12	"$SPT" api --adapter "$ADAPTER" state idle "$id" --session-id "$sid" >/dev/null 2>&1 || true
13	exit 0
14

#!/bin/sh
# Stop: Claude finished responding -> mark the perch idle (also arms the echo-gate fallback,
# L137, since the Stop hook can't inject). [impl->REQ-DIST-HOOKS-API]
. "$CLAUDE_PLUGIN_ROOT/hooks/_common.sh"

input=$(cat)
sid=$(json_str "$input" session_id)
id=$(sptc_self_id "$sid")
[ -z "$id" ] && exit 0

SPT=$(spt_bin)
"$SPT" api --adapter "$ADAPTER" state idle "$id" --session-id "$sid" >/dev/null 2>&1 || true
exit 0


Confirmed the state gap precisely:
- **Stop** → `api state idle` ✅
- **PostToolUse** → `api state idle` (checkpoint path) ✅
- **UserPromptSubmit** → `api poll` only ❌ **never marks busy**

So the endpoint stays idle during a turn → a mid-turn inbound would hit the idle-translation PTY-inject (clobbering the working session) instead of queuing for a PreToolUse drain. Both halves needed. Verify `api state busy` is a real verb:


cd "C:/Users/decid/Documents/projects/spt-claude-code" && command -v spt >/dev/null 2>&1 && { echo "=== api state --help ==="; spt api state --help 2>&1 | head -25; } || echo "no spt on PATH"

Confirm api state busy verb


=== api state --help ===
Set activity state busy|idle (also arms the echo-gate sentinel)

Usage: spt.exe api state [OPTIONS] <STATE> <ID>

Arguments:
  <STATE>  [possible values: busy, idle]
  <ID>

Options:
      --no-gate
      --token <TOKEN>            Capability token proving association to the target perch
      --session-id <SESSION_ID>  Session id proving association (matches the perch's info.json)
  -h, --help                     Print help


also check against https://sabermage.github.io/spt-releases/harness-contract/integration-checklist.html for the intended state change shapes


`busy|idle` confirmed. Now build. Read `_common.sh` for the helpers the PreToolUse drain will reuse:


1	# shellcheck shell=sh
2	# sptc hook common helpers — sourced by each hook wrapper.
3	# DRAFT (pending throwaway-CC validation: Windows-shell, env-file timing, whoami-in-hook).
4	# spt-core is harness-agnostic; this is the CC adapter glue that maps CC hook input -> `spt api`.
5
6	ADAPTER=claude-spt
7
8	# CC spills additionalContext over ~10,000 chars to a file (dropping it from the inline context the
9	# agent sees). We pre-empt below this with margin for the marker + CC's own framing. Override-able
10	# for tests. (ADR-0002 Open #2.)
11	SPTC_CTX_CAP="${SPTC_CTX_CAP:-9000}"
12
13	# Resolve the spt binary: PATH first (post-bootstrap), then known install locations.
14	spt_bin() {
15	  if command -v spt >/dev/null 2>&1; then printf 'spt'; return 0; fi
16	  for p in \
17	    "$HOME/.local/bin/spt" \
18	    "$LOCALAPPDATA/spt-core/bin/spt.exe" \
19	    "$HOME/AppData/Local/spt-core/bin/spt.exe"; do
20	    [ -x "$p" ] && { printf '%s' "$p"; return 0; }
21	  done
22	  printf 'spt'  # last resort; caller tolerates failure
23	}
24
25	# Extract a top-level string field from a flat JSON object on stdin payload ($1=json, $2=key).
26	# CC hook input is a flat object for the fields we need (session_id, source, prompt, agent_id).
27	json_str() {
28	  printf '%s' "$1" | sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -n1
29	}
30
31	# Decide the SessionStart registration verb for this spawn ($1 = CC hook `source`; reads env
32	# $SPT_ENDPOINT_ID). Pure — no spt/CC. [impl->REQ-DIST-SHORTCUT-BASENAME]
33	#   boundary — a /clear or /compact within a live session (rebind the perch to the new session id)
34	#   bind     — spt-hosted: `spt endpoint run` spawned us via [session.self] + injected the endpoint
35	#              id into $SPT_ENDPOINT_ID; the perch already exists, self-register post-spawn
36	#   seed     — harness-hosted: user-launched CC; record an ephemeral seed for /sptc:ready|live
37	sptc_register_verb() {
38	  case "$1" in
39	    clear|compact) printf boundary ;;
40	    *) if [ -n "${SPT_ENDPOINT_ID:-}" ]; then printf bind; else printf seed; fi ;;
41	  esac
42	}
43
44	# Resolve this session's perch id via spt whoami (off $OWL_SESSION_ID / $SPT_AGENT_ID).
45	# Empty => no perch yet (session never readied) => caller no-ops.
46	sptc_self_id() {
47	  _sid="$1"; _spt="$(spt_bin)"
48	  OWL_SESSION_ID="${OWL_SESSION_ID:-$_sid}" "$_spt" whoami 2>/dev/null | head -n1
49	}
50
51	# Decode an spt envelope body to plain text: literal `<br>` -> newline, then HTML entities
52	# (< > " then & LAST, to avoid double-decoding). Exactly the live-agent
53	# body-parsing rule (spt-proto::event, ADR-0001 grammar).
54	sptc_unescape() {
55	  printf '%s' "$1" | sed \
56	    -e 's/
/\
57	/g' \
58	    -e 's/</</g' -e 's/>/>/g' -e 's/"/"/g' -e 's/&/\&/g'
59	}
60
61	# Render an `api poll` drain ($1) for CC. Canonical format: every message is a
62	# self-delimiting `<EVENT type="msg" from="<sender>">body</EVENT>` envelope (spt-proto::event) —
63	# the same grammar the live listener emits. Multi-message drains split cleanly on `</EVENT>`.
64	# Sender is preserved as `from=` (reply-correlation). NOTE: targets canonical <EVENT>; the current
65	# 0.6.0 binary still emits a `__REPLY_TO__` relic at the poll surface until the REQ-MSG-ENVELOPE
66	# refactor lands — finalize/validate against poll only post-refactor. [unit->REQ-UPS-INJECTION]
67	render_frames() {
68	  _in="$1"
69	  [ -z "$_in" ] && return 0
70	  # Normalise to one <EVENT>…</EVENT> per line (body newlines are
-escaped, so each envelope
71	  # is single-line), then parse each.
72	  printf '%s' "$_in" | sed 's#</EVENT>#</EVENT>\
73	#g' | while IFS= read -r _ev; do
74	    case "$_ev" in
75	      *"<EVENT"*"</EVENT>"*) ;;
76	      *) continue ;;
77	    esac
78	    _sender=$(printf '%s' "$_ev" | sed -n 's/.*<EVENT[^>]* from="\([^"]*\)".*/\1/p')
79	    _raw=$(printf '%s' "$_ev" | sed -n 's#.*<EVENT[^>]*>\(.*\)</EVENT>.*#\1#p')
80	    _body=$(sptc_unescape "$_raw")
81	    if [ -n "$_sender" ]; then
82	      printf '<sptc_messages from="%s">\n%s\n</sptc_messages>\n' "$_sender" "$_body"
83	    else
84	      printf '<sptc_messages>\n%s\n</sptc_messages>\n' "$_body"
85	    fi
86	  done
87	}
88
89	# Extract the skill name from a `/sptc:<skill>` slash-command prompt ($1=prompt). UPS fires on a
90	# slash-command with the token intact (ADR-0002 validation 2026-06-15: `prompt:"/sptc:send doyle"`),
91	# so the wrapper detects the command here. Leading-only (after optional whitespace) to avoid firing
92	# on prose that merely mentions `/sptc:x` mid-sentence. Empty => not a sptc slash-command.
93	# [unit->REQ-UPS-INJECTION]
94	sptc_skill_key() {
95	  printf '%s' "$1" | sed -n 's#^[[:space:]]*/sptc:\([a-z][a-z0-9-]*\).*#\1#p' | head -n1
96	}
97
98	# Inject a skill's operative instructions ($1=skill name) as additionalContext. Resolves the
99	# file-backed/inline body from the adapter `[strings.skills].<skill>` via `spt adapter get-string`
100	# (F-003: pointer values resolve to file contents at read time). No-op (silent) if spt is absent,
101	# the adapter is unregistered, or the key is unset — the thin SKILL.md skeleton still loaded by CC
102	# is the floor. The injected body is what the agent follows for this `/sptc:<skill>` invocation.
103	# [impl->REQ-UPS-INJECTION]
104	sptc_inject_skill() {
105	  _skill="$1"; [ -z "$_skill" ] && return 0
106	  _spt="$(spt_bin)"
107	  _body=$("$_spt" adapter get-string "$ADAPTER" "skills.$_skill" 2>/dev/null) || return 0
108	  [ -z "$_body" ] && return 0
109	  printf '<sptc_skill name="%s">\n%s\n</sptc_skill>\n' "$_skill" "$_body"
110	}
111
112	# Emit additionalContext under CC's ~10k spill threshold ($1=text, $2=cap, $3=spill_path). Under the
113	# cap -> emit as-is. Over -> spill the FULL text to a file the agent can Read and emit ONLY a concise
114	# pointer marker (never a partial inline — a head-cut would split a <sptc_messages>/<EVENT> block and
115	# lose a message silently). The agent reads the spill file to see everything; nothing is dropped. CC
116	# would otherwise spill oversized additionalContext itself, evicting it from the inline context.
117	# [impl->REQ-UPS-INJECTION]
118	sptc_cap_output() {
119	  _text="$1"; _cap="$2"; _spill="$3"
120	  [ -z "$_text" ] && return 0
121	  _len=$(printf '%s' "$_text" | wc -c | tr -d ' ')
122	  if [ "$_len" -le "$_cap" ]; then
123	    printf '%s' "$_text"
124	    return 0
125	  fi
126	  printf '%s' "$_text" > "$_spill" 2>/dev/null || _spill="(spill failed; content too large to inline)"
127	  printf '<sptc_overflow bytes="%s" cap="%s" spilled_to="%s">\nDelivery exceeded CC'\''s additionalContext cap. The full content (skill instructions and/or %s message bytes) was written to the file above — read it now to see everything; it is NOT inlined here to avoid silently dropping a message.\n</sptc_overflow>\n' \
128	    "$_len" "$_cap" "$_spill" "$_len"
129	}
130
131	# ── Checkpoint detection (REQ-DIST-CHECKPOINT-COMMUNE) ──────────────────────────────────────────
132	# A live agent requests a CHECKPOINT (self-initiated context reset) by embedding the literal
133	# `!!checkpoint!!` trigger in its commune. The PostToolUse hook detects it at Write-time and self-sends
134	# a structured signal that loops back through the translation binary's clear+wake macro. spt-core
135	# v0.15.0 strips the marker from the durable/pending context on ingest, so it is one-shot. The pure
136	# predicates below run on a single physical line (the raw hook JSON, whose content newlines are `\n`
137	# escapes) so the paired-marker scan works even for a multi-line commune body.
138
139	# PURE predicate: does this commune content ($1) carry the checkpoint trigger? exit 0 iff present.
140	# [unit->REQ-DIST-CHECKPOINT-COMMUNE]
141	sptc_has_checkpoint() {
142	  case "$1" in *'!!checkpoint!!'*) return 0 ;; *) return 1 ;; esac
143	}
144
145	# PURE: extract a CUSTOM wake directive from commune content ($1) — the text between the FIRST pair of
146	# `!!checkpoint!!` markers, trimmed. Prints nothing when there is only one marker (single = default
147	# wake; the translation binary supplies the default) or none. The pair must be inline (same physical
148	# line); a lone or unmatched marker yields the default. [unit->REQ-DIST-CHECKPOINT-COMMUNE]
149	sptc_checkpoint_wake() {
150	  _n=$(printf '%s' "$1" | grep -o '!!checkpoint!!' | wc -l | tr -d ' ')
151	  [ "${_n:-0}" -ge 2 ] || return 0
152	  printf '%s' "$1" | sed -n 's/.*!!checkpoint!!\(.*\)!!checkpoint!!.*/\1/p' | head -n1 \
153	    | sed 's/^[[:space:]]*//; s/[[:space:]]*$//'
154	}
155
156	# PURE predicate: is this tool call ($1=tool_name, $2=file_path, $3=endpoint id) a Write to THIS
157	# agent's own commune file `<id>-commune.md`? Suffix match tolerates the JSON-escaped Windows path
158	# (doubled backslashes) and either separator. exit 0 iff it is. [unit->REQ-DIST-CHECKPOINT-COMMUNE]
159	sptc_is_commune_write() {
160	  [ "$1" = "Write" ] || return 1
161	  [ -n "$3" ] || return 1
162	  case "$2" in *"$3-commune.md") return 0 ;; *) return 1 ;; esac
163	}
164
165	# ── SessionStart agent-facing briefs (REQ-DIST-SESSIONSTART-BRIEF) ──────────────────────────────
166	# The hook COMPOSES adapter-string briefs into a SessionStart additionalContext block; it never
167	# authors agent-facing prose (single-source throughline — bodies live in [strings.briefs]). Pure
168	# assemblers below carry the only composition logic ({id} substitution, concat, wrapping) so they
169	# unit-test without spt; the impure resolvers wrap them with `spt adapter get-string`.
170
171	# Resolve a brief body by key from the adapter `[strings.briefs].<key>` (file-backed or inline).
172	# Empty on absent key / unregistered adapter / missing spt — caller tolerates. [impl->REQ-DIST-SESSIONSTART-BRIEF]
173	sptc_brief() {
174	  _key="$1"; [ -z "$_key" ] && return 0
175	  _spt="$(spt_bin)"
176	  "$_spt" adapter get-string "$ADAPTER" "briefs.$_key" 2>/dev/null
177	}
178
179	# PURE: assemble the identity (perched) block. $1=id $2=identity-body $3=messaging-body $4=roster.
180	# Substitutes {id} in the identity body (agent ids are [a-z0-9-] — sed-safe, no metachar risk).
181	# [impl->REQ-DIST-SESSIONSTART-BRIEF]
182	sptc_assemble_perch() {
183	  _id="$1"
184	  printf '<sptc-active-perch id="%s">\n%s\n\n%s\n\n%s\n</sptc-active-perch>' \
185	    "$_id" "$(printf '%s' "$2" | sed "s/{id}/$_id/g")" "$3" "$4"
186	}
187
188	# PURE: assemble the ring (no-perch) block. $1=messaging-body $2=roster. [impl->REQ-DIST-SESSIONSTART-BRIEF]
189	sptc_assemble_noperch() {
190	  printf '<sptc-reach>\n%s\n\n%s\n</sptc-reach>' "$1" "$2"
191	}
192
193	# Impure: resolve + assemble the perched-session identity brief for id $1. [impl->REQ-DIST-SESSIONSTART-BRIEF]
194	sptc_perch_brief() {
195	  sptc_assemble_perch "$1" "$(sptc_brief identity)" "$(sptc_brief messaging-perch)" "$(sptc_brief endpoint-list)"
196	}
197
198	# Impure: resolve + assemble the no-perch ring brief. [impl->REQ-DIST-SESSIONSTART-BRIEF]
199	sptc_noperch_brief() {
200	  sptc_assemble_noperch "$(sptc_brief messaging-no-perch)" "$(sptc_brief endpoint-list)"
201	}
202
203	# Impure: pull the live-agent durable RESUME context for endpoint id $1 (optional session id $2) via
204	# `spt api psyche-download` — the spt-core v0.15.0 W5 verb that closes F-020 (claude-spt rehydrates NO
205	# durable context today). stdout = durable role/live/project tiers + the freshest not-yet-synthesized
206	# <pending-commune>/<pending-signoff> (trigger stripped core-side), to inject VERBATIM. Mirrors the
207	# id-scoped `api poll` auth shape (--session-id proves association; project resolves from the bound
208	# cwd, no --project). Empty on NO-CONTEXT (stderr) / the verb absent (pre-v0.15.0 node) / unregistered
209	# — caller appends nothing. Also the checkpoint re-seed fast-path. [impl->REQ-DIST-RESUME-CONTEXT]
210	sptc_psyche_download() {
211	  _id="$1"; [ -z "$_id" ] && return 0
212	  _psid="$2"; _spt="$(spt_bin)"
213	  if [ -n "$_psid" ]; then
214	    "$_spt" api --adapter "$ADAPTER" psyche-download "$_id" --session-id "$_psid" 2>/dev/null
215	  else
216	    "$_spt" api --adapter "$ADAPTER" psyche-download "$_id" 2>/dev/null
217	  fi
218	}
219
220	# PURE: append resume context ($2) below a SessionStart brief ($1), skipping cleanly when the resume
221	# is empty (NO-CONTEXT). Newline-joined so the verbatim XML resume sits below the identity brief.
222	# [impl->REQ-DIST-RESUME-CONTEXT]
223	sptc_append_resume() {
224	  if [ -n "$2" ]; then printf '%s\n%s' "$1" "$2"; else printf '%s' "$1"; fi
225	}
226
227	# PURE predicate: should this SessionStart inject a brief? Subagent sessions (agent_type set) get
228	# none. $1 = the stdin agent_type value (empty = a real user session). [impl->REQ-DIST-SESSIONSTART-BRIEF]
229	sptc_is_subagent() { [ -n "$1" ]; }
230
231	# PURE predicate: peer-presence gate for the ring brief. Reads `spt subnet status` output on stdin;
232	# exit 0 (peers) iff >1 non-empty line (header + >=1 subnet row). Line-count only — NEVER parses a
233	# column value (the columnar layout is human-formatted, not a hook contract). [impl->REQ-DIST-SESSIONSTART-BRIEF]
234	sptc_has_peers_lines() {
235	  _n=$(grep -c . 2>/dev/null)
236	  [ "${_n:-0}" -gt 1 ]
237	}
238
239	# Impure: does this node participate in any subnet (has reachable peers)? [impl->REQ-DIST-SESSIONSTART-BRIEF]
240	sptc_node_has_peers() {
241	  _spt="$(spt_bin)"
242	  "$_spt" subnet status 2>/dev/null | sptc_has_peers_lines
243	}
244
245	# PURE: JSON-escape stdin into a string value (no surrounding quotes). Escapes \ then " , tabs, CR;
246	# newlines become literal \n. awk so multi-line bodies survive. [impl->REQ-DIST-SESSIONSTART-BRIEF]
247	sptc_json_escape() {
248	  awk '
249	    BEGIN { ORS="" }
250	    {
251	      gsub(/\\/, "\\\\"); gsub(/"/, "\\\""); gsub(/\t/, "\\t"); gsub(/\r/, "\\r");
252	      if (NR > 1) printf "%s", "\\n";
253	      printf "%s", $0;
254	    }
255	  '
256	}
257
258	# Emit a SessionStart hookSpecificOutput JSON line carrying $1 as additionalContext. No-op on empty
259	# ($1 unset/blank → nothing to stdout, hook stays silent). [impl->REQ-DIST-SESSIONSTART-BRIEF]
260	sptc_emit_additional_context() {
261	  [ -z "$1" ] && return 0
262	  _esc=$(printf '%s' "$1" | sptc_json_escape)
263	  printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"%s"}}\n' "$_esc"
264	}
265

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

ADAPTER=claude-spt

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

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

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

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

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

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

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

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

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

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

# ── Checkpoint detection (REQ-DIST-CHECKPOINT-COMMUNE) ──────────────────────────────────────────
# A live agent requests a CHECKPOINT (self-initiated context reset) by embedding the literal
# `!!checkpoint!!` trigger in its commune. The PostToolUse hook detects it at Write-time and self-sends
# a structured signal that loops back through the translation binary's clear+wake macro. spt-core
# v0.15.0 strips the marker from the durable/pending context on ingest, so it is one-shot. The pure
# predicates below run on a single physical line (the raw hook JSON, whose content newlines are `\n`
# escapes) so the paired-marker scan works even for a multi-line commune body.

# PURE predicate: does this commune content ($1) carry the checkpoint trigger? exit 0 iff present.
# [unit->REQ-DIST-CHECKPOINT-COMMUNE]
sptc_has_checkpoint() {
  case "$1" in *'!!checkpoint!!'*) return 0 ;; *) return 1 ;; esac
}

# PURE: extract a CUSTOM wake directive from commune content ($1) — the text between the FIRST pair of
# `!!checkpoint!!` markers, trimmed. Prints nothing when there is only one marker (single = default
# wake; the translation binary supplies the default) or none. The pair must be inline (same physical
# line); a lone or unmatched marker yields the default. [unit->REQ-DIST-CHECKPOINT-COMMUNE]
sptc_checkpoint_wake() {
  _n=$(printf '%s' "$1" | grep -o '!!checkpoint!!' | wc -l | tr -d ' ')
  [ "${_n:-0}" -ge 2 ] || return 0
  printf '%s' "$1" | sed -n 's/.*!!checkpoint!!\(.*\)!!checkpoint!!.*/\1/p' | head -n1 \
    | sed 's/^[[:space:]]*//; s/[[:space:]]*$//'
}

# PURE predicate: is this tool call ($1=tool_name, $2=file_path, $3=endpoint id) a Write to THIS
# agent's own commune file `<id>-commune.md`? Suffix match tolerates the JSON-escaped Windows path
# (doubled backslashes) and either separator. exit 0 iff it is. [unit->REQ-DIST-CHECKPOINT-COMMUNE]
sptc_is_commune_write() {
  [ "$1" = "Write" ] || return 1
  [ -n "$3" ] || return 1
  case "$2" in *"$3-commune.md") return 0 ;; *) return 1 ;; esac
}

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

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

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

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

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

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

# Impure: pull the live-agent durable RESUME context for endpoint id $1 (optional session id $2) via
# `spt api psyche-download` — the spt-core v0.15.0 W5 verb that closes F-020 (claude-spt rehydrates NO
# durable context today). stdout = durable role/live/project tiers + the freshest not-yet-synthesized
# <pending-commune>/<pending-signoff> (trigger stripped core-side), to inject VERBATIM. Mirrors the
# id-scoped `api poll` auth shape (--session-id proves association; project resolves from the bound
# cwd, no --project). Empty on NO-CONTEXT (stderr) / the verb absent (pre-v0.15.0 node) / unregistered
# — caller appends nothing. Also the checkpoint re-seed fast-path. [impl->REQ-DIST-RESUME-CONTEXT]
sptc_psyche_download() {
  _id="$1"; [ -z "$_id" ] && return 0
  _psid="$2"; _spt="$(spt_bin)"
  if [ -n "$_psid" ]; then
    "$_spt" api --adapter "$ADAPTER" psyche-download "$_id" --session-id "$_psid" 2>/dev/null
  else
    "$_spt" api --adapter "$ADAPTER" psyche-download "$_id" 2>/dev/null
  fi
}

# PURE: append resume context ($2) below a SessionStart brief ($1), skipping cleanly when the resume
# is empty (NO-CONTEXT). Newline-joined so the verbatim XML resume sits below the identity brief.
# [impl->REQ-DIST-RESUME-CONTEXT]
sptc_append_resume() {
  if [ -n "$2" ]; then printf '%s\n%s' "$1" "$2"; else printf '%s' "$1"; fi
}

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

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

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

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

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


Prompt: also check against https://sabermage.github.io/spt-releases/harness-contract/integration-checklist.html for the intended state change shapes


User wants me to check the published integration-checklist for the intended state-change shapes. That's the authority — fetching it before finalizing the build. Loading WebFetch:


ToolSearch

Question: select:WebFetch


Question: select:WebFetch


WebFetch

Prompt: Extract everything about activity STATE changes and message POLLING across harness hook events. Specifically: which hook event should set the endpoint state to BUSY, which should set it to IDLE, and which should POLL for messages? What exact `spt api` commands/shapes are specified (e.g. `api state busy`, `api state idle`, `api poll`)? Is there a PreToolUse hook in the checklist, and what should it do? Quote the relevant lines/table rows verbatim, including any notes about turn-start vs turn-end and mid-turn delivery.


# Activity State and Message Polling in SPT Harness Integration

## State Management

**IDLE state** should fire when the user is inactive:
> "`[hooks.Idle] → api state idle` (and `api state busy`)" drives "Honest activity — spt-core never infers idleness from terminal quiescence (it lies). Arms the echo gate, drives Psyche pulses + most-recently-active routing"

The checklist specifies: "`api state idle` fires on real inactivity; `can_inject` values are honest"

There is no corresponding PreToolUse hook mentioned in this document. The state transitions focus on user-driven idle/busy signals rather than tool-use lifecycle events.

## Message Polling

Inbound messages arrive through two mechanisms:

> "`[inject]` channels (`activity` / `idle`) + `api poll <id> --include-deferred`" enable "Inbound message delivery. Declares HOW spt-core reaches the agent (hook inject vs. pull-relay); `poll` is the pull path for hooks that can't inject"

The checklist requirement states: "An inbound delivery channel is declared (`[inject]`) or pulled (`api poll`)"

The concrete example shows Claude Code uses: "messages arrive over the hook inject channel (`can_inject = true`), pull-relay fallback when busy," indicating polling serves as a fallback for scenarios where direct injection isn't feasible.