@dougharness/cli 1.1.0 → 1.2.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 (59) hide show
  1. package/dist/bin.js +10 -3
  2. package/dist/bin.js.map +1 -1
  3. package/dist/board-serve.js +45 -10
  4. package/dist/board-serve.js.map +1 -1
  5. package/dist/board.d.ts +1 -1
  6. package/dist/board.js +33 -8
  7. package/dist/board.js.map +1 -1
  8. package/dist/init.js +1 -1
  9. package/dist/init.js.map +1 -1
  10. package/dist/memory-setup.d.ts +7 -0
  11. package/dist/memory-setup.js +75 -48
  12. package/dist/memory-setup.js.map +1 -1
  13. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +6 -2
  14. package/node_modules/@dougharness/flow/agents/coder.md +23 -0
  15. package/node_modules/@dougharness/flow/agents/lead.md +1 -1
  16. package/node_modules/@dougharness/flow/agents/planner.md +2 -2
  17. package/node_modules/@dougharness/flow/agents/spec-writer.md +23 -0
  18. package/node_modules/@dougharness/flow/agents/tester.md +21 -0
  19. package/node_modules/@dougharness/flow/hooks/hooks.json +4 -1
  20. package/node_modules/@dougharness/flow/hooks/register.tsx +75 -0
  21. package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +117 -0
  22. package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +17 -0
  23. package/node_modules/@dougharness/flow/lib/board.mjs +107 -10
  24. package/node_modules/@dougharness/flow/lib/card-land.mjs +115 -0
  25. package/node_modules/@dougharness/flow/lib/card-loop.mjs +746 -0
  26. package/node_modules/@dougharness/flow/lib/coordinator.mjs +368 -0
  27. package/node_modules/@dougharness/flow/lib/cost.mjs +44 -11
  28. package/node_modules/@dougharness/flow/lib/embeddings.mjs +147 -4
  29. package/node_modules/@dougharness/flow/lib/land.mjs +33 -9
  30. package/node_modules/@dougharness/flow/lib/memory.mjs +96 -33
  31. package/node_modules/@dougharness/flow/lib/plan.mjs +34 -5
  32. package/node_modules/@dougharness/flow/lib/worktrees.mjs +3 -3
  33. package/node_modules/@dougharness/flow/package.json +1 -1
  34. package/node_modules/@dougharness/flow/scripts/autopilot.mjs +407 -0
  35. package/node_modules/@dougharness/flow/scripts/board.mjs +128 -32
  36. package/node_modules/@dougharness/flow/scripts/context.mjs +4 -2
  37. package/node_modules/@dougharness/flow/scripts/cost.mjs +16 -3
  38. package/node_modules/@dougharness/flow/scripts/memory.mjs +29 -14
  39. package/node_modules/@dougharness/flow/scripts/plan.mjs +122 -30
  40. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +10 -4
  41. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +34 -12
  42. package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +1 -1
  43. package/node_modules/@dougharness/flow/skills/doug-start/SKILL.md +80 -0
  44. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +2 -2
  45. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +12 -10
  46. package/node_modules/@dougharness/flow/workflows/card-queen.js +807 -0
  47. package/node_modules/@dougharness/flow/workflows/doug-implement.js +33 -21
  48. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  49. package/node_modules/@dougharness/gates/lib/anchor.mjs +29 -4
  50. package/node_modules/@dougharness/gates/lib/bash-rules.mjs +217 -20
  51. package/node_modules/@dougharness/gates/lib/gate-lock.mjs +126 -0
  52. package/node_modules/@dougharness/gates/lib/proposals.mjs +41 -5
  53. package/node_modules/@dougharness/gates/lib/scope.mjs +43 -4
  54. package/node_modules/@dougharness/gates/package.json +1 -1
  55. package/node_modules/@dougharness/gates/scripts/reanchor.mjs +2 -1
  56. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +148 -97
  57. package/node_modules/@dougharness/gates/scripts/verify.mjs +64 -5
  58. package/package.json +4 -4
  59. package/templates/board-app.js +48 -1
@@ -7,11 +7,13 @@
7
7
  // the first n runnable Ready cards, in board order, as a JSON array
8
8
  // (exit 1 if none): the cards /doug-next plans together; stderr also
9
9
  // carries a `wall cap: <m> min` line (the cards' budgets from the size x class table)
10
- // board.mjs budgets [dir] [--from <live-runs file>]
10
+ // and a `usd cap: $<x>` line after it (the cards' budgets from the usd table)
11
+ // board.mjs budgets [dir] [--usd] [--from <live-runs file>]
11
12
  // without --from, the shipped wall budget table as `table: <JSON>`;
12
13
  // with --from, recomputes it (p80 per size/class cell) from the file's
13
14
  // Wall clock rows joined to the board, one line per cell and a final
14
- // `table:` line; writes nothing (replace WALL_BUDGET_MIN by hand)
15
+ // `table:` line; writes nothing (replace WALL_BUDGET_MIN by hand). --usd does the same for the usd
16
+ // table (USD_BUDGET) from the Cost rows of flow and hand entries
15
17
  // board.mjs card <id> [dir] one card as JSON
16
18
  // board.mjs reorder <id> [dir] --before <other> | --after <other> | --top | --bottom
17
19
  // reorder a card within its column
@@ -27,33 +29,35 @@
27
29
  // flow, or named by .doug/plan.json (each with --force removes it
28
30
  // and prints a warning to stderr); prints the removed card as JSON
29
31
  // to stdout so a mistaken removal can be re-added by hand
30
- // board.mjs record <id> <report.json> [dir] [--cost <usd>] [--codex-cost <usd>] [--shared-cost <usd>] [--wall <text>] [--commit <sha>] [--adversary "<id>=<class>[: <reason>]"]... [--note <text>] [--rehearsal <scenario>] [--run <run-id>] [--claude-dir <dir>]
32
+ // board.mjs record <id> <report.json> [dir] --summary "<one sentence>" [--cost <usd>] [--codex-cost <usd>] [--shared-cost <usd>] [--wall <text>] [--commit <sha>] [--adversary "<id>=<class>[: <reason>]"]... [--note <text>] [--rehearsal <scenario> --rehearsal-outcome <passed|failed>] [--run <run-id>] [--claude-dir <dir>]
31
33
  // append a run entry for the card to docs/live-runs.md; every adversary block
32
34
  // in the report is classified real, marginal, or false (repeat the flag per block).
