@holmes-lab/holmes-kit 0.20.0 → 0.20.1

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,53 @@ 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.20.1] - 2026-09-16
9
+
10
+ The Windows release. 0.20.0's external matrix was run natively on Windows 11 (Node 24, npm 12, pytest 9)
11
+ and it found defects no POSIX run could see; this patch fixes them, then reconciles the result with the
12
+ POSIX suite. Eighteen A-SPECs (651.1–651.5, 652.1–652.6, 653), each RED-first with an official run.
13
+
14
+ ### Fixed
15
+ - **Every 3-way merge on Windows failed** (A-SPEC-652.6). `entity_integrate`'s merge tool pointed git's
16
+ global config at `\\.\nul`, which Windows git cannot open, so each merge reported
17
+ `merge-tool-failure`. It now uses `NUL` on win32 (`os.devNull` elsewhere).
18
+ - **One path, two spellings** (A-SPEC-651.1). Reaching a workspace through an 8.3 short name
19
+ (`C:\Users\SUNGNA~1\…`) made entity adoption refuse its own store as `foreign-store` and keyed pytest
20
+ outcomes by a `../../…` path. Entity binding, execution context and the pytest cwd now judge with the
21
+ same canonical spelling `resolveProjectRoot` uses.
22
+ - **The MCP server opens the right spec store** (A-SPEC-652.2). The store is resolved from
23
+ `CLAUDE_PROJECT_DIR`, then the nearest `.mcp.json`/`.ax` ancestor, then the cwd, and reported as an
24
+ absolute path; refusals name it.
25
+ - **Lock retirement survives Windows' transient `EPERM`** (A-SPEC-652.5): the rename retries briefly, and a
26
+ reader treats those codes as a generation change instead of an error.
27
+ - **A doctor probe that times out is retried once** and the failure reason is written into the detail
28
+ (A-SPEC-651.2) — a healthy install no longer exits 1 on a cold npx cache.
29
+ - **`spec_slice_init` never overwrites** (A-SPEC-651.5): numbering counts both stores and an existing file
30
+ refuses the whole slice; **`spec_slice_approve` picks the mirror T-SPEC** (`A-SPEC-652.1 → T-SPEC-652.1`)
31
+ instead of the first base-number match, and refuses by name when there is none (A-SPEC-652.1).
32
+ - **`test-support` modules no longer ship** in `dist` or the npm package.
33
+
34
+ ### Added
35
+ - **`review-resolve` requests reach the decision inbox** (A-SPEC-651.4): resolving a critical finding
36
+ through `review_record` files a request `holmes-kit approve` can grant, and the grant is consumed.
37
+ - **`doctor` names corrupted npx cache entries** (`npx cache` check, A-SPEC-652.2) — the cause of an MCP
38
+ server that dies at session start with `ENOENT … _npx/<hash>/package.json`. Filesystem reads only
39
+ (measured 0.18 ms).
40
+
41
+ ### Changed
42
+ - **The test-case counter recognises the reporting skip** (A-SPEC-653): `posixOnly(reason)(…)` and its
43
+ siblings count as cases for ART-4, so a platform-conditional suite reports `skipped` on the other OS
44
+ instead of returning early and reading as a pass. Three suites converted.
45
+ - Windows Jest budgets scale by a measured factor of 4 (test infrastructure only, A-SPEC-652.4).
46
+ - `doctor`'s `Check`/`Level` types moved to their own module (an import cycle removed, A-SPEC-653).
47
+
48
+ ### Measured, stated limits
49
+ - Windows native external matrix (`EVIDENCE-windows-native-matrix-2026-09-14.md`): every step passes from the
50
+ long-name path; the full Windows Jest run still carries pre-existing single-assertion reds that these
51
+ slices did not scope (`EVIDENCE-windows-followup-2026-09-15.md`). macOS: 501 suites green.
52
+ - Open findings: the write gate cannot admit a root-level file named in Files to Touch (a token needs a
53
+ slash); `doctor`'s recovery-skill drift check compares bytes and reads CRLF checkouts as drifted.
54
+
8
55
  ## [0.20.0] - 2026-09-14
9
56
 
10
57
  The Concurrent Multi-Agent Workspace release. Several agents, machines and clones now work on one spec
