PreToolUse hooks route through kei-capability check when orchestrator registers a capability via KEI_CAPABILITY_NAME env var on agent spawn. hooks/agent-capability-check.sh (22 LOC): - Pass-through (exit 0) when KEI_CAPABILITY_NAME unset — no-op by default - Fail-open (exit 0) when kei-capability binary missing — kit convention - Sources _lib/gate.sh for KEI_DISABLED_HOOKS / KEI_HOOK_PROFILE respect - exec kei-capability check "$CAP_NAME" when active hooks/agent-capability-verify.sh (24 LOC): - Orchestrator-driven, NOT a Claude Code native hook - Carries env: AGENT_ID, TASK_TOML, WORKTREE_PATH, MAIN_REPO, RUN_MODE - exec kei-capability verify "$CAP_NAME" Registered in hooks/hooks.json + settings-snippet.json under both PreToolUse:Bash and PreToolUse:Edit|Write matchers. Internal NotApplicable returns exit 0 so non-matching tool calls cost nothing. install.sh unchanged — hooks/*.sh glob picks up both new files. tests/hook_wiring_integration.sh (64 LOC) — 3 contract assertions: (1) pass-through on unset KEI_CAPABILITY_NAME (2) deny+exit 2 on git-op pattern (3) allow+exit 0 on cargo-check pattern Multi-capability routing (for phase 5): KEI_CAPABILITY_NAME currently holds ONE name. When a role requires N capabilities, orchestrator will either iterate or kei-capability gains a compose subcommand. Design note left for phase 5. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
24 KiB
KeiSeiKit Agent Substrate Schema v1
STATUS: Decisions resolved 2026-04-23 — see updated Decision log at bottom. LOCK active upon AGENT-SCHEMA-LOCKED.md commit. 3-week parallel phase window.
PURPOSE: Sibling SSoT to SUBSTRATE-SCHEMA.md. That one decomposes code primitives (atoms). This one decomposes agent invocations (capabilities).
Motivation from substrate v1 orchestration pain: across 7 agent spawns in audit+follow-up waves, the same friction recurred — 40% prompt boilerplate, self-reported green tests that broke at integration, scope violations surfacing only after merge. Fix: capabilities become enforced triplets, not suggestions in freetext prompts.
Core concept: capability atom = triplet
An agent capability is not a reusable text block. It is a declarative bundle + Rust implementation that gives every restriction meaning across three layers:
| Artifact | Format | Who consumes |
|---|---|---|
capability.toml |
TOML declarative metadata (name, category, patterns, parameters) | kei-agent-runtime at compose + lint time |
text.md |
Markdown prompt fragment | Agent (via LLM context) |
Rust module gates/<slug>.rs |
Rust impl Capability trait |
kei-capability check binary at PreToolUse |
Rust module verifies/<slug>.rs |
Rust impl Capability trait |
kei-capability verify binary at on-return |
The two Rust modules live in _primitives/_rust/kei-agent-runtime/src/ — one compilation unit, one registry, cargo test on all gates/verifies at once. Shell hooks are 3-line glue that execs the binary.
The invariant: if any of the four artifacts is missing or fails, the capability did not hold. Self-reported compliance is not trusted — verification runs via worktree short-circuit → simulated merge pattern (see §Verify execution below) after agent return, catching integration regressions before merge to main.
File layout
_capabilities/ — DECLARATIVE artefacts (phase 1 writes these)
├── policy/
│ └── no-git-ops/
│ ├── capability.toml
│ └── text.md
├── scope/
│ ├── files-whitelist/{capability.toml, text.md}
│ └── files-denylist/{capability.toml, text.md}
├── quality/
│ ├── constructor-pattern/{capability.toml, text.md}
│ ├── cargo-check-green/{capability.toml, text.md}
│ └── tests-green/{capability.toml, text.md}
├── safety/
│ └── no-dep-bump/{capability.toml, text.md}
├── output/
│ ├── report-format/{capability.toml, text.md}
│ └── severity-grade/{capability.toml, text.md}
└── tools/
├── read-only/{capability.toml, text.md}
└── cargo-only-bash/{capability.toml, text.md}
_roles/ — DECLARATIVE (phase 2 writes these)
├── read-only.toml
├── explorer.toml
├── edit-local.toml
├── edit-shared.toml
└── git-ops.toml — documented; NOT spawnable (orchestrator-only)
_primitives/_rust/kei-agent-runtime/ — BINARY (phase 3 writes this)
├── Cargo.toml
├── src/
│ ├── lib.rs — exports Capability trait + registry
│ ├── main.rs — CLI: compose | spawn | verify | run
│ ├── compose.rs — task.toml + role + capabilities → prompt.md
│ ├── spawn.rs — Agent-tool invocation with composed prompt
│ ├── verify.rs — worktree short-circuit → simulated merge
│ ├── simulated_merge.rs — create temp branch + apply diff + run checks
│ ├── registry.rs — &str → Box<dyn Capability> dispatch
│ ├── gates/ — PreToolUse logic
│ │ ├── mod.rs
│ │ ├── policy_no_git_ops.rs
│ │ ├── scope_files_whitelist.rs
│ │ ├── scope_files_denylist.rs
│ │ ├── safety_no_dep_bump.rs
│ │ ├── tools_read_only.rs
│ │ └── tools_cargo_only_bash.rs — 6 gates
│ └── verifies/ — on-return logic
│ ├── mod.rs
│ ├── quality_constructor_pattern.rs
│ ├── quality_cargo_check_green.rs
│ ├── quality_tests_green.rs
│ ├── safety_no_dep_bump.rs
│ ├── scope_files_whitelist.rs
│ ├── scope_files_denylist.rs
│ ├── output_report_format.rs
│ └── output_severity_grade.rs — 8 verifies
└── tests/
_primitives/_rust/kei-capability/ — BINARY (phase 3)
├── Cargo.toml — depends on kei-agent-runtime
└── src/main.rs — clap CLI:
kei-capability check <name> (stdin JSON, exit 0|2)
kei-capability verify <name> (env-driven, exit 0 or fail)
hooks/ — 3-line shell glue (phase 4 ✓ shipped)
├── agent-capability-check.sh — `exec kei-capability check "$KEI_CAPABILITY_NAME"` — PreToolUse:Bash|Edit|Write, no-op when env unset, fail-open on missing binary
└── agent-capability-verify.sh — orchestrator-driven post-agent: `exec kei-capability verify "$KEI_CAPABILITY_NAME"` with AGENT_ID/TASK_TOML/WORKTREE_PATH/MAIN_REPO/RUN_MODE env
tasks/ — ephemeral, gitignored
└── <agent-id>/{task.toml, prompt.md}
docs/AGENT-SUBSTRATE-SCHEMA.md — this file
docs/AGENT-ROLES.md — human-readable role matrix (generated from _roles/*.toml)
docs/AGENT-SCHEMA-LOCKED.md — lock marker
Capability atom — capability.toml shape
[capability]
name = "policy::no-git-ops" # <category>::<slug> namespace
category = "policy" # policy | scope | quality | safety | output | tools
version = "1.0"
description = "RULE 0.13 — orchestrator owns git, agent writes files only"
rationale = "See ~/.claude/rules/orchestrator-branch-first.md"
[restricts]
# What this capability forbids. Runtime gate enforces.
tool-patterns = [ # matched against tool_input.command
'^git( |$)',
'^gh (repo|api /repos)',
]
tools-denied = [] # e.g. ["Edit", "Write"] for read-only
[parameterized]
# Is this capability instance-configurable per task?
accepts = [] # e.g. ["files-whitelist"] for scope/* caps
[text]
path = "text.md" # relative to capability dir
[gate]
# Rust module path inside kei-agent-runtime — registry dispatches by capability.name
rust-module = "gates::policy_no_git_ops" # or empty if capability has no gate (verify-only)
event = "PreToolUse:Bash" # PreToolUse:Bash | PreToolUse:Edit|Write | PreToolUse:Agent
severity = "block" # block (exit 2) | warn (exit 0 + stderr) | advisory (log only)
bypass-env = "ORCHESTRATOR_META" # optional env var to disable
[verify]
rust-module = "verifies::policy_no_git_ops" # or empty if gate-only
run-mode = "simulated-merge" # worktree | simulated-merge | both
when = "on-return" # on-return | per-tool-call
run-mode values:
worktree— run predicate inside the agent's worktree (fastest; what the agent saw)simulated-merge— orchestrator createstest-merge/<agent-id>branch off main, applies agent diff, runs predicate from there (catches integration regressions of the E1-jsonschema-class — see §Verify execution)both— worktree first (fail-fast), then simulated-merge (integration guarantee). Default forquality::*capabilities.
Capability text.md conventions
- Imperative, second-person, short.
- ≤ 200 words per fragment.
- No overlap — if two capabilities say the same thing, extract into a shared one.
- Fragment stands alone — composer concatenates multiple fragments with
\n\n---\n\nseparator; fragments must not reference each other. - Lead with the rule ("You MUST NOT X"), follow with the why ("because Y").
Example (_capabilities/policy/no-git-ops/text.md):
## No git operations
You MUST NOT invoke `git`, `gh repo`, `gh api /repos`, or any shell
command that modifies git state. Orchestrator handles all git operations
(commits, branches, pushes, rebases).
If your task requires staging a change, describe it in the return
file-list — the orchestrator will commit on your behalf.
Bypass exists for orchestrator-meta agents only; it is not available here.
Capability trait contract (Rust)
All gates and verifies implement the same trait, dispatched by string name. Registry in kei-agent-runtime/src/registry.rs maps "policy::no-git-ops" to Box<dyn Capability>.
// kei-agent-runtime/src/capability.rs
pub trait Capability: Send + Sync {
fn name(&self) -> &'static str;
/// PreToolUse gate. Called by `kei-capability check <name>` binary.
/// Receives the hook JSON payload from Claude Code on stdin.
/// Returns Allow / Deny{reason} / NotApplicable.
fn check(&self, ctx: &GateContext) -> GateDecision {
GateDecision::NotApplicable // default: no gate, verify-only
}
/// On-return verification predicate. Called by `kei-capability verify <name>`.
/// Receives task context (agent-id, worktree path, main repo, task.toml values).
/// Returns Pass / Fail{reason}.
fn verify(&self, ctx: &VerifyContext) -> VerifyResult {
VerifyResult::Pass // default: no verify, gate-only
}
}
pub struct GateContext<'a> {
pub tool_name: &'a str,
pub tool_input: &'a Value,
pub task: &'a TaskSpec, // parsed task.toml
pub env: &'a HashMap<String, String>,
}
pub enum GateDecision {
Allow,
Deny { reason: String },
NotApplicable,
}
pub struct VerifyContext<'a> {
pub agent_id: &'a str,
pub task: &'a TaskSpec,
pub worktree_path: &'a Path,
pub main_repo: &'a Path,
pub run_mode: RunMode, // Worktree | SimulatedMerge | Both
}
pub enum VerifyResult {
Pass,
Fail { reason: String, detail: Option<String> },
}
Example implementation (_primitives/_rust/kei-agent-runtime/src/gates/policy_no_git_ops.rs):
use crate::capability::*;
use regex::Regex;
use once_cell::sync::Lazy;
pub struct NoGitOps;
static GIT_PATTERNS: Lazy<Vec<Regex>> = Lazy::new(|| vec![
Regex::new(r"(?m)(?:^|[;&|]|\s)git(?:\s|$)").unwrap(),
Regex::new(r"(?m)(?:^|[;&|]|\s)gh\s+repo").unwrap(),
Regex::new(r"(?m)(?:^|[;&|]|\s)gh\s+api\s+/?repos").unwrap(),
]);
impl Capability for NoGitOps {
fn name(&self) -> &'static str { "policy::no-git-ops" }
fn check(&self, ctx: &GateContext) -> GateDecision {
if ctx.tool_name != "Bash" { return GateDecision::NotApplicable; }
if ctx.env.get("ORCHESTRATOR_META").map(|v| v == "1").unwrap_or(false) {
return GateDecision::Allow;
}
let cmd = ctx.tool_input.get("command").and_then(|v| v.as_str()).unwrap_or("");
for pat in GIT_PATTERNS.iter() {
if pat.is_match(cmd) {
return GateDecision::Deny {
reason: format!("RULE 0.13 — git operation blocked (pattern {})", pat.as_str()),
};
}
}
GateDecision::Allow
}
}
Example verify (_primitives/_rust/kei-agent-runtime/src/verifies/quality_cargo_check_green.rs):
use crate::capability::*;
use std::process::Command;
pub struct CargoCheckGreen;
impl Capability for CargoCheckGreen {
fn name(&self) -> &'static str { "quality::cargo-check-green" }
fn verify(&self, ctx: &VerifyContext) -> VerifyResult {
let run_dir = match ctx.run_mode {
RunMode::Worktree => ctx.worktree_path,
RunMode::SimulatedMerge => &ctx.simulated_merge_path(),
RunMode::Both => unreachable!("runtime runs `both` as two sequential calls"),
};
let out = Command::new("cargo")
.arg("check")
.arg("--workspace")
.current_dir(run_dir.join("_primitives/_rust"))
.output();
match out {
Err(e) => VerifyResult::Fail {
reason: "cargo invocation failed".to_string(),
detail: Some(e.to_string()),
},
Ok(o) if !o.status.success() => {
let tail = String::from_utf8_lossy(&o.stderr).lines().rev().take(5).collect::<Vec<_>>();
VerifyResult::Fail {
reason: "cargo check --workspace FAILED — agent-local green ≠ integration green".to_string(),
detail: Some(tail.into_iter().rev().collect::<Vec<_>>().join("\n")),
}
}
Ok(_) => VerifyResult::Pass,
}
}
}
Verify execution — worktree → simulated merge
The orchestrator runs verification in two sequential passes for run-mode = "both":
Pass 1 — worktree (fail-fast)
cd <agent-worktree>
run capability.verify(RunMode::Worktree)
if Fail → reject immediately, don't bother with pass 2
Pass 2 — simulated-merge (integration guarantee)
git checkout -b test-merge/<agent-id> main # from MAIN repo, not worktree
git apply <agent-diff> # apply agent's changes on clean main
cd <test-merge branch>
run capability.verify(RunMode::SimulatedMerge)
if Fail → reject with regression report
if Pass → safe to merge, orchestrator proceeds
Why both: agent's worktree passing doesn't mean merged-main passing. E1's jsonschema regression was green in worktree (no real atoms there) but broke main integration (real atom schemas triggered the 0.17→0.18 breaking change). Simulated merge catches this class before it lands on main.
Implementation lives in kei-agent-runtime/src/simulated_merge.rs — creates a temp worktree via git worktree add, applies diff, runs verify, cleans up.
Role — _roles/<name>.toml shape
[role]
name = "edit-local"
display-name = "code-implementer (local edit scope)"
description = "Write code + run cargo check/test + emit report. No git, no workspace touches."
[capabilities]
# Ordered list — text.md fragments concatenated in this order
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",
]
[tools]
# Tool allowlist — anything not in this list is denied
allowed = ["Read", "Write", "Edit", "Glob", "Grep", "Bash"]
# Bash further restricted by quality/tools atoms
bash-patterns-allowed = ['^cargo( |$)', '^mkdir( |$)', '^rm -rf /tmp/']
[escalation]
policy = "ask-via-return" # ask-via-return | orchestrator-notify | fail-fast
Task spec — task.toml shape (orchestrator writes per spawn)
[task]
role = "edit-local"
agent-id = "abc123…" # allocated by kei-ledger fork
parent-agent = null # or parent ID for nested
[scope]
# Parameterizes scope::files-whitelist + scope::files-denylist
files-whitelist = [
"_primitives/_rust/kei-forge/**",
]
files-denylist = [
"_primitives/_rust/Cargo.toml",
"_primitives/_rust/Cargo.lock",
"scripts/**",
".github/**",
]
[verification]
# Parameterizes quality/* caps
cargo-check-crates = ["kei-forge"]
cargo-test-crates = ["kei-forge"]
test-count-min = 44
[output]
# Parameterizes output/report-format
report-fields-required = ["files-touched", "cargo-check", "cargo-test", "loc-delta"]
[body]
# Free-text task instructions, concatenated AFTER role capability fragments
text = """
Replace shell-out with pure-Rust templating. …
"""
Runtime execution contract
kei-agent-runtime crate provides:
# Compose prompt from task spec
kei-agent-runtime compose <task.toml>
# → writes <task-dir>/prompt.md
# Spawn agent with composed prompt + install gates + record ledger
kei-agent-runtime spawn <task.toml>
# → returns agent-id; background-task notification semantics
# Run all capability verify predicates against agent's return
kei-agent-runtime verify <task.toml> <worktree-path>
# → exit 0 if all hold, non-zero with report of violations
# One-shot helper: compose + spawn + verify
kei-agent-runtime run <task.toml>
Execution flow:
1. orchestrator writes task.toml
2. `kei-agent-runtime compose` → prompt.md
3. `kei-agent-runtime spawn` →
a. kei-ledger fork <agent-id>
b. install PreToolUse gates parameterized by task.scope
c. Agent tool call with isolation=worktree + composed prompt
4. [agent executes]
5. `kei-agent-runtime verify` →
a. run each capability verify.sh from MAIN repo (not worktree)
b. collect all violations
c. exit 0 if empty, non-zero with report
6. orchestrator decides: merge | reject + respawn | reject + rollback
Initial capability atom inventory (phase 1 builds these 10)
| Name | Category | text / gate / verify | Core restriction |
|---|---|---|---|
policy::no-git-ops |
policy | ✓/✓/✓ | Block git, gh repo, gh api /repos |
scope::files-whitelist |
scope | ✓/✓/✓ | PreToolUse:Edit|Write denies paths outside whitelist; on-return git diff check |
scope::files-denylist |
scope | ✓/✓/✓ | PreToolUse:Edit|Write denies paths in denylist (overrides whitelist) |
quality::constructor-pattern |
quality | ✓/—/✓ | On return: no file > 200 LOC, no fn > 30 LOC |
quality::cargo-check-green |
quality | ✓/—/✓ | On return: cargo check --workspace from MAIN passes |
quality::tests-green |
quality | ✓/—/✓ | On return: cargo test -p <crate> passes, count ≥ task min |
safety::no-dep-bump |
safety | ✓/✓/✓ | PreToolUse:Edit on Cargo.toml denies unless task opts in; on-return lock-diff check |
output::report-format |
output | ✓/—/✓ | On return: parse report, assert required fields present |
tools::read-only |
tools | ✓/✓/— | PreToolUse denies Edit/Write entirely |
tools::cargo-only-bash |
tools | ✓/✓/— | PreToolUse:Bash denies unless command matches allowlist pattern |
Initial role inventory (phase 2 builds these 5)
| Role | Capabilities | Tools |
|---|---|---|
read-only |
tools::read-only + output::report-format + output::severity-grade | Read / Glob / Grep / WebFetch |
explorer |
read-only caps + tools::cargo-only-bash (for cargo check) |
+ Bash-cargo |
edit-local |
policy::no-git-ops + scope::* + quality::* + safety::no-dep-bump + output::report-format | + Edit / Write / Bash-cargo |
edit-shared |
edit-local caps + permission for specified SSoT patterns | Same + SSoT paths |
git-ops |
Documented-only, NOT spawnable (orchestrator holds this) | All |
Decision log — resolved 2026-04-23
| # | Question | Decision | Rationale |
|---|---|---|---|
| 1 | Layout per capability | Declarative bundle (capability.toml + text.md) + Rust modules in runtime crate |
Declarative artefacts live with capability; executable logic lives with its sibling capabilities in one Rust crate for shared tests + type safety |
| 2 | Gate language | Rust via kei-capability check <name> binary; shell hook = 3-line exec glue |
Type safety, unit tests, one compilation unit for all gates. Shell remains only as Claude-Code-hook-protocol adapter |
| 3 | Verify language | Rust same binary, kei-capability verify <name> subcommand |
Same reasoning. Cargo output parsing, LOC checks, diff analysis — all better in Rust |
| 4 | Config format (capability.toml / role.toml / task.toml) | TOML | Consistent with Cargo ecosystem. YAML reserved only for locked atom .md frontmatter (immutable under atom substrate v1 lock) |
| 5 | Capability ID separator | :: |
Consistent with atom IDs. Rust-native |
| 6 | Capability path layout | Nested _capabilities/<category>/<slug>/ |
Scales to 50+ capabilities, category browsability |
| 7 | Text fragment max | 200 words per capability | Agent context budget; forces atomicity |
| 8 | Verify execution | worktree short-circuit → simulated-merge (default both for quality::*) |
Catches E1-jsonschema-class integration regressions before main merge. See §Verify execution |
Locked values: all 8 above. Breaking changes require explicit user revocation + all-phases sync.
Phase plan (post-lock, parallel)
| Phase | What | Depends on | Agent | Estimate |
|---|---|---|---|---|
| 0 | This schema + lock marker | — | me | 0.5 day ✓ |
| 1 | Capability library — 10 × (capability.toml + text.md) = 20 declarative files |
phase 0 | 1 code-implementer | 1-2 days |
| 2 | Role matrix — 5 _roles/*.toml + auto-gen docs/AGENT-ROLES.md |
phase 0 | 1 code-implementer | 0.5 day |
| 3 | kei-agent-runtime + kei-capability binaries — compose/spawn/verify CLI + 6 gate modules + 8 verify modules + registry + simulated-merge executor |
phase 0 | 1 code-implementer | 5-6 days |
| 4 ✓ | Hook wiring — agent-capability-check.sh + agent-capability-verify.sh 3-line glue + settings.json registration |
phases 1+3 | 1 code-implementer | 0.5 day (shipped) |
| 5 | Migration — 5 custom agents (code-implementer / critic / architect / security-auditor / validator) adopt role+task-spec invocation | phases 1+2+3+4 | 1 code-implementer | 1 day |
Phases 1, 2, 3 start in parallel immediately after lock (different dirs, zero file overlap). Phase 4 depends on 1+3. Phase 5 depends on everything.
Total wall-time with parallel phases 1+2+3: ~7-8 days from lock (phase 3 is critical path).
Integration with substrate v1
This schema is additive to locked SUBSTRATE-SCHEMA.md. The two SSoTs sit side by side:
SUBSTRATE-SCHEMA.md— how code decomposes into atoms (locked 2026-04-22)AGENT-SUBSTRATE-SCHEMA.md— how agent invocation decomposes into capabilities (this doc)
Cross-ref: agent capability quality::cargo-check-green verifies that atoms compiled; atom agents produced via kei-forge can themselves be invoked through kei-runtime (atom substrate) OR composed into role definitions (agent substrate).
Eventually (post-both-locks): agents compose atoms, atoms compose agents. Symmetric substrates.
Lock declaration
Once this document is approved by the user and docs/AGENT-SCHEMA-LOCKED.md is committed, the capability-triplet shape + role shape + task-spec shape + runtime contract are immutable for 3 weeks (shorter lock than atom substrate because agent substrate is greenfield, expected revisions).
Breaking changes during lock require:
- Explicit revocation by user
- All parallel phase agents paused
- Lock marker amended with revocation reason
kei-ledgerrow: bypass reason + revocation timestamp
Non-breaking additions (new capability atoms beyond the initial 10, new roles, new parameterized fields on existing capabilities) are allowed during lock.
Deferred extension candidates (non-breaking post-lock)
Capability atoms NOT in the initial 10 but good follow-up PRs (non-breaking additions during lock window):
safety::no-mass-delete— PreToolUse deniesrm -rfon more than N filesoutput::ledger-row-required— verify agent emitted ledger row per RULE 0.12quality::no-warnings—cargo build --workspacewith-D warningsscope::no-rule-edits— denies edits to~/.claude/rules/*.mdunless orchestrator-meta
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.