@holmes-lab/holmes-kit 0.19.5 → 0.20.0

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 (128) hide show
  1. package/CHANGELOG.md +135 -0
  2. package/README.md +17 -1
  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/gitignore-merge.d.ts +0 -5
  13. package/dist/holmes/cli/gitignore-merge.js +6 -2
  14. package/dist/holmes/cli/index.js +176 -139
  15. package/dist/holmes/cli/playbook-skills.d.ts +2 -22
  16. package/dist/holmes/cli/playbook-skills.js +10 -54
  17. package/dist/holmes/governance/approval-grants.d.ts +22 -0
  18. package/dist/holmes/governance/approval-grants.js +55 -1
  19. package/dist/holmes/governance/approval-queue.d.ts +43 -4
  20. package/dist/holmes/governance/approval-queue.js +86 -12
  21. package/dist/holmes/governance/display-id-aliases.d.ts +29 -0
  22. package/dist/holmes/governance/display-id-aliases.js +86 -0
  23. package/dist/holmes/governance/ledger-store.d.ts +42 -14
  24. package/dist/holmes/governance/ledger-store.js +49 -3
  25. package/dist/holmes/governance/ledger-timeline.d.ts +3 -1
  26. package/dist/holmes/governance/ledger-timeline.js +11 -1
  27. package/dist/holmes/governance/provenance-chain.d.ts +2 -0
  28. package/dist/holmes/governance/provenance-chain.js +13 -2
  29. package/dist/holmes/governance/provenance-ledger.d.ts +28 -0
  30. package/dist/holmes/governance/provenance-ledger.js +69 -16
  31. package/dist/holmes/governance/session-context.d.ts +3 -0
  32. package/dist/holmes/governance/session-context.js +26 -8
  33. package/dist/holmes/guardrail/risk-classifier.js +2 -2
  34. package/dist/holmes/guardrail/write-target.js +2 -1
  35. package/dist/holmes/hooks/pre-tool-use.js +12 -1
  36. package/dist/holmes/hooks/stop.js +14 -3
  37. package/dist/holmes/mcp/handlers/adoption.d.ts +118 -0
  38. package/dist/holmes/mcp/handlers/adoption.js +186 -0
  39. package/dist/holmes/mcp/handlers/entity-integration.d.ts +100 -0
  40. package/dist/holmes/mcp/handlers/entity-integration.js +324 -0
  41. package/dist/holmes/mcp/handlers/entity-renumber.d.ts +137 -0
  42. package/dist/holmes/mcp/handlers/entity-renumber.js +228 -0
  43. package/dist/holmes/mcp/handlers/entity-store.d.ts +78 -0
  44. package/dist/holmes/mcp/handlers/entity-store.js +212 -0
  45. package/dist/holmes/mcp/handlers/graph-operations.d.ts +90 -0
  46. package/dist/holmes/mcp/handlers/graph-operations.js +260 -0
  47. package/dist/holmes/mcp/handlers/maintenance-analysis.d.ts +34 -0
  48. package/dist/holmes/mcp/handlers/maintenance-analysis.js +288 -0
  49. package/dist/holmes/mcp/handlers/maintenance-evidence.d.ts +40 -0
  50. package/dist/holmes/mcp/handlers/maintenance-evidence.js +104 -0
  51. package/dist/holmes/mcp/handlers/operator-inspection.d.ts +167 -0
  52. package/dist/holmes/mcp/handlers/operator-inspection.js +284 -0
  53. package/dist/holmes/mcp/handlers/phase-queries.d.ts +34 -0
  54. package/dist/holmes/mcp/handlers/phase-queries.js +86 -0
  55. package/dist/holmes/mcp/handlers/review-evidence.d.ts +47 -0
  56. package/dist/holmes/mcp/handlers/review-evidence.js +370 -0
  57. package/dist/holmes/mcp/handlers/review-queries.d.ts +68 -0
  58. package/dist/holmes/mcp/handlers/review-queries.js +79 -0
  59. package/dist/holmes/mcp/handlers/risk-assessment.d.ts +25 -0
  60. package/dist/holmes/mcp/handlers/risk-assessment.js +181 -0
  61. package/dist/holmes/mcp/handlers/slice-orchestration.d.ts +55 -0
  62. package/dist/holmes/mcp/handlers/slice-orchestration.js +324 -0
  63. package/dist/holmes/mcp/handlers/spec-approval.d.ts +105 -0
  64. package/dist/holmes/mcp/handlers/spec-approval.js +504 -0
  65. package/dist/holmes/mcp/handlers/spec-authoring.d.ts +57 -0
  66. package/dist/holmes/mcp/handlers/spec-authoring.js +326 -0
  67. package/dist/holmes/mcp/handlers/spec-lifecycle.d.ts +122 -0
  68. package/dist/holmes/mcp/handlers/spec-lifecycle.js +339 -0
  69. package/dist/holmes/mcp/handlers/spec-queries.d.ts +30 -0
  70. package/dist/holmes/mcp/handlers/spec-queries.js +74 -0
  71. package/dist/holmes/mcp/handlers/taint-screening.d.ts +48 -0
  72. package/dist/holmes/mcp/handlers/taint-screening.js +183 -0
  73. package/dist/holmes/mcp/handlers/test-execution.d.ts +63 -0
  74. package/dist/holmes/mcp/handlers/test-execution.js +210 -0
  75. package/dist/holmes/mcp/handlers/workspace-identity.d.ts +30 -0
  76. package/dist/holmes/mcp/handlers/workspace-identity.js +68 -0
  77. package/dist/holmes/mcp/handlers/workspace-queries.d.ts +77 -0
  78. package/dist/holmes/mcp/handlers/workspace-queries.js +193 -0
  79. package/dist/holmes/mcp/handlers.d.ts +653 -424
  80. package/dist/holmes/mcp/handlers.js +267 -3250
  81. package/dist/holmes/mcp/server.js +2 -1
  82. package/dist/holmes/mcp/tool-schemas.js +43 -2
  83. package/dist/holmes/project/execution-context.d.ts +17 -0
  84. package/dist/holmes/project/execution-context.js +120 -0
  85. package/dist/holmes/project/installer-markers.d.ts +34 -0
  86. package/dist/holmes/project/installer-markers.js +65 -0
  87. package/dist/holmes/project/root.d.ts +3 -1
  88. package/dist/holmes/project/root.js +4 -3
  89. package/dist/holmes/project/workspace-identity.d.ts +29 -0
  90. package/dist/holmes/project/workspace-identity.js +181 -0
  91. package/dist/holmes/review/test-outcomes.d.ts +6 -1
  92. package/dist/holmes/review/test-outcomes.js +23 -5
  93. package/dist/holmes/review/test-runner.d.ts +18 -0
  94. package/dist/holmes/review/test-runner.js +132 -4
  95. package/dist/holmes/rtm/graph-store.d.ts +50 -1
  96. package/dist/holmes/rtm/graph-store.js +244 -3
  97. package/dist/holmes/rtm/incremental.d.ts +1 -0
  98. package/dist/holmes/rtm/incremental.js +12 -3
  99. package/dist/holmes/rtm/localize.js +7 -0
  100. package/dist/holmes/rtm/rtm-graph.d.ts +9 -0
  101. package/dist/holmes/rtm/rtm-graph.js +13 -0
  102. package/dist/holmes/semantic/vector-cache.d.ts +16 -1
  103. package/dist/holmes/semantic/vector-cache.js +155 -19
  104. package/dist/holmes/spec/approval-status.d.ts +10 -0
  105. package/dist/holmes/spec/approval-status.js +7 -3
  106. package/dist/holmes/spec/entity-git-snapshot.d.ts +50 -0
  107. package/dist/holmes/spec/entity-git-snapshot.js +276 -0
  108. package/dist/holmes/spec/entity-integration-discharge.d.ts +73 -0
  109. package/dist/holmes/spec/entity-integration-discharge.js +233 -0
  110. package/dist/holmes/spec/entity-integration-transaction.d.ts +179 -0
  111. package/dist/holmes/spec/entity-integration-transaction.js +1221 -0
  112. package/dist/holmes/spec/entity-integration.d.ts +207 -0
  113. package/dist/holmes/spec/entity-integration.js +747 -0
  114. package/dist/holmes/spec/entity-renumber-transaction.d.ts +82 -0
  115. package/dist/holmes/spec/entity-renumber-transaction.js +711 -0
  116. package/dist/holmes/spec/entity-renumber.d.ts +32 -0
  117. package/dist/holmes/spec/entity-renumber.js +156 -0
  118. package/dist/holmes/spec/entity-store.d.ts +135 -0
  119. package/dist/holmes/spec/entity-store.js +1051 -0
  120. package/dist/holmes/spec/entity-transaction.d.ts +89 -0
  121. package/dist/holmes/spec/entity-transaction.js +701 -0
  122. package/dist/holmes/spec/renumber.d.ts +58 -0
  123. package/dist/holmes/spec/renumber.js +200 -1
  124. package/dist/holmes/spec/spec-store.d.ts +3 -2
  125. package/dist/holmes/spec/spec-store.js +23 -2
  126. package/dist/holmes/spec/transition-policy.d.ts +36 -0
  127. package/dist/holmes/spec/transition-policy.js +135 -0
  128. package/package.json +1 -1
