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)

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

-             [session.echo_commune]\ncommand=\"{cmd}\"\n{guard_line}"

+             [session.echo_commune]\ncommand='{cmd}'\n{guard_line}"


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

    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()


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

//! 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> {
    spawn_and_bind(runtime, PSYCHE_INIT_ROLE, parent_id, keys, event, now)
}

/// Shared spawn+bind core for the Psyche roles (`psyche_init` — fresh; and
/// `psyche_resume` — re-entry, T7). Injects the three spt-core-owned keys
/// (`id` / `psyche_dir` / `psyche_prompt`), spawns the role, and binds the
/// nested `<id>-psyche` perch.
pub(crate) fn spawn_and_bind(
    runtime: &dyn AgentRuntime,
    role: &str,
    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(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:?}");
        });
    }
}

-             [session.psyche_init]\ncommand=\"{GUARD_CMD}\"\ncwd=\"{{psyche_dir}}\"\n\

+             [session.psyche_init]\ncommand='{GUARD_CMD}'\ncwd=\"{{psyche_dir}}\"\n\


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

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


119
120	    fn resume_manifest(cmd: &str) -> Manifest {
121	        let toml = format!(
122	            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
123	             [session.psyche_resume]\ncommand=\"{cmd}\"\ncwd=\"{{psyche_dir}}\"\n"
124	        );
125	        Manifest::from_toml_str(&toml).unwrap()
126	    }

    fn resume_manifest(cmd: &str) -> Manifest {
        let toml = format!(
            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
             [session.psyche_resume]\ncommand=\"{cmd}\"\ncwd=\"{{psyche_dir}}\"\n"
        );
        Manifest::from_toml_str(&toml).unwrap()
    }


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

//! resume-session seam (M2b T7) — re-entry into a Psyche's mind.
//!
//! Two distinct forms (CONTEXT §resume-session seam), both spawned from
//! `[session.psyche_resume]`:
//! - **continue-existing** — resume an existing harness session under the
//!   adapter (its native `--resume {session_id}`). Requires `session_id`.
//! - **fresh-with-preload** — resume with *cleared* context (a fresh session)
//!   plus the **psyche-download**: the prior context, preloaded via the
//!   `{psyche_context}` key (or the adapter pulls it in its SessionStart hook).
//!   Requires `psyche_context`.
//!
//! The required key for the chosen mode is validated **before** spawn — a
//! wrong/missing key errors cleanly, never a half-launched session
//! (REQ-SEAM-RESUME). Both wire into the spawn path and the boundary re-entry
//! (T6 authors the resume commune; this preloads it).
//
// Clean-room over the M2a spawn seam; the native-resume mechanics live in the
// adapter's opaque psyche_resume template (model + --resume flag), never here.
// [impl->REQ-SEAM-RESUME]

use crate::ingest::context_path;
use crate::psyche::{spawn_and_bind, PsycheError, PsycheHandle};
use spt_store::perch::ParentHint;
use std::collections::BTreeMap;

/// The role spawned for a Psyche re-entry.
pub const PSYCHE_RESUME_ROLE: &str = "psyche_resume";

/// Which resume form to perform.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResumeMode {
    /// Native resume of an existing harness session (`{session_id}`).
    ContinueExisting,
    /// Fresh cleared session preloaded with the psyche-download (`{psyche_context}`).
    FreshWithPreload,
}

impl ResumeMode {
    /// The substitution key this mode *requires* the caller to have supplied.
    fn required_key(self) -> &'static str {
        match self {
            ResumeMode::ContinueExisting => "session_id",
            ResumeMode::FreshWithPreload => "psyche_context",
        }
    }
}

/// A resume failure.
#[derive(Debug)]
pub enum ResumeError {
    /// The required key for the chosen mode is missing/empty — caught *before*
    /// any spawn.
    MissingResumeKey {
        mode: ResumeMode,
        key: &'static str,
    },
    /// Spawn / bind failure from the Psyche core.
    Psyche(PsycheError),
}

impl std::fmt::Display for ResumeError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ResumeError::MissingResumeKey { mode, key } => {
                write!(f, "resume mode {mode:?} requires key `{key}` (missing before spawn)")
            }
            ResumeError::Psyche(e) => write!(f, "resume spawn failed: {e}"),
        }
    }
}

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

/// Read the perch's stored context — the **psyche-download** a fresh-with-preload
/// resume feeds back in via `{psyche_context}`. `None` if nothing is stored yet.
pub fn download_psyche_context(id: &str, hint: ParentHint<'_>) -> Option<String> {
    std::fs::read_to_string(context_path(id, hint)).ok()
}

