Write

//! B8 integration: the **PTY-digest daemon-half**, end to end through the real
//! IPC (ADR-0008 / REQ-TERM-4).
//!
//! A [`Broker`] hosts a real PTY child; a [`Brain`] folds that child's live output
//! through a [`DigestEngine`] (the brain-side parse — never the broker, ADR-0004
//! §B) and publishes each change into the [`DigestHub`]; a separate client reads
//! it back over the real digest-control channel both ways ADR-0008 specifies — a
//! `spt digest` **snapshot pull** ([`pull_snapshot`]) and a **delta-stream**
//! ([`follow`]) — and the opt-in Path-B persistence appends settled turns to the
//! perch history store. This is the whole B8 contract over the actual named-pipe
//! / UDS transport, on both OSes.
//!
//! The child is the same cross-OS echo program the B1 broker test uses (`cat` /
//! `findstr "."`): bytes injected into its PTY round-trip back as output carrying
//! the digest boundary markers, so the engine has a real stream to parse. PTY
//! echo may duplicate a line; the assertions are therefore content-tolerant
//! (`any`), exactly as the digest parser's own repaint-tolerance contract expects.
// [int->REQ-TERM-4]

use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};

use spt_daemon::msg::SpawnReq;
use spt_daemon::{
    follow, pull_snapshot, serve_digest_control, Brain, Broker, DigestEngine, DigestHub,
};
use spt_runtime::PtyDigest;
use spt_store::perch::{resolve_perch_path, ParentHint};

static SEQ: AtomicU32 = AtomicU32::new(0);
fn unique(prefix: &str) -> String {
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    format!("spt-daemon-b8-{prefix}-{}-{}.sock", std::process::id(), n)
}

/// The cross-OS echo child: reads stdin, writes each line to stdout.
fn echo_req() -> SpawnReq {
    #[cfg(unix)]
    let (program, args) = ("cat".to_string(), Vec::<String>::new());
    #[cfg(windows)]
    let (program, args) = ("findstr".to_string(), vec![".".to_string()]);
    SpawnReq { program, args, rows: 24, cols: 80 }
}

/// The adapter-supplied digest seam, with persistence opted in.
fn seam() -> PtyDigest {
    PtyDigest {
        input_pattern: r"\nUSER> ".to_string(),
        agent_pattern: r"\nAGENT> ".to_string(),
        tool_patterns: vec![r"(?P<name>Write)\((?P<arg>[^)]*)\)".to_string()],
        catchall_pattern: None,
        window_turns: Some(5),
        persist: true,
    }
}

fn contains(hay: &[u8], needle: &[u8]) -> bool {
    hay.windows(needle.len()).any(|w| w == needle)
}

/// Block up to `dur` for `cond` to hold, polling gently.
fn wait_until(dur: Duration, mut cond: impl FnMut() -> bool) -> bool {
    let start = Instant::now();
    while start.elapsed() < dur {
        if cond() {
            return true;
        }
        thread::sleep(Duration::from_millis(15));
    }
    cond()
}

#[test]
fn digest_daemon_half_end_to_end() {
    // Isolate on-disk state (the perch + Path-B history.log) to a temp home.
    let home = tempfile::tempdir().expect("tempdir");
    std::env::set_var("SPT_HOME", home.path());

    let broker_name = unique("broker");
    let digest_name = unique("digest");

    // The digest hub + its control channel (the `spt digest` access surface).
    let hub = Arc::new(DigestHub::new());
    {
        let hub = Arc::clone(&hub);
        let name = digest_name.clone();
        thread::spawn(move || {
            let _ = serve_digest_control(&name, hub);
        });
    }

    // The broker hosts PTYs; serve it on a background thread.
    let broker = Broker::bind(&broker_name).expect("bind broker");
    {
        let broker = Arc::clone(&broker);
        thread::spawn(move || {
            let _ = broker.serve();
        });
    }

    // One brain spawns the echo child, injects the marker stream, and folds the
    // echoed output through the engine (publishing each change to the hub).
    let mut brain = connect_brain(&broker_name);
    let _sid = brain.spawn_session(echo_req()).expect("spawn echo child");
    let mut engine = DigestEngine::from_manifest(&seam()).expect("compile patterns");

    // Inject TWO complete turns + a tool sprint, then a sentinel: the first turn
    // settles (→ persistence), the second is the active turn.
    let blob = b"\nUSER> first task\nAGENT> working on it\nWrite(src/a.rs) done\nUSER> second task\nAGENT> all set\n";
    brain.send_input(blob).expect("inject markers");

    // Read the echoed output, feeding the engine + publishing, until both turns
    // have round-tripped through the real PTY.
    let mut acc = Vec::<u8>::new();
    let mut guard = 0;
    loop {
        guard += 1;
        assert!(guard < 100_000, "echoed markers never arrived: {:?}", String::from_utf8_lossy(&acc));
        if contains(&acc, b"second task") && contains(&acc, b"first task") {
            break;
        }
        match brain.read_event().expect("read event") {
            spt_daemon::BrokerEvent::Output { bytes, .. } => {
                acc.extend_from_slice(&bytes);
                spt_daemon::ingest_chunk(&mut engine, &hub, "doyle", &bytes);
            }
            spt_daemon::BrokerEvent::Exit { .. } => panic!("child exited before echo"),
            _ => {}
        }
    }

    // ── Snapshot pull over the control channel ──────────────────────────────
    let (digest, version) = pull_snapshot(&digest_name, "doyle")
        .expect("pull ok")
        .expect("a live digest exists for the daemon-hosted session");
    assert!(version >= 1, "the digest advanced at least one version");
    assert!(
        digest.turns.iter().any(|t| t.input.as_deref() == Some("first task")),
        "the snapshot carries the parsed user turn: {digest:?}"
    );
    assert!(
        digest.turns.iter().any(|t| t
            .entries
            .iter()
            .any(|e| matches!(e, spt_daemon_digest_entry_is_tool(e)))),
        "the snapshot carries a collapsed tool sprint: {digest:?}"
    );

    // ── Delta-stream: a subscriber receives only the new change ─────────────
    let base = version;
    let (tx, rx) = std::sync::mpsc::channel::<u64>();
    {
        let dname = digest_name.clone();
        thread::spawn(move || {
            // Skip the base (== base) and stop on the first true delta (> base).
            let _ = follow(&dname, "doyle", base, |up| {
                if up.version > base {
                    let _ = tx.send(up.version);
                    false
                } else {
                    true
                }
            });
        });
    }
    // Give the subscriber a moment to register, then drive a third turn.
    thread::sleep(Duration::from_millis(80));
    brain.send_input(b"\nUSER> third task\nAGENT> reflecting\n").expect("inject third");
    // Keep feeding until the third turn round-trips (publishing new versions).
    guard = 0;
    while !contains(&acc, b"third task") {
        guard += 1;
        assert!(guard < 100_000, "third turn never echoed");
        if let spt_daemon::BrokerEvent::Output { bytes, .. } =
            brain.read_event().expect("read event")
        {
            acc.extend_from_slice(&bytes);
            spt_daemon::ingest_chunk(&mut engine, &hub, "doyle", &bytes);
        }
    }
    let delta_version = rx
        .recv_timeout(Duration::from_secs(5))
        .expect("the subscriber received a delta beyond the base");
    assert!(delta_version > base, "the delta is a newer version than the base");

    // ── Opt-in Path-B persistence: the settled turn was logged ──────────────
    let history = resolve_perch_path("doyle", ParentHint::Infer).join("history.log");
    assert!(
        wait_until(Duration::from_secs(2), || history.exists()),
        "persistence created the Path-B history log"
    );
    let log = std::fs::read_to_string(&history).expect("read history.log");
    assert!(log.contains("digest_turn"), "a digest activity record was appended: {log}");
    assert!(log.contains("first task"), "the settled turn was persisted: {log}");

    brain.kill_session().expect("kill child");
}

/// Connect a brain to the broker, retrying briefly while `accept` lags the bind.
fn connect_brain(name: &str) -> Brain {
    for _ in 0..200 {
        if let Ok(b) = Brain::cold_start(name, 1) {
            return b;
        }
        thread::sleep(Duration::from_millis(10));
    }
    panic!("could not connect a brain to the broker");
}

// A tiny matcher helper kept out of the assertion above for readability: whether a
// digest entry is a tool sprint.
#[allow(non_snake_case)]
fn spt_daemon_digest_entry_is_tool(_e: &spt_daemon::DigestEntryProbe) {}


NOTE fROM THE USER: proceed with next steps.


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

//! B8 integration: the **PTY-digest daemon-half**, end to end through the real
//! IPC (ADR-0008 / REQ-TERM-4).
//!
//! A [`Broker`] hosts a real PTY child; a [`Brain`] folds that child's live output
//! through a [`DigestEngine`] (the brain-side parse — never the broker, ADR-0004
//! §B) and publishes each change into the [`DigestHub`]; a separate client reads
//! it back over the real digest-control channel both ways ADR-0008 specifies — a
//! `spt digest` **snapshot pull** ([`pull_snapshot`]) and a **delta-stream**
//! ([`follow`]) — and the opt-in Path-B persistence appends settled turns to the
//! perch history store. This is the whole B8 contract over the actual named-pipe
//! / UDS transport, on both OSes.
//!
//! The child is the same cross-OS echo program the B1 broker test uses (`cat` /
//! `findstr "."`): bytes injected into its PTY round-trip back as output carrying
//! the digest boundary markers, so the engine has a real stream to parse. PTY
//! echo may duplicate a line; the assertions are therefore content-tolerant
//! (`any`), exactly as the digest parser's own repaint-tolerance contract expects.
// [int->REQ-TERM-4]

use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};

use spt_daemon::msg::SpawnReq;
use spt_daemon::{
    follow, pull_snapshot, serve_digest_control, Brain, Broker, DigestEngine, DigestHub,
};
use spt_runtime::PtyDigest;
use spt_store::perch::{resolve_perch_path, ParentHint};

static SEQ: AtomicU32 = AtomicU32::new(0);
fn unique(prefix: &str) -> String {
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    format!("spt-daemon-b8-{prefix}-{}-{}.sock", std::process::id(), n)
}

/// The cross-OS echo child: reads stdin, writes each line to stdout.
fn echo_req() -> SpawnReq {
    #[cfg(unix)]
    let (program, args) = ("cat".to_string(), Vec::<String>::new());
    #[cfg(windows)]
    let (program, args) = ("findstr".to_string(), vec![".".to_string()]);
    SpawnReq { program, args, rows: 24, cols: 80 }
}

/// The adapter-supplied digest seam, with persistence opted in.
fn seam() -> PtyDigest {
    PtyDigest {
        input_pattern: r"\nUSER> ".to_string(),
        agent_pattern: r"\nAGENT> ".to_string(),
        tool_patterns: vec![r"(?P<name>Write)\((?P<arg>[^)]*)\)".to_string()],
        catchall_pattern: None,
        window_turns: Some(5),
        persist: true,
    }
}

