@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
@@ -12,8 +12,22 @@
12
12
  * This module is the pure re-statement of that rule: no I/O, no process, no platform — the same
13
13
  * verdict on every OS, shared by the test that pins package.json and by doctor (A-SPEC-580).
14
14
  */
15
- /** Dependencies whose install script must actually RUN for the module to load. */
16
- export declare const NATIVE_INSTALL_SCRIPT_DEPS: readonly ["better-sqlite3"];
15
+ /**
16
+ * Dependencies whose install script must actually RUN for the module to load.
17
+ *
18
+ * A-SPEC-595, measured 2026-09-10: `sharp@0.32.6` belongs here on EVERY platform, not just the
19
+ * Windows machine that first hit it. `npm pack sharp@0.32.6` yields a tarball with zero entries
20
+ * matching `.node`, `prebuilds/` or `vendor/`; its declared hook is
21
+ * `(node install/libvips && node install/dll-copy && prebuild-install) || (node install/can-compile
22
+ * && node-gyp rebuild && node install/dll-copy)`; the lockfile records `hasInstallScript: true`;
23
+ * and this repository's macOS tree holds `build/Release/sharp-darwin-arm64v8.node` and
24
+ * `vendor/8.14.5` only because that script ran. (sharp 0.33+ moved to platform packages that DO
25
+ * ship prebuilds — a version bump makes this entry a re-measurement, not a given.)
26
+ *
27
+ * This list is what doctor reads, and `allowScripts` is a root-manifest policy that does not
28
+ * transfer to consumers — so a name missing here is a name no consumer is ever told to approve.
29
+ */
30
+ export declare const NATIVE_INSTALL_SCRIPT_DEPS: readonly ["better-sqlite3", "sharp"];
17
31
  /** Native dependencies that ship prebuilds and load with the script blocked (measured). */
18
32
  export declare const PREBUILT_NATIVE_DEPS: readonly ["tree-sitter", "tree-sitter-typescript", "tree-sitter-python", "tree-sitter-c-sharp", "tree-sitter-java", "tree-sitter-go", "tree-sitter-rust", "tree-sitter-cpp"];
19
33
  export type AllowScripts = Record<string, boolean>;
@@ -20,8 +20,22 @@ exports.policyVerdict = policyVerdict;
20
20
  * This module is the pure re-statement of that rule: no I/O, no process, no platform — the same
21
21
  * verdict on every OS, shared by the test that pins package.json and by doctor (A-SPEC-580).
22
22
  */
23
- /** Dependencies whose install script must actually RUN for the module to load. */
24
- exports.NATIVE_INSTALL_SCRIPT_DEPS = ['better-sqlite3'];
23
+ /**
24
+ * Dependencies whose install script must actually RUN for the module to load.
25
+ *
26
+ * A-SPEC-595, measured 2026-09-10: `sharp@0.32.6` belongs here on EVERY platform, not just the
27
+ * Windows machine that first hit it. `npm pack sharp@0.32.6` yields a tarball with zero entries
28
+ * matching `.node`, `prebuilds/` or `vendor/`; its declared hook is
29
+ * `(node install/libvips && node install/dll-copy && prebuild-install) || (node install/can-compile
30
+ * && node-gyp rebuild && node install/dll-copy)`; the lockfile records `hasInstallScript: true`;
31
+ * and this repository's macOS tree holds `build/Release/sharp-darwin-arm64v8.node` and
32
+ * `vendor/8.14.5` only because that script ran. (sharp 0.33+ moved to platform packages that DO
33
+ * ship prebuilds — a version bump makes this entry a re-measurement, not a given.)
34
+ *
35
+ * This list is what doctor reads, and `allowScripts` is a root-manifest policy that does not
36
+ * transfer to consumers — so a name missing here is a name no consumer is ever told to approve.
37
+ */
38
+ exports.NATIVE_INSTALL_SCRIPT_DEPS = ['better-sqlite3', 'sharp'];
25
39
  /** Native dependencies that ship prebuilds and load with the script blocked (measured). */
