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

//! claude-spt — consolidated tool binary for the claude-spt adapter (ADR-0006, U2).
//!
//! ONE binary, three subcommands (was three crates → fewer artifacts per triple in adapter.spt):
//!   claude-spt digest      — the [digest] extractor (was claude-spt-digest)
//!   claude-spt psyche      — the [session.psyche_init] Psyche runner (was claude-spt-psyche)
//!   claude-spt post-update — reconcile the cplugs plugin after `spt adapter update` (NEW, ADR-0006)
//!
//! Dispatch is a bare `argv[1]` match (no clap — keep the dependency-light ethos both predecessor
//! crates were built on). Each subcommand owns its remaining argv via `std::env::args().skip(2)`
//! (skip the binary name + the subcommand token) and its own hand-written flag parser.
//!
//! `cc-spt-idle-translate` stays a SEPARATE binary this milestone — it folds into a `claude-spt
//! translate` subcommand only once doyle ask #3 lets `[message-idle-translation-binary]` take a
//! command/subcommand rather than a bare `path` (D3). [impl->REQ-DIST-BINARY-CONSOLIDATE]

mod digest;
mod post_update;
mod psyche;

use std::process::ExitCode;

/// The resolved subcommand. Pure classification of `argv[1]` so dispatch routing is unit-testable
/// without spawning the binary. [impl->REQ-DIST-BINARY-CONSOLIDATE]
#[derive(Debug, PartialEq)]
enum Sub {
    Digest,
    Psyche,
    PostUpdate,
    Help,
    Unknown(String),
}

fn classify(sub: Option<&str>) -> Sub {
    match sub {
        Some("digest") => Sub::Digest,
        Some("psyche") => Sub::Psyche,
        Some("post-update") => Sub::PostUpdate,
        None | Some("-h") | Some("--help") => Sub::Help,
        Some(other) => Sub::Unknown(other.to_string()),
    }
}

fn usage() {
    eprintln!(
        "claude-spt <subcommand> [args]\n\
         \n\
         subcommands:\n\
         \x20 digest       map a Claude Code JSONL transcript to digest NDJSON ([digest] extractor)\n\
         \x20 psyche       run the LiveAgent Psyche companion ([session.psyche_init] runner)\n\
         \x20 post-update  reconcile the cplugs plugin after `spt adapter update`"
    );
}

fn main() -> ExitCode {
    let sub = std::env::args().nth(1);
    match classify(sub.as_deref()) {
        Sub::Digest => digest::run(),
        Sub::Psyche => psyche::run(),
        Sub::PostUpdate => post_update::run(),
        Sub::Help => {
            usage();
            ExitCode::SUCCESS
        }
        Sub::Unknown(other) => {
            eprintln!("claude-spt: unknown subcommand: {other}");
            usage();
            ExitCode::from(2)
        }
    }
}

// [unit->REQ-DIST-BINARY-CONSOLIDATE]
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn classifies_each_real_subcommand() {
        assert_eq!(classify(Some("digest")), Sub::Digest);
        assert_eq!(classify(Some("psyche")), Sub::Psyche);
        assert_eq!(classify(Some("post-update")), Sub::PostUpdate);
    }

    #[test]
    fn no_subcommand_and_help_flags_are_help() {
        assert_eq!(classify(None), Sub::Help);
        assert_eq!(classify(Some("-h")), Sub::Help);
        assert_eq!(classify(Some("--help")), Sub::Help);
    }

    #[test]
    fn unrecognized_subcommand_is_unknown_not_misrouted() {
        // A typo must NOT silently fall through to a real subcommand (it exits 2 in main).
        assert_eq!(classify(Some("digset")), Sub::Unknown("digset".into()));
    }
}


Now the digest module (moved verbatim, entry points adjusted for subcommand dispatch + tags preserved):


Write

//! `claude-spt digest` — the `[digest]` session-digest extractor (was the claude-spt-digest crate).
//!
//! Maps Claude Code's native JSONL transcript -> the published digest-record contract: NDJSON, one
//! `{"role","text"?,"tool"?,"ts"?}` object per stdout line. Emits RAW records — spt-core's digest
//! renderer applies the `[digest]` presentation defaults (window_turns / arg_truncation /
//! sprint_collapse), NOT this extractor. `role` is one of `input | agent | tool` (the published enum).
//!
//! Per-session by contract: spt-core's session ledger stitches the per-endpoint digest across
//! `/clear`; this reads exactly ONE session's log (the watch-item doyle flagged 2026-06-15 —
//! single-session slice is correct here, the spanning is ledger-side).
//!
//! Invoked by spt-core:  `claude-spt digest --session <session_id> --in <source>`
//!   `<source>` is CC's per-project ROOT (`~/.claude/projects`); the session file lives at
//!   `<source>/<cwd-slug>/<session_id>.jsonl`. The cwd-slug subdir is a CC-internal encoding not
//!   expressible as a flat template, so we LOCATE `<session_id>.jsonl` by search under `<source>`.
//!   `spt adapter digest-proof --sample <file>` points `--in` straight at a log file; both shapes
//!   are handled (file -> read directly; dir -> locate the session within).
//!
//! ccs profile (REQ-CCS-PROFILES): a ccs-launched session relocates CC's whole state tree —
//! including `projects/` — under `$CLAUDE_CONFIG_DIR` (e.g. `~/.ccs/instances/<account>/.claude`).
//! That value is set by ccs at runtime per-account and is NOT expressible as a static manifest path,
//! so the `claude-spt:ccs` overlay carries no `[digest]` leaf — instead the dir-locate branch here
//! prefers `$CLAUDE_CONFIG_DIR/projects` over the `--in` root. Mirrors the known-good sister project
//! claude_skill_owl (owlery::claude_projects_root). The `--sample` file path is never overridden.
//!
//! Mapping (CC event -> digest record):
//!   type=user,  message.content str         -> {role:input, text}              (a real user prompt)
//!   type=user,  content[].type=text         -> {role:input, text}
//!   type=assistant, content[].type=text     -> {role:agent, text}
//!   type=assistant, content[].type=tool_use -> {role:tool,  tool:{name,arg}}
//!   thinking / tool_result / attachment / queue-operation / last-prompt -> skipped (not emitted)
//! [impl->REQ-DIST-DIGEST-EXTRACTOR]

use serde_json::{json, Value};
use std::fs::{self, File};
use std::io::{self, BufRead, BufReader, BufWriter, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;

/// Single salient arg per tool call (spt-core truncates to arg_truncation). Preference order picks
/// the most digest-meaningful field; falls back to the first scalar, then compact JSON of the input.
const ARG_KEYS: &[&str] = &[
    "file_path", "path", "notebook_path", "command", "pattern", "query", "url", "prompt",
    "old_string", "description", "subagent_type",
];

fn scalar_str(v: &Value) -> Option<String> {
    match v {
        Value::String(s) => Some(s.clone()),
        Value::Number(n) => Some(n.to_string()),
        Value::Bool(b) => Some(b.to_string()),
        _ => None,
    }
}

fn tool_arg(input: &Value) -> String {
    let obj = match input.as_object() {
        Some(o) => o,
        None => return String::new(),
    };
    for k in ARG_KEYS {
        if let Some(s) = obj.get(*k).and_then(scalar_str) {
            if !s.is_empty() {
                return s;
            }
        }
    }
    for v in obj.values() {
        if let Some(s) = scalar_str(v) {
            if !s.is_empty() {
                return s;
            }
        }
    }
    serde_json::to_string(input).unwrap_or_default()
}

fn rec(role: &str, text: Option<&str>, tool: Option<Value>, ts: Option<&str>) -> Value {
    let mut m = serde_json::Map::new();
    m.insert("role".into(), json!(role));
    if let Some(t) = text {
        m.insert("text".into(), json!(t));
    }
    if let Some(t) = tool {
        m.insert("tool".into(), t);
    }
    if let Some(t) = ts {
        if !t.is_empty() {
            m.insert("ts".into(), json!(t));
        }
    }
    Value::Object(m)
}

/// Read CC JSONL from `reader`, write digest NDJSON to `writer`. Pure over byte streams so it is
/// directly unit-testable without touching the filesystem.
fn extract<R: BufRead, W: Write>(reader: R, mut writer: W) -> io::Result<()> {
    for line in reader.lines() {
        let line = line?;
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        let o: Value = match serde_json::from_str(line) {
            Ok(v) => v,
            Err(_) => continue, // CC's log is well-formed; a stray line is not ours to emit
        };
        let typ = o.get("type").and_then(Value::as_str).unwrap_or("");
        let ts = o.get("timestamp").and_then(Value::as_str);
        let msg = match o.get("message").and_then(Value::as_object) {
            Some(m) => m,
            None => continue,
        };
        let role = msg.get("role").and_then(Value::as_str).unwrap_or("");
        let content = msg.get("content");

        if typ == "user" && role == "user" {
            // A plain prompt is a string. List-content user turns are tool_results -> skip those.
            match content {
                Some(Value::String(s)) if !s.is_empty() => {
                    writeln!(writer, "{}", rec("input", Some(s), None, ts))?;
                }
                Some(Value::Array(items)) => {
                    for it in items {
                        if it.get("type").and_then(Value::as_str) == Some("text") {
                            if let Some(t) = it.get("text").and_then(Value::as_str) {
                                if !t.is_empty() {
                                    writeln!(writer, "{}", rec("input", Some(t), None, ts))?;
                                }
                            }
                        }
                    }
                }
                _ => {}
            }
        } else if typ == "assistant" && role == "assistant" {
            if let Some(Value::Array(items)) = content {
                for it in items {
                    match it.get("type").and_then(Value::as_str) {
                        Some("text") => {
                            if let Some(t) = it.get("text").and_then(Value::as_str) {
                                if !t.is_empty() {
                                    writeln!(writer, "{}", rec("agent", Some(t), None, ts))?;
                                }
                            }
                        }
                        Some("tool_use") => {
                            let name = it.get("name").and_then(Value::as_str).unwrap_or("");
                            let arg = it.get("input").map(tool_arg).unwrap_or_default();
                            let tool = json!({"name": name, "arg": arg});
                            writeln!(writer, "{}", rec("tool", None, Some(tool), ts))?;
                        }
                        _ => {} // thinking, etc. -> skip
                    }
                }
            }
        }
        // other event types -> skip
    }
    writer.flush()
}

/// Locate `<session>.jsonl` under `root` (depth-1 guess first, then a bounded recursive sweep —
/// the cwd-slug subdir is CC-internal so the exact path is not known a priori).
fn locate(root: &Path, session: &str) -> Option<PathBuf> {
    let target = format!("{session}.jsonl");
    // Depth-1: root/<slug>/<session>.jsonl
    if let Ok(entries) = fs::read_dir(root) {
        for e in entries.flatten() {
            let p = e.path();
            if p.is_dir() {
                let cand = p.join(&target);
                if cand.is_file() {
                    return Some(cand);
                }
            }
        }
    }
    // Bounded recursive fallback.
    find_recursive(root, &target, 6)
}

fn find_recursive(dir: &Path, target: &str, depth: usize) -> Option<PathBuf> {
    if depth == 0 {
        return None;
    }
    let entries = fs::read_dir(dir).ok()?;
    for e in entries.flatten() {
        let p = e.path();
        if p.is_file() {
            if p.file_name().and_then(|n| n.to_str()) == Some(target) {
                return Some(p);
            }
        } else if p.is_dir() {
            if let Some(hit) = find_recursive(&p, target, depth - 1) {
                return Some(hit);
            }
        }
    }
    None
}

struct Args {
    session: Option<String>,
    source: Option<String>,
}

/// Parse `--session <id> --in <source>` from the subcommand's argv (main already skipped the binary
/// name + the `digest` token). Order-independent single-value flags.
fn parse_args<I: IntoIterator<Item = String>>(argv: I) -> Result<Args, String> {
    let mut session = None;
    let mut source = None;
    let mut it = argv.into_iter();
    while let Some(a) = it.next() {
        match a.as_str() {
            "--session" => session = it.next(),
            "--in" => source = it.next(),
            "-h" | "--help" => {
                println!("claude-spt digest --session <id> --in <projects-root-or-logfile>");
                std::process::exit(0);
            }
            other => return Err(format!("unknown arg: {other}")),
        }
    }
    Ok(Args { session, source })
}

/// Expand a leading `~/` (or bare `~`) to the user's home. spt-core resolves `~` in the `source`
/// template, but the extractor expands defensively too — it runs argv-direct (no shell), so a
/// literal `~` would otherwise become a bogus relative path.
fn expand_tilde(p: &str) -> PathBuf {
    if p == "~" || p.starts_with("~/") || p.starts_with("~\\") {
        if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
            let rest = p[1..].trim_start_matches(['/', '\\']);
            return Path::new(&home).join(rest);
        }
    }
    PathBuf::from(p)
}

