    fn spawn_binds_nested_perch_and_sets_guard() {
        with_home(|_| {

//! 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 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.
    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:?}");
        });
    }
}


Bash

Re-test spt-live with shared lock


test psyche::tests::prompt_includes_time_and_event ... ok
test history::tests::native_reads_logged_records ... ok
test history::tests::fetcher_yields_records ... ok
test history::tests::locate_normalize_reads_and_normalizes ... ok
test psyche::tests::spawn_binds_nested_perch_and_sets_guard ... ok
test history::tests::native_missing_log_is_empty ... ok
test history::tests::fetcher_timeout_errors ... ok

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

   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


Green. Activate REQ-SEAM-PSYCHE, clippy + check, commit T2.


Edit

id = "REQ-SEAM-PSYCHE"
title = "spawn-psyche seam (fresh + resume templates)"
required_stages = []

id = "REQ-SEAM-PSYCHE"
title = "spawn-psyche seam (fresh + resume templates)"
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-PSYCHE"
title = "spawn-psyche seam (fresh + resume templates)"
required_stages = []

id = "REQ-SEAM-PSYCHE"
title = "spawn-psyche seam (fresh + resume templates)"
required_stages = ["impl", "unit"]

title = "History subsystem (fetcher / locate-normalize / native store)"


Bash

cargo clippy -p spt-live --all-targets -- -D warnings 2>&1 | tail -5; echo "=== TRACE ==="; traceable-reqs check > /dev/null 2>&1; echo "check_exit=$?"

Clippy spt-live + traceable check


    Checking spt-live v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-live)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.74s
=== TRACE ===
check_exit=0


git add -A && git commit -q -F - <<'EOF'
feat(spt-live): M2b T2 — spawn-psyche seam (nested perch + owned prompt)

spawn_psyche brings a Self's Psyche companion to life from
[session.psyche_init], bound as a nested <id>-psyche perch (M0 layout). spt-core
owns exactly one slice of the command content — {psyche_prompt}: the revival
essentials (timestamp + incoming event envelope, CONTEXT §spawn-psyche seam),
composed by compose_psyche_prompt and uniform across adapters (the live/project
authoring split, CONTEXT-MEMORY). Model selection stays in the adapter template.

The runtime applies the role's recursion_guard_env to the child so the Psyche's
own hooks bail (no Psyche-of-Psyche). Interim no-daemon model: the Psyche is a
real detached subprocess and spt-core writes its perch info.json (state=psyche,
spawned pid); M3 re-hosts it as a daemon loop with authoritative liveness.

Shared crate-level test_support::with_home gives all SPT_HOME tests ONE lock
(per the cross-module env-race gotcha — per-module locks race on the global
env). 3 unit tests: prompt composition (event / no-event) + a spawn that binds
the nested perch and proves the guard env reached the child. REQ-SEAM-PSYCHE
activated [impl,unit].

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

Commit T2


warning: in the working copy of 'crates/spt-live/src/history.rs', LF will be replaced by CRLF the next time Git touches it
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 '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/psyche.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-live/src/test_support.rs', LF will be replaced by CRLF the next time Git touches it
4b2ee3c feat(spt-live): M2b T2 — spawn-psyche seam (nested perch + owned prompt)


Updated task #3 status


T2 done (3 shipped). T3 — echo-commune. It must run the role bounded *with history on stdin* + recursion guard. Check existing `AgentRuntime` impls + MANIFEST echo spec.


grep -rn "impl AgentRuntime\|run_bounded\|echo_commune\|echo commune\|Source:" crates/ adapters/ docs/MANIFEST.md docs/CONTEXT-MEMORY.md | grep -v "src/spt-live\|spt-live/src" | head -30

Find AgentRuntime impls + echo spec


crates/spt/tests/contract_e2e.rs:71:        .run_bounded("self", &keys(&[("id", id), ("session_id", session_id)]), Duration::from_secs(30))
crates/spt-proto/src/event.rs:13://! - pre-formed typed envelope (e.g. `echo_commune`, `file_drop`) → emitted verbatim
crates/spt-proto/src/event.rs:243:            r#"<EVENT type="echo_commune" from="doyle-psyche" note="brief">hello world</EVENT>"#;
crates/spt-proto/src/event.rs:246:        assert_eq!(p.event_type.as_deref(), Some("echo_commune"));
crates/spt-runtime/src/lib.rs:25:    run_bounded_command, AgentRuntime, BoundedOutput, ManifestRuntime, RuntimeError, Spawned,
crates/spt-runtime/src/manifest.rs:100:    // the `psyche_*`/`echo_commune`/`signoff` roles are parsed here but only
crates/spt-runtime/src/manifest.rs:109:    pub echo_commune: Option<SessionRole>,
crates/spt-runtime/src/manifest.rs:122:            && self.echo_commune.is_none()
crates/spt-runtime/src/manifest.rs:134:            ("echo_commune", &self.echo_commune),
crates/spt-runtime/src/manifest.rs:570:        assert_eq!(m.identity.unwrap().session_id_source, SessionIdSource::PostSpawn);
crates/spt-runtime/src/runtime.rs:178:    fn run_bounded(
crates/spt-runtime/src/runtime.rs:236:impl AgentRuntime for ManifestRuntime {
crates/spt-runtime/src/runtime.rs:253:    fn run_bounded(
crates/spt-runtime/src/runtime.rs:328:/// same kill-on-timeout guarantee as [`AgentRuntime::run_bounded`]
crates/spt-runtime/src/runtime.rs:334:/// lines that live *outside* the role set, so [`ManifestRuntime::run_bounded`]
crates/spt-runtime/src/runtime.rs:339:pub fn run_bounded_command(
crates/spt-runtime/src/runtime.rs:425:            .run_bounded("self", &keys(&[]), Duration::from_secs(10))
crates/spt-runtime/src/runtime.rs:440:            .run_bounded("self", &keys(&[]), Duration::from_millis(300))
docs/MANIFEST.md:49:One opaque command template per role: `self`, `psyche_init`, `psyche_resume`, `echo_commune`, `signoff`. Plus `cwd`, env inject/remove, detachment, and the recursion-guard env var. **Model, tools, flags, dir-grants all live inside `command`** — not as fields.
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.)
docs/MANIFEST.md:160:| Echo-commune | `claude -p --model haiku --effort low …`, `OWL_ECHO_COMMUNE=1` guard | `[session.echo_commune]` + `recursion_guard_env` |
docs/CONTEXT-MEMORY.md:23:4. **Commune-source asymmetry** (the structural fix): the live signal is strong only from **Self-authored communes**; **echo communes are project-centric** (they synthesize project-heavy session logs). So **echo communes are project-context-primary and live-context-conservative** — they make few/no live edits. This kills the leak at its source. Provenance is recorded on each block (`Source:`).
docs/CONTEXT-MEMORY.md:61:Source: self-commune | echo-commune | user   # provenance (drives the live-conservative rule)


T3 needs role-bounded run *with stdin* (history) + recursion guard. Refactor `AgentRuntime`: make `run_bounded_stdin` the primitive, `run_bounded` sugar.


164	/// The spawn-session seam. M2a = local process; M3 daemon = loop/PTY child,
165	/// same trait. A caller holds an `&dyn AgentRuntime` and never learns which.
166	pub trait AgentRuntime {
167	    /// Spawn a **detached** session from a `[session.<role>]` template, filling
168	    /// its substitution keys. Returns once the process exists (REQ-SEAM-SPAWN).
169	    fn spawn_session(
170	        &self,
171	        role: &str,
172	        keys: &BTreeMap<String, String>,
173	    ) -> Result<Spawned, RuntimeError>;
174
175	    /// Run a **bounded** invocation (a metadata-producing role/command) to
176	    /// completion under `timeout`, capturing output. Hung children are killed
177	    /// (REQ-HAZARD-SUBPROCESS-TIMEOUT).
178	    fn run_bounded(
179	        &self,
180	        role: &str,
181	        keys: &BTreeMap<String, String>,
182	        timeout: Duration,
183	    ) -> Result<BoundedOutput, RuntimeError>;
184	}
185

/// The spawn-session seam. M2a = local process; M3 daemon = loop/PTY child,
/// same trait. A caller holds an `&dyn AgentRuntime` and never learns which.
pub trait AgentRuntime {
    /// Spawn a **detached** session from a `[session.<role>]` template, filling
    /// its substitution keys. Returns once the process exists (REQ-SEAM-SPAWN).
    fn spawn_session(
        &self,
        role: &str,
        keys: &BTreeMap<String, String>,
    ) -> Result<Spawned, RuntimeError>;

    /// Run a **bounded** invocation (a metadata-producing role/command) to
    /// completion under `timeout`, capturing output. Hung children are killed
    /// (REQ-HAZARD-SUBPROCESS-TIMEOUT).
    fn run_bounded(
        &self,
        role: &str,
        keys: &BTreeMap<String, String>,
        timeout: Duration,
    ) -> Result<BoundedOutput, RuntimeError>;
}


Edit

    /// Run a **bounded** invocation (a metadata-producing role/command) to
    /// completion under `timeout`, capturing output. Hung children are killed
    /// (REQ-HAZARD-SUBPROCESS-TIMEOUT).
    fn run_bounded(
        &self,
        role: &str,
        keys: &BTreeMap<String, String>,
        timeout: Duration,
    ) -> Result<BoundedOutput, RuntimeError>;
}

    /// Run a **bounded** role invocation to completion under `timeout`, feeding
    /// optional `stdin` and capturing output. Hung children are killed
    /// (REQ-HAZARD-SUBPROCESS-TIMEOUT). `stdin` is the channel the echo-commune
    /// (M2b) feeds normalized history to a cheap-model summarizer template.
    fn run_bounded_stdin(
        &self,
        role: &str,
        keys: &BTreeMap<String, String>,
        timeout: Duration,
        stdin: Option<&[u8]>,
    ) -> Result<BoundedOutput, RuntimeError>;

    /// Bounded role invocation with no stdin — sugar over [`Self::run_bounded_stdin`].
    fn run_bounded(
        &self,
        role: &str,
        keys: &BTreeMap<String, String>,
        timeout: Duration,
    ) -> Result<BoundedOutput, RuntimeError> {
        self.run_bounded_stdin(role, keys, timeout, None)
    }
}


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

    /// Run a **bounded** invocation (a metadata-producing role/command) to
    /// completion under `timeout`, capturing output. Hung children are killed
    /// (REQ-HAZARD-SUBPROCESS-TIMEOUT).
    fn run_bounded(
        &self,
        role: &str,
        keys: &BTreeMap<String, String>,
        timeout: Duration,
    ) -> Result<BoundedOutput, RuntimeError>;
}

    /// Run a **bounded** role invocation to completion under `timeout`, feeding
    /// optional `stdin` and capturing output. Hung children are killed
    /// (REQ-HAZARD-SUBPROCESS-TIMEOUT). `stdin` is the channel the echo-commune
    /// (M2b) feeds normalized history to a cheap-model summarizer template.
    fn run_bounded_stdin(
        &self,
        role: &str,
        keys: &BTreeMap<String, String>,
        timeout: Duration,
        stdin: Option<&[u8]>,
    ) -> Result<BoundedOutput, RuntimeError>;

    /// Bounded role invocation with no stdin — sugar over [`Self::run_bounded_stdin`].
    fn run_bounded(
        &self,
        role: &str,
        keys: &BTreeMap<String, String>,
        timeout: Duration,
    ) -> Result<BoundedOutput, RuntimeError> {
        self.run_bounded_stdin(role, keys, timeout, None)
    }
}