@@ -40,7 +40,9 @@ exports.appendOutcomes = appendOutcomes;
40
40
  exports.readOutcomes = readOutcomes;
41
41
  exports.buildOutcomeRecords = buildOutcomeRecords;
42
42
  exports.groupOutcomesByAspec = groupOutcomesByAspec;
43
- // @implements A-SPEC-534.3
43
+ // @implements A-SPEC-534.3, A-SPEC-625
44
+ // @implements A-SPEC-645
45
+ const execution_context_1 = require("../project/execution-context");
44
46
  const fs = __importStar(require("node:fs"));
45
47
  const path = __importStar(require("node:path"));
46
48
  const replica_id_1 = require("../governance/replica-id");
@@ -66,9 +68,16 @@ function isOutcomesFilename(name) {
66
68
  */
67
69
  function appendOutcomes(root, records) {
68
70
  try {
69
- const file = path.join(root, '.ax', 'ledger', outcomesFilename((0, replica_id_1.resolveReplicaId)(root)));
71
+ const prepared = records.map(r => {
72
+ const execution = (0, execution_context_1.observationExecution)(root, r);
73
+ return execution ? { ...r, execution } : r;
74
+ });
75
+ const attributed = prepared.find(r => r.execution)?.execution;
76
+ if (attributed && prepared.some(r => !r.execution || r.execution.replicaId !== attributed.replicaId))
77
+ return false;
78
+ const file = path.join(root, '.ax', 'ledger', outcomesFilename(attributed?.replicaId ?? (0, replica_id_1.resolveReplicaId)(root)));
70
79
  fs.mkdirSync(path.dirname(file), { recursive: true });
71
- fs.appendFileSync(file, records.map((r) => `${JSON.stringify(r)}\n`).join(''));
80
+ fs.appendFileSync(file, prepared.map((r) => `${JSON.stringify(r)}\n`).join(''));
72
81
  return true;
73
82
  }
74
83
  catch {
@@ -110,6 +119,11 @@ function readOutcomes(root) {
110
119
  const r = JSON.parse(s);
111
120
  if (r && typeof r === 'object' && typeof r.aspec === 'string' && typeof r.outcome === 'string'
112
121
  && typeof r.ts === 'string' && typeof r.head === 'string') {
122
+ if ('execution' in r) {
123
+ r.execution = (0, execution_context_1.validateExecution)(r.execution);
124
+ if (name !== outcomesFilename(r.execution.replicaId))
125
+ continue;
126
+ }
113
127
  out.push({ r: r, i: i++ });
114
128
  }
115
129
  }
@@ -136,12 +150,16 @@ function buildOutcomeRecords(outcomeByFile, anchors, head, ts) {
136
150
  * Group outcomes by A-SPEC, keeping only records stamped with the given baseline `head` — a stale
137
151
  * record from another commit cannot vouch for the current work (the isFresh discipline). Pure.
138
152
  */
139
- function groupOutcomesByAspec(records, head) {
153
+ function groupOutcomesByAspec(records, head,
154
+ /** @implements A-SPEC-645 — maps a record's display id to the id the entity carries now (from the ledger's renames). */
155
+ canonical) {
140
156
  const out = {};
141
157
  for (const r of records) {
142
158
  if (r.head !== head)
143
159
  continue;
144
- (out[r.aspec] ??= []).push({ outcome: r.outcome, ts: r.ts });
160
+ const execution = 'execution' in r ? (0, execution_context_1.validateExecution)(r.execution) : undefined;
161
+ const key = canonical ? canonical(r.aspec) : r.aspec; // A-SPEC-645 — evidence follows the entity
162
+ (out[key] ??= []).push({ outcome: r.outcome, ts: r.ts, ...(execution ? { execution } : {}) });
145
163
  }
146
164
  return out;
147
165
  }
@@ -106,10 +106,28 @@ export declare function jestEntry(cwd: string): string | null;
106
106
  */
107
107
  export declare function runJestOutcomes(files: string[], cwd: string): Record<string, TestOutcome>;
108
108
  /** Run the pytest half of a plan, taking execution evidence from pytest's built-in JUnit XML. */
109
+ /**
110
+ * @implements A-SPEC-647
111
+ * Per-file RED-first outcome from the same JUnit XML `parseJUnitXmlCounts` reads. Measured 2026-09-14
112
+ * in an external pytest project: a ranged test_run executed the anchored case and recorded NOTHING,
113
+ * because classification was jest-only — a Python project could never produce the red-assertion→green
114
+ * sequence ART-8 reads. `<error>` (collection/import/setup) is a red-error, `<failure>` (an assertion)
115
+ * a red-assertion, executed cases without either are green; a file with only skipped cases says nothing.
116
+ */
117
+ export declare function parseJUnitXmlOutcomes(xml: string, ext?: string): Record<string, TestOutcome>;
118
+ /**
119
+ * @implements A-SPEC-647
120
+ * The ecosystems a project's OWN test files belong to, by file-name convention, under a bounded walk
121
+ * that skips vendored trees and virtualenvs. Measured: a full-tier run with no impacted list assumed
122
+ * the jest suite on a pytest-only project, ran nothing and reported passed:false. This repository's
123
+ * Python tests live under `reference/`, so it detects jest only and its runs are unchanged.
124
+ */
125
+ export declare function detectProjectEcosystems(cwd: string): Ecosystem[];
109
126
  export declare function runPytest(files: string[], mode: TestRunPlan['mode'], cwd: string): {
110
127
  passed: boolean;
111
128
  tail: string;
112
129
  executed: Record<string, number>;
130
+ outcomes: Record<string, TestOutcome>;
113
131
  };
114
132
  /**
115
133
  * @implements A-SPEC-137.2
@@ -42,6 +42,8 @@ exports.classifyJestOutcomes = classifyJestOutcomes;
42
42
  exports.parseJUnitXmlCounts = parseJUnitXmlCounts;
43
43
  exports.jestEntry = jestEntry;
44
44
  exports.runJestOutcomes = runJestOutcomes;
45
+ exports.parseJUnitXmlOutcomes = parseJUnitXmlOutcomes;
46
+ exports.detectProjectEcosystems = detectProjectEcosystems;
45
47
  exports.runPytest = runPytest;
46
48
  exports.parseCargoTest = parseCargoTest;
47
49
  exports.runCargo = runCargo;
@@ -53,6 +55,7 @@ exports.tailOf = tailOf;
53
55
  exports.failedTestSummary = failedTestSummary;
54
56
  exports.summarizeJestJson = summarizeJestJson;
55
57
  // @implements A-SPEC-102.1
58
+ // @implements A-SPEC-647
56
59
  const node_child_process_1 = require("node:child_process");
57
60
  const fs = __importStar(require("node:fs"));
58
61
  const os = __importStar(require("node:os"));
@@ -325,6 +328,105 @@ function runJestOutcomes(files, cwd) {
325
328
  }
326
329
  /** Run the pytest half of a plan, taking execution evidence from pytest's built-in JUnit XML. */
327
330
  // @implements A-SPEC-502.1 — exported for the wiring test (a fake venv python capturing argv).
331
+ /**
332
+ * @implements A-SPEC-647
333
+ * Per-file RED-first outcome from the same JUnit XML `parseJUnitXmlCounts` reads. Measured 2026-09-14
334
+ * in an external pytest project: a ranged test_run executed the anchored case and recorded NOTHING,
335
+ * because classification was jest-only — a Python project could never produce the red-assertion→green
336
+ * sequence ART-8 reads. `<error>` (collection/import/setup) is a red-error, `<failure>` (an assertion)
337
+ * a red-assertion, executed cases without either are green; a file with only skipped cases says nothing.
338
+ */
339
+ function parseJUnitXmlOutcomes(xml, ext = 'py') {
340
+ const flags = new Map();
341
+ for (const m of xml.matchAll(/<testcase\b([^>]*?)(\/>|>([\s\S]*?)<\/testcase>)/g)) {
342
+ const attrs = m[1] ?? '';
343
+ const body = m[3] ?? '';
344
+ let file = /\bfile="([^"]*)"/.exec(attrs)?.[1];
345
+ if (!file) {
346
+ const cls = /\bclassname="([^"]*)"/.exec(attrs)?.[1];
347
+ if (!cls)
348
+ continue;
349
+ const parts = cls.split('.').filter(Boolean);
350
+ if (ext === 'py') {
351
+ while (parts.length > 1 && /^[A-Z]/.test(parts[parts.length - 1]))
352
+ parts.pop();
353
+ }
354
+ if (parts.length === 0)
355
+ continue;
356
+ file = `${parts.join('/')}.${ext}`;
357
+ }
358
+ const key = file.split('\\').join('/');
359
+ const f = flags.get(key) ?? { error: false, failure: false, executed: false };
360
+ if (/<error\b/.test(body))
361
+ f.error = true;
362
+ else if (/<failure\b/.test(body))
363
+ f.failure = true;
364
+ if (!/<skipped\b/.test(body))
365
+ f.executed = true;
366
+ flags.set(key, f);
367
+ }
368
+ const out = {};
369
+ for (const [file, f] of flags) {
370
+ if (f.error)
371
+ out[file] = 'red-error';
372
+ else if (f.failure)
373
+ out[file] = 'red-assertion';
374
+ else if (f.executed)
375
+ out[file] = 'green';
376
+ }
377
+ return out;
378
+ }
379
+ /**
380
+ * @implements A-SPEC-647
381
+ * The ecosystems a project's OWN test files belong to, by file-name convention, under a bounded walk
382
+ * that skips vendored trees and virtualenvs. Measured: a full-tier run with no impacted list assumed
383
+ * the jest suite on a pytest-only project, ran nothing and reported passed:false. This repository's
384
+ * Python tests live under `reference/`, so it detects jest only and its runs are unchanged.
385
+ */
386
+ function detectProjectEcosystems(cwd) {
387
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'dist-tools', 'build', 'vendor', 'third_party', 'reference']);
388
+ const found = new Set();
389
+ const walk = (dir, depth) => {
390
+ if (depth > 6)
391
+ return;
392
+ let entries;
393
+ try {
394
+ entries = fs.readdirSync(dir, { withFileTypes: true });
395
+ }
396
+ catch {
397
+ return;
398
+ }
399
+ if (entries.some((e) => e.isFile() && e.name === 'pyvenv.cfg'))
400
+ return; // a virtualenv is not the project
401
+ for (const e of entries) {
402
+ const name = e.name;
403
+ if (e.isDirectory()) {
404
+ if (SKIP_DIRS.has(name) || (name.startsWith('.') && name !== '.'))
405
+ continue;
406
+ if (name === '__tests__')
407
+ found.add('jest');
408
+ walk(path.join(dir, name), depth + 1);
409
+ continue;
410
+ }
411
+ if (!e.isFile())
412
+ continue;
413
+ if (/\.(test|spec)\.(ts|tsx|js|jsx|mjs|cjs|mts|cts)$/.test(name))
414
+ found.add('jest');
415
+ else if (/^(test_[^/]*|[^/]*_test)\.py$/.test(name) || name === 'conftest.py')
416
+ found.add('pytest');
417
+ else if (/_test\.go$/.test(name))
418
+ found.add('go');
419
+ else if (name === 'Cargo.toml')
420
+ found.add('rust');
421
+ else if (name === 'build.gradle' || name === 'build.gradle.kts')
422
+ found.add('java');
423
+ else if (/\.csproj$/.test(name))
424
+ found.add('dotnet');
425
+ }
426
+ };
427
+ walk(cwd, 0);
428
+ return [...found].sort();
429
+ }
328
430
  function runPytest(files, mode, cwd) {
329
431
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'holmes-pytest-'));
330
432
  const report = path.join(dir, 'report.xml');
