@holmes-lab/holmes-kit 0.1.15 → 0.1.17

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
@@ -4,6 +4,48 @@ All notable changes to this project will be documented in this file.
4
4
 
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
+ <!-- @implements A-SPEC-209 -->
8
+ ## [0.1.17] - 2026-08-23
9
+
10
+ The two `main` lines — the Windows-compatibility line (12 commits, verified on Windows 11 and
11
+ independently re-verified on macOS 26.5/APFS: 156/156 suites, gate probes over the `/tmp` →
12
+ `/private/tmp` link spelling all as expected) and the mac release line (0.1.10–0.1.16) — are merged
13
+ into one linear history in this release. Full suite on the merged tree: 191/191.
14
+
15
+ ### Added
16
+ - **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.
17
+
18
+ ### Fixed
19
+ - **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.
20
+ - **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.
21
+ - **Antigravity hook commands quote their paths**: a space in the install path no longer splits the command and silently disables the gate.
22
+ - **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.
23
+ - **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.
24
+
25
+ ### Changed
26
+ - **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.
27
+
28
+ ## [0.1.16] - 2026-08-23
29
+
30
+ Predictable spec numbering — enforced at the door, and the corpus cleaned to match.
31
+
32
+ ### Added
33
+ - **spec_create refuses a wild id (REQ-252)**: a new id whose base number leaps past
34
+ `max(base) + 1` is refused, naming the next available number. `spec_slice_init` assigns
35
+ `max + 1`, which is only predictable while numbering stays sequential — a single hand-picked
36
+ id (REQ-1403) once dragged the max to 1403 and every slice after jumped to 1404. The guard
37
+ closes the one door that let a manual id in, so `max + 1` can never be poisoned again.
38
+ Gap-fills, chain completion (base == max), and sub-slices (250.1) still pass; slice_init is
39
+ unaffected. Pure `sequentialIdVerdict`, injected id list — no filesystem.
40
+
41
+ ### Changed
42
+ - **Spec numbering restructured for readability**: the MCP drift fix shipped in 0.1.15 as two
43
+ top-level REQs (1404/1405) that were really one problem. Refiled under this repo's own
44
+ sub-slice convention as REQ-251 with A-SPEC-251.1 (npx wiring) and A-SPEC-251.2 (doctor
45
+ drift). The out-of-sequence REQ-1403 was renumbered to its rightful 250. Code `@implements`
46
+ anchors moved with them. No runtime behavior change — ids and anchors only; the 0.1.15
47
+ features are byte-identical.
48
+
7
49
  <!-- @implements A-SPEC-209 -->
8
50
  ## [0.1.15] - 2026-08-23
9
51
 
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
- d6b14ca-mt5ea641
1
+ e6199ca-mt5qbc5d
@@ -70,7 +70,7 @@ exports.HARNESS_ENFORCES = {
70
70
  codex: false,
71
71
  };
72
72
  const q = (p) => p;
73
- // @implements A-SPEC-1404 — same launch contract as the Claude wiring: npx-pin for installed
73
+ // @implements A-SPEC-251.1 — same launch contract as the Claude wiring: npx-pin for installed
74
74
  // packageRoots, node for source checkouts. Shared via mcpEntryForInstall so all three harnesses
75
75
  // move together.
76
76
  const mcpConfig = (packageRoot, specsDir, launcher) => {
@@ -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
  },
@@ -456,7 +456,7 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
456
456
  else {
457
457
  add('approval channel', 'WARN', '이 프로세스에는 HOLMES_APPROVAL이 있으나 .mcp.json의 서버 환경에는 없습니다 — spec_approve는 서버 환경을 읽습니다', 'settings.local.json의 env가 MCP 서버까지 전달된다는 보장은 없습니다. 그 변수를 설정한 셸에서 Claude Code를 띄우면 자식 서버가 상속합니다.');
458
458
  }
459
- // @implements A-SPEC-1405 — the version this project's MCP server will ACTUALLY launch, vs this
459
+ // @implements A-SPEC-251.2 — the version this project's MCP server will ACTUALLY launch, vs this
460
460
  // CLI. The stale-global trap (measured 2026-08-23: .mcp.json pinned a 0.1.9 global while the user
