@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
@@ -0,0 +1,341 @@
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.rewriteAnchors = rewriteAnchors;
37
+ exports.planRenumber = planRenumber;
38
+ exports.applyRenumber = applyRenumber;
39
+ exports.readSourcesForRenumber = readSourcesForRenumber;
40
+ exports.readSpecsForRenumber = readSpecsForRenumber;
41
+ // @implements A-SPEC-255
42
+ /**
43
+ * Renumbering a spec, as a contract instead of a memory exercise.
44
+ *
45
+ * A spec id lives in eight places: the filename, the frontmatter `id`, every child's `depends_on`,
46
+ * the `slice` tag, source `@implements` anchors, test titles, prose citations and the CHANGELOG.
47
+ * Measured 2026-08-23, a person doing REQ-220 → 253 by hand moved two anchors and left SEVEN prose
48
+ * references — two test `describe` titles among six, plus a comment inside the SHIPPED install.ps1 —
49
+ * and the bulk regex brought in to catch the rest rewrote REQ-253's own `source.ref` into a citation
50
+ * of itself. Review caught all three before release; that is not a process.
51
+ *
52
+ * So the contract is deliberately ASYMMETRIC:
53
+ *
54
+ * definite grammar (filename, id, depends_on, slice, @implements) -> the tool MOVES it
55
+ * needs context (prose, comments, test titles, CHANGELOG, ref:) -> the tool NAMES it
56
+ *
57
+ * The tool never substitutes prose. That substitution IS the accident.
58
+ *
59
+ * Re-sealing is not here either: `spec_approve` is the only sealer (REQ-245 principle — a second
60
+ * sealer becomes a second truth and they drift). This module reports the two ORDERS instead, and
61
+ * they are opposites: `spec_unseal` refuses while an approved child depends on the target, so
62
+ * unsealing runs child-first; `spec_approve` refuses while a parent is unsealed, so re-approving
63
+ * runs parent-first.
64
+ */
65
+ const fs = __importStar(require("node:fs"));
66
+ const path = __importStar(require("node:path"));
67
+ const spec_id_1 = require("./spec-id");
68
+ /** The anchor grammar, shared with `rtm/anchor-ids.ts` so the two judgments cannot drift apart. */
69
+ const ID_RE = String.raw `(?:REQ|A-SPEC|H-SPEC|C-SPEC|T-SPEC)-\d{3,}(?:\.\d+)?`;
70
+ const ANCHOR_LIST_RE = new RegExp(String.raw `(@implements[ \t]+)(${ID_RE}(?:[ \t]*,[ \t]*${ID_RE})*)`, 'g');
71
+ /** Unseal is child-first; re-approve is the exact reverse. */
72
+ const CHILD_FIRST = ['T-SPEC', 'C-SPEC', 'A-SPEC', 'H-SPEC', 'REQ'];
73
+ const baseOf = (id) => {
74
+ const n = (0, spec_id_1.specIdBase)(id);
75
+ return n === null ? '' : String(n);
76
+ };
77
+ /** `A-SPEC-596.1` -> `A-SPEC-599.1`: the dot suffix is identity, not base. */
78
+ const renamed = (id, oldBase, newBase) => baseOf(id) !== oldBase ? id : id.replace(new RegExp(`-${oldBase}(\\.\\d+)?$`), `-${newBase}$1`);
79
+ const EMPTY = () => ({
80
+ moves: [], dependsOn: [], slices: [], anchors: [], unsealOrder: [], approveOrder: [], proseCandidates: [],
81
+ });
82
+ /** Rewrite ONLY `@implements` ids. Everything else in the text is left exactly as it was. */
83
+ function rewriteAnchors(text, pairs) {
84
+ if (pairs.length === 0)
85
+ return text;
86
+ const map = new Map(pairs.map((p) => [p.oldId, p.newId]));
87
+ return text.replace(ANCHOR_LIST_RE, (_m, marker, list) => marker + list.split(/([ \t]*,[ \t]*)/).map((part) => map.get(part.trim()) ?? part).join(''));
88
+ }
89
+ function planRenumber(input) {
90
+ const { specs = [], sources = [], oldBase, newBase } = input ?? {};
91
+ const refuse = (reason) => ({ ...EMPTY(), refusal: reason });
92
+ const family = specs.filter((s) => baseOf(s.id) === oldBase);
93
+ if (family.length === 0)
94
+ return refuse(`base ${oldBase} 를 쓰는 스펙이 없습니다 — 옮길 것이 없습니다.`);
95
+ // A partial plan invites a partial move, so an occupied destination empties EVERY list.
96
+ const taken = specs.filter((s) => baseOf(s.id) === newBase).map((s) => s.id);
97
+ if (taken.length > 0)
98
+ return refuse(`목적지 base ${newBase} 는 이미 ${taken.join(', ')} 가 쓰고 있습니다 — 리넘버가 새 충돌을 만들 수 없습니다.`);
99
+ const plan = EMPTY();
100
+ const pairs = [];
101
+ for (const s of family) {
102
+ const newId = renamed(s.id, oldBase, newBase);
103
+ pairs.push({ oldId: s.id, newId });
104
+ plan.moves.push({
105
+ from: s.file,
106
+ to: path.posix.join(path.posix.dirname(s.file.replace(/\\/g, '/')), `${newId}.md`),
107
+ oldId: s.id, newId,
108
+ });
109
+ }
110
+ const moving = new Set(pairs.map((p) => p.oldId));
111
+ // Every spec in the store that NAMES one of the moving ids — children of the family included.
112
+ for (const s of specs) {
113
+ for (const parent of s.dependsOn ?? []) {
114
+ if (!moving.has(parent))
115
+ continue;
116
+ plan.dependsOn.push({ file: s.file, oldId: parent, newId: renamed(parent, oldBase, newBase) });
117
+ }
118
+ const slice = s.frontmatter?.slice;
119
+ if (typeof slice === 'string' && slice.includes(oldBase)) {
120
+ plan.slices.push({ file: s.file, from: slice, to: slice.split(oldBase).join(newBase) });
121
+ }
122
+ }
123
+ // `depends_on` is hashed by specDigest; `id` is NOT. A spec that only MOVES keeps a valid seal,
124
+ // so it belongs in neither order — demanding an unnecessary unseal would add risk for nothing.
125
+ const reseal = new Set(plan.dependsOn.map((d) => d.file));
126
+ const affected = specs.filter((s) => reseal.has(s.file) && (s.status ?? '') === 'approved');
127
+ const byKind = (a, b) => CHILD_FIRST.indexOf(a.type) - CHILD_FIRST.indexOf(b.type);
128
+ plan.unsealOrder = [...affected].sort(byKind).map((s) => s.id);
129
+ plan.approveOrder = [...affected].sort(byKind).reverse().map((s) => renamed(s.id, oldBase, newBase));
130
+ const idInText = new RegExp(String.raw `(?:REQ|A-SPEC|H-SPEC|C-SPEC|T-SPEC)-${oldBase}(?:\.\d+)?\b`);
131
+ /** A line the tool will NOT rewrite but a human must look at. Anchors are excluded — those move. */
132
+ const scanProse = (file, text, skipFrontmatter) => {
133
+ const lines = text.split('\n');
134
+ // A spec's frontmatter (`id`, `depends_on`, `parent_digests`) is STRUCTURED — the tool moves it,
135
+ // so reporting it would be noise that trains the reader to skim the list.
136
+ let end = 0;
137
+ if (skipFrontmatter && lines[0] === '---') {
138
+ const close = lines.indexOf('---', 1);
139
+ end = close === -1 ? 0 : close + 1;
140
+ }
141
+ lines.forEach((line, i) => {
142
+ if (i < end)
143
+ return;
144
+ if (!idInText.test(line))
145
+ return;
146
+ if (!idInText.test(line.replace(ANCHOR_LIST_RE, '')))
147
+ return; // the only mention was an anchor
148
+ plan.proseCandidates.push({ file, line: i + 1, text: line });
149
+ });
150
+ };
151
+ // Prose inside a SPEC BODY counts. Measured on the real Windows branch: H-SPEC-596 said "Satisfy
152
+ // every REQ-596 success criterion" and A-SPEC-596 said "as specified in REQ-596" — sections that
153
+ // specDigest hashes, and that after a move would cite a spec which no longer exists. Scanning only
154
+ // src/ and scripts/ reported zero candidates there: a false all-clear.
155
+ for (const s of specs) {
156
+ if (typeof s.body === 'string')
157
+ scanProse(s.file, s.body, true);
158
+ }
159
+ for (const src of sources) {
160
+ const anchored = new Map();
161
+ for (const m of src.text.matchAll(ANCHOR_LIST_RE)) {
162
+ for (const id of m[1 + 1].split(/[ \t]*,[ \t]*/).map((x) => x.trim())) {
163
+ if (moving.has(id))
164
+ anchored.set(id, (anchored.get(id) ?? 0) + 1);
165
+ }
166
+ }
167
+ for (const [oldId, count] of anchored) {
168
+ plan.anchors.push({ file: src.file, oldId, newId: renamed(oldId, oldBase, newBase), count });
169
+ }
170
+ // A line that mentions the old id but is NOT an anchor needs context to rewrite correctly.
171
+ // Name it; do not touch it.
172
+ scanProse(src.file, src.text, false);
173
+ }
174
+ return plan;
175
+ }
176
+ /**
177
+ * Apply the plan's STRUCTURED edits. `proseCandidates` is not read here — that is the whole point.
178
+ * A half-renumbered store is worse than an unrenumbered one, so a failure returns what it moved.
179
+ */
180
+ function applyRenumber(root, plan) {
181
+ if (plan.refusal)
182
+ throw new Error(plan.refusal);
183
+ const abs = (rel) => path.join(root, rel);
184
+ // Snapshot EVERY file this call will touch, not just the ones it renames: an anchor rewrite that
185
+ // throws after a depends_on rewrite succeeded would otherwise leave the store half-edited, which
186
+ // is worse than not having started. Content and location are restored together.
187
+ const snapshot = new Map();
188
+ const remember = (rel) => {
189
+ if (snapshot.has(rel))
190
+ return;
191
+ try {
192
+ snapshot.set(rel, fs.readFileSync(abs(rel), 'utf8'));
193
+ }
194
+ catch { /* absent: the move will name it */ }
195
+ };
196
+ const moved = [];
197
+ const created = [];
198
+ try {
199
+ for (const mv of plan.moves) {
200
+ remember(mv.from);
201
+ fs.mkdirSync(path.dirname(abs(mv.to)), { recursive: true });
202
+ fs.renameSync(abs(mv.from), abs(mv.to));
203
+ moved.push(mv);
204
+ created.push(mv.to);
205
+ const at = abs(mv.to);
206
+ fs.writeFileSync(at, fs.readFileSync(at, 'utf8')
207
+ .replace(new RegExp(`^(id:[ \\t]*)${mv.oldId}[ \\t]*$`, 'm'), `$1${mv.newId}`));
208
+ }
209
+ const relocated = new Map(plan.moves.map((m) => [m.from, m.to]));
210
+ const edit = (file, fn) => {
211
+ const rel = relocated.get(file) ?? file;
212
+ if (!relocated.has(file))
213
+ remember(rel);
214
+ const at = abs(rel);
215
+ fs.writeFileSync(at, fn(fs.readFileSync(at, 'utf8')));
216
+ };
217
+ for (const d of plan.dependsOn) {
218
+ edit(d.file, (t) => t.replace(new RegExp(`(-[ \\t]*|\\[[ \\t]*)${d.oldId}\\b`, 'g'), `$1${d.newId}`));
219
+ }
220
+ for (const s of plan.slices)
221
+ edit(s.file, (t) => t.split(s.from).join(s.to));
222
+ // Each anchor entry carries its OWN ids. Deriving them from `moves` instead coupled the two
223
+ // passes, and because specs and sources live under different roots this function is called twice
224
+ // — once with `anchors: []`, once with `moves: []` — so the second call silently rewrote nothing
225
+ // while the specs had already moved. Measured on the real branch: 22 files kept the old id.
226
+ const byFile = new Map();
227
+ for (const a of plan.anchors) {
228
+ byFile.set(a.file, [...(byFile.get(a.file) ?? []), { oldId: a.oldId, newId: a.newId }]);
229
+ }
230
+ for (const [file, pairs] of byFile)
231
+ edit(file, (t) => rewriteAnchors(t, pairs));
232
+ return plan.moves.length;
233
+ }
234
+ catch (e) {
235
+ for (const rel of created.reverse()) {
236
+ try {
237
+ fs.rmSync(abs(rel), { force: true });
238
+ }
239
+ catch { /* best effort */ }
240
+ }
241
+ for (const [rel, text] of snapshot) {
242
+ try {
243
+ fs.mkdirSync(path.dirname(abs(rel)), { recursive: true });
244
+ fs.writeFileSync(abs(rel), text);
245
+ }
246
+ catch { /* best effort: the original error is what matters */ }
247
+ }
248
+ void moved;
249
+ throw e;
250
+ }
251
+ }
252
+ /**
253
+ * The I/O half, kept HERE rather than in the MCP handler: `handlers.ts` carries 92 anchors against a
254
+ * p90 of 7, and every rule added there is a rule nobody can find later. The handler stays a wiring.
255
+ */
256
+ const SOURCE_DIRS = ['src', 'scripts'];
257
+ const SOURCE_EXT = /\.(ts|js|mjs|cjs|ps1|sh)$/;
258
+ function walk(dir, out = []) {
259
+ let entries;
260
+ try {
261
+ entries = fs.readdirSync(dir, { withFileTypes: true });
262
+ }
263
+ catch {
264
+ return out;
265
+ }
266
+ for (const e of entries) {
267
+ const p = path.join(dir, e.name);
268
+ if (e.isDirectory()) {
269
+ if (e.name !== 'node_modules')
270
+ walk(p, out);
271
+ }
272
+ else if (SOURCE_EXT.test(e.name))
273
+ out.push(p);
274
+ }
275
+ return out;
276
+ }
277
+ /** Source files an anchor can live in, as `{file, text}` with repo-relative POSIX paths. */
278
+ function readSourcesForRenumber(projectRoot) {
279
+ const out = [];
280
+ for (const d of SOURCE_DIRS) {
281
+ for (const abs of walk(path.join(projectRoot, d))) {
282
+ try {
283
+ out.push({
284
+ file: path.relative(projectRoot, abs).split(path.sep).join('/'),
285
+ text: fs.readFileSync(abs, 'utf8'),
286
+ });
287
+ }
288
+ catch { /* unreadable is not anchored */ }
289
+ }
290
+ }
291
+ return out;
292
+ }
293
+ /** Spec documents as the planner needs them, with store-relative POSIX paths. */
294
+ function readSpecsForRenumber(specsRoot) {
295
+ const out = [];
296
+ for (const abs of walk(specsRoot).concat((function md(dir, acc = []) {
297
+ let entries;
298
+ try {
299
+ entries = fs.readdirSync(dir, { withFileTypes: true });
300
+ }
301
+ catch {
302
+ return acc;
303
+ }
304
+ for (const e of entries) {
305
+ const p = path.join(dir, e.name);
306
+ if (e.isDirectory())
307
+ md(p, acc);
308
+ else if (e.name.endsWith('.md'))
309
+ acc.push(p);
310
+ }
311
+ return acc;
312
+ })(specsRoot))) {
313
+ if (!abs.endsWith('.md'))
314
+ continue;
315
+ let text;
316
+ try {
317
+ text = fs.readFileSync(abs, 'utf8');
318
+ }
319
+ catch {
320
+ continue;
321
+ }
322
+ const fm = /^---\n([\s\S]*?)\n---/.exec(text);
323
+ if (!fm)
324
+ continue;
325
+ const field = (k) => (new RegExp(`^${k}:[ \\t]*(.+)$`, 'm').exec(fm[1])?.[1] ?? '').trim();
326
+ const id = field('id');
327
+ if (id === '')
328
+ continue;
329
+ const deps = (/^depends_on:\n((?:[ \t]*-[ \t]*.+\n?)+)/m.exec(fm[1])?.[1] ?? '')
330
+ .split('\n').map((l) => l.replace(/^[ \t]*-[ \t]*/, '').trim()).filter((x) => x !== '');
331
+ const inline = /^depends_on:[ \t]*\[(.*)\]/m.exec(fm[1])?.[1];
332
+ out.push({
333
+ id, type: field('type'), status: field('status'),
334
+ file: path.relative(specsRoot, abs).split(path.sep).join('/'),
335
+ dependsOn: inline !== undefined ? inline.split(',').map((x) => x.trim()).filter(Boolean) : deps,
336
+ frontmatter: { slice: field('slice') || undefined },
337
+ body: text,
338
+ });
339
+ }
340
+ return out;
341
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The base number inside a spec id, as a SPEC-layer fact.
3
+ *
4
+ * It lived in `mcp/spec-id-guard.ts` because `spec_create` was its first caller, but the spec layer
5
+ * may not import the mcp layer (C-SPEC-224: `import src/holmes/spec/ -x-> src/holmes/mcp/`), and
6
+ * renumbering needs the same parse. Defined here and re-exported there, so there is one parser and
7
+ * the dependency points the way the architecture says it must.
8
+ */
9
+ export declare function specIdBase(id: string): number | null;
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.specIdBase = specIdBase;
4
+ // @implements A-SPEC-252
5
+ // @implements A-SPEC-255
6
+ /**
7
+ * The base number inside a spec id, as a SPEC-layer fact.
8
+ *
9
+ * It lived in `mcp/spec-id-guard.ts` because `spec_create` was its first caller, but the spec layer
10
+ * may not import the mcp layer (C-SPEC-224: `import src/holmes/spec/ -x-> src/holmes/mcp/`), and
11
+ * renumbering needs the same parse. Defined here and re-exported there, so there is one parser and
12
+ * the dependency points the way the architecture says it must.
13
+ */
14
+ function specIdBase(id) {
15
+ if (typeof id !== 'string')
16
+ return null;
17
+ // 마지막 하이픈 뒤의 숫자 토막(점 앞)을 base 로 본다.
18
+ const m = /-(\d+)(?:\.\d+)?\s*$/.exec(id.trim());
19
+ if (!m)
20
+ return null;
21
+ const n = parseInt(m[1], 10);
22
+ return Number.isNaN(n) ? null : n;
23
+ }
@@ -99,17 +99,22 @@ silently: `npm ci` exits 0, `better-sqlite3` never runs `prebuild-install`, and
99
99
  npm 12.0.1, Node 24.19.0). `npx holmes-kit doctor` names this cause as `scripts-blocked` and prints