@@ -351,14 +453,35 @@ function runPytest(files, mode, cwd) {
351
453
  return {};
352
454
  }
353
455
  };
456
+ // @implements A-SPEC-647 — per-file outcomes from the same report; in a scoped run a planned file the
457
+ // failed run never reached (no case at all — a collection error before its cases) is a red-error, so a
458
+ // RED that never ran is not mistaken for evidence of nothing.
459
+ const readOutcomes = (passed) => {
460
+ let outcomes = {};
461
+ try {
462
+ outcomes = parseJUnitXmlOutcomes(fs.readFileSync(report, 'utf8'));
463
+ }
464
+ catch {
465
+ outcomes = {};
466
+ }
467
+ if (mode === 'scoped' && !passed) {
468
+ for (const f of files) {
469
+ const key = f.split('\\').join('/');
470
+ if (path.basename(key) === 'conftest.py' || outcomes[key])
471
+ continue;
472
+ outcomes[key] = 'red-error';
473
+ }
474
+ }
475
+ return outcomes;
476
+ };
354
477
  try {
355
478
  const out = (0, node_child_process_1.execFileSync)(python, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER, env: (0, root_1.cleanTestEnv)() });
356
- return { passed: true, tail: tailOf(out), executed: read() };
479
+ return { passed: true, tail: tailOf(out), executed: read(), outcomes: readOutcomes(true) };
357
480
  }