-    /// Run a **bounded** invocation (a metadata-producing role/command) to

-    /// completion under `timeout`, capturing output. Hung children are killed

-    /// (REQ-HAZARD-SUBPROCESS-TIMEOUT).

+    /// Run a **bounded** role invocation to completion under `timeout`, feeding

+    /// optional `stdin` and capturing output. Hung children are killed

+    /// (REQ-HAZARD-SUBPROCESS-TIMEOUT). `stdin` is the channel the echo-commune

+    /// (M2b) feeds normalized history to a cheap-model summarizer template.

+    /// Bounded role invocation with no stdin — sugar over [`Self::run_bounded_stdin`].

+        keys: &BTreeMap<String, String>,

+    ) -> Result<BoundedOutput, RuntimeError> {

+        self.run_bounded_stdin(role, keys, timeout, None)

/// Default [`AgentRuntime`]: substitution-fill a manifest role and spawn it as a


Now update the `ManifestRuntime` impl to provide `run_bounded_stdin` (with stdin piping):


    fn run_bounded(
        &self,
        role: &str,
        keys: &BTreeMap<String, String>,
        timeout: Duration,
    ) -> Result<BoundedOutput, RuntimeError> {
        let role_def = self.role(role)?;
        let mut cmd = self.command_for(role_def, keys)?;
        cmd.stdin(Stdio::null())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped());
        let label = fill_template(&role_def.command, keys)?;
        let child = cmd.spawn().map_err(RuntimeError::Spawn)?;
        wait_bounded(child, timeout, &label)
    }
}

    fn run_bounded_stdin(
        &self,
        role: &str,
        keys: &BTreeMap<String, String>,
        timeout: Duration,
        stdin: Option<&[u8]>,
    ) -> Result<BoundedOutput, RuntimeError> {
        let role_def = self.role(role)?;
        let mut cmd = self.command_for(role_def, keys)?;
        cmd.stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() })
            .stdout(Stdio::piped())
            .stderr(Stdio::piped());
        let label = fill_template(&role_def.command, keys)?;
        let mut child = cmd.spawn().map_err(RuntimeError::Spawn)?;
        if let Some(bytes) = stdin {
            if let Some(mut sink) = child.stdin.take() {
                let owned = bytes.to_vec();
                // Write on a thread + drop the sink (EOF) so the child finishes.
                std::thread::spawn(move || {
                    let _ = sink.write_all(&owned);
                });
            }
        }
        wait_bounded(child, timeout, &label)
    }
}


    fn run_bounded(
        &self,
        role: &str,
        keys: &BTreeMap<String, String>,
        timeout: Duration,
    ) -> Result<BoundedOutput, RuntimeError> {
        let role_def = self.role(role)?;
        let mut cmd = self.command_for(role_def, keys)?;
        cmd.stdin(Stdio::null())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped());
        let label = fill_template(&role_def.command, keys)?;
        let child = cmd.spawn().map_err(RuntimeError::Spawn)?;
        wait_bounded(child, timeout, &label)
    }
}

    fn run_bounded_stdin(
        &self,
        role: &str,
        keys: &BTreeMap<String, String>,
        timeout: Duration,
        stdin: Option<&[u8]>,
    ) -> Result<BoundedOutput, RuntimeError> {
        let role_def = self.role(role)?;
        let mut cmd = self.command_for(role_def, keys)?;
        cmd.stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() })
            .stdout(Stdio::piped())
            .stderr(Stdio::piped());
        let label = fill_template(&role_def.command, keys)?;
        let mut child = cmd.spawn().map_err(RuntimeError::Spawn)?;
        if let Some(bytes) = stdin {
            if let Some(mut sink) = child.stdin.take() {
                let owned = bytes.to_vec();
                // Write on a thread + drop the sink (EOF) so the child finishes.
                std::thread::spawn(move || {
                    let _ = sink.write_all(&owned);
                });
            }
        }
        wait_bounded(child, timeout, &label)
    }
}

+        cmd.stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() })

-        let child = cmd.spawn().map_err(RuntimeError::Spawn)?;

+        let mut child = cmd.spawn().map_err(RuntimeError::Spawn)?;

