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

//! Path-B native history store — the append-only `history.log` under a perch.
//!
//! Path B (CONTEXT §history subsystem) is the *native* transcript strategy: the
//! adapter (or spt-core itself) pushes already-normalized records and spt-core
//! stores them, rather than locating + parsing a version-fragile harness
//! transcript. The store is deliberately trivial — one newline-delimited record
//! per line, append-only — so it is a commodity store operation, not logic.
//!
//! Two callers append here today: `api history-log` (an adapter pushing a
//! normalized conversation record, REQ-API-2) and the M3b PTY-digest's opt-in
//! "Option (C)" persistence (a coarse activity-log record per completed turn,
//! ADR-0008 / REQ-TERM-4). Both want the same primitive, and it must live below
//! the `spt` binary (the daemon, which is below `spt`, persists digests) — so it
//! sits here in the store layer, the lowest common home.
// [impl->REQ-API-2]

use std::io::Write;
use std::path::Path;

/// Append a single normalized `record` to `<perch_path>/history.log`, creating
/// the perch dir + file if absent. A trailing newline is ensured (the store is
/// one record per line). Returns the number of bytes written (record + newline).
pub fn append_record(perch_path: &Path, record: &str) -> std::io::Result<usize> {
    std::fs::create_dir_all(perch_path)?;
    let mut f = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(perch_path.join("history.log"))?;
    let line = if record.ends_with('\n') {
        record.to_string()
    } else {
        format!("{record}\n")
    };
    f.write_all(line.as_bytes())?;
    Ok(line.len())
}

#[cfg(test)]
mod tests {
    use super::*;

    // [unit->REQ-API-2] records append one-per-line, file auto-created.
    #[test]
    fn appends_one_record_per_line() {
        let dir = tempfile::tempdir().unwrap();
        let perch = dir.path().join("perch");
        append_record(&perch, "{\"role\":\"user\"}").unwrap();
        append_record(&perch, "{\"role\":\"assistant\"}\n").unwrap();
        let log = std::fs::read_to_string(perch.join("history.log")).unwrap();
        assert_eq!(log.lines().count(), 2, "two records, two lines");
        assert!(log.lines().all(|l| !l.is_empty()));
    }
}


Edit

pub mod atomic;
mod db;
pub mod history;
pub mod info;


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

pub mod atomic;
mod db;
pub mod history;
pub mod info;

//! `spt-store` — the persistence commodity layer for spt-core.
//!
//! Sits directly above `spt-proto` (R-ARCH-1 layering): it serializes proto
//! types to disk (spool, registry, info.json) through a single atomic-write
//! primitive. Stable on-disk formats are copied verbatim from the sister
//! project (ADR-0001); the EBUSY-retry hardening on the write path is the
//! clean-room addition demanded by KNOWN-HAZARDS 5.2.
//!
// Second layer of the acyclically-layered crate stack (R-ARCH-1): spt-store
// depends only on spt-proto (one layer down), never the reverse — the
// dependency edge is one-way, so the workspace graph stays acyclic.
// [impl->REQ-ARCH-1]

pub mod atomic;
mod db;
pub mod info;
pub mod liveness;
pub mod perch;
pub mod proc;
pub mod registry;
pub mod seed;
pub mod spool;

pub mod atomic;
mod db;
+pub mod history;
pub mod info;
pub mod liveness;
pub mod perch;


Now refactor `reporting::append_history` to delegate:


/// Append a normalized record to the perch's Path-B native history store,
/// returning bytes written. Separated for unit-testability.
pub fn append_history(id: &str, record: &str) -> std::io::Result<usize> {
    use std::io::Write;
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    std::fs::create_dir_all(&perch_path)?;
    let mut f = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(perch_path.join("history.log"))?;
    let line = if record.ends_with('\n') {
        record.to_string()
    } else {
        format!("{record}\n")
    };
    f.write_all(line.as_bytes())?;
    Ok(line.len())
}

/// Append a normalized record to the perch's Path-B native history store,
/// returning bytes written. Thin wrapper over the store-layer primitive
/// ([`spt_store::history::append_record`]) — shared with the M3b digest's opt-in
/// persistence so the append behavior lives in exactly one place.
pub fn append_history(id: &str, record: &str) -> std::io::Result<usize> {
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    spt_store::history::append_record(&perch_path, record)
}


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

/// Append a normalized record to the perch's Path-B native history store,
/// returning bytes written. Separated for unit-testability.
pub fn append_history(id: &str, record: &str) -> std::io::Result<usize> {
    use std::io::Write;
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    std::fs::create_dir_all(&perch_path)?;
    let mut f = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(perch_path.join("history.log"))?;
    let line = if record.ends_with('\n') {
        record.to_string()
    } else {
        format!("{record}\n")
    };
    f.write_all(line.as_bytes())?;
    Ok(line.len())
}

/// Append a normalized record to the perch's Path-B native history store,
/// returning bytes written. Thin wrapper over the store-layer primitive
/// ([`spt_store::history::append_record`]) — shared with the M3b digest's opt-in
/// persistence so the append behavior lives in exactly one place.
pub fn append_history(id: &str, record: &str) -> std::io::Result<usize> {
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    spt_store::history::append_record(&perch_path, record)
}

-/// returning bytes written. Separated for unit-testability.

+/// returning bytes written. Thin wrapper over the store-layer primitive

+/// ([`spt_store::history::append_record`]) — shared with the M3b digest's opt-in

+/// persistence so the append behavior lives in exactly one place.

-    std::fs::create_dir_all(&perch_path)?;

-    let mut f = std::fs::OpenOptions::new()

-        .open(perch_path.join("history.log"))?;

