feat(e2): kei-capability fork subcommand + lineage stamping

New 'fork' subcommand copies capability dir + rewrites capability.toml
with [lineage].fork_from + parents + creator + created. Refuses
clobber, validates slug regex.

Tests: 4 integration + 2 unit (epoch_to_iso, split_cap_name) = 6/6.

Doc update in AGENT-SUBSTRATE-SCHEMA.md §Orchestrator ergonomics.

Zero-chrono ISO-8601 via Hinnant's algorithm (single-file).

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 6e7e517f83
7 changed files with 428 additions and 1 deletions

View file

@ -1929,6 +1929,7 @@ dependencies = [
"kei-agent-runtime",
"serde",
"serde_json",
"tempfile",
"toml",
]

View file

@ -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 <name>` / `kei-capability verify <name>`"

View file

@ -0,0 +1,200 @@
//! `kei-capability fork <source> --as <new-name>` — copy+rewrite a capability.
//!
//! Reads `_capabilities/<src-cat>/<src-slug>/{capability.toml, text.md}`,
//! validates the new `<cat>::<slug>` 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<ForkSummary> {
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 `<cat>::<slug>` 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 <cat>::<slug>"))?;
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<String, Value>, 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<String, Value>, source: &str, creator: &str, now_iso: &str) {
let mut lineage: Map<String, Value> = 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());
}
}

View file

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

View file

@ -6,6 +6,11 @@
//! - `verify <name>` — 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 <source> --as <new-name> [--kit-root <dir>]` — copy an
//! existing capability dir under a new
//! `<cat>::<slug>` 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 <cat>::<slug> name with lineage.
Fork {
/// Existing `<cat>::<slug>` to clone.
source: String,
/// New `<cat>::<slug>` 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)
}
}
}

View file

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

View file

@ -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 <source> --as <new-name> [--kit-root <dir>]` copies an existing `_capabilities/<src-cat>/<src-slug>/` directory under a new `<cat>::<slug>` 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 `<source>` and `<new-name>` must parse as `<cat>::<slug>` 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/<new-cat>/<new-slug>/` must NOT exist — fork refuses to clobber.
3. `capability.toml` is parsed, rewritten with `[capability].name = "<new-name>"` (and `category` set to `<new-cat>`), then augmented with a new `[lineage]` table:
```toml
[lineage]
fork_from = "<source-name>"
parents = ["<source-name>"]
creator = "<env KEI_CREATOR_ID or 'unknown'>"
created = "<ISO-8601 UTC at fork time>"
```
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):