+            if let Some(mut sink) = child.stdin.take() {

+                let owned = bytes.to_vec();

+                // Write on a thread + drop the sink (EOF) so the child finishes.

+                std::thread::spawn(move || {

+                    let _ = sink.write_all(&owned);


Runtime refactored. Build + test runtime, then write echo module.


Bash

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

Test runtime after trait refactor


   Doc-tests spt_runtime

running 0 tests

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


Updated task #4 status


cargo test -p spt-runtime 2>&1 | grep -E "test result|error" | head

Confirm runtime test count


test manifest::tests::syntactically_broken_toml_is_an_error_not_a_panic ... ok
test runtime::tests::missing_key_errors_before_spawn ... ok
test manifest::tests::unknown_enum_variant_is_an_error_not_a_panic ... ok
test runtime::tests::unknown_role_errors ... ok
test result: ok. 17 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.32s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s


Write echo module. Check spt-store atomic-write export for the drop file.


grep -n "pub mod\|pub use" crates/spt-store/src/lib.rs; echo "---"; grep -n "pub fn atomic_write" crates/spt-store/src/atomic.rs

Check spt-store atomic exports


14:pub mod atomic;
15:pub mod info;
16:pub mod perch;
17:pub mod proc;
18:pub mod registry;
19:pub mod seed;
20:pub mod spool;
---
49:pub fn atomic_write_string(path: &Path, body: &str) -> io::Result<()> {
56:pub fn atomic_write_bytes(path: &Path, body: &[u8]) -> io::Result<()> {


Write

//! echo-commune seam (M2b T3) — the cheap-model brief that keeps a Psyche's
//! mind warm between live sessions.
//!
//! spt-core spawns the `[session.echo_commune]` template (a cheap model — the
//! adapter picks it; `claude -p --model haiku …` in the reference manifest),
//! feeding it the normalized **history** (T1) on stdin. Its stdout is written
//! as a **commune drop-file** (`<id>-commune.md`) that the drop-file ingest (T4)
//! later folds into the perch's context.
//!
//! ## Provenance — the anti-leak rule (`docs/CONTEXT-MEMORY.md`)
//! Echo communes synthesize *project-heavy* session logs, so they are
//! **project-context-primary / live-context-conservative** — strong project
//! signal, few/no live edits. Every commune the echo writes is tagged
//! `Source: echo-commune` so the later memformat pass (deferred) inherits the
//! correct tiering. Getting provenance right now is cheap; retrofitting is not.
//!
//! ## Recursion guard
//! The echo child runs with the role's `recursion_guard_env` set (applied by
//! `ManifestRuntime`), so its *own* harness hooks bail instead of firing a
//! second echo (no echo-of-echo).
//
// Clean-room over the M2a bounded seam; spt-core writes the drop file (it is the
// single writer — KNOWN-HAZARDS 6.4), the mind never does.
// [impl->REQ-SEAM-HISTORY]
// [impl->REQ-HAZARD-DROP-FILE-SINGLE-WRITER]

use crate::history::HistoryRecord;
use spt_runtime::{AgentRuntime, RuntimeError};
use spt_store::atomic::atomic_write_string;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::Duration;

/// The role spawned for the echo-commune brief.
pub const ECHO_COMMUNE_ROLE: &str = "echo_commune";

/// Provenance tag stamped on echo-authored communes (drives the
/// live-conservative tiering — `docs/CONTEXT-MEMORY.md`).
pub const SOURCE_ECHO_COMMUNE: &str = "echo-commune";

/// Outcome of an echo run: the written drop-file path and the brief body.
#[derive(Debug, Clone)]
pub struct EchoResult {
    pub drop_path: PathBuf,
    pub body: String,
}

/// An echo-commune failure. Carries an operator message; never panics, never
/// hangs (the summarizer runs bounded).
#[derive(Debug)]
pub enum EchoError {
    /// Template fill / spawn / timeout failure from the runtime.
    Runtime(RuntimeError),
    /// The summarizer exited non-zero (its stderr, for the operator).
    NonZero { code: Option<i32>, stderr: String },
    /// Writing the commune drop-file failed.
    Write(std::io::Error),
}

impl std::fmt::Display for EchoError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            EchoError::Runtime(e) => write!(f, "echo-commune spawn failed: {e}"),
            EchoError::NonZero { code, stderr } => {
                write!(f, "echo-commune exited {code:?}: {}", stderr.trim())
            }
            EchoError::Write(e) => write!(f, "writing the commune drop-file failed: {e}"),
        }
    }
}

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

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

/// Stamp the spt-core-owned provenance header onto a commune body. One header
/// line drives the tiering at ingest; the body is the adapter summarizer's
/// verbatim output.
pub fn stamp_provenance(source: &str, body: &str) -> String {
    format!("Source: {source}\n{}\n", body.trim_end())
}

/// Run the echo-commune: feed `history` to the `[session.echo_commune]`
/// summarizer (bounded), then write its brief as the `<id>-commune.md` drop-file
/// under `commune_dir`, tagged `Source: echo-commune`. spt-core is the **single
/// writer** of that file (6.4).
pub fn run_echo_commune(
    runtime: &dyn AgentRuntime,
    id: &str,
    keys: &BTreeMap<String, String>,
    history: &[HistoryRecord],
    commune_dir: &Path,
    timeout: Duration,
) -> Result<EchoResult, EchoError> {
    // Feed normalized history (newline-delimited) on stdin.
    let mut stdin = String::new();
    for r in history {
        stdin.push_str(&r.raw);
        stdin.push('\n');
    }
    let out = runtime.run_bounded_stdin(ECHO_COMMUNE_ROLE, keys, timeout, Some(stdin.as_bytes()))?;
    if !out.success() {
        return Err(EchoError::NonZero {
            code: out.status_code,
            stderr: out.stderr,
        });
    }

    let content = stamp_provenance(SOURCE_ECHO_COMMUNE, &out.stdout);
    std::fs::create_dir_all(commune_dir).map_err(EchoError::Write)?;
    let drop_path = commune_dir.join(format!("{id}-commune.md"));
    atomic_write_string(&drop_path, &content).map_err(EchoError::Write)?;

    Ok(EchoResult {
        drop_path,
        body: out.stdout,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use spt_runtime::{Manifest, ManifestRuntime};

    // stdin → stdout pass-through (the "summarizer" echoes the history it read).
    #[cfg(windows)]
    const PASSTHROUGH: &str = "cmd /C findstr /r \".\"";
    #[cfg(unix)]
    const PASSTHROUGH: &str = "cat";
    // Emits the recursion-guard env value (what an adapter hook keys off to bail).
    #[cfg(windows)]
    const GUARD_ECHO: &str = "cmd /C echo guard=%SPT_ECHO_COMMUNE%";
    #[cfg(unix)]
    const GUARD_ECHO: &str = "sh -c \"echo guard=$SPT_ECHO_COMMUNE\"";
    #[cfg(windows)]
    const FAIL: &str = "cmd /C exit 1";
    #[cfg(unix)]
    const FAIL: &str = "sh -c \"exit 1\"";

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

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

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

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

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

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

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


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

//! echo-commune seam (M2b T3) — the cheap-model brief that keeps a Psyche's
//! mind warm between live sessions.
//!
//! spt-core spawns the `[session.echo_commune]` template (a cheap model — the
//! adapter picks it; `claude -p --model haiku …` in the reference manifest),
//! feeding it the normalized **history** (T1) on stdin. Its stdout is written
//! as a **commune drop-file** (`<id>-commune.md`) that the drop-file ingest (T4)
//! later folds into the perch's context.
//!
//! ## Provenance — the anti-leak rule (`docs/CONTEXT-MEMORY.md`)
//! Echo communes synthesize *project-heavy* session logs, so they are
//! **project-context-primary / live-context-conservative** — strong project
//! signal, few/no live edits. Every commune the echo writes is tagged
//! `Source: echo-commune` so the later memformat pass (deferred) inherits the
//! correct tiering. Getting provenance right now is cheap; retrofitting is not.
//!
//! ## Recursion guard
//! The echo child runs with the role's `recursion_guard_env` set (applied by
//! `ManifestRuntime`), so its *own* harness hooks bail instead of firing a
//! second echo (no echo-of-echo).
//
// Clean-room over the M2a bounded seam; spt-core writes the drop file (it is the
// single writer — KNOWN-HAZARDS 6.4), the mind never does.
// [impl->REQ-SEAM-HISTORY]
// [impl->REQ-HAZARD-DROP-FILE-SINGLE-WRITER]

use crate::history::HistoryRecord;
use spt_runtime::{AgentRuntime, RuntimeError};
use spt_store::atomic::atomic_write_string;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::Duration;

/// The role spawned for the echo-commune brief.
pub const ECHO_COMMUNE_ROLE: &str = "echo_commune";

/// Provenance tag stamped on echo-authored communes (drives the
/// live-conservative tiering — `docs/CONTEXT-MEMORY.md`).
pub const SOURCE_ECHO_COMMUNE: &str = "echo-commune";

/// Outcome of an echo run: the written drop-file path and the brief body.
#[derive(Debug, Clone)]
pub struct EchoResult {
    pub drop_path: PathBuf,
    pub body: String,
}

/// An echo-commune failure. Carries an operator message; never panics, never
/// hangs (the summarizer runs bounded).
#[derive(Debug)]
pub enum EchoError {
    /// Template fill / spawn / timeout failure from the runtime.
    Runtime(RuntimeError),
    /// The summarizer exited non-zero (its stderr, for the operator).
    NonZero { code: Option<i32>, stderr: String },
    /// Writing the commune drop-file failed.
    Write(std::io::Error),
}

impl std::fmt::Display for EchoError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            EchoError::Runtime(e) => write!(f, "echo-commune spawn failed: {e}"),
            EchoError::NonZero { code, stderr } => {
                write!(f, "echo-commune exited {code:?}: {}", stderr.trim())
            }
            EchoError::Write(e) => write!(f, "writing the commune drop-file failed: {e}"),
        }
    }
}

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

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

/// Stamp the spt-core-owned provenance header onto a commune body. One header
/// line drives the tiering at ingest; the body is the adapter summarizer's
/// verbatim output.
pub fn stamp_provenance(source: &str, body: &str) -> String {
    format!("Source: {source}\n{}\n", body.trim_end())
}

/// Run the echo-commune: feed `history` to the `[session.echo_commune]`
/// summarizer (bounded), then write its brief as the `<id>-commune.md` drop-file
/// under `commune_dir`, tagged `Source: echo-commune`. spt-core is the **single
/// writer** of that file (6.4).
pub fn run_echo_commune(
    runtime: &dyn AgentRuntime,
    id: &str,
    keys: &BTreeMap<String, String>,
    history: &[HistoryRecord],
    commune_dir: &Path,
    timeout: Duration,
) -> Result<EchoResult, EchoError> {
    // Feed normalized history (newline-delimited) on stdin.
    let mut stdin = String::new();
    for r in history {
        stdin.push_str(&r.raw);
        stdin.push('\n');
    }
    let out = runtime.run_bounded_stdin(ECHO_COMMUNE_ROLE, keys, timeout, Some(stdin.as_bytes()))?;
    if !out.success() {
        return Err(EchoError::NonZero {
            code: out.status_code,
            stderr: out.stderr,
        });
    }

    let content = stamp_provenance(SOURCE_ECHO_COMMUNE, &out.stdout);
    std::fs::create_dir_all(commune_dir).map_err(EchoError::Write)?;
    let drop_path = commune_dir.join(format!("{id}-commune.md"));
    atomic_write_string(&drop_path, &content).map_err(EchoError::Write)?;

    Ok(EchoResult {
        drop_path,
        body: out.stdout,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use spt_runtime::{Manifest, ManifestRuntime};

    // stdin → stdout pass-through (the "summarizer" echoes the history it read).
    #[cfg(windows)]
    const PASSTHROUGH: &str = "cmd /C findstr /r \".\"";
    #[cfg(unix)]
    const PASSTHROUGH: &str = "cat";
    // Emits the recursion-guard env value (what an adapter hook keys off to bail).
    #[cfg(windows)]
    const GUARD_ECHO: &str = "cmd /C echo guard=%SPT_ECHO_COMMUNE%";
    #[cfg(unix)]
    const GUARD_ECHO: &str = "sh -c \"echo guard=$SPT_ECHO_COMMUNE\"";
    #[cfg(windows)]
    const FAIL: &str = "cmd /C exit 1";
    #[cfg(unix)]
    const FAIL: &str = "sh -c \"exit 1\"";

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

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

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

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

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

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

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


Edit

pub mod history;
pub mod psyche;

#[cfg(test)]
mod test_support;

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

pub mod echo;
pub mod history;
pub mod psyche;

#[cfg(test)]
mod test_support;

pub use echo::{run_echo_commune, stamp_provenance, EchoError, EchoResult};
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 echo;
pub mod history;
pub mod psyche;

#[cfg(test)]
mod test_support;

pub use echo::{run_echo_commune, stamp_provenance, EchoError, EchoResult};
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 mod psyche;

#[cfg(test)]
mod test_support;

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

+pub use echo::{run_echo_commune, stamp_provenance, EchoError, EchoResult};


Bash

Test spt-live with echo module


failures:

---- echo::tests::echo_consumes_history_and_writes_commune stdout ----

thread 'echo::tests::echo_consumes_history_and_writes_commune' (37052) panicked at crates\spt-live\src\echo.rs:154:40:
called `Result::unwrap()` on an `Err` value: Parse(TomlError { message: "expected newline, `#`", raw: Some("[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n[session.echo_commune]\ncommand=\"cmd /C findstr /r \".\"\"\n"), keys: [], span: Some(111..112) })
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

failures:
    echo::tests::echo_consumes_history_and_writes_commune

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

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


Embedded quotes in `findstr "."` break the TOML manifest string. Use `sort` (reads stdin, no quotes) for the windows passthrough.


Edit

    // stdin → stdout pass-through (the "summarizer" echoes the history it read).
    #[cfg(windows)]
    const PASSTHROUGH: &str = "cmd /C findstr /r \".\"";
    #[cfg(unix)]
    const PASSTHROUGH: &str = "cat";

    // stdin → stdout pass-through (the "summarizer" echoes the history it read).
    // `sort` reads stdin with no file arg + needs no embedded quotes in the TOML
    // manifest (order-independent: the assertions check membership, not order).
    #[cfg(windows)]
    const PASSTHROUGH: &str = "sort";
    #[cfg(unix)]
    const PASSTHROUGH: &str = "cat";


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

    // stdin → stdout pass-through (the "summarizer" echoes the history it read).
    // `sort` reads stdin with no file arg + needs no embedded quotes in the TOML
    // manifest (order-independent: the assertions check membership, not order).
    #[cfg(windows)]
    const PASSTHROUGH: &str = "sort";
    #[cfg(unix)]
    const PASSTHROUGH: &str = "cat";

//! echo-commune seam (M2b T3) — the cheap-model brief that keeps a Psyche's
//! mind warm between live sessions.
//!
//! spt-core spawns the `[session.echo_commune]` template (a cheap model — the
//! adapter picks it; `claude -p --model haiku …` in the reference manifest),
//! feeding it the normalized **history** (T1) on stdin. Its stdout is written
//! as a **commune drop-file** (`<id>-commune.md`) that the drop-file ingest (T4)
//! later folds into the perch's context.
//!
//! ## Provenance — the anti-leak rule (`docs/CONTEXT-MEMORY.md`)
//! Echo communes synthesize *project-heavy* session logs, so they are
//! **project-context-primary / live-context-conservative** — strong project
//! signal, few/no live edits. Every commune the echo writes is tagged
//! `Source: echo-commune` so the later memformat pass (deferred) inherits the
//! correct tiering. Getting provenance right now is cheap; retrofitting is not.
//!
//! ## Recursion guard
//! The echo child runs with the role's `recursion_guard_env` set (applied by
//! `ManifestRuntime`), so its *own* harness hooks bail instead of firing a
//! second echo (no echo-of-echo).
//
// Clean-room over the M2a bounded seam; spt-core writes the drop file (it is the
// single writer — KNOWN-HAZARDS 6.4), the mind never does.
// [impl->REQ-SEAM-HISTORY]
// [impl->REQ-HAZARD-DROP-FILE-SINGLE-WRITER]

use crate::history::HistoryRecord;
use spt_runtime::{AgentRuntime, RuntimeError};
use spt_store::atomic::atomic_write_string;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::Duration;

/// The role spawned for the echo-commune brief.
pub const ECHO_COMMUNE_ROLE: &str = "echo_commune";

/// Provenance tag stamped on echo-authored communes (drives the
/// live-conservative tiering — `docs/CONTEXT-MEMORY.md`).
pub const SOURCE_ECHO_COMMUNE: &str = "echo-commune";

/// Outcome of an echo run: the written drop-file path and the brief body.
#[derive(Debug, Clone)]
pub struct EchoResult {
    pub drop_path: PathBuf,
    pub body: String,
}

/// An echo-commune failure. Carries an operator message; never panics, never
/// hangs (the summarizer runs bounded).
#[derive(Debug)]
pub enum EchoError {
    /// Template fill / spawn / timeout failure from the runtime.
    Runtime(RuntimeError),
    /// The summarizer exited non-zero (its stderr, for the operator).
    NonZero { code: Option<i32>, stderr: String },
    /// Writing the commune drop-file failed.
    Write(std::io::Error),
}

impl std::fmt::Display for EchoError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            EchoError::Runtime(e) => write!(f, "echo-commune spawn failed: {e}"),
            EchoError::NonZero { code, stderr } => {
                write!(f, "echo-commune exited {code:?}: {}", stderr.trim())
            }
            EchoError::Write(e) => write!(f, "writing the commune drop-file failed: {e}"),
        }
    }
}

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

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

/// Stamp the spt-core-owned provenance header onto a commune body. One header
/// line drives the tiering at ingest; the body is the adapter summarizer's
/// verbatim output.
pub fn stamp_provenance(source: &str, body: &str) -> String {
    format!("Source: {source}\n{}\n", body.trim_end())
}

/// Run the echo-commune: feed `history` to the `[session.echo_commune]`
/// summarizer (bounded), then write its brief as the `<id>-commune.md` drop-file
/// under `commune_dir`, tagged `Source: echo-commune`. spt-core is the **single
/// writer** of that file (6.4).
pub fn run_echo_commune(
    runtime: &dyn AgentRuntime,
    id: &str,
    keys: &BTreeMap<String, String>,
    history: &[HistoryRecord],
    commune_dir: &Path,
    timeout: Duration,
) -> Result<EchoResult, EchoError> {
    // Feed normalized history (newline-delimited) on stdin.
    let mut stdin = String::new();
    for r in history {
        stdin.push_str(&r.raw);
        stdin.push('\n');
    }
    let out = runtime.run_bounded_stdin(ECHO_COMMUNE_ROLE, keys, timeout, Some(stdin.as_bytes()))?;
    if !out.success() {
        return Err(EchoError::NonZero {
            code: out.status_code,
            stderr: out.stderr,
        });
    }

    let content = stamp_provenance(SOURCE_ECHO_COMMUNE, &out.stdout);
    std::fs::create_dir_all(commune_dir).map_err(EchoError::Write)?;
    let drop_path = commune_dir.join(format!("{id}-commune.md"));
    atomic_write_string(&drop_path, &content).map_err(EchoError::Write)?;

    Ok(EchoResult {
        drop_path,
        body: out.stdout,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use spt_runtime::{Manifest, ManifestRuntime};

    // stdin → stdout pass-through (the "summarizer" echoes the history it read).
    #[cfg(windows)]
    const PASSTHROUGH: &str = "cmd /C findstr /r \".\"";
    #[cfg(unix)]
    const PASSTHROUGH: &str = "cat";
    // Emits the recursion-guard env value (what an adapter hook keys off to bail).
    #[cfg(windows)]
    const GUARD_ECHO: &str = "cmd /C echo guard=%SPT_ECHO_COMMUNE%";
    #[cfg(unix)]
    const GUARD_ECHO: &str = "sh -c \"echo guard=$SPT_ECHO_COMMUNE\"";
    #[cfg(windows)]
    const FAIL: &str = "cmd /C exit 1";
    #[cfg(unix)]
    const FAIL: &str = "sh -c \"exit 1\"";

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

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

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

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

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

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

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

+    // `sort` reads stdin with no file arg + needs no embedded quotes in the TOML

+    // manifest (order-independent: the assertions check membership, not order).

-    const PASSTHROUGH: &str = "cmd /C findstr /r \".\"";


Bash

Re-test spt-live


test result: ok. 13 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


13 green. Full workspace test + clippy + check (trait refactor touched shared runtime).


cargo test --workspace 2>&1 | grep -E "test result: FAILED|error\[|FAILED|result: ok" | grep -v "0 passed; 0 failed" | tail -15; echo "=== CLIPPY ==="; cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -3; echo "=== TRACE ==="; traceable-reqs check 2>&1 | tail -5; echo "check_exit=$?"

Full workspace test + clippy + check


test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.67s
test result: ok. 13 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.37s
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 10.60s
=== CLIPPY ===
    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.93s
=== TRACE ===
       No endpoint process terminates/suspends during self-update
  [OK] REQ-UPD-4  required: []  stages: -doc -impl -unit -int
       Update gated on user confirmation by default; opt-in full-auto
  [OK] REQ-UPD-5  required: []  stages: -doc -impl -unit -int
       spt-core ripple-updates registered adapters
check_exit=0


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.


All green, check exit 0. Commit T3.


git add -A && git commit -q -F - <<'EOF'
feat(spt-live): M2b T3 — echo-commune seam (history brief → commune drop)

run_echo_commune spawns [session.echo_commune] (cheap-model template; the
adapter picks the model), feeds it the normalized history (T1) on stdin, and
writes its brief as the <id>-commune.md drop-file — spt-core the single writer
(6.4), the mind never. Every commune is stamped `Source: echo-commune` so the
later memformat pass inherits the project-primary / live-conservative tiering
(CONTEXT-MEMORY anti-leak rule). The echo child runs with the role's
recursion_guard_env set so its own hooks bail (no echo-of-echo).

Runtime: AgentRuntime gains run_bounded_stdin (the bounded primitive, now
stdin-feedable for the history channel); run_bounded becomes sugar over it. One
timeout/kill path still governs all bounded role invocations (5.3).

4 unit tests: history consumed → single-writer commune with provenance; the
echo child is recursion-guarded; a failing summarizer surfaces NonZero with no
half-written drop; provenance stamping. Evidence tagged for REQ-SEAM-HISTORY +
REQ-HAZARD-DROP-FILE-SINGLE-WRITER (hazard activated at T4).

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

Commit T3


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/runtime.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-live/src/echo.rs', LF will be replaced by CRLF the next time Git touches it
c8bfe6e feat(spt-live): M2b T3 — echo-commune seam (history brief → commune drop)


Four shipped (T0–T3). T4 is the hazard-heavy ingest. Read KNOWN-HAZARDS 6.4/6.5 + the context-store target precisely.


sed -n '/6.4/,/6.6/p' docs/KNOWN-HAZARDS.md | head -80

Read hazards 6.4 + 6.5


### 6.4 Drop files are single-writer (supervisor-owned), read-only for the mind
- **Failure:** the Psyche LLM deletes a commune drop file the wrapper is concurrently reading → race, lost commune.
- **Invariant:** drop files (`<id>-commune.md`) are supervisor-owned single-writer; the LLM is read-only; only the supervisor (or explicit operator) deletes them.
- **spt-core mapping:** the daemon is the single writer; the harness-invoked mind never mutates drop files. Bake the ownership into the runtime contract.
- **Sister cite:** CHANGELOG v1.11.7; `src/live/context.rs:615` (removed delete).

### 6.5 Direct-write precedence guard against stale LLM overwrites
- **Failure:** the LLM emits an older snapshot that clobbers a fresher direct write to a context file.
- **Invariant:** every context write carries a source+timestamp precedence marker; LLM writes within a protection window after a recent direct write are suppressed (logged); direct writes always proceed.
- **spt-core mapping:** cross-node Psyche sync (ADR-0003) makes this multi-writer across machines — the precedence marker must include node identity, and the freshness rule (newest-and-newer-than-mine, per the cross-instance context-freshness feature) is the distributed generalization of this guard. Highest-value carryover for the sync design.
- **Sister cite:** CHANGELOG v1.11.6; `src/owl/echo_commune.rs`.

---

## 7. Boundary & delivery integrity (added 2026-05-31 — Stage A red-team)

These were absent from the sister-project harvest; codex surfaced them as load-bearing gaps for spt-core's new daemon/network surface.

### 7.1 Local `api` mutation auth  `[REQ-HAZARD-LOCAL-API-AUTH]`
- **Failure:** any local process calls `spt api bind|state|session-end|history-log|poll` and binds, ends, injects, or spoofs the state of an endpoint it does not own. Local untrusted processes are explicitly in scope (shells, third-party adapters).
- **Invariant:** every `api` *mutation* is authenticated to an endpoint/session — per-endpoint link token or OS-credential binding. An unrelated local process cannot bind, inject, end, or spoof state.
- **spt-core mapping:** the `api` subcommand surface (PRD R-API-*) + broker IPC.
- **Source:** codex Stage A #13 (`docs/reviews/STAGE-A-codex-redteam.md`).

### 7.2 Idempotent delivery across brain restart  `[REQ-HAZARD-RESTART-IDEMPOTENT]`
- **Failure:** broker queues, spool rows, PTY injection, remote streams, and file transfers cross the broker↔brain boundary; a brain restart duplicates or drops a side effect.
- **Invariant:** every side effect crossing the boundary carries a durable ID + replay rule; replay after restart is exactly-once / idempotent. Crash the brain before/after spool write, before/after PTY write, mid-transfer, mid-registry update — no dup, no drop.
- **spt-core mapping:** broker↔brain IPC (ADR-0004), self-update handoff.
- **Source:** codex Stage A #14.

---

## Conformance checklist (condensed)

| # | Invariant | spt-core surface |
|---|---|---|
| 1.1 | Grace wait precedes INIT_SIGNOFF | daemon teardown |
| 1.4/4.4 | Deferred rows excluded from event-stream drain | daemon spool drain |
| 2.1/5.1 | Stable PID/broker-handle over ephemeral PID | liveness detection |
| 2.3 | Handoff argv/IPC version-tolerant (newer brain ↔ older broker) | broker↔brain IPC, self-update |
| 2.4 | gen_start = now() on cold-start + handoff | per-instance generation |
| 3.1 | Ephemeral perch cleanup on all exit paths | `ring` (RAII guard) |
| 3.3 | Echo-commune before INIT_SIGNOFF | daemon psyche loop |
| 4.1 | Envelope decode order, `&` last | spt-proto (public, wire-versioned) |
| 4.2 | Parser panic-free + tolerant | spt-proto |
| 4.3 | Stale registry entries → fallback, never hard-fail | subnet registry resolution |
| 5.2 | tmp-write + atomic-rename + retry (EBUSY) | all state writes, binary swap |
| 5.3 | Timeout every harness subprocess | manifest invocations |
| 5.4 | Strip UNC prefix on serialized paths | spt-proto path normalization |
| 5.5 | ConPTY reader answers DSR (`ESC[6n`) | spt-term broker PTY reader |
| 6.1 | Single path/registry source of truth | storage layout |
| 6.4 | Drop files supervisor-owned single-writer | runtime contract |
| 6.5 | Direct-write precedence marker (+ node id) | cross-node Psyche sync |
| 7.1 | Local `api` mutation authenticated to endpoint | api surface / broker IPC |
| 7.2 | Idempotent delivery across brain restart | broker↔brain IPC |


grep -rn "context store\|context.md\|context file\|context block\|precedence\|protection window\|Source:\|Updated:\|provenance" docs/CONTEXT-MEMORY.md docs/STORAGE.md | head -40

Find context store + precedence marker spec


docs/CONTEXT-MEMORY.md:23:4. **Commune-source asymmetry** (the structural fix): the live signal is strong only from **Self-authored communes**; **echo communes are project-centric** (they synthesize project-heavy session logs). So **echo communes are project-context-primary and live-context-conservative** — they make few/no live edits. This kills the leak at its source. Provenance is recorded on each block (`Source:`).
docs/CONTEXT-MEMORY.md:47:- **Tier-tagged topics.** Each topic carries a tier: `live` or `project`. The tier is decided **once, when the topic is created** (apply the discriminator one time), not per fragment. Communes then just *fill* topics; the tier routes content to the right context file + sync tier. The two-slice envelope (`<live-context>`/`<project-context>`) becomes the *rendering* of tier-tagged topics.
docs/CONTEXT-MEMORY.md:61:Source: self-commune | echo-commune | user   # provenance (drives the live-conservative rule)
docs/CONTEXT-MEMORY.md:63:Updated: <iso>
docs/CONTEXT-MEMORY.md:70:`Concepts`/`Aliases` make context **retrievable**; `Applies to` enables fine-grained scoping + query-routing; `Source` carries provenance; `Pinned` drives start-injection density (below).
docs/CONTEXT-MEMORY.md:92:pi-agent-memory's **human-in-the-loop approval** (extract → review → approve, max-5-candidates) and **bash-mutation guardrails** fit its "human curates" philosophy. SPT's Psyche is **trusted to author its own mind autonomously** — so we adopt pi's *structure* (blocks, INDEX, bounded awareness, provenance, OCC) but **not** its approval gate. Controlled mutation is already covered by the drop-file single-writer rule (KNOWN-HAZARDS 6.4).
docs/STORAGE.md:24:      live-context.md            per-agent live context           (branch a-<endpoint_id>)
docs/STORAGE.md:27:        project-context.md       per-(agent,project) context      (branch p-<project_id>)
docs/STORAGE.md:50:- **Two-tier sync maps onto the structure:** `agents/<id>/live-context.md` (the `a-` view) syncs to **all** instances of the endpoint; `projects/<project_id>/<id>/project-context.md` (the `p-` view) syncs only to instances sharing that project.
docs/STORAGE.md:51:- **P2P replaces the *remote*, not the local git.** Modern SPT pushed this repo to a private GitHub remote; spt-core keeps the local git tracking + branch views and moves the data over the built-in P2P layer instead (no `gh`/account/setup). The precedence/freshness guard (KNOWN-HAZARDS 6.5) — with node identity in the marker — arbitrates concurrent writes from multiple nodes/instances.
docs/STORAGE.md:55:**Single-writer invariant.** Each context file has exactly one logical writer — the owning agent's *active* instance (the active-instance-authoritative rule). Two nodes never legitimately write the same file simultaneously, so sync is mostly **propagation**, and disjoint files (node A has `agents/ling`, node B has `agents/momo`) simply **union**.
docs/STORAGE.md:57:**Merge = precedence marker, never raw 3-way.** A raw git merge of LLM-authored markdown would inject `<<<<<<<` conflict markers into an agent's mind. Instead `tracked/` ships a `.gitattributes` declaring context files use a **custom spt merge driver** that resolves by the precedence marker on every write — `<!-- spt:source=direct|llm spt:routed_at=<iso> spt:node=<node_id> -->` (node-id added for cross-node) — **newest wins, per file**. Never a conflict marker. The marker is the merge authority; git topology is just versioning + the branch-worktree views.


Full model clear. Build T4 in two parts: a precedence-guarded context store + the ingest (read→guarded-write→delete).


Updated task #5 status


Write

//! Precedence-guarded context store (M2b T4, KNOWN-HAZARDS 6.5).
//!
//! Every context write carries a **precedence marker** — `source` (`direct` vs
//! `llm`) + a `routed_at` timestamp — recorded as the file's first line:
//!
//! ```text
//! <!-- spt:source=llm spt:routed_at_ms=1717200000000 -->
//! ```
//!
//! The guard: a **direct** write always proceeds; an **llm** write that lands
//! within the *protection window* after a recent **direct** write is suppressed
//! (and logged), so a stale LLM snapshot never clobbers a fresher direct write.
//!
//! ## Interim vs M4
//! 6.5's full form adds **node identity** to the marker and generalizes to the
//! distributed *newest-and-newer-than-mine* rule (ADR-0003, M4). M2b lands the
//! **local** guard only: one node, `source` + `routed_at` (epoch-ms for exact
//! window math; the ISO/`spt:node` form is the M4 sync concern). Keep the marker
//! parse/render here so the M4 generalization is an extension, not a rewrite.
//
// [impl->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE]

use spt_store::atomic::atomic_write_string;
use std::path::Path;

/// Default protection window: an llm write within this long after a direct write
/// is suppressed. Interim default; per-endpoint override is a later knob.
pub const DEFAULT_PROTECTION_WINDOW_MS: u64 = 60_000;

/// Who authored a context write. `Direct` = spt-core / operator / sync
/// propagation (authoritative, fresh); `Llm` = a model snapshot (possibly
/// stale) — the case the guard protects against.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WriteSource {
    Direct,
    Llm,
}

impl WriteSource {
    fn as_str(self) -> &'static str {
        match self {
            WriteSource::Direct => "direct",
            WriteSource::Llm => "llm",
        }
    }
    fn from_str(s: &str) -> Option<WriteSource> {
        match s {
            "direct" => Some(WriteSource::Direct),
            "llm" => Some(WriteSource::Llm),
            _ => None,
        }
    }
}

/// The precedence marker stamped as a context file's first line.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Marker {
    pub source: WriteSource,
    pub routed_at_ms: u64,
}

impl Marker {
    /// Render the marker as its HTML-comment first line (no trailing newline).
    pub fn render(&self) -> String {
        format!(
            "<!-- spt:source={} spt:routed_at_ms={} -->",
            self.source.as_str(),
            self.routed_at_ms
        )
    }

    /// Parse a marker from a context file's first line. Returns `None` if the
    /// line isn't a well-formed marker (an unmarked / hand-edited file ⇒ no
    /// prior precedence, so any write proceeds).
    pub fn parse(line: &str) -> Option<Marker> {
        let inner = line.trim().strip_prefix("<!--")?.strip_suffix("-->")?.trim();
        let mut source = None;
        let mut routed_at_ms = None;
        for tok in inner.split_whitespace() {
            if let Some(v) = tok.strip_prefix("spt:source=") {
                source = WriteSource::from_str(v);
            } else if let Some(v) = tok.strip_prefix("spt:routed_at_ms=") {
                routed_at_ms = v.parse::<u64>().ok();
            }
        }
        Some(Marker {
            source: source?,
            routed_at_ms: routed_at_ms?,
        })
    }
}

/// Result of a guarded write.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WriteOutcome {
    /// The write landed; the new marker timestamp.
    Written { routed_at_ms: u64 },
    /// An llm write was suppressed within the protection window of a direct
    /// write; the file is unchanged.
    Suppressed { direct_at_ms: u64 },
}

/// Decide whether a write proceeds, given the existing marker (if any). Pure —
/// the testable core of the 6.5 guard. A direct write always wins; an llm write
/// is suppressed only when the *existing* marker is a direct write still inside
/// the protection window.
pub fn decide(
    existing: Option<Marker>,
    source: WriteSource,
    now_ms: u64,
    window_ms: u64,
) -> WriteOutcome {
    if source == WriteSource::Direct {
        return WriteOutcome::Written { routed_at_ms: now_ms };
    }
    if let Some(m) = existing {
        if m.source == WriteSource::Direct && now_ms.saturating_sub(m.routed_at_ms) < window_ms {
            return WriteOutcome::Suppressed {
                direct_at_ms: m.routed_at_ms,
            };
        }
    }
    WriteOutcome::Written { routed_at_ms: now_ms }
}

/// Read the leading precedence marker of a context file, if present.
pub fn read_marker(path: &Path) -> Option<Marker> {
    let s = std::fs::read_to_string(path).ok()?;
    Marker::parse(s.lines().next().unwrap_or(""))
}

/// Apply a precedence-guarded write to a context file. On `Written`, the file is
/// atomically replaced with `marker-line + body`; on `Suppressed`, the file is
/// left untouched and the suppression is logged (the body is discarded as the
/// stale snapshot it is).
pub fn write_context(
    path: &Path,
    body: &str,
    source: WriteSource,
    now_ms: u64,
    window_ms: u64,
) -> std::io::Result<WriteOutcome> {
    let existing = read_marker(path);
    let outcome = decide(existing, source, now_ms, window_ms);
    match outcome {
        WriteOutcome::Written { routed_at_ms } => {
            let marker = Marker { source, routed_at_ms };
            let content = format!("{}\n{}", marker.render(), body);
            atomic_write_string(path, &content)?;
        }
        WriteOutcome::Suppressed { direct_at_ms } => {
            // [impl->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] stale llm overwrite
            // within the window: suppress + log, never clobber the fresher write.
            eprintln!(
                "PRECEDENCE_SUPPRESS:{} (llm write within {}ms of direct write @ {})",
                path.display(),
                window_ms,
                direct_at_ms
            );
        }
    }
    Ok(outcome)
}

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

    #[test] // [unit->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] marker round-trips.
    fn marker_round_trips() {
        let m = Marker { source: WriteSource::Llm, routed_at_ms: 1717200000000 };
        assert_eq!(Marker::parse(&m.render()), Some(m));
        assert_eq!(Marker::parse("just some text"), None);
    }

    #[test] // [unit->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] a direct write always proceeds.
    fn direct_always_writes() {
        let prior = Some(Marker { source: WriteSource::Direct, routed_at_ms: 1000 });
        assert_eq!(
            decide(prior, WriteSource::Direct, 1001, 60_000),
            WriteOutcome::Written { routed_at_ms: 1001 }
        );
    }

    #[test] // [unit->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] llm within the window of a direct write is suppressed.
    fn llm_within_window_suppressed() {
        let prior = Some(Marker { source: WriteSource::Direct, routed_at_ms: 1000 });
        assert_eq!(
            decide(prior, WriteSource::Llm, 1500, 60_000),
            WriteOutcome::Suppressed { direct_at_ms: 1000 }
        );
    }

    #[test] // [unit->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] llm past the window proceeds.
    fn llm_past_window_writes() {
        let prior = Some(Marker { source: WriteSource::Direct, routed_at_ms: 1000 });
        assert_eq!(
            decide(prior, WriteSource::Llm, 61_001, 60_000),
            WriteOutcome::Written { routed_at_ms: 61_001 }
        );
    }

    #[test] // [unit->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] llm over a prior llm write proceeds (no direct to protect).
    fn llm_over_llm_writes() {
        let prior = Some(Marker { source: WriteSource::Llm, routed_at_ms: 1000 });
        assert_eq!(
            decide(prior, WriteSource::Llm, 1001, 60_000),
            WriteOutcome::Written { routed_at_ms: 1001 }
        );
    }

    #[test] // [unit->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] guarded file write honors the decision.
    fn write_context_suppresses_stale_llm() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("context.md");

        // A fresh direct write lands.
        let o = write_context(&path, "DIRECT BODY", WriteSource::Direct, 1000, 60_000).unwrap();
        assert!(matches!(o, WriteOutcome::Written { .. }));
        assert!(std::fs::read_to_string(&path).unwrap().contains("DIRECT BODY"));

        // A stale llm write within the window is suppressed — file unchanged.
        let o = write_context(&path, "STALE LLM", WriteSource::Llm, 1500, 60_000).unwrap();
        assert!(matches!(o, WriteOutcome::Suppressed { .. }));
        let after = std::fs::read_to_string(&path).unwrap();
        assert!(after.contains("DIRECT BODY") && !after.contains("STALE LLM"));

        // Past the window, the llm write proceeds.
        let o = write_context(&path, "FRESH LLM", WriteSource::Llm, 70_000, 60_000).unwrap();
        assert!(matches!(o, WriteOutcome::Written { .. }));
        assert!(std::fs::read_to_string(&path).unwrap().contains("FRESH LLM"));
    }
}


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

