Task #1 created successfully: T0 — Scaffold spt-live crate

T0 — Scaffold spt-live crate


TaskCreate

T1 — History subsystem

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


Task #2 created successfully: T1 — History subsystem

T1 — History subsystem


T2 — spawn-psyche seam

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


Task #3 created successfully: T2 — spawn-psyche seam

T2 — spawn-psyche seam


T3 — echo-commune seam

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


Task #4 created successfully: T3 — echo-commune seam

T3 — echo-commune seam


T4 — drop-file ingest

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


Task #5 created successfully: T4 — drop-file ingest

T4 — drop-file ingest


T5 — pulse heartbeat

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


Task #6 created successfully: T5 — pulse heartbeat

T5 — pulse heartbeat


T6 — signoff + boundary lifecycle

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


Task #7 created successfully: T6 — signoff + boundary lifecycle

T6 — signoff + boundary lifecycle


T7 — resume-session seam

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


Task #8 created successfully: T7 — resume-session seam

T7 — resume-session seam


T8 — api listen lifecycle wiring

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


Task #9 created successfully: T8 — api listen lifecycle wiring

T8 — api listen lifecycle wiring


T9 — LiveAgent E2E

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


Task #10 created successfully: T9 — LiveAgent E2E

T9 — LiveAgent E2E


T10 — Activation sweep

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


Task #11 created successfully: T10 — Activation sweep

T10 — Activation sweep


Check looks green. Confirm exit code, mark T0 done, commit.


Bash

traceable-reqs check > $null 2>&1; echo "check_exit=$LASTEXITCODE"; cargo build --workspace 2>&1 | tail -3

Confirm check exit code and workspace build


/usr/bin/bash: line 4: $null: ambiguous redirect
check_exit=
   Compiling spt v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.37s


Updated task #1 status


Bash tool = bash. Use `/dev/null`.


traceable-reqs check > /dev/null 2>&1; echo "check_exit=$?"

Confirm check exit code


Green. Commit T0.


git add -A && git commit -q -F - <<'EOF'
feat(spt-live): M2b T0 — scaffold live-agent lifecycle crate

Add crates/spt-live (fifth layer: spt-proto -> spt-store -> spt-msg ->
spt-runtime -> spt-live), deps proto/store/msg/runtime + serde. Daemon-bound
executor for the M2b lifecycle seams that spt-runtime (M2a) only declared and
parsed; NOT public SDK (R-ARCH-2) — M3 consolidates it into spt-daemon.

lib.rs docs the interim no-daemon model (decision 2026-06-01) + the subsystem
roadmap (history/spawn-psyche/echo/ingest/pulse/signoff/resume). Layering stays
acyclic; workspace members updated; traceable-reqs check green.

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

Commit T0 scaffold


warning: in the working copy of 'Cargo.lock', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'Cargo.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-live/Cargo.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-live/src/lib.rs', LF will be replaced by CRLF the next time Git touches it
e0b2c80 feat(spt-live): M2b T0 — scaffold live-agent lifecycle crate


T0 shipped. Now T1 — history subsystem. Read manifest `[history]` schema + existing history-log + STORAGE.


grep -rn "history" crates/spt-runtime/src/manifest.rs | head -40

Find history in manifest schema


9://! Closed vocabularies (adapter kind, history strategy, env direction, inject
15://! lifecycle sections (`[session.psyche_*]`, commune/signoff dirs, `[history]`
40:    pub history: Option<History>,
188:/// `[history]` — transcript access strategy.
192:    /// `fetcher` strategy: adapter binary emitting normalized history.
210:    /// Adapter pushes via `api history-log`; spt-core stores (Path-B).
282:    /// Ephemeral ⇒ no offline perch + no history retention.
426:        // history strategy ↔ required fields.
427:        if let Some(h) = &self.history {
430:                    return err("[history] strategy = \"fetcher\" requires `fetcher`".into());
435:                    return err("[history] strategy = \"locate_normalize\" requires both `locate_template` and `normalize_command`".into());
506:[history]
508:fetcher = "claude-spt-history --session {session_id}"
566:        // history / inject / identity / update enums
567:        assert_eq!(m.history.unwrap().strategy, HistoryStrategy::Fetcher);
602:[history]
663:[history]


grep -rln "history" crates/ docs/ | head -30

Find history references across repo


crates/spt/src/api/mod.rs
crates/spt/src/api/reporting.rs
crates/spt-live/Cargo.toml
crates/spt-live/src/lib.rs
crates/spt-runtime/src/manifest.rs
crates/spt-runtime/src/runtime.rs
docs/adr/0003-multi-instance-node-anchored-identity-model.md
docs/KNOWN-HAZARDS.md
docs/MANIFEST.md
docs/reviews/STAGE-A-codex-redteam.md
docs/STORAGE.md


186	}
187
188	/// `[history]` — transcript access strategy.
189	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
190	pub struct History {
191	    pub strategy: HistoryStrategy,
192	    /// `fetcher` strategy: adapter binary emitting normalized history.
193	    #[serde(default, skip_serializing_if = "Option::is_none")]
194	    pub fetcher: Option<String>,
195	    /// `locate_normalize` strategy: where the raw transcript lives.
196	    #[serde(default, skip_serializing_if = "Option::is_none")]
197	    pub locate_template: Option<String>,
198	    /// `locate_normalize` strategy: command normalizing the raw transcript.
199	    #[serde(default, skip_serializing_if = "Option::is_none")]
200	    pub normalize_command: Option<String>,
201	}
202
203	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
204	#[serde(rename_all = "snake_case")]
205	pub enum HistoryStrategy {
206	    /// spt-core asks the adapter (pull, adapter binary emits normalized).
207	    Fetcher,
208	    /// spt-core locates the raw transcript then normalizes it.
209	    LocateNormalize,
210	    /// Adapter pushes via `api history-log`; spt-core stores (Path-B).
211	    Native,
212	}
213
214	/// `[inject]` — inject-input methods per activity state.
215	#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
216	pub struct Inject {
217	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
218	    pub activity: Vec<InjectMethod>,
219	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
220	    pub idle: Vec<InjectMethod>,
221	}
222
223	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
224	#[serde(rename_all = "snake_case")]
225	pub enum InjectMethod {
226	    Pty,
227	    Hook,
228	    Relay,
229	    Http,
230	}
231
232	/// `[identity]` — how the harness's session id is obtained.
233	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
234	pub struct Identity {
235	    pub session_id_source: SessionIdSource,
236	    /// Process-tree anchor name when `session_id` is absent.
237	    #[serde(default, skip_serializing_if = "Option::is_none")]
238	    pub parent_ancestor_name: Option<String>,
239	}
240
241	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
242	#[serde(rename_all = "snake_case")]
243	pub enum SessionIdSource {
244	    /// Discovered after spawn (process-tree / wrapper handoff).
245	    PostSpawn,
246	    /// Injected as a UUID the harness echoes back.
247	    UuidInject,
248	}
249
250	/// `[update]` — adapter self-update directive (parsed in M2a; conducted in M3).
251	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
252	pub struct Update {
253	    pub avenue: UpdateAvenue,
254	    /// `delegated` avenue: the command spt-core delegates to.
255	    #[serde(default, skip_serializing_if = "Option::is_none")]
256	    pub command: Option<String>,
257	    /// `file_pull` avenue: source repo.
258	    #[serde(default, skip_serializing_if = "Option::is_none")]
259	    pub repo: Option<String>,
260	    /// `file_pull` avenue: path selector.
261	    #[serde(default, skip_serializing_if = "Option::is_none")]
262	    pub path_regex: Option<String>,
263	    /// Verify spt-core satisfies `min_spt_core_version` before/after.
264	    #[serde(default)]
265	    pub version_check: bool,
266	}
267
268	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
269	#[serde(rename_all = "snake_case")]
270	pub enum UpdateAvenue {
271	    /// Delegate to the adapter's own updater (e.g. `claude plugin update`).
272	    Delegated,
273	    /// spt-core pulls files from a repo.
274	    FilePull,
275	}
276
277	/// `[shell]` — the body of a `kind = "shell"` adapter (a driven surface).
278	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
279	pub struct Shell {
280	    /// Broker-launched opaque spawn command.
281	    pub spawn: String,
282	    /// Ephemeral ⇒ no offline perch + no history retention.
283	    #[serde(default)]
284	    pub ephemeral: bool,
285	    #[serde(default, skip_serializing_if = "Option::is_none")]
286	    pub broadcast: Option<Broadcast>,
287	    /// How the shell receives agent commands.
288	    #[serde(default, skip_serializing_if = "Option::is_none")]
289	    pub command_receipt: Option<CommandReceipt>,
290	    /// Instruction sent to the binary on link-break.
291	    #[serde(default, skip_serializing_if = "Option::is_none")]
292	    pub pre_close: Option<String>,
293	    /// Graceful-termination window before force-close.
294	    #[serde(default, skip_serializing_if = "Option::is_none")]
295	    pub close_timeout_ms: Option<u64>,

}

