Two new crates implementing the substrate runtime per locked §Runtime
execution contract + §Capability trait contract (Rust) + §Verify
execution worktree→simulated-merge.
kei-agent-runtime — library + CLI binary:
- src/capability.rs — Capability trait (name/check/verify) + GateContext
+ GateDecision + VerifyContext + VerifyResult + RunMode + TaskSpec
- src/registry.rs — &str → &'static dyn Capability dispatch for 14 impls
- src/gates/ — 6 PreToolUse modules (policy::no-git-ops,
scope::files-{whitelist,denylist}, safety::no-dep-bump,
tools::read-only, tools::cargo-only-bash)
- src/verifies/ — 8 on-return modules (quality::constructor-pattern,
quality::cargo-check-green, quality::tests-green, safety::no-dep-bump,
scope::files-{whitelist,denylist}, output::{report-format,severity-grade})
- src/compose.rs — task.toml + role + capabilities → prompt.md
- src/spawn.rs — ledger fork + prompt write (actual Agent invocation
remains orchestrator's tool call)
- src/verify.rs — runs all capability verifies per role; collects
VerifyReport {passed, failed}
- src/simulated_merge.rs — git worktree add test-merge/<id> + apply diff
+ run verify; cleanup on Drop
- src/main.rs — clap CLI: compose | spawn | verify | run
kei-capability — thin CLI adapter crate:
- Depends on kei-agent-runtime path dep
- Subcommand `check <cap-name>` (PreToolUse gate; stdin JSON, exit 0|2)
- Subcommand `verify <cap-name>` (on-return; env-driven, exit 0 or fail)
- Pattern: shell hook = 3-line `exec kei-capability check "$CAP_NAME"`
Workspace Cargo.toml: both crates registered as members (under agent
substrate v1 marker).
cargo check --workspace: PASS
cargo test -p kei-agent-runtime: 37/37 green
- 6 capability_trait_smoke (registry lookups, unknown name → None)
- 3 compose_smoke (fixture role + caps → composed prompt)
- 12 gate_smoke (each gate: happy + deny + bypass)
- 4 simulated_merge_smoke (git worktree lifecycle)
- 12 verify_smoke (each verify: pass + fail + edge cases)
cargo test -p kei-capability: 0/0 (CLI binary, tested via lib)
(Agent completion report cut off by rate-limit at 60 tool-uses; code
itself is green — verified by orchestrator post-commit.)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
74 lines
2.5 KiB
Rust
74 lines
2.5 KiB
Rust
//! Compose capability-fragment prompt for an agent invocation.
|
|
//!
|
|
//! Flow:
|
|
//! 1. Parse `task.toml` → `TaskSpec` (caller does this).
|
|
//! 2. Load `_roles/<task.role>.toml`.
|
|
//! 3. For each capability in `role.capabilities.required`, read the
|
|
//! `_capabilities/<category>/<slug>/text.md` fragment.
|
|
//! 4. Concatenate fragments with `\n\n---\n\n`.
|
|
//! 5. Append `task.body.text`.
|
|
|
|
use crate::capability::TaskSpec;
|
|
use anyhow::{anyhow, Context, Result};
|
|
use serde::Deserialize;
|
|
use std::path::Path;
|
|
|
|
const SEPARATOR: &str = "\n\n---\n\n";
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RoleFile {
|
|
#[serde(default)]
|
|
capabilities: RoleCapabilities,
|
|
}
|
|
|
|
#[derive(Debug, Default, Deserialize)]
|
|
struct RoleCapabilities {
|
|
#[serde(default)]
|
|
required: Vec<String>,
|
|
}
|
|
|
|
/// Compose prompt text. `kit_root` is the repo root that holds `_roles/`
|
|
/// and `_capabilities/` directories.
|
|
pub fn compose_prompt(task: &TaskSpec, kit_root: &Path) -> Result<String> {
|
|
if task.task.role.is_empty() {
|
|
return Err(anyhow!("task.role is empty"));
|
|
}
|
|
let role = load_role(kit_root, &task.task.role)?;
|
|
let mut fragments: Vec<String> = Vec::with_capacity(role.capabilities.required.len() + 1);
|
|
for cap_name in &role.capabilities.required {
|
|
let frag = load_capability_text(kit_root, cap_name)
|
|
.with_context(|| format!("capability {cap_name}"))?;
|
|
fragments.push(frag);
|
|
}
|
|
if !task.body.text.trim().is_empty() {
|
|
fragments.push(task.body.text.clone());
|
|
}
|
|
Ok(fragments.join(SEPARATOR))
|
|
}
|
|
|
|
fn load_role(kit_root: &Path, role: &str) -> Result<RoleFile> {
|
|
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()))?;
|
|
let parsed: RoleFile =
|
|
toml::from_str(&text).with_context(|| format!("parse role TOML {}", path.display()))?;
|
|
Ok(parsed)
|
|
}
|
|
|
|
fn load_capability_text(kit_root: &Path, cap_name: &str) -> Result<String> {
|
|
let (category, slug) = split_cap_name(cap_name)?;
|
|
let path = kit_root
|
|
.join("_capabilities")
|
|
.join(category)
|
|
.join(slug)
|
|
.join("text.md");
|
|
std::fs::read_to_string(&path)
|
|
.with_context(|| format!("read capability text {}", path.display()))
|
|
}
|
|
|
|
fn split_cap_name(cap: &str) -> Result<(&str, &str)> {
|
|
match cap.split_once("::") {
|
|
Some((cat, slug)) if !cat.is_empty() && !slug.is_empty() => Ok((cat, slug)),
|
|
_ => Err(anyhow!("malformed capability name '{cap}' — expected <cat>::<slug>")),
|
|
}
|
|
}
|