@holmes-lab/holmes-kit 0.19.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 (35) hide show
  1. package/CHANGELOG.md +80 -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 +36 -1
  7. package/dist/holmes/cli/doctor.js +182 -35
  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
package/CHANGELOG.md CHANGED
@@ -5,6 +5,86 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
  <!-- @implements A-SPEC-209 -->
8
+ ## [0.19.2] - 2026-09-10
9
+
10
+ Wiring diagnosis no longer depends on where you run it. No API change.
11
+
12
+ ### Fixed
13
+ - **`doctor` gave opposite verdicts for the same wiring depending on the directory it was run
14
+ from** (REQ-581). A relative executable path in a wiring file is resolved by Node against
15
+ `process.cwd()`, and a relative path is the CONVENTION here — `.mcp.json` has always carried
16
+ `bin/holmes-mcp.js`. Measured: the same project under the same `--target` read
17
+ `PASS codex wiring` from the repository root and `FAIL — points at a missing file` from `/tmp`,
18
+ with the fix line telling the operator to re-init a wiring that was never wrong. All four
19
+ affected checks (`mcp wiring spawn`, `codex wiring`, and the `wiringHandshakeChecks` that cover
20
+ all three harnesses) now resolve against the TARGET project, and the handshake launches the
21
+ server FROM that project — the way a harness actually starts it. Absolute wirings (antigravity's)
22
+ are returned untouched, and a path that is genuinely missing still fails. The message keeps the
23
+ string exactly as written in the wiring file: what an operator must fix is that string, not our
24
+ arithmetic.
25
+ - **The same wiring could read PASS from one check and FAIL from its sibling.** Measured on a
26
+ third-party tarball install: `claude wiring handshake` passed while `mcp wiring spawn` failed on
27
+ the identical `npx` command, because only one of the two spawn paths had been moved. Both now
28
+ launch from the target.
29
+
30
+ ### Verified on a third-party install (macOS, npm 11.17.0, Node 26.5.1)
31
+ - Tarball installed into a clean project: `better-sqlite3` binding present, `:memory:` query ok.
32
+ - With install scripts blocked (`--ignore-scripts`, the npm 12 condition): the CLI still starts,
33
+ `init` still wires, the MCP server still answers — and `doctor` names the cause and prints a
34
+ recovery command that works verbatim (`npm rebuild better-sqlite3 --foreground-scripts`).
35
+ - Diagnosed from an unrelated working directory with the consumer's own CLI: claude / codex /
36
+ antigravity handshakes 3/3 PASS, `target wiring — 3 holmes hook(s) resolve to this install`.
37
+
38
+ ### Corrected from 0.19.1's notes
39
+ - 0.19.1 recorded that `codexMcpBlock` "writes Windows backslash paths into `config.toml`
40
+ unescaped". Re-measured: it does not. `tomlStr` escapes `\` correctly and the write→read
41
+ round-trip returns `C:\Users\name\proj\bin\holmes-mcp.js` byte-identical (pure functions, so
42
+ the result is platform-independent). The `doctor-wiring-handshake` failure seen on a Windows
43
+ checkout is therefore unexplained by escaping — and is a candidate instance of the cwd defect
44
+ fixed above, which is a HYPOTHESIS for the next Windows run, not a conclusion.
45
+
46
+ ## [0.19.1] - 2026-09-10
47
+
48
+ Windows install hardening, measured on Windows 11 / npm 12.0.1 / Node 24.19.0. No API change.
49
+
50
+ ### Fixed
51
+ - **npm 12 skipped `better-sqlite3`'s install script and nothing said so** (REQ-579). npm ≥ 12
52
+ (and npm 11.19+) blocks dependency install scripts unless the root `package.json` approves
53
+ them; `npm ci` then succeeds with no `better_sqlite3.node`, and every SQLite-backed feature
54
+ fails on first use. `package.json` now declares `allowScripts: { "better-sqlite3@12.11.1": true }`
55
+ — the ONE dependency that needs its script, pinned to the lockfile version so a bump forces a
56
+ fresh review (a test goes red until the pin is updated). The 8 tree-sitter packages are
57
+ deliberately not approved: measured, they load from their shipped `prebuilds/` with the script
58
+ blocked. Older npm 11 ignores the field.
59
+ - **doctor misdiagnosed every missing SQLite binary as an ABI mismatch** (REQ-580). The
60
+ `better-sqlite3` check now judges from evidence — binary present or not, the load error's
61
+ wording, the npm major, the governing `allowScripts`, the install layout (repository / project
62
+ dependency / global / npx), and on Windows the toolchain and a spaced path — and names the
63
+ cause: `scripts-blocked`, `abi-mismatch`, `build-failed`, or `unknown` with the raw error. A
64
+ prebuilt-download failure is stated as unobservable, never asserted. The fix line is the exact
65
+ command for that layout; for a global install it targets the dependency
66
+ (`npm rebuild -g better-sqlite3 --foreground-scripts --allow-scripts=better-sqlite3`), because
67
+ `rebuild -g @holmes-lab/holmes-kit` dies `EEXIST` re-linking the bin under npm 12 (measured).
68
+ - **doctor's grammar check only resolved packages.** It now parses a source with all 8 grammars
69
+ (typescript, tsx, python, c-sharp, java, go, rust, cpp) in a fresh child process — in-process
70
+ parsing is unreliable after another module registry has loaded tree-sitter's native addon.
71
+ - **Windows recovery commands are runnable.** npm commands are emitted as `npm.cmd` on win32
72
+ (`npmBin`, the twin of `npxBin`), and when a `Restricted`/`AllSigned` PowerShell execution policy
73
+ is observed the verdict says the `.ps1` shims are blocked and the `.cmd` ones are not.
74
+
75
+ ### Docs
76
+ - `docs/install-guide.md`: npm 12 section, per-layout recovery table, PowerShell policy note,
77
+ build-failed evidence. `docs/windows-test-plan-2026-09.md`: items E/F/G updated with the
78
+ 2026-09-09/10 measurements and the known Windows-only baseline failures.
79
+
80
+ ### Known, not fixed (recorded honestly)
81
+ - macOS/Linux were not exercised on hardware in this release; their behaviour is covered by
82
+ platform-injected unit tests and the three-harness parity suite only.
83
+ - `codexMcpBlock` writes Windows backslash paths into `config.toml` unescaped
84
+ (`doctor-wiring-handshake` fails on a Windows checkout); doctor's `global prefix` check reports a
85
+ protected `C:\Program Files\nodejs` as writable. Both are pre-existing and tracked as follow-ups.
86
+ **(The escaping half of this was re-measured in 0.19.2 and does not reproduce — see that entry.)**
87
+
8
88
  ## [0.19.0] - 2026-09-08
9
89
 
10
90
  ### Added
package/dist/.build-id CHANGED
@@ -1 +1 @@
1
- 0043954-mtspy09e
1
+ e63e589-mtuemhhb
@@ -62,6 +62,28 @@ export declare function antigravityHookCommand(scriptPath: string): string;
62
62
  /** win32-only, best-effort: a spaced path becomes its 8.3 short name (no spaces); anything else — or
63
63
  * any lookup failure — returns the input unchanged. */
64
64
  export declare function shortPathIfSpaced(p: string): string;
65
+ export declare const MANAGED_BEGIN = "<!-- holmes-kit:managed:begin -->";
66
+ export declare const MANAGED_END = "<!-- holmes-kit:managed:end -->";
67
+ /**
68
+ * @implements A-SPEC-576.2
69
+ * The regeneration that keeps what it did not write.
70
+ *
71
+ * Measured cause: `init` rewrote AGENTS.md from the template, deleting the ADR-018 parity rule a
72
+ * commit had added the day before — and said nothing, so the loss was found weeks later by grep.
73
+ * Generated text now lives inside a MANAGED BLOCK; everything outside it belongs to the person
74
+ * whose repository this is.
75
+ *
76
+ * PURE, and it takes the whole file rather than a path: the caller reads `before` during init's
77
+ * COMPUTE phase, so a dry-run predicts the same bytes the real run writes (A-SPEC-190 §9).
78
+ *
79
+ * `preserved` is the report, not a courtesy. A run that moved something and returned an empty list
80
+ * would be the same silent loss in a new costume.
81
+ */
82
+ export declare function mergeAgentsMd(existing: string | null, generated: string): {
83
+ content: string;
84
+ preserved: string[];
85
+ note: string | null;
86
+ };
65
87
  /**
66
88
  * 이 하네스에 써야 할 파일들. **쓰지는 않는다** — 무엇을 쓸지만 말한다.
67
89
  *
@@ -33,9 +33,10 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.CODEX_MARKETPLACE = exports.CODEX_PLUGIN_DIR = exports.HARNESS_ENFORCES = exports.AGENTS = void 0;
36
+ exports.MANAGED_END = exports.MANAGED_BEGIN = exports.CODEX_MARKETPLACE = exports.CODEX_PLUGIN_DIR = exports.HARNESS_ENFORCES = exports.AGENTS = void 0;
37
37
  exports.antigravityHookCommand = antigravityHookCommand;
38
38
  exports.shortPathIfSpaced = shortPathIfSpaced;
39
+ exports.mergeAgentsMd = mergeAgentsMd;
39
40
  exports.agentFiles = agentFiles;
40
41
  exports.agentLinks = agentLinks;
41
42
  // @implements A-SPEC-442
@@ -197,6 +198,80 @@ ${enforced
197
198
  ? `Gates are **enforced**. Tool calls that write un-anchored code or target unapproved specifications will be denied. Unresolved critical findings block completion.`
198
199
  : `This harness does not have measured **hook enforcement points**. Holmes-Kit provides tools and guidance here without active gate blocking.`}
199
200
  `;
201
+ exports.MANAGED_BEGIN = '<!-- holmes-kit:managed:begin -->';
202
+ exports.MANAGED_END = '<!-- holmes-kit:managed:end -->';
203
+ const PRESERVED_NOTE = '<!-- kept from this file by holmes-kit init — holmes-kit does not write below this line -->';
204
+ /**
205
+ * @implements A-SPEC-576.2
206
+ * The regeneration that keeps what it did not write.
207
+ *
208
+ * Measured cause: `init` rewrote AGENTS.md from the template, deleting the ADR-018 parity rule a
209
+ * commit had added the day before — and said nothing, so the loss was found weeks later by grep.
210
+ * Generated text now lives inside a MANAGED BLOCK; everything outside it belongs to the person
211
+ * whose repository this is.
212
+ *
213
+ * PURE, and it takes the whole file rather than a path: the caller reads `before` during init's
214
+ * COMPUTE phase, so a dry-run predicts the same bytes the real run writes (A-SPEC-190 §9).
215
+ *
216
+ * `preserved` is the report, not a courtesy. A run that moved something and returned an empty list
217
+ * would be the same silent loss in a new costume.
218
+ */
219
+ function mergeAgentsMd(existing, generated) {
220
+ const block = exports.MANAGED_BEGIN + '\n' + (generated.endsWith('\n') ? generated : generated + '\n') + exports.MANAGED_END + '\n';
221
+ if (existing === null || existing === '')
222
+ return { content: block, preserved: [], note: null };
223
+ const b = existing.indexOf(exports.MANAGED_BEGIN);
224
+ const e = existing.indexOf(exports.MANAGED_END);
225
+ if (b !== -1 && e > b) {
226
+ // The ordinary case once a file has been through here: swap the block, and do not touch a
227
+ // single byte on either side of it.
228
+ const body = generated.endsWith('\n') ? generated : generated + '\n';
229
+ return {
230
+ content: existing.slice(0, b) + exports.MANAGED_BEGIN + '\n' + body + exports.MANAGED_END + existing.slice(e + exports.MANAGED_END.length),
231
+ preserved: [],
232
+ note: null,
233
+ };
234
+ }
235
+ // LEGACY: a file written before the markers existed — every AGENTS.md in the field today. Its
236
+ // lines are compared to the generated ones, and whatever the generator never wrote is carried
237
+ // below the block. Line terminators are kept as read, so a CRLF checkout stays CRLF.
238
+ const known = new Set(generated.split(/\r?\n/).map((l) => l.trim()).filter((l) => l !== ''));
239
+ const lines = existing.split(/(?<=\n)/);
240
+ const runs = [];
241
+ let run = [];
242
+ let pendingBlanks = [];
243
+ for (const line of lines) {
244
+ const t = line.trim();
245
+ if (t === '') {
246
+ if (run.length > 0)
247
+ pendingBlanks.push(line);
248
+ continue;
249
+ }
250
+ if (known.has(t)) {
251
+ run = closeRun(runs, run);
252
+ pendingBlanks = [];
253
+ continue;
254
+ }
255
+ // A blank line INSIDE a run is part of it; blanks trailing a run are not.
256
+ run.push(...pendingBlanks, line);
257
+ pendingBlanks = [];
258
+ }
259
+ closeRun(runs, run);
260
+ if (runs.length === 0)
261
+ return { content: block, preserved: [], note: null };
262
+ const preserved = runs.map((r) => (r.find((l) => l.trim() !== '') ?? '').trim());
263
+ return {
264
+ content: block + '\n' + PRESERVED_NOTE + '\n\n' + runs.map((r) => r.join('')).join('\n'),
265
+ preserved,
266
+ note: `kept ${runs.length} section(s) this generator did not write`,
267
+ };
268
+ }
269
+ /** Close the current run into `runs` (dropping an empty one) and hand back a fresh one. */
270
+ function closeRun(runs, run) {
271
+ if (run.length > 0)
272
+ runs.push(run);
273
+ return [];
274
+ }
200
275
  /**
201
276
  * 이 하네스에 써야 할 파일들. **쓰지는 않는다** — 무엇을 쓸지만 말한다.
202
277
  *
@@ -78,7 +78,12 @@ const DEFAULT_TTL_MINUTES = 30;
78
78
  const MAX_TTL_MINUTES = 7 * 24 * 60;
79
79
  const DEFAULT_RATIONALE = 'approved via holmes-kit approve';
80
80
  const findPending = (root, id) => {
81
- const state = (0, approval_queue_1.readQueue)(root);
81
+ // @implements A-SPEC-576.1 RESOLUTION reads everything; the human's LIST reads decisions only.
82
+ // The inbox filter hides gate refusals from the screen, but `approve <id>` must still find one:
83
+ // the fallback below only holds entries written after the REQ-563 split, so anything refused
84
+ // before it lives in the queue alone. Filtering here would cut the out-of-band approval path the
85
+ // deny message itself promises.
86
+ const state = (0, approval_queue_1.readQueue)(root, { includeAllKinds: true });
82
87
  const pending = state.pending.find((p) => p.id === id);
83
88
  if (pending)
84
89
  return { entry: pending, state };
@@ -1,3 +1,4 @@
1
+ import { NativeEvidence } from './native-deps';
1
2
  import { type SemanticTier } from '../semantic/tier';
2
3
  /**
3
4
  * `holmes-kit doctor` — install diagnostics.
@@ -82,6 +83,28 @@ export declare function prefixVerdict(input: PrefixProbe): {
82
83
  detail: string;
83
84
  fix?: string;
84
85
  };
86
+ /**
87
+ * @implements A-SPEC-580
88
+ * Parse a trivial source with every grammar, in a child node whose module resolution starts at
89
+ * `packageRoot`. `parsed` counts typescript and tsx separately (8 for the 7 grammar packages).
90
+ */
91
+ export declare function grammarProbe(packageRoot: string): {
92
+ parsed: number;
93
+ failed: string[];
94
+ error?: string;
95
+ };
96
+ /**
97
+ * @implements A-SPEC-580
98
+ * Everything observable about the better-sqlite3 install, gathered for `nativeVerdict`. Each probe
99
+ * is independent and optional: a spawn that fails leaves its field `undefined` (not observed), and
100
+ * the verdict says so rather than treating silence as evidence.
101
+ *
102
+ * Which package.json holds the approval policy depends on the layout: this checkout's own for a
103
+ * repository run, the PROJECT's (three levels up from node_modules/@holmes-lab/holmes-kit) for a
104
+ * local dependency. A global or npx install has no project package.json that npm consults, so its
105
+ * coverage is `uncovered` by construction and the remedy is the per-command `--allow-scripts`.
106
+ */
107
+ export declare function gatherNativeEvidence(packageRoot: string): NativeEvidence;
85
108
  /** The parent environment minus the variables that legitimately change a gate decision. Pure. */
86
109
  export declare function probeEnv(parent: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
87
110
  /** Seam for the gate probe's child process. Default is a real `spawnSync`; tests inject a fake. */
@@ -94,6 +117,18 @@ export interface ProbeRunner {
94
117
  stderr?: string;
95
118
  };
96
119
  }
120
+ /**
121
+ * Where a wiring's executable actually is, judged from the PROJECT rather than from the caller.
122
+ *
123
+ * Measured 2026-09-10: the same `.codex/config.toml` under the same `--target` read PASS from the
124
+ * repository root and FAIL from `/tmp`, because a relative arg is resolved by Node against
125
+ * `process.cwd()`. A relative arg is the CONVENTION here — `.mcp.json` has always carried
126
+ * `bin/holmes-mcp.js` — so the verdict was decided by where the person diagnosing stood.
127
+ *
128
+ * An absolute arg is returned untouched: agy's wiring is absolute and must not move. An empty arg
129
+ * stays empty — resolving it would conjure the target directory itself into a "file that exists".
130
+ */
131
+ export declare function resolveWiringPath(target: string, arg: string): string;
97
132
  export declare function runDoctor(packageRoot: string, target?: string, opts?: DoctorOptions, extraChecks?: Check[]): Promise<Check[]>;
98
133
  /**
99
134
  * Drive a real MCP stdio handshake: initialize -> initialized -> tools/list, with a timeout.
@@ -101,7 +136,7 @@ export declare function runDoctor(packageRoot: string, target?: string, opts?: D
101
136
  * and closes stdin makes the server exit before answering (a false FAIL this check produced on its
102
137
  * very first run against a healthy server).
103
138
  */
104
- export declare function wiringSpawnCheck(command: string, args: string[], timeoutMs?: number, platform?: NodeJS.Platform): Promise<Check>;
139
+ export declare function wiringSpawnCheck(command: string, args: string[], timeoutMs?: number, platform?: NodeJS.Platform, cwd?: string): Promise<Check>;
105
140
  /**
106
141
  * Push-gate presence, diagnosed only where it applies: a repo that opted into governance (.ax)
107
142
  * AND has git. Absence or a hook without our signature is a WARN carrying the install command —
@@ -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]