-    let line = if record.ends_with('\n') {

+    spt_store::history::append_record(&perch_path, record)

/// `api history-log <id>` — Path-B ingest: read a normalized record from stdin


Now the **`DigestEngine`** module (spt-daemon):


Write

//! The PTY-digest engine (B8) — the **brain-side** projection of the live
//! activity buffer (ADR-0008, REQ-TERM-4).
//!
//! ## Why brain-side, not broker-side
//!
//! ADR-0008 frames the digest as a "broker-pushed structured-delta stream," but
//! that wording predates the strict ADR-0004 §B split. Running adapter-supplied
//! regexes over a byte stream is **logic** — routing/registry/parse is exactly
//! what the broker must NOT hold (M3b-PLAN risk: "a task that finds itself
//! putting parse in the broker has crossed the line"). So the digest is a
//! brain-side projection: the brain already subscribes to the broker's R-TERM-3
//! output stream for a session ([`crate::brain::Brain`]); this engine folds those
//! same bytes through the M3a [`DigestParser`] and publishes the structured
//! result. Raw bytes (R-TERM-3) and the parsed digest are the "two channels on
//! the one session surface" ADR-0008 describes — the broker owns the bytes, the
//! brain owns the parse.
//!
//! ## Snapshot vs delta
//!
//! [`DigestEngine::feed`] returns a [`DigestUpdate`] only when the digest changed,
//! so a subscriber receives **only deltas**, never a re-push of an unchanged
//! buffer. An update is either:
//! - **`full`** — replace the whole view with `turns` (sent on first subscribe
//!   and whenever the window *composition* shifts: the oldest turn dropped or the
//!   turn count changed), or
//! - **tail** (`full = false`) — the last `turns.len()` turns changed *in place*
//!   (the steady state: the active turn grew as the agent produced more output).
//!   The subscriber overwrites that many turns at its tail.
//!
//! ## Opt-in Path-B persistence (ADR-0008 "Option (C)")
//!
//! When enabled, [`DigestUpdate::newly_completed`] carries each turn that settled
//! this feed (every turn but the still-active last one, deduped against the last
//! persisted turn). The feed driver appends them to the perch history store as a
//! **coarse** activity log — explicitly not a replacement for Path A/B transcript
//! history. Because the engine is fed every output chunk, a completed turn is
//! observed long before the rolling window could evict it, so the dedup-by-value
//! tracking never drops or double-writes a turn in practice (a multi-turn single
//! feed is the only degenerate case, and it can at worst re-emit in-window turns —
//! bounded by the window depth, acceptable for a coarse log).
// [impl->REQ-TERM-4]

use spt_runtime::PtyDigest;
use spt_term::{Digest, DigestConfig, DigestError, DigestParser, DigestPatterns, Turn};

/// A structured digest change to push to a subscriber. Sent only when the digest
/// actually changed (so the stream carries deltas, not heartbeats).
#[derive(Debug, Clone, PartialEq)]
pub struct DigestUpdate {
    /// Monotonic version of the digest after this change (starts at 1).
    pub version: u64,
    /// `true` ⇒ replace the entire view with `turns`; `false` ⇒ the last
    /// `turns.len()` turns changed in place (tail-overwrite).
    pub full: bool,
    /// The changed turns (the whole window when `full`, else the changed tail).
    pub turns: Vec<Turn>,
    /// Turns that settled this feed (for opt-in Path-B persistence). Empty when
    /// persistence is off or nothing newly completed.
    pub newly_completed: Vec<Turn>,
}

/// The brain-side digest engine for one session: a [`DigestParser`] plus the
/// change-tracking that turns "the whole window, re-derived" into pushable
/// deltas + completed-turn persistence.
pub struct DigestEngine {
    parser: DigestParser,
    /// The last digest we computed (the diff base + the version anchor).
    last: Digest,
    /// Monotonic version, bumped on each observed change.
    version: u64,
    /// Whether opt-in Path-B persistence is requested for this endpoint.
    persist: bool,
    /// The most recent turn handed to persistence — the dedup floor so a turn is
    /// appended exactly once as it settles.
    persisted_floor: Option<Turn>,
}

impl DigestEngine {
    /// Build an engine from the manifest [`PtyDigest`] seam. Compiles the
    /// adapter-supplied patterns (returning [`DigestError`] naming the first that
    /// fails) and applies the spt-core presentation defaults, with `window_turns`
    /// overridden when the manifest sets it.
    // [impl->REQ-TERM-4]
    pub fn from_manifest(seam: &PtyDigest) -> Result<Self, DigestError> {
        let patterns = DigestPatterns {
            input: seam.input_pattern.clone(),
            agent: seam.agent_pattern.clone(),
            tools: seam.tool_patterns.clone(),
            catchall: seam.catchall_pattern.clone(),
        };
        let mut config = DigestConfig::default();
        if let Some(n) = seam.window_turns {
            config.window_turns = n;
        }
        Self::with_parts(patterns, config, seam.persist)
    }

    /// Build directly from compiled parts (the testable seam; also lets a caller
    /// override the presentation config wholesale).
    pub fn with_parts(
        patterns: DigestPatterns,
        config: DigestConfig,
        persist: bool,
    ) -> Result<Self, DigestError> {
        Ok(DigestEngine {
            parser: DigestParser::new(patterns, config)?,
            last: Digest::default(),
            version: 0,
            persist,
            persisted_floor: None,
        })
    }

    /// Whether opt-in Path-B persistence is enabled for this engine.
    pub fn persist(&self) -> bool {
        self.persist
    }

    /// The current digest version (0 before any change).
    pub fn version(&self) -> u64 {
        self.version
    }

    /// The current structured digest (the snapshot a `spt digest <id>` pull
    /// returns).
    pub fn snapshot(&self) -> Digest {
        self.last.clone()
    }

    /// A `full` update carrying the current snapshot — what a fresh subscriber
    /// receives first so it has a base to apply later tail-deltas against. Always
    /// carries the live version (no change is recorded).
    pub fn full_update(&self) -> DigestUpdate {
        DigestUpdate {
            version: self.version,
            full: true,
            turns: self.last.turns.clone(),
            newly_completed: Vec::new(),
        }
    }

    /// Feed a chunk of the session's PTY output. Re-derives the digest; returns a
    /// [`DigestUpdate`] iff it changed (else `None` — nothing to push).
    // [impl->REQ-TERM-4]
    pub fn feed(&mut self, bytes: &[u8]) -> Option<DigestUpdate> {
        self.parser.feed(bytes);
        let next = self.parser.digest();
        if next == self.last {
            return None; // unchanged — emit no delta
        }
        self.version += 1;

        // Decide full-vs-tail: a window composition shift (the oldest turn rolled
        // off, or the turn count changed) needs a full refresh because tail
        // offsets no longer align; an in-place change to existing turns is a tail
        // delta. The cheap, robust discriminator: if the two windows share a
        // common *prefix-aligned* head of equal turns, only the differing tail
        // changed; otherwise the window slid → full.
        let full = !same_window_frame(&self.last, &next);
        let turns = if full {
            next.turns.clone()
        } else {
            let unchanged = common_prefix_len(&self.last.turns, &next.turns);
            next.turns[unchanged..].to_vec()
        };

        let newly_completed = if self.persist {
            self.take_newly_completed(&next)
        } else {
            Vec::new()
        };

        self.last = next;
        Some(DigestUpdate { version: self.version, full, turns, newly_completed })
    }

    /// Completed (settled) turns observed since the last call: every turn but the
    /// still-active last one, starting just after the dedup floor. Advances the
    /// floor to the newest completed turn so each settles exactly once.
    fn take_newly_completed(&mut self, snap: &Digest) -> Vec<Turn> {
        if snap.turns.len() < 2 {
            return Vec::new(); // only the active turn exists; nothing settled yet
        }
        let completed = &snap.turns[..snap.turns.len() - 1];
        let start = match &self.persisted_floor {
            // Resume just after the floor turn if it is still in the window;
            // if it rolled off (None), fall back to the window start (coarse).
            Some(floor) => completed.iter().position(|t| t == floor).map(|i| i + 1).unwrap_or(0),
            None => 0,
        };
        let fresh = completed[start..].to_vec();
        if let Some(last) = fresh.last() {
            self.persisted_floor = Some(last.clone());
        }
        fresh
    }
}

/// Whether `a` and `b` share the same window frame — i.e. `b` is `a` with only
/// its tail mutated/extended, not slid. True when one turn list is a prefix-equal
/// head of the other (so tail offsets still align for an in-place delta).
fn same_window_frame(a: &Digest, b: &Digest) -> bool {
    let n = common_prefix_len(&a.turns, &b.turns);
    // Aligned if everything up to the shorter length matched (only the tail of
    // the longer differs) — a pure in-place/extend, no front eviction.
    n == a.turns.len().min(b.turns.len())
}

/// Length of the equal-by-value prefix shared by two turn slices.
fn common_prefix_len(a: &[Turn], b: &[Turn]) -> usize {
    a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count()
}

#[cfg(test)]
mod tests {
    use super::*;
    use spt_term::DigestEntry;

    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(3),
            persist: false,
        }
    }

    // [unit->REQ-TERM-4] the manifest seam compiles into a working engine, and a
    // first feed yields a full update with the parsed turn.
    #[test]
    fn from_manifest_feeds_and_snapshots() {
        let mut e = DigestEngine::from_manifest(&seam()).expect("compile");
        let up = e.feed(b"\nUSER> hello\nAGENT> hi there\n").expect("changed");
        assert_eq!(up.version, 1);
        assert!(up.full, "first observed change is a full update");
        assert_eq!(up.turns.len(), 1);
        assert_eq!(up.turns[0].input.as_deref(), Some("hello"));
        assert_eq!(e.snapshot().turns.len(), 1);
    }

    // [unit->REQ-TERM-4] an unchanged feed pushes nothing (deltas only — no
    // heartbeat re-push of a static buffer).
    #[test]
    fn unchanged_feed_emits_no_delta() {
        let mut e = DigestEngine::from_manifest(&seam()).expect("compile");
        e.feed(b"\nUSER> hi\nAGENT> yo\n").expect("first change");
        let v = e.version();
        assert!(e.feed(b"").is_none(), "empty feed changes nothing");
        assert!(e.feed(b"   ").is_none(), "whitespace-only adds no marker");
        assert_eq!(e.version(), v, "version did not advance on a no-op");
    }

    // [unit->REQ-TERM-4] the active turn growing in place is a TAIL delta (only
    // the changed last turn is sent), not a full re-push.
    #[test]
    fn active_turn_growth_is_a_tail_delta() {
        let mut e = DigestEngine::from_manifest(&seam()).expect("compile");
        // Two settled turns + an active third.
        e.feed(b"\nUSER> a\nAGENT> 1\n").unwrap();
        e.feed(b"\nUSER> b\nAGENT> 2\n").unwrap();
        let up = e.feed(b"\nUSER> c\nAGENT> 3").unwrap();
        // The new turn 'c' appended: the differing tail is just that one turn.
        assert!(!up.full, "a pure append/extend is a tail delta");
        assert_eq!(up.turns.len(), 1);
        assert_eq!(up.turns.last().unwrap().input.as_deref(), Some("c"));
    }

    // [unit->REQ-TERM-4] when the rolling window slides (oldest turn evicted), the
    // update is FULL — tail offsets no longer align so the whole view is resent.
    #[test]
    fn window_slide_is_a_full_update() {
        let mut e = DigestEngine::from_manifest(&seam()).expect("compile"); // window 3
        for i in 0..3 {
            e.feed(format!("\nUSER> u{i}\nAGENT> r{i}\n").as_bytes()).unwrap();
        }
        // A 4th turn evicts u0 → the window frame shifts.
        let up = e.feed(b"\nUSER> u3\nAGENT> r3\n").unwrap();
        assert!(up.full, "a front eviction forces a full refresh");
        assert_eq!(up.turns.len(), 3, "the full current window");
        assert_eq!(up.turns[0].input.as_deref(), Some("u1"), "oldest is now u1");
    }

    // [unit->REQ-TERM-4] collapsed tool sprints survive into the structured turn
    // (the digest is structured, source-tagged — not flat text).
    #[test]
    fn tool_sprint_is_structured_in_the_turn() {
        let mut e = DigestEngine::from_manifest(&seam()).expect("compile");
        let up = e.feed(b"\nUSER> go\nAGENT> ok\nWrite(a.rs) done\n").unwrap();
        let t = up.turns.last().unwrap();
        assert!(t.entries.iter().any(|x| matches!(x, DigestEntry::ToolSprint(_))));
    }

    // [unit->REQ-TERM-4] with persistence on, a turn is reported newly-completed
    // exactly once (as it settles), not re-emitted on later feeds.
    #[test]
    fn persistence_reports_each_completed_turn_once() {
        let mut s = seam();
        s.persist = true;
        let mut e = DigestEngine::from_manifest(&s).expect("compile");

        // Turn 'a' is still the active (last) turn → not yet completed.
        let up = e.feed(b"\nUSER> a\nAGENT> 1\n").unwrap();
        assert!(up.newly_completed.is_empty(), "the active turn has not settled");

        // Opening turn 'b' settles 'a' → reported once.
        let up = e.feed(b"\nUSER> b\nAGENT> 2\n").unwrap();
        assert_eq!(up.newly_completed.len(), 1);
        assert_eq!(up.newly_completed[0].input.as_deref(), Some("a"));

        // Growing 'b' in place settles nothing new.
        let up = e.feed(b" more").unwrap();
        assert!(up.newly_completed.is_empty(), "no new turn settled");

        // Opening 'c' settles 'b'.
        let up = e.feed(b"\nUSER> c\nAGENT> 3\n").unwrap();
        assert_eq!(up.newly_completed.len(), 1);
        assert_eq!(up.newly_completed[0].input.as_deref(), Some("b"));
    }

    // [unit->REQ-TERM-4] persistence off ⇒ no completed turns are ever surfaced.
    #[test]
    fn persistence_off_surfaces_nothing() {
        let mut e = DigestEngine::from_manifest(&seam()).expect("compile"); // persist=false
        e.feed(b"\nUSER> a\nAGENT> 1\n").unwrap();
        let up = e.feed(b"\nUSER> b\nAGENT> 2\n").unwrap();
        assert!(up.newly_completed.is_empty(), "persistence off → never reported");
    }
}


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

//! The PTY-digest engine (B8) — the **brain-side** projection of the live
//! activity buffer (ADR-0008, REQ-TERM-4).
//!
//! ## Why brain-side, not broker-side
//!
//! ADR-0008 frames the digest as a "broker-pushed structured-delta stream," but
//! that wording predates the strict ADR-0004 §B split. Running adapter-supplied
//! regexes over a byte stream is **logic** — routing/registry/parse is exactly
//! what the broker must NOT hold (M3b-PLAN risk: "a task that finds itself
//! putting parse in the broker has crossed the line"). So the digest is a
//! brain-side projection: the brain already subscribes to the broker's R-TERM-3
//! output stream for a session ([`crate::brain::Brain`]); this engine folds those
//! same bytes through the M3a [`DigestParser`] and publishes the structured
//! result. Raw bytes (R-TERM-3) and the parsed digest are the "two channels on
//! the one session surface" ADR-0008 describes — the broker owns the bytes, the
//! brain owns the parse.
//!
//! ## Snapshot vs delta
//!
//! [`DigestEngine::feed`] returns a [`DigestUpdate`] only when the digest changed,
//! so a subscriber receives **only deltas**, never a re-push of an unchanged
//! buffer. An update is either:
//! - **`full`** — replace the whole view with `turns` (sent on first subscribe
//!   and whenever the window *composition* shifts: the oldest turn dropped or the
//!   turn count changed), or
//! - **tail** (`full = false`) — the last `turns.len()` turns changed *in place*
//!   (the steady state: the active turn grew as the agent produced more output).
//!   The subscriber overwrites that many turns at its tail.
//!
//! ## Opt-in Path-B persistence (ADR-0008 "Option (C)")
//!
//! When enabled, [`DigestUpdate::newly_completed`] carries each turn that settled
//! this feed (every turn but the still-active last one, deduped against the last
//! persisted turn). The feed driver appends them to the perch history store as a
//! **coarse** activity log — explicitly not a replacement for Path A/B transcript
//! history. Because the engine is fed every output chunk, a completed turn is
//! observed long before the rolling window could evict it, so the dedup-by-value
//! tracking never drops or double-writes a turn in practice (a multi-turn single
//! feed is the only degenerate case, and it can at worst re-emit in-window turns —
//! bounded by the window depth, acceptable for a coarse log).
// [impl->REQ-TERM-4]

use spt_runtime::PtyDigest;
use spt_term::{Digest, DigestConfig, DigestError, DigestParser, DigestPatterns, Turn};

/// A structured digest change to push to a subscriber. Sent only when the digest
/// actually changed (so the stream carries deltas, not heartbeats).
#[derive(Debug, Clone, PartialEq)]
pub struct DigestUpdate {
    /// Monotonic version of the digest after this change (starts at 1).
    pub version: u64,
    /// `true` ⇒ replace the entire view with `turns`; `false` ⇒ the last
    /// `turns.len()` turns changed in place (tail-overwrite).
    pub full: bool,
    /// The changed turns (the whole window when `full`, else the changed tail).
    pub turns: Vec<Turn>,
    /// Turns that settled this feed (for opt-in Path-B persistence). Empty when
    /// persistence is off or nothing newly completed.
    pub newly_completed: Vec<Turn>,
}

