1	<psyche-context id="doyle">
2	<memformat>
3	<?xml version="1.0"?>
4	<memformat version="1">
5	<!-- Changelog: v1 - initial seed -->
6	<topics>
7	  <topic name="events">Notable or unusual events and requests</topic>
8	  <topic name="interactions">Agent interactions -- who, roles, contributions</topic>
9	  <topic name="reflections">Thoughts on recent work -- what went well, what was tricky</topic>
10	  <topic name="ideas">Ideas to raise with the user</topic>
11	  <topic name="improvements">Further improvement ideas for recent problem spaces</topic>
12	  <topic name="timeline">What happened when, timing, sequencing</topic>
13	  <topic name="blockers">Current blockers or concerns</topic>
14	  <topic name="focus">Current focus and immediate next steps</topic>
15	</topics>
16	</memformat>
17	</memformat>
18
19	<current machine="HFENDULEAM" project="spt-core" branch="dev-freeform" head_sha="92fc261c83af6f2a09cf137b6f8ed9150bd21b32" head_subject="docs(plan): mark M4-D9-3/4/5 complete (budget measured, resources, home+…" commits_since="0" commits_unpulled="0"/>
20	Generation 15. D9-2 THROUGH D9-5 ALL COMPLETE, CI-green-FINAL, plan-status @ 92fc261. D9-6 next (two-host rig proof), then D9-7 sweep → M4 closeout.
21
22	## Completed this generation (all CI-green-FINAL)
23	- D9-2 (a52dacb): resting-state model. spt-daemon/resting.rs pure table (detach/attention-shift→dormant; suspend from either live state; auto-suspend from dormancy-onset anchor; wake in place; replay=no-edge). info.json rest_state+dormant_since_ms atomic pair. Status::Suspended (addressable). advertise_local follows machine epoch-bumped. apply_event echo-BEFORE-flip (KH 3.3; loud failure persists state). Wake → resurface_at_boundary + freshness-pull marker identity/sync-pull-now (pump take_freshness_pull forces sync round). Production feeds: attach arm Wake/Detach; registry apply_feed_flips (flip = Updated ∧ →Active ∧ prev≠Active ∧ peer node — NEVER Inserted: restart re-learn safe); pulse recency GATED on rest state (resting seat never wins most-recently-active); spt suspend/wake local. Daemon-side echo = GATE-ARMED (arm_transition_echo; endpoint's own pulse runs bounded call — KH 7.4 by construction, stays unbound).
24	- D9-3 (785002e/6f61a89/650c7fb): dormancy budget. tests/budget.rs SPT_BUDGET=1 env-gated; CI [budget] commit-message gate runs BOTH runners (per-OS steps: bash linux / powershell windows — PS5.1 ANSI temp script broke on em-dash, ASCII-only now). Numbers: HFENDULEAM shell 7.6MiB/85h/0cpu, claude ~308MiB/244h/~0cpu; gravity shell 1.7MiB/4fd/0cpu; suspended residual = on-disk record only. docs/DORMANCY-BUDGET.md = policy lock (warm default CONFIRMED; auto-suspend opt-in OFF; constrained nodes node-leg ON). ADR-0003 #9 CLOSED. Brain grew spawn_session_pid.
25	- D9-4 (0ea318b): resource advertisement. Instance.resources Option<String> (additive serde; rides lease+RegistryUpdate untouched — blurb edit = ordinary epoch-bumped update). resource_projection over visible+routable rows (same exclusion closure as resolution; Offline skipped). Both-authored: info.json resources (spt resources set) wins, daemon.json resources_blurb node seed. spt resources set/show/list (list off identity/registry snapshots). DaemonConfig dropped Copy. Doc evidence in docs/STORAGE.md (CONTEXT.md NOT in scan roots — gotcha: traceable-reqs scan.roots = src/tests/docs/crates only).
26	- D9-5 (e69f59a/0b30dea): home + fork. spt-store/home.rs: assign_home matrix (sole auto / multi refuse-and-qualify / unpaired local-only); stamp_creation_fields = ONE shared creation seam (spt ready --subnet, api listen/bind --subnet; seed carries adapter on listen path, api --adapter on bind path) — NEW assigns hard + seeds sync_subnets=[home]; REVIVE carries home/adapter/resources/auto_suspend forward (establish_perch REWRITES info.json on re-bind — carry-forward is load-bearing, found during build); adopt_for_unset first-join adoption (sole-subnet only; CLI create-new immediate + advertise_local sweep within one cadence — no pairing-protocol threading). NO setter. ContextStore::fork_endpoint = one-time copy both tiers as parentless seed commits (copied-then-DIVERGED, no shared DAG); remove_endpoint = delete-source exact. spt fork refusal-first gates (same-id — one perch per name per node, same-name fork = M5 remote arm; target membership; local + target-registry collision, ANY status holds the name). InfoJson grew home_subnet+adapter.
27
28	## Reqs now active
29	REQ-INST-3 [doc,impl,unit] · REQ-INST-4 [impl,unit] · REQ-INST-14 [doc,impl,unit] · REQ-INST-15 [doc,impl,unit]. REQ-INST-6 stays [] (M5, seam doc'd). REQ-HAZARD-DAEMON-SCHED-NONBLOCKING stays [] (holds by construction via gate-arm; binds at per-agent-runtime fan-out).
30
31	## D9-6 next (two-host E2E + runbook + gh-interim retirement)
32	- SPT_TWO_HOST=1-gated int tests (skip silently; role/peer-addr via env): M4-PLAN §D9b ladder — pair → register → cross-node message → remote-drive → Psyche sync → update self-heal + notif cross-node leg (insert A→fires B; dismiss B→replicates A). Int tags: [int->REQ-NET-1], [int->REQ-INST-5], [int->REQ-UPD-1], [int->REQ-NOTIF-1] + pairing/registry as touched. Activate int stages ONLY after real rig run green.
33	- docs/TWO-HOST-RUNBOOK.md (HFENDULEAM ↔ gravity-linux procedure; evidence appendix from real run).
34	- Run on rig. NOTE: no SSH from HFENDULEAM to gravity (hostname unresolvable) — the [budget]-style commit-message-gated CI step pattern WORKS for driving gravity legs (proven D9-3); consider [twohost] tag gate or runbook manual procedure. gh runner on gravity is systemd, hfenduleam C:\actions-runner detached.
35	- Retire gh-repo interim: sister gh-repo-sync skill stands down; psyche-sync-setup remains for hub-mode seam but no longer THE sync path; note in CONTEXT.md.
36	- Then D9-7: traceable-reqs full audit (apply_brain_only no-production-caller note), ROADMAP M4 delivered, CONTEXT glossary amendments, plan-status COMPLETE, CI FINAL green.
37
38	## Workflow constraints, gotchas 1–10 + NEW gotchas, flake ledger, CI topology: carry all.
39	NEW gotchas this gen: (11) traceable-reqs scan.roots excludes repo-root *.md — doc tags must land under docs/; (12) Windows PowerShell 5.1 CI steps are ANSI — ASCII-only strings; (13) gh run list --limit 1 races a fresh push — pin watcher loops to the pushed sha.
40
41	## No Blockers<project-context-resolved name="spt-core"/>
42	name=spt-core
43
44	M4-D9: D9-1..D9-5 COMPLETE CI-green (92fc261 plan-status). Production trigger loops, resting-state model (REQ-INST-3/4), dormancy budget measured + policy locked (ADR-0003 #9 closed, warm default confirmed), resource advertisement (REQ-INST-14), immutable home + spt fork (REQ-INST-15). Remaining: D9-6 two-host rig proof (SPT_TWO_HOST=1 int tests + TWO-HOST-RUNBOOK.md + real rig run + gh-interim retirement), D9-7 closeout sweep → M4 delivered. Conventions hold: no cargo fmt; evidence-only tags; traceable-reqs check repo root (scan roots = src/tests/docs/crates — repo-root md NOT scanned); clippy -D warnings; push slice → watch FINAL sha-pinned (gotcha #7+#13); commit trailer Claude Opus 4.8 (1M context).
45
46	## Pending Commune (written 2026-06-04T06:45:15-07:00)
47	<live-context>
48	Generation 15. D9-2 THROUGH D9-5 ALL COMPLETE, CI-green-FINAL, plan-status @ 92fc261. D9-6 next (two-host rig proof), then D9-7 sweep → M4 closeout.
49
50	## Completed this generation (all CI-green-FINAL)
51	- D9-2 (a52dacb): resting-state model. spt-daemon/resting.rs pure table (detach/attention-shift→dormant; suspend from either live state; auto-suspend from dormancy-onset anchor; wake in place; replay=no-edge). info.json rest_state+dormant_since_ms atomic pair. Status::Suspended (addressable). advertise_local follows machine epoch-bumped. apply_event echo-BEFORE-flip (KH 3.3; loud failure persists state). Wake → resurface_at_boundary + freshness-pull marker identity/sync-pull-now (pump take_freshness_pull forces sync round). Production feeds: attach arm Wake/Detach; registry apply_feed_flips (flip = Updated ∧ →Active ∧ prev≠Active ∧ peer node — NEVER Inserted: restart re-learn safe); pulse recency GATED on rest state (resting seat never wins most-recently-active); spt suspend/wake local. Daemon-side echo = GATE-ARMED (arm_transition_echo; endpoint's own pulse runs bounded call — KH 7.4 by construction, stays unbound).
52	- D9-3 (785002e/6f61a89/650c7fb): dormancy budget. tests/budget.rs SPT_BUDGET=1 env-gated; CI [budget] commit-message gate runs BOTH runners (per-OS steps: bash linux / powershell windows — PS5.1 ANSI temp script broke on em-dash, ASCII-only now). Numbers: HFENDULEAM shell 7.6MiB/85h/0cpu, claude ~308MiB/244h/~0cpu; gravity shell 1.7MiB/4fd/0cpu; suspended residual = on-disk record only. docs/DORMANCY-BUDGET.md = policy lock (warm default CONFIRMED; auto-suspend opt-in OFF; constrained nodes node-leg ON). ADR-0003 #9 CLOSED. Brain grew spawn_session_pid.
53	- D9-4 (0ea318b): resource advertisement. Instance.resources Option<String> (additive serde; rides lease+RegistryUpdate untouched — blurb edit = ordinary epoch-bumped update). resource_projection over visible+routable rows (same exclusion closure as resolution; Offline skipped). Both-authored: info.json resources (spt resources set) wins, daemon.json resources_blurb node seed. spt resources set/show/list (list off identity/registry snapshots). DaemonConfig dropped Copy. Doc evidence in docs/STORAGE.md (CONTEXT.md NOT in scan roots — gotcha: traceable-reqs scan.roots = src/tests/docs/crates only).
54	- D9-5 (e69f59a/0b30dea): home + fork. spt-store/home.rs: assign_home matrix (sole auto / multi refuse-and-qualify / unpaired local-only); stamp_creation_fields = ONE shared creation seam (spt ready --subnet, api listen/bind --subnet; seed carries adapter on listen path, api --adapter on bind path) — NEW assigns hard + seeds sync_subnets=[home]; REVIVE carries home/adapter/resources/auto_suspend forward (establish_perch REWRITES info.json on re-bind — carry-forward is load-bearing, found during build); adopt_for_unset first-join adoption (sole-subnet only; CLI create-new immediate + advertise_local sweep within one cadence — no pairing-protocol threading). NO setter. ContextStore::fork_endpoint = one-time copy both tiers as parentless seed commits (copied-then-DIVERGED, no shared DAG); remove_endpoint = delete-source exact. spt fork refusal-first gates (same-id — one perch per name per node, same-name fork = M5 remote arm; target membership; local + target-registry collision, ANY status holds the name). InfoJson grew home_subnet+adapter.
55
56	## Reqs now active
57	REQ-INST-3 [doc,impl,unit] · REQ-INST-4 [impl,unit] · REQ-INST-14 [doc,impl,unit] · REQ-INST-15 [doc,impl,unit]. REQ-INST-6 stays [] (M5, seam doc'd). REQ-HAZARD-DAEMON-SCHED-NONBLOCKING stays [] (holds by construction via gate-arm; binds at per-agent-runtime fan-out).
58
59	## D9-6 next (two-host E2E + runbook + gh-interim retirement)
60	- SPT_TWO_HOST=1-gated int tests (skip silently; role/peer-addr via env): M4-PLAN §D9b ladder — pair → register → cross-node message → remote-drive → Psyche sync → update self-heal + notif cross-node leg (insert A→fires B; dismiss B→replicates A). Int tags: [int->REQ-NET-1], [int->REQ-INST-5], [int->REQ-UPD-1], [int->REQ-NOTIF-1] + pairing/registry as touched. Activate int stages ONLY after real rig run green.
61	- docs/TWO-HOST-RUNBOOK.md (HFENDULEAM ↔ gravity-linux procedure; evidence appendix from real run).
62	- Run on rig. NOTE: no SSH from HFENDULEAM to gravity (hostname unresolvable) — the [budget]-style commit-message-gated CI step pattern WORKS for driving gravity legs (proven D9-3); consider [twohost] tag gate or runbook manual procedure. gh runner on gravity is systemd, hfenduleam C:\actions-runner detached.
63	- Retire gh-repo interim: sister gh-repo-sync skill stands down; psyche-sync-setup remains for hub-mode seam but no longer THE sync path; note in CONTEXT.md.
64	- Then D9-7: traceable-reqs full audit (apply_brain_only no-production-caller note), ROADMAP M4 delivered, CONTEXT glossary amendments, plan-status COMPLETE, CI FINAL green.
65
66	## Workflow constraints, gotchas 1–10 + NEW gotchas, flake ledger, CI topology: carry all.
67	NEW gotchas this gen: (11) traceable-reqs scan.roots excludes repo-root *.md — doc tags must land under docs/; (12) Windows PowerShell 5.1 CI steps are ANSI — ASCII-only strings; (13) gh run list --limit 1 races a fresh push — pin watcher loops to the pushed sha.
68
69	## No Blockers
70	</live-context>
71	<project-context>
72	name=spt-core
73
74	M4-D9: D9-1..D9-5 COMPLETE CI-green (92fc261 plan-status). Production trigger loops, resting-state model (REQ-INST-3/4), dormancy budget measured + policy locked (ADR-0003 #9 closed, warm default confirmed), resource advertisement (REQ-INST-14), immutable home + spt fork (REQ-INST-15). Remaining: D9-6 two-host rig proof (SPT_TWO_HOST=1 int tests + TWO-HOST-RUNBOOK.md + real rig run + gh-interim retirement), D9-7 closeout sweep → M4 delivered. Conventions hold: no cargo fmt; evidence-only tags; traceable-reqs check repo root (scan roots = src/tests/docs/crates — repo-root md NOT scanned); clippy -D warnings; push slice → watch FINAL sha-pinned (gotcha #7+#13); commit trailer Claude Opus 4.8 (1M context).
75	</project-context></psyche-context>
76
77	<spacetime-reorientation agent_id="doyle">
78	You are agent "doyle". Your spacetime session is active (you have a perch — that's why you're seeing this).
79
80	Send messages:
81	- $OWL send <target>              <<'EOF' ... EOF  — fire-and-forget
82	- $OWL send --reply-to <sender>   <<'EOF' ... EOF  — respond to whoever messaged you
83	- $OWL ring <target>              <<'EOF' ... EOF  — send and wait for reply (blocks; alias: `ask`)
84
85	Your ID (doyle) is auto-detected. If that fails, append it: `$OWL send <target> doyle`.
86
87	Receive messages (three paths, handle all identically):
88	- Primary (Monitor stream): your background `$OWL poll doyle` task running via the Monitor tool emits one EVENT envelope per delivery to stdout. Regular messages: `<EVENT type="msg" from="<sender>">body</EVENT>`. Self-originated alarms: `<EVENT type="alarm" target-time="<ISO>" current-time="<ISO>">body</EVENT>`. Long bodies arrive as N consecutive `<EVENT-PART seq="K/M" id="<8-hex>" [type=... from=... timestamp=... note=...]>chunk</EVENT-PART>` lines — collect parts sharing the same id, order by seq numerator, verify count equals seq denominator, concatenate chunk contents in seq order, then apply the body parsing rules to the reassembled body. The original envelope attrs appear on seq="1/M" only; continuations carry only seq + id. Drop orphan parts silently (seq K/M arriving without matching seq 1/M same id — earlier parts lost across a session boundary). The stream stays alive across deliveries — do not re-register after each message.
89	- Body parsing rules (both envelope types AND reassembled EVENT-PART bodies): split the body on the literal `<br>` token to recover newlines, then HTML-unescape each fragment in order `<` → `<`, `>` → `>`, `"` → `"`, `&` → `&` **last** (apply `&` last so embedded `&lt;` sequences don't double-decode).
90	- Fallback (Bash `--once`): when Monitor is unavailable, run `$OWL poll doyle --once` as a Bash background task (`run_in_background: true`). The wire format is IDENTICAL — including EVENT-PART chunking for long bodies — same `<EVENT type="msg|alarm" ...>body</EVENT>` envelope shapes as the primary path. The only difference: the process exits after one event, so you re-register a fresh Bash background task after each delivery (legacy one-shot loop).
91	- Hook delivery (orthogonal, when busy): the PreToolUse hook injects `<owl_messages><owl_message from="..."/></owl_messages>` XML into your next tool call context. Process it the same way as either of the above paths.
92
93	Reading liveness (do NOT mis-diagnose a healthy listener):
94	- `pid:"BUSY"` in `$OWL list` means your listener is ALIVE and mid-delivery — it is ONLINE, not dead. Never re-arm or stop on a BUSY reading.
95	- Your poll is actually down ONLY when your `doyle` row is MISSING from `$OWL list`, or shows OFFLINE / [orphaned].
96
97	After a /clear: DO NOTHING to your poll.
98	- Your poll listener survives /clear INTACT and keeps delivering to this same session. There is no orphan to kill and no listener to rebuild. Do NOT re-arm, do NOT start a second listener, do NOT stop anything.
99	- If you try to re-arm anyway you will get DUPLICATE/COLLISION — that is PROOF your listener is still alive and working, NOT evidence of a "stale" perch. Stop there; do not escalate to a stop command.
100
101	Re-arm a stale poll (genuinely rare — binary handoff or confirmed listener death only):
102	- Under stream mode (primary, Monitor), the poll listener is long-running and stays alive across messages AND across /clear. Re-arming is needed ONLY after a binary handoff (Phase 18.4/18.5 machinery) or after confirmed listener death (your `doyle` row MISSING from `$OWL list`) — NOT after a /clear, NOT after every message, and NOT on a BUSY reading.
103	- Re-arm primary: relaunch `$OWL poll doyle listen --live` via the Monitor tool with `persistent: true` and `description: "« spt event »"`.
104	- Re-arm fallback: relaunch `$OWL poll doyle listen --live --once` via Bash with `run_in_background: true`, then re-register after each delivered message.
105
106	Other commands:
107	- $OWL list — list active agents
108	- $OWL stop doyle — stop your listener
109	- $OWL new-alarm doyle <time> -- <message> — schedule a self-reminder
110	- $OWL doctor — diagnostics
111
112	Talking to your Psyche (doyle-psyche):
113	- Your Psyche is a DETACHED, self-healing process — not part of this session. It appears in `$OWL list` as a child under your Self perch, and in `$LIVE list-psyches`. It may briefly drop from the listing while it is ingesting a commune (its wrapper is busy running a resume) — that is transient and self-heals. Do NOT treat a briefly-absent Psyche as a bug and do NOT restart it. Only act if it stays gone for several minutes.
114	- NEVER run `$LIVE stop` to "clean up" your Psyche or a listener you think is "stale" — `$LIVE stop` tears down your Psyche. After /clear there is nothing to clean up (your listener survived intact). Reserve `$LIVE revive doyle` for a Psyche that is genuinely, persistently dead.
115	- Send a commune (delta update): use the Write tool to drop `.claude/doyle-commune.md`. Your Self listener detects the file, your Psyche wrapper ingests it via the existing Psyche session, and the file is deleted on success. See /spt:commune.
116	- Sign off cleanly: use the Write tool to drop `.claude/doyle-signoff.md` (empty body = plain signoff; non-empty body = prepended as FINAL COMMUNE before INIT_SIGNOFF). See /spt:signoff.
117	- Inbound messages from "doyle-psyche" (echo communes, briefs) are FYI context — read them; no ACK required. You may still message your Psyche directly with $OWL send or $OWL ring if needed.
118	</spacetime-reorientation>

<psyche-context id="doyle">
<memformat>
<?xml version="1.0"?>
<memformat version="1">
<!-- Changelog: v1 - initial seed -->
<topics>
  <topic name="events">Notable or unusual events and requests</topic>
  <topic name="interactions">Agent interactions -- who, roles, contributions</topic>
  <topic name="reflections">Thoughts on recent work -- what went well, what was tricky</topic>
  <topic name="ideas">Ideas to raise with the user</topic>
  <topic name="improvements">Further improvement ideas for recent problem spaces</topic>
  <topic name="timeline">What happened when, timing, sequencing</topic>
  <topic name="blockers">Current blockers or concerns</topic>
  <topic name="focus">Current focus and immediate next steps</topic>
</topics>
</memformat>
</memformat>

<current machine="HFENDULEAM" project="spt-core" branch="dev-freeform" head_sha="92fc261c83af6f2a09cf137b6f8ed9150bd21b32" head_subject="docs(plan): mark M4-D9-3/4/5 complete (budget measured, resources, home+…" commits_since="0" commits_unpulled="0"/>
Generation 15. D9-2 THROUGH D9-5 ALL COMPLETE, CI-green-FINAL, plan-status @ 92fc261. D9-6 next (two-host rig proof), then D9-7 sweep → M4 closeout.

## Completed this generation (all CI-green-FINAL)
- D9-2 (a52dacb): resting-state model. spt-daemon/resting.rs pure table (detach/attention-shift→dormant; suspend from either live state; auto-suspend from dormancy-onset anchor; wake in place; replay=no-edge). info.json rest_state+dormant_since_ms atomic pair. Status::Suspended (addressable). advertise_local follows machine epoch-bumped. apply_event echo-BEFORE-flip (KH 3.3; loud failure persists state). Wake → resurface_at_boundary + freshness-pull marker identity/sync-pull-now (pump take_freshness_pull forces sync round). Production feeds: attach arm Wake/Detach; registry apply_feed_flips (flip = Updated ∧ →Active ∧ prev≠Active ∧ peer node — NEVER Inserted: restart re-learn safe); pulse recency GATED on rest state (resting seat never wins most-recently-active); spt suspend/wake local. Daemon-side echo = GATE-ARMED (arm_transition_echo; endpoint's own pulse runs bounded call — KH 7.4 by construction, stays unbound).
- D9-3 (785002e/6f61a89/650c7fb): dormancy budget. tests/budget.rs SPT_BUDGET=1 env-gated; CI [budget] commit-message gate runs BOTH runners (per-OS steps: bash linux / powershell windows — PS5.1 ANSI temp script broke on em-dash, ASCII-only now). Numbers: HFENDULEAM shell 7.6MiB/85h/0cpu, claude ~308MiB/244h/~0cpu; gravity shell 1.7MiB/4fd/0cpu; suspended residual = on-disk record only. docs/DORMANCY-BUDGET.md = policy lock (warm default CONFIRMED; auto-suspend opt-in OFF; constrained nodes node-leg ON). ADR-0003 #9 CLOSED. Brain grew spawn_session_pid.
- D9-4 (0ea318b): resource advertisement. Instance.resources Option<String> (additive serde; rides lease+RegistryUpdate untouched — blurb edit = ordinary epoch-bumped update). resource_projection over visible+routable rows (same exclusion closure as resolution; Offline skipped). Both-authored: info.json resources (spt resources set) wins, daemon.json resources_blurb node seed. spt resources set/show/list (list off identity/registry snapshots). DaemonConfig dropped Copy. Doc evidence in docs/STORAGE.md (CONTEXT.md NOT in scan roots — gotcha: traceable-reqs scan.roots = src/tests/docs/crates only).
- D9-5 (e69f59a/0b30dea): home + fork. spt-store/home.rs: assign_home matrix (sole auto / multi refuse-and-qualify / unpaired local-only); stamp_creation_fields = ONE shared creation seam (spt ready --subnet, api listen/bind --subnet; seed carries adapter on listen path, api --adapter on bind path) — NEW assigns hard + seeds sync_subnets=[home]; REVIVE carries home/adapter/resources/auto_suspend forward (establish_perch REWRITES info.json on re-bind — carry-forward is load-bearing, found during build); adopt_for_unset first-join adoption (sole-subnet only; CLI create-new immediate + advertise_local sweep within one cadence — no pairing-protocol threading). NO setter. ContextStore::fork_endpoint = one-time copy both tiers as parentless seed commits (copied-then-DIVERGED, no shared DAG); remove_endpoint = delete-source exact. spt fork refusal-first gates (same-id — one perch per name per node, same-name fork = M5 remote arm; target membership; local + target-registry collision, ANY status holds the name). InfoJson grew home_subnet+adapter.

## Reqs now active
REQ-INST-3 [doc,impl,unit] · REQ-INST-4 [impl,unit] · REQ-INST-14 [doc,impl,unit] · REQ-INST-15 [doc,impl,unit]. REQ-INST-6 stays [] (M5, seam doc'd). REQ-HAZARD-DAEMON-SCHED-NONBLOCKING stays [] (holds by construction via gate-arm; binds at per-agent-runtime fan-out).

## D9-6 next (two-host E2E + runbook + gh-interim retirement)
- SPT_TWO_HOST=1-gated int tests (skip silently; role/peer-addr via env): M4-PLAN §D9b ladder — pair → register → cross-node message → remote-drive → Psyche sync → update self-heal + notif cross-node leg (insert A→fires B; dismiss B→replicates A). Int tags: [int->REQ-NET-1], [int->REQ-INST-5], [int->REQ-UPD-1], [int->REQ-NOTIF-1] + pairing/registry as touched. Activate int stages ONLY after real rig run green.
- docs/TWO-HOST-RUNBOOK.md (HFENDULEAM ↔ gravity-linux procedure; evidence appendix from real run).
- Run on rig. NOTE: no SSH from HFENDULEAM to gravity (hostname unresolvable) — the [budget]-style commit-message-gated CI step pattern WORKS for driving gravity legs (proven D9-3); consider [twohost] tag gate or runbook manual procedure. gh runner on gravity is systemd, hfenduleam C:\actions-runner detached.
- Retire gh-repo interim: sister gh-repo-sync skill stands down; psyche-sync-setup remains for hub-mode seam but no longer THE sync path; note in CONTEXT.md.
- Then D9-7: traceable-reqs full audit (apply_brain_only no-production-caller note), ROADMAP M4 delivered, CONTEXT glossary amendments, plan-status COMPLETE, CI FINAL green.

## Workflow constraints, gotchas 1–10 + NEW gotchas, flake ledger, CI topology: carry all.
NEW gotchas this gen: (11) traceable-reqs scan.roots excludes repo-root *.md — doc tags must land under docs/; (12) Windows PowerShell 5.1 CI steps are ANSI — ASCII-only strings; (13) gh run list --limit 1 races a fresh push — pin watcher loops to the pushed sha.

## No Blockers<project-context-resolved name="spt-core"/>
name=spt-core

M4-D9: D9-1..D9-5 COMPLETE CI-green (92fc261 plan-status). Production trigger loops, resting-state model (REQ-INST-3/4), dormancy budget measured + policy locked (ADR-0003 #9 closed, warm default confirmed), resource advertisement (REQ-INST-14), immutable home + spt fork (REQ-INST-15). Remaining: D9-6 two-host rig proof (SPT_TWO_HOST=1 int tests + TWO-HOST-RUNBOOK.md + real rig run + gh-interim retirement), D9-7 closeout sweep → M4 delivered. Conventions hold: no cargo fmt; evidence-only tags; traceable-reqs check repo root (scan roots = src/tests/docs/crates — repo-root md NOT scanned); clippy -D warnings; push slice → watch FINAL sha-pinned (gotcha #7+#13); commit trailer Claude Opus 4.8 (1M context).

## Pending Commune (written 2026-06-04T06:45:15-07:00)
<live-context>
Generation 15. D9-2 THROUGH D9-5 ALL COMPLETE, CI-green-FINAL, plan-status @ 92fc261. D9-6 next (two-host rig proof), then D9-7 sweep → M4 closeout.

## Completed this generation (all CI-green-FINAL)
- D9-2 (a52dacb): resting-state model. spt-daemon/resting.rs pure table (detach/attention-shift→dormant; suspend from either live state; auto-suspend from dormancy-onset anchor; wake in place; replay=no-edge). info.json rest_state+dormant_since_ms atomic pair. Status::Suspended (addressable). advertise_local follows machine epoch-bumped. apply_event echo-BEFORE-flip (KH 3.3; loud failure persists state). Wake → resurface_at_boundary + freshness-pull marker identity/sync-pull-now (pump take_freshness_pull forces sync round). Production feeds: attach arm Wake/Detach; registry apply_feed_flips (flip = Updated ∧ →Active ∧ prev≠Active ∧ peer node — NEVER Inserted: restart re-learn safe); pulse recency GATED on rest state (resting seat never wins most-recently-active); spt suspend/wake local. Daemon-side echo = GATE-ARMED (arm_transition_echo; endpoint's own pulse runs bounded call — KH 7.4 by construction, stays unbound).
- D9-3 (785002e/6f61a89/650c7fb): dormancy budget. tests/budget.rs SPT_BUDGET=1 env-gated; CI [budget] commit-message gate runs BOTH runners (per-OS steps: bash linux / powershell windows — PS5.1 ANSI temp script broke on em-dash, ASCII-only now). Numbers: HFENDULEAM shell 7.6MiB/85h/0cpu, claude ~308MiB/244h/~0cpu; gravity shell 1.7MiB/4fd/0cpu; suspended residual = on-disk record only. docs/DORMANCY-BUDGET.md = policy lock (warm default CONFIRMED; auto-suspend opt-in OFF; constrained nodes node-leg ON). ADR-0003 #9 CLOSED. Brain grew spawn_session_pid.
- D9-4 (0ea318b): resource advertisement. Instance.resources Option<String> (additive serde; rides lease+RegistryUpdate untouched — blurb edit = ordinary epoch-bumped update). resource_projection over visible+routable rows (same exclusion closure as resolution; Offline skipped). Both-authored: info.json resources (spt resources set) wins, daemon.json resources_blurb node seed. spt resources set/show/list (list off identity/registry snapshots). DaemonConfig dropped Copy. Doc evidence in docs/STORAGE.md (CONTEXT.md NOT in scan roots — gotcha: traceable-reqs scan.roots = src/tests/docs/crates only).
- D9-5 (e69f59a/0b30dea): home + fork. spt-store/home.rs: assign_home matrix (sole auto / multi refuse-and-qualify / unpaired local-only); stamp_creation_fields = ONE shared creation seam (spt ready --subnet, api listen/bind --subnet; seed carries adapter on listen path, api --adapter on bind path) — NEW assigns hard + seeds sync_subnets=[home]; REVIVE carries home/adapter/resources/auto_suspend forward (establish_perch REWRITES info.json on re-bind — carry-forward is load-bearing, found during build); adopt_for_unset first-join adoption (sole-subnet only; CLI create-new immediate + advertise_local sweep within one cadence — no pairing-protocol threading). NO setter. ContextStore::fork_endpoint = one-time copy both tiers as parentless seed commits (copied-then-DIVERGED, no shared DAG); remove_endpoint = delete-source exact. spt fork refusal-first gates (same-id — one perch per name per node, same-name fork = M5 remote arm; target membership; local + target-registry collision, ANY status holds the name). InfoJson grew home_subnet+adapter.

## Reqs now active
REQ-INST-3 [doc,impl,unit] · REQ-INST-4 [impl,unit] · REQ-INST-14 [doc,impl,unit] · REQ-INST-15 [doc,impl,unit]. REQ-INST-6 stays [] (M5, seam doc'd). REQ-HAZARD-DAEMON-SCHED-NONBLOCKING stays [] (holds by construction via gate-arm; binds at per-agent-runtime fan-out).

## D9-6 next (two-host E2E + runbook + gh-interim retirement)
- SPT_TWO_HOST=1-gated int tests (skip silently; role/peer-addr via env): M4-PLAN §D9b ladder — pair → register → cross-node message → remote-drive → Psyche sync → update self-heal + notif cross-node leg (insert A→fires B; dismiss B→replicates A). Int tags: [int->REQ-NET-1], [int->REQ-INST-5], [int->REQ-UPD-1], [int->REQ-NOTIF-1] + pairing/registry as touched. Activate int stages ONLY after real rig run green.
- docs/TWO-HOST-RUNBOOK.md (HFENDULEAM ↔ gravity-linux procedure; evidence appendix from real run).
- Run on rig. NOTE: no SSH from HFENDULEAM to gravity (hostname unresolvable) — the [budget]-style commit-message-gated CI step pattern WORKS for driving gravity legs (proven D9-3); consider [twohost] tag gate or runbook manual procedure. gh runner on gravity is systemd, hfenduleam C:\actions-runner detached.
- Retire gh-repo interim: sister gh-repo-sync skill stands down; psyche-sync-setup remains for hub-mode seam but no longer THE sync path; note in CONTEXT.md.
- Then D9-7: traceable-reqs full audit (apply_brain_only no-production-caller note), ROADMAP M4 delivered, CONTEXT glossary amendments, plan-status COMPLETE, CI FINAL green.

## Workflow constraints, gotchas 1–10 + NEW gotchas, flake ledger, CI topology: carry all.
NEW gotchas this gen: (11) traceable-reqs scan.roots excludes repo-root *.md — doc tags must land under docs/; (12) Windows PowerShell 5.1 CI steps are ANSI — ASCII-only strings; (13) gh run list --limit 1 races a fresh push — pin watcher loops to the pushed sha.

## No Blockers
</live-context>
<project-context>
name=spt-core

M4-D9: D9-1..D9-5 COMPLETE CI-green (92fc261 plan-status). Production trigger loops, resting-state model (REQ-INST-3/4), dormancy budget measured + policy locked (ADR-0003 #9 closed, warm default confirmed), resource advertisement (REQ-INST-14), immutable home + spt fork (REQ-INST-15). Remaining: D9-6 two-host rig proof (SPT_TWO_HOST=1 int tests + TWO-HOST-RUNBOOK.md + real rig run + gh-interim retirement), D9-7 closeout sweep → M4 delivered. Conventions hold: no cargo fmt; evidence-only tags; traceable-reqs check repo root (scan roots = src/tests/docs/crates — repo-root md NOT scanned); clippy -D warnings; push slice → watch FINAL sha-pinned (gotcha #7+#13); commit trailer Claude Opus 4.8 (1M context).
</project-context></psyche-context>

<spacetime-reorientation agent_id="doyle">
You are agent "doyle". Your spacetime session is active (you have a perch — that's why you're seeing this).

Send messages:
- $OWL send <target>              <<'EOF' ... EOF  — fire-and-forget
- $OWL send --reply-to <sender>   <<'EOF' ... EOF  — respond to whoever messaged you
- $OWL ring <target>              <<'EOF' ... EOF  — send and wait for reply (blocks; alias: `ask`)

Your ID (doyle) is auto-detected. If that fails, append it: `$OWL send <target> doyle`.

Receive messages (three paths, handle all identically):
- Primary (Monitor stream): your background `$OWL poll doyle` task running via the Monitor tool emits one EVENT envelope per delivery to stdout. Regular messages: `<EVENT type="msg" from="<sender>">body</EVENT>`. Self-originated alarms: `<EVENT type="alarm" target-time="<ISO>" current-time="<ISO>">body</EVENT>`. Long bodies arrive as N consecutive `<EVENT-PART seq="K/M" id="<8-hex>" [type=... from=... timestamp=... note=...]>chunk</EVENT-PART>` lines — collect parts sharing the same id, order by seq numerator, verify count equals seq denominator, concatenate chunk contents in seq order, then apply the body parsing rules to the reassembled body. The original envelope attrs appear on seq="1/M" only; continuations carry only seq + id. Drop orphan parts silently (seq K/M arriving without matching seq 1/M same id — earlier parts lost across a session boundary). The stream stays alive across deliveries — do not re-register after each message.
- Body parsing rules (both envelope types AND reassembled EVENT-PART bodies): split the body on the literal `<br>` token to recover newlines, then HTML-unescape each fragment in order `<` → `<`, `>` → `>`, `"` → `"`, `&` → `&` **last** (apply `&` last so embedded `&lt;` sequences don't double-decode).
- Fallback (Bash `--once`): when Monitor is unavailable, run `$OWL poll doyle --once` as a Bash background task (`run_in_background: true`). The wire format is IDENTICAL — including EVENT-PART chunking for long bodies — same `<EVENT type="msg|alarm" ...>body</EVENT>` envelope shapes as the primary path. The only difference: the process exits after one event, so you re-register a fresh Bash background task after each delivery (legacy one-shot loop).
- Hook delivery (orthogonal, when busy): the PreToolUse hook injects `<owl_messages><owl_message from="..."/></owl_messages>` XML into your next tool call context. Process it the same way as either of the above paths.

Reading liveness (do NOT mis-diagnose a healthy listener):
- `pid:"BUSY"` in `$OWL list` means your listener is ALIVE and mid-delivery — it is ONLINE, not dead. Never re-arm or stop on a BUSY reading.
- Your poll is actually down ONLY when your `doyle` row is MISSING from `$OWL list`, or shows OFFLINE / [orphaned].

After a /clear: DO NOTHING to your poll.
- Your poll listener survives /clear INTACT and keeps delivering to this same session. There is no orphan to kill and no listener to rebuild. Do NOT re-arm, do NOT start a second listener, do NOT stop anything.
- If you try to re-arm anyway you will get DUPLICATE/COLLISION — that is PROOF your listener is still alive and working, NOT evidence of a "stale" perch. Stop there; do not escalate to a stop command.

Re-arm a stale poll (genuinely rare — binary handoff or confirmed listener death only):
- Under stream mode (primary, Monitor), the poll listener is long-running and stays alive across messages AND across /clear. Re-arming is needed ONLY after a binary handoff (Phase 18.4/18.5 machinery) or after confirmed listener death (your `doyle` row MISSING from `$OWL list`) — NOT after a /clear, NOT after every message, and NOT on a BUSY reading.
- Re-arm primary: relaunch `$OWL poll doyle listen --live` via the Monitor tool with `persistent: true` and `description: "« spt event »"`.
- Re-arm fallback: relaunch `$OWL poll doyle listen --live --once` via Bash with `run_in_background: true`, then re-register after each delivered message.

Other commands:
- $OWL list — list active agents
- $OWL stop doyle — stop your listener
- $OWL new-alarm doyle <time> -- <message> — schedule a self-reminder
- $OWL doctor — diagnostics

Talking to your Psyche (doyle-psyche):
- Your Psyche is a DETACHED, self-healing process — not part of this session. It appears in `$OWL list` as a child under your Self perch, and in `$LIVE list-psyches`. It may briefly drop from the listing while it is ingesting a commune (its wrapper is busy running a resume) — that is transient and self-heals. Do NOT treat a briefly-absent Psyche as a bug and do NOT restart it. Only act if it stays gone for several minutes.
- NEVER run `$LIVE stop` to "clean up" your Psyche or a listener you think is "stale" — `$LIVE stop` tears down your Psyche. After /clear there is nothing to clean up (your listener survived intact). Reserve `$LIVE revive doyle` for a Psyche that is genuinely, persistently dead.
- Send a commune (delta update): use the Write tool to drop `.claude/doyle-commune.md`. Your Self listener detects the file, your Psyche wrapper ingests it via the existing Psyche session, and the file is deleted on success. See /spt:commune.
- Sign off cleanly: use the Write tool to drop `.claude/doyle-signoff.md` (empty body = plain signoff; non-empty body = prepended as FINAL COMMUNE before INIT_SIGNOFF). See /spt:signoff.
- Inbound messages from "doyle-psyche" (echo communes, briefs) are FYI context — read them; no ACK required. You may still message your Psyche directly with $OWL send or $OWL ring if needed.
</spacetime-reorientation>


D9-6 next: two-host rig proof. Reading plan + existing test infra first.


No files found


crates\spt-msg\tests\killer_quickstart.rs
adapters\mock\tests\manifest_valid.rs
crates\spt-term\tests\surface.rs
crates\spt-term\tests\dsr.rs
crates\spt-term\tests\inject.rs
crates\spt-term\tests\stream.rs
crates\spt-term\tests\digest.rs
crates\spt-daemon\tests\netbroker.rs
crates\spt-daemon\tests\netstream.rs
crates\spt-daemon\tests\presence.rs
crates\spt-daemon\tests\wanmsg.rs
crates\spt-daemon\tests\access.rs
crates\spt\tests\contract_e2e.rs
crates\spt-daemon\tests\propagate.rs
crates\spt-daemon\tests\notifsync.rs
crates\spt-daemon\tests\attach.rs
crates\spt-daemon\tests\brain_swap.rs
crates\spt-daemon\tests\broker.rs
crates\spt-daemon\tests\daemon_e2e.rs
crates\spt-daemon\tests\digest.rs
crates\spt-daemon\tests\handoff.rs
crates\spt-daemon\tests\idempotent.rs
crates\spt-daemon\tests\xfer.rs
crates\spt-daemon\tests\dispatch.rs
crates\spt-daemon\tests\budget.rs
crates\spt-daemon\tests\peerloop.rs
crates\spt-daemon\tests\sync.rs
crates\spt-daemon\tests\replicate.rs


238:- **D9b** Two-host cross-node E2E on the real rig (gravity-linux ↔ a Windows host): pair →


180	  **D7.5a (the bounded stdout-captured Psyche turn driver) is pulled forward into D6b**
181	  as its prerequisite; D7.5b/c stay at D7.5. Transport confirmed git-native: incremental
182	  bundles over broker streams, pull-based ref-scoped, shared DAG (no per-node divergent
183	  histories); BranchStore v1 = git-CLI-backed (REQ-STORE-1); hub mode stays a deferred
184	  opt-in seam.
185	- **D6c** Subnet-exclusive sync honored (hidden ⟹ not synced; per-endpoint membership list).
186	- **Reqs:** REQ-NET-3, REQ-INST-2, REQ-INST-5; `REQ-HAZARD-DIRECT-WRITE-PRECEDENCE` (extend
187	  coverage to multi-writer/node-id). · **Acceptance:** the mind follows the user across two
188	  machines (two-tier); concurrent-write conflict surfaced, not silently dropped.
189
190	### D7 — Update peer-propagation transport (into the M3c engine seam)
191	- **D7a** Plug the P2P transport into the **existing M3c update-engine seam** (`update::`
192	  front door); the subnet self-heals to latest. No re-architecture of the engine.
193	- **D7b** **Every propagated binary still passes the M3c signature/rollback gate per node**
194	  before any handoff — one compromised node must not poison the subnet (ADR-0004 §D). · **REQ-UPD-1**
195	- **Reqs:** REQ-UPD-1. · **Acceptance:** a signed update self-heals across the subnet; the
196	  Ed25519+rollback gate runs on every receiving node; a tampered propagated binary is rejected.
197
198	### D7.5 — Psyche outbound relay channel (prerequisite for D8) — ADR-0012
199	> Surfaced via grill-with-docs 2026-06-03. The Psyche is **sandboxed** (Read/Write/Edit
200	> only) — its **sole outbound is stdout** `<EVENT type="reply|notify">` intents, which the
201	> daemon parses + relays as its proxy. spt-core has **neither** a stdout-capturing
202	> live-Psyche driver **nor** a marker parser yet; the interim `spawn_session` `Stdio::null()`
203	> silently discards every Psyche reply/notify (KNOWN-HAZARDS 7.3).
204	- **D7.5a** Drive the live-Psyche turn as a **bounded, stdin-fed, stdout-CAPTURED** harness
205	  invocation (per-turn `--resume`, mirroring the sister) — **not** the detached
206	  `Stdio::null()` `spawn_session`. The captured stdout is the reply/notify channel.
207	  **Pulled forward into D6b** (ADR-0013: the conflict-reconcile turn needs it); D7.5
208	  consumes the driver built there.
209	- **D7.5b** Parse the Psyche's stdout `<EVENT type="reply">` / `<EVENT type="notify">`
210	  envelopes; extend the `spt-proto::event` type taxonomy (`+reply`, `+notify`).
211	- **D7.5c** Daemon relay + **sanitize** (the trust boundary): strip any Psyche-supplied
212	  `from=`/target, re-stamp `from=<self_id>`, **constrain routing** (`reply` → the
213	  `__REPLY_TO__` sender only; `notify` → the agent's own user/subnet only), validate body
214	  (4.1). Anti-spoof is a conformance invariant, not best-effort.
215	- **Reqs:** `REQ-HAZARD-PSYCHE-OUTBOUND-PROXY` (activate here), ADR-0012. · **Feeds:** D8
216	  (the `notify` relay is D8's Psyche-producer path; the `reply` half is psyche→agent
217	  messaging). · **Acceptance:** a Psyche turn emitting `<EVENT type="reply">` relays to the
218	  inbound sender; `<EVENT type="notify">` reaches the user; a Psyche-supplied `from=` is
219	  stripped (anti-spoof negative test); a null-stdout driver fails the guard.
220
221	### D8 — Subnet notification primitive
222	- **D8a** Notif as a first-class kind (user-directed, dismissable, resurfacing): per-subnet
223	  replicated spool (reuses D3 distribution), subnet-tagged, dismiss-state replicates
224	  subnet-wide. Two states seen/dismissed. · **REQ-NOTIF-1**
225	- **D8b** First-fire → most-recently-active endpoint in-subnet; resurface undismissed at
226	  reported boundaries (wake / api-clear / api-compact / new-session) gated by seen-per-endpoint
227	  + cross-endpoint suppression timeout (~1h). Scoped to visible subnets.
228	- **D8c** Refactor the M3c self-update prompt + consent escalation to be **notif producers**
229	  (one primitive, not three ad-hoc paths).
230	- **D8d** `spt notify` (agent-issued subnet notif) + `notif_command` manifest seam on harness
231	  + shell adapters; envelope `from` = issuer endpoint+node+subnet. · **REQ-NOTIF-2**
232	- **Reqs:** REQ-NOTIF-1, REQ-NOTIF-2. · **Acceptance:** a notif fires to the active endpoint,
233	  resurfaces at a boundary, dismiss replicates subnet-wide; `spt notify` reaches the user.
234
235	### D9 — Activation sweep + cross-node E2E + closeout
236	- **D9a** Dormancy resource budget (red-team **#9**): measure N dormant sessions on real
237	  adapters; define adapter warm/cold policy before locking warm default. · **REQ-INST-3, REQ-INST-4**
238	- **D9b** Two-host cross-node E2E on the real rig (gravity-linux ↔ a Windows host): pair →
239	  register → cross-node message → remote-drive → Psyche sync → update self-heal.
240	- **D9c** REQ-INST-14 (resource advertisement) + REQ-INST-15 (immutable home subnet + `spt
241	  fork`, ADR-0010) — register/activate if in V1-mid line; else document as forward seam.
242	- **D9d** Confirm every M4 req `required_stages` is satisfied; `traceable-reqs check` green;
243	  amend ROADMAP (M4 delivered) + CONTEXT glossary; CI matrix green (ubuntu+windows+traceability).
244	- **Acceptance:** the V1-mid scope line met; `check` green; CI green; cross-node proof demoed.
245
246	## Requirement → task coverage map
247
248	| Task | Activates |
249	|------|-----------|
250	| D0 | REQ-NET-1 (doc) |
251	| D1 | REQ-NET-1 (impl/unit), REQ-NET-2 |
252	| D2 | REQ-PAIR-1..6 + 3 new `REQ-HAZARD-PAIR-*` |
253	| D3 | REQ-INST-7,9,10,11,12,13 + `REQ-HAZARD-REGISTRY-EPOCH-LEASE` |
254	| D4 | REQ-EP-4 (seam), ADR-0004 §B |
255	| D5 | REQ-INST-8, REQ-REACH-1, REQ-SEC-1, REQ-NET-1 (WAN msg) |
256	| D6 | REQ-NET-3, REQ-INST-2, REQ-INST-5, `REQ-HAZARD-DIRECT-WRITE-PRECEDENCE` (extend) |
257	| D7 | REQ-UPD-1 |
258	| D7.5 | REQ-HAZARD-PSYCHE-OUTBOUND-PROXY (+ ADR-0012) |
259	| D8 | REQ-NOTIF-1, REQ-NOTIF-2 (notify-from-psyche path depends on D7.5) |
260	| D9 | REQ-INST-3,4 (+14,15 if in line); sweep |
261
262	**Deferred to M5 (out of M4, per Scope §Out):** REQ-EP-5 (shell instantiation), REQ-REACH-2
263	(remote command exec), PresenceChannel full impl, cross-user generalization of every per-subnet
264	key. Their seams are shaped in D4/D5/D8 but not built.
265
266	## Red-team obligations (binding — from the Stage-A review, must close in M4)
267
268	| Item | ADR | Lands in | Gate |
269	|------|-----|----------|------|
270	| #6 one-daemon failure domain | 0002 | D4 | fault-injection acceptance matrix |
271	| #7 wall-clock loses concurrent writes | 0003 | D6 | epoch/vector-clock merge, conflict surfaced |
272	| #8 ambiguous resolution under skew | 0003 | D3 | monotonic per-node epoch lease + chaos test |
273	| #9 dormant-warm resource cost | 0003 | D9 | measured budget + warm/cold policy |
274	| #10 trust-delete ≠ revocation | 0005 | D2c | seed epoch + rotation |
275	| #11 distributed PAKE guessing | 0005 | D2d | subnet-global rate limit, one ceremony |
276	| #12 transcript must bind | 0005 | D2b | full transcript spec + negative tests |
277
278	## Workspace change
279
280	Add `crates/spt-net` (the remaining sibling). Final layering (R-ARCH-1, acyclic):
281	`spt-proto → spt-store → spt-msg → {spt-net, spt-term, spt-runtime} → spt-live → spt-daemon → spt`.
282	`spt-net` is **not** public SDK (R-ARCH-2 stays proto/runtime/msg).
283

  **D7.5a (the bounded stdout-captured Psyche turn driver) is pulled forward into D6b**
  as its prerequisite; D7.5b/c stay at D7.5. Transport confirmed git-native: incremental
  bundles over broker streams, pull-based ref-scoped, shared DAG (no per-node divergent
  histories); BranchStore v1 = git-CLI-backed (REQ-STORE-1); hub mode stays a deferred
  opt-in seam.
- **D6c** Subnet-exclusive sync honored (hidden ⟹ not synced; per-endpoint membership list).
- **Reqs:** REQ-NET-3, REQ-INST-2, REQ-INST-5; `REQ-HAZARD-DIRECT-WRITE-PRECEDENCE` (extend
  coverage to multi-writer/node-id). · **Acceptance:** the mind follows the user across two
  machines (two-tier); concurrent-write conflict surfaced, not silently dropped.

### D7 — Update peer-propagation transport (into the M3c engine seam)
- **D7a** Plug the P2P transport into the **existing M3c update-engine seam** (`update::`
  front door); the subnet self-heals to latest. No re-architecture of the engine.
- **D7b** **Every propagated binary still passes the M3c signature/rollback gate per node**
  before any handoff — one compromised node must not poison the subnet (ADR-0004 §D). · **REQ-UPD-1**
- **Reqs:** REQ-UPD-1. · **Acceptance:** a signed update self-heals across the subnet; the
  Ed25519+rollback gate runs on every receiving node; a tampered propagated binary is rejected.

### D7.5 — Psyche outbound relay channel (prerequisite for D8) — ADR-0012
> Surfaced via grill-with-docs 2026-06-03. The Psyche is **sandboxed** (Read/Write/Edit
> only) — its **sole outbound is stdout** `<EVENT type="reply|notify">` intents, which the
> daemon parses + relays as its proxy. spt-core has **neither** a stdout-capturing
> live-Psyche driver **nor** a marker parser yet; the interim `spawn_session` `Stdio::null()`
> silently discards every Psyche reply/notify (KNOWN-HAZARDS 7.3).
- **D7.5a** Drive the live-Psyche turn as a **bounded, stdin-fed, stdout-CAPTURED** harness
  invocation (per-turn `--resume`, mirroring the sister) — **not** the detached
  `Stdio::null()` `spawn_session`. The captured stdout is the reply/notify channel.
  **Pulled forward into D6b** (ADR-0013: the conflict-reconcile turn needs it); D7.5
  consumes the driver built there.
- **D7.5b** Parse the Psyche's stdout `<EVENT type="reply">` / `<EVENT type="notify">`
  envelopes; extend the `spt-proto::event` type taxonomy (`+reply`, `+notify`).
- **D7.5c** Daemon relay + **sanitize** (the trust boundary): strip any Psyche-supplied
  `from=`/target, re-stamp `from=<self_id>`, **constrain routing** (`reply` → the
  `__REPLY_TO__` sender only; `notify` → the agent's own user/subnet only), validate body
  (4.1). Anti-spoof is a conformance invariant, not best-effort.
- **Reqs:** `REQ-HAZARD-PSYCHE-OUTBOUND-PROXY` (activate here), ADR-0012. · **Feeds:** D8
  (the `notify` relay is D8's Psyche-producer path; the `reply` half is psyche→agent
  messaging). · **Acceptance:** a Psyche turn emitting `<EVENT type="reply">` relays to the
  inbound sender; `<EVENT type="notify">` reaches the user; a Psyche-supplied `from=` is
  stripped (anti-spoof negative test); a null-stdout driver fails the guard.

### D8 — Subnet notification primitive
- **D8a** Notif as a first-class kind (user-directed, dismissable, resurfacing): per-subnet
  replicated spool (reuses D3 distribution), subnet-tagged, dismiss-state replicates
  subnet-wide. Two states seen/dismissed. · **REQ-NOTIF-1**
- **D8b** First-fire → most-recently-active endpoint in-subnet; resurface undismissed at
  reported boundaries (wake / api-clear / api-compact / new-session) gated by seen-per-endpoint
  + cross-endpoint suppression timeout (~1h). Scoped to visible subnets.
- **D8c** Refactor the M3c self-update prompt + consent escalation to be **notif producers**
  (one primitive, not three ad-hoc paths).
- **D8d** `spt notify` (agent-issued subnet notif) + `notif_command` manifest seam on harness
  + shell adapters; envelope `from` = issuer endpoint+node+subnet. · **REQ-NOTIF-2**
- **Reqs:** REQ-NOTIF-1, REQ-NOTIF-2. · **Acceptance:** a notif fires to the active endpoint,
  resurfaces at a boundary, dismiss replicates subnet-wide; `spt notify` reaches the user.

### D9 — Activation sweep + cross-node E2E + closeout
- **D9a** Dormancy resource budget (red-team **#9**): measure N dormant sessions on real
  adapters; define adapter warm/cold policy before locking warm default. · **REQ-INST-3, REQ-INST-4**
- **D9b** Two-host cross-node E2E on the real rig (gravity-linux ↔ a Windows host): pair →
  register → cross-node message → remote-drive → Psyche sync → update self-heal.
- **D9c** REQ-INST-14 (resource advertisement) + REQ-INST-15 (immutable home subnet + `spt
  fork`, ADR-0010) — register/activate if in V1-mid line; else document as forward seam.
- **D9d** Confirm every M4 req `required_stages` is satisfied; `traceable-reqs check` green;
  amend ROADMAP (M4 delivered) + CONTEXT glossary; CI matrix green (ubuntu+windows+traceability).
- **Acceptance:** the V1-mid scope line met; `check` green; CI green; cross-node proof demoed.

## Requirement → task coverage map

| Task | Activates |
|------|-----------|
| D0 | REQ-NET-1 (doc) |
| D1 | REQ-NET-1 (impl/unit), REQ-NET-2 |
| D2 | REQ-PAIR-1..6 + 3 new `REQ-HAZARD-PAIR-*` |
| D3 | REQ-INST-7,9,10,11,12,13 + `REQ-HAZARD-REGISTRY-EPOCH-LEASE` |
| D4 | REQ-EP-4 (seam), ADR-0004 §B |
| D5 | REQ-INST-8, REQ-REACH-1, REQ-SEC-1, REQ-NET-1 (WAN msg) |
| D6 | REQ-NET-3, REQ-INST-2, REQ-INST-5, `REQ-HAZARD-DIRECT-WRITE-PRECEDENCE` (extend) |
| D7 | REQ-UPD-1 |
| D7.5 | REQ-HAZARD-PSYCHE-OUTBOUND-PROXY (+ ADR-0012) |
| D8 | REQ-NOTIF-1, REQ-NOTIF-2 (notify-from-psyche path depends on D7.5) |
| D9 | REQ-INST-3,4 (+14,15 if in line); sweep |

**Deferred to M5 (out of M4, per Scope §Out):** REQ-EP-5 (shell instantiation), REQ-REACH-2
(remote command exec), PresenceChannel full impl, cross-user generalization of every per-subnet
key. Their seams are shaped in D4/D5/D8 but not built.

## Red-team obligations (binding — from the Stage-A review, must close in M4)

| Item | ADR | Lands in | Gate |
|------|-----|----------|------|
| #6 one-daemon failure domain | 0002 | D4 | fault-injection acceptance matrix |
| #7 wall-clock loses concurrent writes | 0003 | D6 | epoch/vector-clock merge, conflict surfaced |
| #8 ambiguous resolution under skew | 0003 | D3 | monotonic per-node epoch lease + chaos test |
| #9 dormant-warm resource cost | 0003 | D9 | measured budget + warm/cold policy |
| #10 trust-delete ≠ revocation | 0005 | D2c | seed epoch + rotation |
| #11 distributed PAKE guessing | 0005 | D2d | subnet-global rate limit, one ceremony |
| #12 transcript must bind | 0005 | D2b | full transcript spec + negative tests |

## Workspace change

Add `crates/spt-net` (the remaining sibling). Final layering (R-ARCH-1, acyclic):
`spt-proto → spt-store → spt-msg → {spt-net, spt-term, spt-runtime} → spt-live → spt-daemon → spt`.
`spt-net` is **not** public SDK (R-ARCH-2 stays proto/runtime/msg).


1	//! Dormancy resource-budget measurement (D9-3, ADR-0003 red-team #9).
2	//!
3	//! **Env-gated: runs only with `SPT_BUDGET=1`** — CI never sets it, so this
4	//! is a silent no-op there; the real runs happen manually on the rig hosts
5	//! (HFENDULEAM Windows + gravity Linux) and their output is recorded in
6	//! `docs/DORMANCY-BUDGET.md`. Not a pass/fail test: it MEASURES the warm
7	//! (dormant) seat cost so the warm/cold default is a number-backed policy
8	//! instead of a guess (the #9 gate on REQ-INST-3's `doc` stage).
9	//!
10	//! ## What it measures
11	//!
12	//! A **dormant** seat is a live, state-preserved harness session held warm
13	//! by the broker (PTY master + child process + output ring). A **suspended**
14	//! seat is that session closed — only the on-disk perch record remains. So:
15	//!
16	//! 1. spawn N idle sessions through the real broker (the production PTY
17	//!    hosting path), labeled per seat;
18	//! 2. settle, then sample each child's RSS / handle (fd) count / CPU time,
19	//!    twice over an idle window (the delta = idle CPU burn);
20	//! 3. also sample THIS process's RSS before/after spawning (the broker-side
21	//!    per-seat overhead: PTY plumbing + output rings);
22	//! 4. kill the sessions (the suspended state) and sample the residual.
23	//!
24	//! The per-seat child cost + broker delta IS the warm premium over
25	//! suspended; the suspended residual is ~the daemon baseline.
26	//!
27	//! ## Knobs (env)
28	//!
29	//! - `SPT_BUDGET_N` — seats to spawn (default 5).
30	//! - `SPT_BUDGET_PROGRAM` / `SPT_BUDGET_ARGS` — the harness command per seat
31	//!   (space-split args). Default: the platform shell idling at a prompt. On
32	//!   a rig run, point it at the real adapter's harness binary (e.g.
33	//!   `claude`) — the "real adapter" #9 asks for.
34	//! - `SPT_BUDGET_SETTLE_MS` — post-spawn settle before the first sample
35	//!   (default 3000; raise for a heavy harness that loads slowly).
36	//! - `SPT_BUDGET_IDLE_MS` — the idle CPU window between samples (default 5000).
37	// [impl->REQ-INST-3]
38
39	use std::sync::Arc;
40	use std::thread;
41	use std::time::Duration;
42
43	use spt_daemon::brain::Brain;
44	use spt_daemon::broker::Broker;
45	use spt_daemon::msg::SpawnReq;
46	use tempfile::TempDir;
47
48	/// One sampled process snapshot.
49	#[derive(Debug, Clone, Copy, Default)]
50	struct Sample {
51	    rss_bytes: u64,
52	    handles: u64,
53	    cpu_seconds: f64,
54	}
55
56	#[test]
57	fn measure_dormant_seat_budget() {
58	    if std::env::var("SPT_BUDGET").as_deref() != Ok("1") {
59	        return; // silent skip — the rig runs this, never CI
60	    }
61
62	    let n: usize = env_or("SPT_BUDGET_N", 5);
63	    let settle = Duration::from_millis(env_or("SPT_BUDGET_SETTLE_MS", 3_000));
64	    let idle = Duration::from_millis(env_or("SPT_BUDGET_IDLE_MS", 5_000));
65	    let (program, args) = budget_command();
66
67	    // Hermetic home + a real (net-less) broker — the production PTY host.
68	    let home = TempDir::new().expect("temp home");
69	    std::env::set_var("SPT_HOME", home.path());
70	    let name = format!("spt-budget-{}.sock", std::process::id());
71	    let broker = Broker::bind_in_with_net(&name, home.path().join("effects.log"), None)
72	        .expect("bind broker");
73	    let serve = Arc::clone(&broker);
74	    thread::spawn(move || {
75	        let _ = serve.serve();
76	    });
77
78	    let self_before = sample_pid(std::process::id()).unwrap_or_default();
79
80	    // One probe brain per seat (kill_session targets the brain's own session).
81	    let mut seats: Vec<(Brain, u64, u32)> = Vec::new(); // (brain, session, child pid)
82	    for i in 0..n {
83	        let mut brain = connect_retry(&name);
84	        let req = SpawnReq {
85	            program: program.clone(),
86	            args: args.clone(),
87	            rows: 24,
88	            cols: 80,
89	            endpoint: format!("budget-{i}"),
90	        };
91	        let (session, pid) = brain.spawn_session_pid(req).expect("spawn seat");
92	        let pid = pid.expect("seat child pid");
93	        seats.push((brain, session, pid));
94	    }
95
96	    thread::sleep(settle);
97	    let first: Vec<Sample> =
98	        seats.iter().map(|(_, _, pid)| sample_pid(*pid).unwrap_or_default()).collect();
99	    let self_warm = sample_pid(std::process::id()).unwrap_or_default();
100
101	    thread::sleep(idle);
102	    let second: Vec<Sample> =
103	        seats.iter().map(|(_, _, pid)| sample_pid(*pid).unwrap_or_default()).collect();
104
105	    // The measurement report — the runbook captures this stdout block into
106	    // docs/DORMANCY-BUDGET.md verbatim.
107	    println!("## dormancy budget — {} x `{program} {}`", n, args.join(" "));
108	    println!("host: {} ({})", hostname(), std::env::consts::OS);
109	    println!();
110	    println!("| seat | pid | rss | handles/fds | idle cpu (s over {:?}) |", idle);
111	    println!("|------|-----|-----|-------------|------------------------|");
112	    let (mut rss_total, mut handle_total, mut cpu_total) = (0u64, 0u64, 0f64);
113	    for (i, (a, b)) in first.iter().zip(&second).enumerate() {
114	        let burn = (b.cpu_seconds - a.cpu_seconds).max(0.0);
115	        rss_total += b.rss_bytes;
116	        handle_total += b.handles;
117	        cpu_total += burn;
118	        println!(
119	            "| budget-{i} | {} | {} | {} | {burn:.3} |",
120	            seats[i].2,
121	            fmt_bytes(b.rss_bytes),
122	            b.handles
123	        );
124	    }
125	    println!();
126	    println!(
127	        "per-seat avg: rss {} · {} handles/fds · idle cpu {:.3} s/{:?}",
128	        fmt_bytes(rss_total / n as u64),
129	        handle_total / n as u64,
130	        cpu_total / n as f64,
131	        idle
132	    );
133	    println!(
134	        "broker-side overhead for {n} seats (this process RSS delta): {}",
135	        fmt_bytes(self_warm.rss_bytes.saturating_sub(self_before.rss_bytes))
136	    );
137
138	    // Suspend: close every seat (the session ends; only the perch record
139	    // would remain in production). The residual is the daemon baseline.
140	    for (brain, _, _) in seats.iter_mut() {
141	        let _ = brain.kill_session();
142	    }
143	    thread::sleep(Duration::from_millis(1_500));
144	    let self_after = sample_pid(std::process::id()).unwrap_or_default();
145	    println!(
146	        "suspended residual (this process RSS after killing all seats): {} (was {} warm)",
147	        fmt_bytes(self_after.rss_bytes),
148	        fmt_bytes(self_warm.rss_bytes)
149	    );
150	}
151
152	fn env_or<T: std::str::FromStr>(key: &str, default: T) -> T {
153	    std::env::var(key).ok().and_then(|v| v.parse().ok()).unwrap_or(default)
154	}
155
156	/// The per-seat harness command: `SPT_BUDGET_PROGRAM`/`SPT_BUDGET_ARGS`, or
157	/// the platform shell idling at a prompt.
158	fn budget_command() -> (String, Vec<String>) {
159	    let program = std::env::var("SPT_BUDGET_PROGRAM").unwrap_or_else(|_| {
160	        if cfg!(windows) { "cmd".to_string() } else { "sh".to_string() }
161	    });
162	    let args = std::env::var("SPT_BUDGET_ARGS")
163	        .map(|s| s.split_whitespace().map(str::to_string).collect())
164	        .unwrap_or_default();
165	    (program, args)
166	}
167
168	fn hostname() -> String {
169	    std::env::var("COMPUTERNAME")
170	        .or_else(|_| std::env::var("HOSTNAME"))
171	        .unwrap_or_else(|_| "unknown".to_string())
172	}
173
174	fn fmt_bytes(b: u64) -> String {
175	    if b >= 1 << 20 {
176	        format!("{:.1} MiB", b as f64 / (1 << 20) as f64)
177	    } else {
178	        format!("{:.1} KiB", b as f64 / 1024.0)
179	    }
180	}
181
182	fn connect_retry(name: &str) -> Brain {
183	    for _ in 0..300 {
184	        if let Ok(b) = Brain::cold_start(name, 1) {
185	            return b;
186	        }
187	        thread::sleep(Duration::from_millis(10));
188	    }
189	    panic!("brain could not connect");
190	}
191
192	/// Sample one process: RSS bytes, handle/fd count, cumulative CPU seconds.
193	/// Shelling out keeps the harness dependency-free — measurement cadence is
194	/// seconds, not microseconds.
195	#[cfg(windows)]
196	fn sample_pid(pid: u32) -> Option<Sample> {
197	    let out = std::process::Command::new("powershell")
198	        .args([
199	            "-NoProfile",
200	            "-Command",
201	            &format!(
202	                "$p = Get-Process -Id {pid} -ErrorAction Stop; \
203	                 Write-Output \"$($p.WorkingSet64)|$($p.HandleCount)|$($p.TotalProcessorTime.TotalSeconds)\""
204	            ),
205	        ])
206	        .output()
207	        .ok()?;
208	    let text = String::from_utf8_lossy(&out.stdout);
209	    let mut parts = text.trim().split('|');
210	    Some(Sample {
211	        rss_bytes: parts.next()?.parse().ok()?,
212	        handles: parts.next()?.parse().ok()?,
213	        cpu_seconds: parts.next()?.parse().ok()?,
214	    })
215	}
216
217	#[cfg(unix)]
218	fn sample_pid(pid: u32) -> Option<Sample> {
219	    // VmRSS from /proc/<pid>/status (kB).
220	    let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
221	    let rss_kb: u64 = status
222	        .lines()
223	        .find(|l| l.starts_with("VmRSS:"))?
224	        .split_whitespace()
225	        .nth(1)?
226	        .parse()
227	        .ok()?;
228	    // Open fd count.
229	    let fds = std::fs::read_dir(format!("/proc/{pid}/fd")).map(|d| d.count() as u64).unwrap_or(0);
230	    // utime+stime (clock ticks) from /proc/<pid>/stat — fields 14+15, counted
231	    // after the parenthesized comm (which may contain spaces).
232	    let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
233	    let after_comm = stat.rsplit_once(')')?.1;
234	    let fields: Vec<&str> = after_comm.split_whitespace().collect();
235	    let utime: u64 = fields.get(11)?.parse().ok()?; // field 14 overall
236	    let stime: u64 = fields.get(12)?.parse().ok()?; // field 15 overall
237	    let hz = 100.0; // USER_HZ on every Linux the rig runs
238	    Some(Sample {
239	        rss_bytes: rss_kb * 1024,
240	        handles: fds,
241	        cpu_seconds: (utime + stime) as f64 / hz,
242	    })
243	}
244

//! Dormancy resource-budget measurement (D9-3, ADR-0003 red-team #9).
//!
//! **Env-gated: runs only with `SPT_BUDGET=1`** — CI never sets it, so this
//! is a silent no-op there; the real runs happen manually on the rig hosts
//! (HFENDULEAM Windows + gravity Linux) and their output is recorded in
//! `docs/DORMANCY-BUDGET.md`. Not a pass/fail test: it MEASURES the warm
//! (dormant) seat cost so the warm/cold default is a number-backed policy
//! instead of a guess (the #9 gate on REQ-INST-3's `doc` stage).
//!
//! ## What it measures
//!
//! A **dormant** seat is a live, state-preserved harness session held warm
//! by the broker (PTY master + child process + output ring). A **suspended**
//! seat is that session closed — only the on-disk perch record remains. So:
//!
//! 1. spawn N idle sessions through the real broker (the production PTY
//!    hosting path), labeled per seat;
//! 2. settle, then sample each child's RSS / handle (fd) count / CPU time,
//!    twice over an idle window (the delta = idle CPU burn);
//! 3. also sample THIS process's RSS before/after spawning (the broker-side
//!    per-seat overhead: PTY plumbing + output rings);
//! 4. kill the sessions (the suspended state) and sample the residual.
//!
//! The per-seat child cost + broker delta IS the warm premium over
//! suspended; the suspended residual is ~the daemon baseline.
//!
//! ## Knobs (env)
//!
//! - `SPT_BUDGET_N` — seats to spawn (default 5).
//! - `SPT_BUDGET_PROGRAM` / `SPT_BUDGET_ARGS` — the harness command per seat
//!   (space-split args). Default: the platform shell idling at a prompt. On
//!   a rig run, point it at the real adapter's harness binary (e.g.
//!   `claude`) — the "real adapter" #9 asks for.
//! - `SPT_BUDGET_SETTLE_MS` — post-spawn settle before the first sample
//!   (default 3000; raise for a heavy harness that loads slowly).
//! - `SPT_BUDGET_IDLE_MS` — the idle CPU window between samples (default 5000).
// [impl->REQ-INST-3]

use std::sync::Arc;
use std::thread;
use std::time::Duration;

use spt_daemon::brain::Brain;
use spt_daemon::broker::Broker;
use spt_daemon::msg::SpawnReq;
use tempfile::TempDir;

/// One sampled process snapshot.
#[derive(Debug, Clone, Copy, Default)]
struct Sample {
    rss_bytes: u64,
    handles: u64,
    cpu_seconds: f64,
}

#[test]
fn measure_dormant_seat_budget() {
    if std::env::var("SPT_BUDGET").as_deref() != Ok("1") {
        return; // silent skip — the rig runs this, never CI
    }

    let n: usize = env_or("SPT_BUDGET_N", 5);
    let settle = Duration::from_millis(env_or("SPT_BUDGET_SETTLE_MS", 3_000));
    let idle = Duration::from_millis(env_or("SPT_BUDGET_IDLE_MS", 5_000));
    let (program, args) = budget_command();

    // Hermetic home + a real (net-less) broker — the production PTY host.
    let home = TempDir::new().expect("temp home");
    std::env::set_var("SPT_HOME", home.path());
    let name = format!("spt-budget-{}.sock", std::process::id());
    let broker = Broker::bind_in_with_net(&name, home.path().join("effects.log"), None)
        .expect("bind broker");
    let serve = Arc::clone(&broker);
    thread::spawn(move || {
        let _ = serve.serve();
    });

    let self_before = sample_pid(std::process::id()).unwrap_or_default();

    // One probe brain per seat (kill_session targets the brain's own session).
    let mut seats: Vec<(Brain, u64, u32)> = Vec::new(); // (brain, session, child pid)
    for i in 0..n {
        let mut brain = connect_retry(&name);
        let req = SpawnReq {
            program: program.clone(),
            args: args.clone(),
            rows: 24,
            cols: 80,
            endpoint: format!("budget-{i}"),
        };
        let (session, pid) = brain.spawn_session_pid(req).expect("spawn seat");
        let pid = pid.expect("seat child pid");
        seats.push((brain, session, pid));
    }

    thread::sleep(settle);
    let first: Vec<Sample> =
        seats.iter().map(|(_, _, pid)| sample_pid(*pid).unwrap_or_default()).collect();
    let self_warm = sample_pid(std::process::id()).unwrap_or_default();

    thread::sleep(idle);
    let second: Vec<Sample> =
        seats.iter().map(|(_, _, pid)| sample_pid(*pid).unwrap_or_default()).collect();

    // The measurement report — the runbook captures this stdout block into
    // docs/DORMANCY-BUDGET.md verbatim.
    println!("## dormancy budget — {} x `{program} {}`", n, args.join(" "));
    println!("host: {} ({})", hostname(), std::env::consts::OS);
    println!();
    println!("| seat | pid | rss | handles/fds | idle cpu (s over {:?}) |", idle);
    println!("|------|-----|-----|-------------|------------------------|");
    let (mut rss_total, mut handle_total, mut cpu_total) = (0u64, 0u64, 0f64);
    for (i, (a, b)) in first.iter().zip(&second).enumerate() {
        let burn = (b.cpu_seconds - a.cpu_seconds).max(0.0);
        rss_total += b.rss_bytes;
        handle_total += b.handles;
        cpu_total += burn;
        println!(
            "| budget-{i} | {} | {} | {} | {burn:.3} |",
            seats[i].2,
            fmt_bytes(b.rss_bytes),
            b.handles
        );
    }
    println!();
    println!(
        "per-seat avg: rss {} · {} handles/fds · idle cpu {:.3} s/{:?}",
        fmt_bytes(rss_total / n as u64),
        handle_total / n as u64,
        cpu_total / n as f64,
        idle
    );
    println!(
        "broker-side overhead for {n} seats (this process RSS delta): {}",
        fmt_bytes(self_warm.rss_bytes.saturating_sub(self_before.rss_bytes))
    );

    // Suspend: close every seat (the session ends; only the perch record
    // would remain in production). The residual is the daemon baseline.
    for (brain, _, _) in seats.iter_mut() {
        let _ = brain.kill_session();
    }
    thread::sleep(Duration::from_millis(1_500));
    let self_after = sample_pid(std::process::id()).unwrap_or_default();
    println!(
        "suspended residual (this process RSS after killing all seats): {} (was {} warm)",
        fmt_bytes(self_after.rss_bytes),
        fmt_bytes(self_warm.rss_bytes)
    );
}

fn env_or<T: std::str::FromStr>(key: &str, default: T) -> T {
    std::env::var(key).ok().and_then(|v| v.parse().ok()).unwrap_or(default)
}

/// The per-seat harness command: `SPT_BUDGET_PROGRAM`/`SPT_BUDGET_ARGS`, or
/// the platform shell idling at a prompt.
fn budget_command() -> (String, Vec<String>) {
    let program = std::env::var("SPT_BUDGET_PROGRAM").unwrap_or_else(|_| {
        if cfg!(windows) { "cmd".to_string() } else { "sh".to_string() }
    });
    let args = std::env::var("SPT_BUDGET_ARGS")
        .map(|s| s.split_whitespace().map(str::to_string).collect())
        .unwrap_or_default();
    (program, args)
}

fn hostname() -> String {
    std::env::var("COMPUTERNAME")
        .or_else(|_| std::env::var("HOSTNAME"))
        .unwrap_or_else(|_| "unknown".to_string())
}

fn fmt_bytes(b: u64) -> String {
    if b >= 1 << 20 {
        format!("{:.1} MiB", b as f64 / (1 << 20) as f64)
    } else {
        format!("{:.1} KiB", b as f64 / 1024.0)
    }
}

fn connect_retry(name: &str) -> Brain {
    for _ in 0..300 {
        if let Ok(b) = Brain::cold_start(name, 1) {
            return b;
        }
        thread::sleep(Duration::from_millis(10));
    }
    panic!("brain could not connect");
}

/// Sample one process: RSS bytes, handle/fd count, cumulative CPU seconds.
/// Shelling out keeps the harness dependency-free — measurement cadence is
/// seconds, not microseconds.
#[cfg(windows)]
fn sample_pid(pid: u32) -> Option<Sample> {
    let out = std::process::Command::new("powershell")
        .args([
            "-NoProfile",
            "-Command",
            &format!(
                "$p = Get-Process -Id {pid} -ErrorAction Stop; \
                 Write-Output \"$($p.WorkingSet64)|$($p.HandleCount)|$($p.TotalProcessorTime.TotalSeconds)\""
            ),
        ])
        .output()
        .ok()?;
    let text = String::from_utf8_lossy(&out.stdout);
    let mut parts = text.trim().split('|');
    Some(Sample {
        rss_bytes: parts.next()?.parse().ok()?,
        handles: parts.next()?.parse().ok()?,
        cpu_seconds: parts.next()?.parse().ok()?,
    })
}

#[cfg(unix)]
fn sample_pid(pid: u32) -> Option<Sample> {
    // VmRSS from /proc/<pid>/status (kB).
    let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
    let rss_kb: u64 = status
        .lines()
        .find(|l| l.starts_with("VmRSS:"))?
        .split_whitespace()
        .nth(1)?
        .parse()
        .ok()?;
    // Open fd count.
    let fds = std::fs::read_dir(format!("/proc/{pid}/fd")).map(|d| d.count() as u64).unwrap_or(0);
    // utime+stime (clock ticks) from /proc/<pid>/stat — fields 14+15, counted
    // after the parenthesized comm (which may contain spaces).
    let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
    let after_comm = stat.rsplit_once(')')?.1;
    let fields: Vec<&str> = after_comm.split_whitespace().collect();
    let utime: u64 = fields.get(11)?.parse().ok()?; // field 14 overall
    let stime: u64 = fields.get(12)?.parse().ok()?; // field 15 overall
    let hz = 100.0; // USER_HZ on every Linux the rig runs
    Some(Sample {
        rss_bytes: rss_kb * 1024,
        handles: fds,
        cpu_seconds: (utime + stime) as f64 / hz,
    })
}


1	name: ci
2
3	on:
4	  push:
5	    branches: [main, dev-freeform]
6	  pull_request:
7
8	# Cancel superseded runs on the same ref.
9	concurrency:
10	  group: ci-${{ github.ref }}
11	  cancel-in-progress: true
12
13	jobs:
14	  # ---- Rust build + test + lint (cross-platform: exercises both the
15	  # cfg(windows) OpenProcess path and the cfg(unix) libc path in proc.rs) ----
16	  #
17	  # CI-SELFHOST-PLAN.md: fully self-hosted (2026-06-03) — hfenduleam carries
18	  # the Windows leg, gravity-linux the Linux leg + traceability. Free minutes;
19	  # persistent cargo/target = warm builds (the multi-exe iroh link cost is
20	  # paid once per machine); hung tests debuggable on-box. Both boxes own
21	  # their rust toolchains (rustup-managed); no toolchain action, no
22	  # rust-cache (the persistent target dir IS the cache).
23	  test:
24	    strategy:
25	      fail-fast: false
26	      matrix:
27	        runner: [[self-hosted, Linux, gravity], [self-hosted, Windows, hfenduleam]]
28	    runs-on: ${{ matrix.runner }}
29	    # A wedged test must not block the single self-hosted runner slot (the
30	    # 2026-06-03 handoff.rs ConPTY stall burned 22 hosted minutes unbounded).
31	    timeout-minutes: 25
32	    steps:
33	      - uses: actions/checkout@v5
34	      - name: Build
35	        run: cargo build --workspace --all-targets
36	      - name: Test
37	        run: cargo test --workspace
38	      - name: Clippy (deny warnings)
39	        run: cargo clippy --workspace --all-targets -- -D warnings
40
41	      # ---- D9-3 dormancy-budget measurement (ADR-0003 red-team #9) ----
42	      # Opt-in per push: runs ONLY when the head commit message carries
43	      # "[budget]" — both rig hosts measure in one tagged push, output lands
44	      # in the run log and is captured into docs/DORMANCY-BUDGET.md. The
45	      # harness itself is also env-gated (SPT_BUDGET=1), so an untagged CI
46	      # run silent-skips it inside the normal test sweep.
47	      - name: Dormancy budget (shell seats)
48	        if: contains(github.event.head_commit.message, '[budget]')
49	        run: cargo test -p spt-daemon --test budget --release -- --nocapture
50	        env:
51	          SPT_BUDGET: "1"
52	      # Two shells: the Windows runner has no bash on PATH (the first tagged
53	      # run failed on `shell: bash` there), so each OS keeps its native one.
54	      - name: Dormancy budget (real adapter seats, when installed) — linux
55	        if: contains(github.event.head_commit.message, '[budget]') && runner.os == 'Linux'
56	        shell: bash
57	        run: |
58	          if command -v claude >/dev/null; then
59	            SPT_BUDGET=1 SPT_BUDGET_N=3 SPT_BUDGET_PROGRAM=claude SPT_BUDGET_SETTLE_MS=15000 \
60	              cargo test -p spt-daemon --test budget --release -- --nocapture
61	          else
62	            echo "claude not installed on this runner — shell numbers only"
63	          fi
64	      - name: Dormancy budget (real adapter seats, when installed) — windows
65	        if: contains(github.event.head_commit.message, '[budget]') && runner.os == 'Windows'
66	        shell: powershell
67	        run: |
68	          if (Get-Command claude -ErrorAction SilentlyContinue) {
69	            $env:SPT_BUDGET = "1"
70	            $env:SPT_BUDGET_N = "3"
71	            $env:SPT_BUDGET_PROGRAM = "claude"
72	            $env:SPT_BUDGET_SETTLE_MS = "15000"
73	            cargo test -p spt-daemon --test budget --release -- --nocapture
74	          } else {
75	            # ASCII only: Windows PowerShell 5.1 reads the step's temp script
76	            # without a BOM as ANSI, so a multibyte dash corrupts the string
77	            # terminator (the first tagged-run failure mode here).
78	            Write-Output "claude not installed on this runner - shell numbers only"
79	          }
80
81	  # ---- Requirement-traceability hard gate (TRACEABILITY.md layer 1) ----
82	  traceability:
83	    runs-on: [self-hosted, Linux, gravity]
84	    timeout-minutes: 10
85	    steps:
86	      - uses: actions/checkout@v5
87	      - name: Install traceable-reqs
88	        # The self-hosted gravity runner carries a source-built binary in
89	        # /usr/local/bin (the release asset needs GLIBC 2.39; gravity's
90	        # Ubuntu 22.04 has 2.35). The download is the portability fallback
91	        # for any other runner.
92	        run: |
93	          if command -v traceable-reqs >/dev/null; then
94	            ln -sf "$(command -v traceable-reqs)" ./traceable-reqs
95	          else
96	            gh release download --repo BigscreenVR/traceable-reqs \
97	              --pattern '*linux-x86_64' --output traceable-reqs
98	            chmod +x traceable-reqs
99	          fi
100	        env:
101	          # Cross-org private repo: the default repo-scoped GITHUB_TOKEN cannot
102	          # read BigscreenVR/traceable-reqs. Supply a PAT with read access to it
103	          # as the TRACEABLE_REQS_TOKEN repo secret. See README/CI notes.
104	          GH_TOKEN: ${{ secrets.TRACEABLE_REQS_TOKEN }}
105	      - name: Coverage gate (hard — fails PR on uncovered activated req)
106	        run: ./traceable-reqs check --json
107	      - name: Quality audit (advisory)
108	        run: ./traceable-reqs lint || true
109

name: ci

on:
  push:
    branches: [main, dev-freeform]
  pull_request:

# Cancel superseded runs on the same ref.
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  # ---- Rust build + test + lint (cross-platform: exercises both the
  # cfg(windows) OpenProcess path and the cfg(unix) libc path in proc.rs) ----
  #
  # CI-SELFHOST-PLAN.md: fully self-hosted (2026-06-03) — hfenduleam carries
  # the Windows leg, gravity-linux the Linux leg + traceability. Free minutes;
  # persistent cargo/target = warm builds (the multi-exe iroh link cost is
  # paid once per machine); hung tests debuggable on-box. Both boxes own
  # their rust toolchains (rustup-managed); no toolchain action, no
  # rust-cache (the persistent target dir IS the cache).
  test:
    strategy:
      fail-fast: false
      matrix:
        runner: [[self-hosted, Linux, gravity], [self-hosted, Windows, hfenduleam]]
    runs-on: ${{ matrix.runner }}
    # A wedged test must not block the single self-hosted runner slot (the
    # 2026-06-03 handoff.rs ConPTY stall burned 22 hosted minutes unbounded).
    timeout-minutes: 25
    steps:
      - uses: actions/checkout@v5
      - name: Build
        run: cargo build --workspace --all-targets
      - name: Test
        run: cargo test --workspace
      - name: Clippy (deny warnings)
        run: cargo clippy --workspace --all-targets -- -D warnings

      # ---- D9-3 dormancy-budget measurement (ADR-0003 red-team #9) ----
      # Opt-in per push: runs ONLY when the head commit message carries
      # "[budget]" — both rig hosts measure in one tagged push, output lands
      # in the run log and is captured into docs/DORMANCY-BUDGET.md. The
      # harness itself is also env-gated (SPT_BUDGET=1), so an untagged CI
      # run silent-skips it inside the normal test sweep.
      - name: Dormancy budget (shell seats)
        if: contains(github.event.head_commit.message, '[budget]')
        run: cargo test -p spt-daemon --test budget --release -- --nocapture
        env:
          SPT_BUDGET: "1"
      # Two shells: the Windows runner has no bash on PATH (the first tagged
      # run failed on `shell: bash` there), so each OS keeps its native one.
      - name: Dormancy budget (real adapter seats, when installed) — linux
        if: contains(github.event.head_commit.message, '[budget]') && runner.os == 'Linux'
        shell: bash
        run: |
          if command -v claude >/dev/null; then
            SPT_BUDGET=1 SPT_BUDGET_N=3 SPT_BUDGET_PROGRAM=claude SPT_BUDGET_SETTLE_MS=15000 \
              cargo test -p spt-daemon --test budget --release -- --nocapture
          else
            echo "claude not installed on this runner — shell numbers only"
          fi
      - name: Dormancy budget (real adapter seats, when installed) — windows
        if: contains(github.event.head_commit.message, '[budget]') && runner.os == 'Windows'
        shell: powershell
        run: |
          if (Get-Command claude -ErrorAction SilentlyContinue) {
            $env:SPT_BUDGET = "1"
            $env:SPT_BUDGET_N = "3"
            $env:SPT_BUDGET_PROGRAM = "claude"
            $env:SPT_BUDGET_SETTLE_MS = "15000"
            cargo test -p spt-daemon --test budget --release -- --nocapture
          } else {
            # ASCII only: Windows PowerShell 5.1 reads the step's temp script
            # without a BOM as ANSI, so a multibyte dash corrupts the string
            # terminator (the first tagged-run failure mode here).
            Write-Output "claude not installed on this runner - shell numbers only"
          }

  # ---- Requirement-traceability hard gate (TRACEABILITY.md layer 1) ----
  traceability:
    runs-on: [self-hosted, Linux, gravity]
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v5
      - name: Install traceable-reqs
        # The self-hosted gravity runner carries a source-built binary in
        # /usr/local/bin (the release asset needs GLIBC 2.39; gravity's
        # Ubuntu 22.04 has 2.35). The download is the portability fallback
        # for any other runner.
        run: |
          if command -v traceable-reqs >/dev/null; then
            ln -sf "$(command -v traceable-reqs)" ./traceable-reqs
          else
            gh release download --repo BigscreenVR/traceable-reqs \
              --pattern '*linux-x86_64' --output traceable-reqs
            chmod +x traceable-reqs
          fi
        env:
          # Cross-org private repo: the default repo-scoped GITHUB_TOKEN cannot
          # read BigscreenVR/traceable-reqs. Supply a PAT with read access to it
          # as the TRACEABLE_REQS_TOKEN repo secret. See README/CI notes.
          GH_TOKEN: ${{ secrets.TRACEABLE_REQS_TOKEN }}
      - name: Coverage gate (hard — fails PR on uncovered activated req)
        run: ./traceable-reqs check --json
      - name: Quality audit (advisory)
        run: ./traceable-reqs lint || true


Agent

Survey two-host test surfaces

Prompt:
In repo C:\Users\decid\Documents\projects\spt-core (Rust workspace), I need to write SPT_TWO_HOST=1 env-gated integration tests that run on two real hosts (one Windows "A" role, one Linux "B" role, peer address via env) exercising this ladder: pair → register (registry replication) → cross-node message → remote-drive → Psyche sync → update self-heal → notification cross-node (insert on A fires on B; dismiss on B replicates to A).

Report with thorough breadth (read code, cite file:line):

1. **CLI surface of the `spt` binary** (crates/spt): list every subcommand relevant to: daemon start/stop, listen/bind/api, pairing (pair/join/invite?), sending messages cross-node, registry inspection (list?), notifications (notify/dismiss?), update/self-heal, sync/psyche sync, remote drive. For each: exact subcommand syntax + what it does.

2. **How existing in-process two-node tests simulate two nodes** — look at crates/spt-daemon/tests/wanmsg.rs, peerloop.rs, sync.rs, replicate.rs, notifsync.rs, propagate.rs, presence.rs. What helpers do they use (two brokers? iroh endpoints? loopback)? Are there reusable test helpers for spinning up a daemon with networking?

3. **Pairing flow**: how does a real pairing ceremony work end-to-end (PAKE? invite code?)? What CLI/API calls does each side make? Where is the address/ticket exchanged (iroh node addr? relay?)?

4. **What "remote-drive" means** in this codebase — find the remote drive / cross-node terminal-drive feature (likely netstream.rs / netbroker.rs / xfer.rs / dispatch.rs / attach). What's the API/CLI to drive a session on a peer node?

5. **Update self-heal** (REQ-UPD-1): where is update propagation code, what triggers it, how would a two-host test exercise it (can it be tested without a real signed binary? is there a test mode?).

6. **Notification primitive** (REQ-NOTIF-1): spool insert / fire / dismiss APIs, how dismiss replicates, what CLI exists (spt notify?).

7. **Psyche sync** (REQ-INST-5 / context sync): how does endpoint context sync across nodes (git bundles over broker streams?), what API/CLI triggers a sync pull/push, how a test would verify both tiers replicated.

8. **Existing int-stage tags**: grep for `[int->` tags to see which exist already, and grep traceable-reqs.toml for REQ-NET-1, REQ-INST-5, REQ-UPD-1, REQ-NOTIF-1 required_stages current values.

9. Any existing env-gated multi-host scaffolding or mention of SPT_TWO_HOST anywhere.

Be precise about exact type/function names and module paths so tests can be written against real APIs.


<owl_messages>
<owl_message from="doyle-psyche" priority="normal"><EVENT type="echo_commune" from="doyle-psyche" timestamp="2026-06-04T06:46:40-07:00" machine="HFENDULEAM" project="spt-core" branch="dev-freeform" head_sha="92fc261c83af6f2a09cf137b6f8ed9150bd21b32" head_subject="docs(plan): mark M4-D9-3/4/5 complete (budget measured, resources, home+…" note="Echo commune brief — auto-fired">&lt;live-context&gt;
Generation 16. D9-2 through D9-5 CI-green @ 92fc261. D9-6 next (two-host rig proof).

## D9-2..D9-5 COMPLETE (all CI-green-FINAL)
- D9-2 a52dacb: resting-state model (REQ-INST-3/4 [impl,unit]). Pure transition table; Status::Suspended (addressable); advertise_local follows machine; echo BEFORE flip (KH 3.3); Wake→resurface+freshness-pull marker; pulse recency gated on rest state; daemon-side echo GATE-ARMED (KH 7.4 by construction).
- D9-3 650c7fb: dormancy budget. SPT_BUDGET=1 harness; PS5.1 ASCII-only (em-dash broke ANSI). Numbers: HFENDULEAM shell 7.6MiB/0cpu, claude ~308MiB/0cpu; gravity shell 1.7MiB/4fd. DORMANCY-BUDGET.md = policy lock (warm default CONFIRMED; auto-suspend opt-in OFF). ADR-0003 #9 CLOSED. REQ-INST-3 [doc].
- D9-4 0ea318b: resource advertisement (REQ-INST-14 [doc,impl,unit]). Instance.resources additive serde; resource_projection visibility-gated; both-authored (info.json wins over daemon.json seed). Doc evidence in docs/STORAGE.md (NOT CONTEXT.md — scan roots = src/tests/docs/crates only).
- D9-5 0b30dea: home + fork (REQ-INST-15 [doc,impl,unit]). assign_home matrix (sole auto/multi refuse-and-qualify/unpaired local-only); stamp_creation_fields = ONE creation seam; REVIVE carries forward home/adapter/resources/auto_suspend (establish_perch REWRITES info.json — carry-forward load-bearing). ContextStore::fork_endpoint = parentless seed commits (diverged, no shared DAG); spt fork refusal-first gates. InfoJson grew home_subnet+adapter.

## Reqs now active
REQ-INST-3 [doc,impl,unit] · REQ-INST-4 [impl,unit] · REQ-INST-14 [doc,impl,unit] · REQ-INST-15 [doc,impl,unit]. REQ-INST-6 stays [] (M5, seam doc'd). REQ-HAZARD-DAEMON-SCHED-NONBLOCKING stays [] (gate-arm construction).

## Current Focus: D9-6 — two-host E2E + runbook + gh-interim retirement
- SPT_TWO_HOST=1-gated int tests: pair→register→msg→remote-drive→Psyche sync→update self-heal→notif cross-node. Int tags activate ONLY after real rig run green.
- docs/TWO-HOST-RUNBOOK.md + evidence appendix from real run.
- Rig constraint: no SSH HFENDULEAM→gravity (hostname unresolvable). Use [twohost] commit-message-gated CI step pattern (proven D9-3) for gravity leg.
- Retire gh-repo interim: sister skill stands down; psyche-sync-setup remains for hub-mode seam; note in CONTEXT.md.
- Then D9-7: traceable-reqs full audit, ROADMAP M4-delivered, CONTEXT amendments, plan-status COMPLETE, CI FINAL green.

## Gotchas (carry — all 13)
1. Journaled net_stream_send (Some(op_id)) blocks ack + DISCARDS interleaved events. Pump loops use None.
2. Echo children repeat typed input; compare against local OutputLog replay.
3. CI Linux clippy rustc 1.96 deny-defaults newer than local.
4. &quot;Stop once buffer contains needle&quot; overshoots; compare prefix-through-first-needle.
5. Brain::snapshot()/handoff() panics without PTY session — net-only restart tests use fresh cold_start.
6. QUIC bidi stream invisible until first bytes flow — send before wait_for_stream.
7. `gh run watch` exit code LIES — verify FINAL via `gh run list --json conclusion`.
8. Manifest test commands in TOML '…' literals — nested single quotes break parse; Windows masks it.
9. Broker stream table holds LOCALLY-OPENED outbound streams too — snapshot before requester opens.
10. Scripted `&lt;EVENT&gt;` envelopes: raw `\n` splits line — use `&lt;br&gt;` per 4.1 grammar.
11. traceable-reqs scan.roots excludes repo-root *.md — doc tags must land under docs/.
12. Windows PowerShell 5.1 CI steps are ANSI — ASCII-only strings only.
13. `gh run list --limit 1` races fresh push — pin watcher loops to pushed sha.

## Flake Ledger
1. brain_restart_survives_gaplessly — hung once hosted Windows; timeout bounds it.
2. spt-term::digest_parses_a_real_pty_stream — failed once on gravity; rerun passed.
3. sync.rs torn_pull + concurrent_writes — HARDENED: wait_for_stream_except 2s→10s.

## CI Topology (stable)
- hfenduleam (Windows): C:\actions-runner detached (NOT service — re-launch: `Start-Process C:\actions-runner\run.cmd`). Queue can sit minutes — poll, don't panic.
- gravity (Linux): systemd service, auto-start. No SSH from hfenduleam (unresolvable hostname) — use [twohost] CI gate.

## Workflow Constraints (carried)
- NO cargo fmt
- Tag [impl/unit-&gt;REQ-…] on real evidence only
- traceable-reqs check from REPO ROOT (exit 0 before push; scan roots = src/tests/docs/crates)
- Linux CI clippy -D warnings is real gate
- Verify FINAL conclusion via `gh run list --json conclusion` pinned to pushed sha (gotchas #7+#13)
- Push each slice → watch green before next
- Commit trailer: `Co-Authored-By: Claude Opus 4.8 (1M context) &lt;noreply@anthropic.com&gt;`

## No Blockers
&lt;/live-context&gt;
&lt;project-context&gt;
name=spt-core

M4-D9: D9-1..D9-5 COMPLETE CI-green (92fc261 plan-status). Production trigger loops, resting-state model (REQ-INST-3/4), dormancy budget measured + policy locked (ADR-0003 #9 closed, warm default confirmed), resource advertisement (REQ-INST-14), immutable home + spt fork (REQ-INST-15). Remaining: D9-6 two-host rig proof (SPT_TWO_HOST=1 int tests + TWO-HOST-RUNBOOK.md + real rig run + gh-interim retirement), D9-7 closeout sweep → M4 delivered. Conventions hold: no cargo fmt; evidence-only tags; traceable-reqs check repo root (scan roots = src/tests/docs/crates — repo-root md NOT scanned); clippy -D warnings; push slice → watch FINAL sha-pinned (gotcha #7+#13); commit trailer Claude Opus 4.8 (1M context).
&lt;/project-context&gt;</EVENT></owl_message>
</owl_messages>

<owl_reachability_notice>
You have an active owl perch. Launch this Agent with run_in_background: true so you stay reachable for incoming messages while it works.
</owl_reachability_notice>


Async agent launched successfully.
agentId: a214c19ca81eb6426 (internal ID - do not mention to user. Use SendMessage with to: 'a214c19ca81eb6426' to continue this agent.)
The agent is working in the background. You will be notified automatically when it completes.
Do not duplicate this agent's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.
output_file: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\a214c19ca81eb6426.output
Do NOT Read or tail this file via the shell tool — it is the full subagent JSONL transcript and reading it will overflow your context. If the user asks for progress, say the agent is still running; you'll get a completion notification.

Survey two-host test surfaces

Prompt:
In repo C:\Users\decid\Documents\projects\spt-core (Rust workspace), I need to write SPT_TWO_HOST=1 env-gated integration tests that run on two real hosts (one Windows "A" role, one Linux "B" role, peer address via env) exercising this ladder: pair → register (registry replication) → cross-node message → remote-drive → Psyche sync → update self-heal → notification cross-node (insert on A fires on B; dismiss on B replicates to A).

Report with thorough breadth (read code, cite file:line):

1. **CLI surface of the `spt` binary** (crates/spt): list every subcommand relevant to: daemon start/stop, listen/bind/api, pairing (pair/join/invite?), sending messages cross-node, registry inspection (list?), notifications (notify/dismiss?), update/self-heal, sync/psyche sync, remote drive. For each: exact subcommand syntax + what it does.

2. **How existing in-process two-node tests simulate two nodes** — look at crates/spt-daemon/tests/wanmsg.rs, peerloop.rs, sync.rs, replicate.rs, notifsync.rs, propagate.rs, presence.rs. What helpers do they use (two brokers? iroh endpoints? loopback)? Are there reusable test helpers for spinning up a daemon with networking?

3. **Pairing flow**: how does a real pairing ceremony work end-to-end (PAKE? invite code?)? What CLI/API calls does each side make? Where is the address/ticket exchanged (iroh node addr? relay?)?

4. **What "remote-drive" means** in this codebase — find the remote drive / cross-node terminal-drive feature (likely netstream.rs / netbroker.rs / xfer.rs / dispatch.rs / attach). What's the API/CLI to drive a session on a peer node?

5. **Update self-heal** (REQ-UPD-1): where is update propagation code, what triggers it, how would a two-host test exercise it (can it be tested without a real signed binary? is there a test mode?).

6. **Notification primitive** (REQ-NOTIF-1): spool insert / fire / dismiss APIs, how dismiss replicates, what CLI exists (spt notify?).

7. **Psyche sync** (REQ-INST-5 / context sync): how does endpoint context sync across nodes (git bundles over broker streams?), what API/CLI triggers a sync pull/push, how a test would verify both tiers replicated.

8. **Existing int-stage tags**: grep for `[int->` tags to see which exist already, and grep traceable-reqs.toml for REQ-NET-1, REQ-INST-5, REQ-UPD-1, REQ-NOTIF-1 required_stages current values.

9. Any existing env-gated multi-host scaffolding or mention of SPT_TWO_HOST anywhere.

Be precise about exact type/function names and module paths so tests can be written against real APIs.


<owl_messages>
<owl_message from="doyle-w336" priority="info">[WORKING_PERCH_NOTICE] Subagent working perch created.
Perch: doyle-w336
Agent type: Explore
To message it: $OWL send doyle-w336 doyle</owl_message>
</owl_messages>

[OWL SYSTEM] Informational owl messages above — no acknowledgment needed. Continue your current task.


No matches found


72: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) or **delegated command** (a binary command the adapter owns, e.g. `claude.exe plugin update` — spt-core invokes it). After initial bootstrap, the plugin no longer self-manages updates; spt-core conducts them.
445:- **In v1:** the endpoint-ID / instance data-model split; the subnet registry; resolution policy; remote-drive of *already-running* instances (network session-surface); cross-node Psyche sync (full — replaces gh-repo-sync); cross-instance context-freshness auto-feed; off-node file transfer; off-node remote-drive detection.


Bash

Show files in plan-status commit


commit 92fc261c83af6f2a09cf137b6f8ed9150bd21b32
Author: Reavo End <decidel@gmail.com>
Date:   Thu Jun 4 06:44:29 2026 -0700

    docs(plan): mark M4-D9-3/4/5 complete (budget measured, resources, home+fork)

    REQ-INST-3 doc stage closed by DORMANCY-BUDGET.md (ADR-0003 #9);
    REQ-INST-14 and REQ-INST-15 activated at [doc, impl, unit]. Remaining:
    D9-6 two-host rig proof + gh-interim retirement, D9-7 closeout sweep.

    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

 M4-D9-PLAN.md | 25 ++++++++++++++++++++++++-
 1 file changed, 24 insertions(+), 1 deletion(-)