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

/// Split normalized output into records: one per non-empty line, trailing
/// whitespace trimmed. The native store is already newline-delimited; fetchers
/// and normalizers emit the same shape.
fn records(s: &str) -> Vec<HistoryRecord> {
    s.lines()
        .map(str::trim_end)
        .filter(|l| !l.is_empty())
        .map(|l| HistoryRecord { raw: l.to_string() })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Mutex;
    use std::time::Duration;

    // One process-wide lock serializing every SPT_HOME mutation in this test
    // binary. Per the cross-module env-race gotcha, a per-module lock is not
    // enough — all SPT_HOME-touching tests must share one lock.
    static HOME_LOCK: Mutex<()> = Mutex::new(());

    /// Run `body` with `SPT_HOME` pointed at a fresh temp dir, restored after.
    fn with_home<F: FnOnce(&std::path::Path)>(body: F) {
        let _g = HOME_LOCK.lock().unwrap_or_else(|p| p.into_inner());
        let prev = std::env::var("SPT_HOME").ok();
        let tmp = tempfile::tempdir().unwrap();
        std::env::set_var("SPT_HOME", tmp.path());
        body(tmp.path());
        match prev {
            Some(v) => std::env::set_var("SPT_HOME", v),
            None => std::env::remove_var("SPT_HOME"),
        }
    }

    fn no_keys() -> BTreeMap<String, String> {
        BTreeMap::new()
    }

    // Cross-platform fixtures: a command that prints two lines, a stdin
    // pass-through normalizer, and a sleeper that outlives any test timeout.
    #[cfg(windows)]
    fn two_line_cmd() -> &'static str {
        "cmd /C \"echo rec1&&echo rec2\""
    }
    #[cfg(unix)]
    fn two_line_cmd() -> &'static str {
        "sh -c \"printf 'rec1\\nrec2\\n'\""
    }
    #[cfg(windows)]
    fn passthrough() -> &'static str {
        "cmd /C findstr \"^\""
    }
    #[cfg(unix)]
    fn passthrough() -> &'static str {
        "cat"
    }
    #[cfg(windows)]
    fn sleeper() -> &'static str {
        "cmd /C ping 127.0.0.1 -n 30"
    }
    #[cfg(unix)]
    fn sleeper() -> &'static str {
        "sh -c \"sleep 30\""
    }

    #[test] // [unit->REQ-SEAM-HISTORY] fetcher: adapter pull → normalized records.
    fn fetcher_yields_records() {
        let h = History {
            strategy: HistoryStrategy::Fetcher,
            fetcher: Some(two_line_cmd().to_string()),
            locate_template: None,
            normalize_command: None,
        };
        let recs = fetch_history(&h, &no_keys(), "alice", ParentHint::Infer, Duration::from_secs(10))
            .expect("fetch");
        let raws: Vec<&str> = recs.iter().map(|r| r.raw.as_str()).collect();
        assert_eq!(raws, ["rec1", "rec2"]);
    }

    #[test] // [unit->REQ-SEAM-HISTORY] locate_normalize: read raw transcript, pipe through normalizer.
    fn locate_normalize_reads_and_normalizes() {
        let raw = tempfile::NamedTempFile::new().unwrap();
        std::fs::write(raw.path(), "line-a\nline-b\n").unwrap();
        // Quote the located path so a space in the temp dir stays one token.
        let locate = format!("\"{}\"", raw.path().display());
        let h = History {
            strategy: HistoryStrategy::LocateNormalize,
            fetcher: None,
            locate_template: Some(locate),
            normalize_command: Some(passthrough().to_string()),
        };
        let recs = fetch_history(&h, &no_keys(), "alice", ParentHint::Infer, Duration::from_secs(10))
            .expect("fetch");
        let raws: Vec<&str> = recs.iter().map(|r| r.raw.as_str()).collect();
        assert_eq!(raws, ["line-a", "line-b"]);
    }

    #[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] a fresh perch (no log) yields no records, not an error.
    fn native_missing_log_is_empty() {
        with_home(|_| {
            let h = History {
                strategy: HistoryStrategy::Native,
                fetcher: None,
                locate_template: None,
                normalize_command: None,
            };
            let recs =
                fetch_history(&h, &no_keys(), "fresh", ParentHint::Infer, Duration::from_secs(5))
                    .expect("fetch");
            assert!(recs.is_empty());
        });
    }

    #[test] // [unit->REQ-SEAM-HISTORY] a hung fetcher is killed by the timeout — never hangs (5.3).
    fn fetcher_timeout_errors() {
        let h = History {
            strategy: HistoryStrategy::Fetcher,
            fetcher: Some(sleeper().to_string()),
            locate_template: None,
            normalize_command: None,
        };
        let start = std::time::Instant::now();
        let e = fetch_history(&h, &no_keys(), "alice", ParentHint::Infer, Duration::from_millis(300))
            .unwrap_err();
        assert!(matches!(e, HistoryError::Runtime(RuntimeError::Timeout { .. })), "got {e}");
        assert!(start.elapsed() < Duration::from_secs(5), "must return promptly");
    }

    #[test] // [unit->REQ-SEAM-HISTORY] a strategy missing its required field errors defensively.
    fn missing_fetcher_field_errors() {
        let h = History {
            strategy: HistoryStrategy::Fetcher,
            fetcher: None,
            locate_template: None,
            normalize_command: None,
        };
        let e = fetch_history(&h, &no_keys(), "alice", ParentHint::Infer, Duration::from_secs(5))
            .unwrap_err();
        assert!(matches!(e, HistoryError::MissingField { field: "fetcher", .. }), "got {e}");
    }
}

//! History subsystem (M2b T1) — transcript access behind the `[history]`
//! manifest section. Feeds the echo-commune (T3) and the resume briefs (T7).
//!
//! Three strategies, one normalized output ([`HistoryRecord`] lines):
//! - **`fetcher`** — spt-core *pulls*: run the adapter's `fetcher` command
//!   bounded (REQ-HAZARD-SUBPROCESS-TIMEOUT); its stdout is already-normalized
//!   history.
//! - **`locate_normalize`** — spt-core *locates* the raw transcript
//!   (`locate_template`), reads it, then pipes it through `normalize_command`
//!   bounded (raw transcript on stdin → normalized stdout).
//! - **`native`** — Path-B store: the adapter *pushed* records via
//!   `api history-log`; spt-core reads the perch's `history.log` (the T7/M2a
//!   sink in `spt::api::reporting::append_history`).
//!
//! A record is one non-empty line of normalized output. spt-core treats each as
//! **opaque** — the adapter owns the per-line schema (MANIFEST §Principle); the
//! history subsystem only delimits, never parses.
//
// Clean-room over the M2a seams (the sister's transcript access is `claude -p`
// /`.claude/`-coupled). The fetcher/normalize command lines are the adapter's
// opaque templates run through `run_bounded_command`; spt-core selects no model.
// [impl->REQ-SEAM-HISTORY]

use spt_runtime::manifest::{History, HistoryStrategy};
use spt_runtime::{run_bounded_command, RuntimeError};
use spt_store::perch::{resolve_perch_path, ParentHint};
use std::collections::BTreeMap;
use std::time::Duration;

/// One normalized transcript record — an opaque line. The adapter defines its
/// schema; spt-core carries it verbatim.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HistoryRecord {
    pub raw: String,
}

/// A history-access failure. Carries an operator message; never panics, never
/// hangs (the bounded commands are timeout-governed).
#[derive(Debug)]
pub enum HistoryError {
    /// The chosen strategy is missing a field the manifest should have required
    /// (defensive — `Manifest::validate` already gates this).
    MissingField {
        strategy: HistoryStrategy,
        field: &'static str,
    },
    /// A `fetcher` / `normalize_command` spawn, fill, or timeout failure.
    Runtime(RuntimeError),
    /// The bounded command exited non-zero (its stderr, for the operator).
    NonZero { code: Option<i32>, stderr: String },
    /// Reading the raw transcript (`locate_normalize`) or the native log failed.
    Read(std::io::Error),
}

impl std::fmt::Display for HistoryError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            HistoryError::MissingField { strategy, field } => {
                write!(f, "[history] strategy {strategy:?} requires `{field}`")
            }
            HistoryError::Runtime(e) => write!(f, "history command failed: {e}"),
            HistoryError::NonZero { code, stderr } => {
                write!(f, "history command exited {code:?}: {}", stderr.trim())
            }
            HistoryError::Read(e) => write!(f, "reading transcript failed: {e}"),
        }
    }
}

impl std::error::Error for HistoryError {}

impl From<RuntimeError> for HistoryError {
    fn from(e: RuntimeError) -> Self {
        HistoryError::Runtime(e)
    }
}

/// Fetch + normalize the perch's history via the manifest's `[history]`
/// strategy. `keys` supplies the substitution values (`session_id`, etc.) for
/// the `fetcher` / `locate_template` / `normalize_command` templates; `id` +
/// `hint` resolve the native Path-B store. A bounded command that hangs is
/// killed (never an indefinite block); a missing native log yields *no* records
/// (a fresh perch), not an error.
pub fn fetch_history(
    history: &History,
    keys: &BTreeMap<String, String>,
    id: &str,
    hint: ParentHint<'_>,
    timeout: Duration,
) -> Result<Vec<HistoryRecord>, HistoryError> {
    match history.strategy {
        HistoryStrategy::Fetcher => {
            let cmd = history.fetcher.as_deref().ok_or(HistoryError::MissingField {
                strategy: HistoryStrategy::Fetcher,
                field: "fetcher",
            })?;
            let out = run_bounded_command(cmd, keys, timeout, None)?;
            ok_or_nonzero(out).map(|s| records(&s))
        }
        HistoryStrategy::LocateNormalize => {
            let locate = history
                .locate_template
                .as_deref()
                .ok_or(HistoryError::MissingField {
                    strategy: HistoryStrategy::LocateNormalize,
                    field: "locate_template",
                })?;
            let normalize = history
                .normalize_command
                .as_deref()
                .ok_or(HistoryError::MissingField {
                    strategy: HistoryStrategy::LocateNormalize,
                    field: "normalize_command",
                })?;
            // Fill the locate template to find the raw transcript, read it, then
            // hand the bytes to the normalizer on stdin.
            let path = spt_runtime::runtime::fill_template(locate, keys)?;
            let raw = std::fs::read(&path).map_err(HistoryError::Read)?;
            let out = run_bounded_command(normalize, keys, timeout, Some(&raw))?;
            ok_or_nonzero(out).map(|s| records(&s))
        }
        HistoryStrategy::Native => {
            let log = resolve_perch_path(id, hint).join("history.log");
            match std::fs::read_to_string(&log) {
                Ok(s) => Ok(records(&s)),
                // No log yet ⇒ a fresh perch with no recorded history.
                Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
                Err(e) => Err(HistoryError::Read(e)),
            }
        }
    }
}

/// A bounded command's stdout, or `NonZero` carrying its stderr if it failed.
fn ok_or_nonzero(out: spt_runtime::BoundedOutput) -> Result<String, HistoryError> {
    if out.success() {
        Ok(out.stdout)
    } else {
        Err(HistoryError::NonZero {
            code: out.status_code,
            stderr: out.stderr,
        })
    }
}