/// The brain-side digest engine for one session: a [`DigestParser`] plus the
/// change-tracking that turns "the whole window, re-derived" into pushable
/// deltas + completed-turn persistence.
pub struct DigestEngine {
    parser: DigestParser,
    /// The last digest we computed (the diff base + the version anchor).
    last: Digest,
    /// Monotonic version, bumped on each observed change.
    version: u64,
    /// Whether opt-in Path-B persistence is requested for this endpoint.
    persist: bool,
    /// The most recent turn handed to persistence — the dedup floor so a turn is
    /// appended exactly once as it settles.
    persisted_floor: Option<Turn>,
}

impl DigestEngine {
    /// Build an engine from the manifest [`PtyDigest`] seam. Compiles the
    /// adapter-supplied patterns (returning [`DigestError`] naming the first that
    /// fails) and applies the spt-core presentation defaults, with `window_turns`
    /// overridden when the manifest sets it.
    // [impl->REQ-TERM-4]
    pub fn from_manifest(seam: &PtyDigest) -> Result<Self, DigestError> {
        let patterns = DigestPatterns {
            input: seam.input_pattern.clone(),
            agent: seam.agent_pattern.clone(),
            tools: seam.tool_patterns.clone(),
            catchall: seam.catchall_pattern.clone(),
        };
        let mut config = DigestConfig::default();
        if let Some(n) = seam.window_turns {
            config.window_turns = n;
        }
        Self::with_parts(patterns, config, seam.persist)
    }

    /// Build directly from compiled parts (the testable seam; also lets a caller
    /// override the presentation config wholesale).
    pub fn with_parts(
        patterns: DigestPatterns,
        config: DigestConfig,
        persist: bool,
    ) -> Result<Self, DigestError> {
        Ok(DigestEngine {
            parser: DigestParser::new(patterns, config)?,
            last: Digest::default(),
            version: 0,
            persist,
            persisted_floor: None,
        })
    }

    /// Whether opt-in Path-B persistence is enabled for this engine.
    pub fn persist(&self) -> bool {
        self.persist
    }

    /// The current digest version (0 before any change).
    pub fn version(&self) -> u64 {
        self.version
    }

    /// The current structured digest (the snapshot a `spt digest <id>` pull
    /// returns).
    pub fn snapshot(&self) -> Digest {
        self.last.clone()
    }

    /// A `full` update carrying the current snapshot — what a fresh subscriber
    /// receives first so it has a base to apply later tail-deltas against. Always
    /// carries the live version (no change is recorded).
    pub fn full_update(&self) -> DigestUpdate {
        DigestUpdate {
            version: self.version,
            full: true,
            turns: self.last.turns.clone(),
            newly_completed: Vec::new(),
        }
    }

    /// Feed a chunk of the session's PTY output. Re-derives the digest; returns a
    /// [`DigestUpdate`] iff it changed (else `None` — nothing to push).
    // [impl->REQ-TERM-4]
    pub fn feed(&mut self, bytes: &[u8]) -> Option<DigestUpdate> {
        self.parser.feed(bytes);
        let next = self.parser.digest();
        if next == self.last {
            return None; // unchanged — emit no delta
        }
        self.version += 1;

        // Decide full-vs-tail: a window composition shift (the oldest turn rolled
        // off, or the turn count changed) needs a full refresh because tail
        // offsets no longer align; an in-place change to existing turns is a tail
        // delta. The cheap, robust discriminator: if the two windows share a
        // common *prefix-aligned* head of equal turns, only the differing tail
        // changed; otherwise the window slid → full.
        let full = !same_window_frame(&self.last, &next);
        let turns = if full {
            next.turns.clone()
        } else {
            let unchanged = common_prefix_len(&self.last.turns, &next.turns);
            next.turns[unchanged..].to_vec()
        };

        let newly_completed = if self.persist {
            self.take_newly_completed(&next)
        } else {
            Vec::new()
        };

        self.last = next;
        Some(DigestUpdate { version: self.version, full, turns, newly_completed })
    }

    /// Completed (settled) turns observed since the last call: every turn but the
    /// still-active last one, starting just after the dedup floor. Advances the
    /// floor to the newest completed turn so each settles exactly once.
    fn take_newly_completed(&mut self, snap: &Digest) -> Vec<Turn> {
        if snap.turns.len() < 2 {
            return Vec::new(); // only the active turn exists; nothing settled yet
        }
        let completed = &snap.turns[..snap.turns.len() - 1];
        let start = match &self.persisted_floor {
            // Resume just after the floor turn if it is still in the window;
            // if it rolled off (None), fall back to the window start (coarse).
            Some(floor) => completed.iter().position(|t| t == floor).map(|i| i + 1).unwrap_or(0),
            None => 0,
        };
        let fresh = completed[start..].to_vec();
        if let Some(last) = fresh.last() {
            self.persisted_floor = Some(last.clone());
        }
        fresh
    }
}

/// Whether `a` and `b` share the same window frame — i.e. `b` is `a` with only
/// its tail mutated/extended, not slid. True when one turn list is a prefix-equal
/// head of the other (so tail offsets still align for an in-place delta).
fn same_window_frame(a: &Digest, b: &Digest) -> bool {
    let n = common_prefix_len(&a.turns, &b.turns);
    // Aligned if everything up to the shorter length matched (only the tail of
    // the longer differs) — a pure in-place/extend, no front eviction.
    n == a.turns.len().min(b.turns.len())
}

/// Length of the equal-by-value prefix shared by two turn slices.
fn common_prefix_len(a: &[Turn], b: &[Turn]) -> usize {
    a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count()
}

#[cfg(test)]
mod tests {
    use super::*;
    use spt_term::DigestEntry;

    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(3),
            persist: false,
        }
    }

    // [unit->REQ-TERM-4] the manifest seam compiles into a working engine, and a
    // first feed yields a full update with the parsed turn.
    #[test]
    fn from_manifest_feeds_and_snapshots() {
        let mut e = DigestEngine::from_manifest(&seam()).expect("compile");
        let up = e.feed(b"\nUSER> hello\nAGENT> hi there\n").expect("changed");
        assert_eq!(up.version, 1);
        assert!(up.full, "first observed change is a full update");
        assert_eq!(up.turns.len(), 1);
        assert_eq!(up.turns[0].input.as_deref(), Some("hello"));
        assert_eq!(e.snapshot().turns.len(), 1);
    }

    // [unit->REQ-TERM-4] an unchanged feed pushes nothing (deltas only — no
    // heartbeat re-push of a static buffer).
    #[test]
    fn unchanged_feed_emits_no_delta() {
        let mut e = DigestEngine::from_manifest(&seam()).expect("compile");
        e.feed(b"\nUSER> hi\nAGENT> yo\n").expect("first change");
        let v = e.version();
        assert!(e.feed(b"").is_none(), "empty feed changes nothing");
        assert!(e.feed(b"   ").is_none(), "whitespace-only adds no marker");
        assert_eq!(e.version(), v, "version did not advance on a no-op");
    }

    // [unit->REQ-TERM-4] the active turn growing in place is a TAIL delta (only
    // the changed last turn is sent), not a full re-push.
    #[test]
    fn active_turn_growth_is_a_tail_delta() {
        let mut e = DigestEngine::from_manifest(&seam()).expect("compile");
        // Two settled turns + an active third.
        e.feed(b"\nUSER> a\nAGENT> 1\n").unwrap();
        e.feed(b"\nUSER> b\nAGENT> 2\n").unwrap();
        let up = e.feed(b"\nUSER> c\nAGENT> 3").unwrap();
        // The new turn 'c' appended: the differing tail is just that one turn.
        assert!(!up.full, "a pure append/extend is a tail delta");
        assert_eq!(up.turns.len(), 1);
        assert_eq!(up.turns.last().unwrap().input.as_deref(), Some("c"));
    }

    // [unit->REQ-TERM-4] when the rolling window slides (oldest turn evicted), the
    // update is FULL — tail offsets no longer align so the whole view is resent.
    #[test]
    fn window_slide_is_a_full_update() {
        let mut e = DigestEngine::from_manifest(&seam()).expect("compile"); // window 3
        for i in 0..3 {
            e.feed(format!("\nUSER> u{i}\nAGENT> r{i}\n").as_bytes()).unwrap();
        }
        // A 4th turn evicts u0 → the window frame shifts.
        let up = e.feed(b"\nUSER> u3\nAGENT> r3\n").unwrap();
        assert!(up.full, "a front eviction forces a full refresh");
        assert_eq!(up.turns.len(), 3, "the full current window");
        assert_eq!(up.turns[0].input.as_deref(), Some("u1"), "oldest is now u1");
    }

    // [unit->REQ-TERM-4] collapsed tool sprints survive into the structured turn
    // (the digest is structured, source-tagged — not flat text).
    #[test]
    fn tool_sprint_is_structured_in_the_turn() {
        let mut e = DigestEngine::from_manifest(&seam()).expect("compile");
        let up = e.feed(b"\nUSER> go\nAGENT> ok\nWrite(a.rs) done\n").unwrap();
        let t = up.turns.last().unwrap();
        assert!(t.entries.iter().any(|x| matches!(x, DigestEntry::ToolSprint(_))));
    }

    // [unit->REQ-TERM-4] with persistence on, a turn is reported newly-completed
    // exactly once (as it settles), not re-emitted on later feeds.
    #[test]
    fn persistence_reports_each_completed_turn_once() {
        let mut s = seam();
        s.persist = true;
        let mut e = DigestEngine::from_manifest(&s).expect("compile");

        // Turn 'a' is still the active (last) turn → not yet completed.
        let up = e.feed(b"\nUSER> a\nAGENT> 1\n").unwrap();
        assert!(up.newly_completed.is_empty(), "the active turn has not settled");

        // Opening turn 'b' settles 'a' → reported once.
        let up = e.feed(b"\nUSER> b\nAGENT> 2\n").unwrap();
        assert_eq!(up.newly_completed.len(), 1);
        assert_eq!(up.newly_completed[0].input.as_deref(), Some("a"));

        // Growing 'b' in place settles nothing new.
        let up = e.feed(b" more").unwrap();
        assert!(up.newly_completed.is_empty(), "no new turn settled");

        // Opening 'c' settles 'b'.
        let up = e.feed(b"\nUSER> c\nAGENT> 3\n").unwrap();
        assert_eq!(up.newly_completed.len(), 1);
        assert_eq!(up.newly_completed[0].input.as_deref(), Some("b"));
    }

    // [unit->REQ-TERM-4] persistence off ⇒ no completed turns are ever surfaced.
    #[test]
    fn persistence_off_surfaces_nothing() {
        let mut e = DigestEngine::from_manifest(&seam()).expect("compile"); // persist=false
        e.feed(b"\nUSER> a\nAGENT> 1\n").unwrap();
        let up = e.feed(b"\nUSER> b\nAGENT> 2\n").unwrap();
        assert!(up.newly_completed.is_empty(), "persistence off → never reported");
    }
}


NOTE fROM THE USER: proceed with next steps.


Register module + build/test the engine before wiring the hub:


Edit

pub mod brain;
pub mod broker;
pub mod codec;
pub mod config;
pub mod daemon;
pub mod effect;

pub mod brain;
pub mod broker;
pub mod codec;
pub mod config;
pub mod daemon;
pub mod digest;
pub mod digesthub;
pub mod effect;


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

pub mod brain;
pub mod broker;
pub mod codec;
pub mod config;
pub mod daemon;
pub mod digest;
pub mod digesthub;
pub mod effect;

