155 requirements: 155 complete, 0 incomplete, 0 findings [OK] REQ-ACROSS-QUIET-WINDOW required: [impl, unit] stages: -doc +impl +unit -int An armed across-commune closes the endpoint's INBOUND door for the rest of the arming turn: `arm_wake` no longer marks the perch idle — it writes a `clearing` latch (`state/clearing/.latch`, adapter-local, hook-side custody, same shape as the wake park) and the endpoint STAYS BUSY, so core spools inbound instead of pushing it. While latched, PreToolUse still busy-marks but does NOT poll and does NOT drain the msg park (and therefore does not COMMIT it — a skipped drain must never destroy bodies staged by a prior one), Stop does NOT mark idle, and an idle_prompt Notification does NOT mark idle. `SessionStart(clear)` — the boundary the latch was armed for — drops the latch and the existing clear-path idle mark drains the spool into the FRESH session. OUTBOUND IS UNAFFECTED: mid-turn tag dispatch (`@<…@>`) and the Stop-leg dispatch keep firing throughout, latched or not. WHY (operator report, grilled 2026-08-04): `arm_wake` marks idle at a moment when the agent is demonstrably mid-turn — it only ever runs from a PostToolUse or a mid-turn tag dispatch — while CC QUEUES the typed `/clear` until turn end, so the window is 'the rest of the current turn'. The agent's next tool call re-marks busy, polls, and drains the park, delivering a peer message to an agent whose `/clear` is already queued; it answers, and often writes a second across-commune, and both the answer and the second commune die at the clear. WHY BUSY AND NOT MERELY UNPOLLED: suppressing only our own polling does not close the door — an IDLE endpoint still gets messages PUSHED, typed into the input box through the translation binary without consulting our hooks. Staying busy is the only state that closes it. [OK] REQ-ALT-ACCOUNT-ROOTS required: [doc, impl, unit] stages: +doc +impl +unit -int claude-spt can run a session under a DIFFERENT Anthropic account without logging the user out of the first, by launching Claude Code against an adapter-owned ACCOUNT ROOT selected by an alt profile. Claude Code 2.1.239 holds exactly one account per config root (`/login` REPLACES the credentials there; `claude auth` offers only login/logout/status; the published docs state separate CLAUDE_CONFIG_DIR roots are the only way to hold two), so swapping the root at launch is the sole mechanism and no adapter cleverness can avoid it. ONE ROOT PER ACCOUNT, NOT PER ENDPOINT — this is the load-bearing choice and it was arrived at by rejecting the other: credentials live IN the root, so a per-endpoint root means one login of the same account per endpoint, which is disqualifying for the failover this exists to serve (an account runs out of tokens mid-work, the operator switches and CONTINUES THE SAME WORK). What the per-endpoint layout bought — separation of Claude Code's own session state per endpoint — is not worth a login apiece, because per-endpoint memory and identity are spt's job at the mind layer, and endpoints already share one root today. CONTINUITY IS SHARED BY DIRECTORY JUNCTION into every account root: one node-wide tree of `projects/`, `todos/`, `file-history/`, `shell-snapshots/`, `session-env/`. Without a shared `projects/` an account switch ABANDONS the in-flight session, since spt resume passes `--resume ` and Claude Code reads that transcript out of the active root — so the junctioned tree is not a convenience, it is the requirement. CREDENTIALS AND `.claude.json` ARE REAL FILES IN EACH ROOT AND MUST NEVER BE LINKED: measured 2026-08-21 on this node, `.claude.json` takes a NEW FILE IDENTITY every ~15-25 seconds while a session is live (eight consecutive samples, eight distinct inodes, mtime advancing, size constant at 130532) — it is written tmp-then-rename, so a hard link or symlink at that path stops tracking on the first rewrite and the two stores diverge SILENTLY, a token refresh landing in one while the other rots. That is strictly worse than not sharing, and it is the same silent-divergence class as copying credentials in at launch and writing them back at session end (two endpoints on one account clobber each other's refreshed token), which is rejected for the same reason. Directories use JUNCTIONS specifically because they need no elevation, where Windows file symlinks require Developer Mode — the privilege ccs lacks on locked-down boxes, where it silently degrades to directory copies. ACCOUNT IDENTITY IS `.credentials.json` PLUS `.claude.json`'s `userID`/`oauthAccount.*` (measured: those keys differ between two logged-in roots, and a never-logged-in root carries no `oauthAccount` key at all), so treating credentials alone as the account is wrong. SCOPE OF THE MOVE: every spawned role follows the profile — session, resume, psyche_init, echo_commune — because a failover that leaves the Psyche drawing on the exhausted account is not a failover. TRUST MUST BE SEEDED into the root before launch (trust is per-config-root, REQ-HAZARD/F-027): an unseeded root does not fail, it HANGS on the dialog, which would strand the first failover at the worst possible moment. ACCEPTED LOSSES, both deliberate: `history.jsonl` is a FILE, cannot join the junctioned tree, and stays per-account, so prompt history does not follow a switch; and endpoints on one account share that root's Claude Code state, which is the status quo. AN ENDPOINT KEEPS ITS ID ACROSS PROFILES — the profile selects which account pays, never which endpoint is speaking. USERS ADD THEIR OWN ACCOUNTS WITHOUT AN ADAPTER RELEASE via `spt adapter create-profile` (a node-local overlay that survives re-registration and update); spt has NO runtime profile parameterization and no `{profile}` substitution key, so each profile names its own account literally, and `{id}` is available but deliberately unused here. The deliverable is therefore ONE shipped example profile, a launch-shim flag, a one-shot init, and documentation — not a subsystem, and explicitly not a reimplementation of ccs's instance manager, whose per-launch maintenance is the reason its launches are slow. OUT OF SCOPE: settings-type profiles (non-Anthropic backends), provider swapping, and removing ccs — ccs stays installed and keeps its delegation, dashboard and proxy roles, and the shipped `claude-spt:ccs` profile is untouched. [OK] REQ-ALT-AUTO-INIT required: [impl, unit] stages: -doc +impl +unit -int First launch on an alt profile SETS ITSELF UP: a launch whose account root is MISSING runs the one-shot `alt init` itself and proceeds, and the account name defaults to "alt" — the user is never asked to run a binary command. Request BigscreenVR/claude-spt-bs#15 (requester discord:reavo, 2026-08-22): v0.29.0 shipped `claude-spt alt init ` as a user-facing setup step, but a user launching the shipped `claude-spt:alt` profile has no straightforward path to the `claude-spt` binary (it lives in the adapter dir, not on PATH), so asking them to run it is unacceptable — setup must happen automatically on the first launch of an endpoint via the alt profile. THE FAST PATH IS PRESERVED, which is what keeps this compatible with the ADR-0010 rejection of per-launch maintenance (the ccs cost this feature exists to remove): init runs at launch in EXACTLY ONE case, Preflight::MissingRoot — every launch after the first pays only the preflight stat it already paid. A FAILED auto-init fails the launch loudly and names `claude-spt alt init ` as the manual repair (the observation is reported, not a diagnosis); a successful one falls through to the normal credential preflight, so the first launch ends at Claude Code's own login prompt exactly as v0.29.0 documented. The default account name "alt" is a shared constant with the shipped profile's literal `--account alt`, so `alt init` bare and the profile agree by construction. The manifest is UNCHANGED — the shipped profile already bakes `--account alt` into every role's command; what this removes is the human step, not a profile knob. [OK] REQ-ALT-PLUGINS-SURFACE required: [impl, unit] stages: -doc +impl +unit -int The plugins store is part of the junctioned authoring surface: `alt init` junctions `plugins/` from the primary root, so the copied settings.json can never enable a plugin the account root does not hold. Field defect BigscreenVR/claude-spt-bs#18 (first launch of the shipped alt profile, 2026-08-23, endpoint perri): init copied settings.json — whose enabledPlugins named sptc@cplugs, everything@cplugs and screen-timelapse@cplugs — but nothing seeded the plugins store, so the session came up with the adapter plugin ENABLED-BUT-ABSENT: no SessionStart hook fired (the operator had to run psyche-download by hand), no sptc skills loaded, no perch came up — the endpoint sat UNBOUND on the roster with no delivery channel, and nothing on screen said why. The gap even read as PARTIAL, which is worse than total: Claude Code re-clones marketplaces it can discover on its own (the official one plus settings' extraKnownMarketplaces), so GitHub-sourced marketplaces reappeared in the fresh root while the locally-registered cplugs — recorded only in the primary root's plugins/known_marketplaces.json — and every actual plugin install did not. THE SPLIT IS DELIBERATE: enable/disable stays per-account (settings.json is a copy by design), while the STORE — marketplaces, plugin cache, installed_plugins.json — is shared by junction. The junction is in the same rewrite-safety class as the continuity tree: Claude Code's tmp-then-rename writes happen INSIDE the junctioned directory, never AT the junction path, so nothing stops tracking (the never-link rule protects account-bound FILES at the root, which plugins/ is not). An existing REAL plugins/ directory in an account root is warned about and left alone — the same non-destructive stance as the rest of the authoring surface; moving it aside and re-running init is the repair. [OK] REQ-BRIEF-WORK-DISCIPLINE required: [impl, unit] stages: -doc +impl +unit -int spt-hosted perched sessions (bind, or boundary with $SPT_ENDPOINT_ID present) get a standing autonomous-work-discipline brief composed into the SessionStart perch brief: keep the context window under ~50%; on substantial headway write a checkpoint commune (immediate next steps + broad project status/end goal); keep working across checkpoint cycles until the goal is met. Gated spt-hosted-only (the directive leans on the checkpoint clear+wake machinery, which needs the translation binary); the brief rides boundary too so the post-clear reborn agent re-reads the discipline every cycle. Prose is adapter-string-backed ([strings.briefs].work-discipline, file-backed) — the hook composes, never authors (operator sprint 2026-07-10). [OK] REQ-CC-LAUNCHER-BIND required: [int] stages: -doc -impl -unit +int spt-hosted bringup self-binds E2E: `spt endpoint run claude-spt ` spawns the [session.self] CC session which — via [env.SPT_ENDPOINT_ID]={id} (spt-core fills it, v0.11.0+ REQ-HAZARD-ENV-SUBST/F-013) + the SessionStart `bind` branch — registers a BOUND perch on disk reachable by `spt send` (live PTY inject, SENT). The M12 cc-launcher reachability, the gap that hid the wall-b zero-perch. [OK] REQ-CCS-PROFILES required: [doc, impl, unit, int] stages: +doc +impl +unit +int The adapter ships a ccs profile template (claude-spt:ccs) — LOCKED-ADD overlay that retargets the spawn command template through `ccs` (a drop-in for the `claude` binary) and honors ccs's relocated CLAUDE_CONFIG_DIR transcript root in the digest extractor; templates only (user supplies their own ccs config/keys) [OK] REQ-CI-ACCEPTANCE required: [doc, impl, int] stages: +doc +impl -unit +int Acceptance = scripted orchestration spawning real claude/headless sessions as the system-under-test, asserting spt-state/digest output (LLM is SUT, never the runner) [OK] REQ-CI-BUS required: [doc, impl] stages: +doc +impl -unit -int CI progress/results report over spt messaging (dogfood the product as its own CI nervous system) [OK] REQ-CI-GATES required: [doc, impl, unit] stages: +doc +impl +unit -int Deterministic gates (shell-syntax + unit tests + traceable-reqs check + manifest-schema + docs-drift), each a binary pass/fail, runnable as one command [OK] REQ-CI-MANUAL required: [doc, impl] stages: +doc +impl -unit -int A manual 'run gates' command always exists — the same gate scripts runnable by hand on any fleet host [OK] REQ-CI-OWL-DISCOVERY required: [doc, impl, unit] stages: +doc +impl +unit -int The spt messaging (bus) binary is located robustly at run time (per-version plugins path / PATH / configured) — never a hard-coded versioned path [OK] REQ-CI-TRIGGER required: [doc, impl] stages: +doc +impl -unit -int A git push hook fires the gates: pings a fleet runner-agent over spt rather than polling [OK] REQ-COMMUNE-CONTEXT-TIER-SLICING required: [impl, unit] stages: -doc +impl +unit -int The two adapter-authored commune prompts must teach the two-slice context envelope so spt-core routes context per-agent-per-project instead of dumping everything in the live tier. spt-core ingests a commune (and the echo-commune summary) into durable tiers — `` (agents//live-context.md, cross-project identity) and `` (projects///project-context.md, this-project detail), the same tiers `psyche-download` re-emits on resume — and routes by these tags; UNTAGGED text defaults to the live tier, so an unsliced commune pollutes the durable identity with project-specific noise that also leaks across projects (doyle finding 2026-07-08). FIX (prompt-only, both authoring paths): (1) the live-agent commune brief (adapter/strings/briefs/live-ops.md) instructs the agent to wrap project detail in `…` and cross-project/role context in `…`; (2) the echo-commune summarizer directive (echo_commune.rs compose_prompt) emits the same two slices for the no-signoff auto-summary. Untagged-defaults-to-live is stated in both so partial tagging is safe. The `` sentinel is core-emitted OUTPUT only — never agent-authored. [OK] REQ-COMMUNE-OUTPUT-SHORTCUT required: [impl, unit] stages: -doc +impl +unit -int An agent writes its commune WITHOUT the Write tool by starting an output with `>>commune<<` (after leading whitespace); the rest of that output is the commune body, written to `.claude/-commune.md` under the session cwd — the same file the /sptc:commune Write path targets, so the daemon's watch ingests+deletes it identically. The existing `!!checkpoint!!` in-body marker is reused for the checkpoint escalation (no new keyword): a commune body carrying it fires the same idle + checkpoint self-send loopback as the Write-path PostToolUse handler. COLLISION RULE (meta-recursion guard): once an output is a commune it is NOT scanned for `@<…@>` — a commune may quote the messaging syntax verbatim without dispatching. [OK] REQ-DIGEST-NOISE-FILTER required: [impl, unit] stages: -doc +impl +unit -int The digest extractor filters CC transcript noise from input records in the same pass as the relay surface: a // slash-command triple collapses to the bare command line (name + args, e.g. `/clear`); … spans are stripped; BOUNDARY … spans (leading/trailing on the prompt — the /clear-boundary and hook-injected reminder frames) are stripped while mid-text reminder mentions are left alone; an input whose text empties after filtering emits nothing. Keeps the operator's RC digest readable — pre-filter, every /clear rendered as three tag-soup input records (field sample 2026-07-10: the caveat line, the command triple, the stdout line). [OK] REQ-DIGEST-RELAY-SURFACE required: [impl, unit] stages: -doc +impl +unit -int The digest extractor surfaces the adapter's own delivery frames (… / …) out of the transcript's hook_success attachment entries as role=input records — which also makes mid-turn PreToolUse deliveries digest-visible for the first time — and collapses a stub-input turn (whole-prompt-exact / , the shared is_stub() recognizer hook and digest both use) with its forward-adjacent attachment frames into ONE input record, so the operator's RC view keeps full message parity once ADR-0007 stub delivery ships (stubs park the body adapter-side; without this collapse the RC view shows a bare 7-char stub and the body vanishes). Shapes (field-verified 2026-07-10 against live transcripts): hook_success carries the body EITHER in attachment.content (raw text or list-of-strings — the UserPromptSubmit raw-stdout channel) OR in attachment.stdout as the {"hookSpecificOutput":{"additionalContext":…}} JSON envelope (the PreToolUse channel); hookName-filtered to UserPromptSubmit/PreToolUse only (SessionStart briefs are boot noise, and the parallel hook_additional_context entry duplicates every hook_success — reading hook_success alone avoids the double-count). Non-frame hook context (reachability nudges etc.) never emits. ADR-0007 digest-parity leg; ships BEFORE or WITH the stub protocol (Slice A of STUB-INJECT-PLAN.md). [OK] REQ-DIST-ADAPTER-PEROS required: [doc, impl, unit] stages: +doc +impl +unit -int The adapter ships ONE host-agnostic MULTI-PLATFORM `adapter.spt` (ADR-0024 W1, spt-core >= 0.13.2): a single fat archive bundles every recognized target-triple's tool binaries under a `/` dir beside the SHARED manifest.toml + strings/ at the archive root; install classifies the triple dirs and flattens THIS node's triple into the install dir, so a bare-name command token resolves (REQ-INSTALL-11). `spt adapter add --release` (default asset adapter.spt) + the `[update] gh_release` avenue auto-resolve the host's binaries from the one asset — RETIRING the F-014 per-OS stopgap (the old single-OS adapter.spt-by-default that broke on a foreign host). v1 triples = x86_64-pc-windows-msvc + x86_64-unknown-linux-gnu (the only two spt-core recognizes in a fat archive; platforms beyond these ship a separate single-triple asset via `--asset`). [OK] REQ-DIST-ADAPTER-RELEASE required: [doc, impl, unit, int] stages: +doc +impl +unit +int The adapter ships to end users as an `adapter.spt` GitHub release asset (tar ROOT = manifest.toml + strings/ + the tool binaries), acquired via `spt adapter add --release SaberMage/claude-spt` — distribution straight from the monorepo, no dedicated repo (doyle's --release acquisition source, spt v0.7.3/counter-15) [OK] REQ-DIST-BINARY-CONSOLIDATE required: [impl, unit, int] stages: -doc +impl +unit +int claude-spt-digest + claude-spt-psyche merge into ONE `claude-spt` crate with clap subcommands (digest / psyche / post-update); post-update = the plugin-sync logic (detect claude|ccs CLI -> ensure cplugs marketplace -> claude plugin add|update -> print notice; does NOT run /reload-plugins). cc-spt-idle-translate stays separate (folds at D3). U2. [OK] REQ-DIST-BOOTSTRAP-INSTALL required: [doc, impl] stages: +doc +impl -unit -int SessionStart bootstrap installs spt-core when absent (invisible-installer pattern) [OK] REQ-DIST-BOUNDARY-RENAME required: [impl, unit, int] stages: -doc +impl +unit +int Every /clear boundary on an spt-hosted endpoint RE-ASSERTS the session display name (the boundary rename): CC drops the `-n " @ (/)"` display name when a clear rotates the session, leaving the operator no rendered TUI anchor. The launch shim exports the computed display name as SPT_SESSION_NAME into the spawned CC env (single computation site — the same string that feeds `-n`, parity by construction); the SessionStart hook, on a `clear` boundary, self-sends {"rename":"v1","name":""} --force-native BEFORE the {"checkpoint_fire":"v1"} signal; the translation binary STASHES the name on the rename delivery (answering the mandatory bare {commit}) and emits the rename keystrokes as the FIRST HALF of the fire's ONE COMBINED post-clear sequence (ctrl+s . 50 . `/rename ` . 50 . enter . 150ms enter->ctrl+s bridge . ctrl+s . 50 . wake . 50 . enter . commit) — REVISED v0.15.1: the original design emitted rename and wake as two back-to-back inject sequences, which RACED at the just-rebuilt post-clear boundary (CC input processing lags the PTY stream; the rename's enter registered as a soft newline, the next sequence's ctrl+s failed to stash the residue, and the wake text submitted INSIDE the /rename argument — flynn 2026-07-06, session titled with the wake, NO wake turn, agent dormant ~9.5h, ~33% observed rate). The single sequence removes the inter-sequence race BY CONSTRUCTION; the stash NEVER touches pending wake. Ordering: rename submits before any armed wake fires (/rename is a local no-turn CC command — empirically confirmed 2026-07-05: inline rest-of-line arg, renames the CURRENT session, no agent turn). Missing SPT_SESSION_NAME (pre-0.14.1 spawn, non-shim spawn) => the hook SKIPS the rename send with a loud RENAME_SKIP:no-name log line, never silently; heals on respawn. /compact RETAINS the name (empirically confirmed 2026-07-05) so the compact boundary sends nothing — the existing no-send invariant stands. A rename envelope with a blank/absent name answers a bare {commit} (REQ-HAZARD-EMPTY-RESPONSE-COMMIT discipline), never a stray `/rename ` submit. [OK] REQ-DIST-BOUNDARY-ROTATE required: [impl, unit, int] stages: -doc +impl +unit +int The /clear|compact SessionStart boundary rotation actually rotates the perch: the hook resolves the endpoint id via $SPT_ENDPOINT_ID FIRST (whoami-by-NEW-sid is a catch-22 — the new sid is unregistered until this very call succeeds, so it resolves self:null and the old code silently skipped rotation), persists the CURRENT session id to an adapter-owned state file ({adapter_dir}/state/session/.sid) at EVERY SessionStart, and presents the PRIOR session's sid from that file as the boundary auth proof (`--session-id `; the verb refuses `AUTH_REFUSED (need --token or matching --session-id)` without it — the departed session's env is gone, so a state file is the only carrier). doyle ruling F024D-BOUNDARY-RULING 2026-07-02: adapter-owned state is the contract-clean surface (glue-model, ADR-0021 family); NOT CLAUDE_ENV_FILE (per-session, dies with the rotation — the exact failure being fixed). [OK] REQ-DIST-CCS-PLUGIN-FOLLOWUP required: [impl, unit] stages: -doc +impl +unit -int When `ccs` is installed alongside `claude`, the post-update plugin reconcile ALSO runs `ccs plugin update sptc@cplugs` (best-effort): ccs-launched sessions read their own per-account plugin tree (the CLAUDE_CONFIG_DIR relocation, ~/.ccs/instances//.claude), which the claude-side reconcile never touches — without the follow-up a ccs session keeps running the STALE plugin after every `spt adapter update`. Not fired when ccs is absent or when ccs IS the primary CLI (already reconciled). Best-effort by design: `update` (never `install` — a ccs account that never installed the plugin is not this reconcile's call), failure → stderr noise only, NEVER a post-update failure and NEVER the stdout arbiter channel. Operator ask 2026-07-01 (v0.10.3). [OK] REQ-DIST-CHECKPOINT-COMMUNE required: [doc, impl, unit, int] stages: +doc +impl +unit +int Agent-driven checkpoint: a live agent flags a commune with the literal trigger !!checkpoint!! (one = default wake; a pair brackets a custom wake directive); a PostToolUse hook detects the trigger in the Write tool_input.content for .claude/-commune.md, sets the perch idle (api state idle), and self-sends a reserved wire-sentinel (spt send --from ); the message loops back through the endpoint's own translation binary, which ARMS the wake and emits CLEAR-ONLY; the wake is fired separately AFTER the clear completes (the split ordering fix — see REQ-HAZARD-CHECKPOINT-CLEAR-RACE, which supersedes the old single-sequence ctrl+s·50ms·/clear·enter·500ms·wake·enter·commit macro). spt-hosted-only. INVARIANT: the commune is authored INLINE pre-clear (ADR-0004, Shape 1), never via a post-clear resume-Self refresh. [OK] REQ-DIST-CLAUDE-AUTOINSTALL required: [impl, unit] stages: -doc +impl +unit -int A fresh machine with NEITHER `claude` nor `ccs` on PATH gets Claude Code AUTO-INSTALLED by post-update via the platform-native installer (verbatim the code.claude.com/docs/en/quickstart recommended channel: PowerShell `irm https://claude.ai/install.ps1 | iex`; unix `curl -fsSL https://claude.ai/install.sh | bash`) — the only surface a fresh machine is guaranteed to run (chicken-egg: no claude → no Claude Code → no plugin → no /sptc:setup; grill ruling 2026-07-16). Loud stderr narration; after install the process re-probes PATH then the known ~/.local/bin install location (PATH registration reaches NEW shells only) and continues via the absolute path; install failure is LOUD + exit nonzero carrying the manual one-liner. A present ccs skips the install (documented drop-in — never mutate the system beyond necessity). Authentication stays the operator's first `claude` run; the verified notice says so. [OK] REQ-DIST-DIGEST-EXTRACTOR required: [impl, unit, int] stages: -doc +impl +unit +int The claude-spt [digest] extractor (claude-spt-digest) maps Claude Code's JSONL transcript to the published digest-record NDJSON contract (role∈{input,agent,tool}; text/tool/ts), emitting RAW records for spt-core's renderer [OK] REQ-DIST-DIGEST-FETCHER required: [impl, unit, int] stages: -doc +impl +unit +int The [digest] seam runs under strategy="fetcher" (spt-core v0.19.0, #17): the extractor IS the locator — `claude-spt digest --session {session_id} --config-dir {CLAUDE_CONFIG_DIR}` locates /projects//.jsonl itself (no spt-core pre-read, no `source`, no harness slug in the catalog); the NEW [env.CLAUDE_CONFIG_DIR] direction="read" capture (bind-time env → info.json.read_env → fill → ~expand, value="~/.claude" fallback) delivers the ccs-relocated tree to the extractor in the DAEMON's context — the pre-0.19.0 inherited-env preference stays as the next precedence rung [OK] REQ-DIST-HOOK-BINARY required: [doc, impl, unit] stages: +doc +impl +unit +int Hook LOGIC lives in the consolidated `claude-spt hook ` binary (so it rides `spt adapter update`, not a cplugs republish); the plugin ships only a static-forever hooks.json + dispatch wrapper that resolves the binary via the lazily-substituted `[strings].hook_cmd = {adapter_dir}/claude-spt hook` (ADR-0006 ask #1 → resolve-not-execute; spt-core v0.16.0 {adapter_dir} + lazy [strings] subst). D1 / v0.9.0. [OK] REQ-DIST-HOOKS-API required: [doc, impl, unit, int] stages: +doc +impl +unit +int hooks.json delegates to `spt api` rather than carrying adapter logic [OK] REQ-DIST-IDLE-MULTILINE required: [impl, unit, int] stages: -doc +impl +unit +int The idle-translation binary renders each inbound envelope across MULTIPLE LINES in CC's input box for visual distinction — an embedded raw newline byte in the {text} payload after the opening tag and before the closing (spt-core writes {text} byte-verbatim including newlines, doyle-confirmed; CC soft-newlines on a bare newline, empirically gated). Cyan color is impossible (SGR bytes eaten by CC input handling; user-turns theme-fixed). [OK] REQ-DIST-IDLE-POST-ENTER-SETTLE required: [impl, unit] stages: -doc +impl +unit -int The idle translation binary pauses POST_ENTER_MS (50ms, operator spec 2026-07-18) after every TERMINAL `enter` submit, before the `{commit}` that ends the inject sequence. A commit releases the InjectFloor and flushes the live controller's buffered input; if it lands before Claude Code has fully registered the submit keypress, residue can leak into the just-submitted turn ("leftover messages"). The settle is applied after the terminal enter in the message-stub, full-envelope, /clear, and post-clear boundary (rename and/or wake) choreographies. A MID-sequence enter followed by more keystrokes (the rename enter when a wake half follows) is NOT double-settled — it keeps the existing 150ms BOUNDARY_BRIDGE_MS; a bare-{commit} answer (nothing emitted) adds no settle (no enter to register). [OK] REQ-DIST-IDLE-TRANSLATE required: [impl, unit, int] stages: -doc +impl +unit +int The adapter ships the [message-idle-translation-binary] (command="{adapter_dir}/claude-spt translate", spt-core v0.16.0 seam — path deprecated; D3 fold into the consolidated binary) — a lifecycle-managed stdin→stdout JSON-lines filter that turns each idle inbound envelope into the keystroke choreography spt-core applies ATOMICALLY to the broker PTY: ctrl+s (stash any draft) · 50ms · (type the text, no trailing CR) · 50ms · {key:enter} (submit the PTY line) · {commit:true} (terminate the inject sequence) — so an inbound message never clobbers a half-typed operator draft (CC auto-restores the stashed draft after submit; no trailing restore keystroke). The submit is a DISCRETE {key:enter}, NOT a trailing \r in the text: a \r byte does NOT submit a Claude Code message (corrected 2026-06-23). The trailing {commit} is MANDATORY: spt-core's run_inject_worker (broker.rs:1075-1090) ends a sequence only on {commit}; without it the broker FAULTs at the 5s INJECT_COMMIT_DEADLINE on every delivery (the enter key submits the line, {commit} releases the InjectFloor — two distinct signals). Idle delivery ONLY (the spt-hosted complement to the busy/mid-turn [inject] hook path); coexists with a live `spt rc` controller. [OK] REQ-DIST-INSTALL-UX required: [doc] stages: +doc -impl -unit -int README ships platform-specific install chains (cmd / PowerShell / bash) — check-for/install spt-core (call the spt-releases per-platform install script; claude-spt may be a user's first spt-core exposure) then `spt adapter add --release SaberMage/claude-spt` — plus a copy-paste agent prompt (the casual-user skin) running the chain in one sequenced Bash call, symmetric with the update lever. U5. [OK] REQ-DIST-INSTALL-VERIFIED required: [impl, unit] stages: -doc +impl +unit -int The plugin reconcile is VERIFY-THEN-NOTIFY (the documented [update.post] pattern; the 2026-07-16 fresh-install incident proved exit codes alone lie): after the marketplace/plugin steps, post-update RE-READS installed_plugins.json and asserts sptc@cplugs is present — claude-primary verification failure is LOUD stderr + exit nonzero with an empty stdout (no happy notice can fire); verified success prints the ✔-VERIFIED custom stdout notice (custom supersedes the static [update].message); the static [update].message is reworded failure-aware because core's emit_fallback fires it even after a failed post-step (deliberate, documented core design — NOT a core bug; doyle classification 2026-07-16). ccs-primary trees are per-account and unverifiable from this process — they keep the sentinel/static path rather than a false FAIL. [OK] REQ-DIST-MANIFEST-SCHEMA required: [doc, impl, unit, int] stages: +doc +impl +unit +int CC adapter manifest validates against spt-core's published manifest.schema.json (from spt-releases) [OK] REQ-DIST-MIGRATION-SCRIPT required: [doc, impl] stages: +doc +impl -unit -int A ONE-TIME per-node migration script (per-platform: sh + ps1) repoints an installed claude-spt adapter's [update].repo from the old public repo to the private home, then runs the normal `spt adapter update claude-spt` — no committed pointer in the public repo (the scripts live on the migration/v0.22.0 branch of the private home, fetched via `gh api` per internally-distributed instructions; ADR-0008). Idempotent (already-migrated nodes short-circuit), backs up the manifest before patching, fails loud on an unexpected [update].repo. Endpoints need no action — the updated adapter rides each endpoint's next start. [OK] REQ-DIST-NAME-UNIFY required: [doc, impl, unit] stages: +doc +impl +unit -int GitHub repo renamed spt-claude-code -> claude-spt; [update].repo, README, CI, package scripts, every SaberMage/claude-spt reference + install-dir test assumptions (_github/SaberMage-claude-spt) updated. Adapter name claude-spt UNCHANGED; plugin sptc UNCHANGED this milestone (succession = D4, owl-gated). U3. [OK] REQ-DIST-PLUGIN-SKELETON required: [doc, impl, unit] stages: +doc +impl +unit -int Marketplace artifact on cplugs is a thin skeleton: namespaced /sptc:* skill stubs + hooks.json + plugin.json, no embedded logic [OK] REQ-DIST-PRETOOL-POLL required: [impl, unit] stages: -doc +impl +unit -int 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. [OK] REQ-DIST-PRIVATE-HOME required: [doc, impl] stages: +doc +impl +unit -int Adapter releases publish at and update from the PRIVATE home (BigscreenVR/claude-spt-bs): the committed [update] block names the private repo with transport = "gh" (the proven 2026-07-10 private-repo machinery — gh CLI for version check + asset download), so `spt adapter update claude-spt` and `spt adapter add --release` ride the same contract with only the repo coordinate moved. The public repo gets NO new releases, ever (ADR-0008). Every consuming node is Bigscreen-internal and gh-authenticated. [OK] REQ-DIST-RC-STARTUP required: [impl, unit] stages: -doc +impl +unit -int Every spt-hosted CC bringup — [session.self] (fresh) AND [session.resume] (native-resume), base AND ccs profile — is NODE-AND-PROJECT-NAMED: display name `-n " @ (/)"` + Remote Control `--remote-control ----`, so a fleet of endpoints is cross-node- AND cross-project-distinguishable in the prompt box / /resume picker / RC list. Delivered ADAPTER-SIDE via the `claude-spt launch` spawn shim (v0.10.3+): the published fill catalog has NO {node} key (reported spt-core gap; {node} adopted v0.20.0) and NO {project} key, so the shim computes the node label (--node fill, else COMPUTERNAME/HOSTNAME/`hostname`) and the project folder (launch-cwd basename) ON-NODE and passes each name as one clean argv element. The display uses the raw project name (spaces/parens/slash fine — one argv element); RC gets the tokeny `----` form with a CHARACTER-SAFE project token (alphanumeric + `-`/`_`, other runs → `_`) since space-safety of explicit RC names is unverifiable from the public surface. Unknown project ⇒ drop only its suffix; unknown node ⇒ both names degrade to bare . `spt rc ` is unaffected (broker attaches by ENDPOINT id, not CC's RC name). U6 origin: the {id}-only -n/RC threading. [OK] REQ-DIST-REACHABILITY-NOTICE required: [impl, unit] stages: -doc +impl +unit -int Perched sessions are steered to BACKGROUND long-running work so the perch stays reachable — the spt reachability notice, ported from legacy owl's (sibling claude_skill_owl hook_prompt.rs + hook_check.rs) and renamed to spt semantics (, 'active spt perch'). Two injection sites, both perch-gated (a plain CC session is never told it has a perch): (1) UserPromptSubmit — EVERY perched turn's additionalContext closes with the general notice ('when spawning subagents or running long tasks, use run_in_background: true so you stay reachable for incoming messages'), appended AFTER any skill injection + drained messages (a notice never displaces a delivery); (2) PreToolUse — when the tool about to run is a subagent spawn (tool_name Agent, or its older payload name Task — matched both ways so a CC rename in either direction keeps the nudge alive), the targeted launch-this-in-background nudge rides the same emit, messages first, nudge last, and emits EVEN when the drain is empty (the point is catching the foreground spawn before it happens); and (3) PreToolUse — when the tool is `Bash`, `run_in_background` is not true, and the call is LONG-RUNNING by its own declaration (an explicit `timeout` of 60s or more) or by command shape (cargo, npm/pnpm/yarn, gh, pytest/jest, make, cmake, docker, sleep, `find`, `rg`, recursive `grep`), the same nudge rides the emit with a Bash-specific body — CAPPED AT THREE PER TURN (operator's number), the counter reset by each UserPromptSubmit, so the steering is early enough to shape the turn without becoming wallpaper. Non-spawn, non-long-Bash tools with an empty drain stay silent. Both ride the existing once-capped emit (spill guard unchanged). ADVISORY ONLY, deliberately: PreToolUse returns `additionalContext` and the tool runs anyway, so a nudge cannot convert the call it fires on — only the next one. Deny-and-steer (PreToolUse denying the call so the agent re-issues it backgrounded) is the only lever that would convert the current call and was RULED OUT by the operator 2026-08-04. This buys coverage, not enforcement. WHY (field, 2026-07-07 operator): every claude-spt agent ran subagents/long tasks in the FOREGROUND, going unreachable for the task's whole duration — the legacy-owl notice was the missing steering pressure. WHY THE BASH ARM (2026-08-04 grill): both legacy-owl legs were already ported and shipped, so the honest finding was not 'the feature is missing' but 'the feature does not cover the common case' — long foreground Bash (cargo, gh, test suites, recursive greps) is the ordinary way a perch goes unreachable and fired NOTHING. Observed in the grill session itself: a foreground `grep` blocked 120s and the harness backgrounded it, with the general notice already in context and no targeted nudge existing for that call. [OK] REQ-DIST-RESUME-CONTEXT required: [impl, unit, int] stages: -doc +impl +unit +int claude-spt SessionStart pulls the live agent resume context via the v0.15.0 `spt api psyche-download [--session-id ]` verb and injects stdout as additionalContext (skip on NO-CONTEXT) — closing a pre-existing parity gap (claude-spt rehydrates NO durable context today; F-020). One call returns durable role/live/project tiers + the freshest not-yet-synthesized / (trigger stripped core-side); it is also the checkpoint re-seed fast-path. [OK] REQ-DIST-SESSION-RESUME required: [unit] stages: -doc -impl +unit -int The adapter manifest declares [session.resume] — Claude Code's NATIVE-RESUME verb (`claude -r {session_id} --remote-control {id} --dangerously-skip-permissions`, keys=["session_id","id"]) — so a spt-hosted resume (`spt endpoint run --resume` / picker Resume-from-history) reloads the REAL transcript by id instead of silently re-running [session.self] = a fresh blank session. {session_id} reloads the transcript (-r); {id} threads the endpoint as the remote-control session name (the RC channel native-resume drives, the same one `spt rc` / the idle-translation-binary use); skip-permissions for the non-interactive broker PTY (REQ-HAZARD-PSYCHE-PERMS-DEADLOCK). The PTY lands in the resumed session's recorded project cwd; CC resolves the transcript by {session_id}+cwd. [OK] REQ-DIST-SESSIONSTART-BRIEF required: [doc, impl, unit] stages: +doc +impl +unit -int SessionStart injects agent-facing briefs as additionalContext: an identity brief (who + perch-live/don't-re-arm + send/reply/roster) for perched sessions (bind+boundary), and a peer-gated ring brief (spt ring + roster) for no-perch seed sessions; all prose is adapter-string-backed ([strings.briefs]), the hook only composes + {id}-substitutes, never authors prose; subagent (agent_type) sessions are skipped [OK] REQ-DIST-SHORTCUT-BASENAME required: [doc, impl, unit] stages: +doc +impl +unit -int The adapter manifest brands the `spt endpoint run` launcher shortcut as `cc-` via adapter.shortcut_basename = "cc" (the M12 cc launcher; decoupled from the sptc plugin name, ADR-0001) [OK] REQ-DIST-SKELETON-THIN required: [] stages: +doc +impl +unit -int commune/send/signoff skill bodies live in adapter strings, NOT the plugin SKILL.md (stubs: frontmatter + 'live agents only — /sptc:live first'): a perched SessionStart brief string (commune incl. --across, signoff) + the /sptc:live UPS body for the go-live moment, so reactive-skill prose rides `spt adapter update`. AMENDED 2026-07-19 (cap-driven, operator-ruled): a skeleton MAY carry the durable, CLI-free half of its own skill — concepts, reply mechanics, and the user-facing output block — because additionalContext is a HARD 9000-byte delivery limit and prose that overflows is spilled to a FILE the agent must stop and read, which is strictly worse delivery than the skeleton (always present, free, no cap). The split line is CLI COUPLING, not length: anything naming a command, flag, marker, fault token, or injected runtime block (the relay chain, psyche-download, ADAPTER_UNRESOLVED/NO_SEED, `!!wake!!`) STAYS adapter-side so it rides `spt adapter update` and can never be taught stale by a cached plugin copy. U4 + the v0.25.4 live split. [OK] REQ-DIST-SOURCE-MIRROR required: [doc, impl, unit] stages: +doc +impl +unit -int Every release pushes ONE squashed source-only snapshot commit + the vX.Y.Z tag to the public repo (git remote `mirror`) via PLAIN git only — no gh, no GitHub API (ADR-0008). The snapshot excludes the enumerated release/migration plumbing and every file naming the private home (script-authoritative EXCLUDES list); a leak guard hard-fails the mirror on any case-insensitive content OR filename match for the BigscreenVR org / private repo name / -bs shorthand, so a new leak vector must be consciously enumerated, never silently scrubbed. The public tree is deliberately not buildable or installable. [OK] REQ-DIST-UPDATE-MESSAGE required: [impl, unit] stages: -doc +impl +unit -int The adapter manifest [update] table carries a `message` markdown field, printed by spt-core ONLY on a real version apply: it tells the user to run /reload-plugins (the unavoidable TUI step) and points at the more-powerful `spt` CLI endpoint route (`spt endpoint run`) alongside /sptc:live. U1. [OK] REQ-DIST-WHOAMI-JSON required: [impl, unit] stages: -doc +impl +unit -int The hook binary resolves its own perch id ONLY from `spt whoami --json` — RECEIVE-BOTH shapes: the flat identity object {"id":…} (spt-core ≥ 0.33.0's BREAKING identity-only whoami — the very API the 2026-07-10 UPS-timeout RCA filed for; id:null / NO_PERCH-nonzero ⇒ no-perch) AND the legacy enriched wrapper .self.id (cores < 0.33.0; self:null ⇒ no-perch); any parse failure ⇒ empty. NEVER from a line of the human view, whose first line is a `SUBNET ` roster header on any subnet-member node (the doyle bug 2026-07-01: SessionStart crowned an agent "SUBNET SPT_DEV" and the brief's don't-run-whoami instruction made the wrong identity self-reinforce through an orchestration round). The shape compat is LOCKSTEP-mandatory: an adapter parsing only the legacy wrapper on a 0.33.0 node resolves EVERY harness-hosted fallback identity to empty — shortform, idle marks, and briefs silently die. [OK] REQ-DIST-WORKER-LIFECYCLE required: [impl, unit] stages: -doc +impl +unit -int Subagent working perches follow the WORKER-TRUTH wave-1 contract (spt-core v0.27.0, doyle freeze 2026-07-06) and NEVER leak silently. SubagentStart (parent context) calls `api worker-start --session-id --agent-id --agent-type ` — NO worker-id positional (core hard-mints `{parent}-w{N}`), NO token (operator ruling: sid-symmetric auth, registration stores the sid, stop accepts stored OR parent-current sid — /clear rotation covered from both ends). The MINTED id is read from worker-start's BARE STDOUT (machine-readable result; empty/refused = LOUD log, never silent) and persisted in adapter-owned state keyed by the CC agent_id (state/worker/.wid — the SubagentStop payload carries only the CC agent_id, so the mapping is the adapter's to hold; same contract-clean carrier as the F-024 sid proof). SubagentStop looks up the minted id and calls `api worker-stop --session-id ` STRICTLY: a refusal is LOUD (stderr reason surfaced — the pre-0.27.0 silent-swallow leaked 100% of workers, the 2026-07-06 six-worker leak), a missing mapping is LOUD (WORKER_STOP_SKIP breadcrumb), and the state row is cleared on success. Missing agent_id on either hook = LOUD skip, never a silent no-op. Floor: min_spt_core 0.27.0 (older cores clap-reject the id-less worker-start — the doyle-accepted skew; core's registration-time floor enforcement guards the reverse direction). [OK] REQ-DIST-WORKER-PERCH-REACH required: [] stages: -doc -impl -unit -int Subagent worker perches are runtime-REACHABLE: a `spt send` to a nested worker perch (created by subagent-start.sh -> api worker-start; hostable_types includes Worker) actually delivers. Validates existing wiring; F-022. [OK] REQ-DOCS-DRIFT required: [doc, impl, unit] stages: +doc +impl +unit -int Generated docs (llms.txt) are CI-gated against drift: a deterministic generator's output must match what's checked in, and the book must build [OK] REQ-DOCS-SITE required: [doc, impl] stages: +doc +impl -unit -int docs-site/ builds with mdBook from src/ (themed, Diátaxis-shaped, all-real-content — no placeholders) [OK] REQ-ECHO-COMMUNE-NO-INVENTED-ROSTER required: [impl, unit] stages: -doc +impl +unit -int The echo-commune summarizer directive FORBIDS inventing an agent roster: the summary may name another agent only where the transcript shows a direct exchange with it, and must never present machine/host/node names (window titles like `agent @ HOST`, CI runner boxes, `spt endpoint list` node headers) as agents. WHY (field 2026-07-29, chert + operator): two independent echo-commune runs over chert's signoff session emitted a live-context 'coordinates with peer agents … "hfenduleam" …' — hfenduleam is the NODE (the Windows runner box), mentioned in the tail only as 'hfenduleam mid-window' / 'ran it on hfenduleam mid-CI'; the unguided summarizer compiled a peers list and folded the host in. Core ingests that sentence into the DURABLE live tier, so a future session would try to message a machine. Same-day deployah summary showed the same roster-invention pattern (agents only, no host — the ban covers both: no compiled roster at all beyond transcript-evidenced exchanges). [OK] REQ-EVENT-ATTR-PASSTHROUGH-RENDER required: [doc, impl, unit] stages: +doc +impl +unit -int The adapter re-render PASSES THROUGH every envelope attribute it does not itself consume, in attribute position with its wire escaping intact, so an attribute published after our render was written is never lost at our seam. BINDING ANSWERED YES (doyle, 2026-08-25, to the discriminating question put the same day): core's REQ-EVENT-ATTR-PASSTHROUGH binds the ADAPTER RE-RENDER and is written with this surface as its audience — the three by-name cases claude-spt built do NOT discharge it. CLAUSE, routed VERBATIM as committed (core docs/event-attr-passthrough @ e1d1e240, riding v0.63.0's docs publish): 'The attribute set is open - re-render by pass-through, never by allowlist. The sender-authored attributes above are a class, not a list: new ones join the envelope over time (seal is only the newest), and they join with their obligations already in force. So the render rule for any consumer that re-emits deliveries (an adapter pipeline, a digest, a relay surface): CARRY THROUGH EVERY ATTRIBUTE YOU DO NOT YOURSELF CONSUME. Re-emitting from a fixed list of known names silently deletes every attribute added after that list was written - and the deletion is invisible at the site that wrote the list, because nothing there ever names what it dropped. The only closed list in this contract is the receiver-composed STRIP class (trust-warning, mnemonics-json), and it is a strip list, not a render list; everything else rides. KEEP THE VALUE IN ATTRIBUTE POSITION WITH ITS WIRE ESCAPING INTACT. An attribute value is attr-escaped for exactly one context. Unescaping it into a body or frame context hands a hostile sender a forgery seam - a crafted value could close your re-rendered tag and land text at your frame level, indistinguishable from a real delivery. Pass the raw attr-escaped span through; decode only where the final consumer parses attributes.' READ ONTO OUR THREE CASES BY THE FILER: `trust-warning` and `mnemonics-json` are CONSUMED by our pipeline (we render their content into our own blocks) and consuming discharges the obligation for those two; `seal` is surfaced and also discharged. EVERY OTHER attribute, including ones that do not exist yet, must ride through to the agent in attribute position, still attr-escaped, on whatever frame we re-emit (the `` / `` family) — never dropped, never unescaped into body context. OUR OWN v0.30.0 MECHANISM GENERALIZES: the raw attr-escaped carry built for `seal` (claude-spt-bs#20, now the contract's mirror clause) is the correct handling for any unknown attribute, so this is a widening rather than a new invention. MOTIVATION, independent of the wording: `hook::render_frames` keeps `from` + body and drops everything else, so each new attribute has had to be caught BY NAME after a field loss — `mnemonics-json` (v0.26.2), `trust-warning` (v0.29.x), `seal` (v0.30.0). Three losses at ONE seam is a recurrence class, not three bugs, and a by-name fix cannot close it because the next attribute is unnamed by construction. TIMING, stated by the filer: this is a NEW obligation entering the contract at v0.63.0, NOT a retroactive defect in v0.30.0 — nothing to file against what shipped, and the generalization is our release on our schedule. PAGE AMENDED 2026-08-25 (rider @ f7137293, same v0.63.0 publish) BECAUSE OUR QUESTION EXPOSED THAT 'CONSUME' WAS READER-DERIVED — the filer amended the PAGE rather than answering only us, which is the difference between a ruling and a contract. Two bullets added, VERBATIM: '"CONSUME" MEANS THE ATTRIBUTE'S DISTINCTION IS RE-EXPRESSED, NOT MERELY READ. A pipeline consumes an attribute when its handling puts the value's meaning back in front of the agent — content re-rendered into the pipeline's own surface, or a dispatch whose AGENT-VISIBLE OUTCOME differs per value (routing on type consumes it only while distinct types yield distinguishable deliveries). Reading a value and then emitting output that two sender-distinct deliveries would share is peeking, not consuming. The test is the DROP TEST: if omitting the attribute makes two deliveries the sender distinguished indistinguishable at the agent surface, it must ride.' and 'ATTRIBUTE NAMES ARE TOKENS, NOT TEXT. The envelope's name grammar is [a-z0-9_-]+. Names are hostile-reachable — envelopes arrive from peer NODES, not only from this binary — so a pass-through re-emits a name only when it matches that grammar; a tag-position span that does not is framing damage, not an attribute. Refuse or drop it LOUDLY, never re-emit it.' NEITHER of our two candidate definitions of 'consume' was right (not 'the re-emitting site reads it' — that is peeking; not 'the agent still needs it' — untestable). CENSUS RUN 2026-08-25 AND THE VERDICT IS SETTLED: `type` is NOT consumed by this pipeline and MUST RIDE. Method, complete rather than sampled: every non-test attribute read in hook.rs is enumerated — `from` (the re-emitted sender), `seal`, `trust-warning`, `mnemonics-json`, and the update nudge's own lookup, which keys on `from == spt-update` and NOT on `type` (parse_update_notify_version); a second sweep for type-keyed literals (notify / alarm / user-msg / "msg") in non-test code found only doc comments and CC-payload types, which are a different vocabulary. So `render_frames` never reads the envelope's `type` at all, and `b` and `b` render BYTE-IDENTICALLY — the drop test failing exactly as written. The idle/live arm is separately type-agnostic BY DESIGN (REQ-STUB-GENERAL-EVENT: notify, user-msg and alarm stub and park identically to a peer msg), and `type` survives there only because frame_envelope retypes the whole opening tag verbatim — carried, never consumed. Our NAMES flag was taken INTO the contract rather than left as adapter defense-in-depth, so the grammar is now a pinned rule and the test must pin exactly it: a grammar-matching unknown name rides; a tag-position span that does not match is refused LOUDLY and never re-emitted. [OK] REQ-HAZARD-ADAPTER-EXEC-BIT required: [impl, unit] stages: -doc +impl +unit -int Every Linux binary in the packed `adapter.spt` carries mode 0755 IN THE ARCHIVE, forced at tar time and asserted before the archive is accepted — a non-executable adapter binary cannot be driven at all on Linux (no hooks, no endpoint hosting), so a packaging slip does not merely fail an update, it BRICKS the adapter on every Linux node that installs it. Two traps make this silent: (1) the archive is built on Windows, where no POSIX exec bit exists, so the staged Linux binaries land 0644 while the `.exe` — the one file Linux cannot use — is the only entry marked executable; (2) `chmod 0755` DOES NOT STICK on the Windows build filesystem (measured 2026-07-31: chmod then tar still recorded `-rw-r--r--`), so the obvious fix is a silent no-op and forcing the mode at tar time is the only reliable route. THAT NO-OP IS BUILD-SIDE ONLY, and the distinction must not be carried forward wrong (lia, 2026-07-31): `chmod +x` on an ALREADY-EXTRACTED binary on a Linux node DOES take — mode reads back `-rwxr-xr-x` and the update re-runs clean — so it remains a valid emergency repair for a node stranded before a fixed release lands. It is a workaround and not a fix only because the next version bump re-extracts 0644 and fails identically, NOT because chmod fails there. The archive's self-validation must therefore assert MODES, not just entry names — name-only validation is exactly what let this ship, and it is the same class as measuring a proxy instead of the claim. [OK] REQ-HAZARD-CARRIER-CUSTODY required: [impl, unit] stages: -doc +impl +unit -int The session carrier (state/session/.sid) never advances past a FAILED strict registration. Pre-fix, handle_session_start persisted the current sid on EVERY SessionStart even when the bind/boundary spt_strict call was refused (error only logged) — which (a) poisons the NEXT rotation's auth proof (the perch still records the PRIOR sid, so the next boundary presents a sid the perch never accepted → AUTH_REFUSED → stranded perch, the F-024 class reached via carrier skew) and (b) would let the verified-identity fast path (REQ-UPS-IDENTITY-FASTPATH) trust a session the perch never bound. INVARIANT: carrier(eid, sid) is authoritative only when written immediately after a SUCCESSFUL strict bind/boundary registration this SessionStart; on failure the carrier keeps the prior sid (still the true proof for the retry). The seed leg (no endpoint id) never writes. 2026-07-10 UPS-timeout RCA prerequisite (hertz), converged 2026-07-15. [OK] REQ-HAZARD-CHECKPOINT-CLEAR-RACE required: [doc, impl, unit, int] stages: +doc +impl +unit +int The checkpoint clear+wake must NEVER submit the wake before /clear takes effect. A single inject sequence cannot straddle a /clear (the async clear re-runs SessionStart with network I/O, and every sequence must {commit} within the 5s INJECT_COMMIT_DEADLINE), so a fixed post-/clear delay races and the wake lands in the OLD session (field-observed: the followup hit first). The macro is SPLIT and synchronized on CC's own clear-done signal: (ARM) a {"checkpoint":"v1",…} envelope stashes the wake in the translation binary's in-memory pending_wake and emits CLEAR-ONLY (ctrl+s · 50ms · /clear · 50ms · enter · commit); (FIRE) the SessionStart hook, on a `clear` boundary, UNCONDITIONALLY self-sends {"checkpoint_fire":"v1"} and the binary emits WAKE-ONLY (ctrl+s · 50ms · wake · 50ms · enter · commit) iff a wake is armed, else no-ops. State lives ONLY in translate memory (no marker file; the hook is stateless). BOTH self-sends use --force-native so the signal is delivered through the translation binary's stdin (where the markers parse), never spooled to the active-poll channel (the ENLYZEAM plain-text misdelivery mode). Ordering is guaranteed by construction: the wake can only emit after SessionStart, which only fires after /clear completes. Residual (accepted v1): if the checkpoint's own clear fails to fire the signal, the armed wake fires on the NEXT clear — self-limited by the every-clear-fire model, documented in KNOWN-HAZARDS. Fires ONLY on `clear`, not `compact` (no checkpoint variant leverages /compact). [OK] REQ-HAZARD-COMPACT-STUCK-BUSY required: [impl, unit] stages: +doc +impl +unit -int A completed /compact must not leave an spt-hosted endpoint stuck ACTIVE with its parked messages undelivered until a human types. Field-proven (deployah 2026-07-21, hertz RCA): doyle's RELEASE GO was SENT (live listener delivery) 79-88ms before the operator submitted /compact; the stub the translation binary typed did not survive the compact collision, the body stayed committed in adapter msgpark, and SessionStart(source=compact) rebound the boundary but never transitioned ACTIVE->IDLE or re-drove the park — so the GO sat 46m45s until the operator's next UserPromptSubmit drained it (same black-hole class as REQ-HAZARD-STUCK-ACTIVE-NO-IDLE / -STOPFAILURE- / -INTERRUPT-, reached via /compact; the interrupt watcher cannot heal it — no interrupt marker). TWO SEAMS: (1) source-certain — the compact arm of handle_session_start must, after a successful boundary rebind, assert `state idle` and re-drive a non-empty msgpark by self-sending a redrive message through the OWN translation binary (--force-native), whose stub opens the turn whose UserPromptSubmit drains the park (custody unchanged: SessionStart never drains/commits the park itself — only a UPS/PreToolUse drain may stage, only post-emit commit destroys). (2) TRIGGER-GATED (the negative leg): an AUTO compact fires the same SessionStart(source=compact) MID-TURN — the agent is genuinely working, so marking idle or injecting a redrive there is the busy-injection hazard, and the continuing turn's next PreToolUse drains the park anyway (REQ-STUB-PARK-NO-STALL). The hook payload carries no trigger; the transcript's compact_boundary record does (`"subtype":"compact_boundary"`, `compactMetadata.trigger` = manual|auto, ground-truthed against live transcripts 2026-07-21). Read the LAST such record via the payload's transcript_path: trigger=auto -> skip (loud log); trigger=manual OR undeterminable (no path / no record / parse fail) -> heal, because the black-hole direction (46min unreachable) beats the noise direction (one stub turn CC queues behind the running turn, self-corrected at its next busy mark). An empty msgpark redrives NOTHING (no dead prompt: an operator compacting an idle session must not wake the agent). The redrive body must be self-explanatory to the receiving agent. [OK] REQ-HAZARD-DANGLING-FRAME-LOUD required: [doc, impl, unit] stages: +doc +impl +unit -int A poll frame truncated mid-frame is NEVER dropped silently by render_frames. The original loop break'd on a dangling frame (opening ', or a body with no closing ) — the message was taken from the spool (delivered=1) but nothing surfaced to the agent: the §2.8 silent-loss family, one seam upstream (framing instead of stdout channel). Hardening invariant: the dangling-frame arm emits a loud marker carrying the surviving partial content (unescaped) plus a spool-recovery pointer (owlery//spool.db, messages table), riding the existing capped emits on both drain paths. Never field-observed (seed #9's head-truncations were core-side idle-inject pacing with COMPLETE spool rows — structurally unable to produce this shape); closed by construction before it could cost a message. [OK] REQ-HAZARD-DELIVERY-AS-USER-INPUT required: [doc, impl, unit] stages: +doc +impl +unit -int A message DELIVERED to this session is never reported to spt-core as this session's USER_INPUT. Idle delivery types into Claude Code's input box, so an inbound peer message, a notify and a checkpoint wake all reach UserPromptSubmit wearing a prompt's clothes — today as ADR-0007 stubs (``, ``), and from a stale resident translation binary possibly still as a whole `` envelope. Two distinct harms, one guard. FIRST, an IO consumer counting user turns counts a delivery as one, and the funnel publishes words the user never typed. SECOND, AND THIS IS THE ONE THAT MUST NOT WAIT: spt-core parses shortform out of the payloads an adapter reports at its ingest edges, and it deliberately never parses a RECEIVED message body — 'a peer can write a tag at you all day; it is text. Nothing you receive can make you send.' That guarantee is core's to keep only over text core itself handles. Hand a delivered message back to core as OUR user input and it is laundered into an ingest edge: the peer's `@<…@>` tag is now in our USER_INPUT payload and dispatches FROM US, to targets the peer chose. The guard therefore lands in the release that starts reporting the busy payload (REQ-IO-USER-INPUT-PAYLOAD), not in the later release that declares `[io]` compliance — by then it would be a fix rather than an invariant, and the window between the two releases would be exactly the shape the guarantee forbids. Same family as the lesson that a re-render is a lossy copy invisible at the far end; here the copy is not lossy but LAUNDERING, and it is invisible at BOTH ends — the peer sees a message delivered, we see a dispatch we did not author. [OK] REQ-HAZARD-DIGEST-ROOT-BLIND required: [impl, unit] stages: +doc +impl +unit -int The digest extractor's projects-root chain is SESSION-AWARE and never silent: it walks the precedence rungs (--config-dir capture → $CLAUDE_CONFIG_DIR env → legacy --in → ~/.claude/projects) and uses the first rung that actually CONTAINS the requested session's transcript — a rung that resolves to a real directory without the session must NOT end the search. When no rung holds it, the extractor says so on stderr (roots tried + session id) instead of exiting 0 with empty stdout. Rationale (field 2026-07-25, perri): the perch's bind-time {CLAUDE_CONFIG_DIR} capture is STALE the moment a later session boots under a different config root (a ccs-captured `~/.ccs/instances/` perch re-bound by a plain `~/.claude` session). The old first-rung-wins chain pointed the locator at the ccs tree, found no .jsonl, and returned empty-and-quiet — so the endpoint's digest carried spt-injected Context rows ONLY, its newest Agent entry frozen at the pre-reboot session, with no error anywhere on either side. Every digest consumer goes blind together: peers reading the endpoint, the Psyche's context sync, and any tool whose control channel is the digest (rebound's `!!done!!`/`!!wait=m!!` codes were unreachable for the whole session). [OK] REQ-HAZARD-DIGEST-ZERO-INPUT-SILENT required: [impl, unit] stages: +doc +impl +unit -int A LOCATED transcript that yields NO input records is never exit-0-silent. REQ-HAZARD-DIGEST-ROOT-BLIND made the no-rung-holds-it case loud, but the diagnostic fires ONLY when the locator misses — a transcript that IS found, parses N entries, and emits zero role="input" rows still exits 0 with empty-of-input stdout and no word anywhere. That is the SAME observable the field fault wore (entries accreting, `input_seq` null forever, silence read as an idle agent), so the honest boundary is the OUTCOME, not the locator. The extractor now tallies what an extraction actually yielded and reports on stderr when a real transcript produced no input: user turns present but every one suppressed is a DEFECT (the filter ate the session's own turns); no user turns at all is reported as what was seen, since a caller cannot tell an unstarted session from a broken extraction without being told which it is. Exit stays 0 and stdout stays untouched — spt-core owns "the digest is empty"; this requirement owns "and here is why it is empty." Rationale (SEAL-DUAL-TRIGGER-ABSENCE, doyle's binding regression hold, 2026-07-25): sealing a turn takes its identity from an extracted Input record, so zero-input IS zero-seal — ONE cause wearing two symptoms. RCA the extraction, never the seal effect; heuristics stay banned from driving sealing (ADR-0048). [OK] REQ-HAZARD-EMPTY-RESPONSE-COMMIT required: [doc, impl, unit, int] stages: +doc +impl +unit +int EVERY event-typed delivery the translation binary answers must terminate with a {commit} — including deliveries it deliberately answers with no keystrokes (a checkpoint_fire with nothing armed; an event without an envelope). An event answered with ZERO records leaves the broker's inject sequence unterminated → the 5s INJECT_COMMIT_DEADLINE expires → TRANSLATION_FAULT terminates the binary (pinned 2026-07-04 via doyle's 3-discriminant collab, iso wtrace stderr: fault follows the DUPLICATE fire, not the ARM; the ARM's commit was accepted). Because the SessionStart hook self-sends checkpoint_fire on EVERY clear (stateless by design), the unarmed-fire zero-record answer killed the binary at every non-checkpoint /clear boundary — the deterministic half of the B6 ghost. Scope is EVENT-typed responses ONLY: init/input/unknown stdin lines are protocol lines, not inject deliveries — an unsolicited {commit} outside a sequence is protocol noise and they stay silent (doyle-confirmed scoping 2026-07-04). Joint contract gap, not an adapter misread: the published harness contract documents {commit} as the sequence terminator but nowhere states an empty response still requires a bare {commit}, and its missed-commit consequence text is stale (says raw-inject fallback, removed core-side v0.14.3; truth = fault + terminate). Core-side docs amendment + explicit empty-response rule + miss!=fault deadline semantics ride doyle's C-1 chunk. [OK] REQ-HAZARD-HOOKCMD-DISPATCH-LOCKSTEP required: [doc, unit] stages: +doc +impl +unit -int The [strings].hook_cmd SHAPE and the plugin dispatch.sh shim move in LOCKSTEP (or the dispatch contract is pinned): a shape change in one layer with the other stale must DEGRADE, never brick — the 2026-07-01 mid-session skew (old 0.1.8 dispatch execing `"$bin" ` × new bare-path hook_cmd) ran `claude-spt PreToolUse` → unknown-subcommand → nonzero exit on EVERY hook event → CC blocked all tools AND looped the Stop hook; zero self-repair until the operator ran /reload-plugins [OK] REQ-HAZARD-INHERITED-IDENTITY-ADOPTION required: [doc, impl, unit, int] stages: +doc +impl +unit +int The hook's identity FALLBACK resolves by SESSION, never by inheritance — a descendant session never acts as its perched ancestor. Any process launched from a perched agent's own tool call inherits $SPT_ENDPOINT_ID / $OWL_SESSION_ID / $SPT_HOST_PID; when that process is a Claude Code session, `self_id` calls `spt whoami --json` setting ONLY OWL_SESSION_ID and letting the rest of the environment through, so core answers with the ANCESTOR's id even though the caller handed it a session id belonging to no perch. The descendant then WRITES as the ancestor (`state busy|idle --session-id `), re-pointing the ancestor's session pin at a session the ancestor does not own — observed in the field, it moved perri's own pin during the §7.3 probe's first rig and needed a repair re-bind. This SURVIVES REQ-HAZARD-LATE-ACTIVATION-DARK: verified_env_id correctly rejects the inherited endpoint id (carrier sid != payload sid) and the whoami fallback then re-adopts the identity the fastpath just refused. Measured on the live node 2026-07-26 (read-only whoami from a genuine descendant of perri's host process): all SPT_*/OWL_* scrubbed => {"id":null} exit 1, so process ANCESTRY resolved nothing and the vector is env inheritance, NOT the lineage walk the §7.3 adjacent note assumed; SPT_ENDPOINT_ID inherited + a non-matching OWL_SESSION_ID => the ancestor's full identity, exit 0 (inheritance beats an explicit non-matching sid); real OWL_SESSION_ID with the endpoint id scrubbed => correct identity, the path a fix must not disturb. Shape: clear $SPT_ENDPOINT_ID and $SPT_AGENT_ID on the whoami child call so an unperched descendant resolves EMPTY and no-ops. TWO ROUTES REMAINED OPEN after that scrub shipped (found 2026-07-26 while building the spill int probe, whose own runs tripped them): route (d) THE SESSION LEG IS ITSELF AN INHERITANCE LEG — self_id asked whoami with the AMBIENT $OWL_SESSION_ID whenever it was set and used the payload session_id only as a fallback, so a descendant carrying its ancestor's REAL sid (not the no-perch sid every earlier probe assumed) never asked about its own session at all; observed on the live node, the hook binary given a payload session_id belonging to a DIFFERENT live perch answered as perri, marked perri busy, and ran perri's poll and msg-park drain. Route (e) THE AMBIENT ENDPOINT ID IS READ RAW AT EVERY SIDE-EFFECTING SITE — verified_env_id is the only reader that proves custody, while the msg-park drain and commit, the wake-park read and clear, the digest cursor, the role-edit round trip and the Stop leg all take $SPT_ENDPOINT_ID directly and then WRITE against it, so a descendant consumes the ancestor's parked message bodies and clears its wake directive. Shape of the close-out: identity resolves from the session the PAYLOAD names (the ambient sid is never an identity input, though the variable is still SET on the child call — with the resolved value); each handler resolves ONCE and threads that proved id to every side effect, leaving presence-only reads (skill arm selection, register_verb — they choose wording and write nothing) on the ambient variable; and a refused ambient id is logged loudly, because the original scrub was invisible in argv and only found by probing. Lane is ADAPTER-side — core's documented order is $OWL_SESSION_ID / $SPT_AGENT_ID / process ancestry, and handing it a non-matching sid beside an inherited endpoint id is our call site's choice; whether core should additionally REFUSE a sid that matches no perch is filed with doyle and this requirement does not wait on it. ROUTE (f), found in the field 2026-07-26 (doyle's own diagnostic): the env guards hold only when the SPAWNER cooperates. Core's [session.psyche_*]/[session.echo_commune] roles set recursion_guard_env, env_remove the identity vars, and both shims spawn hook-isolated (--safe-mode, else --settings {"disableAllHooks":true}) — those inner sessions are inert three ways over. A HAND-ROLLED `claude -p` run inside `owlery//nested/-psyche` gets none of it: it inherits $SPT_ENDPOINT_ID from the launching shell and carries NO $SPT_HOST_PID, so route (e)'s three-way anchor check reads unknown-not-foreign and the adoption lands. Observed: doyle's probe bound HIS perch onto itself, drained a delivery of his mail, and exited 3s later — his perch stayed pinned to a dead session for an hour (core's last ENDPOINT_INJECT for him is at that same second; every take after it is hook-poll, endpoint still reading ONLINE + CONTROLLED). Closed STRUCTURALLY on the cwd, which an uncooperative spawner cannot forge the way the env can: nested_perch_owner() reads the session cwd and a session inside owlery//nested/ never seeds, binds, rotates a boundary, stamps state, or gets a brief. The whole SessionStart handler returns early rather than only refusing the id, because register_verb routes on PRESENCE — refusing the id alone would have silently re-routed a bind into a seed. Scoped to the OWNER so ordinary project cwds and unrelated endpoints are untouched. [OK] REQ-HAZARD-INTERRUPT-STUCK-BUSY required: [impl, unit] stages: -doc +impl +unit -int An operator Esc-interrupt must not leave an spt-hosted endpoint stuck ACTIVE/undrainable. Field-proven (lia 2026-07-08): an Esc fires NO `Stop` and NO `idle_prompt`, so the endpoint stays ACTIVE and the daemon black-holes every inbound (20+ min silence until the next UserPromptSubmit happens to drain the spool). An Esc DURING a tool call fires `PostToolUseFailure` with `is_interrupt == true` — the zero-latency heal hook. FIX: wire `PostToolUseFailure` → `handle_post_tool_use_failure`; on `is_interrupt == true` → `state idle` (self_id-resolved, sid-scoped). A NON-interrupt tool failure (a command exited nonzero) is NOT a turn-end — the agent keeps working — so it must NOT mark idle (that would drain the spool mid-turn); absent/false `is_interrupt` is likewise a no-op. Interrupt logs unconditionally (load-bearing); a non-interrupt failure logs only under the $SPTC_HOOK_TRACE Step-0 flag (else it spams the hot tool-failure path). COVERAGE NOTE: this heals Esc-during-a-tool-call; a pure-thinking Esc (no tool in flight) fires no PostToolUseFailure — whether it is covered by an idle_prompt Notification or needs the fix-3 digest-pull watcher is gated on the Step-0 live repro (NEXT-WORKLOAD-PLAN Item 1). [OK] REQ-HAZARD-LATE-ACTIVATION-DARK required: [impl, unit] stages: +doc +impl +unit -int A session ACTIVATED MID-FLIGHT never binds a perch to a session id it does not own, and is never left dark without saying so. When the sptc plugin (or the adapter registration) arrives AFTER Claude Code started, no SessionStart ran under our hook: the session is unseeded and carries none of the session env the skills read. `/reload-plugins` restores the surface — hooks fire, `/sptc:*` skills inject — but it never re-runs SessionStart, and neither does `/clear` (the boundary path logs `no endpoint id resolvable` and skips). A perch bound from such a session records a core-MINTED synthetic session id (`sess--`) with no adapter and no {CLAUDE_CONFIG_DIR} capture, so its digest can never locate a transcript — `spt endpoint digest` answers NO_DIGEST forever while the session looks healthy from every other angle (probe 2026-07-26, perri; liam's field instance ran 2 days undetected). TWO HALVES, because the probe showed a seed alone does not carry the id into the perch: core takes the session id from the BRINGUP PROCESS's $OWL_SESSION_ID (same `spt ready`, same session — synthetic id without it, the real sid with it), and that env can never be repaired mid-session ($CLAUDE_ENV_FILE is a SessionStart/CwdChanged/FileChanged-only channel). So (1) the first prompt of an unregistered session re-fires the SessionStart seed on the resolved claude anchor and tells the operator what is empty and what to substitute, ONCE; and (2) every `/sptc:ready`|`/sptc:live` turn in such a session carries the explicit bringup command that passes the session id — the moment the damage would otherwise be done, several turns after the notice. When no claude anchor resolves, no seed is sent and the notice is the RESTART-REQUIRED one instead, naming `/reload-plugins` as explicitly NOT the cure. Detector is env-shaped and subagent-safe: harness-hosted ($SPT_ENDPOINT_ID unset) AND $OWL_SESSION_ID unset — never a MISMATCH, since a subagent inherits its parent's export with its own sid. [OK] REQ-HAZARD-MSYS-PATHCONV required: [unit] stages: -doc -impl +unit -int Hook wrappers read the CC payload from stdin, never from a /-leading positional argv (Git-Bash/MSYS path-mangles those on Windows) [OK] REQ-HAZARD-PARK-DRAIN-DEADLINE required: [impl, unit] stages: -doc +impl +unit -int A deadline-killed hook must never consume a parked message body it could not deliver (the 2026-07-15 doyle<->todlando body-loss incident, operator-grounded root shared with the UPS-timeout RCA). MECHANISM: UserPromptSubmit destructively drained (read+DELETED) the msg park, THEN ran the deadline-dominating identity fanout (whoami -> enriched endpoint list -> synchronous Git, 15-45s vs CC's ~30s external hook ceiling); CC killed the hook before its additionalContext emitted, so the deleted bodies never reached the session — bare stub rendered, body gone, daemon rightly convinced delivery completed. TWO DEFENSES, both required (CC offers NO emission-proof seam, so full closure by ordering alone is impossible): (1) ORDER — deadline-dominating stages (identity resolution incl. the whoami fallback, the /sptc:live roster listing) run BEFORE any destructive park read; (2) TRANSACTIONAL CUSTODY — drain STAGES (renames .park -> .park.pending, reads, never deletes; re-drains found .pending as REDELIVERY), and the destructive commit (delete .pending; also the deferred wake-park clear) is the hook's FINAL act, strictly after emit. A kill before emit leaves everything staged for the next drain; a kill in the emit->commit sliver duplicates — duplicates beat silent loss (the park's documented stance). RESIDUAL (documented, accepted): the duplicate window, and body loss requires a commit without an emit, which the ordering forbids by construction. [OK] REQ-HAZARD-PERCH-COLLISION required: [unit] stages: +doc +impl +unit +int The acceptance harness spawns every nested claude SUT under a DISPOSABLE identity (SPT_AGENT_ID=sptc-ci-), never a live agent's perch id — a colliding id tears down the live agent's perch + poll stream (name-keyed, last-establish-wins) [OK] REQ-HAZARD-PRETOOL-CONTEXT-ENVELOPE required: [doc, impl, unit] stages: +doc +impl +unit -int Every PreToolUse emission rides the {"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":...}} JSON envelope — NEVER raw stdout. CC discards plain PreToolUse stdout (unlike UserPromptSubmit, whose raw stdout IS the context channel), so the F-021 mid-turn delivery leg's raw emission made every message drained during a busy turn a SILENT BLACK HOLE: taken from the spool (delivered=1, core honest) then thrown away by CC — invisible to agent, sender, and spool (field 2026-07-07, doyle node-wide RCA; three doyle->perri messages eaten including the outage evidence itself; binary exonerated by direct-drive repro). The cap decision is made on the WRAPPED byte size (JSON escaping can double newline-heavy drains); an over-cap drain spills the RAW text and sends a WRAPPED overflow pointer — a taken message must always surface somewhere the agent can read. Check ~/.claude/reference_docs/claude-code-hooks.md PER EVENT before wiring any hook output — never by analogy from another event. [OK] REQ-HAZARD-PSYCHE-FOREIGN-CONFIG required: [impl, unit] stages: +doc +impl +unit -int Adapter-spawned INTERNAL sessions (psyche turns, the echo-commune summarizer) must not load the user's foreign configuration — hooks, plugins, skills, CLAUDE.md memory, MCP servers. Field-reported (operator 2026-07-22): these sessions inherit the user's config root, so every user-installed hook and plugin fires inside them — users see their hooks triggered by sessions they never started, and prompt-injecting hooks actively corrupt the psyche's output (observed live: a persona-injecting UserPromptSubmit hook restyling commune summarization). The existing recursion-guard env silences only OUR hook; foreign config had no guard. FIX (binary-side, in the shims — no manifest seam, so base and every profile that routes through the shims are covered identically; :ccs overlays only session.self/resume and inherits the base psyche/echo tables): append `--safe-mode` to every internal claude spawn — it disables hooks from ALL sources (user/project/local settings AND plugin hooks), all plugins, skills, CLAUDE.md, MCP, auto-memory, while KEEPING authentication (docs-confirmed; OAuth field-verify owed on first post-ship psyche run). Version-tolerant: a cheap pre-spawn probe (`claude --safe-mode --version`, exit 0 iff the arg parser accepts the flag) gates it; an older binary degrades to `--settings '{"disableAllHooks":true}'` (kills all hooks including plugins'; plugins/skills/CLAUDE.md still load — weaker but auth-safe on any version), and a failed probe reads as unsupported (degrade, never block the turn). Operator-owned live/ready sessions are UNTOUCHED — user hooks belong in sessions the user owns. Guard the routing assumption as a PREDICATE: any profile overlaying a psyche/echo-commune session command must still route through the claude-spt shim (a rerouted command would bypass the isolation silently). [OK] REQ-HAZARD-PSYCHE-HOST-THRASH required: [doc, unit] stages: +doc -impl +unit -int The psyche host's resident loop is PACED and TERMINAL on persistent instant cycles: a healthy iteration blocks (perch poll or a real claude turn); when iterations complete sub-2s repeatedly (claude dying at spawn — the F-h untrusted-cwd trust prompt — or a thrashing poll), the host backs off exponentially (500ms doubling, 5s cap) and after 8 consecutive instant cycles exits LOUD + NONZERO (PSYCHE_HOST_GIVE_UP stderr, exit 3) so the wrapper DEATH becomes visible to spt-core's psyche_host_error/residency machinery — a silent internal loop is invisible to it (field: ~3 boots/sec for 30min, ordinal 5358, F-h). Threshold coupling (doyle-ruled): core's C3(b) backstop trips at >=10 ledger boundaries/60s; for the pure instant-death class this guard fires FIRST (<=8 boundaries, <60s wall clock); failure modes that dodge the streak (just-over-threshold cycles resetting it) legitimately reach the core backstop — a core trip on a guarded wrapper is correct backstop behavior, not a bug. [OK] REQ-HAZARD-PSYCHE-IDENTITY-ENV required: [doc, unit] stages: +doc +impl +unit -int Both psyche roles ([session.psyche_init] gate + [session.psyche_resume] spawn) declare env_remove = ["OWL_SESSION_ID", "SPT_AGENT_ID"] — a spawned Psyche must NEVER inherit its parent session's identity env, or its harness hooks resolve "self" to the PARENT (or a foreign top-level perch) and stamp/rebind THAT perch (field-observed F-028 C2: f015b-probe-psyche rebound hall-a's info.json.session_id via authenticate()'s dead-owner re-pin). The adapter's half of the f028 cross-perch-contamination guard pair; spt-core runtime honors role env_remove (public contract: manifest.schema.json SessionRole.env_remove). [OK] REQ-HAZARD-PSYCHE-PERMS-DEADLOCK required: [doc, unit] stages: +doc +impl +unit -int Every CC process spt-core spawns NON-INTERACTIVELY carries --dangerously-skip-permissions: each claude-spt-psyche turn (seed + every pulse, detached/null-stdin), both [session.self] bringup commands (base claude + ccs profile, broker PTY), AND [session.resume] (native-resume into the broker PTY). An interactive permission prompt with no operator deadlocks the turn (silently, for the detached Psyche). The Psyche additionally runs inside a Read/Edit/Write tool sandbox so auto-approve is bounded. [OK] REQ-HAZARD-QUIET-LATCH-STRANDED required: [doc, impl, unit] stages: +doc +impl +unit -int A `clearing` latch whose `/clear` never arrives is HEALED, never left standing: suppressing Stop's idle mark means a lost `/clear` would strand the endpoint BUSY forever — the stuck-ACTIVE black-hole class, which already has four entry points and costs unbounded unreachability. Two independent trips. (1) FALSIFIER, primary: the latch records whether a Stop was seen. A UserPromptSubmit ALWAYS proves a new turn started; a PreToolUse proves it only once a Stop has been seen (PreToolUse also fires inside the arming turn, which is the window itself). Either proof means the `/clear` never fired — drop the latch and log loudly. (2) BACKSTOP: a wall-clock stamp on the latch, generous (15 minutes), so a crash that fires no hook at all heals on the next hook of ANY kind. Additionally, ANY `SessionStart` source drops the latch — `clear` is the success path, `startup`/`resume`/`compact` are the stale heal — and any path that marks idle for other reasons (the receive-heal on StopFailure/PostToolUseFailure) drops it too, so 'latched' and 'idle' can never both hold. THE HEAL DOES NOT ASSERT IDLE FROM A HOOK THAT CANNOT KNOW THE TURN STATE: it drops the latch and lets the normal machinery resume — a UserPromptSubmit/PreToolUse heal continues into the busy-mark + poll that delivers the spooled bodies safely mid-turn, and a Notification(idle_prompt) heal falls through to that handler's own idle mark. Asserting idle from a mid-turn hook is the busy-injection hazard (the same reason auto-compact skips its idle mark). A SHORT TIME CAP ALONE WAS REJECTED: the window is an unbounded turn, so any N either expires mid-window (re-opening the bug the latch exists to fix) or leaves a real black hole standing for N minutes. [OK] REQ-HAZARD-RATE-LIMIT-STUCK-BUSY required: [impl, unit] stages: +doc +impl +unit -int A session-limit 429 must not leave an spt-hosted endpoint stuck ACTIVE with the limit banner read as agent activity. When a turn ends on the account session limit, CC files a SYNTHETIC assistant message (model ``, `isApiErrorMessage: true`, `error: rate_limit`, text `You've hit your session limit · resets ` and land attacker text at OUR frame level, where a spoofed `` is indistinguishable from a real delivery — defeating the structural discriminant every one of these blocks depends on. BUILD: a `` sibling block at our frame level LEADING the message it binds, ordered BEHIND the trust warning and the monic (the caution keeps the lead it already has; the citation sits closest to the body it binds), with THE TOKEN CARRIED AS AN ATTRIBUTE HOLDING THE RAW, STILL-ATTR-ESCAPED WIRE VALUE exactly as `from` is — the wire form cannot contain a raw quote, `<` or `>` (they arrive as `"`/`<`/`>`), and a real token is 8-10 characters of narrow lowercase for which escaped and unescaped are byte-identical: faithful for every legal value, unforgeable for every illegal one. WE NEVER VALIDATE THE SHAPE — a verdict about a token is `seal verify`'s to make, not a renderer's (a verdict may not assert more than it measured, applied at authoring time). A present-but-EMPTY `seal=""` renders LOUDLY (absent and blank are different facts and only one is a bug upstream; the defect class here is a silent drop, so the fix may not introduce a quieter one) and offers no verify verb for a token that is not there. The pairing is per-frame: one block per sealed frame, riding the same delivery, never batched with another's and never reordered. The digest carries the span for the same reason the other two are carried: a record that shows a sealed message with its token stripped hands the reader a delivery it cannot follow back to the evidence. NO CHANGE IS OWED ON THE IDLE/LIVE ARM, verified by reading rather than assumed: `translate::frame_envelope` retypes the envelope's opening tag verbatim, so the attribute already survives there. [OK] REQ-SESSION-ECHO-COMMUNE required: [impl, unit] stages: -doc +impl +unit -int The [session.echo_commune] bounded-summarizer role (published contract: 'when a session ends without a signoff, spt-core runs a bounded summarizer over the session's history so the context delta is captured anyway'; core 0.27/0.28 also spawns it from the commune-sync per-event turn — flynn 2026-07-07 field pin) is declared and shipped: `claude-spt echo-commune --id {id} --session-id {session_id}` accepts history on STDIN when fed (kept for the future published wiring — doyle field pin 2026-07-07: core does NOT stdin-feed [history] today) and otherwise SELF-LOCATES the transcript across rungs, most-authoritative first: the perch info.json read_env.CLAUDE_CONFIG_DIR (doyle-sanctioned — the role spawn does not inherit the session env, so ccs-relocated transcripts are invisible to the env rung), $CLAUDE_CONFIG_DIR env, ~/.claude/projects, then every ~/.ccs/instances//projects. It takes a BOUNDED tail (whole JSONL lines, ~48KB cap), runs ONE headless `claude -p` turn (psyche-parity sandbox, Read-only tools — a summarizer never writes), and prints the context-delta summary to stdout for core to ingest. FAILURE DISCIPLINE (v0.15.4 revision — v0.15.2's exit-1-on-locate-miss rode the psyche host's 3-strike budget and LATCHED doyle's + perri's hosts): a locate-miss after every rung is GRACEFUL — ECHO_COMMUNE_NO_TRANSCRIPT: stderr breadcrumb + an explicit no-delta marker on stdout + exit 0 (the miss stays visible in the ingested mind; the host survives); only REAL faults (claude spawn-fail/nonzero/empty-output) stay loud nonzero (ECHO_COMMUNE_FAIL:). recursion_guard_env="SPT_ECHO_COMMUNE" rides the role table; the hook binary BAILS (exit 0, zero api calls, zero perch writes) when $SPT_ECHO_COMMUNE is set, so the summarizer's spawned claude can never seed/bind/rebind a perch (sibling of the F-028 identity-env scrub, which rides env_remove on this table too). [OK] REQ-SETUP-ACTIVATE required: [doc, int] stages: +doc -impl -unit +int /sptc:setup, after confirming the spt binary is present, ACTIVATES the claude-spt adapter (`spt adapter add` — the manifest file in local dev; `--github ` for end-users) so profiles/strings/hints/[digest] go live, then verifies (adapter no longer `deregistered`). Binary-present is NOT a no-op — this is the F-005 post-install activation bridge. [OK] REQ-SETUP-CCS required: [doc] stages: +doc -impl -unit -int /sptc:setup wires the ccs integration (SCOPE LOCKED setup #7): if `~/.ccs` is present, point the user at the shipped `claude-spt:ccs` profile (routes live/ready sessions through `ccs`, a drop-in for `claude`, via `--adapter claude-spt:ccs`) + sanity-check `ccs` on PATH; if absent, offer to install ccs with a one-sentence value prop (optional — base claude-spt is unaffected). [OK] REQ-SETUP-SUBNET required: [doc] stages: +doc -impl -unit -int /sptc:setup offers subnet onboarding (SCOPE LOCKED setup #3/#4): detect membership (`spt subnet status`); not-in-a-subnet -> offer create (`spt subnet create`, prints code/URI/QR) or join (`spt subnet join`); in-a-subnet -> show-code to invite / add-this-machine. Delegates the verb mechanics to /sptc:subnet and surfaces the OS-elevation requirement (create/join/show-code are seed-reveal/enroll-gated) with the per-context elevation paths; automated context-aware elevation is a deeper wave. [OK] REQ-SKILL-ARG-HINT required: [doc, impl, unit] stages: +doc +impl +unit -int Every /sptc:* skill declares an `argument-hint:` frontmatter field so Claude Code renders the slash-command's expected arguments (empty "" for no-arg skills); values carrying YAML-significant chars ([ ] { } | #) are quoted so the frontmatter parser does not mangle the hint. Legacy-parity with the sister project claude_skill_owl (its HINT-01 presence + HINT-04 quoting requirements). [OK] REQ-SKILL-KNOCK required: [doc, impl, unit] stages: +doc +impl +unit -int `/sptc:knock` teaches spt's ACCESS CONTROL — per-endpoint grants over MSG / RC_VIEW / RC_ATTACH and the rest of the control surfaces — and is distinct from `/sptc:subnet`, which is network MEMBERSHIP (pairing machines). Verbs, from the published CLI: bare target = `send` (ask an endpoint to let you reach it), `list` (what is waiting for you), `approve`, `deny`, `new-code` (mint an invite), `redeem` (present one you were given). AMENDED for spt-core 0.54.0 (deployah, flagged at the tag): DIRECTIONALITY IS NOW MANDATORY ON THE ASKING PATHS AND ABSENT FROM THE ANSWERING ONES. `send` and `redeem` each require exactly one of `--send-receive` / `--send-only` — no default, a bare invocation refuses; the flag describes the ASKER's own side (`--send-receive` pre-authorizes the reverse, `--send-only` declines it on the record). `--mutual` / `--one-way` are parse errors anywhere, with no deprecation aliases. `approve` / `deny` / `new-code` take NO directionality flag at all: the counter-knock on approve is REMOVED, not renamed, so reaching someone you approved is a knock BACK. `knock list --json` renames the `mutual` key to `send_receive` — a SILENT break, since a reader of the old key gets nothing rather than an error (searched: the adapter binary reads neither key). Pre-authorizations armed under the old flags are untouched and still honored. THE 0.54.0 SHAPES ARE READ, NOT OBSERVED — taken from the tagging agent's contract statement while this node served 0.53.0, and NOT from the node-local docs, which are the daemon's own build and therefore publish the installed vintage (the F-044 rule). They are re-verified against a 0.54.0 daemon before the release that carries them publishes; the release is held for the fleet rollout because a static skill body cannot serve both versions (docs/plans/CORE-054-MIGRATION-PLAN.md). Shape mirrors `/sptc:subnet` exactly: a thin skeleton `plugin/sptc/skills/knock/SKILL.md` (frontmatter + argument-hint + the one-tool-call operative note) plus a file-backed `[strings.skills].knock` body UPS-injected at invocation, and the skill id joins SPTC_SKILLS so the BARE `/knock` shortname resolves too. Two `[[hints]]`: one keyed on knock/permission language for the verb set, one on the code-redeem language for the redeem path. KNOCKS ARE QUIET BY DESIGN AND THE ADAPTER KEEPS THEM QUIET: the published contract is that a knock 'lands in that endpoint's inbox and is never pushed at its agent — someone has to look'. SessionStart surfacing of pending knocks was PROPOSED and CORRECTED BY THE OPERATOR (2026-08-04): the quiet is a SECURITY PROPERTY, not a gap. All three paths stay user-initiated (send, answer, mint/redeem a code) — no surfacing, no polling, no brief line. A quiet mechanism is not necessarily a broken one. [OK] REQ-SKILL-LIVE required: [doc, impl, unit, int] stages: +doc +impl +unit +int /sptc:live upgrades THIS CC session to a LiveAgent: base claude-spt is live-capable ([session.psyche_init] in the BASE manifest, Option A) so ready-vs-live is the COMMAND not a profile — bare `spt api listen ` stamps the perch state=live_agent and the daemon hosts the Psyche (poll stays ready, livehost.rs:282 gate); the claude-spt-psyche runner keeps the Psyche claude alive (one --continue turn per pulse, NOT one-shot `claude -p`); a resident Monitor relay is the single delivery pipe; zero --adapter (host_binaries resolution, spt-core v0.9.0+) [OK] REQ-SKILL-LIVE-SPT-HOSTED-BRANCH required: [doc] stages: +doc +impl +unit -int The /sptc:live bringup instructions BRANCH on the session's delivery substrate: a NORMAL (operator-launched) CC session arms the step-2 Monitor `spt api listen ` resident relay as its single delivery pipe; an SPT-HOSTED session (daemon-launched via `spt endpoint run`, broker-delivered — recognized by the SessionStart identity brief already present + the perch bound before bringup) must NOT arm that Monitor listener. Field ground truth (flynn 2026-07-07, live-perch-monitor-reachability): in an spt-hosted session an in-session `spt api listen` CANNOT re-arm the perch — the bash.exe child breaks by-pid host_binaries resolution (ADAPTER_UNRESOLVED) and the Monitor-child pid breaks seed lineage (NO_SEED); inbound is ALREADY broker-delivered to the session's turn (the `` hook-injection / existing relay), so a second listener is redundant at best and a resolution-fault at worst. The skill states the branch explicitly, names the two failure branches, and directs the spt-hosted agent to rely on its existing broker delivery (send + continue; the reply surfaces on its own). ready.md carries the same branch note (a ready spt-hosted session likewise must not arm a second `spt ready` listener). [OK] REQ-SKILL-SUBNET required: [doc, int] stages: +doc -impl -unit +int The adapter ships subnet membership skill(s) (create/join/show-code) wrapping the published `spt subnet ` surface — the LOCKED-ADD cross-subnet entrypoint (v1 topology is mandatory, SCOPE) [OK] REQ-SKILL-TOOL-INJECTION required: [impl, unit] stages: +doc +impl +unit -int A MODEL-INVOKED skill gets the same operative body a typed slash-command gets. Claude Code routes to a skill two ways: a leading slash-command (the prompt reaches UserPromptSubmit, where `skill_key` matches and `inject_skill` fires) and a DESCRIPTION MATCH through its `Skill` tool (`Skill(skill: "sptc:knock", args: …)`), which never reaches UserPromptSubmit. The second path injected NOTHING for every skill in SPTC_SKILLS since the skeletons went thin: the agent got the skeleton and fell back to `--help`, and the transcript read healthy because the skeleton degrades by design. Field-observed 2026-08-04 (a live agent's prose knock ask routed by description; four bodyless `sptc:commune` calls in local transcripts — and commune's body is where the across/wake mechanics live). SEAM: PreToolUse, which fires on the `Skill` tool (MEASURED 2026-08-21 on HFENDULEAM: a spooled inbound message was delivered ON a Skill call under a `PreToolUse:Skill` header — the tag-confirm probe that preceded it was INCONCLUSIVE, since a missing confirm cannot distinguish a hook that never fired from one that fired before the tag reached the transcript). SHAPE: `tool_input.skill` carries the PLUGIN-QUALIFIED name (`sptc:`, observed in three field transcripts alongside free-prose `args`). A BARE name is deliberately refused — a foreign personal/project skill named `send` or `version` must never pull an sptc body — as is any id outside SPTC_SKILLS. The body LEADS the PreToolUse envelope (mirrors UPS: body first, deliveries after) and rides the existing cap/spill path. Injected BEFORE both of PreToolUse's early returns: before the subagent gate, because REQ-HAZARD-SUBAGENT-DRAIN-STEAL exists to stop a subagent draining the PARENT's mail and a body for the skill the subagent itself invoked is not the parent's mail (the tool is also the ONLY way a subagent can reach an sptc skill), and before the identity gate, because `/sptc:version` and `/sptc:setup` are valid with no perch — the same reasoning the UPS path already carries. `live` composes per substrate ($SPT_ENDPOINT_ID). NOT replicated from UPS, deliberately: the `role` tagged-input round-trip (a model-invoked role takes the agent-mediated arm) and the `ready`/`live` late-activation bringup override. NO suppression state is added against a double-injection that was never observed (every field `Skill` call was model-initiated; a typed command still arrives as a prompt) — a stale suppression record would recreate the very silent-no-op class this fixes. [OK] REQ-SKILL-VERSION required: [] stages: -doc -impl -unit -int /spt:version reports the spt-core-tracked manifest/binary version (the version-of-truth, not the marketplace skeleton version) [OK] REQ-SPILL-PARTIAL-INLINE required: [] stages: -doc -impl -unit -int An oversized delivery surfaces the delivery blocks that DO fit under the cap inline, spills the full text as today, and names in the pointer exactly which blocks were left in the file — inlining only WHOLE ``/`` blocks, never a head-cut through one (REQ-UPS-INJECTION). Today's marker-only emit is safe but total: 94% of spills are past what CC will accept, so no cap tuning helps and an agent gets zero content for any overflow, however small. THE RISK THIS REQUIREMENT CARRIES IS REQ-HAZARD-SPILL-NOTICE-LOST IN A NEW COAT: a partially-inlined delivery gives the agent something actionable at the top, and an agent that acts on the inlined blocks and never Reads the remainder has taken a message and left part of it unread — the same taken-message-reads-as-non-delivery failure as a lost pointer, arrived at through satisfaction rather than absence. SHARPENED BY THE CONSUMER SIDE OF THE SEAM (deployah, 2026-07-31, an agent this hazard happens to): today's marker-only notice works BECAUSE it is unambiguous — the agent has nothing, the notice says read this file, so it reads it — and partial inlining spends exactly that clarity. The dangerous case is not an obviously-truncated inline but an inlined block that READS COMPLETE: the pull to act on what is in hand is strongest precisely when the visible half looks self-contained, and an agent holding SOME content feels served. It follows that the naming of what stayed behind must be LOUDER than the inlined content and placed ahead of it, never a footnote after it, and that this requirement is not satisfied by a correct-but-quiet remainder line. The remainder notice must also survive the same budget competition with the earlier-spills line (fit_with_priors) that route (c) already showed can eat a notice alive. OPEN, and load-bearing for whether this ships at all: whether an agent that acted on the inlined half without reading the remainder can be detected after the fact — if that is undetectable, this trades a loud failure for a silent one and today's total-spill may be the better design. THE BURDEN OF PROOF SITS ON PARTIAL-INLINE, NOT ON TOTAL-SPILL (deployah, 2026-07-31, adopted): today's design fails LOUDLY and its cost is known and bounded — one Read per oversized delivery — while this design's failure is quiet and its rate unmeasured. Field basis: four spills taken in a single session, every one costing a Read and NOT ONE costing a missed message, a golden hand-off among them. A loud failure with a known cost beats a quiet one with an unknown cost, so partial-inline must prove a skipped remainder is detectable after the fact; total-spill does not have to justify its ~164 Reads/day against it. Whoever picks this up inherits that direction rather than re-deriving it. GATING QUESTION ANSWERED 2026-08-21 (perri, MEASURED on this node, CC 2.1.239): YES — a skipped remainder IS detectable after the fact, so the load-bearing objection above does not sink the design on its own. Instrument: a disposable CC session (own config root, import-free cwd, its own logging PreToolUse hook) was driven through the three read routes that occur in the field, each with a positive control (the session printed all three content markers, so every read genuinely happened and every read produced an event). Results — (a) Read tool: one PreToolUse event carrying tool_input.file_path as an ABSOLUTE path, even though the prompt named the file relatively; (b) Bash `cat`: one event carrying tool_input.command VERBATIM AND RELATIVE (`cat target_cat.txt`), which is the trap — a full-path substring match would score a real read as a skip, so a detector must match the spill BASENAME (unique by sid+ms+pid) or resolve against the payload's `cwd`; (c) a subagent's read: reaches the SAME hook under the SAME session_id, additionally stamped agent_id + agent_type, so a remainder read by a delegate is visible and must count. Every payload also carries session_id, cwd and transcript_path. Our PreToolUse hook is registered with NO matcher — verified on the INSTALLED artifact (cplugs sptc 0.1.18 hooks.json), not only the repo copy — so it already fires on every tool call and hook.rs already parses tool_name + tool_input.*; no new CC surface is needed. LIMITS OF THE INSTRUMENT, stated so the number it produces is not over-read: it sees only reads that NAME the file, so an indirect read (a script or python step that opens the spill without the path appearing in the tool payload) and a glob read (`cat ~/.claude/sptc-drain-*`, which the pointer's own recovery line suggests) are both invisible. It therefore under-counts reads and OVER-counts skips — a lower bound on reads, an upper bound on skips. That is the safe direction for a hazard detector (it fails loud) but it means its rate is a ceiling, not a measurement of the true skip rate. A second, INDEPENDENT instrument exists and is offline: transcript_path rides every payload and the adapter already parses transcripts, so a session can be audited retrospectively for whether a spill was ever read — which also makes the existing 3676-file population auditable without shipping anything. CONSEQUENCE FOR SEQUENCING, unchanged in spirit from deployah's direction: detectability is necessary, not sufficient. The detector must ship FIRST and ALONE against today's total-spill emit (REQ-SPILL-REMAINDER-SKIP-DETECT), so it is proven to observe real reads with no partial inline anywhere near it; only then may this requirement be activated, and its activation should be justified by the detector's measured skip ceiling rather than by the Read-count tax alone. POPULATION REFRESHED 2026-08-21 (same node, same glob): 3676 spills, 19.5 MB, mean 5564 B, max 203873 — roughly 3x the filing's 1154 over three further weeks, 132 on 2026-08-21 alone. Bands against the 1800-byte cap: <=2000 19.4%, 2001-2500 12.5%, 2501-4000 28.1%, 4001-8000 20.9%, 8001+ 19.2%. The cap-tuning share rose from 6.0% to 19.4%, so the original 94%-unreachable figure is now 80.6% — the case against cap tuning is weaker than at filing but still decisive, and presentation remains the only adapter-side lever. [OK] REQ-SPILL-REMAINDER-SKIP-DETECT required: [] stages: -doc -impl -unit -int When a delivery spills to a file, the adapter can tell afterwards whether the agent ever read that file, and a spill still unread when the turn ends is surfaced LOUDLY rather than left silent. This exists to discharge the burden of proof that sits on REQ-SPILL-PARTIAL-INLINE: today's total-spill design fails loudly at a known bounded cost (one Read per oversized delivery), so any design that hands the agent something actionable up front must first make its quiet failure — a taken message whose remainder is never read — observable. The detector is therefore the FIRST deliverable and ships ALONE against today's marker-only emit, where there is no partial inline to confound it and every spill is supposed to be read in full: under that emit the measured skip rate is a pure instrument reading, and any skip it finds is already a real defect worth knowing about. Mechanism (each leg measured 2026-08-21, CC 2.1.239, disposable session, positive control on every leg): a spill emission records the path as OUTSTANDING in durable per-session state; every PreToolUse payload is checked for a reference to an outstanding spill — matching the BASENAME, never the full path, because a Bash read arrives VERBATIM AND RELATIVE (`cat target_cat.txt`) while a Read-tool read arrives ABSOLUTE, and a full-path match would score a real read as a skip; a subagent's read counts, since it reaches the same hook under the same session_id (stamped agent_id/agent_type); and a spill still outstanding at Stop is the skipped-remainder event. THE DETECTOR MUST NOT ASSERT MORE THAN IT MEASURED: it sees only reads that NAME the file, so an indirect read (a script that opens the spill without the path appearing in any tool payload) and a glob read (`cat ~/.claude/sptc-drain-*`, which the overflow pointer's own recovery line suggests) are invisible to it. It is a lower bound on reads and an upper bound on skips, and it must SAY so wherever it reports — a ceiling reported as a rate is the same class of error as an else-branch that names a cause it never observed. Reading late is not a skip either: an agent that reads the spill at the top of the NEXT turn has done nothing wrong, so a turn-end outstanding spill is a candidate that must survive the following turn before it counts. A second and independent instrument is available offline and should be used to cross-check rather than trusted separately: transcript_path rides every hook payload and the adapter already parses transcripts, so sessions can be audited retrospectively — including the 3676 spills already on this node — with nothing shipped. [OK] REQ-STUB-FULL-INJECT-OVERRIDE required: [impl, unit] stages: -doc +impl +unit -int The deliberately undocumented sender-side full-inject override routes ONE message through legacy full-text typing instead of a message stub — for senders that need the body to BE the visible prompt (e.g. a directive that must render as the operator-visible input). Carried as {"full_inject":"v1"} inside the opaque json-payload attr (same collision-proof channel as the checkpoint/rename/role-edit markers — a message BODY can never forge it; spt send --json-payload is sender-side only). Checked BEFORE stub eligibility in the translation binary's dispatch; an overridden delivery is byte-identical to the pre-stub world (multi-line framed envelope, full choreography). Adapter-internal: never taught in agent-facing briefs or the manifest docs. [OK] REQ-STUB-GENERAL-EVENT required: [impl, unit] stages: -doc +impl +unit -int Idle stub+park delivery (ADR-0007 Slice C) is TYPE-AGNOSTIC (operator ask 2026-07-18): notifications (`type="notify"`, e.g. from spt-update — attrs notif_id/subnet), gateway/local-CLI user-authority messages (`type="user-msg"`, e.g. from a `*-gw` endpoint), alarms, communes, replies, and any unknown/newer message type stub+park identically to a peer `type="msg"` — so an idle agent receives them the SAME way (a `` stub turn drains the parked body through render_frames as `` additionalContext). spt-core's ONE grammar (type read generically, N-1 safe — doyle 2026-07-18) makes this safe: an unknown type still carries type+from+body. The shared stub shape (`` for every type) keeps stub::is_msg_stub unchanged (the type distinction rides the parked body, not the stub). GUARD: a NON-EMPTY body is required — `type="file_drop"` and other attr-only envelopes (whose signal is in the attrs render_frames drops) stay on the legacy full-type path so their whole tag stays visible. Control envelopes (wake/rename/role-edit/fire) and the full-inject override are intercepted earlier and never reach the stub path. CONSEQUENCE: the version-gated update nudge (REQ-UPDATE-NUDGE-VERSION-GATED) now scans the PARKED bodies alongside the busy poll drain, since an spt-update notify can arrive via either route — reversing the pre-2026-07-18 rationale that kept notify full-typing so the poll-only nudge could see it. [OK] REQ-STUB-MSG-DELIVERY required: [impl, unit] stages: -doc +impl +unit +int Idle-delivered peer-message bodies stop riding the input box (ADR-0007 Slice C — the msg-stub leg). PARK+STUB (translation binary): a normal idle `` delivery PARKS the raw envelope in HOME-anchored adapter state (~/.spt-claude/msgpark//-.park, ordered filenames, atomic temp+rename) and types only the stub `` (from verbatim off the envelope — @node rides it only when the sender put it there). CUSTODY IS HOME-ANCHORED, NOT EXE-RELATIVE, BY DESIGN: unlike the wake park (hook writes, hook reads — one binary resolution), here the WRITER is the resident translation binary (possibly a stale version, possibly relocated by a future install-dir migration or daemon own-copy) and the READER is the always-fresh hook; anchoring the park at $HOME/$USERPROFILE makes both halves resolve the same directory by construction, killing the silent-skew class (parked-where-nobody-reads = silent delivery loss, this pipeline's worst failure mode). Empirical note 2026-07-10: today every resident translate runs from the install dir ({adapter_dir} manifest command, verified live), so exe-relative would work TODAY — the HOME anchor buys the class, not the instance. STUB TURN (UserPromptSubmit): a whole-prompt-exact `` (shared stub::is_msg_stub) DRAINS ALL parked envelopes in filename order and injects them through the same render_frames() the poll drain uses (one block per envelope, LEADING the turn); a trailing stub whose park is already empty (its body rode an earlier stub turn's drain-all) gets a one-line coalesced note instead — a stub turn is never a dead prompt. FALLBACKS (never lose a message): no endpoint id learned yet (no init handshake), a non-msg event type, an EVENT-PART chunk, a from-less envelope, or a FAILED park write → legacy full typing, which the hook reads as a normal prompt. Mid-turn PreToolUse drains and internal control envelopes (checkpoint/rename/role-edit/fire) are untouched. [OK] REQ-STUB-PARK-NO-STALL required: [impl, unit] stages: -doc +impl +unit -int A parked peer-message body never stalls past the receiver's next hook event (the 2026-07-10 busy-race field pin). THE RACE: the translation binary idle-classifies the session in the Stop→next-turn window, parks the body and types the stub — but the session has already begun a new turn (a background-task notification, a queued operator prompt), so CC folds the typed stub into the RUNNING turn as a queued message whose UserPromptSubmit never fires. Under stub-turn-only draining the body stalls in the park until an unrelated future stub turn — the pre-stub world delivered the full body as the queued text, so this is a regression class the stub redesign introduced. THE BOUND: (1) UserPromptSubmit drains the msg park on EVERY prompt, not just stub turns — parked bodies inject leading (same render_frames), a stub turn with an empty park keeps the coalesced note, a NORMAL prompt with an empty park injects nothing; (2) PreToolUse (the mid-turn leg) drains the park alongside its poll drain, so a busy session surfaces parked bodies at its next tool call without waiting for turn end. Read-then-delete redelivery (over loss) is the park's documented stance; two hook processes racing a drain may split or double-deliver a batch — duplicates beat silent loss. [OK] REQ-STUB-WAKE required: [impl, unit] stages: -doc +impl +unit -int The checkpoint wake stops riding the input box as full text (ADR-0007 Slice B — the first stub-protocol leg). ARM (checkpoint detect, both the PostToolUse commune-Write path and the mid-turn commune-tag path): the hook PARKS the resolved wake directive (the commune's custom !!checkpoint!!-pair text, else the default) in adapter state (state/wake/.park — hook-side custody: the hook always runs fresh from the install dir, so park writer and park reader are the same binary resolution and can never skew across a translate own-copy) and self-sends the unchanged {"checkpoint":"v1"} loopback. FIRE (post-clear boundary): the translation binary types the 7-char stub instead of the wake text — nothing longer than a stub is ever typed at a boundary, killing the paragraph-wake inject-race class (the 0.15.1 boundary-fold, agent dormant ~9.5h). STUB TURN (UserPromptSubmit): a whole-prompt-exact (shared stub::is_wake_stub) reads + clears the park and injects the directive as additionalContext framed …, LEADING the turn's context; an empty/absent park (hand-typed stub, or a crash-stale boundary) injects the default directive — a stub turn is never a dead prompt. A plain /clear stays silent (no ARM ⇒ no wake half ⇒ bare commit — unchanged). Skew-safe by construction: a stale RUNNING translate keeps full-typing, which the new hook reads as a normal prompt (the park then sits unused until the next checkpoint overwrites it — accepted, ADR-0007). [OK] REQ-SUPERVISOR-RELAY-RESPAWN required: [] stages: -doc -impl -unit -int DESIGN CANDIDATE (not yet implemented): the adapter's belt-and-suspenders answer to a dead in-session relay — when a live agent's resident Monitor relay dies mid-session (the delivery pipe silently stops), SOMETHING must notice and either respawn it or surface the death, so the agent does not go silently unreachable while appearing online. This is the ADAPTER half that folds into doyle's W4 supervisor-watchdog activation ruling (the core owns the watchdog itself; the adapter owns the relay-respawn / death-surfacing belt on top). SCOPE FENCE: this REQ is design/doc ONLY for now — do NOT build the watchdog or a respawn loop adapter-side until W4's activation ruling lands and assigns the boundary (core-vs-adapter). Captured now so the debt is tracked, not lost, and so the W4 wave has the adapter's requirement written when it activates. [OK] REQ-TAG-CONFIRM-NEXT-HOOK required: [impl, unit] stages: -doc +impl +unit -int A turn-end (Stop-leg) tag send CONFIRMS to its author on the author's NEXT hook instead of never: the Stop leg parks the shortform confirm in a small per-endpoint adapter-state file beside the digest cursor, and the author's next UserPromptSubmit or PreToolUse surfaces it as additionalContext framed as the PREVIOUS turn's turn-end result (so the author neither waits for a confirmation that already happened nor resends a delivered tag), then clears the park strictly AFTER emission — the wake-park custody: a hook killed before emit leaves the file for redelivery, a kill in the emit-to-clear sliver duplicates, and duplicates beat silent loss. Request BigscreenVR/claude-spt-bs#17 (perri, from the #16 isolation): a pure-success turn-end confirm was DROPPED by design after F-035 (no active window at Stop; a self-send spooled and surfaced on an unrelated relay-woken turn), so an author could not distinguish a delivered turn-end tag from a silently lost one, and the sane defensive rule 'no confirmation = unsent, resend' double-delivered on every SUCCESSFUL turn-end send. NO SPT SELF-SEND ANYWHERE in the success path — the park is a file, so the F-035 wart cannot return. A second Stop before the park is surfaced APPENDS rather than overwrites (a surfacing hook killed pre-clear must not cost the older confirm). The across-clear quiet window does not read the park (the session is about to be rebuilt); the post-clear session's first hook delivers it. FAILURE confirms keep their REQ-TAG-SEND-FAILURE-LOUD self-send (it can WAKE an idle author, which a parked file cannot) — but a REFUSED failure self-send now parks the confirm as its fallback, so even a dead-own-perch failure reaches the author eventually instead of dying in the hook log. Mid-turn (PreToolUse-leg) confirms are untouched — they already inline correctly. [OK] REQ-TAG-PEER-MESSAGING required: [impl, unit] stages: +doc +impl +unit -int An agent messages peers WITHOUT the Bash tool by embedding `@` in its turn output: `@<` opens, the comma-separated target list runs to the first whitespace (no internal spaces), the body runs to the first `@>` (non-greedy, no escaping v1); a bare `@@id` is deliberately inert (prose discussing the syntax must not false-fire). A PreToolUse (mid-turn) + Stop (backstop) scan pulls the endpoint's own finalized digest output newer than a seq cursor (`endpoint digest --after --json`, `entries[].Agent.text` with a `seq`), dispatches `spt send --from ` per valid target (self-target dropped+noted; unknown/offline surfaced via NO_PERCH, never silent-dropped; delivered = SENT/QUEUED/DEFERRED), and fires ONE confirm-back to the sender's OWN perch (`--active-only --ephemeral`, lands mid-turn) so the agent does not re-send via Bash. The seq cursor (state/digest/.seq) is the exactly-once double-send guard across both hooks — only seq-carrying (finalized) entries strictly above the cursor dispatch; a partial never fires. [OK] REQ-TAG-SEND-FAILURE-LOUD required: [impl, unit] stages: -doc +impl +unit -int A shortform (@<…@>) peer send that FAILS delivery is NEVER silent: the mid-turn (PreToolUse) leg already inlines the [tag-send] confirm; the end-of-turn (Stop) leg — which deliberately DROPS success confirms (F-035: a spooled success confirm surfacing on a later unrelated relay-woken turn is noise) — must still surface FAILURES, by self-sending the failure confirm as a normal (spoolable) message so it reaches the agent at the next delivery opportunity, with a loud hook-log fallback when even the self-send refuses (a dead own-perch). Field basis: slammie-n → gaki-n 2026-07-16 — gaki-n's perch died (the win32 anchor class), slammie-n's end-of-turn shortform sends failed NO_PERCH, and the Stop drop swallowed every failure confirm: the agent experienced a total no-op and re-tried blind for hours. Success-only confirms stay dropped at Stop (F-035 preserved); a late failure notice beats silence, which is the asymmetry this requirement encodes. [OK] REQ-TAG-SEND-OUTCOME-LEDGER required: [impl, unit] stages: -doc +impl +unit -int EVERY tag send traces its classified outcome PLUS core's raw answer bytes to the hook trace, on both legs — so a field delivery loss is one grep from a verdict on which side of the adapter/core seam it died. Field case BigscreenVR/claude-spt-bs#16 (doyle, 2026-08-23): two turn-end tags were scanned and dispatched (stamp probes prove it), the recipient never received them, the author saw no confirmation — and the trace could not say what core answered, because only the QUEUED outcome was recorded on the Stop leg. A Stop-leg SENT success writes no confirmation to the agent (dropped by design after F-035) and wrote no trace line; NoPerch/SpawnFailed/Unrecognized surfaced by self-send and traced only when that self-send was refused. So a send whose delivery died downstream of core was byte-for-byte indistinguishable in every adapter record from one that landed live — the diagnosis dead-ended exactly at the seam. THE LEDGER CLAIMS ONLY WHAT IT MEASURED: it records the outcome classification and core's answer verbatim (newlines flattened, bounded at 160 chars); a spawn failure records that spt did not run rather than inventing answer bytes. It does NOT replace the QUEUED tripwire line (REQ-TAG-SEND-QUEUED-VISIBLE keeps its own record and phrasing) and does NOT change what the agent sees — the author-facing turn-end confirm gap is its own request (claude-spt-bs#17), deliberately separate: the ledger answers the diagnostician, #17 answers the author. [OK] REQ-TAG-SEND-PRETOOL-TRACE required: [impl, unit] stages: -doc +impl +unit -int The mid-turn (PreToolUse) tag-send leg records its send outcomes in the hook trace exactly as the Stop leg does, so the trace covers BOTH dispatch legs and a diagnosis can never read half the population as if it were all of it [OK] REQ-TAG-SEND-QUEUED-VISIBLE required: [impl, unit] stages: -doc +impl +unit -int A peer send that lands as QUEUED (accepted + spooled, target not listening) is reported as QUEUED, never merged into 'delivered': the mid-turn confirm names the spooled targets, and the Stop leg — which drops success confirms by design — records them in the hook trace, so 'sitting in a spool' is always distinguishable from 'never dispatched' [OK] REQ-TAG-SEND-STAMP-EXPLICIT required: [impl, unit] stages: -doc +impl +unit -int Adapter-dispatched `@<…@>` peer sends carry `OWL_SESSION_ID` EXPLICITLY, set to the dispatching turn's own session id, so spt-core's send site can compute the `sender_proven` stamp. Core reads that variable FROM THE ENVIRONMENT at the send site and never from `--from` (which is reply-routing metadata); the hook process carries it on NEITHER dispatch leg (MEASURED 2026-08-21, `owl=empty` on PreToolUse and on Stop, two different pids), while an agent's own Bash-tool shell does (measured on two endpoints) because SessionStart writes the export into CC's env FILE, which tool shells read and hook children do not. The consequence of leaving it: one agent, two outbound paths, different attribution — an unstamped send makes core's tier-1 sender rules ABSTAIN (it forges nothing), silently defeating any receiver-side rule naming that agent, and the receiver cannot recover it because the endpoint spool has no `sender_proven` column at all. Same-node traffic hides the whole thing, since same-node origin short-circuits to Allow before the store is consulted, so this surfaces only on a remote arm as an arrival that looks non-discriminating for reasons nobody logged. SHAPE: a `spt_send_with_env` seam (spt_send delegates to it with an empty env, so there is ONE spawn implementation and no second copy to drift), and the tag-dispatch call site passes `OWL_SESSION_ID = ` — the same move `resolved_id`'s whoami call already makes, using the payload's session rather than any ambient value, so a descendant session cannot dispatch as its ancestor. The probe from REQ-TAG-SEND-STAMP-PROBE stays and now records `inherited=` beside `owl=explicit`: what the harness happens to provide is still worth seeing, it just no longer decides the stamp. [OK] REQ-TAG-SEND-STAMP-PROBE required: [impl, unit] stages: -doc +impl +unit -int A turn that dispatches `@<…@>` peer messages RECORDS which `OWL_SESSION_ID` its send child inherited, and which leg dispatched. spt-core stamps `sender_proven` from the SENDING side and the CLI computes it by reading `OWL_SESSION_ID` FROM THE ENVIRONMENT (never `--from`, which is reply-routing metadata), resolving it to an endpoint with a readable perch record; otherwise the field is absent. An absent stamp FORGES NOTHING — core's tier-1 sender rules abstain — but it silently defeats any receiver-side rule naming this agent, and the receiver cannot tell: the endpoint spool carries no `sender_proven` column at all (schema read, lia 2026-08-04), so emission is evidenceable ONLY at the emitter. Our tag dispatch shells `spt send --from ` from the HOOK process and passes nothing explicitly, inheriting that process's environment wholesale, while an agent's own Bash-tool shell is MEASURED to satisfy the predicate on two independent endpoints (perri, lia) because SessionStart appends `export OWL_SESSION_ID` to CC's env FILE — which reaches tool shells, not necessarily CC's own process env. So one agent may stamp on one outbound path and not the other, invisibly at both ends, and same-node traffic hides it completely (same-node origin short-circuits to Allow before the store is consulted). THIS REQUIREMENT IS THE MEASUREMENT, NOT THE FIX, and that separation is deliberate: the standing suspicion rests on an ABSENCE-GREP (no production launcher sets the var), the same instrument class that misled this project twice on 2026-08-04. The probe fires ONLY on a turn that really dispatches (a rolling log must not fill with per-turn wallpaper), names its LEG (PreToolUse and Stop are different processes' children and need not agree — an instrument that cannot separate its two legs is the half-population trap already paid for once), classifies as `self` / `foreign` / `empty` (an inherited ancestor value is NOT folded into either happy or absent, since it may still resolve core-side), and NEVER logs the session id value. The fix — passing the session id explicitly the way the identity call already does — waits on the reading. READING TAKEN 2026-08-21 on HFENDULEAM, and it settles BOTH legs: `owl=empty leg=PreToolUse` and `owl=empty leg=Stop`, logged from two different pids, so the hook child inherits the var on NEITHER dispatch leg and every adapter-dispatched tag send was going out unstamped while the same agent's tool-shell sends carried the stamp. The suspicion the absence-grep raised was correct — which is not the same as it having been evidence. Measured without manufacturing traffic: a SELF-targeted tag fires the probe and is dropped before any send, honoring the standing constraint (lia, 2026-08-04) that no more sends of unknown stamping be produced. Fix shipped as REQ-TAG-SEND-STAMP-EXPLICIT; the probe stays and now records `inherited=` alongside, so a harness change that starts or stops providing the var is visible rather than silent. [OK] REQ-TAG-SEND-VERDICT-NOT-CATCHALL required: [impl, unit] stages: -doc +impl +unit -int A tag-send verdict never asserts more than it measured: NO PERCH is reported ONLY for a core NO_PERCH answer, a failed `spt` spawn is named as a spawn failure, and an unrecognized answer is reported WITH its raw token — so a send outcome is never a claim about a peer's perch that the adapter did not observe [OK] REQ-TRUST-WARNING-CARRY required: [doc, impl, unit] stages: +doc +impl +unit -int The RECEIVER-COMPOSED `trust-warning` envelope attribute survives the adapter's re-render and is surfaced to the agent, VERBATIM, with the message it is about. Contract (spt-releases #170, gated PASS, routed by doyle 2026-08-21; the docs-site section networking/monics -> 'The warning rides the message' PUBLISHED with the PORTER core cut (v0.59.0) and was re-checked 2026-08-21: the attribute shape matches what was built, and the page states the surfacing obligation in the same inverted terms): a delivered message from a warning-eligible stranger carries its trust warning on its own envelope as a `trust-warning` attribute composed by the RECEIVING node, exactly as `mnemonics-json` is — one arrival instead of two. The value is the FULLY COMPOSED block, never a token: the identifying spine ('TRUST WARNING - this message is from , who reached you through an access rule rather than through anything you decided about them. You hold no note about them.'), the advisory line (a custom override may replace the advisory, NEVER the spine), and the how-to-silence line, attr-escaped by the one composer. NOTHING IS EXPANDED ADAPTER-SIDE, EVER: preserve, do not interpret. NEWLINES RIDE AS ` ` AND THE DECODE ORDER IS LOAD-BEARING (doyle Q3, re-measured and RULED 2026-08-21): the envelope codec is LINE-FRAMED, so a literal newline in an attribute tears the envelope at any line-based reader; the attr rule gains exactly one entity beyond the four, escaping normalizes CR/CRLF to LF then maps LF to ` ` AFTER the `&`->`&` step, so the adapter decode must run BEFORE attr_unescape's amp-LAST step - which is what makes literal ` ` CONTENT round-trip (it rides as `&#10;`, the newline decode leaves it alone, the amp step returns it as text). The wrong order silently turns an operator's advisory override saying ` ` into a line break. `
` is never decoded in an attribute (body rule vs attr rule stay distinct), and the decode stays scoped to this attribute until the docs-site attr-rule section publishes the fifth entity. THE SEQUENCE IS PART OF THE RECORD: the first ruling was 'literal newlines, guaranteed' and this decode was REMOVED on it; re-measured at the lane tip, literal newlines proved to be current BEHAVIOUR but a LATENT CORE DEFECT rather than the contract (the 'one line, always' pin only ever covered the monic attribute, whose newlines are a two-character JSON escape), and the codec extension now rides the same unlanded lane ahead of this feature publishing - an adapter-side question about escaping found a core-side defect. THIS ATTRIBUTE DELIBERATELY INVERTS THE mnemonics-json PRECEDENT that unknown attributes are safely ignored — for this one, BEING IGNORED IS THE FAILURE, because a silently dropped trust warning is a security caution that never happened. hook::render_frames re-renders every delivery (keeps `from` + body), so claude-spt holds custody of the envelope and the obligation is ours: the same seam that dropped mnemonics-json in v0.26.2. LOSS SCOPE, measured by the contract rather than guessed (doyle, Q2): the once-per-session-per-peer cadence mark is made by the RECEIVING NODE when the caution enters the delivery channel — the same act that hands the enveloped message over — with no adapter-side acknowledgement in the loop, so an adapter that drops the attribute silences that (session, peer) pair for THE REMAINDER OF THAT SESSION ONLY, not permanently (the dedup marker lives in the per-session scratch, so any reset that mints a new session owes the warning again; the marker is best-effort and fails toward warning again, and an undelivered warning claims nothing). Scoped, still a real loss. BUILD: a `` sibling block LEADING its message at the adapter's own FRAME level, text verbatim after standard attr-unescape, never summarized and never renderer-truncated (spill owns size); ordered ahead of the `` block - a LIVE path, not a defensive one: a CONTENT-triggered monic legitimately matches a stranger's message (classification is a sender question; content matching is not), so warning + monic ride the same frame by design, and only a SENDER-classifying monic beside a warning about that same sender is the disagreement the invariant forbids, which core suppresses at the source (doyle, 2026-08-21); and the digest surfaces that span with the message. THE PAIRING IS PART OF THE CONTRACT: the block rides the SAME delivery as its message, never batched and never reordered across deliveries. Bodies that are already typed envelopes are UNCHANGED (the warning stays its own system-authored delivery under a reserved author, delivered first, no carrier), and anything a SENDER writes into `trust-warning` is stripped at ingress, so the value read here is always the receiving node's own. Imitation surface identical to REQ-MONIC-REVEAL's and bounded the same way: the discriminant is STRUCTURAL (a genuine block sits at our frame level, ahead of and outside the peer's `` block, where nothing a sender writes can appear), which is the same reasoning core uses for the warning's reserved author. [OK] REQ-UPDATE-NUDGE-VERSION-GATED required: [impl, unit] stages: -doc +impl +unit -int When an `` frame rides a UserPromptSubmit poll drain, the adapter appends a version-GATED update nudge — fired ONLY when this node's running `spt --version` is strictly older than the version advertised in the notify body. Self-suppressing: once this node updates, the gate closes and the nudge stops even with the notif undismissed. The nudge steers the agent to `spt update apply` at a safe point (daemon-only bounce; broker + nested agents survive) and explicitly does NOT tell the agent to `spt notif dismiss` — dismissal replicates subnet-wide and would suppress the prompt for nodes not yet updated (leave dismissal to the operator). Only the spt-update notify triggers it; a peer message mentioning a version cannot. [OK] REQ-UPS-IDENTITY-FASTPATH required: [impl, unit] stages: -doc +impl +unit -int Hook identity resolution is CONSTANT-TIME on ordinary spt-hosted sessions: when $SPT_ENDPOINT_ID is set AND the custody-gated session carrier (REQ-HAZARD-CARRIER-CUSTODY) records the hook payload's session_id, the hook uses that endpoint id directly — ZERO whoami / endpoint-list / endpoint-info / project-history / Git calls. ROOT (2026-07-10 RCA, hertz): spt-core >=0.31.0 aliases `whoami --json` to the full enriched endpoint listing (latest_project_ref per perch -> synchronous Git fanout; measured 45.6s on HFENDULEAM vs CC's ~30s external hook ceiling), so every self_id call risked timing out the hook — UserPromptSubmit missed busy-mark/drain (reachability black-hole) and Stop missed its idle mark (stuck-busy class). The proof also rejects leaked-env impostors: a nested/subagent session inheriting $SPT_ENDPOINT_ID has a different payload sid → falls through to the fallback rather than acting as the parent. FALLBACK: missing env / missing carrier / mismatch → the existing whoami path (harness-hosted live sessions unchanged); it remains deadline-vulnerable until spt-core ships the identity-only session→endpoint API (filed to spt-core) and must never widen back into a default. A /sptc:live turn performs exactly ONE endpoint listing (the explicit roster), never a second via identity. Applies to ALL self_id call sites: UserPromptSubmit, PreToolUse, Stop, SessionEnd, SubagentStart. [OK] REQ-UPS-INJECTION required: [doc, impl, unit, int] stages: +doc +impl +unit +int UserPromptSubmit hook detects /sptc:X and injects X's real instructions as additionalContext (SKILL.md files stay skeletons); must be empirically confirmed UPS fires on slash-commands [OK] REQ-UPS-KEYWORD-HINTS required: [impl, unit] stages: -doc +impl +unit -int The UserPromptSubmit hook PIPES THE FULL USER MESSAGE to `spt api hint --session ` and injects the matched hint, which is the published contract for the `[[hints]]` table every claude-spt manifest has carried since v0.2.x (manifest.schema.json, `hints`: 'the adapter's user-prompt hook pipes the full user message to `spt api hint`; a matching keyword surfaces `text` to the agent's context channel, at most once per session and once per message'). ROOT (2026-08-04 field report, two independent sightings): an agent asked in prose for a knock code and an operator pasted an `sptkc_` invite code — both match the shipped table by hand (`spt api --adapter claude-spt hint` answers each with the right Tip) and NEITHER agent saw anything, because the hook never made the call. The table was INERT from the day it was declared: no keyword hint has ever fired in claude-spt, for any keyword, in any session — a whole declared feature delivering nothing while reading as shipped, and REQ-SKILL-KNOCK's 'Two [[hints]]' clause counted a declaration as evidence. A manifest table is a declaration TO spt-core, never a substitute for the call that consumes it. SHAPE: core frames the answer for a human (`keyword hint for SPT adapter : ""-->Tip: …`) on STDOUT and the agent gets the TIP, not the framing — but an answer without the `-->` separator (core's presentation, not a documented contract) is injected WHOLE rather than dropped, since a hint we cannot parse is still a hint we were given. SUPPRESSED on a slash-command turn (skill_key non-empty): the injected skill body is the operative text, and a Tip naming the command the operator just typed would spend that hint's one per-session firing on a no-op. Blank prompt = no call. The once-per-session seen-set is core's, keyed by session id, so a /clear re-arms every hint — the adapter holds no state of its own here. AMENDED 2026-08-28 (claude-spt-bs#22, REQ-NOW-SIGNAL-INJECT): the hint now reaches the agent through spt-core's ONE turn-boundary funnel, `spt api now-signal`, whose HINTS category IS this contract — `api hint` is published as a thin alias over that category sharing its seen-set, so injecting both would inject the same thing twice. EXACTLY ONE of the two verbs runs per prompt, chosen by whether the session has an endpoint id: `now-signal` requires one as a positional and `hint` does not, and this call was deliberately never perch-gated ('an unregistered session is exactly who a tip is for'), so an id-less session keeps the alias and every other session gets the superset. The `[[hints]]` table, the once-per-session cadence, the slash-command suppression and core's ownership of the seen-set are all unchanged; only the transport moved. One consequence to hold: the funnel takes the turn text as an ARGV value with no stdin form, so it is capped (REQ-NOW-SIGNAL-INJECT) where the alias piped the whole message — a keyword past the cap in a very long paste will not match. [OK] REQ-VERSION-SELF-REPORT required: [impl, unit] stages: -doc +impl +unit -int The adapter binary reports its OWN adapter version (`claude-spt version`, and the `--version`/`-V` aliases), compiled in at build time from the SINGLE source of truth — the `[adapter] version` in `adapter/claude-spt.toml` — so a field operator holding an installed binary can establish which build it actually is without trusting the registry, the manifest beside it, or a filename. TWO INDEPENDENT REASONS, and the second is structural: (1) DIAGNOSIS — measuring an install in the field, there was no way to self-report the version from the binary, so a stranded binary could not be distinguished from a current one on the evidence of the file itself; (2) DELIVERABILITY — spt-core skips rewriting an install destination whose CONTENT is identical, so a release changing only packaging or manifest data emits byte-identical binaries and is UNDELIVERABLE to existing installs (see REQ-HAZARD-ADAPTER-EXEC-BIT). Compiling the adapter version into the binary makes every release's binary content differ BY CONSTRUCTION, so that class of stranded release cannot recur for this adapter regardless of how spt-core resolves it. The build MUST FAIL LOUDLY rather than embed a wrong or placeholder version: exactly one `version = "…"` key is expected, and zero or many is a build error, never a silent fallback. [OK] REQ-WAKE-EMIT-FLIP required: [impl, unit] stages: -doc +impl +unit -int The wake EMIT side sends the NEW envelope keys ({"wake_arm":"v1"[,"directive":…]} for ARM, {"wake_fire":"v1"} for FIRE) — stage 2 of the staged across/wake rename (REQ-WAKE-RENAME-STAGED shipped receive-both in v0.23.0, so every post-v0.23.0 resident translate parses the new keys; the one-release stagger is what makes this flip safe). The RECEIVE side still accepts BOTH generations this release; legacy envelope + !!checkpoint!! marker acceptance retires the release AFTER this one (needs one full release where no pre-flip resident can remain). [OK] REQ-WAKE-RENAME-STAGED required: [impl, unit] stages: -doc +impl +unit -int The across/wake rename is STAGED so a cross-version boundary can never brick a live endpoint (the hook_cmd-LOCKSTEP degrade discipline applied to the wake pipeline): agent-facing verbiage hard-cuts to --across / 'commune across' / !!wake!! (the word 'checkpoint' is PURGED from agent-facing text — Claude Code ships an official /checkpoint skill that semantically collides), while the RECEIVE side accepts both marker generations (!!wake!! + legacy !!checkpoint!!, same-generation pairing only) and both envelope shapes ({"wake_arm"/"wake_fire"} + legacy {"checkpoint"/"checkpoint_fire"}), and the EMIT side keeps the LEGACY envelope keys for this one release — an un-bounced pre-rename resident translate receiving an unknown key would full-type raw JSON at a post-clear boundary. The emit flips to the wake keys next release. TRACEABLE_EXIT=0