//   A5  live activity buffer (PTY digest) parser primitive  (REQ-TERM-4)       ← landed

pub mod digest;
pub mod pty;
pub mod reader;
pub mod stream;
pub mod surface;

pub use digest::{
    Digest, DigestConfig, DigestEntry, DigestError, DigestParser, DigestPatterns, ToolUse, Turn,
};
pub use portable_pty::CommandBuilder;
pub use pty::PtySession;
pub use reader::Drain;
pub use stream::OutputStream;
pub use surface::{SessionSurface, SurfaceError, SurfaceSize};

//! spt-core session-surface mechanism — the broker's PTY half (M3a).
//!
//! `spt-term` is the **multiplatform session-surface mechanism** that every hosted
//! agent session runs inside: a real child under a native PTY — ConPTY on Windows
//! 10+, `forkpty(3)` on Unix — behind one [`SessionSurface`] trait, with input
//! injection, byte-stream output, and the live activity buffer (PTY digest) parser
//! primitive layered on top. It is the sixth crate in the acyclic stack
//! (`spt-proto -> spt-store -> spt-msg -> spt-term`), a sibling of `spt-runtime`.
//!
//! M3a delivers the *library mechanism*; the **M3b** `spt-daemon` broker delivers
//! the *process* that hosts many of these behind versioned IPC. `spt-term` is
//! deliberately **single-surface-per-handle** — it owns one child's PTY, not the
//! multiplexed name-addressable fleet (that supervisor is M3b). See `M3a-PLAN.md`.
//!
//! ## OS stream-contract neutrality (Spike #4 / #5, binding)
//!
//! The single biggest design risk this crate carries: `forkpty` is a **raw ordered
//! pipe**; ConPTY is a **screen buffer** (repaint-on-resize reorders + duplicates
//! the stream; a mid-session attach sees the viewport, not scrollback). The
//! [`SessionSurface`] trait and the byte-stream / digest contracts therefore must
//! **not** bake in either OS's semantics — the surface is modeled as a byte stream
//! with explicit, OS-independent attach/replay semantics.
//
// spt-term is internal broker machinery, NOT part of the public semver SDK
// surface (R-ARCH-2 stays spt-proto/spt-store/spt-msg/spt-runtime). The M3b
// daemon programs against it; external harness authors do not.

// Surfaces land across M3a (see M3a-PLAN.md task table):
//   A1  SessionSurface trait + ConPTY/forkpty backends     (REQ-TERM-1)       ← landed
//   A2  ConPTY-DSR auto-answer drain thread                 (REQ-HAZARD-CONPTY-DSR) ← landed
//   A3  send-keys / send-line injection                     (REQ-TERM-2)
//   A4  byte-stream local streaming (bounded backpressure)  (REQ-TERM-3)       ← landed
//   A5  live activity buffer (PTY digest) parser primitive  (REQ-TERM-4)

pub mod pty;
pub mod reader;
pub mod stream;
pub mod surface;

pub use portable_pty::CommandBuilder;
pub use pty::PtySession;
pub use reader::Drain;
pub use stream::OutputStream;
pub use surface::{SessionSurface, SurfaceError, SurfaceSize};

-//   A5  live activity buffer (PTY digest) parser primitive  (REQ-TERM-4)

+//   A5  live activity buffer (PTY digest) parser primitive  (REQ-TERM-4)       ← landed

+    Digest, DigestConfig, DigestEntry, DigestError, DigestParser, DigestPatterns, ToolUse, Turn,


Bash

cargo test -p spt-term --lib digest 2>&1 | tail -30

Build + run digest unit tests


      Adding regex-automata v0.4.14
      Adding regex-syntax v0.8.10
   Compiling aho-corasick v1.1.4
   Compiling regex-syntax v0.8.10
   Compiling regex-automata v0.4.14
   Compiling regex v1.12.3
   Compiling spt-term v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-term)
error[E0277]: `digest::DigestParser` doesn't implement `Debug`
    --> crates\spt-term\src\digest.rs:464:68
     |
 464 |         let err = DigestParser::new(pats, DigestConfig::default()).unwrap_err();
     |                                                                    ^^^^^^^^^^ the trait `Debug` is not implemented for `digest::DigestParser`
     |
     = note: add `#[derive(Debug)]` to `digest::DigestParser` or manually `impl Debug for digest::DigestParser`
note: required by a bound in `Result::<T, E>::unwrap_err`
    --> C:\Users\decid\.rustup\toolchains\stable-x86_64-pc-windows-msvc\lib/rustlib/src/rust\library\core\src\result.rs:1326:12
     |
1324 |     pub fn unwrap_err(self) -> E
     |            ---------- required by a bound in this associated function
1325 |     where
1326 |         T: fmt::Debug,
     |            ^^^^^^^^^^ required by this bound in `Result::<T, E>::unwrap_err`
