Command running in background with ID: bjlemo3v2. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\bjlemo3v2.output. You will be notified when it completes. To check interim output, use Read on that file path.


1	//! spt-core live-agent lifecycle executor (M2b).
2	//!
3	//! `spt-live` *executes* the lifecycle seams that `spt-runtime` (M2a) only
4	//! declared and parsed. It is the fifth layer in the acyclic stack
5	//! (`spt-proto -> spt-store -> spt-msg -> spt-runtime -> spt-live`); the `spt`
6	//! binary's `api listen` runner hosts it.
7	//!
8	//! Unlike `spt-runtime`, `spt-live` is **not** part of the public SDK surface
9	//! (R-ARCH-2 is proto/runtime/msg) — it is the daemon-bound executor M3
10	//! consolidates into `spt-daemon`. Keep every seam thin so that move is a
11	//! re-host, not a rewrite (mirror M2a's seed-file discipline).
12	//!
13	//! Subsystems land across M2b (see `M2b-PLAN.md`):
14	//! - **history** (T1, [`REQ-SEAM-HISTORY`]) — the three `[history]` strategies
15	//!   feeding the echo-commune + resume briefs.
16	//! - **spawn-psyche** (T2, `REQ-SEAM-PSYCHE`) — the nested `<id>-psyche` perch.
17	//! - **echo-commune** (T3) — the cheap-model brief consuming history.
18	//! - **drop-file ingest** (T4) — single-writer commune/signoff ingest (6.4/6.5).
19	//! - **pulse** (T5) — the interim heartbeat in the `api listen` runner.
20	//! - **signoff/boundary** (T6) — the orphan/grace ordering invariants (1.1/3.2/3.3).
21	//! - **resume** (T7, `REQ-SEAM-RESUME`) — fresh-with-preload + continue-existing.
22	//
23	// The interim no-daemon model holds (decision 2026-06-01): the Psyche is a real
24	// subprocess and the `api listen` process hosts the pulse/ingest loops. M3 moves
25	// both into the daemon. See `M2b-PLAN.md` §Interim-model note.
26
27	pub mod context;
28	pub mod echo;
29	pub mod history;
30	pub mod ingest;
31	pub mod psyche;
32	pub mod pulse;
33	pub mod resume;
34	pub mod signoff;
35
36	#[cfg(test)]
37	mod test_support;
38
39	pub use context::{write_context, WriteOutcome, WriteSource};
40	pub use echo::{run_echo_commune, stamp_provenance, EchoError, EchoResult};
41	pub use history::{fetch_history, HistoryError, HistoryRecord};
42	pub use ingest::{ingest_drops, DropKind, Ingested};
43	pub use psyche::{compose_psyche_prompt, spawn_psyche, PsycheError, PsycheHandle};
44	pub use pulse::{tick, PulseConfig, TickOutcome};
45	pub use resume::{download_psyche_context, resume_psyche, ResumeError, ResumeMode};
46	pub use signoff::{
47	    graceful_signoff, sweep_stale_signoff, write_resume_commune, SignoffOutcome,
48	};
49

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

pub mod context;
pub mod echo;
pub mod history;
pub mod ingest;
pub mod psyche;
pub mod pulse;
pub mod resume;
pub mod signoff;

#[cfg(test)]
mod test_support;

pub use context::{write_context, WriteOutcome, WriteSource};
pub use echo::{run_echo_commune, stamp_provenance, EchoError, EchoResult};
pub use history::{fetch_history, HistoryError, HistoryRecord};
pub use ingest::{ingest_drops, DropKind, Ingested};
pub use psyche::{compose_psyche_prompt, spawn_psyche, PsycheError, PsycheHandle};
pub use pulse::{tick, PulseConfig, TickOutcome};
pub use resume::{download_psyche_context, resume_psyche, ResumeError, ResumeMode};
pub use signoff::{
    graceful_signoff, sweep_stale_signoff, write_resume_commune, SignoffOutcome,
};


1	//! Perch-dir rename ripple — the on-disk arm of `spt rename <id> <new_id>`
2	//! (D3f, ADR-0006 §2).
3	//!
4	//! Renames an endpoint's Self perch directory `owlery/<old>/` → `owlery/<new>/` and
5	//! ripples the nested children whose ids embed the parent name (`<old>-psyche` →
6	//! `<new>-psyche`, `<old>-w{N}` → `<new>-w{N}` — the [`PerchKind`]
7	//! (crate::perch::PerchKind) naming convention the path resolver infers parents
8	//! from), rewriting each perch's `info.json` `id` to match. Refuses while the perch
9	//! (or any nested child) is **live** — renaming under a running listener would strand
10	//! its registration and spool handles; stop first.
11	//!
12	//! The local SQLite address registry (`<owlery>/.registry`) is deliberately NOT
13	//! touched: rows exist only for live listeners, a live perch refuses the rename, so
14	//! any old-id row is by definition stale and the stale-entry invariant
15	//! (KNOWN-HAZARDS 4.3) drops it on the next resolve.
16	//!
17	//! Sibling arms: subnet-registry rows = `spt_net::net::registry::rename_endpoint`
18	//! (collision-checked per target subnet); the `a-<id>` context branch lands with the
19	//! context store (D6); cross-node ripple rides the D4 wire (6.5-reconciled).
20
21	use std::path::Path;
22
23	use crate::info::{read_info, write_info};
24	use crate::liveness::is_perch_alive;
25
26	/// Why a perch-dir rename refused. All variants leave the owlery untouched.
27	#[derive(Debug, Clone, PartialEq, Eq)]
28	pub enum RenameDirError {
29	    /// No perch directory for the old id.
30	    NotFound(String),
31	    /// A perch directory for the new id already exists (never overwrite).
32	    TargetExists(String),
33	    /// The named perch (Self or a nested child) is currently live — stop it first.
34	    Live(String),
35	    /// Filesystem failure mid-operation (message preserved).
36	    Io(String),
37	}
38
39	impl std::fmt::Display for RenameDirError {
40	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41	        match self {
42	            RenameDirError::NotFound(id) => write!(f, "no perch for: {id}"),
43	            RenameDirError::TargetExists(id) => write!(f, "perch already exists: {id}"),
44	            RenameDirError::Live(id) => write!(f, "perch is live (stop it first): {id}"),
45	            RenameDirError::Io(e) => write!(f, "perch rename io: {e}"),
46	        }
47	    }
48	}
49
50	impl std::error::Error for RenameDirError {}
51
52	/// Rename the Self perch `owlery/<old_id>/` to `owlery/<new_id>/`, rippling nested
53	/// children and every `info.json` id. Returns the perch ids written (Self first,
54	/// then children) — the rippled set.
55	///
56	/// Liveness is checked on the Self perch AND every nested child **before** any
57	/// mutation; the directory rename itself is a single atomic `fs::rename`, after
58	/// which the `info.json` rewrites are local repairs inside the already-moved tree.
59	// [impl->REQ-INST-11]
60	pub fn rename_perch_in(

//! Perch-dir rename ripple — the on-disk arm of `spt rename <id> <new_id>`
//! (D3f, ADR-0006 §2).
//!
//! Renames an endpoint's Self perch directory `owlery/<old>/` → `owlery/<new>/` and
//! ripples the nested children whose ids embed the parent name (`<old>-psyche` →
//! `<new>-psyche`, `<old>-w{N}` → `<new>-w{N}` — the [`PerchKind`]
//! (crate::perch::PerchKind) naming convention the path resolver infers parents
//! from), rewriting each perch's `info.json` `id` to match. Refuses while the perch
//! (or any nested child) is **live** — renaming under a running listener would strand
//! its registration and spool handles; stop first.
//!
//! The local SQLite address registry (`<owlery>/.registry`) is deliberately NOT
//! touched: rows exist only for live listeners, a live perch refuses the rename, so
//! any old-id row is by definition stale and the stale-entry invariant
//! (KNOWN-HAZARDS 4.3) drops it on the next resolve.
//!
//! Sibling arms: subnet-registry rows = `spt_net::net::registry::rename_endpoint`
//! (collision-checked per target subnet); the `a-<id>` context branch lands with the
//! context store (D6); cross-node ripple rides the D4 wire (6.5-reconciled).

use std::path::Path;

use crate::info::{read_info, write_info};
use crate::liveness::is_perch_alive;

/// Why a perch-dir rename refused. All variants leave the owlery untouched.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RenameDirError {
    /// No perch directory for the old id.
    NotFound(String),
    /// A perch directory for the new id already exists (never overwrite).
    TargetExists(String),
    /// The named perch (Self or a nested child) is currently live — stop it first.
    Live(String),
    /// Filesystem failure mid-operation (message preserved).
    Io(String),
}

impl std::fmt::Display for RenameDirError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            RenameDirError::NotFound(id) => write!(f, "no perch for: {id}"),
            RenameDirError::TargetExists(id) => write!(f, "perch already exists: {id}"),
            RenameDirError::Live(id) => write!(f, "perch is live (stop it first): {id}"),
            RenameDirError::Io(e) => write!(f, "perch rename io: {e}"),
        }
    }
}

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