/// ccs (and any CLAUDE_CONFIG_DIR-relocating launcher) move CC's state tree — including the
/// `projects/` transcript root — under `$CLAUDE_CONFIG_DIR`. Returns `$CLAUDE_CONFIG_DIR/projects`
/// when `cfg` is `Some` and non-empty, else `None` (caller falls back to the `--in` root). Pure over
/// its input so the env read stays at the edge and this is unit-testable without env races. Mirrors
/// the known-good claude_skill_owl resolver (owlery::claude_projects_root). [impl->REQ-CCS-PROFILES]
fn ccs_projects_root_from(cfg: Option<&str>) -> Option<PathBuf> {
    match cfg {
        Some(c) if !c.is_empty() => Some(PathBuf::from(c).join("projects")),
        _ => None,
    }
}

fn execute<I: IntoIterator<Item = String>>(argv: I) -> Result<(), String> {
    let args = parse_args(argv)?;
    let source = args.source.ok_or("missing required --in")?;
    let src = expand_tilde(&source);

    // --in may be a direct log FILE (digest-proof --sample) -> read it as-is, no env override.
    let path: Option<PathBuf> = if src.is_file() {
        Some(src)
    } else {
        // Directory ("projects root") branch. Honor a ccs-relocated config tree: prefer
        // $CLAUDE_CONFIG_DIR/projects over the manifest `--in` root (REQ-CCS-PROFILES).
        let env_cfg = std::env::var("CLAUDE_CONFIG_DIR").ok();
        let root = ccs_projects_root_from(env_cfg.as_deref()).unwrap_or(src);
        if root.is_dir() {
            let session = args.session.ok_or("--in is a directory but no --session to locate")?;
            locate(&root, &session)
        } else {
            None
        }
    };

    let path = match path {
        Some(p) => p,
        None => return Ok(()), // no log -> empty digest (not an error; spt-core reports the empty)
    };

    let file = File::open(&path).map_err(|e| format!("open {}: {e}", path.display()))?;
    let stdout = io::stdout();
    let writer = BufWriter::new(stdout.lock());
    extract(BufReader::new(file), writer).map_err(|e| e.to_string())
}

/// `claude-spt digest` entry. Reads its own argv (binary name + `digest` token already consumed).
pub fn run() -> ExitCode {
    match execute(std::env::args().skip(2)) {
        Ok(()) => ExitCode::SUCCESS,
        Err(e) => {
            eprintln!("claude-spt digest: {e}");
            ExitCode::from(2)
        }
    }
}

// [unit->REQ-DIST-DIGEST-EXTRACTOR]
#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::Value;

    fn run_extract(input: &str) -> Vec<Value> {
        let mut out = Vec::new();
        extract(input.as_bytes(), &mut out).unwrap();
        String::from_utf8(out)
            .unwrap()
            .lines()
            .filter(|l| !l.trim().is_empty())
            .map(|l| serde_json::from_str(l).expect("each output line is valid JSON"))
            .collect()
    }

    #[test]
    fn user_string_is_input() {
        let recs = run_extract(
            r#"{"type":"user","timestamp":"2026-06-15T10:00:00Z","message":{"role":"user","content":"hello"}}"#,
        );
        assert_eq!(recs.len(), 1);
        assert_eq!(recs[0]["role"], "input");
        assert_eq!(recs[0]["text"], "hello");
        assert_eq!(recs[0]["ts"], "2026-06-15T10:00:00Z");
    }

    #[test]
    fn assistant_text_is_agent_thinking_skipped() {
        let recs = run_extract(
            r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","thinking":"x"},{"type":"text","text":"hi"}]}}"#,
        );
        assert_eq!(recs.len(), 1, "thinking dropped, text kept");
        assert_eq!(recs[0]["role"], "agent");
        assert_eq!(recs[0]["text"], "hi");
    }

    #[test]
    fn tool_use_maps_name_and_arg() {
        let recs = run_extract(
            r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Write","input":{"file_path":"src/a.rs","content":"fn main(){}"}}]}}"#,
        );
        assert_eq!(recs.len(), 1);
        assert_eq!(recs[0]["role"], "tool");
        assert_eq!(recs[0]["tool"]["name"], "Write");
        assert_eq!(recs[0]["tool"]["arg"], "src/a.rs"); // file_path preferred over content
    }

    #[test]
    fn tool_arg_prefers_command_then_first_scalar() {
        let recs = run_extract(
            r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash","input":{"command":"cargo build","description":"d"}}]}}"#,
        );
        assert_eq!(recs[0]["tool"]["arg"], "cargo build");
    }

    #[test]
    fn user_tool_result_is_skipped() {
        let recs = run_extract(
            r#"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"ok"}]}}"#,
        );
        assert!(recs.is_empty(), "tool_result has no contract role");
    }

    #[test]
    fn noise_types_and_bad_lines_skipped() {
        let input = concat!(
            r#"{"type":"attachment","attachment":{}}"#,
            "\n",
            r#"{"type":"last-prompt","lastPrompt":"x"}"#,
            "\n",
            "this is not json\n",
            "\n",
            r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"only this"}]}}"#,
        );
        let recs = run_extract(input);
        assert_eq!(recs.len(), 1);
        assert_eq!(recs[0]["text"], "only this");
    }

    #[test]
    fn non_ascii_round_trips_utf8() {
        let recs = run_extract(
            r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"done — built clean"}]}}"#,
        );
        assert_eq!(recs[0]["text"], "done — built clean");
    }

    #[test]
    fn tilde_expands_to_home() {
        std::env::set_var("HOME", "/home/test");
        assert_eq!(expand_tilde("~/.claude/projects"), Path::new("/home/test").join(".claude/projects"));
        assert_eq!(expand_tilde("/abs/path"), PathBuf::from("/abs/path"));
        assert_eq!(expand_tilde("rel/path"), PathBuf::from("rel/path"));
    }

    // [unit->REQ-CCS-PROFILES] ccs CLAUDE_CONFIG_DIR-aware projects-root resolution. Pure helper so
    // no env mutation / no test races (owl parity: set -> $CFG/projects; unset/empty -> fall back).
    #[test]
    fn ccs_root_honors_config_dir() {
        assert_eq!(
            ccs_projects_root_from(Some("/tmp/ccs-acct/.claude")),
            Some(Path::new("/tmp/ccs-acct/.claude").join("projects"))
        );
    }

    #[test]
    fn ccs_root_none_when_unset() {
        assert_eq!(ccs_projects_root_from(None), None);
    }

    #[test]
    fn ccs_root_ignores_empty() {
        // Empty env var must NOT shadow the --in fallback (owl: `!cfg.is_empty()` guard).
        assert_eq!(ccs_projects_root_from(Some("")), None);
    }

    #[test]
    fn parse_args_reads_session_and_in_order_independent() {
        // The subcommand parser sees argv AFTER `claude-spt digest` is stripped by main.
        let a = parse_args(
            ["--in", "/p/root", "--session", "sess-1"]
                .iter()
                .map(|s| s.to_string()),
        )
        .unwrap();
        assert_eq!(a.session.as_deref(), Some("sess-1"));
        assert_eq!(a.source.as_deref(), Some("/p/root"));
    }

    #[test]
    fn every_record_satisfies_the_contract() {
        // role enum + field-presence invariants across a mixed stream.
        let input = concat!(
            r#"{"type":"user","message":{"role":"user","content":"q"}}"#,
            "\n",
            r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"a"},{"type":"tool_use","name":"Read","input":{"file_path":"f"}}]}}"#,
        );
        for r in run_extract(input) {
            let role = r["role"].as_str().unwrap();
            assert!(matches!(role, "input" | "agent" | "tool"), "role enum: {role}");
            match role {
                "input" | "agent" => assert!(r.get("text").is_some() && r.get("tool").is_none()),
                "tool" => {
                    assert!(r.get("text").is_none());
                    assert!(r["tool"].get("name").is_some() && r["tool"].get("arg").is_some());
                }
                _ => unreachable!(),
            }
        }
    }
}


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

//! `claude-spt digest` — the `[digest]` session-digest extractor (was the claude-spt-digest crate).
//!
//! Maps Claude Code's native JSONL transcript -> the published digest-record contract: NDJSON, one
//! `{"role","text"?,"tool"?,"ts"?}` object per stdout line. Emits RAW records — spt-core's digest
//! renderer applies the `[digest]` presentation defaults (window_turns / arg_truncation /
//! sprint_collapse), NOT this extractor. `role` is one of `input | agent | tool` (the published enum).
//!
//! Per-session by contract: spt-core's session ledger stitches the per-endpoint digest across
//! `/clear`; this reads exactly ONE session's log (the watch-item doyle flagged 2026-06-15 —
//! single-session slice is correct here, the spanning is ledger-side).
//!
//! Invoked by spt-core:  `claude-spt digest --session <session_id> --in <source>`
//!   `<source>` is CC's per-project ROOT (`~/.claude/projects`); the session file lives at
//!   `<source>/<cwd-slug>/<session_id>.jsonl`. The cwd-slug subdir is a CC-internal encoding not
//!   expressible as a flat template, so we LOCATE `<session_id>.jsonl` by search under `<source>`.
//!   `spt adapter digest-proof --sample <file>` points `--in` straight at a log file; both shapes
//!   are handled (file -> read directly; dir -> locate the session within).
//!
//! ccs profile (REQ-CCS-PROFILES): a ccs-launched session relocates CC's whole state tree —
//! including `projects/` — under `$CLAUDE_CONFIG_DIR` (e.g. `~/.ccs/instances/<account>/.claude`).
//! That value is set by ccs at runtime per-account and is NOT expressible as a static manifest path,
//! so the `claude-spt:ccs` overlay carries no `[digest]` leaf — instead the dir-locate branch here
//! prefers `$CLAUDE_CONFIG_DIR/projects` over the `--in` root. Mirrors the known-good sister project
//! claude_skill_owl (owlery::claude_projects_root). The `--sample` file path is never overridden.
//!
//! Mapping (CC event -> digest record):
//!   type=user,  message.content str         -> {role:input, text}              (a real user prompt)
//!   type=user,  content[].type=text         -> {role:input, text}
//!   type=assistant, content[].type=text     -> {role:agent, text}
//!   type=assistant, content[].type=tool_use -> {role:tool,  tool:{name,arg}}
//!   thinking / tool_result / attachment / queue-operation / last-prompt -> skipped (not emitted)
//! [impl->REQ-DIST-DIGEST-EXTRACTOR]

