proceed with next steps. if you have any questions or roadblocks, clear them with doyle. when you finish the current task, notify doyle and proceed to the next.


No local example. Need published mock-adapter manifest / harness-contract for the `self` role's guaranteed keys. Fetching public surface.


ToolSearch

Question: select:WebFetch,WebSearch


Question: select:WebFetch,WebSearch


Prompt: proceed with next steps. if you have any questions or roadblocks, clear them with doyle. when you finish the current task, notify doyle and proceed to the next.


Authorized to proceed + use doyle for roadblocks. Continuing slice. Fetching the published mock-adapter manifest for the `[session.self]` shape + guaranteed keys.


WebFetch

Prompt: Find everything about the [session.self] command template in an adapter manifest, and the spt endpoint run command. Specifically: what {key} substitution placeholders does spt-core guarantee to fill for the [session.self] role (e.g. endpoint id, session id)? How does the endpoint id get passed to the spawned harness on argv? Quote any example [session] or [session.self] TOML blocks verbatim. Also list the keys for psyche_init/psyche_resume/echo_commune/signoff/notif roles if shown.


# Session Template Substitution Keys and Endpoint ID Passing

## `[session.self]` Substitution Keys

For the `[session.self]` role, spt-core fills these placeholders:

- `{id}` — The endpoint id being hosted
- `{session_id}` — The harness session id (minted at spawn)
- `{session_name}` — The session's display name
- `{adapter_name}` — The adapter's declared name
- `{agent_type}` — The hosted agent type
- `{agents_json}` — The resolved agents roster

## Example `[session.self]` Block

```toml
[session.self]
command = "mock-session --id {id} --session-id {session_id}"
detach = true
keys = ["id", "session_id"]
```

## How Endpoint ID Reaches the Spawned Process

The endpoint identifier is provided as a command-line argument via the `{id}` placeholder in the template's `command` field. The manifest author determines which argv position receives it—spt-core simply performs string substitution before execution.

## Other Role Substitution Keys

**psyche_init / psyche_resume:**
- `{session_name}`, `{psyche_dir}`, `{psyche_prompt}`, `{psyche_context}`

**echo_commune / signoff:**
- Keys depend on manifest declarations; no fixed set documented

**notif:**
- `{notif_id}`, `{notif_from}`, `{notif_subnet}`, `{notif_body}`


Keys: `{id}` (endpoint id), `{session_id}`, `{session_name}`, `{adapter_name}`, `{agent_type}`, `{agents_json}`. Now the crux: CC mints its own session_id internally + has no `--endpoint-id` flag. Need to know how the perch binds to spt's endpoint `{id}`. Checking the local `api seed` + bind contract.


Bash

spt api seed --help 2>&1 | head -40; echo "=== api ==="; spt api --help 2>&1 | head -40

api seed + api surface help


Harness-hosted startup: record an ephemeral seed keyed by parent pid

Usage: spt.exe api --adapter <ADAPTER> seed --pid <PID> --session-id <SESSION_ID>

Options:
      --pid <PID>
      --session-id <SESSION_ID>
  -h, --help                     Print help
=== api ===
Harness-contract inbound surface (hook entry points).

The entry points a harness's hooks fire to keep spt-core's on-disk state in sync.

Usage: spt.exe api [OPTIONS] --adapter <ADAPTER> <COMMAND>

Commands:
  seed            Harness-hosted startup: record an ephemeral seed keyed by parent pid
  listen          Consume a seed and hold the perch + relay loop (blocks)
  bind            Post-spawn bind of a session to its perch
  bind-shell      Shell-binary bind: the type=Shell flavor of `bind`. Resolves the instance **by
                  link token alone** (the spawn template carries only `{link_token}` — "owner from
                  the link") and flips it online. The credential IS the auth: no token, no bind
  state           Set activity state busy|idle (also arms the echo-gate sentinel)
  echo-gate       Manage the echo-gate sentinel directly
  poll            Drain delivered messages (hook channel). With `--link` this is the shell-flavored
                  relay drain: the link token is the auth, and the drained rows are the shell's
                  MAC-stamped command/text/file frames
  worker-start    Create a nested worker perch under a parent
  worker-stop     Tear down a worker perch
  worker-poll     Drain a worker perch's messages
  boundary        Rebind the perch to a new session_id, preserving identity (a context clear/compact
                  boundary)
  session-end     Soft teardown (spool/history preserved); `--erase` hard-wipes
  presence        Report user/agent presence at this endpoint
  driven-by       Report which node (if any) is remote-driving this endpoint
  history-log     Append normalized history (body on stdin) to the native history store
  digest-entry    Push one digest-record (the published contract JSON line, on stdin) for a log-less
                  adapter — appended to the perch's digest store, tailed by the session-digest
                  projection (ADR-0008 amendment, REQ-TERM-4)
  emit            Emit a Shell sensory payload to the owner's **live** session. REST-only by
                  definition: never spooled, dropped with a diagnostic when the owner isn't live.
                  The link token is the auth
  capability      Print the adapter's declared capability (`hostable_types`)
  hint            Keyword hints: the full user message arrives on **stdin**; emit at most one
                  matched hint line (declaration order, first unseen wins) for the adapter's context
                  channel. The per-session seen-set fires each hint once per `--session` (a `/clear`
                  = a new session = re-armed). Needs `--manifest`
  shutdown        Graceful live-agent signoff: run the final context save BEFORE teardown, then
                  soft-stop. The `spt shutdown` lifecycle path


`api bind` exists — "Post-spawn bind of a session to its perch". This is the self-registration. Checking bind + listen.


echo "=== bind ==="; spt api bind --help 2>&1; echo "=== listen ==="; spt api listen --help 2>&1 | head -30

api bind + listen help


=== bind ===
Post-spawn bind of a session to its perch

Usage: spt.exe api --adapter <ADAPTER> bind [OPTIONS] <ID>

Arguments:
  <ID>

Options:
      --set-session-id <BIND_SESSION>  The session id discovered post-spawn, written into the perch
                                       record
      --subnet <SUBNET>                Home subnet for a NEW endpoint (see `listen`)
      --type <ENDPOINT_TYPE>           The endpoint type tag (info.json `state`). Defaults to
                                       `live_agent` (the agent host); a non-agent endpoint — e.g. a
                                       `gateway` — binds with its own open-type tag
                                       (REQ-EP-1/REQ-EP-6). A revive keeps the prior type unless
                                       this overrides it [default: live_agent]
      --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
=== listen ===
Consume a seed and hold the perch + relay loop (blocks)

Usage: spt.exe api --adapter <ADAPTER> listen [OPTIONS] <ID>

Arguments:
  <ID>

Options:
      --parent-pid <PARENT_PID>  Override the parent-pid anchor (defaults to the self-discovered
                                 PPID)
      --once                     Drain backlog + one receive cycle, then exit (testability)
      --subnet <SUBNET>          Home subnet for a NEW endpoint (required on a multi-subnet node —
                                 home is assigned at creation, never guessed)
  -h, --help                     Print help


Binding flow clear: `endpoint run --id <ID>` spawns `[session.self]` into broker PTY → harness self-registers post-spawn via `api bind <ID> --set-session-id <cc_session_id>`. Two open design points before authoring — checking the published harness-contract on the endpoint-run self-register sequence.


WebFetch

Prompt: Describe the full lifecycle of `spt endpoint run` when it brings up a harness via [session.self]. After spt-core spawns the [session.self] command into the broker-held PTY, what exactly must the spawned harness do to "self-register its perch on bind"? Does it call `api bind <id>` from a hook? How does the harness learn its endpoint id if the harness binary (like Claude Code) has no native flag to receive it — is threading it via an environment variable the sanctioned pattern, or must it be parsed from argv? Also: how does this bind path differ from the seed+listen path (api seed then api listen)? When should an adapter use bind vs seed? Quote any relevant text or examples verbatim.


# The spt-hosted startup lifecycle with `[session.self]`

## The flow after spawn

When spt-core spawns the `[session.self]` template into a broker-held PTY, the harness must discover its endpoint id and call `api bind` to "self-register its perch":

> "spt-hosted startup: bind a freshly spawned session to its perch, recording the session id discovered post-spawn."

The harness learns its endpoint id via **environment variable injection** through the `[env.*]` manifest section:

> "Vars to inject into (or read from) sessions, and how... **spt-hosted** sessions inherit env from the broker that spawned them"

A typical pattern uses `[env.OWL_SESSION_ID]` with `direction = "inject"` and `value = "{id}"`, allowing the harness to read `$OWL_SESSION_ID` at startup.

## Bind vs. Seed+Listen

These are **two distinct startup paths**, not variants:

**Harness-hosted (seed→listen):**
> "the harness owns the process; spt-core is invoked from inside it (hooks)... `seed` records an ephemeral hand-off keyed by parent pid; `listen` consumes it, registers the perch, drains backlog, and blocks relaying events"

**spt-hosted (bind):**
> "spt-core spawns the session itself from the manifest's `[session.self]` template... No seed file is involved; `bind` attaches the live session to its perch post-spawn."

**When to use each:** Use seed+listen when your harness owns process spawning. Use bind when spt-core's broker spawns the session and the harness merely discovers its assigned endpoint id from the environment, then calls `api bind <id> --set-session-id <discovered-id>`.


`[env.*]` is the sanctioned thread: `[env.<VAR>]` with `direction="inject"`, `value="{id}"`. spt-hosted (bind) is a distinct path from harness-hosted (seed→listen). Checking the `[env]` schema block.


Bash

python -c "
import json
s=json.load(open('adapter/manifest.schema.json'))
props=s.get('properties',{})
print('env prop:', json.dumps(props.get('env','MISSING'),indent=1))
defs=s.get('\$defs',s.get('definitions',{}))
for k in defs:
    if 'env' in k.lower(): print('DEF',k,':',json.dumps(defs[k],indent=1)[:800])
"

Env schema block


env prop: {
 "description": "`[env.<VAR>]` \u00e2\u20ac\u201d env-var inject/read table.",
 "type": "object",
 "additionalProperties": {
  "$ref": "#/$defs/EnvVar"
 }
}
DEF EnvVar : {
 "description": "`[env.<VAR>]` \u00e2\u20ac\u201d a single env-var directive.",
 "type": "object",
 "properties": {
  "direction": {
   "$ref": "#/$defs/EnvDirection"
  },
  "value": {
   "description": "Value to inject (with substitution); required for `inject`.",
   "type": [
    "string",
    "null"
   ]
  },
  "channel": {
   "description": "Harness-hosted injection channel (spt-hosted inherits from the broker).",
   "type": [
    "string",
    "null"
   ]
  }
 },
 "required": [
  "direction"
 ]
}
DEF EnvDirection : {
 "type": "string",
 "enum": [
  "inject",
  "read"
 ]
}


Full contract now clear. Sanctioned bind path:
- `[session.self].command` spawns `claude`; broker injects endpoint id via `[env.SPT_ENDPOINT_ID]={id}`.
- harness self-registers post-spawn: `api bind <id> --set-session-id <cc_session_id>` (distinct from the seed→listen path).

One genuine contract question (SessionStart must `bind` not `seed` when spt-hosted — how the registry `fires` field represents that branch). Reading the existing plugin SessionStart hook before I modify, then I'll draft + surface the question to doyle.


find plugin -type f 2>/dev/null | head -40; echo "=== hooks.json ==="; cat plugin/sptc/hooks/hooks.json 2>/dev/null; echo "=== hook scripts ==="; ls plugin/sptc/hooks/ 2>/dev/null

Inspect plugin hook layout


plugin/sptc/.claude/LIVE_AGENT_IDS.json
plugin/sptc/.claude-plugin/plugin.json
plugin/sptc/bootstrap.ps1
plugin/sptc/bootstrap.sh
plugin/sptc/hooks/hooks.json
plugin/sptc/hooks/session-end.sh
plugin/sptc/hooks/session-start.sh
plugin/sptc/hooks/stop.sh
plugin/sptc/hooks/subagent-start.sh
plugin/sptc/hooks/subagent-stop.sh
plugin/sptc/hooks/user-prompt-submit.sh
plugin/sptc/hooks/_common.sh
plugin/sptc/skills/commune/SKILL.md
plugin/sptc/skills/force-stop/SKILL.md
plugin/sptc/skills/list-agents/SKILL.md
plugin/sptc/skills/live/SKILL.md
plugin/sptc/skills/new-alarm/SKILL.md
plugin/sptc/skills/ready/SKILL.md
plugin/sptc/skills/send/SKILL.md
plugin/sptc/skills/setup/SKILL.md
plugin/sptc/skills/signoff/SKILL.md
plugin/sptc/skills/version/SKILL.md
plugin/sptc/skills/whoami/SKILL.md
=== hooks.json ===
{
  "hooks": {
    "SessionStart": [
      { "hooks": [ { "type": "command", "command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/session-start.sh\"", "shell": "bash" } ] }
    ],
    "UserPromptSubmit": [
      { "hooks": [ { "type": "command", "command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/user-prompt-submit.sh\"", "shell": "bash" } ] }
    ],
    "Stop": [
      { "hooks": [ { "type": "command", "command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/stop.sh\"", "shell": "bash" } ] }
    ],
    "SessionEnd": [
      { "hooks": [ { "type": "command", "command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/session-end.sh\"", "shell": "bash" } ] }
    ],
    "SubagentStart": [
      { "hooks": [ { "type": "command", "command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/subagent-start.sh\"", "shell": "bash" } ] }
    ],
    "SubagentStop": [
      { "hooks": [ { "type": "command", "command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/subagent-stop.sh\"", "shell": "bash" } ] }
    ]
  }
}
=== hook scripts ===
_common.sh
hooks.json
session-end.sh
session-start.sh
stop.sh
subagent-start.sh
subagent-stop.sh
user-prompt-submit.sh


1	#!/bin/sh
2	# SessionStart: ensure spt-core installed, seed the perch (or rebind on clear/compact),
3	# and persist session env so whoami + per-prompt hooks resolve. Non-blocking (never `listen`).
4	# [impl->REQ-DIST-HOOKS-API]
5	. "$CLAUDE_PLUGIN_ROOT/hooks/_common.sh"
6
7	input=$(cat)
8	sid=$(json_str "$input" session_id)
9	src=$(json_str "$input" source)
10
11	# Invisible-installer: bootstrap spt-core if absent (no-op when present).
12	sh "$CLAUDE_PLUGIN_ROOT/bootstrap.sh" >/dev/null 2>&1 || true
13	SPT=$(spt_bin)
14
15	case "$src" in
16	  clear|compact)
17	    id=$(sptc_self_id "$sid")
18	    [ -n "$id" ] && "$SPT" api --adapter "$ADAPTER" boundary "$src" "$id" \
19	      --to-session-id "$sid" >/dev/null 2>&1 || true
20	    ;;
21	  *)
22	    "$SPT" api --adapter "$ADAPTER" seed --pid "$PPID" --session-id "$sid" >/dev/null 2>&1 || true
23	    ;;
24	esac
25
26	# Persist for the rest of the session (whoami + per-prompt hooks read these).
27	if [ -n "$CLAUDE_ENV_FILE" ]; then
28	  {
29	    printf 'OWL_SESSION_ID=%s\n' "$sid"
30	    printf 'SPT_ADAPTER=%s\n' "$ADAPTER"
31	  } >> "$CLAUDE_ENV_FILE"
32	fi
33	exit 0
34

