diff --git a/_primitives/_rust/kei-spawn/src/drive.rs b/_primitives/_rust/kei-spawn/src/drive.rs new file mode 100644 index 0000000..4afacd7 --- /dev/null +++ b/_primitives/_rust/kei-spawn/src/drive.rs @@ -0,0 +1,152 @@ +//! drive — design-as-stubbed Anthropic-API driver for `kei-spawn drive`. +//! +//! The `drive` subcommand is the future one-call replacement for the current +//! two-step dance (`kei-spawn spawn` → orchestrator pastes Agent invocation). +//! Wiring it to a live Anthropic HTTP endpoint is a breaking change (adds +//! `reqwest` + tokio + a secrets contract), so v0.1 ships a stub: the +//! pipeline, types, and trait are defined; the HTTP impl returns +//! `NotImplemented` via `ManualDriver`. +//! +//! Exit-code contract (mirrors `kei-runtime::InvokeError::NotImplemented`): +//! - 64 (EX_USAGE range) when the driver yields `NotImplemented` +//! - 1 on spawn failure (same as `kei-spawn spawn`) +//! - 0 only when a real driver returns Ok (HttpDriver future path) +//! +//! Constructor Pattern: one trait + two zero-state impls + one helper fn. + +use serde::Serialize; + +/// Success envelope for a future `HttpDriver` (and the contract +/// `ManualDriver` deliberately never fulfils). +#[derive(Debug, Clone, Serialize)] +pub struct AgentResult { + pub agent_id: String, + pub transcript: String, + pub finish_reason: String, +} + +/// Errors surfaced from driver invocation. `NotImplemented` is retained as +/// the v0.1 escape hatch; `Transport` is reserved for the HTTP impl. +#[derive(Debug)] +pub enum DriveError { + NotImplemented { reason: String }, + Transport { message: String }, +} + +impl std::fmt::Display for DriveError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotImplemented { reason } => { + write!(f, "kei-spawn drive: {reason}") + } + Self::Transport { message } => { + write!(f, "kei-spawn drive transport: {message}") + } + } + } +} + +impl std::error::Error for DriveError {} + +/// Abstraction over "how does an agent invocation actually happen." +/// +/// v0.1 has one impl: `ManualDriver` (prints instructions, returns +/// `NotImplemented`). Future: `HttpDriver` backed by `reqwest` + +/// `KEI_ANTHROPIC_KEY` + POST `https://api.anthropic.com/v1/messages`. +pub trait AnthropicDriver { + fn invoke( + &self, + prompt: &str, + subagent_type: &str, + isolation: Option<&str>, + ) -> Result; +} + +/// v0.1 driver — returns `NotImplemented` unconditionally. +/// +/// Intentional: lets `kei-spawn drive` ship a complete CLI surface +/// (help, argument parsing, JSON emission) before the HTTP dep is taken. +pub struct ManualDriver; + +impl AnthropicDriver for ManualDriver { + fn invoke( + &self, + _prompt: &str, + _subagent_type: &str, + _isolation: Option<&str>, + ) -> Result { + Err(DriveError::NotImplemented { + reason: not_implemented_message(), + }) + } +} + +/// Placeholder for the future HTTP-backed driver. +/// +/// Deliberately kept dep-free: adding `reqwest` + tokio here would force a +/// breaking change on every consumer of `kei-spawn` today. When the HTTP +/// impl lands, this struct gains fields (`api_key`, `endpoint`, `client`) +/// and the `invoke` body is replaced. +pub struct HttpDriver; + +impl AnthropicDriver for HttpDriver { + fn invoke( + &self, + _prompt: &str, + _subagent_type: &str, + _isolation: Option<&str>, + ) -> Result { + Err(DriveError::NotImplemented { + reason: "HttpDriver not wired in v0.1 — add reqwest + tokio in a dedicated PR" + .to_string(), + }) + } +} + +/// Canonical stderr message for the v0.1 stub. Kept as a fn so both the +/// driver impl and the CLI layer emit the exact same string (and so tests +/// can assert on one fixture). +pub fn not_implemented_message() -> String { + "HTTP Anthropic-API integration not yet wired; use spawn then manual \ + Agent-tool invocation (see printed instructions)" + .to_string() +} + +/// Drive helper — orchestrator-facing entry that dispatches to a driver. +/// +/// Kept thin on purpose: the real work (prepare + ledger fork) happens in +/// `spawn_from_task`. `drive` only layers the driver call on top. +pub fn drive_with( + driver: &D, + prompt: &str, + subagent_type: &str, + isolation: Option<&str>, +) -> Result { + driver.invoke(prompt, subagent_type, isolation) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn manual_driver_returns_not_implemented() { + let d = ManualDriver; + let err = d.invoke("p", "code-implementer", Some("worktree")).unwrap_err(); + match err { + DriveError::NotImplemented { reason } => { + assert!(reason.contains("HTTP"), "reason: {reason}"); + } + other => panic!("expected NotImplemented, got {other}"), + } + } + + #[test] + fn http_driver_also_not_implemented_in_v01() { + let d = HttpDriver; + assert!(matches!( + d.invoke("p", "x", None), + Err(DriveError::NotImplemented { .. }) + )); + } +} diff --git a/_primitives/_rust/kei-spawn/src/lib.rs b/_primitives/_rust/kei-spawn/src/lib.rs index bc5986d..0f3935e 100644 --- a/_primitives/_rust/kei-spawn/src/lib.rs +++ b/_primitives/_rust/kei-spawn/src/lib.rs @@ -25,9 +25,14 @@ //! owns git state. kei-spawn only writes into `tasks//` and invokes //! `kei-ledger` (which itself only writes to SQLite). +pub mod drive; pub mod ledger_sh; pub mod spawn; pub mod verify; +pub use drive::{ + drive_with, not_implemented_message, AgentResult, AnthropicDriver, DriveError, HttpDriver, + ManualDriver, +}; 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 index 71a5df2..b46bce8 100644 --- a/_primitives/_rust/kei-spawn/src/main.rs +++ b/_primitives/_rust/kei-spawn/src/main.rs @@ -1,15 +1,26 @@ //! kei-spawn — CLI dispatcher. //! -//! Three subcommands: +//! Four subcommands: //! - `spawn ` — prepare invocation + ledger fork, emit JSON +//! - `drive ` — spawn + attempt driver invocation (v0.1: stub, +//! returns exit 64 NotImplemented after emitting SpawnOutput JSON) //! - `verify ` — run verify pipeline, update ledger //! - `list-pending` — forward `kei-ledger list --status running` +//! +//! Exit codes: +//! 0 success (spawn, verify-clean, list-pending) +//! 1 generic failure (any Err from the pipeline) +//! 2 verify-failed (capabilities failed but pipeline ran) +//! 64 drive NotImplemented (v0.1 stub path) use clap::{Parser, Subcommand}; use std::path::PathBuf; use std::process::ExitCode; -use kei_spawn::{ledger_sh, spawn_from_task, verify_agent}; +use kei_spawn::{ + drive_with, ledger_sh, not_implemented_message, spawn_from_task, verify_agent, DriveError, + ManualDriver, SpawnOutput, +}; #[derive(Parser)] #[command( @@ -32,6 +43,14 @@ enum Cmd { #[arg(long)] kit_root: Option, }, + /// Spawn + invoke driver (v0.1: stub — emits SpawnOutput then exit 64). + Drive { + /// 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`. @@ -49,6 +68,7 @@ fn main() -> ExitCode { let cli = Cli::parse(); match cli.cmd { Cmd::Spawn { task, kit_root } => run_spawn(task, kit_root), + Cmd::Drive { task, kit_root } => run_drive(task, kit_root), Cmd::Verify { agent_id, worktree, kit_root } => { run_verify(agent_id, worktree, kit_root) } @@ -64,6 +84,32 @@ fn run_spawn(task: PathBuf, kit_root: Option) -> ExitCode { } } +fn run_drive(task: PathBuf, kit_root: Option) -> ExitCode { + let kit = kit_root_or_cwd(kit_root); + let out = match spawn_from_task(&task, &kit) { + Ok(o) => o, + Err(e) => return err("drive", e), + }; + // Always emit SpawnOutput JSON first so callers can pipe it regardless + // of the driver outcome. Drive-only failure modes come via stderr. + if emit_json(&out) != ExitCode::SUCCESS { + return ExitCode::from(1); + } + dispatch_driver(&out) +} + +fn dispatch_driver(out: &SpawnOutput) -> ExitCode { + let driver = ManualDriver; + match drive_with(&driver, &out.prompt, &out.subagent_type, out.isolation.as_deref()) { + Ok(_) => ExitCode::SUCCESS, + Err(DriveError::NotImplemented { .. }) => { + eprintln!("kei-spawn drive: {}", not_implemented_message()); + ExitCode::from(64) + } + Err(e) => err("drive", 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) { diff --git a/_primitives/_rust/kei-spawn/tests/drive_smoke.rs b/_primitives/_rust/kei-spawn/tests/drive_smoke.rs new file mode 100644 index 0000000..6ad5a02 --- /dev/null +++ b/_primitives/_rust/kei-spawn/tests/drive_smoke.rs @@ -0,0 +1,130 @@ +//! drive_smoke — integration tests for `kei-spawn drive` subcommand. +//! +//! The drive subcommand shells the full pipeline: +//! 1. spawn_from_task (prepare + ledger fork) +//! 2. SpawnOutput JSON → stdout +//! 3. ManualDriver::invoke → NotImplemented → stderr + exit 64 +//! +//! Because exit-code assertions require invoking the real binary, these +//! tests use `CARGO_BIN_EXE_kei-spawn` (populated by cargo for integration +//! tests) with `KEI_SPAWN_LEDGER_NOOP=1` set so the ledger subprocess is +//! a no-op. + +use std::path::Path; +use std::process::Command; +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 bin() -> Command { + let mut c = Command::new(env!("CARGO_BIN_EXE_kei-spawn")); + c.env("KEI_SPAWN_LEDGER_NOOP", "1"); + c +} + +#[test] +fn drive_on_valid_task_emits_spawn_json_and_exits_64() { + 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 = "Drive smoke test." +"#, + ); + + let output = bin() + .arg("drive") + .arg(&task_path) + .arg("--kit-root") + .arg(root) + .output() + .expect("run kei-spawn drive"); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + // Stdout must be SpawnOutput JSON (contain agent_id + prompt fields). + assert!(stdout.contains("\"agent_id\""), "stdout missing agent_id: {stdout}"); + assert!(stdout.contains("\"subagent_type\""), "stdout missing subagent_type: {stdout}"); + assert!(stdout.contains("\"prompt\""), "stdout missing prompt: {stdout}"); + // Stderr must explain the v0.1 NotImplemented state. + assert!( + stderr.contains("HTTP Anthropic-API integration not yet wired"), + "stderr missing NotImplemented msg: {stderr}" + ); + // Exit code 64 (EX_USAGE range, NotImplemented convention). + assert_eq!(output.status.code(), Some(64), "exit code must be 64, got: {:?}; stderr={stderr}", output.status.code()); +} + +#[test] +fn drive_on_unknown_role_exits_nonzero_with_spawn_error() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + // NO minimal_kit — role cannot resolve. + let task_path = write_task( + root, + r#" +[task] +role = "ghost-role" + +[body] +text = "x" +"#, + ); + + let output = bin() + .arg("drive") + .arg(&task_path) + .arg("--kit-root") + .arg(root) + .output() + .expect("run kei-spawn drive"); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("drive") || stderr.contains("role") || stderr.contains("ghost-role"), + "stderr should reference the spawn failure: {stderr}" + ); + // Spawn error surfaces as exit 1 (not 64); must NOT be success. + let code = output.status.code(); + assert_ne!(code, Some(0), "unknown role must fail"); + assert_ne!(code, Some(64), "unknown role must not be NotImplemented: stderr={stderr}"); +}