diff --git a/README.md b/README.md index a8f717f..e35a607 100644 --- a/README.md +++ b/README.md @@ -96,22 +96,41 @@ honestly, not gaps we hide. ## Loops -One engine, three ways in: - +One engine, three ways in — the headless loop is the front door: + +- **Headless (the front door)**: `knitbrain loop goal.md` drives a checkbox goal file outside any + editor — survives laptop-close, ticks a box only when the verify command exits 0, and never + commits/pushes/deploys. Point any scheduler at it (see Triggers below). Add an independent + reviewer with `--reviewer ""` or a `REVIEWER:` line in the goal file — **writer≠judge**: + both verify AND reviewer must exit 0 before a box ticks, and reviewer rejections feed the next + attempt's prompt. `knitbrain fan` runs N workers in parallel, each in its own git worktree, + draining the same queue. - **Ambient** (after onboarding): say what you're working on; the injected frame classifies it — actionable requests become goals driven through `knitbrain_run_loop` until the verify gate passes; questions get answered directly. - **`/goal `** — loop until met. **`/loop --for 2h --iters N`** — same engine plus a wall-clock and iteration budget. Every iteration runs as a full goal cycle. -- **Headless**: `knitbrain loop goal.md` drives a checkbox goal file outside any editor — survives - laptop-close, ticks boxes only on green verify. `knitbrain fan` runs N workers in parallel, each - in its own git worktree, draining the same queue. **Self-healing:** each failed cycle's verify output is persisted (`failures[]`, last 3) and injected into the next directive — "previous failures — iter 1: …. Address the ROOT CAUSE" — so loops converge in fewer iterations without shortcuts. An adherence gate blocks memory writes until a task was classified: unverified "done" cannot enter the brain. +### Triggers + +knitbrain is the *target* of triggers, never the scheduler — your host (cron, launchd, CI, +Claude Code `/schedule`, Codex schedules) owns when; the loop owns honest-until-done. Exit codes +are scheduler-friendly: **0** = goal done or clean stop, **1** = gate still red or infra failure — +alert on 1. + +```cron +# weekdays 9am: drive the goal for up to 2h, lint as the independent reviewer +0 9 * * 1-5 cd /path/to/repo && knitbrain loop goal.md --for 2h --reviewer "npm run lint" >> ~/.knitbrain/loop.log 2>&1 +``` + +Same one-liner works as a launchd `ProgramArguments`, a CI cron job step, or the command behind +your agent's scheduler. + ## The receipt Optimization you can't see is optimization you don't trust. When a session ends, the Stop hook @@ -191,7 +210,7 @@ rewrite output), knitbrain degrades to the nearest honest mechanism instead of c | `knitbrain onboard` | Scan the repo + import past sessions into the brain; start the charter interview. | | `knitbrain profile` | Measure compression on your real transcripts. | | `knitbrain evals` | Answer-preservation gates on your transcripts (exit 1 on failure). | -| `knitbrain loop ` | Headless verify-gated loop over a checkbox goal file. | +| `knitbrain loop ` | Headless verify-gated loop over a checkbox goal file; `--reviewer` adds an independent second gate. | | `knitbrain fan ` | Parallel loop — N workers in isolated git worktrees. | | `knitbrain dashboard` | Live local dashboard (`127.0.0.1:8790`): meter, graph, wiki, activity, plan usage. | | `knitbrain wrap ` | Launch an agent through the optimizer proxy (API-key setups). | diff --git a/src/index.ts b/src/index.ts index 6f54f36..d00c0ab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -80,7 +80,7 @@ usage: knitbrain onboard import this project's past sessions + scan the repo into the brain (run once after setup) learn mine past sessions for failure→success corrections (--apply writes CLAUDE.md) compress terse-rewrite a memory file (CLAUDE.md) to cut input tokens; keeps .original - loop autonomous loop: drive an agent through a checkbox goal file until done (--max, --verify, --interactive) + loop autonomous loop: drive an agent through a checkbox goal file until done (--max, --verify, --reviewer, --for, --interactive) fan PARALLEL loop: N workers drain a checkbox queue concurrently, isolated in git worktrees (--workers, --verify, --max) dashboard live local dashboard (127.0.0.1:8790) prompt print the full operating prompt (for platforms without MCP instructions) diff --git a/src/loop.ts b/src/loop.ts index 7060eb5..e1abe0d 100644 --- a/src/loop.ts +++ b/src/loop.ts @@ -23,6 +23,8 @@ interface LoopOpts { max: number; agent: string; verify: string | null; + /** Build 3: optional independent review gate (writer≠judge). Opt-in only. */ + reviewer: string | null; interactive: boolean; /** M9: optional wall-clock budget in ms (from --for 30m|1h|90s). */ forMs?: number; @@ -39,12 +41,13 @@ export function parseDuration(s: string): number | null { } function parseArgs(args: string[]): LoopOpts { - const o: LoopOpts = { max: 10, agent: "claude -p", verify: null, interactive: false }; + const o: LoopOpts = { max: 10, agent: "claude -p", verify: null, reviewer: null, interactive: false }; for (let i = 0; i < args.length; i += 1) { const a = args[i]; if (a === "--max") o.max = Math.max(1, Number(args[(i += 1)]) || 10); else if (a === "--agent") o.agent = args[(i += 1)] ?? o.agent; else if (a === "--verify") o.verify = args[(i += 1)] ?? null; + else if (a === "--reviewer") o.reviewer = args[(i += 1)] ?? null; else if (a === "--for") { const d = parseDuration(args[(i += 1)] ?? ""); if (d !== null) o.forMs = d; @@ -70,6 +73,22 @@ export function goalVerify(goalFile: string): string { } } +/** + * Optional independent review gate from the goal file's `REVIEWER:` line + * (writer≠judge: a second command that must ALSO exit 0 before a box ticks). + * OPT-IN only — no fallback default, so an unconfigured loop is unchanged. + * Precedence: explicit --reviewer > goal.md REVIEWER: > none. + */ +export function goalReviewer(goalFile: string): string { + try { + const m = /^REVIEWER:\s*(.+)$/im.exec(readFileSync(goalFile, "utf8")); + const v = m ? m[1]!.trim() : ""; + return v && v !== "(unspecified)" ? v : ""; + } catch { + return ""; + } +} + function buildPrompt(task: string, progress: string, lastFail: string): string { return [ "You are ONE iteration of an autonomous build loop. Do EXACTLY this one task, then stop:", @@ -100,12 +119,16 @@ function run(cmd: string, input?: string): boolean { export async function runLoop(args: string[]): Promise { const o = parseArgs(args); if (!o.goalFile || !existsSync(o.goalFile)) { - console.error("usage: knitbrain loop [--max N=10] [--agent \"cmd\"] [--verify \"cmd\"] [--interactive]"); + console.error("usage: knitbrain loop [--max N=10] [--agent \"cmd\"] [--verify \"cmd\"] [--reviewer \"cmd\"] [--for 2h] [--interactive]"); console.error(" goalfile: markdown with `- [ ] task` checkboxes; the loop ticks them as it goes."); console.error(" verify gate: --verify wins; else the goalfile's `VERIFY: ` line; else `npm test` if package.json exists."); + console.error(" reviewer gate (writer≠judge): --reviewer wins; else the goalfile's `REVIEWER: ` line; else none."); + console.error(" verify = does it work; reviewer = independent second gate. BOTH must exit 0 before a box ticks."); + console.error(" triggers: point cron/launchd/CI/your agent's scheduler at this command — exit 0 = done or clean stop, 1 = gate red/infra."); return 1; } const verify = o.verify ?? (goalVerify(o.goalFile) || (existsSync("package.json") ? "npm test" : "")); + const reviewer = o.reviewer ?? (goalReviewer(o.goalFile) || ""); const progressFile = `${o.goalFile}.progress`; const rl = o.interactive ? createInterface({ input: process.stdin, output: process.stdout }) : null; let done = 0; @@ -154,6 +177,14 @@ export async function runLoop(args: string[]): Promise { console.error(`[loop] "${task}" not green yet — retrying next cycle (${iter}/${o.max})`); continue; } + // Reviewer gate (writer≠judge): verify said it WORKS; an independent + // second command must also approve before the box ticks. Rejections feed + // the next cycle's prompt exactly like verify failures — self-heal free. + if (reviewer && !run(reviewer)) { + lastFail = `reviewer rejected \`${reviewer}\` — verify was green but the independent review gate failed`; + console.error(`[loop] "${task}" reviewer rejected (writer≠judge: box NOT ticked) — retrying next cycle (${iter}/${o.max})`); + continue; + } lastFail = ""; // Re-read before marking: the agent may have edited the goal file, and diff --git a/tests/loop.test.ts b/tests/loop.test.ts index 91c1e4f..fe9cf06 100644 --- a/tests/loop.test.ts +++ b/tests/loop.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { runLoop, goalVerify } from "../src/loop.js"; +import { runLoop, goalVerify, goalReviewer } from "../src/loop.js"; // Cross-platform mock commands (CI runs a Windows matrix — avoid sh builtins). const OK = `node -e ""`; @@ -95,3 +95,60 @@ describe("runLoop — autonomous outer loop", () => { expect(readFileSync(goal, "utf8")).toContain("- [ ] impossible"); // never ticked }); }); + +describe("reviewer gate (Build 3) — writer≠judge", () => { + let dir: string; + let goal: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "kb-loop-rev-")); + goal = join(dir, "goal.md"); + }); + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + it("reviewer red + verify green → box NOT ticked, exit 1 at --max (no false green)", async () => { + writeFileSync(goal, "# g\n- [ ] risky\n"); + const code = await runLoop([goal, "--agent", OK, "--verify", OK, "--reviewer", FAIL, "--max", "2"]); + expect(code).toBe(1); + expect(readFileSync(goal, "utf8")).toContain("- [ ] risky"); + }); + + it("reviewer green + verify green → ticked, exit 0", async () => { + writeFileSync(goal, "# g\n- [ ] safe\n"); + const code = await runLoop([goal, "--agent", OK, "--verify", OK, "--reviewer", OK, "--max", "2"]); + expect(code).toBe(0); + expect(readFileSync(goal, "utf8")).toContain("- [x] safe"); + }); + + it("goalReviewer reads the REVIEWER: line, ignoring (unspecified) and absence", () => { + writeFileSync(goal, "# g\nREVIEWER: npm run lint\n\n- [ ] a\n"); + expect(goalReviewer(goal)).toBe("npm run lint"); + writeFileSync(goal, "# g\nREVIEWER: (unspecified)\n- [ ] a\n"); + expect(goalReviewer(goal)).toBe(""); + writeFileSync(goal, "# g\n- [ ] a\n"); + expect(goalReviewer(goal)).toBe(""); // unconfigured → no reviewer gate at all + }); + + it("uses the goal.md REVIEWER: gate when --reviewer is omitted", async () => { + writeFileSync(goal, `# g\nREVIEWER: ${FAIL}\n\n- [ ] risky\n`); + const code = await runLoop([goal, "--agent", OK, "--verify", OK, "--max", "2"]); + expect(code).toBe(1); + expect(readFileSync(goal, "utf8")).toContain("- [ ] risky"); + }); + + it("--reviewer overrides the goal.md REVIEWER: line", async () => { + writeFileSync(goal, `# g\nREVIEWER: ${FAIL}\n\n- [ ] ok\n`); + const code = await runLoop([goal, "--agent", OK, "--verify", OK, "--reviewer", OK, "--max", "1"]); + expect(code).toBe(0); + expect(readFileSync(goal, "utf8")).toContain("- [x] ok"); + }); + + it("reviewer that rejects once then approves → ticked on a later cycle (self-heal retry)", async () => { + const counter = join(dir, "rev-count"); + writeFileSync(goal, "# g\n- [ ] flaky-review\n"); + // reviewer fails on first invocation, passes afterwards (counter file) + const reviewer = `node -e "const f='${counter.replace(/\\/g, "/")}';const fs=require('fs');if(!fs.existsSync(f)){fs.writeFileSync(f,'1');process.exit(1)}"`; + const code = await runLoop([goal, "--agent", OK, "--verify", OK, "--reviewer", reviewer, "--max", "3"]); + expect(code).toBe(0); + expect(readFileSync(goal, "utf8")).toContain("- [x] flaky-review"); + }); +});