Merge branch 'feat/rule-0.12-agent-git-model' — kei-ledger Rust + hook + /new-project
This commit is contained in:
commit
62e28450bc
12 changed files with 1117 additions and 0 deletions
19
_primitives/_rust/Cargo.toml
Normal file
19
_primitives/_rust/Cargo.toml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["mock-render", "visual-diff", "tokens-sync", "kei-ledger"]
|
||||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
rust-version = "1.75"
|
||||
|
||||
[workspace.dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
20
_primitives/_rust/kei-ledger/Cargo.toml
Normal file
20
_primitives/_rust/kei-ledger/Cargo.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
[package]
|
||||
name = "kei-ledger"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.75"
|
||||
description = "Agent fork / done / fail ledger — SQLite-backed, SSoT for RULE 0.12"
|
||||
|
||||
[[bin]]
|
||||
name = "kei-ledger"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
170
_primitives/_rust/kei-ledger/src/ledger.rs
Normal file
170
_primitives/_rust/kei-ledger/src/ledger.rs
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
//! Ledger operations — fork / done / fail / list / tree / validate.
|
||||
//!
|
||||
//! Constructor Pattern: each public fn <30 LOC, single responsibility.
|
||||
//! Storage: rusqlite Connection (bundled SQLite). One file per caller.
|
||||
|
||||
use crate::schema::{migrate, REQUIRED_ARTEFACTS};
|
||||
use chrono::Utc;
|
||||
use rusqlite::{params, Connection, OptionalExtension, Result as SqlResult};
|
||||
use serde::Serialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct AgentRow {
|
||||
pub id: String,
|
||||
pub branch: String,
|
||||
pub parent_branch: Option<String>,
|
||||
pub spec_sha: String,
|
||||
pub status: String,
|
||||
pub started_ts: i64,
|
||||
pub finished_ts: Option<i64>,
|
||||
pub summary: Option<String>,
|
||||
pub worktree_path: Option<String>,
|
||||
}
|
||||
|
||||
/// Open or create the ledger file and run migrations.
|
||||
pub fn open(path: &Path) -> SqlResult<Connection> {
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let conn = Connection::open(path)?;
|
||||
migrate(&conn)?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
/// Insert a new running-agent row. Errors if id is already present.
|
||||
pub fn fork(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
branch: &str,
|
||||
parent: Option<&str>,
|
||||
spec_sha: &str,
|
||||
worktree: 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],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark a running agent as done. No-op if already in terminal state.
|
||||
pub fn done(conn: &Connection, id: &str, summary: &str) -> SqlResult<usize> {
|
||||
let now = Utc::now().timestamp();
|
||||
conn.execute(
|
||||
"UPDATE agents SET status='done', finished_ts=?1, summary=?2
|
||||
WHERE id=?3 AND status='running'",
|
||||
params![now, summary, id],
|
||||
)
|
||||
}
|
||||
|
||||
/// Mark a running agent as failed with reason.
|
||||
pub fn fail(conn: &Connection, id: &str, reason: &str) -> SqlResult<usize> {
|
||||
let now = Utc::now().timestamp();
|
||||
conn.execute(
|
||||
"UPDATE agents SET status='failed', finished_ts=?1, summary=?2
|
||||
WHERE id=?3 AND status='running'",
|
||||
params![now, reason, id],
|
||||
)
|
||||
}
|
||||
|
||||
/// Mark an agent as merged (post-ceremony bookkeeping).
|
||||
pub fn merged(conn: &Connection, id: &str) -> SqlResult<usize> {
|
||||
let now = Utc::now().timestamp();
|
||||
conn.execute(
|
||||
"UPDATE agents SET status='merged', finished_ts=COALESCE(finished_ts, ?1)
|
||||
WHERE id=?2 AND status IN ('done','failed')",
|
||||
params![now, id],
|
||||
)
|
||||
}
|
||||
|
||||
/// List all agents, optionally filtered by status.
|
||||
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
|
||||
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
|
||||
FROM agents ORDER BY started_ts DESC",
|
||||
vec![],
|
||||
),
|
||||
};
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params_from_iter(bound.iter()), row_to_agent)?
|
||||
.collect::<SqlResult<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn row_to_agent(r: &rusqlite::Row) -> SqlResult<AgentRow> {
|
||||
Ok(AgentRow {
|
||||
id: r.get(0)?,
|
||||
branch: r.get(1)?,
|
||||
parent_branch: r.get(2)?,
|
||||
spec_sha: r.get(3)?,
|
||||
status: r.get(4)?,
|
||||
started_ts: r.get(5)?,
|
||||
finished_ts: r.get(6)?,
|
||||
summary: r.get(7)?,
|
||||
worktree_path: r.get(8)?,
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
FROM agents WHERE id = ?1",
|
||||
params![id],
|
||||
row_to_agent,
|
||||
)
|
||||
.optional()
|
||||
}
|
||||
|
||||
/// Walk the parent chain from `root_id` down to all descendants.
|
||||
/// Returns rows in BFS order starting with root.
|
||||
pub fn tree(conn: &Connection, root_id: &str) -> SqlResult<Vec<AgentRow>> {
|
||||
let root = match by_id(conn, root_id)? {
|
||||
Some(r) => r,
|
||||
None => return Ok(vec![]),
|
||||
};
|
||||
let mut out = vec![root.clone()];
|
||||
let mut frontier = vec![root.branch];
|
||||
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
|
||||
FROM agents WHERE parent_branch = ?1 ORDER BY started_ts ASC",
|
||||
)?;
|
||||
let kids = stmt
|
||||
.query_map(params![parent_branch], row_to_agent)?
|
||||
.collect::<SqlResult<Vec<_>>>()?;
|
||||
for k in kids {
|
||||
frontier.push(k.branch.clone());
|
||||
out.push(k);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Verify all 6 required artefacts exist under `.claude/agents/<id>/`
|
||||
/// rooted at `repo_root`. Returns list of missing artefacts (empty = OK).
|
||||
pub fn validate(repo_root: &Path, agent_id: &str) -> Vec<String> {
|
||||
let mut base: PathBuf = repo_root.to_path_buf();
|
||||
base.push(".claude");
|
||||
base.push("agents");
|
||||
base.push(agent_id);
|
||||
REQUIRED_ARTEFACTS
|
||||
.iter()
|
||||
.filter(|a| !base.join(a).is_file())
|
||||
.map(|a| a.to_string())
|
||||
.collect()
|
||||
}
|
||||
177
_primitives/_rust/kei-ledger/src/main.rs
Normal file
177
_primitives/_rust/kei-ledger/src/main.rs
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
//! kei-ledger — CLI dispatcher.
|
||||
//!
|
||||
//! Single responsibility: parse args, dispatch to ledger ops, format output.
|
||||
//! Storage: `~/.claude/agents/ledger.sqlite` (or $KEI_LEDGER_DB override).
|
||||
|
||||
mod ledger;
|
||||
mod schema;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "kei-ledger", version, about = "Agent fork/done/fail ledger")]
|
||||
struct Cli {
|
||||
/// Override ledger path (default: $KEI_LEDGER_DB or ~/.claude/agents/ledger.sqlite)
|
||||
#[arg(long)]
|
||||
db: Option<PathBuf>,
|
||||
#[command(subcommand)]
|
||||
cmd: Cmd,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Create the ledger file + schema if missing.
|
||||
Init,
|
||||
/// Log a new running agent.
|
||||
Fork {
|
||||
id: String,
|
||||
branch: String,
|
||||
#[arg(long)]
|
||||
parent: Option<String>,
|
||||
#[arg(long)]
|
||||
spec_sha: String,
|
||||
#[arg(long)]
|
||||
worktree: Option<String>,
|
||||
},
|
||||
/// Mark a running agent as done.
|
||||
Done {
|
||||
id: String,
|
||||
#[arg(long)]
|
||||
summary: String,
|
||||
},
|
||||
/// Mark a running agent as failed.
|
||||
Fail {
|
||||
id: String,
|
||||
#[arg(long)]
|
||||
reason: String,
|
||||
},
|
||||
/// Mark a done/failed agent as merged.
|
||||
Merged { id: String },
|
||||
/// List agents, optionally filtered by status.
|
||||
List {
|
||||
#[arg(long)]
|
||||
status: Option<String>,
|
||||
},
|
||||
/// Print parent -> children tree starting at a root agent id.
|
||||
Tree { id: String },
|
||||
/// Validate required artefact bundle for a given branch's agent.
|
||||
Validate {
|
||||
branch: String,
|
||||
#[arg(long, default_value = ".")]
|
||||
repo_root: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
fn db_path(cli_db: Option<PathBuf>) -> PathBuf {
|
||||
if let Some(p) = cli_db {
|
||||
return p;
|
||||
}
|
||||
if let Ok(env) = std::env::var("KEI_LEDGER_DB") {
|
||||
return PathBuf::from(env);
|
||||
}
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
|
||||
PathBuf::from(home).join(".claude/agents/ledger.sqlite")
|
||||
}
|
||||
|
||||
fn cmd_list(conn: &rusqlite::Connection, status: Option<&str>) -> ExitCode {
|
||||
match ledger::list(conn, status) {
|
||||
Ok(rows) => {
|
||||
if rows.is_empty() {
|
||||
println!("(no agents)");
|
||||
}
|
||||
for r in &rows {
|
||||
println!(
|
||||
"{}\t{}\t{}\t{}\tparent={}\tspec={}",
|
||||
r.id,
|
||||
r.status,
|
||||
r.branch,
|
||||
r.started_ts,
|
||||
r.parent_branch.as_deref().unwrap_or("-"),
|
||||
&r.spec_sha[..r.spec_sha.len().min(12)]
|
||||
);
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => err(&format!("list failed: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_tree(conn: &rusqlite::Connection, id: &str) -> ExitCode {
|
||||
match ledger::tree(conn, id) {
|
||||
Ok(rows) if rows.is_empty() => err(&format!("no agent with id {id}")),
|
||||
Ok(rows) => {
|
||||
for r in &rows {
|
||||
let indent = if r.id == id { "" } else { " " };
|
||||
println!("{}{} [{}] branch={}", indent, r.id, r.status, r.branch);
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => err(&format!("tree failed: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_validate(branch: &str, repo_root: &std::path::Path) -> ExitCode {
|
||||
// branch naming convention: agent/<kind>-<ts> OR inline-<ts>
|
||||
// ledger artefact dir uses the raw agent id, which the caller passes as branch.
|
||||
let agent_id = branch.strip_prefix("agent/").unwrap_or(branch);
|
||||
let missing = ledger::validate(repo_root, agent_id);
|
||||
if missing.is_empty() {
|
||||
println!("OK: all 6 artefacts present for {agent_id}");
|
||||
ExitCode::SUCCESS
|
||||
} else {
|
||||
eprintln!("MISSING for {agent_id}:");
|
||||
for m in &missing {
|
||||
eprintln!(" - {m}");
|
||||
}
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
|
||||
fn err(msg: &str) -> ExitCode {
|
||||
eprintln!("kei-ledger: {msg}");
|
||||
ExitCode::from(1)
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let cli = Cli::parse();
|
||||
let path = db_path(cli.db);
|
||||
let conn = match ledger::open(&path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return err(&format!("open {}: {e}", path.display())),
|
||||
};
|
||||
match cli.cmd {
|
||||
Cmd::Init => {
|
||||
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()) {
|
||||
Ok(()) => {
|
||||
println!("forked {id} -> {branch}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => err(&format!("fork failed: {e}")),
|
||||
}
|
||||
}
|
||||
Cmd::Done { id, summary } => match ledger::done(&conn, &id, &summary) {
|
||||
Ok(0) => err(&format!("no running agent with id {id}")),
|
||||
Ok(_) => ExitCode::SUCCESS,
|
||||
Err(e) => err(&format!("done failed: {e}")),
|
||||
},
|
||||
Cmd::Fail { id, reason } => match ledger::fail(&conn, &id, &reason) {
|
||||
Ok(0) => err(&format!("no running agent with id {id}")),
|
||||
Ok(_) => ExitCode::SUCCESS,
|
||||
Err(e) => err(&format!("fail update failed: {e}")),
|
||||
},
|
||||
Cmd::Merged { id } => match ledger::merged(&conn, &id) {
|
||||
Ok(0) => err(&format!("no done/failed agent with id {id}")),
|
||||
Ok(_) => ExitCode::SUCCESS,
|
||||
Err(e) => err(&format!("merged failed: {e}")),
|
||||
},
|
||||
Cmd::List { status } => cmd_list(&conn, status.as_deref()),
|
||||
Cmd::Tree { id } => cmd_tree(&conn, &id),
|
||||
Cmd::Validate { branch, repo_root } => cmd_validate(&branch, &repo_root),
|
||||
}
|
||||
}
|
||||
50
_primitives/_rust/kei-ledger/src/schema.rs
Normal file
50
_primitives/_rust/kei-ledger/src/schema.rs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
//! SQL schema for the agent ledger.
|
||||
//!
|
||||
//! Constructor Pattern: one cube = schema DDL + migration runner.
|
||||
//! Single source of truth for table shape. Any structural change MUST
|
||||
//! bump the migration list below; existing rows are preserved.
|
||||
|
||||
use rusqlite::{Connection, Result};
|
||||
|
||||
/// Ordered migrations. Index = schema version. Never reorder; append only.
|
||||
pub const MIGRATIONS: &[&str] = &[
|
||||
// v1 — initial schema (RULE 0.12, 2026-04-21)
|
||||
"CREATE TABLE IF NOT EXISTS agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
branch TEXT NOT NULL,
|
||||
parent_branch TEXT,
|
||||
spec_sha TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('running','done','failed','merged','rejected')),
|
||||
started_ts INTEGER NOT NULL,
|
||||
finished_ts INTEGER,
|
||||
summary TEXT,
|
||||
worktree_path TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_parent ON agents(parent_branch);
|
||||
CREATE INDEX IF NOT EXISTS idx_status ON agents(status);",
|
||||
];
|
||||
|
||||
/// Apply all pending migrations. Stores current version in pragma user_version.
|
||||
pub fn migrate(conn: &Connection) -> Result<()> {
|
||||
let current: i64 = conn
|
||||
.query_row("PRAGMA user_version", [], |r| r.get(0))
|
||||
.unwrap_or(0);
|
||||
for (i, sql) in MIGRATIONS.iter().enumerate() {
|
||||
let target = (i + 1) as i64;
|
||||
if current < target {
|
||||
conn.execute_batch(sql)?;
|
||||
conn.pragma_update(None, "user_version", target)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Six required artefacts per agent (RULE 0.12 §completion bundle).
|
||||
pub const REQUIRED_ARTEFACTS: &[&str] = &[
|
||||
"spec.md",
|
||||
"plan.md",
|
||||
"progress.json",
|
||||
"chatlog.md",
|
||||
"handoffs.md",
|
||||
"review.md",
|
||||
];
|
||||
147
_primitives/_rust/kei-ledger/tests/integration.rs
Normal file
147
_primitives/_rust/kei-ledger/tests/integration.rs
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
//! Integration tests for kei-ledger.
|
||||
//!
|
||||
//! Constructor Pattern: each test = one scenario, one assertion target.
|
||||
//! Uses tempfile for per-test isolated sqlite file. Loads source modules
|
||||
//! via `#[path]` so we don't need to expose a library crate surface.
|
||||
|
||||
#[path = "../src/schema.rs"]
|
||||
mod schema;
|
||||
#[path = "../src/ledger.rs"]
|
||||
mod ledger;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn open_tmp() -> (TempDir, Connection) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = dir.path().join("ledger.sqlite");
|
||||
let conn = ledger::open(&db).unwrap();
|
||||
(dir, conn)
|
||||
}
|
||||
|
||||
fn write_artefacts(root: &Path, agent_id: &str, which: &[&str]) -> PathBuf {
|
||||
let base = root.join(".claude/agents").join(agent_id);
|
||||
fs::create_dir_all(&base).unwrap();
|
||||
for f in which {
|
||||
fs::write(base.join(f), b"x").unwrap();
|
||||
}
|
||||
base
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_then_done_marks_terminal() {
|
||||
let (_d, conn) = open_tmp();
|
||||
ledger::fork(&conn, "a1", "agent/a1", None, "deadbeef", None).unwrap();
|
||||
let running = ledger::list(&conn, Some("running")).unwrap();
|
||||
assert_eq!(running.len(), 1);
|
||||
assert_eq!(running[0].id, "a1");
|
||||
|
||||
let updated = ledger::done(&conn, "a1", "shipped").unwrap();
|
||||
assert_eq!(updated, 1);
|
||||
let done = ledger::list(&conn, Some("done")).unwrap();
|
||||
assert_eq!(done.len(), 1);
|
||||
assert_eq!(done[0].summary.as_deref(), Some("shipped"));
|
||||
}
|
||||
|
||||
#[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();
|
||||
let updated = ledger::fail(&conn, "b1", "cargo build failed").unwrap();
|
||||
assert_eq!(updated, 1);
|
||||
let failed = ledger::list(&conn, Some("failed")).unwrap();
|
||||
assert_eq!(failed.len(), 1);
|
||||
assert!(failed[0].finished_ts.is_some());
|
||||
assert_eq!(failed[0].summary.as_deref(), Some("cargo build failed"));
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
||||
let t = ledger::tree(&conn, "root").unwrap();
|
||||
let ids: Vec<_> = t.iter().map(|a| a.id.as_str()).collect();
|
||||
assert!(ids.contains(&"root"));
|
||||
assert!(ids.contains(&"c1"));
|
||||
assert!(ids.contains(&"c2"));
|
||||
assert!(ids.contains(&"g1"));
|
||||
assert_eq!(ids[0], "root");
|
||||
assert_eq!(ids.len(), 4);
|
||||
}
|
||||
|
||||
#[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::done(&conn, "r1", "ok").unwrap();
|
||||
let running = ledger::list(&conn, Some("running")).unwrap();
|
||||
assert_eq!(running.len(), 1);
|
||||
assert_eq!(running[0].id, "r2");
|
||||
let all = ledger::list(&conn, None).unwrap();
|
||||
assert_eq!(all.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_detects_missing_artefacts() {
|
||||
let (d, _conn) = open_tmp();
|
||||
write_artefacts(d.path(), "v1", &["spec.md", "plan.md"]);
|
||||
let missing = ledger::validate(d.path(), "v1");
|
||||
assert_eq!(missing.len(), 4);
|
||||
assert!(missing.contains(&"progress.json".to_string()));
|
||||
assert!(missing.contains(&"review.md".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_ok_when_all_six_present() {
|
||||
let (d, _conn) = open_tmp();
|
||||
write_artefacts(
|
||||
d.path(),
|
||||
"v2",
|
||||
&[
|
||||
"spec.md",
|
||||
"plan.md",
|
||||
"progress.json",
|
||||
"chatlog.md",
|
||||
"handoffs.md",
|
||||
"review.md",
|
||||
],
|
||||
);
|
||||
let missing = ledger::validate(d.path(), "v2");
|
||||
assert!(missing.is_empty(), "got missing {missing:?}");
|
||||
}
|
||||
|
||||
#[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);
|
||||
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();
|
||||
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 merged_after_done_transitions_status() {
|
||||
let (_d, conn) = open_tmp();
|
||||
ledger::fork(&conn, "m1", "br-m1", None, "h", None).unwrap();
|
||||
ledger::done(&conn, "m1", "ready").unwrap();
|
||||
assert_eq!(ledger::merged(&conn, "m1").unwrap(), 1);
|
||||
let merged = ledger::list(&conn, Some("merged")).unwrap();
|
||||
assert_eq!(merged.len(), 1);
|
||||
assert_eq!(merged[0].summary.as_deref(), Some("ready"));
|
||||
}
|
||||
51
hooks/agent-fork-logger.sh
Executable file
51
hooks/agent-fork-logger.sh
Executable file
|
|
@ -0,0 +1,51 @@
|
|||
#!/bin/sh
|
||||
# agent-fork-logger.sh — PreToolUse:Agent advisory hook (RULE 0.12).
|
||||
#
|
||||
# Reads the Agent tool invocation JSON from stdin and, if kei-ledger is on
|
||||
# PATH, logs a fork row so the orchestrator can later validate the bundle.
|
||||
# NEVER blocks: every exit path is `exit 0`. Hard-dependency absent (no jq,
|
||||
# no kei-ledger, no git) → silent no-op.
|
||||
|
||||
command -v jq >/dev/null 2>&1 || exit 0
|
||||
|
||||
set -eu
|
||||
|
||||
input="$(cat)"
|
||||
|
||||
# Extract subagent_type / prompt / isolation from the Agent tool_input.
|
||||
subagent=$(printf '%s' "$input" | jq -r '.tool_input.subagent_type // empty' 2>/dev/null || true)
|
||||
prompt=$(printf '%s' "$input" | jq -r '.tool_input.prompt // empty' 2>/dev/null || true)
|
||||
isolation=$(printf '%s' "$input" | jq -r '.tool_input.isolation // empty' 2>/dev/null || true)
|
||||
|
||||
# No subagent name → not an agent call we know how to track.
|
||||
if [ -z "$subagent" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Spec fingerprint = first 16 hex chars of SHA-256(prompt).
|
||||
spec_sha=$(printf '%s' "$prompt" | shasum -a 256 2>/dev/null | cut -c1-16)
|
||||
spec_sha=${spec_sha:-unknown}
|
||||
|
||||
ts=$(date +%s)
|
||||
agent_id="${subagent}-${ts}"
|
||||
|
||||
if [ "$isolation" = "worktree" ]; then
|
||||
branch="agent/${subagent}-${ts}"
|
||||
else
|
||||
branch="inline-${subagent}-${ts}"
|
||||
fi
|
||||
|
||||
# Current git branch (if we're inside a repo). Non-fatal if git absent.
|
||||
current_branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo none)
|
||||
|
||||
# If kei-ledger is unavailable, bail silently — the hook is advisory only.
|
||||
command -v kei-ledger >/dev/null 2>&1 || exit 0
|
||||
|
||||
# Emit the fork row. Always exit 0 even if kei-ledger returns non-zero:
|
||||
# the hook must never block an Agent invocation on ledger trouble.
|
||||
kei-ledger fork "$agent_id" "$branch" \
|
||||
--parent "$current_branch" \
|
||||
--spec-sha "$spec_sha" \
|
||||
>/dev/null 2>&1 || true
|
||||
|
||||
exit 0
|
||||
109
skills/new-project/SKILL.md
Normal file
109
skills/new-project/SKILL.md
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
---
|
||||
name: new-project
|
||||
description: 4-phase pipeline for bootstrapping a new multi-agent project — intake, fork skeleton (branch + ledger row + sub-agent spawn), parallel execution with progress aggregation, and per-branch merge ceremony. Implements RULE 0.12 (agent git-model) at project scale. Hub-and-spoke — each phase lives in its own file and is executed in order.
|
||||
argument-hint: <project name or one-line goal>
|
||||
---
|
||||
|
||||
# New-Project — 4-Phase Pipeline (index)
|
||||
|
||||
You are bootstrapping a **new project** — research, code, theoretical, or
|
||||
hybrid — that will run as a **main agent plus N parallel sub-agents** with
|
||||
full git-model compliance (branch + 6-file artefact bundle + ledger row +
|
||||
merge ceremony). This skill is the orchestrator wrapper; each phase lives
|
||||
in its own file.
|
||||
|
||||
This skill does NOT itself write production code. It routes to
|
||||
`compose-solution` (for each sub-task that needs a kit artefact) and to
|
||||
`new-agent` (for each new specialist spawn) and records every fork in the
|
||||
`kei-ledger` SQLite SSoT. Final merge decisions are user clicks.
|
||||
|
||||
---
|
||||
|
||||
## Pipeline overview (4 phases + final report)
|
||||
|
||||
| Phase | File | Purpose | Free-text? | AskUserQuestion |
|
||||
|---|---|---|---|---|
|
||||
| 1 | [phase-1-intake.md](phase-1-intake.md) | Project-shape intake (type / theory / parallelism / main-agent / DB) | 1 line (`GOAL`) | 1× batch of 5 questions |
|
||||
| 2 | [phase-2-fork-skeleton.md](phase-2-fork-skeleton.md) | Create `project/<slug>` branch, ledger entry, theoretical sub-agent spawn | no | 1× (sub-agent kind confirm) |
|
||||
| 3 | [phase-3-parallel-exec.md](phase-3-parallel-exec.md) | Poll `kei-ledger list --status running`, aggregate `progress.json` | no | 1× (continue / pause / add agent) |
|
||||
| 4 | [phase-4-merge-ceremony.md](phase-4-merge-ceremony.md) | Per-branch merge decision — squash / no-ff / reject / defer | no | ≥ 1× per branch (multi-select) |
|
||||
|
||||
Minimum AskUserQuestion count across a full session: **≥ 6** — one batch
|
||||
of five questions in Phase 1, one per spawn confirmation in Phase 2, one
|
||||
polling click in Phase 3, and one per sub-branch in Phase 4 (≥ 2 branches
|
||||
assumed). This is the pure-click contract: only the goal statement is
|
||||
free-text.
|
||||
|
||||
---
|
||||
|
||||
## Variables the pipeline produces
|
||||
|
||||
| Name | Set in | Meaning |
|
||||
|---|---|---|
|
||||
| `GOAL` | Phase 1a | One-line project description |
|
||||
| `PROJECT_TYPE` | Phase 1b | new-code / research / theoretical / hybrid / documentation |
|
||||
| `THEORY_PART` | Phase 1b | none / math-derivation / prior-art / architecture-spec / paradigm-analysis |
|
||||
| `FANOUT` | Phase 1b | single / up-to-3 / up-to-5 / up-to-10 |
|
||||
| `MAIN_AGENT` | Phase 1b | meta-orchestrator / spawn specialist / compose-solution decides |
|
||||
| `DB_MODE` | Phase 1b | file-only / sqlite-ledger / external-tool |
|
||||
| `PROJECT_SLUG` | Phase 2a | kebab-case slug derived from `GOAL` |
|
||||
| `PROJECT_BRANCH` | Phase 2a | `project/<slug>` |
|
||||
| `LEDGER_ID` | Phase 2b | agent id issued via `kei-ledger fork` |
|
||||
| `SUB_AGENTS` | Phase 2c | list of `{id, branch, kind}` spawned |
|
||||
| `PROGRESS` | Phase 3 | per-sub-agent `{status, pct, last_summary}` aggregated |
|
||||
| `MERGE_PLAN` | Phase 4 | per-branch verdict (merge / squash / reject / defer) |
|
||||
|
||||
---
|
||||
|
||||
## Final report (emit after Phase 4)
|
||||
|
||||
```
|
||||
=== NEW-PROJECT REPORT ===
|
||||
Goal: <first 80 chars of GOAL>...
|
||||
Project slug: <PROJECT_SLUG>
|
||||
Project branch: <PROJECT_BRANCH>
|
||||
Ledger root id: <LEDGER_ID>
|
||||
Sub-agents: <N spawned, M done, K failed>
|
||||
Merge verdicts: <a merged / b squashed / c rejected / d deferred>
|
||||
Artefact bundle: <per sub-agent — 6/6 present / missing list>
|
||||
Next action: <push / open PR / rerun failed / review deferred>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rules (apply throughout — enforced at every phase)
|
||||
|
||||
- **Pure-click contract.** Only `GOAL` (Phase 1a) is typed. Every other
|
||||
decision is an `AskUserQuestion` call.
|
||||
- **NO DOWNGRADE (RULE -1).** Any phase that fails returns 2-3 constructive
|
||||
paths, never "can't be done". E.g. Phase 2 branch-conflict → (A) rename
|
||||
slug, (B) force new branch off HEAD, (C) abort — user clicks.
|
||||
- **NO HALLUCINATION (RULE 0.4).** Every sub-agent kind referenced MUST
|
||||
exist in `_manifests/` or be scheduled for creation via `new-agent`
|
||||
before Phase 3 polling.
|
||||
- **Plan Mode First (RULE 0.5).** This skill IS the plan; each phase file
|
||||
has its own verify-criterion. No Edit/Write to production scope before
|
||||
the corresponding phase's confirm click.
|
||||
- **Constructor Pattern (RULE ZERO).** `SKILL.md` < 200 LOC, phase files
|
||||
< 150 LOC each.
|
||||
- **RULE 0.12 compliance.** Every sub-agent spawn MUST:
|
||||
1. Create `project/<slug>/agent-<id>` branch OR worktree
|
||||
2. `kei-ledger fork <id> <branch> --parent <project-branch> --spec-sha <sha>`
|
||||
3. Produce 6-file bundle in `.claude/agents/<id>/`: spec.md, plan.md,
|
||||
progress.json, chatlog.md, handoffs.md, review.md
|
||||
4. `kei-ledger done <id>` OR `kei-ledger fail <id>` on completion
|
||||
5. `kei-ledger validate` before merge ceremony
|
||||
- **Surgical scope.** new-project writes only to: ledger DB, `.claude/agents/<id>/`
|
||||
bundles it orchestrates, and a project manifest file it creates under
|
||||
`_manifests/project-<slug>.toml` if Phase 1b `MAIN_AGENT != "compose-solution decides"`.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [phase-1-intake.md](phase-1-intake.md) · [phase-2-fork-skeleton.md](phase-2-fork-skeleton.md) · [phase-3-parallel-exec.md](phase-3-parallel-exec.md) · [phase-4-merge-ceremony.md](phase-4-merge-ceremony.md)
|
||||
- `skills/compose-solution/SKILL.md` — per-sub-task kit-artefact composer
|
||||
- `skills/new-agent/SKILL.md` — 8-phase specialist manifest wizard
|
||||
- `_primitives/_rust/kei-ledger/` — SQLite ledger CLI (`kei-ledger init / fork / done / fail / list / tree / validate`)
|
||||
- `hooks/agent-fork-logger.sh` — PreToolUse:Agent advisory logger (auto-fork row)
|
||||
- `~/.claude/rules/agent-git-model.md` — RULE 0.12 full text (fork / sub-fork / completion / merge ceremony)
|
||||
92
skills/new-project/phase-1-intake.md
Normal file
92
skills/new-project/phase-1-intake.md
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# Phase 1 — Intake
|
||||
|
||||
One free-text line + one click batch of 5 questions. Only Phase 1a is typed.
|
||||
|
||||
## 1a — Ask for the project goal
|
||||
|
||||
Emit a regular message (NOT AskUserQuestion):
|
||||
|
||||
> Describe the project in one line: what do you want to produce / prove /
|
||||
> ship, at what scope (feature / service / paper / proof)? Reply in one
|
||||
> message. The main agent and sub-agents will read this verbatim.
|
||||
|
||||
Store the reply as `GOAL`. Derive `PROJECT_SLUG = kebab-case(first 4-6
|
||||
meaningful words of GOAL)`, ≤ 40 chars, ASCII only.
|
||||
|
||||
## 1b — Shape click (AskUserQuestion, ONE batch of 5)
|
||||
|
||||
```json
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"question": "Project type?",
|
||||
"header": "Type",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{"label": "new code", "description": "Greenfield implementation — ship a running artefact"},
|
||||
{"label": "research", "description": "Empirical investigation — experiments + results doc"},
|
||||
{"label": "theoretical", "description": "Math / physics / algorithmic derivation — proofs + chatlog"},
|
||||
{"label": "hybrid", "description": "Code + theory in parallel — both tracks merged at end"},
|
||||
{"label": "documentation", "description": "Docs-only project — no new runtime code"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"question": "Theoretical component shape?",
|
||||
"header": "Theory",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{"label": "none", "description": "Pure-implementation project"},
|
||||
{"label": "math derivation", "description": "Lemma → theorem chain, reviewed by physics-deriver"},
|
||||
{"label": "prior-art research", "description": "Literature + existing-project sweep before any write"},
|
||||
{"label": "architecture spec", "description": "Design document + interface contracts before code"},
|
||||
{"label": "paradigm analysis", "description": "Observable classification + falsifier design (RULE 0.6)"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"question": "Parallel sub-agent budget?",
|
||||
"header": "Fanout",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{"label": "single", "description": "Main agent only — sequential work, no sub-forks"},
|
||||
{"label": "up to 3", "description": "Small fleet — typical feature work"},
|
||||
{"label": "up to 5", "description": "Medium fleet — multi-track research or cross-cutting refactor"},
|
||||
{"label": "up to 10", "description": "Wide fanout — parallel experiments / audits / prior-art sweeps"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"question": "Main-agent role?",
|
||||
"header": "Main",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{"label": "meta-orchestrator", "description": "Generic orchestrator that only fans out and merges"},
|
||||
{"label": "spawn specialist", "description": "Create a new dedicated agent via /new-agent before Phase 2"},
|
||||
{"label": "compose-solution decides", "description": "Hand off project shape to /compose-solution for auto-routing"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"question": "DB / ledger mirror?",
|
||||
"header": "DB",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{"label": "file-only", "description": "No SQLite — write bundle files only; skip kei-ledger"},
|
||||
{"label": "SQLite ledger", "description": "Use kei-ledger (default — RULE 0.12 compliant)"},
|
||||
{"label": "external tool", "description": "Mirror to another tracker (Jira / Linear / Forgejo issues)"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Store answers as `PROJECT_TYPE`, `THEORY_PART`, `FANOUT`, `MAIN_AGENT`,
|
||||
`DB_MODE`.
|
||||
|
||||
## Verify-criterion
|
||||
|
||||
- `GOAL` non-empty.
|
||||
- `PROJECT_SLUG` matches `^[a-z0-9][a-z0-9-]{2,39}$`.
|
||||
- All 5 click answers are exactly one of the labels above.
|
||||
- If `MAIN_AGENT == "spawn specialist"` — Phase 2 begins with a handoff to
|
||||
`/new-agent` before any ledger fork.
|
||||
- If `DB_MODE == "file-only"` — Phase 2 skips the `kei-ledger fork` call
|
||||
but STILL writes the 6-file bundle. Report this deviation in the final
|
||||
report so the user sees the ledger SSoT was bypassed by explicit choice.
|
||||
94
skills/new-project/phase-2-fork-skeleton.md
Normal file
94
skills/new-project/phase-2-fork-skeleton.md
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# Phase 2 — Fork Skeleton
|
||||
|
||||
Create the project branch, the root ledger entry, and the first
|
||||
theoretical sub-agent spawn(s) per Phase 1's `THEORY_PART` choice.
|
||||
|
||||
## 2a — Project branch
|
||||
|
||||
Resolve `PROJECT_BRANCH = project/<PROJECT_SLUG>`.
|
||||
|
||||
Run (via Bash tool):
|
||||
|
||||
```sh
|
||||
git fetch origin main
|
||||
git checkout -b "$PROJECT_BRANCH" origin/main
|
||||
```
|
||||
|
||||
Failure modes — emit a NO-DOWNGRADE recovery AskUserQuestion:
|
||||
|
||||
```json
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"question": "Branch creation failed — how to proceed?",
|
||||
"header": "Recovery",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{"label": "rename slug", "description": "Use a suffix (-v2, -alt) and retry"},
|
||||
{"label": "reuse existing branch", "description": "Check out the existing branch and append this work"},
|
||||
{"label": "abort project", "description": "Stop before touching the ledger"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 2b — Ledger root entry
|
||||
|
||||
Skip when `DB_MODE == "file-only"`. Otherwise:
|
||||
|
||||
```sh
|
||||
spec_sha=$(printf '%s' "$GOAL" | shasum -a 256 | cut -c1-16)
|
||||
LEDGER_ID="project-${PROJECT_SLUG}-$(date +%s)"
|
||||
kei-ledger init
|
||||
kei-ledger fork "$LEDGER_ID" "$PROJECT_BRANCH" \
|
||||
--parent main \
|
||||
--spec-sha "$spec_sha"
|
||||
```
|
||||
|
||||
Also write the root bundle under
|
||||
`.claude/agents/$LEDGER_ID/{spec.md, plan.md, progress.json, chatlog.md, handoffs.md, review.md}`.
|
||||
`spec.md` = `GOAL` + 5 Phase-1 answers; others start empty / scaffolded.
|
||||
|
||||
## 2c — Theoretical sub-agent spawn (AskUserQuestion, ONE)
|
||||
|
||||
Branch on `THEORY_PART`. Emit this confirmation click:
|
||||
|
||||
```json
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"question": "Confirm theoretical sub-agent fan-out (derived from Phase 1)?",
|
||||
"header": "Spawn",
|
||||
"multiSelect": true,
|
||||
"options": [
|
||||
{"label": "physics-deriver", "description": "Math derivation agent (only if THEORY_PART = math derivation)"},
|
||||
{"label": "research sweep", "description": "Prior-art research sub-agent (only if THEORY_PART = prior-art research)"},
|
||||
{"label": "architect", "description": "Architecture spec agent (only if THEORY_PART = architecture spec)"},
|
||||
{"label": "paradigm-classifier", "description": "Observable classification per RULE 0.6 (only if THEORY_PART = paradigm analysis)"},
|
||||
{"label": "skip theory", "description": "No theoretical sub-agent — straight to implementation fan-out"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
For each selected label (except `skip theory`):
|
||||
|
||||
1. Derive `agent_id = <kind>-<ts>`, `agent_branch = project/$PROJECT_SLUG/agent-$agent_id`
|
||||
2. `git worktree add .claude/worktrees/$agent_id -b $agent_branch` (when fanout > single)
|
||||
3. `kei-ledger fork "$agent_id" "$agent_branch" --parent "$PROJECT_BRANCH" --spec-sha "$spec_sha"` (skip if file-only)
|
||||
4. Invoke the Agent tool with the matching `subagent_type` and
|
||||
`isolation: "worktree"` (the `agent-fork-logger.sh` hook will emit a
|
||||
second fork row — OK, ledger de-duplicates by primary key and the hook
|
||||
attempt returns nonzero silently)
|
||||
5. Append `{id, branch, kind}` to `SUB_AGENTS`
|
||||
|
||||
## Verify-criterion
|
||||
|
||||
- `git branch --show-current` returns `$PROJECT_BRANCH`.
|
||||
- `kei-ledger list --status running` returns ≥ 1 row whose id == `LEDGER_ID`
|
||||
(unless `DB_MODE == "file-only"`).
|
||||
- Every entry in `SUB_AGENTS` has a corresponding `kei-ledger list` row
|
||||
with `parent_branch == $PROJECT_BRANCH`.
|
||||
- If any spawn failed: emit NO-DOWNGRADE recovery click (retry / skip / abort).
|
||||
80
skills/new-project/phase-3-parallel-exec.md
Normal file
80
skills/new-project/phase-3-parallel-exec.md
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
# Phase 3 — Parallel Execution
|
||||
|
||||
Poll the ledger, aggregate progress, let the user steer the fleet mid-run.
|
||||
|
||||
## 3a — Aggregate current state
|
||||
|
||||
Every ~60s (or whenever the user asks "status"):
|
||||
|
||||
```sh
|
||||
kei-ledger list --status running
|
||||
kei-ledger list --status done
|
||||
kei-ledger list --status failed
|
||||
```
|
||||
|
||||
For each row, attempt to read
|
||||
`.claude/agents/<id>/progress.json` (written by the child every ≥ 30s per
|
||||
`feedback_agent_observability`). Shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"pct": 0..100,
|
||||
"last_step": "short free text",
|
||||
"wall_time_s": int,
|
||||
"last_updated": iso8601
|
||||
}
|
||||
```
|
||||
|
||||
Aggregate into `PROGRESS = {id: {status, pct, last_summary, stale_s}}`.
|
||||
`stale_s = now - last_updated`. Flag `stale_s > 300` as "possibly hung".
|
||||
|
||||
Render one-line-per-agent table:
|
||||
|
||||
```
|
||||
<id> <status> <pct>% <last_step> stale=<s>s
|
||||
```
|
||||
|
||||
## 3b — Steering click (AskUserQuestion, ONE)
|
||||
|
||||
After displaying the table:
|
||||
|
||||
```json
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"question": "Fleet state — how to proceed?",
|
||||
"header": "Steer",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{"label": "continue polling", "description": "Wait and re-poll — default if > 0 agents are running"},
|
||||
{"label": "add sub-agent", "description": "Spawn another sub-agent — returns to Phase 2c"},
|
||||
{"label": "kill stale", "description": "Call kei-ledger fail on agents with stale_s > 300"},
|
||||
{"label": "proceed to merge", "description": "All required agents done — jump to Phase 4"},
|
||||
{"label": "pause and review", "description": "Stop polling — user reviews manually, re-enter later"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Route:
|
||||
- `continue polling` → re-run 3a after a user-initiated "status" request
|
||||
(do NOT busy-loop on a timer — let the user drive polling cadence)
|
||||
- `add sub-agent` → jump to Phase 2c, add new row, return to Phase 3
|
||||
- `kill stale` → for each `stale_s > 300` running row: `kei-ledger fail <id> --reason "stale > 300s, killed by orchestrator"`
|
||||
- `proceed to merge` → Phase 4 (verify no `running` rows remain first)
|
||||
- `pause and review` → emit final report with current state, user re-enters
|
||||
the skill later (ledger survives)
|
||||
|
||||
## Verify-criterion
|
||||
|
||||
- Ledger-polling code actually called `kei-ledger list` at least once per
|
||||
Phase-3 entry (unless `DB_MODE == "file-only"` — in which case the
|
||||
orchestrator reads each `progress.json` directly).
|
||||
- Every running sub-agent either has a fresh `progress.json`
|
||||
(`stale_s < 300`) or has been flagged as "possibly hung" in the
|
||||
displayed table.
|
||||
- If `proceed to merge` is clicked while `kei-ledger list --status running`
|
||||
is non-empty — block the transition and re-ask with the list shown.
|
||||
- NO-DOWNGRADE: if every child has failed, DO NOT close the project silently.
|
||||
Emit a 3-path recovery click (retry failed / re-fanout / abort with audit).
|
||||
108
skills/new-project/phase-4-merge-ceremony.md
Normal file
108
skills/new-project/phase-4-merge-ceremony.md
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
# Phase 4 — Merge Ceremony
|
||||
|
||||
Per-branch verdict, ledger bookkeeping, integration into the project
|
||||
branch. One AskUserQuestion **per sub-agent branch** (therefore the
|
||||
skill's AskUserQuestion floor rises with fanout).
|
||||
|
||||
## 4a — Validate bundles first
|
||||
|
||||
For every `SUB_AGENTS[i]` where status ∈ {done, failed}:
|
||||
|
||||
```sh
|
||||
kei-ledger validate "$agent_branch"
|
||||
```
|
||||
|
||||
This verifies all 6 required artefacts exist under
|
||||
`.claude/agents/<id>/`: spec.md, plan.md, progress.json, chatlog.md,
|
||||
handoffs.md, review.md.
|
||||
|
||||
If any agent is MISSING artefacts — mark its verdict as "reject-bundle"
|
||||
and skip the per-branch click below. The final report lists them.
|
||||
|
||||
## 4b — Per-branch merge click (AskUserQuestion, ONE per branch)
|
||||
|
||||
For each sub-agent whose bundle is complete, emit:
|
||||
|
||||
```json
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"question": "Merge verdict for <agent_id> on <agent_branch> (status=<s>, summary=<first 60 chars>)?",
|
||||
"header": "Verdict",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{"label": "merge --no-ff", "description": "Preserve the sub-branch history — default for substantive work"},
|
||||
{"label": "squash", "description": "Collapse into one commit on project branch — for small / fixup work"},
|
||||
{"label": "reject", "description": "Do not merge — kei-ledger rejected; branch stays for audit"},
|
||||
{"label": "defer", "description": "Leave for later — no merge, no rejection; re-enter skill next session"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Execute per click:
|
||||
|
||||
- `merge --no-ff` →
|
||||
```sh
|
||||
git checkout "$PROJECT_BRANCH"
|
||||
git merge --no-ff "$agent_branch" -m "merge($agent_id): $summary"
|
||||
kei-ledger merged "$agent_id"
|
||||
```
|
||||
- `squash` →
|
||||
```sh
|
||||
git checkout "$PROJECT_BRANCH"
|
||||
git merge --squash "$agent_branch"
|
||||
git commit -m "feat($agent_id): $summary (squashed)"
|
||||
kei-ledger merged "$agent_id"
|
||||
```
|
||||
- `reject` →
|
||||
```sh
|
||||
kei-ledger fail "$agent_id" --reason "rejected at merge ceremony"
|
||||
# Update status table — ledger has no explicit 'rejected' from 'done',
|
||||
# so we log rejection via a `fail` with reason; the row stays as audit
|
||||
# evidence. (If the agent was already in 'failed' — leave as is.)
|
||||
```
|
||||
- `defer` → no git action, no ledger state change; record in `MERGE_PLAN`
|
||||
as deferred so the final report reminds the user.
|
||||
|
||||
## 4c — Final integration checkpoint
|
||||
|
||||
After every sub-branch has a verdict:
|
||||
|
||||
```sh
|
||||
git checkout "$PROJECT_BRANCH"
|
||||
git log --oneline -20
|
||||
kei-ledger tree "$LEDGER_ID"
|
||||
```
|
||||
|
||||
Emit a final NO-DOWNGRADE click if any sub-branch was rejected or
|
||||
deferred — never silently close the project:
|
||||
|
||||
```json
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"question": "Project state has <N> rejected + <M> deferred branches. Next step?",
|
||||
"header": "Close",
|
||||
"multiSelect": false,
|
||||
"options": [
|
||||
{"label": "open PR as-is", "description": "Push project branch, open PR — rejected work is audit-logged only"},
|
||||
{"label": "retry rejected", "description": "Return to Phase 2c with the rejected sub-agents as fresh spawns"},
|
||||
{"label": "close and re-enter later", "description": "Leave project branch local; re-enter skill next session"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Verify-criterion
|
||||
|
||||
- `kei-ledger list --status running` returns zero rows whose
|
||||
`parent_branch == $PROJECT_BRANCH`.
|
||||
- Every `SUB_AGENTS[i]` has exactly one entry in `MERGE_PLAN` — one of
|
||||
`merge`, `squash`, `reject`, `defer`, `reject-bundle`.
|
||||
- For every `merge` / `squash` verdict, `kei-ledger list --status merged`
|
||||
contains a matching row.
|
||||
- Final report cites each verdict explicitly and does NOT gloss over
|
||||
rejected / deferred branches.
|
||||
Loading…
Reference in a new issue