//! `spt-daemon` — the per-machine broker/brain supervisor (M3b).
//!
//! ADR-0004 splits the logical daemon into two implementation layers so a
//! routine self-update swaps the logic without disturbing live endpoints:
//!
//! - a **broker** (stable kernel) holds *only* the un-transferable resources a
//!   live consumer would lose continuity on if the logic restarted — PTY
//!   masters (from [`spt_term`]), spawned harness children, accepted/listening
//!   sockets — and runs no logic;
//! - a **brain** (restartable logic) holds routing, registry, manifest parse,
//!   and the re-hosted [`spt_live`] lifecycle, attaching to the broker over a
//!   **versioned local IPC** so a *newer brain talks to an older broker*.
//!
//! **B0 (this commit)** lands the crate's layer position plus that versioned
//! IPC seam — the load-bearing contract everything else rides:
//!
//! - [`frame`] — the forward-compatible [`Envelope`] schema + the version
//!   [`Hello`] handshake (KNOWN-HAZARDS 2.3, [`REQ-HAZARD-HANDOFF-ARGV-COMPAT`]);
//! - [`codec`] — length-prefixed JSON framing over any [`std::io::Read`]/`Write`;
//! - [`transport`] — the OS-split carrier (named pipe / UDS) behind one
//!   [`DaemonTransport`] trait, mirroring `spt-term`'s `SessionSurface`.
//!
//! **B1 (this commit)** adds the **broker kernel** ([`broker`]) — the stable
//! half that hosts a real `spt-term` PTY child and relays its output/input over
//! the IPC, holding only the ADR-0004 §B resources and no logic — plus the
//! broker↔brain message catalog ([`msg`]).
//!
//! **B3 (this commit)** consolidates the interim no-daemon model into the brain:
//! the `api listen` pulse / Psyche / drop-ingest loops become scheduled
//! in-process loops ([`lifecycle::BrainLifecycle`]) paced by a durable,
//! configurable period ([`config::DaemonConfig`]) instead of the interim 5s
//! constant; the only residue left in the harness-hosted session is the thin,
//! stateless [`relay::Relay`]. The `spt` binary's `LiveHost` becomes a shim over
//! `BrainLifecycle` (re-host, not rewrite) ([`REQ-DAEMON-1`]).
//!
//! **B5 (this commit)** makes the broker↔brain boundary **exactly-once across a
//! brain crash** ([`effect::EffectJournal`]): an idempotent `input` carries a
//! durable `op_id`, and the broker dedups at the effect keyed by that id over a
//! broker-owned recovery anchor (KNOWN-HAZARDS 7.2, [`REQ-HAZARD-RESTART-IDEMPOTENT`];
//! Spike #6's two constraints — broker-owned anchor, dedup-at-effect-by-id —
//! instantiated on the real PTY-write boundary).
//!
//! The brain + gapless handoff (B2) and the rest of the M3b task table build on
//! this seam. `spt-daemon` is internal supervisor machinery, **not** public SDK
//! (R-ARCH-2).
//!
//! [`Envelope`]: frame::Envelope
//! [`Hello`]: frame::Hello
//! [`DaemonTransport`]: transport::DaemonTransport

pub mod brain;
pub mod broker;
pub mod codec;
pub mod config;
pub mod daemon;
pub mod effect;
pub mod endpoint;
pub mod frame;
pub mod lifecycle;
pub mod msg;
pub mod relay;
pub mod seedmap;
pub mod transport;

#[cfg(test)]
mod test_home;

pub use brain::{Brain, BrainState, BrokerEvent};
pub use broker::Broker;
pub use config::DaemonConfig;
pub use daemon::{ensure_running, is_running, Daemon};
pub use effect::{EffectJournal, EffectKey, EffectKind, Outcome};
pub use endpoint::{broker_socket_name, daemon_pid_path, seed_socket_name};
pub use lifecycle::{BrainLifecycle, TickReport};
pub use relay::Relay;
pub use seedmap::{put_seed, take_seed, SeedRegistry};
pub use frame::{
    accept_hello, Envelope, HandshakeError, Hello, Role, IPC_PROTOCOL_VERSION,
    MIN_COMPATIBLE_VERSION,
};
pub use transport::{DaemonTransport, LocalSocketTransport};

pub mod codec;
pub mod config;
pub mod daemon;
+pub mod digest;
+pub mod digesthub;
pub mod effect;
pub mod endpoint;
pub mod frame;


Add serde to `DigestUpdate` (crosses the control channel; `newly_completed` stays server-side):


use spt_runtime::PtyDigest;
use spt_term::{Digest, DigestConfig, DigestError, DigestParser, DigestPatterns, Turn};

/// A structured digest change to push to a subscriber. Sent only when the digest
/// actually changed (so the stream carries deltas, not heartbeats).
#[derive(Debug, Clone, PartialEq)]
pub struct DigestUpdate {
    /// Monotonic version of the digest after this change (starts at 1).
    pub version: u64,
    /// `true` ⇒ replace the entire view with `turns`; `false` ⇒ the last
    /// `turns.len()` turns changed in place (tail-overwrite).
    pub full: bool,
    /// The changed turns (the whole window when `full`, else the changed tail).
    pub turns: Vec<Turn>,
    /// Turns that settled this feed (for opt-in Path-B persistence). Empty when
    /// persistence is off or nothing newly completed.
    pub newly_completed: Vec<Turn>,
}

use serde::{Deserialize, Serialize};

use spt_runtime::PtyDigest;
use spt_term::{Digest, DigestConfig, DigestError, DigestParser, DigestPatterns, Turn};

/// A structured digest change to push to a subscriber. Sent only when the digest
/// actually changed (so the stream carries deltas, not heartbeats).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DigestUpdate {
    /// Monotonic version of the digest after this change (starts at 1).
    pub version: u64,
    /// `true` ⇒ replace the entire view with `turns`; `false` ⇒ the last
    /// `turns.len()` turns changed in place (tail-overwrite).
    pub full: bool,
    /// The changed turns (the whole window when `full`, else the changed tail).
    pub turns: Vec<Turn>,
    /// Turns that settled this feed (for opt-in Path-B persistence). Server-side
    /// only — never crosses the control channel (the subscriber sees the parsed
    /// turns via `turns`, not the persistence bookkeeping).
    #[serde(skip)]
    pub newly_completed: Vec<Turn>,
}


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

use spt_runtime::PtyDigest;
use spt_term::{Digest, DigestConfig, DigestError, DigestParser, DigestPatterns, Turn};

/// A structured digest change to push to a subscriber. Sent only when the digest
/// actually changed (so the stream carries deltas, not heartbeats).
#[derive(Debug, Clone, PartialEq)]
pub struct DigestUpdate {
    /// Monotonic version of the digest after this change (starts at 1).
    pub version: u64,
    /// `true` ⇒ replace the entire view with `turns`; `false` ⇒ the last
    /// `turns.len()` turns changed in place (tail-overwrite).
    pub full: bool,
    /// The changed turns (the whole window when `full`, else the changed tail).
    pub turns: Vec<Turn>,
    /// Turns that settled this feed (for opt-in Path-B persistence). Empty when
    /// persistence is off or nothing newly completed.
    pub newly_completed: Vec<Turn>,
}

use serde::{Deserialize, Serialize};

use spt_runtime::PtyDigest;
use spt_term::{Digest, DigestConfig, DigestError, DigestParser, DigestPatterns, Turn};

/// A structured digest change to push to a subscriber. Sent only when the digest
/// actually changed (so the stream carries deltas, not heartbeats).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DigestUpdate {
    /// Monotonic version of the digest after this change (starts at 1).
    pub version: u64,
    /// `true` ⇒ replace the entire view with `turns`; `false` ⇒ the last
    /// `turns.len()` turns changed in place (tail-overwrite).
    pub full: bool,
    /// The changed turns (the whole window when `full`, else the changed tail).
    pub turns: Vec<Turn>,
    /// Turns that settled this feed (for opt-in Path-B persistence). Server-side
    /// only — never crosses the control channel (the subscriber sees the parsed
    /// turns via `turns`, not the persistence bookkeeping).
    #[serde(skip)]
    pub newly_completed: Vec<Turn>,
}

//! bounded by the window depth, acceptable for a coarse log).

+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]

-    /// Turns that settled this feed (for opt-in Path-B persistence). Empty when

-    /// persistence is off or nothing newly completed.

+    /// Turns that settled this feed (for opt-in Path-B persistence). Server-side

+    /// only — never crosses the control channel (the subscriber sees the parsed

+    /// turns via `turns`, not the persistence bookkeeping).


Add `digest_socket_name()` to endpoint.rs:


/// The broker listener name (B1/B2): PTY hosting + gapless handoff.
pub fn broker_socket_name() -> String {
    format!("spt-daemon-broker-{}.sock", home_tag())
}

/// The broker listener name (B1/B2): PTY hosting + gapless handoff.
pub fn broker_socket_name() -> String {
    format!("spt-daemon-broker-{}.sock", home_tag())
}

/// The digest-control listener name (B8): the channel `spt digest <id>` connects
/// to for a snapshot pull or a delta-stream subscribe (ADR-0008 / REQ-TERM-4).
pub fn digest_socket_name() -> String {
    format!("spt-daemon-digest-{}.sock", home_tag())
}


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

/// The broker listener name (B1/B2): PTY hosting + gapless handoff.
pub fn broker_socket_name() -> String {
    format!("spt-daemon-broker-{}.sock", home_tag())
}

/// The digest-control listener name (B8): the channel `spt digest <id>` connects
/// to for a snapshot pull or a delta-stream subscribe (ADR-0008 / REQ-TERM-4).
pub fn digest_socket_name() -> String {
    format!("spt-daemon-digest-{}.sock", home_tag())
}

//! Canonical per-machine daemon endpoint names + pid path (B6).
//!
//! The daemon binds two local-IPC listeners — the broker (PTY hosting, B1/B2)
//! and the seed-control channel (B6) — under names derived from the active
//! `spt_home`. Deriving from `spt_home` keeps each isolated home (a test's
//! `SPT_HOME` override, a per-user install) on its own daemon, so parallel test
//! processes never collide on one machine while a real install gets one stable
//! per-home daemon. The auto-start spawn inherits `SPT_HOME`, so the spawned
//! daemon computes the **same** names as the client that started it — that
//! shared derivation is the whole of the rendezvous (no registry lookup).
// [impl->REQ-DAEMON-3]

use std::hash::{Hash, Hasher};
use std::path::PathBuf;

use spt_store::perch::spt_home;

/// A deterministic short tag for the active spt home. `DefaultHasher::new()` is
/// seeded with fixed keys (it is **not** randomized like `RandomState`), so the
/// tag is stable across processes that share the same `spt_home` — exactly what
/// the auto-start handshake needs (the spawned daemon must independently derive
/// the launching client's socket name).
fn home_tag() -> String {
    let mut h = std::collections::hash_map::DefaultHasher::new();
    spt_home().hash(&mut h);
    format!("{:016x}", h.finish())
}

/// The seed-control listener name (B6): the channel `api seed`/`api listen`
/// connect to for the in-memory seed map. It is also the daemon's liveness
/// anchor — present for the daemon's whole lifetime.
pub fn seed_socket_name() -> String {
    format!("spt-daemon-seed-{}.sock", home_tag())
}

/// The broker listener name (B1/B2): PTY hosting + gapless handoff.
pub fn broker_socket_name() -> String {
    format!("spt-daemon-broker-{}.sock", home_tag())
}

/// The daemon's pid file: `<spt_home>/daemon.pid`. Best-effort breadcrumb the
/// running daemon writes on start — discoverable for management/cleanup. Not a
/// liveness signal (liveness is the seed-control ping); a stale pid here is
/// harmless because callers probe the socket, never this file.
pub fn daemon_pid_path() -> PathBuf {
    spt_home().join("daemon.pid")
}

+/// The digest-control listener name (B8): the channel `spt digest <id>` connects

