Write

//! Reporting + boundary surface: `api boundary`, `api session-end`,
//! `api presence`, `api history-log`, `api emit`, plus the M2a *declarations*
//! for commune/signoff file-drops and adapter env aliases.
//!
//! - **boundary** (REQ-API-2) — rebind the perch to the rotated `session_id`
//!   while keeping the stable identity + `parent_pid` anchor (CONTEXT §api
//!   boundary). The Self-resume commune authoring is M2b; M2a rebinds only.
//! - **session-end** (REQ-HAZARD-SOFT-CLEANUP / CASCADE-WIPE-GUARD) — soft
//!   teardown by default (preserve perch + spool + history); `--erase`
//!   hard-wipes, but never a parent that still hosts live workers.
//! - **presence** (REQ-API-2) — record `(endpoint, ts)`; no content (CONTEXT
//!   §presence is privacy-preserving).
//! - **history-log** (REQ-API-2) — Path-B native store: append a normalized
//!   record to the perch's history log.
//! - **emit** (REQ-API-2) — Shell sensory push; a stub here (concrete Shells M5).
//! - **commune/signoff are file-drops, not commands** (REQ-API-3): resolved from
//!   the manifest's watched dirs with spt-core-fixed filenames.
//! - **env aliases** (REQ-START-4): the adapter declares `$SPT`/`$OWL`/`$LIVE`
//!   in `[env]`; spt-core resolves the injections (it supplies the subcommands).
//
// [impl->REQ-API-2]

use std::path::PathBuf;

use spt_runtime::manifest::EnvDirection;
use spt_runtime::Manifest;
use spt_store::info::{self};
use spt_store::perch::{self, ParentHint};
use spt_store::registry;

use super::worker;
use super::{Ctx, EXIT_REFUSED};

/// `api boundary <clear|compact> <id> --to-session-id <sid>` — rebind the perch
/// to the rotated session id, preserving identity + parent_pid anchor.
// [impl->REQ-API-2]
pub fn cmd_boundary(id: &str, mode: &str, to_session: &str) -> i32 {
    if to_session.is_empty() {
        eprintln!("USAGE: boundary requires a non-empty --to-session-id");
        return EXIT_REFUSED;
    }
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    let Some(mut rec) = info::read_info(&perch_path) else {
        eprintln!("NO_ENDPOINT:{id} has no perch to rebind");
        return EXIT_REFUSED;
    };
    // Keep id / started / parent_pid / cwd / state; rotate only the session id.
    rec.session_id = to_session.to_string();
    if let Err(e) = info::write_info(&perch_path, &rec) {
        eprintln!("BOUNDARY_FAIL:{id}: {e}");
        return EXIT_REFUSED;
    }
    eprintln!("BOUNDARY:{mode}:{id} -> {to_session}");
    0
}

/// `api session-end <id> [--erase]` — soft teardown by default; `--erase`
/// hard-wipes, guarded against cascade-wipe (6.3).
// [impl->REQ-HAZARD-SOFT-CLEANUP]
// [impl->REQ-HAZARD-CASCADE-WIPE-GUARD]
pub fn cmd_session_end(id: &str, erase: bool) -> i32 {
    let owlery = perch::owlery_dir();
    if !erase {
        // Soft (6.2): remove the ready marker + unregister; preserve spool/info.
        let _ = std::fs::remove_file(perch::resolve_ready_file(id, ParentHint::Infer));
        let _ = registry::unregister_address(id, &owlery);
        eprintln!("SESSION_END:{id} (soft; spool + history preserved)");
        return 0;
    }
    // Erase: refuse to recursively delete a parent that still hosts live
    // workers — soft-clean and surface their paths instead (6.3).
    let live = worker::live_child_paths(id);
    if !live.is_empty() {
        let _ = std::fs::remove_file(perch::resolve_ready_file(id, ParentHint::Infer));
        eprintln!(
            "CASCADE_GUARD:{id} hosts {} live child perch(es) — soft-cleaned, not erased:",
            live.len()
        );
        for p in live {
            eprintln!("  {}", p.display());
        }
        return EXIT_REFUSED;
    }
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    let _ = registry::unregister_address(id, &owlery);
    if let Err(e) = std::fs::remove_dir_all(&perch_path) {
        eprintln!("SESSION_END_FAIL:{id}: {e}");
        return EXIT_REFUSED;
    }
    eprintln!("SESSION_END:{id} (erased)");
    0
}

/// `<spt_home>/presence.json`.
fn presence_path() -> PathBuf {
    perch::spt_home().join("presence.json")
}

/// `api presence <id>` — record that the user interacted at `id` (endpoint +
/// timestamp; no content — privacy-preserving).
// [impl->REQ-API-2]
pub fn cmd_presence(id: &str) -> i32 {
    let datum = format!("{{\"last_active_endpoint\":\"{id}\",\"ts\":\"{}\"}}", now_stamp());
    if let Err(e) = std::fs::write(presence_path(), datum) {
        eprintln!("PRESENCE_FAIL:{id}: {e}");
        return EXIT_REFUSED;
    }
    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) => {
            eprintln!("HISTORY_FAIL:{id}: {e}");
            EXIT_REFUSED
        }
    }
}

/// `api emit --type <t> <id>` — a Shell pushes a sensory payload to its owner.
/// Stubbed in M2a (concrete Shells + the sensory channel are M5); declared so
/// the surface is complete and adapters can bind it.
// [impl->REQ-API-2]
pub fn cmd_emit(id: &str, sensory_type: &str) -> i32 {
    eprintln!("EMIT_STUB:{id} type={sensory_type} (Shell sensory channel lands in M5)");
    0
}

/// The commune/signoff **file-drop** paths — proof these are file-drops, not
/// commands (REQ-API-3). Dir comes from the manifest's `[session]` watched
/// dirs; the filename is spt-core-fixed (`<id>-commune.md` / `<id>-signoff.md`).
/// M2a resolves them; M2b ingests them.
// [impl->REQ-API-3]
pub fn resolve_filedrops(manifest: Option<&Manifest>, id: &str) -> (Option<PathBuf>, Option<PathBuf>) {
    let session = manifest.map(|m| &m.session);
    let commune = session
        .and_then(|s| s.commune_dir.as_ref())
        .map(|dir| PathBuf::from(dir).join(format!("{id}-commune.md")));
    let signoff = session
        .and_then(|s| s.signoff_dir.as_ref())
        .map(|dir| PathBuf::from(dir).join(format!("{id}-signoff.md")));
    (commune, signoff)
}

/// The env injections spt-core supplies from the adapter's `[env]` table — the
/// channel through which the adapter's `$SPT`/`$OWL`/`$LIVE` aliases reach a
/// session. Only `inject` directives with a value participate (REQ-START-4).
// [impl->REQ-START-4]
pub fn env_injections(manifest: Option<&Manifest>) -> Vec<(String, String)> {
    let Some(m) = manifest else { return Vec::new() };
    m.env
        .iter()
        .filter(|(_, v)| v.direction == EnvDirection::Inject)
        .filter_map(|(name, v)| v.value.clone().map(|val| (name.clone(), val)))
        .collect()
}

/// `api capability` — advertise the adapter's declared `hostable_types`
/// (resolved by adapter_name from the manifest). REQ-SEAM-CAPABILITY.
// [impl->REQ-SEAM-CAPABILITY]
pub fn cmd_capability(ctx: &Ctx) -> i32 {
    let Some(manifest) = &ctx.manifest else {
        eprintln!("NO_MANIFEST: capability needs --manifest");
        return EXIT_REFUSED;
    };
    for ty in &manifest.adapter.hostable_types {
        println!("{ty}");
    }
    0
}

fn now_stamp() -> String {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs().to_string())
        .unwrap_or_else(|_| "0".to_string())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::testutil::isolated_home;
    use spt_store::info::InfoJson;

    fn establish(id: &str, session: &str) {
        let path = perch::resolve_perch_path(id, ParentHint::Infer);
        std::fs::create_dir_all(&path).unwrap();
        let mut rec = InfoJson::new(id, "0", std::process::id(), session, "live_agent");
        rec.parent_pid = Some(4242);
        info::write_info(&path, &rec).unwrap();
        std::fs::write(perch::resolve_ready_file(id, ParentHint::Infer), "").unwrap();
    }

    #[test] // [unit->REQ-API-2] boundary rebinds the session id, keeps identity+anchor.
    fn boundary_rebinds_keeping_anchor() {
        let _h = isolated_home();
        establish("alice", "sid-old");
        assert_eq!(cmd_boundary("alice", "compact", "sid-new"), 0);
        let rec = info::read_info(&perch::resolve_perch_path("alice", ParentHint::Infer)).unwrap();
        assert_eq!(rec.session_id, "sid-new");
        assert_eq!(rec.parent_pid, Some(4242), "parent anchor preserved");
        assert_eq!(rec.id, "alice");
    }

    #[test] // [unit->REQ-API-2] boundary on a missing perch is refused.
    fn boundary_missing_perch_refused() {
        let _h = isolated_home();
        assert_eq!(cmd_boundary("ghost", "clear", "sid"), EXIT_REFUSED);
    }

    #[test] // [unit->REQ-HAZARD-SOFT-CLEANUP] soft session-end preserves info+spool.
    fn session_end_soft_preserves_state() {
        let _h = isolated_home();
        establish("alice", "sid");
        assert_eq!(cmd_session_end("alice", false), 0);
        let perch_path = perch::resolve_perch_path("alice", ParentHint::Infer);
        assert!(perch_path.join("info.json").exists(), "info preserved");
        assert!(!perch::resolve_ready_file("alice", ParentHint::Infer).exists(), "ready removed");
    }

    #[test] // [unit->REQ-HAZARD-SOFT-CLEANUP] --erase hard-wipes a childless perch.
    fn session_end_erase_wipes_childless_perch() {
        let _h = isolated_home();
        establish("alice", "sid");
        assert_eq!(cmd_session_end("alice", true), 0);
        assert!(!perch::resolve_perch_path("alice", ParentHint::Infer).exists());
    }

    #[test] // [unit->REQ-HAZARD-CASCADE-WIPE-GUARD] --erase refuses a parent with
    // live workers; soft-cleans instead.
    fn session_end_erase_refused_with_live_children() {
        let _h = isolated_home();
        establish("alice", "sid");
        worker::cmd_worker_start("alice", "alice-w1");
        assert_eq!(cmd_session_end("alice", true), EXIT_REFUSED);
        // Parent dir (and the live child) survive the refused erase.
        assert!(perch::resolve_perch_path("alice", ParentHint::Infer).join("info.json").exists());
        assert!(perch::resolve_perch_path("alice-w1", ParentHint::Infer).join("info.json").exists());
    }

    #[test] // [unit->REQ-API-2] history-log appends to the Path-B native store.
    fn history_log_appends() {
        let _h = isolated_home();
        establish("alice", "sid");
        append_history("alice", "{\"role\":\"user\",\"text\":\"hi\"}").unwrap();
        append_history("alice", "{\"role\":\"assistant\",\"text\":\"yo\"}").unwrap();
        let log = perch::resolve_perch_path("alice", ParentHint::Infer).join("history.log");
        let content = std::fs::read_to_string(log).unwrap();
        assert_eq!(content.lines().count(), 2);
        assert!(content.contains("\"hi\""));
    }

    #[test] // [unit->REQ-API-2] presence records the endpoint, no content.
    fn presence_records_endpoint() {
        let _h = isolated_home();
        assert_eq!(cmd_presence("alice"), 0);
        let datum = std::fs::read_to_string(presence_path()).unwrap();
        assert!(datum.contains("alice"));
        assert!(datum.contains("ts"));
    }

    #[test] // [unit->REQ-API-3] commune/signoff resolve as file-drops with fixed names.
    fn filedrops_are_fixed_named_files() {
        let m = Manifest::from_toml_str(
            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n[session]\ncommune_dir=\".claude\"\nsignoff_dir=\".claude\"\n",
        )
        .unwrap();
        let (commune, signoff) = resolve_filedrops(Some(&m), "alice");
        assert_eq!(commune.unwrap(), PathBuf::from(".claude").join("alice-commune.md"));
        assert_eq!(signoff.unwrap(), PathBuf::from(".claude").join("alice-signoff.md"));
        // No manifest → no file-drops resolved.
        assert_eq!(resolve_filedrops(None, "alice"), (None, None));
    }

    #[test] // [unit->REQ-START-4] env aliases are resolved from the [env] inject table.
    fn env_aliases_resolved_from_manifest() {
        let m = Manifest::from_toml_str(
            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n[env.OWL_SESSION_ID]\ndirection=\"inject\"\nvalue=\"{session_id}\"\n\n[env.READONLY]\ndirection=\"read\"\n",
        )
        .unwrap();
        let injections = env_injections(Some(&m));
        assert_eq!(injections, vec![("OWL_SESSION_ID".to_string(), "{session_id}".to_string())]);
    }
}


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

