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,69 @@
1
+ // stage-waits.mjs — how long beads on this project WAIT, per stage, from the
2
+ // beads themselves (bp-m0g; bp-7c9 adds the stages the tracker does not yet
3
+ // hold). The scheduler's second clock reads these medians: work is what the
4
+ // estimator predicts, waiting is what history says surrounds it.
5
+ //
6
+ // unclaimed created_at → started_at on closed beads: the queue wait.
7
+ // assigned created_at → first assignee (bead.history.assigned_at, from
8
+ // the walk, bp-7c9): how long a filed bead sits before anyone owns it.
9
+ // review recorded metadata.economics.hours_in_review first; else first
10
+ // MR opened (metadata.ship.mr_opened_at, bp-7c9) → closed; else
11
+ // first in_review (bead.history.in_review_at) → closed. Never invented.
12
+ //
13
+ // Medians, on samples inside (0, 180) days like the estimator's own lead
14
+ // samples, and null when there are none — a null wait is "unknown", which
15
+ // the scheduler covers with the lead-time floor, not "zero".
16
+
17
+ const MS_PER_DAY = 86_400_000;
18
+
19
+ function median(xs) {
20
+ if (xs.length === 0) return null;
21
+ const s = [...xs].sort((a, b) => a - b);
22
+ const mid = Math.floor(s.length / 2);
23
+ return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
24
+ }
25
+
26
+ const days = (from, to) => (Date.parse(to) - Date.parse(from)) / MS_PER_DAY;
27
+ const sane = (d) => Number.isFinite(d) && d > 0 && d < 180;
28
+
29
+ /**
30
+ * @param {ReadonlyArray<{status?: string, created_at?: string|null, started_at?: string|null, closed_at?: string|null,
31
+ * history?: {in_review_at?: string|null} | null, metadata?: {economics?: {hours_in_review?: unknown}} | null}>} beads
32
+ * @returns {{ unclaimedDays: number | null, unclaimedN: number, assignedDays: number | null, assignedN: number, reviewDays: number | null, reviewN: number }}
33
+ */
34
+ export function stageWaits(beads) {
35
+ const unclaimed = [];
36
+ const assigned = [];
37
+ const review = [];
38
+ for (const b of beads ?? []) {
39
+ if (b?.status !== "closed" || !b.closed_at) continue;
40
+ if (b.created_at && b.started_at) {
41
+ const d = days(b.created_at, b.started_at);
42
+ if (sane(d)) unclaimed.push(d);
43
+ }
44
+ if (b.created_at && b.history?.assigned_at) {
45
+ const d = days(b.created_at, b.history.assigned_at);
46
+ if (sane(d)) assigned.push(d);
47
+ }
48
+ const recorded = b.metadata?.economics?.hours_in_review;
49
+ const mrOpened = b.metadata?.ship?.mr_opened_at;
50
+ if (typeof recorded === "number" && Number.isFinite(recorded) && recorded > 0) {
51
+ const d = recorded / 24;
52
+ if (sane(d)) review.push(d);
53
+ } else if (typeof mrOpened === "string" && mrOpened) {
54
+ const d = days(mrOpened, b.closed_at);
55
+ if (sane(d)) review.push(d);
56
+ } else if (b.history?.in_review_at) {
57
+ const d = days(b.history.in_review_at, b.closed_at);
58
+ if (sane(d)) review.push(d);
59
+ }
60
+ }
61
+ return {
62
+ unclaimedDays: median(unclaimed),
63
+ unclaimedN: unclaimed.length,
64
+ assignedDays: median(assigned),
65
+ assignedN: assigned.length,
66
+ reviewDays: median(review),
67
+ reviewN: review.length,
68
+ };
69
+ }
package/src/start.mjs ADDED
@@ -0,0 +1,111 @@
1
+ // start.mjs — `beadcyte start`: run the server in the background and return.
2
+ //
3
+ // Spawns `beadcyte serve` detached, with its output going to a log file, waits
4
+ // for the child to record where it is listening (see server-state.mjs), prints
5
+ // the child's startup output and how to stop it, and exits. The child writes
6
+ // the record rather than this process guessing, because only the child knows
7
+ // the port once serve has fallen through from a busy 4173.
8
+ //
9
+ // Runs on import like the other subcommands; bin/beadcyte.mjs dispatches here.
10
+
11
+ import { spawn } from "node:child_process";
12
+ import { closeSync, mkdirSync, openSync, readFileSync } from "node:fs";
13
+ import { dirname, join } from "node:path";
14
+ import { setTimeout as sleep } from "node:timers/promises";
15
+ import { fileURLToPath } from "node:url";
16
+ import {
17
+ clearState,
18
+ describeRunning,
19
+ isAlive,
20
+ logFileFor,
21
+ readState,
22
+ stateFileFor,
23
+ } from "./server-state.mjs";
24
+
25
+ const argv = process.argv.slice(2);
26
+ if (argv.includes("--help") || argv.includes("-h")) {
27
+ printHelp();
28
+ process.exit(0);
29
+ }
30
+
31
+ const cwd = process.cwd();
32
+ const stateFile = stateFileFor(cwd);
33
+ const logFile = logFileFor(cwd);
34
+
35
+ // One server per directory. A record whose process has gone is just a
36
+ // leftover from a crash or a reboot, and is replaced.
37
+ const existing = readState(stateFile);
38
+ if (existing && isAlive(existing.pid)) {
39
+ console.log(
40
+ describeRunning(existing).replace(/^beadcyte is running/, "beadcyte is already running"),
41
+ );
42
+ process.exit(0);
43
+ }
44
+ if (existing) clearState(stateFile);
45
+
46
+ mkdirSync(dirname(logFile), { recursive: true });
47
+ const logFd = openSync(logFile, "w");
48
+ const dispatcher = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "beadcyte.mjs");
49
+ const child = spawn(process.execPath, [dispatcher, "serve", ...argv], {
50
+ cwd,
51
+ detached: true,
52
+ stdio: ["ignore", logFd, logFd],
53
+ env: { ...process.env, BEADCYTE_STATE_FILE: stateFile, BEADCYTE_LOG_FILE: logFile },
54
+ });
55
+ closeSync(logFd); // the child holds its own copy
56
+ let exitCode = null;
57
+ child.on("exit", (code, signal) => {
58
+ exitCode = code ?? (signal ? 1 : 0);
59
+ });
60
+ child.unref();
61
+
62
+ const TIMEOUT_MS = 30_000;
63
+ const deadline = Date.now() + TIMEOUT_MS;
64
+ while (Date.now() < deadline) {
65
+ const state = readState(stateFile);
66
+ if (state && state.pid === child.pid) {
67
+ process.stdout.write(logSoFar());
68
+ console.log(describeRunning(state));
69
+ process.exit(0);
70
+ }
71
+ if (exitCode !== null) {
72
+ process.stderr.write(logSoFar());
73
+ console.error(
74
+ `beadcyte serve exited with code ${exitCode} before it was listening. Log: ${logFile}`,
75
+ );
76
+ process.exit(1);
77
+ }
78
+ await sleep(100);
79
+ }
80
+ process.stderr.write(logSoFar());
81
+ console.error(
82
+ `beadcyte serve (pid ${child.pid}) has not reported listening after ${TIMEOUT_MS / 1000}s. ` +
83
+ `It may still be starting: check ${logFile}, or run \`beadcyte stop\` to end it.`,
84
+ );
85
+ process.exit(1);
86
+
87
+ function logSoFar() {
88
+ try {
89
+ return readFileSync(logFile, "utf8");
90
+ } catch {
91
+ return "";
92
+ }
93
+ }
94
+
95
+ function printHelp() {
96
+ console.log(`beadcyte start — run the interactive app in the background
97
+
98
+ Usage:
99
+ beadcyte start [--port N] [--roster PATH] [--cache-ttl MS]
100
+
101
+ Starts \`beadcyte serve\` for the CURRENT WORKING DIRECTORY as a detached
102
+ process, prints its address once it is listening, and returns. Output goes to
103
+ a log file whose path is printed. One server per directory: a second start
104
+ reports the running one.
105
+
106
+ beadcyte stop ends it, from the same directory
107
+ beadcyte serve runs the same server in the foreground instead
108
+
109
+ Flags are passed through to serve; see \`beadcyte serve --help\`.
110
+ `);
111
+ }
package/src/stop.mjs ADDED
@@ -0,0 +1,66 @@
1
+ // stop.mjs — `beadcyte stop`: end the background server for this directory.
2
+ //
3
+ // Finds the record `beadcyte start` left for the current working directory
4
+ // (see server-state.mjs), sends SIGTERM, waits briefly, and clears the record.
5
+ // Not running is a normal outcome, not an error: the user wanted it stopped
6
+ // and it is.
7
+ //
8
+ // Runs on import like the other subcommands; bin/beadcyte.mjs dispatches here.
9
+
10
+ import { setTimeout as sleep } from "node:timers/promises";
11
+ import { clearState, isAlive, readState, stateFileFor } from "./server-state.mjs";
12
+
13
+ const argv = process.argv.slice(2);
14
+ if (argv.includes("--help") || argv.includes("-h")) {
15
+ printHelp();
16
+ process.exit(0);
17
+ }
18
+
19
+ const cwd = process.cwd();
20
+ const stateFile = stateFileFor(cwd);
21
+ const state = readState(stateFile);
22
+
23
+ if (!state) {
24
+ console.log(`beadcyte is not running for ${cwd}.`);
25
+ process.exit(0);
26
+ }
27
+ if (!isAlive(state.pid)) {
28
+ clearState(stateFile);
29
+ console.log(`beadcyte (pid ${state.pid}) was already gone; cleared its record.`);
30
+ process.exit(0);
31
+ }
32
+
33
+ try {
34
+ process.kill(state.pid, "SIGTERM");
35
+ } catch (e) {
36
+ console.error(`beadcyte: could not signal pid ${state.pid}: ${e.message}`);
37
+ process.exit(1);
38
+ }
39
+
40
+ const GRACE_MS = 5_000;
41
+ const deadline = Date.now() + GRACE_MS;
42
+ while (isAlive(state.pid) && Date.now() < deadline) await sleep(100);
43
+ if (isAlive(state.pid)) {
44
+ try {
45
+ process.kill(state.pid, "SIGKILL");
46
+ } catch {}
47
+ await sleep(200);
48
+ }
49
+ clearState(stateFile);
50
+
51
+ if (isAlive(state.pid)) {
52
+ console.error(`beadcyte (pid ${state.pid}) did not exit; cleared its record anyway.`);
53
+ process.exit(1);
54
+ }
55
+ console.log(`stopped beadcyte (pid ${state.pid}) at ${state.url}`);
56
+
57
+ function printHelp() {
58
+ console.log(`beadcyte stop — end the background server started in this directory
59
+
60
+ Usage:
61
+ beadcyte stop
62
+
63
+ Sends SIGTERM to the server \`beadcyte start\` launched for the CURRENT
64
+ WORKING DIRECTORY and clears its record. Exits 0 when nothing was running.
65
+ `);
66
+ }
@@ -0,0 +1,209 @@
1
+ // velocity.mjs — observed per-human close-rate, used to calibrate the
2
+ // scheduler's WIP cap against reality.
3
+ //
4
+ // The scheduler's raw WIP-cap model (roster.humans[i].cap, default 5) sets
5
+ // a hard ceiling on concurrent work per person. In practice teams don't
6
+ // run at full WIP forever — throughput is bounded by whoever's actually
7
+ // shipping. If alice ships one bead a week and bob three, projecting
8
+ // alice at cap 5 makes the whole plan fiction.
9
+ //
10
+ // This module walks the shipped history (ship evidence + closed_at) and
11
+ // reports a per-human close-rate over a lookback window. Consumers
12
+ // (scheduler, CLI, UI footer) fold that into an effective cap:
13
+ // calibratedCap = min(wipCap, round(closesPerWeek))
14
+ //
15
+ // Humans with zero closes in the lookback keep their raw cap — new hires
16
+ // or brand-new projects shouldn't get zeroed out.
17
+
18
+ import { isShipped } from "./ship.mjs";
19
+
20
+ const MS_PER_DAY = 86_400_000;
21
+ const MS_PER_WEEK = 7 * MS_PER_DAY;
22
+ const DEFAULT_LOOKBACK_DAYS = 60;
23
+
24
+ /**
25
+ * @param {Array} beads — all beads (bd list --all shape)
26
+ * @param {Object} [opts]
27
+ * @param {number} [opts.lookbackDays=60] — window for close-rate
28
+ * @param {Date} [opts.asOf=new Date()] — reference "now" (test hook)
29
+ * @returns {{
30
+ * asOf: Date,
31
+ * lookbackDays: number,
32
+ * perHuman: Map<string, {
33
+ * handle: string,
34
+ * closedInWindow: number,
35
+ * closesPerWeek: number,
36
+ * lastClosedAt: string | null,
37
+ * hoursInWindow: number,
38
+ * hoursPerWeek: number,
39
+ * weekly: number[], // ships per week, same buckets as the project series (bp-67g.43)
40
+ * }>,
41
+ * totalClosedInWindow: number,
42
+ * totalHoursInWindow: number,
43
+ * activeCount: number,
44
+ * weekly: Array<{ weekStart: Date, count: number }>,
45
+ * weeklyTotal: number,
46
+ * weeklyWeeks: number
47
+ * }}
48
+ */
49
+ export function computeVelocity(beads, opts = {}) {
50
+ const lookbackDays = opts.lookbackDays ?? DEFAULT_LOOKBACK_DAYS;
51
+ const asOf = opts.asOf ?? new Date();
52
+ const cutoff = asOf.getTime() - lookbackDays * MS_PER_DAY;
53
+
54
+ const perHuman = new Map();
55
+ let totalClosedInWindow = 0;
56
+ let totalHoursInWindow = 0;
57
+
58
+ // The weekly series, accumulated in the same pass as the close counts so
59
+ // the trend and the rate can never disagree about what a ship is.
60
+ //
61
+ // Buckets are WHOLE weeks ending at asOf, counted backwards — not forwards
62
+ // from the close-rate cutoff. Forwards leaves a partial final bucket
63
+ // whenever lookbackDays isn't a multiple of 7 (60 days is 8 weeks and 4
64
+ // days), and a partial bucket makes the most recent point dip for
65
+ // arithmetic reasons rather than real ones, which is exactly the reading a
66
+ // trend chart is for. The cost is dropping the oldest remainder days, so
67
+ // this window is `weeklyWeeks * 7` days, which is reported rather than
68
+ // assumed to equal lookbackDays.
69
+ const weeklyWeeks = Math.max(1, Math.floor(lookbackDays / 7));
70
+ const weeklyStart = asOf.getTime() - weeklyWeeks * MS_PER_WEEK;
71
+ const weeklyCounts = new Array(weeklyWeeks).fill(0);
72
+
73
+ const getEntry = (handle) => {
74
+ let entry = perHuman.get(handle);
75
+ if (!entry) {
76
+ entry = {
77
+ handle,
78
+ closedInWindow: 0,
79
+ closesPerWeek: 0,
80
+ lastClosedAt: null,
81
+ hoursInWindow: 0,
82
+ hoursPerWeek: 0,
83
+ // The person's own weekly series, in the project's buckets, so a
84
+ // person's trend and the project's are the same chart on the same
85
+ // definition of a ship (bp-67g.43).
86
+ weekly: new Array(weeklyWeeks).fill(0),
87
+ };
88
+ perHuman.set(handle, entry);
89
+ }
90
+ return entry;
91
+ };
92
+
93
+ // Pass 1: bead-close events (existing signal).
94
+ for (const b of beads) {
95
+ if (b.status !== "closed") continue;
96
+ if (!isShipped(b)) continue; // real ships only — see ship.mjs
97
+ if (!b.closed_at) continue;
98
+ const closedAt = new Date(b.closed_at).getTime();
99
+ if (Number.isNaN(closedAt)) continue;
100
+ // The weekly window is shorter than the close-rate window, so a ship can
101
+ // count toward one and not the other. Bucket before the cutoff test.
102
+ const handle = b.assignee ?? "__unassigned";
103
+ if (closedAt >= weeklyStart && closedAt < asOf.getTime()) {
104
+ const bucket = Math.floor((closedAt - weeklyStart) / MS_PER_WEEK);
105
+ // Guard the boundary: a close landing exactly on asOf would index off
106
+ // the end, and clamping is cheaper than trusting float division.
107
+ const i = Math.min(weeklyWeeks - 1, bucket);
108
+ weeklyCounts[i]++;
109
+ getEntry(handle).weekly[i]++;
110
+ }
111
+ if (closedAt < cutoff) continue;
112
+ const entry = getEntry(handle);
113
+ entry.closedInWindow++;
114
+ if (!entry.lastClosedAt || b.closed_at > entry.lastClosedAt) {
115
+ entry.lastClosedAt = b.closed_at;
116
+ }
117
+ totalClosedInWindow++;
118
+ }
119
+
120
+ // Pass 2: agent-hour sessions (richer signal). metadata.economics.by_seat
121
+ // records per-human session dates + hours; we sum every session whose
122
+ // date falls inside the window. Unlike close-rate this attributes work
123
+ // to WHOEVER actually put in the hours, not just the closing assignee.
124
+ for (const b of beads) {
125
+ const bySeat = b.metadata?.economics?.by_seat;
126
+ if (!bySeat || typeof bySeat !== "object") continue;
127
+ for (const seatData of Object.values(bySeat)) {
128
+ const sessions = seatData?.sessions;
129
+ if (!Array.isArray(sessions)) continue;
130
+ for (const s of sessions) {
131
+ if (!s?.date || typeof s.hours !== "number") continue;
132
+ const sessionAt = new Date(s.date).getTime();
133
+ if (isNaN(sessionAt) || sessionAt < cutoff) continue;
134
+ const handle = s.human ?? "__unassigned";
135
+ const entry = getEntry(handle);
136
+ entry.hoursInWindow += s.hours;
137
+ totalHoursInWindow += s.hours;
138
+ }
139
+ }
140
+ }
141
+
142
+ for (const entry of perHuman.values()) {
143
+ entry.closesPerWeek = entry.closedInWindow / (lookbackDays / 7);
144
+ entry.hoursPerWeek = entry.hoursInWindow / (lookbackDays / 7);
145
+ }
146
+
147
+ return {
148
+ asOf,
149
+ lookbackDays,
150
+ perHuman,
151
+ totalClosedInWindow,
152
+ totalHoursInWindow,
153
+ activeCount: perHuman.size,
154
+ weekly: weeklyCounts.map((count, i) => ({
155
+ weekStart: new Date(weeklyStart + i * MS_PER_WEEK),
156
+ count,
157
+ })),
158
+ weeklyTotal: weeklyCounts.reduce((a, n) => a + n, 0),
159
+ weeklyWeeks,
160
+ };
161
+ }
162
+
163
+ /**
164
+ * Fold observed velocity into a roster's per-human caps. Returns a new
165
+ * roster object; the original is untouched.
166
+ *
167
+ * Applies Little's Law — average WIP = throughput × cycle time — so a
168
+ * human's cap becomes the number of beads they can *realistically* have
169
+ * in flight at once given how fast they ship and how long each bead takes:
170
+ *
171
+ * calibratedCap = clamp(round(closesPerWeek × cycleDays / 7), 1, rawCap)
172
+ *
173
+ * That's much stricter than a naive round(closesPerWeek). A human
174
+ * shipping 4/week with median cycle time 5 days ends up capped at
175
+ * ceil(4·5/7) = 3 in-flight, not 4. Humans with no shipped history keep
176
+ * their raw cap so brand-new projects still get a projection.
177
+ */
178
+ export function calibrateRoster(roster, velocity, options = {}) {
179
+ const defaultCap = roster.cap ?? 5;
180
+ const cycleDays = options.medianCycleDays ?? 5;
181
+ const humans = (roster.humans ?? []).map((h) => {
182
+ const v = velocity.perHuman.get(h.handle);
183
+ const rawCap = h.cap ?? defaultCap;
184
+ let calibratedCap = rawCap;
185
+ let derivation = "raw (no history)";
186
+ // A fixed cap is a decision, not an estimate: roster.json can say so
187
+ // (`"cap_fixed": true`), and a what-if cap always does (bp-q56) — the
188
+ // point of asking "what if their cap were 3" is to see 3, not to have
189
+ // history clamp it back.
190
+ if (h.cap_fixed) {
191
+ derivation = "fixed";
192
+ } else if (v && v.closesPerWeek > 0) {
193
+ const wipEstimate = (v.closesPerWeek * cycleDays) / 7;
194
+ calibratedCap = Math.max(1, Math.min(rawCap, Math.round(wipEstimate)));
195
+ derivation = `Little: ${v.closesPerWeek.toFixed(1)}/wk × ${cycleDays.toFixed(1)}d`;
196
+ }
197
+ return {
198
+ ...h,
199
+ cap: calibratedCap,
200
+ _rawCap: rawCap,
201
+ _closedInWindow: v?.closedInWindow ?? 0,
202
+ _closesPerWeek: v?.closesPerWeek ?? 0,
203
+ _hoursInWindow: v?.hoursInWindow ?? 0,
204
+ _hoursPerWeek: v?.hoursPerWeek ?? 0,
205
+ _capDerivation: derivation,
206
+ };
207
+ });
208
+ return { ...roster, humans, _calibrated: true, _cycleDays: cycleDays };
209
+ }