/// Split normalized output into records: one per non-empty line, trailing
/// whitespace trimmed. The native store is already newline-delimited; fetchers
/// and normalizers emit the same shape.
fn records(s: &str) -> Vec<HistoryRecord> {
    s.lines()
        .map(str::trim_end)
        .filter(|l| !l.is_empty())
        .map(|l| HistoryRecord { raw: l.to_string() })
        .collect()
}

+    // One process-wide lock serializing every SPT_HOME mutation in this test

+    // binary. Per the cross-module env-race gotcha, a per-module lock is not

+    // enough — all SPT_HOME-touching tests must share one lock.

+    static HOME_LOCK: Mutex<()> = Mutex::new(());

+    /// Run `body` with `SPT_HOME` pointed at a fresh temp dir, restored after.

+    fn with_home<F: FnOnce(&std::path::Path)>(body: F) {

+        let _g = HOME_LOCK.lock().unwrap_or_else(|p| p.into_inner());

+        let prev = std::env::var("SPT_HOME").ok();

+        let tmp = tempfile::tempdir().unwrap();

+        std::env::set_var("SPT_HOME", tmp.path());

+            Some(v) => std::env::set_var("SPT_HOME", v),

+            None => std::env::remove_var("SPT_HOME"),

+    fn no_keys() -> BTreeMap<String, String> {

+    // Cross-platform fixtures: a command that prints two lines, a stdin

+    // pass-through normalizer, and a sleeper that outlives any test timeout.

+    fn two_line_cmd() -> &'static str {

+        "cmd /C \"echo rec1&&echo rec2\""

+        "sh -c \"printf 'rec1\\nrec2\\n'\""

+    #[test] // [unit->REQ-SEAM-HISTORY] fetcher: adapter pull → normalized records.

+            strategy: HistoryStrategy::Fetcher,

+            fetcher: Some(two_line_cmd().to_string()),

+        let recs = fetch_history(&h, &no_keys(), "alice", ParentHint::Infer, Duration::from_secs(10))

+        let raws: Vec<&str> = recs.iter().map(|r| r.raw.as_str()).collect();

+        assert_eq!(raws, ["rec1", "rec2"]);

+    #[test] // [unit->REQ-SEAM-HISTORY] locate_normalize: read raw transcript, pipe through normalizer.

+    fn locate_normalize_reads_and_normalizes() {

+        let raw = tempfile::NamedTempFile::new().unwrap();

+        std::fs::write(raw.path(), "line-a\nline-b\n").unwrap();

+        // Quote the located path so a space in the temp dir stays one token.

+        let locate = format!("\"{}\"", raw.path().display());

+            strategy: HistoryStrategy::LocateNormalize,

+            locate_template: Some(locate),

+            normalize_command: Some(passthrough().to_string()),

+        assert_eq!(raws, ["line-a", "line-b"]);

+    #[test] // [unit->REQ-SEAM-HISTORY] native Path-B store: read the perch's history.log.

+            let perch = resolve_perch_path("alice", ParentHint::Infer);

+            std::fs::create_dir_all(&perch).unwrap();

+                strategy: HistoryStrategy::Native,

+                normalize_command: None,

+                fetch_history(&h, &no_keys(), "alice", ParentHint::Infer, Duration::from_secs(5))

+    #[test] // [unit->REQ-SEAM-HISTORY] a fresh perch (no log) yields no records, not an error.

+                fetch_history(&h, &no_keys(), "fresh", ParentHint::Infer, Duration::from_secs(5))

+    #[test] // [unit->REQ-SEAM-HISTORY] a hung fetcher is killed by the timeout — never hangs (5.3).

+            strategy: HistoryStrategy::Fetcher,

+            fetcher: Some(sleeper().to_string()),

+        let start = std::time::Instant::now();

+        let e = fetch_history(&h, &no_keys(), "alice", ParentHint::Infer, Duration::from_millis(300))

+        assert!(matches!(e, HistoryError::Runtime(RuntimeError::Timeout { .. })), "got {e}");

+        assert!(start.elapsed() < Duration::from_secs(5), "must return promptly");

+    #[test] // [unit->REQ-SEAM-HISTORY] a strategy missing its required field errors defensively.

+    fn missing_fetcher_field_errors() {

+        let e = fetch_history(&h, &no_keys(), "alice", ParentHint::Infer, Duration::from_secs(5))

+        assert!(matches!(e, HistoryError::MissingField { field: "fetcher", .. }), "got {e}");


Bash

cargo test -p spt-live -p spt-runtime 2>&1 | tail -35

Test spt-live + spt-runtime


   Compiling spt-runtime v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-runtime)
   Compiling spt-live v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-live)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.30s
     Running unittests src\lib.rs (target\debug\deps\spt_live-1583832c44740622.exe)

running 6 tests
test history::tests::missing_fetcher_field_errors ... ok
test history::tests::native_missing_log_is_empty ... ok
test history::tests::locate_normalize_reads_and_normalizes ... FAILED
test history::tests::native_reads_logged_records ... ok
test history::tests::fetcher_yields_records ... ok
test history::tests::fetcher_timeout_errors ... ok

failures:

---- history::tests::locate_normalize_reads_and_normalizes stdout ----

thread 'history::tests::locate_normalize_reads_and_normalizes' (60220) panicked at crates\spt-live\src\history.rs:239:14:
fetch: Read(Os { code: 123, kind: InvalidFilename, message: "The filename, directory name, or volume label syntax is incorrect." })
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

failures:
    history::tests::locate_normalize_reads_and_normalizes

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

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


Locate path is read directly (not tokenized) — quotes shouldn't be there. Fix test.


Edit

        std::fs::write(raw.path(), "line-a\nline-b\n").unwrap();
        // Quote the located path so a space in the temp dir stays one token.
        let locate = format!("\"{}\"", raw.path().display());
        let h = History {
            strategy: HistoryStrategy::LocateNormalize,
            fetcher: None,
            locate_template: Some(locate),

        std::fs::write(raw.path(), "line-a\nline-b\n").unwrap();
        // The locate template resolves to a path read directly (not tokenized),
        // so spaces need no quoting.
        let locate = raw.path().display().to_string();
        let h = History {
            strategy: HistoryStrategy::LocateNormalize,
            fetcher: None,
            locate_template: Some(locate),


        std::fs::write(raw.path(), "line-a\nline-b\n").unwrap();
        // Quote the located path so a space in the temp dir stays one token.
        let locate = format!("\"{}\"", raw.path().display());
        let h = History {
            strategy: HistoryStrategy::LocateNormalize,
            fetcher: None,
            locate_template: Some(locate),

        std::fs::write(raw.path(), "line-a\nline-b\n").unwrap();
        // The locate template resolves to a path read directly (not tokenized),
        // so spaces need no quoting.
        let locate = raw.path().display().to_string();
        let h = History {
            strategy: HistoryStrategy::LocateNormalize,
            fetcher: None,
            locate_template: Some(locate),

let raw = tempfile::NamedTempFile::new().unwrap();

-        // Quote the located path so a space in the temp dir stays one token.

-        let locate = format!("\"{}\"", raw.path().display());

+        // The locate template resolves to a path read directly (not tokenized),

+        let locate = raw.path().display().to_string();


Bash

Re-test spt-live


failures:

---- history::tests::locate_normalize_reads_and_normalizes stdout ----

thread 'history::tests::locate_normalize_reads_and_normalizes' (59920) panicked at crates\spt-live\src\history.rs:240:14:
fetch: NonZero { code: Some(2), stderr: "FINDSTR: Bad command line\r\n" }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

failures:
    history::tests::locate_normalize_reads_and_normalizes

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

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


Windows `findstr "^"` breaks (caret = cmd escape). Use `findstr /r "."` (regex, matches any non-empty line).


Edit

    #[cfg(windows)]
    fn passthrough() -> &'static str {
        "cmd /C findstr \"^\""
    }

    #[cfg(windows)]
    fn passthrough() -> &'static str {
        // /r "." = regex matching any non-empty line; reads stdin (no file arg).
        "cmd /C findstr /r \".\""
    }


    #[cfg(windows)]
    fn passthrough() -> &'static str {
        "cmd /C findstr \"^\""
    }

    #[cfg(windows)]
    fn passthrough() -> &'static str {
        // /r "." = regex matching any non-empty line; reads stdin (no file arg).
        "cmd /C findstr /r \".\""
    }

+        // /r "." = regex matching any non-empty line; reads stdin (no file arg).


T0 — Scaffold spt-live crate

Add crates/spt-live (deps spt-proto/store/msg/runtime + serde); add to workspace members; acyclic layering …→spt-runtime→spt-live; check green.

T1 — History subsystem

fetch_history over three [history] strategies (fetcher/locate_normalize/native, reuse T7 history-log); normalized records; timeout (5.3) never hangs. REQ-SEAM-HISTORY impl+unit.

T2 — spawn-psyche seam

Spawn Psyche from [session.psyche_init] as nested <id>-psyche perch via ManifestRuntime; compose spt-core-owned $psyche_prompt; set recursion_guard_env. REQ-SEAM-PSYCHE; REQ-HAZARD-SUBPROCESS-TIMEOUT.

T3 — echo-commune seam

Spawn [session.echo_commune] consuming T1 history; recursion-guard; write commune drop-file tagged project-primary/live-conservative (Source:). REQ-SEAM-HISTORY; REQ-HAZARD-DROP-FILE-SINGLE-WRITER.

T4 — drop-file ingest

Watch manifest commune/signoff dirs; ingest <id>-commune.md/<id>-signoff.md; spt-core single-writer (ingest→delete); direct-write precedence marker. REQ-API-3 (int); HAZARD 6.4 + 6.5.

T5 — pulse heartbeat

Interim loop in api listen runner (period = global config knob, per-endpoint override) driving drop-file polling + pending-echo fires + idle checks. Default no new req (mechanism under REQ-SEAM-PSYCHE).

T6 — signoff + boundary lifecycle

Graceful signoff runs echo BEFORE INIT_SIGNOFF (3.3); grace recheck binds still_gone before any write (1.1); stale-signoff sweep on start (3.2); boundary authors Self-resume commune. Ordering-assertion tests.

T7 — resume-session seam

fresh-with-preload (cleared session + $psyche-context) and continue-existing (resume $session_id); wire into spawn + boundary re-entry; wrong/missing key errors before spawn. REQ-SEAM-RESUME.

T8 — api listen lifecycle wiring

Extend M2a relay runner to host LiveAgent lifecycle: spawn Psyche (T2), run pulse (T5), ingest drops (T4), fire echoes (T3), graceful signoff (T6). Keep thin. REQ-SEAM-PSYCHE, REQ-API-2.

T9 — LiveAgent E2E

Extend adapters/mock/manifest.toml with psyche_init/resume/echo_commune/signoff + [history] native + commune/signoff dirs; tests/ integration proving full lifecycle harness-agnostic. REQ-SEAM-PSYCHE/HISTORY/API-3 int.

T10 — Activation sweep

Activate M2b reqs + hazards; REQ-EP-1/2 live-evidence; check green + CI; amend ROADMAP (M2b delivered) + CONTEXT notes; author M3 plan stub.


Bash

Re-test spt-live


test history::tests::fetcher_yields_records ... ok
test history::tests::locate_normalize_reads_and_normalizes ... ok
test history::tests::fetcher_timeout_errors ... ok

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

   Doc-tests spt_live

running 0 tests

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


All green. Activate REQ-SEAM-HISTORY, run full workspace test + clippy + check.


Edit

id = "REQ-SEAM-HISTORY"
title = "History subsystem (fetcher / locate-normalize / native store)"
required_stages = []

id = "REQ-SEAM-HISTORY"
title = "History subsystem (fetcher / locate-normalize / native store)"
required_stages = ["impl", "unit"]


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

id = "REQ-SEAM-HISTORY"
title = "History subsystem (fetcher / locate-normalize / native store)"
required_stages = []

id = "REQ-SEAM-HISTORY"
title = "History subsystem (fetcher / locate-normalize / native store)"
required_stages = ["impl", "unit"]

title = "Activity/idle reported via api sentinels, not PTY quiescence"


Bash

cargo test --workspace 2>&1 | grep -E "test result|error\[|warning: unused|FAILED" | head -30; echo "=== CLIPPY ==="; cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -8; echo "=== TRACE ==="; traceable-reqs check > /dev/null 2>&1; echo "check_exit=$?"

Full workspace test, clippy, traceable-reqs check


test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 40 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.27s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.87s
test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.32s
test result: ok. 22 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.16s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.21s
test result: ok. 43 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
test result: ok. 17 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.32s
test result: ok. 33 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.67s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
=== CLIPPY ===
    Checking mock-adapter v0.0.0 (C:\Users\decid\Documents\projects\spt-core\adapters\mock)
    Checking spt-proto v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-proto)
    Checking spt-store v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-store)
    Checking spt-msg v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-msg)
    Checking spt-runtime v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-runtime)
    Checking spt-live v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-live)
    Checking spt v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.99s