//! Precedence-guarded context store (M2b T4, KNOWN-HAZARDS 6.5).
//!
//! Every context write carries a **precedence marker** — `source` (`direct` vs
//! `llm`) + a `routed_at` timestamp — recorded as the file's first line:
//!
//! ```text
//! <!-- spt:source=llm spt:routed_at_ms=1717200000000 -->
//! ```
//!
//! The guard: a **direct** write always proceeds; an **llm** write that lands
//! within the *protection window* after a recent **direct** write is suppressed
//! (and logged), so a stale LLM snapshot never clobbers a fresher direct write.
//!
//! ## Interim vs M4
//! 6.5's full form adds **node identity** to the marker and generalizes to the
//! distributed *newest-and-newer-than-mine* rule (ADR-0003, M4). M2b lands the
//! **local** guard only: one node, `source` + `routed_at` (epoch-ms for exact
//! window math; the ISO/`spt:node` form is the M4 sync concern). Keep the marker
//! parse/render here so the M4 generalization is an extension, not a rewrite.
//
// [impl->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE]

use spt_store::atomic::atomic_write_string;
use std::path::Path;

/// Default protection window: an llm write within this long after a direct write
/// is suppressed. Interim default; per-endpoint override is a later knob.
pub const DEFAULT_PROTECTION_WINDOW_MS: u64 = 60_000;

