@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
@@ -48,12 +48,18 @@ footer{padding:0 24px 24px;font-size:12px;color:var(--ink-soft)}
48
48
  #autopilot{margin:14px 24px 0;padding:10px 14px;background:var(--surface);border:1px solid var(--rule-strong)}
49
49
  #autopilot[hidden]{display:none}
50
50
  #autopilot h2{font-family:"IBM Plex Mono",Menlo,monospace;font-size:12px;letter-spacing:.06em;text-transform:uppercase;font-weight:500;margin:0 0 6px}
51
+ #autopilot h3{font-family:"IBM Plex Mono",Menlo,monospace;font-size:11px;letter-spacing:.06em;text-transform:uppercase;font-weight:500;color:var(--ink-soft);margin:8px 0 0}
51
52
  #autopilot ul{list-style:none;margin:4px 0;padding:0;font-size:13px}
52
53
  #autopilot li{display:flex;gap:8px;flex-wrap:wrap;align-items:baseline}
53
- #autopilot .st{font-family:"IBM Plex Mono",Menlo,monospace;font-size:11px;text-transform:uppercase;color:var(--ink-soft);min-width:5.5em}
54
+ #autopilot .st{font-family:"IBM Plex Mono",Menlo,monospace;font-size:11px;text-transform:uppercase;color:var(--ink-soft);min-width:1.5em}
54
55
  #autopilot .st.running{color:var(--accent);font-weight:600}
55
56
  #autopilot .st.stopped,#autopilot .st.interrupted{color:var(--c-decision)}
56
57
  #autopilot .caps,#autopilot .filed{font-size:12px;color:var(--ink-soft);margin-top:4px}
