@holmes-lab/holmes-kit 0.19.2 → 0.19.4

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 (47) hide show
  1. package/CHANGELOG.md +177 -0
  2. package/README.md +22 -4
  3. package/dist/.build-id +1 -1
  4. package/dist/holmes/cli/agents.d.ts +8 -0
  5. package/dist/holmes/cli/agents.js +26 -2
  6. package/dist/holmes/cli/codex-toml.d.ts +10 -0
  7. package/dist/holmes/cli/codex-toml.js +76 -12
  8. package/dist/holmes/cli/doctor.d.ts +34 -0
  9. package/dist/holmes/cli/doctor.js +139 -46
  10. package/dist/holmes/cli/index.js +13 -0
  11. package/dist/holmes/cli/init.js +10 -3
  12. package/dist/holmes/cli/native-deps.js +4 -1
  13. package/dist/holmes/cli/playbook-skills.js +6 -4
  14. package/dist/holmes/cli/probe-process.d.ts +8 -0
  15. package/dist/holmes/cli/probe-process.js +73 -0
  16. package/dist/holmes/cli/spawn-spec.js +3 -1
  17. package/dist/holmes/cli/test-platform.d.ts +37 -0
  18. package/dist/holmes/cli/test-platform.js +126 -1
  19. package/dist/holmes/governance/approval-grants.js +26 -3
  20. package/dist/holmes/governance/autonomy.d.ts +17 -1
  21. package/dist/holmes/governance/autonomy.js +37 -5
  22. package/dist/holmes/mcp/handlers.d.ts +30 -5
  23. package/dist/holmes/mcp/handlers.js +111 -13
  24. package/dist/holmes/mcp/spec-id-guard.d.ts +1 -1
  25. package/dist/holmes/mcp/spec-id-guard.js +9 -13
  26. package/dist/holmes/mcp/tool-schemas.js +13 -0
  27. package/dist/holmes/project/install-scripts-policy.d.ts +16 -2
  28. package/dist/holmes/project/install-scripts-policy.js +16 -2
  29. package/dist/holmes/review/point-in-time-replay.js +43 -3
  30. package/dist/holmes/rtm/graph-store.d.ts +2 -0
  31. package/dist/holmes/rtm/graph-store.js +14 -0
  32. package/dist/holmes/rtm/rtm-graph.js +42 -30
  33. package/dist/holmes/semantic/credentials.js +86 -9
  34. package/dist/holmes/semantic/embedder.js +6 -39
  35. package/dist/holmes/semantic/local-model.d.ts +30 -0
  36. package/dist/holmes/semantic/local-model.js +92 -0
  37. package/dist/holmes/semantic/model-cache.d.ts +8 -0
  38. package/dist/holmes/semantic/model-cache.js +67 -0
  39. package/dist/holmes/semantic/tier.d.ts +7 -0
  40. package/dist/holmes/semantic/tier.js +9 -3
  41. package/dist/holmes/spec/renumber.d.ts +72 -0
  42. package/dist/holmes/spec/renumber.js +341 -0
  43. package/dist/holmes/spec/spec-id.d.ts +9 -0
  44. package/dist/holmes/spec/spec-id.js +23 -0
  45. package/docs/install-guide.md +90 -2
  46. package/package.json +6 -3
  47. package/scripts/install.ps1 +30 -27
@@ -34,10 +34,13 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.STRIPPED_FOR_PROBE = void 0;
37
+ exports.resolvesToPackage = resolvesToPackage;
37
38
  exports.globalInstallDir = globalInstallDir;
38
39
  exports.npmCliEntry = npmCliEntry;
40
+ exports.probeWritable = probeWritable;
39
41
  exports.prefixVerdict = prefixVerdict;
40
42
  exports.grammarProbe = grammarProbe;
43
+ exports.resolveDepDir = resolveDepDir;
41
44
  exports.gatherNativeEvidence = gatherNativeEvidence;
42
45
  exports.probeEnv = probeEnv;
43
46
  exports.resolveWiringPath = resolveWiringPath;
@@ -48,6 +51,9 @@ exports.formatChecks = formatChecks;
48
51
  exports.wiringHandshakeChecks = wiringHandshakeChecks;
49
52
  exports.semanticTierVerdict = semanticTierVerdict;
50
53
  exports.detectTreeKeyTemporary = detectTreeKeyTemporary;
54
+ // @implements A-SPEC-594
55
+ // @implements A-SPEC-592
56
+ // @implements A-SPEC-591
51
57
  // @implements A-SPEC-442
52
58
  // @implements A-SPEC-207
53
59
  // @implements A-SPEC-100.2
@@ -57,6 +63,7 @@ const npx_bin_1 = require("../project/npx-bin");
57
63
  const install_scripts_policy_1 = require("../project/install-scripts-policy");
