@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,11 +1,11 @@
1
1
  // The autopilot state file (.doug/.state/autopilot.json, card autopilot-live-queue) as the pane and the served board
2
2
  // page show it. The pure view lives in autopilot-core.mjs (no Node imports, so the pane can use it); this module adds
3
3
  // the file read. Neither throws on a missing, unreadable, or malformed file; a view of "no run" is null.
4
- import { readFileSync } from "node:fs";
5
- import { join } from "node:path";
6
- import { AUTOPILOT_FILE, parseAutopilot } from "./autopilot-core.mjs";
4
+ import { readFileSync, readdirSync, statSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+ import { AUTOPILOT_FILE, parseAutopilot, startedFromList } from "./autopilot-core.mjs";
7
7
 
8
- export { AUTOPILOT_FILE, STEPS, INACTIVE_MS, parseAutopilot, autopilotView, autopilotLines, shouldOpenPane } from "./autopilot-core.mjs";
8
+ export { AUTOPILOT_FILE, STEPS, INACTIVE_MS, parseAutopilot, autopilotView, autopilotLines, shouldOpenPane, clearable, journalSeats, startedFromList } from "./autopilot-core.mjs";
9
9
 
10
10
  // The parsed state, or null for a missing or unreadable file, invalid JSON, a wrong version, or a non-object.
11
11
  export function readAutopilot(dir) {
@@ -15,3 +15,30 @@ export function readAutopilot(dir) {
15
15
  return null;
16
16
  }
17
17
  }
18
+
19
+ // The run data autopilotView reads for each running queen card: its journal texts and the seat start times from the
20
+ // agent meta files beside them. Never throws; an unreadable journal is left out.
21
+ export function readRuns(state) {
22
+ const out = {};
23
+ try {
24
+ for (const c of Array.isArray(state?.cards) ? state.cards : []) {
25
+ if (!c || c.state !== "running" || !Array.isArray(c.journals)) continue;
26
+ const journals = [];
27
+ let startedMs = {};
28
+ for (const j of c.journals) {
29
+ try {
30
+ journals.push(readFileSync(j, "utf8"));
31
+ const d = dirname(j);
32
+ const list = readdirSync(d).map((name) => ({ name, mtimeMs: statSync(join(d, name)).mtimeMs }));
33
+ startedMs = { ...startedMs, ...startedFromList(list) };
34
+ } catch {
35
+ // an unreadable journal adds no seats
36
+ }
37
+ }
38
+ if (journals.length) out[c.id] = { journals, startedMs };
39
+ }
40
+ } catch {
41
+ // no run data
42
+ }
43
+ return out;
44
+ }
@@ -42,6 +42,35 @@ function stopText(t) {
42
42
  return `${t.id}${t.stopClass ? ` [${t.stopClass}]` : ""}: "${t.stopReason}"`;
43
43
  }
44
44
  export const DEFAULT_TAGS = ["bug", "feature", "chore", "docs", "refactor", "spike"];
45
+ // Record version 2 (decision 0016): the priority labels, highest first, and the label groups a new board gets.
46
+ export const PRIORITY_LABELS = ["urgent", "high", "medium", "low"];
47
+ export const DEFAULT_LABEL_GROUPS = [
48
+ {
49
+ id: "type",
50
+ title: "Type",
51
+ exclusive: true,
52
+ labels: [
53
+ { name: "bug", color: "#d73a4a" },
54
+ { name: "feature", color: "#2f81f7" },
55
+ { name: "chore", color: "#848d97" },
56
+ { name: "docs", color: "#0e8a9e" },
57
+ { name: "refactor", color: "#a371f7" },
58
+ { name: "spike", color: "#b0831f" },
59
+ ],
60
+ },
61
+ {
62
+ id: "priority",
63
+ title: "Priority",
64
+ exclusive: true,
65
+ labels: [
66
+ { name: "urgent", color: "#e5534b" },
67
+ { name: "high", color: "#db6d28" },
68
+ { name: "medium", color: "#a8801c" },
69
+ { name: "low", color: "#3f9142" },
70
+ ],
71
+ },
72
+ ];
73
+ export const PLAN_STATUSES = ["planned", "active", "done"];
45
74
 
46
75
  export const DEFAULT_COLUMNS = [
47
76
  { id: "decide", title: "Decide", hint: "needs a decision from the owner before it can be planned" },
@@ -217,13 +246,34 @@ function relpathOf(dir, file) {
217
246
  }
218
247
 
219
248
  export function newBoard({ date } = {}) {
220
- return { version: 1, updated: date || today(), columns: DEFAULT_COLUMNS.map((c) => ({ ...c })), components: [], tags: [...DEFAULT_TAGS], cards: [] };
249
+ const labelGroups = DEFAULT_LABEL_GROUPS.map((g) => ({ ...g, labels: g.labels.map((l) => ({ ...l })) }));
250
+ return {
251
+ version: 2,
252
+ updated: date || today(),
253
+ columns: DEFAULT_COLUMNS.map((c) => ({ ...c })),
254
+ components: [],
255
+ tags: groupLabelNames(labelGroups),
256
+ labelGroups,
257
+ cards: [],
258
+ };
259
+ }
260
+
261
+ // The label names of the groups, in group then label order. Skips whatever is malformed; never throws.
262
+ function groupLabelNames(groups) {
263
+ const names = [];
264
+ if (!Array.isArray(groups)) return names;
265
+ for (const g of groups) {
266
+ if (!g || typeof g !== "object" || !Array.isArray(g.labels)) continue;
267
+ for (const l of g.labels) if (l && typeof l === "object" && typeof l.name === "string" && l.name.length > 0) names.push(l.name);
268
+ }
269
+ return names;
221
270
  }
222
271
 
223
- // The tag vocabulary a board uses: its own `tags` array when it has one (an installed project's board can
272
+ // The tag vocabulary a board uses: the label names of its `labelGroups` when it has them (version 2), else its own `tags` array when it has one (an installed project's board can
224
273
  // pick its own set by editing the record), else DEFAULT_TAGS. .doug/board.json is not migrated by this
225
274
  // function; a record with no `tags` field falls back here rather than being rewritten.
226
275
  export function boardTags(board) {
276
+ if (board && Array.isArray(board.labelGroups)) return groupLabelNames(board.labelGroups);
227
277
  return board && Array.isArray(board.tags) && board.tags.every((t) => typeof t === "string") ? board.tags : DEFAULT_TAGS;
228
278
  }
229
279
 
@@ -260,6 +310,16 @@ function inspectBoard(board) {
260
310
  const errors = [];
261
311
  if (!board || typeof board !== "object" || Array.isArray(board)) return ["board is not an object"];
262
312
 
313
+ const versionOk = board.version === undefined || board.version === 1 || board.version === 2;
314
+ if (!versionOk) errors.push("version must be 1 or 2");
315
+ const isV1 = board.version === undefined || board.version === 1;
316
+ const isV2 = board.version === 2;
317
+ if (isV1) {
318
+ for (const key of ["epics", "milestones", "labelGroups"]) {
319
+ if (board[key] !== undefined) errors.push(`${key} is a version 2 field; this record is version 1`);
320
+ }
321
+ }
322
+
263
323
  const columnIds = [];
264
324
  if (!Array.isArray(board.columns) || board.columns.length === 0) {
265
325
  errors.push("columns must be a non-empty array");
@@ -277,7 +337,8 @@ function inspectBoard(board) {
277
337
  const components = Array.isArray(board.components) && board.components.every((c) => typeof c === "string") ? board.components : null;
278
338
  if (!components) errors.push("components must be an array of strings");
279
339
 
280
- if (board.tags !== undefined) {
340
+ const groupsActive = isV2 && Array.isArray(board.labelGroups);
341
+ if (board.tags !== undefined && !groupsActive) {
281
342
  if (!Array.isArray(board.tags) || !board.tags.every((t) => isNonEmptyString(t))) {
282
343
  errors.push("tags must be an array of strings when present");
283
344
  } else {
@@ -288,7 +349,73 @@ function inspectBoard(board) {
288
349
  }
289
350
  }
290
351
  }
291
- const vocabulary = boardTags(board);
352
+ const vocabulary = isV1 ? (board.tags !== undefined && Array.isArray(board.tags) && board.tags.every((t) => typeof t === "string") ? board.tags : DEFAULT_TAGS) : boardTags(board);
353
+
354
+ const exclusiveGroups = [];
355
+ if (isV2) {
356
+ if (board.labelGroups !== undefined && !Array.isArray(board.labelGroups)) errors.push("labelGroups must be an array");
357
+ else if (Array.isArray(board.labelGroups)) {
358
+ const groupIds = new Set();
359
+ const labelNames = new Set();
360
+ board.labelGroups.forEach((g, i) => {
361
+ if (!g || typeof g !== "object" || Array.isArray(g)) return errors.push(`label group at index ${i}: not an object`);
362
+ const gid = g.id;
363
+ if (!isNonEmptyString(gid)) errors.push(`label group at index ${i}: missing id`);
364
+ else if (groupIds.has(gid)) errors.push(`duplicate label group "${gid}"`);
365
+ else groupIds.add(gid);
366
+ const name = isNonEmptyString(gid) ? gid : null;
367
+ const at = name === null ? `label group at index ${i}` : `label group "${gid}"`;
368
+ if (!isNonEmptyString(g.title)) errors.push(`${at}: missing title`);
369
+ if (typeof g.exclusive !== "boolean") errors.push(`${at}: exclusive must be true or false`);
370
+ if (!Array.isArray(g.labels)) return errors.push(`${at}: labels must be an array`);
371
+ const names = [];
372
+ g.labels.forEach((l, j) => {
373
+ if (!l || typeof l !== "object" || Array.isArray(l) || !isNonEmptyString(l.name)) return errors.push(`${at}: label at index ${j}: missing name`);
374
+ if (labelNames.has(l.name)) errors.push(`duplicate label "${l.name}"`);
375
+ labelNames.add(l.name);
376
+ names.push(l.name);
377
+ if (typeof l.color !== "string" || !/^#[0-9a-fA-F]{6}$/.test(l.color)) errors.push(`label "${l.name}": color must be #rrggbb`);
378
+ if (l.description !== undefined && typeof l.description !== "string") errors.push(`label "${l.name}": description must be a string`);
379
+ });
380
+ if (g.exclusive === true && isNonEmptyString(gid)) exclusiveGroups.push({ id: gid, names });
381
+ });
382
+ const expected = groupLabelNames(board.labelGroups);
383
+ const tags = board.tags;
384
+ if (!Array.isArray(tags) || tags.length !== expected.length || tags.some((t, i) => t !== expected[i])) {
385
+ errors.push(`tags must equal the label names: ${expected.join(", ")}`);
386
+ }
387
+ }
388
+ }
389
+
390
+ const planIds = { epic: new Set(), milestone: new Set() };
391
+ if (isV2) {
392
+ for (const [kind, list] of [["epic", board.epics], ["milestone", board.milestones]]) {
393
+ if (list === undefined) continue;
394
+ if (!Array.isArray(list)) {
395
+ errors.push(`${kind}s must be an array`);
396
+ continue;
397
+ }
398
+ list.forEach((e, i) => {
399
+ if (!e || typeof e !== "object" || Array.isArray(e)) return errors.push(`${kind} at index ${i}: not an object`);
400
+ const eid = e.id;
401
+ if (!isNonEmptyString(eid)) return errors.push(`${kind} at index ${i}: missing id`);
402
+ if (planIds[kind].has(eid)) errors.push(`duplicate ${kind} "${eid}"`);
403
+ planIds[kind].add(eid);
404
+ const at = `${kind} "${eid}"`;
405
+ if (!isNonEmptyString(e.title)) errors.push(`${at}: missing title`);
406
+ if (!isNonEmptyString(e.goal)) errors.push(`${at}: missing goal`);
407
+ if (!PLAN_STATUSES.includes(e.status)) errors.push(`${at}: status must be planned, active, or done`);
408
+ if (e.target !== undefined) {
409
+ const t = e.target;
410
+ if (!t || typeof t !== "object" || Array.isArray(t)) errors.push(`${at}: target must be an object`);
411
+ else {
412
+ if (t.version !== undefined && !isNonEmptyString(t.version)) errors.push(`${at}: target version must be a non-empty string`);
413
+ if (t.date !== undefined && !isRealDate(t.date)) errors.push(`${at}: target date must be YYYY-MM-DD`);
414
+ }
415
+ }
416
+ });
417
+ }
418
+ }
292
419
 
293
420
  if (!Array.isArray(board.cards)) {
294
421
  errors.push("cards must be an array");
@@ -330,10 +457,28 @@ function inspectBoard(board) {
330
457
  }
331
458
  }
332
459
  }
460
+ for (const key of ["epic", "milestone"]) {
461
+ if (card[key] === undefined) continue;
462
+ if (isV1) errors.push(`card "${id}": ${key} is a version 2 field; this record is version 1`);
463
+ else if (isV2 && !planIds[key].has(card[key])) errors.push(`card "${id}": unknown ${key} "${show(card[key])}"`);
464
+ }
465
+ if (isV2 && Array.isArray(card.tags)) {
466
+ for (const g of exclusiveGroups) {
467
+ const hit = card.tags.filter((t, k) => g.names.includes(t) && card.tags.indexOf(t) === k);
468
+ if (hit.length > 1) errors.push(`card "${id}": tags "${hit[0]}" and "${hit[1]}" are both in exclusive group "${g.id}"`);
469
+ }
470
+ }
333
471
  });
334
472
  return errors;
335
473
  }
336
474
 
475
+ // YYYY-MM-DD naming a real calendar date.
476
+ function isRealDate(v) {
477
+ if (typeof v !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(v)) return false;
478
+ const d = new Date(`${v}T00:00:00Z`);
479
+ return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === v;
480
+ }
481
+
337
482
  function errorList(head, errors) {
338
483
  return [head, ...errors.map((e) => `- ${e}`)].join("\n");
339
484
  }
@@ -374,13 +519,12 @@ export function findCard(board, id) {
374
519
  // `tag`, when given, filters to Ready cards carrying it: a card without it is skipped SILENTLY (not pushed onto
375
520
  // `skipped`, since a caller-requested filter should not print a "skipping" line per card it filters out); a `tag`
376
521
  // outside boardTags(board) throws.
377
- export function nextReadyCard(board, { track = "flow", tag } = {}) {
378
- if (tag !== undefined && !boardTags(board).includes(tag)) throw new Error(`unknown tag "${tag}"; tags are ${boardTags(board).join(", ")}`);
522
+ export function nextReadyCard(board, { track = "flow", tag, epic, milestone } = {}) {
523
+ checkFilters(board, { tag, epic, milestone });
379
524
  const done = new Set(board.cards.filter((c) => c.column === DONE).map((c) => c.id));
380
525
  const skipped = [];
381
- for (const c of board.cards) {
382
- if (c.column !== READY) continue;
383
- if (tag !== undefined && !(c.tags || []).includes(tag)) continue;
526
+ for (const c of readyByPriority(board)) {
527
+ if (!passesFilters(c, { tag, epic, milestone })) continue;
384
528
  const cardTrack = c.track === "hand" ? "hand" : "flow";
385
529
  if (track !== "any" && cardTrack !== track) {
386
530
  skipped.push(cardTrack === "hand" ? { id: c.id, hand: true } : { id: c.id, flow: true });
@@ -396,18 +540,18 @@ export function nextReadyCard(board, { track = "flow", tag } = {}) {
396
540
  return { card: null, skipped };
397
541
  }
398
542
 
399
- // The first `batch` runnable Ready cards on the track, in board order, with the skipped ones (card parallel-cards):
543
+ // The first `batch` runnable Ready cards on the track, in priority order, with the skipped ones (card parallel-cards):
400
544
  // what `board next --batch <n>` selects for one plan. Fewer than n when the board has fewer runnable cards.
401
- // `tag`, like nextReadyCard's, filters to cards carrying it, silently, and throws on a tag outside boardTags(board).
402
- export function nextReadyCards(board, { track = "flow", batch, tag } = {}) {
545
+ // `tag`, `epic` and `milestone`, like nextReadyCard's, filter silently; an unknown value throws.
546
+ export function nextReadyCards(board, { track = "flow", batch, tag, epic, milestone } = {}) {
403
547
  if (!(Number.isInteger(batch) && batch > 0)) throw new Error("batch must be a positive integer");
404
- if (tag !== undefined && !boardTags(board).includes(tag)) throw new Error(`unknown tag "${tag}"; tags are ${boardTags(board).join(", ")}`);
548
+ checkFilters(board, { tag, epic, milestone });
405
549
  const done = new Set(board.cards.filter((c) => c.column === DONE).map((c) => c.id));
406
550
  const cards = [];
407
551
  const skipped = [];
408
- for (const c of board.cards) {
409
- if (c.column !== READY || cards.length >= batch) continue;
410
- if (tag !== undefined && !(c.tags || []).includes(tag)) continue;
552
+ for (const c of readyByPriority(board)) {
553
+ if (cards.length >= batch) continue;
554
+ if (!passesFilters(c, { tag, epic, milestone })) continue;
411
555
  const cardTrack = c.track === "hand" ? "hand" : "flow";
412
556
  if (track !== "any" && cardTrack !== track) {
413
557
  skipped.push(cardTrack === "hand" ? { id: c.id, hand: true } : { id: c.id, flow: true });
@@ -423,6 +567,35 @@ export function nextReadyCards(board, { track = "flow", batch, tag } = {}) {
423
567
  return { cards, skipped };
424
568
  }
425
569
 
570
+ export function checkFilters(board, { tag, epic, milestone }) {
571
+ if (tag !== undefined && !boardTags(board).includes(tag)) throw new Error(`unknown tag "${tag}"; tags are ${boardTags(board).join(", ")}`);
572
+ for (const [kind, value, list] of [["epic", epic, board.epics], ["milestone", milestone, board.milestones]]) {
573
+ if (value === undefined) continue;
574
+ const ids = Array.isArray(list) ? list.filter((e) => e && typeof e.id === "string").map((e) => e.id) : [];
575
+ if (!ids.includes(value)) throw new Error(`unknown ${kind} "${value}"; ${ids.length ? `${kind}s are ${ids.join(", ")}` : `no ${kind}s are defined`}`);
576
+ }
577
+ }
578
+
579
+ function passesFilters(c, { tag, epic, milestone }) {
580
+ if (tag !== undefined && !(c.tags || []).includes(tag)) return false;
581
+ if (epic !== undefined && c.epic !== epic) return false;
582
+ if (milestone !== undefined && c.milestone !== milestone) return false;
583
+ return true;
584
+ }
585
+
586
+ // The Ready cards, highest priority first (urgent, high, medium, low, then none), board order breaking ties.
587
+ // A board with no label group "priority" keeps pure board order (decision 0016).
588
+ function readyByPriority(board) {
589
+ const ready = board.cards.filter((c) => c.column === READY);
590
+ const hasPriority = Array.isArray(board.labelGroups) && board.labelGroups.some((g) => g && g.id === "priority");
591
+ if (!hasPriority) return ready;
592
+ const rank = (c) => {
593
+ const ranks = (Array.isArray(c.tags) ? c.tags : []).map((t) => PRIORITY_LABELS.indexOf(t)).filter((r) => r >= 0);
594
+ return ranks.length ? Math.min(...ranks) : PRIORITY_LABELS.length;
595
+ };
596
+ return ready.map((c, i) => ({ c, i, r: rank(c) })).sort((a, b) => a.r - b.r || a.i - b.i).map((x) => x.c);
597
+ }
598
+
426
599
  // Returns a new board with the card moved. `source` replaces the card's source line (the landing commit).
427
600
  // A move keeps the card's place in the record (order is priority in Ready) except a move to Done, which appends
428
601
  // it: Done is then chronological, and the page shows it newest first.
@@ -479,7 +652,7 @@ export function reorderCard(board, id, placement, { date } = {}) {
479
652
 
480
653
  // Returns a new board with a card appended (column defaults to backlog, deps to []). The result is
481
654
  // validated; a duplicate id, unknown column, component, or dep, or a missing field throws.
482
- export function addCard(board, { id, title, goal, component, size, track, deps, column, tags, class: cls }, { date } = {}) {
655
+ export function addCard(board, { id, title, goal, component, size, track, deps, column, tags, epic, milestone, class: cls }, { date } = {}) {
483
656
  const card = {
484
657
  id,
485
658
  column: column === undefined ? "backlog" : column,
@@ -488,6 +661,8 @@ export function addCard(board, { id, title, goal, component, size, track, deps,
488
661
  ...(size !== undefined ? { size } : {}),
489
662
  ...(track !== undefined ? { track } : {}),
490
663
  ...(tags !== undefined ? { tags } : {}),
664
+ ...(epic !== undefined ? { epic } : {}),
665
+ ...(milestone !== undefined ? { milestone } : {}),
491
666
  deps: deps === undefined ? [] : deps,
492
667
  goal,
493
668
  ...(cls !== undefined ? { class: cls } : {}),
@@ -498,8 +673,105 @@ export function addCard(board, { id, title, goal, component, size, track, deps,
498
673
  return next;
499
674
  }
500
675
 
676
+ function addPlanEntry(kind, board, { id, title, goal, target, status = "planned" }, { date } = {}) {
677
+ const key = `${kind}s`;
678
+ const entry = { id, title, goal, ...(target !== undefined ? { target } : {}), status };
679
+ const next = { ...board, version: 2, updated: date || today(), [key]: [...(Array.isArray(board[key]) ? board[key] : []), entry] };
680
+ const errors = validateBoard(next);
681
+ if (errors.length) throw new Error(errorList(`cannot add ${kind} "${id}":`, errors));
682
+ return next;
683
+ }
684
+
685
+ // Returns a new board with the epic appended (input untouched, `updated` stamped). The first epic, milestone or
686
+ // label group added moves a version 1 record to version 2. The result is validated like addCard's.
687
+ export function addEpic(board, entry, opts) {
688
+ return addPlanEntry("epic", board, entry, opts);
689
+ }
690
+
691
+ export function addMilestone(board, entry, opts) {
692
+ return addPlanEntry("milestone", board, entry, opts);
693
+ }
694
+
695
+ // Fields editEpic and editMilestone accept. `target` is { version?, date? }, merged onto the existing target (a null part or a null target removes it).
696
+ const PLAN_EDIT_FIELDS = ["title", "goal", "status", "target"];
697
+
698
+ function editPlanEntry(kind, board, id, fields, { force = false, date } = {}) {
699
+ const prefix = `cannot edit ${kind} "${id}":`;
700
+ const key = `${kind}s`;
701
+ const list = Array.isArray(board[key]) ? board[key] : [];
702
+ const entry = list.find((e) => e && e.id === id);
703
+ if (!entry) throw new Error(`no ${kind} "${id}" on the board`);
704
+ const keys = fields && typeof fields === "object" && !Array.isArray(fields) ? Object.keys(fields) : [];
705
+ if (!keys.length) throw new Error(`${prefix} no field to change`);
706
+ for (const k of keys) {
707
+ if (!PLAN_EDIT_FIELDS.includes(k)) throw new Error(`${prefix} unknown field "${k}"; editable fields are ${PLAN_EDIT_FIELDS.join(", ")}`);
708
+ }
709
+ const warnings = [];
710
+ if (fields.status === "done" && entry.status !== "done") {
711
+ const open = board.cards.filter((c) => c[kind] === id && c.column !== DONE).map((c) => c.id);
712
+ if (open.length) {
713
+ const sentence = `${kind} "${id}" is set to done while ${open.length === 1 ? "this card is" : "these cards are"} not Done: ${open.join(", ")}`;
714
+ if (!force) throw new Error(`${prefix} ${sentence}; pass --force to set it anyway`);
715
+ warnings.push(sentence);
716
+ }
717
+ }
718
+ const nextEntry = { ...entry };
719
+ for (const k of keys) {
720
+ if (k === "target" && fields.target && typeof fields.target === "object" && !Array.isArray(fields.target)) {
721
+ // A target object merges onto the existing one; a null part removes that part, an empty result removes the target.
722
+ const merged = { ...(entry.target && typeof entry.target === "object" ? entry.target : {}) };
723
+ for (const [part, v] of Object.entries(fields.target)) {
724
+ if (v === null) delete merged[part];
725
+ else merged[part] = v;
726
+ }
727
+ if (Object.keys(merged).length) nextEntry.target = merged;
728
+ else delete nextEntry.target;
729
+ } else if (fields[k] === null) delete nextEntry[k];
730
+ else nextEntry[k] = fields[k];
731
+ }
732
+ const next = { ...board, updated: date || today(), [key]: list.map((e) => (e === entry ? nextEntry : e)) };
733
+ const errors = validateBoard(next);
734
+ if (errors.length) throw new Error(errorList(prefix, errors));
735
+ return { board: next, warnings };
736
+ }
737
+
738
+ // Returns { board, warnings }: a new board with the epic's fields changed (input untouched, `updated` stamped).
739
+ // Setting status to done while a card with that epic is not Done is refused unless `force`; forced, it returns a
740
+ // warning naming those cards. An unknown id throws `no epic "<id>" on the board`. The result is validated.
741
+ export function editEpic(board, id, fields, opts) {
742
+ return editPlanEntry("epic", board, id, fields, opts);
743
+ }
744
+
745
+ export function editMilestone(board, id, fields, opts) {
746
+ return editPlanEntry("milestone", board, id, fields, opts);
747
+ }
748
+
749
+ // Adds a label group; the flat `tags` array is rewritten as the label names of every group.
750
+ export function addLabelGroup(board, { id, title, exclusive, labels }, { date } = {}) {
751
+ const groups = [...(Array.isArray(board.labelGroups) ? board.labelGroups : []), { id, title, exclusive, labels }];
752
+ const next = { ...board, version: 2, updated: date || today(), tags: groupLabelNames(groups), labelGroups: groups };
753
+ const errors = validateBoard(next);
754
+ if (errors.length) throw new Error(errorList(`cannot add label group "${id}":`, errors));
755
+ return next;
756
+ }
757
+
758
+ // Migrates a board with no label groups: the type group holds the board's existing tags, the fixed priority group is added, and the record becomes version 2, in one write.
759
+ // A board that already has label groups is refused.
760
+ export function initLabelGroups(board, { date } = {}) {
761
+ if (Array.isArray(board.labelGroups)) throw new Error("cannot init label groups: the board already has label groups");
762
+ const known = new Map(DEFAULT_LABEL_GROUPS[0].labels.map((l) => [l.name, l.color]));
763
+ const type = { ...DEFAULT_LABEL_GROUPS[0], labels: boardTags(board).map((name) => ({ name, color: known.get(name) || "#848d97" })) };
764
+ const groups = [type, DEFAULT_LABEL_GROUPS[1]];
765
+ const next = { ...board, version: 2, updated: date || today(), tags: groupLabelNames(groups), labelGroups: groups };
766
+ const errors = validateBoard(next);
767
+ if (errors.length) throw new Error(errorList("cannot init label groups:", errors));
768
+ return next;
769
+ }
770
+
501
771
  // Fields editCard accepts; the column is never one of them (move stays the way to change a column).
502
- const EDITABLE_FIELDS = ["title", "goal", "size", "component", "track", "deps", "source", "tags", "class"];
772
+ const EDITABLE_FIELDS = ["title", "goal", "size", "component", "track", "deps", "source", "tags", "class", "epic", "milestone"];
773
+ // Planning fields: a done card takes them without --force (the milestone's cards are Done when the milestone is filed).
774
+ const PLANNING_FIELDS = ["epic", "milestone"];
503
775
 
504
776
  // Returns a new board with the named card's fields changed (a new board, input untouched, `updated` stamped
505
777
  // like moveCard) and `warnings`, an array of strings (empty when none). `fields` may carry any of
@@ -524,8 +796,8 @@ export function editCard(board, id, fields, { force = false, date, plan = null }
524
796
  const card = findCard(board, id);
525
797
  const warnings = [];
526
798
 
527
- if (card.column === DONE) {
528
- if (keys.some((k) => k !== "goal" && k !== "source")) throw new Error(`${prefix} a done card accepts only --goal and --source, with --force`);
799
+ if (card.column === DONE && !keys.every((k) => PLANNING_FIELDS.includes(k))) {
800
+ if (keys.some((k) => k !== "goal" && k !== "source" && !PLANNING_FIELDS.includes(k))) throw new Error(`${prefix} a done card accepts only --goal and --source, with --force`);
529
801
  const landed = card.source || "an unrecorded commit";
530
802
  if (!force) throw new Error(`${prefix} a done card changes only with --force; it landed as ${landed}`);
531
803
  warnings.push(`editing done card "${id}", landed as ${landed}: docs/live-runs.md and the landing commit still describe the old text`);
@@ -541,7 +813,10 @@ export function editCard(board, id, fields, { force = false, date, plan = null }
541
813
  }
542
814
 
543
815
  const nextCard = { ...card };
544
- for (const k of keys) nextCard[k] = fields[k];
816
+ for (const k of keys) {
817
+ if (PLANNING_FIELDS.includes(k) && fields[k] === null) delete nextCard[k];
818
+ else nextCard[k] = fields[k];
819
+ }
545
820
  const cards = board.cards.map((c) => (c.id === id ? nextCard : c));
546
821
  const next = { ...board, updated: date || today(), cards };
547
822
  const errors = validateBoard(next);
@@ -782,12 +1057,12 @@ function wallRow(wall, flags) {
782
1057
 
783
1058
  // The first prose line of a rehearsal entry. The start scenario runs in an empty git repo; every other scenario
784
1059
  // (start-feature included) on the ts-basic fixture, the rule rehearse-lib's rehearsalNote uses.
785
- function rehearsalLine(rehearsal, cardId) {
786
- const where = rehearsal === "start" ? "an empty git repo" : "the ts-basic fixture";
1060
+ function rehearsalLine(rehearsal, cardId, fixture = null) {
1061
+ const where = rehearsal === "start" ? "an empty git repo" : `the ${fixture || "ts-basic"} fixture`;
787
1062
  return `Rehearsal ${rehearsal} for card \`${cardId}\` on ${where} (scripts/rehearse.mjs); the commit and gate below are the fixture's, not this repository's.`;
788
1063
  }
789
1064
 
790
- export function runEntry({ card, report, cost = null, codexCost = null, wallClock = null, wall = null, mergeCommit = null, record = null, adversary = [], date = new Date().toISOString().slice(0, 10), batch = null, sharedCost = null, taskIds = [], note = null, rehearsal = null, rehearsalOutcome = null }) {
1065
+ export function runEntry({ card, report, cost = null, codexCost = null, wallClock = null, wall = null, mergeCommit = null, record = null, adversary = [], date = new Date().toISOString().slice(0, 10), batch = null, sharedCost = null, taskIds = [], note = null, rehearsal = null, rehearsalOutcome = null, fixture = null, forcedLanding = false }) {
791
1066
  const inBatch = Array.isArray(batch) && batch.length > 1;
792
1067
  if (inBatch) report = filterReportForCard(report, card.id, taskIds);
793
1068
  const lines = [];
@@ -796,13 +1071,13 @@ export function runEntry({ card, report, cost = null, codexCost = null, wallCloc
796
1071
  const batchNote = inBatch ? `, in one plan with ${listOthers(batch, card.id)} (one approval, one run, one landing)` : "";
797
1072
  lines.push(
798
1073
  rehearsal
799
- ? rehearsalLine(rehearsal, card.id)
1074
+ ? rehearsalLine(rehearsal, card.id, fixture)
800
1075
  : `Ran through /doug-next on this repo from card \`${card.id}\`${record ? ` in \`${record}\`` : ""}${batchNote}. Plan "${report.plan}", integration branch \`${report.integrationBranch}\`${report.modelsSource ? `, models from ${report.modelsSource}` : ""}.`,
801
1076
  );
802
1077
  lines.push("");
803
1078
  lines.push("| Measure | Value |");
804
1079
  lines.push("|---|---|");
805
- lines.push(`| Outcome | ${rehearsal ? `rehearsal ${rehearsal} ${rehearsalOutcome}` : outcomeOf(report)}${!rehearsal && pausedAt(report) ? `; next: ${listNext(report)}` : ""} |`);
1080
+ lines.push(`| Outcome | ${rehearsal ? `rehearsal ${rehearsal} ${rehearsalOutcome}` : forcedLanding ? `landed after plan.mjs done --force; last run ${outcomeOf(report)}` : outcomeOf(report)}${!rehearsal && !forcedLanding && pausedAt(report) ? `; next: ${listNext(report)}` : ""} |`);
806
1081
  lines.push(`| Cost | ${cost === null ? "not measured" : `$${Number(cost).toFixed(2)}${inBatch ? " for this card's tasks" : ""}`} |`);
807
1082
  if (!inBatch || batch[0] === card.id) {
808
1083
  lines.push(
@@ -874,13 +1149,13 @@ export function handWallVerdict(card, wallClock, table = WALL_BUDGET_MIN) {
874
1149
 
875
1150
  // The live-runs entry for a hand-track card (decision 0005): no workflow report and no levels, so it records what
876
1151
  // doug-hand can measure: the landing commit, the wall clock, and the gate result, plus an optional note.
877
- export function handEntry({ card, commit = null, wallClock = null, gate = null, note = null, record = null, date = new Date().toISOString().slice(0, 10), rehearsal = null, rehearsalOutcome = null, table = WALL_BUDGET_MIN, cost = null }) {
1152
+ export function handEntry({ card, commit = null, wallClock = null, gate = null, note = null, record = null, date = new Date().toISOString().slice(0, 10), rehearsal = null, rehearsalOutcome = null, fixture = null, table = WALL_BUDGET_MIN, cost = null }) {
878
1153
  const lines = [];
879
1154
  lines.push(`## ${date}, ${card.id}: ${card.title}`);
880
1155
  lines.push("");
881
1156
  lines.push(
882
1157
  rehearsal
883
- ? rehearsalLine(rehearsal, card.id)
1158
+ ? rehearsalLine(rehearsal, card.id, fixture)
884
1159
  : `Built by hand through doug-hand from card \`${card.id}\`${record ? ` in \`${record}\`` : ""} (hand track: a gated by-hand change); no workflow run.`,
885
1160
  );
886
1161
  lines.push("");
@@ -1005,10 +1280,10 @@ function wallNote(wall, flags) {
1005
1280
 
1006
1281
  // Three lines for the chat after a run: the outcome, the tasks, and what stopped or was not met. Everything in it
1007
1282
  // is read from the report; nothing is estimated.
1008
- export function runSummary({ report, cost = null, codexCost = null, wallClock = null, wall = null, mergeCommit = null, batch = null, card = null }) {
1283
+ export function runSummary({ report, forcedLanding = false, cost = null, codexCost = null, wallClock = null, wall = null, mergeCommit = null, batch = null, card = null }) {
1009
1284
  const tasks = (report.levels || []).flatMap((l) => (l.tasks || []).map((t) => ({ level: l, task: t })));
1010
1285
  const integrated = tasks.filter(({ level }) => level.integration && level.integration.ok).length;
1011
- const outcome = outcomeOf(report);
1286
+ const outcome = forcedLanding ? `landed after plan.mjs done --force; last run ${outcomeOf(report)}` : outcomeOf(report);
1012
1287
  const first = `${report.plan}: ${outcome}; ${tasks.length} task${tasks.length === 1 ? "" : "s"}, ${integrated} integrated; wall ${wallClock === null ? "not measured" : wallClock}; cost ${cost === null ? "not measured" : `$${Number(cost).toFixed(2)}`}${codexCost === null ? "" : `; Codex $${Number(codexCost).toFixed(2)}`}${mergeCommit ? `; landed as ${mergeCommit}` : ""}${wallNote(wall, wall ? wallFlagsFor(wall, report, batch, card) : [])}.`;
1013
1288
  const second = tasks.length
1014
1289
  ? tasks.map(({ task: t }) => {
@@ -1077,3 +1352,73 @@ export function recordLanding(dir, id, markdown) {
1077
1352
  const file = appendRun(dir, markdown);
1078
1353
  return { file, promoted };
1079
1354
  }
1355
+
1356
+ // Roll-up per epic and milestone (decision 0016): counts per column, done out of total, progress weighted by
1357
+ // cardBudgetMinutes (In flow earns nothing), measured cost and wall of the Done cards with coverage (null with no
1358
+ // live-runs text), and the not-Done cards' budget. A version 1 board has no epics or milestones.
1359
+ export function rollup(board, liveRunsText) {
1360
+ const costs = new Map(liveRunsText == null ? [] : parseLiveRunCosts(liveRunsText).map((r) => [r.id, r.usd]));
1361
+ const walls = new Map(liveRunsText == null ? [] : parseLiveRunWalls(liveRunsText).map((r) => [r.id, r.minutes]));
1362
+ const measured = liveRunsText != null;
1363
+ const entry = (item, field) => {
1364
+ const members = board.cards.filter((c) => c[field] === item.id);
1365
+ const doneCards = members.filter((c) => c.column === DONE);
1366
+ const open = members.filter((c) => c.column !== DONE);
1367
+ const columns = {};
1368
+ for (const col of board.columns) columns[col.id] = members.filter((c) => c.column === col.id).length;
1369
+ const weight = (cs) => cs.reduce((n, c) => n + cardBudgetMinutes(c), 0);
1370
+ const total = weight(members);
1371
+ const withUsd = doneCards.filter((c) => costs.has(c.id));
1372
+ const withWall = doneCards.filter((c) => walls.has(c.id));
1373
+ return {
1374
+ id: item.id,
1375
+ title: item.title,
1376
+ status: item.status,
1377
+ target: item.target ?? null,
1378
+ columns,
1379
+ done: doneCards.length,
1380
+ total: members.length,
1381
+ progress: total === 0 ? 0 : weight(doneCards) / total,
1382
+ measured: {
1383
+ usd: measured ? Math.round(withUsd.reduce((n, c) => n + costs.get(c.id), 0) * 100) / 100 : null,
1384
+ usdCards: measured ? withUsd.length : null,
1385
+ wallMinutes: measured ? withWall.reduce((n, c) => n + walls.get(c.id), 0) : null,
1386
+ wallCards: measured ? withWall.length : null,
1387
+ doneCards: doneCards.length,
1388
+ },
1389
+ cards: members.map((c) => c.id),
1390
+ budget: {
1391
+ wallMinutes: weight(open),
1392
+ usd: Math.round(open.reduce((n, c) => n + cardBudgetDollars(c), 0) * 100) / 100,
1393
+ },
1394
+ };
1395
+ };
1396
+ const list = (v) => (Array.isArray(v) ? v.filter((e) => e && typeof e.id === "string") : []);
1397
+ return { epics: list(board.epics).map((e) => entry(e, "epic")), milestones: list(board.milestones).map((m) => entry(m, "milestone")) };
1398
+ }
1399
+
1400
+ // The board page's card surfaces (board-app.js --surface), light and dark.
1401
+ export const LABEL_SURFACES = ["#ffffff", "#1c2022"];
1402
+ export const LABEL_MIN_CONTRAST = 3;
1403
+ function luminance(hex) {
1404
+ const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255).map((v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
1405
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
1406
+ }
1407
+ export function contrastRatio(a, b) {
1408
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
1409
+ return (hi + 0.05) / (lo + 0.05);
1410
+ }
1411
+ // One line per label colour under 3:1 (WCAG 2.2 SC 1.4.11) against a card surface; nothing when all pass.
1412
+ export function labelContrastFailures(board) {
1413
+ const out = [];
1414
+ for (const g of Array.isArray(board.labelGroups) ? board.labelGroups : []) {
1415
+ for (const l of g && Array.isArray(g.labels) ? g.labels : []) {
1416
+ if (!l || typeof l.color !== "string" || !/^#[0-9a-fA-F]{6}$/.test(l.color)) continue;
1417
+ for (const surface of LABEL_SURFACES) {
1418
+ const ratio = contrastRatio(l.color, surface);
1419
+ if (ratio < LABEL_MIN_CONTRAST) out.push(`${l.name} ${l.color} ${ratio.toFixed(2)}:1 against ${surface}`);
1420
+ }
1421
+ }
1422
+ }
1423
+ return out;
1424
+ }