/// Who authored a context write. `Direct` = spt-core / operator / sync
/// propagation (authoritative, fresh); `Llm` = a model snapshot (possibly
/// stale) — the case the guard protects against.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WriteSource {
    Direct,
    Llm,
}

impl WriteSource {
    fn as_str(self) -> &'static str {
        match self {
            WriteSource::Direct => "direct",
            WriteSource::Llm => "llm",
        }
    }
    fn from_str(s: &str) -> Option<WriteSource> {
        match s {
            "direct" => Some(WriteSource::Direct),
            "llm" => Some(WriteSource::Llm),
            _ => None,
        }
    }
}

/// The precedence marker stamped as a context file's first line.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Marker {
    pub source: WriteSource,
    pub routed_at_ms: u64,
}

impl Marker {
    /// Render the marker as its HTML-comment first line (no trailing newline).
    pub fn render(&self) -> String {
        format!(
            "<!-- spt:source={} spt:routed_at_ms={} -->",
            self.source.as_str(),
            self.routed_at_ms
        )
    }

    /// Parse a marker from a context file's first line. Returns `None` if the
    /// line isn't a well-formed marker (an unmarked / hand-edited file ⇒ no
    /// prior precedence, so any write proceeds).
    pub fn parse(line: &str) -> Option<Marker> {
        let inner = line.trim().strip_prefix("<!--")?.strip_suffix("-->")?.trim();
        let mut source = None;
        let mut routed_at_ms = None;
        for tok in inner.split_whitespace() {
            if let Some(v) = tok.strip_prefix("spt:source=") {
                source = WriteSource::from_str(v);
            } else if let Some(v) = tok.strip_prefix("spt:routed_at_ms=") {
                routed_at_ms = v.parse::<u64>().ok();
            }
        }
        Some(Marker {
            source: source?,
            routed_at_ms: routed_at_ms?,
        })
    }
}

