Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 25 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<cmd>"` 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 <done-means>`** — loop until met. **`/loop <same> --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
Expand Down Expand Up @@ -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 <goal>` | Headless verify-gated loop over a checkbox goal file. |
| `knitbrain loop <goal>` | Headless verify-gated loop over a checkbox goal file; `--reviewer` adds an independent second gate. |
| `knitbrain fan <goal>` | 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 <agent>` | Launch an agent through the optimizer proxy (API-key setups). |
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ usage: knitbrain <command>
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 <f> terse-rewrite a memory file (CLAUDE.md) to cut input tokens; keeps <f>.original
loop <goal> autonomous loop: drive an agent through a checkbox goal file until done (--max, --verify, --interactive)
loop <goal> autonomous loop: drive an agent through a checkbox goal file until done (--max, --verify, --reviewer, --for, --interactive)
fan <goal> 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)
Expand Down
35 changes: 33 additions & 2 deletions src/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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:",
Expand Down Expand Up @@ -100,12 +119,16 @@ function run(cmd: string, input?: string): boolean {
export async function runLoop(args: string[]): Promise<number> {
const o = parseArgs(args);
if (!o.goalFile || !existsSync(o.goalFile)) {
console.error("usage: knitbrain loop <goalfile.md> [--max N=10] [--agent \"cmd\"] [--verify \"cmd\"] [--interactive]");
console.error("usage: knitbrain loop <goalfile.md> [--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: <cmd>` line; else `npm test` if package.json exists.");
console.error(" reviewer gate (writer≠judge): --reviewer wins; else the goalfile's `REVIEWER: <cmd>` 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;
Expand Down Expand Up @@ -154,6 +177,14 @@ export async function runLoop(args: string[]): Promise<number> {
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
Expand Down
59 changes: 58 additions & 1 deletion tests/loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""`;
Expand Down Expand Up @@ -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");
});
});
Loading