58
+ #milestones{margin:14px 24px 0;padding:10px 14px;background:var(--surface);border:1px solid var(--rule-strong)}
59
+ #milestones h2{font-family:"IBM Plex Mono",Menlo,monospace;font-size:12px;letter-spacing:.06em;text-transform:uppercase;font-weight:500;margin:0 0 6px}
60
+ #milestones ol{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;font-size:13px}
61
+ #milestones li{overflow-wrap:anywhere}
62
+ @media (max-width:720px){#milestones{margin:14px 12px 0}}
57
63
  @media (max-width:720px){.board{grid-auto-columns:minmax(85vw,1fr)}}
58
64
  `;
59
65
 
@@ -61,17 +67,91 @@ const FONTS = '<link rel="stylesheet" href="https://fonts.googleapis.com/css2?fa
61
67
 
62
68
  function esc(s) { return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); }
63
69
 
64
- function cardHtml(c, columns) {
70
+ // WCAG relative luminance of a #rrggbb colour.
71
+ function relLuminance(hex) {
72
+ const ch = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255).map((v) => (v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4)));
73
+ return 0.2126 * ch[0] + 0.7152 * ch[1] + 0.0722 * ch[2];
74
+ }
75
+
76
+ // WCAG contrast ratio of two #rrggbb colours, in either order.
77
+ function contrastRatio(a, b) {
78
+ const la = relLuminance(a);
79
+ const lb = relLuminance(b);
80
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
81
+ }
82
+
83
+ // Black or white, whichever contrasts more with the label colour (the better is always >= 4.5:1).
84
+ function chipTextColor(bg) {
85
+ return contrastRatio(bg, "#000000") >= contrastRatio(bg, "#ffffff") ? "#000000" : "#ffffff";
86
+ }
87
+
88
+ // Label name -> colour across a board's label groups; empty for a version 1 board.
89
+ function labelColors(labelGroups) {
90
+ const colors = {};
91
+ for (const g of labelGroups || []) for (const l of g.labels || []) colors[l.name] = l.color;
92
+ return colors;
93
+ }
94
+
95
+ const PRIORITY_LABELS = ["urgent", "high", "medium", "low"];
96
+
97
+ const READY_BAND_HINT = "Priority sets the order in Ready; a card moves only among cards of its own priority.";
98
+ const READY_COL_HINT = "by priority; reorder within a priority";
99
+
100
+ function hasPriorityGroup(labelGroups) {
101
+ return (labelGroups || []).some((g) => g.id === "priority");
102
+ }
103
+
104
+ // A card's priority band: the lowest PRIORITY_LABELS index among its tags, none last.
105
+ function priorityRank(c) {
106
+ const idx = (c.tags || []).map((t) => PRIORITY_LABELS.indexOf(t)).filter((i) => i >= 0);
107
+ return idx.length ? Math.min.apply(null, idx) : PRIORITY_LABELS.length;
108
+ }
109
+
110
+ // Ready in the order `next` picks it: priority label rank (none last), then board order. Only a board with a
111
+ // priority group ranks; without one the order is record order. Never mutates its input.
112
+ function readyOrder(cards, labelGroups) {
113
+ if (!hasPriorityGroup(labelGroups)) return cards;
114
+ return cards.map((c, i) => ({ c, i, r: priorityRank(c) })).sort((a, b) => a.r - b.r || a.i - b.i).map((x) => x.c);
115
+ }
116
+
117
+ // What a placement next to targetId in `column` may do. "place": as asked. "refuse": a Ready card dropped among
118
+ // another priority band (Ready is sorted, so the move would snap back). "column": a card from another column
119
+ // dropped among another band; it joins Ready, and the sort picks its slot.
120
+ function priorityBandMove(cards, labelGroups, id, column, targetId) {
121
+ if (column !== "ready" || !hasPriorityGroup(labelGroups)) return "place";
122
+ const card = cards.find((c) => c.id === id);
123
+ const target = cards.find((c) => c.id === targetId);
124
+ if (!card || !target || priorityRank(card) === priorityRank(target)) return "place";
125
+ return card.column === "ready" ? "refuse" : "column";
126
+ }
127
+
128
+ // The cards of a column in display order: Done newest first, Ready by priority, the rest in record order.
129
+ function orderedCards(col, cards, labelGroups) {
130
+ const mine = cards.filter((c) => c.column === col.id);
131
+ if (col.id === "done") return [...mine].reverse();
132
+ if (col.id === "ready") return readyOrder(mine, labelGroups);
133
+ return mine;
134
+ }
135
+
136
+ // The Done column shows only its newest DONE_SHOWN cards until "show all"; the record keeps every card.
137
+ const DONE_SHOWN = 20;
138
+
139
+ function cardHtml(c, columns, colors) {
65
140
  const deps = (c.deps || []).length ? `<div class="meta">after <code>${c.deps.map(esc).join("</code> <code>")}</code></div>` : "";
66
141
  const opts = columns.map((col) => `<option value="${col.id}"${col.id === c.column ? " selected" : ""}>${esc(col.title)}</option>`).join("");
67
142
  const chip = c.component ? `<span class="chip" style="color:var(--c-${esc(c.component)})">${esc(c.component)}</span>` : "";
68
143
  // A hand-track card is a gated by-hand change; the chip says so at a glance.
69
144
  const hand = c.track === "hand" ? `<span class="chip hand" title="hand track: a gated by-hand change">by hand</span>` : "";
70
- const tags = c.tags && c.tags.length ? c.tags.map((t) => `<span class="chip tag">${esc(t)}</span>`).join("") : "";
145
+ const tags = c.tags && c.tags.length ? c.tags.map((t) => {
146
+ const bg = colors && /^#[0-9a-fA-F]{6}$/.test(colors[t] || "") ? colors[t] : "";
147
+ return bg ? `<span class="chip tag" style="background:${bg};color:${chipTextColor(bg)};border-color:${bg}">${esc(t)}</span>` : `<span class="chip tag">${esc(t)}</span>`;
148
+ }).join("") : "";
71
149
  const cls = c.class ? `<span class="chip class" title="class">${esc(c.class)}</span>` : "";
72
150
  const compAttr = c.component ? ` data-component="${esc(c.component)}"` : "";
73
151
  const tagsAttr = c.tags && c.tags.length ? ` data-tags="${esc(c.tags.join(" "))}"` : "";
74
- return `<article class="card" draggable="true" tabindex="0" data-id="${esc(c.id)}"${compAttr} data-column="${esc(c.column)}"${tagsAttr}>
152
+ const epicAttr = c.epic ? ` data-epic="${esc(c.epic)}"` : "";
153
+ const milestoneAttr = c.milestone ? ` data-milestone="${esc(c.milestone)}"` : "";
154
+ return `<article class="card" draggable="true" tabindex="0" data-id="${esc(c.id)}"${compAttr} data-column="${esc(c.column)}"${tagsAttr}${epicAttr}${milestoneAttr}>
75
155
  <div class="top">${chip}${hand}${tags}${cls}<span class="size">${esc(c.size || "")}</span></div>
76
156
  <div class="title">${esc(c.title)}</div>
77
157
  <div class="meta"><code>${esc(c.id)}</code>${c.source ? ` · ${esc(c.source)}` : ""}</div>
@@ -81,16 +161,34 @@ ${c.goal ? `<details><summary>goal</summary><p class="goal">${esc(c.goal)}</p></
81
161
  </article>`;
82
162
  }
83
163
 
164
+ // The inner HTML of a column's .cards: its cards in display order. Collapsed Done keeps the newest DONE_SHOWN
165
+ // and ends with a "show all <total>" button; expanded Done, and every other column, renders all of them.
166
+ function columnCardsHtml(col, cards, columns, labelGroups, expanded) {
167
+ const ordered = orderedCards(col, cards, labelGroups);
168
+ const colors = labelColors(labelGroups);
169
+ const capped = col.id === "done" && !expanded && ordered.length > DONE_SHOWN;
170
+ const shown = capped ? ordered.slice(0, DONE_SHOWN) : ordered;
171
+ const html = shown.map((c) => cardHtml(c, columns, colors)).join("\n");
172
+ return capped ? `${html}\n<button type="button" class="show-all" data-show-all="done">show all ${ordered.length}</button>` : html;
173
+ }
174
+
175
+ // How many Done cards beyond the newest DONE_SHOWN pass the filters: the ones a collapsed column does not render.
176
+ function doneHiddenCount(cards, filters) {
177
+ const older = orderedCards({ id: "done" }, cards, []).slice(DONE_SHOWN);
178
+ return older.filter((c) => cardMatches(c, filters)).length;
179
+ }
180
+
84
181
  // Done reads newest first: a card moves to Done long after it was added, so record order would bury the latest
85
- // landing at the bottom of the longest column. Every other column keeps record order, which is priority.
86
- function columnHtml(col, cards, columns) {
182
+ // landing at the bottom of the longest column. Ready is sorted by priority (readyOrder) on a board with a priority
183
+ // group; every other column, and Ready without one, keeps record order.
184
+ function columnHtml(col, cards, columns, labelGroups) {
87
185
  const mine = cards.filter((c) => c.column === col.id);
88
- const ordered = col.id === "done" ? [...mine].reverse() : mine;
89
- const hint = col.id === "done" ? `${col.hint || ""}${col.hint ? " · " : ""}newest first` : col.hint || "";
186
+ const suffix = col.id === "done" ? "newest first" : col.id === "ready" && hasPriorityGroup(labelGroups) ? READY_COL_HINT : "";
187
+ const hint = suffix ? `${col.hint || ""}${col.hint ? " · " : ""}${suffix}` : col.hint || "";
90
188
  return `<section class="col" data-column="${esc(col.id)}">
91
189
  <div class="col-head"><h2>${esc(col.title)}</h2><span class="n">${mine.length}</span></div>
92
190
  <div class="col-hint">${esc(hint)}</div>
93
- <div class="cards">${ordered.map((c) => cardHtml(c, columns)).join("\n")}</div>
191
+ <div class="cards">${columnCardsHtml(col, cards, columns, labelGroups, false)}</div>
94
192
  </section>`;
95
193
  }
96
194
 
@@ -178,6 +276,12 @@ function connectLive(opts) {
178
276
  try { view = JSON.parse(e.data); } catch (_) {}
179
277
  opts.onAutopilot(view);
180
278
  });
279
+ es.addEventListener("rollup", (e) => {
280
+ if (source !== es || !opts.onRollup) return;
281
+ let roll = null;
282
+ try { roll = JSON.parse(e.data); } catch (_) {}
283
+ opts.onRollup(roll);
284
+ });
181
285
  }
182
286
  open();
183
287
 
@@ -201,7 +305,12 @@ function columnShape(board) {
201
305
 
202
306
  // Sorted component list and sorted tag list, compared to decide whether the filters need a reload.
203
307
  function componentAndTagShape(board) {
204
- return JSON.stringify({ components: [...board.components].sort(), tags: tagOptions(board.cards).sort() });
308
+ return JSON.stringify({ components: [...board.components].sort(), tags: tagOptions(board.cards).sort(), labelGroups: board.labelGroups, epics: planIds(board.epics), milestones: planIds(board.milestones) });
309
+ }
310
+
311
+ // [id, title] of each epic or milestone, in record order; a change reloads the page (filter options, strip).
312
+ function planIds(entries) {
313
+ return (Array.isArray(entries) ? entries : []).map((e) => [e.id, e.title]);
205
314
  }
206
315
 
207
316
  // The header's counts-and-record line: how many cards, how many done, when the record was last
@@ -231,9 +340,7 @@ function applyLiveBoard(board, next, config, opts) {
231
340
  for (const col of next.columns) {
232
341
  const container = doc.querySelector('.col[data-column="' + CSS_escape(col.id) + '"] .cards');
233
342
  if (!container) continue;
234
- const mine = next.cards.filter((c) => c.column === col.id);
235
- const ordered = col.id === "done" ? [...mine].reverse() : mine;
236
- container.innerHTML = ordered.map((c) => cardHtml(c, next.columns)).join("\n");
343
+ container.innerHTML = columnCardsHtml(col, next.cards, next.columns, next.labelGroups, !!opts.doneExpanded);
237
344
  }
238
345
  const sub = doc.querySelector("header .sub");
239
346
  if (sub) sub.innerHTML = headerSubHtml(next, config);
@@ -258,6 +365,14 @@ function cardMatches(card, filters) {
258
365
  const tags = card.tags || [];
259
366
  if (!tags.includes(tag)) return false;
260
367
  }
368
+ const epic = (filters && filters.epic) || "";
369
+ const milestone = (filters && filters.milestone) || "";
370
+ if (epic && card.epic !== epic) return false;
371
+ if (milestone && card.milestone !== milestone) return false;
372
+ const groups = (filters && filters.groups) || {};
373
+ for (const id of Object.keys(groups)) {
374
+ if (groups[id] && !(card.tags || []).includes(groups[id])) return false;
375
+ }
261
376
  return true;
262
377
  }
263
378
 
@@ -265,17 +380,16 @@ function cardMatches(card, filters) {
265
380
  // "" for no view, which leaves the panel hidden.
266
381
  function autopilotHtml(view) {
267
382
  if (!view || typeof view !== "object") return "";
268
- const cards = Array.isArray(view.cards) ? view.cards : [];
383
+ const groups = Array.isArray(view.groups) ? view.groups : [];
269
384
  const filed = Array.isArray(view.filed) ? view.filed : [];
270
- const caps = view.caps || {};
271
- const items = cards.map((c) => {
272
- const shape = [c.size, c.class].filter(Boolean).join("/");
273
- return `<li><span class="st ${esc(c.state)}">${esc(c.state)}</span><code>${esc(c.id)}</code>${c.title ? `<span>${esc(c.title)}</span>` : ""}${shape ? `<span class="meta">${esc(shape)}</span>` : ""}${c.detail ? `<span class="meta">${esc(c.detail)}</span>` : ""}</li>`;
385
+ const sections = groups.map((g) => {
386
+ const items = (Array.isArray(g.cards) ? g.cards : []).map((c) => `<li><span class="st ${esc(c.state)}">${esc(c.mark)}</span><code>${esc(c.id)}</code>${c.status ? `<span class="meta">${esc(c.status)}</span>` : ""}</li>`).join("");
387
+ return `<h3>${esc(g.title)}</h3><ul>${items}</ul>`;
274
388
  }).join("");
275
389
  const filedHtml = filed.length
276
390
  ? `<div class="filed">filed: ${filed.map((f) => `<code>${esc(f.id)}</code> (${esc(f.kind)}${f.from ? " from " + esc(f.from) : ""}) ${esc(f.position || "")}`).join("; ")}</div>`
277
391
  : "";
278
- return `<h2>${esc(view.header || "Autopilot")}</h2><ul>${items}</ul><div class="caps">usd ${esc(caps.usd || "")} · wall ${esc(caps.wall || "")} · context ${esc(caps.context || "")}</div>${filedHtml}`;
392
+ return `<h2>${esc(view.header || "Autopilot")}</h2>${sections}<div class="caps">${esc(view.spend || "")}</div>${filedHtml}`;
279
393
  }
280
394
 
281
395
  // Shows a view in the #autopilot container, or hides the container for none.
@@ -288,12 +402,57 @@ function applyAutopilot(view, doc) {
288
402
  el.hidden = html === "";
289
403
  }
290
404
 
405
+ // One milestone's strip line: title, target, status, done out of total, weighted progress, and, when the roll-up
406
+ // measured it (docs/live-runs.md present), cost and wall with the Done-card coverage.
407
+ function milestoneText(m) {
408
+ const t = m.target;
409
+ const target = t ? [t.version, t.date].filter(Boolean).join(", ") : "";
410
+ const parts = [m.title];
411
+ if (target) parts.push("target " + target);
412
+ parts.push(m.status, `${m.done}/${m.total} done`, `${Math.round(m.progress * 100)}% weighted`);
413
+ const k = m.measured;
414
+ if (k && k.usd !== null && k.usd !== undefined) {
415
+ parts.push(`$${k.usd.toFixed(2)} over ${k.usdCards} of ${k.doneCards} Done cards`);
416
+ parts.push(`${k.wallMinutes} min wall over ${k.wallCards} of ${k.doneCards} Done cards`);
417
+ }
418
+ return parts.join(" · ");
419
+ }
420
+
421
+ function milestoneStripInner(milestones) {
422
+ return `<h2>Milestones</h2><ol>${milestones.map((m) => `<li class="milestone" data-milestone="${esc(m.id)}">${esc(milestoneText(m))}</li>`).join("")}</ol>`;
423
+ }
424
+
425
+ // The milestone strip; "" for a board with no milestones (a version 1 board).
426
+ function milestoneStripHtml(rollup) {
427
+ if (!rollup || !Array.isArray(rollup.milestones) || rollup.milestones.length === 0) return "";
428
+ return `<section id="milestones" aria-label="Milestones">${milestoneStripInner(rollup.milestones)}</section>\n`;
429
+ }
430
+
431
+ // Rewrites the strip from a pushed roll-up; does nothing without the element or a milestones array.
432
+ function applyRollup(rollup, doc) {
433
+ try {
434
+ doc = doc !== undefined ? doc : (typeof document !== "undefined" ? document : null);
435
+ const el = doc && doc.getElementById("milestones");
436
+ if (!el || !rollup || !Array.isArray(rollup.milestones)) return;
437
+ el.innerHTML = milestoneStripInner(rollup.milestones);
438
+ } catch (_) {}
439
+ }
440
+
441
+ function planFilterHtml(id, label, entries) {
442
+ if (!Array.isArray(entries) || entries.length === 0) return "";
443
+ return `<label for="${id}">${label}</label><select id="${id}"><option value="">all</option>${entries.map((e) => `<option value="${esc(e.id)}">${esc(e.title)}</option>`).join("")}</select>`;
444
+ }
445
+
291
446
  // The complete document for a given board state; `doug board serve` renders it from the record on
292
447
  // every request.
293
- function renderDoc(board, appSource, config) {
448
+ function renderDoc(board, appSource, config, rollup) {
294
449
  const comps = board.components.map((c) => `<option value="${esc(c)}">${esc(c)}</option>`).join("");
295
450
  const tags = tagOptions(board.cards);
296
- const tagFilter = tags.length
451
+ const hasGroups = Array.isArray(board.labelGroups);
452
+ const groupFilters = hasGroups
453
+ ? board.labelGroups.map((g) => `<label for="group-filter-${esc(g.id)}">${esc(g.title)}</label><select id="group-filter-${esc(g.id)}" data-group="${esc(g.id)}"><option value="">all</option>${g.labels.map((l) => `<option value="${esc(l.name)}">${esc(l.name)}</option>`).join("")}</select>`).join("\n")
454
+ : "";
455
+ const tagFilter = hasGroups ? groupFilters : tags.length
297
456
  ? `<label for="tag-filter">tag</label><select id="tag-filter"><option value="">all</option>${tags.map((t) => `<option value="${esc(t)}">${esc(t)}</option>`).join("")}</select>`
298
457
  : "";
299
458
  const head = `<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n<title>Doug Board</title>\n${FONTS}\n<style>${CSS}</style>\n</head>\n<body>\n`;
@@ -302,14 +461,14 @@ function renderDoc(board, appSource, config) {
302
461
  <div><h1>Doug Board</h1><div class="sub">${headerSubHtml(board, config)}</div></div>
303
462
  <div class="tools">
304
463
  <label for="filter">component</label><select id="filter"><option value="">all</option>${comps}</select>
305
- ${tagFilter ? tagFilter + "\n" : ""}<span class="status" id="filter-note"></span>
464
+ ${tagFilter ? tagFilter + "\n" : ""}${planFilterHtml("epic-filter", "epic", board.epics)}${planFilterHtml("milestone-filter", "milestone", board.milestones)}<span class="status" id="filter-note"></span>
306
465
  <button type="button" id="copy">Copy board JSON</button>
307
466
  <span class="status" id="status"></span>
308
467
  </div>
309
468
  </header>
310
469
  <section id="autopilot" aria-label="Autopilot" hidden></section>
311
- <main class="board" id="board">
312
- ${board.columns.map((col) => columnHtml(col, board.cards, board.columns)).join("\n")}
470
+ ${milestoneStripHtml(rollup)}<main class="board" id="board">
471
+ ${board.columns.map((col) => columnHtml(col, board.cards, board.columns, board.labelGroups)).join("\n")}
313
472
  </main>
314
473
  ${footerHtml(config)}
315
474
  <script type="application/json" id="board-config">${JSON.stringify(config).replace(/</g, "\\u003c")}<\/script>
@@ -325,6 +484,8 @@ function app() {
325
484
  const status = document.getElementById("status");
326
485
  // In local mode the page stays read-only until `doug board serve` answers for it.
327
486
  let readOnly = local;
487
+ let doneExpanded = false;
488
+ let currentFilters = {};
328
489
 
329
490
  const say = (m) => { status.textContent = m; };
330
491
  const setReadOnly = (why) => { readOnly = true; document.body.classList.add("ro"); say(why); };
@@ -352,6 +513,11 @@ function app() {
352
513
  const card = board.cards.find((c) => c.id === id);
353
514
  if (!card) return;
354
515
  if (readOnly) { say("This view is read-only; the move was not saved."); return; }
516
+ if (column === "ready") {
517
+ const rule = priorityBandMove(board.cards, board.labelGroups, id, column, targetId);
518
+ if (rule === "refuse") { say(READY_BAND_HINT); return; }
519
+ if (rule === "column") { moveCard(id, column); return; }
520
+ }
355
521
  if (card.column !== column) card.column = column;
356
522
  const el = document.querySelector(`.card[data-id="${CSS_escape(id)}"]`);
357
523
  const targetEl = document.querySelector(`.card[data-id="${CSS_escape(targetId)}"]`);
@@ -363,7 +529,9 @@ function app() {
363
529
 
364
530
  function refreshCounts() {
365
531
  for (const col of document.querySelectorAll(".col")) {
366
- col.querySelector(".n").textContent = col.querySelectorAll(".card:not([hidden])").length;
532
+ // A collapsed Done column does not render its older cards; add the ones the filters let through.
533
+ const extra = col.dataset.column === "done" && !doneExpanded ? doneHiddenCount(board.cards, currentFilters) : 0;
534
+ col.querySelector(".n").textContent = col.querySelectorAll(".card:not([hidden])").length + extra;
367
535
  }
368
536
  }
369
537
 
@@ -499,7 +667,27 @@ function app() {
499
667
  // A tag itself may contain spaces (e.g. "needs review"), and data-tags joins every tag on the card
500
668
  // with a single space, so a naive split cannot tell a two-word tag from two one-word tags. Reconstruct
501
669
  // against the known tag options instead, matching the longest known tag first.
502
- const knownTags = tagFilter ? Array.from(tagFilter.options).map((o) => o.value).filter((v) => v) : [];
670
+ const epicFilter = document.getElementById("epic-filter");
671
+ const milestoneFilter = document.getElementById("milestone-filter");
672
+ for (const [sel, key] of [[epicFilter, "doug-board-epic-filter"], [milestoneFilter, "doug-board-milestone-filter"]]) {
673
+ if (!sel) continue;
674
+ try {
675
+ const saved = localStorage.getItem(key);
676
+ if (saved && Array.from(sel.options).some((o) => o.value === saved)) sel.value = saved;
677
+ } catch (_) {}
678
+ }
679
+ const groupSelects = [];
680
+ for (const g of board.labelGroups || []) {
681
+ const sel = document.getElementById("group-filter-" + g.id);
682
+ if (!sel) continue;
683
+ groupSelects.push({ id: g.id, sel: sel });
684
+ try {
685
+ const saved = localStorage.getItem("doug-board-group-filter-" + g.id);
686
+ if (saved && g.labels.some((l) => l.name === saved)) sel.value = saved;
687
+ } catch (_) {}
688
+ }
689
+ const knownTags = tagFilter ? Array.from(tagFilter.options).map((o) => o.value).filter((v) => v)
690
+ : (board.labelGroups || []).flatMap((g) => g.labels.map((l) => l.name));
503
691
  const sortedKnownTags = [...knownTags].sort((a, b) => b.split(" ").length - a.split(" ").length);
504
692
  const parseCardTags = (attr) => {
505
693
  if (!attr) return [];
@@ -519,20 +707,41 @@ function app() {
519
707
  const applyFilter = () => {
520
708
  const component = filter.value;
521
709
  const tag = tagFilter ? tagFilter.value : "";
710
+ const epic = epicFilter ? epicFilter.value : "";
711
+ const milestone = milestoneFilter ? milestoneFilter.value : "";
712
+ const groups = {};
713
+ for (const gs of groupSelects) groups[gs.id] = gs.sel.value;
714
+ const anyGroup = Object.keys(groups).some((k) => groups[k]);
715
+ currentFilters = { component: component, tag: tag, groups: groups, epic: epic, milestone: milestone };
522
716
  for (const card of document.querySelectorAll(".card")) {
523
- const shape = { component: card.dataset.component || "", tags: parseCardTags(card.dataset.tags) };
524
- card.hidden = !cardMatches(shape, { component: component, tag: tag });
717
+ const shape = { component: card.dataset.component || "", tags: parseCardTags(card.dataset.tags), epic: card.dataset.epic || "", milestone: card.dataset.milestone || "" };
718
+ card.hidden = !cardMatches(shape, currentFilters);
525
719
  }
526
720
  refreshCounts();
527
721
  // A saved filter is easy to forget; say what it hides so the column counts are not mistaken for the board.
528
722
  const all = document.querySelectorAll(".card").length;
529
723
  const shown = document.querySelectorAll(".card:not([hidden])").length;
530
- document.getElementById("filter-note").textContent = component || tag ? `showing ${shown} of ${all} cards` : "";
724
+ document.getElementById("filter-note").textContent = component || tag || anyGroup || epic || milestone ? `showing ${shown} of ${all} cards` : "";
531
725
  try { localStorage.setItem("doug-board-filter", component); } catch (_) {}
532
726
  if (tagFilter) { try { localStorage.setItem("doug-board-tag-filter", tag); } catch (_) {} }
727
+ if (epicFilter) { try { localStorage.setItem("doug-board-epic-filter", epic); } catch (_) {} }
728
+ if (milestoneFilter) { try { localStorage.setItem("doug-board-milestone-filter", milestone); } catch (_) {} }
729
+ for (const gs of groupSelects) { try { localStorage.setItem("doug-board-group-filter-" + gs.id, gs.sel.value); } catch (_) {} }
533
730
  };
534
731
  filter.addEventListener("change", applyFilter);
732
+ document.addEventListener("click", (e) => {
733
+ const btn = e.target && e.target.closest ? e.target.closest("[data-show-all]") : null;
734
+ if (!btn) return;
735
+ doneExpanded = true;
736
+ const container = document.querySelector('.col[data-column="done"] .cards');
737
+ const doneCol = board.columns.find((c) => c.id === "done");
738
+ if (container && doneCol) container.innerHTML = columnCardsHtml(doneCol, board.cards, board.columns, board.labelGroups, true);
739
+ applyFilter();
740
+ });
535
741
  if (tagFilter) tagFilter.addEventListener("change", applyFilter);
742
+ if (epicFilter) epicFilter.addEventListener("change", applyFilter);
743
+ if (milestoneFilter) milestoneFilter.addEventListener("change", applyFilter);
744
+ for (const gs of groupSelects) gs.sel.addEventListener("change", applyFilter);
536
745
  applyFilter();
537
746
 
538
747
  document.getElementById("copy").addEventListener("click", async () => {
@@ -562,8 +771,9 @@ function app() {
562
771
  setTimeout: window.setTimeout.bind(window),
563
772
  clearTimeout: window.clearTimeout.bind(window),
564
773
  onAutopilot: (view) => applyAutopilot(view),
774
+ onRollup: (r) => applyRollup(r),
565
775
  onBoard: (next) => {
566
- const updated = applyLiveBoard(board, next, config);
776
+ const updated = applyLiveBoard(board, next, config, { doneExpanded: doneExpanded });
567
777
  if (updated) {
568
778
  board = updated;
569
779
  refreshCounts();