@holmes-lab/holmes-kit 0.19.3 → 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 +137 -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 +19 -0
  9. package/dist/holmes/cli/doctor.js +107 -42
  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,8 +34,10 @@ 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;
41
43
  exports.resolveDepDir = resolveDepDir;
@@ -49,6 +51,9 @@ exports.formatChecks = formatChecks;
49
51
  exports.wiringHandshakeChecks = wiringHandshakeChecks;
50
52
  exports.semanticTierVerdict = semanticTierVerdict;
51
53
  exports.detectTreeKeyTemporary = detectTreeKeyTemporary;
54
+ // @implements A-SPEC-594
55
+ // @implements A-SPEC-592
56
+ // @implements A-SPEC-591
52
57
  // @implements A-SPEC-442
53
58
  // @implements A-SPEC-207
54
59
  // @implements A-SPEC-100.2
@@ -58,6 +63,7 @@ const npx_bin_1 = require("../project/npx-bin");
58
63
  const install_scripts_policy_1 = require("../project/install-scripts-policy");
59
64
  const native_deps_1 = require("./native-deps");
60
65
  const tier_1 = require("../semantic/tier");
66
+ const probe_process_1 = require("./probe-process");
61
67
  const path = __importStar(require("node:path"));
62
68
  const role_policy_1 = require("../governance/role-policy");
63
69
  const blind_spots_1 = require("../guardrail/blind-spots");
@@ -82,8 +88,20 @@ function resolvesToPackage(filePath, packageRoot) {
82
88
  try {
83
89
  if (path.resolve(filePath).startsWith(path.resolve(packageRoot) + path.sep))
84
90
  return true;
85
- const realFile = fs.realpathSync(filePath);
86
- 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);
87
105
  return realFile.startsWith(realPkg + path.sep);
88
106
  }
89
107
  catch {
@@ -147,6 +165,31 @@ function npmCliEntry(env = process.env, execPath = process.execPath) {
147
165
  }
148
166
  return null;
149
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
+ }
150
193
  /**
151
194
  * Judge the global prefix. Pure — the probing lives with the caller.
152
195
  *
@@ -171,6 +214,11 @@ function prefixVerdict(input) {
171
214
  }
172
215
  const prefix = input.prefix;
173
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
+ }
174
222
  return { level: 'PASS', detail: `Global prefix (${prefix}) is writable by the current account — npm install -g works without permission trouble.` };
175
223
  }
176
224
  const fix = input.platform === 'win32'
@@ -1039,7 +1087,14 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
1039
1087
  }
1040
1088
  else {
1041
1089
  const entry = (0, codex_toml_1.readCodexHolmesEntry)(raw);
1042
- 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) {
1043
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.');
1044
1099
  }
1045
1100
  else if (entry.command === 'node') {
@@ -1170,13 +1225,10 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
1170
1225
  break;
1171
1226
  probe = parent;
1172
1227
  }
1173
- let writable = false;
1174
- try {
1175
- fs.accessSync(probe, fs.constants.W_OK);
1176
- writable = true;
1177
- }
1178
- catch { /* not writable */ }
1179
- 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 });
1180
1232
  }
1181
1233
  catch (e) {
1182
1234
  verdict = prefixVerdict({ unreadable: e instanceof Error ? e.message.split('\n')[0] : String(e) });
@@ -1288,15 +1340,15 @@ cwd) {
1288
1340
  return;
1289
1341
  done = true;
1290
1342
  clearTimeout(timer);
1291
- try {
1292
- child.kill();
1293
- }
1294
- catch { /* already gone */ }
1295
- 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.` }));
1296
1347
  };
1297
- const timer = setTimeout(() => finish('FAIL', `wiring did not answer initialize within ${timeoutMs / 1000}s: \`${quoted}\``, 'Re-run `holmes-kit init` in the target to rewrite the wiring, then re-run doctor.'), timeoutMs);
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);
1298
1349
  try {
1299
- 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);
1300
1352
  }