=== TRACE ===
check_exit=0


Green across the board. Commit T1.


git add -A && git commit -q -F - <<'EOF'
feat(spt-live): M2b T1 — history subsystem (fetcher/locate-normalize/native)

fetch_history dispatches the three [history] strategies to one normalized
HistoryRecord-line output (opaque per-line; adapter owns the schema):
- fetcher: pull via the adapter command, bounded;
- locate_normalize: read the raw transcript (locate_template), pipe it through
  normalize_command on stdin, bounded;
- native: read the perch's Path-B history.log (the M2a api history-log sink).

Adds spt-runtime::run_bounded_command — an arbitrary bounded command template
(stdin-feedable) sharing the kill-on-timeout hazard machinery; the history
fetcher/normalizer command lines live outside the [session.*] role set, so
ManifestRuntime::run_bounded (role-keyed) doesn't apply. Clean-room over the
M2a seams; spt-core selects no model (command lives in the adapter template).

A hung fetcher is killed by the timeout (5.3 — never hangs); a missing native
log yields no records (fresh perch), not an error. 6 unit tests cover each
strategy + timeout + missing-field. REQ-SEAM-HISTORY activated [impl,unit].

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

Commit T1


warning: in the working copy of 'crates/spt-live/src/lib.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-runtime/src/lib.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-runtime/src/runtime.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'traceable-reqs.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-live/src/history.rs', LF will be replaced by CRLF the next time Git touches it
a9a079b feat(spt-live): M2b T1 — history subsystem (fetcher/locate-normalize/native)


Updated task #2 status


T1 shipped. T2 — spawn-psyche. Read manifest `[session]` roles + how perches get bound in startup.


grep -n "psyche\|pub struct Session\|roles\|recursion_guard\|psyche_init\|psyche_resume\|echo_commune\|signoff\|commune" crates/spt-runtime/src/manifest.rs | head -50

Find session roles + psyche fields in manifest