100
100
  the commands below; do not reinstall — a reinstall reproduces the same state.
101
101
 
102
- Only **one** package needs its script: `better-sqlite3`. The 8 tree-sitter packages are also
102
+ Two packages need their scripts: **`better-sqlite3` and `sharp`**. SQLite provides local storage;
103
+ sharp is required by the local semantic runtime. The 8 tree-sitter packages are also
103
104
  listed as blocked, but they load from their shipped `prebuilds/` without the script (measured on
104
105
  all 8), so they are deliberately NOT approved. This repository's `package.json` therefore carries
105
106
 
106
107
  ```json
107
- "allowScripts": { "better-sqlite3@12.11.1": true }
108
+ "allowScripts": { "better-sqlite3@12.11.1": true, "sharp@0.32.6": true }
108
109
  ```
109
110
 
110
111
  pinned to the lockfile version, so a dependency bump forces a fresh review (a test fails until the
111
112
  pin is updated). Recovery, by how you installed — on Windows use the `.cmd` spellings (see below):
112
113
 
114
+ The table below shows SQLite recovery. Repeat approval for `sharp@0.32.6` and rebuild `sharp`
115
+ when its native binding is missing. On PowerShell 5.1, run each command separately and proceed
116
+ only if `$LASTEXITCODE` is zero; `&&` requires a newer PowerShell.
117
+
113
118
  | Layout | Commands |
