@holmes-lab/holmes-kit 0.19.3 → 0.19.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +163 -0
  2. package/README.md +22 -4
  3. package/dist/.build-id +1 -1
  4. package/dist/holmes/cli/agents.d.ts +8 -0
  5. package/dist/holmes/cli/agents.js +26 -2
  6. package/dist/holmes/cli/codex-toml.d.ts +10 -0
  7. package/dist/holmes/cli/codex-toml.js +76 -12
  8. package/dist/holmes/cli/doctor.d.ts +19 -0
  9. package/dist/holmes/cli/doctor.js +107 -42
  10. package/dist/holmes/cli/index.js +13 -0
  11. package/dist/holmes/cli/init.js +10 -3
  12. package/dist/holmes/cli/native-deps.js +4 -1
  13. package/dist/holmes/cli/playbook-skills.js +6 -4
  14. package/dist/holmes/cli/probe-process.d.ts +8 -0
  15. package/dist/holmes/cli/probe-process.js +73 -0
  16. package/dist/holmes/cli/spawn-spec.js +3 -1
  17. package/dist/holmes/cli/test-platform.d.ts +37 -0
  18. package/dist/holmes/cli/test-platform.js +126 -1
  19. package/dist/holmes/governance/approval-grants.js +26 -3
  20. package/dist/holmes/governance/autonomy.d.ts +17 -1
  21. package/dist/holmes/governance/autonomy.js +37 -5
  22. package/dist/holmes/mcp/handlers.d.ts +30 -5
  23. package/dist/holmes/mcp/handlers.js +111 -13
  24. package/dist/holmes/mcp/spec-id-guard.d.ts +1 -1
  25. package/dist/holmes/mcp/spec-id-guard.js +9 -13
  26. package/dist/holmes/mcp/tool-schemas.js +13 -0
  27. package/dist/holmes/project/install-scripts-policy.d.ts +16 -2
  28. package/dist/holmes/project/install-scripts-policy.js +16 -2
  29. package/dist/holmes/review/point-in-time-replay.js +43 -3
  30. package/dist/holmes/rtm/graph-store.d.ts +2 -0
  31. package/dist/holmes/rtm/graph-store.js +14 -0
  32. package/dist/holmes/rtm/rtm-graph.js +42 -30
  33. package/dist/holmes/semantic/credentials.js +86 -9
  34. package/dist/holmes/semantic/embedder.js +6 -39
  35. package/dist/holmes/semantic/local-model.d.ts +30 -0
  36. package/dist/holmes/semantic/local-model.js +92 -0
  37. package/dist/holmes/semantic/model-cache.d.ts +8 -0
  38. package/dist/holmes/semantic/model-cache.js +67 -0
  39. package/dist/holmes/semantic/tier.d.ts +7 -0
  40. package/dist/holmes/semantic/tier.js +9 -3
  41. package/dist/holmes/spec/renumber.d.ts +72 -0
  42. package/dist/holmes/spec/renumber.js +341 -0
  43. package/dist/holmes/spec/spec-id.d.ts +9 -0
  44. package/dist/holmes/spec/spec-id.js +23 -0
  45. package/docs/install-guide.md +90 -2
  46. package/package.json +9 -3
  47. package/scripts/install.ps1 +30 -27
