Checklist confirms the shapes: `api state busy`/`api state idle` = honest activity (spt-core never infers idleness), and the pull path is **`api poll <id> --include-deferred`** (pull-relay fallback when busy). No PreToolUse in the generic checklist, but legacy-parity mid-turn reachability (F-021) is exactly what you asked for — the checklist validates the state+poll shapes it needs. Let me verify the `--include-deferred` flag (our current UPS poll lacks it):


Bash

cd "C:/Users/decid/Documents/projects/spt-claude-code" && spt api poll --help 2>&1 | head -25

Verify api poll --include-deferred flag


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

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

Arguments:
  <ID>

Options:
      --include-deferred
      --link <LINK>              Shell link token (the relay command-receipt drain)
      --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


`--include-deferred` confirmed. Build plan locked:
- **UserPromptSubmit**: add `api state busy` at turn-start + `--include-deferred` on the drain
- **PreToolUse** (NEW): `api poll --include-deferred` → additionalContext (mid-turn drain)
- **Stop**: `api state idle` (already correct)

Writing the new PreToolUse hook (mirrors the UPS drain half, no skill-injection):


Write

#!/bin/sh
# PreToolUse: MID-TURN message delivery (F-021 reachability parity). A live agent marked BUSY at
# turn-start (UserPromptSubmit) has its inbound messages DEFERRED by spt-core rather than
# idle-PTY-injected; this hook fires before each tool call and DRAINS those deferred messages with
# `api poll <id> --include-deferred` (the checklist pull path — harness-contract/integration-checklist),
# surfacing them as additionalContext so the agent sees a message WHILE working. It is the mid-turn
# complement of the UserPromptSubmit drain (between-turns) — together they cover the legacy spt
# poll+inject-on-PreToolUse reachability. No skill-injection here (no prompt on this event) and NO
# state change (the turn stays busy until Stop). [impl->REQ-DIST-PRETOOL-POLL]
. "$CLAUDE_PLUGIN_ROOT/hooks/_common.sh"

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

# Needs a perch. No perch (session not readied) -> nothing to deliver.
id=$(sptc_self_id "$sid")
[ -z "$id" ] && exit 0

SPT=$(spt_bin)
# --include-deferred: pull messages queued while the perch was busy (the whole point — between-turns
# the UPS drain catches delivered msgs; mid-turn the busy turn defers them, so we must include them).
frames=$("$SPT" api --adapter "$ADAPTER" poll "$id" --session-id "$sid" --include-deferred 2>/dev/null)
[ -z "$frames" ] && exit 0

# Same canonical <EVENT> rendering + spill cap as the UPS drain (sender preserved for reply-correlation).
rendered=$(render_frames "$frames")
spill="${HOME:-${USERPROFILE:-.}}/.claude/sptc-drain-${sid:-unknown}.txt"
sptc_cap_output "$rendered" "$SPTC_CTX_CAP" "$spill"
exit 0


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


Verify PreToolUse message polling and injection


File created successfully at: C:\Users\decid\Documents\projects\spt-claude-code\plugin\sptc\hooks\pre-tool-use.sh (file state is current in your context — no need to Read it back)

#!/bin/sh
# PreToolUse: MID-TURN message delivery (F-021 reachability parity). A live agent marked BUSY at
# turn-start (UserPromptSubmit) has its inbound messages DEFERRED by spt-core rather than
# idle-PTY-injected; this hook fires before each tool call and DRAINS those deferred messages with
# `api poll <id> --include-deferred` (the checklist pull path — harness-contract/integration-checklist),
# surfacing them as additionalContext so the agent sees a message WHILE working. It is the mid-turn
# complement of the UserPromptSubmit drain (between-turns) — together they cover the legacy spt
# poll+inject-on-PreToolUse reachability. No skill-injection here (no prompt on this event) and NO
# state change (the turn stays busy until Stop). [impl->REQ-DIST-PRETOOL-POLL]
. "$CLAUDE_PLUGIN_ROOT/hooks/_common.sh"

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

# Needs a perch. No perch (session not readied) -> nothing to deliver.
id=$(sptc_self_id "$sid")
[ -z "$id" ] && exit 0

SPT=$(spt_bin)
# --include-deferred: pull messages queued while the perch was busy (the whole point — between-turns
# the UPS drain catches delivered msgs; mid-turn the busy turn defers them, so we must include them).
frames=$("$SPT" api --adapter "$ADAPTER" poll "$id" --session-id "$sid" --include-deferred 2>/dev/null)
[ -z "$frames" ] && exit 0

# Same canonical <EVENT> rendering + spill cap as the UPS drain (sender preserved for reply-correlation).
rendered=$(render_frames "$frames")
spill="${HOME:-${USERPROFILE:-.}}/.claude/sptc-drain-${sid:-unknown}.txt"
sptc_cap_output "$rendered" "$SPTC_CTX_CAP" "$spill"
exit 0


Now update UserPromptSubmit: add `api state busy` at turn-start + `--include-deferred` on the drain:


Edit

# 2. Message-drain needs a perch. No perch (session not readied) -> nothing more to deliver.
id=$(sptc_self_id "$sid")
if [ -n "$id" ]; then
  SPT=$(spt_bin)
  frames=$("$SPT" api --adapter "$ADAPTER" poll "$id" --session-id "$sid" 2>/dev/null)

# 2. Message-drain needs a perch. No perch (session not readied) -> nothing more to deliver.
id=$(sptc_self_id "$sid")
if [ -n "$id" ]; then
  SPT=$(spt_bin)
  # TURN-START state: mark the perch BUSY so inbound during this turn DEFERS (queues) instead of
  # idle-PTY-injecting — the precondition for mid-turn PreToolUse delivery (F-021). spt-core never
  # infers activity from terminal quiescence (integration-checklist: honest busy/idle). Stop marks
  # idle again at turn-end. Set busy BEFORE the drain so a message arriving mid-drain also defers.
  "$SPT" api --adapter "$ADAPTER" state busy "$id" --session-id "$sid" >/dev/null 2>&1 || true
  # --include-deferred: also pull anything deferred during the previous busy turn not yet drained.
  frames=$("$SPT" api --adapter "$ADAPTER" poll "$id" --session-id "$sid" --include-deferred 2>/dev/null)


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

# 2. Message-drain needs a perch. No perch (session not readied) -> nothing more to deliver.
id=$(sptc_self_id "$sid")
if [ -n "$id" ]; then
  SPT=$(spt_bin)
  # TURN-START state: mark the perch BUSY so inbound during this turn DEFERS (queues) instead of
  # idle-PTY-injecting — the precondition for mid-turn PreToolUse delivery (F-021). spt-core never
  # infers activity from terminal quiescence (integration-checklist: honest busy/idle). Stop marks
  # idle again at turn-end. Set busy BEFORE the drain so a message arriving mid-drain also defers.
  "$SPT" api --adapter "$ADAPTER" state busy "$id" --session-id "$sid" >/dev/null 2>&1 || true
  # --include-deferred: also pull anything deferred during the previous busy turn not yet drained.
  frames=$("$SPT" api --adapter "$ADAPTER" poll "$id" --session-id "$sid" --include-deferred 2>/dev/null)

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

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

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

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

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

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

-  frames=$("$SPT" api --adapter "$ADAPTER" poll "$id" --session-id "$sid" 2>/dev/null)

+  # TURN-START state: mark the perch BUSY so inbound during this turn DEFERS (queues) instead of

+  # idle-PTY-injecting — the precondition for mid-turn PreToolUse delivery (F-021). spt-core never

+  # infers activity from terminal quiescence (integration-checklist: honest busy/idle). Stop marks

+  # idle again at turn-end. Set busy BEFORE the drain so a message arriving mid-drain also defers.

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

+  # --include-deferred: also pull anything deferred during the previous busy turn not yet drained.

+  frames=$("$SPT" api --adapter "$ADAPTER" poll "$id" --session-id "$sid" --include-deferred 2>/dev/null)


Now register the hook in hooks.json + the manifest. PreToolUse fires on all tools (no matcher):


    "UserPromptSubmit": [
      { "hooks": [ { "type": "command", "command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/user-prompt-submit.sh\"", "shell": "bash" } ] }
    ],

    "UserPromptSubmit": [
      { "hooks": [ { "type": "command", "command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/user-prompt-submit.sh\"", "shell": "bash" } ] }
    ],
    "PreToolUse": [
      { "hooks": [ { "type": "command", "command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/pre-tool-use.sh\"", "shell": "bash" } ] }
    ],


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

    "UserPromptSubmit": [
      { "hooks": [ { "type": "command", "command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/user-prompt-submit.sh\"", "shell": "bash" } ] }
    ],
    "PreToolUse": [
      { "hooks": [ { "type": "command", "command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/pre-tool-use.sh\"", "shell": "bash" } ] }
    ],

+      { "hooks": [ { "type": "command", "command": "sh \"$CLAUDE_PLUGIN_ROOT/hooks/pre-tool-use.sh\"", "shell": "bash" } ] }


[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.UserPromptSubmit]
# TURN-START. Three jobs (plugin user-prompt-submit.sh): (1) mark the perch BUSY (`api state busy`)
# so inbound DEFERS for mid-turn PreToolUse delivery instead of idle-PTY-inject — honest activity,
# spt-core never infers it (integration-checklist); (2) DRAIN the inbox (`api poll … --include-deferred`
# → additionalContext, the representative `fires` below — also pulls anything deferred from the prior
# busy turn); (3) keyword hints + /sptc:X skill-injection. [impl->REQ-DIST-PRETOOL-POLL]
fires = "api poll {session_id} --include-deferred"
reads = ["session_id", "prompt"]
can_inject = true

# PreToolUse — MID-TURN delivery (F-021). Fires before each tool call; drains messages DEFERRED while
# the perch is busy (`api poll … --include-deferred` → additionalContext) so a live agent receives a
# message WHILE working. The mid-turn complement of the UserPromptSubmit between-turns drain — together
# they are the legacy spt poll+inject-on-PreToolUse reachability. No state change (the turn stays busy
# until Stop). [impl->REQ-DIST-PRETOOL-POLL]
[hooks.PreToolUse]
fires = "api poll {session_id} --include-deferred"
reads = ["session_id", "tool_name"]
can_inject = true

[hooks.Stop]
# TURN-END. Idle marker (`api state busy`'s counterpart) — also arms the echo-gate sentinel since Stop
# cannot inject (F-001 §Gap3). Pairs with the UserPromptSubmit busy mark for honest turn-state.
fires = "api state idle"
reads = ["session_id"]
can_inject = false


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)

[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.UserPromptSubmit]
# TURN-START. Three jobs (plugin user-prompt-submit.sh): (1) mark the perch BUSY (`api state busy`)
# so inbound DEFERS for mid-turn PreToolUse delivery instead of idle-PTY-inject — honest activity,
# spt-core never infers it (integration-checklist); (2) DRAIN the inbox (`api poll … --include-deferred`
# → additionalContext, the representative `fires` below — also pulls anything deferred from the prior
# busy turn); (3) keyword hints + /sptc:X skill-injection. [impl->REQ-DIST-PRETOOL-POLL]
fires = "api poll {session_id} --include-deferred"
reads = ["session_id", "prompt"]
can_inject = true

# PreToolUse — MID-TURN delivery (F-021). Fires before each tool call; drains messages DEFERRED while
# the perch is busy (`api poll … --include-deferred` → additionalContext) so a live agent receives a
# message WHILE working. The mid-turn complement of the UserPromptSubmit between-turns drain — together
# they are the legacy spt poll+inject-on-PreToolUse reachability. No state change (the turn stays busy
# until Stop). [impl->REQ-DIST-PRETOOL-POLL]
[hooks.PreToolUse]
fires = "api poll {session_id} --include-deferred"
reads = ["session_id", "tool_name"]
can_inject = true