/// Rename the Self perch `owlery/<old_id>/` to `owlery/<new_id>/`, rippling nested
/// children and every `info.json` id. Returns the perch ids written (Self first,
/// then children) — the rippled set.
///
/// Liveness is checked on the Self perch AND every nested child **before** any
/// mutation; the directory rename itself is a single atomic `fs::rename`, after
/// which the `info.json` rewrites are local repairs inside the already-moved tree.
// [impl->REQ-INST-11]
pub fn rename_perch_in(


1	//! resume-session seam (M2b T7) — re-entry into a Psyche's mind.
2	//!
3	//! Two distinct forms (CONTEXT §resume-session seam), both spawned from
4	//! `[session.psyche_resume]`:
5	//! - **continue-existing** — resume an existing harness session under the
6	//!   adapter (its native `--resume {session_id}`). Requires `session_id`.
7	//! - **fresh-with-preload** — resume with *cleared* context (a fresh session)
8	//!   plus the **psyche-download**: the prior context, preloaded via the
9	//!   `{psyche_context}` key (or the adapter pulls it in its SessionStart hook).
10	//!   Requires `psyche_context`.
11	//!
12	//! The required key for the chosen mode is validated **before** spawn — a
13	//! wrong/missing key errors cleanly, never a half-launched session
14	//! (REQ-SEAM-RESUME). Both wire into the spawn path and the boundary re-entry
15	//! (T6 authors the resume commune; this preloads it).
16	//
17	// Clean-room over the M2a spawn seam; the native-resume mechanics live in the
18	// adapter's opaque psyche_resume template (model + --resume flag), never here.
19	// [impl->REQ-SEAM-RESUME]
20
21	use crate::ingest::context_path;
22	use crate::psyche::{spawn_and_bind, PsycheError, PsycheHandle};
23	use spt_store::perch::ParentHint;
24	use std::collections::BTreeMap;
25
26	/// The role spawned for a Psyche re-entry.
27	pub const PSYCHE_RESUME_ROLE: &str = "psyche_resume";
28
29	/// Which resume form to perform.
30	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31	pub enum ResumeMode {
32	    /// Native resume of an existing harness session (`{session_id}`).
33	    ContinueExisting,
34	    /// Fresh cleared session preloaded with the psyche-download (`{psyche_context}`).
35	    FreshWithPreload,
36	}
37
38	impl ResumeMode {
39	    /// The substitution key this mode *requires* the caller to have supplied.
40	    fn required_key(self) -> &'static str {
41	        match self {
42	            ResumeMode::ContinueExisting => "session_id",
43	            ResumeMode::FreshWithPreload => "psyche_context",
44	        }
45	    }
46	}
47
48	/// A resume failure.
49	#[derive(Debug)]
50	pub enum ResumeError {
51	    /// The required key for the chosen mode is missing/empty — caught *before*
52	    /// any spawn.
53	    MissingResumeKey {
54	        mode: ResumeMode,
55	        key: &'static str,
56	    },
57	    /// Spawn / bind failure from the Psyche core.
58	    Psyche(PsycheError),
59	}
60
61	impl std::fmt::Display for ResumeError {
62	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63	        match self {
64	            ResumeError::MissingResumeKey { mode, key } => {
65	                write!(f, "resume mode {mode:?} requires key `{key}` (missing before spawn)")
66	            }
67	            ResumeError::Psyche(e) => write!(f, "resume spawn failed: {e}"),
68	        }
69	    }
70	}
71
72	impl std::error::Error for ResumeError {}
73
74	/// Read the perch's stored context — the **psyche-download** a fresh-with-preload
75	/// resume feeds back in via `{psyche_context}`. `None` if nothing is stored yet.
76	pub fn download_psyche_context(id: &str, hint: ParentHint<'_>) -> Option<String> {
77	    std::fs::read_to_string(context_path(id, hint)).ok()
78	}
79
80	/// Resume a Psyche for `parent_id` in the chosen `mode`. The mode's required key
81	/// is validated against `keys` **before** spawn; on success the `psyche_resume`
82	/// role is spawned and the nested perch rebound.
83	pub fn resume_psyche(
84	    runtime: &dyn spt_runtime::AgentRuntime,
85	    parent_id: &str,
86	    mode: ResumeMode,
87	    keys: &BTreeMap<String, String>,
88	    event: Option<&str>,
89	    now: &str,
90	) -> Result<PsycheHandle, ResumeError> {
91	    // Validate the mode's required key BEFORE any spawn (REQ-SEAM-RESUME).
92	    let required = mode.required_key();
93	    if keys.get(required).map(String::as_str).unwrap_or("").is_empty() {
94	        return Err(ResumeError::MissingResumeKey {
95	            mode,
96	            key: required,
97	        });
98	    }
99	    spawn_and_bind(runtime, PSYCHE_RESUME_ROLE, parent_id, keys, event, now)
100	        .map_err(ResumeError::Psyche)
101	}
102
103	#[cfg(test)]
104	mod tests {
105	    use super::*;
106	    use crate::test_support::with_home;
107	    use spt_runtime::{Manifest, ManifestRuntime};
108
109	    // psyche_resume templates that record their resume input into a marker file
110	    // in the cwd (the nested perch dir).
111	    #[cfg(windows)]
112	    const RESUME_SID: &str = "cmd /C echo resumed={session_id} > resumed.txt";
113	    #[cfg(unix)]
114	    const RESUME_SID: &str = "sh -c \"echo resumed={session_id} > resumed.txt\"";
115	    #[cfg(windows)]
116	    const PRELOAD_CTX: &str = "cmd /C echo preload={psyche_context} > preload.txt";
117	    #[cfg(unix)]
118	    const PRELOAD_CTX: &str = "sh -c \"echo preload={psyche_context} > preload.txt\"";
119
120	    fn resume_manifest(cmd: &str) -> Manifest {
121	        let toml = format!(
122	            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
123	             [session.psyche_resume]\ncommand='{cmd}'\ncwd=\"{{psyche_dir}}\"\n"
124	        );
125	        Manifest::from_toml_str(&toml).unwrap()
126	    }
127
128	    fn keys(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
129	        pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
130	    }
131
132	    fn wait_marker(path: &std::path::Path) -> String {
133	        let start = std::time::Instant::now();
134	        loop {
135	            if let Ok(s) = std::fs::read_to_string(path) {
136	                if !s.trim().is_empty() {
137	                    return s;
138	                }
139	            }
140	            assert!(start.elapsed() < std::time::Duration::from_secs(5), "marker never written");
141	            std::thread::sleep(std::time::Duration::from_millis(20));
142	        }
143	    }
144
145	    #[test] // [unit->REQ-SEAM-RESUME] continue-existing re-enters an existing session.
146	    fn continue_existing_resumes_session() {
147	        with_home(|_| {
148	            let rt = ManifestRuntime::new(resume_manifest(RESUME_SID));
149	            let h = resume_psyche(
150	                &rt,
151	                "doyle",
152	                ResumeMode::ContinueExisting,
153	                &keys(&[("session_id", "sid-42")]),
154	                None,
155	                "t0",
156	            )
157	            .expect("resume");
158	            assert_eq!(h.id, "doyle-psyche");
159	            let marker = wait_marker(&h.perch_path.join("resumed.txt"));
160	            assert!(marker.contains("sid-42"), "session id not resumed: {marker:?}");
161	        });
162	    }
163
164	    #[test] // [unit->REQ-SEAM-RESUME] fresh-with-preload launches cleared + preloaded.
165	    fn fresh_with_preload_launches_preloaded() {
166	        with_home(|_| {
167	            let rt = ManifestRuntime::new(resume_manifest(PRELOAD_CTX));
168	            let h = resume_psyche(
169	                &rt,
170	                "doyle",
171	                ResumeMode::FreshWithPreload,
172	                &keys(&[("psyche_context", "PRIORCTX")]),
173	                None,
174	                "t0",
175	            )
176	            .expect("resume");
177	            let marker = wait_marker(&h.perch_path.join("preload.txt"));
178	            assert!(marker.contains("PRIORCTX"), "context not preloaded: {marker:?}");
179	        });
180	    }
181
182	    #[test] // [unit->REQ-SEAM-RESUME] a missing required key errors BEFORE spawn.
183	    fn missing_key_errors_before_spawn() {
184	        with_home(|_| {
185	            let rt = ManifestRuntime::new(resume_manifest(RESUME_SID));
186	            // ContinueExisting with no session_id.
187	            let e = resume_psyche(
188	                &rt,
189	                "doyle",
190	                ResumeMode::ContinueExisting,
191	                &keys(&[]),
192	                None,
193	                "t0",
194	            )
195	            .unwrap_err();
196	            assert!(
197	                matches!(e, ResumeError::MissingResumeKey { key: "session_id", .. }),
198	                "got {e}"
199	            );
200	            // No perch was bound — the error preceded any spawn.
201	            let perch = spt_store::perch::resolve_perch_path("doyle-psyche", ParentHint::Explicit("doyle"));
202	            assert!(!perch.join("info.json").exists(), "must not spawn/bind on missing key");
203	        });
204	    }
205
206	    #[test] // [unit->REQ-SEAM-RESUME] the psyche-download reads the stored context.
207	    fn download_reads_stored_context() {
208	        with_home(|_| {
209	            assert!(download_psyche_context("doyle", ParentHint::Infer).is_none());
210	            let ctx = context_path("doyle", ParentHint::Infer);
211	            std::fs::create_dir_all(ctx.parent().unwrap()).unwrap();
212	            std::fs::write(&ctx, "stored mind").unwrap();
213	            assert_eq!(
214	                download_psyche_context("doyle", ParentHint::Infer).as_deref(),
215	                Some("stored mind")
216	            );
217	        });
218	    }
219	}
220

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


