@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.
- package/CHANGELOG.md +143 -0
- package/README.md +9 -2
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/approve-status.d.ts +24 -0
- package/dist/holmes/cli/approve-status.js +166 -0
- package/dist/holmes/cli/approve-watch.d.ts +6 -0
- package/dist/holmes/cli/approve-watch.js +6 -0
- package/dist/holmes/cli/approve.d.ts +27 -0
- package/dist/holmes/cli/approve.js +68 -7
- package/dist/holmes/cli/cli-execution.d.ts +2 -0
- package/dist/holmes/cli/cli-execution.js +24 -0
- package/dist/holmes/cli/doctor-check.d.ts +15 -0
- package/dist/holmes/cli/doctor-check.js +2 -0
- package/dist/holmes/cli/doctor.d.ts +2 -8
- package/dist/holmes/cli/doctor.js +12 -3
- package/dist/holmes/cli/gitignore-merge.d.ts +0 -5
- package/dist/holmes/cli/gitignore-merge.js +6 -2
- package/dist/holmes/cli/index.js +176 -139
- package/dist/holmes/cli/npx-cache-check.d.ts +16 -0
- package/dist/holmes/cli/npx-cache-check.js +130 -0
- package/dist/holmes/cli/playbook-skills.d.ts +2 -22
- package/dist/holmes/cli/playbook-skills.js +10 -54
- package/dist/holmes/cli/probe-process.d.ts +17 -2
- package/dist/holmes/cli/probe-process.js +44 -11
- package/dist/holmes/governance/approval-grants.d.ts +22 -0
- package/dist/holmes/governance/approval-grants.js +55 -1
- package/dist/holmes/governance/approval-queue.d.ts +43 -4
- package/dist/holmes/governance/approval-queue.js +91 -13
- package/dist/holmes/governance/display-id-aliases.d.ts +29 -0
- package/dist/holmes/governance/display-id-aliases.js +86 -0
- package/dist/holmes/governance/ledger-store.d.ts +42 -14
- package/dist/holmes/governance/ledger-store.js +49 -3
- package/dist/holmes/governance/ledger-timeline.d.ts +3 -1
- package/dist/holmes/governance/ledger-timeline.js +11 -1
- package/dist/holmes/governance/provenance-chain.d.ts +2 -0
- package/dist/holmes/governance/provenance-chain.js +13 -2
- package/dist/holmes/governance/provenance-ledger.d.ts +28 -0
- package/dist/holmes/governance/provenance-ledger.js +69 -16
- package/dist/holmes/governance/session-context.d.ts +3 -0
- package/dist/holmes/governance/session-context.js +26 -8
- package/dist/holmes/guardrail/risk-classifier.js +2 -2
- package/dist/holmes/guardrail/write-target.js +2 -1
- package/dist/holmes/hooks/pre-tool-use.js +12 -1
- package/dist/holmes/hooks/stop.js +14 -3
- package/dist/holmes/mcp/handlers/adoption.d.ts +118 -0
- package/dist/holmes/mcp/handlers/adoption.js +186 -0
- package/dist/holmes/mcp/handlers/entity-integration.d.ts +100 -0
- package/dist/holmes/mcp/handlers/entity-integration.js +324 -0
- package/dist/holmes/mcp/handlers/entity-renumber.d.ts +137 -0
- package/dist/holmes/mcp/handlers/entity-renumber.js +228 -0
- package/dist/holmes/mcp/handlers/entity-store.d.ts +78 -0
- package/dist/holmes/mcp/handlers/entity-store.js +212 -0
- package/dist/holmes/mcp/handlers/graph-operations.d.ts +90 -0
- package/dist/holmes/mcp/handlers/graph-operations.js +260 -0
- package/dist/holmes/mcp/handlers/maintenance-analysis.d.ts +34 -0
- package/dist/holmes/mcp/handlers/maintenance-analysis.js +288 -0
- package/dist/holmes/mcp/handlers/maintenance-evidence.d.ts +40 -0
- package/dist/holmes/mcp/handlers/maintenance-evidence.js +104 -0
- package/dist/holmes/mcp/handlers/operator-inspection.d.ts +167 -0
- package/dist/holmes/mcp/handlers/operator-inspection.js +284 -0
- package/dist/holmes/mcp/handlers/phase-queries.d.ts +34 -0
- package/dist/holmes/mcp/handlers/phase-queries.js +86 -0
- package/dist/holmes/mcp/handlers/review-evidence.d.ts +47 -0
- package/dist/holmes/mcp/handlers/review-evidence.js +370 -0
- package/dist/holmes/mcp/handlers/review-queries.d.ts +68 -0
- package/dist/holmes/mcp/handlers/review-queries.js +79 -0
- package/dist/holmes/mcp/handlers/risk-assessment.d.ts +25 -0
- package/dist/holmes/mcp/handlers/risk-assessment.js +181 -0
- package/dist/holmes/mcp/handlers/slice-orchestration.d.ts +72 -0
- package/dist/holmes/mcp/handlers/slice-orchestration.js +373 -0
- package/dist/holmes/mcp/handlers/spec-approval.d.ts +105 -0
- package/dist/holmes/mcp/handlers/spec-approval.js +504 -0
- package/dist/holmes/mcp/handlers/spec-authoring.d.ts +57 -0
- package/dist/holmes/mcp/handlers/spec-authoring.js +326 -0
- package/dist/holmes/mcp/handlers/spec-lifecycle.d.ts +122 -0
- package/dist/holmes/mcp/handlers/spec-lifecycle.js +339 -0
- package/dist/holmes/mcp/handlers/spec-queries.d.ts +30 -0
- package/dist/holmes/mcp/handlers/spec-queries.js +74 -0
- package/dist/holmes/mcp/handlers/taint-screening.d.ts +48 -0
- package/dist/holmes/mcp/handlers/taint-screening.js +183 -0
- package/dist/holmes/mcp/handlers/test-execution.d.ts +63 -0
- package/dist/holmes/mcp/handlers/test-execution.js +210 -0
- package/dist/holmes/mcp/handlers/workspace-identity.d.ts +30 -0
- package/dist/holmes/mcp/handlers/workspace-identity.js +68 -0
- package/dist/holmes/mcp/handlers/workspace-queries.d.ts +77 -0
- package/dist/holmes/mcp/handlers/workspace-queries.js +193 -0
- package/dist/holmes/mcp/handlers.d.ts +661 -424
- package/dist/holmes/mcp/handlers.js +270 -3251
- package/dist/holmes/mcp/server-specs-dir.d.ts +2 -0
- package/dist/holmes/mcp/server-specs-dir.js +71 -0
- package/dist/holmes/mcp/server.js +12 -2
- package/dist/holmes/mcp/tool-schemas.js +43 -2
- package/dist/holmes/project/execution-context.d.ts +17 -0
- package/dist/holmes/project/execution-context.js +121 -0
- package/dist/holmes/project/installer-markers.d.ts +34 -0
- package/dist/holmes/project/installer-markers.js +65 -0
- package/dist/holmes/project/root.d.ts +12 -1
- package/dist/holmes/project/root.js +22 -3
- package/dist/holmes/project/workspace-identity.d.ts +29 -0
- package/dist/holmes/project/workspace-identity.js +181 -0
- package/dist/holmes/review/test-outcomes.d.ts +6 -1
- package/dist/holmes/review/test-outcomes.js +23 -5
- package/dist/holmes/review/test-runner.d.ts +18 -0
- package/dist/holmes/review/test-runner.js +136 -5
- package/dist/holmes/rtm/graph-store.d.ts +50 -1
- package/dist/holmes/rtm/graph-store.js +244 -3
- package/dist/holmes/rtm/incremental.d.ts +1 -0
- package/dist/holmes/rtm/incremental.js +12 -3
- package/dist/holmes/rtm/localize.js +7 -0
- package/dist/holmes/rtm/rtm-graph.d.ts +9 -0
- package/dist/holmes/rtm/rtm-graph.js +13 -0
- package/dist/holmes/rtm/test-scope.js +4 -1
- package/dist/holmes/semantic/vector-cache.d.ts +16 -1
- package/dist/holmes/semantic/vector-cache.js +155 -19
- package/dist/holmes/spec/approval-status.d.ts +10 -0
- package/dist/holmes/spec/approval-status.js +7 -3
- package/dist/holmes/spec/entity-git-snapshot.d.ts +50 -0
- package/dist/holmes/spec/entity-git-snapshot.js +276 -0
- package/dist/holmes/spec/entity-integration-discharge.d.ts +73 -0
- package/dist/holmes/spec/entity-integration-discharge.js +233 -0
- package/dist/holmes/spec/entity-integration-transaction.d.ts +179 -0
- package/dist/holmes/spec/entity-integration-transaction.js +1221 -0
- package/dist/holmes/spec/entity-integration.d.ts +216 -0
- package/dist/holmes/spec/entity-integration.js +760 -0
- package/dist/holmes/spec/entity-renumber-transaction.d.ts +82 -0
- package/dist/holmes/spec/entity-renumber-transaction.js +711 -0
- package/dist/holmes/spec/entity-renumber.d.ts +32 -0
- package/dist/holmes/spec/entity-renumber.js +156 -0
- package/dist/holmes/spec/entity-store.d.ts +135 -0
- package/dist/holmes/spec/entity-store.js +1053 -0
- package/dist/holmes/spec/entity-transaction.d.ts +105 -0
- package/dist/holmes/spec/entity-transaction.js +741 -0
- package/dist/holmes/spec/renumber.d.ts +58 -0
- package/dist/holmes/spec/renumber.js +200 -1
- package/dist/holmes/spec/spec-store.d.ts +3 -2
- package/dist/holmes/spec/spec-store.js +23 -2
- package/dist/holmes/spec/transition-policy.d.ts +36 -0
- package/dist/holmes/spec/transition-policy.js +135 -0
- 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
|
}
|
|
@@ -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)(
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
fs.
|
|
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
|