@dougharness/cli 1.3.0 → 1.5.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 (51) hide show
  1. package/dist/ablate.js +2 -2
  2. package/dist/ablate.js.map +1 -1
  3. package/dist/bin.js +13 -4
  4. package/dist/bin.js.map +1 -1
  5. package/dist/board-page.d.ts +5 -1
  6. package/dist/board-page.js +19 -5
  7. package/dist/board-page.js.map +1 -1
  8. package/dist/board-serve.js +34 -10
  9. package/dist/board-serve.js.map +1 -1
  10. package/dist/board.d.ts +1 -1
  11. package/dist/board.js +160 -13
  12. package/dist/board.js.map +1 -1
  13. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +1 -1
  14. package/node_modules/@dougharness/flow/agents/planner.md +1 -0
  15. package/node_modules/@dougharness/flow/hooks/register.tsx +18 -2
  16. package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +94 -7
  17. package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +31 -4
  18. package/node_modules/@dougharness/flow/lib/board.mjs +375 -30
  19. package/node_modules/@dougharness/flow/lib/card-loop.mjs +237 -24
  20. package/node_modules/@dougharness/flow/lib/cost.mjs +9 -0
  21. package/node_modules/@dougharness/flow/lib/decisions.mjs +2 -4
  22. package/node_modules/@dougharness/flow/lib/learn.mjs +95 -54
  23. package/node_modules/@dougharness/flow/lib/memory.mjs +1403 -44
  24. package/node_modules/@dougharness/flow/lib/plan.mjs +40 -0
  25. package/node_modules/@dougharness/flow/package.json +1 -1
  26. package/node_modules/@dougharness/flow/scripts/autopilot.mjs +355 -13
  27. package/node_modules/@dougharness/flow/scripts/board.mjs +160 -27
  28. package/node_modules/@dougharness/flow/scripts/cost.mjs +6 -3
  29. package/node_modules/@dougharness/flow/scripts/learn.mjs +1 -1
  30. package/node_modules/@dougharness/flow/scripts/memory.mjs +668 -39
  31. package/node_modules/@dougharness/flow/scripts/plan.mjs +3 -3
  32. package/node_modules/@dougharness/flow/scripts/session-recall.mjs +11 -4
  33. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +7 -3
  34. package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +1 -1
  35. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +11 -2
  36. package/node_modules/@dougharness/flow/skills/doug-start/SKILL.md +9 -8
  37. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +1 -1
  38. package/node_modules/@dougharness/flow/skills/research/SKILL.md +1 -1
  39. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +3 -3
  40. package/node_modules/@dougharness/flow/workflows/card-queen.js +238 -25
  41. package/node_modules/@dougharness/flow/workflows/doug-implement.js +55 -37
  42. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  43. package/node_modules/@dougharness/gates/lib/baseline.mjs +32 -0
  44. package/node_modules/@dougharness/gates/lib/block-ledger.mjs +45 -5
  45. package/node_modules/@dougharness/gates/lib/docs-gate.mjs +6 -2
  46. package/node_modules/@dougharness/gates/lib/verified-tree.mjs +33 -0
  47. package/node_modules/@dougharness/gates/package.json +1 -1
  48. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +130 -42
  49. package/node_modules/@dougharness/gates/scripts/verify.mjs +20 -4
  50. package/package.json +3 -3
  51. package/templates/board-app.js +240 -30
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  // CLI for the development board, used by the /doug-next and doug-hand skills. Deterministic; no agents.
3
- // board.mjs next [dir] [--track flow|hand|any] [--tag <tag>]
3
+ // board.mjs next [dir] [--track flow|hand|any] [--tag <tag>] [--epic <id>] [--milestone <id>]
4
4
  // the first Ready card on that track (default flow) whose deps are
5
5
  // Done, as JSON (exit 1 if none); --tag filters to cards carrying it
6
- // board.mjs next --batch <n> [dir] [--track flow|hand|any] [--tag <tag>]
6
+ // board.mjs next --batch <n> [dir] [--track flow|hand|any] [--tag <tag>] [--epic <id>] [--milestone <id>]
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)
@@ -14,28 +14,37 @@
14
14
  // Wall clock rows joined to the board, one line per cell and a final
