@holmes-lab/holmes-kit 0.19.6 → 0.20.1

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 (139) hide show
  1. package/CHANGELOG.md +143 -0
  2. package/README.md +9 -2
  3. package/dist/.build-id +1 -1
  4. package/dist/holmes/cli/approve-status.d.ts +24 -0
  5. package/dist/holmes/cli/approve-status.js +166 -0
  6. package/dist/holmes/cli/approve-watch.d.ts +6 -0
  7. package/dist/holmes/cli/approve-watch.js +6 -0
  8. package/dist/holmes/cli/approve.d.ts +27 -0
  9. package/dist/holmes/cli/approve.js +68 -7
  10. package/dist/holmes/cli/cli-execution.d.ts +2 -0
  11. package/dist/holmes/cli/cli-execution.js +24 -0
  12. package/dist/holmes/cli/doctor-check.d.ts +15 -0
  13. package/dist/holmes/cli/doctor-check.js +2 -0
  14. package/dist/holmes/cli/doctor.d.ts +2 -8
  15. package/dist/holmes/cli/doctor.js +12 -3
  16. package/dist/holmes/cli/gitignore-merge.d.ts +0 -5
  17. package/dist/holmes/cli/gitignore-merge.js +6 -2
  18. package/dist/holmes/cli/index.js +176 -139
  19. package/dist/holmes/cli/npx-cache-check.d.ts +16 -0
  20. package/dist/holmes/cli/npx-cache-check.js +130 -0
  21. package/dist/holmes/cli/playbook-skills.d.ts +2 -22
  22. package/dist/holmes/cli/playbook-skills.js +10 -54
  23. package/dist/holmes/cli/probe-process.d.ts +17 -2
  24. package/dist/holmes/cli/probe-process.js +44 -11
  25. package/dist/holmes/governance/approval-grants.d.ts +22 -0
  26. package/dist/holmes/governance/approval-grants.js +55 -1
  27. package/dist/holmes/governance/approval-queue.d.ts +43 -4
  28. package/dist/holmes/governance/approval-queue.js +91 -13
  29. package/dist/holmes/governance/display-id-aliases.d.ts +29 -0
  30. package/dist/holmes/governance/display-id-aliases.js +86 -0
  31. package/dist/holmes/governance/ledger-store.d.ts +42 -14
  32. package/dist/holmes/governance/ledger-store.js +49 -3
  33. package/dist/holmes/governance/ledger-timeline.d.ts +3 -1
  34. package/dist/holmes/governance/ledger-timeline.js +11 -1
  35. package/dist/holmes/governance/provenance-chain.d.ts +2 -0
  36. package/dist/holmes/governance/provenance-chain.js +13 -2
  37. package/dist/holmes/governance/provenance-ledger.d.ts +28 -0
  38. package/dist/holmes/governance/provenance-ledger.js +69 -16
  39. package/dist/holmes/governance/session-context.d.ts +3 -0
  40. package/dist/holmes/governance/session-context.js +26 -8
  41. package/dist/holmes/guardrail/risk-classifier.js +2 -2
  42. package/dist/holmes/guardrail/write-target.js +2 -1
  43. package/dist/holmes/hooks/pre-tool-use.js +12 -1
  44. package/dist/holmes/hooks/stop.js +14 -3
  45. package/dist/holmes/mcp/handlers/adoption.d.ts +118 -0
  46. package/dist/holmes/mcp/handlers/adoption.js +186 -0
  47. package/dist/holmes/mcp/handlers/entity-integration.d.ts +100 -0
  48. package/dist/holmes/mcp/handlers/entity-integration.js +324 -0
  49. package/dist/holmes/mcp/handlers/entity-renumber.d.ts +137 -0
  50. package/dist/holmes/mcp/handlers/entity-renumber.js +228 -0
  51. package/dist/holmes/mcp/handlers/entity-store.d.ts +78 -0
  52. package/dist/holmes/mcp/handlers/entity-store.js +212 -0
  53. package/dist/holmes/mcp/handlers/graph-operations.d.ts +90 -0
  54. package/dist/holmes/mcp/handlers/graph-operations.js +260 -0
  55. package/dist/holmes/mcp/handlers/maintenance-analysis.d.ts +34 -0
  56. package/dist/holmes/mcp/handlers/maintenance-analysis.js +288 -0
  57. package/dist/holmes/mcp/handlers/maintenance-evidence.d.ts +40 -0
  58. package/dist/holmes/mcp/handlers/maintenance-evidence.js +104 -0
  59. package/dist/holmes/mcp/handlers/operator-inspection.d.ts +167 -0
  60. package/dist/holmes/mcp/handlers/operator-inspection.js +284 -0
  61. package/dist/holmes/mcp/handlers/phase-queries.d.ts +34 -0
  62. package/dist/holmes/mcp/handlers/phase-queries.js +86 -0
  63. package/dist/holmes/mcp/handlers/review-evidence.d.ts +47 -0
  64. package/dist/holmes/mcp/handlers/review-evidence.js +370 -0
  65. package/dist/holmes/mcp/handlers/review-queries.d.ts +68 -0
  66. package/dist/holmes/mcp/handlers/review-queries.js +79 -0
  67. package/dist/holmes/mcp/handlers/risk-assessment.d.ts +25 -0
  68. package/dist/holmes/mcp/handlers/risk-assessment.js +181 -0
  69. package/dist/holmes/mcp/handlers/slice-orchestration.d.ts +72 -0
  70. package/dist/holmes/mcp/handlers/slice-orchestration.js +373 -0
  71. package/dist/holmes/mcp/handlers/spec-approval.d.ts +105 -0
  72. package/dist/holmes/mcp/handlers/spec-approval.js +504 -0
  73. package/dist/holmes/mcp/handlers/spec-authoring.d.ts +57 -0
  74. package/dist/holmes/mcp/handlers/spec-authoring.js +326 -0
  75. package/dist/holmes/mcp/handlers/spec-lifecycle.d.ts +122 -0
  76. package/dist/holmes/mcp/handlers/spec-lifecycle.js +339 -0
  77. package/dist/holmes/mcp/handlers/spec-queries.d.ts +30 -0
  78. package/dist/holmes/mcp/handlers/spec-queries.js +74 -0
  79. package/dist/holmes/mcp/handlers/taint-screening.d.ts +48 -0
  80. package/dist/holmes/mcp/handlers/taint-screening.js +183 -0
  81. package/dist/holmes/mcp/handlers/test-execution.d.ts +63 -0
  82. package/dist/holmes/mcp/handlers/test-execution.js +210 -0
  83. package/dist/holmes/mcp/handlers/workspace-identity.d.ts +30 -0
  84. package/dist/holmes/mcp/handlers/workspace-identity.js +68 -0
  85. package/dist/holmes/mcp/handlers/workspace-queries.d.ts +77 -0
  86. package/dist/holmes/mcp/handlers/workspace-queries.js +193 -0
  87. package/dist/holmes/mcp/handlers.d.ts +661 -424
  88. package/dist/holmes/mcp/handlers.js +270 -3251
  89. package/dist/holmes/mcp/server-specs-dir.d.ts +2 -0
  90. package/dist/holmes/mcp/server-specs-dir.js +71 -0
  91. package/dist/holmes/mcp/server.js +12 -2
  92. package/dist/holmes/mcp/tool-schemas.js +43 -2
  93. package/dist/holmes/project/execution-context.d.ts +17 -0
  94. package/dist/holmes/project/execution-context.js +121 -0
  95. package/dist/holmes/project/installer-markers.d.ts +34 -0
  96. package/dist/holmes/project/installer-markers.js +65 -0
  97. package/dist/holmes/project/root.d.ts +12 -1
  98. package/dist/holmes/project/root.js +22 -3
  99. package/dist/holmes/project/workspace-identity.d.ts +29 -0
  100. package/dist/holmes/project/workspace-identity.js +181 -0
  101. package/dist/holmes/review/test-outcomes.d.ts +6 -1
  102. package/dist/holmes/review/test-outcomes.js +23 -5
  103. package/dist/holmes/review/test-runner.d.ts +18 -0
  104. package/dist/holmes/review/test-runner.js +136 -5
  105. package/dist/holmes/rtm/graph-store.d.ts +50 -1
  106. package/dist/holmes/rtm/graph-store.js +244 -3
  107. package/dist/holmes/rtm/incremental.d.ts +1 -0
  108. package/dist/holmes/rtm/incremental.js +12 -3
  109. package/dist/holmes/rtm/localize.js +7 -0
  110. package/dist/holmes/rtm/rtm-graph.d.ts +9 -0
  111. package/dist/holmes/rtm/rtm-graph.js +13 -0
  112. package/dist/holmes/rtm/test-scope.js +4 -1
  113. package/dist/holmes/semantic/vector-cache.d.ts +16 -1
  114. package/dist/holmes/semantic/vector-cache.js +155 -19
  115. package/dist/holmes/spec/approval-status.d.ts +10 -0
  116. package/dist/holmes/spec/approval-status.js +7 -3
  117. package/dist/holmes/spec/entity-git-snapshot.d.ts +50 -0
  118. package/dist/holmes/spec/entity-git-snapshot.js +276 -0
  119. package/dist/holmes/spec/entity-integration-discharge.d.ts +73 -0
  120. package/dist/holmes/spec/entity-integration-discharge.js +233 -0
  121. package/dist/holmes/spec/entity-integration-transaction.d.ts +179 -0
  122. package/dist/holmes/spec/entity-integration-transaction.js +1221 -0
  123. package/dist/holmes/spec/entity-integration.d.ts +216 -0
  124. package/dist/holmes/spec/entity-integration.js +760 -0
  125. package/dist/holmes/spec/entity-renumber-transaction.d.ts +82 -0
  126. package/dist/holmes/spec/entity-renumber-transaction.js +711 -0
  127. package/dist/holmes/spec/entity-renumber.d.ts +32 -0
  128. package/dist/holmes/spec/entity-renumber.js +156 -0
  129. package/dist/holmes/spec/entity-store.d.ts +135 -0
  130. package/dist/holmes/spec/entity-store.js +1053 -0
  131. package/dist/holmes/spec/entity-transaction.d.ts +105 -0
  132. package/dist/holmes/spec/entity-transaction.js +741 -0
  133. package/dist/holmes/spec/renumber.d.ts +58 -0
  134. package/dist/holmes/spec/renumber.js +200 -1
  135. package/dist/holmes/spec/spec-store.d.ts +3 -2
  136. package/dist/holmes/spec/spec-store.js +23 -2
  137. package/dist/holmes/spec/transition-policy.d.ts +36 -0
  138. package/dist/holmes/spec/transition-policy.js +135 -0
  139. package/package.json +5 -2
