//! The brain's LiveAgent lifecycle host (B3) — the consolidated re-host of the
//! interim `api listen` runner's live half.
//!
//! In the no-daemon interim model the long-lived `spt api listen` process hosted
//! the LiveAgent lifecycle (Psyche spawn, pulse-driven drop ingest, echo fires,
//! graceful signoff) directly in the agent's session. Consolidation
//! (REQ-DAEMON-1) moves that orchestration **down into `spt-daemon`** so the
//! brain runs it as scheduled in-process loops, with a durable, configurable
//! pulse period ([`crate::config::DaemonConfig`]) instead of the interim 5s
//! constant. The `spt` binary's `LiveHost` becomes a thin shim over this type —
//! a re-host, not a rewrite: the heavy lifting stays in the already-pure
//! [`spt_live`] seams ([`spawn_psyche`], [`ingest_drops`], [`run_echo_commune`],
//! [`graceful_signoff`], [`sweep_stale_signoff`]); the ordering invariants
//! (echo-before-signoff 3.3, grace-before-signoff 1.1, stale-sweep 3.2) live
//! where they always have.
// [impl->REQ-DAEMON-1]

use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use spt_live::{
    fetch_history, graceful_signoff, ingest_drops, run_echo_commune, spawn_psyche,
    sweep_stale_signoff, HistoryRecord, Ingested, PsycheHandle, SignoffOutcome,
};
use spt_runtime::{Manifest, ManifestRuntime};
use spt_store::perch::{self, ParentHint};

use crate::config::DaemonConfig;

/// Bound for a bounded history/echo invocation in the host (carried from M2b).
const LIVE_BOUNDED_TIMEOUT: Duration = Duration::from_secs(30);

/// Wall-clock now in epoch millis — precedence-marker stamp for ingested writes.
fn now_ms() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

/// ISO-ish stamp for the psyche prompt (opaque to the Psyche; just a timestamp).
fn now_stamp() -> String {
    format!("epoch-ms:{}", now_ms())
}

/// One pulse tick's observed effects: any drops ingested + whether an echo fired.
#[derive(Debug, Clone, Default)]
pub struct TickReport {
    pub ingested: Vec<Ingested>,
    pub echo_fired: bool,
}

/// The daemon-hosted LiveAgent lifecycle: the runtime + watched drop dirs built
/// from a live-capable manifest, paced by a config-driven [`DaemonConfig`].
pub struct BrainLifecycle {
    runtime: ManifestRuntime,
    id: String,
    manifest: Manifest,
    commune_dir: Option<PathBuf>,
    signoff_dir: Option<PathBuf>,
    cfg: DaemonConfig,
}

impl BrainLifecycle {
    /// Build a host for `id` from a live-capable manifest (declares
    /// `[session.psyche_init]`); `None` for an absent manifest or a plain relay
    /// endpoint. Loads the durable [`DaemonConfig`] from disk.
    pub fn new_opt(manifest: Option<&Manifest>, id: &str) -> Option<BrainLifecycle> {
        Self::with_config(manifest?, id, DaemonConfig::load())
    }

    /// Build a host from a live-capable manifest with an explicit config (the
    /// testable seam). `None` if the manifest declares no Psyche.
    pub fn with_config(manifest: &Manifest, id: &str, cfg: DaemonConfig) -> Option<BrainLifecycle> {
        manifest.session.psyche_init.as_ref()?;
        Some(BrainLifecycle {
            runtime: ManifestRuntime::new(manifest.clone()),
            id: id.to_string(),
            commune_dir: manifest.session.commune_dir.as_ref().map(PathBuf::from),
            signoff_dir: manifest.session.signoff_dir.as_ref().map(PathBuf::from),
            manifest: manifest.clone(),
            cfg,
        })
    }

    /// The configured pulse period (config-driven, not a constant).
    pub fn pulse_period(&self) -> Duration {
        self.cfg.pulse_period
    }

    /// Base substitution keys spt-core fills for the lifecycle templates.
    fn base_keys(&self, session_id: Option<&str>) -> BTreeMap<String, String> {
        let mut k = BTreeMap::new();
        k.insert("id".to_string(), self.id.clone());
        if let Some(sid) = session_id {
            k.insert("session_id".to_string(), sid.to_string());
        }
        k
    }

    /// The unique watched drop dirs (commune + signoff; usually the same dir).
    fn drop_dirs(&self) -> Vec<PathBuf> {
        let mut dirs: Vec<PathBuf> = Vec::new();
        for d in [self.commune_dir.as_ref(), self.signoff_dir.as_ref()]
            .into_iter()
            .flatten()
        {
            if !dirs.contains(d) {
                dirs.push(d.clone());
            }
        }
        dirs
    }

    /// Sweep a stale signoff sentinel on start (3.2). No-op without a signoff dir.
    pub fn sweep_on_start(&self) {
        if let Some(dir) = &self.signoff_dir {
            let _ = sweep_stale_signoff(dir, &self.id);
        }
    }

    /// Spawn the Psyche companion (T2). `event` is the incoming envelope when the
    /// spawn is event-driven; `None` at cold start.
    pub fn spawn_psyche(
        &self,
        session_id: Option<&str>,
        event: Option<&str>,
    ) -> Result<PsycheHandle, String> {
        spawn_psyche(&self.runtime, &self.id, &self.base_keys(session_id), event, &now_stamp())
            .map_err(|e| e.to_string())
    }

    /// One pulse tick (T5): ingest drops from the watched dirs (T4), then if the
    /// echo-gate is armed fire exactly one echo-commune (T3).
    pub fn pulse_tick(&self, session_id: Option<&str>) -> Result<TickReport, String> {
        let now = now_ms();
        let mut ingested = Vec::new();
        for dir in self.drop_dirs() {
            let mut got =
                ingest_drops(&dir, &self.id, ParentHint::Infer, now, self.cfg.protection_window_ms)
                    .map_err(|e| e.to_string())?;
            ingested.append(&mut got);
        }
        let echo_fired = if spt_live::pulse::take_echo_gate(&self.id, ParentHint::Infer) {
            self.fire_echo(session_id)?;
            true
        } else {
            false
        };
        Ok(TickReport { ingested, echo_fired })
    }

    /// Run the scheduled pulse loop until `stop` is set, calling `on_tick` after
    /// each tick (so a caller — or a test — observes cadence and can decide to
    /// stop). The period comes from the durable config, never a constant. The
    /// loop holds no state beyond the cursor on disk, so it is freely
    /// killable + respawnable: a fresh loop re-reads the drop dirs and gate.
    pub fn run_pulse_loop<F: FnMut(&TickReport)>(
        &self,
        session_id: Option<&str>,
        stop: &AtomicBool,
        mut on_tick: F,
    ) {
        while !stop.load(Ordering::Relaxed) {
            match self.pulse_tick(session_id) {
                Ok(report) => on_tick(&report),
                Err(_) => { /* a transient tick error must not kill the loop */ }
            }
            // Sleep the configured period in small slices so a stop is honored
            // promptly rather than after a full (possibly multi-second) period.
            sleep_interruptible(self.cfg.pulse_period, stop);
        }
    }

    /// Fetch history per the manifest `[history]` strategy (empty if none).
    fn history(&self, session_id: Option<&str>) -> Vec<HistoryRecord> {
        let Some(h) = &self.manifest.history else {
            return Vec::new();
        };
        fetch_history(h, &self.base_keys(session_id), &self.id, ParentHint::Infer, LIVE_BOUNDED_TIMEOUT)
            .unwrap_or_default()
    }

    /// Run the echo-commune into the commune dir (T3). No-op (Ok) without a dir.
    fn fire_echo(&self, session_id: Option<&str>) -> Result<(), String> {
        let Some(dir) = &self.commune_dir else {
            return Ok(());
        };
        let history = self.history(session_id);
        run_echo_commune(
            &self.runtime,
            &self.id,
            &self.base_keys(session_id),
            &history,
            dir,
            LIVE_BOUNDED_TIMEOUT,
        )
        .map(|_| ())
        .map_err(|e| e.to_string())
    }

    /// Execute the graceful signoff (T6): echo-commune BEFORE teardown (3.3),
    /// grace recheck before any signoff write (1.1). The interim graceful path
    /// uses zero grace + always-gone recheck (an intentional shutdown); the
    /// daemon's orphan-watch (B7) supplies the real grace/liveness recheck.
    pub fn graceful_signoff(&self, session_id: Option<&str>) -> Result<SignoffOutcome, String> {
        let mut echo_err: Option<String> = None;
        let mut teardown_err: Option<String> = None;
        let outcome = graceful_signoff::<std::convert::Infallible>(
            session_id,
            Duration::ZERO,
            || {
                if let Err(e) = self.fire_echo(session_id) {
                    echo_err = Some(e);
                }
                Ok(())
            },
            || true, // graceful, intentional shutdown — the Self is leaving on purpose
            || {
                let ready = perch::resolve_ready_file(&self.id, ParentHint::Infer);
                if let Err(e) = std::fs::remove_file(&ready) {
                    if e.kind() != std::io::ErrorKind::NotFound {
                        teardown_err = Some(e.to_string());
                    }
                }
                Ok(())
            },
        )
        .unwrap_or(SignoffOutcome { echo_ran: false, signed_off: false });
        if let Some(e) = echo_err {
            return Err(format!("echo failed during signoff: {e}"));
        }
        if let Some(e) = teardown_err {
            return Err(format!("teardown failed during signoff: {e}"));
        }
        Ok(outcome)
    }
}

/// Sleep `period`, waking early if `stop` is set (10ms slices). Keeps the pulse
/// loop responsive to a kill without busy-spinning.
fn sleep_interruptible(period: Duration, stop: &AtomicBool) {
    const SLICE: Duration = Duration::from_millis(10);
    let mut left = period;
    while left > Duration::ZERO {
        if stop.load(Ordering::Relaxed) {
            return;
        }
        let nap = left.min(SLICE);
        std::thread::sleep(nap);
        left = left.saturating_sub(nap);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_home::with_home;
    use std::sync::atomic::AtomicUsize;

    // A live-capable manifest: a Psyche init template + a commune dir to watch.
    // The Psyche command is a trivial no-op (we never spawn it in these tests;
    // we exercise the pulse/ingest loop, which does not launch the Psyche).
    #[cfg(windows)]
    const NOOP_CMD: &str = "cmd /C rem";
    #[cfg(unix)]
    const NOOP_CMD: &str = "true";

    fn live_manifest(commune_dir: &std::path::Path) -> Manifest {
        let dir = commune_dir.to_string_lossy().replace('\\', "/");
        let toml = format!(
            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
             [session]\ncommune_dir=\"{dir}\"\n\n\
             [session.psyche_init]\ncommand='{NOOP_CMD}'\ncwd=\"{{psyche_dir}}\"\nkeys=[]\n"
        );
        Manifest::from_toml_str(&toml).unwrap()
    }

    #[test] // [unit->REQ-DAEMON-1] a daemon-hosted pulse tick ingests a commune drop.
    fn pulse_tick_ingests_drop() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let manifest = live_manifest(drops.path());
            let host = BrainLifecycle::with_config(&manifest, "doyle", DaemonConfig::default())
                .expect("live host");

            std::fs::write(drops.path().join("doyle-commune.md"), "brief").unwrap();
            let report = host.pulse_tick(Some("sid-1")).expect("tick");
            assert_eq!(report.ingested.len(), 1, "the drop was folded in");
            assert!(!report.echo_fired, "no echo gate armed");
            assert!(!drops.path().join("doyle-commune.md").exists(), "drop consumed");
        });
    }

    #[test] // [unit->REQ-DAEMON-1] the scheduled loop is config-paced + killable.
    fn pulse_loop_is_config_paced_and_killable() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let manifest = live_manifest(drops.path());
            // Fast period so the test is quick; proves the period is config-driven.
            let cfg = DaemonConfig { pulse_period: Duration::from_millis(15), ..DaemonConfig::default() };
            let host = BrainLifecycle::with_config(&manifest, "doyle", cfg).expect("live host");
            assert_eq!(host.pulse_period(), Duration::from_millis(15));

            let stop = AtomicBool::new(false);
            let ticks = AtomicUsize::new(0);
            // Stop the loop from inside on_tick after a few ticks — proves the
            // loop actually schedules repeated ticks and honors the stop flag.
            host.run_pulse_loop(None, &stop, |_| {
                if ticks.fetch_add(1, Ordering::Relaxed) + 1 >= 3 {
                    stop.store(true, Ordering::Relaxed);
                }
            });
            assert!(ticks.load(Ordering::Relaxed) >= 3, "loop ran repeated ticks");
        });
    }
}


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

