1	===echo_commune: separate cheap model? + delivery to psyche===
2	// Echo commune: detached process that fires a fresh ephemeral haiku session
3	// seeded with a filtered jsonl excerpt of recent Self turns, parses a
4	// [COMMUNE] delta marker out of the response, and delivers it to Psyche.
5	//
6	// Spawned by the wrapper's .more-done sentinel path (Phase 18.3
7	// fire_echo_commune_if_due in src/live/wrapper/echo_fire.rs) as a
8	// fire-and-forget process.
9	//
10	// Phase 18.8 refactor (D-01..D-19):
11	//   - D-01..D-03: always capture subprocess stderr and append a framed
12	//                 ECHO_COMMUNE block to logs_latest/{self_id}.log. Header
13	//                 carries self+fresh session UUIDs, pid, exit, stdout/
14	//                 excerpt byte+line counts. Stderr bytes are verbatim.
15	//   - D-04..D-06: fresh haiku session every fire (no `--resume`), CWD
16	//                 pinned to psyche_dir(). `--no-session-persistence` is
17	//                 NOT set — haiku jsonl writeback under <claude_projects_root>
18	//                 /{enc-psyche_dir}/ is preserved so operators can
19	//                 `claude --resume <session_uuid>` for post-hoc forensics.
20	//                 Freshness is guaranteed by the absence of `--resume`, not
21	//                 by suppressing persistence.
22	//   - D-07..D-11: Rust-side jsonl excerpt extraction. owl.exe computes the
23	//                 <claude_projects_root>/{enc-wrapper-cwd}/{self_session}.jsonl
24	//                 path, filters entries by timestamp > last_commune_epoch
25	//                 and entry type (user text + assistant text/thinking;
26	//                 drop tool_use/tool_result/system), tail-caps at 64KB,
27	//                 writes to psyche_dir/echo-input-{epoch}.jsonl. Grants
28	//                 haiku Read via a second --add-dir for the Self-jsonl
29	//                 parent dir as a fallback source.
30	//   - D-12..D-15: bump .last-commune-epoch unconditionally after
31	//                 wait_with_output returned (regardless of marker count).
32	//                 Spawn-failure path skips the bump along with the frame.
33	//   - D-16..D-18: prompt is file-ref only (self_md + excerpt + source
34	//                 jsonl fallback). Haiku produces a delta, not a raw
35	//                 summary.
36	//   - D-19: NO new gate + NO retry-on-empty. Wrapper's 15-min .more-done
37	//           sentinel owns triggering. Empty [COMMUNE][/COMMUNE] is a
38	//           legitimate "nothing significant" signal that still consumes
39	//           the window.
40	//
41	// See .planning/phases/18.8-echo-commune-stderr-capture-no-retry-on-empty-and-refactor-t/
42
43	use std::fs::OpenOptions;
44	use std::io::{BufRead, Write};
45	use std::path::{Path, PathBuf};
46	use std::process::{Command, Stdio};
47	===model used by summarizer (haiku/cheap?)===
48	1:// Echo commune: detached process that fires a fresh ephemeral haiku session
49	3:// [COMMUNE] delta marker out of the response, and delivers it to Psyche.
50	14://   - D-04..D-06: fresh haiku session every fire (no `--resume`), CWD
51	15://                 pinned to psyche_dir(). `--no-session-persistence` is
52	16://                 NOT set — haiku jsonl writeback under <claude_projects_root>
53	26://                 writes to psyche_dir/echo-input-{epoch}.jsonl. Grants
54	27://                 haiku Read via a second --add-dir for the Self-jsonl
55	53:/// `<EVENT type="echo_commune" ...>` envelope delivered to the Psyche bundle
56	210:///   2. Invoke `psyche_deliver(psyche_id, &payload)` — ALWAYS.
57	211:///   3. If `forward_to_self`, invoke `self_deliver(self_id, &from, &payload)`
58	218:/// Production callers pass closures that wrap `send::deliver_body_anonymous`
59	219:/// and `send::deliver_body` in `std::panic::catch_unwind`. Tests pass
60	226:/// substitute the body delivered to SELF only (the Psyche-bundle leg always
61	227:/// keeps the haiku's literal output). Production wires this to the on-disk
62	228:/// snapshot fallback so a quiet/empty delta never delivers a hollow
63	238:    mut psyche_deliver: P,
64	239:    mut self_deliver: S,
65	257:        // carries the haiku's literal output.
66	258:        psyche_deliver(psyche_id, &payload);
67	272:                    self_deliver(self_id, &from, &self_payload);
68	274:                None => self_deliver(self_id, &from, &payload),
69	574:/// haiku echo_commune).
70	662:/// `Written` (file was rewritten), `Suppressed` (LLM source declined inside
71	705:/// Takes a raw commune body emitted by the Psyche-haiku child (or, in Wave 4,
72	743:/// dispatching to Psyche/Self). The `#[allow(dead_code)]` carry-forward
73

===echo_commune: separate cheap model? + delivery to psyche===
// Echo commune: detached process that fires a fresh ephemeral haiku session
// seeded with a filtered jsonl excerpt of recent Self turns, parses a
// [COMMUNE] delta marker out of the response, and delivers it to Psyche.
//
// Spawned by the wrapper's .more-done sentinel path (Phase 18.3
// fire_echo_commune_if_due in src/live/wrapper/echo_fire.rs) as a
// fire-and-forget process.
//
// Phase 18.8 refactor (D-01..D-19):
//   - D-01..D-03: always capture subprocess stderr and append a framed
//                 ECHO_COMMUNE block to logs_latest/{self_id}.log. Header
//                 carries self+fresh session UUIDs, pid, exit, stdout/
//                 excerpt byte+line counts. Stderr bytes are verbatim.
//   - D-04..D-06: fresh haiku session every fire (no `--resume`), CWD
//                 pinned to psyche_dir(). `--no-session-persistence` is
//                 NOT set — haiku jsonl writeback under <claude_projects_root>
//                 /{enc-psyche_dir}/ is preserved so operators can
//                 `claude --resume <session_uuid>` for post-hoc forensics.
//                 Freshness is guaranteed by the absence of `--resume`, not
//                 by suppressing persistence.
//   - D-07..D-11: Rust-side jsonl excerpt extraction. owl.exe computes the
//                 <claude_projects_root>/{enc-wrapper-cwd}/{self_session}.jsonl
//                 path, filters entries by timestamp > last_commune_epoch
//                 and entry type (user text + assistant text/thinking;
//                 drop tool_use/tool_result/system), tail-caps at 64KB,
//                 writes to psyche_dir/echo-input-{epoch}.jsonl. Grants
//                 haiku Read via a second --add-dir for the Self-jsonl
//                 parent dir as a fallback source.
//   - D-12..D-15: bump .last-commune-epoch unconditionally after
//                 wait_with_output returned (regardless of marker count).
//                 Spawn-failure path skips the bump along with the frame.
//   - D-16..D-18: prompt is file-ref only (self_md + excerpt + source
//                 jsonl fallback). Haiku produces a delta, not a raw
//                 summary.
//   - D-19: NO new gate + NO retry-on-empty. Wrapper's 15-min .more-done
//           sentinel owns triggering. Empty [COMMUNE][/COMMUNE] is a
//           legitimate "nothing significant" signal that still consumes
//           the window.
//
// See .planning/phases/18.8-echo-commune-stderr-capture-no-retry-on-empty-and-refactor-t/

