@xhulz/nina 0.35.0 → 0.36.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 (134) hide show
  1. package/bin/nina.mjs +4 -1
  2. package/package.json +1 -1
  3. package/releases/0.36.0/core/GAPS.md +125 -0
  4. package/releases/0.36.0/core/answered.json +29 -0
  5. package/releases/0.36.0/core/defaults/tree/.claude/agents/architect.md +1 -0
  6. package/releases/0.36.0/core/defaults/tree/.claude/agents/dba.md +3 -0
  7. package/releases/0.36.0/core/defaults/tree/.claude/agents/devops.md +1 -0
  8. package/releases/0.36.0/core/defaults/tree/.claude/agents/implementer.md +1 -0
  9. package/releases/0.36.0/core/defaults/tree/.claude/agents/integration-tester.md +3 -0
  10. package/releases/0.36.0/core/defaults/tree/.claude/agents/qa.md +2 -0
  11. package/releases/0.36.0/core/defaults/tree/.claude/agents/reviewer.md +3 -0
  12. package/releases/0.36.0/core/defaults/tree/.claude/agents/secops.md +3 -0
  13. package/releases/0.36.0/core/defaults/tree/.claude/agents/solidity-auditor.md +3 -0
  14. package/releases/0.36.0/core/defaults/tree/.claude/agents/solidity-dev.md +3 -0
  15. package/releases/0.36.0/core/tree/.claude/agents/architect.md +127 -0
  16. package/releases/0.36.0/core/tree/.claude/agents/dba.md +110 -0
  17. package/releases/0.36.0/core/tree/.claude/agents/devops.md +114 -0
  18. package/releases/0.36.0/core/tree/.claude/agents/implementer.md +156 -0
  19. package/releases/0.36.0/core/tree/.claude/agents/integration-tester.md +168 -0
  20. package/releases/0.36.0/core/tree/.claude/agents/planner.md +132 -0
  21. package/releases/0.36.0/core/tree/.claude/agents/qa.md +153 -0
  22. package/releases/0.36.0/core/tree/.claude/agents/reviewer.md +183 -0
  23. package/releases/0.36.0/core/tree/.claude/agents/secops.md +121 -0
  24. package/releases/0.36.0/core/tree/.claude/agents/solidity-auditor.md +126 -0
  25. package/releases/0.36.0/core/tree/.claude/agents/solidity-dev.md +123 -0
  26. package/releases/0.36.0/core/tree/.claude/agents-overview.md +64 -0
  27. package/releases/0.36.0/core/tree/.claude/frontend.md +10 -0
  28. package/releases/0.36.0/core/tree/.claude/graph.md +75 -0
  29. package/releases/0.36.0/core/tree/.claude/patterns.md +213 -0
  30. package/releases/0.36.0/core/tree/.claude/pills/README.md +146 -0
  31. package/releases/0.36.0/core/tree/.claude/pipeline.md +101 -0
  32. package/releases/0.36.0/core/tree/.claude/retrieval.md +103 -0
  33. package/releases/0.36.0/core/tree/.claude/router.md +198 -0
  34. package/releases/0.36.0/core/tree/.claude/templates/integration.md +45 -0
  35. package/releases/0.36.0/core/tree/CLAUDE.md +161 -0
  36. package/releases/0.36.0/core/tree/scripts/cost-watch.mjs +18 -0
  37. package/releases/0.36.0/core/tree/scripts/edit-guard.mjs +16 -0
  38. package/releases/0.36.0/core/tree/scripts/harness-check.mjs +71 -0
  39. package/releases/0.36.0/core/tree/scripts/loop-gate.mjs +19 -0
  40. package/releases/0.36.0/core/vocabulary.json +13 -0
  41. package/releases/0.36.0/surfaces/blockchain/tree/.claude/agents/architect.md +5 -0
  42. package/releases/0.36.0/surfaces/blockchain/tree/.claude/agents/devops.md +5 -0
  43. package/releases/0.36.0/surfaces/blockchain/tree/.claude/agents/reviewer.md +5 -0
  44. package/releases/0.36.0/surfaces/blockchain/tree/.claude/agents/solidity-auditor.md +18 -0
  45. package/releases/0.36.0/surfaces/blockchain/tree/.claude/agents/solidity-dev.md +16 -0
  46. package/releases/0.36.0/surfaces/blockchain/tree/.claude/agents-overview.md +6 -0
  47. package/releases/0.36.0/surfaces/blockchain/tree/.claude/graph.md +11 -0
  48. package/releases/0.36.0/surfaces/blockchain/tree/.claude/router.md +6 -0
  49. package/releases/0.36.0/surfaces/blockchain/tree/CLAUDE.md +12 -0
  50. package/releases/0.36.0/surfaces/db/tree/.claude/agents/architect.md +5 -0
  51. package/releases/0.36.0/surfaces/db/tree/.claude/agents/dba.md +50 -0
  52. package/releases/0.36.0/surfaces/db/tree/.claude/agents/devops.md +5 -0
  53. package/releases/0.36.0/surfaces/db/tree/.claude/agents/implementer.md +2 -0
  54. package/releases/0.36.0/surfaces/db/tree/.claude/agents/integration-tester.md +10 -0
  55. package/releases/0.36.0/surfaces/db/tree/.claude/agents/planner.md +2 -0
  56. package/releases/0.36.0/surfaces/db/tree/.claude/agents/reviewer.md +22 -0
  57. package/releases/0.36.0/surfaces/db/tree/.claude/agents/secops.md +2 -0
  58. package/releases/0.36.0/surfaces/db/tree/.claude/agents-overview.md +5 -0
  59. package/releases/0.36.0/surfaces/db/tree/.claude/graph.md +8 -0
  60. package/releases/0.36.0/surfaces/db/tree/.claude/patterns.md +19 -0
  61. package/releases/0.36.0/surfaces/db/tree/.claude/pipeline.md +13 -0
  62. package/releases/0.36.0/surfaces/db/tree/.claude/retrieval.md +5 -0
  63. package/releases/0.36.0/surfaces/db/tree/.claude/router.md +4 -0
  64. package/releases/0.36.0/surfaces/db/tree/CLAUDE.md +17 -0
  65. package/releases/0.36.0/surfaces/edge-cf/tree/.claude/agents/architect.md +14 -0
  66. package/releases/0.36.0/surfaces/edge-cf/tree/.claude/agents/dba.md +2 -0
  67. package/releases/0.36.0/surfaces/edge-cf/tree/.claude/agents/devops.md +32 -0
  68. package/releases/0.36.0/surfaces/edge-cf/tree/.claude/agents/implementer.md +14 -0
  69. package/releases/0.36.0/surfaces/edge-cf/tree/.claude/agents/integration-tester.md +11 -0
  70. package/releases/0.36.0/surfaces/edge-cf/tree/.claude/agents/qa.md +3 -0
  71. package/releases/0.36.0/surfaces/edge-cf/tree/.claude/agents/reviewer.md +8 -0
  72. package/releases/0.36.0/surfaces/edge-cf/tree/.claude/agents/secops.md +8 -0
  73. package/releases/0.36.0/surfaces/edge-cf/tree/.claude/patterns.md +17 -0
  74. package/releases/0.36.0/surfaces/edge-cf/tree/.claude/retrieval.md +8 -0
  75. package/releases/0.36.0/surfaces/edge-cf/tree/CLAUDE.md +27 -0
  76. package/releases/0.36.0/surfaces/frontend/tree/.claude/agents/architect.md +20 -0
  77. package/releases/0.36.0/surfaces/frontend/tree/.claude/agents/devops.md +28 -0
  78. package/releases/0.36.0/surfaces/frontend/tree/.claude/agents/implementer.md +25 -0
  79. package/releases/0.36.0/surfaces/frontend/tree/.claude/agents/qa.md +17 -0
  80. package/releases/0.36.0/surfaces/frontend/tree/.claude/agents/reviewer.md +56 -0
  81. package/releases/0.36.0/surfaces/frontend/tree/.claude/patterns.md +8 -0
  82. package/releases/0.36.0/surfaces/frontend/tree/.claude/retrieval.md +2 -0
  83. package/releases/0.36.0/surfaces/frontend/tree/.claude/router.md +14 -0
  84. package/releases/0.36.0/surfaces/frontend/tree/CLAUDE.md +5 -0
  85. package/releases/0.36.0/surfaces/integrations/tree/.claude/agents/architect.md +16 -0
  86. package/releases/0.36.0/surfaces/integrations/tree/.claude/agents/implementer.md +12 -0
  87. package/releases/0.36.0/surfaces/integrations/tree/.claude/agents/integration-tester.md +20 -0
  88. package/releases/0.36.0/surfaces/integrations/tree/.claude/agents/planner.md +2 -0
  89. package/releases/0.36.0/surfaces/integrations/tree/.claude/agents/reviewer.md +21 -0
  90. package/releases/0.36.0/surfaces/integrations/tree/.claude/agents/secops.md +2 -0
  91. package/releases/0.36.0/surfaces/integrations/tree/.claude/agents-overview.md +8 -0
  92. package/releases/0.36.0/surfaces/integrations/tree/.claude/graph.md +10 -0
  93. package/releases/0.36.0/surfaces/integrations/tree/.claude/patterns.md +38 -0
  94. package/releases/0.36.0/surfaces/integrations/tree/.claude/pipeline.md +16 -0
  95. package/releases/0.36.0/surfaces/integrations/tree/.claude/retrieval.md +16 -0
  96. package/releases/0.36.0/surfaces/integrations/tree/.claude/router.md +6 -0
  97. package/releases/0.36.0/surfaces/integrations/tree/CLAUDE.md +17 -0
  98. package/releases/0.36.0/surfaces/money/tree/.claude/agents/architect.md +12 -0
  99. package/releases/0.36.0/surfaces/money/tree/.claude/agents/dba.md +14 -0
  100. package/releases/0.36.0/surfaces/money/tree/.claude/agents/implementer.md +11 -0
  101. package/releases/0.36.0/surfaces/money/tree/.claude/agents/integration-tester.md +3 -0
  102. package/releases/0.36.0/surfaces/money/tree/.claude/agents/reviewer.md +23 -0
  103. package/releases/0.36.0/surfaces/money/tree/.claude/agents/secops.md +5 -0
  104. package/releases/0.36.0/surfaces/money/tree/.claude/agents/solidity-auditor.md +4 -0
  105. package/releases/0.36.0/surfaces/money/tree/.claude/agents/solidity-dev.md +4 -0
  106. package/releases/0.36.0/surfaces/money/tree/.claude/graph.md +2 -0
  107. package/releases/0.36.0/surfaces/money/tree/.claude/patterns.md +49 -0
  108. package/releases/0.36.0/surfaces/money/tree/.claude/pipeline.md +2 -0
  109. package/releases/0.36.0/surfaces/money/tree/.claude/retrieval.md +2 -0
  110. package/releases/0.36.0/surfaces/money/tree/CLAUDE.md +11 -0
  111. package/releases/0.36.0/surfaces/pii/tree/.claude/agents/dba.md +2 -0
  112. package/releases/0.36.0/surfaces/pii/tree/.claude/agents/devops.md +8 -0
  113. package/releases/0.36.0/surfaces/pii/tree/.claude/agents/reviewer.md +8 -0
  114. package/releases/0.36.0/surfaces/pii/tree/.claude/agents/secops.md +2 -0
  115. package/releases/0.36.0/surfaces/pii/tree/.claude/agents/solidity-auditor.md +5 -0
  116. package/releases/0.36.0/surfaces/pii/tree/CLAUDE.md +5 -0
  117. package/releases/0.36.0/surfaces/prisma/tree/.claude/agents/architect.md +2 -0
  118. package/releases/0.36.0/surfaces/prisma/tree/.claude/agents/dba.md +39 -0
  119. package/releases/0.36.0/surfaces/prisma/tree/.claude/agents/devops.md +11 -0
  120. package/releases/0.36.0/surfaces/prisma/tree/.claude/agents/implementer.md +2 -0
  121. package/releases/0.36.0/surfaces/prisma/tree/.claude/agents/integration-tester.md +2 -0
  122. package/releases/0.36.0/surfaces/prisma/tree/.claude/agents/reviewer.md +2 -0
  123. package/releases/0.36.0/surfaces/prisma/tree/.claude/patterns.md +15 -0
  124. package/releases/0.36.0/surfaces/prisma/tree/CLAUDE.md +5 -0
  125. package/src/commands/export.mjs +8 -16
  126. package/src/commands/gate.mjs +19 -13
  127. package/src/commands/learn.mjs +5 -15
  128. package/src/commands/pipeline.mjs +4 -4
  129. package/src/commands/runs.mjs +14 -8
  130. package/src/commands/snapshot.mjs +63 -64
  131. package/src/commands/stats.mjs +46 -28
  132. package/src/commands/status.mjs +2 -2
  133. package/src/store.mjs +158 -0
  134. package/src/transcripts.mjs +15 -4
