@dougharness/cli 1.2.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 (47) 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/agents/implementer.md +1 -1
  13. package/node_modules/@dougharness/flow/agents/planner.md +1 -1
  14. package/node_modules/@dougharness/flow/agents/researcher.md +2 -1
  15. package/node_modules/@dougharness/flow/agents/reviewer.md +3 -1
  16. package/node_modules/@dougharness/flow/hooks/register.tsx +67 -32
  17. package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +94 -7
  18. package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +31 -4
  19. package/node_modules/@dougharness/flow/lib/board.mjs +366 -21
  20. package/node_modules/@dougharness/flow/lib/card-loop.mjs +229 -23
  21. package/node_modules/@dougharness/flow/lib/coordinator.mjs +25 -0
  22. package/node_modules/@dougharness/flow/lib/cost.mjs +9 -0
  23. package/node_modules/@dougharness/flow/lib/decisions.mjs +4 -2
  24. package/node_modules/@dougharness/flow/lib/memory.mjs +382 -65
  25. package/node_modules/@dougharness/flow/lib/plan.mjs +1 -1
  26. package/node_modules/@dougharness/flow/package.json +1 -1
  27. package/node_modules/@dougharness/flow/scripts/autopilot.mjs +228 -14
  28. package/node_modules/@dougharness/flow/scripts/board.mjs +122 -13
  29. package/node_modules/@dougharness/flow/scripts/cost.mjs +6 -3
  30. package/node_modules/@dougharness/flow/scripts/memory.mjs +27 -8
  31. package/node_modules/@dougharness/flow/scripts/plan.mjs +5 -1
  32. package/node_modules/@dougharness/flow/scripts/session-recall.mjs +11 -4
  33. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +1 -1
  34. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +9 -2
  35. package/node_modules/@dougharness/flow/skills/research/SKILL.md +2 -2
  36. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +2 -2
  37. package/node_modules/@dougharness/flow/workflows/card-queen.js +238 -25
  38. package/node_modules/@dougharness/flow/workflows/doug-implement.js +77 -34
  39. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  40. package/node_modules/@dougharness/gates/lib/baseline.mjs +23 -0
  41. package/node_modules/@dougharness/gates/lib/block-ledger.mjs +41 -3
  42. package/node_modules/@dougharness/gates/lib/docs-gate.mjs +6 -2
  43. package/node_modules/@dougharness/gates/package.json +1 -1
  44. package/node_modules/@dougharness/gates/scripts/research-cap.mjs +119 -15
  45. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +107 -20
  46. package/package.json +3 -3
  47. package/templates/board-app.js +240 -30
@@ -9,23 +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> writes .doug/.state/reports/<id>/queen-args.json, prints its path
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
+ // autopilot.mjs agent-types --kind <flow|hand> prints the agent types that queen spawns, one per line
14
15
  // autopilot.mjs launched <id> --run <wf_id> --kind <flow|hand> --worktree <abs> --args-file <path>
15
- // autopilot.mjs report --run <wf_id> --file <result.json> saves the queen's report, prints land|resume|ask
16
- // 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
17
21
  // autopilot.mjs lock-wait <id> --ms <n>
22
+ // autopilot.mjs clear drops an ended or inactive run; a running run is kept
18
23
  // autopilot.mjs end
19
24
  // --dir <d> names the project (default cwd). Exit 0 done; 1 no run or unknown card; 2 bad usage.
20
25
  import { execFileSync } from "node:child_process";