//! The brain's LiveAgent lifecycle host (B3) — the consolidated re-host of the
//! interim `api listen` runner's live half.
//!
//! In the no-daemon interim model the long-lived `spt api listen` process hosted
//! the LiveAgent lifecycle (Psyche spawn, pulse-driven drop ingest, echo fires,
//! graceful signoff) directly in the agent's session. Consolidation
//! (REQ-DAEMON-1) moves that orchestration **down into `spt-daemon`** so the
//! brain runs it as scheduled in-process loops, with a durable, configurable
//! pulse period ([`crate::config::DaemonConfig`]) instead of the interim 5s
//! constant. The `spt` binary's `LiveHost` becomes a thin shim over this type —
//! a re-host, not a rewrite: the heavy lifting stays in the already-pure
//! [`spt_live`] seams ([`spawn_psyche`], [`ingest_drops`], [`run_echo_commune`],
//! [`graceful_signoff`], [`sweep_stale_signoff`]); the ordering invariants
//! (echo-before-signoff 3.3, grace-before-signoff 1.1, stale-sweep 3.2) live
//! where they always have.
// [impl->REQ-DAEMON-1]

use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use spt_live::{
    fetch_history, graceful_signoff, ingest_drops, run_echo_commune, spawn_psyche,
    sweep_stale_signoff, HistoryRecord, Ingested, PsycheHandle, SignoffOutcome,
};
use spt_runtime::{Manifest, ManifestRuntime};
use spt_store::perch::{self, ParentHint};

use crate::config::DaemonConfig;

/// Bound for a bounded history/echo invocation in the host (carried from M2b).
const LIVE_BOUNDED_TIMEOUT: Duration = Duration::from_secs(30);

/// Wall-clock now in epoch millis — precedence-marker stamp for ingested writes.
fn now_ms() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

/// ISO-ish stamp for the psyche prompt (opaque to the Psyche; just a timestamp).
fn now_stamp() -> String {
    format!("epoch-ms:{}", now_ms())
}

/// One pulse tick's observed effects: any drops ingested + whether an echo fired.
#[derive(Debug, Clone, Default)]
pub struct TickReport {
    pub ingested: Vec<Ingested>,
    pub echo_fired: bool,
}

/// The daemon-hosted LiveAgent lifecycle: the runtime + watched drop dirs built
/// from a live-capable manifest, paced by a config-driven [`DaemonConfig`].
pub struct BrainLifecycle {
    runtime: ManifestRuntime,
    id: String,
    manifest: Manifest,
    commune_dir: Option<PathBuf>,
    signoff_dir: Option<PathBuf>,
    cfg: DaemonConfig,
}

impl BrainLifecycle {
    /// Build a host for `id` from a live-capable manifest (declares
    /// `[session.psyche_init]`); `None` for an absent manifest or a plain relay
    /// endpoint. Loads the durable [`DaemonConfig`] from disk.
    pub fn new_opt(manifest: Option<&Manifest>, id: &str) -> Option<BrainLifecycle> {
        Self::with_config(manifest?, id, DaemonConfig::load())
    }

    /// Build a host from a live-capable manifest with an explicit config (the
    /// testable seam). `None` if the manifest declares no Psyche.
    pub fn with_config(manifest: &Manifest, id: &str, cfg: DaemonConfig) -> Option<BrainLifecycle> {
        manifest.session.psyche_init.as_ref()?;
        Some(BrainLifecycle {
            runtime: ManifestRuntime::new(manifest.clone()),
            id: id.to_string(),
            commune_dir: manifest.session.commune_dir.as_ref().map(PathBuf::from),
            signoff_dir: manifest.session.signoff_dir.as_ref().map(PathBuf::from),
            manifest: manifest.clone(),
            cfg,
        })
    }

    /// The configured pulse period (config-driven, not a constant).
    pub fn pulse_period(&self) -> Duration {
        self.cfg.pulse_period
    }

    /// Base substitution keys spt-core fills for the lifecycle templates.
    fn base_keys(&self, session_id: Option<&str>) -> BTreeMap<String, String> {
        let mut k = BTreeMap::new();
        k.insert("id".to_string(), self.id.clone());
        if let Some(sid) = session_id {
            k.insert("session_id".to_string(), sid.to_string());
        }
        k
    }

    /// The unique watched drop dirs (commune + signoff; usually the same dir).
    fn drop_dirs(&self) -> Vec<PathBuf> {
        let mut dirs: Vec<PathBuf> = Vec::new();
        for d in [self.commune_dir.as_ref(), self.signoff_dir.as_ref()]
            .into_iter()
            .flatten()
        {
            if !dirs.contains(d) {
                dirs.push(d.clone());
            }
        }
        dirs
    }

    /// Sweep a stale signoff sentinel on start (3.2). No-op without a signoff dir.
    pub fn sweep_on_start(&self) {
        if let Some(dir) = &self.signoff_dir {
            let _ = sweep_stale_signoff(dir, &self.id);
        }
    }

    /// Spawn the Psyche companion (T2). `event` is the incoming envelope when the
    /// spawn is event-driven; `None` at cold start.
    pub fn spawn_psyche(
        &self,
        session_id: Option<&str>,
        event: Option<&str>,
    ) -> Result<PsycheHandle, String> {
        spawn_psyche(&self.runtime, &self.id, &self.base_keys(session_id), event, &now_stamp())
            .map_err(|e| e.to_string())
    }

    /// One pulse tick (T5): ingest drops from the watched dirs (T4), then if the
    /// echo-gate is armed fire exactly one echo-commune (T3).
    pub fn pulse_tick(&self, session_id: Option<&str>) -> Result<TickReport, String> {
        let now = now_ms();
        let mut ingested = Vec::new();
        for dir in self.drop_dirs() {
            let mut got =
                ingest_drops(&dir, &self.id, ParentHint::Infer, now, self.cfg.protection_window_ms)
                    .map_err(|e| e.to_string())?;
            ingested.append(&mut got);
        }
        let echo_fired = if spt_live::pulse::take_echo_gate(&self.id, ParentHint::Infer) {
            self.fire_echo(session_id)?;
            true
        } else {
            false
        };
        Ok(TickReport { ingested, echo_fired })
    }

    /// Run the scheduled pulse loop until `stop` is set, calling `on_tick` after
    /// each tick (so a caller — or a test — observes cadence and can decide to
    /// stop). The period comes from the durable config, never a constant. The
    /// loop holds no state beyond the cursor on disk, so it is freely
    /// killable + respawnable: a fresh loop re-reads the drop dirs and gate.
    pub fn run_pulse_loop<F: FnMut(&TickReport)>(
        &self,
        session_id: Option<&str>,
        stop: &AtomicBool,
        mut on_tick: F,
    ) {
        while !stop.load(Ordering::Relaxed) {
            match self.pulse_tick(session_id) {
                Ok(report) => on_tick(&report),
                Err(_) => { /* a transient tick error must not kill the loop */ }
            }
            // Sleep the configured period in small slices so a stop is honored
            // promptly rather than after a full (possibly multi-second) period.
            sleep_interruptible(self.cfg.pulse_period, stop);
        }
    }

    /// Fetch history per the manifest `[history]` strategy (empty if none).
    fn history(&self, session_id: Option<&str>) -> Vec<HistoryRecord> {
        let Some(h) = &self.manifest.history else {
            return Vec::new();
        };
        fetch_history(h, &self.base_keys(session_id), &self.id, ParentHint::Infer, LIVE_BOUNDED_TIMEOUT)
            .unwrap_or_default()
    }

    /// Run the echo-commune into the commune dir (T3). No-op (Ok) without a dir.
    fn fire_echo(&self, session_id: Option<&str>) -> Result<(), String> {
        let Some(dir) = &self.commune_dir else {
            return Ok(());
        };
        let history = self.history(session_id);
        run_echo_commune(
            &self.runtime,
            &self.id,
            &self.base_keys(session_id),
            &history,
            dir,
            LIVE_BOUNDED_TIMEOUT,
        )
        .map(|_| ())
        .map_err(|e| e.to_string())
    }

    /// Execute the graceful signoff (T6): echo-commune BEFORE teardown (3.3),
    /// grace recheck before any signoff write (1.1). The interim graceful path
    /// uses zero grace + always-gone recheck (an intentional shutdown); the
    /// daemon's orphan-watch (B7) supplies the real grace/liveness recheck.
    pub fn graceful_signoff(&self, session_id: Option<&str>) -> Result<SignoffOutcome, String> {
        let mut echo_err: Option<String> = None;
        let mut teardown_err: Option<String> = None;
        let outcome = graceful_signoff::<std::convert::Infallible>(
            session_id,
            Duration::ZERO,
            || {
                if let Err(e) = self.fire_echo(session_id) {
                    echo_err = Some(e);
                }
                Ok(())
            },
            || true, // graceful, intentional shutdown — the Self is leaving on purpose
            || {
                let ready = perch::resolve_ready_file(&self.id, ParentHint::Infer);
                if let Err(e) = std::fs::remove_file(&ready) {
                    if e.kind() != std::io::ErrorKind::NotFound {
                        teardown_err = Some(e.to_string());
                    }
                }
                Ok(())
            },
        )
        .unwrap_or(SignoffOutcome { echo_ran: false, signed_off: false });
        if let Some(e) = echo_err {
            return Err(format!("echo failed during signoff: {e}"));
        }
        if let Some(e) = teardown_err {
            return Err(format!("teardown failed during signoff: {e}"));
        }
        Ok(outcome)
    }
}

/// Sleep `period`, waking early if `stop` is set (10ms slices). Keeps the pulse
/// loop responsive to a kill without busy-spinning.
fn sleep_interruptible(period: Duration, stop: &AtomicBool) {
    const SLICE: Duration = Duration::from_millis(10);
    let mut left = period;
    while left > Duration::ZERO {
        if stop.load(Ordering::Relaxed) {
            return;
        }
        let nap = left.min(SLICE);
        std::thread::sleep(nap);
        left = left.saturating_sub(nap);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_home::with_home;
    use std::sync::atomic::AtomicUsize;

    // A live-capable manifest: a Psyche init template + a commune dir to watch.
    // The Psyche command is a trivial no-op (we never spawn it in these tests;
    // we exercise the pulse/ingest loop, which does not launch the Psyche).
    #[cfg(windows)]
    const NOOP_CMD: &str = "cmd /C rem";
    #[cfg(unix)]
    const NOOP_CMD: &str = "true";

    fn live_manifest(commune_dir: &std::path::Path) -> Manifest {
        let dir = commune_dir.to_string_lossy().replace('\\', "/");
        let toml = format!(
            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
             [session]\ncommune_dir=\"{dir}\"\n\n\
             [session.psyche_init]\ncommand='{NOOP_CMD}'\ncwd=\"{{psyche_dir}}\"\nkeys=[]\n"
        );
        Manifest::from_toml_str(&toml).unwrap()
    }

    #[test] // [unit->REQ-DAEMON-1] a daemon-hosted pulse tick ingests a commune drop.
    fn pulse_tick_ingests_drop() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let manifest = live_manifest(drops.path());
            let host = BrainLifecycle::with_config(&manifest, "doyle", DaemonConfig::default())
                .expect("live host");

            std::fs::write(drops.path().join("doyle-commune.md"), "brief").unwrap();
            let report = host.pulse_tick(Some("sid-1")).expect("tick");
            assert_eq!(report.ingested.len(), 1, "the drop was folded in");
            assert!(!report.echo_fired, "no echo gate armed");
            assert!(!drops.path().join("doyle-commune.md").exists(), "drop consumed");
        });
    }

    #[test] // [unit->REQ-DAEMON-1] the scheduled loop is config-paced + killable.
    fn pulse_loop_is_config_paced_and_killable() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let manifest = live_manifest(drops.path());
            // Fast period so the test is quick; proves the period is config-driven.
            let cfg = DaemonConfig { pulse_period: Duration::from_millis(15), ..DaemonConfig::default() };
            let host = BrainLifecycle::with_config(&manifest, "doyle", cfg).expect("live host");
            assert_eq!(host.pulse_period(), Duration::from_millis(15));

            let stop = AtomicBool::new(false);
            let ticks = AtomicUsize::new(0);
            // Stop the loop from inside on_tick after a few ticks — proves the
            // loop actually schedules repeated ticks and honors the stop flag.
            host.run_pulse_loop(None, &stop, |_| {
                if ticks.fetch_add(1, Ordering::Relaxed) + 1 >= 3 {
                    stop.store(true, Ordering::Relaxed);
                }
            });
            assert!(ticks.load(Ordering::Relaxed) >= 3, "loop ran repeated ticks");
        });
    }
}