use serde_json::{json, Value};
use std::fs::{self, File};
use std::io::{self, BufRead, BufReader, BufWriter, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;

/// Single salient arg per tool call (spt-core truncates to arg_truncation). Preference order picks
/// the most digest-meaningful field; falls back to the first scalar, then compact JSON of the input.
const ARG_KEYS: &[&str] = &[
    "file_path", "path", "notebook_path", "command", "pattern", "query", "url", "prompt",
    "old_string", "description", "subagent_type",
];

fn scalar_str(v: &Value) -> Option<String> {
    match v {
        Value::String(s) => Some(s.clone()),
        Value::Number(n) => Some(n.to_string()),
        Value::Bool(b) => Some(b.to_string()),
        _ => None,
    }
}

fn tool_arg(input: &Value) -> String {
    let obj = match input.as_object() {
        Some(o) => o,
        None => return String::new(),
    };
    for k in ARG_KEYS {
        if let Some(s) = obj.get(*k).and_then(scalar_str) {
            if !s.is_empty() {
                return s;
            }
        }
    }
    for v in obj.values() {
        if let Some(s) = scalar_str(v) {
            if !s.is_empty() {
                return s;
            }
        }
    }
    serde_json::to_string(input).unwrap_or_default()
}

fn rec(role: &str, text: Option<&str>, tool: Option<Value>, ts: Option<&str>) -> Value {
    let mut m = serde_json::Map::new();
    m.insert("role".into(), json!(role));
    if let Some(t) = text {
        m.insert("text".into(), json!(t));
    }
    if let Some(t) = tool {
        m.insert("tool".into(), t);
    }
    if let Some(t) = ts {
        if !t.is_empty() {
            m.insert("ts".into(), json!(t));
        }
    }
    Value::Object(m)
}

/// Read CC JSONL from `reader`, write digest NDJSON to `writer`. Pure over byte streams so it is
/// directly unit-testable without touching the filesystem.
fn extract<R: BufRead, W: Write>(reader: R, mut writer: W) -> io::Result<()> {
    for line in reader.lines() {
        let line = line?;
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        let o: Value = match serde_json::from_str(line) {
            Ok(v) => v,
            Err(_) => continue, // CC's log is well-formed; a stray line is not ours to emit
        };
        let typ = o.get("type").and_then(Value::as_str).unwrap_or("");
        let ts = o.get("timestamp").and_then(Value::as_str);
        let msg = match o.get("message").and_then(Value::as_object) {
            Some(m) => m,
            None => continue,
        };
        let role = msg.get("role").and_then(Value::as_str).unwrap_or("");
        let content = msg.get("content");

        if typ == "user" && role == "user" {
            // A plain prompt is a string. List-content user turns are tool_results -> skip those.
            match content {
                Some(Value::String(s)) if !s.is_empty() => {
                    writeln!(writer, "{}", rec("input", Some(s), None, ts))?;
                }
                Some(Value::Array(items)) => {
                    for it in items {
                        if it.get("type").and_then(Value::as_str) == Some("text") {
                            if let Some(t) = it.get("text").and_then(Value::as_str) {
                                if !t.is_empty() {
                                    writeln!(writer, "{}", rec("input", Some(t), None, ts))?;
                                }
                            }
                        }
                    }
                }
                _ => {}
            }
        } else if typ == "assistant" && role == "assistant" {
            if let Some(Value::Array(items)) = content {
                for it in items {
                    match it.get("type").and_then(Value::as_str) {
                        Some("text") => {
                            if let Some(t) = it.get("text").and_then(Value::as_str) {
                                if !t.is_empty() {
                                    writeln!(writer, "{}", rec("agent", Some(t), None, ts))?;
                                }
                            }
                        }
                        Some("tool_use") => {
                            let name = it.get("name").and_then(Value::as_str).unwrap_or("");
                            let arg = it.get("input").map(tool_arg).unwrap_or_default();
                            let tool = json!({"name": name, "arg": arg});
                            writeln!(writer, "{}", rec("tool", None, Some(tool), ts))?;
                        }
                        _ => {} // thinking, etc. -> skip
                    }
                }
            }
        }
        // other event types -> skip
    }
    writer.flush()
}

/// Locate `<session>.jsonl` under `root` (depth-1 guess first, then a bounded recursive sweep —
/// the cwd-slug subdir is CC-internal so the exact path is not known a priori).
fn locate(root: &Path, session: &str) -> Option<PathBuf> {
    let target = format!("{session}.jsonl");
    // Depth-1: root/<slug>/<session>.jsonl
    if let Ok(entries) = fs::read_dir(root) {
        for e in entries.flatten() {
            let p = e.path();
            if p.is_dir() {
                let cand = p.join(&target);
                if cand.is_file() {
                    return Some(cand);
                }
            }
        }
    }
    // Bounded recursive fallback.
    find_recursive(root, &target, 6)
}

fn find_recursive(dir: &Path, target: &str, depth: usize) -> Option<PathBuf> {
    if depth == 0 {
        return None;
    }
    let entries = fs::read_dir(dir).ok()?;
    for e in entries.flatten() {
        let p = e.path();
        if p.is_file() {
            if p.file_name().and_then(|n| n.to_str()) == Some(target) {
                return Some(p);
            }
        } else if p.is_dir() {
            if let Some(hit) = find_recursive(&p, target, depth - 1) {
                return Some(hit);
            }
        }
    }
    None
}

struct Args {
    session: Option<String>,
    source: Option<String>,
}

/// Parse `--session <id> --in <source>` from the subcommand's argv (main already skipped the binary
/// name + the `digest` token). Order-independent single-value flags.
fn parse_args<I: IntoIterator<Item = String>>(argv: I) -> Result<Args, String> {
    let mut session = None;
    let mut source = None;
    let mut it = argv.into_iter();
    while let Some(a) = it.next() {
        match a.as_str() {
            "--session" => session = it.next(),
            "--in" => source = it.next(),
            "-h" | "--help" => {
                println!("claude-spt digest --session <id> --in <projects-root-or-logfile>");
                std::process::exit(0);
            }
            other => return Err(format!("unknown arg: {other}")),
        }
    }
    Ok(Args { session, source })
}

/// Expand a leading `~/` (or bare `~`) to the user's home. spt-core resolves `~` in the `source`
/// template, but the extractor expands defensively too — it runs argv-direct (no shell), so a
/// literal `~` would otherwise become a bogus relative path.
fn expand_tilde(p: &str) -> PathBuf {
    if p == "~" || p.starts_with("~/") || p.starts_with("~\\") {
        if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
            let rest = p[1..].trim_start_matches(['/', '\\']);
            return Path::new(&home).join(rest);
        }
    }
    PathBuf::from(p)
}

/// ccs (and any CLAUDE_CONFIG_DIR-relocating launcher) move CC's state tree — including the
/// `projects/` transcript root — under `$CLAUDE_CONFIG_DIR`. Returns `$CLAUDE_CONFIG_DIR/projects`
/// when `cfg` is `Some` and non-empty, else `None` (caller falls back to the `--in` root). Pure over
/// its input so the env read stays at the edge and this is unit-testable without env races. Mirrors
/// the known-good claude_skill_owl resolver (owlery::claude_projects_root). [impl->REQ-CCS-PROFILES]
fn ccs_projects_root_from(cfg: Option<&str>) -> Option<PathBuf> {
    match cfg {
        Some(c) if !c.is_empty() => Some(PathBuf::from(c).join("projects")),
        _ => None,
    }
}

fn execute<I: IntoIterator<Item = String>>(argv: I) -> Result<(), String> {
    let args = parse_args(argv)?;
    let source = args.source.ok_or("missing required --in")?;
    let src = expand_tilde(&source);

    // --in may be a direct log FILE (digest-proof --sample) -> read it as-is, no env override.
    let path: Option<PathBuf> = if src.is_file() {
        Some(src)
    } else {
        // Directory ("projects root") branch. Honor a ccs-relocated config tree: prefer
        // $CLAUDE_CONFIG_DIR/projects over the manifest `--in` root (REQ-CCS-PROFILES).
        let env_cfg = std::env::var("CLAUDE_CONFIG_DIR").ok();
        let root = ccs_projects_root_from(env_cfg.as_deref()).unwrap_or(src);
        if root.is_dir() {
            let session = args.session.ok_or("--in is a directory but no --session to locate")?;
            locate(&root, &session)
        } else {
            None
        }
    };

    let path = match path {
        Some(p) => p,
        None => return Ok(()), // no log -> empty digest (not an error; spt-core reports the empty)
    };

    let file = File::open(&path).map_err(|e| format!("open {}: {e}", path.display()))?;
    let stdout = io::stdout();
    let writer = BufWriter::new(stdout.lock());
    extract(BufReader::new(file), writer).map_err(|e| e.to_string())
}

/// `claude-spt digest` entry. Reads its own argv (binary name + `digest` token already consumed).
pub fn run() -> ExitCode {
    match execute(std::env::args().skip(2)) {
        Ok(()) => ExitCode::SUCCESS,
        Err(e) => {
            eprintln!("claude-spt digest: {e}");
            ExitCode::from(2)
        }
    }
}

// [unit->REQ-DIST-DIGEST-EXTRACTOR]
#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::Value;

    fn run_extract(input: &str) -> Vec<Value> {
        let mut out = Vec::new();
        extract(input.as_bytes(), &mut out).unwrap();
        String::from_utf8(out)
            .unwrap()
            .lines()
            .filter(|l| !l.trim().is_empty())
            .map(|l| serde_json::from_str(l).expect("each output line is valid JSON"))
            .collect()
    }

    #[test]
    fn user_string_is_input() {
        let recs = run_extract(
            r#"{"type":"user","timestamp":"2026-06-15T10:00:00Z","message":{"role":"user","content":"hello"}}"#,
        );
        assert_eq!(recs.len(), 1);
        assert_eq!(recs[0]["role"], "input");
        assert_eq!(recs[0]["text"], "hello");
        assert_eq!(recs[0]["ts"], "2026-06-15T10:00:00Z");
    }

    #[test]
    fn assistant_text_is_agent_thinking_skipped() {
        let recs = run_extract(
            r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","thinking":"x"},{"type":"text","text":"hi"}]}}"#,
        );
        assert_eq!(recs.len(), 1, "thinking dropped, text kept");
        assert_eq!(recs[0]["role"], "agent");
        assert_eq!(recs[0]["text"], "hi");
    }

    #[test]
    fn tool_use_maps_name_and_arg() {
        let recs = run_extract(
            r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Write","input":{"file_path":"src/a.rs","content":"fn main(){}"}}]}}"#,
        );
        assert_eq!(recs.len(), 1);
        assert_eq!(recs[0]["role"], "tool");
        assert_eq!(recs[0]["tool"]["name"], "Write");
        assert_eq!(recs[0]["tool"]["arg"], "src/a.rs"); // file_path preferred over content
    }

    #[test]
    fn tool_arg_prefers_command_then_first_scalar() {
        let recs = run_extract(
            r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash","input":{"command":"cargo build","description":"d"}}]}}"#,
        );
        assert_eq!(recs[0]["tool"]["arg"], "cargo build");
    }

    #[test]
    fn user_tool_result_is_skipped() {
        let recs = run_extract(
            r#"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"ok"}]}}"#,
        );
        assert!(recs.is_empty(), "tool_result has no contract role");
    }

    #[test]
    fn noise_types_and_bad_lines_skipped() {
        let input = concat!(
            r#"{"type":"attachment","attachment":{}}"#,
            "\n",
            r#"{"type":"last-prompt","lastPrompt":"x"}"#,
            "\n",
            "this is not json\n",
            "\n",
            r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"only this"}]}}"#,
        );
        let recs = run_extract(input);
        assert_eq!(recs.len(), 1);
        assert_eq!(recs[0]["text"], "only this");
    }

    #[test]
    fn non_ascii_round_trips_utf8() {
        let recs = run_extract(
            r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"done — built clean"}]}}"#,
        );
        assert_eq!(recs[0]["text"], "done — built clean");
    }

    #[test]
    fn tilde_expands_to_home() {
        std::env::set_var("HOME", "/home/test");
        assert_eq!(expand_tilde("~/.claude/projects"), Path::new("/home/test").join(".claude/projects"));
        assert_eq!(expand_tilde("/abs/path"), PathBuf::from("/abs/path"));
        assert_eq!(expand_tilde("rel/path"), PathBuf::from("rel/path"));
    }

    // [unit->REQ-CCS-PROFILES] ccs CLAUDE_CONFIG_DIR-aware projects-root resolution. Pure helper so
    // no env mutation / no test races (owl parity: set -> $CFG/projects; unset/empty -> fall back).
    #[test]
    fn ccs_root_honors_config_dir() {
        assert_eq!(
            ccs_projects_root_from(Some("/tmp/ccs-acct/.claude")),
            Some(Path::new("/tmp/ccs-acct/.claude").join("projects"))
        );
    }

    #[test]
    fn ccs_root_none_when_unset() {
        assert_eq!(ccs_projects_root_from(None), None);
    }

    #[test]
    fn ccs_root_ignores_empty() {
        // Empty env var must NOT shadow the --in fallback (owl: `!cfg.is_empty()` guard).
        assert_eq!(ccs_projects_root_from(Some("")), None);
    }

    #[test]
    fn parse_args_reads_session_and_in_order_independent() {
        // The subcommand parser sees argv AFTER `claude-spt digest` is stripped by main.
        let a = parse_args(
            ["--in", "/p/root", "--session", "sess-1"]
                .iter()
                .map(|s| s.to_string()),
        )
        .unwrap();
        assert_eq!(a.session.as_deref(), Some("sess-1"));
        assert_eq!(a.source.as_deref(), Some("/p/root"));
    }

    #[test]
    fn every_record_satisfies_the_contract() {
        // role enum + field-presence invariants across a mixed stream.
        let input = concat!(
            r#"{"type":"user","message":{"role":"user","content":"q"}}"#,
            "\n",
            r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"a"},{"type":"tool_use","name":"Read","input":{"file_path":"f"}}]}}"#,
        );
        for r in run_extract(input) {
            let role = r["role"].as_str().unwrap();
            assert!(matches!(role, "input" | "agent" | "tool"), "role enum: {role}");
            match role {
                "input" | "agent" => assert!(r.get("text").is_some() && r.get("tool").is_none()),
                "tool" => {
                    assert!(r.get("text").is_none());
                    assert!(r["tool"].get("name").is_some() && r["tool"].get("arg").is_some());
                }
                _ => unreachable!(),
            }
        }
    }
}


