diff --git a/_primitives/_rust/Cargo.toml b/_primitives/_rust/Cargo.toml new file mode 100644 index 0000000..5b72ffe --- /dev/null +++ b/_primitives/_rust/Cargo.toml @@ -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 diff --git a/_primitives/_rust/kei-ledger/Cargo.toml b/_primitives/_rust/kei-ledger/Cargo.toml new file mode 100644 index 0000000..c304a5c --- /dev/null +++ b/_primitives/_rust/kei-ledger/Cargo.toml @@ -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" diff --git a/_primitives/_rust/kei-ledger/src/ledger.rs b/_primitives/_rust/kei-ledger/src/ledger.rs new file mode 100644 index 0000000..c9a7593 --- /dev/null +++ b/_primitives/_rust/kei-ledger/src/ledger.rs @@ -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, + pub spec_sha: String, + pub status: String, + pub started_ts: i64, + pub finished_ts: Option, + pub summary: Option, + pub worktree_path: Option, +} + +/// Open or create the ledger file and run migrations. +pub fn open(path: &Path) -> SqlResult { + 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 { + 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 { + 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 { + 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> { + let (sql, bound): (&str, Vec) = 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::>>()?; + Ok(rows) +} + +fn row_to_agent(r: &rusqlite::Row) -> SqlResult { + 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> { + 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> { + 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::>>()?; + for k in kids { + frontier.push(k.branch.clone()); + out.push(k); + } + } + Ok(out) +} + +/// Verify all 6 required artefacts exist under `.claude/agents//` +/// rooted at `repo_root`. Returns list of missing artefacts (empty = OK). +pub fn validate(repo_root: &Path, agent_id: &str) -> Vec { + 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() +} diff --git a/_primitives/_rust/kei-ledger/src/main.rs b/_primitives/_rust/kei-ledger/src/main.rs new file mode 100644 index 0000000..4d8b02d --- /dev/null +++ b/_primitives/_rust/kei-ledger/src/main.rs @@ -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, + #[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, + #[arg(long)] + spec_sha: String, + #[arg(long)] + worktree: Option, + }, + /// 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, + }, + /// 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 { + 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/- OR inline- + // 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), + } +} diff --git a/_primitives/_rust/kei-ledger/src/schema.rs b/_primitives/_rust/kei-ledger/src/schema.rs new file mode 100644 index 0000000..5e3c1eb --- /dev/null +++ b/_primitives/_rust/kei-ledger/src/schema.rs @@ -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", +]; diff --git a/_primitives/_rust/kei-ledger/tests/integration.rs b/_primitives/_rust/kei-ledger/tests/integration.rs new file mode 100644 index 0000000..b60b582 --- /dev/null +++ b/_primitives/_rust/kei-ledger/tests/integration.rs @@ -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")); +} diff --git a/hooks/agent-fork-logger.sh b/hooks/agent-fork-logger.sh new file mode 100755 index 0000000..f45807f --- /dev/null +++ b/hooks/agent-fork-logger.sh @@ -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 diff --git a/skills/new-project/SKILL.md b/skills/new-project/SKILL.md new file mode 100644 index 0000000..7668097 --- /dev/null +++ b/skills/new-project/SKILL.md @@ -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: +--- + +# 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/` 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/` | +| `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: ... +Project slug: +Project branch: +Ledger root id: +Sub-agents: +Merge verdicts: +Artefact bundle: +Next action: +``` + +--- + +## 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//agent-` branch OR worktree + 2. `kei-ledger fork --parent --spec-sha ` + 3. Produce 6-file bundle in `.claude/agents//`: spec.md, plan.md, + progress.json, chatlog.md, handoffs.md, review.md + 4. `kei-ledger done ` OR `kei-ledger fail ` on completion + 5. `kei-ledger validate` before merge ceremony +- **Surgical scope.** new-project writes only to: ledger DB, `.claude/agents//` + bundles it orchestrates, and a project manifest file it creates under + `_manifests/project-.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) diff --git a/skills/new-project/phase-1-intake.md b/skills/new-project/phase-1-intake.md new file mode 100644 index 0000000..1c92d8a --- /dev/null +++ b/skills/new-project/phase-1-intake.md @@ -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. diff --git a/skills/new-project/phase-2-fork-skeleton.md b/skills/new-project/phase-2-fork-skeleton.md new file mode 100644 index 0000000..9f4a0ad --- /dev/null +++ b/skills/new-project/phase-2-fork-skeleton.md @@ -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/`. + +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 = -`, `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). diff --git a/skills/new-project/phase-3-parallel-exec.md b/skills/new-project/phase-3-parallel-exec.md new file mode 100644 index 0000000..209cc2a --- /dev/null +++ b/skills/new-project/phase-3-parallel-exec.md @@ -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//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: + +``` + % stale=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 --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). diff --git a/skills/new-project/phase-4-merge-ceremony.md b/skills/new-project/phase-4-merge-ceremony.md new file mode 100644 index 0000000..5c1e00a --- /dev/null +++ b/skills/new-project/phase-4-merge-ceremony.md @@ -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//`: 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 on (status=, summary=)?", + "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 rejected + 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.