/// Resume a Psyche for `parent_id` in the chosen `mode`. The mode's required key
/// is validated against `keys` **before** spawn; on success the `psyche_resume`
/// role is spawned and the nested perch rebound.
pub fn resume_psyche(
    runtime: &dyn spt_runtime::AgentRuntime,
    parent_id: &str,
    mode: ResumeMode,
    keys: &BTreeMap<String, String>,
    event: Option<&str>,
    now: &str,
) -> Result<PsycheHandle, ResumeError> {
    // Validate the mode's required key BEFORE any spawn (REQ-SEAM-RESUME).
    let required = mode.required_key();
    if keys.get(required).map(String::as_str).unwrap_or("").is_empty() {
        return Err(ResumeError::MissingResumeKey {
            mode,
            key: required,
        });
    }
    spawn_and_bind(runtime, PSYCHE_RESUME_ROLE, parent_id, keys, event, now)
        .map_err(ResumeError::Psyche)
}

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

    // psyche_resume templates that record their resume input into a marker file
    // in the cwd (the nested perch dir).
    #[cfg(windows)]
    const RESUME_SID: &str = "cmd /C echo resumed={session_id} > resumed.txt";
    #[cfg(unix)]
    const RESUME_SID: &str = "sh -c \"echo resumed={session_id} > resumed.txt\"";
    #[cfg(windows)]
    const PRELOAD_CTX: &str = "cmd /C echo preload={psyche_context} > preload.txt";
    #[cfg(unix)]
    const PRELOAD_CTX: &str = "sh -c \"echo preload={psyche_context} > preload.txt\"";

    fn resume_manifest(cmd: &str) -> Manifest {
        let toml = format!(
            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
             [session.psyche_resume]\ncommand=\"{cmd}\"\ncwd=\"{{psyche_dir}}\"\n"
        );
        Manifest::from_toml_str(&toml).unwrap()
    }

    fn keys(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
        pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
    }

    fn wait_marker(path: &std::path::Path) -> String {
        let start = std::time::Instant::now();
        loop {
            if let Ok(s) = std::fs::read_to_string(path) {
                if !s.trim().is_empty() {
                    return s;
                }
            }
            assert!(start.elapsed() < std::time::Duration::from_secs(5), "marker never written");
            std::thread::sleep(std::time::Duration::from_millis(20));
        }
    }

    #[test] // [unit->REQ-SEAM-RESUME] continue-existing re-enters an existing session.
    fn continue_existing_resumes_session() {
        with_home(|_| {
            let rt = ManifestRuntime::new(resume_manifest(RESUME_SID));
            let h = resume_psyche(
                &rt,
                "doyle",
                ResumeMode::ContinueExisting,
                &keys(&[("session_id", "sid-42")]),
                None,
                "t0",
            )
            .expect("resume");
            assert_eq!(h.id, "doyle-psyche");
            let marker = wait_marker(&h.perch_path.join("resumed.txt"));
            assert!(marker.contains("sid-42"), "session id not resumed: {marker:?}");
        });
    }

    #[test] // [unit->REQ-SEAM-RESUME] fresh-with-preload launches cleared + preloaded.
    fn fresh_with_preload_launches_preloaded() {
        with_home(|_| {
            let rt = ManifestRuntime::new(resume_manifest(PRELOAD_CTX));
            let h = resume_psyche(
                &rt,
                "doyle",
                ResumeMode::FreshWithPreload,
                &keys(&[("psyche_context", "PRIORCTX")]),
                None,
                "t0",
            )
            .expect("resume");
            let marker = wait_marker(&h.perch_path.join("preload.txt"));
            assert!(marker.contains("PRIORCTX"), "context not preloaded: {marker:?}");
        });
    }

    #[test] // [unit->REQ-SEAM-RESUME] a missing required key errors BEFORE spawn.
    fn missing_key_errors_before_spawn() {
        with_home(|_| {
            let rt = ManifestRuntime::new(resume_manifest(RESUME_SID));
            // ContinueExisting with no session_id.
            let e = resume_psyche(
                &rt,
                "doyle",
                ResumeMode::ContinueExisting,
                &keys(&[]),
                None,
                "t0",
            )
            .unwrap_err();
            assert!(
                matches!(e, ResumeError::MissingResumeKey { key: "session_id", .. }),
                "got {e}"
            );
            // No perch was bound — the error preceded any spawn.
            let perch = spt_store::perch::resolve_perch_path("doyle-psyche", ParentHint::Explicit("doyle"));
            assert!(!perch.join("info.json").exists(), "must not spawn/bind on missing key");
        });
    }

    #[test] // [unit->REQ-SEAM-RESUME] the psyche-download reads the stored context.
    fn download_reads_stored_context() {
        with_home(|_| {
            assert!(download_psyche_context("doyle", ParentHint::Infer).is_none());
            let ctx = context_path("doyle", ParentHint::Infer);
            std::fs::create_dir_all(ctx.parent().unwrap()).unwrap();
            std::fs::write(&ctx, "stored mind").unwrap();
            assert_eq!(
                download_psyche_context("doyle", ParentHint::Infer).as_deref(),
                Some("stored mind")
            );
        });
    }
}

-             [session.psyche_resume]\ncommand=\"{cmd}\"\ncwd=\"{{psyche_dir}}\"\n"

+             [session.psyche_resume]\ncommand='{cmd}'\ncwd=\"{{psyche_dir}}\"\n"


Verify on Windows (local) then Linux.


Bash

cargo test -p spt-live 2>&1 | grep -E "test result|FAILED" | tail

spt-live tests on Windows after fix


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


tar czf /tmp/spt-sync.tgz --exclude=target --exclude=.git . 2>/dev/null && \
SSH_ASKPASS=/tmp/askpass.sh SSH_ASKPASS_REQUIRE=force ssh -o StrictHostKeyChecking=no david@gravity-linux 'cat > /tmp/spt-sync.tgz' < /tmp/spt-sync.tgz 2>&1 | tail -1
SSH_ASKPASS=/tmp/askpass.sh SSH_ASKPASS_REQUIRE=force ssh -o StrictHostKeyChecking=no david@gravity-linux 'rm -rf ~/spt-core-sync && mkdir -p ~/spt-core-sync && tar xzf /tmp/spt-sync.tgz -C ~/spt-core-sync && export PATH=$HOME/.cargo/bin:$PATH && cd ~/spt-core-sync && cargo test --workspace 2>&1 | grep -E "test result: FAILED|FAILED|error\[" | tail -15; echo "FULL_EXIT=${PIPESTATUS[0]}"; cargo test --workspace 2>&1 | grep -cE "test result: ok"' < /dev/null 2>&1 | tail -8