fn contains(hay: &[u8], needle: &[u8]) -> bool {
    hay.windows(needle.len()).any(|w| w == needle)
}

/// Block up to `dur` for `cond` to hold, polling gently.
fn wait_until(dur: Duration, mut cond: impl FnMut() -> bool) -> bool {
    let start = Instant::now();
    while start.elapsed() < dur {
        if cond() {
            return true;
        }
        thread::sleep(Duration::from_millis(15));
    }
    cond()
}

#[test]
fn digest_daemon_half_end_to_end() {
    // Isolate on-disk state (the perch + Path-B history.log) to a temp home.
    let home = tempfile::tempdir().expect("tempdir");
    std::env::set_var("SPT_HOME", home.path());

    let broker_name = unique("broker");
    let digest_name = unique("digest");

    // The digest hub + its control channel (the `spt digest` access surface).
    let hub = Arc::new(DigestHub::new());
    {
        let hub = Arc::clone(&hub);
        let name = digest_name.clone();
        thread::spawn(move || {
            let _ = serve_digest_control(&name, hub);
        });
    }

    // The broker hosts PTYs; serve it on a background thread.
    let broker = Broker::bind(&broker_name).expect("bind broker");
    {
        let broker = Arc::clone(&broker);
        thread::spawn(move || {
            let _ = broker.serve();
        });
    }

    // One brain spawns the echo child, injects the marker stream, and folds the
    // echoed output through the engine (publishing each change to the hub).
    let mut brain = connect_brain(&broker_name);
    let _sid = brain.spawn_session(echo_req()).expect("spawn echo child");
    let mut engine = DigestEngine::from_manifest(&seam()).expect("compile patterns");

    // Inject TWO complete turns + a tool sprint, then a sentinel: the first turn
    // settles (→ persistence), the second is the active turn.
    let blob = b"\nUSER> first task\nAGENT> working on it\nWrite(src/a.rs) done\nUSER> second task\nAGENT> all set\n";
    brain.send_input(blob).expect("inject markers");

    // Read the echoed output, feeding the engine + publishing, until both turns
    // have round-tripped through the real PTY.
    let mut acc = Vec::<u8>::new();
    let mut guard = 0;
    loop {
        guard += 1;
        assert!(guard < 100_000, "echoed markers never arrived: {:?}", String::from_utf8_lossy(&acc));
        if contains(&acc, b"second task") && contains(&acc, b"first task") {
            break;
        }
        match brain.read_event().expect("read event") {
            spt_daemon::BrokerEvent::Output { bytes, .. } => {
                acc.extend_from_slice(&bytes);
                spt_daemon::ingest_chunk(&mut engine, &hub, "doyle", &bytes);
            }
            spt_daemon::BrokerEvent::Exit { .. } => panic!("child exited before echo"),
            _ => {}
        }
    }

    // ── Snapshot pull over the control channel ──────────────────────────────
    let (digest, version) = pull_snapshot(&digest_name, "doyle")
        .expect("pull ok")
        .expect("a live digest exists for the daemon-hosted session");
    assert!(version >= 1, "the digest advanced at least one version");
    assert!(
        digest.turns.iter().any(|t| t.input.as_deref() == Some("first task")),
        "the snapshot carries the parsed user turn: {digest:?}"
    );
    assert!(
        digest.turns.iter().any(|t| t
            .entries
            .iter()
            .any(|e| matches!(e, spt_daemon_digest_entry_is_tool(e)))),
        "the snapshot carries a collapsed tool sprint: {digest:?}"
    );

    // ── Delta-stream: a subscriber receives only the new change ─────────────
    let base = version;
    let (tx, rx) = std::sync::mpsc::channel::<u64>();
    {
        let dname = digest_name.clone();
        thread::spawn(move || {
            // Skip the base (== base) and stop on the first true delta (> base).
            let _ = follow(&dname, "doyle", base, |up| {
                if up.version > base {
                    let _ = tx.send(up.version);
                    false
                } else {
                    true
                }
            });
        });
    }
    // Give the subscriber a moment to register, then drive a third turn.
    thread::sleep(Duration::from_millis(80));
    brain.send_input(b"\nUSER> third task\nAGENT> reflecting\n").expect("inject third");
    // Keep feeding until the third turn round-trips (publishing new versions).
    guard = 0;
    while !contains(&acc, b"third task") {
        guard += 1;
        assert!(guard < 100_000, "third turn never echoed");
        if let spt_daemon::BrokerEvent::Output { bytes, .. } =
            brain.read_event().expect("read event")
        {
            acc.extend_from_slice(&bytes);
            spt_daemon::ingest_chunk(&mut engine, &hub, "doyle", &bytes);
        }
    }
    let delta_version = rx
        .recv_timeout(Duration::from_secs(5))
        .expect("the subscriber received a delta beyond the base");
    assert!(delta_version > base, "the delta is a newer version than the base");

    // ── Opt-in Path-B persistence: the settled turn was logged ──────────────
    let history = resolve_perch_path("doyle", ParentHint::Infer).join("history.log");
    assert!(
        wait_until(Duration::from_secs(2), || history.exists()),
        "persistence created the Path-B history log"
    );
    let log = std::fs::read_to_string(&history).expect("read history.log");
    assert!(log.contains("digest_turn"), "a digest activity record was appended: {log}");
    assert!(log.contains("first task"), "the settled turn was persisted: {log}");

    brain.kill_session().expect("kill child");
}

/// Connect a brain to the broker, retrying briefly while `accept` lags the bind.
fn connect_brain(name: &str) -> Brain {
    for _ in 0..200 {
        if let Ok(b) = Brain::cold_start(name, 1) {
            return b;
        }
        thread::sleep(Duration::from_millis(10));
    }
    panic!("could not connect a brain to the broker");
}

// A tiny matcher helper kept out of the assertion above for readability: whether a
// digest entry is a tool sprint.
#[allow(non_snake_case)]
fn spt_daemon_digest_entry_is_tool(_e: &spt_daemon::DigestEntryProbe) {}


The tool-sprint assertion is malformed — replace with a render-string check (avoids needing `DigestEntry` exported):


Edit

    assert!(
        digest.turns.iter().any(|t| t.input.as_deref() == Some("first task")),
        "the snapshot carries the parsed user turn: {digest:?}"
    );
    assert!(
        digest.turns.iter().any(|t| t
            .entries
            .iter()
            .any(|e| matches!(e, spt_daemon_digest_entry_is_tool(e)))),
        "the snapshot carries a collapsed tool sprint: {digest:?}"
    );

    assert!(
        digest.turns.iter().any(|t| t.input.as_deref() == Some("first task")),
        "the snapshot carries the parsed user turn: {digest:?}"
    );
    // The render is the glanceable view; a collapsed tool sprint shows as a
    // `used: …` line (avoids depending on the internal entry enum here).
    let rendered = spt_daemon::render_digest(&digest);
    assert!(
        rendered.contains("used: Write(src/a.rs)"),
        "the snapshot carries a collapsed tool sprint: {rendered}"
    );


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

    assert!(
        digest.turns.iter().any(|t| t.input.as_deref() == Some("first task")),
        "the snapshot carries the parsed user turn: {digest:?}"
    );
    // The render is the glanceable view; a collapsed tool sprint shows as a
    // `used: …` line (avoids depending on the internal entry enum here).
    let rendered = spt_daemon::render_digest(&digest);
    assert!(
        rendered.contains("used: Write(src/a.rs)"),
        "the snapshot carries a collapsed tool sprint: {rendered}"
    );

//! B8 integration: the **PTY-digest daemon-half**, end to end through the real
//! IPC (ADR-0008 / REQ-TERM-4).
//!
//! A [`Broker`] hosts a real PTY child; a [`Brain`] folds that child's live output
//! through a [`DigestEngine`] (the brain-side parse — never the broker, ADR-0004
//! §B) and publishes each change into the [`DigestHub`]; a separate client reads
//! it back over the real digest-control channel both ways ADR-0008 specifies — a
//! `spt digest` **snapshot pull** ([`pull_snapshot`]) and a **delta-stream**
//! ([`follow`]) — and the opt-in Path-B persistence appends settled turns to the
//! perch history store. This is the whole B8 contract over the actual named-pipe
//! / UDS transport, on both OSes.
//!
//! The child is the same cross-OS echo program the B1 broker test uses (`cat` /
//! `findstr "."`): bytes injected into its PTY round-trip back as output carrying
//! the digest boundary markers, so the engine has a real stream to parse. PTY
//! echo may duplicate a line; the assertions are therefore content-tolerant
//! (`any`), exactly as the digest parser's own repaint-tolerance contract expects.
// [int->REQ-TERM-4]

use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};

use spt_daemon::msg::SpawnReq;
use spt_daemon::{
    follow, pull_snapshot, serve_digest_control, Brain, Broker, DigestEngine, DigestHub,
};
use spt_runtime::PtyDigest;
use spt_store::perch::{resolve_perch_path, ParentHint};

static SEQ: AtomicU32 = AtomicU32::new(0);
fn unique(prefix: &str) -> String {
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    format!("spt-daemon-b8-{prefix}-{}-{}.sock", std::process::id(), n)
}

/// The cross-OS echo child: reads stdin, writes each line to stdout.
fn echo_req() -> SpawnReq {
    #[cfg(unix)]
    let (program, args) = ("cat".to_string(), Vec::<String>::new());
    #[cfg(windows)]
    let (program, args) = ("findstr".to_string(), vec![".".to_string()]);
    SpawnReq { program, args, rows: 24, cols: 80 }
}

/// The adapter-supplied digest seam, with persistence opted in.
fn seam() -> PtyDigest {
    PtyDigest {
        input_pattern: r"\nUSER> ".to_string(),
        agent_pattern: r"\nAGENT> ".to_string(),
        tool_patterns: vec![r"(?P<name>Write)\((?P<arg>[^)]*)\)".to_string()],
        catchall_pattern: None,
        window_turns: Some(5),
        persist: true,
    }
}

fn contains(hay: &[u8], needle: &[u8]) -> bool {
    hay.windows(needle.len()).any(|w| w == needle)
}

/// Block up to `dur` for `cond` to hold, polling gently.
fn wait_until(dur: Duration, mut cond: impl FnMut() -> bool) -> bool {
    let start = Instant::now();
    while start.elapsed() < dur {
        if cond() {
            return true;
        }
        thread::sleep(Duration::from_millis(15));
    }
    cond()
}