114
119
  |---|---|
115
120
  | A project that depends on holmes-kit | `npm install-scripts approve better-sqlite3@12.11.1` then `npm rebuild better-sqlite3 --foreground-scripts` (from the project root; this writes the pin into YOUR package.json) |
@@ -178,3 +183,86 @@ Expect `0 fail` on a healthy install (a few `warn` lines are normal — measured
178
183
  | Approving the tree-sitter grammars in `allowScripts` | Measured unnecessary — they load from shipped prebuilds — and every extra approval is code that runs at install time |
179
184
  | Recommending `npx @holmes-lab/holmes-kit init` with no install | `init` writes wiring with absolute paths; under bare `npx` those point into the npx cache and break when it is pruned |
180
185
  | Fixing your npm prefix from inside the package | A package rewriting your npm configuration is exactly the supply-chain behaviour this guide warns about |
186
+ ## ARM Linux needs Node 20 or 22
187
+
188
+ On **linux-arm64**, installation fails on **Node 24** and succeeds on Node 20 and 22 (measured
189
+ 2026-09-10 in a Debian 12 container with python3, make and g++ all present). The cause is not a
190
+ missing toolchain: `tree-sitter@0.21.1` ships prebuilds only for `darwin-arm64`, `darwin-x64`,
191
+ `linux-x64` and `win32-x64`, so ARM Linux compiles from source, and that compile fails against
192
+ Node 24 headers. On x64 the prebuild is used and nothing is compiled, which is why the same Node
193
+ version is fine there.
194
+
195
+ The failure happens while npm is still installing dependencies — before Holmes-Kit exists — so
196
+ `doctor` cannot diagnose it and this note is the only warning available. If `npm install` dies with
197
+ `gyp ERR!` in `tree_sitter_runtime_binding`, switch to Node 20 or 22 on that machine.
198
+
199
+ # Default local embeddings (Windows, macOS, Linux)
200
+
201
+ Holmes-Kit ships with the **Xenova/bge-m3** local tier available, but **preparing it is
202
+ opt-in**: neither package installation, nor `holmes-kit init`, nor the Windows bootstrap
203
+ downloads model assets on its own. Run one command when you want it:
204
+
205
+ ```text
206
+ holmes-kit semantic-setup
207
+ ```
208
+
209
+ To prepare it during installation instead, set `HOLMES_AUTO_MODEL_INSTALL=1` before
210
+ installing. `HOLMES_SKIP_MODEL_INSTALL=1` still refuses preparation and **outranks** the
211
+ opt-in, so an explicit refusal is never overridden. All three harnesses and all three
212
+ operating systems share this default — Windows does not prepare the model when macOS and
213
+ Linux do not.
214
+
215
+ The first setup downloads public model assets from Hugging Face; it sends no repository
216
+ text. Normal embedding inference is offline and will not download missing assets.
217
+ The model revision is pinned to `4de13258303883538bd53b696b452bf8099f0858`.
218
+
219
+ Until the model is prepared, `doctor` reports the semantic tier as **none** and names what
220
+ it costs — requests lexical search misses (measured 16.4%) have 0% recall — together with the
221
+ command that changes it. A runtime that resolves is not a tier that answers, so an unprepared
222
+ local runtime is never reported as the `local` tier.
223
+
224
+ The Windows `scripts/install.ps1` bootstrap honours the same opt-in. With
225
+ `HOLMES_AUTO_MODEL_INSTALL=1` it prepares the model after npm installation and when the
226
+ requested Holmes-Kit version is already installed, printing readiness only after successful
227
+ inference; failure prints `semantic-setup` / `semantic-check` recovery commands while preserving
228
+ the installed graph tools. Without the opt-in it prepares nothing and names the command. Dry runs
229
+ and the explicit skip flag do no model work.
230
+
231
+ Run `holmes-kit semantic-check` after installation. Success means an actual CPU inference
232
+ produced a finite, normalized 1024-dimensional vector. `doctor` deliberately reports local
233
+ readiness as unverified: resolving a package does not prove that its native libraries or
234
+ model files work. Run `holmes-kit semantic-setup` to prepare the model and retry verification.
235
+ Both commands bound their worker to 15 minutes and return nonzero on failure. Automatic
236
+ setup failure leaves graph features available and reports a retry instruction; npm may
237
+ hide successful lifecycle output, so use `--foreground-scripts` when inspecting installation.
238
+
239
+ Models are shared between projects in `~/.holmes/models` (Windows:
240
+ `%USERPROFILE%\.holmes\models`). Set `HOLMES_MODEL_CACHE` to override this directory,
241
+ including for a prepopulated offline cache. Set `HOLMES_SKIP_MODEL_INSTALL=1` to refuse
242
+ automatic preparation explicitly; it outranks `HOLMES_AUTO_MODEL_INSTALL=1` and does not
243
+ disable an explicit setup/check command. An empty cache directory counts as not prepared —
244
+ an interrupted download leaves exactly that, and reading it as ready would be a false PASS.
245
+ For source checkouts, build first, then run `node bin/holmes-kit.js semantic-setup`.
246
+
247
+ If npm blocks lifecycle scripts, run `npm install-scripts ls`, review the blocked versions,
248
+ approve the required runtime scripts in the **consuming project's** policy, and then run
249
+ `holmes-kit semantic-setup`. A dependency's `allowScripts` does not grant consent on behalf
250
+ of the consuming project. In this checkout, `sharp@0.32.6` is explicitly allowed.
251
+ For this pinned sharp version in a project installation, the repair is:
252
+
253
+ ```text
254
+ npm install-scripts approve sharp@0.32.6
255
+ npm rebuild sharp --foreground-scripts
256
+ holmes-kit semantic-setup
257
+ holmes-kit semantic-check
258
+ ```
259
+
260
+ Windows verification on 2026-09-10: Node 24.19.0, Transformers.js 2.17.2, sharp 0.32.6.
261
+ The initial failure was a missing `sharp-win32-x64.node` after blocked install scripts.
262
+ Repairing sharp and downloading the pinned model succeeded; English and Korean inputs
263
+ both produced valid vectors with remote model access disabled. macOS/Linux use the same
264
+ implementation, but this Windows session did not execute platform-native tests there.
265
+
266
+ The optional cloud credential file uses a protected current-user-only NTFS DACL on Windows.
267
+ Permission setup is verified before a new key is written; failure preserves the prior key and
268
+ refuses the new value. The macOS keychain and POSIX 0700/0600 fallback remain available.
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.19.3",
4
+ "version": "0.19.5",
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",
@@ -22,6 +22,7 @@
22
22
  "node": ">=20"
23
23
  },
