@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.
- package/dist/bin.js +13 -4
- package/dist/bin.js.map +1 -1
- package/dist/board-page.d.ts +5 -1
- package/dist/board-page.js +19 -5
- package/dist/board-page.js.map +1 -1
- package/dist/board-serve.js +34 -10
- package/dist/board-serve.js.map +1 -1
- package/dist/board.d.ts +1 -1
- package/dist/board.js +160 -13
- package/dist/board.js.map +1 -1
- package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/flow/agents/implementer.md +1 -1
- package/node_modules/@dougharness/flow/agents/planner.md +1 -1
- package/node_modules/@dougharness/flow/agents/researcher.md +2 -1
- package/node_modules/@dougharness/flow/agents/reviewer.md +3 -1
- package/node_modules/@dougharness/flow/hooks/register.tsx +67 -32
- package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +94 -7
- package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +31 -4
- package/node_modules/@dougharness/flow/lib/board.mjs +366 -21
- package/node_modules/@dougharness/flow/lib/card-loop.mjs +229 -23
- package/node_modules/@dougharness/flow/lib/coordinator.mjs +25 -0
- package/node_modules/@dougharness/flow/lib/cost.mjs +9 -0
- package/node_modules/@dougharness/flow/lib/decisions.mjs +4 -2
- package/node_modules/@dougharness/flow/lib/memory.mjs +382 -65
- package/node_modules/@dougharness/flow/lib/plan.mjs +1 -1
- package/node_modules/@dougharness/flow/package.json +1 -1
- package/node_modules/@dougharness/flow/scripts/autopilot.mjs +228 -14
- package/node_modules/@dougharness/flow/scripts/board.mjs +122 -13
- package/node_modules/@dougharness/flow/scripts/cost.mjs +6 -3
- package/node_modules/@dougharness/flow/scripts/memory.mjs +27 -8
- package/node_modules/@dougharness/flow/scripts/plan.mjs +5 -1
- package/node_modules/@dougharness/flow/scripts/session-recall.mjs +11 -4
- package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +9 -2
- package/node_modules/@dougharness/flow/skills/research/SKILL.md +2 -2
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +2 -2
- package/node_modules/@dougharness/flow/workflows/card-queen.js +238 -25
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +77 -34
- package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/gates/lib/baseline.mjs +23 -0
- package/node_modules/@dougharness/gates/lib/block-ledger.mjs +41 -3
- package/node_modules/@dougharness/gates/lib/docs-gate.mjs +6 -2
- package/node_modules/@dougharness/gates/package.json +1 -1
- package/node_modules/@dougharness/gates/scripts/research-cap.mjs +119 -15
- package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +107 -20
- package/package.json +3 -3
- package/templates/board-app.js +240 -30
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
382
|
-
if (c
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
409
|
-
if (
|
|
410
|
-
if (
|
|
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)
|
|
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);
|
|
@@ -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
|
+
}
|