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
@@ -0,0 +1,314 @@
1
+ // gantt-estimator.mjs — historical-lookback duration estimator for beads-gantt.
2
+ //
3
+ // v0 (this file): for each shipped bead we compute the calendar duration
4
+ // `closed_at - started_at` (raw wall-clock, keeps blocked/in_review waits —
5
+ // which is real economic time, and the only signal available without
6
+ // agent-reported actuals). We bucket by (issue_type, priority) and take the
7
+ // median of the last N in bucket, falling back (type,priority) → (type) →
8
+ // global. The number of samples that landed in each fallback layer is
9
+ // reported so the SVG legend can be honest about the model.
10
+ //
11
+ // v1 (future, blocked on agents logging metadata.economics.total_hours at
12
+ // close): prefer that number where present, fall back to calendar duration
13
+ // otherwise. The plumbing is here already — see estimateBead()'s comment.
14
+ //
15
+ // A bead only counts as a sample if it shipped — see ship.mjs for what that
16
+ // means and why. The distinction is doing real work here: it separates
17
+ // ship-record closes from procedural ones (superseded, deferred-then-closed,
18
+ // admin cleanup) which would otherwise poison the median with a five-minute
19
+ // wall-clock.
20
+
21
+ import { isShipped } from "./ship.mjs";
22
+
23
+ const MS_PER_DAY = 86_400_000;
24
+ const MIN_SAMPLES_FOR_BUCKET = 3; // below this, fall back a layer
25
+
26
+ /**
27
+ * Hours in a day, for turning reported work-hours into the unit every
28
+ * consumer of this model actually means (bp-67g.39).
29
+ *
30
+ * This was 8 — an office workday — while wall-clock samples were 24-hour
31
+ * calendar days, and both went into the same bucket median. The scheduler
32
+ * then read that median as calendar days: it adds `est.days` to a date, and
33
+ * the Gantt draws it against a 24-hour axis. So the same duration measured
34
+ * two ways came out 3x apart, and a bucket could average the two.
35
+ *
36
+ * Measured on this repo's 28 beads carrying both numbers, `agent_hours`
37
+ * divided by wall-clock `started_at → closed_at` has a **median of 1.05**
38
+ * (range 0.25-3.60). An hour of reported work really is about an hour of
39
+ * calendar here, so one constant of 24 is not a compromise between the two
40
+ * definitions — it is what the data says.
41
+ *
42
+ * The cost, stated plainly: for a HUMAN seat, eight logged hours spans at
43
+ * least one working day and this will under-project it. Two things make that
44
+ * the right trade anyway — the alternative is inventing a working-hours model
45
+ * and calling a guess a measurement, and the scheduler already clamps every
46
+ * cap against observed throughput (Little's Law), which is where "this team
47
+ * does not actually finish that much in a day" belongs. If a seat-aware unit
48
+ * is ever needed, `metadata.economics.by_seat` is where the seat is.
49
+ */
50
+ const HOURS_PER_DAY = 24;
51
+
52
+ /**
53
+ * Fallback ratio from a bucket median to its p90, used only when a bead has
54
+ * its own recorded hours and no bucket exists to measure spread from.
55
+ *
56
+ * The observed ratios on this repo are 1.38x (task), 1.51x (bug), 2.21x
57
+ * (feature) and 1.76x globally, so this is the middle of a real range rather
58
+ * than the invented 1.5 it replaces — but it is still a fallback, and
59
+ * `estimateBead` prefers a measured spread whenever it has one.
60
+ */
61
+ const FALLBACK_P90_RATIO = 1.76;
62
+
63
+ /**
64
+ * Smallest estimate we will report, in days — about seven minutes.
65
+ *
66
+ * This replaces a 0.5-day floor that was destroying the model rather than
67
+ * protecting it. On a 577-bead repo, 6 of the 9 qualifying (type, priority)
68
+ * buckets had medians below half a day — 215, 342, 209, 622, 706 and 272
69
+ * minutes — and every one of them was reported as 0.50d, so buckets three
70
+ * times apart came out identical and 96% of all beads got the same number.
71
+ *
72
+ * The samples were not the problem: only 1 of 143 was under fifteen
73
+ * minutes, and the global median was 437 minutes of real work. The floor
74
+ * was simply an order of magnitude larger than anything it needed to guard.
75
+ *
76
+ * Nothing downstream needs half a day either. BeadsGantt already floors a
77
+ * bar at `Math.max(2, ...)` pixels so a short bead stays visible;
78
+ * scheduleGreedy floors its own `remaining` at 0.5 and advances in whole-day
79
+ * timesteps, so a sub-day estimate behaves identically to a half-day one in
80
+ * the plan. All the old floor changed was the number on screen, and it
81
+ * changed it into a constant.
82
+ *
83
+ * What remains is a guard against zero: a zero-length bar has no start and
84
+ * the scheduler would never retire it.
85
+ */
86
+ const MIN_ESTIMATE_DAYS = 0.005;
87
+
88
+ /**
89
+ * Build a duration model from the full bead list.
90
+ * Returns { estimate(bead) → days, stats } where stats explains the model
91
+ * for the SVG legend footer.
92
+ */
93
+ export function buildEstimator(allBeads, { lookback = 60 } = {}) {
94
+ const shipped = allBeads
95
+ .filter(
96
+ (b) =>
97
+ b.status === "closed" &&
98
+ b.started_at &&
99
+ b.closed_at &&
100
+ isShipped(b),
101
+ )
102
+ .map((b) => {
103
+ // Prefer agent-reported work-hours when available — many beads have
104
+ // `bd close` run immediately after `bd update --claim`, so calendar
105
+ // duration is near-zero even for real work. metadata.economics.totals
106
+ // .agent_hours is the honest number when it's there.
107
+ const agentHours = b.metadata?.economics?.totals?.agent_hours;
108
+ const wallClockDays =
109
+ (new Date(b.closed_at).getTime() -
110
+ new Date(b.started_at).getTime()) /
111
+ MS_PER_DAY;
112
+ const days =
113
+ typeof agentHours === "number" && agentHours > 0
114
+ ? agentHours / HOURS_PER_DAY
115
+ : wallClockDays;
116
+ return {
117
+ id: b.id,
118
+ type: b.issue_type,
119
+ priority: b.priority ?? 4,
120
+ days,
121
+ _source: typeof agentHours === "number" && agentHours > 0 ? "agent" : "wallclock",
122
+ closed: new Date(b.closed_at).getTime(),
123
+ };
124
+ })
125
+ .filter((s) => s.days > 0 && s.days < 90) // trim outliers (deferred>90d)
126
+ .sort((a, b) => b.closed - a.closed)
127
+ .slice(0, lookback * 4); // pull a wide window; buckets trim further
128
+
129
+ const median = (arr) => {
130
+ if (!arr.length) return null;
131
+ const s = [...arr].sort((a, b) => a - b);
132
+ const m = Math.floor(s.length / 2);
133
+ return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
134
+ };
135
+ const p90 = (arr) => {
136
+ if (!arr.length) return null;
137
+ const s = [...arr].sort((a, b) => a - b);
138
+ return s[Math.min(s.length - 1, Math.floor(s.length * 0.9))];
139
+ };
140
+
141
+ // Bucket maps
142
+ const byTypeAndPri = new Map();
143
+ const byType = new Map();
144
+ for (const s of shipped) {
145
+ const k = `${s.type}|${s.priority}`;
146
+ if (!byTypeAndPri.has(k)) byTypeAndPri.set(k, []);
147
+ byTypeAndPri.get(k).push(s.days);
148
+ if (!byType.has(s.type)) byType.set(s.type, []);
149
+ byType.get(s.type).push(s.days);
150
+ }
151
+ const globalDays = shipped.map((s) => s.days);
152
+ const globalMed = median(globalDays) ?? 5;
153
+ const globalP90 = p90(globalDays) ?? 10;
154
+
155
+ // Lead time — created_at → closed_at — is a separate signal from
156
+ // work-time. Wall-clock lead includes queue wait, review, context
157
+ // switches, etc. — much more useful for Little's-Law scheduling than
158
+ // per-bead work duration. Sample the same shipped set as the duration
159
+ // buckets, but keep it in its own median.
160
+ const leadSamples = allBeads
161
+ .filter(
162
+ (b) =>
163
+ b.status === "closed" &&
164
+ b.created_at &&
165
+ b.closed_at &&
166
+ isShipped(b),
167
+ )
168
+ .map(
169
+ (b) =>
170
+ (new Date(b.closed_at).getTime() -
171
+ new Date(b.created_at).getTime()) /
172
+ MS_PER_DAY,
173
+ )
174
+ .filter((d) => d > 0 && d < 180);
175
+ const leadMed = median(leadSamples) ?? globalMed;
176
+ const leadP90 = p90(leadSamples) ?? globalP90;
177
+
178
+ const stats = {
179
+ shipped_sample_size: shipped.length,
180
+ // Three decimals, not one. At one, every sub-day median reports as 0.0 —
181
+ // this repo's global median of 0.0118d printed as `0`, which made the
182
+ // legend claim no history and hid bp-67g.39 entirely. bp-67g.24 removed
183
+ // the half-day floor precisely so sub-day durations survive the model;
184
+ // rounding them away on the way out undid that at the last step.
185
+ // `median_lead_days` also feeds the Little's Law cap calibration, so the
186
+ // precision is load-bearing and not only cosmetic.
187
+ global_median_days: round(globalMed, 3),
188
+ global_p90_days: round(globalP90, 3),
189
+ // Lead-time medians for scheduler calibration. Populated when there's
190
+ // enough shipped history; falls back to work-time medians otherwise.
191
+ median_lead_days: round(leadMed, 3),
192
+ p90_lead_days: round(leadP90, 3),
193
+ lead_sample_size: leadSamples.length,
194
+ used_agent_hours: 0,
195
+ used_spent_floor: 0,
196
+ used_type_pri: 0,
197
+ used_type: 0,
198
+ used_global: 0,
199
+ };
200
+
201
+ /**
202
+ * Spread of the tightest bucket that has samples, as a p90/median ratio.
203
+ * Used to put an honest p90 on a bead estimated from its own recorded
204
+ * hours, which has no distribution of its own.
205
+ */
206
+ function spreadFor(type, pri) {
207
+ for (const bucket of [byTypeAndPri.get(`${type}|${pri}`), byType.get(type), globalDays]) {
208
+ if (!bucket || bucket.length < MIN_SAMPLES_FOR_BUCKET) continue;
209
+ const m = median(bucket);
210
+ const p = p90(bucket);
211
+ if (m > 0 && p !== null && p >= m) return p / m;
212
+ }
213
+ return FALLBACK_P90_RATIO;
214
+ }
215
+
216
+ function estimateBead(bead) {
217
+ const pri = bead.priority ?? 4;
218
+ const type = bead.issue_type ?? "task";
219
+ const agentHours = bead.metadata?.economics?.totals?.agent_hours;
220
+ const hasHours = typeof agentHours === "number" && agentHours > 0;
221
+ const hoursAsDays = hasHours ? agentHours / HOURS_PER_DAY : 0;
222
+
223
+ // A CLOSED bead's recorded hours are the answer, not an estimate: we know
224
+ // what it took. Reported hours beat the bucketed median here because a
225
+ // bead closed shortly after being claimed has a near-zero wall-clock even
226
+ // when real work happened.
227
+ if (hasHours && bead.status === "closed") {
228
+ stats.used_agent_hours = (stats.used_agent_hours ?? 0) + 1;
229
+ const days = Math.max(MIN_ESTIMATE_DAYS, hoursAsDays);
230
+ return {
231
+ days,
232
+ // No distribution of its own, so borrow the spread of the beads it
233
+ // sits with rather than asserting a made-up multiplier.
234
+ p90: days * spreadFor(type, pri),
235
+ source: `agent, ${agentHours.toFixed(2)}h`,
236
+ };
237
+ }
238
+
239
+ const kTP = `${type}|${pri}`;
240
+ const bucketTP = byTypeAndPri.get(kTP);
241
+ if (bucketTP && bucketTP.length >= MIN_SAMPLES_FOR_BUCKET) {
242
+ stats.used_type_pri++;
243
+ return fromBucket(median(bucketTP), p90(bucketTP),
244
+ `(${type}, P${pri}), n=${bucketTP.length}`);
245
+ }
246
+ const bucketT = byType.get(type);
247
+ if (bucketT && bucketT.length >= MIN_SAMPLES_FOR_BUCKET) {
248
+ stats.used_type++;
249
+ return fromBucket(median(bucketT), p90(bucketT), `(${type}), n=${bucketT.length}`);
250
+ }
251
+ stats.used_global++;
252
+ return fromBucket(globalMed, globalP90, `global, n=${shipped.length}`);
253
+
254
+ /**
255
+ * A bucketed estimate, floored at whatever this bead has ALREADY
256
+ * consumed.
257
+ *
258
+ * Live beads are the ones being scheduled, and `agent_hours` on a live
259
+ * bead is work spent, not work needed — taking it as the estimate says a
260
+ * half-finished bead is done. But it is a hard lower bound: a bead cannot
261
+ * end up having taken less than it already has. So the recorded hours
262
+ * become a floor under the bucket median rather than a replacement for
263
+ * it, and the source says when that floor is what answered.
264
+ */
265
+ function fromBucket(med, hi, source) {
266
+ const bucketDays = Math.max(MIN_ESTIMATE_DAYS, med);
267
+ if (hasHours && hoursAsDays > bucketDays) {
268
+ stats.used_spent_floor = (stats.used_spent_floor ?? 0) + 1;
269
+ return {
270
+ days: hoursAsDays,
271
+ p90: hoursAsDays * spreadFor(type, pri),
272
+ source: `${source}, ≥ ${agentHours.toFixed(2)}h spent`,
273
+ };
274
+ }
275
+ return { days: bucketDays, p90: hi, source };
276
+ }
277
+ }
278
+
279
+ return { estimate: estimateBead, stats };
280
+ }
281
+
282
+ function round(n, d) {
283
+ const f = 10 ** d;
284
+ return Math.round(n * f) / f;
285
+ }
286
+
287
+ /**
288
+ * What an estimate's `source` string means, for a reader who meets
289
+ * "(feature), n=8" in the drawer and asks what n is (bp-67g.47). Lives here
290
+ * because this file is what writes those strings: add a layer or rename one
291
+ * and both change together. Null for a string this build does not write.
292
+ */
293
+ export function describeEstimateSource(source) {
294
+ if (typeof source !== "string" || !source) return null;
295
+ const floor = /, ≥ ([\d.]+)h spent$/.exec(source);
296
+ const base = floor ? source.slice(0, floor.index) : source;
297
+ let text;
298
+ let m;
299
+ if ((m = /^agent, ([\d.]+)h$/.exec(base))) {
300
+ text = `Not an estimate: this bead is closed and ${m[1]} hours of work were recorded on it, so that is what it took.`;
301
+ } else if ((m = /^\(([\w-]+), P(\d)\), n=(\d+)$/.exec(base))) {
302
+ text = `The median time of the ${m[3]} shipped ${m[1]} beads at P${m[2]} — the tightest bucket with at least ${MIN_SAMPLES_FOR_BUCKET} samples. n is that sample count; a small n deserves less trust.`;
303
+ } else if ((m = /^\(([\w-]+)\), n=(\d+)$/.exec(base))) {
304
+ text = `The median time of the ${m[2]} shipped ${m[1]} beads at any priority — fewer than ${MIN_SAMPLES_FOR_BUCKET} shipped at this type and priority, so the estimate fell back a layer. n is that sample count.`;
305
+ } else if ((m = /^global, n=(\d+)$/.exec(base))) {
306
+ text = `The median time of all ${m[1]} shipped beads — fewer than ${MIN_SAMPLES_FOR_BUCKET} shipped of this type, so the estimate fell back to the whole repo. n is that sample count.`;
307
+ } else {
308
+ return null;
309
+ }
310
+ if (floor) {
311
+ text += ` Floored at the ${floor[1]} hours already spent on it: a bead cannot end up having taken less than it already has.`;
312
+ }
313
+ return text;
314
+ }
package/src/format.mjs ADDED
@@ -0,0 +1,22 @@
1
+ // format.mjs — how a duration is said out loud.
2
+ //
3
+ // One ladder, because the alternative is three. The web had it in
4
+ // economics.ts as `formatHours`, bead-detail.ts wrapped it as
5
+ // `formatEstimate(days)`, and the SVG CLI had nothing and printed raw days —
6
+ // which is how the legend came to read `Global median = 0.012d` (bp-67g.39).
7
+ //
8
+ // The model's native unit is days and every consumer means calendar days by
9
+ // it, but on an agent-driven repo the medians are minutes. A number has to be
10
+ // printed in a unit a person can hold.
11
+
12
+ /** Hours as a short human string: "17m", "5.5h", "3.2d". */
13
+ export function formatHours(h) {
14
+ if (h < 1) return `${(h * 60).toFixed(0)}m`;
15
+ if (h < 24) return `${h.toFixed(1)}h`;
16
+ return `${(h / 24).toFixed(1)}d`;
17
+ }
18
+
19
+ /** Days — the estimator's own unit — as the same short string. */
20
+ export function formatDays(d) {
21
+ return formatHours(d * 24);
22
+ }
@@ -0,0 +1,170 @@
1
+ // history-walk.mjs — the background walk over `bd history` (bp-67g.57).
2
+ //
3
+ // One shell-out per bead is ~0.4s and ~0.4MB on this repo, so nothing here
4
+ // runs in the request path — and bd serialises its own calls, so the walk
5
+ // must also stay OUT OF THE WAY of the request path: measured, a `bd list`
6
+ // issued while two `bd history` calls ran took 1.9s instead of 0.56s, and a
7
+ // first request that raced a walk took 4.9–7.4s instead of 1.0s. Hence one
8
+ // call at a time (two concurrent history calls take as long as two in a
9
+ // row), a `beforeEach` gate the server uses to hold the walk while a
10
+ // `bd list` is in flight, and a start delay the server adds so the boot-time
11
+ // prefetch is answered before the first history call is made.
12
+ //
13
+ // `refresh()` returns at once; the walk proceeds one bead at a time, writes
14
+ // each answer into a per-project cache keyed
15
+ // by bead id and `updated_at`, persists the cache next to the server's own
16
+ // state file (tmpdir/beadcyte/history-<project>.json) so a restart does not
17
+ // repeat the first pass, and tells the server when something changed so the
18
+ // cached payload can be re-merged and re-stamped for the next poll.
19
+ //
20
+ // `runHistory` is injected so the walker is testable without bd.
21
+
22
+ import { execFile } from "node:child_process";
23
+ import { promisify } from "node:util";
24
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
25
+ import { join } from "node:path";
26
+ import { firstInReviewAt, firstAssignedAt, planWalk } from "./review-hours.mjs";
27
+ import { projectKey, stateDir } from "./server-state.mjs";
28
+
29
+ const execFileAsync = promisify(execFile);
30
+
31
+ /** Default runner: `bd history --json <id>` in the project directory. */
32
+ export async function runBdHistory(project, id) {
33
+ const { stdout } = await execFileAsync("bd", ["history", "--json", id], {
34
+ cwd: project,
35
+ maxBuffer: 64 * 1024 * 1024,
36
+ });
37
+ const parsed = JSON.parse(stdout || "null");
38
+ return Array.isArray(parsed) ? parsed : [];
39
+ }
40
+
41
+ export function historyCacheFile(project, base) {
42
+ return join(stateDir(base), `history-${projectKey(project)}.json`);
43
+ }
44
+
45
+ function readCache(file) {
46
+ try {
47
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
48
+ return parsed && typeof parsed === "object" ? parsed : {};
49
+ } catch {
50
+ return {};
51
+ }
52
+ }
53
+
54
+ function writeCache(file, cache) {
55
+ try {
56
+ mkdirSync(join(file, ".."), { recursive: true });
57
+ writeFileSync(file, JSON.stringify(cache));
58
+ } catch {
59
+ // A read-only tmpdir costs a repeat of the first pass next start; the
60
+ // walk itself still works from memory.
61
+ }
62
+ }
63
+
64
+ /**
65
+ * @param {object} opts
66
+ * @param {(project: string, id: string) => Promise<unknown[]>} [opts.runHistory]
67
+ * @param {number} [opts.concurrency] default 1; see the header
68
+ * @param {() => Promise<void>} [opts.beforeEach] awaited before every history call — the server hands in "no bd list in flight"
69
+ * @param {(project: string) => string} [opts.cacheFileFor]
70
+ * @param {(msg: string) => void} [opts.log]
71
+ * @param {() => number} [opts.now]
72
+ */
73
+ export function createHistoryWalker(opts = {}) {
74
+ const runHistory = opts.runHistory ?? runBdHistory;
75
+ const concurrency = Math.max(1, opts.concurrency ?? 1);
76
+ const beforeEach = opts.beforeEach ?? (() => Promise.resolve());
77
+ const cacheFileFor = opts.cacheFileFor ?? ((project) => historyCacheFile(project));
78
+ const log = opts.log ?? (() => {});
79
+ const now = opts.now ?? (() => Date.now());
80
+
81
+ /** project -> { cache, file, running: Promise|null, pending: beads|null, listeners } */
82
+ const projects = new Map();
83
+
84
+ function state(project) {
85
+ let s = projects.get(project);
86
+ if (!s) {
87
+ const file = cacheFileFor(project);
88
+ s = { cache: readCache(file), file, running: null, pending: null, listeners: new Set() };
89
+ projects.set(project, s);
90
+ }
91
+ return s;
92
+ }
93
+
94
+ async function walk(project, beads) {
95
+ const s = state(project);
96
+ const ids = planWalk(beads, s.cache);
97
+ if (ids.length === 0) return { walked: 0, changed: false, ms: 0 };
98
+ const byId = new Map(beads.map((b) => [b.id, b]));
99
+ const started = now();
100
+ let changed = false;
101
+ let i = 0;
102
+ const worker = async () => {
103
+ while (i < ids.length) {
104
+ const id = ids[i++];
105
+ const b = byId.get(id);
106
+ try {
107
+ await beforeEach();
108
+ const entries = await runHistory(project, id);
109
+ const inReviewAt = firstInReviewAt(entries);
110
+ const assignedAt = firstAssignedAt(entries);
111
+ const prev = s.cache[id];
112
+ s.cache[id] = { updated_at: b?.updated_at, in_review_at: inReviewAt, assigned_at: assignedAt, walked_at: new Date(now()).toISOString() };
113
+ if (!prev || prev.in_review_at !== inReviewAt || prev.assigned_at !== assignedAt || prev.updated_at !== b?.updated_at) changed = true;
114
+ } catch (e) {
115
+ log(`history walk: ${id} failed: ${e?.message ?? e}`);
116
+ }
117
+ }
118
+ };
119
+ await Promise.all(Array.from({ length: Math.min(concurrency, ids.length) }, worker));
120
+ writeCache(s.file, s.cache);
121
+ const ms = now() - started;
122
+ log(`history walk: ${ids.length} bead${ids.length === 1 ? "" : "s"} in ${ms}ms (${concurrency} at a time)`);
123
+ if (changed) for (const fn of s.listeners) fn(project);
124
+ return { walked: ids.length, changed, ms };
125
+ }
126
+
127
+ return {
128
+ /** What the walk knows for this project right now. */
129
+ cache(project) {
130
+ return state(project).cache;
131
+ },
132
+
133
+ /**
134
+ * Start (or queue) a walk for these beads and return immediately. A walk
135
+ * already running finishes first; the newest bead list replaces any
136
+ * earlier pending one, so a burst of polls costs one extra walk at most.
137
+ */
138
+ refresh(project, beads) {
139
+ const s = state(project);
140
+ if (s.running) {
141
+ s.pending = beads;
142
+ return s.running;
143
+ }
144
+ const run = async () => {
145
+ let next = beads;
146
+ while (next) {
147
+ const current = next;
148
+ next = null;
149
+ s.pending = null;
150
+ await walk(project, current);
151
+ next = s.pending;
152
+ }
153
+ };
154
+ s.running = run().finally(() => {
155
+ s.running = null;
156
+ });
157
+ return s.running;
158
+ },
159
+
160
+ /** Called after a walk that changed something for the project. */
161
+ onChange(project, fn) {
162
+ state(project).listeners.add(fn);
163
+ },
164
+
165
+ /** For tests and diagnostics: await the in-flight walk, if any. */
166
+ idle(project) {
167
+ return state(project).running ?? Promise.resolve();
168
+ },
169
+ };
170
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,5 @@
1
+ // Library entry — expose the estimator and scheduler so other tools can compose
2
+ // them (dashboards, seat-load views, wins-over-time). The CLI is at
3
+ // `src/cli.mjs` and mounted at `bin/beadcyte.mjs`.
4
+ export { buildEstimator } from "./estimator.mjs";
5
+ export { scheduleGreedy } from "./scheduler.mjs";