26
40
  exports.PREBUILT_NATIVE_DEPS = [
27
41
  'tree-sitter', 'tree-sitter-typescript', 'tree-sitter-python', 'tree-sitter-c-sharp',
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.materializeTreeAt = materializeTreeAt;
37
37
  exports.verifyTreeFidelity = verifyTreeFidelity;
38
+ // @implements A-SPEC-599
38
39
  // @implements A-SPEC-297
39
40
  const fs = __importStar(require("node:fs"));
40
41
  const os = __importStar(require("node:os"));
@@ -56,12 +57,14 @@ function git(repoRoot, args) {
56
57
  }
57
58
  /** `path mode sha` triples the commit records for the pathspec, keyed by repo-relative path. */
58
59
  function blobsAt(repoRoot, commitish, pathspec) {
59
- const out = git(repoRoot, ['ls-tree', '-r', `${commitish}^{tree}`, '--', pathspec]);
60
+ const out = git(repoRoot, ['ls-tree', '-rz', `${commitish}^{tree}`, '--', pathspec]);
60
61
  const blobs = new Map();
61
- for (const line of out.split('\n')) {
62
+ for (const line of out.split('\0')) {
62
63
  if (line.trim() === '')
63
64
  continue;
64
- const [meta, file] = line.split('\t');
65
+ const tab = line.indexOf('\t');
66
+ const meta = line.slice(0, tab);
67
+ const file = line.slice(tab + 1);
65
68
  const parts = meta.split(/\s+/);
66
69
  if (parts[1] !== 'blob')
67
70
  continue; // submodules and trees are not files we can compare
@@ -119,6 +122,43 @@ function materializeTreeAt(repoRoot, commitish, pathspec, destDir, opts) {
119
122
  const tar = (0, node_child_process_1.execFileSync)('git', ['-C', repoRoot, 'archive', '--format=tar', commitish, '--', pathspec], { maxBuffer: 512 * 1024 * 1024 });
120
123
  fs.writeFileSync(tarPath, tar);
121
124
  (0, node_child_process_1.execFileSync)('tar', ['-x', '-C', destDir, '-f', tarPath], { maxBuffer: 512 * 1024 * 1024 });
125
+ // Archive preserves modes/layout, but applies EOL/export substitutions. Read raw objects in
126
+ // one batch so fidelity is independent of user config and committed attributes alike.
127
+ const refsPath = path.join(path.dirname(tarPath), 'objects.txt');
128
+ fs.writeFileSync(refsPath, [...blobs.values()].join('\n') + '\n');
129
+ const refs = fs.openSync(refsPath, 'r');
130
+ let raw;
131
+ try {
132
+ raw = (0, node_child_process_1.execFileSync)('git', ['-C', repoRoot, 'cat-file', '--batch'], {
133
+ stdio: [refs, 'pipe', 'pipe'], maxBuffer: 512 * 1024 * 1024,
134
+ });
135
+ }
136
+ finally {
137
+ fs.closeSync(refs);
138
+ }
139
+ let offset = 0;
140
+ for (const [file, sha] of blobs) {
141
+ const end = raw.indexOf(10, offset);
142
+ const header = raw.subarray(offset, end).toString('ascii').split(' ');
143
+ const size = Number(header[2]);
144
+ if (end < offset || header[0] !== sha || header[1] !== 'blob' || !Number.isSafeInteger(size)
145
+ || size < 0 || end + 1 + size >= raw.length)
146
+ throw new Error('invalid cat-file response');
147
+ const bytes = raw.subarray(end + 1, end + 1 + size);
148
+ offset = end + 1 + size + 1;
149
+ const target = path.join(destDir, file);
150
+ // Keep archive's symlink representation; never follow it to overwrite a target.
151
+ try {
152
+ if (fs.lstatSync(target).isSymbolicLink())
153
+ continue;
154
+ }
155
+ catch (error) {
156
+ if (error.code !== 'ENOENT')
157
+ throw error;
158
+ }
159
+ fs.mkdirSync(path.dirname(target), { recursive: true });
160
+ fs.writeFileSync(target, bytes);
161
+ }
122
162
  }
123
163
  catch (err) {
124
164
  return { ok: false, reason: `archive-or-extract-failed: could not materialize ${commitish}: ${err.message}` };
@@ -27,6 +27,8 @@ export interface GraphBasis {
27
27
  */
28
28
  scanDigest: string;
29
29
  }
30
+ /** Deterministic content fingerprint for every spec field consumed by buildRtm. */
31
+ export declare function specFingerprint(specs: readonly unknown[]): string;
30
32
  /**
31
33
  * Content address of a scan: every file's path and its symbols/edges, sorted so the digest depends
32
34
  * on the scan's CONTENT and not on the order the scanner happened to walk the tree in.
@@ -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;