/// `[history]` — transcript access strategy.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct History {
    pub strategy: HistoryStrategy,
    /// `fetcher` strategy: adapter binary emitting normalized history.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub fetcher: Option<String>,
    /// `locate_normalize` strategy: where the raw transcript lives.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub locate_template: Option<String>,
    /// `locate_normalize` strategy: command normalizing the raw transcript.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub normalize_command: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HistoryStrategy {
    /// spt-core asks the adapter (pull, adapter binary emits normalized).
    Fetcher,
    /// spt-core locates the raw transcript then normalizes it.
    LocateNormalize,
    /// Adapter pushes via `api history-log`; spt-core stores (Path-B).
    Native,
}

/// `[inject]` — inject-input methods per activity state.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct Inject {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub activity: Vec<InjectMethod>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub idle: Vec<InjectMethod>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InjectMethod {
    Pty,
    Hook,
    Relay,
    Http,
}

/// `[identity]` — how the harness's session id is obtained.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Identity {
    pub session_id_source: SessionIdSource,
    /// Process-tree anchor name when `session_id` is absent.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub parent_ancestor_name: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SessionIdSource {
    /// Discovered after spawn (process-tree / wrapper handoff).
    PostSpawn,
    /// Injected as a UUID the harness echoes back.
    UuidInject,
}

/// `[update]` — adapter self-update directive (parsed in M2a; conducted in M3).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Update {
    pub avenue: UpdateAvenue,
    /// `delegated` avenue: the command spt-core delegates to.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub command: Option<String>,
    /// `file_pull` avenue: source repo.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub repo: Option<String>,
    /// `file_pull` avenue: path selector.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub path_regex: Option<String>,
    /// Verify spt-core satisfies `min_spt_core_version` before/after.
    #[serde(default)]
    pub version_check: bool,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum UpdateAvenue {
    /// Delegate to the adapter's own updater (e.g. `claude plugin update`).
    Delegated,
    /// spt-core pulls files from a repo.
    FilePull,
}

/// `[shell]` — the body of a `kind = "shell"` adapter (a driven surface).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Shell {
    /// Broker-launched opaque spawn command.
    pub spawn: String,
    /// Ephemeral ⇒ no offline perch + no history retention.
    #[serde(default)]
    pub ephemeral: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub broadcast: Option<Broadcast>,
    /// How the shell receives agent commands.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub command_receipt: Option<CommandReceipt>,
    /// Instruction sent to the binary on link-break.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pre_close: Option<String>,
    /// Graceful-termination window before force-close.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub close_timeout_ms: Option<u64>,


Now read the native history-log (Path-B store) + ManifestRuntime API for `run_bounded`.


grep -n "history" crates/spt/src/api/reporting.rs

history in reporting