15
15
  // `table:` line; writes nothing (replace WALL_BUDGET_MIN by hand). --usd does the same for the usd
16
16
  // table (USD_BUDGET) from the Cost rows of flow and hand entries
17
+ // board.mjs rollup [dir] [--epic <id> | --milestone <id>] [--from <live-runs file>]
18
+ // per epic and milestone: columns, done/total, size-weighted progress,
19
+ // measured cost and wall of Done cards, not-Done budget, as JSON; --from
20
+ // defaults to docs/live-runs.md when present (else measured is null)
21
+ // board.mjs labels --check [dir] one line per label colour under 3:1 against #ffffff or #1c2022; exit 0
17
22
  // board.mjs card <id> [dir] one card as JSON
18
23
  // board.mjs reorder <id> [dir] --before <other> | --after <other> | --top | --bottom
19
24
  // reorder a card within its column
20
25
  // board.mjs move <id> <column> [dir] [--source <text>]
21
26
  // move a card
22
27
  // board.mjs edit <id> [dir] [--title <t>] [--goal <g> | --goal-file <path>] [--size S|M|L] [--component <c>]
23
- // [--track flow|hand] [--class <c>] [--deps a,b] [--tag a,b] [--source <s>] [--force]
28
+ // [--track flow|hand] [--class <c>] [--deps a,b] [--tag a,b] [--source <s>] [--epic <id>] [--milestone <id>] [--force]
24
29
  // changes a card's fields through the record's own validation
25
30
  // (never the id or the column; move stays the way to change that);
26
- // --tag "" clears a card's tags
31
+ // --tag "" clears a card's tags; --epic "" / --milestone "" clear those
32
+ // board.mjs epic add <id> --title <t> --goal <g> [dir] [--version <v>] [--date YYYY-MM-DD]
33
+ // board.mjs epic edit <id> [dir] [--title <t>] [--goal <g>] [--version <v>] [--date <d>] [--status planned|active|done] [--force]
34
+ // board.mjs epic list [dir] epics as a JSON array; milestone add|edit|list takes the same flags.
35
+ // --status done is refused while a member card is not Done (--force sets it, with a warning)
27
36
  // board.mjs remove <id> [dir] [--force] removes a card from the record: refused always when another card
28
37
  // lists it in deps; refused without --force when it is done, in
29
38
  // flow, or named by .doug/plan.json (each with --force removes it
30
39
  // and prints a warning to stderr); prints the removed card as JSON
31
40
  // to stdout so a mistaken removal can be re-added by hand
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>]
41
+ // 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> [--rehearsal-fixture <name>]] [--run <run-id>] [--claude-dir <dir>]
33
42
  // append a run entry for the card to docs/live-runs.md; every adversary block
34
43
  // in the report is classified real, marginal, or false (repeat the flag per block).
35
44
  // When .doug/plan.json lists the card among its `cards` (a batch), the entry
36
45
  // shows only that card's tasks, --cost is that card's tasks' cost, and
37
46
  // --shared-cost <usd> the shared integration agents', counted once
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>]
47
+ // board.mjs record <id> --hand [dir] --summary "<one sentence>" [--commit <sha>] [--wall <text>] [--gate <text>] [--note <text>] [--rehearsal <scenario> --rehearsal-outcome <passed|failed> [--rehearsal-fixture <name>]] [--cost <usd>]
39
48
  // append a hand-track entry (no report) for the card
40
49
  // --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
41
50
  // prose line and the commit sha (abbreviated to 7 characters) read as the fixture's, not this repository's.
@@ -49,31 +58,44 @@
49
58
 
50
59
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
51
60
  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";
61
+ import { loadBoard, saveBoard, findCard, nextReadyCard, nextReadyCards, moveCard, editCard, editEpic, editMilestone, initLabelGroups, addEpic, addMilestone, removeCard, reorderCard, readyCapWarning, wallCapMinutes, WALL_BUDGET_MIN, WALL_BUDGET_MIN_N, parseLiveRunWalls, wallBudgetTable, USD_BUDGET, USD_BUDGET_MIN_N, usdCapDollars, parseLiveRunCosts, usdBudgetTable, rollup, checkFilters, labelContrastFailures, LIVE_RUNS_RELPATH, runEntry, handEntry, handWallVerdict, runSummary, boardPath, parseAdversaryClasses, filterReportForCard, recordLanding } from "../lib/board.mjs";
53
62
  import { loadPlan, planFor, unwrapReport, DEFAULT_BUDGET } from "../lib/plan.mjs";
