@holmes-lab/holmes-kit 0.1.16 → 0.1.18

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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,35 @@ 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.1.18] - 2026-08-23
9
+
10
+ ### Fixed
11
+ - **The npx MCP wiring actually starts the server (REQ-259)**: `init` on an npm-installed package wrote `npx -y @holmes-lab/holmes-kit@<ver> holmes-mcp`, which npx resolves to the package-name-matching *default* binary (`holmes-kit`) with `holmes-mcp` as its argument — exit 1, so **every npm-installed wiring since the 0.1.15 npx pin launched a dead MCP server** (`CONNECTION_CLOSED` in the harness). The wiring now rides `-p` (`npx -y -p <pkg>@<ver> holmes-mcp`), the launcher test spawns the generated form against a locally packed tarball (with the pre-fix form kept as a failing control), and `doctor` gains **`mcp wiring spawn`** — it runs the target's `.mcp.json` command verbatim inside a 30s bound and names a dead wiring FAIL with the re-init remediation. Existing projects: re-run `holmes-kit init` (doctor will point at the dead wiring).
12
+ - **`serverInfo.version` reports the real package version** — it was hardcoded to `0.1.0`, blinding client-side drift diagnosis.
13
+
14
+ ### Changed
15
+ - **`approve` without a TTY names the next command (REQ-260)**: the implicit non-TTY fallback (e.g. Claude Code's `!` shell) used to print the pending list and exit 0 silently; it now appends runnable `--grant` / `--deny` / `--ask` examples carrying the first real pending id. Explicit `--list` output is unchanged for scripts.
16
+
17
+ ## [0.1.17] - 2026-08-23
18
+
19
+ The two `main` lines — the Windows-compatibility line (12 commits, verified on Windows 11 and
20
+ independently re-verified on macOS 26.5/APFS: 156/156 suites, gate probes over the `/tmp` →
21
+ `/private/tmp` link spelling all as expected) and the mac release line (0.1.10–0.1.16) — are merged
22
+ into one linear history in this release. Full suite on the merged tree: 191/191.
23
+
24
+ ### Added
25
+ - **Windows Bootstrap Installer (REQ-253)**: `scripts/install.ps1`, a pure Windows PowerShell 5.1 script that runs *before* `npm install` and fixes what npm cannot — relocates out of protected directories (an elevated PowerShell opens in `C:\WINDOWS\System32`, where `npm install` fails with `EPERM`), substitutes a per-user `--prefix` when npm's global prefix is under `C:\Program Files\nodejs` (a second, distinct `EPERM`), gates on `engines.node`, and classifies native-build failures (`msbuild` / `python` / `node-gyp` / `network`) into the one `winget` command that fixes them. Non-interactive, idempotent, no persistent changes. Shipped in the package and documented as the recommended Windows install path. Measured end-to-end on a Windows 11 machine from `System32`: exit 0, no user input.
26
+
27
+ ### Fixed
28
+ - **Path identity is canonical on every filesystem**: `resolveProjectRoot`, the containment gate, the store/derived checks and `resolveRmSignals` all canonicalise through `canonicalPath` (`fs.realpathSync.native`), closing an 8.3-short-name bypass on Windows while keeping the macOS `/tmp → /private/tmp` and case-folding behaviour — measured on both platforms, including mixed link/real spellings against the shipped hook.
29
+ - **Drive-letter paths are absolute**: project containment no longer asks "is this POSIX-absolute" while meaning "is this absolute", so `C:\…` targets are governed on native Windows.
30
+ - **Antigravity hook commands quote their paths**: a space in the install path no longer splits the command and silently disables the gate.
31
+ - **Parser suites run on Windows**: `tsc`/`node` are spawned via `process.execPath`, `dist` is rebuilt only when stale, and `__test-support__` is excluded from `dist` and the npm package.
32
+ - **Suites repaired for fresh checkouts**: the read-compat baseline fixture is tracked through a `.gitignore` re-inclusion chain, and `packaging.test.ts` asserts the CHANGELOG section of the *current* version instead of a stale literal.
33
+
34
+ ### Changed
35
+ - **Spec renumbering**: the Windows installer slice had claimed REQ/H/A/T-SPEC-220, an id family the mac line had already assigned to *Windows Native Tier 1 Governance Parity*; the installer family is renumbered to REQ/H/A/T-SPEC-253 and re-sealed.
36
+
8
37
  ## [0.1.16] - 2026-08-23
9
38
 
10
39
  Predictable spec numbering — enforced at the door, and the corpus cleaned to match.
package/README.md CHANGED
@@ -56,6 +56,16 @@ Holmes-Kit prioritizes **CLI-based AI Coding Agents** where OS-level process hoo
56
56
  The same wrong command was run three times by a real adopter before the right one; a table beats
57
57
  prose read top-to-bottom.
58
58
 
59
+ **Windows (recommended)** — a bootstrap installer that runs *before* npm and fixes what npm cannot: it relocates out of protected folders (an elevated PowerShell opens in `C:\WINDOWS\System32`, where `npm install` fails with `EPERM`), checks the Node.js version, and, if a native module fails to build, prints the one `winget` command that installs the missing C++ toolchain instead of raw compiler output. Pure Windows PowerShell 5.1; no prompts; nothing persistent is changed.
60
+ ```powershell
61
+ # From a downloaded copy of the package (e.g. after `npm pack`, or from a checkout):
62
+ powershell -NoProfile -ExecutionPolicy Bypass -File scripts\install.ps1
63
+
64
+ # One-liner (once hosted):
65
+ # irm https://<host>/install.ps1 | iex
66
+ ```
67
+ Add `-DryRun` to see what it would do without installing. Exit codes: `0` ok / already installed · `2` bad argument · `3` Node.js too old · `4` npm failed (remediation printed) · `5` installed but not on `PATH` (prefix printed).
68
+
59
69
  | Which situation are you in? | Privileges | Command |
60
70
  |---|---|---|
61
71
  | **Using it in one project** (most people) | none | `npm install --save-dev @holmes-lab/holmes-kit` |
package/dist/.build-id CHANGED
@@ -1 +1 @@
1
- 3c84046-mt5fni4h
1
+ f8c9769-mt5t8gnu
@@ -93,19 +93,25 @@ const mcpConfig = (packageRoot, specsDir, launcher) => {
93
93
  * (Claude 배선이 `guardrail` 모드에서 좁은 매처를 쓰는 것과 다른 선택인데, 그쪽은 그 좁힘이
94
94
  * 무엇을 뜻하는지 문서화된 모드 선택이고 여기는 기본 배선이기 때문이다.)
95
95
  */
96
+ // A hook `command` is ONE shell string, so the path must be quoted — `buildHookPlan` in init.ts has
97
+ // always done this for the Claude wiring, and this one did not. Measured 2026-08-23 on a machine
98
+ // whose home directory contains a space: the shell split the path, the hook never ran, and the
99
+ // Antigravity gate was silently off. (The MCP config above is different: it passes `args` as an
100
+ // array, where quoting would put literal quote characters into the path.)
101
+ const hookCommand = (packageRoot, script) => `node "${path.join(packageRoot, 'bin', script)}"`;
96
102
  const hooksJson = (packageRoot) => `${JSON.stringify({
97
103
  'holmes-kit': {
98
104
  PreToolUse: [{
99
105
  matcher: '*',
100
106
  hooks: [{
101
107
  type: 'command',
102
- command: `node ${path.join(packageRoot, 'bin', 'holmes-hook-antigravity.js')}`,
108
+ command: hookCommand(packageRoot, 'holmes-hook-antigravity.js'),
103
109
  timeout: 30,
104
110
  }],
105
111
  }],
106
112
  Stop: [{
107
113
  type: 'command',
108
- command: `node ${path.join(packageRoot, 'bin', 'holmes-stop-antigravity.js')}`,
114
+ command: hookCommand(packageRoot, 'holmes-stop-antigravity.js'),
109
115
  timeout: 30,
110
116
  }],
111
117
  },
@@ -30,6 +30,17 @@ export declare function holdRequest(root: string, id: string, question: string,
30
30
  * teaches syntax, humans start typing it.
31
31
  */
32
32
  export declare function renderPending(state: QueueState): string;
33
+ /**
34
+ * The non-TTY fallback's next-command hint. Pure.
35
+ *
36
+ * @implements A-SPEC-260
37
+ * Without a TTY the interactive loop cannot run, and the silent list-then-exit-0 left the operator
38
+ * with no idea what to type (measured 2026-08-23 — Claude Code's `!` runs with a non-TTY stdin).
39
+ * The examples carry the FIRST pending id read from the queue, never a placeholder the operator
40
+ * must transcribe: [noguess]. An empty queue yields an empty hint — nothing to act on, nothing to
41
+ * advise. `renderPending` itself stays hint-free: the explicit `--list` output is for scripts.
42
+ */
43
+ export declare function renderNonTtyHint(state: QueueState): string;
33
44
  export interface ApproveIO {
34
45
  print: (s: string) => void;
35
46
  /** Ask one question, resolve with the raw answer. Injected so tests script the conversation. */
@@ -37,6 +37,7 @@ exports.grantRequest = grantRequest;
37
37
  exports.denyRequest = denyRequest;
38
38
  exports.holdRequest = holdRequest;
39
39
  exports.renderPending = renderPending;
40
+ exports.renderNonTtyHint = renderNonTtyHint;
40
41
  exports.runInteractive = runInteractive;
41
42
  // @implements A-SPEC-246
42
43
  const fs = __importStar(require("node:fs"));
@@ -154,6 +155,27 @@ function renderPending(state) {
154
155
  lines.push(`(큐에 읽을 수 없는 줄 ${state.malformedLines}건 — 손상 여부를 확인하십시오)`);
155
156
  return lines.join('\n');
156
157
  }
158
+ /**
159
+ * The non-TTY fallback's next-command hint. Pure.
160
+ *
161
+ * @implements A-SPEC-260
162
+ * Without a TTY the interactive loop cannot run, and the silent list-then-exit-0 left the operator
163
+ * with no idea what to type (measured 2026-08-23 — Claude Code's `!` runs with a non-TTY stdin).
164
+ * The examples carry the FIRST pending id read from the queue, never a placeholder the operator
165
+ * must transcribe: [noguess]. An empty queue yields an empty hint — nothing to act on, nothing to
166
+ * advise. `renderPending` itself stays hint-free: the explicit `--list` output is for scripts.
167
+ */
168
+ function renderNonTtyHint(state) {
169
+ if (state.pending.length === 0)
170
+ return '';
171
+ const id = state.pending[0].id;
172
+ return [
173
+ '대화형 결정은 TTY에서만 동작합니다 — 이 셸에서는 아래 비대화형 명령을 사용하십시오:',
174
+ ` holmes-kit approve --grant ${id}`,
175
+ ` holmes-kit approve --deny ${id} --reason "<사유>"`,
176
+ ` holmes-kit approve --ask ${id} --question "<질문>"`,
177
+ ].join('\n');
178
+ }
157
179
  /**
158
180
  * The interactive loop — one entry at a time, one letter per decision.
159
181
  *
@@ -487,6 +487,38 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
487
487
  }
488
488
  }
489
489
  catch { /* .mcp.json 부재/파싱 실패 → 이 점검 없이 진행 */ }
490
+ // @implements A-SPEC-259 — the wiring is judged by SPAWNING it. String comparison is exactly how
491
+ // the dead `npx -y <pkg>@<ver> holmes-mcp` form (npx resolves the default bin and hands
492
+ // `holmes-mcp` to it as an argument) passed the launcher test, this doctor's own handshake check
493
+ // (which starts the server directly, not through the wiring), and the release tarball probe —
494
+ // measured 2026-08-23. A node wiring is judged on path existence (a source checkout must not pay
495
+ // a spawn), an absent .mcp.json is skipped by name, and the spawn is BOUNDED: it succeeds or
496
+ // fails inside its deadline, never hangs (REQ-258's lesson, applied from this check onward).
497
+ {
498
+ const mcpPath = path.join(target, '.mcp.json');
499
+ if (!fs.existsSync(mcpPath)) {
500
+ add('mcp wiring spawn', 'PASS', 'skipped — no .mcp.json in the target: an absent wiring is not a dead one');
501
+ }
502
+ else {
503
+ try {
504
+ const server = JSON.parse(fs.readFileSync(mcpPath, 'utf8'))
505
+ .mcpServers?.[init_1.SERVER_NAME];
506
+ if (!server || typeof server.command !== 'string' || !Array.isArray(server.args)) {
507
+ add('mcp wiring spawn', 'WARN', `.mcp.json carries no runnable ${init_1.SERVER_NAME} entry`, 'Run `holmes-kit init` in the target to write the wiring.');
508
+ }
509
+ else if (server.command === 'node') {
510
+ const bin = server.args[0] ?? '';
511
+ add('mcp wiring spawn', fs.existsSync(bin) ? 'PASS' : 'FAIL', fs.existsSync(bin) ? `node wiring resolves: ${bin}` : `node wiring points at a missing file: ${bin}`, fs.existsSync(bin) ? undefined : 'Run `holmes-kit init` in the target to rewire the absolute path.');
512
+ }
513
+ else {
514
+ checks.push(await wiringSpawnCheck(server.command, server.args));
515
+ }
516
+ }
517
+ catch {
518
+ add('mcp wiring spawn', 'WARN', 'could not read .mcp.json', 'Run `holmes-kit init` in the target to rewrite the wiring.');
519
+ }
520
+ }
521
+ }
490
522
  // @implements A-SPEC-193 — 배선된 하네스마다 그 하네스의 배선을 검사한다. 배선되지 않은
491
523
  // 하네스는 진단하지 않는다(없는 것을 결함이라 부르면 doctor 가 소음이 된다).
492
524
  const agyHooks = path.join(target, '.agents', 'hooks.json');
@@ -616,6 +648,54 @@ function cleanupOnSignal(dir) {
616
648
  * and closes stdin makes the server exit before answering (a false FAIL this check produced on its
617
649
  * very first run against a healthy server).
618
650
  */
651
+ // @implements A-SPEC-259
652
+ // Spawn the TARGET's wiring verbatim and require initialize inside the deadline. Unlike
653
+ // `mcpHandshakeCheck` below (which proves this INSTALL can serve), this proves the WIRING the
654
+ // harness will actually run reaches a server — the gap the dead npx form lived in.
655
+ function wiringSpawnCheck(command, args, timeoutMs = 30000) {
656
+ const quoted = `${command} ${args.join(' ')}`;
657
+ return new Promise((resolve) => {
658
+ let done = false;
659
+ let out = '';
660
+ let child;
661
+ const finish = (level, detail, fix) => {
662
+ if (done)
663
+ return;
664
+ done = true;
665
+ clearTimeout(timer);
666
+ try {
667
+ child.kill();
668
+ }
669
+ catch { /* already gone */ }
670
+ resolve({ name: 'mcp wiring spawn', level, detail, fix });
671
+ };
672
+ 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);
673
+ try {
674
+ child = (0, node_child_process_1.spawn)(command, args, { stdio: ['pipe', 'pipe', 'pipe'] });
675
+ }
676
+ catch (e) {
677
+ finish('FAIL', `wiring could not be spawned: \`${quoted}\` — ${e.message}`, 'Re-run `holmes-kit init` in the target to rewrite the wiring.');
678
+ return;
679
+ }
680
+ child.on('error', (e) => finish('FAIL', `wiring could not be spawned: \`${quoted}\` — ${e.message}`, 'Re-run `holmes-kit init` in the target to rewrite the wiring.'));
681
+ child.on('exit', (code) => finish('FAIL', `wiring exited (code ${code}) before answering initialize: \`${quoted}\``, 'Re-run `holmes-kit init` in the target to rewrite the wiring.'));
682
+ child.stdin?.on('error', () => { });
683
+ child.stdout?.on('data', (d) => {
684
+ out += String(d);
685
+ for (const line of out.split('\n')) {
686
+ try {
687
+ const m = JSON.parse(line);
688
+ if (m?.result?.serverInfo) {
689
+ finish('PASS', `wiring reaches a live server (v${m.result.serverInfo.version ?? '?'}): \`${quoted}\``);
690
+ return;
691
+ }
692
+ }
693
+ catch { /* partial line */ }
694
+ }
695
+ });
696
+ child.stdin?.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'holmes-doctor', version: '1' } } }) + '\n');
697
+ });
698
+ }
619
699
  function mcpHandshakeCheck(packageRoot, timeoutMs = 15000) {
620
700
  return new Promise((resolve) => {
621
701
  const mcpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'holmes-doctor-mcp-'));
@@ -359,7 +359,7 @@ async function main(argv) {
359
359
  // @implements A-SPEC-246 — the surface where a human decides. Interactive on a TTY with no
360
360
  // decision flags; every interactive decision has a flag equivalent so a CI or remote operator
361
361
  // is never locked out.
362
- const { grantRequest, denyRequest, holdRequest, renderPending, runInteractive } = require('./approve');
362
+ const { grantRequest, denyRequest, holdRequest, renderPending, renderNonTtyHint, runInteractive } = require('./approve');
363
363
  const { readQueue } = require('../governance/approval-queue');
364
364
  const root = typeof flags.target === 'string' ? path.resolve(flags.target) : process.cwd();
365
365
  const actor = (() => { try {
@@ -402,10 +402,18 @@ async function main(argv) {
402
402
  process.stdout.write(r.ok ? '✓ 보류 — 질문이 다음 거부 문면에 실립니다\n' : `✗ ${r.reason}\n`);
403
403
  return r.ok ? 0 : 1;
404
404
  }
405
- if (flags.list || !process.stdin.isTTY) {
405
+ if (flags.list) {
406
406
  process.stdout.write(renderPending(readQueue(root)) + '\n');
407
407
  return 0;
408
408
  }
409
+ // @implements A-SPEC-260 — the implicit non-TTY fallback tells the operator the next command,
410
+ // with the real pending id filled in; the explicit --list above stays script-clean.
411
+ if (!process.stdin.isTTY) {
412
+ const state = readQueue(root);
413
+ const hint = renderNonTtyHint(state);
414
+ process.stdout.write(renderPending(state) + (hint ? '\n\n' + hint : '') + '\n');
415
+ return 0;
416
+ }
409
417
  const rl = require('node:readline/promises').createInterface({ input: process.stdin, output: process.stdout });
410
418
  void runInteractive(root, {
411
419
  print: (t) => process.stdout.write(t + '\n'),
@@ -52,7 +52,12 @@ exports.MCP_PACKAGE = '@holmes-lab/holmes-kit';
52
52
  */
53
53
  function mcpServerEntry(opts) {
54
54
  if (opts.mode === 'npx') {
55
- return { command: 'npx', args: ['-y', `${exports.MCP_PACKAGE}@${opts.version}`, 'holmes-mcp'] };
55
+ // @implements A-SPEC-259 the package must ride `-p` with the command named separately.
56
+ // `npx <pkg> holmes-mcp` runs the package-name-matching DEFAULT bin (holmes-kit) and hands
57
+ // `holmes-mcp` to it as an argument: measured 2026-08-23, exit 1 "unknown command" — every
58
+ // npm-installed wiring since the npx pin was a dead server. The `-p` form is the one that
59
+ // reached initialize in the same measurement.
60
+ return { command: 'npx', args: ['-y', '-p', `${exports.MCP_PACKAGE}@${opts.version}`, 'holmes-mcp'] };
56
61
  }
57
62
  return { command: 'node', args: [opts.mcpBinPath] };
58
63
  }
@@ -90,11 +90,14 @@ function resolveRmSignals(command, cwd) {
90
90
  let repoRoot;
91
91
  let baseReal;
92
92
  try {
93
- repoRoot = (0, node_child_process_1.execFileSync)('git', ['rev-parse', '--show-toplevel'], { cwd, stdio: ['ignore', 'pipe', 'ignore'], env: (0, root_1.cleanSubprocessEnv)() })
94
- .toString().trim();
95
- // git realpaths the toplevel (resolves symlinks like macOS /var -> /private/var); resolve the
96
- // cwd the same way so in-project containment comparisons don't spuriously fail on a symlinked path.
97
- baseReal = fs.realpathSync(cwd);
93
+ // Both sides through the ONE canonicaliser. git prints the top-level symlink-resolved, with `/`
94
+ // separators on Windows and the long name; the JS realpath kept 8.3 aliases and `path.sep`
95
+ // comparisons below never matched `C:/…` against `C:\…`. Measured 2026-08-23: `rm -rf build`
96
+ // in a real repo read as inProject=false, so a gitignored build dir was escalated as if it were
97
+ // outside the project — fail-safe in direction, wrong in fact, and a needless HITL every time.
98
+ repoRoot = (0, root_1.canonicalPath)((0, node_child_process_1.execFileSync)('git', ['rev-parse', '--show-toplevel'], { cwd, stdio: ['ignore', 'pipe', 'ignore'], env: (0, root_1.cleanSubprocessEnv)() })
99
+ .toString().trim());
100
+ baseReal = (0, root_1.canonicalPath)(cwd);
98
101
  }
99
102
  catch {
100
103
  return undefined; // not a git repo / git unavailable -> static fallback
@@ -111,7 +114,7 @@ function resolveRmSignals(command, cwd) {
111
114
  // lexical path (nothing to delete yet); realpath errors fail safe to the lexical value.
112
115
  try {
113
116
  if (fs.existsSync(resolved))
114
- resolved = fs.realpathSync(resolved);
117
+ resolved = (0, root_1.canonicalPath)(resolved);
115
118
  }
116
119
  catch { /* keep lexical */ }
117
120
  const inProject = resolved === repoRoot || resolved.startsWith(repoRoot + path.sep);
@@ -615,12 +618,17 @@ function evaluateHook(input, specsDir, opts) {
615
618
  // path under a symlinked ancestor (macOS `/tmp` → `/private/tmp` is the ordinary case) switched
616
619
  // the gate off. A bound that runs out must fail toward the gate, not away from it — so the walk
617
620
  // is not bounded by a count at all; it ends at the filesystem root, which always terminates.
621
+ // `canonicalPath` (realpathSync.native), not the JS realpath: the root arrives canonicalised by
622
+ // root.ts, which expands Windows 8.3 aliases; the JS realpath does not. Measured 2026-08-23 on
623
+ // the built hook — an in-project target spelled through `%TEMP%`'s alias (`C:\Users\SUNGNA~1\…`)
624
+ // resolved to a string the long-name root did not contain, and took the "outside → allow" exit
625
+ // below. Both sides must go through the one canonicaliser, or an alias is a free pass.
618
626
  const realOf = (abs) => {
619
627
  let head = abs;
620
628
  const tail = [];
621
629
  for (;;) {
622
630
  try {
623
- return [fs.realpathSync(head).replace(/\\/g, '/'), ...tail].join('/');
631
+ return [(0, root_1.canonicalPath)(head).replace(/\\/g, '/'), ...tail].join('/');
624
632
  }
625
633
  catch { /* walk up */ }
626
634
  const parent = path.dirname(head);
@@ -262,13 +262,13 @@ function boundNonceLedger(store) {
262
262
  // `--specs-dir docs/specs` an ordinary review_record could still plant `docs/specs/.ax` and move
263
263
  // every consumed nonce, every audit line, and spec_approve's notion of "this project" with it.
264
264
  const storeReal = (() => { try {
265
- return fs.realpathSync(store.specsRoot);
265
+ return (0, root_2.canonicalPath)(store.specsRoot);
266
266
  }
267
267
  catch {
268
268
  return store.specsRoot;
269
269
  } })();
270
270
  const derivedReal = (() => { try {
271
- return fs.realpathSync(derived);
271
+ return (0, root_2.canonicalPath)(derived);
272
272
  }
273
273
  catch {
274
274
  return derived;
@@ -301,13 +301,13 @@ function boundFindingsLedger(store, root) {
301
301
  try {
302
302
  const derived = projectRootOf(store.specsRoot);
303
303
  const storeReal = (() => { try {
304
- return fs.realpathSync(store.specsRoot);
304
+ return (0, root_2.canonicalPath)(store.specsRoot);
305
305
  }
306
306
  catch {
307
307
  return store.specsRoot;
308
308
  } })();
309
309
  const derivedReal = (() => { try {
310
- return fs.realpathSync(derived);
310
+ return (0, root_2.canonicalPath)(derived);
311
311
  }
312
312
  catch {
313
313
  return derived;
@@ -12,7 +12,19 @@ const validate_args_1 = require("./validate-args");
12
12
  // @implements A-SPEC-100.2
13
13
  const store = new spec_store_1.LocalMarkdownRepository(process.env.HOLMES_SPECS ?? '.ax/specs');
14
14
  const handlers = (0, handlers_1.makeHandlers)(store);
15
- const server = new index_js_1.Server({ name: 'holmes-kit', version: '0.1.0' }, { capabilities: { tools: {} } });
15
+ // @implements A-SPEC-259 the advertised version is the package's own, not a literal that froze at
16
+ // 0.1.0: a hardcoded serverInfo.version blinds any client-side drift diagnosis.
17
+ const PKG_VERSION = (() => {
18
+ try {
19
+ // dist/holmes/mcp/server.js → three levels up is the package root, same as the source layout.
20
+ const raw = require('node:fs').readFileSync(require('node:path').join(__dirname, '..', '..', '..', 'package.json'), 'utf8');
21
+ return JSON.parse(raw).version ?? '0.0.0';
22
+ }
23
+ catch {
24
+ return '0.0.0';
25
+ }
26
+ })();
27
+ const server = new index_js_1.Server({ name: 'holmes-kit', version: PKG_VERSION }, { capabilities: { tools: {} } });
16
28
  const fullProfile = process.env.HOLMES_MCP_PROFILE === 'full';
17
29
  // Register each handler as a tool with its real typed inputSchema so MCP
18
30
  // clients can marshal complex (array/object) arguments; fall back to a
@@ -189,7 +189,12 @@ class GitChangeSource {
189
189
  this.base = base;
190
190
  this.head = head;
191
191
  this.topLevel = (0, root_1.resolveProjectRoot)(root).git?.topLevel;
192
- if (this.topLevel && fs.realpathSync(path.resolve(root)) !== this.topLevel) {
192
+ // The GIVEN root must be the top-level — `changes()` runs git against it, so a subdirectory
193
+ // (even one that walks up to the same `.ax`) would tag paths against the wrong base. Both sides
194
+ // go through ONE canonicaliser: the old `fs.realpathSync(root)` here was the JS realpath, which
195
+ // keeps Windows 8.3 aliases the top-level side had already expanded — measured 2026-08-23, the
196
+ // same directory spelled `C:\AI_PRO~1\…` threw as a mismatch.
197
+ if (this.topLevel && (0, root_1.canonicalPath)(path.resolve(root)) !== this.topLevel) {
193
198
  throw new Error(`GitChangeSource: root must be the git top-level (got ${root}, top-level is ${this.topLevel})`);
194
199
  }
195
200
  }
@@ -31,4 +31,17 @@ export interface ProjectRoot {
31
31
  }
32
32
  /** The marker directory that makes a directory a Holmes-Kit project. */
33
33
  export declare const MARKER = ".ax";
34
+ /**
35
+ * The one canonical spelling of an existing path.
36
+ *
37
+ * `fs.realpathSync.native`, not `fs.realpathSync`. The JS implementation resolves symlinks only;
38
+ * on Windows it leaves 8.3 short-name aliases (`C:\Users\SUNGNA~1`, which is what `%TEMP%` and
39
+ * `os.tmpdir()` hand out) exactly as spelled, while `git rev-parse --show-toplevel` prints the
40
+ * long name. Measured 2026-08-23: `resolveProjectRoot('C:/AI_PRO~1/AX-COD~1')` reported
41
+ * `root: C:\AI_PRO~1\AX-COD~1` beside `git.topLevel: C:\ai_project\AX-Coding-Project` for ONE
42
+ * directory, and GitChangeSource refused it as a mismatch. The native call expands both symlinks
43
+ * and 8.3 names (a strict superset, same ENOENT behaviour), so `root` keeps the promise its type
44
+ * makes: one spelling per directory, the spelling every path key is built from.
45
+ */
46
+ export declare const canonicalPath: (p: string) => string;
34
47
  export declare function resolveProjectRoot(dir: string): ProjectRoot;
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.MARKER = void 0;
36
+ exports.canonicalPath = exports.MARKER = void 0;
37
37
  exports.cleanSubprocessEnv = cleanSubprocessEnv;
38
38
  exports.resolveProjectRoot = resolveProjectRoot;
39
39
  // @implements A-SPEC-205
@@ -56,6 +56,21 @@ function cleanSubprocessEnv(env = process.env) {
56
56
  }
57
57
  /** The marker directory that makes a directory a Holmes-Kit project. */
58
58
  exports.MARKER = '.ax';
59
+ /**
60
+ * The one canonical spelling of an existing path.
61
+ *
62
+ * `fs.realpathSync.native`, not `fs.realpathSync`. The JS implementation resolves symlinks only;
63
+ * on Windows it leaves 8.3 short-name aliases (`C:\Users\SUNGNA~1`, which is what `%TEMP%` and
64
+ * `os.tmpdir()` hand out) exactly as spelled, while `git rev-parse --show-toplevel` prints the
65
+ * long name. Measured 2026-08-23: `resolveProjectRoot('C:/AI_PRO~1/AX-COD~1')` reported
66
+ * `root: C:\AI_PRO~1\AX-COD~1` beside `git.topLevel: C:\ai_project\AX-Coding-Project` for ONE
67
+ * directory, and GitChangeSource refused it as a mismatch. The native call expands both symlinks
68
+ * and 8.3 names (a strict superset, same ENOENT behaviour), so `root` keeps the promise its type
69
+ * makes: one spelling per directory, the spelling every path key is built from.
70
+ */
71
+ const canonicalPath = (p) => fs.realpathSync.native(p);
72
+ exports.canonicalPath = canonicalPath;
73
+ const canonical = exports.canonicalPath;
59
74
  function gitTopLevel(dir) {
60
75
  try {
61
76
  // stderr silenced: "not a git repository" is an expected branch here, not a fault worth printing.
@@ -66,7 +81,7 @@ function gitTopLevel(dir) {
66
81
  }).trim();
67
82
  // git resolves symlinks in its output; realpath ours too so comparisons are like-for-like
68
83
  // (macOS /var -> /private/var otherwise makes an identical directory look like a mismatch).
69
- return out ? fs.realpathSync(out) : undefined;
84
+ return out ? canonical(out) : undefined;
70
85
  }
71
86
  catch {
72
87
  return undefined;
@@ -75,7 +90,7 @@ function gitTopLevel(dir) {
75
90
  function resolveProjectRoot(dir) {
76
91
  let start;
77
92
  try {
78
- start = fs.realpathSync(path.resolve(dir));
93
+ start = canonical(path.resolve(dir));
79
94
  }
80
95
  catch {
81
96
  // @implements A-SPEC-189 §7 (round 11) — this is a POINTED refusal about the caller's own
@@ -765,7 +765,7 @@ function renderDashboardHtml() {
765
765
  items.forEach(s => { statuses[s.status] = (statuses[s.status] || 0) + 1; });
766
766
  const statusSummary = Object.keys(statuses).map(k => escapeHtml(k) + ' × ' + statuses[k]).join(' · ');
767
767
  html += '<div class="req-card" style="margin-bottom:20px;">'
768
- + '<div class="req-header" onclick="toggleAccordion(\'legacy-body-' + escapeHtml(kind) + '\')">'
768
+ + '<div class="req-header" onclick="toggleAccordion(\\'legacy-body-' + escapeHtml(kind) + '\\')">'
769
769
  + '<div class="req-title-group"><span class="req-id-badge">' + escapeHtml(kind) + '</span>'
770
770
  + '<span class="req-title">' + items.length + ' unmapped document' + (items.length === 1 ? '' : 's') + '</span>'
771
771
  + '<span class="neighbor-rel badge-unmeasured">' + statusSummary + '</span></div>'
@@ -851,7 +851,7 @@ function renderDashboardHtml() {
851
851
  if (!label) {
852
852
  return '<td class="' + cls + ' stage-missing" title="No artifact for this stage">' + arrow + '— missing</td>';
853
853
  }
854
- const onclick = jumpId ? ' onclick="focusSpecInGraph(\'' + escapeHtml(String(jumpId)) + '\')"' : '';
854
+ const onclick = jumpId ? ' onclick="focusSpecInGraph(\\'' + escapeHtml(String(jumpId)) + '\\')"' : '';
855
855
  return '<td class="' + cls + '"' + onclick + '>' + arrow + icon + ' ' + escapeHtml(String(label)) + '</td>';
856
856
  }
857
857
  // REQ-Grouped Structured 6-Stage Pipeline Heatmap Matrix Renderer
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "@implements A-SPEC-209",
3
3
  "name": "@holmes-lab/holmes-kit",
4
- "version": "0.1.16",
4
+ "version": "0.1.18",
5
5
  "description": "Holmes-Kit — deterministic Agentic Software Engineering (ASE) harness with causal traceability (spec chain + D-CPG + RTM + phase guardrail)",
6
6
  "main": "dist/holmes/mcp/server.js",
7
7
  "types": "dist/holmes/mcp/server.d.ts",
@@ -13,6 +13,7 @@
13
13
  "bin/",
14
14
  "dist/",
15
15
  "playbooks/",
16
+ "scripts/install.ps1",
16
17
  "docs/install-guide.md",
17
18
  "CHANGELOG.md"
18
19
  ],
@@ -0,0 +1,297 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Holmes-Kit bootstrap installer for Windows. @implements A-SPEC-253
4
+ .DESCRIPTION
5
+ Runs BEFORE npm to fix what npm cannot: a protected CWD (an elevated PowerShell opens in
6
+ C:\WINDOWS\System32 and `npm install` dies there with EPERM), an unwritable global prefix
7
+ (C:\Program Files\nodejs), a too-old Node.js, and native-build failures that print compiler
8
+ noise instead of the one command that installs the toolchain. Then it runs
9
+ `npm install -g @holmes-lab/holmes-kit`. Windows PowerShell 5.1 only; no prompts; nothing
10
+ persistent is changed.
11
+ .PARAMETER DryRun Run every check, print the npm command that WOULD run, exit 0 without running it.
12
+ .PARAMETER Version Package version to install (default: latest).
13
+ .PARAMETER Prefix Optional npm --prefix for the global install.
14
+ .EXAMPLE
15
+ powershell -NoProfile -ExecutionPolicy Bypass -File scripts\install.ps1 [-DryRun] [-Version 0.1.9]
16
+ .NOTES
17
+ Exit codes: 0 ok / already installed, 2 argument error, 3 Node too old, 4 npm failed, 5 not on PATH.
18
+ #>
19
+ # Non-positional on purpose: otherwise `install.ps1 foo` binds `foo` to -Version and runs a REAL
20
+ # `npm install -g @holmes-lab/holmes-kit@foo` from a typo. Stray tokens land in $Rest -> exit 2.
21
+ [CmdletBinding(PositionalBinding = $false)]
22
+ param(
23
+ [Parameter(Mandatory = $false)][switch]$DryRun,
24
+ [Parameter(Mandatory = $false)][string]$Version = 'latest',
25
+ [Parameter(Mandatory = $false)][string]$Prefix,
26
+ [Parameter(Mandatory = $false, ValueFromRemainingArguments = $true)][string[]]$Rest
27
+ )
28
+
29
+ Set-StrictMode -Version 2.0
30
+ $ErrorActionPreference = 'Stop'
31
+
32
+ # Mirrors package.json#engines.node (">=20"). Bump both together.
33
+ $script:NodeMajorFloor = 20
34
+ $script:PackageName = '@holmes-lab/holmes-kit'
35
+ $script:BinName = 'holmes-kit'
36
+
37
+ # Failure classifier. Pure: npm's combined output in, one token out. Precedence is fixed so the
38
+ # most ACTIONABLE cause wins when several signatures appear in the same transcript - an EPERM in a
39
+ # protected directory also drags node-gyp down with it, and telling the user to install MSVC
40
+ # would send them the wrong way.
41
+ function Get-InstallFailureKind([string]$NpmOutput) {
42
+ if ($null -eq $NpmOutput) { $NpmOutput = '' }
43
+ # Two EPERMs, one code. CWD (System32): relocating fixes it. GLOBAL PREFIX (C:\Program Files\
44
+ # nodejs, the Node installer's default): only --prefix fixes it. Measured 2026-08-23: after the
45
+ # relocation the install still died on `mkdir 'C:\Program Files\nodejs\node_modules\@holmes-lab'`.
46
+ # Keyed on the FAILING PATH line, not any mention of that directory: every npm stack trace names
47
+ # npm's own home under Program Files\nodejs\node_modules\npm, which is not the error.
48
+ if ($NpmOutput -match 'EPERM' -and $NpmOutput -match "(?m)^npm error path .*Program Files[\\/]+nodejs[\\/]+node_modules[\\/]+(?!npm[\\/])") { return 'eperm-prefix' }
49
+ if ($NpmOutput -match 'EPERM' -and ($NpmOutput -match 'mkdir' -or $NpmOutput -match 'System32' -or $NpmOutput -match 'Program Files')) { return 'eperm' }
50
+ if ($NpmOutput -match 'MSB\d{4}' -or $NpmOutput -match 'MSBuild' -or $NpmOutput -match 'Visual Studio' -or $NpmOutput -match 'vcvarsall') { return 'msbuild' }
51
+ if ($NpmOutput -match 'Could not find any Python' -or $NpmOutput -match 'gyp ERR! find Python') { return 'python' }
52
+ if ($NpmOutput -match 'node-gyp' -or $NpmOutput -match 'gyp ERR!') { return 'nodegyp-generic' }
53
+ if ($NpmOutput -match 'ENOTFOUND' -or $NpmOutput -match 'ETIMEDOUT' -or $NpmOutput -match 'ECONNRESET' -or $NpmOutput -match 'EAI_AGAIN') { return 'network' }
54
+ return 'none'
55
+ }
56
+
57
+ $script:Remediation = @{
58
+ 'eperm' = @'
59
+ npm was denied permission to create files in the current directory.
60
+ This usually means the shell was opened in a protected system folder (e.g. C:\WINDOWS\System32).
61
+ Re-run this installer (it relocates to your user profile automatically), or `cd` to a folder you own first.
62
+ '@
63
+ 'eperm-prefix' = @'
64
+ npm's GLOBAL prefix is inside C:\Program Files\nodejs, which only an administrator can write.
65
+ This installer passes a per-user prefix automatically; if you ran npm yourself, use one of:
66
+ npm install -g @holmes-lab/holmes-kit --prefix "$env:APPDATA\npm" (this install only)
67
+ npm config set prefix "$env:APPDATA\npm" (every global install)
68
+ and make sure that folder is on your user PATH (the Node.js installer normally adds it).
69
+ '@
70
+ 'msbuild' = @'
71
+ A native module needed a C++ compiler and none was found.
72
+ Install the Visual Studio Build Tools with the C++ workload (one command, ~2 GB):
73
+
74
+ winget install Microsoft.VisualStudio.2022.BuildTools --override "--quiet --wait --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"
75
+
76
+ Then open a NEW terminal and re-run this installer.
77
+ '@
78
+ 'python' = @'
79
+ node-gyp could not find a Python 3 interpreter (it needs one to drive the native build).
80
+
81
+ winget install Python.Python.3.12
82
+
83
+ Then open a NEW terminal and re-run this installer.
84
+ '@
85
+ 'nodegyp-generic' = @'
86
+ A native module failed to build and no prebuilt binary matched your Node.js version.
87
+ Most often the C++ Build Tools are missing:
88
+ winget install Microsoft.VisualStudio.2022.BuildTools --override "--quiet --wait --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"
89
+ If they are installed, try a current Node.js LTS (prebuilt binaries track LTS releases).
90
+ '@
91
+ 'network' = @'
92
+ npm could not reach the registry (DNS / timeout / connection reset).
93
+ Check your connection and proxy settings (`npm config get proxy`, `npm config get https-proxy`), then re-run.
94
+ '@
95
+ 'none' = @'
96
+ npm exited with an error this installer does not recognise. The last lines of its output are above.
97
+ '@
98
+ }
99
+
100
+ # Stage helpers
101
+ function Write-Stage([string]$Text) { Write-Host $Text }
102
+ function Write-Problem([string]$Text) { [Console]::Error.WriteLine($Text) }
103
+
104
+ function Get-NormalizedPath([string]$P) {
105
+ if ([string]::IsNullOrWhiteSpace($P)) { return $null }
106
+ $t = $P.TrimEnd('\', '/')
107
+ if ($t.Length -eq 2 -and $t[1] -eq ':') { $t = $t + '\' } # 'C:' -> 'C:\' so the drive root compares as itself
108
+ return $t.ToLowerInvariant()
109
+ }
110
+
111
+ function Test-UnderRoot([string]$Candidate, [string]$Root) {
112
+ if ($null -eq $Root) { return $false }
113
+ if ($Candidate -eq $Root) { return $true }
114
+ $prefix = $Root
115
+ if (-not $prefix.EndsWith('\')) { $prefix = $prefix + '\' } # separator first: C:\Windowsx is NOT under C:\Windows
116
+ return $Candidate.StartsWith($prefix)
117
+ }
118
+
119
+ function Test-ProtectedDirectory([string]$Dir) {
120
+ $here = Get-NormalizedPath $Dir
121
+ # The drive root is protected only when $PWD IS the root (C:\) - everything on the drive is
122
+ # under it, so treating it like the other roots would relocate every project on the machine.
123
+ $driveRoot = Get-NormalizedPath ([IO.Path]::GetPathRoot($Dir))
124
+ if ($null -ne $driveRoot -and $here -eq $driveRoot) { return $true }
125
+ $roots = @($env:SystemRoot, $env:ProgramFiles, ${env:ProgramFiles(x86)}, $env:ProgramData)
126
+ foreach ($r in $roots) {
127
+ if (Test-UnderRoot $here (Get-NormalizedPath $r)) { return $true }
128
+ }
129
+ return $false
130
+ }
131
+
132
+ function Test-WritableDirectory([string]$Dir) {
133
+ $probe = Join-Path $Dir ('.holmes-kit-write-probe-' + [Guid]::NewGuid().ToString('N'))
134
+ try {
135
+ New-Item -ItemType File -Path $probe -ErrorAction Stop | Out-Null
136
+ Remove-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue
137
+ return $true
138
+ } catch {
139
+ return $false
140
+ }
141
+ }
142
+
143
+ function Test-Elevated {
144
+ try {
145
+ $id = [Security.Principal.WindowsIdentity]::GetCurrent()
146
+ $p = New-Object Security.Principal.WindowsPrincipal($id)
147
+ return $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
148
+ } catch { return $false }
149
+ }
150
+
151
+ function Get-NodeVersionString {
152
+ $cmd = Get-Command node -ErrorAction SilentlyContinue
153
+ if ($null -eq $cmd) { return $null }
154
+ try { return ((& node --version) | Select-Object -First 1).Trim() } catch { return $null }
155
+ }
156
+
157
+ function Get-InstalledVersion {
158
+ $cmd = Get-Command $script:BinName -ErrorAction SilentlyContinue
159
+ if ($null -eq $cmd) { return $null }
160
+ try {
161
+ $out = (& $script:BinName --version 2>$null) | Select-Object -First 1
162
+ if ($out -match '(\d+\.\d+\.\d+[^\s]*)') { return $Matches[1] }
163
+ return $null
164
+ } catch { return $null }
165
+ }
166
+
167
+ function Get-NpmPrefix {
168
+ # Skipped in -DryRun only when npm is absent; otherwise it is a local config read, no network.
169
+ $r = Invoke-Npm @('config', 'get', 'prefix')
170
+ if ($r.Code -ne 0) { return $null }
171
+ $v = $r.Output.Trim()
172
+ if ([string]::IsNullOrWhiteSpace($v) -or $v -eq 'undefined') { return $null }
173
+ return $v
174
+ }
175
+
176
+ function Invoke-Npm([string[]]$Arguments) {
177
+ # Array arguments, never one interpolated string; npm is npm.cmd on Windows and needs the
178
+ # .cmd resolution that `&` performs. Both streams are merged so the classifier sees everything.
179
+ $npm = Get-Command npm.cmd -ErrorAction SilentlyContinue
180
+ if ($null -eq $npm) { $npm = Get-Command npm -ErrorAction SilentlyContinue }
181
+ if ($null -eq $npm) { return @{ Code = 127; Output = 'npm was not found on PATH' } }
182
+ $prev = $ErrorActionPreference
183
+ $ErrorActionPreference = 'Continue' # a native command writing to stderr must not throw here
184
+ try {
185
+ $lines = & $npm.Source @Arguments 2>&1 | ForEach-Object { "$_" }
186
+ $code = $LASTEXITCODE
187
+ } finally {
188
+ $ErrorActionPreference = $prev
189
+ }
190
+ return @{ Code = $code; Output = ($lines -join "`n") }
191
+ }
192
+
193
+ # Pipeline
194
+ function Invoke-Main {
195
+ # Stage 0 - arguments
196
+ if ($null -ne $Rest -and $Rest.Count -gt 0) {
197
+ Write-Problem ("Unrecognised argument(s): " + ($Rest -join ' '))
198
+ Write-Problem 'Usage: install.ps1 [-DryRun] [-Version <string>] [-Prefix <dir>]'
199
+ return 2
200
+ }
201
+ if ([string]::IsNullOrWhiteSpace($Version)) { $Version = 'latest' }
202
+
203
+ # Stage 1 - directory guard. Relocate, report, never ask.
204
+ $cwd = (Get-Location).Path
205
+ $protected = Test-ProtectedDirectory $cwd
206
+ $writable = $false
207
+ if (-not $protected) { $writable = Test-WritableDirectory $cwd }
208
+ if ($protected -or -not $writable) {
209
+ $home_ = $env:USERPROFILE
210
+ Write-Stage ("Relocating: {0} is a protected or read-only directory; continuing from {1}" -f $cwd, $home_)
211
+ Set-Location -LiteralPath $home_
212
+ }
213
+
214
+ # Stage 2 - elevation notice (warn, continue)
215
+ if (Test-Elevated) {
216
+ Write-Stage 'Note: this shell is elevated. A global npm install from here may land in a per-machine prefix that your normal (non-admin) terminal does not see. A regular user shell is recommended.'
217
+ }
218
+
219
+ # Stage 3 - Node version gate
220
+ $nodeV = Get-NodeVersionString
221
+ $major = -1
222
+ if ($null -ne $nodeV -and $nodeV -match '^v?(\d+)\.(\d+)\.(\d+)') { $major = [int]$Matches[1] }
223
+ if ($major -lt $script:NodeMajorFloor) {
224
+ $found = $nodeV
225
+ if ($null -eq $found) { $found = 'none' }
226
+ Write-Problem ("Node.js >= {0} is required (found {1}). Install from https://nodejs.org/" -f $script:NodeMajorFloor, $found)
227
+ return 3
228
+ }
229
+ Write-Stage ("Node.js {0} OK" -f $nodeV)
230
+
231
+ # Stage 4 - idempotence
232
+ $installed = Get-InstalledVersion
233
+ if ($null -ne $installed) {
234
+ $want = $Version
235
+ if ($want -eq 'latest' -and -not $DryRun) {
236
+ $view = Invoke-Npm @('view', $script:PackageName, 'version')
237
+ if ($view.Code -eq 0) { $want = $view.Output.Trim() }
238
+ }
239
+ if ($want -ne 'latest' -and $installed -eq $want) {
240
+ Write-Stage ("already installed ({0})" -f $installed)
241
+ return 0
242
+ }
243
+ Write-Stage ("holmes-kit {0} is installed; will install {1}" -f $installed, $Version)
244
+ }
245
+
246
+ # Stage 4b - prefix guard. A non-elevated shell cannot write the Node installer's default
247
+ # prefix (C:\Program Files\nodejs) and Stage 1 does not help. Per-user prefix for THIS call
248
+ # only; the user's npm config is never modified (REQ-253 Constraint 2).
249
+ if ([string]::IsNullOrWhiteSpace($Prefix) -and -not (Test-Elevated)) {
250
+ $cur = Get-NpmPrefix
251
+ if ($null -ne $cur -and (Test-ProtectedDirectory $cur)) {
252
+ $Prefix = Join-Path $env:APPDATA 'npm'
253
+ Write-Stage ("npm's global prefix ({0}) is not writable without elevation; using --prefix {1} for this install" -f $cur, $Prefix)
254
+ }
255
+ }
256
+
257
+ # Stage 5 - npm install
258
+ $spec = '{0}@{1}' -f $script:PackageName, $Version
259
+ $npmArgs = @('install', '-g', $spec)
260
+ $display = 'npm install -g "{0}"' -f $spec
261
+ if (-not [string]::IsNullOrWhiteSpace($Prefix)) {
262
+ $npmArgs += @('--prefix', $Prefix)
263
+ $display = $display + (' --prefix "{0}"' -f $Prefix)
264
+ }
265
+ if ($DryRun) {
266
+ Write-Stage ('DRY-RUN: ' + $display)
267
+ return 0
268
+ }
269
+ Write-Stage ('Running: ' + $display)
270
+ $r = Invoke-Npm $npmArgs
271
+ if ($r.Code -ne 0) {
272
+ $kind = Get-InstallFailureKind $r.Output
273
+ $tail = ($r.Output -split "`n") | Select-Object -Last 15
274
+ Write-Problem '--- npm output (last 15 lines) ---'
275
+ foreach ($l in $tail) { Write-Problem $l }
276
+ Write-Problem '--- what to do ---'
277
+ Write-Problem $script:Remediation[$kind]
278
+ return 4
279
+ }
280
+
281
+ # Stage 6 - PATH verification
282
+ $cmd = Get-Command $script:BinName -ErrorAction SilentlyContinue
283
+ if ($null -eq $cmd) {
284
+ $pfx = (Invoke-Npm @('config', 'get', 'prefix')).Output.Trim()
285
+ Write-Problem ("holmes-kit installed but {0} is not on PATH for this shell. Open a new terminal, or add it to your user PATH." -f $pfx)
286
+ return 5
287
+ }
288
+ $v = Get-InstalledVersion
289
+ Write-Stage ("holmes-kit {0} installed" -f $v)
290
+ return 0
291
+ }
292
+
293
+ # Dot-sourcing (`. .\install.ps1`) loads the functions for testing and does NOT run the pipeline.
294
+ if ($MyInvocation.InvocationName -ne '.') {
295
+ $code = Invoke-Main
296
+ exit $code
297
+ }