runboard 0.1.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.
@@ -0,0 +1,904 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/core/errors.ts
4
+ var UserError = class extends Error {
5
+ };
6
+
7
+ // src/commands/shared.ts
8
+ import { existsSync } from "fs";
9
+
10
+ // src/data/paths.ts
11
+ import path from "path";
12
+ function runboardPaths(root = process.cwd()) {
13
+ const dir = path.join(root, ".runboard");
14
+ return {
15
+ root,
16
+ dir,
17
+ config: path.join(dir, "config.yaml"),
18
+ rubric: path.join(dir, "rubric.yaml"),
19
+ assessmentsDir: path.join(dir, "assessments"),
20
+ reportsDir: path.join(dir, "reports"),
21
+ roadmap: path.join(dir, "roadmap.md"),
22
+ boardHtml: path.join(dir, "board.html")
23
+ };
24
+ }
25
+ function assessmentFile(root, date) {
26
+ return path.join(runboardPaths(root).assessmentsDir, `${date}.md`);
27
+ }
28
+
29
+ // src/commands/shared.ts
30
+ function requireInit(root = process.cwd()) {
31
+ if (!existsSync(runboardPaths(root).dir)) {
32
+ throw new UserError("No .runboard/ found here. Run `runboard init` first.");
33
+ }
34
+ }
35
+ function requireAssessments(dates) {
36
+ if (dates.length === 0) {
37
+ throw new UserError("No assessments yet. Run `runboard assess` to record one.");
38
+ }
39
+ }
40
+
41
+ // src/commands/assess.ts
42
+ import { cancel, intro, isCancel, note, outro, select, text } from "@clack/prompts";
43
+
44
+ // src/core/types.ts
45
+ var AREAS = ["build", "run", "plan"];
46
+ var LENSES = ["team", "tools", "techniques"];
47
+ var TRAJECTORIES = ["up", "flat", "down", "volatile"];
48
+ var ASSESSMENT_TYPES = ["baseline", "pulse", "quarterly", "event"];
49
+ var DIMENSION_KEYS = AREAS.flatMap(
50
+ (area) => LENSES.map((lens) => `${area}.${lens}`)
51
+ );
52
+ var TRAJECTORY_GLYPHS = {
53
+ up: "\u2B06",
54
+ flat: "\u27A1",
55
+ down: "\u2B07",
56
+ volatile: "\u26A0"
57
+ };
58
+ function parseDimensionKey(key) {
59
+ const [area, lens] = key.split(".");
60
+ if (!isArea(area) || !isLens(lens)) {
61
+ throw new Error(`Invalid dimension key: "${key}"`);
62
+ }
63
+ return { area, lens };
64
+ }
65
+ function isArea(value) {
66
+ return typeof value === "string" && AREAS.includes(value);
67
+ }
68
+ function isLens(value) {
69
+ return typeof value === "string" && LENSES.includes(value);
70
+ }
71
+ function isTrajectory(value) {
72
+ return typeof value === "string" && TRAJECTORIES.includes(value);
73
+ }
74
+ function isAssessmentType(value) {
75
+ return typeof value === "string" && ASSESSMENT_TYPES.includes(value);
76
+ }
77
+ function isLevel(value) {
78
+ return value === 1 || value === 2 || value === 3 || value === 4 || value === 5;
79
+ }
80
+
81
+ // src/data/assessments.ts
82
+ import { existsSync as existsSync2, mkdirSync, readFileSync, readdirSync, writeFileSync } from "fs";
83
+ import path2 from "path";
84
+ import { parse, stringify } from "yaml";
85
+ var DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
86
+ var AssessmentError = class extends Error {
87
+ };
88
+ function validateScores(raw) {
89
+ if (!raw || typeof raw !== "object") {
90
+ throw new AssessmentError("Assessment has no scores.");
91
+ }
92
+ const obj = raw;
93
+ const result = {};
94
+ for (const key of DIMENSION_KEYS) {
95
+ const cell2 = obj[key];
96
+ if (!cell2 || typeof cell2 !== "object") {
97
+ throw new AssessmentError(`Missing score for dimension "${key}".`);
98
+ }
99
+ const c = cell2;
100
+ const level = typeof c.level === "string" ? Number(c.level) : c.level;
101
+ if (!isLevel(level)) {
102
+ throw new AssessmentError(`Dimension "${key}" has an invalid level (must be 1-5).`);
103
+ }
104
+ if (!isTrajectory(c.trajectory)) {
105
+ throw new AssessmentError(
106
+ `Dimension "${key}" has an invalid trajectory (must be up/flat/down/volatile).`
107
+ );
108
+ }
109
+ result[key] = {
110
+ level,
111
+ trajectory: c.trajectory,
112
+ evidence: typeof c.evidence === "string" ? c.evidence : ""
113
+ };
114
+ }
115
+ return result;
116
+ }
117
+ function serializeAssessment(a) {
118
+ const frontmatter = stringify({
119
+ date: a.date,
120
+ type: a.type,
121
+ scores: a.scores,
122
+ ...a.notes ? { notes: a.notes } : {}
123
+ });
124
+ const body = a.narrative ? `
125
+ ${a.narrative.trim()}
126
+ ` : "";
127
+ return `---
128
+ ${frontmatter}---
129
+ ${body}`;
130
+ }
131
+ function parseAssessment(text2, fallbackDate) {
132
+ const match = text2.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
133
+ if (!match) {
134
+ throw new AssessmentError("Assessment file is missing YAML frontmatter.");
135
+ }
136
+ const raw = parse(match[1]) ?? {};
137
+ const date = typeof raw.date === "string" ? raw.date : fallbackDate ?? "";
138
+ if (!DATE_RE.test(date)) {
139
+ throw new AssessmentError(`Assessment has an invalid date: "${date}".`);
140
+ }
141
+ let type = "baseline";
142
+ if (raw.type !== void 0) {
143
+ if (!isAssessmentType(raw.type)) {
144
+ throw new AssessmentError(
145
+ `Assessment ${date} has an invalid type "${String(raw.type)}" (use baseline, pulse, quarterly, or event).`
146
+ );
147
+ }
148
+ type = raw.type;
149
+ }
150
+ const scores = validateScores(raw.scores);
151
+ const narrative = (match[2] ?? "").trim();
152
+ return {
153
+ date,
154
+ type,
155
+ scores,
156
+ ...typeof raw.notes === "string" ? { notes: raw.notes } : {},
157
+ ...narrative ? { narrative } : {}
158
+ };
159
+ }
160
+ function listAssessmentDates(root = process.cwd()) {
161
+ const dir = runboardPaths(root).assessmentsDir;
162
+ if (!existsSync2(dir)) {
163
+ return [];
164
+ }
165
+ return readdirSync(dir).filter((f) => f.endsWith(".md")).map((f) => path2.basename(f, ".md")).filter((d) => DATE_RE.test(d)).sort();
166
+ }
167
+ function loadAssessment(date, root = process.cwd()) {
168
+ const file = assessmentFile(root, date);
169
+ return parseAssessment(readFileSync(file, "utf8"), date);
170
+ }
171
+ function loadAllAssessments(root = process.cwd()) {
172
+ return listAssessmentDates(root).map((date) => loadAssessment(date, root));
173
+ }
174
+ function latestAssessment(root = process.cwd()) {
175
+ const dates = listAssessmentDates(root);
176
+ const last = dates[dates.length - 1];
177
+ return last ? loadAssessment(last, root) : void 0;
178
+ }
179
+ function saveAssessment(a, root = process.cwd(), options = {}) {
180
+ const dir = runboardPaths(root).assessmentsDir;
181
+ mkdirSync(dir, { recursive: true });
182
+ const file = assessmentFile(root, a.date);
183
+ if (existsSync2(file) && !options.force) {
184
+ throw new AssessmentError(
185
+ `An assessment for ${a.date} already exists. Re-run with --force to overwrite.`
186
+ );
187
+ }
188
+ writeFileSync(file, serializeAssessment(a), "utf8");
189
+ return file;
190
+ }
191
+
192
+ // src/data/rubric.ts
193
+ import { readFileSync as readFileSync2 } from "fs";
194
+ import path3 from "path";
195
+ import { fileURLToPath } from "url";
196
+ import { parse as parse2 } from "yaml";
197
+ function shippedRubricPath() {
198
+ const here = path3.dirname(fileURLToPath(import.meta.url));
199
+ const candidates = [
200
+ path3.resolve(here, "../rubric/rubric.yaml"),
201
+ path3.resolve(here, "../../rubric/rubric.yaml")
202
+ ];
203
+ for (const candidate of candidates) {
204
+ try {
205
+ readFileSync2(candidate);
206
+ return candidate;
207
+ } catch {
208
+ }
209
+ }
210
+ return candidates[candidates.length - 1];
211
+ }
212
+ function parseRubric(text2) {
213
+ const raw = parse2(text2);
214
+ if (!raw || typeof raw !== "object") {
215
+ throw new Error("Rubric is empty or malformed.");
216
+ }
217
+ const obj = raw;
218
+ const version = typeof obj.version === "string" ? obj.version : "";
219
+ if (!version) {
220
+ throw new Error("Rubric is missing a `version`.");
221
+ }
222
+ if (!Array.isArray(obj.dimensions)) {
223
+ throw new Error("Rubric is missing a `dimensions` list.");
224
+ }
225
+ const dimensions = obj.dimensions.map(parseDimension);
226
+ const keys = dimensions.map((d) => d.key);
227
+ for (const required of DIMENSION_KEYS) {
228
+ if (!keys.includes(required)) {
229
+ throw new Error(`Rubric is missing dimension "${required}".`);
230
+ }
231
+ }
232
+ if (keys.length !== DIMENSION_KEYS.length) {
233
+ throw new Error(
234
+ `Rubric must have exactly ${DIMENSION_KEYS.length} dimensions; found ${keys.length}.`
235
+ );
236
+ }
237
+ return { version, dimensions };
238
+ }
239
+ function parseDimension(raw) {
240
+ if (!raw || typeof raw !== "object") {
241
+ throw new Error("Rubric dimension is malformed.");
242
+ }
243
+ const obj = raw;
244
+ const key = String(obj.key ?? "");
245
+ const { area, lens } = parseDimensionKey(key);
246
+ const title2 = typeof obj.title === "string" ? obj.title : key;
247
+ const anchorsRaw = obj.anchors;
248
+ if (!anchorsRaw || typeof anchorsRaw !== "object") {
249
+ throw new Error(`Dimension "${key}" is missing anchors.`);
250
+ }
251
+ const anchorsObj = anchorsRaw;
252
+ const anchors = {};
253
+ for (const level of [1, 2, 3, 4, 5]) {
254
+ const text2 = anchorsObj[String(level)];
255
+ if (typeof text2 !== "string" || text2.length === 0) {
256
+ throw new Error(`Dimension "${key}" is missing anchor text for level ${level}.`);
257
+ }
258
+ anchors[level] = text2;
259
+ }
260
+ return { key, area, lens, title: title2, anchors };
261
+ }
262
+ function loadRubric(filePath = shippedRubricPath()) {
263
+ let text2;
264
+ try {
265
+ text2 = readFileSync2(filePath, "utf8");
266
+ } catch {
267
+ throw new Error(`Could not read rubric at ${filePath}.`);
268
+ }
269
+ return parseRubric(text2);
270
+ }
271
+
272
+ // src/commands/assess.ts
273
+ function today() {
274
+ const d = /* @__PURE__ */ new Date();
275
+ const pad = (n) => String(n).padStart(2, "0");
276
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
277
+ }
278
+ function parseSetEntry(entry) {
279
+ const eq = entry.indexOf("=");
280
+ if (eq < 0)
281
+ throw new UserError(`Invalid --set "${entry}". Expected <dim>=<level>:<traj>:<evidence>.`);
282
+ const key = entry.slice(0, eq).trim();
283
+ const rest = entry.slice(eq + 1);
284
+ const c1 = rest.indexOf(":");
285
+ const c2 = rest.indexOf(":", c1 + 1);
286
+ if (c1 < 0 || c2 < 0) {
287
+ throw new UserError(`Invalid --set "${entry}". Expected <dim>=<level>:<traj>:<evidence>.`);
288
+ }
289
+ const level = Number(rest.slice(0, c1).trim());
290
+ const trajectory = rest.slice(c1 + 1, c2).trim();
291
+ const evidence = rest.slice(c2 + 1).trim().replace(/^"(.*)"$/, "$1");
292
+ return { key, value: { level, trajectory, evidence } };
293
+ }
294
+ function defaultType(root) {
295
+ return listAssessmentDates(root).length === 0 ? "baseline" : "pulse";
296
+ }
297
+ function resolveType(rawType, root) {
298
+ if (rawType !== void 0 && !isAssessmentType(rawType)) {
299
+ throw new UserError(`Invalid --type "${rawType}". Use baseline, pulse, quarterly, or event.`);
300
+ }
301
+ return rawType ?? defaultType(root);
302
+ }
303
+ function runAssess(opts) {
304
+ const root = opts.root ?? process.cwd();
305
+ requireInit(root);
306
+ const date = opts.date ?? today();
307
+ const type = resolveType(opts.type, root);
308
+ const raw = {};
309
+ for (const entry of opts.sets ?? []) {
310
+ const { key, value } = parseSetEntry(entry);
311
+ raw[key] = value;
312
+ }
313
+ const scores = validateScores(raw);
314
+ const assessment = { date, type, scores };
315
+ const path7 = saveAssessment(assessment, root, { force: opts.force });
316
+ return { date, path: path7 };
317
+ }
318
+ async function runInteractive(opts) {
319
+ const root = opts.root ?? process.cwd();
320
+ requireInit(root);
321
+ const date = opts.date ?? today();
322
+ const type = resolveType(opts.type, root);
323
+ const rubric = loadRubric(runboardPaths(root).rubric);
324
+ intro("Runboard assessment");
325
+ const raw = {};
326
+ for (const key of DIMENSION_KEYS) {
327
+ const dim = rubric.dimensions.find((d) => d.key === key);
328
+ if (!dim) continue;
329
+ note([1, 2, 3, 4, 5].map((l) => `${l} ${dim.anchors[l]}`).join("\n"), dim.title);
330
+ const level = await select({
331
+ message: `${dim.title} \u2014 level?`,
332
+ options: [1, 2, 3, 4, 5].map((l) => ({ value: l, label: `${l}` }))
333
+ });
334
+ if (isCancel(level)) return abort();
335
+ const trajectory = await select({
336
+ message: "Trajectory?",
337
+ options: TRAJECTORIES.map((t) => ({ value: t, label: t }))
338
+ });
339
+ if (isCancel(trajectory)) return abort();
340
+ const evidence = await text({ message: "One-line evidence (optional)", defaultValue: "" });
341
+ if (isCancel(evidence)) return abort();
342
+ raw[key] = { level, trajectory, evidence };
343
+ }
344
+ const scores = validateScores(raw);
345
+ const path7 = saveAssessment({ date, type, scores }, root, { force: opts.force });
346
+ outro(`Saved ${path7}. Run \`runboard board\` to see your scorecard.`);
347
+ return { date, path: path7 };
348
+ }
349
+ function abort() {
350
+ cancel("Assessment cancelled.");
351
+ throw new UserError("Assessment cancelled.");
352
+ }
353
+ function registerAssess(program) {
354
+ program.command("assess").description("Record a 9-dimension assessment (interactive, or --set for agents).").option("--type <type>", "baseline | pulse | quarterly | event").option(
355
+ "--set <entry>",
356
+ "non-interactive: <dim>=<level>:<traj>:<evidence> (repeatable)",
357
+ (val, prev = []) => [...prev, val],
358
+ []
359
+ ).option("--force", "overwrite an existing assessment for today").action(async (options) => {
360
+ const sets = options.set ?? [];
361
+ if (sets.length > 0) {
362
+ const { path: path7 } = runAssess({ sets, type: options.type, force: options.force });
363
+ process.stdout.write(`Saved ${path7}. Run \`runboard board\` to see your scorecard.
364
+ `);
365
+ } else {
366
+ await runInteractive({ type: options.type, force: options.force });
367
+ }
368
+ });
369
+ }
370
+
371
+ // src/commands/board.ts
372
+ import { writeFileSync as writeFileSync2 } from "fs";
373
+
374
+ // src/core/score.ts
375
+ function summarise(assessment) {
376
+ const cells = DIMENSION_KEYS.map((key) => {
377
+ const score = assessment.scores[key];
378
+ return { key, level: score.level, trajectory: score.trajectory };
379
+ });
380
+ const total = cells.reduce((sum, cell2) => sum + cell2.level, 0);
381
+ const average = total / cells.length;
382
+ const trajectoryCounts = Object.fromEntries(TRAJECTORIES.map((t) => [t, 0]));
383
+ for (const cell2 of cells) {
384
+ trajectoryCounts[cell2.trajectory] += 1;
385
+ }
386
+ return { cells, average, trajectoryCounts };
387
+ }
388
+ function formatAverage(average) {
389
+ return average.toFixed(1);
390
+ }
391
+
392
+ // src/core/constraints.ts
393
+ var TRAJECTORY_RANK = {
394
+ down: 0,
395
+ volatile: 1,
396
+ flat: 2,
397
+ up: 3
398
+ };
399
+ function rankConstraints(assessment) {
400
+ return DIMENSION_KEYS.map((key) => ({
401
+ key,
402
+ level: assessment.scores[key].level,
403
+ trajectory: assessment.scores[key].trajectory
404
+ })).sort((a, b) => {
405
+ if (a.level !== b.level) return a.level - b.level;
406
+ const ta = TRAJECTORY_RANK[a.trajectory];
407
+ const tb = TRAJECTORY_RANK[b.trajectory];
408
+ if (ta !== tb) return ta - tb;
409
+ return DIMENSION_KEYS.indexOf(a.key) - DIMENSION_KEYS.indexOf(b.key);
410
+ });
411
+ }
412
+ function bindingConstraint(assessment) {
413
+ return rankConstraints(assessment)[0];
414
+ }
415
+
416
+ // src/render/board-html.ts
417
+ var LEVEL_BG = {
418
+ 1: "#d7263d",
419
+ 2: "#f46036",
420
+ 3: "#2e933c",
421
+ 4: "#1b998b",
422
+ 5: "#2660a4"
423
+ };
424
+ function escapeHtml(s) {
425
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
426
+ }
427
+ function titleFor(rubric, key) {
428
+ return rubric.dimensions.find((d) => d.key === key)?.title ?? key;
429
+ }
430
+ function renderBoardHtml(assessment, summary, rubric) {
431
+ const byKey = new Map(summary.cells.map((c) => [c.key, c]));
432
+ const constraint = bindingConstraint(assessment);
433
+ const rows = AREAS.map((area) => {
434
+ const cells = LENSES.map((lens) => {
435
+ const key = `${area}.${lens}`;
436
+ const c = byKey.get(key);
437
+ if (!c) return "<td></td>";
438
+ const score = assessment.scores[key];
439
+ return `<td class="cell" style="background:${LEVEL_BG[c.level]}" title="${escapeHtml(
440
+ titleFor(rubric, key)
441
+ )}: ${escapeHtml(score.evidence)}">
442
+ <span class="lvl">${c.level}</span>
443
+ <span class="traj">${TRAJECTORY_GLYPHS[c.trajectory]}</span>
444
+ </td>`;
445
+ }).join("");
446
+ const label = area[0]?.toUpperCase() + area.slice(1);
447
+ return `<tr><th class="area">${label}</th>${cells}</tr>`;
448
+ }).join("");
449
+ const counts = summary.trajectoryCounts;
450
+ return `<!doctype html>
451
+ <html lang="en">
452
+ <head>
453
+ <meta charset="utf-8">
454
+ <meta name="viewport" content="width=device-width, initial-scale=1">
455
+ <title>Runboard \u2014 ${assessment.date}</title>
456
+ <style>
457
+ body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 2rem; color: #1a1a1a; }
458
+ h1 { font-size: 1.4rem; margin: 0 0 .25rem; }
459
+ .sub { color: #555; margin: 0 0 1.5rem; }
460
+ table { border-collapse: separate; border-spacing: 6px; }
461
+ th.area { text-align: right; padding-right: .75rem; font-weight: 600; }
462
+ th.lens { padding-bottom: .25rem; font-weight: 600; color: #444; }
463
+ td.cell { width: 90px; height: 70px; border-radius: 8px; color: #fff; text-align: center; vertical-align: middle; }
464
+ td.cell .lvl { display: block; font-size: 1.8rem; font-weight: 700; line-height: 1; }
465
+ td.cell .traj { font-size: 1.1rem; }
466
+ .meta { margin-top: 1.5rem; font-size: 1rem; }
467
+ .meta strong { font-size: 1.2rem; }
468
+ .constraint { margin-top: .5rem; padding: .75rem 1rem; background: #f4f4f5; border-radius: 8px; display: inline-block; }
469
+ </style>
470
+ </head>
471
+ <body>
472
+ <h1>Runboard</h1>
473
+ <p class="sub">Assessment ${assessment.date}</p>
474
+ <table>
475
+ <tr><th></th>${LENSES.map((l) => `<th class="lens">${l[0]?.toUpperCase()}${l.slice(1)}</th>`).join("")}</tr>
476
+ ${rows}
477
+ </table>
478
+ <div class="meta">
479
+ Average <strong>${formatAverage(summary.average)}</strong> / 5
480
+ &nbsp;\xB7&nbsp; \u2B06 ${counts.up} &nbsp; \u27A1 ${counts.flat} &nbsp; \u2B07 ${counts.down} &nbsp; \u26A0 ${counts.volatile}
481
+ </div>
482
+ <div class="constraint">
483
+ Biggest constraint: <strong>${escapeHtml(titleFor(rubric, constraint.key))}</strong>
484
+ (level ${constraint.level}, ${constraint.trajectory})
485
+ </div>
486
+ </body>
487
+ </html>
488
+ `;
489
+ }
490
+
491
+ // src/render/heatmap.ts
492
+ import pc from "picocolors";
493
+ function colorForLevel(level) {
494
+ switch (level) {
495
+ case 1:
496
+ return pc.red;
497
+ case 2:
498
+ return pc.yellow;
499
+ case 3:
500
+ return pc.green;
501
+ case 4:
502
+ return pc.cyan;
503
+ case 5:
504
+ return pc.blue;
505
+ }
506
+ }
507
+ function cell(level, trajectory) {
508
+ const text2 = ` ${level}${TRAJECTORY_GLYPHS[trajectory]} `;
509
+ return colorForLevel(level)(text2);
510
+ }
511
+ var LENS_HEADERS = {
512
+ team: "Team",
513
+ tools: "Tools",
514
+ techniques: "Techniques"
515
+ };
516
+ var AREA_HEADERS = {
517
+ build: "Build",
518
+ run: "Run",
519
+ plan: "Plan"
520
+ };
521
+ function renderHeatmap(assessment, summary) {
522
+ const byKey = new Map(
523
+ summary.cells.map((c) => [c.key, { level: c.level, trajectory: c.trajectory }])
524
+ );
525
+ const lines = [];
526
+ const header = ` ${LENSES.map((l) => LENS_HEADERS[l].padEnd(12)).join("")}`;
527
+ lines.push(pc.bold(header));
528
+ for (const area of AREAS) {
529
+ const cells = LENSES.map((lens) => {
530
+ const c = byKey.get(`${area}.${lens}`);
531
+ if (!c) return "".padEnd(12);
532
+ return cell(c.level, c.trajectory).padEnd(20);
533
+ });
534
+ lines.push(`${pc.bold(AREA_HEADERS[area].padEnd(8))}${cells.join("")}`);
535
+ }
536
+ const counts = summary.trajectoryCounts;
537
+ const countLine = `\u2B06 ${counts.up} \u27A1 ${counts.flat} \u2B07 ${counts.down} \u26A0 ${counts.volatile}`;
538
+ const constraint = bindingConstraint(assessment);
539
+ lines.push("");
540
+ lines.push(`Average: ${pc.bold(formatAverage(summary.average))} / 5 ${countLine}`);
541
+ lines.push(
542
+ `Biggest constraint: ${pc.bold(constraint.key)} (level ${constraint.level}, ${constraint.trajectory})`
543
+ );
544
+ return lines.join("\n");
545
+ }
546
+
547
+ // src/commands/board.ts
548
+ function runBoard(opts = {}) {
549
+ const root = opts.root ?? process.cwd();
550
+ requireInit(root);
551
+ requireAssessments(listAssessmentDates(root));
552
+ const assessment = latestAssessment(root);
553
+ if (!assessment) {
554
+ throw new Error("unreachable");
555
+ }
556
+ const summary = summarise(assessment);
557
+ const text2 = renderHeatmap(assessment, summary);
558
+ if (opts.html) {
559
+ const rubric = loadRubric(runboardPaths(root).rubric);
560
+ const htmlPath = runboardPaths(root).boardHtml;
561
+ writeFileSync2(htmlPath, renderBoardHtml(assessment, summary, rubric), "utf8");
562
+ return { text: text2, assessment, summary, htmlPath };
563
+ }
564
+ return { text: text2, assessment, summary };
565
+ }
566
+ function registerBoard(program) {
567
+ program.command("board").description("Render the 3\xD73 heatmap; --html writes a shareable board.html.").option("--html", "also write a self-contained board.html").action((options) => {
568
+ const { text: text2, htmlPath } = runBoard({ html: options.html });
569
+ process.stdout.write(`${text2}
570
+ `);
571
+ if (htmlPath) {
572
+ process.stdout.write(`
573
+ Wrote ${htmlPath}
574
+ `);
575
+ }
576
+ });
577
+ }
578
+
579
+ // src/commands/pulse.ts
580
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync3 } from "fs";
581
+ import path5 from "path";
582
+
583
+ // src/core/triggers.ts
584
+ var TRIGGER_WINDOW = 3;
585
+ var MAX_LEVEL = 5;
586
+ function detectTriggers(assessmentsChrono) {
587
+ if (assessmentsChrono.length < TRIGGER_WINDOW) {
588
+ return [];
589
+ }
590
+ const window = assessmentsChrono.slice(-TRIGGER_WINDOW);
591
+ const triggered = [];
592
+ for (const key of DIMENSION_KEYS) {
593
+ const levels = window.map((a) => a.scores[key].level);
594
+ if (levels[levels.length - 1] >= MAX_LEVEL) {
595
+ continue;
596
+ }
597
+ let stuck = true;
598
+ for (let i = 1; i < levels.length; i++) {
599
+ if (levels[i] > levels[i - 1]) {
600
+ stuck = false;
601
+ break;
602
+ }
603
+ }
604
+ if (stuck) {
605
+ triggered.push(key);
606
+ }
607
+ }
608
+ return triggered;
609
+ }
610
+
611
+ // src/render/reports.ts
612
+ import { readFileSync as readFileSync3 } from "fs";
613
+ import path4 from "path";
614
+ import { fileURLToPath as fileURLToPath2 } from "url";
615
+ import { Eta } from "eta";
616
+
617
+ // src/core/delta.ts
618
+ function computeDeltas(previous, current) {
619
+ return DIMENSION_KEYS.map((key) => {
620
+ const from = previous.scores[key];
621
+ const to = current.scores[key];
622
+ return {
623
+ key,
624
+ from: from.level,
625
+ to: to.level,
626
+ change: to.level - from.level,
627
+ trajectoryFrom: from.trajectory,
628
+ trajectoryTo: to.trajectory
629
+ };
630
+ });
631
+ }
632
+
633
+ // src/render/reports.ts
634
+ var NOW_LIMIT = 3;
635
+ var NEXT_LIMIT = 5;
636
+ function templatesDir() {
637
+ const here = path4.dirname(fileURLToPath2(import.meta.url));
638
+ const candidates = [path4.resolve(here, "../templates"), path4.resolve(here, "../../templates")];
639
+ for (const candidate of candidates) {
640
+ try {
641
+ readFileSync3(path4.join(candidate, "roadmap.eta"));
642
+ return candidate;
643
+ } catch {
644
+ }
645
+ }
646
+ return candidates[candidates.length - 1];
647
+ }
648
+ function eta() {
649
+ return new Eta({ views: templatesDir(), autoTrim: false });
650
+ }
651
+ function title(rubric, key) {
652
+ return rubric.dimensions.find((d) => d.key === key)?.title ?? key;
653
+ }
654
+ var OUTCOMES = {
655
+ "build.team": "Build retained engineering capability so delivery no longer depends on individuals.",
656
+ "build.tools": "Make builds and environments reproducible so releases are predictable.",
657
+ "build.techniques": "Establish a consistent delivery cadence so output is reliable.",
658
+ "run.team": "Put clear production ownership in place so incidents are handled, not improvised.",
659
+ "run.tools": "Extend monitoring so problems are caught before customers feel them.",
660
+ "run.techniques": "Adopt a defined incident process so outages shrink and don't recur.",
661
+ "plan.team": "Strengthen leadership\u2013business alignment so engineering is trusted and heard.",
662
+ "plan.tools": "Maintain a live roadmap so priorities are visible and defensible.",
663
+ "plan.techniques": "Set a clear strategy and prioritisation method so investment follows value."
664
+ };
665
+ function areaAverage(assessment, area) {
666
+ const levels = DIMENSION_KEYS.filter((k) => k.startsWith(`${area}.`)).map(
667
+ (k) => assessment.scores[k].level
668
+ );
669
+ return levels.reduce((s, l) => s + l, 0) / levels.length;
670
+ }
671
+ function buildRoadmap(assessment, rubric) {
672
+ const ranked = rankConstraints(assessment);
673
+ const belowTarget = ranked.filter((r) => r.level < 3);
674
+ const nowRanked = (belowTarget.length ? belowTarget : ranked.slice(0, 1)).slice(0, NOW_LIMIT);
675
+ const nowKeys = new Set(nowRanked.map((r) => r.key));
676
+ const remaining = ranked.filter((r) => !nowKeys.has(r.key));
677
+ const nextRanked = remaining.filter((r) => r.level <= 3).slice(0, NEXT_LIMIT);
678
+ const nextKeys = new Set(nextRanked.map((r) => r.key));
679
+ const laterRanked = remaining.filter((r) => !nextKeys.has(r.key));
680
+ const now = nowRanked.map((r) => OUTCOMES[r.key]);
681
+ const next = nextRanked.map((r) => OUTCOMES[r.key]);
682
+ const later = laterRanked.map(
683
+ (r) => `Sustain ${title(rubric, r.key)} (already at level ${r.level}).`
684
+ );
685
+ const constraint = bindingConstraint(assessment);
686
+ const text2 = eta().render("roadmap", {
687
+ date: assessment.date,
688
+ constraint: { ...constraint, title: title(rubric, constraint.key) },
689
+ now,
690
+ next,
691
+ later
692
+ });
693
+ return { text: text2, now, next, later };
694
+ }
695
+ function buildPulse(previous, current, history) {
696
+ const deltas = computeDeltas(previous, current);
697
+ const triggers = detectTriggers(history);
698
+ const improved = deltas.filter((d) => d.change > 0).map((d) => d.key);
699
+ const regressed = deltas.filter((d) => d.change < 0).map((d) => d.key);
700
+ return eta().render("pulse", {
701
+ date: current.date,
702
+ prevDate: previous.date,
703
+ average: formatAverage(summarise(current).average),
704
+ prevAverage: formatAverage(summarise(previous).average),
705
+ deltas,
706
+ triggers,
707
+ improved,
708
+ regressed
709
+ });
710
+ }
711
+ function buildReport(type, assessment, rubric, history) {
712
+ const summary = summarise(assessment);
713
+ const average = formatAverage(summary.average);
714
+ const constraint = bindingConstraint(assessment);
715
+ const roadmap = buildRoadmap(assessment, rubric);
716
+ if (type === "board-update") {
717
+ return eta().render("board-update", {
718
+ date: assessment.date,
719
+ average,
720
+ headline: summary.average >= 3 ? "We are at or above our target operating level." : "We are below our target operating level and investing to close the gap.",
721
+ areas: AREAS.map((area) => ({
722
+ title: area[0]?.toUpperCase() + area.slice(1),
723
+ average: formatAverage(areaAverage(assessment, area)),
724
+ comment: areaAverage(assessment, area) >= 3 ? "Operating reliably." : "Needs investment."
725
+ })),
726
+ constraint: { ...constraint, title: title(rubric, constraint.key) },
727
+ constraintBusiness: OUTCOMES[constraint.key],
728
+ now: roadmap.now,
729
+ triggers: detectTriggers(history).map((k) => title(rubric, k))
730
+ });
731
+ }
732
+ if (type === "baseline") {
733
+ return eta().render("baseline", {
734
+ date: assessment.date,
735
+ average,
736
+ rows: DIMENSION_KEYS.map((k) => ({
737
+ title: title(rubric, k),
738
+ level: assessment.scores[k].level,
739
+ trajectory: assessment.scores[k].trajectory,
740
+ evidence: assessment.scores[k].evidence
741
+ })),
742
+ constraint: { ...constraint, title: title(rubric, constraint.key) }
743
+ });
744
+ }
745
+ if (type === "monthly") {
746
+ return eta().render("monthly", {
747
+ date: assessment.date,
748
+ average,
749
+ areas: AREAS.map((area) => ({
750
+ title: area[0]?.toUpperCase() + area.slice(1),
751
+ average: formatAverage(areaAverage(assessment, area))
752
+ })),
753
+ constraint: { ...constraint, title: title(rubric, constraint.key) }
754
+ });
755
+ }
756
+ throw new UserError(`Unknown report type "${type}". Use board-update, baseline, or monthly.`);
757
+ }
758
+
759
+ // src/commands/pulse.ts
760
+ function runPulse(opts = {}) {
761
+ const root = opts.root ?? process.cwd();
762
+ requireInit(root);
763
+ const all = loadAllAssessments(root);
764
+ if (all.length < 2) {
765
+ throw new UserError(
766
+ "Pulse needs at least two assessments to compare. Run `runboard assess` again later."
767
+ );
768
+ }
769
+ const current = all[all.length - 1];
770
+ const previous = all[all.length - 2];
771
+ if (!current || !previous) throw new Error("unreachable");
772
+ const text2 = buildPulse(previous, current, all);
773
+ const triggers = detectTriggers(all);
774
+ const reportsDir = runboardPaths(root).reportsDir;
775
+ mkdirSync2(reportsDir, { recursive: true });
776
+ const file = path5.join(reportsDir, `pulse-${current.date}.md`);
777
+ writeFileSync3(file, text2, "utf8");
778
+ return { path: file, text: text2, triggers };
779
+ }
780
+ function registerPulse(program) {
781
+ program.command("pulse").description("Compare the two latest assessments; flag stuck dimensions.").action(() => {
782
+ const { path: file, triggers } = runPulse();
783
+ process.stdout.write(`Wrote ${file}
784
+ `);
785
+ if (triggers.length > 0) {
786
+ process.stdout.write(`
787
+ Auto-triggers (stuck 3 assessments): ${triggers.join(", ")}
788
+ `);
789
+ } else {
790
+ process.stdout.write("\nNothing is stuck across the last three assessments.\n");
791
+ }
792
+ });
793
+ }
794
+
795
+ // src/commands/report.ts
796
+ import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync4 } from "fs";
797
+ import path6 from "path";
798
+ function runReport(opts) {
799
+ const root = opts.root ?? process.cwd();
800
+ requireInit(root);
801
+ requireAssessments(listAssessmentDates(root));
802
+ if (!opts.type) {
803
+ throw new UserError("report requires --type board-update|baseline|monthly.");
804
+ }
805
+ const assessment = latestAssessment(root);
806
+ if (!assessment) throw new Error("unreachable");
807
+ const rubric = loadRubric(runboardPaths(root).rubric);
808
+ const history = loadAllAssessments(root);
809
+ const text2 = buildReport(opts.type, assessment, rubric, history);
810
+ const reportsDir = runboardPaths(root).reportsDir;
811
+ mkdirSync3(reportsDir, { recursive: true });
812
+ const file = path6.join(reportsDir, `${opts.type}-${assessment.date}.md`);
813
+ writeFileSync4(file, text2, "utf8");
814
+ return { path: file, text: text2 };
815
+ }
816
+ function registerReport(program) {
817
+ program.command("report").description("Render a report from a template (board-update | baseline | monthly).").requiredOption("--type <type>", "board-update | baseline | monthly").action((options) => {
818
+ const { path: path7 } = runReport({ type: options.type });
819
+ process.stdout.write(`Wrote ${path7}
820
+ `);
821
+ });
822
+ }
823
+
824
+ // src/commands/roadmap.ts
825
+ import { writeFileSync as writeFileSync5 } from "fs";
826
+ function runRoadmap(opts = {}) {
827
+ const root = opts.root ?? process.cwd();
828
+ requireInit(root);
829
+ requireAssessments(listAssessmentDates(root));
830
+ const assessment = latestAssessment(root);
831
+ if (!assessment) throw new Error("unreachable");
832
+ const rubric = loadRubric(runboardPaths(root).rubric);
833
+ const { text: text2 } = buildRoadmap(assessment, rubric);
834
+ const file = runboardPaths(root).roadmap;
835
+ writeFileSync5(file, text2, "utf8");
836
+ return { path: file, text: text2 };
837
+ }
838
+ function registerRoadmap(program) {
839
+ program.command("roadmap").description("Generate a Now/Next/Later plan from your binding constraint.").action(() => {
840
+ const { path: path7 } = runRoadmap();
841
+ process.stdout.write(`Wrote ${path7}
842
+ `);
843
+ });
844
+ }
845
+
846
+ // src/commands/status.ts
847
+ function runStatus(root = process.cwd()) {
848
+ requireInit(root);
849
+ const all = loadAllAssessments(root);
850
+ if (all.length === 0) {
851
+ return { activeTriggers: [], empty: true };
852
+ }
853
+ const latest = all[all.length - 1];
854
+ if (!latest) throw new Error("unreachable");
855
+ const summary = summarise(latest);
856
+ return {
857
+ latestDate: latest.date,
858
+ average: formatAverage(summary.average),
859
+ trajectoryCounts: summary.trajectoryCounts,
860
+ activeTriggers: detectTriggers(all),
861
+ empty: false
862
+ };
863
+ }
864
+ function registerStatus(program) {
865
+ program.command("status").description("One-screen current state.").action(() => {
866
+ const s = runStatus();
867
+ if (s.empty) {
868
+ process.stdout.write("No assessments yet. Run `runboard assess` to record one.\n");
869
+ return;
870
+ }
871
+ const counts = s.trajectoryCounts ?? {};
872
+ process.stdout.write(
873
+ [
874
+ `Latest assessment: ${s.latestDate}`,
875
+ `Average: ${s.average} / 5`,
876
+ `Trajectory: \u2B06 ${counts.up ?? 0} \u27A1 ${counts.flat ?? 0} \u2B07 ${counts.down ?? 0} \u26A0 ${counts.volatile ?? 0}`,
877
+ `Active triggers: ${s.activeTriggers.length ? s.activeTriggers.join(", ") : "none"}`,
878
+ ""
879
+ ].join("\n")
880
+ );
881
+ });
882
+ }
883
+
884
+ export {
885
+ runboardPaths,
886
+ latestAssessment,
887
+ shippedRubricPath,
888
+ loadRubric,
889
+ UserError,
890
+ runAssess,
891
+ registerAssess,
892
+ formatAverage,
893
+ bindingConstraint,
894
+ runBoard,
895
+ registerBoard,
896
+ runPulse,
897
+ registerPulse,
898
+ runReport,
899
+ registerReport,
900
+ runRoadmap,
901
+ registerRoadmap,
902
+ runStatus,
903
+ registerStatus
904
+ };