@@ -21,7 +21,7 @@ import { join, resolve } from 'node:path';
21
21
  import { HARNESS, legacyHint, slugFor } from '../paths.mjs';
22
22
  import { GATE, hookCommand, missingWiring, shippedScripts } from '../wiring.mjs';
23
23
  import { LEDGER_TAIL, ledgerPath, loadProject, projectGateDir, readLedger, runGate } from '../gate.mjs';
24
- import { storedRecords } from './snapshot.mjs';
24
+ import { projectRecords } from '../store.mjs';
25
25
  import { layerRootFor } from './compose.mjs';
26
26
  import { handsToModel } from '../detectors.mjs';
27
27
 
@@ -102,7 +102,8 @@ const LIVE_SAMPLE = 3;
102
102
  *
103
103
  * @param {string} target - The project directory.
104
104
  * @param {{graph: {stages: Set<string>}, tokens: Map<string, Set<string>>}} project - Its composed graph.
105
- * @returns {string|null}
105
+ * @returns {{problem: string}|{compared: number}|{unasked: string}} What the comparison found, how many reports it
106
+ * compared, or why there was nothing to compare yet — which the selftest used to report as a comparison that passed.
106
107
  */
107
108
  function liveness(target, project) {
108
109
  const dir = projectGateDir(target);
@@ -110,10 +111,10 @@ function liveness(target, project) {
110
111
  try {
111
112
  since = readFileSync(join(dir, SINCE), 'utf8').trim();
112
113
  } catch {
113
- return null;
114
+ return { unasked: 'no session has been measured since the gate was first asked' };
114
115
  }
115
116
  const now = Date.now();
116
- const reports = (storedRecords(target) ?? []).filter(
117
+ const reports = (projectRecords(target) ?? []).filter(
117
118
  (r) =>
118
119
  r.verdict_source === 'handback' &&
119
120
  r.session &&
@@ -128,18 +129,20 @@ function liveness(target, project) {
128
129
  .filter((rs) => rs.length >= LIVE_SAMPLE)
129
130
  .sort((a, b) => String(a.map((r) => r.result_ts).sort().at(-1)).localeCompare(String(b.map((r) => r.result_ts).sort().at(-1))))
130
131
  .at(-1);
131
- if (!last) return null;
132
+ if (!last) return { unasked: `no session since then holds ${LIVE_SAMPLE} reports with a verdict line to compare` };
132
133
  const path = ledgerPath(target, last[0].session);
133
134
  const entries = readLedger(path);
134
135
  // A ledger too large to read whole is read from its tail; what came before the tail is not asked about.
135
136
  const from = entries.length > 0 && statSync(path).size > LEDGER_TAIL ? String(entries[0].at) : '';
136
137
  const shown = last.filter((r) => String(r.result_ts) >= from).length;
137
138
  const recorded = entries.filter((e) => e.k === 'verdict').length;
138
- if (shown < LIVE_SAMPLE || recorded * 2 >= shown) return null;
139
- return (
140
- `in session ${last[0].session.slice(0, 8)}, the transcripts show ${shown} report(s) with a verdict line and the gate recorded ${recorded}: ` +
141
- `Claude Code may have changed what it sends to the hooks, and a loop the gate does not see is not capped (${path})`
142
- );
139
+ if (shown < LIVE_SAMPLE) return { unasked: `the newest measured session holds ${shown} report(s) with a verdict line, too few to compare` };
140
+ if (recorded * 2 >= shown) return { compared: shown };
141
+ return {
142
+ problem:
143
+ `in session ${last[0].session.slice(0, 8)}, the transcripts show ${shown} report(s) with a verdict line and the gate recorded ${recorded}: ` +
144
+ `Claude Code may have changed what it sends to the hooks, and a loop the gate does not see is not capped (${path})`,
145
+ };
143
146
  }
144
147
 
145
148
  /**
@@ -299,11 +302,14 @@ export async function gate(argv, ctx) {
299
302
  // A gate that is not wired sees nothing, and says so above; asked whether it saw the pipeline, it would
300
303
  // say the same thing twice.
301
304
  const project = composed && unwired.length === 0 ? loadProject(target) : null;
302
- const blind = project ? liveness(target, project) : null;
303
- if (blind) problems.push(blind);
305
+ const seen = project ? liveness(target, project) : null;
306
+ if (seen?.problem) problems.push(seen.problem);
304
307
 
305
308
  if (problems.length === 0) {
306
- console.log(` wired, writable, no new failure, a dry run through the hook command sent the round past a cap to the owner, and it recorded the reports the transcripts show — ledgers in ${projectGateDir(target)}`);
309
+ const compared = seen?.compared
310
+ ? `and it recorded the ${seen.compared} reports the transcripts show`
311
+ : `and whether it records what the transcripts show is not asked yet — ${seen?.unasked ?? 'nothing to compare'}`;
312
+ console.log(` wired, writable, no new failure, a dry run through the hook command sent the round past a cap to the owner, ${compared} — ledgers in ${projectGateDir(target)}`);
307
313
  console.log('gate: current');
308
314
  return 0;
309
315
  }
@@ -26,6 +26,7 @@ import { existsSync, readdirSync } from 'node:fs';
26
26
  import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
27
27
  import { join, relative, resolve } from 'node:path';
28
28
  import { HARNESS, legacyHint, slugFor, snapshotsDir } from '../paths.mjs';
29
+ import { localDay, projectRecords } from '../store.mjs';
29
30
  import { isLoopBack, runOf } from '../transcripts.mjs';
30
31
  import { REQUIRES, byVersion, layerRootFor } from './compose.mjs';
31
32
  import { GRADUATION_AT, frontmatter, graduationTarget, list, pillFiles, roleGates, tidyRetired } from './pills.mjs';
@@ -52,7 +53,8 @@ const VERIFY_MIN = 5;
52
53
  export { slugFor };
53
54
 
54
55
  /** The day part of a timestamp or a date. */
55
- const day = (ts) => String(ts ?? '').slice(0, 10);
56
+ /** The owner's day a moment fell on: a pill's date is written in it. */
57
+ const day = localDay;
56
58
 
57
59
  /** A verdict the parser actually read, as opposed to one it could not find. */
58
60
  const readable = (v) => Boolean(v) && v !== 'UNCLEAR' && v !== 'NONE';
@@ -64,19 +66,7 @@ const readable = (v) => Boolean(v) && v !== 'UNCLEAR' && v !== 'NONE';
64
66
  * @returns {Promise<object[]>}
65
67
  */
66
68
  async function recorded(target) {
67
- const file = join(snapshotsDir(), `${slugFor(target)}.jsonl`);
68
- const text = await readFile(file, 'utf8').catch(() => '');
69
- return text
70
- .split('\n')
71
- .filter((l) => l.trim())
72
- .map((l) => {
73
- try {
74
- return JSON.parse(l);
75
- } catch {
76
- return null;
77
- }
78
- })
79
- .filter(Boolean);
69
+ return projectRecords(target) ?? [];
80
70
  }
81
71
 
82
72
  /**
@@ -486,7 +476,7 @@ async function deepReport(target, records, known, argv) {
486
476
  */
487
477
  async function fileRequest(target, profile, layerRoot, lesson) {
488
478
  const target_ = graduationTarget(lesson.roles, await roleGates(layerRoot));
489
- const date = new Date().toISOString().slice(0, 10);
479
+ const date = localDay(new Date().toISOString());
490
480
  // The directory separator becomes `__`, so `reviewer/a.b.md` and `reviewer-a/b.md` stay two
491
481
  // names — flattening both `/` and `.` to `-` gave them the same one.
492
482
  const name = lesson.rel.replace(/^\.claude\/pills\//, '').replace(/\.md$/, '').replace(/\//g, '__');
@@ -18,7 +18,7 @@ import { TERMINALS, parseGraph } from '../graph.mjs';
18
18
  import { required } from '../tools.mjs';
19
19
  import { isLoopBack, runOf } from '../transcripts.mjs';
20
20
  import { frontmatter } from './pills.mjs';
21
- import { storedRecords } from './snapshot.mjs';
21
+ import { localDay, projectRecords } from '../store.mjs';
22
22
 
23
23
  /** A stage line under "## Stages": "- `name` — what it does". */
24
24
  const STAGE_LINE = /^- `([a-z][a-z-]*)` — (.*)$/;
@@ -149,7 +149,7 @@ export function historyOf(records, since) {
149
149
  const byRole = new Map();
150
150
  const seen = new Map();
151
151
  for (const r of records) {
152
- if (!r.role || r.status === 'denied' || String(r.ts ?? '') < since) continue;
152
+ if (!r.role || r.status === 'denied' || localDay(r.ts) < since) continue;
153
153
  const s = byRole.get(r.role) ?? { runs: 0, rounds: 0, read: 0, back: 0 };
154
154
  const runs = seen.get(r.role) ?? new Set();
155
155
  runs.add(runOf(r));
@@ -288,8 +288,8 @@ export async function pipeline(argv) {
288
288
  for (const file of existsSync(agentsDir) ? readdirSync(agentsDir).filter((f) => f.endsWith('.md')).sort() : []) {
289
289
  specs.set(file.replace(/\.md$/, ''), readFileSync(join(agentsDir, file), 'utf8'));
290
290
  }
291
- const since = arg('--since') || new Date(Date.now() - HISTORY_DAYS * 86_400_000).toISOString().slice(0, 10);
292
- const runs = storedRecords(dir);
291
+ const since = arg('--since') || localDay(new Date(Date.now() - HISTORY_DAYS * 86_400_000).toISOString());
292
+ const runs = projectRecords(dir);
293
293
  const history = runs ? historyOf(runs, since) : null;
294
294
  const stages = [...graph.stages].filter((s) => covered.has(s));
295
295
  const col = Math.max(...stages.map((s) => s.length), 10) + 2;
@@ -16,8 +16,8 @@ import { HARNESS } from '../paths.mjs';
16
16
  import { costOf } from '../prices.mjs';
17
17
  import { isLoopBack, runOf } from '../transcripts.mjs';
18
18
  import { amber, bold, dim, pink } from '../look.mjs';
19
- import { storedRecords } from './snapshot.mjs';
20
- import { cyclesOf, projectName } from './stats.mjs';
19
+ import { localDay, localMinute, projectRecords, zone } from '../store.mjs';
20
+ import { WRITERS, cyclesOf, projectName } from './stats.mjs';
21
21
 
22
22
  /** What ended a cycle, in words. */
23
23
  const ENDS = {
@@ -55,7 +55,8 @@ export function summarize(cycle) {
55
55
  if (isLoopBack(r.verdict)) s.back += 1;
56
56
  stages.set(r.role, s);
57
57
  }
58
- const files = rounds.filter((r) => typeof r.files_touched === 'number').map((r) => r.files_touched);
58
+ // The code a cycle wrote, as `stats` counts it: an architect's spec or a tester's report is not a write of code.
59
+ const files = rounds.filter((r) => WRITERS.has(r.role) && typeof r.files_touched === 'number').map((r) => r.files_touched);
59
60
  const peaks = rounds.filter((r) => typeof r.context_peak === 'number').map((r) => r.context_peak);
60
61
  return {
61
62
  start,
@@ -92,9 +93,14 @@ export async function runs(argv) {
92
93
  }
93
94
  const since = arg('--since');
94
95
  const last = Math.max(1, Number(arg('--last') ?? 10) || 10);
95
- const records = (storedRecords(dir) ?? []).filter((r) => r.status !== 'denied' && (!since || String(r.ts).slice(0, 10) >= since));
96
+ const stored = (projectRecords(dir) ?? []).filter((r) => r.status !== 'denied');
97
+ const records = stored.filter((r) => !since || localDay(r.ts) >= since);
96
98
  if (records.length === 0) {
97
- console.error(' no measured history for this project yet — `nina snapshot` captures it\n');
99
+ console.error(
100
+ stored.length === 0
101
+ ? ' no measured history for this project yet — `nina snapshot` captures it\n'
102
+ : ` nothing measured since ${since} (${zone()}) — the newest round is from ${localDay(stored.map((r) => String(r.ts)).sort().at(-1))}\n`,
103
+ );
98
104
  return 1;
99
105
  }
100
106
  const cycles = cyclesOf(records)
@@ -103,10 +109,10 @@ export async function runs(argv) {
103
109
  const shown = cycles.slice(0, last);
104
110
  console.log(
105
111
  ` ${bold(pink(projectName(records[0].project)))} · ${cycles.length} cycle(s)` +
106
- dim(` newest first${cycles.length > shown.length ? `, the last ${shown.length} — --last N for more` : ''}; a cycle runs up to the verdict that closes it`),
112
+ dim(` newest first${cycles.length > shown.length ? `, the last ${shown.length} — --last N for more` : ''}; a cycle runs up to the verdict that closes it; times in ${zone()}`),
107
113
  );
108
114
  for (const c of shown) {
109
- const when = `${c.start.slice(0, 10)} ${c.start.slice(11, 16)}`;
115
+ const when = localMinute(c.start);
110
116
  console.log(`\n ${pink('▌')} ${bold(when)} · ${span(c.minutes)} · ${bold(money(c.cost))}${c.unpriced ? dim(` + ${c.unpriced} unpriced`) : ''} · ${dim(c.how)}`);
111
117
  console.log(` ${c.name.length > 96 ? `${c.name.slice(0, 95)}…` : c.name}`);
112
118
  const chain = c.stages.map((s) => {
@@ -122,7 +128,7 @@ export async function runs(argv) {
122
128
  const costliest = shown.reduce((a, c) => (c.cost > a.cost ? c : a), shown[0]);
123
129
  console.log(
124
130
  `\n ${shown.length} cycle(s): ${bold(money(total))} in all` +
125
- (shown.length > 1 ? `; the costliest, ${money(costliest.cost)}, ${costliest.start.slice(0, 10)} — ${costliest.name.slice(0, 60)}` : '') +
131
+ (shown.length > 1 ? `; the costliest, ${money(costliest.cost)}, ${localDay(costliest.start)} — ${costliest.name.slice(0, 60)}` : '') +
126
132
  '\n',
127
133
  );
128
134
  return 0;
@@ -9,9 +9,10 @@
9
9
  */
10
10
 
11
11
  import { copyFile, mkdir, open, readFile, rename, rm, writeFile } from 'node:fs/promises';
12
- import { slugFor, snapshotsDir } from '../paths.mjs';
13
- import { createReadStream, existsSync, readFileSync, realpathSync } from 'node:fs';
14
- import { createInterface } from 'node:readline';
12
+ import { HARNESS, snapshotsDir } from '../paths.mjs';
13
+ import { decodeProjectDir } from './stats.mjs';
14
+ import { readStoreFile } from '../store.mjs';
15
+ import { existsSync, readFileSync } from 'node:fs';
15
16
  import { join, resolve } from 'node:path';
16
17
  import { listProjects, scanProject } from '../transcripts.mjs';
17
18
 
@@ -21,7 +22,8 @@ const STATE_FILE = '.state.json';
21
22
  /**
22
23
  * Reads a JSON file, or an empty object when it is missing or unreadable. A state file cut short
23
24
  * by an interrupted write used to throw on every later snapshot, and observation stopped for every
24
- * project on the machine with nothing saying so. An unreadable cursor costs one re-read.
25
+ * project on the machine with nothing saying so. An unreadable cursor costs one re-read, which keeps what the
26
+ * store already holds (`snapshotProject`).
25
27
  *
26
28
  * @param {string} path - The file.
27
29
  * @returns {Promise<object>}
@@ -42,35 +44,10 @@ async function readJson(path) {
42
44
  */
43
45
  async function writeAtomicText(path, text) {
44
46
  const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
45
- await writeFile(tmp, text);
47
+ await writeFile(tmp, text, { mode: 0o600 });
46
48
  await rename(tmp, path);
47
49
  }
48
50
 
49
- /**
50
- * Reads the records already captured for a project.
51
- *
52
- * @param {string} file - Path to the snapshot JSONL.
53
- * @returns {Promise<object[]>} The records, or an empty list.
54
- */
55
- async function readRecords(file) {
56
- const records = [];
57
- try {
58
- await readFile(file, { flag: 'r' });
59
- } catch {
60
- return records;
61
- }
62
- const rl = createInterface({ input: createReadStream(file), crlfDelay: Infinity });
63
- for await (const line of rl) {
64
- if (!line.trim()) continue;
65
- try {
66
- records.push(JSON.parse(line));
67
- } catch {
68
- /* a torn line loses one record, never the file */
69
- }
70
- }
71
- return records;
72
- }
73
-
74
51
  /**
75
52
  * Writes the snapshot through a temp file, so an interrupted run cannot leave a
76
53
  * truncated one behind.
@@ -82,41 +59,11 @@ async function writeAtomic(file, records) {
82
59
  // Unique per writer: two snapshots of one project at once used the same temporary name, and
83
60
  // could interleave into it before either renamed it into place.
84
61
  const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
85
- await writeFile(tmp, `${records.map((r) => JSON.stringify(r)).join('\n')}\n`);
62
+ // Its owner's alone: it holds each dispatch's description, which the orchestrator wrote.
63
+ await writeFile(tmp, `${records.map((r) => JSON.stringify(r)).join('\n')}\n`, { mode: 0o600 });
86
64
  await rename(tmp, file);
87
65
  }
88
66
 
89
- /**
90
- * A project's records in the measurement store, under the name Claude Code gives its directory — as
91
- * given, or with its links resolved, since either may be the one the sessions ran in.
92
- *
93
- * @param {string} dir - The project.
94
- * @returns {object[]|null} Null when the store has nothing for it.
95
- */
96
- export function storedRecords(dir) {
97
- const names = new Set([slugFor(dir)]);
98
- try {
99
- names.add(slugFor(realpathSync(dir)));
100
- } catch {
101
- // A directory that cannot be resolved is looked up as given.
102
- }
103
- for (const name of names) {
104
- const file = join(snapshotsDir(), `${name}.jsonl`);
105
- if (!existsSync(file)) continue;
106
- return readFileSync(file, 'utf8')
107
- .split('\n')
108
- .filter(Boolean)
109
- .flatMap((l) => {
110
- try {
111
- return [JSON.parse(l)];
112
- } catch {
113
- return [];
114
- }
115
- });
116
- }
117
- return null;
118
- }
119
-
120
67
  /**
121
68
  * Takes a project's lock, or says it is taken. The records and the cursors are written one after the other,
122
69
  * and two snapshots of one project at once — a project's own detector and a global hook, both on every
@@ -204,6 +151,42 @@ export async function snapshot(argv, ctx) {
204
151
  return 0;
205
152
  }
206
153
 
154
+ /** Whether a round's verdict came from the report it handed back: the one reading nothing later corrects. */
155
+ const settledByHandback = (r) => r?.verdict_source === 'handback';
156
+
157
+ /**
158
+ * A round as a re-read after lost cursors leaves it. What the store held stands, field by field, and the
159
+ * re-read fills only what it lacked — unless the re-read settled the round from the report its stage handed
160
+ * back and the store had not: a verdict read from a notification is the one a handback corrects, and kept
161
+ * over it, a round the store had as REJECTED stayed so after its stage had said APPROVED, and stayed so for
162
+ * good once that stage's transcript was pruned.
163
+ *
164
+ * @param {object} held - The round as the store held it.
165
+ * @param {object} reread - The round as the re-read found it.
166
+ * @returns {object}
167
+ */
168
+ export function rereadOver(held, reread) {
169
+ const defined = (record) => Object.fromEntries(Object.entries(record).filter(([, v]) => v !== null && v !== undefined));
170
+ return settledByHandback(reread) && !settledByHandback(held) ? { ...held, ...defined(reread) } : { ...reread, ...defined(held) };
171
+ }
172
+
173
+ /**
174
+ * The release a project pins now, or null when its directory or profile cannot be found.
175
+ *
176
+ * @param {string} slug - Its store name.
177
+ * @returns {string|null}
178
+ */
179
+ function pinOf(slug) {
180
+ const dir = decodeProjectDir(slug);
181
+ if (!dir) return null;
182
+ try {
183
+ const core = JSON.parse(readFileSync(join(dir, HARNESS, 'profile.json'), 'utf8')).core;
184
+ return typeof core === 'string' ? core : null;
185
+ } catch {
186
+ return null;
187
+ }
188
+ }
189
+
207
190
  /**
208
191
  * Snapshots one project, under its lock.
209
192
  *
@@ -214,12 +197,28 @@ async function snapshotProject(project, { outDir, rebuild, quiet, legacy }) {
214
197
  // The store is the only copy of history older than the transcripts, and a rebuild re-reads only what is
215
198
  // still on disk: the record it replaces is kept beside it first.
216
199
  if (rebuild && existsSync(file)) await copyFile(file, `${file}.before-rebuild-${new Date().toISOString().replace(/[:.]/g, '-')}`);
217
- const prior = rebuild ? [] : await readRecords(file);
200
+ const stored = readStoreFile(file, { raw: true });
201
+ const prior = rebuild ? [] : stored;
218
202
  const own = rebuild ? {} : await readJson(join(outDir, `${project.slug}.state.json`));
219
203
  const cursors = rebuild ? {} : (own.cursors ?? legacy[project.slug]?.cursors ?? {});
220
204
 
221
205
  const scanned = await scanProject(project.dir, { cursors, records: prior });
222
- const records = scanned.records.map((r) => ({ project: project.slug, ...r }));
206
+ // With its cursors gone — a state file deleted, or cut short — the transcripts are read again from the start
207
+ // over records the store already holds, and read that way a notification is taken a second time: one
208
+ // project had 34 verdicts, 639 return times and 79 durations rewritten. What the store held stands, field
209
+ // by field, and the re-read adds only what it lacked; the cursors it leaves hold from then on.
210
+ const reread = !rebuild && prior.length > 0 && Object.keys(cursors).length === 0;
211
+ const held = new Map(prior.map((r) => [r.dispatch_id, r]));
212
+ const kept = (r) => (reread && held.has(r.dispatch_id) ? rereadOver(held.get(r.dispatch_id), r) : r);
213
+ // The release the project pinned when a round was first captured, so a rule is judged only on the rounds
214
+ // that ran under it. A round already on record keeps what it had — a rebuild included, which would
215
+ // otherwise stamp today's pin on weeks of history — and only a new one takes today's.
216
+ const pinned = new Map(stored.map((r) => [r.dispatch_id, r.core ?? null]));
217
+ const pin = pinOf(project.slug);
218
+ const records = scanned.records.map(kept).map((r) => {
219
+ const core = pinned.has(r.dispatch_id) ? pinned.get(r.dispatch_id) : pin;
220
+ return { ...r, project: project.slug, ...(core ? { core } : {}) };
221
+ });
223
222
  if (records.length === 0) return 0;
224
223
 
225
224
  const fresh = records.length - prior.length;
@@ -14,15 +14,15 @@
14
14
  import { amber, bar, bold, dim, heading, note, pink, stacked } from '../look.mjs';
15
15
  import { readFile, readdir } from 'node:fs/promises';
16
16
  import { spawnSync } from 'node:child_process';
17
- import { closeSync, createReadStream, existsSync, openSync, readFileSync, readSync, readdirSync, realpathSync, statSync } from 'node:fs';
18
- import { createInterface } from 'node:readline';
17
+ import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, realpathSync, statSync } from 'node:fs';
19
18
  import { homedir } from 'node:os';
20
19
  import { basename, join } from 'node:path';
21
20
  import { ROLE_TOKENS, isLoopBack, runOf, transcriptsOf } from '../transcripts.mjs';
21
+ import { localDay, namesFor, readStoreFile } from '../store.mjs';
22
22
  import { frontmatter, pillFiles } from './pills.mjs';
23
23
  import { HARNESS, slugFor, snapshotsDir } from '../paths.mjs';
24
24
  import { defaultVocabulary } from '../vocabulary.mjs';
25
- import { layerRootFor } from './compose.mjs';
25
+ import { byVersion, layerRootFor } from './compose.mjs';
26
26
  import { PRICES_AS_OF, costOf } from '../prices.mjs';
27
27
 
28
28
  /**
@@ -56,27 +56,16 @@ const PIPELINE_ROLES = new Set(Object.keys(ROLE_TOKENS));
56
56
  * Loads snapshot records, optionally filtered.
57
57
  *
58
58
  * @param {string} dir - Snapshot directory.
59
- * @param {{since: string|null, project: string|null}} opts - Filters.
59
+ * @param {{since: string|null, project: string|null}} opts - Filters; `project` as every report reads it.
60
60
  * @returns {Promise<object[]>} The matching records.
61
61
  */
62
62
  async function load(dir, opts) {
63
- const files = (await readdir(dir).catch(() => [])).filter((f) => f.endsWith('.jsonl'));
64
- const out = [];
65
- for (const file of files) {
66
- if (opts.project && !file.includes(opts.project)) continue;
67
- const rl = createInterface({ input: createReadStream(join(dir, file)), crlfDelay: Infinity });
68
- for await (const line of rl) {
69
- if (!line.trim()) continue;
70
- try {
71
- const r = JSON.parse(line);
72
- if (opts.since && String(r.ts).slice(0, 10) < opts.since) continue;
73
- out.push(r);
74
- } catch {
75
- /* skip */
76
- }
77
- }
78
- }
79
- return out;
63
+ const names = opts.project
64
+ ? namesFor(opts.project, dir)
65
+ : (await readdir(dir).catch(() => [])).filter((f) => f.endsWith('.jsonl')).map((f) => f.slice(0, -'.jsonl'.length));
66
+ return names
67
+ .flatMap((name) => readStoreFile(join(dir, `${name}.jsonl`)))
68
+ .filter((r) => !opts.since || localDay(r.ts) >= opts.since);
80
69
  }
81
70
 
82
71
 
@@ -385,7 +374,7 @@ function modelReport(records) {
385
374
  .map((rs) => rs.map((r) => (r.tokens ? costOf(r.tokens, r.usage_model) : null)).filter((c) => c !== null))
386
375
  .filter((cs) => cs.length > 0)
387
376
  .map((cs) => cs.reduce((a, b) => a + b, 0));
388
- const dates = rounds.map((r) => String(r.ts).slice(0, 10)).sort();
377
+ const dates = rounds.map((r) => localDay(r.ts)).sort();
389
378
  lines.push(
390
379
  ` ${i === 0 ? `${pink(role)}${' '.repeat(Math.max(20 - role.length, 1))}` : ''.padEnd(20)}${model.padEnd(26)}${String(runs.size).padStart(5)} run(s) ${dim(`${dates[0]} → ${dates.at(-1)}`)}` +
391
380
  (clear.length ? ` loop-back ${pct(loops, clear.length)} of ${clear.length}` : ' no readable verdict') +
@@ -427,8 +416,25 @@ function modelReport(records) {
427
416
  /** The verdicts that close a pipeline cycle: the change passed its tests, reached preview, or cleared the audit. */
428
417
  const CYCLE_ENDS = { qa: 'PASS', devops: 'DEPLOYED', secops: 'SECURE' };
429
418
 
419
+ /**
420
+ * The release each rule this report judges first shipped in. A round is judged on a rule only when the pin it
421
+ * was captured under (`core`) carries it: over every round, "0 of 16 loop-backs named their issues" read
422
+ * reports written before the rule existed as breaking it. Releases are never rewritten, so neither is this.
423
+ */
424
+ const RULE_SINCE = { issues: '0.22.0', stepFiles: '0.28.8' };
425
+
426
+ /**
427
+ * Whether a round ran under a release that carries a rule. One captured before rounds recorded their pin
428
+ * cannot say, and is left out rather than judged.
429
+ *
430
+ * @param {object} record - A round.
431
+ * @param {keyof RULE_SINCE} rule - The rule.
432
+ * @returns {boolean}
433
+ */
434
+ export const ranUnder = (record, rule) => typeof record.core === 'string' && (record.core === 'dev' || byVersion(record.core, RULE_SINCE[rule]) >= 0);
435
+
430
436
  /** The stages that write code, whose runs' file counts size a cycle. */
431
- const WRITERS = new Set(['implementer', 'solidity-dev']);
437
+ export const WRITERS = new Set(['implementer', 'solidity-dev']);
432
438
 
433
439
  /** The stages whose presence makes a cycle a designed one. */
434
440
  const DESIGNERS = new Set(['planner', 'architect']);
@@ -539,7 +545,7 @@ function proportionReport(records, stepLimit = () => null) {
539
545
  // 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
540
546
  // whose pinned release states the limit.
541
547
  const over = records.filter((r) => {
542
- if (r.role !== 'implementer' || typeof r.files_touched !== 'number') return false;
548
+ if (r.role !== 'implementer' || typeof r.files_touched !== 'number' || !ranUnder(r, 'stepFiles')) return false;
543
549
  const limit = stepLimit(r.project);
544
550
  return limit !== null && r.files_touched > limit;
545
551
  });
@@ -672,7 +678,14 @@ export async function stats(argv, ctx) {
672
678
  // A dispatch a hook denied never ran, so it is neither a run nor a missing verdict.
673
679
  const held = records.filter((r) => r.status === 'denied').length;
674
680
  const now = Date.now();
681
+ // Agents outside the pipeline — Claude Code's own, a project's own — have no verdict to give. Read as
682
+ // stages they were 100% unreadable and were told to add a verdict token their spec never asked for.
683
+ const outside = new Map();
675
684
  for (const r of records.filter((r) => r.status !== 'denied')) {
685
+ if (!PIPELINE_ROLES.has(r.role)) {
686
+ outside.set(r.role, (outside.get(r.role) ?? 0) + 1);
687
+ continue;
688
+ }
676
689
  if (!byRole.has(r.role)) {
677
690
  byRole.set(r.role, { n: 0, runs: new Set(), done: 0, lost: 0, clear: 0, loop: 0, unclear: 0, declared: 0, durations: [] });
678
691
  }
@@ -690,7 +703,7 @@ export async function stats(argv, ctx) {
690
703
  if (typeof r.duration_s === 'number') s.durations.push(r.duration_s);
691
704
  }
692
705
 
693
- const span = [records[0]?.ts, records.at(-1)?.ts].map((t) => String(t).slice(0, 10));
706
+ const span = [records[0]?.ts, records.at(-1)?.ts].map(localDay);
694
707
  const projectCount = new Set(records.map((r) => r.project)).size;
695
708
  const run = byRun(records.filter((r) => r.status !== 'denied')).size;
696
709
  console.log(
@@ -738,9 +751,14 @@ export async function stats(argv, ctx) {
738
751
  const lost = rows.reduce((a, [, s]) => a + s.lost, 0);
739
752
  const done = rows.reduce((a, [, s]) => a + s.done, 0);
740
753
  console.log('');
754
+ if (outside.size > 0) {
755
+ const named = [...outside.entries()].sort((a, b) => b[1] - a[1]).map(([role, n]) => `${role} ×${n}`).join(', ');
756
+ 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'));
757
+ }
741
758
  if (unreadable === 0 && lost === 0) console.log(note("every finished round's verdict could be read.", 'ok'));
759
+ // The same share as the table's column: rounds that finished, or should have, with no verdict read.
742
760
  if (unreadable > 0) {
743
- console.log(note(`${pct(unreadable, done + lost)} of finished rounds report no machine-readable verdict. Those stages need a verdict token on the report's first line.`, 'warn'));
761
+ console.log(note(`${pct(unreadable + lost, done + lost)} of finished rounds have no verdict that can be read. Those stages need a verdict token on the report's first line.`, 'warn'));
744
762
  }
745
763
  if (lost > 0) {
746
764
  console.log(
@@ -754,7 +772,7 @@ export async function stats(argv, ctx) {
754
772
 
755
773
  // Whether the stages that send work back say what they send back. Counted only over reports read
756
774
  // since the record learned the field, and only over declared loop-backs, which are all it asks of.
757
- const named = records.filter((r) => typeof r.issues === 'number');
775
+ const named = records.filter((r) => typeof r.issues === 'number' && ranUnder(r, 'issues'));
758
776
  if (named.length > 0) {
759
777
  const withIds = named.filter((r) => r.issues > 0).length;
760
778
  console.log(
@@ -838,7 +856,7 @@ export async function stats(argv, ctx) {
838
856
  : note(`${pills.retired} of ${pills.total} pill(s) retired into a rule.`, 'ok'),
839
857
  );
840
858
  if (newest) {
841
- const since = records.filter((r) => String(r.ts).slice(0, 10) > newest).length;
859
+ const since = records.filter((r) => localDay(r.ts) > newest).length;
842
860
  if (since > 0) console.log(note(`${since} round(s) since the newest pill (${newest}).`));
843
861
  }
844
862
  }
@@ -22,7 +22,7 @@ import { amber, bold, dim, green, pink } from '../look.mjs';
22
22
  import { byVersion, layerRootFor } from './compose.mjs';
23
23
  import { filedRequests } from './learn.mjs';
24
24
  import { frontmatter, pillFiles } from './pills.mjs';
25
- import { storedRecords } from './snapshot.mjs';
25
+ import { projectRecords } from '../store.mjs';
26
26
 
27
27
  /** How long the registry gets to say which version is newest; a status is not worth waiting on. */
28
28
  const REGISTRY_MS = 3000;
@@ -123,7 +123,7 @@ export async function status(argv, ctx) {
123
123
  const check = verdictOf(bin, ['check', '--project', target]);
124
124
  row('check', check.ok ? green(check.line) : warn(`${check.line} — \`nina check\` lists them`));
125
125
 
126
- const records = storedRecords(target) ?? [];
126
+ const records = projectRecords(target) ?? [];
127
127
  if (records.length === 0) {
128
128
  row('measured', dim('nothing yet — `nina snapshot` captures it'));
129
129
  } else {