@xhulz/nina 0.34.0 → 0.35.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 (137) hide show
  1. package/bin/nina.mjs +219 -48
  2. package/package.json +1 -1
  3. package/releases/0.35.0/core/GAPS.md +125 -0
  4. package/releases/0.35.0/core/answered.json +29 -0
  5. package/releases/0.35.0/core/defaults/tree/.claude/agents/architect.md +1 -0
  6. package/releases/0.35.0/core/defaults/tree/.claude/agents/dba.md +3 -0
  7. package/releases/0.35.0/core/defaults/tree/.claude/agents/devops.md +1 -0
  8. package/releases/0.35.0/core/defaults/tree/.claude/agents/implementer.md +1 -0
  9. package/releases/0.35.0/core/defaults/tree/.claude/agents/integration-tester.md +3 -0
  10. package/releases/0.35.0/core/defaults/tree/.claude/agents/qa.md +2 -0
  11. package/releases/0.35.0/core/defaults/tree/.claude/agents/reviewer.md +3 -0
  12. package/releases/0.35.0/core/defaults/tree/.claude/agents/secops.md +3 -0
  13. package/releases/0.35.0/core/defaults/tree/.claude/agents/solidity-auditor.md +3 -0
  14. package/releases/0.35.0/core/defaults/tree/.claude/agents/solidity-dev.md +3 -0
  15. package/releases/0.35.0/core/tree/.claude/agents/architect.md +127 -0
  16. package/releases/0.35.0/core/tree/.claude/agents/dba.md +110 -0
  17. package/releases/0.35.0/core/tree/.claude/agents/devops.md +114 -0
  18. package/releases/0.35.0/core/tree/.claude/agents/implementer.md +156 -0
  19. package/releases/0.35.0/core/tree/.claude/agents/integration-tester.md +168 -0
  20. package/releases/0.35.0/core/tree/.claude/agents/planner.md +132 -0
  21. package/releases/0.35.0/core/tree/.claude/agents/qa.md +153 -0
  22. package/releases/0.35.0/core/tree/.claude/agents/reviewer.md +183 -0
  23. package/releases/0.35.0/core/tree/.claude/agents/secops.md +121 -0
  24. package/releases/0.35.0/core/tree/.claude/agents/solidity-auditor.md +126 -0
  25. package/releases/0.35.0/core/tree/.claude/agents/solidity-dev.md +123 -0
  26. package/releases/0.35.0/core/tree/.claude/agents-overview.md +64 -0
  27. package/releases/0.35.0/core/tree/.claude/frontend.md +10 -0
  28. package/releases/0.35.0/core/tree/.claude/graph.md +75 -0
  29. package/releases/0.35.0/core/tree/.claude/patterns.md +213 -0
  30. package/releases/0.35.0/core/tree/.claude/pills/README.md +146 -0
  31. package/releases/0.35.0/core/tree/.claude/pipeline.md +101 -0
  32. package/releases/0.35.0/core/tree/.claude/retrieval.md +103 -0
  33. package/releases/0.35.0/core/tree/.claude/router.md +198 -0
  34. package/releases/0.35.0/core/tree/.claude/templates/integration.md +45 -0
  35. package/releases/0.35.0/core/tree/CLAUDE.md +161 -0
  36. package/releases/0.35.0/core/tree/scripts/cost-watch.mjs +18 -0
  37. package/releases/0.35.0/core/tree/scripts/edit-guard.mjs +16 -0
  38. package/releases/0.35.0/core/tree/scripts/harness-check.mjs +71 -0
  39. package/releases/0.35.0/core/tree/scripts/loop-gate.mjs +19 -0
  40. package/releases/0.35.0/core/vocabulary.json +13 -0
  41. package/releases/0.35.0/surfaces/blockchain/tree/.claude/agents/architect.md +5 -0
  42. package/releases/0.35.0/surfaces/blockchain/tree/.claude/agents/devops.md +5 -0
  43. package/releases/0.35.0/surfaces/blockchain/tree/.claude/agents/reviewer.md +5 -0
  44. package/releases/0.35.0/surfaces/blockchain/tree/.claude/agents/solidity-auditor.md +18 -0
  45. package/releases/0.35.0/surfaces/blockchain/tree/.claude/agents/solidity-dev.md +16 -0
  46. package/releases/0.35.0/surfaces/blockchain/tree/.claude/agents-overview.md +6 -0
  47. package/releases/0.35.0/surfaces/blockchain/tree/.claude/graph.md +11 -0
  48. package/releases/0.35.0/surfaces/blockchain/tree/.claude/router.md +6 -0
  49. package/releases/0.35.0/surfaces/blockchain/tree/CLAUDE.md +12 -0
  50. package/releases/0.35.0/surfaces/db/tree/.claude/agents/architect.md +5 -0
  51. package/releases/0.35.0/surfaces/db/tree/.claude/agents/dba.md +50 -0
  52. package/releases/0.35.0/surfaces/db/tree/.claude/agents/devops.md +5 -0
  53. package/releases/0.35.0/surfaces/db/tree/.claude/agents/implementer.md +2 -0
  54. package/releases/0.35.0/surfaces/db/tree/.claude/agents/integration-tester.md +10 -0
  55. package/releases/0.35.0/surfaces/db/tree/.claude/agents/planner.md +2 -0
  56. package/releases/0.35.0/surfaces/db/tree/.claude/agents/reviewer.md +22 -0
  57. package/releases/0.35.0/surfaces/db/tree/.claude/agents/secops.md +2 -0
  58. package/releases/0.35.0/surfaces/db/tree/.claude/agents-overview.md +5 -0
  59. package/releases/0.35.0/surfaces/db/tree/.claude/graph.md +8 -0
  60. package/releases/0.35.0/surfaces/db/tree/.claude/patterns.md +19 -0
  61. package/releases/0.35.0/surfaces/db/tree/.claude/pipeline.md +13 -0
  62. package/releases/0.35.0/surfaces/db/tree/.claude/retrieval.md +5 -0
  63. package/releases/0.35.0/surfaces/db/tree/.claude/router.md +4 -0
  64. package/releases/0.35.0/surfaces/db/tree/CLAUDE.md +17 -0
  65. package/releases/0.35.0/surfaces/edge-cf/tree/.claude/agents/architect.md +14 -0
  66. package/releases/0.35.0/surfaces/edge-cf/tree/.claude/agents/dba.md +2 -0
  67. package/releases/0.35.0/surfaces/edge-cf/tree/.claude/agents/devops.md +32 -0
  68. package/releases/0.35.0/surfaces/edge-cf/tree/.claude/agents/implementer.md +14 -0
  69. package/releases/0.35.0/surfaces/edge-cf/tree/.claude/agents/integration-tester.md +11 -0
  70. package/releases/0.35.0/surfaces/edge-cf/tree/.claude/agents/qa.md +3 -0
  71. package/releases/0.35.0/surfaces/edge-cf/tree/.claude/agents/reviewer.md +8 -0
  72. package/releases/0.35.0/surfaces/edge-cf/tree/.claude/agents/secops.md +8 -0
  73. package/releases/0.35.0/surfaces/edge-cf/tree/.claude/patterns.md +17 -0
  74. package/releases/0.35.0/surfaces/edge-cf/tree/.claude/retrieval.md +8 -0
  75. package/releases/0.35.0/surfaces/edge-cf/tree/CLAUDE.md +27 -0
  76. package/releases/0.35.0/surfaces/frontend/tree/.claude/agents/architect.md +20 -0
  77. package/releases/0.35.0/surfaces/frontend/tree/.claude/agents/devops.md +28 -0
  78. package/releases/0.35.0/surfaces/frontend/tree/.claude/agents/implementer.md +25 -0
  79. package/releases/0.35.0/surfaces/frontend/tree/.claude/agents/qa.md +17 -0
  80. package/releases/0.35.0/surfaces/frontend/tree/.claude/agents/reviewer.md +56 -0
  81. package/releases/0.35.0/surfaces/frontend/tree/.claude/patterns.md +8 -0
  82. package/releases/0.35.0/surfaces/frontend/tree/.claude/retrieval.md +2 -0
  83. package/releases/0.35.0/surfaces/frontend/tree/.claude/router.md +14 -0
  84. package/releases/0.35.0/surfaces/frontend/tree/CLAUDE.md +5 -0
  85. package/releases/0.35.0/surfaces/integrations/tree/.claude/agents/architect.md +16 -0
  86. package/releases/0.35.0/surfaces/integrations/tree/.claude/agents/implementer.md +12 -0
  87. package/releases/0.35.0/surfaces/integrations/tree/.claude/agents/integration-tester.md +20 -0
  88. package/releases/0.35.0/surfaces/integrations/tree/.claude/agents/planner.md +2 -0
  89. package/releases/0.35.0/surfaces/integrations/tree/.claude/agents/reviewer.md +21 -0
  90. package/releases/0.35.0/surfaces/integrations/tree/.claude/agents/secops.md +2 -0
  91. package/releases/0.35.0/surfaces/integrations/tree/.claude/agents-overview.md +8 -0
  92. package/releases/0.35.0/surfaces/integrations/tree/.claude/graph.md +10 -0
  93. package/releases/0.35.0/surfaces/integrations/tree/.claude/patterns.md +38 -0
  94. package/releases/0.35.0/surfaces/integrations/tree/.claude/pipeline.md +16 -0
  95. package/releases/0.35.0/surfaces/integrations/tree/.claude/retrieval.md +16 -0
  96. package/releases/0.35.0/surfaces/integrations/tree/.claude/router.md +6 -0
  97. package/releases/0.35.0/surfaces/integrations/tree/CLAUDE.md +17 -0
  98. package/releases/0.35.0/surfaces/money/tree/.claude/agents/architect.md +12 -0
  99. package/releases/0.35.0/surfaces/money/tree/.claude/agents/dba.md +14 -0
  100. package/releases/0.35.0/surfaces/money/tree/.claude/agents/implementer.md +11 -0
  101. package/releases/0.35.0/surfaces/money/tree/.claude/agents/integration-tester.md +3 -0
  102. package/releases/0.35.0/surfaces/money/tree/.claude/agents/reviewer.md +23 -0
  103. package/releases/0.35.0/surfaces/money/tree/.claude/agents/secops.md +5 -0
  104. package/releases/0.35.0/surfaces/money/tree/.claude/agents/solidity-auditor.md +4 -0
  105. package/releases/0.35.0/surfaces/money/tree/.claude/agents/solidity-dev.md +4 -0
  106. package/releases/0.35.0/surfaces/money/tree/.claude/graph.md +2 -0
  107. package/releases/0.35.0/surfaces/money/tree/.claude/patterns.md +49 -0
  108. package/releases/0.35.0/surfaces/money/tree/.claude/pipeline.md +2 -0
  109. package/releases/0.35.0/surfaces/money/tree/.claude/retrieval.md +2 -0
  110. package/releases/0.35.0/surfaces/money/tree/CLAUDE.md +11 -0
  111. package/releases/0.35.0/surfaces/pii/tree/.claude/agents/dba.md +2 -0
  112. package/releases/0.35.0/surfaces/pii/tree/.claude/agents/devops.md +8 -0
  113. package/releases/0.35.0/surfaces/pii/tree/.claude/agents/reviewer.md +8 -0
  114. package/releases/0.35.0/surfaces/pii/tree/.claude/agents/secops.md +2 -0
  115. package/releases/0.35.0/surfaces/pii/tree/.claude/agents/solidity-auditor.md +5 -0
  116. package/releases/0.35.0/surfaces/pii/tree/CLAUDE.md +5 -0
  117. package/releases/0.35.0/surfaces/prisma/tree/.claude/agents/architect.md +2 -0
  118. package/releases/0.35.0/surfaces/prisma/tree/.claude/agents/dba.md +39 -0
  119. package/releases/0.35.0/surfaces/prisma/tree/.claude/agents/devops.md +11 -0
  120. package/releases/0.35.0/surfaces/prisma/tree/.claude/agents/implementer.md +2 -0
  121. package/releases/0.35.0/surfaces/prisma/tree/.claude/agents/integration-tester.md +2 -0
  122. package/releases/0.35.0/surfaces/prisma/tree/.claude/agents/reviewer.md +2 -0
  123. package/releases/0.35.0/surfaces/prisma/tree/.claude/patterns.md +15 -0
  124. package/releases/0.35.0/surfaces/prisma/tree/CLAUDE.md +5 -0
  125. package/src/agentrun.mjs +89 -9
  126. package/src/commands/check.mjs +12 -8
  127. package/src/commands/compose.mjs +85 -9
  128. package/src/commands/init.mjs +13 -4
  129. package/src/commands/learn.mjs +12 -3
  130. package/src/commands/release.mjs +21 -3
  131. package/src/commands/stats.mjs +57 -7
  132. package/src/commands/upgrade.mjs +131 -25
  133. package/src/guard.mjs +7 -37
  134. package/src/paths.mjs +50 -1
  135. package/src/prices.mjs +7 -7
  136. package/src/vocabulary.mjs +12 -1
  137. package/src/wiring.mjs +106 -41
