Merge feat/convergence-p3-role-dna — role expression + DNA identity

This commit is contained in:
Parfii-bot 2026-04-23 04:47:30 +08:00
commit 30a07e22ca
17 changed files with 673 additions and 103 deletions

View file

@ -1852,9 +1852,11 @@ dependencies = [
"anyhow",
"clap",
"once_cell",
"rand 0.8.6",
"regex",
"serde",
"serde_json",
"sha2 0.10.9",
"tempfile",
"thiserror 1.0.69",
"toml",

View file

@ -23,6 +23,8 @@ thiserror = "1"
regex = "1"
once_cell = "1"
walkdir = "2"
sha2 = { workspace = true }
rand = "0.8"
[dev-dependencies]
tempfile = "3"

View file

@ -2,40 +2,29 @@
//!
//! 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
//! 2. Resolve `_roles/<task.role>.toml` via `role::resolve_role`
//! (handles `extends` / `relaxes` / cycle detection).
//! 3. For each capability in the resolved required list, 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 crate::role::resolve_role;
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 resolved = resolve_role(kit_root, &task.task.role)?;
let mut fragments: Vec<String> = Vec::with_capacity(resolved.required.len() + 1);
for cap_name in &resolved.required {
let frag = load_capability_text(kit_root, cap_name)
.with_context(|| format!("capability {cap_name}"))?;
fragments.push(frag);
@ -46,15 +35,6 @@ pub fn compose_prompt(task: &TaskSpec, kit_root: &Path) -> Result<String> {
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

View file

@ -0,0 +1,159 @@
//! Layer G — DNA identity for agent invocations.
//!
//! DNA format: `<role>::<caps-bitmap>::<scope-hash>::<body-hash>-<nonce>`
//! where
//! - `role` — role slug, e.g. `edit-local`
//! - `caps-bitmap` — hyphen-separated 2-char atom codes (ordered, from
//! the resolved capability list)
//! - `scope-hash` — 4-char truncated SHA-256 of canonicalised scope fields
//! - `body-hash` — 4-char truncated SHA-256 of `task.body.text`
//! - `nonce` — 4-char hex from `rand::random::<u16>()` + a byte of
//! nanosecond time (deterministic enough for uniqueness
//! in a single batch; ledger dedups by DNA prefix)
//!
//! Constructor Pattern: one cube = DNA identity primitive only. No I/O.
//!
//! Round-trip: `compose` → `render` → `parse` → equal.
//! Parse accepts both shipped DNA strings and hand-written ones; it enforces
//! the 5-segment shape but tolerates arbitrary (non-empty) segment content
//! so future schema extensions don't break old ledger rows.
use crate::capability::TaskSpec;
use crate::role::ResolvedRole;
use sha2::{Digest, Sha256};
use thiserror::Error;
/// Capability-name → 2-char atom code lookup.
///
/// Stable, extensible — additions allowed; removals NOT. `compose` emits
/// `?\?` for unknown names so missing entries are visibly flagged rather
/// than silently dropped.
pub const CAP_CODES: &[(&str, &str)] = &[
("policy::no-git-ops", "NG"),
("scope::files-whitelist", "FW"),
("scope::files-denylist", "FD"),
("quality::constructor-pattern", "CP"),
("quality::cargo-check-green", "CG"),
("quality::tests-green", "TG"),
("safety::no-dep-bump", "ND"),
("output::report-format", "RF"),
("output::severity-grade", "SG"),
("tools::deny-tools", "DT"),
("tools::bash-allowlist", "BA"),
];
/// Agent DNA — composition fingerprint.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Dna {
pub role: String,
pub caps_bitmap: String,
pub scope_hash: String,
pub body_hash: String,
pub nonce: String,
}
/// Error during DNA parsing.
#[derive(Debug, Error, PartialEq, Eq)]
pub enum DnaError {
#[error("DNA string must have 4 `::` segments and `<body>-<nonce>` tail")]
Shape,
#[error("DNA segment `{0}` is empty")]
EmptySegment(&'static str),
}
impl Dna {
/// Build DNA from a task + already-resolved role.
pub fn compose(task: &TaskSpec, resolved: &ResolvedRole) -> Self {
let caps_bitmap = build_caps_bitmap(&resolved.required);
let scope_hash = short_sha256(&canonical_scope(task));
let body_hash = short_sha256(&task.body.text);
let nonce = nonce_hex();
Self {
role: task.task.role.clone(),
caps_bitmap,
scope_hash,
body_hash,
nonce,
}
}
/// Render to the canonical wire format.
pub fn render(&self) -> String {
format!(
"{}::{}::{}::{}-{}",
self.role, self.caps_bitmap, self.scope_hash, self.body_hash, self.nonce
)
}
/// Parse a DNA string. Lenient on segment content, strict on shape.
pub fn parse(s: &str) -> Result<Self, DnaError> {
let parts: Vec<&str> = s.splitn(4, "::").collect();
if parts.len() != 4 {
return Err(DnaError::Shape);
}
let role = parts[0];
let caps_bitmap = parts[1];
let scope_hash = parts[2];
let (body_hash, nonce) = parts[3].rsplit_once('-').ok_or(DnaError::Shape)?;
if role.is_empty() {
return Err(DnaError::EmptySegment("role"));
}
if caps_bitmap.is_empty() {
return Err(DnaError::EmptySegment("caps_bitmap"));
}
if scope_hash.is_empty() {
return Err(DnaError::EmptySegment("scope_hash"));
}
if body_hash.is_empty() {
return Err(DnaError::EmptySegment("body_hash"));
}
if nonce.is_empty() {
return Err(DnaError::EmptySegment("nonce"));
}
Ok(Self {
role: role.into(),
caps_bitmap: caps_bitmap.into(),
scope_hash: scope_hash.into(),
body_hash: body_hash.into(),
nonce: nonce.into(),
})
}
}
fn build_caps_bitmap(caps: &[String]) -> String {
caps.iter()
.map(|c| code_for(c).to_string())
.collect::<Vec<_>>()
.join("-")
}
fn code_for(cap_name: &str) -> &'static str {
CAP_CODES
.iter()
.find(|(n, _)| *n == cap_name)
.map(|(_, c)| *c)
.unwrap_or("??")
}
fn canonical_scope(task: &TaskSpec) -> String {
let mut wl = task.scope.files_whitelist.clone();
wl.sort();
let mut dl = task.scope.files_denylist.clone();
dl.sort();
format!("wl={}\ndl={}", wl.join(","), dl.join(","))
}
fn short_sha256(input: &str) -> String {
let digest = Sha256::digest(input.as_bytes());
format!("{:02X}{:02X}", digest[0], digest[1])
}
fn nonce_hex() -> String {
let a: u16 = rand::random();
let b: u16 = rand::random();
format!("{a:04x}")
.chars()
.take(2)
.chain(format!("{b:04x}").chars().take(2))
.collect()
}

View file

@ -15,9 +15,11 @@
pub mod capability;
pub mod compose;
pub mod dna;
pub mod gates;
pub mod prepare;
pub mod registry;
pub mod role;
pub mod simulated_merge;
pub mod spawn;
pub mod verifies;

View file

@ -5,14 +5,16 @@
//! `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/<id>/` 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.
//! Wire: `prepare()` = role resolution + `compose_prompt()` + role→subagent_type
//! resolution + `Dna::compose`. Deliberately does NOT create `tasks/<id>/` 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 crate::dna::Dna;
use crate::role::resolve_role;
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
@ -28,6 +30,8 @@ pub struct AgentInvocation {
pub description: String,
pub verify_command: String,
pub ledger_row: String,
/// Layer G — composition fingerprint, `<role>::<caps>::<scope>::<body>-<nonce>`.
pub dna: String,
}
/// Assemble an `AgentInvocation` from a parsed task.toml.
@ -42,7 +46,7 @@ pub fn prepare(task: &TaskSpec, kit_root: &Path) -> Result<AgentInvocation> {
"task.agent-id is empty — orchestrator must allocate via kei-ledger"
));
}
let role_file = load_role_file(kit_root, &task.task.role)?;
let role_file = load_role_meta(kit_root, &task.task.role)?;
if !role_file.role.spawnable {
return Err(anyhow!(
"role '{}' is NOT spawnable (per RULE 0.13 git-ops is \
@ -50,14 +54,18 @@ pub fn prepare(task: &TaskSpec, kit_root: &Path) -> Result<AgentInvocation> {
task.task.role
));
}
let resolved = resolve_role(kit_root, &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 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);
let dna = Dna::compose(task, &resolved).render();
Ok(AgentInvocation {
agent_id: task.task.agent_id.clone(),
role: task.task.role.clone(),
@ -67,6 +75,7 @@ pub fn prepare(task: &TaskSpec, kit_root: &Path) -> Result<AgentInvocation> {
description,
verify_command,
ledger_row,
dna,
})
}
@ -76,6 +85,7 @@ pub fn render_human(inv: &AgentInvocation) -> String {
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!("dna: {}\n", inv.dna));
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));
@ -143,7 +153,7 @@ fn build_ledger_row(task: &TaskSpec) -> String {
)
}
fn load_role_file(kit_root: &Path, role: &str) -> Result<RoleFile> {
fn load_role_meta(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()))?;