#[test]
fn digest_daemon_half_end_to_end() {
    // Isolate on-disk state (the perch + Path-B history.log) to a temp home.
    let home = tempfile::tempdir().expect("tempdir");
    std::env::set_var("SPT_HOME", home.path());

    let broker_name = unique("broker");
    let digest_name = unique("digest");

    // The digest hub + its control channel (the `spt digest` access surface).
    let hub = Arc::new(DigestHub::new());
    {
        let hub = Arc::clone(&hub);
        let name = digest_name.clone();
        thread::spawn(move || {
            let _ = serve_digest_control(&name, hub);
        });
    }

    // The broker hosts PTYs; serve it on a background thread.
    let broker = Broker::bind(&broker_name).expect("bind broker");
    {
        let broker = Arc::clone(&broker);
        thread::spawn(move || {
            let _ = broker.serve();
        });
    }

    // One brain spawns the echo child, injects the marker stream, and folds the
    // echoed output through the engine (publishing each change to the hub).
    let mut brain = connect_brain(&broker_name);
    let _sid = brain.spawn_session(echo_req()).expect("spawn echo child");
    let mut engine = DigestEngine::from_manifest(&seam()).expect("compile patterns");

    // Inject TWO complete turns + a tool sprint, then a sentinel: the first turn
    // settles (→ persistence), the second is the active turn.
    let blob = b"\nUSER> first task\nAGENT> working on it\nWrite(src/a.rs) done\nUSER> second task\nAGENT> all set\n";
    brain.send_input(blob).expect("inject markers");

    // Read the echoed output, feeding the engine + publishing, until both turns
    // have round-tripped through the real PTY.
    let mut acc = Vec::<u8>::new();
    let mut guard = 0;
    loop {
        guard += 1;
        assert!(guard < 100_000, "echoed markers never arrived: {:?}", String::from_utf8_lossy(&acc));
        if contains(&acc, b"second task") && contains(&acc, b"first task") {
            break;
        }
        match brain.read_event().expect("read event") {
            spt_daemon::BrokerEvent::Output { bytes, .. } => {
                acc.extend_from_slice(&bytes);
                spt_daemon::ingest_chunk(&mut engine, &hub, "doyle", &bytes);
            }
            spt_daemon::BrokerEvent::Exit { .. } => panic!("child exited before echo"),
            _ => {}
        }
    }

    // ── Snapshot pull over the control channel ──────────────────────────────
    let (digest, version) = pull_snapshot(&digest_name, "doyle")
        .expect("pull ok")
        .expect("a live digest exists for the daemon-hosted session");
    assert!(version >= 1, "the digest advanced at least one version");
    assert!(
        digest.turns.iter().any(|t| t.input.as_deref() == Some("first task")),
        "the snapshot carries the parsed user turn: {digest:?}"
    );
    assert!(
        digest.turns.iter().any(|t| t
            .entries
            .iter()
            .any(|e| matches!(e, spt_daemon_digest_entry_is_tool(e)))),
        "the snapshot carries a collapsed tool sprint: {digest:?}"
    );

    // ── Delta-stream: a subscriber receives only the new change ─────────────
    let base = version;
    let (tx, rx) = std::sync::mpsc::channel::<u64>();
    {
        let dname = digest_name.clone();
        thread::spawn(move || {
            // Skip the base (== base) and stop on the first true delta (> base).
            let _ = follow(&dname, "doyle", base, |up| {
                if up.version > base {
                    let _ = tx.send(up.version);
                    false
                } else {
                    true
                }
            });
        });
    }
    // Give the subscriber a moment to register, then drive a third turn.
    thread::sleep(Duration::from_millis(80));
    brain.send_input(b"\nUSER> third task\nAGENT> reflecting\n").expect("inject third");
    // Keep feeding until the third turn round-trips (publishing new versions).
    guard = 0;
    while !contains(&acc, b"third task") {
        guard += 1;
        assert!(guard < 100_000, "third turn never echoed");
        if let spt_daemon::BrokerEvent::Output { bytes, .. } =
            brain.read_event().expect("read event")
        {
            acc.extend_from_slice(&bytes);
            spt_daemon::ingest_chunk(&mut engine, &hub, "doyle", &bytes);
        }
    }
    let delta_version = rx
        .recv_timeout(Duration::from_secs(5))
        .expect("the subscriber received a delta beyond the base");
    assert!(delta_version > base, "the delta is a newer version than the base");

    // ── Opt-in Path-B persistence: the settled turn was logged ──────────────
    let history = resolve_perch_path("doyle", ParentHint::Infer).join("history.log");
    assert!(
        wait_until(Duration::from_secs(2), || history.exists()),
        "persistence created the Path-B history log"
    );
    let log = std::fs::read_to_string(&history).expect("read history.log");
    assert!(log.contains("digest_turn"), "a digest activity record was appended: {log}");
    assert!(log.contains("first task"), "the settled turn was persisted: {log}");

    brain.kill_session().expect("kill child");
}

/// Connect a brain to the broker, retrying briefly while `accept` lags the bind.
fn connect_brain(name: &str) -> Brain {
    for _ in 0..200 {
        if let Ok(b) = Brain::cold_start(name, 1) {
            return b;
        }
        thread::sleep(Duration::from_millis(10));
    }
    panic!("could not connect a brain to the broker");
}

// A tiny matcher helper kept out of the assertion above for readability: whether a
// digest entry is a tool sprint.
#[allow(non_snake_case)]
fn spt_daemon_digest_entry_is_tool(_e: &spt_daemon::DigestEntryProbe) {}

+    // The render is the glanceable view; a collapsed tool sprint shows as a

+    // `used: …` line (avoids depending on the internal entry enum here).

+    let rendered = spt_daemon::render_digest(&digest);

-            .any(|e| matches!(e, spt_daemon_digest_entry_is_tool(e)))),

-        "the snapshot carries a collapsed tool sprint: {digest:?}"

+        rendered.contains("used: Write(src/a.rs)"),

+        "the snapshot carries a collapsed tool sprint: {rendered}"


    panic!("could not connect a brain to the broker");
}

// A tiny matcher helper kept out of the assertion above for readability: whether a
// digest entry is a tool sprint.
#[allow(non_snake_case)]
fn spt_daemon_digest_entry_is_tool(_e: &spt_daemon::DigestEntryProbe) {}

    panic!("could not connect a brain to the broker");
}


//! B8 integration: the **PTY-digest daemon-half**, end to end through the real
//! IPC (ADR-0008 / REQ-TERM-4).
//!
//! A [`Broker`] hosts a real PTY child; a [`Brain`] folds that child's live output
//! through a [`DigestEngine`] (the brain-side parse — never the broker, ADR-0004
//! §B) and publishes each change into the [`DigestHub`]; a separate client reads
//! it back over the real digest-control channel both ways ADR-0008 specifies — a
//! `spt digest` **snapshot pull** ([`pull_snapshot`]) and a **delta-stream**
//! ([`follow`]) — and the opt-in Path-B persistence appends settled turns to the
//! perch history store. This is the whole B8 contract over the actual named-pipe
//! / UDS transport, on both OSes.
//!
//! The child is the same cross-OS echo program the B1 broker test uses (`cat` /
//! `findstr "."`): bytes injected into its PTY round-trip back as output carrying
//! the digest boundary markers, so the engine has a real stream to parse. PTY
//! echo may duplicate a line; the assertions are therefore content-tolerant
//! (`any`), exactly as the digest parser's own repaint-tolerance contract expects.
// [int->REQ-TERM-4]

use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};

use spt_daemon::msg::SpawnReq;
use spt_daemon::{
    follow, pull_snapshot, serve_digest_control, Brain, Broker, DigestEngine, DigestHub,
};
use spt_runtime::PtyDigest;
use spt_store::perch::{resolve_perch_path, ParentHint};

static SEQ: AtomicU32 = AtomicU32::new(0);
fn unique(prefix: &str) -> String {
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    format!("spt-daemon-b8-{prefix}-{}-{}.sock", std::process::id(), n)
}

/// The cross-OS echo child: reads stdin, writes each line to stdout.
fn echo_req() -> SpawnReq {
    #[cfg(unix)]
    let (program, args) = ("cat".to_string(), Vec::<String>::new());
    #[cfg(windows)]
    let (program, args) = ("findstr".to_string(), vec![".".to_string()]);
    SpawnReq { program, args, rows: 24, cols: 80 }
}

/// The adapter-supplied digest seam, with persistence opted in.
fn seam() -> PtyDigest {
    PtyDigest {
        input_pattern: r"\nUSER> ".to_string(),
        agent_pattern: r"\nAGENT> ".to_string(),
        tool_patterns: vec![r"(?P<name>Write)\((?P<arg>[^)]*)\)".to_string()],
        catchall_pattern: None,
        window_turns: Some(5),
        persist: true,
    }
}

fn contains(hay: &[u8], needle: &[u8]) -> bool {
    hay.windows(needle.len()).any(|w| w == needle)
}

/// Block up to `dur` for `cond` to hold, polling gently.
fn wait_until(dur: Duration, mut cond: impl FnMut() -> bool) -> bool {
    let start = Instant::now();
    while start.elapsed() < dur {
        if cond() {
            return true;
        }
        thread::sleep(Duration::from_millis(15));
    }
    cond()
}

#[test]
fn digest_daemon_half_end_to_end() {
    // Isolate on-disk state (the perch + Path-B history.log) to a temp home.
    let home = tempfile::tempdir().expect("tempdir");
    std::env::set_var("SPT_HOME", home.path());

    let broker_name = unique("broker");
    let digest_name = unique("digest");

    // The digest hub + its control channel (the `spt digest` access surface).
    let hub = Arc::new(DigestHub::new());
    {
        let hub = Arc::clone(&hub);
        let name = digest_name.clone();
        thread::spawn(move || {
            let _ = serve_digest_control(&name, hub);
        });
    }

    // The broker hosts PTYs; serve it on a background thread.
    let broker = Broker::bind(&broker_name).expect("bind broker");
    {
        let broker = Arc::clone(&broker);
        thread::spawn(move || {
            let _ = broker.serve();
        });
    }

    // One brain spawns the echo child, injects the marker stream, and folds the
    // echoed output through the engine (publishing each change to the hub).
    let mut brain = connect_brain(&broker_name);
    let _sid = brain.spawn_session(echo_req()).expect("spawn echo child");
    let mut engine = DigestEngine::from_manifest(&seam()).expect("compile patterns");

    // Inject TWO complete turns + a tool sprint, then a sentinel: the first turn
    // settles (→ persistence), the second is the active turn.
    let blob = b"\nUSER> first task\nAGENT> working on it\nWrite(src/a.rs) done\nUSER> second task\nAGENT> all set\n";
    brain.send_input(blob).expect("inject markers");

    // Read the echoed output, feeding the engine + publishing, until both turns
    // have round-tripped through the real PTY.
    let mut acc = Vec::<u8>::new();
    let mut guard = 0;
    loop {
        guard += 1;
        assert!(guard < 100_000, "echoed markers never arrived: {:?}", String::from_utf8_lossy(&acc));
        if contains(&acc, b"second task") && contains(&acc, b"first task") {
            break;
        }
        match brain.read_event().expect("read event") {
            spt_daemon::BrokerEvent::Output { bytes, .. } => {
                acc.extend_from_slice(&bytes);
                spt_daemon::ingest_chunk(&mut engine, &hub, "doyle", &bytes);
            }
            spt_daemon::BrokerEvent::Exit { .. } => panic!("child exited before echo"),
            _ => {}
        }
    }

    // ── Snapshot pull over the control channel ──────────────────────────────
    let (digest, version) = pull_snapshot(&digest_name, "doyle")
        .expect("pull ok")
        .expect("a live digest exists for the daemon-hosted session");
    assert!(version >= 1, "the digest advanced at least one version");
    assert!(
        digest.turns.iter().any(|t| t.input.as_deref() == Some("first task")),
        "the snapshot carries the parsed user turn: {digest:?}"
    );
    // The render is the glanceable view; a collapsed tool sprint shows as a
    // `used: …` line (avoids depending on the internal entry enum here).
    let rendered = spt_daemon::render_digest(&digest);
    assert!(
        rendered.contains("used: Write(src/a.rs)"),
        "the snapshot carries a collapsed tool sprint: {rendered}"
    );

    // ── Delta-stream: a subscriber receives only the new change ─────────────
    let base = version;
    let (tx, rx) = std::sync::mpsc::channel::<u64>();
    {
        let dname = digest_name.clone();
        thread::spawn(move || {
            // Skip the base (== base) and stop on the first true delta (> base).
            let _ = follow(&dname, "doyle", base, |up| {
                if up.version > base {
                    let _ = tx.send(up.version);
                    false
                } else {
                    true
                }
            });
        });
    }
    // Give the subscriber a moment to register, then drive a third turn.
    thread::sleep(Duration::from_millis(80));
    brain.send_input(b"\nUSER> third task\nAGENT> reflecting\n").expect("inject third");
    // Keep feeding until the third turn round-trips (publishing new versions).
    guard = 0;
    while !contains(&acc, b"third task") {
        guard += 1;
        assert!(guard < 100_000, "third turn never echoed");
        if let spt_daemon::BrokerEvent::Output { bytes, .. } =
            brain.read_event().expect("read event")
        {
            acc.extend_from_slice(&bytes);
            spt_daemon::ingest_chunk(&mut engine, &hub, "doyle", &bytes);
        }
    }
    let delta_version = rx
        .recv_timeout(Duration::from_secs(5))
        .expect("the subscriber received a delta beyond the base");
    assert!(delta_version > base, "the delta is a newer version than the base");

    // ── Opt-in Path-B persistence: the settled turn was logged ──────────────
    let history = resolve_perch_path("doyle", ParentHint::Infer).join("history.log");
    assert!(
        wait_until(Duration::from_secs(2), || history.exists()),
        "persistence created the Path-B history log"
    );
    let log = std::fs::read_to_string(&history).expect("read history.log");
    assert!(log.contains("digest_turn"), "a digest activity record was appended: {log}");
    assert!(log.contains("first task"), "the settled turn was persisted: {log}");

    brain.kill_session().expect("kill child");
}

/// Connect a brain to the broker, retrying briefly while `accept` lags the bind.
fn connect_brain(name: &str) -> Brain {
    for _ in 0..200 {
        if let Ok(b) = Brain::cold_start(name, 1) {
            return b;
        }
        thread::sleep(Duration::from_millis(10));
    }
    panic!("could not connect a brain to the broker");
}

// A tiny matcher helper kept out of the assertion above for readability: whether a
// digest entry is a tool sprint.
#[allow(non_snake_case)]
fn spt_daemon_digest_entry_is_tool(_e: &spt_daemon::DigestEntryProbe) {}

-// A tiny matcher helper kept out of the assertion above for readability: whether a

-fn spt_daemon_digest_entry_is_tool(_e: &spt_daemon::DigestEntryProbe) {}


Bash

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

Run the B8 digest integration test


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

running 1 test
test digest_daemon_half_end_to_end ... FAILED

failures:

---- digest_daemon_half_end_to_end stdout ----

thread 'digest_daemon_half_end_to_end' (53068) panicked at crates\spt-daemon\tests\digest.rs:139:5:
the snapshot carries the parsed user turn: Digest { turns: [Turn { input: None, entries: [ToolSprint([ToolUse { name: "Write", arg: "src/a.rs" }])] }] }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

failures:
    digest_daemon_half_end_to_end

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

error: test failed, to rerun pass `-p spt-daemon --test digest`


ConPTY/PTY echoes line breaks as bare `\r` (which the parser strips), collapsing the boundaries — only the newline-free `Write(...)` matched. Real terminals submit lines on `\r`; injecting `\r` line-endings yields clean `\n` after normalization. Fix both injects:


Edit

    // Inject TWO complete turns + a tool sprint, then a sentinel: the first turn
    // settles (→ persistence), the second is the active turn.
    let blob = b"\nUSER> first task\nAGENT> working on it\nWrite(src/a.rs) done\nUSER> second task\nAGENT> all set\n";
    brain.send_input(blob).expect("inject markers");

    // Inject TWO complete turns + a tool sprint, then a sentinel: the first turn
    // settles (→ persistence), the second is the active turn. Lines END IN `\r`
    // (the Enter key): a real PTY submits + echoes a line on `\r`, and the engine
    // normalizes the echoed `\r\n` back to `\n` so the `\nUSER> ` boundary matches.
    // Injecting bare `\n` would echo as a lone `\r` the parser strips — collapsing
    // the boundaries (the cross-OS PTY stream-contract gotcha, Spike #4/#5).
    let blob = b"\rUSER> first task\rAGENT> working on it\rWrite(src/a.rs) done\rUSER> second task\rAGENT> all set\r";
    brain.send_input(blob).expect("inject markers");


    // Inject TWO complete turns + a tool sprint, then a sentinel: the first turn
    // settles (→ persistence), the second is the active turn. Lines END IN `\r`
    // (the Enter key): a real PTY submits + echoes a line on `\r`, and the engine
    // normalizes the echoed `\r\n` back to `\n` so the `\nUSER> ` boundary matches.
    // Injecting bare `\n` would echo as a lone `\r` the parser strips — collapsing
    // the boundaries (the cross-OS PTY stream-contract gotcha, Spike #4/#5).
    let blob = b"\rUSER> first task\rAGENT> working on it\rWrite(src/a.rs) done\rUSER> second task\rAGENT> all set\r";
    brain.send_input(blob).expect("inject markers");

//! B8 integration: the **PTY-digest daemon-half**, end to end through the real
//! IPC (ADR-0008 / REQ-TERM-4).
//!
//! A [`Broker`] hosts a real PTY child; a [`Brain`] folds that child's live output
//! through a [`DigestEngine`] (the brain-side parse — never the broker, ADR-0004
//! §B) and publishes each change into the [`DigestHub`]; a separate client reads
//! it back over the real digest-control channel both ways ADR-0008 specifies — a
//! `spt digest` **snapshot pull** ([`pull_snapshot`]) and a **delta-stream**
//! ([`follow`]) — and the opt-in Path-B persistence appends settled turns to the
//! perch history store. This is the whole B8 contract over the actual named-pipe
//! / UDS transport, on both OSes.
//!
//! The child is the same cross-OS echo program the B1 broker test uses (`cat` /
//! `findstr "."`): bytes injected into its PTY round-trip back as output carrying
//! the digest boundary markers, so the engine has a real stream to parse. PTY
//! echo may duplicate a line; the assertions are therefore content-tolerant
//! (`any`), exactly as the digest parser's own repaint-tolerance contract expects.
// [int->REQ-TERM-4]

use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};

use spt_daemon::msg::SpawnReq;
use spt_daemon::{
    follow, pull_snapshot, serve_digest_control, Brain, Broker, DigestEngine, DigestHub,
};
use spt_runtime::PtyDigest;
use spt_store::perch::{resolve_perch_path, ParentHint};

static SEQ: AtomicU32 = AtomicU32::new(0);
fn unique(prefix: &str) -> String {
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    format!("spt-daemon-b8-{prefix}-{}-{}.sock", std::process::id(), n)
}

/// The cross-OS echo child: reads stdin, writes each line to stdout.
fn echo_req() -> SpawnReq {
    #[cfg(unix)]
    let (program, args) = ("cat".to_string(), Vec::<String>::new());
    #[cfg(windows)]
    let (program, args) = ("findstr".to_string(), vec![".".to_string()]);
    SpawnReq { program, args, rows: 24, cols: 80 }
}

/// The adapter-supplied digest seam, with persistence opted in.
fn seam() -> PtyDigest {
    PtyDigest {
        input_pattern: r"\nUSER> ".to_string(),
        agent_pattern: r"\nAGENT> ".to_string(),
        tool_patterns: vec![r"(?P<name>Write)\((?P<arg>[^)]*)\)".to_string()],
        catchall_pattern: None,
        window_turns: Some(5),
        persist: true,
    }
}

fn contains(hay: &[u8], needle: &[u8]) -> bool {
    hay.windows(needle.len()).any(|w| w == needle)
}

/// Block up to `dur` for `cond` to hold, polling gently.
fn wait_until(dur: Duration, mut cond: impl FnMut() -> bool) -> bool {
    let start = Instant::now();
    while start.elapsed() < dur {
        if cond() {
            return true;
        }
        thread::sleep(Duration::from_millis(15));
    }
    cond()
}