//! Reporting + boundary surface: `api boundary`, `api session-end`,
//! `api presence`, `api history-log`, `api emit`, plus the M2a *declarations*
//! for commune/signoff file-drops and adapter env aliases.
//!
//! - **boundary** (REQ-API-2) — rebind the perch to the rotated `session_id`
//!   while keeping the stable identity + `parent_pid` anchor (CONTEXT §api
//!   boundary). The Self-resume commune authoring is M2b; M2a rebinds only.
//! - **session-end** (REQ-HAZARD-SOFT-CLEANUP / CASCADE-WIPE-GUARD) — soft
//!   teardown by default (preserve perch + spool + history); `--erase`
//!   hard-wipes, but never a parent that still hosts live workers.
//! - **presence** (REQ-API-2) — record `(endpoint, ts)`; no content (CONTEXT
//!   §presence is privacy-preserving).
//! - **history-log** (REQ-API-2) — Path-B native store: append a normalized
//!   record to the perch's history log.
//! - **emit** (REQ-API-2) — Shell sensory push; a stub here (concrete Shells M5).
//! - **commune/signoff are file-drops, not commands** (REQ-API-3): resolved from
//!   the manifest's watched dirs with spt-core-fixed filenames.
//! - **env aliases** (REQ-START-4): the adapter declares `$SPT`/`$OWL`/`$LIVE`
//!   in `[env]`; spt-core resolves the injections (it supplies the subcommands).
//
// [impl->REQ-API-2]

use std::path::PathBuf;

use spt_runtime::manifest::EnvDirection;
use spt_runtime::Manifest;
use spt_store::info::{self};
use spt_store::perch::{self, ParentHint};
use spt_store::registry;

use super::worker;
use super::{Ctx, EXIT_REFUSED};

/// `api boundary <clear|compact> <id> --to-session-id <sid>` — rebind the perch
/// to the rotated session id, preserving identity + parent_pid anchor.
// [impl->REQ-API-2]
pub fn cmd_boundary(id: &str, mode: &str, to_session: &str) -> i32 {
    if to_session.is_empty() {
        eprintln!("USAGE: boundary requires a non-empty --to-session-id");
        return EXIT_REFUSED;
    }
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    let Some(mut rec) = info::read_info(&perch_path) else {
        eprintln!("NO_ENDPOINT:{id} has no perch to rebind");
        return EXIT_REFUSED;
    };
    // Keep id / started / parent_pid / cwd / state; rotate only the session id.
    rec.session_id = to_session.to_string();
    if let Err(e) = info::write_info(&perch_path, &rec) {
        eprintln!("BOUNDARY_FAIL:{id}: {e}");
        return EXIT_REFUSED;
    }
    eprintln!("BOUNDARY:{mode}:{id} -> {to_session}");
    0
}

/// `api session-end <id> [--erase]` — soft teardown by default; `--erase`
/// hard-wipes, guarded against cascade-wipe (6.3).
// [impl->REQ-HAZARD-SOFT-CLEANUP]
// [impl->REQ-HAZARD-CASCADE-WIPE-GUARD]
pub fn cmd_session_end(id: &str, erase: bool) -> i32 {
    let owlery = perch::owlery_dir();
    if !erase {
        // Soft (6.2): remove the ready marker + unregister; preserve spool/info.
        let _ = std::fs::remove_file(perch::resolve_ready_file(id, ParentHint::Infer));
        let _ = registry::unregister_address(id, &owlery);
        eprintln!("SESSION_END:{id} (soft; spool + history preserved)");
        return 0;
    }
    // Erase: refuse to recursively delete a parent that still hosts live
    // workers — soft-clean and surface their paths instead (6.3).
    let live = worker::live_child_paths(id);
    if !live.is_empty() {
        let _ = std::fs::remove_file(perch::resolve_ready_file(id, ParentHint::Infer));
        eprintln!(
            "CASCADE_GUARD:{id} hosts {} live child perch(es) — soft-cleaned, not erased:",
            live.len()
        );
        for p in live {
            eprintln!("  {}", p.display());
        }
        return EXIT_REFUSED;
    }
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    let _ = registry::unregister_address(id, &owlery);
    if let Err(e) = std::fs::remove_dir_all(&perch_path) {
        eprintln!("SESSION_END_FAIL:{id}: {e}");
        return EXIT_REFUSED;
    }
    eprintln!("SESSION_END:{id} (erased)");
    0
}

/// `<spt_home>/presence.json`.
fn presence_path() -> PathBuf {
    perch::spt_home().join("presence.json")
}

/// `api presence <id>` — record that the user interacted at `id` (endpoint +
/// timestamp; no content — privacy-preserving).
// [impl->REQ-API-2]
pub fn cmd_presence(id: &str) -> i32 {
    let datum = format!("{{\"last_active_endpoint\":\"{id}\",\"ts\":\"{}\"}}", now_stamp());
    if let Err(e) = std::fs::write(presence_path(), datum) {
        eprintln!("PRESENCE_FAIL:{id}: {e}");
        return EXIT_REFUSED;
    }
    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) => {
            eprintln!("HISTORY_FAIL:{id}: {e}");
            EXIT_REFUSED
        }
    }
}

/// `api emit --type <t> <id>` — a Shell pushes a sensory payload to its owner.
/// Stubbed in M2a (concrete Shells + the sensory channel are M5); declared so
/// the surface is complete and adapters can bind it.
// [impl->REQ-API-2]
pub fn cmd_emit(id: &str, sensory_type: &str) -> i32 {
    eprintln!("EMIT_STUB:{id} type={sensory_type} (Shell sensory channel lands in M5)");
    0
}

/// The commune/signoff **file-drop** paths — proof these are file-drops, not
/// commands (REQ-API-3). Dir comes from the manifest's `[session]` watched
/// dirs; the filename is spt-core-fixed (`<id>-commune.md` / `<id>-signoff.md`).
/// M2a resolves them; M2b ingests them.
// [impl->REQ-API-3]
pub fn resolve_filedrops(manifest: Option<&Manifest>, id: &str) -> (Option<PathBuf>, Option<PathBuf>) {
    let session = manifest.map(|m| &m.session);
    let commune = session
        .and_then(|s| s.commune_dir.as_ref())
        .map(|dir| PathBuf::from(dir).join(format!("{id}-commune.md")));
    let signoff = session
        .and_then(|s| s.signoff_dir.as_ref())
        .map(|dir| PathBuf::from(dir).join(format!("{id}-signoff.md")));
    (commune, signoff)
}

/// The env injections spt-core supplies from the adapter's `[env]` table — the
/// channel through which the adapter's `$SPT`/`$OWL`/`$LIVE` aliases reach a
/// session. Only `inject` directives with a value participate (REQ-START-4).
// [impl->REQ-START-4]
pub fn env_injections(manifest: Option<&Manifest>) -> Vec<(String, String)> {
    let Some(m) = manifest else { return Vec::new() };
    m.env
        .iter()
        .filter(|(_, v)| v.direction == EnvDirection::Inject)
        .filter_map(|(name, v)| v.value.clone().map(|val| (name.clone(), val)))
        .collect()
}

/// `api capability` — advertise the adapter's declared `hostable_types`
/// (resolved by adapter_name from the manifest). REQ-SEAM-CAPABILITY.
// [impl->REQ-SEAM-CAPABILITY]
pub fn cmd_capability(ctx: &Ctx) -> i32 {
    let Some(manifest) = &ctx.manifest else {
        eprintln!("NO_MANIFEST: capability needs --manifest");
        return EXIT_REFUSED;
    };
    for ty in &manifest.adapter.hostable_types {
        println!("{ty}");
    }
    0
}