#!/bin/sh
# SessionStart: ensure spt-core installed, seed the perch (or rebind on clear/compact),
# and persist session env so whoami + per-prompt hooks resolve. Non-blocking (never `listen`).
# [impl->REQ-DIST-HOOKS-API]
. "$CLAUDE_PLUGIN_ROOT/hooks/_common.sh"

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

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

case "$src" in
  clear|compact)
    id=$(sptc_self_id "$sid")
    [ -n "$id" ] && "$SPT" api --adapter "$ADAPTER" boundary "$src" "$id" \
      --to-session-id "$sid" >/dev/null 2>&1 || true
    ;;
  *)
    "$SPT" api --adapter "$ADAPTER" seed --pid "$PPID" --session-id "$sid" >/dev/null 2>&1 || true
    ;;
esac

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


SessionStart already branches (seed vs boundary) with `fires` showing only the primary — so the bind branch follows existing precedent, not a blocker. One genuine contract ambiguity remains: **spt-hosted `api bind` association proof** — does it need a `--token` (broker-injected via env) or is PTY/PPID ownership the proof? That determines whether I author a second `[env]` token entry. I'll send doyle that question async and keep building the unambiguous parts. Reading `_common.sh` for helpers.


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	# Resolve the spt binary: PATH first (post-bootstrap), then known install locations.
9	spt_bin() {
10	  if command -v spt >/dev/null 2>&1; then printf 'spt'; return 0; fi
11	  for p in \
12	    "$HOME/.local/bin/spt" \
13	    "$LOCALAPPDATA/spt-core/bin/spt.exe" \
14	    "$HOME/AppData/Local/spt-core/bin/spt.exe"; do
15	    [ -x "$p" ] && { printf '%s' "$p"; return 0; }
16	  done
17	  printf 'spt'  # last resort; caller tolerates failure
18	}
19
20	# Extract a top-level string field from a flat JSON object on stdin payload ($1=json, $2=key).
21	# CC hook input is a flat object for the fields we need (session_id, source, prompt, agent_id).
22	json_str() {
23	  printf '%s' "$1" | sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -n1
24	}
25
26	# Resolve this session's perch id via spt whoami (off $OWL_SESSION_ID / $SPT_AGENT_ID).
27	# Empty => no perch yet (session never readied) => caller no-ops.
28	sptc_self_id() {
29	  _sid="$1"; _spt="$(spt_bin)"
30	  OWL_SESSION_ID="${OWL_SESSION_ID:-$_sid}" "$_spt" whoami 2>/dev/null | head -n1
31	}
32
33	# Decode an spt envelope body to plain text: literal `<br>` -> newline, then HTML entities
34	# (< > " then & LAST, to avoid double-decoding). Exactly the live-agent
35	# body-parsing rule (spt-proto::event, ADR-0001 grammar).
36	sptc_unescape() {
37	  printf '%s' "$1" | sed \
38	    -e 's/
/\
39	/g' \
40	    -e 's/</</g' -e 's/>/>/g' -e 's/"/"/g' -e 's/&/\&/g'
41	}
42
43	# Render an `api poll` drain ($1) for CC. Canonical format (ADR-0020): every message is a
44	# self-delimiting `<EVENT type="msg" from="<sender>">body</EVENT>` envelope (spt-proto::event) —
45	# the same grammar the live listener emits. Multi-message drains split cleanly on `</EVENT>`.
46	# Sender is preserved as `from=` (reply-correlation). NOTE: targets canonical <EVENT>; the current
47	# 0.6.0 binary still emits a `__REPLY_TO__` relic at the poll surface until the REQ-MSG-ENVELOPE
48	# refactor lands (ADR-0020) — finalize/validate against poll only post-refactor. [unit->REQ-UPS-INJECTION]
49	render_frames() {
50	  _in="$1"
51	  [ -z "$_in" ] && return 0
52	  # Normalise to one <EVENT>…</EVENT> per line (body newlines are
-escaped, so each envelope
53	  # is single-line), then parse each.
54	  printf '%s' "$_in" | sed 's#</EVENT>#</EVENT>\
55	#g' | while IFS= read -r _ev; do
56	    case "$_ev" in
57	      *"<EVENT"*"</EVENT>"*) ;;
58	      *) continue ;;
59	    esac
60	    _sender=$(printf '%s' "$_ev" | sed -n 's/.*<EVENT[^>]* from="\([^"]*\)".*/\1/p')
61	    _raw=$(printf '%s' "$_ev" | sed -n 's#.*<EVENT[^>]*>\(.*\)</EVENT>.*#\1#p')
62	    _body=$(sptc_unescape "$_raw")
63	    if [ -n "$_sender" ]; then
64	      printf '<sptc_messages from="%s">\n%s\n</sptc_messages>\n' "$_sender" "$_body"
65	    else
66	      printf '<sptc_messages>\n%s\n</sptc_messages>\n' "$_body"
67	    fi
68	  done
69	}
70
71	# Extract the skill name from a `/sptc:<skill>` slash-command prompt ($1=prompt). UPS fires on a
72	# slash-command with the token intact (ADR-0002 validation 2026-06-15: `prompt:"/sptc:send doyle"`),
73	# so the wrapper detects the command here. Leading-only (after optional whitespace) to avoid firing
74	# on prose that merely mentions `/sptc:x` mid-sentence. Empty => not a sptc slash-command.
75	# [unit->REQ-UPS-INJECTION]
76	sptc_skill_key() {
77	  printf '%s' "$1" | sed -n 's#^[[:space:]]*/sptc:\([a-z][a-z0-9-]*\).*#\1#p' | head -n1
78	}
79
80	# Inject a skill's operative instructions ($1=skill name) as additionalContext. Resolves the
81	# file-backed/inline body from the adapter `[strings.skills].<skill>` via `spt adapter get-string`
82	# (F-003: pointer values resolve to file contents at read time). No-op (silent) if spt is absent,
83	# the adapter is unregistered, or the key is unset — the thin SKILL.md skeleton still loaded by CC
84	# is the floor. The injected body is what the agent follows for this `/sptc:<skill>` invocation.
85	# [impl->REQ-UPS-INJECTION]
86	sptc_inject_skill() {
87	  _skill="$1"; [ -z "$_skill" ] && return 0
88	  _spt="$(spt_bin)"
89	  _body=$("$_spt" adapter get-string "$ADAPTER" "skills.$_skill" 2>/dev/null) || return 0
90	  [ -z "$_body" ] && return 0
91	  printf '<sptc_skill name="%s">\n%s\n</sptc_skill>\n' "$_skill" "$_body"
92	}
93

