diff --git a/_primitives/_rust/kei-agent-runtime/src/lib.rs b/_primitives/_rust/kei-agent-runtime/src/lib.rs index 52f3283..7b4424a 100644 --- a/_primitives/_rust/kei-agent-runtime/src/lib.rs +++ b/_primitives/_rust/kei-agent-runtime/src/lib.rs @@ -7,6 +7,7 @@ //! - `verifies` — 8 on-return verify capabilities //! - `compose` — task.toml + role + capabilities → prompt.md //! - `spawn` — prepare tasks//prompt.md + ledger row +//! - `prepare` — orchestrator-facing `AgentInvocation` bundle (ergonomics) //! - `verify` — run all verify capabilities against agent's return //! - `simulated_merge` — orchestrator-side worktree → apply diff → verify //! @@ -15,6 +16,7 @@ pub mod capability; pub mod compose; pub mod gates; +pub mod prepare; pub mod registry; pub mod simulated_merge; pub mod spawn; diff --git a/_primitives/_rust/kei-agent-runtime/src/main.rs b/_primitives/_rust/kei-agent-runtime/src/main.rs index 24ed8ca..b92378e 100644 --- a/_primitives/_rust/kei-agent-runtime/src/main.rs +++ b/_primitives/_rust/kei-agent-runtime/src/main.rs @@ -2,7 +2,7 @@ use clap::{Parser, Subcommand}; use kei_agent_runtime::capability::RunMode; -use kei_agent_runtime::{compose, spawn, verify}; +use kei_agent_runtime::{compose, prepare, spawn, verify}; use std::path::PathBuf; use std::process::ExitCode; @@ -51,6 +51,16 @@ enum Cmd { #[arg(long)] kit_root: Option, }, + /// Assemble everything orchestrator needs to invoke Agent tool. + /// Does NOT write tasks/ on disk — inspection helper. + Prepare { + task: PathBuf, + #[arg(long)] + kit_root: Option, + /// Output format: human (default) | json | toml + #[arg(long, default_value = "human")] + format: String, + }, } fn main() -> ExitCode { @@ -62,6 +72,35 @@ fn main() -> ExitCode { run_verify(task, worktree, kit_root, main_repo, mode) } Cmd::Run { task, worktree, kit_root } => run_run(task, worktree, kit_root), + Cmd::Prepare { task, kit_root, format } => run_prepare(task, kit_root, format), + } +} + +fn run_prepare(task_path: PathBuf, kit_root: Option, format: String) -> ExitCode { + let kit = kit_root_or_cwd(kit_root); + let task = match spawn::load_task(&task_path) { + Ok(t) => t, + Err(e) => return err("load task", e), + }; + let inv = match prepare::prepare(&task, &kit) { + Ok(i) => i, + Err(e) => return err("prepare", e), + }; + let rendered = match format.as_str() { + "human" => Ok(prepare::render_human(&inv)), + "json" => prepare::render_json(&inv), + "toml" => prepare::render_toml(&inv), + other => { + eprintln!("unknown format '{other}' (expected human|json|toml)"); + return ExitCode::from(2); + } + }; + match rendered { + Ok(s) => { + print!("{s}"); + ExitCode::SUCCESS + } + Err(e) => err("render", e), } } diff --git a/_primitives/_rust/kei-agent-runtime/src/prepare.rs b/_primitives/_rust/kei-agent-runtime/src/prepare.rs new file mode 100644 index 0000000..7e18121 --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/src/prepare.rs @@ -0,0 +1,170 @@ +//! Orchestrator-facing wrapper: task.toml → everything needed to invoke +//! Claude Code's `Agent` tool in a single copy-paste-ready bundle. +//! +//! Per RULE 0.13, the orchestrator (main session) owns branch creation, +//! `isolation: "worktree"` selection, and the actual Agent-tool call. This +//! module only assembles the arguments — no git, no spawn, no shell. +//! +//! Wire: `prepare()` = `compose_prompt()` + role lookup + role→subagent_type +//! resolution. Deliberately does NOT create `tasks//` on disk (that is +//! `spawn::prepare_agent`'s job) so orchestrator can inspect before +//! committing. The "ledger row" field is a pretty-printed string, not a DB +//! write — ledger persistence is the orchestrator's step. + +use crate::capability::TaskSpec; +use crate::compose::compose_prompt; +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +/// Everything the orchestrator needs to hand the Claude `Agent` tool. +#[derive(Debug, Clone, Serialize)] +pub struct AgentInvocation { + pub agent_id: String, + pub role: String, + pub prompt: String, + pub subagent_type: String, + pub isolation: Option, + pub description: String, + pub verify_command: String, + pub ledger_row: String, +} + +/// Assemble an `AgentInvocation` from a parsed task.toml. +/// +/// Errors if the role is unknown or non-spawnable (points at RULE 0.13). +pub fn prepare(task: &TaskSpec, kit_root: &Path) -> Result { + if task.task.role.is_empty() { + return Err(anyhow!("task.role is empty")); + } + if task.task.agent_id.is_empty() { + return Err(anyhow!( + "task.agent-id is empty — orchestrator must allocate via kei-ledger" + )); + } + let role_file = load_role_file(kit_root, &task.task.role)?; + if !role_file.role.spawnable { + return Err(anyhow!( + "role '{}' is NOT spawnable (per RULE 0.13 git-ops is \ + orchestrator-only) — refusing to prepare Agent tool invocation", + task.task.role + )); + } + let prompt = compose_prompt(task, kit_root)?; + let subagent_type = role_file.role.claude_subagent_type.clone().unwrap_or_else( + || default_subagent_type(&task.task.role), + ); + let isolation = default_isolation(&task.task.role); + let description = build_description(&task.task.role, &task.task.agent_id); + let verify_command = build_verify_command(&task.task.agent_id); + let ledger_row = build_ledger_row(task); + Ok(AgentInvocation { + agent_id: task.task.agent_id.clone(), + role: task.task.role.clone(), + prompt, + subagent_type, + isolation, + description, + verify_command, + ledger_row, + }) +} + +/// Human-readable block — copy into Claude Code's Agent-tool dialog. +pub fn render_human(inv: &AgentInvocation) -> String { + let iso = inv.isolation.as_deref().unwrap_or(""); + let mut out = String::new(); + out.push_str("=== AGENT SUBSTRATE v1 — PREPARED SPAWN ===\n"); + out.push_str(&format!("agent-id: {}\n", inv.agent_id)); + out.push_str(&format!("subagent_type: {}\n", inv.subagent_type)); + out.push_str(&format!("isolation: {iso}\n")); + out.push_str(&format!("description: {}\n", inv.description)); + out.push_str("\n--- PROMPT (copy into Agent tool `prompt` param) ---\n"); + out.push_str(&inv.prompt); + if !inv.prompt.ends_with('\n') { + out.push('\n'); + } + out.push_str("--- END PROMPT ---\n\n"); + out.push_str("on return:\n"); + out.push_str(&format!(" {}\n", inv.verify_command)); + out.push_str(" (orchestrator harness returns worktree path in the task-notification)\n\n"); + out.push_str(&format!("ledger: {}\n", inv.ledger_row)); + out +} + +pub fn render_json(inv: &AgentInvocation) -> Result { + serde_json::to_string_pretty(inv).context("serialize AgentInvocation to JSON") +} + +pub fn render_toml(inv: &AgentInvocation) -> Result { + toml::to_string_pretty(inv).context("serialize AgentInvocation to TOML") +} + +fn default_isolation(role: &str) -> Option { + match role { + "edit-local" | "edit-shared" => Some("worktree".into()), + _ => None, + } +} + +fn default_subagent_type(role: &str) -> String { + match role { + "edit-local" | "edit-shared" => "code-implementer", + "explorer" => "Explore", + "read-only" => "critic", + _ => "critic", + } + .into() +} + +fn build_description(role: &str, agent_id: &str) -> String { + let short = agent_id.split('-').take(2).collect::>().join("-"); + format!("{role} agent {short}") +} + +fn build_verify_command(agent_id: &str) -> String { + format!( + "kei-agent-runtime verify tasks/{id}/task.toml \ + --worktree ", + id = agent_id + ) +} + +fn build_ledger_row(task: &TaskSpec) -> String { + let parent = task + .task + .parent_agent + .as_deref() + .filter(|s| !s.is_empty()) + .unwrap_or("none"); + format!( + "running agent-id={} role={} parent={}", + task.task.agent_id, task.task.role, parent + ) +} + +fn load_role_file(kit_root: &Path, role: &str) -> Result { + let path = kit_root.join("_roles").join(format!("{role}.toml")); + let text = std::fs::read_to_string(&path) + .with_context(|| format!("read role file {}", path.display()))?; + toml::from_str::(&text) + .with_context(|| format!("parse role TOML {}", path.display())) +} + +#[derive(Debug, Deserialize)] +struct RoleFile { + #[serde(default)] + role: RoleMeta, +} + +#[derive(Debug, Default, Deserialize)] +struct RoleMeta { + #[serde(default = "spawnable_default")] + spawnable: bool, + #[serde(default, rename = "claude-subagent-type")] + claude_subagent_type: Option, +} + +fn spawnable_default() -> bool { + true +} diff --git a/_primitives/_rust/kei-agent-runtime/tests/prepare_smoke.rs b/_primitives/_rust/kei-agent-runtime/tests/prepare_smoke.rs new file mode 100644 index 0000000..28e5081 --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/tests/prepare_smoke.rs @@ -0,0 +1,120 @@ +//! Prepare smoke — validates orchestrator-facing wrapper. +//! +//! Three fixtures per task spec: +//! 1. Happy path — valid task.toml → AgentInvocation fully populated +//! 2. Unknown role → clear error (role lookup fails) +//! 3. Non-spawnable role (git-ops) → explicit refusal + RULE 0.13 pointer + +use kei_agent_runtime::capability::TaskSpec; +use kei_agent_runtime::prepare::{prepare, render_human}; +use tempfile::TempDir; + +fn write_capability(root: &std::path::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: &std::path::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(); +} + +#[test] +fn happy_path_yields_full_invocation() { + let tmp = TempDir::new().unwrap(); + let root = tmp.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"] +"#, + ); + + let mut task = TaskSpec::default(); + task.task.role = "edit-local".into(); + task.task.agent_id = "edit-local-forge-abc123".into(); + task.body.text = "Port kei-forge templating to pure-Rust.".into(); + + let inv = prepare(&task, root).expect("prepare should succeed"); + assert_eq!(inv.agent_id, "edit-local-forge-abc123"); + assert_eq!(inv.role, "edit-local"); + assert_eq!(inv.subagent_type, "code-implementer"); + assert_eq!(inv.isolation.as_deref(), Some("worktree")); + assert!(inv.prompt.contains("Never git")); + assert!(inv.prompt.contains("Report fields")); + assert!(inv.prompt.contains("Port kei-forge templating")); + assert!(inv.verify_command.contains("kei-agent-runtime verify")); + assert!(inv.verify_command.contains("edit-local-forge-abc123")); + assert!(inv.ledger_row.contains("running")); + assert!(inv.ledger_row.contains("edit-local")); + assert!(inv.ledger_row.contains("parent=none")); + + let human = render_human(&inv); + assert!(human.contains("=== AGENT SUBSTRATE v1")); + assert!(human.contains("--- PROMPT")); + assert!(human.contains("--- END PROMPT")); + assert!(human.contains("subagent_type: code-implementer")); +} + +#[test] +fn unknown_role_errors_clearly() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + + let mut task = TaskSpec::default(); + task.task.role = "does-not-exist".into(); + task.task.agent_id = "x-1".into(); + + let err = prepare(&task, root).expect_err("unknown role must fail"); + let msg = format!("{err:#}"); + assert!( + msg.contains("does-not-exist") || msg.contains("role"), + "error should mention the role or the word 'role': got {msg}" + ); +} + +#[test] +fn non_spawnable_role_refused_with_rule_013_pointer() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + + write_role( + root, + "git-ops", + r#" +[role] +name = "git-ops" +spawnable = false +claude-subagent-type = "NOT-SPAWNABLE" + +[capabilities] +required = [] +"#, + ); + + let mut task = TaskSpec::default(); + task.task.role = "git-ops".into(); + task.task.agent_id = "orchestrator-only-1".into(); + + let err = prepare(&task, root).expect_err("git-ops must be refused"); + let msg = format!("{err:#}"); + assert!( + msg.contains("RULE 0.13"), + "refusal must cite RULE 0.13: got {msg}" + ); + assert!( + msg.contains("spawnable") || msg.contains("orchestrator"), + "refusal message should mention spawnable/orchestrator: got {msg}" + ); +} diff --git a/_roles/edit-local.toml b/_roles/edit-local.toml index 8910296..e7e53dc 100644 --- a/_roles/edit-local.toml +++ b/_roles/edit-local.toml @@ -3,6 +3,9 @@ name = "edit-local" display-name = "code-implementer (local edit scope)" description = "Write code within whitelisted files, run cargo check/test, emit structured report. No git, no workspace-level touches, no dep bumps." spawnable = true +# Default Claude Code subagent_type to hand `prepare` output to. +# Overridable per-task; see docs/AGENT-SUBSTRATE-SCHEMA.md §Orchestrator ergonomics. +claude-subagent-type = "code-implementer" [capabilities] # Ordered list — text.md fragments concatenated in this order diff --git a/_roles/edit-shared.toml b/_roles/edit-shared.toml index 4621cdc..a627f70 100644 --- a/_roles/edit-shared.toml +++ b/_roles/edit-shared.toml @@ -3,6 +3,7 @@ name = "edit-shared" display-name = "code-implementer (shared-SSoT edit scope)" description = "Same baseline as edit-local, with one relaxed scope entry permitting edits to a task-specified SSoT path (e.g. workspace Cargo.toml, registry file). The relaxation is configured per task via `[scope].files-whitelist` in task.toml." spawnable = true +claude-subagent-type = "code-implementer" [capabilities] # Ordered list — text.md fragments concatenated in this order diff --git a/_roles/explorer.toml b/_roles/explorer.toml index 19d6457..30d7c13 100644 --- a/_roles/explorer.toml +++ b/_roles/explorer.toml @@ -3,6 +3,7 @@ name = "explorer" display-name = "explorer + cargo-check (read-only analyst with build probe)" description = "Read-only analyst that may run cargo-family commands for build/test introspection. No edits, no git, no non-cargo shell." spawnable = true +claude-subagent-type = "Explore" [capabilities] # Ordered list — text.md fragments concatenated in this order diff --git a/_roles/git-ops.toml b/_roles/git-ops.toml index 4bc49c4..3fc8c31 100644 --- a/_roles/git-ops.toml +++ b/_roles/git-ops.toml @@ -3,6 +3,8 @@ name = "git-ops" display-name = "git operator (orchestrator-only, NOT spawnable)" description = "Documented boundary of git authority. Per RULE 0.13, only the orchestrator (main session) holds git power: branch creation, commit, push, merge, rebase, reset, tag. This role is documented for completeness and is refused by kei-agent-runtime at spawn time." spawnable = false +# Documented for completeness; never consumed because spawnable = false. +claude-subagent-type = "NOT-SPAWNABLE" [capabilities] # No capability restrictions declared here — this role is never composed into diff --git a/_roles/read-only.toml b/_roles/read-only.toml index c235dec..c2eaf4a 100644 --- a/_roles/read-only.toml +++ b/_roles/read-only.toml @@ -3,6 +3,9 @@ name = "read-only" display-name = "explorer (read-only analyst)" description = "Read-only agent: inspects code, emits structured report with severity grades. No shell, no edits, no git." spawnable = true +# Read-only + severity-grade default maps to critic; architect-flavoured tasks +# should override this per task via claude-subagent-type in task.toml. +claude-subagent-type = "critic" [capabilities] # Ordered list — text.md fragments concatenated in this order diff --git a/docs/AGENT-SUBSTRATE-SCHEMA.md b/docs/AGENT-SUBSTRATE-SCHEMA.md index 0487dc6..44e6873 100644 --- a/docs/AGENT-SUBSTRATE-SCHEMA.md +++ b/docs/AGENT-SUBSTRATE-SCHEMA.md @@ -547,6 +547,58 @@ Phase 5 wired the 5 kit-shipped agents to role+task-spec invocation via a new `s Backward compatibility: the `substrate_role` field is optional. The 7 non-migrated kit agents (`kei-cost-guardian`, `kei-fal-ai-runner`, `kei-infra-implementer`, `kei-ml-implementer`, `kei-ml-researcher`, `kei-modal-runner`, `kei-researcher`) continue to assemble without change; a deferred v0.24 migration wave will promote them. Task-spec examples showing how the orchestrator invokes each migrated agent live under `_templates/task-examples/`. +## Orchestrator ergonomics — `prepare` command + +`compose` emits a prompt, `spawn` writes `tasks//` on disk, `verify` runs on return. Between compose and spawn, the orchestrator needs to invoke Claude Code's `Agent` tool — which lives inside Claude Code, not in Rust. `kei-agent-runtime prepare` bridges that step: it parses a `task.toml` and emits every argument the Agent-tool call needs in one copy-paste-ready block. + +``` +kei-agent-runtime prepare [--kit-root .] [--format human|json|toml] +``` + +Human output: + +``` +=== AGENT SUBSTRATE v1 — PREPARED SPAWN === +agent-id: +subagent_type: +isolation: worktree +description: agent + +--- PROMPT (copy into Agent tool `prompt` param) --- + +--- END PROMPT --- + +on return: + kei-agent-runtime verify tasks//task.toml --worktree + (orchestrator harness returns worktree path in the task-notification) + +ledger: running agent-id= role= parent= +``` + +`--format=json` and `--format=toml` emit the same `AgentInvocation` struct for scriptable wrappers (e.g. future `/spawn-agent` Claude Code skill). + +### Role → Claude subagent_type mapping + +Claude Code's `Agent` tool takes a `subagent_type` string. Roles map to subagent_type via an optional `claude-subagent-type` field on `[role]` in `_roles/.toml`. If unset, the runtime falls back to defaults: + +| Role | Default `claude-subagent-type` | +|---|---| +| `edit-local` | `code-implementer` | +| `edit-shared` | `code-implementer` | +| `explorer` | `Explore` | +| `read-only` | `critic` (override per-task for architect-flavour reviews) | +| `git-ops` | `NOT-SPAWNABLE` (never composed — `spawnable = false`) | + +`isolation = "worktree"` is auto-set for `edit-local` and `edit-shared`; other roles default to no isolation. + +### Non-spawnable refusal + +`prepare` refuses roles with `spawnable = false` and cites RULE 0.13 in the error. `git-ops` is the only shipped example; the refusal keeps "who can do git" boundary visible both in the role manifest AND at invocation time. + +### Contract + +`prepare` does NOT write to disk (inspection helper) and does NOT touch the ledger DB (the "ledger row" field is a pretty-printed string for the orchestrator to verify before calling `kei-ledger fork`). `spawn` remains the disk-writing step; `prepare` is additive and read-only. + ## Deferred extension candidates (non-breaking post-lock) Capability atoms NOT in the initial 10 but good follow-up PRs (non-breaking additions during lock window):