1301
1353
  catch (e) {
1302
1354
  finish('FAIL', `wiring could not be spawned: \`${quoted}\` — ${e.message}`, spawnFailFix(e));
@@ -1340,7 +1392,8 @@ cwd) {
1340
1392
  function mcpHandshakeCheck(packageRoot, timeoutMs = 15000) {
1341
1393
  return new Promise((resolve) => {
1342
1394
  const mcpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'holmes-doctor-mcp-'));
1343
- 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;
1344
1397
  let pending = ''; // the unterminated tail of the stdout stream, never the whole history
1345
1398
  let err = '';
1346
1399
  let done = false;
@@ -1353,30 +1406,29 @@ function mcpHandshakeCheck(packageRoot, timeoutMs = 15000) {
1353
1406
  return;
1354
1407
  done = true;
1355
1408
  clearTimeout(timer);
1356
- // Escalate (round-9): SIGTERM alone leaves a wedged server running with doctor's pipes open.
1357
- child.kill();
1358
- // round-10: an unref'd timer never fires in a doctor run that ends in ~0.4s, so the escalation
1359
- // was decoration. The timer holds the loop for its 500ms and is cleared the moment the child
1360
- // actually exits, so a healthy run pays nothing.
1361
- const hard = setTimeout(() => { try {
1362
- child.kill('SIGKILL');
1363
- }
1364
- catch { /* already gone */ } }, 500);
1365
- child.once('exit', () => clearTimeout(hard));
1366
- child.stdout.destroy();
1367
- child.stderr.destroy();
1368
- child.stdin.destroy();
1369
- child.unref?.();
1370
- // Cleanup HERE, not on 'close' — doctor's process can exit before a close handler runs,
1371
- // stranding the isolated cwd (round-6 §7e measured the leak). A signal death still strands it;
1372
- // `cleanupOnSignal` below covers that (round-9).
1373
- try {
1374
- fs.rmSync(mcpCwd, { recursive: true, force: true });
1375
- }
1376
- catch { /* tmp cleaner's job */ }
1377
- 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
+ });
1378
1422
  };
1379
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
+ }
1380
1432
  // EPIPE arrives as an ASYNC 'error' event, not a synchronous throw — the try/catch below never
1381
1433
  // sees it, and an unhandled stream error would crash doctor itself (round-1 REQ-192).
1382
1434
  child.stdin.on('error', () => { });
@@ -1404,7 +1456,7 @@ function mcpHandshakeCheck(packageRoot, timeoutMs = 15000) {
1404
1456
  child.stdin.write(JSON.stringify(o) + '\n');
1405
1457
  }
1406
1458
  catch { /* server gone */ } };
1407
- 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);
1408
1460
  // Line-oriented and stateful. The old shape re-scanned the whole accumulated buffer on every
1409
1461
  // chunk (quadratic, and it re-fired past triggers) and judged `tools` by
1410
1462
  // `typeof result.tools.length === 'number'` — which a NON-ARRAY or an EMPTY list satisfies, so a
@@ -1600,9 +1652,22 @@ function semanticTierVerdict(t) {
1600
1652
  };
1601
1653
  }
1602
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) {
1603
1667
  return {
1604
1668
  level: 'PASS',
1605
- 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%.',
1606
1671
  };
1607
1672
  }
1608
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;
@@ -1,8 +1,48 @@
1
1
  "use strict";
2
- // @implements A-SPEC-220.3
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
3
35
  Object.defineProperty(exports, "__esModule", { value: true });
4
36
  exports.posixOnly = posixOnly;
5
37
  exports.posixOnlyNonRoot = posixOnlyNonRoot;