54
63
  import { wallForRun } from "../lib/cost.mjs";
55
64
 
56
- const EDIT_FIELD_ORDER = ["title", "goal", "size", "component", "track", "class", "tags", "deps", "source"];
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]";
65
+ const EDIT_FIELD_ORDER = ["title", "goal", "size", "component", "track", "class", "tags", "deps", "source", "epic", "milestone"];
66
+ 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>] [--epic <id>] [--milestone <id>] [--force]";
58
67
 
59
68
  const argv = process.argv.slice(2);
60
- const FLAGS = new Set(["hand", "top", "bottom", "force", "usd"]);
69
+ const FLAGS = new Set(["hand", "top", "bottom", "force", "usd", "check"]);
61
70
  const REPEATABLE = new Set(["adversary"]);
62
71
 
63
72
  // Each subcommand's allowed flags, derived from what that case actually reads (card board-cli-unknown-flags).
64
73
  // `record` has two forms (--hand or not) with different allowed sets, handled separately below.
65
74
  const SUBCOMMAND_FLAGS = {
66
- next: ["track", "batch", "tag"],
75
+ next: ["track", "batch", "tag", "epic", "milestone"],
67
76
  card: [],
68
77
  move: ["source"],
69
- edit: ["title", "goal", "goal-file", "size", "component", "track", "class", "deps", "tag", "source", "force"],
78
+ edit: ["title", "goal", "goal-file", "size", "component", "track", "class", "deps", "tag", "source", "epic", "milestone", "force"],
70
79
  remove: ["force"],
71
80
  reorder: ["before", "after", "top", "bottom"],
72
81
  budgets: ["from", "usd"],
82
+ rollup: ["epic", "milestone", "from"],
83
+ labels: ["check"],
73
84
  summary: ["summary", "cost", "codex-cost", "wall", "commit", "card", "run", "claude-dir", "note"],
74
85
  };
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"];
86
+ // epic and milestone: `<kind> add|edit|list`, each action with its own allowed flags.
87
+ const PLAN_ACTION_FLAGS = {
88
+ add: ["title", "goal", "version", "date"],
89
+ edit: ["title", "goal", "version", "date", "status", "force"],
90
+ list: [],
91
+ };
92
+ const planUsage = (kind) => [
93
+ `usage: board.mjs ${kind} add <id> --title <t> --goal <g> [dir] [--version <v>] [--date YYYY-MM-DD]`,
94
+ ` board.mjs ${kind} edit <id> [dir] [--title <t>] [--goal <g>] [--version <v>] [--date YYYY-MM-DD] [--status planned|active|done] [--force]`,
95
+ ` board.mjs ${kind} list [dir]`,
96
+ ].join("\n");
97
+ const RECORD_HAND_FLAGS = ["summary", "hand", "commit", "wall", "gate", "note", "rehearsal", "rehearsal-outcome", "rehearsal-fixture", "cost"];
98
+ const RECORD_REPORT_FLAGS = ["summary", "cost", "codex-cost", "wall", "commit", "adversary", "shared-cost", "note", "rehearsal", "rehearsal-outcome", "rehearsal-fixture", "run", "claude-dir"];
77
99
  const opts = {};
78
100
  const positional = [];
