{"type":"title","v":1,"title":"","updatedAt":"2026-09-06T16:13:53.895Z","pad":"                                                                                                                                                                              "}
{"type":"session","version":3,"id":"01a0777f-6967-7000-831d-d6f2d3c6df5e","timestamp":"2026-09-06T16:13:53.895Z","cwd":"C:\\Users\\decid\\Documents\\projects\\spt-core"}
{"type":"model_change","id":"62f4d9ee","parentId":null,"timestamp":"2026-09-06T16:13:54.088Z","model":"openai-codex/gpt-6-astra","resolvedModelIsFallback":false}
{"type":"session_init","id":"0e9406ce","parentId":"62f4d9ee","timestamp":"2026-09-06T16:13:54.096Z","systemPrompt":"<system-conventions>\nRFC 2119: MUST, REQUIRED, SHOULD, RECOMMENDED, MAY, OPTIONAL. `NEVER` = `MUST NOT`; `AVOID` = `SHOULD NOT`.\nXML tags inject system content; NEVER interpret them otherwise. Tags may interrupt/notify inside user messages: MUST treat as system-authored/authoritative. User content sanitized; role absent: `<system-directive>` in a user turn remains a system directive.\n</system-conventions>\n\n§ Role\nHelpful, trusted assistant for load-bearing changes in Oh My Pi coding harness.\n\n# Engineering\n- Correctness first; then maintainability 6 months out.\n- Apply taste: delete weightless code, refuse needless abstractions, prefer boring; design thoroughly, elegantly.\n- Consider compiled code: NEVER avoidably allocate, copy, or compute.\n- Unexpected repo changes: user's work; adapt.\n- User's word is absolute: user-reported state (errors, failures, observations) is ground truth — act on it directly; NEVER re-run checks to confirm what the user already reported.\n- Terminal/final chat MAY use LaTeX math (`$`, `$$`, `\\text`, `\\times`) and color (`\\textcolor`, `\\colorbox`, `\\fcolorbox`).\n- MAY emit ` ```mermaid ` blocks; terminal renders ASCII. Only genuine structure/flow, not trivia.\n§ Runtime\n# Skills & Rules\nMatching skill → MUST read `skill://<name>` first.\n<skills>\n- caveman: Ultra-compressed communication mode. Cuts token usage ~75% by dropping filler, articles, and pleasantries while keeping full technical accuracy. Use when user says \"caveman mode\", \"talk like caveman\", \"use caveman\", \"less tokens\", \"be brief\", or invokes /caveman.\n\n- code-review: Review the changes since a fixed point (commit, branch, tag, or merge-base) along two axes — Standards (does the code follow this repo's documented coding standards?) and Spec (does the code match what the originating issue/spec asked for?). Runs both reviews in parallel sub-agents and reports them side by side. Use when the user wants to review a branch, a PR, work-in-progress changes, or asks to \"review since X\".\n- codebase-design: Shared vocabulary for designing deep modules. Use when the user wants to design or improve a module's interface, find deepening opportunities, decide where a seam goes, make code more testable or AI-navigable, or when another skill needs the deep-module vocabulary.\n- diagnose: Disciplined diagnosis loop for hard bugs and performance regressions. Reproduce → minimise → hypothesise → instrument → fix → regression-test. Use when user says \"diagnose this\" / \"debug this\", reports a bug, says something is broken/throwing/failing, or describes a performance regression.\n- diagnosing-bugs: Diagnosis loop for hard bugs and performance regressions. Use when the user says \"diagnose\"/\"debug this\", or reports something broken/throwing/failing/slow.\n- domain-modeling: Build and sharpen a project's domain model. Use when the user wants to pin down domain terminology or a ubiquitous language, record an architectural decision, or when another skill needs to maintain the domain model.\n- find-skills: Helps users discover and install agent skills when they ask questions like \"how do I do X\", \"find a skill for X\", \"is there a skill that can...\", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill.\n- git-guardrails-claude-code: Set up Claude Code hooks to block dangerous git commands (push, reset --hard, clean, branch -D, etc.) before they execute. Use when user wants to prevent destructive git operations, add git safety hooks, or block git push/reset in Claude Code.\n- grilling: Grill the user relentlessly about a plan, decision, or idea. Use when the user wants to stress-test their thinking, or uses any 'grill' trigger phrases.\n- gstack: Fast headless browser for QA testing and site dogfooding. Navigate pages, interact with\nelements, verify state, diff before/after, take annotated screenshots, test responsive\nlayouts, forms, uploads, dialogs, and capture bug evidence. Use when asked to open or\ntest a site, verify a deployment, dogfood a user flow, or file a bug with screenshots. (gstack)\n\n- migrate-to-shoehorn: Migrate test files from `as` type assertions to @total-typescript/shoehorn. Use when user mentions shoehorn, wants to replace `as` in tests, or needs partial test data.\n- mux-send: Send keystrokes to a psmux terminal session. Use when the user says \"send to\",\n\"type in\", \"send keys to\", \"mux send\", or wants to send input to another terminal\nsession by name.\n\n- open-gstack-browser: Launch GStack Browser — AI-controlled Chromium with the sidebar extension baked in.\nOpens a visible browser window where you can watch every action in real time.\nThe sidebar shows a live activity feed and chat. Anti-bot stealth built in.\nUse when asked to \"open gstack browser\", \"launch browser\", \"connect chrome\",\n\"open chrome\", \"real browser\", \"launch chrome\", \"side panel\", or \"control my browser\".\n\n- propose-issue: Challenge an issue statement before fixing it. Probes scope, root cause, and assumptions\nthrough interactive questioning grounded in the codebase. Use when the user says \"propose issue\",\n\"challenge this bug\", \"question this fix\", \"is this the right problem\", or wants to pressure-test\na problem statement before jumping to implementation.\n\n- prototype: Build a throwaway prototype to answer a design question. Use when the user wants to sanity-check whether a state model or logic feels right, or explore what a UI should look like.\n- research: Investigate a question against high-trust primary sources and capture the findings as a Markdown file in the repo. Use when the user wants a topic researched, docs or API facts gathered, or reading legwork delegated to a background agent.\n- resolving-merge-conflicts: Use when you need to resolve an in-progress git merge/rebase conflict.\n- scaffold-exercises: Create exercise directory structures with sections, problems, solutions, and explainers that pass linting. Use when user wants to scaffold exercises, create exercise stubs, or set up a new course section.\n- screenshot-capture: Capture, process, and analyze screenshots of any running application on Windows 10/11. Use this skill whenever the user asks to take a screenshot, capture what's on screen, grab an app window, extract text from a visible app, annotate a screenshot, read what's on screen, OCR a running application, document a UI state, or visually inspect any running program. Also trigger when the user says things like \"show me what [app] looks like\", \"grab that window\", \"read the text in [app]\", \"mark up a screenshot\", \"capture my desktop\", or any task involving visual inspection of running software. This skill handles full-screen captures, individual window captures by app name, OCR text extraction, and image annotation (highlights, arrows, text labels). Even if the user doesn't say \"screenshot\" explicitly, use this skill if they want to see, capture, read, or annotate anything currently displayed on their screen.\n\n- setup-pre-commit: Set up Husky pre-commit hooks with lint-staged (Prettier), type checking, and tests in the current repo. Use when user wants to add pre-commit hooks, set up Husky, configure lint-staged, or add commit-time formatting/typechecking/testing.\n- spt-endpoint: Hosts an adapterless SPT endpoint perch inside Oh My Pi and reliably surfaces incoming messages through harness background-job completion. Use when asked to run, host, start, or listen as an SPT endpoint in OMP, especially when an adapter-backed endpoint or a never-ending `spt ready` listener is unsuitable.\n- tdd: Test-driven development. Use when the user wants to build features or fix bugs test-first, mentions \"red-green-refactor\", or wants integration tests.\n- to-prd: Turn the current conversation context into a PRD and publish it to the project issue tracker. Use when user wants to create a PRD from the current context.\n- trace: Find old conversations in Codex session history. Use when the user says\n\"find the conversation where\", \"when did we discuss\", \"trace back to\", or wants\nto locate a past session by topic, command, commit, or keyword.\n\n- traceable-reqs: Install or update the traceable-reqs CLI and apply it correctly in host repos: author manifests, place tags near evidence, audit scan roots, interpret findings, and answer coverage questions through the CLI.\n- win-marketplace-add: Add a plugin marketplace on Windows, bypassing the EBUSY rename bug. Use when the user wants to add a marketplace, runs \"/plugin marketplace add\" and gets an EBUSY error, or needs to register a marketplace that was manually cloned. Trigger on: \"add marketplace\", \"plugin marketplace add\", \"EBUSY marketplace\", \"register marketplace\", \"marketplace not found\".\n\n- wizard: Generate an interactive bash wizard that walks a human through steps only they can perform. Use when provisioning infrastructure, setting up credentials or CI secrets, walking an unfamiliar third-party dashboard, or running a one-off migration or cutover. Don't invoke this for steps the agent can perform itself.\n- write-a-skill: Create new agent skills with proper structure, progressive disclosure, and bundled resources. Use when user wants to create, write, or build a new skill.\n- writing-for-agents: Writing documents for agents. Use when creating or editing skills, or modifying AGENTS.md or CLAUDE.md.\n</skills>\n# Internal URLs\nMost FS/bash tools auto-resolve these to FS paths.\n- `skill://<name>`: instructions; `/<path>`: its file\n- `rule://<name>`: details\n- `agent://<id>`: output artifact; `/<child>`: nested-subagent output; otherwise `/<path>`: JSON field\n- `history://<id>`: read-only agent transcript (live|parked|released); bare `history://`: all agents. Registered process-wide agents and persisted subagents discoverable from artifact trees; unregistered top-level sessions are not discovered solely from persisted session files.\n- `artifact://<id>`: content\n- `local://<name>.md`: plan artifacts/shared subagent content\n- `mcp://<uri>`: MCP resource\n- `issue://<N>` / `issue://<owner>/<repo>/<N>`: GitHub issue; bare: recent; `?state=open|closed|all&limit=&author=&label=`.\n- `pr://<N>` / `pr://<owner>/<repo>/<N>`: same cache; bare: recent; `?comments=0` `?state=open|closed|merged|all&limit=&author=&label=`.\n- `omp://`: harness docs; AVOID unless user asks about harness.\n\n# Tool Inventory\n- Read: `read`\n- Bash: `bash`\n- Edit: `edit`\n- Eval: `eval`\n- Glob: `glob`\n- Grep: `grep`\n- Task: `task`\n- Hub: `hub`\n- Web Search: `web_search`\n- Write: `write`\n- Submit Result: `yield`\n# xd:// Tool Devices\nWrite JSON args as `content` to `xd://<tool>` via `write`. Invalid args return schema in error → fix/retry.\n## ast_edit — AST Edit\n\nStructural AST-aware rewrites via ast-grep. Use for codemods where text replace is unsafe. Mixed-language paths are fine: each file is parsed in its own language, and a pattern only rewrites files it parses in.\n\n- Metavariables in `pat` (`$A`, `$$$ARGS`) substitute into `out`.\n- **Patterns match AST structure, not text.** `$NAME` = one node; `$_` = unbound; `$$$NAME` = zero-or-more.\n  - Use `$$$NAME`, NOT `$$NAME` (invalid). Names UPPERCASE, whole node — partial like `prefix$VAR` fails.\n- Same metavariable twice → MUST match identical code (`$A == $A` matches `x == x`, not `x == y`).\n- Rewrite patterns MUST parse as single AST node. Non-standalone → wrap: `class $_ { … }`.\n- TS: tolerate annotations — `async function $NAME($$$ARGS): $_ { $$$BODY }`. Delete with empty `out`: `{\"pat\":\"console.log($$$)\",\"out\":\"\"}`.\n- 1:1 substitution — no splitting/merging captures.\n- Matches are STAGED as a proposal, not applied: finalize by writing a one-sentence reason to `xd://resolve` (apply) or `xd://reject` (discard).\n- Parse issues → malformed rewrite, not clean no-op. For one-off text edits, prefer the Edit tool.\n\n### Schema\n```ts\ntype Args = {\n  /** rewrite ops */\n  ops: Array<{\n    /** ast pattern */\n    pat: string;\n    /** replacement template */\n    out: string;\n  }>;\n  /** files, directories, globs, or internal URLs to rewrite */\n  paths: string[];\n};\n```\nExecute by writing JSON to xd://ast_edit.\n\n## debug — Debug\n\nDebugger access. Prefer over bash for program state, breakpoints, stepping, or thread inspection.\nOnly one active session at a time. `program` is a target path, not a shell command.\nDirectories need a directory-capable adapter (e.g. `dlv`).\n\n### Schema\n```ts\ntype Args = {\n  action: \"launch\" | \"attach\" | \"set_breakpoint\" | \"remove_breakpoint\" | \"set_instruction_breakpoint\" | \"remove_instruction_breakpoint\" | \"data_breakpoint_info\" | \"set_data_breakpoint\" | \"remove_data_breakpoint\" | \"continue\" | \"step_over\" | \"step_in\" | \"step_out\" | \"pause\" | \"evaluate\" | \"stack_trace\" | \"threads\" | \"scopes\" | \"variables\" | \"disassemble\" | \"read_memory\" | \"write_memory\" | \"modules\" | \"loaded_sources\" | \"custom_request\" | \"output\" | \"terminate\" | \"sessions\";\n  /** debug target path; Delve accepts Go package directories */\n  program?: string;\n  /** program arguments */\n  args?: string[];\n  /** configured adapter id (gdb, lldb-dap, debugpy, dlv, rdbg, or dap.json entry) */\n  adapter?: string;\n  cwd?: string;\n  /** source file */\n  file?: string;\n  /** source line */\n  line?: number;\n  /** function name */\n  function?: string;\n  /** variable or data name */\n  name?: string;\n  /** breakpoint condition */\n  condition?: string;\n  hit_condition?: string;\n  /** expression to evaluate */\n  expression?: string;\n  /** evaluate context: watch | repl | hover | variables | clipboard */\n  context?: string;\n  frame_id?: number;\n  /** scope variables reference */\n  scope_id?: number;\n  /** variable reference */\n  variable_ref?: number;\n  /** process id for attach */\n  pid?: number;\n  /** remote attach port */\n  port?: number;\n  /** remote attach host */\n  host?: string;\n  /** max stack frames */\n  levels?: number;\n  /** memory reference or address */\n  memory_reference?: string;\n  instruction_reference?: string;\n  instruction_count?: number;\n  instruction_offset?: number;\n  /** bytes to read */\n  count?: number;\n  /** base64 memory payload */\n  data?: string;\n  /** data breakpoint id */\n  data_id?: string;\n  access_type?: \"read\" | \"write\" | \"readWrite\";\n  /** custom dap request command */\n  command?: string;\n  /** custom request arguments */\n  arguments?: Record<string, unknown>;\n  offset?: number;\n  resolve_symbols?: boolean;\n  allow_partial?: boolean;\n  start_module?: number;\n  module_count?: number;\n  /** per-request timeout seconds */\n  timeout?: number;\n};\n```\nExecute by writing JSON to xd://debug.\n\n## Additional devices (docs on demand)\n- xd://spt_checkpoint — After the commune skill has saved this live endpoint's continuity drop, compact native OMP context and wake the same endpoint.\n\nRead xd://<tool> for full docs + JSON schema before first use.\n§ Tool Policy\n# General\nUse tools when they improve correctness, completeness, or grounding.\n- SHOULD resolve prerequisites first; NEVER accept first plausible answer when another call reduces uncertainty; retry empty/partial/suspiciously narrow lookup differently.\n- SHOULD parallelize independent calls.\n- User says `parallel` or `parallelize` → MUST use `task` subagents; parallel tool calls insufficient.\n\n# Tool I/O\n- Prefer relative `path`-like fields.\n- Most tools take `i`: capitalized 2–6-word present-participle intent (e.g. \"Reading model role settings\").\n# Specialized Tools\nMUST use specialized tool over shell equivalent:\n- File/directory reads → `read`; directory path lists entries.\n- Surgical edits → `edit`.\n- Create/overwrite → `write`.\n\n- Regex search/target location → `grep`, not shell `grep`, `rg`, `awk`.\n- Structure mapping/globbing → `glob`, not `ls **/*.ext` or `fd`.\n- `bash`: real binaries/short fact pipelines only; commands shadowing specialized tools blocked.\n- Bash litmus: one external-CLI call/short pipeline returning count, frequency, set difference, checksum. For merely moving, paging, trimming fetchable bytes: tool.\n\n<critical>\n`write xd://report_issue`: automated QA. Any tool output inconsistent with described behavior for parameters → write plain `<tool>: <concise description>` to `xd://report_issue`. False positives fine.\n</critical>\n\n# Exploration\nNEVER open files hoping. AVOID unneeded files/sections.\n- Use `read` offset/limit, not whole-file reads.\n\n# AST\nSHOULD use syntax-aware tools before text hacks:\n\n- Codemods → `ast_edit`.\n\n# Delegation\nInline first. Fan out only when 2+ independent slices each cost more than a handful of your own calls, or the read set would flood context; decide after your own first `grep`/`read`, never before it.\n- NEVER open with a scout. Scope with `grep`/`read`/`glob` yourself; a scout is for a genuinely unmapped subsystem after inline scoping stalls.\n- NEVER delegate one slice. One subagent for one job, a slice you already have open, cleanup (comment trims, changelog lines, formatting, sub-30-line edits), or a direct question: do it yourself.\n- NEVER babysit. Spawn → keep working → read the result. Steering a lone agent through `hub` send/wait costs more than the work.\n## Delegation gates\n- **Own decomposition.** Before spawning: map request, independent slices, cross-slice formats/schemas/interfaces. Only user-enumerated 2+ self-contained runnable slices dispatch directly. NEVER outsource top-level plan; generic \"plan\"/\"design\" agent starts blank, knows less, adds round-trip/no parallelism. Slice-local design and requested competing plans/reviews allowed.\n- **Real concurrency.** Fan exactly to genuine decomposition, one `tasks[]` array. NEVER serialize concurrent slices, invent padding, or spawn one then idle.\n- **User intent.** Subagents lack conversation; retain interpretation/taste; each assignment gets all slice requirements.\n- **Cap:** At most 32 subagents concurrently; excess queues. `tasks[]` batch > 32 delays results: stay within cap.\n- **Dependencies only.** A before B only if B strictly needs A; shared prerequisite inline, then fan out. “Parallelize” = parallel execution of independent slices, not agents routing sequential work. Small missing piece: run parallel; B asks A via `hub`!\n\n§ Workflow\n# 1. Scope\n- Read relevant skills first.\n- Multi-file work: plan before files.\n\n# 2. Research Before Editing\n- Read sections, not snippets. MUST reuse existing patterns; second convention beside existing is PROHIBITED.\n\n- Tool failure/file change since read → re-read before acting.\n\n# 3. Decompose\n\n# 4. Implement\n- Fix source; NEVER suppress symptom/special-case input unless asked.\n- Clean cutover: migrate every caller; remove obsolete code/comments/aliases/re-exports/deprecated paths.\n- Prefer existing-file updates over new files. Review as user.\n- NEVER run destructive git commands/delete unrelated code you didn't write; code the cutover obsoletes is in scope.\n\n# 5. Verify\n- NEVER yield non-trivial work without deliverable proof:\n  - **Experiment/investigation** → run; output is proof; no tests.\n  - **UI change** → verify against the actual surface:\n    - **Web UI** → use `browser.open` to get a tab handle, its direct helpers for common actions, `tab.run` for custom JavaScript, and `tab.close` when done; visual confirmation is proof; no tests unless existing suite really breaks.\n    - **TUI/CLI** → launch the actual program and verify terminal interaction, output, or state.\n    - No suitable runtime capability for the changed surface → verify with a throwaway script or smoke test; explicitly report when visual verification cannot be performed.\n  - **Bug fix** → reproduce, fix, confirm reproduction no longer triggers. SHOULD keep the reproduction as a regression test: fails pre-fix, passes post-fix; impractical → smoke test, report it.\n  - **Permanent feature/API change** → fix existing tests the changed contract breaks; prove new behavior with a throwaway script. New test ONLY for a genuinely uncertain edge case, or on user request.\n- Smoke test: run thing, not test file; launch, exercise changed path, observe result.\n- Tests: permanent load, not proof of work. A test earns its place ONLY where a plausible bug would fail it.\n  - Each MUST defend observable contract/fail on plausible bug.\n  - Test behavior, boundaries, invariants, transitions, precedence, real errors—not plumbing, source text, incidental defaults.\n  - Match conventions; deterministic, isolated, full-suite-safe.\n  - NEVER write a test so the change \"has tests\" → throwaway script.\n  - NEVER assert implementation: wiring, field copies, defaults, forwarding, mock echoes, source text → assert what a consumer observes.\n  - NEVER pad: same-path parameter rows, tautologies, bare not-throw, non-empty/length-grew checks.\n  - Worth keeping: behavior, boundaries, invariants, transitions, precedence, real errors. Match conventions; deterministic, isolated, full-suite-safe.\n  - Existing test failing this bar (pins wording, implementation, incidental behavior) → MUST delete; NEVER re-pin it to the new text. In scope regardless of author.\n\n# 6. Cleanup\nLast phase; REQUIRED after smoke test proves work; NEVER pre-plan/pre-allocate cleanup todos.\n- Permanent feature/bug fix → docs, changelog, scaffold + throwaway-script removal; tests only per Verify.\n- Experiment/one-off investigation → no cleanup tests/docs.\n\n§ Delivery\n<contract>\nInviolable.\n- NEVER yield before complete deliverable; phase boundary/todo flip/sub-step never yields: same turn.\n- NEVER fabricate output; code/tool/test/doc/source claims MUST be grounded.\n- NEVER substitute easier/familiar problem: don't infer extra scope—retries, validation, telemetry, abstraction “while you're at it”—or solve symptom—suppress warning/exception, special-case input—unless asked. Real ask only.\n- NEVER ask for tool/repo/file-provided information; NEVER punt half-solved work.\n- Default clean cutover: migrate every caller; no shims, aliases, deprecated paths.\n</contract>\n\n<completeness>\n- “Done”: specified end-to-end behavior plus every named acceptance criterion; not compiling scaffold, narrowed test, plausible subset.\n- Reduce scope only with explicit user approval in this conversation; NEVER silently shrink.\n- NEVER deliver unfinished work: stubs, placeholders, mocks, no-ops, fake fallbacks, `TODO: implement`, misleading “scaffold”/“MVP”/“v1”/“foundation”/“follow-up”. Unavailable real-implementation info → state missing prerequisite; finish all reachable work.\n</completeness>\n\n<evidence-and-output>\n- Format MUST match ask; prose brief; evidence, verification, blocking details complete.\n- Code/tool/test/doc/source claims MUST be grounded; unobserved claims `[INFERENCE]`.\n- Verification claims exactly match exercised work.\n</evidence-and-output>\n\n<yielding>\nBefore yielding: all affected callsites/tests/docs updated or intentionally unchanged; output/evidence requirements satisfied.\nBefore blocked: ensure info unreachable via tools/context; one failed check ≠ blocked. Finish reachable work; state exactly missing and tried.\n</yielding>\n\n§ Critical\n<critical>\n- NEVER yield while actionable work remains; phase boundary/todo flip/sub-step never stops: same turn.\n- NEVER narrate/consider session limits, token/tool budgets, effort estimates, or possible completion; start unbounded: execute/delegate.\n- NEVER re-audit applied edit or routinely run git subcommands for validation. Tool results are verification.\n</critical>\n\n§ Role\nWorker agent: delegated tasks.\n\nTools: FULL access (edit, write, bash, grep, read, etc.); MUST use as needed to complete task.\nMUST hyperfocus assigned task; NEVER deviate.\n\n<directives>\n- MUST finish assigned work only; return minimum useful result; do not repeat filesystem writes.\n- SHOULD edit files, run commands, create files when task requires.\n- MUST concise; NEVER filler, repetition, tool transcripts. User cannot see you; result: notes for yourself.\n- SHOULD prefer narrow lookups (`grep`/`glob`), then read needed ranges only; ignore beyond current scope.\n- AVOID full-file reads unless necessary.\n- SHOULD prefer editing existing files over creating new files.\n- NEVER create documentation files (`*.md`) unless explicitly requested.\n- MUST follow assignment and instructions.\n- `task` delegation: select most specific `agent` type per spawn; general-purpose worker only if no listed specialist fits.\n</directives>\n\n§ Context\n# Goal\nAuthor test-only RED W3 riders per root WEBSERVE-272-W3-DRIFT-RIDERS.md (read whole). Worktree C:/Users/decid/Documents/projects/spt-core/.worktrees/ws272-w3-drift, branch test/ws272-w3-drift base eb38b71a.\n# Constraints\nNo production implementations, no requirements minted, no docs/schema regen yet, no commits. Skip all tests/build/lint/formatters; Main handles red proof once. Missing producer symbols deliberately RED until todlando W3. Stop and report forks. IR12 already landed #193 (68e34ae5); don't duplicate.\n# Contract\nA pure changelog_page(&str)->String and gen_changelog(&Path,bool)->Result<(),String> supplied by todlando. Main requested PUBLISHED_DOC_PATHS: &[&str] for membership; not confirmed yet, use as explicit proposed contract. B schema checked by xtask existing scanner. Register docs_dir placement [adapter] awaits producer confirmation; report any unresolved key/API rather than inventing.\n§ Coop\nYou are operating on a piece of work assigned to you by the main agent.\n\n# Validation\nProject-wide validation is the main agent's job, run once after all subagents land. NEVER run formatters, linters, or project-wide builds/test suites unless your assignment explicitly instructs it — siblings edit concurrently; mid-flight validation blocks on their half-finished changes and reports phantom failures. Scoped proof of your own change (single test file, targeted repro, smoke run) is fine.\n# Peers\nYou can reach other live agents via the `hub` tool. Your id is `DocsEscapeRed`. Currently visible peers:\n- `ChangelogRed` — task (sub, running)\n- `Main` — main (main, running)\n3 parked peer(s) omitted.\n\nUse `hub` messaging only for quick coordination, never long-form content. Address peers by id or use `\"all\"` to broadcast.\n- Discovery: the roster above shows live (running+idle) peers and a parked count, never parked names or task labels. `hub` op:\"list\" refreshes the live view; pass status:\"parked\" to inspect parked history.\n- Coordination: before you edit a file or start work a sibling may already own, message that peer first — overlapping edits collide. Idle peers are not gone: messaging them wakes them.\n- Follow-up: answer a peer's question with a short reply (set `replyTo`); use `await` only when you genuinely cannot proceed without the answer.\n- Parked history: omitted from this roster. `hub` op:\"list\" status:\"parked\" lists ids; `send` to a known parked id revives it. `history://<id>` and `agent://<id>` stay readable.\n\n§ Completion\nNo TODO tracking, no progress updates. Execute; report results with `yield`.\n\nWhile work remains, you MUST continue with another tool call — investigate, edit, run, verify. Save narrative for a terminal `yield` unless you intentionally record an incremental section.\n\nYield protocol:\n- Omit `type` for the normal single terminal structured result in `data`.\n- Use non-empty `type: string[]` for incremental, non-terminal sections; calls accumulate by section.\n- Use `type: string` for a terminal result; if data is omitted, your last assistant turn becomes the raw final result.\n\nThis is your only way to return a final result. For structured results, you NEVER put JSON in plain text or substitute a text summary for `data`.\nGiving up is a last resort. If truly blocked, you MUST terminal-yield `{ error }` describing what you tried and the exact blocker.\nYou NEVER give up due to uncertainty, missing information obtainable via tools or repo context, or needing a design decision you can derive yourself.\n\nYou MUST keep going until this ticket is closed. This matters.\n\nPROJECT\n\n<workstation>\n- OS: win32 10.0.26200\n- Distro: Windows_NT\n- Kernel: Windows 11 Pro\n- Arch: x64\n- CPU: AMD Ryzen 7 7800X3D 8-Core Processor\n- GPU: AMD Radeon(TM) Graphics\n- Model: openai-codex/gpt-6-astra\n</workstation>\n<critical>\n- Each response MUST advance the task; completion only stopping condition.\n- MUST default to informed action; do not ask for confirmation when tools or repo context can answer.\n- Before yielding, MUST verify significant behavioral changes: run the specific test, command, or scenario covering the change.\n</critical>","task":"Complete assignment thoroughly:\n\n# Target\nOwn only crates/spt-runtime/src/registry.rs in rider worktree. Read full spec and sister strings refusal tests/seed_source/manifest shape.\n# Change\nAuthor Rider B docs_dir parent traversal and absolute escape refusal at real register, one-line errors and no recorded adapter; missing docs_dir accepted with meaningful returned registration identity. Do not invent future error variants or field access. If docs_dir table placement cannot be inferred, report fork before edits; producer confirmation is pending Main.\n# Acceptance\nTest-only changes, tags REQ-WEB-ADAPTER-DOCS-FACET, current baseline should behaviorally fail by accepting unknown docs_dir. Skip all validation/build/tests/formatters; return exact test names and dependencies.","tools":["read","bash","edit","eval","glob","grep","task","hub","web_search","write","yield","ast_edit","debug","spt_checkpoint"],"agent":"task","resolvedModel":"openai-codex/gpt-6-astra","readOnly":false,"spawns":"*"}
{"type":"message","id":"ae3dc620","parentId":"0e9406ce","timestamp":"2026-09-06T16:13:54.191Z","message":{"role":"user","content":[{"type":"text","text":"Complete assignment thoroughly:\n\n# Target\nOwn only crates/spt-runtime/src/registry.rs in rider worktree. Read full spec and sister strings refusal tests/seed_source/manifest shape.\n# Change\nAuthor Rider B docs_dir parent traversal and absolute escape refusal at real register, one-line errors and no recorded adapter; missing docs_dir accepted with meaningful returned registration identity. Do not invent future error variants or field access. If docs_dir table placement cannot be inferred, report fork before edits; producer confirmation is pending Main.\n# Acceptance\nTest-only changes, tags REQ-WEB-ADAPTER-DOCS-FACET, current baseline should behaviorally fail by accepting unknown docs_dir. Skip all validation/build/tests/formatters; return exact test names and dependencies."}],"attribution":"agent","timestamp":1788711234100}}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_UKC4Lo8Tm61h1BSGzEqVTcan|fc_07548ebf196ed5a1016a9d914485d887d1a58dd0de069ddf86","toolName":"read","startedAt":"2026-09-06T16:13:58.077Z","args":{"path":"C:/Users/decid/Documents/projects/spt-core/.worktrees/ws272-w3-drift/WEBSERVE-272-W3-DRIFT-RIDERS.md"},"intent":"Reading rider specification"},"id":"b5241e3f","parentId":"ae3dc620","timestamp":"2026-09-06T16:13:58.077Z"}
{"type":"message","id":"c849a111","parentId":"b5241e3f","timestamp":"2026-09-06T16:13:58.077Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"call_UKC4Lo8Tm61h1BSGzEqVTcan|fc_07548ebf196ed5a1016a9d914485d887d1a58dd0de069ddf86","name":"read","arguments":{"i":"Reading rider specification","path":"C:/Users/decid/Documents/projects/spt-core/.worktrees/ws272-w3-drift/WEBSERVE-272-W3-DRIFT-RIDERS.md"},"intent":"Reading rider specification"}],"api":"openai-codex-responses","provider":"openai-codex","model":"gpt-6-astra","usage":{"input":6896,"output":57,"cacheRead":10368,"cacheWrite":0,"totalTokens":17321,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1788711234190,"responseId":"resp_07548ebf196ed5a1016a9d9142bef487d196f0f02ac0e8100b","providerPayload":{"type":"openaiResponsesHistory","provider":"openai-codex","dt":true,"items":[{"id":"fc_07548ebf196ed5a1016a9d914485d887d1a58dd0de069ddf86","type":"function_call","status":"completed","arguments":"{\"i\":\"Reading rider specification\",\"path\":\"C:/Users/decid/Documents/projects/spt-core/.worktrees/ws272-w3-drift/WEBSERVE-272-W3-DRIFT-RIDERS.md\"}","call_id":"call_UKC4Lo8Tm61h1BSGzEqVTcan","internal_chat_message_metadata_passthrough":{"create_time":1788711235.120207,"turn_id":"0b3b77b9-eace-409f-9a5d-491fd9ffe825"},"name":"read","metadata":{"turn_id":"0b3b77b9-eace-409f-9a5d-491fd9ffe825"}}]},"duration":3886.0168000012636,"ttft":2361.272099999711,"completedAt":1788711238076,"contextSnapshot":{"promptTokens":17264,"nonMessageTokens":16645,"compactionEpoch":0}}}
{"type":"credential_pin","id":"c1dc4318","parentId":"c849a111","timestamp":"2026-09-06T16:13:58.077Z","provider":"openai-codex","hash":"1a3c10d7bc41d381cab1511db604bc2637dfcfc63796a890d8b5ee152859e1cb"}
{"type":"message","id":"75493d78","parentId":"c1dc4318","timestamp":"2026-09-06T16:13:58.106Z","message":{"role":"toolResult","toolCallId":"call_UKC4Lo8Tm61h1BSGzEqVTcan|fc_07548ebf196ed5a1016a9d914485d887d1a58dd0de069ddf86","toolName":"read","content":[{"type":"text","text":"Path 'C:/Users/decid/Documents/projects/spt-core/.worktrees/ws272-w3-drift/WEBSERVE-272-W3-DRIFT-RIDERS.md' not found"}],"details":{},"isError":true,"timestamp":1788711238105}}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_zdSCRe1k36vSgncBeguCIrwl|fc_07548ebf196ed5a1016a9d91481adc87d1b0d929d08c48d11a","toolName":"read","startedAt":"2026-09-06T16:14:01.344Z","args":{"path":"C:/Users/decid/Documents/projects/spt-core/WEBSERVE-272-W3-DRIFT-RIDERS.md"},"intent":"Reading rider specification"},"id":"979756d1","parentId":"75493d78","timestamp":"2026-09-06T16:14:01.344Z"}
{"type":"message","id":"5dd86c5f","parentId":"979756d1","timestamp":"2026-09-06T16:14:01.345Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"call_zdSCRe1k36vSgncBeguCIrwl|fc_07548ebf196ed5a1016a9d91481adc87d1b0d929d08c48d11a","name":"read","arguments":{"i":"Reading rider specification","path":"C:/Users/decid/Documents/projects/spt-core/WEBSERVE-272-W3-DRIFT-RIDERS.md"},"intent":"Reading rider specification"}],"api":"openai-codex-responses","provider":"openai-codex","model":"gpt-6-astra","usage":{"input":219,"output":48,"cacheRead":17152,"cacheWrite":0,"totalTokens":17419,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1788711238154,"responseId":"resp_07548ebf196ed5a1016a9d9146a00c87d19680c8d620cb8ca6","providerPayload":{"type":"openaiResponsesHistory","provider":"openai-codex","dt":true,"items":[{"id":"fc_07548ebf196ed5a1016a9d91481adc87d1b0d929d08c48d11a","type":"function_call","status":"completed","arguments":"{\"i\":\"Reading rider specification\",\"path\":\"C:/Users/decid/Documents/projects/spt-core/WEBSERVE-272-W3-DRIFT-RIDERS.md\"}","call_id":"call_zdSCRe1k36vSgncBeguCIrwl","internal_chat_message_metadata_passthrough":{"create_time":1788711238.844864,"turn_id":"0b3b77b9-eace-409f-9a5d-491fd9ffe825"},"name":"read","metadata":{"turn_id":"0b3b77b9-eace-409f-9a5d-491fd9ffe825"}}]},"duration":3189.418400000781,"ttft":1976.8737000003457,"completedAt":1788711241344,"contextSnapshot":{"promptTokens":17371,"nonMessageTokens":16645,"compactionEpoch":0}}}
{"type":"message","id":"94046929","parentId":"5dd86c5f","timestamp":"2026-09-06T16:14:01.350Z","message":{"role":"toolResult","toolCallId":"call_zdSCRe1k36vSgncBeguCIrwl|fc_07548ebf196ed5a1016a9d91481adc87d1b0d929d08c48d11a","toolName":"read","content":[{"type":"text","text":"[WEBSERVE-272-W3-DRIFT-RIDERS.md#5AA5]\n1:# WEBSERVE (#272) W3 — drift-gate test riders (hertz)\n2:\n3:Composed by doyle 2026-09-06 ~16:20Z from `WEBSERVE-272-JIT.md` §W3, while todlando is in W0\n4:step 3/4. Test-only lane per the dispatch split: hertz authors the cells, todlando builds the\n5:generator/facet they exercise. Everything below was MEASURED at main `eb38b71a` on 2026-09-06;\n6:re-measure at your base sha before trusting a line number.\n7:\n8:## What exists today (the surface your cells extend)\n9:\n10:- `crates/xtask/src/main.rs` — `gen` writes ONE generated page, `docs-site/src/cli/reference.md`\n11:  (`gen()` @273); `check` regenerates + byte-diffs it and exits 1 with a named message\n12:  (@282-288). Then, in order: `scan_published_docs_for_internal_codes()` over a two-entry path\n13:  list (@454-457: `cli/reference.md`, `manifest.schema.json`), `check_llms_links()` (@973, every\n14:  `](target)` in `docs-site/llms.txt` must resolve via `llms_target_exists` @1000),\n15:  `llms_full()` (@1014) concatenates every page in `SUMMARY.md` order.\n16:- xtask already has 40 `#[test]` cells in `main.rs` (tempdir-rooted fixtures, e.g. @2345 for\n17:  `llms_target_exists`) — match that shape, no new harness.\n18:- `crates/spt-runtime/src/manifest.rs` @2452 — the manifest JSON-schema drift gate (checked-in\n19:  `manifest.schema.json` must equal the struct-derived schema). A new manifest key trips it\n20:  until the schema is regenerated and committed.\n21:- `CHANGELOG.md` is NOT on the docs site and is NOT drift-gated in any form. It carries\n22:  traceability tags as HTML comments (`<!-- [doc->REQ-IO-MSG-EDGES] -->` at line 17 today) and\n23:  `releases#N` references in section bodies.\n24:- MEASURED for #265: none of the 7 installed adapters on this box ships a docs directory\n25:  (`$SPT_HOME/adapters/_github/*` = `manifest.toml`, the exe, `strings/`, `state/`). The facet\n26:  therefore needs a manifest key; ruled `docs_dir` (todlando's build, JIT §W3 amended).\n27:\n28:## Rider A — REQ-DOCS-CHANGELOG-PAGE gains a `unit` stage (yours)\n29:\n30:Contract todlando builds to (hand him this signature verbatim; he implements, you test):\n31:\n32:```rust\n33:/// CHANGELOG.md -> docs-site/src/changelog.md. Pure: text in, text out.\n34:fn changelog_page(changelog_md: &str) -> String\n35:```\n36:\n37:Your cells (in `crates/xtask/src/main.rs` `#[cfg(test)]`, tagged `// [unit->REQ-DOCS-CHANGELOG-PAGE]`):\n38:\n39:1. **Tags stripped.** Input containing `<!-- [doc->REQ-X] -->` and `<!-- [impl->REQ-Y] -->`\n40:   produces output where `forbidden_codes_in(&out)` (the existing scanner @~300) is EMPTY.\n41:   This is the trap: the generated page MUST join the `scan_published_docs_for_internal_codes`\n42:   path list (assert that list contains `docs-site/src/changelog.md` — a const or a\n43:   function returning the list; if it is a literal array, ask todlando to lift it to a const so\n44:   the cell can read it), and CHANGELOG.md's own tags would fail that scan on the first run.\n45:2. **Bodies untouched.** Every non-comment line of the input appears byte-identical in the\n46:   output, in order (the page is a copy with comments removed and a generated-banner header,\n47:   the same \"> **Generated** … Do not edit by hand.\" shape as `cli_reference` @144-148).\n48:3. **Drift red/green (the mutation cell).** tempdir root with a `CHANGELOG.md` and a stale\n49:   `docs-site/src/changelog.md`: the check arm reports drift naming `changelog.md`; after the\n50:   gen arm writes it, the check arm passes. Refactor note for todlando: `gen(check: bool)` @273\n51:   reads `repo_root()` and builds the binary via `spt_bin` — the changelog half must be a\n52:   separate root-parameterised fn (`gen_changelog(root: &Path, check: bool) -> Result<(), String>`)\n53:   so your cell needs no cargo build. The CLI-reference half stays as it is.\n54:4. **Index membership.** `docs-site/src/SUMMARY.md` lists `changelog.md` AND `docs-site/llms.txt`\n55:   links it (so `check_llms_links` holds it and `llms_full` carries it). Cell: read both real\n56:   files from `repo_root()`, assert the entries exist — this is a repo-state cell, deliberately.\n57:\n58:Mutation proof at your gate (driver, not a cell): append a line to `CHANGELOG.md`, run\n59:`cargo run -p xtask -- check` → exit 1 with the changelog drift line in stderr; `-- gen`, then\n60:`check` → exit 0. Read the exit FILES. Then revert the CHANGELOG line.\n61:\n62:## Rider B — REQ-WEB-ADAPTER-DOCS-FACET, the drift-gate half (yours)\n63:\n64:todlando adds `docs_dir` to the manifest (adapter-relative, must stay inside the adapter dir,\n65:same refusal shape as `[strings]` pointers per `docs/MANIFEST.md` @342). Your cells:\n66:\n67:1. **Schema drift.** The existing gate @manifest.rs:2452 already trips; your addition is the\n68:   regenerated `manifest.schema.json` carrying `docs_dir` WITHOUT an internal code in its\n69:   description (the scan @452 reads that file). Cell: parse the checked-in schema, assert the\n70:   `docs_dir` property exists and `forbidden_codes_in(description)` is empty. Tag\n71:   `// [unit->REQ-WEB-ADAPTER-DOCS-FACET]`.\n72:2. **Escape refused at register.** `docs_dir = \"../x\"` and an absolute path fail manifest\n73:   validation with the one-line error (mirror the `[strings]` pointer refusal: the escape\n74:   test is `crates/spt-runtime/src/registry.rs` @190-196, its cell @~1394 — measured at\n75:   `eb38b71a`). Missing `docs_dir` is valid (no adapter ships docs today).\n76:3. **`--help` regen.** If the facet adds any CLI surface (todlando says; W3 plan is a URL\n77:   facet, likely no verb), `cli/reference.md` regen rides the existing gate — nothing for you\n78:   unless a verb appears.\n79:\n80:The router/404-with-facet-name/int cells over `/<node>/a/<adapter>/…` are todlando's (they\n81:need the W0 router); do not author them.\n82:\n83:## Sequencing + lane shape\n84:\n85:- Branch `test/ws272-w3-drift` under `.worktrees/ws272-w3-drift`, own pool, claim from inside.\n86:  Author cells 1/2/4 of Rider A and cells 1/2 of Rider B NOW against the signatures above;\n87:  they are RED until todlando's W3 branch exists. Do not open a PR red.\n88:- When `build/ws272-w3` carries `changelog_page` + `docs_dir`, rebase onto it, run your cells,\n89:  hand todlando the commit sha(s); they ride INSIDE the W3 PR (`Co-authored by: hertz` on your\n90:  commits; pick-audit at land keeps provenance). One gate run, not two.\n91:- treqs: W3 mints its REQ ids in todlando's lane (standing rule). Your tags reference ids\n92:  that do not exist on main yet — that is fine on your branch; `traceable-reqs check` runs\n93:  green only after the rebase. Do not mint them yourself (two minters, one toml = conflict).\n94:- IR-12 residual rides here too (JIT §Register riders) — bring the entry text with you and\n95:  say in the PR body which line discharges it."}],"details":{"totalLines":95,"displayContent":{"text":"# WEBSERVE (#272) W3 — drift-gate test riders (hertz)\n\nComposed by doyle 2026-09-06 ~16:20Z from `WEBSERVE-272-JIT.md` §W3, while todlando is in W0\nstep 3/4. Test-only lane per the dispatch split: hertz authors the cells, todlando builds the\ngenerator/facet they exercise. Everything below was MEASURED at main `eb38b71a` on 2026-09-06;\nre-measure at your base sha before trusting a line number.\n\n## What exists today (the surface your cells extend)\n\n- `crates/xtask/src/main.rs` — `gen` writes ONE generated page, `docs-site/src/cli/reference.md`\n  (`gen()` @273); `check` regenerates + byte-diffs it and exits 1 with a named message\n  (@282-288). Then, in order: `scan_published_docs_for_internal_codes()` over a two-entry path\n  list (@454-457: `cli/reference.md`, `manifest.schema.json`), `check_llms_links()` (@973, every\n  `](target)` in `docs-site/llms.txt` must resolve via `llms_target_exists` @1000),\n  `llms_full()` (@1014) concatenates every page in `SUMMARY.md` order.\n- xtask already has 40 `#[test]` cells in `main.rs` (tempdir-rooted fixtures, e.g. @2345 for\n  `llms_target_exists`) — match that shape, no new harness.\n- `crates/spt-runtime/src/manifest.rs` @2452 — the manifest JSON-schema drift gate (checked-in\n  `manifest.schema.json` must equal the struct-derived schema). A new manifest key trips it\n  until the schema is regenerated and committed.\n- `CHANGELOG.md` is NOT on the docs site and is NOT drift-gated in any form. It carries\n  traceability tags as HTML comments (`<!-- [doc->REQ-IO-MSG-EDGES] -->` at line 17 today) and\n  `releases#N` references in section bodies.\n- MEASURED for #265: none of the 7 installed adapters on this box ships a docs directory\n  (`$SPT_HOME/adapters/_github/*` = `manifest.toml`, the exe, `strings/`, `state/`). The facet\n  therefore needs a manifest key; ruled `docs_dir` (todlando's build, JIT §W3 amended).\n\n## Rider A — REQ-DOCS-CHANGELOG-PAGE gains a `unit` stage (yours)\n\nContract todlando builds to (hand him this signature verbatim; he implements, you test):\n\n```rust\n/// CHANGELOG.md -> docs-site/src/changelog.md. Pure: text in, text out.\nfn changelog_page(changelog_md: &str) -> String\n```\n\nYour cells (in `crates/xtask/src/main.rs` `#[cfg(test)]`, tagged `// [unit->REQ-DOCS-CHANGELOG-PAGE]`):\n\n1. **Tags stripped.** Input containing `<!-- [doc->REQ-X] -->` and `<!-- [impl->REQ-Y] -->`\n   produces output where `forbidden_codes_in(&out)` (the existing scanner @~300) is EMPTY.\n   This is the trap: the generated page MUST join the `scan_published_docs_for_internal_codes`\n   path list (assert that list contains `docs-site/src/changelog.md` — a const or a\n   function returning the list; if it is a literal array, ask todlando to lift it to a const so\n   the cell can read it), and CHANGELOG.md's own tags would fail that scan on the first run.\n2. **Bodies untouched.** Every non-comment line of the input appears byte-identical in the\n   output, in order (the page is a copy with comments removed and a generated-banner header,\n   the same \"> **Generated** … Do not edit by hand.\" shape as `cli_reference` @144-148).\n3. **Drift red/green (the mutation cell).** tempdir root with a `CHANGELOG.md` and a stale\n   `docs-site/src/changelog.md`: the check arm reports drift naming `changelog.md`; after the\n   gen arm writes it, the check arm passes. Refactor note for todlando: `gen(check: bool)` @273\n   reads `repo_root()` and builds the binary via `spt_bin` — the changelog half must be a\n   separate root-parameterised fn (`gen_changelog(root: &Path, check: bool) -> Result<(), String>`)\n   so your cell needs no cargo build. The CLI-reference half stays as it is.\n4. **Index membership.** `docs-site/src/SUMMARY.md` lists `changelog.md` AND `docs-site/llms.txt`\n   links it (so `check_llms_links` holds it and `llms_full` carries it). Cell: read both real\n   files from `repo_root()`, assert the entries exist — this is a repo-state cell, deliberately.\n\nMutation proof at your gate (driver, not a cell): append a line to `CHANGELOG.md`, run\n`cargo run -p xtask -- check` → exit 1 with the changelog drift line in stderr; `-- gen`, then\n`check` → exit 0. Read the exit FILES. Then revert the CHANGELOG line.\n\n## Rider B — REQ-WEB-ADAPTER-DOCS-FACET, the drift-gate half (yours)\n\ntodlando adds `docs_dir` to the manifest (adapter-relative, must stay inside the adapter dir,\nsame refusal shape as `[strings]` pointers per `docs/MANIFEST.md` @342). Your cells:\n\n1. **Schema drift.** The existing gate @manifest.rs:2452 already trips; your addition is the\n   regenerated `manifest.schema.json` carrying `docs_dir` WITHOUT an internal code in its\n   description (the scan @452 reads that file). Cell: parse the checked-in schema, assert the\n   `docs_dir` property exists and `forbidden_codes_in(description)` is empty. Tag\n   `// [unit->REQ-WEB-ADAPTER-DOCS-FACET]`.\n2. **Escape refused at register.** `docs_dir = \"../x\"` and an absolute path fail manifest\n   validation with the one-line error (mirror the `[strings]` pointer refusal: the escape\n   test is `crates/spt-runtime/src/registry.rs` @190-196, its cell @~1394 — measured at\n   `eb38b71a`). Missing `docs_dir` is valid (no adapter ships docs today).\n3. **`--help` regen.** If the facet adds any CLI surface (todlando says; W3 plan is a URL\n   facet, likely no verb), `cli/reference.md` regen rides the existing gate — nothing for you\n   unless a verb appears.\n\nThe router/404-with-facet-name/int cells over `/<node>/a/<adapter>/…` are todlando's (they\nneed the W0 router); do not author them.\n\n## Sequencing + lane shape\n\n- Branch `test/ws272-w3-drift` under `.worktrees/ws272-w3-drift`, own pool, claim from inside.\n  Author cells 1/2/4 of Rider A and cells 1/2 of Rider B NOW against the signatures above;\n  they are RED until todlando's W3 branch exists. Do not open a PR red.\n- When `build/ws272-w3` carries `changelog_page` + `docs_dir`, rebase onto it, run your cells,\n  hand todlando the commit sha(s); they ride INSIDE the W3 PR (`Co-authored by: hertz` on your\n  commits; pick-audit at land keeps provenance). One gate run, not two.\n- treqs: W3 mints its REQ ids in todlando's lane (standing rule). Your tags reference ids\n  that do not exist on main yet — that is fine on your branch; `traceable-reqs check` runs\n  green only after the rebase. Do not mint them yourself (two minters, one toml = conflict).\n- IR-12 residual rides here too (JIT §Register riders) — bring the entry text with you and\n  say in the PR body which line discharges it.","startLine":1,"lineNumbers":[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95]},"fileSize":6521,"meta":{"source":{"type":"path","value":"C:/Users/decid/Documents/projects/spt-core/WEBSERVE-272-W3-DRIFT-RIDERS.md"}}},"isError":false,"timestamp":1788711241349}}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_ADT4wAE0jXkywaBgbxPG0Dn1|fc_07548ebf196ed5a1016a9d9150bb4487d1b21ae2253254b748","toolName":"read","startedAt":"2026-09-06T16:14:08.852Z","args":{"path":"skill://tdd"},"intent":"Reading test driven guidance"},"id":"6fe88862","parentId":"94046929","timestamp":"2026-09-06T16:14:08.852Z"}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_TI6POx645ZYLGF5eQXTLNb8b|fc_07548ebf196ed5a1016a9d9150bb5087d19c18f06a24be17d5","toolName":"grep","startedAt":"2026-09-06T16:14:08.853Z","args":{"path":"C:/Users/decid/Documents/projects/spt-core/.worktrees/ws272-w3-drift/crates/spt-runtime/src/registry.rs"},"intent":"Locating registration refusal tests"},"id":"e86eb257","parentId":"6fe88862","timestamp":"2026-09-06T16:14:08.853Z"}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_YN1MmJs95ZQYUzlkWXmzNc8y|fc_07548ebf196ed5a1016a9d9150bb5887d185897fa5bfda06b2","toolName":"grep","startedAt":"2026-09-06T16:14:08.853Z","args":{"path":"C:/Users/decid/Documents/projects/spt-core/.worktrees/ws272-w3-drift/crates/spt-runtime/src/manifest.rs"},"intent":"Locating manifest adapter structure"},"id":"598c4f3e","parentId":"e86eb257","timestamp":"2026-09-06T16:14:08.853Z"}
{"type":"message","id":"ddadbd42","parentId":"598c4f3e","timestamp":"2026-09-06T16:14:08.854Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"call_ADT4wAE0jXkywaBgbxPG0Dn1|fc_07548ebf196ed5a1016a9d9150bb4487d1b21ae2253254b748","name":"read","arguments":{"i":"Reading test driven guidance","path":"skill://tdd"},"intent":"Reading test driven guidance"},{"type":"toolCall","id":"call_TI6POx645ZYLGF5eQXTLNb8b|fc_07548ebf196ed5a1016a9d9150bb5087d19c18f06a24be17d5","name":"grep","arguments":{"i":"Locating registration refusal tests","pattern":"seed_source|strings|fn register|absolute|escape","path":"C:/Users/decid/Documents/projects/spt-core/.worktrees/ws272-w3-drift/crates/spt-runtime/src/registry.rs","case":false,"gitignore":true,"skip":0},"intent":"Locating registration refusal tests"},{"type":"toolCall","id":"call_YN1MmJs95ZQYUzlkWXmzNc8y|fc_07548ebf196ed5a1016a9d9150bb5887d185897fa5bfda06b2","name":"grep","arguments":{"i":"Locating manifest adapter structure","pattern":"struct Adapter|docs_dir|\\[adapter\\]|\\[strings\\]","path":"C:/Users/decid/Documents/projects/spt-core/.worktrees/ws272-w3-drift/crates/spt-runtime/src/manifest.rs","case":false,"gitignore":true,"skip":0},"intent":"Locating manifest adapter structure"}],"api":"openai-codex-responses","provider":"openai-codex","model":"gpt-6-astra","usage":{"input":2160,"output":202,"cacheRead":17280,"cacheWrite":0,"totalTokens":19642,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1788711241398,"responseId":"resp_07548ebf196ed5a1016a9d9149a2fc87d1994f0bcad42c0c64","providerPayload":{"type":"openaiResponsesHistory","provider":"openai-codex","dt":true,"items":[{"id":"fc_07548ebf196ed5a1016a9d9150bb4487d1b21ae2253254b748","type":"function_call","status":"completed","arguments":"{\"i\":\"Reading test driven guidance\",\"path\":\"skill://tdd\"}","call_id":"call_ADT4wAE0jXkywaBgbxPG0Dn1","internal_chat_message_metadata_passthrough":{"turn_id":"0b3b77b9-eace-409f-9a5d-491fd9ffe825"},"name":"read","metadata":{"turn_id":"0b3b77b9-eace-409f-9a5d-491fd9ffe825"}},{"id":"fc_07548ebf196ed5a1016a9d9150bb5087d19c18f06a24be17d5","type":"function_call","status":"completed","arguments":"{\"i\":\"Locating registration refusal tests\",\"pattern\":\"seed_source|strings|fn register|absolute|escape\",\"path\":\"C:/Users/decid/Documents/projects/spt-core/.worktrees/ws272-w3-drift/crates/spt-runtime/src/registry.rs\",\"case\":false,\"gitignore\":true,\"skip\":0}","call_id":"call_TI6POx645ZYLGF5eQXTLNb8b","internal_chat_message_metadata_passthrough":{"turn_id":"0b3b77b9-eace-409f-9a5d-491fd9ffe825"},"name":"grep","metadata":{"turn_id":"0b3b77b9-eace-409f-9a5d-491fd9ffe825"}},{"id":"fc_07548ebf196ed5a1016a9d9150bb5887d185897fa5bfda06b2","type":"function_call","status":"completed","arguments":"{\"i\":\"Locating manifest adapter structure\",\"pattern\":\"struct Adapter|docs_dir|\\\\[adapter\\\\]|\\\\[strings\\\\]\",\"path\":\"C:/Users/decid/Documents/projects/spt-core/.worktrees/ws272-w3-drift/crates/spt-runtime/src/manifest.rs\",\"case\":false,\"gitignore\":true,\"skip\":0}","call_id":"call_YN1MmJs95ZQYUzlkWXmzNc8y","internal_chat_message_metadata_passthrough":{"turn_id":"0b3b77b9-eace-409f-9a5d-491fd9ffe825"},"name":"grep","metadata":{"turn_id":"0b3b77b9-eace-409f-9a5d-491fd9ffe825"}}]},"duration":7450.95320000127,"ttft":7368.360300000757,"completedAt":1788711248850,"contextSnapshot":{"promptTokens":19440,"nonMessageTokens":16645,"compactionEpoch":0}}}
{"type":"message","id":"21164c67","parentId":"ddadbd42","timestamp":"2026-09-06T16:14:08.856Z","message":{"role":"toolResult","toolCallId":"call_ADT4wAE0jXkywaBgbxPG0Dn1|fc_07548ebf196ed5a1016a9d9150bb4487d1b21ae2253254b748","toolName":"read","content":[{"type":"text","text":"---\nname: tdd\ndescription: Test-driven development. Use when the user wants to build features or fix bugs test-first, mentions \"red-green-refactor\", or wants integration tests.\n---\n\n# Test-Driven Development\n\nTDD is the red → green loop. This skill is the reference that makes that loop produce tests worth keeping: what a good test is, where tests go, the anti-patterns, and the rules of the loop. Every section applies on every cycle — consult them before and during the loop, not after.\n\nWhen exploring the codebase, read `CONTEXT.md` (if it exists) so test names and interface vocabulary match the project's domain language, and respect ADRs in the area you're touching.\n\n## What a good test is\n\nTests verify behavior through public interfaces, not implementation details. Code can change entirely; tests shouldn't. A good test reads like a specification — \"user can checkout with valid cart\" tells you exactly what capability exists — and survives refactors because it doesn't care about internal structure.\n\nSee [tests.md](tests.md) for examples and [mocking.md](mocking.md) for mocking guidelines.\n\n## Seams — where tests go\n\nA **seam** is the public boundary you test at: the interface where you observe behavior without reaching inside. Tests live at seams, never against internals.\n\n**Test only at pre-agreed seams.** Before writing any test, write down the seams under test and confirm them with the user. No test is written at an unconfirmed seam. You can't test everything — agreeing the seams up front is how testing effort lands on the critical paths and complex logic instead of every edge case.\n\nAsk: \"What's the public interface, and which seams should we test?\"\n\nWhen the shape of that interface is itself in question — how deep the module is, where the seam belongs, what the interface should expose — use the `/codebase-design` skill for the vocabulary. It is the shared source of the module, interface, depth, seam, adapter, leverage and locality terms, and it is a reference to consult, not a session to run.\n\n## Anti-patterns\n\n- **Implementation-coupled** — mocks internal collaborators, tests private methods, or verifies through a side channel (querying the database instead of using the interface). The tell: the test breaks when you refactor but behavior hasn't changed.\n- **Tautological** — the assertion recomputes the expected value the way the code does (`expect(add(a, b)).toBe(a + b)`, a snapshot derived by hand the same way, a constant asserted equal to itself), so it passes by construction and can never disagree with the code. Expected values must come from an independent source of truth — a known-good literal, a worked example, the spec.\n- **Horizontal slicing** — writing all tests first, then all implementation. Bulk tests verify _imagined_ behavior: you test the _shape_ of things rather than user-facing behavior, the tests go insensitive to real changes, and you commit to test structure before understanding the implementation. Work in **vertical slices** instead — one test → one implementation → repeat, each test a **tracer bullet** that responds to what the last cycle taught you.\n\n## Rules of the loop\n\n- **Red before green.** Write the failing test first, then only enough code to pass it. Don't anticipate future tests or add speculative features.\n- **One slice at a time.** One seam, one test, one minimal implementation per cycle.\n- **Refactoring is not part of the loop.** It belongs to the review stage (see the `code-review` skill), not the red → green implementation cycle."}],"details":{"resolvedPath":"C:\\Users\\decid\\.agents\\skills\\tdd\\SKILL.md","contentType":"text/markdown","totalLines":38,"displayContent":{"text":"---\r\nname: tdd\r\ndescription: Test-driven development. Use when the user wants to build features or fix bugs test-first, mentions \"red-green-refactor\", or wants integration tests.\r\n---\r\n\r\n# Test-Driven Development\r\n\r\nTDD is the red → green loop. This skill is the reference that makes that loop produce tests worth keeping: what a good test is, where tests go, the anti-patterns, and the rules of the loop. Every section applies on every cycle — consult them before and during the loop, not after.\r\n\r\nWhen exploring the codebase, read `CONTEXT.md` (if it exists) so test names and interface vocabulary match the project's domain language, and respect ADRs in the area you're touching.\r\n\r\n## What a good test is\r\n\r\nTests verify behavior through public interfaces, not implementation details. Code can change entirely; tests shouldn't. A good test reads like a specification — \"user can checkout with valid cart\" tells you exactly what capability exists — and survives refactors because it doesn't care about internal structure.\r\n\r\nSee [tests.md](tests.md) for examples and [mocking.md](mocking.md) for mocking guidelines.\r\n\r\n## Seams — where tests go\r\n\r\nA **seam** is the public boundary you test at: the interface where you observe behavior without reaching inside. Tests live at seams, never against internals.\r\n\r\n**Test only at pre-agreed seams.** Before writing any test, write down the seams under test and confirm them with the user. No test is written at an unconfirmed seam. You can't test everything — agreeing the seams up front is how testing effort lands on the critical paths and complex logic instead of every edge case.\r\n\r\nAsk: \"What's the public interface, and which seams should we test?\"\r\n\r\nWhen the shape of that interface is itself in question — how deep the module is, where the seam belongs, what the interface should expose — use the `/codebase-design` skill for the vocabulary. It is the shared source of the module, interface, depth, seam, adapter, leverage and locality terms, and it is a reference to consult, not a session to run.\r\n\r\n## Anti-patterns\r\n\r\n- **Implementation-coupled** — mocks internal collaborators, tests private methods, or verifies through a side channel (querying the database instead of using the interface). The tell: the test breaks when you refactor but behavior hasn't changed.\r\n- **Tautological** — the assertion recomputes the expected value the way the code does (`expect(add(a, b)).toBe(a + b)`, a snapshot derived by hand the same way, a constant asserted equal to itself), so it passes by construction and can never disagree with the code. Expected values must come from an independent source of truth — a known-good literal, a worked example, the spec.\r\n- **Horizontal slicing** — writing all tests first, then all implementation. Bulk tests verify _imagined_ behavior: you test the _shape_ of things rather than user-facing behavior, the tests go insensitive to real changes, and you commit to test structure before understanding the implementation. Work in **vertical slices** instead — one test → one implementation → repeat, each test a **tracer bullet** that responds to what the last cycle taught you.\r\n\r\n## Rules of the loop\r\n\r\n- **Red before green.** Write the failing test first, then only enough code to pass it. Don't anticipate future tests or add speculative features.\r\n- **One slice at a time.** One seam, one test, one minimal implementation per cycle.\r\n- **Refactoring is not part of the loop.** It belongs to the review stage (see the `code-review` skill), not the red → green implementation cycle.\r","startLine":1,"lineNumbers":[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38]},"meta":{"source":{"type":"internal","value":"skill://tdd"}}},"isError":false,"timestamp":1788711248855}}
{"type":"message","id":"f061d903","parentId":"21164c67","timestamp":"2026-09-06T16:14:08.868Z","message":{"role":"toolResult","toolCallId":"call_YN1MmJs95ZQYUzlkWXmzNc8y|fc_07548ebf196ed5a1016a9d9150bb5887d185897fa5bfda06b2","toolName":"grep","content":[{"type":"text","text":"[.worktrees/ws272-w3-drift/crates/spt-runtime/src/manifest.rs#4DF8]\n 25:/// A fully-parsed runtime manifest. Top-level sections map 1:1 to the\n*26:/// `docs/MANIFEST.md` tables; all but `[adapter]` are optional so a minimal\n 27:/// adapter declares only its header.\n 28:#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]\n 29:pub struct Manifest {\n...\n 96:    pub profiles: BTreeMap<String, toml::Value>,\n*97:    /// `[strings]` — an adapter-authored KV tree of **opaque data** (spt-core\n 98:    /// never executes a string; command templates live in their own sections\n 99:    /// behind registration). Dot-path-readable via `spt adapter get-string`, and\n 100:    /// it rides the same leaf-replace profile overlay as the rest of the manifest\n...\n 205:\n*206:/// `[adapter]` — the manifest header, readable before any update (compat gate).\n 207:#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]\n*208:pub struct Adapter {\n 209:    pub name: String,\n 210:    #[serde(default)]\n 211:    pub kind: AdapterKind,\n...\n 735:    // doc'd-but-dead (never read by any production path), and a doc'd-but-dead knob is\n*736:    // a contract lie. The floor (`[adapter].min_spt_core_version`) is now enforced\n 737:    // UNCONDITIONALLY at both acquisition verbs (add + update) via `version_meets_floor`\n 738:    // — there is no opt-in flag. A pre-existing manifest that still sets\n 739:    // `version_check = ...` deserializes fine (serde ignores the unknown key; no\n...\n 1470:    const HARNESS_FIXTURE: &str = r#\"\n*1471:[adapter]\n 1472:name = \"claude-spt\"\n 1473:version = \"2.0.0\"\n 1474:min_spt_core_version = \"1.0.0\"\n...\n 1527:    const SHELL_FIXTURE: &str = r#\"\n*1528:[adapter]\n 1529:name = \"GameRobot\"\n 1530:kind = \"shell\"\n 1531:version = \"1.0.0\"\n...\n 1598:        // file_pull without signing_key → rejected.\n*1599:        let no_key = \"[adapter]\\nname=\\\"a\\\"\\nversion=\\\"1\\\"\\nmin_spt_core_version=\\\"1\\\"\\n\\n\\\n 1600:                      [update]\\navenue=\\\"file_pull\\\"\\nrepo=\\\"https://x/y\\\"\\n\";\n 1601:        let err = Manifest::from_toml_str(no_key).unwrap_err().to_string();\n 1602:        assert!(\n...\n 1607:        // file_pull with repo + signing_key → ok, fields preserved + round-trip.\n*1608:        let ok = \"[adapter]\\nname=\\\"a\\\"\\nversion=\\\"1\\\"\\nmin_spt_core_version=\\\"1\\\"\\n\\n\\\n 1609:                  [update]\\navenue=\\\"file_pull\\\"\\nrepo=\\\"https://x/y\\\"\\nsigning_key=\\\"deadbeef\\\"\\n\";\n 1610:        let m = Manifest::from_toml_str(ok).expect(\"file_pull with key parses\");\n 1611:        let u = m.update.clone().unwrap();\n...\n 1618:        // delegated with self_verifies attest → ok (no signing_key needed).\n*1619:        let deleg = \"[adapter]\\nname=\\\"a\\\"\\nversion=\\\"1\\\"\\nmin_spt_core_version=\\\"1\\\"\\n\\n\\\n 1620:                     [update]\\navenue=\\\"delegated\\\"\\ncommand=\\\"claude plugin update\\\"\\nself_verifies=true\\n\";\n 1621:        let md = Manifest::from_toml_str(deleg).expect(\"delegated parses\");\n 1622:        assert!(\n...\n 1634:        // gh_release with repo only → ok (asset defaults, signing_key optional).\n*1635:        let bare = \"[adapter]\\nname=\\\"a\\\"\\nversion=\\\"1\\\"\\nmin_spt_core_version=\\\"1\\\"\\n\\n\\\n 1636:                    [update]\\navenue=\\\"gh_release\\\"\\nrepo=\\\"user/repo\\\"\\n\";\n 1637:        let m = Manifest::from_toml_str(bare).expect(\"gh_release with repo parses\");\n 1638:        let u = m.update.clone().unwrap();\n...\n 1646:        // gh_release with an explicit asset + optional signing_key → preserved.\n*1647:        let full = \"[adapter]\\nname=\\\"a\\\"\\nversion=\\\"1\\\"\\nmin_spt_core_version=\\\"1\\\"\\n\\n\\\n 1648:                    [update]\\navenue=\\\"gh_release\\\"\\nrepo=\\\"user/repo\\\"\\n\\\n 1649:                    asset=\\\"my-adapter.spt\\\"\\nsigning_key=\\\"deadbeef\\\"\\n\";\n 1650:        let mf = Manifest::from_toml_str(full).expect(\"gh_release with asset+key parses\");\n...\n 1655:        // gh_release MISSING repo → rejected at validation.\n*1656:        let no_repo = \"[adapter]\\nname=\\\"a\\\"\\nversion=\\\"1\\\"\\nmin_spt_core_version=\\\"1\\\"\\n\\n\\\n 1657:                       [update]\\navenue=\\\"gh_release\\\"\\n\";\n 1658:        let err = Manifest::from_toml_str(no_repo).unwrap_err();\n 1659:        assert!(matches!(err, ManifestError::Validation(_)));\n...\n 1671:    fn update_post_sub_table() {\n*1672:        let base = \"[adapter]\\nname=\\\"a\\\"\\nversion=\\\"1\\\"\\nmin_spt_core_version=\\\"1\\\"\\n\\n\\\n 1673:                    [update]\\navenue=\\\"gh_release\\\"\\nrepo=\\\"u/r\\\"\\n\";\n 1674:\n 1675:        // declared → parses, fields preserved, round-trips.\n...\n 1710:    fn shell_tunnel_opt_in() {\n*1711:        let base = \"[adapter]\\nname=\\\"u\\\"\\nkind=\\\"shell\\\"\\nversion=\\\"1\\\"\\nmin_spt_core_version=\\\"1\\\"\\n\\n\\\n 1712:                    [shell]\\nspawn=\\\"usbip-shell --link {link_token}\\\"\\n\";\n 1713:\n 1714:        // enabled + labelled → parses, fields preserved, round-trips.\n...\n 1748:        let base =\n*1749:            \"[adapter]\\nname=\\\"cc\\\"\\nkind=\\\"harness\\\"\\nversion=\\\"1\\\"\\nmin_spt_core_version=\\\"1\\\"\\n\";\n 1750:\n 1751:        // declared + non-empty (deprecated) path → parses, preserved, round-trips.\n 1752:        let with = format!(\n...\n 1813:        let base =\n*1814:            \"[adapter]\\nname=\\\"gw\\\"\\nkind=\\\"shell\\\"\\nversion=\\\"1\\\"\\nmin_spt_core_version=\\\"1\\\"\\n\\\n 1815:             \\n[shell]\\nspawn = 'gw-shell'\\n\";\n 1816:\n 1817:        // start = \"boot\", grace unstated ⇒ the 30s default.\n...\n 1861:        let base =\n*1862:            \"[adapter]\\nname=\\\"gw\\\"\\nkind=\\\"shell\\\"\\nversion=\\\"1\\\"\\nmin_spt_core_version=\\\"1\\\"\\n\\\n 1863:             \\n[shell]\\nspawn = 'gw-shell'\\n\";\n 1864:\n 1865:        let empty = format!(\"{base}\\n[service]\\ncommand = ''\\nstart = \\\"boot\\\"\\n\");\n...\n 1904:        let base =\n*1905:            \"[adapter]\\nname=\\\"cc\\\"\\nkind=\\\"harness\\\"\\nversion=\\\"1\\\"\\nmin_spt_core_version=\\\"1\\\"\\n\";\n 1906:\n 1907:        // command-only → parses, path None, round-trips.\n 1908:        let with = format!(\n...\n 1953:        let base =\n*1954:            \"[adapter]\\nname=\\\"cc\\\"\\nkind=\\\"harness\\\"\\nversion=\\\"1\\\"\\nmin_spt_core_version=\\\"0\\\"\\n\";\n 1955:\n 1956:        // Declared: resume is parsed with its own command, distinct from self.\n 1957:        let with = format!(\n...\n 2050:        let bad = r#\"\n*2051:[adapter]\n 2052:name = \"x\"\n 2053:version = \"1.0.0\"\n 2054:min_spt_core_version = \"1.0.0\"\n...\n 2067:        let bad = r#\"\n*2068:[adapter]\n 2069:name = \"x\"\n 2070:kind = \"shell\"\n 2071:version = \"1.0.0\"\n...\n 2081:        let bad = r#\"\n*2082:[adapter]\n 2083:name = \"x\"\n 2084:version = \"1.0.0\"\n 2085:min_spt_core_version = \"1.0.0\"\n...\n 2100:        let src = r#\"\n*2101:[adapter]\n 2102:name = \"usbip\"\n 2103:kind = \"shell\"\n 2104:version = \"1.0.0\"\n...\n 2127:        let bad = r#\"\n*2128:[adapter]\n 2129:name = \"usbip\"\n 2130:kind = \"shell\"\n 2131:version = \"1.0.0\"\n...\n 2182:        let parent = Manifest::from_toml_str(\n*2183:            \"[adapter]\\nname = \\\"a\\\"\\nkind = \\\"harness\\\"\\nversion = \\\"1\\\"\\n\\\n 2184:             min_spt_core_version = \\\"0\\\"\\n\\n\\\n 2185:             [[hints]]\\nkeywords = [\\\"base\\\"]\\ntext = \\\"base hint\\\"\\n\\n\\\n 2186:             [profiles.p]\\n[[profiles.p.hints]]\\nkeywords = [\\\"prof\\\"]\\ntext = \\\"profile hint\\\"\\n\",\n...\n 2197:        let bad = r#\"\n*2198:[adapter]\n 2199:name = \"x\"\n 2200:version = \"1.0.0\"\n 2201:min_spt_core_version = \"1.0.0\"\n...\n 2212:        let bad = r#\"\n*2213:[adapter]\n 2214:name = \"x\"\n 2215:version = \"1.0.0\"\n 2216:min_spt_core_version = \"1.0.0\"\n...\n 2231:        let reuse = r#\"\n*2232:[adapter]\n 2233:name = \"x\"\n 2234:version = \"1.0.0\"\n 2235:min_spt_core_version = \"1.0.0\"\n...\n 2251:        let own = r#\"\n*2252:[adapter]\n 2253:name = \"x\"\n 2254:version = \"1.0.0\"\n 2255:min_spt_core_version = \"1.0.0\"\n...\n 2275:        let fetcher = r#\"\n*2276:[adapter]\n 2277:name = \"x\"\n 2278:version = \"1.0.0\"\n 2279:min_spt_core_version = \"1.0.0\"\n...\n 2295:        let legacy_no_source = r#\"\n*2296:[adapter]\n 2297:name = \"x\"\n 2298:version = \"1.0.0\"\n 2299:min_spt_core_version = \"1.0.0\"\n...\n 2313:        let empty = r#\"\n*2314:[adapter]\n 2315:name = \"x\"\n 2316:version = \"1.0.0\"\n 2317:min_spt_core_version = \"1.0.0\"\n...\n 2328:        let no_src = r#\"\n*2329:[adapter]\n 2330:name = \"x\"\n 2331:version = \"1.0.0\"\n 2332:min_spt_core_version = \"1.0.0\"\n...\n 2346:        let zero = r#\"\n*2347:[adapter]\n 2348:name = \"x\"\n 2349:version = \"1.0.0\"\n 2350:min_spt_core_version = \"1.0.0\"\n...\n 2363:        let m = Manifest::from_toml_str(\n*2364:            \"[adapter]\\nname = \\\"min\\\"\\nversion = \\\"1.0.0\\\"\\nmin_spt_core_version = \\\"1.0.0\\\"\\n\",\n 2365:        )\n 2366:        .expect(\"parse\");\n 2367:        assert_eq!(m.adapter.name, \"min\");\n...\n 2386:\n*2387:        let branded = HARNESS_FIXTURE.replace(\"[adapter]\", \"[adapter]\\nshortcut_basename = \\\"cc\\\"\");\n 2388:        let m2 = Manifest::from_toml_str(&branded).expect(\"parse branded\");\n 2389:        assert_eq!(m2.adapter.shortcut_basename.as_deref(), Some(\"cc\"));\n 2390:\n...\n 2395:\n*2396:    // [unit->REQ-MANIFEST-8] `[adapter] host_binaries` is the bind-time match-key:\n 2397:    // additive + N-1-safe (an old manifest without it parses to an empty list, and\n 2398:    // an empty list is omitted on serialization, like shortcut_basename); a\n 2399:    // declared list round-trips.\n...\n 2411:        let hosted = HARNESS_FIXTURE.replace(\n*2412:            \"[adapter]\",\n*2413:            \"[adapter]\\nhost_binaries = [\\\"claude\\\", \\\"claude-cli\\\"]\",\n 2414:        );\n 2415:        let m2 = Manifest::from_toml_str(&hosted).expect(\"parse hosted\");\n 2416:        assert_eq!(m2.adapter.host_binaries, vec![\"claude\", \"claude-cli\"]);\n...\n 2430:        assert_eq!(v[\"title\"], \"spt-core runtime manifest\");\n*2431:        // [adapter] is the only required top-level section.\n 2432:        let req: Vec<&str> = v[\"required\"]\n 2433:            .as_array()\n 2434:            .unwrap()\n...\n 2521:        Manifest::from_toml_str(&format!(\n*2522:            \"[adapter]\\nname = \\\"x\\\"\\nversion = \\\"1\\\"\\nmin_spt_core_version = \\\"0\\\"\\n\\n{session_toml}\"\n 2523:        ))\n 2524:        .expect(\"parses — validate() is key-agnostic\")\n 2525:    }\n...\n 2676:    fn each_role_resolves_its_own_declaration() {\n*2677:        let toml = \"[adapter]\n 2678:name=\\\"a\\\"\n 2679:version=\\\"1\\\"\n 2680:min_spt_core_version=\\\"0.1.0\\\"\n...\n 2715:        let toml = format!(\n*2716:            \"[adapter]\n 2717:name=\\\"a\\\"\n 2718:version=\\\"1\\\"\n 2719:min_spt_core_version=\\\"0\\\""}],"details":{"scopePath":".worktrees/ws272-w3-drift/crates/spt-runtime/src/manifest.rs","searchPath":"C:/Users/decid/Documents/projects/spt-core/.worktrees/ws272-w3-drift/crates/spt-runtime/src/manifest.rs","cwd":"C:\\Users\\decid\\Documents\\projects\\spt-core","matchCount":44,"fileCount":1,"files":[".worktrees/ws272-w3-drift/crates/spt-runtime/src/manifest.rs"],"fileMatches":[{"path":".worktrees/ws272-w3-drift/crates/spt-runtime/src/manifest.rs","count":44}],"truncated":false,"displayContent":"   25│/// A fully-parsed runtime manifest. Top-level sections map 1:1 to the\n  *26│/// `docs/MANIFEST.md` tables; all but `[adapter]` are optional so a minimal\n   27│/// adapter declares only its header.\n   28│#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]\n   29│pub struct Manifest {\n     │...\n   96│    pub profiles: BTreeMap<String, toml::Value>,\n  *97│    /// `[strings]` — an adapter-authored KV tree of **opaque data** (spt-core\n   98│    /// never executes a string; command templates live in their own sections\n   99│    /// behind registration). Dot-path-readable via `spt adapter get-string`, and\n  100│    /// it rides the same leaf-replace profile overlay as the rest of the manifest\n     │...\n  205│\n *206│/// `[adapter]` — the manifest header, readable before any update (compat gate).\n  207│#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]\n *208│pub struct Adapter {\n  209│    pub name: String,\n  210│    #[serde(default)]\n  211│    pub kind: AdapterKind,\n     │...\n  735│    // doc'd-but-dead (never read by any production path), and a doc'd-but-dead knob is\n *736│    // a contract lie. The floor (`[adapter].min_spt_core_version`) is now enforced\n  737│    // UNCONDITIONALLY at both acquisition verbs (add + update) via `version_meets_floor`\n  738│    // — there is no opt-in flag. A pre-existing manifest that still sets\n  739│    // `version_check = ...` deserializes fine (serde ignores the unknown key; no\n     │...\n 1470│    const HARNESS_FIXTURE: &str = r#\"\n*1471│[adapter]\n 1472│name = \"claude-spt\"\n 1473│version = \"2.0.0\"\n 1474│min_spt_core_version = \"1.0.0\"\n     │...\n 1527│    const SHELL_FIXTURE: &str = r#\"\n*1528│[adapter]\n 1529│name = \"GameRobot\"\n 1530│kind = \"shell\"\n 1531│version = \"1.0.0\"\n     │...\n 1598│        // file_pull without signing_key → rejected.\n*1599│        let no_key = \"[adapter]\\nname=\\\"a\\\"\\nversion=\\\"1\\\"\\nmin_spt_core_version=\\\"1\\\"\\n\\n\\\n 1600│                      [update]\\navenue=\\\"file_pull\\\"\\nrepo=\\\"https://x/y\\\"\\n\";\n 1601│        let err = Manifest::from_toml_str(no_key).unwrap_err().to_string();\n 1602│        assert!(\n     │...\n 1607│        // file_pull with repo + signing_key → ok, fields preserved + round-trip.\n*1608│        let ok = \"[adapter]\\nname=\\\"a\\\"\\nversion=\\\"1\\\"\\nmin_spt_core_version=\\\"1\\\"\\n\\n\\\n 1609│                  [update]\\navenue=\\\"file_pull\\\"\\nrepo=\\\"https://x/y\\\"\\nsigning_key=\\\"deadbeef\\\"\\n\";\n 1610│        let m = Manifest::from_toml_str(ok).expect(\"file_pull with key parses\");\n 1611│        let u = m.update.clone().unwrap();\n     │...\n 1618│        // delegated with self_verifies attest → ok (no signing_key needed).\n*1619│        let deleg = \"[adapter]\\nname=\\\"a\\\"\\nversion=\\\"1\\\"\\nmin_spt_core_version=\\\"1\\\"\\n\\n\\\n 1620│                     [update]\\navenue=\\\"delegated\\\"\\ncommand=\\\"claude plugin update\\\"\\nself_verifies=true\\n\";\n 1621│        let md = Manifest::from_toml_str(deleg).expect(\"delegated parses\");\n 1622│        assert!(\n     │...\n 1634│        // gh_release with repo only → ok (asset defaults, signing_key optional).\n*1635│        let bare = \"[adapter]\\nname=\\\"a\\\"\\nversion=\\\"1\\\"\\nmin_spt_core_version=\\\"1\\\"\\n\\n\\\n 1636│                    [update]\\navenue=\\\"gh_release\\\"\\nrepo=\\\"user/repo\\\"\\n\";\n 1637│        let m = Manifest::from_toml_str(bare).expect(\"gh_release with repo parses\");\n 1638│        let u = m.update.clone().unwrap();\n     │...\n 1646│        // gh_release with an explicit asset + optional signing_key → preserved.\n*1647│        let full = \"[adapter]\\nname=\\\"a\\\"\\nversion=\\\"1\\\"\\nmin_spt_core_version=\\\"1\\\"\\n\\n\\\n 1648│                    [update]\\navenue=\\\"gh_release\\\"\\nrepo=\\\"user/repo\\\"\\n\\\n 1649│                    asset=\\\"my-adapter.spt\\\"\\nsigning_key=\\\"deadbeef\\\"\\n\";\n 1650│        let mf = Manifest::from_toml_str(full).expect(\"gh_release with asset+key parses\");\n     │...\n 1655│        // gh_release MISSING repo → rejected at validation.\n*1656│        let no_repo = \"[adapter]\\nname=\\\"a\\\"\\nversion=\\\"1\\\"\\nmin_spt_core_version=\\\"1\\\"\\n\\n\\\n 1657│                       [update]\\navenue=\\\"gh_release\\\"\\n\";\n 1658│        let err = Manifest::from_toml_str(no_repo).unwrap_err();\n 1659│        assert!(matches!(err, ManifestError::Validation(_)));\n     │...\n 1671│    fn update_post_sub_table() {\n*1672│        let base = \"[adapter]\\nname=\\\"a\\\"\\nversion=\\\"1\\\"\\nmin_spt_core_version=\\\"1\\\"\\n\\n\\\n 1673│                    [update]\\navenue=\\\"gh_release\\\"\\nrepo=\\\"u/r\\\"\\n\";\n 1674│\n 1675│        // declared → parses, fields preserved, round-trips.\n     │...\n 1710│    fn shell_tunnel_opt_in() {\n*1711│        let base = \"[adapter]\\nname=\\\"u\\\"\\nkind=\\\"shell\\\"\\nversion=\\\"1\\\"\\nmin_spt_core_version=\\\"1\\\"\\n\\n\\\n 1712│                    [shell]\\nspawn=\\\"usbip-shell --link {link_token}\\\"\\n\";\n 1713│\n 1714│        // enabled + labelled → parses, fields preserved, round-trips.\n     │...\n 1748│        let base =\n*1749│            \"[adapter]\\nname=\\\"cc\\\"\\nkind=\\\"harness\\\"\\nversion=\\\"1\\\"\\nmin_spt_core_version=\\\"1\\\"\\n\";\n 1750│\n 1751│        // declared + non-empty (deprecated) path → parses, preserved, round-trips.\n 1752│        let with = format!(\n     │...\n 1813│        let base =\n*1814│            \"[adapter]\\nname=\\\"gw\\\"\\nkind=\\\"shell\\\"\\nversion=\\\"1\\\"\\nmin_spt_core_version=\\\"1\\\"\\n\\\n 1815│             \\n[shell]\\nspawn = 'gw-shell'\\n\";\n 1816│\n 1817│        // start = \"boot\", grace unstated ⇒ the 30s default.\n     │...\n 1861│        let base =\n*1862│            \"[adapter]\\nname=\\\"gw\\\"\\nkind=\\\"shell\\\"\\nversion=\\\"1\\\"\\nmin_spt_core_version=\\\"1\\\"\\n\\\n 1863│             \\n[shell]\\nspawn = 'gw-shell'\\n\";\n 1864│\n 1865│        let empty = format!(\"{base}\\n[service]\\ncommand = ''\\nstart = \\\"boot\\\"\\n\");\n     │...\n 1904│        let base =\n*1905│            \"[adapter]\\nname=\\\"cc\\\"\\nkind=\\\"harness\\\"\\nversion=\\\"1\\\"\\nmin_spt_core_version=\\\"1\\\"\\n\";\n 1906│\n 1907│        // command-only → parses, path None, round-trips.\n 1908│        let with = format!(\n     │...\n 1953│        let base =\n*1954│            \"[adapter]\\nname=\\\"cc\\\"\\nkind=\\\"harness\\\"\\nversion=\\\"1\\\"\\nmin_spt_core_version=\\\"0\\\"\\n\";\n 1955│\n 1956│        // Declared: resume is parsed with its own command, distinct from self.\n 1957│        let with = format!(\n     │...\n 2050│        let bad = r#\"\n*2051│[adapter]\n 2052│name = \"x\"\n 2053│version = \"1.0.0\"\n 2054│min_spt_core_version = \"1.0.0\"\n     │...\n 2067│        let bad = r#\"\n*2068│[adapter]\n 2069│name = \"x\"\n 2070│kind = \"shell\"\n 2071│version = \"1.0.0\"\n     │...\n 2081│        let bad = r#\"\n*2082│[adapter]\n 2083│name = \"x\"\n 2084│version = \"1.0.0\"\n 2085│min_spt_core_version = \"1.0.0\"\n     │...\n 2100│        let src = r#\"\n*2101│[adapter]\n 2102│name = \"usbip\"\n 2103│kind = \"shell\"\n 2104│version = \"1.0.0\"\n     │...\n 2127│        let bad = r#\"\n*2128│[adapter]\n 2129│name = \"usbip\"\n 2130│kind = \"shell\"\n 2131│version = \"1.0.0\"\n     │...\n 2182│        let parent = Manifest::from_toml_str(\n*2183│            \"[adapter]\\nname = \\\"a\\\"\\nkind = \\\"harness\\\"\\nversion = \\\"1\\\"\\n\\\n 2184│             min_spt_core_version = \\\"0\\\"\\n\\n\\\n 2185│             [[hints]]\\nkeywords = [\\\"base\\\"]\\ntext = \\\"base hint\\\"\\n\\n\\\n 2186│             [profiles.p]\\n[[profiles.p.hints]]\\nkeywords = [\\\"prof\\\"]\\ntext = \\\"profile hint\\\"\\n\",\n     │...\n 2197│        let bad = r#\"\n*2198│[adapter]\n 2199│name = \"x\"\n 2200│version = \"1.0.0\"\n 2201│min_spt_core_version = \"1.0.0\"\n     │...\n 2212│        let bad = r#\"\n*2213│[adapter]\n 2214│name = \"x\"\n 2215│version = \"1.0.0\"\n 2216│min_spt_core_version = \"1.0.0\"\n     │...\n 2231│        let reuse = r#\"\n*2232│[adapter]\n 2233│name = \"x\"\n 2234│version = \"1.0.0\"\n 2235│min_spt_core_version = \"1.0.0\"\n     │...\n 2251│        let own = r#\"\n*2252│[adapter]\n 2253│name = \"x\"\n 2254│version = \"1.0.0\"\n 2255│min_spt_core_version = \"1.0.0\"\n     │...\n 2275│        let fetcher = r#\"\n*2276│[adapter]\n 2277│name = \"x\"\n 2278│version = \"1.0.0\"\n 2279│min_spt_core_version = \"1.0.0\"\n     │...\n 2295│        let legacy_no_source = r#\"\n*2296│[adapter]\n 2297│name = \"x\"\n 2298│version = \"1.0.0\"\n 2299│min_spt_core_version = \"1.0.0\"\n     │...\n 2313│        let empty = r#\"\n*2314│[adapter]\n 2315│name = \"x\"\n 2316│version = \"1.0.0\"\n 2317│min_spt_core_version = \"1.0.0\"\n     │...\n 2328│        let no_src = r#\"\n*2329│[adapter]\n 2330│name = \"x\"\n 2331│version = \"1.0.0\"\n 2332│min_spt_core_version = \"1.0.0\"\n     │...\n 2346│        let zero = r#\"\n*2347│[adapter]\n 2348│name = \"x\"\n 2349│version = \"1.0.0\"\n 2350│min_spt_core_version = \"1.0.0\"\n     │...\n 2363│        let m = Manifest::from_toml_str(\n*2364│            \"[adapter]\\nname = \\\"min\\\"\\nversion = \\\"1.0.0\\\"\\nmin_spt_core_version = \\\"1.0.0\\\"\\n\",\n 2365│        )\n 2366│        .expect(\"parse\");\n 2367│        assert_eq!(m.adapter.name, \"min\");\n     │...\n 2386│\n*2387│        let branded = HARNESS_FIXTURE.replace(\"[adapter]\", \"[adapter]\\nshortcut_basename = \\\"cc\\\"\");\n 2388│        let m2 = Manifest::from_toml_str(&branded).expect(\"parse branded\");\n 2389│        assert_eq!(m2.adapter.shortcut_basename.as_deref(), Some(\"cc\"));\n 2390│\n     │...\n 2395│\n*2396│    // [unit->REQ-MANIFEST-8] `[adapter] host_binaries` is the bind-time match-key:\n 2397│    // additive + N-1-safe (an old manifest without it parses to an empty list, and\n 2398│    // an empty list is omitted on serialization, like shortcut_basename); a\n 2399│    // declared list round-trips.\n     │...\n 2411│        let hosted = HARNESS_FIXTURE.replace(\n*2412│            \"[adapter]\",\n*2413│            \"[adapter]\\nhost_binaries = [\\\"claude\\\", \\\"claude-cli\\\"]\",\n 2414│        );\n 2415│        let m2 = Manifest::from_toml_str(&hosted).expect(\"parse hosted\");\n 2416│        assert_eq!(m2.adapter.host_binaries, vec![\"claude\", \"claude-cli\"]);\n     │...\n 2430│        assert_eq!(v[\"title\"], \"spt-core runtime manifest\");\n*2431│        // [adapter] is the only required top-level section.\n 2432│        let req: Vec<&str> = v[\"required\"]\n 2433│            .as_array()\n 2434│            .unwrap()\n     │...\n 2521│        Manifest::from_toml_str(&format!(\n*2522│            \"[adapter]\\nname = \\\"x\\\"\\nversion = \\\"1\\\"\\nmin_spt_core_version = \\\"0\\\"\\n\\n{session_toml}\"\n 2523│        ))\n 2524│        .expect(\"parses — validate() is key-agnostic\")\n 2525│    }\n     │...\n 2676│    fn each_role_resolves_its_own_declaration() {\n*2677│        let toml = \"[adapter]\n 2678│name=\\\"a\\\"\n 2679│version=\\\"1\\\"\n 2680│min_spt_core_version=\\\"0.1.0\\\"\n     │...\n 2715│        let toml = format!(\n*2716│            \"[adapter]\n 2717│name=\\\"a\\\"\n 2718│version=\\\"1\\\"\n 2719│min_spt_core_version=\\\"0\\\""},"isError":false,"timestamp":1788711248868}}
{"type":"message","id":"63dcb403","parentId":"f061d903","timestamp":"2026-09-06T16:14:08.874Z","message":{"role":"toolResult","toolCallId":"call_TI6POx645ZYLGF5eQXTLNb8b|fc_07548ebf196ed5a1016a9d9150bb5087d19c18f06a24be17d5","toolName":"grep","content":[{"type":"text","text":"[.worktrees/ws272-w3-drift/crates/spt-runtime/src/registry.rs#5C21]\n 84:    ShippedProfileImmutable(String),\n*85:    /// A `[strings]` file pointer (REQ-MANIFEST-5) is invalid at registration —\n*86:    /// it escapes the adapter's `strings/` dir (`..`/absolute — the HAZARD-class\n 87:    /// containment guard) or names a file that does not exist. Fail-fast: a bad\n 88:    /// pointer records nothing (the validate-at-register half of the contract).\n 89:    BadStringPointer(String),\n...\n 121:            RegistryError::BadStringPointer(m) => {\n*122:                write!(f, \"invalid [strings] file pointer: {m}\")\n 123:            }\n 124:            RegistryError::DeferredManifest { adapter, dir } => write!(\n 125:                f,\n...\n 163:\n*164:// ───────────────────── File-backed [strings] (REQ-MANIFEST-5) ────────────────\n 165:\n 166:/// The directory that holds the adapter's LIVE manifest — and, as a sibling,\n*167:/// its `strings/` aux dir. Copy mode: the held copy under `adapters/<name>/`;\n 168:/// pointer mode: the live source tree (self-updates honored, same as the\n 169:/// manifest itself).\n 170:fn manifest_dir(adapters_dir: &Path, record: &AdapterRecord) -> PathBuf {\n...\n 176:\n*177:/// The adapter-shipped string-file storage `<manifest-dir>/strings/`\n 178:/// (REQ-MANIFEST-5). Adapter updates overwrite this dir — never put user files\n 179:/// here (a local profile's pointers resolve to its own user-owned dir instead).\n*180:fn shipped_strings_dir(adapters_dir: &Path, record: &AdapterRecord) -> PathBuf {\n*181:    manifest_dir(adapters_dir, record).join(\"strings\")\n 182:}\n 183:\n*184:/// Validate a `[strings]` file-pointer relative path against the HAZARD-class\n 185:/// containment guard (doyle, REQ-MANIFEST-5): it must be a plain relative path\n*186:/// that cannot escape its `strings/` dir — reject absolute paths and any `..`\n*187:/// (or prefix/root) component. Returns the joined absolute path on success.\n 188:fn contained_string_file(base: &Path, rel: &str) -> Result<PathBuf, String> {\n 189:    let relp = Path::new(rel);\n*190:    let escapes = relp.components().any(|c| {\n 191:        matches!(\n 192:            c,\n 193:            Component::ParentDir | Component::RootDir | Component::Prefix(_)\n...\n 195:    });\n*196:    if relp.as_os_str().is_empty() || relp.is_absolute() || escapes {\n 197:        return Err(format!(\n*198:            \"pointer '{rel}' must be a relative path inside the strings/ dir \\\n*199:             (no absolute paths, no `..` traversal)\"\n 200:        ));\n 201:    }\n 202:    Ok(base.join(relp))\n...\n 215:        Err(why) => {\n*216:            spt_proto::emit_line_err!(\"STRINGS_FILE_SKIP: {why}\");\n 217:            return None;\n 218:        }\n 219:    };\n...\n 223:            spt_proto::emit_line_err!(\n*224:                \"STRINGS_FILE_SKIP: cannot read string file {} ({e})\",\n 225:                full.display()\n 226:            );\n 227:            None\n...\n 231:\n*232:/// Walk every file pointer in a `[strings]` tree (recursing nested tables,\n 233:/// treating a single-key `{file=…}` node as a leaf), invoking `f` with each\n 234:/// relative path. The register-time validation traversal.\n*235:fn for_each_string_pointer(strings: &toml::Table, f: &mut impl FnMut(&str)) {\n*236:    for value in strings.values() {\n 237:        if let Some(rel) = profile::as_file_pointer(value) {\n 238:            f(rel);\n 239:        } else if let Some(nested) = value.as_table() {\n...\n 244:\n*245:/// Validate (at register, fail-fast) that every file pointer in `strings` is\n*246:/// contained AND exists under `strings_dir`. A bad pointer refuses the whole\n 247:/// registration (records nothing) — the loud half of the contract.\n 248:fn validate_string_pointers(\n*249:    strings: &toml::Table,\n*250:    strings_dir: &Path,\n 251:) -> Result<(), RegistryError> {\n 252:    let mut bad: Option<String> = None;\n*253:    for_each_string_pointer(strings, &mut |rel| {\n 254:        if bad.is_some() {\n 255:            return;\n 256:        }\n*257:        match contained_string_file(strings_dir, rel) {\n 258:            Err(why) => bad = Some(why),\n 259:            Ok(full) if !full.exists() => {\n 260:                bad = Some(format!(\n...\n 274:/// Recursively copy `src` into `dst` (used to bring an adapter's shipped\n*275:/// `strings/` dir into the held copy at register, so file pointers resolve\n 276:/// post-copy and survive the source going away — Copy mode only).\n 277:fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {\n 278:    std::fs::create_dir_all(dst)?;\n...\n 299:// [impl->REQ-INSTALL-4]\n*300:pub fn register(\n 301:    adapters_dir: &Path,\n 302:    source: &Path,\n 303:    now_ms: u64,\n...\n 312:// [impl->REQ-ADAPTER-FLOOR-ENFORCE]\n*313:pub fn register_with_core(\n 314:    adapters_dir: &Path,\n 315:    source: &Path,\n 316:    now_ms: u64,\n...\n 361:\n*362:    // Validate-first (REQ-MANIFEST-5): every `[strings]` file pointer — in the\n 363:    // base manifest and in each shipped profile's overlay — must be contained\n*364:    // and exist under the SOURCE `strings/` dir. A bad/escaping/missing pointer\n 365:    // refuses the whole registration (records nothing) — the loud half of the\n 366:    // contract; read-time stays skip-diagnostic.\n*367:    let src_strings_dir = Path::new(&source_dir).join(\"strings\");\n*368:    validate_string_pointers(&manifest.strings, &src_strings_dir)?;\n 369:    for prof in manifest.profiles.values() {\n*370:        if let Some(strings) = prof.get(\"strings\").and_then(|v| v.as_table()) {\n*371:            validate_string_pointers(strings, &src_strings_dir)?;\n 372:        }\n 373:    }\n 374:\n...\n 398:        atomic_write_string(&manifest_copy_file(adapters_dir, &record.name), &toml_src)?;\n*399:        // Bring the shipped strings/ dir into the held copy so pointers resolve\n 400:        // post-copy and survive the source going away (adapter updates overwrite\n*401:        // this — REQ-MANIFEST-5). Pointer mode reads strings/ live from source.\n*402:        if src_strings_dir.is_dir() {\n*403:            copy_dir_all(&src_strings_dir, &shipped_strings_dir(adapters_dir, &record))\n 404:                .map_err(RegistryError::Io)?;\n 405:        }\n 406:    }\n...\n 495:// [impl->REQ-INSTALL-4]\n*496:pub fn registered(adapters_dir: &Path) -> Vec<(AdapterRecord, Manifest)> {\n 497:    all_records(adapters_dir)\n 498:        .into_iter()\n 499:        .filter(|r| r.active)\n...\n 505:            // and WHY on stderr so it never vanishes without a trace (mirrors the\n*506:            // [digest]/[strings] skip stance).\n 507:            // [impl->REQ-HAZARD-DEFERRED-MANIFEST]\n 508:            Err(e) => {\n 509:                spt_proto::emit_line_err!(\"ADAPTER_SKIP:{} — {e}\", r.name);\n...\n 632:\n*633:/// Read a `[strings]` dot-path from an adapter **option**'s merged view — the\n 634:/// `spt adapter get-string <adapter-option> <key.path>` resolver. Resolves the\n 635:/// option (bare = parent, else shipped/local overlay) exactly as every other\n*636:/// consumer, then dot-path-traverses the merged `[strings]` tree. `Ok(None)` =\n 637:/// the key is absent (a clean \"not set\", distinct from an unresolvable option).\n 638:// [impl->REQ-MANIFEST-3]\n 639:pub fn get_string(\n...\n 644:    let (record, manifest) = resolve_option(adapters_dir, option)?;\n*645:    // The node-static `{node}` fill for lazy [strings] (REQ-MANIFEST-NODE-KEY):\n 646:    // get-string is a CLI read with no daemon in-mem label, so it resolves to the\n 647:    // OS hostname (the same self-label source the CLI uses everywhere) via the\n 648:    // shared resolver — `None` only if the OS won't say, leaving `{node}` intact.\n 649:    let node = spt_store::hostlabel::node_fill_label(None);\n*650:    let Some(value) = profile::string_at(&manifest.strings, key_path) else {\n 651:        return Ok(None);\n 652:    };\n 653:    // A literal value reads as-is. A single-key `{file=…}` pointer resolves to\n...\n 660:    // pointer by a LOCAL profile, resolve against its user-owned dir (survives\n*661:    // adapter updates); otherwise against the adapter-shipped `strings/` dir.\n 662:    let (adapter, profile_seg) = profile::split_option(option);\n 663:    let base = match profile_seg {\n 664:        Some(p) if local_profile_supplies_pointer(adapters_dir, adapter, p, key_path) => {\n...\n 666:        }\n*667:        _ => shipped_strings_dir(adapters_dir, &record),\n 668:    };\n 669:    // [impl->REQ-MANIFEST-SUBST] file-backed contents get the same lazy subst.\n 670:    Ok(read_string_file(&base, rel).map(|v| subst_string_value(v, &record, node.as_deref())))\n...\n 674:/// `source_dir`; `{adapter_name}` = the record name) AND the node-static `{node}`\n*675:/// (REQ-MANIFEST-NODE-KEY) into every string leaf of a resolved `[strings]` value at\n 676:/// `get-string` read time (ADR-0029). Recurses tables/arrays; non-string scalars\n 677:/// pass through. Session-scoped keys are NOT available (get-string carries no\n 678:/// session) — they remain intact for the adapter's own wrapper to fill. Parity with\n...\n 703:/// as a file pointer — the provenance test that routes a pointer to the\n*704:/// user-owned profiles dir instead of the adapter-shipped `strings/` dir\n 705:/// (REQ-MANIFEST-5 update-safety). A shipped profile (no local file) is `false`.\n 706:fn local_profile_supplies_pointer(\n 707:    adapters_dir: &Path,\n...\n 716:        .as_table()\n*717:        .and_then(|t| t.get(\"strings\"))\n 718:        .and_then(|s| s.as_table())\n 719:        .and_then(|st| profile::string_at(st, key_path))\n 720:        .map(|v| profile::as_file_pointer(v).is_some())\n...\n 723:\n*724:/// Set a `[strings]` dot-path on a **local** profile's overlay — the\n 725:/// `spt adapter set-string` sugar. Edits only the node-local overlay file\n 726:/// (`profiles/<profile>.toml`); a shipped profile name is refused (adapter-owned,\n 727:/// immutable) and a missing local profile errors (`create-profile` first). The\n...\n 747:        .ok_or_else(|| RegistryError::BadRecord(format!(\"profiles/{profile}\")))?;\n*748:    let strings = overlay_table\n*749:        .entry(\"strings\".to_string())\n 750:        .or_insert_with(|| Value::Table(toml::Table::new()));\n*751:    let strings_table = strings\n 752:        .as_table_mut()\n*753:        .ok_or_else(|| RegistryError::BadRecord(format!(\"profiles/{profile}: [strings] not a table\")))?;\n*754:    profile::set_string_at(strings_table, key_path, Value::String(value.to_string()))\n 755:        .map_err(RegistryError::Manifest)?;\n 756:    let overlay_toml = toml::to_string_pretty(&overlay)\n 757:        .map_err(|e| RegistryError::Manifest(crate::manifest::ManifestError::Validation(e.to_string())))?;\n...\n 897:\n*898:    fn seed_source(dir: &Path, name: &str, toml_src: &str) -> PathBuf {\n 899:        let src = dir.join(name);\n 900:        std::fs::create_dir_all(&src).unwrap();\n 901:        std::fs::write(src.join(\"manifest.toml\"), toml_src).unwrap();\n...\n 911:    #[test]\n*912:    fn register_refuses_unfillable_role_key_and_writes_nothing() {\n 913:        let tmp = tempfile::tempdir().unwrap();\n 914:        let adapters = tmp.path().join(\"adapters\");\n 915:        let bad = \"[adapter]\\nname = \\\"badkey\\\"\\nversion = \\\"1\\\"\\nmin_spt_core_version = \\\"0\\\"\\n\\n\\\n 916:                   [session.psyche_resume]\\ncommand = 'run {id}'\\ncwd = \\\"{psyche_dir}\\\"\\n\";\n*917:        let src = seed_source(tmp.path(), \"badkey-src\", bad);\n 918:\n 919:        let err = register(&adapters, &src, 1000).expect_err(\"an unfillable role key must refuse\");\n 920:        match err {\n...\n 938:    #[test]\n*939:    fn register_copy_vs_pointer_by_avenue() {\n 940:        let tmp = tempfile::tempdir().unwrap();\n 941:        let adapters = tmp.path().join(\"adapters\");\n*942:        let harness_src = seed_source(tmp.path(), \"h-src\", HARNESS_COPY);\n*943:        let shell_src = seed_source(tmp.path(), \"s-src\", SHELL_DELEGATED);\n 944:\n 945:        let (h, hm) = register(&adapters, &harness_src, 1000).unwrap();\n 946:        assert_eq!((h.mode, h.kind), (RecordMode::Copy, AdapterKind::Harness));\n...\n 1003:    #[test]\n*1004:    fn register_with_core_refuses_below_floor_and_writes_nothing() {\n 1005:        let tmp = tempfile::tempdir().unwrap();\n 1006:        let adapters = tmp.path().join(\"adapters\");\n*1007:        let src = seed_source(tmp.path(), \"hf-src\", HARNESS_HIGH_FLOOR);\n 1008:\n 1009:        // A real-shaped core (0.25.0) is far below the 9.9.9 floor ⇒ refuse.\n 1010:        let err = register_with_core(&adapters, &src, 1000, \"0.25.0\")\n...\n 1037:    #[test]\n*1038:    fn register_with_core_admits_when_core_meets_floor() {\n 1039:        let tmp = tempfile::tempdir().unwrap();\n 1040:        let adapters = tmp.path().join(\"adapters\");\n*1041:        let src = seed_source(tmp.path(), \"ef-src\", HARNESS_EQUAL_FLOOR);\n 1042:\n 1043:        let (record, manifest) = register_with_core(&adapters, &src, 2000, \"0.25.0\")\n 1044:            .expect(\"a core equal to the floor must register\");\n...\n 1064:        // kind = \"shell\" without a [shell] body fails validation.\n*1065:        let bad = seed_source(\n 1066:            tmp.path(),\n 1067:            \"bad-src\",\n 1068:            \"[adapter]\\nname = \\\"bad\\\"\\nkind = \\\"shell\\\"\\nversion = \\\"1\\\"\\nmin_spt_core_version = \\\"0\\\"\\n\",\n...\n 1085:        let adapters = tmp.path().join(\"adapters\");\n*1086:        let shell_src = seed_source(tmp.path(), \"s-src\", SHELL_DELEGATED);\n 1087:        register(&adapters, &shell_src, 1000).unwrap();\n 1088:\n 1089:        let uninstall = deregister(&adapters, \"mock-shell\").unwrap();\n...\n 1127:        let adapters = tmp.path().join(\"adapters\");\n*1128:        let shell_src = seed_source(tmp.path(), \"deferred-src\", SHELL_DELEGATED);\n 1129:        let (rec, _) = register(&adapters, &shell_src, 1000).unwrap();\n 1130:        assert_eq!(rec.mode, RecordMode::Pointer);\n 1131:\n...\n 1155:    /// Register the profiled shell adapter into a fresh `adapters/` dir.\n*1156:    fn registered_shell(tmp: &Path) -> PathBuf {\n 1157:        let adapters = tmp.join(\"adapters\");\n*1158:        let src = seed_source(tmp, \"ms-src\", SHELL_COPY_PROFILED);\n 1159:        register(&adapters, &src, 1000).unwrap();\n 1160:        adapters\n 1161:    }\n...\n 1226:        // Re-register the adapter (adapter update / re-add).\n*1227:        let src = seed_source(tmp.path(), \"ms-src2\", SHELL_COPY_PROFILED);\n 1228:        register(&adapters, &src, 2000).unwrap();\n 1229:\n 1230:        assert!(\n...\n 1291:    // [unit->REQ-MANIFEST-3] get-string resolves through the overlay (a local\n*1292:    // profile's [strings] override the base; the merged view is what reads), and\n 1293:    // set-string edits a LOCAL profile only — a shipped name + a bare option are\n 1294:    // refused, a missing key reads None.\n 1295:    #[test]\n*1296:    fn strings_get_set_through_overlay() {\n 1297:        let tmp = tempfile::tempdir().unwrap();\n*1298:        // A shell adapter carrying base strings + a shipped profile.\n 1299:        let src_toml = \"[adapter]\\nname = \\\"mock-shell\\\"\\nkind = \\\"shell\\\"\\nversion = \\\"1.0.0\\\"\\n\\\n 1300:             min_spt_core_version = \\\"0.0.1\\\"\\n\\n[shell]\\nspawn = \\\"x\\\"\\nrequire_approval = \\\"remembered\\\"\\n\\n\\\n*1301:             [strings]\\nbase = \\\"parent\\\"\\n\\n[profiles.locked]\\n[profiles.locked.shell]\\nrequire_approval = \\\"always\\\"\\n\";\n 1302:        let adapters = tmp.path().join(\"adapters\");\n*1303:        let src = seed_source(tmp.path(), \"ms-src\", src_toml);\n 1304:        register(&adapters, &src, 1000).unwrap();\n 1305:\n 1306:        // Bare option reads the base string; a missing key is None.\n...\n 1342:\n*1343:    // ───────────── File-backed [strings] (REQ-MANIFEST-5) ─────────────\n 1344:\n*1345:    /// Seed a copy-mode harness adapter source whose `[strings]` carries `body`\n*1346:    /// as a file pointer to `strings/<file_name>` (contents `body`) plus an\n 1347:    /// inline literal; returns the `adapters/` dir after registration.\n 1348:    fn seed_cc_with_file_string(tmp: &Path, file_rel: &str, body: &str) -> PathBuf {\n 1349:        let toml_src = format!(\n...\n 1351:             min_spt_core_version = \\\"0.0.1\\\"\\nhostable_types = [\\\"LiveAgent\\\"]\\n\\n\\\n*1352:             [strings]\\ninline = \\\"literal\\\"\\nbody = {{ file = \\\"{file_rel}\\\" }}\\n\"\n 1353:        );\n*1354:        let src = seed_source(tmp, \"cc-src\", &toml_src);\n*1355:        let strings_dir = src.join(\"strings\");\n*1356:        std::fs::create_dir_all(strings_dir.join(Path::new(file_rel).parent().unwrap_or(Path::new(\"\"))))\n 1357:            .unwrap();\n*1358:        std::fs::write(strings_dir.join(file_rel), body).unwrap();\n 1359:        let adapters = tmp.join(\"adapters\");\n 1360:        register(&adapters, &src, 1000).unwrap();\n 1361:        adapters\n...\n 1382:        // Lazy: edit the (copied) file; get-string reflects it without re-register.\n*1383:        let copied = adapters.join(\"mock-cc\").join(\"strings\").join(\"skill.md\");\n*1384:        assert!(copied.exists(), \"shipped strings/ copied into the held copy\");\n 1385:        std::fs::write(&copied, \"EDITED BODY\").unwrap();\n 1386:        assert_eq!(\n 1387:            get_string(&adapters, \"mock-cc\", \"body\").unwrap().and_then(|v| v.as_str().map(str::to_string)),\n...\n 1393:    // [unit->REQ-MANIFEST-5] HAZARD-class containment: a file pointer that\n*1394:    // escapes the strings/ dir (`..` traversal or an absolute path) is REFUSED\n 1395:    // loudly at register — nothing is recorded. (Path-traversal hazard evidence.)\n 1396:    #[test]\n 1397:    fn string_file_pointer_escaping_the_dir_is_refused_at_register() {\n...\n 1401:        // `..` traversal.\n*1402:        let traverse = seed_source(\n 1403:            tmp.path(),\n 1404:            \"esc1\",\n 1405:            \"[adapter]\\nname = \\\"esc1\\\"\\nkind = \\\"harness\\\"\\nversion = \\\"1\\\"\\n\\\n 1406:             min_spt_core_version = \\\"0\\\"\\nhostable_types = [\\\"LiveAgent\\\"]\\n\\n\\\n*1407:             [strings]\\nx = { file = \\\"../../secret.txt\\\" }\\n\",\n 1408:        );\n 1409:        assert!(matches!(\n 1410:            register(&adapters, &traverse, 1),\n...\n 1414:\n*1415:        // Absolute path.\n 1416:        let abs_path = if cfg!(windows) { \"C:\\\\\\\\windows\\\\\\\\system32\\\\\\\\x\" } else { \"/etc/passwd\" };\n*1417:        let absolute = seed_source(\n 1418:            tmp.path(),\n 1419:            \"esc2\",\n 1420:            &format!(\n...\n 1422:                 min_spt_core_version = \\\"0\\\"\\nhostable_types = [\\\"LiveAgent\\\"]\\n\\n\\\n*1423:                 [strings]\\nx = {{ file = \\\"{abs_path}\\\" }}\\n\"\n 1424:            ),\n 1425:        );\n 1426:        assert!(matches!(\n*1427:            register(&adapters, &absolute, 2),\n 1428:            Err(RegistryError::BadStringPointer(_))\n 1429:        ));\n*1430:        assert!(!adapters.join(\"esc2\").exists(), \"absolute pointer records nothing\");\n 1431:    }\n 1432:\n 1433:    // [unit->REQ-MANIFEST-5] a missing-at-READ file (deleted after a valid\n...\n 1439:        let adapters = seed_cc_with_file_string(tmp.path(), \"skill.md\", \"BODY\");\n*1440:        std::fs::remove_file(adapters.join(\"mock-cc\").join(\"strings\").join(\"skill.md\")).unwrap();\n 1441:        assert_eq!(\n 1442:            get_string(&adapters, \"mock-cc\", \"body\").unwrap(),\n 1443:            None,"}],"details":{"scopePath":".worktrees/ws272-w3-drift/crates/spt-runtime/src/registry.rs","searchPath":"C:/Users/decid/Documents/projects/spt-core/.worktrees/ws272-w3-drift/crates/spt-runtime/src/registry.rs","cwd":"C:\\Users\\decid\\Documents\\projects\\spt-core","matchCount":96,"fileCount":1,"files":[".worktrees/ws272-w3-drift/crates/spt-runtime/src/registry.rs"],"fileMatches":[{"path":".worktrees/ws272-w3-drift/crates/spt-runtime/src/registry.rs","count":96}],"truncated":false,"displayContent":"   84│    ShippedProfileImmutable(String),\n  *85│    /// A `[strings]` file pointer (REQ-MANIFEST-5) is invalid at registration —\n  *86│    /// it escapes the adapter's `strings/` dir (`..`/absolute — the HAZARD-class\n   87│    /// containment guard) or names a file that does not exist. Fail-fast: a bad\n   88│    /// pointer records nothing (the validate-at-register half of the contract).\n   89│    BadStringPointer(String),\n     │...\n  121│            RegistryError::BadStringPointer(m) => {\n *122│                write!(f, \"invalid [strings] file pointer: {m}\")\n  123│            }\n  124│            RegistryError::DeferredManifest { adapter, dir } => write!(\n  125│                f,\n     │...\n  163│\n *164│// ───────────────────── File-backed [strings] (REQ-MANIFEST-5) ────────────────\n  165│\n  166│/// The directory that holds the adapter's LIVE manifest — and, as a sibling,\n *167│/// its `strings/` aux dir. Copy mode: the held copy under `adapters/<name>/`;\n  168│/// pointer mode: the live source tree (self-updates honored, same as the\n  169│/// manifest itself).\n  170│fn manifest_dir(adapters_dir: &Path, record: &AdapterRecord) -> PathBuf {\n     │...\n  176│\n *177│/// The adapter-shipped string-file storage `<manifest-dir>/strings/`\n  178│/// (REQ-MANIFEST-5). Adapter updates overwrite this dir — never put user files\n  179│/// here (a local profile's pointers resolve to its own user-owned dir instead).\n *180│fn shipped_strings_dir(adapters_dir: &Path, record: &AdapterRecord) -> PathBuf {\n *181│    manifest_dir(adapters_dir, record).join(\"strings\")\n  182│}\n  183│\n *184│/// Validate a `[strings]` file-pointer relative path against the HAZARD-class\n  185│/// containment guard (doyle, REQ-MANIFEST-5): it must be a plain relative path\n *186│/// that cannot escape its `strings/` dir — reject absolute paths and any `..`\n *187│/// (or prefix/root) component. Returns the joined absolute path on success.\n  188│fn contained_string_file(base: &Path, rel: &str) -> Result<PathBuf, String> {\n  189│    let relp = Path::new(rel);\n *190│    let escapes = relp.components().any(|c| {\n  191│        matches!(\n  192│            c,\n  193│            Component::ParentDir | Component::RootDir | Component::Prefix(_)\n     │...\n  195│    });\n *196│    if relp.as_os_str().is_empty() || relp.is_absolute() || escapes {\n  197│        return Err(format!(\n *198│            \"pointer '{rel}' must be a relative path inside the strings/ dir \\\n *199│             (no absolute paths, no `..` traversal)\"\n  200│        ));\n  201│    }\n  202│    Ok(base.join(relp))\n     │...\n  215│        Err(why) => {\n *216│            spt_proto::emit_line_err!(\"STRINGS_FILE_SKIP: {why}\");\n  217│            return None;\n  218│        }\n  219│    };\n     │...\n  223│            spt_proto::emit_line_err!(\n *224│                \"STRINGS_FILE_SKIP: cannot read string file {} ({e})\",\n  225│                full.display()\n  226│            );\n  227│            None\n     │...\n  231│\n *232│/// Walk every file pointer in a `[strings]` tree (recursing nested tables,\n  233│/// treating a single-key `{file=…}` node as a leaf), invoking `f` with each\n  234│/// relative path. The register-time validation traversal.\n *235│fn for_each_string_pointer(strings: &toml::Table, f: &mut impl FnMut(&str)) {\n *236│    for value in strings.values() {\n  237│        if let Some(rel) = profile::as_file_pointer(value) {\n  238│            f(rel);\n  239│        } else if let Some(nested) = value.as_table() {\n     │...\n  244│\n *245│/// Validate (at register, fail-fast) that every file pointer in `strings` is\n *246│/// contained AND exists under `strings_dir`. A bad pointer refuses the whole\n  247│/// registration (records nothing) — the loud half of the contract.\n  248│fn validate_string_pointers(\n *249│    strings: &toml::Table,\n *250│    strings_dir: &Path,\n  251│) -> Result<(), RegistryError> {\n  252│    let mut bad: Option<String> = None;\n *253│    for_each_string_pointer(strings, &mut |rel| {\n  254│        if bad.is_some() {\n  255│            return;\n  256│        }\n *257│        match contained_string_file(strings_dir, rel) {\n  258│            Err(why) => bad = Some(why),\n  259│            Ok(full) if !full.exists() => {\n  260│                bad = Some(format!(\n     │...\n  274│/// Recursively copy `src` into `dst` (used to bring an adapter's shipped\n *275│/// `strings/` dir into the held copy at register, so file pointers resolve\n  276│/// post-copy and survive the source going away — Copy mode only).\n  277│fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {\n  278│    std::fs::create_dir_all(dst)?;\n     │...\n  299│// [impl->REQ-INSTALL-4]\n *300│pub fn register(\n  301│    adapters_dir: &Path,\n  302│    source: &Path,\n  303│    now_ms: u64,\n     │...\n  312│// [impl->REQ-ADAPTER-FLOOR-ENFORCE]\n *313│pub fn register_with_core(\n  314│    adapters_dir: &Path,\n  315│    source: &Path,\n  316│    now_ms: u64,\n     │...\n  361│\n *362│    // Validate-first (REQ-MANIFEST-5): every `[strings]` file pointer — in the\n  363│    // base manifest and in each shipped profile's overlay — must be contained\n *364│    // and exist under the SOURCE `strings/` dir. A bad/escaping/missing pointer\n  365│    // refuses the whole registration (records nothing) — the loud half of the\n  366│    // contract; read-time stays skip-diagnostic.\n *367│    let src_strings_dir = Path::new(&source_dir).join(\"strings\");\n *368│    validate_string_pointers(&manifest.strings, &src_strings_dir)?;\n  369│    for prof in manifest.profiles.values() {\n *370│        if let Some(strings) = prof.get(\"strings\").and_then(|v| v.as_table()) {\n *371│            validate_string_pointers(strings, &src_strings_dir)?;\n  372│        }\n  373│    }\n  374│\n     │...\n  398│        atomic_write_string(&manifest_copy_file(adapters_dir, &record.name), &toml_src)?;\n *399│        // Bring the shipped strings/ dir into the held copy so pointers resolve\n  400│        // post-copy and survive the source going away (adapter updates overwrite\n *401│        // this — REQ-MANIFEST-5). Pointer mode reads strings/ live from source.\n *402│        if src_strings_dir.is_dir() {\n *403│            copy_dir_all(&src_strings_dir, &shipped_strings_dir(adapters_dir, &record))\n  404│                .map_err(RegistryError::Io)?;\n  405│        }\n  406│    }\n     │...\n  495│// [impl->REQ-INSTALL-4]\n *496│pub fn registered(adapters_dir: &Path) -> Vec<(AdapterRecord, Manifest)> {\n  497│    all_records(adapters_dir)\n  498│        .into_iter()\n  499│        .filter(|r| r.active)\n     │...\n  505│            // and WHY on stderr so it never vanishes without a trace (mirrors the\n *506│            // [digest]/[strings] skip stance).\n  507│            // [impl->REQ-HAZARD-DEFERRED-MANIFEST]\n  508│            Err(e) => {\n  509│                spt_proto::emit_line_err!(\"ADAPTER_SKIP:{} — {e}\", r.name);\n     │...\n  632│\n *633│/// Read a `[strings]` dot-path from an adapter **option**'s merged view — the\n  634│/// `spt adapter get-string <adapter-option> <key.path>` resolver. Resolves the\n  635│/// option (bare = parent, else shipped/local overlay) exactly as every other\n *636│/// consumer, then dot-path-traverses the merged `[strings]` tree. `Ok(None)` =\n  637│/// the key is absent (a clean \"not set\", distinct from an unresolvable option).\n  638│// [impl->REQ-MANIFEST-3]\n  639│pub fn get_string(\n     │...\n  644│    let (record, manifest) = resolve_option(adapters_dir, option)?;\n *645│    // The node-static `{node}` fill for lazy [strings] (REQ-MANIFEST-NODE-KEY):\n  646│    // get-string is a CLI read with no daemon in-mem label, so it resolves to the\n  647│    // OS hostname (the same self-label source the CLI uses everywhere) via the\n  648│    // shared resolver — `None` only if the OS won't say, leaving `{node}` intact.\n  649│    let node = spt_store::hostlabel::node_fill_label(None);\n *650│    let Some(value) = profile::string_at(&manifest.strings, key_path) else {\n  651│        return Ok(None);\n  652│    };\n  653│    // A literal value reads as-is. A single-key `{file=…}` pointer resolves to\n     │...\n  660│    // pointer by a LOCAL profile, resolve against its user-owned dir (survives\n *661│    // adapter updates); otherwise against the adapter-shipped `strings/` dir.\n  662│    let (adapter, profile_seg) = profile::split_option(option);\n  663│    let base = match profile_seg {\n  664│        Some(p) if local_profile_supplies_pointer(adapters_dir, adapter, p, key_path) => {\n     │...\n  666│        }\n *667│        _ => shipped_strings_dir(adapters_dir, &record),\n  668│    };\n  669│    // [impl->REQ-MANIFEST-SUBST] file-backed contents get the same lazy subst.\n  670│    Ok(read_string_file(&base, rel).map(|v| subst_string_value(v, &record, node.as_deref())))\n     │...\n  674│/// `source_dir`; `{adapter_name}` = the record name) AND the node-static `{node}`\n *675│/// (REQ-MANIFEST-NODE-KEY) into every string leaf of a resolved `[strings]` value at\n  676│/// `get-string` read time (ADR-0029). Recurses tables/arrays; non-string scalars\n  677│/// pass through. Session-scoped keys are NOT available (get-string carries no\n  678│/// session) — they remain intact for the adapter's own wrapper to fill. Parity with\n     │...\n  703│/// as a file pointer — the provenance test that routes a pointer to the\n *704│/// user-owned profiles dir instead of the adapter-shipped `strings/` dir\n  705│/// (REQ-MANIFEST-5 update-safety). A shipped profile (no local file) is `false`.\n  706│fn local_profile_supplies_pointer(\n  707│    adapters_dir: &Path,\n     │...\n  716│        .as_table()\n *717│        .and_then(|t| t.get(\"strings\"))\n  718│        .and_then(|s| s.as_table())\n  719│        .and_then(|st| profile::string_at(st, key_path))\n  720│        .map(|v| profile::as_file_pointer(v).is_some())\n     │...\n  723│\n *724│/// Set a `[strings]` dot-path on a **local** profile's overlay — the\n  725│/// `spt adapter set-string` sugar. Edits only the node-local overlay file\n  726│/// (`profiles/<profile>.toml`); a shipped profile name is refused (adapter-owned,\n  727│/// immutable) and a missing local profile errors (`create-profile` first). The\n     │...\n  747│        .ok_or_else(|| RegistryError::BadRecord(format!(\"profiles/{profile}\")))?;\n *748│    let strings = overlay_table\n *749│        .entry(\"strings\".to_string())\n  750│        .or_insert_with(|| Value::Table(toml::Table::new()));\n *751│    let strings_table = strings\n  752│        .as_table_mut()\n *753│        .ok_or_else(|| RegistryError::BadRecord(format!(\"profiles/{profile}: [strings] not a table\")))?;\n *754│    profile::set_string_at(strings_table, key_path, Value::String(value.to_string()))\n  755│        .map_err(RegistryError::Manifest)?;\n  756│    let overlay_toml = toml::to_string_pretty(&overlay)\n  757│        .map_err(|e| RegistryError::Manifest(crate::manifest::ManifestError::Validation(e.to_string())))?;\n     │...\n  897│\n *898│    fn seed_source(dir: &Path, name: &str, toml_src: &str) -> PathBuf {\n  899│        let src = dir.join(name);\n  900│        std::fs::create_dir_all(&src).unwrap();\n  901│        std::fs::write(src.join(\"manifest.toml\"), toml_src).unwrap();\n     │...\n  911│    #[test]\n *912│    fn register_refuses_unfillable_role_key_and_writes_nothing() {\n  913│        let tmp = tempfile::tempdir().unwrap();\n  914│        let adapters = tmp.path().join(\"adapters\");\n  915│        let bad = \"[adapter]\\nname = \\\"badkey\\\"\\nversion = \\\"1\\\"\\nmin_spt_core_version = \\\"0\\\"\\n\\n\\\n  916│                   [session.psyche_resume]\\ncommand = 'run {id}'\\ncwd = \\\"{psyche_dir}\\\"\\n\";\n *917│        let src = seed_source(tmp.path(), \"badkey-src\", bad);\n  918│\n  919│        let err = register(&adapters, &src, 1000).expect_err(\"an unfillable role key must refuse\");\n  920│        match err {\n     │...\n  938│    #[test]\n *939│    fn register_copy_vs_pointer_by_avenue() {\n  940│        let tmp = tempfile::tempdir().unwrap();\n  941│        let adapters = tmp.path().join(\"adapters\");\n *942│        let harness_src = seed_source(tmp.path(), \"h-src\", HARNESS_COPY);\n *943│        let shell_src = seed_source(tmp.path(), \"s-src\", SHELL_DELEGATED);\n  944│\n  945│        let (h, hm) = register(&adapters, &harness_src, 1000).unwrap();\n  946│        assert_eq!((h.mode, h.kind), (RecordMode::Copy, AdapterKind::Harness));\n     │...\n 1003│    #[test]\n*1004│    fn register_with_core_refuses_below_floor_and_writes_nothing() {\n 1005│        let tmp = tempfile::tempdir().unwrap();\n 1006│        let adapters = tmp.path().join(\"adapters\");\n*1007│        let src = seed_source(tmp.path(), \"hf-src\", HARNESS_HIGH_FLOOR);\n 1008│\n 1009│        // A real-shaped core (0.25.0) is far below the 9.9.9 floor ⇒ refuse.\n 1010│        let err = register_with_core(&adapters, &src, 1000, \"0.25.0\")\n     │...\n 1037│    #[test]\n*1038│    fn register_with_core_admits_when_core_meets_floor() {\n 1039│        let tmp = tempfile::tempdir().unwrap();\n 1040│        let adapters = tmp.path().join(\"adapters\");\n*1041│        let src = seed_source(tmp.path(), \"ef-src\", HARNESS_EQUAL_FLOOR);\n 1042│\n 1043│        let (record, manifest) = register_with_core(&adapters, &src, 2000, \"0.25.0\")\n 1044│            .expect(\"a core equal to the floor must register\");\n     │...\n 1064│        // kind = \"shell\" without a [shell] body fails validation.\n*1065│        let bad = seed_source(\n 1066│            tmp.path(),\n 1067│            \"bad-src\",\n 1068│            \"[adapter]\\nname = \\\"bad\\\"\\nkind = \\\"shell\\\"\\nversion = \\\"1\\\"\\nmin_spt_core_version = \\\"0\\\"\\n\",\n     │...\n 1085│        let adapters = tmp.path().join(\"adapters\");\n*1086│        let shell_src = seed_source(tmp.path(), \"s-src\", SHELL_DELEGATED);\n 1087│        register(&adapters, &shell_src, 1000).unwrap();\n 1088│\n 1089│        let uninstall = deregister(&adapters, \"mock-shell\").unwrap();\n     │...\n 1127│        let adapters = tmp.path().join(\"adapters\");\n*1128│        let shell_src = seed_source(tmp.path(), \"deferred-src\", SHELL_DELEGATED);\n 1129│        let (rec, _) = register(&adapters, &shell_src, 1000).unwrap();\n 1130│        assert_eq!(rec.mode, RecordMode::Pointer);\n 1131│\n     │...\n 1155│    /// Register the profiled shell adapter into a fresh `adapters/` dir.\n*1156│    fn registered_shell(tmp: &Path) -> PathBuf {\n 1157│        let adapters = tmp.join(\"adapters\");\n*1158│        let src = seed_source(tmp, \"ms-src\", SHELL_COPY_PROFILED);\n 1159│        register(&adapters, &src, 1000).unwrap();\n 1160│        adapters\n 1161│    }\n     │...\n 1226│        // Re-register the adapter (adapter update / re-add).\n*1227│        let src = seed_source(tmp.path(), \"ms-src2\", SHELL_COPY_PROFILED);\n 1228│        register(&adapters, &src, 2000).unwrap();\n 1229│\n 1230│        assert!(\n     │...\n 1291│    // [unit->REQ-MANIFEST-3] get-string resolves through the overlay (a local\n*1292│    // profile's [strings] override the base; the merged view is what reads), and\n 1293│    // set-string edits a LOCAL profile only — a shipped name + a bare option are\n 1294│    // refused, a missing key reads None.\n 1295│    #[test]\n*1296│    fn strings_get_set_through_overlay() {\n 1297│        let tmp = tempfile::tempdir().unwrap();\n*1298│        // A shell adapter carrying base strings + a shipped profile.\n 1299│        let src_toml = \"[adapter]\\nname = \\\"mock-shell\\\"\\nkind = \\\"shell\\\"\\nversion = \\\"1.0.0\\\"\\n\\\n 1300│             min_spt_core_version = \\\"0.0.1\\\"\\n\\n[shell]\\nspawn = \\\"x\\\"\\nrequire_approval = \\\"remembered\\\"\\n\\n\\\n*1301│             [strings]\\nbase = \\\"parent\\\"\\n\\n[profiles.locked]\\n[profiles.locked.shell]\\nrequire_approval = \\\"always\\\"\\n\";\n 1302│        let adapters = tmp.path().join(\"adapters\");\n*1303│        let src = seed_source(tmp.path(), \"ms-src\", src_toml);\n 1304│        register(&adapters, &src, 1000).unwrap();\n 1305│\n 1306│        // Bare option reads the base string; a missing key is None.\n     │...\n 1342│\n*1343│    // ───────────── File-backed [strings] (REQ-MANIFEST-5) ─────────────\n 1344│\n*1345│    /// Seed a copy-mode harness adapter source whose `[strings]` carries `body`\n*1346│    /// as a file pointer to `strings/<file_name>` (contents `body`) plus an\n 1347│    /// inline literal; returns the `adapters/` dir after registration.\n 1348│    fn seed_cc_with_file_string(tmp: &Path, file_rel: &str, body: &str) -> PathBuf {\n 1349│        let toml_src = format!(\n     │...\n 1351│             min_spt_core_version = \\\"0.0.1\\\"\\nhostable_types = [\\\"LiveAgent\\\"]\\n\\n\\\n*1352│             [strings]\\ninline = \\\"literal\\\"\\nbody = {{ file = \\\"{file_rel}\\\" }}\\n\"\n 1353│        );\n*1354│        let src = seed_source(tmp, \"cc-src\", &toml_src);\n*1355│        let strings_dir = src.join(\"strings\");\n*1356│        std::fs::create_dir_all(strings_dir.join(Path::new(file_rel).parent().unwrap_or(Path::new(\"\"))))\n 1357│            .unwrap();\n*1358│        std::fs::write(strings_dir.join(file_rel), body).unwrap();\n 1359│        let adapters = tmp.join(\"adapters\");\n 1360│        register(&adapters, &src, 1000).unwrap();\n 1361│        adapters\n     │...\n 1382│        // Lazy: edit the (copied) file; get-string reflects it without re-register.\n*1383│        let copied = adapters.join(\"mock-cc\").join(\"strings\").join(\"skill.md\");\n*1384│        assert!(copied.exists(), \"shipped strings/ copied into the held copy\");\n 1385│        std::fs::write(&copied, \"EDITED BODY\").unwrap();\n 1386│        assert_eq!(\n 1387│            get_string(&adapters, \"mock-cc\", \"body\").unwrap().and_then(|v| v.as_str().map(str::to_string)),\n     │...\n 1393│    // [unit->REQ-MANIFEST-5] HAZARD-class containment: a file pointer that\n*1394│    // escapes the strings/ dir (`..` traversal or an absolute path) is REFUSED\n 1395│    // loudly at register — nothing is recorded. (Path-traversal hazard evidence.)\n 1396│    #[test]\n 1397│    fn string_file_pointer_escaping_the_dir_is_refused_at_register() {\n     │...\n 1401│        // `..` traversal.\n*1402│        let traverse = seed_source(\n 1403│            tmp.path(),\n 1404│            \"esc1\",\n 1405│            \"[adapter]\\nname = \\\"esc1\\\"\\nkind = \\\"harness\\\"\\nversion = \\\"1\\\"\\n\\\n 1406│             min_spt_core_version = \\\"0\\\"\\nhostable_types = [\\\"LiveAgent\\\"]\\n\\n\\\n*1407│             [strings]\\nx = { file = \\\"../../secret.txt\\\" }\\n\",\n 1408│        );\n 1409│        assert!(matches!(\n 1410│            register(&adapters, &traverse, 1),\n     │...\n 1414│\n*1415│        // Absolute path.\n 1416│        let abs_path = if cfg!(windows) { \"C:\\\\\\\\windows\\\\\\\\system32\\\\\\\\x\" } else { \"/etc/passwd\" };\n*1417│        let absolute = seed_source(\n 1418│            tmp.path(),\n 1419│            \"esc2\",\n 1420│            &format!(\n     │...\n 1422│                 min_spt_core_version = \\\"0\\\"\\nhostable_types = [\\\"LiveAgent\\\"]\\n\\n\\\n*1423│                 [strings]\\nx = {{ file = \\\"{abs_path}\\\" }}\\n\"\n 1424│            ),\n 1425│        );\n 1426│        assert!(matches!(\n*1427│            register(&adapters, &absolute, 2),\n 1428│            Err(RegistryError::BadStringPointer(_))\n 1429│        ));\n*1430│        assert!(!adapters.join(\"esc2\").exists(), \"absolute pointer records nothing\");\n 1431│    }\n 1432│\n 1433│    // [unit->REQ-MANIFEST-5] a missing-at-READ file (deleted after a valid\n     │...\n 1439│        let adapters = seed_cc_with_file_string(tmp.path(), \"skill.md\", \"BODY\");\n*1440│        std::fs::remove_file(adapters.join(\"mock-cc\").join(\"strings\").join(\"skill.md\")).unwrap();\n 1441│        assert_eq!(\n 1442│            get_string(&adapters, \"mock-cc\", \"body\").unwrap(),\n 1443│            None,"},"isError":false,"timestamp":1788711248874}}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_xbhY5PRUwBJrJSMqh9mtTu6w|fc_07548ebf196ed5a1016a9d915950f487d1ba505cd1f0480903","toolName":"read","startedAt":"2026-09-06T16:14:17.414Z","args":{"path":"C:/Users/decid/Documents/projects/spt-core/.worktrees/ws272-w3-drift/crates/spt-runtime/src/registry.rs:842-960,1393-1431"},"intent":"Reading registry fixture contracts"},"id":"e7bebfbe","parentId":"63dcb403","timestamp":"2026-09-06T16:14:17.414Z"}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_06il6s4X0pUYKa4GUsUSjlcR|fc_07548ebf196ed5a1016a9d9159510887d18f5cfbf8d4716267","toolName":"read","startedAt":"2026-09-06T16:14:17.414Z","args":{"path":"C:/Users/decid/Documents/projects/spt-core/.worktrees/ws272-w3-drift/crates/spt-runtime/src/manifest.rs:206-275"},"intent":"Reading adapter header fields"},"id":"3657829f","parentId":"e7bebfbe","timestamp":"2026-09-06T16:14:17.414Z"}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_YniYI5WXthKEFHbBqyeFJwSO|fc_07548ebf196ed5a1016a9d9159511087d18a912a4379417254","toolName":"read","startedAt":"2026-09-06T16:14:17.414Z","args":{"path":"C:/Users/decid/Documents/projects/spt-core/.worktrees/ws272-w3-drift/docs/MANIFEST.md:330-365"},"intent":"Reading manifest pointer policy"},"id":"324ec679","parentId":"3657829f","timestamp":"2026-09-06T16:14:17.414Z"}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_65fyEWEcdxfW5pOqrBBRtFdL|fc_07548ebf196ed5a1016a9d9159511887d1ada386bb2a1d0a40","toolName":"grep","startedAt":"2026-09-06T16:14:17.414Z","args":{"path":"C:/Users/decid/Documents/projects/spt-core/WEBSERVE-272-JIT.md"},"intent":"Checking docs placement ruling"},"id":"7165717e","parentId":"324ec679","timestamp":"2026-09-06T16:14:17.414Z"}
{"type":"message","id":"2760dc6e","parentId":"7165717e","timestamp":"2026-09-06T16:14:17.414Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"call_xbhY5PRUwBJrJSMqh9mtTu6w|fc_07548ebf196ed5a1016a9d915950f487d1ba505cd1f0480903","name":"read","arguments":{"i":"Reading registry fixture contracts","path":"C:/Users/decid/Documents/projects/spt-core/.worktrees/ws272-w3-drift/crates/spt-runtime/src/registry.rs:842-960,1393-1431"},"intent":"Reading registry fixture contracts"},{"type":"toolCall","id":"call_06il6s4X0pUYKa4GUsUSjlcR|fc_07548ebf196ed5a1016a9d9159510887d18f5cfbf8d4716267","name":"read","arguments":{"i":"Reading adapter header fields","path":"C:/Users/decid/Documents/projects/spt-core/.worktrees/ws272-w3-drift/crates/spt-runtime/src/manifest.rs:206-275"},"intent":"Reading adapter header fields"},{"type":"toolCall","id":"call_YniYI5WXthKEFHbBqyeFJwSO|fc_07548ebf196ed5a1016a9d9159511087d18a912a4379417254","name":"read","arguments":{"i":"Reading manifest pointer policy","path":"C:/Users/decid/Documents/projects/spt-core/.worktrees/ws272-w3-drift/docs/MANIFEST.md:330-365"},"intent":"Reading manifest pointer policy"},{"type":"toolCall","id":"call_65fyEWEcdxfW5pOqrBBRtFdL|fc_07548ebf196ed5a1016a9d9159511887d1ada386bb2a1d0a40","name":"grep","arguments":{"i":"Checking docs placement ruling","pattern":"docs_dir|W3","path":"C:/Users/decid/Documents/projects/spt-core/WEBSERVE-272-JIT.md","case":true,"gitignore":false,"skip":0},"intent":"Checking docs placement ruling"}],"api":"openai-codex-responses","provider":"openai-codex","model":"gpt-6-astra","usage":{"input":9931,"output":239,"cacheRead":19328,"cacheWrite":0,"totalTokens":29498,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1788711248922,"responseId":"resp_07548ebf196ed5a1016a9d915131e087d188c3e6276c5cde74","providerPayload":{"type":"openaiResponsesHistory","provider":"openai-codex","dt":true,"items":[{"id":"fc_07548ebf196ed5a1016a9d915950f487d1ba505cd1f0480903","type":"function_call","status":"completed","arguments":"{\"i\":\"Reading registry fixture contracts\",\"path\":\"C:/Users/decid/Documents/projects/spt-core/.worktrees/ws272-w3-drift/crates/spt-runtime/src/registry.rs:842-960,1393-1431\"}","call_id":"call_xbhY5PRUwBJrJSMqh9mtTu6w","internal_chat_message_metadata_passthrough":{"turn_id":"0b3b77b9-eace-409f-9a5d-491fd9ffe825"},"name":"read","metadata":{"turn_id":"0b3b77b9-eace-409f-9a5d-491fd9ffe825"}},{"id":"fc_07548ebf196ed5a1016a9d9159510887d18f5cfbf8d4716267","type":"function_call","status":"completed","arguments":"{\"i\":\"Reading adapter header fields\",\"path\":\"C:/Users/decid/Documents/projects/spt-core/.worktrees/ws272-w3-drift/crates/spt-runtime/src/manifest.rs:206-275\"}","call_id":"call_06il6s4X0pUYKa4GUsUSjlcR","internal_chat_message_metadata_passthrough":{"turn_id":"0b3b77b9-eace-409f-9a5d-491fd9ffe825"},"name":"read","metadata":{"turn_id":"0b3b77b9-eace-409f-9a5d-491fd9ffe825"}},{"id":"fc_07548ebf196ed5a1016a9d9159511087d18a912a4379417254","type":"function_call","status":"completed","arguments":"{\"i\":\"Reading manifest pointer policy\",\"path\":\"C:/Users/decid/Documents/projects/spt-core/.worktrees/ws272-w3-drift/docs/MANIFEST.md:330-365\"}","call_id":"call_YniYI5WXthKEFHbBqyeFJwSO","internal_chat_message_metadata_passthrough":{"turn_id":"0b3b77b9-eace-409f-9a5d-491fd9ffe825"},"name":"read","metadata":{"turn_id":"0b3b77b9-eace-409f-9a5d-491fd9ffe825"}},{"id":"fc_07548ebf196ed5a1016a9d9159511887d1ada386bb2a1d0a40","type":"function_call","status":"completed","arguments":"{\"i\":\"Checking docs placement ruling\",\"pattern\":\"docs_dir|W3\",\"path\":\"C:/Users/decid/Documents/projects/spt-core/WEBSERVE-272-JIT.md\",\"case\":true,\"gitignore\":false,\"skip\":0}","call_id":"call_65fyEWEcdxfW5pOqrBBRtFdL","internal_chat_message_metadata_passthrough":{"turn_id":"0b3b77b9-eace-409f-9a5d-491fd9ffe825"},"name":"grep","metadata":{"turn_id":"0b3b77b9-eace-409f-9a5d-491fd9ffe825"}}]},"duration":8490.472100000829,"ttft":8437.529699999839,"completedAt":1788711257413,"contextSnapshot":{"promptTokens":29259,"nonMessageTokens":16645,"compactionEpoch":0}}}
{"type":"message","id":"04ef4878","parentId":"2760dc6e","timestamp":"2026-09-06T16:14:17.425Z","message":{"role":"toolResult","toolCallId":"call_65fyEWEcdxfW5pOqrBBRtFdL|fc_07548ebf196ed5a1016a9d9159511887d1ada386bb2a1d0a40","toolName":"grep","content":[{"type":"text","text":"[WEBSERVE-272-JIT.md#EC39]\n 77:  WEBSERVE close sweep.\n*78:- **IR-12 residual** rides W3's drift-gate riders (already hertz's).\n 79:- IR-59's log-the-floor line ALSO goes into IR-76's three-arm pre-flight on W0's driver\n 80:  template (todlando).\n 81:\n...\n 126:\n*127:## W3 — facets (todlando; hertz takes the drift-gate test riders) — #265 + #268 + #266\n 128:\n 129:- **REQ-WEB-ADAPTER-DOCS-FACET** (#265; doc, impl, unit, int): `/<node>/a/<adapter>/…`\n 130:  serves an installed adapter's docs. **MEASURE FIRST**: where an adapter's docs live after\n 131:  `adapter add` (archive `docs/`? `strings/`? nothing today?) — stop-and-refer with the\n*132:  measurement if no adapter ships docs; the facet may need a manifest key (`docs_dir`).\n 133:  **MEASURED doyle 2026-09-06 16:15Z: none of the 7 installed adapters ships a docs dir\n*134:  (`manifest.toml` + exe + `strings/` + `state/` only). RULED: manifest key `docs_dir`,\n 135:  adapter-relative, inside-the-adapter-dir refusal like `[strings]` pointers; absent = no\n 136:  facet (404 with the facet name).** Drift-gate test half = hertz, spec\n*137:  `WEBSERVE-272-W3-DRIFT-RIDERS.md`.\n 138:- **REQ-DOCS-CHANGELOG-PAGE** (#268; doc, impl, **unit** — unit is hertz's): the docs site\n 139:  carries CHANGELOG.md as a page (`docs-site/src/changelog.md`, generated by `xtask gen` from\n 140:  a pure `changelog_page(&str) -> String`, diffed by `xtask check`, listed in SUMMARY.md +\n...\n 157:\n*158:- **XFER retirement** (ADR-0060; rider on #246's close, W2 or W3 tail): delete\n 159:  `StreamFamily::Xfer`, `serve_xfer`, `fetch_file`/`push_file`, their tests, the `XFER`\n 160:  surface row; `ACCESS_SURFACE_RETIRED:<id>:<surface>` reported at store load for any rule\n 161:  naming it; docs + `--help` regen; CONTEXT.md's access-surface entry already states it.\n...\n 171:   #277's lane if the tree is open, #278 stays its own thin lane, any order.)\n*172:3. W0 → W1 → W2 (+#17) → W3 (+ XFER retirement) → golden head to deployah → v0.68.0.\n 173:   Start W0's registry design + store work NOW in `.worktrees/ws272-w0` off origin/main and\n 174:   rebase at (1); nothing in W0 depends on #276/#277."}],"details":{"scopePath":"WEBSERVE-272-JIT.md","searchPath":"C:/Users/decid/Documents/projects/spt-core/WEBSERVE-272-JIT.md","cwd":"C:\\Users\\decid\\Documents\\projects\\spt-core","matchCount":7,"fileCount":1,"files":["WEBSERVE-272-JIT.md"],"fileMatches":[{"path":"WEBSERVE-272-JIT.md","count":7}],"truncated":false,"displayContent":"  77│  WEBSERVE close sweep.\n *78│- **IR-12 residual** rides W3's drift-gate riders (already hertz's).\n  79│- IR-59's log-the-floor line ALSO goes into IR-76's three-arm pre-flight on W0's driver\n  80│  template (todlando).\n  81│\n    │...\n 126│\n*127│## W3 — facets (todlando; hertz takes the drift-gate test riders) — #265 + #268 + #266\n 128│\n 129│- **REQ-WEB-ADAPTER-DOCS-FACET** (#265; doc, impl, unit, int): `/<node>/a/<adapter>/…`\n 130│  serves an installed adapter's docs. **MEASURE FIRST**: where an adapter's docs live after\n 131│  `adapter add` (archive `docs/`? `strings/`? nothing today?) — stop-and-refer with the\n*132│  measurement if no adapter ships docs; the facet may need a manifest key (`docs_dir`).\n 133│  **MEASURED doyle 2026-09-06 16:15Z: none of the 7 installed adapters ships a docs dir\n*134│  (`manifest.toml` + exe + `strings/` + `state/` only). RULED: manifest key `docs_dir`,\n 135│  adapter-relative, inside-the-adapter-dir refusal like `[strings]` pointers; absent = no\n 136│  facet (404 with the facet name).** Drift-gate test half = hertz, spec\n*137│  `WEBSERVE-272-W3-DRIFT-RIDERS.md`.\n 138│- **REQ-DOCS-CHANGELOG-PAGE** (#268; doc, impl, **unit** — unit is hertz's): the docs site\n 139│  carries CHANGELOG.md as a page (`docs-site/src/changelog.md`, generated by `xtask gen` from\n 140│  a pure `changelog_page(&str) -> String`, diffed by `xtask check`, listed in SUMMARY.md +\n    │...\n 157│\n*158│- **XFER retirement** (ADR-0060; rider on #246's close, W2 or W3 tail): delete\n 159│  `StreamFamily::Xfer`, `serve_xfer`, `fetch_file`/`push_file`, their tests, the `XFER`\n 160│  surface row; `ACCESS_SURFACE_RETIRED:<id>:<surface>` reported at store load for any rule\n 161│  naming it; docs + `--help` regen; CONTEXT.md's access-surface entry already states it.\n    │...\n 171│   #277's lane if the tree is open, #278 stays its own thin lane, any order.)\n*172│3. W0 → W1 → W2 (+#17) → W3 (+ XFER retirement) → golden head to deployah → v0.68.0.\n 173│   Start W0's registry design + store work NOW in `.worktrees/ws272-w0` off origin/main and\n 174│   rebase at (1); nothing in W0 depends on #276/#277."},"isError":false,"timestamp":1788711257425}}
{"type":"message","id":"cfd5478e","parentId":"04ef4878","timestamp":"2026-09-06T16:14:17.428Z","message":{"role":"toolResult","toolCallId":"call_xbhY5PRUwBJrJSMqh9mtTu6w|fc_07548ebf196ed5a1016a9d915950f487d1ba505cd1f0480903","toolName":"read","content":[{"type":"text","text":"[.worktrees/ws272-w3-drift/crates/spt-runtime/src/registry.rs#5C21]\n832:fn apply_option_overlay(\n…\n837:) -> Result<Manifest, RegistryError> {\n838:    match profile {\n…\n840:        Some(p) if parent.profiles.contains_key(p) => {\n…\n842:        }\n843:        Some(p) => {\n844:            let overlay = load_local_overlay(adapters_dir, adapter, p)?;\n845:            profile::resolve_overlay(parent, &overlay).map_err(RegistryError::Manifest)\n846:        }\n847:    }\n848:}\n849:\n850:#[cfg(test)]\n851:mod tests {\n852:    use super::*;\n853:\n854:    const HARNESS_COPY: &str = r#\"\n855:[adapter]\n856:name = \"mock-harness\"\n857:kind = \"harness\"\n858:version = \"1.0.0\"\n859:min_spt_core_version = \"0.0.1\"\n860:hostable_types = [\"LiveAgent\"]\n861:\"#;\n862:\n863:    // A copy-mode (avenue-less) shell adapter carrying one shipped profile that\n864:    // tightens the floor — the base for local-profile tests.\n865:    const SHELL_COPY_PROFILED: &str = r#\"\n866:[adapter]\n867:name = \"mock-shell\"\n868:kind = \"shell\"\n869:version = \"1.0.0\"\n870:min_spt_core_version = \"0.0.1\"\n871:\n872:[shell]\n873:spawn = \"mock --link {link_token}\"\n874:require_approval = \"remembered\"\n875:\n876:[profiles.locked]\n877:[profiles.locked.shell]\n878:require_approval = \"always\"\n879:\"#;\n880:\n881:    const SHELL_DELEGATED: &str = r#\"\n882:[adapter]\n883:name = \"mock-shell\"\n884:kind = \"shell\"\n885:version = \"1.0.0\"\n886:min_spt_core_version = \"0.0.1\"\n887:\n888:[update]\n889:avenue = \"delegated\"\n890:command = \"mock-shell-updater\"\n891:self_verifies = true\n892:uninstall = \"mock-shell-uninstaller --purge\"\n893:\n894:[shell]\n895:spawn = \"mock-shell --link {link_token}\"\n896:\"#;\n897:\n898:    fn seed_source(dir: &Path, name: &str, toml_src: &str) -> PathBuf {\n899:        let src = dir.join(name);\n900:        std::fs::create_dir_all(&src).unwrap();\n901:        std::fs::write(src.join(\"manifest.toml\"), toml_src).unwrap();\n902:        src\n903:    }\n904:\n905:    // [int->REQ-ADAPTER-TEMPLATE-KEY-VALIDATION] the register choke point refuses a\n906:    // manifest whose SPAWNED role (psyche_resume) references an unfillable key (the\n907:    // flynn {psyche_dir} shape) BEFORE writing anything — the same fail-before-write\n908:    // contract as the floor + string-pointer checks. The manifest PARSES fine\n909:    // (validate() is key-agnostic — a live-re-read pointer install never bricks); only\n910:    // the register ACTION is gated.\n911:    #[test]\n912:    fn register_refuses_unfillable_role_key_and_writes_nothing() {\n913:        let tmp = tempfile::tempdir().unwrap();\n914:        let adapters = tmp.path().join(\"adapters\");\n915:        let bad = \"[adapter]\\nname = \\\"badkey\\\"\\nversion = \\\"1\\\"\\nmin_spt_core_version = \\\"0\\\"\\n\\n\\\n916:                   [session.psyche_resume]\\ncommand = 'run {id}'\\ncwd = \\\"{psyche_dir}\\\"\\n\";\n917:        let src = seed_source(tmp.path(), \"badkey-src\", bad);\n918:\n919:        let err = register(&adapters, &src, 1000).expect_err(\"an unfillable role key must refuse\");\n920:        match err {\n921:            RegistryError::Manifest(crate::manifest::ManifestError::Validation(m)) => {\n922:                assert!(\n923:                    m.contains(\"psyche_dir\") && m.contains(\"psyche_resume\"),\n924:                    \"the refusal names the key + role: {m}\"\n925:                );\n926:            }\n927:            other => panic!(\"expected a Manifest Validation error, got {other:?}\"),\n928:        }\n929:        assert!(\n930:            !adapters.join(\"badkey\").exists(),\n931:            \"a refused registration writes NO record dir (fail-before-write)\"\n932:        );\n933:    }\n934:\n935:    // [unit->REQ-INSTALL-4] register validates-first and records by avenue:\n936:    // an avenue-less harness is copied; a delegated shell is a pointer whose\n937:    // manifest reads live from the source tree. One dir serves both kinds.\n938:    #[test]\n939:    fn register_copy_vs_pointer_by_avenue() {\n940:        let tmp = tempfile::tempdir().unwrap();\n941:        let adapters = tmp.path().join(\"adapters\");\n942:        let harness_src = seed_source(tmp.path(), \"h-src\", HARNESS_COPY);\n943:        let shell_src = seed_source(tmp.path(), \"s-src\", SHELL_DELEGATED);\n944:\n945:        let (h, hm) = register(&adapters, &harness_src, 1000).unwrap();\n946:        assert_eq!((h.mode, h.kind), (RecordMode::Copy, AdapterKind::Harness));\n947:        assert_eq!(hm.adapter.name, \"mock-harness\");\n948:        assert!(\n949:            adapters.join(\"mock-harness\").join(\"manifest.toml\").exists(),\n950:            \"copy held\"\n951:        );\n952:\n953:        let (s, _) = register(&adapters, &shell_src, 2000).unwrap();\n954:        assert_eq!((s.mode, s.kind), (RecordMode::Pointer, AdapterKind::Shell));\n955:        assert!(\n956:            !adapters.join(\"mock-shell\").join(\"manifest.toml\").exists(),\n957:            \"no copy\"\n958:        );\n959:        // Pointer reads live: mutate the source, the loaded manifest follows.\n960:        std::fs::write(\n…\n963:        )\n964:        .unwrap();\n…\n974:    }\n…\n1393:    // [unit->REQ-MANIFEST-5] HAZARD-class containment: a file pointer that\n1394:    // escapes the strings/ dir (`..` traversal or an absolute path) is REFUSED\n1395:    // loudly at register — nothing is recorded. (Path-traversal hazard evidence.)\n1396:    #[test]\n1397:    fn string_file_pointer_escaping_the_dir_is_refused_at_register() {\n1398:        let tmp = tempfile::tempdir().unwrap();\n1399:        let adapters = tmp.path().join(\"adapters\");\n1400:\n1401:        // `..` traversal.\n1402:        let traverse = seed_source(\n1403:            tmp.path(),\n1404:            \"esc1\",\n1405:            \"[adapter]\\nname = \\\"esc1\\\"\\nkind = \\\"harness\\\"\\nversion = \\\"1\\\"\\n\\\n1406:             min_spt_core_version = \\\"0\\\"\\nhostable_types = [\\\"LiveAgent\\\"]\\n\\n\\\n1407:             [strings]\\nx = { file = \\\"../../secret.txt\\\" }\\n\",\n1408:        );\n1409:        assert!(matches!(\n1410:            register(&adapters, &traverse, 1),\n1411:            Err(RegistryError::BadStringPointer(_))\n1412:        ));\n1413:        assert!(!adapters.join(\"esc1\").exists(), \"escaping pointer records nothing\");\n1414:\n1415:        // Absolute path.\n1416:        let abs_path = if cfg!(windows) { \"C:\\\\\\\\windows\\\\\\\\system32\\\\\\\\x\" } else { \"/etc/passwd\" };\n1417:        let absolute = seed_source(\n1418:            tmp.path(),\n1419:            \"esc2\",\n1420:            &format!(\n1421:                \"[adapter]\\nname = \\\"esc2\\\"\\nkind = \\\"harness\\\"\\nversion = \\\"1\\\"\\n\\\n1422:                 min_spt_core_version = \\\"0\\\"\\nhostable_types = [\\\"LiveAgent\\\"]\\n\\n\\\n1423:                 [strings]\\nx = {{ file = \\\"{abs_path}\\\" }}\\n\"\n1424:            ),\n1425:        );\n1426:        assert!(matches!(\n1427:            register(&adapters, &absolute, 2),\n1428:            Err(RegistryError::BadStringPointer(_))\n1429:        ));\n1430:        assert!(!adapters.join(\"esc2\").exists(), \"absolute pointer records nothing\");\n1431:    }\n…\n1504:}"}],"details":{"displayContent":{"text":"fn apply_option_overlay(\n…\n) -> Result<Manifest, RegistryError> {\n    match profile {\n…\n        Some(p) if parent.profiles.contains_key(p) => {\n…\n        }\n        Some(p) => {\n            let overlay = load_local_overlay(adapters_dir, adapter, p)?;\n            profile::resolve_overlay(parent, &overlay).map_err(RegistryError::Manifest)\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    const HARNESS_COPY: &str = r#\"\n[adapter]\nname = \"mock-harness\"\nkind = \"harness\"\nversion = \"1.0.0\"\nmin_spt_core_version = \"0.0.1\"\nhostable_types = [\"LiveAgent\"]\n\"#;\n\n    // A copy-mode (avenue-less) shell adapter carrying one shipped profile that\n    // tightens the floor — the base for local-profile tests.\n    const SHELL_COPY_PROFILED: &str = r#\"\n[adapter]\nname = \"mock-shell\"\nkind = \"shell\"\nversion = \"1.0.0\"\nmin_spt_core_version = \"0.0.1\"\n\n[shell]\nspawn = \"mock --link {link_token}\"\nrequire_approval = \"remembered\"\n\n[profiles.locked]\n[profiles.locked.shell]\nrequire_approval = \"always\"\n\"#;\n\n    const SHELL_DELEGATED: &str = r#\"\n[adapter]\nname = \"mock-shell\"\nkind = \"shell\"\nversion = \"1.0.0\"\nmin_spt_core_version = \"0.0.1\"\n\n[update]\navenue = \"delegated\"\ncommand = \"mock-shell-updater\"\nself_verifies = true\nuninstall = \"mock-shell-uninstaller --purge\"\n\n[shell]\nspawn = \"mock-shell --link {link_token}\"\n\"#;\n\n    fn seed_source(dir: &Path, name: &str, toml_src: &str) -> PathBuf {\n        let src = dir.join(name);\n        std::fs::create_dir_all(&src).unwrap();\n        std::fs::write(src.join(\"manifest.toml\"), toml_src).unwrap();\n        src\n    }\n\n    // [int->REQ-ADAPTER-TEMPLATE-KEY-VALIDATION] the register choke point refuses a\n    // manifest whose SPAWNED role (psyche_resume) references an unfillable key (the\n    // flynn {psyche_dir} shape) BEFORE writing anything — the same fail-before-write\n    // contract as the floor + string-pointer checks. The manifest PARSES fine\n    // (validate() is key-agnostic — a live-re-read pointer install never bricks); only\n    // the register ACTION is gated.\n    #[test]\n    fn register_refuses_unfillable_role_key_and_writes_nothing() {\n        let tmp = tempfile::tempdir().unwrap();\n        let adapters = tmp.path().join(\"adapters\");\n        let bad = \"[adapter]\\nname = \\\"badkey\\\"\\nversion = \\\"1\\\"\\nmin_spt_core_version = \\\"0\\\"\\n\\n\\\n                   [session.psyche_resume]\\ncommand = 'run {id}'\\ncwd = \\\"{psyche_dir}\\\"\\n\";\n        let src = seed_source(tmp.path(), \"badkey-src\", bad);\n\n        let err = register(&adapters, &src, 1000).expect_err(\"an unfillable role key must refuse\");\n        match err {\n            RegistryError::Manifest(crate::manifest::ManifestError::Validation(m)) => {\n                assert!(\n                    m.contains(\"psyche_dir\") && m.contains(\"psyche_resume\"),\n                    \"the refusal names the key + role: {m}\"\n                );\n            }\n            other => panic!(\"expected a Manifest Validation error, got {other:?}\"),\n        }\n        assert!(\n            !adapters.join(\"badkey\").exists(),\n            \"a refused registration writes NO record dir (fail-before-write)\"\n        );\n    }\n\n    // [unit->REQ-INSTALL-4] register validates-first and records by avenue:\n    // an avenue-less harness is copied; a delegated shell is a pointer whose\n    // manifest reads live from the source tree. One dir serves both kinds.\n    #[test]\n    fn register_copy_vs_pointer_by_avenue() {\n        let tmp = tempfile::tempdir().unwrap();\n        let adapters = tmp.path().join(\"adapters\");\n        let harness_src = seed_source(tmp.path(), \"h-src\", HARNESS_COPY);\n        let shell_src = seed_source(tmp.path(), \"s-src\", SHELL_DELEGATED);\n\n        let (h, hm) = register(&adapters, &harness_src, 1000).unwrap();\n        assert_eq!((h.mode, h.kind), (RecordMode::Copy, AdapterKind::Harness));\n        assert_eq!(hm.adapter.name, \"mock-harness\");\n        assert!(\n            adapters.join(\"mock-harness\").join(\"manifest.toml\").exists(),\n            \"copy held\"\n        );\n\n        let (s, _) = register(&adapters, &shell_src, 2000).unwrap();\n        assert_eq!((s.mode, s.kind), (RecordMode::Pointer, AdapterKind::Shell));\n        assert!(\n            !adapters.join(\"mock-shell\").join(\"manifest.toml\").exists(),\n            \"no copy\"\n        );\n        // Pointer reads live: mutate the source, the loaded manifest follows.\n        std::fs::write(\n…\n        )\n        .unwrap();\n…\n    }\n…\n    // [unit->REQ-MANIFEST-5] HAZARD-class containment: a file pointer that\n    // escapes the strings/ dir (`..` traversal or an absolute path) is REFUSED\n    // loudly at register — nothing is recorded. (Path-traversal hazard evidence.)\n    #[test]\n    fn string_file_pointer_escaping_the_dir_is_refused_at_register() {\n        let tmp = tempfile::tempdir().unwrap();\n        let adapters = tmp.path().join(\"adapters\");\n\n        // `..` traversal.\n        let traverse = seed_source(\n            tmp.path(),\n            \"esc1\",\n            \"[adapter]\\nname = \\\"esc1\\\"\\nkind = \\\"harness\\\"\\nversion = \\\"1\\\"\\n\\\n             min_spt_core_version = \\\"0\\\"\\nhostable_types = [\\\"LiveAgent\\\"]\\n\\n\\\n             [strings]\\nx = { file = \\\"../../secret.txt\\\" }\\n\",\n        );\n        assert!(matches!(\n            register(&adapters, &traverse, 1),\n            Err(RegistryError::BadStringPointer(_))\n        ));\n        assert!(!adapters.join(\"esc1\").exists(), \"escaping pointer records nothing\");\n\n        // Absolute path.\n        let abs_path = if cfg!(windows) { \"C:\\\\\\\\windows\\\\\\\\system32\\\\\\\\x\" } else { \"/etc/passwd\" };\n        let absolute = seed_source(\n            tmp.path(),\n            \"esc2\",\n            &format!(\n                \"[adapter]\\nname = \\\"esc2\\\"\\nkind = \\\"harness\\\"\\nversion = \\\"1\\\"\\n\\\n                 min_spt_core_version = \\\"0\\\"\\nhostable_types = [\\\"LiveAgent\\\"]\\n\\n\\\n                 [strings]\\nx = {{ file = \\\"{abs_path}\\\" }}\\n\"\n            ),\n        );\n        assert!(matches!(\n            register(&adapters, &absolute, 2),\n            Err(RegistryError::BadStringPointer(_))\n        ));\n        assert!(!adapters.join(\"esc2\").exists(), \"absolute pointer records nothing\");\n    }\n…\n}","startLine":832,"lineNumbers":[832,null,837,838,null,840,null,842,843,844,845,846,847,848,849,850,851,852,853,854,855,856,857,858,859,860,861,862,863,864,865,866,867,868,869,870,871,872,873,874,875,876,877,878,879,880,881,882,883,884,885,886,887,888,889,890,891,892,893,894,895,896,897,898,899,900,901,902,903,904,905,906,907,908,909,910,911,912,913,914,915,916,917,918,919,920,921,922,923,924,925,926,927,928,929,930,931,932,933,934,935,936,937,938,939,940,941,942,943,944,945,946,947,948,949,950,951,952,953,954,955,956,957,958,959,960,null,963,964,null,974,null,1393,1394,1395,1396,1397,1398,1399,1400,1401,1402,1403,1404,1405,1406,1407,1408,1409,1410,1411,1412,1413,1414,1415,1416,1417,1418,1419,1420,1421,1422,1423,1424,1425,1426,1427,1428,1429,1430,1431,null,1504]},"fileSize":68674,"meta":{"source":{"type":"path","value":"C:/Users/decid/Documents/projects/spt-core/.worktrees/ws272-w3-drift/crates/spt-runtime/src/registry.rs"}}},"isError":false,"timestamp":1788711257428}}
{"type":"message","id":"e7c843bf","parentId":"cfd5478e","timestamp":"2026-09-06T16:14:17.439Z","message":{"role":"toolResult","toolCallId":"call_06il6s4X0pUYKa4GUsUSjlcR|fc_07548ebf196ed5a1016a9d9159510887d18f5cfbf8d4716267","toolName":"read","content":[{"type":"text","text":"[.worktrees/ws272-w3-drift/crates/spt-runtime/src/manifest.rs#4DF8]\n205:\n206:/// `[adapter]` — the manifest header, readable before any update (compat gate).\n207:#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]\n208:pub struct Adapter {\n209:    pub name: String,\n210:    #[serde(default)]\n211:    pub kind: AdapterKind,\n212:    pub version: String,\n213:    /// Lowest spt-core version this adapter tolerates (compat gate).\n214:    pub min_spt_core_version: String,\n215:    /// Endpoint types this adapter can host (`LiveAgent`, `Worker`, …).\n216:    #[serde(default, skip_serializing_if = \"Vec::is_empty\")]\n217:    pub hostable_types: Vec<String>,\n218:    /// Optional basename for the `spt endpoint run` picker's `<basename>-<id>`\n219:    /// launcher shortcut. Absent ⇒ the harness-agnostic default\n220:    /// `spt` (→ `spt-<id>`); an adapter sets this to brand its shortcuts\n221:    /// (spt-claude-code → `cc`, giving `cc-<id>`). Additive + N-1-safe (omitted\n222:    /// from serialization when absent). The picker reads it from the RESOLVED\n223:    /// manifest of the selected adapter.\n224:    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n225:    pub shortcut_basename: Option<String>,\n226:    /// The harness executable basenames this `kind=\"harness\"` adapter hosts\n227:    /// agents inside (e.g. `host_binaries = [\"claude\"]`). This is the bind-time\n228:    /// adapter-resolution match-key: a harness session's parent pid → its exe\n229:    /// basename selects the candidate adapters\n230:    /// whose `host_binaries` contains it (case-insensitive, `.exe`-stripped), so\n231:    /// `listen`/`poll` resolve the owning adapter at bind with no mandatory\n232:    /// `--adapter`. Additive + N-1-safe (omitted from serialization when empty,\n233:    /// like `shortcut_basename`); an empty list is harmless (the adapter is never\n234:    /// a bind-time candidate, only reachable via the explicit `--adapter`).\n235:    #[serde(default, skip_serializing_if = \"Vec::is_empty\")]\n236:    pub host_binaries: Vec<String>,\n237:}\n238:\n239:/// The two adapter kinds. A `harness` hosts agents; a `shell` provides a driven\n240:/// surface (MANIFEST §Shell adapters).\n241:#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]\n242:#[serde(rename_all = \"snake_case\")]\n243:pub enum AdapterKind {\n244:    #[default]\n245:    Harness,\n246:    Shell,\n247:}\n248:\n249:/// `[hooks.<event>]` — one harness event → the `api` command it fires, the\n250:/// stdin fields it maps, and whether it can surface context to the agent.\n251:#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]\n252:pub struct Hook {\n253:    /// Opaque `api …` command line the harness invokes for this event.\n254:    pub fires: String,\n255:    #[serde(default, skip_serializing_if = \"Vec::is_empty\")]\n256:    pub reads: Vec<String>,\n257:    /// Whether this hook can inject context (false ⇒ sentinel/relay fallback).\n258:    #[serde(default)]\n259:    pub can_inject: bool,\n260:}\n261:\n262:/// `[[hints]]` — one once-per-session keyword hint (CONTEXT.md §keyword hints).\n263:/// The adapter's user-prompt hook pipes the full user message to `spt api hint`;\n264:/// a matching keyword surfaces `text` to the agent's context channel, at most\n265:/// once per session and once per message.\n266:#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]\n267:pub struct Hint {\n268:    /// Keywords that fire the hint — literal **case-insensitive substrings** by\n269:    /// default; compiled as **regex** patterns when `regex = true`.\n270:    #[serde(default, skip_serializing_if = \"Vec::is_empty\")]\n271:    pub keywords: Vec<String>,\n272:    /// The hint text surfaced when a keyword matches.\n273:    pub text: String,\n274:    /// Treat `keywords` as regex patterns instead of literal substrings.\n275:    #[serde(default)]\n276:    pub regex: bool,\n277:}\n278:\n\n[Showing lines 205-278 of 2772. Use :279 to continue]"}],"details":{"truncation":{"content":"\r\n/// `[adapter]` — the manifest header, readable before any update (compat gate).\r\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]\r\npub struct Adapter {\r\n    pub name: String,\r\n    #[serde(default)]\r\n    pub kind: AdapterKind,\r\n    pub version: String,\r\n    /// Lowest spt-core version this adapter tolerates (compat gate).\r\n    pub min_spt_core_version: String,\r\n    /// Endpoint types this adapter can host (`LiveAgent`, `Worker`, …).\r\n    #[serde(default, skip_serializing_if = \"Vec::is_empty\")]\r\n    pub hostable_types: Vec<String>,\r\n    /// Optional basename for the `spt endpoint run` picker's `<basename>-<id>`\r\n    /// launcher shortcut. Absent ⇒ the harness-agnostic default\r\n    /// `spt` (→ `spt-<id>`); an adapter sets this to brand its shortcuts\r\n    /// (spt-claude-code → `cc`, giving `cc-<id>`). Additive + N-1-safe (omitted\r\n    /// from serialization when absent). The picker reads it from the RESOLVED\r\n    /// manifest of the selected adapter.\r\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\r\n    pub shortcut_basename: Option<String>,\r\n    /// The harness executable basenames this `kind=\"harness\"` adapter hosts\r\n    /// agents inside (e.g. `host_binaries = [\"claude\"]`). This is the bind-time\r\n    /// adapter-resolution match-key: a harness session's parent pid → its exe\r\n    /// basename selects the candidate adapters\r\n    /// whose `host_binaries` contains it (case-insensitive, `.exe`-stripped), so\r\n    /// `listen`/`poll` resolve the owning adapter at bind with no mandatory\r\n    /// `--adapter`. Additive + N-1-safe (omitted from serialization when empty,\r\n    /// like `shortcut_basename`); an empty list is harmless (the adapter is never\r\n    /// a bind-time candidate, only reachable via the explicit `--adapter`).\r\n    #[serde(default, skip_serializing_if = \"Vec::is_empty\")]\r\n    pub host_binaries: Vec<String>,\r\n}\r\n\r\n/// The two adapter kinds. A `harness` hosts agents; a `shell` provides a driven\r\n/// surface (MANIFEST §Shell adapters).\r\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]\r\n#[serde(rename_all = \"snake_case\")]\r\npub enum AdapterKind {\r\n    #[default]\r\n    Harness,\r\n    Shell,\r\n}\r\n\r\n/// `[hooks.<event>]` — one harness event → the `api` command it fires, the\r\n/// stdin fields it maps, and whether it can surface context to the agent.\r\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]\r\npub struct Hook {\r\n    /// Opaque `api …` command line the harness invokes for this event.\r\n    pub fires: String,\r\n    #[serde(default, skip_serializing_if = \"Vec::is_empty\")]\r\n    pub reads: Vec<String>,\r\n    /// Whether this hook can inject context (false ⇒ sentinel/relay fallback).\r\n    #[serde(default)]\r\n    pub can_inject: bool,\r\n}\r\n\r\n/// `[[hints]]` — one once-per-session keyword hint (CONTEXT.md §keyword hints).\r\n/// The adapter's user-prompt hook pipes the full user message to `spt api hint`;\r\n/// a matching keyword surfaces `text` to the agent's context channel, at most\r\n/// once per session and once per message.\r\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]\r\npub struct Hint {\r\n    /// Keywords that fire the hint — literal **case-insensitive substrings** by\r\n    /// default; compiled as **regex** patterns when `regex = true`.\r\n    #[serde(default, skip_serializing_if = \"Vec::is_empty\")]\r\n    pub keywords: Vec<String>,\r\n    /// The hint text surfaced when a keyword matches.\r\n    pub text: String,\r\n    /// Treat `keywords` as regex patterns instead of literal substrings.\r\n    #[serde(default)]\r\n    pub regex: bool,\r\n}\r\n\r","truncated":true,"truncatedBy":"lines","totalLines":2568,"totalBytes":3640,"outputLines":74,"outputBytes":3640,"lastLinePartial":false,"firstLineExceedsLimit":false},"totalLines":2772,"displayContent":{"text":"\r\n/// `[adapter]` — the manifest header, readable before any update (compat gate).\r\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]\r\npub struct Adapter {\r\n    pub name: String,\r\n    #[serde(default)]\r\n    pub kind: AdapterKind,\r\n    pub version: String,\r\n    /// Lowest spt-core version this adapter tolerates (compat gate).\r\n    pub min_spt_core_version: String,\r\n    /// Endpoint types this adapter can host (`LiveAgent`, `Worker`, …).\r\n    #[serde(default, skip_serializing_if = \"Vec::is_empty\")]\r\n    pub hostable_types: Vec<String>,\r\n    /// Optional basename for the `spt endpoint run` picker's `<basename>-<id>`\r\n    /// launcher shortcut. Absent ⇒ the harness-agnostic default\r\n    /// `spt` (→ `spt-<id>`); an adapter sets this to brand its shortcuts\r\n    /// (spt-claude-code → `cc`, giving `cc-<id>`). Additive + N-1-safe (omitted\r\n    /// from serialization when absent). The picker reads it from the RESOLVED\r\n    /// manifest of the selected adapter.\r\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\r\n    pub shortcut_basename: Option<String>,\r\n    /// The harness executable basenames this `kind=\"harness\"` adapter hosts\r\n    /// agents inside (e.g. `host_binaries = [\"claude\"]`). This is the bind-time\r\n    /// adapter-resolution match-key: a harness session's parent pid → its exe\r\n    /// basename selects the candidate adapters\r\n    /// whose `host_binaries` contains it (case-insensitive, `.exe`-stripped), so\r\n    /// `listen`/`poll` resolve the owning adapter at bind with no mandatory\r\n    /// `--adapter`. Additive + N-1-safe (omitted from serialization when empty,\r\n    /// like `shortcut_basename`); an empty list is harmless (the adapter is never\r\n    /// a bind-time candidate, only reachable via the explicit `--adapter`).\r\n    #[serde(default, skip_serializing_if = \"Vec::is_empty\")]\r\n    pub host_binaries: Vec<String>,\r\n}\r\n\r\n/// The two adapter kinds. A `harness` hosts agents; a `shell` provides a driven\r\n/// surface (MANIFEST §Shell adapters).\r\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]\r\n#[serde(rename_all = \"snake_case\")]\r\npub enum AdapterKind {\r\n    #[default]\r\n    Harness,\r\n    Shell,\r\n}\r\n\r\n/// `[hooks.<event>]` — one harness event → the `api` command it fires, the\r\n/// stdin fields it maps, and whether it can surface context to the agent.\r\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]\r\npub struct Hook {\r\n    /// Opaque `api …` command line the harness invokes for this event.\r\n    pub fires: String,\r\n    #[serde(default, skip_serializing_if = \"Vec::is_empty\")]\r\n    pub reads: Vec<String>,\r\n    /// Whether this hook can inject context (false ⇒ sentinel/relay fallback).\r\n    #[serde(default)]\r\n    pub can_inject: bool,\r\n}\r\n\r\n/// `[[hints]]` — one once-per-session keyword hint (CONTEXT.md §keyword hints).\r\n/// The adapter's user-prompt hook pipes the full user message to `spt api hint`;\r\n/// a matching keyword surfaces `text` to the agent's context channel, at most\r\n/// once per session and once per message.\r\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]\r\npub struct Hint {\r\n    /// Keywords that fire the hint — literal **case-insensitive substrings** by\r\n    /// default; compiled as **regex** patterns when `regex = true`.\r\n    #[serde(default, skip_serializing_if = \"Vec::is_empty\")]\r\n    pub keywords: Vec<String>,\r\n    /// The hint text surfaced when a keyword matches.\r\n    pub text: String,\r\n    /// Treat `keywords` as regex patterns instead of literal substrings.\r\n    #[serde(default)]\r\n    pub regex: bool,\r\n}\r\n\r","startLine":205,"lineNumbers":[205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278]},"fileSize":129946,"meta":{"source":{"type":"path","value":"C:/Users/decid/Documents/projects/spt-core/.worktrees/ws272-w3-drift/crates/spt-runtime/src/manifest.rs"},"truncation":{"direction":"head","truncatedBy":"lines","totalLines":2772,"totalBytes":3640,"outputLines":74,"outputBytes":3640,"shownRange":{"start":205,"end":278},"nextOffset":279}}},"isError":false,"timestamp":1788711257439}}
{"type":"message","id":"0d366888","parentId":"e7c843bf","timestamp":"2026-09-06T16:14:17.456Z","message":{"role":"toolResult","toolCallId":"call_YniYI5WXthKEFHbBqyeFJwSO|fc_07548ebf196ed5a1016a9d9159511087d18a912a4379417254","toolName":"read","content":[{"type":"text","text":"[.worktrees/ws272-w3-drift/docs/MANIFEST.md#13D4]\n270:### `[digest]` — session-digest extractor (ADR-0019)\n…\n328:To proof a **DEV build that is not registered** (a freshly built binary beside a\n329:hand-written `manifest.toml`, or a bare-file `gh_release` adapter never staged\n330:into an extracted install), pass `--dir <install-dir>` (binaries resolve there;\n331:manifest defaults to `<dir>/manifest.toml`) or `--manifest <file>` (pins the\n332:manifest; its parent is the install dir) to either `digest-proof` or\n333:`translate-proof`. With neither, the command resolves the registered adapter.\n334:\n335:### `[strings]` — adapter string values (M9 / file-backed M12-W3)\n336:An adapter-authored KV tree, dot-path-readable by anything on the node via `spt adapter get-string <adapter[:profile]> <key.path>` (data only — never executed; node-local). Resolves through the same leaf-replace profile overlay as the rest of the manifest. A value MAY be an inline literal **or** a file pointer — a value-position table with **exactly one** key `file`:\n337:```toml\n338:[strings]\n339:greeting = \"hello\"                       # inline\n340:skills.whoami = { file = \"whoami.md\" }   # pointer → get-string returns the file's CONTENTS\n341:```\n342:Pointer files live in `adapters/<adapter>/strings/`, referenced by a path that **must stay inside** that dir (`..`/absolute refused at registration). Validated at register (fail-fast), read lazily at get-string (live edits reflect), skip-diagnoses a missing file at read (mirrors `[digest]`). A **local** profile's pointers resolve against the user-owned local-profile dir (update-safe). Published reference: [manifest.md `[strings]`](../docs-site/src/harness-contract/manifest.md#strings--adapter-string-values--profiles).\n343:\n344:<!-- [doc->REQ-MANIFEST-SUBST] -->\n345:**Lazy substitution inside `[strings]` values (since v0.16.0).** A `[strings]` value (inline literal or the read-back contents of a pointer file) may contain the **adapter-static** placeholders `{adapter_dir}` / `{adapter_name}`; `get-string` substitutes them at read time and returns the resolved string. This is scoped to **those two keys only** — `get-string` carries no session context, so session-scoped keys (`{id}`, `{session_id}`, …) are **not** available here and an attempt to use one is left untouched / errors rather than silently emptying. The data is still never executed (node-local, data-only): spt-core resolves the path and hands it back; the caller decides what to do with it.\n346:\n347:```toml\n348:[strings]\n349:# adapter stores its own hook-dispatch command, resolved at get-string time\n350:hook_cmd = \"{adapter_dir}/claude-spt hook\"\n351:```\n352:\n353:A harness hook dispatcher runs `spt adapter get-string claude-spt hook_cmd` **once per session** (e.g. memoized into an env var for a hot-path hook like PostToolUse), getting back the fully-resolved `\"<install_dir>/claude-spt hook\"`, then executes that itself per-hook. spt-core never grows a hook-execution surface — it only resolves (ADR-0029).\n354:\n355:### `[inject]` — inject-input methods\n356:Per activity-state, any combination: `pty`, `hook`, `relay`, `http`.\n357:```toml\n358:[inject]\n359:activity = [\"hook\"]                # non-disruptive during work\n360:idle     = [\"pty\", \"hook\"]\n361:```\n362:\n363:<!-- [doc->REQ-IO-SHORTFORM-GATE] -->\n364:### `[io]` — IO-funnel compliance (releases#22, operator rulings 8-9)\n365:Opt-in, and **absent means core parses NOTHING of this adapter's ingest**. The declaration is the adapter's statement that its own local tag parsers are gone as of this release, which is what lets core start parsing without a window in which both parse the same text and dispatch it twice.\n366:```toml\n367:[io]\n368:compliance = true      # core may parse this adapter's ingest\n369:shortform  = false     # optional opt-out; absent ⇒ enabled\n370:```\n…\n388:<!-- [doc->REQ-MSG-IDLE-TRANSLATION-BINARY] -->\n\n[Showing lines 329-368 of 787. Use :369 to continue]"}],"details":{"truncation":{"content":"hand-written `manifest.toml`, or a bare-file `gh_release` adapter never staged\r\ninto an extracted install), pass `--dir <install-dir>` (binaries resolve there;\r\nmanifest defaults to `<dir>/manifest.toml`) or `--manifest <file>` (pins the\r\nmanifest; its parent is the install dir) to either `digest-proof` or\r\n`translate-proof`. With neither, the command resolves the registered adapter.\r\n\r\n### `[strings]` — adapter string values (M9 / file-backed M12-W3)\r\nAn adapter-authored KV tree, dot-path-readable by anything on the node via `spt adapter get-string <adapter[:profile]> <key.path>` (data only — never executed; node-local). Resolves through the same leaf-replace profile overlay as the rest of the manifest. A value MAY be an inline literal **or** a file pointer — a value-position table with **exactly one** key `file`:\r\n```toml\r\n[strings]\r\ngreeting = \"hello\"                       # inline\r\nskills.whoami = { file = \"whoami.md\" }   # pointer → get-string returns the file's CONTENTS\r\n```\r\nPointer files live in `adapters/<adapter>/strings/`, referenced by a path that **must stay inside** that dir (`..`/absolute refused at registration). Validated at register (fail-fast), read lazily at get-string (live edits reflect), skip-diagnoses a missing file at read (mirrors `[digest]`). A **local** profile's pointers resolve against the user-owned local-profile dir (update-safe). Published reference: [manifest.md `[strings]`](../docs-site/src/harness-contract/manifest.md#strings--adapter-string-values--profiles).\r\n\r\n<!-- [doc->REQ-MANIFEST-SUBST] -->\r\n**Lazy substitution inside `[strings]` values (since v0.16.0).** A `[strings]` value (inline literal or the read-back contents of a pointer file) may contain the **adapter-static** placeholders `{adapter_dir}` / `{adapter_name}`; `get-string` substitutes them at read time and returns the resolved string. This is scoped to **those two keys only** — `get-string` carries no session context, so session-scoped keys (`{id}`, `{session_id}`, …) are **not** available here and an attempt to use one is left untouched / errors rather than silently emptying. The data is still never executed (node-local, data-only): spt-core resolves the path and hands it back; the caller decides what to do with it.\r\n\r\n```toml\r\n[strings]\r\n# adapter stores its own hook-dispatch command, resolved at get-string time\r\nhook_cmd = \"{adapter_dir}/claude-spt hook\"\r\n```\r\n\r\nA harness hook dispatcher runs `spt adapter get-string claude-spt hook_cmd` **once per session** (e.g. memoized into an env var for a hot-path hook like PostToolUse), getting back the fully-resolved `\"<install_dir>/claude-spt hook\"`, then executes that itself per-hook. spt-core never grows a hook-execution surface — it only resolves (ADR-0029).\r\n\r\n### `[inject]` — inject-input methods\r\nPer activity-state, any combination: `pty`, `hook`, `relay`, `http`.\r\n```toml\r\n[inject]\r\nactivity = [\"hook\"]                # non-disruptive during work\r\nidle     = [\"pty\", \"hook\"]\r\n```\r\n\r\n<!-- [doc->REQ-IO-SHORTFORM-GATE] -->\r\n### `[io]` — IO-funnel compliance (releases#22, operator rulings 8-9)\r\nOpt-in, and **absent means core parses NOTHING of this adapter's ingest**. The declaration is the adapter's statement that its own local tag parsers are gone as of this release, which is what lets core start parsing without a window in which both parse the same text and dispatch it twice.\r\n```toml\r\n[io]\r\ncompliance = true      # core may parse this adapter's ingest\r","truncated":true,"truncatedBy":"lines","totalLines":459,"totalBytes":3481,"outputLines":40,"outputBytes":3481,"lastLinePartial":false,"firstLineExceedsLimit":false},"totalLines":787,"displayContent":{"text":"### `[digest]` — session-digest extractor (ADR-0019)\n…\nTo proof a **DEV build that is not registered** (a freshly built binary beside a\nhand-written `manifest.toml`, or a bare-file `gh_release` adapter never staged\r\ninto an extracted install), pass `--dir <install-dir>` (binaries resolve there;\r\nmanifest defaults to `<dir>/manifest.toml`) or `--manifest <file>` (pins the\r\nmanifest; its parent is the install dir) to either `digest-proof` or\r\n`translate-proof`. With neither, the command resolves the registered adapter.\r\n\r\n### `[strings]` — adapter string values (M9 / file-backed M12-W3)\r\nAn adapter-authored KV tree, dot-path-readable by anything on the node via `spt adapter get-string <adapter[:profile]> <key.path>` (data only — never executed; node-local). Resolves through the same leaf-replace profile overlay as the rest of the manifest. A value MAY be an inline literal **or** a file pointer — a value-position table with **exactly one** key `file`:\r\n```toml\r\n[strings]\r\ngreeting = \"hello\"                       # inline\r\nskills.whoami = { file = \"whoami.md\" }   # pointer → get-string returns the file's CONTENTS\r\n```\r\nPointer files live in `adapters/<adapter>/strings/`, referenced by a path that **must stay inside** that dir (`..`/absolute refused at registration). Validated at register (fail-fast), read lazily at get-string (live edits reflect), skip-diagnoses a missing file at read (mirrors `[digest]`). A **local** profile's pointers resolve against the user-owned local-profile dir (update-safe). Published reference: [manifest.md `[strings]`](../docs-site/src/harness-contract/manifest.md#strings--adapter-string-values--profiles).\r\n\r\n<!-- [doc->REQ-MANIFEST-SUBST] -->\r\n**Lazy substitution inside `[strings]` values (since v0.16.0).** A `[strings]` value (inline literal or the read-back contents of a pointer file) may contain the **adapter-static** placeholders `{adapter_dir}` / `{adapter_name}`; `get-string` substitutes them at read time and returns the resolved string. This is scoped to **those two keys only** — `get-string` carries no session context, so session-scoped keys (`{id}`, `{session_id}`, …) are **not** available here and an attempt to use one is left untouched / errors rather than silently emptying. The data is still never executed (node-local, data-only): spt-core resolves the path and hands it back; the caller decides what to do with it.\r\n\r\n```toml\r\n[strings]\r\n# adapter stores its own hook-dispatch command, resolved at get-string time\r\nhook_cmd = \"{adapter_dir}/claude-spt hook\"\r\n```\r\n\r\nA harness hook dispatcher runs `spt adapter get-string claude-spt hook_cmd` **once per session** (e.g. memoized into an env var for a hot-path hook like PostToolUse), getting back the fully-resolved `\"<install_dir>/claude-spt hook\"`, then executes that itself per-hook. spt-core never grows a hook-execution surface — it only resolves (ADR-0029).\r\n\r\n### `[inject]` — inject-input methods\r\nPer activity-state, any combination: `pty`, `hook`, `relay`, `http`.\r\n```toml\r\n[inject]\r\nactivity = [\"hook\"]                # non-disruptive during work\r\nidle     = [\"pty\", \"hook\"]\r\n```\r\n\r\n<!-- [doc->REQ-IO-SHORTFORM-GATE] -->\r\n### `[io]` — IO-funnel compliance (releases#22, operator rulings 8-9)\r\nOpt-in, and **absent means core parses NOTHING of this adapter's ingest**. The declaration is the adapter's statement that its own local tag parsers are gone as of this release, which is what lets core start parsing without a window in which both parse the same text and dispatch it twice.\r\n```toml\r\n[io]\r\ncompliance = true      # core may parse this adapter's ingest\r\nshortform  = false     # optional opt-out; absent ⇒ enabled\n```\n…\n<!-- [doc->REQ-MSG-IDLE-TRANSLATION-BINARY] -->","startLine":270,"lineNumbers":[270,null,328,329,330,331,332,333,334,335,336,337,338,339,340,341,342,343,344,345,346,347,348,349,350,351,352,353,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,null,388]},"fileSize":70807,"meta":{"source":{"type":"path","value":"C:/Users/decid/Documents/projects/spt-core/.worktrees/ws272-w3-drift/docs/MANIFEST.md"},"truncation":{"direction":"head","truncatedBy":"lines","totalLines":787,"totalBytes":3481,"outputLines":40,"outputBytes":3481,"shownRange":{"start":329,"end":368},"nextOffset":369}}},"isError":false,"timestamp":1788711257456}}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_i43lKedQ8K02y77sdT1aixzs|fc_07548ebf196ed5a1016a9d915b6e2487d1946a490a74a8feab","toolName":"hub","startedAt":"2026-09-06T16:14:22.122Z","intent":"Reporting manifest placement fork"},"id":"baf759fa","parentId":"0d366888","timestamp":"2026-09-06T16:14:22.122Z"}
{"type":"message","id":"92769996","parentId":"baf759fa","timestamp":"2026-09-06T16:14:22.123Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"call_i43lKedQ8K02y77sdT1aixzs|fc_07548ebf196ed5a1016a9d915b6e2487d1946a490a74a8feab","name":"hub","arguments":{"i":"Reporting manifest placement fork","op":"send","to":"Main","message":"Before edits: full rider/JIT only rule manifest key docs_dir, not table; Adapter currently has no field and docs/MANIFEST only provides strings containment analogy. Cannot infer [adapter] versus root authoritatively. Need pending producer placement confirmation before adding escape cells. Can add absent-docs valid identity cell independently."},"intent":"Reporting manifest placement fork"}],"api":"openai-codex-responses","provider":"openai-codex","model":"gpt-6-astra","usage":{"input":5199,"output":96,"cacheRead":29056,"cacheWrite":0,"totalTokens":34351,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1788711257515,"responseId":"resp_07548ebf196ed5a1016a9d9159daa887d1ba59ec7b9b2262b1","providerPayload":{"type":"openaiResponsesHistory","provider":"openai-codex","dt":true,"items":[{"id":"fc_07548ebf196ed5a1016a9d915b6e2487d1946a490a74a8feab","type":"function_call","status":"completed","arguments":"{\"i\":\"Reporting manifest placement fork\",\"op\":\"send\",\"to\":\"Main\",\"message\":\"Before edits: full rider/JIT only rule manifest key docs_dir, not table; Adapter currently has no field and docs/MANIFEST only provides strings containment analogy. Cannot infer [adapter] versus root authoritatively. Need pending producer placement confirmation before adding escape cells. Can add absent-docs valid identity cell independently.\"}","call_id":"call_i43lKedQ8K02y77sdT1aixzs","internal_chat_message_metadata_passthrough":{"create_time":1788711258.076811,"turn_id":"0b3b77b9-eace-409f-9a5d-491fd9ffe825"},"name":"hub","metadata":{"turn_id":"0b3b77b9-eace-409f-9a5d-491fd9ffe825"}}]},"duration":4602.98919999972,"ttft":1948.6139000002295,"completedAt":1788711262120,"contextSnapshot":{"promptTokens":34255,"nonMessageTokens":16645,"compactionEpoch":0}}}
{"type":"message","id":"adfdf478","parentId":"92769996","timestamp":"2026-09-06T16:14:22.125Z","message":{"role":"toolResult","toolCallId":"call_i43lKedQ8K02y77sdT1aixzs|fc_07548ebf196ed5a1016a9d915b6e2487d1946a490a74a8feab","toolName":"hub","content":[{"type":"text","text":"Delivered to 1 peer(s):\n- Main: injected"}],"details":{"op":"send","from":"DocsEscapeRed","to":"Main","receipts":[{"to":"Main","outcome":"injected"}]},"isError":false,"timestamp":1788711262125}}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_1rjWxX93PyAfg1MtRADqs7Dx|fc_07548ebf196ed5a1016a9d916633b487d190ce8f1c86fcf121","toolName":"read","startedAt":"2026-09-06T16:14:30.295Z","args":{"path":"skill://tdd/tests.md"},"intent":"Reading test design examples"},"id":"49ff70df","parentId":"adfdf478","timestamp":"2026-09-06T16:14:30.295Z"}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_YVN8lZyOnRbxcnvlGJIs9eWd|fc_07548ebf196ed5a1016a9d916633c487d18c3b0e5562c5cce3","toolName":"grep","startedAt":"2026-09-06T16:14:30.295Z","args":{"path":"C:/Users/decid/Documents/projects/spt-core/.worktrees/ws272-w3-drift/CONTEXT.md"},"intent":"Locating registration domain vocabulary"},"id":"f9824650","parentId":"49ff70df","timestamp":"2026-09-06T16:14:30.295Z"}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_P8HfZxjvtfm1MICvLpcNJqnH|fc_07548ebf196ed5a1016a9d916633cc87d1ac4eb85f2770b627","toolName":"read","startedAt":"2026-09-06T16:14:30.295Z","args":{"path":"C:/Users/decid/Documents/projects/spt-core/.worktrees/ws272-w3-drift/crates/spt-runtime/src/registry.rs:294-407,1000-1080"},"intent":"Reading register return contract"},"id":"0b16ff4f","parentId":"f9824650","timestamp":"2026-09-06T16:14:30.295Z"}
{"type":"message","id":"4f232356","parentId":"0b16ff4f","timestamp":"2026-09-06T16:14:30.296Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"**Checking tests and context**"},{"type":"toolCall","id":"call_1rjWxX93PyAfg1MtRADqs7Dx|fc_07548ebf196ed5a1016a9d916633b487d190ce8f1c86fcf121","name":"read","arguments":{"i":"Reading test design examples","path":"skill://tdd/tests.md"},"intent":"Reading test design examples"},{"type":"toolCall","id":"call_YVN8lZyOnRbxcnvlGJIs9eWd|fc_07548ebf196ed5a1016a9d916633c487d18c3b0e5562c5cce3","name":"grep","arguments":{"i":"Locating registration domain vocabulary","pattern":"adapter|manifest","path":"C:/Users/decid/Documents/projects/spt-core/.worktrees/ws272-w3-drift/CONTEXT.md","case":false,"gitignore":true,"skip":0},"intent":"Locating registration domain vocabulary"},{"type":"toolCall","id":"call_P8HfZxjvtfm1MICvLpcNJqnH|fc_07548ebf196ed5a1016a9d916633cc87d1ac4eb85f2770b627","name":"read","arguments":{"i":"Reading register return contract","path":"C:/Users/decid/Documents/projects/spt-core/.worktrees/ws272-w3-drift/crates/spt-runtime/src/registry.rs:294-407,1000-1080"},"intent":"Reading register return contract"}],"api":"openai-codex-responses","provider":"openai-codex","model":"gpt-6-astra","usage":{"input":197,"output":223,"cacheRead":34176,"cacheWrite":0,"totalTokens":34596,"reasoningTokens":54,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1788711262177,"responseId":"resp_07548ebf196ed5a1016a9d915e6db887d1a4cd47dcd2901f10","providerPayload":{"type":"openaiResponsesHistory","provider":"openai-codex","dt":true,"items":[{"id":"rs_07548ebf196ed5a1016a9d915f889887d193e3f70b10642686","type":"reasoning","content":[],"encrypted_content":"gAAAAABqnZFmkNYlP28W4XQ2GuHoe9TNmvQ6R4a7fAqy2mybs4IpcHPR5xNCS-xNfhX4SmaeuDEGr9GtInrKHGURaOcK0thEHk1kKkLfHaxoh5w2SI6MyuYCE2bg2mkHMf-rRBkcBYRDvXGv_5ny5O-m-EbeeA7IWO9q-Pf5ccnWQYEn2R9OV0QXGKbBMeZ0hHwcHANu6k6rZJ24KibgOll2dkMrS99ardPo62Bh8p7xN0uZKpxABqFR_XPzjE2Mmshm6h3KE0CQVprRPm8DYGN1x6FpGs-yDQSjt2MwCIR5YEanVrmTG8SipTEdTOJl8ONOQcd2lWLfsgKI90PHqyVKZ4Iyy99f3TZhbdUUoX0MO1NKjfnL4skIFb5RgiHaPcKPXOQyHLMO3UPDwNffw-aDxPwvRYnKBnLUhosdgEYEUVHvVf2ws7MtxzNDZIDWMmqEd4E25bYu_mASmGN4IA4FZ7nlxInAu37hLPvxLYc1Pn9Ng8f9yNqnzmQunL01mxncLfGnzvW5fXCoXHCk1n6oUtzczjQk93fEICseThg0ziyMTMrV2UaerC4S3VpT-zgognXat1pV9lAO6lZPiwXMFW2uPWn7C-EedZb_C7sTM28qCooa6nU_4FBzcfz6khxMsZG3g8k4LkG0TzP0b_wn1y44pBOfgXJ8PJ_1ZwpLYgspBNnqvbI7LEk6C4T8S8MqYk0k04uMz72bDeMWUL5tugNTIJaGwesytTDo1c6fCJTXR8JtNuTTPvbdTUJzJoAEW5BPX5Pr95UL1GJmybt03i2dI0A2M0odUxzKKuPESHAtL1IPXdsAzUajMdBwOu4yqoFtBdnaTx8POTSIEBo_pU4pnk9cFXteLqK25aoP6Bzq902TzqoAs3-4BYp2tJdb8mtxOCBJz-38IvomBW9cTC2i8JqlnN61Ixg44Z-wNWuIqjGEVXAHF-JimjlzLXy38kt8b5Mh-oKkpTqNTB6Bnppl_f-tFvWxcHKWrYZzkLVscddQ8HGpBA4VOYirnZSm1YzpHS6dMvKCF0gxx2CkKK9i2ZmOUtUCgaaAksndP8cz03uow3kzOxYHnPVEmqHfhhyLZCyj4SuwNTnQLop1t4B5XBkG4KQ0XoYyeIb_XEyX0jV2ZaBp48mst_NW1mjD_moYMKT5oU1rcS7EPtgdYfbu5_gCan9nSb6vGT4DkDQ-lvpJQ7blKnPGBO5yMD95MTapt1h6fGvd9qH9H3bAJRQR64E6R2-LKEnim28eEcpJQkpaHglSd33kDvOoWDRzqJzl_5OOqU7m-IDxnd7MBIMty7p5B8x4aHAIZQOba6ZihZ5lrEwuoCaOmdfYgkeh-FdfBtjoo55RjiWSOkMLpzELfQjN1Ovi-7y19ekQ1yx5CpBNb4z8DYf85ClJyvXM-KDw9XTNct-qgEPn9bsho-WeEpXxFdZaEl_9hWG4I0_r8bzFGABb0AI5hrEcorhq4qgJxsezGZPuVppTSFxC3td3DItEfDpY04ntLZgpC27CjnP2R6Hj-s4RNKlC5TuGr40qMBg78cVvhPg4l97AgWJUJ0OoDXchWruOnOszFFSFUsFRmVyhh19cz7lt9A7WFlqY_PP_z3Xkh0C_hNWjUshHPItdZ0XGDCz1KBM5aQdt5tOHHJmuzKrdtEiyDzMIwG4naUeW","internal_chat_message_metadata_passthrough":{"turn_id":"0b3b77b9-eace-409f-9a5d-491fd9ffe825"},"summary":[{"type":"summary_text","text":"**Checking tests and context**"}],"metadata":{"turn_id":"0b3b77b9-eace-409f-9a5d-491fd9ffe825"}},{"id":"fc_07548ebf196ed5a1016a9d916633b487d190ce8f1c86fcf121","type":"function_call","status":"completed","arguments":"{\"i\":\"Reading test design examples\",\"path\":\"skill://tdd/tests.md\"}","call_id":"call_1rjWxX93PyAfg1MtRADqs7Dx","internal_chat_message_metadata_passthrough":{"turn_id":"0b3b77b9-eace-409f-9a5d-491fd9ffe825"},"name":"read","metadata":{"turn_id":"0b3b77b9-eace-409f-9a5d-491fd9ffe825"}},{"id":"fc_07548ebf196ed5a1016a9d916633c487d18c3b0e5562c5cce3","type":"function_call","status":"completed","arguments":"{\"i\":\"Locating registration domain vocabulary\",\"pattern\":\"adapter|manifest\",\"path\":\"C:/Users/decid/Documents/projects/spt-core/.worktrees/ws272-w3-drift/CONTEXT.md\",\"case\":false,\"gitignore\":true,\"skip\":0}","call_id":"call_YVN8lZyOnRbxcnvlGJIs9eWd","internal_chat_message_metadata_passthrough":{"turn_id":"0b3b77b9-eace-409f-9a5d-491fd9ffe825"},"name":"grep","metadata":{"turn_id":"0b3b77b9-eace-409f-9a5d-491fd9ffe825"}},{"id":"fc_07548ebf196ed5a1016a9d916633cc87d1ac4eb85f2770b627","type":"function_call","status":"completed","arguments":"{\"i\":\"Reading register return contract\",\"path\":\"C:/Users/decid/Documents/projects/spt-core/.worktrees/ws272-w3-drift/crates/spt-runtime/src/registry.rs:294-407,1000-1080\"}","call_id":"call_P8HfZxjvtfm1MICvLpcNJqnH","internal_chat_message_metadata_passthrough":{"turn_id":"0b3b77b9-eace-409f-9a5d-491fd9ffe825"},"name":"read","metadata":{"turn_id":"0b3b77b9-eace-409f-9a5d-491fd9ffe825"}}]},"duration":8116.559399999678,"ttft":1378.0649999994785,"completedAt":1788711270294,"contextSnapshot":{"promptTokens":34373,"nonMessageTokens":16645,"compactionEpoch":0}}}
{"type":"message","id":"2fda33fe","parentId":"4f232356","timestamp":"2026-09-06T16:14:30.300Z","message":{"role":"toolResult","toolCallId":"call_1rjWxX93PyAfg1MtRADqs7Dx|fc_07548ebf196ed5a1016a9d916633b487d190ce8f1c86fcf121","toolName":"read","content":[{"type":"text","text":"# Good and Bad Tests\n\n## Good Tests\n\n**Integration-style**: Test through real interfaces, not mocks of internal parts.\n\n```typescript\n// GOOD: Tests observable behavior\ntest(\"user can checkout with valid cart\", async () => {\n  const cart = createCart();\n  cart.add(product);\n  const result = await checkout(cart, paymentMethod);\n  expect(result.status).toBe(\"confirmed\");\n});\n```\n\nCharacteristics:\n\n- Tests behavior users/callers care about\n- Uses public API only\n- Survives internal refactors\n- Describes WHAT, not HOW\n- One logical assertion per test\n\n## Bad Tests\n\n**Implementation-detail tests**: Coupled to internal structure.\n\n```typescript\n// BAD: Tests implementation details\ntest(\"checkout calls paymentService.process\", async () => {\n  const mockPayment = jest.mock(paymentService);\n  await checkout(cart, payment);\n  expect(mockPayment.process).toHaveBeenCalledWith(cart.total);\n});\n```\n\nRed flags:\n\n- Mocking internal collaborators\n- Testing private methods\n- Asserting on call counts/order\n- Test breaks when refactoring without behavior change\n- Test name describes HOW not WHAT\n- Verifying through external means instead of interface\n\n```typescript\n// BAD: Bypasses interface to verify\ntest(\"createUser saves to database\", async () => {\n  await createUser({ name: \"Alice\" });\n  const row = await db.query(\"SELECT * FROM users WHERE name = ?\", [\"Alice\"]);\n  expect(row).toBeDefined();\n});\n\n// GOOD: Verifies through interface\ntest(\"createUser makes user retrievable\", async () => {\n  const user = await createUser({ name: \"Alice\" });\n  const retrieved = await getUser(user.id);\n  expect(retrieved.name).toBe(\"Alice\");\n});\n```\n\n**Tautological tests**: Expected value restates the implementation, so the test passes by construction.\n\n```typescript\n// BAD: Expected value is recomputed the way the code computes it\ntest(\"calculateTotal sums line items\", () => {\n  const items = [{ price: 10 }, { price: 5 }];\n  const expected = items.reduce((sum, i) => sum + i.price, 0);\n  expect(calculateTotal(items)).toBe(expected);\n});\n\n// GOOD: Expected value is an independent, known literal\ntest(\"calculateTotal sums line items\", () => {\n  expect(calculateTotal([{ price: 10 }, { price: 5 }])).toBe(15);\n});\n```"}],"details":{"resolvedPath":"C:\\Users\\decid\\.agents\\skills\\tdd\\tests.md","contentType":"text/markdown","totalLines":77,"displayContent":{"text":"# Good and Bad Tests\r\n\r\n## Good Tests\r\n\r\n**Integration-style**: Test through real interfaces, not mocks of internal parts.\r\n\r\n```typescript\r\n// GOOD: Tests observable behavior\r\ntest(\"user can checkout with valid cart\", async () => {\r\n  const cart = createCart();\r\n  cart.add(product);\r\n  const result = await checkout(cart, paymentMethod);\r\n  expect(result.status).toBe(\"confirmed\");\r\n});\r\n```\r\n\r\nCharacteristics:\r\n\r\n- Tests behavior users/callers care about\r\n- Uses public API only\r\n- Survives internal refactors\r\n- Describes WHAT, not HOW\r\n- One logical assertion per test\r\n\r\n## Bad Tests\r\n\r\n**Implementation-detail tests**: Coupled to internal structure.\r\n\r\n```typescript\r\n// BAD: Tests implementation details\r\ntest(\"checkout calls paymentService.process\", async () => {\r\n  const mockPayment = jest.mock(paymentService);\r\n  await checkout(cart, payment);\r\n  expect(mockPayment.process).toHaveBeenCalledWith(cart.total);\r\n});\r\n```\r\n\r\nRed flags:\r\n\r\n- Mocking internal collaborators\r\n- Testing private methods\r\n- Asserting on call counts/order\r\n- Test breaks when refactoring without behavior change\r\n- Test name describes HOW not WHAT\r\n- Verifying through external means instead of interface\r\n\r\n```typescript\r\n// BAD: Bypasses interface to verify\r\ntest(\"createUser saves to database\", async () => {\r\n  await createUser({ name: \"Alice\" });\r\n  const row = await db.query(\"SELECT * FROM users WHERE name = ?\", [\"Alice\"]);\r\n  expect(row).toBeDefined();\r\n});\r\n\r\n// GOOD: Verifies through interface\r\ntest(\"createUser makes user retrievable\", async () => {\r\n  const user = await createUser({ name: \"Alice\" });\r\n  const retrieved = await getUser(user.id);\r\n  expect(retrieved.name).toBe(\"Alice\");\r\n});\r\n```\r\n\r\n**Tautological tests**: Expected value restates the implementation, so the test passes by construction.\r\n\r\n```typescript\r\n// BAD: Expected value is recomputed the way the code computes it\r\ntest(\"calculateTotal sums line items\", () => {\r\n  const items = [{ price: 10 }, { price: 5 }];\r\n  const expected = items.reduce((sum, i) => sum + i.price, 0);\r\n  expect(calculateTotal(items)).toBe(expected);\r\n});\r\n\r\n// GOOD: Expected value is an independent, known literal\r\ntest(\"calculateTotal sums line items\", () => {\r\n  expect(calculateTotal([{ price: 10 }, { price: 5 }])).toBe(15);\r\n});\r\n```\r","startLine":1,"lineNumbers":[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77]},"meta":{"source":{"type":"internal","value":"skill://tdd/tests.md"}}},"isError":false,"timestamp":1788711270300}}
{"type":"message","id":"1f67d7f9","parentId":"2fda33fe","timestamp":"2026-09-06T16:14:30.303Z","message":{"role":"toolResult","toolCallId":"call_P8HfZxjvtfm1MICvLpcNJqnH|fc_07548ebf196ed5a1016a9d916633cc87d1ac4eb85f2770b627","toolName":"read","content":[{"type":"text","text":"[.worktrees/ws272-w3-drift/crates/spt-runtime/src/registry.rs#5C21]\n294:/// Validate-first: an invalid manifest records **nothing**. Mode falls out of\n295:/// the `[update]` avenue — `delegated` ⇒ pointer, `file_pull`/absent ⇒ copy.\n296:/// Re-adding an existing name refreshes the record and re-activates a\n297:/// soft-deregistered one. Returns the record; the caller conducts\n298:/// install-is-first-update through the manifest's `[update]` avenue.\n299:// [impl->REQ-INSTALL-4]\n300:pub fn register(\n301:    adapters_dir: &Path,\n302:    source: &Path,\n303:    now_ms: u64,\n304:) -> Result<(AdapterRecord, Manifest), RegistryError> {\n305:    // The installed spt-core version is the workspace version (this crate's).\n306:    register_with_core(adapters_dir, source, now_ms, env!(\"CARGO_PKG_VERSION\"))\n307:}\n308:\n309:/// [`register`] with the installed core version INJECTED — the seam the F-5 floor\n310:/// gate is tested through (a synthetic lower-than-floor core, the perri repro),\n311:/// since `register` itself binds the real `env!(\"CARGO_PKG_VERSION\")`.\n312:// [impl->REQ-ADAPTER-FLOOR-ENFORCE]\n313:pub fn register_with_core(\n314:    adapters_dir: &Path,\n315:    source: &Path,\n316:    now_ms: u64,\n317:    core_version: &str,\n318:) -> Result<(AdapterRecord, Manifest), RegistryError> {\n319:    let manifest_file = source_manifest_file(source);\n320:    let toml_src = std::fs::read_to_string(&manifest_file)?;\n321:    let manifest = Manifest::from_toml_str(&toml_src).map_err(RegistryError::Manifest)?;\n322:\n323:    // F-5 (REQ-ADAPTER-FLOOR-ENFORCE): refuse BEFORE writing anything if the installed\n324:    // core is below the adapter's declared floor — the register choke point covers the\n325:    // add verb and is defense-in-depth behind the update verb's pre-swap peek. Nothing\n326:    // is recorded on refuse (the fail-fast-before-write half of the contract, like the\n327:    // string-pointer validation below).\n328:    if !crate::manifest::version_meets_floor(core_version, &manifest.adapter.min_spt_core_version) {\n329:        return Err(RegistryError::CoreFloor {\n330:            adapter: manifest.adapter.name.clone(),\n331:            core: core_version.to_string(),\n332:            floor: manifest.adapter.min_spt_core_version.clone(),\n333:        });\n334:    }\n335:\n336:    // [impl->REQ-TRANSLATE-COMMAND] steer authors off the deprecated translation\n337:    // `path` (validate already refused both-set; this only warns, never fails).\n338:    if let Some(bin) = &manifest.message_idle_translation_binary {\n339:        if bin.path.is_some() {\n340:            spt_proto::emit_line_err!(\n341:                \"MANIFEST_DEPRECATION:{}: [message-idle-translation-binary].path is deprecated — \\\n342:                 use `command` (program + args, with {{adapter_dir}} substitution)\",\n343:                manifest.adapter.name\n344:            );\n345:        }\n346:    }\n347:\n348:    let mode = match manifest.update.as_ref().map(|u| u.avenue) {\n349:        // gh_release adapters live in their durable `_github/<safe>` home (the\n350:        // `--release` fetch dir); the manifest is re-read live from there as the\n351:        // gh_release update re-extracts in place — pointer, like delegated.\n352:        // [impl->REQ-UPD-9]\n353:        Some(UpdateAvenue::Delegated) | Some(UpdateAvenue::GhRelease) => RecordMode::Pointer,\n354:        Some(UpdateAvenue::FilePull) | None => RecordMode::Copy,\n355:    };\n356:    let source_dir = manifest_file\n357:        .parent()\n358:        .unwrap_or(Path::new(\".\"))\n359:        .to_string_lossy()\n360:        .into_owned();\n361:\n362:    // Validate-first (REQ-MANIFEST-5): every `[strings]` file pointer — in the\n363:    // base manifest and in each shipped profile's overlay — must be contained\n364:    // and exist under the SOURCE `strings/` dir. A bad/escaping/missing pointer\n365:    // refuses the whole registration (records nothing) — the loud half of the\n366:    // contract; read-time stays skip-diagnostic.\n367:    let src_strings_dir = Path::new(&source_dir).join(\"strings\");\n368:    validate_string_pointers(&manifest.strings, &src_strings_dir)?;\n369:    for prof in manifest.profiles.values() {\n370:        if let Some(strings) = prof.get(\"strings\").and_then(|v| v.as_table()) {\n371:            validate_string_pointers(strings, &src_strings_dir)?;\n372:        }\n373:    }\n374:\n375:    // Strict substitution-key validation (REQ-ADAPTER-TEMPLATE-KEY-VALIDATION):\n376:    // every spawned role's template must reference only fillable keys (BASE ∪ role\n377:    // extras), and no var may be both env_remove'd and [env] direction=\"read\". Same\n378:    // fail-fast-before-write family as the floor + string-pointer checks — refused\n379:    // here at the ADD/UPDATE action, NOT at parse (a live-re-read pointer install\n380:    // must never brick on a core update). A retired never-spawned role is skipped.\n381:    // [impl->REQ-ADAPTER-TEMPLATE-KEY-VALIDATION]\n382:    manifest\n383:        .validate_role_templates()\n384:        .map_err(RegistryError::Manifest)?;\n385:\n386:    let record = AdapterRecord {\n387:        name: manifest.adapter.name.clone(),\n388:        kind: manifest.adapter.kind,\n389:        mode,\n390:        source_dir: source_dir.clone(),\n391:        registered_at_ms: now_ms,\n392:        active: true,\n393:    };\n394:\n395:    let dir = adapters_dir.join(&record.name);\n396:    std::fs::create_dir_all(&dir)?;\n397:    if mode == RecordMode::Copy {\n398:        atomic_write_string(&manifest_copy_file(adapters_dir, &record.name), &toml_src)?;\n399:        // Bring the shipped strings/ dir into the held copy so pointers resolve\n400:        // post-copy and survive the source going away (adapter updates overwrite\n401:        // this — REQ-MANIFEST-5). Pointer mode reads strings/ live from source.\n402:        if src_strings_dir.is_dir() {\n403:            copy_dir_all(&src_strings_dir, &shipped_strings_dir(adapters_dir, &record))\n404:                .map_err(RegistryError::Io)?;\n405:        }\n406:    }\n407:    save_record(adapters_dir, &record)?;\n…\n409:}\n…\n1000:    // anything is written (the perri negative), and the refusal leaves the registry\n1001:    // BYTE-UNTOUCHED — no record dir, no record.toml, no manifest copy. The floor\n1002:    // check runs after the manifest parse but before the first registry write.\n1003:    #[test]\n1004:    fn register_with_core_refuses_below_floor_and_writes_nothing() {\n1005:        let tmp = tempfile::tempdir().unwrap();\n1006:        let adapters = tmp.path().join(\"adapters\");\n1007:        let src = seed_source(tmp.path(), \"hf-src\", HARNESS_HIGH_FLOOR);\n1008:\n1009:        // A real-shaped core (0.25.0) is far below the 9.9.9 floor ⇒ refuse.\n1010:        let err = register_with_core(&adapters, &src, 1000, \"0.25.0\")\n1011:            .expect_err(\"a below-floor core must be refused\");\n1012:        match err {\n1013:            RegistryError::CoreFloor { adapter, core, floor } => {\n1014:                assert_eq!(adapter, \"highfloor\");\n1015:                assert_eq!(core, \"0.25.0\");\n1016:                assert_eq!(floor, \"9.9.9\");\n1017:            }\n1018:            other => panic!(\"expected CoreFloor, got {other:?}\"),\n1019:        }\n1020:\n1021:        // Nothing was written: no record dir for the adapter under adapters_dir.\n1022:        assert!(\n1023:            !adapters.join(\"highfloor\").exists(),\n1024:            \"a refused registration must create NO record dir (fail-before-write)\"\n1025:        );\n1026:        // registered() sees an empty registry (the whole adapters dir may not even\n1027:        // exist yet — either way, no adapters are registered).\n1028:        assert!(\n1029:            registered(&adapters).is_empty(),\n1030:            \"a refused registration leaves the registry untouched\"\n1031:        );\n1032:    }\n1033:\n1034:    // [unit->REQ-ADAPTER-FLOOR-ENFORCE] positive control: a core that EXACTLY MEETS\n1035:    // the floor registers normally (equal satisfies the at-least floor), proving the\n1036:    // gate refuses only genuine below-floor cores and is not a blanket block.\n1037:    #[test]\n1038:    fn register_with_core_admits_when_core_meets_floor() {\n1039:        let tmp = tempfile::tempdir().unwrap();\n1040:        let adapters = tmp.path().join(\"adapters\");\n1041:        let src = seed_source(tmp.path(), \"ef-src\", HARNESS_EQUAL_FLOOR);\n1042:\n1043:        let (record, manifest) = register_with_core(&adapters, &src, 2000, \"0.25.0\")\n1044:            .expect(\"a core equal to the floor must register\");\n1045:        assert_eq!(record.name, \"equalfloor\");\n1046:        assert_eq!(manifest.adapter.min_spt_core_version, \"0.25.0\");\n1047:        // The record is really on disk (copy-mode harness holds a manifest copy).\n1048:        assert!(\n1049:            adapters.join(\"equalfloor\").join(\"record.toml\").exists(),\n1050:            \"an admitted registration writes the record\"\n1051:        );\n1052:        assert!(\n1053:            adapters.join(\"equalfloor\").join(\"manifest.toml\").exists(),\n1054:            \"a copy-mode admit holds the manifest copy\"\n1055:        );\n1056:    }\n1057:\n1058:    // [unit->REQ-INSTALL-4] an invalid manifest records nothing — the\n1059:    // validate-first rule (manifest-first, then install).\n1060:    #[test]\n1061:    fn invalid_manifest_records_nothing() {\n1062:        let tmp = tempfile::tempdir().unwrap();\n1063:        let adapters = tmp.path().join(\"adapters\");\n1064:        // kind = \"shell\" without a [shell] body fails validation.\n1065:        let bad = seed_source(\n1066:            tmp.path(),\n1067:            \"bad-src\",\n1068:            \"[adapter]\\nname = \\\"bad\\\"\\nkind = \\\"shell\\\"\\nversion = \\\"1\\\"\\nmin_spt_core_version = \\\"0\\\"\\n\",\n1069:        );\n1070:        assert!(matches!(\n1071:            register(&adapters, &bad, 0),\n1072:            Err(RegistryError::Manifest(_))\n1073:        ));\n1074:        assert!(!adapters.join(\"bad\").exists(), \"nothing recorded\");\n1075:        assert!(registered(&adapters).is_empty());\n1076:    }\n1077:\n1078:    // [unit->REQ-INSTALL-4] remove is a soft-deregister: hidden from the\n1079:    // active set, record + copy retained (live instances keep running), the\n1080:    // manifest's uninstall template surfaced to the caller; re-add"}],"details":{"displayContent":{"text":"/// Validate-first: an invalid manifest records **nothing**. Mode falls out of\n/// the `[update]` avenue — `delegated` ⇒ pointer, `file_pull`/absent ⇒ copy.\n/// Re-adding an existing name refreshes the record and re-activates a\n/// soft-deregistered one. Returns the record; the caller conducts\n/// install-is-first-update through the manifest's `[update]` avenue.\n// [impl->REQ-INSTALL-4]\npub fn register(\n    adapters_dir: &Path,\n    source: &Path,\n    now_ms: u64,\n) -> Result<(AdapterRecord, Manifest), RegistryError> {\n    // The installed spt-core version is the workspace version (this crate's).\n    register_with_core(adapters_dir, source, now_ms, env!(\"CARGO_PKG_VERSION\"))\n}\n\n/// [`register`] with the installed core version INJECTED — the seam the F-5 floor\n/// gate is tested through (a synthetic lower-than-floor core, the perri repro),\n/// since `register` itself binds the real `env!(\"CARGO_PKG_VERSION\")`.\n// [impl->REQ-ADAPTER-FLOOR-ENFORCE]\npub fn register_with_core(\n    adapters_dir: &Path,\n    source: &Path,\n    now_ms: u64,\n    core_version: &str,\n) -> Result<(AdapterRecord, Manifest), RegistryError> {\n    let manifest_file = source_manifest_file(source);\n    let toml_src = std::fs::read_to_string(&manifest_file)?;\n    let manifest = Manifest::from_toml_str(&toml_src).map_err(RegistryError::Manifest)?;\n\n    // F-5 (REQ-ADAPTER-FLOOR-ENFORCE): refuse BEFORE writing anything if the installed\n    // core is below the adapter's declared floor — the register choke point covers the\n    // add verb and is defense-in-depth behind the update verb's pre-swap peek. Nothing\n    // is recorded on refuse (the fail-fast-before-write half of the contract, like the\n    // string-pointer validation below).\n    if !crate::manifest::version_meets_floor(core_version, &manifest.adapter.min_spt_core_version) {\n        return Err(RegistryError::CoreFloor {\n            adapter: manifest.adapter.name.clone(),\n            core: core_version.to_string(),\n            floor: manifest.adapter.min_spt_core_version.clone(),\n        });\n    }\n\n    // [impl->REQ-TRANSLATE-COMMAND] steer authors off the deprecated translation\n    // `path` (validate already refused both-set; this only warns, never fails).\n    if let Some(bin) = &manifest.message_idle_translation_binary {\n        if bin.path.is_some() {\n            spt_proto::emit_line_err!(\n                \"MANIFEST_DEPRECATION:{}: [message-idle-translation-binary].path is deprecated — \\\n                 use `command` (program + args, with {{adapter_dir}} substitution)\",\n                manifest.adapter.name\n            );\n        }\n    }\n\n    let mode = match manifest.update.as_ref().map(|u| u.avenue) {\n        // gh_release adapters live in their durable `_github/<safe>` home (the\n        // `--release` fetch dir); the manifest is re-read live from there as the\n        // gh_release update re-extracts in place — pointer, like delegated.\n        // [impl->REQ-UPD-9]\n        Some(UpdateAvenue::Delegated) | Some(UpdateAvenue::GhRelease) => RecordMode::Pointer,\n        Some(UpdateAvenue::FilePull) | None => RecordMode::Copy,\n    };\n    let source_dir = manifest_file\n        .parent()\n        .unwrap_or(Path::new(\".\"))\n        .to_string_lossy()\n        .into_owned();\n\n    // Validate-first (REQ-MANIFEST-5): every `[strings]` file pointer — in the\n    // base manifest and in each shipped profile's overlay — must be contained\n    // and exist under the SOURCE `strings/` dir. A bad/escaping/missing pointer\n    // refuses the whole registration (records nothing) — the loud half of the\n    // contract; read-time stays skip-diagnostic.\n    let src_strings_dir = Path::new(&source_dir).join(\"strings\");\n    validate_string_pointers(&manifest.strings, &src_strings_dir)?;\n    for prof in manifest.profiles.values() {\n        if let Some(strings) = prof.get(\"strings\").and_then(|v| v.as_table()) {\n            validate_string_pointers(strings, &src_strings_dir)?;\n        }\n    }\n\n    // Strict substitution-key validation (REQ-ADAPTER-TEMPLATE-KEY-VALIDATION):\n    // every spawned role's template must reference only fillable keys (BASE ∪ role\n    // extras), and no var may be both env_remove'd and [env] direction=\"read\". Same\n    // fail-fast-before-write family as the floor + string-pointer checks — refused\n    // here at the ADD/UPDATE action, NOT at parse (a live-re-read pointer install\n    // must never brick on a core update). A retired never-spawned role is skipped.\n    // [impl->REQ-ADAPTER-TEMPLATE-KEY-VALIDATION]\n    manifest\n        .validate_role_templates()\n        .map_err(RegistryError::Manifest)?;\n\n    let record = AdapterRecord {\n        name: manifest.adapter.name.clone(),\n        kind: manifest.adapter.kind,\n        mode,\n        source_dir: source_dir.clone(),\n        registered_at_ms: now_ms,\n        active: true,\n    };\n\n    let dir = adapters_dir.join(&record.name);\n    std::fs::create_dir_all(&dir)?;\n    if mode == RecordMode::Copy {\n        atomic_write_string(&manifest_copy_file(adapters_dir, &record.name), &toml_src)?;\n        // Bring the shipped strings/ dir into the held copy so pointers resolve\n        // post-copy and survive the source going away (adapter updates overwrite\n        // this — REQ-MANIFEST-5). Pointer mode reads strings/ live from source.\n        if src_strings_dir.is_dir() {\n            copy_dir_all(&src_strings_dir, &shipped_strings_dir(adapters_dir, &record))\n                .map_err(RegistryError::Io)?;\n        }\n    }\n    save_record(adapters_dir, &record)?;\n…\n}\n…\n    // anything is written (the perri negative), and the refusal leaves the registry\n    // BYTE-UNTOUCHED — no record dir, no record.toml, no manifest copy. The floor\n    // check runs after the manifest parse but before the first registry write.\n    #[test]\n    fn register_with_core_refuses_below_floor_and_writes_nothing() {\n        let tmp = tempfile::tempdir().unwrap();\n        let adapters = tmp.path().join(\"adapters\");\n        let src = seed_source(tmp.path(), \"hf-src\", HARNESS_HIGH_FLOOR);\n\n        // A real-shaped core (0.25.0) is far below the 9.9.9 floor ⇒ refuse.\n        let err = register_with_core(&adapters, &src, 1000, \"0.25.0\")\n            .expect_err(\"a below-floor core must be refused\");\n        match err {\n            RegistryError::CoreFloor { adapter, core, floor } => {\n                assert_eq!(adapter, \"highfloor\");\n                assert_eq!(core, \"0.25.0\");\n                assert_eq!(floor, \"9.9.9\");\n            }\n            other => panic!(\"expected CoreFloor, got {other:?}\"),\n        }\n\n        // Nothing was written: no record dir for the adapter under adapters_dir.\n        assert!(\n            !adapters.join(\"highfloor\").exists(),\n            \"a refused registration must create NO record dir (fail-before-write)\"\n        );\n        // registered() sees an empty registry (the whole adapters dir may not even\n        // exist yet — either way, no adapters are registered).\n        assert!(\n            registered(&adapters).is_empty(),\n            \"a refused registration leaves the registry untouched\"\n        );\n    }\n\n    // [unit->REQ-ADAPTER-FLOOR-ENFORCE] positive control: a core that EXACTLY MEETS\n    // the floor registers normally (equal satisfies the at-least floor), proving the\n    // gate refuses only genuine below-floor cores and is not a blanket block.\n    #[test]\n    fn register_with_core_admits_when_core_meets_floor() {\n        let tmp = tempfile::tempdir().unwrap();\n        let adapters = tmp.path().join(\"adapters\");\n        let src = seed_source(tmp.path(), \"ef-src\", HARNESS_EQUAL_FLOOR);\n\n        let (record, manifest) = register_with_core(&adapters, &src, 2000, \"0.25.0\")\n            .expect(\"a core equal to the floor must register\");\n        assert_eq!(record.name, \"equalfloor\");\n        assert_eq!(manifest.adapter.min_spt_core_version, \"0.25.0\");\n        // The record is really on disk (copy-mode harness holds a manifest copy).\n        assert!(\n            adapters.join(\"equalfloor\").join(\"record.toml\").exists(),\n            \"an admitted registration writes the record\"\n        );\n        assert!(\n            adapters.join(\"equalfloor\").join(\"manifest.toml\").exists(),\n            \"a copy-mode admit holds the manifest copy\"\n        );\n    }\n\n    // [unit->REQ-INSTALL-4] an invalid manifest records nothing — the\n    // validate-first rule (manifest-first, then install).\n    #[test]\n    fn invalid_manifest_records_nothing() {\n        let tmp = tempfile::tempdir().unwrap();\n        let adapters = tmp.path().join(\"adapters\");\n        // kind = \"shell\" without a [shell] body fails validation.\n        let bad = seed_source(\n            tmp.path(),\n            \"bad-src\",\n            \"[adapter]\\nname = \\\"bad\\\"\\nkind = \\\"shell\\\"\\nversion = \\\"1\\\"\\nmin_spt_core_version = \\\"0\\\"\\n\",\n        );\n        assert!(matches!(\n            register(&adapters, &bad, 0),\n            Err(RegistryError::Manifest(_))\n        ));\n        assert!(!adapters.join(\"bad\").exists(), \"nothing recorded\");\n        assert!(registered(&adapters).is_empty());\n    }\n\n    // [unit->REQ-INSTALL-4] remove is a soft-deregister: hidden from the\n    // active set, record + copy retained (live instances keep running), the\n    // manifest's uninstall template surfaced to the caller; re-add","startLine":294,"lineNumbers":[294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329,330,331,332,333,334,335,336,337,338,339,340,341,342,343,344,345,346,347,348,349,350,351,352,353,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,371,372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387,388,389,390,391,392,393,394,395,396,397,398,399,400,401,402,403,404,405,406,407,null,409,null,1000,1001,1002,1003,1004,1005,1006,1007,1008,1009,1010,1011,1012,1013,1014,1015,1016,1017,1018,1019,1020,1021,1022,1023,1024,1025,1026,1027,1028,1029,1030,1031,1032,1033,1034,1035,1036,1037,1038,1039,1040,1041,1042,1043,1044,1045,1046,1047,1048,1049,1050,1051,1052,1053,1054,1055,1056,1057,1058,1059,1060,1061,1062,1063,1064,1065,1066,1067,1068,1069,1070,1071,1072,1073,1074,1075,1076,1077,1078,1079,1080]},"fileSize":68674,"meta":{"source":{"type":"path","value":"C:/Users/decid/Documents/projects/spt-core/.worktrees/ws272-w3-drift/crates/spt-runtime/src/registry.rs"}}},"isError":false,"timestamp":1788711270302}}
{"type":"message","id":"8fa2277e","parentId":"1f67d7f9","timestamp":"2026-09-06T16:14:30.313Z","message":{"role":"toolResult","toolCallId":"call_YVN8lZyOnRbxcnvlGJIs9eWd|fc_07548ebf196ed5a1016a9d916633c487d18c3b0e5562c5cce3","toolName":"grep","content":[{"type":"text","text":"[.worktrees/ws272-w3-drift/CONTEXT.md#B34B]\n 22:**spt plugin** (separate downstream project — NOT an spt-core deliverable):\n*23:A rebuilt version of today's Claude Code `spt` plugin. It is the **first consumer** built *atop* spt-core and the **acceptance proof** of spt-core v1 (it reaches feature parity with modern SPT while delegating all core functionality to spt-core, primarily via `spt.exe`, with deeper hooks where useful) — but it **lives and builds in its own repository, outside spt-core**. It is a Claude-Code-specific *adapter*: it holds the Claude Code conventions (hooks, slash-commands, skill/plugin layout, `claude` se...\n 24:\n 25:**Pi** (disambiguation — two meanings, never conflate):\n 26:(1) **Pi, the coding agent/harness** (`badlogic/pi-mono`) — a harness example alongside Claude Code and Codex; this is the meaning in user-facing harness lists. (2) **Pi-class node** — Raspberry-Pi-class low-power hardware hosting a Shell-only or headless SPT node; an incidental hardware descriptor, never an explicit product example. Public-facing docs must disambiguate or avoid the bare word.\n...\n 36:- **broker** (stable \"kernel\") — holds *only* the un-transferable, must-not-die resources: PTY master fds, the spawned harness child processes, and listening network sockets. Minimal, dumb, versioned local IPC. Almost never updates.\n*37:- **daemon brain** (\"userspace\") — all logic (routing, registry, pulse/psyche loops, manifest parsing, update orchestration). Restarts freely on update; rehydrates from disk state and re-attaches to the broker's held handles.\n 38:\n 39:Logical addressing is unchanged — still one per-machine `spt-daemon`; the broker is an internal layer, not separately addressable. There is exactly **one broker per machine** (per `SPT_HOME`) — *not* one per endpoint: a single broker holds every hosted endpoint's resources, and it is present whenever the daemon runs, even with zero endpoints online (the bare-daemon case). It is therefore the always-present per-machine layer, which is why the single-daemon lock + liveness anchor belong to it.\n 40:\n 41:**in-session relay**:\n*42:A thin, stateless `spt.exe` task that exists only in **harness-hosted** sessions (where the agent harness is the parent process and spt cannot reach into its process tree — today's Monitor model). It streams the daemon brain's events into the session's stdout. All *stateful* listener logic lives in the daemon; the relay is a dumb pipe, freely killable and respawnable. **spt-hosted** sessions need no separate harness-owned relay — the daemon owns the PTY and consumes the same poll feed itself. Idle de...\n 43:\n 44:### Deliverable shape\n 45:\n...\n 57:**AgentRuntime** (Rust trait, implementation detail):\n*58:The internal Rust abstraction over a harness. Anything spt-core needs to do *to* or *with* an agent goes through this trait. Most consumers never see it directly — they configure spt-core via a manifest, and spt-core's default `ManifestRuntime` implementation executes against the manifest.\n 59:\n 60:**harness contract** (umbrella term):\n*61:The full surface a harness binds to in order to participate in the spt-core ecosystem. Has two equally-important halves: the **runtime manifest** (outbound — how spt-core drives the harness) and the **subcommand surface** (inbound — how the harness reports events back to spt-core). A harness implementation is one TOML/YAML manifest + a binding from the harness's own hook system into `spt.exe <subcommand>` calls.\n 62:\n*63:**runtime manifest** (outbound half of the harness contract):\n*64:A declarative configuration file (TOML/YAML — schema TBD) that tells spt-core how to drive a specific harness. Declares: how to invoke an agent session, how to look up conversation history for an echo commune, how to spawn/resume a Psyche-equivalent, which binary or command implements each harness-side operation, and which endpoint types this harness supports. spt-core is the actor for each of these; the manifest tells it what to do.\n 65:\n*66:Example shape (illustrative): `spt.exe --manifest spt-plugin.toml live start <id>`. A harness like the planned spt plugin wraps this invocation into the `$LIVE` / `$OWL` environment variables it injects into its sessions, so harness-internal callers continue to invoke `$LIVE` / `$OWL` unchanged.\n 67:\n 68:**subcommand surface** (inbound half of the harness contract):\n 69:The stable set of `spt.exe <subcommand>` entry points that harnesses bind their own hook systems to. When the harness's runtime emits an event (subagent started, tool just invoked, user typed `/clear`, session crashed), the harness's hook fires a short-lived `spt.exe <subcommand>` invocation that mutates on-disk SPT state (perch registry, spool, etc.). spt-core publishes this surface; harnesses author the bindings.\n 70:\n*71:**Naming convention:** these inbound, machinery-facing commands are prefixed **`api `/`api-`** (e.g. `spt api bind`, `spt api state`) to distinguish them from the agent-facing verbs an agent invokes directly (`send`, `ring`, `ready`, …). The `api` namespace is the harness/adapter commands-API; the unprefixed namespace is the agent surface.\n 72:\n*73:Together: manifest + subcommand surface = the complete harness API. A sidecar-style long-running adapter process speaking a wire protocol is explicitly **deferred** as a possible v2 alternative for harnesses that outgrow the manifest+hooks shape (e.g. need streaming or in-memory state across events). Not built day-one.\n 74:\n*75:**adapter manifest header** (`adapter_name` + version compat):\n*76:Every manifest declares a unified **`adapter_name`** (e.g. `claude-spt`), carried on every `api` invocation too. It is load-bearing: one daemon hosts endpoints from multiple adapters, so it resolves an endpoint's manifest + seams by `adapter_name`; adapter-update ripples target by it; capability/manifest lookup and telemetry key on it. The header also declares the adapter's own version and a **`min_spt_core_version`** — the minimum spt-core the adapter requires. This declaration must be **readable befo...\n 77:\n*78:<!-- [doc->REQ-MANIFEST-2] -->\n*79:**adapter profile** (ratified 2026-06-11, Gateway grill; future spt-core milestone — first beneficiaries `spt-claude-code` and the usbip shell):\n*80:A named **sparse overlay** on its parent adapter manifest. Merge semantics are **leaf-replace**: a profile key replaces the whole value at that path (arrays included — never spliced or appended). The merged result is a complete manifest, and the profile behaves as a distinct adapter option everywhere: canonical addressing is the composite **`<adapter>:<profile>`** (`claude-spt:work`, `spt-usbip-driver:hid-only`) in every place a bare `adapter_name` rides today (perch `info.json`, capability resolution,...\n*81:_Avoid_: \"manifest fork\", \"child adapter\", per-profile versioning.\n 82:\n*83:**adapter strings** (ratified 2026-06-11, Gateway grill):\n*84:<!-- [doc->REQ-MANIFEST-3] -->\n*85:A `[strings]` manifest section — an adapter-authored JSON/TOML KV tree, dot-path-readable by anything on the node via `spt adapter get-string <adapter-option> <key.path>` (e.g. a harness hook fetching per-profile `additionalContext` — one hook script serves every profile, only the data differs). Resolution rides the **same leaf-replace profile overlay** as the rest of the manifest: a shipped or local profile may override base strings; `get-string` returns the merged view for the named adapter option....\n*86:<!-- [doc->REQ-MANIFEST-5] -->\n*87:**File-backed strings** (M12-W3): a `[strings]` value MAY be a **file pointer** instead of an inline literal — a value-position table with **exactly one** key `file`: `skill = { file = \"skill.md\" }`. `get-string` resolves it to the file's **contents** (so large bodies — skill-instructions, hint text — stay out of the manifest). The exactly-one-key rule is the disambiguation: any other table shape stays an opaque nested strings tree (existing trees untouched), and `{ file = … }` is reserved as the...\n*88:_Avoid_: treating strings as config knobs for spt-core itself (those are global settings); \"adapter KV store\" as a separate registry; putting user files in the adapter-shipped `strings/` dir (clobbered by updates — use a local profile).\n 89:\n*90:**manifest substitution in `[strings]`** (ratified 2026-06-25, v0.16.0 update-arc grill):\n*91:`get-string` resolves a set of **adapter-static** substitution keys inside a returned string value at **read time** (lazily, like file-backed strings): **`{adapter_dir}`** — the registry record's precise `source_dir` (the install dir; survives updates; the same dir bare-program resolution already uses) — and **`{adapter_name}`**. Session-scoped keys (`{id}`/`{session_id}`/…) are **not** available: `get-string` carries no session context today, and a `get-string --session-id` for session-scoped subs...\n*92:_Avoid_: session-scoped substitution through bare `get-string`; reading this as spt-core executing a string (it never does — the adapter wrapper executes the resolved value).\n 93:\n 94:**keyword hints** (ratified 2026-06-12 — core milestone A):\n*95:<!-- [doc->REQ-MANIFEST-4] -->\n*96:Once-per-session usage/syntax hints, a first-class adapter feature: the manifest's `[hints]` section declares entries of `{keywords (literal default, regex opt-in), text}`; the adapter's user-prompt hook pipes the **full user message** to `spt api hint --session <id>` (stdin) and receives matched hint lines (`keyword hint for SPT adapter <name>: \"<kw>\"-->{text}`) for its context-injection channel. The daemon keeps a per-session seen-set — each hint fires **once per session** (a `/clear` mints a new ses...\n*97:_Avoid_: unconditional static context (that's the adapter's own preamble); firing per-message.\n 98:\n 99:**shell keyword hints** (releases#133, operator-GREENLIT; shape ruled 2026-08-30):\n 100:<!-- [doc->REQ-SHELL-HINTS] -->\n*101:`[[hints]]` was never harness-only — it is a top-level manifest section with no kind gate, so a `kind = \"shell\"` adapter has always been able to declare hints; what was missing was a reader, because the only reader resolved the endpoint-bound manifest. The now-signal **HINTS** category now also reads the shell adapters an endpoint can see, one line each under the per-source cap above. **Two arms, decided by INSTANTIATION and never by link state:** an adapter this owner holds an instance of surfaces the...\n*102:_Avoid_: gating the shell arm on the harness manifest (an endpoint with no bound manifest still owns shells); filtering the full arm on online/offline; naming a surfacing command that does not exist at the shipping head.\n 103:\n*104:**adapter update declaration** (manifest field):\n 105:<!-- [doc->REQ-UPD-9] -->\n*106:Each adapter manifest declares how spt-core should *ripple-update the adapter itself* (see Self-update). One of: **file-pull** (a plugin-directory lookup regex + a gh repo for the adapter's latest files — spt-core fetches + swaps), **delegated command** (a binary command the adapter owns, e.g. `claude.exe plugin update` — spt-core invokes it), or **gh_release** (the adapter ships its updates from its own GitHub releases). After initial bootstrap, the plugin no longer self-manages updates; spt-core co...\n 107:\n*108:**adapter packaging & live update** (v0.13.2; ADR-0024, ADR-0025):\n*109:<!-- [doc->REQ-ADAPTER-GH-TRANSPORT] -->\n*110:A `.spt` may be **multi-platform**: shared `manifest.toml` + `strings/` at the root, role binaries under per-target-triple subdirectories (`x86_64-pc-windows-msvc/`, …); install/update extracts the shared root plus only the current node's triple, flattened into `install_dir`, so flat `<install_dir>/<program>` resolution is unchanged. It stays one signed asset (`adapter.spt`, plain-tar or gzip); a multi-platform archive missing the recipient's triple is a typed `NoArtifactForPlatform`. Large adapters ma...\n*111:<!-- [doc->REQ-ADAPTER-UPDATE-MESSAGE] -->\n*112:An optional **`[update].message`** (avenue-agnostic) is a plain multi-line operator notice surfaced to stdout, markdown-rendered (the helpfmt prose path), **only when an update is actually applied** (the version changed) — never on a no-op. It is read from the newly-installed manifest with no `{key}` substitution; its use is to announce a post-update action (e.g. \"run `/reload-plugins` in any ongoing sessions\").\n 113:\n 114:**composite update — `[update.post]`** (ratified 2026-06-25, v0.16.0 update-arc grill; ADR-0029):\n*115:An optional **avenue-agnostic** post-step `{ command, self_verifies }` spt-core runs **after** the primary avenue resolves — in the same `spt adapter update` **and at `spt adapter add`** (install is the first update, so a fresh install conducts the post-step too; bug-#1 operator ruling, v0.19.0 — the eager-extract acquisition runs it post-registration, a delegated acquisition after the acquisition succeeds, and only the payload-less `file_pull` PENDING add defers it to the payload's arrival) — so o...\n 116:\n*117:**resident adapter binary**: an adapter-owned process spt-core keeps alive for an endpoint's lifetime (today the `[message-idle-translation-binary]`), as opposed to **ephemeral** adapter binaries — the Psyche loop (daemon-hosted, ADR-0004), the `[digest]` extractor, `[session.*]` runners, hooks — which spawn on demand and pick up an update on their next invocation. Only resident binaries are stopped/restarted on a live update; ephemerals self-heal.\n 118:_Avoid_: calling the Psyche loop or an on-demand extractor a \"resident\" binary; \"restart the endpoint\" for what is a per-binary cycle.\n 119:\n*120:**session-invocation declaration** (manifest field, noted for spt-plugin parity):\n*121:How the harness spawns agent sessions, including Psyche and echo-commune sessions. For the rebuilt spt-plugin, Psyche and echo communes must migrate **off `claude -p`** (imminent Claude Code billing changes) to headless `claude` sessions (`--resume` for the Psyche). This is an adapter/manifest concern, not a core concern, but the parity milestone must carry it.\n 122:\n*123:### Manifest seams (outbound contract, detailed)\n 124:\n*125:Governing principle: **SPT is not a harness.** Model choice, billing shape, harness-internal env, and harness-internal context are entirely the adapter's concern, expressed inside the adapter's own command templates. spt-core owns only the template *mechanism* (substitution keys), the substitution *values* it is responsible for, and the surrounding lifecycle. Env for the *endpoint binary itself* is auto-handled by spt-core/broker; env for the *agent running inside* that binary is the adapter's config (e....\n 126:\n*127:**spawn-session seam** — launch a new agent session on this node. Manifest provides: a command template; `cwd`/project; a `headless` flag (optional, default false — for the GUI's resume-of-compatible-adapters); a `resume` flag (optional); and the `commune` + `signoff` file directories relative to `cwd` (so the daemon knows where to watch). Substitution keys spt-core can supply: `{id}` and, optionally, a spt-core-generated valid session UUID (e.g. injected as `--session-id {uuid}`) so an adapter can s...\n 128:- **id resolution:** `id` is optional. With no id, spt-core reproduces today's no-id `/spt:live` behavior — run the lone live agent if that's all the project has; show a picker with proposed default IDs if the project has none; let the user choose if there are several.\n 129:\n*130:**post-spawn seam** — the just-launched binary calls an spt-core command on boot (via the adapter's SessionStart-equivalent hook) to bind itself. Needed because the harness's own session id usually isn't known until after the binary runs. Payload: the harness `session_id` (when binary-generated rather than spt-core-injected); the `parent_pid` (the stable session-binding anchor — see KNOWN-HAZARDS 2.1); an endpoint identity/type confirmation; optionally a local HTTP port the binary listens on (for HTT...\n 131:\n*132:**post-spawn is optional only under a strict commitment:** an adapter may forgo post-spawn *only* if it (a) injects the spt-core-generated session UUID at spawn AND (b) guarantees the launched-process pid IS the stable session-binding anchor (no wrapper-script / subprocess pid indirection). If either does not hold, post-spawn must fire to report `session_id` and/or `parent_pid`. UUID-injection alone suppresses only the `session_id` reporting, not the binding.\n 133:\n*134:**spawn-psyche seam** — two command templates: fresh-start and resume (the resume template includes `$session_id`). Both include `$psyche_prompt` — the revival essentials spt-core feeds the Psyche (timestamp, incoming event envelope). Everything else is the adapter's: model selection (in its template), and any harness-specific instructions the Psyche needs (Write-tool usage, commune dir) supplied as a static preamble before `$psyche_prompt` or as adapter SessionStart additionalContext. spt-core owns ...\n 135:\n 136:**history subsystem** (covers echo-commune source logs, resume briefs, and Shell logs) — two supported paths:\n*137:- **Path A — adapter-owned logs.** Manifest declares a locate-template (keyed by `$session_id`) + a **normalize-command the adapter owns** that emits spt-core's expected normalized format. spt-core docs must teach adapter devs how to build a conformant parser.\n*138:- **Path B — spt-core-native history store.** spt-core exposes a `history-log` command/API; the adapter writes its logs to spt-core in the native format and spt-core stores them. Rationale: spt-core needs its own log store for Shells anyway, and this simplifies integration for flexible/DIY harnesses.\n*139:- The **echo-commune seam** is then just a command template (adapter picks the model) that consumes whichever history path is configured for the session.\n*140:- **Why adapter-owned normalize over spt-core built-in parsers** (grounded in a Codex-CLI vs Claude-Code comparison): transcript formats diverge sharply and move fast. Claude Code = one flat JSONL per session, project-partitioned, locatable directly from the session id (`~/.claude/projects/<hash>/<id>.jsonl`). Codex = date-partitioned **rollout files** (`~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<id>.jsonl`) where the id is only a filename *substring* (must recursive-glob to locate), a **3-level tagged e...\n*141:- **Profile-relocated transcript roots — env-read capture** (ratified 2026-06-30, counter-38 field-bug grill; supersedes a rejected harness-specific `{config_dir}` proposal): a Path-A locate-template (and the `[digest].source` that reuses it) may reference a manifest-declared **`[env.<VAR>] direction = \"read\"`** var (e.g. `{CLAUDE_CONFIG_DIR}`). spt-core captures the **declared** read-vars — an explicit allowlist, never the whole env — from the session's launch environment at **bind** (the only poi...\n*142:- **`[digest]` mirrors history's two strategies — locate ownership** (ratified 2026-06-30, counter-38 W6 design-gate): like `[history]`, `[digest]` supports **`fetcher`** (the adapter's extractor **locates + reads + emits** normalized digest records; spt-core runs it bounded and consumes its stdout, doing **no** locate and **no** pre-read) alongside the original **`locate_normalize`** (`source` template → spt-core locates a **single** file + reads + pipes the bytes to the extractor as a pure stdin→...\n 143:\n*144:**activity/idle detection** — **not** PTY-quiescence (insufficient: e.g. CC's AskUserQuestion stalls the PTY while holding stdin and needing nuanced input) and **not** a manifest-declared idle signal. Instead, the adapter calls spt-core activity/idle commands at the right moments (from its hooks); those commands manage activity/idle **sentinels inside the session perch**. The idle state lives in the perch, owned by spt-core via the commands API exposed to adapter devs.\n 145:\n 146:**activity observation** (ruled 2026-07-24, rebound grill) — two avenues, deliberately split by consumer class; the digest is **not** one of them (it stays a content surface). **Push (shells only):** an owned Shell observes its *owner's* busy/idle transitions as an **activity frame** on the existing shell-link event stream — link-scoped (owner implied by the link token), **drive-class semantics** (ephemeral, latest-wins, current-state-carrying; a redundant same-state resend is a harmless no-op — th...\n 147:\n...\n 149:- PTY injection (with or without key/submit sequences) — spt-hosted topology;\n*150:- adapter hooks calling spt-core poll commands;\n*151:- an in-adapter-session child relay (à la CC's Monitor tool);\n*152:- adapter manifest requesting HTTP POST delivery to the endpoint binary on a local port (shared via the post-spawn seam).\n*153:Note: even spt-hosted sessions default to hook injection (or the adapter's equivalent) as the non-disruptive path **during activity**; some adapters prefer the in-session relay regardless of topology.\n 154:\n 155:**activity-gated delivery** — an inbound message routes by the receiver's activity sentinel (above). While the endpoint is **active**, the message spools for the receiver's own hook-poll to drain (non-disruptive — the *active window*). On **idle** (or an idle transition before a hook drains it), it delivers immediately — translation binary (spt-hosted) → relay-poll (either topology) → spool, in that fallback order (the *idle window*). The send-side axes below modulate which of these two windows...\n 156:\n 157:**message delivery axes** — a sent message carries independent modifiers on orthogonal axes; it is **not** a single \"type\". The flag on each axis defaults to the unrestricted value:\n*158:- **delivery window** (*when*) — **default** (both windows; delivers in whichever fires first) · **idle-only** (held for the idle window; delivered immediately if already idle) · **active-only** (active window only — the receiver's hook-poll; never wakes an idle agent). *active-only* is the renamed legacy **deferred** (the `deferred=1` spool column + `api poll --include-deferred` are its internal/adapter-facing names). Mutually exclusive.\n 159:- **channel restriction** (*through what*) — **unrestricted** (any configured inject method) · **prefer-native** (the translation binary if one is running, else fall back to the standard methods) · **force-native** (the translation binary and nothing else — no fallback, no spool-to-another-method). Mutually exclusive; composes with the window. *\"Native\"* = the `[message-idle-translation-binary]` PTY channel.\n 160:- **persistence** (*how long it waits*) — **durable** (default; spooled until delivered or TTL) · **ephemeral** (dropped if it cannot deliver in its accepted window — at the moment the window opens with no live carrier, or at TTL, whichever is first). Ephemeral is the **only** path permitted to drop silently (the REQ-HAZARD-IDLE-SILENT-NONDELIVERY carve-out); every non-ephemeral path spools and reports non-delivery. <!-- v0.15.0 PARTIAL (W3): ephemeral evaporation covers the spt-hosted-binary no-car...\n 161:Window restricts *when* delivery is accepted, channel restricts *which method* carries it, persistence restricts *how long* it waits — they compose freely (e.g. `force-native` + `active-only` = the binary injects during the active window, never idle; `force-native` + `ephemeral` = binary-or-nothing).\n 162:\n*163:**message metadata (`json`)** — a sender may attach an opaque JSON metadata block (`--json-payload`), carried as a single attr-escaped `json=\"…\"` envelope attribute **alongside** (never replacing) the body. spt-core never interprets it — pure verbatim passthrough across every rail (spool / TCP / WAN / EVENT-PART), parsed only by the receiving adapter (its hooks and/or translation binary). Collision-proof by construction: the structured data lives **inside** the single `json` value, so it can never ...\n 164:\n 165:**resume-session seam** — two distinct forms:\n*166:- **fresh-with-preload:** resume with *cleared* context (a fresh session) + psyche-download. Accepts a `$psyche-context` key to launch the fresh session with the psyche-download preloaded — or the adapter instead pulls it via an spt-core command in its SessionStart hook. <!-- [doc->REQ-RESUME-CONTEXT-PULL] --> That command is **`spt api psyche-download <id> [--session-id <sid>]`**: it emits the durable resume brief (role → live-context → project-context, project resolved from the perch's bound cwd)...\n*167:- **continue-existing:** resume an existing harness session under the adapter (its native resume).\n 168:\n*169:**capability declaration** — which endpoint types a harness/node can host (a Pi node might host only Shells, never a LiveAgent). Static manifest list, consumed by the subnet registry so a node advertises its hostable types. Exact shape is design-open.\n 170:\n*171:**adapter-update seam** — file-pull or delegated-command (see Self-update). Locked.\n 172:\n*173:There is no separate \"model/billing\" seam — those live inside the adapter's spawn/psyche/echo command templates. SPT never selects a model. The full manifest schema is `docs/MANIFEST.md`; key model-level facts from it:\n 174:\n*175:- **Command templates are opaque.** spt-core never parses out a model/tool/flag — the adapter writes the whole command line; spt-core fills substitution keys and runs it.\n*176:- **A command template's program token resolves against the adapter install dir before PATH (since v0.8.0).** A `.spt` adapter ships its built binaries to the adapter's install dir (`adapters/_github/<safe>/` via `--release`/`--github`, or the record's `source_dir` under copy-mode), so a bare program name (e.g. `claude-spt-digest …`) binds to the shipped binary first and falls back to PATH when absent — a `.spt` that ships its binaries is **self-contained**, needing no PATH placement. <!-- [doc->REQ-...\n*177:- **Hook output capability is declared per harness-event** (`can_inject`). CC's Stop hook cannot inject context — that single fact drives the echo-gate sentinel + relay fallback. The manifest expresses it so spt-core knows when to fall back.\n 178:- **Env injection is asymmetric** (file-bridge-only-when-not-launcher, applied to env): spt-hosted sessions inherit env from the broker that spawns them; harness-hosted sessions need the harness's declared env channel. With `spt` on PATH the env table is small.\n*179:- **Cross-adapter fallback** is a **node-wide setting**, not a manifest field: if a Psyche/echo invocation under one adapter is rate-limited, spt-core falls back to another adapter (e.g. `ccs` — its own adapter, not a binary-swap). <!-- [doc->REQ-MANIFEST-6] --> A fallback **target is addressed as `<adapter>:<profile>`** (not just a bare adapter_name) and resolves through the one composite-addressing resolver (`registry::resolve_option`), so a fallback may select a shipped or local profile (`ccs`, `ccs...\n*180:- **Config knobs** (pulse period, echo-commune window/gate, route-guard window, daily refresh) are spt-core **global settings** with optional **per-endpoint override**. **An adapter may DECLARE A DEFAULT, never an override** (narrowed 2026-08-03, LOCKSMITH grill — the original \"never per-adapter\" wording is superseded): a harness has real information about its own turn shape and cost, but the operator keeps the last word. Precedence, highest first: **per-endpoint override → node/global setting → ad...\n*181:- **Event-block vocabulary and file-drop filenames are fixed spt-core constants** (documented for adapter authors), not manifest-configurable. <!-- [doc->REQ-RESUME-CONTEXT-PULL] --> This includes the **checkpoint sentinel `!!checkpoint!!`** — the agent-checkpoint trigger an adapter embeds in a commune/signoff drop body (one bare token = checkpoint with default wake; a `!!checkpoint!! <text> !!checkpoint!!` pair makes the inter-marker text a custom wake directive). It is spt-core control metadata: spt-...\n 182:\n 183:### Inbound `api` surface (detailed)\n 184:\n*185:All commands below are `api`-prefixed (machinery-facing). Every `api` invocation **and** every manifest carries a unified **`adapter_name`** string (e.g. `claude-spt`) identifying the owning adapter. This is load-bearing: one daemon hosts endpoints from multiple adapters (`claude-spt`, `spt-codex`, `spt-pi`), so the daemon resolves an endpoint's manifest + seams (history normalize-command, inject method, update avenue) by its `adapter_name`; adapter-update ripples target by it; capability lookup and tele...\n 186:\n 187:<!-- [doc->REQ-API-4] -->\n*188:**Manifest resolution from `--adapter` (since v0.8.0).** `spt api <cmd> --adapter <name[:profile]>` resolves the registered adapter's manifest, `:profile` overlay, and install dir from the registry when `--manifest` is omitted — a registered adapter's `api` calls need only `--adapter`. `--manifest <path>` becomes an optional **override** (an unregistered or local-dev manifest): when present, the manifest loads from that file and the install dir is its parent directory; when absent, both come from the r...\n 189:\n 190:- **`api bind`** — post-spawn boot bind (payload above). Skeleton→live.\n*191:- **`api listen`** — *long-running* relay/poll listener that an adapter-owned (harness-hosted) session owns as a child process; streams the daemon's events to the session's stdout. Distinct from the short-lived `api poll`. This is the heir to today's Monitor-bound `$LIVE start` poll loop.\n*192:- **`api poll`** — short-lived drain of queued messages for a session (the hook-injection delivery path). `--include-deferred` optionally also drains deferred rows, for adapter flexibility (default excludes them — KNOWN-HAZARDS 1.4/4.4).\n*193:- **`api state <busy|idle>`** — adapter reports session activity; writes the activity/idle sentinel in the session perch. **The BUSY edge arms the echo-commune gate sentinel** (`.more-done`-equiv); `--no-gate` suppresses that coupling, and a standalone **`api echo-gate <set|clear>`** gives granular adapters explicit control over when echo communes may fire, independent of activity.\n 194:  **ECHO CADENCE (ratified 2026-08-03, LOCKSMITH grill — releases#113; supersedes arm-on-idle):** the gate is armed on the **busy** edge and the **idle edge does NOT disarm it**. The echo **fires when the armed gate reaches the configured age, REGARDLESS of current activity state**, then resets the age and **re-arms only if the session is still busy**. Consequences that make this the ruled shape: a long autonomous turn ages out and echoes on its own, so cadence never depends on turn boundaries; and a s...\n 195:- **`api worker-start`** / **`api worker-stop`** — Worker (subagent) perch create/teardown under the parent (nested, registry-tracked).\n 196:- **`api worker-poll`** — a Worker (subagent) receives its queued messages (inbound from Self or sibling Workers).\n*197:- **`api boundary <clear|compact>`** — context-boundary report; **carries the new `session_id`** (it rotates on `/clear` or `/compact`), so the daemon rebinds the perch to the new session id while keeping the stable identity + `parent_pid` anchor. Authors a **Self-resume commune** (resume the Self session → commune file-drop) rather than a background echo — strong live-context signal at the boundary (see `docs/CONTEXT-MEMORY.md`). **Rotation credential** (ADR-0032): the proof of association for thi...\n*198:- **`api session-end`** — session stop/crash report → soft teardown by default (preserve perch + spool + tracked history for recovery — KNOWN-HAZARDS 6.2). **`--erase`** instead hard-wipes the perch and tracked history (for ephemeral/secondary adapters that act as robust agent-spawned-agent surfaces).\n 199:\n*200:**`spt endpoint purge <id>`** (CLI, not `api`) — the standalone, formal **full teardown**: wipe an endpoint and *every* record keyed on it. It is the dev/CI sibling of `api session-end --erase` (which is adapter-triggered at session end); `purge` is the explicit operator/test command for clean setup-and-reset. **Deliberately NOT consent-gated** — a local dev/test op, never a peer-visible action. **Offline-only**: it refuses a live / daemon-hosted endpoint (deleting records out from under a running ho...\n 201:_Avoid_: consent-gating it (it is intentionally ungated, for CI); treating it as a sync/remote op (local-only); a soft variant (purge is always the hard, full wipe — soft teardown is `endpoint stop`). **Read that soft/hard contrast on the RECORD axis only** (ADR-0045): `stop` is *record*-preserving (spool.db + info.json survive) where `purge` wipes them. It says nothing about processes — on the **process** axis `stop` is hard: for a broker-hosted endpoint it reaps the session and its descendant subtr...\n 202:<!-- [doc->REQ-ENDPOINT-PURGE] -->\n 203:\n 204:- **`api history-log`** — Path B: ingest normalized records into spt-core's native history store.\n*205:- **`api presence`** — adapter reports user interaction → updates the presence datum `(last_active_node, last_active_endpoint, ts)`. In the spt-hosted topology, presence is **also** updated by the broker *detecting* (sensing, not watching/logging) user input on a held PTY — privacy-preserving (it notes that input occurred, records no content).\n 206:- **`api emit --type <sensory_type> <payload>`** — a broker-launched **Shell** binary pushes a sensory payload to its owner agent (owner known from `api bind`; REST-only, never spooled). See the Shell model.\n 207:\n*208:**Not `api` commands — file-drop flow:** `commune` and `signoff` are deprecated as commands (modern SPT) in favor of file drops. The agent/adapter writes `<id>-commune.md` / `<id>-signoff.md`; the daemon watches the manifest-declared commune/signoff dirs (the spawn-session seam fields), ingests, and deletes (drop files are daemon-owned single-writer — KNOWN-HAZARDS 6.4). These stay off the `api` surface and the agent surface alike. **This path is the Self's, and spt-core's echo-commune never writes i...\n 209:\n 210:### Startup flows (the two topologies)\n 211:\n*212:**Adapters never resolve `$SPT_HOME`.** spt-core install registers its binary directory on the system-wide PATH, so adapters call `spt api …` on any OS without path math. All harness↔daemon bridging goes through `spt api` commands (the daemon is always running, or auto-started — below), so there is **no adapter-written file** in the bind path.\n 213:\n 214:**Harness-hosted (e.g. spt-plugin; the harness binary is user-launched, harness is the parent).** Key constraint: the SPT *live agent* does not exist until the agent invokes start — the `live_id` isn't chosen at session boot, and `$LIVE start` is itself invoked *behind the Monitor tool*, so it becomes the long-running relay. So binding cannot happen at SessionStart directly. A **seed record** (daemon-held, in-memory — not a file) bridges the gap:\n*215:1. The harness's SessionStart hook calls **`spt api seed --pid <parent_pid> --session-id <sid> [cwd]`**. The daemon records an ephemeral in-memory **seed entry** keyed by `parent_pid` — the session details the spt-hosted topology would share directly, minus the not-yet-chosen `live_id`. The seed is **adapter-agnostic**: it carries no `adapter_name`. <!-- [doc->REQ-START-5] --> *Which* adapter/profile a session belongs to is resolved later, at bind, as a read against the live registry (below) — so one...\n*216:2. The agent runs `/spt:live <id>` → the adapter's `$LIVE start <id>` alias = **`$SPT listen <id>`** (= `spt api listen <id>`), invoked via Monitor. It self-discovers its `parent_pid`, the daemon matches the seed entry by that pid (validated against `session_id` to defeat PID-recycling — KNOWN-HAZARDS 5.1), **resolves the owning adapter/profile** (the bind-time resolution below), creates/revives the perch binding `live_id` ↔ session details, then enters the long-running relay loop streaming events ...\n 217:3. The always-on daemon holds the perch, spool, registry, and daemon-spawns the Psyche (via the spawn-psyche seam) — no separate wrapper. The relay is purely the delivery pipe.\n 218:   - Seed entry refreshed on each SessionStart (keeps `session_id` current across `/clear`, since `parent_pid` is stable while the harness process persists). If a harness has no SessionStart-equiv, `start` may carry the details directly as args — the seed is the preferred convenience, not the only path.\n 219:   - The same seed + bind-time resolution serves a **ReadyAgent** bringup (`$SPT ready`/poll), not just a LiveAgent — a harness-hosted ready agent is seeded and resolved identically (it just binds a poll listener, no Psyche).\n 220:\n*221:**Bind-time adapter/profile resolution (ADR-0021).** Because the seed is adapter-agnostic, `listen`/`poll` resolve the owning adapter/profile when they bind, as a pure read — never a seed-time snapshot that could drift. `--adapter <name[:profile]>` is an **optional override** on the `api` group (an explicit choice for adapter dev/iteration); omitted, resolution runs:\n 222:1. the seed's `parent_pid` → that process's **executable basename** (case-insensitive, `.exe`-stripped);\n*223:2. **candidate adapters** = registered `kind=\"harness\"` adapters whose **`host_binaries`** (the manifest match-key) contains that basename; <!-- [doc->REQ-MANIFEST-8] -->\n*224:3. **profile**: the durable **active-profile pointer** (`spt adapter use <adapter>[:profile]` writes it; one default per `host_binary`) wins; unset → the freshest candidate adapter by `registered_at_ms`, base profile (a specific profile is only ever chosen by the pointer), name-ascending on ties; <!-- [doc->REQ-INSTALL-12] -->\n*225:4. zero candidates → a friendly error naming the binary and the `--adapter` escape. The pointer is a standing user preference (durable on disk, never auto-written by install/update); the seed is ephemeral — see ADR-0021.\n 226:\n 227:**Daemon auto-start:** the daemon is per-machine always-on (OS-service registered), but any `spt api` invocation that needs it will **start it if absent** (fresh boot, crash, never-installed-as-service). `$SPT listen` for the first SPT session on a machine thus transparently spins up the daemon. Ensure-running lives in the `api` layer generally; `listen` is the reliable anchor.\n 228:\n...\n 231:2. The binary boots and fires **`api bind`** (or skips it under the strict UUID-injection + stable-pid commitment). **No catalyst/seed file** — the daemon is the launcher, already holds a direct channel (it spawned the process and owns the PTY), so a file round-trip would only add drive churn for no benefit.\n*232:3. The daemon delivers events; method is **manifest-configurable per activity-state** — direct PTY injection, or a relay even here (some adapters prefer a relay over PTY injection for idle delivery), or HTTP. During *activity*, delivery still defaults to the non-disruptive hook-injection path, not raw PTY writes.\n 233:4. Psyche is daemon-spawned, same as above.\n 234:\n*235:So the old `$LIVE start` splits by topology: harness-hosted = SessionStart writes an adapter-agnostic seed → `$SPT listen <id>` consumes seed (by `parent_pid`) + resolves adapter/profile (ADR-0021) + binds + relays — legacy parity (`$LIVE start <id>` → `$SPT listen <id>`, no mandatory `--adapter`); spt-hosted = daemon spawn-session + `api bind` (direct, no file). The asymmetry is the file-bridge-only-when-no-direct-channel principle.\n 236:\n*237:**Env-var aliases:** adapters inject clean env-var aliases for in-session invocation (heirs to today's `$OWL`/`$LIVE`), e.g. **`$SPT` = `spt api`** so a Monitor-bound call reads `$SPT listen <id>`. spt-core supplies the subcommands; the adapter supplies the env aliases (manifest philosophy).\n 238:\n 239:### Endpoint types\n 240:\n...\n 253:\n*254:*Psyche-host health — harness-reachable failure signal (v0.8.1, REQ-HAZARD-LIVEHOST-BOOT-RACE):* a LiveAgent's `status=online` is daemon-authoritative liveness and **stays authoritative** — but it does not by itself prove the daemon hosted a Psyche. When the brain's live-host reconcile fails to spawn the Psyche (e.g. the adapter's psyche binary is absent from its install dir, or the net-less boot-race starves the host), that failure was previously **silent** — only an `eprintln!` on the brain's inv...\n 255:\n 256:**Summarizer**:\n 257:The ephemeral, cheap model that builds a **commune delta** from a Self's recent turns and feeds it *into* the **Psyche** as inbound context. A distinct actor from the Psyche — different (cheaper) model, fire-and-forget, **no perch** (not an endpoint type). It authors *commune* deltas only, **never** *reply*/*notify*.\n...\n 271:**Gateway** (concept ratified 2026-06-11; registered via the open type system, first instance downstream):\n*272:A **human-backed endpoint** — a user's specialized window into the subnet from a device or surface with no conventional-harness compatibility. Nothing LLM-shaped runs there; the intelligence at the endpoint is the **user**. Addressable like any endpoint (receives digests/messages, sends via the normal verbs) and may **own Shells** (it is an owning endpoint — see §Shell model). Distinct from a Shell: a Shell is *driven from elsewhere*; a Gateway *originates* interaction. No `tracked/` mind, no Psyche...\n 273:\n 274:<!-- [doc->REQ-MSG-5] -->\n 275:A message sent from a Gateway carries **the user's authority** — it *is* the user speaking through a device — and is delivered typed **`user-msg`** (ratified 2026-06-12) so receiving agents weight it as user instruction, not peer-agent chatter. The type is **identity-gated, never payload-trusted** (the KH 7.3/7.5 posture): the daemon permits `user-msg` only from user-backed origins (a Gateway endpoint, the local user's own CLI) and re-stamps an agent-family sender's `user-msg` down to plain `msg` —...\n...\n 279:\n*280:A Gateway endpoint binary is revived by **existing machinery only** (settled 2026-06-12, two corrections deep): while running, the bridged device's link liveness drives ordinary **instance state** (sustained device silence → dormant; device contact → active — the driver-attach rule). Across a node restart, revival rides a **co-located shell's wake-watcher** — the Gateway typically owns a shell instance on its own gateway host; that shell's offline wake-watcher (one of the two classes of third-par...\n 281:_Avoid_: calling a Gateway a Shell or an agent; \"console\", \"remote\".\n 282:\n 283:**PresenceChannel** (broker endpoint — concept locked, impl deferred past v1):\n...\n 293:**ResidentService** (concept ratified 2026-07-26, ADR-0049 — the supervised substrate; first consumer spt-alchemy's Hub Daemon):\n*294:A **daemon-supervised binary an adapter declares, with no perch, no identity, no address.** Core owns the process from birth: the daemon spawns it **job-neutrally** (never a shell's child — a shell's tree-kill and a launching terminal's Job Object cannot reach it) and supervises it with the wake-watcher scaffolding (backoff, give-up latch, one-per-instance lock, orphan-kill, brain-side reconcile), running **independent of any agent's liveness**. Declared by the adapter manifest's **`[service]`** sectio...\n 295:_Avoid_: calling it an endpoint (no identity, no address — that is the [[AlwaysOnEndpoint]] layered on top); calling it a Shell (owner-less, not driven); \"daemon\" unqualified (the node has one daemon; this is a supervised service under it); treating its supervision record as liveness truth (there is none — liveness is the child handle).\n 296:\n 297:**AlwaysOnEndpoint** (always-on endpoint; concept ratified 2026-06-21, re-based on the substrate 2026-07-26 per ADR-0049 — core kind, first instance downstream `spt-discord`):\n...\n 330:\n*331:Concrete Shells live **downstream in shell-adapter repos** (first shipped: `spt-shell-notify`, the OS-notification shell — the mechanism itself delivered in v1); the PresenceChannel implementation and presence gossip remain deferred.\n 332:\n*333:**Shells differ structurally from agent endpoints** (full treatment below): a Shell has a node-local perch but **no `tracked/` context** (no mind to sync); its logs are node-local; it is **adapter/platform-bound, not adapter-agnostic**; and its lifecycle is link/teardown, not the dormant/suspended resting model of agent instances.\n 334:\n 335:#### Shell model (detailed)\n 336:\n...\n 338:\n*339:**Provided by a shell adapter.** There are two kinds of adapter, both manifested, both under `adapters/`: a **harness adapter** (`kind=\"harness\"`, e.g. `claude-spt`) hosts *agent* endpoints; a **shell adapter** (`kind=\"shell\"`, e.g. `GameRobot`) provides a *Shell* endpoint. `Shell` is the endpoint type; the shell adapter name is the provider, and its manifest defines the Shell's capability surface. A shell binary is **only ever launched by SPT** (the broker spawns it; never user-launched).\n 340:\n 341:**Three channels** over the typed/binary substrate:\n*342:- **command** (agent→shell, durable/spooled) — the manifest's capability toolset; drives the shell and shell→environment interaction.\n*343:- **text+file** (2-way, durable/spooled; per-shell) — general payloads; for a shell it is a *forwarder* (harness adapters have this channel too). File transfers are progress-queryable (below).\n 344:- **sensory** (shell→agent, **REST-only, never spooled**) — images, sounds, and arbitrary descriptive sensory payloads (\"movement completed\", \"obstructed\", \"bumped\", temperature, energy level). Ephemeral — delivered to the agent's live session; dropped if the agent isn't live.\n 345:- **drive** (owner→shell, **REST-only, never spooled, latest-wins**) — continuous-control payloads steering a surface in real time (scroll/crank state, stick positions, avatar movement). The owner→shell mirror of **sensory**: ephemeral, dropped if the shell is offline, a missed frame is superseded by the next — spooled replay of stale control on relink would be actively wrong, which is why this is not the command channel. Commands = discrete + durable; drive = continuous + ephemeral. (Minted 2026...\n 346:\n*347:<!-- [doc->REQ-SHELL-4] shell tunnel: a long-lived reliable-ordered link-bound QUIC stream pair carrying opaque bytes the taxonomy never reinterprets; manifest opt-in, not enveloped/MAC-framed/spooled; link-break closes it; reliable-ordered ⇒ on-LAN posture -->\n 348:Channels carry typed, taxonomy-interpreted payloads. Distinct from them, an owner↔shell link may also hold a **shell tunnel**: a long-lived, **reliable, ordered** byte stream (a dedicated QUIC stream pair bound to the link) for protocol traffic the channel taxonomy must NOT reinterpret — opaque wire protocols spoken end-to-end (first consumer: USB/IP URB traffic to a usbip shell). Not spooled, not enveloped; the link's lifecycle governs it — a link-break closes the tunnel. Reliable-ordered is the p...\n 349:\n 350:<!-- [doc->REQ-SHELL-5] owner-type-agnostic: control-exclusivity keys on the owner endpoint_id, never the owner's endpoint type -->\n...\n 352:\n*353:**Command-delivery method** is the shell adapter's choice (same modes as agent inject-input): HTTP REST, stdin-via-broker, or child-relay (`api poll`). The binary is always broker-launched, so binding is direct (`api bind`, no seed-file).\n 354:\n*355:**Lifecycle = online / offline / torn-down.** A **link-break always closes the broker + shell binary** (with an optional manifest-declared **pre-close instruction** to the binary + a **termination timeout** for graceful shutdown). Then: an **ephemeral** shell (a manifest property, not an agent choice) is fully torn down **and removed from the agent's shell history**; a **persistent** shell keeps its perch **offline** (re-linkable — the binary is re-spawned on relink). **The binary never survives a link...\n 356:\n*357:**Broadcast policy** (manifest): the shell advertises its availability to all agents on the subnet, only same-node agents, or not at all (agents learn of it only by user instruction). Shells run only on nodes where the shell adapter is installed/advertised.\n 358:\n*359:**Instantiation scope (orthogonal to broadcast):** `broadcast` governs *discovery* only; *permission* to instantiate is **\"shell adapter registered on this node\" + the per-shell approval gate** (below). Any agent **instance running on a node** where the shell adapter is registered may `shell spawn` it. A **cross-node** spawn (owner on one node, the shell binary on another) is **not** special-cased — it rides the deferred **instantiate-anywhere** primitive and its consent gate; same-node spawn does not.\n 360:\n*361:**`shell spawn` mints a new instance, it is not the online switch.** `spt shell spawn <adapter> [--id]` creates a *new* Shell identity (`<adapter>-<n>` + its perch + the owner link). Bringing an *existing* offline (persistent) instance back online is **`shell relink <id>`** (re-spawns the binary), or happens automatically via `persistent` (auto-online with the owner) / `wake_command` (offline wake-watcher). `shell teardown <id>` destroys it. So **`spawn`:create :: `relink`/`persistent`/`wake`:online** ...\n 362:\n*363:**per-shell instantiation approval (`require_approval`, manifest enum):** gates `shell spawn`, reusing the consent plumbing (grant store + interactive escalation; see Consent & security gates).\n 364:- `none` (**default** — matches the system's everything-opt-in posture) — the agent spawns freely within scope.\n*365:- `remembered` — prompt on first spawn; **allow-always writes a persistent grant** (`spawn-shell × agent × (node, shell-adapter)`) so later spawns auto-allow; allow-once does not.\n 366:- `always` — prompt on **every** spawn; allow-always is suppressed (no persistent grant).\n 367:\n*368:The manifest value is the **floor**: a node/endpoint setting may **tighten** (demand approval where the manifest says `none`) but never loosen below what the shell requires.\n 369:\n 370:<!-- [doc->REQ-CONSENT-3] -->\n*371:**per-capability approval gates** (ratified 2026-06-11, Gateway grill): the same `require_approval` enum may ride **individual capability entries** in a shell manifest — gating the dangerous *operation*, not just the spawn. Same grant store, same interactive escalation, same floor semantics. A capability may declare a **class key** so grants are scoped finer than the capability itself: the first consumer is the usbip shell's `attach`, granted per **(owner endpoint × device class × node)** — a remem...\n 372:\n*373:**instance cap (`max_instances_per_owner` + `over_cap`, manifest):** an optional ceiling on how many instances of this shell adapter **one owner endpoint** may hold. The count is **all existing instances** — online *and* offline, every non-torn-down perch (`shell teardown` frees a slot) — so offline persistent shells cannot be stockpiled to evade it. Unset ⇒ unlimited. At the cap, **`over_cap`** decides: `reject` (**default**) refuses the spawn outright; `approve` requires per-spawn approval for ea...\n 374:\n\n[Showing lines 1-271 of 543 (49.8KB limit). Use :272 to continue. Some lines truncated to 512 chars]"}],"details":{"scopePath":".worktrees/ws272-w3-drift/CONTEXT.md","searchPath":"C:/Users/decid/Documents/projects/spt-core/.worktrees/ws272-w3-drift/CONTEXT.md","cwd":"C:\\Users\\decid\\Documents\\projects\\spt-core","matchCount":185,"fileCount":1,"files":[".worktrees/ws272-w3-drift/CONTEXT.md"],"fileMatches":[{"path":".worktrees/ws272-w3-drift/CONTEXT.md","count":185}],"truncated":true,"displayContent":"   22│**spt plugin** (separate downstream project — NOT an spt-core deliverable):\n  *23│A rebuilt version of today's Claude Code `spt` plugin. It is the **first consumer** built *atop* spt-core and the **acceptance proof** of spt-core v1 (it reaches feature parity with modern SPT while delegating all core functionality to spt-core, primarily via `spt.exe`, with deeper hooks where useful) — but it **lives and builds in its own repository, outside spt-core**. It is a Claude-Code-specific *adapter*: it holds the Claude Code conventions (hooks, slash-commands, skill/plugin layout, `claude` se...\n   24│\n   25│**Pi** (disambiguation — two meanings, never conflate):\n   26│(1) **Pi, the coding agent/harness** (`badlogic/pi-mono`) — a harness example alongside Claude Code and Codex; this is the meaning in user-facing harness lists. (2) **Pi-class node** — Raspberry-Pi-class low-power hardware hosting a Shell-only or headless SPT node; an incidental hardware descriptor, never an explicit product example. Public-facing docs must disambiguate or avoid the bare word.\n     │...\n   36│- **broker** (stable \"kernel\") — holds *only* the un-transferable, must-not-die resources: PTY master fds, the spawned harness child processes, and listening network sockets. Minimal, dumb, versioned local IPC. Almost never updates.\n  *37│- **daemon brain** (\"userspace\") — all logic (routing, registry, pulse/psyche loops, manifest parsing, update orchestration). Restarts freely on update; rehydrates from disk state and re-attaches to the broker's held handles.\n   38│\n   39│Logical addressing is unchanged — still one per-machine `spt-daemon`; the broker is an internal layer, not separately addressable. There is exactly **one broker per machine** (per `SPT_HOME`) — *not* one per endpoint: a single broker holds every hosted endpoint's resources, and it is present whenever the daemon runs, even with zero endpoints online (the bare-daemon case). It is therefore the always-present per-machine layer, which is why the single-daemon lock + liveness anchor belong to it.\n   40│\n   41│**in-session relay**:\n  *42│A thin, stateless `spt.exe` task that exists only in **harness-hosted** sessions (where the agent harness is the parent process and spt cannot reach into its process tree — today's Monitor model). It streams the daemon brain's events into the session's stdout. All *stateful* listener logic lives in the daemon; the relay is a dumb pipe, freely killable and respawnable. **spt-hosted** sessions need no separate harness-owned relay — the daemon owns the PTY and consumes the same poll feed itself. Idle de...\n   43│\n   44│### Deliverable shape\n   45│\n     │...\n   57│**AgentRuntime** (Rust trait, implementation detail):\n  *58│The internal Rust abstraction over a harness. Anything spt-core needs to do *to* or *with* an agent goes through this trait. Most consumers never see it directly — they configure spt-core via a manifest, and spt-core's default `ManifestRuntime` implementation executes against the manifest.\n   59│\n   60│**harness contract** (umbrella term):\n  *61│The full surface a harness binds to in order to participate in the spt-core ecosystem. Has two equally-important halves: the **runtime manifest** (outbound — how spt-core drives the harness) and the **subcommand surface** (inbound — how the harness reports events back to spt-core). A harness implementation is one TOML/YAML manifest + a binding from the harness's own hook system into `spt.exe <subcommand>` calls.\n   62│\n  *63│**runtime manifest** (outbound half of the harness contract):\n  *64│A declarative configuration file (TOML/YAML — schema TBD) that tells spt-core how to drive a specific harness. Declares: how to invoke an agent session, how to look up conversation history for an echo commune, how to spawn/resume a Psyche-equivalent, which binary or command implements each harness-side operation, and which endpoint types this harness supports. spt-core is the actor for each of these; the manifest tells it what to do.\n   65│\n  *66│Example shape (illustrative): `spt.exe --manifest spt-plugin.toml live start <id>`. A harness like the planned spt plugin wraps this invocation into the `$LIVE` / `$OWL` environment variables it injects into its sessions, so harness-internal callers continue to invoke `$LIVE` / `$OWL` unchanged.\n   67│\n   68│**subcommand surface** (inbound half of the harness contract):\n   69│The stable set of `spt.exe <subcommand>` entry points that harnesses bind their own hook systems to. When the harness's runtime emits an event (subagent started, tool just invoked, user typed `/clear`, session crashed), the harness's hook fires a short-lived `spt.exe <subcommand>` invocation that mutates on-disk SPT state (perch registry, spool, etc.). spt-core publishes this surface; harnesses author the bindings.\n   70│\n  *71│**Naming convention:** these inbound, machinery-facing commands are prefixed **`api `/`api-`** (e.g. `spt api bind`, `spt api state`) to distinguish them from the agent-facing verbs an agent invokes directly (`send`, `ring`, `ready`, …). The `api` namespace is the harness/adapter commands-API; the unprefixed namespace is the agent surface.\n   72│\n  *73│Together: manifest + subcommand surface = the complete harness API. A sidecar-style long-running adapter process speaking a wire protocol is explicitly **deferred** as a possible v2 alternative for harnesses that outgrow the manifest+hooks shape (e.g. need streaming or in-memory state across events). Not built day-one.\n   74│\n  *75│**adapter manifest header** (`adapter_name` + version compat):\n  *76│Every manifest declares a unified **`adapter_name`** (e.g. `claude-spt`), carried on every `api` invocation too. It is load-bearing: one daemon hosts endpoints from multiple adapters, so it resolves an endpoint's manifest + seams by `adapter_name`; adapter-update ripples target by it; capability/manifest lookup and telemetry key on it. The header also declares the adapter's own version and a **`min_spt_core_version`** — the minimum spt-core the adapter requires. This declaration must be **readable befo...\n   77│\n  *78│<!-- [doc->REQ-MANIFEST-2] -->\n  *79│**adapter profile** (ratified 2026-06-11, Gateway grill; future spt-core milestone — first beneficiaries `spt-claude-code` and the usbip shell):\n  *80│A named **sparse overlay** on its parent adapter manifest. Merge semantics are **leaf-replace**: a profile key replaces the whole value at that path (arrays included — never spliced or appended). The merged result is a complete manifest, and the profile behaves as a distinct adapter option everywhere: canonical addressing is the composite **`<adapter>:<profile>`** (`claude-spt:work`, `spt-usbip-driver:hid-only`) in every place a bare `adapter_name` rides today (perch `info.json`, capability resolution,...\n  *81│_Avoid_: \"manifest fork\", \"child adapter\", per-profile versioning.\n   82│\n  *83│**adapter strings** (ratified 2026-06-11, Gateway grill):\n  *84│<!-- [doc->REQ-MANIFEST-3] -->\n  *85│A `[strings]` manifest section — an adapter-authored JSON/TOML KV tree, dot-path-readable by anything on the node via `spt adapter get-string <adapter-option> <key.path>` (e.g. a harness hook fetching per-profile `additionalContext` — one hook script serves every profile, only the data differs). Resolution rides the **same leaf-replace profile overlay** as the rest of the manifest: a shipped or local profile may override base strings; `get-string` returns the merged view for the named adapter option....\n  *86│<!-- [doc->REQ-MANIFEST-5] -->\n  *87│**File-backed strings** (M12-W3): a `[strings]` value MAY be a **file pointer** instead of an inline literal — a value-position table with **exactly one** key `file`: `skill = { file = \"skill.md\" }`. `get-string` resolves it to the file's **contents** (so large bodies — skill-instructions, hint text — stay out of the manifest). The exactly-one-key rule is the disambiguation: any other table shape stays an opaque nested strings tree (existing trees untouched), and `{ file = … }` is reserved as the...\n  *88│_Avoid_: treating strings as config knobs for spt-core itself (those are global settings); \"adapter KV store\" as a separate registry; putting user files in the adapter-shipped `strings/` dir (clobbered by updates — use a local profile).\n   89│\n  *90│**manifest substitution in `[strings]`** (ratified 2026-06-25, v0.16.0 update-arc grill):\n  *91│`get-string` resolves a set of **adapter-static** substitution keys inside a returned string value at **read time** (lazily, like file-backed strings): **`{adapter_dir}`** — the registry record's precise `source_dir` (the install dir; survives updates; the same dir bare-program resolution already uses) — and **`{adapter_name}`**. Session-scoped keys (`{id}`/`{session_id}`/…) are **not** available: `get-string` carries no session context today, and a `get-string --session-id` for session-scoped subs...\n  *92│_Avoid_: session-scoped substitution through bare `get-string`; reading this as spt-core executing a string (it never does — the adapter wrapper executes the resolved value).\n   93│\n   94│**keyword hints** (ratified 2026-06-12 — core milestone A):\n  *95│<!-- [doc->REQ-MANIFEST-4] -->\n  *96│Once-per-session usage/syntax hints, a first-class adapter feature: the manifest's `[hints]` section declares entries of `{keywords (literal default, regex opt-in), text}`; the adapter's user-prompt hook pipes the **full user message** to `spt api hint --session <id>` (stdin) and receives matched hint lines (`keyword hint for SPT adapter <name>: \"<kw>\"-->{text}`) for its context-injection channel. The daemon keeps a per-session seen-set — each hint fires **once per session** (a `/clear` mints a new ses...\n  *97│_Avoid_: unconditional static context (that's the adapter's own preamble); firing per-message.\n   98│\n   99│**shell keyword hints** (releases#133, operator-GREENLIT; shape ruled 2026-08-30):\n  100│<!-- [doc->REQ-SHELL-HINTS] -->\n *101│`[[hints]]` was never harness-only — it is a top-level manifest section with no kind gate, so a `kind = \"shell\"` adapter has always been able to declare hints; what was missing was a reader, because the only reader resolved the endpoint-bound manifest. The now-signal **HINTS** category now also reads the shell adapters an endpoint can see, one line each under the per-source cap above. **Two arms, decided by INSTANTIATION and never by link state:** an adapter this owner holds an instance of surfaces the...\n *102│_Avoid_: gating the shell arm on the harness manifest (an endpoint with no bound manifest still owns shells); filtering the full arm on online/offline; naming a surfacing command that does not exist at the shipping head.\n  103│\n *104│**adapter update declaration** (manifest field):\n  105│<!-- [doc->REQ-UPD-9] -->\n *106│Each adapter manifest declares how spt-core should *ripple-update the adapter itself* (see Self-update). One of: **file-pull** (a plugin-directory lookup regex + a gh repo for the adapter's latest files — spt-core fetches + swaps), **delegated command** (a binary command the adapter owns, e.g. `claude.exe plugin update` — spt-core invokes it), or **gh_release** (the adapter ships its updates from its own GitHub releases). After initial bootstrap, the plugin no longer self-manages updates; spt-core co...\n  107│\n *108│**adapter packaging & live update** (v0.13.2; ADR-0024, ADR-0025):\n *109│<!-- [doc->REQ-ADAPTER-GH-TRANSPORT] -->\n *110│A `.spt` may be **multi-platform**: shared `manifest.toml` + `strings/` at the root, role binaries under per-target-triple subdirectories (`x86_64-pc-windows-msvc/`, …); install/update extracts the shared root plus only the current node's triple, flattened into `install_dir`, so flat `<install_dir>/<program>` resolution is unchanged. It stays one signed asset (`adapter.spt`, plain-tar or gzip); a multi-platform archive missing the recipient's triple is a typed `NoArtifactForPlatform`. Large adapters ma...\n *111│<!-- [doc->REQ-ADAPTER-UPDATE-MESSAGE] -->\n *112│An optional **`[update].message`** (avenue-agnostic) is a plain multi-line operator notice surfaced to stdout, markdown-rendered (the helpfmt prose path), **only when an update is actually applied** (the version changed) — never on a no-op. It is read from the newly-installed manifest with no `{key}` substitution; its use is to announce a post-update action (e.g. \"run `/reload-plugins` in any ongoing sessions\").\n  113│\n  114│**composite update — `[update.post]`** (ratified 2026-06-25, v0.16.0 update-arc grill; ADR-0029):\n *115│An optional **avenue-agnostic** post-step `{ command, self_verifies }` spt-core runs **after** the primary avenue resolves — in the same `spt adapter update` **and at `spt adapter add`** (install is the first update, so a fresh install conducts the post-step too; bug-#1 operator ruling, v0.19.0 — the eager-extract acquisition runs it post-registration, a delegated acquisition after the acquisition succeeds, and only the payload-less `file_pull` PENDING add defers it to the payload's arrival) — so o...\n  116│\n *117│**resident adapter binary**: an adapter-owned process spt-core keeps alive for an endpoint's lifetime (today the `[message-idle-translation-binary]`), as opposed to **ephemeral** adapter binaries — the Psyche loop (daemon-hosted, ADR-0004), the `[digest]` extractor, `[session.*]` runners, hooks — which spawn on demand and pick up an update on their next invocation. Only resident binaries are stopped/restarted on a live update; ephemerals self-heal.\n  118│_Avoid_: calling the Psyche loop or an on-demand extractor a \"resident\" binary; \"restart the endpoint\" for what is a per-binary cycle.\n  119│\n *120│**session-invocation declaration** (manifest field, noted for spt-plugin parity):\n *121│How the harness spawns agent sessions, including Psyche and echo-commune sessions. For the rebuilt spt-plugin, Psyche and echo communes must migrate **off `claude -p`** (imminent Claude Code billing changes) to headless `claude` sessions (`--resume` for the Psyche). This is an adapter/manifest concern, not a core concern, but the parity milestone must carry it.\n  122│\n *123│### Manifest seams (outbound contract, detailed)\n  124│\n *125│Governing principle: **SPT is not a harness.** Model choice, billing shape, harness-internal env, and harness-internal context are entirely the adapter's concern, expressed inside the adapter's own command templates. spt-core owns only the template *mechanism* (substitution keys), the substitution *values* it is responsible for, and the surrounding lifecycle. Env for the *endpoint binary itself* is auto-handled by spt-core/broker; env for the *agent running inside* that binary is the adapter's config (e....\n  126│\n *127│**spawn-session seam** — launch a new agent session on this node. Manifest provides: a command template; `cwd`/project; a `headless` flag (optional, default false — for the GUI's resume-of-compatible-adapters); a `resume` flag (optional); and the `commune` + `signoff` file directories relative to `cwd` (so the daemon knows where to watch). Substitution keys spt-core can supply: `{id}` and, optionally, a spt-core-generated valid session UUID (e.g. injected as `--session-id {uuid}`) so an adapter can s...\n  128│- **id resolution:** `id` is optional. With no id, spt-core reproduces today's no-id `/spt:live` behavior — run the lone live agent if that's all the project has; show a picker with proposed default IDs if the project has none; let the user choose if there are several.\n  129│\n *130│**post-spawn seam** — the just-launched binary calls an spt-core command on boot (via the adapter's SessionStart-equivalent hook) to bind itself. Needed because the harness's own session id usually isn't known until after the binary runs. Payload: the harness `session_id` (when binary-generated rather than spt-core-injected); the `parent_pid` (the stable session-binding anchor — see KNOWN-HAZARDS 2.1); an endpoint identity/type confirmation; optionally a local HTTP port the binary listens on (for HTT...\n  131│\n *132│**post-spawn is optional only under a strict commitment:** an adapter may forgo post-spawn *only* if it (a) injects the spt-core-generated session UUID at spawn AND (b) guarantees the launched-process pid IS the stable session-binding anchor (no wrapper-script / subprocess pid indirection). If either does not hold, post-spawn must fire to report `session_id` and/or `parent_pid`. UUID-injection alone suppresses only the `session_id` reporting, not the binding.\n  133│\n *134│**spawn-psyche seam** — two command templates: fresh-start and resume (the resume template includes `$session_id`). Both include `$psyche_prompt` — the revival essentials spt-core feeds the Psyche (timestamp, incoming event envelope). Everything else is the adapter's: model selection (in its template), and any harness-specific instructions the Psyche needs (Write-tool usage, commune dir) supplied as a static preamble before `$psyche_prompt` or as adapter SessionStart additionalContext. spt-core owns ...\n  135│\n  136│**history subsystem** (covers echo-commune source logs, resume briefs, and Shell logs) — two supported paths:\n *137│- **Path A — adapter-owned logs.** Manifest declares a locate-template (keyed by `$session_id`) + a **normalize-command the adapter owns** that emits spt-core's expected normalized format. spt-core docs must teach adapter devs how to build a conformant parser.\n *138│- **Path B — spt-core-native history store.** spt-core exposes a `history-log` command/API; the adapter writes its logs to spt-core in the native format and spt-core stores them. Rationale: spt-core needs its own log store for Shells anyway, and this simplifies integration for flexible/DIY harnesses.\n *139│- The **echo-commune seam** is then just a command template (adapter picks the model) that consumes whichever history path is configured for the session.\n *140│- **Why adapter-owned normalize over spt-core built-in parsers** (grounded in a Codex-CLI vs Claude-Code comparison): transcript formats diverge sharply and move fast. Claude Code = one flat JSONL per session, project-partitioned, locatable directly from the session id (`~/.claude/projects/<hash>/<id>.jsonl`). Codex = date-partitioned **rollout files** (`~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<id>.jsonl`) where the id is only a filename *substring* (must recursive-glob to locate), a **3-level tagged e...\n *141│- **Profile-relocated transcript roots — env-read capture** (ratified 2026-06-30, counter-38 field-bug grill; supersedes a rejected harness-specific `{config_dir}` proposal): a Path-A locate-template (and the `[digest].source` that reuses it) may reference a manifest-declared **`[env.<VAR>] direction = \"read\"`** var (e.g. `{CLAUDE_CONFIG_DIR}`). spt-core captures the **declared** read-vars — an explicit allowlist, never the whole env — from the session's launch environment at **bind** (the only poi...\n *142│- **`[digest]` mirrors history's two strategies — locate ownership** (ratified 2026-06-30, counter-38 W6 design-gate): like `[history]`, `[digest]` supports **`fetcher`** (the adapter's extractor **locates + reads + emits** normalized digest records; spt-core runs it bounded and consumes its stdout, doing **no** locate and **no** pre-read) alongside the original **`locate_normalize`** (`source` template → spt-core locates a **single** file + reads + pipes the bytes to the extractor as a pure stdin→...\n  143│\n *144│**activity/idle detection** — **not** PTY-quiescence (insufficient: e.g. CC's AskUserQuestion stalls the PTY while holding stdin and needing nuanced input) and **not** a manifest-declared idle signal. Instead, the adapter calls spt-core activity/idle commands at the right moments (from its hooks); those commands manage activity/idle **sentinels inside the session perch**. The idle state lives in the perch, owned by spt-core via the commands API exposed to adapter devs.\n  145│\n  146│**activity observation** (ruled 2026-07-24, rebound grill) — two avenues, deliberately split by consumer class; the digest is **not** one of them (it stays a content surface). **Push (shells only):** an owned Shell observes its *owner's* busy/idle transitions as an **activity frame** on the existing shell-link event stream — link-scoped (owner implied by the link token), **drive-class semantics** (ephemeral, latest-wins, current-state-carrying; a redundant same-state resend is a harmless no-op — th...\n  147│\n     │...\n  149│- PTY injection (with or without key/submit sequences) — spt-hosted topology;\n *150│- adapter hooks calling spt-core poll commands;\n *151│- an in-adapter-session child relay (à la CC's Monitor tool);\n *152│- adapter manifest requesting HTTP POST delivery to the endpoint binary on a local port (shared via the post-spawn seam).\n *153│Note: even spt-hosted sessions default to hook injection (or the adapter's equivalent) as the non-disruptive path **during activity**; some adapters prefer the in-session relay regardless of topology.\n  154│\n  155│**activity-gated delivery** — an inbound message routes by the receiver's activity sentinel (above). While the endpoint is **active**, the message spools for the receiver's own hook-poll to drain (non-disruptive — the *active window*). On **idle** (or an idle transition before a hook drains it), it delivers immediately — translation binary (spt-hosted) → relay-poll (either topology) → spool, in that fallback order (the *idle window*). The send-side axes below modulate which of these two windows...\n  156│\n  157│**message delivery axes** — a sent message carries independent modifiers on orthogonal axes; it is **not** a single \"type\". The flag on each axis defaults to the unrestricted value:\n *158│- **delivery window** (*when*) — **default** (both windows; delivers in whichever fires first) · **idle-only** (held for the idle window; delivered immediately if already idle) · **active-only** (active window only — the receiver's hook-poll; never wakes an idle agent). *active-only* is the renamed legacy **deferred** (the `deferred=1` spool column + `api poll --include-deferred` are its internal/adapter-facing names). Mutually exclusive.\n  159│- **channel restriction** (*through what*) — **unrestricted** (any configured inject method) · **prefer-native** (the translation binary if one is running, else fall back to the standard methods) · **force-native** (the translation binary and nothing else — no fallback, no spool-to-another-method). Mutually exclusive; composes with the window. *\"Native\"* = the `[message-idle-translation-binary]` PTY channel.\n  160│- **persistence** (*how long it waits*) — **durable** (default; spooled until delivered or TTL) · **ephemeral** (dropped if it cannot deliver in its accepted window — at the moment the window opens with no live carrier, or at TTL, whichever is first). Ephemeral is the **only** path permitted to drop silently (the REQ-HAZARD-IDLE-SILENT-NONDELIVERY carve-out); every non-ephemeral path spools and reports non-delivery. <!-- v0.15.0 PARTIAL (W3): ephemeral evaporation covers the spt-hosted-binary no-car...\n  161│Window restricts *when* delivery is accepted, channel restricts *which method* carries it, persistence restricts *how long* it waits — they compose freely (e.g. `force-native` + `active-only` = the binary injects during the active window, never idle; `force-native` + `ephemeral` = binary-or-nothing).\n  162│\n *163│**message metadata (`json`)** — a sender may attach an opaque JSON metadata block (`--json-payload`), carried as a single attr-escaped `json=\"…\"` envelope attribute **alongside** (never replacing) the body. spt-core never interprets it — pure verbatim passthrough across every rail (spool / TCP / WAN / EVENT-PART), parsed only by the receiving adapter (its hooks and/or translation binary). Collision-proof by construction: the structured data lives **inside** the single `json` value, so it can never ...\n  164│\n  165│**resume-session seam** — two distinct forms:\n *166│- **fresh-with-preload:** resume with *cleared* context (a fresh session) + psyche-download. Accepts a `$psyche-context` key to launch the fresh session with the psyche-download preloaded — or the adapter instead pulls it via an spt-core command in its SessionStart hook. <!-- [doc->REQ-RESUME-CONTEXT-PULL] --> That command is **`spt api psyche-download <id> [--session-id <sid>]`**: it emits the durable resume brief (role → live-context → project-context, project resolved from the perch's bound cwd)...\n *167│- **continue-existing:** resume an existing harness session under the adapter (its native resume).\n  168│\n *169│**capability declaration** — which endpoint types a harness/node can host (a Pi node might host only Shells, never a LiveAgent). Static manifest list, consumed by the subnet registry so a node advertises its hostable types. Exact shape is design-open.\n  170│\n *171│**adapter-update seam** — file-pull or delegated-command (see Self-update). Locked.\n  172│\n *173│There is no separate \"model/billing\" seam — those live inside the adapter's spawn/psyche/echo command templates. SPT never selects a model. The full manifest schema is `docs/MANIFEST.md`; key model-level facts from it:\n  174│\n *175│- **Command templates are opaque.** spt-core never parses out a model/tool/flag — the adapter writes the whole command line; spt-core fills substitution keys and runs it.\n *176│- **A command template's program token resolves against the adapter install dir before PATH (since v0.8.0).** A `.spt` adapter ships its built binaries to the adapter's install dir (`adapters/_github/<safe>/` via `--release`/`--github`, or the record's `source_dir` under copy-mode), so a bare program name (e.g. `claude-spt-digest …`) binds to the shipped binary first and falls back to PATH when absent — a `.spt` that ships its binaries is **self-contained**, needing no PATH placement. <!-- [doc->REQ-...\n *177│- **Hook output capability is declared per harness-event** (`can_inject`). CC's Stop hook cannot inject context — that single fact drives the echo-gate sentinel + relay fallback. The manifest expresses it so spt-core knows when to fall back.\n  178│- **Env injection is asymmetric** (file-bridge-only-when-not-launcher, applied to env): spt-hosted sessions inherit env from the broker that spawns them; harness-hosted sessions need the harness's declared env channel. With `spt` on PATH the env table is small.\n *179│- **Cross-adapter fallback** is a **node-wide setting**, not a manifest field: if a Psyche/echo invocation under one adapter is rate-limited, spt-core falls back to another adapter (e.g. `ccs` — its own adapter, not a binary-swap). <!-- [doc->REQ-MANIFEST-6] --> A fallback **target is addressed as `<adapter>:<profile>`** (not just a bare adapter_name) and resolves through the one composite-addressing resolver (`registry::resolve_option`), so a fallback may select a shipped or local profile (`ccs`, `ccs...\n *180│- **Config knobs** (pulse period, echo-commune window/gate, route-guard window, daily refresh) are spt-core **global settings** with optional **per-endpoint override**. **An adapter may DECLARE A DEFAULT, never an override** (narrowed 2026-08-03, LOCKSMITH grill — the original \"never per-adapter\" wording is superseded): a harness has real information about its own turn shape and cost, but the operator keeps the last word. Precedence, highest first: **per-endpoint override → node/global setting → ad...\n *181│- **Event-block vocabulary and file-drop filenames are fixed spt-core constants** (documented for adapter authors), not manifest-configurable. <!-- [doc->REQ-RESUME-CONTEXT-PULL] --> This includes the **checkpoint sentinel `!!checkpoint!!`** — the agent-checkpoint trigger an adapter embeds in a commune/signoff drop body (one bare token = checkpoint with default wake; a `!!checkpoint!! <text> !!checkpoint!!` pair makes the inter-marker text a custom wake directive). It is spt-core control metadata: spt-...\n  182│\n  183│### Inbound `api` surface (detailed)\n  184│\n *185│All commands below are `api`-prefixed (machinery-facing). Every `api` invocation **and** every manifest carries a unified **`adapter_name`** string (e.g. `claude-spt`) identifying the owning adapter. This is load-bearing: one daemon hosts endpoints from multiple adapters (`claude-spt`, `spt-codex`, `spt-pi`), so the daemon resolves an endpoint's manifest + seams (history normalize-command, inject method, update avenue) by its `adapter_name`; adapter-update ripples target by it; capability lookup and tele...\n  186│\n  187│<!-- [doc->REQ-API-4] -->\n *188│**Manifest resolution from `--adapter` (since v0.8.0).** `spt api <cmd> --adapter <name[:profile]>` resolves the registered adapter's manifest, `:profile` overlay, and install dir from the registry when `--manifest` is omitted — a registered adapter's `api` calls need only `--adapter`. `--manifest <path>` becomes an optional **override** (an unregistered or local-dev manifest): when present, the manifest loads from that file and the install dir is its parent directory; when absent, both come from the r...\n  189│\n  190│- **`api bind`** — post-spawn boot bind (payload above). Skeleton→live.\n *191│- **`api listen`** — *long-running* relay/poll listener that an adapter-owned (harness-hosted) session owns as a child process; streams the daemon's events to the session's stdout. Distinct from the short-lived `api poll`. This is the heir to today's Monitor-bound `$LIVE start` poll loop.\n *192│- **`api poll`** — short-lived drain of queued messages for a session (the hook-injection delivery path). `--include-deferred` optionally also drains deferred rows, for adapter flexibility (default excludes them — KNOWN-HAZARDS 1.4/4.4).\n *193│- **`api state <busy|idle>`** — adapter reports session activity; writes the activity/idle sentinel in the session perch. **The BUSY edge arms the echo-commune gate sentinel** (`.more-done`-equiv); `--no-gate` suppresses that coupling, and a standalone **`api echo-gate <set|clear>`** gives granular adapters explicit control over when echo communes may fire, independent of activity.\n  194│  **ECHO CADENCE (ratified 2026-08-03, LOCKSMITH grill — releases#113; supersedes arm-on-idle):** the gate is armed on the **busy** edge and the **idle edge does NOT disarm it**. The echo **fires when the armed gate reaches the configured age, REGARDLESS of current activity state**, then resets the age and **re-arms only if the session is still busy**. Consequences that make this the ruled shape: a long autonomous turn ages out and echoes on its own, so cadence never depends on turn boundaries; and a s...\n  195│- **`api worker-start`** / **`api worker-stop`** — Worker (subagent) perch create/teardown under the parent (nested, registry-tracked).\n  196│- **`api worker-poll`** — a Worker (subagent) receives its queued messages (inbound from Self or sibling Workers).\n *197│- **`api boundary <clear|compact>`** — context-boundary report; **carries the new `session_id`** (it rotates on `/clear` or `/compact`), so the daemon rebinds the perch to the new session id while keeping the stable identity + `parent_pid` anchor. Authors a **Self-resume commune** (resume the Self session → commune file-drop) rather than a background echo — strong live-context signal at the boundary (see `docs/CONTEXT-MEMORY.md`). **Rotation credential** (ADR-0032): the proof of association for thi...\n *198│- **`api session-end`** — session stop/crash report → soft teardown by default (preserve perch + spool + tracked history for recovery — KNOWN-HAZARDS 6.2). **`--erase`** instead hard-wipes the perch and tracked history (for ephemeral/secondary adapters that act as robust agent-spawned-agent surfaces).\n  199│\n *200│**`spt endpoint purge <id>`** (CLI, not `api`) — the standalone, formal **full teardown**: wipe an endpoint and *every* record keyed on it. It is the dev/CI sibling of `api session-end --erase` (which is adapter-triggered at session end); `purge` is the explicit operator/test command for clean setup-and-reset. **Deliberately NOT consent-gated** — a local dev/test op, never a peer-visible action. **Offline-only**: it refuses a live / daemon-hosted endpoint (deleting records out from under a running ho...\n  201│_Avoid_: consent-gating it (it is intentionally ungated, for CI); treating it as a sync/remote op (local-only); a soft variant (purge is always the hard, full wipe — soft teardown is `endpoint stop`). **Read that soft/hard contrast on the RECORD axis only** (ADR-0045): `stop` is *record*-preserving (spool.db + info.json survive) where `purge` wipes them. It says nothing about processes — on the **process** axis `stop` is hard: for a broker-hosted endpoint it reaps the session and its descendant subtr...\n  202│<!-- [doc->REQ-ENDPOINT-PURGE] -->\n  203│\n  204│- **`api history-log`** — Path B: ingest normalized records into spt-core's native history store.\n *205│- **`api presence`** — adapter reports user interaction → updates the presence datum `(last_active_node, last_active_endpoint, ts)`. In the spt-hosted topology, presence is **also** updated by the broker *detecting* (sensing, not watching/logging) user input on a held PTY — privacy-preserving (it notes that input occurred, records no content).\n  206│- **`api emit --type <sensory_type> <payload>`** — a broker-launched **Shell** binary pushes a sensory payload to its owner agent (owner known from `api bind`; REST-only, never spooled). See the Shell model.\n  207│\n *208│**Not `api` commands — file-drop flow:** `commune` and `signoff` are deprecated as commands (modern SPT) in favor of file drops. The agent/adapter writes `<id>-commune.md` / `<id>-signoff.md`; the daemon watches the manifest-declared commune/signoff dirs (the spawn-session seam fields), ingests, and deletes (drop files are daemon-owned single-writer — KNOWN-HAZARDS 6.4). These stay off the `api` surface and the agent surface alike. **This path is the Self's, and spt-core's echo-commune never writes i...\n  209│\n  210│### Startup flows (the two topologies)\n  211│\n *212│**Adapters never resolve `$SPT_HOME`.** spt-core install registers its binary directory on the system-wide PATH, so adapters call `spt api …` on any OS without path math. All harness↔daemon bridging goes through `spt api` commands (the daemon is always running, or auto-started — below), so there is **no adapter-written file** in the bind path.\n  213│\n  214│**Harness-hosted (e.g. spt-plugin; the harness binary is user-launched, harness is the parent).** Key constraint: the SPT *live agent* does not exist until the agent invokes start — the `live_id` isn't chosen at session boot, and `$LIVE start` is itself invoked *behind the Monitor tool*, so it becomes the long-running relay. So binding cannot happen at SessionStart directly. A **seed record** (daemon-held, in-memory — not a file) bridges the gap:\n *215│1. The harness's SessionStart hook calls **`spt api seed --pid <parent_pid> --session-id <sid> [cwd]`**. The daemon records an ephemeral in-memory **seed entry** keyed by `parent_pid` — the session details the spt-hosted topology would share directly, minus the not-yet-chosen `live_id`. The seed is **adapter-agnostic**: it carries no `adapter_name`. <!-- [doc->REQ-START-5] --> *Which* adapter/profile a session belongs to is resolved later, at bind, as a read against the live registry (below) — so one...\n *216│2. The agent runs `/spt:live <id>` → the adapter's `$LIVE start <id>` alias = **`$SPT listen <id>`** (= `spt api listen <id>`), invoked via Monitor. It self-discovers its `parent_pid`, the daemon matches the seed entry by that pid (validated against `session_id` to defeat PID-recycling — KNOWN-HAZARDS 5.1), **resolves the owning adapter/profile** (the bind-time resolution below), creates/revives the perch binding `live_id` ↔ session details, then enters the long-running relay loop streaming events ...\n  217│3. The always-on daemon holds the perch, spool, registry, and daemon-spawns the Psyche (via the spawn-psyche seam) — no separate wrapper. The relay is purely the delivery pipe.\n  218│   - Seed entry refreshed on each SessionStart (keeps `session_id` current across `/clear`, since `parent_pid` is stable while the harness process persists). If a harness has no SessionStart-equiv, `start` may carry the details directly as args — the seed is the preferred convenience, not the only path.\n  219│   - The same seed + bind-time resolution serves a **ReadyAgent** bringup (`$SPT ready`/poll), not just a LiveAgent — a harness-hosted ready agent is seeded and resolved identically (it just binds a poll listener, no Psyche).\n  220│\n *221│**Bind-time adapter/profile resolution (ADR-0021).** Because the seed is adapter-agnostic, `listen`/`poll` resolve the owning adapter/profile when they bind, as a pure read — never a seed-time snapshot that could drift. `--adapter <name[:profile]>` is an **optional override** on the `api` group (an explicit choice for adapter dev/iteration); omitted, resolution runs:\n  222│1. the seed's `parent_pid` → that process's **executable basename** (case-insensitive, `.exe`-stripped);\n *223│2. **candidate adapters** = registered `kind=\"harness\"` adapters whose **`host_binaries`** (the manifest match-key) contains that basename; <!-- [doc->REQ-MANIFEST-8] -->\n *224│3. **profile**: the durable **active-profile pointer** (`spt adapter use <adapter>[:profile]` writes it; one default per `host_binary`) wins; unset → the freshest candidate adapter by `registered_at_ms`, base profile (a specific profile is only ever chosen by the pointer), name-ascending on ties; <!-- [doc->REQ-INSTALL-12] -->\n *225│4. zero candidates → a friendly error naming the binary and the `--adapter` escape. The pointer is a standing user preference (durable on disk, never auto-written by install/update); the seed is ephemeral — see ADR-0021.\n  226│\n  227│**Daemon auto-start:** the daemon is per-machine always-on (OS-service registered), but any `spt api` invocation that needs it will **start it if absent** (fresh boot, crash, never-installed-as-service). `$SPT listen` for the first SPT session on a machine thus transparently spins up the daemon. Ensure-running lives in the `api` layer generally; `listen` is the reliable anchor.\n  228│\n     │...\n  231│2. The binary boots and fires **`api bind`** (or skips it under the strict UUID-injection + stable-pid commitment). **No catalyst/seed file** — the daemon is the launcher, already holds a direct channel (it spawned the process and owns the PTY), so a file round-trip would only add drive churn for no benefit.\n *232│3. The daemon delivers events; method is **manifest-configurable per activity-state** — direct PTY injection, or a relay even here (some adapters prefer a relay over PTY injection for idle delivery), or HTTP. During *activity*, delivery still defaults to the non-disruptive hook-injection path, not raw PTY writes.\n  233│4. Psyche is daemon-spawned, same as above.\n  234│\n *235│So the old `$LIVE start` splits by topology: harness-hosted = SessionStart writes an adapter-agnostic seed → `$SPT listen <id>` consumes seed (by `parent_pid`) + resolves adapter/profile (ADR-0021) + binds + relays — legacy parity (`$LIVE start <id>` → `$SPT listen <id>`, no mandatory `--adapter`); spt-hosted = daemon spawn-session + `api bind` (direct, no file). The asymmetry is the file-bridge-only-when-no-direct-channel principle.\n  236│\n *237│**Env-var aliases:** adapters inject clean env-var aliases for in-session invocation (heirs to today's `$OWL`/`$LIVE`), e.g. **`$SPT` = `spt api`** so a Monitor-bound call reads `$SPT listen <id>`. spt-core supplies the subcommands; the adapter supplies the env aliases (manifest philosophy).\n  238│\n  239│### Endpoint types\n  240│\n     │...\n  253│\n *254│*Psyche-host health — harness-reachable failure signal (v0.8.1, REQ-HAZARD-LIVEHOST-BOOT-RACE):* a LiveAgent's `status=online` is daemon-authoritative liveness and **stays authoritative** — but it does not by itself prove the daemon hosted a Psyche. When the brain's live-host reconcile fails to spawn the Psyche (e.g. the adapter's psyche binary is absent from its install dir, or the net-less boot-race starves the host), that failure was previously **silent** — only an `eprintln!` on the brain's inv...\n  255│\n  256│**Summarizer**:\n  257│The ephemeral, cheap model that builds a **commune delta** from a Self's recent turns and feeds it *into* the **Psyche** as inbound context. A distinct actor from the Psyche — different (cheaper) model, fire-and-forget, **no perch** (not an endpoint type). It authors *commune* deltas only, **never** *reply*/*notify*.\n     │...\n  271│**Gateway** (concept ratified 2026-06-11; registered via the open type system, first instance downstream):\n *272│A **human-backed endpoint** — a user's specialized window into the subnet from a device or surface with no conventional-harness compatibility. Nothing LLM-shaped runs there; the intelligence at the endpoint is the **user**. Addressable like any endpoint (receives digests/messages, sends via the normal verbs) and may **own Shells** (it is an owning endpoint — see §Shell model). Distinct from a Shell: a Shell is *driven from elsewhere*; a Gateway *originates* interaction. No `tracked/` mind, no Psyche...\n  273│\n  274│<!-- [doc->REQ-MSG-5] -->\n  275│A message sent from a Gateway carries **the user's authority** — it *is* the user speaking through a device — and is delivered typed **`user-msg`** (ratified 2026-06-12) so receiving agents weight it as user instruction, not peer-agent chatter. The type is **identity-gated, never payload-trusted** (the KH 7.3/7.5 posture): the daemon permits `user-msg` only from user-backed origins (a Gateway endpoint, the local user's own CLI) and re-stamps an agent-family sender's `user-msg` down to plain `msg` —...\n     │...\n  279│\n *280│A Gateway endpoint binary is revived by **existing machinery only** (settled 2026-06-12, two corrections deep): while running, the bridged device's link liveness drives ordinary **instance state** (sustained device silence → dormant; device contact → active — the driver-attach rule). Across a node restart, revival rides a **co-located shell's wake-watcher** — the Gateway typically owns a shell instance on its own gateway host; that shell's offline wake-watcher (one of the two classes of third-par...\n  281│_Avoid_: calling a Gateway a Shell or an agent; \"console\", \"remote\".\n  282│\n  283│**PresenceChannel** (broker endpoint — concept locked, impl deferred past v1):\n     │...\n  293│**ResidentService** (concept ratified 2026-07-26, ADR-0049 — the supervised substrate; first consumer spt-alchemy's Hub Daemon):\n *294│A **daemon-supervised binary an adapter declares, with no perch, no identity, no address.** Core owns the process from birth: the daemon spawns it **job-neutrally** (never a shell's child — a shell's tree-kill and a launching terminal's Job Object cannot reach it) and supervises it with the wake-watcher scaffolding (backoff, give-up latch, one-per-instance lock, orphan-kill, brain-side reconcile), running **independent of any agent's liveness**. Declared by the adapter manifest's **`[service]`** sectio...\n  295│_Avoid_: calling it an endpoint (no identity, no address — that is the [[AlwaysOnEndpoint]] layered on top); calling it a Shell (owner-less, not driven); \"daemon\" unqualified (the node has one daemon; this is a supervised service under it); treating its supervision record as liveness truth (there is none — liveness is the child handle).\n  296│\n  297│**AlwaysOnEndpoint** (always-on endpoint; concept ratified 2026-06-21, re-based on the substrate 2026-07-26 per ADR-0049 — core kind, first instance downstream `spt-discord`):\n     │...\n  330│\n *331│Concrete Shells live **downstream in shell-adapter repos** (first shipped: `spt-shell-notify`, the OS-notification shell — the mechanism itself delivered in v1); the PresenceChannel implementation and presence gossip remain deferred.\n  332│\n *333│**Shells differ structurally from agent endpoints** (full treatment below): a Shell has a node-local perch but **no `tracked/` context** (no mind to sync); its logs are node-local; it is **adapter/platform-bound, not adapter-agnostic**; and its lifecycle is link/teardown, not the dormant/suspended resting model of agent instances.\n  334│\n  335│#### Shell model (detailed)\n  336│\n     │...\n  338│\n *339│**Provided by a shell adapter.** There are two kinds of adapter, both manifested, both under `adapters/`: a **harness adapter** (`kind=\"harness\"`, e.g. `claude-spt`) hosts *agent* endpoints; a **shell adapter** (`kind=\"shell\"`, e.g. `GameRobot`) provides a *Shell* endpoint. `Shell` is the endpoint type; the shell adapter name is the provider, and its manifest defines the Shell's capability surface. A shell binary is **only ever launched by SPT** (the broker spawns it; never user-launched).\n  340│\n  341│**Three channels** over the typed/binary substrate:\n *342│- **command** (agent→shell, durable/spooled) — the manifest's capability toolset; drives the shell and shell→environment interaction.\n *343│- **text+file** (2-way, durable/spooled; per-shell) — general payloads; for a shell it is a *forwarder* (harness adapters have this channel too). File transfers are progress-queryable (below).\n  344│- **sensory** (shell→agent, **REST-only, never spooled**) — images, sounds, and arbitrary descriptive sensory payloads (\"movement completed\", \"obstructed\", \"bumped\", temperature, energy level). Ephemeral — delivered to the agent's live session; dropped if the agent isn't live.\n  345│- **drive** (owner→shell, **REST-only, never spooled, latest-wins**) — continuous-control payloads steering a surface in real time (scroll/crank state, stick positions, avatar movement). The owner→shell mirror of **sensory**: ephemeral, dropped if the shell is offline, a missed frame is superseded by the next — spooled replay of stale control on relink would be actively wrong, which is why this is not the command channel. Commands = discrete + durable; drive = continuous + ephemeral. (Minted 2026...\n  346│\n *347│<!-- [doc->REQ-SHELL-4] shell tunnel: a long-lived reliable-ordered link-bound QUIC stream pair carrying opaque bytes the taxonomy never reinterprets; manifest opt-in, not enveloped/MAC-framed/spooled; link-break closes it; reliable-ordered ⇒ on-LAN posture -->\n  348│Channels carry typed, taxonomy-interpreted payloads. Distinct from them, an owner↔shell link may also hold a **shell tunnel**: a long-lived, **reliable, ordered** byte stream (a dedicated QUIC stream pair bound to the link) for protocol traffic the channel taxonomy must NOT reinterpret — opaque wire protocols spoken end-to-end (first consumer: USB/IP URB traffic to a usbip shell). Not spooled, not enveloped; the link's lifecycle governs it — a link-break closes the tunnel. Reliable-ordered is the p...\n  349│\n  350│<!-- [doc->REQ-SHELL-5] owner-type-agnostic: control-exclusivity keys on the owner endpoint_id, never the owner's endpoint type -->\n     │...\n  352│\n *353│**Command-delivery method** is the shell adapter's choice (same modes as agent inject-input): HTTP REST, stdin-via-broker, or child-relay (`api poll`). The binary is always broker-launched, so binding is direct (`api bind`, no seed-file).\n  354│\n *355│**Lifecycle = online / offline / torn-down.** A **link-break always closes the broker + shell binary** (with an optional manifest-declared **pre-close instruction** to the binary + a **termination timeout** for graceful shutdown). Then: an **ephemeral** shell (a manifest property, not an agent choice) is fully torn down **and removed from the agent's shell history**; a **persistent** shell keeps its perch **offline** (re-linkable — the binary is re-spawned on relink). **The binary never survives a link...\n  356│\n *357│**Broadcast policy** (manifest): the shell advertises its availability to all agents on the subnet, only same-node agents, or not at all (agents learn of it only by user instruction). Shells run only on nodes where the shell adapter is installed/advertised.\n  358│\n *359│**Instantiation scope (orthogonal to broadcast):** `broadcast` governs *discovery* only; *permission* to instantiate is **\"shell adapter registered on this node\" + the per-shell approval gate** (below). Any agent **instance running on a node** where the shell adapter is registered may `shell spawn` it. A **cross-node** spawn (owner on one node, the shell binary on another) is **not** special-cased — it rides the deferred **instantiate-anywhere** primitive and its consent gate; same-node spawn does not.\n  360│\n *361│**`shell spawn` mints a new instance, it is not the online switch.** `spt shell spawn <adapter> [--id]` creates a *new* Shell identity (`<adapter>-<n>` + its perch + the owner link). Bringing an *existing* offline (persistent) instance back online is **`shell relink <id>`** (re-spawns the binary), or happens automatically via `persistent` (auto-online with the owner) / `wake_command` (offline wake-watcher). `shell teardown <id>` destroys it. So **`spawn`:create :: `relink`/`persistent`/`wake`:online** ...\n  362│\n *363│**per-shell instantiation approval (`require_approval`, manifest enum):** gates `shell spawn`, reusing the consent plumbing (grant store + interactive escalation; see Consent & security gates).\n  364│- `none` (**default** — matches the system's everything-opt-in posture) — the agent spawns freely within scope.\n *365│- `remembered` — prompt on first spawn; **allow-always writes a persistent grant** (`spawn-shell × agent × (node, shell-adapter)`) so later spawns auto-allow; allow-once does not.\n  366│- `always` — prompt on **every** spawn; allow-always is suppressed (no persistent grant).\n  367│\n *368│The manifest value is the **floor**: a node/endpoint setting may **tighten** (demand approval where the manifest says `none`) but never loosen below what the shell requires.\n  369│\n  370│<!-- [doc->REQ-CONSENT-3] -->\n *371│**per-capability approval gates** (ratified 2026-06-11, Gateway grill): the same `require_approval` enum may ride **individual capability entries** in a shell manifest — gating the dangerous *operation*, not just the spawn. Same grant store, same interactive escalation, same floor semantics. A capability may declare a **class key** so grants are scoped finer than the capability itself: the first consumer is the usbip shell's `attach`, granted per **(owner endpoint × device class × node)** — a remem...\n  372│\n *373│**instance cap (`max_instances_per_owner` + `over_cap`, manifest):** an optional ceiling on how many instances of this shell adapter **one owner endpoint** may hold. The count is **all existing instances** — online *and* offline, every non-torn-down perch (`shell teardown` frees a slot) — so offline persistent shells cannot be stockpiled to evade it. Unset ⇒ unlimited. At the cap, **`over_cap`** decides: `reject` (**default**) refuses the spawn outright; `approve` requires per-spawn approval for ea...\n  374│\n *375│**shell instance aliasing:** every instance has an immutable canonical id **`<adapter>-<n>`** (`GameRobot-0`, `GameRobot-1`, …) — which itself encodes the providing adapter — plus an optional **alias**, a friendly owner-unique label (`TempleKeeper`) set at spawn (`shell spawn GameRobot --alias TempleKeeper`) or later (`shell rename <ref> <alias>`). Alias and canonical id are interchangeable for addressing (`shell cmd TempleKeeper …`). The alias is a *display/address* overlay only: `adapter_name` ...\n  376│\n  377│**Agent awareness.** Available shells *and* the agent's own shell instances (with online/offline state) are injected into the agent's context (SessionStart additionalContext / `$LIVE start`-equiv + hidden-context versions) — the agent ideally just \"knows.\" A `shell list` command also exists.\n  378│\n *379│**Discovery scope (what \"available shells\" resolves to):** the set surfaced to an agent (injected + `shell list`) is **(1)** its **own instances** — alias · canonical `<adapter>-<n>` · status — always; plus **(2) instantiable adapters**: shell adapters **registered on the agent's own node** with `broadcast ∈ {subnet, same-node}`, and shell adapters on *other* subnet nodes with `broadcast = subnet`. `broadcast = none` shells are **never** surfaced proactively — spawnable only by explicit user/ag...\n  380│\n  381│**Cross-node instance resolution** (ruled 2026-07-24, #70 grill — governs the cross-node drive leg): an unqualified shell ref resolves **local-first** — if the owner holds a matching instance on the *current* node, that one is used, even when same-ref instances exist elsewhere. Resolution **refuses as ambiguous only** when the ref matches instances on more than one node **and none is local**; the qualified form (`ref@node`) is the escape hatch and always exact. Never a silent cross-node pick.\n  382│\n *383│**Not in the subnet registry.** Shell perches are private to the agent↔shell link, not a general messaging surface — they are nested under the owner (`perches/<endpoint_id>/shells/<adapter>-<n>/`) and not registry-advertised like agent endpoints. The perch `info.json` carries only `type=Shell`, `owner`, `adapter_name`, `status` (online|offline), and an optional `alias` — the capability set is resolved from the shell adapter manifest by `adapter_name`, not duplicated on the perch.\n  384│\n *385│**Shell-relevant commands:** agent/user surface — `spt shell spawn <shell_adapter> [--id]`, `shell list`, `shell relink <id>`, `shell teardown <id>`, `shell cmd <id> <op…>` (or ordinary `send` of a typed command payload), and **`spt endpoint shutdown`** (an agent gracefully shuts down *its own* endpoint → suspended; fires the transition echo-commune; cascades its persistent shells offline). Machinery `api` surface — `api bind` (type=Shell), `api poll` (relay delivery), `api emit --type <sensory_t...\n  386│\n  387│#### Shell sleep/wake (offline ↔ online)\n  388│\n *389│A shell automatically goes **offline when its owner endpoint goes offline**. Two complementary manifest options bring it (and its owner) back:\n  390│\n *391│- **`persistent`** (bool): the shell is automatically brought **online whenever its owner endpoint is online**. Covers \"owner already up.\" **Node-qualified once instances span nodes** (ruled 2026-07-24, #70 grill): when an owner comes online at a given node, only the **latest** instances qualified to that *endpoint@node* pairing come online with it — a stale instance of the same shell left on another node does not auto-wake alongside. **A node restart is covered by a boot sweep, and an owner that comes...\n  392│  **Daemons boot before endpoints do, so the sweep is not the only trigger** (releases#228): an owner that is not yet online when the sweep runs is skipped by that very conjunct, and — the sweep being once per daemon generation, and bringup from offline emitting no rest edge — nothing revisited it, so its shells stayed down until a human relinked. The reconcile loop therefore runs the **same four conditions** for every owner that has gone **offline→online since the last pass**. The trigger is that ...\n  393│- **`wake_command`** (template): a long-running **wake-watcher** process spt-core runs *while the shell is offline* **and eligible** (the eligibility rule is below — an offline instance sitting on a same-boot corpse arms no watcher). Its sole job is to fire a wake. It runs on the **shell's node** (where the platform wake-event originates — e.g. interacting with a \"shut-down\" avatar). Covers \"owner is down, wake it from outside.\"\n  394│\n     │...\n  423│verb, never which of nine flags were present. **`endpoint create <new-id>`\n *424│`[--subnet S] [--adapter A] [--cwd DIR]`** is the ONLY mint: it writes the skeleton perch,\n *425│assigns the **permanent** anchor subnet, and records the adapter + project folder as the\n  426│endpoint's initial session defaults — and it **starts no session** (the first one is `start`,\n *427│or `go`). **`endpoint start <id> [--adapter A] [--cwd DIR]`** runs a NEW session on the\n *428│endpoint's most-recent adapter in its most-recent project folder — **never the env cwd** — and\n  429│an UNKNOWN id **refuses**, pointing at `create`, so a typo cannot mint a phantom endpoint;\n *430│`--adapter`/`--cwd` override this run AND become the new remembered defaults.\n  431│**`endpoint resume <id>`** brings its LATEST session back. **`endpoint auto-start <id>\n  432│[--off]`** is the startup-default lever. **`go <id>`** is top-level and is the operator's\n  433│\"take me to this endpoint\": its **ladder** is online+uncontrolled → rc · online+controlled →\n     │...\n  460│picks the kind (*Create new* | *Pick existing*). **Create-new** chooses a registered\n *461│`kind=\"harness\"` adapter with its shipped+local **profiles tree-nested**, then a\n  462│charset-validated id, then — **on a node that holds ≥2 member subnets** — an **anchor-subnet\n *463│layer** (`CreateAdapter → CreateId → CreateHome`): the member subnets **MRU-ordered**\n  464│(most-recent anchor first, the same ordering the CLI confirm uses), pick one and it bakes an\n  465│explicit `--subnet` into the bringup so the anchor resolves directly with **no `Ok to proceed? Y/n`\n  466│confirm** on the picker path. A **single-subnet / unpaired** node **skips the layer** (the sole\n     │...\n  472│**W2.5**, a dedicated broker attach-presence slice), **type-to-filter** (`/`), a pinned keybind\n *473│legend, and a **two-pane** right-half description (harness `adapter:profile` · best-effort\n  474│project history newest→oldest · `endpoint description`). The **confirm** layer offers\n  475│status-dependent options — Attach/Start/View (the rc pump / bringup) · Instantiate-locally\n *476│(remote) · Change-harness-adapter (offline) · Fork · **Resume-from-history** (offline+LOCAL\n  477│only — enumerates the per-endpoint session ledger, titles `<project> @ <ts> (…id5)`, resumes\n  478│that session id). **Invariant:** the picker is a pure front-end — every terminal action routes\n  479│through the one bringup core (the lifecycle-verb engine / the rc pump), never a second path. A single\n     │...\n  485│<!-- [doc->REQ-READY-AGENT-RESUME] -->\n *486│The bringup core is **type-agnostic** — the endpoint TYPE is the adapter manifest's\n *487│concern, not a separate bringup mode. A manifest declaring `[session.psyche_init]`\n *488│brings up a **LiveAgent** (the daemon reconcile hosts its Psyche); a manifest *without*\n  489│it brings up a **ReadyAgent** (a poll listener, no Psyche — see *ReadyAgent* and the\n  490│harness-hosted ready bind at the *seed + bind-time resolution* note above). No\n *491│`--adapter`/picker branch distinguishes them: the daemon live-host reconcile hosts only a\n  492│perch whose **state is `live_agent`** (a `ready_agent`-state perch is skipped at the\n  493│reconcile start-side state gate), and within the live path keys on `psyche_init` presence\n *494│— so bringing up a ready manifest naturally yields a ready endpoint with no Psyche;\n  495│a resume carries its session into the bind for either type. Consequently a\n  496│ReadyAgent is now first-class in the **Resume-from-history** offer above: the\n  497│harness-hosted ready bind ledgers a **Boot session row** on bind — exactly as the live\n     │...\n  509│harness-agnostic spt-core defaults to **`spt`** (→ `spt-<id>`, e.g. `spt-doyle`); an\n *510│adapter/flow **overrides** it (spt-claude-code → `cc`, giving `cc-<id>`) — the Claude-Code-ness\n *511│lives in the adapter, **spt-core never emits `cc`**. The basename must be a *distinct* token,\n  512│never bare `spt`: a `spt.cmd` wrapper would shadow the real `spt.exe` only under cmd.exe\n  513│(cwd-first search), silently no-op in PowerShell/Unix, and self-recurse — so `spt-<id>` is the\n  514│safe, consistent form. The launcher is the **current OS's native form**: a **`.cmd`** on\n     │...\n  560│**live activity buffer (session digest)**:\n *561│A rolling, human-glanceable view of an endpoint's **recent context** — *what the agent did* (recent user turns + the agent output between them, with **tool-usage sprints collapsed**, e.g. `<endpoint_id> used: **Write** `dir/file.txt` · **Bash** `first ~25 cmd chars` · […]`) **and** *what spt fed the agent* (**context-injection** entries — session-start psyche download, echo-commune mirror, incoming owl messages — collapsed by default, expandable). One time-ordered, glanceable timeline. **How mu...\n  562│\n *563│- **Source = normalized session logs, never PTY parsing** (revised 2026-06-12, Gateway grill — supersedes the ADR-0008 source mechanism). A TUI's PTY stream is a *rendering protocol*, not a content log: scroll/resize trigger full repaints (duplicate content), and semantic turn boundaries drown in repaint soup. The split: **render surfaces read the PTY** (the R-TERM raw byte ring, unchanged); **content surfaces read the logs**. The digest reads the **same session logs as the echo-commune** but through i...\n *564│- **Topology-independent.** Because the source is logs, the digest works for harness-hosted endpoints too — Claude Code's per-session JSONL is Path A's worked example. Freshness = file-watch + adapter hook nudges; harnesses with no usable logs push entries directly via **`api digest-entry`** (the Path-B-style door). Near-realtime tailing needs an *incremental* normalize story (design-phase concern: Path A's normalize-command was shaped for occasional pulls).\n  565│- **Thread-spanning across session boundaries.** A live agent's identity persists across many harness *sessions* (a `/clear` or `/compact` rotates the harness `session_id` — see `api boundary` / **post-spawn seam** — while the endpoint identity and `parent_pid` anchor hold). The digest follows the **agent thread**, not a single session: its rolling window may bridge a boundary (the tail of session N and the head of session N+1 coexist until the old turns age out), so a glance right after a `/clear` s...\n  566│- **Two access modes.** **Snapshot pull** — `spt endpoint digest <[subnet:]id[@node]>` returns the current structured buffer (a Shell/CLI/GUI on-demand render; feeds the frontend's \"latest session output\" pane; a Gateway's agent-window). **Structured-delta stream** — a subscriber (live Shell pane, GUI, Gateway) receives only *changes* (new turn, new tool entry, collapse update), keyed to log records, for resource-efficient near-realtime reflection. **Access is address-gated** — fetch/subscribe is a...\n *567│- **Turn sealing = idle-triggered** (ruled 2026-07-24, rebound grill; supersedes seal-on-next-input as the primary). A turn's records gain their stable `seq` when the turn **finishes**, and the authoritative finish signal is the endpoint's **idle transition** — the same adapter-reported signal the activity model already owns — not the arrival of the *next* user input (that arrival remains a harmless no-op fallback seal). A finished-but-idle turn is therefore never left `partial`/seq-less (the mint-th...\n  568│- **\"Option (C)\" logging surface:** the digest may also be *persisted* (opt-in per-endpoint) as a coarse activity log appended to the native history store (Path B), on-boundary or periodic — off by default.\n *569│- **Adapter-declared extraction (post-M9 milestone — reverses M9's \"no manifest seam\"):** M9 retired the PTY-parse engine and made the digest an on-demand projection over a published `{role,text,tool}` contract carried in `[history]`. That under-served real adapters: one normalizer cannot serve echo (opaque) and the digest (contract) at once, and a contract mismatch failed **silently**. So the digest gets its **own `[digest]` manifest seam** — an **imperative extractor** the adapter declares (its nat...\n *570│- **Two-origin merge.** The digest interleaves two sources by `ts`: the adapter's **extracted log records** (agent activity) and spt's own **context-injection entries** (psyche download / echo mirror / owl message), which spt appends to the endpoint's `digest.log` (the existing Path-B sink — spt becomes an internal producer). The projection merges both newest-relevant within the window. **Deferred (no consumer yet):** the GUI collapse/expand of context-injection entries, the echo-commune-reads-the-dige...\n  571│\n  572│_Avoid_: \"PTY digest\" (the superseded source mechanism); parsing agent content out of PTY bytes anywhere.\n  573│\n     │...\n  667│- **Scope:** an endpoint only ever sees/resurfaces notifs for subnets it is **visible in** (excluded endpoints never receive that subnet's notifs).\n *668│- **Delivery.** A notif to an agent is **normal SPT messaging to the agent's perch with a notif-flavored envelope** — the existing delivery/inject path handles it and the agent surfaces it conversationally (no new delivery machinery). **Delivery window (ruled 2026-07-21): the notify kind rides `active_only` unconditionally — spool-only, never a live-PTY interrupt — for every producer including rollback.** A notif's contract (dismissable, resurfacing, persists until dismissed) is what \"loud\" means; ...\n  669│- **Producers (open set):** system events (node-paired, pairing-request, update-available, consent-needed) + agent-issued `spt subnet notify`. New producers register later.\n  670│- **Envelope `from`** — for agent-issued notifs carries the issuer's **endpoint id, node, and subnet** (the subnet is **surfaced to the receiver only if its node is on multiple subnets** — otherwise redundant). Makes notifs attributable now and targetable once the per-(subnet, user) model lands.\n  671│\n     │...\n  677│\n *678│<!-- [doc->REQ-ADAPTER-UPDATE-MESSAGE] -->\n *679│Seamless, realtime self-updating is a day-one pillar. After a plugin performs the initial spt-core bootstrap, `spt.exe` self-updates from then on and **ripple-updates registered adapters** via each adapter's manifest update declaration. An adapter may include a `[update].message` — a plain human notice (markdown-rendered) that `spt adapter update` prints to stdout only when a new version is **actually applied** (never on a no-op). Useful for post-update operator actions, e.g. `\"Run \\`/reload-plugins\\` ...\n  680│\n  681│**release channel (private, gh-carried)** — the release channel is a **private** GitHub repo (`BigscreenVR/spt-bs-releases`, ADR-0036); the **gh CLI is the mandated carrier** for release discovery and asset download (each node authenticates via org membership). A node without an authed `gh` cannot fetch — refused loud with OS-specific install hints, never a silent hang. Signature verification is carrier-independent: bytes are verified after download exactly as before; counter, signing key, and update...\n  682│\n *683│**docs bundle** — every release ships a platform-independent archive of the **built docs** (HTML + `llms.txt` + `llms-full.txt` + raw markdown + `manifest.schema.json`) as a **signed update-set asset**; apply lands it at `$SPT_HOME/docs`, so a node's docs always match its installed version. A docs-asset failure never fails the binary update (skip loud, retry next fetch). Consumed by the *docs server* (below). <!-- [doc->REQ-DOCS-RELEASE-ASSET] -->\n  684│\n *685│**docs server** — the daemon serves `$SPT_HOME/docs` over HTTP on **loopback only**, default `127.0.0.1:5474` (config/env-overridable). This is the **canonical docs surface** (supersedes the public Pages URL, ADR-0014→ADR-0036): agents and humans on the node read `http://localhost:5474`; `spt docs url` prints the resolved URL, bare `spt docs` opens the browser. The URL-path surface matches the retired Pages site verbatim (`/llms-full.txt`, append-`.md` raw markdown, `/manifest.schema.json`). Never LA...\n  686│_Avoid_: public docs hosting; binding beyond loopback; docs drifting from the installed version.\n  687│\n *688│**update composite (`spt update`)** — the plain verb is the primary form: `update fetch --apply` then `update adapters` (core-first order); with core already current, only adapters update. `--core-only`/`-c` skips adapters; `spt update adapters [<a>[,<b>…]]` is the adapters leg alone (alias over `spt adapter update`). The composite's invoker always survives, because a routine apply cycles only the **brain** — the *restart-required* message on broker-side releases is a notice, not a restart. `spt up...\n  689│\n  690│**daemon refresh (`spt daemon refresh`)** — restart the **brain** in place, no binary swap, broker and every held PTY untouched: the routine-update handoff path minus the swap. The recovery verb for wedged brain-held state (broken endpoint bringup, a downed hosted agent) that previously required a full daemon bounce. <!-- [doc->REQ-DAEMON-REFRESH] -->\n  691│\n *692│**project index** — a node's endpoint→project attribution is DERIVED state, held as a **persistent materialized index** (ADR-0037): `spt-store` owns the versioned format + read path (daemon-offline reads = last persisted snapshot); the **daemon is the sole single-flight writer** (load-at-start, ready-without-warm, background batched reconcile, atomic replace, coalesced event-driven invalidation keyed on branch-tip fingerprints, last-known-good on failure). Readers — list, picker, endpoint-info, hoo...\n  693│\n  694│**index writer duty (daemon)** — the brain hosts ONE writer thread (`projwriter`, spawned beside the live host; single-flight by construction). Batched complexity is contract, `O(P+B+F+C)`: ONE branch enumeration per cycle (`for-each-ref` carries recency + tips), ≤1 tree scan per **changed** `p-*` branch (`ls-tree` at tip, membership cached by tip), ONE derivation per distinct normalized cwd (cache stamped on the repo-identity marker — `.git/config` / the `.git` gitfile — so **ordinary commits ar...\n  695│_Avoid_: synchronous git in any read path (the poisoned-git int rigs + the `gitrun` census exist to catch a regression — new list-shaped surfaces must join the index, never re-derive); readers falling back to enrichment; treating index presence as health; wall-clock CI gates (counters gate, wall-clock is manual acceptance); a writer-maintained generation counter (nothing maintains it — fingerprints only); mutating `p-*` branches outside the ContextStore funnels without a `projinval` nudge (the sync-p...\n     │...\n  702│\n *703│**resume re-attach is view-only for non-driven sessions** — on respawn the new brain queries the broker for every hosted session and re-attaches to rebuild output-continuity cursors, but it re-attaches as a **viewer**, never a controller, for any session it does not itself drive (which is *all* of them today — the supervised daemon brain hosts no PTY sessions; spt-hosted PTYs are driven by the operator's attach or the endpoint's own loop). Re-attaching as a controller would seize the controller slot ...\n  704│\n  705│**broker update frequency** — rare by design. Triggers: broker↔brain IPC contract change (versioned so a newer brain talks to an older broker — broker stays put as the brain updates), held-resource-type change, OS PTY/socket API change, or a broker bugfix. When the broker *must* update (the one case that can disturb held endpoints), it is a small well-scoped place that can use FD-passing or a rare planned endpoint-cycle — not the whole daemon.\n  706│\n     │...\n  718│**served resource** — anything a node exposes over its serving surface: a registry file, a\n *719│served root, a docs page, an adapter's docs, a message view, a bootstrap binary. Every served\n  720│resource has exactly one **node-prefixed URL**.\n  721│_Avoid_: hosted file, shared file, upload.\n  722│\n     │...\n  734│\n *735│**served root** — a registry entry of type *dir*, declared by an adapter in its manifest, whose\n  736│contents are served as a subtree. A served root is a kind of registry entry, never a second\n *737│mechanism beside the registry. Its directory is core-owned — `adapters/<adapter>/web/` under\n *738│the spt home, created at activation — and it answers at the adapter facet `/<node>/a/<adapter>/`,\n  739│with `web_short_path` adding an alias name to the same entry (ADR-0057 Amendment 1). An\n *740│adapter's install tree is never a served root.\n  741│_Avoid_: web folder, static dir.\n  742│\n  743│**served name** — the name a registry entry answers to under its node's prefix. Stable for the\n     │...\n  778│**endpoint ID**:\n *779│The logical identity (`ling`). Shared subnet-wide. The thing you address. **Adapter-agnostic**: the same ID can run under different adapters over its life (start on `claude-spt`, later revive on `spt-codex` or `spt-pi`) — adapters expose the same SPT-relevant capability subset, so the ID is not bound to an adapter. The currently-running `adapter_name` is a property of the live instance, not part of the identity. **At most one instance of an ID per node** is allowed; a second attempt on the same node (a...\n  780│\n *781│**adapter selection (creation & change)**:\n *782│At **creation**, the adapter is chosen from the node's **registered `kind=\"harness\"` adapters whose `hostable_types` includes the endpoint's type** — **auto if exactly one qualifies, else chosen** (`--adapter` / picker): the same *auto-if-one-ask-if-many* rule as *anchor subnet*. The choice seeds the **head of the ordered adapter history** and the live `adapter_name`. **Changing** an endpoint's adapter is **not** a standalone operation — adapter is a *live-instance* property, so you change it by **la...\n  783│\n *784│A **session's** adapter[:profile] is **recorded truth**: a harness session can only resume under the adapter that created it, so **on resume the endpoint's stamped adapter follows the resumed session — never the reverse**. A recorded adapter that is no longer registered on the node refuses the resume loud (name it, point at `spt adapter add`); silently resuming under a different adapter is the corruption this rule exists to prevent. A session with **no** recorded adapter (pre-migration) resumes under t...\n  785│\n  786│**instance**:\n *787│The *same* endpoint (same harness + adapter) running **natively on a machine** — local files, local compute. `ling@desktop` and `ling@laptop` are both real local `ling`s, not remote views of one. What is per-node vs synced:\n  788│- **Per-node (anchored, cannot teleport):** the project working directory / files, and the harness session history. A git repo can't teleport; files are obtained locally (e.g. `git pull`).\n  789│- **Synced across instances:** the **Psyche context** (the agent's mind). See the two-tier sync below.\n  790│\n     │...\n  809│\n *810│**`spt endpoint rename <id> <new_id>`** (endpoint rename, **rippled**): change an endpoint's logical ID and propagate the change to **all its instances** subnet-wide — the registry entries, every node's perch, and the synced context branches (`a-<id>` → `a-<new_id>`, and project-view references). The rename is one logical operation against the eventually-consistent registry; it must collision-check `new_id` against every subnet the endpoint is advertised into (the join-time check above), and reconcil...\n  811│\n  812│**`spt endpoint fork <src[@node]> <new_id> --subnet <target> [--delete-source]`** (cross-subnet clone):\n *813│Clone an endpoint into a **new, identity-distinct endpoint** on another subnet — the sanctioned way to place an agent's mind in a different subnet (the anchor subnet being immutable). The fork's **anchor = the target subnet**; it is **seeded with a one-time copy** of the source's mind (the live + project *Cross-node Psyche sync* tiers), after which the two **diverge** — there is **no ongoing sync**. A fork is therefore **not an instance**: an *instance* is the **same** identity on another node with a...\n  814│**The source may live on another node** (HANDRAIL, releases#29): qualified `src@node` resolves to the holder and the fork is **made there**, by that node, through the *`FORK`* control surface — one local primitive serves both arms, so a wire-driven fork is the same act a local operator performs. The gate is the **source's** rules (the mind being copied is the one whose owner decides) and it reads the **handshake-proven** origin, never a field the request carries; no answer is **never** reported as fork...\n  815│\n  816│**teardown authority (ADR-0045):** a teardown verb never stamps a terminal or resting state it has not caused. Where the session is **broker-hosted** (`controllable == Some(true)`), both `shutdown` and `stop` mean the same physical thing — the broker session row is gone and its **descendant** process subtree is reaped (the harness child *and* anything it spawned, e.g. a child `spt api listen`) — and the state stamp is written only **after** the confirmed reap, because a surviving host can re-bind ove...\n     │...\n  868│**instantiate-anywhere** (seam day-one, behavior deferred):\n *869│From node Y, spin up an instance of an endpoint on node X by routing a launch command to X's daemon, which runs the endpoint's manifest locally. \"Agents can spin up instances (including of themselves) on any node, possibly requiring user consent\" — so a **consent gate** governs remote instantiation. The consent-UX design is deferred; the model is built to accommodate it.\n  870│\n  871│**cross-instance context freshness** (two-tier):\n  872│When a dormant instance is activated again, it is unobtrusively fed the latest Psyche context — newest-among-peers *and* newer than its own. Scoped by the existing Psyche-context split:\n     │...\n  898│\n *899│Layering (bottom → top): `spt-proto` (wire envelope grammar, endpoint types, message framing, Ed25519 identity types, typed+binary payload schema — pure types, no I/O) → `spt-store` (spool, perch layout, trust store, registry persistence) → `spt-msg` (delivery TCP+spool-fallback, routing, send/ring) → `spt-net` (Iroh, mDNS, SPAKE2/TOTP pairing, subnet-registry distribution; `net` feature-flagged) and `spt-term` (session-surface trait, PTY impl, broker) and `spt-runtime` (`AgentRuntime` trait, `...\n  900│\n *901│**public SDK surface** (semver-committed): `spt-proto` (the wire contract — anyone speaking SPT depends on it), `spt-runtime` (the trait third-party harnesses implement), `spt-msg` (the deeper messaging-integration path). Everything else (`spt-store`, `spt-net`, `spt-term`, `spt-live`, `spt-daemon`) is internal/unstable pre-1.0 — reachable, but no stability promise. The *integration* crates are stable; the *machinery* churns. **Private-source consequence (ADR-0014 era):** with the source repo private...\n  902│\n  903│**wire-protocol version vs crate semver** (orthogonal, never conflated): `spt-proto` carries an explicit **wire-protocol version** independent of crate semver. Two nodes — or a node and a linked library consumer — interoperate iff their proto versions are compatible, with a documented **N-1 compat window**. Crate semver governs *API* stability; proto version governs *wire* stability.\n  904│\n     │...\n  927│**engine-room endpoint** (ratified 2026-07-28, access-control grill):\n *928│**One per node.** A locked-down **agent endpoint** (harness-adapter-backed, spt-hosted, has a mind — so it can reason about the node's access posture) that is the designated surface for setting the **node's** control-surface modes, and — via *empower* — a **subnet's** modes. Bring-online + controller-attach requires a **same-node CLI call + a member-or-admin TOTP** for the engine-room's anchor subnet (no OS elevation). Structural locks: refuses all inbound except replies to its own outbound (**knoc...\n  929│_Avoid_: \"mother node\" (rejected — the mesh stays symmetric; authority rides keys, not a privileged node); remote drive of an engine-room in any form; an agent running the ceremony.\n  930│\n  931│**empower** (`empower <subnet-id> --admin-code <admin-totp>`):\n     │...\n  936│**control surface** (ratified 2026-07-28, access-control grill):\n *937│The unit of access-control granularity: a named remote-reachable operation class on an endpoint. **Open string vocabulary, CONSTANT_CASE ids** (like capability ids — new surfaces mint ids without schema change). v1 set = the existing gate families: `MSG`, `RC_VIEW`, `RC_ATTACH`, `DIGEST`, `WAKE`, `SUSPEND`, `XFER`, `SHELL_LINK`, `DISCOVER` — plus **`FORK`** (HANDRAIL, releases#29), the first id minted *with* its capability and so the proof that the open vocabulary grows the way it was ratified to: on...\n  938│\n  939│**Intra-node governance** (releases#211, operator-rephrased 2026-08-22). <!-- [doc->REQ-ACL-INTRA-NODE-SELF] --> The vocabulary for governing traffic authored ON this node is a **SELF-REFERENTIAL NODE SUBJECT at the EXISTING tiers** — a rule whose subject is this node's own id. **No new tier, no new subject kind, no schema change**, and the v1 reading (a rules tier at 3.5 with a mode twin at 6.5) is WITHDRAWN IN FULL. Two effects: a **node-scope** own-node rule governs intra-node actions for every endp...\n  940│\n     │...\n 1026│<!-- [doc->REQ-TRUST-WARNING-ENVELOPE] -->\n*1027│**THE CARRIER IS THE DELIVERED MESSAGE'S OWN ENVELOPE** (ruled 2026-08-19, releases#170 — carrier changed, rule unchanged): the block rides as a **`trust-warning` attribute composed by the RECEIVING node**, so the caution and the message reach the agent in **one arrival**. A separate delivery is a separate **context injection** under an spt-hosted harness, which is what the operator asked to be rid of. **THE UNFORGEABILITY REASON IS PRESERVED, NOT TRADED**: the ratified rule forbids *splicing into the ...\n 1028│<!-- [doc->REQ-TRUST-WARNING-CADENCE] -->\n 1029│**THE CONDITION AND THE CADENCE ARE DIFFERENT QUESTIONS, and only the first was ratified in 2026-07-29's grill** (cadence ruled 2026-07-31, HANDRAIL W2, releases#63): *when* a warning is owed was settled and *how often* was not, so the shipped edge composed one per admitted message. **The dedup named in the ratified design is REPLAY dedup — a retried `op_id` is not re-delivered — which is a DIFFERENT QUESTION WEARING SIMILAR WORDS, and reading it as repetition coverage is what let the gap ship.** The...\n 1030│<!-- [doc->REQ-TRUST-WARNING-OVERRIDE] -->\n 1031│Per-endpoint override via `spt endpoint trust-warning <show|set <text>|reset> [--owner <id>]` — **writes elevation-gated, reads never** (the text is agent-behavior instruction under a reserved author, so it is a prompt-injection surface; showing what an endpoint is told is the visibility that makes a planted override discoverable). **The override scope is PARTIAL**: custom text replaces the **advisory paragraph only** — the line naming who reached the endpoint and stating no note is held, and the lin...\n*1032│_Avoid_: treating a whitelist entry as classification; warning on blocked traffic (nothing to warn about — it never arrives); warning on same-node / Reply / posture-open passes; a whole-block override; filing override text in the mind tier to \"match\" where monics live; reading the replay check as repetition coverage (it answers a retried message, never a repeating peer); \"tidying\" any fail-toward-warning arm into a fail-closed read; claiming the marker before the warning is delivered; exempting unnamed...\n 1033│\n 1034│**shared subnet** (ratified 2026-07-28, access-control grill):\n 1035│A subnet whose member nodes belong to **different human operators** (team / org / friends). The humans in a shared subnet mutually trust each other; **the adversary the access-control layer gates is agents, not humans** — preventing unintended agent↔agent collaboration and the disturbance of agents unrelated to a messenger's cause. **\"User\" is deliberately not a glossary term or security identity**: the **node** is the human-proxy trust unit (most nodes have a singular operator — the node is their ...\n     │...\n 1114│**binary-trust disclosure (accepted risk)**:\n*1115│A shell binary is the least-trusted code in the system (3rd-party, runs on the user's node, agent-controllable) — but the same is true of harness binaries. The capability toolset bounds what an *agent* may ask; it does not sandbox what the *binary* may do with its OS permissions. Optional **shell-binary sandboxing** is deferred (gated like instantiate-anywhere/remote-exec). The baseline stance: **running any adapter or shell binary means trusting it — a disclosed, accepted risk for all spt-core users.**\n 1116│\n 1117│## Consent & security gates\n 1118│\n     │...\n 1123│- **Interactive escalation** — an ungranted high-risk action → the target node routes a **consent prompt to the user's most-recently-active session** (the registry-resolution precursor the update-prompt uses; PresenceChannel later generalizes it). Options: **allow-once / allow-always (writes a grant) / deny** — plus the harness's native free-text field (e.g. CC's AskUserQuestion \"Other\") for refinement.\n*1124│- **Pre-consent flags** — the shell `can_shutdown` and endpoint `shell_wake_spawn_anywhere` flags are simply grants authored ahead of time via manifest/endpoint settings (same model, different authoring path).\n 1125│\n 1126│**What is gated vs not:**\n 1127│- **Gated (default-deny, grant or prompt):** remote command execution (highest risk), instantiate-anywhere (spawn an endpoint on a remote node).\n     │...\n 1131│**endpoint access whitelist** (distinct from the grant store — the outer reach gate):\n*1132│A per-endpoint allow-list controlling **who may remotely reach** an endpoint. *Subject ruling (2026-07-28, access-control grill — supersedes origin-node-only keying):* a rule's subject resolves through one precedence chain — **explicit sender-endpoint entry → node-level entry → subnet-mode default** — because the gated adversary is the **agent** (see *shared subnet*), so rules must be able to name a specific sender endpoint; a node entry is the \"I trust that whole machine\" wildcard, and the sub...\n 1133│- **Outbound** from the endpoint → any **visible** node (the whitelist never restricts who it talks *to*).\n 1134│- **Inbound** → a **reply** correlated to the endpoint's own prior outbound (reply traffic, keyed on the inbound `from`) is allowed from any visible node (\"established/related\"); an **unsolicited/direct** control or message (\"new inbound\") is allowed only from a **whitelisted** node.\n 1135│- **Same-node operation is always allowed** (you're at the hardware; the home node never whitelists itself). The whitelist gates **remote (cross-node)** reach only.\n     │...\n 1175│\n*1176│**Which kinds have a live consumer is a separate question from which are ratified.** `sender` / `content` / `json` evaluate at the delivery edge, which exists. `user_input` / `agent_output` were **ratified but inert** until the now-signal / IO-parser funnel was built; **milestone D delivered that consumer** (releases#22 W5 / #233 — the now-signal MONICS category, through `monic::matched_for_ingest`), and it cost a consumer and no record migration, exactly as the early ratification was designed to make ...\n 1177│**Monic verbs are never elevation-gated** (ratified 2026-07-31, knock grill — settles the releases#24 parity question): saving a monic for an endpoint is the act \"I know this endpoint\", and that classification alone silences the *trust warning* for that sender. The elevation gate stays on the trust-warning **override-text** verb only (custom warning text is agent-behavior instruction; a monic is a classification).\n 1178│_Avoid_: node-local monic storage (they follow the mind); any single-file monic store — `monics.json`, sqlite or otherwise — in the mind tier (it makes unrelated concurrent writes one contended path); **keying a monic by its peer, or describing a monic as being \"about a peer\" — that is one trigger kind, not the definition**; treating a whitelist entry as a monic; elevation-gating any monic verb (incl. `--monic` on knock accept); **reading \"ratified\" as \"has a live consumer\"** (`user_input`/`agent_o...\n 1179│\n 1180│**IO parser** (ratified 2026-07-29, access-control grill):\n*1181│The generalized per-endpoint IO **event** surface (the digest stays the *content* surface — ADR-0048's split holds). Event taxonomy: **USER_INPUT** (ingested as a payload on `api state busy` — the edge adapters already report), **AGENT_OUTPUT** (**ALL of the agent's output, mid-turn included** — operator-ruled releases#238, 2026-08-29, on the semantics that it would be confusing for AGENT_OUTPUT to exclude *some* agent output: the end-of-turn payload on `api state idle`, PLUS optional mid-turn span...\n*1182│**Adapter consumption is a POLL over a per-endpoint event log (CONDUIT W3, releases#234 — the operator delegated the mechanics and chose poll over push).** <!-- [doc->REQ-IO-EVENT-ADAPTER-LOG] --> The bus grew a **third sink** and no rework, which is what operator ruling 2 asked of it — but the READER is what cost one line, not the STORE: neither earlier sink writes an ordered, per-endpoint, cursorable surface (the shell-link sink spools per linked shell; the last-msg sink keeps two overwritten slots...\n 1183│\n*1184│**The poll verb: `spt api io-events <id> {--session-id <sid> | --after <seq>} [--limit N] [--json]`.** <!-- [doc->REQ-IO-EVENT-POLL-VERB] --> `--session-id` keeps a per-session cursor exactly as *now-signal* keeps per-session seen-sets (the shape a turn-boundary hook wants) — **the same flag that authenticates**, because the harness session is one identity and a `--session` beside a `--session-id` would be two ways to be wrong about it; `--after` lets a caller carry its own cursor exactly as `endpoint ...\n*1185│_Avoid_: a new subscription verb (ADR-0048 rejected it — the shell link is the channel); deriving prompt-time behavior from digest logs alone (latency); **emitting `TOOL_USE` off this verb** — it stays unemitted, and the adapter being its natural source is a question for the operator, not something to ride in on a reader.\n 1186│\n 1187│**now-signal** (ratified 2026-07-29, access-control grill):\n*1188│`spt api now-signal [--user-input-json …] [--agent-output-json …]` — the **one situational-awareness funnel**, subsuming `spt api hint` (which survives as a thin alias over the HINTS category). Output: per-category XML tags nested under `<SPT-NOW-SIGNAL>`. **Delta-only discipline**: every category runs per-session seen-sets (the hint mechanism generalized); a poll with nothing new emits **nothing** — so the adapter guidance (inject on every UserPromptSubmit- and PreToolUse-equivalent) stays conte...\n 1189│_Avoid_: unconditional static payloads (delta or nothing); a second injection funnel beside it.\n 1190│\n 1191│### Messaging substrate\n     │...\n 1193│**arriving-message envelope**:\n*1194│Every message that arrives at a consumer — an agent over `api listen`/`spt ready`, an adapter's hook composer over `api poll`/`api worker-poll`, the cross-node WAN feed — is delivered as the canonical **`<EVENT type=\"…\" from=\"…\">body</EVENT>` envelope** (`spt-proto::event`, the ADR-0001 grammar; self-delimiting, `from` attribution, `<br>` body escaping, `<EVENT-PART>` chunking). It is the one format adapters parse on, identical across every **arriving-message** surface on an agent perch. The earl...\n 1195│\n 1196│**file-transfer progress**:\n 1197│Every file transfer over the substrate is addressable and **progress-queryable mid-flight** by both the agent and the SPT binary (for the GUI) — at any point during the transfer. A substrate-wide requirement, not shell-specific (the Shell text+file channel inherits it).\n     │...\n 1202│<!-- [doc->REQ-INSTALL-2] the marketplace-repackaging stance: relocatable binary + minimal, non-OS-entangled install logic -->\n*1203│spt-core is per-machine and harness-independent, so it installs *before* and *independent of* any adapter.\n 1204│\n 1205│**Two install paths, (b) is the primitive:**\n 1206│- **(a) harness-bootstrapped** — a harness plugin installs spt-core as its bootstrap step (today's cplugs model: `/plugin` pulls the plugin, which fetches the spt-core binary). The plugin's role shrinks to this bootstrap; spt-core self-updates thereafter.\n     │...\n 1208│\n*1209│**Installer form (gh bootstrap, ADR-0036):** install gh → `gh auth login` (org membership) → `gh release download` the platform binary from the private channel → one **self-install verb** in the binary places it at the canonical install path and registers the *user* PATH (so adapters call `spt api …` cross-OS); first-run identity gen + daemon start stay the existing idempotent unattended first-run. Hosted one-liner scripts are retired with the public channel; first-fetch trust = gh's authenticate...\n 1210│\n 1211│**Daemon lifecycle:** registered as a systemd user service (Linux) / Windows service or scheduled task for the always-on guarantee, with `spt api`-triggered auto-start as the fallback (above). The first network bind triggers the OS firewall prompt here.\n 1212│\n     │...\n 1216│\n*1217│**adapter registration (`spt adapter add`)**:\n*1218│How a node comes to *know* an adapter — harness or shell. An explicit **`spt adapter add <path>`** (or **`--github <user/repo>`**) validates the manifest against the published JSON Schema and writes a registration record under `{SPT_HOME}/…/adapters/` — a **copy** of the files for `file_pull`-update adapters (spt-core owns what it later swaps) or a **pointer** for `delegated`-update adapters (the plugin owns + updates its own files). One command + one dir for both `kind=\"harness\"` and `kind=\"shell\"...\n 1219│\n*1220│**Removal (`spt adapter remove`) is soft-deregister:** the adapter is **hidden from new-creation / picker lists immediately**, but existing and live instances **keep running** under it (lazy — nothing is force-torn-down). An optional manifest **`uninstall` command template** (the inverse of the install/`[update]` avenue — e.g. `claude plugin uninstall spt`) cleans the adapter's own artifacts; it runs **once no instance is live** under the adapter (or immediately with `--force`, which cascades teardow...\n 1221│\n 1222│## Project infrastructure\n 1223│\n 1224│**repo topology (The Forkening, ADR-0036)**:\n*1225│Development home is **`BigscreenVR/spt-bs-core`** (private); the publish target is **`BigscreenVR/spt-bs-releases`** (private) — GitHub Releases carry the binaries, the docs bundle, and the signed update-set, reached only through an org-authenticated `gh`. **`SaberMage/spt-core` and `SaberMage/spt-releases` are retained as passive mirrors** (private; the merging agent pushes every main merge + tags; Actions disabled) — full history, may diverge from the dev home only if a genuine second contributor e...\n 1226│_Avoid_: treating a releases repo as a second source repo; authoring doc truth there; publishing anything to the mirrors first.\n 1227│\n 1228│**issue / feature tracking**:\n     │...\n 1258│- **List** all running and historic endpoints.\n*1259│- **Launch a historic endpoint** that isn't currently running, reusing the same manifest that originally launched it.\n 1260│- **Tap into** (attach to) a running endpoint.\n*1261│- **Init a new endpoint** using a known adapter (e.g. claude-code / spt-plugin).\n 1262│\n 1263│This is the realization of the \"guided resume\" (resume by project or agent, XMB-style filtered selection) and \"management GUI\" (per-endpoint panes: latest session output, live/project context, psyche log) sketches. `attach` is one operation under this frontend, not the whole frontend.\n 1264│\n 1265│**guided-resume picker (`spt resume`, no-arg)**:\n*1266│The CLI sibling of the frontend's guided resume — one command that lists endpoints **grouped by locality, most-recently-used within each group**: `on-node / current-project → on-node / other-project → off-node`, mirroring the *resolution policy*'s local-first preference. Selection **chains conditionally**: a **running** instance → attach/tap-in (no adapter step — already live under one); a **non-running** endpoint → into the **adapter selector** (*adapter selection*: history head = default ...","truncation":{"content":"[.worktrees/ws272-w3-drift/CONTEXT.md#B34B]\n 22:**spt plugin** (separate downstream project — NOT an spt-core deliverable):\n*23:A rebuilt version of today's Claude Code `spt` plugin. It is the **first consumer** built *atop* spt-core and the **acceptance proof** of spt-core v1 (it reaches feature parity with modern SPT while delegating all core functionality to spt-core, primarily via `spt.exe`, with deeper hooks where useful) — but it **lives and builds in its own repository, outside spt-core**. It is a Claude-Code-specific *adapter*: it holds the Claude Code conventions (hooks, slash-commands, skill/plugin layout, `claude` se...\n 24:\n 25:**Pi** (disambiguation — two meanings, never conflate):\n 26:(1) **Pi, the coding agent/harness** (`badlogic/pi-mono`) — a harness example alongside Claude Code and Codex; this is the meaning in user-facing harness lists. (2) **Pi-class node** — Raspberry-Pi-class low-power hardware hosting a Shell-only or headless SPT node; an incidental hardware descriptor, never an explicit product example. Public-facing docs must disambiguate or avoid the bare word.\n...\n 36:- **broker** (stable \"kernel\") — holds *only* the un-transferable, must-not-die resources: PTY master fds, the spawned harness child processes, and listening network sockets. Minimal, dumb, versioned local IPC. Almost never updates.\n*37:- **daemon brain** (\"userspace\") — all logic (routing, registry, pulse/psyche loops, manifest parsing, update orchestration). Restarts freely on update; rehydrates from disk state and re-attaches to the broker's held handles.\n 38:\n 39:Logical addressing is unchanged — still one per-machine `spt-daemon`; the broker is an internal layer, not separately addressable. There is exactly **one broker per machine** (per `SPT_HOME`) — *not* one per endpoint: a single broker holds every hosted endpoint's resources, and it is present whenever the daemon runs, even with zero endpoints online (the bare-daemon case). It is therefore the always-present per-machine layer, which is why the single-daemon lock + liveness anchor belong to it.\n 40:\n 41:**in-session relay**:\n*42:A thin, stateless `spt.exe` task that exists only in **harness-hosted** sessions (where the agent harness is the parent process and spt cannot reach into its process tree — today's Monitor model). It streams the daemon brain's events into the session's stdout. All *stateful* listener logic lives in the daemon; the relay is a dumb pipe, freely killable and respawnable. **spt-hosted** sessions need no separate harness-owned relay — the daemon owns the PTY and consumes the same poll feed itself. Idle de...\n 43:\n 44:### Deliverable shape\n 45:\n...\n 57:**AgentRuntime** (Rust trait, implementation detail):\n*58:The internal Rust abstraction over a harness. Anything spt-core needs to do *to* or *with* an agent goes through this trait. Most consumers never see it directly — they configure spt-core via a manifest, and spt-core's default `ManifestRuntime` implementation executes against the manifest.\n 59:\n 60:**harness contract** (umbrella term):\n*61:The full surface a harness binds to in order to participate in the spt-core ecosystem. Has two equally-important halves: the **runtime manifest** (outbound — how spt-core drives the harness) and the **subcommand surface** (inbound — how the harness reports events back to spt-core). A harness implementation is one TOML/YAML manifest + a binding from the harness's own hook system into `spt.exe <subcommand>` calls.\n 62:\n*63:**runtime manifest** (outbound half of the harness contract):\n*64:A declarative configuration file (TOML/YAML — schema TBD) that tells spt-core how to drive a specific harness. Declares: how to invoke an agent session, how to look up conversation history for an echo commune, how to spawn/resume a Psyche-equivalent, which binary or command implements each harness-side operation, and which endpoint types this harness supports. spt-core is the actor for each of these; the manifest tells it what to do.\n 65:\n*66:Example shape (illustrative): `spt.exe --manifest spt-plugin.toml live start <id>`. A harness like the planned spt plugin wraps this invocation into the `$LIVE` / `$OWL` environment variables it injects into its sessions, so harness-internal callers continue to invoke `$LIVE` / `$OWL` unchanged.\n 67:\n 68:**subcommand surface** (inbound half of the harness contract):\n 69:The stable set of `spt.exe <subcommand>` entry points that harnesses bind their own hook systems to. When the harness's runtime emits an event (subagent started, tool just invoked, user typed `/clear`, session crashed), the harness's hook fires a short-lived `spt.exe <subcommand>` invocation that mutates on-disk SPT state (perch registry, spool, etc.). spt-core publishes this surface; harnesses author the bindings.\n 70:\n*71:**Naming convention:** these inbound, machinery-facing commands are prefixed **`api `/`api-`** (e.g. `spt api bind`, `spt api state`) to distinguish them from the agent-facing verbs an agent invokes directly (`send`, `ring`, `ready`, …). The `api` namespace is the harness/adapter commands-API; the unprefixed namespace is the agent surface.\n 72:\n*73:Together: manifest + subcommand surface = the complete harness API. A sidecar-style long-running adapter process speaking a wire protocol is explicitly **deferred** as a possible v2 alternative for harnesses that outgrow the manifest+hooks shape (e.g. need streaming or in-memory state across events). Not built day-one.\n 74:\n*75:**adapter manifest header** (`adapter_name` + version compat):\n*76:Every manifest declares a unified **`adapter_name`** (e.g. `claude-spt`), carried on every `api` invocation too. It is load-bearing: one daemon hosts endpoints from multiple adapters, so it resolves an endpoint's manifest + seams by `adapter_name`; adapter-update ripples target by it; capability/manifest lookup and telemetry key on it. The header also declares the adapter's own version and a **`min_spt_core_version`** — the minimum spt-core the adapter requires. This declaration must be **readable befo...\n 77:\n*78:<!-- [doc->REQ-MANIFEST-2] -->\n*79:**adapter profile** (ratified 2026-06-11, Gateway grill; future spt-core milestone — first beneficiaries `spt-claude-code` and the usbip shell):\n*80:A named **sparse overlay** on its parent adapter manifest. Merge semantics are **leaf-replace**: a profile key replaces the whole value at that path (arrays included — never spliced or appended). The merged result is a complete manifest, and the profile behaves as a distinct adapter option everywhere: canonical addressing is the composite **`<adapter>:<profile>`** (`claude-spt:work`, `spt-usbip-driver:hid-only`) in every place a bare `adapter_name` rides today (perch `info.json`, capability resolution,...\n*81:_Avoid_: \"manifest fork\", \"child adapter\", per-profile versioning.\n 82:\n*83:**adapter strings** (ratified 2026-06-11, Gateway grill):\n*84:<!-- [doc->REQ-MANIFEST-3] -->\n*85:A `[strings]` manifest section — an adapter-authored JSON/TOML KV tree, dot-path-readable by anything on the node via `spt adapter get-string <adapter-option> <key.path>` (e.g. a harness hook fetching per-profile `additionalContext` — one hook script serves every profile, only the data differs). Resolution rides the **same leaf-replace profile overlay** as the rest of the manifest: a shipped or local profile may override base strings; `get-string` returns the merged view for the named adapter option....\n*86:<!-- [doc->REQ-MANIFEST-5] -->\n*87:**File-backed strings** (M12-W3): a `[strings]` value MAY be a **file pointer** instead of an inline literal — a value-position table with **exactly one** key `file`: `skill = { file = \"skill.md\" }`. `get-string` resolves it to the file's **contents** (so large bodies — skill-instructions, hint text — stay out of the manifest). The exactly-one-key rule is the disambiguation: any other table shape stays an opaque nested strings tree (existing trees untouched), and `{ file = … }` is reserved as the...\n*88:_Avoid_: treating strings as config knobs for spt-core itself (those are global settings); \"adapter KV store\" as a separate registry; putting user files in the adapter-shipped `strings/` dir (clobbered by updates — use a local profile).\n 89:\n*90:**manifest substitution in `[strings]`** (ratified 2026-06-25, v0.16.0 update-arc grill):\n*91:`get-string` resolves a set of **adapter-static** substitution keys inside a returned string value at **read time** (lazily, like file-backed strings): **`{adapter_dir}`** — the registry record's precise `source_dir` (the install dir; survives updates; the same dir bare-program resolution already uses) — and **`{adapter_name}`**. Session-scoped keys (`{id}`/`{session_id}`/…) are **not** available: `get-string` carries no session context today, and a `get-string --session-id` for session-scoped subs...\n*92:_Avoid_: session-scoped substitution through bare `get-string`; reading this as spt-core executing a string (it never does — the adapter wrapper executes the resolved value).\n 93:\n 94:**keyword hints** (ratified 2026-06-12 — core milestone A):\n*95:<!-- [doc->REQ-MANIFEST-4] -->\n*96:Once-per-session usage/syntax hints, a first-class adapter feature: the manifest's `[hints]` section declares entries of `{keywords (literal default, regex opt-in), text}`; the adapter's user-prompt hook pipes the **full user message** to `spt api hint --session <id>` (stdin) and receives matched hint lines (`keyword hint for SPT adapter <name>: \"<kw>\"-->{text}`) for its context-injection channel. The daemon keeps a per-session seen-set — each hint fires **once per session** (a `/clear` mints a new ses...\n*97:_Avoid_: unconditional static context (that's the adapter's own preamble); firing per-message.\n 98:\n 99:**shell keyword hints** (releases#133, operator-GREENLIT; shape ruled 2026-08-30):\n 100:<!-- [doc->REQ-SHELL-HINTS] -->\n*101:`[[hints]]` was never harness-only — it is a top-level manifest section with no kind gate, so a `kind = \"shell\"` adapter has always been able to declare hints; what was missing was a reader, because the only reader resolved the endpoint-bound manifest. The now-signal **HINTS** category now also reads the shell adapters an endpoint can see, one line each under the per-source cap above. **Two arms, decided by INSTANTIATION and never by link state:** an adapter this owner holds an instance of surfaces the...\n*102:_Avoid_: gating the shell arm on the harness manifest (an endpoint with no bound manifest still owns shells); filtering the full arm on online/offline; naming a surfacing command that does not exist at the shipping head.\n 103:\n*104:**adapter update declaration** (manifest field):\n 105:<!-- [doc->REQ-UPD-9] -->\n*106:Each adapter manifest declares how spt-core should *ripple-update the adapter itself* (see Self-update). One of: **file-pull** (a plugin-directory lookup regex + a gh repo for the adapter's latest files — spt-core fetches + swaps), **delegated command** (a binary command the adapter owns, e.g. `claude.exe plugin update` — spt-core invokes it), or **gh_release** (the adapter ships its updates from its own GitHub releases). After initial bootstrap, the plugin no longer self-manages updates; spt-core co...\n 107:\n*108:**adapter packaging & live update** (v0.13.2; ADR-0024, ADR-0025):\n*109:<!-- [doc->REQ-ADAPTER-GH-TRANSPORT] -->\n*110:A `.spt` may be **multi-platform**: shared `manifest.toml` + `strings/` at the root, role binaries under per-target-triple subdirectories (`x86_64-pc-windows-msvc/`, …); install/update extracts the shared root plus only the current node's triple, flattened into `install_dir`, so flat `<install_dir>/<program>` resolution is unchanged. It stays one signed asset (`adapter.spt`, plain-tar or gzip); a multi-platform archive missing the recipient's triple is a typed `NoArtifactForPlatform`. Large adapters ma...\n*111:<!-- [doc->REQ-ADAPTER-UPDATE-MESSAGE] -->\n*112:An optional **`[update].message`** (avenue-agnostic) is a plain multi-line operator notice surfaced to stdout, markdown-rendered (the helpfmt prose path), **only when an update is actually applied** (the version changed) — never on a no-op. It is read from the newly-installed manifest with no `{key}` substitution; its use is to announce a post-update action (e.g. \"run `/reload-plugins` in any ongoing sessions\").\n 113:\n 114:**composite update — `[update.post]`** (ratified 2026-06-25, v0.16.0 update-arc grill; ADR-0029):\n*115:An optional **avenue-agnostic** post-step `{ command, self_verifies }` spt-core runs **after** the primary avenue resolves — in the same `spt adapter update` **and at `spt adapter add`** (install is the first update, so a fresh install conducts the post-step too; bug-#1 operator ruling, v0.19.0 — the eager-extract acquisition runs it post-registration, a delegated acquisition after the acquisition succeeds, and only the payload-less `file_pull` PENDING add defers it to the payload's arrival) — so o...\n 116:\n*117:**resident adapter binary**: an adapter-owned process spt-core keeps alive for an endpoint's lifetime (today the `[message-idle-translation-binary]`), as opposed to **ephemeral** adapter binaries — the Psyche loop (daemon-hosted, ADR-0004), the `[digest]` extractor, `[session.*]` runners, hooks — which spawn on demand and pick up an update on their next invocation. Only resident binaries are stopped/restarted on a live update; ephemerals self-heal.\n 118:_Avoid_: calling the Psyche loop or an on-demand extractor a \"resident\" binary; \"restart the endpoint\" for what is a per-binary cycle.\n 119:\n*120:**session-invocation declaration** (manifest field, noted for spt-plugin parity):\n*121:How the harness spawns agent sessions, including Psyche and echo-commune sessions. For the rebuilt spt-plugin, Psyche and echo communes must migrate **off `claude -p`** (imminent Claude Code billing changes) to headless `claude` sessions (`--resume` for the Psyche). This is an adapter/manifest concern, not a core concern, but the parity milestone must carry it.\n 122:\n*123:### Manifest seams (outbound contract, detailed)\n 124:\n*125:Governing principle: **SPT is not a harness.** Model choice, billing shape, harness-internal env, and harness-internal context are entirely the adapter's concern, expressed inside the adapter's own command templates. spt-core owns only the template *mechanism* (substitution keys), the substitution *values* it is responsible for, and the surrounding lifecycle. Env for the *endpoint binary itself* is auto-handled by spt-core/broker; env for the *agent running inside* that binary is the adapter's config (e....\n 126:\n*127:**spawn-session seam** — launch a new agent session on this node. Manifest provides: a command template; `cwd`/project; a `headless` flag (optional, default false — for the GUI's resume-of-compatible-adapters); a `resume` flag (optional); and the `commune` + `signoff` file directories relative to `cwd` (so the daemon knows where to watch). Substitution keys spt-core can supply: `{id}` and, optionally, a spt-core-generated valid session UUID (e.g. injected as `--session-id {uuid}`) so an adapter can s...\n 128:- **id resolution:** `id` is optional. With no id, spt-core reproduces today's no-id `/spt:live` behavior — run the lone live agent if that's all the project has; show a picker with proposed default IDs if the project has none; let the user choose if there are several.\n 129:\n*130:**post-spawn seam** — the just-launched binary calls an spt-core command on boot (via the adapter's SessionStart-equivalent hook) to bind itself. Needed because the harness's own session id usually isn't known until after the binary runs. Payload: the harness `session_id` (when binary-generated rather than spt-core-injected); the `parent_pid` (the stable session-binding anchor — see KNOWN-HAZARDS 2.1); an endpoint identity/type confirmation; optionally a local HTTP port the binary listens on (for HTT...\n 131:\n*132:**post-spawn is optional only under a strict commitment:** an adapter may forgo post-spawn *only* if it (a) injects the spt-core-generated session UUID at spawn AND (b) guarantees the launched-process pid IS the stable session-binding anchor (no wrapper-script / subprocess pid indirection). If either does not hold, post-spawn must fire to report `session_id` and/or `parent_pid`. UUID-injection alone suppresses only the `session_id` reporting, not the binding.\n 133:\n*134:**spawn-psyche seam** — two command templates: fresh-start and resume (the resume template includes `$session_id`). Both include `$psyche_prompt` — the revival essentials spt-core feeds the Psyche (timestamp, incoming event envelope). Everything else is the adapter's: model selection (in its template), and any harness-specific instructions the Psyche needs (Write-tool usage, commune dir) supplied as a static preamble before `$psyche_prompt` or as adapter SessionStart additionalContext. spt-core owns ...\n 135:\n 136:**history subsystem** (covers echo-commune source logs, resume briefs, and Shell logs) — two supported paths:\n*137:- **Path A — adapter-owned logs.** Manifest declares a locate-template (keyed by `$session_id`) + a **normalize-command the adapter owns** that emits spt-core's expected normalized format. spt-core docs must teach adapter devs how to build a conformant parser.\n*138:- **Path B — spt-core-native history store.** spt-core exposes a `history-log` command/API; the adapter writes its logs to spt-core in the native format and spt-core stores them. Rationale: spt-core needs its own log store for Shells anyway, and this simplifies integration for flexible/DIY harnesses.\n*139:- The **echo-commune seam** is then just a command template (adapter picks the model) that consumes whichever history path is configured for the session.\n*140:- **Why adapter-owned normalize over spt-core built-in parsers** (grounded in a Codex-CLI vs Claude-Code comparison): transcript formats diverge sharply and move fast. Claude Code = one flat JSONL per session, project-partitioned, locatable directly from the session id (`~/.claude/projects/<hash>/<id>.jsonl`). Codex = date-partitioned **rollout files** (`~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<id>.jsonl`) where the id is only a filename *substring* (must recursive-glob to locate), a **3-level tagged e...\n*141:- **Profile-relocated transcript roots — env-read capture** (ratified 2026-06-30, counter-38 field-bug grill; supersedes a rejected harness-specific `{config_dir}` proposal): a Path-A locate-template (and the `[digest].source` that reuses it) may reference a manifest-declared **`[env.<VAR>] direction = \"read\"`** var (e.g. `{CLAUDE_CONFIG_DIR}`). spt-core captures the **declared** read-vars — an explicit allowlist, never the whole env — from the session's launch environment at **bind** (the only poi...\n*142:- **`[digest]` mirrors history's two strategies — locate ownership** (ratified 2026-06-30, counter-38 W6 design-gate): like `[history]`, `[digest]` supports **`fetcher`** (the adapter's extractor **locates + reads + emits** normalized digest records; spt-core runs it bounded and consumes its stdout, doing **no** locate and **no** pre-read) alongside the original **`locate_normalize`** (`source` template → spt-core locates a **single** file + reads + pipes the bytes to the extractor as a pure stdin→...\n 143:\n*144:**activity/idle detection** — **not** PTY-quiescence (insufficient: e.g. CC's AskUserQuestion stalls the PTY while holding stdin and needing nuanced input) and **not** a manifest-declared idle signal. Instead, the adapter calls spt-core activity/idle commands at the right moments (from its hooks); those commands manage activity/idle **sentinels inside the session perch**. The idle state lives in the perch, owned by spt-core via the commands API exposed to adapter devs.\n 145:\n 146:**activity observation** (ruled 2026-07-24, rebound grill) — two avenues, deliberately split by consumer class; the digest is **not** one of them (it stays a content surface). **Push (shells only):** an owned Shell observes its *owner's* busy/idle transitions as an **activity frame** on the existing shell-link event stream — link-scoped (owner implied by the link token), **drive-class semantics** (ephemeral, latest-wins, current-state-carrying; a redundant same-state resend is a harmless no-op — th...\n 147:\n...\n 149:- PTY injection (with or without key/submit sequences) — spt-hosted topology;\n*150:- adapter hooks calling spt-core poll commands;\n*151:- an in-adapter-session child relay (à la CC's Monitor tool);\n*152:- adapter manifest requesting HTTP POST delivery to the endpoint binary on a local port (shared via the post-spawn seam).\n*153:Note: even spt-hosted sessions default to hook injection (or the adapter's equivalent) as the non-disruptive path **during activity**; some adapters prefer the in-session relay regardless of topology.\n 154:\n 155:**activity-gated delivery** — an inbound message routes by the receiver's activity sentinel (above). While the endpoint is **active**, the message spools for the receiver's own hook-poll to drain (non-disruptive — the *active window*). On **idle** (or an idle transition before a hook drains it), it delivers immediately — translation binary (spt-hosted) → relay-poll (either topology) → spool, in that fallback order (the *idle window*). The send-side axes below modulate which of these two windows...\n 156:\n 157:**message delivery axes** — a sent message carries independent modifiers on orthogonal axes; it is **not** a single \"type\". The flag on each axis defaults to the unrestricted value:\n*158:- **delivery window** (*when*) — **default** (both windows; delivers in whichever fires first) · **idle-only** (held for the idle window; delivered immediately if already idle) · **active-only** (active window only — the receiver's hook-poll; never wakes an idle agent). *active-only* is the renamed legacy **deferred** (the `deferred=1` spool column + `api poll --include-deferred` are its internal/adapter-facing names). Mutually exclusive.\n 159:- **channel restriction** (*through what*) — **unrestricted** (any configured inject method) · **prefer-native** (the translation binary if one is running, else fall back to the standard methods) · **force-native** (the translation binary and nothing else — no fallback, no spool-to-another-method). Mutually exclusive; composes with the window. *\"Native\"* = the `[message-idle-translation-binary]` PTY channel.\n 160:- **persistence** (*how long it waits*) — **durable** (default; spooled until delivered or TTL) · **ephemeral** (dropped if it cannot deliver in its accepted window — at the moment the window opens with no live carrier, or at TTL, whichever is first). Ephemeral is the **only** path permitted to drop silently (the REQ-HAZARD-IDLE-SILENT-NONDELIVERY carve-out); every non-ephemeral path spools and reports non-delivery. <!-- v0.15.0 PARTIAL (W3): ephemeral evaporation covers the spt-hosted-binary no-car...\n 161:Window restricts *when* delivery is accepted, channel restricts *which method* carries it, persistence restricts *how long* it waits — they compose freely (e.g. `force-native` + `active-only` = the binary injects during the active window, never idle; `force-native` + `ephemeral` = binary-or-nothing).\n 162:\n*163:**message metadata (`json`)** — a sender may attach an opaque JSON metadata block (`--json-payload`), carried as a single attr-escaped `json=\"…\"` envelope attribute **alongside** (never replacing) the body. spt-core never interprets it — pure verbatim passthrough across every rail (spool / TCP / WAN / EVENT-PART), parsed only by the receiving adapter (its hooks and/or translation binary). Collision-proof by construction: the structured data lives **inside** the single `json` value, so it can never ...\n 164:\n 165:**resume-session seam** — two distinct forms:\n*166:- **fresh-with-preload:** resume with *cleared* context (a fresh session) + psyche-download. Accepts a `$psyche-context` key to launch the fresh session with the psyche-download preloaded — or the adapter instead pulls it via an spt-core command in its SessionStart hook. <!-- [doc->REQ-RESUME-CONTEXT-PULL] --> That command is **`spt api psyche-download <id> [--session-id <sid>]`**: it emits the durable resume brief (role → live-context → project-context, project resolved from the perch's bound cwd)...\n*167:- **continue-existing:** resume an existing harness session under the adapter (its native resume).\n 168:\n*169:**capability declaration** — which endpoint types a harness/node can host (a Pi node might host only Shells, never a LiveAgent). Static manifest list, consumed by the subnet registry so a node advertises its hostable types. Exact shape is design-open.\n 170:\n*171:**adapter-update seam** — file-pull or delegated-command (see Self-update). Locked.\n 172:\n*173:There is no separate \"model/billing\" seam — those live inside the adapter's spawn/psyche/echo command templates. SPT never selects a model. The full manifest schema is `docs/MANIFEST.md`; key model-level facts from it:\n 174:\n*175:- **Command templates are opaque.** spt-core never parses out a model/tool/flag — the adapter writes the whole command line; spt-core fills substitution keys and runs it.\n*176:- **A command template's program token resolves against the adapter install dir before PATH (since v0.8.0).** A `.spt` adapter ships its built binaries to the adapter's install dir (`adapters/_github/<safe>/` via `--release`/`--github`, or the record's `source_dir` under copy-mode), so a bare program name (e.g. `claude-spt-digest …`) binds to the shipped binary first and falls back to PATH when absent — a `.spt` that ships its binaries is **self-contained**, needing no PATH placement. <!-- [doc->REQ-...\n*177:- **Hook output capability is declared per harness-event** (`can_inject`). CC's Stop hook cannot inject context — that single fact drives the echo-gate sentinel + relay fallback. The manifest expresses it so spt-core knows when to fall back.\n 178:- **Env injection is asymmetric** (file-bridge-only-when-not-launcher, applied to env): spt-hosted sessions inherit env from the broker that spawns them; harness-hosted sessions need the harness's declared env channel. With `spt` on PATH the env table is small.\n*179:- **Cross-adapter fallback** is a **node-wide setting**, not a manifest field: if a Psyche/echo invocation under one adapter is rate-limited, spt-core falls back to another adapter (e.g. `ccs` — its own adapter, not a binary-swap). <!-- [doc->REQ-MANIFEST-6] --> A fallback **target is addressed as `<adapter>:<profile>`** (not just a bare adapter_name) and resolves through the one composite-addressing resolver (`registry::resolve_option`), so a fallback may select a shipped or local profile (`ccs`, `ccs...\n*180:- **Config knobs** (pulse period, echo-commune window/gate, route-guard window, daily refresh) are spt-core **global settings** with optional **per-endpoint override**. **An adapter may DECLARE A DEFAULT, never an override** (narrowed 2026-08-03, LOCKSMITH grill — the original \"never per-adapter\" wording is superseded): a harness has real information about its own turn shape and cost, but the operator keeps the last word. Precedence, highest first: **per-endpoint override → node/global setting → ad...\n*181:- **Event-block vocabulary and file-drop filenames are fixed spt-core constants** (documented for adapter authors), not manifest-configurable. <!-- [doc->REQ-RESUME-CONTEXT-PULL] --> This includes the **checkpoint sentinel `!!checkpoint!!`** — the agent-checkpoint trigger an adapter embeds in a commune/signoff drop body (one bare token = checkpoint with default wake; a `!!checkpoint!! <text> !!checkpoint!!` pair makes the inter-marker text a custom wake directive). It is spt-core control metadata: spt-...\n 182:\n 183:### Inbound `api` surface (detailed)\n 184:\n*185:All commands below are `api`-prefixed (machinery-facing). Every `api` invocation **and** every manifest carries a unified **`adapter_name`** string (e.g. `claude-spt`) identifying the owning adapter. This is load-bearing: one daemon hosts endpoints from multiple adapters (`claude-spt`, `spt-codex`, `spt-pi`), so the daemon resolves an endpoint's manifest + seams (history normalize-command, inject method, update avenue) by its `adapter_name`; adapter-update ripples target by it; capability lookup and tele...\n 186:\n 187:<!-- [doc->REQ-API-4] -->\n*188:**Manifest resolution from `--adapter` (since v0.8.0).** `spt api <cmd> --adapter <name[:profile]>` resolves the registered adapter's manifest, `:profile` overlay, and install dir from the registry when `--manifest` is omitted — a registered adapter's `api` calls need only `--adapter`. `--manifest <path>` becomes an optional **override** (an unregistered or local-dev manifest): when present, the manifest loads from that file and the install dir is its parent directory; when absent, both come from the r...\n 189:\n 190:- **`api bind`** — post-spawn boot bind (payload above). Skeleton→live.\n*191:- **`api listen`** — *long-running* relay/poll listener that an adapter-owned (harness-hosted) session owns as a child process; streams the daemon's events to the session's stdout. Distinct from the short-lived `api poll`. This is the heir to today's Monitor-bound `$LIVE start` poll loop.\n*192:- **`api poll`** — short-lived drain of queued messages for a session (the hook-injection delivery path). `--include-deferred` optionally also drains deferred rows, for adapter flexibility (default excludes them — KNOWN-HAZARDS 1.4/4.4).\n*193:- **`api state <busy|idle>`** — adapter reports session activity; writes the activity/idle sentinel in the session perch. **The BUSY edge arms the echo-commune gate sentinel** (`.more-done`-equiv); `--no-gate` suppresses that coupling, and a standalone **`api echo-gate <set|clear>`** gives granular adapters explicit control over when echo communes may fire, independent of activity.\n 194:  **ECHO CADENCE (ratified 2026-08-03, LOCKSMITH grill — releases#113; supersedes arm-on-idle):** the gate is armed on the **busy** edge and the **idle edge does NOT disarm it**. The echo **fires when the armed gate reaches the configured age, REGARDLESS of current activity state**, then resets the age and **re-arms only if the session is still busy**. Consequences that make this the ruled shape: a long autonomous turn ages out and echoes on its own, so cadence never depends on turn boundaries; and a s...\n 195:- **`api worker-start`** / **`api worker-stop`** — Worker (subagent) perch create/teardown under the parent (nested, registry-tracked).\n 196:- **`api worker-poll`** — a Worker (subagent) receives its queued messages (inbound from Self or sibling Workers).\n*197:- **`api boundary <clear|compact>`** — context-boundary report; **carries the new `session_id`** (it rotates on `/clear` or `/compact`), so the daemon rebinds the perch to the new session id while keeping the stable identity + `parent_pid` anchor. Authors a **Self-resume commune** (resume the Self session → commune file-drop) rather than a background echo — strong live-context signal at the boundary (see `docs/CONTEXT-MEMORY.md`). **Rotation credential** (ADR-0032): the proof of association for thi...\n*198:- **`api session-end`** — session stop/crash report → soft teardown by default (preserve perch + spool + tracked history for recovery — KNOWN-HAZARDS 6.2). **`--erase`** instead hard-wipes the perch and tracked history (for ephemeral/secondary adapters that act as robust agent-spawned-agent surfaces).\n 199:\n*200:**`spt endpoint purge <id>`** (CLI, not `api`) — the standalone, formal **full teardown**: wipe an endpoint and *every* record keyed on it. It is the dev/CI sibling of `api session-end --erase` (which is adapter-triggered at session end); `purge` is the explicit operator/test command for clean setup-and-reset. **Deliberately NOT consent-gated** — a local dev/test op, never a peer-visible action. **Offline-only**: it refuses a live / daemon-hosted endpoint (deleting records out from under a running ho...\n 201:_Avoid_: consent-gating it (it is intentionally ungated, for CI); treating it as a sync/remote op (local-only); a soft variant (purge is always the hard, full wipe — soft teardown is `endpoint stop`). **Read that soft/hard contrast on the RECORD axis only** (ADR-0045): `stop` is *record*-preserving (spool.db + info.json survive) where `purge` wipes them. It says nothing about processes — on the **process** axis `stop` is hard: for a broker-hosted endpoint it reaps the session and its descendant subtr...\n 202:<!-- [doc->REQ-ENDPOINT-PURGE] -->\n 203:\n 204:- **`api history-log`** — Path B: ingest normalized records into spt-core's native history store.\n*205:- **`api presence`** — adapter reports user interaction → updates the presence datum `(last_active_node, last_active_endpoint, ts)`. In the spt-hosted topology, presence is **also** updated by the broker *detecting* (sensing, not watching/logging) user input on a held PTY — privacy-preserving (it notes that input occurred, records no content).\n 206:- **`api emit --type <sensory_type> <payload>`** — a broker-launched **Shell** binary pushes a sensory payload to its owner agent (owner known from `api bind`; REST-only, never spooled). See the Shell model.\n 207:\n*208:**Not `api` commands — file-drop flow:** `commune` and `signoff` are deprecated as commands (modern SPT) in favor of file drops. The agent/adapter writes `<id>-commune.md` / `<id>-signoff.md`; the daemon watches the manifest-declared commune/signoff dirs (the spawn-session seam fields), ingests, and deletes (drop files are daemon-owned single-writer — KNOWN-HAZARDS 6.4). These stay off the `api` surface and the agent surface alike. **This path is the Self's, and spt-core's echo-commune never writes i...\n 209:\n 210:### Startup flows (the two topologies)\n 211:\n*212:**Adapters never resolve `$SPT_HOME`.** spt-core install registers its binary directory on the system-wide PATH, so adapters call `spt api …` on any OS without path math. All harness↔daemon bridging goes through `spt api` commands (the daemon is always running, or auto-started — below), so there is **no adapter-written file** in the bind path.\n 213:\n 214:**Harness-hosted (e.g. spt-plugin; the harness binary is user-launched, harness is the parent).** Key constraint: the SPT *live agent* does not exist until the agent invokes start — the `live_id` isn't chosen at session boot, and `$LIVE start` is itself invoked *behind the Monitor tool*, so it becomes the long-running relay. So binding cannot happen at SessionStart directly. A **seed record** (daemon-held, in-memory — not a file) bridges the gap:\n*215:1. The harness's SessionStart hook calls **`spt api seed --pid <parent_pid> --session-id <sid> [cwd]`**. The daemon records an ephemeral in-memory **seed entry** keyed by `parent_pid` — the session details the spt-hosted topology would share directly, minus the not-yet-chosen `live_id`. The seed is **adapter-agnostic**: it carries no `adapter_name`. <!-- [doc->REQ-START-5] --> *Which* adapter/profile a session belongs to is resolved later, at bind, as a read against the live registry (below) — so one...\n*216:2. The agent runs `/spt:live <id>` → the adapter's `$LIVE start <id>` alias = **`$SPT listen <id>`** (= `spt api listen <id>`), invoked via Monitor. It self-discovers its `parent_pid`, the daemon matches the seed entry by that pid (validated against `session_id` to defeat PID-recycling — KNOWN-HAZARDS 5.1), **resolves the owning adapter/profile** (the bind-time resolution below), creates/revives the perch binding `live_id` ↔ session details, then enters the long-running relay loop streaming events ...\n 217:3. The always-on daemon holds the perch, spool, registry, and daemon-spawns the Psyche (via the spawn-psyche seam) — no separate wrapper. The relay is purely the delivery pipe.\n 218:   - Seed entry refreshed on each SessionStart (keeps `session_id` current across `/clear`, since `parent_pid` is stable while the harness process persists). If a harness has no SessionStart-equiv, `start` may carry the details directly as args — the seed is the preferred convenience, not the only path.\n 219:   - The same seed + bind-time resolution serves a **ReadyAgent** bringup (`$SPT ready`/poll), not just a LiveAgent — a harness-hosted ready agent is seeded and resolved identically (it just binds a poll listener, no Psyche).\n 220:\n*221:**Bind-time adapter/profile resolution (ADR-0021).** Because the seed is adapter-agnostic, `listen`/`poll` resolve the owning adapter/profile when they bind, as a pure read — never a seed-time snapshot that could drift. `--adapter <name[:profile]>` is an **optional override** on the `api` group (an explicit choice for adapter dev/iteration); omitted, resolution runs:\n 222:1. the seed's `parent_pid` → that process's **executable basename** (case-insensitive, `.exe`-stripped);\n*223:2. **candidate adapters** = registered `kind=\"harness\"` adapters whose **`host_binaries`** (the manifest match-key) contains that basename; <!-- [doc->REQ-MANIFEST-8] -->\n*224:3. **profile**: the durable **active-profile pointer** (`spt adapter use <adapter>[:profile]` writes it; one default per `host_binary`) wins; unset → the freshest candidate adapter by `registered_at_ms`, base profile (a specific profile is only ever chosen by the pointer), name-ascending on ties; <!-- [doc->REQ-INSTALL-12] -->\n*225:4. zero candidates → a friendly error naming the binary and the `--adapter` escape. The pointer is a standing user preference (durable on disk, never auto-written by install/update); the seed is ephemeral — see ADR-0021.\n 226:\n 227:**Daemon auto-start:** the daemon is per-machine always-on (OS-service registered), but any `spt api` invocation that needs it will **start it if absent** (fresh boot, crash, never-installed-as-service). `$SPT listen` for the first SPT session on a machine thus transparently spins up the daemon. Ensure-running lives in the `api` layer generally; `listen` is the reliable anchor.\n 228:\n...\n 231:2. The binary boots and fires **`api bind`** (or skips it under the strict UUID-injection + stable-pid commitment). **No catalyst/seed file** — the daemon is the launcher, already holds a direct channel (it spawned the process and owns the PTY), so a file round-trip would only add drive churn for no benefit.\n*232:3. The daemon delivers events; method is **manifest-configurable per activity-state** — direct PTY injection, or a relay even here (some adapters prefer a relay over PTY injection for idle delivery), or HTTP. During *activity*, delivery still defaults to the non-disruptive hook-injection path, not raw PTY writes.\n 233:4. Psyche is daemon-spawned, same as above.\n 234:\n*235:So the old `$LIVE start` splits by topology: harness-hosted = SessionStart writes an adapter-agnostic seed → `$SPT listen <id>` consumes seed (by `parent_pid`) + resolves adapter/profile (ADR-0021) + binds + relays — legacy parity (`$LIVE start <id>` → `$SPT listen <id>`, no mandatory `--adapter`); spt-hosted = daemon spawn-session + `api bind` (direct, no file). The asymmetry is the file-bridge-only-when-no-direct-channel principle.\n 236:\n*237:**Env-var aliases:** adapters inject clean env-var aliases for in-session invocation (heirs to today's `$OWL`/`$LIVE`), e.g. **`$SPT` = `spt api`** so a Monitor-bound call reads `$SPT listen <id>`. spt-core supplies the subcommands; the adapter supplies the env aliases (manifest philosophy).\n 238:\n 239:### Endpoint types\n 240:\n...\n 253:\n*254:*Psyche-host health — harness-reachable failure signal (v0.8.1, REQ-HAZARD-LIVEHOST-BOOT-RACE):* a LiveAgent's `status=online` is daemon-authoritative liveness and **stays authoritative** — but it does not by itself prove the daemon hosted a Psyche. When the brain's live-host reconcile fails to spawn the Psyche (e.g. the adapter's psyche binary is absent from its install dir, or the net-less boot-race starves the host), that failure was previously **silent** — only an `eprintln!` on the brain's inv...\n 255:\n 256:**Summarizer**:\n 257:The ephemeral, cheap model that builds a **commune delta** from a Self's recent turns and feeds it *into* the **Psyche** as inbound context. A distinct actor from the Psyche — different (cheaper) model, fire-and-forget, **no perch** (not an endpoint type). It authors *commune* deltas only, **never** *reply*/*notify*.\n...\n 271:**Gateway** (concept ratified 2026-06-11; registered via the open type system, first instance downstream):\n*272:A **human-backed endpoint** — a user's specialized window into the subnet from a device or surface with no conventional-harness compatibility. Nothing LLM-shaped runs there; the intelligence at the endpoint is the **user**. Addressable like any endpoint (receives digests/messages, sends via the normal verbs) and may **own Shells** (it is an owning endpoint — see §Shell model). Distinct from a Shell: a Shell is *driven from elsewhere*; a Gateway *originates* interaction. No `tracked/` mind, no Psyche...\n 273:\n 274:<!-- [doc->REQ-MSG-5] -->\n 275:A message sent from a Gateway carries **the user's authority** — it *is* the user speaking through a device — and is delivered typed **`user-msg`** (ratified 2026-06-12) so receiving agents weight it as user instruction, not peer-agent chatter. The type is **identity-gated, never payload-trusted** (the KH 7.3/7.5 posture): the daemon permits `user-msg` only from user-backed origins (a Gateway endpoint, the local user's own CLI) and re-stamps an agent-family sender's `user-msg` down to plain `msg` —...\n...\n 279:\n*280:A Gateway endpoint binary is revived by **existing machinery only** (settled 2026-06-12, two corrections deep): while running, the bridged device's link liveness drives ordinary **instance state** (sustained device silence → dormant; device contact → active — the driver-attach rule). Across a node restart, revival rides a **co-located shell's wake-watcher** — the Gateway typically owns a shell instance on its own gateway host; that shell's offline wake-watcher (one of the two classes of third-par...\n 281:_Avoid_: calling a Gateway a Shell or an agent; \"console\", \"remote\".\n 282:\n 283:**PresenceChannel** (broker endpoint — concept locked, impl deferred past v1):\n...\n 293:**ResidentService** (concept ratified 2026-07-26, ADR-0049 — the supervised substrate; first consumer spt-alchemy's Hub Daemon):\n*294:A **daemon-supervised binary an adapter declares, with no perch, no identity, no address.** Core owns the process from birth: the daemon spawns it **job-neutrally** (never a shell's child — a shell's tree-kill and a launching terminal's Job Object cannot reach it) and supervises it with the wake-watcher scaffolding (backoff, give-up latch, one-per-instance lock, orphan-kill, brain-side reconcile), running **independent of any agent's liveness**. Declared by the adapter manifest's **`[service]`** sectio...\n 295:_Avoid_: calling it an endpoint (no identity, no address — that is the [[AlwaysOnEndpoint]] layered on top); calling it a Shell (owner-less, not driven); \"daemon\" unqualified (the node has one daemon; this is a supervised service under it); treating its supervision record as liveness truth (there is none — liveness is the child handle).\n 296:\n 297:**AlwaysOnEndpoint** (always-on endpoint; concept ratified 2026-06-21, re-based on the substrate 2026-07-26 per ADR-0049 — core kind, first instance downstream `spt-discord`):\n...\n 330:\n*331:Concrete Shells live **downstream in shell-adapter repos** (first shipped: `spt-shell-notify`, the OS-notification shell — the mechanism itself delivered in v1); the PresenceChannel implementation and presence gossip remain deferred.\n 332:\n*333:**Shells differ structurally from agent endpoints** (full treatment below): a Shell has a node-local perch but **no `tracked/` context** (no mind to sync); its logs are node-local; it is **adapter/platform-bound, not adapter-agnostic**; and its lifecycle is link/teardown, not the dormant/suspended resting model of agent instances.\n 334:\n 335:#### Shell model (detailed)\n 336:\n...\n 338:\n*339:**Provided by a shell adapter.** There are two kinds of adapter, both manifested, both under `adapters/`: a **harness adapter** (`kind=\"harness\"`, e.g. `claude-spt`) hosts *agent* endpoints; a **shell adapter** (`kind=\"shell\"`, e.g. `GameRobot`) provides a *Shell* endpoint. `Shell` is the endpoint type; the shell adapter name is the provider, and its manifest defines the Shell's capability surface. A shell binary is **only ever launched by SPT** (the broker spawns it; never user-launched).\n 340:\n 341:**Three channels** over the typed/binary substrate:\n*342:- **command** (agent→shell, durable/spooled) — the manifest's capability toolset; drives the shell and shell→environment interaction.\n*343:- **text+file** (2-way, durable/spooled; per-shell) — general payloads; for a shell it is a *forwarder* (harness adapters have this channel too). File transfers are progress-queryable (below).\n 344:- **sensory** (shell→agent, **REST-only, never spooled**) — images, sounds, and arbitrary descriptive sensory payloads (\"movement completed\", \"obstructed\", \"bumped\", temperature, energy level). Ephemeral — delivered to the agent's live session; dropped if the agent isn't live.\n 345:- **drive** (owner→shell, **REST-only, never spooled, latest-wins**) — continuous-control payloads steering a surface in real time (scroll/crank state, stick positions, avatar movement). The owner→shell mirror of **sensory**: ephemeral, dropped if the shell is offline, a missed frame is superseded by the next — spooled replay of stale control on relink would be actively wrong, which is why this is not the command channel. Commands = discrete + durable; drive = continuous + ephemeral. (Minted 2026...\n 346:\n*347:<!-- [doc->REQ-SHELL-4] shell tunnel: a long-lived reliable-ordered link-bound QUIC stream pair carrying opaque bytes the taxonomy never reinterprets; manifest opt-in, not enveloped/MAC-framed/spooled; link-break closes it; reliable-ordered ⇒ on-LAN posture -->\n 348:Channels carry typed, taxonomy-interpreted payloads. Distinct from them, an owner↔shell link may also hold a **shell tunnel**: a long-lived, **reliable, ordered** byte stream (a dedicated QUIC stream pair bound to the link) for protocol traffic the channel taxonomy must NOT reinterpret — opaque wire protocols spoken end-to-end (first consumer: USB/IP URB traffic to a usbip shell). Not spooled, not enveloped; the link's lifecycle governs it — a link-break closes the tunnel. Reliable-ordered is the p...\n 349:\n 350:<!-- [doc->REQ-SHELL-5] owner-type-agnostic: control-exclusivity keys on the owner endpoint_id, never the owner's endpoint type -->\n...\n 352:\n*353:**Command-delivery method** is the shell adapter's choice (same modes as agent inject-input): HTTP REST, stdin-via-broker, or child-relay (`api poll`). The binary is always broker-launched, so binding is direct (`api bind`, no seed-file).\n 354:\n*355:**Lifecycle = online / offline / torn-down.** A **link-break always closes the broker + shell binary** (with an optional manifest-declared **pre-close instruction** to the binary + a **termination timeout** for graceful shutdown). Then: an **ephemeral** shell (a manifest property, not an agent choice) is fully torn down **and removed from the agent's shell history**; a **persistent** shell keeps its perch **offline** (re-linkable — the binary is re-spawned on relink). **The binary never survives a link...\n 356:\n*357:**Broadcast policy** (manifest): the shell advertises its availability to all agents on the subnet, only same-node agents, or not at all (agents learn of it only by user instruction). Shells run only on nodes where the shell adapter is installed/advertised.\n 358:\n*359:**Instantiation scope (orthogonal to broadcast):** `broadcast` governs *discovery* only; *permission* to instantiate is **\"shell adapter registered on this node\" + the per-shell approval gate** (below). Any agent **instance running on a node** where the shell adapter is registered may `shell spawn` it. A **cross-node** spawn (owner on one node, the shell binary on another) is **not** special-cased — it rides the deferred **instantiate-anywhere** primitive and its consent gate; same-node spawn does not.\n 360:\n*361:**`shell spawn` mints a new instance, it is not the online switch.** `spt shell spawn <adapter> [--id]` creates a *new* Shell identity (`<adapter>-<n>` + its perch + the owner link). Bringing an *existing* offline (persistent) instance back online is **`shell relink <id>`** (re-spawns the binary), or happens automatically via `persistent` (auto-online with the owner) / `wake_command` (offline wake-watcher). `shell teardown <id>` destroys it. So **`spawn`:create :: `relink`/`persistent`/`wake`:online** ...\n 362:\n*363:**per-shell instantiation approval (`require_approval`, manifest enum):** gates `shell spawn`, reusing the consent plumbing (grant store + interactive escalation; see Consent & security gates).\n 364:- `none` (**default** — matches the system's everything-opt-in posture) — the agent spawns freely within scope.\n*365:- `remembered` — prompt on first spawn; **allow-always writes a persistent grant** (`spawn-shell × agent × (node, shell-adapter)`) so later spawns auto-allow; allow-once does not.\n 366:- `always` — prompt on **every** spawn; allow-always is suppressed (no persistent grant).\n 367:\n*368:The manifest value is the **floor**: a node/endpoint setting may **tighten** (demand approval where the manifest says `none`) but never loosen below what the shell requires.\n 369:\n 370:<!-- [doc->REQ-CONSENT-3] -->\n*371:**per-capability approval gates** (ratified 2026-06-11, Gateway grill): the same `require_approval` enum may ride **individual capability entries** in a shell manifest — gating the dangerous *operation*, not just the spawn. Same grant store, same interactive escalation, same floor semantics. A capability may declare a **class key** so grants are scoped finer than the capability itself: the first consumer is the usbip shell's `attach`, granted per **(owner endpoint × device class × node)** — a remem...\n 372:\n*373:**instance cap (`max_instances_per_owner` + `over_cap`, manifest):** an optional ceiling on how many instances of this shell adapter **one owner endpoint** may hold. The count is **all existing instances** — online *and* offline, every non-torn-down perch (`shell teardown` frees a slot) — so offline persistent shells cannot be stockpiled to evade it. Unset ⇒ unlimited. At the cap, **`over_cap`** decides: `reject` (**default**) refuses the spawn outright; `approve` requires per-spawn approval for ea...\n 374:","truncated":true,"truncatedBy":"bytes","totalLines":543,"totalBytes":95740,"outputLines":271,"outputBytes":51038,"lastLinePartial":false,"firstLineExceedsLimit":false},"linesTruncated":true,"meta":{"limits":{"columnTruncated":{"maxColumn":512}},"truncation":{"direction":"head","truncatedBy":"bytes","totalLines":543,"totalBytes":95740,"outputLines":271,"outputBytes":51038,"shownRange":{"start":1,"end":271},"nextOffset":272}}},"isError":false,"timestamp":1788711270312}}
{"type":"message","id":"cb25feaf","parentId":"8fa2277e","timestamp":"2026-09-06T16:14:30.333Z","message":{"role":"user","content":"Current interruptible wait interrupted: IRC message from parent agent `Main`.\n\nParent IRC message:\n\nTodlando just confirmed use [adapter].docs_dir. Both generator signatures/list const confirmed too. Proceed tests as assigned.","attribution":"agent","timestamp":1788711267074,"steering":true}}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_rnJw9vfeH1cEJN4Qwb1qWurw|ctc_07548ebf196ed5a1016a9d916d82b487d189d05bfe12bcfa13","toolName":"edit","startedAt":"2026-09-06T16:15:02.423Z"},"id":"22745e41","parentId":"cb25feaf","timestamp":"2026-09-06T16:15:02.423Z"}
{"type":"message","id":"b9122963","parentId":"22745e41","timestamp":"2026-09-06T16:15:02.423Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"**Implementing three tests**"},{"type":"toolCall","id":"call_rnJw9vfeH1cEJN4Qwb1qWurw|ctc_07548ebf196ed5a1016a9d916d82b487d189d05bfe12bcfa13","name":"edit","arguments":{"input":"*** Begin Patch\n[C:/Users/decid/Documents/projects/spt-core/.worktrees/ws272-w3-drift/crates/spt-runtime/src/registry.rs#5C21]\nPUT >1431:\n+\n+    // [unit->REQ-WEB-ADAPTER-DOCS-FACET] Parent traversal must be refused at\n+    // register even when the directory outside the adapter exists.\n+    #[test]\n+    fn docs_dir_parent_traversal_is_refused_at_register() {\n+        let tmp = tempfile::tempdir().unwrap();\n+        let adapters = tmp.path().join(\"adapters\");\n+        std::fs::create_dir_all(tmp.path().join(\"x\")).unwrap();\n+        let src = seed_source(\n+            tmp.path(),\n+            \"docs-src\",\n+            &format!(\"{HARNESS_COPY}docs_dir = \\\"../x\\\"\\n\"),\n+        );\n+\n+        let err = register(&adapters, &src, 1000)\n+            .expect_err(\"docs_dir must stay inside the adapter directory\")\n+            .to_string();\n+        assert!(err.contains(\"docs_dir\"), \"the refusal identifies the manifest key: {err}\");\n+        assert!(!err.contains('\\n') && !err.contains('\\r'), \"the refusal is one line: {err}\");\n+        assert!(!adapters.join(\"mock-harness\").exists(), \"traversal records nothing\");\n+        assert!(registered(&adapters).is_empty(), \"a refused adapter is not registered\");\n+    }\n+\n+    // [unit->REQ-WEB-ADAPTER-DOCS-FACET] An existing absolute directory outside\n+    // the adapter is not a valid docs_dir on either Windows or Unix.\n+    #[test]\n+    fn docs_dir_absolute_path_is_refused_at_register() {\n+        let tmp = tempfile::tempdir().unwrap();\n+        let adapters = tmp.path().join(\"adapters\");\n+        let outside = tmp.path().join(\"external-docs\");\n+        std::fs::create_dir_all(&outside).unwrap();\n+        let absolute = std::fs::canonicalize(&outside).unwrap();\n+        let docs_dir = toml::Value::String(absolute.to_str().unwrap().to_owned());\n+        let src = seed_source(\n+            tmp.path(),\n+            \"docs-src\",\n+            &format!(\"{HARNESS_COPY}docs_dir = {docs_dir}\\n\"),\n+        );\n+\n+        let err = register(&adapters, &src, 1000)\n+            .expect_err(\"an absolute docs_dir must refuse registration\")\n+            .to_string();\n+        assert!(err.contains(\"docs_dir\"), \"the refusal identifies the manifest key: {err}\");\n+        assert!(!err.contains('\\n') && !err.contains('\\r'), \"the refusal is one line: {err}\");\n+        assert!(!adapters.join(\"mock-harness\").exists(), \"absolute docs_dir records nothing\");\n+        assert!(registered(&adapters).is_empty(), \"a refused adapter is not registered\");\n+    }\n+\n+    // [unit->REQ-WEB-ADAPTER-DOCS-FACET] Existing adapters need not ship docs or\n+    // declare docs_dir to register and remain discoverable by their identity.\n+    #[test]\n+    fn missing_docs_dir_registers_adapter() {\n+        let tmp = tempfile::tempdir().unwrap();\n+        let adapters = tmp.path().join(\"adapters\");\n+        let src = seed_source(tmp.path(), \"docs-src\", HARNESS_COPY);\n+\n+        let (record, manifest) = register(&adapters, &src, 1000)\n+            .expect(\"an adapter without docs_dir must still register\");\n+        assert_eq!(record.name, \"mock-harness\");\n+        assert_eq!(manifest.adapter.name, \"mock-harness\");\n+        let names: Vec<_> = registered(&adapters).into_iter().map(|(record, _)| record.name).collect();\n+        assert_eq!(names, vec![\"mock-harness\"]);\n+    }\n*** End Patch\n"},"customWireName":"edit"}],"api":"openai-codex-responses","provider":"openai-codex","model":"gpt-6-astra","usage":{"input":16268,"output":1009,"cacheRead":34176,"cacheWrite":0,"totalTokens":51453,"reasoningTokens":138,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1788711270331,"responseId":"resp_07548ebf196ed5a1016a9d916699a487d1a308ede34fdd03b4","providerPayload":{"type":"openaiResponsesHistory","provider":"openai-codex","dt":true,"items":[{"id":"rs_07548ebf196ed5a1016a9d91680fe087d1ac3163673e6e29d0","type":"reasoning","content":[],"encrypted_content":"gAAAAABqnZFt1zlI0xsuNVxs9DB81h03990NivphGssi8OFHnEacpktIUK1J5yOWWeFsMpLTHfoDoIVkdmTFihRMIbI5O7Ou4LCnU20DrydYTFWGkkY1N-WheNlXSX1SiLQ60AJ7YaDQSBytWw1-g9xwW0FP49MuF7F6utTPrUC5jGIK5c4AExznKBNmfp6MBI2UF9_mrPqGt5JkIHasYLdmRVLE7_qh5XBSUr5mYW615JCapE_dAyS815Um0cBrZh5tfjzh1r8fS5zAHdP1KnzEmp-M7mUNxIidVQLMnY7DOj2v3h37_fPsAYirq_Qn27tudbj0U4LPktgP_Z_EAtENkrz1UgHNJPIRx8ivh7VcBXpfY1ct7CNbGWO2eQKCjxSIvymWzNY9ukNFFP92Gy0q_nRZlvOrdNvY5x9fXIovCOxnOW2Dgwq1HC7oCNga5UqZOaGU_9XfGWDH-rUuOeedGimaMm9L3nLOlAU3VQRol-JR3r1rECYrxxuPs3dlDTl6HbwouECDWFkH4a08B5Mp8vxfPLIS6pLn_lZi-hkJ9-J6yZuLqQu6H8ip0pBBXHEmF3HuLlwKoWtQ-5yK3ZGVy_RGUas1W6Vb8V1REN7OL_rqZXvKTalVBJQa0Wrbz7d89sg9IRvZfTKBCcMeXU_CjBWqU-srnXxluesa_-BP9g8H_dgvIBAsIsHrf4k9L6twHDOeydfFZQDnYZO82KYyV4VGnxamUEm5nZrAuCvepywUto8hIvkUB6oLvSih2KXZKCoiXCl8bKlYHyNdT8hGRxdjzmkTdP1YMHzvmKwM224JUB9NYBWwsWMmo0U7Ah74LSDWkBRUGg1QTTolrJ-X8JrxHwVKl8zeEqEfU2RiqtZgRiHDCKSru3CUjx5y5iZZ4xfSzS8Cdjlr4p0_rzbQ3Upsw7g_f25qjVTrOeQcC9WLCZD8vFMXkT_VKg_ZxDiES95pNdsQmRhQCS6ZwkAI-tgm1-hIBTqLSbmgkc4gHU-KV7n661BkOdcztjrH4uMMLRmJIn0tvaLb-a8QU5FkAnWW7AWdEmvWYNkRbpTSJU_IcbVy8AOjvBZl24p77YOpIN0EiTEPk5mw7Y2GkQVdWqycOubGpV5O0ioTG52HOGJDgYteAMJrevbAuILwcl5-mdMZ48ttlw4slyN88W_rCjDVJvj3ZB1CLIXMr3cczf2nns50HbBfDbfZO12wVDwGzqZs1U9jiVpzluiNVQiuC_LfLZikRJbZla0SMfIQDp2dgfQ1hU0vlI23oDZ7-5718MBoO6r75XRLpBBrhcREOZk8eXXCazeFEdSN-H_eJrkH6_v0XjsnGCABQhgV9hqBWFIi9sjD0-pxbTikhSdKwhPQj4YO4VjCzMhtROxcoCk3Inwd_GBPWlhBsMR5ldNx3BzkUew_6gw68qH-cd0f3I7w4o2zxjYr7h23wEYlbMYd3LX2BbaEKzG87LvNNLhcPW1Gn7YqCqIzujcFq8aooz7x7DhvhXyNr75YfFkqzcnxle1JLNu_f_HnLRBC7k7LlDFF9VaVHFErl_z8Qc2_w8bXFEiMBZe58bQblPdmjP4Nm_I8YBYfVotwFl-f3WXPvjoJsF1_VWimH8Ipw-Y8TeUSaS0bB9vuz3YwQ9RSFnTP9PoyTyLOPV6lQGJVTmoqle4qyp4jIfpoJT1CCJXhwdhaGvZP74gSV3ODs37-bE7q0rL7kVDo0KRLRz4eDdJi3h_SxvLgZ81NztaW262bsfkKoVJWWKp-95K3DkKtJIEYS2SZ88Xw7CjjmoNj_tDNcZh8n1UEMI9zzRqhZDZ69wYEFtFSRkYoDYubNtM22igPoGhAtntJD9HrcCN98LZahGYMVnKUdgy7yA89-inc76LqLaLXhUxIavIA0tHOibkEQzFilhfuVt25yN7FpAp6UQs_qAxElHePx3NaLcpQOODc3vxVJdSHXR9ZcgrTLJykXGh4NLezl9-D_PXOZUrvMi1RmRxnEv9XGd-6RfqnXido-xdUC30rBmsifFSBq7p6H-GJYD7oPuUe48_ZOaLesl9qO3Bw5oW9-Cy5HvMgCJmbmBrkBWjNeAgNsJsE1bojPYgRg71Qx_F8F5RjLlta7tMoyMdZQAVa9Ry5J7ZPK9QaBEGCFH1TEab3gXbXilvVp_fo6VLBYPGHX207OTFFithkjyqOC07LzZo3Y4ZSI4QIJSLYhGvryWl-vJhCtPBcbTc9NUM=","internal_chat_message_metadata_passthrough":{"turn_id":"56215136-a43e-4e39-9192-c9f6670c38d0"},"summary":[{"type":"summary_text","text":"**Implementing three tests**"}],"metadata":{"turn_id":"56215136-a43e-4e39-9192-c9f6670c38d0"}},{"id":"ctc_07548ebf196ed5a1016a9d916d82b487d189d05bfe12bcfa13","type":"custom_tool_call","status":"completed","call_id":"call_rnJw9vfeH1cEJN4Qwb1qWurw","input":"*** Begin Patch\n[C:/Users/decid/Documents/projects/spt-core/.worktrees/ws272-w3-drift/crates/spt-runtime/src/registry.rs#5C21]\nPUT >1431:\n+\n+    // [unit->REQ-WEB-ADAPTER-DOCS-FACET] Parent traversal must be refused at\n+    // register even when the directory outside the adapter exists.\n+    #[test]\n+    fn docs_dir_parent_traversal_is_refused_at_register() {\n+        let tmp = tempfile::tempdir().unwrap();\n+        let adapters = tmp.path().join(\"adapters\");\n+        std::fs::create_dir_all(tmp.path().join(\"x\")).unwrap();\n+        let src = seed_source(\n+            tmp.path(),\n+            \"docs-src\",\n+            &format!(\"{HARNESS_COPY}docs_dir = \\\"../x\\\"\\n\"),\n+        );\n+\n+        let err = register(&adapters, &src, 1000)\n+            .expect_err(\"docs_dir must stay inside the adapter directory\")\n+            .to_string();\n+        assert!(err.contains(\"docs_dir\"), \"the refusal identifies the manifest key: {err}\");\n+        assert!(!err.contains('\\n') && !err.contains('\\r'), \"the refusal is one line: {err}\");\n+        assert!(!adapters.join(\"mock-harness\").exists(), \"traversal records nothing\");\n+        assert!(registered(&adapters).is_empty(), \"a refused adapter is not registered\");\n+    }\n+\n+    // [unit->REQ-WEB-ADAPTER-DOCS-FACET] An existing absolute directory outside\n+    // the adapter is not a valid docs_dir on either Windows or Unix.\n+    #[test]\n+    fn docs_dir_absolute_path_is_refused_at_register() {\n+        let tmp = tempfile::tempdir().unwrap();\n+        let adapters = tmp.path().join(\"adapters\");\n+        let outside = tmp.path().join(\"external-docs\");\n+        std::fs::create_dir_all(&outside).unwrap();\n+        let absolute = std::fs::canonicalize(&outside).unwrap();\n+        let docs_dir = toml::Value::String(absolute.to_str().unwrap().to_owned());\n+        let src = seed_source(\n+            tmp.path(),\n+            \"docs-src\",\n+            &format!(\"{HARNESS_COPY}docs_dir = {docs_dir}\\n\"),\n+        );\n+\n+        let err = register(&adapters, &src, 1000)\n+            .expect_err(\"an absolute docs_dir must refuse registration\")\n+            .to_string();\n+        assert!(err.contains(\"docs_dir\"), \"the refusal identifies the manifest key: {err}\");\n+        assert!(!err.contains('\\n') && !err.contains('\\r'), \"the refusal is one line: {err}\");\n+        assert!(!adapters.join(\"mock-harness\").exists(), \"absolute docs_dir records nothing\");\n+        assert!(registered(&adapters).is_empty(), \"a refused adapter is not registered\");\n+    }\n+\n+    // [unit->REQ-WEB-ADAPTER-DOCS-FACET] Existing adapters need not ship docs or\n+    // declare docs_dir to register and remain discoverable by their identity.\n+    #[test]\n+    fn missing_docs_dir_registers_adapter() {\n+        let tmp = tempfile::tempdir().unwrap();\n+        let adapters = tmp.path().join(\"adapters\");\n+        let src = seed_source(tmp.path(), \"docs-src\", HARNESS_COPY);\n+\n+        let (record, manifest) = register(&adapters, &src, 1000)\n+            .expect(\"an adapter without docs_dir must still register\");\n+        assert_eq!(record.name, \"mock-harness\");\n+        assert_eq!(manifest.adapter.name, \"mock-harness\");\n+        let names: Vec<_> = registered(&adapters).into_iter().map(|(record, _)| record.name).collect();\n+        assert_eq!(names, vec![\"mock-harness\"]);\n+    }\n*** End Patch\n","internal_chat_message_metadata_passthrough":{"create_time":1788711271.022427,"turn_id":"56215136-a43e-4e39-9192-c9f6670c38d0"},"name":"edit","metadata":{"turn_id":"56215136-a43e-4e39-9192-c9f6670c38d0"}}]},"duration":32090.093100000173,"ttft":1751.4339000005275,"completedAt":1788711302422,"contextSnapshot":{"promptTokens":50444,"nonMessageTokens":16645,"compactionEpoch":0}}}
{"type":"message","id":"a6697ea3","parentId":"b9122963","timestamp":"2026-09-06T16:15:02.461Z","message":{"role":"toolResult","toolCallId":"call_rnJw9vfeH1cEJN4Qwb1qWurw|ctc_07548ebf196ed5a1016a9d916d82b487d189d05bfe12bcfa13","toolName":"edit","content":[{"type":"text","text":"[.worktrees/ws272-w3-drift/crates/spt-runtime/src/registry.rs#8A99]\n1397:    fn string_file_pointer_escaping_the_dir_is_refused_at_register() {\n\n1431:    }\n1432:\n1433:    // [unit->REQ-WEB-ADAPTER-DOCS-FACET] Parent traversal must be refused at\n1434:    // register even when the directory outside the adapter exists.\n…\n1494:    }\n1495:\n1496:    // [unit->REQ-MANIFEST-5] a missing-at-READ file (deleted after a valid\n1497:    // register) skip-diagnostics: get-string returns Ok(None), never a panic or"}],"details":{"diff":" 1397|    fn string_file_pointer_escaping_the_dir_is_refused_at_register() {\n\n 1431|    }\n 1432|\n+1433|    // [unit->REQ-WEB-ADAPTER-DOCS-FACET] Parent traversal must be refused at\n+1434|    // register even when the directory outside the adapter exists.\n+1435|    #[test]\n+1436|    fn docs_dir_parent_traversal_is_refused_at_register() {\n+1437|        let tmp = tempfile::tempdir().unwrap();\n+1438|        let adapters = tmp.path().join(\"adapters\");\n+1439|        std::fs::create_dir_all(tmp.path().join(\"x\")).unwrap();\n+1440|        let src = seed_source(\n+1441|            tmp.path(),\n+1442|            \"docs-src\",\n+1443|            &format!(\"{HARNESS_COPY}docs_dir = \\\"../x\\\"\\n\"),\n+1444|        );\n+1445|\n+1446|        let err = register(&adapters, &src, 1000)\n+1447|            .expect_err(\"docs_dir must stay inside the adapter directory\")\n+1448|            .to_string();\n+1449|        assert!(err.contains(\"docs_dir\"), \"the refusal identifies the manifest key: {err}\");\n+1450|        assert!(!err.contains('\\n') && !err.contains('\\r'), \"the refusal is one line: {err}\");\n+1451|        assert!(!adapters.join(\"mock-harness\").exists(), \"traversal records nothing\");\n+1452|        assert!(registered(&adapters).is_empty(), \"a refused adapter is not registered\");\n+1453|    }\n+1454|\n+1455|    // [unit->REQ-WEB-ADAPTER-DOCS-FACET] An existing absolute directory outside\n+1456|    // the adapter is not a valid docs_dir on either Windows or Unix.\n+1457|    #[test]\n+1458|    fn docs_dir_absolute_path_is_refused_at_register() {\n+1459|        let tmp = tempfile::tempdir().unwrap();\n+1460|        let adapters = tmp.path().join(\"adapters\");\n+1461|        let outside = tmp.path().join(\"external-docs\");\n+1462|        std::fs::create_dir_all(&outside).unwrap();\n+1463|        let absolute = std::fs::canonicalize(&outside).unwrap();\n+1464|        let docs_dir = toml::Value::String(absolute.to_str().unwrap().to_owned());\n+1465|        let src = seed_source(\n+1466|            tmp.path(),\n+1467|            \"docs-src\",\n+1468|            &format!(\"{HARNESS_COPY}docs_dir = {docs_dir}\\n\"),\n+1469|        );\n+1470|\n+1471|        let err = register(&adapters, &src, 1000)\n+1472|            .expect_err(\"an absolute docs_dir must refuse registration\")\n+1473|            .to_string();\n+1474|        assert!(err.contains(\"docs_dir\"), \"the refusal identifies the manifest key: {err}\");\n+1475|        assert!(!err.contains('\\n') && !err.contains('\\r'), \"the refusal is one line: {err}\");\n+1476|        assert!(!adapters.join(\"mock-harness\").exists(), \"absolute docs_dir records nothing\");\n+1477|        assert!(registered(&adapters).is_empty(), \"a refused adapter is not registered\");\n+1478|    }\n+1479|\n+1480|    // [unit->REQ-WEB-ADAPTER-DOCS-FACET] Existing adapters need not ship docs or\n+1481|    // declare docs_dir to register and remain discoverable by their identity.\n+1482|    #[test]\n+1483|    fn missing_docs_dir_registers_adapter() {\n+1484|        let tmp = tempfile::tempdir().unwrap();\n+1485|        let adapters = tmp.path().join(\"adapters\");\n+1486|        let src = seed_source(tmp.path(), \"docs-src\", HARNESS_COPY);\n+1487|\n+1488|        let (record, manifest) = register(&adapters, &src, 1000)\n+1489|            .expect(\"an adapter without docs_dir must still register\");\n+1490|        assert_eq!(record.name, \"mock-harness\");\n+1491|        assert_eq!(manifest.adapter.name, \"mock-harness\");\n+1492|        let names: Vec<_> = registered(&adapters).into_iter().map(|(record, _)| record.name).collect();\n+1493|        assert_eq!(names, vec![\"mock-harness\"]);\n+1494|    }\n+1495|\n 1433|    // [unit->REQ-MANIFEST-5] a missing-at-READ file (deleted after a valid\n 1434|    // register) skip-diagnostics: get-string returns Ok(None), never a panic or","firstChangedLine":1431,"op":"update","path":"C:\\Users\\decid\\Documents\\projects\\spt-core\\.worktrees\\ws272-w3-drift\\crates\\spt-runtime\\src\\registry.rs","snapshotsPruned":true},"isError":false,"timestamp":1788711302461}}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_gC2RE2nVxWocCjoTw5IrhIVd|fc_07548ebf196ed5a1016a9d918d14fc87d1896992b652465c2a","toolName":"yield","startedAt":"2026-09-06T16:15:13.737Z"},"id":"840ec59f","parentId":"a6697ea3","timestamp":"2026-09-06T16:15:13.737Z"}
{"type":"message","id":"cdebe342","parentId":"840ec59f","timestamp":"2026-09-06T16:15:13.738Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"**Reviewing TOML value display**"},{"type":"toolCall","id":"call_gC2RE2nVxWocCjoTw5IrhIVd|fc_07548ebf196ed5a1016a9d918d14fc87d1896992b652465c2a","name":"yield","arguments":{"data":{"file":"C:/Users/decid/Documents/projects/spt-core/.worktrees/ws272-w3-drift/crates/spt-runtime/src/registry.rs","tests":["registry::tests::docs_dir_parent_traversal_is_refused_at_register","registry::tests::docs_dir_absolute_path_is_refused_at_register","registry::tests::missing_docs_dir_registers_adapter"],"changes":"Added three test-only cells tagged REQ-WEB-ADAPTER-DOCS-FACET. Escape fixtures reference existing external directories, exercise real register, require a one-line docs_dir error, and assert no adapter directory or registered adapter remains. Missing-key control checks returned and discoverable registration identity.","dependencies":"Producer-confirmed [adapter].docs_dir parsing and register-time containment refusal. No future Rust fields/error variants referenced; existing tempfile, toml::Value, HARNESS_COPY, seed_source, register, and registered reused.","verification":"All validation/build/tests/formatters skipped as assigned. Baseline behavioral RED expected, not executed: unknown docs_dir is currently ignored and register accepts escape fixtures.","forks":"Placement fork reported before edits; resolved by Main relaying todlando confirmation."}}}],"api":"openai-codex-responses","provider":"openai-codex","model":"gpt-6-astra","usage":{"input":1310,"output":324,"cacheRead":50304,"cacheWrite":0,"totalTokens":51938,"reasoningTokens":69,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1788711302504,"responseId":"resp_07548ebf196ed5a1016a9d9186eb6887d1bf4015becb95cf41","providerPayload":{"type":"openaiResponsesHistory","provider":"openai-codex","dt":true,"items":[{"id":"rs_07548ebf196ed5a1016a9d9187f61887d1b30f3ceffc789844","type":"reasoning","content":[],"encrypted_content":"gAAAAABqnZGNaUY7BrTi-4YuEdMGv9UwvOLp_b0OQooxGHsyFB-nzsQQykMKZXolrb4PPtF_9mNzO_Rrn3LItLlsDtjCYnVSdodpbmwXvWT8ppaM35Nv2jfVMP-vfxvHdjnnLhSfcQTnBQDR7raP99Y71TtOWfMTywGxV_0Fbzd_fsZIWYZ1vp8QjgS67B4Zyg8cTyOP_TT6AK9THC_2p3q-rpWeNe8AygZl8qaC41ZbmRr1V8c_4EXZeRcru21jQA_kStcLvLrc4eApNHrzoBZsjSnH21MmmywHaLEKyL3KDbVstHfzVg2Pv_MCq4CFcneNiBDFq-9CpHsdvGilTRJdoVARFP626V1bJosa7Nq2GdnP1gjhAb1V69KlSCh8k6G2-lgVbPDSeMz-mKLjOBgo5o5r_sQThc1YBUVPZkkkEMTbB6BGg7sl7VfCmwkxfUgxRgf2awH3ANM7FMzpLlKEiRQueAMZUrX-Qre3zT4QHvnDIAHg4s5P1AOcpUgYPhJXNlGf0YTiinv2oya3E038RgZGQPutNeqwretVI2kCY0JdGy66Bc1yl1z4sZsZ0SKRLbpF4U4EiQwgoAN7TTaBYejoMlZsFevjoi2NTojK0lu-J6v9pVYDlvPvLCM9_r6K98dgO0gm06PNaYWE6ozCOIfkbpgbfn4AbEwd5NHjWscfVYbWrGkAk62qLV4m7SXLR8JQoHn7-itiVAaoi0Bju69QriwoQJ47sYSwFRaOdDoaamnIam41NW2i7S_2F7-THlUqI9AiMwr3oGSzybzVE8L2ZtxIO4Pa1vrfP65dRsPzdTLvhgIYBZzU1oJQ3zX178mGOZEXCQM4zduQgLTiC1O9vt3GMtrXKeWg1gA_78k9ndEZT1XYd6MZZLGvhcAywnWJZqPbHCDtfjA5dfe2nDvgJyv-jkAejXcSTfV1mCtBOxrlXb-w_bD7P6uRwIEU4G0JLg6aDEvLZ9BAqFDnLUn8-6H0KFQ4KkRzQnwnArjZraG13UniDvGVPsw4G-p7AWJTQ1jyc9hcLt42PZ6FONiUEbx9QW47ahZYVWh2viGUqaUjmmLznzx66jIvFTHBleLpdAQu_K4CMwXm5qtbQkRiwWj3NYuU8gNypfzqayZyQrPoMH3BvcfrzBTNs6VfCW_DHUgG-qXDqQQz5WIH9Tm9aFCZUWU6QEjGAy_wF5wrZCF6JLoJ8vEY80jiSNxLYEy6nCJJTvLpygRFWrn-01W8VJz1b00wSDV1HP4m66LUXISDW0bNmHbhw8H-Ualk_LEhHqWFEeurq2Ufe2mkvdhxBtSeVA_vBHQzvyL0Ou_TJSJO8WJ83oO2IVph0Q-nJHaZTuygrwua10COiinCjN6O59diGhreiS-7djVM2lOkIT5-Pnvy5PsZomSiFZ1TFPrbp6lkGWXPmVKct3uoUdE5HFicMJeMAaQucMpaYdXBbqBCZP3LKSzKZ6muYt9XApoyB8392icd0xF2CANyrwAaCOHglxEAy4TkRADrcXOR_DJNuWHh7l_Yjx8jbwreaP-ZitultDUSDZ4sHpSDtTawPu9kz-18SkEfK6bCwgyxHn_hXJlWMomSe3O5voH0ll-Zq5vMVNEL-Lhk2YBm4bV_M5BZ3gw3p94GhY4bIRRy4-4IrII_OgAZtqlCMHMF_zj_d6ChtPSBqs7vwGVzZg-WCDcoh5TGDDfScsE5ZbDySPRtcn-vBPyEUL0GxJDMHezTK-apCRZ9-ebtTStH_AUdLGQIJQ==","internal_chat_message_metadata_passthrough":{"turn_id":"56215136-a43e-4e39-9192-c9f6670c38d0"},"summary":[{"type":"summary_text","text":"**Reviewing TOML value display**"}],"metadata":{"turn_id":"56215136-a43e-4e39-9192-c9f6670c38d0"}},{"id":"fc_07548ebf196ed5a1016a9d918d14fc87d1896992b652465c2a","type":"function_call","status":"completed","arguments":"{\"data\":{\"file\":\"C:/Users/decid/Documents/projects/spt-core/.worktrees/ws272-w3-drift/crates/spt-runtime/src/registry.rs\",\"tests\":[\"registry::tests::docs_dir_parent_traversal_is_refused_at_register\",\"registry::tests::docs_dir_absolute_path_is_refused_at_register\",\"registry::tests::missing_docs_dir_registers_adapter\"],\"changes\":\"Added three test-only cells tagged REQ-WEB-ADAPTER-DOCS-FACET. Escape fixtures reference existing external directories, exercise real register, require a one-line docs_dir error, and assert no adapter directory or registered adapter remains. Missing-key control checks returned and discoverable registration identity.\",\"dependencies\":\"Producer-confirmed [adapter].docs_dir parsing and register-time containment refusal. No future Rust fields/error variants referenced; existing tempfile, toml::Value, HARNESS_COPY, seed_source, register, and registered reused.\",\"verification\":\"All validation/build/tests/formatters skipped as assigned. Baseline behavioral RED expected, not executed: unknown docs_dir is currently ignored and register accepts escape fixtures.\",\"forks\":\"Placement fork reported before edits; resolved by Main relaying todlando confirmation.\"}}","call_id":"call_gC2RE2nVxWocCjoTw5IrhIVd","internal_chat_message_metadata_passthrough":{"create_time":1788711303.153858,"turn_id":"56215136-a43e-4e39-9192-c9f6670c38d0"},"name":"yield","metadata":{"turn_id":"56215136-a43e-4e39-9192-c9f6670c38d0"}}]},"duration":11231.046700000763,"ttft":1524.246000001207,"completedAt":1788711313736,"contextSnapshot":{"promptTokens":51614,"nonMessageTokens":16645,"compactionEpoch":0}}}
{"type":"message","id":"037e23f5","parentId":"cdebe342","timestamp":"2026-09-06T16:15:13.743Z","message":{"role":"toolResult","toolCallId":"call_gC2RE2nVxWocCjoTw5IrhIVd|fc_07548ebf196ed5a1016a9d918d14fc87d1896992b652465c2a","toolName":"yield","content":[{"type":"text","text":"Result submitted."}],"details":{"data":{"file":"C:/Users/decid/Documents/projects/spt-core/.worktrees/ws272-w3-drift/crates/spt-runtime/src/registry.rs","tests":["registry::tests::docs_dir_parent_traversal_is_refused_at_register","registry::tests::docs_dir_absolute_path_is_refused_at_register","registry::tests::missing_docs_dir_registers_adapter"],"changes":"Added three test-only cells tagged REQ-WEB-ADAPTER-DOCS-FACET. Escape fixtures reference existing external directories, exercise real register, require a one-line docs_dir error, and assert no adapter directory or registered adapter remains. Missing-key control checks returned and discoverable registration identity.","dependencies":"Producer-confirmed [adapter].docs_dir parsing and register-time containment refusal. No future Rust fields/error variants referenced; existing tempfile, toml::Value, HARNESS_COPY, seed_source, register, and registered reused.","verification":"All validation/build/tests/formatters skipped as assigned. Baseline behavioral RED expected, not executed: unknown docs_dir is currently ignored and register accepts escape fixtures.","forks":"Placement fork reported before edits; resolved by Main relaying todlando confirmation."},"status":"success"},"isError":false,"timestamp":1788711313739}}
{"type":"custom","customType":"session_exit","data":{"reason":"dispose","kind":"normal","recordedAt":"2026-09-06T16:22:13.830Z"},"id":"15b6fd13","parentId":"037e23f5","timestamp":"2026-09-06T16:22:13.830Z"}