Psyche module (verbatim logic, `cfg(not(test))` gating dropped since main.rs owns the single entry, `run()` reads skip(2)):


//! `claude-spt psyche` — the `[session.psyche_init]` Psyche runner (was the claude-spt-psyche crate).
//!
//! WHAT A PSYCHE IS (doyle 2026-06-15, traced spt-core): a LiveAgent's detached companion. The
//! spt-core daemon's livehost hosts it — only when the resolved manifest declares
//! `[session.psyche_init]` AND the perch came up as state=live_agent (the live `api listen` path;
//! Option A 2026-06-17 — psyche_init lives in the BASE claude-spt manifest, no `:live` profile; a
//! ready/poll perch is skipped regardless, livehost.rs:282). The Psyche owns its OWN perch
//! (`<parent>-psyche`), receives daemon PULSES
//! on that perch, and on each pulse authors a COMMUNE delta (a context brief for the parent's
//! resume across `/clear`/compact) — it NEVER replies/notifies (the echo-commune is a distinct,
//! cheaper actor). It exits at session end. The Psyche is daemon-MANAGED by contract (decoupled
//! from message delivery); this runner does NOT orchestrate its own lifecycle — it is the dumb
//! resident wrapper the daemon launches and the daemon tears down (perch close = our exit signal).
//!
//! WHY A RESIDENT WRAPPER (not bare `claude -p`): `claude -p` is ONE-SHOT (one turn, then exits;
//! Stop hooks cannot re-loop it), and CC is migrating off `-p` billing-wise. So the Psyche-as-an-
//! LLM is a headless `claude` session in `{psyche_dir}` that we KEEP ALIVE: seed it once with the
//! daemon-supplied `{psyche_prompt}` (which tells that claude its Psyche role + the commune-on-pulse
//! contract), then drive one `claude --continue` turn per pulse. The COMMUNE authoring lives in
//! that claude (driven by the prompt) — this runner stays pure orchestration glue.
//!
//! Invoked by the daemon (detached, cwd=`{psyche_dir}`, stdio null):
//!   claude-spt psyche --id <parent>-psyche --session-id <session_id> --prompt <psyche_prompt…>
//! ({id} is the daemon-OVERRIDDEN `<parent>-psyche`, NOT the parent endpoint id — see spawn_psyche.)
//! NOTE on `--prompt` parsing: spt-core **<0.8.2** substituted `{psyche_prompt}` into the command
//! STRING then whitespace-split it, so the multi-word prompt reached us as many trailing argv tokens.
//! spt-core **0.8.2+** fills each `{key}` as exactly ONE argv element (tokenize-then-fill — re-validated
//! 2026-06-17: the prompt arrives as a single element, newlines intact). We parse `--prompt` GREEDILY
//! (slurp the rest) anyway — defensive: it reconstructs a split prompt on <0.8.2 AND passes the single
//! 0.8.2 element through unchanged. Keep `--prompt` LAST in the command template. [impl->REQ-SKILL-LIVE]
//!
//! Loop:  seed `claude -p <prompt>`  ->  forever { `spt ready <id> --once` (blocks for one pulse;
//! its stdout is the pulse body) ; perch-closed => exit ; empty drain => next ; else feed the pulse
//! to `claude --continue -p <pulse>` }. The perch register + backlog drain are the `spt ready`
//! command's own job (ready.md), so no separate establish step.
//! [impl->REQ-SKILL-LIVE]

use std::process::{Command, ExitCode, Stdio};

/// Parsed launch args. Only the three daemon-filled `psyche_init.keys` we template
/// (`{id, session_id, psyche_prompt}`; `{psyche_dir}` is consumed as cwd by the daemon, not argv).
#[derive(Debug, PartialEq)]
struct Args {
    id: String,
    session_id: String,
    prompt: String,
}

impl Args {
    /// Parse `--id V --session-id V --prompt <rest…>`. `--id`/`--session-id` are single-value and
    /// order-independent; `--prompt` is TERMINAL and GREEDY — it slurps every remaining token as the
    /// prompt (rejoined with single spaces). History: spt-core **<0.8.2** substituted `{psyche_prompt}`
    /// into the `[session.psyche_init]` command STRING then whitespace-split it, so a multi-word prompt
    /// (it always is — "PSYCHE REVIVAL time: … incoming event: …") arrived as many tokens, NOT one; a
    /// non-greedy `--prompt` read only the first word and rejected the second as "unknown arg" → instant
    /// exit 2 → the daemon recorded a phantom hosted Psyche (diagnosed v0.8.1, 2026-06-16). spt-core
    /// **0.8.2** fixed it (fills each `{key}` as ONE argv element; re-validated 2026-06-17). Greedy is
    /// KEPT as defensive: with one element, `collect().join(" ")` returns it unchanged; with the old
    /// split, it reconstructs. Our manifest places `--prompt` last. Returns the offending flag on a
    /// missing value or an unknown arg seen BEFORE `--prompt`, so a real misfire is still loud.
    fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args, String> {
        let (mut id, mut session_id, mut prompt) = (None, None, None);
        let mut it = argv.into_iter();
        while let Some(flag) = it.next() {
            let want = |it: &mut dyn Iterator<Item = String>| {
                it.next().ok_or_else(|| format!("{flag} expects a value"))
            };
            match flag.as_str() {
                "--id" => id = Some(want(&mut it)?),
                "--session-id" => session_id = Some(want(&mut it)?),
                // Terminal + greedy: consume ALL remaining tokens (spt-core whitespace-split the prompt).
                "--prompt" => {
                    prompt = Some(it.by_ref().collect::<Vec<_>>().join(" "));
                }
                other => return Err(format!("unknown arg: {other}")),
            }
        }
        Ok(Args {
            id: id.ok_or("missing --id")?,
            session_id: session_id.ok_or("missing --session-id")?,
            prompt: prompt.ok_or("missing --prompt")?,
        })
    }
}

/// Tool/permission SANDBOX flags applied to EVERY psyche claude turn (seed + each pulse) — legacy
/// owl parity (claude_skill_owl `src/live/wrapper/claude.rs`, init/resume/final all carry this exact
/// set). The Psyche is a CONSTRAINED companion, not a general agent:
///   * `--tools Read,Edit,Write` — the commune authoring needs file IO only; NO Bash/network/etc.
///     (legacy ALSO scopes an `--agents owl-psyche` subagent to the same three; the session-level
///     `--tools` cap is the effective gate, and our role-prompt arrives via `-p`, not an embedded
///     `--agents` template, so we apply the cap directly rather than recreating the subagent).
///   * `--disable-slash-commands` — the Psyche drives itself from the prompt; no slash surface.
///   * `--dangerously-skip-permissions` — REQUIRED, not cosmetic: the daemon spawns this runner
///     DETACHED with `Stdio::null` (see run()), so an interactive permission prompt would have no
///     operator/stdin to approve it and would HANG the turn. Auto-approve within the Read/Edit/Write
///     sandbox is the safe combination (bounded surface + no deadlock).
///   * model pin (`sonnet` primary, `opus` fallback, `medium` effort) — mirrors legacy so the cheap
///     companion does not silently ride the parent's heavier model.
/// [impl->REQ-SKILL-LIVE]
fn sandbox_flags() -> Vec<String> {
    [
        "--model",
        "sonnet",
        "--fallback-model",
        "opus",
        "--effort",
        "medium",
        "--dangerously-skip-permissions",
        "--disable-slash-commands",
        "--tools",
        "Read,Edit,Write",
    ]
    .iter()
    .map(|s| (*s).to_string())
    .collect()
}

/// argv for the one-shot SEED turn: establishes the Psyche's headless `claude` session in the cwd
/// (`{psyche_dir}`) from the daemon-supplied psyche prompt. `-p` is the headless/print mode; the
/// sandbox flags (see [`sandbox_flags`]) constrain it to the legacy Read/Edit/Write companion box.
fn seed_cmd(prompt: &str) -> Vec<String> {
    let mut v = vec!["-p".into(), prompt.into()];
    v.extend(sandbox_flags());
    v
}

/// argv for a per-PULSE turn: resume the seeded session (`--continue` picks the most-recent session
/// in the cwd) and feed the pulse body as the turn's prompt. The seeded role-prompt makes that
/// claude author a commune delta for this pulse. Carries the SAME sandbox flags as the seed so the
/// constraint holds for every turn, not just the first (legacy applies them to resume too).
fn pulse_cmd(pulse: &str) -> Vec<String> {
    let mut v = vec!["--continue".into(), "-p".into(), pulse.into()];
    v.extend(sandbox_flags());
    v
}

/// argv for one perch poll: `spt ready <id> --once` registers the perch (first call), drains the
/// spooled backlog, blocks for exactly one delivery, then exits — re-run to stay reachable
/// (ready.md). Non-zero exit = perch gone (session end) = our cue to stop.
fn poll_cmd(id: &str) -> Vec<String> {
    vec!["ready".into(), id.into(), "--once".into()]
}

/// A pulse body is actionable only if non-blank: `spt ready` may print just its `READY:<id>` online
/// signal (on stderr) or an empty drain, which must NOT trigger a (billed) claude turn.
fn is_actionable(pulse: &str) -> bool {
    !pulse.trim().is_empty()
}

/// `claude-spt psyche` entry. Reads its own argv (binary name + `psyche` token already consumed).
pub fn run() -> ExitCode {
    let args = match Args::parse(std::env::args().skip(2)) {
        Ok(a) => a,
        Err(e) => {
            eprintln!("claude-spt psyche: {e}");
            return ExitCode::from(2);
        }
    };

    // Seed the resident Psyche claude session (best-effort: if claude is absent the daemon's perch
    // poll still defines our lifetime; we don't abort the runner over one failed turn).
    let _ = Command::new("claude")
        .args(seed_cmd(&args.prompt))
        .stdin(Stdio::null())
        .status();

    // Resident pulse loop. The perch IS the lifecycle: when the daemon tears the LiveAgent down at
    // session end the perch closes, `spt ready --once` exits non-zero, and we return.
    loop {
        let out = match Command::new("spt").args(poll_cmd(&args.id)).output() {
            Ok(o) => o,
            Err(_) => return ExitCode::SUCCESS, // spt vanished — nothing left to serve
        };
        if !out.status.success() {
            return ExitCode::SUCCESS; // perch gone => session ended
        }
        let pulse = String::from_utf8_lossy(&out.stdout);
        if !is_actionable(&pulse) {
            continue; // online signal / empty drain — no turn
        }
        let _ = Command::new("claude")
            .args(pulse_cmd(&pulse))
            .stdin(Stdio::null())
            .status();
    }
}

// [unit->REQ-SKILL-LIVE]
#[cfg(test)]
mod tests {
    use super::*;

    fn argv(parts: &[&str]) -> Vec<String> {
        parts.iter().map(|s| s.to_string()).collect()
    }

    #[test]
    fn id_and_session_are_order_independent_prompt_is_terminal() {
        // --id/--session-id may precede --prompt in any order; --prompt is last and slurps the rest.
        let a = Args::parse(argv(&[
            "--session-id", "sess-9", "--id", "perri-psyche", "--prompt", "be the psyche",
        ]))
        .unwrap();
        assert_eq!(
            a,
            Args {
                id: "perri-psyche".into(),
                session_id: "sess-9".into(),
                prompt: "be the psyche".into(),
            }
        );
    }

    #[test]
    fn prompt_slurps_whitespace_split_tokens() {
        // spt-core whitespace-splits the substituted {psyche_prompt}; we must rejoin every trailing
        // token, not reject the second word as "unknown arg" (the v0.8.1 phantom-Psyche bug).
        let a = Args::parse(argv(&[
            "--id", "p-psyche", "--session-id", "s",
            "--prompt", "PSYCHE", "REVIVAL", "time:", "epoch-ms:123", "incoming", "event:", "(none)",
        ]))
        .unwrap();
        assert_eq!(a.id, "p-psyche");
        assert_eq!(a.session_id, "s");
        assert_eq!(a.prompt, "PSYCHE REVIVAL time: epoch-ms:123 incoming event: (none)");
    }

