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:
parent
010def05ad
commit
6e7e517f83
7 changed files with 428 additions and 1 deletions
1
_primitives/_rust/Cargo.lock
generated
1
_primitives/_rust/Cargo.lock
generated
|
|
@ -1929,6 +1929,7 @@ dependencies = [
|
|||
"kei-agent-runtime",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"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 <name>` / `kei-capability verify <name>`"
|
||||
|
|
|
|||
200
_primitives/_rust/kei-capability/src/fork.rs
Normal file
200
_primitives/_rust/kei-capability/src/fork.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
6
_primitives/_rust/kei-capability/src/lib.rs
Normal file
6
_primitives/_rust/kei-capability/src/lib.rs
Normal 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;
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
145
_primitives/_rust/kei-capability/tests/fork_smoke.rs
Normal file
145
_primitives/_rust/kei-capability/tests/fork_smoke.rs
Normal 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());
|
||||
}
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Reference in a new issue