beadcyte 0.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 (108) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/LICENSE +661 -0
  3. package/README.md +386 -0
  4. package/THIRD_PARTY_NOTICES.md +56 -0
  5. package/bin/beadcyte.mjs +60 -0
  6. package/package.json +77 -0
  7. package/src/changelog-cli.mjs +105 -0
  8. package/src/changelog.mjs +196 -0
  9. package/src/cli.mjs +577 -0
  10. package/src/estimator.mjs +314 -0
  11. package/src/format.mjs +22 -0
  12. package/src/history-walk.mjs +170 -0
  13. package/src/index.mjs +5 -0
  14. package/src/mutate.mjs +193 -0
  15. package/src/projects.mjs +120 -0
  16. package/src/provenance.mjs +75 -0
  17. package/src/review-hours.mjs +117 -0
  18. package/src/roster-path.mjs +24 -0
  19. package/src/scheduler.mjs +424 -0
  20. package/src/serve.mjs +411 -0
  21. package/src/server-state.mjs +105 -0
  22. package/src/ship.mjs +178 -0
  23. package/src/stage-waits.mjs +69 -0
  24. package/src/start.mjs +111 -0
  25. package/src/stop.mjs +66 -0
  26. package/src/velocity.mjs +209 -0
  27. package/src/web/App.vue +691 -0
  28. package/src/web/app.css +54 -0
  29. package/src/web/assets/favicon.svg +12 -0
  30. package/src/web/avatar.ts +53 -0
  31. package/src/web/bead-detail.ts +240 -0
  32. package/src/web/changelog-view.ts +41 -0
  33. package/src/web/components/BeadDrawer.vue +1883 -0
  34. package/src/web/components/BeadSubGraph.vue +326 -0
  35. package/src/web/components/BeadSubGraphOverlay.vue +192 -0
  36. package/src/web/components/BeadTooltip.vue +516 -0
  37. package/src/web/components/BeadcyteMark.vue +64 -0
  38. package/src/web/components/BeadsGantt.vue +2125 -0
  39. package/src/web/components/BeadsGrid.vue +468 -0
  40. package/src/web/components/BeadsIncytes.vue +567 -0
  41. package/src/web/components/BeadsMine.vue +325 -0
  42. package/src/web/components/BeadsTable.vue +335 -0
  43. package/src/web/components/ChangelogOverlay.vue +198 -0
  44. package/src/web/components/ContextMenu.vue +386 -0
  45. package/src/web/components/ControlsPanel.vue +476 -0
  46. package/src/web/components/CostTrend.vue +206 -0
  47. package/src/web/components/FilterPopover.vue +245 -0
  48. package/src/web/components/GroupProgress.vue +274 -0
  49. package/src/web/components/LoadMeter.vue +144 -0
  50. package/src/web/components/MineRow.vue +28 -0
  51. package/src/web/components/OptionsMenu.vue +825 -0
  52. package/src/web/components/PriorityChip.vue +105 -0
  53. package/src/web/components/ScoreStrip.vue +131 -0
  54. package/src/web/components/SearchPalette.vue +210 -0
  55. package/src/web/components/ShipTrend.vue +510 -0
  56. package/src/web/components/ShortcutsOverlay.vue +164 -0
  57. package/src/web/components/Term.vue +177 -0
  58. package/src/web/components/Toast.vue +50 -0
  59. package/src/web/components/TriageMeters.vue +426 -0
  60. package/src/web/components/TypeChip.vue +96 -0
  61. package/src/web/components/Walkthrough.vue +209 -0
  62. package/src/web/components/WhatIfPanel.vue +206 -0
  63. package/src/web/components/WipBullets.vue +191 -0
  64. package/src/web/components/filter-option.ts +9 -0
  65. package/src/web/composables/url-codec.ts +136 -0
  66. package/src/web/composables/useBeadTooltip.ts +148 -0
  67. package/src/web/composables/useKeyboard.ts +97 -0
  68. package/src/web/composables/useLiveRefresh.ts +69 -0
  69. package/src/web/composables/useTheme.ts +125 -0
  70. package/src/web/composables/useUrlState.ts +208 -0
  71. package/src/web/controls-scope.ts +83 -0
  72. package/src/web/cost.ts +251 -0
  73. package/src/web/dep-headings.ts +62 -0
  74. package/src/web/economics.ts +440 -0
  75. package/src/web/env.d.ts +85 -0
  76. package/src/web/frontier.ts +208 -0
  77. package/src/web/gantt-viewport.ts +99 -0
  78. package/src/web/highlights.ts +124 -0
  79. package/src/web/index.html +46 -0
  80. package/src/web/insights.ts +107 -0
  81. package/src/web/keybindings.ts +200 -0
  82. package/src/web/load-meter.ts +72 -0
  83. package/src/web/main.ts +20 -0
  84. package/src/web/markdown.ts +14 -0
  85. package/src/web/mine.ts +137 -0
  86. package/src/web/mutations.ts +21 -0
  87. package/src/web/person.ts +102 -0
  88. package/src/web/projects-text.ts +15 -0
  89. package/src/web/projects.ts +188 -0
  90. package/src/web/refresh.ts +47 -0
  91. package/src/web/search.ts +50 -0
  92. package/src/web/shortcuts.ts +113 -0
  93. package/src/web/status-filter.ts +48 -0
  94. package/src/web/store.ts +1378 -0
  95. package/src/web/style-audit.mjs +346 -0
  96. package/src/web/styles-alt.css +111 -0
  97. package/src/web/styles-ported.css +270 -0
  98. package/src/web/subgraph.ts +362 -0
  99. package/src/web/table.ts +201 -0
  100. package/src/web/theme.ts +88 -0
  101. package/src/web/tokens.css +168 -0
  102. package/src/web/triage.ts +914 -0
  103. package/src/web/view-model.ts +717 -0
  104. package/src/web/walkthrough.ts +133 -0
  105. package/src/web/watchlist.ts +47 -0
  106. package/src/web/whatif.ts +291 -0
  107. package/src/web/window.ts +73 -0
  108. package/src/web/wip.ts +83 -0