#[test]
fn digest_daemon_half_end_to_end() {
    // Isolate on-disk state (the perch + Path-B history.log) to a temp home.
    let home = tempfile::tempdir().expect("tempdir");
    std::env::set_var("SPT_HOME", home.path());

    let broker_name = unique("broker");
    let digest_name = unique("digest");

    // The digest hub + its control channel (the `spt digest` access surface).
    let hub = Arc::new(DigestHub::new());
    {
        let hub = Arc::clone(&hub);
        let name = digest_name.clone();
        thread::spawn(move || {
            let _ = serve_digest_control(&name, hub);
        });
    }

    // The broker hosts PTYs; serve it on a background thread.
    let broker = Broker::bind(&broker_name).expect("bind broker");
    {
        let broker = Arc::clone(&broker);
        thread::spawn(move || {
            let _ = broker.serve();
        });
    }

    // One brain spawns the echo child, injects the marker stream, and folds the
    // echoed output through the engine (publishing each change to the hub).
    let mut brain = connect_brain(&broker_name);
    let _sid = brain.spawn_session(echo_req()).expect("spawn echo child");
    let mut engine = DigestEngine::from_manifest(&seam()).expect("compile patterns");

    // Inject TWO complete turns + a tool sprint, then a sentinel: the first turn
    // settles (→ persistence), the second is the active turn.
    let blob = b"\nUSER> first task\nAGENT> working on it\nWrite(src/a.rs) done\nUSER> second task\nAGENT> all set\n";
    brain.send_input(blob).expect("inject markers");

    // Read the echoed output, feeding the engine + publishing, until both turns
    // have round-tripped through the real PTY.
    let mut acc = Vec::<u8>::new();
    let mut guard = 0;
    loop {
        guard += 1;
        assert!(guard < 100_000, "echoed markers never arrived: {:?}", String::from_utf8_lossy(&acc));
        if contains(&acc, b"second task") && contains(&acc, b"first task") {
            break;
        }
        match brain.read_event().expect("read event") {
            spt_daemon::BrokerEvent::Output { bytes, .. } => {
                acc.extend_from_slice(&bytes);
                spt_daemon::ingest_chunk(&mut engine, &hub, "doyle", &bytes);
            }
            spt_daemon::BrokerEvent::Exit { .. } => panic!("child exited before echo"),
            _ => {}
        }
    }

    // ── Snapshot pull over the control channel ──────────────────────────────
    let (digest, version) = pull_snapshot(&digest_name, "doyle")
        .expect("pull ok")
        .expect("a live digest exists for the daemon-hosted session");
    assert!(version >= 1, "the digest advanced at least one version");
    assert!(
        digest.turns.iter().any(|t| t.input.as_deref() == Some("first task")),
        "the snapshot carries the parsed user turn: {digest:?}"
    );
    // The render is the glanceable view; a collapsed tool sprint shows as a
    // `used: …` line (avoids depending on the internal entry enum here).
    let rendered = spt_daemon::render_digest(&digest);
    assert!(
        rendered.contains("used: Write(src/a.rs)"),
        "the snapshot carries a collapsed tool sprint: {rendered}"
    );

    // ── Delta-stream: a subscriber receives only the new change ─────────────
    let base = version;
    let (tx, rx) = std::sync::mpsc::channel::<u64>();
    {
        let dname = digest_name.clone();
        thread::spawn(move || {
            // Skip the base (== base) and stop on the first true delta (> base).
            let _ = follow(&dname, "doyle", base, |up| {
                if up.version > base {
                    let _ = tx.send(up.version);
                    false
                } else {
                    true
                }
            });
        });
    }
    // Give the subscriber a moment to register, then drive a third turn.
    thread::sleep(Duration::from_millis(80));
    brain.send_input(b"\nUSER> third task\nAGENT> reflecting\n").expect("inject third");
    // Keep feeding until the third turn round-trips (publishing new versions).
    guard = 0;
    while !contains(&acc, b"third task") {
        guard += 1;
        assert!(guard < 100_000, "third turn never echoed");
        if let spt_daemon::BrokerEvent::Output { bytes, .. } =
            brain.read_event().expect("read event")
        {
            acc.extend_from_slice(&bytes);
            spt_daemon::ingest_chunk(&mut engine, &hub, "doyle", &bytes);
        }
    }
    let delta_version = rx
        .recv_timeout(Duration::from_secs(5))
        .expect("the subscriber received a delta beyond the base");
    assert!(delta_version > base, "the delta is a newer version than the base");

    // ── Opt-in Path-B persistence: the settled turn was logged ──────────────
    let history = resolve_perch_path("doyle", ParentHint::Infer).join("history.log");
    assert!(
        wait_until(Duration::from_secs(2), || history.exists()),
        "persistence created the Path-B history log"
    );
    let log = std::fs::read_to_string(&history).expect("read history.log");
    assert!(log.contains("digest_turn"), "a digest activity record was appended: {log}");
    assert!(log.contains("first task"), "the settled turn was persisted: {log}");

    brain.kill_session().expect("kill child");
}

/// Connect a brain to the broker, retrying briefly while `accept` lags the bind.
fn connect_brain(name: &str) -> Brain {
    for _ in 0..200 {
        if let Ok(b) = Brain::cold_start(name, 1) {
            return b;
        }
        thread::sleep(Duration::from_millis(10));
    }
    panic!("could not connect a brain to the broker");
}

-    // settles (→ persistence), the second is the active turn.

-    let blob = b"\nUSER> first task\nAGENT> working on it\nWrite(src/a.rs) done\nUSER> second task\nAGENT> all set\n";

+    // settles (→ persistence), the second is the active turn. Lines END IN `\r`

+    // (the Enter key): a real PTY submits + echoes a line on `\r`, and the engine

+    // normalizes the echoed `\r\n` back to `\n` so the `\nUSER> ` boundary matches.

+    // Injecting bare `\n` would echo as a lone `\r` the parser strips — collapsing

+    // the boundaries (the cross-OS PTY stream-contract gotcha, Spike #4/#5).

+    let blob = b"\rUSER> first task\rAGENT> working on it\rWrite(src/a.rs) done\rUSER> second task\rAGENT> all set\r";


brain.send_input(b"\nUSER> third task\nAGENT> reflecting\n").expect("inject third");

brain.send_input(b"\rUSER> third task\rAGENT> reflecting\r").expect("inject third");


//! B8 integration: the **PTY-digest daemon-half**, end to end through the real
//! IPC (ADR-0008 / REQ-TERM-4).
//!
//! A [`Broker`] hosts a real PTY child; a [`Brain`] folds that child's live output
//! through a [`DigestEngine`] (the brain-side parse — never the broker, ADR-0004
//! §B) and publishes each change into the [`DigestHub`]; a separate client reads
//! it back over the real digest-control channel both ways ADR-0008 specifies — a
//! `spt digest` **snapshot pull** ([`pull_snapshot`]) and a **delta-stream**
//! ([`follow`]) — and the opt-in Path-B persistence appends settled turns to the
//! perch history store. This is the whole B8 contract over the actual named-pipe
//! / UDS transport, on both OSes.
//!
//! The child is the same cross-OS echo program the B1 broker test uses (`cat` /
//! `findstr "."`): bytes injected into its PTY round-trip back as output carrying
//! the digest boundary markers, so the engine has a real stream to parse. PTY
//! echo may duplicate a line; the assertions are therefore content-tolerant
//! (`any`), exactly as the digest parser's own repaint-tolerance contract expects.
// [int->REQ-TERM-4]

use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};

use spt_daemon::msg::SpawnReq;
use spt_daemon::{
    follow, pull_snapshot, serve_digest_control, Brain, Broker, DigestEngine, DigestHub,
};
use spt_runtime::PtyDigest;
use spt_store::perch::{resolve_perch_path, ParentHint};

static SEQ: AtomicU32 = AtomicU32::new(0);
fn unique(prefix: &str) -> String {
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    format!("spt-daemon-b8-{prefix}-{}-{}.sock", std::process::id(), n)
}

/// The cross-OS echo child: reads stdin, writes each line to stdout.
fn echo_req() -> SpawnReq {
    #[cfg(unix)]
    let (program, args) = ("cat".to_string(), Vec::<String>::new());
    #[cfg(windows)]
    let (program, args) = ("findstr".to_string(), vec![".".to_string()]);
    SpawnReq { program, args, rows: 24, cols: 80 }
}

/// The adapter-supplied digest seam, with persistence opted in.
fn seam() -> PtyDigest {
    PtyDigest {
        input_pattern: r"\nUSER> ".to_string(),
        agent_pattern: r"\nAGENT> ".to_string(),
        tool_patterns: vec![r"(?P<name>Write)\((?P<arg>[^)]*)\)".to_string()],
        catchall_pattern: None,
        window_turns: Some(5),
        persist: true,
    }
}

fn contains(hay: &[u8], needle: &[u8]) -> bool {
    hay.windows(needle.len()).any(|w| w == needle)
}

/// Block up to `dur` for `cond` to hold, polling gently.
fn wait_until(dur: Duration, mut cond: impl FnMut() -> bool) -> bool {
    let start = Instant::now();
    while start.elapsed() < dur {
        if cond() {
            return true;
        }
        thread::sleep(Duration::from_millis(15));
    }
    cond()
}

#[test]
fn digest_daemon_half_end_to_end() {
    // Isolate on-disk state (the perch + Path-B history.log) to a temp home.
    let home = tempfile::tempdir().expect("tempdir");
    std::env::set_var("SPT_HOME", home.path());

    let broker_name = unique("broker");
    let digest_name = unique("digest");

    // The digest hub + its control channel (the `spt digest` access surface).
    let hub = Arc::new(DigestHub::new());
    {
        let hub = Arc::clone(&hub);
        let name = digest_name.clone();
        thread::spawn(move || {
            let _ = serve_digest_control(&name, hub);
        });
    }

    // The broker hosts PTYs; serve it on a background thread.
    let broker = Broker::bind(&broker_name).expect("bind broker");
    {
        let broker = Arc::clone(&broker);
        thread::spawn(move || {
            let _ = broker.serve();
        });
    }

    // One brain spawns the echo child, injects the marker stream, and folds the
    // echoed output through the engine (publishing each change to the hub).
    let mut brain = connect_brain(&broker_name);
    let _sid = brain.spawn_session(echo_req()).expect("spawn echo child");
    let mut engine = DigestEngine::from_manifest(&seam()).expect("compile patterns");

    // Inject TWO complete turns + a tool sprint, then a sentinel: the first turn
    // settles (→ persistence), the second is the active turn. Lines END IN `\r`
    // (the Enter key): a real PTY submits + echoes a line on `\r`, and the engine
    // normalizes the echoed `\r\n` back to `\n` so the `\nUSER> ` boundary matches.
    // Injecting bare `\n` would echo as a lone `\r` the parser strips — collapsing
    // the boundaries (the cross-OS PTY stream-contract gotcha, Spike #4/#5).
    let blob = b"\rUSER> first task\rAGENT> working on it\rWrite(src/a.rs) done\rUSER> second task\rAGENT> all set\r";
    brain.send_input(blob).expect("inject markers");

    // Read the echoed output, feeding the engine + publishing, until both turns
    // have round-tripped through the real PTY.
    let mut acc = Vec::<u8>::new();
    let mut guard = 0;
    loop {
        guard += 1;
        assert!(guard < 100_000, "echoed markers never arrived: {:?}", String::from_utf8_lossy(&acc));
        if contains(&acc, b"second task") && contains(&acc, b"first task") {
            break;
        }
        match brain.read_event().expect("read event") {
            spt_daemon::BrokerEvent::Output { bytes, .. } => {
                acc.extend_from_slice(&bytes);
                spt_daemon::ingest_chunk(&mut engine, &hub, "doyle", &bytes);
            }
            spt_daemon::BrokerEvent::Exit { .. } => panic!("child exited before echo"),
            _ => {}
        }
    }

    // ── Snapshot pull over the control channel ──────────────────────────────
    let (digest, version) = pull_snapshot(&digest_name, "doyle")
        .expect("pull ok")
        .expect("a live digest exists for the daemon-hosted session");
    assert!(version >= 1, "the digest advanced at least one version");
    assert!(
        digest.turns.iter().any(|t| t.input.as_deref() == Some("first task")),
        "the snapshot carries the parsed user turn: {digest:?}"
    );
    // The render is the glanceable view; a collapsed tool sprint shows as a
    // `used: …` line (avoids depending on the internal entry enum here).
    let rendered = spt_daemon::render_digest(&digest);
    assert!(
        rendered.contains("used: Write(src/a.rs)"),
        "the snapshot carries a collapsed tool sprint: {rendered}"
    );

    // ── Delta-stream: a subscriber receives only the new change ─────────────
    let base = version;
    let (tx, rx) = std::sync::mpsc::channel::<u64>();
    {
        let dname = digest_name.clone();
        thread::spawn(move || {
            // Skip the base (== base) and stop on the first true delta (> base).
            let _ = follow(&dname, "doyle", base, |up| {
                if up.version > base {
                    let _ = tx.send(up.version);
                    false
                } else {
                    true
                }
            });
        });
    }
    // Give the subscriber a moment to register, then drive a third turn.
    thread::sleep(Duration::from_millis(80));
    brain.send_input(b"\nUSER> third task\nAGENT> reflecting\n").expect("inject third");
    // Keep feeding until the third turn round-trips (publishing new versions).
    guard = 0;
    while !contains(&acc, b"third task") {
        guard += 1;
        assert!(guard < 100_000, "third turn never echoed");
        if let spt_daemon::BrokerEvent::Output { bytes, .. } =
            brain.read_event().expect("read event")
        {
            acc.extend_from_slice(&bytes);
            spt_daemon::ingest_chunk(&mut engine, &hub, "doyle", &bytes);
        }
    }
    let delta_version = rx
        .recv_timeout(Duration::from_secs(5))
        .expect("the subscriber received a delta beyond the base");
    assert!(delta_version > base, "the delta is a newer version than the base");

    // ── Opt-in Path-B persistence: the settled turn was logged ──────────────
    let history = resolve_perch_path("doyle", ParentHint::Infer).join("history.log");
    assert!(
        wait_until(Duration::from_secs(2), || history.exists()),
        "persistence created the Path-B history log"
    );
    let log = std::fs::read_to_string(&history).expect("read history.log");
    assert!(log.contains("digest_turn"), "a digest activity record was appended: {log}");
    assert!(log.contains("first task"), "the settled turn was persisted: {log}");

    brain.kill_session().expect("kill child");
}

