@xhulz/nina 0.39.0 → 0.40.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 (152) hide show
  1. package/package.json +1 -1
  2. package/releases/0.40.0/core/GAPS.md +125 -0
  3. package/releases/0.40.0/core/answered.json +32 -0
  4. package/releases/0.40.0/core/defaults/tree/.claude/agents/architect.md +1 -0
  5. package/releases/0.40.0/core/defaults/tree/.claude/agents/dba.md +3 -0
  6. package/releases/0.40.0/core/defaults/tree/.claude/agents/devops.md +1 -0
  7. package/releases/0.40.0/core/defaults/tree/.claude/agents/implementer.md +1 -0
  8. package/releases/0.40.0/core/defaults/tree/.claude/agents/integration-tester.md +3 -0
  9. package/releases/0.40.0/core/defaults/tree/.claude/agents/qa.md +2 -0
  10. package/releases/0.40.0/core/defaults/tree/.claude/agents/reviewer.md +3 -0
  11. package/releases/0.40.0/core/defaults/tree/.claude/agents/secops.md +3 -0
  12. package/releases/0.40.0/core/defaults/tree/.claude/agents/solidity-auditor.md +3 -0
  13. package/releases/0.40.0/core/defaults/tree/.claude/agents/solidity-dev.md +3 -0
  14. package/releases/0.40.0/core/tree/.claude/agents/architect.md +127 -0
  15. package/releases/0.40.0/core/tree/.claude/agents/dba.md +110 -0
  16. package/releases/0.40.0/core/tree/.claude/agents/devops.md +114 -0
  17. package/releases/0.40.0/core/tree/.claude/agents/implementer.md +156 -0
  18. package/releases/0.40.0/core/tree/.claude/agents/integration-tester.md +168 -0
  19. package/releases/0.40.0/core/tree/.claude/agents/planner.md +132 -0
  20. package/releases/0.40.0/core/tree/.claude/agents/qa.md +152 -0
  21. package/releases/0.40.0/core/tree/.claude/agents/reviewer.md +183 -0
  22. package/releases/0.40.0/core/tree/.claude/agents/secops.md +121 -0
  23. package/releases/0.40.0/core/tree/.claude/agents/solidity-auditor.md +126 -0
  24. package/releases/0.40.0/core/tree/.claude/agents/solidity-dev.md +123 -0
  25. package/releases/0.40.0/core/tree/.claude/agents-overview.md +64 -0
  26. package/releases/0.40.0/core/tree/.claude/frontend.md +10 -0
  27. package/releases/0.40.0/core/tree/.claude/graph.md +75 -0
  28. package/releases/0.40.0/core/tree/.claude/patterns.md +213 -0
  29. package/releases/0.40.0/core/tree/.claude/pills/README.md +147 -0
  30. package/releases/0.40.0/core/tree/.claude/pipeline.md +101 -0
  31. package/releases/0.40.0/core/tree/.claude/retrieval.md +103 -0
  32. package/releases/0.40.0/core/tree/.claude/router.md +198 -0
  33. package/releases/0.40.0/core/tree/.claude/templates/integration.md +45 -0
  34. package/releases/0.40.0/core/tree/CLAUDE.md +163 -0
  35. package/releases/0.40.0/core/tree/scripts/cost-watch.mjs +25 -0
  36. package/releases/0.40.0/core/tree/scripts/edit-guard.mjs +24 -0
  37. package/releases/0.40.0/core/tree/scripts/harness-check.mjs +71 -0
  38. package/releases/0.40.0/core/tree/scripts/loop-gate.mjs +19 -0
  39. package/releases/0.40.0/core/vocabulary.json +13 -0
  40. package/releases/0.40.0/surfaces/blockchain/tree/.claude/agents/architect.md +5 -0
  41. package/releases/0.40.0/surfaces/blockchain/tree/.claude/agents/devops.md +5 -0
  42. package/releases/0.40.0/surfaces/blockchain/tree/.claude/agents/reviewer.md +5 -0
  43. package/releases/0.40.0/surfaces/blockchain/tree/.claude/agents/solidity-auditor.md +18 -0
  44. package/releases/0.40.0/surfaces/blockchain/tree/.claude/agents/solidity-dev.md +16 -0
  45. package/releases/0.40.0/surfaces/blockchain/tree/.claude/agents-overview.md +6 -0
  46. package/releases/0.40.0/surfaces/blockchain/tree/.claude/graph.md +11 -0
  47. package/releases/0.40.0/surfaces/blockchain/tree/.claude/router.md +6 -0
  48. package/releases/0.40.0/surfaces/blockchain/tree/CLAUDE.md +12 -0
  49. package/releases/0.40.0/surfaces/db/tree/.claude/agents/architect.md +5 -0
  50. package/releases/0.40.0/surfaces/db/tree/.claude/agents/dba.md +50 -0
  51. package/releases/0.40.0/surfaces/db/tree/.claude/agents/devops.md +5 -0
  52. package/releases/0.40.0/surfaces/db/tree/.claude/agents/implementer.md +2 -0
  53. package/releases/0.40.0/surfaces/db/tree/.claude/agents/integration-tester.md +10 -0
  54. package/releases/0.40.0/surfaces/db/tree/.claude/agents/planner.md +2 -0
  55. package/releases/0.40.0/surfaces/db/tree/.claude/agents/reviewer.md +22 -0
  56. package/releases/0.40.0/surfaces/db/tree/.claude/agents/secops.md +2 -0
  57. package/releases/0.40.0/surfaces/db/tree/.claude/agents-overview.md +5 -0
  58. package/releases/0.40.0/surfaces/db/tree/.claude/graph.md +8 -0
  59. package/releases/0.40.0/surfaces/db/tree/.claude/patterns.md +19 -0
  60. package/releases/0.40.0/surfaces/db/tree/.claude/pipeline.md +13 -0
  61. package/releases/0.40.0/surfaces/db/tree/.claude/retrieval.md +5 -0
  62. package/releases/0.40.0/surfaces/db/tree/.claude/router.md +4 -0
  63. package/releases/0.40.0/surfaces/db/tree/CLAUDE.md +17 -0
  64. package/releases/0.40.0/surfaces/edge-cf/tree/.claude/agents/architect.md +14 -0
  65. package/releases/0.40.0/surfaces/edge-cf/tree/.claude/agents/dba.md +2 -0
  66. package/releases/0.40.0/surfaces/edge-cf/tree/.claude/agents/devops.md +32 -0
  67. package/releases/0.40.0/surfaces/edge-cf/tree/.claude/agents/implementer.md +14 -0
  68. package/releases/0.40.0/surfaces/edge-cf/tree/.claude/agents/integration-tester.md +11 -0
  69. package/releases/0.40.0/surfaces/edge-cf/tree/.claude/agents/qa.md +3 -0
  70. package/releases/0.40.0/surfaces/edge-cf/tree/.claude/agents/reviewer.md +8 -0
  71. package/releases/0.40.0/surfaces/edge-cf/tree/.claude/agents/secops.md +8 -0
  72. package/releases/0.40.0/surfaces/edge-cf/tree/.claude/patterns.md +17 -0
  73. package/releases/0.40.0/surfaces/edge-cf/tree/.claude/retrieval.md +8 -0
  74. package/releases/0.40.0/surfaces/edge-cf/tree/CLAUDE.md +27 -0
  75. package/releases/0.40.0/surfaces/frontend/tree/.claude/agents/architect.md +20 -0
  76. package/releases/0.40.0/surfaces/frontend/tree/.claude/agents/devops.md +28 -0
  77. package/releases/0.40.0/surfaces/frontend/tree/.claude/agents/implementer.md +25 -0
  78. package/releases/0.40.0/surfaces/frontend/tree/.claude/agents/qa.md +17 -0
  79. package/releases/0.40.0/surfaces/frontend/tree/.claude/agents/reviewer.md +56 -0
  80. package/releases/0.40.0/surfaces/frontend/tree/.claude/patterns.md +8 -0
  81. package/releases/0.40.0/surfaces/frontend/tree/.claude/retrieval.md +2 -0
  82. package/releases/0.40.0/surfaces/frontend/tree/.claude/router.md +14 -0
  83. package/releases/0.40.0/surfaces/frontend/tree/CLAUDE.md +5 -0
  84. package/releases/0.40.0/surfaces/integrations/tree/.claude/agents/architect.md +16 -0
  85. package/releases/0.40.0/surfaces/integrations/tree/.claude/agents/implementer.md +12 -0
  86. package/releases/0.40.0/surfaces/integrations/tree/.claude/agents/integration-tester.md +20 -0
  87. package/releases/0.40.0/surfaces/integrations/tree/.claude/agents/planner.md +2 -0
  88. package/releases/0.40.0/surfaces/integrations/tree/.claude/agents/reviewer.md +21 -0
  89. package/releases/0.40.0/surfaces/integrations/tree/.claude/agents/secops.md +2 -0
  90. package/releases/0.40.0/surfaces/integrations/tree/.claude/agents-overview.md +8 -0
  91. package/releases/0.40.0/surfaces/integrations/tree/.claude/graph.md +10 -0
  92. package/releases/0.40.0/surfaces/integrations/tree/.claude/patterns.md +38 -0
  93. package/releases/0.40.0/surfaces/integrations/tree/.claude/pipeline.md +16 -0
  94. package/releases/0.40.0/surfaces/integrations/tree/.claude/retrieval.md +16 -0
  95. package/releases/0.40.0/surfaces/integrations/tree/.claude/router.md +6 -0
  96. package/releases/0.40.0/surfaces/integrations/tree/CLAUDE.md +17 -0
  97. package/releases/0.40.0/surfaces/money/tree/.claude/agents/architect.md +12 -0
  98. package/releases/0.40.0/surfaces/money/tree/.claude/agents/dba.md +14 -0
  99. package/releases/0.40.0/surfaces/money/tree/.claude/agents/implementer.md +11 -0
  100. package/releases/0.40.0/surfaces/money/tree/.claude/agents/integration-tester.md +3 -0
  101. package/releases/0.40.0/surfaces/money/tree/.claude/agents/reviewer.md +23 -0
  102. package/releases/0.40.0/surfaces/money/tree/.claude/agents/secops.md +5 -0
  103. package/releases/0.40.0/surfaces/money/tree/.claude/agents/solidity-auditor.md +4 -0
  104. package/releases/0.40.0/surfaces/money/tree/.claude/agents/solidity-dev.md +4 -0
  105. package/releases/0.40.0/surfaces/money/tree/.claude/graph.md +2 -0
  106. package/releases/0.40.0/surfaces/money/tree/.claude/patterns.md +49 -0
  107. package/releases/0.40.0/surfaces/money/tree/.claude/pipeline.md +2 -0
  108. package/releases/0.40.0/surfaces/money/tree/.claude/retrieval.md +2 -0
  109. package/releases/0.40.0/surfaces/money/tree/CLAUDE.md +11 -0
  110. package/releases/0.40.0/surfaces/pii/tree/.claude/agents/dba.md +2 -0
  111. package/releases/0.40.0/surfaces/pii/tree/.claude/agents/devops.md +8 -0
  112. package/releases/0.40.0/surfaces/pii/tree/.claude/agents/reviewer.md +8 -0
  113. package/releases/0.40.0/surfaces/pii/tree/.claude/agents/secops.md +2 -0
  114. package/releases/0.40.0/surfaces/pii/tree/.claude/agents/solidity-auditor.md +5 -0
  115. package/releases/0.40.0/surfaces/pii/tree/CLAUDE.md +5 -0
  116. package/releases/0.40.0/surfaces/prisma/tree/.claude/agents/architect.md +2 -0
  117. package/releases/0.40.0/surfaces/prisma/tree/.claude/agents/dba.md +39 -0
  118. package/releases/0.40.0/surfaces/prisma/tree/.claude/agents/devops.md +11 -0
  119. package/releases/0.40.0/surfaces/prisma/tree/.claude/agents/implementer.md +2 -0
  120. package/releases/0.40.0/surfaces/prisma/tree/.claude/agents/integration-tester.md +2 -0
  121. package/releases/0.40.0/surfaces/prisma/tree/.claude/agents/reviewer.md +2 -0
  122. package/releases/0.40.0/surfaces/prisma/tree/.claude/patterns.md +15 -0
  123. package/releases/0.40.0/surfaces/prisma/tree/CLAUDE.md +5 -0
  124. package/src/commands/check.mjs +15 -28
  125. package/src/commands/compose.mjs +43 -61
  126. package/src/commands/eval.mjs +1 -1
  127. package/src/commands/export.mjs +4 -34
  128. package/src/commands/gate.mjs +4 -11
  129. package/src/commands/init.mjs +21 -29
  130. package/src/commands/langfuse.mjs +1 -1
  131. package/src/commands/learn.mjs +10 -12
  132. package/src/commands/pills.mjs +5 -5
  133. package/src/commands/pipeline.mjs +4 -8
  134. package/src/commands/release.mjs +2 -3
  135. package/src/commands/runs.mjs +1 -1
  136. package/src/commands/snapshot.mjs +12 -50
  137. package/src/commands/stats.mjs +7 -8
  138. package/src/commands/status.mjs +4 -5
  139. package/src/commands/upgrade.mjs +9 -12
  140. package/src/commands/where.mjs +9 -18
  141. package/src/commands/wire.mjs +8 -19
  142. package/src/deep.mjs +3 -3
  143. package/src/gate.mjs +5 -6
  144. package/src/graph.mjs +1 -1
  145. package/src/guard.mjs +7 -8
  146. package/src/langfuse.mjs +9 -8
  147. package/src/lock.mjs +82 -0
  148. package/src/paths.mjs +65 -0
  149. package/src/shell.mjs +3 -3
  150. package/src/steps.mjs +5 -19
  151. package/src/store.mjs +2 -2
  152. package/src/wiring.mjs +17 -6
