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,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
|
+
}
|
package/src/velocity.mjs
ADDED
|
@@ -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
|
+
}
|