help: consider annotating `digest::DigestParser` with `#[derive(Debug)]`
     |
 140 + #[derive(Debug)]
 141 | pub struct DigestParser {
     |

For more information about this error, try `rustc --explain E0277`.
error: could not compile `spt-term` (lib test) due to 1 previous error


Edit

/// The digest parser: feed bytes from the A4 stream, read a structured [`Digest`].
pub struct DigestParser {

/// The digest parser: feed bytes from the A4 stream, read a structured [`Digest`].
#[derive(Debug)]
pub struct DigestParser {


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-term\src\digest.rs has been updated successfully. (file state is current in your context — no need to Read it back)

/// The digest parser: feed bytes from the A4 stream, read a structured [`Digest`].
pub struct DigestParser {

/// The digest parser: feed bytes from the A4 stream, read a structured [`Digest`].
#[derive(Debug)]
pub struct DigestParser {


Bash

cargo test -p spt-term --lib digest 2>&1 | tail -25

Re-run digest tests


   Compiling spt-term v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-term)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.80s
     Running unittests src\lib.rs (target\debug\deps\spt_term-f964ef9f0f6037ae.exe)

running 5 tests
test digest::tests::bad_pattern_is_reported_by_name ... ok
test digest::tests::windows_to_last_n_turns ... ok
test digest::tests::strips_ansi_and_tolerates_repaint_duplication ... ok
test digest::tests::truncates_tool_args_to_width ... ok
test digest::tests::tags_input_agent_and_collapses_tool_sprints ... ok

test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 3 filtered out; finished in 0.01s


5/5 digest unit tests green. Add a real-PTY integration test proving the primitive over the live A4 stream.


Write

//! A5 integration test: the digest parser primitive over a **real** A4 byte
//! stream (an echo child on a native PTY), proving the stream→parser wiring on
//! both backends. The daemon-side fetch / delta-stream / persistence is M3b.

use std::time::Duration;

use spt_term::{
    DigestConfig, DigestEntry, DigestParser, DigestPatterns, PtySession, SessionSurface,
    SurfaceSize,
};

fn echo_child() -> PtySession {
    #[cfg(unix)]
    let s = PtySession::spawn_program("cat", Vec::<String>::new(), SurfaceSize::new(24, 80));
    #[cfg(windows)]
    let s = PtySession::spawn_program("findstr", ["."], SurfaceSize::new(24, 80));
    s.expect("spawn echo child")
}

fn patterns() -> DigestPatterns {
    DigestPatterns {
        input: r"(?m)^USER> ".to_string(),
        agent: r"(?m)^AGENT> ".to_string(),
        tools: vec![
            r"(?P<name>Write)\((?P<arg>[^)]*)\)".to_string(),
            r"(?P<name>Bash)\((?P<arg>[^)]*)\)".to_string(),
        ],
        catchall: None,
    }
}

// [unit->REQ-TERM-4]
#[test]
fn digest_parses_a_real_pty_stream() {
    let session = echo_child();
    let stream = session.byte_stream(256).expect("open stream");
    let mut parser =
        DigestParser::new(patterns(), DigestConfig { window_turns: 3, ..Default::default() })
            .expect("compile patterns");

    // Drive a scripted transcript through the child; it echoes back into the
    // stream, which we feed into the digest primitive.
    for line in [
        "USER> first request",
        "AGENT> working on it",
        "Write(file.txt) then Bash(ls -la) done",
        "USER> second request",
        "AGENT> all finished",
    ] {
        session.send_line(line).expect("inject transcript line");
    }

    // Pump the stream into the parser until the latest turn lands or we time out.
    let mut ok = false;
    for _ in 0..200 {
        while let Some(chunk) = stream.try_recv() {
            parser.feed(&chunk);
        }
        let d = parser.digest();
        if d.turns
            .last()
            .and_then(|t| t.input.as_deref())
            .is_some_and(|i| i.contains("second request"))
        {
            ok = true;
            break;
        }
        if let Some(chunk) = stream.recv_timeout(Duration::from_millis(25)) {
            parser.feed(&chunk);
        }
    }

    stream.request_stop();
    let _ = session.send_keys(&[0x03]);
    let _ = session.kill();

    let d = parser.digest();
    assert!(ok, "the digest must reflect the latest user turn; got {d:?}");

    // The first turn must show the collapsed Write+Bash tool sprint.
    let first = d.turns.iter().find(|t| {
        t.input.as_deref().is_some_and(|i| i.contains("first request"))
    });
    let first = first.expect("first turn present in window");
    let has_sprint = first.entries.iter().any(|e| matches!(e, DigestEntry::ToolSprint(s) if s.len() >= 2));
    assert!(has_sprint, "first turn must collapse Write+Bash into a sprint; got {first:?}");
}


File created successfully at: C:\Users\decid\Documents\projects\spt-core\crates\spt-term\tests\digest.rs (file state is current in your context — no need to Read it back)

//! A5 integration test: the digest parser primitive over a **real** A4 byte
//! stream (an echo child on a native PTY), proving the stream→parser wiring on
//! both backends. The daemon-side fetch / delta-stream / persistence is M3b.

use std::time::Duration;

use spt_term::{
    DigestConfig, DigestEntry, DigestParser, DigestPatterns, PtySession, SessionSurface,
    SurfaceSize,
};

fn echo_child() -> PtySession {
    #[cfg(unix)]
    let s = PtySession::spawn_program("cat", Vec::<String>::new(), SurfaceSize::new(24, 80));
    #[cfg(windows)]
    let s = PtySession::spawn_program("findstr", ["."], SurfaceSize::new(24, 80));
    s.expect("spawn echo child")
}

fn patterns() -> DigestPatterns {
    DigestPatterns {
        input: r"(?m)^USER> ".to_string(),
        agent: r"(?m)^AGENT> ".to_string(),
        tools: vec![
            r"(?P<name>Write)\((?P<arg>[^)]*)\)".to_string(),
            r"(?P<name>Bash)\((?P<arg>[^)]*)\)".to_string(),
        ],
        catchall: None,
    }
}

// [unit->REQ-TERM-4]
#[test]
fn digest_parses_a_real_pty_stream() {
    let session = echo_child();
    let stream = session.byte_stream(256).expect("open stream");
    let mut parser =
        DigestParser::new(patterns(), DigestConfig { window_turns: 3, ..Default::default() })
            .expect("compile patterns");

    // Drive a scripted transcript through the child; it echoes back into the
    // stream, which we feed into the digest primitive.
    for line in [
        "USER> first request",
        "AGENT> working on it",
        "Write(file.txt) then Bash(ls -la) done",
        "USER> second request",
        "AGENT> all finished",
    ] {
        session.send_line(line).expect("inject transcript line");
    }

    // Pump the stream into the parser until the latest turn lands or we time out.
    let mut ok = false;
    for _ in 0..200 {
        while let Some(chunk) = stream.try_recv() {
            parser.feed(&chunk);
        }
        let d = parser.digest();
        if d.turns
            .last()
            .and_then(|t| t.input.as_deref())
            .is_some_and(|i| i.contains("second request"))
        {
            ok = true;
            break;
        }
        if let Some(chunk) = stream.recv_timeout(Duration::from_millis(25)) {
            parser.feed(&chunk);
        }
    }

    stream.request_stop();
    let _ = session.send_keys(&[0x03]);
    let _ = session.kill();

    let d = parser.digest();
    assert!(ok, "the digest must reflect the latest user turn; got {d:?}");

    // The first turn must show the collapsed Write+Bash tool sprint.
    let first = d.turns.iter().find(|t| {
        t.input.as_deref().is_some_and(|i| i.contains("first request"))
    });
    let first = first.expect("first turn present in window");
    let has_sprint = first.entries.iter().any(|e| matches!(e, DigestEntry::ToolSprint(s) if s.len() >= 2));
    assert!(has_sprint, "first turn must collapse Write+Bash into a sprint; got {first:?}");
}


Bash

cargo test -p spt-term --test digest 2>&1 | tail -20

Run digest integration test on Windows


   Compiling spt-term v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-term)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.49s
     Running tests\digest.rs (target\debug\deps\digest-c8f2f36fb943fbad.exe)

running 1 test
test digest_parses_a_real_pty_stream ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s


Real-PTY digest passes on Windows. Full suite + clippy, activate REQ-TERM-4.


cargo test -p spt-term 2>&1 | grep -E "running|test result|FAILED"; echo "===CLIPPY==="; cargo clippy -p spt-term --all-targets -- -D warnings 2>&1 | tail -6

Full spt-term test + clippy


running 8 tests
test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
running 1 test
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s
running 2 tests
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s
running 2 tests
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s
running 2 tests
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.01s
running 1 test
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
===CLIPPY===
    Checking aho-corasick v1.1.4
    Checking regex-syntax v0.8.10
    Checking regex-automata v0.4.14
    Checking regex v1.12.3
    Checking spt-term v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-term)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.25s


Edit

id = "REQ-TERM-4"
title = "Live activity buffer (PTY digest): adapter-supplied patterns over broker PTY, spt digest pull + delta-stream, opt-in Path-B log"
required_stages = []

id = "REQ-TERM-4"
title = "Live activity buffer (PTY digest): adapter-supplied patterns over broker PTY, spt digest pull + delta-stream, opt-in Path-B log"
required_stages = ["impl", "unit"]


The file C:\Users\decid\Documents\projects\spt-core\traceable-reqs.toml has been updated successfully. (file state is current in your context — no need to Read it back)

id = "REQ-TERM-4"
title = "Live activity buffer (PTY digest): adapter-supplied patterns over broker PTY, spt digest pull + delta-stream, opt-in Path-B log"
required_stages = []

id = "REQ-TERM-4"
title = "Live activity buffer (PTY digest): adapter-supplied patterns over broker PTY, spt digest pull + delta-stream, opt-in Path-B log"
required_stages = ["impl", "unit"]

title = "Day-one launcher/manager frontend (list/launch/attach/init)"


Bash

/c/Users/decid/AppData/Local/Programs/traceable-reqs/traceable-reqs check 2>&1 | grep -E "TERM-4|FAIL|MISSING|error|invalid" | head; echo "=== overall ==="; /c/Users/decid/AppData/Local/Programs/traceable-reqs/traceable-reqs check 2>&1 | grep -cE "\[OK\]"; /c/Users/decid/AppData/Local/Programs/traceable-reqs/traceable-reqs check >/dev/null 2>&1; echo "exit=$?"

Check REQ-TERM-4 + overall exit


  [OK] REQ-TERM-4  required: [impl, unit]  stages: -doc +impl +unit -int
=== overall ===
115
exit=0


115 green, exit 0. Validate full crate on Linux, then commit A5.


tar czf /tmp/spt-sync.tgz --exclude=target --exclude=.git . 2>/dev/null && \
SSH_ASKPASS=/tmp/askpass.sh SSH_ASKPASS_REQUIRE=force ssh -o StrictHostKeyChecking=no david@gravity-linux 'cat > /tmp/spt-sync.tgz' < /tmp/spt-sync.tgz 2>&1 | tail -1
SSH_ASKPASS=/tmp/askpass.sh SSH_ASKPASS_REQUIRE=force ssh -o StrictHostKeyChecking=no david@gravity-linux 'rm -rf ~/spt-core-sync && mkdir -p ~/spt-core-sync && tar xzf /tmp/spt-sync.tgz -C ~/spt-core-sync && export PATH=$HOME/.cargo/bin:$PATH && cd ~/spt-core-sync && cargo test -p spt-term 2>&1 | grep -E "running|test result|FAILED|error\[" ' < /dev/null 2>&1 | tail -20

Full spt-term test on Linux


running 8 tests
test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
running 1 test
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
running 1 test
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
running 2 tests
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s
running 2 tests
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.00s
running 2 tests
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s


All green on Linux too (16 tests across both OSes). Commit A5.


git add -A && git commit -q -m "feat(term): PTY digest parser primitive (M3a A5, ADR-0008)

The live activity buffer parser: adapter-supplied patterns in,
structured source-tagged Digest out, fed from the A4 byte stream.
Honors the no-built-in-parser rule (ADR-0008) — spt-core runs the
patterns, the adapter owns them. Seam: input/agent boundary markers
(each opens a span capturing until the next hit of any kind) + a tools
list + optional catchall (name/arg capture groups). Collapses
consecutive tool hits into one sprint; groups into user turns; windows
to the last N; truncates tool args to width. Bounded rolling text
buffer.

OS-neutral (Spike #4/#5): strips ANSI before matching and reports the
tail, so a ConPTY repaint that reorders/duplicates content just lets
the latest copy win (regression test feeds a [2J/[H repaint + a
duplicated turn and asserts the right last-N + no ANSI residue).

Scope is the PRIMITIVE ONLY — manifest-sourcing, spt digest pull,
delta-stream, and Path-B persistence are M3b (REQ-TERM-4 int lands
there). 5 unit tests (tagging/collapse, windowing, truncation, repaint,
bad-pattern) + a real-PTY integration test over the live stream.

Validated on Windows (ConPTY) + Linux (forkpty). Activates REQ-TERM-4
(impl, unit). Adds regex dep.

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

Commit A5


warning: in the working copy of 'Cargo.lock', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-term/Cargo.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-term/src/lib.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'traceable-reqs.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-term/src/digest.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-term/tests/digest.rs', LF will be replaced by CRLF the next time Git touches it
3877dd2 feat(term): PTY digest parser primitive (M3a A5, ADR-0008)


A0–A5 all landed, 115 green. Now A6: workspace clippy/test, CI matrix, ROADMAP/CONTEXT amend, M3b stub. Check CI config + ROADMAP M3 section.


find .github -type f 2>/dev/null; echo "===workspace clippy+test==="; cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -4; echo "---TEST---"; cargo test --workspace 2>&1 | grep -E "test result|FAILED|error\[" | tail -20

CI files + workspace clippy + test


.github/workflows/ci.yml
===workspace clippy+test===
    Checking spt-term v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-term)
    Checking spt-live v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-live)
    Checking spt v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.83s
---TEST---
test result: ok. 40 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.27s
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.02s
test result: ok. 35 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.32s
test result: ok. 22 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.14s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.25s
test result: ok. 49 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s
test result: ok. 17 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.32s
test result: ok. 33 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.96s
test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.01s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s


Workspace clippy clean, all tests pass on Windows. Check CI matrix covers spt-term.


1	name: ci
2
3	on:
4	  push:
5	    branches: [main, dev-freeform]
6	  pull_request:
7
8	# Cancel superseded runs on the same ref.
9	concurrency:
10	  group: ci-${{ github.ref }}
11	  cancel-in-progress: true
12
13	jobs:
14	  # ---- Rust build + test + lint (cross-platform: exercises both the
15	  # cfg(windows) OpenProcess path and the cfg(unix) libc path in proc.rs) ----
16	  test:
17	    strategy:
18	      fail-fast: false
19	      matrix:
20	        os: [ubuntu-latest, windows-latest]
21	    runs-on: ${{ matrix.os }}
22	    steps:
23	      - uses: actions/checkout@v4
24	      - name: Install Rust (stable + clippy)
25	        uses: dtolnay/rust-toolchain@stable
26	        with:
27	          components: clippy
28	      - name: Cache cargo
29	        uses: Swatinem/rust-cache@v2
30	      - name: Build
31	        run: cargo build --workspace --all-targets
32	      - name: Test
33	        run: cargo test --workspace
34	      - name: Clippy (deny warnings)
35	        run: cargo clippy --workspace --all-targets -- -D warnings
36
37	  # ---- Requirement-traceability hard gate (TRACEABILITY.md layer 1) ----
38	  traceability:
39	    runs-on: ubuntu-latest
40	    steps:
41	      - uses: actions/checkout@v4
42	      - name: Install traceable-reqs
43	        run: |
44	          gh release download --repo BigscreenVR/traceable-reqs \
45	            --pattern '*linux-x86_64' --output traceable-reqs
46	          chmod +x traceable-reqs
47	        env:
48	          GH_TOKEN: ${{ github.token }}
49	      - name: Coverage gate (hard — fails PR on uncovered activated req)
50	        run: ./traceable-reqs check --json
51	      - name: Quality audit (advisory)
52	        run: ./traceable-reqs lint || true
53

name: ci

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

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

jobs:
  # ---- Rust build + test + lint (cross-platform: exercises both the
  # cfg(windows) OpenProcess path and the cfg(unix) libc path in proc.rs) ----
  test:
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, windows-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - name: Install Rust (stable + clippy)
        uses: dtolnay/rust-toolchain@stable
        with:
          components: clippy
      - name: Cache cargo
        uses: Swatinem/rust-cache@v2
      - name: Build
        run: cargo build --workspace --all-targets
      - name: Test
        run: cargo test --workspace
      - name: Clippy (deny warnings)
        run: cargo clippy --workspace --all-targets -- -D warnings

  # ---- Requirement-traceability hard gate (TRACEABILITY.md layer 1) ----
  traceability:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install traceable-reqs
        run: |
          gh release download --repo BigscreenVR/traceable-reqs \
            --pattern '*linux-x86_64' --output traceable-reqs
          chmod +x traceable-reqs
        env:
          GH_TOKEN: ${{ github.token }}
      - name: Coverage gate (hard — fails PR on uncovered activated req)
        run: ./traceable-reqs check --json
      - name: Quality audit (advisory)
        run: ./traceable-reqs lint || true


CI matrix already runs `cargo test --workspace` + workspace clippy on ubuntu+windows — covers spt-term automatically. Now ROADMAP/CONTEXT amend + M3b stub. Check ROADMAP M3 section.


grep -n "M3\|M2b\|spt-term\|DELIVERED\|delivered\|✅\|SHIPPED\|shipped" ROADMAP.md | head -40

Find M3 markers in ROADMAP


7:A harness-independent core for an agent ecosystem — messaging, live-agent lifecycle, terminal hosting, **first-class P2P networking**, and a runtime-manifest harness contract — shipped as Rust crates + a single `spt` binary. The proof: the same agent runs natively across all of a user's machines with a synced mind, reachable and operable from anywhere, with seamless self-update and zero-config cross-machine messaging. First thing built atop it: a rebuilt `spt` plugin at parity with today's modern SPT.
30:  - **M2a — Harness contract. ✅ delivered (2026-06-01).** `spt-runtime` (AgentRuntime, ManifestRuntime, manifest schema) + the `api` subcommand surface (seed/listen/bind/state/echo-gate/poll/worker-*/boundary/session-end/presence/history-log/emit/capability) + the **harness-hosted** startup topology (seed→listen→bind, interim seed-file) + local-api-auth + the generic mock adapter + contract E2E. All M2a reqs activated + green. Task breakdown: [`M2a-PLAN.md`](./M2a-PLAN.md).
31:  - **M2b — Live-agent lifecycle. ✅ delivered (2026-06-01).** `spt-live` (fifth layer): history subsystem (fetcher/locate-normalize/native) + spawn-psyche (nested perch, spt-core-owned `$psyche_prompt`) + echo-commune (history brief → single-writer commune drop) + drop-file ingest (single-writer 6.4 + direct-write precedence 6.5) + interim pulse + signoff/boundary ordering invariants (echo-before-signoff 3.3, grace-before-signoff 1.1, stale-signoff sweep 3.2) + resume seam (continue-existing / fresh-with-preload). Hosted by the `api listen` runner + `api shutdown`; mock adapter gained the lifecycle templates; LiveAgent E2E proves spawn→Psyche-perch→commune-ingest→graceful-signoff harness-agnostically. All M2b reqs activated + green. Task breakdown: [`M2b-PLAN.md`](./M2b-PLAN.md).
32:  - **Interim, no daemon** (like M1): the `api listen` process holds the perch + relays; the seed bridges via a file. **The spt-hosted topology (PTY-launch) + the consolidated `spt-daemon` move to M3** (they need the `spt-term` broker) — this narrows the earlier "both topologies in M2" wording.
33:- **M3 — Terminal wrapper + daemon + self-update.** `spt-term` (session-surface, PTY, broker) + the consolidated `spt-daemon` (broker/brain split) + peer-propagated signed self-update. Scope + carried-forward Spike #1 gaps: [`M3-PLAN.md`](./M3-PLAN.md) (stub).
47:- ✅ **Stage A — codex adversarial pass** — done. 14 findings (5 FATAL, 9 SERIOUS) in `docs/reviews/STAGE-A-codex-redteam.md`.
48:- ✅ **Spike #1 (broker/brain handoff)** — PASS on Windows ConPTY. `docs/spikes/SPIKE-01-broker-handoff.md`. Found+fixed the ConPTY DSR-stall hazard. 4 open gaps carried to M3.
49:- ✅ **Spike #2 (Iroh smoke test)** — PASS (single-host). `docs/spikes/SPIKE-02-iroh-smoke.md`. iroh 0.98 API workable; 13.57 MB release. Cross-NAT/relay proof deferred to M4.
50:- ✅ **Findings folded** into ADR-0002/0003/0004/0005 + KNOWN-HAZARDS (+3) + traceable-reqs.toml (+4 REQ-HAZARD).
65:- The dual-audience docs strategy (`docs/DOCS-STRATEGY.md`) governs the *shipped* docs, authored as code lands.


33	- **M3 — Terminal wrapper + daemon + self-update.** `spt-term` (session-surface, PTY, broker) + the consolidated `spt-daemon` (broker/brain split) + peer-propagated signed self-update. Scope + carried-forward Spike #1 gaps: [`M3-PLAN.md`](./M3-PLAN.md) (stub).
34	- **M4 — Networking + instances.** `spt-net` (Iroh WAN, mDNS LAN, TOTP-SPAKE2 pairing, subnet registry) + the multi-instance model + cross-node Psyche sync (P2P, opt-in GitHub hub). **= the real proof: cross-node message + drive an agent on machine A from machine B.**
35	- **M5 — Shells, presence, deferred capabilities.** Shell adapters + sleep/wake, PresenceChannel, and the consent-gated capabilities (remote-exec, instantiate-anywhere) landing into the v1 consent seam.

- **M3 — Terminal wrapper + daemon + self-update.** `spt-term` (session-surface, PTY, broker) + the consolidated `spt-daemon` (broker/brain split) + peer-propagated signed self-update. Scope + carried-forward Spike #1 gaps: [`M3-PLAN.md`](./M3-PLAN.md) (stub).
- **M4 — Networking + instances.** `spt-net` (Iroh WAN, mDNS LAN, TOTP-SPAKE2 pairing, subnet registry) + the multi-instance model + cross-node Psyche sync (P2P, opt-in GitHub hub). **= the real proof: cross-node message + drive an agent on machine A from machine B.**
- **M5 — Shells, presence, deferred capabilities.** Shell adapters + sleep/wake, PresenceChannel, and the consent-gated capabilities (remote-exec, instantiate-anywhere) landing into the v1 consent seam.


Edit

- **M3 — Terminal wrapper + daemon + self-update.** `spt-term` (session-surface, PTY, broker) + the consolidated `spt-daemon` (broker/brain split) + peer-propagated signed self-update. Scope + carried-forward Spike #1 gaps: [`M3-PLAN.md`](./M3-PLAN.md) (stub).

- **M3 — Terminal wrapper + daemon + self-update.** `spt-term` (session-surface, PTY, broker) + the consolidated `spt-daemon` (broker/brain split) + peer-propagated signed self-update. Split M3a/M3b/M3c. Scope + carried-forward Spike #1 gaps: [`M3-PLAN.md`](./M3-PLAN.md).
  - **M3a — `spt-term` session-surface mechanism. ✅ delivered (2026-06-02).** The sixth crate (`…→spt-msg→spt-term`, sibling of spt-runtime): the `SessionSurface` trait + native PTY backends (ConPTY/forkpty via `portable-pty`, REQ-TERM-1) · ConPTY-DSR auto-answer drain pump (REQ-HAZARD-CONPTY-DSR) · send-keys/send-line injection (REQ-TERM-2) · bounded byte-stream (REQ-TERM-3) · the PTY-digest parser primitive (REQ-TERM-4 impl/unit, ADR-0008). OS-neutral per Spike #4/#5 (no OS's stream contract baked in; ANSI-stripped, tail-wins repaint tolerance). Validated on Windows (ConPTY) + Linux (forkpty). All M3a reqs activated + green. Task breakdown: [`M3a-PLAN.md`](./M3a-PLAN.md).
  - **M3b — `spt-daemon` broker/brain process** (NEXT). Hosts many `spt-term` surfaces behind versioned IPC: broker/brain split + daemon-authoritative liveness + idempotent boundary + auto-start, plus the digest's daemon half (manifest-sourcing, `spt digest` pull, delta-stream, Path-B persistence → REQ-TERM-4 `int`). Stub: [`M3b-PLAN.md`](./M3b-PLAN.md).
  - **M3c — peer-propagated signed self-update.**


The file C:\Users\decid\Documents\projects\spt-core\ROADMAP.md has been updated successfully. (file state is current in your context — no need to Read it back)

- **M3 — Terminal wrapper + daemon + self-update.** `spt-term` (session-surface, PTY, broker) + the consolidated `spt-daemon` (broker/brain split) + peer-propagated signed self-update. Split M3a/M3b/M3c. Scope + carried-forward Spike #1 gaps: [`M3-PLAN.md`](./M3-PLAN.md).
  - **M3a — `spt-term` session-surface mechanism. ✅ delivered (2026-06-02).** The sixth crate (`…→spt-msg→spt-term`, sibling of spt-runtime): the `SessionSurface` trait + native PTY backends (ConPTY/forkpty via `portable-pty`, REQ-TERM-1) · ConPTY-DSR auto-answer drain pump (REQ-HAZARD-CONPTY-DSR) · send-keys/send-line injection (REQ-TERM-2) · bounded byte-stream (REQ-TERM-3) · the PTY-digest parser primitive (REQ-TERM-4 impl/unit, ADR-0008). OS-neutral per Spike #4/#5 (no OS's stream contract baked in; ANSI-stripped, tail-wins repaint tolerance). Validated on Windows (ConPTY) + Linux (forkpty). All M3a reqs activated + green. Task breakdown: [`M3a-PLAN.md`](./M3a-PLAN.md).
  - **M3b — `spt-daemon` broker/brain process** (NEXT). Hosts many `spt-term` surfaces behind versioned IPC: broker/brain split + daemon-authoritative liveness + idempotent boundary + auto-start, plus the digest's daemon half (manifest-sourcing, `spt digest` pull, delta-stream, Path-B persistence → REQ-TERM-4 `int`). Stub: [`M3b-PLAN.md`](./M3b-PLAN.md).
  - **M3c — peer-propagated signed self-update.**

# spt-core Roadmap

> Lightweight-but-structured path from design → fruition. Not a GSD/phase-gated process — a clear sequence with de-risk gates. Planning corpus is complete (PRD + CONTEXT + 5 ADRs + 6 design docs); this is how we build it.

## End goal

A harness-independent core for an agent ecosystem — messaging, live-agent lifecycle, terminal hosting, **first-class P2P networking**, and a runtime-manifest harness contract — shipped as Rust crates + a single `spt` binary. The proof: the same agent runs natively across all of a user's machines with a synced mind, reachable and operable from anywhere, with seamless self-update and zero-config cross-machine messaging. First thing built atop it: a rebuilt `spt` plugin at parity with today's modern SPT.

## Scope decisions (locked)

- **v1 = the full PRD, networking included.** The networked element is the *reason* spt-core exists; it is not deferrable to v1.1.
- **The hard freeze on `claude_skill_owl` is not a clock.** It works today and delivers value as-is while spt-core is built — no schedule pressure from it.
- **Process: lightweight but structured.** GSD is too heavy. This roadmap + the design docs are the structure.

## Path to fruition (sequence)

**Stage A — Red-team review (before building).** Pressure-test the design adversarially. Focus on the riskiest architectural decisions, not bikeshedding. Tools: `plan-eng-review` (architecture) + `codex` (adversarial second opinion). Scope-cutting is *not* a goal (v1 is locked as full PRD), but surfacing contradictions / over-engineering / unproven assumptions is.

**Stage B — De-risk spikes.** Throwaway proofs of the load-bearing unknowns — anything that, if it fails, forces a redesign. Do these before committing to the full build. See *De-risk spikes* below.

**Stage C — Walking skeleton.** Build the killer-quickstart slice end-to-end through the real crate boundaries: **two agents exchange a message** (first local; ultimately cross-node — the real proof). Validates the whole stack early; then thicken.

**Stage D — Phased build (M0–M5).** Dependency-ordered, each milestone ships something usable.

## Milestones (all v1)

- **M0 — Workspace + commodity layer.** Cargo workspace; `spt-proto` (wire envelope grammar, endpoint types, framing, Ed25519 identity, typed+binary payloads) + `spt-store` (spool, perch layout, trust store, registry persistence). Copy-verbatim the stable formats from the sister project (ADR-0001).
- **M1 — Local messaging + binary.** `spt-msg` (delivery TCP+spool, routing, send/ring/ready) + the `spt` binary + CLI. **= the killer quickstart (local): two agents exchange a message.** Task breakdown: [`M1-PLAN.md`](./M1-PLAN.md).
- **M2 — Harness contract + lifecycle.** **Strictly harness-agnostic — zero Claude Code conventions in this repo.** spt-core defines the adapter contract; the only adapter-shaped artifact in-tree is a generic mock/test adapter (manifest fixture + fake `api` caller) that exercises the contract (PRD R-DOCS-2 dev-agent quickstart). **The rebuilt `spt` plugin (Claude Code) is a SEPARATE downstream project** built atop these crates/binary — spt-core v1's acceptance proof (PRD §"feature parity"), not an spt-core deliverable. It lives and builds outside this repo. **Split into two JIT milestones** (decision 2026-06-01):
  - **M2a — Harness contract. ✅ delivered (2026-06-01).** `spt-runtime` (AgentRuntime, ManifestRuntime, manifest schema) + the `api` subcommand surface (seed/listen/bind/state/echo-gate/poll/worker-*/boundary/session-end/presence/history-log/emit/capability) + the **harness-hosted** startup topology (seed→listen→bind, interim seed-file) + local-api-auth + the generic mock adapter + contract E2E. All M2a reqs activated + green. Task breakdown: [`M2a-PLAN.md`](./M2a-PLAN.md).
  - **M2b — Live-agent lifecycle. ✅ delivered (2026-06-01).** `spt-live` (fifth layer): history subsystem (fetcher/locate-normalize/native) + spawn-psyche (nested perch, spt-core-owned `$psyche_prompt`) + echo-commune (history brief → single-writer commune drop) + drop-file ingest (single-writer 6.4 + direct-write precedence 6.5) + interim pulse + signoff/boundary ordering invariants (echo-before-signoff 3.3, grace-before-signoff 1.1, stale-signoff sweep 3.2) + resume seam (continue-existing / fresh-with-preload). Hosted by the `api listen` runner + `api shutdown`; mock adapter gained the lifecycle templates; LiveAgent E2E proves spawn→Psyche-perch→commune-ingest→graceful-signoff harness-agnostically. All M2b reqs activated + green. Task breakdown: [`M2b-PLAN.md`](./M2b-PLAN.md).
  - **Interim, no daemon** (like M1): the `api listen` process holds the perch + relays; the seed bridges via a file. **The spt-hosted topology (PTY-launch) + the consolidated `spt-daemon` move to M3** (they need the `spt-term` broker) — this narrows the earlier "both topologies in M2" wording.
- **M3 — Terminal wrapper + daemon + self-update.** `spt-term` (session-surface, PTY, broker) + the consolidated `spt-daemon` (broker/brain split) + peer-propagated signed self-update. Scope + carried-forward Spike #1 gaps: [`M3-PLAN.md`](./M3-PLAN.md) (stub).
- **M4 — Networking + instances.** `spt-net` (Iroh WAN, mDNS LAN, TOTP-SPAKE2 pairing, subnet registry) + the multi-instance model + cross-node Psyche sync (P2P, opt-in GitHub hub). **= the real proof: cross-node message + drive an agent on machine A from machine B.**
- **M5 — Shells, presence, deferred capabilities.** Shell adapters + sleep/wake, PresenceChannel, and the consent-gated capabilities (remote-exec, instantiate-anywhere) landing into the v1 consent seam.

## De-risk spikes (do early — these can invalidate the architecture)

1. **Broker/brain no-terminate handoff** — prove a PTY child + open socket survive a daemon-*logic* restart (FD-passing or stable-broker shim). The hardest invariant in the design (ADR-0004).
2. **Iroh + mDNS smoke test** — two nodes, separate NATs, one message end-to-end; measure direct-vs-relay + binary size (research brief §5). Validates the whole networking premise.
3. **ConPTY + `portable-pty`** — host and drive a real session in a broker PTY on Windows + Linux.
4. **Manifest-driven lifecycle** — a manifest drives one real Claude Code session through spawn → bind → commune → signoff, proving harness-independence works in practice.

## Immediate next steps

Progress (2026-05-31):
- ✅ **Stage A — codex adversarial pass** — done. 14 findings (5 FATAL, 9 SERIOUS) in `docs/reviews/STAGE-A-codex-redteam.md`.
- ✅ **Spike #1 (broker/brain handoff)** — PASS on Windows ConPTY. `docs/spikes/SPIKE-01-broker-handoff.md`. Found+fixed the ConPTY DSR-stall hazard. 4 open gaps carried to M3.
- ✅ **Spike #2 (Iroh smoke test)** — PASS (single-host). `docs/spikes/SPIKE-02-iroh-smoke.md`. iroh 0.98 API workable; 13.57 MB release. Cross-NAT/relay proof deferred to M4.
- ✅ **Findings folded** into ADR-0002/0003/0004/0005 + KNOWN-HAZARDS (+3) + traceable-reqs.toml (+4 REQ-HAZARD).

Remaining before M0:
1. **Stage A — `plan-eng-review`** (interactive architecture pass; second half of Stage A).
2. **Spike #4 (manifest-driven lifecycle)** — a manifest drives one real session spawn→bind→commune→signoff. (Spike #3 ConPTY hosting is largely covered by Spike #1.)
3. Resolve the open user decisions logged in the ADR amendments (context-merge model, seed-rotation, R-UPD-3 rewording).
4. **M0** (workspace + `spt-proto` + `spt-store`) — **started**; task breakdown in [`M0-PLAN.md`](./M0-PLAN.md). Each milestone gets its own just-in-time plan (this one is M0); the roadmap stays the milestone sequence, the JIT plan is the task layer.

## Tooling: requirement traceability (adopted)

`traceable-reqs` is adopted as the requirement-tracing gate. `traceable-reqs.toml` seeds the `REQ-*` registry from PRD `R-*` + KNOWN-HAZARDS invariants (all inactive until milestone activation). The development contract + enforcement (CI gate, milestone activation, agent rule, quality audit) is `docs/TRACEABILITY.md`; the agent rule lives in `CLAUDE.md`. **M0 task:** install the CLI, validate the seed, wire the CI workflow, activate M0 reqs.

## Open threads carried forward

- New deferred items logged this session (`docs/DEFERRED.md`): block-level OCC context merge; the rebalance / Memory-Health pass; the consent framework's gated capabilities.
- The dual-audience docs strategy (`docs/DOCS-STRATEGY.md`) governs the *shipped* docs, authored as code lands.

-- **M3 — Terminal wrapper + daemon + self-update.** `spt-term` (session-surface, PTY, broker) + the consolidated `spt-daemon` (broker/brain split) + peer-propagated signed self-update. Scope + carried-forward Spike #1 gaps: [`M3-PLAN.md`](./M3-PLAN.md) (stub).

+- **M3 — Terminal wrapper + daemon + self-update.** `spt-term` (session-surface, PTY, broker) + the consolidated `spt-daemon` (broker/brain split) + peer-propagated signed self-update. Split M3a/M3b/M3c. Scope + carried-forward Spike #1 gaps: [`M3-PLAN.md`](./M3-PLAN.md).

+  - **M3a — `spt-term` session-surface mechanism. ✅ delivered (2026-06-02).** The sixth crate (`…→spt-msg→spt-term`, sibling of spt-runtime): the `SessionSurface` trait + native PTY backends (ConPTY/forkpty via `portable-pty`, REQ-TERM-1) · ConPTY-DSR auto-answer drain pump (REQ-HAZARD-CONPTY-DSR) · send-keys/send-line injection (REQ-TERM-2) · bounded byte-stream (REQ-TERM-3) · the PTY-digest parser primitive (REQ-TERM-4 impl/unit, ADR-0008). OS-neutral per Spike #4/#5 (no OS's stream contract baked in; ANSI-stripped, tail-wins repaint tolerance). Validated on Windows (ConPTY) + Linux (forkpty). All M3a reqs activated + green. Task breakdown: [`M3a-PLAN.md`](./M3a-PLAN.md).

+  - **M3b — `spt-daemon` broker/brain process** (NEXT). Hosts many `spt-term` surfaces behind versioned IPC: broker/brain split + daemon-authoritative liveness + idempotent boundary + auto-start, plus the digest's daemon half (manifest-sourcing, `spt digest` pull, delta-stream, Path-B persistence → REQ-TERM-4 `int`). Stub: [`M3b-PLAN.md`](./M3b-PLAN.md).

+  - **M3c — peer-propagated signed self-update.**


Now the M3b-PLAN.md stub. Read M3-PLAN.md for the M3b scope already sketched.


Bash

grep -n "M3b\|M3c\|broker\|brain\|daemon\|IPC\|liveness\|idempotent\|auto-start\|REQ-DAEMON\|REQ-START\|gap" M3-PLAN.md | head -50

M3b scope in M3-PLAN


1:# M3 Plan — Terminal wrapper + daemon + self-update
10:> undifferentiated task list would bury the broker/brain boundary the whole
12:> - **Phase 0 — spike-gate** (BLOCKING): close ADR-0004 §E before building the daemon.
13:> - **M3a — `spt-term`**: the broker's PTY/session-surface half (REQ-TERM-*).
14:> - **M3b — `spt-daemon`**: the broker/brain split + consolidation; re-hosts the
15:>   M2b lifecycle (REQ-DAEMON-*, REQ-START-3).
16:> - **M3c — self-update**: the gated, signed, ripple update engine (REQ-UPD-*,
21:Replace the interim no-daemon model with the **real architecture** (ADR-0004):
22:one per-machine `spt-daemon` (broker/brain split) that owns all per-machine state
23:and re-hosts the M2b lifecycle as in-process loops with daemon-authoritative
24:liveness; a `spt-term` broker hosting PTYs behind a versioned local IPC; and a
25:**seamless self-update** that swaps the brain without any endpoint noticing. This
26:is where the two surviving FATALs land (ADR-0004 daemon, the broker↔brain
29:**M3 done =** the daemon hosts the LiveAgent lifecycle (Psyche-as-loop, no
31:ConPTY-DSR hazard closed; a brain-only self-update swaps the brain with **zero**
43:| `api listen` process holds perch + relays | daemon-hosted loop + thin killable relay (topology 1) | REQ-DAEMON-1, ADR-0004 |
44:| seed-file bridge (`spt-store::seed`) | in-memory seed; spt-hosted spawn→bind (no file) | REQ-START-3 |
45:| interim pulse in the listen runner (`spt-live::pulse`) | daemon Psyche-loop with durable, configurable period | REQ-DAEMON-1 |
46:| per-pid Psyche liveness (`spt-live::psyche`) | daemon-authoritative `status` field | REQ-HAZARD-DAEMON-HOSTED-LIVENESS (2.5) |
54:> All four ADR-0004 §E gaps closed **PASS**: Spike #3 (QUIC survival), #4 (forkpty
55:> parity), #5 (100× restart + resize), #6 (idempotent boundary). Docs in
56:> `docs/spikes/SPIKE-0{3,4,5,6}-*.md`; ADR-0004 §E updated. M3a + M3b unblocked.
58:ADR-0004 §E: four gaps **must close before M3 builds the daemon**. Spikes live in
61:- **Spike #3 — QUIC + file-transfer stream survival across brain restart**
65:  broker, restart the brain, prove the stream survives. **De-risks the broker's
66:  ownership table (ADR-0004 §B) before the daemon is built around it.** Uses iroh
69:  ConPTY path). Rerun the broker handoff on Linux `forkpty`.
71:  restart; harden before trusting the broker as the stable kernel.
72:- **Spike #6 — idempotent delivery across the broker↔brain boundary** (#14):
76:**Gate rule:** M3b does not start until #3 and #6 return a verdict. #4/#5 may run
78:before any daemon code (cheaper than discovering it in the build).
80:### M3a — `spt-term` (the broker's PTY / session-surface half)
84:> manifest-sourcing + `spt digest` + delta-stream + persistence are M3b).
87:- the **process-supervisor terminal wrapper** hosting broker PTYs (`REQ-TERM-1`)
98:**Out:** the broker *process* + IPC (that's M3b — `spt-term` provides the PTY
99:*mechanism* the broker hosts); off-node terminal transport (M4).
101:### M3b — `spt-daemon` (broker/brain split + consolidation)
103:**In:** the `spt-daemon` crate (above `spt-live`, below `spt`):
104:- **broker** (stable kernel) — holds only un-transferable resources per the
107:  **versioned local IPC** (`REQ-HAZARD-HANDOFF-ARGV-COMPAT` / 2.3 — newer brain ↔
108:  older broker).
109:- **brain** (userspace) — all logic: routing, registry, **the re-hosted M2b
112:  broker-held handles. `gen_start = now()` on cold-start **and** handoff
114:- **consolidation** (`REQ-DAEMON-1`) — poll-listener + Psyche/pulse loops move
115:  into the daemon; the only residue is the thin **stateless in-session relay**
117:- **daemon-authoritative liveness** (`REQ-HAZARD-DAEMON-HOSTED-LIVENESS` / 2.5) —
118:  the daemon's `status` field supersedes `is_process_alive(info.pid)`; the M2b
120:- **idempotent delivery** (`REQ-HAZARD-RESTART-IDEMPOTENT` / 7.2) — durable IDs +
121:  replay rules at every broker↔brain side-effect boundary (from Spike #6).
122:- **auto-start** (`REQ-DAEMON-3`) — any `api` invocation auto-starts the daemon


101	### M3b — `spt-daemon` (broker/brain split + consolidation)
102
103	**In:** the `spt-daemon` crate (above `spt-live`, below `spt`):
104	- **broker** (stable kernel) — holds only un-transferable resources per the
105	  ADR-0004 §B ownership table: PTY master handles (`spt-term`), spawned harness
106	  child processes, accepted local client sockets, listening sockets. Minimal
107	  **versioned local IPC** (`REQ-HAZARD-HANDOFF-ARGV-COMPAT` / 2.3 — newer brain ↔
108	  older broker).
109	- **brain** (userspace) — all logic: routing, registry, **the re-hosted M2b
110	  lifecycle** (Psyche-as-loop + the durable pulse period replacing the interim
111	  glue), manifest parse. Restarts freely; rehydrates from disk and re-attaches to
112	  broker-held handles. `gen_start = now()` on cold-start **and** handoff
113	  (`REQ-HAZARD-GEN-START-NOW` / 2.4).
114	- **consolidation** (`REQ-DAEMON-1`) — poll-listener + Psyche/pulse loops move
115	  into the daemon; the only residue is the thin **stateless in-session relay**
116	  for harness-hosted sessions (topology 1), freely killable.
117	- **daemon-authoritative liveness** (`REQ-HAZARD-DAEMON-HOSTED-LIVENESS` / 2.5) —
118	  the daemon's `status` field supersedes `is_process_alive(info.pid)`; the M2b
119	  per-pid checks gated behind the interim seam become the swap.
120	- **idempotent delivery** (`REQ-HAZARD-RESTART-IDEMPOTENT` / 7.2) — durable IDs +
121	  replay rules at every broker↔brain side-effect boundary (from Spike #6).
122	- **auto-start** (`REQ-DAEMON-3`) — any `api` invocation auto-starts the daemon
123	  if absent.
124	- **spt-hosted startup** (`REQ-START-3`) — spawn-session then `api bind`, in-memory
125	  seed, no seed file (swaps the M2a interim `spt-store::seed`).
126	- **orphan-watch** — carry the M2b graceful ordering (grace 1.1 / echo 3.3 /
127	  stale-sweep 3.2) into the **supervised crash** path the always-on daemon now
128	  enables (M2b delivered graceful-only).
129
130	**Out:** networking/P2P (M4); the QUIC-stream broker ownership *implementation*
131	(M4 — but its *shape* validated by Spike #3 now).
132
133	### M3c — self-update (gated, signed, ripple)
134
135	**In:**
136	- the **update-class taxonomy** (ADR-0004 §A): **brain-only** (no endpoint
137	  terminates — `REQ-UPD-3`, absolute, the routine case, proven E2E),
138	  **broker-compatible** (hot-swap behind the versioned IPC), **broker-breaking**
139	  (planned endpoint-cycle, may suspend with consent + scheduling);
140	- **signature verification before handoff** (`REQ-UPD-2`) — spt-core's release
141	  key, on every binary regardless of source;
142	- **update hardening** (`REQ-HAZARD-UPDATE-ROLLBACK`, ADR-0004 §D) — monotonic
143	  version (rollback rejection), release-metadata expiry, channel pinning, key
144	  rotation/revocation list, **adapter content signing**;
145	- **consent gating** (`REQ-UPD-4`) — gated on user confirmation delivered to the

### M3b — `spt-daemon` (broker/brain split + consolidation)

**In:** the `spt-daemon` crate (above `spt-live`, below `spt`):
- **broker** (stable kernel) — holds only un-transferable resources per the
  ADR-0004 §B ownership table: PTY master handles (`spt-term`), spawned harness
  child processes, accepted local client sockets, listening sockets. Minimal
  **versioned local IPC** (`REQ-HAZARD-HANDOFF-ARGV-COMPAT` / 2.3 — newer brain ↔
  older broker).
- **brain** (userspace) — all logic: routing, registry, **the re-hosted M2b
  lifecycle** (Psyche-as-loop + the durable pulse period replacing the interim
  glue), manifest parse. Restarts freely; rehydrates from disk and re-attaches to
  broker-held handles. `gen_start = now()` on cold-start **and** handoff
  (`REQ-HAZARD-GEN-START-NOW` / 2.4).
- **consolidation** (`REQ-DAEMON-1`) — poll-listener + Psyche/pulse loops move
  into the daemon; the only residue is the thin **stateless in-session relay**
  for harness-hosted sessions (topology 1), freely killable.
- **daemon-authoritative liveness** (`REQ-HAZARD-DAEMON-HOSTED-LIVENESS` / 2.5) —
  the daemon's `status` field supersedes `is_process_alive(info.pid)`; the M2b
  per-pid checks gated behind the interim seam become the swap.
- **idempotent delivery** (`REQ-HAZARD-RESTART-IDEMPOTENT` / 7.2) — durable IDs +
  replay rules at every broker↔brain side-effect boundary (from Spike #6).
- **auto-start** (`REQ-DAEMON-3`) — any `api` invocation auto-starts the daemon
  if absent.
- **spt-hosted startup** (`REQ-START-3`) — spawn-session then `api bind`, in-memory
  seed, no seed file (swaps the M2a interim `spt-store::seed`).
- **orphan-watch** — carry the M2b graceful ordering (grace 1.1 / echo 3.3 /
  stale-sweep 3.2) into the **supervised crash** path the always-on daemon now
  enables (M2b delivered graceful-only).

**Out:** networking/P2P (M4); the QUIC-stream broker ownership *implementation*
(M4 — but its *shape* validated by Spike #3 now).

### M3c — self-update (gated, signed, ripple)

**In:**
- the **update-class taxonomy** (ADR-0004 §A): **brain-only** (no endpoint
  terminates — `REQ-UPD-3`, absolute, the routine case, proven E2E),
  **broker-compatible** (hot-swap behind the versioned IPC), **broker-breaking**
  (planned endpoint-cycle, may suspend with consent + scheduling);
- **signature verification before handoff** (`REQ-UPD-2`) — spt-core's release
  key, on every binary regardless of source;
- **update hardening** (`REQ-HAZARD-UPDATE-ROLLBACK`, ADR-0004 §D) — monotonic
  version (rollback rejection), release-metadata expiry, channel pinning, key
  rotation/revocation list, **adapter content signing**;
- **consent gating** (`REQ-UPD-4`) — gated on user confirmation delivered to the


M3-PLAN already details M3b. Author a just-in-time M3b-PLAN.md stub consistent with the M3a header style.


Write

# M3b Plan — `spt-daemon` broker/brain process (stub)

> **Just-in-time, lightweight** — same pattern as `M0/M1/M2a/M2b/M3a-PLAN.md`. The
> ordered task layer between `ROADMAP.md`, `M3-PLAN.md` (the M3 umbrella), and
> `traceable-reqs.toml`, scoped to **M3b only**. Branch: `dev-freeform`. This is a
> **stub** — the full task table is authored when M3b starts (after M3a, now
> delivered). Authoritative scope lives in [`M3-PLAN.md`](./M3-PLAN.md) §M3b.

> **Upstream is done:** Phase 0 spike-gate ✅ (Spikes #3/#4/#5/#6 PASS) and **M3a
> ✅ delivered** — `spt-term` provides the single-surface PTY *mechanism*
> (SessionSurface + ConPTY/forkpty backend + DSR drain + injection + bounded
> byte-stream + digest parser primitive). **M3b builds the *process* that hosts
> many of those surfaces** behind versioned IPC.

## Goal

Replace the interim no-daemon model (M1/M2a/M2b: the `api listen` process holds
the perch + relays, seed bridges via a file) with the **real architecture**
(ADR-0004): one per-machine `spt-daemon` with a **broker/brain split** that owns
all per-machine state and re-hosts the M2b lifecycle as in-process loops with
**daemon-authoritative liveness**, hosting `spt-term` surfaces behind a versioned
local IPC, with an **idempotent** broker↔brain boundary and **auto-start**.

**M3b done =** the daemon hosts the LiveAgent lifecycle (Psyche-as-loop, no
interim glue); the broker hosts `spt-term` PTYs behind versioned IPC; liveness is
the daemon's `status` field (not `is_process_alive(pid)`); every broker↔brain
side-effect boundary is idempotent (Spike #6); any `api` call auto-starts the
daemon; `cargo test --workspace` + clippy `-D warnings` + `traceable-reqs check`
green on the CI matrix.

## Scope (authoritative detail: `M3-PLAN.md` §M3b)

### In — the `spt-daemon` crate (above `spt-live`, below `spt`)

- **broker** (stable kernel) — owns only un-transferable resources per the
  ADR-0004 §B table: PTY master handles (**from `spt-term`**), spawned harness
  children, accepted local client sockets, listening sockets. Minimal **versioned
  local IPC** (`REQ-HAZARD-HANDOFF-ARGV-COMPAT` / 2.3).
- **brain** (userspace) — routing, registry, the re-hosted M2b lifecycle
  (Psyche-as-loop + durable pulse period), manifest parse. Restarts freely;
  rehydrates from disk + re-attaches to broker-held handles. `gen_start = now()`
  on cold-start **and** handoff (`REQ-HAZARD-GEN-START-NOW` / 2.4).
- **consolidation** (`REQ-DAEMON-1`) — poll-listener + Psyche/pulse loops move
  into the daemon; only the thin stateless in-session relay remains (topology 1).
- **daemon-authoritative liveness** (`REQ-HAZARD-DAEMON-HOSTED-LIVENESS` / 2.5).
- **idempotent delivery** (`REQ-HAZARD-RESTART-IDEMPOTENT` / 7.2 — Spike #6).
- **auto-start** (`REQ-DAEMON-3`) + **spt-hosted startup** (`REQ-START-3`: spawn →
  `api bind`, in-memory seed, no seed file) + **orphan-watch** (carry the M2b
  grace 1.1 / echo 3.3 / stale-sweep 3.2 ordering into the supervised-crash path).
- **digest daemon-half** — the M3b completion of REQ-TERM-4: manifest-sourcing of
  the `input/agent/tool` patterns, `spt digest <[subnet:]id[@node]>` snapshot
  pull, the broker-pushed structured **delta-stream** (riding the R-TERM-3
  byte-stream substrate), and opt-in Path-B persistence. **Activates REQ-TERM-4
  `int`** over the M3a parser primitive.

### Out

- networking / P2P + multi-subnet + notifications (M4); the QUIC-stream broker
  ownership *implementation* (M4 — shape validated by Spike #3); self-update (M3c).

## Requirements to activate (currently `required_stages = []`)

`REQ-DAEMON-1/3`, `REQ-START-3`, `REQ-HAZARD-DAEMON-HOSTED-LIVENESS`,
`REQ-HAZARD-RESTART-IDEMPOTENT`, `REQ-HAZARD-HANDOFF-ARGV-COMPAT`,
`REQ-HAZARD-GEN-START-NOW`, and **REQ-TERM-4 `int`** (the digest daemon-half).
Activate each as its task lands (TRACEABILITY rule 5). New requirements →
register in `traceable-reqs.toml` first (rule 3).

## Workspace change

Add `crates/spt-daemon` (sixth-ish layer, above `spt-live`, below `spt`):

```
spt-proto → spt-store → spt-msg → {spt-net, spt-term, spt-runtime} → spt-live → spt-daemon → spt
```

## Carried constraints

- **Versioned IPC = newer brain ↔ older broker** (`REQ-HAZARD-HANDOFF-ARGV-COMPAT`):
  the broker is the stable kernel; never break its handoff contract.
- **`spt-term` is single-surface per handle** — the daemon supplies the
  multiplexed, name-addressable, attach/detach supervisor *around* it. Do not push
  fleet/registry concerns back down into `spt-term`.
- **OS stream-neutrality (Spike #4/#5)** continues to hold through the IPC: the
  delta-stream is a structured overlay on the same OS-neutral byte stream.


File created successfully at: C:\Users\decid\Documents\projects\spt-core\M3b-PLAN.md (file state is current in your context — no need to Read it back)

# M3b Plan — `spt-daemon` broker/brain process (stub)

> **Just-in-time, lightweight** — same pattern as `M0/M1/M2a/M2b/M3a-PLAN.md`. The
> ordered task layer between `ROADMAP.md`, `M3-PLAN.md` (the M3 umbrella), and
> `traceable-reqs.toml`, scoped to **M3b only**. Branch: `dev-freeform`. This is a
> **stub** — the full task table is authored when M3b starts (after M3a, now
> delivered). Authoritative scope lives in [`M3-PLAN.md`](./M3-PLAN.md) §M3b.

> **Upstream is done:** Phase 0 spike-gate ✅ (Spikes #3/#4/#5/#6 PASS) and **M3a
> ✅ delivered** — `spt-term` provides the single-surface PTY *mechanism*
> (SessionSurface + ConPTY/forkpty backend + DSR drain + injection + bounded
> byte-stream + digest parser primitive). **M3b builds the *process* that hosts
> many of those surfaces** behind versioned IPC.

## Goal

Replace the interim no-daemon model (M1/M2a/M2b: the `api listen` process holds
the perch + relays, seed bridges via a file) with the **real architecture**
(ADR-0004): one per-machine `spt-daemon` with a **broker/brain split** that owns
all per-machine state and re-hosts the M2b lifecycle as in-process loops with
**daemon-authoritative liveness**, hosting `spt-term` surfaces behind a versioned
local IPC, with an **idempotent** broker↔brain boundary and **auto-start**.

**M3b done =** the daemon hosts the LiveAgent lifecycle (Psyche-as-loop, no
interim glue); the broker hosts `spt-term` PTYs behind versioned IPC; liveness is
the daemon's `status` field (not `is_process_alive(pid)`); every broker↔brain
side-effect boundary is idempotent (Spike #6); any `api` call auto-starts the
daemon; `cargo test --workspace` + clippy `-D warnings` + `traceable-reqs check`
green on the CI matrix.

## Scope (authoritative detail: `M3-PLAN.md` §M3b)

### In — the `spt-daemon` crate (above `spt-live`, below `spt`)

- **broker** (stable kernel) — owns only un-transferable resources per the
  ADR-0004 §B table: PTY master handles (**from `spt-term`**), spawned harness
  children, accepted local client sockets, listening sockets. Minimal **versioned
  local IPC** (`REQ-HAZARD-HANDOFF-ARGV-COMPAT` / 2.3).
- **brain** (userspace) — routing, registry, the re-hosted M2b lifecycle
  (Psyche-as-loop + durable pulse period), manifest parse. Restarts freely;
  rehydrates from disk + re-attaches to broker-held handles. `gen_start = now()`
  on cold-start **and** handoff (`REQ-HAZARD-GEN-START-NOW` / 2.4).
- **consolidation** (`REQ-DAEMON-1`) — poll-listener + Psyche/pulse loops move
  into the daemon; only the thin stateless in-session relay remains (topology 1).
- **daemon-authoritative liveness** (`REQ-HAZARD-DAEMON-HOSTED-LIVENESS` / 2.5).
- **idempotent delivery** (`REQ-HAZARD-RESTART-IDEMPOTENT` / 7.2 — Spike #6).
- **auto-start** (`REQ-DAEMON-3`) + **spt-hosted startup** (`REQ-START-3`: spawn →
  `api bind`, in-memory seed, no seed file) + **orphan-watch** (carry the M2b
  grace 1.1 / echo 3.3 / stale-sweep 3.2 ordering into the supervised-crash path).
- **digest daemon-half** — the M3b completion of REQ-TERM-4: manifest-sourcing of
  the `input/agent/tool` patterns, `spt digest <[subnet:]id[@node]>` snapshot
  pull, the broker-pushed structured **delta-stream** (riding the R-TERM-3
  byte-stream substrate), and opt-in Path-B persistence. **Activates REQ-TERM-4
  `int`** over the M3a parser primitive.

### Out

- networking / P2P + multi-subnet + notifications (M4); the QUIC-stream broker
  ownership *implementation* (M4 — shape validated by Spike #3); self-update (M3c).

## Requirements to activate (currently `required_stages = []`)

`REQ-DAEMON-1/3`, `REQ-START-3`, `REQ-HAZARD-DAEMON-HOSTED-LIVENESS`,
`REQ-HAZARD-RESTART-IDEMPOTENT`, `REQ-HAZARD-HANDOFF-ARGV-COMPAT`,
`REQ-HAZARD-GEN-START-NOW`, and **REQ-TERM-4 `int`** (the digest daemon-half).
Activate each as its task lands (TRACEABILITY rule 5). New requirements →
register in `traceable-reqs.toml` first (rule 3).

## Workspace change

Add `crates/spt-daemon` (sixth-ish layer, above `spt-live`, below `spt`):

```
spt-proto → spt-store → spt-msg → {spt-net, spt-term, spt-runtime} → spt-live → spt-daemon → spt
```

## Carried constraints

- **Versioned IPC = newer brain ↔ older broker** (`REQ-HAZARD-HANDOFF-ARGV-COMPAT`):
  the broker is the stable kernel; never break its handoff contract.
- **`spt-term` is single-surface per handle** — the daemon supplies the
  multiplexed, name-addressable, attach/detach supervisor *around* it. Do not push
  fleet/registry concerns back down into `spt-term`.
- **OS stream-neutrality (Spike #4/#5)** continues to hold through the IPC: the
  delta-stream is a structured overlay on the same OS-neutral byte stream.


Update CONTEXT.md where it describes the session surface / spt-term to reflect M3a delivered.


Bash

grep -n "spt-term\|session surface\|session-surface\|SessionSurface\|broker PTY\|ConPTY\|forkpty\|byte-stream\|byte stream" CONTEXT.md | head -25

Find session-surface refs in CONTEXT


152:**spt-hosted (terminal wrapper / GUI launcher; the daemon launches the binary into a broker PTY):**
256: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.
261:**session surface** (library abstraction):
262:The trait spt-core exposes over "anything you can write input to and read output from." Native PTY (ConPTY on Windows 10+, `forkpty(3)` on Unix, via the `portable-pty` crate) is the day-one implementation. The abstraction generalizes so future implementations — network-attached surfaces (for `spt-node` remote control), GUI text panes, etc. — are uniform to consumers. Pre-Windows-10 (winpty) is explicitly dropped.
265:Two granularities, both first-class. `send-keys` — raw byte stream including escape sequences (e.g. `Ctrl-C`). `send-line` — cooked line-level input flushed on newline (the common case; matches how user input reaches an agent prompt).
274:- **Broker-PTY-dependent → spt-hosted-primary.** Tapping the live byte stream needs the **broker-owned PTY** (topology 2). **Harness-hosted (topology 1) is capability-gated:** an adapter that can feed spt-core the stream (e.g. a `pi`-style harness) may opt in via a declared capability; harnesses that own their process tree opaquely (Claude Code) cannot, and the digest is simply unavailable there. Lands with M3 (spt-term broker).
275:- **Two access modes over one transport.** The digest is a *parsed projection of the same PTY stream the broker already owns for byte-stream remote terminal (R-TERM-3)*, so it is offered both ways: **snapshot pull** — `spt digest <[subnet:]id[@node]>` returns the current structured buffer (a Shell/CLI/GUI on-demand render; feeds the frontend's "latest session output" pane) — and a **broker-pushed structured-delta stream** — a subscriber (live Shell pane / GUI) receives only *changes* (new turn, new tool entry, collapse update), not the whole buffer, for resource-efficient near-realtime reflection. The delta-stream is a **structured overlay riding the same R-TERM-3 streaming substrate** as the raw byte-stream (raw bytes vs parsed digest = two channels on the one session surface). **Access is address-gated** — fetch/subscribe is allowed for anyone who can address the endpoint (visible + resolvable per the resolution policy), the same gate as messaging.
371:- **Remote-control (Shell-like):** attach a control/view surface to an instance *running on another node* — compute + files stay remote; you are a viewport (the byte-stream terminal attach, daemon-to-daemon over Iroh). Used when you specifically want *that machine's* environment. This is effectively a Shell (a driven surface, user→agent direction), separate from the instance concept itself.
405:- **detect** that it is being driven remotely (and from which node) — observable from the `spt-daemon`, which knows whether the driving session-surface stream is local or network-attached; (v1)
411:- **In v1:** the endpoint-ID / instance data-model split; the subnet registry; resolution policy; remote-drive of *already-running* instances (network session-surface); cross-node Psyche sync (full — replaces gh-repo-sync); cross-instance context-freshness auto-feed; off-node file transfer; off-node remote-drive detection.
419:Layering (bottom → top): `spt-proto` (wire envelope grammar, endpoint types, message framing, Ed25519 identity types, typed+binary payload schema — pure types, no I/O) → `spt-store` (spool, perch layout, trust store, registry persistence) → `spt-msg` (delivery TCP+spool-fallback, routing, send/ring) → `spt-net` (Iroh, mDNS, SPAKE2/TOTP pairing, subnet-registry distribution; `net` feature-flagged) and `spt-term` (session-surface trait, PTY impl, broker) and `spt-runtime` (`AgentRuntime` trait, `ManifestRuntime`, manifest schema) → `spt-live` (Psyche/pulse/commune/signoff) → `spt-daemon` (the brain: ties all, hosts broker, update orchestration, instance registry) → `spt` (the `spt.exe` binary; thin CLI).
421:**public SDK surface** (semver-committed): `spt-proto` (the wire contract — anyone speaking SPT depends on it), `spt-runtime` (the trait third-party harnesses implement), `spt-msg` (the deeper messaging-integration path). Everything else (`spt-store`, `spt-net`, `spt-term`, `spt-live`, `spt-daemon`) is internal/unstable pre-1.0 — reachable, but no stability promise. The *integration* crates are stable; the *machinery* churns.


254	## Terminal wrapper
255
256	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.
257
258	**term-daemon** (process supervisor):
259	A long-lived, one-per-machine daemon that owns the PTYs for all hosted sessions, multiplexes them (sessions addressable by name, tmux-style), and accepts attach/detach from frontends. A hosted agent session (a LiveAgent's Self, a ReadyAgent, etc.) runs *inside* a supervised PTY. **Headed** = a frontend is attached and rendering/driving the PTY; **headless** = the session runs with no frontend attached. The daemon's persistence is what lets a session keep running while unwatched and be re-attached "on a whim."
260
261	**session surface** (library abstraction):
262	The trait spt-core exposes over "anything you can write input to and read output from." Native PTY (ConPTY on Windows 10+, `forkpty(3)` on Unix, via the `portable-pty` crate) is the day-one implementation. The abstraction generalizes so future implementations — network-attached surfaces (for `spt-node` remote control), GUI text panes, etc. — are uniform to consumers. Pre-Windows-10 (winpty) is explicitly dropped.
263
264	**input injection**:
265	Two granularities, both first-class. `send-keys` — raw byte stream including escape sequences (e.g. `Ctrl-C`). `send-line` — cooked line-level input flushed on newline (the common case; matches how user input reaches an agent prompt).
266
267	**scrollback**:

## Terminal wrapper

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.

**term-daemon** (process supervisor):
A long-lived, one-per-machine daemon that owns the PTYs for all hosted sessions, multiplexes them (sessions addressable by name, tmux-style), and accepts attach/detach from frontends. A hosted agent session (a LiveAgent's Self, a ReadyAgent, etc.) runs *inside* a supervised PTY. **Headed** = a frontend is attached and rendering/driving the PTY; **headless** = the session runs with no frontend attached. The daemon's persistence is what lets a session keep running while unwatched and be re-attached "on a whim."

**session surface** (library abstraction):
The trait spt-core exposes over "anything you can write input to and read output from." Native PTY (ConPTY on Windows 10+, `forkpty(3)` on Unix, via the `portable-pty` crate) is the day-one implementation. The abstraction generalizes so future implementations — network-attached surfaces (for `spt-node` remote control), GUI text panes, etc. — are uniform to consumers. Pre-Windows-10 (winpty) is explicitly dropped.

**input injection**:
Two granularities, both first-class. `send-keys` — raw byte stream including escape sequences (e.g. `Ctrl-C`). `send-line` — cooked line-level input flushed on newline (the common case; matches how user input reaches an agent prompt).

**scrollback**:


Edit

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.

**term-daemon** (process supervisor):
A long-lived, one-per-machine daemon that owns the PTYs for all hosted sessions, multiplexes them (sessions addressable by name, tmux-style), and accepts attach/detach from frontends. A hosted agent session (a LiveAgent's Self, a ReadyAgent, etc.) runs *inside* a supervised PTY. **Headed** = a frontend is attached and rendering/driving the PTY; **headless** = the session runs with no frontend attached. The daemon's persistence is what lets a session keep running while unwatched and be re-attached "on a whim."

**session surface** (library abstraction):
The trait spt-core exposes over "anything you can write input to and read output from." Native PTY (ConPTY on Windows 10+, `forkpty(3)` on Unix, via the `portable-pty` crate) is the day-one implementation. The abstraction generalizes so future implementations — network-attached surfaces (for `spt-node` remote control), GUI text panes, etc. — are uniform to consumers. Pre-Windows-10 (winpty) is explicitly dropped.

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 M3b (`spt-daemon`).**

**term-daemon** (process supervisor):
A long-lived, one-per-machine daemon that owns the PTYs for all hosted sessions, multiplexes them (sessions addressable by name, tmux-style), and accepts attach/detach from frontends. A hosted agent session (a LiveAgent's Self, a ReadyAgent, etc.) runs *inside* a supervised PTY. **Headed** = a frontend is attached and rendering/driving the PTY; **headless** = the session runs with no frontend attached. The daemon's persistence is what lets a session keep running while unwatched and be re-attached "on a whim." *(M3b — hosts many M3a `spt-term` surfaces behind versioned IPC; `spt-term` itself is deliberately single-surface-per-handle.)*

**session surface** (library abstraction):
The trait spt-core exposes over "anything you can write input to and read output from" — delivered in M3a as `spt_term::SessionSurface` (`write_input` + `resize`, with `send_keys`/`send_line` as first-class injection). Native PTY (`spt_term::PtySession`: ConPTY on Windows 10+, `forkpty(3)` on Unix, via the `portable-pty` crate) is the day-one implementation. The abstraction generalizes so future implementations — network-attached surfaces (for `spt-node` remote control), GUI text panes, etc. — are uniform to consumers. Pre-Windows-10 (winpty) is explicitly dropped. **OS-neutral by contract (Spike #4/#5):** the trait bakes in neither `forkpty`'s raw-ordered-pipe nor ConPTY's repaint-on-resize screen-buffer semantics; output is a plain byte stream (`spt_term::OutputStream`, bounded backpressure) with OS-independent replay.


The file C:\Users\decid\Documents\projects\spt-core\CONTEXT.md has been updated successfully. (file state is current in your context — no need to Read it back)

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.

**term-daemon** (process supervisor):
A long-lived, one-per-machine daemon that owns the PTYs for all hosted sessions, multiplexes them (sessions addressable by name, tmux-style), and accepts attach/detach from frontends. A hosted agent session (a LiveAgent's Self, a ReadyAgent, etc.) runs *inside* a supervised PTY. **Headed** = a frontend is attached and rendering/driving the PTY; **headless** = the session runs with no frontend attached. The daemon's persistence is what lets a session keep running while unwatched and be re-attached "on a whim."

**session surface** (library abstraction):
The trait spt-core exposes over "anything you can write input to and read output from." Native PTY (ConPTY on Windows 10+, `forkpty(3)` on Unix, via the `portable-pty` crate) is the day-one implementation. The abstraction generalizes so future implementations — network-attached surfaces (for `spt-node` remote control), GUI text panes, etc. — are uniform to consumers. Pre-Windows-10 (winpty) is explicitly dropped.

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 M3b (`spt-daemon`).**

**term-daemon** (process supervisor):
A long-lived, one-per-machine daemon that owns the PTYs for all hosted sessions, multiplexes them (sessions addressable by name, tmux-style), and accepts attach/detach from frontends. A hosted agent session (a LiveAgent's Self, a ReadyAgent, etc.) runs *inside* a supervised PTY. **Headed** = a frontend is attached and rendering/driving the PTY; **headless** = the session runs with no frontend attached. The daemon's persistence is what lets a session keep running while unwatched and be re-attached "on a whim." *(M3b — hosts many M3a `spt-term` surfaces behind versioned IPC; `spt-term` itself is deliberately single-surface-per-handle.)*

**session surface** (library abstraction):
The trait spt-core exposes over "anything you can write input to and read output from" — delivered in M3a as `spt_term::SessionSurface` (`write_input` + `resize`, with `send_keys`/`send_line` as first-class injection). Native PTY (`spt_term::PtySession`: ConPTY on Windows 10+, `forkpty(3)` on Unix, via the `portable-pty` crate) is the day-one implementation. The abstraction generalizes so future implementations — network-attached surfaces (for `spt-node` remote control), GUI text panes, etc. — are uniform to consumers. Pre-Windows-10 (winpty) is explicitly dropped. **OS-neutral by contract (Spike #4/#5):** the trait bakes in neither `forkpty`'s raw-ordered-pipe nor ConPTY's repaint-on-resize screen-buffer semantics; output is a plain byte stream (`spt_term::OutputStream`, bounded backpressure) with OS-independent replay.

-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 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 M3b (`spt-daemon`).**

-A long-lived, one-per-machine daemon that owns the PTYs for all hosted sessions, multiplexes them (sessions addressable by name, tmux-style), and accepts attach/detach from frontends. A hosted agent session (a LiveAgent's Self, a ReadyAgent, etc.) runs *inside* a supervised PTY. **Headed** = a frontend is attached and rendering/driving the PTY; **headless** = the session runs with no frontend attached. The daemon's persistence is what lets a session keep running while unwatched and be re-attached "on a whim."

+A long-lived, one-per-machine daemon that owns the PTYs for all hosted sessions, multiplexes them (sessions addressable by name, tmux-style), and accepts attach/detach from frontends. A hosted agent session (a LiveAgent's Self, a ReadyAgent, etc.) runs *inside* a supervised PTY. **Headed** = a frontend is attached and rendering/driving the PTY; **headless** = the session runs with no frontend attached. The daemon's persistence is what lets a session keep running while unwatched and be re-attached "on a whim." *(M3b — hosts many M3a `spt-term` surfaces behind versioned IPC; `spt-term` itself is deliberately single-surface-per-handle.)*

-The trait spt-core exposes over "anything you can write input to and read output from." Native PTY (ConPTY on Windows 10+, `forkpty(3)` on Unix, via the `portable-pty` crate) is the day-one implementation. The abstraction generalizes so future implementations — network-attached surfaces (for `spt-node` remote control), GUI text panes, etc. — are uniform to consumers. Pre-Windows-10 (winpty) is explicitly dropped.

+The trait spt-core exposes over "anything you can write input to and read output from" — delivered in M3a as `spt_term::SessionSurface` (`write_input` + `resize`, with `send_keys`/`send_line` as first-class injection). Native PTY (`spt_term::PtySession`: ConPTY on Windows 10+, `forkpty(3)` on Unix, via the `portable-pty` crate) is the day-one implementation. The abstraction generalizes so future implementations — network-attached surfaces (for `spt-node` remote control), GUI text panes, etc. — are uniform to consumers. Pre-Windows-10 (winpty) is explicitly dropped. **OS-neutral by contract (Spike #4/#5):** the trait bakes in neither `forkpty`'s raw-ordered-pipe nor ConPTY's repaint-on-resize screen-buffer semantics; output is a plain byte stream (`spt_term::OutputStream`, bounded backpressure) with OS-independent replay.

Two granularities, both first-class. `send-keys` — raw byte stream including escape sequences (e.g. `Ctrl-C`). `send-line` — cooked line-level input flushed on newline (the common case; matches how user input reaches an agent prompt).


A6 docs amended. Check for the doc-drift generation gate (CLAUDE.md: doc generation is CI-gated).


Bash

grep -rn "drift\|generate\|gen-docs\|doc-gen" docs/DOCS-STRATEGY.md 2>/dev/null | head; echo "===scripts==="; ls scripts 2>/dev/null; find . -path ./target -prune -o -name "*.sh" -print 2>/dev/null | grep -iv node_modules | head; grep -n "doc\|drift\|generate" .github/workflows/ci.yml

Find doc-drift gate


14:4. **Dual-depth agent exports** — `llms.txt` (slim curated index) + `llms-full.txt` (full concatenated export), auto-generated in CI, plus markdown content negotiation (`.md` suffix / `Accept: text/markdown`) which cuts agent token use ~90% vs HTML.
16:6. **Complete reference, auto-generated, all error variants** — rustdoc for the public SDK crates (`spt-proto`, `spt-runtime`, `spt-msg`) + a machine-readable manifest/contract schema. Generic placeholders in reference are a failure mode.
20:10. **Docs-as-product, gated in CI** — generation (rustdoc, `llms.txt`, schema, CLI help) is part of the build so docs can never drift from code. Drift is the #1 most-cited docs failure; this kills it structurally.
43:Doc/code drift (#1 — solved by CI gating); *what* without *why*; too much setup before first success; generic placeholders in reference; mixed Diátaxis modes; poor search/navigation; multiple non-canonical ways to do X (fatal for agents).
47:rustdoc, the manifest JSON Schema, `llms.txt`/`llms-full.txt`, and CLI help exports are **generated and checked in CI** — a doc-drift gate. Doc quality lives on the same footing as tests.
78:## Reference (auto-generated)
134:Top-level `--help` groups by audience (`AGENT COMMANDS` send/ring/ready/poll · `HARNESS COMMANDS` api … · `NODE COMMANDS` pair/subnet/update). Per-command: **USAGE · FLAGS · EXAMPLES (real values) · LEARN MORE**. Beyond gh: a **`spt <cmd> --help --json`** structured-help mode so dev-agents parse the contract without scraping, plus gh's uniform **`--json <fields>` / `--jq` / `--template`** trio on any list command. The `--json` help export is generated + CI-gated alongside rustdoc and the schema.
===scripts===


spt-term is internal (not public-SDK), so the rustdoc/llms doc-gen gate doesn't apply. Final check: rustdoc sanity (I used many intra-doc links) + full traceable.


RUSTDOCFLAGS="-D warnings" cargo doc -p spt-term --no-deps 2>&1 | tail -8

rustdoc spt-term, deny warnings (broken intra-doc links)


    Checking spt-proto v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-proto)
    Checking spt-store v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-store)
    Checking spt-msg v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-msg)
 Documenting spt-term v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-term)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.00s
   Generated C:\Users\decid\Documents\projects\spt-core\target\doc\spt_term\index.html


rustdoc clean. Final traceable gate + commit A6 (docs/plan).


/c/Users/decid/AppData/Local/Programs/traceable-reqs/traceable-reqs check >/dev/null 2>&1; echo "check exit=$?"; git add -A && git commit -q -m "docs(M3a): activation sweep — ROADMAP/CONTEXT amend + M3b stub (A6)

M3a delivered: mark it in ROADMAP (M3 split into M3a ✅ / M3b NEXT /
M3c), annotate CONTEXT's terminal-wrapper + session-surface sections
(mechanism = M3a spt-term, supervisor process = M3b; OS-neutral by
contract), and author M3b-PLAN.md (broker/brain daemon stub). All M3a
reqs (REQ-TERM-1/2/3/4 + REQ-HAZARD-CONPTY-DSR) already activated +
green across the A1–A5 commits; traceable-reqs check exit 0
(115 reqs), workspace clippy -D warnings clean, rustdoc clean. CI
matrix (ubuntu+windows) covers spt-term via cargo test --workspace.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>" && git log --oneline -8

Verify traceable + commit A6


check exit=0
warning: in the working copy of 'CONTEXT.md', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'ROADMAP.md', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'M3b-PLAN.md', LF will be replaced by CRLF the next time Git touches it
1c39997 docs(M3a): activation sweep — ROADMAP/CONTEXT amend + M3b stub (A6)
3877dd2 feat(term): PTY digest parser primitive (M3a A5, ADR-0008)
4b789ee feat(term): bounded byte-stream (M3a A4)
ad21334 feat(term): send-keys + send-line injection (M3a A3)
0adaae5 feat(term): ConPTY-DSR auto-answer drain pump (M3a A2)
4a00304 feat(term): SessionSurface trait + native PTY backend (M3a A1)
8e23957 feat(term): scaffold spt-term crate (M3a A0)
f03a490 docs: grill-firm resource advertisement, subnet icon, endpoint access whitelist (ADR-0009)


M3a complete — all 7 tasks (A0–A6) committed. Final cross-OS sanity: full workspace test on Linux (confirms spt-term + regex didn't break other crates there).


tar czf /tmp/spt-sync.tgz --exclude=target --exclude=.git . 2>/dev/null && \
SSH_ASKPASS=/tmp/askpass.sh SSH_ASKPASS_REQUIRE=force ssh -o StrictHostKeyChecking=no david@gravity-linux 'cat > /tmp/spt-sync.tgz' < /tmp/spt-sync.tgz 2>&1 | tail -1
SSH_ASKPASS=/tmp/askpass.sh SSH_ASKPASS_REQUIRE=force ssh -o StrictHostKeyChecking=no david@gravity-linux 'rm -rf ~/spt-core-sync && mkdir -p ~/spt-core-sync && tar xzf /tmp/spt-sync.tgz -C ~/spt-core-sync && export PATH=$HOME/.cargo/bin:$PATH && cd ~/spt-core-sync && cargo test --workspace 2>&1 | grep -E "test result: FAILED|FAILED|error\[|passed" | tail -25; echo "WORKSPACE_TEST_EXIT=${PIPESTATUS[0]}"' < /dev/null 2>&1 | tail -25

Full workspace test on Linux


test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 40 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.11s
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.59s
test echo::tests::echo_child_is_recursion_guarded ... FAILED
test echo::tests::echo_nonzero_errors ... FAILED
test psyche::tests::spawn_binds_nested_perch_and_sets_guard ... FAILED
test resume::tests::continue_existing_resumes_session ... FAILED
test resume::tests::fresh_with_preload_launches_preloaded ... FAILED
test resume::tests::missing_key_errors_before_spawn ... FAILED
test result: FAILED. 29 passed; 6 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.31s
WORKSPACE_TEST_EXIT=101


6 failures — but all in **spt-live** (echo/psyche/resume lifecycle tests), not spt-term. spt-live doesn't depend on spt-term (it's above spt-runtime, not spt-term), so my changes can't cause these. They pass on Windows. Likely gravity-linux env. Confirm the failure cause.


SSH_ASKPASS=/tmp/askpass.sh SSH_ASKPASS_REQUIRE=force ssh -o StrictHostKeyChecking=no david@gravity-linux 'export PATH=$HOME/.cargo/bin:$PATH && cd ~/spt-core-sync && cargo test -p spt-live echo_nonzero_errors 2>&1 | sed -n "/running\|FAILED\|panicked\|assert\|Error\|No such\|error:/p" | head -20' < /dev/null 2>&1 | tail -20

Inspect one spt-live failure on Linux


running 1 test
test echo::tests::echo_nonzero_errors ... FAILED
thread 'echo::tests::echo_nonzero_errors' (150573) panicked at crates/spt-live/src/echo.rs:156:40:
called `Result::unwrap()` on an `Err` value: Parse(TomlError { message: "expected newline, `#`", raw: Some("[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n[session.echo_commune]\ncommand=\"sh -c \"exit 1\"\"\n"), keys: [], span: Some(99..100) })
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 34 filtered out; finished in 0.00s
error: test failed, to rerun pass `-p spt-live --lib`


Root cause: a **pre-existing** spt-live test-fixture bug, unrelated to M3a — a manifest TOML with nested unescaped quotes (`command="sh -c "exit 1""`) that's invalid TOML on the unix path. It passes on Windows (different cfg command string) so it was never caught on Linux. Let me assess the scope.


grep -rn 'sh -c\|cmd /c\|command=\\"\|command = ' crates/spt-live/src/echo.rs crates/spt-live/src/psyche.rs crates/spt-live/src/resume.rs 2>/dev/null | grep -i 'exit\|sh -c\|cmd /c' | head -20

Find nested-quote command fixtures in spt-live


crates/spt-live/src/echo.rs:140:    const GUARD_ECHO: &str = "sh -c \"echo guard=$SPT_ECHO_COMMUNE\"";
crates/spt-live/src/echo.rs:144:    const FAIL: &str = "sh -c \"exit 1\"";
crates/spt-live/src/psyche.rs:180:    const GUARD_CMD: &str = "sh -c \"echo $SPT_GUARD > guard.txt\"";
crates/spt-live/src/resume.rs:114:    const RESUME_SID: &str = "sh -c \"echo resumed={session_id} > resumed.txt\"";
crates/spt-live/src/resume.rs:118:    const PRELOAD_CTX: &str = "sh -c \"echo preload={psyche_context} > preload.txt\"";


135	    const PASSTHROUGH: &str = "cat";
136	    // Emits the recursion-guard env value (what an adapter hook keys off to bail).
137	    #[cfg(windows)]
138	    const GUARD_ECHO: &str = "cmd /C echo guard=%SPT_ECHO_COMMUNE%";
139	    #[cfg(unix)]
140	    const GUARD_ECHO: &str = "sh -c \"echo guard=$SPT_ECHO_COMMUNE\"";
141	    #[cfg(windows)]
142	    const FAIL: &str = "cmd /C exit 1";
143	    #[cfg(unix)]
144	    const FAIL: &str = "sh -c \"exit 1\"";
145
146	    fn echo_manifest(cmd: &str, guard: bool) -> Manifest {
147	        let guard_line = if guard {
148	            "recursion_guard_env=\"SPT_ECHO_COMMUNE\"\n"
149	        } else {
150	            ""
151	        };
152	        let toml = format!(
153	            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
154	             [session.echo_commune]\ncommand=\"{cmd}\"\n{guard_line}"
155	        );
156	        Manifest::from_toml_str(&toml).unwrap()
157	    }
158
159	    fn recs(lines: &[&str]) -> Vec<HistoryRecord> {
160	        lines.iter().map(|l| HistoryRecord { raw: l.to_string() }).collect()
161	    }
162
163	    #[test] // [unit->REQ-SEAM-HISTORY] echo consumes history + emits a single-writer commune drop.
164	    fn echo_consumes_history_and_writes_commune() {
165	        let dir = tempfile::tempdir().unwrap();
166	        let rt = ManifestRuntime::new(echo_manifest(PASSTHROUGH, false));
167	        let history = recs(&["{\"r\":\"u\",\"t\":\"hi\"}", "{\"r\":\"a\",\"t\":\"yo\"}"]);
168	        let res = run_echo_commune(
169	            &rt,
170	            "doyle",
171	            &BTreeMap::new(),
172	            &history,
173	            dir.path(),
174	            Duration::from_secs(10),
175	        )
176	        .expect("echo");
177
178	        assert_eq!(res.drop_path, dir.path().join("doyle-commune.md"));
179	        let written = std::fs::read_to_string(&res.drop_path).unwrap();
180	        // [unit->REQ-HAZARD-DROP-FILE-SINGLE-WRITER] spt-core wrote the drop file.
181	        assert!(written.starts_with("Source: echo-commune\n"), "provenance: {written:?}");
182	        assert!(written.contains("\"t\":\"hi\""), "consumed history: {written:?}");
183	        assert!(written.contains("\"t\":\"yo\""));
184	    }
185
186	    #[test] // [unit->REQ-SEAM-HISTORY] the echo child runs with the recursion guard set (won't re-echo).
187	    fn echo_child_is_recursion_guarded() {
188	        let dir = tempfile::tempdir().unwrap();
189	        let rt = ManifestRuntime::new(echo_manifest(GUARD_ECHO, true));
190	        let res = run_echo_commune(&rt, "doyle", &BTreeMap::new(), &[], dir.path(), Duration::from_secs(10))
191	            .expect("echo");
192	        assert!(res.body.contains("guard=1"), "guard env not set on echo child: {:?}", res.body);
193	    }
194
195	    #[test] // [unit->REQ-SEAM-HISTORY] a failing summarizer surfaces NonZero, no half-written drop.
196	    fn echo_nonzero_errors() {
197	        let dir = tempfile::tempdir().unwrap();
198	        let rt = ManifestRuntime::new(echo_manifest(FAIL, false));
199	        let e = run_echo_commune(&rt, "doyle", &BTreeMap::new(), &[], dir.path(), Duration::from_secs(10))
200	            .unwrap_err();
201	        assert!(matches!(e, EchoError::NonZero { .. }), "got {e}");
202	        assert!(!dir.path().join("doyle-commune.md").exists(), "no drop on failure");
203	    }
204
205	    #[test] // [unit->REQ-HAZARD-DROP-FILE-SINGLE-WRITER] provenance header is stamped by spt-core.
206	    fn provenance_is_stamped() {
207	        let s = stamp_provenance(SOURCE_ECHO_COMMUNE, "the brief body");
208	        assert_eq!(s, "Source: echo-commune\nthe brief body\n");
209	    }

    const PASSTHROUGH: &str = "cat";
    // Emits the recursion-guard env value (what an adapter hook keys off to bail).
    #[cfg(windows)]
    const GUARD_ECHO: &str = "cmd /C echo guard=%SPT_ECHO_COMMUNE%";
    #[cfg(unix)]
    const GUARD_ECHO: &str = "sh -c \"echo guard=$SPT_ECHO_COMMUNE\"";
    #[cfg(windows)]
    const FAIL: &str = "cmd /C exit 1";
    #[cfg(unix)]
    const FAIL: &str = "sh -c \"exit 1\"";

    fn echo_manifest(cmd: &str, guard: bool) -> Manifest {
        let guard_line = if guard {
            "recursion_guard_env=\"SPT_ECHO_COMMUNE\"\n"
        } else {
            ""
        };
        let toml = format!(
            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
             [session.echo_commune]\ncommand=\"{cmd}\"\n{guard_line}"
        );
        Manifest::from_toml_str(&toml).unwrap()
    }

    fn recs(lines: &[&str]) -> Vec<HistoryRecord> {
        lines.iter().map(|l| HistoryRecord { raw: l.to_string() }).collect()
    }

    #[test] // [unit->REQ-SEAM-HISTORY] echo consumes history + emits a single-writer commune drop.
    fn echo_consumes_history_and_writes_commune() {
        let dir = tempfile::tempdir().unwrap();
        let rt = ManifestRuntime::new(echo_manifest(PASSTHROUGH, false));
        let history = recs(&["{\"r\":\"u\",\"t\":\"hi\"}", "{\"r\":\"a\",\"t\":\"yo\"}"]);
        let res = run_echo_commune(
            &rt,
            "doyle",
            &BTreeMap::new(),
            &history,
            dir.path(),
            Duration::from_secs(10),
        )
        .expect("echo");

        assert_eq!(res.drop_path, dir.path().join("doyle-commune.md"));
        let written = std::fs::read_to_string(&res.drop_path).unwrap();
        // [unit->REQ-HAZARD-DROP-FILE-SINGLE-WRITER] spt-core wrote the drop file.
        assert!(written.starts_with("Source: echo-commune\n"), "provenance: {written:?}");
        assert!(written.contains("\"t\":\"hi\""), "consumed history: {written:?}");
        assert!(written.contains("\"t\":\"yo\""));
    }

    #[test] // [unit->REQ-SEAM-HISTORY] the echo child runs with the recursion guard set (won't re-echo).
    fn echo_child_is_recursion_guarded() {
        let dir = tempfile::tempdir().unwrap();
        let rt = ManifestRuntime::new(echo_manifest(GUARD_ECHO, true));
        let res = run_echo_commune(&rt, "doyle", &BTreeMap::new(), &[], dir.path(), Duration::from_secs(10))
            .expect("echo");
        assert!(res.body.contains("guard=1"), "guard env not set on echo child: {:?}", res.body);
    }

    #[test] // [unit->REQ-SEAM-HISTORY] a failing summarizer surfaces NonZero, no half-written drop.
    fn echo_nonzero_errors() {
        let dir = tempfile::tempdir().unwrap();
        let rt = ManifestRuntime::new(echo_manifest(FAIL, false));
        let e = run_echo_commune(&rt, "doyle", &BTreeMap::new(), &[], dir.path(), Duration::from_secs(10))
            .unwrap_err();
        assert!(matches!(e, EchoError::NonZero { .. }), "got {e}");
        assert!(!dir.path().join("doyle-commune.md").exists(), "no drop on failure");
    }

    #[test] // [unit->REQ-HAZARD-DROP-FILE-SINGLE-WRITER] provenance header is stamped by spt-core.
    fn provenance_is_stamped() {
        let s = stamp_provenance(SOURCE_ECHO_COMMUNE, "the brief body");
        assert_eq!(s, "Source: echo-commune\nthe brief body\n");
    }


Confirmed: a real pre-existing M2b bug. The `cfg(unix)` command constants embed inner `"` into `command="{cmd}"`, producing invalid TOML — so these 6 tests are broken on Linux/CI (Windows uses quote-free `cmd /C` strings and passes). The fix is a safe, test-only one: emit the command as a TOML **literal** (single-quoted) string. Let me find all embedding sites.


grep -rn 'command=\\"{' crates/spt-live/src/*.rs; echo "---"; grep -rn 'command=' crates/spt-live/src/psyche.rs crates/spt-live/src/resume.rs | grep '{'

Find all TOML command embedding sites


crates/spt-live/src/echo.rs:154:             [session.echo_commune]\ncommand=\"{cmd}\"\n{guard_line}"
crates/spt-live/src/psyche.rs:185:             [session.psyche_init]\ncommand=\"{GUARD_CMD}\"\ncwd=\"{{psyche_dir}}\"\n\
crates/spt-live/src/resume.rs:123:             [session.psyche_resume]\ncommand=\"{cmd}\"\ncwd=\"{{psyche_dir}}\"\n"
---
crates/spt-live/src/psyche.rs:185:             [session.psyche_init]\ncommand=\"{GUARD_CMD}\"\ncwd=\"{{psyche_dir}}\"\n\
crates/spt-live/src/resume.rs:123:             [session.psyche_resume]\ncommand=\"{cmd}\"\ncwd=\"{{psyche_dir}}\"\n"


Three sites. Fix each to a TOML literal (single-quoted) string — preserves the inner double quotes that `tokenize()` needs.