View file

@ -0,0 +1,95 @@
//! Role expression resolver — Layer E.
//!
//! Parses `_roles/<name>.toml` and resolves `extends` chains with `relaxes`
//! subtraction, emitting a flat `ResolvedRole` for downstream consumers
//! (`compose`, `prepare`, `verify`, `dna`).
//!
//! Semantics:
//! - `extends` — optional parent role slug; loaded recursively.
//! - `required` (local) — merged on top of parent's resolved required.
//! - `relaxes` — slugs in parent's resolved required to DROP. Warn on stderr
//! if a relaxed cap wasn't present in the inherited set.
//! - Cycle detection — visited set passed down the recursion; an error with
//! a clear path is returned when a cycle is found.
//!
//! Constructor Pattern: one cube = one responsibility (role expression only).
//! No I/O beyond `std::fs::read_to_string`. Dispatched from `compose::load_role`
//! and `verify::load_role_capabilities` so both share the same semantics.
use anyhow::{anyhow, Context, Result};
use serde::Deserialize;
use std::collections::HashSet;
use std::path::Path;
/// Flattened role ready for downstream composition.
#[derive(Debug, Clone, Default)]
pub struct ResolvedRole {
/// Ordered capability names after `extends` merge + `relaxes` subtraction.
pub required: Vec<String>,
}
/// Deserialized role file (raw shape, pre-resolution).
#[derive(Debug, Default, Deserialize)]
pub struct RoleFileRaw {
#[serde(default)]
pub capabilities: RoleCapsRaw,
}
#[derive(Debug, Default, Deserialize)]
pub struct RoleCapsRaw {
#[serde(default)]
pub extends: Option<String>,
#[serde(default)]
pub required: Vec<String>,
#[serde(default)]
pub relaxes: Vec<String>,
}
/// Resolve a role by slug; read role file, walk `extends`, apply `relaxes`.
pub fn resolve_role(kit_root: &Path, role: &str) -> Result<ResolvedRole> {
let mut visited: HashSet<String> = HashSet::new();
resolve_inner(kit_root, role, &mut visited)
}
fn resolve_inner(
kit_root: &Path,
role: &str,
visited: &mut HashSet<String>,
) -> Result<ResolvedRole> {
if !visited.insert(role.to_string()) {
return Err(anyhow!(
"cycle detected in role `extends` chain at `{role}` (path: {:?})",
visited
));
}
let raw = read_role_file(kit_root, role)?;
let mut merged = match raw.capabilities.extends.as_deref() {
Some(parent) => resolve_inner(kit_root, parent, visited)?.required,
None => Vec::new(),
};
for cap in &raw.capabilities.required {
if !merged.iter().any(|c| c == cap) {
merged.push(cap.clone());
}
}
for dropped in &raw.capabilities.relaxes {
let before = merged.len();
merged.retain(|c| c != dropped);
if merged.len() == before {
eprintln!(
"[kei-agent-runtime] role `{role}` relaxes `{dropped}` \
but it was not in the inherited capability set no-op"
);
}
}
visited.remove(role);
Ok(ResolvedRole { required: merged })
}
fn read_role_file(kit_root: &Path, role: &str) -> Result<RoleFileRaw> {
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::<RoleFileRaw>(&text)
.with_context(|| format!("parse role TOML {}", path.display()))
}