crates\spt-live\src\signoff.rs:32:use crate::ingest::context_path;
crates\spt-live\src\signoff.rs:123:    let ctx = context_path(id, hint);
crates\spt-live\src\signoff.rs:223:            let ctx = context_path("doyle", ParentHint::Infer);
crates\spt-live\src\resume.rs:21:use crate::ingest::context_path;
crates\spt-live\src\resume.rs:76:pub fn download_psyche_context(id: &str, hint: ParentHint<'_>) -> Option<String> {
crates\spt-live\src\resume.rs:77:    std::fs::read_to_string(context_path(id, hint)).ok()
crates\spt-live\src\resume.rs:209:            assert!(download_psyche_context("doyle", ParentHint::Infer).is_none());
crates\spt-live\src\resume.rs:210:            let ctx = context_path("doyle", ParentHint::Infer);
crates\spt-live\src\resume.rs:214:                download_psyche_context("doyle", ParentHint::Infer).as_deref(),
crates\spt-live\src\lib.rs:42:pub use ingest::{ingest_drops, DropKind, Ingested};
crates\spt-live\src\lib.rs:45:pub use resume::{download_psyche_context, resume_psyche, ResumeError, ResumeMode};
crates\spt-live\src\pulse.rs:24:use crate::ingest::{ingest_drops, Ingested};
crates\spt-live\src\pulse.rs:69:    let ingested = ingest_drops(drop_dir, id, hint.clone(), now_ms, cfg.protection_window_ms)?;
crates\spt-store\src\contextstore.rs:90:    pub fn live_context_path(&self, id: &str) -> std::io::Result<PathBuf> {
crates\spt-store\src\contextstore.rs:103:    pub fn project_context_path(&self, project_id: &str, id: &str) -> std::io::Result<PathBuf> {
crates\spt-store\src\contextstore.rs:115:    /// [`live_context_path`](Self::live_context_path).
crates\spt-store\src\contextstore.rs:143:        let live = cs.live_context_path("doyle").unwrap();
crates\spt-store\src\contextstore.rs:153:        let proj = cs.project_context_path("spt-core", "doyle").unwrap();
crates\spt-store\src\contextstore.rs:173:            let p = cs.project_context_path("spt-core", id).unwrap();
crates\spt-store\src\contextstore.rs:187:            assert!(cs.live_context_path(bad).is_err(), "{bad:?} must refuse");
crates\spt-live\src\ingest.rs:44:pub fn context_path(id: &str, hint: ParentHint<'_>) -> PathBuf {
crates\spt-live\src\ingest.rs:55:pub fn ingest_drops(
crates\spt-live\src\ingest.rs:62:    let ctx = context_path(id, hint);
crates\spt-live\src\ingest.rs:103:            let ingested = ingest_drops(drops.path(), "doyle", ParentHint::Infer, 1000, 60_000)
crates\spt-live\src\ingest.rs:112:            let ctx = context_path("doyle", ParentHint::Infer);
crates\spt-live\src\ingest.rs:123:            let ingested = ingest_drops(drops.path(), "doyle", ParentHint::Infer, 1000, 60_000)
crates\spt-live\src\ingest.rs:137:            let ctx = context_path("doyle", ParentHint::Infer);
crates\spt-live\src\ingest.rs:146:                ingest_drops(drops.path(), "doyle", ParentHint::Infer, 1500, DEFAULT_PROTECTION_WINDOW_MS)
crates\spt-live\src\ingest.rs:161:                ingest_drops(drops.path(), "doyle", ParentHint::Infer, 1000, 60_000).expect("ingest");
crates\spt-daemon\src\lifecycle.rs:12://! [`spt_live`] seams ([`spawn_psyche`], [`ingest_drops`], [`run_echo_commune`],
crates\spt-daemon\src\lifecycle.rs:24:    fetch_history, graceful_signoff, ingest_drops, run_echo_commune, spawn_psyche,
crates\spt-daemon\src\lifecycle.rs:177:                ingest_drops(&dir, &self.id, ParentHint::Infer, now, self.cfg.protection_window_ms)


90	    };
91
92	    Ok(SignoffOutcome {
93	        echo_ran,
94	        signed_off,
95	    })
96	}
97
98	/// Sweep a stale `<id>-signoff.md` from `signoff_dir` on listener start (3.2).
99	/// Returns whether one was removed. Signoff files are write-once per
100	/// generation; a leftover from a prior session must never read as a live signoff.
101	pub fn sweep_stale_signoff(signoff_dir: &Path, id: &str) -> std::io::Result<bool> {
102	    let stale = signoff_dir.join(format!("{id}-signoff.md"));
103	    match std::fs::remove_file(&stale) {
104	        Ok(()) => {
105	            eprintln!("SIGNOFF_SWEEP:{id} (removed stale sentinel)");
106	            Ok(true)
107	        }
108	        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
109	        Err(e) => Err(e),
110	    }
111	}
112
113	/// Author the **Self-resume commune** at a boundary: stamp `Source: self-commune`
114	/// provenance and write it (precedence-guarded) into the perch's context store
115	/// for the re-entry (T7) to preload. The Self authored it, so it is an llm write.
116	pub fn write_resume_commune(
117	    id: &str,
118	    hint: ParentHint<'_>,
119	    body: &str,
120	    now_ms: u64,
121	    window_ms: u64,
122	) -> std::io::Result<WriteOutcome> {
123	    let ctx = context_path(id, hint);
124	    if let Some(parent) = ctx.parent() {
125	        std::fs::create_dir_all(parent)?;
126	    }
127	    let content = stamp_provenance(SOURCE_SELF_COMMUNE, body);
128	    write_context(&ctx, &content, WriteSource::Llm, now_ms, window_ms)
129	}
130
131	#[cfg(test)]
132	mod tests {
133	    use super::*;
134	    use crate::context::DEFAULT_PROTECTION_WINDOW_MS;
135	    use crate::test_support::with_home;
136	    use std::cell::RefCell;
137
138	    #[test] // [unit->REQ-HAZARD-ECHO-BEFORE-SIGNOFF] echo runs BEFORE the signoff compose.
139	    fn echo_precedes_signoff_compose() {
140	        let log: RefCell<Vec<&str>> = RefCell::new(Vec::new());
141	        let out = graceful_signoff::<()>(
142	            Some("sid-1"),
143	            Duration::ZERO,
144	            || {
145	                log.borrow_mut().push("echo");
146	                Ok(())
147	            },
148	            || {
149	                log.borrow_mut().push("recheck");

    };

    Ok(SignoffOutcome {
        echo_ran,
        signed_off,
    })
}

/// Sweep a stale `<id>-signoff.md` from `signoff_dir` on listener start (3.2).
/// Returns whether one was removed. Signoff files are write-once per
/// generation; a leftover from a prior session must never read as a live signoff.
pub fn sweep_stale_signoff(signoff_dir: &Path, id: &str) -> std::io::Result<bool> {
    let stale = signoff_dir.join(format!("{id}-signoff.md"));
    match std::fs::remove_file(&stale) {
        Ok(()) => {
            eprintln!("SIGNOFF_SWEEP:{id} (removed stale sentinel)");
            Ok(true)
        }
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
        Err(e) => Err(e),
    }
}

/// Author the **Self-resume commune** at a boundary: stamp `Source: self-commune`
/// provenance and write it (precedence-guarded) into the perch's context store
/// for the re-entry (T7) to preload. The Self authored it, so it is an llm write.
pub fn write_resume_commune(
    id: &str,
    hint: ParentHint<'_>,
    body: &str,
    now_ms: u64,
    window_ms: u64,
) -> std::io::Result<WriteOutcome> {
    let ctx = context_path(id, hint);
    if let Some(parent) = ctx.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let content = stamp_provenance(SOURCE_SELF_COMMUNE, body);
    write_context(&ctx, &content, WriteSource::Llm, now_ms, window_ms)
}

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

    #[test] // [unit->REQ-HAZARD-ECHO-BEFORE-SIGNOFF] echo runs BEFORE the signoff compose.
    fn echo_precedes_signoff_compose() {
        let log: RefCell<Vec<&str>> = RefCell::new(Vec::new());
        let out = graceful_signoff::<()>(
            Some("sid-1"),
            Duration::ZERO,
            || {
                log.borrow_mut().push("echo");
                Ok(())
            },
            || {
                log.borrow_mut().push("recheck");


D6a-1: BranchStore + ContextStore + project_id in spt-store

gitrun.rs (bounded git, 5.3), branchstore.rs (git-CLI BranchStore: bare seed, ensure branch/worktree, commit=checkpoint/tip=resume, REQ-STORE-1 activate), contextstore.rs (tracked/ two-tier layout, a-/p- branches), project.rs (project_id from git remote / folder fallback), perch.rs tracked paths, STORAGE.md seed-repo note. Push CI-green.

D6a-2: two-tier ingest routing + rename arm in spt-live

ingest.rs routes commune slices (parse_two_slice) into ContextStore tier files (precedence-guarded + committed); lifecycle wiring (project dir → project_id); spt rename context-branch arm (a-<old>→a-<new>); REQ-INST-2 activate [impl,unit]. Push CI-green.

D6b: vector merge + Psyche reconcile + D7.5a driver

VersionVector + marker v2 (node+vector), per-file merge fn, conflict artifacts (REQ-HAZARD-CONFLICT-BOTH-PRESERVED register+activate), reconciler election, bounded stdout-captured Psyche turn driver (D7.5a pulled forward), reconcile flow + validation + retry + event seam.

D6c: bundle sync over broker streams + gates

SyncRequest/bundle-chunk records, pull-based ref-scoped (synced(E,S)+visibility server-side fail-closed), fetch+driver merge+two-parent commits, triggers (write-notify, activation freshness feed, anti-entropy), loopback two-daemon tests, FAULT-MATRIX rows. REQ-NET-3 + REQ-INST-5 activate.


1	//! pulse heartbeat (M2b T5) — the interim per-tick driver.
2	//!
3	//! In the no-daemon interim model, the long-running `api listen` process
4	//! (T8) hosts a pulse loop. Each tick does the lightweight, recurring work:
5	//! - **poll the drop dirs** → fold any commune/signoff drops into context
6	//!   (single-writer ingest→delete — T4);
7	//! - **fire the pending echo** → if the echo-gate (`.more-done`, armed by
8	//!   `api state idle`) is set, signal that an echo is due and **clear the gate**
9	//!   so it fires *exactly once*;
10	//! - surface the **idle** flag for the caller's capsule/idle decisions.
11	//!
12	//! The pulse is deliberately thin — it decides *when* an echo is due and
13	//! guarantees once-only; the heavy echo run ([`crate::echo::run_echo_commune`])
14	//! is the caller's (T8), which holds the runtime + history + commune dir. Keep
15	//! all of this behind this one module so M3's daemon re-host (Psyche-as-loop,
16	//! daemon-owned period) is a localized swap, not a rewrite (mirrors M2a's
17	//! seed-file discipline).
18	//
19	// Interim only: the durable pulse loop + its global/per-endpoint period config
20	// belong to the M3 daemon (CONTEXT R-DAEMON-1). No dedicated REQ — the pulse is
21	// throwaway glue under REQ-SEAM-PSYCHE (see M2b-PLAN §New requirements).
22
23	use crate::context::DEFAULT_PROTECTION_WINDOW_MS;
24	use crate::ingest::{ingest_drops, Ingested};
25	use spt_store::perch::{resolve_echo_gate_file, resolve_idle_file, ParentHint};
26	use std::path::Path;
27	use std::time::Duration;
28
29	/// Interim default pulse period. The durable, configurable knob (global +
30	/// per-endpoint override) is the M3 daemon's; this is the interim constant.
31	pub const DEFAULT_PULSE_PERIOD: Duration = Duration::from_secs(5);
32
33	/// Pulse tuning. `period` paces the `api listen` loop; `protection_window_ms`
34	/// is threaded into the drop-file precedence guard (6.5).
35	#[derive(Debug, Clone, Copy)]
36	pub struct PulseConfig {
37	    pub period: Duration,
38	    pub protection_window_ms: u64,
39	}
40
41	impl Default for PulseConfig {
42	    fn default() -> Self {
43	        PulseConfig {
44	            period: DEFAULT_PULSE_PERIOD,
45	            protection_window_ms: DEFAULT_PROTECTION_WINDOW_MS,
46	        }
47	    }
48	}
49
50	/// What one tick observed/did. `echo_due` means the gate was armed and has now
51	/// been cleared — the caller should fire exactly one echo-commune.
52	#[derive(Debug, Clone)]
53	pub struct TickOutcome {
54	    pub ingested: Vec<Ingested>,
55	    pub echo_due: bool,
56	    pub idle: bool,
57	}
58
59	/// Run one pulse tick for `id`: ingest drops from `drop_dir`, then read+clear
60	/// the echo-gate (once-only fire) and the idle flag. `now_ms` stamps the
61	/// precedence markers of any ingested writes.
62	pub fn tick(
63	    id: &str,
64	    hint: ParentHint<'_>,
65	    drop_dir: &Path,
66	    now_ms: u64,
67	    cfg: &PulseConfig,
68	) -> std::io::Result<TickOutcome> {
69	    let ingested = ingest_drops(drop_dir, id, hint.clone(), now_ms, cfg.protection_window_ms)?;
70	    let echo_due = take_echo_gate(id, hint.clone());
71	    let idle = resolve_idle_file(id, hint).exists();
72	    Ok(TickOutcome {
73	        ingested,
74	        echo_due,
75	        idle,
76	    })
77	}
78
79	/// Read-and-clear the echo-gate: returns whether it was armed, removing the
80	/// sentinel so the echo fires exactly once. Clearing a missing gate is a no-op.
81	pub fn take_echo_gate(id: &str, hint: ParentHint<'_>) -> bool {
82	    let gate = resolve_echo_gate_file(id, hint);
83	    if gate.exists() {
84	        // Best-effort clear; if removal fails the next tick re-fires (at-least-
85	        // once degrades safely — a duplicate echo is harmless, a lost one less so).
86	        let _ = std::fs::remove_file(&gate);
87	        true
88	    } else {
89	        false
90	    }
91	}
92
93	#[cfg(test)]
94	mod tests {
95	    use super::*;
96	    use crate::test_support::with_home;
97	    use spt_store::perch::resolve_perch_path;
98
99	    fn arm_gate(id: &str) {
100	        let perch = resolve_perch_path(id, ParentHint::Infer);
101	        std::fs::create_dir_all(&perch).unwrap();
102	        std::fs::write(resolve_echo_gate_file(id, ParentHint::Infer), "").unwrap();
103	    }
104
105	    #[test] // [unit->REQ-SEAM-PSYCHE] an armed echo-gate fires exactly once, then clears.
106	    fn echo_gate_fires_exactly_once() {
107	        with_home(|_| {
108	            let drops = tempfile::tempdir().unwrap();
109	            arm_gate("doyle");
110
111	            let t1 = tick("doyle", ParentHint::Infer, drops.path(), 1000, &PulseConfig::default())
112	                .expect("tick");
113	            assert!(t1.echo_due, "armed gate should fire");
114
115	            // Second tick: gate cleared, no re-fire.
116	            let t2 = tick("doyle", ParentHint::Infer, drops.path(), 2000, &PulseConfig::default())
117	                .expect("tick");
118	            assert!(!t2.echo_due, "gate must clear after one fire");
119	        });
120	    }
121
122	    #[test] // [unit->REQ-SEAM-PSYCHE] a tick polls + ingests the drop dir.
123	    fn tick_ingests_drops() {
124	        with_home(|_| {
125	            let drops = tempfile::tempdir().unwrap();
126	            std::fs::write(drops.path().join("doyle-commune.md"), "brief").unwrap();
127
128	            let t = tick("doyle", ParentHint::Infer, drops.path(), 1000, &PulseConfig::default())
129	                .expect("tick");
130	            assert_eq!(t.ingested.len(), 1);
131	            assert!(!t.echo_due, "no gate armed");
132	            assert!(!drops.path().join("doyle-commune.md").exists(), "drop consumed");
133	        });
134	    }
135
136	    #[test] // [unit->REQ-SEAM-PSYCHE] idle sentinel surfaces in the tick outcome.
137	    fn tick_reports_idle() {
138	        with_home(|_| {
139	            let drops = tempfile::tempdir().unwrap();
140	            let perch = resolve_perch_path("doyle", ParentHint::Infer);
141	            std::fs::create_dir_all(&perch).unwrap();
142	            std::fs::write(resolve_idle_file("doyle", ParentHint::Infer), "").unwrap();
143
144	            let t = tick("doyle", ParentHint::Infer, drops.path(), 1000, &PulseConfig::default())
145	                .expect("tick");
146	            assert!(t.idle);
147	        });
148	    }
149	}
150

//! pulse heartbeat (M2b T5) — the interim per-tick driver.
//!
//! In the no-daemon interim model, the long-running `api listen` process
//! (T8) hosts a pulse loop. Each tick does the lightweight, recurring work:
//! - **poll the drop dirs** → fold any commune/signoff drops into context
//!   (single-writer ingest→delete — T4);
//! - **fire the pending echo** → if the echo-gate (`.more-done`, armed by
//!   `api state idle`) is set, signal that an echo is due and **clear the gate**
//!   so it fires *exactly once*;
//! - surface the **idle** flag for the caller's capsule/idle decisions.
//!
//! The pulse is deliberately thin — it decides *when* an echo is due and
//! guarantees once-only; the heavy echo run ([`crate::echo::run_echo_commune`])
//! is the caller's (T8), which holds the runtime + history + commune dir. Keep
//! all of this behind this one module so M3's daemon re-host (Psyche-as-loop,
//! daemon-owned period) is a localized swap, not a rewrite (mirrors M2a's
//! seed-file discipline).
//
// Interim only: the durable pulse loop + its global/per-endpoint period config
// belong to the M3 daemon (CONTEXT R-DAEMON-1). No dedicated REQ — the pulse is
// throwaway glue under REQ-SEAM-PSYCHE (see M2b-PLAN §New requirements).

use crate::context::DEFAULT_PROTECTION_WINDOW_MS;
use crate::ingest::{ingest_drops, Ingested};
use spt_store::perch::{resolve_echo_gate_file, resolve_idle_file, ParentHint};
use std::path::Path;
use std::time::Duration;

/// Interim default pulse period. The durable, configurable knob (global +
/// per-endpoint override) is the M3 daemon's; this is the interim constant.
pub const DEFAULT_PULSE_PERIOD: Duration = Duration::from_secs(5);

/// Pulse tuning. `period` paces the `api listen` loop; `protection_window_ms`
/// is threaded into the drop-file precedence guard (6.5).
#[derive(Debug, Clone, Copy)]
pub struct PulseConfig {
    pub period: Duration,
    pub protection_window_ms: u64,
}

impl Default for PulseConfig {
    fn default() -> Self {
        PulseConfig {
            period: DEFAULT_PULSE_PERIOD,
            protection_window_ms: DEFAULT_PROTECTION_WINDOW_MS,
        }
    }
}

/// What one tick observed/did. `echo_due` means the gate was armed and has now
/// been cleared — the caller should fire exactly one echo-commune.
#[derive(Debug, Clone)]
pub struct TickOutcome {
    pub ingested: Vec<Ingested>,
    pub echo_due: bool,
    pub idle: bool,
}

/// Run one pulse tick for `id`: ingest drops from `drop_dir`, then read+clear
/// the echo-gate (once-only fire) and the idle flag. `now_ms` stamps the
/// precedence markers of any ingested writes.
pub fn tick(
    id: &str,
    hint: ParentHint<'_>,
    drop_dir: &Path,
    now_ms: u64,
    cfg: &PulseConfig,
) -> std::io::Result<TickOutcome> {
    let ingested = ingest_drops(drop_dir, id, hint.clone(), now_ms, cfg.protection_window_ms)?;
    let echo_due = take_echo_gate(id, hint.clone());
    let idle = resolve_idle_file(id, hint).exists();
    Ok(TickOutcome {
        ingested,
        echo_due,
        idle,
    })
}

/// Read-and-clear the echo-gate: returns whether it was armed, removing the
/// sentinel so the echo fires exactly once. Clearing a missing gate is a no-op.
pub fn take_echo_gate(id: &str, hint: ParentHint<'_>) -> bool {
    let gate = resolve_echo_gate_file(id, hint);
    if gate.exists() {
        // Best-effort clear; if removal fails the next tick re-fires (at-least-
        // once degrades safely — a duplicate echo is harmless, a lost one less so).
        let _ = std::fs::remove_file(&gate);
        true
    } else {
        false
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_support::with_home;
    use spt_store::perch::resolve_perch_path;

    fn arm_gate(id: &str) {
        let perch = resolve_perch_path(id, ParentHint::Infer);
        std::fs::create_dir_all(&perch).unwrap();
        std::fs::write(resolve_echo_gate_file(id, ParentHint::Infer), "").unwrap();
    }

    #[test] // [unit->REQ-SEAM-PSYCHE] an armed echo-gate fires exactly once, then clears.
    fn echo_gate_fires_exactly_once() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            arm_gate("doyle");

            let t1 = tick("doyle", ParentHint::Infer, drops.path(), 1000, &PulseConfig::default())
                .expect("tick");
            assert!(t1.echo_due, "armed gate should fire");

            // Second tick: gate cleared, no re-fire.
            let t2 = tick("doyle", ParentHint::Infer, drops.path(), 2000, &PulseConfig::default())
                .expect("tick");
            assert!(!t2.echo_due, "gate must clear after one fire");
        });
    }

    #[test] // [unit->REQ-SEAM-PSYCHE] a tick polls + ingests the drop dir.
    fn tick_ingests_drops() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            std::fs::write(drops.path().join("doyle-commune.md"), "brief").unwrap();

            let t = tick("doyle", ParentHint::Infer, drops.path(), 1000, &PulseConfig::default())
                .expect("tick");
            assert_eq!(t.ingested.len(), 1);
            assert!(!t.echo_due, "no gate armed");
            assert!(!drops.path().join("doyle-commune.md").exists(), "drop consumed");
        });
    }

    #[test] // [unit->REQ-SEAM-PSYCHE] idle sentinel surfaces in the tick outcome.
    fn tick_reports_idle() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let perch = resolve_perch_path("doyle", ParentHint::Infer);
            std::fs::create_dir_all(&perch).unwrap();
            std::fs::write(resolve_idle_file("doyle", ParentHint::Infer), "").unwrap();

            let t = tick("doyle", ParentHint::Infer, drops.path(), 1000, &PulseConfig::default())
                .expect("tick");
            assert!(t.idle);
        });
    }
}