461
461
  // believed they had upgraded) was invisible because nothing compared them. Offline: the launch
462
462
  // version comes from the wiring string, or a node wiring's LOCAL package.json — never the registry.
@@ -429,7 +429,7 @@ async function main(argv) {
429
429
  }
430
430
  if (cmd === 'init') {
431
431
  const mode = (typeof flags.mode === 'string' ? flags.mode : 'guardrail');
432
- // @implements A-SPEC-1404 — an unknown launch mode is refused, not defaulted: substituting a
432
+ // @implements A-SPEC-251.1 — an unknown launch mode is refused, not defaulted: substituting a
433
433
  // default acts where the operator did not point (REQ-144). Only npx|node are wiring modes.
434
434
  let mcpLauncher;
435
435
  if (typeof flags['mcp-launcher'] === 'string') {
@@ -310,7 +310,7 @@ function runInit(opts) {
310
310
  const kept = Object.keys(prevEnv).filter((k) => !settings_merge_2.HOLMES_OWNED_MCP_ENV.includes(k));
311
311
  if (kept.length > 0)
312
312
  messages.push(`Preserved your MCP server env: ${kept.join(', ')}`);
313
- // @implements A-SPEC-1404 — compute the launch entry once (npx-pin for installs, node for source),
313
+ // @implements A-SPEC-251.1 — compute the launch entry once (npx-pin for installs, node for source),
314
314
  // then merge; env preservation is unchanged.
315
315
  const mcpEntry = (0, mcp_launcher_1.mcpEntryForInstall)({ packageRoot: opts.packageRoot, mcpBinPath: mcpBin, flag: opts.mcpLauncher });
316
316
  changes.push({ path: mcpPath, before: m.raw, after: JSON.stringify((0, settings_merge_1.mergeMcpServers)(m.value, exports.SERVER_NAME, mcpEntry, opts.specsDir), null, 2) + '\n' });
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * 왜 한 곳인가: `.mcp.json`(Claude)·`.agents/mcp_config.json`(antigravity)·`.codex/mcp_config.json`
5
5
  * 세 배선이 같은 서버를 띄운다. 셋이 각자 command/args 를 지으면 하나가 npx 로 옮겨갈 때 나머지가
6
- * 절대경로에 남아 어긋난다 — 그 드리프트가 REQ-1404 자체의 출발점이었다.
6
+ * 절대경로에 남아 어긋난다 — 그 드리프트가 REQ-251 자체의 출발점이었다.
7
7
  *
8
8
  * 왜 두 모드인가: 최종 사용자는 npm 설치본에서 init 하므로 `npx ...@<정확한버전> holmes-mcp` 가
9
9
  * 옳다 — 전역 설치도 `-g` 도 필요 없고(Windows EPERM 회피), 절대경로 고착이 없다. 그러나 개발
@@ -45,7 +45,7 @@ export declare function readPackageVersion(packageRoot: string): string | undefi
45
45
  * 세 배선(Claude/antigravity/codex)이 공유하는 조립: 모드 판별 → 버전 읽기 → entry.
46
46
  *
47
47
  * npx 로 판별됐어도 버전을 못 읽으면 node 로 물러난다 — 빈 핀(`@holmes-lab/holmes-kit@`)을 박느니
48
- * 로컬 bin 을 부르는 편이 유효하다(A-SPEC-1404 경계). `readVersion` 은 주입 가능해 시험이 fs 없이 돈다.
48
+ * 로컬 bin 을 부르는 편이 유효하다(A-SPEC-251.1 경계). `readVersion` 은 주입 가능해 시험이 fs 없이 돈다.
49
49
  */
50
50
  export declare function mcpEntryForInstall(opts: {
51
51
  packageRoot: string;
@@ -38,7 +38,7 @@ exports.mcpServerEntry = mcpServerEntry;
38
38
  exports.resolveLauncherMode = resolveLauncherMode;
39
39
  exports.readPackageVersion = readPackageVersion;
40
40
  exports.mcpEntryForInstall = mcpEntryForInstall;
41
- // @implements A-SPEC-1404
41
+ // @implements A-SPEC-251.1
42
42
  const fs = __importStar(require("node:fs"));
43
43
  const path = __importStar(require("node:path"));
44
44
  /** npm 상의 정식 패키지 이름. 핀은 이 이름에 정확한 버전을 붙인다. */
@@ -83,7 +83,7 @@ function readPackageVersion(packageRoot) {
83
83
  * 세 배선(Claude/antigravity/codex)이 공유하는 조립: 모드 판별 → 버전 읽기 → entry.
84
84
  *
85
85
  * npx 로 판별됐어도 버전을 못 읽으면 node 로 물러난다 — 빈 핀(`@holmes-lab/holmes-kit@`)을 박느니
86
- * 로컬 bin 을 부르는 편이 유효하다(A-SPEC-1404 경계). `readVersion` 은 주입 가능해 시험이 fs 없이 돈다.
86
+ * 로컬 bin 을 부르는 편이 유효하다(A-SPEC-251.1 경계). `readVersion` 은 주입 가능해 시험이 fs 없이 돈다.
87
87
  */
88
88
  function mcpEntryForInstall(opts) {
89
89
  const mode = resolveLauncherMode({ flag: opts.flag, packageRoot: opts.packageRoot });
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `.mcp.json` 배선이 **실제로 띄울** holmes-kit 버전을, 네트워크 없이 로컬로 뽑는다.
3
3
  *
4
- * 이 판정이 없어서 사용자의 전역 0.1.9 정체를 아무 진단도 보지 못했다(REQ-1405). 레지스트리를
4
+ * 이 판정이 없어서 사용자의 전역 0.1.9 정체를 아무 진단도 보지 못했다(REQ-251). 레지스트리를
5
5
  * 묻지 않는다 — 배선 문자열과, node 배선인 경우 주입된 로컬 reader 만 본다. reader 를 안 주면 node
6
6
  * 경로는 판정 불능(null)이지 실제 fs 를 더듬지 않는다: 순수성이 [offline] 을 시험 가능하게 한다.
7
7
  */
@@ -35,7 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.mcpLaunchVersion = mcpLaunchVersion;
37
37
  exports.versionDriftVerdict = versionDriftVerdict;
38
- // @implements A-SPEC-1405
38
+ // @implements A-SPEC-251.2
39
39
  const path = __importStar(require("node:path"));
40
40
  /** npx 인자에서 핀 버전을 뽑는 정규식: `@holmes-lab/holmes-kit@<X>` 의 X. `@` 없는 스펙은 안 잡힌다. */
41
41
  const NPX_PIN = /^@holmes-lab\/holmes-kit@(.+)$/;
@@ -90,7 +90,7 @@ function disableMcpServer(existing, name) {
90
90
  * put it there.
91
91
  */
92
92
  exports.HOLMES_OWNED_MCP_ENV = ['HOLMES_SPECS'];
93
- // @implements A-SPEC-1404
93
+ // @implements A-SPEC-251.1
94
94
  // The server entry (command/args) is COMPUTED by the caller (mcp-launcher: npx-pin for installs,
95
95
  // node for source checkouts) rather than assembled here, so all three harness wirings share one
96
96
  // launch contract. This merge owns only env preservation, not how the server is launched.
@@ -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);
@@ -138,6 +138,7 @@ const ignore_1 = require("../project/ignore");
138
138
  // @implements A-SPEC-126
139
139
  const scan_1 = require("../reverse/scan");
140
140
  const draft_1 = require("../reverse/draft");
141
+ const spec_id_guard_1 = require("./spec-id-guard");
141
142
  const anchor_1 = require("../reverse/anchor");
142
143
  // @implements A-SPEC-128
143
144
  // Where a project begins, for tools that only walk the tree.
@@ -261,13 +262,13 @@ function boundNonceLedger(store) {
261
262
  // `--specs-dir docs/specs` an ordinary review_record could still plant `docs/specs/.ax` and move
262
263
  // every consumed nonce, every audit line, and spec_approve's notion of "this project" with it.
263
264
  const storeReal = (() => { try {
264
- return fs.realpathSync(store.specsRoot);
265
+ return (0, root_2.canonicalPath)(store.specsRoot);
265
266
  }
266
267
  catch {
267
268
  return store.specsRoot;
268
269
  } })();
269
270
  const derivedReal = (() => { try {
270
- return fs.realpathSync(derived);
271
+ return (0, root_2.canonicalPath)(derived);
271
272
  }
272
273
  catch {
273
274
  return derived;
@@ -300,13 +301,13 @@ function boundFindingsLedger(store, root) {
300
301
  try {
301
302
  const derived = projectRootOf(store.specsRoot);
302
303
  const storeReal = (() => { try {
303
- return fs.realpathSync(store.specsRoot);
304
+ return (0, root_2.canonicalPath)(store.specsRoot);
304
305
  }
305
306
  catch {
306
307
  return store.specsRoot;
307
308
  } })();
308
309
  const derivedReal = (() => { try {
309
- return fs.realpathSync(derived);
310
+ return (0, root_2.canonicalPath)(derived);
310
311
  }
311
312
  catch {
312
313
  return derived;
@@ -593,6 +594,16 @@ function makeRawHandlers(store) {
593
594
  return { ok: false, reason: `depends_on "${String(pid)}"는 ${ptype}입니다 — ${def.type}의 부모는 ${def.parents.join('|') || '없음(빈 depends_on)'}이어야 합니다.` };
594
595
  }
595
596
  }
597
+ // @implements A-SPEC-252
598
+ // A new id may not LEAP past the sequence. spec_slice_init assigns nextId = max(base)+1, which
599
+ // is only predictable while numbering stays sequential; a hand-picked wild id (REQ-1403) once
600
+ // dragged max to 1403 and every slice after jumped to 1404. Refused here, at the one door that
601
+ // lets a manual id in — so max+1 can never be poisoned again. After the shape check on purpose:
602
+ // an id whose base cannot be read is the shape check's to name, not this guard's.
603
+ const corpus = await store.list();
604
+ const seq = (0, spec_id_guard_1.sequentialIdVerdict)(String(id), corpus.map((s) => s.id));
605
+ if (!seq.ok)
606
+ return { ok: false, reason: seq.reason };
596
607
  // @implements A-SPEC-188
597
608
  // Creation creates. Probed before this check existed: spec_create over an approved+sealed
598
609
  // REQ returned {"created"} while the disk went draft / seal gone / prose gone — the sanctioned
@@ -600,7 +611,7 @@ function makeRawHandlers(store) {
600
611
  // refusals on purpose: a malformed id must keep its own message (order is pinned by test).
601
612
  // Legacy (typeless) documents count as existing too — overwriting one makes the store's
602
613
  // orphan removal delete the differently-named original file along with its history.
603
- const existing = (await store.list()).find((s) => s.id === id);
614
+ const existing = corpus.find((s) => s.id === id);
604
615
  if (existing) {
605
616
  const sealed = existing.status === 'approved' || Boolean(existing.frontmatter?.approved_digest);
606
617
  const kind = existing.type ? `${existing.type} (status: ${existing.status})` : `옛 형식 문서 (status: ${existing.status})`;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * 넘버링 오염을 생성 시점에 막는 순수 가드.
3
+ *
4
+ * spec_slice_init 의 `nextId = max(base) + 1` 은 번호가 항상 순차 배정될 때만 예측 가능하다. 그런데
5
+ * spec_create 는 id 형태만 보고 순번은 보지 않아, 손으로 심은 wild id(REQ-1403) 하나가 max 를 끌어올려
6
+ * 이후 모든 슬라이스를 오염시켰다(실측: 249→1404). 이 가드는 그 하나의 구멍을 막는다 — 새 base 는
7
+ * 현재 max 를 한 칸 넘게 뛸 수 없다. 막으면 max+1 은 다시 오염되지 않고 넘버링이 영구히 예측 가능하다.
8
+ */
9
+ /** id 의 base 숫자. "REQ-1403"→1403, "A-SPEC-250.1"→250(서브번호 무시), 레거시 "H-SPEC-050"→50. 못 뽑으면 null. */
10
+ export declare function specIdBase(id: string): number | null;
11
+ export type IdVerdict = {
12
+ ok: true;
13
+ } | {
14
+ ok: false;
15
+ reason: string;
16
+ nextAvailable: number;
17
+ };
18
+ /**
19
+ * 새 id 의 base 가 코퍼스 max base 를 한 칸 넘게 뛰면 거부한다.
20
+ *
21
+ * - 빈 코퍼스, 또는 base 를 못 뽑는 id → 통과(비교 대상이 없거나, 형태 검증은 별도 소관이라 한 결함에
22
+ * 두 이름을 주지 않는다).
23
+ * - base ≤ maxBase(갭 메우기·체인 완성) 또는 base == maxBase+1(새 체인) → 통과.
24
+ * - base > maxBase+1(leap) → 거부, 다음 가용 번호를 문면과 필드에 댄다.
25
+ */
26
+ export declare function sequentialIdVerdict(newId: string, existingIds: string[]): IdVerdict;
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ // @implements A-SPEC-252
3
+ /**
4
+ * 넘버링 오염을 생성 시점에 막는 순수 가드.
5
+ *
6
+ * spec_slice_init 의 `nextId = max(base) + 1` 은 번호가 항상 순차 배정될 때만 예측 가능하다. 그런데
7
+ * spec_create 는 id 형태만 보고 순번은 보지 않아, 손으로 심은 wild id(REQ-1403) 하나가 max 를 끌어올려
8
+ * 이후 모든 슬라이스를 오염시켰다(실측: 249→1404). 이 가드는 그 하나의 구멍을 막는다 — 새 base 는
9
+ * 현재 max 를 한 칸 넘게 뛸 수 없다. 막으면 max+1 은 다시 오염되지 않고 넘버링이 영구히 예측 가능하다.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.specIdBase = specIdBase;
13
+ exports.sequentialIdVerdict = sequentialIdVerdict;
14
+ /** id 의 base 숫자. "REQ-1403"→1403, "A-SPEC-250.1"→250(서브번호 무시), 레거시 "H-SPEC-050"→50. 못 뽑으면 null. */
15
+ function specIdBase(id) {
16
+ if (typeof id !== 'string')
17
+ return null;
18
+ // 마지막 하이픈 뒤의 숫자 토막(점 앞)을 base 로 본다.
19
+ const m = /-(\d+)(?:\.\d+)?\s*$/.exec(id.trim());
20
+ if (!m)
21
+ return null;
22
+ const n = parseInt(m[1], 10);
23
+ return Number.isNaN(n) ? null : n;
24
+ }
25
+ /**
26
+ * 새 id 의 base 가 코퍼스 max base 를 한 칸 넘게 뛰면 거부한다.
27
+ *
28
+ * - 빈 코퍼스, 또는 base 를 못 뽑는 id → 통과(비교 대상이 없거나, 형태 검증은 별도 소관이라 한 결함에
29
+ * 두 이름을 주지 않는다).
30
+ * - base ≤ maxBase(갭 메우기·체인 완성) 또는 base == maxBase+1(새 체인) → 통과.
31
+ * - base > maxBase+1(leap) → 거부, 다음 가용 번호를 문면과 필드에 댄다.
32
+ */
33
+ function sequentialIdVerdict(newId, existingIds) {
34
+ const base = specIdBase(newId);
35
+ if (base === null)
36
+ return { ok: true };
37
+ const bases = (existingIds ?? []).map(specIdBase).filter((n) => n !== null);
38
+ if (bases.length === 0)
39
+ return { ok: true };
40
+ const maxBase = Math.max(...bases);
41
+ if (base > maxBase + 1) {
42
+ const nextAvailable = maxBase + 1;
43
+ return {
44
+ ok: false,
45
+ reason: `${newId} 는 순번을 벗어납니다 — 현재 최대 번호는 ${maxBase}, 다음 가용 번호는 ${nextAvailable} 입니다. 순차 번호나 기존 base 의 서브슬라이스만 만들 수 있습니다(넘버링 오염 방지).`,
46
+ nextAvailable,
47
+ };
48
+ }
49
+ return { ok: true };
50
+ }
@@ -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
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.15",
4
+ "version": "0.1.17",
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
+ }