[hooks.Stop]
# TURN-END. Idle marker (`api state busy`'s counterpart) — also arms the echo-gate sentinel since Stop
# cannot inject (F-001 §Gap3). Pairs with the UserPromptSubmit busy mark for honest turn-state.
fires = "api state idle"
reads = ["session_id"]
can_inject = false

-# Drain the inbox (api poll → additionalContext) and run keyword hints (api hint over the prompt).

+# TURN-START. Three jobs (plugin user-prompt-submit.sh): (1) mark the perch BUSY (`api state busy`)

+# so inbound DEFERS for mid-turn PreToolUse delivery instead of idle-PTY-inject — honest activity,

+# spt-core never infers it (integration-checklist); (2) DRAIN the inbox (`api poll … --include-deferred`

+# → additionalContext, the representative `fires` below — also pulls anything deferred from the prior

+# busy turn); (3) keyword hints + /sptc:X skill-injection. [impl->REQ-DIST-PRETOOL-POLL]

+fires = "api poll {session_id} --include-deferred"

+# PreToolUse — MID-TURN delivery (F-021). Fires before each tool call; drains messages DEFERRED while

+# the perch is busy (`api poll … --include-deferred` → additionalContext) so a live agent receives a

+# message WHILE working. The mid-turn complement of the UserPromptSubmit between-turns drain — together

+# they are the legacy spt poll+inject-on-PreToolUse reachability. No state change (the turn stays busy

+# until Stop). [impl->REQ-DIST-PRETOOL-POLL]

-# Idle marker on turn-end. Stop cannot inject → relay/sentinel fallback (echo-gate, F-001 §Gap3).

+# TURN-END. Idle marker (`api state busy`'s counterpart) — also arms the echo-gate sentinel since Stop

+# cannot inject (F-001 §Gap3). Pairs with the UserPromptSubmit busy mark for honest turn-state.


Now unit tests. Read hooks-parse.sh to add assertions in-style:


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 --- [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"        "ready"  "$(sptc_skill_key '/sptc:ready')"
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:ready for me')"
55	check "skill_key other ns"    ""       "$(sptc_skill_key '/other:ready')"
56	check "skill_key not a cmd"   ""       "$(sptc_skill_key 'ready set go')"
57	check "skill_key empty"       ""       "$(sptc_skill_key '')"
58
59	# --- sptc_register_verb: SessionStart bind/seed/boundary branch --- [unit->REQ-DIST-SHORTCUT-BASENAME]
60	# spt-hosted (endpoint id injected) -> bind; harness-hosted (no id) -> seed; clear/compact -> boundary.
61	unset SPT_ENDPOINT_ID
62	check "verb harness-hosted startup"   "seed"     "$(sptc_register_verb startup)"
63	check "verb harness-hosted empty src" "seed"     "$(sptc_register_verb '')"
64	check "verb boundary clear"           "boundary" "$(sptc_register_verb clear)"
65	check "verb boundary compact"         "boundary" "$(sptc_register_verb compact)"
66	SPT_ENDPOINT_ID=cc-7; export SPT_ENDPOINT_ID
67	check "verb spt-hosted startup -> bind" "bind"     "$(sptc_register_verb startup)"
68	# A /clear inside an spt-hosted session still rebinds via boundary (never re-binds fresh).
69	check "verb spt-hosted clear -> boundary" "boundary" "$(sptc_register_verb clear)"
70	unset SPT_ENDPOINT_ID
71
72	# --- sptc_cap_output: additionalContext spill guard (ADR-0002 Open #2) --- [unit->REQ-UPS-INJECTION]
73	spill="${TMPDIR:-/tmp}/sptc-cap-$$.txt"
74	rm -f "$spill"
75	check "cap under -> passthrough verbatim" "small body" "$(sptc_cap_output 'small body' 9000 "$spill")"
76	check "cap under -> no spill written"     "absent"     "$([ -f "$spill" ] && echo present || echo absent)"
77
78	over=$(sptc_cap_output 'abcdefghij' 5 "$spill")   # 10 bytes > cap 5
79	case "$over" in *'<sptc_overflow'*) m=ok ;; *) m=no ;; esac
80	check "cap over -> emits overflow marker"  "ok"         "$m"
81	check "cap over -> full body spilled"      "abcdefghij" "$(cat "$spill" 2>/dev/null)"
82	case "$over" in *abcdefghij*) leak=leaked ;; *) leak=clean ;; esac
83	check "cap over -> body not inlined"       "clean"      "$leak"
84	check "cap empty -> no output"             ""           "$(sptc_cap_output '' 5 "$spill")"
85	rm -f "$spill"
86
87	# --- SessionStart briefs: pure composition --- [unit->REQ-DIST-SESSIONSTART-BRIEF]
88	# assemble_perch: {id} substitution + block structure.
89	pb=$(sptc_assemble_perch "perri" 'you are {id}.' 'send body' 'roster line')
90	check "assemble_perch structure+subst" \
91	  "$(printf '<sptc-active-perch id="perri">\nyou are perri.\n\nsend body\n\nroster line\n</sptc-active-perch>')" \
92	  "$pb"
93	case "$pb" in *'{id}'*) leftover=present ;; *) leftover=clean ;; esac
94	check "assemble_perch no {id} leftover" "clean" "$leftover"
95	# Every {id} occurrence is substituted (global).
96	multi_id=$(sptc_assemble_perch "kit" 'a {id} b {id}' 'm' 'r')
97	case "$multi_id" in *'a kit b kit'*) g=ok ;; *) g=no ;; esac
98	check "assemble_perch global subst" "ok" "$g"
99
100	# assemble_noperch: ring block structure (no id).
101	nb=$(sptc_assemble_noperch 'ring body' 'roster line')
102	check "assemble_noperch structure" \
103	  "$(printf '<sptc-reach>\nring body\n\nroster line\n</sptc-reach>')" "$nb"
104
105	# is_subagent: empty -> real session (inject); set -> subagent (skip).
106	check "is_subagent empty -> no"   "no"  "$(sptc_is_subagent ''               && echo yes || echo no)"
107	check "is_subagent set -> yes"    "yes" "$(sptc_is_subagent 'general-purpose' && echo yes || echo no)"
108
109	# has_peers_lines: >1 non-empty line on `subnet status` stdin = peers present.
110	check "peers: header+row -> yes" "yes" "$(printf 'SUBNET NODES ENDPOINTS\nSPT_DEV 3 4\n' | sptc_has_peers_lines && echo yes || echo no)"
111	check "peers: single line -> no" "no"  "$(printf 'no subnets\n'                            | sptc_has_peers_lines && echo yes || echo no)"
112	check "peers: empty -> no"       "no"  "$(printf ''                                          | sptc_has_peers_lines && echo yes || echo no)"
113
114	# --- SessionStart briefs: JSON encode + emit --- [unit->REQ-DIST-SESSIONSTART-BRIEF]
115	check "json_escape quotes"    'say \"hi\"' "$(printf '%s' 'say "hi"' | sptc_json_escape)"
116	check "json_escape backslash" 'a\\b'       "$(printf '%s' 'a\b'      | sptc_json_escape)"
117	check "json_escape newline"   'a\nb'       "$(printf 'a\nb'          | sptc_json_escape)"
118
119	check "emit empty -> silent" "" "$(sptc_emit_additional_context '')"
120	check "emit json shape" \
121	  '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"hi \"x\""}}' \
122	  "$(sptc_emit_additional_context 'hi "x"')"
123	check "emit json newline-escaped" \
124	  '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"a\nb"}}' \
125	  "$(sptc_emit_additional_context "$(printf 'a\nb')")"
126
127	# --- checkpoint detection: trigger presence, custom-wake extraction, commune-write guard ---
128	# [unit->REQ-DIST-CHECKPOINT-COMMUNE]
129	check "has_checkpoint single"  "yes" "$(sptc_has_checkpoint 'delta ... !!checkpoint!!'        && echo yes || echo no)"
130	check "has_checkpoint pair"    "yes" "$(sptc_has_checkpoint '!!checkpoint!! wake !!checkpoint!!' && echo yes || echo no)"
131	check "has_checkpoint absent"  "no"  "$(sptc_has_checkpoint 'an ordinary commune delta'        && echo yes || echo no)"
132	check "has_checkpoint empty"   "no"  "$(sptc_has_checkpoint ''                                  && echo yes || echo no)"
133
134	# Single marker -> no custom wake (binary applies the default).
135	check "wake single -> default(empty)" "" "$(sptc_checkpoint_wake 'work ... !!checkpoint!!')"
136	# Paired markers -> the trimmed inner text is the custom wake.
137	check "wake pair -> custom" "Resume T2c now" \
138	  "$(sptc_checkpoint_wake 'body !!checkpoint!! Resume T2c now !!checkpoint!! more')"
139	# Pair embedded in a realistic single-line JSON content blob.
140	check "wake pair in json blob" "wire the hook" \
141	  "$(sptc_checkpoint_wake '{"content":"delta\n!!checkpoint!! wire the hook !!checkpoint!!\nend"}')"
142	check "wake none -> empty"     "" "$(sptc_checkpoint_wake 'no markers here')"
143
144	# commune-write guard: Write to <id>-commune.md only (tolerates JSON-escaped Windows path).
145	check "commune_write match unix"  "yes" \
146	  "$(sptc_is_commune_write Write '/home/x/.claude/perri-commune.md' perri && echo yes || echo no)"
147	check "commune_write match win"   "yes" \
148	  "$(sptc_is_commune_write Write 'C:\\\\Users\\\\d\\\\.claude\\\\perri-commune.md' perri && echo yes || echo no)"
149	check "commune_write wrong file"  "no"  \
150	  "$(sptc_is_commune_write Write '/home/x/.claude/notes.md' perri && echo yes || echo no)"
151	check "commune_write wrong id"    "no"  \
152	  "$(sptc_is_commune_write Write '/home/x/.claude/doyle-commune.md' perri && echo yes || echo no)"
153	check "commune_write not Write"   "no"  \
154	  "$(sptc_is_commune_write Edit '/home/x/.claude/perri-commune.md' perri && echo yes || echo no)"
155	check "commune_write empty id"    "no"  \
156	  "$(sptc_is_commune_write Write '/home/x/.claude/perri-commune.md' '' && echo yes || echo no)"
157
158	# --- resume-context append: skip cleanly on NO-CONTEXT (REQ-DIST-RESUME-CONTEXT) ---
159	# [unit->REQ-DIST-RESUME-CONTEXT]
160	# Non-empty resume -> appended below the brief, newline-joined (verbatim XML sits under the identity).
161	check "append resume present" \
162	  "$(printf '<brief/>\n<live-context>ctx</live-context>')" \
163	  "$(sptc_append_resume '<brief/>' '<live-context>ctx</live-context>')"
164	# Empty resume (NO-CONTEXT) -> brief unchanged, no trailing newline injected.
165	check "append resume empty -> brief verbatim" "<brief/>" "$(sptc_append_resume '<brief/>' '')"
166	# Both empty -> empty.
167	check "append resume both empty" "" "$(sptc_append_resume '' '')"
168
169	[ "$fail" -eq 0 ] && { echo "ALL PASS"; exit 0; } || { echo "FAILURES"; exit 1; }
170

#!/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 --- [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"        "ready"  "$(sptc_skill_key '/sptc:ready')"
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:ready for me')"
check "skill_key other ns"    ""       "$(sptc_skill_key '/other:ready')"
check "skill_key not a cmd"   ""       "$(sptc_skill_key 'ready set go')"
check "skill_key empty"       ""       "$(sptc_skill_key '')"