package/src/cli.mjs ADDED
@@ -0,0 +1,577 @@
1
+ #!/usr/bin/env node
2
+ // beadcyte — single-SVG Gantt of bead history + projected future.
3
+ //
4
+ // Past bars: real `started_at → closed_at` for shipped beads (colored by
5
+ // outcome). Future bars: greedy WIP-cap scheduler (src/scheduler.mjs) using a
6
+ // historical-median duration estimator (src/estimator.mjs). Grouping default:
7
+ // parent epic (unparented → "no epic" swimlane at bottom). Non-spec:ready
8
+ // backlog is drawn faint; a bar whose assignee the scheduler inferred is
9
+ // diagonal-hatched — the same vocabulary as the interactive Gantt.
10
+ //
11
+ // beadcyte --out /tmp/gantt.svg
12
+ // beadcyte --from -60d --to +60d --group-by assignee
13
+ // beadcyte --roster ./roster.json # per-project WIP caps + affinities
14
+ // beadcyte --roster ./.beadcyte/roster.json # conventional default
15
+ //
16
+ // Deliberately zero runtime deps beyond node + `bd`. Reads the CWD's beads DB
17
+ // via `bd list --all --limit 0 --json` — run from any beads project.
18
+
19
+ import { readFileSync } from "node:fs";
20
+ import { writeFileSync } from "node:fs";
21
+ import { execSync } from "node:child_process";
22
+ import { existsSync } from "node:fs";
23
+ import { buildEstimator } from "./estimator.mjs";
24
+ import { resolveRosterPath, ROSTER_CANDIDATES } from "./roster-path.mjs";
25
+ import { scheduleGreedy } from "./scheduler.mjs";
26
+ import { stageWaits } from "./stage-waits.mjs";
27
+ import { isShipped } from "./ship.mjs";
28
+ import { formatDays } from "./format.mjs";
29
+ import { computeVelocity, calibrateRoster } from "./velocity.mjs";
30
+
31
+ // ── args ──────────────────────────────────────────────────────────────────
32
+ const args = parseArgs(process.argv.slice(2));
33
+ if (args.help || args.h) {
34
+ printHelp();
35
+ process.exit(0);
36
+ }
37
+ const fromDays = parseRelDays(args.from ?? "-90d");
38
+ const toDays = parseRelDays(args.to ?? "+90d");
39
+ const groupBy = args["group-by"] ?? "epic";
40
+ const includeDeferred = args["include-deferred"] === "true";
41
+ const maxPerGroup = parseInt(args["max-per-group"] ?? "20", 10);
42
+ const outPath = args.out ?? "./beadcyte.svg";
43
+ const rosterPath = args.roster;
44
+ const velocityLookback = parseInt(args["velocity-lookback"] ?? "60", 10);
45
+ const noCalibrate = args["no-calibrate"] === "true";
46
+
47
+ const today = startOfDay(new Date());
48
+ const t0 = addDays(today, fromDays);
49
+ const t1 = addDays(today, toDays);
50
+ const totalDays = daysBetween(t0, t1);
51
+
52
+ // ── data ──────────────────────────────────────────────────────────────────
53
+ console.error("[beadcyte] loading beads…");
54
+ const beads = JSON.parse(
55
+ execSync("bd list --all --limit 0 --json", {
56
+ encoding: "utf8",
57
+ maxBuffer: 256 * 1024 * 1024,
58
+ }),
59
+ );
60
+ console.error(`[beadcyte] loaded ${beads.length} beads`);
61
+
62
+ // Roster (per-project WIP caps + affinities). Conventional path is
63
+ // `./.beadcyte/roster.json`, override with `--roster`. Shape:
64
+ // {
65
+ // "cap": 5, // default cap for humans
66
+ // "humans": [
67
+ // { "handle": "alice", "cap": 3,
68
+ // "labels": ["component::backend"], // exact-match label affinities
69
+ // "areas": ["schema", "sql", "backend"] // fuzzy title/label keyword affinities
70
+ // },
71
+ // ...
72
+ // ]
73
+ // }
74
+ // If missing, every real assignee-string on a bead becomes a human with the
75
+ // default cap and no affinities — the model still runs, just with no synthetic
76
+ // pool routing.
77
+ let roster = { humans: [], cap: 5 };
78
+ const foundRoster = resolveRosterPath(process.cwd(), rosterPath);
79
+ if (foundRoster) {
80
+ roster = JSON.parse(readFileSync(foundRoster.path, "utf8"));
81
+ } else {
82
+ if (rosterPath) {
83
+ console.error(
84
+ `[beadcyte] --roster ${rosterPath} does not exist; deriving from beads`,
85
+ );
86
+ }
87
+ const seen = new Set();
88
+ for (const b of beads) if (b.assignee) seen.add(b.assignee);
89
+ roster.humans = [...seen].map((handle) => ({ handle }));
90
+ console.error(
91
+ `[beadcyte] no roster at ${ROSTER_CANDIDATES.join(" or ")}; derived ` +
92
+ `${roster.humans.length} humans from beads (default cap ${roster.cap})`,
93
+ );
94
+ }
95
+
96
+ // Build estimator from shipped history — we need its median cycle time
97
+ // for the Little's-Law calibration below, so build it first.
98
+ const { estimate, stats } = buildEstimator(beads);
99
+
100
+ // Reality calibration — measure per-human throughput over the last N days
101
+ // (default 60) and clamp each human's WIP cap using Little's Law
102
+ // (WIP = throughput × cycle time). Without this the projection assumes
103
+ // everyone runs at max WIP forever, which turns 4 devs × cap 5 into 20
104
+ // simultaneous in-flight beads even when the team ships ~10/month.
105
+ // --no-calibrate skips this for brand-new projects with no ship history.
106
+ const velocity = computeVelocity(beads, { lookbackDays: velocityLookback });
107
+ const rawRoster = roster;
108
+ if (!noCalibrate) {
109
+ roster = calibrateRoster(rawRoster, velocity, {
110
+ medianCycleDays: stats.median_lead_days || stats.global_median_days,
111
+ });
112
+ const nudges = roster.humans
113
+ .filter((h) => h._rawCap !== h.cap)
114
+ .map((h) => `${h.handle}=${h._rawCap}→${h.cap}`);
115
+ if (nudges.length) {
116
+ console.error(
117
+ `[beadcyte] calibration (${velocityLookback}d, ${velocity.totalClosedInWindow} ships, lead ${formatDays(stats.median_lead_days)}): ${nudges.join(", ")}`,
118
+ );
119
+ }
120
+ }
121
+
122
+ // Schedule the future
123
+ const { schedule, unscheduled, unassignedBacklog, unassignedProjection } =
124
+ scheduleGreedy(beads, roster, estimate, t1, {
125
+ waits: stageWaits(beads),
126
+ minLeadDays: stats.median_lead_days || 0,
127
+ });
128
+
129
+ // Merge projections into the primary schedule map for uniform lookup
130
+ for (const [id, s] of unassignedProjection) {
131
+ if (!schedule.has(id)) schedule.set(id, s);
132
+ }
133
+
134
+ // ── grouping ──────────────────────────────────────────────────────────────
135
+ const byId = new Map(beads.map((b) => [b.id, b]));
136
+ const groupFor = (b) => {
137
+ if (groupBy === "epic") return b.parent || "__no_epic";
138
+ if (groupBy === "assignee") {
139
+ if (b.assignee) return b.assignee;
140
+ const s = schedule.get(b.id);
141
+ if (s?.assignee) return s.assignee; // synthetic dispatch lands here
142
+ return "__unassigned";
143
+ }
144
+ if (groupBy === "seat") {
145
+ const s = (b.labels ?? []).find((l) => l.startsWith("seat::"));
146
+ return s ?? "__no_seat";
147
+ }
148
+ if (groupBy === "track") {
149
+ const t = (b.labels ?? []).find((l) => l.startsWith("track::"));
150
+ return t ?? "__no_track";
151
+ }
152
+ return "__all";
153
+ };
154
+
155
+ // Filter to what actually appears in the window
156
+ const visible = [];
157
+ for (const b of beads) {
158
+ if (b.status === "deferred" && !includeDeferred) continue;
159
+ if (b.issue_type === "epic") continue; // epics are group headers, not rows
160
+
161
+ // Past: closed and closed_at within window
162
+ if (b.status === "closed") {
163
+ if (!b.closed_at) continue;
164
+ const closedDay = daysBetween(t0, new Date(b.closed_at));
165
+ if (closedDay < 0 || closedDay > totalDays) continue;
166
+ visible.push(b);
167
+ continue;
168
+ }
169
+ // Future: has a schedule entry, in-window
170
+ const sched = schedule.get(b.id);
171
+ if (sched) {
172
+ const s = daysBetween(t0, today) + sched.start_day;
173
+ const e = daysBetween(t0, today) + sched.end_day;
174
+ if (e < 0 || s > totalDays) continue;
175
+ visible.push(b);
176
+ continue;
177
+ }
178
+ // Unscheduled but still "in the future queue" — belongs on the unassigned
179
+ // backlog rail if we're rendering that
180
+ if (unscheduled.includes(b.id) || unassignedBacklog.includes(b)) {
181
+ // Show them stacked at horizon end (no schedule guess)
182
+ visible.push(b);
183
+ }
184
+ }
185
+
186
+ // Group visible beads
187
+ const groups = new Map();
188
+ for (const b of visible) {
189
+ const g = groupFor(b);
190
+ if (!groups.has(g)) groups.set(g, []);
191
+ groups.get(g).push(b);
192
+ }
193
+ // Sort groups: real epics first (by id creation order, then no_epic last)
194
+ const groupOrder = [...groups.keys()].sort((a, b) => {
195
+ const aFake = a.startsWith("__");
196
+ const bFake = b.startsWith("__");
197
+ if (aFake !== bFake) return aFake ? 1 : -1;
198
+ return a.localeCompare(b);
199
+ });
200
+ // Sort within group: by start day, then priority
201
+ const startDayOf = (b) => {
202
+ if (b.status === "closed") return daysBetween(t0, new Date(b.started_at ?? b.closed_at));
203
+ const s = schedule.get(b.id);
204
+ if (s) return daysBetween(t0, today) + s.start_day;
205
+ return totalDays + 999; // unscheduled → far right
206
+ };
207
+ for (const arr of groups.values()) {
208
+ arr.sort((a, b) => startDayOf(a) - startDayOf(b) || (a.priority ?? 4) - (b.priority ?? 4));
209
+ }
210
+ // Cap rows per group. Prefer keeping: in-flight + shipped-in-window +
211
+ // spec:ready future, drop backlog-tail if we overflow. Records the drop count
212
+ // per group for a "+N more" indicator.
213
+ const truncatedBy = new Map();
214
+ for (const [g, arr] of groups) {
215
+ if (arr.length <= maxPerGroup) continue;
216
+ // Priority: past-shipped in window > in-flight > spec:ready > backlog
217
+ const rank = (b) => {
218
+ if (b.status === "closed") return 0;
219
+ if (b.status === "in_progress" || b.status === "in_review") return 1;
220
+ if ((b.labels ?? []).includes("spec:ready")) return 2;
221
+ return 3;
222
+ };
223
+ arr.sort((a, b) => rank(a) - rank(b) || startDayOf(a) - startDayOf(b));
224
+ const kept = arr.slice(0, maxPerGroup);
225
+ truncatedBy.set(g, arr.length - maxPerGroup);
226
+ // Re-sort kept by time for display
227
+ kept.sort((a, b) => startDayOf(a) - startDayOf(b) || (a.priority ?? 4) - (b.priority ?? 4));
228
+ groups.set(g, kept);
229
+ }
230
+
231
+ // ── layout constants ──────────────────────────────────────────────────────
232
+ const PX_PER_DAY = 7;
233
+ const LEFT_COL = 340;
234
+ const RIGHT_PAD = 24;
235
+ const ROW_H = 20;
236
+ const GROUP_H = 26;
237
+ const HEADER_H = 56;
238
+ const LEGEND_H = 120;
239
+ const PLOT_W = totalDays * PX_PER_DAY;
240
+ const TOTAL_W = LEFT_COL + PLOT_W + RIGHT_PAD;
241
+
242
+ let plotBodyH = 0;
243
+ for (const g of groupOrder) plotBodyH += GROUP_H + groups.get(g).length * ROW_H + 6;
244
+ const TOTAL_H = HEADER_H + plotBodyH + LEGEND_H;
245
+
246
+ // ── colors ────────────────────────────────────────────────────────────────
247
+ const C = {
248
+ bg: "#0b1220",
249
+ panel: "#111a2b",
250
+ gridWeak: "#1f2a44",
251
+ gridStrong: "#334366",
252
+ text: "#e5e7eb",
253
+ textDim: "#94a3b8",
254
+ today: "#ef4444",
255
+ shipped: "#4ade80",
256
+ shippedStroke: "#166534",
257
+ closedNoShip: "#64748b",
258
+ inProgress: "#60a5fa",
259
+ inReview: "#fbbf24",
260
+ blocked: "#f87171",
261
+ future: "#a78bfa",
262
+ epicHeader: "#c084fc",
263
+ };
264
+
265
+ // ── SVG builder ───────────────────────────────────────────────────────────
266
+ const parts = [];
267
+ const svgOpen = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${TOTAL_W} ${TOTAL_H}" width="${TOTAL_W}" height="${TOTAL_H}" font-family="ui-sans-serif,-apple-system,Segoe UI,Roboto,sans-serif" font-size="11">`;
268
+ parts.push(svgOpen);
269
+
270
+ // Defs: diagonal hatch for a synthetic assignee + subtle stripe for
271
+ // in_progress. The hatch is stripes of the future colour with nothing between
272
+ // them, so a bar takes its opacity exactly as a flat fill would and a hatched
273
+ // backlog bar is a faint hatch, not a different colour.
274
+ parts.push(`<defs>
275
+ <pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
276
+ <rect width="3" height="6" fill="${C.future}"/>
277
+ </pattern>
278
+ <pattern id="live" width="8" height="8" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
279
+ <rect width="8" height="8" fill="${C.inProgress}"/>
280
+ <line x1="0" y1="0" x2="0" y2="8" stroke="#93c5fd" stroke-width="2"/>
281
+ </pattern>
282
+ </defs>`);
283
+
284
+ // Background
285
+ parts.push(`<rect width="${TOTAL_W}" height="${TOTAL_H}" fill="${C.bg}"/>`);
286
+ parts.push(`<rect x="${LEFT_COL}" y="${HEADER_H}" width="${PLOT_W}" height="${plotBodyH}" fill="${C.panel}"/>`);
287
+
288
+ // Header: title + date axis
289
+ parts.push(`<text x="16" y="22" fill="${C.text}" font-size="14" font-weight="600">Beads timeline · ${fmtDate(t0)} → ${fmtDate(t1)}</text>`);
290
+ parts.push(`<text x="16" y="40" fill="${C.textDim}">group by ${groupBy} · ${visible.length} beads in window · generated ${fmtDate(today)}</text>`);
291
+
292
+ // Date grid + week/month ticks
293
+ const weekMs = 7 * 86_400_000;
294
+ for (let d = 0; d <= totalDays; d++) {
295
+ const x = LEFT_COL + d * PX_PER_DAY;
296
+ const date = addDays(t0, d);
297
+ const isMonthStart = date.getDate() === 1;
298
+ const isWeekStart = date.getDay() === 1;
299
+ if (isMonthStart) {
300
+ parts.push(`<line x1="${x}" y1="${HEADER_H}" x2="${x}" y2="${HEADER_H + plotBodyH}" stroke="${C.gridStrong}" stroke-width="1"/>`);
301
+ parts.push(`<text x="${x + 3}" y="${HEADER_H - 4}" fill="${C.text}" font-size="10" font-weight="600">${fmtMonth(date)}</text>`);
302
+ } else if (isWeekStart) {
303
+ parts.push(`<line x1="${x}" y1="${HEADER_H}" x2="${x}" y2="${HEADER_H + plotBodyH}" stroke="${C.gridWeak}" stroke-width="0.5"/>`);
304
+ }
305
+ }
306
+
307
+ // Today marker
308
+ const todayX = LEFT_COL + daysBetween(t0, today) * PX_PER_DAY;
309
+ parts.push(`<line x1="${todayX}" y1="${HEADER_H}" x2="${todayX}" y2="${HEADER_H + plotBodyH}" stroke="${C.today}" stroke-width="1.5" stroke-dasharray="3,3"/>`);
310
+ parts.push(`<text x="${todayX + 4}" y="${HEADER_H - 42}" fill="${C.today}" font-size="10" font-weight="600">TODAY</text>`);
311
+
312
+ // Rows
313
+ let y = HEADER_H + 4;
314
+ for (const g of groupOrder) {
315
+ const items = groups.get(g);
316
+ const epicBead = byId.get(g);
317
+ const label =
318
+ g === "__no_epic"
319
+ ? "(no epic)"
320
+ : g === "__unassigned"
321
+ ? "(unassigned)"
322
+ : g === "__no_seat"
323
+ ? "(no seat)"
324
+ : g === "__no_track"
325
+ ? "(no track)"
326
+ : epicBead
327
+ ? `${g} · ${trunc(epicBead.title, 44)}`
328
+ : g;
329
+
330
+ // Group header stripe
331
+ parts.push(`<rect x="0" y="${y}" width="${TOTAL_W}" height="${GROUP_H}" fill="${C.gridWeak}" opacity="0.4"/>`);
332
+ parts.push(`<text x="10" y="${y + 17}" fill="${C.epicHeader}" font-size="12" font-weight="600">${escapeXml(label)}</text>`);
333
+ const dropped = truncatedBy.get(g) ?? 0;
334
+ const countLabel = dropped
335
+ ? `${items.length} shown · ${dropped} more hidden`
336
+ : `${items.length} bead${items.length === 1 ? "" : "s"}`;
337
+ parts.push(`<text x="${TOTAL_W - RIGHT_PAD}" y="${y + 17}" fill="${C.textDim}" font-size="10" text-anchor="end">${countLabel}</text>`);
338
+ y += GROUP_H;
339
+
340
+ for (const b of items) {
341
+ const rowY = y;
342
+ y += ROW_H;
343
+
344
+ // Left label
345
+ const idText = b.id;
346
+ const title = trunc(b.title, 38);
347
+ const prio = b.priority ?? 4;
348
+ parts.push(`<text x="18" y="${rowY + 14}" fill="${C.textDim}" font-size="10" font-family="ui-monospace,monospace">${escapeXml(idText)}</text>`);
349
+ parts.push(`<text x="112" y="${rowY + 14}" fill="${C.text}" font-size="11">${escapeXml(title)}</text>`);
350
+ parts.push(`<text x="${LEFT_COL - 8}" y="${rowY + 14}" fill="${C.textDim}" font-size="9" text-anchor="end">P${prio}</text>`);
351
+
352
+ // Bar(s)
353
+ const bars = barsFor(b, schedule, t0, today, totalDays);
354
+ for (const bar of bars) {
355
+ const x = LEFT_COL + bar.startDay * PX_PER_DAY;
356
+ const w = Math.max(2, (bar.endDay - bar.startDay) * PX_PER_DAY);
357
+ const barY = rowY + 4;
358
+ const barH = ROW_H - 8;
359
+ const fill = bar.fill;
360
+ const stroke = bar.stroke ?? "none";
361
+ const opacity = bar.opacity ?? 1;
362
+ const dashAttr = bar.strokeDash ? ` stroke-dasharray="${bar.strokeDash}"` : "";
363
+ parts.push(`<rect x="${x}" y="${barY}" width="${w}" height="${barH}" fill="${fill}" stroke="${stroke}" stroke-width="0.75"${dashAttr} opacity="${opacity}" rx="2"/>`);
364
+ if (bar.marker === "ship") {
365
+ parts.push(`<circle cx="${x + w}" cy="${barY + barH / 2}" r="3" fill="${C.shipped}" stroke="#052e16" stroke-width="1"/>`);
366
+ }
367
+ }
368
+ }
369
+ y += 6;
370
+ }
371
+
372
+ // Legend
373
+ const legY = HEADER_H + plotBodyH + 20;
374
+ parts.push(`<text x="16" y="${legY}" fill="${C.text}" font-size="12" font-weight="600">Legend</text>`);
375
+ const legendItems = [
376
+ { fill: C.shipped, label: "shipped (past)" },
377
+ { fill: C.closedNoShip, label: "closed w/o ship" },
378
+ { fill: "url(#live)", label: "in progress today" },
379
+ { fill: C.inReview, label: "in review" },
380
+ { fill: C.future, label: "future · spec:ready" },
381
+ { fill: C.future, label: "future · backlog (faint)", opacity: 0.35 },
382
+ { fill: "url(#hatch)", label: "synthetic assignee (hatched)" },
383
+ { fill: C.blocked, label: "blocked" },
384
+ ];
385
+ legendItems.forEach((it, i) => {
386
+ const lx = 16 + (i % 4) * 200;
387
+ const ly = legY + 14 + Math.floor(i / 4) * 20;
388
+ const dash = it.strokeDash ? ` stroke-dasharray="${it.strokeDash}"` : "";
389
+ const op = it.opacity != null ? ` opacity="${it.opacity}"` : "";
390
+ const stroke = it.stroke ?? "#333";
391
+ parts.push(`<rect x="${lx}" y="${ly}" width="16" height="12" fill="${it.fill}" stroke="${stroke}" stroke-width="1"${dash}${op} rx="2"/>`);
392
+ parts.push(`<text x="${lx + 22}" y="${ly + 10}" fill="${C.textDim}" font-size="11">${it.label}</text>`);
393
+ });
394
+
395
+ // Model footer
396
+ const footerY = legY + 60;
397
+ const modelLine =
398
+ `estimator: median of last ${stats.shipped_sample_size} shipped beads (with ship metadata); ` +
399
+ `bucketed (type,priority) for ${stats.used_type_pri}, fell back (type) for ${stats.used_type}, ` +
400
+ `global for ${stats.used_global}. Global median = ${formatDays(stats.global_median_days)}, ` +
401
+ `p90 = ${formatDays(stats.global_p90_days)}.`;
402
+ parts.push(`<text x="16" y="${footerY}" fill="${C.textDim}" font-size="10">${escapeXml(modelLine)}</text>`);
403
+ // Roster line names each human's calibrated cap alongside their raw cap
404
+ // (parenthesized) + observed weekly rate, so the reader can see how the
405
+ // projection was calibrated against reality.
406
+ const humanBlurbs = (roster.humans ?? []).map((h) => {
407
+ const cap = h.cap ?? roster.cap ?? 5;
408
+ if (h._rawCap !== undefined && h._rawCap !== cap) {
409
+ return `${h.handle}=${cap} (raw ${h._rawCap}, ${h._closesPerWeek.toFixed(1)}/wk)`;
410
+ }
411
+ if (h._closesPerWeek > 0) return `${h.handle}=${cap} (${h._closesPerWeek.toFixed(1)}/wk)`;
412
+ return `${h.handle}=${cap}`;
413
+ });
414
+ const calibrationHint = noCalibrate
415
+ ? "calibration OFF"
416
+ : `calibrated to last ${velocityLookback}d (${velocity.totalClosedInWindow} ships, ${velocity.activeCount} active)`;
417
+ parts.push(`<text x="16" y="${footerY + 14}" fill="${C.textDim}" font-size="10">scheduler: greedy WIP-cap · ${calibrationHint} · ${humanBlurbs.join(", ")}. Deps: blocks-edges; spec:ready gates dispatch.</text>`);
418
+ parts.push(`<text x="16" y="${footerY + 28}" fill="${C.textDim}" font-size="10">${unscheduled.length} beads not scheduled within window (blocked, no assignee, or past horizon).</text>`);
419
+
420
+ parts.push(`</svg>`);
421
+ writeFileSync(outPath, parts.join("\n"));
422
+ console.error(`[beadcyte] wrote ${outPath} (${(parts.join("").length / 1024).toFixed(1)} KB)`);
423
+ console.error(`[beadcyte] estimator: type-pri=${stats.used_type_pri}, type=${stats.used_type}, global=${stats.used_global}`);
424
+ console.error(`[beadcyte] unscheduled: ${unscheduled.length}`);
425
+
426
+ // ── helpers ───────────────────────────────────────────────────────────────
427
+ function barsFor(b, schedule, t0, today, totalDays) {
428
+ if (b.status === "closed") {
429
+ // Past bar. `started_at` is often minutes before `closed_at` (bd close
430
+ // right after claim), so enforce a minimum visual duration of 1 day —
431
+ // the bar is a rendering hint, the ship marker is the truth.
432
+ const start = b.started_at ? new Date(b.started_at) : new Date(b.closed_at);
433
+ const end = new Date(b.closed_at);
434
+ const s = daysBetween(t0, start);
435
+ const eRaw = daysBetween(t0, end);
436
+ const e = Math.max(eRaw, s + 1); // min 1-day bar
437
+ const sc = clamp(s, 0, totalDays);
438
+ const ec = clamp(e, 0, totalDays);
439
+ const shipped = isShipped(b);
440
+ return [
441
+ {
442
+ startDay: sc,
443
+ endDay: Math.max(ec, sc + 0.5),
444
+ fill: shipped ? C.shipped : C.closedNoShip,
445
+ stroke: shipped ? C.shippedStroke : "none",
446
+ marker: shipped ? "ship" : null,
447
+ opacity: shipped ? 0.95 : 0.5,
448
+ },
449
+ ];
450
+ }
451
+ const sched = schedule.get(b.id);
452
+ if (sched) {
453
+ const todayDay = daysBetween(t0, today);
454
+ // Unassigned projections use absolute day indices (from today); regular
455
+ // schedule entries use relative to today. Detect by presence of `unassigned`.
456
+ const relToday = sched.unassigned ? todayDay : todayDay;
457
+ const startDay = clamp(relToday + sched.start_day, 0, totalDays);
458
+ const endDay = clamp(relToday + sched.end_day, 0, totalDays);
459
+ const bars = [];
460
+ // Same vocabulary as the interactive Gantt (BeadsGantt.vue): backlog is
461
+ // faint, an inferred assignee is hatched, and the two compose — a
462
+ // hatched backlog bar is a faint hatch. The dashed stroke this replaces
463
+ // was the epic colour on the future colour, a few degrees of hue apart
464
+ // on a 12px bar, and read as nothing.
465
+ const fill = sched.synthetic_assignee ? "url(#hatch)" : C.future;
466
+
467
+ if (sched.in_progress_at_start && startDay < todayDay) {
468
+ bars.push({
469
+ startDay,
470
+ endDay: todayDay,
471
+ fill: "url(#live)",
472
+ });
473
+ bars.push({
474
+ startDay: todayDay,
475
+ endDay: Math.max(endDay, todayDay + 0.5),
476
+ fill,
477
+ opacity: sched.backlog ? 0.35 : 0.75,
478
+ });
479
+ } else {
480
+ bars.push({
481
+ startDay,
482
+ endDay: Math.max(endDay, startDay + 0.5),
483
+ fill,
484
+ opacity: sched.backlog ? 0.35 : sched.unassigned ? 0.45 : 0.85,
485
+ stroke: sched.unassigned ? "#4c1d95" : "none",
486
+ });
487
+ }
488
+ return bars;
489
+ }
490
+ return [];
491
+ }
492
+
493
+ function parseArgs(argv) {
494
+ const out = {};
495
+ for (let i = 0; i < argv.length; i++) {
496
+ const a = argv[i];
497
+ if (!a.startsWith("--")) continue;
498
+ const k = a.slice(2);
499
+ const v = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : "true";
500
+ out[k] = v;
501
+ }
502
+ return out;
503
+ }
504
+ function parseRelDays(s) {
505
+ const m = String(s).match(/^([+-]?\d+)d$/);
506
+ if (!m) throw new Error(`bad --from/--to: ${s} (want e.g. -90d, +30d)`);
507
+ return parseInt(m[1], 10);
508
+ }
509
+ function startOfDay(d) {
510
+ const x = new Date(d);
511
+ x.setHours(0, 0, 0, 0);
512
+ return x;
513
+ }
514
+ function addDays(d, n) {
515
+ const x = new Date(d);
516
+ x.setDate(x.getDate() + n);
517
+ return startOfDay(x);
518
+ }
519
+ function daysBetween(a, b) {
520
+ return Math.round((b.getTime() - a.getTime()) / 86_400_000);
521
+ }
522
+ function clamp(v, lo, hi) {
523
+ return Math.max(lo, Math.min(hi, v));
524
+ }
525
+ function fmtDate(d) {
526
+ return d.toISOString().slice(0, 10);
527
+ }
528
+ function fmtMonth(d) {
529
+ return d.toLocaleDateString("en-US", { month: "short", year: "2-digit" });
530
+ }
531
+ function trunc(s, n) {
532
+ if (!s) return "";
533
+ return s.length > n ? s.slice(0, n - 1) + "…" : s;
534
+ }
535
+ function escapeXml(s) {
536
+ return String(s ?? "")
537
+ .replace(/&/g, "&amp;")
538
+ .replace(/</g, "&lt;")
539
+ .replace(/>/g, "&gt;")
540
+ .replace(/"/g, "&quot;")
541
+ .replace(/'/g, "&apos;");
542
+ }
543
+
544
+ function printHelp() {
545
+ console.log(`beadcyte — Gantt of bead history + projected future
546
+
547
+ Usage:
548
+ beadcyte [--from -Nd] [--to +Nd] [--group-by MODE] [--out PATH] [flags]
549
+
550
+ Runs against the beads DB in the CURRENT WORKING DIRECTORY (bd list --all).
551
+ Emits a single self-contained SVG.
552
+
553
+ Flags:
554
+ --from -Nd Window start relative to today (default: -90d)
555
+ --to +Nd Window end relative to today (default: +90d)
556
+ --group-by MODE epic (default) | assignee | seat | track
557
+ --max-per-group N Rows per swimlane before truncation (default: 20)
558
+ --include-deferred Include status=deferred beads (default: false)
559
+ --roster PATH Per-project WIP caps + affinities
560
+ (default: .beadcyte/roster.json)
561
+ --velocity-lookback N Days of history for close-rate calibration
562
+ (default: 60)
563
+ --no-calibrate Skip calibration — trust raw WIP caps as-is
564
+ (useful for brand-new projects with no shipped
565
+ history yet)
566
+ --out PATH SVG output path (default: ./beads-gantt.svg)
567
+ -h, --help This message
568
+
569
+ Roster JSON shape:
570
+ { "cap": 5, "humans": [
571
+ { "handle": "alice", "cap": 3,
572
+ "labels": ["component::backend"],
573
+ "areas": ["schema", "sql", "backend"] } ] }
574
+
575
+ If no roster file exists, one is derived from bead assignees at the default cap.
576
+ `);
577
+ }