# 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

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

# 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 (ADR-0020): 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 (ADR-0020) — 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"
}


Helpers understood. Checking the bus to send doyle the bind-association question async, then I'll author the manifest + hook in parallel.


echo "OWL=$OWL"; echo "LIVE=$LIVE"; $OWL list 2>&1 | head -20

Check bus + roster


OWL=C:/Users/decid/.claude/plugins/cache/cplugs/spt/1.11.25/owl.exe
LIVE=C:/Users/decid/.claude/plugins/cache/cplugs/spt/1.11.25/owl.exe live
⚠ (2 orphan directories present — run `owl doctor --fix` to GC)
✓ ACTIVE:deployah
✓   - ACTIVE:deployah-psyche
✓ ACTIVE:doyle
✓   - ACTIVE:doyle-psyche
✓ ACTIVE:perri
✓   - ACTIVE:perri-psyche
✓ ACTIVE:solar
✓   - ACTIVE:solar-psyche
✓ ACTIVE:todlando
✓   - ACTIVE:todlando-psyche


doyle ACTIVE. Sending the bind-association question async, then drafting the manifest blocks in parallel.


$OWL send doyle <<'EOF'
[perri → doyle] Q on spt-hosted bind (claude-spt `[session.self]` slice, v0.7.1).

Building the endpoint-run/bind path: `spt endpoint run --adapter claude-spt --id <id>` spawns `[session.self]` (= `claude`) into the broker PTY; I inject the endpoint id via `[env.SPT_ENDPOINT_ID]={id}`; CC's SessionStart hook then self-registers post-spawn with `api bind <id> --set-session-id <cc_session_id>`.

Question — bind ASSOCIATION PROOF for a broker-spawned (spt-hosted) session:
`api bind <ID>` offers `--token <cap-token>` OR `--session-id <matches-info.json>` as the association proof. For a session the BROKER itself spawned into its own PTY, is process-tree/PTY ownership (PPID anchor) sufficient — i.e. bind with just `--set-session-id` and no proof token? Or must the broker inject a capability token into the spawned env (a second `[env.*]` entry) that bind echoes back via `--token`?

If a token is required: what env var name does the broker inject it under (so I author the matching `[env.<VAR>] direction="read"`)? Building the unblocked parts meanwhile; will adjust on your reply. No rush.
EOF
echo "sent rc=$?"

Send doyle the bind-token question


➜ SENT:doyle
sent rc=0


doyle pinged. Now authoring the unambiguous parts. Checking the REQ registry for the shortcut-basename + any session REQ stages before I tag evidence.


grep -n "SHORTCUT-BASENAME\|session\|SESSION-SELF\|endpoint" traceable-reqs.toml | head -30

Find relevant REQs


82:id = "REQ-DIST-SHORTCUT-BASENAME"
83:title = "The adapter manifest brands the `spt endpoint run` launcher shortcut as `cc-<id>` via adapter.shortcut_basename = \"cc\" (the M12 cc launcher; decoupled from the sptc plugin name, ADR-0001)"
84:required_stages = ["doc", "unit"]  # doc: ADR-0001 (cc launcher brand, decoupled from the s/sptc/spt/ plugin-name succession). unit: tests/manifest-shortcut.sh (brand-value regression guard on the declarative field). impl (the cc launcher) + int (real `spt endpoint run` picker emits cc-<id>) deferred to the endpoint-run/cc-launcher parity slice — the shortcut-emitting picker "lands in a later spt-core wave" (per `endpoint run --help`) and needs `[session.self]`. The field itself is declarative config, not production code, so it carries no impl tag (the scanner classifies impl by file type — config is not impl).
122:title = "Acceptance = scripted orchestration spawning real claude/headless sessions as the system-under-test, asserting spt-state/digest output (LLM is SUT, never the runner)"


78	title = "CC adapter manifest validates against spt-core's published manifest.schema.json (from spt-releases)"
79	required_stages = ["doc", "impl", "unit", "int"]  # doc: ADR-0001 (manifest layer + version-of-truth). impl: adapter/claude-spt.toml + vendored adapter/manifest.schema.json + ci/manifest/{validate_manifest.py,check-manifest.sh} (gate). unit: tests/manifest-schema.sh (pass real + fail tampered). int: ci/manifest/registration-int.sh — real `spt adapter add` (v0.7.0) cross-field registration-validation accepted the manifest, shipped profile resolved, [strings] read + overlay observable; 6/6 green 2026-06-15 (registry left clean via soft-remove). Cross-field discovery: [digest] requires source-or-[history].locate_template (not in JSON schema) — fixed with source="{home}/.claude/projects".
80
81	[[requirements]]
82	id = "REQ-DIST-SHORTCUT-BASENAME"
83	title = "The adapter manifest brands the `spt endpoint run` launcher shortcut as `cc-<id>` via adapter.shortcut_basename = \"cc\" (the M12 cc launcher; decoupled from the sptc plugin name, ADR-0001)"
84	required_stages = ["doc", "unit"]  # doc: ADR-0001 (cc launcher brand, decoupled from the s/sptc/spt/ plugin-name succession). unit: tests/manifest-shortcut.sh (brand-value regression guard on the declarative field). impl (the cc launcher) + int (real `spt endpoint run` picker emits cc-<id>) deferred to the endpoint-run/cc-launcher parity slice — the shortcut-emitting picker "lands in a later spt-core wave" (per `endpoint run --help`) and needs `[session.self]`. The field itself is declarative config, not production code, so it carries no impl tag (the scanner classifies impl by file type — config is not impl).
85
86	[[requirements]]
87	id = "REQ-UPS-INJECTION"
88	title = "UserPromptSubmit hook detects /sptc:X and injects X's real instructions as additionalContext (SKILL.md files stay skeletons); must be empirically confirmed UPS fires on slash-commands"
89	required_stages = ["doc", "impl", "unit", "int"]  # doc: ADR-0002. impl: user-prompt-submit.sh (skill-injection + drain) + _common.sh. unit: render_frames + sptc_skill_key tests. int: ci/hooks/poll-int.sh — the poll->additionalContext drain round-trip vs live v0.7.1 (<EVENT> envelope, render_frames confirm-match, multi-message). UPS-fires-on-slash empirical = ADR-0002 validation (CC 2.1.177); skill-injection resolve = registration-int 4c.
90
91	# ── Milestone: CI (autonomous fleet) — minted on-start. Deterministic gates + git push-trigger +
92	# spt reporting bus. Seeded from SCOPE.md "CI model" + docs/CI.md. Real-claude acceptance is a
93	# later slice (doc-only now). All harness-agnostic shell; runs on the Win+Linux fleet.
94
95	[[requirements]]
96	id = "REQ-CI-GATES"
97	title = "Deterministic gates (shell-syntax + unit tests + traceable-reqs check + manifest-schema + docs-drift), each a binary pass/fail, runnable as one command"

title = "CC adapter manifest validates against spt-core's published manifest.schema.json (from spt-releases)"
required_stages = ["doc", "impl", "unit", "int"]  # doc: ADR-0001 (manifest layer + version-of-truth). impl: adapter/claude-spt.toml + vendored adapter/manifest.schema.json + ci/manifest/{validate_manifest.py,check-manifest.sh} (gate). unit: tests/manifest-schema.sh (pass real + fail tampered). int: ci/manifest/registration-int.sh — real `spt adapter add` (v0.7.0) cross-field registration-validation accepted the manifest, shipped profile resolved, [strings] read + overlay observable; 6/6 green 2026-06-15 (registry left clean via soft-remove). Cross-field discovery: [digest] requires source-or-[history].locate_template (not in JSON schema) — fixed with source="{home}/.claude/projects".

