From 02451f5f4922a5d49faba6933fd32b3544a7b269 Mon Sep 17 00:00:00 2001 From: Parfii-bot Date: Thu, 23 Apr 2026 10:21:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(sp1):=20NEW=20kei-spawn=20crate=20?= =?UTF-8?q?=E2=80=94=20automation=20envelope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spawn internally calls prepare + ledger fork, emits JSON ready for Agent tool invocation. verify wraps post-return check+ledger update. list-pending shows running forks. kei-ledger invoked via subprocess (no lib.rs in kei-ledger). KEI_SPAWN_LEDGER_NOOP=1 test escape hatch for CI without binary. spec_sha = SHA-256 of task.toml bytes (workspace sha2 dep). Tests: 6/6 integration (happy, explicit-id, unknown-role, non-spawnable, verify-missing, end-to-end roundtrip). Step 3 (Anthropic API) stays with orchestrator — next iteration adds kei-spawn drive for HTTP automation. Workspace Cargo.toml: +kei-spawn member. Co-Authored-By: Claude Opus 4.7 (1M context) --- _primitives/_rust/Cargo.lock | 13 ++ _primitives/_rust/Cargo.toml | 2 + _primitives/_rust/kei-spawn/Cargo.toml | 29 +++ _primitives/_rust/kei-spawn/src/ledger_sh.rs | 107 +++++++++ _primitives/_rust/kei-spawn/src/lib.rs | 33 +++ _primitives/_rust/kei-spawn/src/main.rs | 106 +++++++++ _primitives/_rust/kei-spawn/src/spawn.rs | 114 +++++++++ _primitives/_rust/kei-spawn/src/verify.rs | 83 +++++++ .../_rust/kei-spawn/tests/spawn_smoke.rs | 216 ++++++++++++++++++ 9 files changed, 703 insertions(+) create mode 100644 _primitives/_rust/kei-spawn/Cargo.toml create mode 100644 _primitives/_rust/kei-spawn/src/ledger_sh.rs create mode 100644 _primitives/_rust/kei-spawn/src/lib.rs create mode 100644 _primitives/_rust/kei-spawn/src/main.rs create mode 100644 _primitives/_rust/kei-spawn/src/spawn.rs create mode 100644 _primitives/_rust/kei-spawn/src/verify.rs create mode 100644 _primitives/_rust/kei-spawn/tests/spawn_smoke.rs diff --git a/_primitives/_rust/Cargo.lock b/_primitives/_rust/Cargo.lock index 492cd96..9e70c25 100644 --- a/_primitives/_rust/Cargo.lock +++ b/_primitives/_rust/Cargo.lock @@ -2197,6 +2197,19 @@ dependencies = [ "tempfile", ] +[[package]] +name = "kei-spawn" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "kei-agent-runtime", + "serde", + "serde_json", + "sha2 0.10.9", + "tempfile", +] + [[package]] name = "kei-store" version = "0.1.0" diff --git a/_primitives/_rust/Cargo.toml b/_primitives/_rust/Cargo.toml index 6067713..a73896b 100644 --- a/_primitives/_rust/Cargo.toml +++ b/_primitives/_rust/Cargo.toml @@ -47,6 +47,8 @@ members = [ "kei-pipe", # v1 substrate — deterministic result cache for pure (query/transform) atoms "kei-cache", + # agent substrate v1 — automation envelope: prepare + ledger fork + verify + "kei-spawn", ] [workspace.package] diff --git a/_primitives/_rust/kei-spawn/Cargo.toml b/_primitives/_rust/kei-spawn/Cargo.toml new file mode 100644 index 0000000..e4cb5b1 --- /dev/null +++ b/_primitives/_rust/kei-spawn/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "kei-spawn" +version = "0.1.0" +edition = "2021" +rust-version = "1.75" +description = "Agent substrate v1 — automation envelope around prepare + ledger fork + verify" + +[[bin]] +name = "kei-spawn" +path = "src/main.rs" + +[lib] +name = "kei_spawn" +path = "src/lib.rs" + +[dependencies] +kei-agent-runtime = { path = "../kei-agent-runtime" } +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +anyhow = "1" +sha2 = { workspace = true } + +[dev-dependencies] +tempfile = "3" + +[package.metadata.keisei] +backend = "none" +description = "Wraps kei-agent-runtime prepare + kei-ledger fork + kei-agent-runtime verify into a single CLI. Step 3 (Agent tool call) stays with the orchestrator." diff --git a/_primitives/_rust/kei-spawn/src/ledger_sh.rs b/_primitives/_rust/kei-spawn/src/ledger_sh.rs new file mode 100644 index 0000000..38935c6 --- /dev/null +++ b/_primitives/_rust/kei-spawn/src/ledger_sh.rs @@ -0,0 +1,107 @@ +//! Thin subprocess wrapper around the `kei-ledger` binary. +//! +//! kei-ledger is a bin-only crate (no lib.rs at the time kei-spawn was +//! introduced). We shell to it rather than replicate SQL — same process +//! model users expect, same DB file, same env contract (`KEI_LEDGER_DB`). +//! +//! Every call surfaces stderr on failure so orchestrator sees the real +//! ledger error (branch too long, duplicate id, etc.), not a wrapped one. + +use anyhow::{anyhow, Result}; +use std::process::Command; + +/// Resolve `kei-ledger` executable. Env override → CARGO env (tests) → PATH. +pub fn ledger_bin() -> String { + if let Ok(b) = std::env::var("KEI_LEDGER_BIN") { + return b; + } + // CARGO_BIN_EXE_kei-ledger is set for integration tests under workspace. + if let Ok(b) = std::env::var("CARGO_BIN_EXE_kei-ledger") { + return b; + } + "kei-ledger".into() +} + +/// Test / sandbox escape hatch: when set, every ledger call is a no-op. +/// Integration tests use this to avoid needing the real kei-ledger binary +/// on PATH. Production callers MUST NOT set this env var. +fn is_noop() -> bool { + std::env::var("KEI_SPAWN_LEDGER_NOOP") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false) +} + +/// Run `kei-ledger fork` with DNA + worktree metadata. +pub fn fork( + id: &str, + branch: &str, + parent: Option<&str>, + spec_sha: &str, + worktree: Option<&str>, + dna: Option<&str>, +) -> Result<()> { + if is_noop() { + let _ = (id, branch, parent, spec_sha, worktree, dna); + return Ok(()); + } + let mut cmd = Command::new(ledger_bin()); + cmd.args(["fork", id, branch, "--spec-sha", spec_sha]); + if let Some(p) = parent { + cmd.args(["--parent", p]); + } + if let Some(w) = worktree { + cmd.args(["--worktree", w]); + } + if let Some(d) = dna { + cmd.args(["--dna", d]); + } + run(&mut cmd, "fork") +} + +/// Run `kei-ledger done --summary `. +pub fn done(id: &str, summary: &str) -> Result<()> { + if is_noop() { + let _ = (id, summary); + return Ok(()); + } + let mut cmd = Command::new(ledger_bin()); + cmd.args(["done", id, "--summary", summary]); + run(&mut cmd, "done") +} + +/// Run `kei-ledger fail --reason `. +pub fn fail(id: &str, reason: &str) -> Result<()> { + if is_noop() { + let _ = (id, reason); + return Ok(()); + } + let mut cmd = Command::new(ledger_bin()); + cmd.args(["fail", id, "--reason", reason]); + run(&mut cmd, "fail") +} + +/// Run `kei-ledger list --status running`. Returns raw stdout lines. +pub fn list_running() -> Result { + if is_noop() { + return Ok(String::from("(noop: KEI_SPAWN_LEDGER_NOOP=1)\n")); + } + let mut cmd = Command::new(ledger_bin()); + cmd.args(["list", "--status", "running"]); + let out = cmd.output().map_err(|e| anyhow!("spawn kei-ledger: {e}"))?; + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + return Err(anyhow!("kei-ledger list failed: {stderr}")); + } + Ok(String::from_utf8_lossy(&out.stdout).to_string()) +} + +fn run(cmd: &mut Command, stage: &str) -> Result<()> { + let out = cmd + .output() + .map_err(|e| anyhow!("spawn kei-ledger {stage}: {e}"))?; + if out.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + Err(anyhow!("kei-ledger {stage} failed: {stderr}")) +} diff --git a/_primitives/_rust/kei-spawn/src/lib.rs b/_primitives/_rust/kei-spawn/src/lib.rs new file mode 100644 index 0000000..bc5986d --- /dev/null +++ b/_primitives/_rust/kei-spawn/src/lib.rs @@ -0,0 +1,33 @@ +//! kei-spawn — automation envelope around kei-agent-runtime + kei-ledger. +//! +//! Orchestrator flow pre-kei-spawn: +//! 1. Write task.toml manually +//! 2. Run `kei-agent-runtime prepare` +//! 3. Invoke Agent tool (harness-internal, orchestrator-only) +//! 4. Run `kei-ledger fork` +//! 5. On return, run `kei-agent-runtime verify` +//! +//! With kei-spawn, steps 2 + 4 collapse to one `kei-spawn spawn ` call +//! and step 5 collapses to one `kei-spawn verify ` call. +//! Step 3 (the actual Agent tool invocation) STILL belongs to the orchestrator +//! because Claude Code's `Agent` tool is harness-internal — it can't be invoked +//! from Rust. `kei-spawn` emits a JSON bundle the orchestrator pastes. +//! +//! Design constraints: +//! - Constructor Pattern: one module = one responsibility, ≤200 LOC file, +//! ≤30 LOC fn. +//! - No HTTP / no Anthropic API — that's a later `kei-spawn drive` iteration. +//! - No git / no shell — ledger interactions go through `kei-ledger` as a +//! subprocess to avoid adding kei-ledger as a direct dep while it still +//! lacks a lib.rs (can't link to a bin-only crate). +//! +//! Per RULE 0.13: kei-spawn NEVER creates branches or commits. The orchestrator +//! owns git state. kei-spawn only writes into `tasks//` and invokes +//! `kei-ledger` (which itself only writes to SQLite). + +pub mod ledger_sh; +pub mod spawn; +pub mod verify; + +pub use spawn::{spawn_from_task, SpawnOutput}; +pub use verify::{verify_agent, VerifyOutput}; diff --git a/_primitives/_rust/kei-spawn/src/main.rs b/_primitives/_rust/kei-spawn/src/main.rs new file mode 100644 index 0000000..71a5df2 --- /dev/null +++ b/_primitives/_rust/kei-spawn/src/main.rs @@ -0,0 +1,106 @@ +//! kei-spawn — CLI dispatcher. +//! +//! Three subcommands: +//! - `spawn ` — prepare invocation + ledger fork, emit JSON +//! - `verify ` — run verify pipeline, update ledger +//! - `list-pending` — forward `kei-ledger list --status running` + +use clap::{Parser, Subcommand}; +use std::path::PathBuf; +use std::process::ExitCode; + +use kei_spawn::{ledger_sh, spawn_from_task, verify_agent}; + +#[derive(Parser)] +#[command( + name = "kei-spawn", + version, + about = "Automation envelope: prepare + ledger fork + verify (RULE 0.13-compliant)" +)] +struct Cli { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// Prepare an Agent-tool invocation + register ledger row. + Spawn { + /// Path to task.toml. + task: PathBuf, + /// kit root (default: cwd). + #[arg(long)] + kit_root: Option, + }, + /// Run verify pipeline + update ledger status. + Verify { + /// agent-id previously emitted by `kei-spawn spawn`. + agent_id: String, + /// Worktree path reported by the Claude harness on agent return. + worktree: PathBuf, + #[arg(long)] + kit_root: Option, + }, + /// Show all running ledger rows. + ListPending, +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + match cli.cmd { + Cmd::Spawn { task, kit_root } => run_spawn(task, kit_root), + Cmd::Verify { agent_id, worktree, kit_root } => { + run_verify(agent_id, worktree, kit_root) + } + Cmd::ListPending => run_list_pending(), + } +} + +fn run_spawn(task: PathBuf, kit_root: Option) -> ExitCode { + let kit = kit_root_or_cwd(kit_root); + match spawn_from_task(&task, &kit) { + Ok(out) => emit_json(&out), + Err(e) => err("spawn", e), + } +} + +fn run_verify(agent_id: String, worktree: PathBuf, kit_root: Option) -> ExitCode { + let kit = kit_root_or_cwd(kit_root); + match verify_agent(&agent_id, &worktree, &kit) { + Ok(out) => { + let code = if out.is_clean { ExitCode::SUCCESS } else { ExitCode::from(2) }; + let _ = emit_json(&out); + code + } + Err(e) => err("verify", e), + } +} + +fn run_list_pending() -> ExitCode { + match ledger_sh::list_running() { + Ok(s) => { + print!("{s}"); + ExitCode::SUCCESS + } + Err(e) => err("list-pending", e), + } +} + +fn emit_json(v: &T) -> ExitCode { + match serde_json::to_string_pretty(v) { + Ok(s) => { + println!("{s}"); + ExitCode::SUCCESS + } + Err(e) => err("serialize json", e), + } +} + +fn kit_root_or_cwd(arg: Option) -> PathBuf { + arg.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))) +} + +fn err(stage: &str, e: impl std::fmt::Display) -> ExitCode { + eprintln!("kei-spawn {stage}: {e}"); + ExitCode::from(1) +} diff --git a/_primitives/_rust/kei-spawn/src/spawn.rs b/_primitives/_rust/kei-spawn/src/spawn.rs new file mode 100644 index 0000000..f4d9d7c --- /dev/null +++ b/_primitives/_rust/kei-spawn/src/spawn.rs @@ -0,0 +1,114 @@ +//! spawn — orchestrator-driven task → prepared agent + ledger row. +//! +//! One public entry point: `spawn_from_task`. Given a task.toml and a +//! kit_root, it: +//! 1. Parses task.toml via `kei_agent_runtime::spawn::load_task` +//! 2. Composes `AgentInvocation` via `kei_agent_runtime::prepare::prepare` +//! (auto-generates agent-id if absent) +//! 3. Copies the resolved agent-id back into the task and writes +//! `tasks//{prompt.md, task.toml}` via +//! `kei_agent_runtime::spawn::prepare_agent` +//! 4. Computes spec_sha (SHA-256 of the task TOML content) +//! 5. Registers a running row in the ledger via `kei-ledger fork` +//! 6. Returns `SpawnOutput` — everything orchestrator needs to call +//! Claude Code's `Agent` tool (serialised as JSON). +//! +//! Never invokes git. Never invokes the Agent tool. Per RULE 0.13. + +use anyhow::{Context, Result}; +use kei_agent_runtime::{prepare, spawn as runtime_spawn}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; + +use crate::ledger_sh; + +/// The bundle orchestrator hands to Claude Code's Agent tool. +#[derive(Debug, Clone, Serialize)] +pub struct SpawnOutput { + pub agent_id: String, + pub dna: String, + pub role: String, + pub subagent_type: String, + pub isolation: Option, + pub description: String, + pub prompt: String, + pub prompt_path: PathBuf, + pub task_path: PathBuf, + pub spec_sha: String, + pub branch: String, + pub verify_command: String, + pub ledger_row: String, + pub next_step: String, +} + +/// Main spawn entry. See module doc for the 6-step pipeline. +pub fn spawn_from_task(task_path: &Path, kit_root: &Path) -> Result { + let mut task = runtime_spawn::load_task(task_path) + .with_context(|| format!("load task {}", task_path.display()))?; + let inv = prepare::prepare(&task, kit_root).context("compose AgentInvocation")?; + // Propagate auto-generated agent-id back into the task so `prepare_agent` + // can use it as the directory name and the ledger row keys by it. + task.task.agent_id = inv.agent_id.clone(); + let prepared = runtime_spawn::prepare_agent(&task, kit_root).context("prepare_agent")?; + let task_bytes = std::fs::read(&prepared.task_path) + .with_context(|| format!("read written task {}", prepared.task_path.display()))?; + let spec_sha = sha256_hex(&task_bytes); + let branch = format!("agent/{}", inv.agent_id); + let parent = task + .task + .parent_agent + .as_deref() + .filter(|s| !s.is_empty()); + ledger_sh::fork( + &inv.agent_id, + &branch, + parent, + &spec_sha, + prepared.dir.to_str(), + Some(&inv.dna), + ) + .context("kei-ledger fork")?; + Ok(build_output(inv, prepared, spec_sha, branch)) +} + +fn build_output( + inv: prepare::AgentInvocation, + prepared: runtime_spawn::PreparedAgent, + spec_sha: String, + branch: String, +) -> SpawnOutput { + let next_step = format!( + "Invoke Agent tool with subagent_type={}, isolation={}, prompt=", + inv.subagent_type, + inv.isolation.as_deref().unwrap_or(""), + prepared.prompt_path.display() + ); + SpawnOutput { + agent_id: inv.agent_id, + dna: inv.dna, + role: inv.role, + subagent_type: inv.subagent_type, + isolation: inv.isolation, + description: inv.description, + prompt: inv.prompt, + prompt_path: prepared.prompt_path, + task_path: prepared.task_path, + spec_sha, + branch, + verify_command: inv.verify_command, + ledger_row: inv.ledger_row, + next_step, + } +} + +fn sha256_hex(bytes: &[u8]) -> String { + let mut h = Sha256::new(); + h.update(bytes); + let digest = h.finalize(); + let mut s = String::with_capacity(64); + for b in digest { + s.push_str(&format!("{:02x}", b)); + } + s +} diff --git a/_primitives/_rust/kei-spawn/src/verify.rs b/_primitives/_rust/kei-spawn/src/verify.rs new file mode 100644 index 0000000..1cea47c --- /dev/null +++ b/_primitives/_rust/kei-spawn/src/verify.rs @@ -0,0 +1,83 @@ +//! verify — orchestrator-side post-return verification + ledger bookkeeping. +//! +//! Given an agent-id and the worktree path the harness returned, this module: +//! 1. Reads `/tasks//task.toml` +//! 2. Resolves role → ordered capability list +//! 3. Runs `kei_agent_runtime::verify::verify_task` (worktree pass) +//! 4. On pass, marks ledger row `done`; on fail, marks `failed` +//! 5. Emits a `VerifyOutput` JSON (pass/fail + failed-capability list) +//! +//! Simulated-merge pass is orchestrator-scope (needs git) so we stay in +//! `RunMode::Worktree`. A future `kei-spawn verify-merge` flavour can be +//! added once orchestrator-owned git helpers exist. + +use anyhow::{anyhow, Context, Result}; +use kei_agent_runtime::capability::RunMode; +use kei_agent_runtime::{spawn as runtime_spawn, verify as runtime_verify}; +use serde::Serialize; +use std::path::{Path, PathBuf}; + +use crate::ledger_sh; + +/// Outcome of a single verify pass, including failed-capability detail. +#[derive(Debug, Clone, Serialize)] +pub struct VerifyOutput { + pub agent_id: String, + pub passed: Vec, + pub failed: Vec, + pub is_clean: bool, + pub worktree: PathBuf, +} + +/// Main verify entry. On pass → ledger done; on fail → ledger failed. +pub fn verify_agent(agent_id: &str, worktree: &Path, kit_root: &Path) -> Result { + let task_path = task_toml_path(kit_root, agent_id)?; + let task = runtime_spawn::load_task(&task_path) + .with_context(|| format!("load task {}", task_path.display()))?; + let caps = runtime_verify::load_role_capabilities(kit_root, &task.task.role) + .context("resolve role capabilities")?; + let report = runtime_verify::verify_task( + &task, agent_id, worktree, kit_root, RunMode::Worktree, &caps, None, + ) + .context("run verify pipeline")?; + let is_clean = report.is_clean(); + update_ledger(agent_id, &report)?; + Ok(VerifyOutput { + agent_id: agent_id.to_string(), + passed: report.passed, + failed: report.failed, + is_clean, + worktree: worktree.to_path_buf(), + }) +} + +/// Resolve and validate `/tasks//task.toml`. +fn task_toml_path(kit_root: &Path, agent_id: &str) -> Result { + let p = kit_root.join("tasks").join(agent_id).join("task.toml"); + if !p.is_file() { + return Err(anyhow!( + "task.toml not found at {}: did you run `kei-spawn spawn` first?", + p.display() + )); + } + Ok(p) +} + +fn update_ledger(agent_id: &str, report: &runtime_verify::VerifyReport) -> Result<()> { + if report.is_clean() { + let summary = format!("verify passed ({} capabilities)", report.passed.len()); + ledger_sh::done(agent_id, &summary).context("kei-ledger done")?; + } else { + let reason = format_failures(&report.failed); + ledger_sh::fail(agent_id, &reason).context("kei-ledger fail")?; + } + Ok(()) +} + +fn format_failures(failed: &[runtime_verify::FailedEntry]) -> String { + let mut parts = Vec::with_capacity(failed.len()); + for f in failed { + parts.push(format!("{}: {}", f.capability, f.reason)); + } + parts.join("; ") +} diff --git a/_primitives/_rust/kei-spawn/tests/spawn_smoke.rs b/_primitives/_rust/kei-spawn/tests/spawn_smoke.rs new file mode 100644 index 0000000..c841939 --- /dev/null +++ b/_primitives/_rust/kei-spawn/tests/spawn_smoke.rs @@ -0,0 +1,216 @@ +//! spawn_smoke — integration tests for kei-spawn library API. +//! +//! These tests set `KEI_SPAWN_LEDGER_NOOP=1` so the ledger subprocess is a +//! no-op — we exercise the compose + prepare_agent + output shape path +//! without depending on a real `kei-ledger` binary being on PATH. +//! +//! Fixtures follow the same pattern as kei-agent-runtime's tests: write a +//! minimal `_roles/` + `_capabilities/` tree into a tempdir, a task.toml +//! referencing the role, then call `spawn_from_task` and assert the JSON +//! shape + on-disk artefacts. + +use kei_spawn::{spawn_from_task, verify_agent}; +use std::path::Path; +use tempfile::TempDir; + +fn write_capability(root: &Path, cat: &str, slug: &str, body: &str) { + let dir = root.join("_capabilities").join(cat).join(slug); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("text.md"), body).unwrap(); +} + +fn write_role(root: &Path, name: &str, toml: &str) { + std::fs::create_dir_all(root.join("_roles")).unwrap(); + std::fs::write(root.join("_roles").join(format!("{name}.toml")), toml).unwrap(); +} + +fn write_task(root: &Path, toml: &str) -> std::path::PathBuf { + let path = root.join("task.toml"); + std::fs::write(&path, toml).unwrap(); + path +} + +fn minimal_kit(root: &Path) { + write_capability(root, "policy", "no-git-ops", "## Never git.\n"); + write_capability(root, "output", "report-format", "## Report fields.\n"); + write_role( + root, + "edit-local", + r#" +[role] +name = "edit-local" +spawnable = true +claude-subagent-type = "code-implementer" + +[capabilities] +required = ["policy::no-git-ops", "output::report-format"] +"#, + ); +} + +fn set_noop() { + std::env::set_var("KEI_SPAWN_LEDGER_NOOP", "1"); +} + +#[test] +fn spawn_happy_path_emits_full_output() { + set_noop(); + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + minimal_kit(root); + + let task_path = write_task( + root, + r#" +[task] +role = "edit-local" + +[body] +text = "Port kei-forge templating to pure Rust." +"#, + ); + + let out = spawn_from_task(&task_path, root).expect("spawn should succeed"); + assert!(out.agent_id.starts_with("ag-edit-local-"), "id: {}", out.agent_id); + assert_eq!(out.role, "edit-local"); + assert_eq!(out.subagent_type, "code-implementer"); + assert_eq!(out.isolation.as_deref(), Some("worktree")); + assert!(out.prompt.contains("Port kei-forge")); + assert!(out.prompt.contains("Never git")); + assert_eq!(out.spec_sha.len(), 64, "sha256 hex = 64 chars: {}", out.spec_sha); + assert!(out.branch.starts_with("agent/")); + assert!(out.branch.contains(&out.agent_id)); + assert!(out.next_step.contains("code-implementer")); + assert!(out.prompt_path.is_file()); + assert!(out.task_path.is_file()); + assert!(!out.dna.is_empty()); +} + +#[test] +fn spawn_preserves_explicit_agent_id() { + set_noop(); + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + minimal_kit(root); + + let task_path = write_task( + root, + r#" +[task] +role = "edit-local" +agent-id = "ag-edit-local-explicit-12345" + +[body] +text = "Explicit id test." +"#, + ); + + let out = spawn_from_task(&task_path, root).expect("spawn should succeed"); + assert_eq!(out.agent_id, "ag-edit-local-explicit-12345"); + assert_eq!(out.branch, "agent/ag-edit-local-explicit-12345"); +} + +#[test] +fn spawn_rejects_unknown_role() { + set_noop(); + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + + let task_path = write_task( + root, + r#" +[task] +role = "does-not-exist" + +[body] +text = "x" +"#, + ); + + let err = spawn_from_task(&task_path, root).expect_err("unknown role must fail"); + let msg = format!("{err:#}"); + assert!( + msg.contains("role") || msg.contains("does-not-exist"), + "error should reference the role: {msg}" + ); +} + +#[test] +fn spawn_refuses_non_spawnable_role() { + set_noop(); + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + + write_role( + root, + "git-ops", + r#" +[role] +name = "git-ops" +spawnable = false + +[capabilities] +required = [] +"#, + ); + + let task_path = write_task( + root, + r#" +[task] +role = "git-ops" + +[body] +text = "should refuse" +"#, + ); + + let err = spawn_from_task(&task_path, root).expect_err("git-ops must be refused"); + let msg = format!("{err:#}"); + assert!(msg.contains("RULE 0.13"), "refusal must cite RULE 0.13: {msg}"); +} + +#[test] +fn verify_fails_when_task_missing() { + set_noop(); + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + minimal_kit(root); + + let worktree = tmp.path().join("wt"); + std::fs::create_dir_all(&worktree).unwrap(); + + let err = verify_agent("ag-does-not-exist", &worktree, root) + .expect_err("missing task.toml must fail"); + let msg = format!("{err:#}"); + assert!(msg.contains("task.toml not found"), "msg: {msg}"); +} + +#[test] +fn spawn_then_verify_end_to_end() { + set_noop(); + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + minimal_kit(root); + + let task_path = write_task( + root, + r#" +[task] +role = "edit-local" + +[body] +text = "Round-trip test." +"#, + ); + + let spawned = spawn_from_task(&task_path, root).expect("spawn"); + // worktree doesn't need real content — the two capabilities in + // minimal_kit have no verify implementations, so the report is clean. + let worktree = tmp.path().join("wt"); + std::fs::create_dir_all(&worktree).unwrap(); + + let verified = verify_agent(&spawned.agent_id, &worktree, root).expect("verify"); + assert_eq!(verified.agent_id, spawned.agent_id); + assert!(verified.is_clean, "failed: {:?}", verified.failed); +}