58
64
  const native_deps_1 = require("./native-deps");
59
65
  const tier_1 = require("../semantic/tier");
66
+ const probe_process_1 = require("./probe-process");
60
67
  const path = __importStar(require("node:path"));
61
68
  const role_policy_1 = require("../governance/role-policy");
62
69
  const blind_spots_1 = require("../guardrail/blind-spots");
@@ -81,8 +88,20 @@ function resolvesToPackage(filePath, packageRoot) {
81
88
  try {
82
89
  if (path.resolve(filePath).startsWith(path.resolve(packageRoot) + path.sep))
83
90
  return true;
84
- const realFile = fs.realpathSync(filePath);
85
- const realPkg = fs.realpathSync(packageRoot);
91
+ // @implements A-SPEC-586.2 — canonical, not merely real: the JS `realpathSync` walks lstat and
92
+ // leaves a Windows 8.3 short name (`HOLMES~1`) as it is, so the very hook path init writes for a
93
+ // spaced install read as "another install". `realpathSync.native` asks the OS for the final
94
+ // path, which expands short names and follows links alike (measured 2026-09-10).
95
+ const canonical = (p) => {
96
+ try {
97
+ return fs.realpathSync.native(p);
98
+ }
99
+ catch {
100
+ return fs.realpathSync(p);
101
+ }
102
+ };
103
+ const realFile = canonical(filePath);
104
+ const realPkg = canonical(packageRoot);
86
105
  return realFile.startsWith(realPkg + path.sep);
87
106
  }
88
107
  catch {
@@ -146,6 +165,31 @@ function npmCliEntry(env = process.env, execPath = process.execPath) {
146
165
  }
147
166
  return null;
148
167
  }
168
+ /**
169
+ * @implements A-SPEC-584
170
+ * Is `dir` writable by THIS account? Asked by TRYING, not by `fs.accessSync(W_OK)`: on Windows the
171
+ * mode bits accessSync consults do not carry the ACL, so it answered "writable" for
172
+ * `C:\Program Files\nodejs` on a non-admin account while `mkdir` died EPERM (measured 2026-09-10).
173
+ * A mkdir is the very syscall npm's own EPERM comes from (creating the scope directory), so the probe
174
+ * makes one and removes it. A probe entry that could not be removed is reported, never hidden.
175
+ * Nothing thrown; a missing or non-directory `dir` is simply not writable.
176
+ */
177
+ function probeWritable(dir) {
178
+ const probe = path.join(dir, `.holmes-doctor-probe-${process.pid}-${Math.random().toString(36).slice(2, 8)}`);
179
+ try {
180
+ fs.mkdirSync(probe);
181
+ }
182
+ catch {
183
+ return { writable: false };
184
+ }
185
+ try {
186
+ fs.rmdirSync(probe);
187
+ return { writable: true };
188
+ }
189
+ catch {
190
+ return { writable: true, leftover: probe };
191
+ }
192
+ }
149
193
  /**
150
194
  * Judge the global prefix. Pure — the probing lives with the caller.
151
195
  *
@@ -170,6 +214,11 @@ function prefixVerdict(input) {
170
214
  }
171
215
  const prefix = input.prefix;
172
216
  if (input.writable) {
217
+ // @implements A-SPEC-584 — writable, but the probe left something behind: a PASS would hide a
218
+ // file the user now has to clean up. Say where it is.
219
+ if (typeof input.leftover === 'string' && input.leftover !== '') {
220
+ return { level: 'WARN', detail: `Global prefix (${prefix}) is writable by the current account, but doctor's probe directory could not be removed: ${input.leftover} — delete it by hand.` };
221
+ }
173
222
  return { level: 'PASS', detail: `Global prefix (${prefix}) is writable by the current account — npm install -g works without permission trouble.` };
174
223
  }
175
224
  const fix = input.platform === 'win32'
@@ -241,13 +290,39 @@ function observe(file, args) {
241
290
  * local dependency. A global or npx install has no project package.json that npm consults, so its
242
291
  * coverage is `uncovered` by construction and the remedy is the per-command `--allow-scripts`.
243
292
  */
293
+ // @implements A-SPEC-583.1
294
+ /**
295
+ * Where an installed dependency actually lives, asked of NODE rather than guessed from a path.
296
+ *
297
+ * 0.19.2 shipped a false FAIL to every third-party consumer: it built the directory by joining
298
+ * `packageRoot/node_modules/<name>`, and npm HOISTS dependencies to the project root, so for an
299
+ * install at `<proj>/node_modules/@holmes-lab/holmes-kit` the joined path never existed — while the
300
+ * binding sat at `<proj>/node_modules/better-sqlite3` and loaded fine. Hoisting is npm's default,
301
+ * which made the false verdict the default path of a normal install.
302
+ *
303
+ * `package.json` is the resolution target because we want the DIRECTORY, not the entry point: a
304
+ * package whose `exports` hides its main still resolves its own manifest by convention. A failure
305
+ * to resolve returns null, and the caller reports "absent" exactly as before — the repair changes
306
+ * how we look, never whether a real miss is reported.
307
+ */
308
+ function resolveDepDir(fromRoot, name) {
309
+ if (name === '')
310
+ return null;
311
+ try {
312
+ return path.dirname(require.resolve(`${name}/package.json`, { paths: [fromRoot] }));
313
+ }
314
+ catch {
315
+ return null;
316
+ }
317
+ }
244
318
  function gatherNativeEvidence(packageRoot) {
245
319
  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'));
320
+ // @implements A-SPEC-583.1 — resolved, not joined. A hoisted install is the npm default.
321
+ const pkgDir = resolveDepDir(packageRoot, name);
322
+ const bindingPresent = pkgDir !== null && fs.existsSync(path.join(pkgDir, 'build', 'Release', 'better_sqlite3.node'));
248
323
  let packageVersion = '(unknown)';
249
324
  try {
250
- packageVersion = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8')).version ?? packageVersion;
325
+ packageVersion = JSON.parse(fs.readFileSync(path.join(pkgDir ?? '', 'package.json'), 'utf8')).version ?? packageVersion;
251
326
  }
252
327
  catch { /* not installed */ }
253
328
  let loadError;
@@ -354,7 +429,8 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
354
429
  // 2. Node version vs better-sqlite3's declared range (WARN only — a mismatch still often works).
355
430
  let sqliteRange = '(unknown)';
356
431
  try {
357
- sqliteRange = JSON.parse(fs.readFileSync(path.join(packageRoot, 'node_modules', 'better-sqlite3', 'package.json'), 'utf8')).engines?.node ?? '(none)';
432
+ // @implements A-SPEC-583.1 the SIBLING of the evidence lookup; both move together.
433
+ sqliteRange = JSON.parse(fs.readFileSync(path.join(resolveDepDir(packageRoot, 'better-sqlite3') ?? '', 'package.json'), 'utf8')).engines?.node ?? '(none)';
358
434
  }
359
435
  catch { /* not resolvable from here */ }
360
436
  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.');
@@ -1011,7 +1087,14 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
1011
1087
  }
1012
1088
  else {
1013
1089
  const entry = (0, codex_toml_1.readCodexHolmesEntry)(raw);
1014
- if (!entry) {
1090
+ // @implements A-SPEC-585 — an invalid basic-string escape (a raw Windows backslash path is the
1091
+ // measured case) is not "half-done": Codex refuses the whole file, and the reader must not
1092
+ // hand doctor a mangled path to judge. Name the escape and the line.
1093
+ const badEscape = entry ? null : (0, codex_toml_1.invalidTomlEscape)(raw);
1094
+ if (!entry && badEscape) {
1095
+ add('codex wiring', 'WARN', `${cdxToml} has an invalid escape ${badEscape.escape} on line ${badEscape.line} inside [mcp_servers.${init_1.SERVER_NAME}] — Codex refuses this file. On Windows, double each backslash ("C:\\\\path") or use a single-quoted literal string ('C:\\path').`, 'Rewire with holmes-kit init --target <dir> --agent codex --force (it writes valid TOML).');
1096
+ }
1097
+ else if (!entry) {
1015
1098
  add('codex wiring', 'WARN', `${cdxToml} has no runnable [mcp_servers.${init_1.SERVER_NAME}] entry — a half-done wiring`, 'Rewire with holmes-kit init --target <dir> --agent codex.');
1016
1099
  }
1017
1100
  else if (entry.command === 'node') {
@@ -1142,13 +1225,10 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
1142
1225
  break;
1143
1226
  probe = parent;
1144
1227
  }
1145
- let writable = false;
1146
- try {
1147
- fs.accessSync(probe, fs.constants.W_OK);
1148
- writable = true;
1149
- }
1150
- catch { /* not writable */ }
1151
- verdict = prefixVerdict({ prefix, dir, writable, platform: process.platform });
1228
+ // @implements A-SPEC-584 — TRY, do not ask: the mode-bit question said "writable" for a
1229
+ // protected Windows prefix (ACLs are invisible to it) and sent users into npm's EPERM.
1230
+ const tried = probeWritable(probe);
1231
+ verdict = prefixVerdict({ prefix, dir, writable: tried.writable, leftover: tried.leftover, platform: process.platform });
1152
1232
  }
1153
1233
  catch (e) {
1154
1234
  verdict = prefixVerdict({ unreadable: e instanceof Error ? e.message.split('\n')[0] : String(e) });
@@ -1260,15 +1340,15 @@ cwd) {
1260
1340
  return;
1261
1341
  done = true;
1262
1342
  clearTimeout(timer);
1263
- try {
1264
- child.kill();
1265
- }
1266
- catch { /* already gone */ }
1267
- resolve({ name: 'mcp wiring spawn', level, detail, fix });
1343
+ void (0, probe_process_1.stopProbeProcess)(child).then(released => resolve(released
1344
+ ? { name: 'mcp wiring spawn', level, detail, fix }
1345
+ : { name: 'mcp wiring spawn', level: 'FAIL', detail: `${detail}; probe cleanup could not be confirmed`,
1346
+ fix: `${fix ?? ''} Close the remaining diagnostic process for \`${command}\`, then retry doctor.` }));
1268
1347
  };
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);
1348
+ 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 for \`${command}\`, then re-run doctor.`), timeoutMs);
1270
1349
  try {
1271
- child = (0, node_child_process_1.spawn)(spec.command, spec.args, { stdio: ['pipe', 'pipe', 'pipe'], ...(cwd === undefined ? {} : { cwd }) });
1350
+ child = (0, node_child_process_1.spawn)(spec.command, spec.args, { ...probe_process_1.PROBE_SPAWN_OPTIONS, stdio: ['pipe', 'pipe', 'pipe'], ...(cwd === undefined ? {} : { cwd }) });
1351
+ (0, probe_process_1.trackProbeProcess)(child);
1272
1352
  }