crates\spt-live\src\pulse.rs:36:pub struct PulseConfig {
crates\spt-live\src\pulse.rs:41:impl Default for PulseConfig {
crates\spt-live\src\pulse.rs:43:        PulseConfig {
crates\spt-live\src\pulse.rs:67:    cfg: &PulseConfig,
crates\spt-live\src\pulse.rs:111:            let t1 = tick("doyle", ParentHint::Infer, drops.path(), 1000, &PulseConfig::default())
crates\spt-live\src\pulse.rs:116:            let t2 = tick("doyle", ParentHint::Infer, drops.path(), 2000, &PulseConfig::default())
crates\spt-live\src\pulse.rs:128:            let t = tick("doyle", ParentHint::Infer, drops.path(), 1000, &PulseConfig::default())
crates\spt-live\src\pulse.rs:144:            let t = tick("doyle", ParentHint::Infer, drops.path(), 1000, &PulseConfig::default())
crates\spt-live\src\lib.rs:44:pub use pulse::{tick, PulseConfig, TickOutcome};


crates\spt\src\api\live.rs:16:use spt_live::{Ingested, PsycheHandle, SignoffOutcome};


No matches found


1	//! LiveAgent lifecycle hosting — a thin shim over the daemon's re-hosted
2	//! lifecycle ([`spt_daemon::BrainLifecycle`]).
3	//!
4	//! M2b hosted the LiveAgent lifecycle (Psyche spawn, pulse-driven drop ingest +
5	//! echo, graceful signoff ordering) directly in the `api listen` runner. B3
6	//! consolidated that orchestration **down into `spt-daemon`** (REQ-DAEMON-1); the
7	//! brain now runs it as scheduled in-process loops paced by a durable config.
8	//! This module keeps the binary's call sites stable — `cmd_listen` /
9	//! `cmd_shutdown` — by forwarding to `BrainLifecycle`. The logic lives once, in
10	//! the daemon crate; the duplicate here is gone.
11	//
12	// [impl->REQ-SEAM-PSYCHE]
13	// [impl->REQ-DAEMON-1]
14
15	use spt_daemon::{BrainLifecycle, TickReport};
16	use spt_live::{Ingested, PsycheHandle, SignoffOutcome};
17	use spt_runtime::Manifest;
18	use spt_store::perch::{self, ParentHint};
19
20	/// The LiveAgent lifecycle host — a thin newtype over the daemon's
21	/// [`BrainLifecycle`]. Built from a live-capable manifest; `None` for a plain
22	/// relay endpoint (no Psyche).
23	pub struct LiveHost(BrainLifecycle);
24
25	impl LiveHost {
26	    /// Build a host for `id` from a live-capable manifest; `None` if the manifest
27	    /// is absent or declares no Psyche.
28	    pub fn new_opt(manifest: Option<&Manifest>, id: &str) -> Option<LiveHost> {
29	        BrainLifecycle::new_opt(manifest, id).map(LiveHost)
30	    }
31
32	    /// Sweep a stale signoff sentinel on listener start (3.2).
33	    pub fn sweep_on_start(&self) {
34	        self.0.sweep_on_start();
35	    }
36
37	    /// Spawn the Psyche companion (T2).
38	    pub fn spawn_psyche(
39	        &self,
40	        session_id: Option<&str>,
41	        event: Option<&str>,
42	    ) -> Result<PsycheHandle, String> {
43	        self.0.spawn_psyche(session_id, event)
44	    }
45
46	    /// One pulse tick (T5): ingest drops (T4) then fire a pending echo (T3).
47	    /// Returns the ingested drops and whether an echo fired (the historical shape
48	    /// the `api listen` runner consumes).
49	    pub fn pulse_tick(&self, session_id: Option<&str>) -> Result<(Vec<Ingested>, bool), String> {
50	        let TickReport { ingested, echo_fired } = self.0.pulse_tick(session_id)?;
51	        Ok((ingested, echo_fired))
52	    }
53
54	    /// Execute the graceful signoff (T6): echo-commune before teardown (3.3),
55	    /// grace recheck before any signoff write (1.1).
56	    pub fn graceful_signoff(&self, session_id: Option<&str>) -> Result<SignoffOutcome, String> {
57	        self.0.graceful_signoff(session_id)
58	    }
59	}
60
61	/// `api shutdown <id>` — the graceful live-agent signoff path. Builds the live
62	/// host from the manifest and runs the echo-before-teardown ordering (T6). A
63	/// non-live manifest (no Psyche) is a plain soft-stop: just drop the `ready`
64	/// marker. Auth-gated by the dispatcher.
65	pub fn cmd_shutdown(id: &str, manifest: Option<&Manifest>) -> i32 {
66	    let session_id = perch_session_id(id);
67	    match LiveHost::new_opt(manifest, id) {
68	        Some(host) => match host.graceful_signoff(session_id.as_deref()) {
69	            Ok(outcome) => {
70	                eprintln!(
71	                    "SHUTDOWN:{id} echo_ran={} signed_off={}",
72	                    outcome.echo_ran, outcome.signed_off
73	                );
74	                0
75	            }
76	            Err(e) => {
77	                eprintln!("SHUTDOWN_FAIL:{id}: {e}");
78	                super::EXIT_REFUSED
79	            }
80	        },
81	        None => {
82	            // Non-live endpoint: soft-stop only (remove the online marker).
83	            let _ = std::fs::remove_file(perch::resolve_ready_file(id, ParentHint::Infer));
84	            eprintln!("SHUTDOWN:{id} (non-live soft-stop)");
85	            0
86	        }
87	    }
88	}
89
90	/// The bound session id from the perch's info.json, if any.
91	fn perch_session_id(id: &str) -> Option<String> {
92	    let path = perch::resolve_perch_path(id, ParentHint::Infer);
93	    spt_store::info::read_info(&path)
94	        .map(|i| i.session_id)
95	        .filter(|s| !s.is_empty())
96	}
97

//! 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())
}


crates\spt-live\src\lib.rs:47:    graceful_signoff, sweep_stale_signoff, write_resume_commune, SignoffOutcome,
crates\spt-live\src\signoff.rs:18://! Plus the **boundary Self-resume commune** ([`write_resume_commune`]): M2a's
crates\spt-live\src\signoff.rs:116:pub fn write_resume_commune(
crates\spt-live\src\signoff.rs:212:    fn boundary_writes_resume_commune() {
crates\spt-live\src\signoff.rs:214:            let o = write_resume_commune(


crates\spt\src\cli.rs:201:        Cmd::Rename { old_id, new_id } => cmd_rename(&old_id, &new_id),
crates\spt\src\cli.rs:620:/// [`spt_store::rename::rename_perch_in`]. Prints each rippled perch id.
crates\spt\src\cli.rs:717:fn cmd_rename(old_id: &str, new_id: &str) -> i32 {
crates\spt\src\cli.rs:722:    match spt_store::rename::rename_perch_in(&perch::owlery_dir(), old_id, new_id) {


610	    }
611	    eprintln!("{}", result.msg);
612	    result.code
613	}
614
615	/// Current unix time in whole seconds (the TOTP clock input). A pre-epoch clock
616	/// degrades to step 0 rather than panicking.
617	/// `spt rename <old> <new>` — the on-disk rename ripple (REQ-INST-11). Validates
618	/// the new id (the `:`/`@` reserved-delimiter rule — a qualified string can never
619	/// become a bare id), then ripples the perch tree via
620	/// [`spt_store::rename::rename_perch_in`]. Prints each rippled perch id.
621	// [impl->REQ-INST-11]
622	/// `spt access allow|revoke|open|list` — operate the endpoint access whitelist
623	/// (`identity/trust/access.json`, ADR-0009). The daemon's gate re-reads the
624	/// store per check, so edits take effect on the next inbound — no restart.
625	// [impl->REQ-SEC-1]
626	fn cmd_access(action: AccessCmd) -> i32 {
627	    use spt_store::access::AccessStore;
628
629	    /// A node subject must be a 64-char Ed25519 pubkey hex — refuse typos
630	    /// rather than store a subject that can never match (a silent lockout).
631	    fn valid_node_hex(node: &str) -> bool {
632	        node.len() == 64 && node.chars().all(|c| c.is_ascii_hexdigit())
633	    }
634	    fn save(store: &AccessStore) -> i32 {
635	        match store.save() {
636	            Ok(()) => 0,
637	            Err(e) => {
638	                eprintln!("ACCESS_FAIL: {e}");
639	                1
640	            }
641	        }
642	    }
643
644	    match action {
645	        AccessCmd::Allow { endpoint, node } => {
646	            let node = node.to_lowercase();
647	            if !valid_node_hex(&node) {
648	                eprintln!("ACCESS_BAD_NODE: {node:?} is not a 64-hex node pubkey");
649	                return 1;
650	            }
651	            let mut s = AccessStore::load();
652	            s.allow(&endpoint, &node);
653	            let code = save(&s);
654	            if code == 0 {
655	                eprintln!("ACCESS_ALLOW:{endpoint}:{node} (endpoint is now restricted)");
656	            }
657	            code
658	        }
659	        AccessCmd::Revoke { endpoint, node } => {
660	            let node = node.to_lowercase();
661	            let mut s = AccessStore::load();
662	            if !s.revoke(&endpoint, &node) {
663	                eprintln!("ACCESS_NOT_LISTED:{endpoint}:{node} (nothing revoked)");
664	                return 1;
665	            }
666	            let locked = s.lookup(&endpoint).map(|e| e.nodes.is_empty()).unwrap_or(false);
667	            let code = save(&s);
668	            if code == 0 {
669	                eprintln!("ACCESS_REVOKE:{endpoint}:{node}");
670	                if locked {
671	                    eprintln!(
672	                        "ACCESS_LOCKED:{endpoint} now refuses ALL unsolicited remote inbound; \
673	                         `spt access open {endpoint}` returns it to default-open"
674	                    );
675	                }
676	            }
677	            code
678	        }
679	        AccessCmd::Open { endpoint } => {
680	            let mut s = AccessStore::load();
681	            if !s.open(&endpoint) {
682	                eprintln!("ACCESS_ALREADY_OPEN:{endpoint} (no restriction existed)");
683	                return 0;
684	            }
685	            let code = save(&s);
686	            if code == 0 {
687	                eprintln!("ACCESS_OPEN:{endpoint} (back to default-open)");
688	            }
689	            code
690	        }
691	        AccessCmd::List { endpoint } => {
692	            let s = AccessStore::load();
693	            match endpoint {
694	                Some(ep) => match s.lookup(&ep) {
695	                    None => println!("OPEN:{ep} (no restriction; any visible node may reach it)"),
696	                    Some(e) if e.nodes.is_empty() => println!("LOCKED:{ep} (refuses all unsolicited remote inbound)"),
697	                    Some(e) => println!("RESTRICTED:{ep}: {}", e.nodes.join(", ")),
698	                },
699	                None => {
700	                    if s.entries.is_empty() {
701	                        println!("no restrictions (every endpoint default-open)");
702	                    }
703	                    for e in &s.entries {
704	                        if e.nodes.is_empty() {
705	                            println!("LOCKED:{}", e.endpoint);
706	                        } else {
707	                            println!("RESTRICTED:{}: {}", e.endpoint, e.nodes.join(", "));
708	                        }
709	                    }
710	                }
711	            }
712	            0
713	        }
714	    }
715	}
716
717	fn cmd_rename(old_id: &str, new_id: &str) -> i32 {
718	    if let Err(e) = spt_proto::id::validate_endpoint_id(new_id) {
719	        eprintln!("ERROR: invalid new id: {e}");
720	        return 2;
721	    }
722	    match spt_store::rename::rename_perch_in(&perch::owlery_dir(), old_id, new_id) {
723	        Ok(rippled) => {
724	            for id in &rippled {
725	                println!("RENAMED:{id}");
726	            }
727	            eprintln!("renamed {old_id} -> {new_id} ({} perch(es))", rippled.len());
728	            0
729	        }
730	        Err(e) => {
731	            eprintln!("ERROR: {e}");
732	            1
733	        }
734	    }
735	}
736
737	fn now_unix() -> u64 {
738	    std::time::SystemTime::now()
739	        .duration_since(std::time::UNIX_EPOCH)

    }
    eprintln!("{}", result.msg);
    result.code
}

/// Current unix time in whole seconds (the TOTP clock input). A pre-epoch clock
/// degrades to step 0 rather than panicking.
/// `spt rename <old> <new>` — the on-disk rename ripple (REQ-INST-11). Validates
/// the new id (the `:`/`@` reserved-delimiter rule — a qualified string can never
/// become a bare id), then ripples the perch tree via
/// [`spt_store::rename::rename_perch_in`]. Prints each rippled perch id.
// [impl->REQ-INST-11]
/// `spt access allow|revoke|open|list` — operate the endpoint access whitelist
/// (`identity/trust/access.json`, ADR-0009). The daemon's gate re-reads the
/// store per check, so edits take effect on the next inbound — no restart.
// [impl->REQ-SEC-1]
fn cmd_access(action: AccessCmd) -> i32 {
    use spt_store::access::AccessStore;

    /// A node subject must be a 64-char Ed25519 pubkey hex — refuse typos
    /// rather than store a subject that can never match (a silent lockout).
    fn valid_node_hex(node: &str) -> bool {
        node.len() == 64 && node.chars().all(|c| c.is_ascii_hexdigit())
    }
    fn save(store: &AccessStore) -> i32 {
        match store.save() {
            Ok(()) => 0,
            Err(e) => {
                eprintln!("ACCESS_FAIL: {e}");
                1
            }
        }
    }

    match action {
        AccessCmd::Allow { endpoint, node } => {
            let node = node.to_lowercase();
            if !valid_node_hex(&node) {
                eprintln!("ACCESS_BAD_NODE: {node:?} is not a 64-hex node pubkey");
                return 1;
            }
            let mut s = AccessStore::load();
            s.allow(&endpoint, &node);
            let code = save(&s);
            if code == 0 {
                eprintln!("ACCESS_ALLOW:{endpoint}:{node} (endpoint is now restricted)");
            }
            code
        }
        AccessCmd::Revoke { endpoint, node } => {
            let node = node.to_lowercase();
            let mut s = AccessStore::load();
            if !s.revoke(&endpoint, &node) {
                eprintln!("ACCESS_NOT_LISTED:{endpoint}:{node} (nothing revoked)");
                return 1;
            }
            let locked = s.lookup(&endpoint).map(|e| e.nodes.is_empty()).unwrap_or(false);
            let code = save(&s);
            if code == 0 {
                eprintln!("ACCESS_REVOKE:{endpoint}:{node}");
                if locked {
                    eprintln!(
                        "ACCESS_LOCKED:{endpoint} now refuses ALL unsolicited remote inbound; \
                         `spt access open {endpoint}` returns it to default-open"
                    );
                }
            }
            code
        }
        AccessCmd::Open { endpoint } => {
            let mut s = AccessStore::load();
            if !s.open(&endpoint) {
                eprintln!("ACCESS_ALREADY_OPEN:{endpoint} (no restriction existed)");
                return 0;
            }
            let code = save(&s);
            if code == 0 {
                eprintln!("ACCESS_OPEN:{endpoint} (back to default-open)");
            }
            code
        }
        AccessCmd::List { endpoint } => {
            let s = AccessStore::load();
            match endpoint {
                Some(ep) => match s.lookup(&ep) {
                    None => println!("OPEN:{ep} (no restriction; any visible node may reach it)"),
                    Some(e) if e.nodes.is_empty() => println!("LOCKED:{ep} (refuses all unsolicited remote inbound)"),
                    Some(e) => println!("RESTRICTED:{ep}: {}", e.nodes.join(", ")),
                },
                None => {
                    if s.entries.is_empty() {
                        println!("no restrictions (every endpoint default-open)");
                    }
                    for e in &s.entries {
                        if e.nodes.is_empty() {
                            println!("LOCKED:{}", e.endpoint);
                        } else {
                            println!("RESTRICTED:{}: {}", e.endpoint, e.nodes.join(", "));
                        }
                    }
                }
            }
            0
        }
    }
}

fn cmd_rename(old_id: &str, new_id: &str) -> i32 {
    if let Err(e) = spt_proto::id::validate_endpoint_id(new_id) {
        eprintln!("ERROR: invalid new id: {e}");
        return 2;
    }
    match spt_store::rename::rename_perch_in(&perch::owlery_dir(), old_id, new_id) {
        Ok(rippled) => {
            for id in &rippled {
                println!("RENAMED:{id}");
            }
            eprintln!("renamed {old_id} -> {new_id} ({} perch(es))", rippled.len());
            0
        }
        Err(e) => {
            eprintln!("ERROR: {e}");
            1
        }
    }
}

fn now_unix() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)


<task-notification>
<task-id>bjlemo3v2</task-id>
<tool-use-id>toolu_0194wz1wJbyYnY7BZEtBBdRE</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\bjlemo3v2.output</output-file>
<status>completed</status>
<summary>Background command "Wait + check CI conclusion for 8b42452" completed (exit code 0)</summary>
</task-notification>


CI watch running in background. Task 2 starts — first, pure path fns + rename arm in contextstore:


Updated task #2 status


Prompt:
<task-notification>
<task-id>bjlemo3v2</task-id>
<tool-use-id>toolu_0194wz1wJbyYnY7BZEtBBdRE</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\bjlemo3v2.output</output-file>
<status>completed</status>
<summary>Background command "Wait + check CI conclusion for 8b42452" completed (exit code 0)</summary>
</task-notification>


CI for `8b42452` still queued. Re-poll in background; continuing D6a-2.


Bash

cd C:/Users/decid/Documents/projects/spt-core && for i in 1 2 3 4 5 6; do sleep 60; out=$(gh run list --branch dev-freeform --limit 1 --json conclusion,status,headSha); echo "$out"; if echo "$out" | grep -q '"status":"completed"'; then break; fi; done

Poll CI until 8b42452 completes


Command running in background with ID: bwfbkkgia. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\bwfbkkgia.output. You will be notified when it completes. To check interim output, use Read on that file path.


Edit

/// The branch carrying `id`'s live context (the per-agent view).
pub fn agent_branch(id: &str) -> String {
    format!("a-{id}")
}

/// The branch carrying `project_id`'s per-project context (all agents).
pub fn project_branch(project_id: &str) -> String {
    format!("p-{project_id}")
}

/// The branch carrying `id`'s live context (the per-agent view).
pub fn agent_branch(id: &str) -> String {
    format!("a-{id}")
}

/// The branch carrying `project_id`'s per-project context (all agents).
pub fn project_branch(project_id: &str) -> String {
    format!("p-{project_id}")
}

/// Pure path of `id`'s live-context file under a `tracked/` root — the
/// **read** form (no branch/worktree creation; readers must not mint store
/// state). The single layout authority both the ensure-paths and every reader
/// route through (6.1).
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn live_context_file(root: &Path, id: &str) -> PathBuf {
    root.join("agents").join(id).join(LIVE_CONTEXT_FILE)
}

/// Pure path of the per-(agent,project) context file under a `tracked/` root —
/// the **read** form (no creation).
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn project_context_file(root: &Path, project_id: &str, id: &str) -> PathBuf {
    root.join("projects").join(project_id).join(id).join(PROJECT_CONTEXT_FILE)
}


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

/// The branch carrying `id`'s live context (the per-agent view).
pub fn agent_branch(id: &str) -> String {
    format!("a-{id}")
}

/// The branch carrying `project_id`'s per-project context (all agents).
pub fn project_branch(project_id: &str) -> String {
    format!("p-{project_id}")
}

/// Pure path of `id`'s live-context file under a `tracked/` root — the
/// **read** form (no branch/worktree creation; readers must not mint store
/// state). The single layout authority both the ensure-paths and every reader
/// route through (6.1).
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn live_context_file(root: &Path, id: &str) -> PathBuf {
    root.join("agents").join(id).join(LIVE_CONTEXT_FILE)
}

/// Pure path of the per-(agent,project) context file under a `tracked/` root —
/// the **read** form (no creation).
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn project_context_file(root: &Path, project_id: &str, id: &str) -> PathBuf {
    root.join("projects").join(project_id).join(id).join(PROJECT_CONTEXT_FILE)
}

//! The two-tier context store — `tracked/` realized over [`BranchStore`]
//! (M4-D6a; STORAGE §tracked/, ADR-0011, ADR-0013).
//!
//! Layout (the synced *mind*, physically separate from node-local `perches/`):
//!
//! ```text
//! tracked/
//!   .seed.git                    bare BranchStore repo (the one true object DB)
//!   agents/<id>/                 linked worktree of branch  a-<id>
//!     live-context.md            per-agent live context  → syncs to ALL instances
//!   projects/<project_id>/       linked worktree of branch  p-<project_id>
//!     <id>/project-context.md    per-(agent,project)      → same-project instances
//! ```
//!
//! Branches are the sync/merge unit (`a-`/`p-` refs are what D6c's bundle
//! exchange ships); worktrees are the read/write view. Tier routing is the
//! CONTEXT-MEMORY two-tier split: the `a-` branch is the agent's mind
//! everywhere, the `p-` branch holds every agent's context for one project
//! (cross-agent synthesis + query-routing ride that shape).
//!
//! Writes here are **paths + commits only** — the precedence-marker guard
//! (6.5) stays in `spt-live::context`, which writes through the paths this
//! store hands out, then calls the commit hook. Single writer per branch
//! (active-instance-authoritative; 6.4).
// [impl->REQ-STORE-1]

use std::path::{Path, PathBuf};

use crate::branchstore::BranchStore;
use crate::perch::tracked_dir;
use spt_proto::id::validate_endpoint_id;

/// The per-agent live-context file inside an `a-<id>` worktree.
pub const LIVE_CONTEXT_FILE: &str = "live-context.md";
/// The per-(agent,project) context file inside a `p-<project>` worktree,
/// nested under the agent id.
pub const PROJECT_CONTEXT_FILE: &str = "project-context.md";

/// The branch carrying `id`'s live context (the per-agent view).
pub fn agent_branch(id: &str) -> String {
    format!("a-{id}")
}

/// The branch carrying `project_id`'s per-project context (all agents).
pub fn project_branch(project_id: &str) -> String {
    format!("p-{project_id}")
}

/// The two-tier context store rooted at a `tracked/` directory.
pub struct ContextStore {
    store: BranchStore,
    root: PathBuf,
}

impl ContextStore {
    /// Open (or initialize) the node's canonical store at
    /// [`tracked_dir`](crate::perch::tracked_dir).
    pub fn open_or_init() -> std::io::Result<ContextStore> {
        Self::open_or_init_in(&tracked_dir())
    }

    /// Open (or initialize) a store rooted at an explicit `tracked/` dir —
    /// the testable seam.
    // [impl->REQ-STORE-1]
    pub fn open_or_init_in(root: &Path) -> std::io::Result<ContextStore> {
        let store = BranchStore::open_or_init(&root.join(".seed.git"))?;
        Ok(ContextStore { store, root: root.to_path_buf() })
    }

    /// The underlying branch-as-KV store (D6c's bundle exchange works on it).
    pub fn branch_store(&self) -> &BranchStore {
        &self.store
    }

    /// The `tracked/` root this store lives under.
    pub fn root(&self) -> &Path {
        &self.root
    }

    fn validated(id: &str) -> std::io::Result<()> {
        validate_endpoint_id(id)
            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()))
    }

    /// Ensure the `a-<id>` branch + worktree exist; return the live-context
    /// file path inside it (`tracked/agents/<id>/live-context.md`). The id is
    /// charset-validated (4.6) — branch names and worktree dirs are derived
    /// from it.
    // [impl->REQ-STORE-1]
    pub fn live_context_path(&self, id: &str) -> std::io::Result<PathBuf> {
        Self::validated(id)?;
        let wt = self.root.join("agents").join(id);
        self.store.ensure_worktree(&agent_branch(id), &wt)?;
        Ok(wt.join(LIVE_CONTEXT_FILE))
    }

    /// Ensure the `p-<project_id>` branch + worktree exist; return the
    /// per-(agent,project) context file path inside it
    /// (`tracked/projects/<project_id>/<id>/project-context.md`). Both
    /// components are charset-validated (4.6; project ids come from
    /// [`crate::project::project_id_for_dir`], which emits the same charset).
    // [impl->REQ-STORE-1]
    pub fn project_context_path(&self, project_id: &str, id: &str) -> std::io::Result<PathBuf> {
        Self::validated(project_id)?;
        Self::validated(id)?;
        let wt = self.root.join("projects").join(project_id);
        self.store.ensure_worktree(&project_branch(project_id), &wt)?;
        let dir = wt.join(id);
        std::fs::create_dir_all(&dir)?;
        Ok(dir.join(PROJECT_CONTEXT_FILE))
    }

    /// Commit `id`'s live-context worktree — one checkpoint commit on `a-<id>`
    /// (no-op when nothing changed). Call after a guarded write through
    /// [`live_context_path`](Self::live_context_path).
    // [impl->REQ-STORE-1]
    pub fn commit_live(&self, id: &str, message: &str) -> std::io::Result<Option<String>> {
        Self::validated(id)?;
        let wt = self.root.join("agents").join(id);
        self.store.commit_in_worktree(&wt, &[], message)
    }

    /// Commit the `p-<project_id>` worktree — one checkpoint commit (no-op
    /// when nothing changed).
    // [impl->REQ-STORE-1]
    pub fn commit_project(&self, project_id: &str, message: &str) -> std::io::Result<Option<String>> {
        Self::validated(project_id)?;
        let wt = self.root.join("projects").join(project_id);
        self.store.commit_in_worktree(&wt, &[], message)
    }
}

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

    #[test] // [unit->REQ-STORE-1] tier paths land on the documented layout; commits checkpoint per branch.
    fn two_tier_layout_and_commits() {
        let tmp = tempdir().unwrap();
        let cs = ContextStore::open_or_init_in(tmp.path()).unwrap();

        let live = cs.live_context_path("doyle").unwrap();
        assert!(live.ends_with(Path::new("agents").join("doyle").join(LIVE_CONTEXT_FILE)));
        std::fs::write(&live, "live mind").unwrap();
        let c = cs.commit_live("doyle", "live v1").unwrap();
        assert!(c.is_some());
        assert_eq!(
            cs.branch_store().read_at_tip(&agent_branch("doyle"), LIVE_CONTEXT_FILE).unwrap(),
            Some(b"live mind".to_vec())
        );

        let proj = cs.project_context_path("spt-core", "doyle").unwrap();
        assert!(proj.ends_with(
            Path::new("projects").join("spt-core").join("doyle").join(PROJECT_CONTEXT_FILE)
        ));
        std::fs::write(&proj, "project mind").unwrap();
        let c = cs.commit_project("spt-core", "proj v1").unwrap();
        assert!(c.is_some());
        assert_eq!(
            cs.branch_store()
                .read_at_tip(&project_branch("spt-core"), "doyle/project-context.md")
                .unwrap(),
            Some(b"project mind".to_vec())
        );
    }

    #[test] // [unit->REQ-STORE-1] the p- branch aggregates multiple agents (cross-agent synthesis shape).
    fn project_branch_holds_many_agents() {
        let tmp = tempdir().unwrap();
        let cs = ContextStore::open_or_init_in(tmp.path()).unwrap();
        for id in ["doyle", "momo"] {
            let p = cs.project_context_path("spt-core", id).unwrap();
            std::fs::write(&p, format!("{id} ctx")).unwrap();
        }
        cs.commit_project("spt-core", "both").unwrap().unwrap();
        let bs = cs.branch_store();
        assert!(bs.read_at_tip(&project_branch("spt-core"), "doyle/project-context.md").unwrap().is_some());
        assert!(bs.read_at_tip(&project_branch("spt-core"), "momo/project-context.md").unwrap().is_some());
    }

    #[test] // [unit->REQ-HAZARD-ID-CHARSET] a path-smuggling id is refused before any branch/dir derives from it.
    fn invalid_id_refused() {
        let tmp = tempdir().unwrap();
        let cs = ContextStore::open_or_init_in(tmp.path()).unwrap();
        for bad in ["../up", "a/b", "a:b", "a@b", ""] {
            assert!(cs.live_context_path(bad).is_err(), "{bad:?} must refuse");
        }
    }
}