@@ -6,16 +6,15 @@
6
6
  * already wrote that the new harness has nowhere to put. A slot that was renamed or dropped
7
7
  * takes the project's own text out of the composition with it, silently, because a fragment
8
8
  * nothing references composes to nothing and no check notices. So this reports first and
9
- * writes only when told to, and what it writes is one field: the version. Reconciling the
10
- * fragments is work, and work does not get done by a flag.
9
+ * writes only when told to. Reconciling the fragments is work, and work does not get done by a flag.
11
10
  */
12
11
 
13
- import { readFile, readdir, writeFile } from 'node:fs/promises';
12
+ import { readFile, readdir } from 'node:fs/promises';
14
13
  import { spawnSync } from 'node:child_process';
15
- import { HARNESS, legacyHint } from '../paths.mjs';
16
- import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
14
+ import { HARNESS, describeMove, interruptedMove, legacyHint, moveJournal, movedFrom } from '../paths.mjs';
15
+ import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
17
16
  import { dirname, join, resolve } from 'node:path';
18
- import { composeProject, composedPaths, defaultedSlots, layerRootFor, walk } from './compose.mjs';
17
+ import { REMOVED, composeProject, composedPaths, defaultedSlots, layerRootFor, walk } from './compose.mjs';
19
18
  import { NEEDS, detected } from '../surfaces.mjs';