2://! `api presence`, `api history-log`, `api emit`, plus the M2a *declarations*
9://!   teardown by default (preserve perch + spool + history); `--erase`
13://! - **history-log** (REQ-API-2) — Path-B native store: append a normalized
14://!   record to the perch's history log.
67:        eprintln!("SESSION_END:{id} (soft; spool + history preserved)");
112:/// Append a normalized record to the perch's Path-B native history store,
114:pub fn append_history(id: &str, record: &str) -> std::io::Result<usize> {
121:        .open(perch_path.join("history.log"))?;
131:/// `api history-log <id>` — Path-B ingest: read a normalized record from stdin
134:pub fn cmd_history_log(id: &str) -> i32 {
139:        eprintln!("EMPTY_RECORD: history-log read nothing on stdin");
142:    match append_history(id, record.trim_end()) {
277:    #[test] // [unit->REQ-API-2] history-log appends to the Path-B native store.
278:    fn history_log_appends() {
281:        append_history("alice", "{\"role\":\"user\",\"text\":\"hi\"}").unwrap();
282:        append_history("alice", "{\"role\":\"assistant\",\"text\":\"yo\"}").unwrap();
283:        let log = perch::resolve_perch_path("alice", ParentHint::Infer).join("history.log");


1	//! The outbound contract: turn a parsed [`Manifest`] role template into a real
2	//! process. Two surfaces:
3	//!
4	//! - [`AgentRuntime`] — the **spawn-session seam** M3 reimplements. M2a's
5	//!   [`ManifestRuntime`] spawns a local OS process; M3's daemon will spawn the
6	//!   session as a loop/PTY child behind the *same* trait, so callers don't
7	//!   change (REQ-SEAM-SPAWN).
8	//! - [`fill_template`] — opaque-template substitution. spt-core never parses a
9	//!   model/flag out of `command`; it only fills `{key}` placeholders from the
10	//!   value map spt-core guarantees (MANIFEST §Principle). A placeholder with no
11	//!   provided value is an error **before** spawn — never a half-built command.
12	//!
13	//! ## Subprocess timeout (KNOWN-HAZARDS 5.3, REQ-HAZARD-SUBPROCESS-TIMEOUT)
14	//! Every *bounded* manifest invocation (a non-detached role, a delegated
15	//! update, a history fetcher) runs under a mandatory timeout: a hung child is
16	//! killed and yields [`RuntimeError::Timeout`], never an indefinite block.
17	//! Detached, long-running sessions (`detach = true`) are fire-and-forget — the
18	//! timeout governs the *launch handshake*, not the session's lifetime.
19	//
20	// [impl->REQ-SEAM-SPAWN]
21
22	use crate::manifest::{Manifest, SessionRole};
23	use std::collections::BTreeMap;
24	use std::io::Read;
25	use std::process::{Child, Command, Stdio};
26	use std::time::{Duration, Instant};
27
28	/// Default bound for a bounded invocation when the caller names none. Generous
29	/// enough for a normalizer/fetcher, finite enough to never hang a supervisor.
30	pub const DEFAULT_BOUNDED_TIMEOUT: Duration = Duration::from_secs(30);
31
32	/// The substitution keys spt-core guarantees it can fill. A role's `keys` must
33	/// be a subset of this catalog; any `{placeholder}` in a `command` must resolve
34	/// to a provided value. Documented here so adapter authors know the vocabulary
35	/// (MANIFEST §Open: "exact substitution-key catalog").
36	pub const SUBSTITUTION_KEYS: &[&str] = &[
37	    "id",
38	    "adapter_name",
39	    "session_id",
40	    "session_name",
41	    "parent_pid",
42	    "psyche_dir",
43	    "psyche_prompt",
44	    "agents_json",
45	    "link_token",
46	    "source",
47	    "agent_type",
48	];
49
50	/// A spawn/template failure. Carries a message for the operator; never panics.
51	#[derive(Debug)]
52	pub enum RuntimeError {
53	    /// A `{placeholder}` in a command had no provided value.
54	    MissingKey { key: String, command: String },
55	    /// The filled command was empty (no program token).
56	    EmptyCommand,
57	    /// The named role isn't declared in the manifest.
58	    UnknownRole(String),
59	    /// The OS refused to spawn the process.
60	    Spawn(std::io::Error),
61	    /// A bounded invocation exceeded its timeout and was killed.
62	    Timeout { command: String, after: Duration },
63	}
64
65	impl std::fmt::Display for RuntimeError {
66	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67	        match self {
68	            RuntimeError::MissingKey { key, command } => {
69	                write!(f, "no value for substitution key {{{key}}} in command: {command}")
70	            }
71	            RuntimeError::EmptyCommand => write!(f, "command template produced an empty command"),
72	            RuntimeError::UnknownRole(r) => write!(f, "manifest declares no [session.{r}] role"),
73	            RuntimeError::Spawn(e) => write!(f, "failed to spawn session: {e}"),
74	            RuntimeError::Timeout { command, after } => {
75	                write!(f, "bounded invocation timed out after {after:?} (killed): {command}")
76	            }
77	        }
78	    }
79	}
80
81	impl std::error::Error for RuntimeError {}
82
83	/// Fill `{key}` placeholders in an opaque template from `keys`. Unknown
84	/// placeholders error (listing the missing key); literal text passes through.
85	/// `{{`/`}}` are not interpreted — command templates use bare `{key}` only.
86	pub fn fill_template(
87	    template: &str,
88	    keys: &BTreeMap<String, String>,
89	) -> Result<String, RuntimeError> {
90	    let mut out = String::with_capacity(template.len());
91	    let mut rest = template;
92	    while let Some(open) = rest.find('{') {
93	        out.push_str(&rest[..open]);
94	        let after = &rest[open + 1..];
95	        let close = after.find('}').ok_or_else(|| RuntimeError::MissingKey {
96	            key: after.chars().take(16).collect(),
97	            command: template.to_string(),
98	        })?;
99	        let key = &after[..close];
100	        let value = keys.get(key).ok_or_else(|| RuntimeError::MissingKey {
101	            key: key.to_string(),
102	            command: template.to_string(),
103	        })?;
104	        out.push_str(value);
105	        rest = &after[close + 1..];
106	    }
107	    out.push_str(rest);
108	    Ok(out)
109	}
110
111	/// Split a filled command line into program + args, honoring double quotes so a
112	/// quoted argument with spaces stays one token. Clean-room (no shell dependency,
113	/// portable). Single quotes are literal — adapters quote with `"`.
114	pub fn tokenize(command: &str) -> Vec<String> {
115	    let mut tokens = Vec::new();
116	    let mut cur = String::new();
117	    let mut in_quote = false;
118	    let mut has_token = false;
119	    for ch in command.chars() {
120	        match ch {
121	            '"' => {
122	                in_quote = !in_quote;
123	                has_token = true;
124	            }
125	            c if c.is_whitespace() && !in_quote => {
126	                if has_token {
127	                    tokens.push(std::mem::take(&mut cur));
128	                    has_token = false;
129	                }
130	            }
131	            c => {
132	                cur.push(c);
133	                has_token = true;
134	            }
135	        }
136	    }
137	    if has_token {
138	        tokens.push(cur);
139	    }
140	    tokens
141	}
142
143	/// A spawned detached session — its OS pid. The handle is intentionally dropped
144	/// (fire-and-forget); M3's broker owns the durable handle.
145	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146	pub struct Spawned {
147	    pub pid: u32,
148	}
149
150	/// Output of a bounded invocation that ran to completion within its timeout.
151	#[derive(Debug, Clone)]
152	pub struct BoundedOutput {
153	    pub status_code: Option<i32>,
154	    pub stdout: String,
155	    pub stderr: String,
156	}
157
158	impl BoundedOutput {
159	    pub fn success(&self) -> bool {
160	        self.status_code == Some(0)
161	    }
162	}
163
164	/// The spawn-session seam. M2a = local process; M3 daemon = loop/PTY child,
165	/// same trait. A caller holds an `&dyn AgentRuntime` and never learns which.
166	pub trait AgentRuntime {
167	    /// Spawn a **detached** session from a `[session.<role>]` template, filling
168	    /// its substitution keys. Returns once the process exists (REQ-SEAM-SPAWN).
169	    fn spawn_session(
170	        &self,
171	        role: &str,
172	        keys: &BTreeMap<String, String>,
173	    ) -> Result<Spawned, RuntimeError>;
174
175	    /// Run a **bounded** invocation (a metadata-producing role/command) to
176	    /// completion under `timeout`, capturing output. Hung children are killed
177	    /// (REQ-HAZARD-SUBPROCESS-TIMEOUT).
178	    fn run_bounded(
179	        &self,
180	        role: &str,
181	        keys: &BTreeMap<String, String>,
182	        timeout: Duration,
183	    ) -> Result<BoundedOutput, RuntimeError>;
184	}
185
186	/// Default [`AgentRuntime`]: substitution-fill a manifest role and spawn it as a
187	/// local OS process.
188	pub struct ManifestRuntime {
189	    manifest: Manifest,
190	}
191
192	impl ManifestRuntime {
193	    pub fn new(manifest: Manifest) -> Self {
194	        ManifestRuntime { manifest }
195	    }
196
197	    pub fn manifest(&self) -> &Manifest {
198	        &self.manifest
199	    }
200
201	    fn role(&self, role: &str) -> Result<&SessionRole, RuntimeError> {
202	        self.manifest
203	            .session
204	            .roles()
205	            .into_iter()
206	            .find(|(name, _)| *name == role)
207	            .map(|(_, r)| r)
208	            .ok_or_else(|| RuntimeError::UnknownRole(role.to_string()))
209	    }
210
211	    /// Build a `Command` from a role: fill the template, tokenize, apply cwd,
212	    /// env_remove, and the recursion guard. Shared by both spawn paths.
213	    fn command_for(
214	        &self,
215	        role: &SessionRole,
216	        keys: &BTreeMap<String, String>,
217	    ) -> Result<Command, RuntimeError> {
218	        let filled = fill_template(&role.command, keys)?;
219	        let tokens = tokenize(&filled);
220	        let (program, args) = tokens.split_first().ok_or(RuntimeError::EmptyCommand)?;
221	        let mut cmd = Command::new(program);
222	        cmd.args(args);
223	        if let Some(cwd) = &role.cwd {
224	            cmd.current_dir(fill_template(cwd, keys)?);
225	        }
226	        for var in &role.env_remove {
227	            cmd.env_remove(var);
228	        }
229	        if let Some(guard) = &role.recursion_guard_env {
230	            cmd.env(guard, "1");
231	        }
232	        Ok(cmd)
233	    }
234	}
235
236	impl AgentRuntime for ManifestRuntime {
237	    fn spawn_session(
238	        &self,
239	        role: &str,
240	        keys: &BTreeMap<String, String>,
241	    ) -> Result<Spawned, RuntimeError> {
242	        let role_def = self.role(role)?;
243	        let mut cmd = self.command_for(role_def, keys)?;
244	        // Detached: discard stdio, spawn, drop the handle. Best-effort detach
245	        // for the interim no-daemon model; M3's broker owns true detachment.
246	        cmd.stdin(Stdio::null())
247	            .stdout(Stdio::null())
248	            .stderr(Stdio::null());
249	        let child = cmd.spawn().map_err(RuntimeError::Spawn)?;
250	        Ok(Spawned { pid: child.id() })
251	    }
252
253	    fn run_bounded(
254	        &self,
255	        role: &str,
256	        keys: &BTreeMap<String, String>,
257	        timeout: Duration,
258	    ) -> Result<BoundedOutput, RuntimeError> {
259	        let role_def = self.role(role)?;
260	        let mut cmd = self.command_for(role_def, keys)?;
261	        cmd.stdin(Stdio::null())
262	            .stdout(Stdio::piped())
263	            .stderr(Stdio::piped());
264	        let label = fill_template(&role_def.command, keys)?;
265	        let child = cmd.spawn().map_err(RuntimeError::Spawn)?;
266	        wait_bounded(child, timeout, &label)
267	    }
268	}
269
270	/// Wait for `child` up to `timeout`, draining its pipes on reader threads so a
271	/// chatty child never deadlocks the poll. On timeout the child is killed and
272	/// reaped. Uses `Instant` (monotonic) for the deadline.
273	fn wait_bounded(
274	    mut child: Child,
275	    timeout: Duration,
276	    label: &str,
277	) -> Result<BoundedOutput, RuntimeError> {
278	    // Drain stdout/stderr on threads to avoid a full-pipe deadlock while we poll.
279	    let mut out_pipe = child.stdout.take();
280	    let mut err_pipe = child.stderr.take();
281	    let out_handle = std::thread::spawn(move || {
282	        let mut buf = String::new();
283	        if let Some(p) = out_pipe.as_mut() {
284	            let _ = p.read_to_string(&mut buf);
285	        }
286	        buf
287	    });
288	    let err_handle = std::thread::spawn(move || {
289	        let mut buf = String::new();
290	        if let Some(p) = err_pipe.as_mut() {
291	            let _ = p.read_to_string(&mut buf);
292	        }
293	        buf
294	    });
295
296	    let start = Instant::now();
297	    let status = loop {
298	        match child.try_wait() {
299	            Ok(Some(status)) => break status,
300	            Ok(None) => {
301	                if start.elapsed() >= timeout {
302	                    // [impl->REQ-HAZARD-SUBPROCESS-TIMEOUT] hung child → kill + reap,
303	                    // return Timeout; never block the supervisor indefinitely.
304	                    let _ = child.kill();
305	                    let _ = child.wait();
306	                    return Err(RuntimeError::Timeout {
307	                        command: label.to_string(),
308	                        after: timeout,
309	                    });
310	                }
311	                std::thread::sleep(Duration::from_millis(20));
312	            }
313	            Err(e) => return Err(RuntimeError::Spawn(e)),
314	        }
315	    };
316
317	    let stdout = out_handle.join().unwrap_or_default();
318	    let stderr = err_handle.join().unwrap_or_default();
319	    Ok(BoundedOutput {
320	        status_code: status.code(),
321	        stdout,
322	        stderr,
323	    })
324	}
325
326	#[cfg(test)]
327	mod tests {
328	    use super::*;
329
330	    fn keys(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
331	        pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
332	    }
333
334	    #[test] // [unit->REQ-SEAM-SPAWN]
335	    fn fills_known_placeholders() {
336	        let k = keys(&[("session_id", "abc"), ("session_name", "owl")]);
337	        let out = fill_template("run --id {session_id} --name {session_name}", &k).unwrap();
338	        assert_eq!(out, "run --id abc --name owl");
339	    }
340
341	    #[test] // [unit->REQ-SEAM-SPAWN]
342	    fn missing_key_errors_before_spawn() {
343	        let k = keys(&[("session_id", "abc")]);
344	        let e = fill_template("run --name {session_name}", &k).unwrap_err();
345	        match e {
346	            RuntimeError::MissingKey { key, .. } => assert_eq!(key, "session_name"),
347	            other => panic!("expected MissingKey, got {other}"),
348	        }
349	    }
350
351	    #[test] // [unit->REQ-SEAM-SPAWN]
352	    fn tokenize_honors_double_quotes() {
353	        let t = tokenize(r#"prog --msg "hello world" --flag"#);
354	        assert_eq!(t, ["prog", "--msg", "hello world", "--flag"]);
355	    }
356
357	    #[test] // [unit->REQ-SEAM-SPAWN]
358	    fn unknown_role_errors() {
359	        let m = Manifest::from_toml_str(
360	            "[adapter]\nname=\"x\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n",
361	        )
362	        .unwrap();
363	        let rt = ManifestRuntime::new(m);
364	        let e = rt.spawn_session("self", &keys(&[])).unwrap_err();
365	        assert!(matches!(e, RuntimeError::UnknownRole(_)));
366	    }
367
368	    // A trivially-portable bounded command: the host's own test binary does
369	    // nothing and exits 0 when given a no-op. Use the platform `cmd`/`true`.
370	    fn echo_manifest(cmd: &str) -> ManifestRuntime {
371	        let toml = format!(
372	            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n[session.self]\ncommand=\"{cmd}\"\n"
373	        );
374	        ManifestRuntime::new(Manifest::from_toml_str(&toml).unwrap())
375	    }
376
377	    #[test] // [unit->REQ-SEAM-SPAWN]
378	    fn bounded_run_captures_success() {
379	        // Cross-platform no-op that exits 0.
380	        #[cfg(windows)]
381	        let rt = echo_manifest("cmd /C exit 0");
382	        #[cfg(unix)]
383	        let rt = echo_manifest("sh -c 'exit 0'");
384	        let out = rt
385	            .run_bounded("self", &keys(&[]), Duration::from_secs(10))
386	            .expect("bounded run");
387	        assert!(out.success(), "status {:?}", out.status_code);
388	    }
389
390	    #[test] // [unit->REQ-HAZARD-SUBPROCESS-TIMEOUT]
391	    fn bounded_run_kills_on_timeout() {
392	        // A child that sleeps far longer than the timeout must be killed and
393	        // return Timeout — never hang the caller.
394	        #[cfg(windows)]
395	        let rt = echo_manifest("cmd /C ping 127.0.0.1 -n 30");
396	        #[cfg(unix)]
397	        let rt = echo_manifest("sh -c 'sleep 30'");
398	        let start = Instant::now();
399	        let e = rt
400	            .run_bounded("self", &keys(&[]), Duration::from_millis(300))
401	            .unwrap_err();
402	        assert!(matches!(e, RuntimeError::Timeout { .. }), "got {e}");
403	        assert!(
404	            start.elapsed() < Duration::from_secs(5),
405	            "timeout must return promptly, took {:?}",
406	            start.elapsed()
407	        );
408	    }
409
410	    #[test] // [unit->REQ-SEAM-SPAWN]
411	    fn spawn_session_detached_returns_pid() {
412	        #[cfg(windows)]
413	        let rt = echo_manifest("cmd /C exit 0");
414	        #[cfg(unix)]
415	        let rt = echo_manifest("sh -c 'exit 0'");
416	        let spawned = rt.spawn_session("self", &keys(&[])).expect("spawn");
417	        assert!(spawned.pid > 0);
418	    }
419	}
420

//! The outbound contract: turn a parsed [`Manifest`] role template into a real
//! process. Two surfaces:
//!
//! - [`AgentRuntime`] — the **spawn-session seam** M3 reimplements. M2a's
//!   [`ManifestRuntime`] spawns a local OS process; M3's daemon will spawn the
//!   session as a loop/PTY child behind the *same* trait, so callers don't
//!   change (REQ-SEAM-SPAWN).
//! - [`fill_template`] — opaque-template substitution. spt-core never parses a
//!   model/flag out of `command`; it only fills `{key}` placeholders from the
//!   value map spt-core guarantees (MANIFEST §Principle). A placeholder with no
//!   provided value is an error **before** spawn — never a half-built command.
//!
//! ## Subprocess timeout (KNOWN-HAZARDS 5.3, REQ-HAZARD-SUBPROCESS-TIMEOUT)
//! Every *bounded* manifest invocation (a non-detached role, a delegated
//! update, a history fetcher) runs under a mandatory timeout: a hung child is
//! killed and yields [`RuntimeError::Timeout`], never an indefinite block.
//! Detached, long-running sessions (`detach = true`) are fire-and-forget — the
//! timeout governs the *launch handshake*, not the session's lifetime.
//
// [impl->REQ-SEAM-SPAWN]

use crate::manifest::{Manifest, SessionRole};
use std::collections::BTreeMap;
use std::io::Read;
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};

/// Default bound for a bounded invocation when the caller names none. Generous
/// enough for a normalizer/fetcher, finite enough to never hang a supervisor.
pub const DEFAULT_BOUNDED_TIMEOUT: Duration = Duration::from_secs(30);

/// The substitution keys spt-core guarantees it can fill. A role's `keys` must
/// be a subset of this catalog; any `{placeholder}` in a `command` must resolve
/// to a provided value. Documented here so adapter authors know the vocabulary
/// (MANIFEST §Open: "exact substitution-key catalog").
pub const SUBSTITUTION_KEYS: &[&str] = &[
    "id",
    "adapter_name",
    "session_id",
    "session_name",
    "parent_pid",
    "psyche_dir",
    "psyche_prompt",
    "agents_json",
    "link_token",
    "source",
    "agent_type",
];

/// A spawn/template failure. Carries a message for the operator; never panics.
#[derive(Debug)]
pub enum RuntimeError {
    /// A `{placeholder}` in a command had no provided value.
    MissingKey { key: String, command: String },
    /// The filled command was empty (no program token).
    EmptyCommand,
    /// The named role isn't declared in the manifest.
    UnknownRole(String),
    /// The OS refused to spawn the process.
    Spawn(std::io::Error),
    /// A bounded invocation exceeded its timeout and was killed.
    Timeout { command: String, after: Duration },
}

impl std::fmt::Display for RuntimeError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            RuntimeError::MissingKey { key, command } => {
                write!(f, "no value for substitution key {{{key}}} in command: {command}")
            }
            RuntimeError::EmptyCommand => write!(f, "command template produced an empty command"),
            RuntimeError::UnknownRole(r) => write!(f, "manifest declares no [session.{r}] role"),
            RuntimeError::Spawn(e) => write!(f, "failed to spawn session: {e}"),
            RuntimeError::Timeout { command, after } => {
                write!(f, "bounded invocation timed out after {after:?} (killed): {command}")
            }
        }
    }
}

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