@@ -34,7 +34,7 @@ const ENDS = {
34
34
  * @param {{runs: object[], end: string}} cycle
35
35
  * @returns {{start: string, end: string, minutes: number|null, cost: number, unpriced: number, name: string, how: string, stages: {role: string, runs: number, rounds: number, back: number}[], files: number|null, peak: number|null}}
36
36
  */
37
- export function summarize(cycle) {
37
+ function summarize(cycle) {
38
38
  const rounds = cycle.runs;
39
39
  const start = rounds.map((r) => String(r.ts)).sort()[0];
40
40
  const end = rounds.map((r) => String(r.result_ts ?? r.ts)).sort().at(-1);
@@ -8,16 +8,14 @@
8
8
  * the transcripts are hundreds of megabytes and growing.
9
9
  */
10
10
 
11
- import { copyFile, mkdir, open, readFile, rename, rm, writeFile } from 'node:fs/promises';
11
+ import { copyFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
12
12
  import { HARNESS, snapshotsDir } from '../paths.mjs';
13
13
  import { decodeProjectDir } from './stats.mjs';
14
14
  import { readStoreFile } from '../store.mjs';
15
15
  import { existsSync, readFileSync } from 'node:fs';
16
16
  import { join, resolve } from 'node:path';
17
17
  import { listProjects, scanProject } from '../transcripts.mjs';
18
-
19
- /** Where per-project read cursors live, beside the snapshots they describe. */
20
- const STATE_FILE = '.state.json';
18
+ import { takeLock } from '../lock.mjs';
21
19
 
22
20
  /**
23
21
  * Reads a JSON file, or an empty object when it is missing or unreadable. A state file cut short
@@ -64,40 +62,6 @@ async function writeAtomic(file, records) {
64
62
  await rename(tmp, file);
65
63
  }
66
64
 
67
- /**
68
- * Takes a project's lock, or says it is taken. The records and the cursors are written one after the other,
69
- * and two snapshots of one project at once — a project's own detector and a global hook, both on every
70
- * Stop — could leave the cursors of the scan that read further beside the records of the one that read less,
71
- * and whatever lay between was never captured. One that finds the lock taken skips the project: the next
72
- * snapshot reads from where this one leaves it. A lock names its process, so one left by a snapshot that
73
- * died is taken over.
74
- *
75
- * @param {string} path - The lock file.
76
- * @returns {Promise<(() => Promise<void>)|null>} Its release, or null when another snapshot holds it.
77
- */
78
- async function lock(path) {
79
- for (let attempt = 0; attempt < 2; attempt += 1) {
80
- try {
81
- const handle = await open(path, 'wx');
82
- await handle.writeFile(String(process.pid));
83
- await handle.close();
84
- return () => rm(path, { force: true });
85
- } catch (error) {
86
- if (error.code !== 'EEXIST') throw error;
87
- const pid = Number(await readFile(path, 'utf8').catch(() => ''));
88
- let alive = false;
89
- try {
90
- alive = pid > 0 && process.kill(pid, 0);
91
- } catch (e) {
92
- alive = e.code === 'EPERM';
93
- }
94
- if (alive) return null;
95
- await rm(path, { force: true });
96
- }
97
- }
98
- return null;
99
- }
100
-
101
65
  /**
102
66
  * Runs the snapshot.
103
67
  *
@@ -115,14 +79,6 @@ export async function snapshot(argv, ctx) {
115
79
  const quiet = argv.includes('--quiet');
116
80
 
117
81
  await mkdir(outDir, { recursive: true });
118
- // The cursors used to live in one file shared by every project, rewritten whole and in place.
119
- // Two snapshots at once — the global hook and a project's own detector, or two sessions ending
120
- // together — each read all of it, updated their own project, and wrote all of it back: the later
121
- // writer silently put every other project's cursor back where it had been, and a cursor that
122
- // goes back re-reads history. Each project now has its own, beside its records, written by
123
- // rename. The shared file is still read, once, so a project's cursor is not lost in the move.
124
- const legacy = rebuild ? {} : await readJson(join(outDir, STATE_FILE));
125
-
126
82
  const projects = await listProjects();
127
83
  // `--exact` for a caller that knows the slug — a project's own detector. A substring also
128
84
  // matched every project nested under it, and snapshotted them all on every turn.
@@ -136,10 +92,13 @@ export async function snapshot(argv, ctx) {
136
92
 
137
93
  let totalNew = 0;
138
94
  for (const project of targets) {
139
- const release = await lock(join(outDir, `${project.slug}.lock`));
95
+ // One at a time per project: the records and the cursors are written one after the other, and two at once could
96
+ // leave the cursors of the scan that read further beside the records of the one that read less. One that finds
97
+ // the lock held skips the project, and the next reads from where this one leaves it.
98
+ const release = await takeLock(join(outDir, `${project.slug}.lock`));
140
99
  if (!release) continue;
141
100
  try {
142
- totalNew += await snapshotProject(project, { outDir, rebuild, quiet, legacy });
101
+ totalNew += await snapshotProject(project, { outDir, rebuild, quiet });
143
102
  } finally {
144
103
  await release();
145
104
  }
@@ -192,15 +151,18 @@ function pinOf(slug) {
192
151
  *
193
152
  * @returns {Promise<number>} How many rounds it captured that were not on record.
194
153
  */
195
- async function snapshotProject(project, { outDir, rebuild, quiet, legacy }) {
154
+ async function snapshotProject(project, { outDir, rebuild, quiet }) {
196
155
  const file = join(outDir, `${project.slug}.jsonl`);
197
156
  // The store is the only copy of history older than the transcripts, and a rebuild re-reads only what is
198
157
  // still on disk: the record it replaces is kept beside it first.
199
158
  if (rebuild && existsSync(file)) await copyFile(file, `${file}.before-rebuild-${new Date().toISOString().replace(/[:.]/g, '-')}`);
200
159
  const stored = readStoreFile(file, { raw: true });
201
160
  const prior = rebuild ? [] : stored;
161
+ // Each project's read cursors live beside its records, written by rename. They once shared one file, rewritten
162
+ // whole: two snapshots at once each wrote every other project's cursor back where it had been, and a cursor
163
+ // that goes back re-reads history.
202
164
  const own = rebuild ? {} : await readJson(join(outDir, `${project.slug}.state.json`));
203
- const cursors = rebuild ? {} : (own.cursors ?? legacy[project.slug]?.cursors ?? {});
165
+ const cursors = rebuild ? {} : (own.cursors ?? {});
204
166
 
205
167
  const scanned = await scanProject(project.dir, { cursors, records: prior });
206
168
  // With its cursors gone — a state file deleted, or cut short — the transcripts are read again from the start
@@ -164,7 +164,7 @@ export function projectName(encoded) {
164
164
  * @param {string[]} projects - The encoded project names in the report.
165
165
  * @returns {Promise<{total:number, retired:number, undated:number, dates:string[], unresolved:string[]}>}
166
166
  */
167
- export async function harvest(projects) {
167
+ async function harvest(projects) {
168
168
  const out = { total: 0, retired: 0, undated: 0, dates: [], unresolved: [] };
169
169
  for (const name of projects) {
170
170
  const dir = decodeProjectDir(name);
@@ -243,7 +243,6 @@ function costReport(records) {
243
243
  if (total === 0) return;
244
244
  const money = (n) => `$${n >= 100 ? n.toFixed(0) : n.toFixed(2)}`;
245
245
  // The same convention as the duration column beside it: the upper middle of an even count.
246
- const median = (xs) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)];
247
246
  console.log(
248
247
  heading(
249
248
  'cost',
@@ -273,7 +272,6 @@ function costReport(records) {
273
272
  function contextReport(records) {
274
273
  const shaped = records.filter((r) => typeof r.turns === 'number' && r.turns > 0);
275
274
  if (shaped.length === 0) return;
276
- const median = (xs) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)];
277
275
  const k = (n) => (typeof n === 'number' ? `${Math.round(n / 1000)}k` : '—');
278
276
  const chars = (xs) => (xs.length ? median(xs).toLocaleString('en-US') : '—');
279
277
  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'));
@@ -362,7 +360,6 @@ function modelReport(records) {
362
360
  const changed = [...byRole].filter(([, models]) => models.size > 1).sort(([a], [b]) => a.localeCompare(b));
363
361
 
364
362
  const money = (n) => `$${n.toFixed(2)}`;
365
- const median = (xs) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)];
366
363
  const lines = [];
367
364
  for (const [role, models] of changed) {
368
365
  const ordered = [...models].sort(([, a], [, b]) => String(a[0].ts).localeCompare(String(b[0].ts)));
@@ -431,7 +428,7 @@ const RULE_SINCE = { issues: '0.22.0', stepFiles: '0.28.8' };
431
428
  * @param {keyof RULE_SINCE} rule - The rule.
432
429
  * @returns {boolean}
433
430
  */
434
- export const ranUnder = (record, rule) => typeof record.core === 'string' && (record.core === 'dev' || byVersion(record.core, RULE_SINCE[rule]) >= 0);
431
+ const ranUnder = (record, rule) => typeof record.core === 'string' && (record.core === 'dev' || byVersion(record.core, RULE_SINCE[rule]) >= 0);
435
432
 
436
433
  /** The stages that write code, whose runs' file counts size a cycle. */
437
434
  export const WRITERS = new Set(['implementer', 'solidity-dev']);
@@ -590,11 +587,13 @@ function stepLimitOf(ctx) {
590
587
  };
591
588
  }
592
589
 
590
+ /** The middle of a list of numbers — the upper one of two — or undefined for none. */
591
+ const median = (xs) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)];
592
+
593
593
  /** Formats a median duration in minutes from a list of seconds. */
594
594
  function medianMin(values) {
595
- const xs = values.filter((v) => typeof v === 'number' && v >= 0).sort((a, b) => a - b);
596
- if (xs.length === 0) return '—';
597
- return `${(xs[Math.floor(xs.length / 2)] / 60).toFixed(1)}m`;
595
+ const xs = values.filter((v) => typeof v === 'number' && v >= 0);
596
+ return xs.length ? `${(median(xs) / 60).toFixed(1)}m` : '—';
598
597
  }
599
598
 
600
599
  /**
@@ -14,7 +14,7 @@ import { spawnSync } from 'node:child_process';
14
14
  import { existsSync, readFileSync } from 'node:fs';
15
15
  import { createRequire } from 'node:module';
16
16
  import { join, resolve } from 'node:path';
17
- import { HARNESS } from '../paths.mjs';
17
+ import { readProfile } from '../paths.mjs';
18
18
  import { costOf } from '../prices.mjs';
19
19
  import { runOf } from '../transcripts.mjs';
20
20
  import { missingWiring, shippedScripts } from '../wiring.mjs';
@@ -86,12 +86,11 @@ function ago(iso, now) {
86
86
  */
87
87
  export async function status(argv, ctx) {
88
88
  const target = resolve(argv.includes('--project') ? argv[argv.indexOf('--project') + 1] : '.');
89
- const profilePath = join(target, HARNESS, 'profile.json');
90
- if (!existsSync(profilePath)) {
91
- console.error(` no ${HARNESS}/profile.json under ${target} — run it inside a project, or name one with --project\n`);
89
+ const { profile, error } = readProfile(target);
90
+ if (error) {
91
+ console.error(` ${error}\n`);
92
92
  return 1;
93
93
  }
94
- const profile = JSON.parse(readFileSync(profilePath, 'utf8'));
95
94
  const bin = join(ctx.root, 'bin', 'nina.mjs');
96
95
  const now = Date.now();
97
96
  const row = (label, text) => console.log(` ${pink(label.padEnd(10))} ${text}`);
@@ -9,9 +9,9 @@
9
9
  * writes only when told to. Reconciling the fragments is work, and work does not get done by a flag.
10
10
  */
11
11
 
12
- import { readFile, readdir } from 'node:fs/promises';
12
+ import { readFile } from 'node:fs/promises';
13
13
  import { spawnSync } from 'node:child_process';
14
- import { HARNESS, describeMove, interruptedMove, legacyHint, moveJournal, movedFrom } from '../paths.mjs';
14
+ import { describeMove, HARNESS, interruptedMove, movedFrom, moveJournal, readProfile, surfacesIn } from '../paths.mjs';
15
15
  import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
16
16
  import { dirname, join, resolve } from 'node:path';
17
17
  import { REMOVED, composeProject, composedPaths, defaultedSlots, layerRootFor, walk } from './compose.mjs';
@@ -20,7 +20,7 @@ import { filledSlots, projectSlots, referencedVocabulary } from './check.mjs';
20
20
  import { EXPECT_ENV } from '../expected.mjs';
21
21
  import { closeAnswered } from './learn.mjs';
22
22
  import { tidyRetired } from './pills.mjs';
23
- import { missingFragment, missingWiring, shippedScripts } from '../wiring.mjs';
23
+ import { lacksPackage, missingFragment, missingWiring, shippedScripts } from '../wiring.mjs';
24
24
  import { runSteps } from '../steps.mjs';
25
25
  import { defaultVocabulary } from '../vocabulary.mjs';
26
26
 
@@ -239,12 +239,11 @@ export async function upgrade(argv, ctx) {
239
239
  const target = resolve(arg('--project') ?? '.');
240
240
  const apply = argv.includes('--apply');
241
241
 
242
- const profilePath = join(target, HARNESS, 'profile.json');
243
- if (!existsSync(profilePath)) {
244
- console.error(` no .nina/profile.json under ${target}${legacyHint(target)}\n`);
242
+ const { profile, path: profilePath, error } = readProfile(target);
243
+ if (error) {
244
+ console.error(` ${error}\n`);
245
245
  return 1;
246
246
  }
247
- const profile = JSON.parse(await readFile(profilePath, 'utf8'));
248
247
 
249
248
  // A move stopped half-way comes before anything else asked of this command: its pin may already name the
250
249
  // version it was moving to, and "already pins" left the tree half-moved for good.
@@ -290,15 +289,13 @@ export async function upgrade(argv, ctx) {
290
289
  }
291
290
 
292
291
  const surfaces = profile.surfaces ?? [];
293
- const available = (await readdir(join(onto.dir, 'surfaces'), { withFileTypes: true }))
294
- .filter((e) => e.isDirectory())
295
- .map((e) => e.name);
292
+ const available = (await surfacesIn(onto.dir));
296
293
  const gone = surfaces.filter((s) => !available.includes(s));
297
294
  const kept = surfaces.filter((s) => available.includes(s));
298
295
  // A surface the target release adds that this project's own files show it has. When a stack is split out
299
296
  // of a concern — `prisma` out of `db` — its rules go with it, and a project that does not declare it loses
300
297
  // them in silence: the rules that were in its specs yesterday are not there after the move.
301
- const offeredBefore = (await readdir(join(from.dir, 'surfaces'), { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
298
+ const offeredBefore = (await surfacesIn(from.dir));
302
299
  const offered = detected(await walk(target)).filter(
303
300
  (d) => available.includes(d.surface) && !offeredBefore.includes(d.surface) && !surfaces.includes(d.surface) && (!NEEDS[d.surface] || kept.includes(NEEDS[d.surface])),
304
301
  );
@@ -376,7 +373,7 @@ export async function upgrade(argv, ctx) {
376
373
  for (const line of fragment.split('\n')) console.log(` ${line}`);
377
374
  }
378
375
  console.log(` \`nina wire --to ${to} --apply\` merges them. They stay inert until ${to} is pinned, so wiring first is safe.`);
379
- if (unwired.some((w) => w.startsWith('@xhulz/nina is not installed'))) {
376
+ if (lacksPackage(unwired)) {
380
377
  console.log(' The package is the one thing `wire` cannot do: `pnpm add -D -E @xhulz/nina`.');
381
378
  }
382
379
  console.log('');
@@ -19,10 +19,10 @@
19
19
  */
20
20
 
21
21
  import { existsSync } from 'node:fs';
22
- import { readFile, readdir } from 'node:fs/promises';
22
+ import { readFile } from 'node:fs/promises';
23
23
  import { isAbsolute, join, relative, resolve, sep } from 'node:path';
24
- import { HARNESS, legacyHint } from '../paths.mjs';
25
- import { REQUIRES, SLOT, defaultedSlots, generatedNotice, layerRootFor } from './compose.mjs';
24
+ import { HARNESS, readProfile, surfacesIn } from '../paths.mjs';
25
+ import { SLOT, closedGate, defaultedSlots, generatedNotice, layerRootFor } from './compose.mjs';
26
26
  import { filledSlots, owedDocuments } from './check.mjs';
27
27
  import { roleGates } from './pills.mjs';
28
28
 
@@ -71,16 +71,9 @@ export async function where(argv, ctx) {
71
71
  return 1;
72
72
  }
73
73
 
74
- const profilePath = join(target, HARNESS, 'profile.json');
75
- if (!existsSync(profilePath)) {
76
- console.error(` no ${HARNESS}/profile.json under ${target}${legacyHint(target)}\n`);
77
- return 1;
78
- }
79
- let profile;
80
- try {
81
- profile = JSON.parse(await readFile(profilePath, 'utf8'));
82
- } catch (error) {
83
- console.error(` ${profilePath} is not valid JSON — ${error.message}\n`);
74
+ const { profile, error } = readProfile(target);
75
+ if (error) {
76
+ console.error(` ${error}\n`);
84
77
  return 1;
85
78
  }
86
79
 
@@ -89,9 +82,7 @@ export async function where(argv, ctx) {
89
82
  console.error(` ${resolved.error}\n`);
90
83
  return 1;
91
84
  }
92
- const available = (await readdir(join(resolved.dir, 'surfaces'), { withFileTypes: true }))
93
- .filter((e) => e.isDirectory())
94
- .map((e) => e.name);
85
+ const available = (await surfacesIn(resolved.dir));
95
86
  const surfaces = (profile.surfaces ?? []).filter((s) => available.includes(s));
96
87
 
97
88
  console.log(` ${posix(rel)}\n`);
@@ -99,8 +90,8 @@ export async function where(argv, ctx) {
99
90
  const corePath = join(resolved.dir, 'core', 'tree', rel);
100
91
  if (existsSync(corePath)) {
101
92
  const core = await readFile(corePath, 'utf8');
102
- const gate = REQUIRES.exec(core)?.[1] ?? null;
103
- if (gate && !surfaces.includes(gate)) {
93
+ const gate = closedGate(core, surfaces);
94
+ if (gate) {
104
95
  block(`defined in core ${profile.core}, but gated on the "${gate}" surface, which this project
105
96
  does not declare — so it is never composed here and is not on disk.
106
97
  to get it: add "${gate}" to ${HARNESS}/profile.json and run \`nina compose\`.`);
@@ -12,11 +12,9 @@
12
12
  * hook runs its script only once that script is composed
13
13
  */
14
14
 
15
- import { existsSync } from 'node:fs';
16
- import { readFile, readdir } from 'node:fs/promises';
17
- import { join, resolve } from 'node:path';
18
- import { HARNESS, legacyHint } from '../paths.mjs';
19
- import { applyWiring, missingFragment, missingWiring, shippedScripts, staleHooks } from '../wiring.mjs';
15
+ import { resolve } from 'node:path';
16
+ import { readProfile, surfacesIn } from '../paths.mjs';
17
+ import { applyWiring, lacksPackage, missingFragment, missingWiring, shippedScripts, staleHooks } from '../wiring.mjs';
20
18
  import { layerRootFor } from './compose.mjs';
21
19
 
22
20
  /**
@@ -27,16 +25,9 @@ import { layerRootFor } from './compose.mjs';
27
25
  export async function wire(argv, ctx) {
28
26
  const arg = (name) => (argv.includes(name) ? argv[argv.indexOf(name) + 1] : null);
29
27
  const target = resolve(arg('--project') ?? '.');
30
- const profilePath = join(target, HARNESS, 'profile.json');
31
- if (!existsSync(profilePath)) {
32
- console.error(` no ${HARNESS}/profile.json under ${target}${legacyHint(target)}\n`);
33
- return 2;
34
- }
35
- let profile;
36
- try {
37
- profile = JSON.parse(await readFile(profilePath, 'utf8'));
38
- } catch (error) {
39
- console.error(` ${profilePath} is not valid JSON — ${error.message}\n`);
28
+ const { profile, error } = readProfile(target);
29
+ if (error) {
30
+ console.error(` ${error}\n`);
40
31
  return 2;
41
32
  }
42
33
  const version = arg('--to') ?? profile.core;
@@ -45,9 +36,7 @@ export async function wire(argv, ctx) {
45
36
  console.error(` ${resolved.error}\n`);
46
37
  return 2;
47
38
  }
48
- const available = (await readdir(join(resolved.dir, 'surfaces'), { withFileTypes: true }).catch(() => []))
49
- .filter((e) => e.isDirectory())
50
- .map((e) => e.name);
39
+ const available = (await surfacesIn(resolved.dir));
51
40
  const shipped = await shippedScripts(resolved.dir, (profile.surfaces ?? []).filter((s) => available.includes(s)));
52
41
 
53
42
  const missing = await missingWiring(target, shipped);
@@ -71,7 +60,7 @@ export async function wire(argv, ctx) {
71
60
  for (const line of fragment.split('\n')) console.log(` ${line}`);
72
61
  }
73
62
  console.log(`\n \`nina wire${arg('--to') ? ` --to ${version}` : ''} --apply\` merges them, changing nothing else — a customised command is never touched.`);
74
- if (missing.some((m) => m.startsWith('@xhulz/nina is not installed'))) {
63
+ if (lacksPackage(missing)) {
75
64
  console.log(' The package it cannot install: that one is `pnpm add -D -E @xhulz/nina`.');
76
65
  }
77
66
  console.log(`wire: ${missing.length} missing${stale.length ? `, ${stale.length} to update` : ''}`);
package/src/deep.mjs CHANGED
@@ -30,7 +30,7 @@ const REPORT_CHARS = 1500;
30
30
  /** The default model: grouping sentences, not reviewing code. */
31
31
  const MODEL = 'haiku';
32
32
 
33
- export const MAP_SCHEMA = {
33
+ const MAP_SCHEMA = {
34
34
  type: 'object',
35
35
  properties: {
36
36
  causes: {
@@ -45,7 +45,7 @@ export const MAP_SCHEMA = {
45
45
  required: ['causes'],
46
46
  };
47
47
 
48
- export const REDUCE_SCHEMA = {
48
+ const REDUCE_SCHEMA = {
49
49
  type: 'object',
50
50
  properties: {
51
51
  clusters: {
@@ -134,7 +134,7 @@ export function mapPrompt(batch) {
134
134
  }
135
135
 
136
136
  /** The reduce prompt: the causes grouped, set against the pills already written. */
137
- export function reducePrompt(causes, known) {
137
+ function reducePrompt(causes, known) {
138
138
  return [
139
139
  'Below are root causes of work a software pipeline sent back, one per report, and the lessons ("pills") already',
140
140
  'written for it. Group the causes that are the same mistake. For each group give the refs, the pill that already',
package/src/gate.mjs CHANGED
@@ -59,7 +59,7 @@ import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSyn
59
59
  import { dirname, join } from 'node:path';
60
60
  import { fileURLToPath } from 'node:url';
61
61
  import { TERMINALS, declaredTokens, parseGraph } from './graph.mjs';
62
- import { gateDir, slugFor } from './paths.mjs';
62
+ import { gateDir, layerRootFor, readProfile, slugFor } from './paths.mjs';
63
63
  import { declaredIssues, isLoopBack, saidToAgent } from './transcripts.mjs';
64
64
  import { lessonIndex } from './lessons.mjs';
65
65
 
@@ -135,12 +135,11 @@ export function ledgerPath(root, session) {
135
135
  * @param {string} [pkg] - The NINA install to look in.
136
136
  * @returns {boolean}
137
137
  */
138
- export function pinnedShipsGate(root, pkg = PACKAGE) {
138
+ function pinnedShipsGate(root, pkg = PACKAGE) {
139
139
  try {
140
- const core = JSON.parse(readFileSync(join(root, '.nina', 'profile.json'), 'utf8')).core;
141
- if (typeof core !== 'string' || !/^[A-Za-z0-9._-]+$/.test(core)) return false;
142
- const layers = core === 'dev' ? pkg : join(pkg, 'releases', core);
143
- return existsSync(join(layers, 'core', 'tree', 'scripts', 'loop-gate.mjs'));
140
+ const { profile } = readProfile(root);
141
+ const layers = profile ? layerRootFor(pkg, profile.core) : { error: 'no profile' };
142
+ return !layers.error && existsSync(join(layers.dir, 'core', 'tree', 'scripts', 'loop-gate.mjs'));
144
143
  } catch {
145
144
  return false;
146
145
  }
package/src/graph.mjs CHANGED
@@ -102,7 +102,7 @@ export function declaredTokens(spec) {
102
102
  * @param {Set<string>} known - Every stage name that could be meant.
103
103
  * @returns {Set<string>}
104
104
  */
105
- export function proseRoutes(spec, known) {
105
+ function proseRoutes(spec, known) {
106
106
  const routes = new Set();
107
107
  for (const m of spec.matchAll(/(?:back to|→)\s+\*\*([A-Za-z-]+)\*\*/g)) {
108
108
  const name = m[1].toLowerCase();
package/src/guard.mjs CHANGED
@@ -21,7 +21,7 @@ import { closeSync, existsSync, openSync, readFileSync, readSync, realpathSync }
21
21
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
22
22
  import { fileURLToPath } from 'node:url';
23
23
  import { NOTICE_HEAD, composedPaths, noticeOf } from './commands/compose.mjs';
24
- import { HARNESS } from './paths.mjs';
24
+ import { HARNESS, layerRootFor, readProfile } from './paths.mjs';
25
25
 
26
26
  // Read here and by the suites from here; the test itself lives beside the compiler that writes the notice.
27
27
  export { NOTICE_HEAD, noticeOf };
@@ -57,7 +57,7 @@ const SPECIAL = new Set(["'", '"', '\\', '#', '\n', '<', '&', ';', '|', '(', ')'
57
57
  * @param {string} command
58
58
  * @returns {string[][]}
59
59
  */
60
- export function simpleCommands(command) {
60
+ function simpleCommands(command) {
61
61
  const text = String(command);
62
62
  const commands = [[]];
63
63
  const heredocs = [];
@@ -227,13 +227,12 @@ const HEAD_BYTES = 16 * 1024;
227
227
  * @param {string} [pkg] - The NINA install to look in.
228
228
  * @returns {{layers: string, surfaces: string[]}|null}
229
229
  */
230
- export function pinnedLayers(root, pkg = PACKAGE) {
230
+ function pinnedLayers(root, pkg = PACKAGE) {
231
231
  try {
232
- const profile = JSON.parse(readFileSync(join(root, HARNESS, 'profile.json'), 'utf8'));
233
- if (typeof profile.core !== 'string' || !/^[A-Za-z0-9._-]+$/.test(profile.core)) return null;
234
- const layers = profile.core === 'dev' ? pkg : join(pkg, 'releases', profile.core);
235
- if (!existsSync(join(layers, 'core', 'tree', 'scripts', 'edit-guard.mjs'))) return null;
236
- return { layers, surfaces: Array.isArray(profile.surfaces) ? profile.surfaces : [] };
232
+ const { profile } = readProfile(root);
233
+ const layers = profile ? layerRootFor(pkg, profile.core) : { error: 'no profile' };
234
+ if (layers.error || !existsSync(join(layers.dir, 'core', 'tree', 'scripts', 'edit-guard.mjs'))) return null;
235
+ return { layers: layers.dir, surfaces: Array.isArray(profile.surfaces) ? profile.surfaces : [] };
237
236
  } catch {
238
237
  return null;
239
238
  }
package/src/langfuse.mjs CHANGED
@@ -13,10 +13,11 @@
13
13
  *
14
14
  * Observations go over OpenTelemetry (`/api/public/otel/v1/traces`, OTLP/HTTP as JSON), the path
15
15
  * Langfuse's v4 data model takes, with the session and trace attributes on every span, as its guide asks.
16
- * Verdicts go to `/api/public/scores` as categorical scores. Langfuse keeps what it was first sent: the
16
+ * Verdicts go as categorical scores, in batches to `/api/public/ingestion`, or one at a time to
17
+ * `/api/public/scores` where the batch endpoint is gone. Langfuse keeps what it was first sent: the
17
18
  * same span id sent again is a second observation, not an update, and every sum counts both; a score is
18
- * replaced only when its id, name and date all match, and that endpoint takes no date — it stamps the
19
- * day it is called. So each is sent once, and only for a run that has settled. No dependency: `fetch`
19
+ * replaced only when its id, name and date all match, and the scores endpoint takes no date — it stamps
20
+ * the day it is called. So each is sent once, and only for a run that has settled. No dependency: `fetch`
20
21
  * is enough.
21
22
  */
22
23
 
@@ -40,13 +41,13 @@ export const BATCH = 100;
40
41
  export const SETTLE_MINUTES = 15;
41
42
 
42
43
  /** How long a run that never returned is waited for before it is sent as it is. */
43
- export const UNRETURNED_HOURS = 24;
44
+ const UNRETURNED_HOURS = 24;
44
45
 
45
46
  /** A stable hex id of a given length, from what it identifies. */
46
47
  const hexId = (text, length) => createHash('sha256').update(String(text)).digest('hex').slice(0, length);
47
48
 
48
49
  /** The trace a dispatch becomes, and the observation at its root. */
49
- export const traceIdOf = (record) => hexId(`trace|${record.dispatch_id}`, 32);
50
+ const traceIdOf = (record) => hexId(`trace|${record.dispatch_id}`, 32);
50
51
  export const spanIdOf = (record) => hexId(record.dispatch_id, 16);
51
52
 
52
53
  /** An ISO time, as OTLP's nanoseconds since the epoch — a string, since it outgrows a double. */
@@ -230,7 +231,7 @@ export function scoreOf(record) {
230
231
  }
231
232
 
232
233
  /** The OTLP request body for a batch of spans. */
233
- export function otlpBody(spans) {
234
+ function otlpBody(spans) {
234
235
  return {
235
236
  resourceSpans: [
236
237
  {
@@ -247,8 +248,8 @@ export function otlpBody(spans) {
247
248
  * request cut off after Langfuse took it is the one way a span goes twice, so a batch of spans, the one
248
249
  * request that carries real weight, gets the longer wait.
249
250
  */
250
- export const REQUEST_MS = 30_000;
251
- export const SPANS_MS = 120_000;
251
+ const REQUEST_MS = 30_000;
252
+ const SPANS_MS = 120_000;
252
253
 
253
254
  /** The headers every request carries. */
254
255
  const headersFor = ({ publicKey, secretKey }) => ({
package/src/lock.mjs ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * A lock file that names the process holding it, for the commands that must not run twice at once on one
3
+ * project: two snapshots would leave one's cursors beside the other's records, two exports would send the same
4
+ * runs twice.
5
+ *
6
+ * The file appears with the pid already in it — written aside, then linked into place, which fails when a lock
7
+ * is there — so it is never read empty and taken for a dead one's. A lock whose process died is removed only by
8
+ * the taker holding its takeover, and only once it has read the holder again under it: each taker removed it
9
+ * outright before, after its own read, and the second removed the lock the first had just taken, so both held it.
10
+ */
11
+
12
+ import { link, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
13
+ import { dirname } from 'node:path';
14
+
15
+ /** Whether a process is running. One this user may not signal is running too. */
16
+ function alive(pid) {
17
+ if (!(pid > 0)) return false;
18
+ try {
19
+ return process.kill(pid, 0);
20
+ } catch (error) {
21
+ return error.code === 'EPERM';
22
+ }
23
+ }
24
+
25
+ /** The pid a lock file names — 0 when it names none — or undefined when there is no file. */
26
+ async function holder(path) {
27
+ try {
28
+ return Number(await readFile(path, 'utf8')) || 0;
29
+ } catch (error) {
30
+ if (error.code === 'ENOENT') return undefined;
31
+ throw error;
32
+ }
33
+ }
34
+
35
+ /** A name beside the lock no other taker uses, in this process or another. */
36
+ const aside = (path) => `${path}.${process.pid}.${Math.random().toString(36).slice(2)}`;
37
+
38
+ /** Puts a file naming this process at `path`, unless one is there. */
39
+ async function place(path) {
40
+ const mine = aside(path);
41
+ await writeFile(mine, String(process.pid));
42
+ try {
43
+ await link(mine, path);
44
+ return true;
45
+ } catch (error) {
46
+ if (error.code !== 'EEXIST') throw error;
47
+ return false;
48
+ } finally {
49
+ await rm(mine, { force: true });
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Takes a lock, or says it is held.
55
+ *
56
+ * @param {string} path - The lock file.
57
+ * @returns {Promise<(() => Promise<void>)|null>} Its release, or null when a running process holds it.
58
+ */
59
+ export async function takeLock(path) {
60
+ await mkdir(dirname(path), { recursive: true });
61
+ const takeover = `${path}.takeover`;
62
+ for (let attempt = 0; attempt < 3; attempt += 1) {
63
+ if (await place(path)) return () => rm(path, { force: true });
64
+ if (alive(await holder(path))) return null;
65
+ if (!(await place(takeover))) {
66
+ const taker = await holder(takeover);
67
+ if (alive(taker)) return null;
68
+ // A taker that died mid-takeover. One that finished is simply gone: try again.
69
+ if (taker !== undefined) await rm(takeover, { force: true });
70
+ continue;
71
+ }
72
+ try {
73
+ // Under the takeover nobody else removes the lock, and nobody can link over it: a lock read now stays what
74
+ // it is. A missing one does not — a taker may link it in the next instant — so there is nothing to remove.
75
+ const pid = await holder(path);
76
+ if (pid !== undefined && !alive(pid)) await rm(path, { force: true });
77
+ } finally {
78
+ await rm(takeover, { force: true });
79
+ }
80
+ }
81
+ return null;
82
+ }