fn now_stamp() -> String {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs().to_string())
        .unwrap_or_else(|_| "0".to_string())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::testutil::isolated_home;
    use spt_store::info::InfoJson;

    fn establish(id: &str, session: &str) {
        let path = perch::resolve_perch_path(id, ParentHint::Infer);
        std::fs::create_dir_all(&path).unwrap();
        let mut rec = InfoJson::new(id, "0", std::process::id(), session, "live_agent");
        rec.parent_pid = Some(4242);
        info::write_info(&path, &rec).unwrap();
        std::fs::write(perch::resolve_ready_file(id, ParentHint::Infer), "").unwrap();
    }

    #[test] // [unit->REQ-API-2] boundary rebinds the session id, keeps identity+anchor.
    fn boundary_rebinds_keeping_anchor() {
        let _h = isolated_home();
        establish("alice", "sid-old");
        assert_eq!(cmd_boundary("alice", "compact", "sid-new"), 0);
        let rec = info::read_info(&perch::resolve_perch_path("alice", ParentHint::Infer)).unwrap();
        assert_eq!(rec.session_id, "sid-new");
        assert_eq!(rec.parent_pid, Some(4242), "parent anchor preserved");
        assert_eq!(rec.id, "alice");
    }

    #[test] // [unit->REQ-API-2] boundary on a missing perch is refused.
    fn boundary_missing_perch_refused() {
        let _h = isolated_home();
        assert_eq!(cmd_boundary("ghost", "clear", "sid"), EXIT_REFUSED);
    }

    #[test] // [unit->REQ-HAZARD-SOFT-CLEANUP] soft session-end preserves info+spool.
    fn session_end_soft_preserves_state() {
        let _h = isolated_home();
        establish("alice", "sid");
        assert_eq!(cmd_session_end("alice", false), 0);
        let perch_path = perch::resolve_perch_path("alice", ParentHint::Infer);
        assert!(perch_path.join("info.json").exists(), "info preserved");
        assert!(!perch::resolve_ready_file("alice", ParentHint::Infer).exists(), "ready removed");
    }

    #[test] // [unit->REQ-HAZARD-SOFT-CLEANUP] --erase hard-wipes a childless perch.
    fn session_end_erase_wipes_childless_perch() {
        let _h = isolated_home();
        establish("alice", "sid");
        assert_eq!(cmd_session_end("alice", true), 0);
        assert!(!perch::resolve_perch_path("alice", ParentHint::Infer).exists());
    }

    #[test] // [unit->REQ-HAZARD-CASCADE-WIPE-GUARD] --erase refuses a parent with
    // live workers; soft-cleans instead.
    fn session_end_erase_refused_with_live_children() {
        let _h = isolated_home();
        establish("alice", "sid");
        worker::cmd_worker_start("alice", "alice-w1");
        assert_eq!(cmd_session_end("alice", true), EXIT_REFUSED);
        // Parent dir (and the live child) survive the refused erase.
        assert!(perch::resolve_perch_path("alice", ParentHint::Infer).join("info.json").exists());
        assert!(perch::resolve_perch_path("alice-w1", ParentHint::Infer).join("info.json").exists());
    }

    #[test] // [unit->REQ-API-2] history-log appends to the Path-B native store.
    fn history_log_appends() {
        let _h = isolated_home();
        establish("alice", "sid");
        append_history("alice", "{\"role\":\"user\",\"text\":\"hi\"}").unwrap();
        append_history("alice", "{\"role\":\"assistant\",\"text\":\"yo\"}").unwrap();
        let log = perch::resolve_perch_path("alice", ParentHint::Infer).join("history.log");
        let content = std::fs::read_to_string(log).unwrap();
        assert_eq!(content.lines().count(), 2);
        assert!(content.contains("\"hi\""));
    }

    #[test] // [unit->REQ-API-2] presence records the endpoint, no content.
    fn presence_records_endpoint() {
        let _h = isolated_home();
        assert_eq!(cmd_presence("alice"), 0);
        let datum = std::fs::read_to_string(presence_path()).unwrap();
        assert!(datum.contains("alice"));
        assert!(datum.contains("ts"));
    }

    #[test] // [unit->REQ-API-3] commune/signoff resolve as file-drops with fixed names.
    fn filedrops_are_fixed_named_files() {
        let m = Manifest::from_toml_str(
            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n[session]\ncommune_dir=\".claude\"\nsignoff_dir=\".claude\"\n",
        )
        .unwrap();
        let (commune, signoff) = resolve_filedrops(Some(&m), "alice");
        assert_eq!(commune.unwrap(), PathBuf::from(".claude").join("alice-commune.md"));
        assert_eq!(signoff.unwrap(), PathBuf::from(".claude").join("alice-signoff.md"));
        // No manifest → no file-drops resolved.
        assert_eq!(resolve_filedrops(None, "alice"), (None, None));
    }

    #[test] // [unit->REQ-START-4] env aliases are resolved from the [env] inject table.
    fn env_aliases_resolved_from_manifest() {
        let m = Manifest::from_toml_str(
            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n[env.OWL_SESSION_ID]\ndirection=\"inject\"\nvalue=\"{session_id}\"\n\n[env.READONLY]\ndirection=\"read\"\n",
        )
        .unwrap();
        let injections = env_injections(Some(&m));
        assert_eq!(injections, vec![("OWL_SESSION_ID".to_string(), "{session_id}".to_string())]);
    }
}


Wire reporting into dispatch; replace the local capability handler.


Edit

pub mod auth;
pub mod delivery;
pub mod startup;
pub mod worker;

pub mod auth;
pub mod delivery;
pub mod reporting;
pub mod startup;
pub mod worker;


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

pub mod auth;
pub mod delivery;
pub mod startup;
pub mod worker;

pub mod auth;
pub mod delivery;
pub mod reporting;
pub mod startup;
pub mod worker;

pub mod auth;
pub mod delivery;
+pub mod reporting;
pub mod startup;
pub mod worker;


    match args.cmd {
        ApiCmd::Capability => cmd_capability(&ctx),

    match args.cmd {
        ApiCmd::Capability => reporting::cmd_capability(&ctx),


    match args.cmd {
        ApiCmd::Capability => cmd_capability(&ctx),

    match args.cmd {
        ApiCmd::Capability => reporting::cmd_capability(&ctx),

let ctx = Ctx { adapter: args.adapter, manifest };

-        ApiCmd::Capability => cmd_capability(&ctx),

+        ApiCmd::Capability => reporting::cmd_capability(&ctx),

// Harness-hosted startup (T4). Seed establishes; listen consumes+binds;

// bind is the post-spawn establishing call (intrinsic auth).

ApiCmd::Seed { pid, session_id } => startup::cmd_seed(&ctx, pid, session_id),


        // Mutations gated by auth — handlers land in T7.
        ApiCmd::Boundary { id, auth, .. } => gated(&id, &auth, |_| not_implemented("boundary")),
        ApiCmd::SessionEnd { id, auth, .. } => gated(&id, &auth, |_| not_implemented("session-end")),
        ApiCmd::Presence { id, auth, .. } => gated(&id, &auth, |_| not_implemented("presence")),
        ApiCmd::HistoryLog { id, auth, .. } => gated(&id, &auth, |_| not_implemented("history-log")),
        ApiCmd::Emit { id, auth, .. } => gated(&id, &auth, |_| not_implemented("emit")),
    }
}

        // Reporting + boundary (T7), gated by auth.
        ApiCmd::Boundary { mode, id, to_session, auth } => {
            gated(&id, &auth, |id| reporting::cmd_boundary(id, &mode, &to_session))
        }
        ApiCmd::SessionEnd { id, erase, auth } => {
            gated(&id, &auth, |id| reporting::cmd_session_end(id, erase))
        }
        ApiCmd::Presence { id, auth } => gated(&id, &auth, |id| reporting::cmd_presence(id)),
        ApiCmd::HistoryLog { id, auth } => gated(&id, &auth, |id| reporting::cmd_history_log(id)),
        ApiCmd::Emit { id, r#type, auth } => {
            gated(&id, &auth, |id| reporting::cmd_emit(id, &r#type))
        }
    }
}


        // Mutations gated by auth — handlers land in T7.
        ApiCmd::Boundary { id, auth, .. } => gated(&id, &auth, |_| not_implemented("boundary")),
        ApiCmd::SessionEnd { id, auth, .. } => gated(&id, &auth, |_| not_implemented("session-end")),
        ApiCmd::Presence { id, auth, .. } => gated(&id, &auth, |_| not_implemented("presence")),
        ApiCmd::HistoryLog { id, auth, .. } => gated(&id, &auth, |_| not_implemented("history-log")),
        ApiCmd::Emit { id, auth, .. } => gated(&id, &auth, |_| not_implemented("emit")),
    }
}

        // Reporting + boundary (T7), gated by auth.
        ApiCmd::Boundary { mode, id, to_session, auth } => {
            gated(&id, &auth, |id| reporting::cmd_boundary(id, &mode, &to_session))
        }
        ApiCmd::SessionEnd { id, erase, auth } => {
            gated(&id, &auth, |id| reporting::cmd_session_end(id, erase))
        }
        ApiCmd::Presence { id, auth } => gated(&id, &auth, |id| reporting::cmd_presence(id)),
        ApiCmd::HistoryLog { id, auth } => gated(&id, &auth, |id| reporting::cmd_history_log(id)),
        ApiCmd::Emit { id, r#type, auth } => {
            gated(&id, &auth, |id| reporting::cmd_emit(id, &r#type))
        }
    }
}

gated(&id, &auth, |id| worker::cmd_worker_poll(id, false))

-        // Mutations gated by auth — handlers land in T7.

-        ApiCmd::Boundary { id, auth, .. } => gated(&id, &auth, |_| not_implemented("boundary")),

-        ApiCmd::SessionEnd { id, auth, .. } => gated(&id, &auth, |_| not_implemented("session-end")),

-        ApiCmd::Presence { id, auth, .. } => gated(&id, &auth, |_| not_implemented("presence")),

-        ApiCmd::HistoryLog { id, auth, .. } => gated(&id, &auth, |_| not_implemented("history-log")),

-        ApiCmd::Emit { id, auth, .. } => gated(&id, &auth, |_| not_implemented("emit")),

+        // Reporting + boundary (T7), gated by auth.