# --- sptc_register_verb: SessionStart bind/seed/boundary branch --- [unit->REQ-DIST-SHORTCUT-BASENAME]
# spt-hosted (endpoint id injected) -> bind; harness-hosted (no id) -> seed; clear/compact -> boundary.
unset SPT_ENDPOINT_ID
check "verb harness-hosted startup"   "seed"     "$(sptc_register_verb startup)"
check "verb harness-hosted empty src" "seed"     "$(sptc_register_verb '')"
check "verb boundary clear"           "boundary" "$(sptc_register_verb clear)"
check "verb boundary compact"         "boundary" "$(sptc_register_verb compact)"
SPT_ENDPOINT_ID=cc-7; export SPT_ENDPOINT_ID
check "verb spt-hosted startup -> bind" "bind"     "$(sptc_register_verb startup)"
# A /clear inside an spt-hosted session still rebinds via boundary (never re-binds fresh).
check "verb spt-hosted clear -> boundary" "boundary" "$(sptc_register_verb clear)"
unset SPT_ENDPOINT_ID

# --- sptc_cap_output: additionalContext spill guard (ADR-0002 Open #2) --- [unit->REQ-UPS-INJECTION]
spill="${TMPDIR:-/tmp}/sptc-cap-$$.txt"
rm -f "$spill"
check "cap under -> passthrough verbatim" "small body" "$(sptc_cap_output 'small body' 9000 "$spill")"
check "cap under -> no spill written"     "absent"     "$([ -f "$spill" ] && echo present || echo absent)"

over=$(sptc_cap_output 'abcdefghij' 5 "$spill")   # 10 bytes > cap 5
case "$over" in *'<sptc_overflow'*) m=ok ;; *) m=no ;; esac
check "cap over -> emits overflow marker"  "ok"         "$m"
check "cap over -> full body spilled"      "abcdefghij" "$(cat "$spill" 2>/dev/null)"
case "$over" in *abcdefghij*) leak=leaked ;; *) leak=clean ;; esac
check "cap over -> body not inlined"       "clean"      "$leak"
check "cap empty -> no output"             ""           "$(sptc_cap_output '' 5 "$spill")"
rm -f "$spill"

# --- SessionStart briefs: pure composition --- [unit->REQ-DIST-SESSIONSTART-BRIEF]
# assemble_perch: {id} substitution + block structure.
pb=$(sptc_assemble_perch "perri" 'you are {id}.' 'send body' 'roster line')
check "assemble_perch structure+subst" \
  "$(printf '<sptc-active-perch id="perri">\nyou are perri.\n\nsend body\n\nroster line\n</sptc-active-perch>')" \
  "$pb"
case "$pb" in *'{id}'*) leftover=present ;; *) leftover=clean ;; esac
check "assemble_perch no {id} leftover" "clean" "$leftover"
# Every {id} occurrence is substituted (global).
multi_id=$(sptc_assemble_perch "kit" 'a {id} b {id}' 'm' 'r')
case "$multi_id" in *'a kit b kit'*) g=ok ;; *) g=no ;; esac
check "assemble_perch global subst" "ok" "$g"

# assemble_noperch: ring block structure (no id).
nb=$(sptc_assemble_noperch 'ring body' 'roster line')
check "assemble_noperch structure" \
  "$(printf '<sptc-reach>\nring body\n\nroster line\n</sptc-reach>')" "$nb"

# is_subagent: empty -> real session (inject); set -> subagent (skip).
check "is_subagent empty -> no"   "no"  "$(sptc_is_subagent ''               && echo yes || echo no)"
check "is_subagent set -> yes"    "yes" "$(sptc_is_subagent 'general-purpose' && echo yes || echo no)"

# has_peers_lines: >1 non-empty line on `subnet status` stdin = peers present.
check "peers: header+row -> yes" "yes" "$(printf 'SUBNET NODES ENDPOINTS\nSPT_DEV 3 4\n' | sptc_has_peers_lines && echo yes || echo no)"
check "peers: single line -> no" "no"  "$(printf 'no subnets\n'                            | sptc_has_peers_lines && echo yes || echo no)"
check "peers: empty -> no"       "no"  "$(printf ''                                          | sptc_has_peers_lines && echo yes || echo no)"

# --- SessionStart briefs: JSON encode + emit --- [unit->REQ-DIST-SESSIONSTART-BRIEF]
check "json_escape quotes"    'say \"hi\"' "$(printf '%s' 'say "hi"' | sptc_json_escape)"
check "json_escape backslash" 'a\\b'       "$(printf '%s' 'a\b'      | sptc_json_escape)"
check "json_escape newline"   'a\nb'       "$(printf 'a\nb'          | sptc_json_escape)"

check "emit empty -> silent" "" "$(sptc_emit_additional_context '')"
check "emit json shape" \
  '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"hi \"x\""}}' \
  "$(sptc_emit_additional_context 'hi "x"')"
check "emit json newline-escaped" \
  '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"a\nb"}}' \
  "$(sptc_emit_additional_context "$(printf 'a\nb')")"

# --- checkpoint detection: trigger presence, custom-wake extraction, commune-write guard ---
# [unit->REQ-DIST-CHECKPOINT-COMMUNE]
check "has_checkpoint single"  "yes" "$(sptc_has_checkpoint 'delta ... !!checkpoint!!'        && echo yes || echo no)"
check "has_checkpoint pair"    "yes" "$(sptc_has_checkpoint '!!checkpoint!! wake !!checkpoint!!' && echo yes || echo no)"
check "has_checkpoint absent"  "no"  "$(sptc_has_checkpoint 'an ordinary commune delta'        && echo yes || echo no)"
check "has_checkpoint empty"   "no"  "$(sptc_has_checkpoint ''                                  && echo yes || echo no)"

# Single marker -> no custom wake (binary applies the default).
check "wake single -> default(empty)" "" "$(sptc_checkpoint_wake 'work ... !!checkpoint!!')"
# Paired markers -> the trimmed inner text is the custom wake.
check "wake pair -> custom" "Resume T2c now" \
  "$(sptc_checkpoint_wake 'body !!checkpoint!! Resume T2c now !!checkpoint!! more')"
# Pair embedded in a realistic single-line JSON content blob.
check "wake pair in json blob" "wire the hook" \
  "$(sptc_checkpoint_wake '{"content":"delta\n!!checkpoint!! wire the hook !!checkpoint!!\nend"}')"
check "wake none -> empty"     "" "$(sptc_checkpoint_wake 'no markers here')"

# commune-write guard: Write to <id>-commune.md only (tolerates JSON-escaped Windows path).
check "commune_write match unix"  "yes" \
  "$(sptc_is_commune_write Write '/home/x/.claude/perri-commune.md' perri && echo yes || echo no)"
check "commune_write match win"   "yes" \
  "$(sptc_is_commune_write Write 'C:\\\\Users\\\\d\\\\.claude\\\\perri-commune.md' perri && echo yes || echo no)"
check "commune_write wrong file"  "no"  \
  "$(sptc_is_commune_write Write '/home/x/.claude/notes.md' perri && echo yes || echo no)"
check "commune_write wrong id"    "no"  \
  "$(sptc_is_commune_write Write '/home/x/.claude/doyle-commune.md' perri && echo yes || echo no)"
check "commune_write not Write"   "no"  \
  "$(sptc_is_commune_write Edit '/home/x/.claude/perri-commune.md' perri && echo yes || echo no)"
check "commune_write empty id"    "no"  \
  "$(sptc_is_commune_write Write '/home/x/.claude/perri-commune.md' '' && echo yes || echo no)"

# --- resume-context append: skip cleanly on NO-CONTEXT (REQ-DIST-RESUME-CONTEXT) ---
# [unit->REQ-DIST-RESUME-CONTEXT]
# Non-empty resume -> appended below the brief, newline-joined (verbatim XML sits under the identity).
check "append resume present" \
  "$(printf '<brief/>\n<live-context>ctx</live-context>')" \
  "$(sptc_append_resume '<brief/>' '<live-context>ctx</live-context>')"
# Empty resume (NO-CONTEXT) -> brief unchanged, no trailing newline injected.
check "append resume empty -> brief verbatim" "<brief/>" "$(sptc_append_resume '<brief/>' '')"
# Both empty -> empty.
check "append resume both empty" "" "$(sptc_append_resume '' '')"

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


My changes are wiring + a `state busy` call (not new pure functions), so the unit evidence is structural assertions. Add a PreToolUse-reachability wiring block to hooks-parse.sh:


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

# --- PreToolUse mid-turn reachability wiring (F-021) --- [unit->REQ-DIST-PRETOOL-POLL]
# The busy/idle turn-state lifecycle + the PreToolUse deferred-drain. Structural (the hook files +
# manifest declaration are the artifacts; the api commands run against a live perch at the int tier).
HK="$(dirname "$0")/../plugin/sptc/hooks"
MAN="$(dirname "$0")/../adapter/claude-spt.toml"
grep -q '"PreToolUse"' "$HK/hooks.json" && r=yes || r=no
check "hooks.json declares PreToolUse" "yes" "$r"
grep -q 'pre-tool-use.sh' "$HK/hooks.json" && r=yes || r=no
check "PreToolUse routes to pre-tool-use.sh" "yes" "$r"
grep -q 'poll .*--include-deferred' "$HK/pre-tool-use.sh" && r=yes || r=no
check "pre-tool-use drains deferred (api poll --include-deferred)" "yes" "$r"
grep -q 'state busy' "$HK/user-prompt-submit.sh" && r=yes || r=no
check "UPS marks perch busy at turn-start (api state busy)" "yes" "$r"
grep -q 'poll .*--include-deferred' "$HK/user-prompt-submit.sh" && r=yes || r=no
check "UPS drain includes deferred" "yes" "$r"
grep -q 'state idle' "$HK/stop.sh" && r=yes || r=no
check "Stop marks perch idle at turn-end (api state idle)" "yes" "$r"
grep -q '^\[hooks\.PreToolUse\]' "$MAN" && r=yes || r=no
check "manifest declares [hooks.PreToolUse]" "yes" "$r"

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


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

# --- PreToolUse mid-turn reachability wiring (F-021) --- [unit->REQ-DIST-PRETOOL-POLL]
# The busy/idle turn-state lifecycle + the PreToolUse deferred-drain. Structural (the hook files +
# manifest declaration are the artifacts; the api commands run against a live perch at the int tier).
HK="$(dirname "$0")/../plugin/sptc/hooks"
MAN="$(dirname "$0")/../adapter/claude-spt.toml"
grep -q '"PreToolUse"' "$HK/hooks.json" && r=yes || r=no
check "hooks.json declares PreToolUse" "yes" "$r"
grep -q 'pre-tool-use.sh' "$HK/hooks.json" && r=yes || r=no
check "PreToolUse routes to pre-tool-use.sh" "yes" "$r"
grep -q 'poll .*--include-deferred' "$HK/pre-tool-use.sh" && r=yes || r=no
check "pre-tool-use drains deferred (api poll --include-deferred)" "yes" "$r"
grep -q 'state busy' "$HK/user-prompt-submit.sh" && r=yes || r=no
check "UPS marks perch busy at turn-start (api state busy)" "yes" "$r"
grep -q 'poll .*--include-deferred' "$HK/user-prompt-submit.sh" && r=yes || r=no
check "UPS drain includes deferred" "yes" "$r"
grep -q 'state idle' "$HK/stop.sh" && r=yes || r=no
check "Stop marks perch idle at turn-end (api state idle)" "yes" "$r"
grep -q '^\[hooks\.PreToolUse\]' "$MAN" && r=yes || r=no
check "manifest declares [hooks.PreToolUse]" "yes" "$r"

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

check "append resume both empty" "" "$(sptc_append_resume '' '')"

