@holmes-lab/holmes-kit 0.19.3 → 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 +137 -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 +6 -3
  47. package/scripts/install.ps1 +30 -27
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.specFingerprint = specFingerprint;
36
37
  exports.scanDigest = scanDigest;
37
38
  exports.openReusableGraph = openReusableGraph;
38
39
  // @implements A-SPEC-282
@@ -43,6 +44,19 @@ const rtm_graph_1 = require("./rtm-graph");
43
44
  const BASIS_FIELDS = [
44
45
  'graphSchema', 'extractorVersion', 'sourceCommit', 'specFingerprint', 'scanDigest',
45
46
  ];
47
+ /** Deterministic content fingerprint for every spec field consumed by buildRtm. */
48
+ function specFingerprint(specs) {
49
+ const canonical = (value) => {
50
+ if (Array.isArray(value))
51
+ return value.map(canonical);
52
+ if (value !== null && typeof value === 'object') {
53
+ return Object.fromEntries(Object.entries(value)
54
+ .sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => [k, canonical(v)]));
55
+ }
56
+ return value;
57
+ };
58
+ return `sha256:${(0, node_crypto_1.createHash)('sha256').update(JSON.stringify(specs.map(canonical))).digest('hex')}`;
59
+ }
46
60
  /**
47
61
  * Content address of a scan: every file's path and its symbols/edges, sorted so the digest depends
48
62
  * on the scan's CONTENT and not on the order the scanner happened to walk the tree in.
@@ -66,16 +66,17 @@ class RtmGraph {
66
66
  // full rebuild.
67
67
  constructor(dbPath = ':memory:') {
68
68
  this.db = new better_sqlite3_1.default(dbPath);
69
- // @implements A-SPEC-283
70
- // Only a FILE-backed store can be contended. Several MCP servers can be live at once, and the
71
- // hook and the CLI can touch the same project, so a reader must not be blocked by a writer and a
72
- // busy file must be waited on rather than thrown at. `:memory:` is private to this process and
73
- // cannot use WAL at all setting it there would be a pragma that cannot apply.
74
- if (dbPath !== ':memory:') {
75
- this.db.pragma('journal_mode = WAL');
76
- this.db.pragma('busy_timeout = 5000');
77
- }
78
- this.db.exec(`CREATE TABLE IF NOT EXISTS nodes (id TEXT PRIMARY KEY, kind TEXT NOT NULL, source_path TEXT, summary TEXT);
69
+ try {
70
+ // @implements A-SPEC-283
71
+ // Only a FILE-backed store can be contended. Several MCP servers can be live at once, and the
72
+ // hook and the CLI can touch the same project, so a reader must not be blocked by a writer and a
73
+ // busy file must be waited on rather than thrown at. `:memory:` is private to this process and
74
+ // cannot use WAL at all — setting it there would be a pragma that cannot apply.
75
+ if (dbPath !== ':memory:') {
76
+ this.db.pragma('journal_mode = WAL');
77
+ this.db.pragma('busy_timeout = 5000');
78
+ }
79
+ this.db.exec(`CREATE TABLE IF NOT EXISTS nodes (id TEXT PRIMARY KEY, kind TEXT NOT NULL, source_path TEXT, summary TEXT);
79
80
  CREATE TABLE IF NOT EXISTS edges (src TEXT NOT NULL, dst TEXT NOT NULL, rel TEXT NOT NULL, source_path TEXT, PRIMARY KEY (src,dst,rel));
80
81
  -- The PK covers src-prefixed lookups; nothing covered dst or source_path, so every reverse
81
82
  -- traversal and every removeBySource was a full table scan. Measured on a 240k-node graph:
@@ -86,28 +87,39 @@ class RtmGraph {
86
87
  CREATE INDEX IF NOT EXISTS idx_edges_dst ON edges(dst);
87
88
  CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source_path);
88
89
  CREATE INDEX IF NOT EXISTS idx_nodes_source ON nodes(source_path);`);
89
- // @implements A-SPEC-281
90
- // `CREATE TABLE IF NOT EXISTS` leaves an EXISTING database on its old schema, so a store written
91
- // before provenance existed would silently reject every new insert. Add the missing columns in
92
- // place; existing rows keep their data and simply report null provenance, which is the honest
93
- // record for a fact nobody annotated.
94
- // @implements A-SPEC-282 — a small key/value side table so a persisted graph can carry the basis
95
- // it was built on. Kept out of `nodes`/`edges` because it describes the WHOLE graph, not a fact.
96
- this.db.exec('CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)');
97
- for (const table of ['nodes', 'edges']) {
98
- const present = new Set(this.db.prepare(`PRAGMA table_info(${table})`).all()
99
- .map((c) => c.name));
100
- for (const column of PROVENANCE_COLUMNS) {
101
- if (present.has(column))
102
- continue;
103
- const type = column === 'confidence' ? 'REAL' : 'TEXT';
104
- this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
90
+ // @implements A-SPEC-281
91
+ // `CREATE TABLE IF NOT EXISTS` leaves an EXISTING database on its old schema, so a store written
92
+ // before provenance existed would silently reject every new insert. Add the missing columns in
93
+ // place; existing rows keep their data and simply report null provenance, which is the honest
94
+ // record for a fact nobody annotated.
95
+ // @implements A-SPEC-282 — a small key/value side table so a persisted graph can carry the basis
96
+ // it was built on. Kept out of `nodes`/`edges` because it describes the WHOLE graph, not a fact.
97
+ this.db.exec('CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)');
98
+ for (const table of ['nodes', 'edges']) {
99
+ const present = new Set(this.db.prepare(`PRAGMA table_info(${table})`).all()
100
+ .map((c) => c.name));
101
+ for (const column of PROVENANCE_COLUMNS) {
102
+ if (present.has(column))
103
+ continue;
104
+ const type = column === 'confidence' ? 'REAL' : 'TEXT';
105
+ this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
106
+ }
107
+ // @implements A-SPEC-568.1 — same in-place migration rule as provenance: an rtm-graph/2 file
108
+ // must keep OPENING (openReusableGraph decides reuse; an unreadable file would look corrupt).
109
+ if (table === 'nodes' && !present.has('summary')) {
110
+ this.db.exec('ALTER TABLE nodes ADD COLUMN summary TEXT');
111
+ }
105
112
  }
106
- // @implements A-SPEC-568.1 — same in-place migration rule as provenance: an rtm-graph/2 file
107
- // must keep OPENING (openReusableGraph decides reuse; an unreadable file would look corrupt).
108
- if (table === 'nodes' && !present.has('summary')) {
109
- this.db.exec('ALTER TABLE nodes ADD COLUMN summary TEXT');
113
+ }
114
+ catch (error) {
115
+ // A corrupt file can fail during pragma/schema setup after better-sqlite3 has opened it.
116
+ // Release that native handle before propagating the original error so the caller can replace
117
+ // the file on Windows instead of receiving a second EPERM/SQLITE_NOTADB failure (A-SPEC-588).
118
+ try {
119
+ this.db.close();
110
120
  }
121
+ catch { /* preserve the initialization error */ }
122
+ throw error;
111
123
  }
