feat(sp1): NEW kei-spawn crate — automation envelope

spawn <task.toml> 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 <task.toml> for HTTP automation.

Workspace Cargo.toml: +kei-spawn member.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Parfii-bot 2026-04-23 10:21:45 +08:00
parent 010def05ad
commit 02451f5f49
9 changed files with 703 additions and 0 deletions

View file

@ -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"

View file

@ -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]

View file

@ -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."

View file

@ -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 <id> --summary <s>`.
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 <id> --reason <r>`.
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<String> {
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}"))
}

View file

@ -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 <task.toml>` call
//! and step 5 collapses to one `kei-spawn verify <agent-id> <worktree>` 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/<agent-id>/` 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};

View file

@ -0,0 +1,106 @@
//! kei-spawn — CLI dispatcher.
//!
//! Three subcommands:
//! - `spawn <task.toml>` — prepare invocation + ledger fork, emit JSON
//! - `verify <agent-id> <worktree>` — 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<PathBuf>,
},
/// 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<PathBuf>,
},
/// 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<PathBuf>) -> 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<PathBuf>) -> 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<T: serde::Serialize>(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>) -> 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)
}

View file

@ -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/<agent-id>/{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<String>,
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<SpawnOutput> {
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=<see prompt field or {}>",
inv.subagent_type,
inv.isolation.as_deref().unwrap_or("<none>"),
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
}

View file

@ -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 `<kit_root>/tasks/<agent-id>/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<String>,
pub failed: Vec<runtime_verify::FailedEntry>,
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<VerifyOutput> {
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 `<kit>/tasks/<agent-id>/task.toml`.
fn task_toml_path(kit_root: &Path, agent_id: &str) -> Result<std::path::PathBuf> {
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("; ")
}

View file

@ -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);
}