+        ApiCmd::Boundary { mode, id, to_session, auth } => {

+            gated(&id, &auth, |id| reporting::cmd_boundary(id, &mode, &to_session))

+        ApiCmd::SessionEnd { id, erase, auth } => {

+            gated(&id, &auth, |id| reporting::cmd_session_end(id, erase))

+        ApiCmd::Presence { id, auth } => gated(&id, &auth, |id| reporting::cmd_presence(id)),

+        ApiCmd::HistoryLog { id, auth } => gated(&id, &auth, |id| reporting::cmd_history_log(id)),

+        ApiCmd::Emit { id, r#type, auth } => {

+            gated(&id, &auth, |id| reporting::cmd_emit(id, &r#type))


Now remove the now-dead local `cmd_capability` and `not_implemented` in mod.rs.


/// `api capability` — advertise the adapter's `hostable_types`. Requires a
/// manifest (the capability lives there, resolved by adapter_name).
fn cmd_capability(ctx: &Ctx) -> i32 {
    let Some(manifest) = &ctx.manifest else {
        eprintln!("NO_MANIFEST: capability needs --manifest");
        return EXIT_REFUSED;
    };
    for ty in &manifest.adapter.hostable_types {
        println!("{ty}");
    }
    0
}

fn not_implemented(cmd: &str) -> i32 {
    eprintln!("NOT_IMPLEMENTED:api {cmd} (lands in a later M2a task)");
    EXIT_NOT_IMPLEMENTED
}

#[cfg(test)]


/// `api capability` — advertise the adapter's `hostable_types`. Requires a
/// manifest (the capability lives there, resolved by adapter_name).
fn cmd_capability(ctx: &Ctx) -> i32 {
    let Some(manifest) = &ctx.manifest else {
        eprintln!("NO_MANIFEST: capability needs --manifest");
        return EXIT_REFUSED;
    };
    for ty in &manifest.adapter.hostable_types {
        println!("{ty}");
    }
    0
}

fn not_implemented(cmd: &str) -> i32 {
    eprintln!("NOT_IMPLEMENTED:api {cmd} (lands in a later M2a task)");
    EXIT_NOT_IMPLEMENTED
}

#[cfg(test)]

-/// `api capability` — advertise the adapter's `hostable_types`. Requires a

-/// manifest (the capability lives there, resolved by adapter_name).

-    let Some(manifest) = &ctx.manifest else {

-        eprintln!("NO_MANIFEST: capability needs --manifest");

-    for ty in &manifest.adapter.hostable_types {

-    eprintln!("NOT_IMPLEMENTED:api {cmd} (lands in a later M2a task)");


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.


/// Exit code for an `api` subcommand whose handler lands in a later M2a task.
const EXIT_NOT_IMPLEMENTED: i32 = 3;
/// Exit code for a runtime refusal (auth/no-endpoint).
pub const EXIT_REFUSED: i32 = 1;

/// Exit code for a runtime refusal (auth/no-endpoint).
pub const EXIT_REFUSED: i32 = 1;


/// Exit code for an `api` subcommand whose handler lands in a later M2a task.
const EXIT_NOT_IMPLEMENTED: i32 = 3;
/// Exit code for a runtime refusal (auth/no-endpoint).
pub const EXIT_REFUSED: i32 = 1;

use auth::{authenticate, AuthResult, Proof};

-/// Exit code for an `api` subcommand whose handler lands in a later M2a task.


//! Exit codes (superset of the messaging surface): `0` ok · `1` runtime refusal
//! (auth refused, no endpoint) · `2` usage (clap) · `3` not-yet-built in this
//! M2a slice.

//! Exit codes (superset of the messaging surface): `0` ok · `1` runtime refusal
//! (auth refused, no endpoint, bad manifest) · `2` usage (clap).


//! Exit codes (superset of the messaging surface): `0` ok · `1` runtime refusal
//! (auth refused, no endpoint) · `2` usage (clap) · `3` not-yet-built in this
//! M2a slice.

//! Exit codes (superset of the messaging surface): `0` ok · `1` runtime refusal
//! (auth refused, no endpoint, bad manifest) · `2` usage (clap).

-//! (auth refused, no endpoint) · `2` usage (clap) · `3` not-yet-built in this

+//! (auth refused, no endpoint, bad manifest) · `2` usage (clap).


#[test] // [unit->REQ-START-4] env aliases are resolved from the [env] inject table.

    #[test] // [unit->REQ-SEAM-CAPABILITY] capability lists the adapter's hostable_types.
    fn capability_lists_hostable_types() {
        let m = Manifest::from_toml_str(
            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\nhostable_types=[\"LiveAgent\",\"Worker\"]\n",
        )
        .unwrap();
        let ctx = Ctx { adapter: "m".into(), manifest: Some(m) };
        assert_eq!(cmd_capability(&ctx), 0);
        // No manifest → refused.
        let bare = Ctx { adapter: "m".into(), manifest: None };
        assert_eq!(cmd_capability(&bare), EXIT_REFUSED);
    }

    #[test] // [unit->REQ-START-4] env aliases are resolved from the [env] inject table.


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

    #[test] // [unit->REQ-SEAM-CAPABILITY] capability lists the adapter's hostable_types.
    fn capability_lists_hostable_types() {
        let m = Manifest::from_toml_str(
            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\nhostable_types=[\"LiveAgent\",\"Worker\"]\n",
        )
        .unwrap();
        let ctx = Ctx { adapter: "m".into(), manifest: Some(m) };
        assert_eq!(cmd_capability(&ctx), 0);
        // No manifest → refused.
        let bare = Ctx { adapter: "m".into(), manifest: None };
        assert_eq!(cmd_capability(&bare), EXIT_REFUSED);
    }

    #[test] // [unit->REQ-START-4] env aliases are resolved from the [env] inject table.

assert_eq!(resolve_filedrops(None, "alice"), (None, None));

+    #[test] // [unit->REQ-SEAM-CAPABILITY] capability lists the adapter's hostable_types.

+    fn capability_lists_hostable_types() {

+        let m = Manifest::from_toml_str(

+            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\nhostable_types=[\"LiveAgent\",\"Worker\"]\n",

+        let ctx = Ctx { adapter: "m".into(), manifest: Some(m) };

+        assert_eq!(cmd_capability(&ctx), 0);

+        let bare = Ctx { adapter: "m".into(), manifest: None };

+        assert_eq!(cmd_capability(&bare), EXIT_REFUSED);


Bash

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

Build + test spt with reporting


test cli::tests::invalid_usage_errors ... ok
test cli::tests::ring_timeout_defaults ... ok
test cli::tests::send_target_optional_with_reply_to ... ok
test cli::tests::subcommands_parse ... ok
test api::reporting::tests::presence_records_endpoint ... ok
test api::reporting::tests::session_end_erase_refused_with_live_children ... ok
test api::reporting::tests::session_end_erase_wipes_childless_perch ... ok
test api::reporting::tests::session_end_soft_preserves_state ... ok
test api::startup::tests::empty_session_seed_refused ... ok
test api::startup::tests::missing_seed_refused ... ok
test api::startup::tests::live_perch_under_other_session_conflicts ... ok
test api::startup::tests::post_spawn_bind_establishes_perch ... ok
test api::startup::tests::rebind_same_session_ok ... ok
test api::startup::tests::stale_seed_with_dead_anchor_refused ... ok
test api::startup::tests::valid_seed_binds_live_perch ... ok
test api::worker::tests::cascade_guard_tracks_live_children ... ok
test api::worker::tests::guard_clears_only_when_all_children_stopped ... ok
test api::worker::tests::worker_poll_drains_spool ... ok
test api::worker::tests::worker_start_nests_under_parent ... ok

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


40 pass. Activate REQ-API-3, REQ-START-4, REQ-SEAM-CAPABILITY.


90	[[requirements]]
91	id = "REQ-SEAM-CAPABILITY"
92	title = "Hostable endpoint-types capability declaration"
93	required_stages = []

[[requirements]]
id = "REQ-SEAM-CAPABILITY"
title = "Hostable endpoint-types capability declaration"
required_stages = []


108	[[requirements]]
109	id = "REQ-API-3"
110	title = "commune/signoff are file-drops, not commands"
111	required_stages = []

[[requirements]]
id = "REQ-API-3"
title = "commune/signoff are file-drops, not commands"
required_stages = []


126	[[requirements]]
127	id = "REQ-START-4"
128	title = "Adapter-injected env aliases (SPT/OWL/LIVE)"
129	required_stages = []

[[requirements]]
id = "REQ-START-4"
title = "Adapter-injected env aliases (SPT/OWL/LIVE)"
required_stages = []


Edit

id = "REQ-SEAM-CAPABILITY"
title = "Hostable endpoint-types capability declaration"
required_stages = []

id = "REQ-SEAM-CAPABILITY"
title = "Hostable endpoint-types capability declaration"
required_stages = ["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-SEAM-CAPABILITY"
title = "Hostable endpoint-types capability declaration"
required_stages = []

id = "REQ-SEAM-CAPABILITY"
title = "Hostable endpoint-types capability declaration"
required_stages = ["impl", "unit"]

title = "Adapter-update avenue (file-pull / delegated command)"


id = "REQ-API-3"
title = "commune/signoff are file-drops, not commands"
required_stages = []

id = "REQ-API-3"
title = "commune/signoff are file-drops, not commands"
required_stages = ["impl", "unit"]


id = "REQ-API-3"
title = "commune/signoff are file-drops, not commands"
required_stages = []

id = "REQ-API-3"
title = "commune/signoff are file-drops, not commands"
required_stages = ["impl", "unit"]

# ───────────────────────────── Startup flows ────────────────────────────────


id = "REQ-START-4"
title = "Adapter-injected env aliases (SPT/OWL/LIVE)"
required_stages = []

id = "REQ-START-4"
title = "Adapter-injected env aliases (SPT/OWL/LIVE)"
required_stages = ["impl", "unit"]


id = "REQ-START-4"
title = "Adapter-injected env aliases (SPT/OWL/LIVE)"
required_stages = []

id = "REQ-START-4"
title = "Adapter-injected env aliases (SPT/OWL/LIVE)"
required_stages = ["impl", "unit"]

# ───────────────────────────── Endpoints / Shells ───────────────────────────


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


Bash

cd "C:\Users\decid\Documents\projects\spt-core" && traceable-reqs check 2>&1 | grep -E "API-3|START-4|SEAM-CAPABILITY"; traceable-reqs check >/dev/null 2>&1; echo "exit:$?"

Verify T7 coverage


  [OK] REQ-API-3  required: [impl, unit]  stages: -doc +impl +unit -int
  [OK] REQ-SEAM-CAPABILITY  required: [impl, unit]  stages: -doc +impl +unit -int
  [OK] REQ-START-4  required: [impl, unit]  stages: -doc +impl +unit -int
exit:0


Updated task #7 status


Updated task #8 status


cd "C:\Users\decid\Documents\projects\spt-core" && git add -A && git commit -q -F - <<'EOF'
feat(spt): M2a T7 — reporting + boundary surface

Completes the inbound api surface.

- api boundary <clear|compact> --to-session-id — rebind the perch to the
  rotated session id, preserving identity + parent_pid anchor (Self-resume
  commune authoring is M2b).
- api session-end [--erase] — soft teardown by default (6.2: ready removed,
  info+spool preserved); --erase hard-wipes but the cascade-wipe guard (6.3)
  refuses a parent hosting live workers, soft-cleaning + surfacing their paths.
- api presence — record (endpoint, ts), no content (privacy-preserving).
- api history-log — Path-B native store: append a normalized stdin record to
  the perch history.log.
- api emit --type — Shell sensory push, stubbed (concrete Shells M5).
- resolve_filedrops (REQ-API-3) — commune/signoff are file-drops with
  spt-core-fixed names under the manifest's watched dirs, not commands.
- env_injections (REQ-START-4) — resolve the adapter's $SPT/$OWL/$LIVE aliases
  from the [env] inject table.
- capability moved to reporting (REQ-SEAM-CAPABILITY) + a unit test.

Dropped the T3 not_implemented stubs — the surface is now fully wired.
REQ-API-3/START-4/SEAM-CAPABILITY activated [impl, unit]; soft-cleanup +
cascade guard gain teardown evidence. 40 spt tests green; check green.

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

Commit T7


warning: in the working copy of 'crates/spt/src/api/mod.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/src/api/reporting.rs', LF will be replaced by CRLF the next time Git touches it
d555c93 feat(spt): M2a T7 — reporting + boundary surface


T7 done. T8 — generic mock adapter. Create a leaf `adapters/mock` crate (manifest + `mock-session` helper bin, zero CC conventions).


Write

[package]
name = "mock-adapter"
version = "0.0.0"
edition = "2021"
publish = false
description = "Generic mock harness adapter — exercises the spt-core harness contract (manifest + api surface) end-to-end with ZERO Claude Code conventions. The only adapter-shaped artifact in-tree (PRD R-DOCS-2). M2a test fixture."

[[bin]]
name = "mock-session"
path = "src/main.rs"

[dev-dependencies]
spt-runtime = { path = "../../crates/spt-runtime" }


File created successfully at: C:\Users\decid\Documents\projects\spt-core\adapters\mock\Cargo.toml (file state is current in your context — no need to Read it back)

[package]
name = "mock-adapter"
version = "0.0.0"
edition = "2021"
publish = false
description = "Generic mock harness adapter — exercises the spt-core harness contract (manifest + api surface) end-to-end with ZERO Claude Code conventions. The only adapter-shaped artifact in-tree (PRD R-DOCS-2). M2a test fixture."

[[bin]]
name = "mock-session"
path = "src/main.rs"

[dev-dependencies]
spt-runtime = { path = "../../crates/spt-runtime" }


//! `mock-session` — the generic mock adapter's "agent session". A trivial,
//! cross-platform stand-in for a real harness session (no `claude`, no `.claude`,
//! no CC hooks). When `ManifestRuntime` spawns it from the mock manifest's
//! `[session.self]` template, it does what a real session's boot hook would do:
//! fire `spt api bind` to flip its perch live, then `spt api state idle`.
//!
//! It shells out to the `spt` binary exactly as a real adapter would — resolved
//! from `--spt <path>`, else `$SPT_BIN`, else `spt` on PATH. This is what proves
//! the inbound contract is harness-agnostic: a dumb helper drives it with only
//! `spt api` calls.

use std::process::Command;

fn arg(args: &[String], flag: &str) -> Option<String> {
    args.iter().position(|a| a == flag).and_then(|i| args.get(i + 1).cloned())
}

fn main() {
    let args: Vec<String> = std::env::args().skip(1).collect();
    let id = arg(&args, "--id").unwrap_or_else(|| {
        eprintln!("mock-session: --id required");
        std::process::exit(2);
    });
    let session_id = arg(&args, "--session-id").unwrap_or_else(|| "mock-session-1".to_string());
    let spt = arg(&args, "--spt")
        .or_else(|| std::env::var("SPT_BIN").ok())
        .unwrap_or_else(|| "spt".to_string());

    // 1. Post-spawn bind: flip the perch skeleton→live under this session id.
    let bind = Command::new(&spt)
        .args(["api", "--adapter", "mock", "bind", &id, "--set-session-id", &session_id])
        .status();
    if !matches!(bind, Ok(s) if s.success()) {
        eprintln!("mock-session: api bind failed: {bind:?}");
        std::process::exit(1);
    }

    // 2. Report idle (activity sentinel + echo-gate), authenticating by session.
    let state = Command::new(&spt)
        .args(["api", "--adapter", "mock", "state", "idle", &id, "--session-id", &session_id])
        .status();
    if !matches!(state, Ok(s) if s.success()) {
        eprintln!("mock-session: api state idle failed: {state:?}");
        std::process::exit(1);
    }

    println!("MOCK_SESSION_READY:{id}");
}


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

//! `mock-session` — the generic mock adapter's "agent session". A trivial,
//! cross-platform stand-in for a real harness session (no `claude`, no `.claude`,
//! no CC hooks). When `ManifestRuntime` spawns it from the mock manifest's
//! `[session.self]` template, it does what a real session's boot hook would do:
//! fire `spt api bind` to flip its perch live, then `spt api state idle`.
//!
//! It shells out to the `spt` binary exactly as a real adapter would — resolved
//! from `--spt <path>`, else `$SPT_BIN`, else `spt` on PATH. This is what proves
//! the inbound contract is harness-agnostic: a dumb helper drives it with only
//! `spt api` calls.

use std::process::Command;

fn arg(args: &[String], flag: &str) -> Option<String> {
    args.iter().position(|a| a == flag).and_then(|i| args.get(i + 1).cloned())
}

fn main() {
    let args: Vec<String> = std::env::args().skip(1).collect();
    let id = arg(&args, "--id").unwrap_or_else(|| {
        eprintln!("mock-session: --id required");
        std::process::exit(2);
    });
    let session_id = arg(&args, "--session-id").unwrap_or_else(|| "mock-session-1".to_string());
    let spt = arg(&args, "--spt")
        .or_else(|| std::env::var("SPT_BIN").ok())
        .unwrap_or_else(|| "spt".to_string());

    // 1. Post-spawn bind: flip the perch skeleton→live under this session id.
    let bind = Command::new(&spt)
        .args(["api", "--adapter", "mock", "bind", &id, "--set-session-id", &session_id])
        .status();
    if !matches!(bind, Ok(s) if s.success()) {
        eprintln!("mock-session: api bind failed: {bind:?}");
        std::process::exit(1);
    }

    // 2. Report idle (activity sentinel + echo-gate), authenticating by session.
    let state = Command::new(&spt)
        .args(["api", "--adapter", "mock", "state", "idle", &id, "--session-id", &session_id])
        .status();
    if !matches!(state, Ok(s) if s.success()) {
        eprintln!("mock-session: api state idle failed: {state:?}");
        std::process::exit(1);
    }

    println!("MOCK_SESSION_READY:{id}");
}


# Generic mock harness adapter — the canonical, harness-AGNOSTIC exercise of the
# spt-core contract. ZERO Claude Code conventions: no `claude`, no `.claude`, no
# CC hook names. The command templates spawn the trivial `mock-session` helper;
# the [hooks.*] table binds generic event names to the inbound `api` surface.
#
# This is the only adapter-shaped artifact in spt-core (PRD R-DOCS-2). A real
# harness adapter (e.g. the downstream spt-plugin) lives in its own repo.

[adapter]
name = "mock"
kind = "harness"
version = "1.0.0"
min_spt_core_version = "1.0.0"
hostable_types = ["LiveAgent", "ReadyAgent", "Worker"]

# Inbound hooks: generic event names → the `api` command each fires. SessionStart
# seeds; Idle reports idle (and arms the echo-gate); SessionEnd soft-tears-down.
[hooks.SessionStart]
fires = "api seed --pid {parent_pid} --session-id {session_id} --adapter {adapter_name}"
reads = ["session_id", "parent_pid"]
can_inject = true

[hooks.Idle]
fires = "api state idle"
reads = []
can_inject = false              # no inject channel → sentinel/relay fallback

[hooks.SessionEnd]
fires = "api session-end"
reads = []
can_inject = false

# Watched dirs: commune/signoff are file-drops (filenames spt-core-fixed), not
# commands.
[session]
commune_dir = ".mock"
signoff_dir = ".mock"

# Outbound session role: the opaque template ManifestRuntime spawns. `mock-session`
# stands in for a real harness binary; spt-core just runs it with keys filled.
[session.self]
command = "mock-session --id {id} --session-id {session_id}"
detach = true
keys = ["id", "session_id"]

# Env aliases the adapter injects for in-session use ($SPT = spt api, etc.).
[env.SPT_AGENT_ID]
direction = "inject"
value = "{id}"

# Inject methods per activity state (PTY is spt-core/M3, never offered here).
[inject]
activity = ["hook"]
idle = ["hook", "relay"]

[identity]
session_id_source = "post_spawn"
parent_ancestor_name = "mock-session"


File created successfully at: C:\Users\decid\Documents\projects\spt-core\adapters\mock\manifest.toml (file state is current in your context — no need to Read it back)

# Generic mock harness adapter — the canonical, harness-AGNOSTIC exercise of the
# spt-core contract. ZERO Claude Code conventions: no `claude`, no `.claude`, no
# CC hook names. The command templates spawn the trivial `mock-session` helper;
# the [hooks.*] table binds generic event names to the inbound `api` surface.
#
# This is the only adapter-shaped artifact in spt-core (PRD R-DOCS-2). A real
# harness adapter (e.g. the downstream spt-plugin) lives in its own repo.

[adapter]
name = "mock"
kind = "harness"
version = "1.0.0"
min_spt_core_version = "1.0.0"
hostable_types = ["LiveAgent", "ReadyAgent", "Worker"]

# Inbound hooks: generic event names → the `api` command each fires. SessionStart
# seeds; Idle reports idle (and arms the echo-gate); SessionEnd soft-tears-down.
[hooks.SessionStart]
fires = "api seed --pid {parent_pid} --session-id {session_id} --adapter {adapter_name}"
reads = ["session_id", "parent_pid"]
can_inject = true

[hooks.Idle]
fires = "api state idle"
reads = []
can_inject = false              # no inject channel → sentinel/relay fallback

[hooks.SessionEnd]
fires = "api session-end"
reads = []
can_inject = false

# Watched dirs: commune/signoff are file-drops (filenames spt-core-fixed), not
# commands.
[session]
commune_dir = ".mock"
signoff_dir = ".mock"

# Outbound session role: the opaque template ManifestRuntime spawns. `mock-session`
# stands in for a real harness binary; spt-core just runs it with keys filled.
[session.self]
command = "mock-session --id {id} --session-id {session_id}"
detach = true
keys = ["id", "session_id"]

# Env aliases the adapter injects for in-session use ($SPT = spt api, etc.).
[env.SPT_AGENT_ID]
direction = "inject"
value = "{id}"

# Inject methods per activity state (PTY is spt-core/M3, never offered here).
[inject]
activity = ["hook"]
idle = ["hook", "relay"]

[identity]
session_id_source = "post_spawn"
parent_ancestor_name = "mock-session"


Add the mock crate to the workspace + `id` to the key catalog + a manifest-validity test.


Edit

members = ["crates/spt-proto", "crates/spt-store", "crates/spt-msg", "crates/spt-runtime", "crates/spt"]

members = ["crates/spt-proto", "crates/spt-store", "crates/spt-msg", "crates/spt-runtime", "crates/spt", "adapters/mock"]


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

# spt-core cargo workspace (M0). Acyclically-layered crates per R-ARCH-1:
#   spt-proto -> spt-store -> spt-msg -> {spt-net, spt-term, spt-runtime} -> spt-live -> spt-daemon -> spt
# M0 builds the bottom two: spt-proto (wire) + spt-store (persistence).
# M1 adds spt-msg (local messaging) — third layer, one-way above spt-store.
# M2a adds spt-runtime (harness contract: manifest schema + AgentRuntime) — fourth layer.
[workspace]
resolver = "2"
members = ["crates/spt-proto", "crates/spt-store", "crates/spt-msg", "crates/spt-runtime", "crates/spt"]

-members = ["crates/spt-proto", "crates/spt-store", "crates/spt-msg", "crates/spt-runtime", "crates/spt"]

+members = ["crates/spt-proto", "crates/spt-store", "crates/spt-msg", "crates/spt-runtime", "crates/spt", "adapters/mock"]


pub const SUBSTITUTION_KEYS: &[&str] = &[
    "adapter_name",
    "session_id",

pub const SUBSTITUTION_KEYS: &[&str] = &[
    "id",
    "adapter_name",
    "session_id",


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)

pub const SUBSTITUTION_KEYS: &[&str] = &[
    "adapter_name",
    "session_id",

pub const SUBSTITUTION_KEYS: &[&str] = &[
    "id",
    "adapter_name",
    "session_id",

/// to a provided value. Documented here so adapter authors know the vocabulary

/// (MANIFEST §Open: "exact substitution-key catalog").


Write

//! The mock adapter's manifest must parse + validate against the spt-core
//! schema, and declare a complete harness contract — with ZERO Claude Code
//! conventions. This is the T8 fixture proof; T9 drives it end-to-end.

use spt_runtime::manifest::{AdapterKind, HistoryStrategy, Manifest};

fn load() -> Manifest {
    let path = concat!(env!("CARGO_MANIFEST_DIR"), "/manifest.toml");
    let text = std::fs::read_to_string(path).expect("read mock manifest");
    Manifest::from_toml_str(&text).expect("mock manifest must parse + validate")
}

#[test] // [unit->REQ-MANIFEST-1]
fn mock_manifest_parses_and_validates() {
    let m = load();
    assert_eq!(m.adapter.name, "mock");
    assert_eq!(m.adapter.kind, AdapterKind::Harness);
    assert!(!m.adapter.hostable_types.is_empty());
    // The outbound role template spawns the helper.
    let self_role = m.session.self_.as_ref().expect("[session.self]");
    assert!(self_role.command.starts_with("mock-session"));
    assert!(self_role.command.contains("{id}"));
    // Inbound hooks bind generic events to the api surface.
    assert!(m.hooks["SessionStart"].fires.contains("api seed"));
    assert!(!m.hooks["Idle"].can_inject, "Idle hook drives the sentinel fallback");
}

#[test] // [unit->REQ-API-3]
fn mock_declares_commune_signoff_as_filedrops() {
    let m = load();
    // Watched dirs are declared; filenames are spt-core-fixed (not commands).
    assert_eq!(m.session.commune_dir.as_deref(), Some(".mock"));
    assert_eq!(m.session.signoff_dir.as_deref(), Some(".mock"));
}

#[test] // [unit->REQ-MANIFEST-1] zero Claude Code conventions leak into the fixture.
fn mock_has_no_claude_code_conventions() {
    let path = concat!(env!("CARGO_MANIFEST_DIR"), "/manifest.toml");
    let text = std::fs::read_to_string(path).unwrap().to_lowercase();
    for forbidden in ["claude", ".claude", "anthropic"] {
        assert!(!text.contains(forbidden), "mock manifest must not mention {forbidden}");
    }
    // History strategy, if declared, is the native/portable kind — never a CC fetcher.
    if let Some(h) = load().history {
        assert_ne!(h.strategy, HistoryStrategy::LocateNormalize);
    }
}


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

//! The mock adapter's manifest must parse + validate against the spt-core
//! schema, and declare a complete harness contract — with ZERO Claude Code
//! conventions. This is the T8 fixture proof; T9 drives it end-to-end.

use spt_runtime::manifest::{AdapterKind, HistoryStrategy, Manifest};

fn load() -> Manifest {
    let path = concat!(env!("CARGO_MANIFEST_DIR"), "/manifest.toml");
    let text = std::fs::read_to_string(path).expect("read mock manifest");
    Manifest::from_toml_str(&text).expect("mock manifest must parse + validate")
}

#[test] // [unit->REQ-MANIFEST-1]
fn mock_manifest_parses_and_validates() {
    let m = load();
    assert_eq!(m.adapter.name, "mock");
    assert_eq!(m.adapter.kind, AdapterKind::Harness);
    assert!(!m.adapter.hostable_types.is_empty());
    // The outbound role template spawns the helper.
    let self_role = m.session.self_.as_ref().expect("[session.self]");
    assert!(self_role.command.starts_with("mock-session"));
    assert!(self_role.command.contains("{id}"));
    // Inbound hooks bind generic events to the api surface.
    assert!(m.hooks["SessionStart"].fires.contains("api seed"));
    assert!(!m.hooks["Idle"].can_inject, "Idle hook drives the sentinel fallback");
}

#[test] // [unit->REQ-API-3]
fn mock_declares_commune_signoff_as_filedrops() {
    let m = load();
    // Watched dirs are declared; filenames are spt-core-fixed (not commands).
    assert_eq!(m.session.commune_dir.as_deref(), Some(".mock"));
    assert_eq!(m.session.signoff_dir.as_deref(), Some(".mock"));
}

#[test] // [unit->REQ-MANIFEST-1] zero Claude Code conventions leak into the fixture.
fn mock_has_no_claude_code_conventions() {
    let path = concat!(env!("CARGO_MANIFEST_DIR"), "/manifest.toml");
    let text = std::fs::read_to_string(path).unwrap().to_lowercase();
    for forbidden in ["claude", ".claude", "anthropic"] {
        assert!(!text.contains(forbidden), "mock manifest must not mention {forbidden}");
    }
    // History strategy, if declared, is the native/portable kind — never a CC fetcher.
    if let Some(h) = load().history {
        assert_ne!(h.strategy, HistoryStrategy::LocateNormalize);
    }
}


Bash

cd "C:\Users\decid\Documents\projects\spt-core" && cargo build -p mock-adapter 2>&1 | tail -5 && cargo test -p mock-adapter 2>&1 | tail -12

Build mock helper + run manifest tests


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

thread 'mock_has_no_claude_code_conventions' (57568) panicked at adapters\mock\tests\manifest_valid.rs:41:9:
mock manifest must not mention claude
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

failures:
    mock_has_no_claude_code_conventions

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

error: test failed, to rerun pass `-p mock-adapter --test manifest_valid`


The disclaimer comment itself says "Claude Code". Reword to avoid the literal while keeping intent.


Edit

# Generic mock harness adapter — the canonical, harness-AGNOSTIC exercise of the
# spt-core contract. ZERO Claude Code conventions: no `claude`, no `.claude`, no
# CC hook names. The command templates spawn the trivial `mock-session` helper;
# the [hooks.*] table binds generic event names to the inbound `api` surface.
#
# This is the only adapter-shaped artifact in spt-core (PRD R-DOCS-2). A real
# harness adapter (e.g. the downstream spt-plugin) lives in its own repo.

# Generic mock harness adapter — the canonical, harness-AGNOSTIC exercise of the
# spt-core contract. ZERO vendor harness conventions: no vendor binary name, no
# vendor config dir, no vendor hook names. The command templates spawn the
# trivial `mock-session` helper; the [hooks.*] table binds generic event names to
# the inbound `api` surface.
#
# This is the only adapter-shaped artifact in spt-core (PRD R-DOCS-2). A real
# harness adapter (e.g. the downstream spt-plugin) lives in its own repo.


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

# Generic mock harness adapter — the canonical, harness-AGNOSTIC exercise of the
# spt-core contract. ZERO vendor harness conventions: no vendor binary name, no
# vendor config dir, no vendor hook names. The command templates spawn the
# trivial `mock-session` helper; the [hooks.*] table binds generic event names to
# the inbound `api` surface.
#
# This is the only adapter-shaped artifact in spt-core (PRD R-DOCS-2). A real
# harness adapter (e.g. the downstream spt-plugin) lives in its own repo.

# Generic mock harness adapter — the canonical, harness-AGNOSTIC exercise of the
# spt-core contract. ZERO Claude Code conventions: no `claude`, no `.claude`, no
# CC hook names. The command templates spawn the trivial `mock-session` helper;
# the [hooks.*] table binds generic event names to the inbound `api` surface.
#
# This is the only adapter-shaped artifact in spt-core (PRD R-DOCS-2). A real
# harness adapter (e.g. the downstream spt-plugin) lives in its own repo.

[adapter]
name = "mock"
kind = "harness"
version = "1.0.0"
min_spt_core_version = "1.0.0"
hostable_types = ["LiveAgent", "ReadyAgent", "Worker"]

# Inbound hooks: generic event names → the `api` command each fires. SessionStart
# seeds; Idle reports idle (and arms the echo-gate); SessionEnd soft-tears-down.
[hooks.SessionStart]
fires = "api seed --pid {parent_pid} --session-id {session_id} --adapter {adapter_name}"
reads = ["session_id", "parent_pid"]
can_inject = true

[hooks.Idle]
fires = "api state idle"
reads = []
can_inject = false              # no inject channel → sentinel/relay fallback

[hooks.SessionEnd]
fires = "api session-end"
reads = []
can_inject = false

# Watched dirs: commune/signoff are file-drops (filenames spt-core-fixed), not
# commands.
[session]
commune_dir = ".mock"
signoff_dir = ".mock"

# Outbound session role: the opaque template ManifestRuntime spawns. `mock-session`
# stands in for a real harness binary; spt-core just runs it with keys filled.
[session.self]
command = "mock-session --id {id} --session-id {session_id}"
detach = true
keys = ["id", "session_id"]

# Env aliases the adapter injects for in-session use ($SPT = spt api, etc.).
[env.SPT_AGENT_ID]
direction = "inject"
value = "{id}"

# Inject methods per activity state (PTY is spt-core/M3, never offered here).
[inject]
activity = ["hook"]
idle = ["hook", "relay"]

[identity]
session_id_source = "post_spawn"
parent_ancestor_name = "mock-session"

-# spt-core contract. ZERO Claude Code conventions: no `claude`, no `.claude`, no

-# CC hook names. The command templates spawn the trivial `mock-session` helper;

-# the [hooks.*] table binds generic event names to the inbound `api` surface.

+# spt-core contract. ZERO vendor harness conventions: no vendor binary name, no

+# vendor config dir, no vendor hook names. The command templates spawn the

+# trivial `mock-session` helper; the [hooks.*] table binds generic event names to


Bash

cd "C:\Users\decid\Documents\projects\spt-core" && cargo test -p mock-adapter 2>&1 | tail -8

Re-run mock tests


running 3 tests
test mock_has_no_claude_code_conventions ... ok
test mock_declares_commune_signoff_as_filedrops ... ok
test mock_manifest_parses_and_validates ... ok

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


3 pass. T8 reqs (MANIFEST-1, API-3) already active — evidence added. Workspace test + commit.


cd "C:\Users\decid\Documents\projects\spt-core" && cargo test --workspace 2>&1 | grep -E "test result: FAIL|error\[|passed" | head -15; echo "===check==="; traceable-reqs check >/dev/null 2>&1; echo "exit:$?"

Workspace test + check


test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 40 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.35s
test result: ok. 22 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.09s
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. 33 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.68s
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
===check===
exit:0


Updated task #9 status


cd "C:\Users\decid\Documents\projects\spt-core" && git add -A && git commit -q -F - <<'EOF'
feat(mock-adapter): M2a T8 — generic mock adapter (manifest + helper)

The only adapter-shaped artifact in spt-core (PRD R-DOCS-2) — a harness-agnostic
exercise of the contract with ZERO vendor conventions (no vendor binary/dir/hook
names; a test greps the manifest to enforce it).

- adapters/mock/manifest.toml — full harness manifest: [adapter] + hostable
  types, generic [hooks.*] (SessionStart→api seed, Idle→api state, SessionEnd→
  api session-end), [session.self] spawning the helper, watched dirs (file-drops),
  [env] alias, [inject] (hook/relay, never pty), [identity].
- adapters/mock/src/main.rs (mock-session) — trivial cross-platform "session":
  shells to `spt` (from --spt / $SPT_BIN / PATH) to fire `api bind` then
  `api state idle`. Proves the inbound surface is harness-agnostic.
- "id" added to the SUBSTITUTION_KEYS catalog (the role template uses {id}).

3 fixture tests (parse+validate, file-drops declared, no-vendor-conventions
grep). REQ-MANIFEST-1 + REQ-API-3 gain fixture evidence. Workspace green.

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

Commit T8


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-runtime/src/runtime.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'adapters/mock/Cargo.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'adapters/mock/manifest.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'adapters/mock/src/main.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'adapters/mock/tests/manifest_valid.rs', LF will be replaced by CRLF the next time Git touches it
146a7b8 feat(mock-adapter): M2a T8 — generic mock adapter (manifest + helper)


T8 done. Now T9 — the contract E2E. Place it in the `spt` crate tests (resolves both binaries as target/debug siblings).


Write

//! M2a contract E2E (T9): the inbound + outbound halves of the harness contract
//! proven end-to-end through the **generic mock adapter** — no vendor harness.
//!
//! Flow: load the mock manifest → `ManifestRuntime` spawns the `mock-session`
//! helper → the helper fires `spt api bind` (skeleton→live) then
//! `spt api state idle` → a message is spooled to the perch → `spt api poll`
//! drains it. Asserts the round-trip.
//
// [int->REQ-API-1]
// [int->REQ-API-2]
// [int->REQ-START-2]

use std::path::PathBuf;
use std::process::Command;
use std::time::Duration;

use spt_runtime::{Manifest, ManifestRuntime, AgentRuntime};
use spt_store::perch::{self, ParentHint};
use spt_store::spool;

/// Absolute path to a sibling binary in the same target dir as the `spt` test bin.
fn sibling_bin(name: &str) -> PathBuf {
    let spt = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
    spt.with_file_name(format!("{name}{}", std::env::consts::EXE_SUFFIX))
}

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

#[test]
fn mock_adapter_drives_the_full_contract() {
    // Isolated home shared by this process *and* the child `spt` invocations
    // (inherited via env). This test owns the process, so a plain set_var is safe.
    let home = tempfile::tempdir().unwrap();
    std::env::set_var("SPT_HOME", home.path());

    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
    let mock_bin = sibling_bin("mock-session");
    assert!(mock_bin.exists(), "mock-session must be built: {}", mock_bin.display());

    // Load the canonical mock manifest, then point its [session.self] template at
    // the freshly-built helper + spt binary (PATH isn't set up in the test rig).
    let manifest_path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../adapters/mock/manifest.toml");
    let mut manifest = Manifest::from_toml_str(
        &std::fs::read_to_string(manifest_path).expect("read mock manifest"),
    )
    .expect("mock manifest valid");
    manifest.session.self_.as_mut().unwrap().command = format!(
        "\"{}\" --id {{id}} --session-id {{session_id}} --spt \"{}\"",
        mock_bin.display(),
        spt_bin.display(),
    );

    let runtime = ManifestRuntime::new(manifest);
    let id = "mock-e2e";
    let session_id = "sid-e2e-1";

    // Outbound: spawn the session. Bounded (not detached) so the test waits for
    // the helper's bind + state-idle to complete before asserting.
    let out = runtime
        .run_bounded("self", &keys(&[("id", id), ("session_id", session_id)]), Duration::from_secs(30))
        .expect("spawn mock session");
    assert!(
        out.success(),
        "mock-session failed: code={:?}\nstdout={}\nstderr={}",
        out.status_code, out.stdout, out.stderr
    );

    // Inbound assertion 1 — `api bind` flipped the perch live (REQ-START-2 /
    // REQ-SEAM-POSTSPAWN): info.json exists with the reported session id.
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    let rec = spt_store::info::read_info(&perch_path).expect("perch info.json after bind");
    assert_eq!(rec.session_id, session_id);
    assert_eq!(rec.state, "live_agent");

    // Inbound assertion 2 — `api state idle` wrote the activity sentinel.
    assert!(perch_path.join(".idle").exists(), "state idle must write the .idle sentinel");

    // A peer spools a message to the now-live perch.
    spool::spool_message_at(&perch_path, "tester", "hello over the contract").unwrap();

    // Inbound assertion 3 — `api poll` drains it (the full delivery round-trip).
    let poll = Command::new(&spt_bin)
        .args(["api", "--adapter", "mock", "poll", id, "--session-id", session_id])
        .env("SPT_HOME", home.path())
        .output()
        .expect("run api poll");
    assert!(poll.status.success(), "api poll exit: {:?}", poll.status);
    let stdout = String::from_utf8_lossy(&poll.stdout);
    assert!(
        stdout.contains("hello over the contract"),
        "api poll must deliver the spooled message, got: {stdout:?}"
    );

    // Auth negative — a poll with no proof is refused (REQ-HAZARD-LOCAL-API-AUTH
    // exercised through the real binary).
    let unauth = Command::new(&spt_bin)
        .args(["api", "--adapter", "mock", "poll", id])
        .env("SPT_HOME", home.path())
        .env_remove("OWL_SESSION_ID")
        .output()
        .expect("run unauth poll");
    assert!(!unauth.status.success(), "poll without proof must be refused");
}


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

//! M2a contract E2E (T9): the inbound + outbound halves of the harness contract
//! proven end-to-end through the **generic mock adapter** — no vendor harness.
//!
//! Flow: load the mock manifest → `ManifestRuntime` spawns the `mock-session`
//! helper → the helper fires `spt api bind` (skeleton→live) then
//! `spt api state idle` → a message is spooled to the perch → `spt api poll`
//! drains it. Asserts the round-trip.
//
// [int->REQ-API-1]
// [int->REQ-API-2]
// [int->REQ-START-2]

use std::path::PathBuf;
use std::process::Command;
use std::time::Duration;

use spt_runtime::{Manifest, ManifestRuntime, AgentRuntime};
use spt_store::perch::{self, ParentHint};
use spt_store::spool;

/// Absolute path to a sibling binary in the same target dir as the `spt` test bin.
fn sibling_bin(name: &str) -> PathBuf {
    let spt = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
    spt.with_file_name(format!("{name}{}", std::env::consts::EXE_SUFFIX))
}

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

#[test]
fn mock_adapter_drives_the_full_contract() {
    // Isolated home shared by this process *and* the child `spt` invocations
    // (inherited via env). This test owns the process, so a plain set_var is safe.
    let home = tempfile::tempdir().unwrap();
    std::env::set_var("SPT_HOME", home.path());

    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
    let mock_bin = sibling_bin("mock-session");
    assert!(mock_bin.exists(), "mock-session must be built: {}", mock_bin.display());

    // Load the canonical mock manifest, then point its [session.self] template at
    // the freshly-built helper + spt binary (PATH isn't set up in the test rig).
    let manifest_path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../adapters/mock/manifest.toml");
    let mut manifest = Manifest::from_toml_str(
        &std::fs::read_to_string(manifest_path).expect("read mock manifest"),
    )
    .expect("mock manifest valid");
    manifest.session.self_.as_mut().unwrap().command = format!(
        "\"{}\" --id {{id}} --session-id {{session_id}} --spt \"{}\"",
        mock_bin.display(),
        spt_bin.display(),
    );

    let runtime = ManifestRuntime::new(manifest);
    let id = "mock-e2e";
    let session_id = "sid-e2e-1";

    // Outbound: spawn the session. Bounded (not detached) so the test waits for
    // the helper's bind + state-idle to complete before asserting.
    let out = runtime
        .run_bounded("self", &keys(&[("id", id), ("session_id", session_id)]), Duration::from_secs(30))
        .expect("spawn mock session");
    assert!(
        out.success(),
        "mock-session failed: code={:?}\nstdout={}\nstderr={}",
        out.status_code, out.stdout, out.stderr
    );

    // Inbound assertion 1 — `api bind` flipped the perch live (REQ-START-2 /
    // REQ-SEAM-POSTSPAWN): info.json exists with the reported session id.
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    let rec = spt_store::info::read_info(&perch_path).expect("perch info.json after bind");
    assert_eq!(rec.session_id, session_id);
    assert_eq!(rec.state, "live_agent");

    // Inbound assertion 2 — `api state idle` wrote the activity sentinel.
    assert!(perch_path.join(".idle").exists(), "state idle must write the .idle sentinel");

    // A peer spools a message to the now-live perch.
    spool::spool_message_at(&perch_path, "tester", "hello over the contract").unwrap();

    // Inbound assertion 3 — `api poll` drains it (the full delivery round-trip).
    let poll = Command::new(&spt_bin)
        .args(["api", "--adapter", "mock", "poll", id, "--session-id", session_id])
        .env("SPT_HOME", home.path())
        .output()
        .expect("run api poll");
    assert!(poll.status.success(), "api poll exit: {:?}", poll.status);
    let stdout = String::from_utf8_lossy(&poll.stdout);
    assert!(
        stdout.contains("hello over the contract"),
        "api poll must deliver the spooled message, got: {stdout:?}"
    );

    // Auth negative — a poll with no proof is refused (REQ-HAZARD-LOCAL-API-AUTH
    // exercised through the real binary).
    let unauth = Command::new(&spt_bin)
        .args(["api", "--adapter", "mock", "poll", id])
        .env("SPT_HOME", home.path())
        .env_remove("OWL_SESSION_ID")
        .output()
        .expect("run unauth poll");
    assert!(!unauth.status.success(), "poll without proof must be refused");
}


Bash

cd "C:\Users\decid\Documents\projects\spt-core" && cargo build -p spt -p mock-adapter 2>&1 | tail -3 && cargo test -p spt --test contract_e2e 2>&1 | tail -25

Build bins + run E2E


warning: `spt` (bin "spt") generated 5 warnings
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.41s
warning: variant `NoParentPid` is never constructed
  --> crates\spt\src\api\startup.rs:69:5
   |
68 | pub enum BindError {
   |          --------- variant in this enum
69 |     NoParentPid,
   |     ^^^^^^^^^^^
   |
   = note: `BindError` has a derived impl for the trait `Debug`, but this is intentionally ignored during dead code analysis

warning: function `has_live_children` is never used
   --> crates\spt\src\api\worker.rs:110:8
    |
110 | pub fn has_live_children(parent_id: &str) -> bool {
    |        ^^^^^^^^^^^^^^^^^

warning: `spt` (bin "spt") generated 5 warnings
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.48s
     Running tests\contract_e2e.rs (target\debug\deps\contract_e2e-0e9cfdca991b371f.exe)

running 1 test
test mock_adapter_drives_the_full_contract ... ok

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


E2E passes! Clean up the dead-code warnings (CI may deny warnings). Check CI policy.


cd "C:\Users\decid\Documents\projects\spt-core" && grep -rn "RUSTFLAGS\|-D warnings\|deny\|clippy" .github/workflows/ 2>/dev/null | head; echo "---warns---"; cargo build -p spt 2>&1 | grep "never\|warning:" | head

Check CI warning policy


.github/workflows/ci.yml:24:      - name: Install Rust (stable + clippy)
.github/workflows/ci.yml:27:          components: clippy
.github/workflows/ci.yml:34:      - name: Clippy (deny warnings)
.github/workflows/ci.yml:35:        run: cargo clippy --workspace --all-targets -- -D warnings
---warns---
warning: function `echo_gate_armed` is never used
warning: function `resolve_filedrops` is never used
warning: function `env_injections` is never used
warning: variant `NoParentPid` is never constructed
warning: function `has_live_children` is never used
warning: `spt` (bin "spt") generated 5 warnings


CI runs `clippy -D warnings`. The unused ones are forward-looking/test-only APIs. Remove the truly redundant, `allow(dead_code)` the deliberate forward APIs.


Edit

#[derive(Debug, PartialEq, Eq)]
pub enum BindError {
    NoParentPid,
    NoSeed(u32),

#[derive(Debug, PartialEq, Eq)]
pub enum BindError {
    NoSeed(u32),


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

#[derive(Debug, PartialEq, Eq)]
pub enum BindError {
    NoParentPid,
    NoSeed(u32),

#[derive(Debug, PartialEq, Eq)]
pub enum BindError {
    NoSeed(u32),

/// a stale/recycled seed never silently binds the wrong session.

/// The seed's anchor process is dead → recycled/abandoned (5.1).


    match e {
        BindError::NoParentPid => eprintln!("NO_PARENT_PID: could not resolve the parent pid"),
        BindError::NoSeed(p) => eprintln!("NO_SEED:{p} (no seed for this parent pid)"),

    match e {
        BindError::NoSeed(p) => eprintln!("NO_SEED:{p} (no seed for this parent pid)"),


    match e {
        BindError::NoParentPid => eprintln!("NO_PARENT_PID: could not resolve the parent pid"),
        BindError::NoSeed(p) => eprintln!("NO_SEED:{p} (no seed for this parent pid)"),

    match e {
        BindError::NoSeed(p) => eprintln!("NO_SEED:{p} (no seed for this parent pid)"),

-        BindError::NoParentPid => eprintln!("NO_PARENT_PID: could not resolve the parent pid"),

BindError::StaleSeed(p) => eprintln!("STALE_SEED:{p} (anchor process is dead)"),

BindError::EmptySession => eprintln!("EMPTY_SESSION: seed carried no session id"),


/// True if `parent_id` hosts at least one live nested child.
// [impl->REQ-HAZARD-CASCADE-WIPE-GUARD]
pub fn has_live_children(parent_id: &str) -> bool {
    !live_child_paths(parent_id).is_empty()
}

fn now_stamp() -> String {


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

//! Worker perches: `api worker-start` / `worker-stop` / `worker-poll`, plus the
//! **cascade-wipe guard** (KNOWN-HAZARDS 6.3).
//!
//! A worker is a nested child perch under a parent agent
//! (`owlery/<parent>/nested/<worker>/`), addressable like any perch and routed
//! through the *single* path resolver (REQ-HAZARD-SINGLE-PATH-SOURCE / WORKER-
//! PATH — no divergent sibling layouts).
//!
//! ## Cascade-wipe guard (6.3, REQ-HAZARD-CASCADE-WIPE-GUARD)
//! A destructive parent teardown must never hard-delete a parent that still
//! hosts **live** children. "Live" here = the child still carries its `ready`
//! marker (the soft-cleanup signal; `worker-stop` removes it). When live
//! children exist, the caller soft-cleans and surfaces their paths instead of
//! recursively deleting in-flight work. [`has_live_children`] /
//! [`live_child_paths`] are the guard primitives T7's `session-end` consults.
//
// [impl->REQ-API-2]
// [impl->REQ-HAZARD-CASCADE-WIPE-GUARD]

use spt_store::info::{self, InfoJson};
use spt_store::perch::{self, ParentHint};
use spt_store::spool;

use super::auth;
use super::EXIT_REFUSED;

/// `api worker-start <parent> <id>` — create a nested worker perch under
/// `parent`. Resolves with an Explicit parent so the layout is unambiguous even
/// if the id's own suffix would infer a different one.
// [impl->REQ-API-2]
pub fn cmd_worker_start(parent: &str, id: &str) -> i32 {
    let perch_path = perch::resolve_perch_path(id, ParentHint::Explicit(parent));
    if let Err(e) = std::fs::create_dir_all(&perch_path) {
        eprintln!("WORKER_START_FAIL:{id}: {e}");
        return EXIT_REFUSED;
    }
    let rec = InfoJson::new(id, &now_stamp(), std::process::id(), "", "worker");
    if let Err(e) = info::write_info(&perch_path, &rec) {
        eprintln!("WORKER_START_FAIL:{id}: {e}");
        return EXIT_REFUSED;
    }
    // ready marker = the live-child signal the cascade-wipe guard checks.
    let _ = std::fs::write(
        perch::resolve_ready_file(id, ParentHint::Explicit(parent)),
        "",
    );
    let token = auth::mint_token();
    let _ = auth::write_token(&perch_path, &token);
    eprintln!("WORKER_STARTED:{id} under {parent} token={token}");
    0
}

/// `api worker-stop <id>` — soft-clean a worker (REQ-HAZARD-SOFT-CLEANUP):
/// remove its `ready` marker so it no longer counts as a live child; the
/// `info.json` + spool are preserved. Idempotent.
// [impl->REQ-API-2]
pub fn cmd_worker_stop(id: &str) -> i32 {
    let removed = std::fs::remove_file(perch::resolve_ready_file(id, ParentHint::Infer)).is_ok();
    if removed {
        eprintln!("WORKER_STOPPED:{id}");
    } else {
        eprintln!("WORKER_STOPPED:{id} (no ready marker)");
    }
    0
}

/// `api worker-poll <id>` — drain a worker perch's spool (oldest first), framed
/// to stdout. Non-deferred only unless `include_deferred`.
// [impl->REQ-API-2]
pub fn cmd_worker_poll(id: &str, include_deferred: bool) -> i32 {
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    let drained = if include_deferred {
        spool::drain_all_at(&perch_path)
    } else {
        spool::drain_non_deferred_at(&perch_path)
    };
    for msg in drained.unwrap_or_default() {
        use std::io::Write;
        print!("{msg}");
        let _ = std::io::stdout().flush();
    }
    0
}

/// The paths of `parent_id`'s **live** nested children (those still carrying a
/// `ready` marker). Empty ⇒ the parent is safe to hard-delete.
// [impl->REQ-HAZARD-CASCADE-WIPE-GUARD]
pub fn live_child_paths(parent_id: &str) -> Vec<std::path::PathBuf> {
    let nested = perch::resolve_perch_path(parent_id, ParentHint::Infer).join("nested");
    let mut live = Vec::new();
    let Ok(entries) = std::fs::read_dir(&nested) else {
        return live;
    };
    for entry in entries.flatten() {
        let child = entry.path();
        if !child.is_dir() {
            continue;
        }
        // A child is live while its ready marker exists (info.json present =
        // it's a real perch; ready = not yet soft-cleaned).
        if info::read_info(&child).is_some() && child.join("ready").exists() {
            live.push(child);
        }
    }
    live
}

/// True if `parent_id` hosts at least one live nested child.
// [impl->REQ-HAZARD-CASCADE-WIPE-GUARD]
pub fn has_live_children(parent_id: &str) -> bool {
    !live_child_paths(parent_id).is_empty()
}

fn now_stamp() -> String {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs().to_string())
        .unwrap_or_else(|_| "0".to_string())
}

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

    fn establish_parent(id: &str) {
        let path = perch::resolve_perch_path(id, ParentHint::Infer);
        std::fs::create_dir_all(&path).unwrap();
        info::write_info(
            &path,
            &InfoJson::new(id, "0", std::process::id(), "sid", "live_agent"),
        )
        .unwrap();
    }

    #[test] // [unit->REQ-API-2] a worker nests under its parent and is addressable.
    fn worker_start_nests_under_parent() {
        let _h = isolated_home();
        establish_parent("alice");
        assert_eq!(cmd_worker_start("alice", "alice-w1"), 0);

        let expected = perch::resolve_perch_path("alice", ParentHint::Infer)
            .join("nested")
            .join("alice-w1");
        assert!(expected.join("info.json").exists());
        assert!(expected.join("ready").exists());
        // Inferred resolution agrees with the explicit nesting.
        assert_eq!(perch::resolve_perch_path("alice-w1", ParentHint::Infer), expected);
    }

    #[test] // [unit->REQ-HAZARD-CASCADE-WIPE-GUARD] a parent with a live worker
    // reports children; stopping the worker clears the guard.
    fn cascade_guard_tracks_live_children() {
        let _h = isolated_home();
        establish_parent("alice");
        assert!(!has_live_children("alice"), "no children yet");

        cmd_worker_start("alice", "alice-w1");
        assert!(has_live_children("alice"), "live worker must be seen");
        assert_eq!(live_child_paths("alice").len(), 1);

        // Soft-stop the worker → ready marker gone → no longer a live child.
        assert_eq!(cmd_worker_stop("alice-w1"), 0);
        assert!(!has_live_children("alice"), "soft-stopped worker is not live");
        // The record is preserved (soft cleanup), only the ready marker removed.
        let child = perch::resolve_perch_path("alice-w1", ParentHint::Infer);
        assert!(child.join("info.json").exists());
        assert!(!child.join("ready").exists());
    }

    #[test] // [unit->REQ-API-2] worker-poll drains the worker's own spool.
    fn worker_poll_drains_spool() {
        let _h = isolated_home();
        establish_parent("alice");
        cmd_worker_start("alice", "alice-w1");
        let child = perch::resolve_perch_path("alice-w1", ParentHint::Infer);
        spool::spool_message_at(&child, "alice", "task result").unwrap();

        let perch_path = perch::resolve_perch_path("alice-w1", ParentHint::Infer);
        let got = spool::drain_non_deferred_at(&perch_path).unwrap();
        assert_eq!(got.len(), 1);
        // And the command path returns 0.
        cmd_worker_start("alice", "alice-w2");
        assert_eq!(cmd_worker_poll("alice-w2", false), 0);
    }

    #[test] // [unit->REQ-HAZARD-CASCADE-WIPE-GUARD] two workers: guard holds until
    // the last live child is stopped.
    fn guard_clears_only_when_all_children_stopped() {
        let _h = isolated_home();
        establish_parent("alice");
        cmd_worker_start("alice", "alice-w1");
        cmd_worker_start("alice", "alice-w2");
        assert_eq!(live_child_paths("alice").len(), 2);
        cmd_worker_stop("alice-w1");
        assert!(has_live_children("alice"), "one child still live");
        cmd_worker_stop("alice-w2");
        assert!(!has_live_children("alice"));
    }
}

-/// True if `parent_id` hosts at least one live nested child.

-pub fn has_live_children(parent_id: &str) -> bool {

-    !live_child_paths(parent_id).is_empty()


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


Update worker tests to use `live_child_paths`, and allow the forward APIs.


Bash

cd "C:\Users\decid\Documents\projects\spt-core" && perl -0pi -e 's/assert!\(!has_live_children\("alice"\), ("[^"]*")\)/assert!(live_child_paths("alice").is_empty(), $1)/g; s/assert!\(has_live_children\("alice"\), ("[^"]*")\)/assert!(!live_child_paths("alice").is_empty(), $1)/g' crates/spt/src/api/worker.rs && grep -n "has_live_children\|live_child_paths(\"alice\")" crates/spt/src/api/worker.rs

Swap has_live_children to live_child_paths in worker tests


14://! recursively deleting in-flight work. [`has_live_children`] /
150:        assert!(live_child_paths("alice").is_empty(), "no children yet");
153:        assert!(!live_child_paths("alice").is_empty(), "live worker must be seen");
154:        assert_eq!(live_child_paths("alice").len(), 1);
158:        assert!(live_child_paths("alice").is_empty(), "soft-stopped worker is not live");
188:        assert_eq!(live_child_paths("alice").len(), 2);
190:        assert!(!live_child_paths("alice").is_empty(), "one child still live");
192:        assert!(!has_live_children("alice"));