112
124
  }
113
125
  // @implements A-SPEC-139
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.resolveSemanticKey = resolveSemanticKey;
37
37
  exports.storeSemanticKey = storeSemanticKey;
38
38
  exports.removeSemanticKey = removeSemanticKey;
39
+ // @implements A-SPEC-592
39
40
  // @implements A-SPEC-477
40
41
  /**
41
42
  * The cloud tier's credential: WHERE the consent lives, and in what order it is looked up.
@@ -49,9 +50,9 @@ exports.removeSemanticKey = removeSemanticKey;
49
50
  * Resolution chain, the order being the contract:
50
51
  * 1. HOLMES_SEMANTIC_API_KEY — dedicated name, CI/headless.
51
52
  * 2. GEMINI_API_KEY — ecosystem-compatible name.
52
- * 3. macOS keychain — `security` via injected exec (absent elsewhere; win32 is a
53
- * recorded Windows-agent follow-up).
54
- * 4. ~/.holmes/credentials.json — 0600 under 0700, the everywhere-fallback.
53
+ * 3. macOS keychain — `security` via injected exec (absent elsewhere).
54
+ * 4. ~/.holmes/credentials.json — POSIX0600 under0700, or protected current-user-only
55
+ * Windows DACLs, the everywhere-fallback.
55
56
  *
56
57
  * The VALUE never appears in argv (ps/history surfaces), logs, reports, or error messages.
57
58
  * Keychain writes feed the secret over STDIN (`security -i`) for the same reason.
@@ -60,11 +61,59 @@ const fs = __importStar(require("node:fs"));
60
61
  const path = __importStar(require("node:path"));
61
62
  const os = __importStar(require("node:os"));
62
63
  const node_child_process_1 = require("node:child_process");
64
+ const node_crypto_1 = require("node:crypto");
63
65
  const SERVICE = 'holmes-kit';
64
66
  const ACCOUNT = 'semantic';
65
67
  const defaultExec = (cmd, args, stdin) => (0, node_child_process_1.execFileSync)(cmd, args, { encoding: 'utf8', input: stdin, stdio: ['pipe', 'pipe', 'pipe'] });
66
68
  const credFile = (home) => path.join(home, '.holmes', 'credentials.json');
67
69
  const nonBlank = (v) => typeof v === 'string' && v.trim() !== '' ? v.trim() : null;
70
+ // Fixed command: its stdin contains only encoded paths, never credential content. Fresh security
71
+ // descriptors discard both inherited and explicit broad grants; chmod cannot do that on Windows.
72
+ const PRIVATE_WINDOWS_ACL = `
73
+ $ErrorActionPreference = 'Stop'
74
+ $ProgressPreference = 'SilentlyContinue'
75
+ $targets = ConvertFrom-Json ([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String([Console]::In.ReadToEnd())))
76
+ $sid = [Security.Principal.WindowsIdentity]::GetCurrent().User
77
+ foreach ($target in $targets) {
78
+ $item = Get-Item -LiteralPath $target.path -Force
79
+ if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Credential path is a reparse point' }
80
+ if ($target.directory) {
81
+ $acl = [Security.AccessControl.DirectorySecurity]::new()
82
+ $rule = [Security.AccessControl.FileSystemAccessRule]::new($sid, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')
83
+ } else {
84
+ $acl = [Security.AccessControl.FileSecurity]::new()
85
+ $rule = [Security.AccessControl.FileSystemAccessRule]::new($sid, 'FullControl', 'Allow')
86
+ }
87
+ $acl.SetAccessRuleProtection($true, $false)
88
+ $acl.AddAccessRule($rule)
89
+ # Persist only the DACL. Set-Acl can request SACL privileges during a repeated protected update.
90
+ if ($target.directory) { [IO.Directory]::SetAccessControl($target.path, $acl) }
91
+ else { [IO.File]::SetAccessControl($target.path, $acl) }
92
+ $actual = Get-Acl -LiteralPath $target.path
93
+ $rules = @($actual.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier]))
94
+ if (-not $actual.AreAccessRulesProtected -or $rules.Count -ne 1 -or
95
+ $rules[0].IdentityReference.Value -ne $sid.Value -or $rules[0].IsInherited -or
96
+ $rules[0].AccessControlType -ne 'Allow' -or
97
+ $rules[0].FileSystemRights -ne [Security.AccessControl.FileSystemRights]::FullControl) {
98
+ throw 'Credential ACL verification failed'
99
+ }
100
+ }
101
+ `;
102
+ function protectWindowsPaths(targets) {
103
+ try {
104
+ (0, node_child_process_1.execFileSync)('powershell.exe', [
105
+ '-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand',
106
+ Buffer.from(PRIVATE_WINDOWS_ACL, 'utf16le').toString('base64'),
107
+ ], {
108
+ input: Buffer.from(JSON.stringify(targets), 'utf8').toString('base64'),
109
+ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, timeout: 15000,
110
+ });
111
+ }
112
+ catch {
113
+ // Native errors may carry command output. Keep the public error stable and free of values.
114
+ throw new Error('Unable to protect semantic credentials with a private Windows ACL; the new key was not stored.');
115
+ }
116
+ }
68
117
  function resolveSemanticKey(opts = {}) {
69
118
  const env = opts.env ?? process.env;
70
119
  const home = opts.home ?? os.homedir();
@@ -109,12 +158,40 @@ function storeSemanticKey(key, opts = {}) {
109
158
  catch { /* fall through to the file */ }