NOTE fROM THE USER: proceedwithnextsteps.


Update lib.rs with new modules + re-exports.


Edit

//! The brain + gapless handoff (B2) and the rest of the M3b task table build on
//! this seam. `spt-daemon` is internal supervisor machinery, **not** public SDK
//! (R-ARCH-2).
//!
//! [`Envelope`]: frame::Envelope
//! [`Hello`]: frame::Hello
//! [`DaemonTransport`]: transport::DaemonTransport

pub mod brain;
pub mod broker;
pub mod codec;
pub mod frame;
pub mod msg;
pub mod transport;

pub use brain::{Brain, BrainState, BrokerEvent};
pub use broker::Broker;

//! **B3 (this commit)** consolidates the interim no-daemon model into the brain:
//! the `api listen` pulse / Psyche / drop-ingest loops become scheduled
//! in-process loops ([`lifecycle::BrainLifecycle`]) paced by a durable,
//! configurable period ([`config::DaemonConfig`]) instead of the interim 5s
//! constant; the only residue left in the harness-hosted session is the thin,
//! stateless [`relay::Relay`]. The `spt` binary's `LiveHost` becomes a shim over
//! `BrainLifecycle` (re-host, not rewrite) ([`REQ-DAEMON-1`]).
//!
//! The brain + gapless handoff (B2) and the rest of the M3b task table build on
//! this seam. `spt-daemon` is internal supervisor machinery, **not** public SDK
//! (R-ARCH-2).
//!
//! [`Envelope`]: frame::Envelope
//! [`Hello`]: frame::Hello
//! [`DaemonTransport`]: transport::DaemonTransport

pub mod brain;
pub mod broker;
pub mod codec;
pub mod config;
pub mod frame;
pub mod lifecycle;
pub mod msg;
pub mod relay;
pub mod transport;

#[cfg(test)]
mod test_home;

pub use brain::{Brain, BrainState, BrokerEvent};
pub use broker::Broker;
pub use config::DaemonConfig;
pub use lifecycle::{BrainLifecycle, TickReport};
pub use relay::Relay;


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

//! **B3 (this commit)** consolidates the interim no-daemon model into the brain:
//! the `api listen` pulse / Psyche / drop-ingest loops become scheduled
//! in-process loops ([`lifecycle::BrainLifecycle`]) paced by a durable,
//! configurable period ([`config::DaemonConfig`]) instead of the interim 5s
//! constant; the only residue left in the harness-hosted session is the thin,
//! stateless [`relay::Relay`]. The `spt` binary's `LiveHost` becomes a shim over
//! `BrainLifecycle` (re-host, not rewrite) ([`REQ-DAEMON-1`]).
//!
//! The brain + gapless handoff (B2) and the rest of the M3b task table build on
//! this seam. `spt-daemon` is internal supervisor machinery, **not** public SDK
//! (R-ARCH-2).
//!
//! [`Envelope`]: frame::Envelope
//! [`Hello`]: frame::Hello
//! [`DaemonTransport`]: transport::DaemonTransport

pub mod brain;
pub mod broker;
pub mod codec;
pub mod config;
pub mod frame;
pub mod lifecycle;
pub mod msg;
pub mod relay;
pub mod transport;

#[cfg(test)]
mod test_home;

pub use brain::{Brain, BrainState, BrokerEvent};
pub use broker::Broker;
pub use config::DaemonConfig;
pub use lifecycle::{BrainLifecycle, TickReport};
pub use relay::Relay;

//! `spt-daemon` — the per-machine broker/brain supervisor (M3b).
//!
//! ADR-0004 splits the logical daemon into two implementation layers so a
//! routine self-update swaps the logic without disturbing live endpoints:
//!
//! - a **broker** (stable kernel) holds *only* the un-transferable resources a
//!   live consumer would lose continuity on if the logic restarted — PTY
//!   masters (from [`spt_term`]), spawned harness children, accepted/listening
//!   sockets — and runs no logic;
//! - a **brain** (restartable logic) holds routing, registry, manifest parse,
//!   and the re-hosted [`spt_live`] lifecycle, attaching to the broker over a
//!   **versioned local IPC** so a *newer brain talks to an older broker*.
//!
//! **B0 (this commit)** lands the crate's layer position plus that versioned
//! IPC seam — the load-bearing contract everything else rides:
//!
//! - [`frame`] — the forward-compatible [`Envelope`] schema + the version
//!   [`Hello`] handshake (KNOWN-HAZARDS 2.3, [`REQ-HAZARD-HANDOFF-ARGV-COMPAT`]);
//! - [`codec`] — length-prefixed JSON framing over any [`std::io::Read`]/`Write`;
//! - [`transport`] — the OS-split carrier (named pipe / UDS) behind one
//!   [`DaemonTransport`] trait, mirroring `spt-term`'s `SessionSurface`.
//!
//! **B1 (this commit)** adds the **broker kernel** ([`broker`]) — the stable
//! half that hosts a real `spt-term` PTY child and relays its output/input over
//! the IPC, holding only the ADR-0004 §B resources and no logic — plus the
//! broker↔brain message catalog ([`msg`]).
//!
//! The brain + gapless handoff (B2) and the rest of the M3b task table build on
//! this seam. `spt-daemon` is internal supervisor machinery, **not** public SDK
//! (R-ARCH-2).
//!
//! [`Envelope`]: frame::Envelope
//! [`Hello`]: frame::Hello
//! [`DaemonTransport`]: transport::DaemonTransport

pub mod brain;
pub mod broker;
pub mod codec;
pub mod frame;
pub mod msg;
pub mod transport;

pub use brain::{Brain, BrainState, BrokerEvent};
pub use broker::Broker;
pub use frame::{
    accept_hello, Envelope, HandshakeError, Hello, Role, IPC_PROTOCOL_VERSION,
    MIN_COMPATIBLE_VERSION,
};
pub use transport::{DaemonTransport, LocalSocketTransport};

+//! **B3 (this commit)** consolidates the interim no-daemon model into the brain:

+//! the `api listen` pulse / Psyche / drop-ingest loops become scheduled

+//! in-process loops ([`lifecycle::BrainLifecycle`]) paced by a durable,

+//! configurable period ([`config::DaemonConfig`]) instead of the interim 5s

+//! constant; the only residue left in the harness-hosted session is the thin,

+//! stateless [`relay::Relay`]. The `spt` binary's `LiveHost` becomes a shim over

+//! `BrainLifecycle` (re-host, not rewrite) ([`REQ-DAEMON-1`]).

+pub use lifecycle::{BrainLifecycle, TickReport};


Now rewire the binary's `LiveHost` to delegate to `spt_daemon::BrainLifecycle` (consolidation, no duplicate logic).


Write

//! LiveAgent lifecycle hosting — a thin shim over the daemon's re-hosted
//! lifecycle ([`spt_daemon::BrainLifecycle`]).
//!
//! M2b hosted the LiveAgent lifecycle (Psyche spawn, pulse-driven drop ingest +
//! echo, graceful signoff ordering) directly in the `api listen` runner. B3
//! consolidated that orchestration **down into `spt-daemon`** (REQ-DAEMON-1); the
//! brain now runs it as scheduled in-process loops paced by a durable config.
//! This module keeps the binary's call sites stable — `cmd_listen` /
//! `cmd_shutdown` — by forwarding to `BrainLifecycle`. The logic lives once, in
//! the daemon crate; the duplicate here is gone.
//
// [impl->REQ-SEAM-PSYCHE]
// [impl->REQ-DAEMON-1]

use spt_daemon::{BrainLifecycle, TickReport};
use spt_live::{Ingested, PsycheHandle, SignoffOutcome};
use spt_runtime::Manifest;
use spt_store::perch::{self, ParentHint};

/// The LiveAgent lifecycle host — a thin newtype over the daemon's
/// [`BrainLifecycle`]. Built from a live-capable manifest; `None` for a plain
/// relay endpoint (no Psyche).
pub struct LiveHost(BrainLifecycle);

impl LiveHost {
    /// Build a host for `id` from a live-capable manifest; `None` if the manifest
    /// is absent or declares no Psyche.
    pub fn new_opt(manifest: Option<&Manifest>, id: &str) -> Option<LiveHost> {
        BrainLifecycle::new_opt(manifest, id).map(LiveHost)
    }

    /// Sweep a stale signoff sentinel on listener start (3.2).
    pub fn sweep_on_start(&self) {
        self.0.sweep_on_start();
    }

    /// Spawn the Psyche companion (T2).
    pub fn spawn_psyche(
        &self,
        session_id: Option<&str>,
        event: Option<&str>,
    ) -> Result<PsycheHandle, String> {
        self.0.spawn_psyche(session_id, event)
    }

    /// One pulse tick (T5): ingest drops (T4) then fire a pending echo (T3).
    /// Returns the ingested drops and whether an echo fired (the historical shape
    /// the `api listen` runner consumes).
    pub fn pulse_tick(&self, session_id: Option<&str>) -> Result<(Vec<Ingested>, bool), String> {
        let TickReport { ingested, echo_fired } = self.0.pulse_tick(session_id)?;
        Ok((ingested, echo_fired))
    }

    /// Execute the graceful signoff (T6): echo-commune before teardown (3.3),
    /// grace recheck before any signoff write (1.1).
    pub fn graceful_signoff(&self, session_id: Option<&str>) -> Result<SignoffOutcome, String> {
        self.0.graceful_signoff(session_id)
    }
}

/// `api shutdown <id>` — the graceful live-agent signoff path. Builds the live
/// host from the manifest and runs the echo-before-teardown ordering (T6). A
/// non-live manifest (no Psyche) is a plain soft-stop: just drop the `ready`
/// marker. Auth-gated by the dispatcher.
pub fn cmd_shutdown(id: &str, manifest: Option<&Manifest>) -> i32 {
    let session_id = perch_session_id(id);
    match LiveHost::new_opt(manifest, id) {
        Some(host) => match host.graceful_signoff(session_id.as_deref()) {
            Ok(outcome) => {
                eprintln!(
                    "SHUTDOWN:{id} echo_ran={} signed_off={}",
                    outcome.echo_ran, outcome.signed_off
                );
                0
            }
            Err(e) => {
                eprintln!("SHUTDOWN_FAIL:{id}: {e}");
                super::EXIT_REFUSED
            }
        },
        None => {
            // Non-live endpoint: soft-stop only (remove the online marker).
            let _ = std::fs::remove_file(perch::resolve_ready_file(id, ParentHint::Infer));
            eprintln!("SHUTDOWN:{id} (non-live soft-stop)");
            0
        }
    }
}

/// The bound session id from the perch's info.json, if any.
fn perch_session_id(id: &str) -> Option<String> {
    let path = perch::resolve_perch_path(id, ParentHint::Infer);
    spt_store::info::read_info(&path)
        .map(|i| i.session_id)
        .filter(|s| !s.is_empty())
}


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

//! LiveAgent lifecycle hosting — a thin shim over the daemon's re-hosted
//! lifecycle ([`spt_daemon::BrainLifecycle`]).
//!
//! M2b hosted the LiveAgent lifecycle (Psyche spawn, pulse-driven drop ingest +
//! echo, graceful signoff ordering) directly in the `api listen` runner. B3
//! consolidated that orchestration **down into `spt-daemon`** (REQ-DAEMON-1); the
//! brain now runs it as scheduled in-process loops paced by a durable config.
//! This module keeps the binary's call sites stable — `cmd_listen` /
//! `cmd_shutdown` — by forwarding to `BrainLifecycle`. The logic lives once, in
//! the daemon crate; the duplicate here is gone.
//
// [impl->REQ-SEAM-PSYCHE]
// [impl->REQ-DAEMON-1]

