planrails 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +225 -0
  4. package/bin/planrails.mjs +7 -0
  5. package/docs/PLANNING_GUIDE.md +632 -0
  6. package/package.json +51 -0
  7. package/src/hooks/_lib.mjs +32 -0
  8. package/src/hooks/guard-never-delete.sh +29 -0
  9. package/src/hooks/install.mjs +173 -0
  10. package/src/hooks/plan-pre-tool.mjs +71 -0
  11. package/src/hooks/plan-session-start.mjs +46 -0
  12. package/src/hooks/plan-stop.mjs +60 -0
  13. package/src/hooks/plan-subagent-start.mjs +27 -0
  14. package/src/hooks/postcompact-journal.mjs +35 -0
  15. package/src/hooks/precompact-journal.mjs +128 -0
  16. package/src/hooks/selftest.mjs +128 -0
  17. package/src/init.mjs +155 -0
  18. package/src/issue.mjs +55 -0
  19. package/src/plan/fixtures/README.md +7 -0
  20. package/src/plan/fixtures/broken-cli.mjs +27 -0
  21. package/src/plan/fixtures/broken-hooks-root/.claude/settings.json +83 -0
  22. package/src/plan/fixtures/broken-hooks-root/.project-management/plans/.gitkeep +0 -0
  23. package/src/plan/fixtures/broken-hooks-root/CLAUDE.md +9 -0
  24. package/src/plan/fixtures/broken-root/.project-management/plans/broken/PLAN.md +4 -0
  25. package/src/plan/fixtures/broken-root/.project-management/plans/broken/gates.json +1 -0
  26. package/src/plan/fixtures/broken-root/.project-management/plans/broken/rules.json +1 -0
  27. package/src/plan/fixtures/broken-root/.project-management/plans/broken/state.json +67 -0
  28. package/src/plan/fixtures/broken-root/CLAUDE.md +3 -0
  29. package/src/plan/fixtures/broken-trial.mjs +25 -0
  30. package/src/plan/lib/brief.mjs +116 -0
  31. package/src/plan/lib/claude-md.mjs +66 -0
  32. package/src/plan/lib/glob.mjs +81 -0
  33. package/src/plan/lib/judgment.mjs +19 -0
  34. package/src/plan/lib/paths.mjs +65 -0
  35. package/src/plan/lib/schema.mjs +199 -0
  36. package/src/plan/lib/store.mjs +338 -0
  37. package/src/plan/lib/time.mjs +21 -0
  38. package/src/plan/plan.mjs +843 -0
  39. package/src/plan/run.mjs +88 -0
  40. package/src/plan/skill/SKILL.md +15 -0