+/// Pure path of `id`'s live-context file under a `tracked/` root — the

+/// **read** form (no branch/worktree creation; readers must not mint store

+/// state). The single layout authority both the ensure-paths and every reader

+pub fn live_context_file(root: &Path, id: &str) -> PathBuf {

+    root.join("agents").join(id).join(LIVE_CONTEXT_FILE)

+/// Pure path of the per-(agent,project) context file under a `tracked/` root —

+pub fn project_context_file(root: &Path, project_id: &str, id: &str) -> PathBuf {

+    root.join("projects").join(project_id).join(id).join(PROJECT_CONTEXT_FILE)


    pub fn live_context_path(&self, id: &str) -> std::io::Result<PathBuf> {
        Self::validated(id)?;
        let wt = self.root.join("agents").join(id);
        self.store.ensure_worktree(&agent_branch(id), &wt)?;
        Ok(wt.join(LIVE_CONTEXT_FILE))
    }

    pub fn live_context_path(&self, id: &str) -> std::io::Result<PathBuf> {
        Self::validated(id)?;
        let file = live_context_file(&self.root, id);
        let wt = file.parent().expect("live-context file has a worktree parent");
        self.store.ensure_worktree(&agent_branch(id), wt)?;
        Ok(file)
    }


    pub fn live_context_path(&self, id: &str) -> std::io::Result<PathBuf> {
        Self::validated(id)?;
        let file = live_context_file(&self.root, id);
        let wt = file.parent().expect("live-context file has a worktree parent");
        self.store.ensure_worktree(&agent_branch(id), wt)?;
        Ok(file)
    }

//! The two-tier context store — `tracked/` realized over [`BranchStore`]
//! (M4-D6a; STORAGE §tracked/, ADR-0011, ADR-0013).
//!
//! Layout (the synced *mind*, physically separate from node-local `perches/`):
//!
//! ```text
//! tracked/
//!   .seed.git                    bare BranchStore repo (the one true object DB)
//!   agents/<id>/                 linked worktree of branch  a-<id>
//!     live-context.md            per-agent live context  → syncs to ALL instances
//!   projects/<project_id>/       linked worktree of branch  p-<project_id>
//!     <id>/project-context.md    per-(agent,project)      → same-project instances
//! ```
//!
//! Branches are the sync/merge unit (`a-`/`p-` refs are what D6c's bundle
//! exchange ships); worktrees are the read/write view. Tier routing is the
//! CONTEXT-MEMORY two-tier split: the `a-` branch is the agent's mind
//! everywhere, the `p-` branch holds every agent's context for one project
//! (cross-agent synthesis + query-routing ride that shape).
//!
//! Writes here are **paths + commits only** — the precedence-marker guard
//! (6.5) stays in `spt-live::context`, which writes through the paths this
//! store hands out, then calls the commit hook. Single writer per branch
//! (active-instance-authoritative; 6.4).
// [impl->REQ-STORE-1]

use std::path::{Path, PathBuf};

use crate::branchstore::BranchStore;
use crate::perch::tracked_dir;
use spt_proto::id::validate_endpoint_id;

/// The per-agent live-context file inside an `a-<id>` worktree.
pub const LIVE_CONTEXT_FILE: &str = "live-context.md";
/// The per-(agent,project) context file inside a `p-<project>` worktree,
/// nested under the agent id.
pub const PROJECT_CONTEXT_FILE: &str = "project-context.md";

/// The branch carrying `id`'s live context (the per-agent view).
pub fn agent_branch(id: &str) -> String {
    format!("a-{id}")
}

/// The branch carrying `project_id`'s per-project context (all agents).
pub fn project_branch(project_id: &str) -> String {
    format!("p-{project_id}")
}

/// Pure path of `id`'s live-context file under a `tracked/` root — the
/// **read** form (no branch/worktree creation; readers must not mint store
/// state). The single layout authority both the ensure-paths and every reader
/// route through (6.1).
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn live_context_file(root: &Path, id: &str) -> PathBuf {
    root.join("agents").join(id).join(LIVE_CONTEXT_FILE)
}

/// Pure path of the per-(agent,project) context file under a `tracked/` root —
/// the **read** form (no creation).
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn project_context_file(root: &Path, project_id: &str, id: &str) -> PathBuf {
    root.join("projects").join(project_id).join(id).join(PROJECT_CONTEXT_FILE)
}

/// The two-tier context store rooted at a `tracked/` directory.
pub struct ContextStore {
    store: BranchStore,
    root: PathBuf,
}

impl ContextStore {
    /// Open (or initialize) the node's canonical store at
    /// [`tracked_dir`](crate::perch::tracked_dir).
    pub fn open_or_init() -> std::io::Result<ContextStore> {
        Self::open_or_init_in(&tracked_dir())
    }

    /// Open (or initialize) a store rooted at an explicit `tracked/` dir —
    /// the testable seam.
    // [impl->REQ-STORE-1]
    pub fn open_or_init_in(root: &Path) -> std::io::Result<ContextStore> {
        let store = BranchStore::open_or_init(&root.join(".seed.git"))?;
        Ok(ContextStore { store, root: root.to_path_buf() })
    }

    /// The underlying branch-as-KV store (D6c's bundle exchange works on it).
    pub fn branch_store(&self) -> &BranchStore {
        &self.store
    }

    /// The `tracked/` root this store lives under.
    pub fn root(&self) -> &Path {
        &self.root
    }

    fn validated(id: &str) -> std::io::Result<()> {
        validate_endpoint_id(id)
            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()))
    }

    /// Ensure the `a-<id>` branch + worktree exist; return the live-context
    /// file path inside it (`tracked/agents/<id>/live-context.md`). The id is
    /// charset-validated (4.6) — branch names and worktree dirs are derived
    /// from it.
    // [impl->REQ-STORE-1]
    pub fn live_context_path(&self, id: &str) -> std::io::Result<PathBuf> {
        Self::validated(id)?;
        let wt = self.root.join("agents").join(id);
        self.store.ensure_worktree(&agent_branch(id), &wt)?;
        Ok(wt.join(LIVE_CONTEXT_FILE))
    }

    /// Ensure the `p-<project_id>` branch + worktree exist; return the
    /// per-(agent,project) context file path inside it
    /// (`tracked/projects/<project_id>/<id>/project-context.md`). Both
    /// components are charset-validated (4.6; project ids come from
    /// [`crate::project::project_id_for_dir`], which emits the same charset).
    // [impl->REQ-STORE-1]
    pub fn project_context_path(&self, project_id: &str, id: &str) -> std::io::Result<PathBuf> {
        Self::validated(project_id)?;
        Self::validated(id)?;
        let wt = self.root.join("projects").join(project_id);
        self.store.ensure_worktree(&project_branch(project_id), &wt)?;
        let dir = wt.join(id);
        std::fs::create_dir_all(&dir)?;
        Ok(dir.join(PROJECT_CONTEXT_FILE))
    }

    /// Commit `id`'s live-context worktree — one checkpoint commit on `a-<id>`
    /// (no-op when nothing changed). Call after a guarded write through
    /// [`live_context_path`](Self::live_context_path).
    // [impl->REQ-STORE-1]
    pub fn commit_live(&self, id: &str, message: &str) -> std::io::Result<Option<String>> {
        Self::validated(id)?;
        let wt = self.root.join("agents").join(id);
        self.store.commit_in_worktree(&wt, &[], message)
    }

    /// Commit the `p-<project_id>` worktree — one checkpoint commit (no-op
    /// when nothing changed).
    // [impl->REQ-STORE-1]
    pub fn commit_project(&self, project_id: &str, message: &str) -> std::io::Result<Option<String>> {
        Self::validated(project_id)?;
        let wt = self.root.join("projects").join(project_id);
        self.store.commit_in_worktree(&wt, &[], message)
    }
}

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

    #[test] // [unit->REQ-STORE-1] tier paths land on the documented layout; commits checkpoint per branch.
    fn two_tier_layout_and_commits() {
        let tmp = tempdir().unwrap();
        let cs = ContextStore::open_or_init_in(tmp.path()).unwrap();

        let live = cs.live_context_path("doyle").unwrap();
        assert!(live.ends_with(Path::new("agents").join("doyle").join(LIVE_CONTEXT_FILE)));
        std::fs::write(&live, "live mind").unwrap();
        let c = cs.commit_live("doyle", "live v1").unwrap();
        assert!(c.is_some());
        assert_eq!(
            cs.branch_store().read_at_tip(&agent_branch("doyle"), LIVE_CONTEXT_FILE).unwrap(),
            Some(b"live mind".to_vec())
        );

        let proj = cs.project_context_path("spt-core", "doyle").unwrap();
        assert!(proj.ends_with(
            Path::new("projects").join("spt-core").join("doyle").join(PROJECT_CONTEXT_FILE)
        ));
        std::fs::write(&proj, "project mind").unwrap();
        let c = cs.commit_project("spt-core", "proj v1").unwrap();
        assert!(c.is_some());
        assert_eq!(
            cs.branch_store()
                .read_at_tip(&project_branch("spt-core"), "doyle/project-context.md")
                .unwrap(),
            Some(b"project mind".to_vec())
        );
    }

    #[test] // [unit->REQ-STORE-1] the p- branch aggregates multiple agents (cross-agent synthesis shape).
    fn project_branch_holds_many_agents() {
        let tmp = tempdir().unwrap();
        let cs = ContextStore::open_or_init_in(tmp.path()).unwrap();
        for id in ["doyle", "momo"] {
            let p = cs.project_context_path("spt-core", id).unwrap();
            std::fs::write(&p, format!("{id} ctx")).unwrap();
        }
        cs.commit_project("spt-core", "both").unwrap().unwrap();
        let bs = cs.branch_store();
        assert!(bs.read_at_tip(&project_branch("spt-core"), "doyle/project-context.md").unwrap().is_some());
        assert!(bs.read_at_tip(&project_branch("spt-core"), "momo/project-context.md").unwrap().is_some());
    }

    #[test] // [unit->REQ-HAZARD-ID-CHARSET] a path-smuggling id is refused before any branch/dir derives from it.
    fn invalid_id_refused() {
        let tmp = tempdir().unwrap();
        let cs = ContextStore::open_or_init_in(tmp.path()).unwrap();
        for bad in ["../up", "a/b", "a:b", "a@b", ""] {
            assert!(cs.live_context_path(bad).is_err(), "{bad:?} must refuse");
        }
    }
}

-        let wt = self.root.join("agents").join(id);

-        self.store.ensure_worktree(&agent_branch(id), &wt)?;

+        let file = live_context_file(&self.root, id);

+        let wt = file.parent().expect("live-context file has a worktree parent");

+        self.store.ensure_worktree(&agent_branch(id), wt)?;


    pub fn project_context_path(&self, project_id: &str, id: &str) -> std::io::Result<PathBuf> {
        Self::validated(project_id)?;
        Self::validated(id)?;
        let wt = self.root.join("projects").join(project_id);
        self.store.ensure_worktree(&project_branch(project_id), &wt)?;
        let dir = wt.join(id);
        std::fs::create_dir_all(&dir)?;
        Ok(dir.join(PROJECT_CONTEXT_FILE))
    }

    pub fn project_context_path(&self, project_id: &str, id: &str) -> std::io::Result<PathBuf> {
        Self::validated(project_id)?;
        Self::validated(id)?;
        let file = project_context_file(&self.root, project_id, id);
        let wt = self.root.join("projects").join(project_id);
        self.store.ensure_worktree(&project_branch(project_id), &wt)?;
        std::fs::create_dir_all(file.parent().expect("project-context file has an agent dir"))?;
        Ok(file)
    }