<!-- [doc->REQ-MANIFEST-1] The sections below are the authoritative schema; the
typed form in `crates/spt-runtime/src/manifest.rs` is kept in lockstep. -->

## Manifest sections


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

<!-- [doc->REQ-MANIFEST-1] The sections below are the authoritative schema; the
typed form in `crates/spt-runtime/src/manifest.rs` is kept in lockstep. -->

## Manifest sections

- **Cross-adapter fallback** (psyche/echo invocation falls back to another adapter on rate-limit) — a **node-wide setting**, not a manifest field, because it references *multiple* adapters (under spt-core, `ccs` is its own adapter, not a binary-swap within one). Adapter-agnostic IDs make this safe — an endpoint's Psyche can run under a fallback adapter temporarily.

- **Plugin-cache / version-trampoline layout** — none of SPT's concern. A CC-plugin adapter updates via `claude plugin update` (delegated command).

+<!-- [doc->REQ-MANIFEST-1] The sections below are the authoritative schema; the

+typed form in `crates/spt-runtime/src/manifest.rs` is kept in lockstep. -->


55	required_stages = []
56
57	# ───────────────────────────── Harness contract — manifest seams ────────────
58	[[requirements]]
59	id = "REQ-MANIFEST-1"
60	title = "Per-adapter manifest with adapter_name and min_spt_core_version"
61	required_stages = []
62	[[requirements]]
63	id = "REQ-SEAM-SPAWN"
64	title = "spawn-session seam"

required_stages = []

# ───────────────────────────── Harness contract — manifest seams ────────────
[[requirements]]
id = "REQ-MANIFEST-1"
title = "Per-adapter manifest with adapter_name and min_spt_core_version"
required_stages = []
[[requirements]]
id = "REQ-SEAM-SPAWN"
title = "spawn-session seam"


T0: Scaffold spt-runtime crate

New crate (members + deps spt-proto/store/msg, serde, toml); activation-prep. Compiles; traceable-reqs check green.

T1: Manifest schema + parse + validate

Typed structs for every docs/MANIFEST.md section + parse + validate (helpful errors). REQ-MANIFEST-1.

T2: AgentRuntime trait + ManifestRuntime

Substitution-key catalog + opaque-template fill + spawn-session seam (timeout). REQ-SEAM-SPAWN, REQ-HAZARD-SUBPROCESS-TIMEOUT.

T3: spt api dispatch group + local-api-auth

--manifest/--adapter resolution, adapter_name on every call, exit codes, local-api-auth guard. REQ-API-1, REQ-HAZARD-LOCAL-API-AUTH.

T4: Harness-hosted startup (seed/listen/bind)

api seed (interim seed-file by parent_pid) + api listen (consume seed, validate session_id, bind skeleton->live, relay loop) + api bind. REQ-START-1/2, REQ-SEAM-POSTSPAWN, REQ-API-2, REQ-HAZARD-WINDOWS-PID-RECYCLE.

T5: Delivery + activity (state/poll/inject)

api state busy|idle (+echo-gate sentinel, --no-gate, echo-gate set/clear) + api poll [--include-deferred] + inject-method selection. REQ-SEAM-ACTIVITY, REQ-SEAM-INJECT, REQ-API-2.

T6: Worker perches + cascade-wipe guard

api worker-start/worker-stop/worker-poll + cascade-wipe guard. REQ-API-2, REQ-HAZARD-CASCADE-WIPE-GUARD.

T7: Reporting + boundary surface

api boundary clear|compact + session-end (--erase) + presence + history-log + emit stub + capability hostable_types + env-alias supply. REQ-API-2/3, REQ-START-4, REQ-SEAM-CAPABILITY, REQ-HAZARD-SOFT-CLEANUP.

T8: Generic mock adapter

adapters/mock/manifest.toml + cross-platform session scripts/helper bin; zero CC conventions. REQ-MANIFEST-1, REQ-API-3.

T9: Contract E2E test

load mock manifest -> ManifestRuntime spawns session -> api bind -> api state idle -> api poll delivers spooled message. REQ-API-1/2 int, REQ-START-2 int.

T10: Activation sweep

Activate M2a reqs + hazards + REQ-ARCH-2, green check, CI green, amend ROADMAP/CONTEXT for split.


traceable-reqs edit needs read first — now apply.


Edit

id = "REQ-MANIFEST-1"
title = "Per-adapter manifest with adapter_name and min_spt_core_version"
required_stages = []

id = "REQ-MANIFEST-1"
title = "Per-adapter manifest with adapter_name and min_spt_core_version"
required_stages = ["doc", "impl", "unit"]


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

id = "REQ-MANIFEST-1"
title = "Per-adapter manifest with adapter_name and min_spt_core_version"
required_stages = []

id = "REQ-MANIFEST-1"
title = "Per-adapter manifest with adapter_name and min_spt_core_version"
required_stages = ["doc", "impl", "unit"]

+required_stages = ["doc", "impl", "unit"]


Bash

traceable-reqs check 2>&1 | grep -i "MANIFEST-1\|error\|FAIL\|missing" | head; echo "exit:"; traceable-reqs check >/dev/null 2>&1; echo $?

Verify REQ-MANIFEST-1 coverage


       Stale registry entries degrade to fallback, never hard-fail (4.3)
  [OK] REQ-MANIFEST-1  required: [doc, impl, unit]  stages: +doc +impl +unit -int
exit:
0


REQ-MANIFEST-1 covered (doc+impl+unit), check green. Commit T1.


git add -A && git commit -q -F - <<'EOF'
feat(spt-runtime): M2a T1 — runtime-manifest schema + parse + validate

Typed structs for every docs/MANIFEST.md section ([adapter], [hooks.*],
[session.<role>] + watched dirs, [env], [history], [inject], [identity],
[update], and the [shell] body). Closed vocabularies (adapter kind, history
strategy, env direction, inject method, identity source, update avenue, shell
broadcast/receipt) are enums so an unknown variant is a parse error naming the
bad value — the "helpful errors" open sub-item.