View file

@ -8,7 +8,7 @@
use crate::capability::{RunMode, TaskSpec, VerifyContext, VerifyResult};
use crate::registry;
use anyhow::{Context, Result};
use anyhow::Result;
use serde::Serialize;
use std::path::{Path, PathBuf};
@ -68,22 +68,9 @@ pub fn verify_task(
Ok(report)
}
/// Extract the ordered capability list from a role.toml file.
/// Extract the ordered capability list from a role.toml file,
/// resolving `extends` chains and `relaxes` subtractions (Layer E).
pub fn load_role_capabilities(kit_root: &Path, role: &str) -> Result<Vec<String>> {
#[derive(serde::Deserialize)]
struct Role {
#[serde(default)]
capabilities: Caps,
}
#[derive(serde::Deserialize, Default)]
struct Caps {
#[serde(default)]
required: Vec<String>,
}
let path = kit_root.join("_roles").join(format!("{role}.toml"));
let text = std::fs::read_to_string(&path)
.with_context(|| format!("read role {}", path.display()))?;
let r: Role = toml::from_str(&text)
.with_context(|| format!("parse role TOML {}", path.display()))?;
Ok(r.capabilities.required)
let resolved = crate::role::resolve_role(kit_root, role)?;
Ok(resolved.required)
}