/// Fill `{key}` placeholders in an opaque template from `keys`. Unknown
/// placeholders error (listing the missing key); literal text passes through.
/// `{{`/`}}` are not interpreted — command templates use bare `{key}` only.
pub fn fill_template(
    template: &str,
    keys: &BTreeMap<String, String>,
) -> Result<String, RuntimeError> {
    let mut out = String::with_capacity(template.len());
    let mut rest = template;
    while let Some(open) = rest.find('{') {
        out.push_str(&rest[..open]);
        let after = &rest[open + 1..];
        let close = after.find('}').ok_or_else(|| RuntimeError::MissingKey {
            key: after.chars().take(16).collect(),
            command: template.to_string(),
        })?;
        let key = &after[..close];
        let value = keys.get(key).ok_or_else(|| RuntimeError::MissingKey {
            key: key.to_string(),
            command: template.to_string(),
        })?;
        out.push_str(value);
        rest = &after[close + 1..];
    }
    out.push_str(rest);
    Ok(out)
}

/// Split a filled command line into program + args, honoring double quotes so a
/// quoted argument with spaces stays one token. Clean-room (no shell dependency,
/// portable). Single quotes are literal — adapters quote with `"`.
pub fn tokenize(command: &str) -> Vec<String> {
    let mut tokens = Vec::new();
    let mut cur = String::new();
    let mut in_quote = false;
    let mut has_token = false;
    for ch in command.chars() {
        match ch {
            '"' => {
                in_quote = !in_quote;
                has_token = true;
            }
            c if c.is_whitespace() && !in_quote => {
                if has_token {
                    tokens.push(std::mem::take(&mut cur));
                    has_token = false;
                }
            }
            c => {
                cur.push(c);
                has_token = true;
            }
        }
    }
    if has_token {
        tokens.push(cur);
    }
    tokens
}

