@xhulz/nina 0.52.0 → 0.53.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 (144) hide show
  1. package/README.md +4 -0
  2. package/bin/nina.mjs +29 -5
  3. package/package.json +1 -1
  4. package/releases/0.53.0/core/GAPS.md +125 -0
  5. package/releases/0.53.0/core/answered.json +68 -0
  6. package/releases/0.53.0/core/defaults/tree/.claude/agents/architect.md +1 -0
  7. package/releases/0.53.0/core/defaults/tree/.claude/agents/dba.md +3 -0
  8. package/releases/0.53.0/core/defaults/tree/.claude/agents/devops.md +1 -0
  9. package/releases/0.53.0/core/defaults/tree/.claude/agents/implementer.md +1 -0
  10. package/releases/0.53.0/core/defaults/tree/.claude/agents/integration-tester.md +3 -0
  11. package/releases/0.53.0/core/defaults/tree/.claude/agents/qa.md +7 -0
  12. package/releases/0.53.0/core/defaults/tree/.claude/agents/reviewer.md +3 -0
  13. package/releases/0.53.0/core/defaults/tree/.claude/agents/secops.md +3 -0
  14. package/releases/0.53.0/core/defaults/tree/.claude/agents/solidity-auditor.md +3 -0
  15. package/releases/0.53.0/core/defaults/tree/.claude/agents/solidity-dev.md +3 -0
  16. package/releases/0.53.0/core/tree/.claude/agents/architect.md +129 -0
  17. package/releases/0.53.0/core/tree/.claude/agents/dba.md +117 -0
  18. package/releases/0.53.0/core/tree/.claude/agents/devops.md +118 -0
  19. package/releases/0.53.0/core/tree/.claude/agents/implementer.md +163 -0
  20. package/releases/0.53.0/core/tree/.claude/agents/integration-tester.md +168 -0
  21. package/releases/0.53.0/core/tree/.claude/agents/planner.md +132 -0
  22. package/releases/0.53.0/core/tree/.claude/agents/qa.md +150 -0
  23. package/releases/0.53.0/core/tree/.claude/agents/reviewer.md +184 -0
  24. package/releases/0.53.0/core/tree/.claude/agents/secops.md +120 -0
  25. package/releases/0.53.0/core/tree/.claude/agents/solidity-auditor.md +126 -0
  26. package/releases/0.53.0/core/tree/.claude/agents/solidity-dev.md +123 -0
  27. package/releases/0.53.0/core/tree/.claude/agents-overview.md +64 -0
  28. package/releases/0.53.0/core/tree/.claude/frontend.md +10 -0
  29. package/releases/0.53.0/core/tree/.claude/graph.md +75 -0
  30. package/releases/0.53.0/core/tree/.claude/patterns.md +251 -0
  31. package/releases/0.53.0/core/tree/.claude/pills/README.md +150 -0
  32. package/releases/0.53.0/core/tree/.claude/pipeline.md +101 -0
  33. package/releases/0.53.0/core/tree/.claude/retrieval.md +103 -0
  34. package/releases/0.53.0/core/tree/.claude/router.md +202 -0
  35. package/releases/0.53.0/core/tree/.claude/templates/integration.md +46 -0
  36. package/releases/0.53.0/core/tree/CLAUDE.md +164 -0
  37. package/releases/0.53.0/core/tree/scripts/cost-watch.mjs +25 -0
  38. package/releases/0.53.0/core/tree/scripts/edit-guard.mjs +24 -0
  39. package/releases/0.53.0/core/tree/scripts/harness-check.mjs +71 -0
  40. package/releases/0.53.0/core/tree/scripts/loop-gate.mjs +19 -0
  41. package/releases/0.53.0/core/vocabulary.json +13 -0
  42. package/releases/0.53.0/surfaces/blockchain/tree/.claude/agents/architect.md +5 -0
  43. package/releases/0.53.0/surfaces/blockchain/tree/.claude/agents/devops.md +5 -0
  44. package/releases/0.53.0/surfaces/blockchain/tree/.claude/agents/reviewer.md +5 -0
  45. package/releases/0.53.0/surfaces/blockchain/tree/.claude/agents/solidity-auditor.md +18 -0
  46. package/releases/0.53.0/surfaces/blockchain/tree/.claude/agents/solidity-dev.md +16 -0
  47. package/releases/0.53.0/surfaces/blockchain/tree/.claude/agents-overview.md +6 -0
  48. package/releases/0.53.0/surfaces/blockchain/tree/.claude/graph.md +11 -0
  49. package/releases/0.53.0/surfaces/blockchain/tree/.claude/router.md +6 -0
  50. package/releases/0.53.0/surfaces/blockchain/tree/CLAUDE.md +12 -0
  51. package/releases/0.53.0/surfaces/d1/tree/.claude/agents/dba.md +15 -0
  52. package/releases/0.53.0/surfaces/d1/tree/.claude/agents/devops.md +2 -0
  53. package/releases/0.53.0/surfaces/d1/tree/.claude/agents/implementer.md +2 -0
  54. package/releases/0.53.0/surfaces/d1/tree/.claude/patterns.md +8 -0
  55. package/releases/0.53.0/surfaces/d1/tree/CLAUDE.md +2 -0
  56. package/releases/0.53.0/surfaces/db/tree/.claude/agents/architect.md +5 -0
  57. package/releases/0.53.0/surfaces/db/tree/.claude/agents/dba.md +54 -0
  58. package/releases/0.53.0/surfaces/db/tree/.claude/agents/devops.md +5 -0
  59. package/releases/0.53.0/surfaces/db/tree/.claude/agents/implementer.md +2 -0
  60. package/releases/0.53.0/surfaces/db/tree/.claude/agents/integration-tester.md +10 -0
  61. package/releases/0.53.0/surfaces/db/tree/.claude/agents/planner.md +2 -0
  62. package/releases/0.53.0/surfaces/db/tree/.claude/agents/reviewer.md +22 -0
  63. package/releases/0.53.0/surfaces/db/tree/.claude/agents/secops.md +2 -0
  64. package/releases/0.53.0/surfaces/db/tree/.claude/agents-overview.md +5 -0
  65. package/releases/0.53.0/surfaces/db/tree/.claude/graph.md +8 -0
  66. package/releases/0.53.0/surfaces/db/tree/.claude/patterns.md +22 -0
  67. package/releases/0.53.0/surfaces/db/tree/.claude/pipeline.md +13 -0
  68. package/releases/0.53.0/surfaces/db/tree/.claude/retrieval.md +5 -0
  69. package/releases/0.53.0/surfaces/db/tree/.claude/router.md +4 -0
  70. package/releases/0.53.0/surfaces/db/tree/CLAUDE.md +17 -0
  71. package/releases/0.53.0/surfaces/drizzle/tree/.claude/agents/dba.md +9 -0
  72. package/releases/0.53.0/surfaces/drizzle/tree/.claude/agents/devops.md +5 -0
  73. package/releases/0.53.0/surfaces/drizzle/tree/.claude/agents/implementer.md +2 -0
  74. package/releases/0.53.0/surfaces/drizzle/tree/.claude/patterns.md +7 -0
  75. package/releases/0.53.0/surfaces/edge-cf/tree/.claude/agents/architect.md +14 -0
  76. package/releases/0.53.0/surfaces/edge-cf/tree/.claude/agents/dba.md +2 -0
  77. package/releases/0.53.0/surfaces/edge-cf/tree/.claude/agents/devops.md +29 -0
  78. package/releases/0.53.0/surfaces/edge-cf/tree/.claude/agents/implementer.md +14 -0
  79. package/releases/0.53.0/surfaces/edge-cf/tree/.claude/agents/integration-tester.md +11 -0
  80. package/releases/0.53.0/surfaces/edge-cf/tree/.claude/agents/qa.md +3 -0
  81. package/releases/0.53.0/surfaces/edge-cf/tree/.claude/agents/reviewer.md +8 -0
  82. package/releases/0.53.0/surfaces/edge-cf/tree/.claude/agents/secops.md +8 -0
  83. package/releases/0.53.0/surfaces/edge-cf/tree/.claude/patterns.md +17 -0
  84. package/releases/0.53.0/surfaces/edge-cf/tree/.claude/retrieval.md +8 -0
  85. package/releases/0.53.0/surfaces/edge-cf/tree/CLAUDE.md +27 -0
  86. package/releases/0.53.0/surfaces/frontend/tree/.claude/agents/architect.md +20 -0
  87. package/releases/0.53.0/surfaces/frontend/tree/.claude/agents/devops.md +28 -0
  88. package/releases/0.53.0/surfaces/frontend/tree/.claude/agents/implementer.md +25 -0
  89. package/releases/0.53.0/surfaces/frontend/tree/.claude/agents/qa.md +17 -0
  90. package/releases/0.53.0/surfaces/frontend/tree/.claude/agents/reviewer.md +56 -0
  91. package/releases/0.53.0/surfaces/frontend/tree/.claude/patterns.md +8 -0
  92. package/releases/0.53.0/surfaces/frontend/tree/.claude/retrieval.md +2 -0
  93. package/releases/0.53.0/surfaces/frontend/tree/.claude/router.md +14 -0
  94. package/releases/0.53.0/surfaces/frontend/tree/CLAUDE.md +5 -0
  95. package/releases/0.53.0/surfaces/integrations/tree/.claude/agents/architect.md +16 -0
  96. package/releases/0.53.0/surfaces/integrations/tree/.claude/agents/implementer.md +12 -0
  97. package/releases/0.53.0/surfaces/integrations/tree/.claude/agents/integration-tester.md +20 -0
  98. package/releases/0.53.0/surfaces/integrations/tree/.claude/agents/planner.md +2 -0
  99. package/releases/0.53.0/surfaces/integrations/tree/.claude/agents/reviewer.md +21 -0
  100. package/releases/0.53.0/surfaces/integrations/tree/.claude/agents/secops.md +2 -0
  101. package/releases/0.53.0/surfaces/integrations/tree/.claude/agents-overview.md +8 -0
  102. package/releases/0.53.0/surfaces/integrations/tree/.claude/graph.md +10 -0
  103. package/releases/0.53.0/surfaces/integrations/tree/.claude/patterns.md +38 -0
  104. package/releases/0.53.0/surfaces/integrations/tree/.claude/pipeline.md +16 -0
  105. package/releases/0.53.0/surfaces/integrations/tree/.claude/retrieval.md +16 -0
  106. package/releases/0.53.0/surfaces/integrations/tree/.claude/router.md +6 -0
  107. package/releases/0.53.0/surfaces/integrations/tree/CLAUDE.md +17 -0
  108. package/releases/0.53.0/surfaces/money/tree/.claude/agents/architect.md +12 -0
  109. package/releases/0.53.0/surfaces/money/tree/.claude/agents/dba.md +14 -0
  110. package/releases/0.53.0/surfaces/money/tree/.claude/agents/implementer.md +11 -0
  111. package/releases/0.53.0/surfaces/money/tree/.claude/agents/integration-tester.md +3 -0
  112. package/releases/0.53.0/surfaces/money/tree/.claude/agents/reviewer.md +23 -0
  113. package/releases/0.53.0/surfaces/money/tree/.claude/agents/secops.md +5 -0
  114. package/releases/0.53.0/surfaces/money/tree/.claude/agents/solidity-auditor.md +4 -0
  115. package/releases/0.53.0/surfaces/money/tree/.claude/agents/solidity-dev.md +4 -0
  116. package/releases/0.53.0/surfaces/money/tree/.claude/graph.md +2 -0
  117. package/releases/0.53.0/surfaces/money/tree/.claude/patterns.md +49 -0
  118. package/releases/0.53.0/surfaces/money/tree/.claude/pipeline.md +2 -0
  119. package/releases/0.53.0/surfaces/money/tree/.claude/retrieval.md +2 -0
  120. package/releases/0.53.0/surfaces/money/tree/CLAUDE.md +11 -0
  121. package/releases/0.53.0/surfaces/pii/tree/.claude/agents/dba.md +2 -0
  122. package/releases/0.53.0/surfaces/pii/tree/.claude/agents/devops.md +8 -0
  123. package/releases/0.53.0/surfaces/pii/tree/.claude/agents/reviewer.md +8 -0
  124. package/releases/0.53.0/surfaces/pii/tree/.claude/agents/secops.md +2 -0
  125. package/releases/0.53.0/surfaces/pii/tree/.claude/agents/solidity-auditor.md +5 -0
  126. package/releases/0.53.0/surfaces/pii/tree/CLAUDE.md +5 -0
  127. package/releases/0.53.0/surfaces/prisma/tree/.claude/agents/architect.md +2 -0
  128. package/releases/0.53.0/surfaces/prisma/tree/.claude/agents/dba.md +42 -0
  129. package/releases/0.53.0/surfaces/prisma/tree/.claude/agents/devops.md +11 -0
  130. package/releases/0.53.0/surfaces/prisma/tree/.claude/agents/implementer.md +2 -0
  131. package/releases/0.53.0/surfaces/prisma/tree/.claude/agents/integration-tester.md +2 -0
  132. package/releases/0.53.0/surfaces/prisma/tree/.claude/agents/reviewer.md +2 -0
  133. package/releases/0.53.0/surfaces/prisma/tree/.claude/patterns.md +15 -0
  134. package/releases/0.53.0/surfaces/prisma/tree/CLAUDE.md +5 -0
  135. package/src/commands/dashboard.mjs +33 -0
  136. package/src/commands/learn.mjs +51 -19
  137. package/src/commands/pills.mjs +55 -28
  138. package/src/commands/pipeline.mjs +62 -33
  139. package/src/commands/runs.mjs +14 -3
  140. package/src/commands/stats.mjs +199 -107
  141. package/src/dashboard.html +328 -0
  142. package/src/dashboard.mjs +185 -0
  143. package/src/office.mjs +48 -83
  144. package/src/serve.mjs +108 -0
