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.
- package/CHANGELOG.md +73 -0
- package/LICENSE +661 -0
- package/README.md +386 -0
- package/THIRD_PARTY_NOTICES.md +56 -0
- package/bin/beadcyte.mjs +60 -0
- package/package.json +77 -0
- package/src/changelog-cli.mjs +105 -0
- package/src/changelog.mjs +196 -0
- package/src/cli.mjs +577 -0
- package/src/estimator.mjs +314 -0
- package/src/format.mjs +22 -0
- package/src/history-walk.mjs +170 -0
- package/src/index.mjs +5 -0
- package/src/mutate.mjs +193 -0
- package/src/projects.mjs +120 -0
- package/src/provenance.mjs +75 -0
- package/src/review-hours.mjs +117 -0
- package/src/roster-path.mjs +24 -0
- package/src/scheduler.mjs +424 -0
- package/src/serve.mjs +411 -0
- package/src/server-state.mjs +105 -0
- package/src/ship.mjs +178 -0
- package/src/stage-waits.mjs +69 -0
- package/src/start.mjs +111 -0
- package/src/stop.mjs +66 -0
- package/src/velocity.mjs +209 -0
- package/src/web/App.vue +691 -0
- package/src/web/app.css +54 -0
- package/src/web/assets/favicon.svg +12 -0
- package/src/web/avatar.ts +53 -0
- package/src/web/bead-detail.ts +240 -0
- package/src/web/changelog-view.ts +41 -0
- package/src/web/components/BeadDrawer.vue +1883 -0
- package/src/web/components/BeadSubGraph.vue +326 -0
- package/src/web/components/BeadSubGraphOverlay.vue +192 -0
- package/src/web/components/BeadTooltip.vue +516 -0
- package/src/web/components/BeadcyteMark.vue +64 -0
- package/src/web/components/BeadsGantt.vue +2125 -0
- package/src/web/components/BeadsGrid.vue +468 -0
- package/src/web/components/BeadsIncytes.vue +567 -0
- package/src/web/components/BeadsMine.vue +325 -0
- package/src/web/components/BeadsTable.vue +335 -0
- package/src/web/components/ChangelogOverlay.vue +198 -0
- package/src/web/components/ContextMenu.vue +386 -0
- package/src/web/components/ControlsPanel.vue +476 -0
- package/src/web/components/CostTrend.vue +206 -0
- package/src/web/components/FilterPopover.vue +245 -0
- package/src/web/components/GroupProgress.vue +274 -0
- package/src/web/components/LoadMeter.vue +144 -0
- package/src/web/components/MineRow.vue +28 -0
- package/src/web/components/OptionsMenu.vue +825 -0
- package/src/web/components/PriorityChip.vue +105 -0
- package/src/web/components/ScoreStrip.vue +131 -0
- package/src/web/components/SearchPalette.vue +210 -0
- package/src/web/components/ShipTrend.vue +510 -0
- package/src/web/components/ShortcutsOverlay.vue +164 -0
- package/src/web/components/Term.vue +177 -0
- package/src/web/components/Toast.vue +50 -0
- package/src/web/components/TriageMeters.vue +426 -0
- package/src/web/components/TypeChip.vue +96 -0
- package/src/web/components/Walkthrough.vue +209 -0
- package/src/web/components/WhatIfPanel.vue +206 -0
- package/src/web/components/WipBullets.vue +191 -0
- package/src/web/components/filter-option.ts +9 -0
- package/src/web/composables/url-codec.ts +136 -0
- package/src/web/composables/useBeadTooltip.ts +148 -0
- package/src/web/composables/useKeyboard.ts +97 -0
- package/src/web/composables/useLiveRefresh.ts +69 -0
- package/src/web/composables/useTheme.ts +125 -0
- package/src/web/composables/useUrlState.ts +208 -0
- package/src/web/controls-scope.ts +83 -0
- package/src/web/cost.ts +251 -0
- package/src/web/dep-headings.ts +62 -0
- package/src/web/economics.ts +440 -0
- package/src/web/env.d.ts +85 -0
- package/src/web/frontier.ts +208 -0
- package/src/web/gantt-viewport.ts +99 -0
- package/src/web/highlights.ts +124 -0
- package/src/web/index.html +46 -0
- package/src/web/insights.ts +107 -0
- package/src/web/keybindings.ts +200 -0
- package/src/web/load-meter.ts +72 -0
- package/src/web/main.ts +20 -0
- package/src/web/markdown.ts +14 -0
- package/src/web/mine.ts +137 -0
- package/src/web/mutations.ts +21 -0
- package/src/web/person.ts +102 -0
- package/src/web/projects-text.ts +15 -0
- package/src/web/projects.ts +188 -0
- package/src/web/refresh.ts +47 -0
- package/src/web/search.ts +50 -0
- package/src/web/shortcuts.ts +113 -0
- package/src/web/status-filter.ts +48 -0
- package/src/web/store.ts +1378 -0
- package/src/web/style-audit.mjs +346 -0
- package/src/web/styles-alt.css +111 -0
- package/src/web/styles-ported.css +270 -0
- package/src/web/subgraph.ts +362 -0
- package/src/web/table.ts +201 -0
- package/src/web/theme.ts +88 -0
- package/src/web/tokens.css +168 -0
- package/src/web/triage.ts +914 -0
- package/src/web/view-model.ts +717 -0
- package/src/web/walkthrough.ts +133 -0
- package/src/web/watchlist.ts +47 -0
- package/src/web/whatif.ts +291 -0
- package/src/web/window.ts +73 -0
- 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";
|
package/src/web/env.d.ts
ADDED
|
@@ -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
|
+
}
|