110
159
  }
111
160
  const file = credFile(home);
112
- fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
113
- fs.chmodSync(path.dirname(file), 0o700);
114
- const tmp = file + '.tmp';
115
- fs.writeFileSync(tmp, JSON.stringify({ semantic: { provider: 'gemini', key } }), { mode: 0o600 });
116
- fs.renameSync(tmp, file);
117
- fs.chmodSync(file, 0o600);
161
+ const directory = path.dirname(file);
162
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
163
+ // opts.platform selects a credential source in tests. Permissions must follow the actual host.
164
+ if (process.platform === 'win32') {
165
+ protectWindowsPaths([
166
+ { path: directory, directory: true },
167
+ ...(fs.existsSync(file) ? [{ path: file, directory: false }] : []),
168
+ ]);
169
+ }
170
+ else {
171
+ fs.chmodSync(directory, 0o700);
172
+ }
173
+ const tmp = `${file}.${(0, node_crypto_1.randomUUID)()}.tmp`;
174
+ let fd;
175
+ let created = false;
176
+ try {
177
+ fd = fs.openSync(tmp, 'wx', 0o600);
178
+ created = true;
179
+ if (process.platform === 'win32')
180
+ protectWindowsPaths([{ path: tmp, directory: false }]);
181
+ else
182
+ fs.fchmodSync(fd, 0o600);
183
+ // The empty file is private before the first secret byte, and the descriptor stays owned.
184
+ fs.writeFileSync(fd, JSON.stringify({ semantic: { provider: 'gemini', key } }));
185
+ fs.closeSync(fd);
186
+ fd = undefined;
187
+ fs.renameSync(tmp, file);
188
+ }
189
+ finally {
190
+ if (fd !== undefined)
191
+ fs.closeSync(fd);
192
+ if (created)
193
+ fs.rmSync(tmp, { force: true });
194
+ }
118
195
  return 'file';