+/// to for a snapshot pull or a delta-stream subscribe (ADR-0008 / REQ-TERM-4).

+pub fn digest_socket_name() -> String {

+    format!("spt-daemon-digest-{}.sock", home_tag())


Now the hub + control channel:


Write

//! The daemon-held digest hub + its control channel (B8, ADR-0008 / REQ-TERM-4).
//!
//! The brain runs a [`crate::digest::DigestEngine`] per daemon-hosted session and
//! **publishes** each change into this hub; the hub holds the latest structured
//! snapshot per endpoint and fans every change out to live subscribers. The
//! control channel is the access surface ADR-0008 describes — two modes over one
//! transport:
//!
//! - **snapshot pull** — `spt digest <id>` ([`pull_snapshot`]) returns the current
//!   structured buffer for a daemon-hosted session;
//! - **delta-stream** — `spt digest <id> --follow` ([`follow`]) subscribes and
//!   receives a base snapshot then **only the changes** ([`DigestUpdate`]s) as the
//!   agent produces output (the "near-realtime reflection" mode).
//!
//! The channel rides the same forward-compatible [`Envelope`] + codec + version
//! handshake as the broker / seed-control IPC (a transient `spt digest` CLI
//! connects as a [`Role::Brain`] logic client). **Local addressing only** — the
//! qualified `[subnet:]id@node` cross-node form is M4 (rejected by the CLI before
//! it ever reaches here).
// [impl->REQ-TERM-4]

use std::collections::HashMap;
use std::io::{self, Read, Write};
use std::sync::mpsc::{channel, Receiver, Sender};
use std::sync::{Arc, Mutex};
use std::thread;

use serde::{Deserialize, Serialize};
use spt_term::Digest;

use crate::codec::{read_frame, write_frame};
use crate::digest::DigestUpdate;
use crate::frame::{Envelope, Role};
use crate::transport::{recv_hello, send_hello, DaemonTransport, LocalSocketTransport};

/// Client→daemon: pull the current structured digest for an endpoint.
pub const KIND_DIGEST_SNAPSHOT: &str = "digest_snapshot";
/// Daemon→client: the snapshot reply (digest + version; absent ⇒ no live digest).
pub const KIND_DIGEST_RESULT: &str = "digest_result";
/// Client→daemon: subscribe to an endpoint's delta-stream from a version floor.
pub const KIND_DIGEST_SUBSCRIBE: &str = "digest_subscribe";
/// Daemon→client: one structured digest change (the streamed delta / base).
pub const KIND_DIGEST_DELTA: &str = "digest_delta";

/// `digest_snapshot` / `digest_subscribe` request payload.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DigestReq {
    /// The (local) endpoint id whose digest to read.
    pub id: String,
    /// Subscribe floor: only stream changes with `version > from_version`. The
    /// base snapshot is sent regardless so a fresh subscriber has an apply base.
    #[serde(default)]
    pub from_version: u64,
}

/// `digest_result` payload — the snapshot pull reply.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DigestResult {
    /// The current structured digest, absent if the endpoint has no live digest.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub digest: Option<Digest>,
    /// The digest version (0 when none).
    #[serde(default)]
    pub version: u64,
}

/// One endpoint's live digest state in the hub: the latest snapshot + version
/// (for pulls) and the set of live subscriber senders (for the delta-stream).
#[derive(Default)]
struct EndpointState {
    snapshot: Digest,
    version: u64,
    subscribers: Vec<Sender<DigestUpdate>>,
}

/// The daemon's per-endpoint live-digest registry. The brain publishes; the
/// control channel reads + subscribes. Holds only the projected digest state —
/// the same ephemeral-projection category as the broker's accepted-socket
/// bookkeeping, not routing/registry/parse logic (the parse already happened in
/// the brain's engine).
#[derive(Default)]
pub struct DigestHub {
    map: Mutex<HashMap<String, EndpointState>>,
}

impl DigestHub {
    /// A fresh, empty hub.
    pub fn new() -> Self {
        DigestHub { map: Mutex::new(HashMap::new()) }
    }

    /// Publish a digest change for `id`: store the new `snapshot` + version and
    /// fan `update` out to every live subscriber (pruning any whose receiver has
    /// been dropped — a disconnected `--follow` client). Called by the brain's
    /// feed loop after each [`DigestEngine::feed`](crate::digest::DigestEngine::feed).
    pub fn publish(&self, id: &str, snapshot: Digest, update: DigestUpdate) {
        let mut map = self.map.lock().unwrap();
        let st = map.entry(id.to_string()).or_default();
        st.version = update.version;
        st.snapshot = snapshot;
        st.subscribers.retain(|tx| tx.send(update.clone()).is_ok());
    }

    /// The current snapshot + version for `id`, if a live digest exists.
    pub fn snapshot(&self, id: &str) -> Option<(Digest, u64)> {
        let map = self.map.lock().unwrap();
        map.get(id).map(|st| (st.snapshot.clone(), st.version))
    }

    /// Register a subscriber for `id`, returning the current base snapshot+version
    /// (to apply later deltas against) and the receiver future changes arrive on.
    /// The base is always returned so a fresh subscriber has an apply anchor.
    fn subscribe(&self, id: &str) -> (Digest, u64, Receiver<DigestUpdate>) {
        let (tx, rx) = channel();
        let mut map = self.map.lock().unwrap();
        let st = map.entry(id.to_string()).or_default();
        st.subscribers.push(tx);
        (st.snapshot.clone(), st.version, rx)
    }

    /// Number of live subscribers for `id` (tests / introspection).
    pub fn subscriber_count(&self, id: &str) -> usize {
        self.map.lock().unwrap().get(id).map(|s| s.subscribers.len()).unwrap_or(0)
    }
}

/// Build a `digest_delta` envelope from a [`DigestUpdate`].
fn delta_frame(up: &DigestUpdate) -> Envelope {
    Envelope::new(KIND_DIGEST_DELTA, serde_json::to_value(up).expect("DigestUpdate serializes"))
}

/// Bind the digest-control channel at `name` and serve it forever against `hub`.
/// Thread-per-connection (mirroring the broker / seed-control): a `--follow`
/// subscribe blocks its connection thread streaming deltas, so serving inline
/// would stall every other client.
pub fn serve_digest_control(name: &str, hub: Arc<DigestHub>) -> io::Result<()> {
    let listener = LocalSocketTransport::bind(name)?;
    loop {
        let conn = listener.accept()?;
        let hub = Arc::clone(&hub);
        thread::spawn(move || {
            let _ = handle_conn(conn, &hub);
        });
    }
}

/// Drive one control connection: handshake, then service snapshot pulls and
/// subscribe streams until the client disconnects.
fn handle_conn<C: Read + Write>(mut conn: C, hub: &DigestHub) -> io::Result<()> {
    recv_hello(&mut conn, Role::Brain)?;
    loop {
        let env = match read_frame(&mut conn) {
            Ok(e) => e,
            Err(_) => return Ok(()), // peer hung up
        };
        match env.kind.as_str() {
            KIND_DIGEST_SNAPSHOT => {
                let req: DigestReq = serde_json::from_value(env.payload)
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                let (digest, version) = match hub.snapshot(&req.id) {
                    Some((d, v)) => (Some(d), v),
                    None => (None, 0),
                };
                let res = DigestResult { digest, version };
                write_frame(
                    &mut conn,
                    &Envelope::new(KIND_DIGEST_RESULT, serde_json::to_value(res).unwrap()),
                )?;
            }
            KIND_DIGEST_SUBSCRIBE => {
                let req: DigestReq = serde_json::from_value(env.payload)
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                // Register, send the base snapshot (full), then stream changes.
                let (base, version, rx) = hub.subscribe(&req.id);
                if version >= req.from_version {
                    let base_update = DigestUpdate {
                        version,
                        full: true,
                        turns: base.turns,
                        newly_completed: Vec::new(),
                    };
                    write_frame(&mut conn, &delta_frame(&base_update))?;
                }
                // Forward each published change until the client disconnects (a
                // write failure) or the hub drops the sender.
                while let Ok(up) = rx.recv() {
                    if up.version <= req.from_version {
                        continue;
                    }
                    if write_frame(&mut conn, &delta_frame(&up)).is_err() {
                        return Ok(()); // client gone — the next publish prunes us
                    }
                }
                return Ok(());
            }
            // Forward-compat: an unknown control kind is tolerated, not fatal.
            _ => {}
        }
    }
}

/// Connect to the digest-control channel `name`, announcing ourselves as a brain
/// (the `spt digest` CLI is a transient logic client of the daemon).
fn connect(name: &str) -> io::Result<interprocess::local_socket::Stream> {
    let mut conn = LocalSocketTransport::connect(name)?;
    send_hello(&mut conn, Role::Brain)?;
    Ok(conn)
}

/// Pull the current structured digest for `id` over the control channel `name`.
/// `Ok(None)` ⇒ the daemon holds no live digest for that endpoint.
// [impl->REQ-TERM-4]
pub fn pull_snapshot(name: &str, id: &str) -> io::Result<Option<(Digest, u64)>> {
    let mut conn = connect(name)?;
    let req = DigestReq { id: id.to_string(), from_version: 0 };
    write_frame(&mut conn, &Envelope::new(KIND_DIGEST_SNAPSHOT, serde_json::to_value(req).unwrap()))?;
    let env = read_frame(&mut conn)?;
    let res: DigestResult = serde_json::from_value(env.payload)
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
    Ok(res.digest.map(|d| (d, res.version)))
}

