@holmes-lab/holmes-kit 0.19.2 → 0.19.4

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 +177 -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 +34 -0
  9. package/dist/holmes/cli/doctor.js +139 -46
  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 +6 -3
  47. package/scripts/install.ps1 +30 -27
@@ -0,0 +1,67 @@
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.modelCacheDir = modelCacheDir;
37
+ exports.modelCacheReady = modelCacheReady;
38
+ // @implements A-SPEC-594
39
+ /**
40
+ * Where the local model's assets live, and whether they are actually there.
41
+ *
42
+ * A leaf on purpose: `tier.ts` must consult readiness, and `local-model.ts` already imports
43
+ * `tier.ts`, so putting this in either one closes an import cycle. Nothing here imports
44
+ * anything of ours.
45
+ */
46
+ const os = __importStar(require("node:os"));
47
+ const path = __importStar(require("node:path"));
48
+ const fs = __importStar(require("node:fs"));
49
+ function modelCacheDir(env = process.env, home = os.homedir()) {
50
+ return path.resolve(env.HOLMES_MODEL_CACHE || path.join(home, '.holmes', 'models'));
51
+ }
52
+ /**
53
+ * Ready means the cache holds SOMETHING. An interrupted download leaves the directory created
54
+ * and empty, and reading that as ready is the whole failure mode this predicate exists to stop.
55
+ * Proof of a working model is `semantic-check`'s actual inference — deliberately not here, because
56
+ * doctor calls this on every run and must stay cheap and offline.
57
+ */
58
+ function modelCacheReady(cacheDir = modelCacheDir()) {
59
+ if (cacheDir === '')
60
+ return false;
61
+ try {
62
+ return fs.readdirSync(cacheDir).length > 0;
63
+ }
64
+ catch {
65
+ return false;
66
+ } // absent, unreadable, not a directory — none of them are ready
67
+ }
@@ -22,6 +22,12 @@ export interface SemanticTier {
22
22
  egress: boolean;
23
23
  /** Where the cloud consent came from — shown by doctor/status, never the value itself. */
24
24
  keySource?: KeySource;
25
+ /**
26
+ * @implements A-SPEC-594 — 'none' only: the local runtime resolves but its model was never
27
+ * prepared. The distinction exists because the two 'none' states need different advice, and
28
+ * because reporting this one as 'local' claims a recall the tier cannot deliver.
29
+ */
30
+ localRuntimePresent?: boolean;
25
31
  }
26
32
  /**
27
33
  * Which pooling a model's dense head expects. bge-m3 is CLS-pooled: mean-pooling it was the
@@ -33,5 +39,6 @@ export declare function POOLING_OF(model: string): 'cls' | 'mean';
33
39
  export declare function resolveSemanticTier(opts?: {
34
40
  env?: NodeJS.ProcessEnv;
35
41
  hasLocalModule?: boolean;
42
+ hasLocalModel?: boolean;
36
43
  credentials?: CredentialOpts;
37
44
  }): SemanticTier;
@@ -17,6 +17,7 @@ exports.CLOUD_MODEL = exports.LOCAL_MODEL = void 0;
17
17
  exports.POOLING_OF = POOLING_OF;
18
18
  exports.resolveSemanticTier = resolveSemanticTier;
19
19
  const credentials_1 = require("./credentials");
20
+ const model_cache_1 = require("./model-cache");
20
21
  /** The local tier's model. CLS-pooled — see POOLING_OF. */
21
22
  exports.LOCAL_MODEL = 'Xenova/bge-m3';
22
23
  /** The opt-in cloud tier's model (S-491 four-arm winner). */
@@ -47,8 +48,13 @@ function resolveSemanticTier(opts = {}) {
47
48
  if (found !== null) {
48
49
  return { tier: 'cloud', model: exports.CLOUD_MODEL, egress: true, keySource: found.source };
49
50
  }
50
- const hasLocal = opts.hasLocalModule ?? localModulePresent();
51
- if (hasLocal)
51
+ // @implements A-SPEC-594 — availability is a CONJUNCTION. Once @xenova/transformers became a
52
+ // required dependency, `localModulePresent()` answered true for every install and stopped
53
+ // discriminating; a resolvable runtime with no model produces no embedding at all. Wiring
54
+ // presence is not readiness.
55
+ const hasModule = opts.hasLocalModule ?? localModulePresent();
56
+ const hasModel = opts.hasLocalModel ?? (0, model_cache_1.modelCacheReady)();
57
+ if (hasModule && hasModel)
52
58
  return { tier: 'local', model: exports.LOCAL_MODEL, egress: false };
53
- return { tier: 'none', egress: false };
59
+ return { tier: 'none', egress: false, ...(hasModule ? { localRuntimePresent: true } : {}) };
54
60
  }
@@ -0,0 +1,72 @@
1
+ export interface RenumberPlan {
2
+ moves: {
3
+ from: string;
4
+ to: string;
5
+ oldId: string;
6
+ newId: string;
7
+ }[];
8
+ dependsOn: {
9
+ file: string;
10
+ oldId: string;
11
+ newId: string;
12
+ }[];
13
+ slices: {
14
+ file: string;
15
+ from: string;
16
+ to: string;
17
+ }[];
18
+ anchors: {
19
+ file: string;
20
+ oldId: string;
21
+ newId: string;
22
+ count: number;
23
+ }[];
24
+ /** Child-first: T -> A -> H -> REQ. Only what a move actually unseals. */
25
+ unsealOrder: string[];
26
+ /** Parent-first: REQ -> H -> A -> T. Only what a move actually breaks. */
27
+ approveOrder: string[];
28
+ /** Reported, never rewritten. */
29
+ proseCandidates: {
30
+ file: string;
31
+ line: number;
32
+ text: string;
33
+ }[];
34
+ refusal?: string;
35
+ }
36
+ export interface RenumberSpec {
37
+ id: string;
38
+ type: string;
39
+ file: string;
40
+ dependsOn: string[];
41
+ status?: string;
42
+ frontmatter?: Record<string, unknown>;
43
+ /** The document text, so prose INSIDE a spec is reported like prose anywhere else. */
44
+ body?: string;
45
+ }
46
+ export interface RenumberInput {
47
+ specs: RenumberSpec[];
48
+ sources: {
49
+ file: string;
50
+ text: string;
51
+ }[];
52
+ oldBase: string;
53
+ newBase: string;
54
+ }
55
+ /** Rewrite ONLY `@implements` ids. Everything else in the text is left exactly as it was. */
56
+ export declare function rewriteAnchors(text: string, pairs: {
57
+ oldId: string;
58
+ newId: string;
59
+ }[]): string;
60
+ export declare function planRenumber(input: RenumberInput): RenumberPlan;
61
+ /**
62
+ * Apply the plan's STRUCTURED edits. `proseCandidates` is not read here — that is the whole point.
63
+ * A half-renumbered store is worse than an unrenumbered one, so a failure returns what it moved.
64
+ */
65
+ export declare function applyRenumber(root: string, plan: RenumberPlan): number;
66
+ /** Source files an anchor can live in, as `{file, text}` with repo-relative POSIX paths. */
67
+ export declare function readSourcesForRenumber(projectRoot: string): {
68
+ file: string;
69
+ text: string;
70
+ }[];
71
+ /** Spec documents as the planner needs them, with store-relative POSIX paths. */
72
+ export declare function readSpecsForRenumber(specsRoot: string): RenumberSpec[];
@@ -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.