{"type":"title","v":1,"title":"","updatedAt":"2026-09-22T01:09:48.641Z","pad":"                                                                                                                                                                              "}
{"type":"session","version":3,"id":"01a0c6a9-71e2-7000-96f9-bd6f116ae5d5","timestamp":"2026-09-22T01:09:48.641Z","cwd":"C:\\Users\\decid\\Documents\\projects\\spt-core"}
{"type":"model_change","id":"cfa67169","parentId":null,"timestamp":"2026-09-22T01:09:48.787Z","model":"openai-codex/gpt-6-astra","resolvedModelIsFallback":false}
{"type":"thinking_level_change","id":"486e1797","parentId":"cfa67169","timestamp":"2026-09-22T01:09:48.788Z","thinkingLevel":"medium","configured":null}
{"type":"session_init","id":"d3e69e30","parentId":"486e1797","timestamp":"2026-09-22T01:09:48.790Z","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- Grep: `grep`\n- Glob: `glob`\n- Web Search: `web_search`\n- Submit Result: `yield`\n- Hub: `hub`\n- Write: `write`\n# xd:// Tool Devices\nWrite JSON args as `content` to `xd://<tool>` via `write`. Invalid args return schema in error → fix/retry.\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# 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- Regex search/target location → `grep`, not shell `grep`, `rg`, `awk`.\n- Structure mapping/globbing → `glob`, not `ls **/*.ext` or `fd`.\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§ 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    - **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\nInvestigate the codebase rapidly. Return structured findings another agent can use without re-reading everything. `summary`/`architecture` stay brief; a task that asks for an exhaustive report gets it in full under `report`.\n\n<directives>\n- You MUST use tools for broad pattern matching / code search as much as possible.\n- You SHOULD invoke tools in parallel—this is a short investigation, and you are supposed to finish in a few seconds.\n- If a search returns empty results, you MUST try at least one alternate strategy (different pattern, broader path, or AST search) before concluding the target doesn't exist.\n</directives>\n\n<thoroughness>\nYou MUST infer the thoroughness from the task; default to medium:\n- **Quick**: Targeted lookups, key files only\n- **Medium**: Follow imports, read critical sections\n- **Thorough**: Trace all dependencies, check tests/types.\n</thoroughness>\n\n<procedure>\n1. Locate relevant code using tools.\n2. Read key sections. NEVER read full files unless they're tiny.\n3. Identify types/interfaces/key functions.\n4. Note dependencies between files.\n</procedure>\n\n<critical>\nYou MUST operate as read-only. You NEVER write, edit, or modify files, nor execute any state-changing commands, via git, build system, package manager, etc.\nYou MUST keep going until complete.\n</critical>\n\n§ Context\n# Goal\nPrepare read-only source RCA for HANDED-FILE waves W4 releases#312 and W5 releases#311 while Main runs W3's fresh native regression build. These are two independent, already-scoped later-wave investigations; Main owns implementation and serial PR execution.\n# Constraints\nRead-only. Skip all validation: no Cargo, builds, tests, linters, formatters, generated docs, edits, commits, or worktree/pool operations. Never cargo fmt. Work from the root checkout; cite file/symbol/line evidence. Code repo BigscreenVR/spt-bs-core; issues BigscreenVR/spt-bs-releases. Follow relevant repo orientation/skills; read docs/DEBUG-ROLLOUT.md for update-path investigation. Use LSP for symbol references/definitions when available. Do not investigate historical #302/#307/#309 lanes.\n# Contract\nReturn compressed findings only: exact call chain/root cause, minimal compatible change seam, existing useful test fixtures and observable regression contracts, uncertainty and any authority/security constraints. No speculative scope expansion. No shared-file mutation.\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 `CredentialProbeRca`. Currently visible peers:\n- `CeremonyTokenRca` — scout (sub, running)\n- `Main` — main (main, running)\n39 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- A data-less terminal `type: \"result\"` only finalizes previously submitted incremental sections; it NEVER substitutes for `data`.\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`.\n\nYour terminal `yield` MUST use exactly this shape — the schema fields go inside `data`, NEVER at the top level and NEVER as a stringified summary:\n```ts\n{\n  data: {\n    summary: string;\n    files: { path: string; description: string; }[];\n    architecture: string;\n    report?: string;\n  };\n}\n```\n\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\nreleases#312; crates/spt/src/cli.rs::gh_status (~10062), render_gh_auth_required (~10107), related update-fetch gate callers and spt_runtime::run_bounded_command. Do not change files.\n# Change\nInvestigate why gh auth status exit1 from a stale stored account vetoes an effective valid GH_TOKEN. User's measured valid release-list success is ground truth; do not rerun auth or inspect secrets. Determine effective environment inheritance in run_bounded_command, existing positive-probe patterns, refusal payload flow and how to name a stale account safely. Read issue312 and DEBUG-ROLLOUT. Identify a minimal positive authentication/read probe before fetch that preserves real auth failures and existing deadlines. Locate tests; flag wording-only tests that should be removed rather than repinned.\n# Acceptance\nEvidence-backed source RCA and concrete implementation/test seams sufficient for Main to implement later, with no native commands or edits.","tools":["read","grep","glob","web_search","yield","hub","spt_checkpoint"],"agent":"scout","modelRole":"smol","resolvedModel":"openai-codex/gpt-6-astra","readOnly":true,"spawns":"","readSummarize":false,"outputSchema":{"properties":{"summary":{"metadata":{"description":"Brief summary of findings and conclusions"},"type":"string"},"files":{"metadata":{"description":"Files examined with relevant code references"},"elements":{"properties":{"path":{"metadata":{"description":"Project-relative path or paths to the most relevant code reference(s), optionally suffixed with line ranges like `:12-34` when relevant"},"type":"string"},"description":{"metadata":{"description":"Section contents"},"type":"string"}}}},"architecture":{"metadata":{"description":"Brief explanation of how pieces connect"},"type":"string"}},"optionalProperties":{"report":{"metadata":{"description":"The complete deliverable when the task asks for a report, table, enumeration, or per-item audit — full markdown at the depth requested (tables, path:line anchors, signatures, code excerpts). Never a summary of it; `summary` already covers that. Omit only for quick lookups."},"type":"string"}}},"outputSchemaMode":"permissive"}
{"type":"message","id":"a98be0c6","parentId":"d3e69e30","timestamp":"2026-09-22T01:09:48.960Z","message":{"role":"user","content":[{"type":"text","text":"Complete assignment thoroughly:\n\n# Target\nreleases#312; crates/spt/src/cli.rs::gh_status (~10062), render_gh_auth_required (~10107), related update-fetch gate callers and spt_runtime::run_bounded_command. Do not change files.\n# Change\nInvestigate why gh auth status exit1 from a stale stored account vetoes an effective valid GH_TOKEN. User's measured valid release-list success is ground truth; do not rerun auth or inspect secrets. Determine effective environment inheritance in run_bounded_command, existing positive-probe patterns, refusal payload flow and how to name a stale account safely. Read issue312 and DEBUG-ROLLOUT. Identify a minimal positive authentication/read probe before fetch that preserves real auth failures and existing deadlines. Locate tests; flag wording-only tests that should be removed rather than repinned.\n# Acceptance\nEvidence-backed source RCA and concrete implementation/test seams sufficient for Main to implement later, with no native commands or edits."}],"attribution":"agent","timestamp":1790039388792}}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_xpIk3fHiXMSks7LO7t65NTbr|fc_06340275dc54e8ed016ab1d562f50487d196ced1539d891298","toolName":"read","startedAt":"2026-09-22T01:09:55.122Z","args":{"path":"skill://diagnose"},"intent":"Reading diagnosis instructions"},"id":"ef4a997a","parentId":"a98be0c6","timestamp":"2026-09-22T01:09:55.122Z"}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_HTw5Vu6JHDEjWrfqPcXMgvKM|fc_06340275dc54e8ed016ab1d562f51887d1a3f14a9f49684ddf","toolName":"read","startedAt":"2026-09-22T01:09:55.123Z","args":{"path":"issue://BigscreenVR/spt-bs-releases/312"},"intent":"Reading issue requirements"},"id":"fb8d487e","parentId":"ef4a997a","timestamp":"2026-09-22T01:09:55.123Z"}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_DEM7fsEWBMHULmATHpZYosRZ|fc_06340275dc54e8ed016ab1d562f52487d1a910faf84ab1b28e","toolName":"read","startedAt":"2026-09-22T01:09:55.123Z","args":{"path":"AGENTS.md"},"intent":"Reading repository orientation"},"id":"af508fed","parentId":"fb8d487e","timestamp":"2026-09-22T01:09:55.123Z"}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_DACxEjwaVfhEz6FYONDwcfEh|fc_06340275dc54e8ed016ab1d562f53087d19c4ccc489db1e97b","toolName":"read","startedAt":"2026-09-22T01:09:55.123Z","args":{"path":"docs/DEBUG-ROLLOUT.md"},"intent":"Reading update rollout guidance"},"id":"c2f922e3","parentId":"af508fed","timestamp":"2026-09-22T01:09:55.123Z"}
{"type":"message","id":"b5d94669","parentId":"c2f922e3","timestamp":"2026-09-22T01:09:55.123Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"call_xpIk3fHiXMSks7LO7t65NTbr|fc_06340275dc54e8ed016ab1d562f50487d196ced1539d891298","name":"read","arguments":{"i":"Reading diagnosis instructions","path":"skill://diagnose"},"intent":"Reading diagnosis instructions"},{"type":"toolCall","id":"call_HTw5Vu6JHDEjWrfqPcXMgvKM|fc_06340275dc54e8ed016ab1d562f51887d1a3f14a9f49684ddf","name":"read","arguments":{"i":"Reading issue requirements","path":"issue://BigscreenVR/spt-bs-releases/312"},"intent":"Reading issue requirements"},{"type":"toolCall","id":"call_DEM7fsEWBMHULmATHpZYosRZ|fc_06340275dc54e8ed016ab1d562f52487d1a910faf84ab1b28e","name":"read","arguments":{"i":"Reading repository orientation","path":"AGENTS.md"},"intent":"Reading repository orientation"},{"type":"toolCall","id":"call_DACxEjwaVfhEz6FYONDwcfEh|fc_06340275dc54e8ed016ab1d562f53087d19c4ccc489db1e97b","name":"read","arguments":{"i":"Reading update rollout guidance","path":"docs/DEBUG-ROLLOUT.md"},"intent":"Reading update rollout guidance"}],"api":"openai-codex-responses","provider":"openai-codex","model":"gpt-6-astra","usage":{"input":10210,"output":120,"cacheRead":0,"cacheWrite":0,"totalTokens":10330,"cost":{"input":0.10210000000000001,"output":0.006,"cacheRead":0,"cacheWrite":0,"total":0.10810000000000002}},"stopReason":"toolUse","timestamp":1790039388958,"responseId":"resp_06340275dc54e8ed016ab1d55d73dc87d1a52f03f3e4cb89b5","providerPayload":{"type":"openaiResponsesHistory","provider":"openai-codex","dt":true,"items":[{"id":"fc_06340275dc54e8ed016ab1d562f50487d196ced1539d891298","type":"function_call","status":"completed","arguments":"{\"i\":\"Reading diagnosis instructions\",\"path\":\"skill://diagnose\"}","call_id":"call_xpIk3fHiXMSks7LO7t65NTbr","internal_chat_message_metadata_passthrough":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"},"name":"read","metadata":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"}},{"id":"fc_06340275dc54e8ed016ab1d562f51887d1a3f14a9f49684ddf","type":"function_call","status":"completed","arguments":"{\"i\":\"Reading issue requirements\",\"path\":\"issue://BigscreenVR/spt-bs-releases/312\"}","call_id":"call_HTw5Vu6JHDEjWrfqPcXMgvKM","internal_chat_message_metadata_passthrough":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"},"name":"read","metadata":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"}},{"id":"fc_06340275dc54e8ed016ab1d562f52487d1a910faf84ab1b28e","type":"function_call","status":"completed","arguments":"{\"i\":\"Reading repository orientation\",\"path\":\"AGENTS.md\"}","call_id":"call_DEM7fsEWBMHULmATHpZYosRZ","internal_chat_message_metadata_passthrough":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"},"name":"read","metadata":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"}},{"id":"fc_06340275dc54e8ed016ab1d562f53087d19c4ccc489db1e97b","type":"function_call","status":"completed","arguments":"{\"i\":\"Reading update rollout guidance\",\"path\":\"docs/DEBUG-ROLLOUT.md\"}","call_id":"call_DACxEjwaVfhEz6FYONDwcfEh","internal_chat_message_metadata_passthrough":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"},"name":"read","metadata":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"}}]},"duration":6063.740899998695,"ttft":6020.327399998903,"completedAt":1790039395023,"contextSnapshot":{"promptTokens":10210,"nonMessageTokens":10333,"compactionEpoch":0}}}
{"type":"credential_pin","id":"017f5c38","parentId":"b5d94669","timestamp":"2026-09-22T01:09:55.124Z","provider":"openai-codex","hash":"1a3c10d7bc41d381cab1511db604bc2637dfcfc63796a890d8b5ee152859e1cb"}
{"type":"message","id":"cb4bd105","parentId":"017f5c38","timestamp":"2026-09-22T01:09:55.141Z","message":{"role":"toolResult","toolCallId":"call_xpIk3fHiXMSks7LO7t65NTbr|fc_06340275dc54e8ed016ab1d562f50487d196ced1539d891298","toolName":"read","content":[{"type":"text","text":"---\nname: diagnose\ndescription: 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---\n\n# Diagnose\n\nA discipline for hard bugs. Skip phases only when explicitly justified.\n\nWhen exploring the codebase, use the project's domain glossary to get a clear mental model of the relevant modules, and check ADRs in the area you're touching.\n\n## Phase 1 — Build a feedback loop\n\n**This is the skill.** Everything else is mechanical. If you have a fast, deterministic, agent-runnable pass/fail signal for the bug, you will find the cause — bisection, hypothesis-testing, and instrumentation all just consume that signal. If you don't have one, no amount of staring at code will save you.\n\nSpend disproportionate effort here. **Be aggressive. Be creative. Refuse to give up.**\n\n### Ways to construct one — try them in roughly this order\n\n1. **Failing test** at whatever seam reaches the bug — unit, integration, e2e.\n2. **Curl / HTTP script** against a running dev server.\n3. **CLI invocation** with a fixture input, diffing stdout against a known-good snapshot.\n4. **Headless browser script** (Playwright / Puppeteer) — drives the UI, asserts on DOM/console/network.\n5. **Replay a captured trace.** Save a real network request / payload / event log to disk; replay it through the code path in isolation.\n6. **Throwaway harness.** Spin up a minimal subset of the system (one service, mocked deps) that exercises the bug code path with a single function call.\n7. **Property / fuzz loop.** If the bug is \"sometimes wrong output\", run 1000 random inputs and look for the failure mode.\n8. **Bisection harness.** If the bug appeared between two known states (commit, dataset, version), automate \"boot at state X, check, repeat\" so you can `git bisect run` it.\n9. **Differential loop.** Run the same input through old-version vs new-version (or two configs) and diff outputs.\n10. **HITL bash script.** Last resort. If a human must click, drive _them_ with `scripts/hitl-loop.template.sh` so the loop is still structured. Captured output feeds back to you.\n\nBuild the right feedback loop, and the bug is 90% fixed.\n\n### Iterate on the loop itself\n\nTreat the loop as a product. Once you have _a_ loop, ask:\n\n- Can I make it faster? (Cache setup, skip unrelated init, narrow the test scope.)\n- Can I make the signal sharper? (Assert on the specific symptom, not \"didn't crash\".)\n- Can I make it more deterministic? (Pin time, seed RNG, isolate filesystem, freeze network.)\n\nA 30-second flaky loop is barely better than no loop. A 2-second deterministic loop is a debugging superpower.\n\n### Non-deterministic bugs\n\nThe goal is not a clean repro but a **higher reproduction rate**. Loop the trigger 100×, parallelise, add stress, narrow timing windows, inject sleeps. A 50%-flake bug is debuggable; 1% is not — keep raising the rate until it's debuggable.\n\n### When you genuinely cannot build a loop\n\nStop and say so explicitly. List what you tried. Ask the user for: (a) access to whatever environment reproduces it, (b) a captured artifact (HAR file, log dump, core dump, screen recording with timestamps), or (c) permission to add temporary production instrumentation. Do **not** proceed to hypothesise without a loop.\n\nDo not proceed to Phase 2 until you have a loop you believe in.\n\n## Phase 2 — Reproduce\n\nRun the loop. Watch the bug appear.\n\nConfirm:\n\n- [ ] The loop produces the failure mode the **user** described — not a different failure that happens to be nearby. Wrong bug = wrong fix.\n- [ ] The failure is reproducible across multiple runs (or, for non-deterministic bugs, reproducible at a high enough rate to debug against).\n- [ ] You have captured the exact symptom (error message, wrong output, slow timing) so later phases can verify the fix actually addresses it.\n\nDo not proceed until you reproduce the bug.\n\n## Phase 3 — Hypothesise\n\nGenerate **3–5 ranked hypotheses** before testing any of them. Single-hypothesis generation anchors on the first plausible idea.\n\nEach hypothesis must be **falsifiable**: state the prediction it makes.\n\n> Format: \"If <X> is the cause, then <changing Y> will make the bug disappear / <changing Z> will make it worse.\"\n\nIf you cannot state the prediction, the hypothesis is a vibe — discard or sharpen it.\n\n**Show the ranked list to the user before testing.** They often have domain knowledge that re-ranks instantly (\"we just deployed a change to #3\"), or know hypotheses they've already ruled out. Cheap checkpoint, big time saver. Don't block on it — proceed with your ranking if the user is AFK.\n\n## Phase 4 — Instrument\n\nEach probe must map to a specific prediction from Phase 3. **Change one variable at a time.**\n\nTool preference:\n\n1. **Debugger / REPL inspection** if the env supports it. One breakpoint beats ten logs.\n2. **Targeted logs** at the boundaries that distinguish hypotheses.\n3. Never \"log everything and grep\".\n\n**Tag every debug log** with a unique prefix, e.g. `[DEBUG-a4f2]`. Cleanup at the end becomes a single grep. Untagged logs survive; tagged logs die.\n\n**Perf branch.** For performance regressions, logs are usually wrong. Instead: establish a baseline measurement (timing harness, `performance.now()`, profiler, query plan), then bisect. Measure first, fix second.\n\n## Phase 5 — Fix + regression test\n\nWrite the regression test **before the fix** — but only if there is a **correct seam** for it.\n\nA correct seam is one where the test exercises the **real bug pattern** as it occurs at the call site. If the only available seam is too shallow (single-caller test when the bug needs multiple callers, unit test that can't replicate the chain that triggered the bug), a regression test there gives false confidence.\n\n**If no correct seam exists, that itself is the finding.** Note it. The codebase architecture is preventing the bug from being locked down. Flag this for the next phase.\n\nIf a correct seam exists:\n\n1. Turn the minimised repro into a failing test at that seam.\n2. Watch it fail.\n3. Apply the fix.\n4. Watch it pass.\n5. Re-run the Phase 1 feedback loop against the original (un-minimised) scenario.\n\n## Phase 6 — Cleanup + post-mortem\n\nRequired before declaring done:\n\n- [ ] Original repro no longer reproduces (re-run the Phase 1 loop)\n- [ ] Regression test passes (or absence of seam is documented)\n- [ ] All `[DEBUG-...]` instrumentation removed (`grep` the prefix)\n- [ ] Throwaway prototypes deleted (or moved to a clearly-marked debug location)\n- [ ] The hypothesis that turned out correct is stated in the commit / PR message — so the next debugger learns\n\n**Then ask: what would have prevented this bug?** If the answer involves architectural change (no good test seam, tangled callers, hidden coupling) hand off to the `/improve-codebase-architecture` skill with the specifics. Make the recommendation **after** the fix is in, not before — you have more information now than when you started."}],"details":{"resolvedPath":"C:\\Users\\decid\\.agents\\skills\\diagnose\\SKILL.md","contentType":"text/markdown","totalLines":117,"displayContent":{"text":"---\r\nname: diagnose\r\ndescription: 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.\r\n---\r\n\r\n# Diagnose\r\n\r\nA discipline for hard bugs. Skip phases only when explicitly justified.\r\n\r\nWhen exploring the codebase, use the project's domain glossary to get a clear mental model of the relevant modules, and check ADRs in the area you're touching.\r\n\r\n## Phase 1 — Build a feedback loop\r\n\r\n**This is the skill.** Everything else is mechanical. If you have a fast, deterministic, agent-runnable pass/fail signal for the bug, you will find the cause — bisection, hypothesis-testing, and instrumentation all just consume that signal. If you don't have one, no amount of staring at code will save you.\r\n\r\nSpend disproportionate effort here. **Be aggressive. Be creative. Refuse to give up.**\r\n\r\n### Ways to construct one — try them in roughly this order\r\n\r\n1. **Failing test** at whatever seam reaches the bug — unit, integration, e2e.\r\n2. **Curl / HTTP script** against a running dev server.\r\n3. **CLI invocation** with a fixture input, diffing stdout against a known-good snapshot.\r\n4. **Headless browser script** (Playwright / Puppeteer) — drives the UI, asserts on DOM/console/network.\r\n5. **Replay a captured trace.** Save a real network request / payload / event log to disk; replay it through the code path in isolation.\r\n6. **Throwaway harness.** Spin up a minimal subset of the system (one service, mocked deps) that exercises the bug code path with a single function call.\r\n7. **Property / fuzz loop.** If the bug is \"sometimes wrong output\", run 1000 random inputs and look for the failure mode.\r\n8. **Bisection harness.** If the bug appeared between two known states (commit, dataset, version), automate \"boot at state X, check, repeat\" so you can `git bisect run` it.\r\n9. **Differential loop.** Run the same input through old-version vs new-version (or two configs) and diff outputs.\r\n10. **HITL bash script.** Last resort. If a human must click, drive _them_ with `scripts/hitl-loop.template.sh` so the loop is still structured. Captured output feeds back to you.\r\n\r\nBuild the right feedback loop, and the bug is 90% fixed.\r\n\r\n### Iterate on the loop itself\r\n\r\nTreat the loop as a product. Once you have _a_ loop, ask:\r\n\r\n- Can I make it faster? (Cache setup, skip unrelated init, narrow the test scope.)\r\n- Can I make the signal sharper? (Assert on the specific symptom, not \"didn't crash\".)\r\n- Can I make it more deterministic? (Pin time, seed RNG, isolate filesystem, freeze network.)\r\n\r\nA 30-second flaky loop is barely better than no loop. A 2-second deterministic loop is a debugging superpower.\r\n\r\n### Non-deterministic bugs\r\n\r\nThe goal is not a clean repro but a **higher reproduction rate**. Loop the trigger 100×, parallelise, add stress, narrow timing windows, inject sleeps. A 50%-flake bug is debuggable; 1% is not — keep raising the rate until it's debuggable.\r\n\r\n### When you genuinely cannot build a loop\r\n\r\nStop and say so explicitly. List what you tried. Ask the user for: (a) access to whatever environment reproduces it, (b) a captured artifact (HAR file, log dump, core dump, screen recording with timestamps), or (c) permission to add temporary production instrumentation. Do **not** proceed to hypothesise without a loop.\r\n\r\nDo not proceed to Phase 2 until you have a loop you believe in.\r\n\r\n## Phase 2 — Reproduce\r\n\r\nRun the loop. Watch the bug appear.\r\n\r\nConfirm:\r\n\r\n- [ ] The loop produces the failure mode the **user** described — not a different failure that happens to be nearby. Wrong bug = wrong fix.\r\n- [ ] The failure is reproducible across multiple runs (or, for non-deterministic bugs, reproducible at a high enough rate to debug against).\r\n- [ ] You have captured the exact symptom (error message, wrong output, slow timing) so later phases can verify the fix actually addresses it.\r\n\r\nDo not proceed until you reproduce the bug.\r\n\r\n## Phase 3 — Hypothesise\r\n\r\nGenerate **3–5 ranked hypotheses** before testing any of them. Single-hypothesis generation anchors on the first plausible idea.\r\n\r\nEach hypothesis must be **falsifiable**: state the prediction it makes.\r\n\r\n> Format: \"If <X> is the cause, then <changing Y> will make the bug disappear / <changing Z> will make it worse.\"\r\n\r\nIf you cannot state the prediction, the hypothesis is a vibe — discard or sharpen it.\r\n\r\n**Show the ranked list to the user before testing.** They often have domain knowledge that re-ranks instantly (\"we just deployed a change to #3\"), or know hypotheses they've already ruled out. Cheap checkpoint, big time saver. Don't block on it — proceed with your ranking if the user is AFK.\r\n\r\n## Phase 4 — Instrument\r\n\r\nEach probe must map to a specific prediction from Phase 3. **Change one variable at a time.**\r\n\r\nTool preference:\r\n\r\n1. **Debugger / REPL inspection** if the env supports it. One breakpoint beats ten logs.\r\n2. **Targeted logs** at the boundaries that distinguish hypotheses.\r\n3. Never \"log everything and grep\".\r\n\r\n**Tag every debug log** with a unique prefix, e.g. `[DEBUG-a4f2]`. Cleanup at the end becomes a single grep. Untagged logs survive; tagged logs die.\r\n\r\n**Perf branch.** For performance regressions, logs are usually wrong. Instead: establish a baseline measurement (timing harness, `performance.now()`, profiler, query plan), then bisect. Measure first, fix second.\r\n\r\n## Phase 5 — Fix + regression test\r\n\r\nWrite the regression test **before the fix** — but only if there is a **correct seam** for it.\r\n\r\nA correct seam is one where the test exercises the **real bug pattern** as it occurs at the call site. If the only available seam is too shallow (single-caller test when the bug needs multiple callers, unit test that can't replicate the chain that triggered the bug), a regression test there gives false confidence.\r\n\r\n**If no correct seam exists, that itself is the finding.** Note it. The codebase architecture is preventing the bug from being locked down. Flag this for the next phase.\r\n\r\nIf a correct seam exists:\r\n\r\n1. Turn the minimised repro into a failing test at that seam.\r\n2. Watch it fail.\r\n3. Apply the fix.\r\n4. Watch it pass.\r\n5. Re-run the Phase 1 feedback loop against the original (un-minimised) scenario.\r\n\r\n## Phase 6 — Cleanup + post-mortem\r\n\r\nRequired before declaring done:\r\n\r\n- [ ] Original repro no longer reproduces (re-run the Phase 1 loop)\r\n- [ ] Regression test passes (or absence of seam is documented)\r\n- [ ] All `[DEBUG-...]` instrumentation removed (`grep` the prefix)\r\n- [ ] Throwaway prototypes deleted (or moved to a clearly-marked debug location)\r\n- [ ] The hypothesis that turned out correct is stated in the commit / PR message — so the next debugger learns\r\n\r\n**Then ask: what would have prevented this bug?** If the answer involves architectural change (no good test seam, tangled callers, hidden coupling) hand off to the `/improve-codebase-architecture` skill with the specifics. Make the recommendation **after** the fix is in, not before — you have more information now than when you started.\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,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117]},"meta":{"source":{"type":"internal","value":"skill://diagnose"}}},"isError":false,"timestamp":1790039395141}}
{"type":"message","id":"6d973330","parentId":"cb4bd105","timestamp":"2026-09-22T01:09:55.143Z","message":{"role":"toolResult","toolCallId":"call_DACxEjwaVfhEz6FYONDwcfEh|fc_06340275dc54e8ed016ab1d562f53087d19c4ccc489db1e97b","toolName":"read","content":[{"type":"text","text":"# Debug rollout runbook\n\n<!-- [doc->REQ-UPD-6] -->\n\nDebug rollout is the maintainer-only fast path for testing a local spt-core\nbuild across a trusted lab subnet. It uses the normal signed self-update\nsubstrate: no raw peer file-copy, no production `spt` CLI surface, and no\nembedded debug trust anchor.\n\nUse this when debugging needs a local build to reach multiple lab nodes quickly.\nDo not use it for public releases; use `docs/RELEASE-RUNBOOK.md` for that.\n\n## Mental model\n\n- A rollout is a signed `SignedUpdateSet`: one signed metadata record, with one\n  artifact digest per Rust target triple.\n- Each recipient verifies the set under its node-local `release-keys.json`,\n  selects only its own platform artifact, stages it, and then follows the normal\n  consent/apply policy.\n- Debug and stable are separate channels. A debug-pinned node accepts only\n  `channel = \"debug\"` offers; a stable-pinned node accepts only stable offers.\n- Debug versions are monotonic within the debug channel. To recover from a bad\n  debug build, publish a higher debug version, even if the bytes are a previous\n  known-good binary.\n- A broker-touching candidate may be staged, but apply refuses it while\n  broker-held runtime is live. Shut down or suspend hosted endpoints first; the\n  first flow does not auto-cycle them.\n\n## One-time lab setup\n\nGenerate a debug key on the coordinator:\n\n```powershell\ncargo run -p xtask -- debug-keygen dev-debug-2026\n```\n\nSet the printed seed as a long-lived coordinator-local environment variable:\n\n```powershell\n$env:SPT_DEBUG_RELEASE_SEED = \"<seed_hex>\"\n```\n\nPin each lab node to the debug channel with the printed public key:\n\n```powershell\ncargo run -p xtask -- debug-pin --key-id dev-debug-2026 --public-key <public_hex>\n```\n\nThis writes `$SPT_HOME/identity/release-keys.json` with the debug public key and\n`\"channel\": \"debug\"`. Removing the key or setting the channel back to\n`\"stable\"` removes the node from debug rollout eligibility.\n\n## Stage a rollout\n\nFrom the fast coordinator, stage an update set into the local release cache:\n\n```powershell\ncargo run -p xtask -- debug-rollout --build-current --artifact \"x86_64-unknown-linux-gnu=<path-to-linux-spt>\"\n```\n\nCommon flags:\n\n- `--build-current` builds and includes the coordinator's current platform.\n- `--artifact <target=path>` adds an already-built artifact for another target,\n  such as `x86_64-pc-windows-msvc` or `x86_64-unknown-linux-gnu`.\n- `--version <u64>` overrides the local debug sequence. Use a higher value when\n  recovering from lost local state.\n- `--product-version <semver>` stamps the product semver on the signed set\n  (default empty — the apply message then falls back to the release counter).\n  The composite `spt update`'s adapters leg reads this field as its\n  staged-basis floor comparison, so a lab recipe exercising adapter floors\n  against a staged core must stamp it. Ordering stays with the monotonic\n  counter; this field never decides which release is newer.\n- `--channel <name>` overrides the set's channel (default `debug`). A lab rig\n  whose recipient home stays pinned to `stable` pairs this with a rig-key\n  overlay in that home's `release-keys.json`; fleet debug nodes keep the\n  default and the `debug-pin` flow.\n- `--stage-dir <path>` stages somewhere other than `$SPT_HOME/releases`.\n- `--state <path>` stores the local debug sequence somewhere other than\n  `target/debug-rollout-state.json`.\n\nThe staged update then propagates through the normal pull-based update pump.\nThere is intentionally no bespoke push path.\n\n## Apply and observe\n\nOn a default-gated node, apply is still explicit:\n\n```powershell\nspt update apply\n```\n\nNodes configured for full-auto update may apply after staging without a prompt.\nFor quick debugging, run or poke the daemon/update pump on lab nodes so they\nquery peers promptly.\n\n**Convergence watcher (M8-D4, decision 19).** Instead of hand-walking nodes,\nrun the maintainer watcher from the coordinator:\n\n```powershell\ncargo run -p xtask -- debug-converge --version <N> [--subnet <name>] [--timeout 120] [--poll 3]\n```\n\nIt polls every expected node (the coordinator's trust rows; `--nodes <hex,…>`\noverrides) over the status-only update query and prints a per-node table —\n`Applied` / `StagedAwaitingConsent` / `NotPinned` / `Rejected{reason}` /\n`Offline` — exiting `0` only when every node applied the target (`1`\notherwise, `2` on usage). Spec + state model: `docs/DEBUG-CONVERGE-PLAN.md`.\nThe manual walk below remains the fallback.\n\nUseful outcomes:\n\n- `NoArtifactForPlatform` means the signed set did not include the recipient's\n  Rust target triple.\n- `WrongChannel` means the node is not pinned to `debug`.\n- `Rollback` means the offered debug version is not greater than the node's\n  current debug-channel version.\n- `RefusedClass(BrokerBreaking)` means the candidate touches broker-held\n  resources; quiesce hosted endpoints first.\n\n## Agent checklist\n\n1. Read `CONTEXT.md` \"debug rollout\" and ADR-0016 before changing the flow.\n2. Keep debug tooling in `xtask` or other maintainer tooling, not the public\n   `spt` CLI/help surface.\n3. Keep verification on the production update substrate: signed metadata,\n   per-platform artifact digest, channel pinning, monotonic version, and\n   apply-time re-verification.\n4. Add or update traceability evidence for `REQ-UPD-6`.\n5. Run `cargo fmt`, focused update tests, and `traceable-reqs check`."}],"details":{"totalLines":130,"displayContent":{"text":"# Debug rollout runbook\r\n\r\n<!-- [doc->REQ-UPD-6] -->\r\n\r\nDebug rollout is the maintainer-only fast path for testing a local spt-core\r\nbuild across a trusted lab subnet. It uses the normal signed self-update\r\nsubstrate: no raw peer file-copy, no production `spt` CLI surface, and no\r\nembedded debug trust anchor.\r\n\r\nUse this when debugging needs a local build to reach multiple lab nodes quickly.\r\nDo not use it for public releases; use `docs/RELEASE-RUNBOOK.md` for that.\r\n\r\n## Mental model\r\n\r\n- A rollout is a signed `SignedUpdateSet`: one signed metadata record, with one\r\n  artifact digest per Rust target triple.\r\n- Each recipient verifies the set under its node-local `release-keys.json`,\r\n  selects only its own platform artifact, stages it, and then follows the normal\r\n  consent/apply policy.\r\n- Debug and stable are separate channels. A debug-pinned node accepts only\r\n  `channel = \"debug\"` offers; a stable-pinned node accepts only stable offers.\r\n- Debug versions are monotonic within the debug channel. To recover from a bad\r\n  debug build, publish a higher debug version, even if the bytes are a previous\r\n  known-good binary.\r\n- A broker-touching candidate may be staged, but apply refuses it while\r\n  broker-held runtime is live. Shut down or suspend hosted endpoints first; the\r\n  first flow does not auto-cycle them.\r\n\r\n## One-time lab setup\r\n\r\nGenerate a debug key on the coordinator:\r\n\r\n```powershell\r\ncargo run -p xtask -- debug-keygen dev-debug-2026\r\n```\r\n\r\nSet the printed seed as a long-lived coordinator-local environment variable:\r\n\r\n```powershell\r\n$env:SPT_DEBUG_RELEASE_SEED = \"<seed_hex>\"\r\n```\r\n\r\nPin each lab node to the debug channel with the printed public key:\r\n\r\n```powershell\r\ncargo run -p xtask -- debug-pin --key-id dev-debug-2026 --public-key <public_hex>\r\n```\r\n\r\nThis writes `$SPT_HOME/identity/release-keys.json` with the debug public key and\r\n`\"channel\": \"debug\"`. Removing the key or setting the channel back to\r\n`\"stable\"` removes the node from debug rollout eligibility.\r\n\r\n## Stage a rollout\r\n\r\nFrom the fast coordinator, stage an update set into the local release cache:\r\n\r\n```powershell\r\ncargo run -p xtask -- debug-rollout --build-current --artifact \"x86_64-unknown-linux-gnu=<path-to-linux-spt>\"\r\n```\r\n\r\nCommon flags:\r\n\r\n- `--build-current` builds and includes the coordinator's current platform.\r\n- `--artifact <target=path>` adds an already-built artifact for another target,\r\n  such as `x86_64-pc-windows-msvc` or `x86_64-unknown-linux-gnu`.\r\n- `--version <u64>` overrides the local debug sequence. Use a higher value when\r\n  recovering from lost local state.\r\n- `--product-version <semver>` stamps the product semver on the signed set\r\n  (default empty — the apply message then falls back to the release counter).\r\n  The composite `spt update`'s adapters leg reads this field as its\r\n  staged-basis floor comparison, so a lab recipe exercising adapter floors\r\n  against a staged core must stamp it. Ordering stays with the monotonic\r\n  counter; this field never decides which release is newer.\r\n- `--channel <name>` overrides the set's channel (default `debug`). A lab rig\r\n  whose recipient home stays pinned to `stable` pairs this with a rig-key\r\n  overlay in that home's `release-keys.json`; fleet debug nodes keep the\r\n  default and the `debug-pin` flow.\r\n- `--stage-dir <path>` stages somewhere other than `$SPT_HOME/releases`.\r\n- `--state <path>` stores the local debug sequence somewhere other than\r\n  `target/debug-rollout-state.json`.\r\n\r\nThe staged update then propagates through the normal pull-based update pump.\r\nThere is intentionally no bespoke push path.\r\n\r\n## Apply and observe\r\n\r\nOn a default-gated node, apply is still explicit:\r\n\r\n```powershell\r\nspt update apply\r\n```\r\n\r\nNodes configured for full-auto update may apply after staging without a prompt.\r\nFor quick debugging, run or poke the daemon/update pump on lab nodes so they\r\nquery peers promptly.\r\n\r\n**Convergence watcher (M8-D4, decision 19).** Instead of hand-walking nodes,\r\nrun the maintainer watcher from the coordinator:\r\n\r\n```powershell\r\ncargo run -p xtask -- debug-converge --version <N> [--subnet <name>] [--timeout 120] [--poll 3]\r\n```\r\n\r\nIt polls every expected node (the coordinator's trust rows; `--nodes <hex,…>`\r\noverrides) over the status-only update query and prints a per-node table —\r\n`Applied` / `StagedAwaitingConsent` / `NotPinned` / `Rejected{reason}` /\r\n`Offline` — exiting `0` only when every node applied the target (`1`\r\notherwise, `2` on usage). Spec + state model: `docs/DEBUG-CONVERGE-PLAN.md`.\r\nThe manual walk below remains the fallback.\r\n\r\nUseful outcomes:\r\n\r\n- `NoArtifactForPlatform` means the signed set did not include the recipient's\r\n  Rust target triple.\r\n- `WrongChannel` means the node is not pinned to `debug`.\r\n- `Rollback` means the offered debug version is not greater than the node's\r\n  current debug-channel version.\r\n- `RefusedClass(BrokerBreaking)` means the candidate touches broker-held\r\n  resources; quiesce hosted endpoints first.\r\n\r\n## Agent checklist\r\n\r\n1. Read `CONTEXT.md` \"debug rollout\" and ADR-0016 before changing the flow.\r\n2. Keep debug tooling in `xtask` or other maintainer tooling, not the public\r\n   `spt` CLI/help surface.\r\n3. Keep verification on the production update substrate: signed metadata,\r\n   per-platform artifact digest, channel pinning, monotonic version, and\r\n   apply-time re-verification.\r\n4. Add or update traceability evidence for `REQ-UPD-6`.\r\n5. Run `cargo fmt`, focused update tests, and `traceable-reqs check`.\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,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130]},"fileSize":5535,"meta":{"source":{"type":"path","value":"C:\\Users\\decid\\Documents\\projects\\spt-core\\docs\\DEBUG-ROLLOUT.md"}}},"isError":false,"timestamp":1790039395143}}
{"type":"message","id":"f8398bd4","parentId":"6d973330","timestamp":"2026-09-22T01:09:55.158Z","message":{"role":"toolResult","toolCallId":"call_DEM7fsEWBMHULmATHpZYosRZ|fc_06340275dc54e8ed016ab1d562f52487d1a910faf84ab1b28e","toolName":"read","content":[{"type":"text","text":"# spt-core — agent working rules\n\nHarness-independent core for an agent ecosystem (messaging, live-agent lifecycle, terminal hosting, P2P networking, runtime-manifest harness contract). Rust crates + a single `spt` binary. Clean-room rebuild of `claude_skill_owl`.\n\n**Orientation — read before working:** `PRD.md` (requirements), `ROADMAP.md` (build path), `CONTEXT.md` (glossary/model, authoritative for meaning), `docs/adr/` (decisions), `docs/KNOWN-HAZARDS.md` (invariants we must not re-break), `docs/{STORAGE,MANIFEST,CONTEXT-MEMORY,DOCS-STRATEGY,TRACEABILITY}.md`.\n\nFor maintainer/debug update work, also read `docs/DEBUG-ROLLOUT.md` before touching the update-set or rollout path.\n\nFor work needing Linux tests and proof, this machine is authorized to ssh into a Linux box: `reavus@kitsubito`\n\nIMPORTANT: All agents you work alongside run on spt-core itself — the legacy-SPT (`$OWL`/`$LIVE`) era ended with the 2026-07 fleet migration; those commands are gone. Reach other agents with `spt send` (or the `/sptc:send` skill); keep message bodies short, and send long bodies from a file rather than inline. Everything else can be learned from the corresponding `/sptc:` skill doc.\n\n## Requirement traceability (binding)\n\nThis project uses `traceable-reqs` (`traceable-reqs.toml` = the authoritative `REQ-*` registry). The full contract is `docs/TRACEABILITY.md`. The rules you must follow:\n\n1. **Tag evidence in the same change.** When you write a function/test/doc-section that satisfies a requirement stage, add its tag in that same commit:\n   `// [impl->REQ-FOO]` · `// [unit->REQ-FOO]` · `<!-- [doc->REQ-FOO] -->`\n   Stages: `doc` / `impl` / `unit` / `int`. Tag *on or immediately above* the real evidence — never at file tops to satisfy coverage.\n2. **Run `traceable-reqs check` before declaring work done.** Exit-1 means missing/invalid evidence — fix it, don't ship it.\n3. **New requirement → add it to `traceable-reqs.toml` first** (with a `REQ-*` id), then satisfy it. No untracked work; no untagged evidence.\n4. **KNOWN-HAZARDS are `REQ-HAZARD-*` requirements** — each needs a test before it's \"covered.\" Treat the hazard list as a conformance checklist you must satisfy, not advice.\n5. **Activate, don't pre-fail.** Requirements you aren't yet working stay `required_stages = []`. Activate (set real stages) only when starting the milestone that delivers them.\n\n## Issue tracking (unique case)\n\nCodebase issues are maintained on **`BigscreenVR/spt-bs-releases`** — the less-private *releases* repo — NOT on `spt-bs-core` where the code lives. This is a deliberate exception (operator-ruled 2026-07-27): file, triage, and reference issues against `spt-bs-releases`.\n\nRequests and milestones are tracked through the **alchemy shell** (`alchemy-0`, the Requests shell bound to `spt-bs-releases`) on top of those GitHub issues. The label taxonomy is the workflow: `state:` BACKLOG → EVAL → GREENLIT → WIP → ACCEPTANCE → DONE (plus CUT / SHELVED), `type:` BUGFIX / ADDITION / CHANGE, `flag: NEEDS-OPERATOR`, and per-node `kind: SPT-TRACKING` pins (marked DO NOT DELETE — leave them alone).\n\n## Other conventions\n\n- Match surrounding code style; copy-verbatim the sister project's stable wire/schema formats (ADR-0001), clean-room everything else.\n- Honor every `docs/KNOWN-HAZARDS.md` invariant — these are real bugs already paid for once.\n- Docs are dual-audience (human + AI dev-agent) per `docs/DOCS-STRATEGY.md`; doc generation is CI-gated against drift.\n- Commit messages end with the project's co-author trailer, exact spelling `Co-authored by: <your live agent name>` (e.g. `Co-authored by: todlando`) — NOT git's hyphenated `Co-Authored-By:`; CI's runner-informant parses only the space spelling for attribution.\n  - **Audit this trailer with `git log --grep` or a raw body read, NEVER `%(trailers:)`** (nor `interpret-trailers --parse`): git's trailer tokenizer requires a whitespace-free token, so the space spelling is structurally invisible to it and every trailer query returns a confident EMPTY on every commit — a clean zero that reads as total attribution loss. Measured 2026-07-30 (main @`8f3e10b`, Signed-off-by and hyphenated-key controls both parse); re-derived at full cost 2026-08-03 by one of its own original measurers, which is why this sentence lives here at the mandate rather than in a document you must decide to open.\n- **Worktrees (binding, operator-ruled 2026-07-05):** every `git worktree` (gate rigs, agent scratch, anything) goes under `.worktrees/` in THIS project's root (`.gitignore`d) — e.g. `git worktree add --detach .worktrees/gate-<sha> <sha>`. NEVER create worktrees as siblings of the project dir; stray sibling folders accumulate orphan-pinned skeletons and pollute `~/Documents/projects`. Remove yours when done (`git worktree remove` + prune; retry later if a handle pins it).\n<!-- [doc->REQ-POOL-FOREIGN-TREE-REFUSED] -->\n- **Build-cache pools (binding, releases#103):** a cargo `target/` directory belongs to ONE source tree. Two trees building into one pool overwrite each other's artifacts and fabricate reds that belong to neither — the v0.51.0 W1/H1 `E0308` that vanished on re-run. **The rule is about the RELATIONSHIP, not the path:** a junction and a `CARGO_TARGET_DIR` env var reach the same hazard, and the env var leaves no directory entry to notice afterwards, so \"is this dir under `.worktrees/`\" never was the question. A build script enforces it (`crates/spt-store/build.rs`) and refuses with `SPT_POOL_FOREIGN`. **Sharing a pool SEQUENTIALLY is fine and expected** — a finished lane is taken over loudly, not refused; only a LIVE lane blocks you. Claim a pool at lane start …\n- **Target-tree teardown (releases#103, same lane):** never delete a `target/` you have not classified. Check OUTBOUND first (`Get-Item -Force`: a junction is deleted as a link, a real dir as a tree), then sweep INBOUND for reparse points aimed at it before removing a real one, and measure size and free space before and after so the reclaim is a number rather than a claim. Reap the target SUBTREE, never a session or worktree directory around it. Do not point `CARGO_TARGET_DIR` into a scratchpad (v0.50.0 incident) or at another checkout's target (H1 incident).\n- **CI / merge strategy:** golden CI (ADR-0050, ruled 2026-07-27) — milestone-batch golden runs, thin-lane PRs, ff-only main (tested sha == merged sha). Piloting from the next milestone; read the ADR before shaping a PR lane.\n\nIf you finish a significant body of work without need for user intervention, or if your context gets too high, you can clear your own context and keep moving:\n1. Create a JIT plan for the next immediate body of work, if it isn't already planned\n2. If you are a live agent, /sptc:commune with immediate next steps and broad summary of the project's status + end goal\n3. Execute the /self-clear skill\n\n[Some lines truncated to 768 chars]"}],"details":{"totalLines":47,"displayContent":{"text":"# spt-core — agent working rules\r\n\r\nHarness-independent core for an agent ecosystem (messaging, live-agent lifecycle, terminal hosting, P2P networking, runtime-manifest harness contract). Rust crates + a single `spt` binary. Clean-room rebuild of `claude_skill_owl`.\r\n\r\n**Orientation — read before working:** `PRD.md` (requirements), `ROADMAP.md` (build path), `CONTEXT.md` (glossary/model, authoritative for meaning), `docs/adr/` (decisions), `docs/KNOWN-HAZARDS.md` (invariants we must not re-break), `docs/{STORAGE,MANIFEST,CONTEXT-MEMORY,DOCS-STRATEGY,TRACEABILITY}.md`.\r\n\r\nFor maintainer/debug update work, also read `docs/DEBUG-ROLLOUT.md` before touching the update-set or rollout path.\r\n\r\nFor work needing Linux tests and proof, this machine is authorized to ssh into a Linux box: `reavus@kitsubito`\r\n\r\nIMPORTANT: All agents you work alongside run on spt-core itself — the legacy-SPT (`$OWL`/`$LIVE`) era ended with the 2026-07 fleet migration; those commands are gone. Reach other agents with `spt send` (or the `/sptc:send` skill); keep message bodies short, and send long bodies from a file rather than inline. Everything else can be learned from the corresponding `/sptc:` skill doc.\r\n\r\n## Requirement traceability (binding)\r\n\r\nThis project uses `traceable-reqs` (`traceable-reqs.toml` = the authoritative `REQ-*` registry). The full contract is `docs/TRACEABILITY.md`. The rules you must follow:\r\n\r\n1. **Tag evidence in the same change.** When you write a function/test/doc-section that satisfies a requirement stage, add its tag in that same commit:\r\n   `// [impl->REQ-FOO]` · `// [unit->REQ-FOO]` · `<!-- [doc->REQ-FOO] -->`\r\n   Stages: `doc` / `impl` / `unit` / `int`. Tag *on or immediately above* the real evidence — never at file tops to satisfy coverage.\r\n2. **Run `traceable-reqs check` before declaring work done.** Exit-1 means missing/invalid evidence — fix it, don't ship it.\r\n3. **New requirement → add it to `traceable-reqs.toml` first** (with a `REQ-*` id), then satisfy it. No untracked work; no untagged evidence.\r\n4. **KNOWN-HAZARDS are `REQ-HAZARD-*` requirements** — each needs a test before it's \"covered.\" Treat the hazard list as a conformance checklist you must satisfy, not advice.\r\n5. **Activate, don't pre-fail.** Requirements you aren't yet working stay `required_stages = []`. Activate (set real stages) only when starting the milestone that delivers them.\r\n\r\n## Issue tracking (unique case)\r\n\r\nCodebase issues are maintained on **`BigscreenVR/spt-bs-releases`** — the less-private *releases* repo — NOT on `spt-bs-core` where the code lives. This is a deliberate exception (operator-ruled 2026-07-27): file, triage, and reference issues against `spt-bs-releases`.\r\n\r\nRequests and milestones are tracked through the **alchemy shell** (`alchemy-0`, the Requests shell bound to `spt-bs-releases`) on top of those GitHub issues. The label taxonomy is the workflow: `state:` BACKLOG → EVAL → GREENLIT → WIP → ACCEPTANCE → DONE (plus CUT / SHELVED), `type:` BUGFIX / ADDITION / CHANGE, `flag: NEEDS-OPERATOR`, and per-node `kind: SPT-TRACKING` pins (marked DO NOT DELETE — leave them alone).\r\n\r\n## Other conventions\r\n\r\n- Match surrounding code style; copy-verbatim the sister project's stable wire/schema formats (ADR-0001), clean-room everything else.\r\n- Honor every `docs/KNOWN-HAZARDS.md` invariant — these are real bugs already paid for once.\r\n- Docs are dual-audience (human + AI dev-agent) per `docs/DOCS-STRATEGY.md`; doc generation is CI-gated against drift.\r\n- Commit messages end with the project's co-author trailer, exact spelling `Co-authored by: <your live agent name>` (e.g. `Co-authored by: todlando`) — NOT git's hyphenated `Co-Authored-By:`; CI's runner-informant parses only the space spelling for attribution.\r\n  - **Audit this trailer with `git log --grep` or a raw body read, NEVER `%(trailers:)`** (nor `interpret-trailers --parse`): git's trailer tokenizer requires a whitespace-free token, so the space spelling is structurally invisible to it and every trailer query returns a confident EMPTY on every commit — a clean zero that reads as total attribution loss. Measured 2026-07-30 (main @`8f3e10b`, Signed-off-by and hyphenated-key controls both parse); re-derived at full cost 2026-08-03 by one of its own original measurers, which is why this sentence lives here at the mandate rather than in a document you must decide to open.\r\n- **Worktrees (binding, operator-ruled 2026-07-05):** every `git worktree` (gate rigs, agent scratch, anything) goes under `.worktrees/` in THIS project's root (`.gitignore`d) — e.g. `git worktree add --detach .worktrees/gate-<sha> <sha>`. NEVER create worktrees as siblings of the project dir; stray sibling folders accumulate orphan-pinned skeletons and pollute `~/Documents/projects`. Remove yours when done (`git worktree remove` + prune; retry later if a handle pins it).\r\n<!-- [doc->REQ-POOL-FOREIGN-TREE-REFUSED] -->\r\n- **Build-cache pools (binding, releases#103):** a cargo `target/` directory belongs to ONE source tree. Two trees building into one pool overwrite each other's artifacts and fabricate reds that belong to neither — the v0.51.0 W1/H1 `E0308` that vanished on re-run. **The rule is about the RELATIONSHIP, not the path:** a junction and a `CARGO_TARGET_DIR` env var reach the same hazard, and the env var leaves no directory entry to notice afterwards, so \"is this dir under `.worktrees/`\" never was the question. A build script enforces it (`crates/spt-store/build.rs`) and refuses with `SPT_POOL_FOREIGN`. **Sharing a pool SEQUENTIALLY is fine and expected** — a finished lane is taken over loudly, not refused; only a LIVE lane blocks you. Claim a pool at lane start …\n- **Target-tree teardown (releases#103, same lane):** never delete a `target/` you have not classified. Check OUTBOUND first (`Get-Item -Force`: a junction is deleted as a link, a real dir as a tree), then sweep INBOUND for reparse points aimed at it before removing a real one, and measure size and free space before and after so the reclaim is a number rather than a claim. Reap the target SUBTREE, never a session or worktree directory around it. Do not point `CARGO_TARGET_DIR` into a scratchpad (v0.50.0 incident) or at another checkout's target (H1 incident).\r\n- **CI / merge strategy:** golden CI (ADR-0050, ruled 2026-07-27) — milestone-batch golden runs, thin-lane PRs, ff-only main (tested sha == merged sha). Piloting from the next milestone; read the ADR before shaping a PR lane.\r\n\r\nIf you finish a significant body of work without need for user intervention, or if your context gets too high, you can clear your own context and keep moving:\r\n1. Create a JIT plan for the next immediate body of work, if it isn't already planned\r\n2. If you are a live agent, /sptc:commune with immediate next steps and broad summary of the project's status + end goal\r\n3. Execute the /self-clear skill\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]},"fileSize":8436,"meta":{"source":{"type":"path","value":"C:\\Users\\decid\\Documents\\projects\\spt-core\\AGENTS.md"},"limits":{"columnTruncated":{"maxColumn":768}}}},"isError":false,"timestamp":1790039395157}}
{"type":"message","id":"0646b2e3","parentId":"f8398bd4","timestamp":"2026-09-22T01:09:55.682Z","message":{"role":"toolResult","toolCallId":"call_HTw5Vu6JHDEjWrfqPcXMgvKM|fc_06340275dc54e8ed016ab1d562f51887d1a3f14a9f49684ddf","toolName":"read","content":[{"type":"text","text":"# Issue #312: spt update fetch gate keys on `gh auth status` EXIT code, so one stale stored gh account blocks the update even when a valid GH_TOKEN (or another valid account) is present\n\nState: OPEN\nAuthor: @SaberMage\nCreated: 2026-09-16T02:07:12Z\nUpdated: 2026-09-21T22:25:47Z\nLabels: state: GREENLIT, type: BUGFIX\nURL: https://github.com/BigscreenVR/spt-bs-releases/issues/312\n\n## Body\n\nMeasured 2026-09-16 02:06Z on ENLYZEAM (spt 0.69.0, gh CLI): `spt update --restart` -> UPDATE_FETCH_REJECTED:GhAuthRequired. Same shell with a valid GH_TOKEN set: `gh auth status` prints \"Logged in ... (GH_TOKEN)\" AND \"Failed to log in ... account SaberMage (default) - token invalid\", exits 1; `gh release list --repo BigscreenVR/spt-bs-releases` SUCCEEDS with that token. spt still refuses: `cli.rs` gh_status() maps any non-zero `gh auth status` to Unauthed (crates/spt/src/cli.rs ~9990). gh exits non-zero when ANY stored host account fails, so a dead leftover login vetoes a working credential. Workaround used: `gh auth logout -h github.com -u <stale>`. ASK: gate on a positive probe instead — e.g. `gh auth token` exit 0 / `gh api user` / a release-channel read — and name the stale account in the refusal when gh reports one.\n\n---\nRequester: doyle\n\n## Comments (2)\n\n### @SaberMage · 2026-09-16T02:10:30Z\n\n**Measured 2026-09-16 02:10Z on ENLYZEAM (doyle, read-only ssh):** with a valid `GH_TOKEN` in the environment AND the stale stored `SaberMage` login present, `gh auth status` exits **1** (`GH_STATUS_EXIT_WITH_TOKEN=1`); without the token also **1**. The same token made `gh release list --repo BigscreenVR/spt-bs-releases` succeed. So the stale-account veto is a measurement, not an inference from the version string. Secondary question still open: whether `spt_runtime::run_bounded_command` passes the parent environment (and thus `GH_TOKEN`) to its `gh auth status` child at all — if it does not, that is a second face of the same gate.\n\n*\\-doyle@HFENDULEAM*\n\nURL: https://github.com/BigscreenVR/spt-bs-releases/issues/312#issuecomment-5690960778\n\n### @SaberMage · 2026-09-16T02:25:48Z\n\n**Bypass used in the field 2026-09-16 02:19Z (doyle, operator-directed) — recorded so the next person does not rediscover it:** the stale-gh gate was not fixed; it was BYPASSED by staging the signed set from an already-updated peer by hand. Procedure (Windows, both boxes same user): from the updated node copy `%LOCALAPPDATA%\\spt-core\neleases\nelease.json` and `releases^Grtifacts\\<platform>.bin` to the target node's `releases\\` (same layout; leave `applied*.json` / `last-outcome.json` alone; no `platform.json` for a set), verify sha256 on the target (`release.json` hash and the artifact hash named inside `release.json`), then `spt update apply` there. `apply` re-verifies signature + platform digest at rest, so an unsigned or tampered set is refused; the non-finish apply leaves the broker network layer on the old version until `spt update apply --finish`. This is exactly the peer-propagation the public doc describes, done by hand because the WAN path to that node was wedged at quic-connect. It does not close this Request: the gate still keys on `gh auth status` exit code, and a node with a stale gh login and no reachable peer stays stuck.\n\n*\\-doyle@HFENDULEAM*\n\nURL: https://github.com/BigscreenVR/spt-bs-releases/issues/312#issuecomment-5691086274"}],"details":{"contentType":"text/markdown","totalLines":35,"displayContent":{"text":"# Issue #312: spt update fetch gate keys on `gh auth status` EXIT code, so one stale stored gh account blocks the update even when a valid GH_TOKEN (or another valid account) is present\n\nState: OPEN\nAuthor: @SaberMage\nCreated: 2026-09-16T02:07:12Z\nUpdated: 2026-09-21T22:25:47Z\nLabels: state: GREENLIT, type: BUGFIX\nURL: https://github.com/BigscreenVR/spt-bs-releases/issues/312\n\n## Body\n\nMeasured 2026-09-16 02:06Z on ENLYZEAM (spt 0.69.0, gh CLI): `spt update --restart` -> UPDATE_FETCH_REJECTED:GhAuthRequired. Same shell with a valid GH_TOKEN set: `gh auth status` prints \"Logged in ... (GH_TOKEN)\" AND \"Failed to log in ... account SaberMage (default) - token invalid\", exits 1; `gh release list --repo BigscreenVR/spt-bs-releases` SUCCEEDS with that token. spt still refuses: `cli.rs` gh_status() maps any non-zero `gh auth status` to Unauthed (crates/spt/src/cli.rs ~9990). gh exits non-zero when ANY stored host account fails, so a dead leftover login vetoes a working credential. Workaround used: `gh auth logout -h github.com -u <stale>`. ASK: gate on a positive probe instead — e.g. `gh auth token` exit 0 / `gh api user` / a release-channel read — and name the stale account in the refusal when gh reports one.\n\n---\nRequester: doyle\n\n## Comments (2)\n\n### @SaberMage · 2026-09-16T02:10:30Z\n\n**Measured 2026-09-16 02:10Z on ENLYZEAM (doyle, read-only ssh):** with a valid `GH_TOKEN` in the environment AND the stale stored `SaberMage` login present, `gh auth status` exits **1** (`GH_STATUS_EXIT_WITH_TOKEN=1`); without the token also **1**. The same token made `gh release list --repo BigscreenVR/spt-bs-releases` succeed. So the stale-account veto is a measurement, not an inference from the version string. Secondary question still open: whether `spt_runtime::run_bounded_command` passes the parent environment (and thus `GH_TOKEN`) to its `gh auth status` child at all — if it does not, that is a second face of the same gate.\n\n*\\-doyle@HFENDULEAM*\n\nURL: https://github.com/BigscreenVR/spt-bs-releases/issues/312#issuecomment-5690960778\n\n### @SaberMage · 2026-09-16T02:25:48Z\n\n**Bypass used in the field 2026-09-16 02:19Z (doyle, operator-directed) — recorded so the next person does not rediscover it:** the stale-gh gate was not fixed; it was BYPASSED by staging the signed set from an already-updated peer by hand. Procedure (Windows, both boxes same user): from the updated node copy `%LOCALAPPDATA%\\spt-core\neleases\nelease.json` and `releases^Grtifacts\\<platform>.bin` to the target node's `releases\\` (same layout; leave `applied*.json` / `last-outcome.json` alone; no `platform.json` for a set), verify sha256 on the target (`release.json` hash and the artifact hash named inside `release.json`), then `spt update apply` there. `apply` re-verifies signature + platform digest at rest, so an unsigned or tampered set is refused; the non-finish apply leaves the broker network layer on the old version until `spt update apply --finish`. This is exactly the peer-propagation the public doc describes, done by hand because the WAN path to that node was wedged at quic-connect. It does not close this Request: the gate still keys on `gh auth status` exit code, and a node with a stale gh login and no reachable peer stays stuck.\n\n*\\-doyle@HFENDULEAM*\n\nURL: https://github.com/BigscreenVR/spt-bs-releases/issues/312#issuecomment-5691086274","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]},"meta":{"source":{"type":"internal","value":"issue://BigscreenVR/spt-bs-releases/312"}}},"isError":false,"timestamp":1790039395681}}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_txEuzqP9dL6mrDsKvDa7Jms7|fc_06340275dc54e8ed016ab1d56be36c87d19694df3b19749034","toolName":"grep","startedAt":"2026-09-22T01:10:03.990Z","args":{"path":"crates/spt/src;crates/spt-runtime;crates/spt/tests"},"intent":"Locating gate and probe references"},"id":"2afe909f","parentId":"0646b2e3","timestamp":"2026-09-22T01:10:03.990Z"}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_GsxAiM7RzkEsi8AYsUwScEDo|fc_06340275dc54e8ed016ab1d56be38087d188fbfb6c6b86464f","toolName":"read","startedAt":"2026-09-22T01:10:03.990Z","args":{"path":"xd://"},"intent":"Discovering read only tooling"},"id":"772a56f9","parentId":"2afe909f","timestamp":"2026-09-22T01:10:03.990Z"}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_CiUci15HRRIew3eqZ7SB1Bod|fc_06340275dc54e8ed016ab1d56be38887d1924197378a0873ce","toolName":"grep","startedAt":"2026-09-22T01:10:03.990Z","args":{"path":"CONTEXT.md;docs/KNOWN-HAZARDS.md;PRD.md;ROADMAP.md;docs/adr"},"intent":"Locating update model guidance"},"id":"bd87795b","parentId":"772a56f9","timestamp":"2026-09-22T01:10:03.990Z"}
{"type":"message","id":"4bbbd498","parentId":"bd87795b","timestamp":"2026-09-22T01:10:03.991Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"call_txEuzqP9dL6mrDsKvDa7Jms7|fc_06340275dc54e8ed016ab1d56be36c87d19694df3b19749034","name":"grep","arguments":{"i":"Locating gate and probe references","pattern":"gh_status|render_gh_auth_required|GhAuthRequired|run_bounded_command|gh api|auth.*token","path":"crates/spt/src;crates/spt-runtime;crates/spt/tests","case":null,"gitignore":null,"skip":null},"intent":"Locating gate and probe references"},{"type":"toolCall","id":"call_GsxAiM7RzkEsi8AYsUwScEDo|fc_06340275dc54e8ed016ab1d56be38087d188fbfb6c6b86464f","name":"read","arguments":{"i":"Discovering read only tooling","path":"xd://"},"intent":"Discovering read only tooling"},{"type":"toolCall","id":"call_CiUci15HRRIew3eqZ7SB1Bod|fc_06340275dc54e8ed016ab1d56be38887d1924197378a0873ce","name":"grep","arguments":{"i":"Locating update model guidance","pattern":"debug rollout|GhAuth|fetch|credential|token|secret","path":"CONTEXT.md;docs/KNOWN-HAZARDS.md;PRD.md;ROADMAP.md;docs/adr","case":false,"gitignore":null,"skip":null},"intent":"Locating update model guidance"}],"api":"openai-codex-responses","provider":"openai-codex","model":"gpt-6-astra","usage":{"input":5915,"output":195,"cacheRead":10112,"cacheWrite":0,"totalTokens":16222,"cost":{"input":0.05915000000000001,"output":0.00975,"cacheRead":0.010112,"cacheWrite":0,"total":0.079012}},"stopReason":"toolUse","timestamp":1790039395757,"responseId":"resp_06340275dc54e8ed016ab1d56408a087d1a838f5e5e8d84bca","providerPayload":{"type":"openaiResponsesHistory","provider":"openai-codex","dt":true,"items":[{"id":"fc_06340275dc54e8ed016ab1d56be36c87d19694df3b19749034","type":"function_call","status":"completed","arguments":"{\"i\":\"Locating gate and probe references\",\"pattern\":\"gh_status|render_gh_auth_required|GhAuthRequired|run_bounded_command|gh api|auth.*token\",\"path\":\"crates/spt/src;crates/spt-runtime;crates/spt/tests\",\"case\":null,\"gitignore\":null,\"skip\":null}","call_id":"call_txEuzqP9dL6mrDsKvDa7Jms7","internal_chat_message_metadata_passthrough":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"},"name":"grep","metadata":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"}},{"id":"fc_06340275dc54e8ed016ab1d56be38087d188fbfb6c6b86464f","type":"function_call","status":"completed","arguments":"{\"i\":\"Discovering read only tooling\",\"path\":\"xd://\"}","call_id":"call_GsxAiM7RzkEsi8AYsUwScEDo","internal_chat_message_metadata_passthrough":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"},"name":"read","metadata":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"}},{"id":"fc_06340275dc54e8ed016ab1d56be38887d1924197378a0873ce","type":"function_call","status":"completed","arguments":"{\"i\":\"Locating update model guidance\",\"pattern\":\"debug rollout|GhAuth|fetch|credential|token|secret\",\"path\":\"CONTEXT.md;docs/KNOWN-HAZARDS.md;PRD.md;ROADMAP.md;docs/adr\",\"case\":false,\"gitignore\":null,\"skip\":null}","call_id":"call_CiUci15HRRIew3eqZ7SB1Bod","internal_chat_message_metadata_passthrough":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"},"name":"grep","metadata":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"}}]},"duration":8230.148200001568,"ttft":8160.983100000769,"completedAt":1790039403988,"contextSnapshot":{"promptTokens":16027,"nonMessageTokens":10333,"compactionEpoch":0}}}
{"type":"message","id":"dcbbf7d9","parentId":"4bbbd498","timestamp":"2026-09-22T01:10:03.991Z","message":{"role":"toolResult","toolCallId":"call_GsxAiM7RzkEsi8AYsUwScEDo|fc_06340275dc54e8ed016ab1d56be38087d188fbfb6c6b86464f","toolName":"read","content":[{"type":"text","text":"xd:// 1 mounted tool devices.\nxd://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 docs + JSON schema; write the JSON args object to xd://<tool> to execute. Active top-level tools accept the same dispatch."}],"details":{"contentType":"text/plain","totalLines":4,"displayContent":{"text":"xd:// 1 mounted tool devices.\nxd://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 docs + JSON schema; write the JSON args object to xd://<tool> to execute. Active top-level tools accept the same dispatch.","startLine":1,"lineNumbers":[1,2,3,4]},"meta":{"source":{"type":"internal","value":"xd://"}}},"isError":false,"timestamp":1790039403991}}
{"type":"message","id":"4a09e301","parentId":"dcbbf7d9","timestamp":"2026-09-22T01:10:04.056Z","message":{"role":"toolResult","toolCallId":"call_txEuzqP9dL6mrDsKvDa7Jms7|fc_06340275dc54e8ed016ab1d56be36c87d19694df3b19749034","toolName":"grep","content":[{"type":"text","text":"# crates/\n\n## spt/\n\n### src/\n#### cli.rs\n 4623|            // (the preflight cannot close the TOCTOU — the broker gate is the\n*4624|            // authority; both speak the same token).\n 4625|            // [impl->REQ-SPAWN-FRESH-TRUTHFUL]\n 4626|            eprintln!(\n 4627|                \"{}\",\n...\n 9782|    keys.insert(\"dest\".to_string(), staging.to_string_lossy().into_owned());\n*9783|    match spt_runtime::run_bounded_command(\n 9784|        \"tar -xzf {bundle} -C {dest}\",\n 9785|        &keys,\n 9786|        Duration::from_secs(120),\n...\n 10022|    matches!(\n*10023|        spt_runtime::run_bounded_command(\n 10024|            \"gh auth status\",\n 10025|            &std::collections::BTreeMap::new(),\n 10026|            Duration::from_secs(10),\n...\n 10061|// [impl->REQ-UPDATE-GH-TRANSPORT]\n*10062|fn gh_status() -> GhStatus {\n*10063|    match spt_runtime::run_bounded_command(\n 10064|        \"gh auth status\",\n 10065|        &std::collections::BTreeMap::new(),\n 10066|        Duration::from_secs(10),\n...\n 10106|// [impl->REQ-UPDATE-GH-TRANSPORT]\n*10107|fn render_gh_auth_required() -> String {\n*10108|    \"UPDATE_FETCH_REJECTED:GhAuthRequired — the gh CLI is installed but not \\\n 10109|     authenticated. Run `gh auth login` with an account that can read the \\\n 10110|     release channel.\"\n 10111|        .to_string()\n...\n 10244|    {\n*10245|        let out = spt_runtime::run_bounded_command(\n 10246|            \"uname -m\",\n 10247|            &std::collections::BTreeMap::new(),\n 10248|            Duration::from_secs(5),\n...\n 10295|        keys.insert(\"script\".to_string(), script);\n*10296|        match spt_runtime::run_bounded_command(\n 10297|            \"powershell -NoProfile -NonInteractive -Command {script}\",\n 10298|            &keys,\n 10299|            Duration::from_secs(30),\n...\n 10559|\n*10560|/// Build the `gh api` command (template + keys) that prints `repo`'s latest\n 10561|/// release tag to stdout. Pure — the caller runs it bounded. The `gh` CLI honors\n 10562|/// OAuth + `GH_TOKEN`, so this reads a private repo with no token in spt's hands.\n 10563|// [impl->REQ-ADAPTER-GH-TRANSPORT]\n...\n 10565|    (\n*10566|        \"gh api repos/{repo}/releases/latest --jq .tag_name\".to_string(),\n 10567|        std::collections::BTreeMap::from([(\"repo\".to_string(), repo.to_string())]),\n 10568|    )\n 10569|}\n...\n 10619|            let (template, keys) = gh_download_command(repo, tag, asset, scratch_dir);\n*10620|            match spt_runtime::run_bounded_command(\n 10621|                &template,\n 10622|                &keys,\n 10623|                Duration::from_secs(300),\n...\n 10706|    ]);\n*10707|    match spt_runtime::run_bounded_command(\n 10708|        \"tar -xf \\\"{archive}\\\" -C \\\"{dest}\\\"\",\n 10709|        &keys,\n 10710|        Duration::from_secs(300),\n...\n 10977|    // [impl->REQ-UPDATE-GH-TRANSPORT]\n*10978|    match gh_status() {\n 10979|        GhStatus::Available => {}\n 10980|        GhStatus::Missing => {\n 10981|            eprintln!(\"{}\", render_gh_cli_required(std::env::consts::OS));\n...\n 10984|        GhStatus::Unauthed => {\n*10985|            eprintln!(\"{}\", render_gh_auth_required());\n 10986|            return 1;\n 10987|        }\n 10988|    }\n...\n 20954|    fn conduct(label: &str, template: &str) -> i32 {\n*20955|        match spt_runtime::run_bounded_command(\n 20956|            template,\n 20957|            &std::collections::BTreeMap::new(),\n 20958|            Duration::from_secs(300),\n...\n 21043|                    ]);\n*21044|                    match spt_runtime::run_bounded_command(\n 21045|                        \"git clone --depth 1 \\\"{url}\\\" \\\"{dest}\\\"\",\n 21046|                        &keys,\n 21047|                        Duration::from_secs(300),\n...\n 22010|\n*22011|    match spt_runtime::runtime::run_bounded_command_in(\n 22012|        &post.command,\n 22013|        &keys,\n 22014|        UPDATE_POST_TIMEOUT,\n...\n 22491|/// `repo`'s latest release `tag_name`, leading `v` stripped (e.g. `v0.2.0` →\n*22492|/// `0.2.0`). HTTPS reads the public GitHub API; `gh` runs `gh api …/releases/latest\n 22493|/// --jq .tag_name` (the private-repo path). (REQ-UPD-9, REQ-ADAPTER-GH-TRANSPORT)\n 22494|// [impl->REQ-UPD-9]\n 22495|// [impl->REQ-ADAPTER-GH-TRANSPORT]\n...\n 22520|            let (template, keys) = gh_version_command(repo);\n*22521|            match spt_runtime::run_bounded_command(\n 22522|                &template,\n 22523|                &keys,\n 22524|                Duration::from_secs(60),\n#### rc.rs\n 194|    ]);\n*195|    let output = spt_runtime::runtime::run_bounded_command(\n 196|        \"{exe} --version\", &keys, Duration::from_secs(2), None,\n 197|    ).ok()?;\n 198|    if !output.success() {\n\n#### api/\n##### auth.rs\n 490|    // [unit->REQ-HAZARD-SESSION-PIN-WEDGE] the token-auth path is UNCHANGED by the\n*491|    // dead-owner branch: a correct token authenticates via the token check (first),\n 492|    // never reaching the re-pin — so the pin is NOT rotated, even with a dead owner\n 493|    // and a mismatched sid alongside the token.\n 494|    #[test]\n...\n 499|        let proof = Proof::resolve(Some(token), Some(\"new-live-sid\".into()));\n*500|        assert_eq!(authenticate(\"alice\", &proof), AuthResult::Ok, \"token authenticates\");\n 501|        assert_eq!(\n 502|            recorded_sid(\"alice\"),\n 503|            \"old-dead-sid\",\n##### engineroom.rs\n 16|//! 3. the caller authenticates against that perch the way every other mutating\n*17|//!    api verb authenticates (capability token or recorded session id).\n 18|//!\n 19|//! Fact 3 is the one that carries the weight: an agent that merely knows the\n 20|//! reserved id gets nowhere without the perch's own proof.\n##### mod.rs\n 108|    /// `{link_token}` — \"owner from the link\") and flips it online. The\n*109|    /// credential IS the auth: no token, no bind.\n 110|    BindShell {\n 111|        /// The link token the broker minted at launch.\n 112|        #[arg(long = \"link\")]\n...\n 682|            // The local-link handshake's receiving end (CONTEXT §local-link\n*683|            // authentication): possession of the minted token is the bind\n 684|            // credential; the perch found by it names owner + instance.\n 685|            // [impl->REQ-SHELL-1]\n 686|            match spt_daemon::shellhost::bind_shell_by_token(\n##### startup.rs\n 824|    // Keep an existing token across a revive; mint one for a fresh perch.\n*825|    let token = auth::read_token(&perch_path).unwrap_or_else(auth::mint_token);\n*826|    auth::write_token(&perch_path, &token).map_err(|e| BindError::Io(e.to_string()))?;\n 827|    Ok(token)\n 828|}\n 829|\n##### worker.rs\n 234|        assert_eq!(rec.agent_type.as_deref(), Some(\"code-reviewer\"));\n*235|        // No token file — worker auth is sid-symmetric, no capability token.\n 236|        assert!(!w1.join(\"api.token\").exists(), \"workers mint no token\");\n 237|\n 238|        // The counter is monotonic: a second start under the same parent is w2.\n\n### tests/\n#### io_events_poll_e2e.rs\n 276|        bare.no_window()\n*277|            .args([\"--json\", \"api\", \"io-events\", author, \"--token\", \"not-a-real-token\"])\n 278|            .env(\"SPT_HOME\", home.path());\n 279|        let out = common::output_bounded(bare, Duration::from_secs(60));\n 280|        assert!(\n\n#### fixtures/\n##### gh_fixture.rs\n 5|//! The test copies it to `gh(.exe)` in a PATH-prepended dir, so every gh\n*6|//! subprocess spt-core shells (`gh auth status`, `gh api …/releases/latest`,\n 7|//! `gh release download …`) resolves HERE and the whole release channel is\n 8|//! served hermetically from a local fixture dir — no network, no real gh.\n 9|//!\n...\n 17|//!   - `gh auth status` → exit 0 (authed).\n*18|//!   - `gh api repos/<repo>/releases/latest --jq .tag_name` → print `tag.txt`,\n 19|//!     exit 0.\n 20|//!   - `gh release download [<tag>] --repo <r> --pattern <asset> --dir <d>\n 21|//!     --clobber` → copy `assets/<asset>` to `<d>/<asset>`; a missing asset →\n...\n 51|        Some(\"api\") => {\n*52|            // `gh api repos/<repo>/releases/latest --jq .tag_name`\n 53|            let tag = std::fs::read_to_string(root.join(\"tag.txt\"))\n 54|                .expect(\"fixture tag.txt (the latest release tag)\");\n 55|            println!(\"{}\", tag.trim());\n\n## spt-runtime/\n### manifest.schema.json\n 780|        \"transport\": {\n*781|          \"description\": \"`gh_release` avenue: the fetch **transport** — `https` (direct reqwest,\\npublic), `gh` (shell the pre-authorized `gh` CLI, the private-repo path),\\nor `auto` (default: prefer `gh` when installed+authed, else HTTPS). `gh`\\nhonors OAuth + `GH_TOKEN`, so spt-core custodies no token. Additive over\\nthe existing fetch path; not applicable to `delegated` / `file_pull`.\\nAbsent ⇒ `auto` (N-1-safe).\",\n 782|          \"$ref\": \"#/$defs/Transport\"\n 783|        }\n 784|      },\n...\n 834|        {\n*835|          \"description\": \"Shell the pre-authorized `gh` CLI (`gh release download` for the asset,\\n`gh api` for the version) — the private-repo path. `gh` honors OAuth +\\n`GH_TOKEN`, so spt-core never custodies a token.\",\n 836|          \"type\": \"string\",\n 837|          \"const\": \"gh\"\n 838|        },\n\n### src/\n#### lib.rs\n 31|pub use runtime::{\n*32|    resolve_program_in_dir, run_bounded_command, run_bounded_command_in, scrub_identity_env,\n 33|    AgentRuntime, BoundedOutput, ManifestRuntime, RuntimeError, Spawned, IDENTITY_ENV_VARS,\n 34|    INJECT_ECHO_ENV_VARS, TEST_TIMING_ENV_VARS,\n 35|};\n#### manifest.rs\n 825|    /// Shell the pre-authorized `gh` CLI (`gh release download` for the asset,\n*826|    /// `gh api` for the version) — the private-repo path. `gh` honors OAuth +\n 827|    /// `GH_TOKEN`, so spt-core never custodies a token.\n 828|    Gh,\n 829|    /// Prefer `gh` when it is installed and authenticated, else fall back to\n#### runtime.rs\n 881|        // shared builder — makes KNOWN-HAZARDS 5.8 hold for every ManifestRuntime\n*882|        // spawn, not just the free run_bounded_command leg.\n 883|        // [impl->REQ-HAZARD-CHILD-CONSOLE-FLASH]\n 884|        #[cfg(windows)]\n 885|        {\n...\n 1026|// [impl->REQ-HAZARD-SUBPROCESS-TIMEOUT]\n*1027|pub fn run_bounded_command(\n 1028|    template: &str,\n 1029|    keys: &BTreeMap<String, String>,\n 1030|    timeout: Duration,\n...\n 1032|) -> Result<BoundedOutput, RuntimeError> {\n*1033|    run_bounded_command_in(template, keys, timeout, stdin, None)\n 1034|}\n 1035|\n 1036|/// Resolve a command template's **program token** (first whitespace-split token)\n...\n 1067|\n*1068|/// As [`run_bounded_command`], but when `install_dir` is `Some`, the template's\n 1069|/// program token is first resolved against that adapter install dir\n 1070|/// ([`resolve_program_in_dir`]) so a bare program name binds to the adapter's\n 1071|/// shipped binary before `PATH` (REQ-INSTALL-11). `None` preserves the legacy\n...\n 1074|// [impl->REQ-HAZARD-SUBPROCESS-TIMEOUT]\n*1075|pub fn run_bounded_command_in(\n 1076|    template: &str,\n 1077|    keys: &BTreeMap<String, String>,\n 1078|    timeout: Duration,"}],"details":{"scopePath":"crates/spt/src, crates/spt-runtime, crates/spt/tests","searchPath":"C:\\Users\\decid\\Documents\\projects\\spt-core\\crates","cwd":"C:\\Users\\decid\\Documents\\projects\\spt-core","matchCount":42,"fileCount":13,"files":["crates/spt/src/api/auth.rs","crates/spt/src/api/engineroom.rs","crates/spt/src/api/mod.rs","crates/spt/src/api/startup.rs","crates/spt/src/api/worker.rs","crates/spt/src/cli.rs","crates/spt/src/rc.rs","crates/spt-runtime/manifest.schema.json","crates/spt-runtime/src/lib.rs","crates/spt-runtime/src/manifest.rs","crates/spt-runtime/src/runtime.rs","crates/spt/tests/fixtures/gh_fixture.rs","crates/spt/tests/io_events_poll_e2e.rs"],"fileMatches":[{"path":"crates/spt/src/api/auth.rs","count":2},{"path":"crates/spt/src/api/engineroom.rs","count":1},{"path":"crates/spt/src/api/mod.rs","count":2},{"path":"crates/spt/src/api/startup.rs","count":2},{"path":"crates/spt/src/api/worker.rs","count":1},{"path":"crates/spt/src/cli.rs","count":20},{"path":"crates/spt/src/rc.rs","count":1},{"path":"crates/spt-runtime/manifest.schema.json","count":2},{"path":"crates/spt-runtime/src/lib.rs","count":1},{"path":"crates/spt-runtime/src/manifest.rs","count":1},{"path":"crates/spt-runtime/src/runtime.rs","count":5},{"path":"crates/spt/tests/fixtures/gh_fixture.rs","count":3},{"path":"crates/spt/tests/io_events_poll_e2e.rs","count":1}],"truncated":true,"perFileLimitReached":20,"displayContent":"# crates/\n\n## spt/\n\n### src/\n#### cli.rs\n  4623│            // (the preflight cannot close the TOCTOU — the broker gate is the\n *4624│            // authority; both speak the same token).\n  4625│            // [impl->REQ-SPAWN-FRESH-TRUTHFUL]\n  4626│            eprintln!(\n  4627│                \"{}\",\n      │...\n  9782│    keys.insert(\"dest\".to_string(), staging.to_string_lossy().into_owned());\n *9783│    match spt_runtime::run_bounded_command(\n  9784│        \"tar -xzf {bundle} -C {dest}\",\n  9785│        &keys,\n  9786│        Duration::from_secs(120),\n      │...\n 10022│    matches!(\n*10023│        spt_runtime::run_bounded_command(\n 10024│            \"gh auth status\",\n 10025│            &std::collections::BTreeMap::new(),\n 10026│            Duration::from_secs(10),\n      │...\n 10061│// [impl->REQ-UPDATE-GH-TRANSPORT]\n*10062│fn gh_status() -> GhStatus {\n*10063│    match spt_runtime::run_bounded_command(\n 10064│        \"gh auth status\",\n 10065│        &std::collections::BTreeMap::new(),\n 10066│        Duration::from_secs(10),\n      │...\n 10106│// [impl->REQ-UPDATE-GH-TRANSPORT]\n*10107│fn render_gh_auth_required() -> String {\n*10108│    \"UPDATE_FETCH_REJECTED:GhAuthRequired — the gh CLI is installed but not \\\n 10109│     authenticated. Run `gh auth login` with an account that can read the \\\n 10110│     release channel.\"\n 10111│        .to_string()\n      │...\n 10244│    {\n*10245│        let out = spt_runtime::run_bounded_command(\n 10246│            \"uname -m\",\n 10247│            &std::collections::BTreeMap::new(),\n 10248│            Duration::from_secs(5),\n      │...\n 10295│        keys.insert(\"script\".to_string(), script);\n*10296│        match spt_runtime::run_bounded_command(\n 10297│            \"powershell -NoProfile -NonInteractive -Command {script}\",\n 10298│            &keys,\n 10299│            Duration::from_secs(30),\n      │...\n 10559│\n*10560│/// Build the `gh api` command (template + keys) that prints `repo`'s latest\n 10561│/// release tag to stdout. Pure — the caller runs it bounded. The `gh` CLI honors\n 10562│/// OAuth + `GH_TOKEN`, so this reads a private repo with no token in spt's hands.\n 10563│// [impl->REQ-ADAPTER-GH-TRANSPORT]\n      │...\n 10565│    (\n*10566│        \"gh api repos/{repo}/releases/latest --jq .tag_name\".to_string(),\n 10567│        std::collections::BTreeMap::from([(\"repo\".to_string(), repo.to_string())]),\n 10568│    )\n 10569│}\n      │...\n 10619│            let (template, keys) = gh_download_command(repo, tag, asset, scratch_dir);\n*10620│            match spt_runtime::run_bounded_command(\n 10621│                &template,\n 10622│                &keys,\n 10623│                Duration::from_secs(300),\n      │...\n 10706│    ]);\n*10707│    match spt_runtime::run_bounded_command(\n 10708│        \"tar -xf \\\"{archive}\\\" -C \\\"{dest}\\\"\",\n 10709│        &keys,\n 10710│        Duration::from_secs(300),\n      │...\n 10977│    // [impl->REQ-UPDATE-GH-TRANSPORT]\n*10978│    match gh_status() {\n 10979│        GhStatus::Available => {}\n 10980│        GhStatus::Missing => {\n 10981│            eprintln!(\"{}\", render_gh_cli_required(std::env::consts::OS));\n      │...\n 10984│        GhStatus::Unauthed => {\n*10985│            eprintln!(\"{}\", render_gh_auth_required());\n 10986│            return 1;\n 10987│        }\n 10988│    }\n      │...\n 20954│    fn conduct(label: &str, template: &str) -> i32 {\n*20955│        match spt_runtime::run_bounded_command(\n 20956│            template,\n 20957│            &std::collections::BTreeMap::new(),\n 20958│            Duration::from_secs(300),\n      │...\n 21043│                    ]);\n*21044│                    match spt_runtime::run_bounded_command(\n 21045│                        \"git clone --depth 1 \\\"{url}\\\" \\\"{dest}\\\"\",\n 21046│                        &keys,\n 21047│                        Duration::from_secs(300),\n      │...\n 22010│\n*22011│    match spt_runtime::runtime::run_bounded_command_in(\n 22012│        &post.command,\n 22013│        &keys,\n 22014│        UPDATE_POST_TIMEOUT,\n      │...\n 22491│/// `repo`'s latest release `tag_name`, leading `v` stripped (e.g. `v0.2.0` →\n*22492│/// `0.2.0`). HTTPS reads the public GitHub API; `gh` runs `gh api …/releases/latest\n 22493│/// --jq .tag_name` (the private-repo path). (REQ-UPD-9, REQ-ADAPTER-GH-TRANSPORT)\n 22494│// [impl->REQ-UPD-9]\n 22495│// [impl->REQ-ADAPTER-GH-TRANSPORT]\n      │...\n 22520│            let (template, keys) = gh_version_command(repo);\n*22521│            match spt_runtime::run_bounded_command(\n 22522│                &template,\n 22523│                &keys,\n 22524│                Duration::from_secs(60),\n#### rc.rs\n 194│    ]);\n*195│    let output = spt_runtime::runtime::run_bounded_command(\n 196│        \"{exe} --version\", &keys, Duration::from_secs(2), None,\n 197│    ).ok()?;\n 198│    if !output.success() {\n\n#### api/\n##### auth.rs\n 490│    // [unit->REQ-HAZARD-SESSION-PIN-WEDGE] the token-auth path is UNCHANGED by the\n*491│    // dead-owner branch: a correct token authenticates via the token check (first),\n 492│    // never reaching the re-pin — so the pin is NOT rotated, even with a dead owner\n 493│    // and a mismatched sid alongside the token.\n 494│    #[test]\n    │...\n 499│        let proof = Proof::resolve(Some(token), Some(\"new-live-sid\".into()));\n*500│        assert_eq!(authenticate(\"alice\", &proof), AuthResult::Ok, \"token authenticates\");\n 501│        assert_eq!(\n 502│            recorded_sid(\"alice\"),\n 503│            \"old-dead-sid\",\n##### engineroom.rs\n 16│//! 3. the caller authenticates against that perch the way every other mutating\n*17│//!    api verb authenticates (capability token or recorded session id).\n 18│//!\n 19│//! Fact 3 is the one that carries the weight: an agent that merely knows the\n 20│//! reserved id gets nowhere without the perch's own proof.\n##### mod.rs\n 108│    /// `{link_token}` — \"owner from the link\") and flips it online. The\n*109│    /// credential IS the auth: no token, no bind.\n 110│    BindShell {\n 111│        /// The link token the broker minted at launch.\n 112│        #[arg(long = \"link\")]\n    │...\n 682│            // The local-link handshake's receiving end (CONTEXT §local-link\n*683│            // authentication): possession of the minted token is the bind\n 684│            // credential; the perch found by it names owner + instance.\n 685│            // [impl->REQ-SHELL-1]\n 686│            match spt_daemon::shellhost::bind_shell_by_token(\n##### startup.rs\n 824│    // Keep an existing token across a revive; mint one for a fresh perch.\n*825│    let token = auth::read_token(&perch_path).unwrap_or_else(auth::mint_token);\n*826│    auth::write_token(&perch_path, &token).map_err(|e| BindError::Io(e.to_string()))?;\n 827│    Ok(token)\n 828│}\n 829│\n##### worker.rs\n 234│        assert_eq!(rec.agent_type.as_deref(), Some(\"code-reviewer\"));\n*235│        // No token file — worker auth is sid-symmetric, no capability token.\n 236│        assert!(!w1.join(\"api.token\").exists(), \"workers mint no token\");\n 237│\n 238│        // The counter is monotonic: a second start under the same parent is w2.\n\n### tests/\n#### io_events_poll_e2e.rs\n 276│        bare.no_window()\n*277│            .args([\"--json\", \"api\", \"io-events\", author, \"--token\", \"not-a-real-token\"])\n 278│            .env(\"SPT_HOME\", home.path());\n 279│        let out = common::output_bounded(bare, Duration::from_secs(60));\n 280│        assert!(\n\n#### fixtures/\n##### gh_fixture.rs\n  5│//! The test copies it to `gh(.exe)` in a PATH-prepended dir, so every gh\n *6│//! subprocess spt-core shells (`gh auth status`, `gh api …/releases/latest`,\n  7│//! `gh release download …`) resolves HERE and the whole release channel is\n  8│//! served hermetically from a local fixture dir — no network, no real gh.\n  9│//!\n   │...\n 17│//!   - `gh auth status` → exit 0 (authed).\n*18│//!   - `gh api repos/<repo>/releases/latest --jq .tag_name` → print `tag.txt`,\n 19│//!     exit 0.\n 20│//!   - `gh release download [<tag>] --repo <r> --pattern <asset> --dir <d>\n 21│//!     --clobber` → copy `assets/<asset>` to `<d>/<asset>`; a missing asset →\n   │...\n 51│        Some(\"api\") => {\n*52│            // `gh api repos/<repo>/releases/latest --jq .tag_name`\n 53│            let tag = std::fs::read_to_string(root.join(\"tag.txt\"))\n 54│                .expect(\"fixture tag.txt (the latest release tag)\");\n 55│            println!(\"{}\", tag.trim());\n\n## spt-runtime/\n### manifest.schema.json\n 780│        \"transport\": {\n*781│          \"description\": \"`gh_release` avenue: the fetch **transport** — `https` (direct reqwest,\\npublic), `gh` (shell the pre-authorized `gh` CLI, the private-repo path),\\nor `auto` (default: prefer `gh` when installed+authed, else HTTPS). `gh`\\nhonors OAuth + `GH_TOKEN`, so spt-core custodies no token. Additive over\\nthe existing fetch path; not applicable to `delegated` / `file_pull`.\\nAbsent ⇒ `auto` (N-1-safe).\",\n 782│          \"$ref\": \"#/$defs/Transport\"\n 783│        }\n 784│      },\n    │...\n 834│        {\n*835│          \"description\": \"Shell the pre-authorized `gh` CLI (`gh release download` for the asset,\\n`gh api` for the version) — the private-repo path. `gh` honors OAuth +\\n`GH_TOKEN`, so spt-core never custodies a token.\",\n 836│          \"type\": \"string\",\n 837│          \"const\": \"gh\"\n 838│        },\n\n### src/\n#### lib.rs\n 31│pub use runtime::{\n*32│    resolve_program_in_dir, run_bounded_command, run_bounded_command_in, scrub_identity_env,\n 33│    AgentRuntime, BoundedOutput, ManifestRuntime, RuntimeError, Spawned, IDENTITY_ENV_VARS,\n 34│    INJECT_ECHO_ENV_VARS, TEST_TIMING_ENV_VARS,\n 35│};\n#### manifest.rs\n 825│    /// Shell the pre-authorized `gh` CLI (`gh release download` for the asset,\n*826│    /// `gh api` for the version) — the private-repo path. `gh` honors OAuth +\n 827│    /// `GH_TOKEN`, so spt-core never custodies a token.\n 828│    Gh,\n 829│    /// Prefer `gh` when it is installed and authenticated, else fall back to\n#### runtime.rs\n  881│        // shared builder — makes KNOWN-HAZARDS 5.8 hold for every ManifestRuntime\n *882│        // spawn, not just the free run_bounded_command leg.\n  883│        // [impl->REQ-HAZARD-CHILD-CONSOLE-FLASH]\n  884│        #[cfg(windows)]\n  885│        {\n     │...\n 1026│// [impl->REQ-HAZARD-SUBPROCESS-TIMEOUT]\n*1027│pub fn run_bounded_command(\n 1028│    template: &str,\n 1029│    keys: &BTreeMap<String, String>,\n 1030│    timeout: Duration,\n     │...\n 1032│) -> Result<BoundedOutput, RuntimeError> {\n*1033│    run_bounded_command_in(template, keys, timeout, stdin, None)\n 1034│}\n 1035│\n 1036│/// Resolve a command template's **program token** (first whitespace-split token)\n     │...\n 1067│\n*1068│/// As [`run_bounded_command`], but when `install_dir` is `Some`, the template's\n 1069│/// program token is first resolved against that adapter install dir\n 1070│/// ([`resolve_program_in_dir`]) so a bare program name binds to the adapter's\n 1071│/// shipped binary before `PATH` (REQ-INSTALL-11). `None` preserves the legacy\n     │...\n 1074│// [impl->REQ-HAZARD-SUBPROCESS-TIMEOUT]\n*1075│pub fn run_bounded_command_in(\n 1076│    template: &str,\n 1077│    keys: &BTreeMap<String, String>,\n 1078│    timeout: Duration,"},"isError":false,"timestamp":1790039404055}}
{"type":"message","id":"5ad089be","parentId":"4a09e301","timestamp":"2026-09-22T01:10:04.115Z","message":{"role":"toolResult","toolCallId":"call_CiUci15HRRIew3eqZ7SB1Bod|fc_06340275dc54e8ed016ab1d56be38887d1924197378a0873ce","toolName":"grep","content":[{"type":"text","text":"# CONTEXT.md\n 54|\n*55|spt-core is harness-independent: it does not know about Claude Code, Codex, Cursor, or any other agent runtime. All harness-specific surfaces (how to invoke an agent session, fetch conversation history for an echo commune, detect activity/idleness, etc.) are abstracted behind a runtime layer that consumers supply.\n 56|\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...\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...\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...\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...\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 148|**inject-input seam** — message delivery into a running session. Configurable per activity-state (activity / idle / both); multiple methods, any combination:\n 149|- PTY injection (with or without key/submit sequences) — spt-hosted topology;\n...\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...\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...\n 412|<!-- [doc->REQ-RCVIEW-1] [doc->REQ-KICK-1] [doc->REQ-VIEWER-SKIP-TO-LIVE-ON-EVICT] [doc->REQ-HAZARD-VIEWER-RING-ROLL-SNAP] -->\n*413|**BUILT (M12 W2.5).** The controller/viewer model is implemented end-to-end. Attach intent is **three-valued** (`AttachIntent = Viewer | Control | Take`, wire-default `Control`): `Control` to a FREE endpoint becomes controller; `Control` to a CONTROLLED endpoint is **refused with guidance** (`--view` to watch, `--take` to control) — never auto-viewer, never silent-displace; `Take` (`spt rc --take` / picker \"Kick\") kicks the incumbent with a **loud `Displaced{by}` notice** and full detach (not demote). ...\n 414|\n 415|**redispatch** (REDISPATCH-TRUTH, ADR-0038): the fresh brain's reconstruction of target-side attach workers over broker-held peer streams after a **brain cycle** (`spt daemon refresh` / `spt update` apply — the PTYs and QUIC streams survive; the brain-owned serve workers do not). Redispatch eligibility is **lifecycle-gated**: finished/terminal stream rows are retired from the dispatcher's enumeration (a fresh dispatcher never re-serves a terminal Attach — the KNOWN-HAZARDS 7.41 frozen-PTY steal/clear...\n 416|_Avoid_: conflating redispatch with msg dispatch or session-cursor resume; filtering replay by origin identity (the legitimate same-`by` successor re-take must keep working — lifecycle is the discriminator).\n...\n 511|adapter/flow **overrides** it (spt-claude-code → `cc`, giving `cc-<id>`) — the Claude-Code-ness\n*512|lives in the adapter, **spt-core never emits `cc`**. The basename must be a *distinct* token,\n 513|never bare `spt`: a `spt.cmd` wrapper would shadow the real `spt.exe` only under cmd.exe\n 514|(cwd-first search), silently no-op in PowerShell/Unix, and self-recurse — so `spt-<id>` is the\n 515|safe, consistent form. The launcher is the **current OS's native form**: a **`.cmd`** on\n...\n 566|- **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*567|- **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 568|- **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 569|- **\"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 570|- **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...\n 642|- **P2P (default):** the data syncs between a user's nodes over the built-in P2P networking — **no `gh`/account/setup**, the no-central-operator promise. Replaces modern SPT's required GitHub-remote sync.\n*643|- **Hub (opt-in, `spt context-github-setup`):** every node pull→merge→pushes against a private GitHub remote — an always-online sync hub *and* a convenient GUI view. Uses a **shared deploy key** distributed as subnet secret material, so only the setup node needs a GitHub account; other nodes use the key over SSH, with **P2P as backstop**. Hub mode is the **context-repo sync transport only** — messaging, registry, remote-drive, pairing, and presence always ride P2P; gh-on never removes the P2P req...\n 644|\n 645|**The gh-repo interim is retired** (M4-D9-6, after the two-host rig proof): during the pre-P2P era the sister project's `gh-repo-sync` skill (and the `spt-agent-storage` private repo wired by `psyche-sync-setup`) was *the* cross-machine context transport. With P2P sync proven end-to-end on real hardware (`docs/TWO-HOST-RUNBOOK.md`), that interim stands down — the sister skill is no longer part of the sync path. `psyche-sync-setup` remains only as the **hub-mode seam** (the opt-in transport above), not ...\n 646|\n...\n 681|\n*682|**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 683|\n*684|**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 685|\n 686|**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 687|_Avoid_: public docs hosting; binding beyond loopback; docs drifting from the installed version.\n 688|\n*689|**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 690|\n 691|**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 692|\n...\n 697|\n*698|**delivery** — peer-propagated over P2P, layered on self-fetch, with out-of-band still supported. One node learns of / obtains an update (marketplace drop *or* self-fetch from a release channel), gossips availability across the subnet, and peers pull the new binary over the networking layer — the subnet self-heals to latest. **All binaries are signature-verified before handoff regardless of source** (peer-propagation otherwise lets a compromised node poison the subnet). spt-core has its own release s...\n 699|\n 700|**handoff invariant** — **no endpoint process terminates or suspends during a self-update.** We cannot assume every endpoint can safely suspend. Satisfied by the broker/brain split: routine updates replace only the daemon brain, which rehydrates from disk state and re-attaches to the broker's held PTY/socket/child handles. Harness-hosted endpoints (topology 1) are safe by construction; spt-hosted endpoints (topology 2) are kept alive by the broker holding their PTY masters + child processes across the ...\n 701|\n...\n 707|\n*708|**debug rollout**:\n\n# PRD.md\n 130|- **R-NET-3** **Cross-node Psyche sync** over P2P **replaces** the GitHub-repo sync (no `gh`/account/setup).\n*131|- **R-PAIR-1** **TOTP-seeded SPAKE2 pairing** (ADR-0005): the rotating TOTP code is the PAKE password (not a bearer token). The seed is the subnet secret, held by the auth app + all trusted nodes; the user interacts only with the new node.\n 132|- **R-PAIR-2** Local trust store (TOFU + warn-on-change). Cross-subnet deferred.\n*133|- **R-PAIR-3** Fetch the current pairing code from any paired node (`spt pair --code` or via an agent).\n 134|- **R-PAIR-4** Prompt to **name the subnet** on first pairing; show the name on every subsequent pairing.\n*135|- **R-PAIR-5** (M4, ADR-0005 amend) **Multi-subnet pairing ceremony:** discovery takes **TOTP code + subnet-name** (the name namespaces the relay rendezvous) in all cases; **create-new names the subnet at creation** (no node-name mode); the relay rendezvous token is `H(subnet-name ‖ TOTP-epoch)` (under the hood — raw name+code at the surface; payload already E2E-encrypted).\n*136|- **R-PAIR-6** (M4, ADR-0005 amend) **Elevation-gated per-subnet code fetch:** retrieving a subnet's current code *from a node* requires OS privilege elevation (Windows UAC / Linux root-or-equivalent) or an elevated agent endpoint; otherwise the user falls back to their authenticator-app store. Fetch is **per-subnet** (`--subnet <name>` / `--create-new`), QR/`otpauth://` optional.\n 137|- **R-PAIR-7** (M4 data / frontend render) **Subnet icon:** an optional small image (square PNG/WebP, ≤256 KB) representing the subnet in GUIs; stored **inline** in subnet metadata (syncs over the same subnet-material channel as the name), editable by any node in the subnet, GUI-only consumer.\n 138|\n 139|### 10b. Endpoint access control (ADR-0009)\n...\n 153|\n*154|- **R-UPD-1** **Peer-propagated** over P2P (layered on self-fetch; out-of-band still supported); the subnet self-heals to latest (ADR-0004).\n 155|- **R-UPD-2** **All binaries signature-verified before handoff**, regardless of source (spt-core release key).\n 156|- **R-UPD-3** **No-endpoint-termination invariant, scoped by update class (ADR-0004 §A).** For the **brain-only** class — the routine case — the guarantee is **absolute**: no endpoint process terminates or suspends, satisfied by the broker/brain split (the broker keeps held PTYs/children/sockets alive across brain replacement). **broker-compatible** swaps the broker behind the versioned IPC with endpoints still surviving. **broker-breaking** (held-resource-type / OS-API change) MAY suspend endpoints...\n 157|- **R-UPD-4** **Not auto by default** — gated on user confirmation delivered to the most-recently-active live session, with an opt-in full-auto.\n\n# ROADMAP.md\n 30|  - **M2a — Harness contract. ✅ delivered (2026-06-01).** `spt-runtime` (AgentRuntime, ManifestRuntime, manifest schema) + the `api` subcommand surface (seed/listen/bind/state/echo-gate/poll/worker-*/boundary/session-end/presence/history-log/emit/capability) + the **harness-hosted** startup topology (seed→listen→bind, interim seed-file) + local-api-auth + the generic mock adapter + contract E2E. All M2a reqs activated + green. Task breakdown: [`M2a-PLAN.md`](./M2a-PLAN.md).\n*31|  - **M2b — Live-agent lifecycle. ✅ delivered (2026-06-01).** `spt-live` (fifth layer): history subsystem (fetcher/locate-normalize/native) + spawn-psyche (nested perch, spt-core-owned `$psyche_prompt`) + echo-commune (history brief → single-writer commune drop) + drop-file ingest (single-writer 6.4 + direct-write precedence 6.5) + interim pulse + signoff/boundary ordering invariants (echo-before-signoff 3.3, grace-before-signoff 1.1, stale-signoff sweep 3.2) + resume seam (continue-existing / fres...\n 32|  - **Interim, no daemon** (like M1): the `api listen` process holds the perch + relays; the seed bridges via a file. **The spt-hosted topology (PTY-launch) + the consolidated `spt-daemon` move to M3** (they need the `spt-term` broker) — this narrows the earlier \"both topologies in M2\" wording.\n 33|- **M3 — Terminal wrapper + daemon + self-update.** `spt-term` (session-surface, PTY, broker) + the consolidated `spt-daemon` (broker/brain split) + peer-propagated signed self-update. Split M3a/M3b/M3c. Scope + carried-forward Spike #1 gaps: [`M3-PLAN.md`](./M3-PLAN.md).\n 34|  - **M3a — `spt-term` session-surface mechanism. ✅ delivered (2026-06-02).** The sixth crate (`…→spt-msg→spt-term`, sibling of spt-runtime): the `SessionSurface` trait + native PTY backends (ConPTY/forkpty via `portable-pty`, REQ-TERM-1) · ConPTY-DSR auto-answer drain pump (REQ-HAZARD-CONPTY-DSR) · send-keys/send-line injection (REQ-TERM-2) · bounded byte-stream (REQ-TERM-3) · the PTY-digest parser primitive (REQ-TERM-4 impl/unit, ADR-0008). OS-neutral per Spike #4/#5 (no OS's stream contr...\n 35|  - **M3b — `spt-daemon` broker/brain process. ✅ delivered (2026-06-03).** The seventh crate (`…→spt-live→spt-daemon→spt`) hosts many `spt-term` surfaces behind a **versioned local IPC** (named-pipe/UDS behind one `DaemonTransport` trait, forward-compatible frame schema): a **broker** (stable kernel — PTY masters, harness children, sockets; no logic) + a **brain** (restartable logic) so a brain kill/restart leaves the hosted PTY child + its output stream **intact and gapless** (Spike #1 mad...\n*36|  - **M3c — signed self-update. ✅ delivered (2026-06-03).** The gated, signed, ripple-capable self-update engine (no new crate — it lives in `spt-daemon`, signing primitives in `release.rs`): the **update-class taxonomy** (brain-only / broker-compatible / broker-breaking, ADR-0004 §A) with the **brain-only zero-interruption swap** that drives the M3b handoff substrate (snapshot→drop→re-attach) so a live PTY child + its output stream survive the logic swap **untouched** (REQ-UPD-3, proven E2E `...\n 37|  - **M3 COMPLETE** (M3a + M3b + M3c all ✅, 2026-06-03): the real ADR-0004 architecture is live — native PTY hosting, the consolidated broker/brain daemon, and seamless signed self-update. Next milestone: M4.\n*38|- **M4 — Networking + instances. ✅ delivered (2026-06-04).** The eighth crate `spt-net` (Iroh WAN endpoint bound to the node's own Ed25519 identity, mDNS LAN discovery, TOTP-SPAKE2 pairing over a dedicated pre-trust ALPN with transcript binding / rate limiting / seed rotation+transfer, subnet registry + NDJSON replication under the per-node epoch lease) · the multi-instance model (registry rows + bare-id resolution refuse-and-qualify + visibility/sync-membership gates + rename/collision + **resting-...\n*39|- **M5 — Shells, presence, deferred capabilities. ✅ delivered (2026-06-04;** scope locked with user 2026-06-04, see [`M5-PLAN.md`](./M5-PLAN.md) + per-task JIT plans D0–D9). The consent framework seam (grant store + interactive escalation + pre-consent flags; remote-exec / instantiate-anywhere reserved-but-refusing) · adapter registration lifecycle (`spt adapter add/remove`, manifest-first) · **shell hosting machinery + sleep/wake** (link-token channels with per-frame MAC, broker-launched binarie...\n 40|\n 41|## Post-v1-core milestones\n 42|\n...\n 46|\n*47|- **✅ Broker/brain process-isolation restoration — delivered (2026-06-11).** Design: [`docs/BROKER-BRAIN-SPLIT-RESTORATION.md`](./docs/BROKER-BRAIN-SPLIT-RESTORATION.md) (verified-with-amendments by `doyle`); decision: **ADR-0018**; build plan: [`RESTORATION-PLAN.md`](./RESTORATION-PLAN.md) (D1–D7). Corrects an unintended regression — the broker/brain split (ADR-0004) was specced + spiked as two *processes*, but the production daemon runs the broker as an in-process *thread* (`daemon.rs:165-170`)...\n 48|\n 49|- **🔜 NEXT milestone (planned 2026-06-12) — M9: Adapter customization & session surfaces.** Product of the two-day **Gateway grill** (operator + `doyle`; CONTEXT.md mints @ `8dc22ff`/`bd469b9`; ADR-0008 amended; the lecturn device design parked in the sibling `spt-lecturn/` repo for a later build). Shell-free, independently-shippable features: **adapter profiles** (sparse leaf-replace overlays on the parent manifest; shipped + local sources, local surviving adapter updates; composite `<adapter>:<pro...\n 50|- **🔜 M10 — Digest extractor & thread-spanning** (planned 2026-06-13; NEXT). Product of the digest grill (operator + `doyle`); governed by **ADR-0019** (reverses M9's \"no manifest seam\"), CONTEXT.md \"session digest\" rewritten. An `spt-claude-code` enabler, so it takes the M10 slot ahead of the Shell/remote-attach work (which shift to M11/M12). Adapter-declared **`[digest]` extractor seam** (imperative, no DSL; same source files as `[history]` by default + own-source escape; `api digest-entry` push f...\n\n# docs/\n## KNOWN-HAZARDS.md\n 126|- **Source:** spt-core field incident 2026-07-27 (emphasys endpoint + spool loss); ruled by doyle, with emphasys's mid-create-race amendment revoking the empty-dir self-heal.\n*127|- **Amendment — the perch GC inherits this reasoning EXTENDED, never carved out (doyle, 2026-08-04, releases#109).** A second consumer now deletes perch directories: `spt endpoint gc` (3.5, `REQ-PERCH-GC-RESIDUE-PREDICATE`). Its authority is something `ring` never had — a **positive** statement that the directory carries **no endpoint record at all**, from the store that answers endpoint existence on a node (the owlery tree itself; the perch dir plus its parseable `info.json` IS the endpoint record)....\n 128|<!-- [doc->REQ-HAZARD-RING-PERCH-ADOPTION] -->\n 129|\n 130|### 3.5 `is_perch_alive` is INVERTED on perch residue — a GC must never key on it\n...\n 202|- **Source:** M7 acceptance run 2026-06-06 (DEFERRED.md \"Ghost registry row eviction\"); the AMBIGUOUS render fix rode along.\n*203|- **Mesh note (ADR-0017, 2026-06-08):** the subnet mesh **preserves** this invariant rather than superseding it. \"No transitive gossip\" sharpens to **no transitive *row* gossip** — the mesh relays only the member *roster* (discovery), while registry **rows stay own-authored and are fetched directly** from each member over a handshake. So \"any future update for a node comes from that node itself, alive\" still holds and the eviction lease is untouched. (The plan's rejected alternative — signed transiti...\n 204|\n 205|### 4.11 Advertisement-epoch reset strands a node  `[REQ-HAZARD-EPOCH-RESET]`\n 206|<!-- [doc->REQ-HAZARD-EPOCH-RESET] -->\n...\n 254|\n*255|### 5.7 Elevated commands spawn the daemon with the wrong token  `[REQ-HAZARD-ELEVATED-DAEMON-SPAWN]`\n 256|<!-- [doc->REQ-HAZARD-ELEVATED-DAEMON-SPAWN] -->\n*257|- **Failure:** membership-implies-reachability made *every* `spt` invocation a potential daemon spawner (`ensure_running`), including the elevation-gated ones (`subnet create`/`join`, REQ-SUBNET-4). The spawned daemon inherits the spawner's token. **Windows:** an elevated `subnet create` auto-starts an ELEVATED daemon whose named pipes deny unelevated clients — every subsequent unelevated `spt` reads \"not running\", tries to spawn its own daemon, and dies on bind Access-denied; the user had to taskkill ...\n*258|- **Invariant:** the daemon **always runs unelevated in the invoking user's universe**, regardless of which command spawns it. Two enforcement points sharing one seam: (a) `spawn_detached` de-elevates the child — Windows: the UAC **linked token** (`TokenLinkedToken` → `DuplicateTokenEx` → `CreateProcessWithTokenW`; inherits no handles, so 5.6 holds by construction); Linux: drop to `SUDO_UID`/`SUDO_GID` with `$HOME`/`$USER`/`$LOGNAME` reset to the invoking user's (passwd lookup); (b) a `Daemon::run`...\n 259|- **spt-core mapping:** `spt-daemon::deelevate` (the OS-split seam) consumed by `daemon::spawn_detached` + the `Daemon::run` entry guard. The fuller Linux elevation model (install symlink + default-account election) is deferred (DEFERRED.md, M8).\n 260|- **Source:** M7 acceptance run 2026-06-06 (DEFERRED.md \"Non-admin daemon spawn\"); interim field rule was \"bring the daemon up unelevated FIRST\".\n 261|\n...\n 264|- **Failure:** the daemon runs DETACHED (no console, 5.6/`detached_no_inherit`). Any console-subsystem child it spawns (`git`, `taskkill`, manifest hook commands) gets a **fresh conhost with a visible window** — piped/null stdio does NOT prevent it. Field shape: the 60s sync pump's two git calls (`for-each-ref` + `rev-parse`) flashed two blank windows per minute on the user's desktop (2026-06-06).\n*265|- **Invariant:** every short-lived console child spawned from daemon-reachable code sets `creation_flags(0x0800_0000)` (`CREATE_NO_WINDOW`). Long-lived detached children use `detached_no_inherit` (already `DETACHED_PROCESS | CREATE_NO_WINDOW`); de-elevated spawns use `CREATE_NEW_CONSOLE + SW_HIDE` (5.7 — `CreateProcessWithTokenW` rejects `CREATE_NO_WINDOW`, error 87).\n 266|- **Test seam caveat:** window-absence is unobservable from a consoled test runner — the child inherits the runner's console and never creates a window, flag or no flag. Unit coverage asserts the flagged spawn still works (the error-87 \"flag combo breaks spawn\" regression class); window-absence was verified live by process-watch capture.\n 267|- **spt-core mapping:** `spt-store::gitrun::run_git` (every BranchStore/ContextStore git call), `spt-daemon::shellhost::kill_shell_pid` (taskkill), `spt-runtime::run_bounded_command` (manifest hook commands), `spt-runtime::ManifestRuntime::command_for` (the one shared builder behind `spawn_session` + `run_bounded_stdin` — the notif pump's `spawn_notif_command` and the live agent's psyche/echo/turn spawns), `spt-daemon::shellwake` (already guarded). The flag lives in each shared builder, not per call si...\n 268|- **Source:** spt-core field bug, 2026-06-06 — two blank windows flashing every 60 seconds on a desktop workstation, caught by process-spawn watcher (git.exe parent=spt daemon, conhost.exe child each).\n...\n 286|- **Failure class:** a privilege-gated command (`subnet create` / `join` / `show-code`) self-elevates by re-launching itself with privilege (Windows UAC `runas`, Linux `pkexec` / a terminal-emulator `sudo`, or inline `sudo`). A careless re-launch is a security hole: widening the privilege scope (adding args), resolving the binary by a bare name (a PATH/`secure_path` hijack runs an attacker's `spt`), interpolating a crafted arg into a shell string (`sh -c \"… $id …\"` injects a second command), or re-el...\n*287|- **Invariant:** self-elevation re-runs the **EXACT** original invocation with the binary's **ABSOLUTE** exe path — never adding/altering args, never a PATH-resolved bare name, never a shell-interpolated string. Every launcher passes an **argv array** (`Command::new(prog).args([...])`, never `sh -c`); the Windows `ShellExecuteW` params string (which is inherently one string) MSVC-quotes each verbatim arg so `CommandLineToArgvW` round-trips it as a single token. The elevated child drops state back to th...\n*288|- **Test seam caveat:** the real launch needs a UAC/polkit/sudo prompt (not hermetic) — manual-verify. The deterministic guards are the pure `decide_elevation_path` matrix (loop-safety: `AlreadyElevated` on every os; the os×env path order) and the argv builders (`sudo_argv` / `pkexec_argv` / `terminal_argv` assert absolute-exe + verbatim args + array; `windows_runas_params` asserts MSVC-quoting with no `cmd /c` interpolation; the crafted-arg test asserts a shell-metachar arg stays one element / one qu...\n 289|- **spt-core mapping:** `spt::elevation::{decide_elevation_path, sudo_argv, pkexec_argv, terminal_argv, windows_runas_params, print_hint_command, ElevatePath}` (pure), `spt::cli::{try_auto_elevate, launch_uac_window, pause_elevated_console_if_fresh, program_on_path, first_terminal_emulator}` (impure launchers) wired into `cmd_subnet_create` / `cmd_subnet_join` / `cmd_subnet_show_code`. Companions: 5.10 (the Unix abs-path-under-sudo facet) and 5.7 (the elevated child's de-elevation drop, which this compos...\n 290|- **Source:** M12-W4 design (subnet QR + self-elevating window), doyle ruling `M12-W4-RULING.md` Q6 — a privilege-escalation feature carries a mandatory hazard REQ.\n 291|\n...\n 477|- **Failure:** any local process calls `spt api bind|state|session-end|history-log|poll` and binds, ends, injects, or spoofs the state of an endpoint it does not own. Local untrusted processes are explicitly in scope (shells, third-party adapters).\n*478|- **Invariant:** every `api` *mutation* is authenticated to an endpoint/session — per-endpoint link token or OS-credential binding. An unrelated local process cannot bind, inject, end, or spoof state.\n 479|- **spt-core mapping:** the `api` subcommand surface (PRD R-API-*) + broker IPC.\n 480|- **Source:** codex Stage A #13 (`docs/reviews/STAGE-A-codex-redteam.md`).\n 481|\n...\n 533|renewed only by data on the matching stream) and are EXCLUDED from the audit's\n*534|fixed-deadline policy controls by record: a tokenizer cannot prove that a guarded\n 535|re-arm is the only renewal, and a false assurance is worse than a named exclusion.\n 536|The exclusion does not imply the renewal behaviour is tested (register follow-up).\n 537|\n...\n 562|- **Invariant:** both daemon spawn paths request `CREATE_BREAKAWAY_FROM_JOB` (0x0100_0000), best-effort with an in-job FALLBACK on `ERROR_ACCESS_DENIED`(5) / `ERROR_INVALID_PARAMETER`(87) so a breakaway-denying job NEVER regresses the spawn. **A real job CAN deny breakaway** (proven: the cargo/CI runner's own ancestor job ACCESS_DENIES it), so efficacy against the real terminal is UNKNOWN until measured. **int stage = OPERATOR MANUAL ACCEPTANCE, not CI** (doyle ruling 2026-06-18): the runner sits inside ...\n*563|- **spt-core mapping:** `daemon.rs::detached_no_inherit` + `deelevate.rs::create_with_token` (the breakaway flag + fallback). Units: `detached_no_inherit_falls_back_under_a_breakaway_denying_job` (no-regression — the shared runner exercises the fallback directly), `breakaway_spawn_escapes_a_kill_on_close_job` (the OS escape mechanism — self-SKIPS where the ancestor job forbids breakaway). Unix: the daemon's `setsid` session-detach already keeps a closing terminal's SIGHUP off its children (guard test...\n 564|- **Source:** v0.12.0 real-harness defect (operator) → v0.12.1 L1 @5ae68f8; doyle design+int ruling 2026-06-18.\n 565|\n 566|### 7.11 A dead PTY child + a dropped operator pump must NOT wedge the broker for other clients  `[REQ-HAZARD-ATTACH-WEDGE]`\n...\n 634|- **Invariant:** on a single brain↔broker connection exactly ONE `controller_writer` is ever the LIVE writer; a SUPERSEDED writer writes no further frames after the epoch bump it observes; and **every `controller_writer` emits a strictly ASCENDING seq stream** (sorted initial batch + ascending live frames). The CORRECTNESS guarantee that falls out: a snap-above consumer over any interleaving of ascending writers — where the surviving writer (`serve_attach`'s `attach_as(sid,0)`) offers the COMPLETE ra...\n*635|- **spt-core mapping:** impl `brain.rs` `Brain::handoff` (KEEP the eager `subscribe`; seed `session_cursors`) + `Brain::subscribe_with` (resume-mode dedup-cursor reset to `from_seq`, shared by `attach`/`attach_as` — the operator-stream boundary fix) + `broker.rs` `OutputLog.controller_epoch: Arc<AtomicU64>` / `become_controller` (atomic `fetch_add`, passes the new epoch + `Arc::clone` into the writer) / `controller_writer` (epoch gate read UNDER the lock on both loops) / `mark_controller_gone` + the `C...\n 636|- **Source:** v0.13.0 P1c (operator-ruled root-fix before ship; doyle root-cause via instrumented repro 2026-06-20; design corrected across two gate rounds — fix #1 reverted, then the kitsubito RACEDIAG pinned the residual to the consumer-side operator-stream boundary, fixed by the `subscribe_with` cursor reset on `attach_as` re-subscribe). The last v0.13.0 ship-blocker.\n 637|\n 638|### 7.22 An idle delivery with no working translation binary must SPOOL, never raw-inject a pseudo-delivery reported as delivered  `[REQ-HAZARD-IDLE-SILENT-NONDELIVERY]`\n...\n 676|- **Failure (F-024C/F-024D, ENLYZEAM field + clean-room repro 2026-07-02):** `authenticate()` (auth.rs) gates `api poll`/`state`/`boundary` on `proof.session_id == info.json.session_id`. If ONE boundary rotation is LOST — the departing session dies (crash / tab-close) or its `/clear`-era `boundary` call never lands — the perch stays PINNED to the dead sid, and every id-scoped hook call thereafter AUTH_REFUSES *including `boundary` itself* (the very call that would re-pin presents the NEW sid). A perm...\n*677|- **Invariant:** `authenticate()` gains a DEAD-OWNER fallback — when the caller's sid MISMATCHES the pin **AND** the perch's recorded owner pid is DEAD (`proc::is_process_alive == false`), ACCEPT the caller's sid and RE-PIN (rotate `info.json.session_id` via the locked `mutate_info` RMW + a LOUD `SESSION_REPIN` stderr line). Same trust model as `establish_perch`'s conflict gate, which already allows a rebind exactly when `owner_alive == false` (an orphaned perch accepts a new LOCAL owner). This is **AD...\n*678|- **spt-core mapping:** impl `spt/src/api/auth.rs` `authenticate` dead-owner-mismatch branch (`read_pid` → `proc::is_process_alive` → `mutate_info` rotate + `SESSION_REPIN` log); unit `auth.rs` `pinned_to_dead_sid_mismatched_poll_repins` (AUTH_REFUSED → drains+re-pins, RED pre-fix) + `live_owner_mismatch_still_refuses` (the `/clear` live-rotation squat guard, unwidened) + `token_auth_path_unchanged` (correct token wins first, no rotation).\n*679|- **Source:** F-024C/F-024D (perri field RCA on ENLYZEAM + doyle code-confirm 2026-07-02; the wedge latches on `/clear` even in a CLEAN env — the env-corruption domino was sufficient but not necessary). COVERAGE: dead-owner re-pin rescues CRASHED/DEAD owners ONLY; live-pid rotation is correctly refused without departed-session proof. Cross-refs ADR-0032 (the 3-layer rotation-credential model) + `[REQ-BOUNDARY-ROTATION-CREDENTIAL]` (layer 1, the adapter/harness-contract half).\n 680|<!-- [doc->REQ-HAZARD-SESSION-PIN-WEDGE] -->\n 681|\n 682|### 7.26 Concurrent first-touch of ONE fresh BranchStore must ALL succeed — a non-atomic `git init` race must never strand a first-toucher  `[REQ-HAZARD-STORE-INIT-RACE]`\n...\n 994|- **Instrument-soundness kin (distinct, do not merge):** a *gate* whose failure signature is indistinguishable from the fault it guards is broken in the same family, and instrument tests are never requirement evidence (see the §7 note on the spawn rigs). This entry is about a PRODUCTION monitor's reported state; that one is about a TEST's verdict.\n*995|- **spt-core mapping:** `spt-daemon/src/pump/health.rs` — `PumpHealth::verdict` (the failing set outranks the live count; `DegradedPartial` requires a live peer so nothing-live stays `Connecting`), `any_fail_since_ms` + `recompute_all_fail` (the window one peer's success cannot close), `note_connected` (closes windows through the shared recompute, never by hand); `spt/src/cli.rs` `render_peer_health_line` + `peer_health_verdict_token` (the `degraded-partial` surfaces). **releases#75 addendum:** a third...\n 996|- **Source:** WAN-TRUTH batch #50 lane 3 (issue #41; doyle-ruled 2026-07-31 after the class appeared twice in one batch). Twin `REQ-PEER-HEALTH-PARTIAL-DEGRADE` (the positive requirement that repairs instance 2). Kin `REQ-PUMP-STAGE-TRUTH` (the total-failure fingerprint and per-stage attribution, which instance 1 produced and this does not change).\n 997|\n 998|### 7.62 A new wire record carries a `kind` tag — the untagged shape is already claimed, and an older daemon delivers it as chat  `[REQ-HAZARD-WIRE-UNTAGGED-RECORD]`\n...\n 1142|<!-- [doc->REQ-HAZARD-ADAPTER-APPLY-SILENT-NOOP] -->\n*1143|| 7.25 | A perch pinned to a DEAD session self-heals; a LIVE-owner rotation still refuses. `authenticate()` (auth.rs): sid mismatch AND recorded owner pid DEAD (`proc::is_process_alive`) → accept caller sid + RE-PIN (`mutate_info` rotate `session_id` + LOUD `SESSION_REPIN`) — ADR-0032 layer 2, same trust as `establish_perch`'s dead-owner rebind. LIVE-owner mismatch (a `/clear`/`/compact` live-pid rotation) STILL refuses (squat, unwidened — live-rotation proof is the adapter's layer-1 job, `[REQ-BOU...\n 1144|<!-- [doc->REQ-HAZARD-SESSION-PIN-WEDGE] -->\n 1145|| 7.26 | Concurrent first-touch of ONE fresh BranchStore all-Ok — the non-atomic `git init` (template-hook copy) + `config.lock` race must never strand a first-toucher. `open_or_init` → `init_bare_tolerant` (bounded backoff-retry on the init collision that desyncs racers + open-after-lose on a racer-created HEAD) + `config_set_locked_retry` (bounded `config.lock`-aware retry; `core.autocrlf` idempotent). Convergence guaranteed: a lone re-init on a partial dir completes. Fixes the G3-gate pump.rs:442 ...\n 1146|| 7.27 | A control/viewer stamp never outlives its session — every teardown path clears what attach stamped. `/exit` kills the CHILD (endpoint suspends on child death) so the teardown runs the REAP path (exit-waiter → `sessions.remove`), never the controller-detach path — the only pre-fix stamp-clear. The exit-waiter now calls `OutputLog::stamp_reaped()` = `set_driven_by(None)`+`set_controlled(false)`+`set_viewer_count(0)` on reap (broker single-writer, unconditional/idempotent, race-free — dead ...\n\n## adr/\n### 0002-bake-networking-into-core-collapse-spt-node.md\n 11|- **Default stays P2P** + no-account — the core promise is unchanged.\n*12|- **Hub mode (opt-in):** every node pull→merge→pushes against a private GitHub remote (always-online sync hub + live GUI view), authed by a **shared deploy key** distributed as subnet secret material (only the setup node needs a GitHub account). **Prefer-GitHub, P2P backstop.**\n 13|- **Scope:** hub mode carries the **context repo only**. Messaging, registry, remote-drive, pairing, and presence always ride P2P/Iroh; gh-on never removes the P2P requirement. So the original decision (networking baked-in, first-class, P2P) stands in full — the hub is a context-sync convenience layered on top, not a return to GitHub-as-infrastructure.\n 14|\n 15|## Context\n### 0003-multi-instance-node-anchored-identity-model.md\n 48|- **#8 — `local → most-recently-active → id@node` is ambiguous under partition/clock-skew.** Can route messages or remote-control to the wrong instance around active/dormant transitions. **Required:** registry lease/epoch using monotonic per-node epochs, not wall-clock; chaos-test stale entries + ambiguous active instances.\n*49|- **#9 — dormant-warm default has real resource/security cost.** Warm sessions retain credentials, file locks, model/billing footprint, PTYs, watchers on *every* node. **Required:** measure N dormant sessions on real adapters; define an adapter-specific warm/cold policy + resource budget before locking warm as the default (R-INST-3).\n 50|  **CLOSED 2026-06-04 (M4-D9-3):** measured with the env-gated harness (`crates/spt-daemon/tests/budget.rs`, `SPT_BUDGET=1`) on the rig — an idle warm seat burns **zero CPU**; the cost is RSS only (~8 MiB shell-class, ~300 MiB LLM-harness-class; suspended residual = the on-disk record). Policy locked in `docs/DORMANCY-BUDGET.md`: warm default **confirmed**, auto-suspend opt-in default OFF with the global → node → endpoint `auto_suspend_after_ms` knob chain (D9-2), constrained nodes default the node...\n### 0004-single-daemon-broker-brain-split-and-self-update.md\n 26|\n*27|**Self-update delivery:** peer-propagated over P2P, layered on self-fetch, out-of-band still supported. All binaries signature-verified before handoff (spt-core's own release key) regardless of source — peer-propagation otherwise lets one compromised node poison the subnet. spt-core conducts updates for the whole stack: self first, then ripple-update each registered adapter via the adapter manifest's update declaration (file-pull or delegated command). The plugin's role shrinks to initial bootstrap only.\n 28|\n 29|**Cadence/consent:** not fully automatic by default; gated on user confirmation delivered to the most-recently-active live session, with an opt-in full-auto choice.\n 30|\n### 0005-totp-seeded-spake2-node-pairing.md\n 10|\n*11|A natural-seeming option is TOTP: the user provisions a seed into an authenticator app and pairs future nodes with the rotating 6-digit code. Used the obvious way (a new node sends the code to a trusted node for verification), this is insecure: the code is a ~20-bit bearer token, replayable within its 30s window, not bound to the key being exchanged — a real-time MITM can replay the code with their own pubkey and join the subnet. It also performs no key exchange, requiring a separate unbound handshake.\n 12|\n 13|## Decision\n 14|\n*15|Adopt **TOTP-seeded SPAKE2 pairing**: keep the familiar TOTP UX but use the 6-digit code as the **password for a SPAKE2 (PAKE) handshake**, not as a verified bearer token.\n 16|\n*17|- The TOTP **seed** is the durable subnet secret: generated on the first node, shown as a QR for the user's authenticator app, and replicated to every already-trusted node.\n 18|- To pair a new node, a trusted node (online, no human — it holds the seed) computes the current code as its SPAKE2 password; the user reads the code off their phone and types it into the new node as its password. Matching codes → PAKE succeeds → pubkeys exchanged + bound, MITM-resistant encrypted channel.\n 19|\n*20|PAKE is the correct primitive for a low-entropy human-transcribed secret: it resists offline brute force (one online guess per attempt) and binds the code to the established key. Cryptographic strength comes from the PAKE construction, not from the 6 digits being secret.\n 21|\n 22|Rejected alternatives:\n*23|- **TOTP-as-bearer-token** — replayable, unbound, MITM-vulnerable (above).\n 24|- **Plain Magic Wormhole / fresh-code-each-time** — secure, but needs a human/relay at *both* ends each pairing. TOTP-seeded PAKE keeps the persistent seed on online trusted nodes so the user interacts only with the new node.\n 25|- **Paste-the-key** — too error-prone for \"just works.\"\n 26|- **Account-anchored auto-trust** — reintroduces the account dependency the product is shedding.\n...\n 36|- Adds a SPAKE2 + TOTP dependency to the core crypto surface (both have mature Rust crates per the research brief).\n\n[Showing lines 1-261 of 448 (49.9KB limit). Use :262 to continue. Some lines truncated to 512 chars]"}],"details":{"scopePath":"CONTEXT.md, docs/KNOWN-HAZARDS.md, PRD.md, ROADMAP.md, docs/adr","searchPath":"C:\\Users\\decid\\Documents\\projects\\spt-core","cwd":"C:\\Users\\decid\\Documents\\projects\\spt-core","matchCount":100,"fileCount":20,"files":["CONTEXT.md","docs/KNOWN-HAZARDS.md","PRD.md","ROADMAP.md","docs/adr/0002-bake-networking-into-core-collapse-spt-node.md","docs/adr/0003-multi-instance-node-anchored-identity-model.md","docs/adr/0004-single-daemon-broker-brain-split-and-self-update.md","docs/adr/0005-totp-seeded-spake2-node-pairing.md","docs/adr/0006-multi-subnet-membership-model.md","docs/adr/0009-endpoint-access-control-model.md","docs/adr/0015-release-signing-two-key-trust-anchor.md","docs/adr/0016-platform-targeted-update-sets.md","docs/adr/0017-subnet-mesh-membership-seed-proof.md","docs/adr/0020-event-envelope-sole-arriving-format-reply-to-removed.md","docs/adr/0021-harness-hosted-adapter-agnostic-seed-and-bind-time-resolution.md","docs/adr/0023-always-on-endpoints-resident-supervised-binary-sigil-addressing.md","docs/adr/0024-multi-platform-adapter-spt-packaging.md","docs/adr/0025-live-daemon-coordinated-adapter-update.md","docs/adr/0029-adapter-update-arc-resolve-not-execute.md","docs/adr/0030-robust-wan-subnet-join.md"],"fileMatches":[{"path":"CONTEXT.md","count":20},{"path":"docs/KNOWN-HAZARDS.md","count":17},{"path":"PRD.md","count":5},{"path":"ROADMAP.md","count":5},{"path":"docs/adr/0002-bake-networking-into-core-collapse-spt-node.md","count":1},{"path":"docs/adr/0003-multi-instance-node-anchored-identity-model.md","count":1},{"path":"docs/adr/0004-single-daemon-broker-brain-split-and-self-update.md","count":1},{"path":"docs/adr/0005-totp-seeded-spake2-node-pairing.md","count":12},{"path":"docs/adr/0006-multi-subnet-membership-model.md","count":4},{"path":"docs/adr/0009-endpoint-access-control-model.md","count":1},{"path":"docs/adr/0015-release-signing-two-key-trust-anchor.md","count":1},{"path":"docs/adr/0016-platform-targeted-update-sets.md","count":16},{"path":"docs/adr/0017-subnet-mesh-membership-seed-proof.md","count":1},{"path":"docs/adr/0020-event-envelope-sole-arriving-format-reply-to-removed.md","count":2},{"path":"docs/adr/0021-harness-hosted-adapter-agnostic-seed-and-bind-time-resolution.md","count":1},{"path":"docs/adr/0023-always-on-endpoints-resident-supervised-binary-sigil-addressing.md","count":1},{"path":"docs/adr/0024-multi-platform-adapter-spt-packaging.md","count":3},{"path":"docs/adr/0025-live-daemon-coordinated-adapter-update.md","count":4},{"path":"docs/adr/0029-adapter-update-arc-resolve-not-execute.md","count":1},{"path":"docs/adr/0030-robust-wan-subnet-join.md","count":3}],"truncated":true,"fileLimitReached":20,"perFileLimitReached":20,"displayContent":"# CONTEXT.md\n  54│\n *55│spt-core is harness-independent: it does not know about Claude Code, Codex, Cursor, or any other agent runtime. All harness-specific surfaces (how to invoke an agent session, fetch conversation history for an echo commune, detect activity/idleness, etc.) are abstracted behind a runtime layer that consumers supply.\n  56│\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    │...\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    │...\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    │...\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    │...\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 148│**inject-input seam** — message delivery into a running session. Configurable per activity-state (activity / idle / both); multiple methods, any combination:\n 149│- PTY injection (with or without key/submit sequences) — spt-hosted topology;\n    │...\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    │...\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    │...\n 412│<!-- [doc->REQ-RCVIEW-1] [doc->REQ-KICK-1] [doc->REQ-VIEWER-SKIP-TO-LIVE-ON-EVICT] [doc->REQ-HAZARD-VIEWER-RING-ROLL-SNAP] -->\n*413│**BUILT (M12 W2.5).** The controller/viewer model is implemented end-to-end. Attach intent is **three-valued** (`AttachIntent = Viewer | Control | Take`, wire-default `Control`): `Control` to a FREE endpoint becomes controller; `Control` to a CONTROLLED endpoint is **refused with guidance** (`--view` to watch, `--take` to control) — never auto-viewer, never silent-displace; `Take` (`spt rc --take` / picker \"Kick\") kicks the incumbent with a **loud `Displaced{by}` notice** and full detach (not demote). ...\n 414│\n 415│**redispatch** (REDISPATCH-TRUTH, ADR-0038): the fresh brain's reconstruction of target-side attach workers over broker-held peer streams after a **brain cycle** (`spt daemon refresh` / `spt update` apply — the PTYs and QUIC streams survive; the brain-owned serve workers do not). Redispatch eligibility is **lifecycle-gated**: finished/terminal stream rows are retired from the dispatcher's enumeration (a fresh dispatcher never re-serves a terminal Attach — the KNOWN-HAZARDS 7.41 frozen-PTY steal/clear...\n 416│_Avoid_: conflating redispatch with msg dispatch or session-cursor resume; filtering replay by origin identity (the legitimate same-`by` successor re-take must keep working — lifecycle is the discriminator).\n    │...\n 511│adapter/flow **overrides** it (spt-claude-code → `cc`, giving `cc-<id>`) — the Claude-Code-ness\n*512│lives in the adapter, **spt-core never emits `cc`**. The basename must be a *distinct* token,\n 513│never bare `spt`: a `spt.cmd` wrapper would shadow the real `spt.exe` only under cmd.exe\n 514│(cwd-first search), silently no-op in PowerShell/Unix, and self-recurse — so `spt-<id>` is the\n 515│safe, consistent form. The launcher is the **current OS's native form**: a **`.cmd`** on\n    │...\n 566│- **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*567│- **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 568│- **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 569│- **\"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 570│- **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    │...\n 642│- **P2P (default):** the data syncs between a user's nodes over the built-in P2P networking — **no `gh`/account/setup**, the no-central-operator promise. Replaces modern SPT's required GitHub-remote sync.\n*643│- **Hub (opt-in, `spt context-github-setup`):** every node pull→merge→pushes against a private GitHub remote — an always-online sync hub *and* a convenient GUI view. Uses a **shared deploy key** distributed as subnet secret material, so only the setup node needs a GitHub account; other nodes use the key over SSH, with **P2P as backstop**. Hub mode is the **context-repo sync transport only** — messaging, registry, remote-drive, pairing, and presence always ride P2P; gh-on never removes the P2P req...\n 644│\n 645│**The gh-repo interim is retired** (M4-D9-6, after the two-host rig proof): during the pre-P2P era the sister project's `gh-repo-sync` skill (and the `spt-agent-storage` private repo wired by `psyche-sync-setup`) was *the* cross-machine context transport. With P2P sync proven end-to-end on real hardware (`docs/TWO-HOST-RUNBOOK.md`), that interim stands down — the sister skill is no longer part of the sync path. `psyche-sync-setup` remains only as the **hub-mode seam** (the opt-in transport above), not ...\n 646│\n    │...\n 681│\n*682│**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 683│\n*684│**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 685│\n 686│**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 687│_Avoid_: public docs hosting; binding beyond loopback; docs drifting from the installed version.\n 688│\n*689│**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 690│\n 691│**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 692│\n    │...\n 697│\n*698│**delivery** — peer-propagated over P2P, layered on self-fetch, with out-of-band still supported. One node learns of / obtains an update (marketplace drop *or* self-fetch from a release channel), gossips availability across the subnet, and peers pull the new binary over the networking layer — the subnet self-heals to latest. **All binaries are signature-verified before handoff regardless of source** (peer-propagation otherwise lets a compromised node poison the subnet). spt-core has its own release s...\n 699│\n 700│**handoff invariant** — **no endpoint process terminates or suspends during a self-update.** We cannot assume every endpoint can safely suspend. Satisfied by the broker/brain split: routine updates replace only the daemon brain, which rehydrates from disk state and re-attaches to the broker's held PTY/socket/child handles. Harness-hosted endpoints (topology 1) are safe by construction; spt-hosted endpoints (topology 2) are kept alive by the broker holding their PTY masters + child processes across the ...\n 701│\n    │...\n 707│\n*708│**debug rollout**:\n\n# PRD.md\n 130│- **R-NET-3** **Cross-node Psyche sync** over P2P **replaces** the GitHub-repo sync (no `gh`/account/setup).\n*131│- **R-PAIR-1** **TOTP-seeded SPAKE2 pairing** (ADR-0005): the rotating TOTP code is the PAKE password (not a bearer token). The seed is the subnet secret, held by the auth app + all trusted nodes; the user interacts only with the new node.\n 132│- **R-PAIR-2** Local trust store (TOFU + warn-on-change). Cross-subnet deferred.\n*133│- **R-PAIR-3** Fetch the current pairing code from any paired node (`spt pair --code` or via an agent).\n 134│- **R-PAIR-4** Prompt to **name the subnet** on first pairing; show the name on every subsequent pairing.\n*135│- **R-PAIR-5** (M4, ADR-0005 amend) **Multi-subnet pairing ceremony:** discovery takes **TOTP code + subnet-name** (the name namespaces the relay rendezvous) in all cases; **create-new names the subnet at creation** (no node-name mode); the relay rendezvous token is `H(subnet-name ‖ TOTP-epoch)` (under the hood — raw name+code at the surface; payload already E2E-encrypted).\n*136│- **R-PAIR-6** (M4, ADR-0005 amend) **Elevation-gated per-subnet code fetch:** retrieving a subnet's current code *from a node* requires OS privilege elevation (Windows UAC / Linux root-or-equivalent) or an elevated agent endpoint; otherwise the user falls back to their authenticator-app store. Fetch is **per-subnet** (`--subnet <name>` / `--create-new`), QR/`otpauth://` optional.\n 137│- **R-PAIR-7** (M4 data / frontend render) **Subnet icon:** an optional small image (square PNG/WebP, ≤256 KB) representing the subnet in GUIs; stored **inline** in subnet metadata (syncs over the same subnet-material channel as the name), editable by any node in the subnet, GUI-only consumer.\n 138│\n 139│### 10b. Endpoint access control (ADR-0009)\n    │...\n 153│\n*154│- **R-UPD-1** **Peer-propagated** over P2P (layered on self-fetch; out-of-band still supported); the subnet self-heals to latest (ADR-0004).\n 155│- **R-UPD-2** **All binaries signature-verified before handoff**, regardless of source (spt-core release key).\n 156│- **R-UPD-3** **No-endpoint-termination invariant, scoped by update class (ADR-0004 §A).** For the **brain-only** class — the routine case — the guarantee is **absolute**: no endpoint process terminates or suspends, satisfied by the broker/brain split (the broker keeps held PTYs/children/sockets alive across brain replacement). **broker-compatible** swaps the broker behind the versioned IPC with endpoints still surviving. **broker-breaking** (held-resource-type / OS-API change) MAY suspend endpoints...\n 157│- **R-UPD-4** **Not auto by default** — gated on user confirmation delivered to the most-recently-active live session, with an opt-in full-auto.\n\n# ROADMAP.md\n 30│  - **M2a — Harness contract. ✅ delivered (2026-06-01).** `spt-runtime` (AgentRuntime, ManifestRuntime, manifest schema) + the `api` subcommand surface (seed/listen/bind/state/echo-gate/poll/worker-*/boundary/session-end/presence/history-log/emit/capability) + the **harness-hosted** startup topology (seed→listen→bind, interim seed-file) + local-api-auth + the generic mock adapter + contract E2E. All M2a reqs activated + green. Task breakdown: [`M2a-PLAN.md`](./M2a-PLAN.md).\n*31│  - **M2b — Live-agent lifecycle. ✅ delivered (2026-06-01).** `spt-live` (fifth layer): history subsystem (fetcher/locate-normalize/native) + spawn-psyche (nested perch, spt-core-owned `$psyche_prompt`) + echo-commune (history brief → single-writer commune drop) + drop-file ingest (single-writer 6.4 + direct-write precedence 6.5) + interim pulse + signoff/boundary ordering invariants (echo-before-signoff 3.3, grace-before-signoff 1.1, stale-signoff sweep 3.2) + resume seam (continue-existing / fres...\n 32│  - **Interim, no daemon** (like M1): the `api listen` process holds the perch + relays; the seed bridges via a file. **The spt-hosted topology (PTY-launch) + the consolidated `spt-daemon` move to M3** (they need the `spt-term` broker) — this narrows the earlier \"both topologies in M2\" wording.\n 33│- **M3 — Terminal wrapper + daemon + self-update.** `spt-term` (session-surface, PTY, broker) + the consolidated `spt-daemon` (broker/brain split) + peer-propagated signed self-update. Split M3a/M3b/M3c. Scope + carried-forward Spike #1 gaps: [`M3-PLAN.md`](./M3-PLAN.md).\n 34│  - **M3a — `spt-term` session-surface mechanism. ✅ delivered (2026-06-02).** The sixth crate (`…→spt-msg→spt-term`, sibling of spt-runtime): the `SessionSurface` trait + native PTY backends (ConPTY/forkpty via `portable-pty`, REQ-TERM-1) · ConPTY-DSR auto-answer drain pump (REQ-HAZARD-CONPTY-DSR) · send-keys/send-line injection (REQ-TERM-2) · bounded byte-stream (REQ-TERM-3) · the PTY-digest parser primitive (REQ-TERM-4 impl/unit, ADR-0008). OS-neutral per Spike #4/#5 (no OS's stream contr...\n 35│  - **M3b — `spt-daemon` broker/brain process. ✅ delivered (2026-06-03).** The seventh crate (`…→spt-live→spt-daemon→spt`) hosts many `spt-term` surfaces behind a **versioned local IPC** (named-pipe/UDS behind one `DaemonTransport` trait, forward-compatible frame schema): a **broker** (stable kernel — PTY masters, harness children, sockets; no logic) + a **brain** (restartable logic) so a brain kill/restart leaves the hosted PTY child + its output stream **intact and gapless** (Spike #1 mad...\n*36│  - **M3c — signed self-update. ✅ delivered (2026-06-03).** The gated, signed, ripple-capable self-update engine (no new crate — it lives in `spt-daemon`, signing primitives in `release.rs`): the **update-class taxonomy** (brain-only / broker-compatible / broker-breaking, ADR-0004 §A) with the **brain-only zero-interruption swap** that drives the M3b handoff substrate (snapshot→drop→re-attach) so a live PTY child + its output stream survive the logic swap **untouched** (REQ-UPD-3, proven E2E `...\n 37│  - **M3 COMPLETE** (M3a + M3b + M3c all ✅, 2026-06-03): the real ADR-0004 architecture is live — native PTY hosting, the consolidated broker/brain daemon, and seamless signed self-update. Next milestone: M4.\n*38│- **M4 — Networking + instances. ✅ delivered (2026-06-04).** The eighth crate `spt-net` (Iroh WAN endpoint bound to the node's own Ed25519 identity, mDNS LAN discovery, TOTP-SPAKE2 pairing over a dedicated pre-trust ALPN with transcript binding / rate limiting / seed rotation+transfer, subnet registry + NDJSON replication under the per-node epoch lease) · the multi-instance model (registry rows + bare-id resolution refuse-and-qualify + visibility/sync-membership gates + rename/collision + **resting-...\n*39│- **M5 — Shells, presence, deferred capabilities. ✅ delivered (2026-06-04;** scope locked with user 2026-06-04, see [`M5-PLAN.md`](./M5-PLAN.md) + per-task JIT plans D0–D9). The consent framework seam (grant store + interactive escalation + pre-consent flags; remote-exec / instantiate-anywhere reserved-but-refusing) · adapter registration lifecycle (`spt adapter add/remove`, manifest-first) · **shell hosting machinery + sleep/wake** (link-token channels with per-frame MAC, broker-launched binarie...\n 40│\n 41│## Post-v1-core milestones\n 42│\n   │...\n 46│\n*47│- **✅ Broker/brain process-isolation restoration — delivered (2026-06-11).** Design: [`docs/BROKER-BRAIN-SPLIT-RESTORATION.md`](./docs/BROKER-BRAIN-SPLIT-RESTORATION.md) (verified-with-amendments by `doyle`); decision: **ADR-0018**; build plan: [`RESTORATION-PLAN.md`](./RESTORATION-PLAN.md) (D1–D7). Corrects an unintended regression — the broker/brain split (ADR-0004) was specced + spiked as two *processes*, but the production daemon runs the broker as an in-process *thread* (`daemon.rs:165-170`)...\n 48│\n 49│- **🔜 NEXT milestone (planned 2026-06-12) — M9: Adapter customization & session surfaces.** Product of the two-day **Gateway grill** (operator + `doyle`; CONTEXT.md mints @ `8dc22ff`/`bd469b9`; ADR-0008 amended; the lecturn device design parked in the sibling `spt-lecturn/` repo for a later build). Shell-free, independently-shippable features: **adapter profiles** (sparse leaf-replace overlays on the parent manifest; shipped + local sources, local surviving adapter updates; composite `<adapter>:<pro...\n 50│- **🔜 M10 — Digest extractor & thread-spanning** (planned 2026-06-13; NEXT). Product of the digest grill (operator + `doyle`); governed by **ADR-0019** (reverses M9's \"no manifest seam\"), CONTEXT.md \"session digest\" rewritten. An `spt-claude-code` enabler, so it takes the M10 slot ahead of the Shell/remote-attach work (which shift to M11/M12). Adapter-declared **`[digest]` extractor seam** (imperative, no DSL; same source files as `[history]` by default + own-source escape; `api digest-entry` push f...\n\n# docs/\n## KNOWN-HAZARDS.md\n  126│- **Source:** spt-core field incident 2026-07-27 (emphasys endpoint + spool loss); ruled by doyle, with emphasys's mid-create-race amendment revoking the empty-dir self-heal.\n *127│- **Amendment — the perch GC inherits this reasoning EXTENDED, never carved out (doyle, 2026-08-04, releases#109).** A second consumer now deletes perch directories: `spt endpoint gc` (3.5, `REQ-PERCH-GC-RESIDUE-PREDICATE`). Its authority is something `ring` never had — a **positive** statement that the directory carries **no endpoint record at all**, from the store that answers endpoint existence on a node (the owlery tree itself; the perch dir plus its parseable `info.json` IS the endpoint record)....\n  128│<!-- [doc->REQ-HAZARD-RING-PERCH-ADOPTION] -->\n  129│\n  130│### 3.5 `is_perch_alive` is INVERTED on perch residue — a GC must never key on it\n     │...\n  202│- **Source:** M7 acceptance run 2026-06-06 (DEFERRED.md \"Ghost registry row eviction\"); the AMBIGUOUS render fix rode along.\n *203│- **Mesh note (ADR-0017, 2026-06-08):** the subnet mesh **preserves** this invariant rather than superseding it. \"No transitive gossip\" sharpens to **no transitive *row* gossip** — the mesh relays only the member *roster* (discovery), while registry **rows stay own-authored and are fetched directly** from each member over a handshake. So \"any future update for a node comes from that node itself, alive\" still holds and the eviction lease is untouched. (The plan's rejected alternative — signed transiti...\n  204│\n  205│### 4.11 Advertisement-epoch reset strands a node  `[REQ-HAZARD-EPOCH-RESET]`\n  206│<!-- [doc->REQ-HAZARD-EPOCH-RESET] -->\n     │...\n  254│\n *255│### 5.7 Elevated commands spawn the daemon with the wrong token  `[REQ-HAZARD-ELEVATED-DAEMON-SPAWN]`\n  256│<!-- [doc->REQ-HAZARD-ELEVATED-DAEMON-SPAWN] -->\n *257│- **Failure:** membership-implies-reachability made *every* `spt` invocation a potential daemon spawner (`ensure_running`), including the elevation-gated ones (`subnet create`/`join`, REQ-SUBNET-4). The spawned daemon inherits the spawner's token. **Windows:** an elevated `subnet create` auto-starts an ELEVATED daemon whose named pipes deny unelevated clients — every subsequent unelevated `spt` reads \"not running\", tries to spawn its own daemon, and dies on bind Access-denied; the user had to taskkill ...\n *258│- **Invariant:** the daemon **always runs unelevated in the invoking user's universe**, regardless of which command spawns it. Two enforcement points sharing one seam: (a) `spawn_detached` de-elevates the child — Windows: the UAC **linked token** (`TokenLinkedToken` → `DuplicateTokenEx` → `CreateProcessWithTokenW`; inherits no handles, so 5.6 holds by construction); Linux: drop to `SUDO_UID`/`SUDO_GID` with `$HOME`/`$USER`/`$LOGNAME` reset to the invoking user's (passwd lookup); (b) a `Daemon::run`...\n  259│- **spt-core mapping:** `spt-daemon::deelevate` (the OS-split seam) consumed by `daemon::spawn_detached` + the `Daemon::run` entry guard. The fuller Linux elevation model (install symlink + default-account election) is deferred (DEFERRED.md, M8).\n  260│- **Source:** M7 acceptance run 2026-06-06 (DEFERRED.md \"Non-admin daemon spawn\"); interim field rule was \"bring the daemon up unelevated FIRST\".\n  261│\n     │...\n  264│- **Failure:** the daemon runs DETACHED (no console, 5.6/`detached_no_inherit`). Any console-subsystem child it spawns (`git`, `taskkill`, manifest hook commands) gets a **fresh conhost with a visible window** — piped/null stdio does NOT prevent it. Field shape: the 60s sync pump's two git calls (`for-each-ref` + `rev-parse`) flashed two blank windows per minute on the user's desktop (2026-06-06).\n *265│- **Invariant:** every short-lived console child spawned from daemon-reachable code sets `creation_flags(0x0800_0000)` (`CREATE_NO_WINDOW`). Long-lived detached children use `detached_no_inherit` (already `DETACHED_PROCESS | CREATE_NO_WINDOW`); de-elevated spawns use `CREATE_NEW_CONSOLE + SW_HIDE` (5.7 — `CreateProcessWithTokenW` rejects `CREATE_NO_WINDOW`, error 87).\n  266│- **Test seam caveat:** window-absence is unobservable from a consoled test runner — the child inherits the runner's console and never creates a window, flag or no flag. Unit coverage asserts the flagged spawn still works (the error-87 \"flag combo breaks spawn\" regression class); window-absence was verified live by process-watch capture.\n  267│- **spt-core mapping:** `spt-store::gitrun::run_git` (every BranchStore/ContextStore git call), `spt-daemon::shellhost::kill_shell_pid` (taskkill), `spt-runtime::run_bounded_command` (manifest hook commands), `spt-runtime::ManifestRuntime::command_for` (the one shared builder behind `spawn_session` + `run_bounded_stdin` — the notif pump's `spawn_notif_command` and the live agent's psyche/echo/turn spawns), `spt-daemon::shellwake` (already guarded). The flag lives in each shared builder, not per call si...\n  268│- **Source:** spt-core field bug, 2026-06-06 — two blank windows flashing every 60 seconds on a desktop workstation, caught by process-spawn watcher (git.exe parent=spt daemon, conhost.exe child each).\n     │...\n  286│- **Failure class:** a privilege-gated command (`subnet create` / `join` / `show-code`) self-elevates by re-launching itself with privilege (Windows UAC `runas`, Linux `pkexec` / a terminal-emulator `sudo`, or inline `sudo`). A careless re-launch is a security hole: widening the privilege scope (adding args), resolving the binary by a bare name (a PATH/`secure_path` hijack runs an attacker's `spt`), interpolating a crafted arg into a shell string (`sh -c \"… $id …\"` injects a second command), or re-el...\n *287│- **Invariant:** self-elevation re-runs the **EXACT** original invocation with the binary's **ABSOLUTE** exe path — never adding/altering args, never a PATH-resolved bare name, never a shell-interpolated string. Every launcher passes an **argv array** (`Command::new(prog).args([...])`, never `sh -c`); the Windows `ShellExecuteW` params string (which is inherently one string) MSVC-quotes each verbatim arg so `CommandLineToArgvW` round-trips it as a single token. The elevated child drops state back to th...\n *288│- **Test seam caveat:** the real launch needs a UAC/polkit/sudo prompt (not hermetic) — manual-verify. The deterministic guards are the pure `decide_elevation_path` matrix (loop-safety: `AlreadyElevated` on every os; the os×env path order) and the argv builders (`sudo_argv` / `pkexec_argv` / `terminal_argv` assert absolute-exe + verbatim args + array; `windows_runas_params` asserts MSVC-quoting with no `cmd /c` interpolation; the crafted-arg test asserts a shell-metachar arg stays one element / one qu...\n  289│- **spt-core mapping:** `spt::elevation::{decide_elevation_path, sudo_argv, pkexec_argv, terminal_argv, windows_runas_params, print_hint_command, ElevatePath}` (pure), `spt::cli::{try_auto_elevate, launch_uac_window, pause_elevated_console_if_fresh, program_on_path, first_terminal_emulator}` (impure launchers) wired into `cmd_subnet_create` / `cmd_subnet_join` / `cmd_subnet_show_code`. Companions: 5.10 (the Unix abs-path-under-sudo facet) and 5.7 (the elevated child's de-elevation drop, which this compos...\n  290│- **Source:** M12-W4 design (subnet QR + self-elevating window), doyle ruling `M12-W4-RULING.md` Q6 — a privilege-escalation feature carries a mandatory hazard REQ.\n  291│\n     │...\n  477│- **Failure:** any local process calls `spt api bind|state|session-end|history-log|poll` and binds, ends, injects, or spoofs the state of an endpoint it does not own. Local untrusted processes are explicitly in scope (shells, third-party adapters).\n *478│- **Invariant:** every `api` *mutation* is authenticated to an endpoint/session — per-endpoint link token or OS-credential binding. An unrelated local process cannot bind, inject, end, or spoof state.\n  479│- **spt-core mapping:** the `api` subcommand surface (PRD R-API-*) + broker IPC.\n  480│- **Source:** codex Stage A #13 (`docs/reviews/STAGE-A-codex-redteam.md`).\n  481│\n     │...\n  533│renewed only by data on the matching stream) and are EXCLUDED from the audit's\n *534│fixed-deadline policy controls by record: a tokenizer cannot prove that a guarded\n  535│re-arm is the only renewal, and a false assurance is worse than a named exclusion.\n  536│The exclusion does not imply the renewal behaviour is tested (register follow-up).\n  537│\n     │...\n  562│- **Invariant:** both daemon spawn paths request `CREATE_BREAKAWAY_FROM_JOB` (0x0100_0000), best-effort with an in-job FALLBACK on `ERROR_ACCESS_DENIED`(5) / `ERROR_INVALID_PARAMETER`(87) so a breakaway-denying job NEVER regresses the spawn. **A real job CAN deny breakaway** (proven: the cargo/CI runner's own ancestor job ACCESS_DENIES it), so efficacy against the real terminal is UNKNOWN until measured. **int stage = OPERATOR MANUAL ACCEPTANCE, not CI** (doyle ruling 2026-06-18): the runner sits inside ...\n *563│- **spt-core mapping:** `daemon.rs::detached_no_inherit` + `deelevate.rs::create_with_token` (the breakaway flag + fallback). Units: `detached_no_inherit_falls_back_under_a_breakaway_denying_job` (no-regression — the shared runner exercises the fallback directly), `breakaway_spawn_escapes_a_kill_on_close_job` (the OS escape mechanism — self-SKIPS where the ancestor job forbids breakaway). Unix: the daemon's `setsid` session-detach already keeps a closing terminal's SIGHUP off its children (guard test...\n  564│- **Source:** v0.12.0 real-harness defect (operator) → v0.12.1 L1 @5ae68f8; doyle design+int ruling 2026-06-18.\n  565│\n  566│### 7.11 A dead PTY child + a dropped operator pump must NOT wedge the broker for other clients  `[REQ-HAZARD-ATTACH-WEDGE]`\n     │...\n  634│- **Invariant:** on a single brain↔broker connection exactly ONE `controller_writer` is ever the LIVE writer; a SUPERSEDED writer writes no further frames after the epoch bump it observes; and **every `controller_writer` emits a strictly ASCENDING seq stream** (sorted initial batch + ascending live frames). The CORRECTNESS guarantee that falls out: a snap-above consumer over any interleaving of ascending writers — where the surviving writer (`serve_attach`'s `attach_as(sid,0)`) offers the COMPLETE ra...\n *635│- **spt-core mapping:** impl `brain.rs` `Brain::handoff` (KEEP the eager `subscribe`; seed `session_cursors`) + `Brain::subscribe_with` (resume-mode dedup-cursor reset to `from_seq`, shared by `attach`/`attach_as` — the operator-stream boundary fix) + `broker.rs` `OutputLog.controller_epoch: Arc<AtomicU64>` / `become_controller` (atomic `fetch_add`, passes the new epoch + `Arc::clone` into the writer) / `controller_writer` (epoch gate read UNDER the lock on both loops) / `mark_controller_gone` + the `C...\n  636│- **Source:** v0.13.0 P1c (operator-ruled root-fix before ship; doyle root-cause via instrumented repro 2026-06-20; design corrected across two gate rounds — fix #1 reverted, then the kitsubito RACEDIAG pinned the residual to the consumer-side operator-stream boundary, fixed by the `subscribe_with` cursor reset on `attach_as` re-subscribe). The last v0.13.0 ship-blocker.\n  637│\n  638│### 7.22 An idle delivery with no working translation binary must SPOOL, never raw-inject a pseudo-delivery reported as delivered  `[REQ-HAZARD-IDLE-SILENT-NONDELIVERY]`\n     │...\n  676│- **Failure (F-024C/F-024D, ENLYZEAM field + clean-room repro 2026-07-02):** `authenticate()` (auth.rs) gates `api poll`/`state`/`boundary` on `proof.session_id == info.json.session_id`. If ONE boundary rotation is LOST — the departing session dies (crash / tab-close) or its `/clear`-era `boundary` call never lands — the perch stays PINNED to the dead sid, and every id-scoped hook call thereafter AUTH_REFUSES *including `boundary` itself* (the very call that would re-pin presents the NEW sid). A perm...\n *677│- **Invariant:** `authenticate()` gains a DEAD-OWNER fallback — when the caller's sid MISMATCHES the pin **AND** the perch's recorded owner pid is DEAD (`proc::is_process_alive == false`), ACCEPT the caller's sid and RE-PIN (rotate `info.json.session_id` via the locked `mutate_info` RMW + a LOUD `SESSION_REPIN` stderr line). Same trust model as `establish_perch`'s conflict gate, which already allows a rebind exactly when `owner_alive == false` (an orphaned perch accepts a new LOCAL owner). This is **AD...\n *678│- **spt-core mapping:** impl `spt/src/api/auth.rs` `authenticate` dead-owner-mismatch branch (`read_pid` → `proc::is_process_alive` → `mutate_info` rotate + `SESSION_REPIN` log); unit `auth.rs` `pinned_to_dead_sid_mismatched_poll_repins` (AUTH_REFUSED → drains+re-pins, RED pre-fix) + `live_owner_mismatch_still_refuses` (the `/clear` live-rotation squat guard, unwidened) + `token_auth_path_unchanged` (correct token wins first, no rotation).\n *679│- **Source:** F-024C/F-024D (perri field RCA on ENLYZEAM + doyle code-confirm 2026-07-02; the wedge latches on `/clear` even in a CLEAN env — the env-corruption domino was sufficient but not necessary). COVERAGE: dead-owner re-pin rescues CRASHED/DEAD owners ONLY; live-pid rotation is correctly refused without departed-session proof. Cross-refs ADR-0032 (the 3-layer rotation-credential model) + `[REQ-BOUNDARY-ROTATION-CREDENTIAL]` (layer 1, the adapter/harness-contract half).\n  680│<!-- [doc->REQ-HAZARD-SESSION-PIN-WEDGE] -->\n  681│\n  682│### 7.26 Concurrent first-touch of ONE fresh BranchStore must ALL succeed — a non-atomic `git init` race must never strand a first-toucher  `[REQ-HAZARD-STORE-INIT-RACE]`\n     │...\n  994│- **Instrument-soundness kin (distinct, do not merge):** a *gate* whose failure signature is indistinguishable from the fault it guards is broken in the same family, and instrument tests are never requirement evidence (see the §7 note on the spawn rigs). This entry is about a PRODUCTION monitor's reported state; that one is about a TEST's verdict.\n *995│- **spt-core mapping:** `spt-daemon/src/pump/health.rs` — `PumpHealth::verdict` (the failing set outranks the live count; `DegradedPartial` requires a live peer so nothing-live stays `Connecting`), `any_fail_since_ms` + `recompute_all_fail` (the window one peer's success cannot close), `note_connected` (closes windows through the shared recompute, never by hand); `spt/src/cli.rs` `render_peer_health_line` + `peer_health_verdict_token` (the `degraded-partial` surfaces). **releases#75 addendum:** a third...\n  996│- **Source:** WAN-TRUTH batch #50 lane 3 (issue #41; doyle-ruled 2026-07-31 after the class appeared twice in one batch). Twin `REQ-PEER-HEALTH-PARTIAL-DEGRADE` (the positive requirement that repairs instance 2). Kin `REQ-PUMP-STAGE-TRUTH` (the total-failure fingerprint and per-stage attribution, which instance 1 produced and this does not change).\n  997│\n  998│### 7.62 A new wire record carries a `kind` tag — the untagged shape is already claimed, and an older daemon delivers it as chat  `[REQ-HAZARD-WIRE-UNTAGGED-RECORD]`\n     │...\n 1142│<!-- [doc->REQ-HAZARD-ADAPTER-APPLY-SILENT-NOOP] -->\n*1143│| 7.25 | A perch pinned to a DEAD session self-heals; a LIVE-owner rotation still refuses. `authenticate()` (auth.rs): sid mismatch AND recorded owner pid DEAD (`proc::is_process_alive`) → accept caller sid + RE-PIN (`mutate_info` rotate `session_id` + LOUD `SESSION_REPIN`) — ADR-0032 layer 2, same trust as `establish_perch`'s dead-owner rebind. LIVE-owner mismatch (a `/clear`/`/compact` live-pid rotation) STILL refuses (squat, unwidened — live-rotation proof is the adapter's layer-1 job, `[REQ-BOU...\n 1144│<!-- [doc->REQ-HAZARD-SESSION-PIN-WEDGE] -->\n 1145│| 7.26 | Concurrent first-touch of ONE fresh BranchStore all-Ok — the non-atomic `git init` (template-hook copy) + `config.lock` race must never strand a first-toucher. `open_or_init` → `init_bare_tolerant` (bounded backoff-retry on the init collision that desyncs racers + open-after-lose on a racer-created HEAD) + `config_set_locked_retry` (bounded `config.lock`-aware retry; `core.autocrlf` idempotent). Convergence guaranteed: a lone re-init on a partial dir completes. Fixes the G3-gate pump.rs:442 ...\n 1146│| 7.27 | A control/viewer stamp never outlives its session — every teardown path clears what attach stamped. `/exit` kills the CHILD (endpoint suspends on child death) so the teardown runs the REAP path (exit-waiter → `sessions.remove`), never the controller-detach path — the only pre-fix stamp-clear. The exit-waiter now calls `OutputLog::stamp_reaped()` = `set_driven_by(None)`+`set_controlled(false)`+`set_viewer_count(0)` on reap (broker single-writer, unconditional/idempotent, race-free — dead ...\n\n## adr/\n### 0002-bake-networking-into-core-collapse-spt-node.md\n 11│- **Default stays P2P** + no-account — the core promise is unchanged.\n*12│- **Hub mode (opt-in):** every node pull→merge→pushes against a private GitHub remote (always-online sync hub + live GUI view), authed by a **shared deploy key** distributed as subnet secret material (only the setup node needs a GitHub account). **Prefer-GitHub, P2P backstop.**\n 13│- **Scope:** hub mode carries the **context repo only**. Messaging, registry, remote-drive, pairing, and presence always ride P2P/Iroh; gh-on never removes the P2P requirement. So the original decision (networking baked-in, first-class, P2P) stands in full — the hub is a context-sync convenience layered on top, not a return to GitHub-as-infrastructure.\n 14│\n 15│## Context\n### 0003-multi-instance-node-anchored-identity-model.md\n 48│- **#8 — `local → most-recently-active → id@node` is ambiguous under partition/clock-skew.** Can route messages or remote-control to the wrong instance around active/dormant transitions. **Required:** registry lease/epoch using monotonic per-node epochs, not wall-clock; chaos-test stale entries + ambiguous active instances.\n*49│- **#9 — dormant-warm default has real resource/security cost.** Warm sessions retain credentials, file locks, model/billing footprint, PTYs, watchers on *every* node. **Required:** measure N dormant sessions on real adapters; define an adapter-specific warm/cold policy + resource budget before locking warm as the default (R-INST-3).\n 50│  **CLOSED 2026-06-04 (M4-D9-3):** measured with the env-gated harness (`crates/spt-daemon/tests/budget.rs`, `SPT_BUDGET=1`) on the rig — an idle warm seat burns **zero CPU**; the cost is RSS only (~8 MiB shell-class, ~300 MiB LLM-harness-class; suspended residual = the on-disk record). Policy locked in `docs/DORMANCY-BUDGET.md`: warm default **confirmed**, auto-suspend opt-in default OFF with the global → node → endpoint `auto_suspend_after_ms` knob chain (D9-2), constrained nodes default the node...\n### 0004-single-daemon-broker-brain-split-and-self-update.md\n 26│\n*27│**Self-update delivery:** peer-propagated over P2P, layered on self-fetch, out-of-band still supported. All binaries signature-verified before handoff (spt-core's own release key) regardless of source — peer-propagation otherwise lets one compromised node poison the subnet. spt-core conducts updates for the whole stack: self first, then ripple-update each registered adapter via the adapter manifest's update declaration (file-pull or delegated command). The plugin's role shrinks to initial bootstrap only.\n 28│\n 29│**Cadence/consent:** not fully automatic by default; gated on user confirmation delivered to the most-recently-active live session, with an opt-in full-auto choice.\n 30│\n### 0005-totp-seeded-spake2-node-pairing.md\n  10│\n *11│A natural-seeming option is TOTP: the user provisions a seed into an authenticator app and pairs future nodes with the rotating 6-digit code. Used the obvious way (a new node sends the code to a trusted node for verification), this is insecure: the code is a ~20-bit bearer token, replayable within its 30s window, not bound to the key being exchanged — a real-time MITM can replay the code with their own pubkey and join the subnet. It also performs no key exchange, requiring a separate unbound handshake.\n  12│\n  13│## Decision\n  14│\n *15│Adopt **TOTP-seeded SPAKE2 pairing**: keep the familiar TOTP UX but use the 6-digit code as the **password for a SPAKE2 (PAKE) handshake**, not as a verified bearer token.\n  16│\n *17│- The TOTP **seed** is the durable subnet secret: generated on the first node, shown as a QR for the user's authenticator app, and replicated to every already-trusted node.\n  18│- To pair a new node, a trusted node (online, no human — it holds the seed) computes the current code as its SPAKE2 password; the user reads the code off their phone and types it into the new node as its password. Matching codes → PAKE succeeds → pubkeys exchanged + bound, MITM-resistant encrypted channel.\n  19│\n *20│PAKE is the correct primitive for a low-entropy human-transcribed secret: it resists offline brute force (one online guess per attempt) and binds the code to the established key. Cryptographic strength comes from the PAKE construction, not from the 6 digits being secret.\n  21│\n  22│Rejected alternatives:\n *23│- **TOTP-as-bearer-token** — replayable, unbound, MITM-vulnerable (above).\n  24│- **Plain Magic Wormhole / fresh-code-each-time** — secure, but needs a human/relay at *both* ends each pairing. TOTP-seeded PAKE keeps the persistent seed on online trusted nodes so the user interacts only with the new node.\n  25│- **Paste-the-key** — too error-prone for \"just works.\"\n  26│- **Account-anchored auto-trust** — reintroduces the account dependency the product is shedding.\n    │...\n  36│- Adds a SPAKE2 + TOTP dependency to the core crypto surface (both have mature Rust crates per the research brief).\n *37│- Because every paired node holds the seed, the current code is fetchable from any trusted node (`spt pair --code` or via an agent) — no phone needed if a trusted node is handy. The user is prompted to **name the subnet** on first pairing; the name is shown on every subsequent pairing for confidence as the fleet grows.\n  38│\n  39│## Stage A red-team amendments (2026-05-31)\n  40│\n    │...\n  52│- **\"Create-new subnet\" names the subnet at creation.** One machine generates the new seed *and* runs the R-PAIR-4 naming prompt up front (becoming the sole seed-holder); the joiner then uses that subnet-name + the new seed's code. Naming simply moves to link-start.\n *53│- **Rendezvous-token hashing.** The relay routes the pre-trust ceremony by `H(subnet-name ‖ TOTP-epoch)`, not the plaintext label — the user/agent enters the raw name + code; the hash is internal. (Payload is already E2E-encrypted; this protects the relay-visible routing token from leaking the human subnet label.) Composes with the #12 transcript binding (the subnet ID / seed epoch already in the transcript).\n *54│- **Per-subnet code fetch is gated behind OS elevation** (Windows UAC / Linux root-or-equivalent), or an elevated agent endpoint; else the user falls back to their authenticator app. Replaces the old unqualified \"fetchable from any trusted node\" with an elevation-gated, *per-subnet* fetch (a node now holds several subnets' seeds).\n *55│- **Code fetch is per-subnet** (a node holds *N* seeds): `spt pair show-totp` prompts which subnet (or `--subnet <name>` / `--create-new` to bypass), offered optionally as a QR / `otpauth://` URI.\n  56│- **Forward (cross-user, ADR-0006 seam):** the seed key becomes per-(subnet, user); the prompt becomes 2-stage. The #10 seed epoch+rotation and #11 subnet-global rate-limit requirements apply **per subnet**.\n  57│\n  58│## Product-surface amendment (2026-06-05 — M7 D3)\n    │...\n  77│  exit 3, gate-before-everything): `subnet create` reveals a fresh subnet's\n *78│  joining secret, and `subnet join` enrolls the whole machine into a subnet's\n  79│  trust fabric — an unprivileged process must do neither (an attacker-run\n  80│  joiner would enroll the box into the attacker's subnet). `subnet status`\n *81│  stays ungated: read-only, membership shape only, never secrets.\n  82│\n  83│Surface rename, same semantics: `spt pair show-totp [--subnet|--create-new]`\n  84│became `spt subnet show-code [NAME]` (named `show-totp` for five days) / `spt subnet create <NAME>` /\n    │...\n 110│re-meets the (now proven-present) member; the daemon-held pre-trust meet session\n*111│is NOT adopted across the elevation boundary (a new cross-process credential seam\n 112│we deliberately do not open — the cheap re-meet is preferred). Discovery being\n 113│observable to an unprivileged local process leaks nothing the pre-trust meet ALPN\n 114│did not already expose.\n### 0006-multi-subnet-membership-model.md\n 22│\n*23│3. **Unified subnet-name link discovery.** The TOTP code is also the relay rendezvous selector; discovery+link takes **TOTP code + subnet-name** in *all* cases. \"Create-new\" **names the subnet at creation** (no node-name mode). The relay rendezvous token is `H(subnet-name ‖ TOTP-epoch)` (under the hood; the user enters the raw name + code) so the plaintext label isn't exposed on the public relay. Payload is E2E-encrypted regardless (R-NET-2). See the ADR-0005 amendment.\n 24│\n*25│4. **Per-subnet code fetch gated behind OS elevation.** Retrieving a subnet's current code *from a node* requires Windows UAC / Linux root-or-equivalent, or an elevated agent endpoint; otherwise the user falls back to their own authenticator app. Narrows the multi-subnet exposure (mere CLI/agent presence no longer yields any subnet's join-code).\n 26│\n 27│5. **Subnet-exclusive sync.** An endpoint's mind replicates only within its home subnet by default. Endpoint config carries a **subnet-membership list** for sync; the mind replicates across every listed subnet's nodes. Distinct from visibility: being addressable in a subnet does not imply replicating the mind there.\n 28│\n   │...\n 34│\n*35│- The subnet registry, pairing, code-fetch, and context-sync all gain a subnet dimension; \"one user's nodes\" generalizes to \"one user's nodes per subnet.\"\n*36│- New v1 requirements to register: multi-subnet membership, qualified addressing, `rename`, endpoint visibility, elevation-gated code fetch. These land in M4 (networking + registry + pairing).\n 37│- Endpoint visibility introduces a per-`(endpoint, subnet)` ACL surface the registry must carry and the resolution/routing path must enforce (excluded = unroutable).\n 38│- The cross-user (b) future is now a **seam, not a rewrite**: seed key, 2-stage prompt, `from`-attributed notifs, and excluded-visibility boundaries are all shaped for it.\n 39│- Amends ADR-0005 (pairing ceremony) — see that ADR's amendment; builds on ADR-0003 (node-anchored identity).\n### 0009-endpoint-access-control-model.md\n  8│the `<EVENT>` envelope's **`from` attribute** (correlate an inbound `from` against\n *9│the endpoint's prior outbound), **not** the `__REPLY_TO__` token. `__REPLY_TO__`\n 10│was a mis-elevated relic and is removed from spt-core; the sender-identity\n 11│granularity is unchanged (the relic carried the same `from`).\n 12│\n### 0015-release-signing-two-key-trust-anchor.md\n  8│\n *9│- **Signing key as a CI secret (automated signing)** — rejected for now. The runners are self-hosted dev boxes running arbitrary dev workloads; a release key in their secret store is weaker custody than a manual local sign, and release cadence doesn't justify automation. Revisit if cadence demands it.\n 10│- **Single key + good backups** — rejected; rotation-after-loss requires a *second already-trusted* key, otherwise recovery means out-of-band reinstall for every user.\n 11│\n 12│## Consequences\n### 0016-platform-targeted-update-sets.md\n *1│# Platform-targeted update sets for debug rollout and release distribution\n  2│\n  3│<!-- [doc->REQ-UPD-6] -->\n  4│\n   │...\n  8│\n *9│spt-core's peer-propagated update substrate should understand a signed update as a platform-targeted artifact set, not only as one opaque binary blob. A node offering an update may stage metadata for multiple platform artifacts (for example Windows x86_64 and Linux x86_64); each recipient verifies the set metadata, fetches only the artifact matching its own platform, verifies that artifact's digest, and then follows the existing consent/apply path.\n 10│\n*11│An update set has one signature over the whole set metadata, with per-platform artifact digests inside that signed metadata. This makes a debug version or stable version an atomic, auditable set while still letting each recipient fetch only its matching artifact.\n 12│\n*13│If a recipient's platform is missing from an offered set, the result is a typed rejection such as `NoArtifactForPlatform`, not an up-to-date/no-op answer. A malformed or incomplete debug rollout should be visible in convergence reporting.\n 14│\n*15│The receiver may support both the original single-artifact `SignedRelease` shape and the new platform-targeted set shape during migration. Debug rollout can be the first user of update sets; stable release publishing can move later without blocking the debugging workflow.\n 16│\n 17│Platform keys in the signed metadata use Rust target triples, for example `x86_64-pc-windows-msvc` and `x86_64-unknown-linux-gnu`. Release asset names remain a packaging concern and may keep their public-friendly names; target triples are the protocol identifiers.\n 18│\n*19│This keeps debug rollout fast without turning it into raw peer file-copy: a fast coordinator can assemble and sign every platform artifact, then let the existing subnet update machinery distribute the right bytes to each node. The generic receiver/protocol support belongs in the production daemon because public releases and debug rollouts have the same cross-platform distribution problem. The rollout driver does not: build recipes, cross-build/WSL/runner coordination, dev-key signing, and \"stage this deb...\n 20│\n*21│Release monotonicity is scoped to the pinned channel. Stable releases and debug rollouts use separate counters, so a fast debug sequence cannot consume, block, or roll back the stable release counter; rollback protection compares only against the latest accepted version for the recipient's pinned channel.\n 22│\n*23│Debug rollout targets are opt-in by channel pin, not by subnet membership alone. A debug offer may traverse trusted subnet peers, but a stable-pinned node rejects it at the metadata gate; only nodes pinned to the debug channel and provisioned with the dev signing key can stage and apply it.\n 24│\n*25│Pinning a lab node to debug uses the existing release-key overlay: `$SPT_HOME/identity/release-keys.json` adds the dev public key and sets `\"channel\": \"debug\"` (for example `\"keys\": {\"dev-debug-2026\": \"<ed25519-public-key-hex>\"}`). Removing the key or pinning the channel back to `\"stable\"` takes the node out of debug rollout eligibility.\n 26│\n 27│Channel pinning is exclusive. A debug-pinned node accepts only debug-channel offers and rejects stable offers until re-pinned to stable; a stable-pinned node accepts only stable-channel offers. This keeps rollback accounting and test provenance unambiguous.\n 28│\n   │...\n 30│\n*31│Bad debug builds recover the same way as stable releases: publish a newer signed debug rollout, even if the artifact bytes are a previous known-good binary. Numeric rollback remains rejected; the debug channel never gets a bypass around the anti-rollback invariant.\n 32│\n 33│Debug signing key custody is deliberately weaker than stable release signing. The stable release seed stays manual/password-manager driven per ADR-0015; the debug seed may live in a long-lived `SPT_DEBUG_RELEASE_SEED` environment variable on the fast coordinator so agents can drive rollouts. This is acceptable only because the key's blast radius is limited to nodes explicitly pinned to `debug` and provisioned with that dev public key; rotation is minting a new debug key, updating lab overlays, and deleti...\n 34│\n   │...\n 36│\n*37│Initial debug rollout scope is the spt-core binary only. Adapter updates remain on the adapter ripple-update seam with adapter-owned signing or delegated verification; maintainer tooling may compose adapter updates later, but the fast core-debug loop should not blur those trust boundaries.\n 38│\n*39│Debug rollout may carry broker-touching updates. Brain-only debug applies keep the normal no-endpoint-termination invariant. If the candidate class requires replacing the broker, apply requires the node to quiesce first: no broker-held endpoints or live resources may be running, and tooling must refuse with a typed \"shut down/suspend hosted endpoints first\" result rather than cycling them implicitly.\n 40│\n 41│The first broker-touching debug flow refuses only; it does not auto-suspend or auto-shutdown endpoints. The refusal should list the blocking broker-held endpoints/resources and print the commands needed to quiesce them, preserving lifecycle control with the operator.\n 42│\n   │...\n 48│\n*49│Debug rollout metadata should carry signed diagnostic provenance: source git commit, dirty flag, build host, build timestamp, and platform target. These fields are for observability (\"which bits are running where?\"), not for trust; verification remains signature plus artifact digest plus channel/version policy.\n 50│\n*51│The first implementation slice is intentionally narrow: update-set metadata and verification, daemon staging/read path with local platform selection, maintainer `xtask` helpers for debug pinning and debug rollout, and loopback/two-host proof of propagation plus convergence reporting. Remote build orchestration, rich dashboards, adapter inclusion, and stable-release publishing migration are later work.\n 52│\n 53│## Considered Options\n 54│\n*55│- **Single artifact per staged update** — simpler metadata, but forces dev tooling to stage different binaries through platform-specific side paths and makes debug rollout brittle.\n 56│- **Debug-only remote copy/install scripts** — fastest to hack, but bypasses the verification, rollback, channel, and consent gates that peer propagation exists to protect.\n*57│- **Platform-targeted update set** — chosen: one generic substrate shape, recipients fetch only their matching artifact, and both stable releases and debug rollouts reuse the same trust path.\n### 0017-subnet-mesh-membership-seed-proof.md\n 33│\n*34│Rejected alternative — **per-node-signed transitive row relay** (the plan's opening sketch): preserves per-author honesty but requires relaying third-party rows, which re-opens the KH 4.10 eviction lease against lagging replays and adds per-row signature machinery. It buys insider-honesty (one compromised member can't forge *another's* rows) that the v1 same-user threat model does not require — every member is the one user's own node, and a compromised member already holds the seed (full compromise, ...\n 35│\n 36│## Consequences\n 37│\n### 0020-event-envelope-sole-arriving-format-reply-to-removed.md\n 102│     outbound (sender-identity granularity, exactly as the\n*103│     `__REPLY_TO__:{from}` token carried — no finer message-id correlation added).\n 104│   - **Psyche / spt-live reply-target** (ADR-0012): a `reply` routes to the\n 105│     **inbound message's `from`** (the daemon's strip-and-re-stamp anti-spoof is\n 106│     unchanged; it sources the target from `from`, not the relic).\n    │...\n 142│- **ADR-0009 and ADR-0012 amended** — correlation/reply-target keyed on `from`,\n*143│  not the `__REPLY_TO__` token.\n 144│- **KNOWN-HAZARDS** Psyche-routing invariant (7.x) updated: \"reply → the inbound\n 145│  message's `from`\" (was \"the `__REPLY_TO__` sender\").\n 146│- **F-002 resolved by design** (the envelope is self-delimiting); no delimiter\n### 0021-harness-hosted-adapter-agnostic-seed-and-bind-time-resolution.md\n 107│  rejected — `[session.self]` is the *spt-launches-harness* command (spt-hosted\n*108│  direction); harness-hosted is the inverse, so no existing token reliably names\n 109│  the parent binary, and an explicit field gives a precise \"no adapter for this\n 110│  binary\" error and multi-alias support.\n 111│- **Pure fs-mtime selection + `touch` to change** (the first proposal):\n### 0023-always-on-endpoints-resident-supervised-binary-sigil-addressing.md\n 27│- New tracked requirements `REQ-EP-8` (the kind + supervision) and `REQ-EP-9` (the `#` sigil), both **inactive** (`required_stages = []`) until the delivering milestone.\n*28│- All Discord-specific design (webhooks, per-agent threads, inbound routing, hybrid delivery, echo-loop prevention, secrets) is recorded in the sister repo **`../spt-discord/`**, deliberately not here.\n### 0024-multi-platform-adapter-spt-packaging.md\n 12│\n*13│An adapter that updates via the `gh_release` avenue is fetched as a single release asset, `adapter.spt` — a tar whose root holds `manifest.toml` + `strings/` + the role binaries, extracted flat into the adapter's `install_dir`, where bare program tokens resolve `<install_dir>/<program>` before `PATH` (REQ-INSTALL-11). That format is **single-platform**: one archive carries one OS's binaries. A cross-platform adapter (e.g. a harness integration shipping a Windows `.exe` and a Linux ELF for its translati...\n 14│\n*15│spt-core's own binary already solves cross-platform distribution with **platform-targeted update sets** (ADR-0016): per-triple *separate* artifacts, one signed set-metadata blob with per-platform digests, and each recipient fetches only its own platform's bytes. That model is bandwidth-optimal at fleet scale but heavier for an adapter author: multiple artifacts plus signed set metadata, for what is often a few kilobytes of binary.\n 16│\n 17│## Decision\n 18│\n   │...\n 43│- **Manifest-declared platform map with suffixed binary names at the root** — invents an adapter-only platform-naming scheme divergent from ADR-0016's triples and mangles binary names. Rejected.\n*44│- **Fat multi-platform `.spt` with triple subdirs (chosen)** — one artifact, one signature, one flag-free fetch; flat install layout unchanged; reuses ADR-0016's triple vocabulary; split stays available. Trade-off accepted: a fat archive ships every platform's bytes to every recipient — negligible for the small adapters this targets; large adapters split.\n### 0025-live-daemon-coordinated-adapter-update.md\n 12│\n*13│`spt adapter update` runs **CLI-side**: it fetches, optionally verifies, and re-registers the adapter on disk (REQ-UPD-7/9 deliberately keep HTTP off the daemon). It is **daemon-blind** — it never coordinates with the running daemon. Two facts make that unsafe while an endpoint is live:\n 14│\n 15│1. **A running endpoint caches the manifest in memory.** A `BrainLifecycle` clones the adapter `Manifest` at endpoint bringup (`lifecycle.rs`, the `manifest` field + the `ManifestRuntime` clone) and never re-reads it. After a disk update the in-memory manifest is **stale** — the running endpoint drives new binaries against an old manifest, or an old binary path against a new layout.\n 16│\n   │...\n 22│\n*23│When an adapter has **running resident binaries**, its update becomes **daemon-coordinated**. The CLI keeps fetch + verify (no new daemon HTTP surface); it then hands the **apply** to the daemon over IPC. The daemon apply, per affected endpoint:\n 24│\n 25│1. **Stop** the adapter's resident binaries → releases the OS file lock (fixes the Windows \"Access denied\" overwrite failure).\n 26│2. **CRC-gated swap.** Replace on disk **only files whose CRC differs** from the staged archive; identical files (and their still-running binaries, when unchanged) are left untouched. Avoids needless rewrites and needless restarts.\n   │...\n 52│\n*53│**Correction 3 / the third axis — *ephemeral-but-install-dir-locking → run from a per-endpoint own copy*.** The resident/ephemeral split needs a third class: an **ephemeral** binary (excluded from the daemon-coordinated stop/restart, self-heals on next spawn) that nonetheless **locks the install dir while it runs** (a detached, unsupervised, long-lived install-dir binary). The fix is to run it from a **per-endpoint private copy** under `<perch>/.live-bin/<program>` (copied on first spawn / when the h...\n 54│\n*55│**Net shipped flow.** CLI fetches + verifies + extracts; for an adapter with no live endpoint it CRC-swaps directly; with a live endpoint it hands the apply to the broker, which per affected endpoint **stops the resident translation child** (releasing the OS lock) → **CRC-swaps only changed files** (never-strand) → **restarts the translation from the NEW on-disk manifest path**. The Psyche (own copy) is untouched and picks up new bytes on its next respawn; the brain self-heals its manifest via reload...\n 56│\n 57│## Amendment 2 (F015B, 2026-07-02) — matcher parity + never success-without-swap\n 58│\n### 0029-adapter-update-arc-resolve-not-execute.md\n 26│\n*27│Add a sibling optional **`command`** (opaque; args + **adapter-static** `{adapter_dir}`/`{adapter_name}` substitution only; program token resolved against `install_dir` like `[digest].extractor`/`[session.psyche_init]`). **`path` deprecated** — keeps parsing (forward/back-compat), warns at registration. Exactly one of `{path, command}`. Spawn/stdin protocol unchanged. Folds `claude-spt translate` into the one consolidated binary (ADR-0006). **Subst scope ratified adapter-static (v0.16.0 W1), not sessio...\n 28│\n 29│### 3. Composite `[update]` — `gh_release` + a delegated post-step\n 30│\n### 0030-robust-wan-subnet-join.md\n 14│\n*15│1. **A glossary conflation that misshaped the flow.** CONTEXT.md called the **TOTP-code** \"the link-discovery selector.\" It is not — the rendezvous/meet selector is `(subnet-name, TOTP-epoch)` (`rendezvous_token = SHA256(domain ‖ name ‖ totp_step)`), both **public**. The secret **code** is the SPAKE2 password *only*, used in the ceremony *after* the meet. The flow prompted for the code up front because the mental model said \"the code routes discovery\" — so the entered code aged across a slow meet...\n 16│2. **No resilience to half-broken IPv6.** Dead-AAAA candidate attempts silently consume the rendezvous window; the operator had to disable IPv6 by hand.\n 17│3. **A silent failure path.** `connect_seed_holder` (`pairhost.rs:437`) retries for 75 s with no output; `dial_via_rendezvous` (`meet.rs:281`) discards each probe error; `brain.rs:1024` `_ => continue` can swallow the terminal event — so the user saw nothing.\n 18│\n   │...\n 32│\n*33│The meet must never fail silently: (a) **live progress** during the search (elapsed / deadline) so silence never reads as a hang; (b) a **detailed failure** on exhaustion — rendezvous candidates and families attempted, relay vs direct, the last concrete error — surfaced **before** any code prompt (a dead subnet must not make the user fetch a code); (c) **propagate the terminal event** (`brain.rs:1024` must deliver `NoSeedHolder`/`PairFail` to the CLI, not `continue` it away); and (d) a `--verbose` / ...\n 34│\n 35│## Considered and rejected\n 36│\n   │...\n 48│- The per-family gate is reusable beyond join — any spt connectivity benefits from not attempting a dead family.\n*49│- CONTEXT.md is corrected: **TOTP-epoch** (public, routes the meet) vs **TOTP-code** (secret, SPAKE2 only); \"the code routes discovery\" is now an explicit _Avoid_.","truncation":{"content":"# CONTEXT.md\n 54|\n*55|spt-core is harness-independent: it does not know about Claude Code, Codex, Cursor, or any other agent runtime. All harness-specific surfaces (how to invoke an agent session, fetch conversation history for an echo commune, detect activity/idleness, etc.) are abstracted behind a runtime layer that consumers supply.\n 56|\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...\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...\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...\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...\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 148|**inject-input seam** — message delivery into a running session. Configurable per activity-state (activity / idle / both); multiple methods, any combination:\n 149|- PTY injection (with or without key/submit sequences) — spt-hosted topology;\n...\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...\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...\n 412|<!-- [doc->REQ-RCVIEW-1] [doc->REQ-KICK-1] [doc->REQ-VIEWER-SKIP-TO-LIVE-ON-EVICT] [doc->REQ-HAZARD-VIEWER-RING-ROLL-SNAP] -->\n*413|**BUILT (M12 W2.5).** The controller/viewer model is implemented end-to-end. Attach intent is **three-valued** (`AttachIntent = Viewer | Control | Take`, wire-default `Control`): `Control` to a FREE endpoint becomes controller; `Control` to a CONTROLLED endpoint is **refused with guidance** (`--view` to watch, `--take` to control) — never auto-viewer, never silent-displace; `Take` (`spt rc --take` / picker \"Kick\") kicks the incumbent with a **loud `Displaced{by}` notice** and full detach (not demote). ...\n 414|\n 415|**redispatch** (REDISPATCH-TRUTH, ADR-0038): the fresh brain's reconstruction of target-side attach workers over broker-held peer streams after a **brain cycle** (`spt daemon refresh` / `spt update` apply — the PTYs and QUIC streams survive; the brain-owned serve workers do not). Redispatch eligibility is **lifecycle-gated**: finished/terminal stream rows are retired from the dispatcher's enumeration (a fresh dispatcher never re-serves a terminal Attach — the KNOWN-HAZARDS 7.41 frozen-PTY steal/clear...\n 416|_Avoid_: conflating redispatch with msg dispatch or session-cursor resume; filtering replay by origin identity (the legitimate same-`by` successor re-take must keep working — lifecycle is the discriminator).\n...\n 511|adapter/flow **overrides** it (spt-claude-code → `cc`, giving `cc-<id>`) — the Claude-Code-ness\n*512|lives in the adapter, **spt-core never emits `cc`**. The basename must be a *distinct* token,\n 513|never bare `spt`: a `spt.cmd` wrapper would shadow the real `spt.exe` only under cmd.exe\n 514|(cwd-first search), silently no-op in PowerShell/Unix, and self-recurse — so `spt-<id>` is the\n 515|safe, consistent form. The launcher is the **current OS's native form**: a **`.cmd`** on\n...\n 566|- **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*567|- **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 568|- **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 569|- **\"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 570|- **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...\n 642|- **P2P (default):** the data syncs between a user's nodes over the built-in P2P networking — **no `gh`/account/setup**, the no-central-operator promise. Replaces modern SPT's required GitHub-remote sync.\n*643|- **Hub (opt-in, `spt context-github-setup`):** every node pull→merge→pushes against a private GitHub remote — an always-online sync hub *and* a convenient GUI view. Uses a **shared deploy key** distributed as subnet secret material, so only the setup node needs a GitHub account; other nodes use the key over SSH, with **P2P as backstop**. Hub mode is the **context-repo sync transport only** — messaging, registry, remote-drive, pairing, and presence always ride P2P; gh-on never removes the P2P req...\n 644|\n 645|**The gh-repo interim is retired** (M4-D9-6, after the two-host rig proof): during the pre-P2P era the sister project's `gh-repo-sync` skill (and the `spt-agent-storage` private repo wired by `psyche-sync-setup`) was *the* cross-machine context transport. With P2P sync proven end-to-end on real hardware (`docs/TWO-HOST-RUNBOOK.md`), that interim stands down — the sister skill is no longer part of the sync path. `psyche-sync-setup` remains only as the **hub-mode seam** (the opt-in transport above), not ...\n 646|\n...\n 681|\n*682|**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 683|\n*684|**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 685|\n 686|**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 687|_Avoid_: public docs hosting; binding beyond loopback; docs drifting from the installed version.\n 688|\n*689|**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 690|\n 691|**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 692|\n...\n 697|\n*698|**delivery** — peer-propagated over P2P, layered on self-fetch, with out-of-band still supported. One node learns of / obtains an update (marketplace drop *or* self-fetch from a release channel), gossips availability across the subnet, and peers pull the new binary over the networking layer — the subnet self-heals to latest. **All binaries are signature-verified before handoff regardless of source** (peer-propagation otherwise lets a compromised node poison the subnet). spt-core has its own release s...\n 699|\n 700|**handoff invariant** — **no endpoint process terminates or suspends during a self-update.** We cannot assume every endpoint can safely suspend. Satisfied by the broker/brain split: routine updates replace only the daemon brain, which rehydrates from disk state and re-attaches to the broker's held PTY/socket/child handles. Harness-hosted endpoints (topology 1) are safe by construction; spt-hosted endpoints (topology 2) are kept alive by the broker holding their PTY masters + child processes across the ...\n 701|\n...\n 707|\n*708|**debug rollout**:\n\n# PRD.md\n 130|- **R-NET-3** **Cross-node Psyche sync** over P2P **replaces** the GitHub-repo sync (no `gh`/account/setup).\n*131|- **R-PAIR-1** **TOTP-seeded SPAKE2 pairing** (ADR-0005): the rotating TOTP code is the PAKE password (not a bearer token). The seed is the subnet secret, held by the auth app + all trusted nodes; the user interacts only with the new node.\n 132|- **R-PAIR-2** Local trust store (TOFU + warn-on-change). Cross-subnet deferred.\n*133|- **R-PAIR-3** Fetch the current pairing code from any paired node (`spt pair --code` or via an agent).\n 134|- **R-PAIR-4** Prompt to **name the subnet** on first pairing; show the name on every subsequent pairing.\n*135|- **R-PAIR-5** (M4, ADR-0005 amend) **Multi-subnet pairing ceremony:** discovery takes **TOTP code + subnet-name** (the name namespaces the relay rendezvous) in all cases; **create-new names the subnet at creation** (no node-name mode); the relay rendezvous token is `H(subnet-name ‖ TOTP-epoch)` (under the hood — raw name+code at the surface; payload already E2E-encrypted).\n*136|- **R-PAIR-6** (M4, ADR-0005 amend) **Elevation-gated per-subnet code fetch:** retrieving a subnet's current code *from a node* requires OS privilege elevation (Windows UAC / Linux root-or-equivalent) or an elevated agent endpoint; otherwise the user falls back to their authenticator-app store. Fetch is **per-subnet** (`--subnet <name>` / `--create-new`), QR/`otpauth://` optional.\n 137|- **R-PAIR-7** (M4 data / frontend render) **Subnet icon:** an optional small image (square PNG/WebP, ≤256 KB) representing the subnet in GUIs; stored **inline** in subnet metadata (syncs over the same subnet-material channel as the name), editable by any node in the subnet, GUI-only consumer.\n 138|\n 139|### 10b. Endpoint access control (ADR-0009)\n...\n 153|\n*154|- **R-UPD-1** **Peer-propagated** over P2P (layered on self-fetch; out-of-band still supported); the subnet self-heals to latest (ADR-0004).\n 155|- **R-UPD-2** **All binaries signature-verified before handoff**, regardless of source (spt-core release key).\n 156|- **R-UPD-3** **No-endpoint-termination invariant, scoped by update class (ADR-0004 §A).** For the **brain-only** class — the routine case — the guarantee is **absolute**: no endpoint process terminates or suspends, satisfied by the broker/brain split (the broker keeps held PTYs/children/sockets alive across brain replacement). **broker-compatible** swaps the broker behind the versioned IPC with endpoints still surviving. **broker-breaking** (held-resource-type / OS-API change) MAY suspend endpoints...\n 157|- **R-UPD-4** **Not auto by default** — gated on user confirmation delivered to the most-recently-active live session, with an opt-in full-auto.\n\n# ROADMAP.md\n 30|  - **M2a — Harness contract. ✅ delivered (2026-06-01).** `spt-runtime` (AgentRuntime, ManifestRuntime, manifest schema) + the `api` subcommand surface (seed/listen/bind/state/echo-gate/poll/worker-*/boundary/session-end/presence/history-log/emit/capability) + the **harness-hosted** startup topology (seed→listen→bind, interim seed-file) + local-api-auth + the generic mock adapter + contract E2E. All M2a reqs activated + green. Task breakdown: [`M2a-PLAN.md`](./M2a-PLAN.md).\n*31|  - **M2b — Live-agent lifecycle. ✅ delivered (2026-06-01).** `spt-live` (fifth layer): history subsystem (fetcher/locate-normalize/native) + spawn-psyche (nested perch, spt-core-owned `$psyche_prompt`) + echo-commune (history brief → single-writer commune drop) + drop-file ingest (single-writer 6.4 + direct-write precedence 6.5) + interim pulse + signoff/boundary ordering invariants (echo-before-signoff 3.3, grace-before-signoff 1.1, stale-signoff sweep 3.2) + resume seam (continue-existing / fres...\n 32|  - **Interim, no daemon** (like M1): the `api listen` process holds the perch + relays; the seed bridges via a file. **The spt-hosted topology (PTY-launch) + the consolidated `spt-daemon` move to M3** (they need the `spt-term` broker) — this narrows the earlier \"both topologies in M2\" wording.\n 33|- **M3 — Terminal wrapper + daemon + self-update.** `spt-term` (session-surface, PTY, broker) + the consolidated `spt-daemon` (broker/brain split) + peer-propagated signed self-update. Split M3a/M3b/M3c. Scope + carried-forward Spike #1 gaps: [`M3-PLAN.md`](./M3-PLAN.md).\n 34|  - **M3a — `spt-term` session-surface mechanism. ✅ delivered (2026-06-02).** The sixth crate (`…→spt-msg→spt-term`, sibling of spt-runtime): the `SessionSurface` trait + native PTY backends (ConPTY/forkpty via `portable-pty`, REQ-TERM-1) · ConPTY-DSR auto-answer drain pump (REQ-HAZARD-CONPTY-DSR) · send-keys/send-line injection (REQ-TERM-2) · bounded byte-stream (REQ-TERM-3) · the PTY-digest parser primitive (REQ-TERM-4 impl/unit, ADR-0008). OS-neutral per Spike #4/#5 (no OS's stream contr...\n 35|  - **M3b — `spt-daemon` broker/brain process. ✅ delivered (2026-06-03).** The seventh crate (`…→spt-live→spt-daemon→spt`) hosts many `spt-term` surfaces behind a **versioned local IPC** (named-pipe/UDS behind one `DaemonTransport` trait, forward-compatible frame schema): a **broker** (stable kernel — PTY masters, harness children, sockets; no logic) + a **brain** (restartable logic) so a brain kill/restart leaves the hosted PTY child + its output stream **intact and gapless** (Spike #1 mad...\n*36|  - **M3c — signed self-update. ✅ delivered (2026-06-03).** The gated, signed, ripple-capable self-update engine (no new crate — it lives in `spt-daemon`, signing primitives in `release.rs`): the **update-class taxonomy** (brain-only / broker-compatible / broker-breaking, ADR-0004 §A) with the **brain-only zero-interruption swap** that drives the M3b handoff substrate (snapshot→drop→re-attach) so a live PTY child + its output stream survive the logic swap **untouched** (REQ-UPD-3, proven E2E `...\n 37|  - **M3 COMPLETE** (M3a + M3b + M3c all ✅, 2026-06-03): the real ADR-0004 architecture is live — native PTY hosting, the consolidated broker/brain daemon, and seamless signed self-update. Next milestone: M4.\n*38|- **M4 — Networking + instances. ✅ delivered (2026-06-04).** The eighth crate `spt-net` (Iroh WAN endpoint bound to the node's own Ed25519 identity, mDNS LAN discovery, TOTP-SPAKE2 pairing over a dedicated pre-trust ALPN with transcript binding / rate limiting / seed rotation+transfer, subnet registry + NDJSON replication under the per-node epoch lease) · the multi-instance model (registry rows + bare-id resolution refuse-and-qualify + visibility/sync-membership gates + rename/collision + **resting-...\n*39|- **M5 — Shells, presence, deferred capabilities. ✅ delivered (2026-06-04;** scope locked with user 2026-06-04, see [`M5-PLAN.md`](./M5-PLAN.md) + per-task JIT plans D0–D9). The consent framework seam (grant store + interactive escalation + pre-consent flags; remote-exec / instantiate-anywhere reserved-but-refusing) · adapter registration lifecycle (`spt adapter add/remove`, manifest-first) · **shell hosting machinery + sleep/wake** (link-token channels with per-frame MAC, broker-launched binarie...\n 40|\n 41|## Post-v1-core milestones\n 42|\n...\n 46|\n*47|- **✅ Broker/brain process-isolation restoration — delivered (2026-06-11).** Design: [`docs/BROKER-BRAIN-SPLIT-RESTORATION.md`](./docs/BROKER-BRAIN-SPLIT-RESTORATION.md) (verified-with-amendments by `doyle`); decision: **ADR-0018**; build plan: [`RESTORATION-PLAN.md`](./RESTORATION-PLAN.md) (D1–D7). Corrects an unintended regression — the broker/brain split (ADR-0004) was specced + spiked as two *processes*, but the production daemon runs the broker as an in-process *thread* (`daemon.rs:165-170`)...\n 48|\n 49|- **🔜 NEXT milestone (planned 2026-06-12) — M9: Adapter customization & session surfaces.** Product of the two-day **Gateway grill** (operator + `doyle`; CONTEXT.md mints @ `8dc22ff`/`bd469b9`; ADR-0008 amended; the lecturn device design parked in the sibling `spt-lecturn/` repo for a later build). Shell-free, independently-shippable features: **adapter profiles** (sparse leaf-replace overlays on the parent manifest; shipped + local sources, local surviving adapter updates; composite `<adapter>:<pro...\n 50|- **🔜 M10 — Digest extractor & thread-spanning** (planned 2026-06-13; NEXT). Product of the digest grill (operator + `doyle`); governed by **ADR-0019** (reverses M9's \"no manifest seam\"), CONTEXT.md \"session digest\" rewritten. An `spt-claude-code` enabler, so it takes the M10 slot ahead of the Shell/remote-attach work (which shift to M11/M12). Adapter-declared **`[digest]` extractor seam** (imperative, no DSL; same source files as `[history]` by default + own-source escape; `api digest-entry` push f...\n\n# docs/\n## KNOWN-HAZARDS.md\n 126|- **Source:** spt-core field incident 2026-07-27 (emphasys endpoint + spool loss); ruled by doyle, with emphasys's mid-create-race amendment revoking the empty-dir self-heal.\n*127|- **Amendment — the perch GC inherits this reasoning EXTENDED, never carved out (doyle, 2026-08-04, releases#109).** A second consumer now deletes perch directories: `spt endpoint gc` (3.5, `REQ-PERCH-GC-RESIDUE-PREDICATE`). Its authority is something `ring` never had — a **positive** statement that the directory carries **no endpoint record at all**, from the store that answers endpoint existence on a node (the owlery tree itself; the perch dir plus its parseable `info.json` IS the endpoint record)....\n 128|<!-- [doc->REQ-HAZARD-RING-PERCH-ADOPTION] -->\n 129|\n 130|### 3.5 `is_perch_alive` is INVERTED on perch residue — a GC must never key on it\n...\n 202|- **Source:** M7 acceptance run 2026-06-06 (DEFERRED.md \"Ghost registry row eviction\"); the AMBIGUOUS render fix rode along.\n*203|- **Mesh note (ADR-0017, 2026-06-08):** the subnet mesh **preserves** this invariant rather than superseding it. \"No transitive gossip\" sharpens to **no transitive *row* gossip** — the mesh relays only the member *roster* (discovery), while registry **rows stay own-authored and are fetched directly** from each member over a handshake. So \"any future update for a node comes from that node itself, alive\" still holds and the eviction lease is untouched. (The plan's rejected alternative — signed transiti...\n 204|\n 205|### 4.11 Advertisement-epoch reset strands a node  `[REQ-HAZARD-EPOCH-RESET]`\n 206|<!-- [doc->REQ-HAZARD-EPOCH-RESET] -->\n...\n 254|\n*255|### 5.7 Elevated commands spawn the daemon with the wrong token  `[REQ-HAZARD-ELEVATED-DAEMON-SPAWN]`\n 256|<!-- [doc->REQ-HAZARD-ELEVATED-DAEMON-SPAWN] -->\n*257|- **Failure:** membership-implies-reachability made *every* `spt` invocation a potential daemon spawner (`ensure_running`), including the elevation-gated ones (`subnet create`/`join`, REQ-SUBNET-4). The spawned daemon inherits the spawner's token. **Windows:** an elevated `subnet create` auto-starts an ELEVATED daemon whose named pipes deny unelevated clients — every subsequent unelevated `spt` reads \"not running\", tries to spawn its own daemon, and dies on bind Access-denied; the user had to taskkill ...\n*258|- **Invariant:** the daemon **always runs unelevated in the invoking user's universe**, regardless of which command spawns it. Two enforcement points sharing one seam: (a) `spawn_detached` de-elevates the child — Windows: the UAC **linked token** (`TokenLinkedToken` → `DuplicateTokenEx` → `CreateProcessWithTokenW`; inherits no handles, so 5.6 holds by construction); Linux: drop to `SUDO_UID`/`SUDO_GID` with `$HOME`/`$USER`/`$LOGNAME` reset to the invoking user's (passwd lookup); (b) a `Daemon::run`...\n 259|- **spt-core mapping:** `spt-daemon::deelevate` (the OS-split seam) consumed by `daemon::spawn_detached` + the `Daemon::run` entry guard. The fuller Linux elevation model (install symlink + default-account election) is deferred (DEFERRED.md, M8).\n 260|- **Source:** M7 acceptance run 2026-06-06 (DEFERRED.md \"Non-admin daemon spawn\"); interim field rule was \"bring the daemon up unelevated FIRST\".\n 261|\n...\n 264|- **Failure:** the daemon runs DETACHED (no console, 5.6/`detached_no_inherit`). Any console-subsystem child it spawns (`git`, `taskkill`, manifest hook commands) gets a **fresh conhost with a visible window** — piped/null stdio does NOT prevent it. Field shape: the 60s sync pump's two git calls (`for-each-ref` + `rev-parse`) flashed two blank windows per minute on the user's desktop (2026-06-06).\n*265|- **Invariant:** every short-lived console child spawned from daemon-reachable code sets `creation_flags(0x0800_0000)` (`CREATE_NO_WINDOW`). Long-lived detached children use `detached_no_inherit` (already `DETACHED_PROCESS | CREATE_NO_WINDOW`); de-elevated spawns use `CREATE_NEW_CONSOLE + SW_HIDE` (5.7 — `CreateProcessWithTokenW` rejects `CREATE_NO_WINDOW`, error 87).\n 266|- **Test seam caveat:** window-absence is unobservable from a consoled test runner — the child inherits the runner's console and never creates a window, flag or no flag. Unit coverage asserts the flagged spawn still works (the error-87 \"flag combo breaks spawn\" regression class); window-absence was verified live by process-watch capture.\n 267|- **spt-core mapping:** `spt-store::gitrun::run_git` (every BranchStore/ContextStore git call), `spt-daemon::shellhost::kill_shell_pid` (taskkill), `spt-runtime::run_bounded_command` (manifest hook commands), `spt-runtime::ManifestRuntime::command_for` (the one shared builder behind `spawn_session` + `run_bounded_stdin` — the notif pump's `spawn_notif_command` and the live agent's psyche/echo/turn spawns), `spt-daemon::shellwake` (already guarded). The flag lives in each shared builder, not per call si...\n 268|- **Source:** spt-core field bug, 2026-06-06 — two blank windows flashing every 60 seconds on a desktop workstation, caught by process-spawn watcher (git.exe parent=spt daemon, conhost.exe child each).\n...\n 286|- **Failure class:** a privilege-gated command (`subnet create` / `join` / `show-code`) self-elevates by re-launching itself with privilege (Windows UAC `runas`, Linux `pkexec` / a terminal-emulator `sudo`, or inline `sudo`). A careless re-launch is a security hole: widening the privilege scope (adding args), resolving the binary by a bare name (a PATH/`secure_path` hijack runs an attacker's `spt`), interpolating a crafted arg into a shell string (`sh -c \"… $id …\"` injects a second command), or re-el...\n*287|- **Invariant:** self-elevation re-runs the **EXACT** original invocation with the binary's **ABSOLUTE** exe path — never adding/altering args, never a PATH-resolved bare name, never a shell-interpolated string. Every launcher passes an **argv array** (`Command::new(prog).args([...])`, never `sh -c`); the Windows `ShellExecuteW` params string (which is inherently one string) MSVC-quotes each verbatim arg so `CommandLineToArgvW` round-trips it as a single token. The elevated child drops state back to th...\n*288|- **Test seam caveat:** the real launch needs a UAC/polkit/sudo prompt (not hermetic) — manual-verify. The deterministic guards are the pure `decide_elevation_path` matrix (loop-safety: `AlreadyElevated` on every os; the os×env path order) and the argv builders (`sudo_argv` / `pkexec_argv` / `terminal_argv` assert absolute-exe + verbatim args + array; `windows_runas_params` asserts MSVC-quoting with no `cmd /c` interpolation; the crafted-arg test asserts a shell-metachar arg stays one element / one qu...\n 289|- **spt-core mapping:** `spt::elevation::{decide_elevation_path, sudo_argv, pkexec_argv, terminal_argv, windows_runas_params, print_hint_command, ElevatePath}` (pure), `spt::cli::{try_auto_elevate, launch_uac_window, pause_elevated_console_if_fresh, program_on_path, first_terminal_emulator}` (impure launchers) wired into `cmd_subnet_create` / `cmd_subnet_join` / `cmd_subnet_show_code`. Companions: 5.10 (the Unix abs-path-under-sudo facet) and 5.7 (the elevated child's de-elevation drop, which this compos...\n 290|- **Source:** M12-W4 design (subnet QR + self-elevating window), doyle ruling `M12-W4-RULING.md` Q6 — a privilege-escalation feature carries a mandatory hazard REQ.\n 291|\n...\n 477|- **Failure:** any local process calls `spt api bind|state|session-end|history-log|poll` and binds, ends, injects, or spoofs the state of an endpoint it does not own. Local untrusted processes are explicitly in scope (shells, third-party adapters).\n*478|- **Invariant:** every `api` *mutation* is authenticated to an endpoint/session — per-endpoint link token or OS-credential binding. An unrelated local process cannot bind, inject, end, or spoof state.\n 479|- **spt-core mapping:** the `api` subcommand surface (PRD R-API-*) + broker IPC.\n 480|- **Source:** codex Stage A #13 (`docs/reviews/STAGE-A-codex-redteam.md`).\n 481|\n...\n 533|renewed only by data on the matching stream) and are EXCLUDED from the audit's\n*534|fixed-deadline policy controls by record: a tokenizer cannot prove that a guarded\n 535|re-arm is the only renewal, and a false assurance is worse than a named exclusion.\n 536|The exclusion does not imply the renewal behaviour is tested (register follow-up).\n 537|\n...\n 562|- **Invariant:** both daemon spawn paths request `CREATE_BREAKAWAY_FROM_JOB` (0x0100_0000), best-effort with an in-job FALLBACK on `ERROR_ACCESS_DENIED`(5) / `ERROR_INVALID_PARAMETER`(87) so a breakaway-denying job NEVER regresses the spawn. **A real job CAN deny breakaway** (proven: the cargo/CI runner's own ancestor job ACCESS_DENIES it), so efficacy against the real terminal is UNKNOWN until measured. **int stage = OPERATOR MANUAL ACCEPTANCE, not CI** (doyle ruling 2026-06-18): the runner sits inside ...\n*563|- **spt-core mapping:** `daemon.rs::detached_no_inherit` + `deelevate.rs::create_with_token` (the breakaway flag + fallback). Units: `detached_no_inherit_falls_back_under_a_breakaway_denying_job` (no-regression — the shared runner exercises the fallback directly), `breakaway_spawn_escapes_a_kill_on_close_job` (the OS escape mechanism — self-SKIPS where the ancestor job forbids breakaway). Unix: the daemon's `setsid` session-detach already keeps a closing terminal's SIGHUP off its children (guard test...\n 564|- **Source:** v0.12.0 real-harness defect (operator) → v0.12.1 L1 @5ae68f8; doyle design+int ruling 2026-06-18.\n 565|\n 566|### 7.11 A dead PTY child + a dropped operator pump must NOT wedge the broker for other clients  `[REQ-HAZARD-ATTACH-WEDGE]`\n...\n 634|- **Invariant:** on a single brain↔broker connection exactly ONE `controller_writer` is ever the LIVE writer; a SUPERSEDED writer writes no further frames after the epoch bump it observes; and **every `controller_writer` emits a strictly ASCENDING seq stream** (sorted initial batch + ascending live frames). The CORRECTNESS guarantee that falls out: a snap-above consumer over any interleaving of ascending writers — where the surviving writer (`serve_attach`'s `attach_as(sid,0)`) offers the COMPLETE ra...\n*635|- **spt-core mapping:** impl `brain.rs` `Brain::handoff` (KEEP the eager `subscribe`; seed `session_cursors`) + `Brain::subscribe_with` (resume-mode dedup-cursor reset to `from_seq`, shared by `attach`/`attach_as` — the operator-stream boundary fix) + `broker.rs` `OutputLog.controller_epoch: Arc<AtomicU64>` / `become_controller` (atomic `fetch_add`, passes the new epoch + `Arc::clone` into the writer) / `controller_writer` (epoch gate read UNDER the lock on both loops) / `mark_controller_gone` + the `C...\n 636|- **Source:** v0.13.0 P1c (operator-ruled root-fix before ship; doyle root-cause via instrumented repro 2026-06-20; design corrected across two gate rounds — fix #1 reverted, then the kitsubito RACEDIAG pinned the residual to the consumer-side operator-stream boundary, fixed by the `subscribe_with` cursor reset on `attach_as` re-subscribe). The last v0.13.0 ship-blocker.\n 637|\n 638|### 7.22 An idle delivery with no working translation binary must SPOOL, never raw-inject a pseudo-delivery reported as delivered  `[REQ-HAZARD-IDLE-SILENT-NONDELIVERY]`\n...\n 676|- **Failure (F-024C/F-024D, ENLYZEAM field + clean-room repro 2026-07-02):** `authenticate()` (auth.rs) gates `api poll`/`state`/`boundary` on `proof.session_id == info.json.session_id`. If ONE boundary rotation is LOST — the departing session dies (crash / tab-close) or its `/clear`-era `boundary` call never lands — the perch stays PINNED to the dead sid, and every id-scoped hook call thereafter AUTH_REFUSES *including `boundary` itself* (the very call that would re-pin presents the NEW sid). A perm...\n*677|- **Invariant:** `authenticate()` gains a DEAD-OWNER fallback — when the caller's sid MISMATCHES the pin **AND** the perch's recorded owner pid is DEAD (`proc::is_process_alive == false`), ACCEPT the caller's sid and RE-PIN (rotate `info.json.session_id` via the locked `mutate_info` RMW + a LOUD `SESSION_REPIN` stderr line). Same trust model as `establish_perch`'s conflict gate, which already allows a rebind exactly when `owner_alive == false` (an orphaned perch accepts a new LOCAL owner). This is **AD...\n*678|- **spt-core mapping:** impl `spt/src/api/auth.rs` `authenticate` dead-owner-mismatch branch (`read_pid` → `proc::is_process_alive` → `mutate_info` rotate + `SESSION_REPIN` log); unit `auth.rs` `pinned_to_dead_sid_mismatched_poll_repins` (AUTH_REFUSED → drains+re-pins, RED pre-fix) + `live_owner_mismatch_still_refuses` (the `/clear` live-rotation squat guard, unwidened) + `token_auth_path_unchanged` (correct token wins first, no rotation).\n*679|- **Source:** F-024C/F-024D (perri field RCA on ENLYZEAM + doyle code-confirm 2026-07-02; the wedge latches on `/clear` even in a CLEAN env — the env-corruption domino was sufficient but not necessary). COVERAGE: dead-owner re-pin rescues CRASHED/DEAD owners ONLY; live-pid rotation is correctly refused without departed-session proof. Cross-refs ADR-0032 (the 3-layer rotation-credential model) + `[REQ-BOUNDARY-ROTATION-CREDENTIAL]` (layer 1, the adapter/harness-contract half).\n 680|<!-- [doc->REQ-HAZARD-SESSION-PIN-WEDGE] -->\n 681|\n 682|### 7.26 Concurrent first-touch of ONE fresh BranchStore must ALL succeed — a non-atomic `git init` race must never strand a first-toucher  `[REQ-HAZARD-STORE-INIT-RACE]`\n...\n 994|- **Instrument-soundness kin (distinct, do not merge):** a *gate* whose failure signature is indistinguishable from the fault it guards is broken in the same family, and instrument tests are never requirement evidence (see the §7 note on the spawn rigs). This entry is about a PRODUCTION monitor's reported state; that one is about a TEST's verdict.\n*995|- **spt-core mapping:** `spt-daemon/src/pump/health.rs` — `PumpHealth::verdict` (the failing set outranks the live count; `DegradedPartial` requires a live peer so nothing-live stays `Connecting`), `any_fail_since_ms` + `recompute_all_fail` (the window one peer's success cannot close), `note_connected` (closes windows through the shared recompute, never by hand); `spt/src/cli.rs` `render_peer_health_line` + `peer_health_verdict_token` (the `degraded-partial` surfaces). **releases#75 addendum:** a third...\n 996|- **Source:** WAN-TRUTH batch #50 lane 3 (issue #41; doyle-ruled 2026-07-31 after the class appeared twice in one batch). Twin `REQ-PEER-HEALTH-PARTIAL-DEGRADE` (the positive requirement that repairs instance 2). Kin `REQ-PUMP-STAGE-TRUTH` (the total-failure fingerprint and per-stage attribution, which instance 1 produced and this does not change).\n 997|\n 998|### 7.62 A new wire record carries a `kind` tag — the untagged shape is already claimed, and an older daemon delivers it as chat  `[REQ-HAZARD-WIRE-UNTAGGED-RECORD]`\n...\n 1142|<!-- [doc->REQ-HAZARD-ADAPTER-APPLY-SILENT-NOOP] -->\n*1143|| 7.25 | A perch pinned to a DEAD session self-heals; a LIVE-owner rotation still refuses. `authenticate()` (auth.rs): sid mismatch AND recorded owner pid DEAD (`proc::is_process_alive`) → accept caller sid + RE-PIN (`mutate_info` rotate `session_id` + LOUD `SESSION_REPIN`) — ADR-0032 layer 2, same trust as `establish_perch`'s dead-owner rebind. LIVE-owner mismatch (a `/clear`/`/compact` live-pid rotation) STILL refuses (squat, unwidened — live-rotation proof is the adapter's layer-1 job, `[REQ-BOU...\n 1144|<!-- [doc->REQ-HAZARD-SESSION-PIN-WEDGE] -->\n 1145|| 7.26 | Concurrent first-touch of ONE fresh BranchStore all-Ok — the non-atomic `git init` (template-hook copy) + `config.lock` race must never strand a first-toucher. `open_or_init` → `init_bare_tolerant` (bounded backoff-retry on the init collision that desyncs racers + open-after-lose on a racer-created HEAD) + `config_set_locked_retry` (bounded `config.lock`-aware retry; `core.autocrlf` idempotent). Convergence guaranteed: a lone re-init on a partial dir completes. Fixes the G3-gate pump.rs:442 ...\n 1146|| 7.27 | A control/viewer stamp never outlives its session — every teardown path clears what attach stamped. `/exit` kills the CHILD (endpoint suspends on child death) so the teardown runs the REAP path (exit-waiter → `sessions.remove`), never the controller-detach path — the only pre-fix stamp-clear. The exit-waiter now calls `OutputLog::stamp_reaped()` = `set_driven_by(None)`+`set_controlled(false)`+`set_viewer_count(0)` on reap (broker single-writer, unconditional/idempotent, race-free — dead ...\n\n## adr/\n### 0002-bake-networking-into-core-collapse-spt-node.md\n 11|- **Default stays P2P** + no-account — the core promise is unchanged.\n*12|- **Hub mode (opt-in):** every node pull→merge→pushes against a private GitHub remote (always-online sync hub + live GUI view), authed by a **shared deploy key** distributed as subnet secret material (only the setup node needs a GitHub account). **Prefer-GitHub, P2P backstop.**\n 13|- **Scope:** hub mode carries the **context repo only**. Messaging, registry, remote-drive, pairing, and presence always ride P2P/Iroh; gh-on never removes the P2P requirement. So the original decision (networking baked-in, first-class, P2P) stands in full — the hub is a context-sync convenience layered on top, not a return to GitHub-as-infrastructure.\n 14|\n 15|## Context\n### 0003-multi-instance-node-anchored-identity-model.md\n 48|- **#8 — `local → most-recently-active → id@node` is ambiguous under partition/clock-skew.** Can route messages or remote-control to the wrong instance around active/dormant transitions. **Required:** registry lease/epoch using monotonic per-node epochs, not wall-clock; chaos-test stale entries + ambiguous active instances.\n*49|- **#9 — dormant-warm default has real resource/security cost.** Warm sessions retain credentials, file locks, model/billing footprint, PTYs, watchers on *every* node. **Required:** measure N dormant sessions on real adapters; define an adapter-specific warm/cold policy + resource budget before locking warm as the default (R-INST-3).\n 50|  **CLOSED 2026-06-04 (M4-D9-3):** measured with the env-gated harness (`crates/spt-daemon/tests/budget.rs`, `SPT_BUDGET=1`) on the rig — an idle warm seat burns **zero CPU**; the cost is RSS only (~8 MiB shell-class, ~300 MiB LLM-harness-class; suspended residual = the on-disk record). Policy locked in `docs/DORMANCY-BUDGET.md`: warm default **confirmed**, auto-suspend opt-in default OFF with the global → node → endpoint `auto_suspend_after_ms` knob chain (D9-2), constrained nodes default the node...\n### 0004-single-daemon-broker-brain-split-and-self-update.md\n 26|\n*27|**Self-update delivery:** peer-propagated over P2P, layered on self-fetch, out-of-band still supported. All binaries signature-verified before handoff (spt-core's own release key) regardless of source — peer-propagation otherwise lets one compromised node poison the subnet. spt-core conducts updates for the whole stack: self first, then ripple-update each registered adapter via the adapter manifest's update declaration (file-pull or delegated command). The plugin's role shrinks to initial bootstrap only.\n 28|\n 29|**Cadence/consent:** not fully automatic by default; gated on user confirmation delivered to the most-recently-active live session, with an opt-in full-auto choice.\n 30|\n### 0005-totp-seeded-spake2-node-pairing.md\n 10|\n*11|A natural-seeming option is TOTP: the user provisions a seed into an authenticator app and pairs future nodes with the rotating 6-digit code. Used the obvious way (a new node sends the code to a trusted node for verification), this is insecure: the code is a ~20-bit bearer token, replayable within its 30s window, not bound to the key being exchanged — a real-time MITM can replay the code with their own pubkey and join the subnet. It also performs no key exchange, requiring a separate unbound handshake.\n 12|\n 13|## Decision\n 14|\n*15|Adopt **TOTP-seeded SPAKE2 pairing**: keep the familiar TOTP UX but use the 6-digit code as the **password for a SPAKE2 (PAKE) handshake**, not as a verified bearer token.\n 16|\n*17|- The TOTP **seed** is the durable subnet secret: generated on the first node, shown as a QR for the user's authenticator app, and replicated to every already-trusted node.\n 18|- To pair a new node, a trusted node (online, no human — it holds the seed) computes the current code as its SPAKE2 password; the user reads the code off their phone and types it into the new node as its password. Matching codes → PAKE succeeds → pubkeys exchanged + bound, MITM-resistant encrypted channel.\n 19|\n*20|PAKE is the correct primitive for a low-entropy human-transcribed secret: it resists offline brute force (one online guess per attempt) and binds the code to the established key. Cryptographic strength comes from the PAKE construction, not from the 6 digits being secret.\n 21|\n 22|Rejected alternatives:\n*23|- **TOTP-as-bearer-token** — replayable, unbound, MITM-vulnerable (above).\n 24|- **Plain Magic Wormhole / fresh-code-each-time** — secure, but needs a human/relay at *both* ends each pairing. TOTP-seeded PAKE keeps the persistent seed on online trusted nodes so the user interacts only with the new node.\n 25|- **Paste-the-key** — too error-prone for \"just works.\"\n 26|- **Account-anchored auto-trust** — reintroduces the account dependency the product is shedding.\n...\n 36|- Adds a SPAKE2 + TOTP dependency to the core crypto surface (both have mature Rust crates per the research brief).","truncated":true,"truncatedBy":"bytes","totalLines":448,"totalBytes":72554,"outputLines":261,"outputBytes":51076,"lastLinePartial":false,"firstLineExceedsLimit":false},"linesTruncated":true,"meta":{"limits":{"columnTruncated":{"maxColumn":512}},"truncation":{"direction":"head","truncatedBy":"bytes","totalLines":448,"totalBytes":72554,"outputLines":261,"outputBytes":51076,"shownRange":{"start":1,"end":261},"nextOffset":262}}},"isError":false,"timestamp":1790039404114}}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_8xZZJTsvyv7dh14C0R43LYUO|fc_06340275dc54e8ed016ab1d5764cfc87d1a2d1ce42da56d41e","toolName":"read","startedAt":"2026-09-22T01:10:14.373Z","args":{"path":"crates/spt/src/cli.rs:9990-10120"},"intent":"Reading authentication gate implementation"},"id":"4cfa8216","parentId":"5ad089be","timestamp":"2026-09-22T01:10:14.373Z"}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_HPJfu1zX6kSiDsX7W4y0SCpg|fc_06340275dc54e8ed016ab1d5764d0c87d1b6fcfd48c4088a8e","toolName":"read","startedAt":"2026-09-22T01:10:14.373Z","args":{"path":"crates/spt-runtime/src/runtime.rs:1000-1170"},"intent":"Reading bounded child runner"},"id":"dde6769c","parentId":"4cfa8216","timestamp":"2026-09-22T01:10:14.373Z"}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_V5CBc9qXmdaX0LLcMf9jT8nU|fc_06340275dc54e8ed016ab1d5764d1487d1adf5f177c4ff0ac3","toolName":"read","startedAt":"2026-09-22T01:10:14.373Z","args":{"path":"crates/spt/src/cli.rs:10860-11165"},"intent":"Reading fetch gate and download"},"id":"387b8cd4","parentId":"dde6769c","timestamp":"2026-09-22T01:10:14.373Z"}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_GMk4pLLBud8TAR7Yj7oMvoQh|fc_06340275dc54e8ed016ab1d5764d1c87d1ade1b64ba586f420","toolName":"grep","startedAt":"2026-09-22T01:10:14.373Z","args":{"path":"crates;docs/adr/0036*"},"intent":"Locating gate tests and callers"},"id":"13da1999","parentId":"387b8cd4","timestamp":"2026-09-22T01:10:14.373Z"}
{"type":"message","id":"dcb169d5","parentId":"13da1999","timestamp":"2026-09-22T01:10:14.374Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"call_8xZZJTsvyv7dh14C0R43LYUO|fc_06340275dc54e8ed016ab1d5764cfc87d1a2d1ce42da56d41e","name":"read","arguments":{"i":"Reading authentication gate implementation","path":"crates/spt/src/cli.rs:9990-10120"},"intent":"Reading authentication gate implementation"},{"type":"toolCall","id":"call_HPJfu1zX6kSiDsX7W4y0SCpg|fc_06340275dc54e8ed016ab1d5764d0c87d1b6fcfd48c4088a8e","name":"read","arguments":{"i":"Reading bounded child runner","path":"crates/spt-runtime/src/runtime.rs:1000-1170"},"intent":"Reading bounded child runner"},{"type":"toolCall","id":"call_V5CBc9qXmdaX0LLcMf9jT8nU|fc_06340275dc54e8ed016ab1d5764d1487d1adf5f177c4ff0ac3","name":"read","arguments":{"i":"Reading fetch gate and download","path":"crates/spt/src/cli.rs:10860-11165"},"intent":"Reading fetch gate and download"},{"type":"toolCall","id":"call_GMk4pLLBud8TAR7Yj7oMvoQh|fc_06340275dc54e8ed016ab1d5764d1c87d1ade1b64ba586f420","name":"grep","arguments":{"i":"Locating gate tests and callers","pattern":"gh_available|gh_authenticated|gh_status|GhStatus|gh_auth_required|GhAuthRequired|gh_fixture|gh_version_command|resolve_transport","path":"crates;docs/adr/0036*","case":null,"gitignore":null,"skip":null},"intent":"Locating gate tests and callers"}],"api":"openai-codex-responses","provider":"openai-codex","model":"gpt-6-astra","usage":{"input":17050,"output":197,"cacheRead":15872,"cacheWrite":0,"totalTokens":33119,"cost":{"input":0.1705,"output":0.009850000000000001,"cacheRead":0.015872,"cacheWrite":0,"total":0.196222}},"stopReason":"toolUse","timestamp":1790039404157,"responseId":"resp_06340275dc54e8ed016ab1d56c70ec87d1b44996318dc15172","providerPayload":{"type":"openaiResponsesHistory","provider":"openai-codex","dt":true,"items":[{"id":"fc_06340275dc54e8ed016ab1d5764cfc87d1a2d1ce42da56d41e","type":"function_call","status":"completed","arguments":"{\"i\":\"Reading authentication gate implementation\",\"path\":\"crates/spt/src/cli.rs:9990-10120\"}","call_id":"call_8xZZJTsvyv7dh14C0R43LYUO","internal_chat_message_metadata_passthrough":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"},"name":"read","metadata":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"}},{"id":"fc_06340275dc54e8ed016ab1d5764d0c87d1b6fcfd48c4088a8e","type":"function_call","status":"completed","arguments":"{\"i\":\"Reading bounded child runner\",\"path\":\"crates/spt-runtime/src/runtime.rs:1000-1170\"}","call_id":"call_HPJfu1zX6kSiDsX7W4y0SCpg","internal_chat_message_metadata_passthrough":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"},"name":"read","metadata":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"}},{"id":"fc_06340275dc54e8ed016ab1d5764d1487d1adf5f177c4ff0ac3","type":"function_call","status":"completed","arguments":"{\"i\":\"Reading fetch gate and download\",\"path\":\"crates/spt/src/cli.rs:10860-11165\"}","call_id":"call_V5CBc9qXmdaX0LLcMf9jT8nU","internal_chat_message_metadata_passthrough":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"},"name":"read","metadata":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"}},{"id":"fc_06340275dc54e8ed016ab1d5764d1c87d1ade1b64ba586f420","type":"function_call","status":"completed","arguments":"{\"i\":\"Locating gate tests and callers\",\"pattern\":\"gh_available|gh_authenticated|gh_status|GhStatus|gh_auth_required|GhAuthRequired|gh_fixture|gh_version_command|resolve_transport\",\"path\":\"crates;docs/adr/0036*\",\"case\":null,\"gitignore\":null,\"skip\":null}","call_id":"call_GMk4pLLBud8TAR7Yj7oMvoQh","internal_chat_message_metadata_passthrough":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"},"name":"grep","metadata":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"}}]},"duration":10214.009399998933,"ttft":10165.992599999532,"completedAt":1790039414371,"contextSnapshot":{"promptTokens":32922,"nonMessageTokens":10333,"compactionEpoch":0}}}
{"type":"message","id":"9d293b03","parentId":"dcb169d5","timestamp":"2026-09-22T01:10:14.386Z","message":{"role":"toolResult","toolCallId":"call_HPJfu1zX6kSiDsX7W4y0SCpg|fc_06340275dc54e8ed016ab1d5764d0c87d1b6fcfd48c4088a8e","toolName":"read","content":[{"type":"text","text":"fn wait_bounded(\n…\n) -> Result<BoundedOutput, RuntimeError> {\n…\n    let status = loop {\n        match child.try_wait() {\n…\n            Ok(None) => {\n…\n                std::thread::sleep(Duration::from_millis(20));\n            }\n            Err(e) => return Err(RuntimeError::Spawn(e)),\n        }\n    };\n\n    let stdout = out_handle.join().unwrap_or_default();\n    let stderr = err_handle.join().unwrap_or_default();\n    Ok(BoundedOutput {\n        status_code: status.code(),\n        stdout,\n        stderr,\n    })\n}\n\n/// Run an **arbitrary bounded command template** (not a named `[session.*]`\n/// role) to completion under `timeout`, optionally feeding `stdin`. Shares the\n/// same kill-on-timeout guarantee as [`AgentRuntime::run_bounded`]\n/// (REQ-HAZARD-SUBPROCESS-TIMEOUT) — a hung child is killed, never an indefinite\n/// block.\n///\n/// This is the primitive the **history subsystem** (spt-live, M2b) runs its\n/// `fetcher` / `normalize_command` strings through: those are adapter command\n/// lines that live *outside* the role set, so [`ManifestRuntime::run_bounded`]\n/// (which looks a role up in the manifest) does not apply. `stdin` feeds a\n/// `normalize_command` the raw transcript on its standard input; it is written\n/// on a thread so a child that reads slowly never deadlocks the poll.\n// [impl->REQ-HAZARD-SUBPROCESS-TIMEOUT]\npub fn run_bounded_command(\n    template: &str,\n    keys: &BTreeMap<String, String>,\n    timeout: Duration,\n    stdin: Option<&[u8]>,\n) -> Result<BoundedOutput, RuntimeError> {\n    run_bounded_command_in(template, keys, timeout, stdin, None)\n}\n\n/// Resolve a command template's **program token** (first whitespace-split token)\n/// against an adapter's install dir before falling back to `PATH`. A `.spt`\n/// adapter ships its built binaries either to `adapters/_github/<safe>/` (via\n/// `--release`/`--github` acquisition) or keeps them in the adapter's\n/// `source_dir` under copy-mode — both are the adapter's `AdapterRecord`\n/// `source_dir`. A bare program name (`claude-spt-digest`) would otherwise miss\n/// the shipped binary and resolve only from `PATH`; resolving against the install\n/// dir first makes the adapter self-contained.\n///\n/// Returns `<install_dir>/<program>` (absolute) when that file exists — on\n/// Windows also trying the `.exe` suffix — else the original bare token\n/// unchanged (the `PATH` fallback). Pure and side-effect-free apart from the\n/// existence probe; never errors (an unresolvable token is simply passed through\n/// for the OS to resolve / fail at spawn).\n// [impl->REQ-INSTALL-11]\npub fn resolve_program_in_dir(program: &str, install_dir: &Path) -> String {\n    let direct = install_dir.join(program);\n    if direct.is_file() {\n        return direct.to_string_lossy().into_owned();\n    }\n    #[cfg(windows)]\n    {\n        if Path::new(program).extension().is_none() {\n            let exe = install_dir.join(format!(\"{program}.exe\"));\n            if exe.is_file() {\n                return exe.to_string_lossy().into_owned();\n            }\n        }\n    }\n    program.to_string()\n}\n\n/// As [`run_bounded_command`], but when `install_dir` is `Some`, the template's\n/// program token is first resolved against that adapter install dir\n/// ([`resolve_program_in_dir`]) so a bare program name binds to the adapter's\n/// shipped binary before `PATH` (REQ-INSTALL-11). `None` preserves the legacy\n/// PATH-only resolution for callers without an install dir.\n// [impl->REQ-INSTALL-11]\n// [impl->REQ-HAZARD-SUBPROCESS-TIMEOUT]\npub fn run_bounded_command_in(\n    template: &str,\n    keys: &BTreeMap<String, String>,\n    timeout: Duration,\n    stdin: Option<&[u8]>,\n    install_dir: Option<&Path>,\n) -> Result<BoundedOutput, RuntimeError> {\n    // [impl->REQ-HAZARD-TEMPLATE-ARGV-FILL] tokenize-the-template-then-fill-each so a\n    // multi-word/quote/semicolon-bearing {key} value is exactly ONE argv element.\n    let tokens = fill_template_tokens(template, keys)?;\n    let (program, args) = tokens.split_first().ok_or(RuntimeError::EmptyCommand)?;\n    // The timeout/label string mirrors the resolved command line for diagnostics.\n    let filled = tokens.join(\" \");\n    // [impl->REQ-INSTALL-11] adapter install dir wins over PATH for a bare token.\n    let program = match install_dir {\n        Some(dir) => resolve_program_in_dir(program, dir),\n        None => program.clone(),\n    };\n    let mut cmd = Command::new(&program);\n    cmd.args(args)\n        .stdin(if stdin.is_some() {\n            Stdio::piped()\n        } else {\n            Stdio::null()\n        })\n        .stdout(Stdio::piped())\n        .stderr(Stdio::piped());\n    // Bounded commands run from the console-less daemon (notif pump, manifest\n    // hooks): without CREATE_NO_WINDOW each spawn flashes a visible blank\n    // conhost window on the desktop (KNOWN-HAZARDS 5.8).\n    // [impl->REQ-HAZARD-CHILD-CONSOLE-FLASH]\n    #[cfg(windows)]\n    {\n        use std::os::windows::process::CommandExt;\n        cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW\n    }\n    let mut child = cmd.spawn().map_err(RuntimeError::Spawn)?;\n    if let Some(bytes) = stdin {\n        if let Some(mut sink) = child.stdin.take() {\n            let owned = bytes.to_vec();\n            // Write on a thread + drop the sink (EOF) so the child can finish.\n            std::thread::spawn(move || {\n                let _ = sink.write_all(&owned);\n            });\n        }\n    }\n    wait_bounded(child, timeout, &filled)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    fn keys(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {\n        pairs\n            .iter()\n            .map(|(k, v)| (k.to_string(), v.to_string()))\n            .collect()\n    }\n\n    #[test] // [unit->REQ-SEAM-SPAWN]\n    fn fills_known_placeholders() {\n        let k = keys(&[(\"session_id\", \"abc\"), (\"session_name\", \"owl\")]);\n        let out = fill_template(\"run --id {session_id} --name {session_name}\", &k).unwrap();\n        assert_eq!(out, \"run --id abc --name owl\");\n    }\n\n    #[test] // [unit->REQ-SEAM-SPAWN]\n    fn missing_key_errors_before_spawn() {\n        let k = keys(&[(\"session_id\", \"abc\")]);\n        let e = fill_template(\"run --name {session_name}\", &k).unwrap_err();\n        match e {\n            RuntimeError::MissingKey { key, .. } => assert_eq!(key, \"session_name\"),\n            other => panic!(\"expected MissingKey, got {other}\"),\n        }\n    }\n\n    // ---- REQ-DIGEST-PROFILE-ENV: `[env] direction=\"read\"` capture + resolve ----\n\n    fn manifest_env(env_toml: &str) -> Manifest {\n        let toml = format!(\n            \"[adapter]\\nname = \\\"cc\\\"\\nversion = \\\"1.0.0\\\"\\nmin_spt_core_version = \\\"0.1.0\\\"\\n\\n{env_toml}\"\n        );\n        Manifest::from_toml_str(&toml).expect(\"manifest parses\")\n    }\n\n    // [unit->REQ-DIGEST-PROFILE-ENV] Bind capture is an ALLOWLIST: only vars the\n    // manifest declares `direction = \"read\"` are captured, and only when the ambient\n    // env has them — an inject var and an undeclared env var are never captured\n    // (never the whole env; privacy).\n    #[test]\n    fn capture_read_env_is_declared_allowlist_only() {\n        let m = manifest_env(\n            \"[env.CLAUDE_CONFIG_DIR]\\ndirection = \\\"read\\\"\\nvalue = \\\"~/.claude\\\"\\n\\n\\\n             [env.OWL_SESSION_ID]\\ndirection = \\\"inject\\\"\\nvalue = \\\"{session_id}\\\"\\n\",\n        );\n        let ambient = keys(&[\n            (\"CLAUDE_CONFIG_DIR\", \"/home/acct/.ccs/projects\"),\n            (\"OWL_SESSION_ID\", \"sess\"), // an inject var — must NOT be captured\n…\n        ]);\n…\n    }\n…\n}\n\n[Showing lines 999-1173 of 1789. Use :1174 to continue]"}],"details":{"truncation":{"content":"                std::thread::sleep(Duration::from_millis(20));\r\n            }\r\n            Err(e) => return Err(RuntimeError::Spawn(e)),\r\n        }\r\n    };\r\n\r\n    let stdout = out_handle.join().unwrap_or_default();\r\n    let stderr = err_handle.join().unwrap_or_default();\r\n    Ok(BoundedOutput {\r\n        status_code: status.code(),\r\n        stdout,\r\n        stderr,\r\n    })\r\n}\r\n\r\n/// Run an **arbitrary bounded command template** (not a named `[session.*]`\r\n/// role) to completion under `timeout`, optionally feeding `stdin`. Shares the\r\n/// same kill-on-timeout guarantee as [`AgentRuntime::run_bounded`]\r\n/// (REQ-HAZARD-SUBPROCESS-TIMEOUT) — a hung child is killed, never an indefinite\r\n/// block.\r\n///\r\n/// This is the primitive the **history subsystem** (spt-live, M2b) runs its\r\n/// `fetcher` / `normalize_command` strings through: those are adapter command\r\n/// lines that live *outside* the role set, so [`ManifestRuntime::run_bounded`]\r\n/// (which looks a role up in the manifest) does not apply. `stdin` feeds a\r\n/// `normalize_command` the raw transcript on its standard input; it is written\r\n/// on a thread so a child that reads slowly never deadlocks the poll.\r\n// [impl->REQ-HAZARD-SUBPROCESS-TIMEOUT]\r\npub fn run_bounded_command(\r\n    template: &str,\r\n    keys: &BTreeMap<String, String>,\r\n    timeout: Duration,\r\n    stdin: Option<&[u8]>,\r\n) -> Result<BoundedOutput, RuntimeError> {\r\n    run_bounded_command_in(template, keys, timeout, stdin, None)\r\n}\r\n\r\n/// Resolve a command template's **program token** (first whitespace-split token)\r\n/// against an adapter's install dir before falling back to `PATH`. A `.spt`\r\n/// adapter ships its built binaries either to `adapters/_github/<safe>/` (via\r\n/// `--release`/`--github` acquisition) or keeps them in the adapter's\r\n/// `source_dir` under copy-mode — both are the adapter's `AdapterRecord`\r\n/// `source_dir`. A bare program name (`claude-spt-digest`) would otherwise miss\r\n/// the shipped binary and resolve only from `PATH`; resolving against the install\r\n/// dir first makes the adapter self-contained.\r\n///\r\n/// Returns `<install_dir>/<program>` (absolute) when that file exists — on\r\n/// Windows also trying the `.exe` suffix — else the original bare token\r\n/// unchanged (the `PATH` fallback). Pure and side-effect-free apart from the\r\n/// existence probe; never errors (an unresolvable token is simply passed through\r\n/// for the OS to resolve / fail at spawn).\r\n// [impl->REQ-INSTALL-11]\r\npub fn resolve_program_in_dir(program: &str, install_dir: &Path) -> String {\r\n    let direct = install_dir.join(program);\r\n    if direct.is_file() {\r\n        return direct.to_string_lossy().into_owned();\r\n    }\r\n    #[cfg(windows)]\r\n    {\r\n        if Path::new(program).extension().is_none() {\r\n            let exe = install_dir.join(format!(\"{program}.exe\"));\r\n            if exe.is_file() {\r\n                return exe.to_string_lossy().into_owned();\r\n            }\r\n        }\r\n    }\r\n    program.to_string()\r\n}\r\n\r\n/// As [`run_bounded_command`], but when `install_dir` is `Some`, the template's\r\n/// program token is first resolved against that adapter install dir\r\n/// ([`resolve_program_in_dir`]) so a bare program name binds to the adapter's\r\n/// shipped binary before `PATH` (REQ-INSTALL-11). `None` preserves the legacy\r\n/// PATH-only resolution for callers without an install dir.\r\n// [impl->REQ-INSTALL-11]\r\n// [impl->REQ-HAZARD-SUBPROCESS-TIMEOUT]\r\npub fn run_bounded_command_in(\r\n    template: &str,\r\n    keys: &BTreeMap<String, String>,\r\n    timeout: Duration,\r\n    stdin: Option<&[u8]>,\r\n    install_dir: Option<&Path>,\r\n) -> Result<BoundedOutput, RuntimeError> {\r\n    // [impl->REQ-HAZARD-TEMPLATE-ARGV-FILL] tokenize-the-template-then-fill-each so a\r\n    // multi-word/quote/semicolon-bearing {key} value is exactly ONE argv element.\r\n    let tokens = fill_template_tokens(template, keys)?;\r\n    let (program, args) = tokens.split_first().ok_or(RuntimeError::EmptyCommand)?;\r\n    // The timeout/label string mirrors the resolved command line for diagnostics.\r\n    let filled = tokens.join(\" \");\r\n    // [impl->REQ-INSTALL-11] adapter install dir wins over PATH for a bare token.\r\n    let program = match install_dir {\r\n        Some(dir) => resolve_program_in_dir(program, dir),\r\n        None => program.clone(),\r\n    };\r\n    let mut cmd = Command::new(&program);\r\n    cmd.args(args)\r\n        .stdin(if stdin.is_some() {\r\n            Stdio::piped()\r\n        } else {\r\n            Stdio::null()\r\n        })\r\n        .stdout(Stdio::piped())\r\n        .stderr(Stdio::piped());\r\n    // Bounded commands run from the console-less daemon (notif pump, manifest\r\n    // hooks): without CREATE_NO_WINDOW each spawn flashes a visible blank\r\n    // conhost window on the desktop (KNOWN-HAZARDS 5.8).\r\n    // [impl->REQ-HAZARD-CHILD-CONSOLE-FLASH]\r\n    #[cfg(windows)]\r\n    {\r\n        use std::os::windows::process::CommandExt;\r\n        cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW\r\n    }\r\n    let mut child = cmd.spawn().map_err(RuntimeError::Spawn)?;\r\n    if let Some(bytes) = stdin {\r\n        if let Some(mut sink) = child.stdin.take() {\r\n            let owned = bytes.to_vec();\r\n            // Write on a thread + drop the sink (EOF) so the child can finish.\r\n            std::thread::spawn(move || {\r\n                let _ = sink.write_all(&owned);\r\n            });\r\n        }\r\n    }\r\n    wait_bounded(child, timeout, &filled)\r\n}\r\n\r\n#[cfg(test)]\r\nmod tests {\r\n    use super::*;\r\n\r\n    fn keys(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {\r\n        pairs\r\n            .iter()\r\n            .map(|(k, v)| (k.to_string(), v.to_string()))\r\n            .collect()\r\n    }\r\n\r\n    #[test] // [unit->REQ-SEAM-SPAWN]\r\n    fn fills_known_placeholders() {\r\n        let k = keys(&[(\"session_id\", \"abc\"), (\"session_name\", \"owl\")]);\r\n        let out = fill_template(\"run --id {session_id} --name {session_name}\", &k).unwrap();\r\n        assert_eq!(out, \"run --id abc --name owl\");\r\n    }\r\n\r\n    #[test] // [unit->REQ-SEAM-SPAWN]\r\n    fn missing_key_errors_before_spawn() {\r\n        let k = keys(&[(\"session_id\", \"abc\")]);\r\n        let e = fill_template(\"run --name {session_name}\", &k).unwrap_err();\r\n        match e {\r\n            RuntimeError::MissingKey { key, .. } => assert_eq!(key, \"session_name\"),\r\n            other => panic!(\"expected MissingKey, got {other}\"),\r\n        }\r\n    }\r\n\r\n    // ---- REQ-DIGEST-PROFILE-ENV: `[env] direction=\"read\"` capture + resolve ----\r\n\r\n    fn manifest_env(env_toml: &str) -> Manifest {\r\n        let toml = format!(\r\n            \"[adapter]\\nname = \\\"cc\\\"\\nversion = \\\"1.0.0\\\"\\nmin_spt_core_version = \\\"0.1.0\\\"\\n\\n{env_toml}\"\r\n        );\r\n        Manifest::from_toml_str(&toml).expect(\"manifest parses\")\r\n    }\r\n\r\n    // [unit->REQ-DIGEST-PROFILE-ENV] Bind capture is an ALLOWLIST: only vars the\r\n    // manifest declares `direction = \"read\"` are captured, and only when the ambient\r\n    // env has them — an inject var and an undeclared env var are never captured\r\n    // (never the whole env; privacy).\r\n    #[test]\r\n    fn capture_read_env_is_declared_allowlist_only() {\r\n        let m = manifest_env(\r\n            \"[env.CLAUDE_CONFIG_DIR]\\ndirection = \\\"read\\\"\\nvalue = \\\"~/.claude\\\"\\n\\n\\\r\n             [env.OWL_SESSION_ID]\\ndirection = \\\"inject\\\"\\nvalue = \\\"{session_id}\\\"\\n\",\r\n        );\r\n        let ambient = keys(&[\r\n            (\"CLAUDE_CONFIG_DIR\", \"/home/acct/.ccs/projects\"),\r\n            (\"OWL_SESSION_ID\", \"sess\"), // an inject var — must NOT be captured\r","truncated":true,"truncatedBy":"lines","totalLines":791,"totalBytes":7496,"outputLines":175,"outputBytes":7496,"lastLinePartial":false,"firstLineExceedsLimit":false},"totalLines":1789,"displayContent":{"text":"fn wait_bounded(\n…\n) -> Result<BoundedOutput, RuntimeError> {\n…\n    let status = loop {\n        match child.try_wait() {\n…\n            Ok(None) => {\n…\n                std::thread::sleep(Duration::from_millis(20));\r\n            }\r\n            Err(e) => return Err(RuntimeError::Spawn(e)),\r\n        }\r\n    };\r\n\r\n    let stdout = out_handle.join().unwrap_or_default();\r\n    let stderr = err_handle.join().unwrap_or_default();\r\n    Ok(BoundedOutput {\r\n        status_code: status.code(),\r\n        stdout,\r\n        stderr,\r\n    })\r\n}\r\n\r\n/// Run an **arbitrary bounded command template** (not a named `[session.*]`\r\n/// role) to completion under `timeout`, optionally feeding `stdin`. Shares the\r\n/// same kill-on-timeout guarantee as [`AgentRuntime::run_bounded`]\r\n/// (REQ-HAZARD-SUBPROCESS-TIMEOUT) — a hung child is killed, never an indefinite\r\n/// block.\r\n///\r\n/// This is the primitive the **history subsystem** (spt-live, M2b) runs its\r\n/// `fetcher` / `normalize_command` strings through: those are adapter command\r\n/// lines that live *outside* the role set, so [`ManifestRuntime::run_bounded`]\r\n/// (which looks a role up in the manifest) does not apply. `stdin` feeds a\r\n/// `normalize_command` the raw transcript on its standard input; it is written\r\n/// on a thread so a child that reads slowly never deadlocks the poll.\r\n// [impl->REQ-HAZARD-SUBPROCESS-TIMEOUT]\r\npub fn run_bounded_command(\r\n    template: &str,\r\n    keys: &BTreeMap<String, String>,\r\n    timeout: Duration,\r\n    stdin: Option<&[u8]>,\r\n) -> Result<BoundedOutput, RuntimeError> {\r\n    run_bounded_command_in(template, keys, timeout, stdin, None)\r\n}\r\n\r\n/// Resolve a command template's **program token** (first whitespace-split token)\r\n/// against an adapter's install dir before falling back to `PATH`. A `.spt`\r\n/// adapter ships its built binaries either to `adapters/_github/<safe>/` (via\r\n/// `--release`/`--github` acquisition) or keeps them in the adapter's\r\n/// `source_dir` under copy-mode — both are the adapter's `AdapterRecord`\r\n/// `source_dir`. A bare program name (`claude-spt-digest`) would otherwise miss\r\n/// the shipped binary and resolve only from `PATH`; resolving against the install\r\n/// dir first makes the adapter self-contained.\r\n///\r\n/// Returns `<install_dir>/<program>` (absolute) when that file exists — on\r\n/// Windows also trying the `.exe` suffix — else the original bare token\r\n/// unchanged (the `PATH` fallback). Pure and side-effect-free apart from the\r\n/// existence probe; never errors (an unresolvable token is simply passed through\r\n/// for the OS to resolve / fail at spawn).\r\n// [impl->REQ-INSTALL-11]\r\npub fn resolve_program_in_dir(program: &str, install_dir: &Path) -> String {\r\n    let direct = install_dir.join(program);\r\n    if direct.is_file() {\r\n        return direct.to_string_lossy().into_owned();\r\n    }\r\n    #[cfg(windows)]\r\n    {\r\n        if Path::new(program).extension().is_none() {\r\n            let exe = install_dir.join(format!(\"{program}.exe\"));\r\n            if exe.is_file() {\r\n                return exe.to_string_lossy().into_owned();\r\n            }\r\n        }\r\n    }\r\n    program.to_string()\r\n}\r\n\r\n/// As [`run_bounded_command`], but when `install_dir` is `Some`, the template's\r\n/// program token is first resolved against that adapter install dir\r\n/// ([`resolve_program_in_dir`]) so a bare program name binds to the adapter's\r\n/// shipped binary before `PATH` (REQ-INSTALL-11). `None` preserves the legacy\r\n/// PATH-only resolution for callers without an install dir.\r\n// [impl->REQ-INSTALL-11]\r\n// [impl->REQ-HAZARD-SUBPROCESS-TIMEOUT]\r\npub fn run_bounded_command_in(\r\n    template: &str,\r\n    keys: &BTreeMap<String, String>,\r\n    timeout: Duration,\r\n    stdin: Option<&[u8]>,\r\n    install_dir: Option<&Path>,\r\n) -> Result<BoundedOutput, RuntimeError> {\r\n    // [impl->REQ-HAZARD-TEMPLATE-ARGV-FILL] tokenize-the-template-then-fill-each so a\r\n    // multi-word/quote/semicolon-bearing {key} value is exactly ONE argv element.\r\n    let tokens = fill_template_tokens(template, keys)?;\r\n    let (program, args) = tokens.split_first().ok_or(RuntimeError::EmptyCommand)?;\r\n    // The timeout/label string mirrors the resolved command line for diagnostics.\r\n    let filled = tokens.join(\" \");\r\n    // [impl->REQ-INSTALL-11] adapter install dir wins over PATH for a bare token.\r\n    let program = match install_dir {\r\n        Some(dir) => resolve_program_in_dir(program, dir),\r\n        None => program.clone(),\r\n    };\r\n    let mut cmd = Command::new(&program);\r\n    cmd.args(args)\r\n        .stdin(if stdin.is_some() {\r\n            Stdio::piped()\r\n        } else {\r\n            Stdio::null()\r\n        })\r\n        .stdout(Stdio::piped())\r\n        .stderr(Stdio::piped());\r\n    // Bounded commands run from the console-less daemon (notif pump, manifest\r\n    // hooks): without CREATE_NO_WINDOW each spawn flashes a visible blank\r\n    // conhost window on the desktop (KNOWN-HAZARDS 5.8).\r\n    // [impl->REQ-HAZARD-CHILD-CONSOLE-FLASH]\r\n    #[cfg(windows)]\r\n    {\r\n        use std::os::windows::process::CommandExt;\r\n        cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW\r\n    }\r\n    let mut child = cmd.spawn().map_err(RuntimeError::Spawn)?;\r\n    if let Some(bytes) = stdin {\r\n        if let Some(mut sink) = child.stdin.take() {\r\n            let owned = bytes.to_vec();\r\n            // Write on a thread + drop the sink (EOF) so the child can finish.\r\n            std::thread::spawn(move || {\r\n                let _ = sink.write_all(&owned);\r\n            });\r\n        }\r\n    }\r\n    wait_bounded(child, timeout, &filled)\r\n}\r\n\r\n#[cfg(test)]\r\nmod tests {\r\n    use super::*;\r\n\r\n    fn keys(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {\r\n        pairs\r\n            .iter()\r\n            .map(|(k, v)| (k.to_string(), v.to_string()))\r\n            .collect()\r\n    }\r\n\r\n    #[test] // [unit->REQ-SEAM-SPAWN]\r\n    fn fills_known_placeholders() {\r\n        let k = keys(&[(\"session_id\", \"abc\"), (\"session_name\", \"owl\")]);\r\n        let out = fill_template(\"run --id {session_id} --name {session_name}\", &k).unwrap();\r\n        assert_eq!(out, \"run --id abc --name owl\");\r\n    }\r\n\r\n    #[test] // [unit->REQ-SEAM-SPAWN]\r\n    fn missing_key_errors_before_spawn() {\r\n        let k = keys(&[(\"session_id\", \"abc\")]);\r\n        let e = fill_template(\"run --name {session_name}\", &k).unwrap_err();\r\n        match e {\r\n            RuntimeError::MissingKey { key, .. } => assert_eq!(key, \"session_name\"),\r\n            other => panic!(\"expected MissingKey, got {other}\"),\r\n        }\r\n    }\r\n\r\n    // ---- REQ-DIGEST-PROFILE-ENV: `[env] direction=\"read\"` capture + resolve ----\r\n\r\n    fn manifest_env(env_toml: &str) -> Manifest {\r\n        let toml = format!(\r\n            \"[adapter]\\nname = \\\"cc\\\"\\nversion = \\\"1.0.0\\\"\\nmin_spt_core_version = \\\"0.1.0\\\"\\n\\n{env_toml}\"\r\n        );\r\n        Manifest::from_toml_str(&toml).expect(\"manifest parses\")\r\n    }\r\n\r\n    // [unit->REQ-DIGEST-PROFILE-ENV] Bind capture is an ALLOWLIST: only vars the\r\n    // manifest declares `direction = \"read\"` are captured, and only when the ambient\r\n    // env has them — an inject var and an undeclared env var are never captured\r\n    // (never the whole env; privacy).\r\n    #[test]\r\n    fn capture_read_env_is_declared_allowlist_only() {\r\n        let m = manifest_env(\r\n            \"[env.CLAUDE_CONFIG_DIR]\\ndirection = \\\"read\\\"\\nvalue = \\\"~/.claude\\\"\\n\\n\\\r\n             [env.OWL_SESSION_ID]\\ndirection = \\\"inject\\\"\\nvalue = \\\"{session_id}\\\"\\n\",\r\n        );\r\n        let ambient = keys(&[\r\n            (\"CLAUDE_CONFIG_DIR\", \"/home/acct/.ccs/projects\"),\r\n            (\"OWL_SESSION_ID\", \"sess\"), // an inject var — must NOT be captured\r\n…\n        ]);\n…\n    }\n…\n}","startLine":961,"lineNumbers":[961,null,965,null,985,986,null,988,null,999,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,1081,1082,1083,1084,1085,1086,1087,1088,1089,1090,1091,1092,1093,1094,1095,1096,1097,1098,1099,1100,1101,1102,1103,1104,1105,1106,1107,1108,1109,1110,1111,1112,1113,1114,1115,1116,1117,1118,1119,1120,1121,1122,1123,1124,1125,1126,1127,1128,1129,1130,1131,1132,1133,1134,1135,1136,1137,1138,1139,1140,1141,1142,1143,1144,1145,1146,1147,1148,1149,1150,1151,1152,1153,1154,1155,1156,1157,1158,1159,1160,1161,1162,1163,1164,1165,1166,1167,1168,1169,1170,1171,1172,1173,null,1175,null,1190,null,1789]},"fileSize":84125,"meta":{"source":{"type":"path","value":"C:\\Users\\decid\\Documents\\projects\\spt-core\\crates\\spt-runtime\\src\\runtime.rs"},"truncation":{"direction":"head","truncatedBy":"lines","totalLines":1789,"totalBytes":7496,"outputLines":175,"outputBytes":7496,"shownRange":{"start":999,"end":1173},"nextOffset":1174}}},"isError":false,"timestamp":1790039414386}}
{"type":"message","id":"34e6c216","parentId":"9d293b03","timestamp":"2026-09-22T01:10:14.579Z","message":{"role":"toolResult","toolCallId":"call_8xZZJTsvyv7dh14C0R43LYUO|fc_06340275dc54e8ed016ab1d5764cfc87d1a2d1ce42da56d41e","toolName":"read","content":[{"type":"text","text":"enum EffectiveTransport {\n    Https,\n    Gh,\n}\n\n/// Resolve a declared [`Transport`](spt_runtime::manifest::Transport) to the\n/// concrete avenue. `Auto` consults `gh_available` (a bounded probe) — `gh` when\n/// installed+authed, else HTTPS. Pure given the probe, so the decision is\n/// unit-testable with an injected closure.\n// [impl->REQ-ADAPTER-GH-TRANSPORT]\nfn resolve_transport(\n    declared: spt_runtime::manifest::Transport,\n    gh_available: impl FnOnce() -> bool,\n) -> EffectiveTransport {\n    use spt_runtime::manifest::Transport;\n    match declared {\n        Transport::Https => EffectiveTransport::Https,\n        Transport::Gh => EffectiveTransport::Gh,\n        Transport::Auto => {\n            if gh_available() {\n                EffectiveTransport::Gh\n            } else {\n                EffectiveTransport::Https\n            }\n        }\n    }\n}\n\n/// Bounded probe: is the `gh` CLI installed AND authenticated? `gh auth status`\n/// exits 0 only when a host is logged in. Bounded (REQ-HAZARD-SUBPROCESS-TIMEOUT);\n/// any spawn error / non-zero / timeout ⇒ unavailable (fall back to HTTPS).\n// [impl->REQ-ADAPTER-GH-TRANSPORT]\nfn gh_available() -> bool {\n    matches!(\n        spt_runtime::run_bounded_command(\n            \"gh auth status\",\n            &std::collections::BTreeMap::new(),\n            Duration::from_secs(10),\n            None,\n        ),\n        Ok(out) if out.success()\n    )\n}\n\n/// The default self-update release channel: the PRIVATE gh-carried repo\n/// (ADR-0036). `SPT_INSTALL_REPO` overrides — the staging/test seam the\n/// installer scripts already honor.\n// [impl->REQ-UPDATE-GH-TRANSPORT]\nconst DEFAULT_INSTALL_REPO: &str = \"BigscreenVR/spt-bs-releases\";\n\n/// The `owner/repo` the self-update fetch targets (`SPT_INSTALL_REPO` seam,\n/// else the private default).\n// [impl->REQ-UPDATE-GH-TRANSPORT]\nfn install_repo() -> String {\n    std::env::var(\"SPT_INSTALL_REPO\").unwrap_or_else(|_| DEFAULT_INSTALL_REPO.to_string())\n}\n\n/// The gh CLI's availability, split into the two actionable failure classes the\n/// mandated carrier needs (ADR-0036 §3): MISSING (not installed — the fix is an\n/// OS package command) vs UNAUTHED (installed but no logged-in host — the fix is\n/// `gh auth login`). A probe timeout classifies as Unauthed: gh exists (it\n/// spawned) but cannot answer for its auth state.\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum GhStatus {\n    Available,\n    Missing,\n    Unauthed,\n}\n\n/// Bounded probe behind [`GhStatus`]: `gh auth status` exits 0 only when a host\n/// is logged in; a spawn `NotFound` is the not-installed case. Bounded\n/// (REQ-HAZARD-SUBPROCESS-TIMEOUT).\n// [impl->REQ-UPDATE-GH-TRANSPORT]\nfn gh_status() -> GhStatus {\n    match spt_runtime::run_bounded_command(\n        \"gh auth status\",\n        &std::collections::BTreeMap::new(),\n        Duration::from_secs(10),\n        None,\n    ) {\n        Ok(out) if out.success() => GhStatus::Available,\n        Err(spt_runtime::RuntimeError::Spawn(e))\n            if e.kind() == std::io::ErrorKind::NotFound =>\n        {\n            GhStatus::Missing\n        }\n        _ => GhStatus::Unauthed,\n    }\n}\n\n/// OS-specific install hint for the gh CLI, keyed on an `std::env::consts::OS`\n/// string. Pure — unit-tested per OS.\n// [impl->REQ-UPDATE-GH-TRANSPORT]\nfn gh_install_hint(os: &str) -> &'static str {\n    match os {\n        \"windows\" => \"winget install --id GitHub.cli\",\n        \"macos\" => \"brew install gh\",\n        // spacerun-ok: command-annotation — spacing separates a copy-pasteable\n        // command from the note about it, so the command pastes cleanly.\n        _ => \"sudo apt install gh   (or your distro's package manager)\",\n    }\n}\n\n/// Render the loud refusal for a fetch attempted without the gh CLI installed.\n/// Pure — the caller supplies `std::env::consts::OS`.\n// [impl->REQ-UPDATE-GH-TRANSPORT]\nfn render_gh_cli_required(os: &str) -> String {\n    format!(\n        \"UPDATE_FETCH_REJECTED:GhCliRequired — the release channel is private and \\\n         spt fetches releases through the GitHub CLI (gh). Install it:\\n  {}\\n\\\n         then authenticate:  gh auth login\",\n        gh_install_hint(os)\n    )\n}\n\n/// Render the loud refusal for a fetch attempted with gh installed but no\n/// authenticated host. Pure.\n// [impl->REQ-UPDATE-GH-TRANSPORT]\nfn render_gh_auth_required() -> String {\n    \"UPDATE_FETCH_REJECTED:GhAuthRequired — the gh CLI is installed but not \\\n     authenticated. Run `gh auth login` with an account that can read the \\\n     release channel.\"\n        .to_string()\n}\n\n// ───────────────────────────── docs verbs (W3) ───────────────────────────────\n\nenum DocsUrlPolicy {\n    LiveOnly,\n    AllowOfflineFallback,\n}\n\n/// Update pointers require a discovered listener; the docs verb also permits\n/// the existing offline bind-config fallback when no daemon is running.\n// [impl->REQ-WEB-URL-BOUND-PORT]\n\n[Showing lines 9989-10123 of 40000. Use :10124 to continue]"}],"details":{"truncation":{"content":"enum EffectiveTransport {\r\n    Https,\r\n    Gh,\r\n}\r\n\r\n/// Resolve a declared [`Transport`](spt_runtime::manifest::Transport) to the\r\n/// concrete avenue. `Auto` consults `gh_available` (a bounded probe) — `gh` when\r\n/// installed+authed, else HTTPS. Pure given the probe, so the decision is\r\n/// unit-testable with an injected closure.\r\n// [impl->REQ-ADAPTER-GH-TRANSPORT]\r\nfn resolve_transport(\r\n    declared: spt_runtime::manifest::Transport,\r\n    gh_available: impl FnOnce() -> bool,\r\n) -> EffectiveTransport {\r\n    use spt_runtime::manifest::Transport;\r\n    match declared {\r\n        Transport::Https => EffectiveTransport::Https,\r\n        Transport::Gh => EffectiveTransport::Gh,\r\n        Transport::Auto => {\r\n            if gh_available() {\r\n                EffectiveTransport::Gh\r\n            } else {\r\n                EffectiveTransport::Https\r\n            }\r\n        }\r\n    }\r\n}\r\n\r\n/// Bounded probe: is the `gh` CLI installed AND authenticated? `gh auth status`\r\n/// exits 0 only when a host is logged in. Bounded (REQ-HAZARD-SUBPROCESS-TIMEOUT);\r\n/// any spawn error / non-zero / timeout ⇒ unavailable (fall back to HTTPS).\r\n// [impl->REQ-ADAPTER-GH-TRANSPORT]\r\nfn gh_available() -> bool {\r\n    matches!(\r\n        spt_runtime::run_bounded_command(\r\n            \"gh auth status\",\r\n            &std::collections::BTreeMap::new(),\r\n            Duration::from_secs(10),\r\n            None,\r\n        ),\r\n        Ok(out) if out.success()\r\n    )\r\n}\r\n\r\n/// The default self-update release channel: the PRIVATE gh-carried repo\r\n/// (ADR-0036). `SPT_INSTALL_REPO` overrides — the staging/test seam the\r\n/// installer scripts already honor.\r\n// [impl->REQ-UPDATE-GH-TRANSPORT]\r\nconst DEFAULT_INSTALL_REPO: &str = \"BigscreenVR/spt-bs-releases\";\r\n\r\n/// The `owner/repo` the self-update fetch targets (`SPT_INSTALL_REPO` seam,\r\n/// else the private default).\r\n// [impl->REQ-UPDATE-GH-TRANSPORT]\r\nfn install_repo() -> String {\r\n    std::env::var(\"SPT_INSTALL_REPO\").unwrap_or_else(|_| DEFAULT_INSTALL_REPO.to_string())\r\n}\r\n\r\n/// The gh CLI's availability, split into the two actionable failure classes the\r\n/// mandated carrier needs (ADR-0036 §3): MISSING (not installed — the fix is an\r\n/// OS package command) vs UNAUTHED (installed but no logged-in host — the fix is\r\n/// `gh auth login`). A probe timeout classifies as Unauthed: gh exists (it\r\n/// spawned) but cannot answer for its auth state.\r\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\r\nenum GhStatus {\r\n    Available,\r\n    Missing,\r\n    Unauthed,\r\n}\r\n\r\n/// Bounded probe behind [`GhStatus`]: `gh auth status` exits 0 only when a host\r\n/// is logged in; a spawn `NotFound` is the not-installed case. Bounded\r\n/// (REQ-HAZARD-SUBPROCESS-TIMEOUT).\r\n// [impl->REQ-UPDATE-GH-TRANSPORT]\r\nfn gh_status() -> GhStatus {\r\n    match spt_runtime::run_bounded_command(\r\n        \"gh auth status\",\r\n        &std::collections::BTreeMap::new(),\r\n        Duration::from_secs(10),\r\n        None,\r\n    ) {\r\n        Ok(out) if out.success() => GhStatus::Available,\r\n        Err(spt_runtime::RuntimeError::Spawn(e))\r\n            if e.kind() == std::io::ErrorKind::NotFound =>\r\n        {\r\n            GhStatus::Missing\r\n        }\r\n        _ => GhStatus::Unauthed,\r\n    }\r\n}\r\n\r\n/// OS-specific install hint for the gh CLI, keyed on an `std::env::consts::OS`\r\n/// string. Pure — unit-tested per OS.\r\n// [impl->REQ-UPDATE-GH-TRANSPORT]\r\nfn gh_install_hint(os: &str) -> &'static str {\r\n    match os {\r\n        \"windows\" => \"winget install --id GitHub.cli\",\r\n        \"macos\" => \"brew install gh\",\r\n        // spacerun-ok: command-annotation — spacing separates a copy-pasteable\r\n        // command from the note about it, so the command pastes cleanly.\r\n        _ => \"sudo apt install gh   (or your distro's package manager)\",\r\n    }\r\n}\r\n\r\n/// Render the loud refusal for a fetch attempted without the gh CLI installed.\r\n/// Pure — the caller supplies `std::env::consts::OS`.\r\n// [impl->REQ-UPDATE-GH-TRANSPORT]\r\nfn render_gh_cli_required(os: &str) -> String {\r\n    format!(\r\n        \"UPDATE_FETCH_REJECTED:GhCliRequired — the release channel is private and \\\r\n         spt fetches releases through the GitHub CLI (gh). Install it:\\n  {}\\n\\\r\n         then authenticate:  gh auth login\",\r\n        gh_install_hint(os)\r\n    )\r\n}\r\n\r\n/// Render the loud refusal for a fetch attempted with gh installed but no\r\n/// authenticated host. Pure.\r\n// [impl->REQ-UPDATE-GH-TRANSPORT]\r\nfn render_gh_auth_required() -> String {\r\n    \"UPDATE_FETCH_REJECTED:GhAuthRequired — the gh CLI is installed but not \\\r\n     authenticated. Run `gh auth login` with an account that can read the \\\r\n     release channel.\"\r\n        .to_string()\r\n}\r\n\r\n// ───────────────────────────── docs verbs (W3) ───────────────────────────────\r\n\r\nenum DocsUrlPolicy {\r\n    LiveOnly,\r\n    AllowOfflineFallback,\r\n}\r\n\r\n/// Update pointers require a discovered listener; the docs verb also permits\r\n/// the existing offline bind-config fallback when no daemon is running.\r\n// [impl->REQ-WEB-URL-BOUND-PORT]\r","truncated":true,"truncatedBy":"lines","totalLines":30012,"totalBytes":5130,"outputLines":135,"outputBytes":5130,"lastLinePartial":false,"firstLineExceedsLimit":false},"totalLines":40000,"displayContent":{"text":"enum EffectiveTransport {\r\n    Https,\r\n    Gh,\r\n}\r\n\r\n/// Resolve a declared [`Transport`](spt_runtime::manifest::Transport) to the\r\n/// concrete avenue. `Auto` consults `gh_available` (a bounded probe) — `gh` when\r\n/// installed+authed, else HTTPS. Pure given the probe, so the decision is\r\n/// unit-testable with an injected closure.\r\n// [impl->REQ-ADAPTER-GH-TRANSPORT]\r\nfn resolve_transport(\r\n    declared: spt_runtime::manifest::Transport,\r\n    gh_available: impl FnOnce() -> bool,\r\n) -> EffectiveTransport {\r\n    use spt_runtime::manifest::Transport;\r\n    match declared {\r\n        Transport::Https => EffectiveTransport::Https,\r\n        Transport::Gh => EffectiveTransport::Gh,\r\n        Transport::Auto => {\r\n            if gh_available() {\r\n                EffectiveTransport::Gh\r\n            } else {\r\n                EffectiveTransport::Https\r\n            }\r\n        }\r\n    }\r\n}\r\n\r\n/// Bounded probe: is the `gh` CLI installed AND authenticated? `gh auth status`\r\n/// exits 0 only when a host is logged in. Bounded (REQ-HAZARD-SUBPROCESS-TIMEOUT);\r\n/// any spawn error / non-zero / timeout ⇒ unavailable (fall back to HTTPS).\r\n// [impl->REQ-ADAPTER-GH-TRANSPORT]\r\nfn gh_available() -> bool {\r\n    matches!(\r\n        spt_runtime::run_bounded_command(\r\n            \"gh auth status\",\r\n            &std::collections::BTreeMap::new(),\r\n            Duration::from_secs(10),\r\n            None,\r\n        ),\r\n        Ok(out) if out.success()\r\n    )\r\n}\r\n\r\n/// The default self-update release channel: the PRIVATE gh-carried repo\r\n/// (ADR-0036). `SPT_INSTALL_REPO` overrides — the staging/test seam the\r\n/// installer scripts already honor.\r\n// [impl->REQ-UPDATE-GH-TRANSPORT]\r\nconst DEFAULT_INSTALL_REPO: &str = \"BigscreenVR/spt-bs-releases\";\r\n\r\n/// The `owner/repo` the self-update fetch targets (`SPT_INSTALL_REPO` seam,\r\n/// else the private default).\r\n// [impl->REQ-UPDATE-GH-TRANSPORT]\r\nfn install_repo() -> String {\r\n    std::env::var(\"SPT_INSTALL_REPO\").unwrap_or_else(|_| DEFAULT_INSTALL_REPO.to_string())\r\n}\r\n\r\n/// The gh CLI's availability, split into the two actionable failure classes the\r\n/// mandated carrier needs (ADR-0036 §3): MISSING (not installed — the fix is an\r\n/// OS package command) vs UNAUTHED (installed but no logged-in host — the fix is\r\n/// `gh auth login`). A probe timeout classifies as Unauthed: gh exists (it\r\n/// spawned) but cannot answer for its auth state.\r\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\r\nenum GhStatus {\r\n    Available,\r\n    Missing,\r\n    Unauthed,\r\n}\r\n\r\n/// Bounded probe behind [`GhStatus`]: `gh auth status` exits 0 only when a host\r\n/// is logged in; a spawn `NotFound` is the not-installed case. Bounded\r\n/// (REQ-HAZARD-SUBPROCESS-TIMEOUT).\r\n// [impl->REQ-UPDATE-GH-TRANSPORT]\r\nfn gh_status() -> GhStatus {\r\n    match spt_runtime::run_bounded_command(\r\n        \"gh auth status\",\r\n        &std::collections::BTreeMap::new(),\r\n        Duration::from_secs(10),\r\n        None,\r\n    ) {\r\n        Ok(out) if out.success() => GhStatus::Available,\r\n        Err(spt_runtime::RuntimeError::Spawn(e))\r\n            if e.kind() == std::io::ErrorKind::NotFound =>\r\n        {\r\n            GhStatus::Missing\r\n        }\r\n        _ => GhStatus::Unauthed,\r\n    }\r\n}\r\n\r\n/// OS-specific install hint for the gh CLI, keyed on an `std::env::consts::OS`\r\n/// string. Pure — unit-tested per OS.\r\n// [impl->REQ-UPDATE-GH-TRANSPORT]\r\nfn gh_install_hint(os: &str) -> &'static str {\r\n    match os {\r\n        \"windows\" => \"winget install --id GitHub.cli\",\r\n        \"macos\" => \"brew install gh\",\r\n        // spacerun-ok: command-annotation — spacing separates a copy-pasteable\r\n        // command from the note about it, so the command pastes cleanly.\r\n        _ => \"sudo apt install gh   (or your distro's package manager)\",\r\n    }\r\n}\r\n\r\n/// Render the loud refusal for a fetch attempted without the gh CLI installed.\r\n/// Pure — the caller supplies `std::env::consts::OS`.\r\n// [impl->REQ-UPDATE-GH-TRANSPORT]\r\nfn render_gh_cli_required(os: &str) -> String {\r\n    format!(\r\n        \"UPDATE_FETCH_REJECTED:GhCliRequired — the release channel is private and \\\r\n         spt fetches releases through the GitHub CLI (gh). Install it:\\n  {}\\n\\\r\n         then authenticate:  gh auth login\",\r\n        gh_install_hint(os)\r\n    )\r\n}\r\n\r\n/// Render the loud refusal for a fetch attempted with gh installed but no\r\n/// authenticated host. Pure.\r\n// [impl->REQ-UPDATE-GH-TRANSPORT]\r\nfn render_gh_auth_required() -> String {\r\n    \"UPDATE_FETCH_REJECTED:GhAuthRequired — the gh CLI is installed but not \\\r\n     authenticated. Run `gh auth login` with an account that can read the \\\r\n     release channel.\"\r\n        .to_string()\r\n}\r\n\r\n// ───────────────────────────── docs verbs (W3) ───────────────────────────────\r\n\r\nenum DocsUrlPolicy {\r\n    LiveOnly,\r\n    AllowOfflineFallback,\r\n}\r\n\r\n/// Update pointers require a discovered listener; the docs verb also permits\r\n/// the existing offline bind-config fallback when no daemon is running.\r\n// [impl->REQ-WEB-URL-BOUND-PORT]\r","startLine":9989,"lineNumbers":[9989,9990,9991,9992,9993,9994,9995,9996,9997,9998,9999,10000,10001,10002,10003,10004,10005,10006,10007,10008,10009,10010,10011,10012,10013,10014,10015,10016,10017,10018,10019,10020,10021,10022,10023,10024,10025,10026,10027,10028,10029,10030,10031,10032,10033,10034,10035,10036,10037,10038,10039,10040,10041,10042,10043,10044,10045,10046,10047,10048,10049,10050,10051,10052,10053,10054,10055,10056,10057,10058,10059,10060,10061,10062,10063,10064,10065,10066,10067,10068,10069,10070,10071,10072,10073,10074,10075,10076,10077,10078,10079,10080,10081,10082,10083,10084,10085,10086,10087,10088,10089,10090,10091,10092,10093,10094,10095,10096,10097,10098,10099,10100,10101,10102,10103,10104,10105,10106,10107,10108,10109,10110,10111,10112,10113,10114,10115,10116,10117,10118,10119,10120,10121,10122,10123]},"fileSize":1828112,"meta":{"source":{"type":"path","value":"C:\\Users\\decid\\Documents\\projects\\spt-core\\crates\\spt\\src\\cli.rs"},"truncation":{"direction":"head","truncatedBy":"lines","totalLines":40000,"totalBytes":5130,"outputLines":135,"outputBytes":5130,"shownRange":{"start":9989,"end":10123},"nextOffset":10124}}},"isError":false,"timestamp":1790039414578}}
{"type":"message","id":"70e625af","parentId":"34e6c216","timestamp":"2026-09-22T01:10:14.590Z","message":{"role":"toolResult","toolCallId":"call_V5CBc9qXmdaX0LLcMf9jT8nU|fc_06340275dc54e8ed016ab1d5764d1487d1adf5f177c4ff0ac3","toolName":"read","content":[{"type":"text","text":"fn fetch_release_adapter(\n…\n) -> Result<std::path::PathBuf, i32> {\n…\n    let bytes = match fetch_release_asset_bytes(spec, tag, asset, effective, &github) {\n…\n        Err(e) => {\n…\n            return Err(1);\n        }\n    };\n    // F-018: stage-then-swap — write the fetched archive beside the home and\n    // apply it through the never-strand content-hash swap (the same primitive\n    // `adapter update` uses). The prior install is no longer wiped before the new\n    // payload is proven, so a failed fetch/extract leaves `dest` untouched.\n    // [impl->REQ-INSTALL-13]\n    let staged = github.join(format!(\"{safe}.add.spt\"));\n    if let Err(e) = std::fs::write(&staged, &bytes) {\n        eprintln!(\"ADAPTER_RELEASE_FAIL: write archive: {e}\");\n        return Err(1);\n    }\n    if let Err(e) = std::fs::create_dir_all(&dest) {\n        let _ = std::fs::remove_file(&staged);\n        eprintln!(\"ADAPTER_RELEASE_FAIL: create {}: {e}\", dest.display());\n        return Err(1);\n    }\n    let applied = apply_release_crc_swap(&staged, &dest);\n    let _ = std::fs::remove_file(&staged);\n    match applied {\n        Ok(()) => Ok(dest),\n        Err(e) => {\n            eprintln!(\"ADAPTER_RELEASE_FAIL: extract: {e}\");\n            Err(1)\n        }\n    }\n}\n\n/// `spt update fetch` (REQ-UPD-7, platform-safed by REQ-UPD-8) — pull the\n/// signed multi-platform **update set** (`update-set.json` + every platform\n/// artifact it names) from the GitHub origin and stage it through the SAME\n/// verify gate the P2P pull uses (signature + channel + monotonic rollback\n/// floor + per-platform SHA-256), after which the existing `spt update apply`\n/// flow takes over.\n///\n/// Staging the SET (never a single, platform-blind `SignedRelease`) is the\n/// v0.3.1 cross-OS brick fix: the cache holds a platform-keyed set, so the\n/// local apply selects `current_platform()` and a P2P re-serve lets each peer\n/// select ITS own platform. Fetching the whole set (not just this node's\n/// artifact) lets this node re-serve every platform to its subnet peers.\n/// Closes the peer-only discovery gap: a first-in-fleet / isolated node can\n/// update with no peer to pull from. The signed set keeps the GitHub transport\n/// untrusted-but-verified.\n// [impl->REQ-UPD-7]\n// [impl->REQ-UPD-8]\n/// What a `spt update fetch` metadata reject actually MEANS for the operator\n/// (REQ-UPDATE-FETCH-CURRENT-UX). The rollback floor is the STAGED version, so a\n/// `Rollback` against an already-staged/applied latest is not a failure — it is\n/// an already-downloaded or up-to-date state.\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum FetchRejectClass {\n    /// The latest is already downloaded (staged) but not yet installed —\n    /// actionable: `spt update apply` (or `update fetch --apply`).\n    AlreadyStaged { candidate: u64 },\n    /// The latest is already installed — nothing to do.\n    AlreadyApplied { candidate: u64 },\n    /// A genuine verify/floor failure (bad signature, true downgrade, …).\n    GenuineError,\n}\n\n/// Classify a fetch *metadata* reject (pure — REQ-UPDATE-FETCH-CURRENT-UX). A\n/// `Rollback` whose candidate equals the already-staged version (and is not yet\n/// applied) is \"already downloaded\"; a candidate already at/below the applied\n/// version is \"up to date\"; everything else (a non-`Rollback` reason, or a\n/// candidate below the staged floor) is a genuine error.\n// [impl->REQ-UPDATE-FETCH-CURRENT-UX]\nfn classify_fetch_reject(\n    reason: &spt_daemon::release::RejectReason,\n    applied: Option<u64>,\n    staged: Option<u64>,\n) -> FetchRejectClass {\n    if let spt_daemon::release::RejectReason::Rollback { candidate, .. } = reason {\n        let c = *candidate;\n        // Option ordering: None < Some(_), so an unapplied node (applied=None)\n        // with the latest staged is correctly AlreadyStaged.\n        if staged == Some(c) && applied < Some(c) {\n            return FetchRejectClass::AlreadyStaged { candidate: c };\n        }\n        if applied >= Some(c) {\n            return FetchRejectClass::AlreadyApplied { candidate: c };\n        }\n    }\n    FetchRejectClass::GenuineError\n}\n\n/// The action `spt update fetch [--apply]` takes for a classified reject (pure —\n/// REQ-UPDATE-FETCH-APPLY-FLAG). `--apply` installs an already-staged set;\n/// without it the operator gets the actionable hint. Already-applied is a no-op\n/// either way; a genuine error never applies.\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum FetchAction {\n    /// Install the staged set now (invoke the apply core).\n    Apply,\n    /// Print the actionable / up-to-date message and exit 0.\n    DoneOk,\n    /// Print the genuine error (Display) and exit 1.\n    Error,\n}\n\n// [impl->REQ-UPDATE-FETCH-APPLY-FLAG]\nfn fetch_reject_action(class: FetchRejectClass, apply: bool) -> FetchAction {\n    match class {\n        FetchRejectClass::AlreadyStaged { .. } if apply => FetchAction::Apply,\n        FetchRejectClass::AlreadyStaged { .. } | FetchRejectClass::AlreadyApplied { .. } => {\n            FetchAction::DoneOk\n        }\n        FetchRejectClass::GenuineError => FetchAction::Error,\n    }\n}\n\nfn cmd_update_fetch(channel: Option<String>, tag: Option<String>, apply: bool) -> i32 {\n    let repo = install_repo();\n\n    // The gh CLI is the mandated carrier (ADR-0036 §3): the channel is private,\n    // and a private repo's `browser_download_url` 404s even with a valid token —\n    // the API asset-id dance is gh's job. Refuse LOUD before any network work,\n    // split into the two actionable classes.\n    // [impl->REQ-UPDATE-GH-TRANSPORT]\n    match gh_status() {\n        GhStatus::Available => {}\n        GhStatus::Missing => {\n            eprintln!(\"{}\", render_gh_cli_required(std::env::consts::OS));\n            return 1;\n        }\n        GhStatus::Unauthed => {\n            eprintln!(\"{}\", render_gh_auth_required());\n            return 1;\n        }\n    }\n\n    // Verify policy: the two-key anchor + the node's release-keys overlay; the\n    // rollback floor is the staged version on the pinned channel (mirrors the\n    // P2P path, pump/mod.rs). `--channel` overrides the pin to deliberately\n    // accept another channel's release.\n    let cache = spt_daemon::ReleaseCache::open(&perch::spt_home().join(\"releases\"));\n    let keys = perch::identity_dir().join(\"release-keys.json\");\n    let mut policy = spt_daemon::release::VerifyPolicy::load_from(&keys, 0, now_ms());\n    let pinned = policy.pinned_channel.clone();\n    policy.current_version = if cache.staged_channel().as_deref() == Some(pinned.as_str()) {\n        cache.staged_version().unwrap_or(0)\n    } else {\n        0\n    };\n    if let Some(c) = channel {\n        policy.pinned_channel = c;\n    }\n    if policy.trusted_keys.is_empty() {\n        eprintln!(\"UPDATE_FETCH_FAIL: no trusted release keys\");\n        return 1;\n    }\n\n    eprintln!(\"Fetching update set from {repo}…\");\n    // gh downloads land in a scratch dir and are read back as bytes — the same\n    // shape the adapter gh avenue uses (fetch_release_asset_bytes).\n    // [impl->REQ-UPDATE-GH-TRANSPORT]\n    let scratch = std::env::temp_dir().join(format!(\"spt-update-fetch-{}\", std::process::id()));\n\n    // 1. The signed set metadata. A missing update-set.json means the release\n    //    predates the platform-safe set (v0.3.2+) — refuse with a pointer to\n    //    the installer rather than falling back to a platform-blind single.\n    let set_text = match fetch_release_asset_bytes(\n        &repo,\n        tag.as_deref(),\n        \"update-set.json\",\n        EffectiveTransport::Gh,\n        &scratch,\n    )\n    .and_then(|b| String::from_utf8(b).map_err(|e| e.to_string()))\n    {\n        Ok(t) => t,\n        Err(e) => {\n            let _ = std::fs::remove_dir_all(&scratch);\n            eprintln!(\n                \"UPDATE_FETCH_FAIL: update-set.json from {repo}: {e}\\n\\\n                 (no platform-safe update set at this release — it may predate v0.3.2; \\\n                 use the installer for cross-version updates)\"\n            );\n            return 1;\n        }\n    };\n    let signed: spt_daemon::release::SignedUpdateSet = match serde_json::from_str(&set_text) {\n        Ok(s) => s,\n        Err(e) => {\n            let _ = std::fs::remove_dir_all(&scratch);\n            eprintln!(\"UPDATE_FETCH_FAIL: bad update-set.json: {e}\");\n            return 1;\n        }\n    };\n\n    // 2. Authenticate the set (signature + channel + rollback floor) — the same\n    //    gate a peer pull runs, no inherited trust from the GitHub transport.\n    let meta = match spt_daemon::release::verify_update_set_metadata(&signed, &policy) {\n        Ok(m) => m,\n        Err(reason) => {\n            let _ = std::fs::remove_dir_all(&scratch);\n            // The rollback floor is the staged version, so a Rollback here often\n            // means \"the latest is already downloaded/installed\", not a failure\n            // (REQ-UPDATE-FETCH-CURRENT-UX). Classify and report it as such; with\n            // --apply, install an already-staged set instead of printing the hint\n            // (REQ-UPDATE-FETCH-APPLY-FLAG).\n            let class = classify_fetch_reject(&reason, cache.applied_version(), cache.staged_version());\n            return match fetch_reject_action(class, apply) {\n                FetchAction::Apply => cmd_update_apply(false),\n                FetchAction::DoneOk => {\n                    match class {\n                        FetchRejectClass::AlreadyStaged { candidate } => println!(\n                            \"Update (counter {candidate}) is already downloaded. \\\n                             Run `spt update apply` to install it.\"\n                        ),\n                        FetchRejectClass::AlreadyApplied { candidate } => {\n                            println!(\"Already up to date (counter {candidate}).\")\n                        }\n                        FetchRejectClass::GenuineError => {}\n                    }\n                    0\n                }\n                // A genuine reject prints the friendly Display (not Debug) and\n                // stays an error; --apply never forces past a failed verify.\n                FetchAction::Error => {\n                    eprintln!(\"UPDATE_FETCH_REJECTED: {reason}\");\n                    1\n                }\n            };\n        }\n    };\n\n    // 3. Download EVERY platform artifact the set names, verifying each against\n    //    its signed digest before it lands in the cache (so this node can later\n    //    re-serve any platform to peers without serving poison).\n    let mut artifacts: std::collections::BTreeMap<String, Vec<u8>> =\n        std::collections::BTreeMap::new();\n    for (triple, art) in &meta.artifacts {\n        let Some(asset) = art.asset_name.as_deref() else {\n            eprintln!(\"UPDATE_FETCH_FAIL: set artifact for {triple} has no asset_name\");\n            return 1;\n        };\n        eprintln!(\"  fetching {asset} ({triple})…\");\n        // [impl->REQ-UPDATE-GH-TRANSPORT] asset bytes ride the gh carrier; the\n        // signed-digest verify below is carrier-independent (ADR-0036 §3).\n        let bytes = match fetch_release_asset_bytes(\n            &repo,\n            tag.as_deref(),\n            asset,\n            EffectiveTransport::Gh,\n            &scratch,\n        ) {\n            Ok(b) => b,\n            Err(e) => {\n                let _ = std::fs::remove_dir_all(&scratch);\n                eprintln!(\"UPDATE_FETCH_FAIL: {asset} from {repo}: {e}\");\n                return 1;\n            }\n        };\n        if let Err(reason) =\n            spt_daemon::release::verify_update_set_artifact(&meta, triple, &bytes)\n        {\n            let _ = std::fs::remove_dir_all(&scratch);\n            // Friendly Display, not the raw enum Debug (REQ-UPDATE-FETCH-CURRENT-UX);\n            // a genuine artifact failure stays an error.\n            eprintln!(\"UPDATE_FETCH_REJECTED: {reason}\");\n            return 1;\n        }\n        artifacts.insert(triple.clone(), bytes);\n    }\n\n    // 3b. The docs bundle (ADR-0036 §4) — best-effort by BINDING: any docs\n    //     failure here prints the machine-greppable UPDATE_DOCS_SKIPPED and\n    //     the binary staging below proceeds UNCHANGED (retried next fetch).\n    // [impl->REQ-DOCS-RELEASE-ASSET]\n    match &meta.docs {\n        None => {}\n        Some(docs) => {\n            eprintln!(\"  fetching {} (docs bundle)…\", docs.asset_name);\n            match fetch_release_asset_bytes(\n                &repo,\n                tag.as_deref(),\n                &docs.asset_name,\n                EffectiveTransport::Gh,\n                &scratch,\n            ) {\n                Ok(bundle) => match spt_daemon::verify_update_set_docs(&meta, &bundle) {\n                    Ok(()) => {\n                        if let Err(e) = cache.stage_docs(&bundle) {\n                            eprintln!(\"UPDATE_DOCS_SKIPPED: stage: {e} — docs retry next fetch\");\n                        }\n                    }\n                    Err(reason) => eprintln!(\n                        \"UPDATE_DOCS_SKIPPED: {reason} — docs retry next fetch\"\n                    ),\n                },\n                Err(e) => eprintln!(\n                    \"UPDATE_DOCS_SKIPPED: {} from {repo}: {e} — docs retry next fetch\",\n                    docs.asset_name\n                ),\n            }\n        }\n    }\n    let _ = std::fs::remove_dir_all(&scratch);\n\n    // 4. The full per-node front door for THIS platform (selects + classifies)\n    //    — mirrors what apply will do, so a set with no artifact for this node\n    //    fails loud here, not silently at apply.\n    let local = spt_daemon::current_platform();\n    let Some(local_bytes) = artifacts.get(local) else {\n        eprintln!(\"UPDATE_FETCH_REJECTED:NoArtifactForPlatform({local:?})\");\n        return 1;\n    };\n    match spt_daemon::update::plan_verified_update_set(\n        &spt_daemon::update::BrokerAbi::current(),\n…\n    ) {\n…\n    }\n}\n\n[Showing lines 10859-11168 of 40000. Use :11169 to continue]"}],"details":{"truncation":{"content":"            return Err(1);\r\n        }\r\n    };\r\n    // F-018: stage-then-swap — write the fetched archive beside the home and\r\n    // apply it through the never-strand content-hash swap (the same primitive\r\n    // `adapter update` uses). The prior install is no longer wiped before the new\r\n    // payload is proven, so a failed fetch/extract leaves `dest` untouched.\r\n    // [impl->REQ-INSTALL-13]\r\n    let staged = github.join(format!(\"{safe}.add.spt\"));\r\n    if let Err(e) = std::fs::write(&staged, &bytes) {\r\n        eprintln!(\"ADAPTER_RELEASE_FAIL: write archive: {e}\");\r\n        return Err(1);\r\n    }\r\n    if let Err(e) = std::fs::create_dir_all(&dest) {\r\n        let _ = std::fs::remove_file(&staged);\r\n        eprintln!(\"ADAPTER_RELEASE_FAIL: create {}: {e}\", dest.display());\r\n        return Err(1);\r\n    }\r\n    let applied = apply_release_crc_swap(&staged, &dest);\r\n    let _ = std::fs::remove_file(&staged);\r\n    match applied {\r\n        Ok(()) => Ok(dest),\r\n        Err(e) => {\r\n            eprintln!(\"ADAPTER_RELEASE_FAIL: extract: {e}\");\r\n            Err(1)\r\n        }\r\n    }\r\n}\r\n\r\n/// `spt update fetch` (REQ-UPD-7, platform-safed by REQ-UPD-8) — pull the\r\n/// signed multi-platform **update set** (`update-set.json` + every platform\r\n/// artifact it names) from the GitHub origin and stage it through the SAME\r\n/// verify gate the P2P pull uses (signature + channel + monotonic rollback\r\n/// floor + per-platform SHA-256), after which the existing `spt update apply`\r\n/// flow takes over.\r\n///\r\n/// Staging the SET (never a single, platform-blind `SignedRelease`) is the\r\n/// v0.3.1 cross-OS brick fix: the cache holds a platform-keyed set, so the\r\n/// local apply selects `current_platform()` and a P2P re-serve lets each peer\r\n/// select ITS own platform. Fetching the whole set (not just this node's\r\n/// artifact) lets this node re-serve every platform to its subnet peers.\r\n/// Closes the peer-only discovery gap: a first-in-fleet / isolated node can\r\n/// update with no peer to pull from. The signed set keeps the GitHub transport\r\n/// untrusted-but-verified.\r\n// [impl->REQ-UPD-7]\r\n// [impl->REQ-UPD-8]\r\n/// What a `spt update fetch` metadata reject actually MEANS for the operator\r\n/// (REQ-UPDATE-FETCH-CURRENT-UX). The rollback floor is the STAGED version, so a\r\n/// `Rollback` against an already-staged/applied latest is not a failure — it is\r\n/// an already-downloaded or up-to-date state.\r\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\r\nenum FetchRejectClass {\r\n    /// The latest is already downloaded (staged) but not yet installed —\r\n    /// actionable: `spt update apply` (or `update fetch --apply`).\r\n    AlreadyStaged { candidate: u64 },\r\n    /// The latest is already installed — nothing to do.\r\n    AlreadyApplied { candidate: u64 },\r\n    /// A genuine verify/floor failure (bad signature, true downgrade, …).\r\n    GenuineError,\r\n}\r\n\r\n/// Classify a fetch *metadata* reject (pure — REQ-UPDATE-FETCH-CURRENT-UX). A\r\n/// `Rollback` whose candidate equals the already-staged version (and is not yet\r\n/// applied) is \"already downloaded\"; a candidate already at/below the applied\r\n/// version is \"up to date\"; everything else (a non-`Rollback` reason, or a\r\n/// candidate below the staged floor) is a genuine error.\r\n// [impl->REQ-UPDATE-FETCH-CURRENT-UX]\r\nfn classify_fetch_reject(\r\n    reason: &spt_daemon::release::RejectReason,\r\n    applied: Option<u64>,\r\n    staged: Option<u64>,\r\n) -> FetchRejectClass {\r\n    if let spt_daemon::release::RejectReason::Rollback { candidate, .. } = reason {\r\n        let c = *candidate;\r\n        // Option ordering: None < Some(_), so an unapplied node (applied=None)\r\n        // with the latest staged is correctly AlreadyStaged.\r\n        if staged == Some(c) && applied < Some(c) {\r\n            return FetchRejectClass::AlreadyStaged { candidate: c };\r\n        }\r\n        if applied >= Some(c) {\r\n            return FetchRejectClass::AlreadyApplied { candidate: c };\r\n        }\r\n    }\r\n    FetchRejectClass::GenuineError\r\n}\r\n\r\n/// The action `spt update fetch [--apply]` takes for a classified reject (pure —\r\n/// REQ-UPDATE-FETCH-APPLY-FLAG). `--apply` installs an already-staged set;\r\n/// without it the operator gets the actionable hint. Already-applied is a no-op\r\n/// either way; a genuine error never applies.\r\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\r\nenum FetchAction {\r\n    /// Install the staged set now (invoke the apply core).\r\n    Apply,\r\n    /// Print the actionable / up-to-date message and exit 0.\r\n    DoneOk,\r\n    /// Print the genuine error (Display) and exit 1.\r\n    Error,\r\n}\r\n\r\n// [impl->REQ-UPDATE-FETCH-APPLY-FLAG]\r\nfn fetch_reject_action(class: FetchRejectClass, apply: bool) -> FetchAction {\r\n    match class {\r\n        FetchRejectClass::AlreadyStaged { .. } if apply => FetchAction::Apply,\r\n        FetchRejectClass::AlreadyStaged { .. } | FetchRejectClass::AlreadyApplied { .. } => {\r\n            FetchAction::DoneOk\r\n        }\r\n        FetchRejectClass::GenuineError => FetchAction::Error,\r\n    }\r\n}\r\n\r\nfn cmd_update_fetch(channel: Option<String>, tag: Option<String>, apply: bool) -> i32 {\r\n    let repo = install_repo();\r\n\r\n    // The gh CLI is the mandated carrier (ADR-0036 §3): the channel is private,\r\n    // and a private repo's `browser_download_url` 404s even with a valid token —\r\n    // the API asset-id dance is gh's job. Refuse LOUD before any network work,\r\n    // split into the two actionable classes.\r\n    // [impl->REQ-UPDATE-GH-TRANSPORT]\r\n    match gh_status() {\r\n        GhStatus::Available => {}\r\n        GhStatus::Missing => {\r\n            eprintln!(\"{}\", render_gh_cli_required(std::env::consts::OS));\r\n            return 1;\r\n        }\r\n        GhStatus::Unauthed => {\r\n            eprintln!(\"{}\", render_gh_auth_required());\r\n            return 1;\r\n        }\r\n    }\r\n\r\n    // Verify policy: the two-key anchor + the node's release-keys overlay; the\r\n    // rollback floor is the staged version on the pinned channel (mirrors the\r\n    // P2P path, pump/mod.rs). `--channel` overrides the pin to deliberately\r\n    // accept another channel's release.\r\n    let cache = spt_daemon::ReleaseCache::open(&perch::spt_home().join(\"releases\"));\r\n    let keys = perch::identity_dir().join(\"release-keys.json\");\r\n    let mut policy = spt_daemon::release::VerifyPolicy::load_from(&keys, 0, now_ms());\r\n    let pinned = policy.pinned_channel.clone();\r\n    policy.current_version = if cache.staged_channel().as_deref() == Some(pinned.as_str()) {\r\n        cache.staged_version().unwrap_or(0)\r\n    } else {\r\n        0\r\n    };\r\n    if let Some(c) = channel {\r\n        policy.pinned_channel = c;\r\n    }\r\n    if policy.trusted_keys.is_empty() {\r\n        eprintln!(\"UPDATE_FETCH_FAIL: no trusted release keys\");\r\n        return 1;\r\n    }\r\n\r\n    eprintln!(\"Fetching update set from {repo}…\");\r\n    // gh downloads land in a scratch dir and are read back as bytes — the same\r\n    // shape the adapter gh avenue uses (fetch_release_asset_bytes).\r\n    // [impl->REQ-UPDATE-GH-TRANSPORT]\r\n    let scratch = std::env::temp_dir().join(format!(\"spt-update-fetch-{}\", std::process::id()));\r\n\r\n    // 1. The signed set metadata. A missing update-set.json means the release\r\n    //    predates the platform-safe set (v0.3.2+) — refuse with a pointer to\r\n    //    the installer rather than falling back to a platform-blind single.\r\n    let set_text = match fetch_release_asset_bytes(\r\n        &repo,\r\n        tag.as_deref(),\r\n        \"update-set.json\",\r\n        EffectiveTransport::Gh,\r\n        &scratch,\r\n    )\r\n    .and_then(|b| String::from_utf8(b).map_err(|e| e.to_string()))\r\n    {\r\n        Ok(t) => t,\r\n        Err(e) => {\r\n            let _ = std::fs::remove_dir_all(&scratch);\r\n            eprintln!(\r\n                \"UPDATE_FETCH_FAIL: update-set.json from {repo}: {e}\\n\\\r\n                 (no platform-safe update set at this release — it may predate v0.3.2; \\\r\n                 use the installer for cross-version updates)\"\r\n            );\r\n            return 1;\r\n        }\r\n    };\r\n    let signed: spt_daemon::release::SignedUpdateSet = match serde_json::from_str(&set_text) {\r\n        Ok(s) => s,\r\n        Err(e) => {\r\n            let _ = std::fs::remove_dir_all(&scratch);\r\n            eprintln!(\"UPDATE_FETCH_FAIL: bad update-set.json: {e}\");\r\n            return 1;\r\n        }\r\n    };\r\n\r\n    // 2. Authenticate the set (signature + channel + rollback floor) — the same\r\n    //    gate a peer pull runs, no inherited trust from the GitHub transport.\r\n    let meta = match spt_daemon::release::verify_update_set_metadata(&signed, &policy) {\r\n        Ok(m) => m,\r\n        Err(reason) => {\r\n            let _ = std::fs::remove_dir_all(&scratch);\r\n            // The rollback floor is the staged version, so a Rollback here often\r\n            // means \"the latest is already downloaded/installed\", not a failure\r\n            // (REQ-UPDATE-FETCH-CURRENT-UX). Classify and report it as such; with\r\n            // --apply, install an already-staged set instead of printing the hint\r\n            // (REQ-UPDATE-FETCH-APPLY-FLAG).\r\n            let class = classify_fetch_reject(&reason, cache.applied_version(), cache.staged_version());\r\n            return match fetch_reject_action(class, apply) {\r\n                FetchAction::Apply => cmd_update_apply(false),\r\n                FetchAction::DoneOk => {\r\n                    match class {\r\n                        FetchRejectClass::AlreadyStaged { candidate } => println!(\r\n                            \"Update (counter {candidate}) is already downloaded. \\\r\n                             Run `spt update apply` to install it.\"\r\n                        ),\r\n                        FetchRejectClass::AlreadyApplied { candidate } => {\r\n                            println!(\"Already up to date (counter {candidate}).\")\r\n                        }\r\n                        FetchRejectClass::GenuineError => {}\r\n                    }\r\n                    0\r\n                }\r\n                // A genuine reject prints the friendly Display (not Debug) and\r\n                // stays an error; --apply never forces past a failed verify.\r\n                FetchAction::Error => {\r\n                    eprintln!(\"UPDATE_FETCH_REJECTED: {reason}\");\r\n                    1\r\n                }\r\n            };\r\n        }\r\n    };\r\n\r\n    // 3. Download EVERY platform artifact the set names, verifying each against\r\n    //    its signed digest before it lands in the cache (so this node can later\r\n    //    re-serve any platform to peers without serving poison).\r\n    let mut artifacts: std::collections::BTreeMap<String, Vec<u8>> =\r\n        std::collections::BTreeMap::new();\r\n    for (triple, art) in &meta.artifacts {\r\n        let Some(asset) = art.asset_name.as_deref() else {\r\n            eprintln!(\"UPDATE_FETCH_FAIL: set artifact for {triple} has no asset_name\");\r\n            return 1;\r\n        };\r\n        eprintln!(\"  fetching {asset} ({triple})…\");\r\n        // [impl->REQ-UPDATE-GH-TRANSPORT] asset bytes ride the gh carrier; the\r\n        // signed-digest verify below is carrier-independent (ADR-0036 §3).\r\n        let bytes = match fetch_release_asset_bytes(\r\n            &repo,\r\n            tag.as_deref(),\r\n            asset,\r\n            EffectiveTransport::Gh,\r\n            &scratch,\r\n        ) {\r\n            Ok(b) => b,\r\n            Err(e) => {\r\n                let _ = std::fs::remove_dir_all(&scratch);\r\n                eprintln!(\"UPDATE_FETCH_FAIL: {asset} from {repo}: {e}\");\r\n                return 1;\r\n            }\r\n        };\r\n        if let Err(reason) =\r\n            spt_daemon::release::verify_update_set_artifact(&meta, triple, &bytes)\r\n        {\r\n            let _ = std::fs::remove_dir_all(&scratch);\r\n            // Friendly Display, not the raw enum Debug (REQ-UPDATE-FETCH-CURRENT-UX);\r\n            // a genuine artifact failure stays an error.\r\n            eprintln!(\"UPDATE_FETCH_REJECTED: {reason}\");\r\n            return 1;\r\n        }\r\n        artifacts.insert(triple.clone(), bytes);\r\n    }\r\n\r\n    // 3b. The docs bundle (ADR-0036 §4) — best-effort by BINDING: any docs\r\n    //     failure here prints the machine-greppable UPDATE_DOCS_SKIPPED and\r\n    //     the binary staging below proceeds UNCHANGED (retried next fetch).\r\n    // [impl->REQ-DOCS-RELEASE-ASSET]\r\n    match &meta.docs {\r\n        None => {}\r\n        Some(docs) => {\r\n            eprintln!(\"  fetching {} (docs bundle)…\", docs.asset_name);\r\n            match fetch_release_asset_bytes(\r\n                &repo,\r\n                tag.as_deref(),\r\n                &docs.asset_name,\r\n                EffectiveTransport::Gh,\r\n                &scratch,\r\n            ) {\r\n                Ok(bundle) => match spt_daemon::verify_update_set_docs(&meta, &bundle) {\r\n                    Ok(()) => {\r\n                        if let Err(e) = cache.stage_docs(&bundle) {\r\n                            eprintln!(\"UPDATE_DOCS_SKIPPED: stage: {e} — docs retry next fetch\");\r\n                        }\r\n                    }\r\n                    Err(reason) => eprintln!(\r\n                        \"UPDATE_DOCS_SKIPPED: {reason} — docs retry next fetch\"\r\n                    ),\r\n                },\r\n                Err(e) => eprintln!(\r\n                    \"UPDATE_DOCS_SKIPPED: {} from {repo}: {e} — docs retry next fetch\",\r\n                    docs.asset_name\r\n                ),\r\n            }\r\n        }\r\n    }\r\n    let _ = std::fs::remove_dir_all(&scratch);\r\n\r\n    // 4. The full per-node front door for THIS platform (selects + classifies)\r\n    //    — mirrors what apply will do, so a set with no artifact for this node\r\n    //    fails loud here, not silently at apply.\r\n    let local = spt_daemon::current_platform();\r\n    let Some(local_bytes) = artifacts.get(local) else {\r\n        eprintln!(\"UPDATE_FETCH_REJECTED:NoArtifactForPlatform({local:?})\");\r\n        return 1;\r\n    };\r\n    match spt_daemon::update::plan_verified_update_set(\r\n        &spt_daemon::update::BrokerAbi::current(),\r","truncated":true,"truncatedBy":"lines","totalLines":29142,"totalBytes":13965,"outputLines":310,"outputBytes":13965,"lastLinePartial":false,"firstLineExceedsLimit":false},"totalLines":40000,"displayContent":{"text":"fn fetch_release_adapter(\n…\n) -> Result<std::path::PathBuf, i32> {\n…\n    let bytes = match fetch_release_asset_bytes(spec, tag, asset, effective, &github) {\n…\n        Err(e) => {\n…\n            return Err(1);\r\n        }\r\n    };\r\n    // F-018: stage-then-swap — write the fetched archive beside the home and\r\n    // apply it through the never-strand content-hash swap (the same primitive\r\n    // `adapter update` uses). The prior install is no longer wiped before the new\r\n    // payload is proven, so a failed fetch/extract leaves `dest` untouched.\r\n    // [impl->REQ-INSTALL-13]\r\n    let staged = github.join(format!(\"{safe}.add.spt\"));\r\n    if let Err(e) = std::fs::write(&staged, &bytes) {\r\n        eprintln!(\"ADAPTER_RELEASE_FAIL: write archive: {e}\");\r\n        return Err(1);\r\n    }\r\n    if let Err(e) = std::fs::create_dir_all(&dest) {\r\n        let _ = std::fs::remove_file(&staged);\r\n        eprintln!(\"ADAPTER_RELEASE_FAIL: create {}: {e}\", dest.display());\r\n        return Err(1);\r\n    }\r\n    let applied = apply_release_crc_swap(&staged, &dest);\r\n    let _ = std::fs::remove_file(&staged);\r\n    match applied {\r\n        Ok(()) => Ok(dest),\r\n        Err(e) => {\r\n            eprintln!(\"ADAPTER_RELEASE_FAIL: extract: {e}\");\r\n            Err(1)\r\n        }\r\n    }\r\n}\r\n\r\n/// `spt update fetch` (REQ-UPD-7, platform-safed by REQ-UPD-8) — pull the\r\n/// signed multi-platform **update set** (`update-set.json` + every platform\r\n/// artifact it names) from the GitHub origin and stage it through the SAME\r\n/// verify gate the P2P pull uses (signature + channel + monotonic rollback\r\n/// floor + per-platform SHA-256), after which the existing `spt update apply`\r\n/// flow takes over.\r\n///\r\n/// Staging the SET (never a single, platform-blind `SignedRelease`) is the\r\n/// v0.3.1 cross-OS brick fix: the cache holds a platform-keyed set, so the\r\n/// local apply selects `current_platform()` and a P2P re-serve lets each peer\r\n/// select ITS own platform. Fetching the whole set (not just this node's\r\n/// artifact) lets this node re-serve every platform to its subnet peers.\r\n/// Closes the peer-only discovery gap: a first-in-fleet / isolated node can\r\n/// update with no peer to pull from. The signed set keeps the GitHub transport\r\n/// untrusted-but-verified.\r\n// [impl->REQ-UPD-7]\r\n// [impl->REQ-UPD-8]\r\n/// What a `spt update fetch` metadata reject actually MEANS for the operator\r\n/// (REQ-UPDATE-FETCH-CURRENT-UX). The rollback floor is the STAGED version, so a\r\n/// `Rollback` against an already-staged/applied latest is not a failure — it is\r\n/// an already-downloaded or up-to-date state.\r\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\r\nenum FetchRejectClass {\r\n    /// The latest is already downloaded (staged) but not yet installed —\r\n    /// actionable: `spt update apply` (or `update fetch --apply`).\r\n    AlreadyStaged { candidate: u64 },\r\n    /// The latest is already installed — nothing to do.\r\n    AlreadyApplied { candidate: u64 },\r\n    /// A genuine verify/floor failure (bad signature, true downgrade, …).\r\n    GenuineError,\r\n}\r\n\r\n/// Classify a fetch *metadata* reject (pure — REQ-UPDATE-FETCH-CURRENT-UX). A\r\n/// `Rollback` whose candidate equals the already-staged version (and is not yet\r\n/// applied) is \"already downloaded\"; a candidate already at/below the applied\r\n/// version is \"up to date\"; everything else (a non-`Rollback` reason, or a\r\n/// candidate below the staged floor) is a genuine error.\r\n// [impl->REQ-UPDATE-FETCH-CURRENT-UX]\r\nfn classify_fetch_reject(\r\n    reason: &spt_daemon::release::RejectReason,\r\n    applied: Option<u64>,\r\n    staged: Option<u64>,\r\n) -> FetchRejectClass {\r\n    if let spt_daemon::release::RejectReason::Rollback { candidate, .. } = reason {\r\n        let c = *candidate;\r\n        // Option ordering: None < Some(_), so an unapplied node (applied=None)\r\n        // with the latest staged is correctly AlreadyStaged.\r\n        if staged == Some(c) && applied < Some(c) {\r\n            return FetchRejectClass::AlreadyStaged { candidate: c };\r\n        }\r\n        if applied >= Some(c) {\r\n            return FetchRejectClass::AlreadyApplied { candidate: c };\r\n        }\r\n    }\r\n    FetchRejectClass::GenuineError\r\n}\r\n\r\n/// The action `spt update fetch [--apply]` takes for a classified reject (pure —\r\n/// REQ-UPDATE-FETCH-APPLY-FLAG). `--apply` installs an already-staged set;\r\n/// without it the operator gets the actionable hint. Already-applied is a no-op\r\n/// either way; a genuine error never applies.\r\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\r\nenum FetchAction {\r\n    /// Install the staged set now (invoke the apply core).\r\n    Apply,\r\n    /// Print the actionable / up-to-date message and exit 0.\r\n    DoneOk,\r\n    /// Print the genuine error (Display) and exit 1.\r\n    Error,\r\n}\r\n\r\n// [impl->REQ-UPDATE-FETCH-APPLY-FLAG]\r\nfn fetch_reject_action(class: FetchRejectClass, apply: bool) -> FetchAction {\r\n    match class {\r\n        FetchRejectClass::AlreadyStaged { .. } if apply => FetchAction::Apply,\r\n        FetchRejectClass::AlreadyStaged { .. } | FetchRejectClass::AlreadyApplied { .. } => {\r\n            FetchAction::DoneOk\r\n        }\r\n        FetchRejectClass::GenuineError => FetchAction::Error,\r\n    }\r\n}\r\n\r\nfn cmd_update_fetch(channel: Option<String>, tag: Option<String>, apply: bool) -> i32 {\r\n    let repo = install_repo();\r\n\r\n    // The gh CLI is the mandated carrier (ADR-0036 §3): the channel is private,\r\n    // and a private repo's `browser_download_url` 404s even with a valid token —\r\n    // the API asset-id dance is gh's job. Refuse LOUD before any network work,\r\n    // split into the two actionable classes.\r\n    // [impl->REQ-UPDATE-GH-TRANSPORT]\r\n    match gh_status() {\r\n        GhStatus::Available => {}\r\n        GhStatus::Missing => {\r\n            eprintln!(\"{}\", render_gh_cli_required(std::env::consts::OS));\r\n            return 1;\r\n        }\r\n        GhStatus::Unauthed => {\r\n            eprintln!(\"{}\", render_gh_auth_required());\r\n            return 1;\r\n        }\r\n    }\r\n\r\n    // Verify policy: the two-key anchor + the node's release-keys overlay; the\r\n    // rollback floor is the staged version on the pinned channel (mirrors the\r\n    // P2P path, pump/mod.rs). `--channel` overrides the pin to deliberately\r\n    // accept another channel's release.\r\n    let cache = spt_daemon::ReleaseCache::open(&perch::spt_home().join(\"releases\"));\r\n    let keys = perch::identity_dir().join(\"release-keys.json\");\r\n    let mut policy = spt_daemon::release::VerifyPolicy::load_from(&keys, 0, now_ms());\r\n    let pinned = policy.pinned_channel.clone();\r\n    policy.current_version = if cache.staged_channel().as_deref() == Some(pinned.as_str()) {\r\n        cache.staged_version().unwrap_or(0)\r\n    } else {\r\n        0\r\n    };\r\n    if let Some(c) = channel {\r\n        policy.pinned_channel = c;\r\n    }\r\n    if policy.trusted_keys.is_empty() {\r\n        eprintln!(\"UPDATE_FETCH_FAIL: no trusted release keys\");\r\n        return 1;\r\n    }\r\n\r\n    eprintln!(\"Fetching update set from {repo}…\");\r\n    // gh downloads land in a scratch dir and are read back as bytes — the same\r\n    // shape the adapter gh avenue uses (fetch_release_asset_bytes).\r\n    // [impl->REQ-UPDATE-GH-TRANSPORT]\r\n    let scratch = std::env::temp_dir().join(format!(\"spt-update-fetch-{}\", std::process::id()));\r\n\r\n    // 1. The signed set metadata. A missing update-set.json means the release\r\n    //    predates the platform-safe set (v0.3.2+) — refuse with a pointer to\r\n    //    the installer rather than falling back to a platform-blind single.\r\n    let set_text = match fetch_release_asset_bytes(\r\n        &repo,\r\n        tag.as_deref(),\r\n        \"update-set.json\",\r\n        EffectiveTransport::Gh,\r\n        &scratch,\r\n    )\r\n    .and_then(|b| String::from_utf8(b).map_err(|e| e.to_string()))\r\n    {\r\n        Ok(t) => t,\r\n        Err(e) => {\r\n            let _ = std::fs::remove_dir_all(&scratch);\r\n            eprintln!(\r\n                \"UPDATE_FETCH_FAIL: update-set.json from {repo}: {e}\\n\\\r\n                 (no platform-safe update set at this release — it may predate v0.3.2; \\\r\n                 use the installer for cross-version updates)\"\r\n            );\r\n            return 1;\r\n        }\r\n    };\r\n    let signed: spt_daemon::release::SignedUpdateSet = match serde_json::from_str(&set_text) {\r\n        Ok(s) => s,\r\n        Err(e) => {\r\n            let _ = std::fs::remove_dir_all(&scratch);\r\n            eprintln!(\"UPDATE_FETCH_FAIL: bad update-set.json: {e}\");\r\n            return 1;\r\n        }\r\n    };\r\n\r\n    // 2. Authenticate the set (signature + channel + rollback floor) — the same\r\n    //    gate a peer pull runs, no inherited trust from the GitHub transport.\r\n    let meta = match spt_daemon::release::verify_update_set_metadata(&signed, &policy) {\r\n        Ok(m) => m,\r\n        Err(reason) => {\r\n            let _ = std::fs::remove_dir_all(&scratch);\r\n            // The rollback floor is the staged version, so a Rollback here often\r\n            // means \"the latest is already downloaded/installed\", not a failure\r\n            // (REQ-UPDATE-FETCH-CURRENT-UX). Classify and report it as such; with\r\n            // --apply, install an already-staged set instead of printing the hint\r\n            // (REQ-UPDATE-FETCH-APPLY-FLAG).\r\n            let class = classify_fetch_reject(&reason, cache.applied_version(), cache.staged_version());\r\n            return match fetch_reject_action(class, apply) {\r\n                FetchAction::Apply => cmd_update_apply(false),\r\n                FetchAction::DoneOk => {\r\n                    match class {\r\n                        FetchRejectClass::AlreadyStaged { candidate } => println!(\r\n                            \"Update (counter {candidate}) is already downloaded. \\\r\n                             Run `spt update apply` to install it.\"\r\n                        ),\r\n                        FetchRejectClass::AlreadyApplied { candidate } => {\r\n                            println!(\"Already up to date (counter {candidate}).\")\r\n                        }\r\n                        FetchRejectClass::GenuineError => {}\r\n                    }\r\n                    0\r\n                }\r\n                // A genuine reject prints the friendly Display (not Debug) and\r\n                // stays an error; --apply never forces past a failed verify.\r\n                FetchAction::Error => {\r\n                    eprintln!(\"UPDATE_FETCH_REJECTED: {reason}\");\r\n                    1\r\n                }\r\n            };\r\n        }\r\n    };\r\n\r\n    // 3. Download EVERY platform artifact the set names, verifying each against\r\n    //    its signed digest before it lands in the cache (so this node can later\r\n    //    re-serve any platform to peers without serving poison).\r\n    let mut artifacts: std::collections::BTreeMap<String, Vec<u8>> =\r\n        std::collections::BTreeMap::new();\r\n    for (triple, art) in &meta.artifacts {\r\n        let Some(asset) = art.asset_name.as_deref() else {\r\n            eprintln!(\"UPDATE_FETCH_FAIL: set artifact for {triple} has no asset_name\");\r\n            return 1;\r\n        };\r\n        eprintln!(\"  fetching {asset} ({triple})…\");\r\n        // [impl->REQ-UPDATE-GH-TRANSPORT] asset bytes ride the gh carrier; the\r\n        // signed-digest verify below is carrier-independent (ADR-0036 §3).\r\n        let bytes = match fetch_release_asset_bytes(\r\n            &repo,\r\n            tag.as_deref(),\r\n            asset,\r\n            EffectiveTransport::Gh,\r\n            &scratch,\r\n        ) {\r\n            Ok(b) => b,\r\n            Err(e) => {\r\n                let _ = std::fs::remove_dir_all(&scratch);\r\n                eprintln!(\"UPDATE_FETCH_FAIL: {asset} from {repo}: {e}\");\r\n                return 1;\r\n            }\r\n        };\r\n        if let Err(reason) =\r\n            spt_daemon::release::verify_update_set_artifact(&meta, triple, &bytes)\r\n        {\r\n            let _ = std::fs::remove_dir_all(&scratch);\r\n            // Friendly Display, not the raw enum Debug (REQ-UPDATE-FETCH-CURRENT-UX);\r\n            // a genuine artifact failure stays an error.\r\n            eprintln!(\"UPDATE_FETCH_REJECTED: {reason}\");\r\n            return 1;\r\n        }\r\n        artifacts.insert(triple.clone(), bytes);\r\n    }\r\n\r\n    // 3b. The docs bundle (ADR-0036 §4) — best-effort by BINDING: any docs\r\n    //     failure here prints the machine-greppable UPDATE_DOCS_SKIPPED and\r\n    //     the binary staging below proceeds UNCHANGED (retried next fetch).\r\n    // [impl->REQ-DOCS-RELEASE-ASSET]\r\n    match &meta.docs {\r\n        None => {}\r\n        Some(docs) => {\r\n            eprintln!(\"  fetching {} (docs bundle)…\", docs.asset_name);\r\n            match fetch_release_asset_bytes(\r\n                &repo,\r\n                tag.as_deref(),\r\n                &docs.asset_name,\r\n                EffectiveTransport::Gh,\r\n                &scratch,\r\n            ) {\r\n                Ok(bundle) => match spt_daemon::verify_update_set_docs(&meta, &bundle) {\r\n                    Ok(()) => {\r\n                        if let Err(e) = cache.stage_docs(&bundle) {\r\n                            eprintln!(\"UPDATE_DOCS_SKIPPED: stage: {e} — docs retry next fetch\");\r\n                        }\r\n                    }\r\n                    Err(reason) => eprintln!(\r\n                        \"UPDATE_DOCS_SKIPPED: {reason} — docs retry next fetch\"\r\n                    ),\r\n                },\r\n                Err(e) => eprintln!(\r\n                    \"UPDATE_DOCS_SKIPPED: {} from {repo}: {e} — docs retry next fetch\",\r\n                    docs.asset_name\r\n                ),\r\n            }\r\n        }\r\n    }\r\n    let _ = std::fs::remove_dir_all(&scratch);\r\n\r\n    // 4. The full per-node front door for THIS platform (selects + classifies)\r\n    //    — mirrors what apply will do, so a set with no artifact for this node\r\n    //    fails loud here, not silently at apply.\r\n    let local = spt_daemon::current_platform();\r\n    let Some(local_bytes) = artifacts.get(local) else {\r\n        eprintln!(\"UPDATE_FETCH_REJECTED:NoArtifactForPlatform({local:?})\");\r\n        return 1;\r\n    };\r\n    match spt_daemon::update::plan_verified_update_set(\r\n        &spt_daemon::update::BrokerAbi::current(),\r\n…\n    ) {\n…\n    }\n}","startLine":10839,"lineNumbers":[10839,null,10845,null,10855,null,10857,null,10859,10860,10861,10862,10863,10864,10865,10866,10867,10868,10869,10870,10871,10872,10873,10874,10875,10876,10877,10878,10879,10880,10881,10882,10883,10884,10885,10886,10887,10888,10889,10890,10891,10892,10893,10894,10895,10896,10897,10898,10899,10900,10901,10902,10903,10904,10905,10906,10907,10908,10909,10910,10911,10912,10913,10914,10915,10916,10917,10918,10919,10920,10921,10922,10923,10924,10925,10926,10927,10928,10929,10930,10931,10932,10933,10934,10935,10936,10937,10938,10939,10940,10941,10942,10943,10944,10945,10946,10947,10948,10949,10950,10951,10952,10953,10954,10955,10956,10957,10958,10959,10960,10961,10962,10963,10964,10965,10966,10967,10968,10969,10970,10971,10972,10973,10974,10975,10976,10977,10978,10979,10980,10981,10982,10983,10984,10985,10986,10987,10988,10989,10990,10991,10992,10993,10994,10995,10996,10997,10998,10999,11000,11001,11002,11003,11004,11005,11006,11007,11008,11009,11010,11011,11012,11013,11014,11015,11016,11017,11018,11019,11020,11021,11022,11023,11024,11025,11026,11027,11028,11029,11030,11031,11032,11033,11034,11035,11036,11037,11038,11039,11040,11041,11042,11043,11044,11045,11046,11047,11048,11049,11050,11051,11052,11053,11054,11055,11056,11057,11058,11059,11060,11061,11062,11063,11064,11065,11066,11067,11068,11069,11070,11071,11072,11073,11074,11075,11076,11077,11078,11079,11080,11081,11082,11083,11084,11085,11086,11087,11088,11089,11090,11091,11092,11093,11094,11095,11096,11097,11098,11099,11100,11101,11102,11103,11104,11105,11106,11107,11108,11109,11110,11111,11112,11113,11114,11115,11116,11117,11118,11119,11120,11121,11122,11123,11124,11125,11126,11127,11128,11129,11130,11131,11132,11133,11134,11135,11136,11137,11138,11139,11140,11141,11142,11143,11144,11145,11146,11147,11148,11149,11150,11151,11152,11153,11154,11155,11156,11157,11158,11159,11160,11161,11162,11163,11164,11165,11166,11167,11168,null,11173,null,11200,11201]},"fileSize":1828112,"meta":{"source":{"type":"path","value":"C:\\Users\\decid\\Documents\\projects\\spt-core\\crates\\spt\\src\\cli.rs"},"truncation":{"direction":"head","truncatedBy":"lines","totalLines":40000,"totalBytes":13965,"outputLines":310,"outputBytes":13965,"shownRange":{"start":10859,"end":11168},"nextOffset":11169}}},"isError":false,"timestamp":1790039414589}}
{"type":"message","id":"075db823","parentId":"70e625af","timestamp":"2026-09-22T01:10:14.609Z","message":{"role":"toolResult","toolCallId":"call_GMk4pLLBud8TAR7Yj7oMvoQh|fc_06340275dc54e8ed016ab1d5764d1c87d1ade1b64ba586f420","toolName":"grep","content":[{"type":"text","text":"# crates/\n\n## spt/\n### Cargo.toml\n 46|[[bin]]\n*47|name = \"gh_fixture\"\n*48|path = \"tests/fixtures/gh_fixture.rs\"\n 49|\n 50|# Test-only POISONED `git` for `tests/whoami_identity_e2e.rs`\n 51|# (REQ-WHOAMI-IDENTITY-ONLY). Same tests/-homed `[[bin]]` pattern. Copied to\n\n### src/\n#### cli.rs\n 9994|/// Resolve a declared [`Transport`](spt_runtime::manifest::Transport) to the\n*9995|/// concrete avenue. `Auto` consults `gh_available` (a bounded probe) — `gh` when\n 9996|/// installed+authed, else HTTPS. Pure given the probe, so the decision is\n 9997|/// unit-testable with an injected closure.\n 9998|// [impl->REQ-ADAPTER-GH-TRANSPORT]\n*9999|fn resolve_transport(\n 10000|    declared: spt_runtime::manifest::Transport,\n*10001|    gh_available: impl FnOnce() -> bool,\n 10002|) -> EffectiveTransport {\n 10003|    use spt_runtime::manifest::Transport;\n 10004|    match declared {\n...\n 10007|        Transport::Auto => {\n*10008|            if gh_available() {\n 10009|                EffectiveTransport::Gh\n 10010|            } else {\n 10011|                EffectiveTransport::Https\n...\n 10020|// [impl->REQ-ADAPTER-GH-TRANSPORT]\n*10021|fn gh_available() -> bool {\n 10022|    matches!(\n 10023|        spt_runtime::run_bounded_command(\n 10024|            \"gh auth status\",\n...\n 10051|#[derive(Debug, Clone, Copy, PartialEq, Eq)]\n*10052|enum GhStatus {\n 10053|    Available,\n 10054|    Missing,\n 10055|    Unauthed,\n...\n 10057|\n*10058|/// Bounded probe behind [`GhStatus`]: `gh auth status` exits 0 only when a host\n 10059|/// is logged in; a spawn `NotFound` is the not-installed case. Bounded\n 10060|/// (REQ-HAZARD-SUBPROCESS-TIMEOUT).\n 10061|// [impl->REQ-UPDATE-GH-TRANSPORT]\n*10062|fn gh_status() -> GhStatus {\n 10063|    match spt_runtime::run_bounded_command(\n 10064|        \"gh auth status\",\n 10065|        &std::collections::BTreeMap::new(),\n...\n 10068|    ) {\n*10069|        Ok(out) if out.success() => GhStatus::Available,\n 10070|        Err(spt_runtime::RuntimeError::Spawn(e))\n 10071|            if e.kind() == std::io::ErrorKind::NotFound =>\n 10072|        {\n*10073|            GhStatus::Missing\n 10074|        }\n*10075|        _ => GhStatus::Unauthed,\n 10076|    }\n 10077|}\n 10078|\n...\n 10106|// [impl->REQ-UPDATE-GH-TRANSPORT]\n*10107|fn render_gh_auth_required() -> String {\n*10108|    \"UPDATE_FETCH_REJECTED:GhAuthRequired — the gh CLI is installed but not \\\n 10109|     authenticated. Run `gh auth login` with an account that can read the \\\n 10110|     release channel.\"\n 10111|        .to_string()\n...\n 10563|// [impl->REQ-ADAPTER-GH-TRANSPORT]\n*10564|fn gh_version_command(repo: &str) -> (String, std::collections::BTreeMap<String, String>) {\n 10565|    (\n 10566|        \"gh api repos/{repo}/releases/latest --jq .tag_name\".to_string(),\n 10567|        std::collections::BTreeMap::from([(\"repo\".to_string(), repo.to_string())]),\n...\n 10852|    }\n*10853|    let effective = resolve_transport(transport, gh_available);\n 10854|    eprintln!(\"Fetching adapter release asset {asset} from {spec} via {effective:?}…\");\n 10855|    let bytes = match fetch_release_asset_bytes(spec, tag, asset, effective, &github) {\n 10856|        Ok(b) => b,\n...\n 10977|    // [impl->REQ-UPDATE-GH-TRANSPORT]\n*10978|    match gh_status() {\n*10979|        GhStatus::Available => {}\n*10980|        GhStatus::Missing => {\n 10981|            eprintln!(\"{}\", render_gh_cli_required(std::env::consts::OS));\n 10982|            return 1;\n 10983|        }\n*10984|        GhStatus::Unauthed => {\n*10985|            eprintln!(\"{}\", render_gh_auth_required());\n 10986|            return 1;\n 10987|        }\n 10988|    }\n\n### tests/\n#### composite_e2e.rs\n 3|//! release, THEN updates a registered gh_release adapter — core-first order,\n*4|//! both legs landing observably. Hermetic: a fake `gh` CLI (`gh_fixture`, a\n 5|//! sibling `[[bin]]` copied to `gh(.exe)` on a PATH-prepended dir) serves the\n 6|//! whole release channel from a local fixture dir, so the mandated gh carrier\n 7|//! runs for real — subprocess, argv shapes, asset staging — with no network\n...\n 122|    let gh = bindir.join(if cfg!(windows) { \"gh.exe\" } else { \"gh\" });\n*123|    std::fs::copy(common::sibling_bin(\"gh_fixture\"), &gh).expect(\"gh_fixture must be built\");\n 124|    #[cfg(unix)]\n 125|    {\n 126|        use std::os::unix::fs::PermissionsExt;\n\n## xtask/src/\n### binedge.rs\n 88|    \"git_fixture\",\n*89|    \"gh_fixture\",\n 90|    \"post_step_fixture\",\n 91|];\n 92|\n### binnames.rs\n 153|        let targets = vec![\n*154|            (\"spt\".to_string(), \"gh_fixture\".to_string()),\n*155|            (\"spt\".to_string(), \"gh_fixture\".to_string()),\n 156|        ];\n 157|        assert!(duplicate_bin_names(&targets).is_empty());\n 158|    }"}],"details":{"scopePath":"crates, docs/adr/0036*","searchPath":"C:\\Users\\decid\\Documents\\projects\\spt-core","cwd":"C:\\Users\\decid\\Documents\\projects\\spt-core","matchCount":27,"fileCount":5,"files":["crates/spt/Cargo.toml","crates/spt/src/cli.rs","crates/spt/tests/composite_e2e.rs","crates/xtask/src/binedge.rs","crates/xtask/src/binnames.rs"],"fileMatches":[{"path":"crates/spt/Cargo.toml","count":2},{"path":"crates/spt/src/cli.rs","count":20},{"path":"crates/spt/tests/composite_e2e.rs","count":2},{"path":"crates/xtask/src/binedge.rs","count":1},{"path":"crates/xtask/src/binnames.rs","count":2}],"truncated":true,"perFileLimitReached":20,"displayContent":"# crates/\n\n## spt/\n### Cargo.toml\n 46│[[bin]]\n*47│name = \"gh_fixture\"\n*48│path = \"tests/fixtures/gh_fixture.rs\"\n 49│\n 50│# Test-only POISONED `git` for `tests/whoami_identity_e2e.rs`\n 51│# (REQ-WHOAMI-IDENTITY-ONLY). Same tests/-homed `[[bin]]` pattern. Copied to\n\n### src/\n#### cli.rs\n  9994│/// Resolve a declared [`Transport`](spt_runtime::manifest::Transport) to the\n *9995│/// concrete avenue. `Auto` consults `gh_available` (a bounded probe) — `gh` when\n  9996│/// installed+authed, else HTTPS. Pure given the probe, so the decision is\n  9997│/// unit-testable with an injected closure.\n  9998│// [impl->REQ-ADAPTER-GH-TRANSPORT]\n *9999│fn resolve_transport(\n 10000│    declared: spt_runtime::manifest::Transport,\n*10001│    gh_available: impl FnOnce() -> bool,\n 10002│) -> EffectiveTransport {\n 10003│    use spt_runtime::manifest::Transport;\n 10004│    match declared {\n      │...\n 10007│        Transport::Auto => {\n*10008│            if gh_available() {\n 10009│                EffectiveTransport::Gh\n 10010│            } else {\n 10011│                EffectiveTransport::Https\n      │...\n 10020│// [impl->REQ-ADAPTER-GH-TRANSPORT]\n*10021│fn gh_available() -> bool {\n 10022│    matches!(\n 10023│        spt_runtime::run_bounded_command(\n 10024│            \"gh auth status\",\n      │...\n 10051│#[derive(Debug, Clone, Copy, PartialEq, Eq)]\n*10052│enum GhStatus {\n 10053│    Available,\n 10054│    Missing,\n 10055│    Unauthed,\n      │...\n 10057│\n*10058│/// Bounded probe behind [`GhStatus`]: `gh auth status` exits 0 only when a host\n 10059│/// is logged in; a spawn `NotFound` is the not-installed case. Bounded\n 10060│/// (REQ-HAZARD-SUBPROCESS-TIMEOUT).\n 10061│// [impl->REQ-UPDATE-GH-TRANSPORT]\n*10062│fn gh_status() -> GhStatus {\n 10063│    match spt_runtime::run_bounded_command(\n 10064│        \"gh auth status\",\n 10065│        &std::collections::BTreeMap::new(),\n      │...\n 10068│    ) {\n*10069│        Ok(out) if out.success() => GhStatus::Available,\n 10070│        Err(spt_runtime::RuntimeError::Spawn(e))\n 10071│            if e.kind() == std::io::ErrorKind::NotFound =>\n 10072│        {\n*10073│            GhStatus::Missing\n 10074│        }\n*10075│        _ => GhStatus::Unauthed,\n 10076│    }\n 10077│}\n 10078│\n      │...\n 10106│// [impl->REQ-UPDATE-GH-TRANSPORT]\n*10107│fn render_gh_auth_required() -> String {\n*10108│    \"UPDATE_FETCH_REJECTED:GhAuthRequired — the gh CLI is installed but not \\\n 10109│     authenticated. Run `gh auth login` with an account that can read the \\\n 10110│     release channel.\"\n 10111│        .to_string()\n      │...\n 10563│// [impl->REQ-ADAPTER-GH-TRANSPORT]\n*10564│fn gh_version_command(repo: &str) -> (String, std::collections::BTreeMap<String, String>) {\n 10565│    (\n 10566│        \"gh api repos/{repo}/releases/latest --jq .tag_name\".to_string(),\n 10567│        std::collections::BTreeMap::from([(\"repo\".to_string(), repo.to_string())]),\n      │...\n 10852│    }\n*10853│    let effective = resolve_transport(transport, gh_available);\n 10854│    eprintln!(\"Fetching adapter release asset {asset} from {spec} via {effective:?}…\");\n 10855│    let bytes = match fetch_release_asset_bytes(spec, tag, asset, effective, &github) {\n 10856│        Ok(b) => b,\n      │...\n 10977│    // [impl->REQ-UPDATE-GH-TRANSPORT]\n*10978│    match gh_status() {\n*10979│        GhStatus::Available => {}\n*10980│        GhStatus::Missing => {\n 10981│            eprintln!(\"{}\", render_gh_cli_required(std::env::consts::OS));\n 10982│            return 1;\n 10983│        }\n*10984│        GhStatus::Unauthed => {\n*10985│            eprintln!(\"{}\", render_gh_auth_required());\n 10986│            return 1;\n 10987│        }\n 10988│    }\n\n### tests/\n#### composite_e2e.rs\n   3│//! release, THEN updates a registered gh_release adapter — core-first order,\n  *4│//! both legs landing observably. Hermetic: a fake `gh` CLI (`gh_fixture`, a\n   5│//! sibling `[[bin]]` copied to `gh(.exe)` on a PATH-prepended dir) serves the\n   6│//! whole release channel from a local fixture dir, so the mandated gh carrier\n   7│//! runs for real — subprocess, argv shapes, asset staging — with no network\n    │...\n 122│    let gh = bindir.join(if cfg!(windows) { \"gh.exe\" } else { \"gh\" });\n*123│    std::fs::copy(common::sibling_bin(\"gh_fixture\"), &gh).expect(\"gh_fixture must be built\");\n 124│    #[cfg(unix)]\n 125│    {\n 126│        use std::os::unix::fs::PermissionsExt;\n\n## xtask/src/\n### binedge.rs\n 88│    \"git_fixture\",\n*89│    \"gh_fixture\",\n 90│    \"post_step_fixture\",\n 91│];\n 92│\n### binnames.rs\n 153│        let targets = vec![\n*154│            (\"spt\".to_string(), \"gh_fixture\".to_string()),\n*155│            (\"spt\".to_string(), \"gh_fixture\".to_string()),\n 156│        ];\n 157│        assert!(duplicate_bin_names(&targets).is_empty());\n 158│    }"},"isError":false,"timestamp":1790039414608}}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_V9ZLYfuqsiX6ydXKeqqeXVW8|fc_06340275dc54e8ed016ab1d57fd08887d1ae9e348c8c7291ad","toolName":"grep","startedAt":"2026-09-22T01:10:23.879Z","args":{"path":"crates/spt;tests"},"intent":"Locating split tests and dispatch"},"id":"72e463d2","parentId":"075db823","timestamp":"2026-09-22T01:10:23.879Z"}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_6XrpGJxlyUm7X8qHos2JiHJd|fc_06340275dc54e8ed016ab1d57fd09887d1a621eb8e6b17afac","toolName":"read","startedAt":"2026-09-22T01:10:23.880Z","args":{"path":"crates/spt/src/cli.rs:10530-10655,22485-22545"},"intent":"Reading gh transport functions"},"id":"829f3023","parentId":"72e463d2","timestamp":"2026-09-22T01:10:23.880Z"}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_VVlVd65GWxfM9SDF62aT5OiP|fc_06340275dc54e8ed016ab1d57fd0a087d192a68f6a7f9f709c","toolName":"read","startedAt":"2026-09-22T01:10:23.880Z","args":{"path":"crates/spt/tests/fixtures/gh_fixture.rs:1-125"},"intent":"Reading hermetic gh fixture"},"id":"ba9be4a4","parentId":"829f3023","timestamp":"2026-09-22T01:10:23.880Z"}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_xZKY3H0SNjR7UOLUzPn47UQB|fc_06340275dc54e8ed016ab1d57fd0a487d18a00cc3d0454803e","toolName":"read","startedAt":"2026-09-22T01:10:23.880Z","args":{"path":"crates/spt/tests/composite_e2e.rs:1-230"},"intent":"Reading composite fixture setup"},"id":"a1b38321","parentId":"ba9be4a4","timestamp":"2026-09-22T01:10:23.880Z"}
{"type":"message","id":"b0e9c39f","parentId":"a1b38321","timestamp":"2026-09-22T01:10:23.880Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"call_V9ZLYfuqsiX6ydXKeqqeXVW8|fc_06340275dc54e8ed016ab1d57fd08887d1ae9e348c8c7291ad","name":"grep","arguments":{"i":"Locating split tests and dispatch","pattern":"render_gh_auth_required|GhAuthRequired|gh_install_hint|gh_status|cmd_update_fetch\\(|resolve_transport\\(","path":"crates/spt;tests","case":null,"gitignore":null,"skip":null},"intent":"Locating split tests and dispatch"},{"type":"toolCall","id":"call_6XrpGJxlyUm7X8qHos2JiHJd|fc_06340275dc54e8ed016ab1d57fd09887d1a621eb8e6b17afac","name":"read","arguments":{"i":"Reading gh transport functions","path":"crates/spt/src/cli.rs:10530-10655,22485-22545"},"intent":"Reading gh transport functions"},{"type":"toolCall","id":"call_VVlVd65GWxfM9SDF62aT5OiP|fc_06340275dc54e8ed016ab1d57fd0a087d192a68f6a7f9f709c","name":"read","arguments":{"i":"Reading hermetic gh fixture","path":"crates/spt/tests/fixtures/gh_fixture.rs:1-125"},"intent":"Reading hermetic gh fixture"},{"type":"toolCall","id":"call_xZKY3H0SNjR7UOLUzPn47UQB|fc_06340275dc54e8ed016ab1d57fd0a487d18a00cc3d0454803e","name":"read","arguments":{"i":"Reading composite fixture setup","path":"crates/spt/tests/composite_e2e.rs:1-230"},"intent":"Reading composite fixture setup"},{"type":"toolCall","id":"call_QNRy74V9TmGFPNqE2Y4rsmOn|fc_06340275dc54e8ed016ab1d57fd0a887d1b93a6d767b4a0088","name":"glob","arguments":{"i":"Locating release authority decisions","path":"docs/adr/0036*;docs/adr/0016*;crates/spt/src/cli*","hidden":null,"gitignore":null,"limit":50},"intent":"Locating release authority decisions"}],"api":"openai-codex-responses","provider":"openai-codex","model":"gpt-6-astra","usage":{"input":8310,"output":254,"cacheRead":32768,"cacheWrite":0,"totalTokens":41332,"cost":{"input":0.08310000000000001,"output":0.012700000000000001,"cacheRead":0.032768,"cacheWrite":0,"total":0.12856800000000002}},"stopReason":"toolUse","timestamp":1790039414660,"responseId":"resp_06340275dc54e8ed016ab1d576da5c87d19c8c93fc8ebd760b","providerPayload":{"type":"openaiResponsesHistory","provider":"openai-codex","dt":true,"items":[{"id":"fc_06340275dc54e8ed016ab1d57fd08887d1ae9e348c8c7291ad","type":"function_call","status":"completed","arguments":"{\"i\":\"Locating split tests and dispatch\",\"pattern\":\"render_gh_auth_required|GhAuthRequired|gh_install_hint|gh_status|cmd_update_fetch\\\\(|resolve_transport\\\\(\",\"path\":\"crates/spt;tests\",\"case\":null,\"gitignore\":null,\"skip\":null}","call_id":"call_V9ZLYfuqsiX6ydXKeqqeXVW8","internal_chat_message_metadata_passthrough":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"},"name":"grep","metadata":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"}},{"id":"fc_06340275dc54e8ed016ab1d57fd09887d1a621eb8e6b17afac","type":"function_call","status":"completed","arguments":"{\"i\":\"Reading gh transport functions\",\"path\":\"crates/spt/src/cli.rs:10530-10655,22485-22545\"}","call_id":"call_6XrpGJxlyUm7X8qHos2JiHJd","internal_chat_message_metadata_passthrough":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"},"name":"read","metadata":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"}},{"id":"fc_06340275dc54e8ed016ab1d57fd0a087d192a68f6a7f9f709c","type":"function_call","status":"completed","arguments":"{\"i\":\"Reading hermetic gh fixture\",\"path\":\"crates/spt/tests/fixtures/gh_fixture.rs:1-125\"}","call_id":"call_VVlVd65GWxfM9SDF62aT5OiP","internal_chat_message_metadata_passthrough":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"},"name":"read","metadata":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"}},{"id":"fc_06340275dc54e8ed016ab1d57fd0a487d18a00cc3d0454803e","type":"function_call","status":"completed","arguments":"{\"i\":\"Reading composite fixture setup\",\"path\":\"crates/spt/tests/composite_e2e.rs:1-230\"}","call_id":"call_xZKY3H0SNjR7UOLUzPn47UQB","internal_chat_message_metadata_passthrough":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"},"name":"read","metadata":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"}},{"id":"fc_06340275dc54e8ed016ab1d57fd0a887d1b93a6d767b4a0088","type":"function_call","status":"completed","arguments":"{\"i\":\"Locating release authority decisions\",\"path\":\"docs/adr/0036*;docs/adr/0016*;crates/spt/src/cli*\",\"hidden\":null,\"gitignore\":null,\"limit\":50}","call_id":"call_QNRy74V9TmGFPNqE2Y4rsmOn","internal_chat_message_metadata_passthrough":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"},"name":"glob","metadata":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"}}]},"duration":9211.39690000005,"ttft":9190.20409999974,"completedAt":1790039423875,"contextSnapshot":{"promptTokens":41078,"nonMessageTokens":10333,"compactionEpoch":0}}}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_QNRy74V9TmGFPNqE2Y4rsmOn|fc_06340275dc54e8ed016ab1d57fd0a887d1b93a6d767b4a0088","toolName":"glob","startedAt":"2026-09-22T01:10:23.881Z","args":{"path":"docs/adr/0036*;docs/adr/0016*;crates/spt/src/cli*"},"intent":"Locating release authority decisions"},"id":"1713b3fc","parentId":"b0e9c39f","timestamp":"2026-09-22T01:10:23.881Z"}
{"type":"message","id":"4c9691b3","parentId":"1713b3fc","timestamp":"2026-09-22T01:10:23.892Z","message":{"role":"toolResult","toolCallId":"call_QNRy74V9TmGFPNqE2Y4rsmOn|fc_06340275dc54e8ed016ab1d57fd0a887d1b93a6d767b4a0088","toolName":"glob","content":[{"type":"text","text":"# crates/spt/src/\ncli.rs\n# docs/adr/\n0036-private-fork-distribution.md\n0016-platform-targeted-update-sets.md"}],"details":{"scopePath":"docs/adr/0036*, docs/adr/0016*, crates/spt/src/cli*","fileCount":3,"files":["crates/spt/src/cli.rs","docs/adr/0036-private-fork-distribution.md","docs/adr/0016-platform-targeted-update-sets.md"],"truncated":false,"cwd":"C:\\Users\\decid\\Documents\\projects\\spt-core"},"isError":false,"timestamp":1790039423891}}
{"type":"message","id":"403301e8","parentId":"4c9691b3","timestamp":"2026-09-22T01:10:23.894Z","message":{"role":"toolResult","toolCallId":"call_VVlVd65GWxfM9SDF62aT5OiP|fc_06340275dc54e8ed016ab1d57fd0a087d192a68f6a7f9f709c","toolName":"read","content":[{"type":"text","text":"//! Test-only fake `gh` CLI for the `spt` crate's update-composite int gate\n//! (REQ-UPDATE-DEFAULT-COMPOSITE). Declared as a `[[bin]]` whose source lives\n//! under `tests/` so it never ships from `src/` (CI's release build is\n//! `cargo build -p spt --bin spt`, which excludes it). NOT a production binary.\n//! The test copies it to `gh(.exe)` in a PATH-prepended dir, so every gh\n//! subprocess spt-core shells (`gh auth status`, `gh api …/releases/latest`,\n//! `gh release download …`) resolves HERE and the whole release channel is\n//! served hermetically from a local fixture dir — no network, no real gh.\n//!\n//! Contract (env):\n//!   - `SPT_FAKE_GH_ROOT` — fixture dir: `tag.txt` (the latest release tag,\n//!     e.g. `v1.1.0`) + `assets/<name>` (release asset bytes by file name).\n//!   - `SPT_FAKE_GH_LOG` — optional append-log; each invocation records its\n//!     argv joined by spaces (the test asserts core-before-adapters ordering).\n//!\n//! Verbs (exactly the shapes cli.rs builds — anything else exits 2 loud):\n//!   - `gh auth status` → exit 0 (authed).\n//!   - `gh api repos/<repo>/releases/latest --jq .tag_name` → print `tag.txt`,\n//!     exit 0.\n//!   - `gh release download [<tag>] --repo <r> --pattern <asset> --dir <d>\n//!     --clobber` → copy `assets/<asset>` to `<d>/<asset>`; a missing asset →\n//!     exit 1.\n\nuse std::path::PathBuf;\n\nfn flag_value(args: &[String], flag: &str) -> Option<String> {\n    args.iter()\n        .position(|a| a == flag)\n        .and_then(|i| args.get(i + 1))\n        .cloned()\n}\n\nfn main() {\n    let args: Vec<String> = std::env::args().skip(1).collect();\n\n    if let Ok(log) = std::env::var(\"SPT_FAKE_GH_LOG\") {\n        use std::io::Write;\n        if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&log) {\n            let _ = writeln!(f, \"gh {}\", args.join(\" \"));\n        }\n    }\n\n    let root = PathBuf::from(\n        std::env::var(\"SPT_FAKE_GH_ROOT\").expect(\"SPT_FAKE_GH_ROOT must point at the fixture dir\"),\n    );\n\n    match args.first().map(String::as_str) {\n        Some(\"auth\") => {\n            // `gh auth status` — this fake is always installed + authed.\n        }\n        Some(\"api\") => {\n            // `gh api repos/<repo>/releases/latest --jq .tag_name`\n            let tag = std::fs::read_to_string(root.join(\"tag.txt\"))\n                .expect(\"fixture tag.txt (the latest release tag)\");\n            println!(\"{}\", tag.trim());\n        }\n        Some(\"release\") if args.get(1).map(String::as_str) == Some(\"download\") => {\n            let asset = flag_value(&args, \"--pattern\").expect(\"--pattern <asset>\");\n            let dir = PathBuf::from(flag_value(&args, \"--dir\").expect(\"--dir <out>\"));\n            let src = root.join(\"assets\").join(&asset);\n            if !src.is_file() {\n                eprintln!(\"fake gh: no such release asset: {asset}\");\n                std::process::exit(1);\n            }\n            std::fs::create_dir_all(&dir).expect(\"create --dir\");\n            std::fs::copy(&src, dir.join(&asset)).expect(\"copy asset\");\n        }\n        other => {\n            eprintln!(\"fake gh: unexpected verb {other:?} (args: {args:?})\");\n            std::process::exit(2);\n        }\n    }\n}"}],"details":{"totalLines":73,"displayContent":{"text":"//! Test-only fake `gh` CLI for the `spt` crate's update-composite int gate\r\n//! (REQ-UPDATE-DEFAULT-COMPOSITE). Declared as a `[[bin]]` whose source lives\r\n//! under `tests/` so it never ships from `src/` (CI's release build is\r\n//! `cargo build -p spt --bin spt`, which excludes it). NOT a production binary.\r\n//! The test copies it to `gh(.exe)` in a PATH-prepended dir, so every gh\r\n//! subprocess spt-core shells (`gh auth status`, `gh api …/releases/latest`,\r\n//! `gh release download …`) resolves HERE and the whole release channel is\r\n//! served hermetically from a local fixture dir — no network, no real gh.\r\n//!\r\n//! Contract (env):\r\n//!   - `SPT_FAKE_GH_ROOT` — fixture dir: `tag.txt` (the latest release tag,\r\n//!     e.g. `v1.1.0`) + `assets/<name>` (release asset bytes by file name).\r\n//!   - `SPT_FAKE_GH_LOG` — optional append-log; each invocation records its\r\n//!     argv joined by spaces (the test asserts core-before-adapters ordering).\r\n//!\r\n//! Verbs (exactly the shapes cli.rs builds — anything else exits 2 loud):\r\n//!   - `gh auth status` → exit 0 (authed).\r\n//!   - `gh api repos/<repo>/releases/latest --jq .tag_name` → print `tag.txt`,\r\n//!     exit 0.\r\n//!   - `gh release download [<tag>] --repo <r> --pattern <asset> --dir <d>\r\n//!     --clobber` → copy `assets/<asset>` to `<d>/<asset>`; a missing asset →\r\n//!     exit 1.\r\n\r\nuse std::path::PathBuf;\r\n\r\nfn flag_value(args: &[String], flag: &str) -> Option<String> {\r\n    args.iter()\r\n        .position(|a| a == flag)\r\n        .and_then(|i| args.get(i + 1))\r\n        .cloned()\r\n}\r\n\r\nfn main() {\r\n    let args: Vec<String> = std::env::args().skip(1).collect();\r\n\r\n    if let Ok(log) = std::env::var(\"SPT_FAKE_GH_LOG\") {\r\n        use std::io::Write;\r\n        if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&log) {\r\n            let _ = writeln!(f, \"gh {}\", args.join(\" \"));\r\n        }\r\n    }\r\n\r\n    let root = PathBuf::from(\r\n        std::env::var(\"SPT_FAKE_GH_ROOT\").expect(\"SPT_FAKE_GH_ROOT must point at the fixture dir\"),\r\n    );\r\n\r\n    match args.first().map(String::as_str) {\r\n        Some(\"auth\") => {\r\n            // `gh auth status` — this fake is always installed + authed.\r\n        }\r\n        Some(\"api\") => {\r\n            // `gh api repos/<repo>/releases/latest --jq .tag_name`\r\n            let tag = std::fs::read_to_string(root.join(\"tag.txt\"))\r\n                .expect(\"fixture tag.txt (the latest release tag)\");\r\n            println!(\"{}\", tag.trim());\r\n        }\r\n        Some(\"release\") if args.get(1).map(String::as_str) == Some(\"download\") => {\r\n            let asset = flag_value(&args, \"--pattern\").expect(\"--pattern <asset>\");\r\n            let dir = PathBuf::from(flag_value(&args, \"--dir\").expect(\"--dir <out>\"));\r\n            let src = root.join(\"assets\").join(&asset);\r\n            if !src.is_file() {\r\n                eprintln!(\"fake gh: no such release asset: {asset}\");\r\n                std::process::exit(1);\r\n            }\r\n            std::fs::create_dir_all(&dir).expect(\"create --dir\");\r\n            std::fs::copy(&src, dir.join(&asset)).expect(\"copy asset\");\r\n        }\r\n        other => {\r\n            eprintln!(\"fake gh: unexpected verb {other:?} (args: {args:?})\");\r\n            std::process::exit(2);\r\n        }\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]},"fileSize":3296,"meta":{"source":{"type":"path","value":"C:\\Users\\decid\\Documents\\projects\\spt-core\\crates\\spt\\tests\\fixtures\\gh_fixture.rs"}}},"isError":false,"timestamp":1790039423893}}
{"type":"message","id":"43d5e927","parentId":"403301e8","timestamp":"2026-09-22T01:10:23.895Z","message":{"role":"toolResult","toolCallId":"call_xZKY3H0SNjR7UOLUzPn47UQB|fc_06340275dc54e8ed016ab1d57fd0a487d18a00cc3d0454803e","toolName":"read","content":[{"type":"text","text":"//! THE-FORKENING W4 — the bare `spt update` composite, end to end\n//! (REQ-UPDATE-DEFAULT-COMPOSITE): ONE invocation fetches + applies the core\n//! release, THEN updates a registered gh_release adapter — core-first order,\n//! both legs landing observably. Hermetic: a fake `gh` CLI (`gh_fixture`, a\n//! sibling `[[bin]]` copied to `gh(.exe)` on a PATH-prepended dir) serves the\n//! whole release channel from a local fixture dir, so the mandated gh carrier\n//! runs for real — subprocess, argv shapes, asset staging — with no network\n//! and no real gh auth. The trust anchor is a test key overlaid via\n//! `release-keys.json`; the core apply is the daemonless path (no daemon in\n//! this home), which swaps THIS test's private copy of the spt binary (never\n//! the shared target/debug artifact).\n//!\n//! NOTE: the file/binary name deliberately avoids the substring \"update\" —\n//! Windows' installer-detection heuristic forces UAC elevation on a\n//! manifest-less exe whose name contains it (os error 740; the\n//! post_step_fixture precedent).\n//\n// [int->REQ-UPDATE-DEFAULT-COMPOSITE]\n\nuse ed25519_dalek::{Signer, SigningKey};\nuse std::collections::BTreeMap;\nuse std::path::Path;\nuse std::process::Command;\nuse std::time::Duration;\n\nmod common;\nuse common::CommandNoWindowExt;\n\nconst KEY_ID: &str = \"rel-composite-e2e\";\nconst NEW_CORE_BYTES: &[u8] = b\"NEW-CORE-BYTES-composite-e2e\";\n\nfn release_key() -> SigningKey {\n    SigningKey::from_bytes(&[7u8; 32])\n}\n\nfn hex(bytes: &[u8]) -> String {\n    bytes.iter().map(|b| format!(\"{b:02x}\")).collect()\n}\n\n\n\n/// The signed update set the fake channel serves: one artifact for the CURRENT\n/// platform (fetch requires `asset_name`), product 9.9.9, docs-less.\nfn signed_set_json(version: u64, artifact: &[u8]) -> String {\n    let meta = spt_daemon::UpdateSetMetadata {\n        version,\n        channel: \"stable\".to_string(),\n        expires_at_ms: u64::MAX,\n        key_id: KEY_ID.to_string(),\n        artifacts: BTreeMap::from([(\n            spt_daemon::current_platform().to_string(),\n            spt_daemon::UpdateArtifactMetadata {\n                artifact_sha256: spt_daemon::sha256_hex(artifact),\n                brain_ipc_version: spt_daemon::IPC_PROTOCOL_VERSION,\n                broker_resource_abi: spt_daemon::update::BROKER_RESOURCE_ABI,\n                asset_name: Some(\"spt-composite-artifact\".to_string()),\n            },\n        )]),\n        provenance: None,\n        product_version: \"9.9.9\".to_string(),\n        docs: None,\n    };\n    let metadata_json = serde_json::to_string(&meta).unwrap();\n    let sig = release_key().sign(metadata_json.as_bytes());\n    serde_json::to_string(&spt_daemon::SignedUpdateSet {\n        metadata_json,\n        signature_hex: hex(&sig.to_bytes()),\n    })\n    .unwrap()\n}\n\n/// Build a `.spt` adapter release archive (tar, relative members — the exact\n/// shape `extract_release_archive` consumes) whose root manifest declares\n/// `version` and keeps the gh_release `[update]` block (so the re-register\n/// after the swap preserves the avenue).\nfn adapter_spt(scratch: &Path, version: &str) -> Vec<u8> {\n    let tree = scratch.join(format!(\"adapter-tree-{version}\"));\n    std::fs::create_dir_all(&tree).unwrap();\n    std::fs::write(tree.join(\"manifest.toml\"), adapter_manifest(version)).unwrap();\n    let out = scratch.join(format!(\"adapter-{version}.spt\"));\n    let st = Command::new(\"tar\")\n        .arg(\"-cf\")\n        .arg(&out)\n        .arg(\"-C\")\n        .arg(&tree)\n        .arg(\".\")\n        .status()\n        .expect(\"system tar (present on Win10+ and Linux)\");\n    assert!(st.success(), \"tar the adapter tree\");\n    std::fs::read(&out).unwrap()\n}\n\nfn adapter_manifest(version: &str) -> String {\n    format!(\n        \"[adapter]\\nname = \\\"cc\\\"\\nkind = \\\"harness\\\"\\nversion = \\\"{version}\\\"\\n\\\n         min_spt_core_version = \\\"0\\\"\\n\\n\\\n         [update]\\navenue = \\\"gh_release\\\"\\nrepo = \\\"u/r\\\"\\n\"\n    )\n}\n\n#[test]\nfn bare_update_applies_core_then_updates_adapter_in_one_invocation() {\n    // ── (1) Isolated home + this test's PRIVATE copy of the spt binary (the\n    //    core apply swaps `current_exe` — driving the shared target/debug\n    //    binary would clobber it for the rest of the suite). The fake gh sits\n    //    beside it on a PATH-prepended dir. ──\n    let root = tempfile::tempdir().unwrap();\n    let home = root.path().join(\"home\");\n    let identity = home.join(\"identity\");\n    std::fs::create_dir_all(&identity).unwrap();\n    std::fs::write(\n        identity.join(\"release-keys.json\"),\n        serde_json::json!({ \"keys\": { KEY_ID: hex(release_key().verifying_key().as_bytes()) } })\n            .to_string(),\n    )\n    .unwrap();\n\n    let bindir = root.path().join(\"bin\");\n    std::fs::create_dir_all(&bindir).unwrap();\n    let spt = bindir.join(if cfg!(windows) { \"spt.exe\" } else { \"spt\" });\n    std::fs::copy(env!(\"CARGO_BIN_EXE_spt\"), &spt).unwrap();\n    let gh = bindir.join(if cfg!(windows) { \"gh.exe\" } else { \"gh\" });\n    std::fs::copy(common::sibling_bin(\"gh_fixture\"), &gh).expect(\"gh_fixture must be built\");\n    #[cfg(unix)]\n    {\n        use std::os::unix::fs::PermissionsExt;\n        for b in [&spt, &gh] {\n            let mut p = std::fs::metadata(b).unwrap().permissions();\n            p.set_mode(0o755);\n            std::fs::set_permissions(b, p).unwrap();\n        }\n    }\n    let path_env = format!(\n        \"{}{}{}\",\n        bindir.display(),\n        if cfg!(windows) { \";\" } else { \":\" },\n        std::env::var(\"PATH\").unwrap_or_default()\n    );\n\n    // ── (2) The fake release channel: the CORE set (update-set.json + the\n    //    platform artifact) and the ADAPTER release (latest tag v1.1.0 +\n    //    adapter.spt with the bumped manifest). ──\n    let gh_root = root.path().join(\"gh-root\");\n    let assets = gh_root.join(\"assets\");\n    std::fs::create_dir_all(&assets).unwrap();\n    std::fs::write(gh_root.join(\"tag.txt\"), \"v1.1.0\").unwrap();\n    std::fs::write(assets.join(\"update-set.json\"), signed_set_json(1, NEW_CORE_BYTES)).unwrap();\n    std::fs::write(assets.join(\"spt-composite-artifact\"), NEW_CORE_BYTES).unwrap();\n    std::fs::write(assets.join(\"adapter.spt\"), adapter_spt(root.path(), \"1.1.0\")).unwrap();\n    let gh_log = root.path().join(\"gh.log\");\n\n    // ── (3) Register the gh_release adapter `cc` at 1.0.0 (its install dir is\n    //    the registered source_dir — the in-place update target). ──\n    std::env::set_var(\"SPT_HOME\", &home);\n    let install_dir = spt_store::perch::spt_home().join(\"srcs\").join(\"cc\");\n    std::fs::create_dir_all(&install_dir).unwrap();\n    std::fs::write(install_dir.join(\"manifest.toml\"), adapter_manifest(\"1.0.0\")).unwrap();\n    spt_runtime::registry::register(&spt_store::perch::adapters_dir(), &install_dir, 1000)\n        .unwrap();\n    std::env::remove_var(\"SPT_HOME\");\n\n    // ── (4) ONE invocation: bare `spt update`. ──\n    let out = {\n        let mut cmd = Command::new(&spt);\n        cmd.no_window()\n            .arg(\"update\")\n            .env(\"SPT_HOME\", &home)\n            .env(\"PATH\", &path_env)\n            .env(\"SPT_FAKE_GH_ROOT\", &gh_root)\n            .env(\"SPT_FAKE_GH_LOG\", &gh_log);\n        common::output_bounded(cmd, Duration::from_secs(120))\n    };\n    let stdout = String::from_utf8_lossy(&out.stdout).to_string();\n    let stderr = String::from_utf8_lossy(&out.stderr).to_string();\n    let log = std::fs::read_to_string(&gh_log).unwrap_or_default();\n    eprintln!(\n        \"=== composite: ok={} ===\\n--- stdout ---\\n{stdout}\\n--- stderr ---\\n{stderr}\\n--- gh log ---\\n{log}\",\n        out.status.success()\n    );\n\n    assert!(\n        out.status.success(),\n        \"bare `spt update` must exit 0 when both legs succeed\"\n    );\n\n    // ── (5) CORE leg landed: the private spt copy now holds the NEW bytes\n    //    (daemonless swap — no daemon in this home), reported as installed. ──\n    assert_eq!(\n        std::fs::read(&spt).unwrap(),\n        NEW_CORE_BYTES,\n        \"the core apply must swap the invoking binary's on-disk seat\"\n    );\n    assert!(\n        stdout.contains(\"Installed spt-core v9.9.9 on disk\"),\n        \"the daemonless applied notice names the product version: {stdout}\"\n    );\n\n    // ── (6) ADAPTERS leg landed IN THE SAME INVOCATION: cc pulled 1.0.0 →\n    //    1.1.0 through the fake channel and re-registered. ──\n    assert!(\n        stderr.contains(\"ADAPTER_UPDATE_DONE:cc\"),\n        \"the adapter update must apply: {stderr}\"\n    );\n    assert!(\n        stderr.contains(\"ADAPTER_UPDATE_SUMMARY:cc: updated 1.0.0 -> 1.1.0\"),\n        \"the per-adapter summary line reports the pull: {stderr}\"\n    );\n    let landed = std::fs::read_to_string(install_dir.join(\"manifest.toml\")).unwrap();\n    assert!(\n        landed.contains(\"version = \\\"1.1.0\\\"\"),\n        \"the registered install dir must hold the new manifest: {landed}\"\n    );\n\n    // ── (7) CORE-FIRST order (the composite doctrine): the set download\n    //    happens strictly before the adapter's latest-release query. ──\n    let set_at = log\n        .find(\"--pattern update-set.json\")\n        .expect(\"the core leg downloaded the update set via gh\");\n    let adapter_at = log\n        .find(\"api repos/u/r/releases/latest\")\n        .expect(\"the adapters leg queried the adapter release via gh\");\n    assert!(\n        set_at < adapter_at,\n        \"core leg must run before the adapters leg (core-first): \\n{log}\"\n    );\n}"}],"details":{"totalLines":226,"displayContent":{"text":"//! THE-FORKENING W4 — the bare `spt update` composite, end to end\r\n//! (REQ-UPDATE-DEFAULT-COMPOSITE): ONE invocation fetches + applies the core\r\n//! release, THEN updates a registered gh_release adapter — core-first order,\r\n//! both legs landing observably. Hermetic: a fake `gh` CLI (`gh_fixture`, a\r\n//! sibling `[[bin]]` copied to `gh(.exe)` on a PATH-prepended dir) serves the\r\n//! whole release channel from a local fixture dir, so the mandated gh carrier\r\n//! runs for real — subprocess, argv shapes, asset staging — with no network\r\n//! and no real gh auth. The trust anchor is a test key overlaid via\r\n//! `release-keys.json`; the core apply is the daemonless path (no daemon in\r\n//! this home), which swaps THIS test's private copy of the spt binary (never\r\n//! the shared target/debug artifact).\r\n//!\r\n//! NOTE: the file/binary name deliberately avoids the substring \"update\" —\r\n//! Windows' installer-detection heuristic forces UAC elevation on a\r\n//! manifest-less exe whose name contains it (os error 740; the\r\n//! post_step_fixture precedent).\r\n//\r\n// [int->REQ-UPDATE-DEFAULT-COMPOSITE]\r\n\r\nuse ed25519_dalek::{Signer, SigningKey};\r\nuse std::collections::BTreeMap;\r\nuse std::path::Path;\r\nuse std::process::Command;\r\nuse std::time::Duration;\r\n\r\nmod common;\r\nuse common::CommandNoWindowExt;\r\n\r\nconst KEY_ID: &str = \"rel-composite-e2e\";\r\nconst NEW_CORE_BYTES: &[u8] = b\"NEW-CORE-BYTES-composite-e2e\";\r\n\r\nfn release_key() -> SigningKey {\r\n    SigningKey::from_bytes(&[7u8; 32])\r\n}\r\n\r\nfn hex(bytes: &[u8]) -> String {\r\n    bytes.iter().map(|b| format!(\"{b:02x}\")).collect()\r\n}\r\n\r\n\r\n\r\n/// The signed update set the fake channel serves: one artifact for the CURRENT\r\n/// platform (fetch requires `asset_name`), product 9.9.9, docs-less.\r\nfn signed_set_json(version: u64, artifact: &[u8]) -> String {\r\n    let meta = spt_daemon::UpdateSetMetadata {\r\n        version,\r\n        channel: \"stable\".to_string(),\r\n        expires_at_ms: u64::MAX,\r\n        key_id: KEY_ID.to_string(),\r\n        artifacts: BTreeMap::from([(\r\n            spt_daemon::current_platform().to_string(),\r\n            spt_daemon::UpdateArtifactMetadata {\r\n                artifact_sha256: spt_daemon::sha256_hex(artifact),\r\n                brain_ipc_version: spt_daemon::IPC_PROTOCOL_VERSION,\r\n                broker_resource_abi: spt_daemon::update::BROKER_RESOURCE_ABI,\r\n                asset_name: Some(\"spt-composite-artifact\".to_string()),\r\n            },\r\n        )]),\r\n        provenance: None,\r\n        product_version: \"9.9.9\".to_string(),\r\n        docs: None,\r\n    };\r\n    let metadata_json = serde_json::to_string(&meta).unwrap();\r\n    let sig = release_key().sign(metadata_json.as_bytes());\r\n    serde_json::to_string(&spt_daemon::SignedUpdateSet {\r\n        metadata_json,\r\n        signature_hex: hex(&sig.to_bytes()),\r\n    })\r\n    .unwrap()\r\n}\r\n\r\n/// Build a `.spt` adapter release archive (tar, relative members — the exact\r\n/// shape `extract_release_archive` consumes) whose root manifest declares\r\n/// `version` and keeps the gh_release `[update]` block (so the re-register\r\n/// after the swap preserves the avenue).\r\nfn adapter_spt(scratch: &Path, version: &str) -> Vec<u8> {\r\n    let tree = scratch.join(format!(\"adapter-tree-{version}\"));\r\n    std::fs::create_dir_all(&tree).unwrap();\r\n    std::fs::write(tree.join(\"manifest.toml\"), adapter_manifest(version)).unwrap();\r\n    let out = scratch.join(format!(\"adapter-{version}.spt\"));\r\n    let st = Command::new(\"tar\")\r\n        .arg(\"-cf\")\r\n        .arg(&out)\r\n        .arg(\"-C\")\r\n        .arg(&tree)\r\n        .arg(\".\")\r\n        .status()\r\n        .expect(\"system tar (present on Win10+ and Linux)\");\r\n    assert!(st.success(), \"tar the adapter tree\");\r\n    std::fs::read(&out).unwrap()\r\n}\r\n\r\nfn adapter_manifest(version: &str) -> String {\r\n    format!(\r\n        \"[adapter]\\nname = \\\"cc\\\"\\nkind = \\\"harness\\\"\\nversion = \\\"{version}\\\"\\n\\\r\n         min_spt_core_version = \\\"0\\\"\\n\\n\\\r\n         [update]\\navenue = \\\"gh_release\\\"\\nrepo = \\\"u/r\\\"\\n\"\r\n    )\r\n}\r\n\r\n#[test]\r\nfn bare_update_applies_core_then_updates_adapter_in_one_invocation() {\r\n    // ── (1) Isolated home + this test's PRIVATE copy of the spt binary (the\r\n    //    core apply swaps `current_exe` — driving the shared target/debug\r\n    //    binary would clobber it for the rest of the suite). The fake gh sits\r\n    //    beside it on a PATH-prepended dir. ──\r\n    let root = tempfile::tempdir().unwrap();\r\n    let home = root.path().join(\"home\");\r\n    let identity = home.join(\"identity\");\r\n    std::fs::create_dir_all(&identity).unwrap();\r\n    std::fs::write(\r\n        identity.join(\"release-keys.json\"),\r\n        serde_json::json!({ \"keys\": { KEY_ID: hex(release_key().verifying_key().as_bytes()) } })\r\n            .to_string(),\r\n    )\r\n    .unwrap();\r\n\r\n    let bindir = root.path().join(\"bin\");\r\n    std::fs::create_dir_all(&bindir).unwrap();\r\n    let spt = bindir.join(if cfg!(windows) { \"spt.exe\" } else { \"spt\" });\r\n    std::fs::copy(env!(\"CARGO_BIN_EXE_spt\"), &spt).unwrap();\r\n    let gh = bindir.join(if cfg!(windows) { \"gh.exe\" } else { \"gh\" });\r\n    std::fs::copy(common::sibling_bin(\"gh_fixture\"), &gh).expect(\"gh_fixture must be built\");\r\n    #[cfg(unix)]\r\n    {\r\n        use std::os::unix::fs::PermissionsExt;\r\n        for b in [&spt, &gh] {\r\n            let mut p = std::fs::metadata(b).unwrap().permissions();\r\n            p.set_mode(0o755);\r\n            std::fs::set_permissions(b, p).unwrap();\r\n        }\r\n    }\r\n    let path_env = format!(\r\n        \"{}{}{}\",\r\n        bindir.display(),\r\n        if cfg!(windows) { \";\" } else { \":\" },\r\n        std::env::var(\"PATH\").unwrap_or_default()\r\n    );\r\n\r\n    // ── (2) The fake release channel: the CORE set (update-set.json + the\r\n    //    platform artifact) and the ADAPTER release (latest tag v1.1.0 +\r\n    //    adapter.spt with the bumped manifest). ──\r\n    let gh_root = root.path().join(\"gh-root\");\r\n    let assets = gh_root.join(\"assets\");\r\n    std::fs::create_dir_all(&assets).unwrap();\r\n    std::fs::write(gh_root.join(\"tag.txt\"), \"v1.1.0\").unwrap();\r\n    std::fs::write(assets.join(\"update-set.json\"), signed_set_json(1, NEW_CORE_BYTES)).unwrap();\r\n    std::fs::write(assets.join(\"spt-composite-artifact\"), NEW_CORE_BYTES).unwrap();\r\n    std::fs::write(assets.join(\"adapter.spt\"), adapter_spt(root.path(), \"1.1.0\")).unwrap();\r\n    let gh_log = root.path().join(\"gh.log\");\r\n\r\n    // ── (3) Register the gh_release adapter `cc` at 1.0.0 (its install dir is\r\n    //    the registered source_dir — the in-place update target). ──\r\n    std::env::set_var(\"SPT_HOME\", &home);\r\n    let install_dir = spt_store::perch::spt_home().join(\"srcs\").join(\"cc\");\r\n    std::fs::create_dir_all(&install_dir).unwrap();\r\n    std::fs::write(install_dir.join(\"manifest.toml\"), adapter_manifest(\"1.0.0\")).unwrap();\r\n    spt_runtime::registry::register(&spt_store::perch::adapters_dir(), &install_dir, 1000)\r\n        .unwrap();\r\n    std::env::remove_var(\"SPT_HOME\");\r\n\r\n    // ── (4) ONE invocation: bare `spt update`. ──\r\n    let out = {\r\n        let mut cmd = Command::new(&spt);\r\n        cmd.no_window()\r\n            .arg(\"update\")\r\n            .env(\"SPT_HOME\", &home)\r\n            .env(\"PATH\", &path_env)\r\n            .env(\"SPT_FAKE_GH_ROOT\", &gh_root)\r\n            .env(\"SPT_FAKE_GH_LOG\", &gh_log);\r\n        common::output_bounded(cmd, Duration::from_secs(120))\r\n    };\r\n    let stdout = String::from_utf8_lossy(&out.stdout).to_string();\r\n    let stderr = String::from_utf8_lossy(&out.stderr).to_string();\r\n    let log = std::fs::read_to_string(&gh_log).unwrap_or_default();\r\n    eprintln!(\r\n        \"=== composite: ok={} ===\\n--- stdout ---\\n{stdout}\\n--- stderr ---\\n{stderr}\\n--- gh log ---\\n{log}\",\r\n        out.status.success()\r\n    );\r\n\r\n    assert!(\r\n        out.status.success(),\r\n        \"bare `spt update` must exit 0 when both legs succeed\"\r\n    );\r\n\r\n    // ── (5) CORE leg landed: the private spt copy now holds the NEW bytes\r\n    //    (daemonless swap — no daemon in this home), reported as installed. ──\r\n    assert_eq!(\r\n        std::fs::read(&spt).unwrap(),\r\n        NEW_CORE_BYTES,\r\n        \"the core apply must swap the invoking binary's on-disk seat\"\r\n    );\r\n    assert!(\r\n        stdout.contains(\"Installed spt-core v9.9.9 on disk\"),\r\n        \"the daemonless applied notice names the product version: {stdout}\"\r\n    );\r\n\r\n    // ── (6) ADAPTERS leg landed IN THE SAME INVOCATION: cc pulled 1.0.0 →\r\n    //    1.1.0 through the fake channel and re-registered. ──\r\n    assert!(\r\n        stderr.contains(\"ADAPTER_UPDATE_DONE:cc\"),\r\n        \"the adapter update must apply: {stderr}\"\r\n    );\r\n    assert!(\r\n        stderr.contains(\"ADAPTER_UPDATE_SUMMARY:cc: updated 1.0.0 -> 1.1.0\"),\r\n        \"the per-adapter summary line reports the pull: {stderr}\"\r\n    );\r\n    let landed = std::fs::read_to_string(install_dir.join(\"manifest.toml\")).unwrap();\r\n    assert!(\r\n        landed.contains(\"version = \\\"1.1.0\\\"\"),\r\n        \"the registered install dir must hold the new manifest: {landed}\"\r\n    );\r\n\r\n    // ── (7) CORE-FIRST order (the composite doctrine): the set download\r\n    //    happens strictly before the adapter's latest-release query. ──\r\n    let set_at = log\r\n        .find(\"--pattern update-set.json\")\r\n        .expect(\"the core leg downloaded the update set via gh\");\r\n    let adapter_at = log\r\n        .find(\"api repos/u/r/releases/latest\")\r\n        .expect(\"the adapters leg queried the adapter release via gh\");\r\n    assert!(\r\n        set_at < adapter_at,\r\n        \"core leg must run before the adapters leg (core-first): \\n{log}\"\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,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226]},"fileSize":9607,"meta":{"source":{"type":"path","value":"C:\\Users\\decid\\Documents\\projects\\spt-core\\crates\\spt\\tests\\composite_e2e.rs"}}},"isError":false,"timestamp":1790039423895}}
{"type":"message","id":"01c87402","parentId":"43d5e927","timestamp":"2026-09-22T01:10:23.907Z","message":{"role":"toolResult","toolCallId":"call_6XrpGJxlyUm7X8qHos2JiHJd|fc_06340275dc54e8ed016ab1d57fd09887d1a621eb8e6b17afac","toolName":"read","content":[{"type":"text","text":"fn cmd_install(\n…\n) -> i32 {\n…\n    // moment the binder's path can change is the moment the rule can go stale\n    // — and a stale rule reads GREEN BY NAME while inbound is dead on the\n    // Public profile. Field specimen: a rule naming an actions-runner debug exe\n    // on a node whose installed binder serves.\n    // [impl->REQ-INSTALL-7]\n    report_inbound_rule_reconcile(&target);\n    if !no_path {\n        match register_user_path(&dir) {\n            Ok(Some(note)) => println!(\"spt install: {note}\"),\n            Ok(None) => {}\n            Err(e) => {\n                // PATH failure degrades loud but never fails the placement —\n                // the printed absolute path keeps the node usable.\n                eprintln!(\"INSTALL_PATH_SKIPPED: {e}\");\n                eprintln!(\n                    \"spt install: invoke it absolutely ({}) or add {} to PATH yourself\",\n                    target.display(),\n                    dir.display()\n                );\n            }\n        }\n    }\n    println!(\n        \"spt install: in THIS terminal invoke it as {}; self-update now rides the \\\n         gh channel (`spt update fetch`)\",\n        target.display()\n    );\n    0\n}\n\n/// Build the `gh api` command (template + keys) that prints `repo`'s latest\n/// release tag to stdout. Pure — the caller runs it bounded. The `gh` CLI honors\n/// OAuth + `GH_TOKEN`, so this reads a private repo with no token in spt's hands.\n// [impl->REQ-ADAPTER-GH-TRANSPORT]\nfn gh_version_command(repo: &str) -> (String, std::collections::BTreeMap<String, String>) {\n    (\n        \"gh api repos/{repo}/releases/latest --jq .tag_name\".to_string(),\n        std::collections::BTreeMap::from([(\"repo\".to_string(), repo.to_string())]),\n    )\n}\n\n/// Build the `gh release download` command (template + keys) that writes `asset`\n/// from `repo` into `out_dir`. A concrete `tag` downloads that release; `None`\n/// downloads the latest (gh's no-tag-arg form — `latest` is NOT a literal tag).\n/// Pure — the caller runs it bounded. (REQ-ADAPTER-GH-TRANSPORT)\n// [impl->REQ-ADAPTER-GH-TRANSPORT]\nfn gh_download_command(\n    repo: &str,\n    tag: Option<&str>,\n    asset: &str,\n    out_dir: &std::path::Path,\n) -> (String, std::collections::BTreeMap<String, String>) {\n    let mut keys = std::collections::BTreeMap::from([\n        (\"repo\".to_string(), repo.to_string()),\n        (\"asset\".to_string(), asset.to_string()),\n        (\"dir\".to_string(), out_dir.to_string_lossy().into_owned()),\n    ]);\n    let template = match tag {\n        Some(t) => {\n            keys.insert(\"tag\".to_string(), t.to_string());\n            \"gh release download {tag} --repo {repo} --pattern {asset} --dir {dir} --clobber\"\n                .to_string()\n        }\n        None => {\n            \"gh release download --repo {repo} --pattern {asset} --dir {dir} --clobber\".to_string()\n        }\n    };\n    (template, keys)\n}\n\n/// Fetch a release asset's raw bytes via the resolved transport. HTTPS does a\n/// direct GET of the origin asset URL; `gh` shells `gh release download` into\n/// `scratch_dir` (the private-repo path) and reads the file back. `tag` is the\n/// concrete tag (e.g. `v0.13.2`) or `None` for the latest release.\n// [impl->REQ-ADAPTER-GH-TRANSPORT]\nfn fetch_release_asset_bytes(\n    repo: &str,\n    tag: Option<&str>,\n    asset: &str,\n    transport: EffectiveTransport,\n    scratch_dir: &std::path::Path,\n) -> Result<Vec<u8>, String> {\n    match transport {\n        EffectiveTransport::Https => {\n            let url = origin_asset_url(repo, tag, asset);\n            http_get_bytes(&release_http_client()?, &url)\n        }\n        EffectiveTransport::Gh => {\n            std::fs::create_dir_all(scratch_dir).map_err(|e| e.to_string())?;\n            let (template, keys) = gh_download_command(repo, tag, asset, scratch_dir);\n            match spt_runtime::run_bounded_command(\n                &template,\n                &keys,\n                Duration::from_secs(300),\n                None,\n            ) {\n                Ok(out) if out.success() => {\n                    let path = scratch_dir.join(asset);\n                    let bytes = std::fs::read(&path).map_err(|e| e.to_string());\n                    let _ = std::fs::remove_file(&path);\n                    bytes\n                }\n                Ok(out) => Err(format!(\"gh release download exit {:?}\", out.status_code)),\n                Err(e) => Err(e.to_string()),\n            }\n        }\n    }\n}\n\n/// Why a `.spt` archive could not be extracted into the adapter install dir.\n/// `NoArtifactForPlatform` is the typed multi-platform rejection (ADR-0024,\n/// mirrors ADR-0016's `RejectReason`) — a fat archive that carries other\n/// platforms' triple subdirs but not this node's; never a silent no-op.\n#[derive(Debug)]\nenum ExtractError {\n    /// A multi-platform archive (one or more known triple subdirs) does not\n    /// carry the recipient's own triple.\n    NoArtifactForPlatform { platform: String },\n    /// The bounded `tar` extract itself failed (non-zero exit / spawn error).\n    Tar(String),\n    /// A filesystem step (staging, moving the selected tree) failed.\n    Io(String),\n}\n\nimpl std::fmt::Display for ExtractError {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n…\n    }\n}\n…\nfn update_one_adapter(\n…\n) -> AdapterUpdateOutcome {\n…\n    {\n…\n        if adapter_applied {\n…\n        } else {\n            AdapterUpdateOutcome::UpToDate {\n                version: installed.to_string(),\n            }\n        }\n    }\n}\n\n/// `repo`'s latest release `tag_name`, leading `v` stripped (e.g. `v0.2.0` →\n/// `0.2.0`). HTTPS reads the public GitHub API; `gh` runs `gh api …/releases/latest\n/// --jq .tag_name` (the private-repo path). (REQ-UPD-9, REQ-ADAPTER-GH-TRANSPORT)\n// [impl->REQ-UPD-9]\n// [impl->REQ-ADAPTER-GH-TRANSPORT]\nfn gh_latest_release_version(\n    repo: &str,\n    transport: EffectiveTransport,\n) -> Result<String, String> {\n    // Test seam: `SPT_TEST_GH_LATEST` short-circuits the network so the\n    // `adapter update` post-step flow (REQ-ADAPTER-UPDATE-POST) can be integration-\n    // tested deterministically — set it to the installed version to exercise the\n    // no-op-unconditional post-step path. Never set in production.\n    if let Ok(v) = std::env::var(\"SPT_TEST_GH_LATEST\") {\n        if !v.is_empty() {\n            return Ok(v.strip_prefix('v').unwrap_or(&v).to_string());\n        }\n    }\n    let tag = match transport {\n        EffectiveTransport::Https => {\n            let url = format!(\"https://api.github.com/repos/{repo}/releases/latest\");\n            let body = http_get_text(&release_http_client()?, &url)?;\n            let v: serde_json::Value = serde_json::from_str(&body).map_err(|e| e.to_string())?;\n            v.get(\"tag_name\")\n                .and_then(|t| t.as_str())\n                .ok_or(\"release JSON had no tag_name\")?\n                .to_string()\n        }\n        EffectiveTransport::Gh => {\n            let (template, keys) = gh_version_command(repo);\n            match spt_runtime::run_bounded_command(\n                &template,\n                &keys,\n                Duration::from_secs(60),\n                None,\n            ) {\n                Ok(out) if out.success() => out.stdout.trim().to_string(),\n                Ok(out) => return Err(format!(\"gh api exit {:?}\", out.status_code)),\n                Err(e) => return Err(e.to_string()),\n            }\n        }\n    };\n    Ok(tag.strip_prefix('v').unwrap_or(&tag).to_string())\n}\n\n/// Compare two dotted-numeric versions (`a` newer than `b`?). Hand-rolled rather\n/// than pulling a semver dependency: splits on `.`, compares each numeric\n/// component, treats a missing component as 0, and a non-numeric component as 0\n/// (best-effort — a release tag spt-core can't order is treated as not-newer, so\n/// an unparseable tag never triggers an unrequested update). (REQ-UPD-9)\n// [impl->REQ-UPD-9]\nfn version_is_newer(a: &str, b: &str) -> bool {\n    let part = |s: &str| -> Vec<u64> {\n        s.split('.')\n            .map(|c| c.trim().parse::<u64>().unwrap_or(0))\n            .collect()\n    };\n…\n}"}],"details":{"displayContent":{"text":"fn cmd_install(\n…\n) -> i32 {\n…\n    // moment the binder's path can change is the moment the rule can go stale\n    // — and a stale rule reads GREEN BY NAME while inbound is dead on the\n    // Public profile. Field specimen: a rule naming an actions-runner debug exe\n    // on a node whose installed binder serves.\n    // [impl->REQ-INSTALL-7]\n    report_inbound_rule_reconcile(&target);\n    if !no_path {\n        match register_user_path(&dir) {\n            Ok(Some(note)) => println!(\"spt install: {note}\"),\n            Ok(None) => {}\n            Err(e) => {\n                // PATH failure degrades loud but never fails the placement —\n                // the printed absolute path keeps the node usable.\n                eprintln!(\"INSTALL_PATH_SKIPPED: {e}\");\n                eprintln!(\n                    \"spt install: invoke it absolutely ({}) or add {} to PATH yourself\",\n                    target.display(),\n                    dir.display()\n                );\n            }\n        }\n    }\n    println!(\n        \"spt install: in THIS terminal invoke it as {}; self-update now rides the \\\n         gh channel (`spt update fetch`)\",\n        target.display()\n    );\n    0\n}\n\n/// Build the `gh api` command (template + keys) that prints `repo`'s latest\n/// release tag to stdout. Pure — the caller runs it bounded. The `gh` CLI honors\n/// OAuth + `GH_TOKEN`, so this reads a private repo with no token in spt's hands.\n// [impl->REQ-ADAPTER-GH-TRANSPORT]\nfn gh_version_command(repo: &str) -> (String, std::collections::BTreeMap<String, String>) {\n    (\n        \"gh api repos/{repo}/releases/latest --jq .tag_name\".to_string(),\n        std::collections::BTreeMap::from([(\"repo\".to_string(), repo.to_string())]),\n    )\n}\n\n/// Build the `gh release download` command (template + keys) that writes `asset`\n/// from `repo` into `out_dir`. A concrete `tag` downloads that release; `None`\n/// downloads the latest (gh's no-tag-arg form — `latest` is NOT a literal tag).\n/// Pure — the caller runs it bounded. (REQ-ADAPTER-GH-TRANSPORT)\n// [impl->REQ-ADAPTER-GH-TRANSPORT]\nfn gh_download_command(\n    repo: &str,\n    tag: Option<&str>,\n    asset: &str,\n    out_dir: &std::path::Path,\n) -> (String, std::collections::BTreeMap<String, String>) {\n    let mut keys = std::collections::BTreeMap::from([\n        (\"repo\".to_string(), repo.to_string()),\n        (\"asset\".to_string(), asset.to_string()),\n        (\"dir\".to_string(), out_dir.to_string_lossy().into_owned()),\n    ]);\n    let template = match tag {\n        Some(t) => {\n            keys.insert(\"tag\".to_string(), t.to_string());\n            \"gh release download {tag} --repo {repo} --pattern {asset} --dir {dir} --clobber\"\n                .to_string()\n        }\n        None => {\n            \"gh release download --repo {repo} --pattern {asset} --dir {dir} --clobber\".to_string()\n        }\n    };\n    (template, keys)\n}\n\n/// Fetch a release asset's raw bytes via the resolved transport. HTTPS does a\n/// direct GET of the origin asset URL; `gh` shells `gh release download` into\n/// `scratch_dir` (the private-repo path) and reads the file back. `tag` is the\n/// concrete tag (e.g. `v0.13.2`) or `None` for the latest release.\n// [impl->REQ-ADAPTER-GH-TRANSPORT]\nfn fetch_release_asset_bytes(\n    repo: &str,\n    tag: Option<&str>,\n    asset: &str,\n    transport: EffectiveTransport,\n    scratch_dir: &std::path::Path,\n) -> Result<Vec<u8>, String> {\n    match transport {\n        EffectiveTransport::Https => {\n            let url = origin_asset_url(repo, tag, asset);\n            http_get_bytes(&release_http_client()?, &url)\n        }\n        EffectiveTransport::Gh => {\n            std::fs::create_dir_all(scratch_dir).map_err(|e| e.to_string())?;\n            let (template, keys) = gh_download_command(repo, tag, asset, scratch_dir);\n            match spt_runtime::run_bounded_command(\n                &template,\n                &keys,\n                Duration::from_secs(300),\n                None,\n            ) {\n                Ok(out) if out.success() => {\n                    let path = scratch_dir.join(asset);\n                    let bytes = std::fs::read(&path).map_err(|e| e.to_string());\n                    let _ = std::fs::remove_file(&path);\n                    bytes\n                }\n                Ok(out) => Err(format!(\"gh release download exit {:?}\", out.status_code)),\n                Err(e) => Err(e.to_string()),\n            }\n        }\n    }\n}\n\n/// Why a `.spt` archive could not be extracted into the adapter install dir.\n/// `NoArtifactForPlatform` is the typed multi-platform rejection (ADR-0024,\n/// mirrors ADR-0016's `RejectReason`) — a fat archive that carries other\n/// platforms' triple subdirs but not this node's; never a silent no-op.\n#[derive(Debug)]\nenum ExtractError {\n    /// A multi-platform archive (one or more known triple subdirs) does not\n    /// carry the recipient's own triple.\n    NoArtifactForPlatform { platform: String },\n    /// The bounded `tar` extract itself failed (non-zero exit / spawn error).\n    Tar(String),\n    /// A filesystem step (staging, moving the selected tree) failed.\n    Io(String),\n}\n\nimpl std::fmt::Display for ExtractError {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n…\n    }\n}\n…\nfn update_one_adapter(\n…\n) -> AdapterUpdateOutcome {\n…\n    {\n…\n        if adapter_applied {\n…\n        } else {\n            AdapterUpdateOutcome::UpToDate {\n                version: installed.to_string(),\n            }\n        }\n    }\n}\n\n/// `repo`'s latest release `tag_name`, leading `v` stripped (e.g. `v0.2.0` →\n/// `0.2.0`). HTTPS reads the public GitHub API; `gh` runs `gh api …/releases/latest\n/// --jq .tag_name` (the private-repo path). (REQ-UPD-9, REQ-ADAPTER-GH-TRANSPORT)\n// [impl->REQ-UPD-9]\n// [impl->REQ-ADAPTER-GH-TRANSPORT]\nfn gh_latest_release_version(\n    repo: &str,\n    transport: EffectiveTransport,\n) -> Result<String, String> {\n    // Test seam: `SPT_TEST_GH_LATEST` short-circuits the network so the\n    // `adapter update` post-step flow (REQ-ADAPTER-UPDATE-POST) can be integration-\n    // tested deterministically — set it to the installed version to exercise the\n    // no-op-unconditional post-step path. Never set in production.\n    if let Ok(v) = std::env::var(\"SPT_TEST_GH_LATEST\") {\n        if !v.is_empty() {\n            return Ok(v.strip_prefix('v').unwrap_or(&v).to_string());\n        }\n    }\n    let tag = match transport {\n        EffectiveTransport::Https => {\n            let url = format!(\"https://api.github.com/repos/{repo}/releases/latest\");\n            let body = http_get_text(&release_http_client()?, &url)?;\n            let v: serde_json::Value = serde_json::from_str(&body).map_err(|e| e.to_string())?;\n            v.get(\"tag_name\")\n                .and_then(|t| t.as_str())\n                .ok_or(\"release JSON had no tag_name\")?\n                .to_string()\n        }\n        EffectiveTransport::Gh => {\n            let (template, keys) = gh_version_command(repo);\n            match spt_runtime::run_bounded_command(\n                &template,\n                &keys,\n                Duration::from_secs(60),\n                None,\n            ) {\n                Ok(out) if out.success() => out.stdout.trim().to_string(),\n                Ok(out) => return Err(format!(\"gh api exit {:?}\", out.status_code)),\n                Err(e) => return Err(e.to_string()),\n            }\n        }\n    };\n    Ok(tag.strip_prefix('v').unwrap_or(&tag).to_string())\n}\n\n/// Compare two dotted-numeric versions (`a` newer than `b`?). Hand-rolled rather\n/// than pulling a semver dependency: splits on `.`, compares each numeric\n/// component, treats a missing component as 0, and a non-numeric component as 0\n/// (best-effort — a release tag spt-core can't order is treated as not-newer, so\n/// an unparseable tag never triggers an unrequested update). (REQ-UPD-9)\n// [impl->REQ-UPD-9]\nfn version_is_newer(a: &str, b: &str) -> bool {\n    let part = |s: &str| -> Vec<u64> {\n        s.split('.')\n            .map(|c| c.trim().parse::<u64>().unwrap_or(0))\n            .collect()\n    };\n…\n}","startLine":10483,"lineNumbers":[10483,null,10488,null,10530,10531,10532,10533,10534,10535,10536,10537,10538,10539,10540,10541,10542,10543,10544,10545,10546,10547,10548,10549,10550,10551,10552,10553,10554,10555,10556,10557,10558,10559,10560,10561,10562,10563,10564,10565,10566,10567,10568,10569,10570,10571,10572,10573,10574,10575,10576,10577,10578,10579,10580,10581,10582,10583,10584,10585,10586,10587,10588,10589,10590,10591,10592,10593,10594,10595,10596,10597,10598,10599,10600,10601,10602,10603,10604,10605,10606,10607,10608,10609,10610,10611,10612,10613,10614,10615,10616,10617,10618,10619,10620,10621,10622,10623,10624,10625,10626,10627,10628,10629,10630,10631,10632,10633,10634,10635,10636,10637,10638,10639,10640,10641,10642,10643,10644,10645,10646,10647,10648,10649,10650,10651,10652,10653,10654,10655,null,10663,10664,null,22265,null,22270,null,22272,null,22478,null,22483,22484,22485,22486,22487,22488,22489,22490,22491,22492,22493,22494,22495,22496,22497,22498,22499,22500,22501,22502,22503,22504,22505,22506,22507,22508,22509,22510,22511,22512,22513,22514,22515,22516,22517,22518,22519,22520,22521,22522,22523,22524,22525,22526,22527,22528,22529,22530,22531,22532,22533,22534,22535,22536,22537,22538,22539,22540,22541,22542,22543,22544,22545,22546,22547,null,22558]},"fileSize":1828112,"meta":{"source":{"type":"path","value":"C:\\Users\\decid\\Documents\\projects\\spt-core\\crates\\spt\\src\\cli.rs"}}},"isError":false,"timestamp":1790039423906}}
{"type":"message","id":"d3170774","parentId":"01c87402","timestamp":"2026-09-22T01:10:23.944Z","message":{"role":"toolResult","toolCallId":"call_V9ZLYfuqsiX6ydXKeqqeXVW8|fc_06340275dc54e8ed016ab1d57fd08887d1ae9e348c8c7291ad","toolName":"grep","content":[{"type":"text","text":"# crates/spt/src/\n## cli.rs\n 2488|                apply,\n*2489|            }) => cmd_update_fetch(channel, tag, apply),\n 2490|            // [impl->REQ-UPDATE-ADAPTERS-VERB] the alias form; one engine.\n 2491|            // BARE VERB: no update is in flight, so the installed core IS the right\n 2492|            // question and this keeps passing the running CLI's version. \"One engine\" is\n...\n 9646|        let code = match leg {\n*9647|            UpdateLeg::FetchApply => cmd_update_fetch(None, None, true),\n*9648|            UpdateLeg::Fetch => cmd_update_fetch(None, None, false),\n 9649|            // THE COMPOSITE LEG — the only composite entry, and it covers BOTH plans\n 9650|            // (`plan_update_legs` pushes `Adapters` for the `--restart` and the plain\n 9651|            // form alike). This is the one caller that must NOT ask about the running\n...\n 9998|// [impl->REQ-ADAPTER-GH-TRANSPORT]\n*9999|fn resolve_transport(\n 10000|    declared: spt_runtime::manifest::Transport,\n 10001|    gh_available: impl FnOnce() -> bool,\n 10002|) -> EffectiveTransport {\n...\n 10061|// [impl->REQ-UPDATE-GH-TRANSPORT]\n*10062|fn gh_status() -> GhStatus {\n 10063|    match spt_runtime::run_bounded_command(\n 10064|        \"gh auth status\",\n 10065|        &std::collections::BTreeMap::new(),\n...\n 10081|// [impl->REQ-UPDATE-GH-TRANSPORT]\n*10082|fn gh_install_hint(os: &str) -> &'static str {\n 10083|    match os {\n 10084|        \"windows\" => \"winget install --id GitHub.cli\",\n 10085|        \"macos\" => \"brew install gh\",\n...\n 10099|         then authenticate:  gh auth login\",\n*10100|        gh_install_hint(os)\n 10101|    )\n 10102|}\n 10103|\n...\n 10106|// [impl->REQ-UPDATE-GH-TRANSPORT]\n*10107|fn render_gh_auth_required() -> String {\n*10108|    \"UPDATE_FETCH_REJECTED:GhAuthRequired — the gh CLI is installed but not \\\n 10109|     authenticated. Run `gh auth login` with an account that can read the \\\n 10110|     release channel.\"\n 10111|        .to_string()\n...\n 10852|    }\n*10853|    let effective = resolve_transport(transport, gh_available);\n 10854|    eprintln!(\"Fetching adapter release asset {asset} from {spec} via {effective:?}…\");\n 10855|    let bytes = match fetch_release_asset_bytes(spec, tag, asset, effective, &github) {\n 10856|        Ok(b) => b,\n...\n 10969|\n*10970|fn cmd_update_fetch(channel: Option<String>, tag: Option<String>, apply: bool) -> i32 {\n 10971|    let repo = install_repo();\n 10972|\n 10973|    // The gh CLI is the mandated carrier (ADR-0036 §3): the channel is private,\n...\n 10977|    // [impl->REQ-UPDATE-GH-TRANSPORT]\n*10978|    match gh_status() {\n 10979|        GhStatus::Available => {}\n 10980|        GhStatus::Missing => {\n 10981|            eprintln!(\"{}\", render_gh_cli_required(std::env::consts::OS));\n...\n 10984|        GhStatus::Unauthed => {\n*10985|            eprintln!(\"{}\", render_gh_auth_required());\n 10986|            return 1;\n 10987|        }\n 10988|    }\n...\n 22286|        // [impl->REQ-ADAPTER-GH-TRANSPORT]\n*22287|        let transport = resolve_transport(update.transport, gh_available);\n 22288|\n 22289|        // 1. Latest release version off the GitHub API (no download yet).\n 22290|        let latest = match gh_latest_release_version(repo, transport) {\n...\n 26541|        assert_eq!(\n*26542|            resolve_transport(Transport::Https, || unreachable!(\"Https must not probe gh\")),\n 26543|            EffectiveTransport::Https,\n 26544|            \"explicit Https resolves to Https without probing\",\n 26545|        );\n 26546|        assert_eq!(\n*26547|            resolve_transport(Transport::Gh, || unreachable!(\"Gh must not probe gh\")),\n 26548|            EffectiveTransport::Gh,\n 26549|            \"explicit Gh resolves to Gh without probing\",\n 26550|        );\n...\n 26559|        assert_eq!(\n*26560|            resolve_transport(Transport::Auto, || true),\n 26561|            EffectiveTransport::Gh,\n 26562|            \"auto picks gh when the probe reports it available\",\n 26563|        );\n 26564|        assert_eq!(\n*26565|            resolve_transport(Transport::Auto, || false),\n 26566|            EffectiveTransport::Https,\n 26567|            \"auto falls back to https when gh is unavailable\",\n 26568|        );\n...\n 35349|        // Unauthed: a DISTINCT token routing at `gh auth login`, no install hint.\n*35350|        let unauthed = render_gh_auth_required();\n 35351|        assert!(\n*35352|            unauthed.contains(\"UPDATE_FETCH_REJECTED:GhAuthRequired\"),\n 35353|            \"{unauthed}\"\n 35354|        );\n 35355|        assert!(unauthed.contains(\"gh auth login\"), \"{unauthed}\");"}],"details":{"scopePath":"crates/spt, tests","searchPath":"C:\\Users\\decid\\Documents\\projects\\spt-core","cwd":"C:\\Users\\decid\\Documents\\projects\\spt-core","matchCount":20,"fileCount":1,"files":["crates/spt/src/cli.rs"],"fileMatches":[{"path":"crates/spt/src/cli.rs","count":20}],"truncated":false,"displayContent":"# crates/spt/src/\n## cli.rs\n  2488│                apply,\n *2489│            }) => cmd_update_fetch(channel, tag, apply),\n  2490│            // [impl->REQ-UPDATE-ADAPTERS-VERB] the alias form; one engine.\n  2491│            // BARE VERB: no update is in flight, so the installed core IS the right\n  2492│            // question and this keeps passing the running CLI's version. \"One engine\" is\n      │...\n  9646│        let code = match leg {\n *9647│            UpdateLeg::FetchApply => cmd_update_fetch(None, None, true),\n *9648│            UpdateLeg::Fetch => cmd_update_fetch(None, None, false),\n  9649│            // THE COMPOSITE LEG — the only composite entry, and it covers BOTH plans\n  9650│            // (`plan_update_legs` pushes `Adapters` for the `--restart` and the plain\n  9651│            // form alike). This is the one caller that must NOT ask about the running\n      │...\n  9998│// [impl->REQ-ADAPTER-GH-TRANSPORT]\n *9999│fn resolve_transport(\n 10000│    declared: spt_runtime::manifest::Transport,\n 10001│    gh_available: impl FnOnce() -> bool,\n 10002│) -> EffectiveTransport {\n      │...\n 10061│// [impl->REQ-UPDATE-GH-TRANSPORT]\n*10062│fn gh_status() -> GhStatus {\n 10063│    match spt_runtime::run_bounded_command(\n 10064│        \"gh auth status\",\n 10065│        &std::collections::BTreeMap::new(),\n      │...\n 10081│// [impl->REQ-UPDATE-GH-TRANSPORT]\n*10082│fn gh_install_hint(os: &str) -> &'static str {\n 10083│    match os {\n 10084│        \"windows\" => \"winget install --id GitHub.cli\",\n 10085│        \"macos\" => \"brew install gh\",\n      │...\n 10099│         then authenticate:  gh auth login\",\n*10100│        gh_install_hint(os)\n 10101│    )\n 10102│}\n 10103│\n      │...\n 10106│// [impl->REQ-UPDATE-GH-TRANSPORT]\n*10107│fn render_gh_auth_required() -> String {\n*10108│    \"UPDATE_FETCH_REJECTED:GhAuthRequired — the gh CLI is installed but not \\\n 10109│     authenticated. Run `gh auth login` with an account that can read the \\\n 10110│     release channel.\"\n 10111│        .to_string()\n      │...\n 10852│    }\n*10853│    let effective = resolve_transport(transport, gh_available);\n 10854│    eprintln!(\"Fetching adapter release asset {asset} from {spec} via {effective:?}…\");\n 10855│    let bytes = match fetch_release_asset_bytes(spec, tag, asset, effective, &github) {\n 10856│        Ok(b) => b,\n      │...\n 10969│\n*10970│fn cmd_update_fetch(channel: Option<String>, tag: Option<String>, apply: bool) -> i32 {\n 10971│    let repo = install_repo();\n 10972│\n 10973│    // The gh CLI is the mandated carrier (ADR-0036 §3): the channel is private,\n      │...\n 10977│    // [impl->REQ-UPDATE-GH-TRANSPORT]\n*10978│    match gh_status() {\n 10979│        GhStatus::Available => {}\n 10980│        GhStatus::Missing => {\n 10981│            eprintln!(\"{}\", render_gh_cli_required(std::env::consts::OS));\n      │...\n 10984│        GhStatus::Unauthed => {\n*10985│            eprintln!(\"{}\", render_gh_auth_required());\n 10986│            return 1;\n 10987│        }\n 10988│    }\n      │...\n 22286│        // [impl->REQ-ADAPTER-GH-TRANSPORT]\n*22287│        let transport = resolve_transport(update.transport, gh_available);\n 22288│\n 22289│        // 1. Latest release version off the GitHub API (no download yet).\n 22290│        let latest = match gh_latest_release_version(repo, transport) {\n      │...\n 26541│        assert_eq!(\n*26542│            resolve_transport(Transport::Https, || unreachable!(\"Https must not probe gh\")),\n 26543│            EffectiveTransport::Https,\n 26544│            \"explicit Https resolves to Https without probing\",\n 26545│        );\n 26546│        assert_eq!(\n*26547│            resolve_transport(Transport::Gh, || unreachable!(\"Gh must not probe gh\")),\n 26548│            EffectiveTransport::Gh,\n 26549│            \"explicit Gh resolves to Gh without probing\",\n 26550│        );\n      │...\n 26559│        assert_eq!(\n*26560│            resolve_transport(Transport::Auto, || true),\n 26561│            EffectiveTransport::Gh,\n 26562│            \"auto picks gh when the probe reports it available\",\n 26563│        );\n 26564│        assert_eq!(\n*26565│            resolve_transport(Transport::Auto, || false),\n 26566│            EffectiveTransport::Https,\n 26567│            \"auto falls back to https when gh is unavailable\",\n 26568│        );\n      │...\n 35349│        // Unauthed: a DISTINCT token routing at `gh auth login`, no install hint.\n*35350│        let unauthed = render_gh_auth_required();\n 35351│        assert!(\n*35352│            unauthed.contains(\"UPDATE_FETCH_REJECTED:GhAuthRequired\"),\n 35353│            \"{unauthed}\"\n 35354│        );\n 35355│        assert!(unauthed.contains(\"gh auth login\"), \"{unauthed}\");"},"isError":false,"timestamp":1790039423943}}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_9fm3KPIIbMEEVPwDqioV8bWR|fc_06340275dc54e8ed016ab1d58d028487d190ac3d4830a847aa","toolName":"read","startedAt":"2026-09-22T01:10:37.077Z","args":{"path":"crates/spt/src/cli.rs:35300-35375,26525-26665,9620-9695"},"intent":"Reading gate rendering tests"},"id":"e159b82d","parentId":"d3170774","timestamp":"2026-09-22T01:10:37.077Z"}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_VYcpbrsWr8yFcrLGxsvFLmv5|fc_06340275dc54e8ed016ab1d58d029487d1b0eeafcaa9e48c4c","toolName":"read","startedAt":"2026-09-22T01:10:37.077Z","args":{"path":"crates/spt-runtime/src/runtime.rs:960-1012"},"intent":"Reading timeout implementation details"},"id":"e57b9b01","parentId":"e159b82d","timestamp":"2026-09-22T01:10:37.077Z"}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_AMduIm11L8sFeaIefj4PklNO|fc_06340275dc54e8ed016ab1d58d029c87d1b5f66e95b8e63d39","toolName":"read","startedAt":"2026-09-22T01:10:37.077Z","args":{"path":"docs/adr/0036-private-fork-distribution.md:1-140"},"intent":"Reading private distribution authority"},"id":"e2c765e2","parentId":"e57b9b01","timestamp":"2026-09-22T01:10:37.077Z"}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_Qsjh6lLOHwCKHsRZruS7Omr3|fc_06340275dc54e8ed016ab1d58d02a087d1bef1615e7bc47ba2","toolName":"read","startedAt":"2026-09-22T01:10:37.077Z","args":{"path":"docs/adr/0016-platform-targeted-update-sets.md:1-100"},"intent":"Reading update set authority"},"id":"319cbcd8","parentId":"e2c765e2","timestamp":"2026-09-22T01:10:37.078Z"}
{"type":"message","id":"75dc6fdf","parentId":"319cbcd8","timestamp":"2026-09-22T01:10:37.078Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"call_9fm3KPIIbMEEVPwDqioV8bWR|fc_06340275dc54e8ed016ab1d58d028487d190ac3d4830a847aa","name":"read","arguments":{"i":"Reading gate rendering tests","path":"crates/spt/src/cli.rs:35300-35375,26525-26665,9620-9695"},"intent":"Reading gate rendering tests"},{"type":"toolCall","id":"call_VYcpbrsWr8yFcrLGxsvFLmv5|fc_06340275dc54e8ed016ab1d58d029487d1b0eeafcaa9e48c4c","name":"read","arguments":{"i":"Reading timeout implementation details","path":"crates/spt-runtime/src/runtime.rs:960-1012"},"intent":"Reading timeout implementation details"},{"type":"toolCall","id":"call_AMduIm11L8sFeaIefj4PklNO|fc_06340275dc54e8ed016ab1d58d029c87d1b5f66e95b8e63d39","name":"read","arguments":{"i":"Reading private distribution authority","path":"docs/adr/0036-private-fork-distribution.md:1-140"},"intent":"Reading private distribution authority"},{"type":"toolCall","id":"call_Qsjh6lLOHwCKHsRZruS7Omr3|fc_06340275dc54e8ed016ab1d58d02a087d1bef1615e7bc47ba2","name":"read","arguments":{"i":"Reading update set authority","path":"docs/adr/0016-platform-targeted-update-sets.md:1-100"},"intent":"Reading update set authority"},{"type":"toolCall","id":"call_aZqRsQ02A9LE9ZsKyLPzQskX|fc_06340275dc54e8ed016ab1d58d02a887d19c1b7cd2269cd3e7","name":"grep","arguments":{"i":"Finding independent positive probes","pattern":"\"gh\"|gh api|gh release list|auth status|auth token","path":"scripts;crates/xtask;crates/spt-daemon/src;crates/spt-install","case":null,"gitignore":null,"skip":null},"intent":"Finding independent positive probes"},{"type":"toolCall","id":"call_ijq7Vt5RZT1baYDqEzBWLQuZ|fc_06340275dc54e8ed016ab1d58d02ac87d1b1ab4e4175359f97","name":"read","arguments":{"i":"Reading scoped update vocabulary","path":"CONTEXT.md:678-724"},"intent":"Reading scoped update vocabulary"}],"api":"openai-codex-responses","provider":"openai-codex","model":"gpt-6-astra","usage":{"input":7131,"output":267,"cacheRead":40960,"cacheWrite":0,"totalTokens":48358,"cost":{"input":0.07131000000000001,"output":0.01335,"cacheRead":0.040959999999999996,"cacheWrite":0,"total":0.12562}},"stopReason":"toolUse","timestamp":1790039423994,"responseId":"resp_06340275dc54e8ed016ab1d580334887d1b9184d61de301996","providerPayload":{"type":"openaiResponsesHistory","provider":"openai-codex","dt":true,"items":[{"id":"fc_06340275dc54e8ed016ab1d58d028487d190ac3d4830a847aa","type":"function_call","status":"completed","arguments":"{\"i\":\"Reading gate rendering tests\",\"path\":\"crates/spt/src/cli.rs:35300-35375,26525-26665,9620-9695\"}","call_id":"call_9fm3KPIIbMEEVPwDqioV8bWR","internal_chat_message_metadata_passthrough":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"},"name":"read","metadata":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"}},{"id":"fc_06340275dc54e8ed016ab1d58d029487d1b0eeafcaa9e48c4c","type":"function_call","status":"completed","arguments":"{\"i\":\"Reading timeout implementation details\",\"path\":\"crates/spt-runtime/src/runtime.rs:960-1012\"}","call_id":"call_VYcpbrsWr8yFcrLGxsvFLmv5","internal_chat_message_metadata_passthrough":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"},"name":"read","metadata":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"}},{"id":"fc_06340275dc54e8ed016ab1d58d029c87d1b5f66e95b8e63d39","type":"function_call","status":"completed","arguments":"{\"i\":\"Reading private distribution authority\",\"path\":\"docs/adr/0036-private-fork-distribution.md:1-140\"}","call_id":"call_AMduIm11L8sFeaIefj4PklNO","internal_chat_message_metadata_passthrough":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"},"name":"read","metadata":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"}},{"id":"fc_06340275dc54e8ed016ab1d58d02a087d1bef1615e7bc47ba2","type":"function_call","status":"completed","arguments":"{\"i\":\"Reading update set authority\",\"path\":\"docs/adr/0016-platform-targeted-update-sets.md:1-100\"}","call_id":"call_Qsjh6lLOHwCKHsRZruS7Omr3","internal_chat_message_metadata_passthrough":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"},"name":"read","metadata":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"}},{"id":"fc_06340275dc54e8ed016ab1d58d02a887d19c1b7cd2269cd3e7","type":"function_call","status":"completed","arguments":"{\"i\":\"Finding independent positive probes\",\"pattern\":\"\\\"gh\\\"|gh api|gh release list|auth status|auth token\",\"path\":\"scripts;crates/xtask;crates/spt-daemon/src;crates/spt-install\",\"case\":null,\"gitignore\":null,\"skip\":null}","call_id":"call_aZqRsQ02A9LE9ZsKyLPzQskX","internal_chat_message_metadata_passthrough":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"},"name":"grep","metadata":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"}},{"id":"fc_06340275dc54e8ed016ab1d58d02ac87d1b1ab4e4175359f97","type":"function_call","status":"completed","arguments":"{\"i\":\"Reading scoped update vocabulary\",\"path\":\"CONTEXT.md:678-724\"}","call_id":"call_ijq7Vt5RZT1baYDqEzBWLQuZ","internal_chat_message_metadata_passthrough":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"},"name":"read","metadata":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"}}]},"duration":13079.143699999899,"ttft":13040.786100000143,"completedAt":1790039437075,"contextSnapshot":{"promptTokens":48091,"nonMessageTokens":10333,"compactionEpoch":0}}}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_aZqRsQ02A9LE9ZsKyLPzQskX|fc_06340275dc54e8ed016ab1d58d02a887d19c1b7cd2269cd3e7","toolName":"grep","startedAt":"2026-09-22T01:10:37.078Z","args":{"path":"scripts;crates/xtask;crates/spt-daemon/src;crates/spt-install"},"intent":"Finding independent positive probes"},"id":"dbdf76d1","parentId":"75dc6fdf","timestamp":"2026-09-22T01:10:37.078Z"}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_ijq7Vt5RZT1baYDqEzBWLQuZ|fc_06340275dc54e8ed016ab1d58d02ac87d1b1ab4e4175359f97","toolName":"read","startedAt":"2026-09-22T01:10:37.078Z","args":{"path":"CONTEXT.md:678-724"},"intent":"Reading scoped update vocabulary"},"id":"1bf39dd0","parentId":"dbdf76d1","timestamp":"2026-09-22T01:10:37.078Z"}
{"type":"message","id":"00c6e9a2","parentId":"1bf39dd0","timestamp":"2026-09-22T01:10:37.090Z","message":{"role":"toolResult","toolCallId":"call_AMduIm11L8sFeaIefj4PklNO|fc_06340275dc54e8ed016ab1d58d029c87d1b5f66e95b8e63d39","toolName":"read","content":[{"type":"text","text":"# 0036 — The Forkening: private fork, gh-carried distribution, node-local docs\n\nDate: 2026-07-14 · Status: accepted (operator-ruled, THE-FORKENING grill) · Supersedes: ADR-0014 (permanent public Pages URL)\n\n## Context\n\nDevelopment moves under a private GitHub org. Every distribution surface\nassumed public reachability: `spt update` fetched unauthenticated\n`github.com/.../releases/download` URLs, the install one-liner and adapter\nbootstrap hardcoded the public Pages URL that ADR-0014 declared permanent,\nand the rendered docs lived on public GitHub Pages. GitHub offers no\naccess-controlled Pages below Enterprise Cloud, so \"private repos + public\nPages\" cannot express \"private docs\" — and agents (the primary doc\nconsumers) need the docs regardless of repo visibility.\n\n## Decision\n\n1. **Repo topology.** Development home becomes `BigscreenVR/spt-bs-core`\n   (private); `SaberMage/spt-core` remains a full passive mirror (receives\n   every merge until a real second contributor appears; the repos may\n   diverge later). Local checkouts flip `origin` to the dev home **before**\n   build work starts; the SaberMage remote is named `mirror`; the merging\n   agent pushes `mirror main --follow-tags` at merge time. Actions are\n   disabled repo-settings-level on both SaberMage repos. The `project_id`\n   change this causes is absorbed once by copy+renaming the tracked\n   project-context dirs (no pin-file indirection — identity follows the\n   real remote, ruled over a committed pin).\n2. **Release channel.** Publishing moves to `BigscreenVR/spt-bs-releases`\n   (private). Counter, signing key, and update-set format continue\n   unchanged — the trust anchor, not the carrier, is the continuity.\n   `SaberMage/spt-releases` is privated after a hard cutover in which the\n   operator hand-updates every fielded node (org membership supplies each\n   node's `gh` auth; no transition release).\n3. **gh CLI is the mandated carrier.** Release discovery and asset download\n   shell out to `gh` (deadline-wrapped subprocess, same pattern as\n   `run_git`): private-repo asset download requires the API asset-id dance\n   that `gh release download` already implements. Missing/unauthed gh fails\n   loud with OS-specific install hints. Bootstrap install becomes\n   `gh release download` + a self-install verb in the binary itself\n   (no hosted install scripts; the downloaded exe keeps the `spt-*` name —\n   Windows installer-detection). Signature verification is unchanged and\n   carrier-independent.\n4. **Docs go node-local.** Public Pages dies. Each release ships a\n   platform-independent docs bundle (the built mdbook: HTML, `llms.txt`,\n   `llms-full.txt`, raw `.md`, `manifest.schema.json`) as a **signed\n   update-set asset**; apply lands it at `$SPT_HOME/docs` so docs always\n   match the installed version. The daemon serves that dir on\n   **`127.0.0.1:5474`** (loopback only, hyper — already in-tree via\n   iroh/reqwest; GET-only, path-sanitized), preserving the published URL\n   surface verbatim. `spt docs url` prints the resolved URL; bare\n   `spt docs` opens the browser. The canonical docs URL is now\n   `http://localhost:5474` — node-local, lapse-proof by construction.\n\n## Consequences\n\n- ADR-0014's permanent-URL promise transfers from a hosted origin to a\n  local one; everything that baked the Pages URL (install one-liner,\n  llms.txt pointers, adapter bootstrap) re-anchors on the local server or\n  the gh channel.\n- A node without `gh` (or without org membership) cannot install or update\n  — accepted: the fleet is org-controlled by definition now.\n- Docs asset failure must never fail a binary update (degrade loud,\n  retry next fetch).\n- Version-matched docs replace always-latest docs — an upgrade for agents,\n  a regression only for reading future release notes, which remain in the\n  release entries on the channel."}],"details":{"totalLines":66,"displayContent":{"text":"# 0036 — The Forkening: private fork, gh-carried distribution, node-local docs\n\nDate: 2026-07-14 · Status: accepted (operator-ruled, THE-FORKENING grill) · Supersedes: ADR-0014 (permanent public Pages URL)\n\n## Context\n\nDevelopment moves under a private GitHub org. Every distribution surface\nassumed public reachability: `spt update` fetched unauthenticated\n`github.com/.../releases/download` URLs, the install one-liner and adapter\nbootstrap hardcoded the public Pages URL that ADR-0014 declared permanent,\nand the rendered docs lived on public GitHub Pages. GitHub offers no\naccess-controlled Pages below Enterprise Cloud, so \"private repos + public\nPages\" cannot express \"private docs\" — and agents (the primary doc\nconsumers) need the docs regardless of repo visibility.\n\n## Decision\n\n1. **Repo topology.** Development home becomes `BigscreenVR/spt-bs-core`\n   (private); `SaberMage/spt-core` remains a full passive mirror (receives\n   every merge until a real second contributor appears; the repos may\n   diverge later). Local checkouts flip `origin` to the dev home **before**\n   build work starts; the SaberMage remote is named `mirror`; the merging\n   agent pushes `mirror main --follow-tags` at merge time. Actions are\n   disabled repo-settings-level on both SaberMage repos. The `project_id`\n   change this causes is absorbed once by copy+renaming the tracked\n   project-context dirs (no pin-file indirection — identity follows the\n   real remote, ruled over a committed pin).\n2. **Release channel.** Publishing moves to `BigscreenVR/spt-bs-releases`\n   (private). Counter, signing key, and update-set format continue\n   unchanged — the trust anchor, not the carrier, is the continuity.\n   `SaberMage/spt-releases` is privated after a hard cutover in which the\n   operator hand-updates every fielded node (org membership supplies each\n   node's `gh` auth; no transition release).\n3. **gh CLI is the mandated carrier.** Release discovery and asset download\n   shell out to `gh` (deadline-wrapped subprocess, same pattern as\n   `run_git`): private-repo asset download requires the API asset-id dance\n   that `gh release download` already implements. Missing/unauthed gh fails\n   loud with OS-specific install hints. Bootstrap install becomes\n   `gh release download` + a self-install verb in the binary itself\n   (no hosted install scripts; the downloaded exe keeps the `spt-*` name —\n   Windows installer-detection). Signature verification is unchanged and\n   carrier-independent.\n4. **Docs go node-local.** Public Pages dies. Each release ships a\n   platform-independent docs bundle (the built mdbook: HTML, `llms.txt`,\n   `llms-full.txt`, raw `.md`, `manifest.schema.json`) as a **signed\n   update-set asset**; apply lands it at `$SPT_HOME/docs` so docs always\n   match the installed version. The daemon serves that dir on\n   **`127.0.0.1:5474`** (loopback only, hyper — already in-tree via\n   iroh/reqwest; GET-only, path-sanitized), preserving the published URL\n   surface verbatim. `spt docs url` prints the resolved URL; bare\n   `spt docs` opens the browser. The canonical docs URL is now\n   `http://localhost:5474` — node-local, lapse-proof by construction.\n\n## Consequences\n\n- ADR-0014's permanent-URL promise transfers from a hosted origin to a\n  local one; everything that baked the Pages URL (install one-liner,\n  llms.txt pointers, adapter bootstrap) re-anchors on the local server or\n  the gh channel.\n- A node without `gh` (or without org membership) cannot install or update\n  — accepted: the fleet is org-controlled by definition now.\n- Docs asset failure must never fail a binary update (degrade loud,\n  retry next fetch).\n- Version-matched docs replace always-latest docs — an upgrade for agents,\n  a regression only for reading future release notes, which remain in the\n  release entries on the channel.","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]},"fileSize":3852,"meta":{"source":{"type":"path","value":"C:\\Users\\decid\\Documents\\projects\\spt-core\\docs\\adr\\0036-private-fork-distribution.md"}}},"isError":false,"timestamp":1790039437089}}
{"type":"message","id":"915f4e86","parentId":"00c6e9a2","timestamp":"2026-09-22T01:10:37.091Z","message":{"role":"toolResult","toolCallId":"call_Qsjh6lLOHwCKHsRZruS7Omr3|fc_06340275dc54e8ed016ab1d58d02a087d1bef1615e7bc47ba2","toolName":"read","content":[{"type":"text","text":"# Platform-targeted update sets for debug rollout and release distribution\n\n<!-- [doc->REQ-UPD-6] -->\n\n## Status\n\naccepted (2026-06-06)\n\nspt-core's peer-propagated update substrate should understand a signed update as a platform-targeted artifact set, not only as one opaque binary blob. A node offering an update may stage metadata for multiple platform artifacts (for example Windows x86_64 and Linux x86_64); each recipient verifies the set metadata, fetches only the artifact matching its own platform, verifies that artifact's digest, and then follows the existing consent/apply path.\n\nAn update set has one signature over the whole set metadata, with per-platform artifact digests inside that signed metadata. This makes a debug version or stable version an atomic, auditable set while still letting each recipient fetch only its matching artifact.\n\nIf a recipient's platform is missing from an offered set, the result is a typed rejection such as `NoArtifactForPlatform`, not an up-to-date/no-op answer. A malformed or incomplete debug rollout should be visible in convergence reporting.\n\nThe receiver may support both the original single-artifact `SignedRelease` shape and the new platform-targeted set shape during migration. Debug rollout can be the first user of update sets; stable release publishing can move later without blocking the debugging workflow.\n\nPlatform keys in the signed metadata use Rust target triples, for example `x86_64-pc-windows-msvc` and `x86_64-unknown-linux-gnu`. Release asset names remain a packaging concern and may keep their public-friendly names; target triples are the protocol identifiers.\n\nThis keeps debug rollout fast without turning it into raw peer file-copy: a fast coordinator can assemble and sign every platform artifact, then let the existing subnet update machinery distribute the right bytes to each node. The generic receiver/protocol support belongs in the production daemon because public releases and debug rollouts have the same cross-platform distribution problem. The rollout driver does not: build recipes, cross-build/WSL/runner coordination, dev-key signing, and \"stage this debug rollout\" live in source-repo maintainer tooling (for example `xtask`), not in the public `spt` CLI/help/docs for end users.\n\nRelease monotonicity is scoped to the pinned channel. Stable releases and debug rollouts use separate counters, so a fast debug sequence cannot consume, block, or roll back the stable release counter; rollback protection compares only against the latest accepted version for the recipient's pinned channel.\n\nDebug rollout targets are opt-in by channel pin, not by subnet membership alone. A debug offer may traverse trusted subnet peers, but a stable-pinned node rejects it at the metadata gate; only nodes pinned to the debug channel and provisioned with the dev signing key can stage and apply it.\n\nPinning a lab node to debug uses the existing release-key overlay: `$SPT_HOME/identity/release-keys.json` adds the dev public key and sets `\"channel\": \"debug\"` (for example `\"keys\": {\"dev-debug-2026\": \"<ed25519-public-key-hex>\"}`). Removing the key or pinning the channel back to `\"stable\"` takes the node out of debug rollout eligibility.\n\nChannel pinning is exclusive. A debug-pinned node accepts only debug-channel offers and rejects stable offers until re-pinned to stable; a stable-pinned node accepts only stable-channel offers. This keeps rollback accounting and test provenance unambiguous.\n\nMaintainer tooling may provide a helper to write that file correctly, but the canonical mechanism remains the generic release-key overlay. `spt` itself does not need a special debug-mode command; source-repo tooling can offer convenience such as `xtask debug-pin` for local lab setup.\n\nBad debug builds recover the same way as stable releases: publish a newer signed debug rollout, even if the artifact bytes are a previous known-good binary. Numeric rollback remains rejected; the debug channel never gets a bypass around the anti-rollback invariant.\n\nDebug signing key custody is deliberately weaker than stable release signing. The stable release seed stays manual/password-manager driven per ADR-0015; the debug seed may live in a long-lived `SPT_DEBUG_RELEASE_SEED` environment variable on the fast coordinator so agents can drive rollouts. This is acceptable only because the key's blast radius is limited to nodes explicitly pinned to `debug` and provisioned with that dev public key; rotation is minting a new debug key, updating lab overlays, and deleting the old env var/key.\n\n`xtask debug-rollout` must not auto-mint a signing key. If `SPT_DEBUG_RELEASE_SEED` is missing it fails with setup guidance; a separate `xtask debug-keygen` may print a seed/public-key pair for deliberate lab setup.\n\nInitial debug rollout scope is the spt-core binary only. Adapter updates remain on the adapter ripple-update seam with adapter-owned signing or delegated verification; maintainer tooling may compose adapter updates later, but the fast core-debug loop should not blur those trust boundaries.\n\nDebug rollout may carry broker-touching updates. Brain-only debug applies keep the normal no-endpoint-termination invariant. If the candidate class requires replacing the broker, apply requires the node to quiesce first: no broker-held endpoints or live resources may be running, and tooling must refuse with a typed \"shut down/suspend hosted endpoints first\" result rather than cycling them implicitly.\n\nThe first broker-touching debug flow refuses only; it does not auto-suspend or auto-shutdown endpoints. The refusal should list the blocking broker-held endpoints/resources and print the commands needed to quiesce them, preserving lifecycle control with the operator.\n\nInitiation is local staging plus normal pull-based peer propagation. The debug driver writes the signed update set into the local daemon's staged-release cache and may poke the daemon to advertise/check immediately for responsiveness, but it must not introduce a bespoke \"push this binary to node X\" path.\n\nThe debug driver should verify convergence after staging. By default, \"expected\" means every reachable node in the selected lab subnet that is pinned to `debug`; maintainer tooling may accept an explicit node list for targeted tests. It watches until every expected debug-pinned reachable node reports the target debug version applied, or returns a timeout table with per-node state such as not pinned, offline, staged awaiting consent, blocked by live broker resources, applied, or rejected.\n\nThe debug channel sequence is maintainer-tooling state, not project truth or node identity. It may live in an ignored source-repo local file such as under `target/`; losing it is recoverable by choosing a higher debug version. Deployed nodes only require that the next accepted debug metadata version is greater than the current debug version they have seen.\n\nDebug rollout metadata should carry signed diagnostic provenance: source git commit, dirty flag, build host, build timestamp, and platform target. These fields are for observability (\"which bits are running where?\"), not for trust; verification remains signature plus artifact digest plus channel/version policy.\n\nThe first implementation slice is intentionally narrow: update-set metadata and verification, daemon staging/read path with local platform selection, maintainer `xtask` helpers for debug pinning and debug rollout, and loopback/two-host proof of propagation plus convergence reporting. Remote build orchestration, rich dashboards, adapter inclusion, and stable-release publishing migration are later work.\n\n## Considered Options\n\n- **Single artifact per staged update** — simpler metadata, but forces dev tooling to stage different binaries through platform-specific side paths and makes debug rollout brittle.\n- **Debug-only remote copy/install scripts** — fastest to hack, but bypasses the verification, rollback, channel, and consent gates that peer propagation exists to protect.\n- **Platform-targeted update set** — chosen: one generic substrate shape, recipients fetch only their matching artifact, and both stable releases and debug rollouts reuse the same trust path."}],"details":{"totalLines":57,"displayContent":{"text":"# Platform-targeted update sets for debug rollout and release distribution\r\n\r\n<!-- [doc->REQ-UPD-6] -->\r\n\r\n## Status\r\n\r\naccepted (2026-06-06)\r\n\r\nspt-core's peer-propagated update substrate should understand a signed update as a platform-targeted artifact set, not only as one opaque binary blob. A node offering an update may stage metadata for multiple platform artifacts (for example Windows x86_64 and Linux x86_64); each recipient verifies the set metadata, fetches only the artifact matching its own platform, verifies that artifact's digest, and then follows the existing consent/apply path.\r\n\r\nAn update set has one signature over the whole set metadata, with per-platform artifact digests inside that signed metadata. This makes a debug version or stable version an atomic, auditable set while still letting each recipient fetch only its matching artifact.\r\n\r\nIf a recipient's platform is missing from an offered set, the result is a typed rejection such as `NoArtifactForPlatform`, not an up-to-date/no-op answer. A malformed or incomplete debug rollout should be visible in convergence reporting.\r\n\r\nThe receiver may support both the original single-artifact `SignedRelease` shape and the new platform-targeted set shape during migration. Debug rollout can be the first user of update sets; stable release publishing can move later without blocking the debugging workflow.\r\n\r\nPlatform keys in the signed metadata use Rust target triples, for example `x86_64-pc-windows-msvc` and `x86_64-unknown-linux-gnu`. Release asset names remain a packaging concern and may keep their public-friendly names; target triples are the protocol identifiers.\r\n\r\nThis keeps debug rollout fast without turning it into raw peer file-copy: a fast coordinator can assemble and sign every platform artifact, then let the existing subnet update machinery distribute the right bytes to each node. The generic receiver/protocol support belongs in the production daemon because public releases and debug rollouts have the same cross-platform distribution problem. The rollout driver does not: build recipes, cross-build/WSL/runner coordination, dev-key signing, and \"stage this debug rollout\" live in source-repo maintainer tooling (for example `xtask`), not in the public `spt` CLI/help/docs for end users.\r\n\r\nRelease monotonicity is scoped to the pinned channel. Stable releases and debug rollouts use separate counters, so a fast debug sequence cannot consume, block, or roll back the stable release counter; rollback protection compares only against the latest accepted version for the recipient's pinned channel.\r\n\r\nDebug rollout targets are opt-in by channel pin, not by subnet membership alone. A debug offer may traverse trusted subnet peers, but a stable-pinned node rejects it at the metadata gate; only nodes pinned to the debug channel and provisioned with the dev signing key can stage and apply it.\r\n\r\nPinning a lab node to debug uses the existing release-key overlay: `$SPT_HOME/identity/release-keys.json` adds the dev public key and sets `\"channel\": \"debug\"` (for example `\"keys\": {\"dev-debug-2026\": \"<ed25519-public-key-hex>\"}`). Removing the key or pinning the channel back to `\"stable\"` takes the node out of debug rollout eligibility.\r\n\r\nChannel pinning is exclusive. A debug-pinned node accepts only debug-channel offers and rejects stable offers until re-pinned to stable; a stable-pinned node accepts only stable-channel offers. This keeps rollback accounting and test provenance unambiguous.\r\n\r\nMaintainer tooling may provide a helper to write that file correctly, but the canonical mechanism remains the generic release-key overlay. `spt` itself does not need a special debug-mode command; source-repo tooling can offer convenience such as `xtask debug-pin` for local lab setup.\r\n\r\nBad debug builds recover the same way as stable releases: publish a newer signed debug rollout, even if the artifact bytes are a previous known-good binary. Numeric rollback remains rejected; the debug channel never gets a bypass around the anti-rollback invariant.\r\n\r\nDebug signing key custody is deliberately weaker than stable release signing. The stable release seed stays manual/password-manager driven per ADR-0015; the debug seed may live in a long-lived `SPT_DEBUG_RELEASE_SEED` environment variable on the fast coordinator so agents can drive rollouts. This is acceptable only because the key's blast radius is limited to nodes explicitly pinned to `debug` and provisioned with that dev public key; rotation is minting a new debug key, updating lab overlays, and deleting the old env var/key.\r\n\r\n`xtask debug-rollout` must not auto-mint a signing key. If `SPT_DEBUG_RELEASE_SEED` is missing it fails with setup guidance; a separate `xtask debug-keygen` may print a seed/public-key pair for deliberate lab setup.\r\n\r\nInitial debug rollout scope is the spt-core binary only. Adapter updates remain on the adapter ripple-update seam with adapter-owned signing or delegated verification; maintainer tooling may compose adapter updates later, but the fast core-debug loop should not blur those trust boundaries.\r\n\r\nDebug rollout may carry broker-touching updates. Brain-only debug applies keep the normal no-endpoint-termination invariant. If the candidate class requires replacing the broker, apply requires the node to quiesce first: no broker-held endpoints or live resources may be running, and tooling must refuse with a typed \"shut down/suspend hosted endpoints first\" result rather than cycling them implicitly.\r\n\r\nThe first broker-touching debug flow refuses only; it does not auto-suspend or auto-shutdown endpoints. The refusal should list the blocking broker-held endpoints/resources and print the commands needed to quiesce them, preserving lifecycle control with the operator.\r\n\r\nInitiation is local staging plus normal pull-based peer propagation. The debug driver writes the signed update set into the local daemon's staged-release cache and may poke the daemon to advertise/check immediately for responsiveness, but it must not introduce a bespoke \"push this binary to node X\" path.\r\n\r\nThe debug driver should verify convergence after staging. By default, \"expected\" means every reachable node in the selected lab subnet that is pinned to `debug`; maintainer tooling may accept an explicit node list for targeted tests. It watches until every expected debug-pinned reachable node reports the target debug version applied, or returns a timeout table with per-node state such as not pinned, offline, staged awaiting consent, blocked by live broker resources, applied, or rejected.\r\n\r\nThe debug channel sequence is maintainer-tooling state, not project truth or node identity. It may live in an ignored source-repo local file such as under `target/`; losing it is recoverable by choosing a higher debug version. Deployed nodes only require that the next accepted debug metadata version is greater than the current debug version they have seen.\r\n\r\nDebug rollout metadata should carry signed diagnostic provenance: source git commit, dirty flag, build host, build timestamp, and platform target. These fields are for observability (\"which bits are running where?\"), not for trust; verification remains signature plus artifact digest plus channel/version policy.\r\n\r\nThe first implementation slice is intentionally narrow: update-set metadata and verification, daemon staging/read path with local platform selection, maintainer `xtask` helpers for debug pinning and debug rollout, and loopback/two-host proof of propagation plus convergence reporting. Remote build orchestration, rich dashboards, adapter inclusion, and stable-release publishing migration are later work.\r\n\r\n## Considered Options\r\n\r\n- **Single artifact per staged update** — simpler metadata, but forces dev tooling to stage different binaries through platform-specific side paths and makes debug rollout brittle.\r\n- **Debug-only remote copy/install scripts** — fastest to hack, but bypasses the verification, rollback, channel, and consent gates that peer propagation exists to protect.\r\n- **Platform-targeted update set** — chosen: one generic substrate shape, recipients fetch only their matching artifact, and both stable releases and debug rollouts reuse the same trust path.\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]},"fileSize":8257,"meta":{"source":{"type":"path","value":"C:\\Users\\decid\\Documents\\projects\\spt-core\\docs\\adr\\0016-platform-targeted-update-sets.md"}}},"isError":false,"timestamp":1790039437090}}
{"type":"message","id":"badf872d","parentId":"915f4e86","timestamp":"2026-09-22T01:10:37.093Z","message":{"role":"toolResult","toolCallId":"call_VYcpbrsWr8yFcrLGxsvFLmv5|fc_06340275dc54e8ed016ab1d58d029487d1b0eeafcaa9e48c4c","toolName":"read","content":[{"type":"text","text":"/// chatty child never deadlocks the poll. On timeout the child is killed and\n/// reaped. Uses `Instant` (monotonic) for the deadline.\nfn wait_bounded(\n    mut child: Child,\n    timeout: Duration,\n    label: &str,\n) -> Result<BoundedOutput, RuntimeError> {\n    // Drain stdout/stderr on threads to avoid a full-pipe deadlock while we poll.\n    let mut out_pipe = child.stdout.take();\n    let mut err_pipe = child.stderr.take();\n    let out_handle = std::thread::spawn(move || {\n        let mut buf = String::new();\n        if let Some(p) = out_pipe.as_mut() {\n            let _ = p.read_to_string(&mut buf);\n        }\n        buf\n    });\n    let err_handle = std::thread::spawn(move || {\n        let mut buf = String::new();\n        if let Some(p) = err_pipe.as_mut() {\n            let _ = p.read_to_string(&mut buf);\n        }\n        buf\n    });\n\n    let start = Instant::now();\n    let status = loop {\n        match child.try_wait() {\n            Ok(Some(status)) => break status,\n            Ok(None) => {\n                if start.elapsed() >= timeout {\n                    // [impl->REQ-HAZARD-SUBPROCESS-TIMEOUT] hung child → kill + reap,\n                    // return Timeout; never block the supervisor indefinitely.\n                    let _ = child.kill();\n                    let _ = child.wait();\n                    return Err(RuntimeError::Timeout {\n                        command: label.to_string(),\n                        after: timeout,\n                    });\n                }\n                std::thread::sleep(Duration::from_millis(20));\n            }\n            Err(e) => return Err(RuntimeError::Spawn(e)),\n        }\n    };\n\n    let stdout = out_handle.join().unwrap_or_default();\n    let stderr = err_handle.join().unwrap_or_default();\n    Ok(BoundedOutput {\n        status_code: status.code(),\n        stdout,\n        stderr,\n    })\n}\n\n/// Run an **arbitrary bounded command template** (not a named `[session.*]`\n/// role) to completion under `timeout`, optionally feeding `stdin`. Shares the\n\n[Showing lines 959-1015 of 1789. Use :1016 to continue]"}],"details":{"truncation":{"content":"/// chatty child never deadlocks the poll. On timeout the child is killed and\r\n/// reaped. Uses `Instant` (monotonic) for the deadline.\r\nfn wait_bounded(\r\n    mut child: Child,\r\n    timeout: Duration,\r\n    label: &str,\r\n) -> Result<BoundedOutput, RuntimeError> {\r\n    // Drain stdout/stderr on threads to avoid a full-pipe deadlock while we poll.\r\n    let mut out_pipe = child.stdout.take();\r\n    let mut err_pipe = child.stderr.take();\r\n    let out_handle = std::thread::spawn(move || {\r\n        let mut buf = String::new();\r\n        if let Some(p) = out_pipe.as_mut() {\r\n            let _ = p.read_to_string(&mut buf);\r\n        }\r\n        buf\r\n    });\r\n    let err_handle = std::thread::spawn(move || {\r\n        let mut buf = String::new();\r\n        if let Some(p) = err_pipe.as_mut() {\r\n            let _ = p.read_to_string(&mut buf);\r\n        }\r\n        buf\r\n    });\r\n\r\n    let start = Instant::now();\r\n    let status = loop {\r\n        match child.try_wait() {\r\n            Ok(Some(status)) => break status,\r\n            Ok(None) => {\r\n                if start.elapsed() >= timeout {\r\n                    // [impl->REQ-HAZARD-SUBPROCESS-TIMEOUT] hung child → kill + reap,\r\n                    // return Timeout; never block the supervisor indefinitely.\r\n                    let _ = child.kill();\r\n                    let _ = child.wait();\r\n                    return Err(RuntimeError::Timeout {\r\n                        command: label.to_string(),\r\n                        after: timeout,\r\n                    });\r\n                }\r\n                std::thread::sleep(Duration::from_millis(20));\r\n            }\r\n            Err(e) => return Err(RuntimeError::Spawn(e)),\r\n        }\r\n    };\r\n\r\n    let stdout = out_handle.join().unwrap_or_default();\r\n    let stderr = err_handle.join().unwrap_or_default();\r\n    Ok(BoundedOutput {\r\n        status_code: status.code(),\r\n        stdout,\r\n        stderr,\r\n    })\r\n}\r\n\r\n/// Run an **arbitrary bounded command template** (not a named `[session.*]`\r\n/// role) to completion under `timeout`, optionally feeding `stdin`. Shares the\r","truncated":true,"truncatedBy":"lines","totalLines":831,"totalBytes":2079,"outputLines":57,"outputBytes":2079,"lastLinePartial":false,"firstLineExceedsLimit":false},"totalLines":1789,"displayContent":{"text":"/// chatty child never deadlocks the poll. On timeout the child is killed and\r\n/// reaped. Uses `Instant` (monotonic) for the deadline.\r\nfn wait_bounded(\r\n    mut child: Child,\r\n    timeout: Duration,\r\n    label: &str,\r\n) -> Result<BoundedOutput, RuntimeError> {\r\n    // Drain stdout/stderr on threads to avoid a full-pipe deadlock while we poll.\r\n    let mut out_pipe = child.stdout.take();\r\n    let mut err_pipe = child.stderr.take();\r\n    let out_handle = std::thread::spawn(move || {\r\n        let mut buf = String::new();\r\n        if let Some(p) = out_pipe.as_mut() {\r\n            let _ = p.read_to_string(&mut buf);\r\n        }\r\n        buf\r\n    });\r\n    let err_handle = std::thread::spawn(move || {\r\n        let mut buf = String::new();\r\n        if let Some(p) = err_pipe.as_mut() {\r\n            let _ = p.read_to_string(&mut buf);\r\n        }\r\n        buf\r\n    });\r\n\r\n    let start = Instant::now();\r\n    let status = loop {\r\n        match child.try_wait() {\r\n            Ok(Some(status)) => break status,\r\n            Ok(None) => {\r\n                if start.elapsed() >= timeout {\r\n                    // [impl->REQ-HAZARD-SUBPROCESS-TIMEOUT] hung child → kill + reap,\r\n                    // return Timeout; never block the supervisor indefinitely.\r\n                    let _ = child.kill();\r\n                    let _ = child.wait();\r\n                    return Err(RuntimeError::Timeout {\r\n                        command: label.to_string(),\r\n                        after: timeout,\r\n                    });\r\n                }\r\n                std::thread::sleep(Duration::from_millis(20));\r\n            }\r\n            Err(e) => return Err(RuntimeError::Spawn(e)),\r\n        }\r\n    };\r\n\r\n    let stdout = out_handle.join().unwrap_or_default();\r\n    let stderr = err_handle.join().unwrap_or_default();\r\n    Ok(BoundedOutput {\r\n        status_code: status.code(),\r\n        stdout,\r\n        stderr,\r\n    })\r\n}\r\n\r\n/// Run an **arbitrary bounded command template** (not a named `[session.*]`\r\n/// role) to completion under `timeout`, optionally feeding `stdin`. Shares the\r","startLine":959,"lineNumbers":[959,960,961,962,963,964,965,966,967,968,969,970,971,972,973,974,975,976,977,978,979,980,981,982,983,984,985,986,987,988,989,990,991,992,993,994,995,996,997,998,999,1000,1001,1002,1003,1004,1005,1006,1007,1008,1009,1010,1011,1012,1013,1014,1015]},"fileSize":84125,"meta":{"source":{"type":"path","value":"C:\\Users\\decid\\Documents\\projects\\spt-core\\crates\\spt-runtime\\src\\runtime.rs"},"truncation":{"direction":"head","truncatedBy":"lines","totalLines":1789,"totalBytes":2079,"outputLines":57,"outputBytes":2079,"shownRange":{"start":959,"end":1015},"nextOffset":1016}}},"isError":false,"timestamp":1790039437093}}
{"type":"message","id":"6d6de723","parentId":"badf872d","timestamp":"2026-09-22T01:10:37.108Z","message":{"role":"toolResult","toolCallId":"call_9fm3KPIIbMEEVPwDqioV8bWR|fc_06340275dc54e8ed016ab1d58d028487d190ac3d4830a847aa","toolName":"read","content":[{"type":"text","text":"/// becomes adapters-only), then `update adapters`. The invoker survives the\n/// core leg by construction — apply cycles the BRAIN only, the broker and every\n/// PTY keep running (applyhost::apply_staged) — so no re-run machinery exists.\n/// With `--restart` (REQ-UPDATE-RESTART-SAFE-SWAP) the plan is fetch → adapters\n/// → `apply --finish` LAST: the one-step safe full-cycle swap.\n///\n/// Failure policy: a CORE-leg failure (fetch/apply refused, gh missing…)\n/// ABORTS the remaining legs — updating adapters against a channel that just\n/// refused is compounding, and a `--restart` finish after a failed fetch would\n/// bounce the daemon for nothing. An ADAPTERS-leg failure is isolated (that is\n/// its contract): loud, nonzero at exit, but the composite proceeds — under\n/// `--restart` the finish still completes the core cycle. The accepted\n/// asymmetry (new adapters briefly on old-activated core if the finish itself\n/// fails) is loud and operator-attended by nature of the flag.\n///\n/// Aggregation policy (releases#216): the composite's exit is the WORST leg\n/// outcome by PRECEDENCE — success < refusal (`3`) < failure (any other\n/// nonzero) — folded through [`fold_composite_exit`], never the last nonzero\n/// written. This doc stated the failure policy and said nothing about\n/// aggregation, which is how a refusal masking a failure stayed invisible to a\n/// source reader for as long as it did.\n// [impl->REQ-UPDATE-DEFAULT-COMPOSITE]\n// [impl->REQ-UPDATE-RESTART-SAFE-SWAP]\nfn cmd_update_composite(core_only: bool, restart: bool) -> i32 {\n    let mut worst = 0;\n    for leg in plan_update_legs(core_only, restart) {\n        let code = match leg {\n            UpdateLeg::FetchApply => cmd_update_fetch(None, None, true),\n            UpdateLeg::Fetch => cmd_update_fetch(None, None, false),\n            // THE COMPOSITE LEG — the only composite entry, and it covers BOTH plans\n            // (`plan_update_legs` pushes `Adapters` for the `--restart` and the plain\n            // form alike). This is the one caller that must NOT ask about the running\n            // core: the new core is staged and the invoking CLI is still the old image,\n            // so the floor question is about the core this run will activate.\n            // [impl->REQ-ADAPTER-FLOOR-VS-STAGED-CORE]\n            UpdateLeg::Adapters => {\n                let basis = floor_basis(staged_release_metadata_json().as_deref());\n                // Degrade LOUDLY: an absent answer is not a zero, and silently comparing\n                // against the running core is how this defect stayed invisible.\n                if let Some(notice) = floor_basis_degrade_notice(&basis) {\n                    eprintln!(\"{notice}\");\n                }\n                let core_version = floor_basis_version(&basis, env!(\"CARGO_PKG_VERSION\"));\n                cmd_adapter_update(&spt_store::perch::adapters_dir(), None, &core_version)\n            }\n            UpdateLeg::Finish => cmd_update_apply(true),\n        };\n        if code != 0 {\n            if composite_abort_on_failure(leg) {\n                eprintln!(\n                    \"UPDATE_COMPOSITE_ABORTED: the core update failed — the remaining \\\n                     leg(s) were not run\"\n                );\n                return code;\n            }\n            worst = fold_composite_exit(worst, code);\n        }\n    }\n    worst\n}\n\n/// The composite exit CLASS of one leg's code — the fold's whole vocabulary\n/// (releases#216).\n///\n/// Classed BY VALUE, never by which constant minted it: the tree holds TWO\n/// distinct `3`s ([`EXIT_NOT_ELEVATED`] and [`EXIT_REFUSED_NO_WORK`]), and a\n/// fold that cared which one it saw would grow a second vocabulary to drift\n/// from. An UNKNOWN nonzero is a FAILURE, not a refusal — mapping an unminted\n/// code to the polite class would re-create this defect for a code nobody has\n/// designed yet, and it puts clap's usage `2` in the failure class, which is\n/// what makes the fold total.\n// [impl->REQ-UPDATE-COMPOSITE-EXIT-PRECEDENCE]\nfn composite_exit_rank(code: i32) -> u8 {\n    match code {\n        0 => 0,\n        EXIT_REFUSED_NO_WORK => 1, // == EXIT_NOT_ELEVATED; the VALUE is the class\n…\n    }\n}\n…\n    fn daemon_refresh_verb_parses() {\n        assert!(matches!(\n…\n            Some(Cmd::Node {\n                action: Some(DaemonCmd::Refresh)\n            })\n        ));\n    }\n\n    // ── REQ-ADAPTER-GH-TRANSPORT (v0.13.2 W2): gh transport for private adapter\n    // release repos. Covers the DECISION (resolve_transport) and the COMMAND\n    // SHAPE (the gh command builders) only — the real `gh`/network path is HITL\n    // (int), explicitly unit-covered-not-int per doyle.\n\n    // [unit->REQ-ADAPTER-GH-TRANSPORT] An EXPLICIT transport is honored verbatim\n    // and NEVER consults the probe: a `gh_available` closure that panics if\n    // called proves `Https`→Https and `Gh`→Gh short-circuit before any probe.\n    #[test]\n    fn resolve_transport_explicit_never_probes() {\n        use spt_runtime::manifest::Transport;\n        assert_eq!(\n            resolve_transport(Transport::Https, || unreachable!(\"Https must not probe gh\")),\n            EffectiveTransport::Https,\n            \"explicit Https resolves to Https without probing\",\n        );\n        assert_eq!(\n            resolve_transport(Transport::Gh, || unreachable!(\"Gh must not probe gh\")),\n            EffectiveTransport::Gh,\n            \"explicit Gh resolves to Gh without probing\",\n        );\n    }\n\n    // [unit->REQ-ADAPTER-GH-TRANSPORT] AUTO consults the probe: gh-when-available\n    // (probe→true ⇒ Gh) else HTTPS (probe→false ⇒ Https). This is the default\n    // (`Transport::Auto`) avenue selection.\n    #[test]\n    fn resolve_transport_auto_follows_probe() {\n        use spt_runtime::manifest::Transport;\n        assert_eq!(\n            resolve_transport(Transport::Auto, || true),\n            EffectiveTransport::Gh,\n            \"auto picks gh when the probe reports it available\",\n        );\n        assert_eq!(\n            resolve_transport(Transport::Auto, || false),\n            EffectiveTransport::Https,\n            \"auto falls back to https when gh is unavailable\",\n        );\n    }\n\n    // ── REQ-ADAPTER-UPDATE-MESSAGE (v0.13.2 W4): an adapter's `[update].message`\n    // is surfaced (markdown-rendered) ONLY on an applied update. The notice\n    // renderer is a pure seam; the \"not on a no-op\" guarantee is the call SITE\n    // (only the post-`register` apply arm invokes it — the up-to-date path\n    // `continue`s before reaching it).\n\n    fn manifest_with_message(message: Option<&str>) -> spt_runtime::manifest::Manifest {\n        let mut toml = String::from(\n            \"[adapter]\\n\\\n             name = \\\"x\\\"\\n\\\n             kind = \\\"harness\\\"\\n\\\n             version = \\\"1.0.0\\\"\\n\\\n             min_spt_core_version = \\\"0.1.0\\\"\\n\\\n             hostable_types = [\\\"Worker\\\"]\\n\\\n             \\n\\\n             [update]\\n\\\n             avenue = \\\"gh_release\\\"\\n\\\n             repo = \\\"user/repo\\\"\\n\",\n        );\n        if let Some(m) = message {\n            toml.push_str(&format!(\"message = {m:?}\\n\"));\n        }\n        spt_runtime::manifest::Manifest::from_toml_str(&toml).expect(\"fixture manifest parses\")\n    }\n\n    // [unit->REQ-ADAPTER-UPDATE-MESSAGE] A declared message renders through the\n    // helpfmt markdown path: with color off the `**`/`` ` `` markers are STRIPPED\n    // to bare prose (the multi-line body is preserved), so an applied update\n    // surfaces a clean operator notice.\n    #[test]\n    fn adapter_update_notice_renders_declared_message_markdown() {\n        let m = manifest_with_message(Some(\"**Reload** required: run `/reload-plugins`.\"));\n        let notice = adapter_update_notice(&m, false).expect(\"a declared message yields a notice\");\n        assert_eq!(\n            notice, \"Reload required: run /reload-plugins.\",\n            \"color-off render strips the markdown markers to bare prose\",\n        );\n        assert!(!notice.contains('*'), \"no literal markdown markers leak through\");\n    }\n\n    // [unit->REQ-ADAPTER-UPDATE-MESSAGE] No `[update].message` ⇒ no notice: an\n    // applied update for a message-less adapter prints nothing extra (and the\n    // call site never reaches here on a no-op).\n    #[test]\n    fn adapter_update_notice_absent_when_no_message() {\n        let m = manifest_with_message(None);\n        assert!(\n            adapter_update_notice(&m, false).is_none(),\n            \"a manifest with no [update].message yields no notice\",\n        );\n    }\n\n    // [unit->REQ-ADAPTER-UPDATE-POST] the post-step stdout notice arbitration\n    // (ADR-0029): empty stdout selects nothing; the reserved sentinel selects the\n    // static [update].message; any other non-empty text is a custom dynamic notice\n    // that supersedes the manifest message. Whitespace-trimmed.\n    #[test]\n    fn post_step_notice_arbitration() {\n        assert_eq!(post_step_notice(\"\"), PostNotice::None);\n        assert_eq!(post_step_notice(\"   \\n  \"), PostNotice::None, \"blank → None\");\n        assert_eq!(\n            post_step_notice(UPDATE_POST_MESSAGE_SENTINEL),\n            PostNotice::ManifestMessage,\n            \"the reserved sentinel fires the static manifest message\",\n        );\n        assert_eq!(\n            post_step_notice(&format!(\"  {UPDATE_POST_MESSAGE_SENTINEL}\\n\")),\n            PostNotice::ManifestMessage,\n            \"sentinel recognized after trim\",\n        );\n        assert_eq!(\n            post_step_notice(\"Plugin synced — run /reload-plugins\"),\n            PostNotice::Custom(\"Plugin synced — run /reload-plugins\"),\n            \"custom text supersedes the manifest message\",\n        );\n    }\n\n    // ── REQ-ADAPTER-PROOF-DIR-OVERRIDE (v0.13.2 W5): the `*-proof` `--dir`/\n    // `--manifest` override resolves a DEV manifest+install-dir straight off disk,\n    // bypassing the registry (perri F-011 — a bare-file adapter the registry can't\n    // resolve). The end-to-end proof against a real binary is the int gate; this\n    // unit pins the RESOLUTION contract.\n\n    // [unit->REQ-ADAPTER-PROOF-DIR-OVERRIDE] `--dir` sets the install dir and\n    // defaults the manifest to `<dir>/manifest.toml`; `--manifest` pins the file\n    // (its parent is the install dir absent `--dir`); both → dir wins as install\n    // dir. With NEITHER, an unregistered option falls through to the registry and\n    // errors (the unchanged normal path).\n    #[test]\n    fn resolve_proof_target_override_reads_on_disk() {\n        let home = crate::testutil::isolated_home();\n        let _ = &home;\n        let adapters = spt_store::perch::adapters_dir();\n\n        let devdir = spt_store::perch::spt_home().join(\"devsrc\");\n…\n    }\n…\n\n    // [unit->REQ-SUBNET-1] the bounded probe seam: a fast op returns its value;\n    // an op that overruns the deadline yields the timeout default (so the\n    // --nodes view never hangs on a dead peer's full dial timeout).\n    #[test]\n    fn run_bounded_yields_value_or_timeout_default() {\n        use std::time::Duration;\n        assert_eq!(run_bounded(Duration::from_secs(5), 0, || 42), 42);\n        assert_eq!(\n            run_bounded(Duration::from_millis(50), -1, || {\n                std::thread::sleep(Duration::from_millis(800));\n                7\n            }),\n            -1,\n            \"an op past the deadline reads as the timeout default\"\n        );\n    }\n\n    // [unit->REQ-UPD-7] the origin-source fetch URL derivation (latest vs a\n    // pinned tag, per the release-channel layout) — the HTTPS asset URL now\n    // serves only the adapter avenue's HTTPS arm; the self-update set rides\n    // the gh carrier (REQ-UPDATE-GH-TRANSPORT).\n    #[test]\n    fn origin_asset_urls_latest_and_tag() {\n        assert_eq!(\n            origin_asset_url(\"Owner/repo\", None, \"spt-x86_64-linux\"),\n            \"https://github.com/Owner/repo/releases/latest/download/spt-x86_64-linux\"\n        );\n        assert_eq!(\n            origin_asset_url(\"Owner/repo\", Some(\"v0.3.2\"), \"spt-x86_64-windows.exe\"),\n            \"https://github.com/Owner/repo/releases/download/v0.3.2/spt-x86_64-windows.exe\"\n        );\n    }\n\n    // [unit->REQ-UPDATE-GH-TRANSPORT] the two loud gh failure classes render\n    // OS-correct install hints (missing) and the auth pointer (unauthed), each\n    // under its own machine-greppable token — never a silent hang or a bare\n    // network error for what is really a carrier precondition.\n    #[test]\n    fn gh_failure_classes_render_os_correct_hints() {\n        // Missing: every OS names its package command + the auth follow-up.\n        let win = render_gh_cli_required(\"windows\");\n        assert!(win.contains(\"UPDATE_FETCH_REJECTED:GhCliRequired\"), \"{win}\");\n        assert!(win.contains(\"winget install --id GitHub.cli\"), \"{win}\");\n        assert!(win.contains(\"gh auth login\"), \"{win}\");\n        let mac = render_gh_cli_required(\"macos\");\n        assert!(mac.contains(\"brew install gh\"), \"{mac}\");\n        let linux = render_gh_cli_required(\"linux\");\n        assert!(linux.contains(\"apt install gh\"), \"{linux}\");\n        // Unauthed: a DISTINCT token routing at `gh auth login`, no install hint.\n        let unauthed = render_gh_auth_required();\n        assert!(\n            unauthed.contains(\"UPDATE_FETCH_REJECTED:GhAuthRequired\"),\n            \"{unauthed}\"\n        );\n        assert!(unauthed.contains(\"gh auth login\"), \"{unauthed}\");\n        assert!(!unauthed.contains(\"winget\"), \"{unauthed}\");\n    }\n\n    // [unit->REQ-INSTALL-BOOTSTRAP-VERB] the platform gate: a registered stamp\n    // matching the probed host arch installs; an unregistered/unknown stamp\n    // refuses (the v0.3.2 unstamped posture); a stamp whose arch disagrees with\n    // the probed host refuses; an unprobeable host skips ONLY the agreement leg.\n    #[test]\n    fn install_platform_check_gates_stamp_and_host_arch() {\n        let ok = install_platform_check(\"x86_64-pc-windows-msvc\", Some(\"x86_64\"));\n        assert!(ok.is_ok(), \"{ok:?}\");\n        let unknown = install_platform_check(\"unknown\", Some(\"x86_64\"));\n        assert!(unknown.is_err(), \"an unstamped build must refuse\");\n        let unregistered = install_platform_check(\"riscv64-unknown-none\", None);\n        assert!(unregistered.is_err(), \"an unregistered triple must refuse\");\n        let mismatch = install_platform_check(\"x86_64-unknown-linux-gnu\", Some(\"aarch64\"));\n        assert!(mismatch.is_err(), \"a host-arch disagreement must refuse\");\n        let unprobed = install_platform_check(\"x86_64-unknown-linux-gnu\", None);\n        assert!(unprobed.is_ok(), \"an unprobeable host keeps the registry leg only\");\n    }"}],"details":{"displayContent":{"text":"/// becomes adapters-only), then `update adapters`. The invoker survives the\n/// core leg by construction — apply cycles the BRAIN only, the broker and every\n/// PTY keep running (applyhost::apply_staged) — so no re-run machinery exists.\n/// With `--restart` (REQ-UPDATE-RESTART-SAFE-SWAP) the plan is fetch → adapters\n/// → `apply --finish` LAST: the one-step safe full-cycle swap.\n///\n/// Failure policy: a CORE-leg failure (fetch/apply refused, gh missing…)\n/// ABORTS the remaining legs — updating adapters against a channel that just\n/// refused is compounding, and a `--restart` finish after a failed fetch would\n/// bounce the daemon for nothing. An ADAPTERS-leg failure is isolated (that is\n/// its contract): loud, nonzero at exit, but the composite proceeds — under\n/// `--restart` the finish still completes the core cycle. The accepted\n/// asymmetry (new adapters briefly on old-activated core if the finish itself\n/// fails) is loud and operator-attended by nature of the flag.\n///\n/// Aggregation policy (releases#216): the composite's exit is the WORST leg\n/// outcome by PRECEDENCE — success < refusal (`3`) < failure (any other\n/// nonzero) — folded through [`fold_composite_exit`], never the last nonzero\n/// written. This doc stated the failure policy and said nothing about\n/// aggregation, which is how a refusal masking a failure stayed invisible to a\n/// source reader for as long as it did.\n// [impl->REQ-UPDATE-DEFAULT-COMPOSITE]\n// [impl->REQ-UPDATE-RESTART-SAFE-SWAP]\nfn cmd_update_composite(core_only: bool, restart: bool) -> i32 {\n    let mut worst = 0;\n    for leg in plan_update_legs(core_only, restart) {\n        let code = match leg {\n            UpdateLeg::FetchApply => cmd_update_fetch(None, None, true),\n            UpdateLeg::Fetch => cmd_update_fetch(None, None, false),\n            // THE COMPOSITE LEG — the only composite entry, and it covers BOTH plans\n            // (`plan_update_legs` pushes `Adapters` for the `--restart` and the plain\n            // form alike). This is the one caller that must NOT ask about the running\n            // core: the new core is staged and the invoking CLI is still the old image,\n            // so the floor question is about the core this run will activate.\n            // [impl->REQ-ADAPTER-FLOOR-VS-STAGED-CORE]\n            UpdateLeg::Adapters => {\n                let basis = floor_basis(staged_release_metadata_json().as_deref());\n                // Degrade LOUDLY: an absent answer is not a zero, and silently comparing\n                // against the running core is how this defect stayed invisible.\n                if let Some(notice) = floor_basis_degrade_notice(&basis) {\n                    eprintln!(\"{notice}\");\n                }\n                let core_version = floor_basis_version(&basis, env!(\"CARGO_PKG_VERSION\"));\n                cmd_adapter_update(&spt_store::perch::adapters_dir(), None, &core_version)\n            }\n            UpdateLeg::Finish => cmd_update_apply(true),\n        };\n        if code != 0 {\n            if composite_abort_on_failure(leg) {\n                eprintln!(\n                    \"UPDATE_COMPOSITE_ABORTED: the core update failed — the remaining \\\n                     leg(s) were not run\"\n                );\n                return code;\n            }\n            worst = fold_composite_exit(worst, code);\n        }\n    }\n    worst\n}\n\n/// The composite exit CLASS of one leg's code — the fold's whole vocabulary\n/// (releases#216).\n///\n/// Classed BY VALUE, never by which constant minted it: the tree holds TWO\n/// distinct `3`s ([`EXIT_NOT_ELEVATED`] and [`EXIT_REFUSED_NO_WORK`]), and a\n/// fold that cared which one it saw would grow a second vocabulary to drift\n/// from. An UNKNOWN nonzero is a FAILURE, not a refusal — mapping an unminted\n/// code to the polite class would re-create this defect for a code nobody has\n/// designed yet, and it puts clap's usage `2` in the failure class, which is\n/// what makes the fold total.\n// [impl->REQ-UPDATE-COMPOSITE-EXIT-PRECEDENCE]\nfn composite_exit_rank(code: i32) -> u8 {\n    match code {\n        0 => 0,\n        EXIT_REFUSED_NO_WORK => 1, // == EXIT_NOT_ELEVATED; the VALUE is the class\n…\n    }\n}\n…\n    fn daemon_refresh_verb_parses() {\n        assert!(matches!(\n…\n            Some(Cmd::Node {\n                action: Some(DaemonCmd::Refresh)\n            })\n        ));\n    }\n\n    // ── REQ-ADAPTER-GH-TRANSPORT (v0.13.2 W2): gh transport for private adapter\n    // release repos. Covers the DECISION (resolve_transport) and the COMMAND\n    // SHAPE (the gh command builders) only — the real `gh`/network path is HITL\n    // (int), explicitly unit-covered-not-int per doyle.\n\n    // [unit->REQ-ADAPTER-GH-TRANSPORT] An EXPLICIT transport is honored verbatim\n    // and NEVER consults the probe: a `gh_available` closure that panics if\n    // called proves `Https`→Https and `Gh`→Gh short-circuit before any probe.\n    #[test]\n    fn resolve_transport_explicit_never_probes() {\n        use spt_runtime::manifest::Transport;\n        assert_eq!(\n            resolve_transport(Transport::Https, || unreachable!(\"Https must not probe gh\")),\n            EffectiveTransport::Https,\n            \"explicit Https resolves to Https without probing\",\n        );\n        assert_eq!(\n            resolve_transport(Transport::Gh, || unreachable!(\"Gh must not probe gh\")),\n            EffectiveTransport::Gh,\n            \"explicit Gh resolves to Gh without probing\",\n        );\n    }\n\n    // [unit->REQ-ADAPTER-GH-TRANSPORT] AUTO consults the probe: gh-when-available\n    // (probe→true ⇒ Gh) else HTTPS (probe→false ⇒ Https). This is the default\n    // (`Transport::Auto`) avenue selection.\n    #[test]\n    fn resolve_transport_auto_follows_probe() {\n        use spt_runtime::manifest::Transport;\n        assert_eq!(\n            resolve_transport(Transport::Auto, || true),\n            EffectiveTransport::Gh,\n            \"auto picks gh when the probe reports it available\",\n        );\n        assert_eq!(\n            resolve_transport(Transport::Auto, || false),\n            EffectiveTransport::Https,\n            \"auto falls back to https when gh is unavailable\",\n        );\n    }\n\n    // ── REQ-ADAPTER-UPDATE-MESSAGE (v0.13.2 W4): an adapter's `[update].message`\n    // is surfaced (markdown-rendered) ONLY on an applied update. The notice\n    // renderer is a pure seam; the \"not on a no-op\" guarantee is the call SITE\n    // (only the post-`register` apply arm invokes it — the up-to-date path\n    // `continue`s before reaching it).\n\n    fn manifest_with_message(message: Option<&str>) -> spt_runtime::manifest::Manifest {\n        let mut toml = String::from(\n            \"[adapter]\\n\\\n             name = \\\"x\\\"\\n\\\n             kind = \\\"harness\\\"\\n\\\n             version = \\\"1.0.0\\\"\\n\\\n             min_spt_core_version = \\\"0.1.0\\\"\\n\\\n             hostable_types = [\\\"Worker\\\"]\\n\\\n             \\n\\\n             [update]\\n\\\n             avenue = \\\"gh_release\\\"\\n\\\n             repo = \\\"user/repo\\\"\\n\",\n        );\n        if let Some(m) = message {\n            toml.push_str(&format!(\"message = {m:?}\\n\"));\n        }\n        spt_runtime::manifest::Manifest::from_toml_str(&toml).expect(\"fixture manifest parses\")\n    }\n\n    // [unit->REQ-ADAPTER-UPDATE-MESSAGE] A declared message renders through the\n    // helpfmt markdown path: with color off the `**`/`` ` `` markers are STRIPPED\n    // to bare prose (the multi-line body is preserved), so an applied update\n    // surfaces a clean operator notice.\n    #[test]\n    fn adapter_update_notice_renders_declared_message_markdown() {\n        let m = manifest_with_message(Some(\"**Reload** required: run `/reload-plugins`.\"));\n        let notice = adapter_update_notice(&m, false).expect(\"a declared message yields a notice\");\n        assert_eq!(\n            notice, \"Reload required: run /reload-plugins.\",\n            \"color-off render strips the markdown markers to bare prose\",\n        );\n        assert!(!notice.contains('*'), \"no literal markdown markers leak through\");\n    }\n\n    // [unit->REQ-ADAPTER-UPDATE-MESSAGE] No `[update].message` ⇒ no notice: an\n    // applied update for a message-less adapter prints nothing extra (and the\n    // call site never reaches here on a no-op).\n    #[test]\n    fn adapter_update_notice_absent_when_no_message() {\n        let m = manifest_with_message(None);\n        assert!(\n            adapter_update_notice(&m, false).is_none(),\n            \"a manifest with no [update].message yields no notice\",\n        );\n    }\n\n    // [unit->REQ-ADAPTER-UPDATE-POST] the post-step stdout notice arbitration\n    // (ADR-0029): empty stdout selects nothing; the reserved sentinel selects the\n    // static [update].message; any other non-empty text is a custom dynamic notice\n    // that supersedes the manifest message. Whitespace-trimmed.\n    #[test]\n    fn post_step_notice_arbitration() {\n        assert_eq!(post_step_notice(\"\"), PostNotice::None);\n        assert_eq!(post_step_notice(\"   \\n  \"), PostNotice::None, \"blank → None\");\n        assert_eq!(\n            post_step_notice(UPDATE_POST_MESSAGE_SENTINEL),\n            PostNotice::ManifestMessage,\n            \"the reserved sentinel fires the static manifest message\",\n        );\n        assert_eq!(\n            post_step_notice(&format!(\"  {UPDATE_POST_MESSAGE_SENTINEL}\\n\")),\n            PostNotice::ManifestMessage,\n            \"sentinel recognized after trim\",\n        );\n        assert_eq!(\n            post_step_notice(\"Plugin synced — run /reload-plugins\"),\n            PostNotice::Custom(\"Plugin synced — run /reload-plugins\"),\n            \"custom text supersedes the manifest message\",\n        );\n    }\n\n    // ── REQ-ADAPTER-PROOF-DIR-OVERRIDE (v0.13.2 W5): the `*-proof` `--dir`/\n    // `--manifest` override resolves a DEV manifest+install-dir straight off disk,\n    // bypassing the registry (perri F-011 — a bare-file adapter the registry can't\n    // resolve). The end-to-end proof against a real binary is the int gate; this\n    // unit pins the RESOLUTION contract.\n\n    // [unit->REQ-ADAPTER-PROOF-DIR-OVERRIDE] `--dir` sets the install dir and\n    // defaults the manifest to `<dir>/manifest.toml`; `--manifest` pins the file\n    // (its parent is the install dir absent `--dir`); both → dir wins as install\n    // dir. With NEITHER, an unregistered option falls through to the registry and\n    // errors (the unchanged normal path).\n    #[test]\n    fn resolve_proof_target_override_reads_on_disk() {\n        let home = crate::testutil::isolated_home();\n        let _ = &home;\n        let adapters = spt_store::perch::adapters_dir();\n\n        let devdir = spt_store::perch::spt_home().join(\"devsrc\");\n…\n    }\n…\n\n    // [unit->REQ-SUBNET-1] the bounded probe seam: a fast op returns its value;\n    // an op that overruns the deadline yields the timeout default (so the\n    // --nodes view never hangs on a dead peer's full dial timeout).\n    #[test]\n    fn run_bounded_yields_value_or_timeout_default() {\n        use std::time::Duration;\n        assert_eq!(run_bounded(Duration::from_secs(5), 0, || 42), 42);\n        assert_eq!(\n            run_bounded(Duration::from_millis(50), -1, || {\n                std::thread::sleep(Duration::from_millis(800));\n                7\n            }),\n            -1,\n            \"an op past the deadline reads as the timeout default\"\n        );\n    }\n\n    // [unit->REQ-UPD-7] the origin-source fetch URL derivation (latest vs a\n    // pinned tag, per the release-channel layout) — the HTTPS asset URL now\n    // serves only the adapter avenue's HTTPS arm; the self-update set rides\n    // the gh carrier (REQ-UPDATE-GH-TRANSPORT).\n    #[test]\n    fn origin_asset_urls_latest_and_tag() {\n        assert_eq!(\n            origin_asset_url(\"Owner/repo\", None, \"spt-x86_64-linux\"),\n            \"https://github.com/Owner/repo/releases/latest/download/spt-x86_64-linux\"\n        );\n        assert_eq!(\n            origin_asset_url(\"Owner/repo\", Some(\"v0.3.2\"), \"spt-x86_64-windows.exe\"),\n            \"https://github.com/Owner/repo/releases/download/v0.3.2/spt-x86_64-windows.exe\"\n        );\n    }\n\n    // [unit->REQ-UPDATE-GH-TRANSPORT] the two loud gh failure classes render\n    // OS-correct install hints (missing) and the auth pointer (unauthed), each\n    // under its own machine-greppable token — never a silent hang or a bare\n    // network error for what is really a carrier precondition.\n    #[test]\n    fn gh_failure_classes_render_os_correct_hints() {\n        // Missing: every OS names its package command + the auth follow-up.\n        let win = render_gh_cli_required(\"windows\");\n        assert!(win.contains(\"UPDATE_FETCH_REJECTED:GhCliRequired\"), \"{win}\");\n        assert!(win.contains(\"winget install --id GitHub.cli\"), \"{win}\");\n        assert!(win.contains(\"gh auth login\"), \"{win}\");\n        let mac = render_gh_cli_required(\"macos\");\n        assert!(mac.contains(\"brew install gh\"), \"{mac}\");\n        let linux = render_gh_cli_required(\"linux\");\n        assert!(linux.contains(\"apt install gh\"), \"{linux}\");\n        // Unauthed: a DISTINCT token routing at `gh auth login`, no install hint.\n        let unauthed = render_gh_auth_required();\n        assert!(\n            unauthed.contains(\"UPDATE_FETCH_REJECTED:GhAuthRequired\"),\n            \"{unauthed}\"\n        );\n        assert!(unauthed.contains(\"gh auth login\"), \"{unauthed}\");\n        assert!(!unauthed.contains(\"winget\"), \"{unauthed}\");\n    }\n\n    // [unit->REQ-INSTALL-BOOTSTRAP-VERB] the platform gate: a registered stamp\n    // matching the probed host arch installs; an unregistered/unknown stamp\n    // refuses (the v0.3.2 unstamped posture); a stamp whose arch disagrees with\n    // the probed host refuses; an unprobeable host skips ONLY the agreement leg.\n    #[test]\n    fn install_platform_check_gates_stamp_and_host_arch() {\n        let ok = install_platform_check(\"x86_64-pc-windows-msvc\", Some(\"x86_64\"));\n        assert!(ok.is_ok(), \"{ok:?}\");\n        let unknown = install_platform_check(\"unknown\", Some(\"x86_64\"));\n        assert!(unknown.is_err(), \"an unstamped build must refuse\");\n        let unregistered = install_platform_check(\"riscv64-unknown-none\", None);\n        assert!(unregistered.is_err(), \"an unregistered triple must refuse\");\n        let mismatch = install_platform_check(\"x86_64-unknown-linux-gnu\", Some(\"aarch64\"));\n        assert!(mismatch.is_err(), \"a host-arch disagreement must refuse\");\n        let unprobed = install_platform_check(\"x86_64-unknown-linux-gnu\", None);\n        assert!(unprobed.is_ok(), \"an unprobeable host keeps the registry leg only\");\n    }","startLine":9620,"lineNumbers":[9620,9621,9622,9623,9624,9625,9626,9627,9628,9629,9630,9631,9632,9633,9634,9635,9636,9637,9638,9639,9640,9641,9642,9643,9644,9645,9646,9647,9648,9649,9650,9651,9652,9653,9654,9655,9656,9657,9658,9659,9660,9661,9662,9663,9664,9665,9666,9667,9668,9669,9670,9671,9672,9673,9674,9675,9676,9677,9678,9679,9680,9681,9682,9683,9684,9685,9686,9687,9688,9689,9690,9691,9692,9693,9694,9695,null,9697,9698,null,26521,26522,null,26524,26525,26526,26527,26528,26529,26530,26531,26532,26533,26534,26535,26536,26537,26538,26539,26540,26541,26542,26543,26544,26545,26546,26547,26548,26549,26550,26551,26552,26553,26554,26555,26556,26557,26558,26559,26560,26561,26562,26563,26564,26565,26566,26567,26568,26569,26570,26571,26572,26573,26574,26575,26576,26577,26578,26579,26580,26581,26582,26583,26584,26585,26586,26587,26588,26589,26590,26591,26592,26593,26594,26595,26596,26597,26598,26599,26600,26601,26602,26603,26604,26605,26606,26607,26608,26609,26610,26611,26612,26613,26614,26615,26616,26617,26618,26619,26620,26621,26622,26623,26624,26625,26626,26627,26628,26629,26630,26631,26632,26633,26634,26635,26636,26637,26638,26639,26640,26641,26642,26643,26644,26645,26646,26647,26648,26649,26650,26651,26652,26653,26654,26655,26656,26657,26658,26659,26660,26661,26662,26663,26664,26665,null,26708,null,35300,35301,35302,35303,35304,35305,35306,35307,35308,35309,35310,35311,35312,35313,35314,35315,35316,35317,35318,35319,35320,35321,35322,35323,35324,35325,35326,35327,35328,35329,35330,35331,35332,35333,35334,35335,35336,35337,35338,35339,35340,35341,35342,35343,35344,35345,35346,35347,35348,35349,35350,35351,35352,35353,35354,35355,35356,35357,35358,35359,35360,35361,35362,35363,35364,35365,35366,35367,35368,35369,35370,35371,35372,35373,35374,35375]},"fileSize":1828112,"meta":{"source":{"type":"path","value":"C:\\Users\\decid\\Documents\\projects\\spt-core\\crates\\spt\\src\\cli.rs"}}},"isError":false,"timestamp":1790039437108}}
{"type":"message","id":"88eca3a0","parentId":"6d6de723","timestamp":"2026-09-22T01:10:37.220Z","message":{"role":"toolResult","toolCallId":"call_ijq7Vt5RZT1baYDqEzBWLQuZ|fc_06340275dc54e8ed016ab1d58d02ac87d1b1ab4e4175359f97","toolName":"read","content":[{"type":"text","text":"## Self-update\n\n<!-- [doc->REQ-ADAPTER-UPDATE-MESSAGE] -->\nSeamless, 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\\` in any ongoing sessions.\"` for spt-claude-code.\n\n**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-set format are unchanged from the public-channel era. <!-- [doc->REQ-UPDATE-GH-TRANSPORT] -->\n\n**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\n**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 LAN-exposed — a non-loopback bind would re-publish what privating hid. Under WEBSERVE (ADR-0056) the same paths are also reachable under the local node prefix; the bare paths stay as a compatibility alias. <!-- [doc->REQ-DOCS-LOCAL-SERVER] -->\n_Avoid_: public docs hosting; binding beyond loopback; docs drifting from the installed version.\n\n**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 update --restart` is the one-step **full cycle**: fetch → adapters → `apply --finish` last (the finish restarts the whole daemon, so it is the final act). **A REFUSAL IS NOT A FAILURE AND NEITHER IS A SUCCESS** (releases#153): every update surface answers with **`0` …\n\n**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\n**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, hooks — join index × perch roster and **never run git**; stale renders last-known or `-`, never a stall. Precedence (session-cwd → origin-cwd → context-recency) and rendered names are contract. **Since W3 the cutover is COMPLETE**: `endpoint list` (human + `--json`), …\n\n**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 are a no-op by construction**); backgrounding the legacy 100+ process loop is REJECTED. Cold start: daemon ready + CLI fast before the background publish. Warm start: persisted index readable immediately; **unchanged generation performs no scan**. Any cycle failure kee…\n_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-pull seam nudges; the periodic reconcile is a backstop, not a license). <!-- [doc->REQ-PROJECT-INDEX-READER-CUTOVER] -->\n\n**delivery** — peer-propagated over P2P, layered on self-fetch, with out-of-band still supported. One node learns of / obtains an update (marketplace drop *or* self-fetch from a release channel), gossips availability across the subnet, and peers pull the new binary over the networking layer — the subnet self-heals to latest. **All binaries are signature-verified before handoff regardless of source** (peer-propagation otherwise lets a compromised node poison the subnet). spt-core has its own release signing key, distinct from any OS-publisher code-signing (the Windows-publisher-trust question is separate).\n\n**handoff invariant** — **no endpoint process terminates or suspends during a self-update.** We cannot assume every endpoint can safely suspend. Satisfied by the broker/brain split: routine updates replace only the daemon brain, which rehydrates from disk state and re-attaches to the broker's held PTY/socket/child handles. Harness-hosted endpoints (topology 1) are safe by construction; spt-hosted endpoints (topology 2) are kept alive by the broker holding their PTY masters + child processes across the brain restart.\n\n**brain-trial promotion (readiness + drained)** — the broker supervises the swapped-in brain through a bounded readiness **trial** and *promotes* the new binary only when it both signals ready for its own generation **and** the OUTGOING generation's control plane has **drained** — the old brain's local (brain-owned) controller connection is closed or stall-evicted, never still holding blocked writes. A hard-killed prior generation leaves that connection **black-holed** (its hosted PTYs keep producing output the broker's writer blocks on, since a killed peer's pipe blocks rather than EOFs), and the drained signal is a *passive read* of broker truth — nothing in the isolated trial window would otherwise evict it. So **the boot/trial brain itself drives the evi…\n\n**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 of every free/local-controlled session — including the operator's local `spt rc` — and then, because the daemon brain never drains those PTYs, the broker would stall-evict that seized controller after the write deadline, leaving the session uncontrollable *and* h…\n\n**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\n**debug rollout**:\nA deliberately non-production update distribution across a trusted lab subnet, used to test a local build on multiple nodes before a public release. It is still an update, not a raw peer file-copy: every recipient treats it as a signed, channel-scoped candidate that must pass the normal verification and consent gates; lab nodes pinned to the debug channel may explicitly opt into full-auto apply. Trust is node-local: debug rollout keys are installed through each lab node's release-key overlay, never embedded in the production trust anchor. A debug rollout may carry a platform artifact set assembled by one fast coordinator; each recipient applies only the artifact for its own platform. The rollout driver is maintainer/dev tooling that reuses SPT's update subst…\n_Avoid_: production release, peer proliferation, subnet binary copy\n\n**cadence/consent** — **not fully automatic by default; gated on user confirmation.** The update prompt is delivered via spt to the user's **most-recently-active live session** (v1 registry resolution — a precursor to PresenceChannel dispatch, implemented on the v1 registry, not the deferred channel) and offers an \"enable full-auto\" choice. Full-auto is the opt-in seamless path.\n\n## Web serving\n\nRatified 2026-09-06 (WEBSERVE grill round 1, releases#272; ADR-0056..0059). The docs server\ngrows into the node's general serving surface; these are the terms the surface is described in.\n\n**served resource** — anything a node exposes over its serving surface: a registry file, a\nserved root, a docs page, an adapter's docs, a message view, a bootstrap binary. Every served\nresource has exactly one **node-prefixed URL**.\n_Avoid_: hosted file, shared file, upload.\n\n**node-prefixed URL** — the one URL grammar for every served resource, `…/<node-name>/…`,\nidentical on the owning node and on every other subnet machine (the local daemon resolves the\nprefix and proxies to the owner when it is elsewhere). A URL that only works on one machine\nis not a node-prefixed URL. (ADR-0056)\n…\nbare paths are a compatibility alias, not a second grammar).\n…\n\n\n[Showing lines 677-727 of 1268. Use :728 to continue. Some lines truncated to 768 chars]"}],"details":{"truncation":{"content":"## Self-update\r\n\r\n<!-- [doc->REQ-ADAPTER-UPDATE-MESSAGE] -->\r\nSeamless, 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\\` in any ongoing sessions.\"` for spt-claude-code.\r\n\r\n**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-set format are unchanged from the public-channel era. <!-- [doc->REQ-UPDATE-GH-TRANSPORT] -->\r\n\r\n**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] -->\r\n\r\n**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 LAN-exposed — a non-loopback bind would re-publish what privating hid. Under WEBSERVE (ADR-0056) the same paths are also reachable under the local node prefix; the bare paths stay as a compatibility alias. <!-- [doc->REQ-DOCS-LOCAL-SERVER] -->\r\n_Avoid_: public docs hosting; binding beyond loopback; docs drifting from the installed version.\r\n\r\n**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 update --restart` is the one-step **full cycle**: fetch → adapters → `apply --finish` last (the finish restarts the whole daemon, so it is the final act). **A REFUSAL IS NOT A FAILURE AND NEITHER IS A SUCCESS** (releases#153): every update surface answers with **`0` …\n\r\n**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] -->\r\n\r\n**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, hooks — join index × perch roster and **never run git**; stale renders last-known or `-`, never a stall. Precedence (session-cwd → origin-cwd → context-recency) and rendered names are contract. **Since W3 the cutover is COMPLETE**: `endpoint list` (human + `--json`), …\n\r\n**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 are a no-op by construction**); backgrounding the legacy 100+ process loop is REJECTED. Cold start: daemon ready + CLI fast before the background publish. Warm start: persisted index readable immediately; **unchanged generation performs no scan**. Any cycle failure kee…\n_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-pull seam nudges; the periodic reconcile is a backstop, not a license). <!-- [doc->REQ-PROJECT-INDEX-READER-CUTOVER] -->\r\n\r\n**delivery** — peer-propagated over P2P, layered on self-fetch, with out-of-band still supported. One node learns of / obtains an update (marketplace drop *or* self-fetch from a release channel), gossips availability across the subnet, and peers pull the new binary over the networking layer — the subnet self-heals to latest. **All binaries are signature-verified before handoff regardless of source** (peer-propagation otherwise lets a compromised node poison the subnet). spt-core has its own release signing key, distinct from any OS-publisher code-signing (the Windows-publisher-trust question is separate).\r\n\r\n**handoff invariant** — **no endpoint process terminates or suspends during a self-update.** We cannot assume every endpoint can safely suspend. Satisfied by the broker/brain split: routine updates replace only the daemon brain, which rehydrates from disk state and re-attaches to the broker's held PTY/socket/child handles. Harness-hosted endpoints (topology 1) are safe by construction; spt-hosted endpoints (topology 2) are kept alive by the broker holding their PTY masters + child processes across the brain restart.\r\n\r\n**brain-trial promotion (readiness + drained)** — the broker supervises the swapped-in brain through a bounded readiness **trial** and *promotes* the new binary only when it both signals ready for its own generation **and** the OUTGOING generation's control plane has **drained** — the old brain's local (brain-owned) controller connection is closed or stall-evicted, never still holding blocked writes. A hard-killed prior generation leaves that connection **black-holed** (its hosted PTYs keep producing output the broker's writer blocks on, since a killed peer's pipe blocks rather than EOFs), and the drained signal is a *passive read* of broker truth — nothing in the isolated trial window would otherwise evict it. So **the boot/trial brain itself drives the evi…\n\r\n**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 of every free/local-controlled session — including the operator's local `spt rc` — and then, because the daemon brain never drains those PTYs, the broker would stall-evict that seized controller after the write deadline, leaving the session uncontrollable *and* h…\n\r\n**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.\r\n\r\n**debug rollout**:\r\nA deliberately non-production update distribution across a trusted lab subnet, used to test a local build on multiple nodes before a public release. It is still an update, not a raw peer file-copy: every recipient treats it as a signed, channel-scoped candidate that must pass the normal verification and consent gates; lab nodes pinned to the debug channel may explicitly opt into full-auto apply. Trust is node-local: debug rollout keys are installed through each lab node's release-key overlay, never embedded in the production trust anchor. A debug rollout may carry a platform artifact set assembled by one fast coordinator; each recipient applies only the artifact for its own platform. The rollout driver is maintainer/dev tooling that reuses SPT's update subst…\n_Avoid_: production release, peer proliferation, subnet binary copy\r\n\r\n**cadence/consent** — **not fully automatic by default; gated on user confirmation.** The update prompt is delivered via spt to the user's **most-recently-active live session** (v1 registry resolution — a precursor to PresenceChannel dispatch, implemented on the v1 registry, not the deferred channel) and offers an \"enable full-auto\" choice. Full-auto is the opt-in seamless path.\r\n\r\n## Web serving\r\n\r\nRatified 2026-09-06 (WEBSERVE grill round 1, releases#272; ADR-0056..0059). The docs server\r\ngrows into the node's general serving surface; these are the terms the surface is described in.\r\n\r\n**served resource** — anything a node exposes over its serving surface: a registry file, a\r\nserved root, a docs page, an adapter's docs, a message view, a bootstrap binary. Every served\r\nresource has exactly one **node-prefixed URL**.\r\n_Avoid_: hosted file, shared file, upload.\r\n\r\n**node-prefixed URL** — the one URL grammar for every served resource, `…/<node-name>/…`,\r\nidentical on the owning node and on every other subnet machine (the local daemon resolves the\r\nprefix and proxies to the owner when it is elsewhere). A URL that only works on one machine\r\nis not a node-prefixed URL. (ADR-0056)\r","truncated":true,"truncatedBy":"lines","totalLines":592,"totalBytes":14586,"outputLines":51,"outputBytes":14586,"lastLinePartial":false,"firstLineExceedsLimit":false},"totalLines":1268,"displayContent":{"text":"## Self-update\r\n\r\n<!-- [doc->REQ-ADAPTER-UPDATE-MESSAGE] -->\r\nSeamless, 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\\` in any ongoing sessions.\"` for spt-claude-code.\r\n\r\n**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-set format are unchanged from the public-channel era. <!-- [doc->REQ-UPDATE-GH-TRANSPORT] -->\r\n\r\n**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] -->\r\n\r\n**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 LAN-exposed — a non-loopback bind would re-publish what privating hid. Under WEBSERVE (ADR-0056) the same paths are also reachable under the local node prefix; the bare paths stay as a compatibility alias. <!-- [doc->REQ-DOCS-LOCAL-SERVER] -->\r\n_Avoid_: public docs hosting; binding beyond loopback; docs drifting from the installed version.\r\n\r\n**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 update --restart` is the one-step **full cycle**: fetch → adapters → `apply --finish` last (the finish restarts the whole daemon, so it is the final act). **A REFUSAL IS NOT A FAILURE AND NEITHER IS A SUCCESS** (releases#153): every update surface answers with **`0` …\n\r\n**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] -->\r\n\r\n**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, hooks — join index × perch roster and **never run git**; stale renders last-known or `-`, never a stall. Precedence (session-cwd → origin-cwd → context-recency) and rendered names are contract. **Since W3 the cutover is COMPLETE**: `endpoint list` (human + `--json`), …\n\r\n**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 are a no-op by construction**); backgrounding the legacy 100+ process loop is REJECTED. Cold start: daemon ready + CLI fast before the background publish. Warm start: persisted index readable immediately; **unchanged generation performs no scan**. Any cycle failure kee…\n_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-pull seam nudges; the periodic reconcile is a backstop, not a license). <!-- [doc->REQ-PROJECT-INDEX-READER-CUTOVER] -->\r\n\r\n**delivery** — peer-propagated over P2P, layered on self-fetch, with out-of-band still supported. One node learns of / obtains an update (marketplace drop *or* self-fetch from a release channel), gossips availability across the subnet, and peers pull the new binary over the networking layer — the subnet self-heals to latest. **All binaries are signature-verified before handoff regardless of source** (peer-propagation otherwise lets a compromised node poison the subnet). spt-core has its own release signing key, distinct from any OS-publisher code-signing (the Windows-publisher-trust question is separate).\r\n\r\n**handoff invariant** — **no endpoint process terminates or suspends during a self-update.** We cannot assume every endpoint can safely suspend. Satisfied by the broker/brain split: routine updates replace only the daemon brain, which rehydrates from disk state and re-attaches to the broker's held PTY/socket/child handles. Harness-hosted endpoints (topology 1) are safe by construction; spt-hosted endpoints (topology 2) are kept alive by the broker holding their PTY masters + child processes across the brain restart.\r\n\r\n**brain-trial promotion (readiness + drained)** — the broker supervises the swapped-in brain through a bounded readiness **trial** and *promotes* the new binary only when it both signals ready for its own generation **and** the OUTGOING generation's control plane has **drained** — the old brain's local (brain-owned) controller connection is closed or stall-evicted, never still holding blocked writes. A hard-killed prior generation leaves that connection **black-holed** (its hosted PTYs keep producing output the broker's writer blocks on, since a killed peer's pipe blocks rather than EOFs), and the drained signal is a *passive read* of broker truth — nothing in the isolated trial window would otherwise evict it. So **the boot/trial brain itself drives the evi…\n\r\n**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 of every free/local-controlled session — including the operator's local `spt rc` — and then, because the daemon brain never drains those PTYs, the broker would stall-evict that seized controller after the write deadline, leaving the session uncontrollable *and* h…\n\r\n**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.\r\n\r\n**debug rollout**:\r\nA deliberately non-production update distribution across a trusted lab subnet, used to test a local build on multiple nodes before a public release. It is still an update, not a raw peer file-copy: every recipient treats it as a signed, channel-scoped candidate that must pass the normal verification and consent gates; lab nodes pinned to the debug channel may explicitly opt into full-auto apply. Trust is node-local: debug rollout keys are installed through each lab node's release-key overlay, never embedded in the production trust anchor. A debug rollout may carry a platform artifact set assembled by one fast coordinator; each recipient applies only the artifact for its own platform. The rollout driver is maintainer/dev tooling that reuses SPT's update subst…\n_Avoid_: production release, peer proliferation, subnet binary copy\r\n\r\n**cadence/consent** — **not fully automatic by default; gated on user confirmation.** The update prompt is delivered via spt to the user's **most-recently-active live session** (v1 registry resolution — a precursor to PresenceChannel dispatch, implemented on the v1 registry, not the deferred channel) and offers an \"enable full-auto\" choice. Full-auto is the opt-in seamless path.\r\n\r\n## Web serving\r\n\r\nRatified 2026-09-06 (WEBSERVE grill round 1, releases#272; ADR-0056..0059). The docs server\r\ngrows into the node's general serving surface; these are the terms the surface is described in.\r\n\r\n**served resource** — anything a node exposes over its serving surface: a registry file, a\r\nserved root, a docs page, an adapter's docs, a message view, a bootstrap binary. Every served\r\nresource has exactly one **node-prefixed URL**.\r\n_Avoid_: hosted file, shared file, upload.\r\n\r\n**node-prefixed URL** — the one URL grammar for every served resource, `…/<node-name>/…`,\r\nidentical on the owning node and on every other subnet machine (the local daemon resolves the\r\nprefix and proxies to the owner when it is elsewhere). A URL that only works on one machine\r\nis not a node-prefixed URL. (ADR-0056)\r\n…\nbare paths are a compatibility alias, not a second grammar).\n…\n","startLine":677,"lineNumbers":[677,678,679,680,681,682,683,684,685,686,687,688,689,690,691,692,693,694,695,696,697,698,699,700,701,702,703,704,705,706,707,708,709,710,711,712,713,714,715,716,717,718,719,720,721,722,723,724,725,726,727,null,729,null,775]},"fileSize":351732,"meta":{"source":{"type":"path","value":"C:\\Users\\decid\\Documents\\projects\\spt-core\\CONTEXT.md"},"truncation":{"direction":"head","truncatedBy":"lines","totalLines":1268,"totalBytes":14586,"outputLines":51,"outputBytes":14586,"shownRange":{"start":677,"end":727},"nextOffset":728},"limits":{"columnTruncated":{"maxColumn":768}}}},"isError":false,"timestamp":1790039437219}}
{"type":"message","id":"bada95a3","parentId":"88eca3a0","timestamp":"2026-09-22T01:10:37.221Z","message":{"role":"toolResult","toolCallId":"call_aZqRsQ02A9LE9ZsKyLPzQskX|fc_06340275dc54e8ed016ab1d58d02a887d19c1b7cd2269cd3e7","toolName":"grep","content":[{"type":"text","text":"# crates/xtask/src/\n## main.rs\n 2004|    run(\n*2005|        Command::new(\"gh\").args([\n 2006|            \"release\",\n 2007|            \"download\",\n 2008|            &tag,\n...\n 2057|    let docs_name = \"spt-docs.tar.gz\";\n*2058|    let docs_ok = Command::new(\"gh\")\n 2059|        .args([\n 2060|            \"release\",\n 2061|            \"download\",\n...\n 2121|    // 4. Upload the SignedRelease assets + the signed update set.\n*2122|    let mut upload = Command::new(\"gh\");\n 2123|    upload.args([\"release\", \"upload\", &tag, \"--repo\", REPO, \"--clobber\"]);\n 2124|    for s in &signed {\n 2125|        upload.arg(s);\n...\n 2131|    run(\n*2132|        Command::new(\"gh\").args([\"release\", \"edit\", &tag, \"--repo\", REPO, \"--draft=false\"]),\n 2133|        \"gh release edit --draft=false\",\n 2134|    );\n 2135|    println!(\"release {tag} signed + published.\");\n\nSkipped missing paths: scripts, crates/spt-install"}],"details":{"scopePath":"crates/xtask, crates/spt-daemon/src","searchPath":"C:\\Users\\decid\\Documents\\projects\\spt-core\\crates","cwd":"C:\\Users\\decid\\Documents\\projects\\spt-core","matchCount":4,"fileCount":1,"files":["crates/xtask/src/main.rs"],"fileMatches":[{"path":"crates/xtask/src/main.rs","count":4}],"truncated":false,"displayContent":"# crates/xtask/src/\n## main.rs\n 2004│    run(\n*2005│        Command::new(\"gh\").args([\n 2006│            \"release\",\n 2007│            \"download\",\n 2008│            &tag,\n     │...\n 2057│    let docs_name = \"spt-docs.tar.gz\";\n*2058│    let docs_ok = Command::new(\"gh\")\n 2059│        .args([\n 2060│            \"release\",\n 2061│            \"download\",\n     │...\n 2121│    // 4. Upload the SignedRelease assets + the signed update set.\n*2122│    let mut upload = Command::new(\"gh\");\n 2123│    upload.args([\"release\", \"upload\", &tag, \"--repo\", REPO, \"--clobber\"]);\n 2124│    for s in &signed {\n 2125│        upload.arg(s);\n     │...\n 2131│    run(\n*2132│        Command::new(\"gh\").args([\"release\", \"edit\", &tag, \"--repo\", REPO, \"--draft=false\"]),\n 2133│        \"gh release edit --draft=false\",\n 2134│    );\n 2135│    println!(\"release {tag} signed + published.\");","missingPaths":["scripts","crates/spt-install"]},"isError":false,"timestamp":1790039437221}}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_GID5xfNG9hgQ6kF0Amvm05hH|fc_06340275dc54e8ed016ab1d596129c87d18c2454d681943098","toolName":"read","startedAt":"2026-09-22T01:10:46.144Z","args":{"path":"https://cli.github.com/manual/gh_auth_status"},"intent":"Reading gh status contract"},"id":"33edfb74","parentId":"bada95a3","timestamp":"2026-09-22T01:10:46.144Z"}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_75XCQuorfA2KcDdPYjEfMhvt|fc_06340275dc54e8ed016ab1d59612b087d1895ae5e3ba3a2e99","toolName":"read","startedAt":"2026-09-22T01:10:46.145Z","args":{"path":"https://cli.github.com/manual/gh_release_list"},"intent":"Reading gh release probe contract"},"id":"7e311fdc","parentId":"33edfb74","timestamp":"2026-09-22T01:10:46.145Z"}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_3iIVcVunEePD0Umz4HT635dI|fc_06340275dc54e8ed016ab1d59612b887d1b8db7f060c8d27df","toolName":"read","startedAt":"2026-09-22T01:10:46.145Z","args":{"path":"https://cli.github.com/manual/gh_help_environment"},"intent":"Reading gh environment contract"},"id":"7e68938f","parentId":"7e311fdc","timestamp":"2026-09-22T01:10:46.145Z"}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_xxBc0WPQsqGPbFt5hQ0ezZeC|fc_06340275dc54e8ed016ab1d59612c087d1a7201672bf43c4c9","toolName":"grep","startedAt":"2026-09-22T01:10:46.145Z","args":{"path":"crates/spt-runtime/src/runtime.rs"},"intent":"Locating deadline regression fixtures"},"id":"24c171f4","parentId":"7e68938f","timestamp":"2026-09-22T01:10:46.145Z"}
{"type":"message","id":"71e9315c","parentId":"24c171f4","timestamp":"2026-09-22T01:10:46.146Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"**Checking official release details**"},{"type":"toolCall","id":"call_GID5xfNG9hgQ6kF0Amvm05hH|fc_06340275dc54e8ed016ab1d596129c87d18c2454d681943098","name":"read","arguments":{"i":"Reading gh status contract","path":"https://cli.github.com/manual/gh_auth_status"},"intent":"Reading gh status contract"},{"type":"toolCall","id":"call_75XCQuorfA2KcDdPYjEfMhvt|fc_06340275dc54e8ed016ab1d59612b087d1895ae5e3ba3a2e99","name":"read","arguments":{"i":"Reading gh release probe contract","path":"https://cli.github.com/manual/gh_release_list"},"intent":"Reading gh release probe contract"},{"type":"toolCall","id":"call_3iIVcVunEePD0Umz4HT635dI|fc_06340275dc54e8ed016ab1d59612b887d1b8db7f060c8d27df","name":"read","arguments":{"i":"Reading gh environment contract","path":"https://cli.github.com/manual/gh_help_environment"},"intent":"Reading gh environment contract"},{"type":"toolCall","id":"call_xxBc0WPQsqGPbFt5hQ0ezZeC|fc_06340275dc54e8ed016ab1d59612c087d1a7201672bf43c4c9","name":"grep","arguments":{"i":"Locating deadline regression fixtures","pattern":"fn .*timeout|fn .*bounded|fn .*env|struct BoundedOutput|fn success","path":"crates/spt-runtime/src/runtime.rs","case":null,"gitignore":null,"skip":null},"intent":"Locating deadline regression fixtures"},{"type":"toolCall","id":"call_JmysPlgU9Akw44M6SF6GQ8x3|fc_06340275dc54e8ed016ab1d59612c887d1874e20491c542973","name":"read","arguments":{"i":"Reading update requirement entry","path":"traceable-reqs.toml"},"intent":"Reading update requirement entry"}],"api":"openai-codex-responses","provider":"openai-codex","model":"gpt-6-astra","usage":{"input":9765,"output":222,"cacheRead":48000,"cacheWrite":0,"totalTokens":57987,"reasoningTokens":21,"cost":{"input":0.09765000000000001,"output":0.0111,"cacheRead":0.048,"cacheWrite":0,"total":0.15675}},"stopReason":"toolUse","timestamp":1790039437255,"responseId":"resp_06340275dc54e8ed016ab1d58d84c887d186f4c697a0099ce8","providerPayload":{"type":"openaiResponsesHistory","provider":"openai-codex","dt":true,"items":[{"id":"rs_06340275dc54e8ed016ab1d58f6b0c87d1b897ebd918bfa8f6","type":"reasoning","content":[],"encrypted_content":"gAAAAABqsdWWHVoL2x36SUvY7YBKOzeXtp9wc8xOMRYzIvd49xOmAu3VVp6Tj-g6GIFvKl0PQu3s9ftnGKa5fAnv26hyyJDh3GId_1NxlpUNc7yiIoZoK6M3H2kCo4g8ZguNdAZaZD3zpyRCvjdgedfaVP6mRuaBUM3SzwjNqTFlwd93YOJlxm_G_zVj-A3jU_VQP1hgKgbmKBIx8xhbC45LY-3NrLAR1qGTyqLWFfsXMCIEDbL5h4WzytyISe3AfY8SyQvDZWkbDWmaZ2vwG8Antnk4YTIYDoIaOPd57M6_aXVPRavwIF5S2T24-iLMtJCt00NzF6ihrPTSja0w5WsTmtXl2NzYKFgfwKu0WnAUILoRWdbD3Ux31fAF8ATe9UUyX-R_8BG1nOAl0UqhlSGUEOgkVAGNI9ZAwmq6JB_GoAyaClz-VTkNqVEmf9H9aHbM8GaTY60OoNInWjMonRGbsjbf7PL2obwfCfQzQgbKN5MqBVFaQPZLuHr0pP3DaiGeIRGR-mPwOMTqEwHcCCint4QxgH1BBxQTxlFN5D7rRKY9wGIFqpIPPygBfISY1z7GJmh-Fq1r4Zc2_WeDkBv7DHjsm2r7Q8QQjRFRfGEbFoJr6C8l5ei6YX7sV9Lo83mM71yz3Iql0w8SiH5nYB3vekVy7aNU1RGNFsC7ooadk2b6AxgssbYv6ZtCVc7mrdOCHw4YSbnVXufkUcOGGUEzStwNCH79aO5nDCAWgvgwDyXT5DZFUEfjV0qktQER2lAiXupfkB0j8FCVYVoour4ZiAss_64AO4NmKeQgrZy7OOfS-qWlA9U_Jny7_42npYoutL0qfRPFVb-jBmlfsXaNXndj4ASnKVcuB0b-2h1HIqHPfafcTxyGkyFFcPzPN6U0scno-isuiT3W18sOzJNYnVmxibvfX-jjiaCfBf66-rVm-hFCdSS0VlKN2Tr2SsYGv37PFUHLxTjwB5mjgFCSAYCAT2439ko_XL6JtGHwUCdkCqJncetm83Hp0Z3Lfqe58I8tuKyzmhoIqD2bq8ZqjgTelxN5qPp9xfM7zVyzge6NEYi6ttpHDx-DhCIPQ0TsaKSbKJN5ErTVsmGVdH7ovSsOP3aghLurxV28B4crUVFc4cFwMP3VT7TLv0XlWZi1vpyXtwObTy6rQWXLYITvOlG-5NDQyqgn7DF3qJEGeKaraCC1FdHve7MYsb-NYvg2MU6SWKNYq9rYOwtXY_Fw8H3oX20gGBjfbwKgpqSJF_EzwsuEZ3cEdMWaIyh2GV2KR8Jf-QBVDEGAlEqnnKmcxnPwV8pCmxF5cDcFlAlAqTixyV8hlc7ckAwz2Tuw2-oNYofHhOfxDK6nwR_BGh4RFtfnkwEh1GVfUFx1moy4vVWsC3GUP8gyg9-VD6abCFgBunYJ9ECl3XoqRSOyS4Iu7Y5oV7rSkw==","internal_chat_message_metadata_passthrough":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"},"summary":[{"type":"summary_text","text":"**Checking official release details**"}],"metadata":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"}},{"id":"fc_06340275dc54e8ed016ab1d596129c87d18c2454d681943098","type":"function_call","status":"completed","arguments":"{\"i\":\"Reading gh status contract\",\"path\":\"https://cli.github.com/manual/gh_auth_status\"}","call_id":"call_GID5xfNG9hgQ6kF0Amvm05hH","internal_chat_message_metadata_passthrough":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"},"name":"read","metadata":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"}},{"id":"fc_06340275dc54e8ed016ab1d59612b087d1895ae5e3ba3a2e99","type":"function_call","status":"completed","arguments":"{\"i\":\"Reading gh release probe contract\",\"path\":\"https://cli.github.com/manual/gh_release_list\"}","call_id":"call_75XCQuorfA2KcDdPYjEfMhvt","internal_chat_message_metadata_passthrough":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"},"name":"read","metadata":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"}},{"id":"fc_06340275dc54e8ed016ab1d59612b887d1b8db7f060c8d27df","type":"function_call","status":"completed","arguments":"{\"i\":\"Reading gh environment contract\",\"path\":\"https://cli.github.com/manual/gh_help_environment\"}","call_id":"call_3iIVcVunEePD0Umz4HT635dI","internal_chat_message_metadata_passthrough":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"},"name":"read","metadata":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"}},{"id":"fc_06340275dc54e8ed016ab1d59612c087d1a7201672bf43c4c9","type":"function_call","status":"completed","arguments":"{\"i\":\"Locating deadline regression fixtures\",\"pattern\":\"fn .*timeout|fn .*bounded|fn .*env|struct BoundedOutput|fn success\",\"path\":\"crates/spt-runtime/src/runtime.rs\",\"case\":null,\"gitignore\":null,\"skip\":null}","call_id":"call_xxBc0WPQsqGPbFt5hQ0ezZeC","internal_chat_message_metadata_passthrough":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"},"name":"grep","metadata":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"}},{"id":"fc_06340275dc54e8ed016ab1d59612c887d1874e20491c542973","type":"function_call","status":"completed","arguments":"{\"i\":\"Reading update requirement entry\",\"path\":\"traceable-reqs.toml\"}","call_id":"call_JmysPlgU9Akw44M6SF6GQ8x3","internal_chat_message_metadata_passthrough":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"},"name":"read","metadata":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"}}]},"duration":8884.376700000837,"ttft":2181.9518999997526,"completedAt":1790039446141,"contextSnapshot":{"promptTokens":57765,"nonMessageTokens":10333,"compactionEpoch":0}}}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_JmysPlgU9Akw44M6SF6GQ8x3|fc_06340275dc54e8ed016ab1d59612c887d1874e20491c542973","toolName":"read","startedAt":"2026-09-22T01:10:46.146Z","args":{"path":"traceable-reqs.toml"},"intent":"Reading update requirement entry"},"id":"76b05984","parentId":"71e9315c","timestamp":"2026-09-22T01:10:46.146Z"}
{"type":"message","id":"a814a319","parentId":"76b05984","timestamp":"2026-09-22T01:10:46.257Z","message":{"role":"toolResult","toolCallId":"call_JmysPlgU9Akw44M6SF6GQ8x3|fc_06340275dc54e8ed016ab1d59612c887d1874e20491c542973","toolName":"read","content":[{"type":"text","text":"# traceable-reqs manifest for spt-core — SEED.\n#\n# Authoritative requirement registry: every PRD R-* requirement and every\n# KNOWN-HAZARDS invariant lives here as a REQ-* id (see docs/TRACEABILITY.md\n# for the development contract).\n#\n# ACTIVATION MODEL: every requirement starts inactive (`required_stages = []`)\n# so `check` stays green pre-code. A milestone (M0-M5) ACTIVATES its requirements\n# by setting their real `required_stages`. Validate this seed against the\n# installed CLI at M0 (the binary isn't installed yet).\n\n[scan]\n# CONTEXT.md is a file root: the glossary is authoritative for meaning and\n# carries doc-stage tags (e.g. §Installation). installer/ added M6-D2.\nroots = [\"src\", \"tests\", \"docs\", \"crates\", \"installer\", \"CONTEXT.md\", \"docs-site\", \".github\"]\n\n[policy]\n# Default policy applied at activation: doc + impl + unit. Networking/lifecycle\n# reqs add \"int\". Until a req is activated, its per-req `required_stages = []`\n# keeps it out of the coverage gate.\nrequired_stages = []\n\n[placement]\nenforce = \"on\"\nmodule_banner = \"accept\"\n\n[[requirements]]\nid = \"REQ-STATUS-LIVE-SUPERVISOR-PID\"\ntitle = \"Service status obtains the running supervisor PID from the live service, reports a disagreeing daemon.pid as stale, and never promotes an unverified file PID to running\"\nrequired_stages = [\"doc\", \"impl\", \"unit\", \"int\"]\n\n# ───────────────────────────── Architecture / workspace ─────────────────────\n[[requirements]]\nid = \"REQ-ARCH-1\"\ntitle = \"Many small acyclically-layered crates\"\nrequired_stages = [\"impl\"]  # activated M0: spt-proto<-spt-store one-way layering (T14). impl-only: architecture is build-evidenced (cargo rejects cycles); grows as crates are added M1+.\n[[requirements]]\nid = \"REQ-ARCH-2\"\ntitle = \"Public SDK surface is spt-proto, spt-runtime, spt-msg\"\nrequired_stages = [\"impl\"]\n[[requirements]]\nid = \"REQ-ARCH-3\"\ntitle = \"Wire-protocol version independent of crate semver, N-1 compat window\"\nrequired_stages = [\"impl\", \"unit\"]  # activated M0: spt-proto wire version + compat window (T6)\n[[requirements]]\nid = \"REQ-ARCH-4\"\ntitle = \"Copy-verbatim the commodity layer from the sister project\"\nrequired_stages = [\"impl\", \"unit\"]  # activated M0: spt-proto EVENT grammar (T2)\n\n# ───────────────────────────── Daemon ───────────────────────────────────────\n[[requirements]]\nid = \"REQ-DAEMON-1\"\ntitle = \"One per-machine spt-daemon owning all per-machine state\"\nrequired_stages = [\"impl\", \"unit\", \"int\"]  # int activated M3b-B9: the daemon E2E (spawn→Psyche-loop→commune→brain-restart-survives→graceful-signoff) drives the consolidated daemon-hosted lifecycle through the real IPC\n[[requirements]]\nid = \"REQ-DAEMON-2\"\ntitle = \"Broker/brain split for seamless self-update\"\nrequired_stages = [\"impl\", \"unit\", \"int\"]  # int RE-POINTED at restoration D7-1 (ADR-0018 V5) to the PROCESS-level survival E2E (crates/spt/tests/brain_survive.rs: broker/brain split delivers the seamless update at the process level — a brain-PROCESS restart onto a swapped binary, PTY child + QUIC conn held). Was M3b-B9 daemon_e2e + handoff.rs (the in-process handoff shape — regression-masked per ADR-0018); those tags removed in the same D7-1 commit. The net-ownership facet (netbroker.rs) stays distinct evidence\n[[requirements]]\nid = \"REQ-DAEMON-3\"\ntitle = \"Any api invocation auto-starts the daemon if absent\"\nrequired_stages = [\"impl\", \"unit\", \"int\"]\n[[requirements]]\nid = \"REQ-DAEMON-4\"\ntitle = \"Honor every KNOWN-HAZARDS invariant\"\nrequired_stages = [\"impl\", \"unit\", \"int\"]  # int activated M3b-B9: the daemon E2E proves graceful signoff runs the echo-before-teardown ordering (3.3) and removes the Self's ready marker in the consolidated daemon\n\n# ───────────────────────────── Storage ──────────────────────────────────────\n# git-KV BranchStore substrate (ADR-0011, 2026-06-02; implement M4, optional M3c)\n[[requirements]]\nid = \"REQ-STORE-1\"\ntitle = \"spt-store::BranchStore (git branch as versioned KV; commit=checkpoint/tip=resume, atomic multi-key, merge-native sync) is the substrate for coarse/durable/audited state (context, registry snapshot+distribution, daemon checkpoint); hot paths (B5 fsync journal) + indexed queries (SQLite spool) excluded (ADR-0011)\"\nrequired_stages = [\"impl\", \"unit\"]  # activated M4-D6a: spt_store::branchstore::BranchStore — bare seed repo + linked worktrees (relative-path config, repair fallback), ensure_branch = parentless empty-tree seed (independent KV roots; sync joins with --allow-unrelated-histories at first contact), commit_in_worktree = ONE commit per write incl. atomic multi-key (no-op mints no empty checkpoint), tip/read_at_tip = the recovery read. Git-CLI-backed per ADR-0013 (bounded runner gitrun.rs, hazard 5.3; identity pinned via env, never user config). First consumer: contextstore.rs two-tier tracked/ layout (a-<id>/p-<project> branches) + project.rs project_id derivation (remote-URL slug → toplevel folder → dir name). unit = init-idempotent + commit/read/tip + no-op-no…\n\n# ───────────────────────────── Harness contract — manifest seams ────────────\n[[requirements]]\nid = \"REQ-MANIFEST-1\"\ntitle = \"Per-adapter manifest with adapter_name and min_spt_core_version\"\nrequired_stages = [\"doc\", \"impl\", \"unit\"]\n[[requirements]]\nid = \"REQ-MANIFEST-2\"\ntitle = \"Adapter profiles — sparse leaf-replace overlays (shipped + local), composite <adapter>:<profile> addressing, shadow-refusal, tighten-only consent floors\"\nrequired_stages = [\"doc\", \"impl\", \"unit\"]\n[[requirements]]\nid = \"REQ-MANIFEST-3\"\ntitle = \"Adapter strings — [strings] KV tree, dot-path get-string resolving through the profile leaf-replace overlay, set-string editing a local profile's [strings] only; data-only (nothing executes a string)\"\nrequired_stages = [\"doc\", \"impl\", \"unit\"]  # activated 2026-06-12: M9-T2 (CONTEXT.md §adapter strings, adapter-string get/set CLI + overlay resolution)\n[[requirements]]\nid = \"REQ-MANIFEST-4\"\ntitle = \"Keyword hints — [[hints]] {keywords (literal/regex), text}; spt api hint --session emits at most one matched hint per message, once per session (seen-set), declaration-order first match; profiles overlay [[hints]] by leaf-replace\"\nrequired_stages = [\"doc\", \"impl\", \"unit\"]  # activated 2026-06-12: M9-T3 (CONTEXT.md §keyword hints, api hint surface + per-session seen-set)\n[[requirements]]\nid = \"REQ-MANIFEST-5\"\ntitle = \"File-backed adapter [strings] (M12-W3-T3.1): a [strings] dot-path value MAY be an inline-table FILE POINTER `key = { file = \\\"rel/path\\\" }` resolved to the file's contents at get-string time, keeping large bodies (skill-instructions, hint text) out of the manifest. A value-position table with a `file` key IS the pointer form (reserved — cannot double as data). Per-adapter aux storage `adapters/<adapter>/strings/`; pointers resolve relative to it with CONTAINMENT (reject `..`/absolute escaping the dir). UPDATE-SAFETY: a LOCAL profile's file-pointers resolve relative to the user-owned local-profile dir (NOT adapter-shipped strings/, which adapter updates overwrite), or the local profile inlines. Validate-at-register (fail-fast on a bad/escaping/missin…\nrequired_stages = [\"doc\", \"impl\", \"unit\"]  # activated M12-W3\n[[requirements]]\nid = \"REQ-MANIFEST-6\"\ntitle = \"Cross-adapter fallback target addressing (M12-W3-T3.2): a cross-adapter fallback target is addressed as `<adapter>:<profile>` (not just a bare adapter_name), resolved through the one composite-addressing resolver (registry::resolve_option) at every adapter-option read site so a fallback may select a shipped/local profile (e.g. a `ccs` profile). CONTEXT.md §cross-adapter-fallback reconciled (\\\"ccs is a profile; cross-adapter fallback may target <adapter>:<profile>\\\"). Contract-only this milestone: the node-wide fallback SETTING + its rate-limit invocation are deferred to the consuming milestone (the runtime path does not exist yet); this REQ guarantees the ADDRESSING resolves.\"\nrequired_stages = [\"doc\", \"unit\"]  # activated M12-W3 (contract-only: no int)\n[[requirements]]\nid = \"REQ-MANIFEST-7\"\ntitle = \"Adapter-declared shortcut basename (M12-W2 follow-on): an optional `[adapter] shortcut_basename` manifest field names the basename the picker bakes into the generated `<basename>-<id>` launcher shortcut (REQ-RUN-SHORTCUT). Absent ⇒ the harness-agnostic default `spt` (→ `spt-<id>`); an adapter sets it to brand its shortcuts (claude-spt → `cc` → `cc-<id>`), so the Claude-Code-ness lives in the PUBLISHED adapter manifest, never hardcoded in spt-core. The picker reads it from the RESOLVED manifest of the selected adapter (registry::resolve_option), falling back to `spt` when absent/empty/unresolvable. Additive + N-1-safe (serde-default Option, omitted from serialization when absent; old manifests parse clean); manifest.schema.json regenerated from the d…\nrequired_stages = [\"doc\", \"impl\", \"unit\"]  # activated M12-W2 follow-on\n[[requirements]]\nid = \"REQ-MANIFEST-8\"\ntitle = \"[adapter] host_binaries declares the harness executable basenames a kind=\\\"harness\\\" adapter hosts agents inside (e.g. host_binaries = [\\\"claude\\\"]); bind-time pid→exe-basename match (case-insensitive, .exe-stripped) over the seed's parent_pid selects the candidate adapter set; zero matches → a friendly error naming the binary + the --adapter escape hatch. Additive + N-1-safe: optional Vec<String>, #[serde(default, skip_serializing_if = \\\"Vec::is_empty\\\")] (omitted-serialized like shortcut_basename, old manifests parse clean); manifest.schema.json regenerated from the derive (ADR-0001, CI drift-gated). The match-key for ADR-0021 adapter-agnostic bind-time resolution. (v0.9.0)\"\nrequired_stages = [\"doc\", \"impl\", \"unit\", \"int\"]  # activated v0.9.0 W2: doc = CONTEXT.md §179 host_binaries candidate rule + docs/MANIFEST.md + docs-site harness-contract host_binaries field. impl = the `[adapter] host_binaries` field (manifest.rs, serde-default omit-empty) + spt_store::proc::exe_basename (Win QueryFullProcessImageNameW / Linux /proc/pid/exe) + the resolver's host_binaries candidate filter (spt-runtime resolve). unit = host_binaries_optional_and_n1_safe (manifest) + exe_basename_resolves_current_process/dead_pid_is_none (proc) + no_candidate/normalize tests (resolve). int folds into REQ-START-5's final bringup E2E\n[[requirements]]\nid = \"REQ-SEAM-SPAWN\"\ntitle = \"spawn-session seam\"\nrequired_stages = [\"impl\", \"unit\"]\n[[requirements]]\nid = \"REQ-SEAM-POSTSPAWN\"\ntitle = \"post-spawn / api bind seam with boot nonce\"\nrequired_stages = [\"impl\", \"unit\"]\n[[requirements]]\nid = \"REQ-SEAM-PSYCHE\"\ntitle = \"spawn-psyche seam (fresh + resume templates)\"\nrequired_stages = [\"impl\", \"unit\", \"int\"]\n[[requirements]]\nid = \"REQ-SEAM-HISTORY\"\ntitle = \"History subsystem (fetcher / locate-normalize / native store)\"\nrequired_stages = [\"impl\", \"unit\", \"int\"]\n[[requirements]]\nid = \"REQ-SEAM-ACTIVITY\"\ntitle = \"Activity/idle reported via api sentinels, not PTY quiescence\"\nrequired_stages = [\"impl\", \"unit\"]\n[[requirements]]\nid = \"REQ-SEAM-INJECT\"\ntitle = \"inject-input methods configurable per activity-state\"\nrequired_stages = [\"impl\", \"unit\"]\n[[requirements]]\nid = \"REQ-SEAM-RESUME\"\ntitle = \"resume-session seam (fresh-with-preload / continue-existing)\"\nrequired_stages = []  # SUPERSEDED 2026-07-04 (F-030 W5, todlando) by REQ-PSYCHE-EPHEMERAL-DRIVER (+ REQ-PSYCHE-SID-CUSTODY + REQ-PSYCHE-CONTEXT-FILE-INDIRECTION). The standalone resume_psyche(ResumeMode) spawn seam is retired dead-code (ZERO production callers post-W3 — the driver is psyche_turn_and_relay → run_psyche_turn; resume_psyche + its 3 units are DELETED with the .live-bin machinery). The fresh-with-preload / continue-existing DISTINCTION it owned now lives in the per-event turn: FreshWithPreload = custody None → mint sid + NON-EMPTY {psyche_context_file} (SID-CUSTODY + CONTEXT-FILE-INDIRECTION), ContinueExisting = custody Some → the psyche's own sid + 0-byte context file. download_psyche_context + ResumeMode STAY (used by psyche_download_context /…\n[[requirements]]\nid = \"REQ-SEAM-CAPABILITY\"\ntitle = \"Hostable endpoint-types capability declaration\"\nrequired_stages = [\"impl\", \"unit\"]\n[[requirements]]\nid = \"REQ-SEAM-UPDATE\"\ntitle = \"Adapter-update avenue (file-pull / delegated command)\"\nrequired_stages = [\"impl\", \"unit\"]  # activated M3c-C3: manifest [update] gains signing_key (file_pull content key) + self_verifies (delegated attest); adapter_update::plan_adapter_update dispatches each avenue (file_pull verified against the adapter key, delegated delegated-or-skipped)\n\n# ───────────────────────────── Harness contract — api surface ───────────────\n[[requirements]]\nid = \"REQ-API-1\"\ntitle = \"api prefix and adapter_name on every machinery invocation\"\nrequired_stages = [\"impl\", \"unit\", \"int\"]\n[[requirements]]\nid = \"REQ-API-2\"\ntitle = \"The api subcommand surface (bind/listen/poll/state/worker/boundary/...)\"\nrequired_stages = [\"impl\", \"unit\", \"int\"]\n[[requirements]]\nid = \"REQ-API-3\"\ntitle = \"commune/signoff are file-drops, not commands\"\nrequired_stages = [\"impl\", \"unit\", \"int\"]\n[[requirements]]\nid = \"REQ-API-4\"\ntitle = \"api resolves the adapter manifest (+ profile + install dir) from `--adapter name:profile` via the registry when `--manifest` is omitted; `--manifest` becomes an optional OVERRIDE (unregistered / local-dev manifests). Removes the require-both-flags redundancy — a registered adapter's live bringup / digest / capability needs only `--adapter` — and yields the precise install dir (the record's source_dir) rather than the --manifest parent, closing the copy-mode psyche-binary edge (v0.8.0)\"\nrequired_stages = [\"doc\", \"impl\", \"unit\"]  # activate v0.8.0: doc = CONTEXT.md \"Manifest resolution from --adapter (since v0.8.0)\" §inbound-api-surface. impl = api/mod.rs run() — when args.manifest is None, resolve (record, manifest) via registry::resolve_option(perch::adapters_dir(), &args.adapter) (the same composite name:profile resolution digest.rs + the gh_release update use), setting manifest = Some + install_dir = record.source_dir (PRECISE — supersedes the --manifest-parent approximation for the resolved path, fixing REQ-INSTALL-11's copy-mode psyche edge); args.manifest = Some keeps the explicit-path override (install_dir = its parent). Backward-compatible: an unregistered/dev adapter with no --manifest still yields manifest=None (registry miss), un…\n\n# ───────────────────────────── Startup flows ────────────────────────────────\n[[requirements]]\nid = \"REQ-START-1\"\ntitle = \"Adapters never resolve SPT_HOME; binary on PATH; api bridging only\"\nrequired_stages = [\"impl\", \"unit\"]\n[[requirements]]\nid = \"REQ-START-2\"\ntitle = \"Harness-hosted startup: api seed then listen\"\nrequired_stages = [\"impl\", \"unit\", \"int\"]\n[[requirements]]\nid = \"REQ-START-3\"\ntitle = \"spt-hosted startup: spawn-session then api bind (no file)\"\nrequired_stages = [\"impl\", \"unit\", \"int\"]\n[[requirements]]\nid = \"REQ-START-4\"\ntitle = \"Adapter-injected env aliases (SPT/OWL/LIVE)\"\nrequired_stages = [\"impl\", \"unit\"]\n[[requirements]]\nid = \"REQ-START-5\"\ntitle = \"Adapter-agnostic harness-hosted seed + bind-time adapter/profile resolution (ADR-0021): `api seed` carries only parent_pid + session_id (+ optional cwd), no --adapter — a pure \\\"a harness session exists at this pid\\\" record; --adapter becomes an OPTIONAL override across the whole api group (an explicit name[:profile] for adapter dev, never required). Omitted, listen/poll resolve the owning adapter/profile AT BIND as a pure read against the live registry — never a seed-time snapshot that can drift: seed parent_pid → exe basename → host_binaries candidate set (REQ-MANIFEST-8) → active-profile pointer (REQ-INSTALL-12) primary, else greatest-registered_at_ms candidate base profile (name-asc tie) → friendly zero-match error. Covers BOTH LiveAgent (listen…\nrequired_stages = [\"doc\", \"impl\", \"unit\", \"int\"]  # activated v0.9.0 W1-W2 (LiveAgent/listen): doc = CONTEXT.md §171/§177-181 (adapter-agnostic seed + bind-time resolution) + docs-site harness-contract/api.md + docs/MANIFEST.md SessionStart hook. impl = adapter-agnostic Seed (seed.rs drops adapter) + cmd_seed + --adapter Option across the api group (api/mod.rs ApiArgs/Ctx/resolve_ctx_manifest) + bind-time resolution in cmd_listen (explicit override else resolve::resolve_bind_adapter) feeding both the info.json adapter stamp (bind_from_seed) and the live_capable manifest. unit = adapter_is_optional (api/mod) + resolve_from_basename/pointer tests (spt-runtime resolve). int (agnostic-seed listen bringup E2E) activates at the final LiveAgent wave. RESIDUAL (doyl…\n\n# ───────────────────────────── Endpoints / Shells ───────────────────────────\n[[requirements]]\nid = \"REQ-EP-1\"\ntitle = \"Day-one endpoint types; open type system\"\nrequired_stages = [\"impl\", \"unit\"]  # activated M0: spt-proto endpoint taxonomy (T4)\n[[requirements]]\nid = \"REQ-EP-2\"\ntitle = \"Agent endpoints vs Shells distinction in the type model\"\nrequired_stages = [\"impl\", \"unit\"]  # activated M0: spt-proto endpoint taxonomy (T4)\n[[requirements]]\nid = \"REQ-EP-3\"\ntitle = \"Messaging payloads carry typed operation commands + file blobs\"\nrequired_stages = [\"impl\", \"unit\"]  # activated M0: spt-proto payload model (T7)\n[[requirements]]\nid = \"REQ-EP-4\"\ntitle = \"PresenceChannel broker endpoint (seam day-one)\"\nrequired_stages = [\"impl\", \"unit\"]  # activated M4-D4c: the broker-owned PresenceLog — a seq'd ring of {connected|disconnected, conn_id, remote_id_hex} events mirroring the StreamLog discipline. register_conn appends connected + spawns a closed-watcher (conn.closed() → conn row removed → disconnected appended), so the conn table finally reflects liveness; the brain subscribes via net-presence-subscribe {from_seq} and consumes net-presence-event under the same contiguous/dedup/gap cursor discipline as streams (brain-restart cursor-resume). The full PresenceChannel *endpoint* (agent-visible channel) builds on this broker seam later; int = two-host at D9.\n[[requirements]]\nid = \"REQ-EP-5\"\ntitle = \"Concrete shell instantiation model: spawn-mints-instance (vs relink/online), registered-on-node permission + broadcast-is-discovery, per-shell require_approval gate, max_instances_per_owner + over_cap, instance aliasing, discovery scope\"\nrequired_stages = [\"impl\", \"unit\", \"int\"]  # activated M5-D3a; int activated M5-D9b: the D3e shell E2E + the real-shell E2E (notify_shell_e2e.rs, CI-hook-driven against the standalone spt-shell-notify adapter) carry the int tags\n[[requirements]]\nid = \"REQ-EP-6\"\ntitle = \"Gateway type acceptance: a Gateway-typed perch binds (api bind --type, open type system — un-hardcode the live_agent default), advertises/addressable like any endpoint, owns shells (owner validation not agent-family-gated), subscribes to digests, and is the user-msg identity gate's user-backed origin (REQ-MSG-5); in-tree mock-gateway fixture (R-DOCS-2 pattern, no downstream adapter code). Cross-node WAN Gateway-origin (registry endpoint_type trust) tracked by REQ-MSG-6\"\nrequired_stages = [\"doc\", \"impl\", \"unit\"]  # activated 2026-06-13: M9-T5 (CONTEXT.md §Gateway; api bind --type un-hardcode at establish_perch; gateway-owns-shell + local user-backed-origin E2E via mock-gateway fixture; the WAN fail-closed row tested at the receive_wan funnel)\n[[requirements]]\nid = \"REQ-EP-7\"\ntitle = \"Durable live-role.md: a per-agent broad-purpose statement in tracked/agents/<id>/ beside live-context.md (replicates with the mind on the same a-<id> branch); renders FIRST at start-transition context injection (role -> live-context -> project-context); SOLE writer `spt endpoint role --overwrite <file>` — mechanical no-automated-writer guarantee (echo-commune ingest / signoff / Psyche reconcile structurally exclude it). The user-backed-origin hard gate on the writer is a deferred later tightening (rides the user-msg identity plumbing)\"\nrequired_stages = [\"doc\", \"impl\", \"unit\"]  # activated 2026-06-13: M9-T6 (CONTEXT.md §live role; contextstore live_role_file/live_role_path seam; resume::download_psyche_context role-first injection order; spt endpoint role print/--overwrite sole writer; injection-order + no-automated-writer tests; M9-WAVE2-ROLE-WRITERS.md survey)\n# ── Always-on endpoints (ADR-0023, ratified 2026-06-21; first consumer downstream spt-discord) ──\n[[requirements]]\nid = \"REQ-EP-8\"\ntitle = \"AlwaysOnEndpoint: a resident, addressable, mindless endpoint whose adapter binary the daemon supervises continuously — register-triggered by an adapter-option's `[always-on]` manifest section, one supervised binary per `<adapter>[:profile]`, running independent of agent liveness. It self-manages its `#`-addressed channel endpoints via the existing `api bind` (one connection fronts many). The SECOND class of spt-core-boot-launched third-party binary (after the shell wake-watcher); supervision reuses the wake-watcher scaffolding (backoff / give-up latch / one-per-instance lock / orphan-kill / brain-side reconcile) MINUS the offline-only flip — always online, never resting (no dormant/suspended states). Two-way: agents message it; it may call `endpoint…\nrequired_stages = []  # rule 5: design ratified 2026-06-21 (grill-with-docs, ADR-0023); SPLIT 2026-07-26 (ADR-0049): the process substrate (supervision, [service] manifest, hold/quiesce, cardinality, derived liveness) moved to REQ-RESIDENT-SERVICE — this req retains ONLY the endpoint layer (api bind channel fronting, wake verb, addressable identity) and builds ON that substrate; the [always-on] manifest section is superseded by [service] + the endpoint-layer declaration. UNBUILT — activate when the milestone delivering the endpoint layer lands; first consumer is the downstream spt-discord adapter (own repo).\n[[requirements]]\nid = \"REQ-EP-9\"\ntitle = \"`#` always-on address sigil: a reserved LEADING sigil marking an AlwaysOnEndpoint, extending the REQ-INST-10 grammar to `[subnet:]#id[@node]`. Mandatory + bijective — `#name` ⟺ always-on endpoint, bare `name` ⟺ agent endpoint — so the router resolves endpoint class from the address alone, before any registry lookup. Sits ABOVE REQ-HAZARD-ID-CHARSET: the address parser strips the single leading `#` before id validation, so the bare/stored id stays charset-clean and a mid-id `#` remains rejected (the charset contract is unchanged).\"\nrequired_stages = []  # rule 5: design ratified 2026-06-21 (ADR-0023); UNBUILT — activate with REQ-EP-8's milestone (spt_proto::addr::Address::parse sigil extension + class-discriminated resolve in spt_net registry). REQ-HAZARD-ID-CHARSET is NOT amended (sigil is an address-layer concern).\n\n# ───────────────────────────── Instances ────────────────────────────────────\n[[requirements]]\nid = \"REQ-INST-1\"\ntitle = \"endpoint ID vs instance split (adapter-agnostic ID)\"\nrequired_stages = []  # rule 5: the data-model split shipped across M4 D3 (registry Instance rows) + D9-5 (home/adapter on the endpoint record); this umbrella req activates when instantiate-anywhere makes the split user-facing (instance minting) — which DEFERS PAST M5 (user decision 2026-06-04, M5-PLAN scope decision 2: consent framework seam only); its concrete slices are tracked by REQ-INST-7..15\n[[requirements]]\nid = \"REQ-INST-2\"\ntitle = \"Per-node files, synced Psyche mind\"\nrequired_stages = [\"impl\", \"unit\"]  # activated M4-D6a: the mind now lives in the tracked/ two-tier store (spt-store::contextstore over BranchStore), physically separate from never-synced node-local perches/ — spt_live::ingest::route_slices routes commune drops' <live-context>/<project-context> slices into a-<id>/p-<project_id> branch files (untagged body → live, the parser fallback; precedence-guarded 6.5; checkpoint commit per written tier); signoff::write_resume_commune stamps Self provenance per slice through the same router; resume::download_psyche_context composes the two tiers back into the two-slice envelope (pure read, never mints store state; other-project download sees live only). Daemon lifecycle derives project_id from the commune dir's parent (…\n[[requirements]]\nid = \"REQ-INST-3\"\ntitle = \"Dormant (warm) / suspended (cold) resting states\"\nrequired_stages = [\"doc\", \"impl\", \"unit\"]  # doc activated M4-D9-3: docs/DORMANCY-BUDGET.md — the measured warm/cold policy lock (ADR-0003 #9 closed: warm default confirmed, zero idle CPU, RSS-only cost; auto-suspend opt-in default OFF). impl/unit activated M4-D9-2-1: spt-daemon::resting — the explicit daemon-owned state machine (pure transition table: detach/attention-shift rest warm, manual suspend rests cold from either live state, opt-in auto-suspend counted from the dormancy-onset anchor, wake re-activates in place; no idle timer by design) + durable info.json rest_state/dormant_since_ms record (anchor moves atomically with the state) + the global→node→endpoint auto-suspend knob chain default OFF (daemon.json node leg, info.json endpoint leg, 0 = explic…\n[[requirements]]\nid = \"REQ-INST-4\"\ntitle = \"active to dormant/suspended fires a transition echo commune\"\nrequired_stages = [\"impl\", \"unit\"]  # activated M4-D9-2-2: resting::apply_event fires the on_rest_edge echo hook at every active→(dormant|suspended) edge BEFORE the flip persists (KH 3.3 echo-before-teardown ordering; echo failure is loud but never wedges the instance active — the signoff_with posture); BrainLifecycle::rest_event binds the hook to the real run_echo_commune machinery (fire_echo), so the final context delta lands in the commune drop dir and syncs to whichever instance activates next. Exactly-once per edge by the pure table's idempotence (replayed events are no-edges); resting→resting fires nothing. unit = echo-once-per-edge (pure + real-summarizer lifecycle e2e) + loud-failure-persists + no-echo-on-suspend-from-dormant\n[[requirements]]\nid = \"REQ-INST-5\"\ntitle = \"Two-tier context sync (live to all, project to same-project)\"\nrequired_stages = [\"impl\", \"unit\", \"int\"]  # activated M4-D6c-2: scoping is the pull model + the server-side gate (ADR-0013 — requester names a-<id>/p-<project> refs, no new registry fields). spt-daemon::sync::SyncPolicy.allows: a-<id> served only when ∃ subnet S with the handshake-proven origin pinned in the trust store AND synced(id,S) (the D3e gate finally consumed — membership list + visibility, hidden ⟹ not synced, fail-closed on unconfigured lists); p-<project> only to a trusted origin for a hosted project (honest residual: p- tier is branch-granular, a hidden endpoint's per-project slice rides a hosted project's branch — per-file p- filtering post-v1); machinery refs never served. select_refs mirrors the gate requester-side (symmetric scoping). Server…\n[[requirements]]\nid = \"REQ-INST-6\"\ntitle = \"Deferred messages not delivered to dormant/suspended instances\"\nrequired_stages = [\"impl\", \"unit\", \"int\"]  # activated M5-D5a/b: the gate is state-keyed at the hook-channel consumer sites — resting::deferred_held reads the owner's durable rest record at drain time; \"held\" = (deferred=1) ∧ (Dormant|Suspended); poll_drain + cmd_worker_poll gated (the KH 1.4 \"all sites agree\" sweep — shellchan peek ungated: no deferred writer targets shell perches; ring's ephemeral perch recordless by construction = legacy fallback). Release is by construction at the wake edge: the persisted Active state stops the narrowing, the unchanged delivered-mark discipline keeps it exactly-once. D5b adds the remote arm: StreamFamily::Rest + resthost request/serve (remote-drive trust class — access_check on the handshake-proven origin, no grant gate;…\n[[requirements]]\nid = \"REQ-INST-7\"\ntitle = \"Subnet registry + bare-id resolution policy\"\nrequired_stages = [\"impl\", \"unit\", \"int\"]  # activated M4-D3a: spt-net::net::registry — SubnetRegistry { endpoint_id -> [Instance{node,status,epoch}] }, Status{Active,Dormant,Offline}, serde-roundtrippable, merge_instance keeps distinct-node instances as separate rows. The bare-id *resolution policy* (local -> most-recently-active -> id@node, ambiguity refuses) is D3c (REQ-INST-10). Cross-node replication delivered M4-D4d: spt-net::net::replicate — RegistryUpdate {subnet,endpoint_id,Instance} as NDJSON over broker-owned QUIC streams (framing survives chunk coalescing/splitting; corrupt lines skipped, 4.3 posture), apply_update routes by subnet into merge_instance and drops non-member subnets fail-closed (REQ-INST-13 posture); advertise gates (D3d/D3e) run lo…\n[[requirements]]\nid = \"REQ-INST-8\"\ntitle = \"Remote-control mode distinct from local operation\"\nrequired_stages = [\"impl\", \"unit\", \"int\"]  # activated M4-D5b: AttachRecord wire protocol (spt-net::net::attach) + spt-daemon::attach serve/request/input — a byte-stream viewport onto a remote session over broker QUIC streams; compute+files stay on the target node. tests/attach.rs drives a real PTY child on daemon A from daemon B and proves restart survival (worst-case seq-0 re-serve, all journals dedup). int activated M4-D9-6: two-host rig E2E green (tests/twohost.rs — A types into B's live PTY across the rig and reads the echo back)\n# ── Multi-subnet membership model (ADR-0006, 2026-06-01; activate M4) ──\n[[requirements]]\nid = \"REQ-INST-9\"\ntitle = \"Multi-subnet membership (same-user N subnets; cross-user seam)\"\nrequired_stages = [\"impl\", \"unit\"]  # activated M4-D3d: SubnetStore already carries N seeds (D2c/f); D3d adds the join-time bare-id collision check — spt_net::net::registry::SubnetRegistry::join_endpoint refuses (JoinCollision, registry untouched) when a joining endpoint id is already held by a DIFFERENT node in the target subnet (Offline rows still own the name); same-node re-advertise falls through to the merge_instance epoch lease. Per-subnet scope: the same bare name in different subnets is legal (resolver forces qualification, REQ-INST-10). Same-user only; cross-user seam = registry key generalizes to (subnet,user) per ADR-0006, check rides along unchanged. unit = fresh-join insert + other-node refuse-untouched + offline-holder collide + same-node lease…\n[[requirements]]\nid = \"REQ-INST-10\"\ntitle = \"Qualified addressing [subnet:]id[@node] + ambiguity forces qualification\"\nrequired_stages = [\"impl\", \"unit\"]  # activated M4-D3c: spt_proto::addr::Address::parse (grammar, reserved :/@ → unambiguous split, each component charset-validated) + spt_net::net::registry::resolve/resolve_across (policy: @node exact → local wins → sole live → REFUSE on multiple live NODES [AcrossNodes, force @node]; Offline never routed). Subnet is NEVER a disambiguation axis (ADR-0006 §1: identity node-global) — the unqualified cross-subnet form UNIONs instances and DEDUPEs by node (same (id,node) in N subnets = ONE endpoint; freshest epoch among twins), then applies the single-registry policy; a `subnet:` qualifier still routes straight to one subnet (counter-39 bug #1: removed the false AcrossSubnets refusal). Per-node epochs are NOT cross-node compara…\n[[requirements]]\nid = \"REQ-INST-11\"\ntitle = \"spt rename <id> rippled to all instances (collision-checked, 6.5-reconciled)\"\nrequired_stages = [\"impl\", \"unit\"]  # activated M4-D3f: three local arms. (1) registry rows: spt_net::net::registry::rename_endpoint — collision-checked against EVERY subnet the endpoint is advertised into FIRST (any row, any status holds the name), then moved all-or-nothing; rows verbatim (relabel, not liveness event); new id charset-validated (:/@ reserved). (2) perch dirs: spt_store::rename::rename_perch_in — Self dir + nested <id>-psyche/<id>-w{N} children + every info.json id; refuses while any perch in the tree is live; local SQLite registry rows deliberately untouched (live refuses ⇒ old rows stale ⇒ 4.3 stale-clean drops). (3) CLI `spt rename <old> <new>` wires the perch arm. Seams: daemon-held registry wiring = D4; a-<id> context branch = D6 context…\n[[requirements]]\nid = \"REQ-INST-12\"\ntitle = \"Endpoint visibility per-(endpoint,subnet): excluded semantics, OR-of-defaults + override, gates sync\"\nrequired_stages = [\"impl\", \"unit\"]  # activated M4-D3e: spt_store::visibility::VisibilityStore (identity/visibility.json, atomic, degrade-safe) — hidden(E,S,flag) = per-(E,S) override wins both directions, else S.hide_new_endpoints OR E.default_hide (both ship OFF). SubnetRecord.hide_new_endpoints serde-defaulted (legacy subnet.json loads OFF). Enforcement in spt_net::net::registry: excluded = not advertised (advertise_if_visible gates BEFORE the collision check — hidden neither claims nor clashes) AND not routable (resolve_visible/resolve_across_visible refuse even @node pins; exclusion prunes the cross-subnet ambiguity set). unit = OR-of-defaults + override-both-directions + persist/degrade + legacy-load + not-routable + ambiguity-prune + advertise-gate.\n[[requirements]]\nid = \"REQ-INST-13\"\ntitle = \"Subnet-exclusive sync + per-endpoint subnet-membership list\"\nrequired_stages = [\"impl\", \"unit\"]  # activated M4-D3e: VisibilityStore.sync_subnets per-endpoint membership list + synced(E,S,flag) — the D6 replication gate: subnet must be on the list (unconfigured list syncs NOWHERE, fail-closed; creation flow seeds [home] per ADR-0010) AND visibility gates sync (hidden ⟹ not synced even when listed, ADR-0006 §6). Actual mind replication consuming this gate = D6. unit = listed-visible-syncs + unlisted/unconfigured-no + hidden-gates-sync (defaults + override paths).\n[[requirements]]\nid = \"REQ-INST-14\"\ntitle = \"Resource advertisement (subnet resource registry): free-text blurb, both-authored, registry projection, visibility/whitelist-gated\"\nrequired_stages = [\"doc\", \"impl\", \"unit\"]  # activated M4-D9-4: resources: Option<String> on the registry Instance (additive serde-default, rides RegistryUpdate replication + the epoch lease — a blurb edit is an ordinary epoch-bumped update, NO new record kind/store/merge surface) + resource_projection (the (id,node,status,blurb) yellow-pages view over VISIBLE+routable rows — same exclusion closure as resolution, ADR-0006 §6 leak-free by construction) + both-authored sourcing (info.json resources via spt resources set wins; daemon.json resources_blurb node seed fills the gap) + spt resources set/show/list CLI off the stale-tolerant registry snapshots. The whitelist leg of the gate (viewer-node filtering) rides the deferred consent/whitelist generalization — …\n[[requirements]]\nid = \"REQ-INST-15\"\ntitle = \"Immutable home subnet (assigned at creation: auto-if-one/ask-if-many) + spt fork (cross-subnet clone to a new identity, copy-then-diverge, not re-home); adapter chosen at creation from registered hostable adapters, changed only via launch/resume-under-new (ADR-0010)\"\nrequired_stages = [\"doc\", \"impl\", \"unit\"]  # activated M4-D9-5: spt-store::home (assign_home matrix: sole auto / several refuse-and-qualify / unpaired local-only; stamp_creation_fields = the ONE creation seam shared by spt ready + api listen/bind — NEW assigns hard + seeds sync_subnets=[home], REVIVE carries home/adapter/blurb/knobs forward so a re-bind never wipes them; adopt_for_unset = first-join adoption, sole-subnet only, never re-homes; NO setter — immutability by construction) + info.json home_subnet/adapter + ContextStore::fork_endpoint (one-time copy of live+project tiers as parentless seed commits — copied-then-diverged, no shared history) / remove_endpoint (--delete-source, exactly the source) + spt fork CLI (refusal-first gates: same-id, target m…\n[[requirements]]\nid = \"REQ-REACH-1\"\ntitle = \"Off-node remote-drive detection. THE FILE-TRANSFER HALF IS STRUCK (ADR-0060, releases#246, doyle-ruled 2026-09-07): the WAN transfer family it named is retired -- see REQ-XFER-RETIRED -- so this requirement now covers detection only. What survives is what never depended on the transport: the daemon-side fact of WHICH node is driving an endpoint's session, stamped by the serve path and read back by the endpoint. The int stage stands on the remote-drive cell in tests/dispatch.rs; the transfer int arms left with the family they covered.\"\nrequired_stages = [\"impl\", \"unit\", \"int\"]  # int activated M4-D9-6: two-host rig E2E green (tests/twohost.rs — file fetch off B + push back to B across the rig; chunked xfer through the dispatcher gate)\n[[requirements]]\nid = \"REQ-REACH-2\"\ntitle = \"Remote command execution (deferred, consent-gated)\"\nrequired_stages = []  # rule 5: deferred by design (PRD: consent-gated remote exec is the highest-risk capability; workarounds exist) — M5+ with the instantiate-anywhere consent model\n\n# ───────────────────────────── Local messaging (M1) ─────────────────────────\n# No PRD R-* covers local message delivery (foundational/implicit). Registered\n# at M1 start per TRACEABILITY rule 3 (precedent: REQ-NODE-IDENTITY at M0 T5).\n# Inactive until the M1 task that delivers each lands (rule 5: activate, don't pre-fail).\n[[requirements]]\nid = \"REQ-MSG-1\"\n…\nrequired_stages = [\"impl\", \"unit\", \"int\"]  # activated M1 T1 (impl+unit); int added T9: killer-quickstart cross-perch E2E\n\n[Showing lines 1-300 of 7800. Use :301 to continue. Some lines truncated to 768 chars]"}],"details":{"truncation":{"content":"# traceable-reqs manifest for spt-core — SEED.\r\n#\r\n# Authoritative requirement registry: every PRD R-* requirement and every\r\n# KNOWN-HAZARDS invariant lives here as a REQ-* id (see docs/TRACEABILITY.md\r\n# for the development contract).\r\n#\r\n# ACTIVATION MODEL: every requirement starts inactive (`required_stages = []`)\r\n# so `check` stays green pre-code. A milestone (M0-M5) ACTIVATES its requirements\r\n# by setting their real `required_stages`. Validate this seed against the\r\n# installed CLI at M0 (the binary isn't installed yet).\r\n\r\n[scan]\r\n# CONTEXT.md is a file root: the glossary is authoritative for meaning and\r\n# carries doc-stage tags (e.g. §Installation). installer/ added M6-D2.\r\nroots = [\"src\", \"tests\", \"docs\", \"crates\", \"installer\", \"CONTEXT.md\", \"docs-site\", \".github\"]\r\n\r\n[policy]\r\n# Default policy applied at activation: doc + impl + unit. Networking/lifecycle\r\n# reqs add \"int\". Until a req is activated, its per-req `required_stages = []`\r\n# keeps it out of the coverage gate.\r\nrequired_stages = []\r\n\r\n[placement]\r\nenforce = \"on\"\r\nmodule_banner = \"accept\"\r\n\r\n[[requirements]]\r\nid = \"REQ-STATUS-LIVE-SUPERVISOR-PID\"\r\ntitle = \"Service status obtains the running supervisor PID from the live service, reports a disagreeing daemon.pid as stale, and never promotes an unverified file PID to running\"\r\nrequired_stages = [\"doc\", \"impl\", \"unit\", \"int\"]\r\n\r\n# ───────────────────────────── Architecture / workspace ─────────────────────\r\n[[requirements]]\r\nid = \"REQ-ARCH-1\"\r\ntitle = \"Many small acyclically-layered crates\"\r\nrequired_stages = [\"impl\"]  # activated M0: spt-proto<-spt-store one-way layering (T14). impl-only: architecture is build-evidenced (cargo rejects cycles); grows as crates are added M1+.\r\n[[requirements]]\r\nid = \"REQ-ARCH-2\"\r\ntitle = \"Public SDK surface is spt-proto, spt-runtime, spt-msg\"\r\nrequired_stages = [\"impl\"]\r\n[[requirements]]\r\nid = \"REQ-ARCH-3\"\r\ntitle = \"Wire-protocol version independent of crate semver, N-1 compat window\"\r\nrequired_stages = [\"impl\", \"unit\"]  # activated M0: spt-proto wire version + compat window (T6)\r\n[[requirements]]\r\nid = \"REQ-ARCH-4\"\r\ntitle = \"Copy-verbatim the commodity layer from the sister project\"\r\nrequired_stages = [\"impl\", \"unit\"]  # activated M0: spt-proto EVENT grammar (T2)\r\n\r\n# ───────────────────────────── Daemon ───────────────────────────────────────\r\n[[requirements]]\r\nid = \"REQ-DAEMON-1\"\r\ntitle = \"One per-machine spt-daemon owning all per-machine state\"\r\nrequired_stages = [\"impl\", \"unit\", \"int\"]  # int activated M3b-B9: the daemon E2E (spawn→Psyche-loop→commune→brain-restart-survives→graceful-signoff) drives the consolidated daemon-hosted lifecycle through the real IPC\r\n[[requirements]]\r\nid = \"REQ-DAEMON-2\"\r\ntitle = \"Broker/brain split for seamless self-update\"\r\nrequired_stages = [\"impl\", \"unit\", \"int\"]  # int RE-POINTED at restoration D7-1 (ADR-0018 V5) to the PROCESS-level survival E2E (crates/spt/tests/brain_survive.rs: broker/brain split delivers the seamless update at the process level — a brain-PROCESS restart onto a swapped binary, PTY child + QUIC conn held). Was M3b-B9 daemon_e2e + handoff.rs (the in-process handoff shape — regression-masked per ADR-0018); those tags removed in the same D7-1 commit. The net-ownership facet (netbroker.rs) stays distinct evidence\r\n[[requirements]]\r\nid = \"REQ-DAEMON-3\"\r\ntitle = \"Any api invocation auto-starts the daemon if absent\"\r\nrequired_stages = [\"impl\", \"unit\", \"int\"]\r\n[[requirements]]\r\nid = \"REQ-DAEMON-4\"\r\ntitle = \"Honor every KNOWN-HAZARDS invariant\"\r\nrequired_stages = [\"impl\", \"unit\", \"int\"]  # int activated M3b-B9: the daemon E2E proves graceful signoff runs the echo-before-teardown ordering (3.3) and removes the Self's ready marker in the consolidated daemon\r\n\r\n# ───────────────────────────── Storage ──────────────────────────────────────\r\n# git-KV BranchStore substrate (ADR-0011, 2026-06-02; implement M4, optional M3c)\r\n[[requirements]]\r\nid = \"REQ-STORE-1\"\r\ntitle = \"spt-store::BranchStore (git branch as versioned KV; commit=checkpoint/tip=resume, atomic multi-key, merge-native sync) is the substrate for coarse/durable/audited state (context, registry snapshot+distribution, daemon checkpoint); hot paths (B5 fsync journal) + indexed queries (SQLite spool) excluded (ADR-0011)\"\r\nrequired_stages = [\"impl\", \"unit\"]  # activated M4-D6a: spt_store::branchstore::BranchStore — bare seed repo + linked worktrees (relative-path config, repair fallback), ensure_branch = parentless empty-tree seed (independent KV roots; sync joins with --allow-unrelated-histories at first contact), commit_in_worktree = ONE commit per write incl. atomic multi-key (no-op mints no empty checkpoint), tip/read_at_tip = the recovery read. Git-CLI-backed per ADR-0013 (bounded runner gitrun.rs, hazard 5.3; identity pinned via env, never user config). First consumer: contextstore.rs two-tier tracked/ layout (a-<id>/p-<project> branches) + project.rs project_id derivation (remote-URL slug → toplevel folder → dir name). unit = init-idempotent + commit/read/tip + no-op-no…\n\r\n# ───────────────────────────── Harness contract — manifest seams ────────────\r\n[[requirements]]\r\nid = \"REQ-MANIFEST-1\"\r\ntitle = \"Per-adapter manifest with adapter_name and min_spt_core_version\"\r\nrequired_stages = [\"doc\", \"impl\", \"unit\"]\r\n[[requirements]]\r\nid = \"REQ-MANIFEST-2\"\r\ntitle = \"Adapter profiles — sparse leaf-replace overlays (shipped + local), composite <adapter>:<profile> addressing, shadow-refusal, tighten-only consent floors\"\r\nrequired_stages = [\"doc\", \"impl\", \"unit\"]\r\n[[requirements]]\r\nid = \"REQ-MANIFEST-3\"\r\ntitle = \"Adapter strings — [strings] KV tree, dot-path get-string resolving through the profile leaf-replace overlay, set-string editing a local profile's [strings] only; data-only (nothing executes a string)\"\r\nrequired_stages = [\"doc\", \"impl\", \"unit\"]  # activated 2026-06-12: M9-T2 (CONTEXT.md §adapter strings, adapter-string get/set CLI + overlay resolution)\r\n[[requirements]]\r\nid = \"REQ-MANIFEST-4\"\r\ntitle = \"Keyword hints — [[hints]] {keywords (literal/regex), text}; spt api hint --session emits at most one matched hint per message, once per session (seen-set), declaration-order first match; profiles overlay [[hints]] by leaf-replace\"\r\nrequired_stages = [\"doc\", \"impl\", \"unit\"]  # activated 2026-06-12: M9-T3 (CONTEXT.md §keyword hints, api hint surface + per-session seen-set)\r\n[[requirements]]\r\nid = \"REQ-MANIFEST-5\"\r\ntitle = \"File-backed adapter [strings] (M12-W3-T3.1): a [strings] dot-path value MAY be an inline-table FILE POINTER `key = { file = \\\"rel/path\\\" }` resolved to the file's contents at get-string time, keeping large bodies (skill-instructions, hint text) out of the manifest. A value-position table with a `file` key IS the pointer form (reserved — cannot double as data). Per-adapter aux storage `adapters/<adapter>/strings/`; pointers resolve relative to it with CONTAINMENT (reject `..`/absolute escaping the dir). UPDATE-SAFETY: a LOCAL profile's file-pointers resolve relative to the user-owned local-profile dir (NOT adapter-shipped strings/, which adapter updates overwrite), or the local profile inlines. Validate-at-register (fail-fast on a bad/escaping/missin…\nrequired_stages = [\"doc\", \"impl\", \"unit\"]  # activated M12-W3\r\n[[requirements]]\r\nid = \"REQ-MANIFEST-6\"\r\ntitle = \"Cross-adapter fallback target addressing (M12-W3-T3.2): a cross-adapter fallback target is addressed as `<adapter>:<profile>` (not just a bare adapter_name), resolved through the one composite-addressing resolver (registry::resolve_option) at every adapter-option read site so a fallback may select a shipped/local profile (e.g. a `ccs` profile). CONTEXT.md §cross-adapter-fallback reconciled (\\\"ccs is a profile; cross-adapter fallback may target <adapter>:<profile>\\\"). Contract-only this milestone: the node-wide fallback SETTING + its rate-limit invocation are deferred to the consuming milestone (the runtime path does not exist yet); this REQ guarantees the ADDRESSING resolves.\"\r\nrequired_stages = [\"doc\", \"unit\"]  # activated M12-W3 (contract-only: no int)\r\n[[requirements]]\r\nid = \"REQ-MANIFEST-7\"\r\ntitle = \"Adapter-declared shortcut basename (M12-W2 follow-on): an optional `[adapter] shortcut_basename` manifest field names the basename the picker bakes into the generated `<basename>-<id>` launcher shortcut (REQ-RUN-SHORTCUT). Absent ⇒ the harness-agnostic default `spt` (→ `spt-<id>`); an adapter sets it to brand its shortcuts (claude-spt → `cc` → `cc-<id>`), so the Claude-Code-ness lives in the PUBLISHED adapter manifest, never hardcoded in spt-core. The picker reads it from the RESOLVED manifest of the selected adapter (registry::resolve_option), falling back to `spt` when absent/empty/unresolvable. Additive + N-1-safe (serde-default Option, omitted from serialization when absent; old manifests parse clean); manifest.schema.json regenerated from the d…\nrequired_stages = [\"doc\", \"impl\", \"unit\"]  # activated M12-W2 follow-on\r\n[[requirements]]\r\nid = \"REQ-MANIFEST-8\"\r\ntitle = \"[adapter] host_binaries declares the harness executable basenames a kind=\\\"harness\\\" adapter hosts agents inside (e.g. host_binaries = [\\\"claude\\\"]); bind-time pid→exe-basename match (case-insensitive, .exe-stripped) over the seed's parent_pid selects the candidate adapter set; zero matches → a friendly error naming the binary + the --adapter escape hatch. Additive + N-1-safe: optional Vec<String>, #[serde(default, skip_serializing_if = \\\"Vec::is_empty\\\")] (omitted-serialized like shortcut_basename, old manifests parse clean); manifest.schema.json regenerated from the derive (ADR-0001, CI drift-gated). The match-key for ADR-0021 adapter-agnostic bind-time resolution. (v0.9.0)\"\r\nrequired_stages = [\"doc\", \"impl\", \"unit\", \"int\"]  # activated v0.9.0 W2: doc = CONTEXT.md §179 host_binaries candidate rule + docs/MANIFEST.md + docs-site harness-contract host_binaries field. impl = the `[adapter] host_binaries` field (manifest.rs, serde-default omit-empty) + spt_store::proc::exe_basename (Win QueryFullProcessImageNameW / Linux /proc/pid/exe) + the resolver's host_binaries candidate filter (spt-runtime resolve). unit = host_binaries_optional_and_n1_safe (manifest) + exe_basename_resolves_current_process/dead_pid_is_none (proc) + no_candidate/normalize tests (resolve). int folds into REQ-START-5's final bringup E2E\r\n[[requirements]]\r\nid = \"REQ-SEAM-SPAWN\"\r\ntitle = \"spawn-session seam\"\r\nrequired_stages = [\"impl\", \"unit\"]\r\n[[requirements]]\r\nid = \"REQ-SEAM-POSTSPAWN\"\r\ntitle = \"post-spawn / api bind seam with boot nonce\"\r\nrequired_stages = [\"impl\", \"unit\"]\r\n[[requirements]]\r\nid = \"REQ-SEAM-PSYCHE\"\r\ntitle = \"spawn-psyche seam (fresh + resume templates)\"\r\nrequired_stages = [\"impl\", \"unit\", \"int\"]\r\n[[requirements]]\r\nid = \"REQ-SEAM-HISTORY\"\r\ntitle = \"History subsystem (fetcher / locate-normalize / native store)\"\r\nrequired_stages = [\"impl\", \"unit\", \"int\"]\r\n[[requirements]]\r\nid = \"REQ-SEAM-ACTIVITY\"\r\ntitle = \"Activity/idle reported via api sentinels, not PTY quiescence\"\r\nrequired_stages = [\"impl\", \"unit\"]\r\n[[requirements]]\r\nid = \"REQ-SEAM-INJECT\"\r\ntitle = \"inject-input methods configurable per activity-state\"\r\nrequired_stages = [\"impl\", \"unit\"]\r\n[[requirements]]\r\nid = \"REQ-SEAM-RESUME\"\r\ntitle = \"resume-session seam (fresh-with-preload / continue-existing)\"\r\nrequired_stages = []  # SUPERSEDED 2026-07-04 (F-030 W5, todlando) by REQ-PSYCHE-EPHEMERAL-DRIVER (+ REQ-PSYCHE-SID-CUSTODY + REQ-PSYCHE-CONTEXT-FILE-INDIRECTION). The standalone resume_psyche(ResumeMode) spawn seam is retired dead-code (ZERO production callers post-W3 — the driver is psyche_turn_and_relay → run_psyche_turn; resume_psyche + its 3 units are DELETED with the .live-bin machinery). The fresh-with-preload / continue-existing DISTINCTION it owned now lives in the per-event turn: FreshWithPreload = custody None → mint sid + NON-EMPTY {psyche_context_file} (SID-CUSTODY + CONTEXT-FILE-INDIRECTION), ContinueExisting = custody Some → the psyche's own sid + 0-byte context file. download_psyche_context + ResumeMode STAY (used by psyche_download_context /…\n[[requirements]]\r\nid = \"REQ-SEAM-CAPABILITY\"\r\ntitle = \"Hostable endpoint-types capability declaration\"\r\nrequired_stages = [\"impl\", \"unit\"]\r\n[[requirements]]\r\nid = \"REQ-SEAM-UPDATE\"\r\ntitle = \"Adapter-update avenue (file-pull / delegated command)\"\r\nrequired_stages = [\"impl\", \"unit\"]  # activated M3c-C3: manifest [update] gains signing_key (file_pull content key) + self_verifies (delegated attest); adapter_update::plan_adapter_update dispatches each avenue (file_pull verified against the adapter key, delegated delegated-or-skipped)\r\n\r\n# ───────────────────────────── Harness contract — api surface ───────────────\r\n[[requirements]]\r\nid = \"REQ-API-1\"\r\ntitle = \"api prefix and adapter_name on every machinery invocation\"\r\nrequired_stages = [\"impl\", \"unit\", \"int\"]\r\n[[requirements]]\r\nid = \"REQ-API-2\"\r\ntitle = \"The api subcommand surface (bind/listen/poll/state/worker/boundary/...)\"\r\nrequired_stages = [\"impl\", \"unit\", \"int\"]\r\n[[requirements]]\r\nid = \"REQ-API-3\"\r\ntitle = \"commune/signoff are file-drops, not commands\"\r\nrequired_stages = [\"impl\", \"unit\", \"int\"]\r\n[[requirements]]\r\nid = \"REQ-API-4\"\r\ntitle = \"api resolves the adapter manifest (+ profile + install dir) from `--adapter name:profile` via the registry when `--manifest` is omitted; `--manifest` becomes an optional OVERRIDE (unregistered / local-dev manifests). Removes the require-both-flags redundancy — a registered adapter's live bringup / digest / capability needs only `--adapter` — and yields the precise install dir (the record's source_dir) rather than the --manifest parent, closing the copy-mode psyche-binary edge (v0.8.0)\"\r\nrequired_stages = [\"doc\", \"impl\", \"unit\"]  # activate v0.8.0: doc = CONTEXT.md \"Manifest resolution from --adapter (since v0.8.0)\" §inbound-api-surface. impl = api/mod.rs run() — when args.manifest is None, resolve (record, manifest) via registry::resolve_option(perch::adapters_dir(), &args.adapter) (the same composite name:profile resolution digest.rs + the gh_release update use), setting manifest = Some + install_dir = record.source_dir (PRECISE — supersedes the --manifest-parent approximation for the resolved path, fixing REQ-INSTALL-11's copy-mode psyche edge); args.manifest = Some keeps the explicit-path override (install_dir = its parent). Backward-compatible: an unregistered/dev adapter with no --manifest still yields manifest=None (registry miss), un…\n\r\n# ───────────────────────────── Startup flows ────────────────────────────────\r\n[[requirements]]\r\nid = \"REQ-START-1\"\r\ntitle = \"Adapters never resolve SPT_HOME; binary on PATH; api bridging only\"\r\nrequired_stages = [\"impl\", \"unit\"]\r\n[[requirements]]\r\nid = \"REQ-START-2\"\r\ntitle = \"Harness-hosted startup: api seed then listen\"\r\nrequired_stages = [\"impl\", \"unit\", \"int\"]\r\n[[requirements]]\r\nid = \"REQ-START-3\"\r\ntitle = \"spt-hosted startup: spawn-session then api bind (no file)\"\r\nrequired_stages = [\"impl\", \"unit\", \"int\"]\r\n[[requirements]]\r\nid = \"REQ-START-4\"\r\ntitle = \"Adapter-injected env aliases (SPT/OWL/LIVE)\"\r\nrequired_stages = [\"impl\", \"unit\"]\r\n[[requirements]]\r\nid = \"REQ-START-5\"\r\ntitle = \"Adapter-agnostic harness-hosted seed + bind-time adapter/profile resolution (ADR-0021): `api seed` carries only parent_pid + session_id (+ optional cwd), no --adapter — a pure \\\"a harness session exists at this pid\\\" record; --adapter becomes an OPTIONAL override across the whole api group (an explicit name[:profile] for adapter dev, never required). Omitted, listen/poll resolve the owning adapter/profile AT BIND as a pure read against the live registry — never a seed-time snapshot that can drift: seed parent_pid → exe basename → host_binaries candidate set (REQ-MANIFEST-8) → active-profile pointer (REQ-INSTALL-12) primary, else greatest-registered_at_ms candidate base profile (name-asc tie) → friendly zero-match error. Covers BOTH LiveAgent (listen…\nrequired_stages = [\"doc\", \"impl\", \"unit\", \"int\"]  # activated v0.9.0 W1-W2 (LiveAgent/listen): doc = CONTEXT.md §171/§177-181 (adapter-agnostic seed + bind-time resolution) + docs-site harness-contract/api.md + docs/MANIFEST.md SessionStart hook. impl = adapter-agnostic Seed (seed.rs drops adapter) + cmd_seed + --adapter Option across the api group (api/mod.rs ApiArgs/Ctx/resolve_ctx_manifest) + bind-time resolution in cmd_listen (explicit override else resolve::resolve_bind_adapter) feeding both the info.json adapter stamp (bind_from_seed) and the live_capable manifest. unit = adapter_is_optional (api/mod) + resolve_from_basename/pointer tests (spt-runtime resolve). int (agnostic-seed listen bringup E2E) activates at the final LiveAgent wave. RESIDUAL (doyl…\n\r\n# ───────────────────────────── Endpoints / Shells ───────────────────────────\r\n[[requirements]]\r\nid = \"REQ-EP-1\"\r\ntitle = \"Day-one endpoint types; open type system\"\r\nrequired_stages = [\"impl\", \"unit\"]  # activated M0: spt-proto endpoint taxonomy (T4)\r\n[[requirements]]\r\nid = \"REQ-EP-2\"\r\ntitle = \"Agent endpoints vs Shells distinction in the type model\"\r\nrequired_stages = [\"impl\", \"unit\"]  # activated M0: spt-proto endpoint taxonomy (T4)\r\n[[requirements]]\r\nid = \"REQ-EP-3\"\r\ntitle = \"Messaging payloads carry typed operation commands + file blobs\"\r\nrequired_stages = [\"impl\", \"unit\"]  # activated M0: spt-proto payload model (T7)\r\n[[requirements]]\r\nid = \"REQ-EP-4\"\r\ntitle = \"PresenceChannel broker endpoint (seam day-one)\"\r\nrequired_stages = [\"impl\", \"unit\"]  # activated M4-D4c: the broker-owned PresenceLog — a seq'd ring of {connected|disconnected, conn_id, remote_id_hex} events mirroring the StreamLog discipline. register_conn appends connected + spawns a closed-watcher (conn.closed() → conn row removed → disconnected appended), so the conn table finally reflects liveness; the brain subscribes via net-presence-subscribe {from_seq} and consumes net-presence-event under the same contiguous/dedup/gap cursor discipline as streams (brain-restart cursor-resume). The full PresenceChannel *endpoint* (agent-visible channel) builds on this broker seam later; int = two-host at D9.\r\n[[requirements]]\r\nid = \"REQ-EP-5\"\r\ntitle = \"Concrete shell instantiation model: spawn-mints-instance (vs relink/online), registered-on-node permission + broadcast-is-discovery, per-shell require_approval gate, max_instances_per_owner + over_cap, instance aliasing, discovery scope\"\r\nrequired_stages = [\"impl\", \"unit\", \"int\"]  # activated M5-D3a; int activated M5-D9b: the D3e shell E2E + the real-shell E2E (notify_shell_e2e.rs, CI-hook-driven against the standalone spt-shell-notify adapter) carry the int tags\r\n[[requirements]]\r\nid = \"REQ-EP-6\"\r\ntitle = \"Gateway type acceptance: a Gateway-typed perch binds (api bind --type, open type system — un-hardcode the live_agent default), advertises/addressable like any endpoint, owns shells (owner validation not agent-family-gated), subscribes to digests, and is the user-msg identity gate's user-backed origin (REQ-MSG-5); in-tree mock-gateway fixture (R-DOCS-2 pattern, no downstream adapter code). Cross-node WAN Gateway-origin (registry endpoint_type trust) tracked by REQ-MSG-6\"\r\nrequired_stages = [\"doc\", \"impl\", \"unit\"]  # activated 2026-06-13: M9-T5 (CONTEXT.md §Gateway; api bind --type un-hardcode at establish_perch; gateway-owns-shell + local user-backed-origin E2E via mock-gateway fixture; the WAN fail-closed row tested at the receive_wan funnel)\r\n[[requirements]]\r\nid = \"REQ-EP-7\"\r\ntitle = \"Durable live-role.md: a per-agent broad-purpose statement in tracked/agents/<id>/ beside live-context.md (replicates with the mind on the same a-<id> branch); renders FIRST at start-transition context injection (role -> live-context -> project-context); SOLE writer `spt endpoint role --overwrite <file>` — mechanical no-automated-writer guarantee (echo-commune ingest / signoff / Psyche reconcile structurally exclude it). The user-backed-origin hard gate on the writer is a deferred later tightening (rides the user-msg identity plumbing)\"\r\nrequired_stages = [\"doc\", \"impl\", \"unit\"]  # activated 2026-06-13: M9-T6 (CONTEXT.md §live role; contextstore live_role_file/live_role_path seam; resume::download_psyche_context role-first injection order; spt endpoint role print/--overwrite sole writer; injection-order + no-automated-writer tests; M9-WAVE2-ROLE-WRITERS.md survey)\r\n# ── Always-on endpoints (ADR-0023, ratified 2026-06-21; first consumer downstream spt-discord) ──\r\n[[requirements]]\r\nid = \"REQ-EP-8\"\r\ntitle = \"AlwaysOnEndpoint: a resident, addressable, mindless endpoint whose adapter binary the daemon supervises continuously — register-triggered by an adapter-option's `[always-on]` manifest section, one supervised binary per `<adapter>[:profile]`, running independent of agent liveness. It self-manages its `#`-addressed channel endpoints via the existing `api bind` (one connection fronts many). The SECOND class of spt-core-boot-launched third-party binary (after the shell wake-watcher); supervision reuses the wake-watcher scaffolding (backoff / give-up latch / one-per-instance lock / orphan-kill / brain-side reconcile) MINUS the offline-only flip — always online, never resting (no dormant/suspended states). Two-way: agents message it; it may call `endpoint…\nrequired_stages = []  # rule 5: design ratified 2026-06-21 (grill-with-docs, ADR-0023); SPLIT 2026-07-26 (ADR-0049): the process substrate (supervision, [service] manifest, hold/quiesce, cardinality, derived liveness) moved to REQ-RESIDENT-SERVICE — this req retains ONLY the endpoint layer (api bind channel fronting, wake verb, addressable identity) and builds ON that substrate; the [always-on] manifest section is superseded by [service] + the endpoint-layer declaration. UNBUILT — activate when the milestone delivering the endpoint layer lands; first consumer is the downstream spt-discord adapter (own repo).\r\n[[requirements]]\r\nid = \"REQ-EP-9\"\r\ntitle = \"`#` always-on address sigil: a reserved LEADING sigil marking an AlwaysOnEndpoint, extending the REQ-INST-10 grammar to `[subnet:]#id[@node]`. Mandatory + bijective — `#name` ⟺ always-on endpoint, bare `name` ⟺ agent endpoint — so the router resolves endpoint class from the address alone, before any registry lookup. Sits ABOVE REQ-HAZARD-ID-CHARSET: the address parser strips the single leading `#` before id validation, so the bare/stored id stays charset-clean and a mid-id `#` remains rejected (the charset contract is unchanged).\"\r\nrequired_stages = []  # rule 5: design ratified 2026-06-21 (ADR-0023); UNBUILT — activate with REQ-EP-8's milestone (spt_proto::addr::Address::parse sigil extension + class-discriminated resolve in spt_net registry). REQ-HAZARD-ID-CHARSET is NOT amended (sigil is an address-layer concern).\r\n\r\n# ───────────────────────────── Instances ────────────────────────────────────\r\n[[requirements]]\r\nid = \"REQ-INST-1\"\r\ntitle = \"endpoint ID vs instance split (adapter-agnostic ID)\"\r\nrequired_stages = []  # rule 5: the data-model split shipped across M4 D3 (registry Instance rows) + D9-5 (home/adapter on the endpoint record); this umbrella req activates when instantiate-anywhere makes the split user-facing (instance minting) — which DEFERS PAST M5 (user decision 2026-06-04, M5-PLAN scope decision 2: consent framework seam only); its concrete slices are tracked by REQ-INST-7..15\r\n[[requirements]]\r\nid = \"REQ-INST-2\"\r\ntitle = \"Per-node files, synced Psyche mind\"\r\nrequired_stages = [\"impl\", \"unit\"]  # activated M4-D6a: the mind now lives in the tracked/ two-tier store (spt-store::contextstore over BranchStore), physically separate from never-synced node-local perches/ — spt_live::ingest::route_slices routes commune drops' <live-context>/<project-context> slices into a-<id>/p-<project_id> branch files (untagged body → live, the parser fallback; precedence-guarded 6.5; checkpoint commit per written tier); signoff::write_resume_commune stamps Self provenance per slice through the same router; resume::download_psyche_context composes the two tiers back into the two-slice envelope (pure read, never mints store state; other-project download sees live only). Daemon lifecycle derives project_id from the commune dir's parent (…\n[[requirements]]\r\nid = \"REQ-INST-3\"\r\ntitle = \"Dormant (warm) / suspended (cold) resting states\"\r\nrequired_stages = [\"doc\", \"impl\", \"unit\"]  # doc activated M4-D9-3: docs/DORMANCY-BUDGET.md — the measured warm/cold policy lock (ADR-0003 #9 closed: warm default confirmed, zero idle CPU, RSS-only cost; auto-suspend opt-in default OFF). impl/unit activated M4-D9-2-1: spt-daemon::resting — the explicit daemon-owned state machine (pure transition table: detach/attention-shift rest warm, manual suspend rests cold from either live state, opt-in auto-suspend counted from the dormancy-onset anchor, wake re-activates in place; no idle timer by design) + durable info.json rest_state/dormant_since_ms record (anchor moves atomically with the state) + the global→node→endpoint auto-suspend knob chain default OFF (daemon.json node leg, info.json endpoint leg, 0 = explic…\n[[requirements]]\r\nid = \"REQ-INST-4\"\r\ntitle = \"active to dormant/suspended fires a transition echo commune\"\r\nrequired_stages = [\"impl\", \"unit\"]  # activated M4-D9-2-2: resting::apply_event fires the on_rest_edge echo hook at every active→(dormant|suspended) edge BEFORE the flip persists (KH 3.3 echo-before-teardown ordering; echo failure is loud but never wedges the instance active — the signoff_with posture); BrainLifecycle::rest_event binds the hook to the real run_echo_commune machinery (fire_echo), so the final context delta lands in the commune drop dir and syncs to whichever instance activates next. Exactly-once per edge by the pure table's idempotence (replayed events are no-edges); resting→resting fires nothing. unit = echo-once-per-edge (pure + real-summarizer lifecycle e2e) + loud-failure-persists + no-echo-on-suspend-from-dormant\r\n[[requirements]]\r\nid = \"REQ-INST-5\"\r\ntitle = \"Two-tier context sync (live to all, project to same-project)\"\r\nrequired_stages = [\"impl\", \"unit\", \"int\"]  # activated M4-D6c-2: scoping is the pull model + the server-side gate (ADR-0013 — requester names a-<id>/p-<project> refs, no new registry fields). spt-daemon::sync::SyncPolicy.allows: a-<id> served only when ∃ subnet S with the handshake-proven origin pinned in the trust store AND synced(id,S) (the D3e gate finally consumed — membership list + visibility, hidden ⟹ not synced, fail-closed on unconfigured lists); p-<project> only to a trusted origin for a hosted project (honest residual: p- tier is branch-granular, a hidden endpoint's per-project slice rides a hosted project's branch — per-file p- filtering post-v1); machinery refs never served. select_refs mirrors the gate requester-side (symmetric scoping). Server…\n[[requirements]]\r\nid = \"REQ-INST-6\"\r\ntitle = \"Deferred messages not delivered to dormant/suspended instances\"\r\nrequired_stages = [\"impl\", \"unit\", \"int\"]  # activated M5-D5a/b: the gate is state-keyed at the hook-channel consumer sites — resting::deferred_held reads the owner's durable rest record at drain time; \"held\" = (deferred=1) ∧ (Dormant|Suspended); poll_drain + cmd_worker_poll gated (the KH 1.4 \"all sites agree\" sweep — shellchan peek ungated: no deferred writer targets shell perches; ring's ephemeral perch recordless by construction = legacy fallback). Release is by construction at the wake edge: the persisted Active state stops the narrowing, the unchanged delivered-mark discipline keeps it exactly-once. D5b adds the remote arm: StreamFamily::Rest + resthost request/serve (remote-drive trust class — access_check on the handshake-proven origin, no grant gate;…\n[[requirements]]\r\nid = \"REQ-INST-7\"\r\ntitle = \"Subnet registry + bare-id resolution policy\"\r\nrequired_stages = [\"impl\", \"unit\", \"int\"]  # activated M4-D3a: spt-net::net::registry — SubnetRegistry { endpoint_id -> [Instance{node,status,epoch}] }, Status{Active,Dormant,Offline}, serde-roundtrippable, merge_instance keeps distinct-node instances as separate rows. The bare-id *resolution policy* (local -> most-recently-active -> id@node, ambiguity refuses) is D3c (REQ-INST-10). Cross-node replication delivered M4-D4d: spt-net::net::replicate — RegistryUpdate {subnet,endpoint_id,Instance} as NDJSON over broker-owned QUIC streams (framing survives chunk coalescing/splitting; corrupt lines skipped, 4.3 posture), apply_update routes by subnet into merge_instance and drops non-member subnets fail-closed (REQ-INST-13 posture); advertise gates (D3d/D3e) run lo…\n[[requirements]]\r\nid = \"REQ-INST-8\"\r\ntitle = \"Remote-control mode distinct from local operation\"\r\nrequired_stages = [\"impl\", \"unit\", \"int\"]  # activated M4-D5b: AttachRecord wire protocol (spt-net::net::attach) + spt-daemon::attach serve/request/input — a byte-stream viewport onto a remote session over broker QUIC streams; compute+files stay on the target node. tests/attach.rs drives a real PTY child on daemon A from daemon B and proves restart survival (worst-case seq-0 re-serve, all journals dedup). int activated M4-D9-6: two-host rig E2E green (tests/twohost.rs — A types into B's live PTY across the rig and reads the echo back)\r\n# ── Multi-subnet membership model (ADR-0006, 2026-06-01; activate M4) ──\r\n[[requirements]]\r\nid = \"REQ-INST-9\"\r\ntitle = \"Multi-subnet membership (same-user N subnets; cross-user seam)\"\r\nrequired_stages = [\"impl\", \"unit\"]  # activated M4-D3d: SubnetStore already carries N seeds (D2c/f); D3d adds the join-time bare-id collision check — spt_net::net::registry::SubnetRegistry::join_endpoint refuses (JoinCollision, registry untouched) when a joining endpoint id is already held by a DIFFERENT node in the target subnet (Offline rows still own the name); same-node re-advertise falls through to the merge_instance epoch lease. Per-subnet scope: the same bare name in different subnets is legal (resolver forces qualification, REQ-INST-10). Same-user only; cross-user seam = registry key generalizes to (subnet,user) per ADR-0006, check rides along unchanged. unit = fresh-join insert + other-node refuse-untouched + offline-holder collide + same-node lease…\n[[requirements]]\r\nid = \"REQ-INST-10\"\r\ntitle = \"Qualified addressing [subnet:]id[@node] + ambiguity forces qualification\"\r\nrequired_stages = [\"impl\", \"unit\"]  # activated M4-D3c: spt_proto::addr::Address::parse (grammar, reserved :/@ → unambiguous split, each component charset-validated) + spt_net::net::registry::resolve/resolve_across (policy: @node exact → local wins → sole live → REFUSE on multiple live NODES [AcrossNodes, force @node]; Offline never routed). Subnet is NEVER a disambiguation axis (ADR-0006 §1: identity node-global) — the unqualified cross-subnet form UNIONs instances and DEDUPEs by node (same (id,node) in N subnets = ONE endpoint; freshest epoch among twins), then applies the single-registry policy; a `subnet:` qualifier still routes straight to one subnet (counter-39 bug #1: removed the false AcrossSubnets refusal). Per-node epochs are NOT cross-node compara…\n[[requirements]]\r\nid = \"REQ-INST-11\"\r\ntitle = \"spt rename <id> rippled to all instances (collision-checked, 6.5-reconciled)\"\r\nrequired_stages = [\"impl\", \"unit\"]  # activated M4-D3f: three local arms. (1) registry rows: spt_net::net::registry::rename_endpoint — collision-checked against EVERY subnet the endpoint is advertised into FIRST (any row, any status holds the name), then moved all-or-nothing; rows verbatim (relabel, not liveness event); new id charset-validated (:/@ reserved). (2) perch dirs: spt_store::rename::rename_perch_in — Self dir + nested <id>-psyche/<id>-w{N} children + every info.json id; refuses while any perch in the tree is live; local SQLite registry rows deliberately untouched (live refuses ⇒ old rows stale ⇒ 4.3 stale-clean drops). (3) CLI `spt rename <old> <new>` wires the perch arm. Seams: daemon-held registry wiring = D4; a-<id> context branch = D6 context…\n[[requirements]]\r\nid = \"REQ-INST-12\"\r\ntitle = \"Endpoint visibility per-(endpoint,subnet): excluded semantics, OR-of-defaults + override, gates sync\"\r\nrequired_stages = [\"impl\", \"unit\"]  # activated M4-D3e: spt_store::visibility::VisibilityStore (identity/visibility.json, atomic, degrade-safe) — hidden(E,S,flag) = per-(E,S) override wins both directions, else S.hide_new_endpoints OR E.default_hide (both ship OFF). SubnetRecord.hide_new_endpoints serde-defaulted (legacy subnet.json loads OFF). Enforcement in spt_net::net::registry: excluded = not advertised (advertise_if_visible gates BEFORE the collision check — hidden neither claims nor clashes) AND not routable (resolve_visible/resolve_across_visible refuse even @node pins; exclusion prunes the cross-subnet ambiguity set). unit = OR-of-defaults + override-both-directions + persist/degrade + legacy-load + not-routable + ambiguity-prune + advertise-gate.\r\n[[requirements]]\r\nid = \"REQ-INST-13\"\r\ntitle = \"Subnet-exclusive sync + per-endpoint subnet-membership list\"\r\nrequired_stages = [\"impl\", \"unit\"]  # activated M4-D3e: VisibilityStore.sync_subnets per-endpoint membership list + synced(E,S,flag) — the D6 replication gate: subnet must be on the list (unconfigured list syncs NOWHERE, fail-closed; creation flow seeds [home] per ADR-0010) AND visibility gates sync (hidden ⟹ not synced even when listed, ADR-0006 §6). Actual mind replication consuming this gate = D6. unit = listed-visible-syncs + unlisted/unconfigured-no + hidden-gates-sync (defaults + override paths).\r\n[[requirements]]\r\nid = \"REQ-INST-14\"\r\ntitle = \"Resource advertisement (subnet resource registry): free-text blurb, both-authored, registry projection, visibility/whitelist-gated\"\r\nrequired_stages = [\"doc\", \"impl\", \"unit\"]  # activated M4-D9-4: resources: Option<String> on the registry Instance (additive serde-default, rides RegistryUpdate replication + the epoch lease — a blurb edit is an ordinary epoch-bumped update, NO new record kind/store/merge surface) + resource_projection (the (id,node,status,blurb) yellow-pages view over VISIBLE+routable rows — same exclusion closure as resolution, ADR-0006 §6 leak-free by construction) + both-authored sourcing (info.json resources via spt resources set wins; daemon.json resources_blurb node seed fills the gap) + spt resources set/show/list CLI off the stale-tolerant registry snapshots. The whitelist leg of the gate (viewer-node filtering) rides the deferred consent/whitelist generalization — …\n[[requirements]]\r\nid = \"REQ-INST-15\"\r\ntitle = \"Immutable home subnet (assigned at creation: auto-if-one/ask-if-many) + spt fork (cross-subnet clone to a new identity, copy-then-diverge, not re-home); adapter chosen at creation from registered hostable adapters, changed only via launch/resume-under-new (ADR-0010)\"\r\nrequired_stages = [\"doc\", \"impl\", \"unit\"]  # activated M4-D9-5: spt-store::home (assign_home matrix: sole auto / several refuse-and-qualify / unpaired local-only; stamp_creation_fields = the ONE creation seam shared by spt ready + api listen/bind — NEW assigns hard + seeds sync_subnets=[home], REVIVE carries home/adapter/blurb/knobs forward so a re-bind never wipes them; adopt_for_unset = first-join adoption, sole-subnet only, never re-homes; NO setter — immutability by construction) + info.json home_subnet/adapter + ContextStore::fork_endpoint (one-time copy of live+project tiers as parentless seed commits — copied-then-diverged, no shared history) / remove_endpoint (--delete-source, exactly the source) + spt fork CLI (refusal-first gates: same-id, target m…\n[[requirements]]\r\nid = \"REQ-REACH-1\"\r\ntitle = \"Off-node remote-drive detection. THE FILE-TRANSFER HALF IS STRUCK (ADR-0060, releases#246, doyle-ruled 2026-09-07): the WAN transfer family it named is retired -- see REQ-XFER-RETIRED -- so this requirement now covers detection only. What survives is what never depended on the transport: the daemon-side fact of WHICH node is driving an endpoint's session, stamped by the serve path and read back by the endpoint. The int stage stands on the remote-drive cell in tests/dispatch.rs; the transfer int arms left with the family they covered.\"\r\nrequired_stages = [\"impl\", \"unit\", \"int\"]  # int activated M4-D9-6: two-host rig E2E green (tests/twohost.rs — file fetch off B + push back to B across the rig; chunked xfer through the dispatcher gate)\r\n[[requirements]]\r\nid = \"REQ-REACH-2\"\r\ntitle = \"Remote command execution (deferred, consent-gated)\"\r\nrequired_stages = []  # rule 5: deferred by design (PRD: consent-gated remote exec is the highest-risk capability; workarounds exist) — M5+ with the instantiate-anywhere consent model\r\n\r\n# ───────────────────────────── Local messaging (M1) ─────────────────────────\r\n# No PRD R-* covers local message delivery (foundational/implicit). Registered\r\n# at M1 start per TRACEABILITY rule 3 (precedent: REQ-NODE-IDENTITY at M0 T5).\r\n# Inactive until the M1 task that delivers each lands (rule 5: activate, don't pre-fail).\r\n[[requirements]]\r\nid = \"REQ-MSG-1\"\r","truncated":true,"truncatedBy":"lines","totalLines":7800,"totalBytes":42456,"outputLines":300,"outputBytes":42456,"lastLinePartial":false,"firstLineExceedsLimit":false},"totalLines":7800,"displayContent":{"text":"# traceable-reqs manifest for spt-core — SEED.\r\n#\r\n# Authoritative requirement registry: every PRD R-* requirement and every\r\n# KNOWN-HAZARDS invariant lives here as a REQ-* id (see docs/TRACEABILITY.md\r\n# for the development contract).\r\n#\r\n# ACTIVATION MODEL: every requirement starts inactive (`required_stages = []`)\r\n# so `check` stays green pre-code. A milestone (M0-M5) ACTIVATES its requirements\r\n# by setting their real `required_stages`. Validate this seed against the\r\n# installed CLI at M0 (the binary isn't installed yet).\r\n\r\n[scan]\r\n# CONTEXT.md is a file root: the glossary is authoritative for meaning and\r\n# carries doc-stage tags (e.g. §Installation). installer/ added M6-D2.\r\nroots = [\"src\", \"tests\", \"docs\", \"crates\", \"installer\", \"CONTEXT.md\", \"docs-site\", \".github\"]\r\n\r\n[policy]\r\n# Default policy applied at activation: doc + impl + unit. Networking/lifecycle\r\n# reqs add \"int\". Until a req is activated, its per-req `required_stages = []`\r\n# keeps it out of the coverage gate.\r\nrequired_stages = []\r\n\r\n[placement]\r\nenforce = \"on\"\r\nmodule_banner = \"accept\"\r\n\r\n[[requirements]]\r\nid = \"REQ-STATUS-LIVE-SUPERVISOR-PID\"\r\ntitle = \"Service status obtains the running supervisor PID from the live service, reports a disagreeing daemon.pid as stale, and never promotes an unverified file PID to running\"\r\nrequired_stages = [\"doc\", \"impl\", \"unit\", \"int\"]\r\n\r\n# ───────────────────────────── Architecture / workspace ─────────────────────\r\n[[requirements]]\r\nid = \"REQ-ARCH-1\"\r\ntitle = \"Many small acyclically-layered crates\"\r\nrequired_stages = [\"impl\"]  # activated M0: spt-proto<-spt-store one-way layering (T14). impl-only: architecture is build-evidenced (cargo rejects cycles); grows as crates are added M1+.\r\n[[requirements]]\r\nid = \"REQ-ARCH-2\"\r\ntitle = \"Public SDK surface is spt-proto, spt-runtime, spt-msg\"\r\nrequired_stages = [\"impl\"]\r\n[[requirements]]\r\nid = \"REQ-ARCH-3\"\r\ntitle = \"Wire-protocol version independent of crate semver, N-1 compat window\"\r\nrequired_stages = [\"impl\", \"unit\"]  # activated M0: spt-proto wire version + compat window (T6)\r\n[[requirements]]\r\nid = \"REQ-ARCH-4\"\r\ntitle = \"Copy-verbatim the commodity layer from the sister project\"\r\nrequired_stages = [\"impl\", \"unit\"]  # activated M0: spt-proto EVENT grammar (T2)\r\n\r\n# ───────────────────────────── Daemon ───────────────────────────────────────\r\n[[requirements]]\r\nid = \"REQ-DAEMON-1\"\r\ntitle = \"One per-machine spt-daemon owning all per-machine state\"\r\nrequired_stages = [\"impl\", \"unit\", \"int\"]  # int activated M3b-B9: the daemon E2E (spawn→Psyche-loop→commune→brain-restart-survives→graceful-signoff) drives the consolidated daemon-hosted lifecycle through the real IPC\r\n[[requirements]]\r\nid = \"REQ-DAEMON-2\"\r\ntitle = \"Broker/brain split for seamless self-update\"\r\nrequired_stages = [\"impl\", \"unit\", \"int\"]  # int RE-POINTED at restoration D7-1 (ADR-0018 V5) to the PROCESS-level survival E2E (crates/spt/tests/brain_survive.rs: broker/brain split delivers the seamless update at the process level — a brain-PROCESS restart onto a swapped binary, PTY child + QUIC conn held). Was M3b-B9 daemon_e2e + handoff.rs (the in-process handoff shape — regression-masked per ADR-0018); those tags removed in the same D7-1 commit. The net-ownership facet (netbroker.rs) stays distinct evidence\r\n[[requirements]]\r\nid = \"REQ-DAEMON-3\"\r\ntitle = \"Any api invocation auto-starts the daemon if absent\"\r\nrequired_stages = [\"impl\", \"unit\", \"int\"]\r\n[[requirements]]\r\nid = \"REQ-DAEMON-4\"\r\ntitle = \"Honor every KNOWN-HAZARDS invariant\"\r\nrequired_stages = [\"impl\", \"unit\", \"int\"]  # int activated M3b-B9: the daemon E2E proves graceful signoff runs the echo-before-teardown ordering (3.3) and removes the Self's ready marker in the consolidated daemon\r\n\r\n# ───────────────────────────── Storage ──────────────────────────────────────\r\n# git-KV BranchStore substrate (ADR-0011, 2026-06-02; implement M4, optional M3c)\r\n[[requirements]]\r\nid = \"REQ-STORE-1\"\r\ntitle = \"spt-store::BranchStore (git branch as versioned KV; commit=checkpoint/tip=resume, atomic multi-key, merge-native sync) is the substrate for coarse/durable/audited state (context, registry snapshot+distribution, daemon checkpoint); hot paths (B5 fsync journal) + indexed queries (SQLite spool) excluded (ADR-0011)\"\r\nrequired_stages = [\"impl\", \"unit\"]  # activated M4-D6a: spt_store::branchstore::BranchStore — bare seed repo + linked worktrees (relative-path config, repair fallback), ensure_branch = parentless empty-tree seed (independent KV roots; sync joins with --allow-unrelated-histories at first contact), commit_in_worktree = ONE commit per write incl. atomic multi-key (no-op mints no empty checkpoint), tip/read_at_tip = the recovery read. Git-CLI-backed per ADR-0013 (bounded runner gitrun.rs, hazard 5.3; identity pinned via env, never user config). First consumer: contextstore.rs two-tier tracked/ layout (a-<id>/p-<project> branches) + project.rs project_id derivation (remote-URL slug → toplevel folder → dir name). unit = init-idempotent + commit/read/tip + no-op-no…\n\r\n# ───────────────────────────── Harness contract — manifest seams ────────────\r\n[[requirements]]\r\nid = \"REQ-MANIFEST-1\"\r\ntitle = \"Per-adapter manifest with adapter_name and min_spt_core_version\"\r\nrequired_stages = [\"doc\", \"impl\", \"unit\"]\r\n[[requirements]]\r\nid = \"REQ-MANIFEST-2\"\r\ntitle = \"Adapter profiles — sparse leaf-replace overlays (shipped + local), composite <adapter>:<profile> addressing, shadow-refusal, tighten-only consent floors\"\r\nrequired_stages = [\"doc\", \"impl\", \"unit\"]\r\n[[requirements]]\r\nid = \"REQ-MANIFEST-3\"\r\ntitle = \"Adapter strings — [strings] KV tree, dot-path get-string resolving through the profile leaf-replace overlay, set-string editing a local profile's [strings] only; data-only (nothing executes a string)\"\r\nrequired_stages = [\"doc\", \"impl\", \"unit\"]  # activated 2026-06-12: M9-T2 (CONTEXT.md §adapter strings, adapter-string get/set CLI + overlay resolution)\r\n[[requirements]]\r\nid = \"REQ-MANIFEST-4\"\r\ntitle = \"Keyword hints — [[hints]] {keywords (literal/regex), text}; spt api hint --session emits at most one matched hint per message, once per session (seen-set), declaration-order first match; profiles overlay [[hints]] by leaf-replace\"\r\nrequired_stages = [\"doc\", \"impl\", \"unit\"]  # activated 2026-06-12: M9-T3 (CONTEXT.md §keyword hints, api hint surface + per-session seen-set)\r\n[[requirements]]\r\nid = \"REQ-MANIFEST-5\"\r\ntitle = \"File-backed adapter [strings] (M12-W3-T3.1): a [strings] dot-path value MAY be an inline-table FILE POINTER `key = { file = \\\"rel/path\\\" }` resolved to the file's contents at get-string time, keeping large bodies (skill-instructions, hint text) out of the manifest. A value-position table with a `file` key IS the pointer form (reserved — cannot double as data). Per-adapter aux storage `adapters/<adapter>/strings/`; pointers resolve relative to it with CONTAINMENT (reject `..`/absolute escaping the dir). UPDATE-SAFETY: a LOCAL profile's file-pointers resolve relative to the user-owned local-profile dir (NOT adapter-shipped strings/, which adapter updates overwrite), or the local profile inlines. Validate-at-register (fail-fast on a bad/escaping/missin…\nrequired_stages = [\"doc\", \"impl\", \"unit\"]  # activated M12-W3\r\n[[requirements]]\r\nid = \"REQ-MANIFEST-6\"\r\ntitle = \"Cross-adapter fallback target addressing (M12-W3-T3.2): a cross-adapter fallback target is addressed as `<adapter>:<profile>` (not just a bare adapter_name), resolved through the one composite-addressing resolver (registry::resolve_option) at every adapter-option read site so a fallback may select a shipped/local profile (e.g. a `ccs` profile). CONTEXT.md §cross-adapter-fallback reconciled (\\\"ccs is a profile; cross-adapter fallback may target <adapter>:<profile>\\\"). Contract-only this milestone: the node-wide fallback SETTING + its rate-limit invocation are deferred to the consuming milestone (the runtime path does not exist yet); this REQ guarantees the ADDRESSING resolves.\"\r\nrequired_stages = [\"doc\", \"unit\"]  # activated M12-W3 (contract-only: no int)\r\n[[requirements]]\r\nid = \"REQ-MANIFEST-7\"\r\ntitle = \"Adapter-declared shortcut basename (M12-W2 follow-on): an optional `[adapter] shortcut_basename` manifest field names the basename the picker bakes into the generated `<basename>-<id>` launcher shortcut (REQ-RUN-SHORTCUT). Absent ⇒ the harness-agnostic default `spt` (→ `spt-<id>`); an adapter sets it to brand its shortcuts (claude-spt → `cc` → `cc-<id>`), so the Claude-Code-ness lives in the PUBLISHED adapter manifest, never hardcoded in spt-core. The picker reads it from the RESOLVED manifest of the selected adapter (registry::resolve_option), falling back to `spt` when absent/empty/unresolvable. Additive + N-1-safe (serde-default Option, omitted from serialization when absent; old manifests parse clean); manifest.schema.json regenerated from the d…\nrequired_stages = [\"doc\", \"impl\", \"unit\"]  # activated M12-W2 follow-on\r\n[[requirements]]\r\nid = \"REQ-MANIFEST-8\"\r\ntitle = \"[adapter] host_binaries declares the harness executable basenames a kind=\\\"harness\\\" adapter hosts agents inside (e.g. host_binaries = [\\\"claude\\\"]); bind-time pid→exe-basename match (case-insensitive, .exe-stripped) over the seed's parent_pid selects the candidate adapter set; zero matches → a friendly error naming the binary + the --adapter escape hatch. Additive + N-1-safe: optional Vec<String>, #[serde(default, skip_serializing_if = \\\"Vec::is_empty\\\")] (omitted-serialized like shortcut_basename, old manifests parse clean); manifest.schema.json regenerated from the derive (ADR-0001, CI drift-gated). The match-key for ADR-0021 adapter-agnostic bind-time resolution. (v0.9.0)\"\r\nrequired_stages = [\"doc\", \"impl\", \"unit\", \"int\"]  # activated v0.9.0 W2: doc = CONTEXT.md §179 host_binaries candidate rule + docs/MANIFEST.md + docs-site harness-contract host_binaries field. impl = the `[adapter] host_binaries` field (manifest.rs, serde-default omit-empty) + spt_store::proc::exe_basename (Win QueryFullProcessImageNameW / Linux /proc/pid/exe) + the resolver's host_binaries candidate filter (spt-runtime resolve). unit = host_binaries_optional_and_n1_safe (manifest) + exe_basename_resolves_current_process/dead_pid_is_none (proc) + no_candidate/normalize tests (resolve). int folds into REQ-START-5's final bringup E2E\r\n[[requirements]]\r\nid = \"REQ-SEAM-SPAWN\"\r\ntitle = \"spawn-session seam\"\r\nrequired_stages = [\"impl\", \"unit\"]\r\n[[requirements]]\r\nid = \"REQ-SEAM-POSTSPAWN\"\r\ntitle = \"post-spawn / api bind seam with boot nonce\"\r\nrequired_stages = [\"impl\", \"unit\"]\r\n[[requirements]]\r\nid = \"REQ-SEAM-PSYCHE\"\r\ntitle = \"spawn-psyche seam (fresh + resume templates)\"\r\nrequired_stages = [\"impl\", \"unit\", \"int\"]\r\n[[requirements]]\r\nid = \"REQ-SEAM-HISTORY\"\r\ntitle = \"History subsystem (fetcher / locate-normalize / native store)\"\r\nrequired_stages = [\"impl\", \"unit\", \"int\"]\r\n[[requirements]]\r\nid = \"REQ-SEAM-ACTIVITY\"\r\ntitle = \"Activity/idle reported via api sentinels, not PTY quiescence\"\r\nrequired_stages = [\"impl\", \"unit\"]\r\n[[requirements]]\r\nid = \"REQ-SEAM-INJECT\"\r\ntitle = \"inject-input methods configurable per activity-state\"\r\nrequired_stages = [\"impl\", \"unit\"]\r\n[[requirements]]\r\nid = \"REQ-SEAM-RESUME\"\r\ntitle = \"resume-session seam (fresh-with-preload / continue-existing)\"\r\nrequired_stages = []  # SUPERSEDED 2026-07-04 (F-030 W5, todlando) by REQ-PSYCHE-EPHEMERAL-DRIVER (+ REQ-PSYCHE-SID-CUSTODY + REQ-PSYCHE-CONTEXT-FILE-INDIRECTION). The standalone resume_psyche(ResumeMode) spawn seam is retired dead-code (ZERO production callers post-W3 — the driver is psyche_turn_and_relay → run_psyche_turn; resume_psyche + its 3 units are DELETED with the .live-bin machinery). The fresh-with-preload / continue-existing DISTINCTION it owned now lives in the per-event turn: FreshWithPreload = custody None → mint sid + NON-EMPTY {psyche_context_file} (SID-CUSTODY + CONTEXT-FILE-INDIRECTION), ContinueExisting = custody Some → the psyche's own sid + 0-byte context file. download_psyche_context + ResumeMode STAY (used by psyche_download_context /…\n[[requirements]]\r\nid = \"REQ-SEAM-CAPABILITY\"\r\ntitle = \"Hostable endpoint-types capability declaration\"\r\nrequired_stages = [\"impl\", \"unit\"]\r\n[[requirements]]\r\nid = \"REQ-SEAM-UPDATE\"\r\ntitle = \"Adapter-update avenue (file-pull / delegated command)\"\r\nrequired_stages = [\"impl\", \"unit\"]  # activated M3c-C3: manifest [update] gains signing_key (file_pull content key) + self_verifies (delegated attest); adapter_update::plan_adapter_update dispatches each avenue (file_pull verified against the adapter key, delegated delegated-or-skipped)\r\n\r\n# ───────────────────────────── Harness contract — api surface ───────────────\r\n[[requirements]]\r\nid = \"REQ-API-1\"\r\ntitle = \"api prefix and adapter_name on every machinery invocation\"\r\nrequired_stages = [\"impl\", \"unit\", \"int\"]\r\n[[requirements]]\r\nid = \"REQ-API-2\"\r\ntitle = \"The api subcommand surface (bind/listen/poll/state/worker/boundary/...)\"\r\nrequired_stages = [\"impl\", \"unit\", \"int\"]\r\n[[requirements]]\r\nid = \"REQ-API-3\"\r\ntitle = \"commune/signoff are file-drops, not commands\"\r\nrequired_stages = [\"impl\", \"unit\", \"int\"]\r\n[[requirements]]\r\nid = \"REQ-API-4\"\r\ntitle = \"api resolves the adapter manifest (+ profile + install dir) from `--adapter name:profile` via the registry when `--manifest` is omitted; `--manifest` becomes an optional OVERRIDE (unregistered / local-dev manifests). Removes the require-both-flags redundancy — a registered adapter's live bringup / digest / capability needs only `--adapter` — and yields the precise install dir (the record's source_dir) rather than the --manifest parent, closing the copy-mode psyche-binary edge (v0.8.0)\"\r\nrequired_stages = [\"doc\", \"impl\", \"unit\"]  # activate v0.8.0: doc = CONTEXT.md \"Manifest resolution from --adapter (since v0.8.0)\" §inbound-api-surface. impl = api/mod.rs run() — when args.manifest is None, resolve (record, manifest) via registry::resolve_option(perch::adapters_dir(), &args.adapter) (the same composite name:profile resolution digest.rs + the gh_release update use), setting manifest = Some + install_dir = record.source_dir (PRECISE — supersedes the --manifest-parent approximation for the resolved path, fixing REQ-INSTALL-11's copy-mode psyche edge); args.manifest = Some keeps the explicit-path override (install_dir = its parent). Backward-compatible: an unregistered/dev adapter with no --manifest still yields manifest=None (registry miss), un…\n\r\n# ───────────────────────────── Startup flows ────────────────────────────────\r\n[[requirements]]\r\nid = \"REQ-START-1\"\r\ntitle = \"Adapters never resolve SPT_HOME; binary on PATH; api bridging only\"\r\nrequired_stages = [\"impl\", \"unit\"]\r\n[[requirements]]\r\nid = \"REQ-START-2\"\r\ntitle = \"Harness-hosted startup: api seed then listen\"\r\nrequired_stages = [\"impl\", \"unit\", \"int\"]\r\n[[requirements]]\r\nid = \"REQ-START-3\"\r\ntitle = \"spt-hosted startup: spawn-session then api bind (no file)\"\r\nrequired_stages = [\"impl\", \"unit\", \"int\"]\r\n[[requirements]]\r\nid = \"REQ-START-4\"\r\ntitle = \"Adapter-injected env aliases (SPT/OWL/LIVE)\"\r\nrequired_stages = [\"impl\", \"unit\"]\r\n[[requirements]]\r\nid = \"REQ-START-5\"\r\ntitle = \"Adapter-agnostic harness-hosted seed + bind-time adapter/profile resolution (ADR-0021): `api seed` carries only parent_pid + session_id (+ optional cwd), no --adapter — a pure \\\"a harness session exists at this pid\\\" record; --adapter becomes an OPTIONAL override across the whole api group (an explicit name[:profile] for adapter dev, never required). Omitted, listen/poll resolve the owning adapter/profile AT BIND as a pure read against the live registry — never a seed-time snapshot that can drift: seed parent_pid → exe basename → host_binaries candidate set (REQ-MANIFEST-8) → active-profile pointer (REQ-INSTALL-12) primary, else greatest-registered_at_ms candidate base profile (name-asc tie) → friendly zero-match error. Covers BOTH LiveAgent (listen…\nrequired_stages = [\"doc\", \"impl\", \"unit\", \"int\"]  # activated v0.9.0 W1-W2 (LiveAgent/listen): doc = CONTEXT.md §171/§177-181 (adapter-agnostic seed + bind-time resolution) + docs-site harness-contract/api.md + docs/MANIFEST.md SessionStart hook. impl = adapter-agnostic Seed (seed.rs drops adapter) + cmd_seed + --adapter Option across the api group (api/mod.rs ApiArgs/Ctx/resolve_ctx_manifest) + bind-time resolution in cmd_listen (explicit override else resolve::resolve_bind_adapter) feeding both the info.json adapter stamp (bind_from_seed) and the live_capable manifest. unit = adapter_is_optional (api/mod) + resolve_from_basename/pointer tests (spt-runtime resolve). int (agnostic-seed listen bringup E2E) activates at the final LiveAgent wave. RESIDUAL (doyl…\n\r\n# ───────────────────────────── Endpoints / Shells ───────────────────────────\r\n[[requirements]]\r\nid = \"REQ-EP-1\"\r\ntitle = \"Day-one endpoint types; open type system\"\r\nrequired_stages = [\"impl\", \"unit\"]  # activated M0: spt-proto endpoint taxonomy (T4)\r\n[[requirements]]\r\nid = \"REQ-EP-2\"\r\ntitle = \"Agent endpoints vs Shells distinction in the type model\"\r\nrequired_stages = [\"impl\", \"unit\"]  # activated M0: spt-proto endpoint taxonomy (T4)\r\n[[requirements]]\r\nid = \"REQ-EP-3\"\r\ntitle = \"Messaging payloads carry typed operation commands + file blobs\"\r\nrequired_stages = [\"impl\", \"unit\"]  # activated M0: spt-proto payload model (T7)\r\n[[requirements]]\r\nid = \"REQ-EP-4\"\r\ntitle = \"PresenceChannel broker endpoint (seam day-one)\"\r\nrequired_stages = [\"impl\", \"unit\"]  # activated M4-D4c: the broker-owned PresenceLog — a seq'd ring of {connected|disconnected, conn_id, remote_id_hex} events mirroring the StreamLog discipline. register_conn appends connected + spawns a closed-watcher (conn.closed() → conn row removed → disconnected appended), so the conn table finally reflects liveness; the brain subscribes via net-presence-subscribe {from_seq} and consumes net-presence-event under the same contiguous/dedup/gap cursor discipline as streams (brain-restart cursor-resume). The full PresenceChannel *endpoint* (agent-visible channel) builds on this broker seam later; int = two-host at D9.\r\n[[requirements]]\r\nid = \"REQ-EP-5\"\r\ntitle = \"Concrete shell instantiation model: spawn-mints-instance (vs relink/online), registered-on-node permission + broadcast-is-discovery, per-shell require_approval gate, max_instances_per_owner + over_cap, instance aliasing, discovery scope\"\r\nrequired_stages = [\"impl\", \"unit\", \"int\"]  # activated M5-D3a; int activated M5-D9b: the D3e shell E2E + the real-shell E2E (notify_shell_e2e.rs, CI-hook-driven against the standalone spt-shell-notify adapter) carry the int tags\r\n[[requirements]]\r\nid = \"REQ-EP-6\"\r\ntitle = \"Gateway type acceptance: a Gateway-typed perch binds (api bind --type, open type system — un-hardcode the live_agent default), advertises/addressable like any endpoint, owns shells (owner validation not agent-family-gated), subscribes to digests, and is the user-msg identity gate's user-backed origin (REQ-MSG-5); in-tree mock-gateway fixture (R-DOCS-2 pattern, no downstream adapter code). Cross-node WAN Gateway-origin (registry endpoint_type trust) tracked by REQ-MSG-6\"\r\nrequired_stages = [\"doc\", \"impl\", \"unit\"]  # activated 2026-06-13: M9-T5 (CONTEXT.md §Gateway; api bind --type un-hardcode at establish_perch; gateway-owns-shell + local user-backed-origin E2E via mock-gateway fixture; the WAN fail-closed row tested at the receive_wan funnel)\r\n[[requirements]]\r\nid = \"REQ-EP-7\"\r\ntitle = \"Durable live-role.md: a per-agent broad-purpose statement in tracked/agents/<id>/ beside live-context.md (replicates with the mind on the same a-<id> branch); renders FIRST at start-transition context injection (role -> live-context -> project-context); SOLE writer `spt endpoint role --overwrite <file>` — mechanical no-automated-writer guarantee (echo-commune ingest / signoff / Psyche reconcile structurally exclude it). The user-backed-origin hard gate on the writer is a deferred later tightening (rides the user-msg identity plumbing)\"\r\nrequired_stages = [\"doc\", \"impl\", \"unit\"]  # activated 2026-06-13: M9-T6 (CONTEXT.md §live role; contextstore live_role_file/live_role_path seam; resume::download_psyche_context role-first injection order; spt endpoint role print/--overwrite sole writer; injection-order + no-automated-writer tests; M9-WAVE2-ROLE-WRITERS.md survey)\r\n# ── Always-on endpoints (ADR-0023, ratified 2026-06-21; first consumer downstream spt-discord) ──\r\n[[requirements]]\r\nid = \"REQ-EP-8\"\r\ntitle = \"AlwaysOnEndpoint: a resident, addressable, mindless endpoint whose adapter binary the daemon supervises continuously — register-triggered by an adapter-option's `[always-on]` manifest section, one supervised binary per `<adapter>[:profile]`, running independent of agent liveness. It self-manages its `#`-addressed channel endpoints via the existing `api bind` (one connection fronts many). The SECOND class of spt-core-boot-launched third-party binary (after the shell wake-watcher); supervision reuses the wake-watcher scaffolding (backoff / give-up latch / one-per-instance lock / orphan-kill / brain-side reconcile) MINUS the offline-only flip — always online, never resting (no dormant/suspended states). Two-way: agents message it; it may call `endpoint…\nrequired_stages = []  # rule 5: design ratified 2026-06-21 (grill-with-docs, ADR-0023); SPLIT 2026-07-26 (ADR-0049): the process substrate (supervision, [service] manifest, hold/quiesce, cardinality, derived liveness) moved to REQ-RESIDENT-SERVICE — this req retains ONLY the endpoint layer (api bind channel fronting, wake verb, addressable identity) and builds ON that substrate; the [always-on] manifest section is superseded by [service] + the endpoint-layer declaration. UNBUILT — activate when the milestone delivering the endpoint layer lands; first consumer is the downstream spt-discord adapter (own repo).\r\n[[requirements]]\r\nid = \"REQ-EP-9\"\r\ntitle = \"`#` always-on address sigil: a reserved LEADING sigil marking an AlwaysOnEndpoint, extending the REQ-INST-10 grammar to `[subnet:]#id[@node]`. Mandatory + bijective — `#name` ⟺ always-on endpoint, bare `name` ⟺ agent endpoint — so the router resolves endpoint class from the address alone, before any registry lookup. Sits ABOVE REQ-HAZARD-ID-CHARSET: the address parser strips the single leading `#` before id validation, so the bare/stored id stays charset-clean and a mid-id `#` remains rejected (the charset contract is unchanged).\"\r\nrequired_stages = []  # rule 5: design ratified 2026-06-21 (ADR-0023); UNBUILT — activate with REQ-EP-8's milestone (spt_proto::addr::Address::parse sigil extension + class-discriminated resolve in spt_net registry). REQ-HAZARD-ID-CHARSET is NOT amended (sigil is an address-layer concern).\r\n\r\n# ───────────────────────────── Instances ────────────────────────────────────\r\n[[requirements]]\r\nid = \"REQ-INST-1\"\r\ntitle = \"endpoint ID vs instance split (adapter-agnostic ID)\"\r\nrequired_stages = []  # rule 5: the data-model split shipped across M4 D3 (registry Instance rows) + D9-5 (home/adapter on the endpoint record); this umbrella req activates when instantiate-anywhere makes the split user-facing (instance minting) — which DEFERS PAST M5 (user decision 2026-06-04, M5-PLAN scope decision 2: consent framework seam only); its concrete slices are tracked by REQ-INST-7..15\r\n[[requirements]]\r\nid = \"REQ-INST-2\"\r\ntitle = \"Per-node files, synced Psyche mind\"\r\nrequired_stages = [\"impl\", \"unit\"]  # activated M4-D6a: the mind now lives in the tracked/ two-tier store (spt-store::contextstore over BranchStore), physically separate from never-synced node-local perches/ — spt_live::ingest::route_slices routes commune drops' <live-context>/<project-context> slices into a-<id>/p-<project_id> branch files (untagged body → live, the parser fallback; precedence-guarded 6.5; checkpoint commit per written tier); signoff::write_resume_commune stamps Self provenance per slice through the same router; resume::download_psyche_context composes the two tiers back into the two-slice envelope (pure read, never mints store state; other-project download sees live only). Daemon lifecycle derives project_id from the commune dir's parent (…\n[[requirements]]\r\nid = \"REQ-INST-3\"\r\ntitle = \"Dormant (warm) / suspended (cold) resting states\"\r\nrequired_stages = [\"doc\", \"impl\", \"unit\"]  # doc activated M4-D9-3: docs/DORMANCY-BUDGET.md — the measured warm/cold policy lock (ADR-0003 #9 closed: warm default confirmed, zero idle CPU, RSS-only cost; auto-suspend opt-in default OFF). impl/unit activated M4-D9-2-1: spt-daemon::resting — the explicit daemon-owned state machine (pure transition table: detach/attention-shift rest warm, manual suspend rests cold from either live state, opt-in auto-suspend counted from the dormancy-onset anchor, wake re-activates in place; no idle timer by design) + durable info.json rest_state/dormant_since_ms record (anchor moves atomically with the state) + the global→node→endpoint auto-suspend knob chain default OFF (daemon.json node leg, info.json endpoint leg, 0 = explic…\n[[requirements]]\r\nid = \"REQ-INST-4\"\r\ntitle = \"active to dormant/suspended fires a transition echo commune\"\r\nrequired_stages = [\"impl\", \"unit\"]  # activated M4-D9-2-2: resting::apply_event fires the on_rest_edge echo hook at every active→(dormant|suspended) edge BEFORE the flip persists (KH 3.3 echo-before-teardown ordering; echo failure is loud but never wedges the instance active — the signoff_with posture); BrainLifecycle::rest_event binds the hook to the real run_echo_commune machinery (fire_echo), so the final context delta lands in the commune drop dir and syncs to whichever instance activates next. Exactly-once per edge by the pure table's idempotence (replayed events are no-edges); resting→resting fires nothing. unit = echo-once-per-edge (pure + real-summarizer lifecycle e2e) + loud-failure-persists + no-echo-on-suspend-from-dormant\r\n[[requirements]]\r\nid = \"REQ-INST-5\"\r\ntitle = \"Two-tier context sync (live to all, project to same-project)\"\r\nrequired_stages = [\"impl\", \"unit\", \"int\"]  # activated M4-D6c-2: scoping is the pull model + the server-side gate (ADR-0013 — requester names a-<id>/p-<project> refs, no new registry fields). spt-daemon::sync::SyncPolicy.allows: a-<id> served only when ∃ subnet S with the handshake-proven origin pinned in the trust store AND synced(id,S) (the D3e gate finally consumed — membership list + visibility, hidden ⟹ not synced, fail-closed on unconfigured lists); p-<project> only to a trusted origin for a hosted project (honest residual: p- tier is branch-granular, a hidden endpoint's per-project slice rides a hosted project's branch — per-file p- filtering post-v1); machinery refs never served. select_refs mirrors the gate requester-side (symmetric scoping). Server…\n[[requirements]]\r\nid = \"REQ-INST-6\"\r\ntitle = \"Deferred messages not delivered to dormant/suspended instances\"\r\nrequired_stages = [\"impl\", \"unit\", \"int\"]  # activated M5-D5a/b: the gate is state-keyed at the hook-channel consumer sites — resting::deferred_held reads the owner's durable rest record at drain time; \"held\" = (deferred=1) ∧ (Dormant|Suspended); poll_drain + cmd_worker_poll gated (the KH 1.4 \"all sites agree\" sweep — shellchan peek ungated: no deferred writer targets shell perches; ring's ephemeral perch recordless by construction = legacy fallback). Release is by construction at the wake edge: the persisted Active state stops the narrowing, the unchanged delivered-mark discipline keeps it exactly-once. D5b adds the remote arm: StreamFamily::Rest + resthost request/serve (remote-drive trust class — access_check on the handshake-proven origin, no grant gate;…\n[[requirements]]\r\nid = \"REQ-INST-7\"\r\ntitle = \"Subnet registry + bare-id resolution policy\"\r\nrequired_stages = [\"impl\", \"unit\", \"int\"]  # activated M4-D3a: spt-net::net::registry — SubnetRegistry { endpoint_id -> [Instance{node,status,epoch}] }, Status{Active,Dormant,Offline}, serde-roundtrippable, merge_instance keeps distinct-node instances as separate rows. The bare-id *resolution policy* (local -> most-recently-active -> id@node, ambiguity refuses) is D3c (REQ-INST-10). Cross-node replication delivered M4-D4d: spt-net::net::replicate — RegistryUpdate {subnet,endpoint_id,Instance} as NDJSON over broker-owned QUIC streams (framing survives chunk coalescing/splitting; corrupt lines skipped, 4.3 posture), apply_update routes by subnet into merge_instance and drops non-member subnets fail-closed (REQ-INST-13 posture); advertise gates (D3d/D3e) run lo…\n[[requirements]]\r\nid = \"REQ-INST-8\"\r\ntitle = \"Remote-control mode distinct from local operation\"\r\nrequired_stages = [\"impl\", \"unit\", \"int\"]  # activated M4-D5b: AttachRecord wire protocol (spt-net::net::attach) + spt-daemon::attach serve/request/input — a byte-stream viewport onto a remote session over broker QUIC streams; compute+files stay on the target node. tests/attach.rs drives a real PTY child on daemon A from daemon B and proves restart survival (worst-case seq-0 re-serve, all journals dedup). int activated M4-D9-6: two-host rig E2E green (tests/twohost.rs — A types into B's live PTY across the rig and reads the echo back)\r\n# ── Multi-subnet membership model (ADR-0006, 2026-06-01; activate M4) ──\r\n[[requirements]]\r\nid = \"REQ-INST-9\"\r\ntitle = \"Multi-subnet membership (same-user N subnets; cross-user seam)\"\r\nrequired_stages = [\"impl\", \"unit\"]  # activated M4-D3d: SubnetStore already carries N seeds (D2c/f); D3d adds the join-time bare-id collision check — spt_net::net::registry::SubnetRegistry::join_endpoint refuses (JoinCollision, registry untouched) when a joining endpoint id is already held by a DIFFERENT node in the target subnet (Offline rows still own the name); same-node re-advertise falls through to the merge_instance epoch lease. Per-subnet scope: the same bare name in different subnets is legal (resolver forces qualification, REQ-INST-10). Same-user only; cross-user seam = registry key generalizes to (subnet,user) per ADR-0006, check rides along unchanged. unit = fresh-join insert + other-node refuse-untouched + offline-holder collide + same-node lease…\n[[requirements]]\r\nid = \"REQ-INST-10\"\r\ntitle = \"Qualified addressing [subnet:]id[@node] + ambiguity forces qualification\"\r\nrequired_stages = [\"impl\", \"unit\"]  # activated M4-D3c: spt_proto::addr::Address::parse (grammar, reserved :/@ → unambiguous split, each component charset-validated) + spt_net::net::registry::resolve/resolve_across (policy: @node exact → local wins → sole live → REFUSE on multiple live NODES [AcrossNodes, force @node]; Offline never routed). Subnet is NEVER a disambiguation axis (ADR-0006 §1: identity node-global) — the unqualified cross-subnet form UNIONs instances and DEDUPEs by node (same (id,node) in N subnets = ONE endpoint; freshest epoch among twins), then applies the single-registry policy; a `subnet:` qualifier still routes straight to one subnet (counter-39 bug #1: removed the false AcrossSubnets refusal). Per-node epochs are NOT cross-node compara…\n[[requirements]]\r\nid = \"REQ-INST-11\"\r\ntitle = \"spt rename <id> rippled to all instances (collision-checked, 6.5-reconciled)\"\r\nrequired_stages = [\"impl\", \"unit\"]  # activated M4-D3f: three local arms. (1) registry rows: spt_net::net::registry::rename_endpoint — collision-checked against EVERY subnet the endpoint is advertised into FIRST (any row, any status holds the name), then moved all-or-nothing; rows verbatim (relabel, not liveness event); new id charset-validated (:/@ reserved). (2) perch dirs: spt_store::rename::rename_perch_in — Self dir + nested <id>-psyche/<id>-w{N} children + every info.json id; refuses while any perch in the tree is live; local SQLite registry rows deliberately untouched (live refuses ⇒ old rows stale ⇒ 4.3 stale-clean drops). (3) CLI `spt rename <old> <new>` wires the perch arm. Seams: daemon-held registry wiring = D4; a-<id> context branch = D6 context…\n[[requirements]]\r\nid = \"REQ-INST-12\"\r\ntitle = \"Endpoint visibility per-(endpoint,subnet): excluded semantics, OR-of-defaults + override, gates sync\"\r\nrequired_stages = [\"impl\", \"unit\"]  # activated M4-D3e: spt_store::visibility::VisibilityStore (identity/visibility.json, atomic, degrade-safe) — hidden(E,S,flag) = per-(E,S) override wins both directions, else S.hide_new_endpoints OR E.default_hide (both ship OFF). SubnetRecord.hide_new_endpoints serde-defaulted (legacy subnet.json loads OFF). Enforcement in spt_net::net::registry: excluded = not advertised (advertise_if_visible gates BEFORE the collision check — hidden neither claims nor clashes) AND not routable (resolve_visible/resolve_across_visible refuse even @node pins; exclusion prunes the cross-subnet ambiguity set). unit = OR-of-defaults + override-both-directions + persist/degrade + legacy-load + not-routable + ambiguity-prune + advertise-gate.\r\n[[requirements]]\r\nid = \"REQ-INST-13\"\r\ntitle = \"Subnet-exclusive sync + per-endpoint subnet-membership list\"\r\nrequired_stages = [\"impl\", \"unit\"]  # activated M4-D3e: VisibilityStore.sync_subnets per-endpoint membership list + synced(E,S,flag) — the D6 replication gate: subnet must be on the list (unconfigured list syncs NOWHERE, fail-closed; creation flow seeds [home] per ADR-0010) AND visibility gates sync (hidden ⟹ not synced even when listed, ADR-0006 §6). Actual mind replication consuming this gate = D6. unit = listed-visible-syncs + unlisted/unconfigured-no + hidden-gates-sync (defaults + override paths).\r\n[[requirements]]\r\nid = \"REQ-INST-14\"\r\ntitle = \"Resource advertisement (subnet resource registry): free-text blurb, both-authored, registry projection, visibility/whitelist-gated\"\r\nrequired_stages = [\"doc\", \"impl\", \"unit\"]  # activated M4-D9-4: resources: Option<String> on the registry Instance (additive serde-default, rides RegistryUpdate replication + the epoch lease — a blurb edit is an ordinary epoch-bumped update, NO new record kind/store/merge surface) + resource_projection (the (id,node,status,blurb) yellow-pages view over VISIBLE+routable rows — same exclusion closure as resolution, ADR-0006 §6 leak-free by construction) + both-authored sourcing (info.json resources via spt resources set wins; daemon.json resources_blurb node seed fills the gap) + spt resources set/show/list CLI off the stale-tolerant registry snapshots. The whitelist leg of the gate (viewer-node filtering) rides the deferred consent/whitelist generalization — …\n[[requirements]]\r\nid = \"REQ-INST-15\"\r\ntitle = \"Immutable home subnet (assigned at creation: auto-if-one/ask-if-many) + spt fork (cross-subnet clone to a new identity, copy-then-diverge, not re-home); adapter chosen at creation from registered hostable adapters, changed only via launch/resume-under-new (ADR-0010)\"\r\nrequired_stages = [\"doc\", \"impl\", \"unit\"]  # activated M4-D9-5: spt-store::home (assign_home matrix: sole auto / several refuse-and-qualify / unpaired local-only; stamp_creation_fields = the ONE creation seam shared by spt ready + api listen/bind — NEW assigns hard + seeds sync_subnets=[home], REVIVE carries home/adapter/blurb/knobs forward so a re-bind never wipes them; adopt_for_unset = first-join adoption, sole-subnet only, never re-homes; NO setter — immutability by construction) + info.json home_subnet/adapter + ContextStore::fork_endpoint (one-time copy of live+project tiers as parentless seed commits — copied-then-diverged, no shared history) / remove_endpoint (--delete-source, exactly the source) + spt fork CLI (refusal-first gates: same-id, target m…\n[[requirements]]\r\nid = \"REQ-REACH-1\"\r\ntitle = \"Off-node remote-drive detection. THE FILE-TRANSFER HALF IS STRUCK (ADR-0060, releases#246, doyle-ruled 2026-09-07): the WAN transfer family it named is retired -- see REQ-XFER-RETIRED -- so this requirement now covers detection only. What survives is what never depended on the transport: the daemon-side fact of WHICH node is driving an endpoint's session, stamped by the serve path and read back by the endpoint. The int stage stands on the remote-drive cell in tests/dispatch.rs; the transfer int arms left with the family they covered.\"\r\nrequired_stages = [\"impl\", \"unit\", \"int\"]  # int activated M4-D9-6: two-host rig E2E green (tests/twohost.rs — file fetch off B + push back to B across the rig; chunked xfer through the dispatcher gate)\r\n[[requirements]]\r\nid = \"REQ-REACH-2\"\r\ntitle = \"Remote command execution (deferred, consent-gated)\"\r\nrequired_stages = []  # rule 5: deferred by design (PRD: consent-gated remote exec is the highest-risk capability; workarounds exist) — M5+ with the instantiate-anywhere consent model\r\n\r\n# ───────────────────────────── Local messaging (M1) ─────────────────────────\r\n# No PRD R-* covers local message delivery (foundational/implicit). Registered\r\n# at M1 start per TRACEABILITY rule 3 (precedent: REQ-NODE-IDENTITY at M0 T5).\r\n# Inactive until the M1 task that delivers each lands (rule 5: activate, don't pre-fail).\r\n[[requirements]]\r\nid = \"REQ-MSG-1\"\r\n…\nrequired_stages = [\"impl\", \"unit\", \"int\"]  # activated M1 T1 (impl+unit); int added T9: killer-quickstart cross-perch E2E","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,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,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,279,280,281,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,null,302]},"fileSize":2082124,"meta":{"source":{"type":"path","value":"C:\\Users\\decid\\Documents\\projects\\spt-core\\traceable-reqs.toml"},"truncation":{"direction":"head","truncatedBy":"lines","totalLines":7800,"totalBytes":42456,"outputLines":300,"outputBytes":42456,"shownRange":{"start":1,"end":300},"nextOffset":301},"limits":{"columnTruncated":{"maxColumn":768}}}},"isError":false,"timestamp":1790039446256}}
{"type":"message","id":"954d3abe","parentId":"a814a319","timestamp":"2026-09-22T01:10:46.262Z","message":{"role":"toolResult","toolCallId":"call_xxBc0WPQsqGPbFt5hQ0ezZeC|fc_06340275dc54e8ed016ab1d59612c087d1a7201672bf43c4c9","toolName":"grep","content":[{"type":"text","text":" 83|// [impl->REQ-HAZARD-DAEMON-IDENTITY-ENV-SANITIZE]\n*84|pub fn scrub_identity_env() {\n 85|    for var in IDENTITY_ENV_VARS {\n 86|        std::env::remove_var(var);\n 87|    }\n...\n 233|    // [impl->REQ-PSYCHE-OUTCOME-CLASSIFIED]\n*234|    pub fn is_timeout(&self) -> bool {\n 235|        matches!(self, RuntimeError::Timeout { .. })\n 236|    }\n 237|}\n...\n 398|// [impl->REQ-DIGEST-PROFILE-ENV]\n*399|pub fn capture_read_env(\n 400|    manifest: &Manifest,\n 401|    get: impl Fn(&str) -> Option<String>,\n 402|) -> BTreeMap<String, String> {\n...\n 422|// [impl->REQ-DIGEST-PROFILE-ENV]\n*423|pub fn inject_read_env_keys(\n 424|    keys: &mut BTreeMap<String, String>,\n 425|    manifest: &Manifest,\n 426|    captured: &BTreeMap<String, String>,\n...\n 590|#[derive(Debug, Clone)]\n*591|pub struct BoundedOutput {\n 592|    pub status_code: Option<i32>,\n 593|    pub stdout: String,\n 594|    pub stderr: String,\n...\n 597|impl BoundedOutput {\n*598|    pub fn success(&self) -> bool {\n 599|        self.status_code == Some(0)\n 600|    }\n 601|}\n...\n 629|    /// (M2b) feeds normalized history to a cheap-model summarizer template.\n*630|    fn run_bounded_stdin(\n 631|        &self,\n 632|        role: &str,\n 633|        keys: &BTreeMap<String, String>,\n...\n 638|    /// Bounded role invocation with no stdin — sugar over [`Self::run_bounded_stdin`].\n*639|    fn run_bounded(\n 640|        &self,\n 641|        role: &str,\n 642|        keys: &BTreeMap<String, String>,\n...\n 741|    // [impl->REQ-PSYCHE-SPAWN-ENV-PARITY]\n*742|    pub fn with_spawn_env(\n 743|        mut self,\n 744|        spawn_env: std::collections::BTreeMap<String, String>,\n 745|    ) -> Self {\n...\n 926|\n*927|    fn run_bounded_stdin(\n 928|        &self,\n 929|        role: &str,\n 930|        keys: &BTreeMap<String, String>,\n...\n 960|/// reaped. Uses `Instant` (monotonic) for the deadline.\n*961|fn wait_bounded(\n 962|    mut child: Child,\n 963|    timeout: Duration,\n 964|    label: &str,\n...\n 1026|// [impl->REQ-HAZARD-SUBPROCESS-TIMEOUT]\n*1027|pub fn run_bounded_command(\n 1028|    template: &str,\n 1029|    keys: &BTreeMap<String, String>,\n 1030|    timeout: Duration,\n...\n 1074|// [impl->REQ-HAZARD-SUBPROCESS-TIMEOUT]\n*1075|pub fn run_bounded_command_in(\n 1076|    template: &str,\n 1077|    keys: &BTreeMap<String, String>,\n 1078|    timeout: Duration,\n...\n 1153|\n*1154|    fn manifest_env(env_toml: &str) -> Manifest {\n 1155|        let toml = format!(\n 1156|            \"[adapter]\\nname = \\\"cc\\\"\\nversion = \\\"1.0.0\\\"\\nmin_spt_core_version = \\\"0.1.0\\\"\\n\\n{env_toml}\"\n 1157|        );\n...\n 1165|    #[test]\n*1166|    fn capture_read_env_is_declared_allowlist_only() {\n 1167|        let m = manifest_env(\n 1168|            \"[env.CLAUDE_CONFIG_DIR]\\ndirection = \\\"read\\\"\\nvalue = \\\"~/.claude\\\"\\n\\n\\\n 1169|             [env.OWL_SESSION_ID]\\ndirection = \\\"inject\\\"\\nvalue = \\\"{session_id}\\\"\\n\",\n...\n 1195|    #[test]\n*1196|    fn inject_read_env_keys_resolves_ambient_then_value_then_skips() {\n 1197|        let m = manifest_env(\n 1198|            \"[env.RELOCATED]\\ndirection = \\\"read\\\"\\nvalue = \\\"/base/relocated\\\"\\n\\n\\\n 1199|             [env.FALLBACK]\\ndirection = \\\"read\\\"\\nvalue = \\\"/base/fallback\\\"\\n\\n\\\n...\n 1362|    /// stamps land here as `Some`.\n*1363|    fn env_set(cmd: &Command) -> BTreeMap<String, String> {\n 1364|        cmd.get_envs()\n 1365|            .filter_map(|(k, v)| Some((k.to_str()?.to_string(), v?.to_str()?.to_string())))\n 1366|            .collect()\n...\n 1371|    /// its vars here.\n*1372|    fn env_removed(cmd: &Command) -> std::collections::BTreeSet<String> {\n 1373|        cmd.get_envs()\n 1374|            .filter(|(_, v)| v.is_none())\n 1375|            .filter_map(|(k, _)| k.to_str().map(str::to_string))\n...\n 1388|    #[test]\n*1389|    fn spawn_env_stamps_reach_the_command_env() {\n 1390|        let m = psyche_manifest(\"\");\n 1391|        // No stamps → the var is absent from the command's explicit env.\n 1392|        let plain = ManifestRuntime::new(m.clone());\n...\n 1418|    #[test]\n*1419|    fn stamp_survives_env_remove() {\n 1420|        let m = psyche_manifest(\"env_remove = [\\\"CLAUDE_CONFIG_DIR\\\"]\\n\");\n 1421|        let mut stamps = BTreeMap::new();\n 1422|        stamps.insert(\"CLAUDE_CONFIG_DIR\".to_string(), \"/relocated/root\".to_string());\n...\n 1442|    #[test]\n*1443|    fn identity_env_scrubbed_even_when_role_declares_no_env_remove() {\n 1444|        let m = psyche_manifest(\"\"); // psyche_resume with an EMPTY env_remove\n 1445|        let rt = ManifestRuntime::new(m);\n 1446|        let role = rt.role(\"psyche_resume\").unwrap();\n...\n 1462|    #[test]\n*1463|    fn identity_scrub_overrides_a_spawn_env_stamp() {\n 1464|        let m = psyche_manifest(\"\");\n 1465|        let mut stamps = BTreeMap::new();\n 1466|        stamps.insert(\"SPT_ENDPOINT_ID\".to_string(), \"evil\".to_string());\n...\n 1490|    #[test]\n*1491|    fn scrub_identity_env_clears_all_identity_vars_from_process() {\n 1492|        for var in IDENTITY_ENV_VARS.iter().chain(INJECT_ECHO_ENV_VARS.iter()) {\n 1493|            std::env::set_var(var, \"leaked\");\n 1494|        }\n...\n 1710|    #[test] // [unit->REQ-SEAM-SPAWN]\n*1711|    fn bounded_run_captures_success() {\n 1712|        // Cross-platform no-op that exits 0.\n 1713|        #[cfg(windows)]\n 1714|        let rt = echo_manifest(\"cmd /C exit 0\");\n...\n 1723|    #[test] // [unit->REQ-HAZARD-SUBPROCESS-TIMEOUT]\n*1724|    fn bounded_run_kills_on_timeout() {\n 1725|        // A child that sleeps far longer than the timeout must be killed and\n 1726|        // return Timeout — never hang the caller.\n 1727|        #[cfg(windows)]"}],"details":{"scopePath":"crates/spt-runtime/src/runtime.rs","searchPath":"C:\\Users\\decid\\Documents\\projects\\spt-core\\crates\\spt-runtime\\src\\runtime.rs","cwd":"C:\\Users\\decid\\Documents\\projects\\spt-core","matchCount":25,"fileCount":1,"files":["crates/spt-runtime/src/runtime.rs"],"fileMatches":[{"path":"crates/spt-runtime/src/runtime.rs","count":25}],"truncated":false,"displayContent":"   83│// [impl->REQ-HAZARD-DAEMON-IDENTITY-ENV-SANITIZE]\n  *84│pub fn scrub_identity_env() {\n   85│    for var in IDENTITY_ENV_VARS {\n   86│        std::env::remove_var(var);\n   87│    }\n     │...\n  233│    // [impl->REQ-PSYCHE-OUTCOME-CLASSIFIED]\n *234│    pub fn is_timeout(&self) -> bool {\n  235│        matches!(self, RuntimeError::Timeout { .. })\n  236│    }\n  237│}\n     │...\n  398│// [impl->REQ-DIGEST-PROFILE-ENV]\n *399│pub fn capture_read_env(\n  400│    manifest: &Manifest,\n  401│    get: impl Fn(&str) -> Option<String>,\n  402│) -> BTreeMap<String, String> {\n     │...\n  422│// [impl->REQ-DIGEST-PROFILE-ENV]\n *423│pub fn inject_read_env_keys(\n  424│    keys: &mut BTreeMap<String, String>,\n  425│    manifest: &Manifest,\n  426│    captured: &BTreeMap<String, String>,\n     │...\n  590│#[derive(Debug, Clone)]\n *591│pub struct BoundedOutput {\n  592│    pub status_code: Option<i32>,\n  593│    pub stdout: String,\n  594│    pub stderr: String,\n     │...\n  597│impl BoundedOutput {\n *598│    pub fn success(&self) -> bool {\n  599│        self.status_code == Some(0)\n  600│    }\n  601│}\n     │...\n  629│    /// (M2b) feeds normalized history to a cheap-model summarizer template.\n *630│    fn run_bounded_stdin(\n  631│        &self,\n  632│        role: &str,\n  633│        keys: &BTreeMap<String, String>,\n     │...\n  638│    /// Bounded role invocation with no stdin — sugar over [`Self::run_bounded_stdin`].\n *639│    fn run_bounded(\n  640│        &self,\n  641│        role: &str,\n  642│        keys: &BTreeMap<String, String>,\n     │...\n  741│    // [impl->REQ-PSYCHE-SPAWN-ENV-PARITY]\n *742│    pub fn with_spawn_env(\n  743│        mut self,\n  744│        spawn_env: std::collections::BTreeMap<String, String>,\n  745│    ) -> Self {\n     │...\n  926│\n *927│    fn run_bounded_stdin(\n  928│        &self,\n  929│        role: &str,\n  930│        keys: &BTreeMap<String, String>,\n     │...\n  960│/// reaped. Uses `Instant` (monotonic) for the deadline.\n *961│fn wait_bounded(\n  962│    mut child: Child,\n  963│    timeout: Duration,\n  964│    label: &str,\n     │...\n 1026│// [impl->REQ-HAZARD-SUBPROCESS-TIMEOUT]\n*1027│pub fn run_bounded_command(\n 1028│    template: &str,\n 1029│    keys: &BTreeMap<String, String>,\n 1030│    timeout: Duration,\n     │...\n 1074│// [impl->REQ-HAZARD-SUBPROCESS-TIMEOUT]\n*1075│pub fn run_bounded_command_in(\n 1076│    template: &str,\n 1077│    keys: &BTreeMap<String, String>,\n 1078│    timeout: Duration,\n     │...\n 1153│\n*1154│    fn manifest_env(env_toml: &str) -> Manifest {\n 1155│        let toml = format!(\n 1156│            \"[adapter]\\nname = \\\"cc\\\"\\nversion = \\\"1.0.0\\\"\\nmin_spt_core_version = \\\"0.1.0\\\"\\n\\n{env_toml}\"\n 1157│        );\n     │...\n 1165│    #[test]\n*1166│    fn capture_read_env_is_declared_allowlist_only() {\n 1167│        let m = manifest_env(\n 1168│            \"[env.CLAUDE_CONFIG_DIR]\\ndirection = \\\"read\\\"\\nvalue = \\\"~/.claude\\\"\\n\\n\\\n 1169│             [env.OWL_SESSION_ID]\\ndirection = \\\"inject\\\"\\nvalue = \\\"{session_id}\\\"\\n\",\n     │...\n 1195│    #[test]\n*1196│    fn inject_read_env_keys_resolves_ambient_then_value_then_skips() {\n 1197│        let m = manifest_env(\n 1198│            \"[env.RELOCATED]\\ndirection = \\\"read\\\"\\nvalue = \\\"/base/relocated\\\"\\n\\n\\\n 1199│             [env.FALLBACK]\\ndirection = \\\"read\\\"\\nvalue = \\\"/base/fallback\\\"\\n\\n\\\n     │...\n 1362│    /// stamps land here as `Some`.\n*1363│    fn env_set(cmd: &Command) -> BTreeMap<String, String> {\n 1364│        cmd.get_envs()\n 1365│            .filter_map(|(k, v)| Some((k.to_str()?.to_string(), v?.to_str()?.to_string())))\n 1366│            .collect()\n     │...\n 1371│    /// its vars here.\n*1372│    fn env_removed(cmd: &Command) -> std::collections::BTreeSet<String> {\n 1373│        cmd.get_envs()\n 1374│            .filter(|(_, v)| v.is_none())\n 1375│            .filter_map(|(k, _)| k.to_str().map(str::to_string))\n     │...\n 1388│    #[test]\n*1389│    fn spawn_env_stamps_reach_the_command_env() {\n 1390│        let m = psyche_manifest(\"\");\n 1391│        // No stamps → the var is absent from the command's explicit env.\n 1392│        let plain = ManifestRuntime::new(m.clone());\n     │...\n 1418│    #[test]\n*1419│    fn stamp_survives_env_remove() {\n 1420│        let m = psyche_manifest(\"env_remove = [\\\"CLAUDE_CONFIG_DIR\\\"]\\n\");\n 1421│        let mut stamps = BTreeMap::new();\n 1422│        stamps.insert(\"CLAUDE_CONFIG_DIR\".to_string(), \"/relocated/root\".to_string());\n     │...\n 1442│    #[test]\n*1443│    fn identity_env_scrubbed_even_when_role_declares_no_env_remove() {\n 1444│        let m = psyche_manifest(\"\"); // psyche_resume with an EMPTY env_remove\n 1445│        let rt = ManifestRuntime::new(m);\n 1446│        let role = rt.role(\"psyche_resume\").unwrap();\n     │...\n 1462│    #[test]\n*1463│    fn identity_scrub_overrides_a_spawn_env_stamp() {\n 1464│        let m = psyche_manifest(\"\");\n 1465│        let mut stamps = BTreeMap::new();\n 1466│        stamps.insert(\"SPT_ENDPOINT_ID\".to_string(), \"evil\".to_string());\n     │...\n 1490│    #[test]\n*1491│    fn scrub_identity_env_clears_all_identity_vars_from_process() {\n 1492│        for var in IDENTITY_ENV_VARS.iter().chain(INJECT_ECHO_ENV_VARS.iter()) {\n 1493│            std::env::set_var(var, \"leaked\");\n 1494│        }\n     │...\n 1710│    #[test] // [unit->REQ-SEAM-SPAWN]\n*1711│    fn bounded_run_captures_success() {\n 1712│        // Cross-platform no-op that exits 0.\n 1713│        #[cfg(windows)]\n 1714│        let rt = echo_manifest(\"cmd /C exit 0\");\n     │...\n 1723│    #[test] // [unit->REQ-HAZARD-SUBPROCESS-TIMEOUT]\n*1724│    fn bounded_run_kills_on_timeout() {\n 1725│        // A child that sleeps far longer than the timeout must be killed and\n 1726│        // return Timeout — never hang the caller.\n 1727│        #[cfg(windows)]"},"isError":false,"timestamp":1790039446261}}
{"type":"message","id":"bcbd7438","parentId":"954d3abe","timestamp":"2026-09-22T01:10:46.376Z","message":{"role":"toolResult","toolCallId":"call_75XCQuorfA2KcDdPYjEfMhvt|fc_06340275dc54e8ed016ab1d59612b087d1895ae5e3ba3a2e99","toolName":"read","content":[{"type":"text","text":"URL: https://cli.github.com/manual/gh_release_list\nContent-Type: text/html\nMethod: native\n\n---\n\n---\nmeta-description: Take GitHub to the command line\nmeta-generator: Jekyll v4.4.1\nmeta-google-site-verification: c1kuD-K2HIVF635lypcsWPoD4kilo5-jA_wBFyT4uMY\nmeta-ha-url: https://collector.githubapp.com/github-cli/collect\nmeta-og:description: Take GitHub to the command line\nmeta-og:image: /assets/images/social-card.png\nmeta-og:locale: en_US\nmeta-og:site_name: GitHub CLI\nmeta-og:title: GitHub CLI\nmeta-og:type: website\nmeta-twitter:card: summary_large_image\nmeta-twitter:description: Take GitHub to the command line\nmeta-twitter:image: /assets/images/social-card.png\nmeta-twitter:site: @github\nmeta-twitter:title: GitHub CLI\nmeta-viewport: width=device-width, initial-scale=1\ntitle: GitHub CLI | Take GitHub to the command line\n---\n\n**![SVG Image](data:image/svg+xml;base64,PHN2ZyBhcmlhLWhpZGRlbj0idHJ1ZSIgY2xhc3M9Im9jdGljb24gb2N0aWNvbi10aHJlZS1iYXJzIiBoZWlnaHQ9IjE2IiB2aWV3Qm94PSIwIDAgMTYgMTYiIHdpZHRoPSIxNiI+CiAgICAgIDxwYXRoIGQ9Ik0xIDIuNzVBLjc1Ljc1IDAgMCAxIDEuNzUgMmgxMi41YS43NS43NSAwIDAgMSAwIDEuNUgxLjc1QS43NS43NSAwIDAgMSAxIDIuNzVabTAgNUEuNzUuNzUgMCAwIDEgMS43NSA3aDEyLjVhLjc1Ljc1IDAgMCAxIDAgMS41SDEuNzVBLjc1Ljc1IDAgMCAxIDEgNy43NVpNMS43NSAxMmgxMi41YS43NS43NSAwIDAgMSAwIDEuNUgxLjc1YS43NS43NSAwIDAgMSAwLTEuNVoiIC8+CiAgICA8L3N2Zz4=)CLI manual**\n\n## gh release list\n\nList releases in a repository\n\n```\ngh release list [flags]\n```\n\n### Options\n\n`--exclude-drafts`\nExclude draft releases\n\n`--exclude-pre-releases`\nExclude pre-releases\n\n`-q`, `--jq <expression>`\nFilter JSON output using a jq expression\n\n`--json <fields>`\nOutput JSON with the specified fields\n\n`-L`, `--limit <int> (default 30)`\nMaximum number of items to fetch\n\n`-O`, `--order <string> (default \"desc\")`\nOrder of releases returned: {asc|desc}\n\n`-t`, `--template <string>`\nFormat JSON output using a Go template; see \"gh help formatting\"\n\n### Options inherited from parent commands\n\n`-R`, `--repo <[HOST/]OWNER/REPO>`\nSelect another repository using the [HOST/]OWNER/REPO format\n\n### ALIASES\n\ngh release ls\n\n### JSON Fields\n\n`createdAt`, `isDraft`, `isImmutable`, `isLatest`, `isPrerelease`, `name`, `publishedAt`, `tagName`\n\n### See also\n\n- [gh release](./gh_release)"}],"details":{"kind":"url","url":"https://cli.github.com/manual/gh_release_list","finalUrl":"https://cli.github.com/manual/gh_release_list","contentType":"text/html","method":"native","truncated":false,"notes":[],"meta":{"source":{"type":"url","value":"https://cli.github.com/manual/gh_release_list"}}},"isError":false,"timestamp":1790039446375}}
{"type":"message","id":"1c7cfd8c","parentId":"bcbd7438","timestamp":"2026-09-22T01:10:46.377Z","message":{"role":"toolResult","toolCallId":"call_GID5xfNG9hgQ6kF0Amvm05hH|fc_06340275dc54e8ed016ab1d596129c87d18c2454d681943098","toolName":"read","content":[{"type":"text","text":"URL: https://cli.github.com/manual/gh_auth_status\nContent-Type: text/html\nMethod: native\n\n---\n\n---\nmeta-description: Take GitHub to the command line\nmeta-generator: Jekyll v4.4.1\nmeta-google-site-verification: c1kuD-K2HIVF635lypcsWPoD4kilo5-jA_wBFyT4uMY\nmeta-ha-url: https://collector.githubapp.com/github-cli/collect\nmeta-og:description: Take GitHub to the command line\nmeta-og:image: /assets/images/social-card.png\nmeta-og:locale: en_US\nmeta-og:site_name: GitHub CLI\nmeta-og:title: GitHub CLI\nmeta-og:type: website\nmeta-twitter:card: summary_large_image\nmeta-twitter:description: Take GitHub to the command line\nmeta-twitter:image: /assets/images/social-card.png\nmeta-twitter:site: @github\nmeta-twitter:title: GitHub CLI\nmeta-viewport: width=device-width, initial-scale=1\ntitle: GitHub CLI | Take GitHub to the command line\n---\n\n**![SVG Image](data:image/svg+xml;base64,PHN2ZyBhcmlhLWhpZGRlbj0idHJ1ZSIgY2xhc3M9Im9jdGljb24gb2N0aWNvbi10aHJlZS1iYXJzIiBoZWlnaHQ9IjE2IiB2aWV3Qm94PSIwIDAgMTYgMTYiIHdpZHRoPSIxNiI+CiAgICAgIDxwYXRoIGQ9Ik0xIDIuNzVBLjc1Ljc1IDAgMCAxIDEuNzUgMmgxMi41YS43NS43NSAwIDAgMSAwIDEuNUgxLjc1QS43NS43NSAwIDAgMSAxIDIuNzVabTAgNUEuNzUuNzUgMCAwIDEgMS43NSA3aDEyLjVhLjc1Ljc1IDAgMCAxIDAgMS41SDEuNzVBLjc1Ljc1IDAgMCAxIDEgNy43NVpNMS43NSAxMmgxMi41YS43NS43NSAwIDAgMSAwIDEuNUgxLjc1YS43NS43NSAwIDAgMSAwLTEuNVoiIC8+CiAgICA8L3N2Zz4=)CLI manual**\n\n## gh auth status\n\n```\ngh auth status [flags]\n```\n\nDisplay active account and authentication state on each known GitHub host.\n\nFor each host, the authentication state of each known account is tested and any issues are included in the output.\nEach host section will indicate the active account, which will be used when targeting that host.\n\nIf an account on any host (or only the one given via `--hostname`) has authentication issues,\nthe command will exit with 1 and output to stderr. Note that when using the `--json` option, the command\nwill always exit with zero regardless of any authentication issues, unless there is a fatal error.\n\nTo change the active account for a host, see `gh auth switch`.\n\n### Options\n\n`-a`, `--active`\nDisplay the active account only\n\n`-h`, `--hostname <string>`\nCheck only a specific hostname's auth status\n\n`--jq <expression>`\nFilter JSON output using a jq expression\n\n`--json <fields>`\nOutput JSON with the specified fields\n\n`-t`, `--show-token`\nDisplay the auth token\n\n`--template <string>`\nFormat JSON output using a Go template; see \"gh help formatting\"\n\n### JSON Fields\n\nhosts\n\n### Examples\n\n```bash\n# Display authentication status for all accounts on all hosts\n$ gh auth status\n\n# Display authentication status for the active account on a specific host\n$ gh auth status --active --hostname github.example.com\n\n# Display tokens in plain text\n$ gh auth status --show-token\n\n# Format authentication status as JSON\n$ gh auth status --json hosts\n\n# Include plain text token in JSON output\n$ gh auth status --json hosts --show-token\n\n# Format hosts as a flat JSON array\n$ gh auth status --json hosts --jq '.hosts | add'\n```\n\n### See also\n\n- [gh auth](./gh_auth)"}],"details":{"kind":"url","url":"https://cli.github.com/manual/gh_auth_status","finalUrl":"https://cli.github.com/manual/gh_auth_status","contentType":"text/html","method":"native","truncated":false,"notes":[],"meta":{"source":{"type":"url","value":"https://cli.github.com/manual/gh_auth_status"}}},"isError":false,"timestamp":1790039446376}}
{"type":"message","id":"12936cd4","parentId":"1c7cfd8c","timestamp":"2026-09-22T01:10:46.377Z","message":{"role":"toolResult","toolCallId":"call_3iIVcVunEePD0Umz4HT635dI|fc_06340275dc54e8ed016ab1d59612b887d1b8db7f060c8d27df","toolName":"read","content":[{"type":"text","text":"URL: https://cli.github.com/manual/gh_help_environment\nContent-Type: text/html\nMethod: native\n\n---\n\n---\nmeta-description: Take GitHub to the command line\nmeta-generator: Jekyll v4.4.1\nmeta-google-site-verification: c1kuD-K2HIVF635lypcsWPoD4kilo5-jA_wBFyT4uMY\nmeta-ha-url: https://collector.githubapp.com/github-cli/collect\nmeta-og:description: Take GitHub to the command line\nmeta-og:image: /assets/images/social-card.png\nmeta-og:locale: en_US\nmeta-og:site_name: GitHub CLI\nmeta-og:title: GitHub CLI\nmeta-og:type: website\nmeta-twitter:card: summary_large_image\nmeta-twitter:description: Take GitHub to the command line\nmeta-twitter:image: /assets/images/social-card.png\nmeta-twitter:site: @github\nmeta-twitter:title: GitHub CLI\nmeta-viewport: width=device-width, initial-scale=1\ntitle: GitHub CLI | Take GitHub to the command line\n---\n\n**![SVG Image](data:image/svg+xml;base64,PHN2ZyBhcmlhLWhpZGRlbj0idHJ1ZSIgY2xhc3M9Im9jdGljb24gb2N0aWNvbi10aHJlZS1iYXJzIiBoZWlnaHQ9IjE2IiB2aWV3Qm94PSIwIDAgMTYgMTYiIHdpZHRoPSIxNiI+CiAgICAgIDxwYXRoIGQ9Ik0xIDIuNzVBLjc1Ljc1IDAgMCAxIDEuNzUgMmgxMi41YS43NS43NSAwIDAgMSAwIDEuNUgxLjc1QS43NS43NSAwIDAgMSAxIDIuNzVabTAgNUEuNzUuNzUgMCAwIDEgMS43NSA3aDEyLjVhLjc1Ljc1IDAgMCAxIDAgMS41SDEuNzVBLjc1Ljc1IDAgMCAxIDEgNy43NVpNMS43NSAxMmgxMi41YS43NS43NSAwIDAgMSAwIDEuNUgxLjc1YS43NS43NSAwIDAgMSAwLTEuNVoiIC8+CiAgICA8L3N2Zz4=)CLI manual**\n\n## gh environment\n\n`GH_TOKEN`, `GITHUB_TOKEN` (in order of precedence): an authentication token that will be used when\na command targets either `github.com` or a subdomain of `ghe.com`. Setting this avoids being prompted to\nauthenticate and takes precedence over previously stored credentials.\n\n`GH_ENTERPRISE_TOKEN`, `GITHUB_ENTERPRISE_TOKEN` (in order of precedence): an authentication\ntoken that will be used when a command targets a GitHub Enterprise Server host.\n\n`GH_HOST`: specify the GitHub hostname for commands where a hostname has not been provided, or\ncannot be inferred from the context of a local Git repository. If this host was previously\nauthenticated with, the stored credentials will be used. Otherwise, setting `GH_TOKEN` or `GH_ENTERPRISE_TOKEN` is required, depending on the targeted host.\n\n`GH_REPO`: specify the GitHub repository in the `[HOST/]OWNER/REPO` format for commands\nthat otherwise operate on a local repository.\n\n`GH_EDITOR`, `GIT_EDITOR`, `VISUAL`, `EDITOR` (in order of precedence): the editor tool to use\nfor authoring text.\n\n`GH_BROWSER`, `BROWSER` (in order of precedence): the web browser to use for opening links.\n\n`GH_DEBUG`: set to a truthy value to enable verbose output on standard error. Set to `api` to additionally log details of HTTP traffic.\n\n`DEBUG` (deprecated): set to `1`, `true`, or `yes` to enable verbose output on standard\nerror.\n\n`GH_PAGER`, `PAGER` (in order of precedence): a terminal paging program to send standard output\nto, e.g. `less`.\n\n`GLAMOUR_STYLE`: the style to use for rendering Markdown. See <https://github.com/charmbracelet/glamour#styles>\n\n`NO_COLOR`: set to any value to avoid printing ANSI escape sequences for color output.\n\n`CLICOLOR`: set to `0` to disable printing ANSI colors in output.\n\n`CLICOLOR_FORCE`: set to a value other than `0` to keep ANSI colors in output\neven when the output is piped.\n\n`GH_COLOR_LABELS`: set to any value to display labels using their RGB hex color codes in terminals that\nsupport truecolor.\n\n`GH_ACCESSIBLE_COLORS` (preview): set to a truthy value to use customizable, 4-bit accessible colors.\n\n`GH_FORCE_TTY`: set to any value to force terminal-style output even when the output is\nredirected. When the value is a number, it is interpreted as the number of columns\navailable in the viewport. When the value is a percentage, it will be applied against\nthe number of columns available in the current viewport.\n\n`GH_NO_UPDATE_NOTIFIER`: set to any value to disable GitHub CLI update notifications.\nWhen any command is executed, gh checks for new versions once every 24 hours.\nIf a newer version was found, an upgrade notice is displayed on standard error.\n\n`GH_NO_EXTENSION_UPDATE_NOTIFIER`: set to any value to disable GitHub CLI extension update notifications.\nWhen an extension is executed, gh checks for new versions for the executed extension once every 24 hours.\nIf a newer version was found, an upgrade notice is displayed on standard error.\n\n`GH_EXTENSION`: set to `1` by gh when it invokes an extension, allowing an extension to\ntell whether it was run as `gh <extension>` or directly as a standalone program.\n\n`GH_CONFIG_DIR`: the directory where gh will store configuration files. If not specified,\nthe default value will be one of the following paths (in order of precedence):\n\n- `$XDG_CONFIG_HOME/gh` (if `$XDG_CONFIG_HOME` is set),\n- `$AppData/GitHub CLI` (on Windows if `$AppData` is set), or\n- `$HOME/.config/gh`.\n\n`GH_PROMPT_DISABLED`: set to any value to disable interactive prompting in the terminal.\n\n`GH_PATH`: set the path to the gh executable, useful for when gh can not properly determine\nits own path such as in the cygwin terminal. gh also sets this when invoking extensions so they\ncan call back into the same gh executable.\n\n`GH_MDWIDTH`: default maximum width for markdown render wrapping. The max width of lines\nwrapped on the terminal will be taken as the lesser of the terminal width, this value, or 120 if\nnot specified. This value is used, for example, with `pr view` subcommand.\n\n`GH_ACCESSIBLE_PROMPTER` (preview): set to a truthy value to enable prompts that are\nmore compatible with speech synthesis and braille screen readers.\n\n`GH_TELEMETRY`: set to `log` to print telemetry data to standard error instead of sending it.\nSet to `false` or `0` to disable telemetry. Takes precedence over `DO_NOT_TRACK`.\n\n`DO_NOT_TRACK`: set to `true` or `1` to disable telemetry. Ignored when `GH_TELEMETRY` is set, which takes precedence.\n\n`GH_SPINNER_DISABLED`: set to a truthy value to replace the spinner animation with\na textual progress indicator.\n\n### See also\n\n- [gh](./gh)"}],"details":{"kind":"url","url":"https://cli.github.com/manual/gh_help_environment","finalUrl":"https://cli.github.com/manual/gh_help_environment","contentType":"text/html","method":"native","truncated":false,"notes":[],"meta":{"source":{"type":"url","value":"https://cli.github.com/manual/gh_help_environment"}}},"isError":false,"timestamp":1790039446377}}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_AMqsHwtTHpI6bJuKIasCmwCH|fc_06340275dc54e8ed016ab1d59da48887d1a254edaa0739d735","toolName":"read","startedAt":"2026-09-22T01:10:53.711Z","args":{"path":"https://raw.githubusercontent.com/cli/cli/trunk/pkg/cmd/auth/status/status.go"},"intent":"Reading upstream stale account format"},"id":"5b07ea3d","parentId":"12936cd4","timestamp":"2026-09-22T01:10:53.711Z"}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_Kp6i1KwrZFLv0EO6HfYTJXXb|fc_06340275dc54e8ed016ab1d59da49887d193d6c8ef57b1de00","toolName":"grep","startedAt":"2026-09-22T01:10:53.711Z","args":{"path":"traceable-reqs.toml;installer"},"intent":"Locating requirement and installer probes"},"id":"f72b2f39","parentId":"5b07ea3d","timestamp":"2026-09-22T01:10:53.711Z"}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_KliC4DcNmgwBnP4VCzsU46IF|fc_06340275dc54e8ed016ab1d59da4a087d1ba3c7df928fa1cdd","toolName":"read","startedAt":"2026-09-22T01:10:53.711Z","args":{"path":"crates/spt-runtime/src/runtime.rs:1710-1755"},"intent":"Reading existing bounded timeout test"},"id":"eb94121f","parentId":"f72b2f39","timestamp":"2026-09-22T01:10:53.711Z"}
{"type":"message","id":"de0796ce","parentId":"eb94121f","timestamp":"2026-09-22T01:10:53.712Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"**Locating status.go source**"},{"type":"toolCall","id":"call_AMqsHwtTHpI6bJuKIasCmwCH|fc_06340275dc54e8ed016ab1d59da48887d1a254edaa0739d735","name":"read","arguments":{"i":"Reading upstream stale account format","path":"https://raw.githubusercontent.com/cli/cli/trunk/pkg/cmd/auth/status/status.go"},"intent":"Reading upstream stale account format"},{"type":"toolCall","id":"call_Kp6i1KwrZFLv0EO6HfYTJXXb|fc_06340275dc54e8ed016ab1d59da49887d193d6c8ef57b1de00","name":"grep","arguments":{"i":"Locating requirement and installer probes","pattern":"REQ-UPDATE-GH-TRANSPORT|gh api|gh auth status|gh release list","path":"traceable-reqs.toml;installer","case":null,"gitignore":null,"skip":null},"intent":"Locating requirement and installer probes"},{"type":"toolCall","id":"call_KliC4DcNmgwBnP4VCzsU46IF|fc_06340275dc54e8ed016ab1d59da4a087d1ba3c7df928fa1cdd","name":"read","arguments":{"i":"Reading existing bounded timeout test","path":"crates/spt-runtime/src/runtime.rs:1710-1755"},"intent":"Reading existing bounded timeout test"}],"api":"openai-codex-responses","provider":"openai-codex","model":"gpt-6-astra","usage":{"input":14924,"output":177,"cacheRead":57600,"cacheWrite":0,"totalTokens":72701,"reasoningTokens":17,"cost":{"input":0.14924,"output":0.00885,"cacheRead":0.0576,"cacheWrite":0,"total":0.21569}},"stopReason":"toolUse","timestamp":1790039446425,"responseId":"resp_06340275dc54e8ed016ab1d596a0a487d184e5353f72400720","providerPayload":{"type":"openaiResponsesHistory","provider":"openai-codex","dt":true,"items":[{"id":"rs_06340275dc54e8ed016ab1d598568887d1ab2431746c450b8d","type":"reasoning","content":[],"encrypted_content":"gAAAAABqsdWdw71Hw2PKCIi7nDfgWtQFH-DTL3CIB68LaUV3KAowXbN3lFRcovs5dfDjYp6CqD4awAmfWuHDbJRYD_voffUDLEXM9mchWLXoVnQ2zkRdD5vaiOYMnq0vZO9fLI5ovcFjiyfqTy2qrFqjEoYkP_avIQwMKjRVa60eyKTzyJohYMB6r5ZmtP-QqYgLnpM0jZWHR5nFq7BOx1TWd2LdLUN_sbxLSWlFh5ZLTMHlyroyxMVf1EgVuBQ8kFHtE8BAbqxtAT-s7e_Cen2bChH4MLn8bSLW4_zuTNWIGidbMc_Ae53KwU_eoxaFBJmf61I3OGFoXnuffdMFejpi1U1u55isluqHiRSx58oYA4GpWRtMVKNkjiJ8KiTq99vfY1KPg_IaueP-g6wPGptS6cekpOiNWSZYG0s-KEgG6tcQ6OlY2tm2ct5_RfGD4TIz7E9r7Yx-GHzmpVcwxKD7GeiXRAj02KSlkpw_--ZiXHUAdRZL_v9iRq8xlLkFuVDiaeeNO6d-_Hq3lsM2eUFUNnUQVga4y789DA7r1hqv_tCBytHZEFxlIKEoaLObuqTTNNwVa94vxWwSJ4wkdEYardaIBonUCLx5_GNrYuZXWoYcsRtbbLAtRQeo2bOF4GaofoK4RLJY2Vug7ZMzFP0MIusdeFCmMdCLHTbE5IgprxlC9GZYMabxfe5mTcXBuhlQGWocHRDEmzI6LkjkS_bBpqj3ps-PUn7FflxaaKXhpKdfUUI-tJkBnejz6qSeOyK2gfoa8xaooWsGsiPpHkxl4ipuZ7PLmLtvzKaKZNU8HLvt69egrE6sbDNxDpybNg6yv59GH1nf1BATb7h2OpCMR88x0UbTMBcsLXmxqKy4yyaI9zl4lANcHSQaaUkGnbcwKQV9a4ULCXk2999VtdRKOv30H5ltjo9aICuqEmtizQukEgenRahuneOoWNmL3YA8ZHnCreXLT4-vQcfQ7SSGV93OMRUmYTfq7fCwW867xwO5zS3sjvO7OF7uwtwktCx-EvnfpqaGxDouAOAEoN5WU9lCeQqtDl2LJe8yBG1lUFWauYMEjZ6H_iLEQ0NV6pwZF8EZ5eSZ8O6ieOX-wIpA8uSz1QABnk8pMvlduf4vKoWNS_iqMsZ-WeP5YCVN1R0txioViFnq8XDhQj_e6wkBPyV27GDFAnLwfq_V-CqByJm07wb_LiYe-pjW9xO6yn9Akq-x0X7OfecIOOrsaAQjl7Gwa-deydJfOqIzPv7cWAI8SBHWPZw9ggOqkO_QO68SP8McWPzlRY6sz5x9kDKIHi-CyKLsYpqqDBbmBKqeAuev0EtO4T-AAFoy6RHeGXDAxLKzK7Q-N54X20wX9jjLAP_Q1_WA0mhvy6ulECkRllJ673uj744=","internal_chat_message_metadata_passthrough":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"},"summary":[{"type":"summary_text","text":"**Locating status.go source**"}],"metadata":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"}},{"id":"fc_06340275dc54e8ed016ab1d59da48887d1a254edaa0739d735","type":"function_call","status":"completed","arguments":"{\"i\":\"Reading upstream stale account format\",\"path\":\"https://raw.githubusercontent.com/cli/cli/trunk/pkg/cmd/auth/status/status.go\"}","call_id":"call_AMqsHwtTHpI6bJuKIasCmwCH","internal_chat_message_metadata_passthrough":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"},"name":"read","metadata":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"}},{"id":"fc_06340275dc54e8ed016ab1d59da49887d193d6c8ef57b1de00","type":"function_call","status":"completed","arguments":"{\"i\":\"Locating requirement and installer probes\",\"pattern\":\"REQ-UPDATE-GH-TRANSPORT|gh api|gh auth status|gh release list\",\"path\":\"traceable-reqs.toml;installer\",\"case\":null,\"gitignore\":null,\"skip\":null}","call_id":"call_Kp6i1KwrZFLv0EO6HfYTJXXb","internal_chat_message_metadata_passthrough":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"},"name":"grep","metadata":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"}},{"id":"fc_06340275dc54e8ed016ab1d59da4a087d1ba3c7df928fa1cdd","type":"function_call","status":"completed","arguments":"{\"i\":\"Reading existing bounded timeout test\",\"path\":\"crates/spt-runtime/src/runtime.rs:1710-1755\"}","call_id":"call_KliC4DcNmgwBnP4VCzsU46IF","internal_chat_message_metadata_passthrough":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"},"name":"read","metadata":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"}}]},"duration":7284.591299999505,"ttft":1935.5911999996752,"completedAt":1790039453710,"contextSnapshot":{"promptTokens":72524,"nonMessageTokens":10333,"compactionEpoch":0}}}
{"type":"message","id":"31c3e5d5","parentId":"de0796ce","timestamp":"2026-09-22T01:10:53.732Z","message":{"role":"toolResult","toolCallId":"call_KliC4DcNmgwBnP4VCzsU46IF|fc_06340275dc54e8ed016ab1d59da4a087d1ba3c7df928fa1cdd","toolName":"read","content":[{"type":"text","text":"    // regression class in c7e7d9e.\n    #[test] // [unit->REQ-SEAM-SPAWN]\n    fn bounded_run_captures_success() {\n        // Cross-platform no-op that exits 0.\n        #[cfg(windows)]\n        let rt = echo_manifest(\"cmd /C exit 0\");\n        #[cfg(unix)]\n        let rt = echo_manifest(\"sh -c \\\"exit 0\\\"\");\n        let out = rt\n            .run_bounded(\"self\", &keys(&[]), Duration::from_secs(10))\n            .expect(\"bounded run\");\n        assert!(out.success(), \"status {:?}\", out.status_code);\n    }\n\n    #[test] // [unit->REQ-HAZARD-SUBPROCESS-TIMEOUT]\n    fn bounded_run_kills_on_timeout() {\n        // A child that sleeps far longer than the timeout must be killed and\n        // return Timeout — never hang the caller.\n        #[cfg(windows)]\n        let rt = echo_manifest(\"cmd /C ping 127.0.0.1 -n 30\");\n        #[cfg(unix)]\n        let rt = echo_manifest(\"sh -c \\\"sleep 30\\\"\");\n        let start = Instant::now();\n        let e = rt\n            .run_bounded(\"self\", &keys(&[]), Duration::from_millis(300))\n            .unwrap_err();\n        assert!(matches!(e, RuntimeError::Timeout { .. }), \"got {e}\");\n        assert!(\n            start.elapsed() < Duration::from_secs(5),\n            \"timeout must return promptly, took {:?}\",\n            start.elapsed()\n        );\n    }\n\n    // [unit->REQ-HAZARD-CHILD-CONSOLE-FLASH] the detached spawn_session leg of\n    // command_for carries CREATE_NO_WINDOW on Windows and still returns a live\n    // pid — the notif-pump/psyche spawn path that b90820c's free-fn fix did not\n    // cover.\n    #[test] // [unit->REQ-SEAM-SPAWN]\n    fn spawn_session_detached_returns_pid() {\n        #[cfg(windows)]\n        let rt = echo_manifest(\"cmd /C exit 0\");\n        #[cfg(unix)]\n        let rt = echo_manifest(\"sh -c \\\"exit 0\\\"\");\n        let spawned = rt.spawn_session(\"self\", &keys(&[])).expect(\"spawn\");\n        assert!(spawned.pid > 0);\n    }\n\n    // [unit->REQ-INSTALL-11] a program present in the install dir resolves to the\n    // absolute path there — the adapter's shipped binary wins over PATH.\n\n[Showing lines 1709-1758 of 1789. Use :1759 to continue]"}],"details":{"truncation":{"content":"    // regression class in c7e7d9e.\r\n    #[test] // [unit->REQ-SEAM-SPAWN]\r\n    fn bounded_run_captures_success() {\r\n        // Cross-platform no-op that exits 0.\r\n        #[cfg(windows)]\r\n        let rt = echo_manifest(\"cmd /C exit 0\");\r\n        #[cfg(unix)]\r\n        let rt = echo_manifest(\"sh -c \\\"exit 0\\\"\");\r\n        let out = rt\r\n            .run_bounded(\"self\", &keys(&[]), Duration::from_secs(10))\r\n            .expect(\"bounded run\");\r\n        assert!(out.success(), \"status {:?}\", out.status_code);\r\n    }\r\n\r\n    #[test] // [unit->REQ-HAZARD-SUBPROCESS-TIMEOUT]\r\n    fn bounded_run_kills_on_timeout() {\r\n        // A child that sleeps far longer than the timeout must be killed and\r\n        // return Timeout — never hang the caller.\r\n        #[cfg(windows)]\r\n        let rt = echo_manifest(\"cmd /C ping 127.0.0.1 -n 30\");\r\n        #[cfg(unix)]\r\n        let rt = echo_manifest(\"sh -c \\\"sleep 30\\\"\");\r\n        let start = Instant::now();\r\n        let e = rt\r\n            .run_bounded(\"self\", &keys(&[]), Duration::from_millis(300))\r\n            .unwrap_err();\r\n        assert!(matches!(e, RuntimeError::Timeout { .. }), \"got {e}\");\r\n        assert!(\r\n            start.elapsed() < Duration::from_secs(5),\r\n            \"timeout must return promptly, took {:?}\",\r\n            start.elapsed()\r\n        );\r\n    }\r\n\r\n    // [unit->REQ-HAZARD-CHILD-CONSOLE-FLASH] the detached spawn_session leg of\r\n    // command_for carries CREATE_NO_WINDOW on Windows and still returns a live\r\n    // pid — the notif-pump/psyche spawn path that b90820c's free-fn fix did not\r\n    // cover.\r\n    #[test] // [unit->REQ-SEAM-SPAWN]\r\n    fn spawn_session_detached_returns_pid() {\r\n        #[cfg(windows)]\r\n        let rt = echo_manifest(\"cmd /C exit 0\");\r\n        #[cfg(unix)]\r\n        let rt = echo_manifest(\"sh -c \\\"exit 0\\\"\");\r\n        let spawned = rt.spawn_session(\"self\", &keys(&[])).expect(\"spawn\");\r\n        assert!(spawned.pid > 0);\r\n    }\r\n\r\n    // [unit->REQ-INSTALL-11] a program present in the install dir resolves to the\r\n    // absolute path there — the adapter's shipped binary wins over PATH.\r","truncated":true,"truncatedBy":"lines","totalLines":81,"totalBytes":2100,"outputLines":50,"outputBytes":2100,"lastLinePartial":false,"firstLineExceedsLimit":false},"totalLines":1789,"displayContent":{"text":"    // regression class in c7e7d9e.\r\n    #[test] // [unit->REQ-SEAM-SPAWN]\r\n    fn bounded_run_captures_success() {\r\n        // Cross-platform no-op that exits 0.\r\n        #[cfg(windows)]\r\n        let rt = echo_manifest(\"cmd /C exit 0\");\r\n        #[cfg(unix)]\r\n        let rt = echo_manifest(\"sh -c \\\"exit 0\\\"\");\r\n        let out = rt\r\n            .run_bounded(\"self\", &keys(&[]), Duration::from_secs(10))\r\n            .expect(\"bounded run\");\r\n        assert!(out.success(), \"status {:?}\", out.status_code);\r\n    }\r\n\r\n    #[test] // [unit->REQ-HAZARD-SUBPROCESS-TIMEOUT]\r\n    fn bounded_run_kills_on_timeout() {\r\n        // A child that sleeps far longer than the timeout must be killed and\r\n        // return Timeout — never hang the caller.\r\n        #[cfg(windows)]\r\n        let rt = echo_manifest(\"cmd /C ping 127.0.0.1 -n 30\");\r\n        #[cfg(unix)]\r\n        let rt = echo_manifest(\"sh -c \\\"sleep 30\\\"\");\r\n        let start = Instant::now();\r\n        let e = rt\r\n            .run_bounded(\"self\", &keys(&[]), Duration::from_millis(300))\r\n            .unwrap_err();\r\n        assert!(matches!(e, RuntimeError::Timeout { .. }), \"got {e}\");\r\n        assert!(\r\n            start.elapsed() < Duration::from_secs(5),\r\n            \"timeout must return promptly, took {:?}\",\r\n            start.elapsed()\r\n        );\r\n    }\r\n\r\n    // [unit->REQ-HAZARD-CHILD-CONSOLE-FLASH] the detached spawn_session leg of\r\n    // command_for carries CREATE_NO_WINDOW on Windows and still returns a live\r\n    // pid — the notif-pump/psyche spawn path that b90820c's free-fn fix did not\r\n    // cover.\r\n    #[test] // [unit->REQ-SEAM-SPAWN]\r\n    fn spawn_session_detached_returns_pid() {\r\n        #[cfg(windows)]\r\n        let rt = echo_manifest(\"cmd /C exit 0\");\r\n        #[cfg(unix)]\r\n        let rt = echo_manifest(\"sh -c \\\"exit 0\\\"\");\r\n        let spawned = rt.spawn_session(\"self\", &keys(&[])).expect(\"spawn\");\r\n        assert!(spawned.pid > 0);\r\n    }\r\n\r\n    // [unit->REQ-INSTALL-11] a program present in the install dir resolves to the\r\n    // absolute path there — the adapter's shipped binary wins over PATH.\r","startLine":1709,"lineNumbers":[1709,1710,1711,1712,1713,1714,1715,1716,1717,1718,1719,1720,1721,1722,1723,1724,1725,1726,1727,1728,1729,1730,1731,1732,1733,1734,1735,1736,1737,1738,1739,1740,1741,1742,1743,1744,1745,1746,1747,1748,1749,1750,1751,1752,1753,1754,1755,1756,1757,1758]},"fileSize":84125,"meta":{"source":{"type":"path","value":"C:\\Users\\decid\\Documents\\projects\\spt-core\\crates\\spt-runtime\\src\\runtime.rs"},"truncation":{"direction":"head","truncatedBy":"lines","totalLines":1789,"totalBytes":2100,"outputLines":50,"outputBytes":2100,"shownRange":{"start":1709,"end":1758},"nextOffset":1759}}},"isError":false,"timestamp":1790039453732}}
{"type":"message","id":"f23fc3b3","parentId":"31c3e5d5","timestamp":"2026-09-22T01:10:53.757Z","message":{"role":"toolResult","toolCallId":"call_Kp6i1KwrZFLv0EO6HfYTJXXb|fc_06340275dc54e8ed016ab1d59da49887d193d6c8ef57b1de00","toolName":"grep","content":[{"type":"text","text":"# traceable-reqs.toml\n 2075|title = \"A-4 (F029, operator regression): the picker/confirm views drop an endpoint's adapter `:profile` (showed `claude-spt` where `claude-spt:ccs` was created). ROOT: stamp_creation_fields (spt-store/home.rs) gave the incoming BIND-TIME adapter value UNCONDITIONAL precedence (`rec.adapter = adapter.map(...).or_else(prior)`), but a hook bind resolves the adapter ADAPTER-AGNOSTICALLY (ADR-0021: a binary basename → the BARE parent, profile unknowable), so the first hook bind rewrote the richer `claude-s...\n*2076|required_stages = [\"impl\", \"unit\", \"int\"]  # ACTIVATED F029 W2 (todlando 2026-07-03); int ACTIVATED F029 wave-final (todlando 2026-07-03, doyle ruling: operator-observed field regression = paid-for = conformance E2E). impl = home.rs stamp_creation_fields Some(prior) arm: rec.adapter = match incoming { Some(a) if incoming_is_parent_of_prior(a, prior.adapter) => prior.adapter.clone(), Some(a) => a, None => prior.adapter.clone() } — incoming_is_parent_of_prior = tiny pure helper splitting the prior on the...\n 2077|\n 2078|[[requirements]]\n 2079|id = \"REQ-PICKER-WINDOW-TITLE\"\n...\n 2610|[[requirements]]\n*2611|id = \"REQ-UPDATE-GH-TRANSPORT\"\n*2612|title = \"THE-FORKENING W1 (ADR-0036, operator-ruled 2026-07-14): the release channel is PRIVATE (`BigscreenVR/spt-bs-releases`) and the gh CLI is the mandated carrier — release discovery (`releases/latest`, cli.rs:9717) and asset download (cli.rs:4861 public browser URLs) move to deadline-wrapped `gh` subprocess calls (`gh api`, `gh release download`; run_git pattern). WHY gh not token+HTTP: private-repo `browser_download_url` 404s even with a valid token — the API asset-id dance is gh's job. Default...\n 2613|required_stages = [\"doc\", \"impl\", \"unit\", \"int\"]  # FLIPPED in the W1 build commit (todlando 2026-07-14). doc = CONTEXT.md release-channel entry (triage commit) + docs-site self-update gh prerequisite. impl = cli.rs gh_status probe (Missing/Unauthed split) + render_gh_cli_required/render_gh_auth_required + cmd_update_fetch on fetch_release_asset_bytes(Gh) for update-set.json + every artifact + install_repo() default BigscreenVR/spt-bs-releases (SPT_INSTALL_REPO seam) + RELEASES_URL/CONSENT_CHANGELOG_URL/...\n 2614|\n 2615|[[requirements]]\n 2616|id = \"REQ-INSTALL-BOOTSTRAP-VERB\"\n*2617|title = \"THE-FORKENING W1 (ADR-0036 §3, bootstrap shape b operator-ruled): virgin-box install = `gh release download` the platform binary + ONE self-install verb in the binary itself — the verb places the binary at the canonical install path (the path self-update already respawns from, v0.4.2 lesson), registers user PATH, and leaves first-run identity/daemon-start to the existing idempotent first-run; hosted one-liner install scripts (curl|sh / irm|iex at the dead Pages URL) are RETIRED. Non-interacti...\n 2618|required_stages = [\"doc\", \"impl\", \"unit\", \"int\"]  # FLIPPED in the W1 build commit (todlando 2026-07-14). doc = CONTEXT.md Installer-form (gh bootstrap) entry (triage commit) + README.md install section (gh → auth → download → verb). impl = cli.rs Cmd::Install + cmd_install (place at canonical <spt_home>/bin via stage-then-rename, user-PATH registration marker/entry-set-guarded, platform gate install_platform_check, non-interactive by construction, PATH failure degrades loud never fails placement)....\n 2619|\n 2620|[[requirements]]\n...\n 2626|id = \"REQ-RELEASE-CHANNEL-PRIVATE\"\n*2627|title = \"THE-FORKENING W2 (ADR-0036 §2): the publish pipeline targets `BigscreenVR/spt-bs-releases` — release.yml assemble/draft/flip retargeted (draft lands on the bs releases repo, untagged, per the existing spt-releases pattern); docs-publish.yml RETIRED (no public docs, ADR-0014 superseded) with the mdbook build folded into ci.yml as the drift gate (CLAUDE.md mandate: doc generation stays CI-gated); counter + signing key + update-set format CONTINUE unchanged (trust anchor is the continuity, carri...\n 2628|required_stages = [\"impl\", \"int\"]  # impl FLIPPED in the W2 build commit (todlando 2026-07-14): release.yml retargeted to BigscreenVR/spt-bs-releases (draft + assets incl. spt-docs.tar.gz) + docs-publish.yml RETIRED (deleted; repo-face sync retired with it) + the mdbook/bundle drift gate folded into ci.yml (Linux leg: xtask docs-bundle assembles + key entries asserted — the CLAUDE.md doc-generation gate survives the publish pipeline). int FLIPPED in the v0.32.0 release PR (deployah 2026-07-15, doyle-di...\n 2629|\n 2630|[[requirements]]\n...\n 4892|  \"REQ-UPDATE-FETCH-CURRENT-UX\",\n*4893|  \"REQ-UPDATE-GH-TRANSPORT\",\n 4894|  \"REQ-UPDATE-RUNNING-IMAGE-SURFACE\",\n 4895|  \"REQ-RESIDENT-WEB-SKEW-DIAGNOSIS\",\n 4896|]\n...\n 5267|  \"REQ-UPDATE-FINISH-COMMUNE-FLUSH\",\n*5268|  \"REQ-UPDATE-GH-TRANSPORT\",\n 5269|  \"REQ-UPDATE-ONE-SHOT-FINISH\",\n 5270|  \"REQ-UPDATE-RESTART-SAFE-SWAP\",\n 5271|  \"REQ-UPDATE-TRIAL-DRAIN-DRIVE\",\n\n[Some lines truncated to 512 chars]"}],"details":{"scopePath":"traceable-reqs.toml, installer","searchPath":"C:\\Users\\decid\\Documents\\projects\\spt-core","cwd":"C:\\Users\\decid\\Documents\\projects\\spt-core","matchCount":7,"fileCount":1,"files":["traceable-reqs.toml"],"fileMatches":[{"path":"traceable-reqs.toml","count":7}],"truncated":true,"displayContent":"# traceable-reqs.toml\n 2075│title = \"A-4 (F029, operator regression): the picker/confirm views drop an endpoint's adapter `:profile` (showed `claude-spt` where `claude-spt:ccs` was created). ROOT: stamp_creation_fields (spt-store/home.rs) gave the incoming BIND-TIME adapter value UNCONDITIONAL precedence (`rec.adapter = adapter.map(...).or_else(prior)`), but a hook bind resolves the adapter ADAPTER-AGNOSTICALLY (ADR-0021: a binary basename → the BARE parent, profile unknowable), so the first hook bind rewrote the richer `claude-s...\n*2076│required_stages = [\"impl\", \"unit\", \"int\"]  # ACTIVATED F029 W2 (todlando 2026-07-03); int ACTIVATED F029 wave-final (todlando 2026-07-03, doyle ruling: operator-observed field regression = paid-for = conformance E2E). impl = home.rs stamp_creation_fields Some(prior) arm: rec.adapter = match incoming { Some(a) if incoming_is_parent_of_prior(a, prior.adapter) => prior.adapter.clone(), Some(a) => a, None => prior.adapter.clone() } — incoming_is_parent_of_prior = tiny pure helper splitting the prior on the...\n 2077│\n 2078│[[requirements]]\n 2079│id = \"REQ-PICKER-WINDOW-TITLE\"\n     │...\n 2610│[[requirements]]\n*2611│id = \"REQ-UPDATE-GH-TRANSPORT\"\n*2612│title = \"THE-FORKENING W1 (ADR-0036, operator-ruled 2026-07-14): the release channel is PRIVATE (`BigscreenVR/spt-bs-releases`) and the gh CLI is the mandated carrier — release discovery (`releases/latest`, cli.rs:9717) and asset download (cli.rs:4861 public browser URLs) move to deadline-wrapped `gh` subprocess calls (`gh api`, `gh release download`; run_git pattern). WHY gh not token+HTTP: private-repo `browser_download_url` 404s even with a valid token — the API asset-id dance is gh's job. Default...\n 2613│required_stages = [\"doc\", \"impl\", \"unit\", \"int\"]  # FLIPPED in the W1 build commit (todlando 2026-07-14). doc = CONTEXT.md release-channel entry (triage commit) + docs-site self-update gh prerequisite. impl = cli.rs gh_status probe (Missing/Unauthed split) + render_gh_cli_required/render_gh_auth_required + cmd_update_fetch on fetch_release_asset_bytes(Gh) for update-set.json + every artifact + install_repo() default BigscreenVR/spt-bs-releases (SPT_INSTALL_REPO seam) + RELEASES_URL/CONSENT_CHANGELOG_URL/...\n 2614│\n 2615│[[requirements]]\n 2616│id = \"REQ-INSTALL-BOOTSTRAP-VERB\"\n*2617│title = \"THE-FORKENING W1 (ADR-0036 §3, bootstrap shape b operator-ruled): virgin-box install = `gh release download` the platform binary + ONE self-install verb in the binary itself — the verb places the binary at the canonical install path (the path self-update already respawns from, v0.4.2 lesson), registers user PATH, and leaves first-run identity/daemon-start to the existing idempotent first-run; hosted one-liner install scripts (curl|sh / irm|iex at the dead Pages URL) are RETIRED. Non-interacti...\n 2618│required_stages = [\"doc\", \"impl\", \"unit\", \"int\"]  # FLIPPED in the W1 build commit (todlando 2026-07-14). doc = CONTEXT.md Installer-form (gh bootstrap) entry (triage commit) + README.md install section (gh → auth → download → verb). impl = cli.rs Cmd::Install + cmd_install (place at canonical <spt_home>/bin via stage-then-rename, user-PATH registration marker/entry-set-guarded, platform gate install_platform_check, non-interactive by construction, PATH failure degrades loud never fails placement)....\n 2619│\n 2620│[[requirements]]\n     │...\n 2626│id = \"REQ-RELEASE-CHANNEL-PRIVATE\"\n*2627│title = \"THE-FORKENING W2 (ADR-0036 §2): the publish pipeline targets `BigscreenVR/spt-bs-releases` — release.yml assemble/draft/flip retargeted (draft lands on the bs releases repo, untagged, per the existing spt-releases pattern); docs-publish.yml RETIRED (no public docs, ADR-0014 superseded) with the mdbook build folded into ci.yml as the drift gate (CLAUDE.md mandate: doc generation stays CI-gated); counter + signing key + update-set format CONTINUE unchanged (trust anchor is the continuity, carri...\n 2628│required_stages = [\"impl\", \"int\"]  # impl FLIPPED in the W2 build commit (todlando 2026-07-14): release.yml retargeted to BigscreenVR/spt-bs-releases (draft + assets incl. spt-docs.tar.gz) + docs-publish.yml RETIRED (deleted; repo-face sync retired with it) + the mdbook/bundle drift gate folded into ci.yml (Linux leg: xtask docs-bundle assembles + key entries asserted — the CLAUDE.md doc-generation gate survives the publish pipeline). int FLIPPED in the v0.32.0 release PR (deployah 2026-07-15, doyle-di...\n 2629│\n 2630│[[requirements]]\n     │...\n 4892│  \"REQ-UPDATE-FETCH-CURRENT-UX\",\n*4893│  \"REQ-UPDATE-GH-TRANSPORT\",\n 4894│  \"REQ-UPDATE-RUNNING-IMAGE-SURFACE\",\n 4895│  \"REQ-RESIDENT-WEB-SKEW-DIAGNOSIS\",\n 4896│]\n     │...\n 5267│  \"REQ-UPDATE-FINISH-COMMUNE-FLUSH\",\n*5268│  \"REQ-UPDATE-GH-TRANSPORT\",\n 5269│  \"REQ-UPDATE-ONE-SHOT-FINISH\",\n 5270│  \"REQ-UPDATE-RESTART-SAFE-SWAP\",\n 5271│  \"REQ-UPDATE-TRIAL-DRAIN-DRIVE\",","linesTruncated":true,"meta":{"limits":{"columnTruncated":{"maxColumn":512}}}},"isError":false,"timestamp":1790039453757}}
{"type":"message","id":"a7a683d6","parentId":"f23fc3b3","timestamp":"2026-09-22T01:10:53.884Z","message":{"role":"toolResult","toolCallId":"call_AMqsHwtTHpI6bJuKIasCmwCH|fc_06340275dc54e8ed016ab1d59da48887d1a254edaa0739d735","toolName":"read","content":[{"type":"text","text":"URL: https://raw.githubusercontent.com/cli/cli/trunk/pkg/cmd/auth/status/status.go\nContent-Type: text/plain\nMethod: text\n\n---\n\npackage status\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/http\"\n\t\"path/filepath\"\n\t\"slices\"\n\t\"strings\"\n\n\t\"github.com/MakeNowJust/heredoc\"\n\t\"github.com/cli/cli/v2/api\"\n\t\"github.com/cli/cli/v2/internal/config\"\n\t\"github.com/cli/cli/v2/internal/gh\"\n\t\"github.com/cli/cli/v2/pkg/cmd/auth/shared\"\n\t\"github.com/cli/cli/v2/pkg/cmdutil\"\n\t\"github.com/cli/cli/v2/pkg/iostreams\"\n\t\"github.com/spf13/cobra\"\n)\n\ntype authEntryState string\n\nconst (\n\tauthEntryStateSuccess = \"success\"\n\tauthEntryStateTimeout = \"timeout\"\n\tauthEntryStateError   = \"error\"\n)\n\ntype authEntry struct {\n\tState       authEntryState `json:\"state\"`\n\tError       string         `json:\"error,omitempty\"`\n\tActive      bool           `json:\"active\"`\n\tHost        string         `json:\"host\"`\n\tLogin       string         `json:\"login\"`\n\tTokenSource string         `json:\"tokenSource\"`\n\tToken       string         `json:\"token,omitempty\"`\n\tScopes      string         `json:\"scopes,omitempty\"`\n\tGitProtocol string         `json:\"gitProtocol\"`\n}\n\ntype authStatus struct {\n\tHosts map[string][]authEntry `json:\"hosts\"`\n}\n\nfunc newAuthStatus() *authStatus {\n\treturn &authStatus{\n\t\tHosts: make(map[string][]authEntry),\n\t}\n}\n\nvar authStatusFields = []string{\n\t\"hosts\",\n}\n\nfunc (a authStatus) ExportData(fields []string) map[string]any {\n\treturn cmdutil.StructExportData(a, fields)\n}\n\nfunc (e authEntry) String(cs *iostreams.ColorScheme) string {\n\tvar sb strings.Builder\n\n\tswitch e.State {\n\tcase authEntryStateSuccess:\n\t\tsb.WriteString(\n\t\t\tfmt.Sprintf(\"  %s Logged in to %s account %s (%s)\\n\", cs.SuccessIcon(), e.Host, cs.Bold(e.Login), e.TokenSource),\n\t\t)\n\t\tactiveStr := fmt.Sprintf(\"%v\", e.Active)\n\t\tsb.WriteString(fmt.Sprintf(\"  - Active account: %s\\n\", cs.Bold(activeStr)))\n\t\tsb.WriteString(fmt.Sprintf(\"  - Git operations protocol: %s\\n\", cs.Bold(e.GitProtocol)))\n\t\tsb.WriteString(fmt.Sprintf(\"  - Token: %s\\n\", cs.Bold(e.Token)))\n\n\t\tif expectScopes(e.Token) {\n\t\t\tsb.WriteString(fmt.Sprintf(\"  - Token scopes: %s\\n\", cs.Bold(displayScopes(e.Scopes))))\n\t\t\tif err := shared.HeaderHasMinimumScopes(e.Scopes); err != nil {\n\t\t\t\tif missingScopesError, ok := errors.AsType[*shared.MissingScopesError](err); ok {\n\t\t\t\t\tmissingScopes := strings.Join(missingScopesError.MissingScopes, \",\")\n\t\t\t\t\tsb.WriteString(fmt.Sprintf(\"  %s Missing required token scopes: %s\\n\",\n\t\t\t\t\t\tcs.WarningIcon(),\n\t\t\t\t\t\tcs.Bold(displayScopes(missingScopes))))\n\t\t\t\t\trefreshInstructions := fmt.Sprintf(\"gh auth refresh -h %s\", e.Host)\n\t\t\t\t\tsb.WriteString(fmt.Sprintf(\"  - To request missing scopes, run: %s\\n\", cs.Bold(refreshInstructions)))\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\tcase authEntryStateError:\n\t\tif e.Login != \"\" {\n\t\t\tsb.WriteString(fmt.Sprintf(\"  %s Failed to log in to %s account %s (%s)\\n\", cs.Red(\"X\"), e.Host, cs.Bold(e.Login), e.TokenSource))\n\t\t} else {\n\t\t\tsb.WriteString(fmt.Sprintf(\"  %s Failed to log in to %s using token (%s)\\n\", cs.Red(\"X\"), e.Host, e.TokenSource))\n\t\t}\n\t\tactiveStr := fmt.Sprintf(\"%v\", e.Active)\n\t\tsb.WriteString(fmt.Sprintf(\"  - Active account: %s\\n\", cs.Bold(activeStr)))\n\t\tsb.WriteString(fmt.Sprintf(\"  - The token in %s is invalid.\\n\", e.TokenSource))\n\t\tif authTokenWriteable(e.TokenSource) {\n\t\t\tloginInstructions := fmt.Sprintf(\"gh auth login -h %s\", e.Host)\n\t\t\tif shared.AuthTokenRefreshable(e.Token, e.TokenSource) {\n\t\t\t\tloginInstructions = fmt.Sprintf(\"gh auth refresh -h %s\", e.Host)\n\t\t\t}\n\t\t\tlogoutInstructions := fmt.Sprintf(\"gh auth logout -h %s -u %s\", e.Host, e.Login)\n\t\t\tsb.WriteString(fmt.Sprintf(\"  - To re-authenticate, run: %s\\n\", cs.Bold(loginInstructions)))\n\t\t\tsb.WriteString(fmt.Sprintf(\"  - To forget about this account, run: %s\\n\", cs.Bold(logoutInstructions)))\n\t\t}\n\n\tcase authEntryStateTimeout:\n\t\tif e.Login != \"\" {\n\t\t\tsb.WriteString(fmt.Sprintf(\"  %s Timeout trying to log in to %s account %s (%s)\\n\", cs.Red(\"X\"), e.Host, cs.Bold(e.Login), e.TokenSource))\n\t\t} else {\n\t\t\tsb.WriteString(fmt.Sprintf(\"  %s Timeout trying to log in to %s using token (%s)\\n\", cs.Red(\"X\"), e.Host, e.TokenSource))\n\t\t}\n\t\tactiveStr := fmt.Sprintf(\"%v\", e.Active)\n\t\tsb.WriteString(fmt.Sprintf(\"  - Active account: %s\\n\", cs.Bold(activeStr)))\n\t}\n\n\treturn sb.String()\n}\n\ntype StatusOptions struct {\n\tHttpClient func() (*http.Client, error)\n\tIO         *iostreams.IOStreams\n\tConfig     func() (gh.Config, error)\n\tExporter   cmdutil.Exporter\n\n\tHostname  string\n\tShowToken bool\n\tActive    bool\n}\n\nfunc NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Command {\n\topts := &StatusOptions{\n\t\tHttpClient: f.HttpClient,\n\t\tIO:         f.IOStreams,\n\t\tConfig:     f.Config,\n\t}\n\n\tcmd := &cobra.Command{\n\t\tUse:   \"status\",\n\t\tArgs:  cobra.ExactArgs(0),\n\t\tShort: \"Display active account and authentication state on each known GitHub host\",\n\t\tLong: heredoc.Docf(`\n\t\t\tDisplay active account and authentication state on each known GitHub host.\n\n\t\t\tFor each host, the authentication state of each known account is tested and any issues are included in the output.\n\t\t\tEach host section will indicate the active account, which will be used when targeting that host.\n\n\t\t\tIf an account on any host (or only the one given via %[1]s--hostname%[1]s) has authentication issues,\n\t\t\tthe command will exit with 1 and output to stderr. Note that when using the %[1]s--json%[1]s option, the command\n\t\t\twill always exit with zero regardless of any authentication issues, unless there is a fatal error.\n\n\t\t\tTo change the active account for a host, see %[1]sgh auth switch%[1]s.\n\t\t`, \"`\"),\n\t\tExample: heredoc.Doc(`\n\t\t\t# Display authentication status for all accounts on all hosts\n\t\t\t$ gh auth status\n\n\t\t\t# Display authentication status for the active account on a specific host\n\t\t\t$ gh auth status --active --hostname github.example.com\n\t\t\t\n\t\t\t# Display tokens in plain text\n\t\t\t$ gh auth status --show-token\n\n\t\t\t# Format authentication status as JSON\n\t\t\t$ gh auth status --json hosts\n\n\t\t\t# Include plain text token in JSON output\n\t\t\t$ gh auth status --json hosts --show-token\n\n\t\t\t# Format hosts as a flat JSON array\n\t\t\t$ gh auth status --json hosts --jq '.hosts | add'\n\t\t`),\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tif runF != nil {\n\t\t\t\treturn runF(opts)\n\t\t\t}\n\n\t\t\treturn statusRun(opts)\n\t\t},\n\t}\n\n\tcmd.Flags().StringVarP(&opts.Hostname, \"hostname\", \"h\", \"\", \"Check only a specific hostname's auth status\")\n\tcmd.Flags().BoolVarP(&opts.ShowToken, \"show-token\", \"t\", false, \"Display the auth token\")\n\tcmd.Flags().BoolVarP(&opts.Active, \"active\", \"a\", false, \"Display the active account only\")\n\n\t// the json flags are intentionally not given a shorthand to avoid conflict with -t/--show-token\n\tcmdutil.AddJSONFlagsWithoutShorthand(cmd, &opts.Exporter, authStatusFields)\n\n\treturn cmd\n}\n\nfunc statusRun(opts *StatusOptions) error {\n\tcfg, err := opts.Config()\n\tif err != nil {\n\t\treturn err\n\t}\n\tauthCfg := cfg.Authentication()\n\n\tstderr := opts.IO.ErrOut\n\tstdout := opts.IO.Out\n\tcs := opts.IO.ColorScheme()\n\n\thostnames := authCfg.Hosts()\n\tif len(hostnames) == 0 {\n\t\tfmt.Fprintf(stderr,\n\t\t\t\"You are not logged into any GitHub hosts. To log in, run: %s\\n\", cs.Bold(\"gh auth login\"))\n\t\tif opts.Exporter != nil {\n\t\t\t// In machine-friendly mode, we always exit with no error.\n\t\t\topts.Exporter.Write(opts.IO, newAuthStatus())\n\t\t\treturn nil\n\t\t}\n\t\treturn cmdutil.SilentError\n\t}\n\n\tif opts.Hostname != \"\" && !slices.Contains(hostnames, opts.Hostname) {\n\t\tfmt.Fprintf(stderr,\n\t\t\t\"You are not logged into any accounts on %s\\n\", opts.Hostname)\n\t\tif opts.Exporter != nil {\n\t\t\t// In machine-friendly mode, we always exit with no error.\n\t\t\topts.Exporter.Write(opts.IO, newAuthStatus())\n\t\t\treturn nil\n\t\t}\n\t\treturn cmdutil.SilentError\n\t}\n\n\thttpClient, err := opts.HttpClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tvar finalErr error\n\tstatuses := newAuthStatus()\n\n\tfor _, hostname := range hostnames {\n\t\tif opts.Hostname != \"\" && opts.Hostname != hostname {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar activeUser string\n\t\tgitProtocol := cfg.GitProtocol(hostname).Value\n\t\tactiveUserToken, activeUserTokenSource := authCfg.ActiveToken(hostname)\n\t\tif authTokenWriteable(activeUserTokenSource) {\n\t\t\tactiveUser, _ = authCfg.ActiveUser(hostname)\n\t\t}\n\t\tentry := buildEntry(httpClient, buildEntryOptions{\n\t\t\tactive:      true,\n\t\t\tgitProtocol: gitProtocol,\n\t\t\thostname:    hostname,\n\t\t\ttoken:       activeUserToken,\n\t\t\ttokenSource: activeUserTokenSource,\n\t\t\tusername:    activeUser,\n\t\t})\n\t\tstatuses.Hosts[hostname] = append(statuses.Hosts[hostname], entry)\n\n\t\tif finalErr == nil && entry.State != authEntryStateSuccess {\n\t\t\tfinalErr = cmdutil.SilentError\n\t\t}\n\n\t\tif opts.Active {\n\t\t\tcontinue\n\t\t}\n\n\t\tusers := authCfg.UsersForHost(hostname)\n\t\tfor _, username := range users {\n\t\t\tif username == activeUser {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\ttoken, tokenSource, _ := authCfg.TokenForUser(hostname, username)\n\t\t\tentry := buildEntry(httpClient, buildEntryOptions{\n\t\t\t\tactive:      false,\n\t\t\t\tgitProtocol: gitProtocol,\n\t\t\t\thostname:    hostname,\n\t\t\t\ttoken:       token,\n\t\t\t\ttokenSource: tokenSource,\n\t\t\t\tusername:    username,\n\t\t\t})\n\t\t\tstatuses.Hosts[hostname] = append(statuses.Hosts[hostname], entry)\n\n\t\t\tif finalErr == nil && entry.State != authEntryStateSuccess {\n\t\t\t\tfinalErr = cmdutil.SilentError\n\t\t\t}\n\t\t}\n\t}\n\n\tif !opts.ShowToken {\n\t\tfor _, host := range statuses.Hosts {\n\t\t\tfor i := range host {\n\t\t\t\tif opts.Exporter != nil {\n\t\t\t\t\t// In machine-readable we just drop the token\n\t\t\t\t\thost[i].Token = \"\"\n\t\t\t\t} else {\n\t\t\t\t\thost[i].Token = maskToken(host[i].Token)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n[Showing lines 1-300 of 434. Use :301 to continue. Read artifact://1105 for full output]"}],"details":{"kind":"url","url":"https://raw.githubusercontent.com/cli/cli/trunk/pkg/cmd/auth/status/status.go","finalUrl":"https://raw.githubusercontent.com/cli/cli/trunk/pkg/cmd/auth/status/status.go","contentType":"text/plain","method":"text","truncated":true,"notes":[],"meta":{"source":{"type":"url","value":"https://raw.githubusercontent.com/cli/cli/trunk/pkg/cmd/auth/status/status.go"},"truncation":{"direction":"head","truncatedBy":"lines","totalLines":434,"totalBytes":13097,"outputLines":300,"outputBytes":9457,"shownRange":{"start":1,"end":300},"artifactId":"1105","nextOffset":301}}},"isError":false,"timestamp":1790039453884}}
{"type":"custom","customType":"tool_execution_start","data":{"toolCallId":"call_g4gP1FvE05vDR79cezCwiDZd|fc_06340275dc54e8ed016ab1d5a3439887d190c10fa275313223","toolName":"yield","startedAt":"2026-09-22T01:12:13.324Z"},"id":"fde07683","parentId":"a7a683d6","timestamp":"2026-09-22T01:12:13.324Z"}
{"type":"message","id":"cbf6c00e","parentId":"fde07683","timestamp":"2026-09-22T01:12:13.325Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":""},{"type":"toolCall","id":"call_g4gP1FvE05vDR79cezCwiDZd|fc_06340275dc54e8ed016ab1d5a3439887d190c10fa275313223","name":"yield","arguments":{"type":null,"data":{"summary":"#312 root cause confirmed in source: gh_status treats the aggregate `gh auth status` exit code as effective-credential validity, discards diagnostics, and aborts fetch before its working gh release carrier runs. run_bounded_command DOES inherit the invoking spt process environment, including GH_TOKEN. No commands, auth checks, secret inspection, edits, builds, or tests performed.","files":[{"path":"crates/spt/src/cli.rs:10018-10112","description":"Duplicate aggregate-status probes; GhStatus erases failure detail; fixed-string authentication refusal."},{"path":"crates/spt/src/cli.rs:10970-11145","description":"Fetch preflight veto precedes signed-set download; subsequent metadata and artifact verification must remain unchanged."},{"path":"crates/spt/src/cli.rs:9643-9680","description":"Both composite plans use cmd_update_fetch; core failure aborts adapters and restart finish."},{"path":"crates/spt-runtime/src/runtime.rs:960-1010,1027-1034,1075-1127","description":"Monotonic timeout/kill/reap; plain Command child construction inherits environment; no GH-token stripping."},{"path":"crates/spt/src/cli.rs:10560-10639,22491-22537","description":"Existing bounded positive API/download patterns: gh latest-release query and asset downloads."},{"path":"crates/spt/tests/fixtures/gh_fixture.rs:1-74","description":"Existing PATH-injected native gh fixture; currently always passes auth and treats every API invocation as latest-tag query."},{"path":"crates/spt/tests/composite_e2e.rs:38-68,103-230","description":"Reusable signed set, isolated home, fake gh PATH, child-local environment, and observable installed-byte fixture."},{"path":"crates/spt/src/cli.rs:35333-35357","description":"gh_failure_classes_render_os_correct_hints is a wording/render-only test; remove rather than repin changed prose."},{"path":"docs/DEBUG-ROLLOUT.md; docs/adr/0016-platform-targeted-update-sets.md; docs/adr/0036-private-fork-distribution.md","description":"Carrier-independent signatures, channel/version/platform policy, mandated gh carrier, and maintainer-only debug tooling authority."}],"architecture":"CLI dispatch → cmd_update_composite or direct update fetch → cmd_update_fetch → gh_status → spt_runtime::run_bounded_command → Command::spawn/wait_bounded. Only after Available does fetch download signed metadata and artifacts through gh, verify them, stage, and optionally apply. Authentication preflight is local CLI policy, not release trust authority.","report":"## Exact RCA / environment\n- Source chain: direct fetch dispatch cli.rs:2489; composite FetchApply/Fetch cli.rs:9647-9648 → cmd_update_fetch:10970 → gh_status:10062. `Ok(out) if out.success()` alone yields Available; NotFound yields Missing; EVERYTHING else yields Unauthed (:10069-10075). BoundedOutput::success is strictly status_code == Some(0) (runtime.rs:591-600).\n- The issue's measured working release-list with GH_TOKEN is accepted as ground truth: https://github.com/BigscreenVR/spt-bs-releases/issues/312. GitHub documents aggregate status failure for ANY account on ANY checked host: https://cli.github.com/manual/gh_auth_status. Therefore even another host's stale account can veto this gate. Checking “any success line” is not a safe fix either: a non-effective account may work while the effective environment token fails.\n- Environment question is resolved: runtime.rs:1033 delegates to run_bounded_command_in; :1084-1127 tokenizes argv, calls Command::new, configures stdio/Windows CREATE_NO_WINDOW, then spawn. No env_clear, env_remove, explicit allowlist, or manifest environment filtering occurs on this free-function path. Empty BTreeMap means no template substitutions, NOT empty child environment. Child inherits the invoking spt process environment. GitHub's documented GH_TOKEN > GITHUB_TOKEN > stored credential precedence explains why the release read succeeds: https://cli.github.com/manual/gh_help_environment. This establishes inheritance from spt, not what credentials any independently launched process happens to possess.\n- Refusal loses all useful evidence: stdout/stderr already exist in BoundedOutput, but gh_status discards them, GhStatus::Unauthed carries nothing, render_gh_auth_required takes no parameters, and cmd_update_fetch prints its generic message then returns 1 (:10984-10986). Composite returns that code and skips remaining legs (:9670-9676). No cache opening or download is reached.\n\n## Minimal compatible implementation seam\nRecommended: make `gh_status(repo: &str)` use a positive, read-only release-channel probe FIRST, e.g. `gh release list --repo {repo} --limit 1 --json tagName`, through the existing template-key bounded runner. Pass the already-resolved install_repo() from cmd_update_fetch. This closely matches the user's proven working operation, shares actual fetch repo/host resolution, and avoids requiring user-profile permissions unrelated to release access. Command options are documented at https://cli.github.com/manual/gh_release_list. Success means readable channel, even an empty release list; missing update sets remain downstream download failures. Do not require a latest release just to authorize a pinned `--tag` fetch.\n- Positive success → Available immediately, regardless of stale stored accounts. Spawn NotFound → existing Missing/install hint. Failed/timeout read → preserve nonzero refusal, never mark success because a token exists or a different account passes status. Existing two failure classes can remain; describe inability to authenticate/read the channel rather than asserting every failed request proves invalid credentials (network, scope, and repo access failures also exist).\n- Do NOT use `gh auth token` as the positive authority: it retrieves a token rather than proving it works and places credential bytes in captured stdout. `gh api user` is a positive authentication pattern but tests a different permission surface than release reading.\n- On failed positive probe ONLY, bounded `gh auth status` may supply optional stale-account diagnostics, never the success decision. Carry sanitized optional account information in Unauthed and into render_gh_auth_required; this is an internal enum/render/caller change, not a wire format change. Remove Copy if owned strings are carried.\n- Preserve the current 10-second preflight budget, not two sequential full ten-second waits: record one Instant/deadline, give the positive read the budget and optional diagnostic only its remaining duration; skip diagnostics when exhausted. Preserve NotFound/timeout handling, null stdin, CREATE_NO_WINDOW, and existing 300-second asset / 60-second adapter-version bounds. Existing wait_bounded uses monotonic elapsed time, polls every 20 ms, and kills/reaps on timeout (runtime.rs:986-1004); it is not a strict end-to-end scheduler guarantee. No runtime environment changes are required.\n- Existing positive patterns to reuse: gh_version_command(:10564) builds `gh api repos/{repo}/releases/latest --jq .tag_name`; gh_latest_release_version(:22520) accepts only successful command output; fetch_release_asset_bytes(:10620) likewise accepts successful downloads. None is currently an effective-credential preflight.\n- Related but separate duplicate: gh_available(:10021) also uses aggregate auth status and drives adapter Auto at :10853 and :22287. Explicit Gh skips it. Its stale-account behavior can still make a composite adapter leg fall back to HTTPS. Do not silently expand #312 into adapter policy work; Main should explicitly decide whether that companion is in the wave. If included, use the actual adapter repo in its positive probe and migrate both Auto call sites rather than checking the core release repo for adapters.\n\n## Safe stale-account naming\nUpstream source evidence: https://raw.githubusercontent.com/cli/cli/trunk/pkg/cmd/auth/status/status.go — authEntry.String emits `Failed to log in to <host> account <login> (<source>)`; timeout has a DIFFERENT `Timeout trying to log in...` line, and token-only failures have no account. statusRun evaluates the active token and additional stored accounts and accumulates any failure.\n- Smallest older-gh-compatible seam: pure parser extracts ONLY host/login from explicit failed-account lines in captured stderr/stdout; validate conservative bounded ASCII host/login characters, discard ANSI/control/unrecognized input, and never print whole gh output, token-source paths, Token lines, or error blobs. Do not mistake Logged-in or Timeout lines for confirmed stale accounts. Refer to “gh reports failed account ...”, not “this account caused the release-read failure.” Account metadata is advisory; omission on unknown formatting is safe.\n- Structured `gh auth status --json hosts` is an alternative only with a supported-gh-version assumption. Current JSON has host/login/state/tokenSource; token is omitted without --show-token. Critical trap: JSON status exits ZERO even when unauthenticated, so its exit code must NEVER become the positive probe. Installed gh version/JSON support is unmeasured; no secret-bearing output was inspected. Never auto logout, switch accounts, refresh, modify credential storage, or print --show-token.\n\n## Fixtures / observable regression contracts\n- Extend existing gh_fixture instead of a second fake. Today its auth arm always succeeds and API arm accepts any endpoint, so it cannot distinguish this bug. Add narrowly defined read-probe success/failure and stale-status modes; use a synthetic child-local GH_TOKEN marker and check it inside the fake without logging it. Existing argv log is enough to observe downloads without recording secrets.\n- Use composite_e2e signed_set_json/test-key/home/PATH setup, but drive `spt update fetch` WITHOUT --apply for the new gate regression: assert successful signed metadata/artifact staging despite status exit 1 + stale account, using a deliberately fake token marker. No real auth/network required and no binary swap needed. This catches both the gate bug and lost environment inheritance.\n- Negative case: effective read fails even with another account reported successful → nonzero GhAuthRequired; no download or newly staged set. Optional stale account appears safely in diagnostic; synthetic secret-bearing lines and terminal escapes never reach output. Missing executable remains distinct GhCliRequired. A hanging probe must refuse within the bounded runner policy and never start download. Prefer injected runner/deadline seam for deterministic classification/budget edges, actual fixture for the subprocess/environment/staging contract.\n- Existing runtime bounded_run_kills_on_timeout (:1724-1741) exercises the shared wait_bounded via ManifestRuntime, not gh_status itself. Keep it; no runtime change is needed.\n- Remove gh_failure_classes_render_os_correct_hints (:35337), rather than repinning its contains-prose assertions. Move meaningful failure-class/exit/no-staging evidence into behavioral tests above; retain OS hints in production. resolve_transport tests (:26539-26568) exercise explicit-vs-Auto policy and remain useful if adapter work is approved.\n\n## Authority / uncertainty / scope\nADR-0036 §3 mandates gh carriage and loud missing/unauthed refusal; positive read changes evidence for availability, not the carrier or release trust. Keep verify_update_set_metadata (:11045), verify_update_set_artifact (:11106), signing anchors, channel pinning, rollback floor, consent/apply, and debug restrictions unchanged. Traceability owner is REQ-UPDATE-GH-TRANSPORT (traceable-reqs.toml:2611-2613); replace removed render-only unit evidence with actual classification/parser behavior. Debug runbook was read; its validation instructions were intentionally not executed under this read-only assignment. No LSP device is available in the mounted inventory; references were resolved by repository search. Upstream status format is current trunk, not evidence of the field gh version. No claim of runtime validation or ticket closure is made; this is implementation-ready source RCA."},"error":null}}],"api":"openai-codex-responses","provider":"openai-codex","model":"gpt-6-astra","usage":{"input":4941,"output":2573,"cacheRead":72320,"cacheWrite":0,"totalTokens":79834,"reasoningTokens":63,"cost":{"input":0.04941,"output":0.12865000000000001,"cacheRead":0.07232,"cacheWrite":0,"total":0.25038000000000005}},"stopReason":"toolUse","timestamp":1790039453937,"responseId":"resp_06340275dc54e8ed016ab1d59e334487d189f7e812a9a0ca17","providerPayload":{"type":"openaiResponsesHistory","provider":"openai-codex","dt":true,"items":[{"id":"rs_06340275dc54e8ed016ab1d5a0115087d180b71db64a8ccb0d","type":"reasoning","content":[],"encrypted_content":"gAAAAABqsdWj3b4OzlcXtc9rhyI_vwgVcnfh5_kbdbZ3b3ona-wGYISg_OWADzeO4KPn7PKzv4tf0zOIhaALJYvWtIIc5ZjkvUIqowh6UykF6C7P1unD0KVct58fzeKuqVyidyC5hQAAzjEkkP1j-kPtHJBvPgfOApQhvV5Gxv6oZkuG1w3fabfxHDY-3aFsSFD5QGzZfOS8mplruzeba78NlqtkQJlaK601lKOLyMGhrk4AZheHcgkszM1buKfviML6nC24CqBMN4AVKzZbdVdyjNbHYGq4SkM4wWH7Vy2tWvnlPrlN8133L-IBm1r7wH71jec6X3rSqYkEwlyBLyCdIh8bWLKbh0RBXGObwb4JqUYzQuy7dIlcbuimyI7bVi7zfQmlR81flfOFDh5YqNozq5Ltpzdm13SdgfkQ4JpmqN7U05x7BxB9lVvlzGvcgxO1UdChsSy3ccCZ40l-5b4cilbQmNAN2jghsz4VReFEbIrHcKktgpTj1Ue9-5vfRuNU9cYAut7tDnSg9ctFpHNllpsOmx6gMPLzSrhNZyF9bXvU-sMMDUDX8bS9Xq8hTTcyH7rLs22KClCyYBqr_t1m5b4FpI5PZTAlZm_BM5QsDFvsh6yl7QPFel75anH8sNwiFobRRIEZ1i3YPpyoCRt6ivAAAqObr2lsBZUILkI09lOqWAxz2B2E4NL1ZytxT4iOtu_snZz8a2mEr4v81LZ-B-DULj4htPWn-U6PMfLPn7tnH5WwOfVKrp9NLd4q8E0V12AZ_j4IOrsOE8NKlNIg6KBDB25SdHoXzubBGwo4W9ktND-7I-aspEiesn9GsM09CQMqcF1dWpsoF2X4i0ZdyEA122OM2b7ZgeqYPTOfgJArSv7fK2zWhllJDLXUZJFXje66BjYR55Oyrs7LT6C4x7NUgzSi1_hbyPE5KoQ676goylQiv0G7oHPDCIM5bOSRZth6JUk9u3M0CvUBzU5IgU02rjyGkLZdcFSw7oQaAHivl8tLGsgHC8vh3duZcdH7kRoLGSzoD9AC2ggVj-iw8TsDbG_zqAZWkNyl6A6a6A34vtA8i5ePqOjitMTc74b_qivS_pUGTxXzo0JU_BUqFXtoaTaY--phVl9mJuVOmBymuc9sxMHqMCyADeoPKHecMAqPavTzBVToq1lIIrHpSGEAgSi7HAoYUbvXcTumQJGR5gtrM679FVeKf__-f3EV8rc_uPEZVjIrqCK_jthbNnGtU7IDOcgpMFwnqfK406gyZyVdOM6f6qr31hlv2A0fgjRhKSimgy5WDZ7HUUijoOJEr6sdVbD0iqqQPxw0leNjB9-BVafOFzcW2HmP45Yxja76qhwr8ADjLNrstb2mGIk-P_uevA-gfU5oR79QG7N5K5NladYCRZ7CaFv52u-EBgYlf4nnK6_f-A7Lr3wXpj6L9Vlv-KpA793bj9P-7dzD3kdpRlOKdf5Ww65fbTCKXiGNkNj-6J4yK46qFozIWL35gWUJOW9EoqaIK_S1rt7Zff8wpDm8t1V1nvfVK0vZgwBTvzxs-qWZwg2vKA9diUgpdHpaC88yXniPnd9nfqgmjsdjDWOUkWodTaJRjbTa5YNecg-M5yGyIrQA24L3y8wCFcq1LefKYiQPMeA3UYzMqCLZ3Z3x8eoLgr2Jii0iWopPWBO7Ogudi1Fi0HSmmkAgcwN-G-IYpy-5CWSkO3an3XkGWnaL5850OOcbhhLNxtv_H6H1X3ijPG8YxSFFOAJ4yZ-zSKLFUlpvSqHvJIW1qdU9hKDnQtTH37w2Yf7WOZi1o01i8UNf5yumWMaP5fBRb5FU8Q==","internal_chat_message_metadata_passthrough":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"},"summary":[],"metadata":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"}},{"id":"fc_06340275dc54e8ed016ab1d5a3439887d190c10fa275313223","type":"function_call","status":"completed","arguments":"{\"type\":null,\"data\":{\"summary\":\"#312 root cause confirmed in source: gh_status treats the aggregate `gh auth status` exit code as effective-credential validity, discards diagnostics, and aborts fetch before its working gh release carrier runs. run_bounded_command DOES inherit the invoking spt process environment, including GH_TOKEN. No commands, auth checks, secret inspection, edits, builds, or tests performed.\",\"files\":[{\"path\":\"crates/spt/src/cli.rs:10018-10112\",\"description\":\"Duplicate aggregate-status probes; GhStatus erases failure detail; fixed-string authentication refusal.\"},{\"path\":\"crates/spt/src/cli.rs:10970-11145\",\"description\":\"Fetch preflight veto precedes signed-set download; subsequent metadata and artifact verification must remain unchanged.\"},{\"path\":\"crates/spt/src/cli.rs:9643-9680\",\"description\":\"Both composite plans use cmd_update_fetch; core failure aborts adapters and restart finish.\"},{\"path\":\"crates/spt-runtime/src/runtime.rs:960-1010,1027-1034,1075-1127\",\"description\":\"Monotonic timeout/kill/reap; plain Command child construction inherits environment; no GH-token stripping.\"},{\"path\":\"crates/spt/src/cli.rs:10560-10639,22491-22537\",\"description\":\"Existing bounded positive API/download patterns: gh latest-release query and asset downloads.\"},{\"path\":\"crates/spt/tests/fixtures/gh_fixture.rs:1-74\",\"description\":\"Existing PATH-injected native gh fixture; currently always passes auth and treats every API invocation as latest-tag query.\"},{\"path\":\"crates/spt/tests/composite_e2e.rs:38-68,103-230\",\"description\":\"Reusable signed set, isolated home, fake gh PATH, child-local environment, and observable installed-byte fixture.\"},{\"path\":\"crates/spt/src/cli.rs:35333-35357\",\"description\":\"gh_failure_classes_render_os_correct_hints is a wording/render-only test; remove rather than repin changed prose.\"},{\"path\":\"docs/DEBUG-ROLLOUT.md; docs/adr/0016-platform-targeted-update-sets.md; docs/adr/0036-private-fork-distribution.md\",\"description\":\"Carrier-independent signatures, channel/version/platform policy, mandated gh carrier, and maintainer-only debug tooling authority.\"}],\"architecture\":\"CLI dispatch → cmd_update_composite or direct update fetch → cmd_update_fetch → gh_status → spt_runtime::run_bounded_command → Command::spawn/wait_bounded. Only after Available does fetch download signed metadata and artifacts through gh, verify them, stage, and optionally apply. Authentication preflight is local CLI policy, not release trust authority.\",\"report\":\"## Exact RCA / environment\\n- Source chain: direct fetch dispatch cli.rs:2489; composite FetchApply/Fetch cli.rs:9647-9648 → cmd_update_fetch:10970 → gh_status:10062. `Ok(out) if out.success()` alone yields Available; NotFound yields Missing; EVERYTHING else yields Unauthed (:10069-10075). BoundedOutput::success is strictly status_code == Some(0) (runtime.rs:591-600).\\n- The issue's measured working release-list with GH_TOKEN is accepted as ground truth: https://github.com/BigscreenVR/spt-bs-releases/issues/312. GitHub documents aggregate status failure for ANY account on ANY checked host: https://cli.github.com/manual/gh_auth_status. Therefore even another host's stale account can veto this gate. Checking “any success line” is not a safe fix either: a non-effective account may work while the effective environment token fails.\\n- Environment question is resolved: runtime.rs:1033 delegates to run_bounded_command_in; :1084-1127 tokenizes argv, calls Command::new, configures stdio/Windows CREATE_NO_WINDOW, then spawn. No env_clear, env_remove, explicit allowlist, or manifest environment filtering occurs on this free-function path. Empty BTreeMap means no template substitutions, NOT empty child environment. Child inherits the invoking spt process environment. GitHub's documented GH_TOKEN > GITHUB_TOKEN > stored credential precedence explains why the release read succeeds: https://cli.github.com/manual/gh_help_environment. This establishes inheritance from spt, not what credentials any independently launched process happens to possess.\\n- Refusal loses all useful evidence: stdout/stderr already exist in BoundedOutput, but gh_status discards them, GhStatus::Unauthed carries nothing, render_gh_auth_required takes no parameters, and cmd_update_fetch prints its generic message then returns 1 (:10984-10986). Composite returns that code and skips remaining legs (:9670-9676). No cache opening or download is reached.\\n\\n## Minimal compatible implementation seam\\nRecommended: make `gh_status(repo: &str)` use a positive, read-only release-channel probe FIRST, e.g. `gh release list --repo {repo} --limit 1 --json tagName`, through the existing template-key bounded runner. Pass the already-resolved install_repo() from cmd_update_fetch. This closely matches the user's proven working operation, shares actual fetch repo/host resolution, and avoids requiring user-profile permissions unrelated to release access. Command options are documented at https://cli.github.com/manual/gh_release_list. Success means readable channel, even an empty release list; missing update sets remain downstream download failures. Do not require a latest release just to authorize a pinned `--tag` fetch.\\n- Positive success → Available immediately, regardless of stale stored accounts. Spawn NotFound → existing Missing/install hint. Failed/timeout read → preserve nonzero refusal, never mark success because a token exists or a different account passes status. Existing two failure classes can remain; describe inability to authenticate/read the channel rather than asserting every failed request proves invalid credentials (network, scope, and repo access failures also exist).\\n- Do NOT use `gh auth token` as the positive authority: it retrieves a token rather than proving it works and places credential bytes in captured stdout. `gh api user` is a positive authentication pattern but tests a different permission surface than release reading.\\n- On failed positive probe ONLY, bounded `gh auth status` may supply optional stale-account diagnostics, never the success decision. Carry sanitized optional account information in Unauthed and into render_gh_auth_required; this is an internal enum/render/caller change, not a wire format change. Remove Copy if owned strings are carried.\\n- Preserve the current 10-second preflight budget, not two sequential full ten-second waits: record one Instant/deadline, give the positive read the budget and optional diagnostic only its remaining duration; skip diagnostics when exhausted. Preserve NotFound/timeout handling, null stdin, CREATE_NO_WINDOW, and existing 300-second asset / 60-second adapter-version bounds. Existing wait_bounded uses monotonic elapsed time, polls every 20 ms, and kills/reaps on timeout (runtime.rs:986-1004); it is not a strict end-to-end scheduler guarantee. No runtime environment changes are required.\\n- Existing positive patterns to reuse: gh_version_command(:10564) builds `gh api repos/{repo}/releases/latest --jq .tag_name`; gh_latest_release_version(:22520) accepts only successful command output; fetch_release_asset_bytes(:10620) likewise accepts successful downloads. None is currently an effective-credential preflight.\\n- Related but separate duplicate: gh_available(:10021) also uses aggregate auth status and drives adapter Auto at :10853 and :22287. Explicit Gh skips it. Its stale-account behavior can still make a composite adapter leg fall back to HTTPS. Do not silently expand #312 into adapter policy work; Main should explicitly decide whether that companion is in the wave. If included, use the actual adapter repo in its positive probe and migrate both Auto call sites rather than checking the core release repo for adapters.\\n\\n## Safe stale-account naming\\nUpstream source evidence: https://raw.githubusercontent.com/cli/cli/trunk/pkg/cmd/auth/status/status.go — authEntry.String emits `Failed to log in to <host> account <login> (<source>)`; timeout has a DIFFERENT `Timeout trying to log in...` line, and token-only failures have no account. statusRun evaluates the active token and additional stored accounts and accumulates any failure.\\n- Smallest older-gh-compatible seam: pure parser extracts ONLY host/login from explicit failed-account lines in captured stderr/stdout; validate conservative bounded ASCII host/login characters, discard ANSI/control/unrecognized input, and never print whole gh output, token-source paths, Token lines, or error blobs. Do not mistake Logged-in or Timeout lines for confirmed stale accounts. Refer to “gh reports failed account ...”, not “this account caused the release-read failure.” Account metadata is advisory; omission on unknown formatting is safe.\\n- Structured `gh auth status --json hosts` is an alternative only with a supported-gh-version assumption. Current JSON has host/login/state/tokenSource; token is omitted without --show-token. Critical trap: JSON status exits ZERO even when unauthenticated, so its exit code must NEVER become the positive probe. Installed gh version/JSON support is unmeasured; no secret-bearing output was inspected. Never auto logout, switch accounts, refresh, modify credential storage, or print --show-token.\\n\\n## Fixtures / observable regression contracts\\n- Extend existing gh_fixture instead of a second fake. Today its auth arm always succeeds and API arm accepts any endpoint, so it cannot distinguish this bug. Add narrowly defined read-probe success/failure and stale-status modes; use a synthetic child-local GH_TOKEN marker and check it inside the fake without logging it. Existing argv log is enough to observe downloads without recording secrets.\\n- Use composite_e2e signed_set_json/test-key/home/PATH setup, but drive `spt update fetch` WITHOUT --apply for the new gate regression: assert successful signed metadata/artifact staging despite status exit 1 + stale account, using a deliberately fake token marker. No real auth/network required and no binary swap needed. This catches both the gate bug and lost environment inheritance.\\n- Negative case: effective read fails even with another account reported successful → nonzero GhAuthRequired; no download or newly staged set. Optional stale account appears safely in diagnostic; synthetic secret-bearing lines and terminal escapes never reach output. Missing executable remains distinct GhCliRequired. A hanging probe must refuse within the bounded runner policy and never start download. Prefer injected runner/deadline seam for deterministic classification/budget edges, actual fixture for the subprocess/environment/staging contract.\\n- Existing runtime bounded_run_kills_on_timeout (:1724-1741) exercises the shared wait_bounded via ManifestRuntime, not gh_status itself. Keep it; no runtime change is needed.\\n- Remove gh_failure_classes_render_os_correct_hints (:35337), rather than repinning its contains-prose assertions. Move meaningful failure-class/exit/no-staging evidence into behavioral tests above; retain OS hints in production. resolve_transport tests (:26539-26568) exercise explicit-vs-Auto policy and remain useful if adapter work is approved.\\n\\n## Authority / uncertainty / scope\\nADR-0036 §3 mandates gh carriage and loud missing/unauthed refusal; positive read changes evidence for availability, not the carrier or release trust. Keep verify_update_set_metadata (:11045), verify_update_set_artifact (:11106), signing anchors, channel pinning, rollback floor, consent/apply, and debug restrictions unchanged. Traceability owner is REQ-UPDATE-GH-TRANSPORT (traceable-reqs.toml:2611-2613); replace removed render-only unit evidence with actual classification/parser behavior. Debug runbook was read; its validation instructions were intentionally not executed under this read-only assignment. No LSP device is available in the mounted inventory; references were resolved by repository search. Upstream status format is current trunk, not evidence of the field gh version. No claim of runtime validation or ticket closure is made; this is implementation-ready source RCA.\"},\"error\":null}","call_id":"call_g4gP1FvE05vDR79cezCwiDZd","internal_chat_message_metadata_passthrough":{"create_time":1790039454.370255,"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"},"name":"yield","metadata":{"turn_id":"7cd027e3-d19e-439a-97a0-11d66637da0c"}}]},"duration":79381.3200000003,"ttft":2144.985400000587,"completedAt":1790039533322,"contextSnapshot":{"promptTokens":77261,"nonMessageTokens":10333,"compactionEpoch":0}}}
{"type":"message","id":"ae46828f","parentId":"cbf6c00e","timestamp":"2026-09-22T01:12:13.328Z","message":{"role":"toolResult","toolCallId":"call_g4gP1FvE05vDR79cezCwiDZd|fc_06340275dc54e8ed016ab1d5a3439887d190c10fa275313223","toolName":"yield","content":[{"type":"text","text":"Result submitted."}],"details":{"data":{"summary":"#312 root cause confirmed in source: gh_status treats the aggregate `gh auth status` exit code as effective-credential validity, discards diagnostics, and aborts fetch before its working gh release carrier runs. run_bounded_command DOES inherit the invoking spt process environment, including GH_TOKEN. No commands, auth checks, secret inspection, edits, builds, or tests performed.","files":[{"path":"crates/spt/src/cli.rs:10018-10112","description":"Duplicate aggregate-status probes; GhStatus erases failure detail; fixed-string authentication refusal."},{"path":"crates/spt/src/cli.rs:10970-11145","description":"Fetch preflight veto precedes signed-set download; subsequent metadata and artifact verification must remain unchanged."},{"path":"crates/spt/src/cli.rs:9643-9680","description":"Both composite plans use cmd_update_fetch; core failure aborts adapters and restart finish."},{"path":"crates/spt-runtime/src/runtime.rs:960-1010,1027-1034,1075-1127","description":"Monotonic timeout/kill/reap; plain Command child construction inherits environment; no GH-token stripping."},{"path":"crates/spt/src/cli.rs:10560-10639,22491-22537","description":"Existing bounded positive API/download patterns: gh latest-release query and asset downloads."},{"path":"crates/spt/tests/fixtures/gh_fixture.rs:1-74","description":"Existing PATH-injected native gh fixture; currently always passes auth and treats every API invocation as latest-tag query."},{"path":"crates/spt/tests/composite_e2e.rs:38-68,103-230","description":"Reusable signed set, isolated home, fake gh PATH, child-local environment, and observable installed-byte fixture."},{"path":"crates/spt/src/cli.rs:35333-35357","description":"gh_failure_classes_render_os_correct_hints is a wording/render-only test; remove rather than repin changed prose."},{"path":"docs/DEBUG-ROLLOUT.md; docs/adr/0016-platform-targeted-update-sets.md; docs/adr/0036-private-fork-distribution.md","description":"Carrier-independent signatures, channel/version/platform policy, mandated gh carrier, and maintainer-only debug tooling authority."}],"architecture":"CLI dispatch → cmd_update_composite or direct update fetch → cmd_update_fetch → gh_status → spt_runtime::run_bounded_command → Command::spawn/wait_bounded. Only after Available does fetch download signed metadata and artifacts through gh, verify them, stage, and optionally apply. Authentication preflight is local CLI policy, not release trust authority.","report":"## Exact RCA / environment\n- Source chain: direct fetch dispatch cli.rs:2489; composite FetchApply/Fetch cli.rs:9647-9648 → cmd_update_fetch:10970 → gh_status:10062. `Ok(out) if out.success()` alone yields Available; NotFound yields Missing; EVERYTHING else yields Unauthed (:10069-10075). BoundedOutput::success is strictly status_code == Some(0) (runtime.rs:591-600).\n- The issue's measured working release-list with GH_TOKEN is accepted as ground truth: https://github.com/BigscreenVR/spt-bs-releases/issues/312. GitHub documents aggregate status failure for ANY account on ANY checked host: https://cli.github.com/manual/gh_auth_status. Therefore even another host's stale account can veto this gate. Checking “any success line” is not a safe fix either: a non-effective account may work while the effective environment token fails.\n- Environment question is resolved: runtime.rs:1033 delegates to run_bounded_command_in; :1084-1127 tokenizes argv, calls Command::new, configures stdio/Windows CREATE_NO_WINDOW, then spawn. No env_clear, env_remove, explicit allowlist, or manifest environment filtering occurs on this free-function path. Empty BTreeMap means no template substitutions, NOT empty child environment. Child inherits the invoking spt process environment. GitHub's documented GH_TOKEN > GITHUB_TOKEN > stored credential precedence explains why the release read succeeds: https://cli.github.com/manual/gh_help_environment. This establishes inheritance from spt, not what credentials any independently launched process happens to possess.\n- Refusal loses all useful evidence: stdout/stderr already exist in BoundedOutput, but gh_status discards them, GhStatus::Unauthed carries nothing, render_gh_auth_required takes no parameters, and cmd_update_fetch prints its generic message then returns 1 (:10984-10986). Composite returns that code and skips remaining legs (:9670-9676). No cache opening or download is reached.\n\n## Minimal compatible implementation seam\nRecommended: make `gh_status(repo: &str)` use a positive, read-only release-channel probe FIRST, e.g. `gh release list --repo {repo} --limit 1 --json tagName`, through the existing template-key bounded runner. Pass the already-resolved install_repo() from cmd_update_fetch. This closely matches the user's proven working operation, shares actual fetch repo/host resolution, and avoids requiring user-profile permissions unrelated to release access. Command options are documented at https://cli.github.com/manual/gh_release_list. Success means readable channel, even an empty release list; missing update sets remain downstream download failures. Do not require a latest release just to authorize a pinned `--tag` fetch.\n- Positive success → Available immediately, regardless of stale stored accounts. Spawn NotFound → existing Missing/install hint. Failed/timeout read → preserve nonzero refusal, never mark success because a token exists or a different account passes status. Existing two failure classes can remain; describe inability to authenticate/read the channel rather than asserting every failed request proves invalid credentials (network, scope, and repo access failures also exist).\n- Do NOT use `gh auth token` as the positive authority: it retrieves a token rather than proving it works and places credential bytes in captured stdout. `gh api user` is a positive authentication pattern but tests a different permission surface than release reading.\n- On failed positive probe ONLY, bounded `gh auth status` may supply optional stale-account diagnostics, never the success decision. Carry sanitized optional account information in Unauthed and into render_gh_auth_required; this is an internal enum/render/caller change, not a wire format change. Remove Copy if owned strings are carried.\n- Preserve the current 10-second preflight budget, not two sequential full ten-second waits: record one Instant/deadline, give the positive read the budget and optional diagnostic only its remaining duration; skip diagnostics when exhausted. Preserve NotFound/timeout handling, null stdin, CREATE_NO_WINDOW, and existing 300-second asset / 60-second adapter-version bounds. Existing wait_bounded uses monotonic elapsed time, polls every 20 ms, and kills/reaps on timeout (runtime.rs:986-1004); it is not a strict end-to-end scheduler guarantee. No runtime environment changes are required.\n- Existing positive patterns to reuse: gh_version_command(:10564) builds `gh api repos/{repo}/releases/latest --jq .tag_name`; gh_latest_release_version(:22520) accepts only successful command output; fetch_release_asset_bytes(:10620) likewise accepts successful downloads. None is currently an effective-credential preflight.\n- Related but separate duplicate: gh_available(:10021) also uses aggregate auth status and drives adapter Auto at :10853 and :22287. Explicit Gh skips it. Its stale-account behavior can still make a composite adapter leg fall back to HTTPS. Do not silently expand #312 into adapter policy work; Main should explicitly decide whether that companion is in the wave. If included, use the actual adapter repo in its positive probe and migrate both Auto call sites rather than checking the core release repo for adapters.\n\n## Safe stale-account naming\nUpstream source evidence: https://raw.githubusercontent.com/cli/cli/trunk/pkg/cmd/auth/status/status.go — authEntry.String emits `Failed to log in to <host> account <login> (<source>)`; timeout has a DIFFERENT `Timeout trying to log in...` line, and token-only failures have no account. statusRun evaluates the active token and additional stored accounts and accumulates any failure.\n- Smallest older-gh-compatible seam: pure parser extracts ONLY host/login from explicit failed-account lines in captured stderr/stdout; validate conservative bounded ASCII host/login characters, discard ANSI/control/unrecognized input, and never print whole gh output, token-source paths, Token lines, or error blobs. Do not mistake Logged-in or Timeout lines for confirmed stale accounts. Refer to “gh reports failed account ...”, not “this account caused the release-read failure.” Account metadata is advisory; omission on unknown formatting is safe.\n- Structured `gh auth status --json hosts` is an alternative only with a supported-gh-version assumption. Current JSON has host/login/state/tokenSource; token is omitted without --show-token. Critical trap: JSON status exits ZERO even when unauthenticated, so its exit code must NEVER become the positive probe. Installed gh version/JSON support is unmeasured; no secret-bearing output was inspected. Never auto logout, switch accounts, refresh, modify credential storage, or print --show-token.\n\n## Fixtures / observable regression contracts\n- Extend existing gh_fixture instead of a second fake. Today its auth arm always succeeds and API arm accepts any endpoint, so it cannot distinguish this bug. Add narrowly defined read-probe success/failure and stale-status modes; use a synthetic child-local GH_TOKEN marker and check it inside the fake without logging it. Existing argv log is enough to observe downloads without recording secrets.\n- Use composite_e2e signed_set_json/test-key/home/PATH setup, but drive `spt update fetch` WITHOUT --apply for the new gate regression: assert successful signed metadata/artifact staging despite status exit 1 + stale account, using a deliberately fake token marker. No real auth/network required and no binary swap needed. This catches both the gate bug and lost environment inheritance.\n- Negative case: effective read fails even with another account reported successful → nonzero GhAuthRequired; no download or newly staged set. Optional stale account appears safely in diagnostic; synthetic secret-bearing lines and terminal escapes never reach output. Missing executable remains distinct GhCliRequired. A hanging probe must refuse within the bounded runner policy and never start download. Prefer injected runner/deadline seam for deterministic classification/budget edges, actual fixture for the subprocess/environment/staging contract.\n- Existing runtime bounded_run_kills_on_timeout (:1724-1741) exercises the shared wait_bounded via ManifestRuntime, not gh_status itself. Keep it; no runtime change is needed.\n- Remove gh_failure_classes_render_os_correct_hints (:35337), rather than repinning its contains-prose assertions. Move meaningful failure-class/exit/no-staging evidence into behavioral tests above; retain OS hints in production. resolve_transport tests (:26539-26568) exercise explicit-vs-Auto policy and remain useful if adapter work is approved.\n\n## Authority / uncertainty / scope\nADR-0036 §3 mandates gh carriage and loud missing/unauthed refusal; positive read changes evidence for availability, not the carrier or release trust. Keep verify_update_set_metadata (:11045), verify_update_set_artifact (:11106), signing anchors, channel pinning, rollback floor, consent/apply, and debug restrictions unchanged. Traceability owner is REQ-UPDATE-GH-TRANSPORT (traceable-reqs.toml:2611-2613); replace removed render-only unit evidence with actual classification/parser behavior. Debug runbook was read; its validation instructions were intentionally not executed under this read-only assignment. No LSP device is available in the mounted inventory; references were resolved by repository search. Upstream status format is current trunk, not evidence of the field gh version. No claim of runtime validation or ticket closure is made; this is implementation-ready source RCA."},"status":"success"},"isError":false,"timestamp":1790039533326}}
{"type":"custom","customType":"session_exit","data":{"reason":"dispose","kind":"normal","recordedAt":"2026-09-22T01:19:13.375Z"},"id":"8177a4e5","parentId":"ae46828f","timestamp":"2026-09-22T01:19:13.375Z"}