79
101
  for (let i = 0; i < argv.length; i++) {
@@ -98,17 +120,19 @@ function fail(msg, code = 1) {
98
120
  }
99
121
 
100
122
  const USAGE_FOR = {
101
- next: "usage: board.mjs next [dir] [--track flow|hand|any] [--batch <n>] [--tag <tag>]",
123
+ next: "usage: board.mjs next [dir] [--track flow|hand|any] [--batch <n>] [--tag <tag>] [--epic <id>] [--milestone <id>]",
102
124
  card: "usage: board.mjs card <id> [dir]",
103
125
  move: "usage: board.mjs move <id> <column> [dir] [--source <text>]",
104
126
  edit: EDIT_USAGE,
105
127
  remove: "usage: board.mjs remove <id> [dir] [--force]",
106
128
  reorder: "usage: board.mjs reorder <id> [dir] --before <other> | --after <other> | --top | --bottom",
107
129
  budgets: "usage: board.mjs budgets [dir] [--usd] [--from <live-runs file>]",
130
+ rollup: "usage: board.mjs rollup [dir] [--epic <id> | --milestone <id>] [--from <live-runs file>]",
131
+ labels: "usage: board.mjs labels --check [dir] | labels init [dir]",
108
132
  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>]",
109
133
  };
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>]";
134
+ 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> [--rehearsal-fixture <name>]] [--cost <usd>]";
135
+ 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> [--rehearsal-fixture <name>]] [--run <run-id>] [--claude-dir <dir>]";
112
136
 
113
137
  // An unknown --flag for the given subcommand: exit 2, name the flag and the subcommand's usage.
114
138
  // Runs on flag names only (parse order doesn't matter), before any work or write.
@@ -124,6 +148,12 @@ function dirFrom(args, n) {
124
148
  return resolve(process.env.CLAUDE_PROJECT_DIR || args[n] || process.cwd());
125
149
  }
126
150
 