use spt_daemon::{BrainLifecycle, TickReport};
use spt_live::{Ingested, PsycheHandle, SignoffOutcome};
use spt_runtime::Manifest;
use spt_store::perch::{self, ParentHint};

/// The LiveAgent lifecycle host — a thin newtype over the daemon's
/// [`BrainLifecycle`]. Built from a live-capable manifest; `None` for a plain
/// relay endpoint (no Psyche).
pub struct LiveHost(BrainLifecycle);

impl LiveHost {
    /// Build a host for `id` from a live-capable manifest; `None` if the manifest
    /// is absent or declares no Psyche.
    pub fn new_opt(manifest: Option<&Manifest>, id: &str) -> Option<LiveHost> {
        BrainLifecycle::new_opt(manifest, id).map(LiveHost)
    }

    /// Sweep a stale signoff sentinel on listener start (3.2).
    pub fn sweep_on_start(&self) {
        self.0.sweep_on_start();
    }

    /// Spawn the Psyche companion (T2).
    pub fn spawn_psyche(
        &self,
        session_id: Option<&str>,
        event: Option<&str>,
    ) -> Result<PsycheHandle, String> {
        self.0.spawn_psyche(session_id, event)
    }

    /// One pulse tick (T5): ingest drops (T4) then fire a pending echo (T3).
    /// Returns the ingested drops and whether an echo fired (the historical shape
    /// the `api listen` runner consumes).
    pub fn pulse_tick(&self, session_id: Option<&str>) -> Result<(Vec<Ingested>, bool), String> {
        let TickReport { ingested, echo_fired } = self.0.pulse_tick(session_id)?;
        Ok((ingested, echo_fired))
    }

    /// Execute the graceful signoff (T6): echo-commune before teardown (3.3),
    /// grace recheck before any signoff write (1.1).
    pub fn graceful_signoff(&self, session_id: Option<&str>) -> Result<SignoffOutcome, String> {
        self.0.graceful_signoff(session_id)
    }
}

/// `api shutdown <id>` — the graceful live-agent signoff path. Builds the live
/// host from the manifest and runs the echo-before-teardown ordering (T6). A
/// non-live manifest (no Psyche) is a plain soft-stop: just drop the `ready`
/// marker. Auth-gated by the dispatcher.
pub fn cmd_shutdown(id: &str, manifest: Option<&Manifest>) -> i32 {
    let session_id = perch_session_id(id);
    match LiveHost::new_opt(manifest, id) {
        Some(host) => match host.graceful_signoff(session_id.as_deref()) {
            Ok(outcome) => {
                eprintln!(
                    "SHUTDOWN:{id} echo_ran={} signed_off={}",
                    outcome.echo_ran, outcome.signed_off
                );
                0
            }
            Err(e) => {
                eprintln!("SHUTDOWN_FAIL:{id}: {e}");
                super::EXIT_REFUSED
            }
        },
        None => {
            // Non-live endpoint: soft-stop only (remove the online marker).
            let _ = std::fs::remove_file(perch::resolve_ready_file(id, ParentHint::Infer));
            eprintln!("SHUTDOWN:{id} (non-live soft-stop)");
            0
        }
    }
}

/// The bound session id from the perch's info.json, if any.
fn perch_session_id(id: &str) -> Option<String> {
    let path = perch::resolve_perch_path(id, ParentHint::Infer);
    spt_store::info::read_info(&path)
        .map(|i| i.session_id)
        .filter(|s| !s.is_empty())
}

-//! LiveAgent lifecycle hosting (M2b T8) — the `api listen` runner's live half.

+//! LiveAgent lifecycle hosting — a thin shim over the daemon's re-hosted

-//! In the interim no-daemon model, the long-running `api listen` process hosts

-//! the LiveAgent lifecycle over the M2b `spt-live` seams: spawn the Psyche (T2),

-//! pulse-driven drop ingest + echo fires (T3/T4/T5), and the graceful signoff

-//! ordering (T6). The surface stays **thin** — M3's daemon re-hosts all of this

-//! (Psyche-as-loop, daemon-owned period); keep the wiring localized so that move

+//! M2b hosted the LiveAgent lifecycle (Psyche spawn, pulse-driven drop ingest +

+//! echo, graceful signoff ordering) directly in the `api listen` runner. B3

+//! consolidated that orchestration **down into `spt-daemon`** (REQ-DAEMON-1); the

+//! brain now runs it as scheduled in-process loops paced by a durable config.

+//! This module keeps the binary's call sites stable — `cmd_listen` /

+//! `cmd_shutdown` — by forwarding to `BrainLifecycle`. The logic lives once, in

+//! the daemon crate; the duplicate here is gone.

-use std::time::{Duration, SystemTime, UNIX_EPOCH};

-    fetch_history, graceful_signoff, ingest_drops, run_echo_commune, spawn_psyche,

-    sweep_stale_signoff, HistoryRecord, Ingested, PsycheHandle, PulseConfig, SignoffOutcome,

+use spt_live::{Ingested, PsycheHandle, SignoffOutcome};

-/// Bound for a bounded history/echo invocation in the host.

-const LIVE_BOUNDED_TIMEOUT: Duration = Duration::from_secs(30);

+/// The LiveAgent lifecycle host — a thin newtype over the daemon's

+/// [`BrainLifecycle`]. Built from a live-capable manifest; `None` for a plain

-/// Epoch-millis stamp for precedence markers (monotonic enough for the local

-/// guard; the wall clock is fine for the interim single-node window).

-/// ISO-ish stamp for the psyche prompt (opaque to the Psyche; just needs to be

-/// a timestamp). Reuses epoch-millis to avoid a date dependency.

-/// The LiveAgent lifecycle host: the runtime + the watched dirs, built from a

-/// live-capable manifest. `None` for a non-live manifest.

-    /// Build a host for `id` from a live-capable manifest; `None` if the

-    /// manifest is absent or declares no Psyche (a plain relay endpoint).

+    /// Build a host for `id` from a live-capable manifest; `None` if the manifest

+    /// is absent or declares no Psyche.

+        BrainLifecycle::new_opt(manifest, id).map(LiveHost)

-    /// Build a host for `id` from a live-capable manifest; `None` otherwise.