/// Connect a brain to the broker, retrying briefly while `accept` lags the bind.
fn connect_brain(name: &str) -> Brain {
    for _ in 0..200 {
        if let Ok(b) = Brain::cold_start(name, 1) {
            return b;
        }
        thread::sleep(Duration::from_millis(10));
    }
    panic!("could not connect a brain to the broker");
}

-    brain.send_input(b"\nUSER> third task\nAGENT> reflecting\n").expect("inject third");

+    brain.send_input(b"\rUSER> third task\rAGENT> reflecting\r").expect("inject third");


Bash

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

Re-run the digest integration test


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

running 1 test
test digest_daemon_half_end_to_end ... ok

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


Integration test green — that's the `int` evidence. **Step 7 — activate REQ-TERM-4 `int`** in the registry:


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 = ["impl", "unit"]

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", "int"]


<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>

Error: File has not been read yet. Read it first before writing to it.


344	# ── Live activity buffer / PTY digest (ADR-0008, 2026-06-01; activate M3) ──
345	[[requirements]]
346	id = "REQ-TERM-4"
347	title = "Live activity buffer (PTY digest): adapter-supplied patterns over broker PTY, spt digest pull + delta-stream, opt-in Path-B log"
348	required_stages = ["impl", "unit"]
349	[[requirements]]

# ── Live activity buffer / PTY digest (ADR-0008, 2026-06-01; activate M3) ──
[[requirements]]
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"]
[[requirements]]


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"]

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", "int"]


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 = ["impl", "unit"]

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", "int"]

+required_stages = ["impl", "unit", "int"]

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


Now the gate: traceable-reqs check, clippy, full workspace tests.


Bash

Run traceability gate


       Harness-hosted startup: api seed then listen
  [OK] REQ-START-3  required: [impl, unit, int]  stages: -doc +impl +unit +int
       spt-hosted startup: spawn-session then api bind (no file)
  [OK] REQ-START-4  required: [impl, unit]  stages: -doc +impl +unit -int
       Adapter-injected env aliases (SPT/OWL/LIVE)
  [OK] REQ-STORE-1  required: []  stages: -doc -impl -unit -int
       spt-store::BranchStore (git branch as versioned KV; commit=checkpoint/tip=resume, atomic multi-key, merge-native sync) is the substrate for coarse/durable/audited state (context, registry snapshot+distribution, daemon checkpoint); hot paths (B5 fsync journal) + indexed queries (SQLite spool) excluded (ADR-0011)
  [OK] REQ-TERM-1  required: [impl, unit]  stages: -doc +impl +unit -int
       Process-supervisor terminal wrapper hosting broker PTYs
  [OK] REQ-TERM-2  required: [impl, unit]  stages: -doc +impl +unit -int
       session-surface abstraction; send-keys + send-line injection
  [OK] REQ-TERM-3  required: [impl, unit]  stages: -doc +impl +unit -int
       Byte-stream remote terminal streaming for v1
  [OK] REQ-TERM-4  required: [impl, unit, int]  stages: +doc +impl +unit +int
       Live activity buffer (PTY digest): adapter-supplied patterns over broker PTY, spt digest pull + delta-stream, opt-in Path-B log
  [OK] REQ-UPD-1  required: []  stages: -doc -impl -unit -int
       Peer-propagated update over P2P
  [OK] REQ-UPD-2  required: []  stages: -doc -impl -unit -int
       All binaries signature-verified before handoff
  [OK] REQ-UPD-3  required: []  stages: -doc -impl -unit -int
       No endpoint process terminates/suspends during self-update
  [OK] REQ-UPD-4  required: []  stages: -doc -impl -unit -int
       Update gated on user confirmation by default; opt-in full-auto
  [OK] REQ-UPD-5  required: []  stages: -doc -impl -unit -int
       spt-core ripple-updates registered adapters


REQ-TERM-4 fully covered (`+doc +impl +unit +int`). Full check summary + clippy + workspace tests:


traceable-reqs check 2>&1 | grep -iE "findings|FAIL|MISSING|ERROR|summary|total" | head; echo "--- exit ---"; traceable-reqs check >$null 2>&1; echo $LASTEXITCODE

Traceability findings summary + exit code


120 requirements: 120 complete, 0 incomplete, 0 findings
       Concurrent SQLite openers (registry/spool) must not fail with 'database is locked' (4.7)
       Stale registry entries degrade to fallback, never hard-fail (4.3)
--- exit ---
/usr/bin/bash: line 4: $null: ambiguous redirect


0 findings, 120/120 complete. Now clippy `-D warnings` + full workspace test:


cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -25

Clippy across workspace with -D warnings


    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)
    Checking spt-term v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-term)
    Checking spt-runtime v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-runtime)
    Checking spt-live v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-live)
    Checking mock-adapter v0.0.0 (C:\Users\decid\Documents\projects\spt-core\adapters\mock)
    Checking spt-daemon v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
    Checking spt v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.29s


Clippy clean. Full workspace test:


cargo test --workspace 2>&1 | grep -iE "test result|running|error\[|FAILED|panicked" | tail -50

Run full workspace test suite


     Running unittests src\lib.rs (target\debug\deps\spt_live-05605b7918bd90a7.exe)
running 35 tests
test result: ok. 35 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.32s
     Running unittests src\lib.rs (target\debug\deps\spt_msg-2380ff08cca80cc7.exe)
running 22 tests
test result: ok. 22 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.17s
     Running tests\killer_quickstart.rs (target\debug\deps\killer_quickstart-cbdb2a396ec983ec.exe)
running 2 tests
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.20s
     Running unittests src\lib.rs (target\debug\deps\spt_proto-151dd96e47c9f83c.exe)
running 49 tests
test result: ok. 49 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s
     Running unittests src\lib.rs (target\debug\deps\spt_runtime-02b505711840dc63.exe)
running 20 tests
test result: ok. 20 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.31s
     Running unittests src\lib.rs (target\debug\deps\spt_store-7f8ef5ed5a775913.exe)
running 42 tests
test result: ok. 42 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.14s
     Running unittests src\lib.rs (target\debug\deps\spt_term-4bf162e7406ae12c.exe)
running 8 tests
test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
     Running tests\digest.rs (target\debug\deps\digest-dd11ad5e1d83b0d1.exe)
running 1 test
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s
     Running tests\dsr.rs (target\debug\deps\dsr-bf7afeae5d3de6b1.exe)
running 2 tests
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s
     Running tests\inject.rs (target\debug\deps\inject-b94392081b9e2213.exe)
running 2 tests
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s
     Running tests\stream.rs (target\debug\deps\stream-3ec500fc2f9c7914.exe)
running 2 tests
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.01s
     Running tests\surface.rs (target\debug\deps\surface-efbd6b452e946c18.exe)
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
running 0 tests
test result: ok. 0 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
running 0 tests
test result: ok. 0 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
running 0 tests
test result: ok. 0 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


Let me confirm spt-daemon + spt suites + any failures across the whole run:


cargo test --workspace 2>&1 | grep -iE "FAILED|error\[|panicked|^error" | head; echo "=== exit:$? (grep found none above = clean) ==="; cargo test --workspace 2>&1 | grep -c "test result: ok"

Check for any failures + count ok results


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. 41 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.29s
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.31s
test effect::tests::failed_effect_is_not_recorded_applied ... ok
test result: ok. 48 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.39s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.14s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.19s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.08s
=== exit:0 (grep found none above = clean) ===
28


Full gate green: **traceable-reqs 120/120 · 0 findings · REQ-TERM-4 `+doc+impl+unit+int` · clippy `-D warnings` clean · `cargo test --workspace` 0 failures** (incl. the new digest int-test + contract_e2e 4/4 preserved).

Let me check git state to commit B8:


git -C C:/Users/decid/Documents/projects/spt-core status --short 2>&1 | head -40; echo "=== branch ==="; git -C C:/Users/decid/Documents/projects/spt-core branch --show-current 2>&1

Git status + current branch


 M Cargo.lock
 M crates/spt-daemon/Cargo.toml
 M crates/spt-daemon/src/brain.rs
 M crates/spt-daemon/src/daemon.rs
 M crates/spt-daemon/src/endpoint.rs
 M crates/spt-daemon/src/lib.rs
 M crates/spt-runtime/src/lib.rs
 M crates/spt-runtime/src/manifest.rs
 M crates/spt-store/src/lib.rs
 M crates/spt-term/Cargo.toml
 M crates/spt-term/src/digest.rs
 M crates/spt/src/api/reporting.rs
 M crates/spt/src/cli.rs
 M docs/MANIFEST.md
 M traceable-reqs.toml
?? crates/spt-daemon/src/digest.rs
?? crates/spt-daemon/src/digesthub.rs
?? crates/spt-daemon/tests/digest.rs
?? crates/spt-store/src/history.rs
=== branch ===
dev-freeform


