@dougharness/cli 1.3.0 → 1.4.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 (38) hide show
  1. package/dist/bin.js +13 -4
  2. package/dist/bin.js.map +1 -1
  3. package/dist/board-page.d.ts +5 -1
  4. package/dist/board-page.js +19 -5
  5. package/dist/board-page.js.map +1 -1
  6. package/dist/board-serve.js +34 -10
  7. package/dist/board-serve.js.map +1 -1
  8. package/dist/board.d.ts +1 -1
  9. package/dist/board.js +160 -13
  10. package/dist/board.js.map +1 -1
  11. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +1 -1
  12. package/node_modules/@dougharness/flow/hooks/register.tsx +18 -2
  13. package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +94 -7
  14. package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +31 -4
  15. package/node_modules/@dougharness/flow/lib/board.mjs +366 -21
  16. package/node_modules/@dougharness/flow/lib/card-loop.mjs +194 -20
  17. package/node_modules/@dougharness/flow/lib/cost.mjs +9 -0
  18. package/node_modules/@dougharness/flow/lib/memory.mjs +24 -1
  19. package/node_modules/@dougharness/flow/package.json +1 -1
  20. package/node_modules/@dougharness/flow/scripts/autopilot.mjs +193 -12
  21. package/node_modules/@dougharness/flow/scripts/board.mjs +122 -13
  22. package/node_modules/@dougharness/flow/scripts/cost.mjs +6 -3
  23. package/node_modules/@dougharness/flow/scripts/memory.mjs +14 -7
  24. package/node_modules/@dougharness/flow/scripts/session-recall.mjs +11 -4
  25. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +1 -1
  26. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +9 -2
  27. package/node_modules/@dougharness/flow/skills/research/SKILL.md +1 -1
  28. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +2 -2
  29. package/node_modules/@dougharness/flow/workflows/card-queen.js +195 -21
  30. package/node_modules/@dougharness/flow/workflows/doug-implement.js +21 -18
  31. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  32. package/node_modules/@dougharness/gates/lib/baseline.mjs +23 -0
  33. package/node_modules/@dougharness/gates/lib/block-ledger.mjs +41 -3
  34. package/node_modules/@dougharness/gates/lib/docs-gate.mjs +6 -2
  35. package/node_modules/@dougharness/gates/package.json +1 -1
  36. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +91 -18
  37. package/package.json +3 -3
  38. package/templates/board-app.js +240 -30
@@ -9,24 +9,30 @@
9
9
  // autopilot.mjs stop <id> --reason "<why>"
10
10
  // autopilot.mjs filed <id> --kind <follow-up|user> [--from <card>] --position "<text>"
11
11
  // autopilot.mjs caps [--usd <n>] [--wall <min>] [--context <pct>]
12
- // autopilot.mjs pick [--max <n>] JSON { launch, needsPlan, held, alone }; a held line per held card on stderr
13
- // autopilot.mjs queen-args <id> --kind <flow|hand> [--with <id,id,...>] writes .doug/.state/reports/<id>/queen-args.json and the per-card queen.js beside it, prints both paths (args, then queen.js)
12
+ // autopilot.mjs pick [--max <n>] [--milestone <id>] JSON { launch, needsPlan, held, alone }; a held line per held card on stderr
13
+ // autopilot.mjs queen-args <id> --kind <flow|hand> [--with <id,id,...>] [--research on|off] writes .doug/.state/reports/<id>/queen-args.json and the per-card queen.js beside it, prints both paths (args, then queen.js)
14
14
  // autopilot.mjs agent-types --kind <flow|hand> prints the agent types that queen spawns, one per line
15
15
  // autopilot.mjs launched <id> --run <wf_id> --kind <flow|hand> --worktree <abs> --args-file <path>