[[requirements]]
id = "REQ-DIST-SHORTCUT-BASENAME"
title = "The adapter manifest brands the `spt endpoint run` launcher shortcut as `cc-<id>` via adapter.shortcut_basename = \"cc\" (the M12 cc launcher; decoupled from the sptc plugin name, ADR-0001)"
required_stages = ["doc", "unit"]  # doc: ADR-0001 (cc launcher brand, decoupled from the s/sptc/spt/ plugin-name succession). unit: tests/manifest-shortcut.sh (brand-value regression guard on the declarative field). impl (the cc launcher) + int (real `spt endpoint run` picker emits cc-<id>) deferred to the endpoint-run/cc-launcher parity slice — the shortcut-emitting picker "lands in a later spt-core wave" (per `endpoint run --help`) and needs `[session.self]`. The field itself is declarative config, not production code, so it carries no impl tag (the scanner classifies impl by file type — config is not impl).

[[requirements]]
id = "REQ-UPS-INJECTION"
title = "UserPromptSubmit hook detects /sptc:X and injects X's real instructions as additionalContext (SKILL.md files stay skeletons); must be empirically confirmed UPS fires on slash-commands"
required_stages = ["doc", "impl", "unit", "int"]  # doc: ADR-0002. impl: user-prompt-submit.sh (skill-injection + drain) + _common.sh. unit: render_frames + sptc_skill_key tests. int: ci/hooks/poll-int.sh — the poll->additionalContext drain round-trip vs live v0.7.1 (<EVENT> envelope, render_frames confirm-match, multi-message). UPS-fires-on-slash empirical = ADR-0002 validation (CC 2.1.177); skill-injection resolve = registration-int 4c.

# ── Milestone: CI (autonomous fleet) — minted on-start. Deterministic gates + git push-trigger +
# spt reporting bus. Seeded from SCOPE.md "CI model" + docs/CI.md. Real-claude acceptance is a
# later slice (doc-only now). All harness-agnostic shell; runs on the Win+Linux fleet.

[[requirements]]
id = "REQ-CI-GATES"
title = "Deterministic gates (shell-syntax + unit tests + traceable-reqs check + manifest-schema + docs-drift), each a binary pass/fail, runnable as one command"


