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

//! Session-digest output types + presentation config (ADR-0008 amendment,
//! 2026-06-12; REQ-TERM-4).
//!
//! The structured, source-tagged shape a projected digest takes — the last ~N
//! user turns plus the agent output between them, with tool-usage sprints
//! collapsed to a single entry. Distinct from raw scrollback (bytes) and
//! transcript history (the full conversation); its job is *"what is this agent
//! doing right now, at a glance."*
//!
//! These are the **output** types only. The projection that produces a [`Digest`]
//! from an endpoint's normalized session-log records lives in
//! [`crate::projection`] (the digest is a log projection, never a PTY-byte parse —
//! the superseded source mechanism). The types cross the daemon IPC + the `spt
//! endpoint digest` control channel, so they are serde-serializable.

use serde::{Deserialize, Serialize};

/// Presentation knobs owned by spt-core (not the adapter): window depth `N`
/// (~3 user turns) and tool-arg truncation width (~25 chars). Extraction is the
/// adapter's (the published digest-record contract); presentation is spt-core's.
#[derive(Debug, Clone, Copy)]
pub struct DigestConfig {
    /// Window depth — keep the last `window_turns` user turns (~3).
    pub window_turns: usize,
    /// Tool-arg truncation width in characters (~25).
    pub arg_truncation: usize,
}

impl Default for DigestConfig {
    fn default() -> Self {
        Self {
            window_turns: 3,
            arg_truncation: 25,
        }
    }
}

/// A single collapsed tool invocation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolUse {
    /// The tool name (e.g. `Write`).
    pub name: String,
    /// The (truncated) argument fragment.
    pub arg: String,
}

/// One source-tagged entry within a turn.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum DigestEntry {
    /// Agent output text (between boundaries).
    Agent(String),
    /// A collapsed run of consecutive tool invocations (one "sprint").
    ToolSprint(Vec<ToolUse>),
}

/// One user turn: the user input that opened it (None for any pre-input
/// preamble) plus the agent output / tool sprints that followed, in order.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Turn {
    /// The user input text that opened this turn.
    pub input: Option<String>,
    /// Agent + tool-sprint entries that followed, in stream order.
    pub entries: Vec<DigestEntry>,
}

/// The structured rolling digest — the last `N` turns.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Digest {
    /// Turns oldest-to-newest, at most `window_turns` of them.
    pub turns: Vec<Turn>,
}

/// Truncate `arg` to `width` characters, appending `…` when shortened.
pub(crate) fn truncate_arg(arg: &str, width: usize) -> String {
    if arg.chars().count() <= width {
        return arg.to_string();
    }
    let mut s: String = arg.chars().take(width).collect();
    s.push('…');
    s
}

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

    // [unit->REQ-TERM-4] arg truncation is spt-core presentation: short args pass
    // through; long args are cut to width with an ellipsis.
    #[test]
    fn truncate_arg_cuts_to_width_with_ellipsis() {
        assert_eq!(truncate_arg("short", 25), "short");
        assert_eq!(truncate_arg("this-is-a-very-long-arg", 10), "this-is-a-…");
    }

    // [unit->REQ-TERM-4] the presentation defaults are the spt-core formula
    // (~3-turn window, ~25-char arg truncation).
    #[test]
    fn config_defaults_are_the_presentation_formula() {
        let c = DigestConfig::default();
        assert_eq!((c.window_turns, c.arg_truncation), (3, 25));
    }
}

-//! Live activity buffer (PTY digest) parser primitive (M3a A5, ADR-0008).

+//! Session-digest output types + presentation config (ADR-0008 amendment,

-//! A rolling, *parsed*, source-tagged view of recent PTY I/O — the last ~N user

-//! turns plus the agent output between them, with tool-usage sprints collapsed

-//! to a single entry. Distinct from raw scrollback (bytes) and transcript

-//! history (the full conversation); its job is *"what is this agent doing right

+//! The structured, source-tagged shape a projected digest takes — the last ~N

+//! user turns plus the agent output between them, with tool-usage sprints

+//! collapsed to a single entry. Distinct from raw scrollback (bytes) and

+//! transcript history (the full conversation); its job is *"what is this agent

-//! ## The parsing-boundary rule (ADR-0008 — NOT a built-in per-harness parser)

-//! **The adapter supplies the patterns; this primitive only runs them.** Exactly

-//! Path A's "adapter owns the parser" rule and the "spt-core owns the mechanism,

-//! adapter owns the pattern" principle (identical to opaque command templates).

-//! The seam is three adapter-supplied pattern kinds:

-//! - **`input`** (user-input boundary) and **`agent`** (agent-output boundary) —

-//!   each *opens a span capturing everything, newlines included, until the next

-//! - **`tools`** — a list (one regex per known tool) **plus an optional

-//!   `catchall`** for unknowable user-installed tools — each extracting a tool

-//!   `name` and an `arg` fragment via named capture groups.

-//! Window depth `N` and the tool-arg truncation width are spt-core-controlled

-//! *presentation*; the template controls *extraction*.

-//! ## Scope (M3a is the primitive ONLY)

-//! This is the parser: patterns in, structured [`Digest`] out, fed from the A4

-//! byte stream. The manifest-sourcing of the patterns, the `spt digest` pull,

-//! the broker-pushed delta-stream, and Path-B persistence are **M3b** — they need

-//! the broker + manifest + history store this crate deliberately does not build.

-//! ConPTY repaints on resize — it reorders and *duplicates* stream content. The

-//! parser therefore re-derives the digest from a bounded rolling text buffer and

-//! reports the **tail** (last `N` turns): a repaint that resends a turn just

-//! makes the latest copy win. ANSI control sequences (cursor moves, clears,

-//! repaint machinery) are stripped before matching so the adapter patterns see

-//! clean text on both the forkpty raw-pipe and ConPTY screen-buffer backends.

+//! These are the **output** types only. The projection that produces a [`Digest`]

+//! from an endpoint's normalized session-log records lives in

+//! [`crate::projection`] (the digest is a log projection, never a PTY-byte parse —

+//! the superseded source mechanism). The types cross the daemon IPC + the `spt

+//! endpoint digest` control channel, so they are serde-serializable.

-/// A failure compiling an adapter-supplied pattern.

-    /// A pattern did not compile as a regex. Names which one.

-    BadPattern { which: String, error: String },