/// Result of a guarded write.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WriteOutcome {
    /// The write landed; the new marker timestamp.
    Written { routed_at_ms: u64 },
    /// An llm write was suppressed within the protection window of a direct
    /// write; the file is unchanged.
    Suppressed { direct_at_ms: u64 },
}

/// Decide whether a write proceeds, given the existing marker (if any). Pure —
/// the testable core of the 6.5 guard. A direct write always wins; an llm write
/// is suppressed only when the *existing* marker is a direct write still inside
/// the protection window.
pub fn decide(
    existing: Option<Marker>,
    source: WriteSource,
    now_ms: u64,
    window_ms: u64,
) -> WriteOutcome {
    if source == WriteSource::Direct {
        return WriteOutcome::Written { routed_at_ms: now_ms };
    }
    if let Some(m) = existing {
        if m.source == WriteSource::Direct && now_ms.saturating_sub(m.routed_at_ms) < window_ms {
            return WriteOutcome::Suppressed {
                direct_at_ms: m.routed_at_ms,
            };
        }
    }
    WriteOutcome::Written { routed_at_ms: now_ms }
}

/// Read the leading precedence marker of a context file, if present.
pub fn read_marker(path: &Path) -> Option<Marker> {
    let s = std::fs::read_to_string(path).ok()?;
    Marker::parse(s.lines().next().unwrap_or(""))
}