24
24
  "scripts": {
25
+ "postinstall": "node -e \"const p='./dist/holmes/semantic/local-model.js';if(require('fs').existsSync(p)){process.exitCode=require(p).runModelSetup({automatic:true})}else{console.error('BGE-M3 setup pending: build this source checkout, then run holmes-kit semantic-setup.')}\"",
25
26
  "build": "tsc && node -e \"const{execSync}=require('child_process'),fs=require('fs');const id=(()=>{try{return execSync('git rev-parse --short HEAD').toString().trim()}catch{return 'nogit'}})();fs.writeFileSync('dist/.build-id',id+'-'+Date.now().toString(36))\"",
26
27
  "test": "npm run typecheck && jest",
27
28
  "mcp": "node bin/holmes-mcp.js",
@@ -73,7 +74,8 @@
73
74
  "tree-sitter-rust": "^0.21.0",
74
75
  "tree-sitter-typescript": "^0.21.2",
75
76
  "typescript": "^5.0.0",
76
- "web-tree-sitter": "0.27.0"
77
+ "web-tree-sitter": "0.27.0",
78
+ "@xenova/transformers": "^2.17.2"
77
79
  },
78
80
  "repository": {
79
81
  "type": "git",
@@ -87,6 +89,10 @@
87
89
  "access": "public"
88
90
  },
