feat(agent-substrate/wrapper): kei-agent-runtime prepare — orchestrator ergonomics
Single-command "prepare spawn" that emits everything orchestrator needs to invoke the Agent tool: composed prompt, subagent_type (from role's new claude-subagent-type field), isolation mode, verify command, ledger row. Before this: orchestrator ran compose + read prompt + manually constructed Agent tool call + manually built verify command. 4 steps. After: `kei-agent-runtime prepare <task.toml> --format=human` outputs a single copy-paste-ready block. Orchestrator pastes into Agent tool and records the verify command for return. Files: - src/prepare.rs (170 LOC) — prepare() returns AgentInvocation struct (agent_id, prompt, subagent_type, isolation, description, verify_command, ledger_row) - src/main.rs (+39 LOC) — Prepare subcommand with --format=human|json|toml - src/lib.rs (+2 LOC — pub mod prepare) - _roles/*.toml (5 files) — new optional claude-subagent-type field: - edit-local / edit-shared → "code-implementer" - read-only → "critic" (default; "architect" override possible) - explorer → "Explore" - git-ops → "NOT-SPAWNABLE" (refused by prepare with RULE 0.13) - tests/prepare_smoke.rs (3 tests) — happy path, unknown role, non-spawnable refusal - docs/AGENT-SUBSTRATE-SCHEMA.md (+ ## Orchestrator ergonomics section) Tests: 40/40 (was 37, +3 prepare_smoke). Same path exercised in tempfile fixtures that the real CLI would hit end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
abf2bcb30d
commit
d72ae51f16
10 changed files with 394 additions and 1 deletions
|
|
@ -7,6 +7,7 @@
|
|||
//! - `verifies` — 8 on-return verify capabilities
|
||||
//! - `compose` — task.toml + role + capabilities → prompt.md
|
||||
//! - `spawn` — prepare tasks/<agent-id>/prompt.md + ledger row
|
||||
//! - `prepare` — orchestrator-facing `AgentInvocation` bundle (ergonomics)
|
||||
//! - `verify` — run all verify capabilities against agent's return
|
||||
//! - `simulated_merge` — orchestrator-side worktree → apply diff → verify
|
||||
//!
|
||||
|
|
@ -15,6 +16,7 @@
|
|||
pub mod capability;
|
||||
pub mod compose;
|
||||
pub mod gates;
|
||||
pub mod prepare;
|
||||
pub mod registry;
|
||||
pub mod simulated_merge;
|
||||
pub mod spawn;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
use clap::{Parser, Subcommand};
|
||||
use kei_agent_runtime::capability::RunMode;
|
||||
use kei_agent_runtime::{compose, spawn, verify};
|
||||
use kei_agent_runtime::{compose, prepare, spawn, verify};
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
|
|
@ -51,6 +51,16 @@ enum Cmd {
|
|||
#[arg(long)]
|
||||
kit_root: Option<PathBuf>,
|
||||
},
|
||||
/// Assemble everything orchestrator needs to invoke Agent tool.
|
||||
/// Does NOT write tasks/ on disk — inspection helper.
|
||||
Prepare {
|
||||
task: PathBuf,
|
||||
#[arg(long)]
|
||||
kit_root: Option<PathBuf>,
|
||||
/// Output format: human (default) | json | toml
|
||||
#[arg(long, default_value = "human")]
|
||||
format: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
|
|
@ -62,6 +72,35 @@ fn main() -> ExitCode {
|
|||
run_verify(task, worktree, kit_root, main_repo, mode)
|
||||
}
|
||||
Cmd::Run { task, worktree, kit_root } => run_run(task, worktree, kit_root),
|
||||
Cmd::Prepare { task, kit_root, format } => run_prepare(task, kit_root, format),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_prepare(task_path: PathBuf, kit_root: Option<PathBuf>, format: String) -> ExitCode {
|
||||
let kit = kit_root_or_cwd(kit_root);
|
||||
let task = match spawn::load_task(&task_path) {
|
||||
Ok(t) => t,
|
||||
Err(e) => return err("load task", e),
|
||||
};
|
||||
let inv = match prepare::prepare(&task, &kit) {
|
||||
Ok(i) => i,
|
||||
Err(e) => return err("prepare", e),
|
||||
};
|
||||
let rendered = match format.as_str() {
|
||||
"human" => Ok(prepare::render_human(&inv)),
|
||||
"json" => prepare::render_json(&inv),
|
||||
"toml" => prepare::render_toml(&inv),
|
||||
other => {
|
||||
eprintln!("unknown format '{other}' (expected human|json|toml)");
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
};
|
||||
match rendered {
|
||||
Ok(s) => {
|
||||
print!("{s}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => err("render", e),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
170
_primitives/_rust/kei-agent-runtime/src/prepare.rs
Normal file
170
_primitives/_rust/kei-agent-runtime/src/prepare.rs
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
//! Orchestrator-facing wrapper: task.toml → everything needed to invoke
|
||||
//! Claude Code's `Agent` tool in a single copy-paste-ready bundle.
|
||||
//!
|
||||
//! Per RULE 0.13, the orchestrator (main session) owns branch creation,
|
||||
//! `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.
|
||||
|
||||
use crate::capability::TaskSpec;
|
||||
use crate::compose::compose_prompt;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
/// Everything the orchestrator needs to hand the Claude `Agent` tool.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AgentInvocation {
|
||||
pub agent_id: String,
|
||||
pub role: String,
|
||||
pub prompt: String,
|
||||
pub subagent_type: String,
|
||||
pub isolation: Option<String>,
|
||||
pub description: String,
|
||||
pub verify_command: String,
|
||||
pub ledger_row: String,
|
||||
}
|
||||
|
||||
/// Assemble an `AgentInvocation` from a parsed task.toml.
|
||||
///
|
||||
/// Errors if the role is unknown or non-spawnable (points at RULE 0.13).
|
||||
pub fn prepare(task: &TaskSpec, kit_root: &Path) -> Result<AgentInvocation> {
|
||||
if task.task.role.is_empty() {
|
||||
return Err(anyhow!("task.role is empty"));
|
||||
}
|
||||
if task.task.agent_id.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"task.agent-id is empty — orchestrator must allocate via kei-ledger"
|
||||
));
|
||||
}
|
||||
let role_file = load_role_file(kit_root, &task.task.role)?;
|
||||
if !role_file.role.spawnable {
|
||||
return Err(anyhow!(
|
||||
"role '{}' is NOT spawnable (per RULE 0.13 git-ops is \
|
||||
orchestrator-only) — refusing to prepare Agent tool invocation",
|
||||
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 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);
|
||||
Ok(AgentInvocation {
|
||||
agent_id: task.task.agent_id.clone(),
|
||||
role: task.task.role.clone(),
|
||||
prompt,
|
||||
subagent_type,
|
||||
isolation,
|
||||
description,
|
||||
verify_command,
|
||||
ledger_row,
|
||||
})
|
||||
}
|
||||
|
||||
/// Human-readable block — copy into Claude Code's Agent-tool dialog.
|
||||
pub fn render_human(inv: &AgentInvocation) -> String {
|
||||
let iso = inv.isolation.as_deref().unwrap_or("<none>");
|
||||
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!("subagent_type: {}\n", inv.subagent_type));
|
||||
out.push_str(&format!("isolation: {iso}\n"));
|
||||
out.push_str(&format!("description: {}\n", inv.description));
|
||||
out.push_str("\n--- PROMPT (copy into Agent tool `prompt` param) ---\n");
|
||||
out.push_str(&inv.prompt);
|
||||
if !inv.prompt.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str("--- END PROMPT ---\n\n");
|
||||
out.push_str("on return:\n");
|
||||
out.push_str(&format!(" {}\n", inv.verify_command));
|
||||
out.push_str(" (orchestrator harness returns worktree path in the task-notification)\n\n");
|
||||
out.push_str(&format!("ledger: {}\n", inv.ledger_row));
|
||||
out
|
||||
}
|
||||
|
||||
pub fn render_json(inv: &AgentInvocation) -> Result<String> {
|
||||
serde_json::to_string_pretty(inv).context("serialize AgentInvocation to JSON")
|
||||
}
|
||||
|
||||
pub fn render_toml(inv: &AgentInvocation) -> Result<String> {
|
||||
toml::to_string_pretty(inv).context("serialize AgentInvocation to TOML")
|
||||
}
|
||||
|
||||
fn default_isolation(role: &str) -> Option<String> {
|
||||
match role {
|
||||
"edit-local" | "edit-shared" => Some("worktree".into()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn default_subagent_type(role: &str) -> String {
|
||||
match role {
|
||||
"edit-local" | "edit-shared" => "code-implementer",
|
||||
"explorer" => "Explore",
|
||||
"read-only" => "critic",
|
||||
_ => "critic",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
fn build_description(role: &str, agent_id: &str) -> String {
|
||||
let short = agent_id.split('-').take(2).collect::<Vec<_>>().join("-");
|
||||
format!("{role} agent {short}")
|
||||
}
|
||||
|
||||
fn build_verify_command(agent_id: &str) -> String {
|
||||
format!(
|
||||
"kei-agent-runtime verify tasks/{id}/task.toml \
|
||||
--worktree <path-from-harness>",
|
||||
id = agent_id
|
||||
)
|
||||
}
|
||||
|
||||
fn build_ledger_row(task: &TaskSpec) -> String {
|
||||
let parent = task
|
||||
.task
|
||||
.parent_agent
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("none");
|
||||
format!(
|
||||
"running agent-id={} role={} parent={}",
|
||||
task.task.agent_id, task.task.role, parent
|
||||
)
|
||||
}
|
||||
|
||||
fn load_role_file(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()))?;
|
||||
toml::from_str::<RoleFile>(&text)
|
||||
.with_context(|| format!("parse role TOML {}", path.display()))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RoleFile {
|
||||
#[serde(default)]
|
||||
role: RoleMeta,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct RoleMeta {
|
||||
#[serde(default = "spawnable_default")]
|
||||
spawnable: bool,
|
||||
#[serde(default, rename = "claude-subagent-type")]
|
||||
claude_subagent_type: Option<String>,
|
||||
}
|
||||
|
||||
fn spawnable_default() -> bool {
|
||||
true
|
||||
}
|
||||
120
_primitives/_rust/kei-agent-runtime/tests/prepare_smoke.rs
Normal file
120
_primitives/_rust/kei-agent-runtime/tests/prepare_smoke.rs
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
//! Prepare smoke — validates orchestrator-facing wrapper.
|
||||
//!
|
||||
//! Three fixtures per task spec:
|
||||
//! 1. Happy path — valid task.toml → AgentInvocation fully populated
|
||||
//! 2. Unknown role → clear error (role lookup fails)
|
||||
//! 3. Non-spawnable role (git-ops) → explicit refusal + RULE 0.13 pointer
|
||||
|
||||
use kei_agent_runtime::capability::TaskSpec;
|
||||
use kei_agent_runtime::prepare::{prepare, render_human};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn write_capability(root: &std::path::Path, cat: &str, slug: &str, body: &str) {
|
||||
let dir = root.join("_capabilities").join(cat).join(slug);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("text.md"), body).unwrap();
|
||||
}
|
||||
|
||||
fn write_role(root: &std::path::Path, name: &str, toml: &str) {
|
||||
std::fs::create_dir_all(root.join("_roles")).unwrap();
|
||||
std::fs::write(root.join("_roles").join(format!("{name}.toml")), toml).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn happy_path_yields_full_invocation() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
write_capability(root, "policy", "no-git-ops", "## Never git.\n");
|
||||
write_capability(root, "output", "report-format", "## Report fields.\n");
|
||||
write_role(
|
||||
root,
|
||||
"edit-local",
|
||||
r#"
|
||||
[role]
|
||||
name = "edit-local"
|
||||
spawnable = true
|
||||
claude-subagent-type = "code-implementer"
|
||||
|
||||
[capabilities]
|
||||
required = ["policy::no-git-ops", "output::report-format"]
|
||||
"#,
|
||||
);
|
||||
|
||||
let mut task = TaskSpec::default();
|
||||
task.task.role = "edit-local".into();
|
||||
task.task.agent_id = "edit-local-forge-abc123".into();
|
||||
task.body.text = "Port kei-forge templating to pure-Rust.".into();
|
||||
|
||||
let inv = prepare(&task, root).expect("prepare should succeed");
|
||||
assert_eq!(inv.agent_id, "edit-local-forge-abc123");
|
||||
assert_eq!(inv.role, "edit-local");
|
||||
assert_eq!(inv.subagent_type, "code-implementer");
|
||||
assert_eq!(inv.isolation.as_deref(), Some("worktree"));
|
||||
assert!(inv.prompt.contains("Never git"));
|
||||
assert!(inv.prompt.contains("Report fields"));
|
||||
assert!(inv.prompt.contains("Port kei-forge templating"));
|
||||
assert!(inv.verify_command.contains("kei-agent-runtime verify"));
|
||||
assert!(inv.verify_command.contains("edit-local-forge-abc123"));
|
||||
assert!(inv.ledger_row.contains("running"));
|
||||
assert!(inv.ledger_row.contains("edit-local"));
|
||||
assert!(inv.ledger_row.contains("parent=none"));
|
||||
|
||||
let human = render_human(&inv);
|
||||
assert!(human.contains("=== AGENT SUBSTRATE v1"));
|
||||
assert!(human.contains("--- PROMPT"));
|
||||
assert!(human.contains("--- END PROMPT"));
|
||||
assert!(human.contains("subagent_type: code-implementer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_role_errors_clearly() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
let mut task = TaskSpec::default();
|
||||
task.task.role = "does-not-exist".into();
|
||||
task.task.agent_id = "x-1".into();
|
||||
|
||||
let err = prepare(&task, root).expect_err("unknown role must fail");
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains("does-not-exist") || msg.contains("role"),
|
||||
"error should mention the role or the word 'role': got {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_spawnable_role_refused_with_rule_013_pointer() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
write_role(
|
||||
root,
|
||||
"git-ops",
|
||||
r#"
|
||||
[role]
|
||||
name = "git-ops"
|
||||
spawnable = false
|
||||
claude-subagent-type = "NOT-SPAWNABLE"
|
||||
|
||||
[capabilities]
|
||||
required = []
|
||||
"#,
|
||||
);
|
||||
|
||||
let mut task = TaskSpec::default();
|
||||
task.task.role = "git-ops".into();
|
||||
task.task.agent_id = "orchestrator-only-1".into();
|
||||
|
||||
let err = prepare(&task, root).expect_err("git-ops must be refused");
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains("RULE 0.13"),
|
||||
"refusal must cite RULE 0.13: got {msg}"
|
||||
);
|
||||
assert!(
|
||||
msg.contains("spawnable") || msg.contains("orchestrator"),
|
||||
"refusal message should mention spawnable/orchestrator: got {msg}"
|
||||
);
|
||||
}
|
||||
|
|
@ -3,6 +3,9 @@ name = "edit-local"
|
|||
display-name = "code-implementer (local edit scope)"
|
||||
description = "Write code within whitelisted files, run cargo check/test, emit structured report. No git, no workspace-level touches, no dep bumps."
|
||||
spawnable = true
|
||||
# Default Claude Code subagent_type to hand `prepare` output to.
|
||||
# Overridable per-task; see docs/AGENT-SUBSTRATE-SCHEMA.md §Orchestrator ergonomics.
|
||||
claude-subagent-type = "code-implementer"
|
||||
|
||||
[capabilities]
|
||||
# Ordered list — text.md fragments concatenated in this order
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ name = "edit-shared"
|
|||
display-name = "code-implementer (shared-SSoT edit scope)"
|
||||
description = "Same baseline as edit-local, with one relaxed scope entry permitting edits to a task-specified SSoT path (e.g. workspace Cargo.toml, registry file). The relaxation is configured per task via `[scope].files-whitelist` in task.toml."
|
||||
spawnable = true
|
||||
claude-subagent-type = "code-implementer"
|
||||
|
||||
[capabilities]
|
||||
# Ordered list — text.md fragments concatenated in this order
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ name = "explorer"
|
|||
display-name = "explorer + cargo-check (read-only analyst with build probe)"
|
||||
description = "Read-only analyst that may run cargo-family commands for build/test introspection. No edits, no git, no non-cargo shell."
|
||||
spawnable = true
|
||||
claude-subagent-type = "Explore"
|
||||
|
||||
[capabilities]
|
||||
# Ordered list — text.md fragments concatenated in this order
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ name = "git-ops"
|
|||
display-name = "git operator (orchestrator-only, NOT spawnable)"
|
||||
description = "Documented boundary of git authority. Per RULE 0.13, only the orchestrator (main session) holds git power: branch creation, commit, push, merge, rebase, reset, tag. This role is documented for completeness and is refused by kei-agent-runtime at spawn time."
|
||||
spawnable = false
|
||||
# Documented for completeness; never consumed because spawnable = false.
|
||||
claude-subagent-type = "NOT-SPAWNABLE"
|
||||
|
||||
[capabilities]
|
||||
# No capability restrictions declared here — this role is never composed into
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ name = "read-only"
|
|||
display-name = "explorer (read-only analyst)"
|
||||
description = "Read-only agent: inspects code, emits structured report with severity grades. No shell, no edits, no git."
|
||||
spawnable = true
|
||||
# Read-only + severity-grade default maps to critic; architect-flavoured tasks
|
||||
# should override this per task via claude-subagent-type in task.toml.
|
||||
claude-subagent-type = "critic"
|
||||
|
||||
[capabilities]
|
||||
# Ordered list — text.md fragments concatenated in this order
|
||||
|
|
|
|||
|
|
@ -547,6 +547,58 @@ Phase 5 wired the 5 kit-shipped agents to role+task-spec invocation via a new `s
|
|||
|
||||
Backward compatibility: the `substrate_role` field is optional. The 7 non-migrated kit agents (`kei-cost-guardian`, `kei-fal-ai-runner`, `kei-infra-implementer`, `kei-ml-implementer`, `kei-ml-researcher`, `kei-modal-runner`, `kei-researcher`) continue to assemble without change; a deferred v0.24 migration wave will promote them. Task-spec examples showing how the orchestrator invokes each migrated agent live under `_templates/task-examples/`.
|
||||
|
||||
## Orchestrator ergonomics — `prepare` command
|
||||
|
||||
`compose` emits a prompt, `spawn` writes `tasks/<id>/` on disk, `verify` runs on return. Between compose and spawn, the orchestrator needs to invoke Claude Code's `Agent` tool — which lives inside Claude Code, not in Rust. `kei-agent-runtime prepare` bridges that step: it parses a `task.toml` and emits every argument the Agent-tool call needs in one copy-paste-ready block.
|
||||
|
||||
```
|
||||
kei-agent-runtime prepare <task.toml> [--kit-root .] [--format human|json|toml]
|
||||
```
|
||||
|
||||
Human output:
|
||||
|
||||
```
|
||||
=== AGENT SUBSTRATE v1 — PREPARED SPAWN ===
|
||||
agent-id: <id>
|
||||
subagent_type: <role-derived>
|
||||
isolation: worktree
|
||||
description: <role> agent <short>
|
||||
|
||||
--- PROMPT (copy into Agent tool `prompt` param) ---
|
||||
<composed prompt content>
|
||||
--- END PROMPT ---
|
||||
|
||||
on return:
|
||||
kei-agent-runtime verify tasks/<id>/task.toml --worktree <path-from-harness>
|
||||
(orchestrator harness returns worktree path in the task-notification)
|
||||
|
||||
ledger: running agent-id=<id> role=<role> parent=<parent-or-none>
|
||||
```
|
||||
|
||||
`--format=json` and `--format=toml` emit the same `AgentInvocation` struct for scriptable wrappers (e.g. future `/spawn-agent` Claude Code skill).
|
||||
|
||||
### Role → Claude subagent_type mapping
|
||||
|
||||
Claude Code's `Agent` tool takes a `subagent_type` string. Roles map to subagent_type via an optional `claude-subagent-type` field on `[role]` in `_roles/<name>.toml`. If unset, the runtime falls back to defaults:
|
||||
|
||||
| Role | Default `claude-subagent-type` |
|
||||
|---|---|
|
||||
| `edit-local` | `code-implementer` |
|
||||
| `edit-shared` | `code-implementer` |
|
||||
| `explorer` | `Explore` |
|
||||
| `read-only` | `critic` (override per-task for architect-flavour reviews) |
|
||||
| `git-ops` | `NOT-SPAWNABLE` (never composed — `spawnable = false`) |
|
||||
|
||||
`isolation = "worktree"` is auto-set for `edit-local` and `edit-shared`; other roles default to no isolation.
|
||||
|
||||
### Non-spawnable refusal
|
||||
|
||||
`prepare` refuses roles with `spawnable = false` and cites RULE 0.13 in the error. `git-ops` is the only shipped example; the refusal keeps "who can do git" boundary visible both in the role manifest AND at invocation time.
|
||||
|
||||
### Contract
|
||||
|
||||
`prepare` does NOT write to disk (inspection helper) and does NOT touch the ledger DB (the "ledger row" field is a pretty-printed string for the orchestrator to verify before calling `kei-ledger fork`). `spawn` remains the disk-writing step; `prepare` is additive and read-only.
|
||||
|
||||
## Deferred extension candidates (non-breaking post-lock)
|
||||
|
||||
Capability atoms NOT in the initial 10 but good follow-up PRs (non-breaking additions during lock window):
|
||||
|
|
|
|||
Loading…
Reference in a new issue