Manifest::from_toml_str = deserialize + validate; never panics on adapter
input. validate() enforces the cross-field invariants the type system can't:
kind<->[shell] agreement, history strategy<->required fields, inject-env needs
a value, update avenue<->required fields. to_toml_string round-trips (section
structs ordered scalars-before-tables).

REQ-MANIFEST-1 activated [doc, impl, unit]; doc tag pins the MANIFEST.md schema
section as the contract (DOCS-STRATEGY struct<->doc lockstep). 10 unit tests
(both fixtures, round-trip, 6 error paths). traceable-reqs check green.

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

Commit T1


warning: in the working copy of 'crates/spt-runtime/src/lib.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'docs/MANIFEST.md', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'traceable-reqs.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-runtime/src/manifest.rs', LF will be replaced by CRLF the next time Git touches it
ae23376 feat(spt-runtime): M2a T1 — runtime-manifest schema + parse + validate


T1 done. T2 next — runtime seam. Check the subprocess-timeout hazard + existing spawn patterns.


Updated task #2 status


Updated task #3 status


grep -n "SUBPROCESS-TIMEOUT\|SUBPROCESS_TIMEOUT" -A4 traceable-reqs.toml docs/KNOWN-HAZARDS.md 2>/dev/null | head -30

Inspect subprocess-timeout hazard


traceable-reqs.toml:412:id = "REQ-HAZARD-SUBPROCESS-TIMEOUT"
traceable-reqs.toml-413-title = "Every harness/git subprocess has a timeout (5.3)"
traceable-reqs.toml-414-required_stages = []
traceable-reqs.toml-415-[[requirements]]
traceable-reqs.toml-416-id = "REQ-HAZARD-UNC-PATH-STRIP"


grep -n "5.3\|subprocess\|timeout" -i docs/KNOWN-HAZARDS.md | head -20

Find hazard 5.3 text


31:- **Invariant:** startup drain (and idle/timeout TCP-wake sites) use `drain_non_deferred` only; deferred rows are picked up by their intended consumer via `peek`. All drain sites must agree on which rows they flush.
70:- **Failure:** the sister evaluates Psyche/perch liveness via a dedicated process PID — the wrapper's own pid in `info.json`, checked with `is_process_alive`. Under ADR-0004 the Psyche (and any spt-hosted Self) is a **loop inside the daemon**, not a separate process: it holds no dedicated pid, and its `claude`/summarizer subprocess is ephemeral (spawned per pulse/commune, then exits). If a daemon-hosted perch's `info.json` carries the **daemon's** pid, then *every* hosted endpoint shares one pid, and `is_process_alive(pid)` reads "alive" for a torn-down endpoint as long as the daemon runs — while `clean_stale_entries` (dead-pid deletion) can no longer distinguish a dead endpoint from a live one. The 2.1/5.1 liveness models do **not** cover this third category: the Psyche is neither harness-hosted (no `parent_pid` anchor) nor a broker-held PTY child.
80:- **Failure:** `ring` creates an ephemeral perch; early-exit paths (no-perch, empty-msg, timeout) skip cleanup → stale dirs accumulate.
146:### 5.3 Git/subprocess timeout stamping
147:- **Failure:** a hung subprocess (git on slow net) blocks the supervisor indefinitely.
148:- **Invariant:** every metadata-producing subprocess has a timeout; timeout yields `None` + rate-limited stderr, never a hang.
149:- **spt-core mapping:** generalize to all manifest-declared harness invocations (delegated commands, adapter updates) — timeouts mandatory.
200:## 7. Boundary & delivery integrity (added 2026-05-31 — Stage A red-team)
233:| 5.3 | Timeout every harness subprocess | manifest invocations |


grep -rn "Command::new\|Stdio\|process::\|wait_timeout\|spawn(" crates/spt-store/src/proc.rs crates/spt/src 2>/dev/null | head -20; echo "---proc.rs head---"; head -30 crates/spt-store/src/proc.rs

Find existing process-spawn helpers


crates/spt-store/src/proc.rs:43:        assert!(is_process_alive(std::process::id()));
crates/spt/src/main.rs:14:    std::process::exit(code);
---proc.rs head---
//! Process-liveness probe. Used by the registry to detect stale entries
//! (dead-PID rows) before resolution. Ported from sister `types.rs`
//! `is_process_alive` (ADR-0001): Windows `OpenProcess` (tasklist hangs on
//! Win11 26200+), Unix `kill(pid, 0)`.

/// Whether a process with `pid` is currently alive. Best-effort and
/// side-effect-free — sends no signal, opens then immediately closes a handle.
// [impl->REQ-HAZARD-REGISTRY-STALE-CLEAN]
pub fn is_process_alive(pid: u32) -> bool {
    #[cfg(unix)]
    {
        // kill(pid, 0) probes existence without delivering a signal.
        unsafe { libc::kill(pid as i32, 0) == 0 }
    }
    #[cfg(windows)]
    {
        // OpenProcess with the most limited query right; tasklist/WMIC hang on
        // recent Windows builds.
        extern "system" {
            fn OpenProcess(dw_desired_access: u32, b_inherit_handle: i32, dw_process_id: u32) -> isize;
            fn CloseHandle(h_object: isize) -> i32;
        }
        const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
        let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
        if handle == 0 {
            false
        } else {
            unsafe {
                CloseHandle(handle);
            }


Hazard 5.3 = mandatory timeout on bounded harness invocations. No existing spawn helper. Write runtime seam.


Write

//! 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] = &[
    "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 {
                    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);
    }
}


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

//! 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] = &[
    "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 {
                    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);
    }
}


Edit

pub mod manifest;

pub use manifest::{Manifest, ManifestError};

pub mod manifest;
pub mod runtime;