-impl std::fmt::Display for DigestError {

-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {

-            DigestError::BadPattern { which, error } => {

-                write!(f, "digest pattern {which} did not compile: {error}")

-impl std::error::Error for DigestError {}

-/// The adapter-supplied patterns (regex source strings). `input` / `agent` are

-/// boundary markers; `tools` + `catchall` extract `name` + `arg` capture groups.

-    /// One regex per known tool; each should name an `arg` group (and may name a

-    /// `name` group — else the tool's matched text leads).

-    /// Optional final catchall for user-installed tools.

-/// Presentation knobs owned by spt-core (not the template).

+/// Presentation knobs owned by spt-core (not the adapter): window depth `N`

+/// (~3 user turns) and tool-arg truncation width (~25 chars). Extraction is the

+/// adapter's (the published digest-record contract); presentation is spt-core's.

/// Window depth — keep the last `window_turns` user turns (~3).

/// Tool-arg truncation width in characters (~25).

-    /// Strip ANSI control sequences before matching (default on — see module docs).

-    /// Rolling raw-text buffer cap in bytes; older bytes are dropped from the

-    /// front so memory stays bounded under an endless stream.

Self {
window_turns: 3,
arg_truncation: 25,
-            strip_ansi: true,
-            max_scan_bytes: 64 * 1024,
}
}
}

-/// One located pattern hit while scanning the buffer.

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

-    /// Rolling, ANSI-stripped text buffer.

-    /// Compile the adapter-supplied patterns. Returns [`DigestError::BadPattern`]

-    /// naming the first pattern that fails to compile.

-    pub fn new(patterns: DigestPatterns, config: DigestConfig) -> Result<Self, DigestError> {

-        let compile = |src: &str, which: &str| {

-            Regex::new(src).map_err(|e| DigestError::BadPattern {

-                which: which.to_string(),

-        let input_re = compile(&patterns.input, "input")?;

-        let agent_re = compile(&patterns.agent, "agent")?;

-        let mut tool_res = Vec::with_capacity(patterns.tools.len());

-        for (i, t) in patterns.tools.iter().enumerate() {

-            tool_res.push(compile(t, &format!("tools[{i}]"))?);

-        let catchall_re = match &patterns.catchall {

-            Some(c) => Some(compile(c, "catchall")?),

-        // CSI / OSC / lone-ESC stripper. Compiled by us, never fails.

-        let ansi_re = if config.strip_ansi {

-                .expect("static ANSI regex compiles"),

-    /// Feed a chunk of the byte stream. Decoded lossily to UTF-8, ANSI-stripped

-    /// (if configured), normalized (`\r\n`/`\r` → `\n`), appended, and trimmed to

-    pub fn feed(&mut self, bytes: &[u8]) {

-        let text = String::from_utf8_lossy(bytes);

-        let cleaned = match &self.ansi_re {

-            Some(re) => re.replace_all(&text, "").into_owned(),

-        // Normalize carriage returns left by ConPTY repaint so boundary patterns

-        // and span content match on line-oriented text.

-        let cleaned = cleaned.replace("\r\n", "\n").replace('\r', "");

-        if self.buf.len() > self.config.max_scan_bytes {

-            // Drop from the front, on a char boundary.

-            let cut = self.buf.len() - self.config.max_scan_bytes;

-            while idx < self.buf.len() && !self.buf.is_char_boundary(idx) {

-    /// Re-derive the structured digest from the current buffer: scan all pattern

-    /// hits in order, fold boundary spans + collapsed tool sprints into turns,

-    /// and return the last `window_turns`.

-        let markers = self.scan_markers();

-        let mut turns: Vec<Turn> = Vec::new();

-        let mut current = Turn::default();

-        let mut current_started = false;

-                Marker::Input { content } => {

-                    if current_started || !current.entries.is_empty() || current.input.is_some() {

-                        turns.push(std::mem::take(&mut current));

-                        input: Some(content),

-                        entries: Vec::new(),

-                    current_started = true;

-                Marker::Agent { content } => {

-                    if !content.is_empty() {

-                        current.entries.push(DigestEntry::Agent(content));

-                        current_started = true;

-                    // Collapse into the trailing sprint if the last entry is one.

-                    if let Some(DigestEntry::ToolSprint(sprint)) = current.entries.last_mut() {

-                        sprint.push(tu);

-                        current.entries.push(DigestEntry::ToolSprint(vec![tu]));

-        let n = self.config.window_turns;

-            turns.drain(..turns.len() - n);

-    /// Locate every pattern hit in the buffer, sorted by position, and resolve

-    /// boundary span content (text from a boundary to the next hit of any kind).

-    fn scan_markers(&self) -> Vec<Marker> {

-        // (start, end, kind) where kind carries the extracted payload.

-        let mut hits: Vec<(usize, usize, Kind)> = Vec::new();

-        for m in self.input_re.find_iter(&self.buf) {

-            hits.push((m.start(), m.end(), Kind::Input));

-        for m in self.agent_re.find_iter(&self.buf) {

-            hits.push((m.start(), m.end(), Kind::Agent));

-        let mut push_tool = |re: &Regex| {

-            for caps in re.captures_iter(&self.buf) {

-                let whole = caps.get(0).unwrap();

-                    .map(|m| m.as_str().to_string())

-                    .unwrap_or_else(|| {

-                        // No name group: lead with the matched text's first token.

-                            .split_whitespace()

-                            .unwrap_or("")

-                            .to_string()

-                let arg_raw = caps.name("arg").map(|m| m.as_str()).unwrap_or("");

-                let arg = truncate_arg(arg_raw.trim(), self.config.arg_truncation);

-                    Kind::Tool(ToolUse { name, arg }),

-        if let Some(re) = &self.catchall_re {

-            // Only count a catchall hit where no specific tool already matched.

-                if hits.iter().any(|(s, e, k)| {

-                    matches!(k, Kind::Tool(_)) && *s <= whole.start() && whole.end() <= *e

-        // Order by position; on a tie, boundaries before tools (a boundary opens

-        // a span, tools sit inside it).

-        let rank = |k: &Kind| -> u8 { matches!(k, Kind::Tool(_)) as u8 };

-        hits.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| rank(&a.2).cmp(&rank(&b.2))));

-        // Resolve boundary span content = text from this hit's end to the next

-        let starts: Vec<usize> = hits.iter().map(|(s, _, _)| *s).collect();

-        let mut out = Vec::with_capacity(hits.len());

-        for (i, (_, end, kind)) in hits.iter().enumerate() {

-            let next_start = starts.get(i + 1).copied().unwrap_or(self.buf.len());

-                Kind::Input => out.push(Marker::Input { content: span }),

-                Kind::Agent => out.push(Marker::Agent { content: span }),

-                Kind::Tool(tu) => out.push(Marker::Tool(tu.clone())),

/// Truncate `arg` to `width` characters, appending `…` when shortened.

pub(crate) fn truncate_arg(arg: &str, width: usize) -> String {

-            // Boundary markers (the prompt sigils an adapter would supply).

-            input: r"\nUSER> ".to_string(),

-            agent: r"\nAGENT> ".to_string(),

+    // [unit->REQ-TERM-4] arg truncation is spt-core presentation: short args pass

+    // through; long args are cut to width with an ellipsis.

-    fn tags_input_agent_and_collapses_tool_sprints() {

-            b"\nUSER> add a file\nAGENT> sure, doing it\nWrite(src/a.rs) Bash(cargo build) done\n",

-        assert_eq!(t.input.as_deref(), Some("add a file"));

-        // First entry: agent text; second: a single collapsed sprint of 2 tools.

-            DigestEntry::Agent("sure, doing it".to_string())

-            DigestEntry::ToolSprint(s) => {

-                assert_eq!(s.len(), 2, "consecutive tools collapse into one sprint");

-                        name: "Write".into(),

-                        arg: "src/a.rs".into()

-                        name: "Bash".into(),

-                        arg: "cargo build".into()

-            other => panic!("expected a tool sprint, got {other:?}"),

+    fn truncate_arg_cuts_to_width_with_ellipsis() {

+        assert_eq!(truncate_arg("short", 25), "short");

+        assert_eq!(truncate_arg("this-is-a-very-long-arg", 10), "this-is-a-…");

+    // [unit->REQ-TERM-4] the presentation defaults are the spt-core formula

+    // (~3-turn window, ~25-char arg truncation).

-            p.feed(format!("\nUSER> turn {i}\nAGENT> reply {i}\n").as_bytes());

-        assert_eq!(d.turns.len(), 3, "window keeps only the last N=3 turns");

-        assert_eq!(d.turns[0].input.as_deref(), Some("turn 3"));

-        assert_eq!(d.turns[2].input.as_deref(), Some("turn 5"));

+    fn config_defaults_are_the_presentation_formula() {

+        let c = DigestConfig::default();

+        assert_eq!((c.window_turns, c.arg_truncation), (3, 25));

-    fn truncates_tool_args_to_width() {

-        let mut p = DigestParser::new(patterns(), cfg).unwrap();

-        p.feed(b"\nUSER> x\nAGENT> y\nBash(this-is-a-very-long-command-line) z\n");

-                DigestEntry::ToolSprint(s) => Some(s),

-        assert_eq!(sprint[0].name, "Bash");

-            sprint[0].arg, "this-is-a-…",

-            "arg truncated to width with ellipsis"

-    fn strips_ansi_and_tolerates_repaint_duplication() {

-        // A ConPTY-style repaint: clear + home + a duplicated earlier turn, then

-        // the real latest turn. ANSI is stripped; the tail (last N) wins.

-        p.feed(b"\nUSER> first\nAGENT> \x1b[2J\x1b[Hreply one\n");

-        p.feed(b"\x1b[2J\x1b[H\nUSER> first\nAGENT> reply one\n\nUSER> second\nAGENT> reply two\x1b[0m\n");

-        // No ANSI leaked into content.

-            if let Some(i) = &t.input {

-                assert!(!i.contains('\x1b'), "ANSI stripped from input");

-                if let DigestEntry::Agent(a) = e {

-                    assert!(!a.contains('\x1b'), "ANSI stripped from agent text");

-                    assert!(!a.contains("[2J"), "no CSI residue");

-        // The most recent turn is present and correct despite the repaint.

-        let last = d.turns.last().unwrap();

-        assert_eq!(last.input.as_deref(), Some("second"));

-        assert_eq!(last.entries[0], DigestEntry::Agent("reply two".to_string()));

-    fn bad_pattern_is_reported_by_name() {

-        pats.agent = r"(unclosed".to_string();

-        let err = DigestParser::new(pats, DigestConfig::default()).unwrap_err();

-            DigestError::BadPattern { which, .. } => assert_eq!(which, "agent"),


Update `spt-term/src/lib.rs` exports + module header, and drop the `regex` dep:


Edit

pub use digest::{
    Digest, DigestConfig, DigestEntry, DigestError, DigestParser, DigestPatterns, ToolUse, Turn,
};
pub use projection::{project, project_lines, record_to_tagged, DigestRecord, DigestRole, ToolRef};

pub use digest::{Digest, DigestConfig, DigestEntry, ToolUse, Turn};
pub use projection::{project, project_lines, record_to_tagged, DigestRecord, DigestRole, ToolRef};


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

pub use digest::{Digest, DigestConfig, DigestEntry, ToolUse, Turn};
pub use projection::{project, project_lines, record_to_tagged, DigestRecord, DigestRole, ToolRef};

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

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

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

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

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

+pub use digest::{Digest, DigestConfig, DigestEntry, ToolUse, Turn};


//   A4  byte-stream local streaming (bounded backpressure)  (REQ-TERM-3)       ← landed
//   A5  live activity buffer (PTY digest) parser primitive  (REQ-TERM-4)       ← landed

//   A4  byte-stream local streaming (bounded backpressure)  (REQ-TERM-3)       ← landed
//   A5  live activity buffer — digest output types + the log-source projection
//       (REQ-TERM-4; the superseded PTY-byte parser was retired M9 / ADR-0008
//       amendment — the digest is a projection of normalized session logs).


//   A4  byte-stream local streaming (bounded backpressure)  (REQ-TERM-3)       ← landed
//   A5  live activity buffer — digest output types + the log-source projection
//       (REQ-TERM-4; the superseded PTY-byte parser was retired M9 / ADR-0008
//       amendment — the digest is a projection of normalized session logs).

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

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

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

pub use digest::{Digest, DigestConfig, DigestEntry, ToolUse, Turn};
pub use projection::{project, project_lines, record_to_tagged, DigestRecord, DigestRole, ToolRef};
pub use portable_pty::CommandBuilder;
pub use pty::PtySession;
pub use reader::Drain;
pub use stream::OutputStream;
pub use surface::{SessionSurface, SurfaceError, SurfaceSize};

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

+//   A5  live activity buffer — digest output types + the log-source projection

+//       (REQ-TERM-4; the superseded PTY-byte parser was retired M9 / ADR-0008

+//       amendment — the digest is a projection of normalized session logs).


Drop `regex` from `spt-term` + refresh its description:


description = "spt-core session-surface mechanism (M3a): the multiplatform PTY layer every hosted agent session runs inside. ConPTY (Windows) / forkpty (Unix) behind one SessionSurface trait, byte-stream output with bounded backpressure, and the live activity buffer (PTY digest) parser primitive. Internal broker machinery (NOT public SDK, R-ARCH-2) — the M3b daemon hosts many of these."

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
portable-pty = "0.8"
regex = "1"
# The digest data types (Digest/Turn/DigestEntry/ToolUse) cross the broker↔brain
# IPC + the `spt digest` control channel (M3b B8), so they are serde-serializable.
serde = { version = "1", features = ["derive"] }

description = "spt-core session-surface mechanism (M3a): the multiplatform PTY layer every hosted agent session runs inside. ConPTY (Windows) / forkpty (Unix) behind one SessionSurface trait, byte-stream output with bounded backpressure, and the live activity buffer digest (output types + the log-source projection). Internal broker machinery (NOT public SDK, R-ARCH-2) — the M3b daemon hosts many of these."

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
portable-pty = "0.8"
# The digest data types (Digest/Turn/DigestEntry/ToolUse) cross the broker↔brain
# IPC + the `spt endpoint digest` control channel, so they are serde-serializable.
serde = { version = "1", features = ["derive"] }


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

description = "spt-core session-surface mechanism (M3a): the multiplatform PTY layer every hosted agent session runs inside. ConPTY (Windows) / forkpty (Unix) behind one SessionSurface trait, byte-stream output with bounded backpressure, and the live activity buffer digest (output types + the log-source projection). Internal broker machinery (NOT public SDK, R-ARCH-2) — the M3b daemon hosts many of these."

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
portable-pty = "0.8"
# The digest data types (Digest/Turn/DigestEntry/ToolUse) cross the broker↔brain
# IPC + the `spt endpoint digest` control channel, so they are serde-serializable.
serde = { version = "1", features = ["derive"] }

[package]
name = "spt-term"
version.workspace = true
edition = "2021"
publish = false
description = "spt-core session-surface mechanism (M3a): the multiplatform PTY layer every hosted agent session runs inside. ConPTY (Windows) / forkpty (Unix) behind one SessionSurface trait, byte-stream output with bounded backpressure, and the live activity buffer (PTY digest) parser primitive. Internal broker machinery (NOT public SDK, R-ARCH-2) — the M3b daemon hosts many of these."

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
portable-pty = "0.8"
regex = "1"
# The digest data types (Digest/Turn/DigestEntry/ToolUse) cross the broker↔brain
# IPC + the `spt digest` control channel (M3b B8), so they are serde-serializable.
serde = { version = "1", features = ["derive"] }
# The session-digest log projection parses the published digest-record contract
# (one normalized JSON line per record) — ADR-0008 amendment / REQ-TERM-4.
serde_json = "1"

[dev-dependencies]
tempfile = "3"

-description = "spt-core session-surface mechanism (M3a): the multiplatform PTY layer every hosted agent session runs inside. ConPTY (Windows) / forkpty (Unix) behind one SessionSurface trait, byte-stream output with bounded backpressure, and the live activity buffer (PTY digest) parser primitive. Internal broker machinery (NOT public SDK, R-ARCH-2) — the M3b daemon hosts many of these."

+description = "spt-core session-surface mechanism (M3a): the multiplatform PTY layer every hosted agent session runs inside. ConPTY (Windows) / forkpty (Unix) behind one SessionSurface trait, byte-stream output with bounded backpressure, and the live activity buffer digest (output types + the log-source projection). Internal broker machinery (NOT public SDK, R-ARCH-2) — the M3b daemon hosts many of these."

-# IPC + the `spt digest` control channel (M3b B8), so they are serde-serializable.

+# IPC + the `spt endpoint digest` control channel, so they are serde-serializable.


//! agent session runs inside: a real child under a native PTY — ConPTY on Windows
//! 10+, `forkpty(3)` on Unix — behind one [`SessionSurface`] trait, with input
//! injection, byte-stream output, and the live activity buffer (PTY digest) parser
//! primitive layered on top. It is the sixth crate in the acyclic stack

//! agent session runs inside: a real child under a native PTY — ConPTY on Windows
//! 10+, `forkpty(3)` on Unix — behind one [`SessionSurface`] trait, with input
//! injection, byte-stream output, and the live activity buffer digest (output
//! types + the log-source projection) layered on top. It is the sixth crate in
//! the acyclic stack


//! agent session runs inside: a real child under a native PTY — ConPTY on Windows
//! 10+, `forkpty(3)` on Unix — behind one [`SessionSurface`] trait, with input
//! injection, byte-stream output, and the live activity buffer digest (output
//! types + the log-source projection) layered on top. It is the sixth crate in
//! the acyclic stack

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

// Surfaces land across M3a (see M3a-PLAN.md task table):
//   A1  SessionSurface trait + ConPTY/forkpty backends     (REQ-TERM-1)       ← landed
//   A2  ConPTY-DSR auto-answer drain thread                 (REQ-HAZARD-CONPTY-DSR) ← landed
//   A3  send-keys / send-line injection                     (REQ-TERM-2)
//   A4  byte-stream local streaming (bounded backpressure)  (REQ-TERM-3)       ← landed
//   A5  live activity buffer — digest output types + the log-source projection
//       (REQ-TERM-4; the superseded PTY-byte parser was retired M9 / ADR-0008
//       amendment — the digest is a projection of normalized session logs).

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

pub use digest::{Digest, DigestConfig, DigestEntry, ToolUse, Turn};
pub use projection::{project, project_lines, record_to_tagged, DigestRecord, DigestRole, ToolRef};
pub use portable_pty::CommandBuilder;
pub use pty::PtySession;
pub use reader::Drain;
pub use stream::OutputStream;
pub use surface::{SessionSurface, SurfaceError, SurfaceSize};

-//! injection, byte-stream output, and the live activity buffer (PTY digest) parser

-//! primitive layered on top. It is the sixth crate in the acyclic stack

+//! injection, byte-stream output, and the live activity buffer digest (output

+//! types + the log-source projection) layered on top. It is the sixth crate in


Now retire `PtyDigest` from spt-runtime. Find the struct, the Manifest field, validate, and tests:


51:    /// `[pty_digest]` — the live-activity-buffer pattern seam (ADR-0008). The
57:    pub pty_digest: Option<PtyDigest>,
512:/// `[pty_digest]` — the adapter-supplied patterns for the live activity buffer
526:pub struct PtyDigest {
695:        // pty_digest: the two boundary markers are load-bearing — a digest with
700:        if let Some(d) = &self.pty_digest {
703:                    "[pty_digest] requires non-empty `input_pattern` and `agent_pattern`".into(),
788:[pty_digest]
854:        // pty_digest seam (ADR-0008)
855:        let d = m.pty_digest.unwrap();
902:    #[test] // [unit->REQ-TERM-4] the pty_digest seam round-trips through TOML.
903:    fn pty_digest_round_trips() {
906:        assert_eq!(m.pty_digest, reparsed.pty_digest);
909:    #[test] // [unit->REQ-TERM-4] pty_digest defaults: tools empty, catchall none,
911:    fn pty_digest_minimal_defaults() {
914:             [pty_digest]\ninput_pattern=\"U> \"\nagent_pattern=\"A> \"\n",
917:        let d = m.pty_digest.unwrap();
924:    #[test] // [unit->REQ-TERM-4] a pty_digest missing a boundary marker is rejected
926:    fn pty_digest_requires_both_boundaries() {
928:                   [pty_digest]\ninput_pattern=\"U> \"\nagent_pattern=\"\"\n";
931:        assert!(e.to_string().contains("pty_digest"));


48	    /// `[shell]` body — present iff `adapter.kind = "shell"` (validated).
49	    #[serde(default, skip_serializing_if = "Option::is_none")]
50	    pub shell: Option<Shell>,
51	    /// `[pty_digest]` — the live-activity-buffer pattern seam (ADR-0008). The
52	    /// adapter supplies the boundary + tool patterns; spt-core only *runs* them
53	    /// over the broker PTY byte stream (the no-built-in-parser rule — identical to
54	    /// opaque command templates). Absent ⇒ the digest is unavailable for this
55	    /// adapter.
56	    #[serde(default, skip_serializing_if = "Option::is_none")]
57	    pub pty_digest: Option<PtyDigest>,
58	    /// `[profiles.<name>]` — **shipped** profile overlays: sparse leaf-replace
59	    /// overlays declared by the adapter dev inside the parent manifest, updating

    /// `[shell]` body — present iff `adapter.kind = "shell"` (validated).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub shell: Option<Shell>,
    /// `[pty_digest]` — the live-activity-buffer pattern seam (ADR-0008). The
    /// adapter supplies the boundary + tool patterns; spt-core only *runs* them
    /// over the broker PTY byte stream (the no-built-in-parser rule — identical to
    /// opaque command templates). Absent ⇒ the digest is unavailable for this
    /// adapter.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pty_digest: Option<PtyDigest>,
    /// `[profiles.<name>]` — **shipped** profile overlays: sparse leaf-replace
    /// overlays declared by the adapter dev inside the parent manifest, updating


    /// `[shell]` body — present iff `adapter.kind = "shell"` (validated).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub shell: Option<Shell>,
    /// `[pty_digest]` — the live-activity-buffer pattern seam (ADR-0008). The
    /// adapter supplies the boundary + tool patterns; spt-core only *runs* them
    /// over the broker PTY byte stream (the no-built-in-parser rule — identical to
    /// opaque command templates). Absent ⇒ the digest is unavailable for this
    /// adapter.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pty_digest: Option<PtyDigest>,

    /// `[shell]` body — present iff `adapter.kind = "shell"` (validated).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub shell: Option<Shell>,


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

    /// `[shell]` body — present iff `adapter.kind = "shell"` (validated).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub shell: Option<Shell>,
    /// `[pty_digest]` — the live-activity-buffer pattern seam (ADR-0008). The
    /// adapter supplies the boundary + tool patterns; spt-core only *runs* them
    /// over the broker PTY byte stream (the no-built-in-parser rule — identical to
    /// opaque command templates). Absent ⇒ the digest is unavailable for this
    /// adapter.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pty_digest: Option<PtyDigest>,

-    /// `[pty_digest]` — the live-activity-buffer pattern seam (ADR-0008). The

-    /// adapter supplies the boundary + tool patterns; spt-core only *runs* them

-    /// over the broker PTY byte stream (the no-built-in-parser rule — identical to

-    /// opaque command templates). Absent ⇒ the digest is unavailable for this

-    #[serde(default, skip_serializing_if = "Option::is_none")]

/// `[profiles.<name>]` — **shipped** profile overlays: sparse leaf-replace

/// overlays declared by the adapter dev inside the parent manifest, updating

/// as one unit with it. Stored raw ([`toml::Value`]); [`crate::profile::resolve`]


498	impl OverCap {
499	    /// Whether this is the default (`reject`) — for `skip_serializing_if`.
500	    pub fn is_default(&self) -> bool {
501	        matches!(self, OverCap::Reject)
502	    }
503	}
504
505	/// `[pty_digest]` — the adapter-supplied patterns for the live activity buffer
506	/// (ADR-0008). `input_pattern`/`agent_pattern` are the turn-boundary markers;
507	/// `tool_patterns` is one regex per known tool and `catchall_pattern` an optional
508	/// final catchall for user-installed tools (each naming `name`/`arg` capture
509	/// groups). spt-core compiles + runs these over the broker PTY stream — it never
510	/// parses harness transcripts (the history-subsystem no-built-in-parser rule the
511	/// digest deliberately honors). The patterns are **opaque to spt-core**, exactly
512	/// like the `[session.*]` command templates.
513	///
514	/// `window_turns` is the only presentation override exposed here; the remaining
515	/// presentation knobs (arg truncation, ANSI stripping, scan-buffer cap) stay
516	/// spt-core-owned defaults. `persist` opts this endpoint into the coarse Path-B
517	/// activity log (ADR-0008 "Option (C)") — off by default.
518	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
519	pub struct PtyDigest {
520	    /// User-input boundary marker (regex). Opens a span until the next match.
521	    pub input_pattern: String,
522	    /// Agent-output boundary marker (regex). Opens a span until the next match.
523	    pub agent_pattern: String,
524	    /// One regex per known tool; each should name an `arg` (and may name a
525	    /// `name`) capture group.
526	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
527	    pub tool_patterns: Vec<String>,
528	    /// Optional final catchall for unknowable user-installed tools.
529	    #[serde(default, skip_serializing_if = "Option::is_none")]
530	    pub catchall_pattern: Option<String>,
531	    /// Window depth override (last N user turns). `None` ⇒ spt-core default (~3).
532	    #[serde(default, skip_serializing_if = "Option::is_none")]
533	    pub window_turns: Option<usize>,
534	    /// Opt-in Path-B persistence (ADR-0008 "Option (C)"): append completed turns
535	    /// to the perch's native history log as a coarse activity record.
536	    #[serde(default, skip_serializing_if = "is_false")]
537	    pub persist: bool,
538	}
539

impl OverCap {
    /// Whether this is the default (`reject`) — for `skip_serializing_if`.
    pub fn is_default(&self) -> bool {
        matches!(self, OverCap::Reject)
    }
}

/// `[pty_digest]` — the adapter-supplied patterns for the live activity buffer
/// (ADR-0008). `input_pattern`/`agent_pattern` are the turn-boundary markers;
/// `tool_patterns` is one regex per known tool and `catchall_pattern` an optional
/// final catchall for user-installed tools (each naming `name`/`arg` capture
/// groups). spt-core compiles + runs these over the broker PTY stream — it never
/// parses harness transcripts (the history-subsystem no-built-in-parser rule the
/// digest deliberately honors). The patterns are **opaque to spt-core**, exactly
/// like the `[session.*]` command templates.
///
/// `window_turns` is the only presentation override exposed here; the remaining
/// presentation knobs (arg truncation, ANSI stripping, scan-buffer cap) stay
/// spt-core-owned defaults. `persist` opts this endpoint into the coarse Path-B
/// activity log (ADR-0008 "Option (C)") — off by default.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct PtyDigest {
    /// User-input boundary marker (regex). Opens a span until the next match.
    pub input_pattern: String,
    /// Agent-output boundary marker (regex). Opens a span until the next match.
    pub agent_pattern: String,
    /// One regex per known tool; each should name an `arg` (and may name a
    /// `name`) capture group.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub tool_patterns: Vec<String>,
    /// Optional final catchall for unknowable user-installed tools.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub catchall_pattern: Option<String>,
    /// Window depth override (last N user turns). `None` ⇒ spt-core default (~3).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub window_turns: Option<usize>,
    /// Opt-in Path-B persistence (ADR-0008 "Option (C)"): append completed turns
    /// to the perch's native history log as a coarse activity record.
    #[serde(default, skip_serializing_if = "is_false")]
    pub persist: bool,
}


539
540	/// `skip_serializing_if` helper for a `bool` defaulting to `false`.
541	fn is_false(b: &bool) -> bool {
542	    !*b
543	}
544
545	/// Parse/validation failure — carries a human-readable message for the adapter
546	/// author rather than aborting.
547	#[derive(Debug)]
548	pub enum ManifestError {
549	    /// TOML deserialization failed (syntax or an unknown enum variant).
550	    Parse(toml::de::Error),

/// `skip_serializing_if` helper for a `bool` defaulting to `false`.
fn is_false(b: &bool) -> bool {
    !*b
}

/// Parse/validation failure — carries a human-readable message for the adapter
/// author rather than aborting.
#[derive(Debug)]
pub enum ManifestError {
    /// TOML deserialization failed (syntax or an unknown enum variant).
    Parse(toml::de::Error),


536:    #[serde(default, skip_serializing_if = "is_false")]
541:fn is_false(b: &bool) -> bool {


`is_false` is used only by `PtyDigest.persist`. Delete the struct doc + struct + the now-orphaned helper:


/// `[pty_digest]` — the adapter-supplied patterns for the live activity buffer
/// (ADR-0008). `input_pattern`/`agent_pattern` are the turn-boundary markers;
/// `tool_patterns` is one regex per known tool and `catchall_pattern` an optional
/// final catchall for user-installed tools (each naming `name`/`arg` capture
/// groups). spt-core compiles + runs these over the broker PTY stream — it never
/// parses harness transcripts (the history-subsystem no-built-in-parser rule the
/// digest deliberately honors). The patterns are **opaque to spt-core**, exactly
/// like the `[session.*]` command templates.
///
/// `window_turns` is the only presentation override exposed here; the remaining
/// presentation knobs (arg truncation, ANSI stripping, scan-buffer cap) stay
/// spt-core-owned defaults. `persist` opts this endpoint into the coarse Path-B
/// activity log (ADR-0008 "Option (C)") — off by default.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct PtyDigest {
    /// User-input boundary marker (regex). Opens a span until the next match.
    pub input_pattern: String,
    /// Agent-output boundary marker (regex). Opens a span until the next match.
    pub agent_pattern: String,
    /// One regex per known tool; each should name an `arg` (and may name a
    /// `name`) capture group.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub tool_patterns: Vec<String>,
    /// Optional final catchall for unknowable user-installed tools.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub catchall_pattern: Option<String>,
    /// Window depth override (last N user turns). `None` ⇒ spt-core default (~3).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub window_turns: Option<usize>,
    /// Opt-in Path-B persistence (ADR-0008 "Option (C)"): append completed turns
    /// to the perch's native history log as a coarse activity record.
    #[serde(default, skip_serializing_if = "is_false")]
    pub persist: bool,
}

/// `skip_serializing_if` helper for a `bool` defaulting to `false`.
fn is_false(b: &bool) -> bool {
    !*b
}

/// Parse/validation failure — carries a human-readable message for the adapter

/// Parse/validation failure — carries a human-readable message for the adapter


/// `[pty_digest]` — the adapter-supplied patterns for the live activity buffer
/// (ADR-0008). `input_pattern`/`agent_pattern` are the turn-boundary markers;
/// `tool_patterns` is one regex per known tool and `catchall_pattern` an optional
/// final catchall for user-installed tools (each naming `name`/`arg` capture
/// groups). spt-core compiles + runs these over the broker PTY stream — it never
/// parses harness transcripts (the history-subsystem no-built-in-parser rule the
/// digest deliberately honors). The patterns are **opaque to spt-core**, exactly
/// like the `[session.*]` command templates.
///
/// `window_turns` is the only presentation override exposed here; the remaining
/// presentation knobs (arg truncation, ANSI stripping, scan-buffer cap) stay
/// spt-core-owned defaults. `persist` opts this endpoint into the coarse Path-B
/// activity log (ADR-0008 "Option (C)") — off by default.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct PtyDigest {
    /// User-input boundary marker (regex). Opens a span until the next match.
    pub input_pattern: String,
    /// Agent-output boundary marker (regex). Opens a span until the next match.
    pub agent_pattern: String,
    /// One regex per known tool; each should name an `arg` (and may name a
    /// `name`) capture group.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub tool_patterns: Vec<String>,
    /// Optional final catchall for unknowable user-installed tools.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub catchall_pattern: Option<String>,
    /// Window depth override (last N user turns). `None` ⇒ spt-core default (~3).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub window_turns: Option<usize>,
    /// Opt-in Path-B persistence (ADR-0008 "Option (C)"): append completed turns
    /// to the perch's native history log as a coarse activity record.
    #[serde(default, skip_serializing_if = "is_false")]
    pub persist: bool,
}

/// `skip_serializing_if` helper for a `bool` defaulting to `false`.
fn is_false(b: &bool) -> bool {
    !*b
}

/// Parse/validation failure — carries a human-readable message for the adapter

-/// `[pty_digest]` — the adapter-supplied patterns for the live activity buffer

-/// (ADR-0008). `input_pattern`/`agent_pattern` are the turn-boundary markers;

-/// `tool_patterns` is one regex per known tool and `catchall_pattern` an optional

-/// final catchall for user-installed tools (each naming `name`/`arg` capture

-/// groups). spt-core compiles + runs these over the broker PTY stream — it never

-/// parses harness transcripts (the history-subsystem no-built-in-parser rule the

-/// digest deliberately honors). The patterns are **opaque to spt-core**, exactly

-/// like the `[session.*]` command templates.

-/// `window_turns` is the only presentation override exposed here; the remaining

-/// presentation knobs (arg truncation, ANSI stripping, scan-buffer cap) stay

-/// spt-core-owned defaults. `persist` opts this endpoint into the coarse Path-B

-/// activity log (ADR-0008 "Option (C)") — off by default.

-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]

-    /// User-input boundary marker (regex). Opens a span until the next match.

-    /// Agent-output boundary marker (regex). Opens a span until the next match.

-    /// One regex per known tool; each should name an `arg` (and may name a

-    #[serde(default, skip_serializing_if = "Vec::is_empty")]

-    /// Optional final catchall for unknowable user-installed tools.

-    #[serde(default, skip_serializing_if = "Option::is_none")]

-    pub catchall_pattern: Option<String>,

-    /// Window depth override (last N user turns). `None` ⇒ spt-core default (~3).

-    /// Opt-in Path-B persistence (ADR-0008 "Option (C)"): append completed turns

-    /// to the perch's native history log as a coarse activity record.

-    #[serde(default, skip_serializing_if = "is_false")]

-/// `skip_serializing_if` helper for a `bool` defaulting to `false`.


Now the validate block (~695-704) and the tests. Read the validate region:


648	        // pty_digest: the two boundary markers are load-bearing — a digest with
649	        // no input/agent boundary degenerates to one undifferentiated span, so
650	        // require them non-empty when the section is declared. The tool patterns
651	        // + catchall are optional (an adapter may advertise no tools).
652	        // [impl->REQ-TERM-4]
653	        if let Some(d) = &self.pty_digest {
654	            if d.input_pattern.trim().is_empty() || d.agent_pattern.trim().is_empty() {
655	                return err(
656	                    "[pty_digest] requires non-empty `input_pattern` and `agent_pattern`".into(),
657	                );
658	            }
659	        }
660
661	        // update avenue ↔ required fields.
662	        if let Some(u) = &self.update {
663	            match u.avenue {
664	                UpdateAvenue::Delegated if u.command.is_none() => {
665	                    return err("[update] avenue = \"delegated\" requires `command`".into());
666	                }
667	                UpdateAvenue::FilePull if u.repo.is_none() => {
668	                    return err("[update] avenue = \"file_pull\" requires `repo`".into());
669	                }

        // pty_digest: the two boundary markers are load-bearing — a digest with
        // no input/agent boundary degenerates to one undifferentiated span, so
        // require them non-empty when the section is declared. The tool patterns
        // + catchall are optional (an adapter may advertise no tools).
        // [impl->REQ-TERM-4]
        if let Some(d) = &self.pty_digest {
            if d.input_pattern.trim().is_empty() || d.agent_pattern.trim().is_empty() {
                return err(
                    "[pty_digest] requires non-empty `input_pattern` and `agent_pattern`".into(),
                );
            }
        }

        // update avenue ↔ required fields.
        if let Some(u) = &self.update {
            match u.avenue {
                UpdateAvenue::Delegated if u.command.is_none() => {
                    return err("[update] avenue = \"delegated\" requires `command`".into());
                }
                UpdateAvenue::FilePull if u.repo.is_none() => {
                    return err("[update] avenue = \"file_pull\" requires `repo`".into());
                }


        // pty_digest: the two boundary markers are load-bearing — a digest with
        // no input/agent boundary degenerates to one undifferentiated span, so
        // require them non-empty when the section is declared. The tool patterns
        // + catchall are optional (an adapter may advertise no tools).
        // [impl->REQ-TERM-4]
        if let Some(d) = &self.pty_digest {
            if d.input_pattern.trim().is_empty() || d.agent_pattern.trim().is_empty() {
                return err(
                    "[pty_digest] requires non-empty `input_pattern` and `agent_pattern`".into(),
                );
            }
        }

        // update avenue ↔ required fields.


        // pty_digest: the two boundary markers are load-bearing — a digest with
        // no input/agent boundary degenerates to one undifferentiated span, so
        // require them non-empty when the section is declared. The tool patterns
        // + catchall are optional (an adapter may advertise no tools).
        // [impl->REQ-TERM-4]
        if let Some(d) = &self.pty_digest {
            if d.input_pattern.trim().is_empty() || d.agent_pattern.trim().is_empty() {
                return err(
                    "[pty_digest] requires non-empty `input_pattern` and `agent_pattern`".into(),
                );
            }
        }

        // update avenue ↔ required fields.

-        // pty_digest: the two boundary markers are load-bearing — a digest with

-        // no input/agent boundary degenerates to one undifferentiated span, so

-        // require them non-empty when the section is declared. The tool patterns

-        // + catchall are optional (an adapter may advertise no tools).

-        if let Some(d) = &self.pty_digest {

-            if d.input_pattern.trim().is_empty() || d.agent_pattern.trim().is_empty() {

-                    "[pty_digest] requires non-empty `input_pattern` and `agent_pattern`".into(),


Now the tests. Read the test region around the `[pty_digest]` TOML fixture + the three tests:


736
737	    const SHELL_FIXTURE: &str = r#"
738	[adapter]
739	name = "GameRobot"
740	kind = "shell"
741	version = "1.0.0"
742	min_spt_core_version = "1.0.0"
743
744	[shell]
745	spawn = "gamerobot-shell --link {link_token}"
746	ephemeral = false
747	broadcast = "subnet"
748	command_receipt = "http"
749	persistent = true
750	can_shutdown = false
751
752	[shell.capabilities]
753	move = { args = ["direction", "distance"] }
754	screenshot = {}
755
756	[shell.sensory]
757	types = ["image", "sound", "event"]
758	"#;
759
760	    #[test] // [unit->REQ-MANIFEST-1]
761	    fn harness_fixture_parses_all_sections() {
762	        let m = Manifest::from_toml_str(HARNESS_FIXTURE).expect("parse");
763	        assert_eq!(m.adapter.name, "claude-spt");
764	        assert_eq!(m.adapter.kind, AdapterKind::Harness);
765	        assert_eq!(m.adapter.hostable_types.len(), 4);
766	        // hooks
767	        assert!(m.hooks["SessionStart"].can_inject);
768	        assert!(!m.hooks["Stop"].can_inject);
769	        assert_eq!(
770	            m.hooks["SessionStart"].reads,
771	            ["session_id", "source", "agent_type"]
772	        );
773	        // session: dirs + one role
774	        assert_eq!(m.session.commune_dir.as_deref(), Some(".claude"));
775	        let role = m.session.psyche_init.as_ref().unwrap();
776	        assert!(role.detach);
777	        assert_eq!(role.env_remove, ["OWL_SESSION_ID"]);
778	        assert_eq!(
779	            role.recursion_guard_env.as_deref(),
780	            Some("SPT_ECHO_COMMUNE")
781	        );
782	        assert_eq!(m.session.roles().len(), 1);
783	        // env
784	        assert_eq!(m.env["OWL_SESSION_ID"].direction, EnvDirection::Inject);
785	        // history / inject / identity / update enums
786	        assert_eq!(m.history.unwrap().strategy, HistoryStrategy::Fetcher);
787	        let inject = m.inject.unwrap();
788	        assert_eq!(inject.idle, [InjectMethod::Pty, InjectMethod::Hook]);
789	        assert_eq!(
790	            m.identity.unwrap().session_id_source,
791	            SessionIdSource::PostSpawn
792	        );
793	        assert_eq!(m.update.unwrap().avenue, UpdateAvenue::Delegated);
794	        // pty_digest seam (ADR-0008)
795	        let d = m.pty_digest.unwrap();
796	        assert_eq!(
797	            d.input_pattern, "\\nUSER> ",
798	            "regex source: literal backslash-n, not a newline"
799	        );
800	        assert_eq!(d.tool_patterns.len(), 1);
801	        assert!(d.catchall_pattern.is_some());
802	        assert_eq!(d.window_turns, Some(5));
803	        assert!(d.persist);
804	    }
805
806	    // [unit->REQ-UPD-5] adapter content signing: a `file_pull` avenue must declare
807	    // both `repo` and a `signing_key` (so a pulled payload is verifiable); a
808	    // `delegated` avenue carries the `self_verifies` attest instead. Both shapes
809	    // round-trip through TOML.
810	    #[test]
811	    fn update_avenue_content_signing_fields() {
812	        // file_pull without signing_key → rejected.
813	        let no_key = "[adapter]\nname=\"a\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
814	                      [update]\navenue=\"file_pull\"\nrepo=\"https://x/y\"\n";
815	        let err = Manifest::from_toml_str(no_key).unwrap_err().to_string();
816	        assert!(
817	            err.contains("signing_key"),
818	            "file_pull must require a content key: {err}"
819	        );
820
821	        // file_pull with repo + signing_key → ok, fields preserved + round-trip.
822	        let ok = "[adapter]\nname=\"a\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
823	                  [update]\navenue=\"file_pull\"\nrepo=\"https://x/y\"\nsigning_key=\"deadbeef\"\n";
824	        let m = Manifest::from_toml_str(ok).expect("file_pull with key parses");
825	        let u = m.update.clone().unwrap();

    const SHELL_FIXTURE: &str = r#"
[adapter]
name = "GameRobot"
kind = "shell"
version = "1.0.0"
min_spt_core_version = "1.0.0"

[shell]
spawn = "gamerobot-shell --link {link_token}"
ephemeral = false
broadcast = "subnet"
command_receipt = "http"
persistent = true
can_shutdown = false

[shell.capabilities]
move = { args = ["direction", "distance"] }
screenshot = {}

[shell.sensory]
types = ["image", "sound", "event"]
"#;

    #[test] // [unit->REQ-MANIFEST-1]
    fn harness_fixture_parses_all_sections() {
        let m = Manifest::from_toml_str(HARNESS_FIXTURE).expect("parse");
        assert_eq!(m.adapter.name, "claude-spt");
        assert_eq!(m.adapter.kind, AdapterKind::Harness);
        assert_eq!(m.adapter.hostable_types.len(), 4);
        // hooks
        assert!(m.hooks["SessionStart"].can_inject);
        assert!(!m.hooks["Stop"].can_inject);
        assert_eq!(
            m.hooks["SessionStart"].reads,
            ["session_id", "source", "agent_type"]
        );
        // session: dirs + one role
        assert_eq!(m.session.commune_dir.as_deref(), Some(".claude"));
        let role = m.session.psyche_init.as_ref().unwrap();
        assert!(role.detach);
        assert_eq!(role.env_remove, ["OWL_SESSION_ID"]);
        assert_eq!(
            role.recursion_guard_env.as_deref(),
            Some("SPT_ECHO_COMMUNE")
        );
        assert_eq!(m.session.roles().len(), 1);
        // env
        assert_eq!(m.env["OWL_SESSION_ID"].direction, EnvDirection::Inject);
        // history / inject / identity / update enums
        assert_eq!(m.history.unwrap().strategy, HistoryStrategy::Fetcher);
        let inject = m.inject.unwrap();
        assert_eq!(inject.idle, [InjectMethod::Pty, InjectMethod::Hook]);
        assert_eq!(
            m.identity.unwrap().session_id_source,
            SessionIdSource::PostSpawn
        );
        assert_eq!(m.update.unwrap().avenue, UpdateAvenue::Delegated);
        // pty_digest seam (ADR-0008)
        let d = m.pty_digest.unwrap();
        assert_eq!(
            d.input_pattern, "\\nUSER> ",
            "regex source: literal backslash-n, not a newline"
        );
        assert_eq!(d.tool_patterns.len(), 1);
        assert!(d.catchall_pattern.is_some());
        assert_eq!(d.window_turns, Some(5));
        assert!(d.persist);
    }

    // [unit->REQ-UPD-5] adapter content signing: a `file_pull` avenue must declare
    // both `repo` and a `signing_key` (so a pulled payload is verifiable); a
    // `delegated` avenue carries the `self_verifies` attest instead. Both shapes
    // round-trip through TOML.
    #[test]
    fn update_avenue_content_signing_fields() {
        // file_pull without signing_key → rejected.
        let no_key = "[adapter]\nname=\"a\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
                      [update]\navenue=\"file_pull\"\nrepo=\"https://x/y\"\n";
        let err = Manifest::from_toml_str(no_key).unwrap_err().to_string();
        assert!(
            err.contains("signing_key"),
            "file_pull must require a content key: {err}"
        );

        // file_pull with repo + signing_key → ok, fields preserved + round-trip.
        let ok = "[adapter]\nname=\"a\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
                  [update]\navenue=\"file_pull\"\nrepo=\"https://x/y\"\nsigning_key=\"deadbeef\"\n";
        let m = Manifest::from_toml_str(ok).expect("file_pull with key parses");
        let u = m.update.clone().unwrap();


The HARNESS_FIXTURE contains a `[pty_digest]` block + assertions, plus three dedicated tests. Find the fixture block:


770	            m.hooks["SessionStart"].reads,
771	            ["session_id", "source", "agent_type"]
772	        );
773	        // session: dirs + one role
774	        assert_eq!(m.session.commune_dir.as_deref(), Some(".claude"));
775	        let role = m.session.psyche_init.as_ref().unwrap();
776	        assert!(role.detach);
777	        assert_eq!(role.env_remove, ["OWL_SESSION_ID"]);
778	        assert_eq!(
779	            role.recursion_guard_env.as_deref(),
780	            Some("SPT_ECHO_COMMUNE")
781	        );
782	        assert_eq!(m.session.roles().len(), 1);
783	        // env
784	        assert_eq!(m.env["OWL_SESSION_ID"].direction, EnvDirection::Inject);
785	        // history / inject / identity / update enums
786	        assert_eq!(m.history.unwrap().strategy, HistoryStrategy::Fetcher);
787	        let inject = m.inject.unwrap();
788	        assert_eq!(inject.idle, [InjectMethod::Pty, InjectMethod::Hook]);
789	        assert_eq!(
790	            m.identity.unwrap().session_id_source,
791	            SessionIdSource::PostSpawn

            m.hooks["SessionStart"].reads,
            ["session_id", "source", "agent_type"]
        );
        // session: dirs + one role
        assert_eq!(m.session.commune_dir.as_deref(), Some(".claude"));
        let role = m.session.psyche_init.as_ref().unwrap();
        assert!(role.detach);
        assert_eq!(role.env_remove, ["OWL_SESSION_ID"]);
        assert_eq!(
            role.recursion_guard_env.as_deref(),
            Some("SPT_ECHO_COMMUNE")
        );
        assert_eq!(m.session.roles().len(), 1);
        // env
        assert_eq!(m.env["OWL_SESSION_ID"].direction, EnvDirection::Inject);
        // history / inject / identity / update enums
        assert_eq!(m.history.unwrap().strategy, HistoryStrategy::Fetcher);
        let inject = m.inject.unwrap();
        assert_eq!(inject.idle, [InjectMethod::Pty, InjectMethod::Hook]);
        assert_eq!(
            m.identity.unwrap().session_id_source,
            SessionIdSource::PostSpawn


Line numbers shifted from my edits. Re-locate the remaining `pty_digest` references:


728:[pty_digest]
729:input_pattern = "\\nUSER> "
794:        // pty_digest seam (ADR-0008)
795:        let d = m.pty_digest.unwrap();
797:            d.input_pattern, "\\nUSER> ",
842:    #[test] // [unit->REQ-TERM-4] the pty_digest seam round-trips through TOML.
843:    fn pty_digest_round_trips() {
846:        assert_eq!(m.pty_digest, reparsed.pty_digest);
849:    #[test] // [unit->REQ-TERM-4] pty_digest defaults: tools empty, catchall none,
851:    fn pty_digest_minimal_defaults() {
854:             [pty_digest]\ninput_pattern=\"U> \"\nagent_pattern=\"A> \"\n",
857:        let d = m.pty_digest.unwrap();
864:    #[test] // [unit->REQ-TERM-4] a pty_digest missing a boundary marker is rejected
866:    fn pty_digest_requires_both_boundaries() {
868:                   [pty_digest]\ninput_pattern=\"U> \"\nagent_pattern=\"\"\n";
871:        assert!(e.to_string().contains("pty_digest"));


724	avenue = "delegated"
725	command = "claude plugin update spt"
726	version_check = true
727
728	[pty_digest]
729	input_pattern = "\\nUSER> "
730	agent_pattern = "\\nAGENT> "
731	tool_patterns = ["(?P<name>Write)\\((?P<arg>[^)]*)\\)"]
732	catchall_pattern = "(?P<name>[A-Z][a-zA-Z]+)\\((?P<arg>[^)]*)\\)"
733	window_turns = 5
734	persist = true
735	"#;

avenue = "delegated"
command = "claude plugin update spt"
version_check = true

[pty_digest]
input_pattern = "\\nUSER> "
agent_pattern = "\\nAGENT> "
tool_patterns = ["(?P<name>Write)\\((?P<arg>[^)]*)\\)"]
catchall_pattern = "(?P<name>[A-Z][a-zA-Z]+)\\((?P<arg>[^)]*)\\)"
window_turns = 5
persist = true
"#;


avenue = "delegated"
command = "claude plugin update spt"
version_check = true

[pty_digest]
input_pattern = "\\nUSER> "
agent_pattern = "\\nAGENT> "
tool_patterns = ["(?P<name>Write)\\((?P<arg>[^)]*)\\)"]
catchall_pattern = "(?P<name>[A-Z][a-zA-Z]+)\\((?P<arg>[^)]*)\\)"
window_turns = 5
persist = true
"#;

avenue = "delegated"
command = "claude plugin update spt"
version_check = true
"#;


avenue = "delegated"
command = "claude plugin update spt"
version_check = true

[pty_digest]
input_pattern = "\\nUSER> "
agent_pattern = "\\nAGENT> "
tool_patterns = ["(?P<name>Write)\\((?P<arg>[^)]*)\\)"]
catchall_pattern = "(?P<name>[A-Z][a-zA-Z]+)\\((?P<arg>[^)]*)\\)"
window_turns = 5
persist = true
"#;

avenue = "delegated"
command = "claude plugin update spt"
version_check = true
"#;


        assert_eq!(m.update.unwrap().avenue, UpdateAvenue::Delegated);
        // pty_digest seam (ADR-0008)
        let d = m.pty_digest.unwrap();
        assert_eq!(
            d.input_pattern, "\\nUSER> ",
            "regex source: literal backslash-n, not a newline"
        );
        assert_eq!(d.tool_patterns.len(), 1);
        assert!(d.catchall_pattern.is_some());
        assert_eq!(d.window_turns, Some(5));
        assert!(d.persist);
    }

        assert_eq!(m.update.unwrap().avenue, UpdateAvenue::Delegated);
    }


        assert_eq!(m.update.unwrap().avenue, UpdateAvenue::Delegated);
        // pty_digest seam (ADR-0008)
        let d = m.pty_digest.unwrap();
        assert_eq!(
            d.input_pattern, "\\nUSER> ",
            "regex source: literal backslash-n, not a newline"
        );
        assert_eq!(d.tool_patterns.len(), 1);
        assert!(d.catchall_pattern.is_some());
        assert_eq!(d.window_turns, Some(5));
        assert!(d.persist);
    }

        assert_eq!(m.update.unwrap().avenue, UpdateAvenue::Delegated);
    }

-            d.input_pattern, "\\nUSER> ",

-            "regex source: literal backslash-n, not a newline"

-        assert_eq!(d.tool_patterns.len(), 1);

-        assert!(d.catchall_pattern.is_some());

-        assert_eq!(d.window_turns, Some(5));

// [unit->REQ-UPD-5] adapter content signing: a `file_pull` avenue must declare


Now the three dedicated `pty_digest` tests. Read them:


828	        assert_eq!(m.pty_digest, reparsed.pty_digest);
829	    }
830
831	    #[test] // [unit->REQ-TERM-4] pty_digest defaults: tools empty, catchall none,
832	            // window default, persist off — a minimal digest declares only the boundaries.
833	    fn pty_digest_minimal_defaults() {
834	        let m = Manifest::from_toml_str(
835	            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
836	             [pty_digest]\ninput_pattern=\"U> \"\nagent_pattern=\"A> \"\n",
837	        )
838	        .expect("parse");
839	        let d = m.pty_digest.unwrap();
840	        assert!(d.tool_patterns.is_empty());
841	        assert!(d.catchall_pattern.is_none());
842	        assert_eq!(d.window_turns, None);
843	        assert!(!d.persist, "persist is opt-in (off by default)");
844	    }
845
846	    #[test] // [unit->REQ-TERM-4] a pty_digest missing a boundary marker is rejected
847	            // (an empty boundary degenerates the parse to one undifferentiated span).
848	    fn pty_digest_requires_both_boundaries() {
849	        let bad = "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
850	                   [pty_digest]\ninput_pattern=\"U> \"\nagent_pattern=\"\"\n";
851	        let e = Manifest::from_toml_str(bad).unwrap_err();
852	        assert!(matches!(e, ManifestError::Validation(_)));
853	        assert!(e.to_string().contains("pty_digest"));
854	    }
855
856	    #[test] // [unit->REQ-MANIFEST-1]
857	    fn shell_fixture_parses() {
858	        let m = Manifest::from_toml_str(SHELL_FIXTURE).expect("parse");
859	        assert_eq!(m.adapter.kind, AdapterKind::Shell);
860	        let shell = m.shell.as_ref().unwrap();
861	        assert_eq!(shell.broadcast, Some(Broadcast::Subnet));
862	        assert_eq!(shell.command_receipt, Some(CommandReceipt::Http));
863	        assert_eq!(shell.capabilities["move"].args, ["direction", "distance"]);
864	        assert!(shell.capabilities["screenshot"].args.is_empty());
865	        assert_eq!(shell.sensory.types, ["image", "sound", "event"]);
866	    }
867

        assert_eq!(m.pty_digest, reparsed.pty_digest);
    }

    #[test] // [unit->REQ-TERM-4] pty_digest defaults: tools empty, catchall none,
            // window default, persist off — a minimal digest declares only the boundaries.
    fn pty_digest_minimal_defaults() {
        let m = Manifest::from_toml_str(
            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
             [pty_digest]\ninput_pattern=\"U> \"\nagent_pattern=\"A> \"\n",
        )
        .expect("parse");
        let d = m.pty_digest.unwrap();
        assert!(d.tool_patterns.is_empty());
        assert!(d.catchall_pattern.is_none());
        assert_eq!(d.window_turns, None);
        assert!(!d.persist, "persist is opt-in (off by default)");
    }

    #[test] // [unit->REQ-TERM-4] a pty_digest missing a boundary marker is rejected
            // (an empty boundary degenerates the parse to one undifferentiated span).
    fn pty_digest_requires_both_boundaries() {
        let bad = "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
                   [pty_digest]\ninput_pattern=\"U> \"\nagent_pattern=\"\"\n";
        let e = Manifest::from_toml_str(bad).unwrap_err();
        assert!(matches!(e, ManifestError::Validation(_)));
        assert!(e.to_string().contains("pty_digest"));
    }

    #[test] // [unit->REQ-MANIFEST-1]
    fn shell_fixture_parses() {
        let m = Manifest::from_toml_str(SHELL_FIXTURE).expect("parse");
        assert_eq!(m.adapter.kind, AdapterKind::Shell);
        let shell = m.shell.as_ref().unwrap();
        assert_eq!(shell.broadcast, Some(Broadcast::Subnet));
        assert_eq!(shell.command_receipt, Some(CommandReceipt::Http));
        assert_eq!(shell.capabilities["move"].args, ["direction", "distance"]);
        assert!(shell.capabilities["screenshot"].args.is_empty());
        assert_eq!(shell.sensory.types, ["image", "sound", "event"]);
    }


822	    }
823
824	    #[test] // [unit->REQ-TERM-4] the pty_digest seam round-trips through TOML.
825	    fn pty_digest_round_trips() {
826	        let m = Manifest::from_toml_str(HARNESS_FIXTURE).expect("parse");
827	        let reparsed = Manifest::from_toml_str(&m.to_toml_string().unwrap()).expect("re-parse");
828	        assert_eq!(m.pty_digest, reparsed.pty_digest);
829	    }
830

    }

    #[test] // [unit->REQ-TERM-4] the pty_digest seam round-trips through TOML.
    fn pty_digest_round_trips() {
        let m = Manifest::from_toml_str(HARNESS_FIXTURE).expect("parse");
        let reparsed = Manifest::from_toml_str(&m.to_toml_string().unwrap()).expect("re-parse");
        assert_eq!(m.pty_digest, reparsed.pty_digest);
    }


    #[test] // [unit->REQ-TERM-4] the pty_digest seam round-trips through TOML.
    fn pty_digest_round_trips() {
        let m = Manifest::from_toml_str(HARNESS_FIXTURE).expect("parse");
        let reparsed = Manifest::from_toml_str(&m.to_toml_string().unwrap()).expect("re-parse");
        assert_eq!(m.pty_digest, reparsed.pty_digest);
    }

    #[test] // [unit->REQ-TERM-4] pty_digest defaults: tools empty, catchall none,
            // window default, persist off — a minimal digest declares only the boundaries.
    fn pty_digest_minimal_defaults() {
        let m = Manifest::from_toml_str(
            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
             [pty_digest]\ninput_pattern=\"U> \"\nagent_pattern=\"A> \"\n",
        )
        .expect("parse");
        let d = m.pty_digest.unwrap();
        assert!(d.tool_patterns.is_empty());
        assert!(d.catchall_pattern.is_none());
        assert_eq!(d.window_turns, None);
        assert!(!d.persist, "persist is opt-in (off by default)");
    }

    #[test] // [unit->REQ-TERM-4] a pty_digest missing a boundary marker is rejected
            // (an empty boundary degenerates the parse to one undifferentiated span).
    fn pty_digest_requires_both_boundaries() {
        let bad = "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
                   [pty_digest]\ninput_pattern=\"U> \"\nagent_pattern=\"\"\n";
        let e = Manifest::from_toml_str(bad).unwrap_err();
        assert!(matches!(e, ManifestError::Validation(_)));
        assert!(e.to_string().contains("pty_digest"));
    }

    #[test] // [unit->REQ-MANIFEST-1]
    fn shell_fixture_parses() {

    #[test] // [unit->REQ-MANIFEST-1]
    fn shell_fixture_parses() {


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

    #[test] // [unit->REQ-TERM-4] the pty_digest seam round-trips through TOML.
    fn pty_digest_round_trips() {
        let m = Manifest::from_toml_str(HARNESS_FIXTURE).expect("parse");
        let reparsed = Manifest::from_toml_str(&m.to_toml_string().unwrap()).expect("re-parse");
        assert_eq!(m.pty_digest, reparsed.pty_digest);
    }

    #[test] // [unit->REQ-TERM-4] pty_digest defaults: tools empty, catchall none,
            // window default, persist off — a minimal digest declares only the boundaries.
    fn pty_digest_minimal_defaults() {
        let m = Manifest::from_toml_str(
            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
             [pty_digest]\ninput_pattern=\"U> \"\nagent_pattern=\"A> \"\n",
        )
        .expect("parse");
        let d = m.pty_digest.unwrap();
        assert!(d.tool_patterns.is_empty());
        assert!(d.catchall_pattern.is_none());
        assert_eq!(d.window_turns, None);
        assert!(!d.persist, "persist is opt-in (off by default)");
    }

    #[test] // [unit->REQ-TERM-4] a pty_digest missing a boundary marker is rejected
            // (an empty boundary degenerates the parse to one undifferentiated span).
    fn pty_digest_requires_both_boundaries() {
        let bad = "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
                   [pty_digest]\ninput_pattern=\"U> \"\nagent_pattern=\"\"\n";
        let e = Manifest::from_toml_str(bad).unwrap_err();
        assert!(matches!(e, ManifestError::Validation(_)));
        assert!(e.to_string().contains("pty_digest"));
    }

    #[test] // [unit->REQ-MANIFEST-1]
    fn shell_fixture_parses() {

-    #[test] // [unit->REQ-TERM-4] the pty_digest seam round-trips through TOML.

-        let m = Manifest::from_toml_str(HARNESS_FIXTURE).expect("parse");

-        let reparsed = Manifest::from_toml_str(&m.to_toml_string().unwrap()).expect("re-parse");

-        assert_eq!(m.pty_digest, reparsed.pty_digest);

-    #[test] // [unit->REQ-TERM-4] pty_digest defaults: tools empty, catchall none,

-            // window default, persist off — a minimal digest declares only the boundaries.

-        let m = Manifest::from_toml_str(

-            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\

-             [pty_digest]\ninput_pattern=\"U> \"\nagent_pattern=\"A> \"\n",

-        assert!(d.tool_patterns.is_empty());

-        assert!(d.catchall_pattern.is_none());

-        assert_eq!(d.window_turns, None);

-        assert!(!d.persist, "persist is opt-in (off by default)");

-    #[test] // [unit->REQ-TERM-4] a pty_digest missing a boundary marker is rejected

-            // (an empty boundary degenerates the parse to one undifferentiated span).

-    fn pty_digest_requires_both_boundaries() {

-        let bad = "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\

-                   [pty_digest]\ninput_pattern=\"U> \"\nagent_pattern=\"\"\n";

-        let e = Manifest::from_toml_str(bad).unwrap_err();

-        assert!(matches!(e, ManifestError::Validation(_)));

-        assert!(e.to_string().contains("pty_digest"));

let m = Manifest::from_toml_str(SHELL_FIXTURE).expect("parse");


Check the schema JSON + how it's generated:


crates\xtask\src\main.rs:9://!   llms-full.txt / manifest.schema.json / install scripts at site root +
crates\xtask\src\main.rs:202:            "llms-full.txt" | "manifest.schema.json" => true,
crates\xtask\src\main.rs:264:/// schema, install scripts) + rustdoc for the public SDK crates.
crates\xtask\src\main.rs:288:            "crates/spt-runtime/manifest.schema.json",
crates\xtask\src\main.rs:289:            "manifest.schema.json",


79:    "pty_digest": {
80:      "description": "`[pty_digest]` — the live-activity-buffer pattern seam (ADR-0008). The\nadapter supplies the boundary + tool patterns; spt-core only *runs* them\nover the broker PTY byte stream (the no-built-in-parser rule — identical to\nopaque command templates). Absent ⇒ the digest is unavailable for this\nadapter.",
83:          "$ref": "#/$defs/PtyDigest"
690:    "PtyDigest": {
691:[Omitted long matching line]


`manifest.schema.json` is xtask-generated (CI-gated against drift). Regenerate rather than hand-edit. Find the xtask command:


Grep

Subcommand|enum Cmd|gen|check|fn main|=>.*gen|Command::|match cmd|args\(\)|\"gen\"|\"check\"|\"docs\"


1://! spt-core docs/site generation (M6-D5) — the anti-drift plumbing behind
2://! DOCS-STRATEGY's "generation is part of the build" commitment (REQ-DOCS-5):
4://! - `gen` — regenerate the checked-in generated docs (CLI reference).
5://! - `check` — drift gate: regenerate + diff the CLI reference, and
29:fn main() {
30:    let args: Vec<String> = std::env::args().skip(1).collect();
32:    match cmd {
33:        "gen" => gen(false),
34:        "check" => {
35:            gen(true);
36:            check_llms_links();
37:            eprintln!("xtask check: OK");
43:        "release-keygen" => release_keygen(&args[1..]),
47:        "debug-keygen" => debug_keygen(&args[1..]),
53:                "usage: xtask <gen|check|site <outdir>|release-keygen <key-id>|release-sign …|release-publish …|debug-keygen <key-id>|debug-pin …|debug-rollout …|debug-converge --version <u64> …>"
63:/// `spt` name (Windows prints `spt.exe`, which would make the generated
66:    let mut cmd = Command::new(bin);
75:/// renders two grouped blocks (`User commands:` / `Agent commands:`).
81:        if heading == "Commands:" || heading == "User commands:" || heading == "Agent commands:" {
110:/// The generated CLI reference: every command + nested subcommand's help,
118:         (`cargo run -p xtask -- gen`) and drift-gated in CI — this page \
150:    let status = Command::new("cargo")
160:fn gen(check: bool) {
163:    let generated = cli_reference(&bin);
165:    if check {
167:            .expect("checked-in cli/reference.md (run `cargo run -p xtask -- gen`)")
169:        if on_disk != generated {
171:                "xtask check: docs-site/src/cli/reference.md drifted from the \
172:                 binary's --help — run `cargo run -p xtask -- gen` and commit"
177:        std::fs::write(&path, &generated).expect("write cli reference");

[Showing results with pagination = limit: 30]


crates\xtask\src\main.rs:9://!   llms-full.txt / manifest.schema.json / install scripts at site root +
crates\xtask\src\main.rs:202:            "llms-full.txt" | "manifest.schema.json" => true,
crates\xtask\src\main.rs:288:            "crates/spt-runtime/manifest.schema.json",
crates\xtask\src\main.rs:289:            "manifest.schema.json",
crates\spt-runtime\src\manifest.rs:20:use schemars::JsonSchema;
crates\spt-runtime\src\manifest.rs:61:    #[schemars(with = "BTreeMap<String, serde_json::Value>")]
crates\spt-runtime\src\manifest.rs:71:    #[schemars(with = "BTreeMap<String, serde_json::Value>")]
crates\spt-runtime\src\manifest.rs:541:/// (`crates/spt-runtime/manifest.schema.json`) is the site/release asset;
crates\spt-runtime\src\manifest.rs:552:    let schema = schemars::schema_for!(Manifest);
crates\spt-runtime\src\manifest.rs:557:        "https://sabermage.github.io/spt-releases/manifest.schema.json".into(),
crates\spt-runtime\src\manifest.rs:1000:            "https://sabermage.github.io/spt-releases/manifest.schema.json"
crates\spt-runtime\src\manifest.rs:1024:    /// The drift gate (M6-D3): the checked-in `manifest.schema.json` — the
crates\spt-runtime\src\manifest.rs:1033:        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("manifest.schema.json");
crates\spt-runtime\src\manifest.rs:1035:            std::fs::write(&path, &want).expect("bless schema");
crates\spt-runtime\src\manifest.rs:1039:            "checked-in manifest.schema.json (regenerate: SPT_BLESS=1 cargo test -p spt-runtime)",
crates\spt-runtime\src\manifest.rs:1044:            "manifest.schema.json drifted from the derives — regenerate with \
crates\spt-runtime\manifest.schema.json:761:  "$id": "https://sabermage.github.io/spt-releases/manifest.schema.json"
crates\spt-runtime\Cargo.toml:17:schemars = "1"


The schema is blessed via `SPT_BLESS=1 cargo test -p spt-runtime`. Regenerate it (PtyDigest derive is gone):


Bash

cd C:/Users/decid/Documents/projects/spt-core && SPT_BLESS=1 cargo test -p spt-runtime 2>&1 | grep -E "test result|error\[|panicked|FAILED|bless" | head -20

Bless the manifest schema + run spt-runtime tests


error[E0432]: unresolved import `manifest::PtyDigest`


A lingering re-export. Find it:


crates\spt-daemon\Cargo.toml:58:# (PtyDigest) and the perch-path/history resolution it asserts persistence against.
crates\spt-runtime\src\lib.rs:25:pub use manifest::{Manifest, ManifestError, PtyDigest};
crates\spt-runtime\manifest.schema.json:83:          "$ref": "#/$defs/PtyDigest"
crates\spt-runtime\manifest.schema.json:690:    "PtyDigest": {


Edit

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

pub use manifest::{Manifest, ManifestError};


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

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


23	pub mod runtime;
24
25	pub use manifest::{Manifest, ManifestError, PtyDigest};
26	pub use profile::{merge_leaf_replace, split_option};
27	pub use runtime::{

pub mod runtime;

pub use manifest::{Manifest, ManifestError, PtyDigest};
pub use profile::{merge_leaf_replace, split_option};
pub use runtime::{


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

pub use manifest::{Manifest, ManifestError};


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 profile;
pub mod registry;
pub mod runtime;

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

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

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


Re-bless the schema:


Bash

cd C:/Users/decid/Documents/projects/spt-core && SPT_BLESS=1 cargo test -p spt-runtime 2>&1 | grep -E "test result|error\[|panicked|FAILED" | head -20

Bless schema + spt-runtime tests


test result: ok. 41 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.35s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s


Schema blessed, spt-runtime green. Verify schema clean + fix the stale daemon Cargo comment + migrate the history fixture:


No matches found

Found 0 total occurrences across 0 files.


54	[dev-dependencies]
55	tempfile = "3"
56	# Integration tests are separate crates and do not inherit the lib's deps, so the
57	# B8 digest int-test re-declares the two it touches directly: the manifest seam
58	# (PtyDigest) and the perch-path/history resolution it asserts persistence against.
59	spt-runtime = { path = "../spt-runtime" }
60	spt-store = { path = "../spt-store" }
61	# The D4a net-broker int-test constructs identities + relay policies directly.

[dev-dependencies]
tempfile = "3"
# Integration tests are separate crates and do not inherit the lib's deps, so the
# B8 digest int-test re-declares the two it touches directly: the manifest seam
# (PtyDigest) and the perch-path/history resolution it asserts persistence against.
spt-runtime = { path = "../spt-runtime" }
spt-store = { path = "../spt-store" }
# The D4a net-broker int-test constructs identities + relay policies directly.


Edit

# Integration tests are separate crates and do not inherit the lib's deps, so the
# B8 digest int-test re-declares the two it touches directly: the manifest seam
# (PtyDigest) and the perch-path/history resolution it asserts persistence against.
spt-runtime = { path = "../spt-runtime" }
spt-store = { path = "../spt-store" }

# Integration tests are separate crates and do not inherit the lib's deps, so the
# digest int-test re-declares the two it touches directly: the manifest registry
# (the mock adapter's `[history]` seam) and the perch-path/info resolution the
# on-demand projection reads.
spt-runtime = { path = "../spt-runtime" }
spt-store = { path = "../spt-store" }


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

# Integration tests are separate crates and do not inherit the lib's deps, so the
# digest int-test re-declares the two it touches directly: the manifest registry
# (the mock adapter's `[history]` seam) and the perch-path/info resolution the
# on-demand projection reads.
spt-runtime = { path = "../spt-runtime" }
spt-store = { path = "../spt-store" }

[package]
name = "spt-daemon"
version.workspace = true
edition = "2021"
publish = false
description = "spt-core broker/brain daemon (M3b): the per-machine supervisor process. A stable broker kernel holds the un-transferable resources (PTY masters from spt-term, harness children, sockets) while a restartable brain holds all logic; a versioned local IPC lets a newer brain talk to an older broker so a self-update swaps the brain with zero endpoint interruption (ADR-0004). Internal supervisor (NOT public SDK, R-ARCH-2) — the `spt` binary is a thin CLI over it. B0 lands the crate + the versioned local-IPC transport."

[dependencies]
# Layer-below set (R-ARCH-1 acyclic): …→spt-live→spt-daemon→spt. The broker
# re-hosts spt-term PTYs + the spt-live lifecycle seams; these edges fix the
# crate's position in the workspace layering. Tasks B1–B8 consume them.
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
spt-runtime = { path = "../spt-runtime" }
spt-term = { path = "../spt-term" }
spt-live = { path = "../spt-live" }
# D4 (ADR-0004 §B): the broker owns the live iroh endpoint + QUIC connections so
# they survive a brain restart. spt-net provides the *mechanism* (NetEndpoint);
# the broker hosts the *process* (nethost::NetHost). Default features keep the
# `net` transport on — the daemon is the reference consumer.
spt-net = { path = "../spt-net" }
# The broker is otherwise sync/thread-based; iroh requires tokio, so the broker
# hosts a dedicated runtime on named worker threads (QUIC objects never leave it).
tokio = { version = "1", features = ["rt-multi-thread"] }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
# PTY output is arbitrary bytes; the forward-compat JSON envelope carries it as
# a base64 string in the payload rather than forking the codec for binary.
base64 = "0.22"
# Cross-platform local IPC: named pipe (Windows) / Unix-domain socket (unix)
# behind one API — the OS-split substrate the DaemonTransport trait wraps,
# mirroring how spt-term abstracts ConPTY vs forkpty behind SessionSurface.
interprocess = "2"
# Self-update release signing (C1, REQ-UPD-2): pure-Rust Ed25519 — no C/asm
# build deps, clean cross-compile on win+linux (clean-room posture, ADR-0004 §D).
# `rand_core` lets a release-tooling caller mint a keypair; tests use the
# deterministic `from_bytes` ctor (no RNG).
ed25519-dalek = "2"
# SHA-256 binds the signed release metadata to the actual binary bytes
# (artifact digest) so the signature covers the binary, not just the manifest.
sha2 = "0.10"
# Per-frame link auth on the same-node shell channels (CONTEXT local-link
# authentication): HMAC-SHA256 over the token-derived link key (M5-D3b).
hmac = "0.12"

# De-elevated daemon spawn (KNOWN-HAZARDS 5.7, REQ-HAZARD-ELEVATED-DAEMON-SPAWN):
# the sudo-invoker drop (geteuid/setuid/setgroups/getpwuid_r). Matches the
# workspace's existing libc usage (spt, spt-msg, spt-store); the Windows half
# uses raw advapi32 externs (the daemon.rs detached_no_inherit posture).
[target.'cfg(unix)'.dependencies]
libc = "0.2"

[dev-dependencies]
tempfile = "3"
# Integration tests are separate crates and do not inherit the lib's deps, so the
# B8 digest int-test re-declares the two it touches directly: the manifest seam
# (PtyDigest) and the perch-path/history resolution it asserts persistence against.
spt-runtime = { path = "../spt-runtime" }
spt-store = { path = "../spt-store" }
# The D4a net-broker int-test constructs identities + relay policies directly.
spt-proto = { path = "../spt-proto" }
spt-net = { path = "../spt-net" }
# The D5a wanmsg int-test asserts spool formatting through the spt-msg surface.
spt-msg = { path = "../spt-msg" }

-# B8 digest int-test re-declares the two it touches directly: the manifest seam

-# (PtyDigest) and the perch-path/history resolution it asserts persistence against.

+# digest int-test re-declares the two it touches directly: the manifest registry

+# (the mock adapter's `[history]` seam) and the perch-path/info resolution the


Migrate the stray native fixture to the digest-record contract shape (cosmetic — REQ-SEAM-HISTORY verbatim-carry unchanged):


244	    #[test] // [unit->REQ-SEAM-HISTORY] native Path-B store: read the perch's history.log.
245	    fn native_reads_logged_records() {
246	        with_home(|_| {
247	            let perch = resolve_perch_path("alice", ParentHint::Infer);
248	            std::fs::create_dir_all(&perch).unwrap();
249	            std::fs::write(perch.join("history.log"), "{\"r\":\"u\"}\n{\"r\":\"a\"}\n").unwrap();
250	            let h = History {
251	                strategy: HistoryStrategy::Native,
252	                fetcher: None,
253	                locate_template: None,
254	                normalize_command: None,
255	            };
256	            let recs = fetch_history(
257	                &h,
258	                &no_keys(),
259	                "alice",
260	                ParentHint::Infer,
261	                Duration::from_secs(5),
262	            )
263	            .expect("fetch");
264	            assert_eq!(recs.len(), 2);
265	            assert_eq!(recs[0].raw, "{\"r\":\"u\"}");
266	        });
267	    }

    #[test] // [unit->REQ-SEAM-HISTORY] native Path-B store: read the perch's history.log.
    fn native_reads_logged_records() {
        with_home(|_| {
            let perch = resolve_perch_path("alice", ParentHint::Infer);
            std::fs::create_dir_all(&perch).unwrap();
            std::fs::write(perch.join("history.log"), "{\"r\":\"u\"}\n{\"r\":\"a\"}\n").unwrap();
            let h = History {
                strategy: HistoryStrategy::Native,
                fetcher: None,
                locate_template: None,
                normalize_command: None,
            };
            let recs = fetch_history(
                &h,
                &no_keys(),
                "alice",
                ParentHint::Infer,
                Duration::from_secs(5),
            )
            .expect("fetch");
            assert_eq!(recs.len(), 2);
            assert_eq!(recs[0].raw, "{\"r\":\"u\"}");
        });
    }


    #[test] // [unit->REQ-SEAM-HISTORY] native Path-B store: read the perch's history.log.
    fn native_reads_logged_records() {
        with_home(|_| {
            let perch = resolve_perch_path("alice", ParentHint::Infer);
            std::fs::create_dir_all(&perch).unwrap();
            std::fs::write(perch.join("history.log"), "{\"r\":\"u\"}\n{\"r\":\"a\"}\n").unwrap();
            let h = History {
                strategy: HistoryStrategy::Native,
                fetcher: None,
                locate_template: None,
                normalize_command: None,
            };
            let recs = fetch_history(
                &h,
                &no_keys(),
                "alice",
                ParentHint::Infer,
                Duration::from_secs(5),
            )
            .expect("fetch");
            assert_eq!(recs.len(), 2);
            assert_eq!(recs[0].raw, "{\"r\":\"u\"}");
        });
    }

    #[test] // [unit->REQ-SEAM-HISTORY] native Path-B store: read the perch's
            // history.log. The lines stay OPAQUE to the history subsystem (carried
            // verbatim); the sample shape is the published digest-record contract
            // (one schema across the tree — no second discriminator).
    fn native_reads_logged_records() {
        with_home(|_| {
            let perch = resolve_perch_path("alice", ParentHint::Infer);
            std::fs::create_dir_all(&perch).unwrap();
            std::fs::write(
                perch.join("history.log"),
                "{\"role\":\"input\",\"text\":\"hi\"}\n{\"role\":\"agent\",\"text\":\"yo\"}\n",
            )
            .unwrap();
            let h = History {
                strategy: HistoryStrategy::Native,
                fetcher: None,
                locate_template: None,
                normalize_command: None,
            };
            let recs = fetch_history(
                &h,
                &no_keys(),
                "alice",
                ParentHint::Infer,
                Duration::from_secs(5),
            )
            .expect("fetch");
            assert_eq!(recs.len(), 2);
            assert_eq!(recs[0].raw, "{\"role\":\"input\",\"text\":\"hi\"}");
        });
    }