@holmes-lab/holmes-kit 0.18.0 → 0.19.2

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 (81) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/README.md +3 -0
  3. package/dist/.build-id +1 -1
  4. package/dist/holmes/cli/agents.d.ts +22 -0
  5. package/dist/holmes/cli/agents.js +76 -1
  6. package/dist/holmes/cli/approve.js +6 -1
  7. package/dist/holmes/cli/doctor.d.ts +36 -1
  8. package/dist/holmes/cli/doctor.js +182 -35
  9. package/dist/holmes/cli/index.js +7 -1
  10. package/dist/holmes/cli/init.js +12 -0
  11. package/dist/holmes/cli/native-deps.d.ts +65 -0
  12. package/dist/holmes/cli/native-deps.js +131 -0
  13. package/dist/holmes/cli/release-docs.d.ts +27 -0
  14. package/dist/holmes/cli/release-docs.js +68 -0
  15. package/dist/holmes/cpg/arch-observe.d.ts +15 -0
  16. package/dist/holmes/cpg/arch-observe.js +19 -0
  17. package/dist/holmes/cpg/cpg-scanner.d.ts +10 -36
  18. package/dist/holmes/cpg/cpg-scanner.js +27 -3
  19. package/dist/holmes/cpg/cycle-detect.d.ts +87 -0
  20. package/dist/holmes/cpg/cycle-detect.js +251 -0
  21. package/dist/holmes/cpg/cycle-observation.d.ts +65 -0
  22. package/dist/holmes/cpg/cycle-observation.js +146 -0
  23. package/dist/holmes/cpg/scan-cache.d.ts +1 -1
  24. package/dist/holmes/cpg/scanned-file.d.ts +36 -0
  25. package/dist/holmes/cpg/scanned-file.js +2 -0
  26. package/dist/holmes/governance/approval-queue.d.ts +23 -4
  27. package/dist/holmes/governance/approval-queue.js +44 -6
  28. package/dist/holmes/governance/constitution.d.ts +20 -0
  29. package/dist/holmes/governance/constitution.js +17 -0
  30. package/dist/holmes/governance/ledger-store.d.ts +9 -0
  31. package/dist/holmes/governance/ledger-store.js +47 -0
  32. package/dist/holmes/governance/provenance-chain.d.ts +16 -1
  33. package/dist/holmes/governance/provenance-chain.js +5 -3
  34. package/dist/holmes/hooks/pre-tool-use.js +3 -1
  35. package/dist/holmes/hooks/stop.d.ts +29 -0
  36. package/dist/holmes/hooks/stop.js +119 -3
  37. package/dist/holmes/mcp/defuse-bound.d.ts +1 -0
  38. package/dist/holmes/mcp/defuse-bound.js +8 -0
  39. package/dist/holmes/mcp/handlers.d.ts +7 -0
  40. package/dist/holmes/mcp/handlers.js +132 -6
  41. package/dist/holmes/mcp/history-admission.d.ts +15 -0
  42. package/dist/holmes/mcp/history-admission.js +37 -0
  43. package/dist/holmes/mcp/maintenance-analyze.d.ts +45 -0
  44. package/dist/holmes/mcp/maintenance-analyze.js +117 -9
  45. package/dist/holmes/mcp/maintenance-evidence.d.ts +41 -0
  46. package/dist/holmes/mcp/maintenance-evidence.js +71 -4
  47. package/dist/holmes/project/install-scripts-policy.d.ts +76 -0
  48. package/dist/holmes/project/install-scripts-policy.js +131 -0
  49. package/dist/holmes/project/npx-bin.d.ts +6 -0
  50. package/dist/holmes/project/npx-bin.js +10 -0
  51. package/dist/holmes/review/evaluation-metrics.d.ts +6 -0
  52. package/dist/holmes/review/evaluation-metrics.js +18 -1
  53. package/dist/holmes/review/failed-test-names.d.ts +19 -0
  54. package/dist/holmes/review/failed-test-names.js +43 -0
  55. package/dist/holmes/review/paired-power.d.ts +14 -0
  56. package/dist/holmes/review/paired-power.js +57 -0
  57. package/dist/holmes/review/replay-corpus.d.ts +11 -0
  58. package/dist/holmes/review/replay-corpus.js +34 -0
  59. package/dist/holmes/review/run-replay.d.ts +23 -0
  60. package/dist/holmes/review/run-replay.js +90 -4
  61. package/dist/holmes/review/symbol-truth.d.ts +14 -0
  62. package/dist/holmes/review/symbol-truth.js +23 -0
  63. package/dist/holmes/review/test-runner.d.ts +27 -0
  64. package/dist/holmes/review/test-runner.js +59 -3
  65. package/dist/holmes/rtm/defuse-symbols.d.ts +17 -0
  66. package/dist/holmes/rtm/defuse-symbols.js +91 -0
  67. package/dist/holmes/rtm/incremental.js +5 -0
  68. package/dist/holmes/rtm/rtm-builder.d.ts +8 -0
  69. package/dist/holmes/rtm/rtm-builder.js +32 -5
  70. package/dist/holmes/rtm/rtm-graph.d.ts +11 -0
  71. package/dist/holmes/rtm/rtm-graph.js +13 -0
  72. package/dist/holmes/spec/legacy-fields.d.ts +2 -0
  73. package/dist/holmes/spec/legacy-fields.js +9 -0
  74. package/dist/holmes/spec/legacy-format.d.ts +1 -1
  75. package/dist/holmes/spec/legacy-format.js +4 -1
  76. package/dist/holmes/spec/spec-parser.js +5 -3
  77. package/docs/install-guide.md +54 -5
  78. package/package.json +4 -1
  79. package/playbooks/author-slice/PLAYBOOK.md +14 -0
  80. package/playbooks/publish/PLAYBOOK.md +32 -0
  81. package/playbooks/tdd-slice/PLAYBOOK.md +14 -0
