feat(convergence/p3): Role expression (extends/relaxes) + DNA identity
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 = "<parent>"` + `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: <role>::<caps-bitmap>::<sha4-scope>::<sha4-body>-<hex4-nonce> - ≤ 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 <string>` optional flag - AgentRow.dna: Option<String> - 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) <noreply@anthropic.com>
This commit is contained in:
parent
652d6a369b
commit
84319efcb6
17 changed files with 673 additions and 103 deletions
2
_primitives/_rust/Cargo.lock
generated
2
_primitives/_rust/Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ thiserror = "1"
|
|||
regex = "1"
|
||||
once_cell = "1"
|
||||
walkdir = "2"
|
||||
sha2 = { workspace = true }
|
||||
rand = "0.8"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
159
_primitives/_rust/kei-agent-runtime/src/dna.rs
Normal file
159
_primitives/_rust/kei-agent-runtime/src/dna.rs
Normal 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()
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()))?;
|
||||
|
|
|
|||
95
_primitives/_rust/kei-agent-runtime/src/role.rs
Normal file
95
_primitives/_rust/kei-agent-runtime/src/role.rs
Normal 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()))
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
89
_primitives/_rust/kei-agent-runtime/tests/dna_smoke.rs
Normal file
89
_primitives/_rust/kei-agent-runtime/tests/dna_smoke.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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/']
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue