@dougharness/cli 1.1.1 → 1.3.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 (57) hide show
  1. package/dist/board-serve.js +40 -6
  2. package/dist/board-serve.js.map +1 -1
  3. package/dist/init.js +1 -1
  4. package/dist/init.js.map +1 -1
  5. package/dist/memory-setup.d.ts +7 -0
  6. package/dist/memory-setup.js +70 -46
  7. package/dist/memory-setup.js.map +1 -1
  8. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +6 -2
  9. package/node_modules/@dougharness/flow/agents/coder.md +23 -0
  10. package/node_modules/@dougharness/flow/agents/implementer.md +1 -1
  11. package/node_modules/@dougharness/flow/agents/planner.md +1 -1
  12. package/node_modules/@dougharness/flow/agents/researcher.md +2 -1
  13. package/node_modules/@dougharness/flow/agents/reviewer.md +3 -1
  14. package/node_modules/@dougharness/flow/agents/spec-writer.md +23 -0
  15. package/node_modules/@dougharness/flow/agents/tester.md +21 -0
  16. package/node_modules/@dougharness/flow/hooks/hooks.json +4 -1
  17. package/node_modules/@dougharness/flow/hooks/register.tsx +94 -0
  18. package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +117 -0
  19. package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +17 -0
  20. package/node_modules/@dougharness/flow/lib/board.mjs +91 -10
  21. package/node_modules/@dougharness/flow/lib/card-land.mjs +115 -0
  22. package/node_modules/@dougharness/flow/lib/card-loop.mjs +778 -0
  23. package/node_modules/@dougharness/flow/lib/coordinator.mjs +393 -0
  24. package/node_modules/@dougharness/flow/lib/cost.mjs +15 -5
  25. package/node_modules/@dougharness/flow/lib/decisions.mjs +4 -2
  26. package/node_modules/@dougharness/flow/lib/embeddings.mjs +144 -3
  27. package/node_modules/@dougharness/flow/lib/land.mjs +27 -8
  28. package/node_modules/@dougharness/flow/lib/memory.mjs +406 -76
  29. package/node_modules/@dougharness/flow/lib/plan.mjs +33 -5
  30. package/node_modules/@dougharness/flow/lib/worktrees.mjs +3 -3
  31. package/node_modules/@dougharness/flow/package.json +1 -1
  32. package/node_modules/@dougharness/flow/scripts/autopilot.mjs +440 -0
  33. package/node_modules/@dougharness/flow/scripts/board.mjs +107 -32
  34. package/node_modules/@dougharness/flow/scripts/cost.mjs +16 -3
  35. package/node_modules/@dougharness/flow/scripts/memory.mjs +33 -10
  36. package/node_modules/@dougharness/flow/scripts/plan.mjs +104 -30
  37. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +8 -2
  38. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +30 -10
  39. package/node_modules/@dougharness/flow/skills/doug-start/SKILL.md +80 -0
  40. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +1 -1
  41. package/node_modules/@dougharness/flow/skills/research/SKILL.md +1 -1
  42. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +6 -6
  43. package/node_modules/@dougharness/flow/workflows/card-queen.js +846 -0
  44. package/node_modules/@dougharness/flow/workflows/doug-implement.js +63 -23
  45. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  46. package/node_modules/@dougharness/gates/lib/anchor.mjs +29 -4
  47. package/node_modules/@dougharness/gates/lib/bash-rules.mjs +196 -6
  48. package/node_modules/@dougharness/gates/lib/gate-lock.mjs +40 -8
  49. package/node_modules/@dougharness/gates/lib/proposals.mjs +41 -5
  50. package/node_modules/@dougharness/gates/lib/scope.mjs +43 -4
  51. package/node_modules/@dougharness/gates/package.json +1 -1
  52. package/node_modules/@dougharness/gates/scripts/reanchor.mjs +2 -1
  53. package/node_modules/@dougharness/gates/scripts/research-cap.mjs +119 -15
  54. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +156 -14
  55. package/node_modules/@dougharness/gates/scripts/verify.mjs +8 -4
  56. package/package.json +3 -3
  57. package/templates/board-app.js +46 -0
@@ -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] --summary "<one sentence>" [--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] --summary "<one sentence>" [--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> --summary "<one sentence>" [--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, handWallVerdict, 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: ["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 = ["summary", "hand", "commit", "wall", "gate", "note", "rehearsal"];
72
- const RECORD_REPORT_FLAGS = ["summary", "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> --summary \"<one sentence>\" [--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] --summary \"<one sentence>\" [--commit <sha>] [--wall <text>] [--gate <text>] [--note <text>] [--rehearsal <scenario>]";
107
- 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>] [--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.
@@ -134,11 +138,56 @@ if (cmd === "record" || Object.prototype.hasOwnProperty.call(SUBCOMMAND_FLAGS, c
134
138
  let summarySentence = null;
135
139
  if (cmd === "record" || cmd === "summary") {
136
140
  const text = typeof opts.summary === "string" ? opts.summary.trim() : "";
137
- if (!text) fail(`--summary "<one sentence>" is required for board.mjs ${cmd}: one sentence, in plain words, of what changed for the user`, 2);
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);
138
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);
139
143
  summarySentence = text;
140
144
  }
141
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
+
142
191
  try {
143
192
  switch (cmd) {
144
193
  case "next": {
@@ -155,12 +204,14 @@ try {
155
204
  if (capLine) process.stderr.write(capLine);
156
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.");
157
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`);
158
208
  process.stdout.write(JSON.stringify(batch === null ? card : cards, null, 2) + "\n");
159
209
  break;
160
210
  }
161
211
  case "budgets": {
212
+ const usd = opts.usd === true;
162
213
  if (opts.from === undefined) {
163
- process.stdout.write(`table: ${JSON.stringify(WALL_BUDGET_MIN)}\n`);
214
+ process.stdout.write(`table: ${JSON.stringify(usd ? USD_BUDGET : WALL_BUDGET_MIN)}\n`);
164
215
  break;
165
216
  }
166
217
  const dir = dirFrom(rest, 0);
@@ -170,12 +221,15 @@ try {
170
221
  } catch (e) {
171
222
  fail(`cannot read --from file ${opts.from}: ${e.message}`, 2);
172
223
  }
173
- 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;
174
228
  const out = [];
175
229
  for (const size of ["S", "M", "L"]) {
176
230
  for (const c of cells.filter((x) => x.size === size)) {
177
- 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}`);
178
- 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)})` : ""}`);
179
233
  }
180
234
  }
181
235
  out.push(`table: ${JSON.stringify(table)}`);
@@ -194,6 +248,11 @@ try {
194
248
  const moved = moveCard(loadBoard(dir), id, column, { source: opts.source });
195
249
  saveBoard(dir, moved);
196
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
+ }
197
256
  const capLine = column === "ready" ? readyCapWarning(moved) : null;
198
257
  if (capLine) process.stderr.write(capLine);
199
258
  break;
@@ -266,10 +325,12 @@ try {
266
325
  case "record": {
267
326
  if (opts.hand) {
268
327
  const [id] = rest;
269
- if (!id) fail("usage: board.mjs record <id> --hand [dir] --summary \"<one sentence>\" [--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);
270
330
  const dir = dirFrom(rest, 1);
271
331
  const card = findCard(loadBoard(dir), id);
272
- 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 });
273
334
  const { file, promoted } = recordLanding(dir, id, entry);
274
335
  process.stdout.write(`${summarySentence}\n`);
275
336
  if (!opts.rehearsal) {
@@ -278,39 +339,53 @@ try {
278
339
  }
279
340
  if (promoted) process.stdout.write(`Promoted the research note for ${id} to ${promoted}.\n`);
280
341
  process.stdout.write(`Appended a hand-track entry for ${id} to ${file}.\n`);
342
+ writeLandingBlock(dir, id, block, false, !!opts.rehearsal);
281
343
  break;
282
344
  }
283
345
  const [id, reportFile] = rest;
284
- 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>] [--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);
285
347
  const dir = dirFrom(rest, 2);
286
348
  const card = findCard(loadBoard(dir), id);
287
349
  const report = unwrapReport(JSON.parse(readFileSync(resolve(reportFile), "utf8")));
288
350
  // A batch plan (its `cards` list this card) makes the entry that card's part of the run.
289
- const plan = loadPlan(dir);
351
+ const plan = planFor(dir, id);
290
352
  const batch = plan && Array.isArray(plan.cards) && plan.cards.includes(id) ? plan.cards : null;
291
353
  const taskIds = batch ? plan.tasks.filter((t) => t.card === id).map((t) => t.id) : [];
292
354
  const wall = opts.run ? wallForRun({ runId: opts.run, claudeDir: opts["claude-dir"], projectDir: dir, wallMinutes: report.budget?.wallMinutes ?? DEFAULT_BUDGET.wallMinutes }) : null;
293
- 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 });
294
357
  const { file, promoted } = recordLanding(dir, id, entry);
295
358
  process.stdout.write(`${summarySentence}\n`);
296
359
  if (promoted) process.stdout.write(`Promoted the research note for ${id} to ${promoted}.\n`);
297
360
  process.stdout.write(`Appended a run entry for ${id} to ${file}.\n`);
361
+ writeLandingBlock(dir, id, block, false, !!opts.rehearsal);
298
362
  break;
299
363
  }
300
364
  case "summary": {
301
365
  const [reportFile] = rest;
302
- 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>]", 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);
303
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
304
369
  if (opts.card !== undefined) {
305
370
  // With the plan at hand the filter also covers a report whose tasks carry no card.
306
- const plan = loadPlan(dirFrom(rest, 1));
371
+ const plan = planFor(dirFrom(rest, 1), opts.card);
307
372
  report = filterReportForCard(report, opts.card, plan && Array.isArray(plan.tasks) ? plan.tasks.filter((t) => t.card === opts.card).map((t) => t.id) : []);
308
373
  }
309
374
  const wall = opts.run ? wallForRun({ runId: opts.run, claudeDir: opts["claude-dir"], projectDir: process.cwd(), wallMinutes: report.budget?.wallMinutes ?? DEFAULT_BUDGET.wallMinutes }) : null;
310
- const sumPlan = opts.card !== undefined ? loadPlan(dirFrom(rest, 1)) : null;
375
+ const sumPlan = opts.card !== undefined ? planFor(dirFrom(rest, 1), opts.card) : null;
311
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;
312
386
  process.stdout.write(`${summarySentence}\n`);
313
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);
314
389
  break;
315
390
  }
316
391
  default:
@@ -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;
@@ -97,8 +97,12 @@
97
97
  // 0); --dry-run applies nothing but still runs the LLM pass and
98
98
  // prints what would happen. Cost is measured from the same local
99
99
  // transcripts cost.mjs reads, falling back to the CLI's own
100
- // cost estimate. Needs Node >=22.16 (node:sqlite).
101
- // memory.mjs reflect --hand <card-id> [dir] --commit <sha> [--rounds <n>] [--review "R1=<class>[: <reason>]"]...
100
+ // cost estimate. For each appended lesson with no CLAUDE.md home
101
+ // it prints `claude-md candidate for lesson <id>: <command>`
102
+ // (edits file under .doug/.state/learn/claude-md-candidates/,
103
+ // nothing under --dry-run) or `... dropped: <reason>`; --json
104
+ // carries claudeMd and claudeMdDropped. Needs Node >=22.16 (node:sqlite).
105
+ // memory.mjs reflect --hand <card-id> [dir] --commit <sha> [--rounds <n>] [--review "<R<n> or <card-id>/R<n>>=<class>[: <reason>]"]...
102
106
  // [--harmful <id>]... [--note <text>] [--claude-bin <path>] [--model <name>] [--max-usd <n>] [--dry-run] [--json]
103
107
  // (card memory-every-session) the same pass for a hand landing:
104
108
  // assembles .doug/.state/reports/<card-id>/hand-report.json from
@@ -190,7 +194,7 @@ import { resolve, join, dirname } from "node:path";
190
194
  import { createHash } from "node:crypto";
191
195
  import { spawnSync } from "node:child_process";
192
196
  import { parseAdversaryClasses, loadBoard, findCard } from "../lib/board.mjs";
193
- import { loadPlan, unwrapReport } from "../lib/plan.mjs";
197
+ import { planFor, unwrapReport } from "../lib/plan.mjs";
194
198
  import { costRun } from "../lib/cost.mjs";
195
199
  import { loadModels } from "../lib/models.mjs";
