@xhulz/nina 0.28.21 → 0.29.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 (130) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/releases/0.29.0/core/GAPS.md +125 -0
  4. package/releases/0.29.0/core/answered.json +29 -0
  5. package/releases/0.29.0/core/defaults/tree/.claude/agents/architect.md +1 -0
  6. package/releases/0.29.0/core/defaults/tree/.claude/agents/dba.md +3 -0
  7. package/releases/0.29.0/core/defaults/tree/.claude/agents/devops.md +1 -0
  8. package/releases/0.29.0/core/defaults/tree/.claude/agents/implementer.md +1 -0
  9. package/releases/0.29.0/core/defaults/tree/.claude/agents/integration-tester.md +3 -0
  10. package/releases/0.29.0/core/defaults/tree/.claude/agents/qa.md +2 -0
  11. package/releases/0.29.0/core/defaults/tree/.claude/agents/reviewer.md +3 -0
  12. package/releases/0.29.0/core/defaults/tree/.claude/agents/secops.md +3 -0
  13. package/releases/0.29.0/core/defaults/tree/.claude/agents/solidity-auditor.md +3 -0
  14. package/releases/0.29.0/core/defaults/tree/.claude/agents/solidity-dev.md +3 -0
  15. package/releases/0.29.0/core/tree/.claude/agents/architect.md +125 -0
  16. package/releases/0.29.0/core/tree/.claude/agents/dba.md +107 -0
  17. package/releases/0.29.0/core/tree/.claude/agents/devops.md +113 -0
  18. package/releases/0.29.0/core/tree/.claude/agents/implementer.md +155 -0
  19. package/releases/0.29.0/core/tree/.claude/agents/integration-tester.md +160 -0
  20. package/releases/0.29.0/core/tree/.claude/agents/planner.md +132 -0
  21. package/releases/0.29.0/core/tree/.claude/agents/qa.md +146 -0
  22. package/releases/0.29.0/core/tree/.claude/agents/reviewer.md +180 -0
  23. package/releases/0.29.0/core/tree/.claude/agents/secops.md +121 -0
  24. package/releases/0.29.0/core/tree/.claude/agents/solidity-auditor.md +126 -0
  25. package/releases/0.29.0/core/tree/.claude/agents/solidity-dev.md +123 -0
  26. package/releases/0.29.0/core/tree/.claude/agents-overview.md +64 -0
  27. package/releases/0.29.0/core/tree/.claude/graph.md +73 -0
  28. package/releases/0.29.0/core/tree/.claude/patterns.md +212 -0
  29. package/releases/0.29.0/core/tree/.claude/pills/README.md +145 -0
  30. package/releases/0.29.0/core/tree/.claude/pipeline.md +102 -0
  31. package/releases/0.29.0/core/tree/.claude/retrieval.md +104 -0
  32. package/releases/0.29.0/core/tree/.claude/router.md +201 -0
  33. package/releases/0.29.0/core/tree/.claude/templates/integration.md +45 -0
  34. package/releases/0.29.0/core/tree/CLAUDE.md +155 -0
  35. package/releases/0.29.0/core/tree/scripts/edit-guard.mjs +16 -0
  36. package/releases/0.29.0/core/tree/scripts/harness-check.mjs +71 -0
  37. package/releases/0.29.0/core/tree/scripts/loop-gate.mjs +19 -0
  38. package/releases/0.29.0/core/vocabulary.json +12 -0
  39. package/releases/0.29.0/surfaces/blockchain/tree/.claude/agents/architect.md +5 -0
  40. package/releases/0.29.0/surfaces/blockchain/tree/.claude/agents/devops.md +5 -0
  41. package/releases/0.29.0/surfaces/blockchain/tree/.claude/agents/reviewer.md +5 -0
  42. package/releases/0.29.0/surfaces/blockchain/tree/.claude/agents/solidity-auditor.md +18 -0
  43. package/releases/0.29.0/surfaces/blockchain/tree/.claude/agents/solidity-dev.md +16 -0
  44. package/releases/0.29.0/surfaces/blockchain/tree/.claude/agents-overview.md +6 -0
  45. package/releases/0.29.0/surfaces/blockchain/tree/.claude/graph.md +11 -0
  46. package/releases/0.29.0/surfaces/blockchain/tree/.claude/router.md +9 -0
  47. package/releases/0.29.0/surfaces/blockchain/tree/CLAUDE.md +12 -0
  48. package/releases/0.29.0/surfaces/db/tree/.claude/agents/architect.md +8 -0
  49. package/releases/0.29.0/surfaces/db/tree/.claude/agents/dba.md +84 -0
  50. package/releases/0.29.0/surfaces/db/tree/.claude/agents/devops.md +14 -0
  51. package/releases/0.29.0/surfaces/db/tree/.claude/agents/implementer.md +5 -0
  52. package/releases/0.29.0/surfaces/db/tree/.claude/agents/integration-tester.md +13 -0
  53. package/releases/0.29.0/surfaces/db/tree/.claude/agents/planner.md +2 -0
  54. package/releases/0.29.0/surfaces/db/tree/.claude/agents/reviewer.md +25 -0
  55. package/releases/0.29.0/surfaces/db/tree/.claude/agents/secops.md +2 -0
  56. package/releases/0.29.0/surfaces/db/tree/.claude/agents-overview.md +5 -0
  57. package/releases/0.29.0/surfaces/db/tree/.claude/graph.md +8 -0
  58. package/releases/0.29.0/surfaces/db/tree/.claude/patterns.md +29 -0
  59. package/releases/0.29.0/surfaces/db/tree/.claude/pipeline.md +13 -0
  60. package/releases/0.29.0/surfaces/db/tree/.claude/retrieval.md +5 -0
  61. package/releases/0.29.0/surfaces/db/tree/.claude/router.md +10 -0
  62. package/releases/0.29.0/surfaces/db/tree/CLAUDE.md +23 -0
  63. package/releases/0.29.0/surfaces/edge-cf/tree/.claude/agents/architect.md +14 -0
  64. package/releases/0.29.0/surfaces/edge-cf/tree/.claude/agents/dba.md +2 -0
  65. package/releases/0.29.0/surfaces/edge-cf/tree/.claude/agents/devops.md +31 -0
  66. package/releases/0.29.0/surfaces/edge-cf/tree/.claude/agents/implementer.md +14 -0
  67. package/releases/0.29.0/surfaces/edge-cf/tree/.claude/agents/integration-tester.md +11 -0
  68. package/releases/0.29.0/surfaces/edge-cf/tree/.claude/agents/qa.md +3 -0
  69. package/releases/0.29.0/surfaces/edge-cf/tree/.claude/agents/reviewer.md +8 -0
  70. package/releases/0.29.0/surfaces/edge-cf/tree/.claude/agents/secops.md +8 -0
  71. package/releases/0.29.0/surfaces/edge-cf/tree/.claude/patterns.md +17 -0
  72. package/releases/0.29.0/surfaces/edge-cf/tree/.claude/retrieval.md +8 -0
  73. package/releases/0.29.0/surfaces/edge-cf/tree/.claude/router.md +21 -0
  74. package/releases/0.29.0/surfaces/edge-cf/tree/CLAUDE.md +27 -0
  75. package/releases/0.29.0/surfaces/frontend/tree/.claude/agents/architect.md +20 -0
  76. package/releases/0.29.0/surfaces/frontend/tree/.claude/agents/devops.md +28 -0
  77. package/releases/0.29.0/surfaces/frontend/tree/.claude/agents/implementer.md +25 -0
  78. package/releases/0.29.0/surfaces/frontend/tree/.claude/agents/qa.md +17 -0
  79. package/releases/0.29.0/surfaces/frontend/tree/.claude/agents/reviewer.md +62 -0
  80. package/releases/0.29.0/surfaces/frontend/tree/.claude/patterns.md +15 -0
  81. package/releases/0.29.0/surfaces/frontend/tree/.claude/retrieval.md +5 -0
  82. package/releases/0.29.0/surfaces/frontend/tree/.claude/router.md +17 -0
  83. package/releases/0.29.0/surfaces/frontend/tree/CLAUDE.md +5 -0
  84. package/releases/0.29.0/surfaces/integrations/tree/.claude/agents/architect.md +16 -0
  85. package/releases/0.29.0/surfaces/integrations/tree/.claude/agents/implementer.md +12 -0
  86. package/releases/0.29.0/surfaces/integrations/tree/.claude/agents/integration-tester.md +20 -0
  87. package/releases/0.29.0/surfaces/integrations/tree/.claude/agents/planner.md +2 -0
  88. package/releases/0.29.0/surfaces/integrations/tree/.claude/agents/reviewer.md +21 -0
  89. package/releases/0.29.0/surfaces/integrations/tree/.claude/agents/secops.md +2 -0
  90. package/releases/0.29.0/surfaces/integrations/tree/.claude/agents-overview.md +8 -0
  91. package/releases/0.29.0/surfaces/integrations/tree/.claude/graph.md +9 -0
  92. package/releases/0.29.0/surfaces/integrations/tree/.claude/patterns.md +38 -0
  93. package/releases/0.29.0/surfaces/integrations/tree/.claude/pipeline.md +16 -0
  94. package/releases/0.29.0/surfaces/integrations/tree/.claude/retrieval.md +16 -0
  95. package/releases/0.29.0/surfaces/integrations/tree/.claude/router.md +6 -0
  96. package/releases/0.29.0/surfaces/integrations/tree/CLAUDE.md +17 -0
  97. package/releases/0.29.0/surfaces/money/tree/.claude/agents/architect.md +12 -0
  98. package/releases/0.29.0/surfaces/money/tree/.claude/agents/dba.md +14 -0
  99. package/releases/0.29.0/surfaces/money/tree/.claude/agents/implementer.md +11 -0
  100. package/releases/0.29.0/surfaces/money/tree/.claude/agents/integration-tester.md +3 -0
  101. package/releases/0.29.0/surfaces/money/tree/.claude/agents/reviewer.md +23 -0
  102. package/releases/0.29.0/surfaces/money/tree/.claude/agents/secops.md +5 -0
  103. package/releases/0.29.0/surfaces/money/tree/.claude/agents/solidity-auditor.md +4 -0
  104. package/releases/0.29.0/surfaces/money/tree/.claude/agents/solidity-dev.md +4 -0
  105. package/releases/0.29.0/surfaces/money/tree/.claude/graph.md +2 -0
  106. package/releases/0.29.0/surfaces/money/tree/.claude/patterns.md +49 -0
  107. package/releases/0.29.0/surfaces/money/tree/.claude/pipeline.md +2 -0
  108. package/releases/0.29.0/surfaces/money/tree/.claude/retrieval.md +2 -0
  109. package/releases/0.29.0/surfaces/money/tree/CLAUDE.md +11 -0
  110. package/releases/0.29.0/surfaces/pii/tree/.claude/agents/dba.md +2 -0
  111. package/releases/0.29.0/surfaces/pii/tree/.claude/agents/devops.md +8 -0
  112. package/releases/0.29.0/surfaces/pii/tree/.claude/agents/reviewer.md +8 -0
  113. package/releases/0.29.0/surfaces/pii/tree/.claude/agents/secops.md +2 -0
  114. package/releases/0.29.0/surfaces/pii/tree/.claude/agents/solidity-auditor.md +5 -0
  115. package/releases/0.29.0/surfaces/pii/tree/CLAUDE.md +5 -0
  116. package/src/agentrun.mjs +12 -4
  117. package/src/commands/compose.mjs +45 -1
  118. package/src/commands/export.mjs +15 -4
  119. package/src/commands/gate.mjs +91 -8
  120. package/src/commands/init.mjs +34 -15
  121. package/src/commands/learn.mjs +22 -9
  122. package/src/commands/pipeline.mjs +17 -42
  123. package/src/commands/snapshot.mjs +35 -4
  124. package/src/commands/stats.mjs +96 -53
  125. package/src/commands/upgrade.mjs +3 -0
  126. package/src/deep.mjs +4 -31
  127. package/src/detectors.mjs +33 -4
  128. package/src/gate.mjs +1 -1
  129. package/src/langfuse.mjs +2 -0
  130. package/src/transcripts.mjs +232 -81