View file

@ -0,0 +1,89 @@
//! Layer G — DNA identity smoke tests.
//!
//! Asserts render ↔ parse round-trip, hashing is sensitive to the
//! documented inputs, and the total string length stays short enough to
//! embed in ledger rows.
use kei_agent_runtime::capability::TaskSpec;
use kei_agent_runtime::dna::{Dna, DnaError};
use kei_agent_runtime::role::ResolvedRole;
fn fixture_task(body: &str, wl: &[&str], dl: &[&str]) -> TaskSpec {
let mut t = TaskSpec::default();
t.task.role = "edit-local".into();
t.task.agent_id = "edit-local-forge-abc".into();
t.body.text = body.into();
t.scope.files_whitelist = wl.iter().map(|s| (*s).to_string()).collect();
t.scope.files_denylist = dl.iter().map(|s| (*s).to_string()).collect();
t
}
fn edit_local_resolved() -> ResolvedRole {
ResolvedRole {
required: vec![
"policy::no-git-ops".into(),
"scope::files-whitelist".into(),
"scope::files-denylist".into(),
"quality::constructor-pattern".into(),
"quality::cargo-check-green".into(),
"quality::tests-green".into(),
"safety::no-dep-bump".into(),
"output::report-format".into(),
],
}
}
#[test]
fn render_parse_roundtrip_equals_original() {
let task = fixture_task("Port kei-forge templating.", &["_primitives/_rust/kei-forge/**"], &[]);
let resolved = edit_local_resolved();
let dna = Dna::compose(&task, &resolved);
let rendered = dna.render();
let parsed = Dna::parse(&rendered).expect("parse");
assert_eq!(parsed, dna);
assert_eq!(parsed.render(), rendered);
}
#[test]
fn different_scopes_yield_different_scope_hashes() {
let a = fixture_task("same body", &["a/**"], &[]);
let b = fixture_task("same body", &["b/**"], &[]);
let r = edit_local_resolved();
let da = Dna::compose(&a, &r);
let db = Dna::compose(&b, &r);
assert_eq!(da.body_hash, db.body_hash, "body hash should match");
assert_ne!(da.scope_hash, db.scope_hash, "scope hash must differ");
}
#[test]
fn same_body_yields_same_body_hash() {
let a = fixture_task("exact body", &[], &[]);
let b = fixture_task("exact body", &[], &[]);
let r = edit_local_resolved();
assert_eq!(Dna::compose(&a, &r).body_hash, Dna::compose(&b, &r).body_hash);
}
#[test]
fn rendered_dna_length_within_budget() {
let task = fixture_task("body", &["a"], &["b"]);
let r = edit_local_resolved();
let s = Dna::compose(&task, &r).render();
// role(10) + caps bitmap (8 caps * 3 = 23) + scope(4) + body(4) + nonce(4)
// plus separators (3×2 + 1) = bounded; give ample 80 char headroom
assert!(
s.len() <= 80,
"DNA string should stay short; got {} chars: {}",
s.len(),
s
);
}
#[test]
fn parse_rejects_malformed_shape() {
assert_eq!(Dna::parse("too::few::segments").unwrap_err(), DnaError::Shape);
assert_eq!(
Dna::parse("role::caps::scope::no_nonce").unwrap_err(),
DnaError::Shape,
"missing `-nonce` separator must fail"
);
}

View file

