From 84319efcb6fa7a3accdb2ea648aaf1ba82564f34 Mon Sep 17 00:00:00 2001 From: Parfii-bot Date: Thu, 23 Apr 2026 04:46:48 +0800 Subject: [PATCH] feat(convergence/p3): Role expression (extends/relaxes) + DNA identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer E + G. Role TOML gains extends/relaxes for parent-role composition; agent spawn gets self-describing DNA identity alongside UUID. Role expression: - _roles/*.toml gain optional `extends = ""` + `relaxes = [...]` - compose.rs + verify.rs delegate to new role::resolve_role() with recursive extends-chain resolution + cycle detection - explorer.toml: 28→18 LOC (extends read-only) - edit-shared.toml: 31→23 LOC (extends edit-local, relaxes scope::files-whitelist for task-param override) DNA identity: - new dna.rs (159 LOC) — compose/render/parse round-trip - AgentInvocation carries dna field (prepare.rs) - Format: ::::::- - ≤ 80 chars total, greppable, parseable - 11 capability codes in CAP_CODES table: NG, FW, FD, CP, CG, TG, ND, RF, SG, DT, BA kei-ledger schema v2: - ADD COLUMN dna TEXT + prefix index - `kei-ledger fork --dna ` optional flag - AgentRow.dna: Option - Backward compat: schema migration detects + applies on open Docs: AGENT-SUBSTRATE-SCHEMA.md Layer E + Layer G sections + CAP_CODES table. New deps: sha2 (workspace), rand 0.8. Tests: kei-agent-runtime 50 (was 41, +9: 4 role + 5 DNA), kei-ledger 10 (was 9, +1 DNA roundtrip). Co-Authored-By: Claude Opus 4.7 (1M context) --- _primitives/_rust/Cargo.lock | 2 + .../_rust/kei-agent-runtime/Cargo.toml | 2 + .../_rust/kei-agent-runtime/src/compose.rs | 34 +--- .../_rust/kei-agent-runtime/src/dna.rs | 159 ++++++++++++++++++ .../_rust/kei-agent-runtime/src/lib.rs | 2 + .../_rust/kei-agent-runtime/src/prepare.rs | 30 ++-- .../_rust/kei-agent-runtime/src/role.rs | 95 +++++++++++ .../_rust/kei-agent-runtime/src/verify.rs | 23 +-- .../kei-agent-runtime/tests/dna_smoke.rs | 89 ++++++++++ .../tests/role_expression_smoke.rs | 149 ++++++++++++++++ _primitives/_rust/kei-ledger/src/ledger.rs | 20 ++- _primitives/_rust/kei-ledger/src/main.rs | 15 +- _primitives/_rust/kei-ledger/src/schema.rs | 3 + .../_rust/kei-ledger/tests/integration.rs | 39 +++-- _roles/edit-shared.toml | 21 +-- _roles/explorer.toml | 16 +- docs/AGENT-SUBSTRATE-SCHEMA.md | 77 +++++++++ 17 files changed, 673 insertions(+), 103 deletions(-) create mode 100644 _primitives/_rust/kei-agent-runtime/src/dna.rs create mode 100644 _primitives/_rust/kei-agent-runtime/src/role.rs create mode 100644 _primitives/_rust/kei-agent-runtime/tests/dna_smoke.rs create mode 100644 _primitives/_rust/kei-agent-runtime/tests/role_expression_smoke.rs diff --git a/_primitives/_rust/Cargo.lock b/_primitives/_rust/Cargo.lock index 0b0c21e..bcd9a33 100644 --- a/_primitives/_rust/Cargo.lock +++ b/_primitives/_rust/Cargo.lock @@ -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", diff --git a/_primitives/_rust/kei-agent-runtime/Cargo.toml b/_primitives/_rust/kei-agent-runtime/Cargo.toml index ef1a68f..ffd793b 100644 --- a/_primitives/_rust/kei-agent-runtime/Cargo.toml +++ b/_primitives/_rust/kei-agent-runtime/Cargo.toml @@ -23,6 +23,8 @@ thiserror = "1" regex = "1" once_cell = "1" walkdir = "2" +sha2 = { workspace = true } +rand = "0.8" [dev-dependencies] tempfile = "3" diff --git a/_primitives/_rust/kei-agent-runtime/src/compose.rs b/_primitives/_rust/kei-agent-runtime/src/compose.rs index fb859a2..a416183 100644 --- a/_primitives/_rust/kei-agent-runtime/src/compose.rs +++ b/_primitives/_rust/kei-agent-runtime/src/compose.rs @@ -2,40 +2,29 @@ //! //! Flow: //! 1. Parse `task.toml` → `TaskSpec` (caller does this). -//! 2. Load `_roles/.toml`. -//! 3. For each capability in `role.capabilities.required`, read the +//! 2. Resolve `_roles/.toml` via `role::resolve_role` +//! (handles `extends` / `relaxes` / cycle detection). +//! 3. For each capability in the resolved required list, read the //! `_capabilities///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, -} - /// 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 { 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 = 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 = 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 { Ok(fragments.join(SEPARATOR)) } -fn load_role(kit_root: &Path, role: &str) -> Result { - 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 { let (category, slug) = split_cap_name(cap_name)?; let path = kit_root diff --git a/_primitives/_rust/kei-agent-runtime/src/dna.rs b/_primitives/_rust/kei-agent-runtime/src/dna.rs new file mode 100644 index 0000000..0a77e6f --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/src/dna.rs @@ -0,0 +1,159 @@ +//! Layer G — DNA identity for agent invocations. +//! +//! DNA format: `::::::-` +//! 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::()` + 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 `-` 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 { + 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::>() + .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() +} diff --git a/_primitives/_rust/kei-agent-runtime/src/lib.rs b/_primitives/_rust/kei-agent-runtime/src/lib.rs index 7b4424a..9a6a352 100644 --- a/_primitives/_rust/kei-agent-runtime/src/lib.rs +++ b/_primitives/_rust/kei-agent-runtime/src/lib.rs @@ -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; diff --git a/_primitives/_rust/kei-agent-runtime/src/prepare.rs b/_primitives/_rust/kei-agent-runtime/src/prepare.rs index 7e18121..75bbf6e 100644 --- a/_primitives/_rust/kei-agent-runtime/src/prepare.rs +++ b/_primitives/_rust/kei-agent-runtime/src/prepare.rs @@ -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//` 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//` 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, `::::::-`. + pub dna: String, } /// Assemble an `AgentInvocation` from a parsed task.toml. @@ -42,7 +46,7 @@ pub fn prepare(task: &TaskSpec, kit_root: &Path) -> Result { "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 { 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 { 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 { +fn load_role_meta(kit_root: &Path, role: &str) -> Result { 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()))?; diff --git a/_primitives/_rust/kei-agent-runtime/src/role.rs b/_primitives/_rust/kei-agent-runtime/src/role.rs new file mode 100644 index 0000000..f8bb95b --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/src/role.rs @@ -0,0 +1,95 @@ +//! Role expression resolver — Layer E. +//! +//! Parses `_roles/.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, +} + +/// 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, + #[serde(default)] + pub required: Vec, + #[serde(default)] + pub relaxes: Vec, +} + +/// Resolve a role by slug; read role file, walk `extends`, apply `relaxes`. +pub fn resolve_role(kit_root: &Path, role: &str) -> Result { + let mut visited: HashSet = HashSet::new(); + resolve_inner(kit_root, role, &mut visited) +} + +fn resolve_inner( + kit_root: &Path, + role: &str, + visited: &mut HashSet, +) -> Result { + 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 { + 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::(&text) + .with_context(|| format!("parse role TOML {}", path.display())) +} diff --git a/_primitives/_rust/kei-agent-runtime/src/verify.rs b/_primitives/_rust/kei-agent-runtime/src/verify.rs index 3230cbf..e8cbba1 100644 --- a/_primitives/_rust/kei-agent-runtime/src/verify.rs +++ b/_primitives/_rust/kei-agent-runtime/src/verify.rs @@ -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> { - #[derive(serde::Deserialize)] - struct Role { - #[serde(default)] - capabilities: Caps, - } - #[derive(serde::Deserialize, Default)] - struct Caps { - #[serde(default)] - required: Vec, - } - 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) } diff --git a/_primitives/_rust/kei-agent-runtime/tests/dna_smoke.rs b/_primitives/_rust/kei-agent-runtime/tests/dna_smoke.rs new file mode 100644 index 0000000..f303730 --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/tests/dna_smoke.rs @@ -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" + ); +} diff --git a/_primitives/_rust/kei-agent-runtime/tests/role_expression_smoke.rs b/_primitives/_rust/kei-agent-runtime/tests/role_expression_smoke.rs new file mode 100644 index 0000000..0ad50ef --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/tests/role_expression_smoke.rs @@ -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"); +} diff --git a/_primitives/_rust/kei-ledger/src/ledger.rs b/_primitives/_rust/kei-ledger/src/ledger.rs index c9a7593..ef4d3b6 100644 --- a/_primitives/_rust/kei-ledger/src/ledger.rs +++ b/_primitives/_rust/kei-ledger/src/ledger.rs @@ -20,6 +20,9 @@ pub struct AgentRow { pub finished_ts: Option, pub summary: Option, pub worktree_path: Option, + /// Layer G composition fingerprint; `None` for rows written by pre-v2 + /// clients or fork calls that didn't pass `--dna`. + pub dna: Option, } /// Open or create the ledger file and run migrations. @@ -33,6 +36,7 @@ pub fn open(path: &Path) -> SqlResult { } /// 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> let (sql, bound): (&str, Vec) = 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 { 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> { 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> { 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 diff --git a/_primitives/_rust/kei-ledger/src/main.rs b/_primitives/_rust/kei-ledger/src/main.rs index 4d8b02d..5527996 100644 --- a/_primitives/_rust/kei-ledger/src/main.rs +++ b/_primitives/_rust/kei-ledger/src/main.rs @@ -34,6 +34,9 @@ enum Cmd { spec_sha: String, #[arg(long)] worktree: Option, + /// Layer G DNA fingerprint (optional; kept blank for legacy callers). + #[arg(long)] + dna: Option, }, /// 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 diff --git a/_primitives/_rust/kei-ledger/src/schema.rs b/_primitives/_rust/kei-ledger/src/schema.rs index 5e3c1eb..6655f05 100644 --- a/_primitives/_rust/kei-ledger/src/schema.rs +++ b/_primitives/_rust/kei-ledger/src/schema.rs @@ -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. diff --git a/_primitives/_rust/kei-ledger/tests/integration.rs b/_primitives/_rust/kei-ledger/tests/integration.rs index b60b582..a40f28f 100644 --- a/_primitives/_rust/kei-ledger/tests/integration.rs +++ b/_primitives/_rust/kei-ledger/tests/integration.rs @@ -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(); diff --git a/_roles/edit-shared.toml b/_roles/edit-shared.toml index a627f70..112534f 100644 --- a/_roles/edit-shared.toml +++ b/_roles/edit-shared.toml @@ -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/'] diff --git a/_roles/explorer.toml b/_roles/explorer.toml index 6e07179..d47bd21 100644 --- a/_roles/explorer.toml +++ b/_roles/explorer.toml @@ -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] diff --git a/docs/AGENT-SUBSTRATE-SCHEMA.md b/docs/AGENT-SUBSTRATE-SCHEMA.md index 55374c9..0111cf1 100644 --- a/docs/AGENT-SUBSTRATE-SCHEMA.md +++ b/docs/AGENT-SUBSTRATE-SCHEMA.md @@ -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 = "" # 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: + +``` +::::::- +``` + +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 ` 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.