151
+ if (cmd === "epic" || cmd === "milestone") {
152
+ const action = rest[0];
153
+ if (!Object.prototype.hasOwnProperty.call(PLAN_ACTION_FLAGS, action)) fail(planUsage(cmd), 2);
154
+ const unknown = Object.keys(opts).find((name) => !PLAN_ACTION_FLAGS[action].includes(name));
155
+ if (unknown !== undefined) fail(`unknown option --${unknown} for board.mjs ${cmd}\n${planUsage(cmd)}`, 2);
156
+ }
127
157
  if (cmd === "record" || Object.prototype.hasOwnProperty.call(SUBCOMMAND_FLAGS, cmd)) {
128
158
  const unknown = unknownFlagFor(cmd, opts);
129
159
  if (unknown !== undefined) {
@@ -147,6 +177,7 @@ if (cmd === "record" || cmd === "summary") {
147
177
  if (cmd === "record") {
148
178
  const ro = opts["rehearsal-outcome"];
149
179
  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);
180
+ if (opts.rehearsal === undefined && opts["rehearsal-fixture"] !== undefined) fail(`--rehearsal-fixture is only valid with --rehearsal\n${opts.hand ? RECORD_HAND_USAGE : RECORD_REPORT_USAGE}`, 2);
150
181
  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
182
  }
152
183
 
@@ -162,7 +193,8 @@ function landingBlock({ id, ok, commit, wall, gate, cost, codexCost, note, rehea
162
193
  const learned = typeof note === "string" && note.trim() ? note.trim().replace(/\s+/g, " ") : "nothing new";
163
194
  const lines = [`Changed: ${summarySentence}`, `Outcome: ${outcome}`, `Learned: ${learned}`];
164
195
  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.`);
196
+ // A rehearsal's block is print-only (no landing-block files, so the Stop gate checks nothing): no cap, so a failed stage's long note still records.
197
+ if (!rehearsal && words > LANDING_BLOCK_WORDS) fail(`landing block is ${words} words; the limit is ${LANDING_BLOCK_WORDS}. Shorten --summary or --note.`);
166
198
  return lines;
167
199
  }
168
200
  // The pending marker holds the session that landed, so only that session's Stop checks it (empty when unset).
@@ -182,6 +214,25 @@ function writeLandingBlock(dir, id, lines, keepFile = false, printOnly = false)
182
214
  }
183
215
  process.stdout.write(lines.join("\n") + "\n");
184
216
  }
217
+ // A plan landed after `plan.mjs done --force` (landed set, report not ok, --commit naming the land merge or absent,
218
+ // not a rehearsal) is a landed card whatever the report says; its gate is land's own acceptance. In a batch plan
219
+ // only the entries tagged `[<card>] ` count, or the whole list labelled as the plan's when none carries the tag.
220
+ function forcedLanding({ plan, report, opts, card }) {
221
+ const lc = plan && plan.landed && typeof plan.landed === "object" ? plan.landed : null;
222
+ const lm = lc && typeof lc.mergeCommit === "string" ? lc.mergeCommit : "";
223
+ const forced = report.ok !== true && !opts.rehearsal && lc !== null && (opts.commit === undefined || (lm !== "" && (lm.startsWith(String(opts.commit)) || String(opts.commit).startsWith(lm))));
224
+ if (!forced) return { forced: false, gate: null };
225
+ if (!Array.isArray(lc.acceptance)) return { forced, gate: "acceptance checked at land" };
226
+ const count = (list) => `${list.filter((a) => a && a.ok).length}/${list.length}`;
227
+ if (plan && Array.isArray(plan.cards) && plan.cards.includes(card)) {
228
+ const tag = `[${card}] `;
229
+ const own = lc.acceptance.filter((a) => a && (typeof a === "string" ? a : String(a.text ?? "")).startsWith(tag));
230
+ if (own.length) return { forced, gate: `acceptance ${count(own)} at land` };
231
+ return { forced, gate: `acceptance ${count(lc.acceptance)} at land (the plan's)` };
232
+ }
233
+ return { forced, gate: `acceptance ${count(lc.acceptance)} at land` };
234
+ }
235
+
185
236
  function acceptanceGate(report) {
186
237
  const levels = (report.levels || []).filter((l) => l && l.integration);
187
238
  const acc = levels.length && Array.isArray(levels[levels.length - 1].integration.acceptance) ? levels[levels.length - 1].integration.acceptance : [];
@@ -198,7 +249,7 @@ try {
198
249
  if (batch !== null && !(Number.isInteger(batch) && batch > 0)) fail(`--batch must be a positive integer, got ${JSON.stringify(opts.batch)}`, 2);
199
250
  if (opts.tag !== undefined && opts.tag.includes(",")) fail(`--tag takes one tag here; got "${opts.tag}"`, 2);
200
251
  const board = loadBoard(dir);
201
- const { card, cards, skipped } = batch === null ? nextReadyCard(board, { track, tag: opts.tag }) : nextReadyCards(board, { track, batch, tag: opts.tag });
252
+ const { card, cards, skipped } = batch === null ? nextReadyCard(board, { track, tag: opts.tag, epic: opts.epic, milestone: opts.milestone }) : nextReadyCards(board, { track, batch, tag: opts.tag, epic: opts.epic, milestone: opts.milestone });
202
253
  for (const s of skipped) process.stderr.write(s.hand ? `skipping ${s.id}: hand track (a by-hand card; /doug-next ${s.id} takes it)\n` : s.flow ? `skipping ${s.id}: flow track (run it with /doug-next)\n` : `skipping ${s.id}: waiting on ${s.waitingOn.join(", ")}\n`);
203
254
  const capLine = readyCapWarning(board);
204
255
  if (capLine) process.stderr.write(capLine);
@@ -236,6 +287,42 @@ try {
236
287
  process.stdout.write(out.join("\n") + "\n");
237
288
  break;
238
289
  }
290
+ case "rollup": {
291
+ if (opts.epic !== undefined && opts.milestone !== undefined) fail(USAGE_FOR.rollup, 2);
292
+ const dir = dirFrom(rest, 0);
293
+ const board = loadBoard(dir);
294
+ try {
295
+ checkFilters(board, { epic: opts.epic, milestone: opts.milestone });
296
+ } catch (e) {
297
+ fail(e.message, 2);
298
+ }
299
+ let text = null;
300
+ if (opts.from !== undefined) {
301
+ try {
302
+ text = readFileSync(resolve(opts.from), "utf8");
303
+ } catch (e) {
304
+ fail(`cannot read --from file ${opts.from}: ${e.message}`, 2);
305
+ }
306
+ } else if (existsSync(join(dir, LIVE_RUNS_RELPATH))) {
307
+ text = readFileSync(join(dir, LIVE_RUNS_RELPATH), "utf8");
308
+ }
309
+ const r = rollup(board, text);
310
+ const out = opts.epic !== undefined ? r.epics.find((e) => e.id === opts.epic) : opts.milestone !== undefined ? r.milestones.find((m) => m.id === opts.milestone) : r;
311
+ process.stdout.write(JSON.stringify(out, null, 2) + "\n");
312
+ break;
313
+ }
314
+ case "labels": {
315
+ if (rest[0] === "init" && opts.check !== true) {
316
+ const dir = dirFrom(rest, 1);
317
+ saveBoard(dir, initLabelGroups(loadBoard(dir)));
318
+ process.stdout.write(`Added label groups type and priority to ${relative(dir, boardPath(dir))}.\n`);
319
+ break;
320
+ }
321
+ if (opts.check !== true) fail(USAGE_FOR.labels, 2);
322
+ const lines = labelContrastFailures(loadBoard(dirFrom(rest, 0)));
323
+ if (lines.length) process.stdout.write(lines.join("\n") + "\n");
324
+ break;
325
+ }
239
326
  case "card": {
240
327
  const dir = dirFrom(rest, 1);
241
328
  process.stdout.write(JSON.stringify(findCard(loadBoard(dir), rest[0]), null, 2) + "\n");
@@ -272,6 +359,8 @@ try {
272
359
  if (opts.deps !== undefined) fields.deps = opts.deps.split(",").map((d) => d.trim()).filter(Boolean);
273
360
  if (opts.tag !== undefined) fields.tags = opts.tag.split(",").map((t) => t.trim()).filter(Boolean);
274
361
  if (opts.source !== undefined) fields.source = opts.source;
362
+ if (opts.epic !== undefined) fields.epic = opts.epic === "" ? null : opts.epic;
363
+ if (opts.milestone !== undefined) fields.milestone = opts.milestone === "" ? null : opts.milestone;
275
364
  const changed = EDIT_FIELD_ORDER.filter((k) => Object.prototype.hasOwnProperty.call(fields, k));
276
365
  if (!changed.length) fail(EDIT_USAGE, 2);
277
366
  const dir = dirFrom(rest, 1);
@@ -282,6 +371,47 @@ try {
282
371
  process.stdout.write(`Edited ${id} (${changed.join(", ")}) in ${relative(dir, boardPath(dir))}.\n`);
283
372
  break;
284
373
  }
374
+ case "epic":
375
+ case "milestone": {
376
+ const [action, id] = rest;
377
+ const add = cmd === "epic" ? addEpic : addMilestone;
378
+ const edit = cmd === "epic" ? editEpic : editMilestone;
379
+ const targetOf = () => {
380
+ const t = {};
381
+ if (opts.version !== undefined) t.version = opts.version === "" ? null : opts.version;
382
+ if (opts.date !== undefined) t.date = opts.date === "" ? null : opts.date;
383
+ return t;
384
+ };
385
+ if (action === "list") {
386
+ const board = loadBoard(dirFrom(rest, 1));
387
+ process.stdout.write(JSON.stringify(board[`${cmd}s`] || [], null, 2) + "\n");
388
+ break;
389
+ }
390
+ if (!id) fail(planUsage(cmd), 2);
391
+ if (action === "add") {
392
+ if (!opts.title || !opts.goal) fail(planUsage(cmd), 2);
393
+ const dir = dirFrom(rest, 2);
394
+ const target = {};
395
+ if (opts.version) target.version = opts.version;
396
+ if (opts.date) target.date = opts.date;
397
+ saveBoard(dir, add(loadBoard(dir), { id, title: opts.title, goal: opts.goal, ...(Object.keys(target).length ? { target } : {}) }));
398
+ process.stdout.write(`Added ${cmd} ${id} in ${relative(dir, boardPath(dir))}.\n`);
399
+ break;
400
+ }
401
+ const fields = {};
402
+ if (opts.title !== undefined) fields.title = opts.title;
403
+ if (opts.goal !== undefined) fields.goal = opts.goal;
404
+ if (opts.status !== undefined) fields.status = opts.status;
405
+ const target = targetOf();
406
+ if (Object.keys(target).length) fields.target = target;
407
+ if (!Object.keys(fields).length) fail(planUsage(cmd), 2);
408
+ const dir = dirFrom(rest, 2);
409
+ const { board, warnings } = edit(loadBoard(dir), id, fields, { force: !!opts.force });
410
+ saveBoard(dir, board);
411
+ for (const w of warnings) process.stderr.write(`warning: ${w}\n`);
412
+ process.stdout.write(`Edited ${cmd} ${id} (${Object.keys(fields).join(", ")}) in ${relative(dir, boardPath(dir))}.\n`);
413
+ break;
414
+ }
285
415
  case "remove": {
286
416
  const [id] = rest;
287
417
  if (!id) fail("usage: board.mjs remove <id> [dir] [--force]", 2);
@@ -325,11 +455,11 @@ try {
325
455
  case "record": {
326
456
  if (opts.hand) {
327
457
  const [id] = rest;
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);
458
+ 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> [--rehearsal-fixture <name>]] [--cost <usd>]", 2);
329
459
  if (opts.cost !== undefined && !/^\d+(\.\d+)?$/.test(opts.cost)) fail(`--cost takes a non-negative number of dollars, got ${JSON.stringify(opts.cost)}`, 2);
330
460
  const dir = dirFrom(rest, 1);
331
461
  const card = findCard(loadBoard(dir), id);
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 });
462
+ 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, fixture: opts["rehearsal-fixture"] ?? null, cost: opts.cost !== undefined ? Number(opts.cost) : null });
333
463
  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 });
334
464
  const { file, promoted } = recordLanding(dir, id, entry);
335
465
  process.stdout.write(`${summarySentence}\n`);
@@ -343,7 +473,7 @@ try {
343
473
  break;
344
474
  }
345
475
  const [id, reportFile] = rest;
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);
476
+ 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> [--rehearsal-fixture <name>]] [--run <run-id>] [--claude-dir <dir>]", 2);
347
477
  const dir = dirFrom(rest, 2);
348
478
  const card = findCard(loadBoard(dir), id);
349
479
  const report = unwrapReport(JSON.parse(readFileSync(resolve(reportFile), "utf8")));
@@ -352,8 +482,10 @@ try {
352
482
  const batch = plan && Array.isArray(plan.cards) && plan.cards.includes(id) ? plan.cards : null;
353
483
  const taskIds = batch ? plan.tasks.filter((t) => t.card === id).map((t) => t.id) : [];
354
484
  const wall = opts.run ? wallForRun({ runId: opts.run, claudeDir: opts["claude-dir"], projectDir: dir, wallMinutes: report.budget?.wallMinutes ?? DEFAULT_BUDGET.wallMinutes }) : 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 });