package/CHANGELOG.md CHANGED
@@ -5,6 +5,169 @@ 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.19.5] - 2026-09-11
9
+
10
+ One critical supply-chain advisory removed, by measuring a prediction this project had written down
11
+ and found to be wrong.
12
+
13
+ ### Security
14
+ - **`protobufjs` is overridden to 7.x, and the critical advisory is gone** (REQ-601). The arbitrary
15
+ code execution lives in protobufjs's 6.x line, reached three levels down —
16
+ `@xenova/transformers → onnxruntime-web → onnx-proto → protobufjs@6.11.6` — and this project's own
17
+ supply-chain note predicted that overriding it would break `onnx-proto`, which pins 6.x. That
18
+ prediction was **refuted**: the Windows qualification run measured it first (critical 0 / high 3,
19
+ inference intact) and macOS reproduced it with protobufjs as the only variable, verifying import,
20
+ `ModelProto.decode`, and a **real 1024-dimension normalized inference** — the path where
21
+ `onnx-proto` actually decodes a model, which is where a 6→7 incompatibility would surface.
22
+
23
+ | | critical | high | protobufjs |
24
+ |---|---|---|---|
25
+ | 0.19.4 | 1 | 5 | 6.11.6 |
26
+ | **0.19.5** | **0** | **3** | **7.6.6** |
27
+
28
+ A guard reads the **lockfile**, not the manifest: the manifest is the intent and the lock is the
29
+ fact, so asserting the declaration alone would stay green while the override was being ignored —
30
+ and an override that quietly stops applying brings the critical back without a word. The remaining
31
+ three highs (`@xenova/transformers`, `js-yaml`, `sharp`) are untouched and still recorded, with
32
+ their re-evaluation triggers, in `docs/goals/supply-chain-xenova-2026-09.md`.
33
+
34
+ ## [0.19.4] - 2026-09-10
35
+
36
+ Three Windows diagnoses that pointed the wrong way, found by running the Windows plan on a real
37
+ non-admin machine (Windows 11, Node 24.19, npm 12.0.1, codex 0.153.4); then two things the new
38
+ required dependency broke on every operating system, found by reviewing that work on macOS.
39
+
40
+ ### Changed
41
+ - **Preparing the local model is now opt-in** (REQ-594). Installing the package, running
42
+ `holmes-kit init`, and the Windows bootstrap no longer download model assets on their own; each
43
+ names `holmes-kit semantic-setup` instead. Set `HOLMES_AUTO_MODEL_INSTALL=1` to prepare during
44
+ installation. `HOLMES_SKIP_MODEL_INSTALL=1` still refuses and OUTRANKS the opt-in, so an explicit
45
+ refusal is never overridden, and the two skips say different things — telling someone they opted
46
+ out when they did not is a lie. All three operating systems share the default: the bootstrap
47
+ invokes the explicit command, which always downloads, so the gate had to live in the bootstrap too
48
+ or Windows would keep fetching after macOS and Linux stopped.
49
+
50
+ ### Fixed
51
+ - **The semantic tier reported a capability it did not have** (REQ-594). Availability was decided by
52
+ `require.resolve('@xenova/transformers')`. Once that package became a required dependency the
53
+ predicate answered true for every install and stopped discriminating, so every user without a
54
+ cloud key was reported as `tier: local` — including users who had never downloaded a model, for
55
+ whom no embedding is produced at all. Availability is now a conjunction of runtime AND a prepared
56
+ model cache; an empty cache directory counts as unprepared, because an interrupted download leaves
57
+ exactly that. Until the model is prepared, `doctor` reports tier `none`, names what it costs
58
+ (requests lexical search misses, measured 16.4%, have 0% recall) and the one command that changes
59
+ it — as `PASS`, since a warning no action can resolve teaches people to skip the report.
60
+ - **A new native dependency was approved without the rule that justifies it** (REQ-595).
61
+ `sharp@0.32.6` was added to `allowScripts` while the measured required-script list still held one
62
+ package, so the policy told readers sharp "loads from its shipped prebuilds without an install
63
+ script". Measured 2026-09-10, that is false everywhere: its tarball ships no native binary, its
64
+ hook is `prebuild-install || node-gyp rebuild`, the lockfile records `hasInstallScript: true`.
65
+ Because `allowScripts` is a root-manifest policy that does not transfer to consumers, a rule that
66
+ did not know sharp meant no consumer was ever told to approve it. Both required packages are now
67
+ named. The guard was not loosened: every member of the list must be backed by the lockfile, and an
68
+ empty lock now reports every required package rather than only the first.
69
+
70
+ ### Added
71
+ - **`spec_renumber`** (REQ-255). Two sessions working from the same base each issued number 596, and
72
+ the repository's own prescription for that was `renumber one (manual until REQ-255) and re-seal` —
73
+ a tool specified after the 2026-08-23 incident and never built. A hand renumber then moved two
74
+ anchors and left seven prose references, one of them inside the shipped `install.ps1`, and the bulk
75
+ regex brought in to finish the job rewrote REQ-253's own `source.ref` into a citation of itself.
76
+ The tool is deliberately asymmetric: it moves what has a definite grammar (filenames, frontmatter
77
+ `id`, every `depends_on`, `slice` tags, `@implements` anchors) and only REPORTS what needs context
78
+ (prose, comments, test titles, CHANGELOG). Re-sealing stays with `spec_approve`; the plan returns
79
+ the two orders instead, which are opposites because unsealing refuses child-first and approving
80
+ refuses parent-first. `dryRun` is the default.
81
+ - **`win32Only`** (A-SPEC-255). The mirror of `posixOnly`, for tests whose subject only exists on
82
+ Windows — NTFS ACLs, PowerShell 5.1 parsing. Without it those tests were written as anonymous
83
+ `platform === 'win32' ? it : it.skip` ternaries: a skip with no stated reason, which is the silence
84
+ `posixOnly` exists to end, in different syntax. A reason is required, as it is there.
85
+
86
+ ### Fixed
87
+ - **Windows 0.19.4 release qualification** (REQ-599, merged from the Windows branch; issued there as
88
+ 596 and renumbered here — both sessions had forked from the same base and claimed that number).
89
+ Raw Git blob bytes are preserved during replay despite `autocrlf` and explicit EOL attributes;
90
+ native project/store paths are compared canonically, including 8.3 short aliases; npm's JavaScript
91
+ entry is invoked through Node and the Windows prepublish Jest run is held to two workers, because
92
+ seven concurrent workers produced startup failures in six suites; PowerShell 5.1 recovery commands
93
+ are failure-aware and the SQLite plus sharp script approvals are documented. Windows fixtures were
94
+ repaired for filesystem permissions, branch names, paths, environment key case, npm JSON versions
95
+ and line endings **while keeping their negative assertions**. File identities are read as BigInt in
96
+ playbook skill alias detection: actual NTFS ids exceeded Number precision, so distinct ids collided
97
+ numerically and blocked refresh — genuine hard-link refusal is preserved.
98
+ - **The release gate could not find npm and died looking** (REQ-600). `npmCommand` derived its
99
+ candidates from `process.execPath`, and Node resolves that through the symlink: Homebrew's
100
+ `/opt/homebrew/bin/node` becomes `/opt/homebrew/Cellar/node/<ver>/bin/node`, so `../lib` points
101
+ inside the Cellar while npm sits at `/opt/homebrew/lib/node_modules/npm`. It then THREW. Measured
102
+ when the evidence runner went red on nine assertions while the same suite was green under `npx`,
103
+ which sets `npm_execpath`. The search now also looks beside the node that is on PATH — the place
104
+ the symlink came from, which also covers nvm, fnm, volta and distribution packages — and when
105
+ nothing is found it degrades to the platform `npm` instead of dying. Running npm is the goal; the
106
+ JS entry point is only how the Windows shell is avoided, and `execFile` runs a `.cmd` without one.
107
+ - **`doctor` called a protected global prefix writable** (REQ-584). The check asked
108
+ `fs.accessSync(W_OK)`, which on Windows reads POSIX mode bits and never the ACL: on a non-admin
109
+ account with prefix `C:\Program Files\nodejs` it answered "writable" while `mkdir` died EPERM —
110
+ the very error the user then met in `npm install -g`. The probe now TRIES: it creates and removes
111
+ a directory in the deepest existing ancestor (the same syscall npm fails on). Measured on that
112
+ machine: `WARN` with the user-space prefix prescription; `%APPDATA%\npm` still `PASS`. A probe
113
+ entry that cannot be removed is named, never hidden. POSIX verdicts are pinned unchanged.
114
+ - **The antigravity 8.3 conversion had never fired** (REQ-586). `shortPathIfSpaced` handed cmd a
115
+ `for %I in ("<path>") do @echo %~sI` line through Node's default win32 quoting, which rewrites the
116
+ inner quotes as `\"`; cmd's `for` then answered `C:\"C:\Users\…\"` — spaced and quoted — and the
117
+ function fell back to the spaced path. `init --agent antigravity` on an install under
118
+ `C:\Users\<name with a space>\…` therefore wrote an unquoted spaced command agy's launcher can never
119
+ run, and printed "the gate is enforced". The line now goes to cmd verbatim
120
+ (`windowsVerbatimArguments`), the win32 test asserts a REAL conversion on a spaced temp path, and
121
+ when a space still survives (a volume without 8.3 names) init says so instead of claiming
122
+ enforcement. Its sibling: `doctor` then rejected the correct 8.3 wiring as "not this install",
123
+ because `fs.realpathSync` does not expand short names — `realpathSync.native` does, and all three
124
+ wiring checks that share `resolvesToPackage` now use it (A-SPEC-586.2). Measured end to end on
125
+ the spaced install: hook written as `HOLMES~1.JS`, hook runs, `antigravity wiring` PASS.
126
+ - **The Windows `doctor-wiring-handshake` red had a third cause, and doctor hid it** (REQ-585).
127
+ Not the writer (0.19.1's note — `tomlStr` escapes correctly) and not cwd (0.19.2's hypothesis —
128
+ `doctor` now answers the same from `C:\` and the repo root): the test fixture wrote a Windows path
129
+ with raw backslashes into a TOML basic string, and the reader folded every unknown escape to its
130
+ bare letter, so `C:\ai_project\…` became `C:ai_project…` and doctor judged a path nobody wrote. A
131
+ hand-written `config.toml` does the same, and Codex refuses such a file outright. The reader now
132
+ accepts only the escapes TOML defines (`\b \t \n \f \r \" \\ \uXXXX \UXXXXXXXX`), an invalid one
133
+ makes the entry unreadable, and `codex wiring` names the escape and its line with the two valid
134
+ spellings. The fixture goes through `codexMcpBlock`, the same serializer init uses. The suite is
135
+ green on Windows for the first time.
136
+
137
+ ### Changed
138
+ - **Autonomous approval now covers a low-risk slice's REQ and H-SPEC** (REQ-587). With
139
+ `HOLMES_AUTONOMOUS_APPROVAL` on, upstream documents were graded `hitl` by kind alone, so every
140
+ slice cost the operator two terminal sittings (`holmes-kit approve` for the REQ, again for the
141
+ H-SPEC) — six for the three fixes above. ADR-009's line is "self-approve by default, non-bypassable
142
+ HITL for high risk, risk decided deterministically", and a REQ or H-SPEC carries no code of its
143
+ own: its risk is the risk its A-SPECs declare. `specApprovalAutonomy` now takes the downstream
144
+ A-SPECs and derives the upstream grade — all auto → auto; any hitl (gate behaviour, a
145
+ governance/guardrail/hook/taint path), or no A-SPEC yet, → human. C-SPEC and ADR stay human
146
+ regardless. The ledger line names the A-SPECs the grade was derived from. Autonomy off is
147
+ byte-identical.
148
+ - **`spec_slice_approve` is idempotent** (A-SPEC-587.2). Called again after the REQ was sealed it
149
+ requested a RE-SEAL of that REQ (same request id back in the queue) and stopped there, so the
150
+ H-SPEC was never attempted. Sealed specs are now skipped and reported under `alreadySealed`; the
151
+ chain resumes at the first unsealed spec.
152
+
153
+ ### Measured, no change needed
154
+ - **codex hook commands on a spaced path** (the plan's prediction A/B): codex runs a command hook as
155
+ `%COMSPEC% /C "<command_line>"` (codex-rs `hooks/src/engine/command_runner.rs`; the 0.153.4
156
+ binary carries the same runner), a quote-respecting shell. The quoted `node "<path>"` form
157
+ succeeded 3/3 on a spaced install. `kernelCommand` is unchanged.
158
+ - **npm 12 third-party install on Windows**: install scripts blocked → `doctor` diagnosed
159
+ `scripts-blocked` with the `.cmd` recovery line → pasted verbatim, the binding was built and
160
+ `doctor` went to 12 pass / 0 fail. Three-harness wiring from a foreign cwd: 3/3 handshakes.
161
+
162
+ ### Windows baseline, classified
163
+ A clean `main` on this machine fails 49 of 416 suites in isolation — none are regressions from this
164
+ change. Sixteen were `Cannot find module 'web-tree-sitter'` (a dependency added by a recent pull and
165
+ not yet installed; `npm install` turns 17 of the 18 green). The rest are environment: spawn
166
+ timeouts in doctor tests, jest-worker "circular structure" crashes (`approve`, `mcp-launcher`,
167
+ `doctor-mcp-wiring-spawn`), `chmod`-based negatives that cannot fail on NTFS, git-history corpora,
168
+ CRLF checkouts compared byte-for-byte, and SQLite "file is not a database" on a corrupt-store
169
+ negative. The list with first-failure lines lives in `docs/goals/GOAL-windows-hardening-2026-09-10.md`.
170
+
8
171
  ## [0.19.3] - 2026-09-10
9
172
 
10
173
  `doctor` told every third-party install its SQLite binding was missing while it sat on disk. No API change.
package/README.md CHANGED
@@ -148,12 +148,27 @@ Out of the box Holmes-Kit runs tier **`none`** — lexical + citation + graph se
148
148
  egress**. Two opt-in tiers raise recall on requests your vocabulary can't reach (measured on 305
149
149
  traceability cases — see the feature list above):
150
150
 
151
- **Tier `local` — no egress, no account.** Install the optional embedding runtime next to
152
- holmes-kit and the local model (`bge-m3`) is picked up automatically:
151
+ **Tier `local` — no egress, no account.** The embedding runtime ships with holmes-kit as of
152
+ 0.19.4, so nothing extra to install. Preparing the model is **opt-in**: neither installation nor
153
+ `init` downloads it, because a large download nobody asked for is not a default. One command:
153
154
  ```bash
154
- npm install --save-dev @xenova/transformers
155
- npx holmes-kit doctor # semantic tier: local (no egress)
155
+ npx holmes-kit semantic-setup # downloads the public bge-m3 assets once, shared across projects
156
+ npx holmes-kit semantic-check # verifies OFFLINE inference a real 1024-dim normalized vector
157
+ npx holmes-kit doctor # → semantic tier: local (no egress)
156
158
  ```
159
+ Until it is prepared, `doctor` reports tier **`none`** and names what that costs — requests lexical
160
+ search misses (measured 16.4%) have 0% recall — together with the command that changes it. A runtime
161
+ that resolves is not a tier that answers.
162
+
163
+ To prepare during installation instead, set `HOLMES_AUTO_MODEL_INSTALL=1` before installing.
164
+ `HOLMES_SKIP_MODEL_INSTALL=1` refuses preparation and **outranks** the opt-in, so an explicit refusal
165
+ is never overridden. Models live in `~/.holmes/models` (`%USERPROFILE%\.holmes\models` on Windows);
166
+ `HOLMES_MODEL_CACHE` overrides that, including for a prepopulated offline cache.
167
+
168
+ > On **ARM Linux**, install with Node 20 or 22: `tree-sitter@0.21.1` ships no `linux-arm64` prebuild,
169
+ > so it compiles from source, and that compile fails against Node 24 headers. The failure happens
170
+ > while npm is still installing dependencies — before Holmes-Kit exists — so `doctor` cannot diagnose
171
+ > it. On x64 the prebuild is used and Node 24 is fine.
157
172
 
158
173
  **Tier `cloud` — highest recall, explicit consent (`gemini-embedding-001`).** Setting a key IS
159
174
  the consent act: with a key present, repository-derived text is sent to Google's embedding API.
@@ -213,6 +228,9 @@ flowchart LR
213
228
  | `holmes-kit autonomy status` | Show the current autonomous-approval posture (project default + session envelope) |
214
229
  | `holmes-kit autonomy on --for 2h` | Self-drive low-risk specs for this session only (expiring; needs a TTY or `HOLMES_APPROVAL`) |
215
230
  | `holmes-kit autonomy off` | End session autonomy immediately |
231
+ | `holmes-kit semantic-setup` | Download and verify the local `bge-m3` model (opt-in; shared across projects) |
232
+ | `holmes-kit semantic-check` | Verify local inference **offline**, without downloading anything |
233
+ | `holmes-kit semantic-key set` | Store the cloud-tier key outside the tree (stdin only, never argv) |
216
234
  | `holmes-kit doctor` | Comprehensive health check of specs, hooks, MCP, and anchors |
217
235
  | `holmes-kit doctor --fix` | Automatically self-heal and repair broken hooks or missing skills |
218
236
  | `holmes-kit ci` | Run non-interactive headless governance gate for GitHub Actions / GitLab CI |
package/dist/.build-id CHANGED
@@ -1 +1 @@
1
- fb773a8-mtug045o
1
+ 5efeb5b9-mtx1x7js
@@ -62,6 +62,14 @@ export declare function antigravityHookCommand(scriptPath: string): string;
62
62
  /** win32-only, best-effort: a spaced path becomes its 8.3 short name (no spaces); anything else — or
63
63
  * any lookup failure — returns the input unchanged. */
64
64
  export declare function shortPathIfSpaced(p: string): string;
65
+ /**
66
+ * @implements A-SPEC-586
67
+ * The agy launcher splits its single command string on whitespace, so a hook path that still carries
68
+ * a space after the 8.3 attempt is a hook that never runs. One line per such hook — init prints
69
+ * these INSTEAD of "the gate is enforced". Empty when every path is space-free (posix without a
70
+ * space, or win32 where 8.3 resolved it).
71
+ */
72
+ export declare function antigravityHookWarnings(packageRoot: string): string[];
65
73
  export declare const MANAGED_BEGIN = "<!-- holmes-kit:managed:begin -->";
66
74
  export declare const MANAGED_END = "<!-- holmes-kit:managed:end -->";
67
75
  /**
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.MANAGED_END = exports.MANAGED_BEGIN = exports.CODEX_MARKETPLACE = exports.CODEX_PLUGIN_DIR = exports.HARNESS_ENFORCES = exports.AGENTS = void 0;
37
37
  exports.antigravityHookCommand = antigravityHookCommand;
38
38
  exports.shortPathIfSpaced = shortPathIfSpaced;
39
+ exports.antigravityHookWarnings = antigravityHookWarnings;
39
40
  exports.mergeAgentsMd = mergeAgentsMd;
40
41
  exports.agentFiles = agentFiles;
41
42
  exports.agentLinks = agentLinks;
@@ -132,13 +133,36 @@ function shortPathIfSpaced(p) {
132
133
  if (process.platform !== 'win32' || !p.includes(' '))
133
134
  return p;
134
135
  try {
135
- const out = (0, node_child_process_1.execFileSync)('cmd', ['/d', '/c', `for %I in ("${p}") do @echo %~sI`], { encoding: 'utf8' }).trim();
136
- return out !== '' && !out.includes(' ') ? out : p;
136
+ // @implements A-SPEC-586 VERBATIM. Node's default win32 quoting rewrites the inner `"` as `\"`
137
+ // (CRT rules), but cmd.exe is not a CRT program: its `for` then sees `(\"C:\…\")` and `%~sI`
138
+ // answers `C:\"C:\Users\…\"` — spaced and quoted, so the check below rejected it and the
139
+ // conversion had NEVER fired (measured 2026-09-10). Handing the line to cmd untouched yields
140
+ // the 8.3 form on the same path.
141
+ const r = (0, node_child_process_1.spawnSync)('cmd', ['/d', '/c', `for %I in ("${p}") do @echo %~sI`], { encoding: 'utf8', windowsVerbatimArguments: true });
142
+ const out = r.status === 0 && typeof r.stdout === 'string' ? r.stdout.trim() : '';
143
+ return out !== '' && !out.includes(' ') && !out.includes('"') ? out : p;
137
144
  }
138
145
  catch {
139
146
  return p;
140
147
  }
141
148
  }
149
+ /**
150
+ * @implements A-SPEC-586
151
+ * The agy launcher splits its single command string on whitespace, so a hook path that still carries
152
+ * a space after the 8.3 attempt is a hook that never runs. One line per such hook — init prints
153
+ * these INSTEAD of "the gate is enforced". Empty when every path is space-free (posix without a
154
+ * space, or win32 where 8.3 resolved it).
155
+ */
156
+ function antigravityHookWarnings(packageRoot) {
157
+ const out = [];
158
+ for (const script of ['holmes-hook-antigravity.js', 'holmes-stop-antigravity.js']) {
159
+ const final = shortPathIfSpaced(path.join(packageRoot, 'bin', script));
160
+ if (!/\s/.test(final))
161
+ continue;
162
+ out.push(`antigravity: the hook path still contains a space (${final.replace(/\\/g, '/')}) — agy's launcher splits the command on whitespace, so this hook never runs and the gate is NOT enforced. Install holmes-kit in a path without spaces${process.platform === 'win32' ? ' (or enable NTFS 8.3 names on this volume)' : ''}.`);
163
+ }
164
+ return out;
165
+ }
142
166
  const antigravityHookEntry = (packageRoot, script) => ({
143
167
  type: 'command',
144
168
  command: antigravityHookCommand(shortPathIfSpaced(path.join(packageRoot, 'bin', script))),
@@ -24,3 +24,13 @@ export declare function readCodexHolmesEntry(raw: string): {
24
24
  command: string;
25
25
  args: string[];
26
26
  } | null;
27
+ /**
28
+ * @implements A-SPEC-585
29
+ * The first invalid basic-string escape inside OUR table's `command =` / `args =` lines, with its
30
+ * 1-based line — or null when every escape there is one TOML defines. Doctor turns this into the
31
+ * diagnosis Codex itself would give (it refuses the file), instead of judging a mangled path.
32
+ */
33
+ export declare function invalidTomlEscape(raw: string): {
34
+ escape: string;
35
+ line: number;
36
+ } | null;
@@ -5,6 +5,7 @@ exports.codexMcpBlock = codexMcpBlock;
5
5
  exports.mergeCodexToml = mergeCodexToml;
6
6
  exports.removeCodexToml = removeCodexToml;
7
7
  exports.readCodexHolmesEntry = readCodexHolmesEntry;
8
+ exports.invalidTomlEscape = invalidTomlEscape;
8
9
  /** The one table holmes-kit owns in a Codex config.toml. */
9
10
  exports.CODEX_TABLE = 'mcp_servers.holmes-kit';
10
11
  const HEADER = `[${exports.CODEX_TABLE}]`;
@@ -251,24 +252,87 @@ function readCodexHolmesEntry(raw) {
251
252
  return null;
252
253
  let command = null;
253
254
  let args = null;
254
- for (let i = region.start + 1; i < region.end; i++) {
255
- const cmd = lines[i].match(/^\s*command\s*=\s*"((?:[^"\\]|\\.)*)"\s*(#.*)?$/);
256
- if (cmd)
257
- command = unescapeToml(cmd[1]);
258
- const arr = lines[i].match(/^\s*args\s*=\s*\[(.*)\]\s*(#.*)?$/);
259
- if (arr)
260
- args = parseTomlStringArray(arr[1]);
255
+ try {
256
+ for (let i = region.start + 1; i < region.end; i++) {
257
+ const cmd = lines[i].match(/^\s*command\s*=\s*"((?:[^"\\]|\\.)*)"\s*(#.*)?$/);
258
+ if (cmd)
259
+ command = unescapeToml(cmd[1]);
260
+ const arr = lines[i].match(/^\s*args\s*=\s*\[(.*)\]\s*(#.*)?$/);
261
+ if (arr)
262
+ args = parseTomlStringArray(arr[1]);
263
+ }
264
+ }
265
+ catch (e) {
266
+ if (e instanceof InvalidTomlEscape)
267
+ return null; // @implements A-SPEC-585 — unreadable, not mangled
268
+ throw e;
261
269
  }
262
270
  if (command === null || args === null)
263
271
  return null;
264
272
  return { command, args };
265
273
  }
274
+ /**
275
+ * @implements A-SPEC-585
276
+ * The first invalid basic-string escape inside OUR table's `command =` / `args =` lines, with its
277
+ * 1-based line — or null when every escape there is one TOML defines. Doctor turns this into the
278
+ * diagnosis Codex itself would give (it refuses the file), instead of judging a mangled path.
279
+ */
280
+ function invalidTomlEscape(raw) {
281
+ const lines = raw.split('\n');
282
+ const region = ourRegion(lines);
283
+ if (!region)
284
+ return null;
285
+ for (let i = region.start + 1; i < region.end; i++) {
286
+ if (!/^\s*(command|args)\s*=/.test(lines[i]))
287
+ continue;
288
+ const re = /"((?:[^"\\]|\\.)*)"/g;
289
+ let mm;
290
+ while ((mm = re.exec(lines[i])) !== null) {
291
+ const bad = firstInvalidEscape(mm[1]);
292
+ if (bad !== null)
293
+ return { escape: bad, line: i + 1 };
294
+ }
295
+ }
296
+ return null;
297
+ }
298
+ class InvalidTomlEscape extends Error {
299
+ escape;
300
+ constructor(escape) {
301
+ super(`invalid TOML escape ${escape}`);
302
+ this.escape = escape;
303
+ }
304
+ }
305
+ const SIMPLE_ESCAPES = { b: '\b', t: '\t', n: '\n', f: '\f', r: '\r', '"': '"', '\\': '\\' };
306
+ /** The offending escape (`\a`, or a `\u`/`\U` with too few hex digits), or null when all are valid. */
307
+ function firstInvalidEscape(s) {
308
+ for (let i = 0; i < s.length; i++) {
309
+ if (s[i] !== '\\')
310
+ continue;
311
+ const e = s[i + 1] ?? '';
312
+ if (e in SIMPLE_ESCAPES) {
313
+ i += 1;
314
+ continue;
315
+ }
316
+ const width = e === 'u' ? 4 : e === 'U' ? 8 : 0;
317
+ if (width > 0 && new RegExp(`^[0-9a-fA-F]{${width}}$`).test(s.slice(i + 2, i + 2 + width))) {
318
+ i += 1 + width;
319
+ continue;
320
+ }
321
+ return `\\${e}`;
322
+ }
323
+ return null;
324
+ }
325
+ // @implements A-SPEC-585 — strict: only the escapes TOML defines. Anything else THROWS, so the
326
+ // caller answers "unreadable" — an unknown escape used to fold to its bare letter and turned
327
+ // `C:\ai_project\…` into `C:ai_project…` (measured on every Windows checkout).
266
328
  function unescapeToml(s) {
267
- return s.replace(/\\(u[0-9a-fA-F]{4}|.)/g, (_, e) => {
268
- if (e[0] === 'u')
269
- return String.fromCharCode(parseInt(e.slice(1), 16));
270
- const map = { n: '\n', r: '\r', t: '\t', '"': '"', '\\': '\\' };
271
- return map[e] ?? e;
329
+ const bad = firstInvalidEscape(s);
330
+ if (bad !== null)
331
+ throw new InvalidTomlEscape(bad);
332
+ return s.replace(/\\(U[0-9a-fA-F]{8}|u[0-9a-fA-F]{4}|.)/g, (_, e) => {
333
+ if (e[0] === 'u' || e[0] === 'U')
334
+ return String.fromCodePoint(parseInt(e.slice(1), 16));
335
+ return SIMPLE_ESCAPES[e];
272
336
  });
273
337
  }
274
338
  /** Parse a TOML inline array of basic strings: `"a", "b"` → ['a','b']. Non-conforming → []. */
@@ -1,5 +1,9 @@
1
1
  import { NativeEvidence } from './native-deps';
2
2
  import { type SemanticTier } from '../semantic/tier';
3
+ /**
4
+ * Checks if a script path belongs to the packageRoot, supporting symlinked global installs.
5
+ */
6
+ export declare function resolvesToPackage(filePath: string, packageRoot: string): boolean;
3
7
  /**
4
8
  * `holmes-kit doctor` — install diagnostics.
5
9
  *
@@ -62,7 +66,22 @@ export interface PrefixProbe {
62
66
  writable?: boolean;
63
67
  platform?: string;
64
68
  unreadable?: string;
69
+ /** @implements A-SPEC-584 — a probe entry the caller created but could not remove. */
70
+ leftover?: string;
65
71
  }
72
+ /**
73
+ * @implements A-SPEC-584
74
+ * Is `dir` writable by THIS account? Asked by TRYING, not by `fs.accessSync(W_OK)`: on Windows the
75
+ * mode bits accessSync consults do not carry the ACL, so it answered "writable" for
76
+ * `C:\Program Files\nodejs` on a non-admin account while `mkdir` died EPERM (measured 2026-09-10).
77
+ * A mkdir is the very syscall npm's own EPERM comes from (creating the scope directory), so the probe
78
+ * makes one and removes it. A probe entry that could not be removed is reported, never hidden.
79
+ * Nothing thrown; a missing or non-directory `dir` is simply not writable.
80
+ */
81
+ export declare function probeWritable(dir: string): {
82
+ writable: boolean;
83
+ leftover?: string;
84
+ };
66
85
  /**
67
86
  * Judge the global prefix. Pure — the probing lives with the caller.
68
87
  *