    #[test]
    fn missing_flag_is_an_error_not_a_default() {
        let e = Args::parse(argv(&["--id", "x", "--session-id", "s"])).unwrap_err();
        assert!(e.contains("--prompt"), "got: {e}");
    }

    #[test]
    fn unknown_arg_before_prompt_still_rejected() {
        // Greedy --prompt must not mask a genuine misfire earlier in argv.
        let e = Args::parse(argv(&["--id", "x", "--bogus", "v", "--prompt", "hi"])).unwrap_err();
        assert!(e.contains("unknown arg: --bogus"), "got: {e}");
    }

    #[test]
    fn flag_without_value_errors() {
        let e = Args::parse(argv(&["--id"])).unwrap_err();
        assert!(e.contains("--id expects a value"), "got: {e}");
    }

    #[test]
    fn unknown_arg_rejected() {
        let e = Args::parse(argv(&["--bogus", "v"])).unwrap_err();
        assert!(e.contains("unknown arg: --bogus"), "got: {e}");
    }

    #[test]
    fn seed_is_headless_print_of_the_prompt() {
        // -p <prompt> leads; sandbox flags follow.
        let c = seed_cmd("hello psyche");
        assert_eq!(&c[..2], &["-p", "hello psyche"]);
        assert_eq!(&c[2..], &sandbox_flags()[..]);
    }

    #[test]
    fn pulse_resumes_then_prints() {
        // --continue MUST precede -p so the turn resumes the seeded session rather than starting fresh.
        let c = pulse_cmd("commune now");
        assert_eq!(&c[..3], &["--continue", "-p", "commune now"]);
        assert_eq!(&c[3..], &sandbox_flags()[..]);
    }

    // [unit->REQ-HAZARD-PSYCHE-PERMS-DEADLOCK]
    #[test]
    fn every_turn_is_sandboxed_to_legacy_owl_parity() {
        // The Psyche is a constrained companion (claude_skill_owl parity): Read/Edit/Write only,
        // slash-commands off, and skip-permissions because the daemon spawns it detached/null-stdin
        // (an interactive prompt would deadlock). Both seed AND pulse must carry the full set.
        for cmd in [seed_cmd("seed"), pulse_cmd("pulse")] {
            assert!(cmd.windows(2).any(|w| w == ["--tools", "Read,Edit,Write"]), "tools cap missing: {cmd:?}");
            assert!(cmd.iter().any(|a| a == "--disable-slash-commands"), "slash-commands not disabled: {cmd:?}");
            assert!(cmd.iter().any(|a| a == "--dangerously-skip-permissions"), "skip-permissions missing (detached spawn would hang): {cmd:?}");
            assert!(cmd.windows(2).any(|w| w == ["--model", "sonnet"]), "model not pinned: {cmd:?}");
        }
    }

    #[test]
    fn poll_is_ready_once_on_the_psyche_perch() {
        assert_eq!(
            poll_cmd("perri-psyche"),
            vec!["ready", "perri-psyche", "--once"]
        );
    }

    #[test]
    fn blank_pulses_do_not_trigger_a_turn() {
        assert!(!is_actionable(""));
        assert!(!is_actionable("   \n\t "));
        assert!(is_actionable("READY backlog: resume the build"));
    }
}


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

//! `claude-spt psyche` — the `[session.psyche_init]` Psyche runner (was the claude-spt-psyche crate).
//!
//! WHAT A PSYCHE IS (doyle 2026-06-15, traced spt-core): a LiveAgent's detached companion. The
//! spt-core daemon's livehost hosts it — only when the resolved manifest declares
//! `[session.psyche_init]` AND the perch came up as state=live_agent (the live `api listen` path;
//! Option A 2026-06-17 — psyche_init lives in the BASE claude-spt manifest, no `:live` profile; a
//! ready/poll perch is skipped regardless, livehost.rs:282). The Psyche owns its OWN perch
//! (`<parent>-psyche`), receives daemon PULSES
//! on that perch, and on each pulse authors a COMMUNE delta (a context brief for the parent's
//! resume across `/clear`/compact) — it NEVER replies/notifies (the echo-commune is a distinct,
//! cheaper actor). It exits at session end. The Psyche is daemon-MANAGED by contract (decoupled
//! from message delivery); this runner does NOT orchestrate its own lifecycle — it is the dumb
//! resident wrapper the daemon launches and the daemon tears down (perch close = our exit signal).
//!
//! WHY A RESIDENT WRAPPER (not bare `claude -p`): `claude -p` is ONE-SHOT (one turn, then exits;
//! Stop hooks cannot re-loop it), and CC is migrating off `-p` billing-wise. So the Psyche-as-an-
//! LLM is a headless `claude` session in `{psyche_dir}` that we KEEP ALIVE: seed it once with the
//! daemon-supplied `{psyche_prompt}` (which tells that claude its Psyche role + the commune-on-pulse
//! contract), then drive one `claude --continue` turn per pulse. The COMMUNE authoring lives in
//! that claude (driven by the prompt) — this runner stays pure orchestration glue.
//!
//! Invoked by the daemon (detached, cwd=`{psyche_dir}`, stdio null):
//!   claude-spt psyche --id <parent>-psyche --session-id <session_id> --prompt <psyche_prompt…>
//! ({id} is the daemon-OVERRIDDEN `<parent>-psyche`, NOT the parent endpoint id — see spawn_psyche.)
//! NOTE on `--prompt` parsing: spt-core **<0.8.2** substituted `{psyche_prompt}` into the command
//! STRING then whitespace-split it, so the multi-word prompt reached us as many trailing argv tokens.
//! spt-core **0.8.2+** fills each `{key}` as exactly ONE argv element (tokenize-then-fill — re-validated
//! 2026-06-17: the prompt arrives as a single element, newlines intact). We parse `--prompt` GREEDILY
//! (slurp the rest) anyway — defensive: it reconstructs a split prompt on <0.8.2 AND passes the single
//! 0.8.2 element through unchanged. Keep `--prompt` LAST in the command template. [impl->REQ-SKILL-LIVE]
//!
//! Loop:  seed `claude -p <prompt>`  ->  forever { `spt ready <id> --once` (blocks for one pulse;
//! its stdout is the pulse body) ; perch-closed => exit ; empty drain => next ; else feed the pulse
//! to `claude --continue -p <pulse>` }. The perch register + backlog drain are the `spt ready`
//! command's own job (ready.md), so no separate establish step.
//! [impl->REQ-SKILL-LIVE]

use std::process::{Command, ExitCode, Stdio};

/// Parsed launch args. Only the three daemon-filled `psyche_init.keys` we template
/// (`{id, session_id, psyche_prompt}`; `{psyche_dir}` is consumed as cwd by the daemon, not argv).
#[derive(Debug, PartialEq)]
struct Args {
    id: String,
    session_id: String,
    prompt: String,
}

impl Args {
    /// Parse `--id V --session-id V --prompt <rest…>`. `--id`/`--session-id` are single-value and
    /// order-independent; `--prompt` is TERMINAL and GREEDY — it slurps every remaining token as the
    /// prompt (rejoined with single spaces). History: spt-core **<0.8.2** substituted `{psyche_prompt}`
    /// into the `[session.psyche_init]` command STRING then whitespace-split it, so a multi-word prompt
    /// (it always is — "PSYCHE REVIVAL time: … incoming event: …") arrived as many tokens, NOT one; a
    /// non-greedy `--prompt` read only the first word and rejected the second as "unknown arg" → instant
    /// exit 2 → the daemon recorded a phantom hosted Psyche (diagnosed v0.8.1, 2026-06-16). spt-core
    /// **0.8.2** fixed it (fills each `{key}` as ONE argv element; re-validated 2026-06-17). Greedy is
    /// KEPT as defensive: with one element, `collect().join(" ")` returns it unchanged; with the old
    /// split, it reconstructs. Our manifest places `--prompt` last. Returns the offending flag on a
    /// missing value or an unknown arg seen BEFORE `--prompt`, so a real misfire is still loud.
    fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args, String> {
        let (mut id, mut session_id, mut prompt) = (None, None, None);
        let mut it = argv.into_iter();
        while let Some(flag) = it.next() {
            let want = |it: &mut dyn Iterator<Item = String>| {
                it.next().ok_or_else(|| format!("{flag} expects a value"))
            };
            match flag.as_str() {
                "--id" => id = Some(want(&mut it)?),
                "--session-id" => session_id = Some(want(&mut it)?),
                // Terminal + greedy: consume ALL remaining tokens (spt-core whitespace-split the prompt).
                "--prompt" => {
                    prompt = Some(it.by_ref().collect::<Vec<_>>().join(" "));
                }
                other => return Err(format!("unknown arg: {other}")),
            }
        }
        Ok(Args {
            id: id.ok_or("missing --id")?,
            session_id: session_id.ok_or("missing --session-id")?,
            prompt: prompt.ok_or("missing --prompt")?,
        })
    }
}

/// Tool/permission SANDBOX flags applied to EVERY psyche claude turn (seed + each pulse) — legacy
/// owl parity (claude_skill_owl `src/live/wrapper/claude.rs`, init/resume/final all carry this exact
/// set). The Psyche is a CONSTRAINED companion, not a general agent:
///   * `--tools Read,Edit,Write` — the commune authoring needs file IO only; NO Bash/network/etc.
///     (legacy ALSO scopes an `--agents owl-psyche` subagent to the same three; the session-level
///     `--tools` cap is the effective gate, and our role-prompt arrives via `-p`, not an embedded
///     `--agents` template, so we apply the cap directly rather than recreating the subagent).
///   * `--disable-slash-commands` — the Psyche drives itself from the prompt; no slash surface.
///   * `--dangerously-skip-permissions` — REQUIRED, not cosmetic: the daemon spawns this runner
///     DETACHED with `Stdio::null` (see run()), so an interactive permission prompt would have no
///     operator/stdin to approve it and would HANG the turn. Auto-approve within the Read/Edit/Write
///     sandbox is the safe combination (bounded surface + no deadlock).
///   * model pin (`sonnet` primary, `opus` fallback, `medium` effort) — mirrors legacy so the cheap
///     companion does not silently ride the parent's heavier model.
/// [impl->REQ-SKILL-LIVE]
fn sandbox_flags() -> Vec<String> {
    [
        "--model",
        "sonnet",
        "--fallback-model",
        "opus",
        "--effort",
        "medium",
        "--dangerously-skip-permissions",
        "--disable-slash-commands",
        "--tools",
        "Read,Edit,Write",
    ]
    .iter()
    .map(|s| (*s).to_string())
    .collect()
}

/// argv for the one-shot SEED turn: establishes the Psyche's headless `claude` session in the cwd
/// (`{psyche_dir}`) from the daemon-supplied psyche prompt. `-p` is the headless/print mode; the
/// sandbox flags (see [`sandbox_flags`]) constrain it to the legacy Read/Edit/Write companion box.
fn seed_cmd(prompt: &str) -> Vec<String> {
    let mut v = vec!["-p".into(), prompt.into()];
    v.extend(sandbox_flags());
    v
}

/// argv for a per-PULSE turn: resume the seeded session (`--continue` picks the most-recent session
/// in the cwd) and feed the pulse body as the turn's prompt. The seeded role-prompt makes that
/// claude author a commune delta for this pulse. Carries the SAME sandbox flags as the seed so the
/// constraint holds for every turn, not just the first (legacy applies them to resume too).
fn pulse_cmd(pulse: &str) -> Vec<String> {
    let mut v = vec!["--continue".into(), "-p".into(), pulse.into()];
    v.extend(sandbox_flags());
    v
}

/// argv for one perch poll: `spt ready <id> --once` registers the perch (first call), drains the
/// spooled backlog, blocks for exactly one delivery, then exits — re-run to stay reachable
/// (ready.md). Non-zero exit = perch gone (session end) = our cue to stop.
fn poll_cmd(id: &str) -> Vec<String> {
    vec!["ready".into(), id.into(), "--once".into()]
}

/// A pulse body is actionable only if non-blank: `spt ready` may print just its `READY:<id>` online
/// signal (on stderr) or an empty drain, which must NOT trigger a (billed) claude turn.
fn is_actionable(pulse: &str) -> bool {
    !pulse.trim().is_empty()
}