/// Apply a precedence-guarded write to a context file. On `Written`, the file is
/// atomically replaced with `marker-line + body`; on `Suppressed`, the file is
/// left untouched and the suppression is logged (the body is discarded as the
/// stale snapshot it is).
pub fn write_context(
    path: &Path,
    body: &str,
    source: WriteSource,
    now_ms: u64,
    window_ms: u64,
) -> std::io::Result<WriteOutcome> {
    let existing = read_marker(path);
    let outcome = decide(existing, source, now_ms, window_ms);
    match outcome {
        WriteOutcome::Written { routed_at_ms } => {
            let marker = Marker { source, routed_at_ms };
            let content = format!("{}\n{}", marker.render(), body);
            atomic_write_string(path, &content)?;
        }
        WriteOutcome::Suppressed { direct_at_ms } => {
            // [impl->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] stale llm overwrite
            // within the window: suppress + log, never clobber the fresher write.
            eprintln!(
                "PRECEDENCE_SUPPRESS:{} (llm write within {}ms of direct write @ {})",
                path.display(),
                window_ms,
                direct_at_ms
            );
        }
    }
    Ok(outcome)
}

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

    #[test] // [unit->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] marker round-trips.
    fn marker_round_trips() {
        let m = Marker { source: WriteSource::Llm, routed_at_ms: 1717200000000 };
        assert_eq!(Marker::parse(&m.render()), Some(m));
        assert_eq!(Marker::parse("just some text"), None);
    }

    #[test] // [unit->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] a direct write always proceeds.
    fn direct_always_writes() {
        let prior = Some(Marker { source: WriteSource::Direct, routed_at_ms: 1000 });
        assert_eq!(
            decide(prior, WriteSource::Direct, 1001, 60_000),
            WriteOutcome::Written { routed_at_ms: 1001 }
        );
    }

    #[test] // [unit->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] llm within the window of a direct write is suppressed.
    fn llm_within_window_suppressed() {
        let prior = Some(Marker { source: WriteSource::Direct, routed_at_ms: 1000 });
        assert_eq!(
            decide(prior, WriteSource::Llm, 1500, 60_000),
            WriteOutcome::Suppressed { direct_at_ms: 1000 }
        );
    }

    #[test] // [unit->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] llm past the window proceeds.
    fn llm_past_window_writes() {
        let prior = Some(Marker { source: WriteSource::Direct, routed_at_ms: 1000 });
        assert_eq!(
            decide(prior, WriteSource::Llm, 61_001, 60_000),
            WriteOutcome::Written { routed_at_ms: 61_001 }
        );
    }

    #[test] // [unit->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] llm over a prior llm write proceeds (no direct to protect).
    fn llm_over_llm_writes() {
        let prior = Some(Marker { source: WriteSource::Llm, routed_at_ms: 1000 });
        assert_eq!(
            decide(prior, WriteSource::Llm, 1001, 60_000),
            WriteOutcome::Written { routed_at_ms: 1001 }
        );
    }

    #[test] // [unit->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] guarded file write honors the decision.
    fn write_context_suppresses_stale_llm() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("context.md");

        // A fresh direct write lands.
        let o = write_context(&path, "DIRECT BODY", WriteSource::Direct, 1000, 60_000).unwrap();
        assert!(matches!(o, WriteOutcome::Written { .. }));
        assert!(std::fs::read_to_string(&path).unwrap().contains("DIRECT BODY"));

        // A stale llm write within the window is suppressed — file unchanged.
        let o = write_context(&path, "STALE LLM", WriteSource::Llm, 1500, 60_000).unwrap();
        assert!(matches!(o, WriteOutcome::Suppressed { .. }));
        let after = std::fs::read_to_string(&path).unwrap();
        assert!(after.contains("DIRECT BODY") && !after.contains("STALE LLM"));

        // Past the window, the llm write proceeds.
        let o = write_context(&path, "FRESH LLM", WriteSource::Llm, 70_000, 60_000).unwrap();
        assert!(matches!(o, WriteOutcome::Written { .. }));
        assert!(std::fs::read_to_string(&path).unwrap().contains("FRESH LLM"));
    }
}


