diff --git a/_primitives/_rust/Cargo.lock b/_primitives/_rust/Cargo.lock index b5c82ee..a40a1a8 100644 --- a/_primitives/_rust/Cargo.lock +++ b/_primitives/_rust/Cargo.lock @@ -1929,6 +1929,7 @@ dependencies = [ "kei-agent-runtime", "serde", "serde_json", + "tempfile", "toml", ] diff --git a/_primitives/_rust/kei-capability/Cargo.toml b/_primitives/_rust/kei-capability/Cargo.toml index 0444a6f..340d016 100644 --- a/_primitives/_rust/kei-capability/Cargo.toml +++ b/_primitives/_rust/kei-capability/Cargo.toml @@ -9,6 +9,10 @@ description = "Hook-protocol CLI adapter — routes PreToolUse check + on-return name = "kei-capability" path = "src/main.rs" +[lib] +name = "kei_capability" +path = "src/lib.rs" + [dependencies] kei-agent-runtime = { path = "../kei-agent-runtime" } clap = { version = "4", features = ["derive"] } @@ -17,6 +21,9 @@ serde_json = "1" anyhow = "1" toml = "0.8" +[dev-dependencies] +tempfile = "3" + [package.metadata.keisei] backend = "none" description = "Hook-protocol CLI — `kei-capability check ` / `kei-capability verify `" diff --git a/_primitives/_rust/kei-capability/src/fork.rs b/_primitives/_rust/kei-capability/src/fork.rs new file mode 100644 index 0000000..cf75985 --- /dev/null +++ b/_primitives/_rust/kei-capability/src/fork.rs @@ -0,0 +1,200 @@ +//! `kei-capability fork --as ` — copy+rewrite a capability. +//! +//! Reads `_capabilities///{capability.toml, text.md}`, +//! validates the new `::` name, creates the target directory, +//! writes a rewritten `capability.toml` (new name + `[lineage]` block), +//! and copies `text.md` byte-identical. +//! +//! Constructor Pattern: one cube = fork copy+rewrite. No subcommand dispatch. + +use anyhow::{anyhow, bail, Context, Result}; +use kei_agent_runtime::role::validate_name; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; +use toml::{map::Map, Value}; + +/// Summary returned to the CLI / tests after a successful fork. +#[derive(Debug)] +pub struct ForkSummary { + pub source: String, + pub target: String, + pub diff_count: usize, + pub target_dir: PathBuf, +} + +/// Run the fork operation against a kit root. +/// +/// `now_iso` is an injectable clock (ISO-8601 UTC string). Pass +/// `current_iso_utc` for production; tests pass a fixed value. +pub fn run_fork( + source: &str, + new_name: &str, + kit_root: &Path, + now_iso: &str, +) -> Result { + let (src_dir, target_dir) = resolve_paths(source, new_name, kit_root)?; + ensure_source_exists(&src_dir)?; + ensure_target_free(&target_dir)?; + let src_toml_raw = std::fs::read_to_string(src_dir.join("capability.toml")) + .with_context(|| format!("read {}", src_dir.join("capability.toml").display()))?; + let creator = std::env::var("KEI_CREATOR_ID").unwrap_or_else(|_| "unknown".into()); + let (rewritten, diff_count) = + rewrite_toml(&src_toml_raw, source, new_name, &creator, now_iso)?; + write_fork(&src_dir, &target_dir, &rewritten)?; + Ok(ForkSummary { + source: source.to_string(), + target: new_name.to_string(), + diff_count, + target_dir, + }) +} + +fn resolve_paths(source: &str, new_name: &str, kit_root: &Path) -> Result<(PathBuf, PathBuf)> { + let (src_cat, src_slug) = split_cap_name(source)?; + let (new_cat, new_slug) = split_cap_name(new_name)?; + let caps_root = kit_root.join("_capabilities"); + Ok(( + caps_root.join(src_cat).join(src_slug), + caps_root.join(new_cat).join(new_slug), + )) +} + +fn ensure_source_exists(src_dir: &Path) -> Result<()> { + if !src_dir.is_dir() { + bail!("source capability dir not found: {}", src_dir.display()); + } + Ok(()) +} + +fn ensure_target_free(target_dir: &Path) -> Result<()> { + if target_dir.exists() { + bail!( + "target capability dir already exists — refusing to clobber: {}", + target_dir.display() + ); + } + Ok(()) +} + +fn write_fork(src_dir: &Path, target_dir: &Path, toml_body: &str) -> Result<()> { + std::fs::create_dir_all(target_dir) + .with_context(|| format!("mkdir {}", target_dir.display()))?; + std::fs::write(target_dir.join("capability.toml"), toml_body) + .with_context(|| format!("write {}", target_dir.join("capability.toml").display()))?; + std::fs::copy(src_dir.join("text.md"), target_dir.join("text.md")) + .with_context(|| format!("copy text.md from {}", src_dir.display()))?; + Ok(()) +} + +/// Split `::` and validate both halves through the shared regex. +fn split_cap_name(name: &str) -> Result<(&str, &str)> { + let (cat, slug) = name + .split_once("::") + .filter(|(c, s)| !c.is_empty() && !s.is_empty()) + .ok_or_else(|| anyhow!("malformed capability name '{name}' — expected ::"))?; + validate_name("capability-category", cat)?; + validate_name("capability-slug", slug)?; + Ok((cat, slug)) +} + +/// Parse source capability.toml, rewrite `[capability].name`, insert a +/// `[lineage]` table with `fork_from` / `parents` / `creator` / `created`. +/// Returns the serialized string and the number of field writes performed. +fn rewrite_toml( + src_raw: &str, + source: &str, + new_name: &str, + creator: &str, + now_iso: &str, +) -> Result<(String, usize)> { + let mut root: Value = toml::from_str(src_raw).context("parse source capability.toml")?; + let tbl = root + .as_table_mut() + .ok_or_else(|| anyhow!("source capability.toml root is not a table"))?; + let mut writes = 0usize; + rewrite_capability_name(tbl, new_name)?; + writes += 1; + insert_lineage(tbl, source, creator, now_iso); + writes += 3; // fork_from, parents, creator+created counted as 3 additions + let out = toml::to_string_pretty(&root).context("serialize rewritten capability.toml")?; + Ok((out, writes)) +} + +fn rewrite_capability_name(root: &mut Map, new_name: &str) -> Result<()> { + let cap_tbl = root + .get_mut("capability") + .and_then(|v| v.as_table_mut()) + .ok_or_else(|| anyhow!("source capability.toml missing [capability] table"))?; + cap_tbl.insert("name".into(), Value::String(new_name.into())); + let (cat, _) = new_name.split_once("::").unwrap_or((new_name, "")); + cap_tbl.insert("category".into(), Value::String(cat.into())); + Ok(()) +} + +fn insert_lineage(root: &mut Map, source: &str, creator: &str, now_iso: &str) { + let mut lineage: Map = Map::new(); + lineage.insert("fork_from".into(), Value::String(source.into())); + lineage.insert( + "parents".into(), + Value::Array(vec![Value::String(source.into())]), + ); + lineage.insert("creator".into(), Value::String(creator.into())); + lineage.insert("created".into(), Value::String(now_iso.into())); + root.insert("lineage".into(), Value::Table(lineage)); +} + +/// Current UTC time as `YYYY-MM-DDTHH:MM:SSZ`. No chrono dep — minimal +/// proleptic-Gregorian converter over Unix epoch seconds. +pub fn current_iso_utc() -> String { + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + epoch_to_iso(secs as i64) +} + +fn epoch_to_iso(secs: i64) -> String { + let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); + let (h, rem) = (sod / 3600, sod % 3600); + let (m, s) = (rem / 60, rem % 60); + let (y, mo, d) = days_to_ymd(days); + format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z") +} + +fn days_to_ymd(days_since_epoch: i64) -> (i64, u32, u32) { + // Howard Hinnant's algorithm — days_since_epoch → civil date (y, m, d). + let z = days_since_epoch + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + let y = if m <= 2 { y + 1 } else { y }; + (y, m, d) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn epoch_to_iso_spot_check() { + // Unix epoch itself. + assert_eq!(epoch_to_iso(0), "1970-01-01T00:00:00Z"); + // 2000-01-01T00:00:00Z = 946684800. + assert_eq!(epoch_to_iso(946_684_800), "2000-01-01T00:00:00Z"); + // 2026-04-23T00:00:00Z. + assert_eq!(epoch_to_iso(1_777_334_400 - 5 * 86_400), "2026-04-23T00:00:00Z"); + } + + #[test] + fn split_cap_name_rejects_unqualified() { + assert!(split_cap_name("no-colons").is_err()); + assert!(split_cap_name("a::b").is_ok()); + assert!(split_cap_name("BAD::slug").is_err()); + assert!(split_cap_name("cat::").is_err()); + } +} diff --git a/_primitives/_rust/kei-capability/src/lib.rs b/_primitives/_rust/kei-capability/src/lib.rs new file mode 100644 index 0000000..51dcf6d --- /dev/null +++ b/_primitives/_rust/kei-capability/src/lib.rs @@ -0,0 +1,6 @@ +//! kei-capability library surface — exposes `fork` for integration tests. +//! +//! The binary (`src/main.rs`) owns Check/Verify dispatch; the library +//! re-exports the pure copy+rewrite logic used by `kei-capability fork`. + +pub mod fork; diff --git a/_primitives/_rust/kei-capability/src/main.rs b/_primitives/_rust/kei-capability/src/main.rs index f32bb0e..e78bc3b 100644 --- a/_primitives/_rust/kei-capability/src/main.rs +++ b/_primitives/_rust/kei-capability/src/main.rs @@ -6,6 +6,11 @@ //! - `verify ` — reads env (AGENT_ID, TASK_TOML, WORKTREE_PATH, //! MAIN_REPO, RUN_MODE), runs registry verify, //! exits 0 on pass or non-zero with stderr message. +//! - `fork --as [--kit-root ]` — copy an +//! existing capability dir under a new +//! `::` name and record lineage. + +use kei_capability::fork; use clap::{Parser, Subcommand}; use kei_agent_runtime::capability::{ @@ -15,7 +20,7 @@ use kei_agent_runtime::registry; use serde_json::{json, Value}; use std::collections::HashMap; use std::io::Read; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::ExitCode; #[derive(Parser)] @@ -31,6 +36,17 @@ enum Cmd { Check { name: String }, /// On-return verify — env carries context. Verify { name: String }, + /// Fork a capability: copy dir under a new :: name with lineage. + Fork { + /// Existing `::` to clone. + source: String, + /// New `::` name for the fork. + #[arg(long = "as")] + as_name: String, + /// Kit root (contains `_capabilities/`); defaults to cwd. + #[arg(long = "kit-root", default_value = ".")] + kit_root: PathBuf, + }, } fn main() -> ExitCode { @@ -38,6 +54,31 @@ fn main() -> ExitCode { match cli.cmd { Cmd::Check { name } => run_check(name), Cmd::Verify { name } => run_verify(name), + Cmd::Fork { + source, + as_name, + kit_root, + } => run_fork_cmd(&source, &as_name, &kit_root), + } +} + +fn run_fork_cmd(source: &str, new_name: &str, kit_root: &Path) -> ExitCode { + let now = fork::current_iso_utc(); + match fork::run_fork(source, new_name, kit_root, &now) { + Ok(summary) => { + println!("forked {} → {}", summary.source, summary.target); + println!(" dir: {}", summary.target_dir.display()); + println!(" fields rewritten: {}", summary.diff_count); + println!( + " next: edit text.md to reflect fork semantics; ensure \ + [gate].rust-module and [verify].rust-module match the new slug" + ); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("fork failed: {e:#}"); + ExitCode::from(2) + } } } diff --git a/_primitives/_rust/kei-capability/tests/fork_smoke.rs b/_primitives/_rust/kei-capability/tests/fork_smoke.rs new file mode 100644 index 0000000..ffe196e --- /dev/null +++ b/_primitives/_rust/kei-capability/tests/fork_smoke.rs @@ -0,0 +1,145 @@ +//! Smoke tests for `kei-capability fork`. + +use kei_capability::fork; +use std::path::Path; +use tempfile::TempDir; + +const FIXED_NOW: &str = "2026-04-23T00:00:00Z"; +const SRC_TEXT: &str = "## Test capability\n\nBody line one.\nBody line two.\n"; +const SRC_TOML: &str = r#"[capability] +name = "policy::no-git-ops" +category = "policy" +version = "1.0" +description = "Forbid git operations." +rationale = "RULE 0.13." + +[restricts] +tool-patterns = ['^git( |$)'] +tools-denied = [] + +[parameterized] +accepts = [] + +[text] +path = "text.md" + +[gate] +rust-module = "gates::policy_no_git_ops" +event = "PreToolUse:Bash" +severity = "block" +"#; + +fn seed_source(kit_root: &Path, cat: &str, slug: &str) { + let dir = kit_root.join("_capabilities").join(cat).join(slug); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("capability.toml"), SRC_TOML).unwrap(); + std::fs::write(dir.join("text.md"), SRC_TEXT).unwrap(); +} + +#[test] +fn fork_creates_target_with_lineage() { + let tmp = TempDir::new().unwrap(); + seed_source(tmp.path(), "policy", "no-git-ops"); + let summary = fork::run_fork( + "policy::no-git-ops", + "policy::no-git-ops-lax", + tmp.path(), + FIXED_NOW, + ) + .expect("fork should succeed"); + assert_eq!(summary.target, "policy::no-git-ops-lax"); + assert!(summary.diff_count >= 1); + let target_dir = tmp + .path() + .join("_capabilities") + .join("policy") + .join("no-git-ops-lax"); + assert!(target_dir.join("capability.toml").exists()); + assert!(target_dir.join("text.md").exists()); + let out = std::fs::read_to_string(target_dir.join("capability.toml")).unwrap(); + let parsed: toml::Value = toml::from_str(&out).unwrap(); + let cap = parsed.get("capability").and_then(|v| v.as_table()).unwrap(); + assert_eq!(cap.get("name").unwrap().as_str(), Some("policy::no-git-ops-lax")); + let lin = parsed.get("lineage").and_then(|v| v.as_table()).unwrap(); + assert_eq!( + lin.get("fork_from").unwrap().as_str(), + Some("policy::no-git-ops") + ); + let parents = lin.get("parents").and_then(|v| v.as_array()).unwrap(); + assert_eq!(parents.len(), 1); + assert_eq!(parents[0].as_str(), Some("policy::no-git-ops")); + assert_eq!(lin.get("created").unwrap().as_str(), Some(FIXED_NOW)); + assert!(lin.get("creator").unwrap().as_str().is_some()); +} + +#[test] +fn fork_refuses_when_target_exists() { + let tmp = TempDir::new().unwrap(); + seed_source(tmp.path(), "policy", "no-git-ops"); + // Pre-create target so fork must refuse to clobber. + let target = tmp + .path() + .join("_capabilities") + .join("policy") + .join("no-git-ops-lax"); + std::fs::create_dir_all(&target).unwrap(); + let err = fork::run_fork( + "policy::no-git-ops", + "policy::no-git-ops-lax", + tmp.path(), + FIXED_NOW, + ) + .expect_err("fork should refuse existing target"); + let msg = format!("{err:#}"); + assert!(msg.contains("already exists"), "expected clobber refusal, got: {msg}"); +} + +#[test] +fn fork_validates_new_name_regex() { + let tmp = TempDir::new().unwrap(); + seed_source(tmp.path(), "policy", "no-git-ops"); + // Upper-case and bad chars must be rejected via shared NAME_RE. + assert!(fork::run_fork( + "policy::no-git-ops", + "Policy::no-git-ops-lax", + tmp.path(), + FIXED_NOW, + ) + .is_err()); + assert!(fork::run_fork( + "policy::no-git-ops", + "policy::BadSlug", + tmp.path(), + FIXED_NOW, + ) + .is_err()); + // Missing separator. + assert!(fork::run_fork( + "policy::no-git-ops", + "no-separator", + tmp.path(), + FIXED_NOW, + ) + .is_err()); +} + +#[test] +fn fork_copies_text_md_byte_identical() { + let tmp = TempDir::new().unwrap(); + seed_source(tmp.path(), "policy", "no-git-ops"); + fork::run_fork( + "policy::no-git-ops", + "policy::no-git-ops-lax", + tmp.path(), + FIXED_NOW, + ) + .unwrap(); + let target_text = tmp + .path() + .join("_capabilities") + .join("policy") + .join("no-git-ops-lax") + .join("text.md"); + let copied = std::fs::read(&target_text).unwrap(); + assert_eq!(copied, SRC_TEXT.as_bytes()); +} diff --git a/docs/AGENT-SUBSTRATE-SCHEMA.md b/docs/AGENT-SUBSTRATE-SCHEMA.md index 0111cf1..f212736 100644 --- a/docs/AGENT-SUBSTRATE-SCHEMA.md +++ b/docs/AGENT-SUBSTRATE-SCHEMA.md @@ -599,6 +599,33 @@ Claude Code's `Agent` tool takes a `subagent_type` string. Roles map to subagent `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. +### `kei-capability fork` — clone a capability + +`kei-capability fork --as [--kit-root ]` copies an existing `_capabilities///` directory under a new `::` name and records lineage so downstream tooling can trace the fork back to its parent. + +``` +kei-capability fork policy::no-git-ops --as policy::no-git-ops-lax +``` + +Behaviour: + +1. Both `` and `` must parse as `::` with each half matching the shared slug regex (`^[a-z][a-z0-9-]{0,63}$`); upper-case or path-traversal input is rejected before any filesystem write. +2. Target directory `_capabilities///` must NOT exist — fork refuses to clobber. +3. `capability.toml` is parsed, rewritten with `[capability].name = ""` (and `category` set to ``), then augmented with a new `[lineage]` table: + + ```toml + [lineage] + fork_from = "" + parents = [""] + creator = "" + created = "" + ``` + +4. `text.md` is copied byte-identical — the operator is expected to edit it afterwards to reflect the fork's new semantics. +5. On success the CLI prints source→target, the new directory, the number of fields rewritten, and a next-steps hint reminding the operator to edit `text.md` and ensure `[gate].rust-module` / `[verify].rust-module` match the new slug. + +Fork is local-only; no ledger row is written. It is an ergonomic shortcut for authoring a derived capability; the resulting files are still subject to the normal review + merge workflow. + ## 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):