@@ -19,7 +19,7 @@
19
19
  import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
20
20
  import { HARNESS, legacyHint } from '../paths.mjs';
21
21
  import { expectedUnfilled } from '../expected.mjs';
22
- import { existsSync } from 'node:fs';
22
+ import { existsSync, lstatSync, readFileSync } from 'node:fs';
23
23
  import { dirname, join, relative, resolve } from 'node:path';
24
24
  import { defaultVocabulary } from '../vocabulary.mjs';
25
25
 
@@ -327,6 +327,34 @@ export async function composedPaths(layerRoot, surfaces = []) {
327
327
  return out;
328
328
  }
329
329
 
330
+ /**
331
+ * The files of the project's own that sit where the harness composes one: a CLAUDE.md or an agent spec
332
+ * written by hand before the harness arrived, or a symlink, which a compose would write through. A file
333
+ * that carries a notice can say it was composed; one with no notice where every composed file of its kind
334
+ * has one is the project's, and composing over it would destroy it. `init` refused to compose over one and
335
+ * told the owner to move it aside and run `compose` — which then wrote over whatever was still there, with
336
+ * no copy kept; so did every compose after a surface was added to a profile by hand.
337
+ *
338
+ * @param {string} target - The project.
339
+ * @param {Iterable<string>} paths - Paths the harness composes, relative to it.
340
+ * @returns {string[]} The ones in the way, sorted.
341
+ */
342
+ export function projectOwned(target, paths) {
343
+ const out = [];
344
+ for (const p of paths) {
345
+ let entry;
346
+ try {
347
+ entry = lstatSync(join(target, p));
348
+ } catch {
349
+ continue;
350
+ }
351
+ const stamped = p.endsWith('.md') || SCRIPT.test(p);
352
+ // Read without following links, a link is not a file: it is the project's, whatever it points at.
353
+ if (!entry.isFile() || (stamped && !readFileSync(join(target, p), 'utf8').includes('nina:generated'))) out.push(p);
354
+ }
355
+ return out.sort();
356
+ }
357
+
330
358
  /**
331
359
  * Composes one project, and reports what it did.
332
360
  *
@@ -368,6 +396,22 @@ export async function composeProject(target, ctx, options = {}) {
368
396
  /** Layers whose fragments are expected to exist: the declared surfaces, plus the project's own. */