package/README.md CHANGED
@@ -302,7 +302,7 @@ Full per-cell bases (and every stated limit) live in [docs/language-support.md](
302
302
  | **macOS** | Apple Silicon (arm64) / Intel (x64) | ✅ Tier 1 | macOS 12+ (Full hook enforcement) |
303
303
  | **Linux** | x86_64 / arm64 | ✅ Tier 1 | Ubuntu, Debian, Fedora, Arch, RHEL. **Field-validated 2026-09-14** (Ubuntu 24.04 aarch64, OrbStack): tarball install, `init --agent all`, doctor, pytest RED→GREEN, Git integration, renumber, derived-state recovery and the approval round-trip, all from the external matrix probe |
304
304
  | **Windows (WSL2)** | x86_64 | ✅ Tier 1 | WSL2 Ubuntu/Debian recommended |
305
- | **Windows Native** | x86_64 | ✅ Tier 1 | Windows 10/11 (Node.js 20+; prebuilt natives, no build tools needed in the common case). **Field-validated 2026-08-31** on a real user machine: registry install, natives (better-sqlite3 + 7 tree-sitter grammars), both OS gates, MCP handshake (30 tools), interactive init TUI, out-of-band approval channel (doctor 25 PASS; the 4 false FAILs it also showed were doctor's own win32 spawn bug, fixed in 0.3.2). See ADR-015 (platform tier is decided by executed verification internal decision record) for tier criteria and residual risks (NTFS 8.3 names, reserved device names, 260-char paths; no Windows CI yet) |
305
+ | **Windows Native** | x86_64 | ✅ Tier 1 | Windows 10/11 (Node.js 20+; prebuilt natives, no build tools needed in the common case). **Field-validated 2026-09-14/15** (Windows 11, Node 24, npm 12, pytest 9): tarball install, `init --agent all`, doctor, pytest RED→GREEN, adoption, Git integration, renumber, derived-state recovery and the approval round-trip the external matrix passes from the long-name path; 0.20.1 fixes the defects it found (8.3 short paths, the merge tool's global config, transient `EPERM`, npx cache corruption). Under **npm 12** declare `allowScripts` for `better-sqlite3` and `sharp` in the consuming project (doctor prints the exact entry). |
306
306
 
307
307
  > **Runtime Requirement**: Node.js `>= 20.0.0` (LTS recommended)
308
308
  >
package/dist/.build-id CHANGED
@@ -1 +1 @@
1
- e2d9bd59-mu0pzf41
1
+ 37e99456-mu2vxydy
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The doctor's verdict shape, in a module of its own.
3
+ *
4
+ * Measured 2026-09-16: `npx-cache-check.ts` imported `type Check` from `doctor.ts` while `doctor.ts`
5
+ * imported the check — an import cycle (A-SPEC-574.3), red on every POSIX run after the Windows
6
+ * merge. A check module must be able to name the shape it returns without importing the runner.
7
+ */
8
+ export type Level = 'PASS' | 'WARN' | 'FAIL';
9
+ export interface Check {
10
+ name: string;
11
+ level: Level;
12
+ detail: string;
13
+ fix?: string;
14
+ remediate?: () => boolean;
15
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -11,14 +11,8 @@ export declare function resolvesToPackage(filePath: string, packageRoot: string)
11
11
  * hooks fail OPEN, so a broken install is silent: governance simply never fires. This command makes
12
12
  * that loud. Every check reports PASS/WARN/FAIL plus a concrete remediation.
13
13
  */
14
- export type Level = 'PASS' | 'WARN' | 'FAIL';
15
- export interface Check {
16
- name: string;
17
- level: Level;
18
- detail: string;
19
- fix?: string;
20
- remediate?: () => boolean;
21
- }
14
+ import type { Check, Level } from './doctor-check';
15
+ export type { Check, Level } from './doctor-check';
22
16
  export interface DoctorOptions {
23
17
  run?: ProbeRunner;
24
18
  autoFix?: boolean;
@@ -64,6 +64,7 @@ const install_scripts_policy_1 = require("../project/install-scripts-policy");
64
64
  const native_deps_1 = require("./native-deps");
65
65
  const tier_1 = require("../semantic/tier");
66
66
  const probe_process_1 = require("./probe-process");
67
+ const npx_cache_check_1 = require("./npx-cache-check");
67
68
  const path = __importStar(require("node:path"));
68
69
  const role_policy_1 = require("../governance/role-policy");
69
70
  const blind_spots_1 = require("../guardrail/blind-spots");
@@ -515,6 +516,11 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
515
516
  // before it answers (a false FAIL this check produced on its first run).
516
517
  if (built) {
517
518
  checks.push(await mcpHandshakeCheck(packageRoot));
519
+ // @implements A-SPEC-652.2 — the handshake above proves THIS install; the wiring may run a
520
+ // cached npx copy instead, and a corrupt cache entry dies before any handshake (measured
521
+ // 2026-09-15: "Connection closed" at session start, npm ENOENT on the entry's package.json).
522
+ if (target)
523
+ checks.push((0, npx_cache_check_1.checkNpxCache)(target, { env: process.env, homedir: os.homedir(), platform: process.platform, exists: fs.existsSync, readFile: (p) => fs.readFileSync(p, 'utf8'), readdir: (p) => fs.readdirSync(p) }));
518
524
  // @implements A-SPEC-323
519
525
  // Every wired harness, actually started. The check above proves THIS INSTALL can serve; these
520
526
  // prove the wiring each harness will really run reaches a server. Measured: 367ms for three.
@@ -1340,9 +1346,11 @@ cwd) {
1340
1346
  return;
1341
1347
  done = true;
1342
1348
  clearTimeout(timer);
1343
- void (0, probe_process_1.stopProbeProcess)(child).then(released => resolve(released
1349
+ // @implements A-SPEC-651.2 the platform the spawn was adapted for is the platform the cleanup
1350
+ // is judged on, and an unconfirmed cleanup names its reason (timeout vs refusal vs no close).
1351
+ void (0, probe_process_1.stopProbeProcess)(child, { platform }).then(released => resolve(released
1344
1352
  ? { name: 'mcp wiring spawn', level, detail, fix }
1345
- : { name: 'mcp wiring spawn', level: 'FAIL', detail: `${detail}; probe cleanup could not be confirmed`,
1353
+ : { name: 'mcp wiring spawn', level: 'FAIL', detail: `${detail}; probe cleanup could not be confirmed (${(0, probe_process_1.lastProbeStopReason)() ?? 'unknown'})`,
1346
1354
  fix: `${fix ?? ''} Close the remaining diagnostic process for \`${command}\`, then retry doctor.` }));
1347
1355
  };
1348
1356
  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);
@@ -1414,9 +1422,10 @@ function mcpHandshakeCheck(packageRoot, timeoutMs = 15000) {
1414
1422
  removed = true;
1415
1423
  }
1416
1424
  catch { /* signal cleanup retains the path for a later attempt */ }
1425
+ const why = !released ? ((0, probe_process_1.lastProbeStopReason)() ?? 'unknown') : 'temp dir not removed';
1417
1426
  resolve(released && removed
1418
1427
  ? { name: 'MCP server', level, detail, fix }
1419
- : { name: 'MCP server', level: 'FAIL', detail: `${detail}; probe cleanup could not be confirmed`,
1428
+ : { name: 'MCP server', level: 'FAIL', detail: `${detail}; probe cleanup could not be confirmed (${why})`,
1420
1429
  fix: 'Close the remaining diagnostic process and retry doctor.' });
1421
1430
  });
1422
1431
  };
@@ -0,0 +1,16 @@
1
+ import type { Check } from './doctor-check';
2
+ export interface NpxCacheDeps {
3
+ env: NodeJS.ProcessEnv;
4
+ homedir: string;
5
+ platform: NodeJS.Platform;
6
+ exists: (p: string) => boolean;
7
+ readFile: (p: string) => string;
8
+ readdir: (p: string) => string[];
9
+ }
10
+ /** The `-p <pkg>@<ver>` pin of the target's holmes-kit wiring, or null when it is not an npx pin. */
11
+ export declare function npxPinOf(target: string, deps: Pick<NpxCacheDeps, 'exists' | 'readFile'>): {
12
+ pkg: string;
13
+ version: string;
14
+ } | null;
15
+ export declare function npmCacheRoot(deps: Pick<NpxCacheDeps, 'env' | 'homedir' | 'platform'>): string;
16
+ export declare function checkNpxCache(target: string, deps: NpxCacheDeps): Check;
@@ -0,0 +1,130 @@
1
+ "use strict";
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
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.npxPinOf = npxPinOf;
37
+ exports.npmCacheRoot = npmCacheRoot;
38
+ exports.checkNpxCache = checkNpxCache;
39
+ // @implements A-SPEC-652.2
40
+ /**
41
+ * doctor: is the npx cache entry the wiring will run intact?
42
+ *
43
+ * Measured 2026-09-15 on Windows: `.mcp.json` wires `npx -y -p @holmes-lab/holmes-kit@0.19.4
44
+ * holmes-mcp`; npx resolved it to `_npx/a739130c5322c549`, whose root `package.json` had gone, and
45
+ * died with `npm error ENOENT … package.json` before any handshake. The harness reported only
46
+ * "Connection closed". Six entries in that cache had the same shape (two of them this package).
47
+ *
48
+ * Filesystem only — no npm spawn: the cache root is `npm_config_cache`, else the platform default
49
+ * (`%LOCALAPPDATA%\npm-cache` on win32, `~/.npm` elsewhere). Only entries holding the PINNED version
50
+ * of the pinned package are judged; a corrupt entry of another version is not what this wiring runs.
51
+ */
52
+ const path = __importStar(require("node:path"));
53
+ const NAME = 'npx cache';
54
+ /** The `-p <pkg>@<ver>` pin of the target's holmes-kit wiring, or null when it is not an npx pin. */
55
+ function npxPinOf(target, deps) {
56
+ const mcpPath = path.join(target, '.mcp.json');
57
+ if (!deps.exists(mcpPath))
58
+ return null;
59
+ let server;
60
+ try {
61
+ server = JSON.parse(deps.readFile(mcpPath)).mcpServers?.['holmes-kit'];
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ if (!server || !/^npx(\.cmd)?$/i.test(String(server.command ?? '')) || !Array.isArray(server.args))
67
+ return null;
68
+ const at = server.args.indexOf('-p');
69
+ const spec = at >= 0 ? server.args[at + 1] : undefined;
70
+ const m = spec ? /^(@?[^@]+)@([0-9][^\s]*)$/.exec(spec) : null;
71
+ return m ? { pkg: m[1], version: m[2] } : null;
72
+ }
73
+ function npmCacheRoot(deps) {
74
+ const configured = deps.env.npm_config_cache;
75
+ if (configured && configured.trim() !== '')
76
+ return configured;
77
+ if (deps.platform === 'win32') {
78
+ const local = deps.env.LOCALAPPDATA && deps.env.LOCALAPPDATA.trim() !== '' ? deps.env.LOCALAPPDATA : path.join(deps.homedir, 'AppData', 'Local');
79
+ return path.join(local, 'npm-cache');
80
+ }
81
+ return path.join(deps.homedir, '.npm');
82
+ }
83
+ function checkNpxCache(target, deps) {
84
+ const pin = npxPinOf(target, deps);
85
+ if (!pin)
86
+ return { name: NAME, level: 'PASS', detail: 'the holmes-kit wiring is not an npx pin — nothing cached to judge' };
87
+ const npxDir = path.join(npmCacheRoot(deps), '_npx');
88
+ if (!deps.exists(npxDir))
89
+ return { name: NAME, level: 'PASS', detail: `${pin.pkg}@${pin.version} is not cached yet (${npxDir} absent) — the first launch installs it` };
90
+ let hashes = [];
91
+ try {
92
+ hashes = deps.readdir(npxDir);
93
+ }
94
+ catch {
95
+ return { name: NAME, level: 'PASS', detail: `${npxDir} could not be listed — nothing to judge` };
96
+ }
97
+ const matching = [];
98
+ const corrupt = [];
99
+ for (const hash of hashes) {
100
+ const entry = path.join(npxDir, hash);
101
+ const pkgJson = path.join(entry, 'node_modules', ...pin.pkg.split('/'), 'package.json');
102
+ if (!deps.exists(pkgJson))
103
+ continue;
104
+ let version;
105
+ try {
106
+ version = JSON.parse(deps.readFile(pkgJson)).version;
107
+ }
108
+ catch {
109
+ continue;
110
+ }
111
+ if (version !== pin.version)
112
+ continue;
113
+ matching.push(hash);
114
+ if (!deps.exists(path.join(entry, 'package.json')))
115
+ corrupt.push(hash);
116
+ }
117
+ if (matching.length === 0)
118
+ return { name: NAME, level: 'PASS', detail: `${pin.pkg}@${pin.version} is not cached yet under ${npxDir} — the first launch installs it` };
119
+ if (corrupt.length === 0)
120
+ return { name: NAME, level: 'PASS', detail: `${matching.length} intact npx cache entr${matching.length === 1 ? 'y' : 'ies'} for ${pin.pkg}@${pin.version}` };
121
+ const dirs = corrupt.map((h) => path.join(npxDir, h));
122
+ const remove = deps.platform === 'win32'
123
+ ? dirs.map((d) => `Remove-Item -Recurse -Force "${d}"`).join('; ')
124
+ : dirs.map((d) => `rm -rf ${d}`).join(' && ');
125
+ return {
126
+ name: NAME, level: 'FAIL',
127
+ detail: `npx cache entr${corrupt.length === 1 ? 'y' : 'ies'} ${corrupt.join(', ')} for ${pin.pkg}@${pin.version} ${corrupt.length === 1 ? 'is' : 'are'} missing its package.json — the wiring dies with \`npm error ENOENT\` before the handshake and the harness reports only "Connection closed" (${dirs.join(', ')})`,
128
+ fix: `Delete the corrupt entr${corrupt.length === 1 ? 'y' : 'ies'} and let the next launch reinstall: ${remove}`,
129
+ };
130
+ }
@@ -1,8 +1,23 @@
1
- import { type ChildProcess } from 'node:child_process';
1
+ import { execFile as nodeExecFile, type ChildProcess } from 'node:child_process';
2
2
  /** Each POSIX probe gets its own process group; Windows taskkill targets only its owned PID. */
3
3
  export declare const PROBE_SPAWN_OPTIONS: {
4
4
  detached: boolean;
5
5
  windowsHide: boolean;
6
6
  };
7
+ /**
8
+ * A-SPEC-651.2 — budgets measured, not guessed. taskkill /T /F finished in 0.56–0.67 s on a quiet
9
+ * box (2026-09-14); the old 2.5 s budget, spent once, read "cleanup could not be confirmed" on a
10
+ * healthy install in 1 of 4 quiet doctor runs and 2 of 3 loaded ones. 8 s each, and a timed-out
11
+ * taskkill gets exactly one more try — a refused one does not (A-SPEC-592: a failed tree kill is never
12
+ * success).
13
+ */
14
+ export declare const PROBE_KILL_TIMEOUT_MS = 8000;
15
+ export declare const PROBE_CLOSE_TIMEOUT_MS = 8000;
16
+ export interface ProbeStopDeps {
17
+ platform?: NodeJS.Platform;
18
+ execFile?: typeof nodeExecFile;
19
+ }
20
+ /** Why the most recent stopProbeProcess answered false; undefined after a confirmed cleanup. */
21
+ export declare function lastProbeStopReason(): string | undefined;
7
22
  export declare function trackProbeProcess(child: ChildProcess): void;
8
- export declare function stopProbeProcess(child?: ChildProcess): Promise<boolean>;
23
+ export declare function stopProbeProcess(child?: ChildProcess, deps?: ProbeStopDeps): Promise<boolean>;
@@ -1,13 +1,27 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.PROBE_SPAWN_OPTIONS = void 0;
3
+ exports.PROBE_CLOSE_TIMEOUT_MS = exports.PROBE_KILL_TIMEOUT_MS = exports.PROBE_SPAWN_OPTIONS = void 0;
4
+ exports.lastProbeStopReason = lastProbeStopReason;
4
5
  exports.trackProbeProcess = trackProbeProcess;
5
6
  exports.stopProbeProcess = stopProbeProcess;
6
7
  // @implements A-SPEC-592
8
+ // @implements A-SPEC-651.2
7
9
  const node_child_process_1 = require("node:child_process");
8
10
  /** Each POSIX probe gets its own process group; Windows taskkill targets only its owned PID. */
9
11
  exports.PROBE_SPAWN_OPTIONS = { detached: process.platform !== 'win32', windowsHide: true };
12
+ /**
13
+ * A-SPEC-651.2 — budgets measured, not guessed. taskkill /T /F finished in 0.56–0.67 s on a quiet
14
+ * box (2026-09-14); the old 2.5 s budget, spent once, read "cleanup could not be confirmed" on a
15
+ * healthy install in 1 of 4 quiet doctor runs and 2 of 3 loaded ones. 8 s each, and a timed-out
16
+ * taskkill gets exactly one more try — a refused one does not (A-SPEC-592: a failed tree kill is never
17
+ * success).
18
+ */
19
+ exports.PROBE_KILL_TIMEOUT_MS = 8000;
20
+ exports.PROBE_CLOSE_TIMEOUT_MS = 8000;
10
21
  const states = new WeakMap();
22
+ let lastReason;
23
+ /** Why the most recent stopProbeProcess answered false; undefined after a confirmed cleanup. */
24
+ function lastProbeStopReason() { return lastReason; }
11
25
  function trackProbeProcess(child) {
12
26
  if (states.has(child))
13
27
  return;
@@ -15,30 +29,45 @@ function trackProbeProcess(child) {
15
29
  states.set(child, state);
16
30
  child.once('close', () => { state.closed = true; });
17
31
  }
18
- async function stopProbeProcess(child) {
32
+ const isTimeout = (error) => {
33
+ const e = error;
34
+ return !!e && (e.killed === true || e.code === null || e.code === undefined);
35
+ };
36
+ async function stopProbeProcess(child, deps = {}) {
37
+ lastReason = undefined;
19
38
  if (!child)
20
39
  return true;
21
40
  trackProbeProcess(child);
41
+ const platform = deps.platform ?? process.platform;
42
+ const execFile = deps.execFile ?? node_child_process_1.execFile;
22
43
  let closed = states.get(child).closed;
23
44
  let onClose = () => { };
24
45
  let closeTimer;
25
46
  const closure = new Promise(resolve => {
26
47
  onClose = () => { closed = true; clearTimeout(closeTimer); resolve(true); };
27
48
  child.once('close', onClose);
28
- closeTimer = setTimeout(() => resolve(false), 3000);
49
+ closeTimer = setTimeout(() => resolve(false), exports.PROBE_CLOSE_TIMEOUT_MS);
29
50
  if (closed || !child.pid)
30
51
  onClose();
31
52
  });
32
53
  let stopped = true;
54
+ let stopReason;
33
55
  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
- });
56
+ if (child.pid && platform === 'win32' && !closed) {
57
+ const taskkill = () => new Promise(resolve => {
58
+ execFile('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, timeout: exports.PROBE_KILL_TIMEOUT_MS }, error => resolve(error ?? null));
39
59
  });
60
+ let error = await taskkill();
61
+ if (error && isTimeout(error))
62
+ error = await taskkill(); // one retry, for a timeout only
63
+ if (error) {
64
+ stopped = false;
65
+ stopReason = isTimeout(error)
66
+ ? `taskkill timed out twice (${exports.PROBE_KILL_TIMEOUT_MS} ms each)`
67
+ : `taskkill exited ${String(error.code)}`;
68
+ }
40
69
  }
41
- else if (child.pid && process.platform !== 'win32') {
70
+ else if (child.pid && platform !== 'win32') {
42
71
  const signalGroup = (signal) => {
43
72
  try {
44
73
  process.kill(-child.pid, signal);
@@ -56,8 +85,9 @@ async function stopProbeProcess(child) {
56
85
  signalGroup('SIGKILL');
57
86
  }
58
87
  }
59
- catch {
88
+ catch (error) {
60
89
  stopped = false;
90
+ stopReason = `kill failed: ${error.message}`;
61
91
  }
62
92
  // Terminate the tree before closing stdin: an EOF could make the wrapper exit first,
63
93
  // leaving Windows taskkill with no parent from which to enumerate its descendants.
@@ -69,5 +99,8 @@ async function stopProbeProcess(child) {
69
99
  child.removeListener('close', onClose);
70
100
  if (!released)
71
101
  child.unref();
72
- return stopped && released;
102
+ const ok = stopped && released;
103
+ if (!ok)
104
+ lastReason = stopReason ?? `child never closed within ${exports.PROBE_CLOSE_TIMEOUT_MS} ms`;
105
+ return ok;
73
106
  }
@@ -78,8 +78,12 @@ exports.QUEUE_RELPATH = path.join('.ax', 'approvals', 'queue.jsonl');
78
78
  // single-shot gate refusals nobody ever meant to decide, burying the real inbox (spec-approve 9,
79
79
  // config-write 1) and accumulating raw shell commands in a tracked file. Only these kinds seek a
80
80
  // decision; everything else routes to the local per-replica refusal log below. Wired RED-first.
81
+ // @implements A-SPEC-651.4 — `review-resolve` is a decision too: resolving an open critical needs a
82
+ // human, review_record's refusal points that human at `holmes-kit approve`, and without this entry the
83
+ // request went to the refusal log so the inbox stayed empty (measured 2026-09-14 on Windows: two
84
+ // expired session dialogs, then `approve --list` answered "No requests awaiting approval").
81
85
  exports.DECISION_KINDS = new Set([
82
- 'spec-approve', 'config-write', 'autonomy-grant', 'spec-reappraisal',
86
+ 'spec-approve', 'config-write', 'autonomy-grant', 'spec-reappraisal', 'review-resolve',
83
87
  ]);
84
88
  const REFUSALS_FILE_RE = /^refusals\.([^.]+)\.jsonl$/;
85
89
  function refusalsFilename(replica) {
@@ -16,6 +16,15 @@ export interface SliceOrchestrationContext {
16
16
  }): Promise<unknown>;
17
17
  };
18
18
  }
19
+ /**
20
+ * @implements A-SPEC-652.1 — the T-SPEC that belongs to an A-SPEC, by the mirror rule the
21
+ * author-slice playbook states (the chain inherits the parent's number, dot included): the
22
+ * mirror id first, then a T-SPEC that declares `depends_on` on this exact A-SPEC, then — for an
23
+ * UNDOTTED id only — the old base-number match that kept legacy `T-SPEC-1291`-style ids working.
24
+ * Measured 2026-09-14: `spec_slice_approve('A-SPEC-651.2')` sealed `T-SPEC-651.1`, because the old
25
+ * `find` accepted any T-SPEC whose base number matched and .1 came first.
26
+ */
27
+ export declare function tspecFor(aspec: Spec, specs: Spec[]): Spec | undefined;
19
28
  export declare function createSliceOrchestrationHandlers(context: SliceOrchestrationContext): {
20
29
  spec_slice_init(a: {
21
30
  root?: string;
@@ -25,8 +34,16 @@ export declare function createSliceOrchestrationHandlers(context: SliceOrchestra
25
34
  filesToTouch: string[];
26
35
  }): Promise<{
27
36
  ok: boolean;
37
+ reason: string;
38
+ existing: string[];
28
39
  specsCreated: string[];
29
40
  unreadable: string[];
41
+ } | {
42
+ ok: boolean;
43
+ specsCreated: string[];
44
+ unreadable: string[];
45
+ reason?: undefined;
46
+ existing?: undefined;
30
47
  }>;
31
48
  spec_slice_approve(a: {
32
49
  root?: string;
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.tspecFor = tspecFor;
36
37
  exports.createSliceOrchestrationHandlers = createSliceOrchestrationHandlers;
37
38
  // @implements A-SPEC-609, A-SPEC-100.2, A-SPEC-156, A-SPEC-203, A-SPEC-204, A-SPEC-298, A-SPEC-299, A-SPEC-536.1, A-SPEC-546.1, A-SPEC-565.2, A-SPEC-587.2, A-SPEC-501.1
38
39
  const fs = __importStar(require("node:fs"));
@@ -42,6 +43,30 @@ const root_1 = require("../../project/root");
42
43
  const yaml_scalar_1 = require("../../spec/yaml-scalar");
43
44
  const draft_1 = require("../../reverse/draft");
44
45
  const anchor_comment_1 = require("../../rtm/anchor-comment");
46
+ /**
47
+ * @implements A-SPEC-652.1 — the T-SPEC that belongs to an A-SPEC, by the mirror rule the
48
+ * author-slice playbook states (the chain inherits the parent's number, dot included): the
49
+ * mirror id first, then a T-SPEC that declares `depends_on` on this exact A-SPEC, then — for an
50
+ * UNDOTTED id only — the old base-number match that kept legacy `T-SPEC-1291`-style ids working.
51
+ * Measured 2026-09-14: `spec_slice_approve('A-SPEC-651.2')` sealed `T-SPEC-651.1`, because the old
52
+ * `find` accepted any T-SPEC whose base number matched and .1 came first.
53
+ */
54
+ function tspecFor(aspec, specs) {
55
+ const number = aspec.id.replace(/^A-SPEC-/, '');
56
+ const mirror = specs.find((s) => s.type === 'T-SPEC' && s.id === `T-SPEC-${number}`);
57
+ if (mirror)
58
+ return mirror;
59
+ const dependent = specs.filter((s) => s.type === 'T-SPEC' && s.dependsOn.includes(aspec.id)).sort((a, b) => a.id.localeCompare(b.id))[0];
60
+ if (dependent)
61
+ return dependent;
62
+ if (number.includes('.'))
63
+ return undefined;
64
+ // Undotted only: the old base-number match (`T-SPEC-700.1` for `A-SPEC-700`) and the pre-REQ-187
65
+ // convention that appended one digit (`T-SPEC-1291` for `A-SPEC-129`).
66
+ const mainId = (id) => id.split('.')[0].replace(/\D/g, '');
67
+ const legacy = new RegExp(`^T-SPEC-${number}\\d$`);
68
+ return specs.find((s) => s.type === 'T-SPEC' && (mainId(s.id) === number || legacy.test(s.id)));
69
+ }
45
70
  function createSliceOrchestrationHandlers(context) {
46
71
  return {
47
72
  async spec_slice_init(a) {
@@ -51,7 +76,11 @@ function createSliceOrchestrationHandlers(context) {
51
76
  // @implements A-SPEC-546.1 — align with the sibling reverse-draft path (nextIdBase, floor 100)
52
77
  // instead of a hard-coded 200 floor, so a fresh project starts at REQ-100, not REQ-201. Existing
53
78
  // projects are unchanged: nextIdBase returns max(existing)+1, which dominates the floor.
54
- const nextId = (0, draft_1.nextIdBase)(existing.map((s) => s.id));
79
+ // @implements A-SPEC-651.5 the number is never below EITHER store's max: the bound store's
80
+ // (the contract above) and the store the files are written to. Measured 2026-09-14: the bound
81
+ // store was empty, the id fell to 100 and the sealed REQ-100.md of the target was overwritten.
82
+ const targetIds = fs.existsSync(specsDir) ? (await new spec_store_1.LocalMarkdownRepository(specsDir).list()).map((s) => s.id) : [];
83
+ const nextId = (0, draft_1.nextIdBase)([...existing.map((s) => s.id), ...targetIds]);
55
84
  const reqId = `REQ-${nextId}`;
56
85
  const hspecId = `H-SPEC-${nextId}`;
57
86
  const aspecId = `A-SPEC-${nextId}`;
@@ -194,6 +223,16 @@ depends_on:
194
223
  When operation is performed
195
224
  Then boundary limits are respected
196
225
  `;
226
+ // @implements A-SPEC-651.5 — all or nothing, judged BEFORE the first byte: a target path that
227
+ // already exists (readable or not — the number cannot see an unparseable file) refuses the
228
+ // whole chain and names the files, so a sealed document is never replaced by a stub.
229
+ const targets = [
230
+ ['01_req', `${reqId}.md`], ['02_h-spec/functional', `${hspecId}.md`], ['03_a-spec', `${aspecId}.md`], ['05_t-spec', `${tspecId}.md`],
231
+ ];
232
+ const existingFiles = targets.filter(([dir, name]) => fs.existsSync(path.join(specsDir, dir, name))).map(([dir, name]) => `${dir}/${name}`);
233
+ if (existingFiles.length > 0) {
234
+ return { ok: false, reason: `spec_slice_init: ${existingFiles.length} target file(s) already exist — nothing written`, existing: existingFiles, specsCreated: [], unreadable: [] };
235
+ }
197
236
  fs.mkdirSync(path.join(specsDir, '01_req'), { recursive: true });
198
237
  fs.mkdirSync(path.join(specsDir, '02_h-spec', 'functional'), { recursive: true });
199
238
  fs.mkdirSync(path.join(specsDir, '03_a-spec'), { recursive: true });
@@ -225,12 +264,16 @@ depends_on:
225
264
  return false;
226
265
  });
227
266
  const idsToApprove = [];
267
+ // @implements A-SPEC-652.1 — a dotted A-SPEC with no mirror and no dependent T-SPEC is named
268
+ // in `refused` AFTER the rest of the chain is sealed: the code gate stays shut without a
269
+ // T-SPEC, and a silent `ok:true` would read as "the slice is approved".
270
+ let missingTspec;
228
271
  if (inSlice.length > 0) {
229
272
  const aspec = inSlice.find((s) => s.type === 'A-SPEC') ?? inSlice[0];
230
273
  const mainId = (id) => id.split('.')[0].replace(/\D/g, '');
231
274
  const req = specs.find((s) => aspec.dependsOn.includes(s.id) || mainId(s.id) === mainId(aspec.id));
232
275
  const hspec = specs.find((s) => s.type === 'H-SPEC' && (s.dependsOn.includes(req?.id ?? '') || mainId(s.id) === mainId(aspec.id)));
233
- const tspec = specs.find((s) => s.type === 'T-SPEC' && (s.dependsOn.includes(aspec.id) || mainId(s.id) === mainId(aspec.id)));
276
+ const tspec = tspecFor(aspec, specs);
234
277
  if (req)
235
278
  idsToApprove.push(req.id);
236
279
  if (hspec)
@@ -238,6 +281,8 @@ depends_on:
238
281
  idsToApprove.push(aspec.id);
239
282
  if (tspec)
240
283
  idsToApprove.push(tspec.id);
284
+ else if (aspec.type === 'A-SPEC' && aspec.id.includes('.'))
285
+ missingTspec = `T-SPEC-${aspec.id.replace(/^A-SPEC-/, '')}`;
241
286
  }
242
287
  else {
243
288
  const matches = specs.filter((s) => s.id.includes(a.sliceName));
@@ -281,6 +326,10 @@ depends_on:
281
326
  for (const id of idsToApprove.slice(approvedSpecs.length + alreadySealed.length + refused.length)) {
282
327
  refused.push({ id, reason: 'not attempted — an earlier spec in the chain was refused' });
283
328
  }
329
+ if (missingTspec !== undefined) {
330
+ const aspecId = `A-SPEC-${missingTspec.replace(/^T-SPEC-/, '')}`;
331
+ refused.push({ id: missingTspec, reason: `no T-SPEC mirrors ${aspecId} or depends on it — write ${missingTspec} first` });
332
+ }
284
333
  // `ok` means the slice is approved — freshly or already. Anything less is not a success.
285
334
  return { ok: refused.length === 0 && approvedSpecs.length + alreadySealed.length > 0, approvedSpecs, alreadySealed, refused };
286
335
  },
@@ -590,9 +590,17 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
590
590
  objective: string;
591
591
  filesToTouch: string[];
592
592
  }) => Promise<{
593
+ ok: boolean;
594
+ reason: string;
595
+ existing: string[];
596
+ specsCreated: string[];
597
+ unreadable: string[];
598
+ } | {
593
599
  ok: boolean;
594
600
  specsCreated: string[];
595
601
  unreadable: string[];
602
+ reason?: undefined;
603
+ existing?: undefined;
596
604
  }>;
597
605
  spec_slice_approve: (a: {
598
606
  root?: string;
@@ -326,7 +326,9 @@ function assertSpecStoreReachable(tool, store, root) {
326
326
  // The tool name is NOT prefixed here: the raw-handler wrapper already prefixes it, and doing it
327
327
  // twice produced `spec_list: spec_list: ...` in the installed server (seen in the probe).
328
328
  void tool;
329
- throw new HandlerRefusal(`이 서버가 바인딩된 스펙 디렉터리 ${store.specsRoot} 존재하지 않습니다 —`
329
+ // @implements A-SPEC-652.2 the ABSOLUTE path, so the operator can see which directory was tried
330
+ // (a relative `.ax/specs` in this message told nobody where the server had looked, 2026-09-14).
331
+ throw new HandlerRefusal(`이 서버가 바인딩된 스펙 디렉터리 ${path.resolve(store.specsRoot)} 가 존재하지 않습니다 —`
330
332
  + ` 요청한 root ${root} 에는 스펙 저장소가 있습니다. 빈 답을 돌려주면 '스펙이 없다'로 읽히므로`
331
333
  + ' 거부합니다. 서버를 프로젝트 루트에서 시작하거나 HOLMES_SPECS 로 스펙 경로를 지정하십시오.');
332
334
  }
@@ -0,0 +1,2 @@
1
+ export declare const DEFAULT_SERVER_SPECS = ".ax/specs";
2
+ export declare function resolveServerSpecsDir(env: NodeJS.ProcessEnv, cwd: string, exists?: (p: string) => boolean): string;
@@ -0,0 +1,71 @@
1
+ "use strict";
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
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.DEFAULT_SERVER_SPECS = void 0;
37
+ exports.resolveServerSpecsDir = resolveServerSpecsDir;
38
+ // @implements A-SPEC-652.2
39
+ /**
40
+ * Where the MCP server's spec store is. `HOLMES_SPECS` is written RELATIVE by `init` (`.ax/specs`,
41
+ * so the wiring file stays portable across machines) and used to be resolved against whatever cwd
42
+ * the server got. Measured 2026-09-14: a session's server refused every store-bound tool with
43
+ * "바인딩된 스펙 디렉터리 .ax/specs 가 존재하지 않습니다" — its cwd was not the project.
44
+ *
45
+ * The base for a relative path is, in order: `CLAUDE_PROJECT_DIR` (Claude Code sets it for every
46
+ * stdio MCP server to the stable project root — code.claude.com/docs/en/mcp), then the nearest
47
+ * ancestor of cwd holding `.mcp.json` or `.ax` (a server started in a sub-directory, or by a harness
48
+ * that sets no variable), then cwd itself (the old behaviour, for a project that has not run `init`).
49
+ * The answer is always absolute, so every refusal can name the directory it looked at.
50
+ */
51
+ const fs = __importStar(require("node:fs"));
52
+ const path = __importStar(require("node:path"));
53
+ exports.DEFAULT_SERVER_SPECS = '.ax/specs';
54
+ function resolveServerSpecsDir(env, cwd, exists = fs.existsSync) {
55
+ const configured = env.HOLMES_SPECS && env.HOLMES_SPECS.trim() !== '' ? env.HOLMES_SPECS : exports.DEFAULT_SERVER_SPECS;
56
+ if (path.isAbsolute(configured))
57
+ return path.normalize(configured);
58
+ const projectDir = env.CLAUDE_PROJECT_DIR;
59
+ if (projectDir && projectDir.trim() !== '' && exists(projectDir))
60
+ return path.resolve(projectDir, configured);
61
+ let dir = path.resolve(cwd);
62
+ for (;;) {
63
+ if (exists(path.join(dir, '.mcp.json')) || exists(path.join(dir, '.ax')))
64
+ return path.resolve(dir, configured);
65
+ const parent = path.dirname(dir);
66
+ if (parent === dir)
67
+ break;
68
+ dir = parent;
69
+ }
70
+ return path.resolve(cwd, configured);
71
+ }
@@ -11,7 +11,16 @@ const tool_schemas_1 = require("./tool-schemas");
11
11
  Object.defineProperty(exports, "HOOK_ENFORCED_TOOLS", { enumerable: true, get: function () { return tool_schemas_1.HOOK_ENFORCED_TOOLS; } });
12
12
  const validate_args_1 = require("./validate-args");
13
13
  // @implements A-SPEC-100.2
14
- const store = new spec_store_1.LocalMarkdownRepository(process.env.HOLMES_SPECS ?? '.ax/specs');
14
+ // @implements A-SPEC-652.2 — the store is opened at an ABSOLUTE path resolved against the project
15
+ // root (CLAUDE_PROJECT_DIR, then the nearest .mcp.json/.ax ancestor, then cwd), and the path is
16
+ // logged once to stderr (stdout is the protocol channel) so a mis-bound server is diagnosable.
17
+ const { resolveServerSpecsDir } = require('./server-specs-dir');
18
+ const SPECS_DIR = resolveServerSpecsDir(process.env, process.cwd());
19
+ try {
20
+ process.stderr.write(`[Holmes-Kit] spec store: ${SPECS_DIR}\n`);
21
+ }
22
+ catch { /* a diagnostic must never keep the server from starting */ }
23
+ const store = new spec_store_1.LocalMarkdownRepository(SPECS_DIR);
15
24
  // @implements A-SPEC-263.1 — the elicitation approval channel's wiring. The capability arrives at
16
25
  // initialize, AFTER this factory runs, so it is consulted lazily at CALL time.
17
26
  // @implements A-SPEC-497.1 — the three fates, told apart at THIS seam (the only place the SDK error
@@ -59,7 +59,8 @@ function validateExecution(value) {
59
59
  const scope = new node_async_hooks_1.AsyncLocalStorage();
60
60
  /** Internal entry-point boundary; an unavailable operator identity explicitly clears ambient scope. */
61
61
  function withOwnedExecution(root, execution, act) {
62
- const owned = execution ? { root: fs.realpathSync(root), execution: validateExecution(execution) } : undefined;
62
+ // @implements A-SPEC-651.1 the scope's root and every root compared against it share one spelling.
63
+ const owned = execution ? { root: (0, root_1.canonicalPath)(root), execution: validateExecution(execution) } : undefined;
63
64
  return scope.run(owned, act);
64
65
  }
65
66
  /** Writers may inherit only the context of the actual checkout that owns them. */
@@ -69,7 +70,7 @@ function executionForRoot(root) {
69
70
  return undefined;
70
71
  let actual = path.resolve(root);
71
72
  if (fs.existsSync(actual))
72
- actual = fs.realpathSync(actual);
73
+ actual = (0, root_1.canonicalPath)(actual);
73
74
  if (actual !== active.root)
74
75
  throw new workspace_identity_1.WorkspaceIdentityError('foreign-execution', 'The writer belongs to a different execution workspace.');
75
76
  return active.execution;
@@ -102,7 +103,7 @@ function createExecutionScope(storeRoot, observe) {
102
103
  if (identity.state !== 'registered')
103
104
  throw new workspace_identity_1.WorkspaceIdentityError('unregistered-replica', 'Register this replica through workspace_identity before governance writes.');
104
105
  const execution = validateExecution({ schema: 'holmes-execution/1', workspaceId: identity.workspaceId, replicaId: identity.replicaId, runId });
105
- return scope.run({ root: fs.realpathSync(bound.root), execution }, invoke);
106
+ return scope.run({ root: (0, root_1.canonicalPath)(bound.root), execution }, invoke);
106
107
  };
107
108
  }
108
109
  /** Validate observation attribution before an append; absence is never inferred from bad input. */
@@ -45,6 +45,15 @@ export declare const MARKER = ".ax";
45
45
  * makes: one spelling per directory, the spelling every path key is built from.
46
46
  */
47
47
  export declare const canonicalPath: (p: string) => string;
48
+ /**
49
+ * @implements A-SPEC-651.1 — "the same directory" judged by ONE spelling. Every path that names a
50
+ * workspace or a store is canonicalised the way `resolveProjectRoot` canonicalises (`realpath.native`,
51
+ * so 8.3 aliases and symlinks collapse); a path that does not exist compares by its resolved spelling.
52
+ * `realpath` is injectable so the 8.3 scenario runs on every OS in the tests. Measured 2026-09-14
53
+ * (WIN0200-20260914-SHORTPATH): `entityStoreBinding` compared JS `fs.realpathSync` output against a
54
+ * native-canonical root, so a workspace entered as `C:\Users\SUNGNA~1\…` was refused as foreign.
55
+ */
56
+ export declare function sameCanonicalDirectory(a: string, b: string, realpath?: (p: string) => string): boolean;
48
57
  export declare function resolveProjectRoot(dir: string, opts?: {
49
58
  includeGit?: boolean;
50
59
  }): ProjectRoot;
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.canonicalPath = exports.MARKER = void 0;
37
37
  exports.cleanSubprocessEnv = cleanSubprocessEnv;
38
38
  exports.cleanTestEnv = cleanTestEnv;
39
+ exports.sameCanonicalDirectory = sameCanonicalDirectory;
39
40
  exports.resolveProjectRoot = resolveProjectRoot;
40
41
  // @implements A-SPEC-205, A-SPEC-627
41
42
  // @implements A-SPEC-128
@@ -111,6 +112,23 @@ exports.MARKER = '.ax';
111
112
  const canonicalPath = (p) => fs.realpathSync.native(p);
112
113
  exports.canonicalPath = canonicalPath;
113
114
  const canonical = exports.canonicalPath;
115
+ /**
116
+ * @implements A-SPEC-651.1 — "the same directory" judged by ONE spelling. Every path that names a
117
+ * workspace or a store is canonicalised the way `resolveProjectRoot` canonicalises (`realpath.native`,
118
+ * so 8.3 aliases and symlinks collapse); a path that does not exist compares by its resolved spelling.
119
+ * `realpath` is injectable so the 8.3 scenario runs on every OS in the tests. Measured 2026-09-14
120
+ * (WIN0200-20260914-SHORTPATH): `entityStoreBinding` compared JS `fs.realpathSync` output against a
121
+ * native-canonical root, so a workspace entered as `C:\Users\SUNGNA~1\…` was refused as foreign.
122
+ */
123
+ function sameCanonicalDirectory(a, b, realpath = exports.canonicalPath) {
124
+ const spell = (p) => { try {
125
+ return realpath(p);
126
+ }
127
+ catch {
128
+ return path.resolve(p);
129
+ } };
130
+ return spell(a) === spell(b);
131
+ }
114
132
  function gitTopLevel(dir) {
115
133
  try {
116
134
  // stderr silenced: "not a git repository" is an expected branch here, not a fault worth printing.
@@ -475,7 +475,10 @@ function runPytest(files, mode, cwd) {
475
475
  return outcomes;
476
476
  };
477
477
  try {
478
- const out = (0, node_child_process_1.execFileSync)(python, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER, env: (0, root_1.cleanTestEnv)() });
478
+ // @implements A-SPEC-651.1 launch from the canonical spelling: pytest keys its JUnit `file`
479
+ // attribute relative to its rootdir, and an 8.3 invocation dir made that key climb out of the
480
+ // project (`../../…/tests/test_calc.py`, measured 2026-09-14 on Windows).
481
+ const out = (0, node_child_process_1.execFileSync)(python, args, { cwd: fs.existsSync(cwd) ? (0, root_1.canonicalPath)(cwd) : cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER, env: (0, root_1.cleanTestEnv)() });
479
482
  return { passed: true, tail: tailOf(out), executed: read(), outcomes: readOutcomes(true) };
480
483
  }
481
484
  catch (e) {
@@ -172,7 +172,10 @@ const FRAMEWORKS = [
172
172
  suiteSkip: /\bdescribe\s*(?:\.\s*skip|\[\s*['"]skip['"]\s*\])|\bxdescribe\b/,
173
173
  // Head must be a STANDALONE identifier (not `.test(`/`.it(` method calls) followed directly by
174
174
  // `(` or by `.each(...)(`: `it.skip(`/`it.todo(`/`xit(` never match — no subtraction needed.
175
- head: /(?<![.\w])(?:it|test|fit)(?:\s*\.\s*each\s*\([^)]*\)\s*)?\s*\(/g,
175
+ // @implements A-SPEC-653 — the platform helpers of A-SPEC-220.3 (`posixOnly('why')()`) are heads too:
176
+ // a POSIX-only suite written with the REPORTING skip must not read as "no real cases" (measured: that
177
+ // is why two suites had used a bare early return instead). `.skip` on a helper is still not a head.
178
+ head: /(?<![.\w])(?:it|test|fit|(?:posixOnly|posixOnlyNonRoot|posixOnlyWithCommand|win32Only)\s*\([^)]*\))(?:\s*\.\s*each\s*\([^)]*\)\s*)?\s*\(/g,
176
179
  },
177
180
  {
178
181
  id: 'pytest',
@@ -103,6 +103,15 @@ export interface EntityGitFileMerge {
103
103
  };
104
104
  }
105
105
  /** Merge already-normalized bytes. Scratch files are not a materialized Holmes workspace. */
106
+ /**
107
+ * @implements A-SPEC-652.6 — the environment `git merge-file` runs in: the caller's GIT_* stripped,
108
+ * the system and global configs blocked, the ceiling at the scratch parent. The "empty global
109
+ * config" is `os.devNull` on POSIX; on Windows `os.devNull` is `\\.\nul`, which git cannot open as
110
+ * a config file (`fatal: unable to access '\\.\nul': Invalid argument`, status 128 — measured
111
+ * 2026-09-15), so every content merge there was reported as a merge-tool-failure conflict. `NUL`
112
+ * is the spelling git accepts on that platform.
113
+ */
114
+ export declare function mergeToolEnv(base: NodeJS.ProcessEnv, scratch: string, platform?: NodeJS.Platform): NodeJS.ProcessEnv;
106
115
  export declare function mergeEntityGitFile(base: EntityGitFileVersion | null, left: EntityGitFileVersion | null, right: EntityGitFileVersion | null): EntityGitFileMerge;
107
116
  export interface EntityGitSourceBranch {
108
117
  files: Array<{
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.unchangedNonRegular = unchangedNonRegular;
37
37
  exports.planEntityGitMergedSources = planEntityGitMergedSources;
38
+ exports.mergeToolEnv = mergeToolEnv;
38
39
  exports.mergeEntityGitFile = mergeEntityGitFile;
39
40
  exports.planEntityGitSources = planEntityGitSources;
40
41
  exports.planEntityGitDocuments = planEntityGitDocuments;
@@ -250,6 +251,24 @@ async function planEntityGitMergedSources(root, revisions, storeLocator) {
250
251
  return result;
251
252
  }
252
253
  /** Merge already-normalized bytes. Scratch files are not a materialized Holmes workspace. */
254
+ /**
255
+ * @implements A-SPEC-652.6 — the environment `git merge-file` runs in: the caller's GIT_* stripped,
256
+ * the system and global configs blocked, the ceiling at the scratch parent. The "empty global
257
+ * config" is `os.devNull` on POSIX; on Windows `os.devNull` is `\\.\nul`, which git cannot open as
258
+ * a config file (`fatal: unable to access '\\.\nul': Invalid argument`, status 128 — measured
259
+ * 2026-09-15), so every content merge there was reported as a merge-tool-failure conflict. `NUL`
260
+ * is the spelling git accepts on that platform.
261
+ */
262
+ function mergeToolEnv(base, scratch, platform = process.platform) {
263
+ const env = { ...base };
264
+ for (const key of Object.keys(env))
265
+ if (key.startsWith('GIT_'))
266
+ delete env[key];
267
+ env.GIT_CONFIG_NOSYSTEM = '1';
268
+ env.GIT_CONFIG_GLOBAL = platform === 'win32' ? 'NUL' : os.devNull;
269
+ env.GIT_CEILING_DIRECTORIES = path.dirname(scratch);
270
+ return env;
271
+ }
253
272
  function mergeEntityGitFile(base, left, right) {
254
273
  const copy = (version) => version && ({ mode: version.mode, bytes: Buffer.from(version.bytes) });
255
274
  const inputs = { base: copy(base), left: copy(left), right: copy(right) };
@@ -287,13 +306,7 @@ function mergeEntityGitFile(base, left, right) {
287
306
  try {
288
307
  for (const [name, version] of Object.entries({ base, left, right }))
289
308
  fs.writeFileSync(path.join(scratch, name), version.bytes, { mode: 0o600 });
290
- const env = { ...process.env };
291
- for (const key of Object.keys(env))
292
- if (key.startsWith('GIT_'))
293
- delete env[key];
294
- env.GIT_CONFIG_NOSYSTEM = '1';
295
- env.GIT_CONFIG_GLOBAL = os.devNull;
296
- env.GIT_CEILING_DIRECTORIES = path.dirname(scratch);
309
+ const env = mergeToolEnv(process.env, scratch);
297
310
  const merged = (0, node_child_process_1.spawnSync)('git', ['merge-file', '--stdout', '--diff3', '-L', 'left', '-L', 'base', '-L', 'right', '--', 'left', 'base', 'right'], { cwd: scratch, env, maxBuffer: limit });
298
311
  if (merged.error || merged.status === null || merged.status > 127)
299
312
  return conflict('merge-tool-failure');
@@ -74,13 +74,15 @@ function entityStoreBinding(storeRoot) {
74
74
  const resolved = (0, root_1.resolveProjectRoot)(ancestor, { includeGit: false });
75
75
  if (resolved.marker !== '.ax')
76
76
  throw new entity_transaction_1.EntityStoreError('missing-workspace', 'The configured store must belong to a Holmes workspace.');
77
- const root = fs.realpathSync(resolved.root);
77
+ // @implements A-SPEC-651.1 — the same canonical spelling resolveProjectRoot used (realpath.native):
78
+ // an 8.3 alias on Windows or /var on macOS collapses on both sides of the comparison.
79
+ const root = (0, root_1.canonicalPath)(resolved.root);
78
80
  // The workspace may be reached through a platform alias (/var on macOS).
79
81
  // Normalize that prefix only; retain components inside it for symlink checks.
80
82
  let spelling = ancestor;
81
- while (fs.realpathSync(spelling) !== root && path.dirname(spelling) !== spelling)
83
+ while (!(0, root_1.sameCanonicalDirectory)(spelling, root) && path.dirname(spelling) !== spelling)
82
84
  spelling = path.dirname(spelling);
83
- if (fs.realpathSync(spelling) !== root)
85
+ if (!(0, root_1.sameCanonicalDirectory)(spelling, root))
84
86
  throw new entity_transaction_1.EntityStoreError('foreign-store', 'The configured store does not descend from the workspace.');
85
87
  const relative = path.relative(spelling, store);
86
88
  if (!relative || relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative))
@@ -72,6 +72,22 @@ export declare function settleEntityTransients(root: string, steps: EntityByteCh
72
72
  * after the move-aside is refused and both files are preserved.
73
73
  */
74
74
  export declare function applyEntityByteChange(root: string, change: EntityByteChange): void;
75
+ /**
76
+ * @implements A-SPEC-652.5 — Windows refuses to rename a directory while another process holds a
77
+ * file inside it open, and answers EPERM (EBUSY/EACCES in other shapes). A reader of the lock
78
+ * (entityLockOwner) holds owner.json for microseconds, so the writer's retirement rename raced it
79
+ * and died with `EPERM: operation not permitted, rename …/entity-locks/<hash> ->
80
+ * …/entity-retired-locks/<uuid>` (measured 2026-09-15 beside a looping reader). These codes are
81
+ * TRANSIENT there: retry briefly, on win32 only, for these codes only, within a fixed budget.
82
+ */
83
+ export declare function isTransientWindowsFsError(e: unknown): boolean;
84
+ export declare function renameWithTransientRetry(from: string, to: string, deps?: {
85
+ rename?: (from: string, to: string) => void;
86
+ platform?: NodeJS.Platform;
87
+ attempts?: number;
88
+ delayMs?: number;
89
+ sleep?: (ms: number) => void;
90
+ }): void;
75
91
  /** The shared source-publication coordination namespace, independent of every configured spec store (A-SPEC-634: exported so recovery can release it without importing the renumber module). */
76
92
  export declare const SOURCE_PUBLICATION_LOCATOR = ".ax/state/entity-renumber-source-publication";
77
93
  /** Age after which a `.staging-*` entry beside the locks is treated as abandoned by a publisher that died before its rename (A-SPEC-633). */
@@ -49,6 +49,8 @@ exports.isEntityTransientName = isEntityTransientName;
49
49
  exports.observeEntityTarget = observeEntityTarget;
50
50
  exports.settleEntityTransients = settleEntityTransients;
51
51
  exports.applyEntityByteChange = applyEntityByteChange;
52
+ exports.isTransientWindowsFsError = isTransientWindowsFsError;
53
+ exports.renameWithTransientRetry = renameWithTransientRetry;
52
54
  exports.withEntityStoreLock = withEntityStoreLock;
53
55
  exports.withEntityStoreLockAsync = withEntityStoreLockAsync;
54
56
  exports.entityLockHolderAlive = entityLockHolderAlive;
@@ -219,7 +221,11 @@ function entityLockOwner(root, locator) {
219
221
  bytes = readEntityBytes(path.join(dir, 'owner.json'));
220
222
  }
221
223
  catch (e) {
222
- vanished = e.code === 'ENOENT' || (e instanceof EntityStoreError && e.code === 'entity-state-changed');
224
+ // A-SPEC-652.5 on Windows a file inside a directory that is being renamed away answers
225
+ // EPERM/EBUSY for the instant of the rename: the generation is changing under the reader,
226
+ // the same fact ENOENT reports on POSIX.
227
+ vanished = e.code === 'ENOENT' || (e instanceof EntityStoreError && e.code === 'entity-state-changed')
228
+ || (process.platform === 'win32' && isTransientWindowsFsError(e));
223
229
  if (!vanished)
224
230
  throw e;
225
231
  }
@@ -456,6 +462,40 @@ function applyEntityByteChange(root, change) {
456
462
  fs.unlinkSync(temp);
457
463
  }
458
464
  }
465
+ /**
466
+ * @implements A-SPEC-652.5 — Windows refuses to rename a directory while another process holds a
467
+ * file inside it open, and answers EPERM (EBUSY/EACCES in other shapes). A reader of the lock
468
+ * (entityLockOwner) holds owner.json for microseconds, so the writer's retirement rename raced it
469
+ * and died with `EPERM: operation not permitted, rename …/entity-locks/<hash> ->
470
+ * …/entity-retired-locks/<uuid>` (measured 2026-09-15 beside a looping reader). These codes are
471
+ * TRANSIENT there: retry briefly, on win32 only, for these codes only, within a fixed budget.
472
+ */
473
+ function isTransientWindowsFsError(e) {
474
+ const code = e?.code;
475
+ return code === 'EPERM' || code === 'EBUSY' || code === 'EACCES';
476
+ }
477
+ const sleepSync = (ms) => { try {
478
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
479
+ }
480
+ catch { /* a runtime without Atomics.wait retries without waiting */ } };
481
+ function renameWithTransientRetry(from, to, deps = {}) {
482
+ const rename = deps.rename ?? ((a, b) => fs.renameSync(a, b));
483
+ const platform = deps.platform ?? process.platform;
484
+ const attempts = Math.max(1, deps.attempts ?? 50);
485
+ const delayMs = deps.delayMs ?? 20;
486
+ const sleep = deps.sleep ?? sleepSync;
487
+ for (let attempt = 1;; attempt++) {
488
+ try {
489
+ rename(from, to);
490
+ return;
491
+ }
492
+ catch (e) {
493
+ if (platform !== 'win32' || !isTransientWindowsFsError(e) || attempt >= attempts)
494
+ throw e;
495
+ sleep(delayMs);
496
+ }
497
+ }
498
+ }
459
499
  function retireOwnedLock(root, dir, expected, owned) {
460
500
  const current = entityStat(dir);
461
501
  if (current?.dev !== expected.dev || current?.ino !== expected.ino)
@@ -468,7 +508,7 @@ function retireOwnedLock(root, dir, expected, owned) {
468
508
  if (entityStat(destination))
469
509
  throw new EntityStoreError('retirement-conflict', 'Lock retirement destination is occupied.');
470
510
  // Release the active pathname in one step, retaining owner evidence until then.
471
- fs.renameSync(dir, destination);
511
+ renameWithTransientRetry(dir, destination);
472
512
  for (const file of owned)
473
513
  fs.unlinkSync(path.join(destination, path.basename(file)));
474
514
  fs.rmdirSync(destination);
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.20.0",
4
+ "version": "0.20.1",
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",
@@ -59,7 +59,10 @@
59
59
  "testMatch": [
60
60
  "**/src/**/*.test.ts"
61
61
  ],
62
- "testTimeout": 30000
62
+ "testTimeout": 30000,
63
+ "setupFilesAfterEnv": [
64
+ "<rootDir>/src/holmes/test-support/jest-timeouts-setup.ts"
65
+ ]
63
66
  },
64
67
  "dependencies": {
65
68
  "@modelcontextprotocol/sdk": "^1.29.0",