21
- import { copyFileSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
26
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
27
+ import { homedir } from "node:os";
22
28
  import { dirname, join, resolve } from "node:path";
23
29
  import { fileURLToPath } from "node:url";
24
30
  import { loadModels } from "../lib/models.mjs";
25
- import { AUTOPILOT_FILE, STEPS, readAutopilot } from "../lib/autopilot-view.mjs";
31
+ import { AUTOPILOT_FILE, STEPS, clearable, readAutopilot } from "../lib/autopilot-view.mjs";
26
32
  import { contextThreshold } from "../lib/context.mjs";
27
- import { classifyNotification, pickCards, workersMax } from "../lib/coordinator.mjs";
33
+ import { classifyNotification, pickCards, queenLabel, renameQueenMeta, workersMax } from "../lib/coordinator.mjs";
28
34
  import { nextReadyCards } from "../lib/board.mjs";
35
+ import { SEATS } from "../lib/card-loop.mjs";
29
36
  import { planFor, reportPathFor } from "../lib/plan.mjs";
30
37
 
31
38
  const argv = process.argv.slice(2);
@@ -81,10 +88,43 @@ function cardOf(s, cardId) {
81
88
  if (!c) fail(`unknown card ${cardId}`);
82
89
  return c;
83
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
+ }
84
117
  const clearStep = (c) => {
85
118
  delete c.step;
86
119
  delete c.stepStartedAt;
120
+ delete c.seats;
87
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
+ }
88
128
 
89
129
  function boardCard(cardId) {
90
130
  try {
@@ -168,6 +208,7 @@ switch (cmd) {
168
208
  const c = cardOf(s, id);
169
209
  c.step = arg2;
170
210
  c.stepStartedAt = new Date().toISOString();
211
+ c.seats = [...(Array.isArray(c.seats) ? c.seats : []), { step: arg2, startedAt: c.stepStartedAt }];
171
212
  save(s);
172
213
  say(`${id} ${arg2}`);
173
214
  break;
@@ -255,9 +296,14 @@ switch (cmd) {
255
296
  const b = boardCard(c.id);
256
297
  return b ? shape(b) : { id: c.id, goal: "", track: c.kind === "flow" ? "flow" : "hand" };
257
298
  });
258
- const ready = nextReadyCards(board, { track: "any", batch: Math.max(1, board.cards.length) })
259
- .cards.filter((c) => !flyingIds.has(c.id))
260
- .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);
261
307
  const plans = {};
262
308
  for (const c of [...ready, ...inFlight]) {
263
309
  if (c.track !== "flow") continue;
@@ -279,6 +325,10 @@ switch (cmd) {
279
325
  if (!id) usage("queen-args needs a card id");
280
326
  const kind = need("kind");
281
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
+ }
282
332
  let args;
283
333
  if (kind === "flow") {
284
334
  try {
@@ -295,15 +345,21 @@ switch (cmd) {
295
345
  } catch {
296
346
  // absent or unreadable: no install command
297
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
+ }
298
354
  args = {
299
355
  card: { id: b.id, title: b.title ?? null, size: b.size ?? null, class: b.class ?? null, goal: b.goal ?? "" },
300
356
  options: {
301
357
  testerAgent: true,
302
- research: false,
358
+ research: opts.research === "on",
303
359
  install: (config.commands && config.commands.install) || null,
304
360
  gateCommand: "node .doug/hooks/scripts/verify.mjs",
305
361
  branch: `card/${id}`,
306
- base: "main",
362
+ base,
307
363
  },
308
364
  models: loadModels(dir),
309
365
  repoRoot: dir,
@@ -313,7 +369,39 @@ switch (cmd) {
313
369
  const out = join(dir, ".doug/.state/reports", id, "queen-args.json");
314
370
  mkdirSync(dirname(out), { recursive: true });
315
371
  writeFileSync(out, JSON.stringify(args) + "\n");
316
- process.stdout.write(out + "\n");
372
+ const flying = (readAutopilot(dir)?.cards ?? []).filter((c) => ["running", "done", "landing"].includes(c.state)).map((c) => c.id);
373
+ const batch = String(opts.with ?? "").split(",").map((x) => x.trim()).filter(Boolean);
374
+ const srcFile = join(dirname(fileURLToPath(import.meta.url)), "..", "workflows", kind === "hand" ? "card-queen.js" : "doug-implement.js");
375
+ let renamed;
376
+ try {
377
+ renamed = renameQueenMeta(readFileSync(srcFile, "utf8"), { kind, label: queenLabel(id, [...flying, ...batch]) });
378
+ } catch (e) {
379
+ fail(`cannot build the per-card queen from ${srcFile}: ${e.message}`);
380
+ }
381
+ const script = join(dirname(out), "queen.js");
382
+ rmSync(script, { force: true });
383
+ writeFileSync(script, renamed);
384
+ process.stdout.write(out + "\n" + script + "\n");
385
+ break;
386
+ }
387
+ case "agent-types": {
388
+ const kind = need("kind");
389
+ if (kind !== "flow" && kind !== "hand") usage("--kind must be flow or hand");
390
+ const wf = join(dirname(fileURLToPath(import.meta.url)), "..", "workflows", kind === "hand" ? "card-queen.js" : "doug-implement.js");
391
+ let src;
392
+ try {
393
+ src = readFileSync(wf, "utf8");
394
+ } catch (e) {
395
+ fail(`cannot read ${wf}: ${e.message}`);
396
+ }
397
+ if (kind === "hand") {
398
+ const m = src.match(/const AGENT_TYPES = \{([^}]*)\}/);
399
+ if (!m) fail("AGENT_TYPES not found in card-queen.js");
400
+ src = m[1];
401
+ }
402
+ const types = [...new Set([...src.matchAll(kind === "hand" ? /'(doug-flow:[\w-]+)'/g : /agentType: '([^']+)'/g)].map((x) => x[1]))];
403
+ if (types.length === 0) fail("no agent types found");
404
+ process.stdout.write(types.join("\n") + "\n");
317
405
  break;
318
406
  }
319
407
  case "launched": {
@@ -337,6 +425,9 @@ switch (cmd) {
337
425
  c.kind = kind;
338
426
  c.worktree = worktree;
339
427
  clearStep(c);
428
+ delete c.journals;
429
+ const journal = journalPath(run);
430
+ if (journal) c.journals = [journal];
340
431
  save(s);
341
432
  say(`${id} launched as ${run}`);
342
433
  break;
@@ -360,6 +451,11 @@ switch (cmd) {
360
451
  // unreadable: classify says resume
361
452
  }
362
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
+ }
363
459
  if (parsed !== null) {
364
460
  const dest = kind === "flow" ? join(dir, reportPathFor(c.id)) : join(dir, ".doug/.state/reports", c.id, "card-report.json");
365
461
  try {
@@ -368,11 +464,94 @@ switch (cmd) {
368
464
  } catch (e) {
369
465
  process.stderr.write(`autopilot: warning: cannot write ${dest}: ${e.code || e.message}\n`);
370
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
+ }
371
486
  }
372
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
+ }
373
514
  say(`${c.id} ${v.action}${v.reason ? ` ${v.reason}` : ""}`);
374
515
  break;
375
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
+ }
376
555
  case "resumed": {
377
556
  if (!id) usage("resumed needs a card id");
378
557
  const run = need("run");
@@ -380,6 +559,9 @@ switch (cmd) {
380
559
  const c = cardOf(s, id);
381
560
  c.resumes = (c.resumes || 0) + 1;
382
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];
383
565
  save(s);
384
566
  say(`${id} resumed as ${run}`);
385
567
  break;
@@ -394,8 +576,40 @@ switch (cmd) {
394
576
  say(`${id} lock wait ${c.lockWaitMs}ms`);
395
577
  break;
396
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
+ }
397
591
  case "end": {
398
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
+ }
399
613
  s.status = "ended";
400
614
  s.endedAt = new Date().toISOString();
401
615
  save(s);
@@ -403,5 +617,5 @@ switch (cmd) {
403
617
  break;
404
618
  }
405
619
  default:
406
- 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`);
407
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");