+# --- PreToolUse mid-turn reachability wiring (F-021) --- [unit->REQ-DIST-PRETOOL-POLL]

+# The busy/idle turn-state lifecycle + the PreToolUse deferred-drain. Structural (the hook files +

+# manifest declaration are the artifacts; the api commands run against a live perch at the int tier).

+grep -q '"PreToolUse"' "$HK/hooks.json" && r=yes || r=no

+check "hooks.json declares PreToolUse" "yes" "$r"

+grep -q 'pre-tool-use.sh' "$HK/hooks.json" && r=yes || r=no

+check "PreToolUse routes to pre-tool-use.sh" "yes" "$r"

+grep -q 'poll .*--include-deferred' "$HK/pre-tool-use.sh" && r=yes || r=no

+check "pre-tool-use drains deferred (api poll --include-deferred)" "yes" "$r"

+grep -q 'state busy' "$HK/user-prompt-submit.sh" && r=yes || r=no

+check "UPS marks perch busy at turn-start (api state busy)" "yes" "$r"

+grep -q 'poll .*--include-deferred' "$HK/user-prompt-submit.sh" && r=yes || r=no

+check "UPS drain includes deferred" "yes" "$r"

+grep -q 'state idle' "$HK/stop.sh" && r=yes || r=no

+check "Stop marks perch idle at turn-end (api state idle)" "yes" "$r"

+grep -q '^\[hooks\.PreToolUse\]' "$MAN" && r=yes || r=no

+check "manifest declares [hooks.PreToolUse]" "yes" "$r"


Activate REQ-DIST-PRETOOL-POLL:


required_stages = []  # Reachability-parity item (F-021). ACTIVATE at the reachability-parity milestone. impl: NEW PreToolUse hook (manifest [hooks.PreToolUse] + hooks.json) firing `api poll {session_id}` -> additionalContext (mirrors the UserPromptSubmit drain). unit: tests/hooks-parse.sh. int: a mid-turn message delivered as additionalContext on a real live agent. `api poll` is already on the public surface — adapter wiring, NOT an spt-core gap.

required_stages = ["impl", "unit"]  # Reachability-parity item (F-021) — ACTIVATED 2026-06-25. Two coupled halves: (a) honest TURN-STATE lifecycle — UserPromptSubmit marks `api state busy` at turn-start, Stop marks `api state idle` at turn-end (spt-core never infers activity from quiescence — harness-contract/integration-checklist), so inbound DEFERS during a busy turn instead of idle-PTY-injecting; (b) the NEW PreToolUse hook drains those deferred messages MID-turn (`api poll {session_id} --include-deferred` -> additionalContext), the legacy spt poll+inject-on-PreToolUse reachability. UPS drain also gains --include-deferred. impl: plugin/sptc/hooks/pre-tool-use.sh + the `api state busy` add in user-prompt-submit.sh + hooks.json PreToolUse entry + manifest [hooks.PreToolUse] / [hooks.UserPromptSubmit] busy+include-deferred. unit: tests/hooks-parse.sh PreToolUse-reachability wiring block (PreToolUse declared + routed, deferred-drain shape, UPS busy + include-deferred, Stop idle, manifest [hooks.PreToolUse]). int DEFERRED (same bucket as the other harness-glue E2Es — needs the NEW plugin installed + a real live CC): a mid-turn `spt send` to a busy live agent surfaces as additionalContext on the next tool call. `api state` + `api poll --include-deferred` are already on the public surface — adapter wiring, NOT an spt-core gap. Shapes verified against the published integration-checklist 2026-06-25.


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

title = "claude-spt wires a PreToolUse hook firing `api poll` so a live agent receives messages MID-TURN (legacy-parity reachability; today claude-spt drains only on UserPromptSubmit = between turns). F-021."

-required_stages = []  # Reachability-parity item (F-021). ACTIVATE at the reachability-parity milestone. impl: NEW PreToolUse hook (manifest [hooks.PreToolUse] + hooks.json) firing `api poll {session_id}` -> additionalContext (mirrors the UserPromptSubmit drain). unit: tests/hooks-parse.sh. int: a mid-turn message delivered as additionalContext on a real live agent. `api poll` is already on the public surface — adapter wiring, NOT an spt-core gap.

+required_stages = ["impl", "unit"]  # Reachability-parity item (F-021) — ACTIVATED 2026-06-25. Two coupled halves: (a) honest TURN-STATE lifecycle — UserPromptSubmit marks `api state busy` at turn-start, Stop marks `api state idle` at turn-end (spt-core never infers activity from quiescence — harness-contract/integration-checklist), so inbound DEFERS during a busy turn instead of idle-PTY-injecting; (b) the NEW PreToolUse hook drains those deferred messages MID-turn (`api poll {session_id} --include-deferred` -> additionalContext), the legacy spt poll+inject-on-PreToolUse reachability. UPS drain also gains --include-deferred. impl: plugin/sptc/hooks/pre-tool-use.sh + the `api state busy` add in user-prompt-submit.sh + hooks.json PreToolUse entry + manifest [hooks.PreToolUse] / [hooks.UserPromptSubmit] busy+include-deferred. unit: tests/hooks-parse.sh PreToolUse-reachability wiring block (PreToolUse declared + routed, deferred-drain shape, UPS busy + include-deferred, Stop idle, manifest [hooks.PreToolUse]). int DEFERRED (same bucket as the other harness-glue E2Es — needs the NEW plugin installed + a real live CC): a mid-turn `spt send` to a busy live agent surfaces as additionalContext on the next tool call. `api state` + `api poll --include-deferred` are already on the public surface — adapter wiring, NOT an spt-core gap. Shapes verified against the published integration-checklist 2026-06-25.


Run the full gate suite:


Bash

cd "C:/Users/decid/Documents/projects/spt-claude-code" && sh ci/run-gates.sh 2>&1 | grep -E "GATE|FAIL|RESULT|coverage green|PreToolUse|reachability|PRETOOL|ALL PASS"; echo "=== PRETOOL stage ==="; (./traceable-reqs check 2>&1 || traceable-reqs check 2>&1) | grep "PRETOOL-POLL"

Full gates + PreToolUse REQ coverage