33
35
  // When .doug/plan.json lists the card among its `cards` (a batch), the entry
34
36
  // shows only that card's tasks, --cost is that card's tasks' cost, and
35
37
  // --shared-cost <usd> the shared integration agents', counted once
36
- // board.mjs record <id> --hand [dir] [--commit <sha>] [--wall <text>] [--gate <text>] [--note <text>] [--rehearsal <scenario>]
38
+ // board.mjs record <id> --hand [dir] --summary "<one sentence>" [--commit <sha>] [--wall <text>] [--gate <text>] [--note <text>] [--rehearsal <scenario> --rehearsal-outcome <passed|failed>] [--cost <usd>]
37
39
  // append a hand-track entry (no report) for the card
38
- // --rehearsal <scenario> (either form) marks the entry as a rehearse.mjs run on the ts-basic fixture: the first
40
+ // --rehearsal <scenario> (either form) marks the entry as a rehearse.mjs run on the ts-basic fixture (the start scenario: an empty git repo): the first
39
41
  // prose line and the commit sha (abbreviated to 7 characters) read as the fixture's, not this repository's.
42
+ // --rehearsal-outcome <passed|failed> is required with --rehearsal (and refused without it): the entry's Outcome reads
43
+ // "rehearsal <scenario> <outcome>", never "landed", and a rehearsal record writes no landing-block files (it is no landing).
40
44
  // Either record form also promotes .doug/.state/research/<id>.md to docs/research/<id>.md when that note
41
45
  // exists and differs from the docs copy, copying it and printing one line saying so; a card with no note, or
42
46
  // whose docs copy is already byte-identical, is untouched, silently.
43
- // board.mjs summary <report.json> [--cost <usd>] [--codex-cost <usd>] [--wall <text>] [--commit <sha>] [--card <id>] [--run <run-id>] [--claude-dir <dir>]
47
+ // board.mjs summary <report.json> --summary "<one sentence>" [--cost <usd>] [--codex-cost <usd>] [--wall <text>] [--commit <sha>] [--card <id>] [--run <run-id>] [--claude-dir <dir>] [--note <text>]
44
48
  // the three-line chat summary of a report (--card: that card's tasks only)
45
49
 
46
- import { readFileSync } from "node:fs";
47
- import { relative, resolve } from "node:path";
48
- import { loadBoard, saveBoard, findCard, nextReadyCard, nextReadyCards, moveCard, editCard, removeCard, reorderCard, readyCapWarning, wallCapMinutes, WALL_BUDGET_MIN, WALL_BUDGET_MIN_N, parseLiveRunWalls, wallBudgetTable, runEntry, handEntry, runSummary, boardPath, parseAdversaryClasses, filterReportForCard, recordLanding } from "../lib/board.mjs";
49
- import { loadPlan, unwrapReport, DEFAULT_BUDGET } from "../lib/plan.mjs";
50
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
51
+ import { join, relative, resolve } from "node:path";
52
+ import { loadBoard, saveBoard, findCard, nextReadyCard, nextReadyCards, moveCard, editCard, removeCard, reorderCard, readyCapWarning, wallCapMinutes, WALL_BUDGET_MIN, WALL_BUDGET_MIN_N, parseLiveRunWalls, wallBudgetTable, USD_BUDGET, USD_BUDGET_MIN_N, usdCapDollars, parseLiveRunCosts, usdBudgetTable, runEntry, handEntry, handWallVerdict, runSummary, boardPath, parseAdversaryClasses, filterReportForCard, recordLanding } from "../lib/board.mjs";
53
+ import { loadPlan, planFor, unwrapReport, DEFAULT_BUDGET } from "../lib/plan.mjs";
50
54
  import { wallForRun } from "../lib/cost.mjs";
51
55
 
52
56
  const EDIT_FIELD_ORDER = ["title", "goal", "size", "component", "track", "class", "tags", "deps", "source"];
53
57
  const EDIT_USAGE = "usage: board.mjs edit <id> [dir] [--title <t>] [--goal <g> | --goal-file <path>] [--size S|M|L] [--component <c>] [--track flow|hand] [--class <c>] [--tag a,b] [--deps a,b] [--source <s>] [--force]";
54
58
 
55
59
  const argv = process.argv.slice(2);
56
- const FLAGS = new Set(["hand", "top", "bottom", "force"]);
60
+ const FLAGS = new Set(["hand", "top", "bottom", "force", "usd"]);
57
61
  const REPEATABLE = new Set(["adversary"]);
58
62
 
59
63
  // Each subcommand's allowed flags, derived from what that case actually reads (card board-cli-unknown-flags).
@@ -65,11 +69,11 @@ const SUBCOMMAND_FLAGS = {
65
69
  edit: ["title", "goal", "goal-file", "size", "component", "track", "class", "deps", "tag", "source", "force"],
66
70
  remove: ["force"],
67
71
  reorder: ["before", "after", "top", "bottom"],
68
- budgets: ["from"],
69
- summary: ["cost", "codex-cost", "wall", "commit", "card", "run", "claude-dir"],
72
+ budgets: ["from", "usd"],
73
+ summary: ["summary", "cost", "codex-cost", "wall", "commit", "card", "run", "claude-dir", "note"],
70
74
  };
71
- const RECORD_HAND_FLAGS = ["hand", "commit", "wall", "gate", "note", "rehearsal"];
72
- const RECORD_REPORT_FLAGS = ["cost", "codex-cost", "wall", "commit", "adversary", "shared-cost", "note", "rehearsal", "run", "claude-dir"];
75
+ const RECORD_HAND_FLAGS = ["summary", "hand", "commit", "wall", "gate", "note", "rehearsal", "rehearsal-outcome", "cost"];
76
+ const RECORD_REPORT_FLAGS = ["summary", "cost", "codex-cost", "wall", "commit", "adversary", "shared-cost", "note", "rehearsal", "rehearsal-outcome", "run", "claude-dir"];
73
77
  const opts = {};
74
78
  const positional = [];