/// Subscribe to `id`'s delta-stream over `name`, invoking `on_update` for the base
/// snapshot and each subsequent change. Stops when `on_update` returns `false` or
/// the stream ends. `from_version` skips changes the caller already has.
// [impl->REQ-TERM-4]
pub fn follow<F: FnMut(&DigestUpdate) -> bool>(
    name: &str,
    id: &str,
    from_version: u64,
    mut on_update: F,
) -> io::Result<()> {
    let mut conn = connect(name)?;
    let req = DigestReq { id: id.to_string(), from_version };
    write_frame(
        &mut conn,
        &Envelope::new(KIND_DIGEST_SUBSCRIBE, serde_json::to_value(req).unwrap()),
    )?;
    loop {
        let env = match read_frame(&mut conn) {
            Ok(e) => e,
            Err(_) => return Ok(()), // stream ended
        };
        if env.kind == KIND_DIGEST_DELTA {
            let up: DigestUpdate = serde_json::from_value(env.payload)
                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
            if !on_update(&up) {
                return Ok(());
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use spt_term::Turn;

    fn turn(input: &str) -> Turn {
        Turn { input: Some(input.to_string()), entries: Vec::new() }
    }

    fn an_update(version: u64) -> DigestUpdate {
        DigestUpdate { version, full: true, turns: vec![turn("hi")], newly_completed: Vec::new() }
    }

    // [unit->REQ-TERM-4] publish stores the latest snapshot; a pull reads it back.
    #[test]
    fn publish_then_snapshot_reads_latest() {
        let hub = DigestHub::new();
        assert!(hub.snapshot("doyle").is_none(), "no digest before any publish");
        let snap = Digest { turns: vec![turn("hi")] };
        hub.publish("doyle", snap.clone(), an_update(1));
        let (got, v) = hub.snapshot("doyle").expect("a live digest");
        assert_eq!(v, 1);
        assert_eq!(got, snap);
    }

    // [unit->REQ-TERM-4] a subscriber receives published changes; a dropped
    // subscriber is pruned on the next publish.
    #[test]
    fn subscriber_receives_changes_then_is_pruned() {
        let hub = DigestHub::new();
        let (_base, _v, rx) = hub.subscribe("doyle");
        assert_eq!(hub.subscriber_count("doyle"), 1);

        hub.publish("doyle", Digest { turns: vec![turn("a")] }, an_update(1));
        assert_eq!(rx.recv().unwrap().version, 1, "the change was fanned out");

        // Drop the receiver → the next publish's send fails → the subscriber is pruned.
        drop(rx);
        hub.publish("doyle", Digest { turns: vec![turn("b")] }, an_update(2));
        assert_eq!(hub.subscriber_count("doyle"), 0, "dead subscriber pruned");
    }

    // [unit->REQ-TERM-4] a fresh subscribe returns the current snapshot as its
    // base so later deltas have an apply anchor.
    #[test]
    fn subscribe_returns_current_base() {
        let hub = DigestHub::new();
        hub.publish("doyle", Digest { turns: vec![turn("base")] }, an_update(7));
        let (base, version, _rx) = hub.subscribe("doyle");
        assert_eq!(version, 7);
        assert_eq!(base.turns[0].input.as_deref(), Some("base"));
    }
}


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

//! The daemon-held digest hub + its control channel (B8, ADR-0008 / REQ-TERM-4).
//!
//! The brain runs a [`crate::digest::DigestEngine`] per daemon-hosted session and
//! **publishes** each change into this hub; the hub holds the latest structured
//! snapshot per endpoint and fans every change out to live subscribers. The
//! control channel is the access surface ADR-0008 describes — two modes over one
//! transport:
//!
//! - **snapshot pull** — `spt digest <id>` ([`pull_snapshot`]) returns the current
//!   structured buffer for a daemon-hosted session;
//! - **delta-stream** — `spt digest <id> --follow` ([`follow`]) subscribes and
//!   receives a base snapshot then **only the changes** ([`DigestUpdate`]s) as the
//!   agent produces output (the "near-realtime reflection" mode).
//!
//! The channel rides the same forward-compatible [`Envelope`] + codec + version
//! handshake as the broker / seed-control IPC (a transient `spt digest` CLI
//! connects as a [`Role::Brain`] logic client). **Local addressing only** — the
//! qualified `[subnet:]id@node` cross-node form is M4 (rejected by the CLI before
//! it ever reaches here).
// [impl->REQ-TERM-4]

use std::collections::HashMap;
use std::io::{self, Read, Write};
use std::sync::mpsc::{channel, Receiver, Sender};
use std::sync::{Arc, Mutex};
use std::thread;

use serde::{Deserialize, Serialize};
use spt_term::Digest;

use crate::codec::{read_frame, write_frame};
use crate::digest::DigestUpdate;
use crate::frame::{Envelope, Role};
use crate::transport::{recv_hello, send_hello, DaemonTransport, LocalSocketTransport};

/// Client→daemon: pull the current structured digest for an endpoint.
pub const KIND_DIGEST_SNAPSHOT: &str = "digest_snapshot";
/// Daemon→client: the snapshot reply (digest + version; absent ⇒ no live digest).
pub const KIND_DIGEST_RESULT: &str = "digest_result";
/// Client→daemon: subscribe to an endpoint's delta-stream from a version floor.
pub const KIND_DIGEST_SUBSCRIBE: &str = "digest_subscribe";
/// Daemon→client: one structured digest change (the streamed delta / base).
pub const KIND_DIGEST_DELTA: &str = "digest_delta";

/// `digest_snapshot` / `digest_subscribe` request payload.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DigestReq {
    /// The (local) endpoint id whose digest to read.
    pub id: String,
    /// Subscribe floor: only stream changes with `version > from_version`. The
    /// base snapshot is sent regardless so a fresh subscriber has an apply base.
    #[serde(default)]
    pub from_version: u64,
}

/// `digest_result` payload — the snapshot pull reply.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DigestResult {
    /// The current structured digest, absent if the endpoint has no live digest.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub digest: Option<Digest>,
    /// The digest version (0 when none).
    #[serde(default)]
    pub version: u64,
}

/// One endpoint's live digest state in the hub: the latest snapshot + version
/// (for pulls) and the set of live subscriber senders (for the delta-stream).
#[derive(Default)]
struct EndpointState {
    snapshot: Digest,
    version: u64,
    subscribers: Vec<Sender<DigestUpdate>>,
}

/// The daemon's per-endpoint live-digest registry. The brain publishes; the
/// control channel reads + subscribes. Holds only the projected digest state —
/// the same ephemeral-projection category as the broker's accepted-socket
/// bookkeeping, not routing/registry/parse logic (the parse already happened in
/// the brain's engine).
#[derive(Default)]
pub struct DigestHub {
    map: Mutex<HashMap<String, EndpointState>>,
}

impl DigestHub {
    /// A fresh, empty hub.
    pub fn new() -> Self {
        DigestHub { map: Mutex::new(HashMap::new()) }
    }

    /// Publish a digest change for `id`: store the new `snapshot` + version and
    /// fan `update` out to every live subscriber (pruning any whose receiver has
    /// been dropped — a disconnected `--follow` client). Called by the brain's
    /// feed loop after each [`DigestEngine::feed`](crate::digest::DigestEngine::feed).
    pub fn publish(&self, id: &str, snapshot: Digest, update: DigestUpdate) {
        let mut map = self.map.lock().unwrap();
        let st = map.entry(id.to_string()).or_default();
        st.version = update.version;
        st.snapshot = snapshot;
        st.subscribers.retain(|tx| tx.send(update.clone()).is_ok());
    }

    /// The current snapshot + version for `id`, if a live digest exists.
    pub fn snapshot(&self, id: &str) -> Option<(Digest, u64)> {
        let map = self.map.lock().unwrap();
        map.get(id).map(|st| (st.snapshot.clone(), st.version))
    }

    /// Register a subscriber for `id`, returning the current base snapshot+version
    /// (to apply later deltas against) and the receiver future changes arrive on.
    /// The base is always returned so a fresh subscriber has an apply anchor.
    fn subscribe(&self, id: &str) -> (Digest, u64, Receiver<DigestUpdate>) {
        let (tx, rx) = channel();
        let mut map = self.map.lock().unwrap();
        let st = map.entry(id.to_string()).or_default();
        st.subscribers.push(tx);
        (st.snapshot.clone(), st.version, rx)
    }

    /// Number of live subscribers for `id` (tests / introspection).
    pub fn subscriber_count(&self, id: &str) -> usize {
        self.map.lock().unwrap().get(id).map(|s| s.subscribers.len()).unwrap_or(0)
    }
}

/// Build a `digest_delta` envelope from a [`DigestUpdate`].
fn delta_frame(up: &DigestUpdate) -> Envelope {
    Envelope::new(KIND_DIGEST_DELTA, serde_json::to_value(up).expect("DigestUpdate serializes"))
}

/// Bind the digest-control channel at `name` and serve it forever against `hub`.
/// Thread-per-connection (mirroring the broker / seed-control): a `--follow`
/// subscribe blocks its connection thread streaming deltas, so serving inline
/// would stall every other client.
pub fn serve_digest_control(name: &str, hub: Arc<DigestHub>) -> io::Result<()> {
    let listener = LocalSocketTransport::bind(name)?;
    loop {
        let conn = listener.accept()?;
        let hub = Arc::clone(&hub);
        thread::spawn(move || {
            let _ = handle_conn(conn, &hub);
        });
    }
}

/// Drive one control connection: handshake, then service snapshot pulls and
/// subscribe streams until the client disconnects.
fn handle_conn<C: Read + Write>(mut conn: C, hub: &DigestHub) -> io::Result<()> {
    recv_hello(&mut conn, Role::Brain)?;
    loop {
        let env = match read_frame(&mut conn) {
            Ok(e) => e,
            Err(_) => return Ok(()), // peer hung up
        };
        match env.kind.as_str() {
            KIND_DIGEST_SNAPSHOT => {
                let req: DigestReq = serde_json::from_value(env.payload)
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                let (digest, version) = match hub.snapshot(&req.id) {
                    Some((d, v)) => (Some(d), v),
                    None => (None, 0),
                };
                let res = DigestResult { digest, version };
                write_frame(
                    &mut conn,
                    &Envelope::new(KIND_DIGEST_RESULT, serde_json::to_value(res).unwrap()),
                )?;
            }
            KIND_DIGEST_SUBSCRIBE => {
                let req: DigestReq = serde_json::from_value(env.payload)
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                // Register, send the base snapshot (full), then stream changes.
                let (base, version, rx) = hub.subscribe(&req.id);
                if version >= req.from_version {
                    let base_update = DigestUpdate {
                        version,
                        full: true,
                        turns: base.turns,
                        newly_completed: Vec::new(),
                    };
                    write_frame(&mut conn, &delta_frame(&base_update))?;
                }
                // Forward each published change until the client disconnects (a
                // write failure) or the hub drops the sender.
                while let Ok(up) = rx.recv() {
                    if up.version <= req.from_version {
                        continue;
                    }
                    if write_frame(&mut conn, &delta_frame(&up)).is_err() {
                        return Ok(()); // client gone — the next publish prunes us
                    }
                }
                return Ok(());
            }
            // Forward-compat: an unknown control kind is tolerated, not fatal.
            _ => {}
        }
    }
}

/// Connect to the digest-control channel `name`, announcing ourselves as a brain
/// (the `spt digest` CLI is a transient logic client of the daemon).
fn connect(name: &str) -> io::Result<interprocess::local_socket::Stream> {
    let mut conn = LocalSocketTransport::connect(name)?;
    send_hello(&mut conn, Role::Brain)?;
    Ok(conn)
}

/// Pull the current structured digest for `id` over the control channel `name`.
/// `Ok(None)` ⇒ the daemon holds no live digest for that endpoint.
// [impl->REQ-TERM-4]
pub fn pull_snapshot(name: &str, id: &str) -> io::Result<Option<(Digest, u64)>> {
    let mut conn = connect(name)?;
    let req = DigestReq { id: id.to_string(), from_version: 0 };
    write_frame(&mut conn, &Envelope::new(KIND_DIGEST_SNAPSHOT, serde_json::to_value(req).unwrap()))?;
    let env = read_frame(&mut conn)?;
    let res: DigestResult = serde_json::from_value(env.payload)
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
    Ok(res.digest.map(|d| (d, res.version)))
}

/// Subscribe to `id`'s delta-stream over `name`, invoking `on_update` for the base
/// snapshot and each subsequent change. Stops when `on_update` returns `false` or
/// the stream ends. `from_version` skips changes the caller already has.
// [impl->REQ-TERM-4]
pub fn follow<F: FnMut(&DigestUpdate) -> bool>(
    name: &str,
    id: &str,
    from_version: u64,
    mut on_update: F,
) -> io::Result<()> {
    let mut conn = connect(name)?;
    let req = DigestReq { id: id.to_string(), from_version };
    write_frame(
        &mut conn,
        &Envelope::new(KIND_DIGEST_SUBSCRIBE, serde_json::to_value(req).unwrap()),
    )?;
    loop {
        let env = match read_frame(&mut conn) {
            Ok(e) => e,
            Err(_) => return Ok(()), // stream ended
        };
        if env.kind == KIND_DIGEST_DELTA {
            let up: DigestUpdate = serde_json::from_value(env.payload)
                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
            if !on_update(&up) {
                return Ok(());
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use spt_term::Turn;

    fn turn(input: &str) -> Turn {
        Turn { input: Some(input.to_string()), entries: Vec::new() }
    }

    fn an_update(version: u64) -> DigestUpdate {
        DigestUpdate { version, full: true, turns: vec![turn("hi")], newly_completed: Vec::new() }
    }

    // [unit->REQ-TERM-4] publish stores the latest snapshot; a pull reads it back.
    #[test]
    fn publish_then_snapshot_reads_latest() {
        let hub = DigestHub::new();
        assert!(hub.snapshot("doyle").is_none(), "no digest before any publish");
        let snap = Digest { turns: vec![turn("hi")] };
        hub.publish("doyle", snap.clone(), an_update(1));
        let (got, v) = hub.snapshot("doyle").expect("a live digest");
        assert_eq!(v, 1);
        assert_eq!(got, snap);
    }

    // [unit->REQ-TERM-4] a subscriber receives published changes; a dropped
    // subscriber is pruned on the next publish.
    #[test]
    fn subscriber_receives_changes_then_is_pruned() {
        let hub = DigestHub::new();
        let (_base, _v, rx) = hub.subscribe("doyle");
        assert_eq!(hub.subscriber_count("doyle"), 1);

        hub.publish("doyle", Digest { turns: vec![turn("a")] }, an_update(1));
        assert_eq!(rx.recv().unwrap().version, 1, "the change was fanned out");

        // Drop the receiver → the next publish's send fails → the subscriber is pruned.
        drop(rx);
        hub.publish("doyle", Digest { turns: vec![turn("b")] }, an_update(2));
        assert_eq!(hub.subscriber_count("doyle"), 0, "dead subscriber pruned");
    }

    // [unit->REQ-TERM-4] a fresh subscribe returns the current snapshot as its
    // base so later deltas have an apply anchor.
    #[test]
    fn subscribe_returns_current_base() {
        let hub = DigestHub::new();
        hub.publish("doyle", Digest { turns: vec![turn("base")] }, an_update(7));
        let (base, version, _rx) = hub.subscribe("doyle");
        assert_eq!(version, 7);
        assert_eq!(base.turns[0].input.as_deref(), Some("base"));
    }
}


Build spt-daemon to catch errors before wiring the feed + CLI:


Bash

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

Build + run digest engine/hub unit tests


   Compiling spt-store v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-store)
   Compiling spt-msg v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-msg)
   Compiling spt-runtime v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-runtime)
   Compiling spt-term v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-term)
   Compiling spt-live v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-live)
   Compiling spt-daemon v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