89
91
  "allowScripts": {
90
- "better-sqlite3@12.11.1": true
92
+ "better-sqlite3@12.11.1": true,
93
+ "sharp@0.32.6": true
94
+ },
95
+ "overrides": {
96
+ "protobufjs": "^7.6.3"
91
97
  }
92
98
  }
@@ -1,13 +1,10 @@
1
+ # @implements A-SPEC-593
1
2
  <#
2
3
  .SYNOPSIS
3
4
  Holmes-Kit bootstrap installer for Windows. @implements A-SPEC-253
4
5
  .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.
6
+ Checks the working directory, npm prefix, Node version, and native install failures.
7
+ Installs Holmes-Kit and prepares BGE-M3 through the shared CLI. PowerShell 5.1; no prompts.
11
8
  .PARAMETER DryRun Run every check, print the npm command that WOULD run, exit 0 without running it.
12
9
  .PARAMETER Version Package version to install (default: latest).
13
10
  .PARAMETER Prefix Optional npm --prefix for the global install.
@@ -16,8 +13,7 @@
16
13
  .NOTES
17
14
  Exit codes: 0 ok / already installed, 2 argument error, 3 Node too old, 4 npm failed, 5 not on PATH.
18
15
  #>
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.
16
+ # Stray positional tokens land in $Rest and exit 2 before installation.
21
17
  [CmdletBinding(PositionalBinding = $false)]