119
196
  }
120
197
  function removeSemanticKey(opts = {}) {
@@ -1,39 +1,7 @@
1
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
2
  Object.defineProperty(exports, "__esModule", { value: true });
36
3
  exports.makeTierEmbedder = makeTierEmbedder;
4
+ // @implements A-SPEC-591
37
5
  // @implements A-SPEC-478
38
6
  /**
39
7
  * The tier adapters — the productized form of exactly what the measurement scripts proved:
@@ -54,6 +22,7 @@ exports.makeTierEmbedder = makeTierEmbedder;
54
22
  */
55
23
  const tier_1 = require("./tier");
56
24
  const credentials_1 = require("./credentials");
25
+ const local_model_1 = require("./local-model");
57
26
  const CLOUD_BATCH = 25;
58
27
  const CLOUD_BACKOFF_MS = 30_000;
59
28
  function makeTierEmbedder(tier, cache, opts = {}) {
@@ -61,15 +30,11 @@ function makeTierEmbedder(tier, cache, opts = {}) {
61
30
  return null;
62
31
  const model = tier.model;
63
32
  if (tier.tier === 'local') {
64
- const tag = `${model}@q8-${(0, tier_1.POOLING_OF)(model)}`;
33
+ const tag = `${model}@${local_model_1.LOCAL_REVISION}-q8-${(0, tier_1.POOLING_OF)(model)}`;
65
34
  let pipe = null;
66
35
  const load = () => {
67
36
  if (pipe === null) {
68
- pipe = (opts.pipelineLoader ?? (async (m) => {
69
- const specifier = '@xenova/transformers';
70
- const mod = await Promise.resolve(`${specifier}`).then(s => __importStar(require(s)));
71
- return mod.pipeline('feature-extraction', m, { quantized: true });
72
- }))(model);
37
+ pipe = (opts.pipelineLoader ?? local_model_1.loadLocalPipeline)(model);
73
38
  }
74
39
  return pipe;
75
40
  };
@@ -79,6 +44,8 @@ function makeTierEmbedder(tier, cache, opts = {}) {
79
44
  return Array.from((await p(text, { pooling: (0, tier_1.POOLING_OF)(model), normalize: true })).data);
80
45
  }
81
46
  catch {
47
+ pipe = null;
48
+ (opts.log ?? ((line) => process.stderr.write(`${line}\n`)))('Local embedding unavailable. Run holmes-kit semantic-setup, then holmes-kit semantic-check.');
82
49
  return null;
83
50
  }
84
51
  };
@@ -0,0 +1,30 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { modelCacheDir, modelCacheReady } from './model-cache';
3
+ export { modelCacheDir, modelCacheReady };
4
+ export declare const LOCAL_REVISION = "4de13258303883538bd53b696b452bf8099f0858";
5
+ type Pipeline = (text: string, options: {
6
+ pooling: string;
7
+ normalize: boolean;
8
+ }) => Promise<{
9
+ data: Float32Array;
10
+ }>;
11
+ interface Runtime {
12
+ pipeline(task: string, model: string, options: Record<string, unknown>): Promise<Pipeline>;
13
+ }
14
+ export declare function loadLocalPipeline(model?: string, options?: {
15
+ download?: boolean;
16
+ importer?: () => Promise<Runtime>;
17
+ cacheDir?: string;
18
+ }): Promise<Pipeline>;
19
+ export declare function verifyLocalModel(options?: Parameters<typeof loadLocalPipeline>[1]): Promise<{
20
+ ready: boolean;
21
+ dimension?: number;
22
+ message: string;
23
+ }>;
24
+ /** Isolate setup from the MCP and bound slow downloads/inference; all platforms share this path. */
25
+ export declare function runModelSetup(options?: {
26
+ automatic?: boolean;
27
+ offline?: boolean;
28
+ env?: NodeJS.ProcessEnv;
29
+ spawn?: typeof spawnSync;
30
+ }): number;
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LOCAL_REVISION = exports.modelCacheReady = exports.modelCacheDir = void 0;
4
+ exports.loadLocalPipeline = loadLocalPipeline;
5
+ exports.verifyLocalModel = verifyLocalModel;
6
+ exports.runModelSetup = runModelSetup;
7
+ // @implements A-SPEC-591
8
+ const node_child_process_1 = require("node:child_process");
9
+ const tier_1 = require("./tier");
10
+ const model_cache_1 = require("./model-cache");
11
+ Object.defineProperty(exports, "modelCacheDir", { enumerable: true, get: function () { return model_cache_1.modelCacheDir; } });
12
+ Object.defineProperty(exports, "modelCacheReady", { enumerable: true, get: function () { return model_cache_1.modelCacheReady; } });
13
+ exports.LOCAL_REVISION = '4de13258303883538bd53b696b452bf8099f0858';
14
+ // TypeScript's CommonJS transform rewrites import() into require(), which cannot load this ESM
15
+ // dependency on supported Node 20 versions. Preserve native dynamic import without shell code.
16
+ const importRuntime = new Function('return import("@xenova/transformers")');
17
+ const pipelines = new WeakMap();
18
+ async function loadLocalPipeline(model = tier_1.LOCAL_MODEL, options = {}) {
19
+ const importer = options.importer ?? importRuntime;
20
+ const cacheDir = options.cacheDir ?? (0, model_cache_1.modelCacheDir)();
21
+ const key = JSON.stringify([model, cacheDir, !!options.download]);
22
+ let cache = pipelines.get(importer);
23
+ if (!cache) {
24
+ cache = new Map();
25
+ pipelines.set(importer, cache);
26
+ }
27
+ const existing = cache.get(key);
28
+ if (existing)
29
+ return existing;
30
+ const pending = importer().then(runtime => runtime.pipeline('feature-extraction', model, {
31
+ quantized: true,
32
+ revision: model === tier_1.LOCAL_MODEL ? exports.LOCAL_REVISION : 'main',
33
+ cache_dir: cacheDir,
34
+ local_files_only: !options.download,
35
+ })).catch(error => { cache.delete(key); throw error; });
36
+ cache.set(key, pending);
37
+ return pending;
38
+ }
39
+ async function verifyLocalModel(options = {}) {
40
+ try {
41
+ const pipe = await loadLocalPipeline(tier_1.LOCAL_MODEL, options);
42
+ // Fixed public probe: no repository data is involved in setup.
43
+ const { data } = await pipe('Holmes-Kit local model verification', { pooling: (0, tier_1.POOLING_OF)(tier_1.LOCAL_MODEL), normalize: true });
44
+ const norm = Math.hypot(...data);
45
+ if (data.length !== 1024 || !Array.from(data).every(Number.isFinite) || Math.abs(norm - 1) > 0.001) {
46
+ return { ready: false, dimension: data.length, message: 'BGE-M3 produced an invalid embedding. Select a clean HOLMES_MODEL_CACHE and run holmes-kit semantic-setup.' };
47
+ }
48
+ return { ready: true, dimension: data.length, message: 'BGE-M3 ready: verified 1024-dimensional normalized local embedding.' };
49
+ }
50
+ catch (error) {
51
+ const sharp = String(error instanceof Error ? error.message : '').includes('sharp');
52
+ return { ready: false, message: sharp
53
+ ? 'BGE-M3 runtime unavailable: sharp native installation failed. Review npm install-scripts ls and approve the sharp version, rebuild it, then run holmes-kit semantic-setup.'
54
+ : 'BGE-M3 unavailable: runtime/model load failed. Check network access during setup and cache permissions, then run holmes-kit semantic-setup; use semantic-check to verify offline.' };
55
+ }
56
+ }
57
+ /** Isolate setup from the MCP and bound slow downloads/inference; all platforms share this path. */
58
+ function runModelSetup(options = {}) {
59
+ const env = options.env ?? process.env;
60
+ // @implements A-SPEC-594 — the automatic path does not start a large download the user did not
61
+ // ask for. An explicit refusal outranks an opt-in: someone who set SKIP meant it, and reading
62
+ // AUTO as permission to override that would silently reverse their decision. The two skips say
63
+ // DIFFERENT things, because telling a user "you opted out" when they never did is a lie.
64
+ if (options.automatic) {
65
+ if (env.HOLMES_SKIP_MODEL_INSTALL === '1') {
66
+ process.stderr.write('BGE-M3 automatic setup skipped (HOLMES_SKIP_MODEL_INSTALL=1). Run holmes-kit semantic-setup when ready.\n');
67
+ return 0;
68
+ }
69
+ if (env.HOLMES_AUTO_MODEL_INSTALL !== '1') {
70
+ process.stderr.write('BGE-M3 is not prepared automatically. Run `holmes-kit semantic-setup` to download the public model once (shared across projects), or set HOLMES_AUTO_MODEL_INSTALL=1 to prepare it during installation. Graph features work without it.\n');
71
+ return 0;
72
+ }
73
+ }
74
+ process.stderr.write(options.offline ? 'Checking BGE-M3 offline...\n' : 'Preparing BGE-M3 (public model download on first installation)...\n');
75
+ const result = (options.spawn ?? node_child_process_1.spawnSync)(process.execPath, [__filename, '--worker', ...(options.offline ? ['--offline'] : [])], { env, stdio: 'inherit', timeout: 900_000, windowsHide: true });
76
+ if (result.error || result.status !== 0) {
77
+ process.stderr.write('BGE-M3 setup/check failed or timed out. Graph features remain available; run holmes-kit semantic-setup to retry.\n');
78
+ return options.automatic ? 0 : 1;
79
+ }
80
+ return 0;
81
+ }
82
+ if (require.main === module) {
83
+ if (process.argv.includes('--worker')) {
84
+ verifyLocalModel({ download: !process.argv.includes('--offline') }).then(result => {
85
+ process[result.ready ? 'stdout' : 'stderr'].write(`${result.message}\n`);
86
+ process.exitCode = result.ready ? 0 : 1;
87
+ }).catch(() => { process.stderr.write('BGE-M3 verification failed. Run holmes-kit semantic-setup.\n'); process.exitCode = 1; });
88
+ }
89
+ else {
90
+ process.exitCode = runModelSetup({ automatic: process.argv.includes('--automatic'), offline: process.argv.includes('--offline') });
91
+ }
92
+ }
@@ -0,0 +1,8 @@
1
+ export declare function modelCacheDir(env?: NodeJS.ProcessEnv, home?: string): string;
2
+ /**
3
+ * Ready means the cache holds SOMETHING. An interrupted download leaves the directory created
4
+ * and empty, and reading that as ready is the whole failure mode this predicate exists to stop.
5
+ * Proof of a working model is `semantic-check`'s actual inference — deliberately not here, because
6
+ * doctor calls this on every run and must stay cheap and offline.
7
+ */
8
+ export declare function modelCacheReady(cacheDir?: string): boolean;
@@ -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[];