@@ -222,9 +222,15 @@ const running = (r, now) => !r.verdict && now - Date.parse(r.ts) <= RUNNING_HOUR
222
222
  *
223
223
  * @param {object[]} records - The runs in the window.
224
224
  */
225
- function costReport(records) {
225
+ /**
226
+ * What each stage's runs cost at API list prices, as data: what `stats` prints and the dashboard's stats tab shows.
227
+ * Null when no run has a token record, or none has a price.
228
+ *
229
+ * @param {object[]} records - The rounds in the window.
230
+ */
231
+ export function costData(records) {
226
232
  const measured = records.filter((r) => r.tokens && typeof r.tokens === 'object');
227
- if (measured.length === 0) return;
233
+ if (measured.length === 0) return null;
228
234
  // A run's cost is its rounds', so a reviewer resumed twice is one run at the price of three rounds.
229
235
  /** @type {Map<string, number[]>} */
230
236
  const byRole = new Map();
@@ -240,25 +246,33 @@ function costReport(records) {
240
246
  byRole.set(role, [...(byRole.get(role) ?? []), costs.reduce((a, b) => a + b, 0)]);
241
247
  }
242
248
  const total = [...byRole.values()].flat().reduce((a, b) => a + b, 0);
243
- if (total === 0) return;
249
+ if (total === 0) return null;
250
+ const rows = [...byRole]
251
+ .map(([role, costs]) => ({ role, runs: costs.length, median: median(costs), total: costs.reduce((a, b) => a + b, 0) }))
252
+ .sort((a, b) => b.total - a.total);
253
+ return { pricesAsOf: PRICES_AS_OF, measuredRuns: runs.size, allRuns: byRun(records).size, unpriced, total, rows };
254
+ }
255
+
256
+ function costReport(records) {
257
+ const d = costData(records);
258
+ if (!d) return;
244
259
  const money = (n) => `$${n >= 100 ? n.toFixed(0) : n.toFixed(2)}`;
245
260
  // The same convention as the duration column beside it: the upper middle of an even count.
246
261
  console.log(
247
262
  heading(
248
263
  'cost',
249
- `at API list prices of ${PRICES_AS_OF}; ${runs.size} of ${byRun(records).size} runs have a token record` +
250
- (unpriced > 0 ? `, ${unpriced} on a model the price table does not know` : ''),
264
+ `at API list prices of ${d.pricesAsOf}; ${d.measuredRuns} of ${d.allRuns} runs have a token record` +
265
+ (d.unpriced > 0 ? `, ${d.unpriced} on a model the price table does not know` : ''),
251
266
  ),
252
267
  );
253
268
  console.log(dim(` ${'stage'.padEnd(20)}${'runs'.padStart(6)}${'median'.padStart(10)}${'total'.padStart(10)}${'share'.padStart(8)}`));
254
- for (const [role, costs] of [...byRole].sort((a, b) => b[1].reduce((x, y) => x + y, 0) - a[1].reduce((x, y) => x + y, 0))) {
255
- const sum = costs.reduce((a, b) => a + b, 0);
269
+ for (const { role, runs, median: middle, total: sum } of d.rows) {
256
270
  console.log(
257
- ` ${pink(role)}${' '.repeat(Math.max(20 - role.length, 1))}${String(costs.length).padStart(6)}${money(median(costs)).padStart(10)}` +
258
- `${bold(money(sum).padStart(10))}${pct(sum, total).padStart(8)} ${pink(bar(sum / total, 20))}`,
271
+ ` ${pink(role)}${' '.repeat(Math.max(20 - role.length, 1))}${String(runs).padStart(6)}${money(middle).padStart(10)}` +
272
+ `${bold(money(sum).padStart(10))}${pct(sum, d.total).padStart(8)} ${pink(bar(sum / d.total, 20))}`,
259
273
  );
260
274
  }
261
- console.log(bold(` ${'all stages'.padEnd(20)}${String([...byRole.values()].flat().length).padStart(6)}${''.padStart(10)}${money(total).padStart(10)}`));
275
+ console.log(bold(` ${'all stages'.padEnd(20)}${String(d.rows.reduce((a, r) => a + r.runs, 0)).padStart(6)}${''.padStart(10)}${money(d.total).padStart(10)}`));
262
276
  }
263
277
 
264
278
  /**
@@ -270,30 +284,57 @@ function costReport(records) {
270
284
  * @param {object[]} records - The rounds in the window.
271
285
  */
272
286
  function contextReport(records) {
273
- const shaped = records.filter((r) => typeof r.turns === 'number' && r.turns > 0);
274
- if (shaped.length === 0) return;
287
+ const d = contextData(records);
288
+ if (!d) return;
275
289
  const k = (n) => (typeof n === 'number' ? `${Math.round(n / 1000)}k` : '—');
276
- const chars = (xs) => (xs.length ? median(xs).toLocaleString('en-US') : '—');
277
290
  console.log(heading('context', 'every turn re-reads the whole context: a round costs its turns times its size, and a resumed one starts where it ended; the prompt it was handed is in characters'));
278
291
  console.log(dim(` ${'stage'.padEnd(20)}${'rounds'.padEnd(8)}${'n'.padStart(4)}${'turns'.padStart(7)}${'calls/turn'.padStart(12)}${'starts at'.padStart(11)}${'peak'.padStart(8)}${'prompt'.padStart(9)}`));
292
+ let previous = null;
293
+ for (const row of d.rows) {
294
+ const name = row.role !== previous ? pink(row.role) + ' '.repeat(Math.max(20 - row.role.length, 1)) : ' '.repeat(20);
295
+ previous = row.role;
296
+ console.log(
297
+ ` ${name}${dim(row.label.padEnd(8))}${String(row.n).padStart(4)}${String(row.turns).padStart(7)}` +
298
+ `${row.callsPerTurn.toFixed(2).padStart(12)}${k(row.start).padStart(11)}${k(row.peak).padStart(8)}` +
299
+ `${(row.prompt === null ? '—' : row.prompt.toLocaleString('en-US')).padStart(9)}`,
300
+ );
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Each stage's rounds and how they spent their context, as data (see `contextReport`): the first rounds of a run
306
+ * and the resumed ones apart. Null when no round recorded its turns.
307
+ *
308
+ * @param {object[]} records - The rounds in the window.
309
+ */
310
+ export function contextData(records) {
311
+ const shaped = records.filter((r) => typeof r.turns === 'number' && r.turns > 0);
312
+ if (shaped.length === 0) return null;
279
313
  const byRole = new Map();
280
314
  for (const r of shaped) byRole.set(r.role, [...(byRole.get(r.role) ?? []), r]);
315
+ const rows = [];
281
316
  for (const [role, rounds] of [...byRole].sort((a, b) => b[1].reduce((x, r) => x + r.turns, 0) - a[1].reduce((x, r) => x + r.turns, 0))) {
282
- [
317
+ for (const [label, group] of [
283
318
  ['first', rounds.filter((r) => (r.round ?? 1) === 1)],
284
319
  ['resumed', rounds.filter((r) => (r.round ?? 1) > 1)],
285
- ].forEach(([label, group], i) => {
286
- if (group.length === 0) return;
320
+ ]) {
321
+ if (group.length === 0) continue;
287
322
  const turns = group.reduce((a, r) => a + r.turns, 0);
288
323
  const calls = group.reduce((a, r) => a + (r.tool_calls ?? 0), 0);
289
- const name = i === 0 || !rounds.some((r) => (r.round ?? 1) === 1) ? pink(role) + ' '.repeat(Math.max(20 - role.length, 1)) : ' '.repeat(20);
290
- console.log(
291
- ` ${name}${dim(label.padEnd(8))}${String(group.length).padStart(4)}${String(median(group.map((r) => r.turns))).padStart(7)}` +
292
- `${(calls / turns).toFixed(2).padStart(12)}${k(median(group.map((r) => r.context_start ?? 0))).padStart(11)}${k(median(group.map((r) => r.context_peak ?? 0))).padStart(8)}` +
293
- `${chars(group.map((r) => r.prompt_chars).filter((n) => typeof n === 'number')).padStart(9)}`,
294
- );
295
- });
324
+ const prompts = group.map((r) => r.prompt_chars).filter((n) => typeof n === 'number');
325
+ rows.push({
326
+ role,
327
+ label,
328
+ n: group.length,
329
+ turns: median(group.map((r) => r.turns)),
330
+ callsPerTurn: calls / turns,
331
+ start: median(group.map((r) => r.context_start ?? 0)),
332
+ peak: median(group.map((r) => r.context_peak ?? 0)),
333
+ prompt: prompts.length ? median(prompts) : null,
334
+ });
335
+ }
296
336
  }
337
+ return { rows };
297
338
  }
298
339
 
299
340
  /**
@@ -518,8 +559,44 @@ export function cyclesOf(records) {
518
559
  * @param {object[]} records - The runs in the window.
519
560
  */
520
561
  function proportionReport(records, stepLimit = () => null) {
562
+ const d = proportionData(records, stepLimit);
563
+ if (!d) return;
564
+ console.log(
565
+ heading(
566
+ 'proportion',
567
+ `${d.built} cycle(s) that wrote code; a cycle closes at qa PASS, devops DEPLOYED or secops SECURE, or at the next design after code` +
568
+ (d.unreadable > 0 ? `; ${d.unreadable} closed by a run whose verdict could not be read` : '') +
569
+ (d.open > 0 ? `; ${d.open} still open when their session's record ends` : ''),
570
+ ),
571
+ );
572
+ console.log(dim(` ${'largest write'.padEnd(16)}${'cycles'.padStart(8)}${'with a planner or architect'.padStart(30)}`));
573
+ for (const row of d.rows) {
574
+ const designed = `${row.designed} (${pct(row.designed, row.cycles)})`.padStart(30);
575
+ console.log(` ${row.label.padEnd(16)}${String(row.cycles).padStart(8)}${row.cycles ? designed : dim(designed)} ${pink(bar(row.cycles ? row.designed / row.cycles : 0, 10))}`);
576
+ }
577
+ console.log(dim(' A shape, not a verdict: a critical path is gated in full at any size, and one spec may cover sibling steps.'));
578
+ if (d.over) {
579
+ const read = d.over.readM === null ? '' : `, ${d.over.readM}M tokens read from cache`;
580
+ console.log(
581
+ note(
582
+ `${d.over.count} implementer round(s) wrote more files than one step may (${d.over.limit}); the largest wrote ${d.over.largest}${read}. The architect splits such a spec into steps.`,
583
+ 'warn',
584
+ ),
585
+ );
586
+ }
587
+ }
588
+
589
+ /**
590
+ * The size of each change against the chain that carried it, as data (see `proportionReport`): cycles that wrote code
591
+ * by their largest write, how many had a design stage, and the implementer rounds that wrote more files than one step
592
+ * may. Null when no cycle wrote code that was counted.
593
+ *
594
+ * @param {object[]} records - The rounds in the window.
595
+ * @param {(project: string) => number|null} [stepLimit] - The most files one implementer run may write, by project.
596
+ */
597
+ export function proportionData(records, stepLimit = () => null) {
521
598
  const measured = records.filter((r) => WRITERS.has(r.role) && typeof r.files_touched === 'number');
522
- if (measured.length === 0) return;
599
+ if (measured.length === 0) return null;
523
600
  const cycles = cyclesOf(records);
524
601
  const rows = [
525
602
  { label: '1–2 files', from: 1, to: 2 },
@@ -541,22 +618,7 @@ function proportionReport(records, stepLimit = () => null) {
541
618
  row.cycles += 1;
542
619
  if (cycle.some((r) => DESIGNERS.has(r.role))) row.designed += 1;
543
620
  }
544
- if (built === 0) return;
545
- console.log(
546
- heading(
547
- 'proportion',
548
- `${built} cycle(s) that wrote code; a cycle closes at qa PASS, devops DEPLOYED or secops SECURE, or at the next design after code` +
549
- (unreadable > 0 ? `; ${unreadable} closed by a run whose verdict could not be read` : '') +
550
- (open > 0 ? `; ${open} still open when their session's record ends` : ''),
551
- ),
552
- );
553
- console.log(dim(` ${'largest write'.padEnd(16)}${'cycles'.padStart(8)}${'with a planner or architect'.padStart(30)}`));
554
- for (const row of rows) {
555
- const designed = `${row.designed} (${pct(row.designed, row.cycles)})`.padStart(30);
556
- console.log(` ${row.label.padEnd(16)}${String(row.cycles).padStart(8)}${row.cycles ? designed : dim(designed)} ${pink(bar(row.cycles ? row.designed / row.cycles : 0, 10))}`);
557
- }
558
- console.log(dim(' A shape, not a verdict: a critical path is gated in full at any size, and one spec may cover sibling steps.'));
559
-
621
+ if (built === 0) return null;
560
622
  // The one size the harness does limit: what one implementer run writes. A run re-reads the context it
561
623
  // has built on every turn, so its cost grows faster than its size — measured over two projects, the
562
624
  // cache read per file held near 1M up to 19 files and was 6.9M in a 52-file run. Asked only of a project
@@ -566,16 +628,16 @@ function proportionReport(records, stepLimit = () => null) {
566
628
  const limit = stepLimit(r.project);
567
629
  return limit !== null && r.files_touched > limit;
568
630
  });
569
- if (over.length > 0) {
570
- const largest = over.reduce((a, b) => (b.files_touched > a.files_touched ? b : a));
571
- const read = typeof largest.tokens?.read === 'number' ? `, ${Math.round(largest.tokens.read / 1e6)}M tokens read from cache` : '';
572
- console.log(
573
- note(
574
- `${over.length} implementer round(s) wrote more files than one step may (${stepLimit(largest.project)}); the largest wrote ${largest.files_touched}${read}. The architect splits such a spec into steps.`,
575
- 'warn',
576
- ),
577
- );
578
- }
631
+ const largest = over.length ? over.reduce((a, b) => (b.files_touched > a.files_touched ? b : a)) : null;
632
+ return {
633
+ rows: rows.map(({ label, cycles: n, designed }) => ({ label, cycles: n, designed })),
634
+ built,
635
+ unreadable,
636
+ open,
637
+ over: largest
638
+ ? { count: over.length, limit: stepLimit(largest.project), largest: largest.files_touched, readM: typeof largest.tokens?.read === 'number' ? Math.round(largest.tokens.read / 1e6) : null }
639
+ : null,
640
+ };
579
641
  }
580
642
 
581
643
  /**
@@ -607,13 +669,74 @@ function stepLimitOf(ctx) {
607
669
  };
608
670
  }
609
671
 
672
+ /**
673
+ * Each stage's rounds and what came of them, as data: what `stats` prints in its stages table and the dashboard's
674
+ * stats tab shows. Counted in rounds — the verdicts are the rounds', and the loop gate counts rounds — with the runs
675
+ * they belong to; and the rounds of agents outside the pipeline apart, which have no verdict to give.
676
+ *
677
+ * @param {object[]} records - The rounds in the window.
678
+ * @param {number} [now] - Against which a round with no report is still running or lost.
679
+ */
680
+ export function stageData(records, now = Date.now()) {
681
+ /** @type {Map<string, {n:number, runs:Set<string>, done:number, lost:number, clear:number, loop:number, unclear:number, declared:number, durations:number[]}>} */
682
+ const byRole = new Map();
683
+ // Agents outside the pipeline — Claude Code's own, a project's own — have no verdict to give. Read as
684
+ // stages they were 100% unreadable and were told to add a verdict token their spec never asked for.
685
+ const outside = new Map();
686
+ for (const r of records.filter((r) => r.status !== 'denied')) {
687
+ if (!PIPELINE_ROLES.has(r.role)) {
688
+ outside.set(r.role, (outside.get(r.role) ?? 0) + 1);
689
+ continue;
690
+ }
691
+ if (!byRole.has(r.role)) {
692
+ byRole.set(r.role, { n: 0, runs: new Set(), done: 0, lost: 0, clear: 0, loop: 0, unclear: 0, declared: 0, durations: [] });
693
+ }
694
+ const s = byRole.get(r.role);
695
+ s.n += 1;
696
+ s.runs.add(runOf(r));
697
+ if (!r.verdict && !running(r, now)) s.lost += 1;
698
+ if (r.verdict) s.done += 1;
699
+ if (r.verdict_source === 'declared' || r.verdict_source === 'handback') s.declared += 1;
700
+ if (r.verdict === 'UNCLEAR' || r.verdict === 'NONE') s.unclear += 1;
701
+ else if (r.verdict) {
702
+ s.clear += 1;
703
+ if (isLoopBack(r.verdict)) s.loop += 1;
704
+ }
705
+ if (typeof r.duration_s === 'number') s.durations.push(r.duration_s);
706
+ }
707
+ const rows = [...byRole.entries()]
708
+ .sort((a, b) => b[1].n - a[1].n)
709
+ .map(([role, s]) => {
710
+ const durations = s.durations.filter((v) => typeof v === 'number' && v >= 0);
711
+ return { role, runs: s.runs.size, rounds: s.n, done: s.done, lost: s.lost, clear: s.clear, loop: s.loop, unclear: s.unclear, declared: s.declared, median_s: durations.length ? median(durations) : null };
712
+ });
713
+ return { rows, outside: [...outside.entries()].sort((a, b) => b[1] - a[1]) };
714
+ }
715
+
610
716
  /** The middle of a list of numbers — the upper one of two — or undefined for none. */
611
717
  const median = (xs) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)];
612
718
 
613
- /** Formats a median duration in minutes from a list of seconds. */
614
- function medianMin(values) {
615
- const xs = values.filter((v) => typeof v === 'number' && v >= 0);
616
- return xs.length ? `${(median(xs) / 60).toFixed(1)}m` : '—';
719
+ /**
720
+ * One project's measured history, as the dashboard's stats tab shows it: the same sections `stats` prints — its
721
+ * stages, what they cost, how they spent their context, and the size of each change against its chain — each from
722
+ * the function the report prints from. Null when nothing is recorded.
723
+ *
724
+ * @param {object[]} records - The project's rounds, oldest first.
725
+ * @param {{root: string}} ctx - CLI context, for the step limit its pinned release states.
726
+ */
727
+ export function statsData(records, ctx) {
728
+ if (records.length === 0) return null;
729
+ const ran = records.filter((r) => r.status !== 'denied');
730
+ return {
731
+ runs: byRun(ran).size,
732
+ rounds: ran.length,
733
+ denied: records.length - ran.length,
734
+ span: [records[0]?.ts, records.at(-1)?.ts].map(localDay),
735
+ stages: stageData(records),
736
+ cost: costData(ran),
737
+ context: contextData(ran),
738
+ proportion: proportionData(ran, stepLimitOf(ctx)),
739
+ };
617
740
  }
618
741
 
619
742
  /**
@@ -687,40 +810,9 @@ export async function stats(argv, ctx) {
687
810
 
688
811
  const skipped = new Set(all.map((r) => r.project)).size - new Set(records.map((r) => r.project)).size;
689
812
 
690
- /**
691
- * Per stage, counted in rounds: the verdicts are the rounds', and the loop gate counts rounds.
692
- *
693
- * @type {Map<string, {n:number, runs:Set<string>, done:number, lost:number, clear:number, loop:number,
694
- * unclear:number, declared:number, durations:number[]}>}
695
- */
696
- const byRole = new Map();
697
813
  // A dispatch a hook denied never ran, so it is neither a run nor a missing verdict.
698
814
  const held = records.filter((r) => r.status === 'denied').length;
699
- const now = Date.now();
700
- // Agents outside the pipeline — Claude Code's own, a project's own — have no verdict to give. Read as
701
- // stages they were 100% unreadable and were told to add a verdict token their spec never asked for.
702
- const outside = new Map();
703
- for (const r of records.filter((r) => r.status !== 'denied')) {
704
- if (!PIPELINE_ROLES.has(r.role)) {
705
- outside.set(r.role, (outside.get(r.role) ?? 0) + 1);
706
- continue;
707
- }
708
- if (!byRole.has(r.role)) {
709
- byRole.set(r.role, { n: 0, runs: new Set(), done: 0, lost: 0, clear: 0, loop: 0, unclear: 0, declared: 0, durations: [] });
710
- }
711
- const s = byRole.get(r.role);
712
- s.n += 1;
713
- s.runs.add(runOf(r));
714
- if (!r.verdict && !running(r, now)) s.lost += 1;
715
- if (r.verdict) s.done += 1;
716
- if (r.verdict_source === 'declared' || r.verdict_source === 'handback') s.declared += 1;
717
- if (r.verdict === 'UNCLEAR' || r.verdict === 'NONE') s.unclear += 1;
718
- else if (r.verdict) {
719
- s.clear += 1;
720
- if (isLoopBack(r.verdict)) s.loop += 1;
721
- }
722
- if (typeof r.duration_s === 'number') s.durations.push(r.duration_s);
723
- }
815
+ const { rows, outside } = stageData(records);
724
816
 
725
817
  const span = [records[0]?.ts, records.at(-1)?.ts].map(localDay);
726
818
  const projectCount = new Set(records.map((r) => r.project)).size;
@@ -741,38 +833,37 @@ export async function stats(argv, ctx) {
741
833
  ),
742
834
  );
743
835
 
744
- const rows = [...byRole.entries()].sort((a, b) => b[1].n - a[1].n);
745
836
  // Each stage's runs as one bar, as long as its share of the busiest stage's: what went forward, what
746
837
  // was sent back, and what said nothing readable (or is still running).
747
- const busiest = Math.max(...rows.map(([, s]) => s.n), 1);
838
+ const busiest = Math.max(...rows.map((s) => s.rounds), 1);
748
839
  const zero = (text, n) => (n === 0 ? dim(text) : text);
749
- for (const [role, s] of rows) {
840
+ for (const s of rows) {
750
841
  const runsBar = stacked(
751
842
  [
752
843
  { n: s.clear - s.loop, char: '█', paint: pink },
753
844
  { n: s.loop, char: '▓', paint: amber },
754
- { n: s.n - s.clear, char: '░', paint: dim },
845
+ { n: s.rounds - s.clear, char: '░', paint: dim },
755
846
  ],
756
- Math.max(1, Math.round((16 * s.n) / busiest)),
847
+ Math.max(1, Math.round((16 * s.rounds) / busiest)),
757
848
  );
758
849
  const declared = pct(s.declared, s.done).padStart(10);
759
850
  const unread = pct(s.unclear + s.lost, s.done + s.lost).padStart(12);
760
851
  console.log(
761
- ` ${pink(role)}${' '.repeat(Math.max(20 - role.length, 1))}${String(s.runs.size).padStart(6)}${String(s.n).padStart(9)}` +
852
+ ` ${pink(s.role)}${' '.repeat(Math.max(20 - s.role.length, 1))}${String(s.runs).padStart(6)}${String(s.rounds).padStart(9)}` +
762
853
  `${zero(String(s.loop).padStart(11), s.loop)}${zero(pct(s.loop, s.clear).padStart(7), s.loop)}` +
763
854
  `${s.declared < s.done ? amber(declared) : dim(declared)}${s.unclear + s.lost > 0 ? amber(unread) : dim(unread)}` +
764
- `${medianMin(s.durations).padStart(9)} ${runsBar}`,
855
+ `${(s.median_s === null ? '—' : `${(s.median_s / 60).toFixed(1)}m`).padStart(9)} ${runsBar}`,
765
856
  );
766
857
  }
767
858
  console.log(dim(` ${' '.repeat(86)}█ forward ▓ sent back ░ no verdict read`));
768
859
 
769
- const unreadable = rows.reduce((a, [, s]) => a + s.unclear, 0);
770
- const lost = rows.reduce((a, [, s]) => a + s.lost, 0);
771
- const done = rows.reduce((a, [, s]) => a + s.done, 0);
860
+ const unreadable = rows.reduce((a, s) => a + s.unclear, 0);
861
+ const lost = rows.reduce((a, s) => a + s.lost, 0);
862
+ const done = rows.reduce((a, s) => a + s.done, 0);
772
863
  console.log('');
773
- if (outside.size > 0) {
774
- const named = [...outside.entries()].sort((a, b) => b[1] - a[1]).map(([role, n]) => `${role} ×${n}`).join(', ');
775
- console.log(note(`${[...outside.values()].reduce((a, b) => a + b, 0)} round(s) by agents outside the pipeline (${named}) — not stages, so no verdict is asked of them.`, 'info'));
864
+ if (outside.length > 0) {
865
+ const named = outside.map(([role, n]) => `${role} ×${n}`).join(', ');
866
+ console.log(note(`${outside.reduce((a, [, n]) => a + n, 0)} round(s) by agents outside the pipeline (${named}) — not stages, so no verdict is asked of them.`, 'info'));
776
867
  }
777
868
  if (unreadable === 0 && lost === 0) console.log(note("every finished round's verdict could be read.", 'ok'));
778
869
  // The same share as the table's column: rounds that finished, or should have, with no verdict read.
@@ -819,20 +910,21 @@ export async function stats(argv, ctx) {
819
910
 
820
911
  // A gate that never stops anything is either upstream quality or a rubber stamp, and the
821
912
  // rate alone cannot say which — but it can say which gate to go and look at.
822
- const gates = rows.filter(([role]) => GATES.has(role));
823
- for (const [role, s] of gates) {
913
+ const gates = rows.filter((s) => GATES.has(s.role));
914
+ for (const s of gates) {
915
+ const { role } = s;
824
916
  if (s.clear < GATE_SAMPLE || s.loop / s.clear >= GATE_FLOOR) continue;
825
- const others = gates.filter(([other]) => other !== role);
917
+ const others = gates.filter((o) => o.role !== role);
826
918
  const elsewhere = pct(
827
- others.reduce((a, [, o]) => a + o.loop, 0),
828
- others.reduce((a, [, o]) => a + o.clear, 0),
919
+ others.reduce((a, o) => a + o.loop, 0),
920
+ others.reduce((a, o) => a + o.clear, 0),
829
921
  );
830
922
  console.log(
831
923
  note(
832
924
  `${role}: ${s.loop} loop-back(s) in ${s.clear} readable verdict(s) (${pct(s.loop, s.clear)}),` +
833
925
  ` against ${elsewhere} across the other gates — check whether it still gates anything.` +
834
- (s.n - s.clear > 0
835
- ? ` ${s.n - s.clear} further round(s) produced no readable verdict at all, so the rate may understate it.`
926
+ (s.rounds - s.clear > 0
927
+ ? ` ${s.rounds - s.clear} further round(s) produced no readable verdict at all, so the rate may understate it.`
836
928
  : '') +
837
929
  // A rate built from verdicts a parser guessed at is not the same claim as one built
838
930
  // from verdicts the stage declared, and the difference decides whether it can be acted on.
@@ -852,7 +944,7 @@ export async function stats(argv, ctx) {
852
944
  // What the pipeline learned, against what it had to learn from. A loop-back is the raw
853
945
  // material and a pill is the product, so the two numbers belong on the same screen: the
854
946
  // rule that turns one into the other is the only rule here that nothing else can check.
855
- const loopBacks = rows.reduce((a, [, s]) => a + s.loop, 0);
947
+ const loopBacks = rows.reduce((a, s) => a + s.loop, 0);
856
948
  const pills = await harvest([...new Set(records.map((r) => r.project))]);
857
949
  const newest = pills.dates.at(-1);
858
950
  const inWindow = pills.dates.filter((d) => d >= span[0] && d <= span[1]).length;