Let me check the project's commit trailer convention, then commit B8:


git -C C:/Users/decid/Documents/projects/spt-core log -3 --format='%s%n%b%n---' 2>&1 | head -40

Recent commit messages for trailer convention


feat(daemon): B7 — orphan-watch supervised-crash signoff (REQ-DAEMON-4)
Carry the M2b graceful signoff ordering into the supervised-crash path the
always-on daemon now enables (M2b delivered graceful-only).

The graceful path is an intentional shutdown: zero grace + always-gone recheck.
An unexpected harness death is different — the Self may recover across a brief
window (binary handoff relaunch, stale poll). So supervised_crash_signoff runs
the IDENTICAL load-bearing ordering — echo-commune (3.3) -> grace wait (1.1) ->
liveness recheck -> teardown — but with the real config grace + a real recheck.
A Self that rebound across the grace window is spared (phantom-INIT_SIGNOFF, 1.1).

- config: crash_grace knob (DEFAULT_CRASH_GRACE 2s, crash_grace_ms override).
- lifecycle: factor the shared signoff wiring behind graceful + crash paths so
  the only two things that differ are exactly the two hazard 1.1 turns on (grace
  window, recheck verdict) — the crash path can never drift out of echo-before-
  teardown. harness_owner_gone anchors liveness on the stable parent_pid, never
  an ephemeral poll pid (2.1); run_orphan_watch sweeps a stale sentinel on start
  (3.2), polls the owner, and fires the crash signoff on observed death.

REQ-DAEMON-4 activated ["impl","unit"]; int via the B9 daemon E2E.
Gate: traceable-reqs 120/0 · clippy -D warnings · cargo test --workspace 0 fail.

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

---
feat(daemon): B6 — auto-start + in-memory seed map (REQ-DAEMON-3, REQ-START-3)
Stand up the per-machine daemon process and retire the spt-store seed FILE
bridge, replacing it with the daemon's in-memory seed map over a versioned
local-IPC seed-control channel.

- spt-daemon: endpoint.rs (per-home socket names + pid path, shared by the
  auto-start spawn via inherited SPT_HOME), seedmap.rs (SeedRegistry +
  put/take/ping control protocol, thread-per-connection serve), daemon.rs
  (Daemon::run binds broker + seed-control; is_running/ensure_running auto-start).
- spt: hidden `spt daemon` subcommand; ensure_daemon() at the top of `api::run`
  (any api invocation auto-starts the daemon); cmd_seed PUTs to the daemon
  (no file written); bind_from_seed TAKEs (consume-once) from the daemon.
- spt-store::seed: retire the file I/O (write/read/remove/paths) — keep only the
  shared Seed data contract; the daemon now holds the seed in memory.


cd C:/Users/decid/Documents/projects/spt-core && git add -A && git commit -F - <<'EOF'
feat(daemon): B8 — PTY-digest daemon-half (REQ-TERM-4 int)

Complete the live activity buffer (ADR-0008): the manifest pty_digest seam
sourcing adapter-supplied patterns, the M3a DigestParser run over the broker
PTY as a BRAIN-side projection, `spt digest <id>` snapshot pull + delta-stream
on the R-TERM-3 substrate, and opt-in Path-B persistence. Local addressing
only (the [subnet:]id@node cross-node form is M4).

Architecture: the parse is logic, so it lives in the brain, never the broker
(ADR-0004 §B; M3b-PLAN forbids parse-in-broker). ADR-0008's "broker-pushed"
wording predates the strict split — raw bytes (R-TERM-3, broker-owned) and the
parsed digest (brain-owned) are the "two channels on one session surface".

- spt-runtime: [pty_digest] manifest seam (input/agent boundary patterns,
  tool_patterns[] + optional catchall, window_turns override, opt-in persist).
  Declarative strings only — spt-runtime is a sibling of spt-term, so the
  DigestPatterns conversion lives in spt-daemon. Doc'd in docs/MANIFEST.md.
- spt-term: serde-derive the digest data types (Digest/Turn/DigestEntry/ToolUse)
  so they cross the IPC + control channel.
- spt-store::history: the Path-B append_record primitive, lifted out of the spt
  binary's reporting (the daemon, below spt, persists digests) — one home,
  shared by `api history-log` and the digest's Option (C) log.
- spt-daemon: DigestEngine (DigestParser + index-anchored deltas: truncate-to-
  `from` + append; a window slide naturally yields from=0; completed-turn
  persistence deduped by value), DigestHub + digest-control channel (snapshot
  pull + delta-stream subscribe, mirroring the seed-control surface),
  Brain::run_digest_feed + ingest_chunk (the reusable broker→engine→hub→persist
  feed), digest_socket_name, Daemon::run serves the channel, render helpers.
- spt: `spt digest <id> [--follow] [--json]` — a thin printer over the daemon's
  pull/follow + render; refuses qualified cross-node ids (M4).

The per-session feed that POPULATES the hub is driven by the brain (the parse is
logic); a bare daemon serves the channel and answers "no live digest" until a
session is hosted. The int test wires a real broker PTY + brain feed + control
channel to prove the whole path end to end.

REQ-TERM-4 activated ["impl","unit","int"]; int via tests/digest.rs (real PTY
child → brain feed → snapshot + delta-stream + Path-B persistence over the
actual named-pipe/UDS transport). Broker/B2 gapless-handoff + spawn_detached
untouched.
Gate: traceable-reqs 120/0 · clippy -D warnings · cargo test --workspace 0 fail.

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

Commit B8


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-daemon/Cargo.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/brain.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/daemon.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/endpoint.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/lib.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-runtime/src/lib.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-runtime/src/manifest.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-store/src/lib.rs', 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/digest.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt/src/api/reporting.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt/src/cli.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'docs/MANIFEST.md', 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-daemon/src/digest.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/digesthub.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/tests/digest.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-store/src/history.rs', LF will be replaced by CRLF the next time Git touches it
[dev-freeform 16f4c8e] feat(daemon): B8 — PTY-digest daemon-half (REQ-TERM-4 int)
 19 files changed, 1369 insertions(+), 22 deletions(-)
 create mode 100644 crates/spt-daemon/src/digest.rs
 create mode 100644 crates/spt-daemon/src/digesthub.rs
 create mode 100644 crates/spt-daemon/tests/digest.rs
 create mode 100644 crates/spt-store/src/history.rs


B8 committed (`16f4c8e`, dev-freeform). Gate green.

**B8 done — PTY-digest daemon-half (REQ-TERM-4 `int`).** What landed:
- `[pty_digest]` manifest seam (spt-runtime) + MANIFEST.md doc
- spt-term digest types serde-derived (cross the IPC)
- `spt-store::history::append_record` (Path-B home, lifted below `spt`)
- `DigestEngine` (brain-side parse, index-anchored deltas, deduped persist) + `DigestHub` + digest-control channel + `Brain::run_digest_feed`/`ingest_chunk`
- `spt digest <id> [--follow] [--json]` CLI (local-only; M4 cross-node refused)
- real-PTY int test: broker → brain feed → snapshot + delta-stream + Path-B persist

Key call: parse = logic → **brain-side, not broker** (ADR-0004 §B; M3b-PLAN forbids parse-in-broker). Broker/B2 gapless-handoff + spawn_detached untouched.

Context high after B8. Per CLAUDE.md self-management: commune + self-clear to pick up **B9 (activation sweep + E2E)** fresh. Dropping commune:


Write

## B8 DONE + committed (16f4c8e, dev-freeform, tree clean)

**REQ-TERM-4 now ["impl","unit","int"]** — the PTY-digest daemon-half (ADR-0008) is complete. Gate green: traceable-reqs 120/0 findings · clippy -D warnings · cargo test --workspace 0 failures (incl. the new real-PTY digest int-test + contract_e2e 4/4 preserved).

**What B8 built:**
- **spt-runtime** `[pty_digest]` manifest seam: `input_pattern`/`agent_pattern` (required boundaries), `tool_patterns[]` + optional `catchall_pattern`, `window_turns` override, opt-in `persist`. Declarative regex strings only — spt-runtime is a SIBLING of spt-term, so the `DigestPatterns` conversion lives in spt-daemon, not here. Doc'd in docs/MANIFEST.md (`<!-- [doc->REQ-TERM-4] -->`).
- **spt-term**: serde-derived the digest data types (Digest/Turn/DigestEntry/ToolUse) so they cross the IPC + control channel.
- **spt-store::history**: lifted the Path-B `append_record` primitive out of the `spt` binary's reporting into the store layer (the daemon, BELOW spt, persists digests). `reporting::append_history` now delegates. One home, shared.
- **spt-daemon**: `digest.rs` (DigestEngine: DigestParser + index-anchored deltas — apply = truncate-to-`from` + append; a window slide naturally yields `from=0`; completed-turn persistence deduped by value + `ingest_chunk` bridge), `digesthub.rs` (DigestHub registry + digest-control channel: snapshot-pull + delta-stream subscribe, mirroring the seed-control surface, + render/json helpers), `Brain::run_digest_feed` + `Brain::attach` (reusable broker→engine→hub→persist feed), `endpoint::digest_socket_name`, `Daemon::run` serves the digest-control channel.
- **spt**: `spt digest <id> [--follow] [--json]` — thin printer over the daemon's pull/follow + render; refuses qualified cross-node ids (M4).

**Architecture call (binding):** the parse is LOGIC → DigestEngine runs brain-side, NEVER in the broker (ADR-0004 §B; M3b-PLAN risk forbids parse-in-broker). ADR-0008's "broker-pushed" wording predates the strict split — raw bytes (R-TERM-3, broker-owned) and the parsed digest (brain-owned) are the "two channels on one session surface".

**Known gap (B9/consolidation):** a bare daemon SERVES the digest-control channel but nothing auto-FEEDS the hub yet — the per-session feed (`run_digest_feed`) is driven by the brain, wired as daemon-hosted sessions are spawned through the broker; that auto-spawn-of-live-agent-session-into-broker glue is the broader consolidation, not yet present. The int test wires a real feed manually to prove the path end-to-end. A bare `spt digest <id>` answers "NO_DIGEST" until a session is hosted.