20
19
  import { filledSlots, projectSlots, referencedVocabulary } from './check.mjs';
21
20
  import { EXPECT_ENV } from '../expected.mjs';
@@ -25,6 +24,9 @@ import { missingFragment, missingWiring, shippedScripts } from '../wiring.mjs';
25
24
  import { runSteps } from '../steps.mjs';
26
25
  import { defaultVocabulary } from '../vocabulary.mjs';
27
26
 
27
+ /** What stops a move from outside it: Ctrl-C, a `kill`, the terminal closing. */
28
+ const SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
29
+
28
30
  /** Banner lines, which are for a person watching and only noise inside a captured log. */
29
31
  const BANNER = /^[\s█╗╔╝║═╚▄▀]*$|harness orchestration ·/;
30
32
 
@@ -75,7 +77,7 @@ function nina(ctx, target, args) {
75
77
  cwd: target,
76
78
  encoding: 'utf8',
77
79
  });
78
- const out = [result.stdout ?? '', result.stderr ?? '']
80
+ const out = [result.stdout ?? '', result.stderr ?? '', result.signal ? `stopped by ${result.signal}` : '']
79
81
  .join('\n')
80
82
  .split('\n')
81
83
  .filter((line) => line.trim() && !BANNER.test(line))
@@ -121,7 +123,7 @@ function shell(target, script) {
121
123
  encoding: 'utf8',
122
124
  env: { ...process.env, PATH: `${join(target, 'node_modules', '.bin')}:${process.env.PATH ?? ''}` },
123
125
  });
124
- const out = [result.stdout ?? '', result.stderr ?? ''].join('\n').split('\n').filter((l) => l.trim()).join('\n');
126
+ const out = [result.stdout ?? '', result.stderr ?? '', result.signal ? `stopped by ${result.signal}` : ''].join('\n').split('\n').filter((l) => l.trim()).join('\n');
125
127
  return { ok: result.status === 0, out, summary: summarise(out) };
126
128
  }
127
129
 
@@ -157,6 +159,74 @@ function summarise(out) {
157
159
  return [...lines].reverse().find((l) => /^\w[\w ]*:/.test(l))?.trim();
158
160
  }
159
161
 