@@ -37,7 +37,10 @@ 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.gatherNativeEvidence = gatherNativeEvidence;
40
42
  exports.probeEnv = probeEnv;
43
+ exports.resolveWiringPath = resolveWiringPath;
41
44
  exports.runDoctor = runDoctor;
42
45
  exports.wiringSpawnCheck = wiringSpawnCheck;
43
46
  exports.pushGateCheck = pushGateCheck;
@@ -48,8 +51,11 @@ exports.detectTreeKeyTemporary = detectTreeKeyTemporary;
48
51
  // @implements A-SPEC-442
49
52
  // @implements A-SPEC-207
50
53
  // @implements A-SPEC-100.2
54
+ // @implements A-SPEC-580
51
55
  const fs = __importStar(require("node:fs"));
52
56
  const npx_bin_1 = require("../project/npx-bin");
57
+ const install_scripts_policy_1 = require("../project/install-scripts-policy");
58
+ const native_deps_1 = require("./native-deps");
53
59
  const tier_1 = require("../semantic/tier");
54
60
  const path = __importStar(require("node:path"));
55
61
  const role_policy_1 = require("../governance/role-policy");
@@ -175,6 +181,114 @@ function prefixVerdict(input) {
175
181
  fix,
176
182
  };
177
183
  }
184
+ /**
185
+ * @implements A-SPEC-580
186
+ * Parse a trivial source with every grammar, in a child node whose module resolution starts at
187
+ * `packageRoot`. `parsed` counts typescript and tsx separately (8 for the 7 grammar packages).
188
+ */
189
+ function grammarProbe(packageRoot) {
190
+ const script = `
191
+ const out = { parsed: 0, failed: [] };
192
+ let Parser;
193
+ try { Parser = require('tree-sitter'); }
194
+ catch (e) { process.stdout.write(JSON.stringify({ ...out, error: String(e && e.message || e).split('\\n')[0] })); process.exit(0); }
195
+ const loaders = [
196
+ ['typescript', () => require('tree-sitter-typescript').typescript],
197
+ ['tsx', () => require('tree-sitter-typescript').tsx],
198
+ ];
199
+ for (const g of ${JSON.stringify(GRAMMARS.filter((g) => g !== 'tree-sitter-typescript'))}) {
200
+ loaders.push([g.replace('tree-sitter-', ''), () => require(g)]);
201
+ }
202
+ for (const [name, load] of loaders) {
203
+ try {
204
+ const p = new Parser();
205
+ p.setLanguage(load());
206
+ const node = p.parse('x').rootNode;
207
+ const root = node && node.type;
208
+ if (!root) throw new Error('parse produced no root node');
209
+ out.parsed++;
210
+ } catch (e) { out.failed.push(name + ': ' + String(e && e.message || e).split('\\n')[0]); }
211
+ }
212
+ process.stdout.write(JSON.stringify(out));
213
+ `;
214
+ try {
215
+ 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') } });
216
+ if (r.status !== 0 || !r.stdout)
217
+ return { parsed: 0, failed: [], error: (r.stderr || `exit ${r.status}`).split('\n')[0] };
218
+ return JSON.parse(r.stdout);
219
+ }
220
+ catch (e) {
221
+ return { parsed: 0, failed: [], error: e.message.split('\n')[0] };
222
+ }
223
+ }
224
+ /** A short spawn whose failure means "not observed" — never a verdict. */
225
+ function observe(file, args) {
226
+ try {
227
+ return (0, node_child_process_1.execFileSync)(file, args, { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
228
+ }
229
+ catch {
230
+ return undefined;
231
+ }
232
+ }
233
+ /**
234
+ * @implements A-SPEC-580
235
+ * Everything observable about the better-sqlite3 install, gathered for `nativeVerdict`. Each probe
236
+ * is independent and optional: a spawn that fails leaves its field `undefined` (not observed), and
237
+ * the verdict says so rather than treating silence as evidence.
238
+ *
239
+ * Which package.json holds the approval policy depends on the layout: this checkout's own for a
240
+ * repository run, the PROJECT's (three levels up from node_modules/@holmes-lab/holmes-kit) for a
241
+ * local dependency. A global or npx install has no project package.json that npm consults, so its
242
+ * coverage is `uncovered` by construction and the remedy is the per-command `--allow-scripts`.
243
+ */
244
+ function gatherNativeEvidence(packageRoot) {
245
+ const name = 'better-sqlite3';
246
+ const pkgDir = path.join(packageRoot, 'node_modules', name);
247
+ const bindingPresent = fs.existsSync(path.join(pkgDir, 'build', 'Release', 'better_sqlite3.node'));
248
+ let packageVersion = '(unknown)';
249
+ try {
250
+ packageVersion = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8')).version ?? packageVersion;
251
+ }
252
+ catch { /* not installed */ }
253
+ let loadError;
254
+ try {
255
+ const Database = require(name);
256
+ const db = new Database(':memory:');
257
+ db.prepare('SELECT 1 AS ok').get();
258
+ db.close();
259
+ }
260
+ catch (e) {
261
+ loadError = e.message.split('\n')[0];
262
+ }
263
+ const npmCli = npmCliEntry();
264
+ const npmVersion = npmCli !== null ? observe(process.execPath, [npmCli, '--version']) : observe('npm', ['--version']);
265
+ const npmMajor = npmVersion !== undefined && /^\d+/.test(npmVersion) ? Number(npmVersion.match(/^\d+/)[0]) : undefined;
266
+ const prefix = npmCli !== null ? observe(process.execPath, [npmCli, 'config', 'get', 'prefix']) : observe('npm', ['config', 'get', 'prefix']);
267
+ const kind = (0, native_deps_1.installKind)(packageRoot, prefix ? globalInstallDir(prefix, process.platform) : undefined);
268
+ let coverage = 'uncovered';
269
+ const policyJson = kind === 'repo' ? path.join(packageRoot, 'package.json')
270
+ : kind === 'local' ? path.join(packageRoot, '..', '..', '..', 'package.json')
271
+ : undefined;
272
+ if (policyJson !== undefined) {
273
+ try {
274
+ const allow = JSON.parse(fs.readFileSync(policyJson, 'utf8')).allowScripts;
275
+ coverage = (0, install_scripts_policy_1.allowScriptsCoverage)(allow, name, packageVersion);
276
+ }
277
+ catch { /* unreadable → uncovered, which is what npm would see too */ }
278
+ }
279
+ const ev = {
280
+ platform: process.platform, packageName: name, packageVersion, bindingPresent, loadError, npmMajor,
281
+ coverage, installKind: kind, pathHasSpace: packageRoot.includes(' '),
282
+ };
283
+ if (process.platform === 'win32') {
284
+ const python = observe('where.exe', ['python']) !== undefined || observe('where.exe', ['py']) !== undefined;
285
+ const vswhere = path.join(process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)', 'Microsoft Visual Studio', 'Installer', 'vswhere.exe');
286
+ const msvc = fs.existsSync(vswhere) || observe('where.exe', ['cl']) !== undefined || process.env.VCINSTALLDIR !== undefined;
287
+ ev.toolchain = { python, msvc };
288
+ ev.psPolicy = observe('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', 'Get-ExecutionPolicy']);
289
+ }
290
+ return ev;
291
+ }
178
292
  /** The parent environment minus the variables that legitimately change a gate decision. Pure. */
179
293
  function probeEnv(parent) {
180
294
  const out = { ...parent };
@@ -208,6 +322,23 @@ function wiredSettingsPath(target) {
208
322
  return (0, init_1.settingsPathOf)(target, 'project');
209
323
  return (0, init_1.settingsPathOf)(target, fs.existsSync((0, init_1.settingsPathOf)(target, 'local')) ? 'local' : 'project');
210
324
  }
325
+ // @implements A-SPEC-581.1
326
+ /**
327
+ * Where a wiring's executable actually is, judged from the PROJECT rather than from the caller.
328
+ *
329
+ * Measured 2026-09-10: the same `.codex/config.toml` under the same `--target` read PASS from the
330
+ * repository root and FAIL from `/tmp`, because a relative arg is resolved by Node against
331
+ * `process.cwd()`. A relative arg is the CONVENTION here — `.mcp.json` has always carried
332
+ * `bin/holmes-mcp.js` — so the verdict was decided by where the person diagnosing stood.
333
+ *
334
+ * An absolute arg is returned untouched: agy's wiring is absolute and must not move. An empty arg
335
+ * stays empty — resolving it would conjure the target directory itself into a "file that exists".
336
+ */
337
+ function resolveWiringPath(target, arg) {
338
+ if (arg === '')
339
+ return '';
340
+ return path.isAbsolute(arg) ? arg : path.resolve(target, arg);
341
+ }
211
342
  async function runDoctor(packageRoot, target, opts, extraChecks) {
212
343
  const checks = [];
213
344
  const add = (name, level, detail, fix) => checks.push({ name, level, detail, fix });
@@ -227,35 +358,34 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
227
358
  }
228
359
  catch { /* not resolvable from here */ }
229
360
  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;
361
+ // 3. tree-sitter + EACH grammar — PARSED, not merely resolved. `require.resolve` only proves the
362
+ // package directory exists; a grammar whose prebuilt binding is missing for this platform
363
+ // resolves fine and dies at `setLanguage`. language-parser.ts loads them at module scope, so
364
+ // ONE dead grammar kills MCP server startup — name the dead ones individually.
365
+ // @implements A-SPEC-580
366
+ // Measured in a FRESH process: tree-sitter's native addon binds to the first module registry
367
+ // that loads it, so a second `require` inside the same process (jest workers; any host that
368
+ // already loaded it) hands back a Parser whose `parse()` yields no rootNode — a false FAIL that
369
+ // says nothing about the install. The probe therefore runs in a child node, which is also
370
+ // exactly what the MCP server does at startup.
371
+ {
372
+ const g = grammarProbe(packageRoot);
373
+ if (g.error !== undefined) {
374
+ 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.');
375
+ }
376
+ else if (g.failed.length === 0) {
377
+ add('tree-sitter grammars', 'PASS', `tree-sitter + ${g.parsed} grammars parse`);
378
+ }
379
+ else {
380
+ 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
381
  }
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
382
  }
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.');
383
+ // 4. better-sqlite3 — the one dependency whose install script must RUN (prebuild-install ||
384
+ // node-gyp rebuild). Its failure has at least five causes with five different remedies, so the
385
+ // verdict is computed from evidence (A-SPEC-580), never from the load error's wording alone.
386
+ {
387
+ const v = (0, native_deps_1.nativeVerdict)(gatherNativeEvidence(packageRoot));
388
+ add('better-sqlite3', v.level, v.detail, v.fix);
259
389
  }
260
390
  // 5. Hooks actually gate — prove it with a live allow AND a live deny (exit 2).
261
391
  if (built) {
@@ -809,11 +939,18 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
809
939
  // the whole check for the common wiring, so "the file is there" was reported as if the
810
940
  // server had been proven to start. `wiringHandshakeChecks` below now proves that, for
811
941
  // every wired harness; this stays as the cheap, fast FAIL for a path that is simply gone.
942
+ // @implements A-SPEC-581.1 — resolved against the TARGET; the message keeps the string
943
+ // as written, because what the operator must fix is the wiring file, not our arithmetic.
812
944
  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.');
945
+ const binAt = resolveWiringPath(target, bin);
946
+ 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
947
  }
815
948
  else {
816
- checks.push(await wiringSpawnCheck(server.command, server.args));
949
+ // @implements A-SPEC-581.1 — the SIBLING of the handshake spawn, and it must move with
950
+ // it. Measured on a consumer install: the same npx wiring read PASS from the handshake
951
+ // (launched in the target) and FAIL here (launched in doctor's cwd) — two checks, one
952
+ // wiring, opposite verdicts.
953
+ checks.push(await wiringSpawnCheck(server.command, server.args, undefined, undefined, target));
817
954
  }
818
955
  }
819
956
  catch {
@@ -883,17 +1020,19 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
883
1020
  // the "resolves" claim stronger than the check (adversarial round, Finding 2). Verify the
884
1021
  // basename too, to close that gap.
885
1022
  const bin = entry.args[0] ?? '';
1023
+ // @implements A-SPEC-581.1 — against the TARGET, not the cwd.
1024
+ const binAt = resolveWiringPath(target, bin);
886
1025
  // Resolve the link before judging the name (round-2 F4): a symlink literally named
887
1026
  // `holmes-mcp.js` pointing at /etc/hosts must not read as "resolves to this install".
888
1027
  const realBin = (() => { try {
889
- return fs.realpathSync(bin);
1028
+ return fs.realpathSync(binAt);
890
1029
  }
891
1030
  catch {
892
1031
  return '';
893
1032
  } })();
894
1033
  const resolvesHere = realBin !== '' && path.basename(realBin) === 'holmes-mcp.js';
895
1034
  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.');
1035
+ : (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
1036
  }
898
1037
  else {
899
1038
  const pin = (0, mcp_version_1.mcpLaunchVersion)({ command: entry.command, args: entry.args });
@@ -1095,7 +1234,12 @@ function cleanupOnSignal(dir) {
1095
1234
  // harness will actually run reaches a server — the gap the dead npx form lived in.
1096
1235
  // @implements A-SPEC-499.1 — exported for the prescription tests; `platform` is injectable so the
1097
1236
  // win32 branch is testable off-Windows.
1098
- function wiringSpawnCheck(command, args, timeoutMs = 30000, platform = process.platform) {
1237
+ function wiringSpawnCheck(command, args, timeoutMs = 30000, platform = process.platform,
1238
+ // @implements A-SPEC-581.1 — the directory the wiring is launched FROM. A harness starts the
1239
+ // server in the project, so a relative arg (`bin/holmes-mcp.js`, the .mcp.json convention)
1240
+ // resolves there. Left undefined the child inherits doctor's cwd, which made the handshake
1241
+ // verdict depend on where doctor ran — measured 2026-09-10.
1242
+ cwd) {
1099
1243
  // The DISPLAYED command stays the original wiring string even when the win32 adapter rewraps the
1100
1244
  // execution — the user compares this against their wiring file, not against cmd.exe plumbing.
1101
1245
  const quoted = `${command} ${args.join(' ')}`;
@@ -1124,7 +1268,7 @@ function wiringSpawnCheck(command, args, timeoutMs = 30000, platform = process.p
1124
1268
  };
1125
1269
  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
1270
  try {
1127
- child = (0, node_child_process_1.spawn)(spec.command, spec.args, { stdio: ['pipe', 'pipe', 'pipe'] });
1271
+ child = (0, node_child_process_1.spawn)(spec.command, spec.args, { stdio: ['pipe', 'pipe', 'pipe'], ...(cwd === undefined ? {} : { cwd }) });
1128
1272
  }
1129
1273
  catch (e) {
1130
1274
  finish('FAIL', `wiring could not be spawned: \`${quoted}\` — ${e.message}`, spawnFailFix(e));
@@ -1385,13 +1529,16 @@ async function wiringHandshakeChecks(target) {
1385
1529
  }
1386
1530
  if (entry.command === 'node') {
1387
1531
  const bin = entry.args[0] ?? '';
1388
- if (!fs.existsSync(bin)) {
1532
+ // @implements A-SPEC-581.1 — the handshake covers ALL THREE harnesses, so the cwd-dependent
1533
+ // read here was the widest instance of the same defect.
1534
+ if (!fs.existsSync(resolveWiringPath(target, bin))) {
1389
1535
  out.push({ name, level: 'FAIL', detail: `The wiring points at a missing file: ${bin}`,
1390
1536
  fix: `Run holmes-kit init --target <dir> --agent ${wiring.label} --force to refresh the path.` });
1391
1537
  continue;
1392
1538
  }
1393
1539
  }
1394
- const check = await wiringSpawnCheck(entry.command, entry.args);
1540
+ // @implements A-SPEC-581.1 launched FROM the target, exactly as the harness would.
1541
+ const check = await wiringSpawnCheck(entry.command, entry.args, undefined, undefined, target);
1395
1542
  out.push({ ...check, name });
1396
1543
  }
1397
1544
  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,27 @@
1
+ /**
2
+ * The GitHub target this project publishes to, read from `package.json`.
3
+ *
4
+ * Derived, never hardcoded: this playbook is installed INTO consuming projects, so a literal
5
+ * `owner/repo` here would make someone else's release overwrite ours. When the field is missing,
6
+ * malformed, or points somewhere other than GitHub the answer is `null` — the caller then SKIPS and
7
+ * says so, which is the honest outcome. A fallback would be a guess wearing a default's clothes.
8
+ */
9
+ export declare function repoTargetFrom(pkg: unknown): {
10
+ owner: string;
11
+ repo: string;
12
+ } | null;
13
+ export interface ProfileDriftInput {
14
+ profileText: string;
15
+ /** The version live on the registry, or null when the lookup failed. */
16
+ npmVersion: string | null;
17
+ profilePushedAt: string | null;
18
+ lastReleaseAt: string | null;
19
+ }
20
+ /**
21
+ * Sentences a person should read before the next release — never a rewrite, and never a grade.
22
+ *
23
+ * No severity, no score: attaching one is how an advisory becomes a gate, and the wording of a
24
+ * positioning document is not something a gate should decide. An empty list is a real answer, which
25
+ * is why the "current document" case is tested — a check that always speaks is not a check.
26
+ */
27
+ export declare function profileDriftFindings(input: ProfileDriftInput): string[];
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ // @implements A-SPEC-575.1
3
+ // Publishing reaches surfaces the release procedure never knew about. This repository has NO GitHub
4
+ // remote — origin is a local gitea — so three releases went out while the README that npm's
5
+ // `homepage` points at stayed frozen at 2026-08-31, and the org profile still said the project was
6
+ // a walking skeleton under construction.
7
+ //
8
+ // Two surfaces, two treatments. The repo README is a COPY of the local one (section-for-section
9
+ // identical, measured), so it is synchronised. The profile README is an independent positioning
10
+ // document, so it is only INSPECTED — rewriting someone's positioning is not a release step.
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.repoTargetFrom = repoTargetFrom;
13
+ exports.profileDriftFindings = profileDriftFindings;
14
+ /**
15
+ * The GitHub target this project publishes to, read from `package.json`.
16
+ *
17
+ * Derived, never hardcoded: this playbook is installed INTO consuming projects, so a literal
18
+ * `owner/repo` here would make someone else's release overwrite ours. When the field is missing,
19
+ * malformed, or points somewhere other than GitHub the answer is `null` — the caller then SKIPS and
20
+ * says so, which is the honest outcome. A fallback would be a guess wearing a default's clothes.
21
+ */
22
+ function repoTargetFrom(pkg) {
23
+ const repository = pkg?.repository;
24
+ const url = typeof repository === 'string' ? repository
25
+ : typeof repository?.url === 'string'
26
+ ? repository.url
27
+ : null;
28
+ if (url === null)
29
+ return null;
30
+ // The three shapes npm accepts, plus the `git+` prefix and the optional `.git` suffix.
31
+ const m = /^(?:git\+)?(?:https?:\/\/github\.com\/|git@github\.com:)([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(url.trim());
32
+ return m === null ? null : { owner: m[1], repo: m[2] };
33
+ }
34
+ /** Phrases that claim the project has not shipped yet. */
35
+ const PRE_RELEASE_CLAIMS = ['early stage', 'under construction', 'walking-skeleton', 'walking skeleton', 'bootstrap'];
36
+ /**
37
+ * Sentences a person should read before the next release — never a rewrite, and never a grade.
38
+ *
39
+ * No severity, no score: attaching one is how an advisory becomes a gate, and the wording of a
40
+ * positioning document is not something a gate should decide. An empty list is a real answer, which
41
+ * is why the "current document" case is tested — a check that always speaks is not a check.
42
+ */
43
+ function profileDriftFindings(input) {
44
+ const out = [];
45
+ const text = input.profileText.toLowerCase();
46
+ // Only run the maturity check when the registry actually answered. Not knowing must not turn into
47
+ // a claim about what the document gets wrong.
48
+ if (input.npmVersion !== null && isShipped(input.npmVersion)) {
49
+ const claimed = PRE_RELEASE_CLAIMS.filter((p) => text.includes(p));
50
+ if (claimed.length > 0) {
51
+ out.push(`profile README still claims pre-release status (${claimed.join(', ')}) `
52
+ + `while npm has ${input.npmVersion} — the claim is no longer true.`);
53
+ }
54
+ }
55
+ if (input.profilePushedAt !== null && input.lastReleaseAt !== null
56
+ && Date.parse(input.profilePushedAt) < Date.parse(input.lastReleaseAt)) {
57
+ out.push(`profile README (${input.profilePushedAt}) is older than the last release `
58
+ + `(${input.lastReleaseAt}) — it has not been looked at across at least one shipment.`);
59
+ }
60
+ return out;
61
+ }
62
+ /** A version that means "this shipped" — anything at or past 0.1.0. */
63
+ function isShipped(version) {
64
+ const m = /^(\d+)\.(\d+)\./.exec(version);
65
+ if (m === null)
66
+ return false;
67
+ return Number(m[1]) > 0 || Number(m[2]) > 0;
68
+ }