error[E0432]: unresolved import `spt_runtime::PtyDigest`
  --> crates\spt-daemon\src\digest.rs:45:5
   |
45 | use spt_runtime::PtyDigest;
   |     ^^^^^^^^^^^^^^^^^^^^^^ no `PtyDigest` in the root
   |
help: consider importing this struct instead
   |
45 | use spt_runtime::manifest::PtyDigest;
   |                  ++++++++++

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


Need to re-export `PtyDigest` from spt-runtime root. Check + add:


1	//! spt-core harness contract — outbound half (M2a).
2	//!
3	//! `spt-runtime` makes spt-core **drivable by a manifest-described harness** with
4	//! zero Claude Code (or any harness) conventions in-repo. It is the fourth layer
5	//! in the acyclic stack (`spt-proto -> spt-store -> spt-msg -> spt-runtime`).
6	//!
7	//! Two surfaces land here across M2a:
8	//! - the **runtime-manifest schema** + parse/validate ([`manifest`], T1), and
9	//! - the [`AgentRuntime`] trait + default `ManifestRuntime` (opaque-template
10	//!   substitution + spawn-session seam, T2).
11	//!
12	//! The inbound half (the `spt api` subcommand surface) lives in the `spt` binary
13	//! over this crate. See `M2a-PLAN.md`.
14	//
15	// spt-runtime joins spt-proto + spt-msg as the third member of the public,
16	// semver-committed SDK surface (R-ARCH-2): a harness author programs against
17	// this crate's Manifest/AgentRuntime types.
18	// [impl->REQ-ARCH-2]
19
20	pub mod manifest;
21	pub mod runtime;
22
23	pub use manifest::{Manifest, ManifestError};
24	pub use runtime::{
25	    run_bounded_command, AgentRuntime, BoundedOutput, ManifestRuntime, RuntimeError, Spawned,
26	};
27

//! spt-core harness contract — outbound half (M2a).
//!
//! `spt-runtime` makes spt-core **drivable by a manifest-described harness** with
//! zero Claude Code (or any harness) conventions in-repo. It is the fourth layer
//! in the acyclic stack (`spt-proto -> spt-store -> spt-msg -> spt-runtime`).
//!
//! Two surfaces land here across M2a:
//! - the **runtime-manifest schema** + parse/validate ([`manifest`], T1), and
//! - the [`AgentRuntime`] trait + default `ManifestRuntime` (opaque-template
//!   substitution + spawn-session seam, T2).
//!
//! The inbound half (the `spt api` subcommand surface) lives in the `spt` binary
//! over this crate. See `M2a-PLAN.md`.
//
// spt-runtime joins spt-proto + spt-msg as the third member of the public,
// semver-committed SDK surface (R-ARCH-2): a harness author programs against
// this crate's Manifest/AgentRuntime types.
// [impl->REQ-ARCH-2]

pub mod manifest;
pub mod runtime;

pub use manifest::{Manifest, ManifestError};
pub use runtime::{
    run_bounded_command, AgentRuntime, BoundedOutput, ManifestRuntime, RuntimeError, Spawned,
};


Edit

pub use manifest::{Manifest, ManifestError};

pub use manifest::{Manifest, ManifestError, PtyDigest};


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

//! spt-core harness contract — outbound half (M2a).
//!
//! `spt-runtime` makes spt-core **drivable by a manifest-described harness** with
//! zero Claude Code (or any harness) conventions in-repo. It is the fourth layer
//! in the acyclic stack (`spt-proto -> spt-store -> spt-msg -> spt-runtime`).
//!
//! Two surfaces land here across M2a:
//! - the **runtime-manifest schema** + parse/validate ([`manifest`], T1), and
//! - the [`AgentRuntime`] trait + default `ManifestRuntime` (opaque-template
//!   substitution + spawn-session seam, T2).
//!
//! The inbound half (the `spt api` subcommand surface) lives in the `spt` binary
//! over this crate. See `M2a-PLAN.md`.
//
// spt-runtime joins spt-proto + spt-msg as the third member of the public,
// semver-committed SDK surface (R-ARCH-2): a harness author programs against
// this crate's Manifest/AgentRuntime types.
// [impl->REQ-ARCH-2]

pub mod manifest;
pub mod runtime;

pub use manifest::{Manifest, ManifestError};
pub use runtime::{
    run_bounded_command, AgentRuntime, BoundedOutput, ManifestRuntime, RuntimeError, Spawned,
};

-pub use manifest::{Manifest, ManifestError};

+pub use manifest::{Manifest, ManifestError, PtyDigest};


Bash

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

Rebuild + run digest unit tests


    Finished `test` profile [unoptimized + debuginfo] target(s) in 2.46s
     Running unittests src\lib.rs (target\debug\deps\spt_daemon-eb64e414220d12d6.exe)

running 10 tests
test digesthub::tests::subscribe_returns_current_base ... ok
test digesthub::tests::publish_then_snapshot_reads_latest ... ok
test digesthub::tests::subscriber_receives_changes_then_is_pruned ... ok
test digest::tests::persistence_off_surfaces_nothing ... ok
test digest::tests::unchanged_feed_emits_no_delta ... ok
test digest::tests::active_turn_growth_is_a_tail_delta ... ok
test digest::tests::window_slide_is_a_full_update ... ok
test digest::tests::persistence_reports_each_completed_turn_once ... ok
test digest::tests::from_manifest_feeds_and_snapshots ... FAILED
test digest::tests::tool_sprint_is_structured_in_the_turn ... ok

failures:

---- digest::tests::from_manifest_feeds_and_snapshots stdout ----

thread 'digest::tests::from_manifest_feeds_and_snapshots' (41644) panicked at crates\spt-daemon\src\digest.rs:241:9:
first observed change is a full update
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

failures:
    digest::tests::from_manifest_feeds_and_snapshots

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

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


The tail-delta semantic needs an explicit index. `from`-based apply (truncate-to-`from` + append) is unambiguous and makes the `full` flag redundant — front-eviction naturally yields `from=0`. Refactoring:


Edit

//! [`DigestEngine::feed`] returns a [`DigestUpdate`] only when the digest changed,
//! so a subscriber receives **only deltas**, never a re-push of an unchanged
//! buffer. An update is either:
//! - **`full`** — replace the whole view with `turns` (sent on first subscribe
//!   and whenever the window *composition* shifts: the oldest turn dropped or the
//!   turn count changed), or
//! - **tail** (`full = false`) — the last `turns.len()` turns changed *in place*
//!   (the steady state: the active turn grew as the agent produced more output).
//!   The subscriber overwrites that many turns at its tail.

//! [`DigestEngine::feed`] returns a [`DigestUpdate`] only when the digest changed,
//! so a subscriber receives **only deltas**, never a re-push of an unchanged
//! buffer. The update is index-anchored: `from` is where the changed turns begin
//! in the window, and the subscriber applies it by **truncating its view to
//! `from`, then appending `turns`**. In the steady state (the active turn grows
//! as the agent emits output) `from` points at that last turn and `turns` is just
//! it — a one-turn delta. When the rolling window slides (the oldest turn
//! evicted) the front turns no longer match, so `from` is `0` and the whole
//! current window is sent (a natural full refresh — no separate flag needed).


//! [`DigestEngine::feed`] returns a [`DigestUpdate`] only when the digest changed,
//! so a subscriber receives **only deltas**, never a re-push of an unchanged
//! buffer. An update is either:
//! - **`full`** — replace the whole view with `turns` (sent on first subscribe
//!   and whenever the window *composition* shifts: the oldest turn dropped or the
//!   turn count changed), or
//! - **tail** (`full = false`) — the last `turns.len()` turns changed *in place*
//!   (the steady state: the active turn grew as the agent produced more output).
//!   The subscriber overwrites that many turns at its tail.

//! [`DigestEngine::feed`] returns a [`DigestUpdate`] only when the digest changed,
//! so a subscriber receives **only deltas**, never a re-push of an unchanged
//! buffer. The update is index-anchored: `from` is where the changed turns begin
//! in the window, and the subscriber applies it by **truncating its view to
//! `from`, then appending `turns`**. In the steady state (the active turn grows
//! as the agent emits output) `from` points at that last turn and `turns` is just
//! it — a one-turn delta. When the rolling window slides (the oldest turn
//! evicted) the front turns no longer match, so `from` is `0` and the whole
//! current window is sent (a natural full refresh — no separate flag needed).

-//! - **`full`** — replace the whole view with `turns` (sent on first subscribe

-//!   and whenever the window *composition* shifts: the oldest turn dropped or the

-//! - **tail** (`full = false`) — the last `turns.len()` turns changed *in place*

-//!   (the steady state: the active turn grew as the agent produced more output).

-//!   The subscriber overwrites that many turns at its tail.

+//! buffer. The update is index-anchored: `from` is where the changed turns begin

+//! in the window, and the subscriber applies it by **truncating its view to

+//! `from`, then appending `turns`**. In the steady state (the active turn grows

+//! as the agent emits output) `from` points at that last turn and `turns` is just

+//! it — a one-turn delta. When the rolling window slides (the oldest turn

+//! evicted) the front turns no longer match, so `from` is `0` and the whole

+//! current window is sent (a natural full refresh — no separate flag needed).

//! ## Opt-in Path-B persistence (ADR-0008 "Option (C)")