@ -0,0 +1,149 @@
//! Layer E — role expression resolver smoke tests.
//!
//! Fixtures built in tempdir; each test writes the role files it needs,
//! runs `resolve_role`, asserts the flattened required list.
use kei_agent_runtime::role::resolve_role;
use std::fs;
use std::path::Path;
use tempfile::TempDir;
fn write_role(root: &Path, name: &str, body: &str) {
let dir = root.join("_roles");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join(format!("{name}.toml")), body).unwrap();
}
#[test]
fn extends_chain_merges_parent_plus_local() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
write_role(
root,
"base",
r#"
[role]
name = "base"
[capabilities]
required = ["tools::deny-tools", "output::report-format"]
"#,
);
write_role(
root,
"child",
r#"
[role]
name = "child"
[capabilities]
extends = "base"
required = ["tools::bash-allowlist"]
"#,
);
let r = resolve_role(root, "child").unwrap();
assert_eq!(
r.required,
vec![
"tools::deny-tools".to_string(),
"output::report-format".to_string(),
"tools::bash-allowlist".to_string(),
],
"child should inherit parent ordering then append local"
);
}
#[test]
fn cycle_detection_errors_with_path() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
write_role(
root,
"a",
r#"
[role]
name = "a"
[capabilities]
extends = "b"
"#,
);
write_role(
root,
"b",
r#"
[role]
name = "b"
[capabilities]
extends = "a"
"#,
);
let err = resolve_role(root, "a").unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("cycle"),
"error should mention cycle: got {msg}"
);
}
#[test]
fn relaxes_drops_inherited_capability() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
write_role(
root,
"parent",
r#"
[role]
name = "parent"
[capabilities]
required = ["scope::files-whitelist", "quality::cargo-check-green", "output::report-format"]
"#,
);
write_role(
root,
"relaxed",
r#"
[role]
name = "relaxed"
[capabilities]
extends = "parent"
relaxes = ["scope::files-whitelist"]
"#,
);
let r = resolve_role(root, "relaxed").unwrap();
assert!(
!r.required.iter().any(|c| c == "scope::files-whitelist"),
"relaxed cap must be removed from the inherited list"
);
assert!(r.required.iter().any(|c| c == "quality::cargo-check-green"));
assert!(r.required.iter().any(|c| c == "output::report-format"));
}
#[test]
fn flat_role_without_extends_still_works() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
write_role(
root,
"flat",
r#"
[role]
name = "flat"
[capabilities]
required = ["policy::no-git-ops", "output::report-format"]
"#,
);
let r = resolve_role(root, "flat").unwrap();
assert_eq!(r.required.len(), 2);
assert_eq!(r.required[0], "policy::no-git-ops");
assert_eq!(r.required[1], "output::report-format");
}

View file