pub use manifest::{Manifest, ManifestError};
pub use runtime::{AgentRuntime, 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 mod manifest;
pub mod runtime;

pub use manifest::{Manifest, ManifestError};
pub use runtime::{AgentRuntime, 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`.

pub mod manifest;

pub use manifest::{Manifest, ManifestError};

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


Bash

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

Run runtime tests


    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.19s
     Running unittests src\lib.rs (target\debug\deps\spt_runtime-f434f1e7c3b45576.exe)

running 17 tests
test runtime::tests::fills_known_placeholders ... ok
test runtime::tests::missing_key_errors_before_spawn ... ok
test runtime::tests::tokenize_honors_double_quotes ... ok
test manifest::tests::syntactically_broken_toml_is_an_error_not_a_panic ... ok
test manifest::tests::minimal_header_only_manifest_is_valid ... ok
test runtime::tests::unknown_role_errors ... ok
test manifest::tests::fetcher_strategy_requires_fetcher_field ... ok
test manifest::tests::harness_kind_with_shell_section_fails_validation ... ok
test manifest::tests::shell_kind_without_shell_section_fails_validation ... ok
test manifest::tests::inject_env_without_value_fails_validation ... ok
test manifest::tests::unknown_enum_variant_is_an_error_not_a_panic ... ok
test manifest::tests::shell_fixture_parses ... ok
test manifest::tests::harness_fixture_parses_all_sections ... ok
test manifest::tests::round_trips_through_toml ... ok
test runtime::tests::spawn_session_detached_returns_pid ... ok
test runtime::tests::bounded_run_captures_success ... ok
test runtime::tests::bounded_run_kills_on_timeout ... ok

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

   Doc-tests spt_runtime

running 0 tests

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


17/17 green incl. timeout-kill. Activate REQ-SEAM-SPAWN + REQ-HAZARD-SUBPROCESS-TIMEOUT.


62	[[requirements]]
63	id = "REQ-SEAM-SPAWN"
64	title = "spawn-session seam"
65	required_stages = []

[[requirements]]
id = "REQ-SEAM-SPAWN"
title = "spawn-session seam"
required_stages = []


411	[[requirements]]
412	id = "REQ-HAZARD-SUBPROCESS-TIMEOUT"
413	title = "Every harness/git subprocess has a timeout (5.3)"
414	required_stages = []

[[requirements]]
id = "REQ-HAZARD-SUBPROCESS-TIMEOUT"
title = "Every harness/git subprocess has a timeout (5.3)"
required_stages = []


T0: Scaffold spt-runtime crate

T1: Manifest schema + parse + validate

T6: Worker perches + cascade-wipe guard

T7: Reporting + boundary surface

T8: Generic mock adapter

T9: Contract E2E test

T10: Activation sweep


Edit

id = "REQ-SEAM-SPAWN"
title = "spawn-session seam"
required_stages = []

id = "REQ-SEAM-SPAWN"
title = "spawn-session seam"
required_stages = ["impl", "unit"]


id = "REQ-SEAM-SPAWN"
title = "spawn-session seam"
required_stages = []

id = "REQ-SEAM-SPAWN"
title = "spawn-session seam"
required_stages = ["impl", "unit"]

title = "post-spawn / api bind seam with boot nonce"


id = "REQ-HAZARD-SUBPROCESS-TIMEOUT"
title = "Every harness/git subprocess has a timeout (5.3)"
required_stages = []

id = "REQ-HAZARD-SUBPROCESS-TIMEOUT"
title = "Every harness/git subprocess has a timeout (5.3)"
required_stages = ["impl", "unit"]


id = "REQ-HAZARD-SUBPROCESS-TIMEOUT"
title = "Every harness/git subprocess has a timeout (5.3)"
required_stages = []

id = "REQ-HAZARD-SUBPROCESS-TIMEOUT"
title = "Every harness/git subprocess has a timeout (5.3)"
required_stages = ["impl", "unit"]

title = "Strip Windows UNC prefix on serialized paths (5.4)"


Add the impl-stage tag for the timeout hazard near the enforcement code.


                if start.elapsed() >= timeout {
                    let _ = child.kill();
                    let _ = child.wait();
                    return Err(RuntimeError::Timeout {

                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 {


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)

                if start.elapsed() >= timeout {
                    let _ = child.kill();
                    let _ = child.wait();
                    return Err(RuntimeError::Timeout {

                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 {

+                    // [impl->REQ-HAZARD-SUBPROCESS-TIMEOUT] hung child → kill + reap,

+                    // return Timeout; never block the supervisor indefinitely.


Bash

cargo build -p spt-runtime 2>&1 | tail -2; traceable-reqs check 2>&1 | grep -i "SEAM-SPAWN\|SUBPROCESS-TIMEOUT"; traceable-reqs check >/dev/null 2>&1; echo "exit:$?"

Verify build + coverage


   Compiling spt-runtime v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-runtime)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.46s
  [OK] REQ-HAZARD-SUBPROCESS-TIMEOUT  required: [impl, unit]  stages: -doc +impl +unit -int
  [OK] REQ-SEAM-SPAWN  required: [impl, unit]  stages: -doc +impl +unit -int
exit:0


Both covered. Full workspace test then commit T2.


cargo test --workspace 2>&1 | grep -E "test result|error\[|warning: unused" | head -20

Workspace test run


test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 22 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.11s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.20s
test result: ok. 43 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
test result: ok. 17 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.32s
test result: ok. 30 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.25s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s


cd "C:\Users\decid\Documents\projects\spt-core" && git add -A && git commit -q -F - <<'EOF'
feat(spt-runtime): M2a T2 — AgentRuntime seam + ManifestRuntime

The outbound half of the contract: turn a parsed role template into a process.

- AgentRuntime trait = the spawn-session seam M3 reimplements (daemon loop/PTY
  child) behind the same signature, so callers don't change.
- ManifestRuntime: opaque-template fill (fill_template — {key} substitution,
  missing key errors BEFORE spawn, never a half-built command) + quote-aware
  tokenizer (no shell dependency) + spawn. spawn_session = detached
  fire-and-forget (returns pid); run_bounded = metadata invocation run to
  completion under a mandatory timeout.
- SUBSTITUTION_KEYS catalog documents the guaranteed vocabulary.

KNOWN-HAZARDS 5.3: wait_bounded drains pipes on reader threads (no full-pipe
deadlock), polls try_wait against a monotonic deadline, kills+reaps on timeout
→ RuntimeError::Timeout, never hangs. Detach = best-effort interim; M3 broker
owns true detachment.

REQ-SEAM-SPAWN + REQ-HAZARD-SUBPROCESS-TIMEOUT activated [impl, unit]. 13 unit
tests incl. a real timeout-kill that returns < 5s. cargo test --workspace green;
traceable-reqs check green.

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

Commit T2


warning: in the working copy of 'crates/spt-runtime/src/lib.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'traceable-reqs.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-runtime/src/runtime.rs', LF will be replaced by CRLF the next time Git touches it
fecd6d2 feat(spt-runtime): M2a T2 — AgentRuntime seam + ManifestRuntime


T2 done — `spt-runtime` outbound half complete. Now the inbound `api` surface (T3+). Examine `spt` binary CLI.


Updated task #3 status


Updated task #4 status


cd "C:\Users\decid\Documents\projects\spt-core" && ls crates/spt/src/ && echo "===CARGO==="&& cat crates/spt/Cargo.toml && echo "===MAIN==="&& cat crates/spt/src/main.rs

Inspect spt binary layout


cli.rs
main.rs
roster.rs
===CARGO===
[package]
name = "spt"
version = "0.0.0"
edition = "2021"
publish = false
description = "spt-core CLI: the single `spt` binary. Thin command layer over spt-msg (send/ring/ready/poll/list/stop/whoami)."

[[bin]]
name = "spt"
path = "src/main.rs"

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
clap = { version = "4", features = ["derive"] }
===MAIN===
//! `spt` — the single spt-core binary. A **thin** command layer over `spt-msg`:
//! it owns I/O (stdin/stdout/stderr) and process exit codes; all messaging
//! logic lives in the crate (clean-room separation per the M1 plan).
//!
//! Subcommands (REQ-MSG-2 stable surface): `send` · `ring` · `ready` · `poll` ·
//! `list` · `stop` · `whoami`.
// [impl->REQ-MSG-2]

mod cli;
mod roster;

fn main() {
    let code = cli::run();
    std::process::exit(code);
}


1	//! CLI surface + dispatch for the `spt` binary (REQ-MSG-2).
2	//!
3	//! Stable arg shapes and exit codes are the contract here. Each subcommand is a
4	//! thin shim: read I/O, call the matching `spt-msg` entry point, render the
5	//! structured outcome, return an exit code. No messaging logic lives in this
6	//! layer.
7	//!
8	//! Exit codes: `0` success (delivered, queued, replied, listed) · `1` runtime
9	//! refusal (no perch, empty body, unidentifiable self) · `2` usage error
10	//! (clap, for unknown/missing args).
11	// [impl->REQ-MSG-2]
12
13	use std::io::{Read, Write};
14	use std::time::Duration;
15
16	use clap::{Parser, Subcommand};
17	use spt_msg::deliver::{self, SendOutcome};
18	use spt_msg::ready::ReadyAgent;
19	use spt_msg::ring::{self, RingOutcome};
20	use spt_store::perch::{self, ParentHint};
21	use spt_store::registry;
22
23	use crate::roster;
24
25	#[derive(Parser)]
26	#[command(name = "spt", version, about = "spt-core local messaging", long_about = None)]
27	struct Cli {
28	    #[command(subcommand)]
29	    cmd: Cmd,
30	}
31
32	#[derive(Subcommand)]
33	enum Cmd {
34	    /// Send a message (body read from stdin). Fire-and-forget.
35	    Send {
36	        /// Target perch id. May be omitted when --reply-to is given.
37	        target: Option<String>,
38	        /// Sender id written into __REPLY_TO__ (auto-detected from session if omitted).
39	        #[arg(long)]
40	        from: Option<String>,
41	        /// Reply to this sender; sets the target and labels output REPLIED.
42	        #[arg(long)]
43	        reply_to: Option<String>,
44	        /// Spool only (deferred / hook channel) — no live wake.
45	        #[arg(long)]
46	        deferred: bool,
47	    },
48	    /// Send and block for a reply (body read from stdin; reply printed to stdout).
49	    Ring {
50	        /// Target perch id.
51	        target: String,
52	        /// Sender id (auto-detected from session if omitted).
53	        #[arg(long)]
54	        from: Option<String>,
55	        /// Seconds to wait for a reply before giving up.
56	        #[arg(long, default_value = "60")]
57	        timeout: u64,
58	    },
59	    /// Become reachable: register the perch and listen for messages (blocks).
60	    Ready {
61	        /// This agent's perch id.
62	        id: String,
63	    },
64	    /// Receive messages: drain backlog then listen, printing each to stdout.
65	    Poll {
66	        /// This agent's perch id.
67	        id: String,
68	        /// Run a single drain+receive cycle, then exit (legacy one-shot).
69	        #[arg(long)]
70	        once: bool,
71	    },
72	    /// List perches under the owlery (id, state, address, ready, alive).
73	    List,
74	    /// Soft-stop a perch: remove the ready marker + unregister (spool preserved).
75	    Stop {
76	        /// Perch id to stop.
77	        id: String,
78	    },
79	    /// Print this session's own perch id (from $OWL_SESSION_ID / $SPT_AGENT_ID).
80	    Whoami,
81	}
82
83	/// Parse args and dispatch. Returns the process exit code.
84	pub fn run() -> i32 {
85	    let cli = Cli::parse();
86	    match cli.cmd {
87	        Cmd::Send { target, from, reply_to, deferred } => cmd_send(target, from, reply_to, deferred),
88	        Cmd::Ring { target, from, timeout } => cmd_ring(target, from, timeout),
89	        Cmd::Ready { id } => run_listen(&id, false),
90	        Cmd::Poll { id, once } => run_listen(&id, once),
91	        Cmd::List => cmd_list(),
92	        Cmd::Stop { id } => cmd_stop(&id),
93	        Cmd::Whoami => cmd_whoami(),
94	    }
95	}
96
97	/// Read stdin to a string (message body source for send/ring).
98	fn read_stdin() -> String {
99	    let mut body = String::new();
100	    let _ = std::io::stdin().read_to_string(&mut body);
101	    body
102	}
103
104	/// Resolve the sender id: explicit `--from`, else session auto-detect, else "".
105	fn resolve_from(from: Option<String>) -> String {
106	    from.or_else(roster::detect_self_id).unwrap_or_default()
107	}
108
109	fn cmd_send(target: Option<String>, from: Option<String>, reply_to: Option<String>, deferred: bool) -> i32 {
110	    let is_reply = reply_to.is_some();
111	    // --reply-to provides the target when no positional target is given.
112	    let target = match target.or_else(|| reply_to.clone()) {
113	        Some(t) => t,
114	        None => {
115	            eprintln!("USAGE: a target (or --reply-to <sender>) is required");
116	            return 2;
117	        }
118	    };
119	    let from = resolve_from(from);
120	    let body = read_stdin();
121	    let owlery = perch::owlery_dir();
122
123	    let outcome = if deferred {
124	        deliver::send_deferred(&target, &from, &body)
125	    } else {
126	        deliver::send(&target, &from, &body, &owlery)
127	    };
128
129	    match outcome {
130	        SendOutcome::Sent => {
131	            eprintln!("{}:{}", if is_reply { "REPLIED" } else { "SENT" }, target);
132	            0
133	        }
134	        SendOutcome::Queued => {
135	            let label = if deferred {
136	                "DEFERRED"
137	            } else if is_reply {
138	                "REPLIED(QUEUED)"
139	            } else {
140	                "QUEUED"
141	            };
142	            eprintln!("{}:{}", label, target);
143	            0
144	        }
145	        SendOutcome::NoPerch => {
146	            eprintln!("NO_PERCH:{} is not listening", target);
147	            1
148	        }
149	        SendOutcome::Empty => {
150	            eprintln!("EMPTY_MSG: refusing to send empty message to {}", target);
151	            1
152	        }
153	    }
154	}
155
156	fn cmd_ring(target: String, from: Option<String>, timeout: u64) -> i32 {
157	    let from = resolve_from(from);
158	    if from.is_empty() {
159	        eprintln!("NO_SELF: ring needs a sender id (--from <id> or $OWL_SESSION_ID)");
160	        return 1;
161	    }
162	    let body = read_stdin();
163	    let owlery = perch::owlery_dir();
164
165	    match ring::ring(&target, &from, &body, Duration::from_secs(timeout), &owlery) {
166	        RingOutcome::Replied(reply) => {
167	            print!("{}", reply);
168	            let _ = std::io::stdout().flush();
169	            0
170	        }
171	        RingOutcome::TimedOut => {
172	            eprintln!("TIMEOUT:{} did not reply within {}s", target, timeout);
173	            0
174	        }
175	        RingOutcome::Queued => {
176	            eprintln!("QUEUED:{}", target);
177	            0
178	        }
179	        RingOutcome::Delivered => {
180	            eprintln!("SENT:{} (reply will arrive on your listener)", target);
181	            0
182	        }
183	        RingOutcome::NoPerch => {
184	            eprintln!("NO_PERCH:{} is not listening", target);
185	            1
186	        }
187	        RingOutcome::Empty => {
188	            eprintln!("EMPTY_MSG: refusing to ring {} with an empty message", target);
189	            1
190	        }
191	    }
192	}
193
194	/// Run the listener loop (REQ-MSG-3 lifecycle, driven from the CLI). Blocks
195	/// until the process is killed unless `once` is set. Each received message is
196	/// printed framed to stdout; status goes to stderr.
197	///
198	/// Interim M1 model: the perch is reachable only while this process runs. M3's
199	/// spt-daemon decouples registration from the live process.
200	fn run_listen(id: &str, once: bool) -> i32 {
201	    let (agent, backlog) = match ReadyAgent::start(id) {
202	        Ok(pair) => pair,
203	        Err(e) => {
204	            eprintln!("READY_FAIL:{}: {}", id, e);
205	            return 1;
206	        }
207	    };
208	    eprintln!("READY:{}", id);
209	    for msg in backlog {
210	        emit(&msg);
211	    }
212
213	    if once {
214	        if let Some(msg) = agent.recv() {
215	            emit(&msg);
216	        }
217	        for msg in agent.drain_spool() {
218	            emit(&msg);
219	        }
220	        return 0;
221	    }
222
223	    loop {
224	        if let Some(msg) = agent.recv() {
225	            emit(&msg);
226	        }
227	        for msg in agent.drain_spool() {
228	            emit(&msg);
229	        }
230	    }
231	}
232
233	/// Print a framed message to stdout and flush (one delivery per emit).
234	fn emit(msg: &str) {
235	    print!("{}", msg);
236	    let _ = std::io::stdout().flush();
237	}
238
239	fn cmd_list() -> i32 {
240	    let perches = roster::enumerate();
241	    if perches.is_empty() {
242	        eprintln!("(no perches)");
243	        return 0;
244	    }
245	    for p in perches {
246	        println!(
247	            "{:<28} {:<14} {:<22} ready={} alive={}",
248	            p.id,
249	            p.state,
250	            p.address.as_deref().unwrap_or("-"),
251	            p.ready,
252	            p.alive
253	        );
254	    }
255	    0
256	}
257
258	fn cmd_stop(id: &str) -> i32 {
259	    let owlery = perch::owlery_dir();
260	    // Soft-cleanup (HAZARD-SOFT-CLEANUP 6.2): remove the ready marker +
261	    // unregister the address; spool.db and info.json are preserved.
262	    let removed = std::fs::remove_file(perch::resolve_ready_file(id, ParentHint::Infer)).is_ok();
263	    let _ = registry::unregister_address(id, &owlery);
264	    if removed {
265	        eprintln!("STOPPED:{}", id);
266	    } else {
267	        eprintln!("STOPPED:{} (no ready marker; address unregistered)", id);
268	    }
269	    0
270	}
271
272	fn cmd_whoami() -> i32 {
273	    match roster::detect_self_id() {
274	        Some(id) => {
275	            println!("{}", id);
276	            0
277	        }
278	        None => {
279	            eprintln!("UNKNOWN: no perch matches this session ($OWL_SESSION_ID / $SPT_AGENT_ID)");
280	            1
281	        }
282	    }
283	}
284
285	#[cfg(test)]
286	mod tests {
287	    use super::*;
288
289	    fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
290	        Cli::try_parse_from(args)
291	    }
292
293	    // [unit->REQ-MSG-2] the documented subcommands + arg shapes parse.
294	    #[test]
295	    fn subcommands_parse() {
296	        assert!(matches!(parse(&["spt", "send", "bob", "--from", "alice"]).unwrap().cmd, Cmd::Send { .. }));
297	        assert!(matches!(parse(&["spt", "send", "bob", "--deferred"]).unwrap().cmd, Cmd::Send { deferred: true, .. }));
298	        assert!(matches!(parse(&["spt", "ring", "bob", "--timeout", "5"]).unwrap().cmd, Cmd::Ring { .. }));
299	        assert!(matches!(parse(&["spt", "ready", "bob"]).unwrap().cmd, Cmd::Ready { .. }));
300	        assert!(matches!(parse(&["spt", "poll", "bob", "--once"]).unwrap().cmd, Cmd::Poll { once: true, .. }));
301	        assert!(matches!(parse(&["spt", "list"]).unwrap().cmd, Cmd::List));
302	        assert!(matches!(parse(&["spt", "stop", "bob"]).unwrap().cmd, Cmd::Stop { .. }));
303	        assert!(matches!(parse(&["spt", "whoami"]).unwrap().cmd, Cmd::Whoami));
304	    }
305
306	    // [unit->REQ-MSG-2] ring timeout defaults to 60s when unspecified.
307	    #[test]
308	    fn ring_timeout_defaults() {
309	        match parse(&["spt", "ring", "bob"]).unwrap().cmd {
310	            Cmd::Ring { timeout, .. } => assert_eq!(timeout, 60),
311	            _ => panic!("expected Ring"),
312	        }
313	    }
314
315	    // [unit->REQ-MSG-2] send target is optional (supplied via --reply-to instead).
316	    #[test]
317	    fn send_target_optional_with_reply_to() {
318	        match parse(&["spt", "send", "--reply-to", "alice"]).unwrap().cmd {
319	            Cmd::Send { target, reply_to, .. } => {
320	                assert!(target.is_none());
321	                assert_eq!(reply_to.as_deref(), Some("alice"));
322	            }
323	            _ => panic!("expected Send"),
324	        }
325	    }
326
327	    // [unit->REQ-MSG-2] unknown subcommand / missing required arg are usage errors.
328	    #[test]
329	    fn invalid_usage_errors() {
330	        assert!(parse(&["spt", "bogus"]).is_err());
331	        assert!(parse(&["spt", "ring"]).is_err()); // missing required target
332	    }
333	}
334

//! CLI surface + dispatch for the `spt` binary (REQ-MSG-2).
//!
//! Stable arg shapes and exit codes are the contract here. Each subcommand is a
//! thin shim: read I/O, call the matching `spt-msg` entry point, render the
//! structured outcome, return an exit code. No messaging logic lives in this
//! layer.
//!
//! Exit codes: `0` success (delivered, queued, replied, listed) · `1` runtime
//! refusal (no perch, empty body, unidentifiable self) · `2` usage error
//! (clap, for unknown/missing args).
// [impl->REQ-MSG-2]

use std::io::{Read, Write};
use std::time::Duration;

use clap::{Parser, Subcommand};
use spt_msg::deliver::{self, SendOutcome};
use spt_msg::ready::ReadyAgent;
use spt_msg::ring::{self, RingOutcome};
use spt_store::perch::{self, ParentHint};
use spt_store::registry;

use crate::roster;

#[derive(Parser)]
#[command(name = "spt", version, about = "spt-core local messaging", long_about = None)]
struct Cli {
    #[command(subcommand)]
    cmd: Cmd,
}

#[derive(Subcommand)]
enum Cmd {
    /// Send a message (body read from stdin). Fire-and-forget.
    Send {
        /// Target perch id. May be omitted when --reply-to is given.
        target: Option<String>,
        /// Sender id written into __REPLY_TO__ (auto-detected from session if omitted).
        #[arg(long)]
        from: Option<String>,
        /// Reply to this sender; sets the target and labels output REPLIED.
        #[arg(long)]
        reply_to: Option<String>,
        /// Spool only (deferred / hook channel) — no live wake.
        #[arg(long)]
        deferred: bool,
    },
    /// Send and block for a reply (body read from stdin; reply printed to stdout).
    Ring {
        /// Target perch id.
        target: String,
        /// Sender id (auto-detected from session if omitted).
        #[arg(long)]
        from: Option<String>,
        /// Seconds to wait for a reply before giving up.
        #[arg(long, default_value = "60")]
        timeout: u64,
    },
    /// Become reachable: register the perch and listen for messages (blocks).
    Ready {
        /// This agent's perch id.
        id: String,
    },
    /// Receive messages: drain backlog then listen, printing each to stdout.
    Poll {
        /// This agent's perch id.
        id: String,
        /// Run a single drain+receive cycle, then exit (legacy one-shot).
        #[arg(long)]
        once: bool,
    },
    /// List perches under the owlery (id, state, address, ready, alive).
    List,
    /// Soft-stop a perch: remove the ready marker + unregister (spool preserved).
    Stop {
        /// Perch id to stop.
        id: String,
    },
    /// Print this session's own perch id (from $OWL_SESSION_ID / $SPT_AGENT_ID).
    Whoami,
}

/// Parse args and dispatch. Returns the process exit code.
pub fn run() -> i32 {
    let cli = Cli::parse();
    match cli.cmd {
        Cmd::Send { target, from, reply_to, deferred } => cmd_send(target, from, reply_to, deferred),
        Cmd::Ring { target, from, timeout } => cmd_ring(target, from, timeout),
        Cmd::Ready { id } => run_listen(&id, false),
        Cmd::Poll { id, once } => run_listen(&id, once),
        Cmd::List => cmd_list(),
        Cmd::Stop { id } => cmd_stop(&id),
        Cmd::Whoami => cmd_whoami(),
    }
}

/// Read stdin to a string (message body source for send/ring).
fn read_stdin() -> String {
    let mut body = String::new();
    let _ = std::io::stdin().read_to_string(&mut body);
    body
}

/// Resolve the sender id: explicit `--from`, else session auto-detect, else "".
fn resolve_from(from: Option<String>) -> String {
    from.or_else(roster::detect_self_id).unwrap_or_default()
}

fn cmd_send(target: Option<String>, from: Option<String>, reply_to: Option<String>, deferred: bool) -> i32 {
    let is_reply = reply_to.is_some();
    // --reply-to provides the target when no positional target is given.
    let target = match target.or_else(|| reply_to.clone()) {
        Some(t) => t,
        None => {
            eprintln!("USAGE: a target (or --reply-to <sender>) is required");
            return 2;
        }
    };
    let from = resolve_from(from);
    let body = read_stdin();
    let owlery = perch::owlery_dir();

    let outcome = if deferred {
        deliver::send_deferred(&target, &from, &body)
    } else {
        deliver::send(&target, &from, &body, &owlery)
    };

    match outcome {
        SendOutcome::Sent => {
            eprintln!("{}:{}", if is_reply { "REPLIED" } else { "SENT" }, target);
            0
        }
        SendOutcome::Queued => {
            let label = if deferred {
                "DEFERRED"
            } else if is_reply {
                "REPLIED(QUEUED)"
            } else {
                "QUEUED"
            };
            eprintln!("{}:{}", label, target);
            0
        }
        SendOutcome::NoPerch => {
            eprintln!("NO_PERCH:{} is not listening", target);
            1
        }
        SendOutcome::Empty => {
            eprintln!("EMPTY_MSG: refusing to send empty message to {}", target);
            1
        }
    }
}

fn cmd_ring(target: String, from: Option<String>, timeout: u64) -> i32 {
    let from = resolve_from(from);
    if from.is_empty() {
        eprintln!("NO_SELF: ring needs a sender id (--from <id> or $OWL_SESSION_ID)");
        return 1;
    }
    let body = read_stdin();
    let owlery = perch::owlery_dir();

    match ring::ring(&target, &from, &body, Duration::from_secs(timeout), &owlery) {
        RingOutcome::Replied(reply) => {
            print!("{}", reply);
            let _ = std::io::stdout().flush();
            0
        }
        RingOutcome::TimedOut => {
            eprintln!("TIMEOUT:{} did not reply within {}s", target, timeout);
            0
        }
        RingOutcome::Queued => {
            eprintln!("QUEUED:{}", target);
            0
        }
        RingOutcome::Delivered => {
            eprintln!("SENT:{} (reply will arrive on your listener)", target);
            0
        }
        RingOutcome::NoPerch => {
            eprintln!("NO_PERCH:{} is not listening", target);
            1
        }
        RingOutcome::Empty => {
            eprintln!("EMPTY_MSG: refusing to ring {} with an empty message", target);
            1
        }
    }
}

/// Run the listener loop (REQ-MSG-3 lifecycle, driven from the CLI). Blocks
/// until the process is killed unless `once` is set. Each received message is
/// printed framed to stdout; status goes to stderr.
///
/// Interim M1 model: the perch is reachable only while this process runs. M3's
/// spt-daemon decouples registration from the live process.
fn run_listen(id: &str, once: bool) -> i32 {
    let (agent, backlog) = match ReadyAgent::start(id) {
        Ok(pair) => pair,
        Err(e) => {
            eprintln!("READY_FAIL:{}: {}", id, e);
            return 1;
        }
    };
    eprintln!("READY:{}", id);
    for msg in backlog {
        emit(&msg);
    }

    if once {
        if let Some(msg) = agent.recv() {
            emit(&msg);
        }
        for msg in agent.drain_spool() {
            emit(&msg);
        }
        return 0;
    }

    loop {
        if let Some(msg) = agent.recv() {
            emit(&msg);
        }
        for msg in agent.drain_spool() {
            emit(&msg);
        }
    }
}

/// Print a framed message to stdout and flush (one delivery per emit).
fn emit(msg: &str) {
    print!("{}", msg);
    let _ = std::io::stdout().flush();
}

fn cmd_list() -> i32 {
    let perches = roster::enumerate();
    if perches.is_empty() {
        eprintln!("(no perches)");
        return 0;
    }
    for p in perches {
        println!(
            "{:<28} {:<14} {:<22} ready={} alive={}",
            p.id,
            p.state,
            p.address.as_deref().unwrap_or("-"),
            p.ready,
            p.alive
        );
    }
    0
}

fn cmd_stop(id: &str) -> i32 {
    let owlery = perch::owlery_dir();
    // Soft-cleanup (HAZARD-SOFT-CLEANUP 6.2): remove the ready marker +
    // unregister the address; spool.db and info.json are preserved.
    let removed = std::fs::remove_file(perch::resolve_ready_file(id, ParentHint::Infer)).is_ok();
    let _ = registry::unregister_address(id, &owlery);
    if removed {
        eprintln!("STOPPED:{}", id);
    } else {
        eprintln!("STOPPED:{} (no ready marker; address unregistered)", id);
    }
    0
}

fn cmd_whoami() -> i32 {
    match roster::detect_self_id() {
        Some(id) => {
            println!("{}", id);
            0
        }
        None => {
            eprintln!("UNKNOWN: no perch matches this session ($OWL_SESSION_ID / $SPT_AGENT_ID)");
            1
        }
    }
}

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

    fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
        Cli::try_parse_from(args)
    }

    // [unit->REQ-MSG-2] the documented subcommands + arg shapes parse.
    #[test]
    fn subcommands_parse() {
        assert!(matches!(parse(&["spt", "send", "bob", "--from", "alice"]).unwrap().cmd, Cmd::Send { .. }));
        assert!(matches!(parse(&["spt", "send", "bob", "--deferred"]).unwrap().cmd, Cmd::Send { deferred: true, .. }));
        assert!(matches!(parse(&["spt", "ring", "bob", "--timeout", "5"]).unwrap().cmd, Cmd::Ring { .. }));
        assert!(matches!(parse(&["spt", "ready", "bob"]).unwrap().cmd, Cmd::Ready { .. }));
        assert!(matches!(parse(&["spt", "poll", "bob", "--once"]).unwrap().cmd, Cmd::Poll { once: true, .. }));
        assert!(matches!(parse(&["spt", "list"]).unwrap().cmd, Cmd::List));
        assert!(matches!(parse(&["spt", "stop", "bob"]).unwrap().cmd, Cmd::Stop { .. }));
        assert!(matches!(parse(&["spt", "whoami"]).unwrap().cmd, Cmd::Whoami));
    }

    // [unit->REQ-MSG-2] ring timeout defaults to 60s when unspecified.
    #[test]
    fn ring_timeout_defaults() {
        match parse(&["spt", "ring", "bob"]).unwrap().cmd {
            Cmd::Ring { timeout, .. } => assert_eq!(timeout, 60),
            _ => panic!("expected Ring"),
        }
    }

    // [unit->REQ-MSG-2] send target is optional (supplied via --reply-to instead).
    #[test]
    fn send_target_optional_with_reply_to() {
        match parse(&["spt", "send", "--reply-to", "alice"]).unwrap().cmd {
            Cmd::Send { target, reply_to, .. } => {
                assert!(target.is_none());
                assert_eq!(reply_to.as_deref(), Some("alice"));
            }
            _ => panic!("expected Send"),
        }
    }

    // [unit->REQ-MSG-2] unknown subcommand / missing required arg are usage errors.
    #[test]
    fn invalid_usage_errors() {
        assert!(parse(&["spt", "bogus"]).is_err());
        assert!(parse(&["spt", "ring"]).is_err()); // missing required target
    }
}


Understood. Now survey spt-store/spt-msg primitives the `api` surface builds on.


cd "C:\Users\decid\Documents\projects\spt-core" && cat crates/spt/src/roster.rs

Read roster module


//! Perch enumeration + self-identification helpers for the `list`, `whoami`,
//! and `stop` subcommands. M1 enumerates **flat** (Self_) perches only —
//! nested Psyche/Worker perches arrive with the M2 lifecycle.
//!
//! These are CLI-layer conveniences: they read the on-disk owlery layout and
//! the registry through the spt-store API. Nothing here is on the hot
//! delivery path.

use spt_store::info;
use spt_store::perch::{self, ParentHint};
use spt_store::proc;
use spt_store::registry;

/// A snapshot of one perch for `spt list`.
pub struct PerchEntry {
    pub id: String,
    pub state: String,
    pub address: Option<String>,
    pub ready: bool,
    pub alive: bool,
}

/// Enumerate flat perches under the owlery: any top-level dir holding an
/// `info.json`. Returns them sorted by id.
pub fn enumerate() -> Vec<PerchEntry> {
    let owlery = perch::owlery_dir();
    let mut out = Vec::new();
    let Ok(entries) = std::fs::read_dir(&owlery) else {
        return out;
    };
    for entry in entries.flatten() {
        if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
            continue;
        }
        let perch_path = entry.path();
        let Some(rec) = info::read_info(&perch_path) else {
            continue; // not a perch (no info.json)
        };
        let id = rec.id.clone();
        let ready = perch::resolve_ready_file(&id, ParentHint::Infer).exists();
        let alive = match info::read_pid(&perch_path) {
            Some(pid) => proc::is_process_alive(pid),
            None => true, // BUSY sentinel
        };
        let address = registry::lookup_address(&id, &owlery);
        out.push(PerchEntry {
            id,
            state: rec.state,
            address,
            ready,
            alive,
        });
    }
    out.sort_by(|a, b| a.id.cmp(&b.id));
    out
}

/// Identify the perch owned by *this* session: the one whose `info.json`
/// `session_id` matches `$OWL_SESSION_ID`. Falls back to `$SPT_AGENT_ID` if
/// that env is set. `None` if neither resolves.
pub fn detect_self_id() -> Option<String> {
    if let Ok(session) = std::env::var("OWL_SESSION_ID") {
        if !session.is_empty() {
            let owlery = perch::owlery_dir();
            if let Ok(entries) = std::fs::read_dir(&owlery) {
                for entry in entries.flatten() {
                    if let Some(rec) = info::read_info(&entry.path()) {
                        if rec.session_id == session {
                            return Some(rec.id);
                        }
                    }
                }
            }
        }
    }
    std::env::var("SPT_AGENT_ID").ok().filter(|s| !s.is_empty())
}