@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.
- package/bin/nina.mjs +219 -48
- package/package.json +1 -1
- package/releases/0.35.0/core/GAPS.md +125 -0
- package/releases/0.35.0/core/answered.json +29 -0
- package/releases/0.35.0/core/defaults/tree/.claude/agents/architect.md +1 -0
- package/releases/0.35.0/core/defaults/tree/.claude/agents/dba.md +3 -0
- package/releases/0.35.0/core/defaults/tree/.claude/agents/devops.md +1 -0
- package/releases/0.35.0/core/defaults/tree/.claude/agents/implementer.md +1 -0
- package/releases/0.35.0/core/defaults/tree/.claude/agents/integration-tester.md +3 -0
- package/releases/0.35.0/core/defaults/tree/.claude/agents/qa.md +2 -0
- package/releases/0.35.0/core/defaults/tree/.claude/agents/reviewer.md +3 -0
- package/releases/0.35.0/core/defaults/tree/.claude/agents/secops.md +3 -0
- package/releases/0.35.0/core/defaults/tree/.claude/agents/solidity-auditor.md +3 -0
- package/releases/0.35.0/core/defaults/tree/.claude/agents/solidity-dev.md +3 -0
- package/releases/0.35.0/core/tree/.claude/agents/architect.md +127 -0
- package/releases/0.35.0/core/tree/.claude/agents/dba.md +110 -0
- package/releases/0.35.0/core/tree/.claude/agents/devops.md +114 -0
- package/releases/0.35.0/core/tree/.claude/agents/implementer.md +156 -0
- package/releases/0.35.0/core/tree/.claude/agents/integration-tester.md +168 -0
- package/releases/0.35.0/core/tree/.claude/agents/planner.md +132 -0
- package/releases/0.35.0/core/tree/.claude/agents/qa.md +153 -0
- package/releases/0.35.0/core/tree/.claude/agents/reviewer.md +183 -0
- package/releases/0.35.0/core/tree/.claude/agents/secops.md +121 -0
- package/releases/0.35.0/core/tree/.claude/agents/solidity-auditor.md +126 -0
- package/releases/0.35.0/core/tree/.claude/agents/solidity-dev.md +123 -0
- package/releases/0.35.0/core/tree/.claude/agents-overview.md +64 -0
- package/releases/0.35.0/core/tree/.claude/frontend.md +10 -0
- package/releases/0.35.0/core/tree/.claude/graph.md +75 -0
- package/releases/0.35.0/core/tree/.claude/patterns.md +213 -0
- package/releases/0.35.0/core/tree/.claude/pills/README.md +146 -0
- package/releases/0.35.0/core/tree/.claude/pipeline.md +101 -0
- package/releases/0.35.0/core/tree/.claude/retrieval.md +103 -0
- package/releases/0.35.0/core/tree/.claude/router.md +198 -0
- package/releases/0.35.0/core/tree/.claude/templates/integration.md +45 -0
- package/releases/0.35.0/core/tree/CLAUDE.md +161 -0
- package/releases/0.35.0/core/tree/scripts/cost-watch.mjs +18 -0
- package/releases/0.35.0/core/tree/scripts/edit-guard.mjs +16 -0
- package/releases/0.35.0/core/tree/scripts/harness-check.mjs +71 -0
- package/releases/0.35.0/core/tree/scripts/loop-gate.mjs +19 -0
- package/releases/0.35.0/core/vocabulary.json +13 -0
- package/releases/0.35.0/surfaces/blockchain/tree/.claude/agents/architect.md +5 -0
- package/releases/0.35.0/surfaces/blockchain/tree/.claude/agents/devops.md +5 -0
- package/releases/0.35.0/surfaces/blockchain/tree/.claude/agents/reviewer.md +5 -0
- package/releases/0.35.0/surfaces/blockchain/tree/.claude/agents/solidity-auditor.md +18 -0
- package/releases/0.35.0/surfaces/blockchain/tree/.claude/agents/solidity-dev.md +16 -0
- package/releases/0.35.0/surfaces/blockchain/tree/.claude/agents-overview.md +6 -0
- package/releases/0.35.0/surfaces/blockchain/tree/.claude/graph.md +11 -0
- package/releases/0.35.0/surfaces/blockchain/tree/.claude/router.md +6 -0
- package/releases/0.35.0/surfaces/blockchain/tree/CLAUDE.md +12 -0
- package/releases/0.35.0/surfaces/db/tree/.claude/agents/architect.md +5 -0
- package/releases/0.35.0/surfaces/db/tree/.claude/agents/dba.md +50 -0
- package/releases/0.35.0/surfaces/db/tree/.claude/agents/devops.md +5 -0
- package/releases/0.35.0/surfaces/db/tree/.claude/agents/implementer.md +2 -0
- package/releases/0.35.0/surfaces/db/tree/.claude/agents/integration-tester.md +10 -0
- package/releases/0.35.0/surfaces/db/tree/.claude/agents/planner.md +2 -0
- package/releases/0.35.0/surfaces/db/tree/.claude/agents/reviewer.md +22 -0
- package/releases/0.35.0/surfaces/db/tree/.claude/agents/secops.md +2 -0
- package/releases/0.35.0/surfaces/db/tree/.claude/agents-overview.md +5 -0
- package/releases/0.35.0/surfaces/db/tree/.claude/graph.md +8 -0
- package/releases/0.35.0/surfaces/db/tree/.claude/patterns.md +19 -0
- package/releases/0.35.0/surfaces/db/tree/.claude/pipeline.md +13 -0
- package/releases/0.35.0/surfaces/db/tree/.claude/retrieval.md +5 -0
- package/releases/0.35.0/surfaces/db/tree/.claude/router.md +4 -0
- package/releases/0.35.0/surfaces/db/tree/CLAUDE.md +17 -0
- package/releases/0.35.0/surfaces/edge-cf/tree/.claude/agents/architect.md +14 -0
- package/releases/0.35.0/surfaces/edge-cf/tree/.claude/agents/dba.md +2 -0
- package/releases/0.35.0/surfaces/edge-cf/tree/.claude/agents/devops.md +32 -0
- package/releases/0.35.0/surfaces/edge-cf/tree/.claude/agents/implementer.md +14 -0
- package/releases/0.35.0/surfaces/edge-cf/tree/.claude/agents/integration-tester.md +11 -0
- package/releases/0.35.0/surfaces/edge-cf/tree/.claude/agents/qa.md +3 -0
- package/releases/0.35.0/surfaces/edge-cf/tree/.claude/agents/reviewer.md +8 -0
- package/releases/0.35.0/surfaces/edge-cf/tree/.claude/agents/secops.md +8 -0
- package/releases/0.35.0/surfaces/edge-cf/tree/.claude/patterns.md +17 -0
- package/releases/0.35.0/surfaces/edge-cf/tree/.claude/retrieval.md +8 -0
- package/releases/0.35.0/surfaces/edge-cf/tree/CLAUDE.md +27 -0
- package/releases/0.35.0/surfaces/frontend/tree/.claude/agents/architect.md +20 -0
- package/releases/0.35.0/surfaces/frontend/tree/.claude/agents/devops.md +28 -0
- package/releases/0.35.0/surfaces/frontend/tree/.claude/agents/implementer.md +25 -0
- package/releases/0.35.0/surfaces/frontend/tree/.claude/agents/qa.md +17 -0
- package/releases/0.35.0/surfaces/frontend/tree/.claude/agents/reviewer.md +56 -0
- package/releases/0.35.0/surfaces/frontend/tree/.claude/patterns.md +8 -0
- package/releases/0.35.0/surfaces/frontend/tree/.claude/retrieval.md +2 -0
- package/releases/0.35.0/surfaces/frontend/tree/.claude/router.md +14 -0
- package/releases/0.35.0/surfaces/frontend/tree/CLAUDE.md +5 -0
- package/releases/0.35.0/surfaces/integrations/tree/.claude/agents/architect.md +16 -0
- package/releases/0.35.0/surfaces/integrations/tree/.claude/agents/implementer.md +12 -0
- package/releases/0.35.0/surfaces/integrations/tree/.claude/agents/integration-tester.md +20 -0
- package/releases/0.35.0/surfaces/integrations/tree/.claude/agents/planner.md +2 -0
- package/releases/0.35.0/surfaces/integrations/tree/.claude/agents/reviewer.md +21 -0
- package/releases/0.35.0/surfaces/integrations/tree/.claude/agents/secops.md +2 -0
- package/releases/0.35.0/surfaces/integrations/tree/.claude/agents-overview.md +8 -0
- package/releases/0.35.0/surfaces/integrations/tree/.claude/graph.md +10 -0
- package/releases/0.35.0/surfaces/integrations/tree/.claude/patterns.md +38 -0
- package/releases/0.35.0/surfaces/integrations/tree/.claude/pipeline.md +16 -0
- package/releases/0.35.0/surfaces/integrations/tree/.claude/retrieval.md +16 -0
- package/releases/0.35.0/surfaces/integrations/tree/.claude/router.md +6 -0
- package/releases/0.35.0/surfaces/integrations/tree/CLAUDE.md +17 -0
- package/releases/0.35.0/surfaces/money/tree/.claude/agents/architect.md +12 -0
- package/releases/0.35.0/surfaces/money/tree/.claude/agents/dba.md +14 -0
- package/releases/0.35.0/surfaces/money/tree/.claude/agents/implementer.md +11 -0
- package/releases/0.35.0/surfaces/money/tree/.claude/agents/integration-tester.md +3 -0
- package/releases/0.35.0/surfaces/money/tree/.claude/agents/reviewer.md +23 -0
- package/releases/0.35.0/surfaces/money/tree/.claude/agents/secops.md +5 -0
- package/releases/0.35.0/surfaces/money/tree/.claude/agents/solidity-auditor.md +4 -0
- package/releases/0.35.0/surfaces/money/tree/.claude/agents/solidity-dev.md +4 -0
- package/releases/0.35.0/surfaces/money/tree/.claude/graph.md +2 -0
- package/releases/0.35.0/surfaces/money/tree/.claude/patterns.md +49 -0
- package/releases/0.35.0/surfaces/money/tree/.claude/pipeline.md +2 -0
- package/releases/0.35.0/surfaces/money/tree/.claude/retrieval.md +2 -0
- package/releases/0.35.0/surfaces/money/tree/CLAUDE.md +11 -0
- package/releases/0.35.0/surfaces/pii/tree/.claude/agents/dba.md +2 -0
- package/releases/0.35.0/surfaces/pii/tree/.claude/agents/devops.md +8 -0
- package/releases/0.35.0/surfaces/pii/tree/.claude/agents/reviewer.md +8 -0
- package/releases/0.35.0/surfaces/pii/tree/.claude/agents/secops.md +2 -0
- package/releases/0.35.0/surfaces/pii/tree/.claude/agents/solidity-auditor.md +5 -0
- package/releases/0.35.0/surfaces/pii/tree/CLAUDE.md +5 -0
- package/releases/0.35.0/surfaces/prisma/tree/.claude/agents/architect.md +2 -0
- package/releases/0.35.0/surfaces/prisma/tree/.claude/agents/dba.md +39 -0
- package/releases/0.35.0/surfaces/prisma/tree/.claude/agents/devops.md +11 -0
- package/releases/0.35.0/surfaces/prisma/tree/.claude/agents/implementer.md +2 -0
- package/releases/0.35.0/surfaces/prisma/tree/.claude/agents/integration-tester.md +2 -0
- package/releases/0.35.0/surfaces/prisma/tree/.claude/agents/reviewer.md +2 -0
- package/releases/0.35.0/surfaces/prisma/tree/.claude/patterns.md +15 -0
- package/releases/0.35.0/surfaces/prisma/tree/CLAUDE.md +5 -0
- package/src/agentrun.mjs +89 -9
- package/src/commands/check.mjs +12 -8
- package/src/commands/compose.mjs +85 -9
- package/src/commands/init.mjs +13 -4
- package/src/commands/learn.mjs +12 -3
- package/src/commands/release.mjs +21 -3
- package/src/commands/stats.mjs +57 -7
- package/src/commands/upgrade.mjs +131 -25
- package/src/guard.mjs +7 -37
- package/src/paths.mjs +50 -1
- package/src/prices.mjs +7 -7
- package/src/vocabulary.mjs +12 -1
- package/src/wiring.mjs +106 -41
package/src/commands/upgrade.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
534
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
package/src/vocabulary.mjs
CHANGED
|
@@ -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]) =>
|
|
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
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
410
|
-
(
|
|
411
|
-
|
|
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.
|