-    pub fn new(manifest: &Manifest, id: &str) -> Option<LiveHost> {

-        manifest.session.psyche_init.as_ref()?;

-            runtime: ManifestRuntime::new(manifest.clone()),

-            commune_dir: manifest.session.commune_dir.as_ref().map(PathBuf::from),

-            signoff_dir: manifest.session.signoff_dir.as_ref().map(PathBuf::from),

-            manifest: manifest.clone(),

-            cfg: PulseConfig::default(),

-    /// Base substitution keys spt-core can fill for the lifecycle templates.

-    fn base_keys(&self, session_id: Option<&str>) -> BTreeMap<String, String> {

-        k.insert("id".to_string(), self.id.clone());

-        if let Some(sid) = session_id {

-            k.insert("session_id".to_string(), sid.to_string());

-    /// The unique watched drop dirs (commune + signoff; usually the same dir).

-    fn drop_dirs(&self) -> Vec<PathBuf> {

-        let mut dirs: Vec<PathBuf> = Vec::new();

-        for d in [self.commune_dir.as_ref(), self.signoff_dir.as_ref()]

-    /// Sweep a stale signoff sentinel on listener start (3.2). No-op if no

+    /// Sweep a stale signoff sentinel on listener start (3.2).

-        if let Some(dir) = &self.signoff_dir {

-            let _ = sweep_stale_signoff(dir, &self.id);

-    /// Spawn the Psyche companion (T2). `event` is the incoming envelope (if the

-    /// spawn is event-driven); `None` at cold start.

+    /// Spawn the Psyche companion (T2).

-            &self.base_keys(session_id),

+        self.0.spawn_psyche(session_id, event)

-    /// One pulse tick (T5): ingest any drops from the watched dirs (T4), then if

-    /// the echo-gate is armed, fire exactly one echo-commune (T3). Returns the

-    /// ingested drops and whether an echo fired.

+    /// One pulse tick (T5): ingest drops (T4) then fire a pending echo (T3).

+    /// Returns the ingested drops and whether an echo fired (the historical shape

+    /// the `api listen` runner consumes).

-            let mut got = ingest_drops(&dir, &self.id, ParentHint::Infer, now, self.cfg.protection_window_ms)

-                .map_err(|e| e.to_string())?;

-        // Read+clear the echo-gate: fire exactly once.

-        let echo_fired = if spt_live::pulse::take_echo_gate(&self.id, ParentHint::Infer) {

-            self.fire_echo(session_id)?;

+        let TickReport { ingested, echo_fired } = self.0.pulse_tick(session_id)?;

-    /// Fetch history per the manifest `[history]` strategy (empty if none).

-    fn history(&self, session_id: Option<&str>) -> Vec<HistoryRecord> {

-        let Some(h) = &self.manifest.history else {

-            &self.base_keys(session_id),

-    /// Run the echo-commune into the commune dir (T3). No-op (Ok) if no commune

-    fn fire_echo(&self, session_id: Option<&str>) -> Result<(), String> {

-        let Some(dir) = &self.commune_dir else {

-        let history = self.history(session_id);

-    /// Execute the graceful signoff (T6): echo-commune BEFORE teardown (3.3),

-    /// grace recheck before any signoff write (1.1). The interim graceful path

-    /// uses a zero grace + always-gone recheck (an intentional shutdown); M3's

-    /// orphan-watch supplies the real grace/liveness recheck. Teardown is the

-    /// soft cleanup (remove the `ready` marker — 6.2), preserving state.

+    /// Execute the graceful signoff (T6): echo-commune before teardown (3.3),

+    /// grace recheck before any signoff write (1.1).

-        let mut echo_err: Option<String> = None;

-        let mut teardown_err: Option<String> = None;

-        let outcome = graceful_signoff::<std::convert::Infallible>(

-                if let Err(e) = self.fire_echo(session_id) {

-                    echo_err = Some(e);

-            || true, // graceful, intentional shutdown — the Self is leaving on purpose

-                // Soft teardown: drop the online marker, preserve info+spool (6.2).

-                let ready = perch::resolve_ready_file(&self.id, ParentHint::Infer);

-                if let Err(e) = std::fs::remove_file(&ready) {

-                    if e.kind() != std::io::ErrorKind::NotFound {

-                        teardown_err = Some(e.to_string());

-        .unwrap_or(SignoffOutcome { echo_ran: false, signed_off: false });

-            return Err(format!("echo failed during signoff: {e}"));

-        if let Some(e) = teardown_err {

-            return Err(format!("teardown failed during signoff: {e}"));

+        self.0.graceful_signoff(session_id)

//! LiveAgent lifecycle hosting (M2b T8) — the `api listen` runner's live half.
//!
//! In the interim no-daemon model, the long-running `api listen` process hosts
//! the LiveAgent lifecycle over the M2b `spt-live` seams: spawn the Psyche (T2),
//! pulse-driven drop ingest + echo fires (T3/T4/T5), and the graceful signoff
//! ordering (T6). The surface stays **thin** — M3's daemon re-hosts all of this
//! (Psyche-as-loop, daemon-owned period); keep the wiring localized so that move
//! is a re-host, not a rewrite.
//
// [impl->REQ-SEAM-PSYCHE]

use std::collections::BTreeMap;
use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use spt_live::{
    fetch_history, graceful_signoff, ingest_drops, run_echo_commune, spawn_psyche,
    sweep_stale_signoff, HistoryRecord, Ingested, PsycheHandle, PulseConfig, SignoffOutcome,
};
use spt_runtime::{Manifest, ManifestRuntime};
use spt_store::perch::{self, ParentHint};

/// Bound for a bounded history/echo invocation in the host.
const LIVE_BOUNDED_TIMEOUT: Duration = Duration::from_secs(30);

/// Epoch-millis stamp for precedence markers (monotonic enough for the local
/// guard; the wall clock is fine for the interim single-node window).
fn now_ms() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

/// ISO-ish stamp for the psyche prompt (opaque to the Psyche; just needs to be
/// a timestamp). Reuses epoch-millis to avoid a date dependency.
fn now_stamp() -> String {
    format!("epoch-ms:{}", now_ms())
}

/// The LiveAgent lifecycle host: the runtime + the watched dirs, built from a
/// live-capable manifest. `None` for a non-live manifest.
pub struct LiveHost {
    runtime: ManifestRuntime,
    id: String,
    manifest: Manifest,
    commune_dir: Option<PathBuf>,
    signoff_dir: Option<PathBuf>,
    cfg: PulseConfig,
}

impl LiveHost {
    /// Build a host for `id` from a live-capable manifest; `None` if the
    /// manifest is absent or declares no Psyche (a plain relay endpoint).
    pub fn new_opt(manifest: Option<&Manifest>, id: &str) -> Option<LiveHost> {
        LiveHost::new(manifest?, id)
    }

    /// Build a host for `id` from a live-capable manifest; `None` otherwise.
    pub fn new(manifest: &Manifest, id: &str) -> Option<LiveHost> {
        manifest.session.psyche_init.as_ref()?;
        Some(LiveHost {
            runtime: ManifestRuntime::new(manifest.clone()),
            id: id.to_string(),
            commune_dir: manifest.session.commune_dir.as_ref().map(PathBuf::from),
            signoff_dir: manifest.session.signoff_dir.as_ref().map(PathBuf::from),
            manifest: manifest.clone(),
            cfg: PulseConfig::default(),
        })
    }

    /// Base substitution keys spt-core can fill for the lifecycle templates.
    fn base_keys(&self, session_id: Option<&str>) -> BTreeMap<String, String> {
        let mut k = BTreeMap::new();
        k.insert("id".to_string(), self.id.clone());
        if let Some(sid) = session_id {
            k.insert("session_id".to_string(), sid.to_string());
        }
        k
    }

    /// The unique watched drop dirs (commune + signoff; usually the same dir).
    fn drop_dirs(&self) -> Vec<PathBuf> {
        let mut dirs: Vec<PathBuf> = Vec::new();
        for d in [self.commune_dir.as_ref(), self.signoff_dir.as_ref()]
            .into_iter()
            .flatten()
        {
            if !dirs.contains(d) {
                dirs.push(d.clone());
            }
        }
        dirs
    }

    /// Sweep a stale signoff sentinel on listener start (3.2). No-op if no
    /// signoff dir is declared.
    pub fn sweep_on_start(&self) {
        if let Some(dir) = &self.signoff_dir {
            let _ = sweep_stale_signoff(dir, &self.id);
        }
    }

    /// Spawn the Psyche companion (T2). `event` is the incoming envelope (if the
    /// spawn is event-driven); `None` at cold start.
    pub fn spawn_psyche(
        &self,
        session_id: Option<&str>,
        event: Option<&str>,
    ) -> Result<PsycheHandle, String> {
        spawn_psyche(
            &self.runtime,
            &self.id,
            &self.base_keys(session_id),
            event,
            &now_stamp(),
        )
        .map_err(|e| e.to_string())
    }

    /// One pulse tick (T5): ingest any drops from the watched dirs (T4), then if
    /// the echo-gate is armed, fire exactly one echo-commune (T3). Returns the
    /// ingested drops and whether an echo fired.
    pub fn pulse_tick(&self, session_id: Option<&str>) -> Result<(Vec<Ingested>, bool), String> {
        let now = now_ms();
        let mut ingested = Vec::new();
        for dir in self.drop_dirs() {
            let mut got = ingest_drops(&dir, &self.id, ParentHint::Infer, now, self.cfg.protection_window_ms)
                .map_err(|e| e.to_string())?;
            ingested.append(&mut got);
        }
        // Read+clear the echo-gate: fire exactly once.
        let echo_fired = if spt_live::pulse::take_echo_gate(&self.id, ParentHint::Infer) {
            self.fire_echo(session_id)?;
            true
        } else {
            false
        };
        Ok((ingested, echo_fired))
    }

    /// Fetch history per the manifest `[history]` strategy (empty if none).
    fn history(&self, session_id: Option<&str>) -> Vec<HistoryRecord> {
        let Some(h) = &self.manifest.history else {
            return Vec::new();
        };
        fetch_history(
            h,
            &self.base_keys(session_id),
            &self.id,
            ParentHint::Infer,
            LIVE_BOUNDED_TIMEOUT,
        )
        .unwrap_or_default()
    }

    /// Run the echo-commune into the commune dir (T3). No-op (Ok) if no commune
    /// dir is declared.
    fn fire_echo(&self, session_id: Option<&str>) -> Result<(), String> {
        let Some(dir) = &self.commune_dir else {
            return Ok(());
        };
        let history = self.history(session_id);
        run_echo_commune(
            &self.runtime,
            &self.id,
            &self.base_keys(session_id),
            &history,
            dir,
            LIVE_BOUNDED_TIMEOUT,
        )
        .map(|_| ())
        .map_err(|e| e.to_string())
    }

    /// Execute the graceful signoff (T6): echo-commune BEFORE teardown (3.3),
    /// grace recheck before any signoff write (1.1). The interim graceful path
    /// uses a zero grace + always-gone recheck (an intentional shutdown); M3's
    /// orphan-watch supplies the real grace/liveness recheck. Teardown is the
    /// soft cleanup (remove the `ready` marker — 6.2), preserving state.
    pub fn graceful_signoff(&self, session_id: Option<&str>) -> Result<SignoffOutcome, String> {
        let mut echo_err: Option<String> = None;
        let mut teardown_err: Option<String> = None;
        let outcome = graceful_signoff::<std::convert::Infallible>(
            session_id,
            Duration::ZERO,
            || {
                if let Err(e) = self.fire_echo(session_id) {
                    echo_err = Some(e);
                }
                Ok(())
            },
            || true, // graceful, intentional shutdown — the Self is leaving on purpose
            || {
                // Soft teardown: drop the online marker, preserve info+spool (6.2).
                let ready = perch::resolve_ready_file(&self.id, ParentHint::Infer);
                if let Err(e) = std::fs::remove_file(&ready) {
                    if e.kind() != std::io::ErrorKind::NotFound {
                        teardown_err = Some(e.to_string());
                    }
                }
                Ok(())
            },
        )
        .unwrap_or(SignoffOutcome { echo_ran: false, signed_off: false });
        if let Some(e) = echo_err {
            return Err(format!("echo failed during signoff: {e}"));
        }
        if let Some(e) = teardown_err {
            return Err(format!("teardown failed during signoff: {e}"));
        }
        Ok(outcome)
    }
}

/// `api shutdown <id>` — the graceful live-agent signoff path. Builds the live
/// host from the manifest and runs the echo-before-teardown ordering (T6). A
/// non-live manifest (no Psyche) is a plain soft-stop: just drop the `ready`
/// marker. Auth-gated by the dispatcher.
pub fn cmd_shutdown(id: &str, manifest: Option<&Manifest>) -> i32 {
    let session_id = perch_session_id(id);
    match LiveHost::new_opt(manifest, id) {
        Some(host) => match host.graceful_signoff(session_id.as_deref()) {
            Ok(outcome) => {
                eprintln!(
                    "SHUTDOWN:{id} echo_ran={} signed_off={}",
                    outcome.echo_ran, outcome.signed_off
                );
                0
            }
            Err(e) => {
                eprintln!("SHUTDOWN_FAIL:{id}: {e}");
                super::EXIT_REFUSED
            }
        },
        None => {
            // Non-live endpoint: soft-stop only (remove the online marker).
            let _ = std::fs::remove_file(perch::resolve_ready_file(id, ParentHint::Infer));
            eprintln!("SHUTDOWN:{id} (non-live soft-stop)");
            0
        }
    }
}

/// The bound session id from the perch's info.json, if any.
fn perch_session_id(id: &str) -> Option<String> {
    let path = perch::resolve_perch_path(id, ParentHint::Infer);
    spt_store::info::read_info(&path)
        .map(|i| i.session_id)
        .filter(|s| !s.is_empty())
}


Activate REQ-DAEMON-1 in registry.


Edit

id = "REQ-DAEMON-1"
title = "One per-machine spt-daemon owning all per-machine state"
required_stages = []

id = "REQ-DAEMON-1"
title = "One per-machine spt-daemon owning all per-machine state"
required_stages = ["impl", "unit"]  # activated M3b-B3: interim listen/pulse/Psyche loops consolidated into the brain (lifecycle/config/relay); int via the B9 daemon E2E


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-DAEMON-1"
title = "One per-machine spt-daemon owning all per-machine state"
required_stages = []

id = "REQ-DAEMON-1"
title = "One per-machine spt-daemon owning all per-machine state"
required_stages = ["impl", "unit"]  # activated M3b-B3: interim listen/pulse/Psyche loops consolidated into the brain (lifecycle/config/relay); int via the B9 daemon E2E

+required_stages = ["impl", "unit"]  # activated M3b-B3: interim listen/pulse/Psyche loops consolidated into the brain (lifecycle/config/relay); int via the B9 daemon E2E

title = "Broker/brain split for seamless self-update"


Now build + test the workspace.


Bash

cd "C:/Users/decid/Documents/projects/spt-core" && cargo build -p spt-daemon -p spt 2>&1 | tail -40

Build spt-daemon + spt


   Compiling spt-daemon v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
   Compiling spt v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 3.32s


Builds. Now clippy + tests.


cd "C:/Users/decid/Documents/projects/spt-core" && cargo clippy -p spt-daemon -p spt --all-targets -- -D warnings 2>&1 | tail -30

Clippy daemon + spt all targets


    Checking spt-daemon v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
    Checking spt v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.09s


cd "C:/Users/decid/Documents/projects/spt-core" && cargo test -p spt-daemon 2>&1 | tail -40

Run spt-daemon tests


test frame::tests::handshake_rejects_role_mismatch ... ok
test frame::tests::handshake_accepts_newer_brain_on_older_broker ... ok
test frame::tests::unknown_kind_is_not_a_decode_error ... ok
test msg::tests::bytes_round_trip_through_payload ... ok
test frame::tests::envelope_with_unknown_trailing_field_decodes_on_older_peer ... ok
test msg::tests::output_envelope_round_trips ... ok
test msg::tests::spawn_req_tolerates_unknown_field ... ok
test transport::tests::local_socket_rejects_wrong_role ... ok
test transport::tests::local_socket_handshake_and_frame_round_trip ... ok
test config::tests::missing_file_is_default ... ok
test config::tests::malformed_file_degrades_to_default ... ok
test config::tests::pulse_period_is_config_driven ... ok
test config::tests::save_load_roundtrip ... ok
test lifecycle::tests::pulse_loop_is_config_paced_and_killable ... ok
test lifecycle::tests::pulse_tick_ingests_drop ... ok
test relay::tests::respawn_loses_no_spooled_message ... ok
test relay::tests::run_honors_stop ... ok

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

     Running tests\broker.rs (target\debug\deps\broker-bdddb47dc258412d.exe)

running 1 test
test broker_hosts_pty_child_streams_output_and_accepts_input ... ok

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

     Running tests\handoff.rs (target\debug\deps\handoff-d3c789195f45f88d.exe)

running 1 test
test brain_restart_survives_gaplessly_with_fresh_gen_start ... ok

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

   Doc-tests spt_daemon

running 0 tests

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


All green. Full workspace test + spt crate (rewire check) + traceability.


cd "C:/Users/decid/Documents/projects/spt-core" && cargo test -p spt 2>&1 | tail -25

Run spt binary tests


test api::startup::tests::empty_session_seed_refused ... ok
test api::startup::tests::missing_seed_refused ... ok
test api::startup::tests::live_perch_under_other_session_conflicts ... ok
test api::startup::tests::post_spawn_bind_establishes_perch ... ok
test api::startup::tests::rebind_same_session_ok ... ok
test api::startup::tests::stale_seed_with_dead_anchor_refused ... ok
test api::startup::tests::valid_seed_binds_live_perch ... ok
test api::worker::tests::cascade_guard_tracks_live_children ... ok
test api::worker::tests::guard_clears_only_when_all_children_stopped ... ok
test api::worker::tests::worker_poll_drains_spool ... ok
test api::worker::tests::worker_start_nests_under_parent ... ok

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

     Running tests\contract_e2e.rs (target\debug\deps\contract_e2e-49502962ba3fcb99.exe)

running 3 tests
SEEDED:61984
test live_agent_lifecycle_e2e ... ok
test mock_adapter_drives_the_full_contract ... ok
SEEDED:61984
test seed_then_listen_binds_and_relays ... ok

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


spt green incl E2E. Now traceability check.


cd "C:/Users/decid/Documents/projects/spt-core" && traceable-reqs check 2>&1 | tail -30

Run traceable-reqs check


  [OK] REQ-SEAM-UPDATE  required: []  stages: -doc -impl -unit -int
       Adapter-update avenue (file-pull / delegated command)
  [OK] REQ-SEC-1  required: []  stages: -doc -impl -unit -int
       Per-endpoint access whitelist: origin-node gate, stateful-firewall (reply/outbound exempt), node-now/user-later, outer gate before grants
  [OK] REQ-START-1  required: [impl, unit]  stages: -doc +impl +unit -int
       Adapters never resolve SPT_HOME; binary on PATH; api bridging only
  [OK] REQ-START-2  required: [impl, unit, int]  stages: -doc +impl +unit +int
       Harness-hosted startup: api seed then listen
  [OK] REQ-START-3  required: []  stages: -doc -impl -unit -int
       spt-hosted startup: spawn-session then api bind (no file)
  [OK] REQ-START-4  required: [impl, unit]  stages: -doc +impl +unit -int
       Adapter-injected env aliases (SPT/OWL/LIVE)
  [OK] REQ-TERM-1  required: [impl, unit]  stages: -doc +impl +unit -int
       Process-supervisor terminal wrapper hosting broker PTYs
  [OK] REQ-TERM-2  required: [impl, unit]  stages: -doc +impl +unit -int
       session-surface abstraction; send-keys + send-line injection
  [OK] REQ-TERM-3  required: [impl, unit]  stages: -doc +impl +unit -int
       Byte-stream remote terminal streaming for v1
  [OK] REQ-TERM-4  required: [impl, unit]  stages: -doc +impl +unit -int
       Live activity buffer (PTY digest): adapter-supplied patterns over broker PTY, spt digest pull + delta-stream, opt-in Path-B log
  [OK] REQ-UPD-1  required: []  stages: -doc -impl -unit -int
       Peer-propagated update over P2P
  [OK] REQ-UPD-2  required: []  stages: -doc -impl -unit -int
       All binaries signature-verified before handoff
  [OK] REQ-UPD-3  required: []  stages: -doc -impl -unit -int
       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


cd "C:/Users/decid/Documents/projects/spt-core" && traceable-reqs check 2>&1 | grep -i "DAEMON-1\|FAIL\|missing\|exit\|error" ; echo "EXIT=$?"; traceable-reqs check >/dev/null 2>&1; echo "CHECK_EXIT=$?"

Confirm DAEMON-1 + overall pass


  [OK] REQ-DAEMON-1  required: [impl, unit]  stages: -doc +impl +unit -int
       Ephemeral perch cleanup on every ring exit path (3.1)
       Concurrent SQLite openers (registry/spool) must not fail with 'database is locked' (4.7)
       Stale registry entries degrade to fallback, never hard-fail (4.3)
       spt binary CLI surface: send/ring/ready/poll/list/stop/whoami, stable arg shapes + exit codes
EXIT=0
CHECK_EXIT=0


REQ-DAEMON-1 now `+impl +unit`, check exit 0. Full workspace test to be safe.


cd "C:/Users/decid/Documents/projects/spt-core" && cargo test --workspace 2>&1 | grep -E "test result|Running|error\[|FAILED|panicked" | tail -40

Full workspace test summary


test result: ok. 40 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.26s
     Running tests\contract_e2e.rs (target\debug\deps\contract_e2e-49502962ba3fcb99.exe)
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.95s
     Running unittests src\lib.rs (target\debug\deps\spt_daemon-462e8ba1a09b16aa.exe)
test result: ok. 21 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.20s
     Running tests\broker.rs (target\debug\deps\broker-c2d939f9a30e6b63.exe)
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s
     Running tests\handoff.rs (target\debug\deps\handoff-37a60ed2c608da11.exe)
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.20s
     Running unittests src\lib.rs (target\debug\deps\spt_live-05605b7918bd90a7.exe)
test result: ok. 35 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.32s
     Running unittests src\lib.rs (target\debug\deps\spt_msg-2380ff08cca80cc7.exe)
test result: ok. 22 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.12s
     Running tests\killer_quickstart.rs (target\debug\deps\killer_quickstart-cbdb2a396ec983ec.exe)
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.20s
     Running unittests src\lib.rs (target\debug\deps\spt_proto-151dd96e47c9f83c.exe)
test result: ok. 49 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s
     Running unittests src\lib.rs (target\debug\deps\spt_runtime-02b505711840dc63.exe)
test result: ok. 17 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.31s
     Running unittests src\lib.rs (target\debug\deps\spt_store-7f8ef5ed5a775913.exe)
test result: ok. 34 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 6.08s
     Running unittests src\lib.rs (target\debug\deps\spt_term-f2b0dd6228a127be.exe)
test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
     Running tests\digest.rs (target\debug\deps\digest-fda0050aff47134e.exe)
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s
     Running tests\dsr.rs (target\debug\deps\dsr-4a5fb9f56511b837.exe)
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s
     Running tests\inject.rs (target\debug\deps\inject-bc8da9d24b68f0f8.exe)
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s
     Running tests\stream.rs (target\debug\deps\stream-87712646e52165bb.exe)
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.01s
     Running tests\surface.rs (target\debug\deps\surface-f0a0a9e39d7d59df.exe)
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s


Whole workspace green. Commit per discipline. Check git state.


cd "C:/Users/decid/Documents/projects/spt-core" && git status --short && echo "---branch---" && git rev-parse --abbrev-ref HEAD

Git status + branch


 M CLAUDE.md
 M crates/spt-daemon/src/lib.rs
 M crates/spt/src/api/live.rs
 M traceable-reqs.toml
?? crates/spt-daemon/src/config.rs
?? crates/spt-daemon/src/lifecycle.rs
?? crates/spt-daemon/src/relay.rs
?? crates/spt-daemon/src/test_home.rs
---branch---
dev-freeform


CLAUDE.md modified but I didn't touch it. Check.


cd "C:/Users/decid/Documents/projects/spt-core" && git diff CLAUDE.md | head -40

Inspect CLAUDE.md diff


warning: in the working copy of 'CLAUDE.md', LF will be replaced by CRLF the next time Git touches it
diff --git a/CLAUDE.md b/CLAUDE.md
index 690a11c..1eab937 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -22,3 +22,8 @@ This project uses `traceable-reqs` (`traceable-reqs.toml` = the authoritative `R
 - Honor every `docs/KNOWN-HAZARDS.md` invariant — these are real bugs already paid for once.
 - Docs are dual-audience (human + AI dev-agent) per `docs/DOCS-STRATEGY.md`; doc generation is CI-gated against drift.
 - Commit messages end with the project's Co-Authored-By trailer.
+
+If you finish a significant body of work without need for user intervention, or if your context gets too high, you can clear your own context and keep moving:
+1. Create a JIT plan for the next immediate body of work, if it isn't already planned
+2. If you are a live agent, /spt:commune with immediate next steps and broad summary of the project's status + end goal
+3. Execute the /self-clear skill
\ No newline at end of file


CLAUDE.md change is pre-existing/unrelated — exclude from the atomic B3 commit. Stage only B3 files.


cd "C:/Users/decid/Documents/projects/spt-core" && git add crates/spt-daemon/src/config.rs crates/spt-daemon/src/lifecycle.rs crates/spt-daemon/src/relay.rs crates/spt-daemon/src/test_home.rs crates/spt-daemon/src/lib.rs crates/spt/src/api/live.rs traceable-reqs.toml && git status --short

Stage B3 files only


warning: in the working copy of 'crates/spt-daemon/src/lib.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt/src/api/live.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-daemon/src/config.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/lifecycle.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/relay.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/src/test_home.rs', LF will be replaced by CRLF the next time Git touches it
 M CLAUDE.md
A  crates/spt-daemon/src/config.rs
M  crates/spt-daemon/src/lib.rs
A  crates/spt-daemon/src/lifecycle.rs
A  crates/spt-daemon/src/relay.rs
A  crates/spt-daemon/src/test_home.rs
M  crates/spt/src/api/live.rs
M  traceable-reqs.toml


Write

feat(M3b-B3): consolidate interim listen/pulse/Psyche loops into the brain

Fold the interim no-daemon lifecycle (the long-lived `api listen` runner's
pulse / drop-ingest / echo / Psyche-spawn loops) down into spt-daemon as
scheduled in-process loops, leaving only a thin stateless in-session relay
residue. Re-host, not rewrite: the heavy lifting stays in the already-pure
spt-live seams; the orchestration moves one layer down so the `spt` binary's
LiveHost is now a shim over it (logic lives once). Activates REQ-DAEMON-1
[impl,unit] — int comes via the B9 daemon E2E.

- config.rs: DaemonConfig — durable, configurable pulse period sourced from
  `<spt_home>/daemon.json`, replacing the interim 5s constant; malformed/absent
  file degrades to the default (a broken config never wedges the daemon).
- lifecycle.rs: BrainLifecycle — the daemon-hosted re-host of LiveHost
  (sweep_on_start / spawn_psyche / pulse_tick / graceful_signoff) plus a
  config-paced, stop-flag-killable scheduled pulse loop. Ordering invariants
  (echo-before-signoff 3.3, grace 1.1, stale-sweep 3.2) stay in the spt-live fns.
- relay.rs: Relay — the thin stateless in-session relay residue (forward spool
  backlog + live poll to a sink). Holds no state, so it is freely
  killable+respawnable without losing a message (backlog is durable in the spool).
- spt/api/live.rs: LiveHost reduced to a thin newtype shim over BrainLifecycle;
  call sites (cmd_listen / cmd_shutdown) unchanged. Duplicate logic removed.

Tests (spt-daemon): config-driven period (+ malformed/roundtrip), daemon-hosted
pulse-tick ingest, config-paced + killable scheduled loop, respawn-loses-no-
spooled-message. `cargo test --workspace` + clippy -D warnings + traceable-reqs
check green (Windows local; Linux via CI).

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


File created successfully at: C:/Users/decid/Documents/projects/spt-core/.git/COMMIT_B3.txt (file state is current in your context — no need to Read it back)

feat(M3b-B3): consolidate interim listen/pulse/Psyche loops into the brain

Fold the interim no-daemon lifecycle (the long-lived `api listen` runner's
pulse / drop-ingest / echo / Psyche-spawn loops) down into spt-daemon as
scheduled in-process loops, leaving only a thin stateless in-session relay
residue. Re-host, not rewrite: the heavy lifting stays in the already-pure
spt-live seams; the orchestration moves one layer down so the `spt` binary's
LiveHost is now a shim over it (logic lives once). Activates REQ-DAEMON-1
[impl,unit] — int comes via the B9 daemon E2E.

- config.rs: DaemonConfig — durable, configurable pulse period sourced from
  `<spt_home>/daemon.json`, replacing the interim 5s constant; malformed/absent
  file degrades to the default (a broken config never wedges the daemon).
- lifecycle.rs: BrainLifecycle — the daemon-hosted re-host of LiveHost
  (sweep_on_start / spawn_psyche / pulse_tick / graceful_signoff) plus a
  config-paced, stop-flag-killable scheduled pulse loop. Ordering invariants
  (echo-before-signoff 3.3, grace 1.1, stale-sweep 3.2) stay in the spt-live fns.
- relay.rs: Relay — the thin stateless in-session relay residue (forward spool
  backlog + live poll to a sink). Holds no state, so it is freely
  killable+respawnable without losing a message (backlog is durable in the spool).
- spt/api/live.rs: LiveHost reduced to a thin newtype shim over BrainLifecycle;
  call sites (cmd_listen / cmd_shutdown) unchanged. Duplicate logic removed.

Tests (spt-daemon): config-driven period (+ malformed/roundtrip), daemon-hosted
pulse-tick ingest, config-paced + killable scheduled loop, respawn-loses-no-
spooled-message. `cargo test --workspace` + clippy -D warnings + traceable-reqs
check green (Windows local; Linux via CI).

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


Bash

cd "C:/Users/decid/Documents/projects/spt-core" && git commit -F .git/COMMIT_B3.txt 2>&1 | tail -8 && rm -f .git/COMMIT_B3.txt && git log --oneline -1

Commit B3


[dev-freeform 33d2a71] feat(M3b-B3): consolidate interim listen/pulse/Psyche loops into the brain
 7 files changed, 677 insertions(+), 187 deletions(-)
 create mode 100644 crates/spt-daemon/src/config.rs
 create mode 100644 crates/spt-daemon/src/lifecycle.rs
 create mode 100644 crates/spt-daemon/src/relay.rs
 create mode 100644 crates/spt-daemon/src/test_home.rs
33d2a71 feat(M3b-B3): consolidate interim listen/pulse/Psyche loops into the brain


Committed. Push to trigger both-OS CI.


cd "C:/Users/decid/Documents/projects/spt-core" && git push origin dev-freeform 2>&1 | tail -8

Push dev-freeform


To https://github.com/SaberMage/spt-core.git
   c40524f..33d2a71  dev-freeform -> dev-freeform


Pushed. Check CI run.


cd "C:/Users/decid/Documents/projects/spt-core" && gh run list --branch dev-freeform --limit 1 2>&1 | head

List latest CI run


completed	success	feat(M3b-B2): brain + gapless handoff (kill/restart survives, fresh g…	ci	dev-freeform	push	26818469920	1m58s	2026-06-02T12:03:45Z


B3 run not registered yet. Wait + check for the new run.


cd "C:/Users/decid/Documents/projects/spt-core" && sleep 15 && gh run list --branch dev-freeform --limit 3 2>&1 | head

Re-list CI runs after delay


in_progress		feat(M3b-B3): consolidate interim listen/pulse/Psyche loops into the …	ci	dev-freeform	push	26819305089	23s	2026-06-02T12:20:48Z
completed	success	feat(M3b-B2): brain + gapless handoff (kill/restart survives, fresh g…	ci	dev-freeform	push	26818469920	1m58s	2026-06-02T12:03:45Z
completed	success	feat(M3b-B1): broker kernel hosts a real PTY child over the IPC	ci	dev-freeform	push	26817760692	3m6s	2026-06-02T11:49:21Z


B3 CI run 26819305089 in progress. Watch to completion (both-OS gate).


cd "C:/Users/decid/Documents/projects/spt-core" && gh run watch 26819305089 --exit-status 2>&1 | tail -15

Watch B3 CI run to completion


  ✓ Cache cargo
  ✓ Build
  ✓ Test
  ✓ Clippy (deny warnings)
  ✓ Post Cache cargo
  ✓ Post Run actions/checkout@v4
  ✓ Complete job

ANNOTATIONS
! Node.js 20 actions are deprecated. The following actions are running on Node.js 20 and may not work as expected: actions/checkout@v4. Actions will be forced to run with Node.js 24 by default starting June 16th, 2026. Node.js 20 will be removed from the runner on September 16th, 2026. Please check if updated versions of these actions are available that support Node.js 24. To opt into Node.js 24 now, set the FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true environment variable on the runner or in your workflow file. Once Node.js 24 becomes the default, you can temporarily opt out by setting ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true. For more information see: https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/
traceability: .github#2

! Node.js 20 actions are deprecated. The following actions are running on Node.js 20 and may not work as expected: actions/checkout@v4. Actions will be forced to run with Node.js 24 by default starting June 16th, 2026. Node.js 20 will be removed from the runner on September 16th, 2026. Please check if updated versions of these actions are available that support Node.js 24. To opt into Node.js 24 now, set the FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true environment variable on the runner or in your workflow file. Once Node.js 24 becomes the default, you can temporarily opt out by setting ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true. For more information see: https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/
test (ubuntu-latest): .github#4


cd "C:/Users/decid/Documents/projects/spt-core" && gh run view 26819305089 --json status,conclusion,jobs --jq '{status,conclusion, jobs: [.jobs[] | {name, conclusion}]}'

Confirm all jobs conclusion


B3 SHIPPED. All three CI jobs green both-OS. Drop commune delta for next step.


Write

## B3 SHIPPED — consolidation landed, CI green both-OS

Commit `33d2a71` "feat(M3b-B3): consolidate interim listen/pulse/Psyche loops into the brain" — pushed to dev-freeform. CI run 26819305089: all three jobs green (test ubuntu ✓ / test windows ✓ / traceability ✓). REQ-DAEMON-1 now [impl,unit] (int comes via B9 E2E).

What B3 delivered (re-host, not rewrite — orchestration moved one layer down into spt-daemon):
- `spt-daemon/src/config.rs` — `DaemonConfig`: durable configurable pulse period from `<spt_home>/daemon.json`, replacing the interim 5s constant; malformed/absent → default (never wedges).
- `spt-daemon/src/lifecycle.rs` — `BrainLifecycle`: the daemon-hosted re-host of the old `spt/api/live.rs::LiveHost` (sweep_on_start / spawn_psyche / pulse_tick / graceful_signoff) + a config-paced, stop-flag-killable `run_pulse_loop`. The 3.3/1.1/3.2 ordering invariants stay in the spt-live pure fns.
- `spt-daemon/src/relay.rs` — `Relay`: the thin STATELESS in-session relay residue (spool backlog + live poll → sink). Holds no state ⇒ freely killable+respawnable, backlog durable in the spool. Tested: respawn-loses-no-spooled-message.
- `spt/api/live.rs` — `LiveHost` reduced to a thin newtype shim over `BrainLifecycle`; cmd_listen/cmd_shutdown call sites unchanged, duplicate logic gone.

NEXT STEP — B4 (daemon-authoritative liveness, REQ-HAZARD-DAEMON-HOSTED-LIVENESS). The brain's endpoint table + `info.json` `status` supersede `is_process_alive(info.pid)` for daemon-hosted perches, localized behind the single liveness resolver (`deliver::is_online` / `registry::clean_stale_entries`) so external/interim perches still pid-probe. Acceptance: killing a daemon-hosted Psyche's summarizer subprocess does NOT flip the perch offline; external perches still probe. Full table: M3b-PLAN.md row B4.

Seam to study for B4: `spt-store/src/proc.rs::is_process_alive` reached via `deliver::is_online` + `registry::clean_stale_entries` — that's the single resolver to localize behind.


File created successfully at: C:/Users/decid/Documents/projects/spt-core/.claude/doyle-commune.md (file state is current in your context — no need to Read it back)

## B3 SHIPPED — consolidation landed, CI green both-OS

Commit `33d2a71` "feat(M3b-B3): consolidate interim listen/pulse/Psyche loops into the brain" — pushed to dev-freeform. CI run 26819305089: all three jobs green (test ubuntu ✓ / test windows ✓ / traceability ✓). REQ-DAEMON-1 now [impl,unit] (int comes via B9 E2E).

What B3 delivered (re-host, not rewrite — orchestration moved one layer down into spt-daemon):
- `spt-daemon/src/config.rs` — `DaemonConfig`: durable configurable pulse period from `<spt_home>/daemon.json`, replacing the interim 5s constant; malformed/absent → default (never wedges).
- `spt-daemon/src/lifecycle.rs` — `BrainLifecycle`: the daemon-hosted re-host of the old `spt/api/live.rs::LiveHost` (sweep_on_start / spawn_psyche / pulse_tick / graceful_signoff) + a config-paced, stop-flag-killable `run_pulse_loop`. The 3.3/1.1/3.2 ordering invariants stay in the spt-live pure fns.
- `spt-daemon/src/relay.rs` — `Relay`: the thin STATELESS in-session relay residue (spool backlog + live poll → sink). Holds no state ⇒ freely killable+respawnable, backlog durable in the spool. Tested: respawn-loses-no-spooled-message.
- `spt/api/live.rs` — `LiveHost` reduced to a thin newtype shim over `BrainLifecycle`; cmd_listen/cmd_shutdown call sites unchanged, duplicate logic gone.

NEXT STEP — B4 (daemon-authoritative liveness, REQ-HAZARD-DAEMON-HOSTED-LIVENESS). The brain's endpoint table + `info.json` `status` supersede `is_process_alive(info.pid)` for daemon-hosted perches, localized behind the single liveness resolver (`deliver::is_online` / `registry::clean_stale_entries`) so external/interim perches still pid-probe. Acceptance: killing a daemon-hosted Psyche's summarizer subprocess does NOT flip the perch offline; external perches still probe. Full table: M3b-PLAN.md row B4.

Seam to study for B4: `spt-store/src/proc.rs::is_process_alive` reached via `deliver::is_online` + `registry::clean_stale_entries` — that's the single resolver to localize behind.


**B3 SHIPPED.** ✅

`33d2a71` pushed to dev-freeform. CI run 26819305089 — all three green: **test ubuntu ✓ / test windows ✓ / traceability ✓**. Both-OS gate met.

What landed (REQ-DAEMON-1 → `[impl,unit]`):
- **`config.rs`** — `DaemonConfig`: durable configurable pulse period from `<spt_home>/daemon.json`, replaces interim 5s constant. Malformed/absent → default, never wedges.
- **`lifecycle.rs`** — `BrainLifecycle`: daemon-hosted re-host of the old `LiveHost` + a config-paced, stop-flag-killable `run_pulse_loop`. Ordering invariants (3.3/1.1/3.2) stay in the spt-live pure fns. Re-host, not rewrite.
- **`relay.rs`** — `Relay`: thin **stateless** in-session relay residue. Holds no state → killable+respawnable, backlog durable in spool. Proven by `respawn_loses_no_spooled_message`.
- **`spt/api/live.rs`** — `LiveHost` now a thin shim over `BrainLifecycle`; duplicate logic gone, call sites unchanged.

11 new spt-daemon tests green; whole workspace + clippy `-D warnings` + `traceable-reqs check` green. Commune dropped for Psyche.

Left `CLAUDE.md` (pre-existing unrelated edit) unstaged — not mine, not part of B3.

**Next: B4** — daemon-authoritative liveness (REQ-HAZARD-DAEMON-HOSTED-LIVENESS). Brain endpoint table + `info.json` `status` supersede `is_process_alive(info.pid)` for daemon-hosted perches, localized behind the one resolver so external perches still pid-probe. Say go to start it.


<command-message>spt:commune</command-message>
<command-name>/spt:commune</command-name>
<command-args>with immediate next steps and broad summary of the project's status + end goal. we'll resume in the next session --> proceed with /self-clear</command-args>


Base directory for this skill: C:\Users\decid\.claude\plugins\cache\cplugs\spt\1.11.24\skills\commune

# /spt:commune

All commands use `$LIVE` env var, auto-injected by the plugin's SessionStart hook. If commands fail with "command not found", restart the Claude Code session so the SessionStart hook re-runs.

> **Identity auto-detection:** Your identity is auto-detected from your session. Pass your ID explicitly only if auto-detection fails.

## Flow

1. **Download Psyche's *current* context** — it shifts during the session as background echo communes fire. Re-download right before composing; the gen-N snapshot you read at session start is already stale:
   ```bash
   $LIVE psyche-download <your-id>
   ```

   > **Output truncation.** If the Bash tool reports `Output too large (N KB)` and saves the full result to a `tool-results/<id>.txt` file, use the `Read` tool on that saved path to access the full content. Do NOT re-run `psyche-download` to grep/awk out sections — repeated invocations re-download the same context, waste tokens, and may duplicate `## Pending Commune` sections in `live_context.md`.

2. **Diff your knowledge against this fresh download** — only what's missing from *it* is the real delta (new work, decisions, intentions). Anything already absorbed is not. If the download includes a `<memformat>` section, use it as a guide for what topics to cover. A `## Pending Commune (uncommitted)` section means a previous commune is still queued — do NOT re-send it.

3. **Commune only the delta** -- use the **Write tool** to create
   `.claude/{your-id}-commune.md` with the two-slice body as its contents
   (see `## Two-slice body shape (Phase 25 D-10/D-11)` below for the required
   envelope wrapping). Do not use Bash/heredoc -- Write is the canonical path:
   no shell escaping, no `EOF` collision risk, and Write auto-creates the
   `.claude/` parent directory.

   Your Self listener detects the file on its next poll iteration, notifies
   your Psyche wrapper, and the wrapper ingests the content via the existing
   Psyche session. On success (subprocess exit code 0) the wrapper deletes the
   file. On error (token rate limit, etc) the file is retained and retried on
   the next consume cycle. If you write again before the wrapper consumes, the
   latest write wins (last-write-wins overwrite semantics).

If Psyche's context is already current, no commune is needed. Say so and move on.

Read `commune.md` (in this skill directory) for the full commune protocol -- when to send, what to include, and diligence triggers.

**Agent-type guard:** Only works on live agents. If target is not live, refuses with: "Communes require a live agent with Psyche."

## Two-slice body shape (Phase 25 D-10/D-11)

The body you write in `.claude/{your-id}-commune.md` MUST itself be wrapped in two-slice envelopes per Phase 25 D-10/D-11. The taxonomy — what belongs in `<live-context>` vs `<project-context>` — is defined canonically in `psyche.md` §`<output_envelope>` (lines 282–323). Read that section as the source of truth; this skill doc teaches only when and how to apply it.

**In-project detection rule (D-25.1-04).** Before composing your commune body, run `$LIVE psyche-download <your-id>` (Step 1 of `## Flow` above) and inspect the `<current ... />` tag at the top of the output. The `<current/>` tag carries a `project="..."` attribute whenever any payload is produced — its presence means you are inside a tracked project (inside a git repo, `project` is the repo name; outside a git repo per D-11, `project` is the cwd-basename fallback, which still counts as a project context for routing purposes). A `psyche-download` invocation that returns no payload at all (NO-CONTEXT exit) means no project resolved. So: `project="..."` populated on `<current/>` → emit BOTH envelopes (apply rule 1 or 2 below); no payload at all → emit ONLY `<live-context>` (rule 3). Secondary signal: the `<project-context-resolved name="..."/>` sentinel further down the output indicates the project file already has prior content — useful for spotting first-time-in-project pairings, but NOT the routing rule.

**Three rules (D-25.1-01..03):**

- **In-project, both slices populated** (marker present, you have project-specific content this cycle): emit `<live-context>...</live-context>` AND `<project-context>...</project-context>`, both with non-empty bodies.
- **In-project, no project-specific update this cycle** (marker present, but nothing project-bound to report): emit `<live-context>...</live-context>` AND `<project-context></project-context>` (empty body — the deliberate "in-project but quiet" signal per D-25.1-02). Distinguishable from "no project resolved".
- **Outside any tracked project** (marker absent): emit ONLY `<live-context>...</live-context>`. Do NOT emit an empty `<project-context>` envelope (missing tag means "no project resolved" per D-25.1-03).

**Nested envelopes.** The Phase 23 `<EVENT type="commune">` envelope (see `## Envelope shape (Phase 23 v1.8)` below) is composed by `$LIVE commune` automatically — you NEVER write `<EVENT>` tags. You write ONLY the inner two-slice body. The two envelope layers nest: outer EVENT wraps inner two-slice body.

(See `psyche.md` §`<output_envelope>` for the canonical taxonomy and per-slice content rules.)

## Envelope shape (Phase 23 v1.8)

As of v1.8 Phase 23, commune deliveries are wrapped in a typed EVENT envelope carrying a 5-field stamp (`machine`, `project`, `branch`, `head_sha`, `head_subject`); the prose `COMMUNE (timestamp): body` form is gone.

```xml
<EVENT type="commune" timestamp="2026-05-20T06:00:00-07:00" machine="hostname" project="claude_skill_owl" branch="main" head_sha="abc123..." head_subject="subject line capped at 72 chars">body</EVENT>
```

Outside a git repo (D-11), `branch`, `head_sha`, and `head_subject` are omitted entirely (not empty-string-set):

```xml
<EVENT type="commune" timestamp="2026-05-20T06:00:00-07:00" machine="hostname" project="cwd-basename">body</EVENT>
```

`$LIVE commune` composes this envelope automatically — Self does NOT manually construct EVENT XML. The `body` placeholder in the example above is the two-slice body (see `## Two-slice body shape (Phase 25 D-10/D-11)` above); the two envelope layers nest. The wrapper-side parser ignores unknown attrs, so the stamp fields are forward-compatible with older receivers. For drift detection on resume, see the `live` skill.

ARGUMENTS: with immediate next steps and broad summary of the project's status + end goal. we'll resume in the next session --> proceed with /self-clear


<owl_reachability_notice>
You have an active owl perch. When spawning subagents or doing work, use run_in_background: true so you stay reachable for incoming messages.
</owl_reachability_notice>


CAVEMAN MODE ACTIVE (full). Drop articles/filler/pleasantries/hedging. Fragments OK. Code/commits/security: write normal.


Commune then self-clear. First download fresh Psyche context to diff delta.


Bash

cd "C:/Users/decid/Documents/projects/spt-core" && $LIVE psyche-download doyle 2>&1 | head -60

Download fresh Psyche context


<memformat>
<?xml version="1.0"?>
<memformat version="1">
<!-- Changelog: v1 - initial seed -->
<topics>
  <topic name="events">Notable or unusual events and requests</topic>
  <topic name="interactions">Agent interactions -- who, roles, contributions</topic>
  <topic name="reflections">Thoughts on recent work -- what went well, what was tricky</topic>
  <topic name="ideas">Ideas to raise with the user</topic>
  <topic name="improvements">Further improvement ideas for recent problem spaces</topic>
  <topic name="timeline">What happened when, timing, sequencing</topic>
  <topic name="blockers">Current blockers or concerns</topic>
  <topic name="focus">Current focus and immediate next steps</topic>
</topics>
</memformat>
</memformat>

<current machine="HFENDULEAM" project="spt-core" branch="dev-freeform" head_sha="978e62b91fb28c37334061785c883244c9a7bf8a" head_subject="Continuous work encouragement rev" commits_since="0" commits_unpulled="0"/>
## B3 SHIPPED — consolidation landed, CI green both-OS

Commit `33d2a71` "feat(M3b-B3): consolidate interim listen/pulse/Psyche loops into the brain" — pushed to dev-freeform. CI run 26819305089: all three jobs green (test ubuntu ✓ / test windows ✓ / traceability ✓). REQ-DAEMON-1 now [impl,unit] (int comes via B9 E2E).

What B3 delivered (re-host, not rewrite — orchestration moved one layer down into spt-daemon):
- `spt-daemon/src/config.rs` — `DaemonConfig`: durable configurable pulse period from `<spt_home>/daemon.json`, replacing the interim 5s constant; malformed/absent → default (never wedges).
- `spt-daemon/src/lifecycle.rs` — `BrainLifecycle`: the daemon-hosted re-host of the old `spt/api/live.rs::LiveHost` (sweep_on_start / spawn_psyche / pulse_tick / graceful_signoff) + a config-paced, stop-flag-killable `run_pulse_loop`. The 3.3/1.1/3.2 ordering invariants stay in the spt-live pure fns.
- `spt-daemon/src/relay.rs` — `Relay`: the thin STATELESS in-session relay residue (spool backlog + live poll → sink). Holds no state ⇒ freely killable+respawnable, backlog durable in the spool. Tested: respawn-loses-no-spooled-message.
- `spt/api/live.rs` — `LiveHost` reduced to a thin newtype shim over `BrainLifecycle`; cmd_listen/cmd_shutdown call sites unchanged, duplicate logic gone.

NEXT STEP — B4 (daemon-authoritative liveness, REQ-HAZARD-DAEMON-HOSTED-LIVENESS). The brain's endpoint table + `info.json` `status` supersede `is_process_alive(info.pid)` for daemon-hosted perches, localized behind the single liveness resolver (`deliver::is_online` / `registry::clean_stale_entries`) so external/interim perches still pid-probe. Acceptance: killing a daemon-hosted Psyche's summarizer subprocess does NOT flip the perch offline; external perches still probe. Full table: M3b-PLAN.md row B4.

Seam to study for B4: `spt-store/src/proc.rs::is_process_alive` reached via `deliver::is_online` + `registry::clean_stale_entries` — that's the single resolver to localize behind.<project-context-resolved name="spt-core"/>
## spt-core — where we are (2026-06-02, branch dev-freeform @ c40524f)

**End goal:** a harness-independent core for an agent ecosystem (messaging, live-agent lifecycle, terminal hosting, first-class P2P networking, runtime-manifest harness contract) — Rust crates + a single `spt` binary. Proof: the same agent runs natively across all a user's machines with a synced mind, reachable/operable from anywhere, seamless self-update, zero-config cross-machine messaging. First thing built atop it: a rebuilt `spt` plugin at parity with today's SPT.

**Milestone status:** M0/M1/M2a/M2b/M3a all SHIPPED. **M3b (`spt-daemon` broker/brain process) IN PROGRESS — B0/B1/B2 of B0–B9 done**, all CI-green on ubuntu+windows. The broker/brain structural core + the hardest invariant (Spike #1's child-survives-brain-restart, gapless + exactly-once) are now REAL on both ConPTY and forkpty.

**IMMEDIATE NEXT STEP — B3 (consolidation, REQ-DAEMON-1).** A large integration phase. Fold the interim no-daemon loops into the brain as scheduled in-process loops; leave only a thin stateless in-session relay. Already scoped the seams:
- **`api listen` relay loop** at `crates/spt/src/api/startup.rs:221` (`check_message_blocking → emit` + `spool::drain_non_deferred_at → emit` + `pulse`) → becomes the killable topology-1 relay residue.
- **pulse** `crates/spt-live/src/pulse.rs` (`tick`, `PulseConfig`, 5s `DEFAULT_PULSE_PERIOD` constant) → daemon-scheduled, durable, CONFIGURABLE period.
- **Psyche/echo/ingest/signoff** via `super::live::LiveHost` (`new_opt`/`sweep_on_start`/`spawn_psyche`/`pulse_tick`) calling pure `spt-live` fns (`run_echo_commune`, `ingest_drops`, `graceful_signoff`, `sweep_stale_signoff`, `spawn_psyche`, `resume_psyche`, `compose_psyche_prompt`) → brain in-process loops. RE-HOST not rewrite (M2b seams are already pure fns) — the ordering invariants 3.3/1.1/3.2 stay where they are.
- B3 acceptance: a LiveAgent runs fully daemon-hosted (spawn→Psyche-loop→commune-ingest→pulse) with NO separate listener/wrapper process; relay killable+respawnable without losing state; pulse period config-driven.

**Then B4–B9:** B4 daemon-authoritative liveness (REQ-HAZARD-DAEMON-HOSTED-LIVENESS), B5 idempotent broker↔brain boundary (REQ-HAZARD-RESTART-IDEMPOTENT, Spike #6 — durable IDs + dedup-at-effect + broker-owned recovery anchor), B6 auto-start + in-memory seed (retire the seed file; REQ-DAEMON-3/START-3), B7 orphan-watch (carry M2b grace/echo/stale-sweep into supervised-crash path), B8 digest daemon-half (REQ-TERM-4 `int`, ADR-0008), B9 activation sweep + daemon E2E + ROADMAP/CONTEXT amend + M3c stub. Full task table: `M3b-PLAN.md`.

**Working discipline that held all session:** atomic commit per task with evidence tags in the same commit; `traceable-reqs check` green before declaring done (116/116 now); both-OS validated via CI before the next task; commit trailer `Co-Authored-By: Claude Opus 4.8 (1M context)`. Activate reqs only when the delivering task lands (B-task → its reqs). M4 reqs + REQ-UPD-* stay `[]`.

**After M3b:** M3c (peer-propagated signed self-update), M4 (networking/instances — the real cross-node proof), M5 (shells/presence/deferred capabilities).


/spt:commune with immediate next steps and broad summary of the project's status + end goal. we'll resume in the next session --> proceed with /self-clear


Download current. My B3 commune already ingested in `<live-context>`, but `<project-context>` still says "B3 is next" — stale. Delta = project-status correction + session-end note. Write two-slice commune.