15://! lifecycle sections (`[session.psyche_*]`, commune/signoff dirs, `[history]`
89:/// `[session]` — the watched-dir keys (`commune_dir`/`signoff_dir`) co-located
92:pub struct Session {
96:    pub commune_dir: Option<String>,
98:    pub signoff_dir: Option<String>,
100:    // the `psyche_*`/`echo_commune`/`signoff` roles are parsed here but only
105:    pub psyche_init: Option<SessionRole>,
107:    pub psyche_resume: Option<SessionRole>,
109:    pub echo_commune: Option<SessionRole>,
111:    pub signoff: Option<SessionRole>,
115:    /// True when no dirs and no roles are declared (skip-serialize helper).
117:        self.commune_dir.is_none()
118:            && self.signoff_dir.is_none()
120:            && self.psyche_init.is_none()
121:            && self.psyche_resume.is_none()
122:            && self.echo_commune.is_none()
123:            && self.signoff.is_none()
127:    /// runtime to enumerate spawnable roles.
128:    pub fn roles(&self) -> Vec<(&'static str, &SessionRole)> {
132:            ("psyche_init", &self.psyche_init),
133:            ("psyche_resume", &self.psyche_resume),
134:            ("echo_commune", &self.echo_commune),
135:            ("signoff", &self.signoff),
147:/// (MANIFEST §session roles). No nested tables here (keeps TOML round-trip
150:pub struct SessionRole {
158:    pub recursion_guard_env: Option<String>,
490:commune_dir = ".claude"
491:signoff_dir = ".claude"
493:[session.psyche_init]
494:command = "claude -p --agent owl-psyche --name {session_name} --model sonnet"
495:cwd = "{psyche_dir}"
497:recursion_guard_env = "SPT_ECHO_COMMUNE"
499:keys = ["session_name", "psyche_dir"]
558:        assert_eq!(m.session.commune_dir.as_deref(), Some(".claude"));
559:        let role = m.session.psyche_init.as_ref().unwrap();
562:        assert_eq!(role.recursion_guard_env.as_deref(), Some("SPT_ECHO_COMMUNE"));
563:        assert_eq!(m.session.roles().len(), 1);


89	/// `[session]` — the watched-dir keys (`commune_dir`/`signoff_dir`) co-located
90	/// with the fixed set of `[session.<role>]` command templates.
91	#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
92	pub struct Session {
93	    // Watched dirs (direct `[session]` keys). Filenames are spt-core-fixed;
94	    // only the directory is adapter-declared (MANIFEST §watched dirs).
95	    #[serde(default, skip_serializing_if = "Option::is_none")]
96	    pub commune_dir: Option<String>,
97	    #[serde(default, skip_serializing_if = "Option::is_none")]
98	    pub signoff_dir: Option<String>,
99	    // Role templates (`[session.<role>]`). `self` is the agent's own session;
100	    // the `psyche_*`/`echo_commune`/`signoff` roles are parsed here but only
101	    // *executed* in M2b.
102	    #[serde(rename = "self", default, skip_serializing_if = "Option::is_none")]
103	    pub self_: Option<SessionRole>,
104	    #[serde(default, skip_serializing_if = "Option::is_none")]
105	    pub psyche_init: Option<SessionRole>,
106	    #[serde(default, skip_serializing_if = "Option::is_none")]
107	    pub psyche_resume: Option<SessionRole>,
108	    #[serde(default, skip_serializing_if = "Option::is_none")]
109	    pub echo_commune: Option<SessionRole>,
110	    #[serde(default, skip_serializing_if = "Option::is_none")]
111	    pub signoff: Option<SessionRole>,
112	}
113
114	impl Session {
115	    /// True when no dirs and no roles are declared (skip-serialize helper).
116	    pub fn is_empty(&self) -> bool {
117	        self.commune_dir.is_none()
118	            && self.signoff_dir.is_none()
119	            && self.self_.is_none()
120	            && self.psyche_init.is_none()
121	            && self.psyche_resume.is_none()
122	            && self.echo_commune.is_none()
123	            && self.signoff.is_none()
124	    }
125
126	    /// All declared role templates, paired with their role name. Used by the
127	    /// runtime to enumerate spawnable roles.
128	    pub fn roles(&self) -> Vec<(&'static str, &SessionRole)> {
129	        let mut out = Vec::new();
130	        for (name, role) in [
131	            ("self", &self.self_),
132	            ("psyche_init", &self.psyche_init),
133	            ("psyche_resume", &self.psyche_resume),
134	            ("echo_commune", &self.echo_commune),
135	            ("signoff", &self.signoff),
136	        ] {
137	            if let Some(r) = role {
138	                out.push((name, r));
139	            }
140	        }
141	        out
142	    }
143	}
144
145	/// `[session.<role>]` — one opaque outbound command template plus its spawn
146	/// context. Model/tools/flags all live inside `command`, never as fields
147	/// (MANIFEST §session roles). No nested tables here (keeps TOML round-trip
148	/// emission scalar-before-table clean).
149	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
150	pub struct SessionRole {
151	    /// Opaque command line, with `{key}` substitution placeholders.
152	    pub command: String,
153	    #[serde(default, skip_serializing_if = "Option::is_none")]
154	    pub cwd: Option<String>,
155	    /// Env var set on summarizer children so their own hooks bail (recursion
156	    /// guard).
157	    #[serde(default, skip_serializing_if = "Option::is_none")]
158	    pub recursion_guard_env: Option<String>,
159	    #[serde(default)]
160	    pub detach: bool,
161	    /// Env vars to strip from the child's inherited environment.
162	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
163	    pub env_remove: Vec<String>,
164	    /// Substitution keys spt-core guarantees to fill for this role.
165	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
166	    pub keys: Vec<String>,
167	}
168
169	/// `[env.<VAR>]` — a single env-var directive.
170	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
171	pub struct EnvVar {
172	    pub direction: EnvDirection,
173	    /// Value to inject (with substitution); required for `inject`.
174	    #[serde(default, skip_serializing_if = "Option::is_none")]
175	    pub value: Option<String>,
176	    /// Harness-hosted injection channel (spt-hosted inherits from the broker).
177	    #[serde(default, skip_serializing_if = "Option::is_none")]
178	    pub channel: Option<String>,
179	}
180
181	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
182	#[serde(rename_all = "snake_case")]
183	pub enum EnvDirection {

/// `[session]` — the watched-dir keys (`commune_dir`/`signoff_dir`) co-located
/// with the fixed set of `[session.<role>]` command templates.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct Session {
    // Watched dirs (direct `[session]` keys). Filenames are spt-core-fixed;
    // only the directory is adapter-declared (MANIFEST §watched dirs).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub commune_dir: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub signoff_dir: Option<String>,
    // Role templates (`[session.<role>]`). `self` is the agent's own session;
    // the `psyche_*`/`echo_commune`/`signoff` roles are parsed here but only
    // *executed* in M2b.
    #[serde(rename = "self", default, skip_serializing_if = "Option::is_none")]
    pub self_: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub psyche_init: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub psyche_resume: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub echo_commune: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub signoff: Option<SessionRole>,
}

impl Session {
    /// True when no dirs and no roles are declared (skip-serialize helper).
    pub fn is_empty(&self) -> bool {
        self.commune_dir.is_none()
            && self.signoff_dir.is_none()
            && self.self_.is_none()
            && self.psyche_init.is_none()
            && self.psyche_resume.is_none()
            && self.echo_commune.is_none()
            && self.signoff.is_none()
    }

    /// All declared role templates, paired with their role name. Used by the
    /// runtime to enumerate spawnable roles.
    pub fn roles(&self) -> Vec<(&'static str, &SessionRole)> {
        let mut out = Vec::new();
        for (name, role) in [
            ("self", &self.self_),
            ("psyche_init", &self.psyche_init),
            ("psyche_resume", &self.psyche_resume),
            ("echo_commune", &self.echo_commune),
            ("signoff", &self.signoff),
        ] {
            if let Some(r) = role {
                out.push((name, r));
            }
        }
        out
    }
}

/// `[session.<role>]` — one opaque outbound command template plus its spawn
/// context. Model/tools/flags all live inside `command`, never as fields
/// (MANIFEST §session roles). No nested tables here (keeps TOML round-trip
/// emission scalar-before-table clean).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SessionRole {
    /// Opaque command line, with `{key}` substitution placeholders.
    pub command: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cwd: Option<String>,
    /// Env var set on summarizer children so their own hooks bail (recursion
    /// guard).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub recursion_guard_env: Option<String>,
    #[serde(default)]
    pub detach: bool,
    /// Env vars to strip from the child's inherited environment.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub env_remove: Vec<String>,
    /// Substitution keys spt-core guarantees to fill for this role.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub keys: Vec<String>,
}

/// `[env.<VAR>]` — a single env-var directive.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EnvVar {
    pub direction: EnvDirection,
    /// Value to inject (with substitution); required for `inject`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub value: Option<String>,
    /// Harness-hosted injection channel (spt-hosted inherits from the broker).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub channel: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EnvDirection {


Read how perches get bound (startup) + the info.json writer.


ls crates/spt/src/ crates/spt/src/api/ 2>/dev/null; echo "---startup---"; sed -n '1,60p' crates/spt/src/api/startup.rs 2>/dev/null || grep -rln "bind\|info.json\|write_info\|InfoFile\|fn bind" crates/spt/src/ crates/spt-store/src/info.rs | head

Locate startup + bind logic


crates/spt/src/:
api
cli.rs
main.rs
roster.rs
testutil.rs

crates/spt/src/api/:
auth.rs
delivery.rs
mod.rs
reporting.rs
startup.rs
worker.rs
---startup---
//! Harness-hosted startup (interim no-daemon): `api seed` → `api listen` → bind,
//! plus the post-spawn `api bind`.
//!
//! Flow (CONTEXT §Startup flows, harness-hosted):
//! 1. The harness's SessionStart hook fires `api seed --pid <parent_pid>
//!    --session-id <sid>` → an ephemeral seed file keyed by the stable parent
//!    pid (REQ-START-1).
//! 2. The agent runs its `$SPT listen <id>` alias → `api listen` self-discovers
//!    its parent pid, matches the seed, validates it against pid-recycle
//!    (KNOWN-HAZARDS 5.1), binds the perch skeleton→live, and relays.
//! 3. Alternatively a just-spawned binary fires `api bind <id>` to associate
//!    itself post-spawn (REQ-SEAM-POSTSPAWN) — establishing the live perch on
//!    first contact, or flipping an existing skeleton.
//
// [impl->REQ-START-1]
// [impl->REQ-START-2]
// [impl->REQ-SEAM-POSTSPAWN]

use std::time::Duration;

use spt_msg::listener::PollListener;
use spt_store::info::{self, InfoJson};
use spt_store::perch::{self, ParentHint};
use spt_store::proc;
use spt_store::seed::{self, Seed};
use spt_store::spool;

use super::auth;
use super::{Ctx, EXIT_REFUSED};

/// Poll window for the relay loop — bounds each blocking accept so the loop
/// stays responsive (mirrors M1's listener cadence).
const RELAY_POLL: Duration = Duration::from_millis(200);

/// `api seed` — record the ephemeral startup seed keyed by parent pid. No prior
/// perch exists, so this call is not auth-gated (it *establishes* the binding
/// material the gated calls later authenticate against).
// [impl->REQ-START-1]
pub fn cmd_seed(ctx: &Ctx, parent_pid: u32, session_id: String) -> i32 {
    if session_id.is_empty() {
        eprintln!("USAGE: seed requires a non-empty --session-id");
        return EXIT_REFUSED;
    }
    let seed = Seed {
        parent_pid,
        session_id,
        adapter: ctx.adapter.clone(),
        cwd: std::env::current_dir()
            .ok()
            .map(|p| p.to_string_lossy().replace('\\', "/")),
        created: now_stamp(),
    };
    match seed::write_seed(&seed) {
        Ok(()) => {
            eprintln!("SEEDED:{parent_pid}");
            0
        }
        Err(e) => {
            eprintln!("SEED_FAIL:{parent_pid}: {e}");
            EXIT_REFUSED


grep -n "InfoJson\|fn bind\|fn write_info\|pub fn write\|skeleton\|live\|nested\|psyche\|fn.*bind" crates/spt/src/api/startup.rs | head -40

Find bind + InfoJson usage in startup


10://!    (KNOWN-HAZARDS 5.1), binds the perch skeleton→live, and relays.
12://!    itself post-spawn (REQ-SEAM-POSTSPAWN) — establishing the live perch on
13://!    first contact, or flipping an existing skeleton.
22:use spt_store::info::{self, InfoJson};
73:    /// A *live* perch already holds this id under a different session (5.1).
78:/// Consume the seed for `parent_pid` and bind/revive the perch skeleton→live.
83:pub fn bind_from_seed(id: &str, parent_pid: u32) -> Result<String, BindError> {
88:    // Pid-recycle defense (5.1): anchor liveness on the stable parent pid. A
91:    if !proc::is_process_alive(seed.parent_pid) {
99:/// Write the perch record (skeleton→live) + ready marker + capability token.
100:/// Reviving an existing perch keeps its token; a live perch held under a
106:        let owner_alive = info::read_pid(&perch_path)
107:            .map(proc::is_process_alive)
109:        if owner_alive
121:    let mut rec = InfoJson::new(id, &now_stamp(), std::process::id(), session_id, "live_agent");
189:/// live perch on first contact, or flips an existing skeleton, keyed to the
190:/// session id the binary reports. Auth is intrinsic: an existing *live* perch
193:pub fn cmd_bind(id: &str, set_session_id: Option<String>, proof_session: Option<String>) -> i32 {
216:fn report_bind_error(e: &BindError) {
222:            eprintln!("CONFLICT:{id} is live under session {held}")
228:/// Print a framed message to stdout and flush (one delivery per emit).
260:    #[test] // [unit->REQ-START-2] a valid seed (live anchor) binds a live perch.
261:    fn valid_seed_binds_live_perch() {
263:        // Use this very process as the (alive) anchor.
272:        assert_eq!(rec.state, "live_agent");
305:    #[test] // [unit->REQ-HAZARD-WINDOWS-PID-RECYCLE] a live perch under another
307:    fn live_perch_under_other_session_conflicts() {
310:        // First bind establishes alice@sid-1 owned by this (live) process.
319:    #[test] // [unit->REQ-SEAM-POSTSPAWN] first-contact bind establishes a live perch.
320:    fn post_spawn_bind_establishes_perch() {
326:        assert_eq!(rec.state, "live_agent");
330:    fn rebind_same_session_ok() {


sed -n '1,80p' crates/spt-store/src/info.rs

Read InfoJson struct


//! `info.json` — the per-perch identity/status record at `<perch_path>/info.json`.
//!
//! Adapted from sister `types.rs` (`InfoJson` / `PidValue`) per ADR-0001:
//! - [`PidValue`] (untagged numeric-or-`"BUSY"`) is copied verbatim — the
//!   `"BUSY"` sentinel is load-bearing (a busy poll-listener writes it; numeric
//!   readers must treat it as "no usable pid").
//! - `state` is the spt-core [`EndpointType`](spt_proto::endpoint::EndpointType)
//!   tag string rather than the sister's `PerchState` enum (whose Spine/Touch
//!   kinds the spt-core taxonomy drops — see T4).
//!
//! ## Torn-read tolerance (KNOWN-HAZARDS 1.2, REQ-HAZARD-INFO-JSON-TORN-READ)
//! A reader may hit the file mid-write. Two layers guarantee it never observes a
//! truncated record:
//! 1. **Writers go through [`write_info`]** → [`crate::atomic::atomic_write_string`]
//!    (tmp + rename): a same-volume rename is atomic, so a concurrent reader
//!    sees the *complete* old file or the *complete* new file, never a torn one.
//! 2. **Readers tolerate failure**: [`read_info`] / [`read_pid`] parse via a
//!    bounded retry and return `None` on persistent failure — never panic.

use serde::{Deserialize, Serialize};
use std::path::Path;

use crate::atomic::atomic_write_string;

/// Pid field that is either a real numeric pid or the `"BUSY"` sentinel a
/// listener writes while it holds the perch. Untagged: serializes as a bare
/// integer or a bare string. Copied verbatim (sister `PidValue`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum PidValue {
    Numeric(u32),
    Busy(String),
}

/// On-disk shape of a perch's `info.json`. Field order is locked by declaration
/// (serde_json `preserve_order`). Optional fields are omitted when `None`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InfoJson {
    /// The perch / agent id.
    pub id: String,
    /// Creation timestamp (opaque string; producer's format).
    pub started: String,
    /// Owning process pid, or the `"BUSY"` sentinel.
    pub pid: PidValue,
    /// Harness session id bound to this perch.
    pub session_id: String,
    /// Endpoint type tag (`spt_proto::endpoint::EndpointType::as_tag`).
    pub state: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub parent_pid: Option<u32>,
    /// Working directory the perch was created in (forward-slash normalized).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cwd: Option<String>,
}

impl InfoJson {
    /// Construct a numeric-pid record.
    pub fn new(id: &str, started: &str, pid: u32, session_id: &str, state: &str) -> InfoJson {
        InfoJson {
            id: id.to_string(),
            started: started.to_string(),
            pid: PidValue::Numeric(pid),
            session_id: session_id.to_string(),
            state: state.to_string(),
            parent_pid: None,
            cwd: None,
        }
    }
}

/// Number of read attempts before giving up (torn-read retry).
const READ_ATTEMPTS: u32 = 5;

/// Atomically write `info` to `<perch_path>/info.json` (tmp + rename, EBUSY
/// retry via the atomic layer). The perch directory must already exist.
// [impl->REQ-HAZARD-INFO-JSON-TORN-READ]
pub fn write_info(perch_path: &Path, info: &InfoJson) -> std::io::Result<()> {
    let json = serde_json::to_string(info)
        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
    atomic_write_string(&perch_path.join("info.json"), &json)


Read endpoint tags + any `psyche_prompt`/psyche spec in docs/CONTEXT.


grep -rn "as_tag\|psyche\|Psyche\|EndpointType" crates/spt-proto/src/endpoint.rs | head -30

Endpoint tags incl psyche


8://! Psyches directly and so Shells can be registered later.
13://!   [`EndpointType::Other`] rather than failing (forward compatibility: a
29:    /// ReadyAgent, LiveAgent, Psyche, Worker.
43:/// to [`EndpointType::Other`] and re-encode losslessly.
45:/// Wire form is a flat lowercase tag (see [`EndpointType::as_tag`]). Shells
49:pub enum EndpointType {
50:    /// Minimal participant: a perch + poll listener, no Psyche, no wrapper.
53:    /// A Self with a Psyche companion. Heir to the sister's "live agent".
55:    /// The Psyche companion's own perch, distinct from its LiveAgent's.
56:    Psyche,
72:impl EndpointType {
76:    /// The stable wire tag for this type. Inverse of [`EndpointType::from_tag`].
78:    pub fn as_tag(&self) -> String {
80:            EndpointType::ReadyAgent => "ready_agent".to_string(),
81:            EndpointType::LiveAgent => "live_agent".to_string(),
82:            EndpointType::Psyche => "psyche".to_string(),
83:            EndpointType::Worker => "worker".to_string(),
84:            EndpointType::SptNode => "spt_node".to_string(),
85:            EndpointType::PresenceChannel => "presence_channel".to_string(),
86:            EndpointType::Shell(kind) => format!("{}{}", Self::SHELL_PREFIX, kind),
87:            EndpointType::Other(tag) => tag.clone(),
92:    /// [`EndpointType::Other`] (open type system; forward-compat). A `shell:`
93:    /// prefix yields a [`EndpointType::Shell`] of the trailing kind.
95:    pub fn from_tag(tag: &str) -> EndpointType {
97:            "ready_agent" => EndpointType::ReadyAgent,
98:            "live_agent" => EndpointType::LiveAgent,
99:            "psyche" => EndpointType::Psyche,
100:            "worker" => EndpointType::Worker,
101:            "spt_node" => EndpointType::SptNode,
102:            "presence_channel" => EndpointType::PresenceChannel,


grep -rn "psyche_prompt\|psyche prompt\|incoming event\|event envelope" docs/ CONTEXT.md 2>/dev/null | head -20

Find psyche_prompt semantics in docs


docs/CONTEXT-MEMORY.md:41:The live/project authoring directives are **core-owned** — fed to every Psyche by spt-core (part of `$psyche_prompt` / a core system-prompt fragment), so the split is consistent across all adapters (inconsistent rules → sync chaos). Adapters add only harness mechanics (how to write the drop file).
docs/MANIFEST.md:60:(`psyche_resume` adds `{session_id}`; both carry the spt-core-owned `{psyche_prompt}`. `signoff` is a `psyche_resume` variant firing a final context-save. `echo_commune` is a cheap-model template consuming history.)
CONTEXT.md:88:**spawn-psyche seam** — two command templates: fresh-start and resume (the resume template includes `$session_id`). Both include `$psyche_prompt` — the revival essentials spt-core feeds the Psyche (timestamp, incoming event envelope). Everything else is the adapter's: model selection (in its template), and any harness-specific instructions the Psyche needs (Write-tool usage, commune dir) supplied as a static preamble before `$psyche_prompt` or as adapter SessionStart additionalContext. spt-core owns `$psyche_prompt` content; the adapter owns the rest.


T0 — Scaffold spt-live crate

T1 — History subsystem

T2 — spawn-psyche seam

T3 — echo-commune seam

T4 — drop-file ingest

T5 — pulse heartbeat

T6 — signoff + boundary lifecycle

T7 — resume-session seam

T8 — api listen lifecycle wiring

T9 — LiveAgent E2E

T10 — Activation sweep


CONTEXT confirms `$psyche_prompt` = timestamp + incoming event envelope, spt-core-owned. Implement T2.


Updated task #3 status


Write

//! spawn-psyche seam (M2b T2) — bring a Self's Psyche companion to life.
//!
//! The Psyche is spawned from the `[session.psyche_init]` template (fresh) or
//! `[session.psyche_resume]` (re-entry — T7) as a **nested `<id>-psyche` perch**
//! (M0 layout: `owlery/<parent>/nested/<id>-psyche/`). spt-core owns exactly one
//! piece of the command's content — the `{psyche_prompt}` substitution: the
//! revival essentials (timestamp + the incoming event envelope) per CONTEXT
//! §spawn-psyche seam. Everything else — model selection, harness preamble —
//! lives in the adapter's opaque template; spt-core selects no model.
//!
//! The recursion guard (`recursion_guard_env`) is set on the child by
//! `ManifestRuntime` so the Psyche's own harness hooks bail instead of spawning
//! a Psyche-of-the-Psyche (KNOWN-HAZARDS — echo-commune recursion).
//
// Clean-room over the M2a spawn seam (the sister's psyche wrapper owns a
// `claude -p` subprocess + `.claude/` paths). Interim no-daemon model: the
// Psyche is a real detached subprocess and spt-core writes its nested perch
// record; M3's daemon re-hosts it as a loop with daemon-authoritative liveness.
// [impl->REQ-SEAM-PSYCHE]

use spt_proto::endpoint::EndpointType;
use spt_runtime::{AgentRuntime, RuntimeError};
use spt_store::info::{write_info, InfoJson};
use spt_store::perch::{resolve_perch_path, ParentHint};
use std::collections::BTreeMap;
use std::path::PathBuf;

/// The role spawned for a fresh Psyche.
pub const PSYCHE_INIT_ROLE: &str = "psyche_init";

/// A spawned Psyche companion: its nested id, OS pid, and perch directory.
#[derive(Debug, Clone)]
pub struct PsycheHandle {
    pub id: String,
    pub pid: u32,
    pub perch_path: PathBuf,
}

/// A spawn-psyche failure. Carries an operator message; never panics.
#[derive(Debug)]
pub enum PsycheError {
    /// Template fill / spawn / role-lookup failure from the runtime.
    Runtime(RuntimeError),
    /// Creating the nested perch dir or writing its `info.json` failed.
    Bind(std::io::Error),
}

impl std::fmt::Display for PsycheError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            PsycheError::Runtime(e) => write!(f, "psyche spawn failed: {e}"),
            PsycheError::Bind(e) => write!(f, "binding the psyche perch failed: {e}"),
        }
    }
}

impl std::error::Error for PsycheError {}

impl From<RuntimeError> for PsycheError {
    fn from(e: RuntimeError) -> Self {
        PsycheError::Runtime(e)
    }
}

/// Compose the **spt-core-owned** `{psyche_prompt}`: the revival essentials fed
/// to every Psyche regardless of adapter — a timestamp and the incoming event
/// envelope (CONTEXT §spawn-psyche seam). Consistent across adapters so the
/// live/project authoring split stays uniform (`docs/CONTEXT-MEMORY.md`); the
/// adapter prepends its own static preamble around this.
pub fn compose_psyche_prompt(now: &str, event: Option<&str>) -> String {
    let mut s = String::with_capacity(64);
    s.push_str("PSYCHE REVIVAL\n");
    s.push_str("time: ");
    s.push_str(now);
    s.push('\n');
    match event {
        Some(ev) if !ev.trim().is_empty() => {
            s.push_str("incoming event:\n");
            s.push_str(ev.trim_end());
            s.push('\n');
        }
        _ => s.push_str("incoming event: (none)\n"),
    }
    s
}

/// Spawn a fresh Psyche for `parent_id` and bind its nested perch.
///
/// `keys` supplies the adapter-template substitution values the caller owns
/// (e.g. `session_name`); spt-core overrides the three it owns for a Psyche:
/// `id` (the nested `<id>-psyche`), `psyche_dir` (the created perch dir, the
/// role's `cwd`), and `psyche_prompt` (composed here). The role's
/// `recursion_guard_env` is applied to the child by the runtime.
pub fn spawn_psyche(
    runtime: &dyn AgentRuntime,
    parent_id: &str,
    keys: &BTreeMap<String, String>,
    event: Option<&str>,
    now: &str,
) -> Result<PsycheHandle, PsycheError> {
    let psyche_id = format!("{parent_id}-psyche");
    let perch_path = resolve_perch_path(&psyche_id, ParentHint::Explicit(parent_id));
    // The nested perch dir must exist before spawn — the role's `cwd` is it.
    std::fs::create_dir_all(&perch_path).map_err(PsycheError::Bind)?;

    let mut k = keys.clone();
    k.insert("id".to_string(), psyche_id.clone());
    k.insert(
        "psyche_dir".to_string(),
        perch_path.to_string_lossy().to_string(),
    );
    k.insert(
        "psyche_prompt".to_string(),
        compose_psyche_prompt(now, event),
    );

    let spawned = runtime.spawn_session(PSYCHE_INIT_ROLE, &k)?;

    // Bind the nested perch: spt-core writes the Psyche's identity/status record.
    // Interim per-pid liveness (M3's daemon makes `status` authoritative).
    let session_id = keys.get("session_id").map(String::as_str).unwrap_or("");
    let mut rec = InfoJson::new(
        &psyche_id,
        now,
        spawned.pid,
        session_id,
        &EndpointType::Psyche.as_tag(),
    );
    rec.parent_pid = None;
    write_info(&perch_path, &rec).map_err(PsycheError::Bind)?;

    Ok(PsycheHandle {
        id: psyche_id,
        pid: spawned.pid,
        perch_path,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use spt_runtime::{Manifest, ManifestRuntime};
    use std::sync::Mutex;
    use std::time::{Duration, Instant};

    // Shared SPT_HOME lock (cross-module env-race guard — see history::tests).
    static HOME_LOCK: Mutex<()> = Mutex::new(());

    fn with_home<F: FnOnce()>(body: F) {
        let _g = HOME_LOCK.lock().unwrap_or_else(|p| p.into_inner());
        let prev = std::env::var("SPT_HOME").ok();
        let tmp = tempfile::tempdir().unwrap();
        std::env::set_var("SPT_HOME", tmp.path());
        body();
        match prev {
            Some(v) => std::env::set_var("SPT_HOME", v),
            None => std::env::remove_var("SPT_HOME"),
        }
    }

    #[test] // [unit->REQ-SEAM-PSYCHE] the prompt carries the timestamp + event envelope.
    fn prompt_includes_time_and_event() {
        let p = compose_psyche_prompt("2026-06-01T00:00:00Z", Some("MSG from alice: hi"));
        assert!(p.contains("time: 2026-06-01T00:00:00Z"));
        assert!(p.contains("MSG from alice: hi"));
    }

    #[test] // [unit->REQ-SEAM-PSYCHE] no event ⇒ an explicit "(none)" envelope.
    fn prompt_without_event() {
        let p = compose_psyche_prompt("t0", None);
        assert!(p.contains("incoming event: (none)"));
    }

    // A psyche_init template that records the recursion-guard env value into a
    // marker file in its cwd (the nested perch dir) — proves both that the perch
    // is the cwd and that the guard env reached the child.
    #[cfg(windows)]
    const GUARD_CMD: &str = "cmd /C echo %SPT_GUARD% > guard.txt";
    #[cfg(unix)]
    const GUARD_CMD: &str = "sh -c \"echo $SPT_GUARD > guard.txt\"";

    fn psyche_manifest() -> Manifest {
        let toml = format!(
            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
             [session.psyche_init]\ncommand=\"{GUARD_CMD}\"\ncwd=\"{{psyche_dir}}\"\n\
             recursion_guard_env=\"SPT_GUARD\"\nkeys=[\"psyche_dir\",\"psyche_prompt\"]\n"
        );
        Manifest::from_toml_str(&toml).unwrap()
    }

    #[test] // [unit->REQ-SEAM-PSYCHE] spawning binds a nested psyche perch + sets the guard env.
    fn spawn_binds_nested_perch_and_sets_guard() {
        with_home(|| {
            let rt = ManifestRuntime::new(psyche_manifest());
            let h = spawn_psyche(&rt, "doyle", &BTreeMap::new(), Some("pulse"), "t0")
                .expect("spawn psyche");

            // Nested layout: owlery/doyle/nested/doyle-psyche/.
            assert_eq!(h.id, "doyle-psyche");
            assert_eq!(
                h.perch_path,
                resolve_perch_path("doyle-psyche", ParentHint::Explicit("doyle"))
            );

            // info.json records the psyche endpoint type + the spawned pid.
            let rec = spt_store::info::read_info(&h.perch_path).expect("info.json");
            assert_eq!(rec.state, "psyche");
            assert_eq!(rec.id, "doyle-psyche");

            // The detached child writes the guard value into its cwd; poll for it.
            let marker = h.perch_path.join("guard.txt");
            let start = Instant::now();
            let contents = loop {
                if let Ok(s) = std::fs::read_to_string(&marker) {
                    if !s.trim().is_empty() {
                        break s;
                    }
                }
                assert!(
                    start.elapsed() < Duration::from_secs(5),
                    "psyche child never wrote its guard marker"
                );
                std::thread::sleep(Duration::from_millis(20));
            };
            assert!(contents.contains('1'), "guard env not set on child: {contents:?}");
        });
    }
}


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

//! spawn-psyche seam (M2b T2) — bring a Self's Psyche companion to life.
//!
//! The Psyche is spawned from the `[session.psyche_init]` template (fresh) or
//! `[session.psyche_resume]` (re-entry — T7) as a **nested `<id>-psyche` perch**
//! (M0 layout: `owlery/<parent>/nested/<id>-psyche/`). spt-core owns exactly one
//! piece of the command's content — the `{psyche_prompt}` substitution: the
//! revival essentials (timestamp + the incoming event envelope) per CONTEXT
//! §spawn-psyche seam. Everything else — model selection, harness preamble —
//! lives in the adapter's opaque template; spt-core selects no model.
//!
//! The recursion guard (`recursion_guard_env`) is set on the child by
//! `ManifestRuntime` so the Psyche's own harness hooks bail instead of spawning
//! a Psyche-of-the-Psyche (KNOWN-HAZARDS — echo-commune recursion).
//
// Clean-room over the M2a spawn seam (the sister's psyche wrapper owns a
// `claude -p` subprocess + `.claude/` paths). Interim no-daemon model: the
// Psyche is a real detached subprocess and spt-core writes its nested perch
// record; M3's daemon re-hosts it as a loop with daemon-authoritative liveness.
// [impl->REQ-SEAM-PSYCHE]

use spt_proto::endpoint::EndpointType;
use spt_runtime::{AgentRuntime, RuntimeError};
use spt_store::info::{write_info, InfoJson};
use spt_store::perch::{resolve_perch_path, ParentHint};
use std::collections::BTreeMap;
use std::path::PathBuf;

/// The role spawned for a fresh Psyche.
pub const PSYCHE_INIT_ROLE: &str = "psyche_init";

/// A spawned Psyche companion: its nested id, OS pid, and perch directory.
#[derive(Debug, Clone)]
pub struct PsycheHandle {
    pub id: String,
    pub pid: u32,
    pub perch_path: PathBuf,
}

/// A spawn-psyche failure. Carries an operator message; never panics.
#[derive(Debug)]
pub enum PsycheError {
    /// Template fill / spawn / role-lookup failure from the runtime.
    Runtime(RuntimeError),
    /// Creating the nested perch dir or writing its `info.json` failed.
    Bind(std::io::Error),
}

impl std::fmt::Display for PsycheError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            PsycheError::Runtime(e) => write!(f, "psyche spawn failed: {e}"),
            PsycheError::Bind(e) => write!(f, "binding the psyche perch failed: {e}"),
        }
    }
}

impl std::error::Error for PsycheError {}

impl From<RuntimeError> for PsycheError {
    fn from(e: RuntimeError) -> Self {
        PsycheError::Runtime(e)
    }
}

/// Compose the **spt-core-owned** `{psyche_prompt}`: the revival essentials fed
/// to every Psyche regardless of adapter — a timestamp and the incoming event
/// envelope (CONTEXT §spawn-psyche seam). Consistent across adapters so the
/// live/project authoring split stays uniform (`docs/CONTEXT-MEMORY.md`); the
/// adapter prepends its own static preamble around this.
pub fn compose_psyche_prompt(now: &str, event: Option<&str>) -> String {
    let mut s = String::with_capacity(64);
    s.push_str("PSYCHE REVIVAL\n");
    s.push_str("time: ");
    s.push_str(now);
    s.push('\n');
    match event {
        Some(ev) if !ev.trim().is_empty() => {
            s.push_str("incoming event:\n");
            s.push_str(ev.trim_end());
            s.push('\n');
        }
        _ => s.push_str("incoming event: (none)\n"),
    }
    s
}

/// Spawn a fresh Psyche for `parent_id` and bind its nested perch.
///
/// `keys` supplies the adapter-template substitution values the caller owns
/// (e.g. `session_name`); spt-core overrides the three it owns for a Psyche:
/// `id` (the nested `<id>-psyche`), `psyche_dir` (the created perch dir, the
/// role's `cwd`), and `psyche_prompt` (composed here). The role's
/// `recursion_guard_env` is applied to the child by the runtime.
pub fn spawn_psyche(
    runtime: &dyn AgentRuntime,
    parent_id: &str,
    keys: &BTreeMap<String, String>,
    event: Option<&str>,
    now: &str,
) -> Result<PsycheHandle, PsycheError> {
    let psyche_id = format!("{parent_id}-psyche");
    let perch_path = resolve_perch_path(&psyche_id, ParentHint::Explicit(parent_id));
    // The nested perch dir must exist before spawn — the role's `cwd` is it.
    std::fs::create_dir_all(&perch_path).map_err(PsycheError::Bind)?;

    let mut k = keys.clone();
    k.insert("id".to_string(), psyche_id.clone());
    k.insert(
        "psyche_dir".to_string(),
        perch_path.to_string_lossy().to_string(),
    );
    k.insert(
        "psyche_prompt".to_string(),
        compose_psyche_prompt(now, event),
    );

    let spawned = runtime.spawn_session(PSYCHE_INIT_ROLE, &k)?;

    // Bind the nested perch: spt-core writes the Psyche's identity/status record.
    // Interim per-pid liveness (M3's daemon makes `status` authoritative).
    let session_id = keys.get("session_id").map(String::as_str).unwrap_or("");
    let mut rec = InfoJson::new(
        &psyche_id,
        now,
        spawned.pid,
        session_id,
        &EndpointType::Psyche.as_tag(),
    );
    rec.parent_pid = None;
    write_info(&perch_path, &rec).map_err(PsycheError::Bind)?;

    Ok(PsycheHandle {
        id: psyche_id,
        pid: spawned.pid,
        perch_path,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use spt_runtime::{Manifest, ManifestRuntime};
    use std::sync::Mutex;
    use std::time::{Duration, Instant};

    // Shared SPT_HOME lock (cross-module env-race guard — see history::tests).
    static HOME_LOCK: Mutex<()> = Mutex::new(());

    fn with_home<F: FnOnce()>(body: F) {
        let _g = HOME_LOCK.lock().unwrap_or_else(|p| p.into_inner());
        let prev = std::env::var("SPT_HOME").ok();
        let tmp = tempfile::tempdir().unwrap();
        std::env::set_var("SPT_HOME", tmp.path());
        body();
        match prev {
            Some(v) => std::env::set_var("SPT_HOME", v),
            None => std::env::remove_var("SPT_HOME"),
        }
    }

    #[test] // [unit->REQ-SEAM-PSYCHE] the prompt carries the timestamp + event envelope.
    fn prompt_includes_time_and_event() {
        let p = compose_psyche_prompt("2026-06-01T00:00:00Z", Some("MSG from alice: hi"));
        assert!(p.contains("time: 2026-06-01T00:00:00Z"));
        assert!(p.contains("MSG from alice: hi"));
    }

    #[test] // [unit->REQ-SEAM-PSYCHE] no event ⇒ an explicit "(none)" envelope.
    fn prompt_without_event() {
        let p = compose_psyche_prompt("t0", None);
        assert!(p.contains("incoming event: (none)"));
    }

    // A psyche_init template that records the recursion-guard env value into a
    // marker file in its cwd (the nested perch dir) — proves both that the perch
    // is the cwd and that the guard env reached the child.
    #[cfg(windows)]
    const GUARD_CMD: &str = "cmd /C echo %SPT_GUARD% > guard.txt";
    #[cfg(unix)]
    const GUARD_CMD: &str = "sh -c \"echo $SPT_GUARD > guard.txt\"";

    fn psyche_manifest() -> Manifest {
        let toml = format!(
            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
             [session.psyche_init]\ncommand=\"{GUARD_CMD}\"\ncwd=\"{{psyche_dir}}\"\n\
             recursion_guard_env=\"SPT_GUARD\"\nkeys=[\"psyche_dir\",\"psyche_prompt\"]\n"
        );
        Manifest::from_toml_str(&toml).unwrap()
    }

    #[test] // [unit->REQ-SEAM-PSYCHE] spawning binds a nested psyche perch + sets the guard env.
    fn spawn_binds_nested_perch_and_sets_guard() {
        with_home(|| {
            let rt = ManifestRuntime::new(psyche_manifest());
            let h = spawn_psyche(&rt, "doyle", &BTreeMap::new(), Some("pulse"), "t0")
                .expect("spawn psyche");

            // Nested layout: owlery/doyle/nested/doyle-psyche/.
            assert_eq!(h.id, "doyle-psyche");
            assert_eq!(
                h.perch_path,
                resolve_perch_path("doyle-psyche", ParentHint::Explicit("doyle"))
            );

            // info.json records the psyche endpoint type + the spawned pid.
            let rec = spt_store::info::read_info(&h.perch_path).expect("info.json");
            assert_eq!(rec.state, "psyche");
            assert_eq!(rec.id, "doyle-psyche");

            // The detached child writes the guard value into its cwd; poll for it.
            let marker = h.perch_path.join("guard.txt");
            let start = Instant::now();
            let contents = loop {
                if let Ok(s) = std::fs::read_to_string(&marker) {
                    if !s.trim().is_empty() {
                        break s;
                    }
                }
                assert!(
                    start.elapsed() < Duration::from_secs(5),
                    "psyche child never wrote its guard marker"
                );
                std::thread::sleep(Duration::from_millis(20));
            };
            assert!(contents.contains('1'), "guard env not set on child: {contents:?}");
        });
    }
}


Wire psyche module + verify `read_info` signature.


Bash

grep -n "pub fn read_info\|pub fn read_pid" crates/spt-store/src/info.rs

Check read_info signature


88:pub fn read_info(perch_path: &Path) -> Option<InfoJson> {
106:pub fn read_pid(perch_path: &Path) -> Option<u32> {


Edit

pub mod history;

pub use history::{fetch_history, HistoryError, HistoryRecord};

pub mod history;
pub mod psyche;

pub use history::{fetch_history, HistoryError, HistoryRecord};
pub use psyche::{compose_psyche_prompt, spawn_psyche, PsycheError, PsycheHandle};


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

pub mod history;
pub mod psyche;

pub use history::{fetch_history, HistoryError, HistoryRecord};
pub use psyche::{compose_psyche_prompt, spawn_psyche, PsycheError, PsycheHandle};

//! spt-core live-agent lifecycle executor (M2b).
//!
//! `spt-live` *executes* the lifecycle seams that `spt-runtime` (M2a) only
//! declared and parsed. It is the fifth layer in the acyclic stack
//! (`spt-proto -> spt-store -> spt-msg -> spt-runtime -> spt-live`); the `spt`
//! binary's `api listen` runner hosts it.
//!
//! Unlike `spt-runtime`, `spt-live` is **not** part of the public SDK surface
//! (R-ARCH-2 is proto/runtime/msg) — it is the daemon-bound executor M3
//! consolidates into `spt-daemon`. Keep every seam thin so that move is a
//! re-host, not a rewrite (mirror M2a's seed-file discipline).
//!
//! Subsystems land across M2b (see `M2b-PLAN.md`):
//! - **history** (T1, [`REQ-SEAM-HISTORY`]) — the three `[history]` strategies
//!   feeding the echo-commune + resume briefs.
//! - **spawn-psyche** (T2, `REQ-SEAM-PSYCHE`) — the nested `<id>-psyche` perch.
//! - **echo-commune** (T3) — the cheap-model brief consuming history.
//! - **drop-file ingest** (T4) — single-writer commune/signoff ingest (6.4/6.5).
//! - **pulse** (T5) — the interim heartbeat in the `api listen` runner.
//! - **signoff/boundary** (T6) — the orphan/grace ordering invariants (1.1/3.2/3.3).
//! - **resume** (T7, `REQ-SEAM-RESUME`) — fresh-with-preload + continue-existing.
//
// The interim no-daemon model holds (decision 2026-06-01): the Psyche is a real
// subprocess and the `api listen` process hosts the pulse/ingest loops. M3 moves
// both into the daemon. See `M2b-PLAN.md` §Interim-model note.

pub mod history;

pub use history::{fetch_history, HistoryError, HistoryRecord};

+pub use psyche::{compose_psyche_prompt, spawn_psyche, PsycheError, PsycheHandle};


Bash

Test spt-live with psyche module


test psyche::tests::spawn_binds_nested_perch_and_sets_guard ... ok
test history::tests::fetcher_timeout_errors ... ok

failures:

---- history::tests::native_reads_logged_records stdout ----

thread 'history::tests::native_reads_logged_records' (61160) panicked at crates\spt-live\src\history.rs:261:13:
assertion `left == right` failed
  left: 0
 right: 2
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

failures:
    history::tests::native_reads_logged_records

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

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


Two separate module locks race on SPT_HOME (the exact cross-module gotcha). Shared single lock needed.


Write

//! Shared test scaffolding for spt-live unit tests.
//!
//! **One** process-wide `SPT_HOME` lock for the whole crate. Per the cross-
//! module env-race gotcha, a per-module lock is *not* enough: every module's
//! `SPT_HOME`-touching test races on the single global env var, so they must all
//! serialize on the same `Mutex`.

use std::path::Path;
use std::sync::Mutex;

static HOME_LOCK: Mutex<()> = Mutex::new(());

/// Run `body` with `SPT_HOME` pointed at a fresh temp dir, serialized against
/// every other `SPT_HOME` test in this binary and restored afterward.
pub(crate) fn with_home<F: FnOnce(&Path)>(body: F) {
    let _g = HOME_LOCK.lock().unwrap_or_else(|p| p.into_inner());
    let prev = std::env::var("SPT_HOME").ok();
    let tmp = tempfile::tempdir().unwrap();
    std::env::set_var("SPT_HOME", tmp.path());
    body(tmp.path());
    match prev {
        Some(v) => std::env::set_var("SPT_HOME", v),
        None => std::env::remove_var("SPT_HOME"),
    }
}


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

//! Shared test scaffolding for spt-live unit tests.
//!
//! **One** process-wide `SPT_HOME` lock for the whole crate. Per the cross-
//! module env-race gotcha, a per-module lock is *not* enough: every module's
//! `SPT_HOME`-touching test races on the single global env var, so they must all
//! serialize on the same `Mutex`.

use std::path::Path;
use std::sync::Mutex;

static HOME_LOCK: Mutex<()> = Mutex::new(());

/// Run `body` with `SPT_HOME` pointed at a fresh temp dir, serialized against
/// every other `SPT_HOME` test in this binary and restored afterward.
pub(crate) fn with_home<F: FnOnce(&Path)>(body: F) {
    let _g = HOME_LOCK.lock().unwrap_or_else(|p| p.into_inner());
    let prev = std::env::var("SPT_HOME").ok();
    let tmp = tempfile::tempdir().unwrap();
    std::env::set_var("SPT_HOME", tmp.path());
    body(tmp.path());
    match prev {
        Some(v) => std::env::set_var("SPT_HOME", v),
        None => std::env::remove_var("SPT_HOME"),
    }
}


Edit

pub mod history;
pub mod psyche;

#[cfg(test)]
mod test_support;


pub mod history;
pub mod psyche;

#[cfg(test)]
mod test_support;

//! spt-core live-agent lifecycle executor (M2b).
//!
//! `spt-live` *executes* the lifecycle seams that `spt-runtime` (M2a) only
//! declared and parsed. It is the fifth layer in the acyclic stack
//! (`spt-proto -> spt-store -> spt-msg -> spt-runtime -> spt-live`); the `spt`
//! binary's `api listen` runner hosts it.
//!
//! Unlike `spt-runtime`, `spt-live` is **not** part of the public SDK surface
//! (R-ARCH-2 is proto/runtime/msg) — it is the daemon-bound executor M3
//! consolidates into `spt-daemon`. Keep every seam thin so that move is a
//! re-host, not a rewrite (mirror M2a's seed-file discipline).
//!
//! Subsystems land across M2b (see `M2b-PLAN.md`):
//! - **history** (T1, [`REQ-SEAM-HISTORY`]) — the three `[history]` strategies
//!   feeding the echo-commune + resume briefs.
//! - **spawn-psyche** (T2, `REQ-SEAM-PSYCHE`) — the nested `<id>-psyche` perch.
//! - **echo-commune** (T3) — the cheap-model brief consuming history.
//! - **drop-file ingest** (T4) — single-writer commune/signoff ingest (6.4/6.5).
//! - **pulse** (T5) — the interim heartbeat in the `api listen` runner.
//! - **signoff/boundary** (T6) — the orphan/grace ordering invariants (1.1/3.2/3.3).
//! - **resume** (T7, `REQ-SEAM-RESUME`) — fresh-with-preload + continue-existing.
//
// The interim no-daemon model holds (decision 2026-06-01): the Psyche is a real
// subprocess and the `api listen` process hosts the pulse/ingest loops. M3 moves
// both into the daemon. See `M2b-PLAN.md` §Interim-model note.

pub mod history;
pub mod psyche;

pub use history::{fetch_history, HistoryError, HistoryRecord};
pub use psyche::{compose_psyche_prompt, spawn_psyche, PsycheError, PsycheHandle};


Now replace both modules' local `with_home`/lock with the shared one.


    use super::*;
    use std::sync::Mutex;
    use std::time::Duration;

    // One process-wide lock serializing every SPT_HOME mutation in this test
    // binary. Per the cross-module env-race gotcha, a per-module lock is not
    // enough — all SPT_HOME-touching tests must share one lock.
    static HOME_LOCK: Mutex<()> = Mutex::new(());

    /// Run `body` with `SPT_HOME` pointed at a fresh temp dir, restored after.
    fn with_home<F: FnOnce(&std::path::Path)>(body: F) {
        let _g = HOME_LOCK.lock().unwrap_or_else(|p| p.into_inner());
        let prev = std::env::var("SPT_HOME").ok();
        let tmp = tempfile::tempdir().unwrap();
        std::env::set_var("SPT_HOME", tmp.path());
        body(tmp.path());
        match prev {
            Some(v) => std::env::set_var("SPT_HOME", v),
            None => std::env::remove_var("SPT_HOME"),
        }
    }

    fn no_keys() -> BTreeMap<String, String> {

    use super::*;
    use crate::test_support::with_home;
    use std::time::Duration;

    fn no_keys() -> BTreeMap<String, String> {


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

    use super::*;
    use std::sync::Mutex;
    use std::time::Duration;

    // One process-wide lock serializing every SPT_HOME mutation in this test
    // binary. Per the cross-module env-race gotcha, a per-module lock is not
    // enough — all SPT_HOME-touching tests must share one lock.
    static HOME_LOCK: Mutex<()> = Mutex::new(());

    /// Run `body` with `SPT_HOME` pointed at a fresh temp dir, restored after.
    fn with_home<F: FnOnce(&std::path::Path)>(body: F) {
        let _g = HOME_LOCK.lock().unwrap_or_else(|p| p.into_inner());
        let prev = std::env::var("SPT_HOME").ok();
        let tmp = tempfile::tempdir().unwrap();
        std::env::set_var("SPT_HOME", tmp.path());
        body(tmp.path());
        match prev {
            Some(v) => std::env::set_var("SPT_HOME", v),
            None => std::env::remove_var("SPT_HOME"),
        }
    }

    fn no_keys() -> BTreeMap<String, String> {

    use super::*;
    use crate::test_support::with_home;
    use std::time::Duration;

    fn no_keys() -> BTreeMap<String, String> {

+    use crate::test_support::with_home;

-    // One process-wide lock serializing every SPT_HOME mutation in this test

-    // binary. Per the cross-module env-race gotcha, a per-module lock is not

-    // enough — all SPT_HOME-touching tests must share one lock.

-    static HOME_LOCK: Mutex<()> = Mutex::new(());

-    /// Run `body` with `SPT_HOME` pointed at a fresh temp dir, restored after.

-    fn with_home<F: FnOnce(&std::path::Path)>(body: F) {

-        let _g = HOME_LOCK.lock().unwrap_or_else(|p| p.into_inner());

-        let prev = std::env::var("SPT_HOME").ok();

-        let tmp = tempfile::tempdir().unwrap();

-        std::env::set_var("SPT_HOME", tmp.path());

-            Some(v) => std::env::set_var("SPT_HOME", v),

-            None => std::env::remove_var("SPT_HOME"),


    use super::*;
    use spt_runtime::{Manifest, ManifestRuntime};
    use std::sync::Mutex;
    use std::time::{Duration, Instant};

    // Shared SPT_HOME lock (cross-module env-race guard — see history::tests).
    static HOME_LOCK: Mutex<()> = Mutex::new(());

    fn with_home<F: FnOnce()>(body: F) {
        let _g = HOME_LOCK.lock().unwrap_or_else(|p| p.into_inner());
        let prev = std::env::var("SPT_HOME").ok();
        let tmp = tempfile::tempdir().unwrap();
        std::env::set_var("SPT_HOME", tmp.path());
        body();
        match prev {
            Some(v) => std::env::set_var("SPT_HOME", v),
            None => std::env::remove_var("SPT_HOME"),
        }
    }

    #[test] // [unit->REQ-SEAM-PSYCHE] the prompt carries the timestamp + event envelope.

    use super::*;
    use crate::test_support::with_home;
    use spt_runtime::{Manifest, ManifestRuntime};
    use std::time::{Duration, Instant};

    #[test] // [unit->REQ-SEAM-PSYCHE] the prompt carries the timestamp + event envelope.


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

    use super::*;
    use crate::test_support::with_home;
    use spt_runtime::{Manifest, ManifestRuntime};
    use std::time::{Duration, Instant};

    #[test] // [unit->REQ-SEAM-PSYCHE] the prompt carries the timestamp + event envelope.

//! spawn-psyche seam (M2b T2) — bring a Self's Psyche companion to life.
//!
//! The Psyche is spawned from the `[session.psyche_init]` template (fresh) or
//! `[session.psyche_resume]` (re-entry — T7) as a **nested `<id>-psyche` perch**
//! (M0 layout: `owlery/<parent>/nested/<id>-psyche/`). spt-core owns exactly one
//! piece of the command's content — the `{psyche_prompt}` substitution: the
//! revival essentials (timestamp + the incoming event envelope) per CONTEXT
//! §spawn-psyche seam. Everything else — model selection, harness preamble —
//! lives in the adapter's opaque template; spt-core selects no model.
//!
//! The recursion guard (`recursion_guard_env`) is set on the child by
//! `ManifestRuntime` so the Psyche's own harness hooks bail instead of spawning
//! a Psyche-of-the-Psyche (KNOWN-HAZARDS — echo-commune recursion).
//
// Clean-room over the M2a spawn seam (the sister's psyche wrapper owns a
// `claude -p` subprocess + `.claude/` paths). Interim no-daemon model: the
// Psyche is a real detached subprocess and spt-core writes its nested perch
// record; M3's daemon re-hosts it as a loop with daemon-authoritative liveness.
// [impl->REQ-SEAM-PSYCHE]

use spt_proto::endpoint::EndpointType;
use spt_runtime::{AgentRuntime, RuntimeError};
use spt_store::info::{write_info, InfoJson};
use spt_store::perch::{resolve_perch_path, ParentHint};
use std::collections::BTreeMap;
use std::path::PathBuf;

/// The role spawned for a fresh Psyche.
pub const PSYCHE_INIT_ROLE: &str = "psyche_init";

/// A spawned Psyche companion: its nested id, OS pid, and perch directory.
#[derive(Debug, Clone)]
pub struct PsycheHandle {
    pub id: String,
    pub pid: u32,
    pub perch_path: PathBuf,
}

/// A spawn-psyche failure. Carries an operator message; never panics.
#[derive(Debug)]
pub enum PsycheError {
    /// Template fill / spawn / role-lookup failure from the runtime.
    Runtime(RuntimeError),
    /// Creating the nested perch dir or writing its `info.json` failed.
    Bind(std::io::Error),
}

impl std::fmt::Display for PsycheError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            PsycheError::Runtime(e) => write!(f, "psyche spawn failed: {e}"),
            PsycheError::Bind(e) => write!(f, "binding the psyche perch failed: {e}"),
        }
    }
}

impl std::error::Error for PsycheError {}

impl From<RuntimeError> for PsycheError {
    fn from(e: RuntimeError) -> Self {
        PsycheError::Runtime(e)
    }
}

/// Compose the **spt-core-owned** `{psyche_prompt}`: the revival essentials fed
/// to every Psyche regardless of adapter — a timestamp and the incoming event
/// envelope (CONTEXT §spawn-psyche seam). Consistent across adapters so the
/// live/project authoring split stays uniform (`docs/CONTEXT-MEMORY.md`); the
/// adapter prepends its own static preamble around this.
pub fn compose_psyche_prompt(now: &str, event: Option<&str>) -> String {
    let mut s = String::with_capacity(64);
    s.push_str("PSYCHE REVIVAL\n");
    s.push_str("time: ");
    s.push_str(now);
    s.push('\n');
    match event {
        Some(ev) if !ev.trim().is_empty() => {
            s.push_str("incoming event:\n");
            s.push_str(ev.trim_end());
            s.push('\n');
        }
        _ => s.push_str("incoming event: (none)\n"),
    }
    s
}

/// Spawn a fresh Psyche for `parent_id` and bind its nested perch.
///
/// `keys` supplies the adapter-template substitution values the caller owns
/// (e.g. `session_name`); spt-core overrides the three it owns for a Psyche:
/// `id` (the nested `<id>-psyche`), `psyche_dir` (the created perch dir, the
/// role's `cwd`), and `psyche_prompt` (composed here). The role's
/// `recursion_guard_env` is applied to the child by the runtime.
pub fn spawn_psyche(
    runtime: &dyn AgentRuntime,
    parent_id: &str,
    keys: &BTreeMap<String, String>,
    event: Option<&str>,
    now: &str,
) -> Result<PsycheHandle, PsycheError> {
    let psyche_id = format!("{parent_id}-psyche");
    let perch_path = resolve_perch_path(&psyche_id, ParentHint::Explicit(parent_id));
    // The nested perch dir must exist before spawn — the role's `cwd` is it.
    std::fs::create_dir_all(&perch_path).map_err(PsycheError::Bind)?;

    let mut k = keys.clone();
    k.insert("id".to_string(), psyche_id.clone());
    k.insert(
        "psyche_dir".to_string(),
        perch_path.to_string_lossy().to_string(),
    );
    k.insert(
        "psyche_prompt".to_string(),
        compose_psyche_prompt(now, event),
    );

    let spawned = runtime.spawn_session(PSYCHE_INIT_ROLE, &k)?;

    // Bind the nested perch: spt-core writes the Psyche's identity/status record.
    // Interim per-pid liveness (M3's daemon makes `status` authoritative).
    let session_id = keys.get("session_id").map(String::as_str).unwrap_or("");
    let mut rec = InfoJson::new(
        &psyche_id,
        now,
        spawned.pid,
        session_id,
        &EndpointType::Psyche.as_tag(),
    );
    rec.parent_pid = None;
    write_info(&perch_path, &rec).map_err(PsycheError::Bind)?;

    Ok(PsycheHandle {
        id: psyche_id,
        pid: spawned.pid,
        perch_path,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use spt_runtime::{Manifest, ManifestRuntime};
    use std::sync::Mutex;
    use std::time::{Duration, Instant};

    // Shared SPT_HOME lock (cross-module env-race guard — see history::tests).
    static HOME_LOCK: Mutex<()> = Mutex::new(());

    fn with_home<F: FnOnce()>(body: F) {
        let _g = HOME_LOCK.lock().unwrap_or_else(|p| p.into_inner());
        let prev = std::env::var("SPT_HOME").ok();
        let tmp = tempfile::tempdir().unwrap();
        std::env::set_var("SPT_HOME", tmp.path());
        body();
        match prev {
            Some(v) => std::env::set_var("SPT_HOME", v),
            None => std::env::remove_var("SPT_HOME"),
        }
    }

    #[test] // [unit->REQ-SEAM-PSYCHE] the prompt carries the timestamp + event envelope.
    fn prompt_includes_time_and_event() {
        let p = compose_psyche_prompt("2026-06-01T00:00:00Z", Some("MSG from alice: hi"));
        assert!(p.contains("time: 2026-06-01T00:00:00Z"));
        assert!(p.contains("MSG from alice: hi"));
    }

    #[test] // [unit->REQ-SEAM-PSYCHE] no event ⇒ an explicit "(none)" envelope.
    fn prompt_without_event() {
        let p = compose_psyche_prompt("t0", None);
        assert!(p.contains("incoming event: (none)"));
    }

    // A psyche_init template that records the recursion-guard env value into a
    // marker file in its cwd (the nested perch dir) — proves both that the perch
    // is the cwd and that the guard env reached the child.
    #[cfg(windows)]
    const GUARD_CMD: &str = "cmd /C echo %SPT_GUARD% > guard.txt";
    #[cfg(unix)]
    const GUARD_CMD: &str = "sh -c \"echo $SPT_GUARD > guard.txt\"";

    fn psyche_manifest() -> Manifest {
        let toml = format!(
            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
             [session.psyche_init]\ncommand=\"{GUARD_CMD}\"\ncwd=\"{{psyche_dir}}\"\n\
             recursion_guard_env=\"SPT_GUARD\"\nkeys=[\"psyche_dir\",\"psyche_prompt\"]\n"
        );
        Manifest::from_toml_str(&toml).unwrap()
    }

    #[test] // [unit->REQ-SEAM-PSYCHE] spawning binds a nested psyche perch + sets the guard env.
    fn spawn_binds_nested_perch_and_sets_guard() {
        with_home(|| {
            let rt = ManifestRuntime::new(psyche_manifest());
            let h = spawn_psyche(&rt, "doyle", &BTreeMap::new(), Some("pulse"), "t0")
                .expect("spawn psyche");

            // Nested layout: owlery/doyle/nested/doyle-psyche/.
            assert_eq!(h.id, "doyle-psyche");
            assert_eq!(
                h.perch_path,
                resolve_perch_path("doyle-psyche", ParentHint::Explicit("doyle"))
            );

            // info.json records the psyche endpoint type + the spawned pid.
            let rec = spt_store::info::read_info(&h.perch_path).expect("info.json");
            assert_eq!(rec.state, "psyche");
            assert_eq!(rec.id, "doyle-psyche");

            // The detached child writes the guard value into its cwd; poll for it.
            let marker = h.perch_path.join("guard.txt");
            let start = Instant::now();
            let contents = loop {
                if let Ok(s) = std::fs::read_to_string(&marker) {
                    if !s.trim().is_empty() {
                        break s;
                    }
                }
                assert!(
                    start.elapsed() < Duration::from_secs(5),
                    "psyche child never wrote its guard marker"
                );
                std::thread::sleep(Duration::from_millis(20));
            };
            assert!(contents.contains('1'), "guard env not set on child: {contents:?}");
        });
    }
}

-    // Shared SPT_HOME lock (cross-module env-race guard — see history::tests).

-    fn with_home<F: FnOnce()>(body: F) {


    fn spawn_binds_nested_perch_and_sets_guard() {
        with_home(|| {

    fn spawn_binds_nested_perch_and_sets_guard() {
        with_home(|_| {