1273
1353
  catch (e) {
1274
1354
  finish('FAIL', `wiring could not be spawned: \`${quoted}\` — ${e.message}`, spawnFailFix(e));
@@ -1312,7 +1392,8 @@ cwd) {
1312
1392
  function mcpHandshakeCheck(packageRoot, timeoutMs = 15000) {
1313
1393
  return new Promise((resolve) => {
1314
1394
  const mcpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'holmes-doctor-mcp-'));
1315
- const child = (0, node_child_process_1.spawn)(process.execPath, [path.join(packageRoot, 'bin', 'holmes-mcp.js')], { stdio: ['pipe', 'pipe', 'pipe'], cwd: mcpCwd });
1395
+ let child;
1396
+ let timer;
1316
1397
  let pending = ''; // the unterminated tail of the stdout stream, never the whole history
1317
1398
  let err = '';
1318
1399
  let done = false;
@@ -1325,30 +1406,29 @@ function mcpHandshakeCheck(packageRoot, timeoutMs = 15000) {
1325
1406
  return;
1326
1407
  done = true;
1327
1408
  clearTimeout(timer);
1328
- // Escalate (round-9): SIGTERM alone leaves a wedged server running with doctor's pipes open.
1329
- child.kill();
1330
- // round-10: an unref'd timer never fires in a doctor run that ends in ~0.4s, so the escalation
1331
- // was decoration. The timer holds the loop for its 500ms and is cleared the moment the child
1332
- // actually exits, so a healthy run pays nothing.
1333
- const hard = setTimeout(() => { try {
1334
- child.kill('SIGKILL');
1335
- }
1336
- catch { /* already gone */ } }, 500);
1337
- child.once('exit', () => clearTimeout(hard));
1338
- child.stdout.destroy();
1339
- child.stderr.destroy();
1340
- child.stdin.destroy();
1341
- child.unref?.();
1342
- // Cleanup HERE, not on 'close' — doctor's process can exit before a close handler runs,
1343
- // stranding the isolated cwd (round-6 §7e measured the leak). A signal death still strands it;
1344
- // `cleanupOnSignal` below covers that (round-9).
1345
- try {
1346
- fs.rmSync(mcpCwd, { recursive: true, force: true });
1347
- }
1348
- catch { /* tmp cleaner's job */ }
1349
- resolve({ name: 'MCP server', level, detail, fix });
1409
+ void (0, probe_process_1.stopProbeProcess)(child).then(async (released) => {
1410
+ let removed = false;
1411
+ try {
1412
+ await fs.promises.rm(mcpCwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
1413
+ DOCTOR_TEMP_DIRS.delete(mcpCwd);
1414
+ removed = true;
1415
+ }
1416
+ catch { /* signal cleanup retains the path for a later attempt */ }
1417
+ resolve(released && removed
1418
+ ? { name: 'MCP server', level, detail, fix }
1419
+ : { name: 'MCP server', level: 'FAIL', detail: `${detail}; probe cleanup could not be confirmed`,
1420
+ fix: 'Close the remaining diagnostic process and retry doctor.' });
1421
+ });
1350
1422
  };
1351
1423
  cleanupOnSignal(mcpCwd);
1424
+ try {
1425
+ child = (0, node_child_process_1.spawn)(process.execPath, [path.join(packageRoot, 'bin', 'holmes-mcp.js')], { ...probe_process_1.PROBE_SPAWN_OPTIONS, stdio: ['pipe', 'pipe', 'pipe'], cwd: mcpCwd });
1426
+ (0, probe_process_1.trackProbeProcess)(child);
1427
+ }
1428
+ catch (error) {
1429
+ finish('FAIL', `could not spawn the MCP server: ${error.message}`, 'Check the install and executable permissions, then retry doctor.');
1430
+ return;
1431
+ }
1352
1432
  // EPIPE arrives as an ASYNC 'error' event, not a synchronous throw — the try/catch below never
1353
1433
  // sees it, and an unhandled stream error would crash doctor itself (round-1 REQ-192).
1354
1434
  child.stdin.on('error', () => { });
@@ -1376,7 +1456,7 @@ function mcpHandshakeCheck(packageRoot, timeoutMs = 15000) {
1376
1456
  child.stdin.write(JSON.stringify(o) + '\n');
1377
1457
  }
1378
1458
  catch { /* server gone */ } };
1379
- const timer = setTimeout(() => finish('FAIL', `no tools/list response within ${timeoutMs}ms (stderr: ${err.slice(0, 160)})`, 'The MCP server did not start. A missing tree-sitter grammar is the usual cause (see above).'), timeoutMs);
1459
+ timer = setTimeout(() => finish('FAIL', `no tools/list response within ${timeoutMs}ms (stderr: ${err.slice(0, 160)})`, 'The MCP server did not start. A missing tree-sitter grammar is the usual cause (see above).'), timeoutMs);
1380
1460
  // Line-oriented and stateful. The old shape re-scanned the whole accumulated buffer on every
1381
1461
  // chunk (quadratic, and it re-fired past triggers) and judged `tools` by
1382
1462
  // `typeof result.tools.length === 'number'` — which a NON-ARRAY or an EMPTY list satisfies, so a
@@ -1572,9 +1652,22 @@ function semanticTierVerdict(t) {
1572
1652
  };
1573
1653
  }
1574
1654
  if (t.tier === 'local') {
1655
+ return {
1656
+ level: 'WARN',
1657
+ detail: `Semantic search tier: local (${t.model}, ${(0, tier_1.POOLING_OF)(t.model ?? '')} pooling) — no egress during inference. Runtime and model readiness are unverified by this lightweight check.`,
1658
+ fix: 'Run holmes-kit semantic-check for actual offline inference; if unavailable, run holmes-kit semantic-setup.',
1659
+ };
1660
+ }
1661
+ // @implements A-SPEC-594 — two different 'none' states need two different sentences. When the
1662
+ // runtime is already installed, telling the user to install it is noise; the missing piece is
1663
+ // the model, and the command that fetches it is the whole fix. Both stay PASS: a user who never
1664
+ // asked for a semantic tier is not misconfigured, and a WARN that cannot be resolved by any
1665
+ // action teaches people to skip doctor's output entirely.
1666
+ if (t.localRuntimePresent) {
1575
1667
  return {
1576
1668
  level: 'PASS',
1577
- detail: `Semantic search tier: local (${t.model}, ${(0, tier_1.POOLING_OF)(t.model ?? '')} pooling) no egress. Recovers 52% of requests lexical search misses (measured).`,
1669
+ detail: 'Semantic search tier: none — the local runtime is installed but its model was never prepared, so no embedding is produced. Requests lexical search misses (measured 16.4%) have 0% recall.',
1670
+ fix: 'Run `holmes-kit semantic-setup` to download the public model once (shared across projects) — lexical-zero recall 0→52%, no egress. Cloud tier (opt-in, consent to external transfer): set GEMINI_API_KEY — 0→92%.',
1578
1671
  };
1579
1672
  }
1580
1673
  return {
@@ -37,6 +37,7 @@ exports.isBrokenPipe = void 0;
37
37
  exports.packageRoot = packageRoot;
38
38
  exports.main = main;
39
39
  exports.installPipeGuard = installPipeGuard;
40
+ // @implements A-SPEC-591
40
41
  // @implements A-SPEC-100.2
41
42
  // @implements A-SPEC-213
42
43
  // @implements A-SPEC-215
@@ -82,6 +83,8 @@ const KNOWN_FLAGS = {
82
83
  ledger: ['help', 'target', 'ref', 'dry-run'],
83
84
  // @implements A-SPEC-477 — the human's opt-in act for the cloud semantic tier.
84
85
  'semantic-key': ['help'],
86
+ 'semantic-setup': ['help'],
87
+ 'semantic-check': ['help'],
85
88
  // @implements A-SPEC-509.1 — the explicit installation act for the pre-push evidence gate.
86
89
  'install-push-gate': ['help', 'target'],
87
90
  // @implements A-SPEC-543.2 — one-command upgrade: install + re-pin every registered workspace.
@@ -219,6 +222,8 @@ const USAGE = `holmes-kit — deterministic ASE governance for a project
219
222
  --target <dir> project root (default: cwd)
220
223
 
221
224
  holmes-kit semantic-key set|unset|status manage the cloud semantic tier opt-in key
225
+ holmes-kit semantic-setup install and verify the default local BGE-M3 model
226
+ holmes-kit semantic-check verify BGE-M3 offline without downloading
222
227
  set reads from stdin only (the value never appears in argv or output).
223
228
  Setting the key is consent to egress (spec prose, paths, symbol names sent externally).
224
229
 
@@ -310,6 +315,10 @@ async function main(argv) {
310
315
  process.stdout.write((0, colophon_1.colophon)());
311
316
  return 0;
312
317
  }
318
+ if (cmd === 'semantic-setup' || cmd === 'semantic-check') {
319
+ const { runModelSetup } = await Promise.resolve().then(() => __importStar(require('../semantic/local-model')));
320
+ return runModelSetup({ offline: cmd === 'semantic-check' });
321
+ }
313
322
  if (cmd === 'semantic-key') {
314
323
  // @implements A-SPEC-477 — set|unset|status; the key rides stdin, never argv.
315
324
  const { runSemanticKey } = await Promise.resolve().then(() => __importStar(require('./semantic-key')));
@@ -1141,6 +1150,10 @@ async function main(argv) {
1141
1150
  catch { /* registry failure never fails init */ }
1142
1151
  }
1143
1152
  }
1153
+ if (res.ok && !opts.dryRun && !opts.remove) {
1154
+ const { runModelSetup } = await Promise.resolve().then(() => __importStar(require('../semantic/local-model')));
1155
+ runModelSetup({ automatic: true });
1156
+ }
1144
1157
  return res.exitCode;
1145
1158
  }
1146
1159
  // @implements A-SPEC-543.2 — one-command upgrade. Preparation is zero-config (init records the
@@ -472,9 +472,16 @@ function runInit(opts) {
472
472
  }
473
473
  changes.push({ path: f.path, before, after: f.content });
474
474
  }
475
- messages.push(agents_1.HARNESS_ENFORCES[agent]
476
- ? `${agent}: the gate is enforced (hooks wired).`
477
- : `${agent}: only tools and instructions were wired this harness does not enforce the gate.`);
475
+ // @implements A-SPEC-586 — an antigravity hook whose path still carries a space is a hook the
476
+ // launcher never runs (it splits on whitespace). Saying "enforced" over it was a false PASS
477
+ // measured on a Windows install under a spaced user directory; the warning replaces the claim.
478
+ const spaced = agent === 'antigravity' ? (0, agents_1.antigravityHookWarnings)(opts.packageRoot) : [];
479
+ if (spaced.length > 0)
480
+ messages.push(...spaced);
481
+ else
482
+ messages.push(agents_1.HARNESS_ENFORCES[agent]
483
+ ? `${agent}: the gate is enforced (hooks wired).`
484
+ : `${agent}: only tools and instructions were wired — this harness does not enforce the gate.`);
478
485
  // a link is placement, not content — a dry-run only says so, and only the real run creates it.
479
486
  for (const link of (0, agents_1.agentLinks)(agent, { target: opts.target, packageRoot: opts.packageRoot, specsDir: opts.specsDir })) {
480
487
  if (opts.dryRun) {
@@ -4,6 +4,7 @@ exports.installKind = installKind;
4
4
  exports.recoveryCommands = recoveryCommands;
5
5
  exports.powershellPolicyNote = powershellPolicyNote;
6
6
  exports.nativeVerdict = nativeVerdict;
7
+ // @implements A-SPEC-599
7
8
  // @implements A-SPEC-580
8
9
  // @implements A-SPEC-580.1
9
10
  const npx_bin_1 = require("../project/npx-bin");
@@ -94,7 +95,9 @@ function nativeVerdict(ev) {
94
95
  return {
95
96
  cause: 'scripts-blocked', level: 'FAIL',
96
97
  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
+ fix: `Approve the one script that must run, then rebuild: ${recoveryCommands(ev).join(isWin
99
+ ? "; if ($LASTEXITCODE -ne 0) { throw 'Native dependency recovery failed' }; "
100
+ : ' && ')}`,
98
101
  };
99
102
  }
100
103
  // 6b. Early npm 11 did not block scripts, so a missing approval proves nothing about why the
@@ -45,6 +45,7 @@ exports.skillLabel = skillLabel;
45
45
  exports.playbookSkillStates = playbookSkillStates;
46
46
  exports.classifyExtraEntries = classifyExtraEntries;
47
47
  exports.installedPlaybookCount = installedPlaybookCount;
48
+ // @implements A-SPEC-190
48
49
  // @implements A-SPEC-142
49
50
  const fs = __importStar(require("node:fs"));
50
51
  const path = __importStar(require("node:path"));
@@ -199,13 +200,14 @@ function aliasGroupsOf(packageRoot, target) {
199
200
  // playbook's body overwrite the other's — the exact harm the round-8 pre-scan was added to stop.
200
201
  const identity = (name) => {
201
202
  const real = realOf(name);
203
+ // NTFS file IDs can exceed Number precision; rounded IDs alias unrelated files.
202
204
  try {
203
- const st = fs.statSync(real);
205
+ const st = fs.statSync(real, { bigint: true });
204
206
  return `${st.dev}:${st.ino}`;
205
207
  }
206
208
  catch { /* not yet there */ }
207
209
  try {
208
- const st = fs.statSync(path.dirname(real));
210
+ const st = fs.statSync(path.dirname(real), { bigint: true });
209
211
  return `${st.dev}:${st.ino}:${path.basename(real).toLowerCase()}`;
210
212
  }
211
213
  catch { /* dangling */ }
@@ -652,8 +654,8 @@ function playbookSkillStates(packageRoot, target) {
652
654
  }
653
655
  for (const entry of classifyExtraEntries(entries, shipped, (entry2, canonical) => {
654
656
  try {
655
- const a = fs.statSync(path.join(skillsDir, entry2));
656
- const b = fs.statSync(path.join(skillsDir, canonical));
657
+ const a = fs.statSync(path.join(skillsDir, entry2), { bigint: true });
658
+ const b = fs.statSync(path.join(skillsDir, canonical), { bigint: true });
657
659
  return a.ino === b.ino && a.dev === b.dev;
658
660
  }
659
661
  catch {
@@ -0,0 +1,8 @@
1
+ import { type ChildProcess } from 'node:child_process';
2
+ /** Each POSIX probe gets its own process group; Windows taskkill targets only its owned PID. */
3
+ export declare const PROBE_SPAWN_OPTIONS: {
4
+ detached: boolean;
5
+ windowsHide: boolean;
6
+ };
7
+ export declare function trackProbeProcess(child: ChildProcess): void;
8
+ export declare function stopProbeProcess(child?: ChildProcess): Promise<boolean>;
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PROBE_SPAWN_OPTIONS = void 0;
4
+ exports.trackProbeProcess = trackProbeProcess;
5
+ exports.stopProbeProcess = stopProbeProcess;
6
+ // @implements A-SPEC-592
7
+ const node_child_process_1 = require("node:child_process");
8
+ /** Each POSIX probe gets its own process group; Windows taskkill targets only its owned PID. */
9
+ exports.PROBE_SPAWN_OPTIONS = { detached: process.platform !== 'win32', windowsHide: true };
10
+ const states = new WeakMap();
11
+ function trackProbeProcess(child) {
12
+ if (states.has(child))
13
+ return;
14
+ const state = { closed: false };
15
+ states.set(child, state);
16
+ child.once('close', () => { state.closed = true; });
17
+ }
18
+ async function stopProbeProcess(child) {
19
+ if (!child)
20
+ return true;
21
+ trackProbeProcess(child);
22
+ let closed = states.get(child).closed;
23
+ let onClose = () => { };
24
+ let closeTimer;
25
+ const closure = new Promise(resolve => {
26
+ onClose = () => { closed = true; clearTimeout(closeTimer); resolve(true); };
27
+ child.once('close', onClose);
28
+ closeTimer = setTimeout(() => resolve(false), 3000);
29
+ if (closed || !child.pid)
30
+ onClose();
31
+ });
32
+ let stopped = true;
33
+ try {
34
+ if (child.pid && process.platform === 'win32' && !closed) {
35
+ stopped = await new Promise(resolve => {
36
+ (0, node_child_process_1.execFile)('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, timeout: 2500 }, error => {
37
+ resolve(!error);
38
+ });
39
+ });
40
+ }
41
+ else if (child.pid && process.platform !== 'win32') {
42
+ const signalGroup = (signal) => {
43
+ try {
44
+ process.kill(-child.pid, signal);
45
+ }
46
+ catch (error) {
47
+ if (error.code !== 'ESRCH')
48
+ throw error;
49
+ if (!closed)
50
+ child.kill(signal);
51
+ }
52
+ };
53
+ signalGroup('SIGTERM');
54
+ // Escalate the group even if its wrapper exits first: a descendant may ignore SIGTERM.
55
+ await new Promise(resolve => setTimeout(resolve, 100));
56
+ signalGroup('SIGKILL');
57
+ }
58
+ }
59
+ catch {
60
+ stopped = false;
61
+ }
62
+ // Terminate the tree before closing stdin: an EOF could make the wrapper exit first,
63
+ // leaving Windows taskkill with no parent from which to enumerate its descendants.
64
+ child.stdin?.destroy();
65
+ child.stdout?.destroy();
66
+ child.stderr?.destroy();
67
+ const released = await closure;
68
+ clearTimeout(closeTimer);
69
+ child.removeListener('close', onClose);
70
+ if (!released)
71
+ child.unref();
72
+ return stopped && released;
73
+ }
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.spawnSpecFor = spawnSpecFor;
4
+ // @implements A-SPEC-592
4
5
  // @implements A-SPEC-499.1
5
6
  /**
6
7
  * Platform adapter for spawning a WIRING command verbatim. On win32, `npx` is `npx.cmd`, and a
@@ -14,7 +15,8 @@ exports.spawnSpecFor = spawnSpecFor;
14
15
  * PURE — platform arrives as an argument so every branch is testable off-Windows.
15
16
  */
16
17
  function spawnSpecFor(command, args, platform) {
17
- if (platform !== 'win32')
18
+ const shim = /\.(cmd|bat)$/i.test(command) || /(?:^|[\\/])(npm|npx)$/i.test(command);
19
+ if (platform !== 'win32' || !shim)
18
20
  return { command, args: [...args] };
19
21
  const joined = [command, ...args].map((t) => (/\s/.test(t) ? `"${t}"` : t)).join(' ');
20
22
  return { command: 'cmd.exe', args: ['/d', '/s', '/c', joined] };
@@ -23,3 +23,40 @@ export declare function posixOnly(reason: string, platform?: string): typeof it
23
23
  * it belongs in the same choice rather than in a second early return inside the body.
24
24
  */
25
25
  export declare function posixOnlyNonRoot(reason: string, platform?: string, uid?: number | undefined): typeof it | typeof it.skip;
26
+ /**
27
+ * @implements A-SPEC-597
28
+ * Whether THIS directory's filesystem folds case — measured, never inferred from the platform.
29
+ *
30
+ * macOS defaults to case-insensitive APFS and Linux to case-sensitive ext4, but neither is a rule:
31
+ * a case-sensitive volume mounts fine on macOS, and `os.tmpdir()` need not share the repository's
32
+ * volume. The same reasoning already governs the write gate (`pre-tool-use.ts`), which probes the
33
+ * project root rather than trusting `process.platform`; a test must probe the directory it actually
34
+ * writes into.
35
+ *
36
+ * An unanswerable probe returns FALSE. Claiming "folds" when it cannot tell would let a
37
+ * folding-world assertion run — and pass vacuously — on a case-sensitive box; claiming "sensitive"
38
+ * merely runs the other assertion, which fails loudly if it is wrong. Be wrong toward the side that
39
+ * still produces information.
40
+ */
41
+ export declare function foldsCase(dir: string): boolean;
42
+ /**
43
+ * @implements A-SPEC-597
44
+ * As `posixOnlyNonRoot`, and additionally skipped when `command` is not on PATH.
45
+ *
46
+ * `chflags` is macOS/BSD only. Guarded by platform and uid alone, the assertion that uses it RAN on
47
+ * Linux and died with ENOENT — which reads as a defect in the code under test rather than as a tool
48
+ * this machine does not have. Availability of the tool is knowable at definition time, exactly like
49
+ * the platform and the uid, so it belongs in the same choice.
50
+ */
51
+ export declare function posixOnlyWithCommand(reason: string, command: string, platform?: string, uid?: number | undefined, lookup?: (command: string) => boolean): typeof it | typeof it.skip;
52
+ /**
53
+ * @implements A-SPEC-255
54
+ * The mirror of `posixOnly`: a test whose SUBJECT only exists on Windows.
55
+ *
56
+ * Without this, the Windows work had nowhere to put such a test and improvised
57
+ * `(process.platform === 'win32' ? it : it.skip)('…')` — a skip with no stated reason, which is the
58
+ * silence `posixOnly` was written to end, in different syntax. NTFS ACLs, PowerShell 5.1 parsing and
59
+ * 8.3 short names are real subjects; they simply cannot be exercised on a POSIX box, and saying so
60
+ * in the report is the honest form. `reason` is required for the same reason it is required there.
61
+ */
62
+ export declare function win32Only(reason: string, platform?: string): typeof it | typeof it.skip;