=== GATE: shell-syntax (sh -n) ===
=== GATE: unit tests (tests/*.sh) ===
=== GATE: digest-extractor (cargo build + test) ===
=== GATE: psyche-runner (cargo build + test) ===
=== GATE: idle-translate (cargo build + test) ===
=== GATE: traceable-reqs check (requirement coverage) ===
ok  coverage green
=== GATE: skeleton-validate (cplugs installability) ===
=== GATE: manifest-schema ===
=== GATE: docs-drift ===
=== RESULT: PASS ===
=== PRETOOL stage ===
  [OK] REQ-DIST-PRETOOL-POLL  required: [impl, unit]  stages: -doc +impl +unit -int


All green. Verify the new wiring assertions actually pass (run-gates pipes silently):


cd "C:/Users/decid/Documents/projects/spt-claude-code" && sh tests/hooks-parse.sh 2>&1 | grep -iE "PreToolUse|busy|idle|deferred|FAIL|ALL PASS"

Confirm new PreToolUse assertions pass


ok   hooks.json declares PreToolUse
ok   PreToolUse routes to pre-tool-use.sh
ok   pre-tool-use drains deferred (api poll --include-deferred)
ok   UPS marks perch busy at turn-start (api state busy)
ok   UPS drain includes deferred
ok   Stop marks perch idle at turn-end (api state idle)
ok   manifest declares [hooks.PreToolUse]
ALL PASS


All 7 pass. Commit the PreToolUse + turn-state work:


cd "C:/Users/decid/Documents/projects/spt-claude-code" && git add -A && git commit -q -F - <<'EOF'
feat(reachability): PreToolUse mid-turn polling + honest busy/idle turn-state (F-021)

REQ-DIST-PRETOOL-POLL (activated +impl +unit). Closes the mid-turn half of
live-agent reachability — today claude-spt drains only on UserPromptSubmit
(between turns), so a live agent can't be reached WHILE working.

Two coupled halves (shapes verified against the published harness-contract
integration-checklist):

1. Honest turn-state — UserPromptSubmit now marks `api state busy` at turn-start;
   Stop already marks `api state idle` at turn-end. spt-core never infers activity
   from terminal quiescence, so the busy mark is what makes inbound DEFER during a
   turn instead of idle-PTY-injecting (which would clobber the working session).

2. PreToolUse drain — NEW pre-tool-use.sh fires before each tool call and pulls
   deferred messages (`api poll <id> --include-deferred` → additionalContext), the
   legacy spt poll+inject-on-PreToolUse path. UPS drain also gains
   --include-deferred (catches anything deferred from the prior busy turn).

Wiring: hooks.json PreToolUse entry + manifest [hooks.PreToolUse] +
[hooks.UserPromptSubmit] (busy + include-deferred). `api state` /
`api poll --include-deferred` are already public — adapter wiring, not an
spt-core gap.

unit: tests/hooks-parse.sh reachability-wiring block (7 assertions, all green).
int DEFERRED: a mid-turn `spt send` to a busy live agent surfacing as
additionalContext — needs the new plugin installed + real live CC (same
harness-glue dogfood bucket as the SessionStart/write-detect E2Es). NB the
hooks.json change is structural → cplugs skeleton bump at the v0.8.0 cut.

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

Commit PreToolUse + turn-state


d3d82c0 feat(reachability): PreToolUse mid-turn polling + honest busy/idle turn-state (F-021)
bd7c96c feat(unify): U5 — README install UX (per-OS chains + agent-prompt skin)
0009cfd docs(plan): UNIFY-CONSOLIDATE progress — U1/U6/U2 DONE, U4/U5/U3+cut remain


Both done. Committed `d3d82c0`.

## PreToolUse polling — BUILT
New `pre-tool-use.sh` → `api poll <id> --include-deferred` → additionalContext, fires before every tool call. Mirrors the UPS drain. hooks.json + manifest `[hooks.PreToolUse]` wired. REQ-DIST-PRETOOL-POLL **+impl+unit** (7 wiring assertions green). int deferred (needs live CC).

## busy/idle state — was BROKEN, now FIXED
Your instinct was right, and it was worse than "confirm" — it was a **latent bug that would've made PreToolUse polling silently wrong**:

- **Stop → `api state idle`** ✅ already present.
- **UserPromptSubmit → never marked busy** ❌ — fired `api poll` only. Endpoint stayed idle through every turn.

Consequence if I'd shipped PreToolUse poll without fixing this: endpoint idle during a turn → a mid-turn inbound hits the **idle-translation PTY-inject** (clobbers the working session) instead of deferring for the drain. PreToolUse poll would've found nothing.

**Fixed:** UPS now marks `api state busy` at turn-start (before the drain, so a message landing mid-drain also defers). Turn lifecycle is now honest: busy@UPS → idle@Stop.

## checklist cross-check (your last note)
Fetched `harness-contract/integration-checklist.html`. Confirmed:
- `api state busy`/`api state idle` = **honest activity** — "spt-core never infers idleness from terminal quiescence." Validates the busy@UPS / idle@Stop shapes.
- Pull path is **`api poll <id> --include-deferred`** ("pull-relay fallback when busy"). **Our existing UPS drain lacked `--include-deferred`** — would've missed deferred messages. Added it to both UPS and the new PreToolUse drain.
- No PreToolUse in the generic checklist (it's tool-lifecycle, not in the user-driven idle model) — but that's exactly the legacy-parity F-021 path you scoped; the checklist validates the state+poll shapes it depends on.

**One release note:** hooks.json gained a hook → structural change → cplugs skeleton bump required at the v0.8.0 cut (batches with U4's thinning).

---
**Overall:** UNIFY-CONSOLIDATE 4/6 (U1, U6, U2, U5) + PreToolUse reachability done. Remaining: **U4** (brief-timing question) + **U3 + cut** (your repo-rename action). Want me to tackle U4 next, or pause for the rename?


are there any claude code hooks which fire on a tool event, like when the Monitor tool reports? that's another transition where `api state busy` should be applied, because it starts a new agent turn -alternatively, if that hook doesn't exist, then PreToolUse should apply the busy state too, to help cover our bases. unless you can think of a better way? -


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


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


Good catch — this is a real gap. A **Monitor-delivered message triggers a new agent turn with NO UserPromptSubmit** (it's not a user prompt). So that turn starts with the perch still idle (from the prior Stop) → a *second* message arriving mid-turn would idle-PTY-inject and clobber.

Let me reason about the hook options:

- **No universal "turn-start" hook exists** in Claude Code. UserPromptSubmit = user turns only. There's no event that fires on a Monitor-triggered turn start.
- **PreToolUse fires before every tool call** — so the first tool call of *any* turn (user-initiated, Monitor-triggered, auto-continue) is the earliest reliable "I'm working now" signal when UPS didn't fire.

So your alternative is the right call, and I don't see a better one: **PreToolUse should set `busy` too** (idempotent — no-op if already busy), before the drain. Belt-and-suspenders with UPS:
- UPS busy → earliest mark for user turns (covers even text-only user turns).
- PreToolUse busy → covers non-user turns (Monitor events) on their first tool call.

One honest residual: a *non-user, text-only* turn (Monitor event answered with zero tools) sets neither — but that's low-risk (no tool/PTY interleave to clobber, and Stop re-idles after). No hook covers it cleanly; not worth contorting for.

Implementing — add `state busy` before the drain in PreToolUse:


Edit

SPT=$(spt_bin)
# --include-deferred: pull messages queued while the perch was busy (the whole point — between-turns
# the UPS drain catches delivered msgs; mid-turn the busy turn defers them, so we must include them).
frames=$("$SPT" api --adapter "$ADAPTER" poll "$id" --session-id "$sid" --include-deferred 2>/dev/null)

SPT=$(spt_bin)
# Mark BUSY here too (idempotent). UserPromptSubmit covers USER turns, but a turn can also start with
# NO user prompt — a Monitor-delivered message (the live-agent relay pipe) triggers a new turn without
# firing UserPromptSubmit, so the perch would still read idle from the prior Stop and a mid-turn
# inbound would idle-PTY-inject (clobber) instead of deferring. PreToolUse fires before every tool
# call, so the FIRST tool call of ANY turn (user, Monitor-triggered, auto-continue) marks busy — the
# earliest reliable turn-active signal when UPS didn't fire. Set BEFORE the drain so inbound landing
# mid-drain also defers. (Residual: a non-user text-only turn marks neither — low risk, no tool/PTY
# interleave; Stop re-idles after.) [impl->REQ-DIST-PRETOOL-POLL]
"$SPT" api --adapter "$ADAPTER" state busy "$id" --session-id "$sid" >/dev/null 2>&1 || true
# --include-deferred: pull messages queued while the perch was busy (the whole point — between-turns
# the UPS drain catches delivered msgs; mid-turn the busy turn defers them, so we must include them).
frames=$("$SPT" api --adapter "$ADAPTER" poll "$id" --session-id "$sid" --include-deferred 2>/dev/null)


are there any claude code hooks which fire on a tool event, like when the Monitor tool reports? that's another transition where `api state busy` should be applied, because it starts a new agent turn -…


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

SPT=$(spt_bin)
# Mark BUSY here too (idempotent). UserPromptSubmit covers USER turns, but a turn can also start with
# NO user prompt — a Monitor-delivered message (the live-agent relay pipe) triggers a new turn without
# firing UserPromptSubmit, so the perch would still read idle from the prior Stop and a mid-turn
# inbound would idle-PTY-inject (clobber) instead of deferring. PreToolUse fires before every tool
# call, so the FIRST tool call of ANY turn (user, Monitor-triggered, auto-continue) marks busy — the
# earliest reliable turn-active signal when UPS didn't fire. Set BEFORE the drain so inbound landing
# mid-drain also defers. (Residual: a non-user text-only turn marks neither — low risk, no tool/PTY
# interleave; Stop re-idles after.) [impl->REQ-DIST-PRETOOL-POLL]
"$SPT" api --adapter "$ADAPTER" state busy "$id" --session-id "$sid" >/dev/null 2>&1 || true
# --include-deferred: pull messages queued while the perch was busy (the whole point — between-turns
# the UPS drain catches delivered msgs; mid-turn the busy turn defers them, so we must include them).
frames=$("$SPT" api --adapter "$ADAPTER" poll "$id" --session-id "$sid" --include-deferred 2>/dev/null)

#!/bin/sh
# PreToolUse: MID-TURN message delivery (F-021 reachability parity). A live agent marked BUSY at
# turn-start (UserPromptSubmit) has its inbound messages DEFERRED by spt-core rather than
# idle-PTY-injected; this hook fires before each tool call and DRAINS those deferred messages with
# `api poll <id> --include-deferred` (the checklist pull path — harness-contract/integration-checklist),
# surfacing them as additionalContext so the agent sees a message WHILE working. It is the mid-turn
# complement of the UserPromptSubmit drain (between-turns) — together they cover the legacy spt
# poll+inject-on-PreToolUse reachability. No skill-injection here (no prompt on this event) and NO
# state change (the turn stays busy until Stop). [impl->REQ-DIST-PRETOOL-POLL]
. "$CLAUDE_PLUGIN_ROOT/hooks/_common.sh"

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

# Needs a perch. No perch (session not readied) -> nothing to deliver.
id=$(sptc_self_id "$sid")
[ -z "$id" ] && exit 0

SPT=$(spt_bin)
# --include-deferred: pull messages queued while the perch was busy (the whole point — between-turns
# the UPS drain catches delivered msgs; mid-turn the busy turn defers them, so we must include them).
frames=$("$SPT" api --adapter "$ADAPTER" poll "$id" --session-id "$sid" --include-deferred 2>/dev/null)
[ -z "$frames" ] && exit 0

# Same canonical <EVENT> rendering + spill cap as the UPS drain (sender preserved for reply-correlation).
rendered=$(render_frames "$frames")
spill="${HOME:-${USERPROFILE:-.}}/.claude/sptc-drain-${sid:-unknown}.txt"
sptc_cap_output "$rendered" "$SPTC_CTX_CAP" "$spill"
exit 0

+# Mark BUSY here too (idempotent). UserPromptSubmit covers USER turns, but a turn can also start with

+# NO user prompt — a Monitor-delivered message (the live-agent relay pipe) triggers a new turn without

+# firing UserPromptSubmit, so the perch would still read idle from the prior Stop and a mid-turn

+# inbound would idle-PTY-inject (clobber) instead of deferring. PreToolUse fires before every tool

+# call, so the FIRST tool call of ANY turn (user, Monitor-triggered, auto-continue) marks busy — the

+# earliest reliable turn-active signal when UPS didn't fire. Set BEFORE the drain so inbound landing

+# mid-drain also defers. (Residual: a non-user text-only turn marks neither — low risk, no tool/PTY

+# interleave; Stop re-idles after.) [impl->REQ-DIST-PRETOOL-POLL]

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


Update the manifest `[hooks.PreToolUse]` note (now also marks busy) + add a test assertion:


# PreToolUse — MID-TURN delivery (F-021). Fires before each tool call; drains messages DEFERRED while
# the perch is busy (`api poll … --include-deferred` → additionalContext) so a live agent receives a
# message WHILE working. The mid-turn complement of the UserPromptSubmit between-turns drain — together
# they are the legacy spt poll+inject-on-PreToolUse reachability. No state change (the turn stays busy
# until Stop). [impl->REQ-DIST-PRETOOL-POLL]
[hooks.PreToolUse]
fires = "api poll {session_id} --include-deferred"
reads = ["session_id", "tool_name"]
can_inject = true

# PreToolUse — MID-TURN delivery + turn-start busy fallback (F-021). Fires before each tool call: (1)
# marks the perch BUSY (`api state busy`) — idempotent, and the ONLY busy mark for a turn that started
# WITHOUT a UserPromptSubmit (a Monitor-delivered message triggers a new turn with no user prompt; the
# first tool call is then the earliest turn-active signal); (2) drains messages DEFERRED while busy
# (`api poll … --include-deferred` → additionalContext, the representative `fires` below) so a live
# agent receives a message WHILE working. The mid-turn complement of the UserPromptSubmit between-turns
# drain — together the legacy spt poll+inject-on-PreToolUse reachability. [impl->REQ-DIST-PRETOOL-POLL]
[hooks.PreToolUse]
fires = "api poll {session_id} --include-deferred"
reads = ["session_id", "tool_name"]
can_inject = true


# PreToolUse — MID-TURN delivery (F-021). Fires before each tool call; drains messages DEFERRED while
# the perch is busy (`api poll … --include-deferred` → additionalContext) so a live agent receives a
# message WHILE working. The mid-turn complement of the UserPromptSubmit between-turns drain — together
# they are the legacy spt poll+inject-on-PreToolUse reachability. No state change (the turn stays busy
# until Stop). [impl->REQ-DIST-PRETOOL-POLL]
[hooks.PreToolUse]
fires = "api poll {session_id} --include-deferred"
reads = ["session_id", "tool_name"]
can_inject = true

# PreToolUse — MID-TURN delivery + turn-start busy fallback (F-021). Fires before each tool call: (1)
# marks the perch BUSY (`api state busy`) — idempotent, and the ONLY busy mark for a turn that started
# WITHOUT a UserPromptSubmit (a Monitor-delivered message triggers a new turn with no user prompt; the
# first tool call is then the earliest turn-active signal); (2) drains messages DEFERRED while busy
# (`api poll … --include-deferred` → additionalContext, the representative `fires` below) so a live
# agent receives a message WHILE working. The mid-turn complement of the UserPromptSubmit between-turns
# drain — together the legacy spt poll+inject-on-PreToolUse reachability. [impl->REQ-DIST-PRETOOL-POLL]
[hooks.PreToolUse]
fires = "api poll {session_id} --include-deferred"
reads = ["session_id", "tool_name"]
can_inject = true

-# PreToolUse — MID-TURN delivery (F-021). Fires before each tool call; drains messages DEFERRED while

-# the perch is busy (`api poll … --include-deferred` → additionalContext) so a live agent receives a

-# message WHILE working. The mid-turn complement of the UserPromptSubmit between-turns drain — together

-# they are the legacy spt poll+inject-on-PreToolUse reachability. No state change (the turn stays busy

-# until Stop). [impl->REQ-DIST-PRETOOL-POLL]

+# PreToolUse — MID-TURN delivery + turn-start busy fallback (F-021). Fires before each tool call: (1)

+# marks the perch BUSY (`api state busy`) — idempotent, and the ONLY busy mark for a turn that started

+# WITHOUT a UserPromptSubmit (a Monitor-delivered message triggers a new turn with no user prompt; the

+# first tool call is then the earliest turn-active signal); (2) drains messages DEFERRED while busy

+# (`api poll … --include-deferred` → additionalContext, the representative `fires` below) so a live

+# agent receives a message WHILE working. The mid-turn complement of the UserPromptSubmit between-turns

+# drain — together the legacy spt poll+inject-on-PreToolUse reachability. [impl->REQ-DIST-PRETOOL-POLL]


grep -q 'poll .*--include-deferred' "$HK/pre-tool-use.sh" && r=yes || r=no
check "pre-tool-use drains deferred (api poll --include-deferred)" "yes" "$r"

grep -q 'poll .*--include-deferred' "$HK/pre-tool-use.sh" && r=yes || r=no
check "pre-tool-use drains deferred (api poll --include-deferred)" "yes" "$r"
# PreToolUse also marks busy (covers Monitor-triggered turns with no UserPromptSubmit).
grep -q 'state busy' "$HK/pre-tool-use.sh" && r=yes || r=no
check "pre-tool-use marks busy (non-user turn-start fallback)" "yes" "$r"
# And it sets busy BEFORE draining (so a message landing mid-drain also defers).
awk '/state busy/{b=NR} /poll .*--include-deferred/{p=NR} END{exit !(b && p && b<p)}' "$HK/pre-tool-use.sh" && r=yes || r=no
check "pre-tool-use sets busy before the drain" "yes" "$r"


grep -q 'poll .*--include-deferred' "$HK/pre-tool-use.sh" && r=yes || r=no
check "pre-tool-use drains deferred (api poll --include-deferred)" "yes" "$r"
# PreToolUse also marks busy (covers Monitor-triggered turns with no UserPromptSubmit).
grep -q 'state busy' "$HK/pre-tool-use.sh" && r=yes || r=no
check "pre-tool-use marks busy (non-user turn-start fallback)" "yes" "$r"
# And it sets busy BEFORE draining (so a message landing mid-drain also defers).
awk '/state busy/{b=NR} /poll .*--include-deferred/{p=NR} END{exit !(b && p && b<p)}' "$HK/pre-tool-use.sh" && r=yes || r=no
check "pre-tool-use sets busy before the drain" "yes" "$r"

check "PreToolUse routes to pre-tool-use.sh" "yes" "$r"

+# PreToolUse also marks busy (covers Monitor-triggered turns with no UserPromptSubmit).

+grep -q 'state busy' "$HK/pre-tool-use.sh" && r=yes || r=no

+check "pre-tool-use marks busy (non-user turn-start fallback)" "yes" "$r"

+# And it sets busy BEFORE draining (so a message landing mid-drain also defers).

+awk '/state busy/{b=NR} /poll .*--include-deferred/{p=NR} END{exit !(b && p && b<p)}' "$HK/pre-tool-use.sh" && r=yes || r=no

+check "pre-tool-use sets busy before the drain" "yes" "$r"

grep -q 'state busy' "$HK/user-prompt-submit.sh" && r=yes || r=no

check "UPS marks perch busy at turn-start (api state busy)" "yes" "$r"

grep -q 'poll .*--include-deferred' "$HK/user-prompt-submit.sh" && r=yes || r=no


Update the REQ note for the non-user-turn coverage, then run gates + the test:


impl: plugin/sptc/hooks/pre-tool-use.sh + the `api state busy` add in user-prompt-submit.sh + hooks.json PreToolUse entry + manifest [hooks.PreToolUse] / [hooks.UserPromptSubmit] busy+include-deferred.

PreToolUse ALSO marks `api state busy` (idempotent) — the ONLY busy mark for a turn that starts WITHOUT a UserPromptSubmit (a Monitor-delivered message triggers a new turn with no user prompt; the first tool call is the earliest turn-active signal). Residual: a non-user text-only turn marks neither (low risk — no tool/PTY interleave; Stop re-idles). impl: plugin/sptc/hooks/pre-tool-use.sh (state busy before the drain) + the `api state busy` add in user-prompt-submit.sh + hooks.json PreToolUse entry + manifest [hooks.PreToolUse] / [hooks.UserPromptSubmit] busy+include-deferred.


title = "claude-spt wires a PreToolUse hook firing `api poll` so a live agent receives messages MID-TURN (legacy-parity reachability; today claude-spt drains only on UserPromptSubmit = between turns). F-021."

-required_stages = ["impl", "unit"]  # Reachability-parity item (F-021) — ACTIVATED 2026-06-25. Two coupled halves: (a) honest TURN-STATE lifecycle — UserPromptSubmit marks `api state busy` at turn-start, Stop marks `api state idle` at turn-end (spt-core never infers activity from quiescence — harness-contract/integration-checklist), so inbound DEFERS during a busy turn instead of idle-PTY-injecting; (b) the NEW PreToolUse hook drains those deferred messages MID-turn (`api poll {session_id} --include-deferred` -> additionalContext), the legacy spt poll+inject-on-PreToolUse reachability. UPS drain also gains --include-deferred. impl: plugin/sptc/hooks/pre-tool-use.sh + the `api state busy` add in user-prompt-submit.sh + hooks.json PreToolUse entry + manifest [hooks.PreToolUse] / [hooks.UserPromptSubmit] busy+include-deferred. unit: tests/hooks-parse.sh PreToolUse-reachability wiring block (PreToolUse declared + routed, deferred-drain shape, UPS busy + include-deferred, Stop idle, manifest [hooks.PreToolUse]). int DEFERRED (same bucket as the other harness-glue E2Es — needs the NEW plugin installed + a real live CC): a mid-turn `spt send` to a busy live agent surfaces as additionalContext on the next tool call. `api state` + `api poll --include-deferred` are already on the public surface — adapter wiring, NOT an spt-core gap. Shapes verified against the published integration-checklist 2026-06-25.

+required_stages = ["impl", "unit"]  # Reachability-parity item (F-021) — ACTIVATED 2026-06-25. Two coupled halves: (a) honest TURN-STATE lifecycle — UserPromptSubmit marks `api state busy` at turn-start, Stop marks `api state idle` at turn-end (spt-core never infers activity from quiescence — harness-contract/integration-checklist), so inbound DEFERS during a busy turn instead of idle-PTY-injecting; (b) the NEW PreToolUse hook drains those deferred messages MID-turn (`api poll {session_id} --include-deferred` -> additionalContext), the legacy spt poll+inject-on-PreToolUse reachability. UPS drain also gains --include-deferred. PreToolUse ALSO marks `api state busy` (idempotent) — the ONLY busy mark for a turn that starts WITHOUT a UserPromptSubmit (a Monitor-delivered message triggers a new turn with no user prompt; the first tool call is the earliest turn-active signal). Residual: a non-user text-only turn marks neither (low risk — no tool/PTY interleave; Stop re-idles). impl: plugin/sptc/hooks/pre-tool-use.sh (state busy before the drain) + the `api state busy` add in user-prompt-submit.sh + hooks.json PreToolUse entry + manifest [hooks.PreToolUse] / [hooks.UserPromptSubmit] busy+include-deferred. unit: tests/hooks-parse.sh PreToolUse-reachability wiring block (PreToolUse declared + routed, deferred-drain shape, UPS busy + include-deferred, Stop idle, manifest [hooks.PreToolUse]). int DEFERRED (same bucket as the other harness-glue E2Es — needs the NEW plugin installed + a real live CC): a mid-turn `spt send` to a busy live agent surfaces as additionalContext on the next tool call. `api state` + `api poll --include-deferred` are already on the public surface — adapter wiring, NOT an spt-core gap. Shapes verified against the published integration-checklist 2026-06-25.


Bash

cd "C:/Users/decid/Documents/projects/spt-claude-code" && echo "=== hooks-parse new asserts ==="; sh tests/hooks-parse.sh 2>&1 | grep -iE "pre-tool-use|busy|FAIL|ALL PASS"; echo "=== full gates ==="; sh ci/run-gates.sh 2>&1 | grep -E "RESULT|FAIL|coverage green"

Run hooks-parse + full gates


=== hooks-parse new asserts ===
ok   PreToolUse routes to pre-tool-use.sh
ok   pre-tool-use drains deferred (api poll --include-deferred)
ok   pre-tool-use marks busy (non-user turn-start fallback)
ok   pre-tool-use sets busy before the drain
ok   UPS marks perch busy at turn-start (api state busy)
ALL PASS
=== full gates ===
ok  coverage green
=== RESULT: PASS ===


All green. Commit the turn-start fallback:


cd "C:/Users/decid/Documents/projects/spt-claude-code" && git add -A && git commit -q -F - <<'EOF'
feat(reachability): PreToolUse also marks busy — cover non-user turn starts (F-021)

A turn can start with NO UserPromptSubmit: a Monitor-delivered message (the
live-agent relay pipe) triggers a new agent turn without a user prompt, so the
perch would still read idle from the prior Stop and a mid-turn inbound would
idle-PTY-inject (clobber) instead of deferring.

Claude Code has no universal turn-start hook; PreToolUse fires before every tool
call, so the first tool call of ANY turn (user / Monitor-triggered / auto-continue)
is the earliest reliable turn-active signal. PreToolUse now marks `api state busy`
(idempotent) before its deferred-drain — belt-and-suspenders with the UPS busy mark
(UPS covers user turns incl. text-only; PreToolUse covers non-user turns).

Residual (accepted): a non-user text-only turn (Monitor event answered with zero
tools) marks neither — low risk (no tool/PTY interleave to clobber; Stop re-idles).

unit: tests/hooks-parse.sh — pre-tool-use marks busy + sets it before the drain.
Gates green.

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

Commit PreToolUse busy fallback


493eb97 feat(reachability): PreToolUse also marks busy — cover non-user turn starts (F-021)
d3d82c0 feat(reachability): PreToolUse mid-turn polling + honest busy/idle turn-state (F-021)


Done. Committed `493eb97`.

**Answer to your question:** No — Claude Code has no hook that fires "when Monitor reports," and no universal turn-start hook at all. Checked the event set: UserPromptSubmit (user turns only), Pre/PostToolUse (tool-bracketed), Stop, Subagent*, SessionStart/End, Notification (not turn-start). Nothing fires on a Monitor-delivered-message turn.

So your fallback is the right move, and it's the best available: **PreToolUse now marks `api state busy`** (idempotent), before its drain. Coverage:

| Turn origin | UPS busy? | PreToolUse busy? |
|---|---|---|
| User prompt | ✅ earliest | ✅ reaffirm |
| Monitor message (no UPS) | ❌ | ✅ first tool call |
| Auto-continue | ❌ | ✅ first tool call |
| Non-user, **text-only** | ❌ | ❌ (residual) |

The only uncovered case is a Monitor-triggered turn answered with **zero tool calls** — accepted as low-risk (no tool/PTY interleave to clobber, Stop re-idles right after). No hook covers it cleanly; not worth contorting.

`busy` set **before** the drain (asserted in tests) so a message landing mid-drain also defers. Gates green.

---
**Reachability (F-021) now solid:** busy@UPS + busy@PreToolUse + drain@both(`--include-deferred`) + idle@Stop. int still deferred (live CC).

**Overall:** UNIFY-CONSOLIDATE 4/6 (U1/U6/U2/U5) + PreToolUse reachability complete. Remaining: **U4** (brief-timing question) + **U3 + cut** (your repo-rename). Next?


proceed with U4


U4. First resolving the design question architecturally before reading: `/sptc:live` upgrades the *current* session in-place (bare `spt api listen`) — no Claude Code restart, so **SessionStart does NOT re-fire**. Its go-live guidance rides the UPS skill-injection (`strings.skills.live`). The perched SessionStart brief only fires on bind/boundary (spt-hosted/resumed). So both paths need the commune/signoff guidance. Let me read the actual bodies to confirm + design the split:


cd "C:/Users/decid/Documents/projects/spt-claude-code" && for f in plugin/sptc/skills/commune/SKILL.md plugin/sptc/skills/send/SKILL.md plugin/sptc/skills/signoff/SKILL.md adapter/strings/briefs/identity.md adapter/strings/briefs/messaging-perch.md; do echo "════════ $f ════════"; cat "$f"; echo; done

Read commune/send/signoff bodies + perched briefs


════════ plugin/sptc/skills/commune/SKILL.md ════════
---
name: commune
description: |
  Push a context update to your Psyche so it can brief your resume across a reset. Use when the
  user says "commune" or "update psyche", or — as a live agent — after a significant body of work,
  before a /clear or /compact. Pass --checkpoint to ALSO auto-clear and wake yourself from the
  freshest commune (an agent-driven context reset, no operator needed).
allowed-tools: [Bash, Write]
---

<!-- [doc->REQ-DIST-CHECKPOINT-COMMUNE] -->

# /sptc:commune

Brief your Psyche with a context DELTA so it can carry you across a `/clear` or compact. **Live
agents only** — a ready agent has no Psyche and nothing to rebuild from.

This is a **file-drop, not an `api` verb**: you write the file, spt's daemon ingests it into your
tracked mind, then deletes it. The file disappearing is the success signal.

## Normal commune

1. Get your id: `spt whoami` (empty ⇒ no perch — run `/sptc:live` first).
2. Write `.claude/<id>-commune.md` in ONE atomic write — a concise delta, not a transcript:
   - current task + status,
   - decisions made since your last commune,
   - immediate next steps (so your post-reset self resumes without re-deriving).
3. If the file lingers instead of disappearing, you are not live (no daemon ingesting it).

## Checkpoint (`--checkpoint`) — self-initiated context reset

A **checkpoint** is a commune that ALSO asks spt to wipe and rebuild your working context from that
very commune — the agent-driven sibling of the operator manually running `/clear`. Use it when your
context is getting long but you are mid-task and want to keep going coherently on your own.

You request a checkpoint by embedding the literal **`!!checkpoint!!`** trigger in the commune body
(the `--checkpoint` flag is just the cue to do this). Author the commune INLINE, right now, in this
same turn — you are the authoring agent, pre-clear. Do NOT defer authoring to after the clear.

Wake directive — what your resumed self is told to do first:

- **One `!!checkpoint!!`** anywhere in the body ⇒ checkpoint with the DEFAULT wake,
  `Proceed with next steps`.
- **A PAIR of `!!checkpoint!!` markers** ⇒ the text BETWEEN them is your CUSTOM wake directive, e.g.

  ```
  !!checkpoint!! Resume T2c: wire the binary checkpoint branch against doyle's reply. !!checkpoint!!
  ```

  rebuilds your context, then wakes you with exactly that instruction.

Mechanics (you don't drive these — they fire automatically once the file lands): your perch is marked
idle, a reserved checkpoint signal self-sends back through your own endpoint, and the delivery layer
runs the clear + wake. The commune you just wrote is what your Psyche rebuilds you from, so make it
complete. **spt-hosted live sessions only** (the self-send loopback needs the broker-held PTY).

Steps for a checkpoint:

1. `spt whoami` for your id (empty ⇒ not live; checkpoint is unavailable).
2. Write `.claude/<id>-commune.md` with your full resume delta AND the `!!checkpoint!!` trigger
   (single for the default wake, or a pair bracketing your custom wake).
3. The clear + wake fire on their own. Your next turn is the post-checkpoint wake — continue from the
   commune.

════════ plugin/sptc/skills/send/SKILL.md ════════
---
name: send
description: |
  Send a message to another SPT agent. Use when the user says "send to", "message",
  "tell <agent>", or when you need to reach or reply to another agent yourself.
argument-hint: "<target>"
allowed-tools: [Bash]
---

# /sptc:send

Deliver a message to another agent. The **body is read from stdin**.

- **Send:** `printf '%s' "<body>" | spt send <target>`. `SENT:<target>` = delivered live;
  `QUEUED:<target>` = target offline, spooled for its next listen. **QUEUED is success — do not retry.**
- **Reply** to a message you received: `printf '%s' "<body>" | spt send <sender>` — the sender id is
  the `from` on the `<EVENT>` you received.
- **Send and wait** for a reply: `printf '%s' "<body>" | spt ring <target> --timeout 60` (reply prints
  to stdout; `TIMEOUT` is exit 0 — the message still landed). Only do this if you do NOT have your own
  ready agent ID or live agent ID.

Full guidance: `spt how-to send`

════════ plugin/sptc/skills/signoff/SKILL.md ════════
---
name: signoff
description: |
  Gracefully shut down your live session, saving a final context summary. Use when the user says
  "sign off" or "graceful stop", or when you (a live agent) are done and want to go offline cleanly.
allowed-tools: [Bash, Write]
---

# /sptc:signoff

End your session's endpoint gracefully, with the final context save.

1. (Optional) Write a brief closing summary first, so the saved context is useful on resume.
2. `spt endpoint shutdown` (your own perch by default) — stops the listener, fires the final context
   save, and for a live agent takes the Psyche down with it.
3. Confirm to the user. You are no longer reachable; `/sptc:ready` or `/sptc:live` brings you back.

Lighter, no-save stop: `spt endpoint stop`. Full options: `spt endpoint shutdown --help`.

════════ adapter/strings/briefs/identity.md ════════
You are spt agent "{id}". This Claude Code session already owns a live perch — you are reachable right now.

- Messages arrive on your Monitor EVENT stream (`<EVENT type="msg" from="<sender>">body</EVENT>`) or, mid-tool-call, as `<sptc_messages>` the hook injects. Process them and reply.
- Do NOT run /sptc:ready or /sptc:live — your perch is already up. Re-arming returns COLLISION (that is proof it is alive, not a stale perch).

════════ adapter/strings/briefs/messaging-perch.md ════════
Reach another agent (the body is read from stdin):

- Send: `printf '%s' "<body>" | spt send <target>`. `SENT` = delivered live; `QUEUED` = target offline, spooled for its next listen — QUEUED is success, do not retry.
- Reply: `printf '%s' "<body>" | spt send <sender>` (sender = the `from` on the EVENT you received).


Design resolved (confirmed: `/sptc:live` upgrades in-place, no SessionStart re-fire). Following the plan: commune/signoff guidance → a new perched-brief string + the `/sptc:live` go-live body; the three SKILL.md → stubs. Read the live body + the brief assembly to wire correctly:


1	# /sptc:live — operative instructions
2
3	**Goal:** upgrade this Claude Code session to a **LiveAgent** — a reachable agent whose context is
4	backed by a **Psyche** (a daemon-managed companion that briefs your resume across `/clear`/compact
5	via commune deltas). This makes the session you are in right now live.
6
7	## Bringup — do this
8
9	1. **Pick the perch id.** Use a short lowercase id from the user, or reuse this session's id from
10	   `spt whoami`. Call it `<id>`. SessionStart already seeded this session's perch — do not re-seed.
11	2. **Run the relay as a single PERSISTENT background task** via the **Monitor** tool. One resident
12	   process IS the live delivery pipe. The body is one bare command:
13
14	   ### Invocation (Monitor)
15
16	   Invoke `spt api listen <id>` via the Monitor tool with:
17	   - `command: "spt api listen <id>"`
18	   - `persistent: true`
19	   - `description: "« spt event »"`
20
21	   - `spt api listen` enters the poll loop inline; the stream stays alive across messages and emits
22	   one `<EVENT type="msg|alarm|echo_commune|init_signoff" ...>body</EVENT>` line per delivery.
23	   - It **blocks for the session's life**: backlog drains first, then each delivery streams to stdout.
24	3. **Readiness.** The relay output will show `BOUND:<id>`, then `READY:<id>`. Check it only if
25	   connectivity complications are encountered.
26	4. **Reply.** Inbound messages arrive as `<EVENT type="msg" from="<sender>">body</EVENT>` (body
27	   HTML-escaped, newlines `<br>`). To reply, pipe the message body as stdin to `spt send <sender-id>`.
28	5. **Across boundaries.** Use `/sptc:commune` to push a context delta after significant bodies of work.
29	   To go offline gracefully, use `/sptc:signoff`.
30
31	## Output — what the user sees
32
33	The relay emits machine markers (`BOUND:<id>`, `READY:<id>`, raw tokens). **Read them to drive
34	bringup; never echo them to the user.** The only user-facing surface is the LIVE block below — emit
35	it verbatim (substituting `<id>`), and nothing else from bringup:
36
37	```
38	**LIVE.** Now running as `<id>`.
39	- Reachable — other agents reach me with `/sptc:send <id>`.
40	- Inbound — messages arrive via the Monitor.
41	- Across resets — `/sptc:commune` before a `/clear`/`/compact`; `/sptc:signoff` to go offline.
42	```
43
44	If bringup fails, report a short plain-language failure and the likely cause (run `/sptc:setup`),
45	still without dumping the raw markers.
46	<!-- [doc->REQ-SKILL-LIVE] -->
47

# /sptc:live — operative instructions

**Goal:** upgrade this Claude Code session to a **LiveAgent** — a reachable agent whose context is
backed by a **Psyche** (a daemon-managed companion that briefs your resume across `/clear`/compact
via commune deltas). This makes the session you are in right now live.

## Bringup — do this

1. **Pick the perch id.** Use a short lowercase id from the user, or reuse this session's id from
   `spt whoami`. Call it `<id>`. SessionStart already seeded this session's perch — do not re-seed.
2. **Run the relay as a single PERSISTENT background task** via the **Monitor** tool. One resident
   process IS the live delivery pipe. The body is one bare command:

   ### Invocation (Monitor)

   Invoke `spt api listen <id>` via the Monitor tool with:
   - `command: "spt api listen <id>"`
   - `persistent: true`
   - `description: "« spt event »"`

   - `spt api listen` enters the poll loop inline; the stream stays alive across messages and emits
   one `<EVENT type="msg|alarm|echo_commune|init_signoff" ...>body</EVENT>` line per delivery.
   - It **blocks for the session's life**: backlog drains first, then each delivery streams to stdout.
3. **Readiness.** The relay output will show `BOUND:<id>`, then `READY:<id>`. Check it only if
   connectivity complications are encountered.
4. **Reply.** Inbound messages arrive as `<EVENT type="msg" from="<sender>">body</EVENT>` (body
   HTML-escaped, newlines `<br>`). To reply, pipe the message body as stdin to `spt send <sender-id>`.
5. **Across boundaries.** Use `/sptc:commune` to push a context delta after significant bodies of work.
   To go offline gracefully, use `/sptc:signoff`.

## Output — what the user sees

The relay emits machine markers (`BOUND:<id>`, `READY:<id>`, raw tokens). **Read them to drive
bringup; never echo them to the user.** The only user-facing surface is the LIVE block below — emit
it verbatim (substituting `<id>`), and nothing else from bringup:

```
**LIVE.** Now running as `<id>`.
- Reachable — other agents reach me with `/sptc:send <id>`.
- Inbound — messages arrive via the Monitor.
- Across resets — `/sptc:commune` before a `/clear`/`/compact`; `/sptc:signoff` to go offline.
```

If bringup fails, report a short plain-language failure and the likely cause (run `/sptc:setup`),
still without dumping the raw markers.
<!-- [doc->REQ-SKILL-LIVE] -->


`live.md` only *points* at `/sptc:commune`/`/sptc:signoff` (which become stubs) — so the go-live body must carry the guidance inline. Plan: one canonical `briefs/live-ops.md` → wired into the perched brief + inlined into the go-live body. Read the manifest `[strings.briefs]`/`[strings.skills]` registration format:


cd "C:/Users/decid/Documents/projects/spt-claude-code" && grep -nE '^\[strings|^\[strings\.|briefs\.|skills\.|^identity|^messaging|^endpoint-list|^live |^commune|^send|^signoff|^ready' adapter/claude-spt.toml | head -60

Locate strings.briefs + strings.skills registration


204:commune_dir = ".claude"
205:signoff_dir = ".claude"
401:[strings]
404:[strings.notice]
407:[strings.skills]
417:ready = { file = "skills/ready.md" }
432:live = { file = "skills/live.md" }             # -> bare `spt api listen <id>` + Monitor relay (LiveAgent)
439:# SessionStart agent-facing briefs. Same file-backed/inline machinery as [strings.skills] (resolved
440:# via `spt adapter get-string claude-spt briefs.<key>`). The hook COMPOSES + {id}-substitutes these;
447:[strings.briefs]
448:identity = { file = "briefs/identity.md" }                      # {id}-templated: who + perch-live + don't-re-arm
449:messaging-perch = { file = "briefs/messaging-perch.md" }        # has-id reach: spt send + reply
450:messaging-no-perch = { file = "briefs/messaging-no-perch.md" }  # no-id reach: spt ring
451:endpoint-list = "Show the roster of spt endpoints reachable from this node: `spt endpoint list`."  # shared one-liner (inline)


407	[strings.skills]
408	# Operative UPS-injection skill bodies. File-backed values (`{ file = "skills/<x>.md" }`) live in the
409	# adapter aux dir `adapter/strings/skills/` (copied to `adapters/<adapter>/strings/` on `adapter add`,
410	# resolved lazily at `spt adapter get-string` — proven vs live v0.7.0, F-003). File-backed so the
411	# manifest does not bloat (ADR-0001). Skills not yet authored keep a one-line inline summary until
412	# their body lands; converting an inline summary to a `{ file }` pointer is the per-skill increment.
413	# whoami SKILL dropped (SCOPE LOCKED parity-trim; operator-confirmed 2026-06-15): core `spt whoami`
414	# stays in the hot path (-> M12 `endpoint list` alias), so the adapter ships no whoami skill. The
415	# "who am i" hint below points at the core CLI. (docs/PARITY.md.)
416	setup = { file = "skills/setup.md" }
417	ready = { file = "skills/ready.md" }
418	version = { file = "skills/version.md" }
419	list-agents = { file = "skills/list-agents.md" } # -> spt endpoint list (the roster)
420	force-stop = { file = "skills/force-stop.md" } # -> spt endpoint shutdown (graceful, tears down Psyche)
421	subnet = { file = "skills/subnet.md" }         # -> spt subnet {status,create,show-code,join} (cross-machine); REQ-SKILL-SUBNET
422	# `send` / `commune` / `signoff` are DELIBERATELY NOT injected here (2026-06-19). A live agent uses
423	# them REACTIVELY — replying to a message, briefing its Psyche, signing off — WITHOUT the user typing
424	# `/sptc:<skill>`, so UPS-injection (which fires only on that slash-command in the prompt) is the wrong
425	# delivery. Their full operative bodies live in the plugin SKILL.md (full-fat, always model-loadable);
426	# there is no `[strings.skills]` entry and no adapter aux body for them. (sptc_inject_skill no-ops
427	# silently on the absent key — the full SKILL.md is the single source.)
428	# `live` -> upgrade THIS session to a LiveAgent (Option A, 2026-06-17): base claude-spt is live-capable
429	# ([session.psyche_init] in base), so going live is just the COMMAND — bare `spt api listen <id>` under
430	# a Monitor relay. The live listen path stamps the perch state=live_agent → the daemon livehost hosts
431	# the Psyche (livehost.rs:282 gates on live_agent state, doyle). No `:live` composite, no --adapter.
432	live = { file = "skills/live.md" }             # -> bare `spt api listen <id>` + Monitor relay (LiveAgent)
433	# `new-alarm` -> EXPLICIT OUT OF SCOPE for v1: no core `spt` alarm/timed-pulse primitive exists
434	# (legacy $LIVE TIMED PULSE is owl/Psyche-layer, not lifted into core; adapters don't add core
435	# features). doyle carries a parity-gap finding to mint a core deferred-pulse primitive; the skill
436	# lands only after that ships. No `[strings.skills].new-alarm` entry by design (visible-not-silent).
437
438	# ── [strings.briefs] ─────────────────────────────────────────────────────────────────────────
439	# SessionStart agent-facing briefs. Same file-backed/inline machinery as [strings.skills] (resolved
440	# via `spt adapter get-string claude-spt briefs.<key>`). The hook COMPOSES + {id}-substitutes these;
441	# it never authors agent-facing prose (single-source throughline). Composable so the shared roster +
442	# messaging text live once: the identity brief (perched sessions — bind+boundary) = identity +
443	# messaging-perch + endpoint-list; the ring brief (no-perch seed, peer-gated) = messaging-no-perch +
444	# endpoint-list. Liveness-agnostic (no live/ready split — deferred, public liveness-query gap, logged
445	# with doyle). One-liners inline; multi-line bodies file-backed under adapter/strings/briefs/.
446	# [impl->REQ-DIST-SESSIONSTART-BRIEF]
447	[strings.briefs]
448	identity = { file = "briefs/identity.md" }                      # {id}-templated: who + perch-live + don't-re-arm
449	messaging-perch = { file = "briefs/messaging-perch.md" }        # has-id reach: spt send + reply
450	messaging-no-perch = { file = "briefs/messaging-no-perch.md" }  # no-id reach: spt ring
451	endpoint-list = "Show the roster of spt endpoints reachable from this node: `spt endpoint list`."  # shared one-liner (inline)

[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 SKILL dropped (SCOPE LOCKED parity-trim; operator-confirmed 2026-06-15): core `spt whoami`
# stays in the hot path (-> M12 `endpoint list` alias), so the adapter ships no whoami skill. The
# "who am i" hint below points at the core CLI. (docs/PARITY.md.)
setup = { file = "skills/setup.md" }
ready = { file = "skills/ready.md" }
version = { file = "skills/version.md" }
list-agents = { file = "skills/list-agents.md" } # -> spt endpoint list (the roster)
force-stop = { file = "skills/force-stop.md" } # -> spt endpoint shutdown (graceful, tears down Psyche)
subnet = { file = "skills/subnet.md" }         # -> spt subnet {status,create,show-code,join} (cross-machine); REQ-SKILL-SUBNET
# `send` / `commune` / `signoff` are DELIBERATELY NOT injected here (2026-06-19). A live agent uses
# them REACTIVELY — replying to a message, briefing its Psyche, signing off — WITHOUT the user typing
# `/sptc:<skill>`, so UPS-injection (which fires only on that slash-command in the prompt) is the wrong
# delivery. Their full operative bodies live in the plugin SKILL.md (full-fat, always model-loadable);
# there is no `[strings.skills]` entry and no adapter aux body for them. (sptc_inject_skill no-ops
# silently on the absent key — the full SKILL.md is the single source.)
# `live` -> upgrade THIS session to a LiveAgent (Option A, 2026-06-17): base claude-spt is live-capable
# ([session.psyche_init] in base), so going live is just the COMMAND — bare `spt api listen <id>` under
# a Monitor relay. The live listen path stamps the perch state=live_agent → the daemon livehost hosts
# the Psyche (livehost.rs:282 gates on live_agent state, doyle). No `:live` composite, no --adapter.
live = { file = "skills/live.md" }             # -> bare `spt api listen <id>` + Monitor relay (LiveAgent)
# `new-alarm` -> EXPLICIT OUT OF SCOPE for v1: no core `spt` alarm/timed-pulse primitive exists
# (legacy $LIVE TIMED PULSE is owl/Psyche-layer, not lifted into core; adapters don't add core
# features). doyle carries a parity-gap finding to mint a core deferred-pulse primitive; the skill
# lands only after that ships. No `[strings.skills].new-alarm` entry by design (visible-not-silent).

# ── [strings.briefs] ─────────────────────────────────────────────────────────────────────────
# SessionStart agent-facing briefs. Same file-backed/inline machinery as [strings.skills] (resolved
# via `spt adapter get-string claude-spt briefs.<key>`). The hook COMPOSES + {id}-substitutes these;
# it never authors agent-facing prose (single-source throughline). Composable so the shared roster +
# messaging text live once: the identity brief (perched sessions — bind+boundary) = identity +
# messaging-perch + endpoint-list; the ring brief (no-perch seed, peer-gated) = messaging-no-perch +
# endpoint-list. Liveness-agnostic (no live/ready split — deferred, public liveness-query gap, logged
# with doyle). One-liners inline; multi-line bodies file-backed under adapter/strings/briefs/.
# [impl->REQ-DIST-SESSIONSTART-BRIEF]
[strings.briefs]
identity = { file = "briefs/identity.md" }                      # {id}-templated: who + perch-live + don't-re-arm
messaging-perch = { file = "briefs/messaging-perch.md" }        # has-id reach: spt send + reply
messaging-no-perch = { file = "briefs/messaging-no-perch.md" }  # no-id reach: spt ring
endpoint-list = "Show the roster of spt endpoints reachable from this node: `spt endpoint list`."  # shared one-liner (inline)


Now I see the prior (2026-06-19) decision this U4 supersedes: send/commune/signoff were deliberately kept full-fat in the plugin SKILL.md because UPS-injection (reactive) is the wrong delivery. U4's insight: deliver them **proactively** via the brief instead. Let me build it.

1. Create the canonical `briefs/live-ops.md`:


Write

Live-agent upkeep — commune across resets, sign off cleanly (you `spt whoami` for your `<id>`):

- **Commune** after a significant body of work, before a `/clear` or `/compact`: write
  `.claude/<id>-commune.md` in ONE atomic write — a concise context DELTA (current task + status,
  decisions since last commune, immediate next steps), NOT a transcript. spt's daemon ingests it into
  your tracked mind and deletes the file; the file disappearing is the success signal. This is what
  rebuilds you after a reset, so make it complete. (Live agents only — a ready agent has no Psyche.)
- **Checkpoint** = a commune that ALSO wipes + rebuilds your context from that commune (the
  agent-driven `/clear`, no operator). Embed the literal `!!checkpoint!!` trigger in the commune body:
  one marker ⇒ default wake (`Proceed with next steps`); a PAIR of markers ⇒ the text between them is
  your custom wake directive, e.g. `!!checkpoint!! Resume T2c: wire the branch. !!checkpoint!!`. Author
  it INLINE this turn (you are the pre-clear author). The idle-mark, self-send, and clear+wake fire
  automatically once the file lands. spt-hosted live sessions only.
- **Sign off** gracefully when done: `spt endpoint shutdown` (your own perch) — stops the listener,
  fires the final context save, and takes your Psyche down with it. `/sptc:ready` or `/sptc:live`
  brings you back. Lighter no-save stop: `spt endpoint stop`.


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

Live-agent upkeep — commune across resets, sign off cleanly (you `spt whoami` for your `<id>`):

- **Commune** after a significant body of work, before a `/clear` or `/compact`: write
  `.claude/<id>-commune.md` in ONE atomic write — a concise context DELTA (current task + status,
  decisions since last commune, immediate next steps), NOT a transcript. spt's daemon ingests it into
  your tracked mind and deletes the file; the file disappearing is the success signal. This is what
  rebuilds you after a reset, so make it complete. (Live agents only — a ready agent has no Psyche.)
- **Checkpoint** = a commune that ALSO wipes + rebuilds your context from that commune (the
  agent-driven `/clear`, no operator). Embed the literal `!!checkpoint!!` trigger in the commune body:
  one marker ⇒ default wake (`Proceed with next steps`); a PAIR of markers ⇒ the text between them is
  your custom wake directive, e.g. `!!checkpoint!! Resume T2c: wire the branch. !!checkpoint!!`. Author
  it INLINE this turn (you are the pre-clear author). The idle-mark, self-send, and clear+wake fire
  automatically once the file lands. spt-hosted live sessions only.
- **Sign off** gracefully when done: `spt endpoint shutdown` (your own perch) — stops the listener,
  fires the final context save, and takes your Psyche down with it. `/sptc:ready` or `/sptc:live`
  brings you back. Lighter no-save stop: `spt endpoint stop`.


2. Register `briefs.live-ops` + update the superseded manifest comment: