@holmes-lab/holmes-kit 0.19.0 → 0.19.3

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 (35) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/dist/.build-id +1 -1
  3. package/dist/holmes/cli/agents.d.ts +22 -0
  4. package/dist/holmes/cli/agents.js +76 -1
  5. package/dist/holmes/cli/approve.js +6 -1
  6. package/dist/holmes/cli/doctor.d.ts +51 -1
  7. package/dist/holmes/cli/doctor.js +211 -36
  8. package/dist/holmes/cli/index.js +7 -1
  9. package/dist/holmes/cli/init.js +12 -0
  10. package/dist/holmes/cli/native-deps.d.ts +65 -0
  11. package/dist/holmes/cli/native-deps.js +131 -0
  12. package/dist/holmes/cpg/cycle-observation.d.ts +65 -0
  13. package/dist/holmes/cpg/cycle-observation.js +146 -0
  14. package/dist/holmes/governance/approval-queue.d.ts +23 -4
  15. package/dist/holmes/governance/approval-queue.js +44 -6
  16. package/dist/holmes/hooks/stop.d.ts +15 -0
  17. package/dist/holmes/hooks/stop.js +46 -3
  18. package/dist/holmes/mcp/handlers.d.ts +2 -0
  19. package/dist/holmes/mcp/handlers.js +29 -2
  20. package/dist/holmes/mcp/maintenance-analyze.d.ts +37 -0
  21. package/dist/holmes/mcp/maintenance-analyze.js +73 -1
  22. package/dist/holmes/mcp/maintenance-evidence.d.ts +41 -0
  23. package/dist/holmes/mcp/maintenance-evidence.js +71 -4
  24. package/dist/holmes/project/install-scripts-policy.d.ts +76 -0
  25. package/dist/holmes/project/install-scripts-policy.js +131 -0
  26. package/dist/holmes/project/npx-bin.d.ts +6 -0
  27. package/dist/holmes/project/npx-bin.js +10 -0
  28. package/dist/holmes/review/failed-test-names.d.ts +19 -0
  29. package/dist/holmes/review/failed-test-names.js +43 -0
  30. package/dist/holmes/review/run-replay.d.ts +23 -0
  31. package/dist/holmes/review/run-replay.js +30 -0
  32. package/dist/holmes/review/test-runner.d.ts +27 -0
  33. package/dist/holmes/review/test-runner.js +59 -3
  34. package/docs/install-guide.md +54 -5
  35. package/package.json +4 -1
@@ -37,7 +37,11 @@ exports.STRIPPED_FOR_PROBE = void 0;
37
37
  exports.globalInstallDir = globalInstallDir;
38
38
  exports.npmCliEntry = npmCliEntry;
39
39
  exports.prefixVerdict = prefixVerdict;
40
+ exports.grammarProbe = grammarProbe;
41
+ exports.resolveDepDir = resolveDepDir;
42
+ exports.gatherNativeEvidence = gatherNativeEvidence;
40
43
  exports.probeEnv = probeEnv;
44
+ exports.resolveWiringPath = resolveWiringPath;
41
45
  exports.runDoctor = runDoctor;
42
46
  exports.wiringSpawnCheck = wiringSpawnCheck;
43
47
  exports.pushGateCheck = pushGateCheck;
@@ -48,8 +52,11 @@ exports.detectTreeKeyTemporary = detectTreeKeyTemporary;
48
52
  // @implements A-SPEC-442
49
53
  // @implements A-SPEC-207
50
54
  // @implements A-SPEC-100.2
55
+ // @implements A-SPEC-580
51
56
  const fs = __importStar(require("node:fs"));
52
57
  const npx_bin_1 = require("../project/npx-bin");
58
+ const install_scripts_policy_1 = require("../project/install-scripts-policy");
59
+ const native_deps_1 = require("./native-deps");
53
60
  const tier_1 = require("../semantic/tier");
54
61
  const path = __importStar(require("node:path"));
55
62
  const role_policy_1 = require("../governance/role-policy");
@@ -175,6 +182,140 @@ function prefixVerdict(input) {
175
182
  fix,
176
183
  };
177
184
  }
185
+ /**
186
+ * @implements A-SPEC-580
187
+ * Parse a trivial source with every grammar, in a child node whose module resolution starts at
188
+ * `packageRoot`. `parsed` counts typescript and tsx separately (8 for the 7 grammar packages).
189
+ */
190
+ function grammarProbe(packageRoot) {
191
+ const script = `
192
+ const out = { parsed: 0, failed: [] };
193
+ let Parser;
194
+ try { Parser = require('tree-sitter'); }
195
+ catch (e) { process.stdout.write(JSON.stringify({ ...out, error: String(e && e.message || e).split('\\n')[0] })); process.exit(0); }
196
+ const loaders = [
197
+ ['typescript', () => require('tree-sitter-typescript').typescript],
198
+ ['tsx', () => require('tree-sitter-typescript').tsx],
199
+ ];
200
+ for (const g of ${JSON.stringify(GRAMMARS.filter((g) => g !== 'tree-sitter-typescript'))}) {
201
+ loaders.push([g.replace('tree-sitter-', ''), () => require(g)]);
202
+ }
203
+ for (const [name, load] of loaders) {
204
+ try {
205
+ const p = new Parser();
206
+ p.setLanguage(load());
207
+ const node = p.parse('x').rootNode;
208
+ const root = node && node.type;
209
+ if (!root) throw new Error('parse produced no root node');
210
+ out.parsed++;
211
+ } catch (e) { out.failed.push(name + ': ' + String(e && e.message || e).split('\\n')[0]); }
212
+ }
213
+ process.stdout.write(JSON.stringify(out));
214
+ `;
215
+ try {
216
+ const r = (0, node_child_process_1.spawnSync)(process.execPath, ['-e', script], { cwd: packageRoot, encoding: 'utf8', timeout: 30000, env: { ...process.env, NODE_PATH: path.join(packageRoot, 'node_modules') } });
217
+ if (r.status !== 0 || !r.stdout)
218
+ return { parsed: 0, failed: [], error: (r.stderr || `exit ${r.status}`).split('\n')[0] };
219
+ return JSON.parse(r.stdout);
220
+ }
221
+ catch (e) {
222
+ return { parsed: 0, failed: [], error: e.message.split('\n')[0] };
223
+ }
224
+ }
225
+ /** A short spawn whose failure means "not observed" — never a verdict. */
226
+ function observe(file, args) {
227
+ try {
228
+ return (0, node_child_process_1.execFileSync)(file, args, { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
229
+ }
230
+ catch {
231
+ return undefined;
232
+ }
233
+ }
234
+ /**
235
+ * @implements A-SPEC-580
236
+ * Everything observable about the better-sqlite3 install, gathered for `nativeVerdict`. Each probe
237
+ * is independent and optional: a spawn that fails leaves its field `undefined` (not observed), and
238
+ * the verdict says so rather than treating silence as evidence.
239
+ *
240
+ * Which package.json holds the approval policy depends on the layout: this checkout's own for a
241
+ * repository run, the PROJECT's (three levels up from node_modules/@holmes-lab/holmes-kit) for a
242
+ * local dependency. A global or npx install has no project package.json that npm consults, so its
243
+ * coverage is `uncovered` by construction and the remedy is the per-command `--allow-scripts`.
244
+ */
245
+ // @implements A-SPEC-583.1
246
+ /**
247
+ * Where an installed dependency actually lives, asked of NODE rather than guessed from a path.
248
+ *
249
+ * 0.19.2 shipped a false FAIL to every third-party consumer: it built the directory by joining
250
+ * `packageRoot/node_modules/<name>`, and npm HOISTS dependencies to the project root, so for an
251
+ * install at `<proj>/node_modules/@holmes-lab/holmes-kit` the joined path never existed — while the
252
+ * binding sat at `<proj>/node_modules/better-sqlite3` and loaded fine. Hoisting is npm's default,
253
+ * which made the false verdict the default path of a normal install.
254
+ *
255
+ * `package.json` is the resolution target because we want the DIRECTORY, not the entry point: a
256
+ * package whose `exports` hides its main still resolves its own manifest by convention. A failure
257
+ * to resolve returns null, and the caller reports "absent" exactly as before — the repair changes
258
+ * how we look, never whether a real miss is reported.
259
+ */
260
+ function resolveDepDir(fromRoot, name) {
261
+ if (name === '')
262
+ return null;
263
+ try {
264
+ return path.dirname(require.resolve(`${name}/package.json`, { paths: [fromRoot] }));
265
+ }
266
+ catch {
267
+ return null;
268
+ }
269
+ }
270
+ function gatherNativeEvidence(packageRoot) {
271
+ const name = 'better-sqlite3';
272
+ // @implements A-SPEC-583.1 — resolved, not joined. A hoisted install is the npm default.
273
+ const pkgDir = resolveDepDir(packageRoot, name);
274
+ const bindingPresent = pkgDir !== null && fs.existsSync(path.join(pkgDir, 'build', 'Release', 'better_sqlite3.node'));
275
+ let packageVersion = '(unknown)';
276
+ try {
277
+ packageVersion = JSON.parse(fs.readFileSync(path.join(pkgDir ?? '', 'package.json'), 'utf8')).version ?? packageVersion;
278
+ }
279
+ catch { /* not installed */ }
280
+ let loadError;
281
+ try {
282
+ const Database = require(name);
283
+ const db = new Database(':memory:');
284
+ db.prepare('SELECT 1 AS ok').get();
285
+ db.close();
286
+ }
287
+ catch (e) {
288
+ loadError = e.message.split('\n')[0];
289
+ }
290
+ const npmCli = npmCliEntry();
291
+ const npmVersion = npmCli !== null ? observe(process.execPath, [npmCli, '--version']) : observe('npm', ['--version']);
292
+ const npmMajor = npmVersion !== undefined && /^\d+/.test(npmVersion) ? Number(npmVersion.match(/^\d+/)[0]) : undefined;
293
+ const prefix = npmCli !== null ? observe(process.execPath, [npmCli, 'config', 'get', 'prefix']) : observe('npm', ['config', 'get', 'prefix']);
294
+ const kind = (0, native_deps_1.installKind)(packageRoot, prefix ? globalInstallDir(prefix, process.platform) : undefined);
295
+ let coverage = 'uncovered';
296
+ const policyJson = kind === 'repo' ? path.join(packageRoot, 'package.json')
297
+ : kind === 'local' ? path.join(packageRoot, '..', '..', '..', 'package.json')
298
+ : undefined;
299
+ if (policyJson !== undefined) {
300
+ try {
301
+ const allow = JSON.parse(fs.readFileSync(policyJson, 'utf8')).allowScripts;
302
+ coverage = (0, install_scripts_policy_1.allowScriptsCoverage)(allow, name, packageVersion);
303
+ }
304
+ catch { /* unreadable → uncovered, which is what npm would see too */ }
305
+ }
306
+ const ev = {
307
+ platform: process.platform, packageName: name, packageVersion, bindingPresent, loadError, npmMajor,
308
+ coverage, installKind: kind, pathHasSpace: packageRoot.includes(' '),
309
+ };
310
+ if (process.platform === 'win32') {
311
+ const python = observe('where.exe', ['python']) !== undefined || observe('where.exe', ['py']) !== undefined;
312
+ const vswhere = path.join(process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)', 'Microsoft Visual Studio', 'Installer', 'vswhere.exe');
313
+ const msvc = fs.existsSync(vswhere) || observe('where.exe', ['cl']) !== undefined || process.env.VCINSTALLDIR !== undefined;
314
+ ev.toolchain = { python, msvc };
315
+ ev.psPolicy = observe('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', 'Get-ExecutionPolicy']);
316
+ }
317
+ return ev;
318
+ }
178
319
  /** The parent environment minus the variables that legitimately change a gate decision. Pure. */
179
320
  function probeEnv(parent) {
180
321
  const out = { ...parent };
@@ -208,6 +349,23 @@ function wiredSettingsPath(target) {
208
349
  return (0, init_1.settingsPathOf)(target, 'project');
209
350
  return (0, init_1.settingsPathOf)(target, fs.existsSync((0, init_1.settingsPathOf)(target, 'local')) ? 'local' : 'project');
210
351
  }
352
+ // @implements A-SPEC-581.1
353
+ /**
354
+ * Where a wiring's executable actually is, judged from the PROJECT rather than from the caller.
355
+ *
356
+ * Measured 2026-09-10: the same `.codex/config.toml` under the same `--target` read PASS from the
357
+ * repository root and FAIL from `/tmp`, because a relative arg is resolved by Node against
358
+ * `process.cwd()`. A relative arg is the CONVENTION here — `.mcp.json` has always carried
359
+ * `bin/holmes-mcp.js` — so the verdict was decided by where the person diagnosing stood.
360
+ *
361
+ * An absolute arg is returned untouched: agy's wiring is absolute and must not move. An empty arg
362
+ * stays empty — resolving it would conjure the target directory itself into a "file that exists".
363
+ */
364
+ function resolveWiringPath(target, arg) {
365
+ if (arg === '')
366
+ return '';
367
+ return path.isAbsolute(arg) ? arg : path.resolve(target, arg);
368
+ }
211
369
  async function runDoctor(packageRoot, target, opts, extraChecks) {
212
370
  const checks = [];
213
371
  const add = (name, level, detail, fix) => checks.push({ name, level, detail, fix });
@@ -223,39 +381,39 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
223
381
  // 2. Node version vs better-sqlite3's declared range (WARN only — a mismatch still often works).
224
382
  let sqliteRange = '(unknown)';
225
383
  try {
226
- sqliteRange = JSON.parse(fs.readFileSync(path.join(packageRoot, 'node_modules', 'better-sqlite3', 'package.json'), 'utf8')).engines?.node ?? '(none)';
384
+ // @implements A-SPEC-583.1 the SIBLING of the evidence lookup; both move together.
385
+ sqliteRange = JSON.parse(fs.readFileSync(path.join(resolveDepDir(packageRoot, 'better-sqlite3') ?? '', 'package.json'), 'utf8')).engines?.node ?? '(none)';
227
386
  }
228
387
  catch { /* not resolvable from here */ }
229
388
  add('node version', 'PASS', `node ${process.version}; better-sqlite3 declares engines.node=${sqliteRange}`, 'If a native module fails to load after a Node major upgrade, reinstall holmes-kit.');
230
- // 3. tree-sitter + EACH grammar. language-parser.ts require.resolve's all of them at module scope,
231
- // so ONE missing grammar kills MCP server startup entirely report them individually.
232
- try {
233
- require('tree-sitter');
234
- const missing = GRAMMARS.filter((g) => { try {
235
- require.resolve(g);
236
- return false;
389
+ // 3. tree-sitter + EACH grammar — PARSED, not merely resolved. `require.resolve` only proves the
390
+ // package directory exists; a grammar whose prebuilt binding is missing for this platform
391
+ // resolves fine and dies at `setLanguage`. language-parser.ts loads them at module scope, so
392
+ // ONE dead grammar kills MCP server startup — name the dead ones individually.
393
+ // @implements A-SPEC-580
394
+ // Measured in a FRESH process: tree-sitter's native addon binds to the first module registry
395
+ // that loads it, so a second `require` inside the same process (jest workers; any host that
396
+ // already loaded it) hands back a Parser whose `parse()` yields no rootNode — a false FAIL that
397
+ // says nothing about the install. The probe therefore runs in a child node, which is also
398
+ // exactly what the MCP server does at startup.
399
+ {
400
+ const g = grammarProbe(packageRoot);
401
+ if (g.error !== undefined) {
402
+ add('tree-sitter grammars', 'FAIL', `tree-sitter failed to load: ${g.error}`, 'The tree-sitter runtime binding for this platform is missing from its prebuilds. Reinstall; a source build needs Python and a C++ toolchain.');
403
+ }
404
+ else if (g.failed.length === 0) {
405
+ add('tree-sitter grammars', 'PASS', `tree-sitter + ${g.parsed} grammars parse`);
406
+ }
407
+ else {
408
+ add('tree-sitter grammars', 'FAIL', `failed to parse with: ${g.failed.join('; ')}`, 'Grammars load from shipped prebuilds (no install script needed) — reinstall the package. A single dead grammar prevents the MCP server from starting.');
237
409
  }
238
- catch {
239
- return true;
240
- } });
241
- if (missing.length === 0)
242
- add('tree-sitter grammars', 'PASS', `tree-sitter + ${GRAMMARS.length} grammars resolve`);
243
- else
244
- add('tree-sitter grammars', 'FAIL', `missing: ${missing.join(', ')}`, 'A single missing grammar prevents the MCP server from starting. Reinstall; if a native build failed, ensure a C++ toolchain is available.');
245
- }
246
- catch (e) {
247
- add('tree-sitter grammars', 'FAIL', `tree-sitter failed to load: ${e.message}`, 'Native module build failed. Install Xcode Command Line Tools (macOS) or build-essential, then reinstall.');
248
- }
249
- // 4. better-sqlite3 — ABI-locked (not N-API), the most fragile dependency.
250
- try {
251
- const Database = require('better-sqlite3');
252
- const db = new Database(':memory:');
253
- db.prepare('SELECT 1 AS ok').get();
254
- db.close();
255
- add('better-sqlite3', 'PASS', 'loads and executes against :memory:');
256
410
  }
257
- catch (e) {
258
- add('better-sqlite3', 'FAIL', e.message, 'better-sqlite3 is ABI-locked to the Node version. Reinstall holmes-kit after any Node major change; a source build needs a C++ toolchain.');
411
+ // 4. better-sqlite3 — the one dependency whose install script must RUN (prebuild-install ||
412
+ // node-gyp rebuild). Its failure has at least five causes with five different remedies, so the
413
+ // verdict is computed from evidence (A-SPEC-580), never from the load error's wording alone.
414
+ {
415
+ const v = (0, native_deps_1.nativeVerdict)(gatherNativeEvidence(packageRoot));
416
+ add('better-sqlite3', v.level, v.detail, v.fix);
259
417
  }
260
418
  // 5. Hooks actually gate — prove it with a live allow AND a live deny (exit 2).
261
419
  if (built) {
@@ -809,11 +967,18 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
809
967
  // the whole check for the common wiring, so "the file is there" was reported as if the
810
968
  // server had been proven to start. `wiringHandshakeChecks` below now proves that, for
811
969
  // every wired harness; this stays as the cheap, fast FAIL for a path that is simply gone.
970
+ // @implements A-SPEC-581.1 — resolved against the TARGET; the message keeps the string
971
+ // as written, because what the operator must fix is the wiring file, not our arithmetic.
812
972
  const bin = server.args[0] ?? '';
813
- add('mcp wiring spawn', fs.existsSync(bin) ? 'PASS' : 'FAIL', fs.existsSync(bin) ? `node wiring resolves: ${bin} (handshake proven separately)` : `node wiring points at a missing file: ${bin}`, fs.existsSync(bin) ? undefined : 'Run `holmes-kit init` in the target to rewire the absolute path.');
973
+ const binAt = resolveWiringPath(target, bin);
974
+ add('mcp wiring spawn', fs.existsSync(binAt) ? 'PASS' : 'FAIL', fs.existsSync(binAt) ? `node wiring resolves: ${bin} (handshake proven separately)` : `node wiring points at a missing file: ${bin}`, fs.existsSync(binAt) ? undefined : 'Run `holmes-kit init` in the target to rewire the absolute path.');
814
975
  }
815
976
  else {
816
- checks.push(await wiringSpawnCheck(server.command, server.args));
977
+ // @implements A-SPEC-581.1 — the SIBLING of the handshake spawn, and it must move with
978
+ // it. Measured on a consumer install: the same npx wiring read PASS from the handshake
979
+ // (launched in the target) and FAIL here (launched in doctor's cwd) — two checks, one
980
+ // wiring, opposite verdicts.
981
+ checks.push(await wiringSpawnCheck(server.command, server.args, undefined, undefined, target));
817
982
  }
818
983
  }
819
984
  catch {
@@ -883,17 +1048,19 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
883
1048
  // the "resolves" claim stronger than the check (adversarial round, Finding 2). Verify the
884
1049
  // basename too, to close that gap.
885
1050
  const bin = entry.args[0] ?? '';
1051
+ // @implements A-SPEC-581.1 — against the TARGET, not the cwd.
1052
+ const binAt = resolveWiringPath(target, bin);
886
1053
  // Resolve the link before judging the name (round-2 F4): a symlink literally named
887
1054
  // `holmes-mcp.js` pointing at /etc/hosts must not read as "resolves to this install".
888
1055
  const realBin = (() => { try {
889
- return fs.realpathSync(bin);
1056
+ return fs.realpathSync(binAt);
890
1057
  }
891
1058
  catch {
892
1059
  return '';
893
1060
  } })();
894
1061
  const resolvesHere = realBin !== '' && path.basename(realBin) === 'holmes-mcp.js';
895
1062
  add('codex wiring', resolvesHere ? 'PASS' : 'FAIL', resolvesHere ? `MCP wiring resolves to this install: ${bin}`
896
- : (fs.existsSync(bin) ? `MCP wiring points at a file that is not holmes-mcp.js: ${bin}` : `MCP wiring points at a missing file: ${bin}`), resolvesHere ? undefined : 'Run holmes-kit init --target <dir> --agent codex --force to refresh the absolute path.');
1063
+ : (fs.existsSync(binAt) ? `MCP wiring points at a file that is not holmes-mcp.js: ${bin}` : `MCP wiring points at a missing file: ${bin}`), resolvesHere ? undefined : 'Run holmes-kit init --target <dir> --agent codex --force to refresh the absolute path.');
897
1064
  }
898
1065
  else {
899
1066
  const pin = (0, mcp_version_1.mcpLaunchVersion)({ command: entry.command, args: entry.args });
@@ -1095,7 +1262,12 @@ function cleanupOnSignal(dir) {
1095
1262
  // harness will actually run reaches a server — the gap the dead npx form lived in.
1096
1263
  // @implements A-SPEC-499.1 — exported for the prescription tests; `platform` is injectable so the
1097
1264
  // win32 branch is testable off-Windows.
1098
- function wiringSpawnCheck(command, args, timeoutMs = 30000, platform = process.platform) {
1265
+ function wiringSpawnCheck(command, args, timeoutMs = 30000, platform = process.platform,
1266
+ // @implements A-SPEC-581.1 — the directory the wiring is launched FROM. A harness starts the
1267
+ // server in the project, so a relative arg (`bin/holmes-mcp.js`, the .mcp.json convention)
1268
+ // resolves there. Left undefined the child inherits doctor's cwd, which made the handshake
1269
+ // verdict depend on where doctor ran — measured 2026-09-10.
1270
+ cwd) {
1099
1271
  // The DISPLAYED command stays the original wiring string even when the win32 adapter rewraps the
1100
1272
  // execution — the user compares this against their wiring file, not against cmd.exe plumbing.
1101
1273
  const quoted = `${command} ${args.join(' ')}`;
@@ -1124,7 +1296,7 @@ function wiringSpawnCheck(command, args, timeoutMs = 30000, platform = process.p
1124
1296
  };
1125
1297
  const timer = setTimeout(() => finish('FAIL', `wiring did not answer initialize within ${timeoutMs / 1000}s: \`${quoted}\``, 'Re-run `holmes-kit init` in the target to rewrite the wiring, then re-run doctor.'), timeoutMs);
1126
1298
  try {
1127
- child = (0, node_child_process_1.spawn)(spec.command, spec.args, { stdio: ['pipe', 'pipe', 'pipe'] });
1299
+ child = (0, node_child_process_1.spawn)(spec.command, spec.args, { stdio: ['pipe', 'pipe', 'pipe'], ...(cwd === undefined ? {} : { cwd }) });
1128
1300
  }
1129
1301
  catch (e) {
1130
1302
  finish('FAIL', `wiring could not be spawned: \`${quoted}\` — ${e.message}`, spawnFailFix(e));
@@ -1385,13 +1557,16 @@ async function wiringHandshakeChecks(target) {
1385
1557
  }
1386
1558
  if (entry.command === 'node') {
1387
1559
  const bin = entry.args[0] ?? '';
1388
- if (!fs.existsSync(bin)) {
1560
+ // @implements A-SPEC-581.1 — the handshake covers ALL THREE harnesses, so the cwd-dependent
1561
+ // read here was the widest instance of the same defect.
1562
+ if (!fs.existsSync(resolveWiringPath(target, bin))) {
1389
1563
  out.push({ name, level: 'FAIL', detail: `The wiring points at a missing file: ${bin}`,
1390
1564
  fix: `Run holmes-kit init --target <dir> --agent ${wiring.label} --force to refresh the path.` });
1391
1565
  continue;
1392
1566
  }
1393
1567
  }
1394
- const check = await wiringSpawnCheck(entry.command, entry.args);
1568
+ // @implements A-SPEC-581.1 launched FROM the target, exactly as the harness would.
1569
+ const check = await wiringSpawnCheck(entry.command, entry.args, undefined, undefined, target);
1395
1570
  out.push({ ...check, name });
1396
1571
  }
1397
1572
  return out;
@@ -770,7 +770,13 @@ async function main(argv) {
770
770
  // The detail row travels with it: round-5 measured the one-line form at 81 columns, so the flag
771
771
  // path prints the same two rows the screen does — subject read, id acted on.
772
772
  const resolveRef = (ref) => {
773
- const r = resolveRequestRef(readQueue(root).pending, ref);
773
+ // @implements A-SPEC-576.1 — the grammar decides which queue the reference is resolved against.
774
+ // A NUMBER is an index into the list the operator was just shown, so it must resolve against
775
+ // the same filtered list or `--grant 3` would act on a row nobody saw. An ID or prefix is the
776
+ // operator naming a specific request — including a `shell` refusal they approved out of band
777
+ // (A-SPEC-563.2), which the inbox filter hides from the list but must never make ungrantable.
778
+ const byIndex = /^\d+$/.test(ref.trim());
779
+ const r = resolveRequestRef(readQueue(root, byIndex ? undefined : { includeAllKinds: true }).pending, ref);
774
780
  return r.ok ? { id: r.entry.id, subject: (prefix) => decisionSubject(r.entry, prefix), detail: (tail) => decisionDetail(r.entry, tail) } : { refusal: r.reason };
775
781
  };
776
782
  const root = typeof flags.target === 'string' ? path.resolve(flags.target) : process.cwd();
@@ -458,6 +458,18 @@ function runInit(opts) {
458
458
  for (const agent of opts.agents ?? []) {
459
459
  for (const f of (0, agents_1.agentFiles)(agent, { target: opts.target, packageRoot: opts.packageRoot, specsDir: opts.specsDir, launcher: opts.mcpLauncher })) {
460
460
  const before = fs.existsSync(f.path) ? fs.readFileSync(f.path, 'utf8') : null;
461
+ // @implements A-SPEC-576.2 — AGENTS.md is the one file here a PERSON edits. Regeneration
462
+ // used to overwrite it whole: it deleted the ADR-018 parity rule a commit had just added,
463
+ // and said nothing. The merge runs in the COMPUTE phase so `--dry-run` predicts the same
464
+ // bytes the write produces (A-SPEC-190 §9), and whatever it moves, it names.
465
+ if (path.basename(f.path) === 'AGENTS.md') {
466
+ const merged = (0, agents_1.mergeAgentsMd)(before, f.content);
467
+ changes.push({ path: f.path, before, after: merged.content });
468
+ if (merged.preserved.length > 0) {
469
+ messages.push(`${f.path}: ${merged.note} — ${merged.preserved.join(' | ')}`);
470
+ }
471
+ continue;
472
+ }
461
473
  changes.push({ path: f.path, before, after: f.content });
462
474
  }
463
475
  messages.push(agents_1.HARNESS_ENFORCES[agent]
@@ -0,0 +1,65 @@
1
+ import type { Coverage } from '../project/install-scripts-policy';
2
+ /**
3
+ * @implements A-SPEC-580
4
+ * Why better-sqlite3 has no binary — judged from EVIDENCE, not from the load error's wording.
5
+ *
6
+ * Measured 2026-09-09 (Windows, npm 12.0.1, Node 24.19.0): doctor said "ABI-locked … reinstall"
7
+ * when the real cause was npm 12 skipping the install script for lack of an `allowScripts` entry.
8
+ * A reinstall reproduces the same state. At least five causes hide behind one FAIL and each has a
9
+ * different remedy, so this module collects what is observable and refuses to assert what is not:
10
+ * a prebuilt-download failure leaves no trace doctor can read, and is named as a possibility only.
11
+ *
12
+ * Pure. The caller (doctor) gathers the evidence; every process it spawns for that is optional,
13
+ * and "not observed" is carried as `undefined`, never as `false`.
14
+ */
15
+ export type InstallKind = 'repo' | 'local' | 'global' | 'npx' | 'unknown';
16
+ export type NativeCause = 'ok' | 'scripts-blocked' | 'abi-mismatch' | 'build-failed' | 'unknown';
17
+ export type Level = 'PASS' | 'WARN' | 'FAIL';
18
+ export interface NativeEvidence {
19
+ platform: string;
20
+ packageName: string;
21
+ packageVersion: string;
22
+ /** `build/Release/<name>.node` exists under the package. */
23
+ bindingPresent: boolean;
24
+ /** The first line of the `require` failure, when it failed. */
25
+ loadError?: string;
26
+ /** `npm --version` major; undefined when npm could not be consulted. */
27
+ npmMajor?: number;
28
+ /** How the ROOT package.json that governs this install covers the package (A-SPEC-579). */
29
+ coverage: Coverage;
30
+ installKind: InstallKind;
31
+ /** win32 only: whether a source build could even start. */
32
+ toolchain?: {
33
+ python: boolean;
34
+ msvc: boolean;
35
+ };
36
+ /** node-gyp is known to trip over spaces in the install path. */
37
+ pathHasSpace: boolean;
38
+ /** win32 only: `Get-ExecutionPolicy`, when observed. */
39
+ psPolicy?: string;
40
+ }
41
+ /** Where this package lives — the layout decides which package.json (if any) holds the policy. */
42
+ export declare function installKind(packageRoot: string, globalDir?: string): InstallKind;
43
+ /**
44
+ * The narrowest commands that repair each layout. `--allow-scripts=<pkg>` is a per-invocation flag
45
+ * scoped to ONE package — never `--dangerously-allow-all-scripts`, never a change to npm config.
46
+ *
47
+ * Global targets the DEPENDENCY, not the holmes-kit package: measured 2026-09-09 (npm 12.0.1,
48
+ * Windows), `npm rebuild -g @holmes-lab/holmes-kit` re-links the bin and dies EEXIST on the
49
+ * existing `holmes-kit` shim before any script runs, while `npm rebuild -g better-sqlite3
50
+ * --allow-scripts=better-sqlite3` runs `prebuild-install` in place. (It then needs the global
51
+ * prefix to be writable — a protected prefix fails EPERM there, which is the `global prefix`
52
+ * check's territory, not this one's.)
53
+ */
54
+ export declare function recoveryCommands(ev: Pick<NativeEvidence, 'platform' | 'installKind' | 'packageName' | 'packageVersion'>): string[];
55
+ /**
56
+ * What a PowerShell execution policy means for the emitted commands. Only the two policies that
57
+ * refuse every unsigned local script block the `npm.ps1`/`npx.ps1` shims; the rest add nothing.
58
+ */
59
+ export declare function powershellPolicyNote(policy: string | undefined): string;
60
+ export declare function nativeVerdict(ev: NativeEvidence): {
61
+ cause: NativeCause;
62
+ level: Level;
63
+ detail: string;
64
+ fix?: string;
65
+ };
@@ -0,0 +1,131 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.installKind = installKind;
4
+ exports.recoveryCommands = recoveryCommands;
5
+ exports.powershellPolicyNote = powershellPolicyNote;
6
+ exports.nativeVerdict = nativeVerdict;
7
+ // @implements A-SPEC-580
8
+ // @implements A-SPEC-580.1
9
+ const npx_bin_1 = require("../project/npx-bin");
10
+ const install_scripts_policy_1 = require("../project/install-scripts-policy");
11
+ const HOLMES_PKG = '@holmes-lab/holmes-kit';
12
+ /** Where this package lives — the layout decides which package.json (if any) holds the policy. */
13
+ function installKind(packageRoot, globalDir) {
14
+ const norm = packageRoot.replace(/\\/g, '/').replace(/\/+$/, '');
15
+ if (norm.split('/').includes('_npx'))
16
+ return 'npx';
17
+ if (globalDir) {
18
+ const g = globalDir.replace(/\\/g, '/').replace(/\/+$/, '');
19
+ if (norm.toLowerCase().startsWith(`${g.toLowerCase()}/`))
20
+ return 'global';
21
+ }
22
+ if (/\/node_modules\/@holmes-lab\/holmes-kit$/.test(norm))
23
+ return 'local';
24
+ return 'repo';
25
+ }
26
+ /**
27
+ * The narrowest commands that repair each layout. `--allow-scripts=<pkg>` is a per-invocation flag
28
+ * scoped to ONE package — never `--dangerously-allow-all-scripts`, never a change to npm config.
29
+ *
30
+ * Global targets the DEPENDENCY, not the holmes-kit package: measured 2026-09-09 (npm 12.0.1,
31
+ * Windows), `npm rebuild -g @holmes-lab/holmes-kit` re-links the bin and dies EEXIST on the
32
+ * existing `holmes-kit` shim before any script runs, while `npm rebuild -g better-sqlite3
33
+ * --allow-scripts=better-sqlite3` runs `prebuild-install` in place. (It then needs the global
34
+ * prefix to be writable — a protected prefix fails EPERM there, which is the `global prefix`
35
+ * check's territory, not this one's.)
36
+ */
37
+ function recoveryCommands(ev) {
38
+ const npm = (0, npx_bin_1.npmBin)(ev.platform);
39
+ const project = [
40
+ (0, install_scripts_policy_1.approveCommand)(ev.packageName, ev.packageVersion, npm),
41
+ `${npm} rebuild ${ev.packageName} --foreground-scripts`,
42
+ ];
43
+ switch (ev.installKind) {
44
+ case 'global': return [`${npm} rebuild -g ${ev.packageName} --foreground-scripts --allow-scripts=${ev.packageName}`];
45
+ case 'npx': return [`${npm} install --save-dev ${HOLMES_PKG}`, ...project];
46
+ default: return project;
47
+ }
48
+ }
49
+ /** A rebuild alone (the script is approved or the policy is not the problem). */
50
+ function rebuildCommand(ev) {
51
+ const npm = (0, npx_bin_1.npmBin)(ev.platform);
52
+ return ev.installKind === 'global'
53
+ ? `${npm} rebuild -g ${ev.packageName} --foreground-scripts --allow-scripts=${ev.packageName}`
54
+ : `${npm} rebuild ${ev.packageName} --foreground-scripts`;
55
+ }
56
+ /**
57
+ * What a PowerShell execution policy means for the emitted commands. Only the two policies that
58
+ * refuse every unsigned local script block the `npm.ps1`/`npx.ps1` shims; the rest add nothing.
59
+ */
60
+ function powershellPolicyNote(policy) {
61
+ if (policy === 'Restricted' || policy === 'AllSigned') {
62
+ return `PowerShell execution policy ${policy} blocks the npm.ps1/npx.ps1 shims — use npm.cmd/npx.cmd (the commands above already do).`;
63
+ }
64
+ return '';
65
+ }
66
+ const ABI_RE = /NODE_MODULE_VERSION|compiled against a different Node\.js version/;
67
+ function nativeVerdict(ev) {
68
+ const pkg = `${ev.packageName}@${ev.packageVersion}`;
69
+ const isWin = ev.platform === 'win32';
70
+ const psNote = isWin ? powershellPolicyNote(ev.psPolicy) : '';
71
+ const withPs = (detail) => (psNote ? `${detail} ${psNote}` : detail);
72
+ const approved = ev.coverage === 'approved-pinned' || ev.coverage === 'approved-unpinned';
73
+ const policyHome = ev.installKind === 'repo' || ev.installKind === 'local'
74
+ ? 'the project package.json (allowScripts)'
75
+ : `no package.json can carry the approval for a ${ev.installKind} install — approve per command instead`;
76
+ // 1. Loads → nothing to diagnose. The wording is read by other suites; keep it byte-identical.
77
+ if (ev.bindingPresent && !ev.loadError) {
78
+ return { cause: 'ok', level: 'PASS', detail: 'loads and executes against :memory:' };
79
+ }
80
+ // 2. The binary exists but was built for another Node ABI — the ONE case the old wording fit.
81
+ if (ev.loadError && ABI_RE.test(ev.loadError)) {
82
+ return {
83
+ cause: 'abi-mismatch', level: 'FAIL',
84
+ detail: withPs(`${pkg} was built for a different Node ABI (running ${process.version}): ${ev.loadError}`),
85
+ fix: `Rebuild against this Node: ${rebuildCommand(ev)}`,
86
+ };
87
+ }
88
+ if (!ev.bindingPresent && !approved) {
89
+ // 3. npm ≥ 12 skips the script without an approval. That the approval is missing IS observed;
90
+ // an unobserved npm version does not change the remedy, so it is said and the same commands go out.
91
+ if (ev.npmMajor === undefined || ev.npmMajor >= 12) {
92
+ const npmSaid = ev.npmMajor === undefined ? 'npm (npm version not observed)' : `npm ${ev.npmMajor}`;
93
+ const denied = ev.coverage === 'denied' ? ` (allowScripts explicitly denied ${ev.packageName})` : '';
94
+ return {
95
+ cause: 'scripts-blocked', level: 'FAIL',
96
+ detail: withPs(`no binary — ${npmSaid} blocks dependency install scripts unless allowScripts covers ${pkg}${denied}; policy home: ${policyHome}.`),
97
+ fix: `Approve the one script that must run, then rebuild: ${recoveryCommands(ev).join(' && ')}`,
98
+ };
99
+ }
100
+ // 6b. Early npm 11 did not block scripts, so a missing approval proves nothing about why the
101
+ // script left no binary — say so instead of inventing a cause.
102
+ return {
103
+ cause: 'unknown', level: 'FAIL',
104
+ detail: withPs(`no binary — npm ${ev.npmMajor} may or may not have run the install script; rerun with --foreground-scripts to see what happened.`),
105
+ fix: `${rebuildCommand(ev)} — then run doctor again.`,
106
+ };
107
+ }
108
+ // 4. Approved, yet no binary: the script ran and produced nothing. What it could not do is
109
+ // partly observable (toolchain, path); a failed prebuild download is not.
110
+ if (!ev.bindingPresent) {
111
+ const missing = [];
112
+ if (ev.toolchain?.python === false)
113
+ missing.push('Python is not on PATH (node-gyp needs it)');
114
+ if (ev.toolchain?.msvc === false)
115
+ missing.push('Visual Studio C++ Build Tools (MSVC) were not found');
116
+ if (ev.pathHasSpace)
117
+ missing.push('the install path contains a space, which node-gyp is known to mishandle');
118
+ const because = missing.length ? ` Observed obstacles to a source build: ${missing.join('; ')}.` : '';
119
+ return {
120
+ cause: 'build-failed', level: 'FAIL',
121
+ detail: withPs(`the install script for ${pkg} is approved but produced no binary — a prebuilt download failure is not observable here.${because} Rerun with --foreground-scripts to see the script's own output.`),
122
+ fix: `${rebuildCommand(ev)} — install the missing toolchain (or use a Node version with a prebuilt binary) if the output shows a compile step.`,
123
+ };
124
+ }
125
+ // 6a. Present but failing to load for a reason we do not recognise — quote it, do not classify it.
126
+ return {
127
+ cause: 'unknown', level: 'FAIL',
128
+ detail: withPs(`${pkg} is present but failed to load: ${ev.loadError ?? '(no error text)'}`),
129
+ fix: `${rebuildCommand(ev)} — then run doctor again.`,
130
+ };
131
+ }
@@ -0,0 +1,65 @@
1
+ import { type Cycle } from './cycle-detect';
2
+ /**
3
+ * How many cycles one record lists before it starts counting instead.
4
+ *
5
+ * A record must not grow with the tree: a repository with a thousand cycles would otherwise write a
6
+ * thousand-entry line every turn, and the ledger this exists to make readable would be the thing
7
+ * that makes it unreadable. What is dropped is COUNTED, never silently cut.
8
+ */
9
+ export declare const CYCLE_LIST_CAP = 50;
10
+ /**
11
+ * One turn's observation — paths, integers and enums only.
12
+ *
13
+ * ADR-012's redaction rule is what lets this file be git-tracked at all: no prose, no command
14
+ * strings, no file content. `files` are the scanner's project-relative paths, which the impact and
15
+ * density ledgers already carry.
16
+ */
17
+ export interface CycleObservationRecord {
18
+ ts: string;
19
+ mode: 'strict' | 'track' | 'off';
20
+ cycles: Array<{
21
+ key: string;
22
+ files: string[];
23
+ runtime: boolean;
24
+ edges: number;
25
+ }>;
26
+ /** Cycles beyond CYCLE_LIST_CAP: counted, so a truncation is never mistaken for a clean tree. */
27
+ cyclesOmitted: number;
28
+ /** Keys of the runtime cycles nobody allowed — the ratchet's verdict at this moment. */
29
+ violations: string[];
30
+ allowed: number;
31
+ scope: {
32
+ judged: string[];
33
+ unavailable: string[];
34
+ };
35
+ replica?: string;
36
+ }
37
+ /** The cycle evidence the Stop hook computes, in the shape it already holds it. */
38
+ export interface CycleEvidence {
39
+ current: Cycle[];
40
+ allowed: string[];
41
+ mode: 'strict' | 'track' | 'off';
42
+ scope: {
43
+ judged: string[];
44
+ unavailable: string[];
45
+ };
46
+ }
47
+ /**
48
+ * Build the record. PURE — the clock is an argument, so a test can pin it and two callers cannot
49
+ * disagree about what "now" was.
50
+ *
51
+ * A CLEAN run produces a record too. That is the whole design: a false-positive RATE is violations
52
+ * over chances, and a ledger that only speaks when something is wrong records the numerator and
53
+ * throws the denominator away.
54
+ */
55
+ export declare function buildCycleObservation(ev: CycleEvidence, ts: string): CycleObservationRecord;
56
+ /**
57
+ * Append one record. Never throws, and never creates `.ax` where governance was not opted into
58
+ * (A-SPEC-191 §25 — the same refusal the approval queue makes).
59
+ *
60
+ * A failure returns `false` and changes nothing else: this is an OBSERVATION, and an observation
61
+ * that could alter a verdict would be a gate wearing a different name.
62
+ */
63
+ export declare function appendCycleObservation(root: string, rec: CycleObservationRecord): boolean;
64
+ /** Every replica's records, merged. A corrupt line is skipped, never fatal. */
65
+ export declare function readCycleObservations(root: string): CycleObservationRecord[];