16
- // autopilot.mjs report --run <wf_id> --file <result.json> saves the queen's report, prints land|resume|ask
17
- // autopilot.mjs resumed <id> --run <wf_id>
16
+ // autopilot.mjs report --run <wf_id> --file <result.json> saves the queen's report (a hand card's inline researchNote is written to .doug/.state/research/<id>.md so record promotes it), prints land|resume|ask
17
+ // autopilot.mjs answer <id> --answer "<text>" records the user's answer to any asking stop in queen-args.json options.answers
18
+ // autopilot.mjs resumed <id> --run <wf_id> counts every resume, plain or with the answer, against the single plain resume
19
+ // autopilot.mjs rerun-alone <id> records a rerun-alone answer for a gate-timeout stop; refuses while another queen is in flight or after the card's one resume
20
+ // autopilot.mjs leave <id> --reason "<text>" records the user's choice to leave a card In flow
18
21
  // autopilot.mjs lock-wait <id> --ms <n>
22
+ // autopilot.mjs clear drops an ended or inactive run; a running run is kept
19
23
  // autopilot.mjs end
20
24
  // --dir <d> names the project (default cwd). Exit 0 done; 1 no run or unknown card; 2 bad usage.
21
25
  import { execFileSync } from "node:child_process";
22
- import { copyFileSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
26
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
27
+ import { homedir } from "node:os";
23
28
  import { dirname, join, resolve } from "node:path";
24
29
  import { fileURLToPath } from "node:url";
25
30
  import { loadModels } from "../lib/models.mjs";
26
- import { AUTOPILOT_FILE, STEPS, readAutopilot } from "../lib/autopilot-view.mjs";
31
+ import { AUTOPILOT_FILE, STEPS, clearable, readAutopilot } from "../lib/autopilot-view.mjs";
27
32
  import { contextThreshold } from "../lib/context.mjs";
28
33
  import { classifyNotification, pickCards, queenLabel, renameQueenMeta, workersMax } from "../lib/coordinator.mjs";
29
34
  import { nextReadyCards } from "../lib/board.mjs";
35
+ import { SEATS } from "../lib/card-loop.mjs";
30
36
  import { planFor, reportPathFor } from "../lib/plan.mjs";
31
37
 
32
38
  const argv = process.argv.slice(2);
@@ -82,10 +88,43 @@ function cardOf(s, cardId) {
82
88
  if (!c) fail(`unknown card ${cardId}`);
83
89
  return c;
84
90
  }
91
+ const IN_FLIGHT = ["running", "done", "landing"];
92
+ const RERUN_ALONE = "Rerun alone: no other queen is in flight now.";
93
+ const othersInFlight = (s, id) => s.cards.filter((x) => x.id !== id && IN_FLIGHT.includes(x.state));
94
+ // The saved stop report and queen-args of a card, for the commands that answer an asking stop.
95
+ function savedStop(id, c) {
96
+ const reportDir = join(dir, ".doug/.state/reports", id);
97
+ let rep;
98
+ try {
99
+ rep = JSON.parse(readFileSync(join(reportDir, "card-report.json"), "utf8"));
100
+ } catch (e) {
101
+ fail(`cannot read the saved report for ${id}: ${e.code || e.message}`);
102
+ }
103
+ if (!rep || rep.outcome !== "stopped" || rep.reason === "agent-null" || !SEATS.includes(rep.stoppedAt)) fail(`${id} did not stop on an answerable ask stop`);
104
+ const argsPath = join(reportDir, "queen-args.json");
105
+ let qa;
106
+ try {
107
+ qa = JSON.parse(readFileSync(argsPath, "utf8"));
108
+ } catch (e) {
109
+ fail(`cannot read ${argsPath}: ${e.code || e.message}`);
110
+ }
111
+ const stopped = (Array.isArray(rep.history) ? rep.history : []).filter((h) => h && h.result === "stopped").pop();
112
+ const entry = { seat: rep.stoppedAt, question: rep.detail, answer: "" };
113
+ if (stopped && Number.isInteger(stopped.round) && stopped.round > 0) entry.round = stopped.round;
114
+ entry.run = c.runId;
115
+ return { rep, argsPath, qa, entry };
116
+ }
85
117
  const clearStep = (c) => {
86
118
  delete c.step;
87
119
  delete c.stepStartedAt;
120
+ delete c.seats;
88
121
  };
122
+ // Where the queen run's journal lives: the main session's run dir under the user's Claude dir; "" without a session id.
123
+ function journalPath(run) {
124
+ const sid = process.env.CLAUDE_CODE_SESSION_ID;
125
+ if (!sid) return "";
126
+ return join(homedir(), ".claude", "projects", dir.replace(/[^A-Za-z0-9]/g, "-"), sid, "subagents", "workflows", run, "journal.jsonl");
127
+ }
89
128
 
90
129
  function boardCard(cardId) {
91
130
  try {
@@ -169,6 +208,7 @@ switch (cmd) {
169
208
  const c = cardOf(s, id);
170
209
  c.step = arg2;
171
210
  c.stepStartedAt = new Date().toISOString();
211
+ c.seats = [...(Array.isArray(c.seats) ? c.seats : []), { step: arg2, startedAt: c.stepStartedAt }];
172
212
  save(s);
173
213
  say(`${id} ${arg2}`);
174
214
  break;
@@ -256,9 +296,14 @@ switch (cmd) {
256
296
  const b = boardCard(c.id);
257
297
  return b ? shape(b) : { id: c.id, goal: "", track: c.kind === "flow" ? "flow" : "hand" };
258
298
  });
259
- const ready = nextReadyCards(board, { track: "any", batch: Math.max(1, board.cards.length) })
260
- .cards.filter((c) => !flyingIds.has(c.id))
261
- .map(shape);
299
+ if (opts.milestone === "") usage("--milestone needs an id");
300
+ let picked;
301
+ try {
302
+ picked = nextReadyCards(board, { track: "any", batch: Math.max(1, board.cards.length), milestone: opts.milestone });
303
+ } catch (e) {
304
+ fail(e instanceof Error ? e.message : String(e));
305
+ }
306
+ const ready = picked.cards.filter((c) => !flyingIds.has(c.id)).map(shape);
262
307
  const plans = {};
263
308
  for (const c of [...ready, ...inFlight]) {
264
309
  if (c.track !== "flow") continue;
@@ -280,6 +325,10 @@ switch (cmd) {
280
325
  if (!id) usage("queen-args needs a card id");
281
326
  const kind = need("kind");
282
327
  if (kind !== "flow" && kind !== "hand") usage("--kind must be flow or hand");
328
+ if (opts.research !== undefined) {
329
+ if (kind === "flow") usage("--research is for a hand card; research for a flow card runs before its plan (doug-next section 1)");
330
+ if (opts.research !== "on" && opts.research !== "off") usage("--research must be on or off");
331
+ }
283
332
  let args;
284
333
  if (kind === "flow") {
285
334
  try {
@@ -296,15 +345,21 @@ switch (cmd) {
296
345
  } catch {
297
346
  // absent or unreadable: no install command
298
347
  }
348
+ let base = "main";
349
+ try {
350
+ base = execFileSync("git", ["-C", dir, "symbolic-ref", "--short", "-q", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || "main";
351
+ } catch {
352
+ // not a git repo or a detached HEAD: main
353
+ }
299
354
  args = {
300
355
  card: { id: b.id, title: b.title ?? null, size: b.size ?? null, class: b.class ?? null, goal: b.goal ?? "" },
301
356
  options: {
302
357
  testerAgent: true,
303
- research: false,
358
+ research: opts.research === "on",
304
359
  install: (config.commands && config.commands.install) || null,
305
360
  gateCommand: "node .doug/hooks/scripts/verify.mjs",
306
361
  branch: `card/${id}`,
307
- base: "main",
362
+ base,
308
363
  },
309
364
  models: loadModels(dir),
310
365
  repoRoot: dir,
@@ -370,6 +425,9 @@ switch (cmd) {
370
425
  c.kind = kind;
371
426
  c.worktree = worktree;
372
427
  clearStep(c);
428
+ delete c.journals;
429
+ const journal = journalPath(run);
430
+ if (journal) c.journals = [journal];
373
431
  save(s);
374
432
  say(`${id} launched as ${run}`);
375
433
  break;
@@ -393,6 +451,11 @@ switch (cmd) {
393
451
  // unreadable: classify says resume
394
452
  }
395
453
  const kind = c.kind === "flow" ? "flow" : "hand";
454
+ // The queen's task-output file wraps the result: {summary, agentCount, logs, result}.
455
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) && parsed.result !== null && typeof parsed.result === "object") {
456
+ const bare = kind === "flow" ? !("ok" in parsed) && !("paused" in parsed) : !("card" in parsed) && !("outcome" in parsed);
457
+ if (bare) parsed = parsed.result;
458
+ }
396
459
  if (parsed !== null) {
397
460
  const dest = kind === "flow" ? join(dir, reportPathFor(c.id)) : join(dir, ".doug/.state/reports", c.id, "card-report.json");
398
461
  try {
@@ -401,11 +464,94 @@ switch (cmd) {
401
464
  } catch (e) {
402
465
  process.stderr.write(`autopilot: warning: cannot write ${dest}: ${e.code || e.message}\n`);
403
466
  }
467
+ if (kind === "hand") {
468
+ const rep = parsed.report ?? parsed;
469
+ const note = rep && typeof rep.researchNote === "string" ? rep.researchNote : "";
470
+ if (note.trim() !== "") {
471
+ const noteDest = join(dir, ".doug/.state/research", `${c.id}.md`);
472
+ try {
473
+ const oneLine = note.trim();
474
+ let src = null;
475
+ if (!oneLine.includes("\n") && oneLine.startsWith("/") && resolve(oneLine) !== resolve(noteDest) && existsSync(oneLine)) src = oneLine;
476
+ if (resolve(oneLine) !== resolve(noteDest)) {
477
+ mkdirSync(dirname(noteDest), { recursive: true });
478
+ if (src) copyFileSync(src, noteDest);
479
+ else writeFileSync(noteDest, note);
480
+ }
481
+ } catch (e) {
482
+ process.stderr.write(`autopilot: warning: cannot write ${noteDest}: ${e.code || e.message}\n`);
483
+ }
484
+ }
485
+ }
404
486
  }
405
487
  const v = classifyNotification(kind, { status: "completed", result: parsed === null ? text : parsed });
488
+ if (v.action === "ask") {
489
+ let answered = false;
490
+ try {
491
+ const saved = JSON.parse(readFileSync(join(dir, ".doug/.state/reports", c.id, "queen-args.json"), "utf8"));
492
+ const rep = parsed?.report ?? parsed;
493
+ const stop = (Array.isArray(rep?.history) ? rep.history : []).filter((h) => h && h.result === "stopped").pop();
494
+ answered =
495
+ Array.isArray(saved?.options?.answers) &&
496
+ saved.options.answers.some(
497
+ (a) => a && a.run === run && a.seat === rep?.stoppedAt && stop && a.round === stop.round && a.question === rep?.detail,
498
+ );
499
+ } catch {
500
+ // no readable args: not answered
501
+ }
502
+ if (answered) {
503
+ say(`${c.id} resume with the answer`);
504
+ break;
505
+ }
506
+ }
507
+ if (v.action === "ask" && v.reason === "gate-timeout" && !c.resumes && othersInFlight(s, c.id).length > 0) {
508
+ c.state = "stopped";
509
+ c.reason = "gate-timeout: rerun alone pending";
510
+ save(s);
511
+ say(`${c.id} rerun alone gate-timeout`);
512
+ break;
513
+ }
406
514
  say(`${c.id} ${v.action}${v.reason ? ` ${v.reason}` : ""}`);
407
515
  break;
408
516
  }
517
+ case "answer": {
518
+ if (!id) usage("answer needs a card id");
519
+ const answer = need("answer");
520
+ if (answer.trim() === "") usage("--answer needs a non-blank answer");
521
+ const s = load();
522
+ const c = cardOf(s, id);
523
+ const { rep, argsPath, qa, entry } = savedStop(id, c);
524
+ entry.answer = answer;
525
+ qa.options = { ...(qa.options || {}), answers: [...(Array.isArray(qa.options?.answers) ? qa.options.answers : []), entry] };
526
+ writeFileSync(argsPath, JSON.stringify(qa, null, 2) + "\n");
527
+ say(`${id} answer recorded for ${entry.seat}`);
528
+ break;
529
+ }
530
+ case "rerun-alone": {
531
+ if (!id) usage("rerun-alone needs a card id");
532
+ const s = load();
533
+ const c = cardOf(s, id);
534
+ const others = othersInFlight(s, id);
535
+ if (others.length > 0) fail(`${others.map((x) => x.id).join(", ")} still in flight; rerun ${id} alone after it lands`);
536
+ if (c.resumes >= 1) fail(`${id} was already resumed once`);
537
+ const { rep, argsPath, qa, entry } = savedStop(id, c);
538
+ entry.answer = RERUN_ALONE;
539
+ qa.options = { ...(qa.options || {}), answers: [...(Array.isArray(qa.options?.answers) ? qa.options.answers : []), entry] };
540
+ writeFileSync(argsPath, JSON.stringify(qa, null, 2) + "\n");
541
+ say(`${id} rerun alone recorded for ${rep.stoppedAt}`);
542
+ break;
543
+ }
544
+ case "leave": {
545
+ if (!id) usage("leave needs a card id");
546
+ const reason = need("reason");
547
+ if (reason.trim() === "") usage("--reason needs a non-blank reason");
548
+ const s = load();
549
+ const c = cardOf(s, id);
550
+ c.left = reason;
551
+ save(s);
552
+ say(`${id} left In flow: ${reason}`);
553
+ break;
554
+ }
409
555
  case "resumed": {
410
556
  if (!id) usage("resumed needs a card id");
411
557
  const run = need("run");
@@ -413,6 +559,9 @@ switch (cmd) {
413
559
  const c = cardOf(s, id);
414
560
  c.resumes = (c.resumes || 0) + 1;
415
561
  c.runId = run;
562
+ c.state = "running";
563
+ const journal = journalPath(run);
564
+ if (journal) c.journals = [...(Array.isArray(c.journals) ? c.journals : []), journal];
416
565
  save(s);
417
566
  say(`${id} resumed as ${run}`);
418
567
  break;
@@ -427,8 +576,40 @@ switch (cmd) {
427
576
  say(`${id} lock wait ${c.lockWaitMs}ms`);
428
577
  break;
429
578
  }
579
+ case "clear": {
580
+ if (!existsSync(file)) say("nothing to clear");
581
+ else {
582
+ const s = readAutopilot(dir);
583
+ if (!s) say("kept the unreadable file");
584
+ else if (clearable(s, new Date())) {
585
+ rmSync(file);
586
+ say("cleared");
587
+ } else say("kept the running run");
588
+ }
589
+ break;
590
+ }
430
591
  case "end": {
431
592
  const s = load();
593
+ if (s.cards.length > 0) {
594
+ let board;
595
+ try {
596
+ board = JSON.parse(readFileSync(join(dir, ".doug/board.json"), "utf8"));
597
+ } catch (e) {
598
+ fail(`cannot read the board to check cards In flow: ${e.code || e.message}`);
599
+ }
600
+ const stuck = s.cards.filter((c) => {
601
+ if ((board.cards || []).find((b) => b.id === c.id)?.column !== "flow") return false;
602
+ if (c.resumes >= 1 || (typeof c.left === "string" && c.left.trim() !== "")) return false;
603
+ let answers = [];
604
+ try {
605
+ answers = JSON.parse(readFileSync(join(dir, ".doug/.state/reports", c.id, "queen-args.json"), "utf8")).options.answers;
606
+ } catch {
607
+ // no readable args: not answered
608
+ }
609
+ return !(Array.isArray(answers) && answers.some((a) => a && a.run === c.runId && a.answer !== RERUN_ALONE));
610
+ });
611
+ if (stuck.length > 0) fail(`cannot end: ${stuck.map((c) => c.id).join(", ")} still In flow, neither resumed nor answered`);
612
+ }
432
613
  s.status = "ended";
433
614
  s.endedAt = new Date().toISOString();
434
615
  save(s);
@@ -436,5 +617,5 @@ switch (cmd) {
436
617
  break;
437
618
  }
438
619
  default:
439
- usage(`unknown subcommand${cmd ? ` ${cmd}` : ""}; use start, add, card, step, land, stop, filed, caps, pick, launched, report, resumed, lock-wait, or end`);
620
+ usage(`unknown subcommand${cmd ? ` ${cmd}` : ""}; use start, add, card, step, land, stop, filed, caps, pick, launched, report, answer, rerun-alone, leave, resumed, lock-wait, clear, or end`);
440
621
  }
@@ -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,16 +14,25 @@
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
@@ -49,29 +58,42 @@
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
  };
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");
75
97
  const RECORD_HAND_FLAGS = ["summary", "hand", "commit", "wall", "gate", "note", "rehearsal", "rehearsal-outcome", "cost"];
76
98
  const RECORD_REPORT_FLAGS = ["summary", "cost", "codex-cost", "wall", "commit", "adversary", "shared-cost", "note", "rehearsal", "rehearsal-outcome", "run", "claude-dir"];
77
99
  const opts = {};
@@ -98,13 +120,15 @@ 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
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>] [--cost <usd>]";
@@ -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) {
@@ -198,7 +228,7 @@ try {
198
228
  if (batch !== null && !(Number.isInteger(batch) && batch > 0)) fail(`--batch must be a positive integer, got ${JSON.stringify(opts.batch)}`, 2);
199
229
  if (opts.tag !== undefined && opts.tag.includes(",")) fail(`--tag takes one tag here; got "${opts.tag}"`, 2);
200
230
  const board = loadBoard(dir);
201
- const { card, cards, skipped } = batch === null ? nextReadyCard(board, { track, tag: opts.tag }) : nextReadyCards(board, { track, batch, tag: opts.tag });
231
+ 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
232
  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
233
  const capLine = readyCapWarning(board);
204
234
  if (capLine) process.stderr.write(capLine);
@@ -236,6 +266,42 @@ try {
236
266
  process.stdout.write(out.join("\n") + "\n");
237
267
  break;
238
268
  }
269
+ case "rollup": {
270
+ if (opts.epic !== undefined && opts.milestone !== undefined) fail(USAGE_FOR.rollup, 2);
271
+ const dir = dirFrom(rest, 0);
272
+ const board = loadBoard(dir);
273
+ try {
274
+ checkFilters(board, { epic: opts.epic, milestone: opts.milestone });
275
+ } catch (e) {
276
+ fail(e.message, 2);
277
+ }
278
+ let text = null;
279
+ if (opts.from !== undefined) {
280
+ try {
281
+ text = readFileSync(resolve(opts.from), "utf8");
282
+ } catch (e) {
283
+ fail(`cannot read --from file ${opts.from}: ${e.message}`, 2);
284
+ }
285
+ } else if (existsSync(join(dir, LIVE_RUNS_RELPATH))) {
286
+ text = readFileSync(join(dir, LIVE_RUNS_RELPATH), "utf8");
287
+ }
288
+ const r = rollup(board, text);
289
+ 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;
290
+ process.stdout.write(JSON.stringify(out, null, 2) + "\n");
291
+ break;
292
+ }
293
+ case "labels": {
294
+ if (rest[0] === "init" && opts.check !== true) {
295
+ const dir = dirFrom(rest, 1);
296
+ saveBoard(dir, initLabelGroups(loadBoard(dir)));
297
+ process.stdout.write(`Added label groups type and priority to ${relative(dir, boardPath(dir))}.\n`);
298
+ break;
299
+ }
300
+ if (opts.check !== true) fail(USAGE_FOR.labels, 2);
301
+ const lines = labelContrastFailures(loadBoard(dirFrom(rest, 0)));
302
+ if (lines.length) process.stdout.write(lines.join("\n") + "\n");
303
+ break;
304
+ }
239
305
  case "card": {
240
306
  const dir = dirFrom(rest, 1);
241
307
  process.stdout.write(JSON.stringify(findCard(loadBoard(dir), rest[0]), null, 2) + "\n");
@@ -272,6 +338,8 @@ try {
272
338
  if (opts.deps !== undefined) fields.deps = opts.deps.split(",").map((d) => d.trim()).filter(Boolean);
273
339
  if (opts.tag !== undefined) fields.tags = opts.tag.split(",").map((t) => t.trim()).filter(Boolean);
274
340
  if (opts.source !== undefined) fields.source = opts.source;
341
+ if (opts.epic !== undefined) fields.epic = opts.epic === "" ? null : opts.epic;
342
+ if (opts.milestone !== undefined) fields.milestone = opts.milestone === "" ? null : opts.milestone;
275
343
  const changed = EDIT_FIELD_ORDER.filter((k) => Object.prototype.hasOwnProperty.call(fields, k));
276
344
  if (!changed.length) fail(EDIT_USAGE, 2);
277
345
  const dir = dirFrom(rest, 1);
@@ -282,6 +350,47 @@ try {
282
350
  process.stdout.write(`Edited ${id} (${changed.join(", ")}) in ${relative(dir, boardPath(dir))}.\n`);
283
351
  break;
284
352
  }
353
+ case "epic":
354
+ case "milestone": {
355
+ const [action, id] = rest;
356
+ const add = cmd === "epic" ? addEpic : addMilestone;
357
+ const edit = cmd === "epic" ? editEpic : editMilestone;
358
+ const targetOf = () => {
359
+ const t = {};
360
+ if (opts.version !== undefined) t.version = opts.version === "" ? null : opts.version;
361
+ if (opts.date !== undefined) t.date = opts.date === "" ? null : opts.date;
362
+ return t;
363
+ };
364
+ if (action === "list") {
365
+ const board = loadBoard(dirFrom(rest, 1));
366
+ process.stdout.write(JSON.stringify(board[`${cmd}s`] || [], null, 2) + "\n");
367
+ break;
368
+ }
369
+ if (!id) fail(planUsage(cmd), 2);
370
+ if (action === "add") {
371
+ if (!opts.title || !opts.goal) fail(planUsage(cmd), 2);
372
+ const dir = dirFrom(rest, 2);
373
+ const target = {};
374
+ if (opts.version) target.version = opts.version;
375
+ if (opts.date) target.date = opts.date;
376
+ saveBoard(dir, add(loadBoard(dir), { id, title: opts.title, goal: opts.goal, ...(Object.keys(target).length ? { target } : {}) }));
377
+ process.stdout.write(`Added ${cmd} ${id} in ${relative(dir, boardPath(dir))}.\n`);
378
+ break;
379
+ }
380
+ const fields = {};
381
+ if (opts.title !== undefined) fields.title = opts.title;
382
+ if (opts.goal !== undefined) fields.goal = opts.goal;
383
+ if (opts.status !== undefined) fields.status = opts.status;
384
+ const target = targetOf();
385
+ if (Object.keys(target).length) fields.target = target;
386
+ if (!Object.keys(fields).length) fail(planUsage(cmd), 2);
387
+ const dir = dirFrom(rest, 2);
388
+ const { board, warnings } = edit(loadBoard(dir), id, fields, { force: !!opts.force });
389
+ saveBoard(dir, board);
390
+ for (const w of warnings) process.stderr.write(`warning: ${w}\n`);
391
+ process.stdout.write(`Edited ${cmd} ${id} (${Object.keys(fields).join(", ")}) in ${relative(dir, boardPath(dir))}.\n`);
392
+ break;
393
+ }
285
394
  case "remove": {
286
395
  const [id] = rest;
287
396
  if (!id) fail("usage: board.mjs remove <id> [dir] [--force]", 2);
@@ -389,7 +498,7 @@ try {
389
498
  break;
390
499
  }
391
500
  default:
392
- fail("usage: board.mjs <next|card|reorder|move|edit|remove|record|summary> ...", 2);
501
+ fail("usage: board.mjs <next|card|reorder|move|edit|epic|milestone|rollup|labels|remove|record|summary> ...", 2);
393
502
  }
394
503
  } catch (err) {
395
504
  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");