#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DigestUpdate {
    /// Monotonic version of the digest after this change (starts at 1).
    pub version: u64,
    /// `true` ⇒ replace the entire view with `turns`; `false` ⇒ the last
    /// `turns.len()` turns changed in place (tail-overwrite).
    pub full: bool,
    /// The changed turns (the whole window when `full`, else the changed tail).
    pub turns: Vec<Turn>,

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DigestUpdate {
    /// Monotonic version of the digest after this change (starts at 1).
    pub version: u64,
    /// The window index where `turns` begins. Apply: truncate the view to `from`,
    /// then append `turns`. `from == 0` is a full replace (the base snapshot, or a
    /// window slide that invalidated old indices).
    pub from: usize,
    /// The changed turns starting at `from` (the whole window when `from == 0`).
    pub turns: Vec<Turn>,


#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DigestUpdate {
    /// Monotonic version of the digest after this change (starts at 1).
    pub version: u64,
    /// `true` ⇒ replace the entire view with `turns`; `false` ⇒ the last
    /// `turns.len()` turns changed in place (tail-overwrite).
    pub full: bool,
    /// The changed turns (the whole window when `full`, else the changed tail).
    pub turns: Vec<Turn>,

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DigestUpdate {
    /// Monotonic version of the digest after this change (starts at 1).
    pub version: u64,
    /// The window index where `turns` begins. Apply: truncate the view to `from`,
    /// then append `turns`. `from == 0` is a full replace (the base snapshot, or a
    /// window slide that invalidated old indices).
    pub from: usize,
    /// The changed turns starting at `from` (the whole window when `from == 0`).
    pub turns: Vec<Turn>,

-    /// `true` ⇒ replace the entire view with `turns`; `false` ⇒ the last

-    /// `turns.len()` turns changed in place (tail-overwrite).

-    /// The changed turns (the whole window when `full`, else the changed tail).

+    /// The window index where `turns` begins. Apply: truncate the view to `from`,

+    /// then append `turns`. `from == 0` is a full replace (the base snapshot, or a

+    /// window slide that invalidated old indices).

+    /// The changed turns starting at `from` (the whole window when `from == 0`).

/// Turns that settled this feed (for opt-in Path-B persistence). Server-side

/// only — never crosses the control channel (the subscriber sees the parsed


    /// A `full` update carrying the current snapshot — what a fresh subscriber
    /// receives first so it has a base to apply later tail-deltas against. Always
    /// carries the live version (no change is recorded).
    pub fn full_update(&self) -> DigestUpdate {
        DigestUpdate {
            version: self.version,
            full: true,
            turns: self.last.turns.clone(),
            newly_completed: Vec::new(),
        }
    }

    /// Feed a chunk of the session's PTY output. Re-derives the digest; returns a
    /// [`DigestUpdate`] iff it changed (else `None` — nothing to push).
    // [impl->REQ-TERM-4]
    pub fn feed(&mut self, bytes: &[u8]) -> Option<DigestUpdate> {
        self.parser.feed(bytes);
        let next = self.parser.digest();
        if next == self.last {
            return None; // unchanged — emit no delta
        }
        self.version += 1;

        // Decide full-vs-tail: a window composition shift (the oldest turn rolled
        // off, or the turn count changed) needs a full refresh because tail
        // offsets no longer align; an in-place change to existing turns is a tail
        // delta. The cheap, robust discriminator: if the two windows share a
        // common *prefix-aligned* head of equal turns, only the differing tail
        // changed; otherwise the window slid → full.
        let full = !same_window_frame(&self.last, &next);
        let turns = if full {
            next.turns.clone()
        } else {
            let unchanged = common_prefix_len(&self.last.turns, &next.turns);
            next.turns[unchanged..].to_vec()
        };

        let newly_completed = if self.persist {
            self.take_newly_completed(&next)
        } else {
            Vec::new()
        };

        self.last = next;
        Some(DigestUpdate { version: self.version, full, turns, newly_completed })
    }

    /// A base update carrying the whole current snapshot (`from == 0`) — what a
    /// fresh subscriber receives first so it has an apply anchor for later deltas.
    /// Carries the live version (records no change).
    pub fn full_update(&self) -> DigestUpdate {
        DigestUpdate {
            version: self.version,
            from: 0,
            turns: self.last.turns.clone(),
            newly_completed: Vec::new(),
        }
    }

    /// Feed a chunk of the session's PTY output. Re-derives the digest; returns a
    /// [`DigestUpdate`] iff it changed (else `None` — nothing to push).
    // [impl->REQ-TERM-4]
    pub fn feed(&mut self, bytes: &[u8]) -> Option<DigestUpdate> {
        self.parser.feed(bytes);
        let next = self.parser.digest();
        if next == self.last {
            return None; // unchanged — emit no delta
        }
        self.version += 1;

        // The changed turns begin just past the equal-by-value head the new window
        // shares with the old one. A pure in-place/extend keeps the head intact →
        // `from` points at the active tail (a minimal delta). A window slide
        // evicts the oldest turn → the heads differ from index 0 → `from == 0` and
        // the whole current window is sent (a natural full refresh).
        let from = common_prefix_len(&self.last.turns, &next.turns);
        let turns = next.turns[from..].to_vec();

        let newly_completed = if self.persist {
            self.take_newly_completed(&next)
        } else {
            Vec::new()
        };

        self.last = next;
        Some(DigestUpdate { version: self.version, from, turns, newly_completed })
    }


    /// A `full` update carrying the current snapshot — what a fresh subscriber
    /// receives first so it has a base to apply later tail-deltas against. Always
    /// carries the live version (no change is recorded).
    pub fn full_update(&self) -> DigestUpdate {
        DigestUpdate {
            version: self.version,
            full: true,
            turns: self.last.turns.clone(),
            newly_completed: Vec::new(),
        }
    }

    /// Feed a chunk of the session's PTY output. Re-derives the digest; returns a
    /// [`DigestUpdate`] iff it changed (else `None` — nothing to push).
    // [impl->REQ-TERM-4]
    pub fn feed(&mut self, bytes: &[u8]) -> Option<DigestUpdate> {
        self.parser.feed(bytes);
        let next = self.parser.digest();
        if next == self.last {
            return None; // unchanged — emit no delta
        }
        self.version += 1;

        // Decide full-vs-tail: a window composition shift (the oldest turn rolled
        // off, or the turn count changed) needs a full refresh because tail
        // offsets no longer align; an in-place change to existing turns is a tail
        // delta. The cheap, robust discriminator: if the two windows share a
        // common *prefix-aligned* head of equal turns, only the differing tail
        // changed; otherwise the window slid → full.
        let full = !same_window_frame(&self.last, &next);
        let turns = if full {
            next.turns.clone()
        } else {
            let unchanged = common_prefix_len(&self.last.turns, &next.turns);
            next.turns[unchanged..].to_vec()
        };

        let newly_completed = if self.persist {
            self.take_newly_completed(&next)
        } else {
            Vec::new()
        };

        self.last = next;
        Some(DigestUpdate { version: self.version, full, turns, newly_completed })
    }

    /// A base update carrying the whole current snapshot (`from == 0`) — what a
    /// fresh subscriber receives first so it has an apply anchor for later deltas.
    /// Carries the live version (records no change).
    pub fn full_update(&self) -> DigestUpdate {
        DigestUpdate {
            version: self.version,
            from: 0,
            turns: self.last.turns.clone(),
            newly_completed: Vec::new(),
        }
    }

    /// Feed a chunk of the session's PTY output. Re-derives the digest; returns a
    /// [`DigestUpdate`] iff it changed (else `None` — nothing to push).
    // [impl->REQ-TERM-4]
    pub fn feed(&mut self, bytes: &[u8]) -> Option<DigestUpdate> {
        self.parser.feed(bytes);
        let next = self.parser.digest();
        if next == self.last {
            return None; // unchanged — emit no delta
        }
        self.version += 1;

        // The changed turns begin just past the equal-by-value head the new window
        // shares with the old one. A pure in-place/extend keeps the head intact →
        // `from` points at the active tail (a minimal delta). A window slide
        // evicts the oldest turn → the heads differ from index 0 → `from == 0` and
        // the whole current window is sent (a natural full refresh).
        let from = common_prefix_len(&self.last.turns, &next.turns);
        let turns = next.turns[from..].to_vec();

        let newly_completed = if self.persist {
            self.take_newly_completed(&next)
        } else {
            Vec::new()
        };

        self.last = next;
        Some(DigestUpdate { version: self.version, from, turns, newly_completed })
    }

-    /// A `full` update carrying the current snapshot — what a fresh subscriber

-    /// receives first so it has a base to apply later tail-deltas against. Always

-    /// carries the live version (no change is recorded).

+    /// A base update carrying the whole current snapshot (`from == 0`) — what a

+    /// fresh subscriber receives first so it has an apply anchor for later deltas.

+    /// Carries the live version (records no change).

-        // Decide full-vs-tail: a window composition shift (the oldest turn rolled

-        // off, or the turn count changed) needs a full refresh because tail

-        // offsets no longer align; an in-place change to existing turns is a tail

-        // delta. The cheap, robust discriminator: if the two windows share a

-        // common *prefix-aligned* head of equal turns, only the differing tail

-        // changed; otherwise the window slid → full.

-        let full = !same_window_frame(&self.last, &next);

-            let unchanged = common_prefix_len(&self.last.turns, &next.turns);

-            next.turns[unchanged..].to_vec()

+        // The changed turns begin just past the equal-by-value head the new window

+        // shares with the old one. A pure in-place/extend keeps the head intact →

+        // `from` points at the active tail (a minimal delta). A window slide

+        // evicts the oldest turn → the heads differ from index 0 → `from == 0` and

+        // the whole current window is sent (a natural full refresh).

+        let from = common_prefix_len(&self.last.turns, &next.turns);

+        let turns = next.turns[from..].to_vec();

-        Some(DigestUpdate { version: self.version, full, turns, newly_completed })

+        Some(DigestUpdate { version: self.version, from, turns, newly_completed })

/// Completed (settled) turns observed since the last call: every turn but the


Remove the now-unused `same_window_frame` helper and fix the engine tests:


/// Whether `a` and `b` share the same window frame — i.e. `b` is `a` with only
/// its tail mutated/extended, not slid. True when one turn list is a prefix-equal
/// head of the other (so tail offsets still align for an in-place delta).
fn same_window_frame(a: &Digest, b: &Digest) -> bool {
    let n = common_prefix_len(&a.turns, &b.turns);
    // Aligned if everything up to the shorter length matched (only the tail of
    // the longer differs) — a pure in-place/extend, no front eviction.
    n == a.turns.len().min(b.turns.len())
}

/// Length of the equal-by-value prefix shared by two turn slices.

/// Length of the equal-by-value prefix shared by two turn slices.


/// Whether `a` and `b` share the same window frame — i.e. `b` is `a` with only
/// its tail mutated/extended, not slid. True when one turn list is a prefix-equal
/// head of the other (so tail offsets still align for an in-place delta).
fn same_window_frame(a: &Digest, b: &Digest) -> bool {
    let n = common_prefix_len(&a.turns, &b.turns);
    // Aligned if everything up to the shorter length matched (only the tail of
    // the longer differs) — a pure in-place/extend, no front eviction.
    n == a.turns.len().min(b.turns.len())
}

/// Length of the equal-by-value prefix shared by two turn slices.

-/// Whether `a` and `b` share the same window frame — i.e. `b` is `a` with only

-/// its tail mutated/extended, not slid. True when one turn list is a prefix-equal

-/// head of the other (so tail offsets still align for an in-place delta).

-fn same_window_frame(a: &Digest, b: &Digest) -> bool {

-    let n = common_prefix_len(&a.turns, &b.turns);

-    // Aligned if everything up to the shorter length matched (only the tail of

-    // the longer differs) — a pure in-place/extend, no front eviction.

-    n == a.turns.len().min(b.turns.len())

fn common_prefix_len(a: &[Turn], b: &[Turn]) -> usize {

a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count()