/// `claude-spt psyche` entry. Reads its own argv (binary name + `psyche` token already consumed).
pub fn run() -> ExitCode {
    let args = match Args::parse(std::env::args().skip(2)) {
        Ok(a) => a,
        Err(e) => {
            eprintln!("claude-spt psyche: {e}");
            return ExitCode::from(2);
        }
    };

    // Seed the resident Psyche claude session (best-effort: if claude is absent the daemon's perch
    // poll still defines our lifetime; we don't abort the runner over one failed turn).
    let _ = Command::new("claude")
        .args(seed_cmd(&args.prompt))
        .stdin(Stdio::null())
        .status();

    // Resident pulse loop. The perch IS the lifecycle: when the daemon tears the LiveAgent down at
    // session end the perch closes, `spt ready --once` exits non-zero, and we return.
    loop {
        let out = match Command::new("spt").args(poll_cmd(&args.id)).output() {
            Ok(o) => o,
            Err(_) => return ExitCode::SUCCESS, // spt vanished — nothing left to serve
        };
        if !out.status.success() {
            return ExitCode::SUCCESS; // perch gone => session ended
        }
        let pulse = String::from_utf8_lossy(&out.stdout);
        if !is_actionable(&pulse) {
            continue; // online signal / empty drain — no turn
        }
        let _ = Command::new("claude")
            .args(pulse_cmd(&pulse))
            .stdin(Stdio::null())
            .status();
    }
}

// [unit->REQ-SKILL-LIVE]
#[cfg(test)]
mod tests {
    use super::*;

    fn argv(parts: &[&str]) -> Vec<String> {
        parts.iter().map(|s| s.to_string()).collect()
    }

    #[test]
    fn id_and_session_are_order_independent_prompt_is_terminal() {
        // --id/--session-id may precede --prompt in any order; --prompt is last and slurps the rest.
        let a = Args::parse(argv(&[
            "--session-id", "sess-9", "--id", "perri-psyche", "--prompt", "be the psyche",
        ]))
        .unwrap();
        assert_eq!(
            a,
            Args {
                id: "perri-psyche".into(),
                session_id: "sess-9".into(),
                prompt: "be the psyche".into(),
            }
        );
    }

    #[test]
    fn prompt_slurps_whitespace_split_tokens() {
        // spt-core whitespace-splits the substituted {psyche_prompt}; we must rejoin every trailing
        // token, not reject the second word as "unknown arg" (the v0.8.1 phantom-Psyche bug).
        let a = Args::parse(argv(&[
            "--id", "p-psyche", "--session-id", "s",
            "--prompt", "PSYCHE", "REVIVAL", "time:", "epoch-ms:123", "incoming", "event:", "(none)",
        ]))
        .unwrap();
        assert_eq!(a.id, "p-psyche");
        assert_eq!(a.session_id, "s");
        assert_eq!(a.prompt, "PSYCHE REVIVAL time: epoch-ms:123 incoming event: (none)");
    }

    #[test]
    fn missing_flag_is_an_error_not_a_default() {
        let e = Args::parse(argv(&["--id", "x", "--session-id", "s"])).unwrap_err();
        assert!(e.contains("--prompt"), "got: {e}");
    }

    #[test]
    fn unknown_arg_before_prompt_still_rejected() {
        // Greedy --prompt must not mask a genuine misfire earlier in argv.
        let e = Args::parse(argv(&["--id", "x", "--bogus", "v", "--prompt", "hi"])).unwrap_err();
        assert!(e.contains("unknown arg: --bogus"), "got: {e}");
    }

    #[test]
    fn flag_without_value_errors() {
        let e = Args::parse(argv(&["--id"])).unwrap_err();
        assert!(e.contains("--id expects a value"), "got: {e}");
    }

    #[test]
    fn unknown_arg_rejected() {
        let e = Args::parse(argv(&["--bogus", "v"])).unwrap_err();
        assert!(e.contains("unknown arg: --bogus"), "got: {e}");
    }

    #[test]
    fn seed_is_headless_print_of_the_prompt() {
        // -p <prompt> leads; sandbox flags follow.
        let c = seed_cmd("hello psyche");
        assert_eq!(&c[..2], &["-p", "hello psyche"]);
        assert_eq!(&c[2..], &sandbox_flags()[..]);
    }

    #[test]
    fn pulse_resumes_then_prints() {
        // --continue MUST precede -p so the turn resumes the seeded session rather than starting fresh.
        let c = pulse_cmd("commune now");
        assert_eq!(&c[..3], &["--continue", "-p", "commune now"]);
        assert_eq!(&c[3..], &sandbox_flags()[..]);
    }

    // [unit->REQ-HAZARD-PSYCHE-PERMS-DEADLOCK]
    #[test]
    fn every_turn_is_sandboxed_to_legacy_owl_parity() {
        // The Psyche is a constrained companion (claude_skill_owl parity): Read/Edit/Write only,
        // slash-commands off, and skip-permissions because the daemon spawns it detached/null-stdin
        // (an interactive prompt would deadlock). Both seed AND pulse must carry the full set.
        for cmd in [seed_cmd("seed"), pulse_cmd("pulse")] {
            assert!(cmd.windows(2).any(|w| w == ["--tools", "Read,Edit,Write"]), "tools cap missing: {cmd:?}");
            assert!(cmd.iter().any(|a| a == "--disable-slash-commands"), "slash-commands not disabled: {cmd:?}");
            assert!(cmd.iter().any(|a| a == "--dangerously-skip-permissions"), "skip-permissions missing (detached spawn would hang): {cmd:?}");
            assert!(cmd.windows(2).any(|w| w == ["--model", "sonnet"]), "model not pinned: {cmd:?}");
        }
    }

    #[test]
    fn poll_is_ready_once_on_the_psyche_perch() {
        assert_eq!(
            poll_cmd("perri-psyche"),
            vec!["ready", "perri-psyche", "--once"]
        );
    }

    #[test]
    fn blank_pulses_do_not_trigger_a_turn() {
        assert!(!is_actionable(""));
        assert!(!is_actionable("   \n\t "));
        assert!(is_actionable("READY backlog: resume the build"));
    }
}


Now the new `post-update` subcommand. Let me check the actual installed-plugins config layout so the detection is grounded, not guessed:


Bash

cd "$HOME/.claude/plugins" 2>/dev/null && echo "=== ~/.claude/plugins ===" && ls -la 2>/dev/null | head; echo "=== known_marketplaces.json (shape) ==="; head -c 1200 known_marketplaces.json 2>/dev/null; echo; echo "=== config.json (shape) ==="; head -c 1200 config.json 2>/dev/null; echo; echo "=== marketplaces dir ==="; ls marketplaces 2>/dev/null

Inspect installed-plugins config layout


=== ~/.claude/plugins ===
total 402
drwxr-xr-x 1 decid 197609      0 Jun 24 05:00 .
drwxr-xr-x 1 decid 197609      0 Jun 24 16:22 ..
-rw-r--r-- 1 decid 197609     24 Jun 24 23:41 .last_inuse_sweep
-rw-r--r-- 1 decid 197609    414 Mar 31 20:53 blocklist.json
drwxr-xr-x 1 decid 197609      0 Jun 15 21:32 cache
drwxr-xr-x 1 decid 197609      0 Jun 15 21:32 data
-rw-r--r-- 1 decid 197609   2943 Jun 24 05:00 installed_plugins.json
-rw-r--r-- 1 decid 197609   1416 Apr 18 01:14 installed_plugins.json.bak-fwdslash
-rw-r--r-- 1 decid 197609      0 Apr 15 22:33 installed_plugins.json.tmp
=== known_marketplaces.json (shape) ===
{
  "claude-plugins-official": {
    "source": {
      "source": "github",
      "repo": "anthropics/claude-plugins-official"
    },
    "installLocation": "C:\\Users\\decid\\.claude\\plugins\\marketplaces\\claude-plugins-official",
    "lastUpdated": "2026-06-24T12:00:34.807Z"
  },
  "cplugs": {
    "source": {
      "source": "github",
      "repo": "SaberMage/cplugs"
    },
    "installLocation": "C:\\Users\\decid\\.claude\\plugins\\marketplaces\\cplugs",
    "lastUpdated": "2026-04-02T09:07:33.495Z"
  },
  "mempalace": {
    "source": {
      "source": "github",
      "repo": "milla-jovovich/mempalace"
    },
    "installLocation": "C:\\Users\\decid\\.claude\\plugins\\marketplaces\\mempalace",
    "lastUpdated": "2026-04-09T11:11:20.080Z"
  },
  "caveman": {
    "source": {
      "source": "github",
      "repo": "JuliusBrussee/caveman"
    },
    "installLocation": "C:\\Users\\decid\\.claude\\plugins\\marketplaces\\caveman",
    "lastUpdated": "2026-04-18T11:20:06.766Z"
  }
}
=== config.json (shape) ===

=== marketplaces dir ===
caveman
claude-plugins-official
claude-plugins-official.bak
cplugs
mempalace
Shell cwd was reset to C:\Users\decid\Documents\projects\spt-claude-code


Grounded. Check `installed_plugins.json` shape for the plugin-installed detection:


head -c 1500 "$HOME/.claude/plugins/installed_plugins.json"

Inspect installed_plugins.json shape