485
+ // A plan landed after `plan.mjs done --force` is recorded as landed, with land's own acceptance.
486
+ const { forced, gate: forcedGate } = forcedLanding({ plan, report, opts, card: id });
487
+ const entry = runEntry({ forcedLanding: forced, 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, fixture: opts["rehearsal-fixture"] ?? null });
488
+ const block = landingBlock({ id, ok: report.ok === true || forced, commit: opts.commit, wall: opts.wall, gate: forced ? forcedGate : acceptanceGate(report), cost: opts.cost, codexCost: opts["codex-cost"], note: opts.note, rehearsal: opts.rehearsal ?? null, rehearsalOutcome: opts["rehearsal-outcome"] ?? null });
357
489
  const { file, promoted } = recordLanding(dir, id, entry);
358
490
  process.stdout.write(`${summarySentence}\n`);
359
491
  if (promoted) process.stdout.write(`Promoted the research note for ${id} to ${promoted}.\n`);
@@ -382,14 +514,15 @@ try {
382
514
  if (saved.length === 3) sumSaved = saved;
383
515
  } catch {}
384
516
  }
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;
517
+ const sumForced = opts.card !== undefined ? forcedLanding({ plan: sumPlan, report: wholeReport, opts, card: opts.card }) : { forced: false, gate: null };
518
+ const sumBlock = sumSaved ? sumSaved : opts.card !== undefined ? landingBlock({ id: opts.card, ok: report.ok === true || sumForced.forced, commit: opts.commit, wall: opts.wall, gate: sumForced.forced ? sumForced.gate : acceptanceGate(wholeReport), cost: opts.cost, codexCost: opts["codex-cost"], note: opts.note }) : null;
386
519
  process.stdout.write(`${summarySentence}\n`);
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 }));
520
+ process.stdout.write(runSummary({ report, forcedLanding: sumForced.forced, 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
521
  if (sumBlock) writeLandingBlock(dirFrom(rest, 1), opts.card, sumBlock, sumSaved !== null);
389
522
  break;
390
523
  }
391
524
  default:
392
- fail("usage: board.mjs <next|card|reorder|move|edit|remove|record|summary> ...", 2);
525
+ fail("usage: board.mjs <next|card|reorder|move|edit|epic|milestone|rollup|labels|remove|record|summary> ...", 2);
393
526
  }
394
527
  } catch (err) {
395
528
  fail(err.message);
@@ -14,13 +14,14 @@
14
14
  // --claude-dir <path> where ~/.claude lives (tests point it at a fixture)
15
15
  // --report <path> a saved workflow report (card adversary-usage-in-report): prices its adversary relays'
16
16
  // Codex usage as a separate line/`codex` key, never mixed into the Claude total; default
17
- // <dir>/REPORT_RELPATH (the last report any run wrote there, not necessarily this run's -
18
- // the report carries no run id) when no --report is given and that file is readable
17
+ // <dir>/REPORT_RELPATH when no --report is given and that file is readable. A report is
18
+ // attached only when its top-level runId equals the run asked for; a report with no runId
19
+ // is attached only when named with --report (card cost-run-codex-line-mismatch)
19
20
  // Exit 1 when the run's journal cannot be found, or an explicit --report cannot be read/parsed; the text output ends
20
21
  // with the run total and any unpriced agent. An unreadable/invalid default report is ignored, not a usage error.
21
22
  import { readFileSync } from "node:fs";
22
23
  import { resolve, join } from "node:path";
23
- import { costRun, costSession, renderSession, renderCost, costByCard, renderCostByCard } from "../lib/cost.mjs";
24
+ import { reportForRun, costRun, costSession, renderSession, renderCost, costByCard, renderCostByCard } from "../lib/cost.mjs";
24
25
  import { planFor, REPORT_RELPATH, unwrapReport } from "../lib/plan.mjs";
25
26
 
26
27
  const argv = process.argv.slice(2);
@@ -81,6 +82,8 @@ try {
81
82
  reportPath = null;
82
83
  }
83
84
  }