369
397
  const declared = new Set([...surfaces, 'project']);
370
398
 
399
+ // Nothing is written while a file of the project's own sits where one would go, so a compose that
400
+ // refuses leaves the project as it found it.
401
+ const owned = check ? [] : projectOwned(target, await composedPaths(layerRoot, surfaces));
402
+ if (owned.length > 0) {
403
+ return {
404
+ error:
405
+ `${owned.length} file(s) of this project's own sit where the harness composes one, and composing would destroy them — ` +
406
+ `nothing was written:\n${owned.map((p) => ` ${p}`).join('\n')}\n` +
407
+ `Move each aside, or put its text in ${HARNESS}/project/tree/<the same path> as the fragment that fills its slot, then compose again.`,
408
+ written,
409
+ differ,
410
+ unfilled: [],
411
+ skipped,
412
+ };
413
+ }
414
+
371
415
  /** Slots the core expects a declared layer to fill, and no layer did. */
372
416
  const unfilled = [];
373
417
  /** Files a `nina:why` marker composed into, unclosed. */
@@ -26,10 +26,11 @@ import { join } from 'node:path';
26
26
  import { fileURLToPath } from 'node:url';
27
27
  import { readRun, runFile } from '../agentrun.mjs';
28
28
  import { BATCH, SETTLE_MINUTES, postScore, postScores, postSpans, scoreOf, settled, spanOf, spansOf } from '../langfuse.mjs';
29
- import { transcriptsOf } from '../transcripts.mjs';
29
+ import { runOf, transcriptsOf } from '../transcripts.mjs';
30
30
  import { exportsDir, snapshotsDir } from '../paths.mjs';
31
31
  import { readConfig, statusPath, targetOf } from './langfuse.mjs';
32
32
  import { projectName } from './stats.mjs';
33
+ import { handsToModel } from '../detectors.mjs';
33
34
 
34
35
  /** The CLI, for the export the detector starts in the background. */
35
36
  const BIN = fileURLToPath(new URL('../../bin/nina.mjs', import.meta.url));