75
79
  for (let i = 0; i < argv.length; i++) {
@@ -100,11 +104,11 @@ const USAGE_FOR = {
100
104
  edit: EDIT_USAGE,
101
105
  remove: "usage: board.mjs remove <id> [dir] [--force]",
102
106
  reorder: "usage: board.mjs reorder <id> [dir] --before <other> | --after <other> | --top | --bottom",
103
- budgets: "usage: board.mjs budgets [dir] [--from <live-runs file>]",
104
- summary: "usage: board.mjs summary <report.json> [--cost <usd>] [--codex-cost <usd>] [--wall <text>] [--commit <sha>] [--card <id>] [--run <run-id>] [--claude-dir <dir>]",
107
+ budgets: "usage: board.mjs budgets [dir] [--usd] [--from <live-runs file>]",
108
+ summary: "usage: board.mjs summary <report.json> --summary \"<one sentence>\" [--cost <usd>] [--codex-cost <usd>] [--wall <text>] [--commit <sha>] [--card <id>] [--run <run-id>] [--claude-dir <dir>] [--note <text>]",
105
109
  };
106
- const RECORD_HAND_USAGE = "usage: board.mjs record <id> --hand [dir] [--commit <sha>] [--wall <text>] [--gate <text>] [--note <text>] [--rehearsal <scenario>]";
107
- const RECORD_REPORT_USAGE = "usage: board.mjs record <id> <report.json> [dir] [--cost <usd>] [--codex-cost <usd>] [--shared-cost <usd>] [--wall <text>] [--commit <sha>] [--adversary \"<id>=<class>[: <reason>]\"]... [--note <text>] [--rehearsal <scenario>] [--run <run-id>] [--claude-dir <dir>]";
110
+ const RECORD_HAND_USAGE = "usage: board.mjs record <id> --hand [dir] --summary \"<one sentence>\" [--commit <sha>] [--wall <text>] [--gate <text>] [--note <text>] [--rehearsal <scenario> --rehearsal-outcome <passed|failed>] [--cost <usd>]";
111
+ const RECORD_REPORT_USAGE = "usage: board.mjs record <id> <report.json> [dir] --summary \"<one sentence>\" [--cost <usd>] [--codex-cost <usd>] [--shared-cost <usd>] [--wall <text>] [--commit <sha>] [--adversary \"<id>=<class>[: <reason>]\"]... [--note <text>] [--rehearsal <scenario> --rehearsal-outcome <passed|failed>] [--run <run-id>] [--claude-dir <dir>]";
108
112
 
109
113
  // An unknown --flag for the given subcommand: exit 2, name the flag and the subcommand's usage.
110
114
  // Runs on flag names only (parse order doesn't matter), before any work or write.
@@ -128,6 +132,62 @@ if (cmd === "record" || Object.prototype.hasOwnProperty.call(SUBCOMMAND_FLAGS, c
128
132
  }
129
133
  }
130
134
 
135
+ // --summary: the lead's one sentence of what changed for the user, required by record and summary (card
136
+ // landing-one-sentence-summary). Trimmed, non-empty, one line, no sentence end followed by more text.
137
+ // Checked before anything is appended or promoted; printed as stdout's first line, never written to a file.
138
+ let summarySentence = null;
139
+ if (cmd === "record" || cmd === "summary") {
140
+ const text = typeof opts.summary === "string" ? opts.summary.trim() : "";
141
+ if (!text) fail(`--summary "<one sentence>" is required for board.mjs ${cmd}: one sentence, in plain words, of what changed for the user${cmd === "record" ? `\n${opts.hand ? RECORD_HAND_USAGE : RECORD_REPORT_USAGE}` : ""}`, 2);
142
+ if (/[\r\n]/.test(text) || /[.!?]\s+\S/.test(text)) fail(`--summary must be one sentence for board.mjs ${cmd}: no newline, and no sentence end followed by more text`, 2);
143
+ summarySentence = text;
144
+ }
145
+
146
+ // --rehearsal-outcome (card rehearsal-failure-not-landed): required with --rehearsal, refused without it; checked before any write.
147
+ if (cmd === "record") {
148
+ const ro = opts["rehearsal-outcome"];
149
+ if (opts.rehearsal !== undefined && ro !== "passed" && ro !== "failed") fail(`--rehearsal needs --rehearsal-outcome passed|failed, got ${ro === undefined ? "none" : JSON.stringify(ro)}\n${opts.hand ? RECORD_HAND_USAGE : RECORD_REPORT_USAGE}`, 2);
150
+ if (opts.rehearsal === undefined && ro !== undefined) fail(`--rehearsal-outcome is only valid with --rehearsal\n${opts.hand ? RECORD_HAND_USAGE : RECORD_REPORT_USAGE}`, 2);
151
+ }
152
+
153
+ // The landing block (card landing-block-enforced): three lines (Changed / Outcome / Learned), at most 100 words,
154
+ // printed last by record and summary, kept under .doug/.state/reports/<id>/ for the Stop gate to check against the
155
+ // lead's final message. Built and checked before anything is written, so a refusal leaves no trace.
156
+ const LANDING_BLOCK_WORDS = 100;
157
+ function landingBlock({ id, ok, commit, wall, gate, cost, codexCost, note, rehearsal = null, rehearsalOutcome = null }) {
158
+ const money = (v) => `$${Number(v).toFixed(2)}`;
159
+ const costText = cost !== undefined && cost !== null ? `${money(cost)}${codexCost !== undefined && codexCost !== null ? ` + Codex ${money(codexCost)}` : ""}` : codexCost !== undefined && codexCost !== null ? `Codex ${money(codexCost)}` : "";
160
+ const verdict = rehearsal ? `rehearsal ${rehearsal} ${rehearsalOutcome}` : `${ok ? "landed" : "stopped"} ${id}${commit ? ` at ${commit}` : ""}`;
161
+ const outcome = [verdict, wall, gate, costText].filter(Boolean).join(", ");
162
+ const learned = typeof note === "string" && note.trim() ? note.trim().replace(/\s+/g, " ") : "nothing new";
163
+ const lines = [`Changed: ${summarySentence}`, `Outcome: ${outcome}`, `Learned: ${learned}`];
164
+ const words = lines.join("\n").split(/\s+/).filter(Boolean).length;
165
+ if (words > LANDING_BLOCK_WORDS) fail(`landing block is ${words} words; the limit is ${LANDING_BLOCK_WORDS}. Shorten --summary or --note.`);
166
+ return lines;
167
+ }
168
+ // The pending marker holds the session that landed, so only that session's Stop checks it (empty when unset).
169
+ function arm(d) {
170
+ writeFileSync(join(d, "landing-block.pending"), process.env.CLAUDE_CODE_SESSION_ID || "");
171
+ }
172
+ function writeLandingBlock(dir, id, lines, keepFile = false, printOnly = false) {
173
+ // The block files only arm the Stop gate's check; a state dir that cannot be written must not fail a landing that
174
+ // is already recorded, so the block still prints and the gate simply has nothing to check.
175
+ if (!printOnly) try {
176
+ const d = join(dir, ".doug/.state/reports", id);
177
+ mkdirSync(d, { recursive: true });
178
+ if (!keepFile) writeFileSync(join(d, "landing-block.txt"), lines.join("\n") + "\n");
179
+ arm(d);
180
+ } catch (e) {
181
+ process.stderr.write(`warning: landing block not saved for the Stop gate: ${e.message}\n`);
182
+ }
183
+ process.stdout.write(lines.join("\n") + "\n");
184
+ }
185
+ function acceptanceGate(report) {
186
+ const levels = (report.levels || []).filter((l) => l && l.integration);
187
+ const acc = levels.length && Array.isArray(levels[levels.length - 1].integration.acceptance) ? levels[levels.length - 1].integration.acceptance : [];
188
+ return `acceptance ${acc.filter((a) => a && a.ok).length}/${acc.length}`;
189
+ }
190
+
131
191
  try {
132
192
  switch (cmd) {
133
193
  case "next": {
@@ -144,12 +204,14 @@ try {
144
204
  if (capLine) process.stderr.write(capLine);
145
205
  if (batch === null ? !card : !cards.length) fail(track === "hand" ? "No Ready hand-track card whose dependencies are Done." : "No Ready card whose dependencies are Done.");
146
206
  if (batch !== null) process.stderr.write(`wall cap: ${wallCapMinutes(cards)} min (size x class table at p80, board.mjs budgets; no size counts as M)\n`);
207
+ if (batch !== null) process.stderr.write(`usd cap: $${usdCapDollars(cards).toFixed(2)} (size x class table at p80 of measured cost, board.mjs budgets --usd; no size counts as M)\n`);
147
208
  process.stdout.write(JSON.stringify(batch === null ? card : cards, null, 2) + "\n");
148
209
  break;
149
210
  }
150
211
  case "budgets": {
212
+ const usd = opts.usd === true;
151
213
  if (opts.from === undefined) {
152
- process.stdout.write(`table: ${JSON.stringify(WALL_BUDGET_MIN)}\n`);
214
+ process.stdout.write(`table: ${JSON.stringify(usd ? USD_BUDGET : WALL_BUDGET_MIN)}\n`);
153
215
  break;
154
216
  }
155
217
  const dir = dirFrom(rest, 0);
@@ -159,12 +221,15 @@ try {
159
221
  } catch (e) {
160
222
  fail(`cannot read --from file ${opts.from}: ${e.message}`, 2);
161
223
  }
162
- const { table, cells } = wallBudgetTable(parseLiveRunWalls(text), loadBoard(dir));
224
+ const { table, cells } = usd ? usdBudgetTable(parseLiveRunCosts(text), loadBoard(dir)) : wallBudgetTable(parseLiveRunWalls(text), loadBoard(dir));
225
+ const shown = (v) => (usd ? `$${v.toFixed(2)}` : String(v));
226
+ const shipped = usd ? USD_BUDGET : WALL_BUDGET_MIN;
227
+ const minN = usd ? USD_BUDGET_MIN_N : WALL_BUDGET_MIN_N;
163
228
  const out = [];
164
229
  for (const size of ["S", "M", "L"]) {
165
230
  for (const c of cells.filter((x) => x.size === size)) {
166
- if (c.cls === "all") out.push(c.n === 0 ? `${size}/all n=0 (no landings; keeps the shipped ${WALL_BUDGET_MIN[size].all})` : `${size}/all n=${c.n} p80 ${c.p80}`);
167
- else out.push(`${size}/${c.cls} n=${c.n} p80 ${c.p80}${c.n < WALL_BUDGET_MIN_N ? ` (fewer than ${WALL_BUDGET_MIN_N}; falls back to ${size}/all ${table[size].all})` : ""}`);
231
+ if (c.cls === "all") out.push(c.n === 0 ? `${size}/all n=0 (no landings; keeps the shipped ${shown(shipped[size].all)})` : `${size}/all n=${c.n} p80 ${shown(c.p80)}`);
232
+ else out.push(`${size}/${c.cls} n=${c.n} p80 ${shown(c.p80)}${c.n < minN ? ` (fewer than ${minN}; falls back to ${size}/all ${shown(table[size].all)})` : ""}`);
168
233
  }
169
234
  }
170
235
  out.push(`table: ${JSON.stringify(table)}`);
@@ -180,8 +245,16 @@ try {
180
245
  const [id, column] = rest;
181
246
  if (!id || !column) fail("usage: board.mjs move <id> <column> [dir] [--source <text>]", 2);
182
247
  const dir = dirFrom(rest, 2);
183
- saveBoard(dir, moveCard(loadBoard(dir), id, column, { source: opts.source }));
248
+ const moved = moveCard(loadBoard(dir), id, column, { source: opts.source });
249
+ saveBoard(dir, moved);
184
250
  process.stdout.write(`Moved ${id} to ${column} in ${relative(dir, boardPath(dir))}.\n`);
251
+ // A card moved to done re-arms the Stop gate's landing-block check, only when record left a block (never creates one).
252
+ if (column === "done" && /^[a-z0-9._-]+$/.test(id)) {
253
+ const d = join(dir, ".doug/.state/reports", id);
254
+ if (existsSync(join(d, "landing-block.txt"))) arm(d);
255
+ }
256
+ const capLine = column === "ready" ? readyCapWarning(moved) : null;
257
+ if (capLine) process.stderr.write(capLine);
185
258
  break;
186
259
  }
187
260
  case "edit": {
@@ -252,44 +325,67 @@ try {
252
325
  case "record": {
253
326
  if (opts.hand) {
254
327
  const [id] = rest;
255
- if (!id) fail("usage: board.mjs record <id> --hand [dir] [--commit <sha>] [--wall <text>] [--gate <text>] [--note <text>] [--rehearsal <scenario>]", 2);
328
+ if (!id) fail("usage: board.mjs record <id> --hand [dir] --summary \"<one sentence>\" [--commit <sha>] [--wall <text>] [--gate <text>] [--note <text>] [--rehearsal <scenario> --rehearsal-outcome <passed|failed>] [--cost <usd>]", 2);
329
+ if (opts.cost !== undefined && !/^\d+(\.\d+)?$/.test(opts.cost)) fail(`--cost takes a non-negative number of dollars, got ${JSON.stringify(opts.cost)}`, 2);
256
330
  const dir = dirFrom(rest, 1);
257
331
  const card = findCard(loadBoard(dir), id);
258
- const entry = handEntry({ card, commit: opts.commit ?? null, wallClock: opts.wall ?? null, gate: opts.gate ?? null, note: opts.note ?? null, record: relative(dir, boardPath(dir)), rehearsal: opts.rehearsal ?? null });
332
+ const entry = handEntry({ card, commit: opts.commit ?? null, wallClock: opts.wall ?? null, gate: opts.gate ?? null, note: opts.note ?? null, record: relative(dir, boardPath(dir)), rehearsal: opts.rehearsal ?? null, rehearsalOutcome: opts["rehearsal-outcome"] ?? null, cost: opts.cost !== undefined ? Number(opts.cost) : null });
333
+ const block = landingBlock({ id, ok: true, commit: opts.commit, wall: opts.wall, gate: opts.gate, cost: opts.cost, note: opts.note, rehearsal: opts.rehearsal ?? null, rehearsalOutcome: opts["rehearsal-outcome"] ?? null });
259
334
  const { file, promoted } = recordLanding(dir, id, entry);
335
+ process.stdout.write(`${summarySentence}\n`);
336
+ if (!opts.rehearsal) {
337
+ const verdict = handWallVerdict(card, opts.wall ?? null);
338
+ if (verdict.over) process.stdout.write(`Wall budget: ${verdict.text}\n`);
339
+ }
260
340
  if (promoted) process.stdout.write(`Promoted the research note for ${id} to ${promoted}.\n`);
261
341
  process.stdout.write(`Appended a hand-track entry for ${id} to ${file}.\n`);
342
+ writeLandingBlock(dir, id, block, false, !!opts.rehearsal);
262
343
  break;
263
344
  }
264
345
  const [id, reportFile] = rest;
265
- if (!id || !reportFile) fail("usage: board.mjs record <id> <report.json> [dir] [--cost <usd>] [--codex-cost <usd>] [--shared-cost <usd>] [--wall <text>] [--commit <sha>] [--adversary \"<id>=<class>[: <reason>]\"]... [--note <text>] [--rehearsal <scenario>] [--run <run-id>] [--claude-dir <dir>]", 2);
346
+ if (!id || !reportFile) fail("usage: board.mjs record <id> <report.json> [dir] --summary \"<one sentence>\" [--cost <usd>] [--codex-cost <usd>] [--shared-cost <usd>] [--wall <text>] [--commit <sha>] [--adversary \"<id>=<class>[: <reason>]\"]... [--note <text>] [--rehearsal <scenario> --rehearsal-outcome <passed|failed>] [--run <run-id>] [--claude-dir <dir>]", 2);
266
347
  const dir = dirFrom(rest, 2);
267
348
  const card = findCard(loadBoard(dir), id);
268
349
  const report = unwrapReport(JSON.parse(readFileSync(resolve(reportFile), "utf8")));
269
350
  // A batch plan (its `cards` list this card) makes the entry that card's part of the run.
270
- const plan = loadPlan(dir);
351
+ const plan = planFor(dir, id);
271
352
  const batch = plan && Array.isArray(plan.cards) && plan.cards.includes(id) ? plan.cards : null;
272
353
  const taskIds = batch ? plan.tasks.filter((t) => t.card === id).map((t) => t.id) : [];
273
354
  const wall = opts.run ? wallForRun({ runId: opts.run, claudeDir: opts["claude-dir"], projectDir: dir, wallMinutes: report.budget?.wallMinutes ?? DEFAULT_BUDGET.wallMinutes }) : null;
274
- const entry = runEntry({ card, report, wall, cost: opts.cost !== undefined ? Number(opts.cost) : null, codexCost: opts["codex-cost"] !== undefined ? Number(opts["codex-cost"]) : null, wallClock: opts.wall ?? null, mergeCommit: opts.commit ?? null, record: relative(dir, boardPath(dir)), adversary: parseAdversaryClasses(opts.adversary || []), batch, taskIds, sharedCost: opts["shared-cost"] !== undefined ? Number(opts["shared-cost"]) : null, note: opts.note ?? null, rehearsal: opts.rehearsal ?? null });
355
+ const entry = runEntry({ card, report, wall, cost: opts.cost !== undefined ? Number(opts.cost) : null, codexCost: opts["codex-cost"] !== undefined ? Number(opts["codex-cost"]) : null, wallClock: opts.wall ?? null, mergeCommit: opts.commit ?? null, record: relative(dir, boardPath(dir)), adversary: parseAdversaryClasses(opts.adversary || []), batch, taskIds, sharedCost: opts["shared-cost"] !== undefined ? Number(opts["shared-cost"]) : null, note: opts.note ?? null, rehearsal: opts.rehearsal ?? null, rehearsalOutcome: opts["rehearsal-outcome"] ?? null });
356
+ const block = landingBlock({ id, ok: report.ok === true, commit: opts.commit, wall: opts.wall, gate: acceptanceGate(report), cost: opts.cost, codexCost: opts["codex-cost"], note: opts.note, rehearsal: opts.rehearsal ?? null, rehearsalOutcome: opts["rehearsal-outcome"] ?? null });
275
357
  const { file, promoted } = recordLanding(dir, id, entry);
358
+ process.stdout.write(`${summarySentence}\n`);
276
359
  if (promoted) process.stdout.write(`Promoted the research note for ${id} to ${promoted}.\n`);
277
360
  process.stdout.write(`Appended a run entry for ${id} to ${file}.\n`);
361
+ writeLandingBlock(dir, id, block, false, !!opts.rehearsal);
278
362
  break;
279
363
  }
280
364
  case "summary": {
281
365
  const [reportFile] = rest;
282
- if (!reportFile) fail("usage: board.mjs summary <report.json> [--cost <usd>] [--codex-cost <usd>] [--wall <text>] [--commit <sha>] [--card <id>] [--run <run-id>] [--claude-dir <dir>]", 2);
366
+ if (!reportFile) fail("usage: board.mjs summary <report.json> --summary \"<one sentence>\" [--cost <usd>] [--codex-cost <usd>] [--wall <text>] [--commit <sha>] [--card <id>] [--run <run-id>] [--claude-dir <dir>] [--note <text>]", 2);
283
367
  let report = unwrapReport(JSON.parse(readFileSync(resolve(reportFile), "utf8")));
368
+ const wholeReport = report; // the block's gate reads the final level's acceptance, which the --card filter drops
284
369
  if (opts.card !== undefined) {
285
370
  // With the plan at hand the filter also covers a report whose tasks carry no card.
286
- const plan = loadPlan(dirFrom(rest, 1));
371
+ const plan = planFor(dirFrom(rest, 1), opts.card);
287
372
  report = filterReportForCard(report, opts.card, plan && Array.isArray(plan.tasks) ? plan.tasks.filter((t) => t.card === opts.card).map((t) => t.id) : []);
288
373
  }
289
374
  const wall = opts.run ? wallForRun({ runId: opts.run, claudeDir: opts["claude-dir"], projectDir: process.cwd(), wallMinutes: report.budget?.wallMinutes ?? DEFAULT_BUDGET.wallMinutes }) : null;
290
- const sumPlan = opts.card !== undefined ? loadPlan(dirFrom(rest, 1)) : null;
375
+ const sumPlan = opts.card !== undefined ? planFor(dirFrom(rest, 1), opts.card) : null;
291
376
  const sumBatch = sumPlan && Array.isArray(sumPlan.cards) && sumPlan.cards.includes(opts.card) ? sumPlan.cards : null;
377
+ // A block record already saved for this card is reprinted verbatim and re-armed, never recomputed or rewritten.
378
+ let sumSaved = null;
379
+ if (opts.card !== undefined && /^[a-z0-9._-]+$/.test(opts.card)) {
380
+ try {
381
+ const saved = readFileSync(join(dirFrom(rest, 1), ".doug/.state/reports", opts.card, "landing-block.txt"), "utf8").split("\n").filter(Boolean);
382
+ if (saved.length === 3) sumSaved = saved;
383
+ } catch {}
384
+ }
385
+ const sumBlock = sumSaved ? sumSaved : opts.card !== undefined ? landingBlock({ id: opts.card, ok: report.ok === true, commit: opts.commit, wall: opts.wall, gate: acceptanceGate(wholeReport), cost: opts.cost, codexCost: opts["codex-cost"], note: opts.note }) : null;
386
+ process.stdout.write(`${summarySentence}\n`);
292
387
  process.stdout.write(runSummary({ report, wall, batch: sumBatch, card: opts.card ?? null, cost: opts.cost !== undefined ? Number(opts.cost) : null, codexCost: opts["codex-cost"] !== undefined ? Number(opts["codex-cost"]) : null, wallClock: opts.wall ?? null, mergeCommit: opts.commit ?? null }));
388
+ if (sumBlock) writeLandingBlock(dirFrom(rest, 1), opts.card, sumBlock, sumSaved !== null);
293
389
  break;
294
390
  }
295
391
  default:
@@ -2,7 +2,9 @@
2
2
  // The lead's own context percentage (card autopilot-unlimited), read from the session transcript.
3
3
  // context.mjs [dir] [--session <id|latest>] [--claude-dir <path>]
4
4
  // dir the project directory (default cwd); <dir>/.doug/config.json supplies autopilot.contextThreshold
5
- // --session a session id or latest (default latest), as cost.mjs --session
5
+ // --session a session id or latest, as cost.mjs --session. Default: the CLAUDE_CODE_SESSION_ID environment
6
+ // variable (the lead's own session, so a newer non-lead transcript, such as a reflect pass, is not read);
7
+ // latest only when it is unset or empty. A variable naming a session with no transcript exits 1.
6
8
  // --claude-dir where ~/.claude lives (tests point it at a fixture)
7
9
  // Prints one line and exits 0; exits 1 with a stderr message when there is no transcript or no usage line.
8
10
  import { readFileSync } from "node:fs";
@@ -33,7 +35,7 @@ const threshold = contextThreshold(config, (m) => process.stderr.write(`[doug] $
33
35
 
34
36
  let file;
35
37
  try {
36
- file = sessionTranscriptPath({ session: opts.session || "latest", ...(opts["claude-dir"] ? { claudeDir: resolve(opts["claude-dir"]) } : {}), projectDir: dir });
38
+ file = sessionTranscriptPath({ session: opts.session || process.env.CLAUDE_CODE_SESSION_ID || "latest", ...(opts["claude-dir"] ? { claudeDir: resolve(opts["claude-dir"]) } : {}), projectDir: dir });
37
39
  } catch (err) {
38
40
  process.stderr.write(`context: ${err.message}\n`);
39
41
  process.exit(1);
@@ -6,6 +6,8 @@
6
6
  // cost.mjs <run-id> [dir] [--json] [--claude-dir <path>]
7
7
  // <run-id> the Workflow run id (wf_...)
8
8
  // [dir] the project directory whose ~/.claude/projects slug is searched first (default cwd)
9
+ // --since <iso>, --until <iso> with --session: price only the assistant messages whose timestamp is in the
10
+ // window (card usd-budget-size-class: a hand landing's cost, openedAt to the landing)
9
11
  // --json print the full result as JSON instead of the text tables
10
12
  // --by-card a batch run (the plan at [dir] lists `cards`): add the per-card table, each card's tasks'
11
13
  // agents summed for that card and the shared integration agents listed once (`byCard` in --json)
@@ -19,7 +21,7 @@
19
21
  import { readFileSync } from "node:fs";
20
22
  import { resolve, join } from "node:path";
21
23
  import { costRun, costSession, renderSession, renderCost, costByCard, renderCostByCard } from "../lib/cost.mjs";
22
- import { loadPlan, REPORT_RELPATH, unwrapReport } from "../lib/plan.mjs";
24
+ import { planFor, REPORT_RELPATH, unwrapReport } from "../lib/plan.mjs";
23
25
 
24
26
  const argv = process.argv.slice(2);
25
27
  const FLAGS = new Set(["json", "by-card"]);
@@ -43,6 +45,17 @@ if (opts.session === "") {
43
45
  process.stderr.write("usage: cost.mjs --session <id|latest> [dir] [--json] [--claude-dir <path>]\n");
44
46
  process.exit(2);
45
47
  }
48
+ const USAGE_SESSION = "usage: cost.mjs --session <id|latest> [dir] [--since <iso>] [--until <iso>] [--json] [--claude-dir <path>]\n";
49
+ const window = {};
50
+ for (const flag of ["since", "until"]) {
51
+ if (opts[flag] === undefined) continue;
52
+ const at = opts[flag] === "" ? NaN : Date.parse(opts[flag]);
53
+ if (opts.session === undefined || Number.isNaN(at)) {
54
+ process.stderr.write(`${USAGE_SESSION}--${flag} takes an ISO date with --session, got ${JSON.stringify(opts[flag])}.\n`);
55
+ process.exit(2);
56
+ }
57
+ window[flag] = at;
58
+ }
46
59
  if (!runId && opts.session === undefined) {
47
60
  process.stderr.write("usage: cost.mjs <run-id> [dir] [--json] [--claude-dir <path>]\n");
48
61
  process.exit(2);
@@ -50,7 +63,7 @@ if (!runId && opts.session === undefined) {
50
63
  try {
51
64
  const projectDir = resolve(process.env.CLAUDE_PROJECT_DIR || dirArg || process.cwd());
52
65
  if (opts.session !== undefined) {
53
- const result = costSession({ session: opts.session, projectDir, claudeDir: opts["claude-dir"] ? resolve(opts["claude-dir"]) : undefined });
66
+ const result = costSession({ session: opts.session, projectDir, claudeDir: opts["claude-dir"] ? resolve(opts["claude-dir"]) : undefined, since: window.since ?? null, until: window.until ?? null });
54
67
  process.stdout.write(opts.json ? JSON.stringify(result, null, 2) + "\n" : renderSession(result));
55
68
  process.exit(0);
56
69
  }
@@ -69,7 +82,7 @@ try {
69
82
  }
70
83
  }
71
84
  const result = costRun({ runId, projectDir, claudeDir: opts["claude-dir"] ? resolve(opts["claude-dir"]) : undefined, report, reportPath });
72
- const byCard = opts["by-card"] ? costByCard(result, loadPlan(projectDir)) : null;
85
+ const byCard = opts["by-card"] ? costByCard(result, planFor(projectDir, opts.card)) : null;
73
86
  if (opts.json) process.stdout.write(JSON.stringify(byCard ? { ...result, byCard } : result, null, 2) + "\n");
74
87
  else process.stdout.write(renderCost(result) + (byCard ? "\n" + renderCostByCard(byCard) : ""));
75
88
  } catch (err) {
@@ -8,7 +8,7 @@
8
8
  // run+task); with --run, per-task tokens and USD come from the
9
9
  // same local transcripts lib/cost.mjs reads (null, with a note on
10
10
  // stderr, when the run's journal cannot be found)
11
- // memory.mjs record <card-id> --hand [dir] --commit <sha> [--wall <text>] [--gate <text>] [--note <text>] [--json]
11
+ // memory.mjs record <card-id> --hand [dir] --commit <sha> [--wall <text>] [--gate <text>] [--note <text>] [--cost <usd>] [--json]
12
12
  // record a hand-track landing (card id, commit, wall clock, gate
13
13
  // line, note; nothing else is invented) as one outcomes row keyed
14
14
  // hand:<commit> (upsert on the same commit); --commit is required;
@@ -79,11 +79,14 @@
79
79
  // lesson's citation is re-checked against [dir] only when
80
80
  // [dir] is a real project root (contains .doug)
81
81
  // memory.mjs reflect <report.json> [dir] [--run <wf-id>] [--card <id>] [--commit <sha>]
82
- // [--adversary "<id>=<class>[: <reason>]"]... [--claude-bin <path>] [--claude-dir <path>]
82
+ // [--adversary "<id>=<class>[: <reason>]"]... [--harmful <id>]... [--claude-bin <path>] [--claude-dir <path>]
83
83
  // [--model <name>] [--max-usd <n>] [--dry-run] [--json]
84
84
  // runs after record: applies deterministic helpful/harmful
85
85
  // counters to lessons named in each task's memoryUsed (committed
86
- // even if the LLM pass below fails), then a cheap-row `claude -p
86
+ // even if the LLM pass below fails; a real block alone bumps no
87
+ // harmful: each --harmful <id>, a recalled lesson that misled the
88
+ // work, gets harmful +1 and no helpful; an id outside every
89
+ // task's memoryUsed exits 1 before any write), then a cheap-row `claude -p
87
90
  // --json-schema` pass proposes at most 3 lessons from the report,
88
91
  // filtered in code (a pitfall needs a block classed real, a
89
92
  // pattern needs a first-pass verified task, the citation must be
@@ -95,8 +98,8 @@
95
98
  // prints what would happen. Cost is measured from the same local
96
99
  // transcripts cost.mjs reads, falling back to the CLI's own
97
100
  // cost estimate. Needs Node >=22.16 (node:sqlite).
98
- // memory.mjs reflect --hand <card-id> [dir] --commit <sha> [--rounds <n>] [--review "R1=<class>[: <reason>]"]...
99
- // [--note <text>] [--claude-bin <path>] [--model <name>] [--max-usd <n>] [--dry-run] [--json]
101
+ // memory.mjs reflect --hand <card-id> [dir] --commit <sha> [--rounds <n>] [--review "<R<n> or <card-id>/R<n>>=<class>[: <reason>]"]...
102
+ // [--harmful <id>]... [--note <text>] [--claude-bin <path>] [--model <name>] [--max-usd <n>] [--dry-run] [--json]
100
103
  // (card memory-every-session) the same pass for a hand landing:
101
104
  // assembles .doug/.state/reports/<card-id>/hand-report.json from
102
105
  // the saved briefs/, reviews/ and lessons.json (memoryUsed = its
@@ -187,7 +190,7 @@ import { resolve, join, dirname } from "node:path";
187
190
  import { createHash } from "node:crypto";
188
191
  import { spawnSync } from "node:child_process";
189
192
  import { parseAdversaryClasses, loadBoard, findCard } from "../lib/board.mjs";
190
- import { loadPlan, unwrapReport } from "../lib/plan.mjs";
193
+ import { planFor, unwrapReport } from "../lib/plan.mjs";
191
194
  import { costRun } from "../lib/cost.mjs";
192
195
  import { loadModels } from "../lib/models.mjs";
193
196
  import {
@@ -204,7 +207,7 @@ import {
204
207
 
205
208
  const argv = process.argv.slice(2);
206
209
  const FLAGS = new Set(["json", "all", "hand", "dry-run", "check", "no-check"]);
207
- const REPEATABLE = new Set(["adversary", "review"]);
210
+ const REPEATABLE = new Set(["adversary", "review", "harmful"]);
208
211
  const opts = {};
209
212
  const positional = [];
210
213
  for (let i = 0; i < argv.length; i++) {
@@ -505,7 +508,7 @@ try {
505
508
  case "record": {
506
509
  if (opts.hand) {
507
510
  const handUsage =
508
- 'usage: memory.mjs record <card-id> --hand [dir] --commit <sha> [--wall <text>] [--gate <text>] [--note <text>] [--json]';
511
+ 'usage: memory.mjs record <card-id> --hand [dir] --commit <sha> [--wall <text>] [--gate <text>] [--note <text>] [--cost <usd>] [--json]';
509
512
  const [id, ...extra] = rest;
510
513
  if (!id || !opts.commit) fail(handUsage, 2);
511
514
  if (id.endsWith(".json") || existsSync(resolve(id))) {
@@ -514,6 +517,7 @@ try {
514
517
  if (opts.run !== undefined || opts.adversary !== undefined || opts["claude-dir"] !== undefined || extra.length > 1) {
515
518
  fail(handUsage, 2);
516
519
  }
520
+ if (opts.cost !== undefined && !/^\d+(\.\d+)?$/.test(opts.cost)) fail(`${handUsage}\n--cost takes a non-negative number of dollars, got ${JSON.stringify(opts.cost)}.`, 2);
517
521
  const dir = dirFrom(rest, 1);
518
522
  // condition.json (card outcomes-condition-columns): read when `condition open` wrote one before the
519
523
  // brief; a stamp missing entirely (the common case until core-next-hand-report wires this in) is not a
@@ -528,7 +532,7 @@ try {
528
532
  }
529
533
  }
530
534
  const { openMemory, recordOutcomes, handOutcomeRow } = await import("../lib/memory.mjs");
531
- const row = handOutcomeRow({ card: id, commit: opts.commit, wallClock: textOrNull(opts.wall), gate: textOrNull(opts.gate), note: textOrNull(opts.note), condition });
535
+ const row = handOutcomeRow({ card: id, commit: opts.commit, wallClock: textOrNull(opts.wall), gate: textOrNull(opts.gate), note: textOrNull(opts.note), condition, cost: opts.cost !== undefined ? Number(opts.cost) : null });
532
536
  const m = openMemory(dir);
533
537
  let result;
534
538
  try {
@@ -577,7 +581,7 @@ try {
577
581
  }
578
582
  const reportHash = createHash("sha256").update(reportText).digest("hex");
579
583
  const classes = parseAdversaryClasses(opts.adversary || []);
580
- const plan = loadPlan(dir);
584
+ const plan = planFor(dir, opts.card);
581
585
  const run = opts.run || null;
582
586
  const runLabel = run || `report:${reportHash.slice(0, 12)}`;
583
587
  let cost = null;
@@ -612,7 +616,7 @@ try {
612
616
  }
613
617
  case "reflect": {
614
618
  const usage =
615
- 'usage: memory.mjs reflect <report.json> [dir] [--run <wf-id>] [--card <id>] [--commit <sha>] [--adversary "<id>=<class>[: <reason>]"]... [--claude-bin <path>] [--claude-dir <path>] [--model <name>] [--max-usd <n>] [--dry-run] [--json]';
619
+ 'usage: memory.mjs reflect <report.json> [dir] [--run <wf-id>] [--card <id>] [--commit <sha>] [--adversary "<id>=<class>[: <reason>]"]... [--harmful <id>]... [--claude-bin <path>] [--claude-dir <path>] [--model <name>] [--max-usd <n>] [--dry-run] [--json]';
616
620
  let dir;
617
621
  let reportPath;
618
622
  let reportText;
@@ -621,7 +625,7 @@ try {
621
625
  let handCard = null;
622
626
  if (opts.hand) {
623
627
  const handUsage =
624
- 'usage: memory.mjs reflect --hand <card-id> [dir] --commit <sha> [--rounds <n>] [--review "R1=<real|marginal|false>[: <reason>]"]... [--note <text>] [--claude-bin <path>] [--model <name>] [--max-usd <n>] [--dry-run] [--json]';
628
+ 'usage: memory.mjs reflect --hand <card-id> [dir] --commit <sha> [--rounds <n>] [--review "<R<n> or <card-id>/R<n>>=<real|marginal|false>[: <reason>]"]... [--harmful <id>]... [--note <text>] [--claude-bin <path>] [--model <name>] [--max-usd <n>] [--dry-run] [--json]';
625
629
  const [id] = rest;
626
630
  if (!id || !opts.commit) fail(handUsage, 2);
627
631
  handCard = id;
@@ -632,7 +636,17 @@ try {
632
636
  if (!Number.isInteger(rounds) || rounds < 1) fail(`${handUsage}\n--rounds must be a positive integer, got ${JSON.stringify(opts.rounds)}.`, 2);
633
637
  }
634
638
  try {
635
- classes = parseAdversaryClasses(opts.review || []);
639
+ const byKey = new Map();
640
+ for (const c of parseAdversaryClasses(opts.review || [])) {
641
+ let key = c.id;
642
+ const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
643
+ if (/^R\d+$/.test(key)) key = `${id}/${key}`;
644
+ else if (!new RegExp(`^${escaped}/R\\d+$`).test(key)) throw new Error(`--review key ${JSON.stringify(c.id)} must be R<n> or ${id}/R<n>`);
645
+ const prev = byKey.get(key);
646
+ if (prev && prev.class !== c.class) throw new Error(`--review names ${key} twice with different classes (${prev.class}, ${c.class})`);
647
+ if (!prev) byKey.set(key, { ...c, id: key });
648
+ }
649
+ classes = [...byKey.values()];
636
650
  } catch (err) {
637
651
  fail(`${handUsage}\n${err.message}`, 2);
638
652
  }
@@ -695,7 +709,7 @@ try {
695
709
  classes = parseAdversaryClasses(opts.adversary || []);
696
710
  }
697
711
  const reportHash = createHash("sha256").update(reportText).digest("hex");
698
- const plan = loadPlan(dir);
712
+ const plan = planFor(dir, opts.card);
699
713
  const models = loadModels(dir);
700
714
  const model = textOrNull(opts.model) || (models.tiers.cheap && models.tiers.cheap.model) || "haiku";
701
715
  let maxUsd = 0.5;
@@ -724,6 +738,7 @@ try {
724
738
  model,
725
739
  claudeDir,
726
740
  dryRun,
741
+ harmful: opts.harmful || [],
727
742
  propose,
728
743
  });
729
744
  if (!dryRun && result.ok && result.appended && result.appended.length) {