Scope clear. This slice delivers the launch+bind machinery: `[session.self]` + `[env.SPT_ENDPOINT_ID]` (impl) + SessionStart bind-branch (impl) + unit (hook branch) + int (`adapter add` accepts the blocks; real bringup gated on doyle's token answer). Authoring manifest blocks now.


Edit

# Deferred to later parity slices (not in this slice; each declares an adapter seam we have not
# yet built, or needs a capability not on the public surface — see notes inline):
#   * [session] + [session.<role>]  — live-agent / Psyche / commune / signoff / notif spawn
#     templates. Land with the M12 parity surfaces (/sptc:live, commune, signoff).

# Deferred to later parity slices (not in this slice; each declares an adapter seam we have not
# yet built, or needs a capability not on the public surface — see notes inline):
#   * [session.<role>] for psyche_init/psyche_resume/echo_commune/signoff/notif — the live-agent /
#     Psyche layer (owl/$LIVE), absent from the core `spt` surface. Open scope question (does
#     claude-spt reach the Psyche layer?). [session.self] IS authored below (the endpoint-run/bind
#     bringup that hosts a fresh CC session — the M12 `cc` launcher target).


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

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


1	# claude-spt — Claude Code adapter manifest (publish target #2: the spt-core adapter registry side).
2	#
3	# This is the spt-core-conducted layer of the distribution split (ADR-0001): the volatile
4	# `[digest]` extractor / profiles / strings / hints ride here, NOT in the thin cplugs skeleton.
5	# Registered with `spt adapter add <path>`; the version-of-truth is `spt adapter list`, not the
6	# marketplace `plugin.json` version.
7	#
8	# Authored against spt-core's PUBLISHED v0.7.0 surface ONLY (spt-releases + sabermage.github.io/
9	# spt-releases) — `manifest.schema.json` is vendored beside this file (adapter/manifest.schema.json,
10	# provenance: https://sabermage.github.io/spt-releases/manifest.schema.json). Validated by the
11	# manifest-schema CI gate (ci/manifest/). NEVER copy spt-core internals (AGENTS.md).
12	#
13	# Deferred to later parity slices (not in this slice; each declares an adapter seam we have not
14	# yet built, or needs a capability not on the public surface — see notes inline):
15	#   * [session] + [session.<role>]  — live-agent / Psyche / commune / signoff / notif spawn
16	#     templates. Land with the M12 parity surfaces (/sptc:live, commune, signoff).
17	#   * [history]                     — transcript-access seam. Lands with [digest]'s sibling fetcher.
18	#   * [update]                      — distribution self-update. `file_pull` needs (a) a published
19	#     adapter-registry repo target and (b) an Ed25519 content-signing key we do not yet hold;
20	#     `delegated` to `claude plugin update` needs CC to attest signature verification
21	#     (`self_verifies`) — unconfirmed on the public surface. Tracked as a finding
22	#     (docs/SPT-CORE-FINDINGS.md) rather than guessed here.
23	#
24	# [impl->REQ-DIST-MANIFEST-SCHEMA]
25
26	[adapter]
27	name = "claude-spt"
28	kind = "harness"
29	version = "0.1.0"
30	# Local spt binary is 0.6.0; the published contract we build to is v0.7.0 (manifest.schema.json,
31	# shortcut_basename). 0.7.0 is the floor this manifest's fields require.
32	min_spt_core_version = "0.7.0"
33	hostable_types = ["LiveAgent", "ReadyAgent", "Worker"]
34	# Brands the `spt endpoint run` picker shortcut as `cc-<id>` (default would be `spt-<id>`) — this is
35	# the M12 `cc` launcher, and matches the schema's own example (spt-claude-code → `cc`). The launcher
36	# brand is DECOUPLED from the plugin name (`sptc`, skills `/sptc:*`): the s/sptc/spt/ succession seam
37	# (ADR-0001) renames the plugin, not this shortcut. Guarded by tests/manifest-shortcut.sh
38	# (unit->REQ-DIST-SHORTCUT-BASENAME).
39	shortcut_basename = "cc"
40
41	# ── [hooks.<event>] ──────────────────────────────────────────────────────────────────────────
42	# CC hook event → the opaque `api …` command it fires. These mirror the hand-written plugin
43	# hooks.json (plugin/sptc/hooks/hooks.json) one-for-one; the plugin shells `spt api`, this table
44	# is the registry-side declaration (ADR-0002, F-001 resolution). `reads` = the CC hook-stdin
45	# fields we map; `can_inject` = whether the event can surface additionalContext back to the agent.
46
47	[hooks.SessionStart]
48	# Seed the endpoint. {parent_pid} + {adapter_name} are spt-filled keys (confirmed against the
49	# published mock-adapter manifest); session_id is a CC stdin field. NOT the blocking poll/listen
50	# loop — that is /sptc:ready|live, never SessionStart.
51	fires = "api seed --session-id {session_id} --pid {parent_pid} --adapter {adapter_name}"
52	reads = ["session_id", "parent_pid"]
53	can_inject = true
54
55	[hooks.UserPromptSubmit]
56	# Drain the inbox (api poll → additionalContext) and run keyword hints (api hint over the prompt).
57	fires = "api poll {session_id}"
58	reads = ["session_id", "prompt"]
59	can_inject = true
60
61	[hooks.Stop]
62	# Idle marker on turn-end. Stop cannot inject → relay/sentinel fallback (echo-gate, F-001 §Gap3).
63	fires = "api state idle"
64	reads = ["session_id"]
65	can_inject = false
66
67	[hooks.SessionEnd]
68	fires = "api session-end {session_id}"
69	reads = ["session_id"]
70	can_inject = false
71
72	[hooks.SubagentStart]
73	fires = "api worker-start {session_id}"
74	reads = ["session_id"]
75	can_inject = false

# claude-spt — Claude Code adapter manifest (publish target #2: the spt-core adapter registry side).
#
# This is the spt-core-conducted layer of the distribution split (ADR-0001): the volatile
# `[digest]` extractor / profiles / strings / hints ride here, NOT in the thin cplugs skeleton.
# Registered with `spt adapter add <path>`; the version-of-truth is `spt adapter list`, not the
# marketplace `plugin.json` version.
#
# Authored against spt-core's PUBLISHED v0.7.0 surface ONLY (spt-releases + sabermage.github.io/
# spt-releases) — `manifest.schema.json` is vendored beside this file (adapter/manifest.schema.json,
# provenance: https://sabermage.github.io/spt-releases/manifest.schema.json). Validated by the
# manifest-schema CI gate (ci/manifest/). NEVER copy spt-core internals (AGENTS.md).
#
# Deferred to later parity slices (not in this slice; each declares an adapter seam we have not
# yet built, or needs a capability not on the public surface — see notes inline):
#   * [session] + [session.<role>]  — live-agent / Psyche / commune / signoff / notif spawn
#     templates. Land with the M12 parity surfaces (/sptc:live, commune, signoff).
#   * [history]                     — transcript-access seam. Lands with [digest]'s sibling fetcher.
#   * [update]                      — distribution self-update. `file_pull` needs (a) a published
#     adapter-registry repo target and (b) an Ed25519 content-signing key we do not yet hold;
#     `delegated` to `claude plugin update` needs CC to attest signature verification
#     (`self_verifies`) — unconfirmed on the public surface. Tracked as a finding
#     (docs/SPT-CORE-FINDINGS.md) rather than guessed here.
#
# [impl->REQ-DIST-MANIFEST-SCHEMA]

[adapter]
name = "claude-spt"
kind = "harness"
version = "0.1.0"
# Local spt binary is 0.6.0; the published contract we build to is v0.7.0 (manifest.schema.json,
# shortcut_basename). 0.7.0 is the floor this manifest's fields require.
min_spt_core_version = "0.7.0"
hostable_types = ["LiveAgent", "ReadyAgent", "Worker"]
# Brands the `spt endpoint run` picker shortcut as `cc-<id>` (default would be `spt-<id>`) — this is
# the M12 `cc` launcher, and matches the schema's own example (spt-claude-code → `cc`). The launcher
# brand is DECOUPLED from the plugin name (`sptc`, skills `/sptc:*`): the s/sptc/spt/ succession seam
# (ADR-0001) renames the plugin, not this shortcut. Guarded by tests/manifest-shortcut.sh
# (unit->REQ-DIST-SHORTCUT-BASENAME).
shortcut_basename = "cc"

# ── [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. {parent_pid} + {adapter_name} are spt-filled keys (confirmed against the
# published mock-adapter manifest); session_id is a CC stdin field. NOT the blocking poll/listen
# loop — that is /sptc:ready|live, never SessionStart.
fires = "api seed --session-id {session_id} --pid {parent_pid} --adapter {adapter_name}"
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


#   * [session] + [session.<role>]  — live-agent / Psyche / commune / signoff / notif spawn
#     templates. Land with the M12 parity surfaces (/sptc:live, commune, signoff).

#   * [session.<role>] for psyche_init/psyche_resume/echo_commune/signoff/notif — the live-agent /
#     Psyche layer (owl/$LIVE), absent from the core `spt` surface. Open scope question (does
#     claude-spt reach the Psyche layer?). [session.self] IS authored below (the endpoint-run/bind
#     bringup that hosts a fresh CC session — the M12 `cc` launcher target).


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

#   * [session.<role>] for psyche_init/psyche_resume/echo_commune/signoff/notif — the live-agent /
#     Psyche layer (owl/$LIVE), absent from the core `spt` surface. Open scope question (does
#     claude-spt reach the Psyche layer?). [session.self] IS authored below (the endpoint-run/bind
#     bringup that hosts a fresh CC session — the M12 `cc` launcher target).

# claude-spt — Claude Code adapter manifest (publish target #2: the spt-core adapter registry side).
#
# This is the spt-core-conducted layer of the distribution split (ADR-0001): the volatile
# `[digest]` extractor / profiles / strings / hints ride here, NOT in the thin cplugs skeleton.
# Registered with `spt adapter add <path>`; the version-of-truth is `spt adapter list`, not the
# marketplace `plugin.json` version.
#
# Authored against spt-core's PUBLISHED v0.7.0 surface ONLY (spt-releases + sabermage.github.io/
# spt-releases) — `manifest.schema.json` is vendored beside this file (adapter/manifest.schema.json,
# provenance: https://sabermage.github.io/spt-releases/manifest.schema.json). Validated by the
# manifest-schema CI gate (ci/manifest/). NEVER copy spt-core internals (AGENTS.md).
#
# Deferred to later parity slices (not in this slice; each declares an adapter seam we have not
# yet built, or needs a capability not on the public surface — see notes inline):
#   * [session] + [session.<role>]  — live-agent / Psyche / commune / signoff / notif spawn
#     templates. Land with the M12 parity surfaces (/sptc:live, commune, signoff).
#   * [history]                     — transcript-access seam. Lands with [digest]'s sibling fetcher.
#   * [update]                      — distribution self-update. `file_pull` needs (a) a published
#     adapter-registry repo target and (b) an Ed25519 content-signing key we do not yet hold;
#     `delegated` to `claude plugin update` needs CC to attest signature verification
#     (`self_verifies`) — unconfirmed on the public surface. Tracked as a finding
#     (docs/SPT-CORE-FINDINGS.md) rather than guessed here.
#
# [impl->REQ-DIST-MANIFEST-SCHEMA]

[adapter]
name = "claude-spt"
kind = "harness"
version = "0.1.0"
# Local spt binary is 0.6.0; the published contract we build to is v0.7.0 (manifest.schema.json,
# shortcut_basename). 0.7.0 is the floor this manifest's fields require.
min_spt_core_version = "0.7.0"
hostable_types = ["LiveAgent", "ReadyAgent", "Worker"]
# Brands the `spt endpoint run` picker shortcut as `cc-<id>` (default would be `spt-<id>`) — this is
# the M12 `cc` launcher, and matches the schema's own example (spt-claude-code → `cc`). The launcher
# brand is DECOUPLED from the plugin name (`sptc`, skills `/sptc:*`): the s/sptc/spt/ succession seam
# (ADR-0001) renames the plugin, not this shortcut. Guarded by tests/manifest-shortcut.sh
# (unit->REQ-DIST-SHORTCUT-BASENAME).
shortcut_basename = "cc"

# ── [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. {parent_pid} + {adapter_name} are spt-filled keys (confirmed against the
# published mock-adapter manifest); session_id is a CC stdin field. NOT the blocking poll/listen
# loop — that is /sptc:ready|live, never SessionStart.
fires = "api seed --session-id {session_id} --pid {parent_pid} --adapter {adapter_name}"
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

# ── [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"

# ── [inject] ───────────────────────────────────────────────────────────────────────────────
[inject]
# v1 delivery is the hook channel only (UserPromptSubmit/SessionStart additionalContext).
# PTY injection is spt-core M3 roadmap (F-001 resolution); not claimed here.
activity = ["hook"]
idle = ["hook"]

# ── [digest] ───────────────────────────────────────────────────────────────────────────────
# Session-digest extractor seam (ADR-0019). CC's transcript is JSONL (one event per line); the
# extractor maps that native log → the published {role,text,tool,ts} digest-record contract,
# one JSON record per output line. (CC has a rich log, so the extractor seam fits — a defensible
# divergence from the deliberately log-less mock adapter, which declares no [digest].)
#
# spt-core's registration cross-field rule requires [digest] to name where it reads from (`source`
# or a [history] `locate_template`) — verified against the live v0.7.0 `spt adapter add`. CC stores
# transcripts at `{home}/.claude/projects/<cwd-slug>/<session_id>.jsonl`; the cwd-slug subdir is a
# CC-internal encoding NOT expressible as a flat template with the published key catalog, so
# `source` names the per-project ROOT dir and the extractor finds `{session_id}.jsonl` within it
# (the `source` field documents accepting "log file(s)" — a dir is valid). The extractor binary
# ships in the spt-core-conducted layer (built in a later parity slice); this is its declaration.
[digest]
extractor = "claude-spt-digest --session {session_id} --in {source}"
source = "{home}/.claude/projects"
window_turns = 6
arg_truncation = 240
sprint_collapse = true

# ── [profiles.<name>] ────────────────────────────────────────────────────────────────────────
# Shipped profile overlays (sparse leaf-replace; selected as the composite `claude-spt:<name>`).
# `deep` widens the digest window for long-context review sessions without forking the base, and
# overrides a [strings] leaf so the overlay is observable via `spt adapter get-string` (base vs
# `:deep` differ). (Node-local user profiles are created with `spt adapter create-profile` and
# live in the registry, never in this manifest.)
[profiles.deep.digest]
window_turns = 20

[profiles.deep.strings]
adapter_label = "Claude Code (spt, deep)"

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

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

[strings.skills]
# Operative UPS-injection skill bodies. File-backed values (`{ file = "skills/<x>.md" }`) live in the
# adapter aux dir `adapter/strings/skills/` (copied to `adapters/<adapter>/strings/` on `adapter add`,
# resolved lazily at `spt adapter get-string` — proven vs live v0.7.0, F-003). File-backed so the
# manifest does not bloat (ADR-0001). Skills not yet authored keep a one-line inline summary until
# their body lands; converting an inline summary to a `{ file }` pointer is the per-skill increment.
whoami = { file = "skills/whoami.md" }
setup = { file = "skills/setup.md" }
ready = { file = "skills/ready.md" }
send = { file = "skills/send.md" }
version = { file = "skills/version.md" }
signoff = { file = "skills/signoff.md" }       # -> spt endpoint shutdown (graceful + final save)
list-agents = { file = "skills/list-agents.md" } # -> spt endpoint list (the roster)
# --- inline summaries, pending file-backed bodies. NOT cleanly mappable to the core `spt` v0.7.1
# surface yet (don't invent): `live` -> `spt endpoint run` but needs manifest [session.self] (deferred
# slice); `commune` + `new-alarm` have NO core-spt command (live-agent/Psyche layer, owl/$LIVE);
# `force-stop` has only soft `endpoint stop`/`shutdown`, no force-kill-Psyche equivalent. Verify or
# file a finding before authoring. ---
commune = "Send a communal context update to your Psyche."
live = "Run or resume a live agent session."

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

[[hints]]
keywords = ["who am i", "my owl name", "my agent name", "my endpoint id"]
text = "Tip: /sptc:whoami reports this session's spt endpoint id."

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

-#   * [session] + [session.<role>]  — live-agent / Psyche / commune / signoff / notif spawn

-#     templates. Land with the M12 parity surfaces (/sptc:live, commune, signoff).

+#   * [session.<role>] for psyche_init/psyche_resume/echo_commune/signoff/notif — the live-agent /

+#     Psyche layer (owl/$LIVE), absent from the core `spt` surface. Open scope question (does

+#     claude-spt reach the Psyche layer?). [session.self] IS authored below (the endpoint-run/bind

+#     bringup that hosts a fresh CC session — the M12 `cc` launcher target).


Now add `[session.self]` + `[env]` blocks. Reading the `[identity]`/`[inject]` region to place them.


76	reads = ["session_id"]
77	can_inject = false
78
79	[hooks.SubagentStop]
80	fires = "api worker-stop {session_id}"
81	reads = ["session_id"]
82	can_inject = false
83
84	# ── [identity] ─────────────────────────────────────────────────────────────────────────────
85	[identity]
86	# CC hands the session_id on hook stdin once running (discovered post-spawn, not injected).
87	session_id_source = "post_spawn"
88	# Process-tree anchor when session_id is absent: the Claude Code binary.
89	parent_ancestor_name = "claude"
90
91	# ── [inject] ───────────────────────────────────────────────────────────────────────────────
92	[inject]
93	# v1 delivery is the hook channel only (UserPromptSubmit/SessionStart additionalContext).
94	# PTY injection is spt-core M3 roadmap (F-001 resolution); not claimed here.
95	activity = ["hook"]
96	idle = ["hook"]
97
98	# ── [digest] ───────────────────────────────────────────────────────────────────────────────
99	# Session-digest extractor seam (ADR-0019). CC's transcript is JSONL (one event per line); the
100	# extractor maps that native log → the published {role,text,tool,ts} digest-record contract,
101	# one JSON record per output line. (CC has a rich log, so the extractor seam fits — a defensible
102	# divergence from the deliberately log-less mock adapter, which declares no [digest].)
103	#
104	# spt-core's registration cross-field rule requires [digest] to name where it reads from (`source`
105	# or a [history] `locate_template`) — verified against the live v0.7.0 `spt adapter add`. CC stores
106	# transcripts at `{home}/.claude/projects/<cwd-slug>/<session_id>.jsonl`; the cwd-slug subdir is a
107	# CC-internal encoding NOT expressible as a flat template with the published key catalog, so
108	# `source` names the per-project ROOT dir and the extractor finds `{session_id}.jsonl` within it
109	# (the `source` field documents accepting "log file(s)" — a dir is valid). The extractor binary
110	# ships in the spt-core-conducted layer (built in a later parity slice); this is its declaration.

reads = ["session_id"]
can_inject = false

[hooks.SubagentStop]
fires = "api worker-stop {session_id}"
reads = ["session_id"]
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"

# ── [inject] ───────────────────────────────────────────────────────────────────────────────
[inject]
# v1 delivery is the hook channel only (UserPromptSubmit/SessionStart additionalContext).
# PTY injection is spt-core M3 roadmap (F-001 resolution); not claimed here.
activity = ["hook"]
idle = ["hook"]

# ── [digest] ───────────────────────────────────────────────────────────────────────────────
# Session-digest extractor seam (ADR-0019). CC's transcript is JSONL (one event per line); the
# extractor maps that native log → the published {role,text,tool,ts} digest-record contract,
# one JSON record per output line. (CC has a rich log, so the extractor seam fits — a defensible
# divergence from the deliberately log-less mock adapter, which declares no [digest].)
#
# spt-core's registration cross-field rule requires [digest] to name where it reads from (`source`
# or a [history] `locate_template`) — verified against the live v0.7.0 `spt adapter add`. CC stores
# transcripts at `{home}/.claude/projects/<cwd-slug>/<session_id>.jsonl`; the cwd-slug subdir is a
# CC-internal encoding NOT expressible as a flat template with the published key catalog, so
# `source` names the per-project ROOT dir and the extractor finds `{session_id}.jsonl` within it
# (the `source` field documents accepting "log file(s)" — a dir is valid). The extractor binary
# ships in the spt-core-conducted layer (built in a later parity slice); this is its declaration.


# ── [inject] ───────────────────────────────────────────────────────────────────────────────
[inject]

# ── [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` bare: CC mints its OWN session id internally (identity.session_id_source =
# post_spawn), so spt's {session_id} is not threaded on argv — only the endpoint {id} matters, and
# CC has no native flag for it, so it rides the ENV instead (see [env.SPT_ENDPOINT_ID] below; the
# published sanctioned pattern for a flagless harness). SessionStart reads $SPT_ENDPOINT_ID and binds.
# [impl->REQ-DIST-SHORTCUT-BASENAME]
[session.self]
command = "claude"
keys = ["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]
[env.SPT_ENDPOINT_ID]
direction = "inject"
value = "{id}"

# ── [inject] ───────────────────────────────────────────────────────────────────────────────
[inject]


# ── [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` bare: CC mints its OWN session id internally (identity.session_id_source =
# post_spawn), so spt's {session_id} is not threaded on argv — only the endpoint {id} matters, and
# CC has no native flag for it, so it rides the ENV instead (see [env.SPT_ENDPOINT_ID] below; the
# published sanctioned pattern for a flagless harness). SessionStart reads $SPT_ENDPOINT_ID and binds.
# [impl->REQ-DIST-SHORTCUT-BASENAME]
[session.self]
command = "claude"
keys = ["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]
[env.SPT_ENDPOINT_ID]
direction = "inject"
value = "{id}"

# ── [inject] ───────────────────────────────────────────────────────────────────────────────
[inject]

# claude-spt — Claude Code adapter manifest (publish target #2: the spt-core adapter registry side).
#
# This is the spt-core-conducted layer of the distribution split (ADR-0001): the volatile
# `[digest]` extractor / profiles / strings / hints ride here, NOT in the thin cplugs skeleton.
# Registered with `spt adapter add <path>`; the version-of-truth is `spt adapter list`, not the
# marketplace `plugin.json` version.
#
# Authored against spt-core's PUBLISHED v0.7.0 surface ONLY (spt-releases + sabermage.github.io/
# spt-releases) — `manifest.schema.json` is vendored beside this file (adapter/manifest.schema.json,
# provenance: https://sabermage.github.io/spt-releases/manifest.schema.json). Validated by the
# manifest-schema CI gate (ci/manifest/). NEVER copy spt-core internals (AGENTS.md).
#
# Deferred to later parity slices (not in this slice; each declares an adapter seam we have not
# yet built, or needs a capability not on the public surface — see notes inline):
#   * [session.<role>] for psyche_init/psyche_resume/echo_commune/signoff/notif — the live-agent /
#     Psyche layer (owl/$LIVE), absent from the core `spt` surface. Open scope question (does
#     claude-spt reach the Psyche layer?). [session.self] IS authored below (the endpoint-run/bind
#     bringup that hosts a fresh CC session — the M12 `cc` launcher target).
#   * [history]                     — transcript-access seam. Lands with [digest]'s sibling fetcher.
#   * [update]                      — distribution self-update. `file_pull` needs (a) a published
#     adapter-registry repo target and (b) an Ed25519 content-signing key we do not yet hold;
#     `delegated` to `claude plugin update` needs CC to attest signature verification
#     (`self_verifies`) — unconfirmed on the public surface. Tracked as a finding
#     (docs/SPT-CORE-FINDINGS.md) rather than guessed here.
#
# [impl->REQ-DIST-MANIFEST-SCHEMA]

[adapter]
name = "claude-spt"
kind = "harness"
version = "0.1.0"
# Local spt binary is 0.6.0; the published contract we build to is v0.7.0 (manifest.schema.json,
# shortcut_basename). 0.7.0 is the floor this manifest's fields require.
min_spt_core_version = "0.7.0"
hostable_types = ["LiveAgent", "ReadyAgent", "Worker"]
# Brands the `spt endpoint run` picker shortcut as `cc-<id>` (default would be `spt-<id>`) — this is
# the M12 `cc` launcher, and matches the schema's own example (spt-claude-code → `cc`). The launcher
# brand is DECOUPLED from the plugin name (`sptc`, skills `/sptc:*`): the s/sptc/spt/ succession seam
# (ADR-0001) renames the plugin, not this shortcut. Guarded by tests/manifest-shortcut.sh
# (unit->REQ-DIST-SHORTCUT-BASENAME).
shortcut_basename = "cc"

# ── [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. {parent_pid} + {adapter_name} are spt-filled keys (confirmed against the
# published mock-adapter manifest); session_id is a CC stdin field. NOT the blocking poll/listen
# loop — that is /sptc:ready|live, never SessionStart.
fires = "api seed --session-id {session_id} --pid {parent_pid} --adapter {adapter_name}"
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

# ── [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"

# ── [inject] ───────────────────────────────────────────────────────────────────────────────
[inject]
# v1 delivery is the hook channel only (UserPromptSubmit/SessionStart additionalContext).
# PTY injection is spt-core M3 roadmap (F-001 resolution); not claimed here.
activity = ["hook"]
idle = ["hook"]

# ── [digest] ───────────────────────────────────────────────────────────────────────────────
# Session-digest extractor seam (ADR-0019). CC's transcript is JSONL (one event per line); the
# extractor maps that native log → the published {role,text,tool,ts} digest-record contract,
# one JSON record per output line. (CC has a rich log, so the extractor seam fits — a defensible
# divergence from the deliberately log-less mock adapter, which declares no [digest].)
#
# spt-core's registration cross-field rule requires [digest] to name where it reads from (`source`
# or a [history] `locate_template`) — verified against the live v0.7.0 `spt adapter add`. CC stores
# transcripts at `{home}/.claude/projects/<cwd-slug>/<session_id>.jsonl`; the cwd-slug subdir is a
# CC-internal encoding NOT expressible as a flat template with the published key catalog, so
# `source` names the per-project ROOT dir and the extractor finds `{session_id}.jsonl` within it
# (the `source` field documents accepting "log file(s)" — a dir is valid). The extractor binary
# ships in the spt-core-conducted layer (built in a later parity slice); this is its declaration.
[digest]
extractor = "claude-spt-digest --session {session_id} --in {source}"
source = "{home}/.claude/projects"
window_turns = 6
arg_truncation = 240
sprint_collapse = true

# ── [profiles.<name>] ────────────────────────────────────────────────────────────────────────
# Shipped profile overlays (sparse leaf-replace; selected as the composite `claude-spt:<name>`).
# `deep` widens the digest window for long-context review sessions without forking the base, and
# overrides a [strings] leaf so the overlay is observable via `spt adapter get-string` (base vs
# `:deep` differ). (Node-local user profiles are created with `spt adapter create-profile` and
# live in the registry, never in this manifest.)
[profiles.deep.digest]
window_turns = 20

[profiles.deep.strings]
adapter_label = "Claude Code (spt, deep)"

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

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

[strings.skills]
# Operative UPS-injection skill bodies. File-backed values (`{ file = "skills/<x>.md" }`) live in the
# adapter aux dir `adapter/strings/skills/` (copied to `adapters/<adapter>/strings/` on `adapter add`,
# resolved lazily at `spt adapter get-string` — proven vs live v0.7.0, F-003). File-backed so the
# manifest does not bloat (ADR-0001). Skills not yet authored keep a one-line inline summary until
# their body lands; converting an inline summary to a `{ file }` pointer is the per-skill increment.
whoami = { file = "skills/whoami.md" }
setup = { file = "skills/setup.md" }
ready = { file = "skills/ready.md" }
send = { file = "skills/send.md" }
version = { file = "skills/version.md" }
signoff = { file = "skills/signoff.md" }       # -> spt endpoint shutdown (graceful + final save)
list-agents = { file = "skills/list-agents.md" } # -> spt endpoint list (the roster)
# --- inline summaries, pending file-backed bodies. NOT cleanly mappable to the core `spt` v0.7.1
# surface yet (don't invent): `live` -> `spt endpoint run` but needs manifest [session.self] (deferred
# slice); `commune` + `new-alarm` have NO core-spt command (live-agent/Psyche layer, owl/$LIVE);
# `force-stop` has only soft `endpoint stop`/`shutdown`, no force-kill-Psyche equivalent. Verify or
# file a finding before authoring. ---
commune = "Send a communal context update to your Psyche."
live = "Run or resume a live agent session."

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

[[hints]]
keywords = ["who am i", "my owl name", "my agent name", "my endpoint id"]
text = "Tip: /sptc:whoami reports this session's spt endpoint id."

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

+# ── [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` bare: CC mints its OWN session id internally (identity.session_id_source =

+# post_spawn), so spt's {session_id} is not threaded on argv — only the endpoint {id} matters, and

+# CC has no native flag for it, so it rides the ENV instead (see [env.SPT_ENDPOINT_ID] below; the

+# published sanctioned pattern for a flagless harness). SessionStart reads $SPT_ENDPOINT_ID and binds.

+# ── [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]


Manifest blocks authored. Now the SessionStart bind-branch (spt-hosted → `api bind`, else seed). Binding with `--set-session-id` only for now; token gated on doyle (flagged inline).


  *)
    "$SPT" api --adapter "$ADAPTER" seed --pid "$PPID" --session-id "$sid" >/dev/null 2>&1 || true
    ;;
esac

  *)
    if [ -n "$SPT_ENDPOINT_ID" ]; then
      # spt-hosted (bind path): `spt endpoint run` spawned us into the broker PTY and injected the
      # endpoint id via [env.SPT_ENDPOINT_ID]. The perch already exists — self-register post-spawn
      # by binding our discovered CC session id, NOT seed (no seed file in this path). TODO(perri):
      # if doyle confirms broker-spawned bind needs a capability token, add --token "$SPT_BIND_TOKEN"
      # + the matching [env.*] read directive; PTY/PPID ownership assumed sufficient until then.
      "$SPT" api --adapter "$ADAPTER" bind "$SPT_ENDPOINT_ID" \
        --set-session-id "$sid" >/dev/null 2>&1 || true
    else
      # harness-hosted (seed→listen path): user-launched CC. Record the seed for /sptc:ready|live.
      "$SPT" api --adapter "$ADAPTER" seed --pid "$PPID" --session-id "$sid" >/dev/null 2>&1 || true
    fi
    ;;
esac


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

  *)
    if [ -n "$SPT_ENDPOINT_ID" ]; then
      # spt-hosted (bind path): `spt endpoint run` spawned us into the broker PTY and injected the
      # endpoint id via [env.SPT_ENDPOINT_ID]. The perch already exists — self-register post-spawn
      # by binding our discovered CC session id, NOT seed (no seed file in this path). TODO(perri):
      # if doyle confirms broker-spawned bind needs a capability token, add --token "$SPT_BIND_TOKEN"
      # + the matching [env.*] read directive; PTY/PPID ownership assumed sufficient until then.
      "$SPT" api --adapter "$ADAPTER" bind "$SPT_ENDPOINT_ID" \
        --set-session-id "$sid" >/dev/null 2>&1 || true
    else
      # harness-hosted (seed→listen path): user-launched CC. Record the seed for /sptc:ready|live.
      "$SPT" api --adapter "$ADAPTER" seed --pid "$PPID" --session-id "$sid" >/dev/null 2>&1 || true
    fi
    ;;