162
+ /**
163
+ * Puts a project back on the version a move started from: the pin, then the files the move composed for the
164
+ * first time removed, a recompose, and every file it could touch written back as it was — the recompose alone
165
+ * would write the old composition over the owner's own edits and call it restored.
166
+ *
167
+ * @param {{root: string}} ctx - CLI context.
168
+ * @param {string} target - The project.
169
+ * @param {string} profilePath - Its profile.
170
+ * @param {object} profile - The profile as read, whose pin is set back.
171
+ * @param {string} version - The version the move started from.
172
+ * @param {string[]} created - Paths the move composed where nothing was.
173
+ * @param {Map<string, Buffer>} saved - Every file the move could touch, as it was.
174
+ * @returns {boolean} Whether the recompose passed.
175
+ */
176
+ function restore(ctx, target, profilePath, profile, version, created, saved) {
177
+ profile.core = version;
178
+ writeFileSync(profilePath, `${JSON.stringify(profile, null, 2)}\n`);
179
+ for (const p of created) rmSync(join(target, p), { force: true });
180
+ const recomposed = nina(ctx, target, ['compose', '--project', target]);
181
+ for (const [p, bytes] of saved) {
182
+ mkdirSync(dirname(join(target, p)), { recursive: true });
183
+ writeFileSync(join(target, p), bytes);
184
+ }
185
+ return recomposed.ok;
186
+ }
187
+
188
+ /**
189
+ * `nina upgrade --abort`: undoes a move that was stopped before it finished, from what it wrote down first.
190
+ *
191
+ * @param {{root: string}} ctx - CLI context.
192
+ * @param {string} target - The project.
193
+ * @param {string} profilePath - Its profile.
194
+ * @param {object} profile - The profile as read.
195
+ * @param {ReturnType<typeof interruptedMove>} stopped - The move it was left in.
196
+ * @returns {number} Process exit code.
197
+ */
198
+ function abort(ctx, target, profilePath, profile, stopped) {
199
+ if (!stopped) {
200
+ console.log(' no upgrade was stopped half-way here — there is nothing to undo.\n');
201
+ return 0;
202
+ }
203
+ if (stopped.unreadable) {
204
+ console.error(
205
+ ` ${describeMove(stopped)} Nothing can be undone from it: set "core" in ${HARNESS}/profile.json back by hand, run \`nina compose\`, ` +
206
+ `and delete ${HARNESS}/upgrade.json.\n`,
207
+ );
208
+ return 1;
209
+ }
210
+ const saved = new Map();
211
+ for (const p of stopped.saved ?? []) {
212
+ const copy = join(movedFrom(target), p);
213
+ if (existsSync(copy)) saved.set(p, readFileSync(copy));
214
+ }
215
+ const lost = (stopped.saved ?? []).filter((p) => !saved.has(p));
216
+ if (lost.length > 0) {
217
+ console.error(` ${lost.length} of the files the move kept are missing from ${HARNESS}/upgrade-saved/, so it cannot be undone exactly: ${lost.slice(0, 5).join(', ')}${lost.length > 5 ? ', …' : ''}\n`);
218
+ return 1;
219
+ }
220
+ const recomposed = restore(ctx, target, profilePath, profile, stopped.from, stopped.created ?? [], saved);
221
+ rmSync(moveJournal(target), { force: true });
222
+ rmSync(movedFrom(target), { recursive: true, force: true });
223
+ console.log(
224
+ ` undid the move to ${stopped.to}: pinned ${stopped.from} again and put ${saved.size} file(s) back as they were.` +
225
+ `${recomposed ? '' : ' Recomposing failed along the way — run `nina compose` yourself.'} Run the move again when ready.\n`,
226
+ );
227
+ return recomposed ? 0 : 1;
228
+ }
229
+
160
230
  /**
161
231
  * `nina upgrade`.
162
232
  *
@@ -176,6 +246,15 @@ export async function upgrade(argv, ctx) {
176
246
  }
177
247
  const profile = JSON.parse(await readFile(profilePath, 'utf8'));
178
248
 
249
+ // A move stopped half-way comes before anything else asked of this command: its pin may already name the
250
+ // version it was moving to, and "already pins" left the tree half-moved for good.
251
+ const stopped = interruptedMove(target);
252
+ if (argv.includes('--abort')) return abort(ctx, target, profilePath, profile, stopped);
253
+ if (stopped) {
254
+ console.error(` ${describeMove(stopped)} \`nina upgrade --abort --project ${target}\` undoes it; then run the move again.\n`);
255
+ return 1;
256
+ }
257
+
179
258
  const to = arg('--to');
180
259
  if (!to) {
181
260
  console.error(' usage: nina upgrade --to <version> [--project <dir>] [--apply]\n');
@@ -354,10 +433,6 @@ export async function upgrade(argv, ctx) {
354
433
  }
355
434
 
356
435
  const previous = profile.core;
357
- const pin = async (version) => {
358
- profile.core = version;
359
- await writeFile(profilePath, `${JSON.stringify(profile, null, 2)}\n`);
360
- };
361
436
 
362
437
  // What was already failing is not this upgrade's doing, and rolling back for it would blame
363
438
  // the move for a problem the project brought with it. Measured before anything is written,
@@ -437,8 +512,12 @@ export async function upgrade(argv, ctx) {
437
512
  const keptEdited = dropped.filter((p) => edited.has(p));
438
513
  // Every file the move can touch, as it is now, so a rollback puts the tree back exactly — the
439
514
  // recompose alone would write the old composition over the owner's own edits and call it restored.
515
+ // That is every path either version composes for any surface, not only the declared ones: compose moves
516
+ // a file gated on an undeclared surface to .nina/removed/, and one left out of here stayed there after a
517
+ // rollback that said the tree was as it was.
518
+ const reachable = [...(await composedPaths(from.dir, offeredBefore)), ...(await composedPaths(onto.dir, available))];
440
519
  const saved = new Map();
441
- for (const p of new Set([...pathsBefore, ...pathsAfter])) {
520
+ for (const p of new Set([...pathsBefore, ...pathsAfter, ...reachable])) {
442
521
  const entry = entryAt(p);
443
522
  if (entry?.isFile()) saved.set(p, readFileSync(join(target, p)));
444
523
  }
@@ -449,7 +528,31 @@ export async function upgrade(argv, ctx) {
449
528
  writeFileSync(copy, saved.get(p) ?? readFileSync(join(target, p)));
450
529
  }
451
530
 
452
- await pin(to);
531
+ // All of it written down before the pin moves, copies first and the journal last, so a journal always has
532
+ // its copies. A move stopped from here on — Ctrl-C, a closed terminal, a crash — left the pin moved, the tree
533
+ // half-composed, and the owner's own edits, held only in this process's memory, gone; now the next run says
534
+ // so, and `--abort` undoes it.
535
+ rmSync(movedFrom(target), { recursive: true, force: true });
536
+ for (const [p, bytes] of saved) {
537
+ mkdirSync(dirname(join(movedFrom(target), p)), { recursive: true });
538
+ writeFileSync(join(movedFrom(target), p), bytes);
539
+ }
540
+ const journal = { from: previous, to, started: new Date().toISOString(), created, saved: [...saved.keys()] };
541
+ writeFileSync(`${moveJournal(target)}.tmp`, `${JSON.stringify(journal, null, 2)}\n`);
542
+ renameSync(`${moveJournal(target)}.tmp`, moveJournal(target));
543
+ // Ctrl-C reaches the step running in a child too, which dies of it, and its step fails: with this process
544
+ // still alive, that is an ordinary failed step, and the move rolls back. Without a listener this process died
545
+ // first, half-way through, with nothing left to roll anything back.
546
+ const hold = () => {};
547
+ for (const s of SIGNALS) process.on(s, hold);
548
+ const settle = () => {
549
+ for (const s of SIGNALS) process.off(s, hold);
550
+ rmSync(moveJournal(target), { force: true });
551
+ rmSync(movedFrom(target), { recursive: true, force: true });
552
+ };
553
+
554
+ profile.core = to;
555
+ writeFileSync(profilePath, `${JSON.stringify(profile, null, 2)}\n`);
453
556
  console.log(` pinned ${to}\n`);
454
557
 
455
558
  const steps = [
@@ -473,12 +576,20 @@ export async function upgrade(argv, ctx) {
473
576
  const { failed } = runSteps(steps);
474
577
 
475
578
  if (!failed) {
579
+ settle();
476
580
  delete process.env.NINA_UPGRADE;
477
581
  console.log(`\n upgrade: ${previous} → ${to} applied and verified.`);
478
582
  if (removable.length > 0) console.log(`\n removed ${removable.length} file(s) ${to} no longer composes: ${removable.join(', ')}`);
479
- if (keptEdited.length > 0) {
480
- console.log(`\n kept ${keptEdited.length} file(s) ${to} no longer composes, because they carry edits of yours — delete them once nothing in them is needed:`);
481
- for (const p of keptEdited) console.log(` ${p}`);
583
+ // A file gated on a surface the project does not declare is moved aside by compose itself, edited or not.
584
+ const stillKept = keptEdited.filter((p) => entryAt(p));
585
+ const setAside = keptEdited.filter((p) => !entryAt(p));
586
+ if (stillKept.length > 0) {
587
+ console.log(`\n kept ${stillKept.length} file(s) ${to} no longer composes, because they carry edits of yours — delete them once nothing in them is needed:`);
588
+ for (const p of stillKept) console.log(` ${p}`);
589
+ }
590
+ if (setAside.length > 0) {
591
+ console.log(`\n moved ${setAside.length} file(s) with edits of yours to ${REMOVED}/, since ${to} composes them only for a surface this project does not declare:`);
592
+ for (const p of setAside) console.log(` ${p}`);
482
593
  }
483
594
  if (occupied.length > 0) {
484
595
  console.log(`\n replaced ${occupied.length} file(s) of yours with what ${to} composes there — the originals are in ${HARNESS}/replaced/:`);
@@ -530,15 +641,10 @@ export async function upgrade(argv, ctx) {
530
641
  }
531
642
 
532
643
  console.log(`\n ✗ ${failed.label} failed — rolling back to ${previous}.\n`);
533
- await pin(previous);
534
- for (const p of created) rmSync(join(target, p), { force: true });
535
- const recomposed = nina(ctx, target, ['compose', '--project', target]);
536
- for (const [p, bytes] of saved) {
537
- mkdirSync(dirname(join(target, p)), { recursive: true });
538
- writeFileSync(join(target, p), bytes);
539
- }
644
+ const recomposed = restore(ctx, target, profilePath, profile, previous, created, saved);
645
+ settle();
540
646
  console.log(
541
- recomposed.ok
647
+ recomposed
542
648
  ? ` rolled back: pinned ${previous} and recomposed. The tree is as it was.\n`
543
649
  : ` rolled back the pin to ${previous}, but recomposing failed — run \`nina compose\` yourself.\n`,
544
650
  );
package/src/guard.mjs CHANGED
@@ -20,21 +20,18 @@
20
20
  import { closeSync, existsSync, openSync, readFileSync, readSync, realpathSync } from 'node:fs';
21
21
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
22
22
  import { fileURLToPath } from 'node:url';
23
- import { composedPaths } from './commands/compose.mjs';
23
+ import { NOTICE_HEAD, composedPaths, noticeOf } from './commands/compose.mjs';
24
24
  import { HARNESS } from './paths.mjs';
25
25
 
26
+ // Read here and by the suites from here; the test itself lives beside the compiler that writes the notice.
27
+ export { NOTICE_HEAD, noticeOf };
28
+
26
29
  /** The package this runs from: its releases say whether a pinned version ships the guard. */
27
30
  const PACKAGE = dirname(dirname(fileURLToPath(import.meta.url)));
28
31
 
29
32
  /** The tools that change a file, and where each names it. */
30
33
  const EDITS = { Edit: 'file_path', Write: 'file_path', MultiEdit: 'file_path', NotebookEdit: 'notebook_path' };
31
34
 
32
- /**
33
- * How far into a file the notice is looked for: below a frontmatter block, or a shebang. The compose
34
- * suite holds every composed file's notice inside it.
35
- */
36
- export const NOTICE_HEAD = 60;
37
-
38
35
  /** How much of a file is read to find it: the head, not the whole of a large file on every edit. */
39
36
  const HEAD_BYTES = 16 * 1024;
40
37
 
@@ -69,35 +66,6 @@ function head(path) {
69
66
  }
70
67
  }
71
68
 
72
- /**
73
- * A composed file's notice, as one sentence without its comment syntax, or null when it has none.
74
- *
75
- * @param {string} text - The file.
76
- * @returns {string|null}
77
- */
78
- export function noticeOf(text) {
79
- const lines = String(text).split('\n').slice(0, NOTICE_HEAD);
80
- const at = lines.findIndex((l) => /^(<!--|\/\/) nina:generated/.test(l));
81
- if (at < 0) return null;
82
- const block = [];
83
- if (lines[at].startsWith('//')) {
84
- for (const line of lines.slice(at)) {
85
- if (!line.startsWith('//')) break;
86
- block.push(line.replace(/^\/\/ ?/, ''));
87
- }
88
- } else {
89
- // The notice's own delimiters, taken off by position: this reads text the compiler wrote, it filters
90
- // nothing, and a pattern for it read to a scanner as an HTML sanitizer that misses `--!>`.
91
- for (const line of lines.slice(at)) {
92
- const open = line.startsWith('<!-- ') ? line.slice('<!-- '.length) : line;
93
- const end = open.lastIndexOf('-->');
94
- block.push(end >= 0 ? open.slice(0, end) : open);
95
- if (end >= 0) break;
96
- }
97
- }
98
- return block.map((l) => l.trim()).join(' ');
99
- }
100
-
101
69
  /**
102
70
  * Handles one hook event: the JSON to print, or null to let the call through. Never throws.
103
71
  *
@@ -121,7 +89,9 @@ export async function handleEdit(input, { root, pkg = PACKAGE }) {
121
89
  if (!pinned) return null;
122
90
  const target = isAbsolute(path) ? path : resolve(input.cwd ?? root, path);
123
91
  if (!existsSync(target)) return null;
124
- const rel = relative(realpathSync(root), realpathSync(target)).split(sep).join('/');
92
+ // The native call returns the name as it is on disk: where case is ignored, `claude.md` is CLAUDE.md, and
93
+ // read as spelled it reached no composed path and let the edit through.
94
+ const rel = relative(realpathSync.native(root), realpathSync.native(target)).split(sep).join('/');
125
95
  // Outside the project, or in its own layer — where a change to a composed file belongs.
126
96
  if (rel.startsWith('..') || isAbsolute(rel) || rel.split('/')[0] === HARNESS) return null;
127
97
  const notice = noticeOf(head(target));
package/src/paths.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  * project, and losing it is irreversible — so it lives in the user's own directory.
9
9
  */
10
10
 
11
- import { existsSync } from 'node:fs';
11
+ import { existsSync, readFileSync } from 'node:fs';
12
12
  import { homedir } from 'node:os';
13
13
  import { join, resolve } from 'node:path';
14
14
 
@@ -100,3 +100,52 @@ export function legacyHint(target) {
100
100
  ? ` — this project still has .harness/, which is what ${HARNESS}/ used to be called. Rename it; do not start over.`
101
101
  : '';
102
102
  }
103
+
104
+ /**
105
+ * Where `nina upgrade --apply` writes down a move while it is in flight: the versions it moves between, the
106
+ * files it composes for the first time, and — under {@link movedFrom} — every file it can touch, as it was.
107
+ * It is written before the pin moves and removed once the move is verified or rolled back, so one that is
108
+ * still there is a move that was stopped half-way.
109
+ *
110
+ * @param {string} target - The project.
111
+ * @returns {string}
112
+ */
113
+ export const moveJournal = (target) => join(target, HARNESS, 'upgrade.json');
114
+
115
+ /**
116
+ * Where an in-flight move keeps the files it can touch, as they were before it — at their own paths below.
117
+ *
118
+ * @param {string} target - The project.
119
+ * @returns {string}
120
+ */
121
+ export const movedFrom = (target) => join(target, HARNESS, 'upgrade-saved');
122
+
123
+ /**
124
+ * The move a project was left in when `upgrade --apply` was stopped before it finished, or null when none is.
125
+ *
126
+ * A pin that moved with the composed files half-written looks like hand edits to every check, and the
127
+ * owner's own edits it was about to put back lived only in the stopped process's memory. This is what says
128
+ * so instead, and what `nina upgrade --abort` undoes it from.
129
+ *
130
+ * @param {string} target - The project.
131
+ * @returns {{from: string, to: string, started: string, created: string[], saved: string[]}|{unreadable: string}|null}
132
+ */
133
+ export function interruptedMove(target) {
134
+ try {
135
+ return JSON.parse(readFileSync(moveJournal(target), 'utf8'));
136
+ } catch (error) {
137
+ return error.code === 'ENOENT' ? null : { unreadable: error.message };
138
+ }
139
+ }
140
+
141
+ /**
142
+ * A stopped move, said once for every command that finds one.
143
+ *
144
+ * @param {NonNullable<ReturnType<typeof interruptedMove>>} stopped - The move.
145
+ * @returns {string}
146
+ */
147
+ export function describeMove(stopped) {
148
+ return stopped.unreadable
149
+ ? `An upgrade was stopped before it finished, and its journal ${HARNESS}/upgrade.json cannot be read (${stopped.unreadable}).`
150
+ : `An upgrade from ${stopped.from} to ${stopped.to}, started ${stopped.started}, was stopped before it finished — the pin and the composed files may be half-moved.`;
151
+ }
package/src/prices.mjs CHANGED
@@ -15,7 +15,7 @@
15
15
  /** The date the table was read, printed beside every estimate made from it. */
16
16
  export const PRICES_AS_OF = '2026-06-24';
17
17
 