@@ -0,0 +1,843 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ════════════════════════════════════════════════════════════════════════════
4
+ * THE PLAN CLI — the one writer of a plan's state
5
+ * ════════════════════════════════════════════════════════════════════════════
6
+ *
7
+ * A plan is a directory under .project-management/plans/<id>/ (see
8
+ * docs/PLANNING_GUIDE.md). This tool creates it, records progress into it, runs
9
+ * its gates, and keeps the pointer block in CLAUDE.md in step with it.
10
+ *
11
+ * Why a CLI and not hand edits: every write here is schema-checked, and a task
12
+ * reaches `done` only through `task done`, which RUNS the gate and records the
13
+ * run. A hand edit can still be made — and `validate` (inside `npm run check`)
14
+ * refuses a done task that no recorded run backs. The convenience is the CLI;
15
+ * the rail is the validator.
16
+ *
17
+ * npx planrails init [--with-never-delete] [--no-install] [--force] [--dry-run] # set a project up (new or existing)
18
+ * npx planrails update | uninstall | version | issue [bug|wish|edge] [--title "…"] [--print|--gh]
19
+ * npx planrails new <id> --title "…" [--paths "a/**,b/**"]
20
+ * npx planrails list | brief [id] | status <id> | validate [--all] | doctor
21
+ * npx planrails task add|check|start|done|block|unblock|drop <id> [<T>] …
22
+ * task add … [--done-when "a task-specific condition"] task done … --answer "C4: …" … --answer "C7: …" (one per manual condition; task check lists them)
23
+ * npx planrails condition list|add|drop <id> [--statement "…"] [<C>] # plan-level conditions of done
24
+ * npx planrails review <id> [<T>] # every done task: evidence + each condition with its answer
25
+ * npx planrails log <id> --task T --what "…" --next "…" [--refs a,b] [--uncommitted "…"]
26
+ * npx planrails learn <id> --what "…" --rule "…" --when "…" [--lead] [--enforcement prose|rule|gate|docs --ref X]
27
+ * npx planrails decide <id> --what "…" --why "…" [--rejected "opt: why; opt: why"] [--by owner|agent]
28
+ * npx planrails gate list|run|verify|add <id> [<G>|--all] …
29
+ * npx planrails activate|pause|close|abandon <id> [--confirmed-by-owner]
30
+ * npx planrails agent-brief <id> <T> [--what "the unit"] [--label x] # what a subagent gets instead of the plan
31
+ * npx planrails run <id> [--max-tasks 1] [--model sonnet] [--max-turns 60] [--dry-run] # one FRESH session per task
32
+ * npx planrails learnings --search <term> [--plan id]
33
+ * npx planrails hooks install [--with-never-delete] | uninstall | status | selftest
34
+ * npx planrails selftest # prove the validator can fail (runs inside npm run check)
35
+ */
36
+ import { parseArgs } from "node:util";
37
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, mkdtempSync } from "node:fs";
38
+ import { join } from "node:path";
39
+ import { tmpdir } from "node:os";
40
+ import { spawnSync } from "node:child_process";
41
+ import { randomBytes } from "node:crypto";
42
+ import { z } from "zod";
43
+ import { FILES, planDir, plansRoot, projectRoot, claudeMdPath, hookStateRoot, claudeSettingsPath, cliName, packageRoot } from "./lib/paths.mjs";
44
+ import { SCHEMAS, DEFAULT_DONE_WHEN } from "./lib/schema.mjs";
45
+ import {
46
+ loadPlan, listPlanIds, activePlanIds, validatePlan, validatePlanId, withLock, writeJsonAtomic, appendJsonl,
47
+ readJson, nextId, lastRunFor, verifiedFor, taskCounts, evaluateAuto, verifyStatus,
48
+ } from "./lib/store.mjs";
49
+ import { renderBrief, renderAgentBrief, sectionOf } from "./lib/brief.mjs";
50
+ import { JUDGMENT } from "./lib/judgment.mjs";
51
+ import { writeBlock, idsInBlock, blockCount, readBlock } from "./lib/claude-md.mjs";
52
+ import { nowIso, today, shortStamp, hoursSince } from "./lib/time.mjs";
53
+ import { pathMatches, toRepoRelative, ruleMatches } from "./lib/glob.mjs";
54
+
55
+ const CLI = cliName();
56
+
57
+ const { values: opt, positionals: pos } = parseArgs({
58
+ allowPositionals: true,
59
+ options: {
60
+ title: { type: "string" }, paths: { type: "string" }, all: { type: "boolean" }, quiet: { type: "boolean" },
61
+ task: { type: "string" }, what: { type: "string" }, next: { type: "string" }, refs: { type: "string" },
62
+ uncommitted: { type: "string" }, rule: { type: "string" }, when: { type: "string" }, lead: { type: "boolean" },
63
+ enforcement: { type: "string" }, ref: { type: "string" }, why: { type: "string" }, rejected: { type: "string" },
64
+ by: { type: "string" }, supersedes: { type: "string" }, gate: { type: "string" }, manual: { type: "string" },
65
+ files: { type: "string" }, effort: { type: "string" }, after: { type: "string" }, notes: { type: "string" },
66
+ reason: { type: "string" }, needs: { type: "string" }, question: { type: "string" }, not: { type: "string" },
67
+ command: { type: "string" }, wrong: { type: "string" }, "known-fail": { type: "string" }, "known-fail-why": { type: "string" },
68
+ kind: { type: "string" }, timeout: { type: "string" }, "confirmed-by-owner": { type: "boolean" }, search: { type: "string" },
69
+ plan: { type: "string" }, label: { type: "string" }, session: { type: "string" }, answer: { type: "string", multiple: true }, "done-when": { type: "string", multiple: true },
70
+ "max-tasks": { type: "string" }, "max-turns": { type: "string" }, model: { type: "string" }, statement: { type: "string" }, write: { type: "boolean" }, "dry-run": { type: "boolean" },
71
+ json: { type: "boolean" }, help: { type: "boolean", short: "h" },
72
+ "with-never-delete": { type: "boolean" }, force: { type: "boolean" }, "no-install": { type: "boolean" }, dir: { type: "string" },
73
+ print: { type: "boolean" }, gh: { type: "boolean" }, version: { type: "boolean", short: "v" },
74
+ },
75
+ });
76
+
77
+ const out = (s = "") => process.stdout.write(s + "\n");
78
+ const err = (s) => process.stderr.write(s + "\n");
79
+ /** Abort with a message. THROWS so that withLock's finally releases the lock — a process.exit here left a stale lock behind (found by tests/plan-system.test.ts). */
80
+ class CliExit extends Error { constructor(msg, code) { super(msg); this.code = code; } }
81
+ function die(msg, code = 2) { throw new CliExit(msg, code); }
82
+ function need(name, v) { if (v === undefined || v === "") die(`--${name} is required`); return v; }
83
+ const list = (s) => (s ? s.split(",").map((x) => x.trim()).filter(Boolean) : []);
84
+
85
+ /** The session id the SessionStart hook wrote, so log entries can say which session made them. Best effort. */
86
+ function sessionId() {
87
+ if (opt.session) return opt.session;
88
+ try { return readJson(join(hookStateRoot(), "current-session.json"), {}).session_id || null; } catch { return null; }
89
+ }
90
+ function runId() { return `${Date.now().toString(36)}-${randomBytes(3).toString("hex")}`; }
91
+
92
+ function loadOrDie(id) {
93
+ validatePlanId(id);
94
+ try { return loadPlan(id); } catch (e) { die(e.message); }
95
+ }
96
+ /** Validate, then write state. Refuses to write an invalid state — the file never holds a lie this tool made. */
97
+ function saveState(plan, mutate) {
98
+ const path = join(plan.dir, FILES.state);
99
+ return withLock(path, () => {
100
+ const fresh = loadPlan(plan.id);
101
+ mutate(fresh.state, fresh);
102
+ const { errors } = validatePlan(fresh);
103
+ if (errors.length) die(`refusing to write an invalid state:\n ${errors.join("\n ")}`, 1);
104
+ writeJsonAtomic(path, fresh.state);
105
+ return fresh;
106
+ });
107
+ }
108
+ function findTask(state, tid) {
109
+ const t = state.tasks.find((x) => x.id === tid);
110
+ if (!t) die(`no task ${tid} in ${state.id}`);
111
+ return t;
112
+ }
113
+ function syncClaudeMd() {
114
+ const active = activePlanIds().map((id) => { const s = readJson(join(planDir(id), FILES.state), {}); return { id, title: s.title }; });
115
+ return writeBlock(claudeMdPath(), active);
116
+ }
117
+
118
+ // ---------- gates -------------------------------------------------------------
119
+
120
+ function execGate(command, timeoutSec) {
121
+ const t0 = Date.now();
122
+ const r = spawnSync("bash", ["-c", command], {
123
+ cwd: projectRoot(), encoding: "utf8", timeout: timeoutSec * 1000, maxBuffer: 64 * 1024 * 1024,
124
+ env: { ...process.env, PLAN_GATE: "1" },
125
+ });
126
+ const text = `${r.stdout || ""}${r.stderr ? `\n[stderr]\n${r.stderr}` : ""}`;
127
+ const lines = text.trim().split("\n");
128
+ const tail = lines.slice(-25).join("\n").slice(-4000);
129
+ return { exit: r.status, signal: r.signal, error: r.error, durationMs: Date.now() - t0, stdout: r.stdout || "", tail };
130
+ }
131
+ function gateResult(gate, run) {
132
+ if (run.error || run.signal) return "error";
133
+ if (gate.passWhen === "exit0") return run.exit === 0 ? "pass" : "fail";
134
+ try { return new RegExp(gate.passWhen.stdoutMatches, "m").test(run.stdout) ? "pass" : "fail"; } catch { return "error"; }
135
+ }
136
+ function recordRun(plan, gate, kind, command, run, result) {
137
+ const entry = {
138
+ runId: runId(), gate: gate.id, kind, at: nowIso(), session: sessionId(), command,
139
+ exit: run.exit ?? null, durationMs: run.durationMs, result, tail: run.tail || (run.error ? String(run.error.message) : ""),
140
+ ...(kind === "verify" ? { gateCommand: gate.command } : {}),
141
+ };
142
+ SCHEMAS.GateRun.parse(entry);
143
+ appendJsonl(join(plan.dir, FILES.gateRuns), entry);
144
+ return entry;
145
+ }
146
+ /** Run a gate and record it. Returns the run entry. */
147
+ function runGate(plan, gid) {
148
+ const gate = plan.gates.gates.find((g) => g.id === gid);
149
+ if (!gate) die(`no gate ${gid} in ${plan.id}`);
150
+ out(`▶ ${gate.id}: ${gate.question}\n $ ${gate.command}`);
151
+ const run = execGate(gate.command, gate.timeoutSec);
152
+ const result = gateResult(gate, run);
153
+ const entry = recordRun(plan, gate, "run", gate.command, run, result);
154
+ out(` ${result.toUpperCase()} (exit ${run.exit ?? "—"}, ${run.durationMs} ms, run ${entry.runId})`);
155
+ if (result !== "pass") out(indent(run.tail || String(run.error?.message || "")));
156
+ return entry;
157
+ }
158
+ /** Run the knownFail case. It PASSES the verification only if the command FAILS — that is the whole point. */
159
+ function verifyGate(plan, gid) {
160
+ const gate = plan.gates.gates.find((g) => g.id === gid);
161
+ if (!gate) die(`no gate ${gid} in ${plan.id}`);
162
+ if (!gate.knownFail) { out(`${gid}: no knownFail case declared — nothing shows this gate can fail`); return null; }
163
+ out(`▶ verify ${gate.id}: ${gate.knownFail.description}\n $ ${gate.knownFail.command}`);
164
+ const run = execGate(gate.knownFail.command, gate.timeoutSec);
165
+ const gateSaysPass = gateResult(gate, run) === "pass";
166
+ const want = gate.knownFail.expectExit ?? null;
167
+ const wrongExit = want !== null && run.exit !== want;
168
+ const result = run.error ? "error" : gateSaysPass || wrongExit ? "fail" : "pass";
169
+ const entry = recordRun(plan, gate, "verify", gate.knownFail.command, run, result);
170
+ if (result === "pass") out(` VERIFIED — the gate fails on a case that must fail (exit ${run.exit}${want !== null ? `, as expected` : ""}). Last lines:\n${indent(run.tail.split("\n").slice(-3).join("\n"))}`);
171
+ else if (wrongExit) out(` ⛔ NOT VERIFIED — the case exited ${run.exit}, not ${want}. That is a broken command, not the failure the gate exists for.\n${indent(run.tail)}`);
172
+ else out(` ⛔ NOT VERIFIED — the known-fail case PASSED the gate. The gate cannot see the failure it exists for.\n${indent(run.tail)}`);
173
+ return entry;
174
+ }
175
+ const indent = (s) => String(s || "").split("\n").map((l) => ` ${l}`).join("\n");
176
+
177
+ // ---------- commands ----------------------------------------------------------
178
+
179
+ const PLAN_MD_TEMPLATE = (title) => `# ${title}
180
+
181
+ <!-- The stable narrative. Status, tasks and history live in the JSON files beside this file — never here.
182
+ Everything under a heading is read by people and by the brief; keep it short, keep it true. -->
183
+
184
+ ## Why
185
+ <!-- 2–4 sentences: the user's real goal, and what would make the result useless to them. -->
186
+
187
+ ## Done means
188
+ <!-- Statements someone can observe, each ending with the gate that proves it: "… — G1". -->
189
+
190
+ ## Non-goals
191
+ <!-- What this plan deliberately does not do, and where a deferred item would go if it returns. -->
192
+
193
+ ## Method
194
+ <!-- The recipe for each kind of task, with real commands and ONE worked example with real numbers,
195
+ so the next session does not rediscover it. Point at guides; do not restate them. -->
196
+
197
+ ## Rules for this plan
198
+ <!-- Only rules that cannot be a gate (gates.json) or a hook rule (rules.json). Numbered.
199
+ Each: the rule in one sentence, then the case that taught it. The brief shows the first 14 lines. -->
200
+
201
+ ## Owner decides
202
+ <!-- Irreversible or outward-facing steps. The executor stops and asks before each one. "none" if none. -->
203
+
204
+ ## Map
205
+ | path | role |
206
+ |---|---|
207
+
208
+ ## Sources
209
+ | what | path | what applies here |
210
+ |---|---|---|
211
+ `;
212
+
213
+ function cmdNew() {
214
+ const id = validatePlanId(need("id", pos[1]));
215
+ const title = need("title", opt.title);
216
+ const dir = planDir(id);
217
+ if (existsSync(join(dir, FILES.state))) die(`plan ${id} already exists at ${dir}`);
218
+ mkdirSync(dir, { recursive: true });
219
+ const state = { id, title, status: "draft", created: today(), activatedAt: null, closedAt: null, paths: list(opt.paths), doneWhen: DEFAULT_DONE_WHEN, tasks: [] };
220
+ SCHEMAS.State.parse(state);
221
+ writeJsonAtomic(join(dir, FILES.state), state);
222
+ writeJsonAtomic(join(dir, FILES.gates), { gates: [] });
223
+ writeJsonAtomic(join(dir, FILES.rules), { rules: [] });
224
+ for (const f of [FILES.log, FILES.learnings, FILES.decisions, FILES.gateRuns]) writeFileSync(join(dir, f), "");
225
+ writeFileSync(join(dir, FILES.planMd), PLAN_MD_TEMPLATE(title));
226
+ out(`created ${dir} (status draft). Fill PLAN.md, add tasks and gates, then: ${CLI} validate ${id} && ${CLI} activate ${id}`);
227
+ }
228
+
229
+ function cmdList() {
230
+ const ids = listPlanIds();
231
+ if (!ids.length) { out(`no plans under ${plansRoot()}`); return; }
232
+ for (const id of ids) {
233
+ const p = loadPlan(id); const c = taskCounts(p.state); const last = p.log.at(-1);
234
+ out(`${p.state.status.padEnd(9)} ${id.padEnd(28)} ${String(c.done).padStart(3)}/${String(p.state.tasks.length).padEnd(3)} done last log ${last ? shortStamp(last.at) : "never"} ${p.state.title}`);
235
+ }
236
+ }
237
+
238
+ function cmdValidate() {
239
+ const ids = opt.all || !pos[1] ? listPlanIds() : [pos[1]];
240
+ let errors = [], warnings = [];
241
+ for (const id of ids) {
242
+ const r = validatePlan(loadOrDie(id));
243
+ errors.push(...r.errors); warnings.push(...r.warnings);
244
+ }
245
+ // CLAUDE.md drift: the block must list exactly the active plans.
246
+ const md = existsSync(claudeMdPath()) ? readFileSync(claudeMdPath(), "utf8") : "";
247
+ const inBlock = idsInBlock(md);
248
+ if (blockCount(md) > 1) errors.push(`CLAUDE.md holds ${blockCount(md)} plans blocks — a merge left a duplicate; run: ${CLI} activate <any active id> to collapse them`);
249
+ const active = ids.filter((id) => readJson(join(planDir(id), FILES.state), {}).status === "active");
250
+ if (active.length && inBlock === null) errors.push(`CLAUDE.md has no plans block but ${active.length} plan(s) are active — run: ${CLI} activate <id>`);
251
+ else if (inBlock) {
252
+ for (const id of active) if (!inBlock.includes(id)) errors.push(`CLAUDE.md plans block does not list active plan ${id}`);
253
+ for (const id of inBlock) if (!active.includes(id) && (opt.all || !pos[1] || id === pos[1])) errors.push(`CLAUDE.md plans block lists ${id}, which is not an active plan`);
254
+ }
255
+ if (!opt.quiet) for (const w of warnings) out(`warn ${w}`);
256
+ for (const e of errors) err(`ERROR ${e}`);
257
+ if (errors.length) { err(`plan: ${errors.length} error(s) across ${ids.length} plan(s)`); process.exit(1); }
258
+ if (!opt.quiet || !ids.length) out(`plan: ${ids.length} plan(s) valid${warnings.length ? `, ${warnings.length} warning(s)` : ""}`);
259
+ }
260
+
261
+ function cmdBrief() {
262
+ const ids = opt.all || !pos[1] ? activePlanIds() : [pos[1]];
263
+ if (!ids.length) { out("no active plans"); return; }
264
+ out(ids.map((id) => renderBrief(loadOrDie(id))).join("\n\n"));
265
+ }
266
+
267
+ /** Print the brief a subagent gets for one task. Creates the plan's reports/ directory so the agent's report has a home. */
268
+ function cmdAgentBrief() {
269
+ const plan = loadOrDie(need("id", pos[1]));
270
+ const task = findTask(plan.state, need("task id", pos[2]));
271
+ mkdirSync(join(plan.dir, "reports"), { recursive: true });
272
+ out(renderAgentBrief(plan, task, { unit: opt.what || null, label: opt.label || null }));
273
+ }
274
+
275
+ function cmdStatus() {
276
+ const plan = loadOrDie(need("id", pos[1]));
277
+ out(renderBrief(plan)); out("");
278
+ out("TASKS");
279
+ for (const t of plan.state.tasks) {
280
+ const ev = t.evidence ? (t.evidence.kind === "gate" ? `${t.evidence.gate}@${t.evidence.runId}` : `manual/${t.evidence.by}`) : "";
281
+ const total = plan.state.doneWhen.length + t.doneWhen.length;
282
+ out(` ${t.id.padEnd(4)} ${t.status.padEnd(8)} ${t.title}${t.gate ? ` [${t.gate}]` : " [manual]"}${ev ? ` ✓ ${ev}` : ""}${t.status === "done" ? ` checklist ${t.doneChecklist.length}/${total}` : ""}${t.blocked ? ` ⛔ ${t.blocked.reason}` : ""}`);
283
+ }
284
+ out("GATES");
285
+ for (const g of plan.gates.gates) {
286
+ const r = lastRunFor(plan, g.id);
287
+ out(` ${g.id.padEnd(4)} ${r ? `${r.result.toUpperCase().padEnd(5)} ${shortStamp(r.at)}` : "never run "} ${g.knownFail ? (verifiedFor(plan, g.id) ? "verified" : "UNVERIFIED") : "no knownFail"} ${g.question}`);
288
+ }
289
+ const { errors, warnings } = validatePlan(plan);
290
+ for (const w of warnings) out(`warn ${w}`);
291
+ for (const e of errors) out(`ERROR ${e}`);
292
+ if (plan.decisions.length) { out("DECISIONS"); for (const d of plan.decisions.slice(-5)) out(` ${d.id} (${d.by}) ${d.decision}`); }
293
+ }
294
+
295
+ function cmdTask() {
296
+ const sub = need("subcommand (add|start|done|block|unblock|drop)", pos[1]);
297
+ const plan = loadOrDie(need("id", pos[2]));
298
+ const at = nowIso();
299
+ if (sub === "add") {
300
+ let added = null;
301
+ saveState(plan, (state) => {
302
+ added = nextId("T", state.tasks);
303
+ let n = Math.max(0, ...state.doneWhen.map((s) => Number(s.id.slice(1))), ...state.tasks.flatMap((t) => t.doneWhen.map((s) => Number(s.id.slice(1)))));
304
+ const doneWhen = (opt["done-when"] || []).map((statement) => ({ id: `C${++n}`, statement, kind: "manual" }));
305
+ state.tasks.push({
306
+ id: added, title: need("title", opt.title), status: "todo", gate: opt.gate || null, manualCheck: opt.manual || null,
307
+ files: list(opt.files), effort: opt.effort || null, dependsOn: list(opt.after), notes: opt.notes || "",
308
+ startedAt: null, doneAt: null, evidence: null, blocked: null, doneWhen, doneChecklist: [],
309
+ });
310
+ });
311
+ // Only after the validated write. Printing inside the callback once announced a task the validator then refused.
312
+ out(`added ${added}: ${opt.title}`);
313
+ return;
314
+ }
315
+ const tid = need("task id", pos[3]);
316
+ if (sub === "check") {
317
+ const t = findTask(plan.state, tid);
318
+ const statements = [...plan.state.doneWhen, ...t.doneWhen];
319
+ const { entries, failures } = evaluateAuto(plan, t, statements, { kind: "pending" });
320
+ out(`${tid} — ${t.title}\nBefore "done", each of these must hold:`);
321
+ for (const s of statements) {
322
+ const e = entries.find((x) => x.id === s.id);
323
+ if (s.kind === "manual") out(` ${s.id} [manual] ${s.statement}\n → answer with: --answer "${s.id}: <what you checked>"`);
324
+ else if (s.kind === "auto:gate") out(` ${s.id} [auto:gate] ${s.statement}\n → ${t.gate ? `${t.gate} runs when you call task done` : `no gate; close with --manual "<what was checked and how>"`}`);
325
+ else out(` ${s.id} [${s.kind}] ${s.statement}\n → ${failures.some((f) => f.startsWith(s.id + ":")) ? "✗" : "✓"} ${e?.answer || ""}`);
326
+ }
327
+ // The context refill. After a compaction the agent does not know what it forgot, so the tool brings it back:
328
+ // why the plan exists, what this task is, which files to re-read whole, and what the gate does and does not see.
329
+ const clipTo = (s, n) => { s = String(s || "").replace(/\s+/g, " ").trim(); return s.length > n ? s.slice(0, n - 1) + "…" : s; };
330
+ const gate = t.gate ? plan.gates.gates.find((g) => g.id === t.gate) : null;
331
+ const why = sectionOf(plan.planMd, "Why");
332
+ out(`\nCONTEXT TO HOLD WHILE YOU ANSWER — re-read it now; after a compaction you do not know what you forgot:`);
333
+ if (why) out(` WHY THIS PLAN: ${clipTo(why, 400)}`);
334
+ out(` TASK: ${t.title}${t.notes ? ` — ${clipTo(t.notes, 200)}` : ""}\n FILES TO RE-READ WHOLE: ${t.files.join(", ") || "(none listed — see PLAN.md § Map)"}`);
335
+ if (gate) out(` GATE ${gate.id} answers: ${gate.question}\n it does NOT answer: ${gate.notTheSameAs}\n it could pass while wrong if: ${gate.couldPassWhileWrongIf}`);
336
+ else out(` NO GATE — a person checks: ${t.manualCheck}`);
337
+ out("");
338
+ for (const l of JUDGMENT) out(` ${l}`);
339
+ return;
340
+ }
341
+ if (sub === "start") {
342
+ saveState(plan, (state) => {
343
+ const t = findTask(state, tid);
344
+ if (t.status === "done") die(`${tid} is already done`);
345
+ for (const d of t.dependsOn) { const dep = findTask(state, d); if (dep.status !== "done" && dep.status !== "dropped") err(`warn: ${tid} depends on ${d}, which is ${dep.status}`); }
346
+ t.status = "doing"; t.startedAt = t.startedAt || at; t.blocked = null;
347
+ });
348
+ out(`${tid} → doing. When it lands: ${CLI} task done ${plan.id} ${tid}`);
349
+ return;
350
+ }
351
+ if (sub === "done") {
352
+ const t = findTask(plan.state, tid);
353
+ if (t.status === "done") die(`${tid} is already done`);
354
+ if (t.status === "dropped") die(`${tid} is dropped; unblock/re-add it first`);
355
+ if (t.gate && plan.gates.gates.find((g) => g.id === t.gate)?.kind === "report") die(`${tid} names ${t.gate}, a report gate — a report never proves a task done; give the task a real gate or a manualCheck`);
356
+ const statements = [...plan.state.doneWhen, ...t.doneWhen];
357
+ // Manual statements need an answer BEFORE the gate runs: a gate run that is then thrown away is waste, and an executor who cannot answer C4 should not be running gates.
358
+ const answers = new Map();
359
+ for (const a of opt.answer || []) {
360
+ const m = /^\s*(C\d+)\s*[:=]\s*(.+)$/s.exec(a);
361
+ if (!m) die(`--answer must look like "C4: what you checked" (got: ${a.slice(0, 40)})`);
362
+ answers.set(m[1], m[2].trim());
363
+ }
364
+ const manual = statements.filter((s) => s.kind === "manual");
365
+ const unanswered = manual.filter((s) => !answers.has(s.id) || answers.get(s.id).length < 10);
366
+ if (unanswered.length) {
367
+ // This refusal is a REMINDER, not only an error: the executor sees every condition of done, so a session
368
+ // that lost its context after a compaction still learns what "done" means here before it can claim it.
369
+ const lines = statements.map((s) => {
370
+ const missing = unanswered.some((u) => u.id === s.id);
371
+ const how = s.kind === "manual" ? (missing ? `✗ answer with --answer "${s.id}: <what you checked>"` : "✓ answered") : `checked by the tool when the answers are in (${s.kind})`;
372
+ return ` ${s.id} ${s.statement}\n ${how}`;
373
+ });
374
+ die(`${tid} is NOT done yet. Before a task is done here, every condition below must hold. Read each one, do what it says, then run this command again with one --answer per manual condition:\n${lines.join("\n")}\nAn answer says what you checked and how (10+ characters), never just "yes". Context for the answers: ${CLI} task check ${plan.id} ${tid}\n\n${JUDGMENT.map((l) => " " + l).join("\n")}`, 1);
375
+ }
376
+ for (const id of answers.keys()) if (!statements.some((s) => s.id === id)) die(`--answer names ${id}, which is not a condition of ${tid}`);
377
+ // The cheap conditions (a log entry, the files on disk) are checked BEFORE the gate runs: a gate can take
378
+ // minutes, and an executor who has not logged should hear that now, together with everything else unmet.
379
+ const pre = evaluateAuto(plan, t, statements.filter((s) => s.kind !== "auto:gate"), { kind: "none" });
380
+ if (pre.failures.length) die(`${tid} is NOT done — before the gate runs, these conditions already fail:\n ${pre.failures.join("\n ")}`, 1);
381
+ let evidence;
382
+ if (opt.manual) {
383
+ const by = opt.by || "agent";
384
+ if (!["owner", "agent"].includes(by)) die("--by must be owner or agent");
385
+ if (t.gate && !(opt["confirmed-by-owner"] && by === "owner")) die(`${tid} has gate ${t.gate}. Run it (omit --manual), or record the owner's word with BOTH --by owner AND --confirmed-by-owner.`);
386
+ evidence = { kind: "manual", by, at, reason: opt.manual };
387
+ } else {
388
+ if (!t.gate) die(`${tid} has no gate; its manualCheck says: "${t.manualCheck}". Record it with --manual "<what you checked and how>" [--by owner]`);
389
+ const gate = plan.gates.gates.find((g) => g.id === t.gate);
390
+ if (gate?.knownFail) {
391
+ const vs = verifyStatus(plan, gate);
392
+ if (!vs.verified) die(`${t.gate} declares a known-fail case but was never verified — nothing has shown it CAN fail. Run: ${CLI} gate verify ${plan.id} ${t.gate}`);
393
+ if (vs.stale) die(`${t.gate}'s command changed after it was last verified. A gate edited to pass must still fail its known-fail case: ${CLI} gate verify ${plan.id} ${t.gate} — then run this command again.`);
394
+ }
395
+ const run = runGate(plan, t.gate);
396
+ if (run.result !== "pass") die(`${tid} is NOT done — ${t.gate} ${run.result}.\nFix the CAUSE, then run this command again. Never edit the gate, the test or the data so that it passes.\nIf you believe the GATE is wrong, or you cannot fix the cause: ${CLI} task block ${plan.id} ${tid} --reason "<what the gate computed, and what is true>" --needs owner — then stop.\nLog where it stands: ${CLI} log ${plan.id} --task ${tid} --what "…" --next "…"`, 1);
397
+ evidence = { kind: "gate", gate: t.gate, runId: run.runId, at: run.at };
398
+ }
399
+ // Auto conditions, evaluated NOW against the run just made and the plan's files.
400
+ const gateOutcome = evidence.kind === "gate" ? { kind: "gate", run: loadPlan(plan.id).gateRuns.find((r) => r.runId === evidence.runId) } : { kind: "manual", reason: evidence.reason, by: evidence.by };
401
+ const { entries, failures } = evaluateAuto(loadPlan(plan.id), t, statements, gateOutcome);
402
+ if (failures.length) die(`${tid} is NOT done — a condition fails:\n ${failures.join("\n ")}`, 1);
403
+ const checklist = statements.map((s) => {
404
+ const auto = entries.find((e) => e.id === s.id);
405
+ return { id: s.id, statement: s.statement, kind: s.kind, answer: auto ? auto.answer : answers.get(s.id), at };
406
+ });
407
+ saveState(plan, (state) => { const x = findTask(state, tid); x.status = "done"; x.doneAt = at; x.evidence = evidence; x.blocked = null; x.doneChecklist = checklist; });
408
+ out(`${tid} → done (${evidence.kind === "gate" ? `${evidence.gate} run ${evidence.runId}` : `manual, by ${evidence.by}`}); ${checklist.length} conditions answered. Review: ${CLI} review ${plan.id} ${tid}`);
409
+ return;
410
+ }
411
+ if (sub === "block") {
412
+ const needs = opt.needs || "self";
413
+ saveState(plan, (state) => { const t = findTask(state, tid); t.status = "blocked"; t.blocked = { reason: need("reason", opt.reason), since: at, needs }; });
414
+ out(`${tid} → blocked (needs ${needs})`);
415
+ return;
416
+ }
417
+ if (sub === "unblock") { saveState(plan, (state) => { const t = findTask(state, tid); t.status = "todo"; t.blocked = null; }); out(`${tid} → todo`); return; }
418
+ if (sub === "drop") {
419
+ saveState(plan, (state) => { const t = findTask(state, tid); t.status = "dropped"; t.notes = `${t.notes ? t.notes + "\n" : ""}dropped ${at}: ${need("reason", opt.reason)}`; t.blocked = null; });
420
+ out(`${tid} → dropped`);
421
+ return;
422
+ }
423
+ die(`unknown task subcommand ${sub}`);
424
+ }
425
+
426
+ /** The reviewable record: for each done task, its evidence and every condition with its answer. */
427
+ function cmdReview() {
428
+ const plan = loadOrDie(need("id", pos[1]));
429
+ const tasks = pos[2] ? [findTask(plan.state, pos[2])] : plan.state.tasks.filter((t) => t.status === "done");
430
+ if (!tasks.length) { out("no done tasks yet"); return; }
431
+ for (const t of tasks) {
432
+ out(`${t.id} ${t.status.toUpperCase()} — ${t.title}`);
433
+ if (t.evidence) out(` evidence: ${t.evidence.kind === "gate" ? `${t.evidence.gate} run ${t.evidence.runId} at ${shortStamp(t.evidence.at)}` : `manual by ${t.evidence.by}: ${t.evidence.reason}`}`);
434
+ if (!t.doneChecklist.length) out(t.status === "done" ? " checklist: NONE — closed before doneWhen existed, or by hand" : " checklist: (not done yet)");
435
+ for (const c of t.doneChecklist) out(` ${c.id} ${c.kind === "manual" ? " " : "[auto]"} ${c.statement}\n ↳ ${c.answer}`);
436
+ out("");
437
+ }
438
+ }
439
+
440
+ function cmdLog() {
441
+ const plan = loadOrDie(need("id", pos[1]));
442
+ if (opt.task) findTask(plan.state, opt.task);
443
+ const entry = { at: nowIso(), session: sessionId(), task: opt.task || null, what: need("what", opt.what), next: need("next", opt.next), refs: list(opt.refs), uncommitted: opt.uncommitted || null };
444
+ const r = SCHEMAS.LogEntry.safeParse(entry);
445
+ if (!r.success) die(r.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "));
446
+ appendJsonl(join(plan.dir, FILES.log), entry);
447
+ out(`logged. RESUME now reads: ${entry.next}`);
448
+ }
449
+
450
+ function cmdLearn() {
451
+ const plan0 = loadOrDie(need("id", pos[1]));
452
+ withLock(join(plan0.dir, FILES.state), () => { const plan = loadPlan(plan0.id);
453
+ const id = nextId("L", plan.learnings);
454
+ const entry = {
455
+ id, at: nowIso(), session: sessionId(), task: opt.task || null, what: need("what", opt.what), rule: need("rule", opt.rule),
456
+ appliesWhen: need("when", opt.when), enforcement: opt.enforcement || "prose", ref: opt.ref || null, status: opt.lead ? "lead" : "confirmed",
457
+ };
458
+ const r = SCHEMAS.Learning.safeParse(entry);
459
+ if (!r.success) die(r.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "));
460
+ const test = loadPlan(plan.id); test.learnings.push(entry);
461
+ const { errors } = validatePlan(test);
462
+ if (errors.length) die(`refusing: ${errors.join("; ")}`, 1);
463
+ appendJsonl(join(plan.dir, FILES.learnings), entry);
464
+ out(`${id} recorded (${entry.status}, enforcement ${entry.enforcement}).${entry.enforcement === "prose" ? ` Can it be a gate or a rules.json rule instead? If yes, promote it and set --enforcement.` : ""}`);
465
+ });
466
+ }
467
+
468
+ function cmdDecide() {
469
+ const plan0 = loadOrDie(need("id", pos[1]));
470
+ withLock(join(plan0.dir, FILES.state), () => { const plan = loadPlan(plan0.id);
471
+ const id = nextId("D", plan.decisions);
472
+ const rejected = list(opt.rejected?.replace(/;/g, ",")).map((s) => { const [option, ...why] = s.split(":"); return { option: option.trim(), why: why.join(":").trim() || "not stated" }; });
473
+ const entry = { id, at: nowIso(), session: sessionId(), task: opt.task || null, decision: need("what", opt.what), why: need("why", opt.why), rejected, by: opt.by || "agent", supersedes: opt.supersedes || null };
474
+ const r = SCHEMAS.Decision.safeParse(entry);
475
+ if (!r.success) die(r.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "));
476
+ const test = loadPlan(plan.id); test.decisions.push(entry);
477
+ const { errors } = validatePlan(test);
478
+ if (errors.length) die(`refusing: ${errors.join("; ")}`, 1);
479
+ appendJsonl(join(plan.dir, FILES.decisions), entry);
480
+ out(`${id} recorded (by ${entry.by}).`);
481
+ });
482
+ }
483
+
484
+ function cmdGate() {
485
+ const sub = need("subcommand (list|run|verify|add)", pos[1]);
486
+ const plan = loadOrDie(need("id", pos[2]));
487
+ if (sub === "list") {
488
+ for (const g of plan.gates.gates) {
489
+ const r = lastRunFor(plan, g.id);
490
+ out(`${g.id} [${g.kind}] ${g.question}\n answers NOT: ${g.notTheSameAs}\n $ ${g.command}\n could pass while wrong if: ${g.couldPassWhileWrongIf}\n last run: ${r ? `${r.result} ${shortStamp(r.at)}` : "never"} · knownFail: ${g.knownFail ? (verifiedFor(plan, g.id) ? "verified" : "declared, UNVERIFIED") : "none"}`);
491
+ }
492
+ return;
493
+ }
494
+ if (sub === "run") {
495
+ const ids = opt.all ? plan.gates.gates.map((g) => g.id) : [need("gate id", pos[3])];
496
+ let bad = 0;
497
+ for (const g of ids) if (runGate(plan, g).result !== "pass") bad++;
498
+ if (bad) process.exit(1);
499
+ return;
500
+ }
501
+ if (sub === "verify") {
502
+ const ids = opt.all ? plan.gates.gates.map((g) => g.id) : [need("gate id", pos[3])];
503
+ let bad = 0;
504
+ for (const g of ids) { const e = verifyGate(plan, g); if (e && e.result !== "pass") bad++; }
505
+ if (bad) process.exit(1);
506
+ return;
507
+ }
508
+ if (sub === "add") {
509
+ withLock(join(plan.dir, FILES.state), () => {
510
+ const fresh = loadPlan(plan.id);
511
+ const gate = {
512
+ id: nextId("G", fresh.gates.gates), question: need("question", opt.question), notTheSameAs: need("not", opt.not), command: need("command", opt.command),
513
+ passWhen: "exit0", timeoutSec: Number(opt.timeout || 600),
514
+ knownFail: opt["known-fail"] ? { command: opt["known-fail"], description: need("known-fail-why", opt["known-fail-why"]), expectExit: 1 } : null,
515
+ couldPassWhileWrongIf: need("wrong", opt.wrong), kind: opt.kind || "static",
516
+ };
517
+ const r = SCHEMAS.Gate.safeParse(gate);
518
+ if (!r.success) die(r.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "));
519
+ fresh.gates.gates.push(gate);
520
+ writeJsonAtomic(join(fresh.dir, FILES.gates), fresh.gates);
521
+ out(`added ${gate.id}. Prove it can fail: ${CLI} gate verify ${plan.id} ${gate.id}`);
522
+ });
523
+ return;
524
+ }
525
+ die(`unknown gate subcommand ${sub}`);
526
+ }
527
+
528
+ function cmdActivate() {
529
+ const plan = loadOrDie(need("id", pos[1]));
530
+ if (plan.state.status === "active") { out(`${plan.id} is already active`); syncClaudeMd(); return; }
531
+ if (["done", "abandoned"].includes(plan.state.status)) die(`${plan.id} is ${plan.state.status}; create a new plan instead of reviving it`);
532
+ const trial = loadPlan(plan.id); trial.state.status = "active"; trial.state.activatedAt = trial.state.activatedAt || nowIso();
533
+ const { errors, warnings } = validatePlan(trial);
534
+ if (errors.length) die(`cannot activate:\n ${errors.join("\n ")}`, 1);
535
+ const touchesDocs = trial.state.tasks.some((t) => /\bdocs?\b/i.test(t.title) || t.files.some((f) => f.startsWith("docs/")));
536
+ if (!touchesDocs) warnings.push(`${plan.id}: no task mentions docs — docs ship in the same change as the code they describe (CLAUDE.md rule 9)`);
537
+ saveState(plan, (state) => { state.status = "active"; state.activatedAt = state.activatedAt || nowIso(); });
538
+ const changed = syncClaudeMd();
539
+ for (const w of warnings) out(`warn ${w}`);
540
+ out(`${plan.id} → active. CLAUDE.md plans block ${changed ? "updated" : "already current"}.`);
541
+ out(`Tell the owner the block changed. Then verify each gate can fail: ${CLI} gate verify ${plan.id} --all`);
542
+ out(""); out(renderBrief(loadPlan(plan.id)));
543
+ }
544
+
545
+ function cmdPause() {
546
+ const plan = loadOrDie(need("id", pos[1]));
547
+ saveState(plan, (state) => { state.status = "paused"; });
548
+ syncClaudeMd();
549
+ out(`${plan.id} → paused; removed from CLAUDE.md. Re-activate with: ${CLI} activate ${plan.id}`);
550
+ }
551
+
552
+ function cmdClose() {
553
+ const plan = loadOrDie(need("id", pos[1]));
554
+ const open = plan.state.tasks.filter((t) => !["done", "dropped"].includes(t.status));
555
+ if (open.length) die(`cannot close: ${open.map((t) => `${t.id} (${t.status})`).join(", ")} still open`, 1);
556
+ out("Running every gate fresh (kind ≠ report) — a plan closes on what the gates say NOW, not on their last recorded run.");
557
+ let bad = 0;
558
+ for (const g of plan.gates.gates) if (g.kind !== "report" && runGate(plan, g.id).result !== "pass") bad++;
559
+ if (bad) die(`cannot close: ${bad} gate(s) fail`, 1);
560
+ const prose = plan.learnings.filter((l) => l.enforcement === "prose" && l.status !== "retracted");
561
+ if (prose.length) {
562
+ out(`\n${prose.length} learning(s) are still prose-only. Before closing, ask of each: can it be a gate, a rules.json rule in a later plan, or a line in a docs guide?`);
563
+ for (const l of prose) out(` ${l.id}: ${l.rule}`);
564
+ }
565
+ if (!opt["confirmed-by-owner"]) {
566
+ out(`\nAll gates pass. Closing removes ${plan.id} from CLAUDE.md. ASK THE OWNER in chat, then run:\n ${CLI} close ${plan.id} --confirmed-by-owner`);
567
+ return;
568
+ }
569
+ saveState(plan, (state) => { state.status = "done"; state.closedAt = nowIso(); });
570
+ appendJsonl(join(plan.dir, FILES.log), { at: nowIso(), session: sessionId(), task: null, what: "plan closed: every task done or dropped, every gate passing, owner confirmed", next: "nothing — this plan is closed", refs: [], uncommitted: null });
571
+ syncClaudeMd();
572
+ out(`${plan.id} → done. Removed from CLAUDE.md. Its learnings stay searchable: ${CLI} learnings --plan ${plan.id}`);
573
+ }
574
+
575
+ function cmdAbandon() {
576
+ const plan = loadOrDie(need("id", pos[1]));
577
+ if (!opt["confirmed-by-owner"]) die(`abandoning a plan is the owner's call. Ask, then re-run with --confirmed-by-owner --reason "…"`);
578
+ const reason = need("reason", opt.reason);
579
+ saveState(plan, (state) => { state.status = "abandoned"; state.closedAt = nowIso(); });
580
+ appendJsonl(join(plan.dir, FILES.log), { at: nowIso(), session: sessionId(), task: null, what: `plan abandoned: ${reason}`, next: "nothing — this plan is abandoned", refs: [], uncommitted: null });
581
+ syncClaudeMd();
582
+ out(`${plan.id} → abandoned. Removed from CLAUDE.md.`);
583
+ }
584
+
585
+ function cmdLearnings() {
586
+ const ids = opt.plan ? [opt.plan] : listPlanIds();
587
+ const term = (opt.search || "").toLowerCase();
588
+ let n = 0;
589
+ for (const id of ids) {
590
+ const p = loadOrDie(id);
591
+ for (const l of p.learnings) {
592
+ const hay = `${l.what} ${l.rule} ${l.appliesWhen}`.toLowerCase();
593
+ if (term && !hay.includes(term)) continue;
594
+ n++;
595
+ out(`${id} ${l.id} [${l.status}, ${l.enforcement}${l.ref ? ` → ${l.ref}` : ""}] ${l.rule}\n case: ${l.what}\n when: ${l.appliesWhen}`);
596
+ }
597
+ }
598
+ if (!n) out(term ? `no learning mentions "${term}"` : "no learnings recorded");
599
+ }
600
+
601
+ /** Plan-level conditions of done: list, add, drop. Ids never collide with a task's own conditions; `since` exempts tasks closed earlier. */
602
+ function cmdCondition() {
603
+ const sub = need("subcommand (list|add|drop)", pos[1]);
604
+ const plan = loadOrDie(need("id", pos[2]));
605
+ if (sub === "list") {
606
+ for (const s of plan.state.doneWhen) out(` ${s.id} [${s.kind}] ${s.statement}${s.since ? ` (since ${s.since})` : ""}`);
607
+ out(`${plan.state.doneWhen.length} plan-level condition(s); tasks may add their own with task add --done-when`);
608
+ return;
609
+ }
610
+ withLock(join(plan.dir, FILES.state), () => {
611
+ const fresh = loadPlan(plan.id);
612
+ if (sub === "add") {
613
+ const statement = need("statement", opt.statement);
614
+ const kind = opt.kind || "manual";
615
+ if (kind !== "manual") die("only manual conditions can be added here; the auto:* kinds need code in store.mjs evaluateAuto");
616
+ const used = [...fresh.state.doneWhen, ...fresh.state.tasks.flatMap((t) => t.doneWhen)].map((s) => Number(s.id.slice(1)));
617
+ const s = { id: `C${Math.max(0, ...used) + 1}`, statement, kind, since: nowIso() };
618
+ const r = SCHEMAS.Statement.safeParse(s);
619
+ if (!r.success) die(r.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "));
620
+ fresh.state.doneWhen.push(s);
621
+ writeJsonAtomic(join(fresh.dir, FILES.state), fresh.state);
622
+ out(`${s.id} added to ${plan.id}: every task closed from now on must answer it (tasks already done are not held to it).`);
623
+ return;
624
+ }
625
+ if (sub === "drop") {
626
+ const cid = need("condition id", pos[3]);
627
+ const s = fresh.state.doneWhen.find((x) => x.id === cid);
628
+ if (!s) die(`${plan.id} has no plan-level condition ${cid}`);
629
+ if (s.kind !== "manual") die(`${cid} is checked by the tool (${s.kind}); it stays`);
630
+ fresh.state.doneWhen = fresh.state.doneWhen.filter((x) => x.id !== cid);
631
+ writeJsonAtomic(join(fresh.dir, FILES.state), fresh.state);
632
+ out(`${cid} dropped from ${plan.id}. Answers already stored on done tasks are kept.`);
633
+ return;
634
+ }
635
+ die("condition list|add|drop");
636
+ });
637
+ }
638
+
639
+ /** One fresh claude -p session per task, until the plan is done, blocked, or needs a person. The answer to "clear the context after each task". */
640
+ async function cmdRun() {
641
+ const { runPlan } = await import("./run.mjs");
642
+ const code = await runPlan({ id: need("id", pos[1]), maxTasks: Number(opt["max-tasks"] || 1), model: opt.model || "sonnet", maxTurns: Number(opt["max-turns"] || 60), dryRun: Boolean(opt["dry-run"]), log: out });
643
+ process.exit(code);
644
+ }
645
+
646
+ async function cmdInit() { const { init } = await import("../init.mjs"); process.exit(await init({ dir: opt.dir, force: Boolean(opt.force), noInstall: Boolean(opt["no-install"]), withNeverDelete: Boolean(opt["with-never-delete"]), dryRun: Boolean(opt["dry-run"]), log: out })); }
647
+ async function cmdUpdate() { const { update } = await import("../init.mjs"); process.exit(await update({ dir: opt.dir, log: out })); }
648
+ async function cmdUninstall() { const { uninstall } = await import("../init.mjs"); process.exit(await uninstall({ dir: opt.dir, log: out })); }
649
+ async function cmdIssue() { const { issue } = await import("../issue.mjs"); process.exit(await issue({ kind: pos[1] || "bug", title: opt.title || "", print: Boolean(opt.print), gh: Boolean(opt.gh), log: out })); }
650
+ function cmdVersion() { out(JSON.parse(readFileSync(join(packageRoot(), "package.json"), "utf8")).version); }
651
+
652
+ async function cmdHooks() {
653
+ const { install, status, selftest } = await import("../hooks/install.mjs");
654
+ const sub = pos[1] || "status";
655
+ if (sub === "install") install({ dryRun: Boolean(opt["dry-run"]), withNeverDelete: Boolean(opt["with-never-delete"]) });
656
+ else if (sub === "uninstall") install({ remove: true });
657
+ else if (sub === "status") { const { problems } = status(); process.exit(problems ? 1 : 0); }
658
+ else if (sub === "selftest") selftest();
659
+ else die("hooks install|uninstall|status|selftest");
660
+ }
661
+
662
+ async function cmdDoctor() {
663
+ let problems = 0;
664
+ const ok = (m) => out(`ok ${m}`); const bad = (m) => { problems++; out(`FAIL ${m}`); }; const warn = (m) => out(`warn ${m}`);
665
+ const [maj, min] = process.versions.node.split(".").map(Number);
666
+ (maj > 20 || (maj === 20 && min >= 10)) ? ok(`node ${process.versions.node}`) : bad(`node ${process.versions.node} — need ≥ 20.10 (util.parseArgs, structuredClone)`);
667
+ const ids = listPlanIds();
668
+ ok(`${ids.length} plan(s) under ${plansRoot()}; ${activePlanIds().length} active`);
669
+ for (const id of ids) {
670
+ const p = loadPlan(id); const { errors, warnings } = validatePlan(p);
671
+ errors.length ? bad(`${id}: ${errors.length} error(s) — ${errors[0]}`) : ok(`${id}: valid`);
672
+ for (const w of warnings) warn(w);
673
+ if (p.state.status === "active") {
674
+ const last = p.log.at(-1);
675
+ if (last && hoursSince(last.at) > 24 * 3) warn(`${id}: no log entry for ${Math.round(hoursSince(last.at) / 24)} days`);
676
+ }
677
+ }
678
+ const md = existsSync(claudeMdPath()) ? readFileSync(claudeMdPath(), "utf8") : "";
679
+ const inBlock = idsInBlock(md); const active = activePlanIds();
680
+ if (inBlock === null) (active.length ? bad : warn)(`CLAUDE.md has no plans block${active.length ? ` but ${active.length} plan(s) are active` : ""}`);
681
+ else {
682
+ const drift = [...active.filter((i) => !inBlock.includes(i)), ...inBlock.filter((i) => !active.includes(i))];
683
+ drift.length ? bad(`CLAUDE.md plans block drift: ${drift.join(", ")}`) : ok(`CLAUDE.md plans block lists exactly the active plans`);
684
+ }
685
+ try { mkdirSync(hookStateRoot(), { recursive: true }); ok(`${hookStateRoot()} writable`); } catch { bad(`${hookStateRoot()} not writable`); }
686
+ try { await import("zod"); ok("zod resolves (the hooks import it; without it every hook exits 1 and every rail is off)"); } catch { bad("zod does not resolve — npm install; until then every hook exits 1 silently"); }
687
+ const { status } = await import("../hooks/install.mjs");
688
+ const s = status({ print: false });
689
+ for (const line of s.lines) (line.ok ? ok : line.warn ? warn : bad)(line.text);
690
+ problems += s.problems;
691
+ out(problems ? `\ndoctor: ${problems} problem(s)` : "\ndoctor: healthy");
692
+ process.exit(problems ? 1 : 0);
693
+ }
694
+
695
+ function cmdSchema() {
696
+ const dir = join(projectRoot(), "scripts", "plan", "schema");
697
+ const outFiles = {};
698
+ for (const [name, schema] of Object.entries(SCHEMAS)) {
699
+ try { outFiles[`${name}.schema.json`] = z.toJSONSchema(schema, { unrepresentable: "any" }); } catch (e) { err(`${name}: ${e.message}`); }
700
+ }
701
+ if (!opt.write) { out(Object.keys(outFiles).join("\n")); return; }
702
+ mkdirSync(dir, { recursive: true });
703
+ for (const [f, s] of Object.entries(outFiles)) writeFileSync(join(dir, f), JSON.stringify(s, null, 2) + "\n");
704
+ out(`wrote ${Object.keys(outFiles).length} schema files to ${dir}`);
705
+ }
706
+
707
+ /** Prove each refusal can fire. Runs inside npm run check (project convention: every gate has a --selftest). */
708
+ function cmdSelftest() {
709
+ const box = mkdtempSync(join(tmpdir(), "plan-selftest-"));
710
+ process.env.PLAN_PROJECT_ROOT = box;
711
+ mkdirSync(join(box, ".project-management", "plans"), { recursive: true });
712
+ writeFileSync(join(box, "CLAUDE.md"), "# test\n\n## Project management\n\nx\n");
713
+ const dir = join(box, ".project-management", "plans", "t1");
714
+ mkdirSync(dir, { recursive: true });
715
+ const base = () => ({
716
+ state: { id: "t1", title: "selftest plan", status: "active", created: "2026-09-12", activatedAt: "2026-09-12T10:00:00+05:30", closedAt: null, paths: ["x/**"], doneWhen: [{ id: "C1", statement: "the gate ran and passed", kind: "auto:gate" }], tasks: [] },
717
+ gates: { gates: [{ id: "G1", question: "does the selftest gate command exit zero", notTheSameAs: "whether anything real works", command: "true", passWhen: "exit0", timeoutSec: 10, knownFail: { command: "false", description: "false must fail" }, couldPassWhileWrongIf: "the command is a no-op, which it is", kind: "static" }] },
718
+ rules: { rules: [] }, log: [], learnings: [], decisions: [],
719
+ gateRuns: [{ runId: "verify01", gate: "G1", kind: "verify", at: "2026-09-12T10:30:00+05:30", session: null, command: "false", gateCommand: "true", exit: 1, durationMs: 1, result: "pass", tail: "" }],
720
+ planMd: "# t\n\n## Why\nbecause.\n", dir, id: "t1",
721
+ });
722
+ const task = (o) => ({ id: "T1", title: "a selftest task", status: "todo", gate: "G1", manualCheck: null, files: [], effort: null, dependsOn: [], notes: "", startedAt: null, doneAt: null, evidence: null, blocked: null, doneWhen: [], doneChecklist: [], ...o });
723
+ const DONE_AT = "2026-09-12T11:00:00+05:30";
724
+ const answered = { id: "C1", statement: "the gate ran and passed", kind: "auto:gate", answer: "auto: G1 passed (run abc12345)", at: DONE_AT };
725
+ const cases = [
726
+ ["a valid active plan passes", (p) => { p.state.tasks.push(task({})); }, false],
727
+ ["done with no evidence is refused", (p) => { p.state.tasks.push(task({ status: "done", doneAt: "2026-09-12T11:00:00+05:30" })); }, true],
728
+ ["done citing a run that never happened is refused", (p) => { p.state.tasks.push(task({ status: "done", doneAt: "2026-09-12T11:00:00+05:30", evidence: { kind: "gate", gate: "G1", runId: "nope-000000", at: "2026-09-12T11:00:00+05:30" } })); }, true],
729
+ ["done citing a recorded PASS run is accepted", (p) => { p.gateRuns.push({ runId: "abc12345", gate: "G1", kind: "run", at: "2026-09-12T11:00:00+05:30", session: null, command: "true", exit: 0, durationMs: 1, result: "pass", tail: "" }); p.state.tasks.push(task({ status: "done", doneAt: "2026-09-12T11:00:00+05:30", evidence: { kind: "gate", gate: "G1", runId: "abc12345", at: "2026-09-12T11:00:00+05:30" } })); }, false],
730
+ ["done citing a recorded FAIL run is refused", (p) => { p.gateRuns.push({ runId: "abc12345", gate: "G1", kind: "run", at: "2026-09-12T11:00:00+05:30", session: null, command: "true", exit: 1, durationMs: 1, result: "fail", tail: "" }); p.state.tasks.push(task({ status: "done", doneAt: "2026-09-12T11:00:00+05:30", evidence: { kind: "gate", gate: "G1", runId: "abc12345", at: "2026-09-12T11:00:00+05:30" } })); }, true],
731
+ ["a task with neither gate nor manualCheck is refused", (p) => { p.state.tasks.push(task({ gate: null })); }, true],
732
+ ["a task naming an undefined gate is refused", (p) => { p.state.tasks.push(task({ gate: "G9" })); }, true],
733
+ ["a task depending on itself is refused", (p) => { p.state.tasks.push(task({ dependsOn: ["T1"] })); }, true],
734
+ ["a done task whose OWN condition was never answered is refused", (p) => { p.gateRuns.push({ runId: "abc12345", gate: "G1", kind: "run", at: DONE_AT, session: null, command: "true", exit: 0, durationMs: 1, result: "pass", tail: "" }); p.state.tasks.push(task({ status: "done", doneAt: DONE_AT, evidence: { kind: "gate", gate: "G1", runId: "abc12345", at: DONE_AT }, doneWhen: [{ id: "C7", statement: "the verse count matches the plate", kind: "manual" }], doneChecklist: [answered] })); }, true],
735
+ ["a done task with every condition answered is accepted", (p) => { p.gateRuns.push({ runId: "abc12345", gate: "G1", kind: "run", at: DONE_AT, session: null, command: "true", exit: 0, durationMs: 1, result: "pass", tail: "" }); p.state.tasks.push(task({ status: "done", doneAt: DONE_AT, evidence: { kind: "gate", gate: "G1", runId: "abc12345", at: DONE_AT }, doneWhen: [{ id: "C7", statement: "the verse count matches the plate", kind: "manual" }], doneChecklist: [answered, { id: "C7", statement: "the verse count matches the plate", kind: "manual", answer: "counted 52 on plate 17 at 4x", at: DONE_AT }] })); }, false],
736
+ ["a checklist answer under 10 characters is refused", (p) => { p.gateRuns.push({ runId: "abc12345", gate: "G1", kind: "run", at: DONE_AT, session: null, command: "true", exit: 0, durationMs: 1, result: "pass", tail: "" }); p.state.tasks.push(task({ status: "done", doneAt: DONE_AT, evidence: { kind: "gate", gate: "G1", runId: "abc12345", at: DONE_AT }, doneChecklist: [{ ...answered, answer: "yes" }] })); }, true],
737
+ ["checklist answers on a task that is not done are refused", (p) => { p.state.tasks.push(task({ doneChecklist: [answered] })); }, true],
738
+ ["a task may not reuse a plan-level condition id", (p) => { p.state.tasks.push(task({ doneWhen: [{ id: "C1", statement: "something else entirely", kind: "manual" }] })); }, true],
739
+ ["a done citing a gate whose command CHANGED after it was verified is refused (a gate edited to pass must be re-verified)", (p) => { p.gates.gates[0].command = "true # edited so it passes"; p.gateRuns.push({ runId: "abc12345", gate: "G1", kind: "run", at: DONE_AT, session: null, command: "true # edited so it passes", exit: 0, durationMs: 1, result: "pass", tail: "" }); p.state.tasks.push(task({ status: "done", doneAt: DONE_AT, evidence: { kind: "gate", gate: "G1", runId: "abc12345", at: DONE_AT }, doneChecklist: [answered] })); }, true],
740
+ ["a done citing a gate with a known-fail case that was NEVER verified is refused", (p) => { p.gateRuns = []; p.gateRuns.push({ runId: "abc12345", gate: "G1", kind: "run", at: DONE_AT, session: null, command: "true", exit: 0, durationMs: 1, result: "pass", tail: "" }); p.state.tasks.push(task({ status: "done", doneAt: DONE_AT, evidence: { kind: "gate", gate: "G1", runId: "abc12345", at: DONE_AT }, doneChecklist: [answered] })); }, true],
741
+ ["a plan condition added AFTER a task closed is not held against that task", (p) => { p.state.doneWhen.push({ id: "C9", statement: "a condition that arrived later", kind: "manual", since: "2026-09-12T12:00:00+05:30" }); p.gateRuns.push({ runId: "abc12345", gate: "G1", kind: "run", at: DONE_AT, session: null, command: "true", exit: 0, durationMs: 1, result: "pass", tail: "" }); p.state.tasks.push(task({ status: "done", doneAt: DONE_AT, evidence: { kind: "gate", gate: "G1", runId: "abc12345", at: DONE_AT }, doneChecklist: [answered] })); }, false],
742
+ ["a gated task closed by hand by the AGENT is refused (only the owner's word may replace a gate)", (p) => { p.state.tasks.push(task({ status: "done", doneAt: DONE_AT, evidence: { kind: "manual", by: "agent", at: DONE_AT, reason: "I looked at it and it seemed fine to me" }, doneChecklist: [{ ...answered, answer: "auto: manual reason by agent" }] })); }, true],
743
+ ["a task whose gate is a report gate is refused", (p) => { p.gates.gates.push({ ...p.gates.gates[0], id: "G2", kind: "report" }); p.state.tasks.push(task({ gate: "G2" })); }, true],
744
+ ["evidence citing a run made BEFORE the task started is refused", (p) => { p.gateRuns.push({ runId: "abc12345", gate: "G1", kind: "run", at: "2026-09-12T09:00:00+05:30", session: null, command: "true", exit: 0, durationMs: 1, result: "pass", tail: "" }); p.state.tasks.push(task({ status: "done", startedAt: "2026-09-12T10:00:00+05:30", doneAt: DONE_AT, evidence: { kind: "gate", gate: "G1", runId: "abc12345", at: "2026-09-12T09:00:00+05:30" }, doneChecklist: [answered] })); }, true],
745
+ ["evidence whose time differs from the run's is refused", (p) => { p.gateRuns.push({ runId: "abc12345", gate: "G1", kind: "run", at: DONE_AT, session: null, command: "true", exit: 0, durationMs: 1, result: "pass", tail: "" }); p.state.tasks.push(task({ status: "done", doneAt: DONE_AT, evidence: { kind: "gate", gate: "G1", runId: "abc12345", at: "2026-09-12T11:00:01+05:30" }, doneChecklist: [answered] })); }, true],
746
+ ["one run cited by two tasks is refused", (p) => { p.gateRuns.push({ runId: "abc12345", gate: "G1", kind: "run", at: DONE_AT, session: null, command: "true", exit: 0, durationMs: 1, result: "pass", tail: "" }); const ev = { kind: "gate", gate: "G1", runId: "abc12345", at: DONE_AT }; p.state.tasks.push(task({ status: "done", doneAt: DONE_AT, evidence: ev, doneChecklist: [answered] }), task({ id: "T2", status: "done", doneAt: DONE_AT, evidence: ev, doneChecklist: [answered] })); }, true],
747
+ ["an unregistered key is refused", (p) => { p.state.tasks.push(task({ proof: "trust me" })); }, true],
748
+ ["a done plan with an open task is refused", (p) => { p.state.status = "done"; p.state.closedAt = "2026-09-12T12:00:00+05:30"; p.state.tasks.push(task({})); }, true],
749
+ ["a manual close with a short reason is refused", (p) => { p.state.tasks.push(task({ gate: null, manualCheck: "owner looked at the page", status: "done", doneAt: "2026-09-12T11:00:00+05:30", evidence: { kind: "manual", by: "owner", at: "2026-09-12T11:00:00+05:30", reason: "looks fine" } })); }, true],
750
+ ];
751
+ let bad = 0;
752
+ for (const [name, mutate, shouldFail] of cases) {
753
+ const p = base(); mutate(p);
754
+ const { errors } = validatePlan(p);
755
+ const failed = errors.length > 0;
756
+ const okk = failed === shouldFail;
757
+ if (!okk) bad++;
758
+ out(`${okk ? "ok " : "FAIL"} ${name}${!okk ? ` — expected ${shouldFail ? "errors" : "no errors"}, got: ${errors.join("; ") || "none"}` : ""}`);
759
+ }
760
+ // CLAUDE.md block: write, detect, drift.
761
+ const mdPath = join(box, "CLAUDE.md");
762
+ writeBlock(mdPath, [{ id: "t1", title: "selftest plan" }]);
763
+ const ids = idsInBlock(readFileSync(mdPath, "utf8"));
764
+ const okBlock = Array.isArray(ids) && ids.length === 1 && ids[0] === "t1";
765
+ if (!okBlock) bad++;
766
+ out(`${okBlock ? "ok " : "FAIL"} the CLAUDE.md block round-trips`);
767
+ const unchanged = !writeBlock(mdPath, [{ id: "t1", title: "selftest plan" }]);
768
+ if (!unchanged) bad++;
769
+ out(`${unchanged ? "ok " : "FAIL"} writing the same block twice changes nothing`);
770
+ // a state.json from before the conditions of done existed loads with the defaults (the trial session crashed on this on 2026-09-12)
771
+ {
772
+ const oldBox = mkdtempSync(join(tmpdir(), "plan-old-"));
773
+ const oldDir = join(oldBox, ".project-management", "plans", "old"); mkdirSync(oldDir, { recursive: true });
774
+ const p0 = base(); p0.state.tasks.push(task({})); delete p0.state.doneWhen; for (const t of p0.state.tasks) { delete t.doneWhen; delete t.doneChecklist; }
775
+ p0.state.id = "old"; writeFileSync(join(oldDir, FILES.state), JSON.stringify(p0.state)); writeFileSync(join(oldDir, FILES.gates), JSON.stringify(p0.gates)); writeFileSync(join(oldDir, FILES.rules), JSON.stringify(p0.rules)); writeFileSync(join(oldDir, "PLAN.md"), "# old\n");
776
+ const self = new URL(import.meta.url).pathname; const envOld = { ...process.env, PLAN_PROJECT_ROOT: oldBox };
777
+ const r = spawnSync("node", [self, "task", "check", "old", "T1"], { env: envOld, encoding: "utf8" });
778
+ const okOld = r.status === 0 && r.stdout.includes("C7") && r.stdout.includes("could pass while wrong if") && r.stdout.includes("JUDGMENT OUTRANKS THE GATE");
779
+ if (!okOld) bad++;
780
+ out(`${okOld ? "ok " : "FAIL"} a state.json written before the conditions existed loads with the seven defaults, and task check prints the gate's blind spots and the judgment text (exit ${r.status})`);
781
+ // condition add picks an id above every id used anywhere, and task check shows it
782
+ const r2 = spawnSync("node", [self, "condition", "add", "old", "--statement", "the verse count matches the plate, not the OCR"], { env: envOld, encoding: "utf8" });
783
+ const r3 = spawnSync("node", [self, "task", "check", "old", "T1"], { env: envOld, encoding: "utf8" });
784
+ const okCond = r2.status === 0 && r2.stdout.includes("C8 added") && r3.stdout.includes("C8 [manual] the verse count matches the plate");
785
+ if (!okCond) bad++;
786
+ out(`${okCond ? "ok " : "FAIL"} condition add assigns the next free id (C8) and task check lists it (exit ${r2.status})`);
787
+ }
788
+ // globs: ** crosses dot-directories; * stays in a segment; relative paths resolve against cwd
789
+ const globOk = pathMatches(".tmp/witness-full/x/ch01.json", "**/ch*.json") && pathMatches("research/translations/bphs/ch24.json", "research/translations/**/ch*.json")
790
+ && !pathMatches("research/translations/bphs/notes/ch24.json", "research/translations/*/ch*.json") && pathMatches("a/b.ts", "a/{b,c}.ts") && !pathMatches("a/d.ts", "a/{b,c}.ts")
791
+ && toRepoRelative("src/x.ts", "/root", "/root/sub") === "sub/src/x.ts" && toRepoRelative("/elsewhere/x.ts", "/root") === null
792
+ && ruleMatches({ when: { tool: "Skill", prompt: "plan" } }, { toolName: "Skill", toolInput: { skill: "plan", args: "x" }, root: "/root" });
793
+ if (!globOk) bad++;
794
+ out(`${globOk ? "ok " : "FAIL"} glob matching crosses dot-directories, keeps * in a segment, resolves relative paths against cwd, and sees Skill inputs`);
795
+ // the CLAUDE.md writer never $-expands a title and collapses a duplicated block
796
+ const mdPath2 = join(box, "CLAUDE2.md"); writeFileSync(mdPath2, "# t\n");
797
+ writeBlock(mdPath2, [{ id: "t1", title: "costs $& and $1 dollars" }]);
798
+ const md2 = readFileSync(mdPath2, "utf8");
799
+ const dup = md2 + "\n" + readBlock(md2) + "\n"; writeFileSync(mdPath2, dup);
800
+ writeBlock(mdPath2, [{ id: "t1", title: "costs $& and $1 dollars" }]);
801
+ const md3 = readFileSync(mdPath2, "utf8");
802
+ const okMd = md2.includes("costs $& and $1 dollars") && blockCount(md3) === 1;
803
+ if (!okMd) bad++;
804
+ out(`${okMd ? "ok " : "FAIL"} the CLAUDE.md writer keeps '$&' in a title and collapses a duplicated block (${blockCount(md3)} block)`);
805
+ // brief stays under the cap on a plan with a long log
806
+ const p = base(); p.state.tasks.push(task({ status: "doing", startedAt: "2026-09-12T10:00:00+05:30" }));
807
+ for (let i = 0; i < 50; i++) p.log.push({ at: "2026-09-12T10:00:00+05:30", session: null, task: "T1", what: "x".repeat(300), next: "y".repeat(300), refs: [], uncommitted: null });
808
+ const b = renderBrief(p);
809
+ const okBrief = b.length <= 6000 && b.includes("RESUME:") && b.includes("NOW T1");
810
+ if (!okBrief) bad++;
811
+ out(`${okBrief ? "ok " : "FAIL"} the brief renders RESUME and NOW and stays under 6000 chars (${b.length})`);
812
+ // agent brief: carries the task's files, the rule for those files, done-means, the report path and the do-nots; never the whole plan
813
+ const pa = base(); pa.state.tasks.push(task({ files: ["x/one.ts"], effort: "max", notes: "watch the encoding" }));
814
+ pa.rules.rules.push({ id: "R1", when: { tool: "Edit|Write", path: "x/**/*.ts" }, text: "AGENT-RULE-MARKER keep it pure", repeat: "once", why: "selftest", learning: null });
815
+ pa.rules.rules.push({ id: "R2", when: { tool: "Edit|Write", path: "y/**" }, text: "OTHER-RULE-MARKER", repeat: "once", why: "selftest", learning: null });
816
+ const ab = renderAgentBrief(pa, pa.state.tasks[0], { unit: "read leaf 12" });
817
+ const okAgent = ab.length <= 3000 && ab.includes("AGENT-RULE-MARKER") && !ab.includes("OTHER-RULE-MARKER") && ab.includes("x/one.ts") && ab.includes("reports/T1-read-leaf-12.md") && ab.includes("NEVER:") && ab.includes("G1") && !ab.includes("RESUME:");
818
+ if (!okAgent) bad++;
819
+ out(`${okAgent ? "ok " : "FAIL"} the agent brief carries only the task's files, its matching rule, done-means, report path and do-nots (${ab.length} chars)`);
820
+ // two doing tasks writing the same file → warning
821
+ const po = base(); po.state.tasks.push(task({ status: "doing", startedAt: "2026-09-12T10:00:00+05:30", files: ["x/shared.ts"] }), task({ id: "T2", status: "doing", startedAt: "2026-09-12T10:00:00+05:30", files: ["x/shared.ts"] }));
822
+ const okOverlap = validatePlan(po).warnings.some((w) => w.includes("one writer per file"));
823
+ if (!okOverlap) bad++;
824
+ out(`${okOverlap ? "ok " : "FAIL"} two tasks in flight on the same file draw a one-writer warning`);
825
+ out(bad ? `\nselftest: ${bad} FAILED` : "\nselftest: every refusal can fire, and valid plans pass");
826
+ process.exit(bad ? 1 : 0);
827
+ }
828
+
829
+ function help() {
830
+ const lines = readFileSync(new URL(import.meta.url), "utf8").split("\n");
831
+ const end = lines.findIndex((l) => l.trim() === "*/");
832
+ out(lines.slice(2, end).map((l) => l.replace(/^ \* ?/, "")).join("\n"));
833
+ }
834
+
835
+ const cmd = pos[0];
836
+ const table = { init: cmdInit, update: cmdUpdate, uninstall: cmdUninstall, issue: cmdIssue, version: cmdVersion, new: cmdNew, list: cmdList, "agent-brief": cmdAgentBrief, review: cmdReview, run: cmdRun, condition: cmdCondition, validate: cmdValidate, brief: cmdBrief, status: cmdStatus, task: cmdTask, log: cmdLog, learn: cmdLearn, decide: cmdDecide, gate: cmdGate, activate: cmdActivate, pause: cmdPause, close: cmdClose, abandon: cmdAbandon, learnings: cmdLearnings, hooks: cmdHooks, doctor: cmdDoctor, schema: cmdSchema, selftest: cmdSelftest };
837
+ if (opt.version) { cmdVersion(); process.exit(0); }
838
+ if (!cmd || opt.help || !table[cmd]) { help(); process.exit(cmd && !table[cmd] ? 2 : 0); }
839
+ try { await table[cmd](); }
840
+ catch (e) {
841
+ if (e instanceof CliExit) { err(`plan: ${e.message}`); process.exit(e.code); }
842
+ throw e;
843
+ }