Now the ingest module (read → guarded-write → delete; spt-core single-writer).


//! drop-file ingest (M2b T4, KNOWN-HAZARDS 6.4).
//!
//! The harness-invoked **mind** (a Psyche / echo session) and the **operator**
//! drop context into the manifest's watched `commune_dir` / `signoff_dir` as
//! `<id>-commune.md` / `<id>-signoff.md`. spt-core is the **single writer**: it
//! ingests each drop into the perch's context store and then **deletes** it. The
//! mind is read-only on drop files; it never deletes (6.4 — the removed-delete
//! fix). The write is precedence-guarded (6.5, [`crate::context`]) because a
//! dropped commune is an LLM snapshot.
//
// Clean-room over the M2a seams; the watch/poll cadence is the pulse (T5) — this
// module is the per-pass ingest mechanism it drives.
// [impl->REQ-HAZARD-DROP-FILE-SINGLE-WRITER]
// [impl->REQ-API-3]

use crate::context::{write_context, WriteOutcome, WriteSource};
use spt_store::perch::{resolve_perch_path, ParentHint};
use std::path::{Path, PathBuf};

/// Fixed drop-file suffixes (spt-core owns the filenames; the adapter owns only
/// the directory — MANIFEST §watched dirs).
pub const COMMUNE_SUFFIX: &str = "-commune.md";
pub const SIGNOFF_SUFFIX: &str = "-signoff.md";
/// The per-perch context store file ingested drops fold into.
pub const CONTEXT_FILE: &str = "context.md";

/// Which kind of drop was ingested.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DropKind {
    Commune,
    Signoff,
}

/// One ingested drop: its kind, the guarded-write outcome, and the (now deleted)
/// source path.
#[derive(Debug, Clone)]
pub struct Ingested {
    pub kind: DropKind,
    pub outcome: WriteOutcome,
    pub drop_path: PathBuf,
}

/// The perch's context store path.
pub fn context_path(id: &str, hint: ParentHint<'_>) -> PathBuf {
    resolve_perch_path(id, hint).join(CONTEXT_FILE)
}

/// Ingest any `<id>-commune.md` / `<id>-signoff.md` present in `drop_dir` for
/// `id`: fold each into the perch's context store (precedence-guarded, llm
/// source) and **delete** it (spt-core single-writer; the mind never deletes).
///
/// A drop is deleted whether its write `Written` or `Suppressed` — both mean
/// "consumed" (a suppressed drop was a stale snapshot, correctly discarded). A
/// read/write *error* leaves the drop in place to retry on the next pass.
pub fn ingest_drops(
    drop_dir: &Path,
    id: &str,
    hint: ParentHint<'_>,
    now_ms: u64,
    window_ms: u64,
) -> std::io::Result<Vec<Ingested>> {
    let ctx = context_path(id, hint);
    if let Some(parent) = ctx.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let mut out = Vec::new();
    for (kind, suffix) in [
        (DropKind::Commune, COMMUNE_SUFFIX),
        (DropKind::Signoff, SIGNOFF_SUFFIX),
    ] {
        let drop_path = drop_dir.join(format!("{id}{suffix}"));
        if !drop_path.exists() {
            continue;
        }
        let body = std::fs::read_to_string(&drop_path)?;
        // A dropped commune/signoff is an LLM-authored snapshot.
        let outcome = write_context(&ctx, &body, WriteSource::Llm, now_ms, window_ms)?;
        // [impl->REQ-HAZARD-DROP-FILE-SINGLE-WRITER] spt-core deletes the drop
        // after consuming it; the mind never does.
        std::fs::remove_file(&drop_path)?;
        out.push(Ingested {
            kind,
            outcome,
            drop_path,
        });
    }
    Ok(out)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::context::DEFAULT_PROTECTION_WINDOW_MS;
    use crate::test_support::with_home;

    #[test] // [unit->REQ-HAZARD-DROP-FILE-SINGLE-WRITER] a dropped commune is ingested + deleted by spt-core.
    fn commune_drop_is_ingested_and_deleted() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let drop = drops.path().join("doyle-commune.md");
            std::fs::write(&drop, "Source: echo-commune\nthe brief").unwrap();

            let ingested = ingest_drops(drops.path(), "doyle", ParentHint::Infer, 1000, 60_000)
                .expect("ingest");
            assert_eq!(ingested.len(), 1);
            assert_eq!(ingested[0].kind, DropKind::Commune);
            assert!(matches!(ingested[0].outcome, WriteOutcome::Written { .. }));

            // spt-core deleted the drop (single-writer ingest→delete).
            assert!(!drop.exists(), "drop must be deleted after ingest");
            // Content folded into the perch's context store.
            let ctx = context_path("doyle", ParentHint::Infer);
            assert!(std::fs::read_to_string(&ctx).unwrap().contains("the brief"));
        });
    }

    #[test] // [unit->REQ-API-3] commune + signoff drops both ingest in one pass.
    fn both_drops_ingested() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            std::fs::write(drops.path().join("doyle-commune.md"), "c").unwrap();
            std::fs::write(drops.path().join("doyle-signoff.md"), "s").unwrap();
            let ingested = ingest_drops(drops.path(), "doyle", ParentHint::Infer, 1000, 60_000)
                .expect("ingest");
            let kinds: Vec<DropKind> = ingested.iter().map(|i| i.kind).collect();
            assert!(kinds.contains(&DropKind::Commune) && kinds.contains(&DropKind::Signoff));
            assert!(!drops.path().join("doyle-commune.md").exists());
            assert!(!drops.path().join("doyle-signoff.md").exists());
        });
    }

    #[test] // [unit->REQ-HAZARD-DIRECT-WRITE-PRECEDENCE] a stale commune within a direct window is suppressed but still consumed.
    fn stale_commune_suppressed_but_deleted() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            // Seed a fresh direct write into the context store first.
            let ctx = context_path("doyle", ParentHint::Infer);
            std::fs::create_dir_all(ctx.parent().unwrap()).unwrap();
            write_context(&ctx, "DIRECT", WriteSource::Direct, 1000, DEFAULT_PROTECTION_WINDOW_MS)
                .unwrap();

            // A commune drop landing within the window must be suppressed.
            let drop = drops.path().join("doyle-commune.md");
            std::fs::write(&drop, "STALE COMMUNE").unwrap();
            let ingested =
                ingest_drops(drops.path(), "doyle", ParentHint::Infer, 1500, DEFAULT_PROTECTION_WINDOW_MS)
                    .expect("ingest");
            assert!(matches!(ingested[0].outcome, WriteOutcome::Suppressed { .. }));
            // Still consumed (deleted) and the direct write preserved.
            assert!(!drop.exists());
            let after = std::fs::read_to_string(&ctx).unwrap();
            assert!(after.contains("DIRECT") && !after.contains("STALE COMMUNE"));
        });
    }

    #[test] // no drops present ⇒ a clean empty pass, no error.
    fn no_drops_is_empty() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let ingested =
                ingest_drops(drops.path(), "doyle", ParentHint::Infer, 1000, 60_000).expect("ingest");
            assert!(ingested.is_empty());
        });
    }
}