/// A spawned detached session — its OS pid. The handle is intentionally dropped
/// (fire-and-forget); M3's broker owns the durable handle.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Spawned {
    pub pid: u32,
}

/// Output of a bounded invocation that ran to completion within its timeout.
#[derive(Debug, Clone)]
pub struct BoundedOutput {
    pub status_code: Option<i32>,
    pub stdout: String,
    pub stderr: String,
}

impl BoundedOutput {
    pub fn success(&self) -> bool {
        self.status_code == Some(0)
    }
}

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

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

/// Default [`AgentRuntime`]: substitution-fill a manifest role and spawn it as a
/// local OS process.
pub struct ManifestRuntime {
    manifest: Manifest,
}

impl ManifestRuntime {
    pub fn new(manifest: Manifest) -> Self {
        ManifestRuntime { manifest }
    }

    pub fn manifest(&self) -> &Manifest {
        &self.manifest
    }

    fn role(&self, role: &str) -> Result<&SessionRole, RuntimeError> {
        self.manifest
            .session
            .roles()
            .into_iter()
            .find(|(name, _)| *name == role)
            .map(|(_, r)| r)
            .ok_or_else(|| RuntimeError::UnknownRole(role.to_string()))
    }

    /// Build a `Command` from a role: fill the template, tokenize, apply cwd,
    /// env_remove, and the recursion guard. Shared by both spawn paths.
    fn command_for(
        &self,
        role: &SessionRole,
        keys: &BTreeMap<String, String>,
    ) -> Result<Command, RuntimeError> {
        let filled = fill_template(&role.command, keys)?;
        let tokens = tokenize(&filled);
        let (program, args) = tokens.split_first().ok_or(RuntimeError::EmptyCommand)?;
        let mut cmd = Command::new(program);
        cmd.args(args);
        if let Some(cwd) = &role.cwd {
            cmd.current_dir(fill_template(cwd, keys)?);
        }
        for var in &role.env_remove {
            cmd.env_remove(var);
        }
        if let Some(guard) = &role.recursion_guard_env {
            cmd.env(guard, "1");
        }
        Ok(cmd)
    }
}

impl AgentRuntime for ManifestRuntime {
    fn spawn_session(
        &self,
        role: &str,
        keys: &BTreeMap<String, String>,
    ) -> Result<Spawned, RuntimeError> {
        let role_def = self.role(role)?;
        let mut cmd = self.command_for(role_def, keys)?;
        // Detached: discard stdio, spawn, drop the handle. Best-effort detach
        // for the interim no-daemon model; M3's broker owns true detachment.
        cmd.stdin(Stdio::null())
            .stdout(Stdio::null())
            .stderr(Stdio::null());
        let child = cmd.spawn().map_err(RuntimeError::Spawn)?;
        Ok(Spawned { pid: child.id() })
    }

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

/// Wait for `child` up to `timeout`, draining its pipes on reader threads so a
/// chatty child never deadlocks the poll. On timeout the child is killed and
/// reaped. Uses `Instant` (monotonic) for the deadline.
fn wait_bounded(
    mut child: Child,
    timeout: Duration,
    label: &str,
) -> Result<BoundedOutput, RuntimeError> {
    // Drain stdout/stderr on threads to avoid a full-pipe deadlock while we poll.
    let mut out_pipe = child.stdout.take();
    let mut err_pipe = child.stderr.take();
    let out_handle = std::thread::spawn(move || {
        let mut buf = String::new();
        if let Some(p) = out_pipe.as_mut() {
            let _ = p.read_to_string(&mut buf);
        }
        buf
    });
    let err_handle = std::thread::spawn(move || {
        let mut buf = String::new();
        if let Some(p) = err_pipe.as_mut() {
            let _ = p.read_to_string(&mut buf);
        }
        buf
    });

    let start = Instant::now();
    let status = loop {
        match child.try_wait() {
            Ok(Some(status)) => break status,
            Ok(None) => {
                if start.elapsed() >= timeout {
                    // [impl->REQ-HAZARD-SUBPROCESS-TIMEOUT] hung child → kill + reap,
                    // return Timeout; never block the supervisor indefinitely.
                    let _ = child.kill();
                    let _ = child.wait();
                    return Err(RuntimeError::Timeout {
                        command: label.to_string(),
                        after: timeout,
                    });
                }
                std::thread::sleep(Duration::from_millis(20));
            }
            Err(e) => return Err(RuntimeError::Spawn(e)),
        }
    };

    let stdout = out_handle.join().unwrap_or_default();
    let stderr = err_handle.join().unwrap_or_default();
    Ok(BoundedOutput {
        status_code: status.code(),
        stdout,
        stderr,
    })
}

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

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

    #[test] // [unit->REQ-SEAM-SPAWN]
    fn fills_known_placeholders() {
        let k = keys(&[("session_id", "abc"), ("session_name", "owl")]);
        let out = fill_template("run --id {session_id} --name {session_name}", &k).unwrap();
        assert_eq!(out, "run --id abc --name owl");
    }

    #[test] // [unit->REQ-SEAM-SPAWN]
    fn missing_key_errors_before_spawn() {
        let k = keys(&[("session_id", "abc")]);
        let e = fill_template("run --name {session_name}", &k).unwrap_err();
        match e {
            RuntimeError::MissingKey { key, .. } => assert_eq!(key, "session_name"),
            other => panic!("expected MissingKey, got {other}"),
        }
    }

