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,62 @@
1
+ // dep-headings.ts — tense for the drawer's dependency headings.
2
+ //
3
+ // A dependency edge is a live constraint or a historical one, and the
4
+ // heading should say which. Pure so the rule is testable without the drawer.
5
+
6
+ export interface DepHeading {
7
+ prefix: string;
8
+ /** The word whose tense carries the meaning; rendered emphasised. */
9
+ tense: string;
10
+ suffix: string;
11
+ /** Why this tense, for the reader who hovers the word (bp-67g.47). */
12
+ explain: string;
13
+ }
14
+
15
+ interface HasStatus {
16
+ status: string;
17
+ }
18
+
19
+ /**
20
+ * "This bead IS blocked by:" / "This bead WAS blocked by:".
21
+ *
22
+ * Past tense only when every blocker is closed. One open blocker means the
23
+ * bead really is still blocked, so a partially-cleared list must keep the
24
+ * present tense — otherwise the heading would say the work is unblocked
25
+ * while something above it still holds.
26
+ */
27
+ export function blockedByHeading(blockers: readonly HasStatus[]): DepHeading {
28
+ const allCleared =
29
+ blockers.length > 0 && blockers.every((b) => b.status === "closed");
30
+ return {
31
+ prefix: "This bead ",
32
+ tense: allCleared ? "was" : "is",
33
+ suffix: " blocked by:",
34
+ explain: allCleared
35
+ ? "Past tense: every bead that blocked this one has closed, so the constraint is history."
36
+ : "Present tense: at least one bead listed here is still open, so this bead really is blocked. A partly cleared list keeps the present tense.",
37
+ };
38
+ }
39
+
40
+ /**
41
+ * "This bead BLOCKS:" / "This bead BLOCKED:".
42
+ *
43
+ * Keyed off the bead being viewed rather than its dependents: what makes
44
+ * "blocks" moot is this bead closing. Whether the things it blocked have
45
+ * since closed too doesn't change that it no longer constrains them.
46
+ */
47
+ export function blocksHeading(viewedStatus: string): DepHeading {
48
+ return {
49
+ prefix: "This bead ",
50
+ tense: viewedStatus === "closed" ? "blocked" : "blocks",
51
+ suffix: ":",
52
+ explain:
53
+ viewedStatus === "closed"
54
+ ? "Past tense: this bead has closed, so it no longer constrains anything — whatever became of the beads it blocked."
55
+ : "Present tense: while this bead is open, each bead listed here waits on it.",
56
+ };
57
+ }
58
+
59
+ /** A single edge no longer binds once the bead at its far end is closed. */
60
+ export function edgeIsSpent(other: HasStatus): boolean {
61
+ return other.status === "closed";
62
+ }
@@ -0,0 +1,440 @@
1
+ // economics.ts — derives a bead's time-and-cost picture from
2
+ // metadata.economics.
3
+ //
4
+ // Pure so the arithmetic is testable without a drawer. The shape of this
5
+ // data in practice (measured against a 558-bead repo) drives the design:
6
+ //
7
+ // hours_unclaimed 105 beads, median 101h <- where the time actually goes
8
+ // hours_in_review 110 beads, median 5.5h
9
+ // by_seat 8 beads <- work hours, barely recorded
10
+ // totals 3 beads
11
+ //
12
+ // unclaimed + in_review accounts for a median 99% of created->closed, so the
13
+ // two waiting phases explain essentially a whole bead lifetime. Work hours
14
+ // are the exception rather than the rule, which is why absent work data has
15
+ // to read as "not recorded" instead of zero.
16
+ //
17
+ // ## Derived values
18
+ //
19
+ // `hours_unclaimed` is `started_at - created_at`. That is pure arithmetic on
20
+ // data the tracker already holds, so where the field is absent and both
21
+ // timestamps exist, it is computed here rather than left empty — the same
22
+ // subtraction a ship-record does at close time, applied late. docs/economics.md
23
+ // argues you should "compute the calendar; only log the work" when writing;
24
+ // this is that rule applied to reading, and it is why nothing has to be
25
+ // written back to anyone's beads DB to make the phase bar work.
26
+ //
27
+ // What that costs is a distinction the app did not previously have to make.
28
+ // Before this, an economics value meant "somebody measured this", and the
29
+ // drawer read it that way. A computed value that looks identical would
30
+ // silently convert reconstruction into apparent measurement, with no way to
31
+ // separate them again afterwards. So every phase carries its provenance, and
32
+ // the UI is expected to show it (bp-67g.54).
33
+ //
34
+ // `hours_in_review` is derived too (bp-67g.57), but not from the list
35
+ // payload: the first `in_review` transition lives only in `bd history`, which
36
+ // the server walks in the background and hands over as `bead.history`. The
37
+ // arithmetic — entry to close, or to now while open — is here, and the
38
+ // figure carries `derived:bd-history` so the drawer badges it like the queue
39
+ // wait. A bead the walk has not reached, or that never entered review, stays
40
+ // ABSENT: null, never zero.
41
+ //
42
+ // Worked hours are deliberately never derived. The estimator PREFERS
43
+ // `metadata.economics.totals.agent_hours` over wall-clock, so an invented
44
+ // figure would not add noise at the margin — it would take precedence over
45
+ // the one real signal and dominate every bucket median.
46
+
47
+ import { readProvenance, RECORDED } from "../provenance.mjs";
48
+ import type { Provenance } from "../provenance.mjs";
49
+
50
+ const MS_PER_HOUR = 3_600_000;
51
+
52
+ export interface EconomicsSession {
53
+ date: string | null;
54
+ hours: number;
55
+ human: string | null;
56
+ /** Tokens the session spent, when the record carries them per session (bp-0z5). Null: not recorded. */
57
+ tokensIn: number | null;
58
+ tokensOut: number | null;
59
+ }
60
+
61
+ export interface EconomicsSeat {
62
+ seat: string;
63
+ hours: number;
64
+ sessions: EconomicsSession[];
65
+ }
66
+
67
+ export interface EconomicsTotals {
68
+ agentHours: number | null;
69
+ tokensIn: number | null;
70
+ tokensOut: number | null;
71
+ }
72
+
73
+ /**
74
+ * Where a phase figure came from.
75
+ *
76
+ * "recorded" someone measured it and wrote it down
77
+ * "derived" beadcyte computed it here, now, from the bead's timestamps
78
+ * "reconstructed"
79
+ * the record itself declares it was not measured — see
80
+ * `metadata.economics.provenance` below
81
+ *
82
+ * "derived" and "reconstructed" both mean "not measured" and are still worth
83
+ * separating: a derived figure is arithmetic on data in front of you, and a
84
+ * reconstructed one is a number some other tool admits it inferred. Different
85
+ * things to trust differently.
86
+ *
87
+ * The absence of a provenance entry on a stored record is read as
88
+ * "recorded". That is an assumption, and it is the only one available: every
89
+ * economics record written before this existed was written at close time by a
90
+ * script that had measured it. A tool that reconstructs a value is expected
91
+ * to say so.
92
+ */
93
+ export type { Provenance } from "../provenance.mjs";
94
+ export type ProvenanceKind = "recorded" | "derived" | "reconstructed";
95
+
96
+ /**
97
+ * Provenance per phase, `null` where the phase itself is absent.
98
+ *
99
+ * A sibling map rather than wrapping each value in an object, so every
100
+ * existing reader of `unclaimedHours` keeps working and only code that cares
101
+ * about provenance has to look it up.
102
+ */
103
+ export interface EconomicsProvenance {
104
+ unclaimedHours: Provenance | null;
105
+ reviewHours: Provenance | null;
106
+ workedHours: Provenance | null;
107
+ }
108
+
109
+ // ── what each phase means (bp-67g.47) ─────────────────────────────────────
110
+ // Kept with the fields, because these sentences are only true of THIS
111
+ // arithmetic: change what unclaimed subtracts, and its sentence changes here.
112
+
113
+ export type EconomicsPhase = "unclaimed" | "review" | "worked" | "unaccounted";
114
+
115
+ export const PHASE_EXPLANATIONS: Record<EconomicsPhase, string> = {
116
+ unclaimed:
117
+ "From the bead being created until someone started it — the queue wait, as started_at − created_at. Recorded at close on a ship record when there is one; otherwise derived here from those two timestamps and marked as such.",
118
+ review:
119
+ "Time between the work being finished and the bead closing: from the ship record's hours_in_review when one exists, otherwise derived from the first in_review transition in bd history — walked in the background by the server, and marked as derived. Absent when the bead never entered review.",
120
+ worked:
121
+ "Hours of work logged against the bead, summed across every seat's sessions in its economics record. Never derived — an invented figure would outrank the one real signal the estimator prefers — so absent means not recorded, not zero.",
122
+ unaccounted:
123
+ "The rest of created→closed that no phase claims. A large remainder says time went unrecorded, not that nothing happened.",
124
+ };
125
+
126
+ export interface BeadEconomics {
127
+ /**
128
+ * False when the bead carries no *recorded* economics at all — most of
129
+ * them. Deliberately unchanged by derivation, so a caller asking "did
130
+ * anyone write this down?" still gets an honest answer.
131
+ */
132
+ hasAny: boolean;
133
+ /**
134
+ * True when at least one figure below was computed here rather than read.
135
+ * A UI wanting "is there anything worth drawing?" should test
136
+ * `hasAny || hasDerived`.
137
+ */
138
+ hasDerived: boolean;
139
+ /** created -> closed, or created -> now while still open. Null if undated. */
140
+ elapsedHours: number | null;
141
+ /** True when elapsed is still accumulating. */
142
+ elapsedOngoing: boolean;
143
+ unclaimedHours: number | null;
144
+ reviewHours: number | null;
145
+ /** Summed from by_seat. Null means NOT RECORDED, which is not zero. */
146
+ workedHours: number | null;
147
+ /**
148
+ * Elapsed not explained by the phases above. Negative would mean the parts
149
+ * exceed the whole, so it clamps at zero and `overAccounted` says so —
150
+ * better than a bar with a phase hanging off the end.
151
+ */
152
+ unaccountedHours: number | null;
153
+ overAccounted: boolean;
154
+ seats: EconomicsSeat[];
155
+ totals: EconomicsTotals;
156
+ /** Where each phase figure above came from. */
157
+ provenance: EconomicsProvenance;
158
+ }
159
+
160
+ /** Accepts only finite non-negative numbers; anything else is absent. */
161
+ function num(v: unknown): number | null {
162
+ return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
163
+ }
164
+
165
+ function parseDate(v: unknown): number | null {
166
+ if (typeof v !== "string" || !v) return null;
167
+ const t = new Date(v).getTime();
168
+ return Number.isFinite(t) ? t : null;
169
+ }
170
+
171
+ function parseSeats(raw: unknown): EconomicsSeat[] {
172
+ if (!raw || typeof raw !== "object") return [];
173
+ const out: EconomicsSeat[] = [];
174
+ for (const [seat, value] of Object.entries(raw as Record<string, unknown>)) {
175
+ const v = (value ?? {}) as Record<string, unknown>;
176
+ const sessionsRaw = Array.isArray(v.sessions) ? v.sessions : [];
177
+ const sessions: EconomicsSession[] = sessionsRaw.map((s) => {
178
+ const o = (s ?? {}) as Record<string, unknown>;
179
+ return {
180
+ date: typeof o.date === "string" ? o.date : null,
181
+ hours: num(o.hours) ?? 0,
182
+ human: typeof o.human === "string" ? o.human : null,
183
+ tokensIn: num(o.tokens_in),
184
+ tokensOut: num(o.tokens_out),
185
+ };
186
+ });
187
+ // Prefer the seat's own total; fall back to summing its sessions, since
188
+ // a seat can carry sessions without a rolled-up figure.
189
+ const hours =
190
+ num(v.hours) ?? sessions.reduce((a, s) => a + s.hours, 0);
191
+ out.push({ seat, hours, sessions });
192
+ }
193
+ return out.sort((a, b) => b.hours - a.hours || a.seat.localeCompare(b.seat));
194
+ }
195
+
196
+ interface BeadLike {
197
+ status?: string;
198
+ created_at?: string | null;
199
+ started_at?: string | null;
200
+ closed_at?: string | null;
201
+ metadata?: { economics?: unknown } | null;
202
+ /** What the server's history walk knows (bp-67g.57, bp-7c9); absent until it has run. */
203
+ history?: { in_review_at?: string | null; assigned_at?: string | null } | null;
204
+ }
205
+
206
+ // ── stage boundaries (bp-7c9) ─────────────────────────────────────────────
207
+ // The owner's five: filed, assigned, first MR, first QA, merged — plus
208
+ // started, which bd records and which sits between assigned and the MR.
209
+ // Each comes from one place and says where:
210
+ //
211
+ // filed created_at recorded by bd
212
+ // assigned history.assigned_at derived from bd history (the walk)
213
+ // started started_at recorded by bd (the claim)
214
+ // first MR metadata.ship.mr_opened_at written by the close script; provenance under metadata.ship.provenance
215
+ // first QA metadata.economics.qa_started_at written by the close script; provenance under metadata.economics.provenance
216
+ // merged closed_at recorded by bd
217
+ //
218
+ // The tracker never sees an MR open or QA begin, so those two are absent
219
+ // until something writes them — and absent is shown as absent.
220
+
221
+ export type StageId = "filed" | "assigned" | "started" | "mr" | "qa" | "merged";
222
+
223
+ export const STAGE_ORDER: readonly StageId[] = ["filed", "assigned", "started", "mr", "qa", "merged"];
224
+
225
+ export const STAGE_LABELS: Record<StageId, string> = {
226
+ filed: "filed",
227
+ assigned: "assigned",
228
+ started: "started",
229
+ mr: "first MR",
230
+ qa: "first QA",
231
+ merged: "merged",
232
+ };
233
+
234
+ export const STAGE_EXPLANATIONS: Record<StageId, string> = {
235
+ filed: "When the bead was created — bd's created_at.",
236
+ assigned: "When it first had an assignee, from the first bd history snapshot carrying one; derived by the server's background walk, so it is marked derived and may lag a fresh assignment until the walk reaches the bead.",
237
+ started: "When work began — bd's started_at, set by a claim.",
238
+ mr: "When its first merge request opened — metadata.ship.mr_opened_at, written by the close script; the tracker never sees an MR, so absent means nobody recorded it.",
239
+ qa: "When QA first picked it up — metadata.economics.qa_started_at, written by the close script; absent means nobody recorded it.",
240
+ merged: "When the bead closed — bd's closed_at; a merged MR closes the bead.",
241
+ };
242
+
243
+ export interface StageBoundary {
244
+ id: StageId;
245
+ at: string | null;
246
+ provenance: Provenance | null;
247
+ }
248
+
249
+ const DERIVED_HISTORY_ASSIGNED: Provenance = {
250
+ kind: "derived",
251
+ method: "derived:bd-history",
252
+ note: "first snapshot with an assignee",
253
+ at: null,
254
+ };
255
+
256
+ /** The six boundaries in order, each dated or absent, each with its provenance. */
257
+ export function beadStages(bead: BeadLike): StageBoundary[] {
258
+ const raw = bead.metadata?.economics;
259
+ const e = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
260
+ const shipRaw = (bead.metadata as { ship?: unknown } | null | undefined)?.ship;
261
+ const ship = (shipRaw && typeof shipRaw === "object" ? shipRaw : {}) as Record<string, unknown>;
262
+ const at = (v: unknown): string | null => (typeof v === "string" && v ? v : null);
263
+ const mr = at(ship.mr_opened_at);
264
+ const qa = at(e.qa_started_at);
265
+ return [
266
+ { id: "filed", at: at(bead.created_at), provenance: bead.created_at ? RECORDED : null },
267
+ { id: "assigned", at: at(bead.history?.assigned_at), provenance: bead.history?.assigned_at ? DERIVED_HISTORY_ASSIGNED : null },
268
+ { id: "started", at: at(bead.started_at), provenance: bead.started_at ? RECORDED : null },
269
+ { id: "mr", at: mr, provenance: mr ? (readProvenance(ship.provenance, "mr_opened_at") ?? RECORDED) : null },
270
+ { id: "qa", at: qa, provenance: qa ? (readProvenance(e.provenance, "qa_started_at") ?? RECORDED) : null },
271
+ { id: "merged", at: at(bead.closed_at), provenance: bead.closed_at ? RECORDED : null },
272
+ ];
273
+ }
274
+
275
+ export interface StageSpan {
276
+ from: StageId;
277
+ to: StageId;
278
+ hours: number;
279
+ /** Not measured if either end was derived or reconstructed. */
280
+ derived: boolean;
281
+ }
282
+
283
+ /** Hours between each pair of consecutive KNOWN boundaries, in order. */
284
+ export function stageSpans(stages: readonly StageBoundary[]): StageSpan[] {
285
+ const known = stages.filter((s) => s.at !== null);
286
+ const out: StageSpan[] = [];
287
+ for (let i = 1; i < known.length; i++) {
288
+ const a = known[i - 1]!;
289
+ const b = known[i]!;
290
+ const ta = parseDate(a.at);
291
+ const tb = parseDate(b.at);
292
+ if (ta === null || tb === null) continue;
293
+ out.push({
294
+ from: a.id,
295
+ to: b.id,
296
+ hours: Math.max(0, (tb - ta) / MS_PER_HOUR),
297
+ derived: (a.provenance?.kind ?? "recorded") !== "recorded" || (b.provenance?.kind ?? "recorded") !== "recorded",
298
+ });
299
+ }
300
+ return out;
301
+ }
302
+
303
+ /**
304
+ * Read `metadata.economics.provenance.<field>` for one field.
305
+ *
306
+ * Accepts either a bare string (`"derived:timestamps"`) or an object with a
307
+ * `method`, because both are what people actually write. Any entry at all
308
+ * means the value was not measured; the specific method is not interpreted
309
+ * here, only its presence.
310
+ */
311
+ const DERIVED_TIMESTAMPS: Provenance = {
312
+ kind: "derived",
313
+ method: "derived:timestamps",
314
+ note: "started_at − created_at",
315
+ at: null,
316
+ };
317
+
318
+ const DERIVED_HISTORY: Provenance = {
319
+ kind: "derived",
320
+ method: "derived:bd-history",
321
+ note: "first in_review transition in bd history → close",
322
+ at: null,
323
+ };
324
+
325
+ export function buildEconomics(
326
+ bead: BeadLike,
327
+ now: number = Date.now(),
328
+ ): BeadEconomics {
329
+ const raw = bead.metadata?.economics;
330
+ const e = (raw && typeof raw === "object" ? raw : {}) as Record<
331
+ string,
332
+ unknown
333
+ >;
334
+
335
+ const recordedUnclaimed = num(e.hours_unclaimed);
336
+ const recordedReview = num(e.hours_in_review);
337
+ const seats = parseSeats(e.by_seat);
338
+ const totalsRaw = (e.totals ?? {}) as Record<string, unknown>;
339
+ const totals: EconomicsTotals = {
340
+ agentHours: num(totalsRaw.agent_hours),
341
+ tokensIn: num(totalsRaw.tokens_in),
342
+ tokensOut: num(totalsRaw.tokens_out),
343
+ };
344
+
345
+ // Work hours: seats first, else the rolled-up agent_hours. Null when
346
+ // neither exists — nobody logged it, which is different from zero.
347
+ const seatHours = seats.reduce((a, s) => a + s.hours, 0);
348
+ const workedHours =
349
+ seats.length > 0 ? seatHours : totals.agentHours;
350
+
351
+ const created = parseDate(bead.created_at);
352
+ const started = parseDate(bead.started_at);
353
+ const closed = parseDate(bead.closed_at);
354
+ const end = closed ?? (created !== null ? now : null);
355
+ const elapsedHours =
356
+ created !== null && end !== null
357
+ ? Math.max(0, (end - created) / MS_PER_HOUR)
358
+ : null;
359
+
360
+ // Derive the queue wait when nobody recorded it. Requires `started_at`, so
361
+ // the figure always describes a wait that ENDED — a bead still sitting
362
+ // unclaimed gets nothing here, because created->now is already what
363
+ // `elapsedHours` reports for it and a second identical number dressed as a
364
+ // phase would be one fact pretending to be two.
365
+ //
366
+ // Clamped at zero: `started_at` before `created_at` should be impossible
367
+ // and is not worth a negative segment if a tracker ever produces one.
368
+ const derivedUnclaimed =
369
+ recordedUnclaimed === null && created !== null && started !== null
370
+ ? Math.max(0, (started - created) / MS_PER_HOUR)
371
+ : null;
372
+ const unclaimedHours = recordedUnclaimed ?? derivedUnclaimed;
373
+
374
+ // Review: recorded wins; else the walk's first in_review transition, to
375
+ // the close or to now. Nothing known → null, which the UI keeps absent.
376
+ const inReviewAt = parseDate(bead.history?.in_review_at ?? null);
377
+ const derivedReview =
378
+ recordedReview === null && inReviewAt !== null && end !== null
379
+ ? Math.max(0, (end - inReviewAt) / MS_PER_HOUR)
380
+ : null;
381
+ const reviewHours = recordedReview ?? derivedReview;
382
+
383
+ const provenance: EconomicsProvenance = {
384
+ unclaimedHours:
385
+ recordedUnclaimed !== null
386
+ ? (readProvenance(e.provenance, "hours_unclaimed") ?? RECORDED)
387
+ : derivedUnclaimed !== null
388
+ ? DERIVED_TIMESTAMPS
389
+ : null,
390
+ reviewHours:
391
+ recordedReview !== null
392
+ ? (readProvenance(e.provenance, "hours_in_review") ?? RECORDED)
393
+ : derivedReview !== null
394
+ ? DERIVED_HISTORY
395
+ : null,
396
+ workedHours: null, // set below, once workedHours is known
397
+ };
398
+
399
+ const accounted =
400
+ (unclaimedHours ?? 0) + (reviewHours ?? 0) + (workedHours ?? 0);
401
+ const hasPhase =
402
+ unclaimedHours !== null || reviewHours !== null || workedHours !== null;
403
+ const remainder =
404
+ elapsedHours !== null && hasPhase ? elapsedHours - accounted : null;
405
+
406
+ // Worked hours are never derived (see the header), so their provenance is
407
+ // only ever what the record says.
408
+ provenance.workedHours =
409
+ workedHours !== null
410
+ ? (readProvenance(e.provenance, seats.length > 0 ? "by_seat" : "totals") ??
411
+ RECORDED)
412
+ : null;
413
+
414
+ return {
415
+ // Recorded, not derived: a caller asking "did anyone write this down?"
416
+ // must not be answered by arithmetic beadcyte did itself.
417
+ hasAny:
418
+ recordedUnclaimed !== null ||
419
+ recordedReview !== null ||
420
+ seats.length > 0 ||
421
+ totals.agentHours !== null ||
422
+ totals.tokensIn !== null ||
423
+ totals.tokensOut !== null,
424
+ hasDerived: derivedUnclaimed !== null || derivedReview !== null,
425
+ elapsedHours,
426
+ elapsedOngoing: closed === null && created !== null,
427
+ unclaimedHours,
428
+ reviewHours,
429
+ workedHours,
430
+ unaccountedHours: remainder === null ? null : Math.max(0, remainder),
431
+ overAccounted: remainder !== null && remainder < -0.01,
432
+ seats,
433
+ totals,
434
+ provenance,
435
+ };
436
+ }
437
+
438
+ // Re-exported so the web's many callers keep their import site, while the
439
+ // definition lives in one place the SVG CLI can reach too.
440
+ export { formatHours } from "../format.mjs";
@@ -0,0 +1,85 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ declare module "*.vue" {
4
+ import type { DefineComponent } from "vue";
5
+ const c: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
6
+ export default c;
7
+ }
8
+
9
+ // The estimator + scheduler ship as untyped .mjs (they're shared with the CLI
10
+ // SVG mode). Declare their public surface so store.ts can import them without
11
+ // downgrading tsconfig to allowJs=loose.
12
+ declare module "*/estimator.mjs" {
13
+ export interface EstimatorStats {
14
+ shipped_sample_size: number;
15
+ global_median_days: number;
16
+ global_p90_days: number;
17
+ used_type_pri: number;
18
+ used_type: number;
19
+ used_global: number;
20
+ }
21
+ export interface EstimateResult {
22
+ days: number;
23
+ p90: number | null;
24
+ source: string;
25
+ }
26
+ export function buildEstimator(
27
+ allBeads: unknown[],
28
+ opts?: { lookback?: number },
29
+ ): {
30
+ estimate: (bead: unknown) => EstimateResult;
31
+ stats: EstimatorStats;
32
+ };
33
+ }
34
+
35
+ declare module "*/scheduler.mjs" {
36
+ export function scheduleGreedy(
37
+ beads: unknown[],
38
+ roster: unknown,
39
+ estimate: (bead: unknown) => { days: number; p90: number | null; source: string },
40
+ horizon: Date,
41
+ opts?: {
42
+ waits?: { unclaimedDays?: number | null; reviewDays?: number | null };
43
+ minLeadDays?: number;
44
+ capacityFor?: (human: unknown) => number;
45
+ now?: Date;
46
+ },
47
+ ): {
48
+ schedule: Map<string, unknown>;
49
+ unscheduled: string[];
50
+ unassignedBacklog: unknown[];
51
+ unassignedProjection: Map<string, unknown>;
52
+ waits: { unclaimedDays: number; reviewDays: number | null; minLeadDays: number };
53
+ };
54
+ export function dailyCapacityFor(human: unknown): number;
55
+ }
56
+
57
+ // The wait clock's inputs (bp-m0g).
58
+ declare module "*/stage-waits.mjs" {
59
+ export function stageWaits(beads: readonly unknown[]): {
60
+ unclaimedDays: number | null;
61
+ unclaimedN: number;
62
+ assignedDays: number | null;
63
+ assignedN: number;
64
+ reviewDays: number | null;
65
+ reviewN: number;
66
+ };
67
+ }
68
+
69
+ // The write whitelist shared with the server (bp-ocs).
70
+ declare module "*/mutate.mjs" {
71
+ export const MUTATION_ACTIONS: readonly ["claim", "assign", "priority", "spec-ready", "unspec-ready", "add-label", "defer", "create"];
72
+ export const DEFER_CHOICES: ReadonlyArray<{ until: string; label: string }>;
73
+ export const READY_LABEL: string;
74
+ export function validateMutation(raw: unknown): { action: string; id: string; label?: string; until?: string; assignee?: string; priority?: number; title?: string; type?: string; labels?: string[] };
75
+ export function bdArgsFor(m: { action: string; id: string; label?: string; until?: string; assignee?: string; priority?: number; title?: string; type?: string; labels?: string[] }): string[];
76
+ export function describeMutation(m: { action: string; id: string; label?: string; until?: string; assignee?: string; priority?: number; title?: string; type?: string; labels?: string[] }): string;
77
+ export function createdIdFrom(stdout: string): string | null;
78
+ export function describeOutcome(m: { action: string; id: string; label?: string; until?: string; assignee?: string; priority?: number; title?: string; type?: string; labels?: string[] }): string;
79
+ export function applyOptimistic<B extends { id: string; status: string }>(
80
+ bead: B,
81
+ m: { action: string; id: string; label?: string; until?: string; assignee?: string; priority?: number; title?: string; type?: string; labels?: string[] },
82
+ actor: string | null,
83
+ now?: Date,
84
+ ): B;
85
+ }