Full workspace test on Linux after fix


test runtime::tests::bounded_run_captures_success ... FAILED
test runtime::tests::bounded_run_kills_on_timeout ... FAILED
test result: FAILED. 15 passed; 2 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
FULL_EXIT=101
8


6 spt-live failures fixed; now 2 **pre-existing** spt-runtime failures surface (`bounded_run_*`) — also not M3a-related (spt-term doesn't touch spt-runtime). Inspect before deciding scope.


SSH_ASKPASS=/tmp/askpass.sh SSH_ASKPASS_REQUIRE=force ssh -o StrictHostKeyChecking=no david@gravity-linux 'export PATH=$HOME/.cargo/bin:$PATH && cd ~/spt-core-sync && cargo test -p spt-runtime bounded_run_captures_success 2>&1 | sed -n "/panicked\|assert\|Error\|No such\|left:\|right:/p" | head' < /dev/null 2>&1 | tail -10

Inspect spt-runtime bounded_run failure


thread 'runtime::tests::bounded_run_captures_success' (155551) panicked at crates/spt-runtime/src/runtime.rs:450:9:


430
431	    // A trivially-portable bounded command: the host's own test binary does
432	    // nothing and exits 0 when given a no-op. Use the platform `cmd`/`true`.
433	    fn echo_manifest(cmd: &str) -> ManifestRuntime {
434	        let toml = format!(
435	            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n[session.self]\ncommand=\"{cmd}\"\n"
436	        );
437	        ManifestRuntime::new(Manifest::from_toml_str(&toml).unwrap())
438	    }
439
440	    #[test] // [unit->REQ-SEAM-SPAWN]
441	    fn bounded_run_captures_success() {
442	        // Cross-platform no-op that exits 0.
443	        #[cfg(windows)]
444	        let rt = echo_manifest("cmd /C exit 0");
445	        #[cfg(unix)]
446	        let rt = echo_manifest("sh -c 'exit 0'");
447	        let out = rt
448	            .run_bounded("self", &keys(&[]), Duration::from_secs(10))
449	            .expect("bounded run");
450	        assert!(out.success(), "status {:?}", out.status_code);
451	    }
452
453	    #[test] // [unit->REQ-HAZARD-SUBPROCESS-TIMEOUT]
454	    fn bounded_run_kills_on_timeout() {
455	        // A child that sleeps far longer than the timeout must be killed and
456	        // return Timeout — never hang the caller.
457	        #[cfg(windows)]
458	        let rt = echo_manifest("cmd /C ping 127.0.0.1 -n 30");
459	        #[cfg(unix)]
460	        let rt = echo_manifest("sh -c 'sleep 30'");
461	        let start = Instant::now();
462	        let e = rt
463	            .run_bounded("self", &keys(&[]), Duration::from_millis(300))
464	            .unwrap_err();
465	        assert!(matches!(e, RuntimeError::Timeout { .. }), "got {e}");
466	        assert!(
467	            start.elapsed() < Duration::from_secs(5),
468	            "timeout must return promptly, took {:?}",
469	            start.elapsed()

    // A trivially-portable bounded command: the host's own test binary does
    // nothing and exits 0 when given a no-op. Use the platform `cmd`/`true`.
    fn echo_manifest(cmd: &str) -> ManifestRuntime {
        let toml = format!(
            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n[session.self]\ncommand=\"{cmd}\"\n"
        );
        ManifestRuntime::new(Manifest::from_toml_str(&toml).unwrap())
    }

    #[test] // [unit->REQ-SEAM-SPAWN]
    fn bounded_run_captures_success() {
        // Cross-platform no-op that exits 0.
        #[cfg(windows)]
        let rt = echo_manifest("cmd /C exit 0");
        #[cfg(unix)]
        let rt = echo_manifest("sh -c 'exit 0'");
        let out = rt
            .run_bounded("self", &keys(&[]), Duration::from_secs(10))
            .expect("bounded run");
        assert!(out.success(), "status {:?}", out.status_code);
    }

    #[test] // [unit->REQ-HAZARD-SUBPROCESS-TIMEOUT]
    fn bounded_run_kills_on_timeout() {
        // A child that sleeps far longer than the timeout must be killed and
        // return Timeout — never hang the caller.
        #[cfg(windows)]
        let rt = echo_manifest("cmd /C ping 127.0.0.1 -n 30");
        #[cfg(unix)]
        let rt = echo_manifest("sh -c 'sleep 30'");
        let start = Instant::now();
        let e = rt
            .run_bounded("self", &keys(&[]), Duration::from_millis(300))
            .unwrap_err();
        assert!(matches!(e, RuntimeError::Timeout { .. }), "got {e}");
        assert!(
            start.elapsed() < Duration::from_secs(5),
            "timeout must return promptly, took {:?}",
            start.elapsed()


Same root cause class: these use `sh -c 'exit 0'` (single quotes) in double-quoted TOML — TOML parses, but `tokenize()` only honors double quotes, so it mis-splits into `'exit`/`0'` and the child fails. Fix: single-quoted TOML literal + double-quoted shell (matching the tokenizer convention).


Edit

        let toml = format!(
            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n[session.self]\ncommand=\"{cmd}\"\n"
        );

        let toml = format!(
            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n[session.self]\ncommand='{cmd}'\n"
        );


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)

        let toml = format!(
            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n[session.self]\ncommand=\"{cmd}\"\n"
        );

        let toml = format!(
            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n[session.self]\ncommand='{cmd}'\n"
        );

// nothing and exits 0 when given a no-op. Use the platform `cmd`/`true`.

fn echo_manifest(cmd: &str) -> ManifestRuntime {

-            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n[session.self]\ncommand=\"{cmd}\"\n"

+            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n[session.self]\ncommand='{cmd}'\n"


        #[cfg(unix)]
        let rt = echo_manifest("sh -c 'exit 0'");

        #[cfg(unix)]
        let rt = echo_manifest("sh -c \"exit 0\"");


<tool_use_error>Found 2 matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.
String:         #[cfg(unix)]
        let rt = echo_manifest("sh -c 'exit 0'");</tool_use_error>

Error: Found 2 matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.
String:         #[cfg(unix)]
        let rt = echo_manifest("sh -c 'exit 0'");


Bash

grep -n "sh -c '" crates/spt-runtime/src/runtime.rs

Find all single-quote shell fixtures in runtime.rs


446:        let rt = echo_manifest("sh -c 'exit 0'");
460:        let rt = echo_manifest("sh -c 'sleep 30'");
478:        let rt = echo_manifest("sh -c 'exit 0'");


472
473	    #[test] // [unit->REQ-SEAM-SPAWN]
474	    fn spawn_session_detached_returns_pid() {
475	        #[cfg(windows)]
476	        let rt = echo_manifest("cmd /C exit 0");
477	        #[cfg(unix)]
478	        let rt = echo_manifest("sh -c 'exit 0'");
479	        let spawned = rt.spawn_session("self", &keys(&[])).expect("spawn");
480	        assert!(spawned.pid > 0);
481	    }
482	}
483

    #[test] // [unit->REQ-SEAM-SPAWN]
    fn spawn_session_detached_returns_pid() {
        #[cfg(windows)]
        let rt = echo_manifest("cmd /C exit 0");
        #[cfg(unix)]
        let rt = echo_manifest("sh -c 'exit 0'");
        let spawned = rt.spawn_session("self", &keys(&[])).expect("spawn");
        assert!(spawned.pid > 0);
    }
}


All three are the same latent bug; line 478's test only checks pid>0 so it survived. Fix all consistently.


cd crates/spt-runtime && sed -i "s|sh -c 'exit 0'|sh -c \\\\\"exit 0\\\\\"|g; s|sh -c 'sleep 30'|sh -c \\\\\"sleep 30\\\\\"|g" src/runtime.rs && grep -n 'sh -c' src/runtime.rs

Replace single-quote shell fixtures via sed


446:        let rt = echo_manifest("sh -c \"exit 0\"");
460:        let rt = echo_manifest("sh -c \"sleep 30\"");
478:        let rt = echo_manifest("sh -c \"exit 0\"");


cd /c/Users/decid/Documents/projects/spt-core && cargo test -p spt-runtime 2>&1 | grep -E "test result|FAILED" | tail; echo "===clippy==="; cargo clippy -p spt-runtime -p spt-live --all-targets -- -D warnings 2>&1 | tail -3

spt-runtime tests + clippy on Windows after fix


test result: ok. 17 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.31s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
===clippy===
    Checking spt-runtime v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-runtime)
    Checking spt-live v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-live)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.62s


Windows still green. Now the decisive check — full workspace on Linux.


tar czf /tmp/spt-sync.tgz --exclude=target --exclude=.git . 2>/dev/null && \
SSH_ASKPASS=/tmp/askpass.sh SSH_ASKPASS_REQUIRE=force ssh -o StrictHostKeyChecking=no david@gravity-linux 'cat > /tmp/spt-sync.tgz' < /tmp/spt-sync.tgz 2>&1 | tail -1
SSH_ASKPASS=/tmp/askpass.sh SSH_ASKPASS_REQUIRE=force ssh -o StrictHostKeyChecking=no david@gravity-linux 'rm -rf ~/spt-core-sync && mkdir -p ~/spt-core-sync && tar xzf /tmp/spt-sync.tgz -C ~/spt-core-sync && export PATH=$HOME/.cargo/bin:$PATH && cd ~/spt-core-sync && cargo test --workspace 2>&1 | tee /tmp/wt.log | grep -E "test result: FAILED|FAILED|error\[" | tail -15; echo "FAILS_ABOVE?"; grep -c "test result: ok" /tmp/wt.log; echo "any-failed:"; grep -c "FAILED" /tmp/wt.log' < /dev/null 2>&1 | tail -8

Full workspace test on Linux after all fixes


test two_agents_exchange_message_tcp_and_spool ... FAILED
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s
FAILS_ABOVE?
6
any-failed:
2


spt-runtime/spt-live all green now. One new failure: `two_agents_exchange_message_tcp_and_spool` (spt-msg TCP integration) — could be env (port binding) rather than a code bug. Inspect.


SSH_ASKPASS=/tmp/askpass.sh SSH_ASKPASS_REQUIRE=force ssh -o StrictHostKeyChecking=no david@gravity-linux 'export PATH=$HOME/.cargo/bin:$PATH && cd ~/spt-core-sync && cargo test -p spt-msg two_agents_exchange_message_tcp_and_spool 2>&1 | sed -n "/panicked\|assert\|Error\|refused\|address\|bind\|left:\|right:/p" | head' < /dev/null 2>&1 | tail -12

Inspect spt-msg TCP test failure


(Bash completed with no output)


SSH_ASKPASS=/tmp/askpass.sh SSH_ASKPASS_REQUIRE=force ssh -o StrictHostKeyChecking=no david@gravity-linux 'export PATH=$HOME/.cargo/bin:$PATH && cd ~/spt-core-sync && for i in 1 2 3; do cargo test -p spt-msg two_agents_exchange_message_tcp_and_spool 2>&1 | grep -E "test result|panicked at|assert"; done' < /dev/null 2>&1 | tail -15

Re-run spt-msg test 3x to check flakiness


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


Diagnosis: in isolation it passes 3/3 — a pre-existing **test-parallelism flake** (port/resource contention under `--workspace`), not a code bug and unrelated to M3a. The 8 real unix bugs (TOML/tokenize quoting class) are now fixed. Commit that fix separately (it's required for M3a's "CI matrix green" gate), leave the flake for the user.


/c/Users/decid/AppData/Local/Programs/traceable-reqs/traceable-reqs check >/dev/null 2>&1; echo "traceable exit=$?"; git add -A && git status --short

Traceable check + stage fix


traceable exit=0
warning: in the working copy of 'crates/spt-live/src/echo.rs', 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/resume.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
M  crates/spt-live/src/echo.rs
M  crates/spt-live/src/psyche.rs
M  crates/spt-live/src/resume.rs
M  crates/spt-runtime/src/runtime.rs


git commit -q -m "fix(test): unix manifest fixtures broke on Linux (TOML quoting vs tokenizer)

Pre-existing, surfaced by M3a's cross-OS validation: 8 cfg(unix) test
fixtures in spt-live (echo/psyche/resume) and spt-runtime (bounded-run/
spawn) built session-manifest TOML that fails on the Unix path — never
caught because the dev host is Windows and the cfg(windows) commands
(quote-free 'cmd /C ...') happen to be valid.

Two manifestations, one root cause (TOML string syntax vs the runtime
tokenizer's double-quote convention):
- spt-live used 'sh -c \"...\"' inside command=\"...\" -> the inner \"
  closed the TOML string -> hard parse error.
- spt-runtime used 'sh -c '...'' (single quotes) -> valid TOML but
  tokenize() only honors double quotes, so it split 'exit/0' and the
  child failed.

Fix: emit command as a TOML *literal* (single-quoted) string and use
double-quoted shell args (what tokenize() expects). Test-only; no
production code touched. spt-live + spt-runtime now green on both
Windows and Linux (gravity-linux). Tags unchanged; traceable check
still 115/115.

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

Commit the unix-fixture fix


d8b854e fix(test): unix manifest fixtures broke on Linux (TOML quoting vs tokenizer)


Final cross-OS confirmation: full workspace on Linux (expect only the known parallelism flake, if anything).


tar czf /tmp/spt-sync.tgz --exclude=target --exclude=.git . 2>/dev/null && \
SSH_ASKPASS=/tmp/askpass.sh SSH_ASKPASS_REQUIRE=force ssh -o StrictHostKeyChecking=no david@gravity-linux 'cat > /tmp/spt-sync.tgz' < /tmp/spt-sync.tgz 2>&1 | tail -1
SSH_ASKPASS=/tmp/askpass.sh SSH_ASKPASS_REQUIRE=force ssh -o StrictHostKeyChecking=no david@gravity-linux 'rm -rf ~/spt-core-sync && mkdir -p ~/spt-core-sync && tar xzf /tmp/spt-sync.tgz -C ~/spt-core-sync && export PATH=$HOME/.cargo/bin:$PATH && cd ~/spt-core-sync && cargo test --workspace -- --test-threads=1 2>&1 | grep -E "test result|FAILED|error\[" | grep -v "0 passed; 0 failed" | tail -20; echo "EXIT=${PIPESTATUS[0]}"' < /dev/null 2>&1 | tail -22

Full Linux workspace serial (avoids port-flake)


test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.59s
test result: ok. 35 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.49s
test result: ok. 22 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.86s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.16s
test result: ok. 49 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s
test result: ok. 17 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.33s
test result: ok. 33 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.43s
test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.00s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
EXIT=0


**Full workspace green on Linux (serial): EXIT=0, every suite `ok`.** Confirms the `two_agents` failure was purely a parallel-port flake (pre-existing spt-msg M1 test-isolation issue, passes serial). M3a complete + workspace green on both OSes.

Let me update the gravity-linux memory with the working sync recipe (no rsync; tar-pipe + PATH).


1	---
2	name: cross-machine-test-rig
3	description: Second machine + access for spt-core cross-machine/network spikes (iroh NAT tests)
4	metadata:
5	  node_type: memory
6	  type: reference
7	  originSessionId: 2c168d6d-6896-403a-bb2e-44bdc5b44daf
8	---
9
10	For two-host network tests (iroh NAT traversal, cross-node messaging), there's a second machine **enlyzeam** on the same Tailscale tailnet as the dev box **hfenduleam**. Both Windows.
11
12	- SSH access from hfenduleam: `ssh decid@enlyzeam` — key-based (my ed25519 key is in enlyzeam's `administrators_authorized_keys`), no password. SSH/MagicDNS routes over Tailscale, so it needs Tailscale **up** on both.
13	- Transfer: `scp <file> decid@enlyzeam:` lands in `C:\Users\decid\`.
14	- The Spike #2 binary `iroh-smoke.exe` is left at `C:\Users\decid\iroh-smoke.exe` on enlyzeam.
15
16	**Key gotcha for NAT-traversal tests:** the two boxes are on different home networks, but with Tailscale up iroh picks the `100.x` overlay addr as a "direct" path (Tailscale does the traversal, not iroh). To test iroh's OWN hole-punch, take Tailscale **down on the client (hfenduleam)** — then iroh hole-punches to the peer's public IP. Confirmed working (relay→direct upgrade, ~13ms direct). See [[spike-02-iroh-result]] in docs/spikes/SPIKE-02-iroh-smoke.md. Taking Tailscale down also kills ssh, so have the other side run the server + paste the ticket.
17
18	**Linux host (for `forkpty` / any Linux build or test):** **gravity-linux** on the same tailnet (Ubuntu 22.04, x86_64, 4 cores). enlyzeam is Windows — NOT a Linux option. Access is **password** auth: `ssh david@gravity-linux`, user `david` / pw `bigscreen` (NOT key, NOT my `decid` user; the tailnet is `brandon@`-owned). Windows OpenSSH has no `-pw` flag — feed the password non-interactively with an askpass shim: write a `.cmd` containing `@echo bigscreen`, then set `$env:SSH_ASKPASS=<that.cmd>`, `$env:SSH_ASKPASS_REQUIRE='force'`, `$env:DISPLAY='localhost:0'` before `ssh`/`scp`. rustup (cargo 1.96.0, minimal profile) was installed under `~/.cargo` on 2026-06-01 — `source ~/.cargo/env` first. Spike #4 (Linux forkpty parity) was run here: scp the spike crate to `~/spt-spike4/`, `cargo build && ./target/debug/spike run`. See docs/spikes/SPIKE-04-forkpty-parity.md.
19

---
name: cross-machine-test-rig
description: Second machine + access for spt-core cross-machine/network spikes (iroh NAT tests)
metadata:
  node_type: memory
  type: reference
  originSessionId: 2c168d6d-6896-403a-bb2e-44bdc5b44daf
---

For two-host network tests (iroh NAT traversal, cross-node messaging), there's a second machine **enlyzeam** on the same Tailscale tailnet as the dev box **hfenduleam**. Both Windows.

- SSH access from hfenduleam: `ssh decid@enlyzeam` — key-based (my ed25519 key is in enlyzeam's `administrators_authorized_keys`), no password. SSH/MagicDNS routes over Tailscale, so it needs Tailscale **up** on both.
- Transfer: `scp <file> decid@enlyzeam:` lands in `C:\Users\decid\`.
- The Spike #2 binary `iroh-smoke.exe` is left at `C:\Users\decid\iroh-smoke.exe` on enlyzeam.

**Key gotcha for NAT-traversal tests:** the two boxes are on different home networks, but with Tailscale up iroh picks the `100.x` overlay addr as a "direct" path (Tailscale does the traversal, not iroh). To test iroh's OWN hole-punch, take Tailscale **down on the client (hfenduleam)** — then iroh hole-punches to the peer's public IP. Confirmed working (relay→direct upgrade, ~13ms direct). See [[spike-02-iroh-result]] in docs/spikes/SPIKE-02-iroh-smoke.md. Taking Tailscale down also kills ssh, so have the other side run the server + paste the ticket.

**Linux host (for `forkpty` / any Linux build or test):** **gravity-linux** on the same tailnet (Ubuntu 22.04, x86_64, 4 cores). enlyzeam is Windows — NOT a Linux option. Access is **password** auth: `ssh david@gravity-linux`, user `david` / pw `bigscreen` (NOT key, NOT my `decid` user; the tailnet is `brandon@`-owned). Windows OpenSSH has no `-pw` flag — feed the password non-interactively with an askpass shim: write a `.cmd` containing `@echo bigscreen`, then set `$env:SSH_ASKPASS=<that.cmd>`, `$env:SSH_ASKPASS_REQUIRE='force'`, `$env:DISPLAY='localhost:0'` before `ssh`/`scp`. rustup (cargo 1.96.0, minimal profile) was installed under `~/.cargo` on 2026-06-01 — `source ~/.cargo/env` first. Spike #4 (Linux forkpty parity) was run here: scp the spike crate to `~/spt-spike4/`, `cargo build && ./target/debug/spike run`. See docs/spikes/SPIKE-04-forkpty-parity.md.


Edit

**Linux host (for `forkpty` / any Linux build or test):** **gravity-linux** on the same tailnet (Ubuntu 22.04, x86_64, 4 cores). enlyzeam is Windows — NOT a Linux option. Access is **password** auth: `ssh david@gravity-linux`, user `david` / pw `bigscreen` (NOT key, NOT my `decid` user; the tailnet is `brandon@`-owned). Windows OpenSSH has no `-pw` flag — feed the password non-interactively with an askpass shim: write a `.cmd` containing `@echo bigscreen`, then set `$env:SSH_ASKPASS=<that.cmd>`, `$env:SSH_ASKPASS_REQUIRE='force'`, `$env:DISPLAY='localhost:0'` before `ssh`/`scp`. rustup (cargo 1.96.0, minimal profile) was installed under `~/.cargo` on 2026-06-01 — `source ~/.cargo/env` first. Spike #4 (Linux forkpty parity) was run here: scp the spike crate to `~/spt-spike4/`, `cargo build && ./target/debug/spike run`. See docs/spikes/SPIKE-04-forkpty-parity.md.

**Linux host (for `forkpty` / any Linux build or test):** **gravity-linux** on the same tailnet (Ubuntu 22.04, x86_64, 4 cores). enlyzeam is Windows — NOT a Linux option. Access is **password** auth: `ssh david@gravity-linux`, user `david` / pw `bigscreen` (NOT key, NOT my `decid` user; the tailnet is `brandon@`-owned). Windows OpenSSH has no `-pw` flag — feed the password non-interactively with an askpass shim: write a `.cmd` containing `@echo bigscreen`, then set `$env:SSH_ASKPASS=<that.cmd>`, `$env:SSH_ASKPASS_REQUIRE='force'`, `$env:DISPLAY='localhost:0'` before `ssh`/`scp`. rustup (cargo 1.96.0, minimal profile) was installed under `~/.cargo` on 2026-06-01 — `source ~/.cargo/env` first. Spike #4 (Linux forkpty parity) was run here: scp the spike crate to `~/spt-spike4/`, `cargo build && ./target/debug/spike run`. See docs/spikes/SPIKE-04-forkpty-parity.md.

**Working recipe from the Claude Bash tool (git-bash on Windows, used for M3a cross-OS validation 2026-06-02):** the askpass shim works as a plain `/tmp/askpass.sh` containing `echo bigscreen` (chmod +x) with `SSH_ASKPASS=/tmp/askpass.sh SSH_ASKPASS_REQUIRE=force ssh -o StrictHostKeyChecking=no david@gravity-linux '<cmd>' < /dev/null`. **No `rsync` on either side** — sync the workspace by piping a tarball: `tar czf /tmp/x.tgz --exclude=target --exclude=.git . ; ssh … 'cat > /tmp/x.tgz' < /tmp/x.tgz` then `ssh … 'rm -rf ~/spt-core-sync && mkdir ~/spt-core-sync && tar xzf /tmp/x.tgz -C ~/spt-core-sync'`. cargo/rustc are NOT on the default non-interactive PATH — prefix every remote cargo cmd with `export PATH=$HOME/.cargo/bin:$PATH`. **clippy is NOT installed** on gravity-linux (only Windows + CI lint). Gotcha: `cargo test --workspace` (parallel) flakes `spt-msg::two_agents_exchange_message_tcp_and_spool` on a fixed-port/resource race — passes in isolation and under `--test-threads=1`; a pre-existing M1 test-isolation issue, not a real bug.


The file C:\Users\decid\.claude\projects\C--Users-decid-Documents-projects-spt-core\memory\cross-machine-test-rig.md has been updated successfully. (file state is current in your context — no need to Read it back)

**Linux host (for `forkpty` / any Linux build or test):** **gravity-linux** on the same tailnet (Ubuntu 22.04, x86_64, 4 cores). enlyzeam is Windows — NOT a Linux option. Access is **password** auth: `ssh david@gravity-linux`, user `david` / pw `bigscreen` (NOT key, NOT my `decid` user; the tailnet is `brandon@`-owned). Windows OpenSSH has no `-pw` flag — feed the password non-interactively with an askpass shim: write a `.cmd` containing `@echo bigscreen`, then set `$env:SSH_ASKPASS=<that.cmd>`, `$env:SSH_ASKPASS_REQUIRE='force'`, `$env:DISPLAY='localhost:0'` before `ssh`/`scp`. rustup (cargo 1.96.0, minimal profile) was installed under `~/.cargo` on 2026-06-01 — `source ~/.cargo/env` first. Spike #4 (Linux forkpty parity) was run here: scp the spike crate to `~/spt-spike4/`, `cargo build && ./target/debug/spike run`. See docs/spikes/SPIKE-04-forkpty-parity.md.

**Working recipe from the Claude Bash tool (git-bash on Windows, used for M3a cross-OS validation 2026-06-02):** the askpass shim works as a plain `/tmp/askpass.sh` containing `echo bigscreen` (chmod +x) with `SSH_ASKPASS=/tmp/askpass.sh SSH_ASKPASS_REQUIRE=force ssh -o StrictHostKeyChecking=no david@gravity-linux '<cmd>' < /dev/null`. **No `rsync` on either side** — sync the workspace by piping a tarball: `tar czf /tmp/x.tgz --exclude=target --exclude=.git . ; ssh … 'cat > /tmp/x.tgz' < /tmp/x.tgz` then `ssh … 'rm -rf ~/spt-core-sync && mkdir ~/spt-core-sync && tar xzf /tmp/x.tgz -C ~/spt-core-sync'`. cargo/rustc are NOT on the default non-interactive PATH — prefix every remote cargo cmd with `export PATH=$HOME/.cargo/bin:$PATH`. **clippy is NOT installed** on gravity-linux (only Windows + CI lint). Gotcha: `cargo test --workspace` (parallel) flakes `spt-msg::two_agents_exchange_message_tcp_and_spool` on a fixed-port/resource race — passes in isolation and under `--test-threads=1`; a pre-existing M1 test-isolation issue, not a real bug.

---
name: cross-machine-test-rig
description: Second machine + access for spt-core cross-machine/network spikes (iroh NAT tests)
metadata:
  node_type: memory
  type: reference
  originSessionId: 2c168d6d-6896-403a-bb2e-44bdc5b44daf
---

For two-host network tests (iroh NAT traversal, cross-node messaging), there's a second machine **enlyzeam** on the same Tailscale tailnet as the dev box **hfenduleam**. Both Windows.

- SSH access from hfenduleam: `ssh decid@enlyzeam` — key-based (my ed25519 key is in enlyzeam's `administrators_authorized_keys`), no password. SSH/MagicDNS routes over Tailscale, so it needs Tailscale **up** on both.
- Transfer: `scp <file> decid@enlyzeam:` lands in `C:\Users\decid\`.
- The Spike #2 binary `iroh-smoke.exe` is left at `C:\Users\decid\iroh-smoke.exe` on enlyzeam.

**Key gotcha for NAT-traversal tests:** the two boxes are on different home networks, but with Tailscale up iroh picks the `100.x` overlay addr as a "direct" path (Tailscale does the traversal, not iroh). To test iroh's OWN hole-punch, take Tailscale **down on the client (hfenduleam)** — then iroh hole-punches to the peer's public IP. Confirmed working (relay→direct upgrade, ~13ms direct). See [[spike-02-iroh-result]] in docs/spikes/SPIKE-02-iroh-smoke.md. Taking Tailscale down also kills ssh, so have the other side run the server + paste the ticket.

**Linux host (for `forkpty` / any Linux build or test):** **gravity-linux** on the same tailnet (Ubuntu 22.04, x86_64, 4 cores). enlyzeam is Windows — NOT a Linux option. Access is **password** auth: `ssh david@gravity-linux`, user `david` / pw `bigscreen` (NOT key, NOT my `decid` user; the tailnet is `brandon@`-owned). Windows OpenSSH has no `-pw` flag — feed the password non-interactively with an askpass shim: write a `.cmd` containing `@echo bigscreen`, then set `$env:SSH_ASKPASS=<that.cmd>`, `$env:SSH_ASKPASS_REQUIRE='force'`, `$env:DISPLAY='localhost:0'` before `ssh`/`scp`. rustup (cargo 1.96.0, minimal profile) was installed under `~/.cargo` on 2026-06-01 — `source ~/.cargo/env` first. Spike #4 (Linux forkpty parity) was run here: scp the spike crate to `~/spt-spike4/`, `cargo build && ./target/debug/spike run`. See docs/spikes/SPIKE-04-forkpty-parity.md.

+**Working recipe from the Claude Bash tool (git-bash on Windows, used for M3a cross-OS validation 2026-06-02):** the askpass shim works as a plain `/tmp/askpass.sh` containing `echo bigscreen` (chmod +x) with `SSH_ASKPASS=/tmp/askpass.sh SSH_ASKPASS_REQUIRE=force ssh -o StrictHostKeyChecking=no david@gravity-linux '<cmd>' < /dev/null`. **No `rsync` on either side** — sync the workspace by piping a tarball: `tar czf /tmp/x.tgz --exclude=target --exclude=.git . ; ssh … 'cat > /tmp/x.tgz' < /tmp/x.tgz` then `ssh … 'rm -rf ~/spt-core-sync && mkdir ~/spt-core-sync && tar xzf /tmp/x.tgz -C ~/spt-core-sync'`. cargo/rustc are NOT on the default non-interactive PATH — prefix every remote cargo cmd with `export PATH=$HOME/.cargo/bin:$PATH`. **clippy is NOT installed** on gravity-linux (only Windows + CI lint). Gotcha: `cargo test --workspace` (parallel) flakes `spt-msg::two_agents_exchange_message_tcp_and_spool` on a fixed-port/resource race — passes in isolation and under `--test-threads=1`; a pre-existing M1 test-isolation issue, not a real bug.


M3a done. Memory updated with the gravity-linux sync recipe.

## M3a SHIPPED — `spt-term` session-surface mechanism

**8 commits on `dev-freeform`** (A0→A6 + 1 fix). Sixth crate: `…→spt-msg→spt-term`, sibling of spt-runtime.

| Task | What | Req |
|------|------|-----|
| A0 | scaffold crate (`portable-pty`) | — |
| A1 | `SessionSurface` trait + ConPTY/forkpty backend, resize behind master lock | REQ-TERM-1 |
| A2 | DSR auto-answer drain pump (no-EOF-while-writer-held) | REQ-HAZARD-CONPTY-DSR |
| A3 | send-keys / send-line injection | REQ-TERM-2 |
| A4 | bounded byte-stream (backpressure) | REQ-TERM-3 |
| A5 | PTY-digest parser primitive (ADR-0008) | REQ-TERM-4 (impl,unit) |
| A6 | activation sweep + ROADMAP/CONTEXT + M3b stub | — |

**Gates:** `traceable-reqs check` 115/115 exit 0 · workspace clippy `-D warnings` clean · rustdoc clean · **validated cross-OS: Windows (ConPTY) + Linux (forkpty, gravity-linux).**

**OS-neutrality held (Spike #4/#5):** trait bakes in neither raw-pipe nor screen-buffer semantics; digest strips ANSI + tail-wins on repaint.

**3 real ConPTY findings, all handled:**
- portable-pty 0.8 Windows `ChildKiller` returns spurious error on *successful* `TerminateProcess` → kill best-effort on Windows.
- Win11 26200 ConPTY doesn't emit startup `ESC[6n` → DSR auto-answer proven by synthetic injection, not host behavior.
- ConPTY Ctrl-C dropped if sent before child installs handler → readiness handshake.

**Bonus fix (separate commit):** M3a cross-OS validation exposed **8 pre-existing tests broken on Linux/CI** (spt-live + spt-runtime) — `cfg(unix)` manifest fixtures with bad TOML-vs-tokenizer quoting, never caught on the Windows dev host. Fixed; both crates now green on Linux. **CI ubuntu was almost certainly red before this.**

**One thing left for you (not M3a, not fixed):** `spt-msg::two_agents_exchange_message_tcp_and_spool` flakes under parallel `cargo test --workspace` on Linux (fixed-port/resource race) — passes in isolation and `--test-threads=1`. Pre-existing M1 test-isolation issue. Want me to harden it (ephemeral port / serialize) in a follow-up?

**Next milestone: M3b** — `spt-daemon` broker/brain process hosting many spt-term surfaces behind versioned IPC. Stub at `M3b-PLAN.md`.

Nothing pushed — commits local on `dev-freeform`.


<local-command-caveat>Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to.</local-command-caveat>


<command-name>/reload-plugins</command-name>
            <command-message>reload-plugins</command-message>
            <command-args></command-args>


<local-command-stdout>Reloaded: 6 plugins · 0 skills · 39 agents · 9 hooks · 1 plugin MCP server · 1 plugin LSP server</local-command-stdout>


proceed to harden the message exchange test