{
  "version": 2,
  "plugins": {
    "spt@cplugs": [
      {
        "scope": "user",
        "installPath": "C:\\Users\\decid\\.claude\\plugins\\cache\\cplugs\\spt\\1.11.25",
        "version": "1.11.25",
        "installedAt": "2026-04-21T11:51:04.771Z",
        "lastUpdated": "2026-06-02T21:33:19.2764892Z",
        "gitCommitSha": "33927caa109e1ed97aab964d6ea87e5473869074"
      }
    ],
    "frontend-design@claude-plugins-official": [
      {
        "scope": "user",
        "installPath": "C:\\Users\\decid\\.claude\\plugins\\cache\\claude-plugins-official\\frontend-design\\unknown",
        "version": "unknown",
        "installedAt": "2026-04-18T08:51:46.709Z",
        "lastUpdated": "2026-06-24T12:00:36.736Z"
      }
    ],
    "mempalace@mempalace": [
      {
        "scope": "user",
        "installPath": "C:\\Users\\decid\\.claude\\plugins\\cache\\mempalace\\mempalace\\3.0.14",
        "version": "3.0.14",
        "installedAt": "2026-04-18T08:51:47.444Z",
        "lastUpdated": "2026-04-18T08:51:47.444Z",
        "gitCommitSha": "26835e30ef2bb910aaa4f8626468d9108d13d661"
      }
    ],
    "screen-timelapse@cplugs": [
      {
        "scope": "user",
        "installPath": "C:\\Users\\decid\\.claude\\plugins\\cache\\cplugs\\screen-timelapse\\1.2.2",
        "version": "1.2.2",
        "installedAt": "2026-04-18T08:59:49.817Z",
        "lastUpdated": "2026-04-18T08:59:49.817Z",
        "gitCommitSha": "8b91767b1dddfdb884e14b13ad5996b77981878c"
      }
    ],
    "ca


All grounded (`installed_plugins.json` keys by `<plugin>@<marketplace>`; new plugin = `sptc@cplugs`). Writing the `post-update` subcommand:


Write

//! `claude-spt post-update` — reconcile the cplugs plugin after `spt adapter update` (NEW, ADR-0006).
//!
//! `spt adapter update` pulls the adapter `.spt` (manifest + binaries + strings — the high-churn
//! surface) but it CANNOT touch the cplugs plugin (the thin skeleton: hooks.json + skill stubs +
//! plugin.json), which lives in Claude Code's marketplace registry. This subcommand closes that gap:
//! detect the host CLI (`claude`, or `ccs` — a drop-in), ensure the `cplugs` marketplace is
//! registered, then install/update the plugin. It then PRINTS a notice — it CANNOT run
//! `/reload-plugins` (a TUI-only action), so the manual residual is the user's `/reload-plugins`.
//!
//! WIRING STATUS: standalone-runnable + unit-tested NOW, but `[update]` cannot invoke it until doyle
//! ask #2 (composite `[update]` with a delegated post-step) lands — see ADR-0006 + UPDATE-NAMING-
//! DOYLE-ASKS.md ask 2 (D2). Until then it is run by hand / by /sptc:setup. `--dry-run` prints the
//! intended actions without spawning the CLI (the deterministic surface a future int asserts on).
//! [impl->REQ-DIST-BINARY-CONSOLIDATE]

use std::path::PathBuf;
use std::process::{Command, ExitCode, Stdio};

const MARKETPLACE: &str = "cplugs";
const MARKETPLACE_REPO: &str = "SaberMage/cplugs";
/// Plugin reference Claude Code keys by: `<plugin.json name>@<marketplace>`. This milestone the
/// plugin stays `sptc` (the `sptc`→`spt` succession is D4, owl-gated) so the ref is `sptc@cplugs`.
const PLUGIN_REF: &str = "sptc@cplugs";

/// Pick the CLI to drive. Prefer real `claude`; fall back to `ccs` (a documented drop-in for the
/// `claude` binary, same argv). Pure so the PATH probe stays at the edge and routing is testable.
fn choose_cli(has_claude: bool, has_ccs: bool) -> Option<&'static str> {
    if has_claude {
        Some("claude")
    } else if has_ccs {
        Some("ccs")
    } else {
        None
    }
}

/// `<cli> plugin marketplace add SaberMage/cplugs` — register the marketplace (run only when absent).
fn marketplace_add_cmd() -> Vec<String> {
    ["plugin", "marketplace", "add", MARKETPLACE_REPO]
        .iter()
        .map(|s| (*s).to_string())
        .collect()
}

/// `<cli> plugin (install|update) sptc@cplugs` — `update` when already installed, else `install`.
fn plugin_sync_cmd(installed: bool) -> Vec<String> {
    let verb = if installed { "update" } else { "install" };
    ["plugin", verb, PLUGIN_REF]
        .iter()
        .map(|s| (*s).to_string())
        .collect()
}

/// Is `MARKETPLACE` registered? Parses `known_marketplaces.json` — a top-level object keyed by
/// marketplace name, each with `source.repo`. True if the `cplugs` key exists OR any entry's repo is
/// `SaberMage/cplugs` (robust to a differently-named registration of the same repo). Pure over the
/// file contents (read at the edge). Malformed/empty -> false (caller registers it).
fn marketplace_registered_in(json: &str) -> bool {
    let v: serde_json::Value = match serde_json::from_str(json) {
        Ok(v) => v,
        Err(_) => return false,
    };
    let obj = match v.as_object() {
        Some(o) => o,
        None => return false,
    };
    if obj.contains_key(MARKETPLACE) {
        return true;
    }
    obj.values().any(|entry| {
        entry
            .get("source")
            .and_then(|s| s.get("repo"))
            .and_then(|r| r.as_str())
            == Some(MARKETPLACE_REPO)
    })
}

/// Is the plugin already installed? Parses `installed_plugins.json` — `{ plugins: { "<ref>": [...] } }`
/// — and checks for the `sptc@cplugs` key. Pure over the file contents. Malformed/empty/absent ->
/// false (caller installs rather than updates).
fn plugin_installed_in(json: &str) -> bool {
    serde_json::from_str::<serde_json::Value>(json)
        .ok()
        .and_then(|v| {
            v.get("plugins")
                .and_then(|p| p.as_object())
                .map(|p| p.contains_key(PLUGIN_REF))
        })
        .unwrap_or(false)
}

/// Executable names to probe on PATH for `name`. Windows resolves `claude`/`ccs` via PATHEXT
/// (`.exe`/`.cmd`/`.bat`); POSIX uses the bare name. Pure so PATH resolution is testable.
fn program_candidates(name: &str) -> Vec<String> {
    if cfg!(windows) {
        ["", ".exe", ".cmd", ".bat"]
            .iter()
            .map(|ext| format!("{name}{ext}"))
            .collect()
    } else {
        vec![name.to_string()]
    }
}

/// The post-update notice. It states what was reconciled and the ONE manual residual
/// (`/reload-plugins`) — it deliberately does NOT run that itself (TUI-only). Kept aligned with the
/// manifest `[update].message` copy (REQ-DIST-UPDATE-MESSAGE) so both levers say the same thing.
fn notice() -> &'static str {
    "claude-spt: plugin reconciled. Run /reload-plugins in Claude Code (or restart it) to load the refreshed skills/hooks."
}

/// Claude Code's config root: `$CLAUDE_CONFIG_DIR` (set by `ccs` per-account) else `~/.claude`.
fn claude_config_dir() -> Option<PathBuf> {
    if let Some(cfg) = std::env::var_os("CLAUDE_CONFIG_DIR") {
        if !cfg.is_empty() {
            return Some(PathBuf::from(cfg));
        }
    }
    std::env::var_os("HOME")
        .or_else(|| std::env::var_os("USERPROFILE"))
        .map(|home| PathBuf::from(home).join(".claude"))
}

/// True if `name` (or a PATHEXT variant) resolves to a file on any PATH dir.
fn which(name: &str) -> bool {
    let path = match std::env::var_os("PATH") {
        Some(p) => p,
        None => return false,
    };
    let cands = program_candidates(name);
    std::env::split_paths(&path).any(|dir| cands.iter().any(|c| dir.join(c).is_file()))
}

/// `claude-spt post-update` entry. Reads its own argv (binary name + `post-update` token consumed).
pub fn run() -> ExitCode {
    let dry_run = std::env::args()
        .skip(2)
        .any(|a| a == "--dry-run" || a == "-n");

    let cli = match choose_cli(which("claude"), which("ccs")) {
        Some(c) => c,
        None => {
            eprintln!("claude-spt post-update: no `claude` or `ccs` CLI on PATH — cannot reconcile the plugin");
            return ExitCode::from(3);
        }
    };

    let plugins_dir = claude_config_dir().map(|d| d.join("plugins"));
    let read = |name: &str| -> String {
        plugins_dir
            .as_ref()
            .and_then(|d| std::fs::read_to_string(d.join(name)).ok())
            .unwrap_or_default()
    };
    let registered = marketplace_registered_in(&read("known_marketplaces.json"));
    let installed = plugin_installed_in(&read("installed_plugins.json"));

    let mut steps: Vec<Vec<String>> = Vec::new();
    if !registered {
        steps.push(marketplace_add_cmd());
    }
    steps.push(plugin_sync_cmd(installed));

    for argv in &steps {
        let shown = format!("{cli} {}", argv.join(" "));
        if dry_run {
            println!("would run: {shown}");
            continue;
        }
        eprintln!("claude-spt post-update: {shown}");
        let status = Command::new(cli).args(argv).stdin(Stdio::null()).status();
        match status {
            Ok(s) if s.success() => {}
            Ok(s) => {
                eprintln!("claude-spt post-update: `{shown}` exited {s}");
                return ExitCode::from(1);
            }
            Err(e) => {
                eprintln!("claude-spt post-update: failed to spawn `{shown}`: {e}");
                return ExitCode::from(1);
            }
        }
    }

    println!("{}", notice());
    ExitCode::SUCCESS
}

// [unit->REQ-DIST-BINARY-CONSOLIDATE]
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn cli_prefers_claude_then_ccs_then_none() {
        assert_eq!(choose_cli(true, true), Some("claude"));
        assert_eq!(choose_cli(true, false), Some("claude"));
        assert_eq!(choose_cli(false, true), Some("ccs")); // ccs is a documented claude drop-in
        assert_eq!(choose_cli(false, false), None);
    }

    #[test]
    fn marketplace_add_targets_the_cplugs_repo() {
        assert_eq!(
            marketplace_add_cmd(),
            vec!["plugin", "marketplace", "add", "SaberMage/cplugs"]
        );
    }

    #[test]
    fn plugin_sync_picks_update_when_installed_else_install() {
        assert_eq!(plugin_sync_cmd(false), vec!["plugin", "install", "sptc@cplugs"]);
        assert_eq!(plugin_sync_cmd(true), vec!["plugin", "update", "sptc@cplugs"]);
    }

    #[test]
    fn marketplace_registered_detects_by_key() {
        let json = r#"{"cplugs":{"source":{"source":"github","repo":"SaberMage/cplugs"}}}"#;
        assert!(marketplace_registered_in(json));
    }

    #[test]
    fn marketplace_registered_detects_by_repo_under_other_name() {
        // Same repo registered under a different marketplace name must still count.
        let json = r#"{"my-fork":{"source":{"source":"github","repo":"SaberMage/cplugs"}}}"#;
        assert!(marketplace_registered_in(json));
    }

    #[test]
    fn marketplace_registered_false_when_absent_or_malformed() {
        assert!(!marketplace_registered_in(
            r#"{"other":{"source":{"source":"github","repo":"a/b"}}}"#
        ));
        assert!(!marketplace_registered_in("not json"));
        assert!(!marketplace_registered_in(""));
    }

    #[test]
    fn plugin_installed_detects_the_ref_key() {
        let json = r#"{"version":2,"plugins":{"sptc@cplugs":[{"scope":"user"}]}}"#;
        assert!(plugin_installed_in(json));
    }

    #[test]
    fn plugin_installed_false_for_legacy_or_absent() {
        // The legacy owl plugin is `spt@cplugs`, NOT `sptc@cplugs` — must not be mistaken for ours.
        assert!(!plugin_installed_in(
            r#"{"version":2,"plugins":{"spt@cplugs":[{"scope":"user"}]}}"#
        ));
        assert!(!plugin_installed_in(r#"{"version":2,"plugins":{}}"#));
        assert!(!plugin_installed_in("not json"));
    }

    #[test]
    fn program_candidates_cover_windows_pathext() {
        let c = program_candidates("claude");
        assert!(c.contains(&"claude".to_string()));
        if cfg!(windows) {
            assert!(c.contains(&"claude.exe".to_string()));
            assert!(c.contains(&"claude.cmd".to_string()));
        } else {
            assert_eq!(c, vec!["claude".to_string()]);
        }
    }

    #[test]
    fn notice_names_reload_plugins_but_does_not_claim_to_run_it() {
        let n = notice();
        assert!(n.contains("/reload-plugins"), "notice must point at the manual residual");
        // The notice tells the user to run it — the subcommand never runs the TUI action itself.
        assert!(n.contains("Run /reload-plugins"));
    }
}


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

//! `claude-spt post-update` — reconcile the cplugs plugin after `spt adapter update` (NEW, ADR-0006).
//!
//! `spt adapter update` pulls the adapter `.spt` (manifest + binaries + strings — the high-churn
//! surface) but it CANNOT touch the cplugs plugin (the thin skeleton: hooks.json + skill stubs +
//! plugin.json), which lives in Claude Code's marketplace registry. This subcommand closes that gap:
//! detect the host CLI (`claude`, or `ccs` — a drop-in), ensure the `cplugs` marketplace is
//! registered, then install/update the plugin. It then PRINTS a notice — it CANNOT run
//! `/reload-plugins` (a TUI-only action), so the manual residual is the user's `/reload-plugins`.
//!
//! WIRING STATUS: standalone-runnable + unit-tested NOW, but `[update]` cannot invoke it until doyle
//! ask #2 (composite `[update]` with a delegated post-step) lands — see ADR-0006 + UPDATE-NAMING-
//! DOYLE-ASKS.md ask 2 (D2). Until then it is run by hand / by /sptc:setup. `--dry-run` prints the
//! intended actions without spawning the CLI (the deterministic surface a future int asserts on).
//! [impl->REQ-DIST-BINARY-CONSOLIDATE]

use std::path::PathBuf;
use std::process::{Command, ExitCode, Stdio};

const MARKETPLACE: &str = "cplugs";
const MARKETPLACE_REPO: &str = "SaberMage/cplugs";
/// Plugin reference Claude Code keys by: `<plugin.json name>@<marketplace>`. This milestone the
/// plugin stays `sptc` (the `sptc`→`spt` succession is D4, owl-gated) so the ref is `sptc@cplugs`.
const PLUGIN_REF: &str = "sptc@cplugs";

/// Pick the CLI to drive. Prefer real `claude`; fall back to `ccs` (a documented drop-in for the
/// `claude` binary, same argv). Pure so the PATH probe stays at the edge and routing is testable.
fn choose_cli(has_claude: bool, has_ccs: bool) -> Option<&'static str> {
    if has_claude {
        Some("claude")
    } else if has_ccs {
        Some("ccs")
    } else {
        None
    }
}

/// `<cli> plugin marketplace add SaberMage/cplugs` — register the marketplace (run only when absent).
fn marketplace_add_cmd() -> Vec<String> {
    ["plugin", "marketplace", "add", MARKETPLACE_REPO]
        .iter()
        .map(|s| (*s).to_string())
        .collect()
}

/// `<cli> plugin (install|update) sptc@cplugs` — `update` when already installed, else `install`.
fn plugin_sync_cmd(installed: bool) -> Vec<String> {
    let verb = if installed { "update" } else { "install" };
    ["plugin", verb, PLUGIN_REF]
        .iter()
        .map(|s| (*s).to_string())
        .collect()
}