18
- /** model id prefix → $/MTok. Longest prefix wins, so `claude-opus-5-5` is not read as `claude-opus-5`. */
18
+ /** model id → $/MTok. */
19
19
  const PRICES = {
20
20
  'claude-fable-5-1': { input: 10, output: 50, read: 0.25 },
21
21
  'claude-mythos-5-1': { input: 10, output: 50 },
@@ -39,12 +39,12 @@ const PRICES = {
39
39
  * @returns {{input: number, output: number, read: number, write5m: number, write1h: number}|null}
40
40
  */
41
41
  export function priceOf(model) {
42
- const id = String(model ?? '');
43
- const key = Object.keys(PRICES)
44
- .filter((k) => id === k || id.startsWith(`${k}-`) || id.startsWith(`${k}[`))
45
- .sort((a, b) => b.length - a.length)[0];
46
- if (!key) return null;
47
- const p = PRICES[key];
42
+ // The id itself, less the two suffixes a transcript adds to one: a context window (`[1m]`) and a snapshot
43
+ // date (`-20251001`). Matched by prefix, a model newer than the table was priced as the one before it —
44
+ // `claude-opus-5-6` as Opus 5, at five times its cache reads.
45
+ const id = String(model ?? '').replace(/\[[^\]]*\]$/, '').replace(/-\d{8}$/, '');
46
+ const p = Object.hasOwn(PRICES, id) ? PRICES[id] : null;
47
+ if (!p) return null;
48
48
  return { input: p.input, output: p.output, read: p.read ?? p.input * 0.1, write5m: p.input * 1.25, write1h: p.input * 2 };
49
49
  }
50
50
 
@@ -16,6 +16,17 @@
16
16
  import { readFileSync } from 'node:fs';
17
17
  import { join } from 'node:path';
18
18
 
19
+ /**
20
+ * A vocabulary name: capitals, digits and `_`, from a letter — `{{S3_BUCKET}}` as much as `{{OWNER}}`. Every
21
+ * reader of a placeholder reads it with this one grammar. Compose took any name the profile declared while
22
+ * `check`, `init` and `upgrade` looked only for capitals and `_`, so `{{S3_BUCKET}}` was reported as declared
23
+ * and referenced by nothing, and deleting it on that advice composed the placeholder raw, with nobody saying so.
24
+ */
25
+ export const NAME = /^[A-Z][A-Z0-9_]*$/;
26
+
27
+ /** A placeholder in a text, its name captured. */
28
+ export const PLACEHOLDER = /\{\{([A-Z][A-Z0-9_]*)\}\}/g;
29
+
19
30
  /**
20
31
  * The defaults a layer root supplies, name → value. Anything that is not a string under an
21
32
  * upper-case name is ignored rather than trusted: a default reaches every project that pins it.
@@ -26,7 +37,7 @@ import { join } from 'node:path';
26
37
  export function defaultVocabulary(layerRoot) {
27
38
  try {
28
39
  const parsed = JSON.parse(readFileSync(join(layerRoot, 'core', 'vocabulary.json'), 'utf8'));
29
- return Object.fromEntries(Object.entries(parsed).filter(([name, value]) => /^[A-Z_]+$/.test(name) && typeof value === 'string'));
40
+ return Object.fromEntries(Object.entries(parsed).filter(([name, value]) => NAME.test(name) && typeof value === 'string'));
30
41
  } catch {
31
42
  return {};
32
43
  }
package/src/wiring.mjs CHANGED
@@ -205,21 +205,77 @@ function groupsFor(hooks, event) {
205
205
  return Array.isArray(hooks[event]) ? hooks[event] : null;
206
206
  }
207
207
 
208
+ /** Whether one hook command runs a hook's script, recognised by what it runs, not by its exact text. */
209
+ function runsCommand(x, h) {
210
+ const cmd = String(x?.command ?? '');
211
+ return cmd.includes(h.script) && (!h.mode || cmd.includes(h.mode));
212
+ }
213
+
214
+ /** Whether a settings group runs a hook's script. */
215
+ function runs(g, h) {
216
+ return Boolean(g) && typeof g === 'object' && Array.isArray(g.hooks) && g.hooks.some((x) => runsCommand(x, h));
217
+ }
218
+
219
+ /** A matcher that reaches every tool: none, empty, or `*`. */
220
+ const everyTool = (m) => m === undefined || m === null || m === '' || m === '*';
221
+
222
+ /** A matcher this can read and rewrite: every tool, or a list of exact names. A regular expression is its owner's. */
223
+ const plainMatcher = (m) => everyTool(m) || (typeof m === 'string' && /^[A-Za-z0-9_|]+$/.test(m));
224
+
225
+ /** Whether two matchers reach a tool in common — unknown for a regular expression, taken as no. */
226
+ function overlaps(a, b) {
227
+ if (everyTool(a) || everyTool(b)) return true;
228
+ if (!plainMatcher(a) || !plainMatcher(b)) return false;
229
+ const names = new Set(a.split('|'));
230
+ return b.split('|').some((n) => names.has(n));
231
+ }
232
+
233
+ /** Whether two of these groups run on a tool they share, so the hook runs once per group there. */
234
+ const doubled = (groups) => groups.some((g, i) => groups.slice(i + 1).some((other) => overlaps(g.matcher, other.matcher)));
235
+
208
236
  /**
209
237
  * Whether settings already run a hook. Recognised by what the command runs, not by its exact text —
210
238
  * a project may have written its own — and by a matcher that reaches every tool the hook needs.
211
239
  */
212
240
  function covered(hooks, h) {
213
- const groups = groupsFor(hooks, h.event) ?? [];
214
241
  const need = h.matcher ? h.matcher.split('|') : [];
215
- return groups.some((g) => {
216
- if (!g || typeof g !== 'object') return false;
217
- if (!need.every((tool) => matcherReaches(g.matcher, tool))) return false;
218
- return Array.isArray(g.hooks) && g.hooks.some((x) => {
219
- const cmd = String(x?.command ?? '');
220
- return cmd.includes(h.script) && (!h.mode || cmd.includes(h.mode));
221
- });
222
- });
242
+ return (groupsFor(hooks, h.event) ?? []).some((g) => runs(g, h) && need.every((tool) => matcherReaches(g.matcher, tool)));
243
+ }
244
+
245
+ /**
246
+ * The hooks a project runs, from both settings files merged by event — Claude Code runs both, so a hook
247
+ * kept in the local file counts. Every reader asks this one: `wire` used to read the shared file alone,
248
+ * and appended a second copy of each hook a project kept in its local file, which then ran twice.
249
+ *
250
+ * @param {string} target - The project directory.
251
+ * @param {(event: string) => boolean} [wanted] - Events whose malformed lists are worth a problem.
252
+ * @returns {Promise<{merged: Record<string, object[]>, problems: string[]}>} Each merged group says `from` which file.
253
+ */
254
+ async function projectHooks(target, wanted = () => false) {
255
+ const merged = {};
256
+ const problems = [];
257
+ for (const rel of SETTINGS_FILES) {
258
+ const path = join(target, rel);
259
+ if (!existsSync(path)) continue;
260
+ let settings;
261
+ try {
262
+ settings = JSON.parse(await readFile(path, 'utf8'));
263
+ } catch (error) {
264
+ problems.push(`${rel} is not valid JSON — ${error.message}`);
265
+ continue;
266
+ }
267
+ const shape = settings?.hooks;
268
+ if (shape === undefined || shape === null) continue;
269
+ if (typeof shape !== 'object' || Array.isArray(shape)) {
270
+ problems.push(`${rel} has a "hooks" that is not an object of event lists — no hook in it can run`);
271
+ continue;
272
+ }
273
+ for (const [event, groups] of Object.entries(shape)) {
274
+ if (Array.isArray(groups)) (merged[event] ??= []).push(...groups.map((g) => (g && typeof g === 'object' ? { ...g, from: rel } : g)));
275
+ else if (wanted(event)) problems.push(`${rel}'s hooks.${event} is not a list — no hook in it can run`);
276
+ }
277
+ }
278
+ return { merged, problems };
223
279
  }
224
280
 
225
281
  /** The command settings run for a hook, when they run one — the selftest runs it the same way. */
@@ -299,29 +355,16 @@ export async function missingWiring(target, shipped) {
299
355
  }
300
356
  const wanted = HOOKS.filter((h) => shipped.has(h.script));
301
357
  if (wanted.length === 0) return out;
302
- const merged = {};
303
- for (const rel of SETTINGS_FILES) {
304
- const path = join(target, rel);
305
- if (!existsSync(path)) continue;
306
- let settings;
307
- try {
308
- settings = JSON.parse(await readFile(path, 'utf8'));
309
- } catch (error) {
310
- out.push(`${rel} is not valid JSON — ${error.message}`);
311
- continue;
312
- }
313
- const shape = settings?.hooks;
314
- if (shape === undefined || shape === null) continue;
315
- if (typeof shape !== 'object' || Array.isArray(shape)) {
316
- out.push(`${rel} has a "hooks" that is not an object of event lists — no hook in it can run`);
317
- continue;
318
- }
319
- for (const [event, groups] of Object.entries(shape)) {
320
- if (Array.isArray(groups)) (merged[event] ??= []).push(...groups);
321
- else if (wanted.some((h) => h.event === event)) out.push(`${rel}'s hooks.${event} is not a list — no hook in it can run`);
322
- }
323
- }
358
+ const { merged, problems } = await projectHooks(target, (event) => wanted.some((h) => h.event === event));
359
+ out.push(...problems);
324
360
  for (const h of wanted) {
361
+ const running = (merged[h.event] ?? []).filter((g) => runs(g, h));
362
+ if (doubled(running)) {
363
+ out.push(
364
+ `${running.length} ${h.event} groups run \`${h.script}${h.mode ? ` ${h.mode}` : ''}\` for tools they share, so it runs once per group there — ` +
365
+ '`nina wire --apply` makes them one',
366
+ );
367
+ }
325
368
  if (covered(merged, h)) continue;
326
369
  out.push(
327
370
  `.claude/settings.json has no ${h.event} hook${h.matcher ? ` for ${h.matcher}` : ''} running \`${h.script}${h.mode ? ` ${h.mode}` : ''}\` — without it ${h.why}`,
@@ -359,13 +402,9 @@ export async function staleHooks(target, shipped) {
359
402
  * @returns {Promise<string|null>} Null when nothing is missing.
360
403
  */
361
404
  export async function missingFragment(target, shipped) {
362
- let settings = null;
363
- try {
364
- settings = JSON.parse(await readFile(join(target, '.claude', 'settings.json'), 'utf8'));
365
- } catch {
366
- // Absent or unreadable: everything is missing, and `missingWiring` says which.
367
- }
368
- const lacking = HOOKS.filter((h) => shipped.has(h.script) && !covered(settings?.hooks, h));
405
+ // Absent or unreadable files hold nothing: everything they would have held is missing, and `missingWiring` says why.
406
+ const { merged } = await projectHooks(target);
407
+ const lacking = HOOKS.filter((h) => shipped.has(h.script) && !covered(merged, h));
369
408
  return lacking.length === 0 ? null : JSON.stringify({ hooks: settingsHooks(lacking) }, null, 2);
370
409
  }
371
410
 
@@ -405,10 +444,36 @@ export async function applyWiring(target, shipped, { settings: editSettings = tr
405
444
  if (readable) {
406
445
  const hooks = settings.hooks ?? {};
407
446
  const added = [];
447
+ const { merged } = await projectHooks(target);
408
448
  for (const h of HOOKS.filter((h) => shipped.has(h.script))) {
409
- if (covered(hooks, h)) continue;
410
- (hooks[h.event] ??= []).push(group(h));
411
- added.push(`${h.event}${h.matcher ? ` (${h.matcher})` : ''} → ${h.script}${h.mode ? ` ${h.mode}` : ''}`);
449
+ const running = (merged[h.event] ?? []).filter((g) => runs(g, h));
450
+ if (covered(merged, h) && !doubled(running)) continue;
451
+ const named = `${h.event}${h.matcher ? ` (${h.matcher})` : ''} → ${h.script}${h.mode ? ` ${h.mode}` : ''}`;
452
+ if (running.length === 0) {
453
+ (hooks[h.event] ??= []).push(group(h));
454
+ added.push(named);
455
+ continue;
456
+ }
457
+ // The groups that already run the script become one that reaches every tool it needs. A second group
458
+ // beside a narrower one — or two left as they were — runs it on the tools they share once per group, and
459
+ // the gate counts each of those events twice. A group in the local file, or one matched by a regular
460
+ // expression, is its owner's to change: said, and not doubled.
461
+ const foreign = running.find((g) => g.from !== '.claude/settings.json' || !plainMatcher(g.matcher));
462
+ if (foreign) {
463
+ problems.push(
464
+ `${foreign.from} runs ${h.script} for "${foreign.matcher ?? ''}" — make every group that runs it there one, reaching ${h.matcher ?? 'every tool'}; ` +
465
+ 'another group beside it would run it twice',
466
+ );
467
+ continue;
468
+ }
469
+ const mine = (hooks[h.event] ?? []).filter((g) => runs(g, h));
470
+ const keep = mine.find((g) => everyTool(g.matcher)) ?? mine[0];
471
+ if (!everyTool(keep.matcher)) {
472
+ keep.matcher = [...new Set([...mine.flatMap((g) => g.matcher.split('|')), ...(h.matcher ? h.matcher.split('|') : [])])].join('|');
473
+ }
474
+ for (const g of mine) if (g !== keep) g.hooks = g.hooks.filter((x) => !runsCommand(x, h));
475
+ hooks[h.event] = hooks[h.event].filter((g) => !Array.isArray(g?.hooks) || g.hooks.length > 0);
476
+ added.push(`${named} (${mine.length > 1 ? `${mine.length} groups made one, ` : ''}reaching ${everyTool(keep.matcher) ? 'every tool' : keep.matcher})`);
412
477
  }
413
478
  // An old command, exactly as it was written, learns to say when its script cannot start, and how to
414
479
  // install NINA as it is installed now.