esac

#!/bin/sh
# SessionStart: ensure spt-core installed, seed the perch (or rebind on clear/compact),
# and persist session env so whoami + per-prompt hooks resolve. Non-blocking (never `listen`).
# [impl->REQ-DIST-HOOKS-API]
. "$CLAUDE_PLUGIN_ROOT/hooks/_common.sh"

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

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

case "$src" in
  clear|compact)
    id=$(sptc_self_id "$sid")
    [ -n "$id" ] && "$SPT" api --adapter "$ADAPTER" boundary "$src" "$id" \
      --to-session-id "$sid" >/dev/null 2>&1 || true
    ;;
  *)
    "$SPT" api --adapter "$ADAPTER" seed --pid "$PPID" --session-id "$sid" >/dev/null 2>&1 || true
    ;;
esac

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

-    "$SPT" api --adapter "$ADAPTER" seed --pid "$PPID" --session-id "$sid" >/dev/null 2>&1 || true

+      # spt-hosted (bind path): `spt endpoint run` spawned us into the broker PTY and injected the

+      # endpoint id via [env.SPT_ENDPOINT_ID]. The perch already exists — self-register post-spawn

+      # by binding our discovered CC session id, NOT seed (no seed file in this path). TODO(perri):

+      # if doyle confirms broker-spawned bind needs a capability token, add --token "$SPT_BIND_TOKEN"