@@ -140,7 +141,16 @@ async function keep(slug, sent) {
140
141
  * sends what it does from then on, and its history only when asked.
141
142
  */
142
143
  export function due(records, sent, now = new Date(), since = '') {
143
- const spans = records.filter((r) => r.dispatch_id && !sent.spans[r.dispatch_id] && String(r.ts) >= since && settled(r, now) && spanOf(r, ''));
144
+ // A round split from a run captured before rounds were, whose first round went while it still held the
145
+ // whole run's spend: sent now, its tokens would be counted twice. Its first round's record has changed
146
+ // since it went, which is how it is told.
147
+ const byId = new Map(records.map((r) => [r.dispatch_id, r]));
148
+ const sentWhole = (r) => {
149
+ if (!r.backfilled) return false;
150
+ const first = byId.get(runOf(r));
151
+ return Boolean(first && sent.spans[first.dispatch_id] && sent.spans[first.dispatch_id] !== digest(first));
152
+ };
153
+ const spans = records.filter((r) => r.dispatch_id && !sent.spans[r.dispatch_id] && String(r.ts) >= since && settled(r, now) && !sentWhole(r) && spanOf(r, ''));
144
154
  const going = new Set(spans.map((r) => r.dispatch_id));
145
155
  const scores = records.filter((r) => (sent.spans[r.dispatch_id] || going.has(r.dispatch_id)) && !sent.scores[r.dispatch_id] && scoreOf(r));
146
156
  const changed = records.filter((r) => sent.spans[r.dispatch_id] && sent.spans[r.dispatch_id] !== digest(r)).length;
@@ -168,7 +178,8 @@ export async function autoExport(slug, records, now = new Date()) {
168
178
  const last = JSON.parse(await readFile(statusPath(slug), 'utf8').catch(() => 'null'));
169
179
  if (last?.error && !last.reported) {
170
180
  finding = `the export to Langfuse failed (${last.at}) — ${last.error}; \`nina langfuse status\` says where it stands`;
171
- await writeFile(statusPath(slug), JSON.stringify({ ...last, reported: true }));
181
+ // Said on every run until the prompt hook hands it to the model, and not after (`handsToModel`).
182
+ if (handsToModel()) await writeFile(statusPath(slug), JSON.stringify({ ...last, reported: true }));
172
183
  }
173
184
  const sent = await sentOf(slug);
174
185
  if (typeof sent === 'string') return finding ?? sent;
@@ -306,7 +317,7 @@ async function exportProject({ slug, name, file, target, dry, now, since = '', q
306
317
  let bytes = 0;
307
318
  for (const record of todo.spans) {
308
319
  if (error) break;
309
- const run = content ? await readRun(runFile(transcriptsOf(slug), record)) : null;
320
+ const run = content ? await readRun(runFile(transcriptsOf(slug), record), record.round ?? 1) : null;
310
321
  if (content && !run) bare += 1;
311
322
  let built = spansOf(record, name, run);
312
323
  let size = JSON.stringify(built).length;
@@ -15,18 +15,22 @@
15
15
  */
16
16
 
17
17
  import { spawnSync } from 'node:child_process';
18
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
18
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
19
19
  import { tmpdir } from 'node:os';
20
20
  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
- import { loadProject, projectGateDir, readLedger, runGate } from '../gate.mjs';
23
+ import { LEDGER_TAIL, ledgerPath, loadProject, projectGateDir, readLedger, runGate } from '../gate.mjs';
24
+ import { storedRecords } from './snapshot.mjs';
24
25
  import { layerRootFor } from './compose.mjs';
26
+ import { handsToModel } from '../detectors.mjs';
25
27
 
26
28
  /**
27
- * The gate's failures a person has not been told about yet, as one sentence, or null. Each failure is
28
- * reported once: a detector that repeats one transient error on every turn for a day is the noise the
29
- * detectors exist to remove, and the gate's own log keeps the history.
29
+ * The gate's failures the model has not been told about yet, as one sentence, or null. Each failure is
30
+ * reported until the prompt hook hands it over, and not after: a detector that repeats one transient
31
+ * error on every turn for a day is the noise the detectors exist to remove, and the gate's own log keeps
32
+ * the history. Spent by the first run that saw it, it was usually spent by the Stop hook, whose line the
33
+ * model never reads, or by a check run by hand.
30
34
  *
31
35
  * @param {string} target - The project directory.
32
36
  * @returns {string|null}
@@ -50,7 +54,7 @@ function newErrors(target) {
50
54
  if (fresh.length === 0) return null;
51
55
  const last = fresh.at(-1);
52
56
  try {
53
- writeFileSync(seenFile, `${last.at}\n`);
57
+ if (handsToModel()) writeFileSync(seenFile, `${last.at}\n`);
54
58
  } catch {
55
59
  // Unwritable: it is said again next time, which is better than never.
56
60
  }
@@ -60,6 +64,12 @@ function newErrors(target) {
60
64
  );
61
65
  }
62
66
 
67
+ /**
68
+ * When this check first ran in the project, kept beside its ledgers: what the pipeline did before then
69
+ * was never the gate's to see, so the live check asks only about what came after.
70
+ */
71
+ const SINCE = 'checked-since';
72
+
63
73
  /** Whether the gate can write its ledger here. It lets every call through when it cannot. */
64
74
  function writable(target) {
65
75
  const dir = projectGateDir(target);
@@ -68,12 +78,70 @@ function writable(target) {
68
78
  const probe = join(dir, `.selftest-${process.pid}`);
69
79
  writeFileSync(probe, '');
70
80
  rmSync(probe);
81
+ if (!existsSync(join(dir, SINCE))) writeFileSync(join(dir, SINCE), `${new Date().toISOString()}\n`);
71
82
  return null;
72
83
  } catch (error) {
73
84
  return `the gate cannot write its ledger in ${dir} — ${error.message}. It lets every call through when it cannot, so no cap is held`;
74
85
  }
75
86
  }
76
87
 
88
+ /** How long after a report the gate is asked whether it saw it: the two are written moments apart. */
89
+ const SETTLE_MS = 120_000;
90
+
91
+ /** Fewer reports than this in a session, and a miss is not yet a pattern. */
92
+ const LIVE_SAMPLE = 3;
93
+
94
+ /**
95
+ * Whether the gate saw what the pipeline actually did — what the dry run cannot answer, since it feeds the
96
+ * gate events written here, in the shape Claude Code sent when they were written. The gate reads fields
97
+ * of what Claude Code sends each hook, and the transcripts it writes have changed shape twice under this
98
+ * harness; were the hooks' payloads to change, the gate would record nothing and let everything through,
99
+ * and the dry run would still pass. So the reports of the last session with enough of them — each round
100
+ * whose first line declares a verdict, as the snapshot read it from the transcripts — are set against the
101
+ * verdicts that session's ledger holds. The gate recording fewer than half of them is a finding.
102
+ *
103
+ * @param {string} target - The project directory.
104
+ * @param {{graph: {stages: Set<string>}, tokens: Map<string, Set<string>>}} project - Its composed graph.
105
+ * @returns {string|null}
106
+ */
107
+ function liveness(target, project) {
108
+ const dir = projectGateDir(target);
109
+ let since;
110
+ try {
111
+ since = readFileSync(join(dir, SINCE), 'utf8').trim();
112
+ } catch {
113
+ return null;
114
+ }
115
+ const now = Date.now();
116
+ const reports = (storedRecords(target) ?? []).filter(
117
+ (r) =>
118
+ r.verdict_source === 'handback' &&
119
+ r.session &&
120
+ project.graph.stages.has(r.role) &&
121
+ project.tokens.get(r.role)?.has(r.verdict) &&
122
+ String(r.result_ts) >= since &&
123
+ now - Date.parse(r.result_ts) > SETTLE_MS,
124
+ );
125
+ const bySession = new Map();
126
+ for (const r of reports) bySession.set(r.session, [...(bySession.get(r.session) ?? []), r]);
127
+ const last = [...bySession.values()]
128
+ .filter((rs) => rs.length >= LIVE_SAMPLE)
129
+ .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
+ .at(-1);
131
+ if (!last) return null;
132
+ const path = ledgerPath(target, last[0].session);
133
+ const entries = readLedger(path);
134
+ // A ledger too large to read whole is read from its tail; what came before the tail is not asked about.
135
+ const from = entries.length > 0 && statSync(path).size > LEDGER_TAIL ? String(entries[0].at) : '';
136
+ const shown = last.filter((r) => String(r.result_ts) >= from).length;
137
+ 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
+ );
143
+ }
144
+
77
145
  /**
78
146
  * Runs the gate the way Claude Code runs it — the command in this project's own settings, through
79
147
  * `sh` and whatever `node` is on the PATH — over a whole loop in the shapes Claude Code sends: each
@@ -135,6 +203,15 @@ function dryRun(target) {
135
203
  // Reported by the wiring check; the script is run directly instead.
136
204
  }
137
205
  const command = hookCommand(settings, 'PreToolUse', GATE) ?? `node "${join(target, GATE)}"`;
206
+ // A hook that runs past its timeout is killed before `finally` can remove its directory; one left by a
207
+ // run an hour gone is such a one, and goes now.
208
+ for (const left of readdirSync(tmpdir()).filter((f) => f.startsWith('nina-gate-'))) {
209
+ try {
210
+ if (Date.now() - statSync(join(tmpdir(), left)).mtimeMs > 3_600_000) rmSync(join(tmpdir(), left), { recursive: true, force: true });
211
+ } catch {
212
+ // Another run's, going as this one looks.
213
+ }
214
+ }
138
215
  const data = mkdtempSync(join(tmpdir(), 'nina-gate-'));
139
216
  try {
140
217
  const run = spawnSync('sh', ['-c', command], {
@@ -209,7 +286,8 @@ export async function gate(argv, ctx) {
209
286
  const problems = [];
210
287
  const composed = existsSync(join(target, GATE));
211
288
  if (!composed) problems.push(`${GATE} is not composed — run \`nina compose\``);
212
- problems.push(...(await missingWiring(target, new Set([GATE]))));
289
+ const unwired = await missingWiring(target, new Set([GATE]));
290
+ problems.push(...unwired);
213
291
  const cannotWrite = writable(target);
214
292
  if (cannotWrite) problems.push(cannotWrite);
215
293
  const failing = newErrors(target);
@@ -218,9 +296,14 @@ export async function gate(argv, ctx) {
218
296
  const dry = dryRun(target);
219
297
  if (dry) problems.push(dry);
220
298
  }
299
+ // A gate that is not wired sees nothing, and says so above; asked whether it saw the pipeline, it would
300
+ // say the same thing twice.
301
+ const project = composed && unwired.length === 0 ? loadProject(target) : null;
302
+ const blind = project ? liveness(target, project) : null;
303
+ if (blind) problems.push(blind);
221
304
 
222
305
  if (problems.length === 0) {
223
- console.log(` wired, writable, no new failure, and a dry run through the hook command sent the round past a cap to the owner — ledgers in ${projectGateDir(target)}`);
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)}`);
224
307
  console.log('gate: current');
225
308
  return 0;
226
309
  }
@@ -16,9 +16,9 @@ import { createInterface } from 'node:readline/promises';
16
16
  import { HARNESS } from '../paths.mjs';
17
17
  import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
18
18
  import { applyWiring, missingWiring, shippedScripts } from '../wiring.mjs';
19
- import { existsSync, lstatSync, readFileSync } from 'node:fs';
19
+ import { existsSync, readFileSync } from 'node:fs';
20
20
  import { join, resolve } from 'node:path';
21
- import { REQUIRES, SLOT, byVersion, composeProject, composedPaths, defaultedSlots, layerRootFor, walk } from './compose.mjs';
21
+ import { REQUIRES, SLOT, byVersion, composeProject, composedPaths, defaultedSlots, layerRootFor, projectOwned, walk } from './compose.mjs';
22
22
  import { owedDocuments } from './check.mjs';
23
23
  import { defaultVocabulary } from '../vocabulary.mjs';
24
24
  import { PINK, useColor } from '../banner.mjs';
@@ -269,6 +269,20 @@ export async function init(argv, ctx) {
269
269
  console.error(` ${target} already has a .nina/profile.json — pass --force to start over.\n`);
270
270
  return 1;
271
271
  }
272
+ // Started over, a project keeps what it had declared and asks again only about its surfaces: `init`
273
+ // itself says to re-run with --force to add one, and doing so used to write a profile from nothing —
274
+ // every vocabulary value and integration gone, and the pin moved to the newest release past every
275
+ // check `upgrade` makes.
276
+ let previous = null;
277
+ try {
278
+ previous = JSON.parse(readFileSync(join(harness, 'profile.json'), 'utf8'));
279
+ } catch {
280
+ // None, or unreadable: there is nothing to keep.
281
+ }
282
+ if (previous?.core && arg('--core') && arg('--core') !== previous.core) {
283
+ console.error(` ${target} pins ${previous.core}. A pin moves with \`nina upgrade --to ${arg('--core')}\`, which says what the move costs and rolls it back when it fails.\n`);
284
+ return 1;
285
+ }
272
286
 
273
287
  const tree = await walk(target);
274
288
  // Kept as the matched entries, not just their names: the summary prints WHY each surface was
@@ -284,7 +298,7 @@ export async function init(argv, ctx) {
284
298
  .filter((e) => e.isDirectory())
285
299
  .map((e) => e.name)
286
300
  .sort(byVersion);
287
- const core = arg('--core') ?? releases.at(-1) ?? 'dev';
301
+ const core = arg('--core') ?? previous?.core ?? releases.at(-1) ?? 'dev';
288
302
  const resolved = layerRootFor(ctx.root, core);
289
303
  if (resolved.error) {
290
304
  console.error(` ${resolved.error}\n`);
@@ -357,12 +371,19 @@ export async function init(argv, ctx) {
357
371
  const defaults = defaultVocabulary(resolved.dir);
358
372
  const defaulted = [...vocabulary].filter((name) => name in defaults).sort();
359
373
  for (const name of defaulted) vocabulary.delete(name);
374
+ // What the profile being replaced had filled in, for a name these layers still use or one the release
375
+ // defaults and the project had chosen its own value for.
376
+ const kept = Object.entries(previous?.vocabulary ?? {}).filter(([name, value]) => value !== null && value !== undefined && (vocabulary.has(name) || name in defaults));
377
+ for (const [name] of kept) vocabulary.delete(name);
378
+ const keptIntegrations = surfaces.includes('integrations') && Array.isArray(previous?.integrations) ? previous.integrations : [];
360
379
 
361
380
  const profile = {
362
381
  core,
363
382
  surfaces,
364
- ...(surfaces.includes('integrations') ? { integrations: [] } : {}),
365
- vocabulary: Object.fromEntries([...vocabulary].sort().map((name) => [name, null])),
383
+ ...(surfaces.includes('integrations') ? { integrations: keptIntegrations } : {}),
384
+ vocabulary: Object.fromEntries([...[...vocabulary].map((name) => [name, null]), ...kept].sort(([a], [b]) => a.localeCompare(b))),
385
+ // Anything else the profile said is the project's, and stays.
386
+ ...Object.fromEntries(Object.entries(previous ?? {}).filter(([key]) => !['core', 'surfaces', 'integrations', 'vocabulary'].includes(key))),
366
387
  };
367
388
 
368
389
  await mkdir(join(harness, 'project', 'tree'), { recursive: true });
@@ -379,16 +400,7 @@ export async function init(argv, ctx) {
379
400
  // Files of the project's own where the harness composes one: a CLAUDE.md or an agent spec written by
380
401
  // hand before the harness arrived — the adoption case — or a symlink, which compose would write
381
402
  // through. Composing over them would destroy them, so they hold the compose back and are named.
382
- const inTheWay = [];
383
- for (const p of await composedPaths(resolved.dir, surfaces)) {
384
- let entry = null;
385
- try {
386
- entry = lstatSync(join(target, p));
387
- } catch {
388
- continue;
389
- }
390
- if (entry.isSymbolicLink() || !entry.isFile() || !readFileSync(join(target, p), 'utf8').includes('nina:generated')) inTheWay.push(p);
391
- }
403
+ const inTheWay = projectOwned(target, await composedPaths(resolved.dir, surfaces));
392
404
 
393
405
  const cut = (s) => (s.length > 96 ? `${s.slice(0, 96)}…` : s);
394
406
  const owed = await owedDocuments(resolved.dir, surfaces);
@@ -532,6 +544,13 @@ export async function init(argv, ctx) {
532
544
  }
533
545
 
534
546
  console.log(` initialised ${target}\n`);
547
+ if (previous) {
548
+ console.log(
549
+ ` kept from the profile it replaced: the pin ${core}, ${kept.length} vocabulary value(s)` +
550
+ (surfaces.includes('integrations') ? `, ${keptIntegrations.length} integration(s)` : '') +
551
+ '\n',
552
+ );
553
+ }
535
554
  if (!asked && !canAsk) {
536
555
  const greenfield = tree.filter((f) => !f.startsWith('.git') && !f.startsWith(HARNESS)).length === 0;
537
556
  if (greenfield) {
@@ -26,7 +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 { isLoopBack } from '../transcripts.mjs';
29
+ import { isLoopBack, runOf } from '../transcripts.mjs';
30
30
  import { REQUIRES, byVersion, layerRootFor } from './compose.mjs';
31
31
  import { GRADUATION_AT, frontmatter, graduationTarget, list, pillFiles, roleGates } from './pills.mjs';
32
32
  import { decodeProjectDir } from './stats.mjs';
@@ -35,6 +35,7 @@ import { loadProject, projectGateDir, readLedger, replay } from '../gate.mjs';
35
35
  import { GATE, shippedScripts } from '../wiring.mjs';
36
36
  import { deepLearn, transcriptsOf } from '../deep.mjs';
37
37
  import { autoExport } from './export.mjs';
38
+ import { beforeModel } from '../detectors.mjs';
38
39
 
39
40
  /** Loop-backs from one role, since its newest lesson, that make a lesson overdue. */
40
41
  export const CAPTURE_AT = 3;
@@ -172,13 +173,17 @@ export function overdue(records, known, now = new Date(), days = CAPTURE_DAYS) {
172
173
  */
173
174
  export function applied(records, known) {
174
175
  const taught = new Set(known.filter((l) => !l.retired).flatMap((l) => l.roles));
175
- // Only records captured after the field existed can answer; the rest are unknown, not "no".
176
- const measured = records.filter((r) => taught.has(r.role) && 'lessons_read' in r);
176
+ // Only records captured after the field existed can answer; the rest are unknown, not "no". Asked of
177
+ // runs, not rounds: a lesson a run read in its first round is in front of it in every round after.
178
+ const runs = new Map();
179
+ for (const r of records.filter((r) => taught.has(r.role) && 'lessons_read' in r)) runs.set(runOf(r), [...(runs.get(runOf(r)) ?? []), r]);
180
+ const measured = [...runs.values()];
181
+ const read = (rounds) => rounds.some((r) => r.lessons_read > 0);
177
182
  return {
178
183
  measured: measured.length,
179
- read: measured.filter((r) => r.lessons_read > 0).length,
180
- listed: measured.filter((r) => !(r.lessons_read > 0) && r.lessons_listed).length,
181
- from: measured.map((r) => day(r.ts)).sort()[0] ?? null,
184
+ read: measured.filter(read).length,
185
+ listed: measured.filter((rounds) => !read(rounds) && rounds.some((r) => r.lessons_listed)).length,
186
+ from: measured.map((rounds) => day(rounds[0].ts)).sort()[0] ?? null,
182
187
  };
183
188
  }
184
189
 
@@ -598,7 +603,11 @@ export async function learn(argv, ctx) {
598
603
  const stuck = owed
599
604
  .filter((l) => l.roles.length === 0)
600
605
  .map((lesson) => ({ lesson, why: 'names no role in applies_to, so no layer can take it', fix: 'fix its frontmatter — `nina pills` says what is wrong' }));
601
- for (const lesson of owed.filter((l) => l.roles.length > 0)) {
606
+ // Sent from the Stop hook, a request was news only the person heard: its one line said the model would
607
+ // be told, and by the next message the lesson was no longer owed, so nothing told it. It is sent by the
608
+ // run that hands its findings to the model, or by hand; the Stop hook says it is about to go.
609
+ const due = beforeModel() ? owed.filter((l) => l.roles.length > 0) : [];
610
+ for (const lesson of beforeModel() ? [] : owed.filter((l) => l.roles.length > 0)) {
602
611
  // A detector that throws is reported as one that could not run, every turn, and takes the
603
612
  // rest of its findings with it — so a request that cannot be written is a finding instead.
604
613
  try {
@@ -607,7 +616,7 @@ export async function learn(argv, ctx) {
607
616
  stuck.push({ lesson, why: `could not be written to ${HARNESS}/requests/ — ${error.message}`, fix: `make it writable, or send it by hand: \`nina learn --graduate ${lesson.rel}\`` });
608
617
  }
609
618
  }
610
- if (late.length === 0 && sent.length === 0 && stuck.length === 0 && !exported) {
619
+ if (late.length === 0 && sent.length === 0 && due.length === 0 && stuck.length === 0 && !exported) {
611
620
  console.log('learn: current');
612
621
  return 0;
613
622
  }
@@ -624,6 +633,9 @@ export async function learn(argv, ctx) {
624
633
  console.log(` ${s.lesson.rel} has recurred ${s.lesson.occurrences} times — sent to the harness as ${HARNESS}/requests/${s.file}, proposing a rule in ${s.layer}`);
625
634
  console.log(' → commit it with the pill; nothing else is owed here. `nina upgrade` to the release that answers it closes it and retires the pill');
626
635
  }
636
+ for (const lesson of due) {
637
+ console.log(` ${lesson.rel} has recurred ${lesson.occurrences} times — it goes to the harness as a request before your next message`);
638
+ }
627
639
  for (const s of stuck) {
628
640
  console.log(` ${s.lesson.rel} has recurred ${s.lesson.occurrences} times and ${s.why}`);
629
641
  console.log(` → ${s.fix}`);
@@ -633,6 +645,7 @@ export async function learn(argv, ctx) {
633
645
  `learn: ${[
634
646
  late.length && `${late.length} role(s) keep being sent back with nothing written down`,
635
647
  sent.length && `${sent.length} lesson(s) sent to the harness`,
648
+ due.length && `${due.length} lesson(s) to send to the harness`,
636
649
  stuck.length && `${stuck.length} lesson(s) cannot be sent`,
637
650
  exported && 'the Langfuse export needs a look',
638
651
  ]
@@ -652,7 +665,7 @@ export async function learn(argv, ctx) {
652
665
  console.log(`\n learning — core ${profile.core}\n`);
653
666
  console.log(
654
667
  records.length
655
- ? ` observe ${records.length} dispatch(es) recorded, ${span}`
668
+ ? ` observe ${new Set(records.map(runOf)).size} run(s) in ${records.length} round(s) recorded, ${span}`
656
669
  : ' observe ✗ nothing recorded — no transcripts found for this project, so no link below can be measured',
657
670
  );
658
671
  if (late.length === 0) console.log(` capture ✓ no role has looped back ${CAPTURE_AT}+ times without a lesson in the last ${days} days`);
@@ -10,14 +10,15 @@
10
10
  * back and draws it.
11
11
  */
12
12
 
13
- import { existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
13
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
14
14
  import { basename, join, resolve } from 'node:path';
15
15
  import { dim, pink } from '../look.mjs';
16
- import { HARNESS, slugFor, snapshotsDir } from '../paths.mjs';
16
+ import { HARNESS } from '../paths.mjs';
17
17
  import { TERMINALS, parseGraph } from '../graph.mjs';
18
18
  import { required } from '../tools.mjs';
19
- import { isLoopBack } from '../transcripts.mjs';
19
+ import { isLoopBack, runOf } from '../transcripts.mjs';
20
20
  import { frontmatter } from './pills.mjs';
21
+ import { storedRecords } from './snapshot.mjs';
21
22
 
22
23
  /** A stage line under "## Stages": "- `name` — what it does". */
23
24
  const STAGE_LINE = /^- `([a-z][a-z-]*)` — (.*)$/;
@@ -133,19 +134,24 @@ export function pickShape(shapes, query) {
133
134
  }
134
135
 
135
136
  /**
136
- * What each stage did since a date, from the measurement store: its runs, the verdicts that could be read,
137
- * and how many of those sent work back. A run a hook denied never ran, and is not counted.
137
+ * What each stage did since a date, from the measurement store: its runs, their rounds, the verdicts that
138
+ * could be read, and how many of those sent work back. A run a hook denied never ran, and is not counted.
138
139
  *
139
- * @param {object[]} records - The project's records.
140
+ * @param {object[]} records - The project's records, one per round.
140
141
  * @param {string} since - An ISO date.
141
- * @returns {Map<string, {runs: number, read: number, back: number}>}
142
+ * @returns {Map<string, {runs: number, rounds: number, read: number, back: number}>}
142
143
  */
143
144
  export function historyOf(records, since) {
144
145
  const byRole = new Map();
146
+ const seen = new Map();
145
147
  for (const r of records) {
146
148
  if (!r.role || r.status === 'denied' || String(r.ts ?? '') < since) continue;
147
- const s = byRole.get(r.role) ?? { runs: 0, read: 0, back: 0 };
148
- s.runs += 1;
149
+ const s = byRole.get(r.role) ?? { runs: 0, rounds: 0, read: 0, back: 0 };
150
+ const runs = seen.get(r.role) ?? new Set();
151
+ runs.add(runOf(r));
152
+ seen.set(r.role, runs);
153
+ s.runs = runs.size;
154
+ s.rounds += 1;
149
155
  if (r.verdict && r.verdict !== 'UNCLEAR' && r.verdict !== 'NONE') {
150
156
  s.read += 1;
151
157
  if (isLoopBack(r.verdict)) s.back += 1;
@@ -155,37 +161,6 @@ export function historyOf(records, since) {
155
161
  return byRole;
156
162
  }
157
163
 
158
- /**
159
- * The project's records in the measurement store, under the name Claude Code gives its directory — as
160
- * given, or with its links resolved, since either may be the one the sessions ran in.
161
- *
162
- * @param {string} dir - The project.
163
- * @returns {object[]|null} Null when the store has nothing for it.
164
- */
165
- function storedRuns(dir) {
166
- const names = new Set([slugFor(dir)]);
167
- try {
168
- names.add(slugFor(realpathSync(dir)));
169
- } catch {
170
- // A directory that cannot be resolved is looked up as given.
171
- }
172
- for (const name of names) {
173
- const file = join(snapshotsDir(), `${name}.jsonl`);
174
- if (!existsSync(file)) continue;
175
- return readFileSync(file, 'utf8')
176
- .split('\n')
177
- .filter(Boolean)
178
- .flatMap((l) => {
179
- try {
180
- return [JSON.parse(l)];
181
- } catch {
182
- return [];
183
- }
184
- });
185
- }
186
- return null;
187
- }
188
-
189
164
  /**
190
165
  * Runs the command.
191
166
  *
@@ -309,7 +284,7 @@ export async function pipeline(argv) {
309
284
  specs.set(file.replace(/\.md$/, ''), readFileSync(join(agentsDir, file), 'utf8'));
310
285
  }
311
286
  const since = arg('--since') || new Date(Date.now() - HISTORY_DAYS * 86_400_000).toISOString().slice(0, 10);
312
- const runs = storedRuns(dir);
287
+ const runs = storedRecords(dir);
313
288
  const history = runs ? historyOf(runs, since) : null;
314
289
  const stages = [...graph.stages].filter((s) => covered.has(s));
315
290
  const col = Math.max(...stages.map((s) => s.length), 10) + 2;
@@ -323,7 +298,7 @@ export async function pipeline(argv) {
323
298
  const h = history.get(stage);
324
299
  const said = !h
325
300
  ? 'no run'
326
- : `${h.runs} run(s)${h.read === 0 ? ', no verdict read' : `, sent back ${h.back} of ${h.read} (${Math.round((100 * h.back) / h.read)}%)`}`;
301
+ : `${h.runs} run(s)${h.rounds > h.runs ? ` in ${h.rounds} rounds` : ''}${h.read === 0 ? ', no verdict read' : `, sent back ${h.back} of ${h.read} (${Math.round((100 * h.back) / h.read)}%)`}`;
327
302
  console.log(` ${' '.repeat(col)}${dim('ran ')}${said}`);
328
303
  }
329
304
  const skills = [...required(spec).skills].sort();
@@ -9,8 +9,8 @@
9
9
  */
10
10
 
11
11
  import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
12
- import { snapshotsDir } from '../paths.mjs';
13
- import { createReadStream } from 'node:fs';
12
+ import { slugFor, snapshotsDir } from '../paths.mjs';
13
+ import { createReadStream, existsSync, readFileSync, realpathSync } from 'node:fs';
14
14
  import { createInterface } from 'node:readline';
15
15
  import { join, resolve } from 'node:path';
16
16
  import { listProjects, scanProject } from '../transcripts.mjs';
@@ -86,6 +86,37 @@ async function writeAtomic(file, records) {
86
86
  await rename(tmp, file);
87
87
  }
88
88
 
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
+
89
120
  /**
90
121
  * Runs the snapshot.
91
122
  *
@@ -149,7 +180,7 @@ export async function snapshot(argv, ctx) {
149
180
  if (!quiet) {
150
181
  const span = `${String(records[0].ts).slice(0, 10)} → ${String(records.at(-1).ts).slice(0, 10)}`;
151
182
  console.log(
152
- ` ${project.slug.padEnd(48)} ${String(records.length).padStart(5)} dispatches ` +
183
+ ` ${project.slug.padEnd(48)} ${String(records.length).padStart(5)} rounds ` +
153
184
  `${String(fresh).padStart(5)} new ${span}`,
154
185
  );
155
186
  }
@@ -157,7 +188,7 @@ export async function snapshot(argv, ctx) {
157
188
 
158
189
 
159
190
  if (!quiet) {
160
- console.log(`\n ${totalNew} new dispatch${totalNew === 1 ? '' : 'es'} captured → ${outDir}`);
191
+ console.log(`\n ${totalNew} new round${totalNew === 1 ? '' : 's'} captured → ${outDir}`);
161
192
  }
162
193
  return 0;
163
194
  }