22
18
  param(
23
19
  [Parameter(Mandatory = $false)][switch]$DryRun,
@@ -34,17 +30,10 @@ $script:NodeMajorFloor = 20
34
30
  $script:PackageName = '@holmes-lab/holmes-kit'
35
31
  $script:BinName = 'holmes-kit'
36
32
 
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.
33
+ # Classify npm failures with directory errors taking precedence over downstream build noise.
41
34
  function Get-InstallFailureKind([string]$NpmOutput) {
42
35
  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.
36
+ # Match the failing prefix path, not npm's own installation mentioned in every stack trace.
48
37
  if ($NpmOutput -match 'EPERM' -and $NpmOutput -match "(?m)^npm error path .*Program Files[\\/]+nodejs[\\/]+node_modules[\\/]+(?!npm[\\/])") { return 'eperm-prefix' }
49
38
  if ($NpmOutput -match 'EPERM' -and ($NpmOutput -match 'mkdir' -or $NpmOutput -match 'System32' -or $NpmOutput -match 'Program Files')) { return 'eperm' }
50
39
  if ($NpmOutput -match 'MSB\d{4}' -or $NpmOutput -match 'MSBuild' -or $NpmOutput -match 'Visual Studio' -or $NpmOutput -match 'vcvarsall') { return 'msbuild' }
@@ -97,10 +86,27 @@ npm exited with an error this installer does not recognise. The last lines of i
97
86
  '@
98
87
  }
99
88
 
100
- # Stage helpers
101
89
  function Write-Stage([string]$Text) { Write-Host $Text }
102
90
  function Write-Problem([string]$Text) { [Console]::Error.WriteLine($Text) }
103
91
 
92
+ function Initialize-LocalModel {
93
+ if ($DryRun) { return }
94
+ # A-SPEC-594: semantic-setup always downloads, so the opt-in gate lives HERE or Windows keeps fetching while macOS/Linux no longer do. Skip outranks opt-in.
95
+ if ($env:HOLMES_SKIP_MODEL_INSTALL -eq '1' -or $env:HOLMES_AUTO_MODEL_INSTALL -ne '1') {
96
+ if ($env:HOLMES_SKIP_MODEL_INSTALL -eq '1') { Write-Stage 'BGE-M3 preparation skipped (HOLMES_SKIP_MODEL_INSTALL=1). Run holmes-kit semantic-setup when ready.' }
97
+ else { Write-Stage 'BGE-M3 is not prepared automatically. Run holmes-kit semantic-setup to download the public model once, or set HOLMES_AUTO_MODEL_INSTALL=1 before installing. Graph features work without it.' }
98
+ return
99
+ }
100
+ Write-Stage 'Preparing and verifying BGE-M3 through holmes-kit semantic-setup...'
101
+ $modelCode = 1; $prev = $ErrorActionPreference; $ErrorActionPreference = 'Continue'
102
+ try {
103
+ & $script:BinName semantic-setup 2>&1 | ForEach-Object { Write-Stage "$_" }
104
+ $modelCode = $LASTEXITCODE
105
+ } catch { $modelCode = 1 } finally { $ErrorActionPreference = $prev }
106
+ if ($modelCode -eq 0) { Write-Stage 'BGE-M3 ready: local inference verified.' }
107
+ else { Write-Problem 'BGE-M3 unavailable. Holmes-Kit graph features remain installed. Run holmes-kit semantic-setup, then holmes-kit semantic-check to verify readiness.' }
108
+ }
109
+
104
110
  function Get-NormalizedPath([string]$P) {
105
111
  if ([string]::IsNullOrWhiteSpace($P)) { return $null }
106
112
  $t = $P.TrimEnd('\', '/')
@@ -118,8 +124,7 @@ function Test-UnderRoot([string]$Candidate, [string]$Root) {
118
124
 
119
125
  function Test-ProtectedDirectory([string]$Dir) {
120
126
  $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.
127
+ # Protect the drive root itself, not every project beneath it.
123
128
  $driveRoot = Get-NormalizedPath ([IO.Path]::GetPathRoot($Dir))
124
129
  if ($null -ne $driveRoot -and $here -eq $driveRoot) { return $true }
125
130
  $roots = @($env:SystemRoot, $env:ProgramFiles, ${env:ProgramFiles(x86)}, $env:ProgramData)
@@ -165,7 +170,7 @@ function Get-InstalledVersion {
165
170
  }
166
171
 
167
172
  function Get-NpmPrefix {
168
- # Skipped in -DryRun only when npm is absent; otherwise it is a local config read, no network.
173
+ # A local configuration read, without network access.
169
174
  $r = Invoke-Npm @('config', 'get', 'prefix')
170
175
  if ($r.Code -ne 0) { return $null }
171
176
  $v = $r.Output.Trim()
@@ -174,8 +179,7 @@ function Get-NpmPrefix {
174
179
  }
175
180
 
176
181
  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.
182
+ # Array arguments preserve Windows .cmd resolution; merge streams for failure classification.
179
183
  $npm = Get-Command npm.cmd -ErrorAction SilentlyContinue
180
184
  if ($null -eq $npm) { $npm = Get-Command npm -ErrorAction SilentlyContinue }
181
185
  if ($null -eq $npm) { return @{ Code = 127; Output = 'npm was not found on PATH' } }
@@ -190,7 +194,6 @@ function Invoke-Npm([string[]]$Arguments) {
190
194
  return @{ Code = $code; Output = ($lines -join "`n") }
191
195
  }
192
196
 
193
- # Pipeline
194
197
  function Invoke-Main {
195
198
  # Stage 0 - arguments
196
199
  if ($null -ne $Rest -and $Rest.Count -gt 0) {
@@ -238,14 +241,13 @@ function Invoke-Main {
238
241
  }
239
242
  if ($want -ne 'latest' -and $installed -eq $want) {
240
243
  Write-Stage ("already installed ({0})" -f $installed)
244
+ Initialize-LocalModel
241
245
  return 0
242
246
  }
243
247
  Write-Stage ("holmes-kit {0} is installed; will install {1}" -f $installed, $Version)
244
248
  }
245
249
 
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).
250
+ # Stage 4b - use a writable per-user prefix for this call; never modify npm configuration.
249
251
  if ([string]::IsNullOrWhiteSpace($Prefix) -and -not (Test-Elevated)) {
250
252
  $cur = Get-NpmPrefix
251
253
  if ($null -ne $cur -and (Test-ProtectedDirectory $cur)) {
@@ -287,6 +289,7 @@ function Invoke-Main {
287
289
  }
288
290
  $v = Get-InstalledVersion
289
291
  Write-Stage ("holmes-kit {0} installed" -f $v)
292
+ Initialize-LocalModel
290
293
  return 0
291
294
  }
292
295