38
+ exports.foldsCase = foldsCase;
39
+ exports.posixOnlyWithCommand = posixOnlyWithCommand;
40
+ exports.win32Only = win32Only;
41
+ const fs = __importStar(require("node:fs"));
42
+ const path = __importStar(require("node:path"));
43
+ const node_crypto_1 = require("node:crypto");
44
+ // @implements A-SPEC-220.3
45
+ // @implements A-SPEC-597
6
46
  /**
7
47
  * A test that cannot run here says so, instead of returning early and being counted as a pass.
8
48
  *
@@ -36,3 +76,88 @@ function posixOnlyNonRoot(reason, platform = process.platform, uid = typeof proc
36
76
  const base = posixOnly(reason, platform);
37
77
  return uid === 0 ? it.skip : base;
38
78
  }
79
+ /**
80
+ * @implements A-SPEC-597
81
+ * Whether THIS directory's filesystem folds case — measured, never inferred from the platform.
82
+ *
83
+ * macOS defaults to case-insensitive APFS and Linux to case-sensitive ext4, but neither is a rule:
84
+ * a case-sensitive volume mounts fine on macOS, and `os.tmpdir()` need not share the repository's
85
+ * volume. The same reasoning already governs the write gate (`pre-tool-use.ts`), which probes the
86
+ * project root rather than trusting `process.platform`; a test must probe the directory it actually
87
+ * writes into.
88
+ *
89
+ * An unanswerable probe returns FALSE. Claiming "folds" when it cannot tell would let a
90
+ * folding-world assertion run — and pass vacuously — on a case-sensitive box; claiming "sensitive"
91
+ * merely runs the other assertion, which fails loudly if it is wrong. Be wrong toward the side that
92
+ * still produces information.
93
+ */
94
+ function foldsCase(dir) {
95
+ if (typeof dir !== 'string' || dir === '')
96
+ return false;
97
+ const probe = path.join(dir, `.case-probe-${process.pid}-${(0, node_crypto_1.randomUUID)()}`);
98
+ try {
99
+ fs.writeFileSync(probe, '');
100
+ const flipped = path.join(dir, path.basename(probe).toUpperCase());
101
+ return fs.existsSync(flipped);
102
+ }
103
+ catch {
104
+ return false;
105
+ }
106
+ finally {
107
+ // The probe leaves no trace, on every path out of here.
108
+ try {
109
+ fs.rmSync(probe, { force: true });
110
+ }
111
+ catch { /* nothing was created */ }
112
+ }
113
+ }
114
+ /** PATH lookup without a shell — spawning one to ask about a command is a cost and a surface. */
115
+ function onPath(command) {
116
+ if (typeof command !== 'string' || command === '')
117
+ return false;
118
+ if (command.includes('/') || command.includes('\\'))
119
+ return fs.existsSync(command);
120
+ const sep = process.platform === 'win32' ? ';' : ':';
121
+ const exts = process.platform === 'win32' ? (process.env.PATHEXT ?? '.EXE;.CMD;.BAT').split(';') : [''];
122
+ for (const dir of (process.env.PATH ?? '').split(sep)) {
123
+ if (dir === '')
124
+ continue;
125
+ for (const ext of exts) {
126
+ try {
127
+ if (fs.existsSync(path.join(dir, command + ext)))
128
+ return true;
129
+ }
130
+ catch { /* unreadable PATH entry */ }
131
+ }
132
+ }
133
+ return false;
134
+ }
135
+ /**
136
+ * @implements A-SPEC-597
137
+ * As `posixOnlyNonRoot`, and additionally skipped when `command` is not on PATH.
138
+ *
139
+ * `chflags` is macOS/BSD only. Guarded by platform and uid alone, the assertion that uses it RAN on
140
+ * Linux and died with ENOENT — which reads as a defect in the code under test rather than as a tool
141
+ * this machine does not have. Availability of the tool is knowable at definition time, exactly like
142
+ * the platform and the uid, so it belongs in the same choice.
143
+ */
144
+ function posixOnlyWithCommand(reason, command, platform = process.platform, uid = typeof process.getuid === 'function' ? process.getuid() : undefined, lookup = onPath) {
145
+ const base = posixOnlyNonRoot(reason, platform, uid); // reason is validated here, before anything else
146
+ return lookup(command) ? base : it.skip;
147
+ }
148
+ /**
149
+ * @implements A-SPEC-255
150
+ * The mirror of `posixOnly`: a test whose SUBJECT only exists on Windows.
151
+ *
152
+ * Without this, the Windows work had nowhere to put such a test and improvised
153
+ * `(process.platform === 'win32' ? it : it.skip)('…')` — a skip with no stated reason, which is the
154
+ * silence `posixOnly` was written to end, in different syntax. NTFS ACLs, PowerShell 5.1 parsing and
155
+ * 8.3 short names are real subjects; they simply cannot be exercised on a POSIX box, and saying so
156
+ * in the report is the honest form. `reason` is required for the same reason it is required there.
157
+ */
158
+ function win32Only(reason, platform = process.platform) {
159
+ if (typeof reason !== 'string' || reason.trim().length === 0) {
160
+ throw new Error('win32Only(reason): 사유 없는 스킵은 문법만 바뀐 침묵입니다 — 왜 이 단언이 POSIX에서 무의미한지 적으십시오.');
161
+ }
162
+ return platform === 'win32' ? it : it.skip;
163
+ }