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
package/src/mutate.mjs ADDED
@@ -0,0 +1,193 @@
1
+ // mutate.mjs — the one place that says what beadcyte may write to a tracker
2
+ // (bp-ocs). Shared by the server, which turns a mutation into a bd command,
3
+ // and the client, which applies the same mutation optimistically while the
4
+ // command runs. One whitelist, one argv builder, one optimistic patch, so the
5
+ // menu, the endpoint and the rollback cannot disagree about what an action is.
6
+ //
7
+ // The operations are the ones the allocate ritual runs through the CLI, one
8
+ // click away: claim, assign to a roster member, mark spec:ready (and undo
9
+ // it), add a label, defer. Each
10
+ // is one bead at a time; there is no bulk, and no client-side undo — the
11
+ // notice points at bd for correction.
12
+
13
+ const ID_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:\.[a-z0-9]+)*$/i;
14
+ const LABEL_RE = /^[a-z0-9][a-z0-9:_./-]{0,63}$/i;
15
+ const HANDLE_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
16
+ const TITLE_RE = /^[^\x00-\x1f\x7f]{1,200}$/;
17
+ const TYPES = ["task", "feature", "bug", "chore", "epic", "decision"];
18
+ const UNTIL_RE = /^(\+\d{1,3}[hdw]|tomorrow|next (monday|tuesday|wednesday|thursday|friday|saturday|sunday)|\d{4}-\d{2}-\d{2})$/i;
19
+
20
+ export const READY_LABEL = "spec:ready";
21
+
22
+ /** @typedef {"claim" | "assign" | "priority" | "spec-ready" | "unspec-ready" | "add-label" | "defer" | "create"} MutationAction */
23
+ export const MUTATION_ACTIONS = /** @type {const} */ (["claim", "assign", "priority", "spec-ready", "unspec-ready", "add-label", "defer", "create"]);
24
+
25
+ /** The defer horizons the menu offers; bd's own relative syntax. */
26
+ export const DEFER_CHOICES = /** @type {const} */ ([
27
+ { until: "+1d", label: "a day" },
28
+ { until: "+3d", label: "three days" },
29
+ { until: "+1w", label: "a week" },
30
+ { until: "+2w", label: "two weeks" },
31
+ { until: "+4w", label: "a month" },
32
+ ]);
33
+
34
+ /**
35
+ * Check a request body and return the mutation, or throw a message fit for a
36
+ * 400. Everything a shell argument is built from passes a pattern first.
37
+ * @param {unknown} raw
38
+ * @returns {{ action: MutationAction, id: string, label?: string, until?: string, assignee?: string, priority?: number, title?: string, type?: string, labels?: string[] }}
39
+ */
40
+ export function validateMutation(raw) {
41
+ if (!raw || typeof raw !== "object") throw new Error("body must be an object");
42
+ const b = /** @type {Record<string, unknown>} */ (raw);
43
+ const action = b.action;
44
+ if (typeof action !== "string" || !MUTATION_ACTIONS.includes(/** @type {any} */ (action))) {
45
+ throw new Error(`unknown action; expected one of ${MUTATION_ACTIONS.join(", ")}`);
46
+ }
47
+ // create has no id yet: the title is what it acts on.
48
+ if (action === "create") {
49
+ if (typeof b.title !== "string" || !TITLE_RE.test(b.title.trim())) throw new Error("title must be 1–200 printable characters");
50
+ if (typeof b.type !== "string" || !TYPES.includes(b.type)) throw new Error(`type must be one of ${TYPES.join(", ")}`);
51
+ const pri = b.priority;
52
+ if (typeof pri !== "number" || !Number.isInteger(pri) || pri < 0 || pri > 4) throw new Error("priority must be an integer 0–4");
53
+ const labels = Array.isArray(b.labels) ? b.labels : [];
54
+ for (const l of labels) if (typeof l !== "string" || !LABEL_RE.test(l)) throw new Error("labels must be 1–64 of [a-z0-9:_./-]");
55
+ const out = { action: /** @type {MutationAction} */ ("create"), id: "", title: b.title.trim(), type: b.type, priority: pri, labels: /** @type {string[]} */ (labels) };
56
+ if (b.assignee !== undefined && b.assignee !== null && b.assignee !== "") {
57
+ if (typeof b.assignee !== "string" || !HANDLE_RE.test(b.assignee)) throw new Error("assignee must be a handle: 1–64 of [a-z0-9._-]");
58
+ out.assignee = b.assignee;
59
+ }
60
+ return out;
61
+ }
62
+ const id = b.id;
63
+ if (typeof id !== "string" || !ID_RE.test(id)) throw new Error("id is not a bead id");
64
+ const out = { action: /** @type {MutationAction} */ (action), id };
65
+ if (action === "priority") {
66
+ const pri = b.priority;
67
+ if (typeof pri !== "number" || !Number.isInteger(pri) || pri < 0 || pri > 4) throw new Error("priority must be an integer 0–4");
68
+ out.priority = pri;
69
+ }
70
+ if (action === "assign") {
71
+ if (typeof b.assignee !== "string" || !HANDLE_RE.test(b.assignee)) throw new Error("assignee must be a handle: 1–64 of [a-z0-9._-]");
72
+ out.assignee = b.assignee;
73
+ }
74
+ if (action === "add-label") {
75
+ if (typeof b.label !== "string" || !LABEL_RE.test(b.label)) throw new Error("label must be 1–64 of [a-z0-9:_./-]");
76
+ out.label = b.label;
77
+ }
78
+ if (action === "defer") {
79
+ if (b.until !== undefined && b.until !== null && b.until !== "") {
80
+ if (typeof b.until !== "string" || !UNTIL_RE.test(b.until)) throw new Error("until must be +Nh/+Nd/+Nw, tomorrow, next <weekday> or YYYY-MM-DD");
81
+ out.until = b.until;
82
+ }
83
+ }
84
+ return out;
85
+ }
86
+
87
+ /**
88
+ * The bd command for a mutation, as argv (no shell). Always the one form the
89
+ * CLI documents, so a failure can be reproduced by hand from the notice.
90
+ * @param {{ action: MutationAction, id: string, label?: string, until?: string, assignee?: string }} m
91
+ * @returns {string[]}
92
+ */
93
+ export function bdArgsFor(m) {
94
+ switch (m.action) {
95
+ case "claim":
96
+ return ["update", m.id, "--claim"];
97
+ case "assign":
98
+ return ["update", m.id, "--assignee", /** @type {string} */ (m.assignee)];
99
+ case "priority":
100
+ return ["update", m.id, "--priority", String(m.priority)];
101
+ case "create": {
102
+ const args = ["create", /** @type {string} */ (m.title), "-t", /** @type {string} */ (m.type), "-p", String(m.priority)];
103
+ if (m.labels && m.labels.length) args.push("-l", m.labels.join(","));
104
+ if (m.assignee) args.push("-a", m.assignee);
105
+ return args;
106
+ }
107
+ case "spec-ready":
108
+ return ["update", m.id, "--add-label", READY_LABEL];
109
+ case "unspec-ready":
110
+ return ["update", m.id, "--remove-label", READY_LABEL];
111
+ case "add-label":
112
+ return ["update", m.id, "--add-label", /** @type {string} */ (m.label)];
113
+ case "defer":
114
+ return m.until ? ["defer", m.id, "--until", m.until] : ["defer", m.id];
115
+ }
116
+ throw new Error(`no command for ${String(m.action)}`);
117
+ }
118
+
119
+ /** "bd update bp-x --claim" — for logs and docs; the UI speaks in outcomes. */
120
+ export function describeMutation(m) {
121
+ return `bd ${bdArgsFor(m).join(" ")}`;
122
+ }
123
+
124
+ /** "claimed", "assigned to alice", "deferred for a week" — what the toast says. */
125
+ export function describeOutcome(m) {
126
+ switch (m.action) {
127
+ case "claim":
128
+ return "claimed";
129
+ case "assign":
130
+ return `assigned to ${m.assignee}`;
131
+ case "priority":
132
+ return `set to P${m.priority}`;
133
+ case "create":
134
+ return `created`;
135
+ case "spec-ready":
136
+ return `marked ${READY_LABEL}`;
137
+ case "unspec-ready":
138
+ return `${READY_LABEL} removed`;
139
+ case "add-label":
140
+ return `label ${m.label} added`;
141
+ case "defer": {
142
+ const choice = DEFER_CHOICES.find((c) => c.until === m.until);
143
+ return m.until ? `deferred for ${choice ? choice.label : m.until}` : "deferred";
144
+ }
145
+ }
146
+ return String(m.action);
147
+ }
148
+
149
+ /**
150
+ * What the bead will look like once bd has done it — applied on the client
151
+ * while the request runs, replaced by the server's fresh bead on success and
152
+ * by the original on failure. `actor` is the client's best guess at who
153
+ * `--claim` assigns; the fresh bead corrects it.
154
+ * @template {{ id: string, status: string, assignee?: string | null, labels?: string[] | null, started_at?: string | null, updated_at?: string }} B
155
+ * @param {B} bead
156
+ * @param {{ action: MutationAction, id: string, label?: string, until?: string, assignee?: string }} m
157
+ * @param {string | null} actor
158
+ * @param {Date} [now]
159
+ * @returns {B}
160
+ */
161
+ export function applyOptimistic(bead, m, actor, now = new Date()) {
162
+ const iso = now.toISOString();
163
+ const labels = [...(bead.labels ?? [])];
164
+ switch (m.action) {
165
+ case "claim":
166
+ return { ...bead, status: "in_progress", assignee: actor ?? bead.assignee ?? null, started_at: bead.started_at ?? iso, updated_at: iso };
167
+ case "assign":
168
+ // Assignment only: the status is whatever it was. Claiming is the one
169
+ // that starts work.
170
+ return { ...bead, assignee: /** @type {string} */ (m.assignee), updated_at: iso };
171
+ case "priority":
172
+ return { ...bead, priority: /** @type {number} */ (m.priority), updated_at: iso };
173
+ case "spec-ready":
174
+ return { ...bead, labels: labels.includes(READY_LABEL) ? labels : [...labels, READY_LABEL], updated_at: iso };
175
+ case "unspec-ready":
176
+ return { ...bead, labels: labels.filter((l) => l !== READY_LABEL), updated_at: iso };
177
+ case "add-label":
178
+ return { ...bead, labels: labels.includes(/** @type {string} */ (m.label)) ? labels : [...labels, /** @type {string} */ (m.label)], updated_at: iso };
179
+ case "defer":
180
+ return { ...bead, status: "deferred", updated_at: iso };
181
+ }
182
+ return bead;
183
+ }
184
+
185
+ /**
186
+ * The id of the bead `bd create` just made, from its output line
187
+ * ("✓ Created issue: bp-abc — title"). Null when the line is not there.
188
+ * @param {string} stdout
189
+ */
190
+ export function createdIdFrom(stdout) {
191
+ const m = /Created issue:\s+(\S+)/.exec(stdout ?? "");
192
+ return m ? m[1] : null;
193
+ }
@@ -0,0 +1,120 @@
1
+ // projects.mjs — which directories beadcyte serve is willing to read beads from.
2
+ //
3
+ // The browser holds the list of project locations (localStorage), but the
4
+ // server is what executes `bd`. So a path necessarily arrives from the
5
+ // client, and that makes /api/beads a request to run a subprocess in a
6
+ // caller-chosen directory. Any page the user visits can issue requests to
7
+ // localhost; it cannot read the responses cross-origin, but it can still
8
+ // make this server probe the filesystem and infer results from timing or
9
+ // error shape.
10
+ //
11
+ // Hence two steps rather than one. Registration validates a path and admits
12
+ // it to a per-process allowlist; reads serve only what was admitted. A path
13
+ // that was never registered is refused WITHOUT touching the filesystem, so
14
+ // an unregistered request cannot be used to test whether a directory exists.
15
+
16
+ import { existsSync, realpathSync, statSync, accessSync, constants } from "node:fs";
17
+ import { basename, isAbsolute, join, resolve } from "node:path";
18
+
19
+ /** Why a path was refused. Kept as codes so the UI can phrase them. */
20
+ export const REFUSAL = {
21
+ NOT_ABSOLUTE: "not-absolute",
22
+ MISSING: "missing",
23
+ NOT_A_DIRECTORY: "not-a-directory",
24
+ NO_BEADS: "no-beads",
25
+ NOT_READABLE: "not-readable",
26
+ };
27
+
28
+ /**
29
+ * Validate a candidate project directory.
30
+ *
31
+ * Returns the canonical path on success — symlinks resolved — and the client
32
+ * is expected to store and resend exactly that, so later reads normalise to
33
+ * the same string without the server having to call realpath again.
34
+ */
35
+ export function inspectProject(rawPath) {
36
+ if (typeof rawPath !== "string" || !rawPath.trim()) {
37
+ return { ok: false, reason: REFUSAL.MISSING };
38
+ }
39
+ const candidate = rawPath.trim();
40
+
41
+ // Relative paths are ambiguous once the server's cwd stops being the one
42
+ // project, so require the caller to be explicit.
43
+ if (!isAbsolute(candidate)) {
44
+ return { ok: false, reason: REFUSAL.NOT_ABSOLUTE };
45
+ }
46
+
47
+ if (!existsSync(candidate)) return { ok: false, reason: REFUSAL.MISSING };
48
+
49
+ let real;
50
+ try {
51
+ real = realpathSync(candidate);
52
+ } catch {
53
+ return { ok: false, reason: REFUSAL.MISSING };
54
+ }
55
+
56
+ let st;
57
+ try {
58
+ st = statSync(real);
59
+ } catch {
60
+ return { ok: false, reason: REFUSAL.NOT_READABLE };
61
+ }
62
+ if (!st.isDirectory()) {
63
+ return { ok: false, reason: REFUSAL.NOT_A_DIRECTORY };
64
+ }
65
+
66
+ try {
67
+ accessSync(real, constants.R_OK | constants.X_OK);
68
+ } catch {
69
+ return { ok: false, reason: REFUSAL.NOT_READABLE };
70
+ }
71
+
72
+ // A beads project is a directory with a .beads database in it. Checked
73
+ // before admitting rather than on first read, so a mistyped path fails at
74
+ // the moment the user types it instead of looking like an empty project.
75
+ if (!existsSync(join(real, ".beads"))) {
76
+ return { ok: false, reason: REFUSAL.NO_BEADS };
77
+ }
78
+
79
+ return { ok: true, path: real, label: basename(real) || real };
80
+ }
81
+
82
+ /**
83
+ * Per-process set of admitted project paths.
84
+ *
85
+ * Deliberately not persisted: the allowlist is a statement about this
86
+ * running server, and a restart should not silently inherit permission to
87
+ * read directories from a previous session's browser state.
88
+ */
89
+ export function createAllowlist(seedPaths = []) {
90
+ const admitted = new Set();
91
+
92
+ for (const p of seedPaths) {
93
+ const seen = inspectProject(p);
94
+ if (seen.ok) admitted.add(seen.path);
95
+ }
96
+
97
+ return {
98
+ /** Validate and admit. Returns the inspection result either way. */
99
+ register(rawPath) {
100
+ const seen = inspectProject(rawPath);
101
+ if (seen.ok) admitted.add(seen.path);
102
+ return seen;
103
+ },
104
+
105
+ /**
106
+ * Is this path allowed to be read? Uses a PURE normalisation (resolve
107
+ * only — no realpath, no stat), so refusing an unknown path costs no
108
+ * filesystem access and leaks nothing about what exists.
109
+ */
110
+ allows(rawPath) {
111
+ if (typeof rawPath !== "string" || !rawPath) return false;
112
+ if (!isAbsolute(rawPath)) return false;
113
+ return admitted.has(resolve(rawPath));
114
+ },
115
+
116
+ list() {
117
+ return [...admitted];
118
+ },
119
+ };
120
+ }
@@ -0,0 +1,75 @@
1
+ // provenance.mjs — how a stored value says it was not measured.
2
+ //
3
+ // Two unrelated-looking things need the same answer. `metadata.economics`
4
+ // carries figures that may have been reconstructed (bp-67g.58 rebuilt worked
5
+ // hours from session transcripts), and `metadata.ship` carries evidence that
6
+ // may have been recovered rather than recorded (bp-67g.56 read closing
7
+ // commits out of `Closes <id>` trailers). Both gate models — economics feeds
8
+ // the estimator, ship evidence feeds velocity, the estimator AND the trend —
9
+ // so both need a reader that cannot drift from the other.
10
+ //
11
+ // The convention is a `provenance` sibling map keyed by the field it
12
+ // describes, rather than wrapping each value:
13
+ //
14
+ // metadata.economics.provenance.by_seat = { method, at, note }
15
+ // metadata.ship.provenance.commit = { method, at, note }
16
+ //
17
+ // so every existing reader keeps working and only code that cares about
18
+ // provenance looks it up.
19
+
20
+ /**
21
+ * @typedef {Object} Provenance
22
+ * @property {"recorded" | "derived" | "reconstructed"} kind
23
+ * @property {string | null} method the writer's own machine tag, e.g.
24
+ * "derived:git-trailer"
25
+ * @property {string | null} note the writer's own prose. Printed verbatim
26
+ * by the UI: a reconstruction is only judgeable if it says how it was made,
27
+ * and the writer knows that while beadcyte does not.
28
+ * @property {string | null} at when it was reconstructed
29
+ */
30
+
31
+ /** A value nobody flagged. See `readProvenance` for why absence means this. */
32
+ export const RECORDED = /** @type {Provenance} */ ({
33
+ kind: "recorded",
34
+ method: null,
35
+ note: null,
36
+ at: null,
37
+ });
38
+
39
+ function str(v) {
40
+ return typeof v === "string" && v.trim() ? v.trim() : null;
41
+ }
42
+
43
+ /**
44
+ * Read one field's provenance out of a `provenance` map.
45
+ *
46
+ * Accepts either an object (`{method, at, note}`) or a bare string, which is
47
+ * taken as the method — both are what people actually write. Any entry at all
48
+ * means the field was not measured; nothing here interprets the method.
49
+ *
50
+ * Returns null when there is no entry, which callers read as RECORDED. That
51
+ * is an assumption and the only one available: every record written before
52
+ * this convention existed was written at close time by something that had
53
+ * measured it. A tool that reconstructs a value is expected to say so.
54
+ *
55
+ * @param {unknown} raw the `provenance` map, if any
56
+ * @param {string} field the field being asked about
57
+ * @returns {Provenance | null}
58
+ */
59
+ export function readProvenance(raw, field) {
60
+ if (!raw || typeof raw !== "object") return null;
61
+ const entry = /** @type {Record<string, unknown>} */ (raw)[field];
62
+ if (entry === undefined || entry === null) return null;
63
+ if (typeof entry === "string") {
64
+ const m = str(entry);
65
+ return m ? { kind: "reconstructed", method: m, note: null, at: null } : null;
66
+ }
67
+ if (typeof entry !== "object") return null;
68
+ const o = /** @type {Record<string, unknown>} */ (entry);
69
+ return {
70
+ kind: "reconstructed",
71
+ method: str(o.method),
72
+ note: str(o.note),
73
+ at: str(o.at),
74
+ };
75
+ }
@@ -0,0 +1,117 @@
1
+ // review-hours.mjs — hours_in_review from bd history (bp-67g.57).
2
+ //
3
+ // The one economics phase that is recoverable but not cheaply. The first
4
+ // transition into `in_review` lives only in `bd history`, and
5
+ // `bd history --json <id>` returns a FULL issue snapshot per Dolt commit:
6
+ // measured on this repo, 139 entries and 435KB in 435ms for one bead. So it
7
+ // is never read in the request path. history-walk.mjs walks beads in the
8
+ // background, one shell-out per bead whose `updated_at` moved since the last
9
+ // walk, and caches the answer; this file is the arithmetic on what it finds.
10
+ //
11
+ // Pure, so every rule is testable without bd.
12
+
13
+ const MS_PER_HOUR = 3_600_000;
14
+
15
+ /**
16
+ * The moment a bead first entered `in_review`, from its history entries as
17
+ * `bd history --json` returns them: `{ CommitHash, Committer, CommitDate,
18
+ * Issue }` in any order. The snapshot's own `updated_at` is preferred — it
19
+ * is the bead's clock — with the commit date as the fallback. Null when the
20
+ * bead never entered review, which the caller must keep as ABSENT, not zero.
21
+ *
22
+ * @param {ReadonlyArray<{CommitDate?: string, Issue?: {status?: string, updated_at?: string}}>} entries
23
+ * @returns {string | null} ISO timestamp
24
+ */
25
+ export function firstInReviewAt(entries) {
26
+ let best = null;
27
+ for (const e of entries ?? []) {
28
+ if (e?.Issue?.status !== "in_review") continue;
29
+ const at = e.Issue.updated_at || e.CommitDate;
30
+ const t = at ? Date.parse(at) : NaN;
31
+ if (!Number.isFinite(t)) continue;
32
+ if (best === null || t < best) best = t;
33
+ }
34
+ return best === null ? null : new Date(best).toISOString();
35
+ }
36
+
37
+ /**
38
+ * The moment a bead first had an assignee, from the same history entries:
39
+ * the earliest snapshot whose Issue.assignee is set (bp-7c9). Null when it
40
+ * never had one — kept absent downstream, like in_review.
41
+ *
42
+ * @param {ReadonlyArray<{CommitDate?: string, Issue?: {assignee?: string|null, updated_at?: string}}>} entries
43
+ * @returns {string | null}
44
+ */
45
+ export function firstAssignedAt(entries) {
46
+ let best = null;
47
+ for (const e of entries ?? []) {
48
+ if (!e?.Issue?.assignee) continue;
49
+ const at = e.Issue.updated_at || e.CommitDate;
50
+ const t = at ? Date.parse(at) : NaN;
51
+ if (!Number.isFinite(t)) continue;
52
+ if (best === null || t < best) best = t;
53
+ }
54
+ return best === null ? null : new Date(best).toISOString();
55
+ }
56
+
57
+ /**
58
+ * Hours from entering review to the close, or to now while the bead is
59
+ * still open. Clamped at zero; null when there is no entry time.
60
+ *
61
+ * @param {string | null | undefined} inReviewAt
62
+ * @param {string | null | undefined} closedAt
63
+ * @param {number} now
64
+ */
65
+ export function reviewHours(inReviewAt, closedAt, now = Date.now()) {
66
+ if (!inReviewAt) return null;
67
+ const start = Date.parse(inReviewAt);
68
+ if (!Number.isFinite(start)) return null;
69
+ const endRaw = closedAt ? Date.parse(closedAt) : now;
70
+ const end = Number.isFinite(endRaw) ? endRaw : now;
71
+ return Math.max(0, (end - start) / MS_PER_HOUR);
72
+ }
73
+
74
+ /**
75
+ * Which beads the walker must visit: anything without a cache entry at its
76
+ * current `updated_at`. A bead that has not changed since it was last walked
77
+ * cannot have gained a transition, so the walk is incremental after the
78
+ * first pass — the first pass is the whole cost.
79
+ *
80
+ * @param {ReadonlyArray<{id: string, updated_at?: string}>} beads
81
+ * @param {Record<string, {updated_at?: string}>} cache
82
+ * @returns {string[]} bead ids
83
+ */
84
+ export function planWalk(beads, cache) {
85
+ const out = [];
86
+ for (const b of beads) {
87
+ const hit = cache[b.id];
88
+ // An entry from before assigned_at existed (bp-7c9) is missing a fact
89
+ // the walk now records: revisit it once, whatever its updated_at.
90
+ if (!hit || hit.updated_at !== b.updated_at || hit.assigned_at === undefined) out.push(b.id);
91
+ }
92
+ return out;
93
+ }
94
+
95
+ /**
96
+ * Attach what the walk knows to each bead as `history`, in place. A bead the
97
+ * walk has not reached (or that changed since) gets nothing, so the UI
98
+ * reports the phase as absent rather than as zero or as stale.
99
+ *
100
+ * @template {{id: string, updated_at?: string, history?: unknown}} B
101
+ * @param {B[]} beads
102
+ * @param {Record<string, {updated_at?: string, in_review_at: string | null, assigned_at?: string | null, walked_at?: string}>} cache
103
+ * @returns {number} how many beads carry history after the merge
104
+ */
105
+ export function mergeHistory(beads, cache) {
106
+ let n = 0;
107
+ for (const b of beads) {
108
+ const hit = cache[b.id];
109
+ if (hit && hit.updated_at === b.updated_at) {
110
+ b.history = { in_review_at: hit.in_review_at ?? null, assigned_at: hit.assigned_at ?? null, walked_at: hit.walked_at ?? null };
111
+ n++;
112
+ } else if (b.history) {
113
+ delete b.history;
114
+ }
115
+ }
116
+ return n;
117
+ }
@@ -0,0 +1,24 @@
1
+ import { existsSync } from "node:fs";
2
+ import { isAbsolute, join } from "node:path";
3
+
4
+ /** Conventional roster path. `beadcyte --roster PATH` overrides it. */
5
+ export const ROSTER_CANDIDATES = [
6
+ ".beadcyte/roster.json",
7
+ ];
8
+
9
+ /**
10
+ * Resolve the roster for a project directory.
11
+ *
12
+ * @param {string} projectDir
13
+ * @param {string} [override] value of --roster; used verbatim when given,
14
+ * because an explicit path must not be second-guessed
15
+ * @returns {{ path: string } | null} null when none exists
16
+ */
17
+ export function resolveRosterPath(projectDir, override) {
18
+ if (override) {
19
+ const abs = isAbsolute(override) ? override : join(projectDir, override);
20
+ return existsSync(abs) ? { path: abs } : null;
21
+ }
22
+ const abs = join(projectDir, ROSTER_CANDIDATES[0]);
23
+ return existsSync(abs) ? { path: abs } : null;
24
+ }