@@ -33,13 +33,19 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.GRAPH_STALE_MS = void 0;
36
37
  exports.specFingerprint = specFingerprint;
37
38
  exports.scanDigest = scanDigest;
38
39
  exports.openReusableGraph = openReusableGraph;
40
+ exports.publishGraph = publishGraph;
41
+ exports.openPublishedGraph = openPublishedGraph;
42
+ exports.headCommitOf = headCommitOf;
39
43
  // @implements A-SPEC-282
44
+ // @implements A-SPEC-631
40
45
  const fs = __importStar(require("node:fs"));
41
46
  const path = __importStar(require("node:path"));
42
47
  const node_crypto_1 = require("node:crypto");
48
+ const node_child_process_1 = require("node:child_process");
43
49
  const rtm_graph_1 = require("./rtm-graph");
44
50
  const BASIS_FIELDS = [
45
51
  'graphSchema', 'extractorVersion', 'sourceCommit', 'specFingerprint', 'scanDigest',
@@ -88,8 +94,10 @@ function readStoredBasis(graph) {
88
94
  * to fail: the caller always receives a usable graph, empty when the stored one could not be
89
95
  * trusted, so "the cache is broken" degrades to "this call is slower" rather than to an outage.
90
96
  */
91
- function openReusableGraph(dbPath, expected) {
97
+ function openReusableGraph(dbPath, expected, opts = {}) {
92
98
  fs.mkdirSync(path.dirname(dbPath), { recursive: true });
99
+ if (opts.publishable)
100
+ return openPublishable(dbPath, expected);
93
101
  let graph;
94
102
  let reason = null;
95
103
  try {
@@ -126,11 +134,244 @@ function openReusableGraph(dbPath, expected) {
126
134
  const commitBasis = () => {
127
135
  graph.writeMeta(Object.fromEntries(BASIS_FIELDS.map((f) => [f, expected[f]])));
128
136
  };
137
+ const finish = () => graph.close();
129
138
  if (reason) {
130
139
  // Not reusable means EMPTY. Handing back a partially-stale graph would be worse than no cache:
131
140
  // the caller would build on top of facts whose basis it has just been told it cannot trust.
132
141
  graph.clear();
133
- return { graph, reusable: false, reason, commitBasis };
142
+ return { graph, reusable: false, reason, commitBasis, publish: finish, discard: finish };
143
+ }
144
+ return { graph, reusable: true, reason: null, commitBasis, publish: finish, discard: finish };
145
+ }
146
+ // ---------------------------------------------------------------------------------------------
147
+ // @implements A-SPEC-631 — publication and inspection of the LIVE graph.
148
+ //
149
+ // Before this, rtm_impact (and therefore every Stop-hook refresh) opened `.ax/rtm.sqlite` through
150
+ // openReusableGraph, which CLEARS the file in place when the basis moved and rebuilds into it. Any
151
+ // reader in that window — an approval computing its impact advisory — saw zero nodes and reported
152
+ // "no impact"; a refresher killed after the clear left the file empty and basis-less for everyone.
153
+ // Reproduced with the built product on 2026-09-13. The live file therefore changes only by rename
154
+ // of a complete, basis-committed temporary database, and readers classify what they find.
155
+ // ---------------------------------------------------------------------------------------------
156
+ /** Age after which a `.tmp-*` / `.bak-*` sibling of the live graph is treated as abandoned by a dead publisher. */
157
+ exports.GRAPH_STALE_MS = 10 * 60_000;
158
+ const removeQuietly = (p) => { try {
159
+ fs.rmSync(p, { force: true });
160
+ }
161
+ catch { /* best effort */ } };
162
+ const uniqueSibling = (livePath, kind) => `${livePath}.${kind}-${process.pid}-${(0, node_crypto_1.randomBytes)(6).toString('hex')}`;
163
+ /**
164
+ * Replace the live file with the finished temporary database. One rename is atomic on POSIX and
165
+ * replaces a closed file on Windows. When the live file is held open elsewhere on Windows the
166
+ * direct rename is refused (EPERM/EBUSY/EACCES); only then fall back to the rename-aside sequence,
167
+ * where an ENOENT on the aside step means a sibling publisher already replaced the file and is not
168
+ * an error — executing that sequence unconditionally was what made one of four concurrent reindexes
169
+ * fail before.
170
+ */
171
+ function replaceLive(tempPath, livePath) {
172
+ try {
173
+ fs.renameSync(tempPath, livePath);
174
+ return;
175
+ }
176
+ catch (error) {
177
+ const code = error.code;
178
+ if (code !== 'EPERM' && code !== 'EBUSY' && code !== 'EACCES')
179
+ throw error;
180
+ }
181
+ const backup = uniqueSibling(livePath, 'bak');
182
+ let hadBackup = false;
183
+ try {
184
+ fs.renameSync(livePath, backup);
185
+ hadBackup = true;
186
+ }
187
+ catch (error) {
188
+ if (error.code !== 'ENOENT')
189
+ throw error;
190
+ }
191
+ try {
192
+ fs.renameSync(tempPath, livePath);
193
+ }
194
+ catch (error) {
195
+ if (hadBackup && !fs.existsSync(livePath)) {
196
+ try {
197
+ fs.renameSync(backup, livePath);
198
+ }
199
+ catch { /* preserve the publication error */ }
200
+ }
201
+ throw error;
202
+ }
203
+ if (hadBackup)
204
+ removeQuietly(backup);
205
+ }
206
+ /** Remove `.tmp-*` / `.bak-*` siblings (and their SQLite sidecars) older than the stale bound; younger ones may belong to a live publisher. */
207
+ function sweepAbandoned(livePath) {
208
+ const dir = path.dirname(livePath), base = path.basename(livePath);
209
+ let names;
210
+ try {
211
+ names = fs.readdirSync(dir);
212
+ }
213
+ catch {
214
+ return;
215
+ }
216
+ for (const name of names) {
217
+ if (!name.startsWith(`${base}.tmp-`) && !name.startsWith(`${base}.bak-`))
218
+ continue;
219
+ const p = path.join(dir, name);
220
+ try {
221
+ if (Date.now() - fs.statSync(p).mtimeMs > exports.GRAPH_STALE_MS)
222
+ fs.rmSync(p, { force: true });
223
+ }
224
+ catch { /* raced with its owner or already gone */ }
225
+ }
226
+ }
227
+ /**
228
+ * Build a complete graph in a private temporary database, commit `basis`, close it, and replace
229
+ * the live file atomically. The live file is never opened for writing and never cleared; a build
230
+ * that throws leaves it untouched and removes the temporary file. Returns the published counts.
231
+ */
232
+ function publishGraph(livePath, basis, build) {
233
+ fs.mkdirSync(path.dirname(livePath), { recursive: true });
234
+ const tempPath = uniqueSibling(livePath, 'tmp');
235
+ let nodes = 0, edges = 0;
236
+ try {
237
+ const graph = new rtm_graph_1.RtmGraph(tempPath);
238
+ try {
239
+ build(graph);
240
+ graph.writeMeta(Object.fromEntries(BASIS_FIELDS.map((f) => [f, basis[f]])));
241
+ nodes = graph.nodeCount();
242
+ edges = graph.edgeCount();
243
+ }
244
+ finally {
245
+ graph.close(); // checkpoints WAL and drops the temporary database's sidecars
246
+ }
247
+ replaceLive(tempPath, livePath);
248
+ }
249
+ catch (error) {
250
+ for (const suffix of ['', '-wal', '-shm', '-journal'])
251
+ removeQuietly(tempPath + suffix);
252
+ throw error;
253
+ }
254
+ sweepAbandoned(livePath);
255
+ return { nodes, edges };
256
+ }
257
+ /**
258
+ * Open the live graph WITHOUT mutating it and say what it is: absent, unreadable (open or metadata
259
+ * failed — the file is left for the next publication to replace, never deleted here), empty (a
260
+ * schema with no nodes — the state an interrupted in-place rebuild used to leave behind), or a
261
+ * served graph whose basis commit is current / stale against `head`, or unverified when either side
262
+ * is unknown. Served statuses return an OPEN handle the caller must close.
263
+ */
264
+ function openPublishedGraph(livePath, head) {
265
+ let graphAsOf;
266
+ try {
267
+ graphAsOf = fs.statSync(livePath).mtime.toISOString();
268
+ }
269
+ catch {
270
+ return { status: 'absent', graph: null };
271
+ }
272
+ let graph;
273
+ try {
274
+ graph = new rtm_graph_1.RtmGraph(livePath);
275
+ }
276
+ catch {
277
+ return { status: 'unreadable', graph: null, graphAsOf };
278
+ }
279
+ let meta, nodes;
280
+ try {
281
+ meta = graph.readMeta();
282
+ nodes = graph.nodeCount();
283
+ }
284
+ catch {
285
+ try {
286
+ graph.close();
287
+ }
288
+ catch { /* preserve the classification */ }
289
+ return { status: 'unreadable', graph: null, graphAsOf };
290
+ }
291
+ if (nodes === 0) {
292
+ graph.close();
293
+ return { status: 'empty', graph: null, graphAsOf };
294
+ }
295
+ const sourceCommit = typeof meta.sourceCommit === 'string' && meta.sourceCommit !== '' ? meta.sourceCommit : null;
296
+ const status = sourceCommit === null || head === null ? 'unverified' : sourceCommit === head ? 'current' : 'stale';
297
+ return { status, graph, sourceCommit, head, graphAsOf };
298
+ }
299
+ /**
300
+ * The `publishable` mode of openReusableGraph. Reads the live file without mutating it; when it
301
+ * cannot be reused (absent, unreadable, no basis, a moved field) the returned graph is an empty
302
+ * private temporary database whose `publish()` replaces the live file atomically. The live file is
303
+ * never created, cleared or deleted here — an unreadable one is left for publication to replace.
304
+ */
305
+ function openPublishable(livePath, expected) {
306
+ const basisMeta = () => Object.fromEntries(BASIS_FIELDS.map((f) => [f, expected[f]]));
307
+ let live = null;
308
+ let reason = null;
309
+ if (!fs.existsSync(livePath))
310
+ reason = 'no stored graph — nothing to reuse';
311
+ else {
312
+ try {
313
+ live = new rtm_graph_1.RtmGraph(livePath);
314
+ }
315
+ catch {
316
+ reason = 'stored graph was unreadable (left for publication to replace)';
317
+ }
318
+ if (live !== null) {
319
+ const stored = readStoredBasis(live);
320
+ if (!stored)
321
+ reason = 'no stored basis — nothing to reuse';
322
+ else {
323
+ const moved = BASIS_FIELDS.find((field) => stored[field] !== expected[field]);
324
+ if (moved)
325
+ reason = `${moved} changed since the stored graph was built`;
326
+ }
327
+ }
328
+ }
329
+ if (reason === null && live !== null) {
330
+ const handle = live;
331
+ const finish = () => handle.close();
332
+ return { graph: handle, reusable: true, reason: null, commitBasis: () => handle.writeMeta(basisMeta()), publish: finish, discard: finish };
333
+ }
334
+ if (live !== null) {
335
+ try {
336
+ live.close();
337
+ }
338
+ catch { /* the handle is not reused */ }
339
+ }
340
+ const tempPath = uniqueSibling(livePath, 'tmp');
341
+ const graph = new rtm_graph_1.RtmGraph(tempPath);
342
+ const cleanup = () => { for (const suffix of ['', '-wal', '-shm', '-journal'])
343
+ removeQuietly(tempPath + suffix); };
344
+ return {
345
+ graph,
346
+ reusable: false,
347
+ reason,
348
+ commitBasis: () => graph.writeMeta(basisMeta()),
349
+ publish: () => {
350
+ try {
351
+ graph.close();
352
+ replaceLive(tempPath, livePath);
353
+ }
354
+ catch (error) {
355
+ cleanup();
356
+ throw error;
357
+ }
358
+ sweepAbandoned(livePath);
359
+ },
360
+ discard: () => { try {
361
+ graph.close();
362
+ }
363
+ finally {
364
+ cleanup();
365
+ } },
366
+ };
367
+ }
368
+ /** HEAD of the repository at `root`, or null outside Git — the only freshness input a consumer pays for (no scan). */
369
+ function headCommitOf(root, env = process.env) {
370
+ try {
371
+ const out = (0, node_child_process_1.execFileSync)('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8', env, stdio: ['ignore', 'pipe', 'pipe'] }).trim();
372
+ return /^[0-9a-f]{40}$/.test(out) ? out : null;
373
+ }
374
+ catch {
375
+ return null;
134
376
  }
135
- return { graph, reusable: true, reason: null, commitBasis };
136
377
  }
@@ -59,4 +59,5 @@ export declare function applyIncremental(graph: RtmGraph, changes: ChangeSet, ct
59
59
  buildOptions?: BuildRtmOptions;
60
60
  }): {
61
61
  callEdgesResolved: boolean;
62
+ importsResolved: boolean;
62
63
  };
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.applyIncremental = applyIncremental;
4
+ // @implements A-SPEC-644
4
5
  const rtm_builder_1 = require("./rtm-builder");
5
6
  /**
6
7
  * Reduces every add/modify/delete/rename case to one primitive: replace file
@@ -55,13 +56,21 @@ function applyIncremental(graph, changes, ctx) {
55
56
  replaceFile(f);
56
57
  }
57
58
  if (!ctx.allScanned)
58
- return { callEdgesResolved: false };
59
+ return { callEdgesResolved: false, importsResolved: false };
59
60
  // @implements A-SPEC-573.5 — `calls_ambiguous` is rebuilt by the same pass, so it must be cleared
60
61
  // by the same pass. Missing it left a stale candidate edge behind when an ambiguity RESOLVED
61
62
  // (the second definer was deleted), and incremental stopped converging with a full rebuild —
62
63
  // caught by A-SPEC-280's convergence property, which is exactly what it is for.
64
+ const scanned = ctx.allScanned();
63
65
  graph.removeEdgesByRel('calls');
64
66
  graph.removeEdgesByRel('calls_ambiguous');
65
- (0, rtm_builder_1.addCallEdges)(ctx.allScanned(), graph, ctx.buildOptions);
66
- return { callEdgesResolved: true };
67
+ (0, rtm_builder_1.addCallEdges)(scanned, graph, ctx.buildOptions);
68
+ // @implements A-SPEC-644 the same whole-scan rule for imports. FILE nodes and `imports` edges are
69
+ // written by `addImportEdges` over the whole scan (A-SPEC-289) and never by the per-file subgraph, so
70
+ // measured 2026-09-14 an update that added an import produced no FILE node and no edge, and one that
71
+ // deleted the import target left a stale FILE node. Clear the kind and the relation, then re-resolve.
72
+ graph.removeEdgesByRel('imports');
73
+ graph.removeNodesByKind('FILE');
74
+ (0, rtm_builder_1.addImportEdges)(scanned, graph, ctx.buildOptions);
75
+ return { callEdgesResolved: true, importsResolved: true };
67
76
  }
@@ -163,6 +163,10 @@ function localizeIssue(issueText, scanned, specs, topN = 10) {
163
163
  // be recovered lexically, however many terms it has.
164
164
  const citations = citationsIn(issueText, new Set(specs.map((s) => s.id)));
165
165
  const citedSet = new Set(citations.cited);
166
+ // @implements A-SPEC-619 — explicit architecture-contract evidence is a ranking key,
167
+ // not a bonus that unrelated lexical repetition can dilute out of the result window.
168
+ const priorityCitations = new Set(specs.filter(s => s.type === 'A-SPEC' && citedSet.has(s.id)).map(s => s.id));
169
+ const directlyCitedFiles = new Set();
166
170
  if (terms.length === 0 && citations.cited.length === 0) {
167
171
  return { terms: [], hits: [], matchedSpecs: [], unknownCitations: citations.unknown };
168
172
  }
@@ -314,6 +318,8 @@ function localizeIssue(issueText, scanned, specs, topN = 10) {
314
318
  why.push(`implements matched spec(s) ${viaSpecs.join(', ')} (${viaSpecs.length}/${anchors.length} anchors)`);
315
319
  }
316
320
  const citedAnchors = anchors.filter((id) => citedSet.has(id));
321
+ if (anchors.some(id => priorityCitations.has(id)))
322
+ directlyCitedFiles.add(f.sourcePath);
317
323
  if (citedAnchors.length > 0) {
318
324
  // Normalized by anchor count for the same reason the lexical spec bonus is (A-SPEC-270):
319
325
  // otherwise a hub file would collect this bonus for every citation anyone ever writes.
@@ -361,6 +367,7 @@ function localizeIssue(issueText, scanned, specs, topN = 10) {
361
367
  // project where the vendored code really is the answer. The score halving stays, so the relative
362
368
  // order WITHIN each group is unchanged.
363
369
  hits.sort((a, b) => Number(isVendorPath(a.file)) - Number(isVendorPath(b.file)) || // live source before vendored
370
+ Number(directlyCitedFiles.has(b.file)) - Number(directlyCitedFiles.has(a.file)) ||
364
371
  b.score - a.score || // then by evidence strength
365
372
  a.file.localeCompare(b.file)); // then deterministic path order
366
373
  return { terms, hits: hits.slice(0, topN), matchedSpecs: matchedSpecs.sort(), unknownCitations: citations.unknown };
@@ -213,6 +213,15 @@ export declare class RtmGraph {
213
213
  * re-resolving is the only way an incremental update lands on the full-rebuild answer.
214
214
  */
215
215
  removeEdgesByRel(rel: string): void;
216
+ /**
217
+ * @implements A-SPEC-644
218
+ * Drop every node of one kind and every edge touching one. FILE nodes exist only as import endpoints
219
+ * and are owned by whichever file's import first resolved to them, so `removeBySource` cannot reach
220
+ * the ones another file owns — measured, deleting an import target left a stale FILE node for the
221
+ * importer. Clearing the kind and re-running the whole-scan import pass is the only way an
222
+ * incremental update lands on the full-rebuild value, exactly as `removeEdgesByRel` does for calls.
223
+ */
224
+ removeNodesByKind(kind: string): void;
216
225
  /** @implements A-SPEC-282 — every stored meta key/value. */
217
226
  readMeta(): Record<string, string>;
218
227
  /** @implements A-SPEC-282 — replace the stored meta wholesale, so a stale key cannot survive. */
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.RtmGraph = void 0;
7
7
  // @implements A-SPEC-293
8
+ // @implements A-SPEC-644
8
9
  // @implements A-SPEC-289
9
10
  // @implements A-SPEC-288
10
11
  // @implements A-SPEC-283
@@ -425,6 +426,18 @@ class RtmGraph {
425
426
  removeEdgesByRel(rel) {
426
427
  this.db.prepare('DELETE FROM edges WHERE rel = ?').run(rel);
427
428
  }
429
+ /**
430
+ * @implements A-SPEC-644
431
+ * Drop every node of one kind and every edge touching one. FILE nodes exist only as import endpoints
432
+ * and are owned by whichever file's import first resolved to them, so `removeBySource` cannot reach
433
+ * the ones another file owns — measured, deleting an import target left a stale FILE node for the
434
+ * importer. Clearing the kind and re-running the whole-scan import pass is the only way an
435
+ * incremental update lands on the full-rebuild value, exactly as `removeEdgesByRel` does for calls.
436
+ */
437
+ removeNodesByKind(kind) {
438
+ this.db.prepare('DELETE FROM edges WHERE src IN (SELECT id FROM nodes WHERE kind = ?) OR dst IN (SELECT id FROM nodes WHERE kind = ?)').run(kind, kind);
439
+ this.db.prepare('DELETE FROM nodes WHERE kind = ?').run(kind);
440
+ }
428
441
  /** @implements A-SPEC-282 — every stored meta key/value. */
429
442
  readMeta() {
430
443
  const rows = this.db.prepare('SELECT key, value FROM meta').all();
@@ -172,7 +172,10 @@ const FRAMEWORKS = [
172
172
  suiteSkip: /\bdescribe\s*(?:\.\s*skip|\[\s*['"]skip['"]\s*\])|\bxdescribe\b/,
173
173
  // Head must be a STANDALONE identifier (not `.test(`/`.it(` method calls) followed directly by
174
174
  // `(` or by `.each(...)(`: `it.skip(`/`it.todo(`/`xit(` never match — no subtraction needed.
175
- head: /(?<![.\w])(?:it|test|fit)(?:\s*\.\s*each\s*\([^)]*\)\s*)?\s*\(/g,
175
+ // @implements A-SPEC-653 — the platform helpers of A-SPEC-220.3 (`posixOnly('why')()`) are heads too:
176
+ // a POSIX-only suite written with the REPORTING skip must not read as "no real cases" (measured: that
177
+ // is why two suites had used a bare early return instead). `.skip` on a helper is still not a head.
178
+ head: /(?<![.\w])(?:it|test|fit|(?:posixOnly|posixOnlyNonRoot|posixOnlyWithCommand|win32Only)\s*\([^)]*\))(?:\s*\.\s*each\s*\([^)]*\)\s*)?\s*\(/g,
176
179
  },
177
180
  {
178
181
  id: 'pytest',
@@ -1,11 +1,26 @@
1
+ /** Segment count at which a flushing writer attempts compaction (A-SPEC-630). */
2
+ export declare const COMPACTION_THRESHOLD = 16;
3
+ /** Age after which a compaction lock or an abandoned `.tmp` is considered orphaned by a dead writer. */
4
+ export declare const STALE_MS: number;
1
5
  export declare class VectorCache {
2
6
  private readonly file;
7
+ private readonly dir;
8
+ private readonly lock;
3
9
  private store;
10
+ private readonly dirty;
4
11
  constructor(root: string);
5
12
  private load;
6
13
  private key;
7
14
  get(text: string, modelTag: string, kind: 'query' | 'doc'): number[] | null;
15
+ /** Stores in memory and marks the key for the next flush. A non-vector (empty or non-finite) is never stored. */
8
16
  put(text: string, modelTag: string, kind: 'query' | 'doc', vector: number[]): void;
9
- /** Atomic persist: tmp + rename, so a crash mid-write cannot corrupt the previous cache. */
17
+ /**
18
+ * Publish the entries put since the last flush as one immutable segment (unique tmp + rename), then
19
+ * compact if the segment count reached the threshold. Nothing dirty → nothing written. Publication
20
+ * errors propagate as before; compaction errors never do.
21
+ */
10
22
  flush(): void;
23
+ private acquireLock;
24
+ /** Merge base + segments FROM DISK into a new base under the lock; any failure leaves the segments in place. */
25
+ private compactIfNeeded;
11
26
  }
@@ -33,41 +33,117 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.VectorCache = void 0;
36
+ exports.VectorCache = exports.STALE_MS = exports.COMPACTION_THRESHOLD = void 0;
37
37
  // @implements A-SPEC-476
38
+ // @implements A-SPEC-630
38
39
  /**
39
40
  * Content-addressed vector cache: never compute the same embedding twice, never serve a stale
40
- * vector across a model update.
41
+ * vector across a model update, and never let one writer erase another.
41
42
  *
42
43
  * The key is `hash(text) : modelTag : kind`. The model TAG (id plus a caller-supplied version
43
44
  * label) is load-bearing for the cloud tier — an API model can be re-deployed silently, and
44
45
  * "same text, different vector" across that boundary would corrupt every similarity the product
45
46
  * computes. A tag change is a cache miss by construction.
46
47
  *
47
- * The file lives under `.ax/cpg_cache/` an existing protected directory, so no gate surface
48
- * moves. A cache must be LOSABLE: a corrupted file restarts empty instead of throwing (measured
49
- * economics: 37MB for a three-corpus measurement day; recomputing is cost, not damage).
48
+ * Persistence (A-SPEC-630) segments, not snapshots. Every Holmes process that touches a
49
+ * workspace opens its own instance, and independent clones/worktrees/runs do so at the same time.
50
+ * The previous layout loaded one private snapshot and wrote it back whole through a fixed `.tmp`,
51
+ * so two writers erased each other (measured: A puts, B puts, A flushes, B flushes → A is gone)
52
+ * and simultaneous writers crashed on the shared temporary name. Now:
53
+ *
54
+ * .ax/cpg_cache/semantic-vectors.json the base — legacy file, rewritten ONLY by compaction
55
+ * .ax/cpg_cache/semantic-vectors.d/<ts>-<pid>-<rnd>.json one immutable segment per flush, holding only
56
+ * the entries THIS instance put since its last flush
57
+ * .ax/cpg_cache/semantic-vectors.d/*.tmp a file still being written — readers never open it
58
+ * .ax/cpg_cache/semantic-vectors.d/.compact.lock/ mkdir-owned compaction; stale after STALE_MS
59
+ *
60
+ * A reader merges base then segments in ascending name order (later wins), validating every entry:
61
+ * anything that is not a non-empty array of finite numbers is a miss, a file that does not parse
62
+ * contributes nothing, and load never throws. Publication is a unique tmp + rename, so a writer
63
+ * killed at any point leaves committed files untouched. Compaction is opportunistic: once the
64
+ * segment count reaches COMPACTION_THRESHOLD the flushing writer takes the lock, re-reads base and
65
+ * segments FROM DISK, writes the merged object over the base and deletes exactly the segments it
66
+ * merged. Its only failure mode is "not compacted" — never "lost".
67
+ *
68
+ * Measured (2026-09-13, macOS arm64, 5,000 × 1,024 floats = 46 MB): one file loads in 139 ms,
69
+ * 8–256 segments in 132–141 ms, per-entry files in 209 ms (and 407 ms to write vs 153 ms) —
70
+ * which is why the unit of publication is a flush, not an entry.
71
+ *
72
+ * The cache stays LOSABLE: it lives under `.ax/cpg_cache/` (derived, gitignored, protected — no
73
+ * gate surface moves) and recomputing is cost, not damage.
50
74
  */
51
75
  const fs = __importStar(require("node:fs"));
52
76
  const path = __importStar(require("node:path"));
53
77
  const node_crypto_1 = require("node:crypto");
54
78
  const FILE = path.join('.ax', 'cpg_cache', 'semantic-vectors.json');
79
+ const SEGMENT_DIR = path.join('.ax', 'cpg_cache', 'semantic-vectors.d');
80
+ const LOCK_DIR = '.compact.lock';
81
+ /** Segment count at which a flushing writer attempts compaction (A-SPEC-630). */
82
+ exports.COMPACTION_THRESHOLD = 16;
83
+ /** Age after which a compaction lock or an abandoned `.tmp` is considered orphaned by a dead writer. */
84
+ exports.STALE_MS = 10 * 60_000;
85
+ const isVector = (v) => Array.isArray(v) && v.length > 0 && v.every((x) => typeof x === 'number' && Number.isFinite(x));
86
+ /** Parse one cache file into its valid entries; null when the file is unreadable, unparseable or not an object. */
87
+ function readValid(file) {
88
+ let parsed;
89
+ try {
90
+ parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
96
+ return null;
97
+ const out = {};
98
+ for (const [k, v] of Object.entries(parsed))
99
+ if (isVector(v))
100
+ out[k] = v;
101
+ return out;
102
+ }
103
+ function listSegments(dir) {
104
+ try {
105
+ return fs.readdirSync(dir).filter((f) => f.endsWith('.json')).sort();
106
+ }
107
+ catch {
108
+ return [];
109
+ }
110
+ }
111
+ function isStale(p) {
112
+ try {
113
+ return Date.now() - fs.statSync(p).mtimeMs > exports.STALE_MS;
114
+ }
115
+ catch {
116
+ return false;
117
+ }
118
+ }
119
+ // Segment names sort by (timestamp, pid, random). The timestamp is made strictly increasing within a
120
+ // process so two flushes in the same millisecond keep a deterministic order — "later wins" must mean
121
+ // the later flush, not the luckier random suffix.
122
+ let lastStamp = 0;
123
+ function segmentName() {
124
+ const now = Date.now();
125
+ lastStamp = now > lastStamp ? now : lastStamp + 1;
126
+ return `${String(lastStamp).padStart(15, '0')}-${String(process.pid).padStart(8, '0')}-${(0, node_crypto_1.randomBytes)(4).toString('hex')}.json`;
127
+ }
55
128
  class VectorCache {
56
129
  file;
130
+ dir;
131
+ lock;
57
132
  store = null;
133
+ dirty = new Set();
58
134
  constructor(root) {
59
135
  this.file = path.join(root, FILE);
136
+ this.dir = path.join(root, SEGMENT_DIR);
137
+ this.lock = path.join(this.dir, LOCK_DIR);
60
138
  }
61
139
  load() {
62
140
  if (this.store !== null)
63
141
  return this.store;
64
- try {
65
- this.store = JSON.parse(fs.readFileSync(this.file, 'utf8'));
66
- }
67
- catch {
68
- this.store = {};
69
- }
70
- return this.store;
142
+ const merged = readValid(this.file) ?? {};
143
+ for (const s of listSegments(this.dir))
144
+ Object.assign(merged, readValid(path.join(this.dir, s)) ?? {});
145
+ this.store = merged;
146
+ return merged;
71
147
  }
72
148
  key(text, modelTag, kind) {
73
149
  return `${(0, node_crypto_1.createHash)('sha256').update(text).digest('hex').slice(0, 24)}:${modelTag}:${kind}`;
@@ -75,17 +151,77 @@ class VectorCache {
75
151
  get(text, modelTag, kind) {
76
152
  return this.load()[this.key(text, modelTag, kind)] ?? null;
77
153
  }
154
+ /** Stores in memory and marks the key for the next flush. A non-vector (empty or non-finite) is never stored. */
78
155
  put(text, modelTag, kind, vector) {
79
- this.load()[this.key(text, modelTag, kind)] = vector;
156
+ if (!isVector(vector))
157
+ return;
158
+ const k = this.key(text, modelTag, kind);
159
+ this.load()[k] = vector;
160
+ this.dirty.add(k);
80
161
  }
81
- /** Atomic persist: tmp + rename, so a crash mid-write cannot corrupt the previous cache. */
162
+ /**
163
+ * Publish the entries put since the last flush as one immutable segment (unique tmp + rename), then
164
+ * compact if the segment count reached the threshold. Nothing dirty → nothing written. Publication
165
+ * errors propagate as before; compaction errors never do.
166
+ */
82
167
  flush() {
83
- if (this.store === null)
168
+ if (this.store === null || this.dirty.size === 0)
84
169
  return;
85
- const tmp = this.file + '.tmp';
86
- fs.mkdirSync(path.dirname(this.file), { recursive: true });
87
- fs.writeFileSync(tmp, JSON.stringify(this.store));
88
- fs.renameSync(tmp, this.file);
170
+ const entries = {};
171
+ for (const k of this.dirty)
172
+ entries[k] = this.store[k];
173
+ fs.mkdirSync(this.dir, { recursive: true });
174
+ const name = segmentName();
175
+ const tmp = path.join(this.dir, `${name}.tmp`);
176
+ fs.writeFileSync(tmp, JSON.stringify(entries));
177
+ fs.renameSync(tmp, path.join(this.dir, name));
178
+ this.dirty.clear();
179
+ this.compactIfNeeded();
180
+ }
181
+ acquireLock() {
182
+ for (let attempt = 0; attempt < 2; attempt++) {
183
+ try {
184
+ fs.mkdirSync(this.lock);
185
+ fs.writeFileSync(path.join(this.lock, 'owner.json'), JSON.stringify({ pid: process.pid, ts: new Date().toISOString() }));
186
+ return true;
187
+ }
188
+ catch (e) {
189
+ if (e.code !== 'EEXIST' || !isStale(this.lock))
190
+ return false;
191
+ fs.rmSync(this.lock, { recursive: true, force: true }); // orphaned by a dead writer — break it once
192
+ }
193
+ }
194
+ return false;
195
+ }
196
+ /** Merge base + segments FROM DISK into a new base under the lock; any failure leaves the segments in place. */
197
+ compactIfNeeded() {
198
+ try {
199
+ if (listSegments(this.dir).length < exports.COMPACTION_THRESHOLD)
200
+ return;
201
+ if (!this.acquireLock())
202
+ return;
203
+ try {
204
+ const segments = listSegments(this.dir);
205
+ const merged = readValid(this.file) ?? {};
206
+ for (const s of segments)
207
+ Object.assign(merged, readValid(path.join(this.dir, s)) ?? {});
208
+ const tmp = path.join(this.dir, `base-${segmentName()}.tmp`);
209
+ fs.writeFileSync(tmp, JSON.stringify(merged));
210
+ fs.renameSync(tmp, this.file);
211
+ for (const s of segments)
212
+ fs.rmSync(path.join(this.dir, s), { force: true });
213
+ for (const f of fs.readdirSync(this.dir)) {
214
+ if (f.endsWith('.tmp') && isStale(path.join(this.dir, f)))
215
+ fs.rmSync(path.join(this.dir, f), { force: true });
216
+ }
217
+ }
218
+ finally {
219
+ fs.rmSync(this.lock, { recursive: true, force: true });
220
+ }
221
+ }
222
+ catch {
223
+ // Not compacted. Every committed segment is still there; a later flush will try again.
224
+ }
89
225
  }
90
226
  }
91
227
  exports.VectorCache = VectorCache;
@@ -18,6 +18,16 @@ export interface ApprovalStatus {
18
18
  parents: ParentApproval[];
19
19
  /** the concrete reasons this spec cannot be approved right now (empty once approved). */
20
20
  blockers: string[];
21
+ /**
22
+ * @implements A-SPEC-641 — what a RE-SEAL of this document as it stands would be refused on, by the
23
+ * gate's predicate (`approvalBlockers`): the same list as `blockers` for a draft, and for an approved
24
+ * document the reasons spec_approve, entity_renumber, entity_integrate and store recovery would refuse
25
+ * to seal it again. Measured 2026-09-14: a hand-sealed same-kind chain read `blockers: []` here while
26
+ * every re-seal refused it; 487 of 2,215 approved documents in this repository carry such a duty.
27
+ * Act-only judgements (duplicate ids, REQ acceptance substance, the OS-content cross-check that
28
+ * needs a root) are not included.
29
+ */
30
+ resealBlockers: string[];
21
31
  }
22
32
  /**
23
33
  * @implements A-SPEC-538.2