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/serve.mjs ADDED
@@ -0,0 +1,411 @@
1
+ // beadcyte serve — local Vue dev server + companion /api/beads endpoint.
2
+ //
3
+ // Boots Vite programmatically on the beadcyte web root (`src/web/`) and
4
+ // attaches a `/api/beads` middleware that shells `bd list --all --limit 0
5
+ // --json` from the CWD, cached ~30s. Same port for both, so the browser fetches
6
+ // the API from its own origin and CORS never comes up.
7
+ //
8
+ // Localhost-only. No auth. This is a dev tool, not a service — same posture as
9
+ // bv, and same threat model.
10
+
11
+ import { execFile } from "node:child_process";
12
+ import { readFileSync, existsSync } from "node:fs";
13
+ import { promisify } from "node:util";
14
+ import { createServer as createViteServer } from "vite";
15
+ import vuePlugin from "@vitejs/plugin-vue";
16
+ import { fileURLToPath } from "node:url";
17
+ import { dirname, join } from "node:path";
18
+ import { createAllowlist, inspectProject } from "./projects.mjs";
19
+ import { resolveRosterPath, ROSTER_CANDIDATES } from "./roster-path.mjs";
20
+ import { clearState, writeState } from "./server-state.mjs";
21
+ import { createHistoryWalker } from "./history-walk.mjs";
22
+ import { mergeHistory } from "./review-hours.mjs";
23
+ import { validateMutation, bdArgsFor, describeMutation, createdIdFrom } from "./mutate.mjs";
24
+
25
+ const execFileAsync = promisify(execFile);
26
+ const __dirname = dirname(fileURLToPath(import.meta.url));
27
+ const webRoot = join(__dirname, "web");
28
+
29
+ // ── args ──────────────────────────────────────────────────────────────────
30
+ const args = parseArgs(process.argv.slice(2));
31
+ if (args.help || args.h) {
32
+ printHelp();
33
+ process.exit(0);
34
+ }
35
+ const port = parseInt(args.port ?? "4173", 10);
36
+ // Undefined means "use the convention", which now spans two directory
37
+ // names — see roster-path.mjs.
38
+ const rosterPath = args.roster;
39
+ const cacheTtlMs = parseInt(args["cache-ttl"] ?? "30000", 10);
40
+ const cwd = process.cwd();
41
+ // Set by `beadcyte start`: where to record the port once we are listening, so
42
+ // `beadcyte stop` can find this process. Unset when run in the foreground.
43
+ const stateFile = process.env.BEADCYTE_STATE_FILE;
44
+
45
+ // ── projects ──────────────────────────────────────────────────────────────
46
+ // The launch directory is seeded so a first run is never empty; everything
47
+ // else has to be registered by the client before it can be read.
48
+ const allowlist = createAllowlist([cwd]);
49
+
50
+ // ── beads cache ───────────────────────────────────────────────────────────
51
+ // The shell-out to `bd` is the one non-trivial cost. Cached per project —
52
+ // a single global would serve one project's beads for another for up to the
53
+ // TTL after a switch, or force a refetch on every toggle.
54
+ //
55
+ // `lastMs` is retained per project so the client's load meter can fill
56
+ // against a real prior measurement instead of a fabricated percentage; the
57
+ // subprocess is ~90% of load time and reports no progress of its own.
58
+ const caches = new Map(); // project -> { generated_at, payload, ts, lastMs }
59
+
60
+ // ── history walk (bp-67g.57) ─────────────────────────────────────────────
61
+ // hours_in_review lives only in `bd history`, one shell-out per bead (~0.4s,
62
+ // ~0.4MB each on this repo), so it is walked in the background and cached;
63
+ // the request path only merges what the walk already knows. When a walk
64
+ // changes something, the cached payload is re-merged and re-stamped so the
65
+ // next poll picks it up without another `bd list`.
66
+ //
67
+ // bd serialises its calls, so the walk keeps out of the request path's way:
68
+ // it waits for any `bd list` in flight before each history call, and it does
69
+ // not start until WALK_START_DELAY_MS after a load, so the boot-time prefetch
70
+ // below and the first real request are both answered first. Measured before
71
+ // this: a first request racing the walk took 4.9–7.4s instead of 1.0s.
72
+ const WALK_START_DELAY_MS = 5_000;
73
+ let listInFlight = null; // Promise while a `bd list` runs, else null
74
+ const historyWalker = createHistoryWalker({
75
+ log: (msg) => console.error(`[beadcyte serve] ${msg}`),
76
+ beforeEach: () => listInFlight ?? Promise.resolve(),
77
+ });
78
+ const walkTimers = new Map(); // project -> timeout
79
+ function scheduleWalk(project, beads) {
80
+ clearTimeout(walkTimers.get(project));
81
+ walkTimers.set(
82
+ project,
83
+ setTimeout(() => {
84
+ walkTimers.delete(project);
85
+ void historyWalker.refresh(project, beads);
86
+ }, WALK_START_DELAY_MS),
87
+ );
88
+ }
89
+ const historyListeners = new Set();
90
+ function watchHistory(project) {
91
+ if (historyListeners.has(project)) return;
92
+ historyListeners.add(project);
93
+ historyWalker.onChange(project, () => {
94
+ const hit = caches.get(project);
95
+ if (!hit?.payload) return;
96
+ mergeHistory(hit.payload.beads, historyWalker.cache(project));
97
+ const generated_at = new Date().toISOString();
98
+ hit.generated_at = generated_at;
99
+ hit.payload.generated_at = generated_at;
100
+ });
101
+ }
102
+
103
+ async function loadBeads(project) {
104
+ const now = Date.now();
105
+ const hit = caches.get(project);
106
+ if (hit?.payload && now - hit.ts < cacheTtlMs) return hit;
107
+
108
+ const started = Date.now();
109
+ const listing = execFileAsync(
110
+ "bd",
111
+ ["list", "--all", "--limit", "0", "--json"],
112
+ { cwd: project, maxBuffer: 256 * 1024 * 1024 },
113
+ );
114
+ // Held for the history walk to yield to; cleared however the list ends.
115
+ listInFlight = listing.then(
116
+ () => {
117
+ listInFlight = null;
118
+ },
119
+ () => {
120
+ listInFlight = null;
121
+ },
122
+ );
123
+ const { stdout } = await listing;
124
+ const beads = JSON.parse(stdout);
125
+ mergeHistory(beads, historyWalker.cache(project));
126
+ watchHistory(project);
127
+ scheduleWalk(project, beads);
128
+ const roster = loadRoster(beads, project);
129
+ const took_ms = Date.now() - started;
130
+ const generated_at = new Date().toISOString();
131
+ const entry = {
132
+ generated_at,
133
+ payload: { generated_at, beads, roster, project, took_ms },
134
+ ts: now,
135
+ lastMs: took_ms,
136
+ };
137
+ caches.set(project, entry);
138
+ return entry;
139
+ }
140
+
141
+ function loadRoster(beads, project) {
142
+ const found = resolveRosterPath(project, rosterPath);
143
+ if (found) {
144
+ try {
145
+ return JSON.parse(readFileSync(found.path, "utf8"));
146
+ } catch (e) {
147
+ console.error(`[beadcyte serve] roster ${found.path} failed to parse: ${e.message}`);
148
+ }
149
+ }
150
+ if (rosterPath) {
151
+ console.error(
152
+ `[beadcyte serve] --roster ${rosterPath} does not exist; deriving from beads`,
153
+ );
154
+ }
155
+ const seen = new Set();
156
+ for (const b of beads) if (b.assignee) seen.add(b.assignee);
157
+ return {
158
+ cap: 5,
159
+ humans: [...seen].map((handle) => ({ handle })),
160
+ _derived: true,
161
+ };
162
+ }
163
+
164
+ // ── vite server ───────────────────────────────────────────────────────────
165
+ // The /api/beads route must be registered BEFORE Vite's own middlewares
166
+ // (transformIndexHtml SPA fallback), otherwise every request returns
167
+ // index.html. `configureServer` without a returned function runs pre-internal.
168
+ /**
169
+ * Live data must never be reused from a cache we don't control.
170
+ *
171
+ * These responses carried no Cache-Control at all — just a Date — and with
172
+ * no directives and no validators a browser applies heuristic freshness and
173
+ * may serve a stored copy without revalidating. That quietly defeats the
174
+ * whole point of polling: the client asks again and the browser answers
175
+ * from its own store. `no-store` rather than `no-cache` because there is no
176
+ * value in keeping a copy of a snapshot at all.
177
+ */
178
+ function noStore(res) {
179
+ res.setHeader("Cache-Control", "no-store, max-age=0");
180
+ }
181
+
182
+ /** Collect a request body. Small by construction — one JSON path. */
183
+ function readBody(req, limit = 64 * 1024) {
184
+ return new Promise((resolve, reject) => {
185
+ let out = "";
186
+ req.on("data", (chunk) => {
187
+ out += chunk;
188
+ if (out.length > limit) reject(new Error("body too large"));
189
+ });
190
+ req.on("end", () => resolve(out));
191
+ req.on("error", reject);
192
+ });
193
+ }
194
+
195
+ const beadsApiPlugin = {
196
+ name: "bp-beads-api",
197
+ configureServer(server) {
198
+ // Registration: validate a path and admit it. This is the ONLY way a
199
+ // directory becomes readable, which is what keeps /api/beads from
200
+ // doubling as a filesystem probe for any page that can reach localhost.
201
+ server.middlewares.use("/api/projects", async (req, res) => {
202
+ const json = (code, body) => {
203
+ res.statusCode = code;
204
+ res.setHeader("Content-Type", "application/json");
205
+ noStore(res);
206
+ res.end(JSON.stringify(body));
207
+ };
208
+ if (req.method === "GET") {
209
+ // What this server already trusts, so a client with a stale
210
+ // registry can tell what it still has access to.
211
+ return json(200, { launch: cwd, projects: allowlist.list() });
212
+ }
213
+ if (req.method !== "POST") return json(405, { error: "use GET or POST" });
214
+ try {
215
+ const raw = await readBody(req);
216
+ const { path } = raw ? JSON.parse(raw) : {};
217
+ const seen = allowlist.register(path);
218
+ if (!seen.ok) return json(400, { ok: false, reason: seen.reason });
219
+ return json(200, { ok: true, path: seen.path, label: seen.label });
220
+ } catch (e) {
221
+ return json(400, { ok: false, error: String(e.message ?? e) });
222
+ }
223
+ });
224
+
225
+ // Writes (bp-ocs): one bead, one whitelisted action, one bd command,
226
+ // argv not shell. The project must be registered, exactly like reads;
227
+ // the list cache for it is dropped so the next poll shows the change;
228
+ // the fresh bead comes back so the client can replace its optimistic
229
+ // guess with what bd actually did.
230
+ server.middlewares.use("/api/mutate", async (req, res) => {
231
+ const json = (code, body) => {
232
+ res.statusCode = code;
233
+ res.setHeader("Content-Type", "application/json");
234
+ noStore(res);
235
+ res.end(JSON.stringify(body));
236
+ };
237
+ if (req.method !== "POST") return json(405, { error: "use POST" });
238
+ let m;
239
+ let project;
240
+ try {
241
+ const raw = await readBody(req);
242
+ const body = raw ? JSON.parse(raw) : {};
243
+ project = typeof body.project === "string" && body.project ? body.project : cwd;
244
+ if (!allowlist.allows(project)) return json(403, { error: "project not registered for this session", project });
245
+ m = validateMutation(body);
246
+ } catch (e) {
247
+ return json(400, { error: String(e.message ?? e) });
248
+ }
249
+ const command = describeMutation(m);
250
+ try {
251
+ const ran = await execFileAsync("bd", bdArgsFor(m), { cwd: project, maxBuffer: 8 * 1024 * 1024 });
252
+ caches.delete(project);
253
+ // create has no id until bd names one (bp-2fq).
254
+ const id = m.action === "create" ? createdIdFrom(ran.stdout) : m.id;
255
+ if (!id) return json(200, { ok: true, command, bead: null });
256
+ const { stdout } = await execFileAsync("bd", ["show", id, "--json"], { cwd: project, maxBuffer: 8 * 1024 * 1024 });
257
+ const shown = JSON.parse(stdout);
258
+ const bead = Array.isArray(shown) ? shown[0] ?? null : shown;
259
+ console.error(`[beadcyte serve] mutate: ${command}`);
260
+ return json(200, { ok: true, command, bead });
261
+ } catch (e) {
262
+ const detail = String(e?.stderr || e?.message || e).trim().split("\n").slice(-3).join(" ");
263
+ console.error(`[beadcyte serve] mutate failed: ${command}: ${detail}`);
264
+ return json(500, { ok: false, command, error: detail });
265
+ }
266
+ });
267
+
268
+ server.middlewares.use("/api/beads", async (req, res) => {
269
+ try {
270
+ const url = new URL(req.url ?? "/", "http://localhost");
271
+ const since = url.searchParams.get("since");
272
+ // Absent `project` means the launch directory, so an old client and
273
+ // a plain curl both keep working.
274
+ const project = url.searchParams.get("project") ?? cwd;
275
+ if (!allowlist.allows(project)) {
276
+ // Refused on the allowlist alone — no stat, no realpath, nothing
277
+ // that could reveal whether the path exists.
278
+ res.statusCode = 403;
279
+ res.setHeader("Content-Type", "application/json");
280
+ noStore(res);
281
+ res.end(
282
+ JSON.stringify({
283
+ error: "project not registered for this session",
284
+ project,
285
+ }),
286
+ );
287
+ return;
288
+ }
289
+ const data = await loadBeads(project);
290
+ // Short-circuit for polling: if `since` matches current generated_at,
291
+ // return an empty body with 304-style semantics (via a `stale=false`
292
+ // flag so the client doesn't need to interpret the status code).
293
+ if (since && since === data.generated_at) {
294
+ res.setHeader("Content-Type", "application/json");
295
+ noStore(res);
296
+ res.end(
297
+ JSON.stringify({
298
+ stale: false,
299
+ generated_at: data.generated_at,
300
+ project,
301
+ last_ms: data.lastMs ?? null,
302
+ }),
303
+ );
304
+ return;
305
+ }
306
+ res.setHeader("Content-Type", "application/json");
307
+ noStore(res);
308
+ res.end(JSON.stringify({ stale: true, ...data.payload }));
309
+ } catch (e) {
310
+ res.statusCode = 500;
311
+ res.setHeader("Content-Type", "application/json");
312
+ noStore(res);
313
+ res.end(JSON.stringify({ error: String(e.message ?? e) }));
314
+ }
315
+ });
316
+ },
317
+ };
318
+
319
+ // Expose the package version to the app as `import.meta.env.VITE_BEADCYTE_VERSION`.
320
+ const packageJson = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf8"));
321
+ process.env.VITE_BEADCYTE_VERSION = packageJson.version;
322
+ // The in-app changelog's link out (bp-67g.46). Deployment-specific — a
323
+ // private group's repo is not something to hardcode — so it is an env var,
324
+ // and the link is absent when unset.
325
+ if (process.env.BEADCYTE_CHANGELOG_URL) {
326
+ process.env.VITE_BEADCYTE_CHANGELOG_URL = process.env.BEADCYTE_CHANGELOG_URL;
327
+ }
328
+
329
+ const vite = await createViteServer({
330
+ root: webRoot,
331
+ server: {
332
+ port,
333
+ strictPort: false,
334
+ host: "127.0.0.1",
335
+ },
336
+ plugins: [beadsApiPlugin, vuePlugin()],
337
+ clearScreen: false,
338
+ });
339
+
340
+ await vite.listen(port);
341
+
342
+ const info = vite.config.server;
343
+ const actualPort = vite.httpServer?.address()?.port ?? info.port ?? port;
344
+ console.log(`\n beadcyte serve — http://127.0.0.1:${actualPort}/`);
345
+ console.log(` data at http://127.0.0.1:${actualPort}/api/beads`);
346
+ console.log(` cwd ${cwd}`);
347
+ // Nothing is loaded at boot, so the banner states the path rather than
348
+ // reporting on a cache entry that cannot exist yet.
349
+ console.log(
350
+ ` roster ${rosterPath ?? ROSTER_CANDIDATES.join(" or ")} (per project; derived from beads when absent)`,
351
+ );
352
+ console.log(` projects registered on demand — ${cwd} seeded`);
353
+ console.log(stateFile ? ` stop with beadcyte stop\n` : ` Ctrl-C to stop\n`);
354
+
355
+ if (stateFile) {
356
+ // Written after the banner so `start` can print the banner from the log.
357
+ writeState(stateFile, {
358
+ pid: process.pid,
359
+ port: actualPort,
360
+ url: `http://127.0.0.1:${actualPort}/`,
361
+ cwd,
362
+ startedAt: new Date().toISOString(),
363
+ log: process.env.BEADCYTE_LOG_FILE ?? null,
364
+ });
365
+ for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) {
366
+ process.on(sig, () => {
367
+ clearState(stateFile);
368
+ vite.close().finally(() => process.exit(0));
369
+ });
370
+ }
371
+ }
372
+
373
+ // Warm the cache so the first browser request is instant.
374
+ // Warm the launch project only. Other projects load when first selected —
375
+ // prefetching a registry the browser holds isn't possible at boot anyway.
376
+ loadBeads(cwd).catch((e) =>
377
+ console.error(`[beadcyte serve] initial bd load failed: ${e.message}`),
378
+ );
379
+
380
+ // ── helpers ───────────────────────────────────────────────────────────────
381
+ function parseArgs(argv) {
382
+ const out = {};
383
+ for (let i = 0; i < argv.length; i++) {
384
+ const a = argv[i];
385
+ if (!a.startsWith("--")) continue;
386
+ const k = a.slice(2);
387
+ const v = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : "true";
388
+ out[k] = v;
389
+ }
390
+ return out;
391
+ }
392
+
393
+ function printHelp() {
394
+ console.log(`beadcyte serve — the interactive app, in the foreground (Ctrl-C to stop)
395
+
396
+ Usage:
397
+ beadcyte serve [--port N] [--roster PATH] [--cache-ttl MS]
398
+ beadcyte start [same flags] the same server in the background; returns
399
+ beadcyte stop end a background server for this directory
400
+
401
+ Boots a Vite dev server for the interactive Gantt at 127.0.0.1:<port>. Same
402
+ port serves /api/beads which shells 'bd list --all --limit 0 --json' from the
403
+ CURRENT WORKING DIRECTORY, cached for --cache-ttl ms (default 30000).
404
+
405
+ Flags:
406
+ --port N Listen port (default: 4173; falls through if taken)
407
+ --roster PATH Roster JSON path (default: .beadcyte/roster.json)
408
+ --cache-ttl MS Beads cache TTL in ms (default: 30000)
409
+ -h, --help This message
410
+ `);
411
+ }
@@ -0,0 +1,105 @@
1
+ // server-state.mjs — where `beadcyte start` records the background server.
2
+ //
3
+ // `beadcyte start` launches `beadcyte serve` detached and returns the shell to
4
+ // the user; `beadcyte stop` has to find that process again later, from the same
5
+ // project directory, possibly from a different terminal. The record that joins
6
+ // the two is a small JSON file keyed on the project directory.
7
+ //
8
+ // It lives under the OS temp dir, not inside the project, for two reasons: a
9
+ // user's repository should not need a .gitignore entry to use a viewer, and a
10
+ // leftover record after a crash then costs nothing but a stale file in /tmp.
11
+ // The server's log sits beside it under the same key, so `start` can print
12
+ // where the output went.
13
+ //
14
+ // Pure in the sense this repo means it: nothing runs on import, and every
15
+ // function takes the paths it touches as arguments (with a tmpdir default) so
16
+ // a test can point the whole thing at a scratch directory.
17
+
18
+ import { createHash } from "node:crypto";
19
+ import {
20
+ existsSync,
21
+ mkdirSync,
22
+ readFileSync,
23
+ unlinkSync,
24
+ writeFileSync,
25
+ } from "node:fs";
26
+ import { tmpdir } from "node:os";
27
+ import { join, resolve } from "node:path";
28
+
29
+ /** Directory that holds every project's record and log. */
30
+ export function stateDir(base = tmpdir()) {
31
+ return join(base, "beadcyte");
32
+ }
33
+
34
+ /**
35
+ * Stable key for a project directory: a prefix of the sha1 of its absolute
36
+ * path. Two terminals in the same directory agree; two projects never collide
37
+ * in practice.
38
+ */
39
+ export function projectKey(projectDir) {
40
+ return createHash("sha1").update(resolve(projectDir)).digest("hex").slice(0, 16);
41
+ }
42
+
43
+ /** Path of the JSON record for a project. */
44
+ export function stateFileFor(projectDir, base = tmpdir()) {
45
+ return join(stateDir(base), `${projectKey(projectDir)}.json`);
46
+ }
47
+
48
+ /** Path of the server log for a project — beside its record. */
49
+ export function logFileFor(projectDir, base = tmpdir()) {
50
+ return join(stateDir(base), `${projectKey(projectDir)}.log`);
51
+ }
52
+
53
+ /**
54
+ * Read a record. Returns null when the file is missing or unparsable — a
55
+ * corrupt record is treated as "not running" rather than as an error, because
56
+ * the only recovery either way is to start again.
57
+ *
58
+ * @returns {{ pid: number, port: number, url: string, cwd: string, startedAt: string, log: string|null } | null}
59
+ */
60
+ export function readState(file) {
61
+ try {
62
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
63
+ if (typeof parsed?.pid !== "number") return null;
64
+ return parsed;
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ /** Write a record, creating the directory on first use. */
71
+ export function writeState(file, state) {
72
+ mkdirSync(join(file, ".."), { recursive: true });
73
+ writeFileSync(file, JSON.stringify(state, null, 2) + "\n");
74
+ }
75
+
76
+ /** Remove a record if present. Missing is not an error. */
77
+ export function clearState(file) {
78
+ if (existsSync(file)) unlinkSync(file);
79
+ }
80
+
81
+ /**
82
+ * Is there a process with this pid? Signal 0 probes without delivering.
83
+ * EPERM means it exists but belongs to someone else, which still counts as
84
+ * alive: `stop` will then fail loudly on the real signal instead of quietly
85
+ * deleting a record for a server it cannot reach.
86
+ */
87
+ export function isAlive(pid) {
88
+ if (!Number.isInteger(pid) || pid <= 0) return false;
89
+ try {
90
+ process.kill(pid, 0);
91
+ return true;
92
+ } catch (e) {
93
+ return e?.code === "EPERM";
94
+ }
95
+ }
96
+
97
+ /** The lines `start` prints once the server is up. */
98
+ export function describeRunning(state) {
99
+ return [
100
+ `beadcyte is running for ${state.cwd}`,
101
+ ` ${state.url} (pid ${state.pid})`,
102
+ ...(state.log ? [` log ${state.log}`] : []),
103
+ ` stop with: beadcyte stop`,
104
+ ].join("\n");
105
+ }
package/src/ship.mjs ADDED
@@ -0,0 +1,178 @@
1
+ // ship.mjs — the one place that answers "did this close actually ship?"
2
+ //
3
+ // Three models hang off that answer: velocity (and therefore every
4
+ // calibrated WIP cap), the estimator's duration and lead-time samples, and
5
+ // the ships-per-week trend. The Gantt also draws `shipped` vs `closedNoShip`
6
+ // from it. Until this module existed the rule was written out five times —
7
+ // `!!b.metadata?.ship?.mr_url` in the estimator (twice), velocity, the SVG
8
+ // CLI and the web view-model — which is precisely how two of them end up
9
+ // disagreeing about what a ship is a year from now.
10
+ //
11
+ // ## Why a commit counts
12
+ //
13
+ // `mr_url` was the only accepted evidence, which quietly restricted the tool
14
+ // to teams working through merge requests. beadcyte's own repo is the case in
15
+ // point: real work, 41 closed beads, commits that name the bead they close,
16
+ // and a velocity of zero — not because nothing shipped but because the
17
+ // evidence took a form the tool refused to read.
18
+ //
19
+ // ## Evidence, not a checkbox
20
+ //
21
+ // docs/economics.md argues that `mr_url` is trustworthy downstream precisely
22
+ // because the close command *refuses to close without it*. Widening the
23
+ // accepted forms must not widen that gate, so:
24
+ //
25
+ // - `ship: {}` is not evidence. Neither is `ship: { mr_url: "" }`.
26
+ // - a commit must look like a commit. "TODO", "yes" and "see MR" are not
27
+ // SHAs, and accepting them would let a placeholder manufacture a ship —
28
+ // the one failure mode that would make every number downstream worse
29
+ // rather than merely absent.
30
+ //
31
+ // A rejected-but-present record is reported rather than silently treated as
32
+ // absent, because "you wrote something and it didn't count" needs to be
33
+ // visible or it will be written wrong forever.
34
+ //
35
+ // ## SHAs are trusted, not verified
36
+ //
37
+ // beadcyte never assumes the beads DB and the code live in the same place: it
38
+ // reads `bd list --json`, `beadcyte serve` is normally a global install pointed at
39
+ // some other directory, and beads sync over `refs/dolt/data` independent of
40
+ // any working tree. So there is frequently no repo at hand to check against,
41
+ // and where there is, a SHA that fails to resolve is far more likely to mean
42
+ // a shallow clone, an unfetched branch or a monorepo split than a fabricated
43
+ // record. Verification would also cost one `git cat-file` per closed bead
44
+ // inside a request path that is already cached because a single `bd list` is
45
+ // slow. So the shape is checked and the value is taken on trust.
46
+
47
+ import { readProvenance, RECORDED } from "./provenance.mjs";
48
+
49
+ /**
50
+ * Abbreviated-to-full git object names: 7–40 hex for SHA-1, up to 64 for the
51
+ * SHA-256 transition. Deliberately not anchored at 40 — plenty of workflows
52
+ * record `git rev-parse --short HEAD`, and refusing those would push people
53
+ * back to having no recordable evidence at all.
54
+ */
55
+ const SHA_RE = /^[0-9a-f]{7,64}$/i;
56
+
57
+ /** How many characters of a SHA to show when a UI wants a compact one. */
58
+ const SHORT_SHA_LEN = 8;
59
+
60
+ function trimmed(v) {
61
+ return typeof v === "string" && v.trim() ? v.trim() : null;
62
+ }
63
+
64
+ /**
65
+ * The ship block, or a reason it is unreadable.
66
+ *
67
+ * The string case is not hypothetical: `bd update --set-metadata
68
+ * ship={"commit":"..."}` stores the value as a JSON *string*, so a record
69
+ * that reads correctly in `bd show` is not an object at all and counts for
70
+ * nothing. Naming that beats treating it as absent — it is a typo in the
71
+ * command, and nothing else in the UI would ever hint at it.
72
+ *
73
+ * @param {{ metadata?: unknown }} bead
74
+ * @returns {{ ship: Record<string, unknown> } | { malformed: string | null }}
75
+ */
76
+ function readShip(bead) {
77
+ const md = /** @type {{ ship?: unknown } | null | undefined} */ (bead?.metadata);
78
+ const ship = md?.ship;
79
+ if (ship === undefined || ship === null) return { malformed: null };
80
+ if (typeof ship !== "object" || Array.isArray(ship)) {
81
+ return {
82
+ malformed:
83
+ `ship record is a ${Array.isArray(ship) ? "list" : typeof ship}, not an object` +
84
+ // Plain prose: this string is printed straight into the drawer and
85
+ // the tooltip, neither of which renders markdown.
86
+ (typeof ship === "string"
87
+ ? " — bd update --set-metadata stores JSON as a string; use --metadata"
88
+ : ""),
89
+ };
90
+ }
91
+ return { ship: /** @type {Record<string, unknown>} */ (ship) };
92
+ }
93
+
94
+ /**
95
+ * @typedef {Object} ShipEvidence
96
+ * @property {string[]} forms which kinds of evidence were found, in
97
+ * precedence order: "mr" before "commit". Never empty.
98
+ * @property {string | null} mrUrl
99
+ * @property {string | null} commit full value as recorded
100
+ * @property {string | null} shortCommit first 8 chars, for display
101
+ * @property {string | null} rejected a present-but-unusable field, if any
102
+ * accompanied a usable one (e.g. a valid MR URL and a junk commit)
103
+ * @property {import("./provenance.mjs").Provenance} provenance
104
+ * how the evidence was obtained. A SHA recovered from a `Closes <id>`
105
+ * trailer is a fact; the claim that it is THE closing commit is an
106
+ * inference, and this is what separates the two. Reads the form that
107
+ * carried the evidence — MR first, since it is the stronger record.
108
+ */
109
+
110
+ /**
111
+ * The ship evidence on a bead, or null if it has none that counts.
112
+ *
113
+ * Says nothing about `status` — a bead can carry a ship record before its
114
+ * close lands, and callers that mean "shipped and closed" already test the
115
+ * status themselves. Keeping the two apart is what lets the drawer show the
116
+ * evidence on an in-review bead.
117
+ *
118
+ * @param {{ metadata?: unknown }} bead a bd list --json bead
119
+ * @returns {ShipEvidence | null}
120
+ */
121
+ export function shipEvidence(bead) {
122
+ const { ship } = readShip(bead);
123
+ if (!ship) return null;
124
+
125
+ const mrUrl = trimmed(ship.mr_url);
126
+ const rawCommit = trimmed(ship.commit);
127
+ const commit = rawCommit && SHA_RE.test(rawCommit) ? rawCommit : null;
128
+
129
+ const forms = [];
130
+ if (mrUrl) forms.push("mr");
131
+ if (commit) forms.push("commit");
132
+ if (forms.length === 0) return null;
133
+
134
+ return {
135
+ forms,
136
+ mrUrl,
137
+ commit,
138
+ shortCommit: commit ? commit.slice(0, SHORT_SHA_LEN) : null,
139
+ rejected: rawCommit && !commit ? `commit is not a SHA: ${rawCommit}` : null,
140
+ provenance:
141
+ readProvenance(ship.provenance, forms[0] === "mr" ? "mr_url" : "commit") ??
142
+ RECORDED,
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Did this bead ship? The predicate every model should use.
148
+ *
149
+ * @param {{ metadata?: unknown }} bead
150
+ * @returns {boolean}
151
+ */
152
+ export function isShipped(bead) {
153
+ return shipEvidence(bead) !== null;
154
+ }
155
+
156
+ /**
157
+ * Why a bead that looks like it tried to record a ship doesn't count, or null
158
+ * when there is nothing to explain — no record at all, or a valid one.
159
+ *
160
+ * Three distinguishable states, and a UI that collapses them is lying about
161
+ * one of them: nothing recorded, something recorded that isn't evidence, and
162
+ * evidence.
163
+ *
164
+ * @param {{ metadata?: unknown }} bead
165
+ * @returns {string | null}
166
+ */
167
+ export function shipRecordProblem(bead) {
168
+ const { ship, malformed } = readShip(bead);
169
+ if (!ship) return malformed ?? null;
170
+
171
+ const evidence = shipEvidence(bead);
172
+ if (evidence) return evidence.rejected;
173
+
174
+ const rawCommit = trimmed(ship.commit);
175
+ if (rawCommit) return `commit is not a SHA: ${rawCommit}`;
176
+ if (Object.keys(ship).length === 0) return "ship record is empty";
177
+ return "ship record carries no mr_url and no commit";
178
+ }