use std::fs::OpenOptions;
use std::io::{BufRead, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
===model used by summarizer (haiku/cheap?)===
1:// Echo commune: detached process that fires a fresh ephemeral haiku session
3:// [COMMUNE] delta marker out of the response, and delivers it to Psyche.
14://   - D-04..D-06: fresh haiku session every fire (no `--resume`), CWD
15://                 pinned to psyche_dir(). `--no-session-persistence` is
16://                 NOT set — haiku jsonl writeback under <claude_projects_root>
26://                 writes to psyche_dir/echo-input-{epoch}.jsonl. Grants
27://                 haiku Read via a second --add-dir for the Self-jsonl
53:/// `<EVENT type="echo_commune" ...>` envelope delivered to the Psyche bundle
210:///   2. Invoke `psyche_deliver(psyche_id, &payload)` — ALWAYS.
211:///   3. If `forward_to_self`, invoke `self_deliver(self_id, &from, &payload)`
218:/// Production callers pass closures that wrap `send::deliver_body_anonymous`
219:/// and `send::deliver_body` in `std::panic::catch_unwind`. Tests pass
226:/// substitute the body delivered to SELF only (the Psyche-bundle leg always
227:/// keeps the haiku's literal output). Production wires this to the on-disk
228:/// snapshot fallback so a quiet/empty delta never delivers a hollow
238:    mut psyche_deliver: P,
239:    mut self_deliver: S,
257:        // carries the haiku's literal output.
258:        psyche_deliver(psyche_id, &payload);
272:                    self_deliver(self_id, &from, &self_payload);
274:                None => self_deliver(self_id, &from, &payload),
574:/// haiku echo_commune).
662:/// `Written` (file was rewritten), `Suppressed` (LLM source declined inside
705:/// Takes a raw commune body emitted by the Psyche-haiku child (or, in Wave 4,
743:/// dispatching to Psyche/Self). The `#[allow(dead_code)]` carry-forward


Prompt:
<task-notification>
<task-id>b7rku18av</task-id>
<tool-use-id>toolu_01Efa1AQhDwSvNhxeEy1HAXR</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\b7rku18av.output</output-file>
<status>completed</status>
<summary>Background command "Confirm summarizer separate model + delivery to psyche" completed (exit code 0)</summary>
</task-notification>


Confirmed your correction in the sister source — they're distinct:

- **Summarizer** (echo_commune.rs:1-3): *"fresh ephemeral **haiku** session"* (cheap model), seeded with filtered Self-turn jsonl → emits `[COMMUNE]` delta → `psyche_deliver(...)` feeds it **into** the Psyche (line 258). Fire-and-forget.
- **Psyche** (sonnet/opus, sandboxed R/W/E): receives the commune as inbound context; on its turns emits **`[REPLY]`/`[NOTIFY]` only** — never COMMUNE.

Marker ownership clean: `[COMMUNE]` ← summarizer (→psyche input); `[REPLY]`/`[NOTIFY]` ← psyche (→wrapper-proxied output). My prior framing wrongly merged them.

Pipeline:
```
Self turns → [haiku summarizer] —[COMMUNE]→ delivered to Psyche ─┐
inbound msg/event → (wrapper composes) → Psyche stdin ───────────┤
                                                                 ▼
                                                    [Psyche sonnet] turn
                                                                 │ stdout
                                              wrapper parse_markers
                                                  ├─[REPLY] → send to agent
                                                  └─[NOTIFY]→ notify user
                                          (psyche sandboxed; wrapper = outbound proxy)
```

Proceeding with all 3 steps; step 3 via the `/grill-with-docs` regimen (CONTEXT.md/ADR), not memory. Invoking it now with the corrected model + the gap to fold in.


Skill

Subject: the Psyche I/O channel model + its missing implementation in spt-core. Grill this against CONTEXT.md's existing domain model, sharpen terminology, and crystallize into CONTEXT.md + a new ADR (and flag M4-PLAN.md D-task + a KNOWN-HAZARD/REQ-HAZARD).

Verified facts from the sister project claude_skill_owl (the clean-room source):

1. SUMMARIZER ≠ PSYCHE (distinct actors, distinct models):
   - Summarizer = ephemeral cheap (haiku) session (src/owl/echo_commune.rs:1-3), seeded with a filtered jsonl excerpt of recent Self turns; emits a [COMMUNE] delta marker; psyche_deliver() feeds that delta INTO the Psyche as inbound context (echo_commune.rs:258). Fire-and-forget detached.
   - Psyche = the companion agent (sonnet/opus), sandboxed to tools Read/Write/Edit ONLY (src/live/wrapper/claude.rs:34,75,301,507) — no Bash, no network, no send tool. It CANNOT send messages itself.

2. Psyche I/O is stdin-in / stdout-markers-out, wrapper-proxied:
   - Inbound: the wrapper composes the message/event payload (+ the summarizer's COMMUNE delta + passive context) and writes it to the Psyche's STDIN (claude.rs:316). The Psyche does not read the spool itself.
   - Outbound: the Psyche's ONLY outbound channel is its STDOUT. It emits [REPLY]...[/REPLY] and [NOTIFY]...[/NOTIFY] markers ONLY (never COMMUNE). The wrapper captures stdout (wait_with_output / extract_claude_response), parse_markers (claude.rs:592-614), and RELAYS them — the wrapper holds the send/notify capability; the Psyche does not. The Psyche perch is INBOUND ADDRESSING ONLY; the wrapper is the outbound proxy.

3. Marker ownership: [COMMUNE] <- summarizer (input to psyche); [REPLY],[NOTIFY] <- psyche (output, wrapper-relayed).

The spt-core gap to crystallize:
- spt-core's runtime.spawn_session sets stdout(Stdio::null()) on the detached psyche (crates/spt-runtime/src/runtime.rs:93-95) — discarding the Psyche's ONLY reply channel. As interim-wired, an spt-core live Psyche cannot reply at all. (echo-commune works only because it uses the bounded run_bounded_stdin which DOES capture stdout.)
- spt-core has NOT ported parse_markers — no [REPLY]/[NOTIFY] parsing exists anywhere in crates/.
- ADR-0004 already says the target is "Psyche = a loop inside the daemon, summarizer subprocess ephemeral" (CONTEXT.md:173) — which IS this model: drive the Psyche as a bounded, stdin-fed, stdout-CAPTURED invocation; parse [REPLY]/[NOTIFY]; the daemon is the outbound send/notify proxy (it holds the D8 notif capability; the sandboxed psyche never does). The detached Stdio::null() spawn is interim scaffolding that must NOT become the live-psyche driver.

Goal of this grilling: (a) sharpen the CONTEXT.md terminology so summarizer / Psyche / commune-delta / reply-marker / notify-marker / outbound-proxy are unambiguous and non-conflated; (b) decide whether this is a new ADR (psyche reply-channel architecture: sandboxed psyche + stdin-feed + stdout-marker + daemon-as-outbound-proxy + summarizer-feeds-psyche) or a CONTEXT/ADR-0004 amendment; (c) confirm the M4 D-task ("psyche reply channel: bounded stdin-fed/stdout-captured driver + parse_markers(REPLY/NOTIFY) + daemon send-proxy", D8-notif depends on it); (d) confirm a KNOWN-HAZARD + REQ-HAZARD ("spawn_session Stdio::null() discards the psyche's sole reply channel; the live-psyche driver MUST capture stdout").


Launching skill: grill-with-docs


Base directory for this skill: C:\Users\decid\.claude\skills\grill-with-docs

<what-to-do>

Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer.

Ask the questions one at a time, waiting for feedback on each question before continuing.

If a question can be answered by exploring the codebase, explore the codebase instead.

</what-to-do>

<supporting-info>

## Domain awareness

During codebase exploration, also look for existing documentation:

### File structure

Most repos have a single context:

```
/
├── CONTEXT.md
├── docs/
│   └── adr/
│       ├── 0001-event-sourced-orders.md
│       └── 0002-postgres-for-write-model.md
└── src/
```

If a `CONTEXT-MAP.md` exists at the root, the repo has multiple contexts. The map points to where each one lives:

```
/
├── CONTEXT-MAP.md
├── docs/
│   └── adr/                          ← system-wide decisions
├── src/
│   ├── ordering/
│   │   ├── CONTEXT.md
│   │   └── docs/adr/                 ← context-specific decisions
│   └── billing/
│       ├── CONTEXT.md
│       └── docs/adr/
```

Create files lazily — only when you have something to write. If no `CONTEXT.md` exists, create one when the first term is resolved. If no `docs/adr/` exists, create it when the first ADR is needed.

## During the session

### Challenge against the glossary

When the user uses a term that conflicts with the existing language in `CONTEXT.md`, call it out immediately. "Your glossary defines 'cancellation' as X, but you seem to mean Y — which is it?"

### Sharpen fuzzy language

When the user uses vague or overloaded terms, propose a precise canonical term. "You're saying 'account' — do you mean the Customer or the User? Those are different things."

### Discuss concrete scenarios

When domain relationships are being discussed, stress-test them with specific scenarios. Invent scenarios that probe edge cases and force the user to be precise about the boundaries between concepts.

### Cross-reference with code

When the user states how something works, check whether the code agrees. If you find a contradiction, surface it: "Your code cancels entire Orders, but you just said partial cancellation is possible — which is right?"

### Update CONTEXT.md inline

When a term is resolved, update `CONTEXT.md` right there. Don't batch these up — capture them as they happen. Use the format in [CONTEXT-FORMAT.md](./CONTEXT-FORMAT.md).

`CONTEXT.md` should be totally devoid of implementation details. Do not treat `CONTEXT.md` as a spec, a scratch pad, or a repository for implementation decisions. It is a glossary and nothing else.

### Offer ADRs sparingly

Only offer to create an ADR when all three are true:

1. **Hard to reverse** — the cost of changing your mind later is meaningful
2. **Surprising without context** — a future reader will wonder "why did they do it this way?"
3. **The result of a real trade-off** — there were genuine alternatives and you picked one for specific reasons

If any of the three is missing, skip the ADR. Use the format in [ADR-FORMAT.md](./ADR-FORMAT.md).

</supporting-info>

ARGUMENTS: Subject: the Psyche I/O channel model + its missing implementation in spt-core. Grill this against CONTEXT.md's existing domain model, sharpen terminology, and crystallize into CONTEXT.md + a new ADR (and flag M4-PLAN.md D-task + a KNOWN-HAZARD/REQ-HAZARD).

Verified facts from the sister project claude_skill_owl (the clean-room source):

1. SUMMARIZER ≠ PSYCHE (distinct actors, distinct models):
   - Summarizer = ephemeral cheap (haiku) session (src/owl/echo_commune.rs:1-3), seeded with a filtered jsonl excerpt of recent Self turns; emits a [COMMUNE] delta marker; psyche_deliver() feeds that delta INTO the Psyche as inbound context (echo_commune.rs:258). Fire-and-forget detached.
   - Psyche = the companion agent (sonnet/opus), sandboxed to tools Read/Write/Edit ONLY (src/live/wrapper/claude.rs:34,75,301,507) — no Bash, no network, no send tool. It CANNOT send messages itself.

2. Psyche I/O is stdin-in / stdout-markers-out, wrapper-proxied:
   - Inbound: the wrapper composes the message/event payload (+ the summarizer's COMMUNE delta + passive context) and writes it to the Psyche's STDIN (claude.rs:316). The Psyche does not read the spool itself.
   - Outbound: the Psyche's ONLY outbound channel is its STDOUT. It emits [REPLY]...[/REPLY] and [NOTIFY]...[/NOTIFY] markers ONLY (never COMMUNE). The wrapper captures stdout (wait_with_output / extract_claude_response), parse_markers (claude.rs:592-614), and RELAYS them — the wrapper holds the send/notify capability; the Psyche does not. The Psyche perch is INBOUND ADDRESSING ONLY; the wrapper is the outbound proxy.

3. Marker ownership: [COMMUNE] <- summarizer (input to psyche); [REPLY],[NOTIFY] <- psyche (output, wrapper-relayed).

The spt-core gap to crystallize:
- spt-core's runtime.spawn_session sets stdout(Stdio::null()) on the detached psyche (crates/spt-runtime/src/runtime.rs:93-95) — discarding the Psyche's ONLY reply channel. As interim-wired, an spt-core live Psyche cannot reply at all. (echo-commune works only because it uses the bounded run_bounded_stdin which DOES capture stdout.)
- spt-core has NOT ported parse_markers — no [REPLY]/[NOTIFY] parsing exists anywhere in crates/.
- ADR-0004 already says the target is "Psyche = a loop inside the daemon, summarizer subprocess ephemeral" (CONTEXT.md:173) — which IS this model: drive the Psyche as a bounded, stdin-fed, stdout-CAPTURED invocation; parse [REPLY]/[NOTIFY]; the daemon is the outbound send/notify proxy (it holds the D8 notif capability; the sandboxed psyche never does). The detached Stdio::null() spawn is interim scaffolding that must NOT become the live-psyche driver.

Goal of this grilling: (a) sharpen the CONTEXT.md terminology so summarizer / Psyche / commune-delta / reply-marker / notify-marker / outbound-proxy are unambiguous and non-conflated; (b) decide whether this is a new ADR (psyche reply-channel architecture: sandboxed psyche + stdin-feed + stdout-marker + daemon-as-outbound-proxy + summarizer-feeds-psyche) or a CONTEXT/ADR-0004 amendment; (c) confirm the M4 D-task ("psyche reply channel: bounded stdin-fed/stdout-captured driver + parse_markers(REPLY/NOTIFY) + daemon send-proxy", D8-notif depends on it); (d) confirm a KNOWN-HAZARD + REQ-HAZARD ("spawn_session Stdio::null() discards the psyche's sole reply channel; the live-psyche driver MUST capture stdout").


be careful to not conflate the echo commune summarizer and the psyche. summarizer is a lightweight model separate from psyche that builds the echo commune. its output is fed into the psyche. psyche wo…


Starting the regimen. First grounding in the existing CONTEXT.md glossary so I grill against real definitions, not assumptions.


Bash

cd "C:/Users/decid/Documents/projects/spt-core" && echo "===CONTEXT-MAP?===" && ls CONTEXT-MAP.md 2>/dev/null || echo "no map (single context)"; echo "===glossary terms present===" && grep -n -i "^### \|^## \|summari\|echo commune\|commune\|psyche\|^.*Psyche\|reply\|notify\|outbound\|marker\|wrapper" CONTEXT.md | head -60

Survey CONTEXT.md glossary terms


Command running in background with ID: bjhq3dxhq. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\bjhq3dxhq.output. You will be notified when it completes. To check interim output, use Read on that file path.


<task-notification>
<task-id>bjhq3dxhq</task-id>
<tool-use-id>toolu_01DGY5gqCUduDWmt3PzU8Bt4</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\bjhq3dxhq.output</output-file>
<status>completed</status>
<summary>Background command "Survey CONTEXT.md glossary terms" completed (exit code 0)</summary>
</task-notification>


1	===CONTEXT-MAP?===
2	no map (single context)
3	===glossary terms present===
4	5:**Legacy migration:** it should be possible — ideally *automatic* — for a user to migrate an existing `claude_skill_owl` (modern SPT) install to spt-core (identity, agents, tracked Psyche context). Exact mechanism deferred to design; the commitment is that migration is a first-class supported path, not a manual rebuild.
5	11:## Language
6	26:The single always-on, one-per-machine logical supervisor. Owns the PTYs for all hosted sessions, the node's network identity + WAN endpoint, the subnet registry, all spools, **all poll-listener logic, and all Psyche/pulse loops** — everything is consolidated here (no separate poll-listener or Psyche-wrapper processes; listeners already touch sessions directly under capsule/idle, and Psyche wrappers already invoke harness binaries directly, so they belong in the one supervisor). Collapses what the sister project planned as a separate `spt-node` daemon into one process — see Networking. The `spt-node` separate-deliverable concept is retired.
7	30:- **daemon brain** ("userspace") — all logic (routing, registry, pulse/psyche loops, manifest parsing, update orchestration). Restarts freely on update; rehydrates from disk state and re-attaches to the broker's held handles.
8	37:### Deliverable shape
9	46:## Runtime model
10	48:spt-core is harness-independent: it does not know about Claude Code, Codex, Cursor, or any other agent runtime. All harness-specific surfaces (how to invoke an agent session, fetch conversation history for an echo commune, detect activity/idleness, etc.) are abstracted behind a runtime layer that consumers supply.
11	54:The full surface a harness binds to in order to participate in the spt-core ecosystem. Has two equally-important halves: the **runtime manifest** (outbound — how spt-core drives the harness) and the **subcommand surface** (inbound — how the harness reports events back to spt-core). A harness implementation is one TOML/YAML manifest + a binding from the harness's own hook system into `spt.exe <subcommand>` calls.
12	56:**runtime manifest** (outbound half of the harness contract):
13	57:A declarative configuration file (TOML/YAML — schema TBD) that tells spt-core how to drive a specific harness. Declares: how to invoke an agent session, how to look up conversation history for an echo commune, how to spawn/resume a Psyche-equivalent, which binary or command implements each harness-side operation, and which endpoint types this harness supports. spt-core is the actor for each of these; the manifest tells it what to do.
14	75:How the harness spawns agent sessions, including Psyche and echo-commune sessions. For the rebuilt spt-plugin, Psyche and echo communes must migrate **off `claude -p`** (imminent Claude Code billing changes) to headless `claude` sessions (`--resume` for the Psyche). This is an adapter/manifest concern, not a core concern, but the parity milestone must carry it.
15	77:### Manifest seams (outbound contract, detailed)
16	81:**spawn-session seam** — launch a new agent session on this node. Manifest provides: a command template; `cwd`/project; a `headless` flag (optional, default false — for the GUI's resume-of-compatible-adapters); a `resume` flag (optional); and the `commune` + `signoff` file directories relative to `cwd` (so the daemon knows where to watch). Substitution keys spt-core can supply: `{id}` and, optionally, a spt-core-generated valid session UUID (e.g. injected as `--session-id {uuid}`) so an adapter can skip the post-spawn seam. spt-core does **not** inject: harness-internal env (broker handles binary env; adapter handles in-session env), and **no initial-context handoff** (not needed at start — the agent is prompted for context once its session is up; the first commune populates it).
17	86:**post-spawn is optional only under a strict commitment:** an adapter may forgo post-spawn *only* if it (a) injects the spt-core-generated session UUID at spawn AND (b) guarantees the launched-process pid IS the stable session-binding anchor (no wrapper-script / subprocess pid indirection). If either does not hold, post-spawn must fire to report `session_id` and/or `parent_pid`. UUID-injection alone suppresses only the `session_id` reporting, not the binding.
18	88:**spawn-psyche seam** — two command templates: fresh-start and resume (the resume template includes `$session_id`). Both include `$psyche_prompt` — the revival essentials spt-core feeds the Psyche (timestamp, incoming event envelope). Everything else is the adapter's: model selection (in its template), and any harness-specific instructions the Psyche needs (Write-tool usage, commune dir) supplied as a static preamble before `$psyche_prompt` or as adapter SessionStart additionalContext. spt-core owns `$psyche_prompt` content; the adapter owns the rest.
19	90:**history subsystem** (covers echo-commune source logs, resume briefs, and Shell logs) — two supported paths:
20	93:- The **echo-commune seam** is then just a command template (adapter picks the model) that consumes whichever history path is configured for the session.
21	106:- **fresh-with-preload:** resume with *cleared* context (a fresh session) + psyche-download. Accepts a `$psyche-context` key to launch the fresh session with the psyche-download preloaded — or the adapter instead pulls it via an spt-core command in its SessionStart hook.
22	113:There is no separate "model/billing" seam — those live inside the adapter's spawn/psyche/echo command templates. SPT never selects a model. The full manifest schema is `docs/MANIFEST.md`; key model-level facts from it:
23	118:- **Cross-adapter fallback** is a **node-wide setting**, not a manifest field: if a Psyche/echo invocation under one adapter is rate-limited, spt-core falls back to another adapter (e.g. `ccs`, now its own adapter — not a binary-swap). Adapter-agnostic IDs make this safe; an endpoint's Psyche may run under a fallback adapter temporarily.
24	119:- **Config knobs** (pulse period, echo-commune window, route-guard window, daily refresh) are spt-core **global settings** with optional **per-endpoint override** — never per-adapter.
25	122:### Inbound `api` surface (detailed)
26	129:- **`api state <busy|idle>`** — adapter reports session activity; writes the activity/idle sentinel in the session perch. By default `api state idle` also writes the **echo-commune gate sentinel** (`.more-done`-equiv — modern spt couples them); `--no-gate` suppresses that coupling, and a standalone **`api echo-gate <set|clear>`** gives granular adapters explicit control over when echo communes may fire, independent of idle.
27	132:- **`api boundary <clear|compact>`** — context-boundary report; **carries the new `session_id`** (it rotates on `/clear` or `/compact`), so the daemon rebinds the perch to the new session id while keeping the stable identity + `parent_pid` anchor. Authors a **Self-resume commune** (resume the Self session → commune file-drop) rather than a background echo — strong live-context signal at the boundary (see `docs/CONTEXT-MEMORY.md`).
28	138:**Not `api` commands — file-drop flow:** `commune` and `signoff` are deprecated as commands (modern SPT) in favor of file drops. The agent/adapter writes `<id>-commune.md` / `<id>-signoff.md`; the daemon watches the manifest-declared commune/signoff dirs (the spawn-session seam fields), ingests, and deletes (drop files are daemon-owned single-writer — KNOWN-HAZARDS 6.4). These stay off the `api` surface and the agent surface alike.
29	140:### Startup flows (the two topologies)
30	147:3. The always-on daemon holds the perch, spool, registry, and daemon-spawns the Psyche (via the spawn-psyche seam) — no separate wrapper. The relay is purely the delivery pipe.
31	152:**spt-hosted (terminal wrapper / GUI launcher; the daemon launches the binary into a broker PTY):**
32	156:4. Psyche is daemon-spawned, same as above.
33	162:### Endpoint types
34	167:Minimal SPT participant — a perch + a poll listener, no Psyche, no live-agent wrapper. Direct heir to the sister project's "ready agent".
35	170:A Self with a Psyche companion. Composite logical actor; addressable as one ID, but its component perches (the Self's, the Psyche's) live independently. Direct heir to the sister project's "live agent".
36	172:**Psyche**:
37	173:The Psyche companion's own perch, distinct from its paired LiveAgent's perch. First-class endpoint type so messages addressed to a LiveAgent's Psyche route directly without ambiguity. **Liveness is daemon-held, not PID-based.** Unlike the sister's separate wrapper *process*, the Psyche (under ADR-0004) is a **loop inside the daemon** — it has no dedicated liveness pid, and its summarizer subprocess is ephemeral. Its perch is alive iff the daemon's endpoint table says so, surfaced via a daemon-managed `status` field on `info.json` (the same pattern Shells use), **never** an `is_process_alive(info.pid)` check. The M1/M2a interim (no daemon) keeps it a real process, so per-pid liveness holds *interim*; M3 replaces it with daemon-authoritative liveness (KNOWN-HAZARDS 2.5).
38	193:### Agent endpoints vs Shells
39	197:**agent endpoints** — *host* an agent, backed by a harness. ReadyAgent, LiveAgent, Psyche, Worker. Something intelligent runs there.
40	250:**Shell-relevant commands:** agent/user surface — `spt shell spawn <shell_adapter> [--id]`, `shell list`, `shell relink <id>`, `shell teardown <id>`, `shell cmd <id> <op…>` (or ordinary `send` of a typed command payload), and **`spt shutdown`** (an agent gracefully shuts down *its own* endpoint → suspended; fires the transition echo-commune; cascades its persistent shells offline). Machinery `api` surface — `api bind` (type=Shell), `api poll` (relay delivery), `api emit --type <sensory_type> <payload>` (sensory back to the owner; owner known from bind), and **`api owner-shutdown`** (a shell directly suspends its linked owner, bypassing agent comms — gated by `can_shutdown` in the shell manifest; the channel is this machinery command, not the content channels).
41	271:## Terminal wrapper
42	273:The multiplatform terminal wrapper is the backbone for hosting agent sessions. Supersedes the sister project's planned "capsule" (which leaned on psmux). Architecturally a **process supervisor** with an abstract session surface. **The session-surface *mechanism* is delivered in M3a (`crates/spt-term`); the supervisor *process* (term-daemon) is delivered in M3b (`crates/spt-daemon`, 2026-06-03).**
43	288:A rolling, *parsed*, human-glanceable view of **recent** PTY I/O — the last ~N user turns (default ~3) plus the agent output between them, with **tool-usage sprints collapsed** to a single entry (e.g. `<endpoint_id> used: **Write** `dir/file.txt` · **Bash** `first ~25 cmd chars` · […]`). Distinct from both the **raw scrollback ring** (unparsed bytes) and **transcript history** (the full conversation behind echo communes/resume, history subsystem Paths A/B): its job is *"what is this agent doing right now, at a glance,"* not conversation replay.
44	295:## Networking
45	310:### Cross-node Psyche sync
46	318:Merge is conflict-free: a custom merge driver resolves context files by the precedence marker (`spt:source`/`routed_at`/**`node`**, newest-wins — KNOWN-HAZARDS 6.5), never a git line-merge; the single-writer invariant (active-instance-authoritative) makes true conflicts rare. The per-project branch view lets any agent synthesize broader context from *other* agents' contexts in the same project and supports query-routing ("which endpoint owns this task"). The two-tier sync maps onto the views: live context (`a-`) → all instances; project context (`p-`) → same-project instances.
47	320:### Subnet notifications
48	331:- **Producers (open set):** system events (node-paired, pairing-request, update-available, consent-needed) + agent-issued `spt notify`. New producers register later.
49	334:**`spt notify` (agent-issued subnet notif)**:
50	337:## Self-update
51	349:## Instances
52	357:At **creation**, the adapter is chosen from the node's **registered `kind="harness"` adapters whose `hostable_types` includes the endpoint's type** — **auto if exactly one qualifies, else chosen** (`--adapter` / picker): the same *auto-if-one-ask-if-many* rule as *home subnet*. The choice seeds the **head of the ordered adapter history** and the live `adapter_name`. **Changing** an endpoint's adapter is **not** a standalone operation — adapter is a *live-instance* property, so you change it by **launching/resuming the endpoint under a different registered adapter** (the resume UX surfaces the adapter history: head = default, then prior adapters, then "choose a different adapter"). For a currently-active instance this is a **resume-under-the-new-adapter** carrying context via the *fresh-with-preload* psyche-download (a session cycle, not a hot swap — the mind is adapter-agnostic). Same gate both times: the target adapter must be **registered on the node** and its `hostable_types` must include the endpoint's type.
53	362:- **Synced across instances:** the **Psyche context** (the agent's mind). See the two-tier sync below.
54	381:**Commands** (user/agent-initiated lifecycle, unprefixed): `spt suspend <id[@node]>` (suspend any of your instances from any node), `spt wake <id[@node]>` (explicitly wake a dormant/suspended instance), `spt shutdown` (an agent suspends its *own* endpoint — graceful, fires the suspend boundary signoff, cascades shells offline), `spt refresh` (spt-hosted only — an agent clears + resumes itself without stalling: `/clear` → commune capture → guaranteed post-clear resume signal so its turn restarts from immediate next-steps; see `docs/CONTEXT-MEMORY.md`).
55	383:**`spt rename <id> <new_id>`** (endpoint rename, **rippled**): change an endpoint's logical ID and propagate the change to **all its instances** subnet-wide — the registry entries, every node's perch, and the synced context branches (`a-<id>` → `a-<new_id>`, and project-view references). The rename is one logical operation against the eventually-consistent registry; it must collision-check `new_id` against every subnet the endpoint is advertised into (the join-time check above), and reconcile concurrent renames by the same precedence marker the context merge uses (node + newest-wins, KNOWN-HAZARDS 6.5). The adapter-agnostic, node-anchored identity (ADR-0003) is what makes a clean rename possible; without it the ID would be entangled with per-adapter or per-session state.
56	386:Clone an endpoint into a **new, identity-distinct endpoint** on another subnet — the sanctioned way to place an agent's mind in a different subnet (home subnet being immutable). The fork's **home = the target subnet**; it is **seeded with a one-time copy** of the source's mind (the live + project *Cross-node Psyche sync* tiers), after which the two **diverge** — there is **no ongoing sync**. A fork is therefore **not an instance**: an *instance* is the **same** identity on another node with a **synced** mind; a *fork* is a **new** identity with a **copied-then-independent** mind. The new id defaults to the source's bare name if free in the target subnet (the join-time collision check; else prompt/rename); the adapter follows the normal creation rule (*adapter selection*). The **source is untouched**, optionally deleted afterward (`--delete-source`, or a later delete). Distinct from **rename** (same identity, new label, rippled) and **instantiate-anywhere** (same identity, new *node*, consent-gated).
57	388:**transition echo commune:** any active → (dormant | suspended) transition fires an **echo commune** capturing the outgoing instance's final context delta; that commune syncs to whichever instance becomes active next. This is the finer mechanism behind catch-up-on-activation (and mirrors the sister project's "echo commune before signoff" pattern — KNOWN-HAZARDS 3.3).
58	395:- **Smooth handoff (remote-control → local):** no teardown required. `git pull` brings the files; the **fresh-with-preload resume seam** preloads the other instance's latest synced context (psyche-download); the remote instance simply goes dormant. The mind follows; the files are local.
59	397:### Pieces the Instances model requires
60	403:Whether an endpoint is exposed to a given subnet. **Hidden means *excluded*** — not advertised in that subnet's registry **and not routable from it** (addressing it from that subnet fails until explicitly revealed), not merely unlisted. This makes hiding a real boundary, ready for the cross-user (b) seam, not a silent non-boundary. Computed as: endpoint `E` is **hidden in subnet `S` iff** `S.hide_new_endpoints` (a per-subnet policy captured at the node's **join time**) **OR** `E.default_hide_from_new_subnets` (a per-endpoint setting) — **unless** an explicit per-`(E,S)` override says otherwise (the override always wins). Both defaults ship **OFF** (visible); hiding is opt-in. **Visibility gates sync:** `hidden ⟹ not synced`, and replicating a mind into `S` (the sync-membership list under *Cross-node Psyche sync*) requires `E` be visible in `S` — visibility is the outer gate, sync-membership the inner opt-in within visible subnets.
61	406:The single subnet that anchors an endpoint's *defaults*: the default **sync** scope (subnet-exclusive mind replication — *Cross-node Psyche sync*) and the subnet that qualifies its **bare name**. **Assigned at creation:** the node's **sole** subnet automatically; on a **multi-subnet** node it **must be specified** at creation (no silent guess — mirrors the *resolution policy*'s refuse-and-qualify); on an **unpaired** node the endpoint is local-only until first join, when home is set. Distinct from advertisement: identity is **node-global and advertised into *every* subnet the node belongs to** per the visibility defaults — home subnet is only the default-scope *anchor*, not a limit on where the endpoint is visible or addressable. **Home is immutable** — there is **no re-home** (ADR-0010). To place an agent's mind in a different subnet, **fork** it (see *`spt fork`*) and optionally delete the original; this avoids any scope-migration / stale-mind ambiguity (the source is untouched until explicitly deleted).
62	418:When a dormant instance is activated again, it is unobtrusively fed the latest Psyche context — newest-among-peers *and* newer than its own. Scoped by the existing Psyche-context split:
63	424:The precedence/freshness guard (KNOWN-HAZARDS 6.5) — with node identity in the marker — keeps a dormant instance's stale context from clobbering the active authoritative one.
64

===CONTEXT-MAP?===
no map (single context)
===glossary terms present===
5:**Legacy migration:** it should be possible — ideally *automatic* — for a user to migrate an existing `claude_skill_owl` (modern SPT) install to spt-core (identity, agents, tracked Psyche context). Exact mechanism deferred to design; the commitment is that migration is a first-class supported path, not a manual rebuild.
11:## Language
26:The single always-on, one-per-machine logical supervisor. Owns the PTYs for all hosted sessions, the node's network identity + WAN endpoint, the subnet registry, all spools, **all poll-listener logic, and all Psyche/pulse loops** — everything is consolidated here (no separate poll-listener or Psyche-wrapper processes; listeners already touch sessions directly under capsule/idle, and Psyche wrappers already invoke harness binaries directly, so they belong in the one supervisor). Collapses what the sister project planned as a separate `spt-node` daemon into one process — see Networking. The `spt-node` separate-deliverable concept is retired.
30:- **daemon brain** ("userspace") — all logic (routing, registry, pulse/psyche loops, manifest parsing, update orchestration). Restarts freely on update; rehydrates from disk state and re-attaches to the broker's held handles.
37:### Deliverable shape
46:## Runtime model
48:spt-core is harness-independent: it does not know about Claude Code, Codex, Cursor, or any other agent runtime. All harness-specific surfaces (how to invoke an agent session, fetch conversation history for an echo commune, detect activity/idleness, etc.) are abstracted behind a runtime layer that consumers supply.
54:The full surface a harness binds to in order to participate in the spt-core ecosystem. Has two equally-important halves: the **runtime manifest** (outbound — how spt-core drives the harness) and the **subcommand surface** (inbound — how the harness reports events back to spt-core). A harness implementation is one TOML/YAML manifest + a binding from the harness's own hook system into `spt.exe <subcommand>` calls.
56:**runtime manifest** (outbound half of the harness contract):
57:A declarative configuration file (TOML/YAML — schema TBD) that tells spt-core how to drive a specific harness. Declares: how to invoke an agent session, how to look up conversation history for an echo commune, how to spawn/resume a Psyche-equivalent, which binary or command implements each harness-side operation, and which endpoint types this harness supports. spt-core is the actor for each of these; the manifest tells it what to do.
75:How the harness spawns agent sessions, including Psyche and echo-commune sessions. For the rebuilt spt-plugin, Psyche and echo communes must migrate **off `claude -p`** (imminent Claude Code billing changes) to headless `claude` sessions (`--resume` for the Psyche). This is an adapter/manifest concern, not a core concern, but the parity milestone must carry it.
77:### Manifest seams (outbound contract, detailed)
81:**spawn-session seam** — launch a new agent session on this node. Manifest provides: a command template; `cwd`/project; a `headless` flag (optional, default false — for the GUI's resume-of-compatible-adapters); a `resume` flag (optional); and the `commune` + `signoff` file directories relative to `cwd` (so the daemon knows where to watch). Substitution keys spt-core can supply: `{id}` and, optionally, a spt-core-generated valid session UUID (e.g. injected as `--session-id {uuid}`) so an adapter can skip the post-spawn seam. spt-core does **not** inject: harness-internal env (broker handles binary env; adapter handles in-session env), and **no initial-context handoff** (not needed at start — the agent is prompted for context once its session is up; the first commune populates it).
86:**post-spawn is optional only under a strict commitment:** an adapter may forgo post-spawn *only* if it (a) injects the spt-core-generated session UUID at spawn AND (b) guarantees the launched-process pid IS the stable session-binding anchor (no wrapper-script / subprocess pid indirection). If either does not hold, post-spawn must fire to report `session_id` and/or `parent_pid`. UUID-injection alone suppresses only the `session_id` reporting, not the binding.
88:**spawn-psyche seam** — two command templates: fresh-start and resume (the resume template includes `$session_id`). Both include `$psyche_prompt` — the revival essentials spt-core feeds the Psyche (timestamp, incoming event envelope). Everything else is the adapter's: model selection (in its template), and any harness-specific instructions the Psyche needs (Write-tool usage, commune dir) supplied as a static preamble before `$psyche_prompt` or as adapter SessionStart additionalContext. spt-core owns `$psyche_prompt` content; the adapter owns the rest.
90:**history subsystem** (covers echo-commune source logs, resume briefs, and Shell logs) — two supported paths:
93:- The **echo-commune seam** is then just a command template (adapter picks the model) that consumes whichever history path is configured for the session.
106:- **fresh-with-preload:** resume with *cleared* context (a fresh session) + psyche-download. Accepts a `$psyche-context` key to launch the fresh session with the psyche-download preloaded — or the adapter instead pulls it via an spt-core command in its SessionStart hook.
113:There is no separate "model/billing" seam — those live inside the adapter's spawn/psyche/echo command templates. SPT never selects a model. The full manifest schema is `docs/MANIFEST.md`; key model-level facts from it:
118:- **Cross-adapter fallback** is a **node-wide setting**, not a manifest field: if a Psyche/echo invocation under one adapter is rate-limited, spt-core falls back to another adapter (e.g. `ccs`, now its own adapter — not a binary-swap). Adapter-agnostic IDs make this safe; an endpoint's Psyche may run under a fallback adapter temporarily.
119:- **Config knobs** (pulse period, echo-commune window, route-guard window, daily refresh) are spt-core **global settings** with optional **per-endpoint override** — never per-adapter.
122:### Inbound `api` surface (detailed)
129:- **`api state <busy|idle>`** — adapter reports session activity; writes the activity/idle sentinel in the session perch. By default `api state idle` also writes the **echo-commune gate sentinel** (`.more-done`-equiv — modern spt couples them); `--no-gate` suppresses that coupling, and a standalone **`api echo-gate <set|clear>`** gives granular adapters explicit control over when echo communes may fire, independent of idle.
132:- **`api boundary <clear|compact>`** — context-boundary report; **carries the new `session_id`** (it rotates on `/clear` or `/compact`), so the daemon rebinds the perch to the new session id while keeping the stable identity + `parent_pid` anchor. Authors a **Self-resume commune** (resume the Self session → commune file-drop) rather than a background echo — strong live-context signal at the boundary (see `docs/CONTEXT-MEMORY.md`).
138:**Not `api` commands — file-drop flow:** `commune` and `signoff` are deprecated as commands (modern SPT) in favor of file drops. The agent/adapter writes `<id>-commune.md` / `<id>-signoff.md`; the daemon watches the manifest-declared commune/signoff dirs (the spawn-session seam fields), ingests, and deletes (drop files are daemon-owned single-writer — KNOWN-HAZARDS 6.4). These stay off the `api` surface and the agent surface alike.
140:### Startup flows (the two topologies)
147:3. The always-on daemon holds the perch, spool, registry, and daemon-spawns the Psyche (via the spawn-psyche seam) — no separate wrapper. The relay is purely the delivery pipe.
152:**spt-hosted (terminal wrapper / GUI launcher; the daemon launches the binary into a broker PTY):**
156:4. Psyche is daemon-spawned, same as above.
162:### Endpoint types
167:Minimal SPT participant — a perch + a poll listener, no Psyche, no live-agent wrapper. Direct heir to the sister project's "ready agent".
170:A Self with a Psyche companion. Composite logical actor; addressable as one ID, but its component perches (the Self's, the Psyche's) live independently. Direct heir to the sister project's "live agent".
172:**Psyche**:
173:The Psyche companion's own perch, distinct from its paired LiveAgent's perch. First-class endpoint type so messages addressed to a LiveAgent's Psyche route directly without ambiguity. **Liveness is daemon-held, not PID-based.** Unlike the sister's separate wrapper *process*, the Psyche (under ADR-0004) is a **loop inside the daemon** — it has no dedicated liveness pid, and its summarizer subprocess is ephemeral. Its perch is alive iff the daemon's endpoint table says so, surfaced via a daemon-managed `status` field on `info.json` (the same pattern Shells use), **never** an `is_process_alive(info.pid)` check. The M1/M2a interim (no daemon) keeps it a real process, so per-pid liveness holds *interim*; M3 replaces it with daemon-authoritative liveness (KNOWN-HAZARDS 2.5).
193:### Agent endpoints vs Shells
197:**agent endpoints** — *host* an agent, backed by a harness. ReadyAgent, LiveAgent, Psyche, Worker. Something intelligent runs there.
250:**Shell-relevant commands:** agent/user surface — `spt shell spawn <shell_adapter> [--id]`, `shell list`, `shell relink <id>`, `shell teardown <id>`, `shell cmd <id> <op…>` (or ordinary `send` of a typed command payload), and **`spt shutdown`** (an agent gracefully shuts down *its own* endpoint → suspended; fires the transition echo-commune; cascades its persistent shells offline). Machinery `api` surface — `api bind` (type=Shell), `api poll` (relay delivery), `api emit --type <sensory_type> <payload>` (sensory back to the owner; owner known from bind), and **`api owner-shutdown`** (a shell directly suspends its linked owner, bypassing agent comms — gated by `can_shutdown` in the shell manifest; the channel is this machinery command, not the content channels).
271:## Terminal wrapper
273:The multiplatform terminal wrapper is the backbone for hosting agent sessions. Supersedes the sister project's planned "capsule" (which leaned on psmux). Architecturally a **process supervisor** with an abstract session surface. **The session-surface *mechanism* is delivered in M3a (`crates/spt-term`); the supervisor *process* (term-daemon) is delivered in M3b (`crates/spt-daemon`, 2026-06-03).**
288:A rolling, *parsed*, human-glanceable view of **recent** PTY I/O — the last ~N user turns (default ~3) plus the agent output between them, with **tool-usage sprints collapsed** to a single entry (e.g. `<endpoint_id> used: **Write** `dir/file.txt` · **Bash** `first ~25 cmd chars` · […]`). Distinct from both the **raw scrollback ring** (unparsed bytes) and **transcript history** (the full conversation behind echo communes/resume, history subsystem Paths A/B): its job is *"what is this agent doing right now, at a glance,"* not conversation replay.
295:## Networking
310:### Cross-node Psyche sync
318:Merge is conflict-free: a custom merge driver resolves context files by the precedence marker (`spt:source`/`routed_at`/**`node`**, newest-wins — KNOWN-HAZARDS 6.5), never a git line-merge; the single-writer invariant (active-instance-authoritative) makes true conflicts rare. The per-project branch view lets any agent synthesize broader context from *other* agents' contexts in the same project and supports query-routing ("which endpoint owns this task"). The two-tier sync maps onto the views: live context (`a-`) → all instances; project context (`p-`) → same-project instances.
320:### Subnet notifications
331:- **Producers (open set):** system events (node-paired, pairing-request, update-available, consent-needed) + agent-issued `spt notify`. New producers register later.
334:**`spt notify` (agent-issued subnet notif)**:
337:## Self-update
349:## Instances
357:At **creation**, the adapter is chosen from the node's **registered `kind="harness"` adapters whose `hostable_types` includes the endpoint's type** — **auto if exactly one qualifies, else chosen** (`--adapter` / picker): the same *auto-if-one-ask-if-many* rule as *home subnet*. The choice seeds the **head of the ordered adapter history** and the live `adapter_name`. **Changing** an endpoint's adapter is **not** a standalone operation — adapter is a *live-instance* property, so you change it by **launching/resuming the endpoint under a different registered adapter** (the resume UX surfaces the adapter history: head = default, then prior adapters, then "choose a different adapter"). For a currently-active instance this is a **resume-under-the-new-adapter** carrying context via the *fresh-with-preload* psyche-download (a session cycle, not a hot swap — the mind is adapter-agnostic). Same gate both times: the target adapter must be **registered on the node** and its `hostable_types` must include the endpoint's type.
362:- **Synced across instances:** the **Psyche context** (the agent's mind). See the two-tier sync below.
381:**Commands** (user/agent-initiated lifecycle, unprefixed): `spt suspend <id[@node]>` (suspend any of your instances from any node), `spt wake <id[@node]>` (explicitly wake a dormant/suspended instance), `spt shutdown` (an agent suspends its *own* endpoint — graceful, fires the suspend boundary signoff, cascades shells offline), `spt refresh` (spt-hosted only — an agent clears + resumes itself without stalling: `/clear` → commune capture → guaranteed post-clear resume signal so its turn restarts from immediate next-steps; see `docs/CONTEXT-MEMORY.md`).
383:**`spt rename <id> <new_id>`** (endpoint rename, **rippled**): change an endpoint's logical ID and propagate the change to **all its instances** subnet-wide — the registry entries, every node's perch, and the synced context branches (`a-<id>` → `a-<new_id>`, and project-view references). The rename is one logical operation against the eventually-consistent registry; it must collision-check `new_id` against every subnet the endpoint is advertised into (the join-time check above), and reconcile concurrent renames by the same precedence marker the context merge uses (node + newest-wins, KNOWN-HAZARDS 6.5). The adapter-agnostic, node-anchored identity (ADR-0003) is what makes a clean rename possible; without it the ID would be entangled with per-adapter or per-session state.
386:Clone an endpoint into a **new, identity-distinct endpoint** on another subnet — the sanctioned way to place an agent's mind in a different subnet (home subnet being immutable). The fork's **home = the target subnet**; it is **seeded with a one-time copy** of the source's mind (the live + project *Cross-node Psyche sync* tiers), after which the two **diverge** — there is **no ongoing sync**. A fork is therefore **not an instance**: an *instance* is the **same** identity on another node with a **synced** mind; a *fork* is a **new** identity with a **copied-then-independent** mind. The new id defaults to the source's bare name if free in the target subnet (the join-time collision check; else prompt/rename); the adapter follows the normal creation rule (*adapter selection*). The **source is untouched**, optionally deleted afterward (`--delete-source`, or a later delete). Distinct from **rename** (same identity, new label, rippled) and **instantiate-anywhere** (same identity, new *node*, consent-gated).
388:**transition echo commune:** any active → (dormant | suspended) transition fires an **echo commune** capturing the outgoing instance's final context delta; that commune syncs to whichever instance becomes active next. This is the finer mechanism behind catch-up-on-activation (and mirrors the sister project's "echo commune before signoff" pattern — KNOWN-HAZARDS 3.3).
395:- **Smooth handoff (remote-control → local):** no teardown required. `git pull` brings the files; the **fresh-with-preload resume seam** preloads the other instance's latest synced context (psyche-download); the remote instance simply goes dormant. The mind follows; the files are local.
397:### Pieces the Instances model requires
403:Whether an endpoint is exposed to a given subnet. **Hidden means *excluded*** — not advertised in that subnet's registry **and not routable from it** (addressing it from that subnet fails until explicitly revealed), not merely unlisted. This makes hiding a real boundary, ready for the cross-user (b) seam, not a silent non-boundary. Computed as: endpoint `E` is **hidden in subnet `S` iff** `S.hide_new_endpoints` (a per-subnet policy captured at the node's **join time**) **OR** `E.default_hide_from_new_subnets` (a per-endpoint setting) — **unless** an explicit per-`(E,S)` override says otherwise (the override always wins). Both defaults ship **OFF** (visible); hiding is opt-in. **Visibility gates sync:** `hidden ⟹ not synced`, and replicating a mind into `S` (the sync-membership list under *Cross-node Psyche sync*) requires `E` be visible in `S` — visibility is the outer gate, sync-membership the inner opt-in within visible subnets.
406:The single subnet that anchors an endpoint's *defaults*: the default **sync** scope (subnet-exclusive mind replication — *Cross-node Psyche sync*) and the subnet that qualifies its **bare name**. **Assigned at creation:** the node's **sole** subnet automatically; on a **multi-subnet** node it **must be specified** at creation (no silent guess — mirrors the *resolution policy*'s refuse-and-qualify); on an **unpaired** node the endpoint is local-only until first join, when home is set. Distinct from advertisement: identity is **node-global and advertised into *every* subnet the node belongs to** per the visibility defaults — home subnet is only the default-scope *anchor*, not a limit on where the endpoint is visible or addressable. **Home is immutable** — there is **no re-home** (ADR-0010). To place an agent's mind in a different subnet, **fork** it (see *`spt fork`*) and optionally delete the original; this avoids any scope-migration / stale-mind ambiguity (the source is untouched until explicitly deleted).
418:When a dormant instance is activated again, it is unobtrusively fed the latest Psyche context — newest-among-peers *and* newer than its own. Scoped by the existing Psyche-context split:
424:The precedence/freshness guard (KNOWN-HAZARDS 6.5) — with node identity in the marker — keeps a dormant instance's stale context from clobbering the active authoritative one.


Prompt:
<task-notification>
<task-id>bjhq3dxhq</task-id>
<tool-use-id>toolu_01DGY5gqCUduDWmt3PzU8Bt4</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\bjhq3dxhq.output</output-file>
<status>completed</status>
<summary>Background command "Survey CONTEXT.md glossary terms" completed (exit code 0)</summary>
</task-notification>


Grounding done — and CONTEXT.md surfaces a tension that reframes everything. spt-core already **deviated from the sister**: it replaced the stdout-marker output with **file-drops** for commune/signoff (CONTEXT.md:138 — *"commune and signoff are deprecated as commands in favor of file drops; the agent/adapter writes `<id>-commune.md`; the daemon watches… ingests, and deletes"*), and the spawn-psyche seam hands the adapter *"Write-tool usage, commune dir"* (line 88). So spt-core's psyche-outbound is already file-based, not stdout.

That means my framing ("must capture stdout") may be wrong for spt-core. This is the pivotal fork — grilling it first, since the ADR/D-task/hazard all flow from it.


AskUserQuestion

Question: The sister relays psyche [REPLY]/[NOTIFY] via stdout markers (wrapper captures stdout, parse_markers, relays). But spt-core ALREADY deviated: commune/signoff are file-drops the psyche writes with its Write tool, daemon-watched + ingested (CONTEXT.md:138). For spt-core's psyche reply/notify outbound, which channel?

Choices:
Psyche writes <id>-reply.md / <id>-notify.md to watched drop dirs (Write tool); daemon watches + relays (REPLY→message send, NOTIFY→D8 notif). Consistent with commune/signoff. Stdio::null() stays correct — NO stdout capture. Hazard reframes to 'missing reply/notify drop channel', not 'discarded stdout'.
psyche (Read/Write/Edit, sandboxed)
  writes:
   <id>-commune.md   (already)
   <id>-reply.md     (NEW)  --> daemon relays as message (__REPLY_TO__)
   <id>-notify.md    (NEW)  --> daemon relays as D8 notif
daemon = single-writer ingest + outbound proxy
stdout = Stdio::null()  (fine; not a channel)
Drive psyche bounded with captured stdout; port parse_markers([REPLY]/[NOTIFY]); daemon relays. Matches sister verbatim but requires changing the psyche driver off Stdio::null() and re-introduces stdout as a channel — inconsistent with spt-core's file-drop deviation.
psyche turn --> stdout:
  [REPLY]...[/REPLY]
  [NOTIFY]...[/NOTIFY]
daemon captures stdout, parse_markers, relays
requires: bounded stdin-fed/stdout-captured driver
         (NOT Stdio::null)
Commune/signoff stay file-drops; reply/notify via stdout markers (they're conversational turn outputs, not context snapshots). Two outbound mechanisms for one actor.
commune/signoff --> file-drop (today)
reply/notify     --> stdout markers
(psyche has TWO outbound channels)


Your questions have been answered: "The sister relays psyche [REPLY]/[NOTIFY] via stdout markers (wrapper captures stdout, parse_markers, relays). But spt-core ALREADY deviated: commune/signoff are file-drops the psyche writes with its Write tool, daemon-watched + ingested (CONTEXT.md:138). For spt-core's psyche reply/notify outbound, which channel?"=(no option selected) notes: you are conflating two unrelated things. file drop is how commune and signoff travel from Self-->wrapper/daemon-->Psyche. Psyche's sole outbound messaging system remains the stdout markers. i just don't think it was covered in previous grill sessions.. You can now continue with these answers in mind.

Question: The sister relays psyche [REPLY]/[NOTIFY] via stdout markers (wrapper captures stdout, parse_markers, relays). But spt-core ALREADY deviated: commune/signoff are file-drops the psyche writes with its Write tool, daemon-watched + ingested (CONTEXT.md:138). For spt-core's psyche reply/notify outbound, which channel?

Choices:
Psyche writes <id>-reply.md / <id>-notify.md to watched drop dirs (Write tool); daemon watches + relays (REPLY→message send, NOTIFY→D8 notif). Consistent with commune/signoff. Stdio::null() stays correct — NO stdout capture. Hazard reframes to 'missing reply/notify drop channel', not 'discarded stdout'.
psyche (Read/Write/Edit, sandboxed)
  writes:
   <id>-commune.md   (already)
   <id>-reply.md     (NEW)  --> daemon relays as message (__REPLY_TO__)
   <id>-notify.md    (NEW)  --> daemon relays as D8 notif
daemon = single-writer ingest + outbound proxy
stdout = Stdio::null()  (fine; not a channel)
Drive psyche bounded with captured stdout; port parse_markers([REPLY]/[NOTIFY]); daemon relays. Matches sister verbatim but requires changing the psyche driver off Stdio::null() and re-introduces stdout as a channel — inconsistent with spt-core's file-drop deviation.
psyche turn --> stdout:
  [REPLY]...[/REPLY]
  [NOTIFY]...[/NOTIFY]
daemon captures stdout, parse_markers, relays
requires: bounded stdin-fed/stdout-captured driver
         (NOT Stdio::null)
Commune/signoff stay file-drops; reply/notify via stdout markers (they're conversational turn outputs, not context snapshots). Two outbound mechanisms for one actor.
commune/signoff --> file-drop (today)
reply/notify     --> stdout markers
(psyche has TWO outbound channels)


Got it — I had the flows crossed. Correcting my model:

- **File-drop** (`<id>-commune.md` / `<id>-signoff.md`) = **inbound** plumbing: how commune/signoff travel **Self → wrapper/daemon → Psyche**. It's context *delivered to* the Psyche, not its output.
- **Psyche's sole outbound = stdout markers** (`[REPLY]`/`[NOTIFY]`), daemon-parsed + relayed. Unchanged in spt-core; just never grilled/documented before.

So my original gap stands (not the file-drop detour): `Stdio::null()` discards the psyche's only outbound, and spt-core has no `parse_markers`. The summarizer's `[COMMUNE]` is a *separate, inbound* thing (Self-side → drop → Psyche).

Continuing the grill on the real open decision — where this crystallizes.


Question: Where should the psyche-outbound model crystallize? It meets the ADR bar (hard-to-reverse trust boundary; surprising — 'why can't the psyche just send?'; a real trade-off — sandbox the psyche to Read/Write/Edit for safety vs. grant it send/network tools). ADR-0004 already owns broker/brain + 'psyche = daemon loop'.

Choices:
New ADR-00XX 'Psyche trust boundary + stdout-marker outbound': psyche sandboxed to R/W/E, sole outbound = parsed [REPLY]/[NOTIFY] stdout markers, daemon = outbound send/notify proxy. Cross-ref ADR-0004. Plus CONTEXT.md glossary terms (summarizer, commune-delta, reply-marker, notify-marker, outbound-proxy). Distinct concern from ADR-0004's broker/brain/update scope.
Fold the outbound-channel + sandbox model into ADR-0004 (since it owns the psyche-as-daemon-loop) as an amendment, plus the CONTEXT.md glossary terms. One ADR, no new file.
Just sharpen CONTEXT.md glossary (psyche I/O channels + summarizer distinction); no ADR — treat it as documenting an inherited fact, not a decision.


Your questions have been answered: "Where should the psyche-outbound model crystallize? It meets the ADR bar (hard-to-reverse trust boundary; surprising — 'why can't the psyche just send?'; a real trade-off — sandbox the psyche to Read/Write/Edit for safety vs. grant it send/network tools). ADR-0004 already owns broker/brain + 'psyche = daemon loop'."="New ADR + CONTEXT terms (Rec)". You can now continue with these answers in mind.

Question: Where should the psyche-outbound model crystallize? It meets the ADR bar (hard-to-reverse trust boundary; surprising — 'why can't the psyche just send?'; a real trade-off — sandbox the psyche to Read/Write/Edit for safety vs. grant it send/network tools). ADR-0004 already owns broker/brain + 'psyche = daemon loop'.

Choices:
New ADR-00XX 'Psyche trust boundary + stdout-marker outbound': psyche sandboxed to R/W/E, sole outbound = parsed [REPLY]/[NOTIFY] stdout markers, daemon = outbound send/notify proxy. Cross-ref ADR-0004. Plus CONTEXT.md glossary terms (summarizer, commune-delta, reply-marker, notify-marker, outbound-proxy). Distinct concern from ADR-0004's broker/brain/update scope.
Fold the outbound-channel + sandbox model into ADR-0004 (since it owns the psyche-as-daemon-loop) as an amendment, plus the CONTEXT.md glossary terms. One ADR, no new file.
Just sharpen CONTEXT.md glossary (psyche I/O channels + summarizer distinction); no ADR — treat it as documenting an inherited fact, not a decision.


Two more genuine decisions for the ADR's core, then I'll write everything.

The sister's `parse_markers` returns `(type, content)` with **no target field** — `[REPLY]` body routes to the *inbound sender* (`__REPLY_TO__`), `[NOTIFY]` to the *user*. So the psyche can't address arbitrary endpoints; the daemon proxy constrains it. That's the security payoff of sandbox+proxy, and it's the ADR's load-bearing "consequence."


Question: What does the daemon (outbound proxy) let the psyche's markers do? This is the ADR's trust-boundary core — the reason the psyche is sandboxed + proxied rather than given send tools.

Choices:
[REPLY] routes ONLY to the current inbound message's sender (__REPLY_TO__) — psyche carries no target, can't address arbitrary endpoints. [NOTIFY] targets only the agent's own user/subnet(s). Daemon validates marker body (size cap, strips/escapes envelope-framing tokens). Mirrors sister; psyche cannot spoof/exfil to arbitrary peers.
Same constraints, but allow [REPLY to=<id>] to name a target the daemon authorizes against the agent's reachable set (not just the inbound sender). More flexible, larger trust surface — daemon must authz every target.
Daemon relays whatever the psyche emits, minimal validation. Simplest; weakest boundary (defeats the point of sandboxing the psyche).

Question: Where does the psyche-outbound relay work land in M4, and how is the live-psyche turn driven?

Choices:
New task: bounded stdin-fed/stdout-CAPTURED psyche-turn driver (--resume per turn, like sister) + parse_markers([REPLY]/[NOTIFY]) + daemon relay (REPLY→__REPLY_TO__ send; NOTIFY→D8 notif). D8-notif depends on the [NOTIFY] half. Flag that the detached Stdio::null spawn_session is interim, NOT the turn driver.
Only the [NOTIFY] relay + captured-stdout driver land in M4 (needed by D8); the [REPLY]→message half defers to M5 live-agent work. Splits the channel across milestones.
Treat the entire psyche-outbound channel as M5 live-agent scope; M4 only documents it (ADR + CONTEXT + hazard) without building. D8 notif uses a temporary direct path.


Your questions have been answered: "What does the daemon (outbound proxy) let the psyche's markers do? This is the ADR's trust-boundary core — the reason the psyche is sandboxed + proxied rather than given send tools."="option (1). while we're on the subject, let's refine the formatting to be more consistent with other SPT messaging envelopes. psyche should write these XML-style, i.e. <reply> or <notify>. or feel free to challenge with other ideas regarding shape and consistency.", "Where does the psyche-outbound relay work land in M4, and how is the live-psyche turn driven?"="Prereq feeding D8 (Rec)". You can now continue with these answers in mind.

Question: What does the daemon (outbound proxy) let the psyche's markers do? This is the ADR's trust-boundary core — the reason the psyche is sandboxed + proxied rather than given send tools.

Choices:
[REPLY] routes ONLY to the current inbound message's sender (__REPLY_TO__) — psyche carries no target, can't address arbitrary endpoints. [NOTIFY] targets only the agent's own user/subnet(s). Daemon validates marker body (size cap, strips/escapes envelope-framing tokens). Mirrors sister; psyche cannot spoof/exfil to arbitrary peers.
Same constraints, but allow [REPLY to=<id>] to name a target the daemon authorizes against the agent's reachable set (not just the inbound sender). More flexible, larger trust surface — daemon must authz every target.
Daemon relays whatever the psyche emits, minimal validation. Simplest; weakest boundary (defeats the point of sandboxing the psyche).

Question: Where does the psyche-outbound relay work land in M4, and how is the live-psyche turn driven?

Choices:
New task: bounded stdin-fed/stdout-CAPTURED psyche-turn driver (--resume per turn, like sister) + parse_markers([REPLY]/[NOTIFY]) + daemon relay (REPLY→__REPLY_TO__ send; NOTIFY→D8 notif). D8-notif depends on the [NOTIFY] half. Flag that the detached Stdio::null spawn_session is interim, NOT the turn driver.
Only the [NOTIFY] relay + captured-stdout driver land in M4 (needed by D8); the [REPLY]→message half defers to M5 live-agent work. Splits the channel across milestones.
Treat the entire psyche-outbound channel as M5 live-agent scope; M4 only documents it (ADR + CONTEXT + hazard) without building. D8 notif uses a temporary direct path.

option (1). while we're on the subject, let's refine the formatting to be more consistent with other SPT messaging envelopes. psyche should write these XML-style, i.e. <reply> or <notify>. or feel free to challenge with other ideas regarding shape and consistency.


<task-notification>
<task-id>bx849vuem</task-id>
<tool-use-id>toolu_01FqAR7NomjBWWZYnNnfVQ2V</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\bx849vuem.output</output-file>
<status>completed</status>
<summary>Background command "SPT envelope + EVENT framing + reply routing" completed (exit code 0)</summary>
</task-notification>


Decisions locked: constrained routing (option 1), prereq-feeding-D8, and XML-style markers. Before I propose the exact tag shape, grounding in the actual SPT envelope conventions so `<reply>`/`<notify>` match them.


Bash

cd "C:/Users/decid/Documents/projects" && echo "===spt-core envelope/event shape===" && grep -n "pub struct\|pub enum\|KIND_\|<EVENT\|from\|type\|routed_at\|__REPLY_TO__\|reply" spt-core/crates/spt-proto/src/envelope.rs spt-core/crates/spt-proto/src/event.rs | head -30; echo "===LLM-facing EVENT framing (sister compose)===" && grep -rn "<EVENT\|</EVENT\|<reply\|<notify\|compose_llm_prompt\|format!(\"<" claude_skill_owl/src/live/wrapper/mod.rs claude_skill_owl/src/common/envelope.rs 2>/dev/null | head -20; echo "===spt-core reply routing===" && grep -rn "__REPLY_TO__\|reply_to\|REPLY" spt-core/crates/spt-msg/src/*.rs spt-core/crates/spt-proto/src/*.rs | head

SPT envelope + EVENT framing + reply routing


Command running in background with ID: bx849vuem. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\bx849vuem.output. You will be notified when it completes. To check interim output, use Read on that file path.


Prompt:
<task-notification>
<task-id>bx849vuem</task-id>
<tool-use-id>toolu_01FqAR7NomjBWWZYnNnfVQ2V</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\bx849vuem.output</output-file>
<status>completed</status>
<summary>Background command "SPT envelope + EVENT framing + reply routing" completed (exit code 0)</summary>
</task-notification>


1	===spt-core envelope/event shape===
2	spt-core/crates/spt-proto/src/envelope.rs:2://! Psyche-context parser. **Copied verbatim** from `claude_skill_owl`
3	spt-core/crates/spt-proto/src/envelope.rs:14://! `<EVENT>` and become structurally significant. (KNOWN-HAZARDS 4.1.)
4	spt-core/crates/spt-proto/src/envelope.rs:35:/// `&lt;EVENT&gt;` would double-decode into `<EVENT>`.
5	spt-core/crates/spt-proto/src/envelope.rs:65:/// Extract the `from="..."` attribute from an `<EVENT ... from="ATTR" ...>`
6	spt-core/crates/spt-proto/src/envelope.rs:67:/// `<EVENT ` tag, no `from=` attribute, empty `from=""`, or no closing quote.
7	spt-core/crates/spt-proto/src/envelope.rs:69:pub fn parse_event_from_attr(msg: &str) -> Option<String> {
8	spt-core/crates/spt-proto/src/envelope.rs:70:    let event_start = msg.find("<EVENT ")?;
9	spt-core/crates/spt-proto/src/envelope.rs:72:    let from_marker = "from=\"";
10	spt-core/crates/spt-proto/src/envelope.rs:73:    let from_pos = after_event.find(from_marker)?;
11	spt-core/crates/spt-proto/src/envelope.rs:74:    let value_start = from_pos + from_marker.len();
12	spt-core/crates/spt-proto/src/envelope.rs:98:pub struct TwoSlicePayload {
13	spt-core/crates/spt-proto/src/envelope.rs:146:        // User prose containing a literal entity reference for `<EVENT>`.
14	spt-core/crates/spt-proto/src/envelope.rs:148:        // live `<EVENT>` framing token.
15	spt-core/crates/spt-proto/src/envelope.rs:170:    fn from_attr_unescape_is_amp_last() {
16	spt-core/crates/spt-proto/src/envelope.rs:171:        let msg = "<EVENT from=\"&lt;weird&gt;\" type=\"msg\">body</EVENT>";
17	spt-core/crates/spt-proto/src/envelope.rs:172:        assert_eq!(parse_event_from_attr(msg).as_deref(), Some("<weird>"));
18	spt-core/crates/spt-proto/src/envelope.rs:176:    fn from_attr_returns_none_on_garbage_or_empty() {
19	spt-core/crates/spt-proto/src/envelope.rs:177:        assert_eq!(parse_event_from_attr("no event here"), None);
20	spt-core/crates/spt-proto/src/envelope.rs:178:        assert_eq!(parse_event_from_attr("<EVENT from=\"\">x</EVENT>"), None);
21	spt-core/crates/spt-proto/src/envelope.rs:179:        assert_eq!(parse_event_from_attr("<EVENT from=\"unterminated"), None);
22	spt-core/crates/spt-proto/src/event.rs:1://! EVENT envelope grammar: compose and parse the `<EVENT attrs>body</EVENT>`
23	spt-core/crates/spt-proto/src/event.rs:2://! wire line. The **format** is copied verbatim from `claude_skill_owl`
24	spt-core/crates/spt-proto/src/event.rs:4://! `body_is_typed_event_envelope`) per ADR-0001; this is the stable, public
25	spt-core/crates/spt-proto/src/event.rs:11://! - regular message → `<EVENT type="msg" from="...">body</EVENT>`
26	spt-core/crates/spt-proto/src/event.rs:12://! - self-originated alarm → `<EVENT type="alarm" target-time="..." current-time="...">body</EVENT>`
27	spt-core/crates/spt-proto/src/event.rs:13://! - pre-formed typed envelope (e.g. `echo_commune`, `file_drop`) → emitted verbatim
28	spt-core/crates/spt-proto/src/event.rs:29:/// Compose a regular message envelope: `<EVENT type="msg" from="F">B</EVENT>`.
29	spt-core/crates/spt-proto/src/event.rs:30:/// `from` is attr-escaped; `body` is body-escaped (`\n`→`<br>`). Mirrors the
30	spt-core/crates/spt-proto/src/event.rs:33:pub fn compose_msg_event(from: &str, body: &str) -> String {
31	spt-core/crates/spt-proto/src/event.rs:35:        "<EVENT type=\"msg\" from=\"{}\">{}</EVENT>",
32	===LLM-facing EVENT framing (sister compose)===
33	claude_skill_owl/src/live/wrapper/mod.rs:41:/// at the receiver side (`<EVENT type="msg" from="...">...</EVENT>`); the
34	claude_skill_owl/src/live/wrapper/mod.rs:124:/// shape `<EVENT type="init_signoff"` (case-insensitive on the attribute
35	claude_skill_owl/src/live/wrapper/mod.rs:142:/// `<EVENT type="init_signoff" timestamp="...">...</EVENT>` — because
36	claude_skill_owl/src/live/wrapper/mod.rs:257://   <EVENT type="file_drop" kind="commune|signoff" path="<forward-slash-abs>"
37	claude_skill_owl/src/live/wrapper/mod.rs:258://          from="<self_id>"></EVENT>
38	claude_skill_owl/src/live/wrapper/mod.rs:264://   compose_commune_payload         — user-facing <EVENT type="commune">{body}</EVENT>
39	claude_skill_owl/src/live/wrapper/mod.rs:271:/// Phase 30 Plan 02 Task 2 — pure parser for the `<EVENT type="file_drop">`
40	claude_skill_owl/src/live/wrapper/mod.rs:276:///   `<EVENT type="file_drop" kind="commune|signoff" path="<abs>" from="<id>"></EVENT>`
41	claude_skill_owl/src/live/wrapper/mod.rs:279:/// - `<EVENT type="file_drop"` is present case-insensitively,
42	claude_skill_owl/src/live/wrapper/mod.rs:305:    // Slice up to the closing `</EVENT>` (or `</event>`). Match the lowercased
43	claude_skill_owl/src/live/wrapper/mod.rs:420:/// to the full prefix), and the close-tag `</EVENT>` is part of the wire.
44	claude_skill_owl/src/live/wrapper/mod.rs:463:/// `<EVENT type="commune" timestamp="..." {stamp_attrs}>{body}</EVENT>`
45	claude_skill_owl/src/live/wrapper/mod.rs:480:        "<EVENT type=\"commune\" timestamp=\"{}\"{}>{}</EVENT>",
46	claude_skill_owl/src/live/wrapper/mod.rs:833:/// The LLM stdin contains NO `<EVENT>` framing tokens at the prompt layer —
47	claude_skill_owl/src/live/wrapper/mod.rs:834:/// user body content containing literal `</EVENT>` becomes plain prose
48	claude_skill_owl/src/live/wrapper/mod.rs:835:/// because there is no upstream `<EVENT>` opener in the prompt for it to
49	claude_skill_owl/src/live/wrapper/mod.rs:863:pub(crate) fn compose_llm_prompt_from_envelope(envelope: &str) -> String {
50	claude_skill_owl/src/live/wrapper/mod.rs:866:    if !trimmed.starts_with("<EVENT") {
51	claude_skill_owl/src/live/wrapper/mod.rs:881:    // Step 2: find the LAST `</EVENT>` (framing closer).
52	claude_skill_owl/src/live/wrapper/mod.rs:882:    let close_idx = match trimmed.rfind("</EVENT>") {
53	===spt-core reply routing===
54	spt-core/crates/spt-msg/src/deliver.rs:89:/// message took. `from` is written into the `__REPLY_TO__` line (empty =
55	spt-core/crates/spt-msg/src/deliver.rs:132:/// `__REPLY_TO__` line the original message carried names the target, and the
56	spt-core/crates/spt-msg/src/deliver.rs:210:        assert_eq!(got.as_deref(), Some("__REPLY_TO__:sender\nlive msg\n"));
57	spt-core/crates/spt-msg/src/lib.rs:7://! Per ADR-0001 the **stable wire format** (length-prefixed `__REPLY_TO__`
58	spt-core/crates/spt-msg/src/listener.rs:230:        assert_eq!(got.as_deref(), Some("__REPLY_TO__:sender\nping\n"));
59	spt-core/crates/spt-msg/src/ready.rs:221:        assert_eq!(got.as_deref(), Some("__REPLY_TO__:peer\nhello live\n"));
60	spt-core/crates/spt-msg/src/ring.rs:27:const REPLY_POLL: Duration = Duration::from_millis(50);
61	spt-core/crates/spt-msg/src/ring.rs:110:        std::thread::sleep(REPLY_POLL);
62	spt-core/crates/spt-msg/src/ring.rs:128:    PollListener::bind_blocking(from, REPLY_POLL)
63	spt-core/crates/spt-msg/src/ring.rs:172:            // Wait for the incoming ring, parse __REPLY_TO__, reply to sender.
64

===spt-core envelope/event shape===
spt-core/crates/spt-proto/src/envelope.rs:2://! Psyche-context parser. **Copied verbatim** from `claude_skill_owl`
spt-core/crates/spt-proto/src/envelope.rs:14://! `<EVENT>` and become structurally significant. (KNOWN-HAZARDS 4.1.)
spt-core/crates/spt-proto/src/envelope.rs:35:/// `&lt;EVENT&gt;` would double-decode into `<EVENT>`.
spt-core/crates/spt-proto/src/envelope.rs:65:/// Extract the `from="..."` attribute from an `<EVENT ... from="ATTR" ...>`
spt-core/crates/spt-proto/src/envelope.rs:67:/// `<EVENT ` tag, no `from=` attribute, empty `from=""`, or no closing quote.
spt-core/crates/spt-proto/src/envelope.rs:69:pub fn parse_event_from_attr(msg: &str) -> Option<String> {
spt-core/crates/spt-proto/src/envelope.rs:70:    let event_start = msg.find("<EVENT ")?;
spt-core/crates/spt-proto/src/envelope.rs:72:    let from_marker = "from=\"";
spt-core/crates/spt-proto/src/envelope.rs:73:    let from_pos = after_event.find(from_marker)?;
spt-core/crates/spt-proto/src/envelope.rs:74:    let value_start = from_pos + from_marker.len();
spt-core/crates/spt-proto/src/envelope.rs:98:pub struct TwoSlicePayload {
spt-core/crates/spt-proto/src/envelope.rs:146:        // User prose containing a literal entity reference for `<EVENT>`.
spt-core/crates/spt-proto/src/envelope.rs:148:        // live `<EVENT>` framing token.
spt-core/crates/spt-proto/src/envelope.rs:170:    fn from_attr_unescape_is_amp_last() {
spt-core/crates/spt-proto/src/envelope.rs:171:        let msg = "<EVENT from=\"&lt;weird&gt;\" type=\"msg\">body</EVENT>";
spt-core/crates/spt-proto/src/envelope.rs:172:        assert_eq!(parse_event_from_attr(msg).as_deref(), Some("<weird>"));
spt-core/crates/spt-proto/src/envelope.rs:176:    fn from_attr_returns_none_on_garbage_or_empty() {
spt-core/crates/spt-proto/src/envelope.rs:177:        assert_eq!(parse_event_from_attr("no event here"), None);
spt-core/crates/spt-proto/src/envelope.rs:178:        assert_eq!(parse_event_from_attr("<EVENT from=\"\">x</EVENT>"), None);
spt-core/crates/spt-proto/src/envelope.rs:179:        assert_eq!(parse_event_from_attr("<EVENT from=\"unterminated"), None);
spt-core/crates/spt-proto/src/event.rs:1://! EVENT envelope grammar: compose and parse the `<EVENT attrs>body</EVENT>`
spt-core/crates/spt-proto/src/event.rs:2://! wire line. The **format** is copied verbatim from `claude_skill_owl`
spt-core/crates/spt-proto/src/event.rs:4://! `body_is_typed_event_envelope`) per ADR-0001; this is the stable, public
spt-core/crates/spt-proto/src/event.rs:11://! - regular message → `<EVENT type="msg" from="...">body</EVENT>`
spt-core/crates/spt-proto/src/event.rs:12://! - self-originated alarm → `<EVENT type="alarm" target-time="..." current-time="...">body</EVENT>`
spt-core/crates/spt-proto/src/event.rs:13://! - pre-formed typed envelope (e.g. `echo_commune`, `file_drop`) → emitted verbatim
spt-core/crates/spt-proto/src/event.rs:29:/// Compose a regular message envelope: `<EVENT type="msg" from="F">B</EVENT>`.
spt-core/crates/spt-proto/src/event.rs:30:/// `from` is attr-escaped; `body` is body-escaped (`\n`→`<br>`). Mirrors the
spt-core/crates/spt-proto/src/event.rs:33:pub fn compose_msg_event(from: &str, body: &str) -> String {
spt-core/crates/spt-proto/src/event.rs:35:        "<EVENT type=\"msg\" from=\"{}\">{}</EVENT>",
===LLM-facing EVENT framing (sister compose)===
claude_skill_owl/src/live/wrapper/mod.rs:41:/// at the receiver side (`<EVENT type="msg" from="...">...</EVENT>`); the
claude_skill_owl/src/live/wrapper/mod.rs:124:/// shape `<EVENT type="init_signoff"` (case-insensitive on the attribute
claude_skill_owl/src/live/wrapper/mod.rs:142:/// `<EVENT type="init_signoff" timestamp="...">...</EVENT>` — because
claude_skill_owl/src/live/wrapper/mod.rs:257://   <EVENT type="file_drop" kind="commune|signoff" path="<forward-slash-abs>"
claude_skill_owl/src/live/wrapper/mod.rs:258://          from="<self_id>"></EVENT>
claude_skill_owl/src/live/wrapper/mod.rs:264://   compose_commune_payload         — user-facing <EVENT type="commune">{body}</EVENT>
claude_skill_owl/src/live/wrapper/mod.rs:271:/// Phase 30 Plan 02 Task 2 — pure parser for the `<EVENT type="file_drop">`
claude_skill_owl/src/live/wrapper/mod.rs:276:///   `<EVENT type="file_drop" kind="commune|signoff" path="<abs>" from="<id>"></EVENT>`
claude_skill_owl/src/live/wrapper/mod.rs:279:/// - `<EVENT type="file_drop"` is present case-insensitively,
claude_skill_owl/src/live/wrapper/mod.rs:305:    // Slice up to the closing `</EVENT>` (or `</event>`). Match the lowercased
claude_skill_owl/src/live/wrapper/mod.rs:420:/// to the full prefix), and the close-tag `</EVENT>` is part of the wire.
claude_skill_owl/src/live/wrapper/mod.rs:463:/// `<EVENT type="commune" timestamp="..." {stamp_attrs}>{body}</EVENT>`
claude_skill_owl/src/live/wrapper/mod.rs:480:        "<EVENT type=\"commune\" timestamp=\"{}\"{}>{}</EVENT>",
claude_skill_owl/src/live/wrapper/mod.rs:833:/// The LLM stdin contains NO `<EVENT>` framing tokens at the prompt layer —
claude_skill_owl/src/live/wrapper/mod.rs:834:/// user body content containing literal `</EVENT>` becomes plain prose
claude_skill_owl/src/live/wrapper/mod.rs:835:/// because there is no upstream `<EVENT>` opener in the prompt for it to
claude_skill_owl/src/live/wrapper/mod.rs:863:pub(crate) fn compose_llm_prompt_from_envelope(envelope: &str) -> String {
claude_skill_owl/src/live/wrapper/mod.rs:866:    if !trimmed.starts_with("<EVENT") {
claude_skill_owl/src/live/wrapper/mod.rs:881:    // Step 2: find the LAST `</EVENT>` (framing closer).
claude_skill_owl/src/live/wrapper/mod.rs:882:    let close_idx = match trimmed.rfind("</EVENT>") {
===spt-core reply routing===
spt-core/crates/spt-msg/src/deliver.rs:89:/// message took. `from` is written into the `__REPLY_TO__` line (empty =
spt-core/crates/spt-msg/src/deliver.rs:132:/// `__REPLY_TO__` line the original message carried names the target, and the
spt-core/crates/spt-msg/src/deliver.rs:210:        assert_eq!(got.as_deref(), Some("__REPLY_TO__:sender\nlive msg\n"));
spt-core/crates/spt-msg/src/lib.rs:7://! Per ADR-0001 the **stable wire format** (length-prefixed `__REPLY_TO__`
spt-core/crates/spt-msg/src/listener.rs:230:        assert_eq!(got.as_deref(), Some("__REPLY_TO__:sender\nping\n"));
spt-core/crates/spt-msg/src/ready.rs:221:        assert_eq!(got.as_deref(), Some("__REPLY_TO__:peer\nhello live\n"));
spt-core/crates/spt-msg/src/ring.rs:27:const REPLY_POLL: Duration = Duration::from_millis(50);
spt-core/crates/spt-msg/src/ring.rs:110:        std::thread::sleep(REPLY_POLL);
spt-core/crates/spt-msg/src/ring.rs:128:    PollListener::bind_blocking(from, REPLY_POLL)
spt-core/crates/spt-msg/src/ring.rs:172:            // Wait for the incoming ring, parse __REPLY_TO__, reply to sender.


SPT's canonical envelope is `<EVENT type="X" from="F" …>body</EVENT>` (spt-proto/event.rs, copied-verbatim stable wire format per ADR-0001; body escapes `\n`→`<br>`, KNOWN-HAZARDS 4.1 entity rules). Reply routing already rides a `__REPLY_TO__:sender` line (spt-msg/deliver.rs). That gives a clean way to make your `<reply>`/`<notify>` consistent — and one real sub-decision on *who authors the `<EVENT>`*.


AskUserQuestion

Question: Refining the XML-style shape against SPT's <EVENT> envelope grammar. The trust-boundary (constrained routing) interacts with the tag shape — who is allowed to author a wire <EVENT> with a from=?

Choices:
Terse intent tags, daemon mints EVENT (Rec)
Psyche emits lowercase intent tags <reply>body</reply> / <notify>body</notify> (body only; same \n→<br> + 4.1 entity escaping as <EVENT>). Daemon parses the intent, applies constrained routing, and MINTS the canonical <EVENT type="msg" from="<self>" ...> wire envelope (reply→__REPLY_TO__ sender; notify→user). Psyche is syntactically incapable of authoring <EVENT>/from — anti-spoof by construction. Lowercase intent vs UPPERCASE <EVENT> wire.
psyche stdout (intent, lowercase):
  <reply>got it, merging now</reply>
  <notify>build failed on windows</notify>
        |
        v  daemon parses + constrains + mints
wire <EVENT> (daemon-authored, trusted):
  <EVENT type="msg" from="doyle">got it...</EVENT>  -> __REPLY_TO__ sender
  subnet notif -> user
(psyche cannot set from= or target)
Psyche emits <EVENT type=reply> directly
One grammar everywhere: psyche emits <EVENT type="reply">body</EVENT> / type="notify". Daemon strips any psyche-supplied from=/attrs and re-stamps. Single parser family, but the psyche now authors EVENT envelopes — blurs the 'daemon is sole EVENT author' trust line; daemon must defensively strip spoofed attrs.
psyche stdout:
  <EVENT type="reply">got it</EVENT>
  <EVENT type="notify">build failed</EVENT>
daemon strips from=/target, re-stamps, routes
(psyche authors EVENT; daemon sanitizes)
Same as Recommended, but also allow a daemon-validated attribute set on notify, e.g. <notify priority="high">. Adds a small attr surface now (vs body-only v1) since the notif primitive (D8) carries priority anyway.
<reply>body</reply>            (body only)
<notify priority="high">body</notify>
  daemon whitelists priority in {low,normal,high}
  ignores any other attr


Your questions have been answered: "Refining the XML-style shape against SPT's <EVENT> envelope grammar. The trust-boundary (constrained routing) interacts with the tag shape — who is allowed to author a wire <EVENT> with a from=?"="Psyche emits <EVENT type=reply> directly" selected preview:
psyche stdout:
  <EVENT type="reply">got it</EVENT>
  <EVENT type="notify">build failed</EVENT>
daemon strips from=/target, re-stamps, routes
(psyche authors EVENT; daemon sanitizes). You can now continue with these answers in mind.

Question: Refining the XML-style shape against SPT's <EVENT> envelope grammar. The trust-boundary (constrained routing) interacts with the tag shape — who is allowed to author a wire <EVENT> with a from=?

Choices:
Terse intent tags, daemon mints EVENT (Rec)
Psyche emits lowercase intent tags <reply>body</reply> / <notify>body</notify> (body only; same \n→<br> + 4.1 entity escaping as <EVENT>). Daemon parses the intent, applies constrained routing, and MINTS the canonical <EVENT type="msg" from="<self>" ...> wire envelope (reply→__REPLY_TO__ sender; notify→user). Psyche is syntactically incapable of authoring <EVENT>/from — anti-spoof by construction. Lowercase intent vs UPPERCASE <EVENT> wire.
psyche stdout (intent, lowercase):
  <reply>got it, merging now</reply>
  <notify>build failed on windows</notify>
        |
        v  daemon parses + constrains + mints
wire <EVENT> (daemon-authored, trusted):
  <EVENT type="msg" from="doyle">got it...</EVENT>  -> __REPLY_TO__ sender
  subnet notif -> user
(psyche cannot set from= or target)
Psyche emits <EVENT type=reply> directly
One grammar everywhere: psyche emits <EVENT type="reply">body</EVENT> / type="notify". Daemon strips any psyche-supplied from=/attrs and re-stamps. Single parser family, but the psyche now authors EVENT envelopes — blurs the 'daemon is sole EVENT author' trust line; daemon must defensively strip spoofed attrs.
psyche stdout:
  <EVENT type="reply">got it</EVENT>
  <EVENT type="notify">build failed</EVENT>
daemon strips from=/target, re-stamps, routes
(psyche authors EVENT; daemon sanitizes)
Same as Recommended, but also allow a daemon-validated attribute set on notify, e.g. <notify priority="high">. Adds a small attr surface now (vs body-only v1) since the notif primitive (D8) carries priority anyway.
<reply>body</reply>            (body only)
<notify priority="high">body</notify>
  daemon whitelists priority in {low,normal,high}
  ignores any other attr

Psyche emits <EVENT type=reply> directly


All decisions locked. Final model:
- **Psyche outbound** = stdout `<EVENT type="reply">` / `<EVENT type="notify">` (one grammar with inbound). **Daemon sanitizes**: strips any psyche-supplied `from=`/target, re-stamps `from=<self>`, routes (reply→`__REPLY_TO__` sender; notify→own user/subnet), validates body.

Now writing all artifacts. Reading the exact files + the skill's format guides first.