@ -20,6 +20,9 @@ pub struct AgentRow {
pub finished_ts: Option<i64>,
pub summary: Option<String>,
pub worktree_path: Option<String>,
/// Layer G composition fingerprint; `None` for rows written by pre-v2
/// clients or fork calls that didn't pass `--dna`.
pub dna: Option<String>,
}
/// Open or create the ledger file and run migrations.
@ -33,6 +36,7 @@ pub fn open(path: &Path) -> SqlResult<Connection> {
}
/// Insert a new running-agent row. Errors if id is already present.
/// `dna` (Layer G) is optional; callers on the old CLI path pass `None`.
pub fn fork(
conn: &Connection,
id: &str,
@ -40,13 +44,14 @@ pub fn fork(
parent: Option<&str>,
spec_sha: &str,
worktree: Option<&str>,
dna: Option<&str>,
) -> SqlResult<()> {
let now = Utc::now().timestamp();
conn.execute(
"INSERT INTO agents
(id, branch, parent_branch, spec_sha, status, started_ts, worktree_path)
VALUES (?1, ?2, ?3, ?4, 'running', ?5, ?6)",
params![id, branch, parent, spec_sha, now, worktree],
(id, branch, parent_branch, spec_sha, status, started_ts, worktree_path, dna)
VALUES (?1, ?2, ?3, ?4, 'running', ?5, ?6, ?7)",
params![id, branch, parent, spec_sha, now, worktree, dna],
)?;
Ok(())
}
@ -86,13 +91,13 @@ pub fn list(conn: &Connection, status: Option<&str>) -> SqlResult<Vec<AgentRow>>
let (sql, bound): (&str, Vec<String>) = match status {
Some(s) => (
"SELECT id, branch, parent_branch, spec_sha, status, started_ts,
finished_ts, summary, worktree_path
finished_ts, summary, worktree_path, dna
FROM agents WHERE status = ?1 ORDER BY started_ts DESC",
vec![s.to_string()],
),
None => (
"SELECT id, branch, parent_branch, spec_sha, status, started_ts,
finished_ts, summary, worktree_path
finished_ts, summary, worktree_path, dna
FROM agents ORDER BY started_ts DESC",
vec![],
),
@ -115,13 +120,14 @@ fn row_to_agent(r: &rusqlite::Row) -> SqlResult<AgentRow> {
finished_ts: r.get(6)?,
summary: r.get(7)?,
worktree_path: r.get(8)?,
dna: r.get(9)?,
})
}
fn by_id(conn: &Connection, id: &str) -> SqlResult<Option<AgentRow>> {
conn.query_row(
"SELECT id, branch, parent_branch, spec_sha, status, started_ts,
finished_ts, summary, worktree_path
finished_ts, summary, worktree_path, dna
FROM agents WHERE id = ?1",
params![id],
row_to_agent,
@ -141,7 +147,7 @@ pub fn tree(conn: &Connection, root_id: &str) -> SqlResult<Vec<AgentRow>> {
while let Some(parent_branch) = frontier.pop() {
let mut stmt = conn.prepare(
"SELECT id, branch, parent_branch, spec_sha, status, started_ts,
finished_ts, summary, worktree_path
finished_ts, summary, worktree_path, dna
FROM agents WHERE parent_branch = ?1 ORDER BY started_ts ASC",
)?;
let kids = stmt

View file

@ -34,6 +34,9 @@ enum Cmd {
spec_sha: String,
#[arg(long)]
worktree: Option<String>,
/// Layer G DNA fingerprint (optional; kept blank for legacy callers).
#[arg(long)]
dna: Option<String>,
},
/// Mark a running agent as done.
Done {
@ -146,8 +149,16 @@ fn main() -> ExitCode {
println!("initialised {}", path.display());
ExitCode::SUCCESS
}
Cmd::Fork { id, branch, parent, spec_sha, worktree } => {
match ledger::fork(&conn, &id, &branch, parent.as_deref(), &spec_sha, worktree.as_deref()) {
Cmd::Fork { id, branch, parent, spec_sha, worktree, dna } => {
match ledger::fork(
&conn,
&id,
&branch,
parent.as_deref(),
&spec_sha,
worktree.as_deref(),
dna.as_deref(),
) {
Ok(()) => {
println!("forked {id} -> {branch}");
ExitCode::SUCCESS

View file

@ -22,6 +22,9 @@ pub const MIGRATIONS: &[&str] = &[
);
CREATE INDEX IF NOT EXISTS idx_parent ON agents(parent_branch);
CREATE INDEX IF NOT EXISTS idx_status ON agents(status);",
// v2 — Layer G DNA identity column + prefix index (2026-04-23)
"ALTER TABLE agents ADD COLUMN dna TEXT;
CREATE INDEX IF NOT EXISTS idx_agents_dna_prefix ON agents(substr(dna, 1, 30));",
];
/// Apply all pending migrations. Stores current version in pragma user_version.

View file

@ -33,7 +33,7 @@ fn write_artefacts(root: &Path, agent_id: &str, which: &[&str]) -> PathBuf {
#[test]
fn fork_then_done_marks_terminal() {
let (_d, conn) = open_tmp();
ledger::fork(&conn, "a1", "agent/a1", None, "deadbeef", None).unwrap();
ledger::fork(&conn, "a1", "agent/a1", None, "deadbeef", None, None).unwrap();
let running = ledger::list(&conn, Some("running")).unwrap();
assert_eq!(running.len(), 1);
assert_eq!(running[0].id, "a1");
@ -48,7 +48,7 @@ fn fork_then_done_marks_terminal() {
#[test]
fn fail_flow_sets_reason_and_finished_ts() {
let (_d, conn) = open_tmp();
ledger::fork(&conn, "b1", "agent/b1", Some("main"), "cafebabe", None).unwrap();
ledger::fork(&conn, "b1", "agent/b1", Some("main"), "cafebabe", None, None).unwrap();
let updated = ledger::fail(&conn, "b1", "cargo build failed").unwrap();
assert_eq!(updated, 1);
let failed = ledger::list(&conn, Some("failed")).unwrap();
@ -60,10 +60,10 @@ fn fail_flow_sets_reason_and_finished_ts() {
#[test]
fn tree_walks_parent_child_chain() {
let (_d, conn) = open_tmp();
ledger::fork(&conn, "root", "agent/root", Some("main"), "aa", None).unwrap();
ledger::fork(&conn, "c1", "agent/c1", Some("agent/root"), "bb", None).unwrap();
ledger::fork(&conn, "c2", "agent/c2", Some("agent/root"), "cc", None).unwrap();
ledger::fork(&conn, "g1", "agent/g1", Some("agent/c1"), "dd", None).unwrap();
ledger::fork(&conn, "root", "agent/root", Some("main"), "aa", None, None).unwrap();
ledger::fork(&conn, "c1", "agent/c1", Some("agent/root"), "bb", None, None).unwrap();
ledger::fork(&conn, "c2", "agent/c2", Some("agent/root"), "cc", None, None).unwrap();
ledger::fork(&conn, "g1", "agent/g1", Some("agent/c1"), "dd", None, None).unwrap();
let t = ledger::tree(&conn, "root").unwrap();
let ids: Vec<_> = t.iter().map(|a| a.id.as_str()).collect();
@ -78,8 +78,8 @@ fn tree_walks_parent_child_chain() {
#[test]
fn list_filter_status_excludes_others() {
let (_d, conn) = open_tmp();
ledger::fork(&conn, "r1", "br-r1", None, "s1", None).unwrap();
ledger::fork(&conn, "r2", "br-r2", None, "s2", None).unwrap();
ledger::fork(&conn, "r1", "br-r1", None, "s1", None, None).unwrap();
ledger::fork(&conn, "r2", "br-r2", None, "s2", None, None).unwrap();
ledger::done(&conn, "r1", "ok").unwrap();
let running = ledger::list(&conn, Some("running")).unwrap();
assert_eq!(running.len(), 1);
@ -120,25 +120,40 @@ fn validate_ok_when_all_six_present() {
#[test]
fn duplicate_fork_id_rejected() {
let (_d, conn) = open_tmp();
ledger::fork(&conn, "dup", "br1", None, "x", None).unwrap();
let err = ledger::fork(&conn, "dup", "br2", None, "y", None);
ledger::fork(&conn, "dup", "br1", None, "x", None, None).unwrap();
let err = ledger::fork(&conn, "dup", "br2", None, "y", None, None);
assert!(err.is_err(), "duplicate id must fail");
}
#[test]
fn done_on_already_done_agent_is_noop() {
let (_d, conn) = open_tmp();
ledger::fork(&conn, "n1", "br-n1", None, "h", None).unwrap();
ledger::fork(&conn, "n1", "br-n1", None, "h", None, None).unwrap();
assert_eq!(ledger::done(&conn, "n1", "first").unwrap(), 1);
assert_eq!(ledger::done(&conn, "n1", "second").unwrap(), 0);
let row = &ledger::list(&conn, None).unwrap()[0];
assert_eq!(row.summary.as_deref(), Some("first"));
}
#[test]
fn fork_with_dna_roundtrips_through_list() {
let (_d, conn) = open_tmp();
let dna = "edit-local::NG-FW-FD-CP-CG-TG-ND-RF::A7B2::C9F1-xa7c";
ledger::fork(&conn, "dna1", "agent/dna1", None, "spec", None, Some(dna)).unwrap();
let rows = ledger::list(&conn, None).unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].dna.as_deref(), Some(dna));
ledger::fork(&conn, "legacy1", "agent/legacy1", None, "spec2", None, None).unwrap();
let rows = ledger::list(&conn, None).unwrap();
let legacy = rows.iter().find(|r| r.id == "legacy1").unwrap();
assert!(legacy.dna.is_none(), "legacy fork should leave dna NULL");
}
#[test]
fn merged_after_done_transitions_status() {
let (_d, conn) = open_tmp();
ledger::fork(&conn, "m1", "br-m1", None, "h", None).unwrap();
ledger::fork(&conn, "m1", "br-m1", None, "h", None, None).unwrap();
ledger::done(&conn, "m1", "ready").unwrap();
assert_eq!(ledger::merged(&conn, "m1").unwrap(), 1);
let merged = ledger::list(&conn, Some("merged")).unwrap();

View file

@ -6,22 +6,15 @@ spawnable = true
claude-subagent-type = "code-implementer"
[capabilities]
# Ordered list — text.md fragments concatenated in this order
# Identical to edit-local; the SSoT relaxation rides on scope::files-whitelist
# parameterization in task.toml, not on a separate capability.
required = [
"policy::no-git-ops",
"scope::files-whitelist",
"scope::files-denylist",
"quality::constructor-pattern",
"quality::cargo-check-green",
"quality::tests-green",
"safety::no-dep-bump",
"output::report-format",
]
# Layer E — inherits edit-local baseline. The SSoT relaxation rides on
# scope::files-whitelist parameterization in task.toml. `relaxes` is
# available for tasks that explicitly drop a parent capability; by default
# we keep the full edit-local set.
extends = "edit-local"
required = []
relaxes = []
[tools]
# Tool allowlist — anything not in this list is denied
allowed = ["Read", "Write", "Edit", "Glob", "Grep", "Bash"]
bash-patterns-allowed = ['^cargo( |$)', '^mkdir( |$)', '^rm -rf /tmp/']

View file

@ -6,22 +6,12 @@ spawnable = true
claude-subagent-type = "Explore"
[capabilities]
# Ordered list — text.md fragments concatenated in this order.
# v0.17 renames:
# `tools::read-only` → `tools::deny-tools`
# `tools::cargo-only-bash` → `tools::bash-allowlist`
# (aliases still honored)
required = [
"tools::deny-tools",
"tools::bash-allowlist",
"output::report-format",
"output::severity-grade",
]
# Layer E — inherits read-only capability set, adds bash-allowlist for cargo probing.
extends = "read-only"
required = ["tools::bash-allowlist"]
[tools]
# Tool allowlist — anything not in this list is denied
allowed = ["Read", "Glob", "Grep", "WebFetch", "Bash"]
# Bash restricted by tools::bash-allowlist — cargo invocations only
bash-patterns-allowed = ['^cargo( |$)']
[escalation]

View file

@ -611,3 +611,80 @@ Capability atoms NOT in the initial 10 but good follow-up PRs (non-breaking addi
Role `git-ops` — documented in `docs/AGENT-ROLES.md` only; `_roles/git-ops.toml` has `spawnable = false` field. Orchestrator code refuses to spawn it. Exists for documentation of "who can do git" boundary.
Task spec persistence: task.toml files are ephemeral (gitignored under `tasks/`). Ledger row includes spec-SHA so historical specs are recoverable from `kei-sage` archive if someone wants cold-storage replay.
---
## Layer E — Role expression (extends / relaxes)
Roles compose via three optional fields on `[capabilities]`:
```toml
[capabilities]
extends = "<parent-role-slug>" # optional — flattened first
required = ["cap-a", "cap-b"] # optional — appended after parent
relaxes = ["cap-c"] # optional — dropped from flattened list
```
Resolution order:
1. If `extends` is present, recursively resolve the parent and take its flattened `required` list.
2. Append every local `required` entry not already present (order preserved).
3. Remove every entry named in `relaxes`. If a relaxed cap wasn't inherited, a stderr warning is emitted (no-op, not an error).
4. Cycle detection — an `extends` chain that loops back to an already-visiting role raises an error naming the offender.
Shipped examples:
- `_roles/read-only.toml` — base, no `extends`
- `_roles/explorer.toml``extends = "read-only"`, adds `tools::bash-allowlist`
- `_roles/edit-local.toml` — base
- `_roles/edit-shared.toml``extends = "edit-local"`, `required = []`, `relaxes = []` (the SSoT relaxation rides on `task.scope.files-whitelist`, not on capability drop)
Consumers: `compose::compose_prompt`, `prepare::prepare`, `verify::load_role_capabilities`, `dna::Dna::compose` — all go through `role::resolve_role`.
---
## Layer G — DNA identity
Every `AgentInvocation` carries a `dna` string encoding the composition:
```
<role>::<caps-bitmap>::<scope-hash>::<body-hash>-<nonce>
```
Segments:
- **role** — role slug from `task.role`
- **caps-bitmap** — hyphen-joined 2-char codes from the resolved capability list (see `dna::CAP_CODES`)
- **scope-hash** — 4-char `SHA-256` prefix of canonicalised scope (sorted whitelist + denylist)
- **body-hash** — 4-char `SHA-256` prefix of `task.body.text`
- **nonce** — 4-char random hex (disambiguates re-runs of identical specs)
Example (edit-local task touching `kei-forge`):
```
edit-local::NG-FW-FD-CP-CG-TG-ND-RF::A7B2::C9F1-xa7c
```
Round-trip: `Dna::compose(task, resolved)``.render()``Dna::parse(s)` returns an equal `Dna`. `render_human` prepends `dna: …` to the printable block; `render_json` and `render_toml` emit it as a `dna` field.
### Ledger integration
`kei-ledger` schema v2 adds a nullable `dna TEXT` column plus `idx_agents_dna_prefix` (first 30 chars) for DNA-prefix lookup. `kei-ledger fork … --dna <string>` persists it; legacy calls without the flag leave the column NULL so pre-v2 callers keep working.
### Capability atom codes (stable table)
| Name | Code |
|---|---|
| `policy::no-git-ops` | `NG` |
| `scope::files-whitelist` | `FW` |
| `scope::files-denylist` | `FD` |
| `quality::constructor-pattern` | `CP` |
| `quality::cargo-check-green` | `CG` |
| `quality::tests-green` | `TG` |
| `safety::no-dep-bump` | `ND` |
| `output::report-format` | `RF` |
| `output::severity-grade` | `SG` |
| `tools::deny-tools` | `DT` |
| `tools::bash-allowlist` | `BA` |
Additions are allowed; removals are not. Unknown names render as `??` so missing entries are visible rather than silently dropped.