+      # + the matching [env.*] read directive; PTY/PPID ownership assumed sufficient until then.

+      "$SPT" api --adapter "$ADAPTER" bind "$SPT_ENDPOINT_ID" \

+        --set-session-id "$sid" >/dev/null 2>&1 || true

+      # harness-hosted (seed→listen path): user-launched CC. Record the seed for /sptc:ready|live.

+      "$SPT" api --adapter "$ADAPTER" seed --pid "$PPID" --session-id "$sid" >/dev/null 2>&1 || true


# SessionStart: ensure spt-core installed, seed the perch (or rebind on clear/compact),
# and persist session env so whoami + per-prompt hooks resolve. Non-blocking (never `listen`).

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


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

#!/bin/sh
# SessionStart: ensure spt-core installed, seed the perch (or rebind on clear/compact),
# and persist session env so whoami + per-prompt hooks resolve. Non-blocking (never `listen`).
# [impl->REQ-DIST-HOOKS-API]
. "$CLAUDE_PLUGIN_ROOT/hooks/_common.sh"

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

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

case "$src" in
  clear|compact)
    id=$(sptc_self_id "$sid")
    [ -n "$id" ] && "$SPT" api --adapter "$ADAPTER" boundary "$src" "$id" \
      --to-session-id "$sid" >/dev/null 2>&1 || true
    ;;
  *)
    if [ -n "$SPT_ENDPOINT_ID" ]; then
      # spt-hosted (bind path): `spt endpoint run` spawned us into the broker PTY and injected the
      # endpoint id via [env.SPT_ENDPOINT_ID]. The perch already exists — self-register post-spawn
      # by binding our discovered CC session id, NOT seed (no seed file in this path). TODO(perri):
      # if doyle confirms broker-spawned bind needs a capability token, add --token "$SPT_BIND_TOKEN"
      # + the matching [env.*] read directive; PTY/PPID ownership assumed sufficient until then.
      "$SPT" api --adapter "$ADAPTER" bind "$SPT_ENDPOINT_ID" \
        --set-session-id "$sid" >/dev/null 2>&1 || true
    else
      # harness-hosted (seed→listen path): user-launched CC. Record the seed for /sptc:ready|live.
      "$SPT" api --adapter "$ADAPTER" seed --pid "$PPID" --session-id "$sid" >/dev/null 2>&1 || true
    fi
    ;;
esac

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

-# SessionStart: ensure spt-core installed, seed the perch (or rebind on clear/compact),

-# and persist session env so whoami + per-prompt hooks resolve. Non-blocking (never `listen`).

+# SessionStart: ensure spt-core installed, then register the perch — bind (spt-hosted, broker

+# spawned us via [session.self] + injected $SPT_ENDPOINT_ID), seed (harness-hosted, user-launched

+# CC), or boundary-rebind (clear/compact) — and persist session env so whoami + per-prompt hooks

+# resolve. Non-blocking (never `listen`).


Now the unit test. Finding the existing hooks-parse unit test to extend with the bind-vs-seed branch.


Bash

ls ci/hooks/ tests/ 2>/dev/null; echo "=== grep seed/bind in tests ==="; grep -rln "seed\|session-start\|api bind\|SPT_ENDPOINT" ci/ tests/ 2>/dev/null | head

Locate hook tests


ci/hooks/:
poll-int.sh

tests/:
acceptance-harness.sh
ci-gates.sh
docs-gen.sh
hooks-parse.sh
manifest-schema.sh
manifest-shortcut.sh
msys-hazard.sh
skeleton-validate.sh
=== grep seed/bind in tests ===
ci/hooks/poll-int.sh
tests/msys-hazard.sh


1	#!/bin/sh
2	# Unit tests for the sptc hook parsing glue (pure functions; no spt/CC needed).
3	# Run: sh tests/hooks-parse.sh   (exit 0 = pass)
4	. "$(dirname "$0")/../plugin/sptc/hooks/_common.sh"
5
6	fail=0
7	check() { # check  <expected> <actual>
8	  if [ "$2" = "$3" ]; then printf 'ok   %s\n' "$1"
9	  else printf 'FAIL %s\n  expected: [%s]\n  actual:   [%s]\n' "$1" "$2" "$3"; fail=1; fi
10	}
11
12	# --- json_str: top-level CC hook-input fields ---  [unit->REQ-DIST-HOOKS-API]
13	J='{"session_id":"sess-abc123","transcript_path":"/x","source":"startup","model":"m"}'
14	check "json_str session_id" "sess-abc123" "$(json_str "$J" session_id)"
15	check "json_str source"     "startup"     "$(json_str "$J" source)"
16	check "json_str prompt"      "/sptc:send doyle" \
17	  "$(json_str '{"session_id":"s1","prompt":"/sptc:send doyle"}' prompt)"
18	check "json_str missing key" "" "$(json_str "$J" nope)"
19
20	# --- sptc_unescape:
 -> newline, entities, & LAST --- [unit->REQ-UPS-INJECTION]