/// Is `MARKETPLACE` registered? Parses `known_marketplaces.json` — a top-level object keyed by
/// marketplace name, each with `source.repo`. True if the `cplugs` key exists OR any entry's repo is
/// `SaberMage/cplugs` (robust to a differently-named registration of the same repo). Pure over the
/// file contents (read at the edge). Malformed/empty -> false (caller registers it).
fn marketplace_registered_in(json: &str) -> bool {
    let v: serde_json::Value = match serde_json::from_str(json) {
        Ok(v) => v,
        Err(_) => return false,
    };
    let obj = match v.as_object() {
        Some(o) => o,
        None => return false,
    };
    if obj.contains_key(MARKETPLACE) {
        return true;
    }
    obj.values().any(|entry| {
        entry
            .get("source")
            .and_then(|s| s.get("repo"))
            .and_then(|r| r.as_str())
            == Some(MARKETPLACE_REPO)
    })
}

/// Is the plugin already installed? Parses `installed_plugins.json` — `{ plugins: { "<ref>": [...] } }`
/// — and checks for the `sptc@cplugs` key. Pure over the file contents. Malformed/empty/absent ->
/// false (caller installs rather than updates).
fn plugin_installed_in(json: &str) -> bool {
    serde_json::from_str::<serde_json::Value>(json)
        .ok()
        .and_then(|v| {
            v.get("plugins")
                .and_then(|p| p.as_object())
                .map(|p| p.contains_key(PLUGIN_REF))
        })
        .unwrap_or(false)
}

/// Executable names to probe on PATH for `name`. Windows resolves `claude`/`ccs` via PATHEXT
/// (`.exe`/`.cmd`/`.bat`); POSIX uses the bare name. Pure so PATH resolution is testable.
fn program_candidates(name: &str) -> Vec<String> {
    if cfg!(windows) {
        ["", ".exe", ".cmd", ".bat"]
            .iter()
            .map(|ext| format!("{name}{ext}"))
            .collect()
    } else {
        vec![name.to_string()]
    }
}

/// The post-update notice. It states what was reconciled and the ONE manual residual
/// (`/reload-plugins`) — it deliberately does NOT run that itself (TUI-only). Kept aligned with the
/// manifest `[update].message` copy (REQ-DIST-UPDATE-MESSAGE) so both levers say the same thing.
fn notice() -> &'static str {
    "claude-spt: plugin reconciled. Run /reload-plugins in Claude Code (or restart it) to load the refreshed skills/hooks."
}

/// Claude Code's config root: `$CLAUDE_CONFIG_DIR` (set by `ccs` per-account) else `~/.claude`.
fn claude_config_dir() -> Option<PathBuf> {
    if let Some(cfg) = std::env::var_os("CLAUDE_CONFIG_DIR") {
        if !cfg.is_empty() {
            return Some(PathBuf::from(cfg));
        }
    }
    std::env::var_os("HOME")
        .or_else(|| std::env::var_os("USERPROFILE"))
        .map(|home| PathBuf::from(home).join(".claude"))
}

/// True if `name` (or a PATHEXT variant) resolves to a file on any PATH dir.
fn which(name: &str) -> bool {
    let path = match std::env::var_os("PATH") {
        Some(p) => p,
        None => return false,
    };
    let cands = program_candidates(name);
    std::env::split_paths(&path).any(|dir| cands.iter().any(|c| dir.join(c).is_file()))
}

/// `claude-spt post-update` entry. Reads its own argv (binary name + `post-update` token consumed).
pub fn run() -> ExitCode {
    let dry_run = std::env::args()
        .skip(2)
        .any(|a| a == "--dry-run" || a == "-n");

    let cli = match choose_cli(which("claude"), which("ccs")) {
        Some(c) => c,
        None => {
            eprintln!("claude-spt post-update: no `claude` or `ccs` CLI on PATH — cannot reconcile the plugin");
            return ExitCode::from(3);
        }
    };

    let plugins_dir = claude_config_dir().map(|d| d.join("plugins"));
    let read = |name: &str| -> String {
        plugins_dir
            .as_ref()
            .and_then(|d| std::fs::read_to_string(d.join(name)).ok())
            .unwrap_or_default()
    };
    let registered = marketplace_registered_in(&read("known_marketplaces.json"));
    let installed = plugin_installed_in(&read("installed_plugins.json"));

    let mut steps: Vec<Vec<String>> = Vec::new();
    if !registered {
        steps.push(marketplace_add_cmd());
    }
    steps.push(plugin_sync_cmd(installed));

    for argv in &steps {
        let shown = format!("{cli} {}", argv.join(" "));
        if dry_run {
            println!("would run: {shown}");
            continue;
        }
        eprintln!("claude-spt post-update: {shown}");
        let status = Command::new(cli).args(argv).stdin(Stdio::null()).status();
        match status {
            Ok(s) if s.success() => {}
            Ok(s) => {
                eprintln!("claude-spt post-update: `{shown}` exited {s}");
                return ExitCode::from(1);
            }
            Err(e) => {
                eprintln!("claude-spt post-update: failed to spawn `{shown}`: {e}");
                return ExitCode::from(1);
            }
        }
    }

    println!("{}", notice());
    ExitCode::SUCCESS
}

// [unit->REQ-DIST-BINARY-CONSOLIDATE]
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn cli_prefers_claude_then_ccs_then_none() {
        assert_eq!(choose_cli(true, true), Some("claude"));
        assert_eq!(choose_cli(true, false), Some("claude"));
        assert_eq!(choose_cli(false, true), Some("ccs")); // ccs is a documented claude drop-in
        assert_eq!(choose_cli(false, false), None);
    }

    #[test]
    fn marketplace_add_targets_the_cplugs_repo() {
        assert_eq!(
            marketplace_add_cmd(),
            vec!["plugin", "marketplace", "add", "SaberMage/cplugs"]
        );
    }

    #[test]
    fn plugin_sync_picks_update_when_installed_else_install() {
        assert_eq!(plugin_sync_cmd(false), vec!["plugin", "install", "sptc@cplugs"]);
        assert_eq!(plugin_sync_cmd(true), vec!["plugin", "update", "sptc@cplugs"]);
    }

    #[test]
    fn marketplace_registered_detects_by_key() {
        let json = r#"{"cplugs":{"source":{"source":"github","repo":"SaberMage/cplugs"}}}"#;
        assert!(marketplace_registered_in(json));
    }

    #[test]
    fn marketplace_registered_detects_by_repo_under_other_name() {
        // Same repo registered under a different marketplace name must still count.
        let json = r#"{"my-fork":{"source":{"source":"github","repo":"SaberMage/cplugs"}}}"#;
        assert!(marketplace_registered_in(json));
    }

    #[test]
    fn marketplace_registered_false_when_absent_or_malformed() {
        assert!(!marketplace_registered_in(
            r#"{"other":{"source":{"source":"github","repo":"a/b"}}}"#
        ));
        assert!(!marketplace_registered_in("not json"));
        assert!(!marketplace_registered_in(""));
    }

    #[test]
    fn plugin_installed_detects_the_ref_key() {
        let json = r#"{"version":2,"plugins":{"sptc@cplugs":[{"scope":"user"}]}}"#;
        assert!(plugin_installed_in(json));
    }

    #[test]
    fn plugin_installed_false_for_legacy_or_absent() {
        // The legacy owl plugin is `spt@cplugs`, NOT `sptc@cplugs` — must not be mistaken for ours.
        assert!(!plugin_installed_in(
            r#"{"version":2,"plugins":{"spt@cplugs":[{"scope":"user"}]}}"#
        ));
        assert!(!plugin_installed_in(r#"{"version":2,"plugins":{}}"#));
        assert!(!plugin_installed_in("not json"));
    }

    #[test]
    fn program_candidates_cover_windows_pathext() {
        let c = program_candidates("claude");
        assert!(c.contains(&"claude".to_string()));
        if cfg!(windows) {
            assert!(c.contains(&"claude.exe".to_string()));
            assert!(c.contains(&"claude.cmd".to_string()));
        } else {
            assert_eq!(c, vec!["claude".to_string()]);
        }
    }

    #[test]
    fn notice_names_reload_plugins_but_does_not_claim_to_run_it() {
        let n = notice();
        assert!(n.contains("/reload-plugins"), "notice must point at the manual residual");
        // The notice tells the user to run it — the subcommand never runs the TUI action itself.
        assert!(n.contains("Run /reload-plugins"));
    }
}


Now build + test the consolidated crate:


Bash

cd "C:/Users/decid/Documents/projects/spt-claude-code/tools/claude-spt" && cargo test 2>&1 | tail -40

Build + test consolidated claude-spt crate


test digest::tests::ccs_root_ignores_empty ... ok
test digest::tests::ccs_root_honors_config_dir ... ok
test digest::tests::ccs_root_none_when_unset ... ok
test post_update::tests::marketplace_add_targets_the_cplugs_repo ... ok
test digest::tests::non_ascii_round_trips_utf8 ... ok
test digest::tests::assistant_text_is_agent_thinking_skipped ... ok
test digest::tests::noise_types_and_bad_lines_skipped ... ok
test digest::tests::tilde_expands_to_home ... ok
test digest::tests::tool_arg_prefers_command_then_first_scalar ... ok
test digest::tests::every_record_satisfies_the_contract ... ok
test digest::tests::user_tool_result_is_skipped ... ok
test post_update::tests::cli_prefers_claude_then_ccs_then_none ... ok
test digest::tests::parse_args_reads_session_and_in_order_independent ... ok
test digest::tests::tool_use_maps_name_and_arg ... ok
test post_update::tests::marketplace_registered_detects_by_key ... ok
test digest::tests::user_string_is_input ... ok
test post_update::tests::marketplace_registered_detects_by_repo_under_other_name ... ok
test post_update::tests::marketplace_registered_false_when_absent_or_malformed ... ok
test post_update::tests::notice_names_reload_plugins_but_does_not_claim_to_run_it ... ok
test post_update::tests::plugin_installed_detects_the_ref_key ... ok
test post_update::tests::plugin_installed_false_for_legacy_or_absent ... ok
test post_update::tests::plugin_sync_picks_update_when_installed_else_install ... ok
test post_update::tests::program_candidates_cover_windows_pathext ... ok
test psyche::tests::blank_pulses_do_not_trigger_a_turn ... ok
test psyche::tests::every_turn_is_sandboxed_to_legacy_owl_parity ... ok
test psyche::tests::flag_without_value_errors ... ok
test psyche::tests::id_and_session_are_order_independent_prompt_is_terminal ... ok
test psyche::tests::missing_flag_is_an_error_not_a_default ... ok
test psyche::tests::poll_is_ready_once_on_the_psyche_perch ... ok
test psyche::tests::prompt_slurps_whitespace_split_tokens ... ok
test psyche::tests::pulse_resumes_then_prints ... ok
test psyche::tests::seed_is_headless_print_of_the_prompt ... ok
test psyche::tests::unknown_arg_before_prompt_still_rejected ... ok
test psyche::tests::unknown_arg_rejected ... ok
test tests::classifies_each_real_subcommand ... ok
test tests::no_subcommand_and_help_flags_are_help ... ok
test tests::unrecognized_subcommand_is_unknown_not_misrouted ... ok

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


37/37 pass. Smoke-test dispatch + dry-run, check for warnings:


cd "C:/Users/decid/Documents/projects/spt-claude-code/tools/claude-spt" && cargo build --release 2>&1 | grep -iE "warning|error" || echo "clean build, no warnings"; echo "=== dispatch smoke ==="; BIN=target/release/claude-spt.exe; "$BIN" 2>&1 | head -2; echo "--- unknown ---"; "$BIN" bogus 2>&1 | head -1; echo "rc=$?"; echo "--- post-update dry-run ---"; "$BIN" post-update --dry-run 2>&1 | head -4

Release build warnings check + dispatch/dry-run smoke


clean build, no warnings
=== dispatch smoke ===
claude-spt <subcommand> [args]

--- unknown ---
claude-spt: unknown subcommand: bogus
rc=0
--- post-update dry-run ---
would run: claude plugin update sptc@cplugs
claude-spt: plugin reconciled. Run /reload-plugins in Claude Code (or restart it) to load the refreshed skills/hooks.


Clean build, dispatch + dry-run work (it read this node's live config — `update` since sptc is present here). Now wire the rest of U2. Read the files to update:


cd "C:/Users/decid/Documents/projects/spt-claude-code" && echo "===== ci/digest/build.sh ====="; cat ci/digest/build.sh; echo "===== ci/psyche/build.sh ====="; cat ci/psyche/build.sh

Read digest + psyche build scripts