    #[test] // [unit->REQ-SEAM-SPAWN]
    fn tokenize_honors_double_quotes() {
        let t = tokenize(r#"prog --msg "hello world" --flag"#);
        assert_eq!(t, ["prog", "--msg", "hello world", "--flag"]);
    }

    #[test] // [unit->REQ-SEAM-SPAWN]
    fn unknown_role_errors() {
        let m = Manifest::from_toml_str(
            "[adapter]\nname=\"x\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n",
        )
        .unwrap();
        let rt = ManifestRuntime::new(m);
        let e = rt.spawn_session("self", &keys(&[])).unwrap_err();
        assert!(matches!(e, RuntimeError::UnknownRole(_)));
    }

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

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

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

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


Understand runtime + native store. Check where perch/history.log lives (native strategy reuse).


grep -rn "resolve_perch_path\|fn perch\|history.log\|pub mod perch\|mod perch" crates/spt-store/src/ crates/spt/src/ | head -30

Find perch resolution + history.log home


crates/spt-store/src/info.rs:125:    fn perch() -> tempfile::TempDir {
crates/spt-store/src/lib.rs:16:pub mod perch;
crates/spt-store/src/perch.rs:3://! [`resolve_perch_path`]; nothing composes perch paths by hand. If the layout
crates/spt-store/src/perch.rs:130:pub fn resolve_perch_path(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
crates/spt-store/src/perch.rs:166:    info_file_at(&resolve_perch_path(child_id, hint))
crates/spt-store/src/perch.rs:171:    resolve_perch_path(child_id, hint).join("spool.db")
crates/spt-store/src/perch.rs:176:    resolve_perch_path(child_id, hint).join("ready")
crates/spt-store/src/perch.rs:181:    resolve_perch_path(child_id, hint).join("inbox")
crates/spt-store/src/perch.rs:245:        assert_eq!(resolve_perch_path("dunsen", ParentHint::Infer), owlery.join("dunsen"));
crates/spt-store/src/perch.rs:257:        assert_eq!(resolve_perch_path("dunsen-psyche", ParentHint::Infer), psyche_nested);
crates/spt-store/src/perch.rs:258:        assert_eq!(resolve_perch_path("dunsen-psyche", ParentHint::Explicit("dunsen")), psyche_nested);
crates/spt-store/src/perch.rs:261:        assert_eq!(resolve_perch_path("dunsen-w42", ParentHint::Infer), worker_nested);
crates/spt-store/src/perch.rs:262:        assert_eq!(resolve_perch_path("dunsen-w42", ParentHint::Explicit("dunsen")), worker_nested);
crates/spt-store/src/perch.rs:273:            resolve_perch_path("dunsen-psyche", ParentHint::Explicit("doyle")),
crates/spt-store/src/perch.rs:284:        let root = resolve_perch_path("dunsen-w7", ParentHint::Explicit("dunsen"));
crates/spt-store/src/spool.rs:245:    fn perch() -> tempfile::TempDir {
crates/spt/src/api/auth.rs:79:    let perch_path = perch::resolve_perch_path(target_id, ParentHint::Infer);
crates/spt/src/api/auth.rs:118:        let path = perch::resolve_perch_path(id, ParentHint::Infer);
crates/spt/src/api/delivery.rs:36:    perch::resolve_perch_path(id, ParentHint::Infer).join(name)
crates/spt/src/api/delivery.rs:145:    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
crates/spt/src/api/delivery.rs:167:        let path = perch::resolve_perch_path(id, ParentHint::Infer);
crates/spt/src/api/delivery.rs:214:        let perch_path = perch::resolve_perch_path("alice", ParentHint::Infer);
crates/spt/src/api/mod.rs:239:        ApiCmd::HistoryLog { id, auth } => gated(&id, &auth, reporting::cmd_history_log),
crates/spt/src/api/reporting.rs:2://! `api presence`, `api history-log`, `api emit`, plus the M2a *declarations*
crates/spt/src/api/reporting.rs:13://! - **history-log** (REQ-API-2) — Path-B native store: append a normalized
crates/spt/src/api/reporting.rs:14://!   record to the perch's history log.
crates/spt/src/api/reporting.rs:42:    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
crates/spt/src/api/reporting.rs:84:    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
crates/spt/src/api/reporting.rs:116:    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
crates/spt/src/api/reporting.rs:121:        .open(perch_path.join("history.log"))?;


`perch` is in spt-store (spt-live depends on it). Read perch.rs + the append_history format.


100
101	/// Worker-id rule: rightmost `-w` whose suffix is one-or-more digits, with a
102	/// non-empty name before it.
103	fn is_worker_id(id: &str) -> bool {
104	    if let Some(pos) = id.rfind("-w") {
105	        if pos == 0 {
106	            return false;
107	        }
108	        let suffix = &id[pos + 2..];
109	        !suffix.is_empty() && suffix.bytes().all(|b| b.is_ascii_digit())
110	    } else {
111	        false
112	    }
113	}
114
115	/// How the resolver determines the parent id for nested kinds.
116	/// `Explicit` is preferred (caller knows the parent); `Infer` derives it by
117	/// stripping the kind suffix from the child id (legacy reader call sites).
118	#[derive(Debug, Clone)]
119	pub enum ParentHint<'a> {
120	    Explicit(&'a str),
121	    Infer,
122	}
123
124	/// **The** perch-directory resolver. Self_ → flat `owlery/<id>/`;
125	/// Psyche/Worker → `owlery/<parent>/nested/<child>/`. Panic-free: if `Infer`
126	/// cannot derive a parent (id shape doesn't match its kind — should not happen
127	/// since classify+infer agree), falls back to flat for safety.
128	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
129	// [impl->REQ-HAZARD-WORKER-PATH]
130	pub fn resolve_perch_path(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
131	    let kind = PerchKind::classify(child_id);
132	    if !kind.nests_under_parent() {
133	        return owlery_dir().join(child_id);
134	    }
135	    let parent = match hint {
136	        ParentHint::Explicit(p) => p.to_string(),
137	        ParentHint::Infer => match infer_parent(child_id, kind) {
138	            Some(p) => p,
139	            None => return owlery_dir().join(child_id), // defensive flat fallback
140	        },
141	    };
142	    owlery_dir().join(parent).join("nested").join(child_id)
143	}
144
145	/// Derive the parent id of a nested child by stripping its kind suffix.
146	fn infer_parent(id: &str, kind: PerchKind) -> Option<String> {
147	    match kind {
148	        PerchKind::Psyche => id.strip_suffix("-psyche").map(str::to_string),
149	        PerchKind::Worker => {
150	            let pos = id.rfind("-w")?;
151	            if pos == 0 {
152	                return None;
153	            }
154	            let suffix = &id[pos + 2..];
155	            if suffix.is_empty() || !suffix.bytes().all(|b| b.is_ascii_digit()) {
156	                return None;
157	            }
158	            Some(id[..pos].to_string())
159	        }
160	        PerchKind::Self_ => None,
161	    }
162	}
163
164	/// `<resolved_perch>/info.json`.
165	pub fn resolve_info_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
166	    info_file_at(&resolve_perch_path(child_id, hint))
167	}
168
169	/// `<resolved_perch>/spool.db` — the spool the path-aware spool surface opens.
170	pub fn resolve_spool_db(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
171	    resolve_perch_path(child_id, hint).join("spool.db")
172	}
173
174	/// `<resolved_perch>/ready`.
175	pub fn resolve_ready_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
176	    resolve_perch_path(child_id, hint).join("ready")
177	}
178
179	/// `<resolved_perch>/inbox`.
180	pub fn resolve_inbox_dir(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
181	    resolve_perch_path(child_id, hint).join("inbox")
182	}
183
184	/// `<perch_path>/info.json` from an already-resolved path.
185	pub fn info_file_at(perch_path: &Path) -> PathBuf {
186	    perch_path.join("info.json")
187	}
188
189	#[cfg(test)]

/// Worker-id rule: rightmost `-w` whose suffix is one-or-more digits, with a
/// non-empty name before it.
fn is_worker_id(id: &str) -> bool {
    if let Some(pos) = id.rfind("-w") {
        if pos == 0 {
            return false;
        }
        let suffix = &id[pos + 2..];
        !suffix.is_empty() && suffix.bytes().all(|b| b.is_ascii_digit())
    } else {
        false
    }
}

/// How the resolver determines the parent id for nested kinds.
/// `Explicit` is preferred (caller knows the parent); `Infer` derives it by
/// stripping the kind suffix from the child id (legacy reader call sites).
#[derive(Debug, Clone)]
pub enum ParentHint<'a> {
    Explicit(&'a str),
    Infer,
}

/// **The** perch-directory resolver. Self_ → flat `owlery/<id>/`;
/// Psyche/Worker → `owlery/<parent>/nested/<child>/`. Panic-free: if `Infer`
/// cannot derive a parent (id shape doesn't match its kind — should not happen
/// since classify+infer agree), falls back to flat for safety.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
// [impl->REQ-HAZARD-WORKER-PATH]
pub fn resolve_perch_path(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
    let kind = PerchKind::classify(child_id);
    if !kind.nests_under_parent() {
        return owlery_dir().join(child_id);
    }
    let parent = match hint {
        ParentHint::Explicit(p) => p.to_string(),
        ParentHint::Infer => match infer_parent(child_id, kind) {
            Some(p) => p,
            None => return owlery_dir().join(child_id), // defensive flat fallback
        },
    };
    owlery_dir().join(parent).join("nested").join(child_id)
}

/// Derive the parent id of a nested child by stripping its kind suffix.
fn infer_parent(id: &str, kind: PerchKind) -> Option<String> {
    match kind {
        PerchKind::Psyche => id.strip_suffix("-psyche").map(str::to_string),
        PerchKind::Worker => {
            let pos = id.rfind("-w")?;
            if pos == 0 {
                return None;
            }
            let suffix = &id[pos + 2..];
            if suffix.is_empty() || !suffix.bytes().all(|b| b.is_ascii_digit()) {
                return None;
            }
            Some(id[..pos].to_string())
        }
        PerchKind::Self_ => None,
    }
}

/// `<resolved_perch>/info.json`.
pub fn resolve_info_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
    info_file_at(&resolve_perch_path(child_id, hint))
}

/// `<resolved_perch>/spool.db` — the spool the path-aware spool surface opens.
pub fn resolve_spool_db(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
    resolve_perch_path(child_id, hint).join("spool.db")
}

/// `<resolved_perch>/ready`.
pub fn resolve_ready_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
    resolve_perch_path(child_id, hint).join("ready")
}

/// `<resolved_perch>/inbox`.
pub fn resolve_inbox_dir(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
    resolve_perch_path(child_id, hint).join("inbox")
}

/// `<perch_path>/info.json` from an already-resolved path.
pub fn info_file_at(perch_path: &Path) -> PathBuf {
    perch_path.join("info.json")
}

#[cfg(test)]


108	    eprintln!("PRESENCE:{id}");
109	    0
110	}
111
112	/// Append a normalized record to the perch's Path-B native history store,
113	/// returning bytes written. Separated for unit-testability.
114	pub fn append_history(id: &str, record: &str) -> std::io::Result<usize> {
115	    use std::io::Write;
116	    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
117	    std::fs::create_dir_all(&perch_path)?;
118	    let mut f = std::fs::OpenOptions::new()
119	        .create(true)
120	        .append(true)
121	        .open(perch_path.join("history.log"))?;
122	    let line = if record.ends_with('\n') {
123	        record.to_string()
124	    } else {
125	        format!("{record}\n")
126	    };
127	    f.write_all(line.as_bytes())?;
128	    Ok(line.len())
129	}
130
131	/// `api history-log <id>` — Path-B ingest: read a normalized record from stdin
132	/// and append it to the native store.
133	// [impl->REQ-API-2]
134	pub fn cmd_history_log(id: &str) -> i32 {
135	    use std::io::Read;
136	    let mut record = String::new();
137	    let _ = std::io::stdin().read_to_string(&mut record);
138	    if record.trim().is_empty() {
139	        eprintln!("EMPTY_RECORD: history-log read nothing on stdin");
140	        return EXIT_REFUSED;
141	    }
142	    match append_history(id, record.trim_end()) {
143	        Ok(n) => {
144	            eprintln!("HISTORY_LOGGED:{id}:{n}");
145	            0
146	        }
147	        Err(e) => {

    eprintln!("PRESENCE:{id}");
    0
}

/// Append a normalized record to the perch's Path-B native history store,
/// returning bytes written. Separated for unit-testability.
pub fn append_history(id: &str, record: &str) -> std::io::Result<usize> {
    use std::io::Write;
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    std::fs::create_dir_all(&perch_path)?;
    let mut f = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(perch_path.join("history.log"))?;
    let line = if record.ends_with('\n') {
        record.to_string()
    } else {
        format!("{record}\n")
    };
    f.write_all(line.as_bytes())?;
    Ok(line.len())
}

/// `api history-log <id>` — Path-B ingest: read a normalized record from stdin
/// and append it to the native store.
// [impl->REQ-API-2]
pub fn cmd_history_log(id: &str) -> i32 {
    use std::io::Read;
    let mut record = String::new();
    let _ = std::io::stdin().read_to_string(&mut record);
    if record.trim().is_empty() {
        eprintln!("EMPTY_RECORD: history-log read nothing on stdin");
        return EXIT_REFUSED;
    }
    match append_history(id, record.trim_end()) {
        Ok(n) => {
            eprintln!("HISTORY_LOGGED:{id}:{n}");
            0
        }
        Err(e) => {


T0 — Scaffold spt-live crate

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

T1 — History subsystem

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

T2 — spawn-psyche seam

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

T3 — echo-commune seam

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

T4 — drop-file ingest

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

T5 — pulse heartbeat

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

T6 — signoff + boundary lifecycle

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

T7 — resume-session seam

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

T8 — api listen lifecycle wiring

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

T9 — LiveAgent E2E

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

T10 — Activation sweep

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


Full picture. Check REQ-SEAM-HISTORY registry entry before implementing.


Locate M2b reqs in registry


71:id = "REQ-SEAM-PSYCHE"
75:id = "REQ-SEAM-HISTORY"
87:id = "REQ-SEAM-RESUME"
332:id = "REQ-HAZARD-GRACE-BEFORE-SIGNOFF"
372:id = "REQ-HAZARD-STALE-SIGNOFF-SENTINEL"
376:id = "REQ-HAZARD-ECHO-BEFORE-SIGNOFF"
432:id = "REQ-HAZARD-DROP-FILE-SINGLE-WRITER"
436:id = "REQ-HAZARD-DIRECT-WRITE-PRECEDENCE"


71	id = "REQ-SEAM-PSYCHE"
72	title = "spawn-psyche seam (fresh + resume templates)"
73	required_stages = []
74	[[requirements]]
75	id = "REQ-SEAM-HISTORY"
76	title = "History subsystem (fetcher / locate-normalize / native store)"
77	required_stages = []
78	[[requirements]]
79	id = "REQ-SEAM-ACTIVITY"
80	title = "Activity/idle reported via api sentinels, not PTY quiescence"
81	required_stages = ["impl", "unit"]
82	[[requirements]]
83	id = "REQ-SEAM-INJECT"
84	title = "inject-input methods configurable per activity-state"
85	required_stages = ["impl", "unit"]
86	[[requirements]]
87	id = "REQ-SEAM-RESUME"
88	title = "resume-session seam (fresh-with-preload / continue-existing)"
89	required_stages = []
90	[[requirements]]
91	id = "REQ-SEAM-CAPABILITY"
92	title = "Hostable endpoint-types capability declaration"
93	required_stages = ["impl", "unit"]
94	[[requirements]]
95	id = "REQ-SEAM-UPDATE"

id = "REQ-SEAM-PSYCHE"
title = "spawn-psyche seam (fresh + resume templates)"
required_stages = []
[[requirements]]
id = "REQ-SEAM-HISTORY"
title = "History subsystem (fetcher / locate-normalize / native store)"
required_stages = []
[[requirements]]
id = "REQ-SEAM-ACTIVITY"
title = "Activity/idle reported via api sentinels, not PTY quiescence"
required_stages = ["impl", "unit"]
[[requirements]]
id = "REQ-SEAM-INJECT"
title = "inject-input methods configurable per activity-state"
required_stages = ["impl", "unit"]
[[requirements]]
id = "REQ-SEAM-RESUME"
title = "resume-session seam (fresh-with-preload / continue-existing)"
required_stages = []
[[requirements]]
id = "REQ-SEAM-CAPABILITY"
title = "Hostable endpoint-types capability declaration"
required_stages = ["impl", "unit"]
[[requirements]]
id = "REQ-SEAM-UPDATE"


Implement T1. Mark in_progress, add bounded-command primitive to runtime, then history module.


Updated task #2 status


Edit

use std::io::Read;
use std::process::{Child, Command, Stdio};

use std::io::{Read, Write};
use std::process::{Child, Command, Stdio};


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

use std::io::Read;
use std::process::{Child, Command, Stdio};

use std::io::{Read, Write};
use std::process::{Child, Command, Stdio};


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

/// Run an **arbitrary bounded command template** (not a named `[session.*]`
/// role) to completion under `timeout`, optionally feeding `stdin`. Shares the
/// same kill-on-timeout guarantee as [`AgentRuntime::run_bounded`]
/// (REQ-HAZARD-SUBPROCESS-TIMEOUT) — a hung child is killed, never an indefinite
/// block.
///
/// This is the primitive the **history subsystem** (spt-live, M2b) runs its
/// `fetcher` / `normalize_command` strings through: those are adapter command
/// lines that live *outside* the role set, so [`ManifestRuntime::run_bounded`]
/// (which looks a role up in the manifest) does not apply. `stdin` feeds a
/// `normalize_command` the raw transcript on its standard input; it is written
/// on a thread so a child that reads slowly never deadlocks the poll.
// [impl->REQ-HAZARD-SUBPROCESS-TIMEOUT]
pub fn run_bounded_command(
    template: &str,
    keys: &BTreeMap<String, String>,
    timeout: Duration,
    stdin: Option<&[u8]>,
) -> Result<BoundedOutput, RuntimeError> {
    let filled = fill_template(template, keys)?;
    let tokens = tokenize(&filled);
    let (program, args) = tokens.split_first().ok_or(RuntimeError::EmptyCommand)?;
    let mut cmd = Command::new(program);
    cmd.args(args)
        .stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() })
        .stdout(Stdio::piped())
        .stderr(Stdio::piped());
    let mut child = cmd.spawn().map_err(RuntimeError::Spawn)?;
    if let Some(bytes) = stdin {
        if let Some(mut sink) = child.stdin.take() {
            let owned = bytes.to_vec();
            // Write on a thread + drop the sink (EOF) so the child can finish.
            std::thread::spawn(move || {
                let _ = sink.write_all(&owned);
            });
        }
    }
    wait_bounded(child, timeout, &filled)
}

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


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

/// Run an **arbitrary bounded command template** (not a named `[session.*]`
/// role) to completion under `timeout`, optionally feeding `stdin`. Shares the
/// same kill-on-timeout guarantee as [`AgentRuntime::run_bounded`]
/// (REQ-HAZARD-SUBPROCESS-TIMEOUT) — a hung child is killed, never an indefinite
/// block.
///
/// This is the primitive the **history subsystem** (spt-live, M2b) runs its
/// `fetcher` / `normalize_command` strings through: those are adapter command
/// lines that live *outside* the role set, so [`ManifestRuntime::run_bounded`]
/// (which looks a role up in the manifest) does not apply. `stdin` feeds a
/// `normalize_command` the raw transcript on its standard input; it is written
/// on a thread so a child that reads slowly never deadlocks the poll.
// [impl->REQ-HAZARD-SUBPROCESS-TIMEOUT]
pub fn run_bounded_command(
    template: &str,
    keys: &BTreeMap<String, String>,
    timeout: Duration,
    stdin: Option<&[u8]>,
) -> Result<BoundedOutput, RuntimeError> {
    let filled = fill_template(template, keys)?;
    let tokens = tokenize(&filled);
    let (program, args) = tokens.split_first().ok_or(RuntimeError::EmptyCommand)?;
    let mut cmd = Command::new(program);
    cmd.args(args)
        .stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() })
        .stdout(Stdio::piped())
        .stderr(Stdio::piped());
    let mut child = cmd.spawn().map_err(RuntimeError::Spawn)?;
    if let Some(bytes) = stdin {
        if let Some(mut sink) = child.stdin.take() {
            let owned = bytes.to_vec();
            // Write on a thread + drop the sink (EOF) so the child can finish.
            std::thread::spawn(move || {
                let _ = sink.write_all(&owned);
            });
        }
    }
    wait_bounded(child, timeout, &filled)
}

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

+/// Run an **arbitrary bounded command template** (not a named `[session.*]`

+/// role) to completion under `timeout`, optionally feeding `stdin`. Shares the

+/// same kill-on-timeout guarantee as [`AgentRuntime::run_bounded`]

+/// (REQ-HAZARD-SUBPROCESS-TIMEOUT) — a hung child is killed, never an indefinite

+/// This is the primitive the **history subsystem** (spt-live, M2b) runs its

+/// `fetcher` / `normalize_command` strings through: those are adapter command

+/// lines that live *outside* the role set, so [`ManifestRuntime::run_bounded`]

+/// (which looks a role up in the manifest) does not apply. `stdin` feeds a

+/// `normalize_command` the raw transcript on its standard input; it is written

+/// on a thread so a child that reads slowly never deadlocks the poll.

+) -> Result<BoundedOutput, RuntimeError> {

+    let filled = fill_template(template, keys)?;

+    let (program, args) = tokens.split_first().ok_or(RuntimeError::EmptyCommand)?;

+    let mut cmd = Command::new(program);

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

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

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

+            let owned = bytes.to_vec();

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

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

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

+    wait_bounded(child, timeout, &filled)


pub use runtime::{AgentRuntime, ManifestRuntime, RuntimeError, Spawned};

pub use runtime::{
    run_bounded_command, AgentRuntime, BoundedOutput, ManifestRuntime, RuntimeError, Spawned,
};


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

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


<system-reminder>Warning: the file exists but is shorter than the provided offset (28). The file has 25 lines.</system-reminder>


20	pub mod manifest;
21	pub mod runtime;
22
23	pub use manifest::{Manifest, ManifestError};
24	pub use runtime::{AgentRuntime, ManifestRuntime, RuntimeError, Spawned};
25

pub mod manifest;
pub mod runtime;

pub use manifest::{Manifest, ManifestError};
pub use runtime::{AgentRuntime, ManifestRuntime, RuntimeError, Spawned};


pub use runtime::{AgentRuntime, ManifestRuntime, RuntimeError, Spawned};

pub use runtime::{
    run_bounded_command, AgentRuntime, BoundedOutput, ManifestRuntime, RuntimeError, Spawned,
};


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

pub use runtime::{
    run_bounded_command, AgentRuntime, BoundedOutput, ManifestRuntime, RuntimeError, Spawned,
};

//! spt-core harness contract — outbound half (M2a).
//!
//! `spt-runtime` makes spt-core **drivable by a manifest-described harness** with
//! zero Claude Code (or any harness) conventions in-repo. It is the fourth layer
//! in the acyclic stack (`spt-proto -> spt-store -> spt-msg -> spt-runtime`).
//!
//! Two surfaces land here across M2a:
//! - the **runtime-manifest schema** + parse/validate ([`manifest`], T1), and
//! - the [`AgentRuntime`] trait + default `ManifestRuntime` (opaque-template
//!   substitution + spawn-session seam, T2).
//!
//! The inbound half (the `spt api` subcommand surface) lives in the `spt` binary
//! over this crate. See `M2a-PLAN.md`.
//
// spt-runtime joins spt-proto + spt-msg as the third member of the public,
// semver-committed SDK surface (R-ARCH-2): a harness author programs against
// this crate's Manifest/AgentRuntime types.
// [impl->REQ-ARCH-2]

pub mod manifest;
pub mod runtime;

pub use manifest::{Manifest, ManifestError};
pub use runtime::{AgentRuntime, ManifestRuntime, RuntimeError, Spawned};

-pub use runtime::{AgentRuntime, ManifestRuntime, RuntimeError, Spawned};

+    run_bounded_command, AgentRuntime, BoundedOutput, ManifestRuntime, RuntimeError, Spawned,


Now the history module.


Write

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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


Wire history module into lib + check owlery_dir/home resolution for native test.


Bash

grep -rn "fn owlery_dir\|SPT_HOME\|fn spt_home\|home_dir" crates/spt-store/src/*.rs | head -20

Find home resolution in spt-store


crates/spt-store/src/perch.rs:24:/// Root of all spt-core on-disk state. Honors `$SPT_HOME`, else the platform
crates/spt-store/src/perch.rs:32:/// override (`$SPT_HOME`) is the relocation knob either way.
crates/spt-store/src/perch.rs:34:pub fn spt_home() -> PathBuf {
crates/spt-store/src/perch.rs:35:    if let Ok(over) = std::env::var("SPT_HOME") {
crates/spt-store/src/perch.rs:62:pub fn owlery_dir() -> PathBuf {
crates/spt-store/src/perch.rs:203:            let prev = std::env::var("SPT_HOME").ok();
crates/spt-store/src/perch.rs:204:            std::env::set_var("SPT_HOME", tmp);
crates/spt-store/src/perch.rs:211:                Some(v) => std::env::set_var("SPT_HOME", v),
crates/spt-store/src/perch.rs:212:                None => std::env::remove_var("SPT_HOME"),
crates/spt-store/src/perch.rs:291:    // [unit->REQ-HAZARD-SINGLE-PATH-SOURCE] spt_home is pure: honors SPT_HOME,
crates/spt-store/src/perch.rs:294:    fn spt_home_is_pure_and_honors_env() {
crates/spt-store/src/seed.rs:83:            let prev = std::env::var("SPT_HOME").ok();
crates/spt-store/src/seed.rs:84:            std::env::set_var("SPT_HOME", tmp.path());
crates/spt-store/src/seed.rs:91:                Some(v) => std::env::set_var("SPT_HOME", v),
crates/spt-store/src/seed.rs:92:                None => std::env::remove_var("SPT_HOME"),


T0 — Scaffold spt-live crate

T1 — History subsystem

T2 — spawn-psyche seam

T3 — echo-commune seam

T4 — drop-file ingest

T5 — pulse heartbeat

T6 — signoff + boundary lifecycle

T7 — resume-session seam

T8 — api listen lifecycle wiring

T9 — LiveAgent E2E

T10 — Activation sweep


Wire module + write tests (native needs SPT_HOME isolation lock per the known env-race gotcha).


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

//
// 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.


Edit

//
// 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.

//
// The interim no-daemon model holds (decision 2026-06-01): the Psyche is a real
// subprocess and the `api listen` process hosts the pulse/ingest loops. M3 moves
// both into the daemon. See `M2b-PLAN.md` §Interim-model note.

pub mod history;

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


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

//
// The interim no-daemon model holds (decision 2026-06-01): the Psyche is a real
// subprocess and the `api listen` process hosts the pulse/ingest loops. M3 moves
// both into the daemon. See `M2b-PLAN.md` §Interim-model note.

pub mod history;

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

//! 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 use history::{fetch_history, HistoryError, HistoryRecord};


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

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

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

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

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

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

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

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

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

    #[test] // [unit->REQ-SEAM-HISTORY] native Path-B store: read the perch's history.log.
    fn native_reads_logged_records() {
        with_home(|_| {
            let perch = resolve_perch_path("alice", ParentHint::Infer);
            std::fs::create_dir_all(&perch).unwrap();
            std::fs::write(perch.join("history.log"), "{\"r\":\"u\"}\n{\"r\":\"a\"}\n").unwrap();
            let h = History {
                strategy: HistoryStrategy::Native,
                fetcher: None,
                locate_template: None,
                normalize_command: None,
            };
            let recs =
                fetch_history(&h, &no_keys(), "alice", ParentHint::Infer, Duration::from_secs(5))
                    .expect("fetch");
            assert_eq!(recs.len(), 2);
            assert_eq!(recs[0].raw, "{\"r\":\"u\"}");
        });
    }

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

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

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