**Cross-OS gotcha (cost me a test cycle):** a real PTY echoes injected line breaks as bare `\r` (which the parser strips), collapsing the `\nUSER> ` boundaries. Inject `\r` line-endings (the Enter key) — the terminal submits+echoes the line as `\r\n` → parser normalizes to `\n` → boundary matches. Bare `\n` injection fails. (Spike #4/#5 stream-contract.)

## Next: B9 — activation sweep + E2E (the LAST M3b task)

Per M3b-PLAN B9 row: activate REQ-DAEMON-1/2/3/4 + REQ-START-3 + the 4 hazards + REQ-TERM-4(int) — most already activated as their tasks landed; B9 confirms the full set. Author a **daemon E2E** (spawn→Psyche-loop→commune→**brain-restart-survives**→graceful-signoff) on the CI matrix — this is the cross-cutting `int` evidence for REQ-DAEMON-1/2. Amend ROADMAP (M3b delivered) + CONTEXT. Author the **M3c plan stub**. Gate: check green with M3b reqs activated; clippy -D warnings; CI matrix (ubuntu+windows) green; both-OS validated (Windows local + Linux via gravity-linux).

**Watch:** don't regress spawn_detached (Windows handle-inheritance guard) or B2 gapless-handoff. The daemon process model means tests that exec `spt api …` auto-spawn a real daemon unless an in-process one is pre-started (start_inproc_daemon in contract_e2e).

**End goal (M3b):** replace the interim no-daemon model with ADR-0004's broker/brain spt-daemon behind versioned local IPC so a brain self-update swaps with zero endpoint interruption. **Project:** harness-independent core for an agent ecosystem — clean-room rebuild of claude_skill_owl.


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

## B8 DONE + committed (16f4c8e, dev-freeform, tree clean)

**REQ-TERM-4 now ["impl","unit","int"]** — the PTY-digest daemon-half (ADR-0008) is complete. Gate green: traceable-reqs 120/0 findings · clippy -D warnings · cargo test --workspace 0 failures (incl. the new real-PTY digest int-test + contract_e2e 4/4 preserved).

**What B8 built:**
- **spt-runtime** `[pty_digest]` manifest seam: `input_pattern`/`agent_pattern` (required boundaries), `tool_patterns[]` + optional `catchall_pattern`, `window_turns` override, opt-in `persist`. Declarative regex strings only — spt-runtime is a SIBLING of spt-term, so the `DigestPatterns` conversion lives in spt-daemon, not here. Doc'd in docs/MANIFEST.md (`<!-- [doc->REQ-TERM-4] -->`).
- **spt-term**: serde-derived the digest data types (Digest/Turn/DigestEntry/ToolUse) so they cross the IPC + control channel.
- **spt-store::history**: lifted the Path-B `append_record` primitive out of the `spt` binary's reporting into the store layer (the daemon, BELOW spt, persists digests). `reporting::append_history` now delegates. One home, shared.
- **spt-daemon**: `digest.rs` (DigestEngine: DigestParser + index-anchored deltas — apply = truncate-to-`from` + append; a window slide naturally yields `from=0`; completed-turn persistence deduped by value + `ingest_chunk` bridge), `digesthub.rs` (DigestHub registry + digest-control channel: snapshot-pull + delta-stream subscribe, mirroring the seed-control surface, + render/json helpers), `Brain::run_digest_feed` + `Brain::attach` (reusable broker→engine→hub→persist feed), `endpoint::digest_socket_name`, `Daemon::run` serves the digest-control channel.
- **spt**: `spt digest <id> [--follow] [--json]` — thin printer over the daemon's pull/follow + render; refuses qualified cross-node ids (M4).

**Architecture call (binding):** the parse is LOGIC → DigestEngine runs brain-side, NEVER in the broker (ADR-0004 §B; M3b-PLAN risk forbids parse-in-broker). ADR-0008's "broker-pushed" wording predates the strict split — raw bytes (R-TERM-3, broker-owned) and the parsed digest (brain-owned) are the "two channels on one session surface".

**Known gap (B9/consolidation):** a bare daemon SERVES the digest-control channel but nothing auto-FEEDS the hub yet — the per-session feed (`run_digest_feed`) is driven by the brain, wired as daemon-hosted sessions are spawned through the broker; that auto-spawn-of-live-agent-session-into-broker glue is the broader consolidation, not yet present. The int test wires a real feed manually to prove the path end-to-end. A bare `spt digest <id>` answers "NO_DIGEST" until a session is hosted.

**Cross-OS gotcha (cost me a test cycle):** a real PTY echoes injected line breaks as bare `\r` (which the parser strips), collapsing the `\nUSER> ` boundaries. Inject `\r` line-endings (the Enter key) — the terminal submits+echoes the line as `\r\n` → parser normalizes to `\n` → boundary matches. Bare `\n` injection fails. (Spike #4/#5 stream-contract.)

## Next: B9 — activation sweep + E2E (the LAST M3b task)

Per M3b-PLAN B9 row: activate REQ-DAEMON-1/2/3/4 + REQ-START-3 + the 4 hazards + REQ-TERM-4(int) — most already activated as their tasks landed; B9 confirms the full set. Author a **daemon E2E** (spawn→Psyche-loop→commune→**brain-restart-survives**→graceful-signoff) on the CI matrix — this is the cross-cutting `int` evidence for REQ-DAEMON-1/2. Amend ROADMAP (M3b delivered) + CONTEXT. Author the **M3c plan stub**. Gate: check green with M3b reqs activated; clippy -D warnings; CI matrix (ubuntu+windows) green; both-OS validated (Windows local + Linux via gravity-linux).

**Watch:** don't regress spawn_detached (Windows handle-inheritance guard) or B2 gapless-handoff. The daemon process model means tests that exec `spt api …` auto-spawn a real daemon unless an in-process one is pre-started (start_inproc_daemon in contract_e2e).

**End goal (M3b):** replace the interim no-daemon model with ADR-0004's broker/brain spt-daemon behind versioned local IPC so a brain self-update swaps with zero endpoint interruption. **Project:** harness-independent core for an agent ecosystem — clean-room rebuild of claude_skill_owl.


Commune dropped (Psyche will ingest + delete it). Now self-clear to resume B9 fresh:


Skill

B8 done + committed (16f4c8e, dev-freeform). REQ-TERM-4 at ["impl","unit","int"] — PTY-digest daemon-half complete, gate green. Next: B9 — activation sweep + E2E (LAST M3b task). Orient: M3b-PLAN B9 row. Activate REQ-DAEMON-1/2/3/4 + REQ-START-3 + 4 hazards + REQ-TERM-4(int) (most already active); author daemon E2E (spawn→Psyche-loop→commune→brain-restart-survives→graceful-signoff) = cross-cutting int for REQ-DAEMON-1/2; amend ROADMAP (M3b delivered) + CONTEXT; author M3c plan stub. Don't regress spawn_detached (Windows handle-inherit guard) or B2 gapless-handoff. Both-OS validate (Windows local + Linux via gravity-linux).


Launching skill: self-clear


Base directory for this skill: C:\Users\decid\.claude\skills\self-clear

# /self-clear

Wipes THIS agent's conversation (`/clear`), then ~10s later TYPES a self-note
as a fresh user prompt so the agent wakes on a clean context and proceeds.

## Run it

One script does everything. Just run:

```powershell
& "C:\Users\decid\.claude\skills\self-clear\scripts\self-clear.ps1"
```

Expect: `SELF-CLEAR armed: id=<agent_id> winpid=<n> hwnd=<n> — /clear queued; self-note typed in ~10s`

Then stop — end your turn promptly so the queued `/clear` fires.

## What the script does

1. `owl whoami` → resolves this session's `agent_id` (context-independent, survives the wipe). No perch → it aborts WITHOUT clearing and tells you to `/spt:ready` or `/spt:live` first.
2. Walks the parent chain (`pwsh → claude.exe → … → WindowsTerminal`) to the ancestor that owns the window, foregrounds it.
3. Types `/clear` + Enter — **queued**; only fires when this turn ends.
4. Spawns a **detached** waker: waits 10s (past turn-end + wipe), re-foregrounds the terminal via the arm-time `hwnd`/`winpid`, then **types** `NOTE FROM THE USER: proceed with next steps.` + Enter as a fresh user prompt.

The 10s delay + detachment are deliberate: the prompt must be typed **after**
`/clear` takes effect, so it lands on the clean context and starts a new turn.

Why typed, not `owl send`: after `/clear` the session sits idle awaiting user
input. A spooled owl message can't self-start a turn — it surfaces only during an
already-active turn (PreToolUse hook) or in a live Monitor stream (whose buffer
`/clear` wipes). So a self-addressed owl note never woke the agent. A typed prompt
deterministically starts a turn AND is the re-seed.

## Caveats

- Window focus brings the whole terminal forward; it can't target a specific tab. Clean when the agent's tab is active.
- The note is typed via SendKeys, so the terminal must stay the foreground-able window of `winpid`. Don't close/replace the terminal in the ~10s gap.
- SendKeys reserves `+ ^ % ~ ( ) { } [ ]`; the default note text uses none. If you customize the note, escape those chars.
- If your turn runs longer than ~10s after the script returns, the prompt could be typed before the wipe completes. Stop promptly after running.

ARGUMENTS: B8 done + committed (16f4c8e, dev-freeform). REQ-TERM-4 at ["impl","unit","int"] — PTY-digest daemon-half complete, gate green. Next: B9 — activation sweep + E2E (LAST M3b task). Orient: M3b-PLAN B9 row. Activate REQ-DAEMON-1/2/3/4 + REQ-START-3 + 4 hazards + REQ-TERM-4(int) (most already active); author daemon E2E (spawn→Psyche-loop→commune→brain-restart-survives→graceful-signoff) = cross-cutting int for REQ-DAEMON-1/2; amend ROADMAP (M3b delivered) + CONTEXT; author M3c plan stub. Don't regress spawn_detached (Windows handle-inherit guard) or B2 gapless-handoff. Both-OS validate (Windows local + Linux via gravity-linux).


Let me check the self-clear script for a custom-note parameter so the re-seed carries B9 context (not the stale default):


Bash

grep -niE "param|\[string\]|Note|args|NOTE FROM" "C:/Users/decid/.claude/skills/self-clear/scripts/self-clear.ps1" | head -30

Inspect self-clear script for a note parameter


8:#      TYPES the self-note as a fresh user prompt + Enter.
14:# Why TYPE the note (not `owl send`): after /clear the session sits IDLE awaiting user
61:# --- 4. Detached delayed waker (10s) — re-foregrounds + TYPES the note as a fresh prompt ---
63:$note = 'NOTE FROM THE USER: proceed with next steps.'
81:`$ws.SendKeys('$note')
88:"SELF-CLEAR armed: id=$id winpid=$winpid hwnd=$hwnd — /clear queued; self-note typed in ~10s"


/clear


Script takes no custom-note param (hardcoded "proceed with next steps."). That's fine — the fresh session orients from git (B8 committed `16f4c8e`), M3b-PLAN, and the just-dropped commune the Psyche will echo back. Running the self-clear:


PowerShell

Arm self-clear: queue /clear + schedule re-seed


SELF-CLEAR armed: id=doyle winpid=29328 hwnd=133338 — /clear queued; self-note typed in ~10s


Self-clear armed (`id=doyle`, `/clear` queued, re-seed in ~10s). Ending turn now so the wipe fires.

**B8 complete** — PTY-digest daemon-half shipped + committed (`16f4c8e`), REQ-TERM-4 at `["impl","unit","int"]`, full gate green. Commune dropped for the Psyche. Fresh context picks up **B9** (the last M3b task). Stopping.