358
481
  catch (e) {
359
482
  const err = e;
360
483
  // A red pytest run still writes the XML, so execution evidence survives — same property as jest.
361
- return { passed: false, tail: tailOf(`${err.stdout ?? ''}\n${err.stderr ?? err.message ?? ''}`), executed: read() };
484
+ return { passed: false, tail: tailOf(`${err.stdout ?? ''}\n${err.stderr ?? err.message ?? ''}`), executed: read(), outcomes: readOutcomes(false) };
362
485
  }
363
486
  finally {
364
487
  try {
@@ -650,8 +773,13 @@ function runTestScope(scope, cwd) {
650
773
  // to a caller that only sees passed=true.
651
774
  let assumedJest = false;
652
775
  if (plan.mode === 'full' && groups.size === 0) {
653
- groups.set('jest', []);
654
- assumedJest = true;
776
+ // @implements A-SPEC-647 — run what the project HAS; assume jest only when nothing is detected.
777
+ for (const eco of detectProjectEcosystems(cwd))
778
+ groups.set(eco, []);
779
+ if (groups.size === 0) {
780
+ groups.set('jest', []);
781
+ assumedJest = true;
782
+ }
655
783
  }
656
784
  if (groups.size > 0) {
657
785
  const runners = { jest: runJest, pytest: runPytest, go: runGo, rust: runCargo, java: runGradle, dotnet: runDotnet };
@@ -42,6 +42,23 @@ export interface OpenedGraph {
42
42
  reason: string | null;
43
43
  /** Record the basis for this graph's CURRENT contents. Call after building. */
44
44
  commitBasis(): void;
45
+ /**
46
+ * @implements A-SPEC-631 — finish with the graph. In `publishable` mode a non-reusable graph
47
+ * lives in a private temporary database: `publish()` closes it and replaces the live file by one
48
+ * atomic rename; `discard()` closes it and removes the temporary file. In the default in-place
49
+ * mode both simply close the handle. Exactly one of them is called, exactly once.
50
+ */
51
+ publish(): void;
52
+ discard(): void;
53
+ }
54
+ export interface ReuseOptions {
55
+ /**
56
+ * @implements A-SPEC-631 — never touch the live file: when the stored graph cannot be reused,
57
+ * hand back an EMPTY graph in a private temporary database beside it instead of clearing the live
58
+ * file in place (which let concurrent readers see zero nodes and a killed rebuild leave an empty,
59
+ * basis-less file). The caller builds into it, `commitBasis()`, then `publish()`.
60
+ */
61
+ publishable?: boolean;
45
62
  }
46
63
  /**
47
64
  * Open the graph at `dbPath`, reusing its contents only if `expected` still holds.
@@ -50,4 +67,36 @@ export interface OpenedGraph {
50
67
  * to fail: the caller always receives a usable graph, empty when the stored one could not be
51
68
  * trusted, so "the cache is broken" degrades to "this call is slower" rather than to an outage.
52
69
  */
53
- export declare function openReusableGraph(dbPath: string, expected: GraphBasis): OpenedGraph;
70
+ export declare function openReusableGraph(dbPath: string, expected: GraphBasis, opts?: ReuseOptions): OpenedGraph;
71
+ /** Age after which a `.tmp-*` / `.bak-*` sibling of the live graph is treated as abandoned by a dead publisher. */
72
+ export declare const GRAPH_STALE_MS: number;
73
+ export type PublishedGraph = {
74
+ status: 'absent' | 'unreadable' | 'empty';
75
+ graph: null;
76
+ graphAsOf?: string;
77
+ } | {
78
+ status: 'current' | 'stale' | 'unverified';
79
+ graph: RtmGraph;
80
+ sourceCommit: string | null;
81
+ head: string | null;
82
+ graphAsOf?: string;
83
+ };
84
+ /**
85
+ * Build a complete graph in a private temporary database, commit `basis`, close it, and replace
86
+ * the live file atomically. The live file is never opened for writing and never cleared; a build
87
+ * that throws leaves it untouched and removes the temporary file. Returns the published counts.
88
+ */
89
+ export declare function publishGraph(livePath: string, basis: GraphBasis, build: (graph: RtmGraph) => void): {
90
+ nodes: number;
91
+ edges: number;
92
+ };
93
+ /**
94
+ * Open the live graph WITHOUT mutating it and say what it is: absent, unreadable (open or metadata
95
+ * failed — the file is left for the next publication to replace, never deleted here), empty (a
96
+ * schema with no nodes — the state an interrupted in-place rebuild used to leave behind), or a
97
+ * served graph whose basis commit is current / stale against `head`, or unverified when either side
98
+ * is unknown. Served statuses return an OPEN handle the caller must close.
99
+ */
100
+ export declare function openPublishedGraph(livePath: string, head: string | null): PublishedGraph;
101
+ /** HEAD of the repository at `root`, or null outside Git — the only freshness input a consumer pays for (no scan). */
102
+ export declare function headCommitOf(root: string, env?: NodeJS.ProcessEnv): string | null;
@@ -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 };