196
200
  import {
@@ -508,7 +512,7 @@ try {
508
512
  case "record": {
509
513
  if (opts.hand) {
510
514
  const handUsage =
511
- 'usage: memory.mjs record <card-id> --hand [dir] --commit <sha> [--wall <text>] [--gate <text>] [--note <text>] [--json]';
515
+ 'usage: memory.mjs record <card-id> --hand [dir] --commit <sha> [--wall <text>] [--gate <text>] [--note <text>] [--cost <usd>] [--json]';
512
516
  const [id, ...extra] = rest;
513
517
  if (!id || !opts.commit) fail(handUsage, 2);
514
518
  if (id.endsWith(".json") || existsSync(resolve(id))) {
@@ -517,6 +521,7 @@ try {
517
521
  if (opts.run !== undefined || opts.adversary !== undefined || opts["claude-dir"] !== undefined || extra.length > 1) {
518
522
  fail(handUsage, 2);
519
523
  }
524
+ 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);
520
525
  const dir = dirFrom(rest, 1);
521
526
  // condition.json (card outcomes-condition-columns): read when `condition open` wrote one before the
522
527
  // brief; a stamp missing entirely (the common case until core-next-hand-report wires this in) is not a
@@ -531,7 +536,7 @@ try {
531
536
  }
532
537
  }
533
538
  const { openMemory, recordOutcomes, handOutcomeRow } = await import("../lib/memory.mjs");
534
- const row = handOutcomeRow({ card: id, commit: opts.commit, wallClock: textOrNull(opts.wall), gate: textOrNull(opts.gate), note: textOrNull(opts.note), condition });
539
+ 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 });
535
540
  const m = openMemory(dir);
536
541
  let result;
537
542
  try {
@@ -580,7 +585,7 @@ try {
580
585
  }
581
586
  const reportHash = createHash("sha256").update(reportText).digest("hex");
582
587
  const classes = parseAdversaryClasses(opts.adversary || []);
583
- const plan = loadPlan(dir);
588
+ const plan = planFor(dir, opts.card);
584
589
  const run = opts.run || null;
585
590
  const runLabel = run || `report:${reportHash.slice(0, 12)}`;
586
591
  let cost = null;
@@ -624,7 +629,7 @@ try {
624
629
  let handCard = null;
625
630
  if (opts.hand) {
626
631
  const handUsage =
627
- 'usage: memory.mjs reflect --hand <card-id> [dir] --commit <sha> [--rounds <n>] [--review "R1=<real|marginal|false>[: <reason>]"]... [--harmful <id>]... [--note <text>] [--claude-bin <path>] [--model <name>] [--max-usd <n>] [--dry-run] [--json]';
632
+ '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]';
628
633
  const [id] = rest;
629
634
  if (!id || !opts.commit) fail(handUsage, 2);
630
635
  handCard = id;
@@ -635,7 +640,17 @@ try {
635
640
  if (!Number.isInteger(rounds) || rounds < 1) fail(`${handUsage}\n--rounds must be a positive integer, got ${JSON.stringify(opts.rounds)}.`, 2);
636
641
  }
637
642
  try {
638
- classes = parseAdversaryClasses(opts.review || []);
643
+ const byKey = new Map();
644
+ for (const c of parseAdversaryClasses(opts.review || [])) {
645
+ let key = c.id;
646
+ const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
647
+ if (/^R\d+$/.test(key)) key = `${id}/${key}`;
648
+ 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>`);
649
+ const prev = byKey.get(key);
650
+ if (prev && prev.class !== c.class) throw new Error(`--review names ${key} twice with different classes (${prev.class}, ${c.class})`);
651
+ if (!prev) byKey.set(key, { ...c, id: key });
652
+ }
653
+ classes = [...byKey.values()];
639
654
  } catch (err) {
640
655
  fail(`${handUsage}\n${err.message}`, 2);
641
656
  }
@@ -698,7 +713,7 @@ try {
698
713
  classes = parseAdversaryClasses(opts.adversary || []);
699
714
  }
700
715
  const reportHash = createHash("sha256").update(reportText).digest("hex");
701
- const plan = loadPlan(dir);
716
+ const plan = planFor(dir, opts.card);
702
717
  const models = loadModels(dir);
703
718
  const model = textOrNull(opts.model) || (models.tiers.cheap && models.tiers.cheap.model) || "haiku";
704
719
  let maxUsd = 0.5;
@@ -765,6 +780,14 @@ try {
765
780
  for (const r of result.rejected) {
766
781
  process.stdout.write(` rejected ${r.text ? JSON.stringify(r.text) : "(no text)"}: ${r.reason}\n`);
767
782
  }
783
+ for (const c of result.claudeMd || []) {
784
+ process.stdout.write(
785
+ result.dryRun
786
+ ? `claude-md candidate for lesson ${c.lessonId} (dry run, not written)\n`
787
+ : `claude-md candidate for lesson ${c.lessonId}: ${c.command}\n`
788
+ );
789
+ }
790
+ for (const d of result.claudeMdDropped || []) process.stdout.write(`claude-md candidate for lesson ${d.lessonId} dropped: ${d.reason}\n`);
768
791
  }
769
792
  break;
770
793
  }