21	check "unescape br+entities" \
22	  "$(printf 'a \n"c" & <')" \
23	  "$(sptc_unescape 'a <b>
"c" & &lt;')"
24
25	# --- render_frames: canonical <EVENT> envelope (ADR-0020) --- [unit->REQ-UPS-INJECTION]
26	# Named single,
-escaped multiline body, sender preserved.
27	named=$(render_frames '<EVENT type="msg" from="doyle">hello
world</EVENT>')
28	check "render named <EVENT>" \
29	  "$(printf '<sptc_messages from="doyle">\nhello\nworld\n</sptc_messages>')" "$named"
30
31	# Entity-escaped body decodes.
32	ent=$(render_frames '<EVENT type="msg" from="kit">a <tag> & b</EVENT>')
33	check "render entity body" \
34	  "$(printf '<sptc_messages from="kit">\na <tag> & b\n</sptc_messages>')" "$ent"
35
36	# Multi-message drain: two self-delimiting envelopes -> two rendered blocks.
37	multi=$(render_frames '<EVENT type="msg" from="a">one</EVENT><EVENT type="msg" from="b">two</EVENT>')
38	check "render multi-message" \
39	  "$(printf '<sptc_messages from="a">\none\n</sptc_messages>\n<sptc_messages from="b">\ntwo\n</sptc_messages>')" \
40	  "$multi"
41
42	# Envelope without a from attr -> anonymous rendering.
43	anon=$(render_frames '<EVENT type="msg">sys note</EVENT>')
44	check "render no-from" "$(printf '<sptc_messages>\nsys note\n</sptc_messages>')" "$anon"
45
46	# Empty drain -> no output.
47	check "render empty" "" "$(render_frames '')"
48
49	# --- sptc_skill_key: detect /sptc:<skill> in a slash-command prompt --- [unit->REQ-UPS-INJECTION]
50	check "skill_key bare"        "whoami" "$(sptc_skill_key '/sptc:whoami')"
51	check "skill_key with args"   "send"   "$(sptc_skill_key '/sptc:send doyle hi')"
52	check "skill_key leading ws"  "setup"  "$(sptc_skill_key '   /sptc:setup')"
53	check "skill_key hyphenated"  "list-agents" "$(sptc_skill_key '/sptc:list-agents')"
54	check "skill_key mid-prose"   ""       "$(sptc_skill_key 'please run /sptc:whoami for me')"
55	check "skill_key other ns"    ""       "$(sptc_skill_key '/other:whoami')"
56	check "skill_key not a cmd"   ""       "$(sptc_skill_key 'whoami am i')"
57	check "skill_key empty"       ""       "$(sptc_skill_key '')"
58
59	[ "$fail" -eq 0 ] && { echo "ALL PASS"; exit 0; } || { echo "FAILURES"; exit 1; }
60

#!/bin/sh
# Unit tests for the sptc hook parsing glue (pure functions; no spt/CC needed).
# Run: sh tests/hooks-parse.sh   (exit 0 = pass)
. "$(dirname "$0")/../plugin/sptc/hooks/_common.sh"

fail=0
check() { # check  <expected> <actual>
  if [ "$2" = "$3" ]; then printf 'ok   %s\n' "$1"
  else printf 'FAIL %s\n  expected: [%s]\n  actual:   [%s]\n' "$1" "$2" "$3"; fail=1; fi
}

# --- json_str: top-level CC hook-input fields ---  [unit->REQ-DIST-HOOKS-API]
J='{"session_id":"sess-abc123","transcript_path":"/x","source":"startup","model":"m"}'
check "json_str session_id" "sess-abc123" "$(json_str "$J" session_id)"
check "json_str source"     "startup"     "$(json_str "$J" source)"
check "json_str prompt"      "/sptc:send doyle" \
  "$(json_str '{"session_id":"s1","prompt":"/sptc:send doyle"}' prompt)"
check "json_str missing key" "" "$(json_str "$J" nope)"

# --- sptc_unescape:
 -> newline, entities, & LAST --- [unit->REQ-UPS-INJECTION]
check "unescape br+entities" \
  "$(printf 'a \n"c" & <')" \
  "$(sptc_unescape 'a <b>
"c" & &lt;')"

# --- render_frames: canonical <EVENT> envelope (ADR-0020) --- [unit->REQ-UPS-INJECTION]
# Named single,
-escaped multiline body, sender preserved.
named=$(render_frames '<EVENT type="msg" from="doyle">hello
world</EVENT>')
check "render named <EVENT>" \
  "$(printf '<sptc_messages from="doyle">\nhello\nworld\n</sptc_messages>')" "$named"

# Entity-escaped body decodes.
ent=$(render_frames '<EVENT type="msg" from="kit">a <tag> & b</EVENT>')
check "render entity body" \
  "$(printf '<sptc_messages from="kit">\na <tag> & b\n</sptc_messages>')" "$ent"

# Multi-message drain: two self-delimiting envelopes -> two rendered blocks.
multi=$(render_frames '<EVENT type="msg" from="a">one</EVENT><EVENT type="msg" from="b">two</EVENT>')
check "render multi-message" \
  "$(printf '<sptc_messages from="a">\none\n</sptc_messages>\n<sptc_messages from="b">\ntwo\n</sptc_messages>')" \
  "$multi"

# Envelope without a from attr -> anonymous rendering.
anon=$(render_frames '<EVENT type="msg">sys note</EVENT>')
check "render no-from" "$(printf '<sptc_messages>\nsys note\n</sptc_messages>')" "$anon"

# Empty drain -> no output.
check "render empty" "" "$(render_frames '')"

# --- sptc_skill_key: detect /sptc:<skill> in a slash-command prompt --- [unit->REQ-UPS-INJECTION]
check "skill_key bare"        "whoami" "$(sptc_skill_key '/sptc:whoami')"
check "skill_key with args"   "send"   "$(sptc_skill_key '/sptc:send doyle hi')"
check "skill_key leading ws"  "setup"  "$(sptc_skill_key '   /sptc:setup')"
check "skill_key hyphenated"  "list-agents" "$(sptc_skill_key '/sptc:list-agents')"
check "skill_key mid-prose"   ""       "$(sptc_skill_key 'please run /sptc:whoami for me')"
check "skill_key other ns"    ""       "$(sptc_skill_key '/other:whoami')"
check "skill_key not a cmd"   ""       "$(sptc_skill_key 'whoami am i')"
check "skill_key empty"       ""       "$(sptc_skill_key '')"

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


The branch decision should be a pure helper so it's unit-testable without spt/CC. Adding `sptc_register_verb` to `_common.sh`, refactoring the hook to use it, then testing it.


Edit

# Resolve this session's perch id via spt whoami (off $OWL_SESSION_ID / $SPT_AGENT_ID).

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


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

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

# 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

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

# 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 (ADR-0020): 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 (ADR-0020) — 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"
}

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

+    *) if [ -n "${SPT_ENDPOINT_ID:-}" ]; then printf bind; else printf seed; fi ;;


Now refactor session-start.sh to use the helper:


case "$src" in
  clear|compact)
    id=$(sptc_self_id "$sid")
    [ -n "$id" ] && "$SPT" api --adapter "$ADAPTER" boundary "$src" "$id" \
      --to-session-id "$sid" >/dev/null 2>&1 || true
    ;;
  *)
    if [ -n "$SPT_ENDPOINT_ID" ]; then
      # spt-hosted (bind path): `spt endpoint run` spawned us into the broker PTY and injected the
      # endpoint id via [env.SPT_ENDPOINT_ID]. The perch already exists — self-register post-spawn
      # by binding our discovered CC session id, NOT seed (no seed file in this path). TODO(perri):
      # if doyle confirms broker-spawned bind needs a capability token, add --token "$SPT_BIND_TOKEN"
      # + the matching [env.*] read directive; PTY/PPID ownership assumed sufficient until then.
      "$SPT" api --adapter "$ADAPTER" bind "$SPT_ENDPOINT_ID" \
        --set-session-id "$sid" >/dev/null 2>&1 || true
    else
      # harness-hosted (seed→listen path): user-launched CC. Record the seed for /sptc:ready|live.
      "$SPT" api --adapter "$ADAPTER" seed --pid "$PPID" --session-id "$sid" >/dev/null 2>&1 || true
    fi
    ;;
esac

case "$(sptc_register_verb "$src")" in
  boundary)
    id=$(sptc_self_id "$sid")
    [ -n "$id" ] && "$SPT" api --adapter "$ADAPTER" boundary "$src" "$id" \
      --to-session-id "$sid" >/dev/null 2>&1 || true
    ;;
  bind)
    # spt-hosted: `spt endpoint run` spawned us into the broker PTY and injected the endpoint id via
    # [env.SPT_ENDPOINT_ID]. The perch already exists — self-register post-spawn by binding our
    # discovered CC session id, NOT seed (no seed file in this path). TODO(perri): if doyle confirms
    # broker-spawned bind needs a capability token, add --token "$SPT_BIND_TOKEN" + the matching
    # [env.*] read directive; PTY/PPID ownership assumed sufficient until then.
    "$SPT" api --adapter "$ADAPTER" bind "$SPT_ENDPOINT_ID" \
      --set-session-id "$sid" >/dev/null 2>&1 || true
    ;;
  seed)
    # harness-hosted (seed→listen path): user-launched CC. Record the seed for /sptc:ready|live.
    "$SPT" api --adapter "$ADAPTER" seed --pid "$PPID" --session-id "$sid" >/dev/null 2>&1 || true
    ;;
esac