85
+ report = reportForRun(report, runId, { named: Boolean(opts.report) });
86
+ if (!report) reportPath = null;
84
87
  const result = costRun({ runId, projectDir, claudeDir: opts["claude-dir"] ? resolve(opts["claude-dir"]) : undefined, report, reportPath });
85
88
  const byCard = opts["by-card"] ? costByCard(result, planFor(projectDir, opts.card)) : null;
86
89
  if (opts.json) process.stdout.write(JSON.stringify(byCard ? { ...result, byCard } : result, null, 2) + "\n");
@@ -95,7 +95,7 @@ function readOwnSkills() {
95
95
  function signalsText(s) {
96
96
  return [
97
97
  `outcomes: ${s.outcomes.rows.length} rows, ${s.outcomes.gateFailures} gate failures, blocks real=${s.outcomes.blocksByClass.real} marginal=${s.outcomes.blocksByClass.marginal} false=${s.outcomes.blocksByClass.false}, fixPasses sum=${s.outcomes.fixPasses.sum} max=${s.outcomes.fixPasses.max}, usd sum=${s.outcomes.usd.sum.toFixed(2)} n=${s.outcomes.usd.n}, cards=${Object.keys(s.outcomes.byCard).length}`,
98
- `lessons: ${s.lessons.total} total, ${s.lessons.live} live, ${s.lessons.helpful} helpful, ${s.lessons.harmful} harmful, ${s.lessons.stale} stale, ${s.lessons.repeated.length} repeated`,
98
+ `lessons: ${s.lessons.total} total, ${s.lessons.live} live, ${s.lessons.helpful} helpful, ${s.lessons.harmful} harmful, ${s.lessons.stale} stale, ${s.lessons.pitfalls.length} pitfalls with occurrences`,
99
99
  `trace: ${s.trace.files.length} files, ${s.trace.denials.length} denials, ${s.trace.unmatched.length} unmatched (reported only, never a proposal signal), ${s.trace.skills.length} skills invoked, ${s.trace.instructionsLoaded.length} instruction files loaded`,
100
100
  ].join("\n");
101
101
  }