@holmes-lab/holmes-kit 0.2.0 → 0.3.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 (134) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/.build-id +1 -1
  3. package/dist/holmes/assoc/assoc-arm.d.ts +94 -0
  4. package/dist/holmes/assoc/assoc-arm.js +187 -0
  5. package/dist/holmes/assoc/explore.d.ts +21 -0
  6. package/dist/holmes/assoc/explore.js +160 -0
  7. package/dist/holmes/assoc/impact-baseline.d.ts +13 -0
  8. package/dist/holmes/assoc/impact-baseline.js +48 -0
  9. package/dist/holmes/assoc/ppr.d.ts +64 -0
  10. package/dist/holmes/assoc/ppr.js +110 -0
  11. package/dist/holmes/cli/agents.d.ts +24 -11
  12. package/dist/holmes/cli/agents.js +93 -17
  13. package/dist/holmes/cli/codex-toml.d.ts +26 -0
  14. package/dist/holmes/cli/codex-toml.js +282 -0
  15. package/dist/holmes/cli/doctor.d.ts +56 -0
  16. package/dist/holmes/cli/doctor.js +348 -18
  17. package/dist/holmes/cli/index.js +16 -2
  18. package/dist/holmes/cli/init.js +78 -0
  19. package/dist/holmes/cli/interactive-prompt.js +4 -4
  20. package/dist/holmes/cli/mcp-launcher.d.ts +2 -2
  21. package/dist/holmes/cli/semantic-key.d.ts +19 -0
  22. package/dist/holmes/cli/semantic-key.js +93 -0
  23. package/dist/holmes/config/config.d.ts +9 -0
  24. package/dist/holmes/config/config.js +8 -1
  25. package/dist/holmes/cpg/language-capability.d.ts +65 -0
  26. package/dist/holmes/cpg/language-capability.js +145 -0
  27. package/dist/holmes/cpg/language-parser-walk.js +179 -34
  28. package/dist/holmes/cpg/language-parser.d.ts +1 -1
  29. package/dist/holmes/governance/ledger-rechain.d.ts +12 -0
  30. package/dist/holmes/governance/ledger-rechain.js +17 -2
  31. package/dist/holmes/governance/provenance-ledger.js +21 -0
  32. package/dist/holmes/guardrail/blind-spots.js +12 -1
  33. package/dist/holmes/guardrail/impact-gate.d.ts +77 -0
  34. package/dist/holmes/guardrail/impact-gate.js +263 -0
  35. package/dist/holmes/guardrail/write-target.d.ts +38 -1
  36. package/dist/holmes/guardrail/write-target.js +48 -4
  37. package/dist/holmes/hooks/adapters/antigravity.js +12 -1
  38. package/dist/holmes/hooks/corrupt-state-run.d.ts +33 -0
  39. package/dist/holmes/hooks/corrupt-state-run.js +16 -0
  40. package/dist/holmes/hooks/pre-tool-use.d.ts +26 -0
  41. package/dist/holmes/hooks/pre-tool-use.js +203 -11
  42. package/dist/holmes/hooks/stop.d.ts +65 -0
  43. package/dist/holmes/hooks/stop.js +200 -2
  44. package/dist/holmes/mcp/handlers.d.ts +88 -0
  45. package/dist/holmes/mcp/handlers.js +597 -16
  46. package/dist/holmes/mcp/maintenance-analyze.d.ts +435 -0
  47. package/dist/holmes/mcp/maintenance-analyze.js +994 -0
  48. package/dist/holmes/mcp/maintenance-evidence.d.ts +140 -0
  49. package/dist/holmes/mcp/maintenance-evidence.js +253 -0
  50. package/dist/holmes/mcp/tool-schemas.js +71 -0
  51. package/dist/holmes/project/root.js +3 -1
  52. package/dist/holmes/review/baseline-arm.d.ts +37 -0
  53. package/dist/holmes/review/baseline-arm.js +51 -0
  54. package/dist/holmes/review/captured-stdin-guard.d.ts +8 -0
  55. package/dist/holmes/review/captured-stdin-guard.js +48 -0
  56. package/dist/holmes/review/coherence-verify.d.ts +31 -0
  57. package/dist/holmes/review/coherence-verify.js +144 -0
  58. package/dist/holmes/review/commit-text.d.ts +50 -0
  59. package/dist/holmes/review/commit-text.js +76 -0
  60. package/dist/holmes/review/confidence-calibration.d.ts +39 -0
  61. package/dist/holmes/review/confidence-calibration.js +39 -0
  62. package/dist/holmes/review/content-baseline.d.ts +38 -0
  63. package/dist/holmes/review/content-baseline.js +103 -0
  64. package/dist/holmes/review/content-verify.d.ts +20 -0
  65. package/dist/holmes/review/content-verify.js +73 -0
  66. package/dist/holmes/review/dense-retrieval.d.ts +66 -0
  67. package/dist/holmes/review/dense-retrieval.js +97 -0
  68. package/dist/holmes/review/edge-quality.d.ts +44 -0
  69. package/dist/holmes/review/edge-quality.js +117 -0
  70. package/dist/holmes/review/evaluation-metrics.d.ts +138 -0
  71. package/dist/holmes/review/evaluation-metrics.js +175 -0
  72. package/dist/holmes/review/graph-verifier.d.ts +34 -0
  73. package/dist/holmes/review/graph-verifier.js +62 -0
  74. package/dist/holmes/review/hop-ablation.d.ts +100 -0
  75. package/dist/holmes/review/hop-ablation.js +89 -0
  76. package/dist/holmes/review/manual-baseline.d.ts +209 -0
  77. package/dist/holmes/review/manual-baseline.js +2846 -0
  78. package/dist/holmes/review/oracle-gap.d.ts +32 -0
  79. package/dist/holmes/review/oracle-gap.js +102 -0
  80. package/dist/holmes/review/point-in-time-replay.d.ts +41 -0
  81. package/dist/holmes/review/point-in-time-replay.js +161 -0
  82. package/dist/holmes/review/rank-diagnosis.d.ts +43 -0
  83. package/dist/holmes/review/rank-diagnosis.js +163 -0
  84. package/dist/holmes/review/replay-calibration.d.ts +62 -0
  85. package/dist/holmes/review/replay-calibration.js +83 -0
  86. package/dist/holmes/review/replay-corpus.d.ts +135 -0
  87. package/dist/holmes/review/replay-corpus.js +210 -0
  88. package/dist/holmes/review/run-replay.d.ts +260 -0
  89. package/dist/holmes/review/run-replay.js +729 -0
  90. package/dist/holmes/review/semantic-arm.d.ts +271 -0
  91. package/dist/holmes/review/semantic-arm.js +717 -0
  92. package/dist/holmes/review/semantic-retrieval.d.ts +55 -0
  93. package/dist/holmes/review/semantic-retrieval.js +156 -0
  94. package/dist/holmes/review/spec-layer-stats.d.ts +38 -0
  95. package/dist/holmes/review/spec-layer-stats.js +52 -0
  96. package/dist/holmes/review/temporal-prior.d.ts +33 -0
  97. package/dist/holmes/review/temporal-prior.js +53 -0
  98. package/dist/holmes/review/test-runner.d.ts +15 -0
  99. package/dist/holmes/review/test-runner.js +41 -4
  100. package/dist/holmes/review/test-selection-breadth.d.ts +75 -0
  101. package/dist/holmes/review/test-selection-breadth.js +57 -0
  102. package/dist/holmes/review/traceability-benchmark.d.ts +81 -0
  103. package/dist/holmes/review/traceability-benchmark.js +135 -0
  104. package/dist/holmes/review/union-verify.d.ts +12 -0
  105. package/dist/holmes/review/union-verify.js +70 -0
  106. package/dist/holmes/rtm/graph-store.d.ts +51 -0
  107. package/dist/holmes/rtm/graph-store.js +122 -0
  108. package/dist/holmes/rtm/incremental.d.ts +25 -1
  109. package/dist/holmes/rtm/incremental.js +18 -1
  110. package/dist/holmes/rtm/localize.d.ts +28 -0
  111. package/dist/holmes/rtm/localize.js +272 -14
  112. package/dist/holmes/rtm/rtm-builder.d.ts +118 -3
  113. package/dist/holmes/rtm/rtm-builder.js +265 -28
  114. package/dist/holmes/rtm/rtm-graph.d.ts +117 -2
  115. package/dist/holmes/rtm/rtm-graph.js +194 -31
  116. package/dist/holmes/rtm/taint-benchmark.d.ts +97 -0
  117. package/dist/holmes/rtm/taint-benchmark.js +141 -0
  118. package/dist/holmes/rtm/test-scope.js +24 -1
  119. package/dist/holmes/semantic/credentials.d.ts +15 -0
  120. package/dist/holmes/semantic/credentials.js +134 -0
  121. package/dist/holmes/semantic/embedder.d.ts +44 -0
  122. package/dist/holmes/semantic/embedder.js +185 -0
  123. package/dist/holmes/semantic/hit-rerank.d.ts +4 -0
  124. package/dist/holmes/semantic/hit-rerank.js +38 -0
  125. package/dist/holmes/semantic/tier.d.ts +37 -0
  126. package/dist/holmes/semantic/tier.js +54 -0
  127. package/dist/holmes/semantic/vector-cache.d.ts +11 -0
  128. package/dist/holmes/semantic/vector-cache.js +91 -0
  129. package/dist/holmes/spec/acceptance-quality.d.ts +81 -0
  130. package/dist/holmes/spec/acceptance-quality.js +169 -0
  131. package/dist/holmes/spec/validator.js +33 -1
  132. package/dist/holmes/spec/yaml-scalar.d.ts +1 -0
  133. package/dist/holmes/spec/yaml-scalar.js +43 -0
  134. package/package.json +1 -1
@@ -35,11 +35,32 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.HandlerRefusal = void 0;
37
37
  exports.isHandlerRefusal = isHandlerRefusal;
38
+ exports.unreadableAmong = unreadableAmong;
38
39
  exports.makeHandlers = makeHandlers;
40
+ // @implements A-SPEC-293
41
+ // @implements A-SPEC-292
42
+ // @implements A-SPEC-290
43
+ // @implements A-SPEC-283
44
+ // @implements A-SPEC-277
45
+ // @implements A-SPEC-269
46
+ // @implements A-SPEC-267
39
47
  const fs = __importStar(require("node:fs"));
48
+ const assoc_arm_1 = require("../assoc/assoc-arm");
49
+ // @implements A-SPEC-478 — the semantic tier runtime, resolved HERE (the async edge) and injected
50
+ // into the sync analysis; the replay harness injects nothing, keeping every pin untouched.
51
+ const tier_1 = require("../semantic/tier");
52
+ const vector_cache_1 = require("../semantic/vector-cache");
53
+ const embedder_1 = require("../semantic/embedder");
54
+ // @implements A-SPEC-496 — policy parity: the direct localization path reranks with the exact
55
+ // A-SPEC-478 math, extracted pure.
56
+ const hit_rerank_1 = require("../semantic/hit-rerank");
40
57
  const os = __importStar(require("node:os"));
41
58
  const path = __importStar(require("node:path"));
42
59
  const crypto = __importStar(require("node:crypto"));
60
+ // @implements A-SPEC-378
61
+ const temporal_prior_1 = require("../review/temporal-prior");
62
+ // @implements A-SPEC-388
63
+ const commit_text_1 = require("../review/commit-text");
43
64
  const node_child_process_1 = require("node:child_process");
44
65
  const spec_parser_1 = require("../spec/spec-parser");
45
66
  const validator_1 = require("../spec/validator");
@@ -49,6 +70,9 @@ const gap_analyzer_1 = require("../rtm/gap-analyzer");
49
70
  const test_runner_1 = require("../review/test-runner");
50
71
  const test_evidence_1 = require("../review/test-evidence");
51
72
  const localize_1 = require("../rtm/localize");
73
+ const maintenance_analyze_1 = require("./maintenance-analyze");
74
+ const maintenance_evidence_1 = require("./maintenance-evidence");
75
+ const impact_gate_1 = require("../guardrail/impact-gate");
52
76
  const phase_1 = require("../guardrail/phase");
53
77
  const spec_types_1 = require("../spec/spec-types");
54
78
  const legacy_format_1 = require("../spec/legacy-format");
@@ -74,6 +98,10 @@ const cacheDirFor = (root) => {
74
98
  return path.join(r.root, '.ax', 'cpg_cache');
75
99
  return path.join(os.tmpdir(), `holmes-cpg-cache-${crypto.createHash('sha256').update(r.root).digest('hex').slice(0, 16)}`);
76
100
  };
101
+ // @implements A-SPEC-283 — bumped whenever the graph's node/edge shape changes, so a store written
102
+ // by an older build is rebuilt rather than read with new assumptions.
103
+ const RTM_GRAPH_SCHEMA = 'rtm-graph/2';
104
+ const RTM_EXTRACTOR_VERSION = 'holmes-rtm/1';
77
105
  const cachedScan = (root, repoRoot = root) => new cpg_scanner_1.CpgScanner(undefined, new scan_cache_1.ScanFileCache(cacheDirFor(root))).scan(root, repoRoot);
78
106
  // @implements A-SPEC-131
79
107
  // Same scan, with the skip report kept: the callers that make honesty claims (cpg_scan's surface,
@@ -87,6 +115,7 @@ const rtm_builder_1 = require("../rtm/rtm-builder");
87
115
  const rtm_graph_1 = require("../rtm/rtm-graph");
88
116
  const taint_1 = require("../rtm/taint");
89
117
  const incremental_1 = require("../rtm/incremental");
118
+ const graph_store_1 = require("../rtm/graph-store");
90
119
  const hash_cache_1 = require("../cpg/hash-cache");
91
120
  const bundler_1 = require("../context/bundler");
92
121
  const scope_1 = require("../review/scope");
@@ -95,6 +124,7 @@ const coverage_1 = require("../review/coverage");
95
124
  const basis_1 = require("./basis");
96
125
  const write_target_1 = require("../guardrail/write-target");
97
126
  const spec_store_1 = require("../spec/spec-store");
127
+ const yaml_scalar_1 = require("../spec/yaml-scalar");
98
128
  const findings_1 = require("../review/findings");
99
129
  const package_1 = require("../review/package");
100
130
  const risk_classifier_1 = require("../guardrail/risk-classifier");
@@ -235,6 +265,72 @@ function foreignRootReason(store, root) {
235
265
  return `root를 해석할 수 없습니다: ${String(e.message)}`;
236
266
  }
237
267
  }
268
+ /**
269
+ * @implements A-SPEC-418
270
+ * The server binds its spec store to a RELATIVE path, so the store follows the process's cwd. Start
271
+ * it inside the project and `maintenance_analyze` reads 1199 specs; start it anywhere else and it
272
+ * reads NONE — and then answers the same request with zero candidates, no warning, a normal success
273
+ * shape, and the root's 378 files still scanned, so it reads as working. Found by installing the
274
+ * tarball and speaking JSON-RPC to the packaged server; no test in this repository saw it.
275
+ *
276
+ * `foreignRootReason` deliberately stays quiet here: it compares two projects, and when the store's
277
+ * own directory has no `.ax` marker there is nothing to compare. That judgement is right, and the
278
+ * silence it leaves is what this fills.
279
+ *
280
+ * Keyed on the BINDING, not on the count. Zero specs because a project's specs are all drafts is a
281
+ * different fact from zero because none were read, and warning on the first would fire on every
282
+ * young project.
283
+ */
284
+ function specStoreBlindReason(store, root, loaded) {
285
+ if (loaded > 0 || !(store instanceof spec_store_1.LocalMarkdownRepository))
286
+ return null;
287
+ if (!fs.existsSync(path.join(root, '.ax', 'specs')))
288
+ return null; // 스펙이 없는 프로젝트는 무고하다
289
+ // Compared through realpath: on macOS a temp root is `/var/...` while the same directory resolves
290
+ // to `/private/var/...`, and a plain string compare calls the correctly-bound store foreign.
291
+ const real = (p) => { try {
292
+ return fs.realpathSync(p);
293
+ }
294
+ catch {
295
+ return path.resolve(p);
296
+ } };
297
+ const bound = real(store.specsRoot);
298
+ if (bound === real(path.join(root, '.ax', 'specs')))
299
+ return null;
300
+ return `이 서버는 ${bound} 에 바인딩되어 스펙을 하나도 읽지 못했습니다 — 분석 대상 ${root} 에는`
301
+ + ' 스펙 저장소가 있습니다. 아래 결과는 스펙 층 없이 어휘와 그래프만으로 계산된 것이며,'
302
+ + ' 스펙 매칭에 의존하는 요청은 조용히 0개를 받습니다. 서버를 프로젝트 루트에서 시작하거나'
303
+ + ' HOLMES_SPECS 로 스펙 경로를 지정하십시오.';
304
+ }
305
+ /**
306
+ * @implements A-SPEC-419
307
+ * `phase_status` refuses when this server's spec directory cannot be resolved; the tools whose whole
308
+ * answer is about specs did not, and answered EMPTY instead. From a cwd outside the project the
309
+ * installed server returned `{"specs":[]}` for a repository holding 1199 of them, empty impacted
310
+ * specs for review, no hits for localization, and rewrote the RTM index with no spec nodes at all —
311
+ * every one a success shape. The cause is one line in `LocalMarkdownRepository.list`: its walk
312
+ * returns quietly when the directory is missing, so an ABSENT store is indistinguishable from an
313
+ * empty one.
314
+ *
315
+ * Keyed on a MISMATCH, never on a count: a project that has not run `init` yet has no specs anywhere
316
+ * and must keep getting its empty answer, or this guard becomes the next defect.
317
+ */
318
+ function assertSpecStoreReachable(tool, store, root) {
319
+ if (typeof root !== 'string' || root === '')
320
+ return;
321
+ if (!(store instanceof spec_store_1.LocalMarkdownRepository))
322
+ return;
323
+ if (fs.existsSync(store.specsRoot))
324
+ return;
325
+ if (!fs.existsSync(path.join(root, '.ax', 'specs')))
326
+ return; // 어디에도 스펙이 없으면 무고하다
327
+ // The tool name is NOT prefixed here: the raw-handler wrapper already prefixes it, and doing it
328
+ // twice produced `spec_list: spec_list: ...` in the installed server (seen in the probe).
329
+ void tool;
330
+ throw new HandlerRefusal(`이 서버가 바인딩된 스펙 디렉터리 ${store.specsRoot} 가 존재하지 않습니다 —`
331
+ + ` 요청한 root ${root} 에는 스펙 저장소가 있습니다. 빈 답을 돌려주면 '스펙이 없다'로 읽히므로`
332
+ + ' 거부합니다. 서버를 프로젝트 루트에서 시작하거나 HOLMES_SPECS 로 스펙 경로를 지정하십시오.');
333
+ }
238
334
  /**
239
335
  * A root the caller named that this process cannot read is the CALLER'S mistake, not a fault.
240
336
  * @implements A-SPEC-189 §10 (round 11)
@@ -416,6 +512,15 @@ async function deriveChangedContext(store, rootArg, a, toolName) {
416
512
  * information, and mtime would manufacture divergence on a checkout or a copy.
417
513
  */
418
514
  const LOADED_BUILD = (0, basis_1.loadedBuildId)(path.resolve(__dirname, '..', '..', '..'));
515
+ /**
516
+ * @implements A-SPEC-299
517
+ * Ids that were written but cannot be read back. The store drops documents it cannot parse, so
518
+ * absence from `list()` — not an exception — is the signal.
519
+ */
520
+ function unreadableAmong(listed, created) {
521
+ const present = new Set(listed.map((s) => s.id));
522
+ return created.filter((id) => !present.has(id));
523
+ }
419
524
  function makeHandlers(store, opts) {
420
525
  const raw = makeRawHandlers(store, opts);
421
526
  const wrapped = {};
@@ -465,6 +570,74 @@ function basisFor(root, withDisk = false) {
465
570
  }
466
571
  return (0, basis_1.collectBasis)(ctx);
467
572
  }
573
+ /**
574
+ * @implements A-SPEC-277
575
+ * sha256 of a repo-relative file as it is on disk right now, or null when it cannot be read. Null is
576
+ * a real answer — a deleted or not-yet-created target genuinely cannot be compared against what an
577
+ * analysis recorded, and the gate treats that as unverifiable rather than as unchanged.
578
+ */
579
+ function fileDigestOf(root, rel) {
580
+ try {
581
+ const resolved = path.resolve(root, rel);
582
+ // Never digest something outside the project: the path arrives from a tool caller.
583
+ if (resolved !== root && !resolved.startsWith(root + path.sep))
584
+ return null;
585
+ if (!fs.statSync(resolved).isFile())
586
+ return null;
587
+ return `sha256:${crypto.createHash('sha256').update(fs.readFileSync(resolved)).digest('hex')}`;
588
+ }
589
+ catch {
590
+ return null;
591
+ }
592
+ }
593
+ /**
594
+ * @implements A-SPEC-293
595
+ * Read `.ax/decisions/*.md` and find who cites them. Citation is the link, chosen by measurement:
596
+ * `governs` resolves 9 of 65 names to symbols while 37 source files and 71 spec files cite an ADR.
597
+ * Failure here is never fatal — a project with no decisions directory simply has no decisions.
598
+ */
599
+ function collectDecisions(root, scanned, specs) {
600
+ const dir = path.join(root, '.ax', 'decisions');
601
+ const decisions = [];
602
+ try {
603
+ for (const name of fs.readdirSync(dir).sort()) {
604
+ if (!name.endsWith('.md'))
605
+ continue;
606
+ const text = fs.readFileSync(path.join(dir, name), 'utf8');
607
+ const front = text.split('---')[1] ?? '';
608
+ const id = (front.match(/^id:\s*(\S+)/m) ?? [])[1];
609
+ if (!id)
610
+ continue;
611
+ const supersedes = (front.match(/^supersedes:\s*(.+)$/m) ?? [])[1]?.trim() ?? '';
612
+ decisions.push({
613
+ id,
614
+ title: ((front.match(/^title:\s*(.+)$/m) ?? [])[1] ?? '').trim(),
615
+ status: ((front.match(/^status:\s*(\S+)/m) ?? [])[1] ?? '').trim(),
616
+ supersedes: supersedes && supersedes !== 'null' ? supersedes : null,
617
+ });
618
+ }
619
+ }
620
+ catch {
621
+ return { decisions: [], citations: [] };
622
+ }
623
+ const ids = new Set(decisions.map((d) => d.id));
624
+ const cite = (text, from, out) => {
625
+ for (const m of text.matchAll(/\bADR-\d{3}\b/g))
626
+ if (ids.has(m[0]))
627
+ out.push({ from, adr: m[0] });
628
+ };
629
+ const citations = [];
630
+ for (const f of scanned) {
631
+ try {
632
+ cite(fs.readFileSync(path.join(root, f.sourcePath), 'utf8'), `FILE:${f.sourcePath}`, citations);
633
+ }
634
+ catch { /* unreadable now — the scan is a snapshot, the tree can move */ }
635
+ }
636
+ for (const spec of specs) {
637
+ cite(`${spec.title} ${Object.values(spec.sections ?? {}).join(' ')}`, `SPEC:${spec.id}`, citations);
638
+ }
639
+ return { decisions, citations };
640
+ }
468
641
  function makeRawHandlers(store, opts) {
469
642
  const resolver = (specs) => (id) => specs.find((s) => s.id === id) ?? null;
470
643
  // @implements A-SPEC-263.1
@@ -579,6 +752,42 @@ function makeRawHandlers(store, opts) {
579
752
  };
580
753
  }
581
754
  }
755
+ // @implements A-SPEC-420
756
+ // The branch above steps aside when the server's spec directory does not exist yet, so that a
757
+ // project can create its first spec. Right intent, but the gap let something else through:
758
+ // measured against the INSTALLED server from a cwd outside any project, `spec_create` with
759
+ // `root` naming an established other project wrote REQ-990 into the SERVER's directory and
760
+ // answered `{"created":"REQ-990"}`. The caller's project gained nothing and was told it had.
761
+ //
762
+ // Layout-independent on purpose: the test is not "<root>/.ax/specs equals my store" — that
763
+ // hardcodes the default layout and, as the note above records, made `--specs-dir docs/specs`
764
+ // deployments refuse their own root. It is "you named a project that already exists, and my
765
+ // store is not inside it", which is true whatever the specs directory is called.
766
+ if (typeof reqRoot === 'string' && reqRoot !== '' && derivedForCreate === null && boundSpecsRoot !== null) {
767
+ const real = (p2) => { try {
768
+ return fs.realpathSync(p2);
769
+ }
770
+ catch {
771
+ return path.resolve(p2);
772
+ } };
773
+ let askedRoot = null;
774
+ try {
775
+ askedRoot = real(projectRootOf(reqRoot));
776
+ }
777
+ catch {
778
+ askedRoot = null;
779
+ }
780
+ if (askedRoot !== null && fs.existsSync(path.join(askedRoot, '.ax'))
781
+ && !real(boundSpecsRoot).startsWith(askedRoot + path.sep)) {
782
+ return {
783
+ ok: false,
784
+ reason: `이 서버의 스펙 저장소 ${boundSpecsRoot} 는 아직 만들어지지 않았고 ${askedRoot} 안에도`
785
+ + ' 있지 않습니다 — 요청한 root 는 이미 스펙 저장소를 가진 다른 프로젝트입니다.'
786
+ + ' 여기서 만들면 파일은 호출자가 지정한 곳이 아니라 이 서버 쪽에 생깁니다.'
787
+ + ' 서버를 그 프로젝트에서 시작하거나 HOLMES_SPECS 로 그 프로젝트의 스펙 경로를 지정하십시오.',
788
+ };
789
+ }
790
+ }
582
791
  // @implements A-SPEC-174
583
792
  // Refuse what can never become valid; accept what is merely not filled in yet.
584
793
  //
@@ -1159,6 +1368,7 @@ function makeRawHandlers(store, opts) {
1159
1368
  return { approved: a.id, digest };
1160
1369
  },
1161
1370
  async spec_list(a) {
1371
+ assertSpecStoreReachable('spec_list', store, a.root); // @implements A-SPEC-419
1162
1372
  const specs = await store.list();
1163
1373
  // @implements A-SPEC-186
1164
1374
  // The parent travels with the id because a bare one cannot be read. Measured: a reader took
@@ -1215,6 +1425,15 @@ function makeRawHandlers(store, opts) {
1215
1425
  return { next: null };
1216
1426
  },
1217
1427
  async rtm_check(a) {
1428
+ // @implements A-SPEC-461 — a root that names a different project would check THIS project's
1429
+ // specs against THAT project's files and report clean — A-SPEC-191 §15. foreignRootReason
1430
+ // returns null for an absent or matching root, so the spec-only and correct-root paths are
1431
+ // unaffected. Shared guard (citation_pin/maintenance_analyze use it), not a copy (A-SPEC-449).
1432
+ {
1433
+ const foreign = foreignRootReason(store, a?.root);
1434
+ if (foreign)
1435
+ return { ok: false, reason: foreign };
1436
+ }
1218
1437
  const specs = await store.list();
1219
1438
  // rtm_check runs on the GOVERNED set only (excludes archived legacy +
1220
1439
  // JOB specs, which otherwise flood orphan/dangling results; see
@@ -1403,6 +1622,13 @@ function makeRawHandlers(store, opts) {
1403
1622
  * never "vulnerable".
1404
1623
  */
1405
1624
  async taint_scan(a) {
1625
+ // @implements A-SPEC-461 — same §15 defence as rtm_check: refuse a root that is not this
1626
+ // server's project rather than scanning it against the bound store's specs.
1627
+ {
1628
+ const foreign = foreignRootReason(store, a.root);
1629
+ if (foreign)
1630
+ return { ok: false, reason: foreign };
1631
+ }
1406
1632
  const root = projectRootOf(a.root);
1407
1633
  const scanned = cachedScan(root, root);
1408
1634
  const g = new rtm_graph_1.RtmGraph();
@@ -1450,7 +1676,7 @@ function makeRawHandlers(store, opts) {
1450
1676
  const executedByAspec = (0, test_evidence_1.computeExecutedByAspec)(result.executedByFile ?? {}, anchors);
1451
1677
  let head = '';
1452
1678
  try {
1453
- head = (0, node_child_process_1.execFileSync)('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8', env: (0, root_1.cleanSubprocessEnv)() }).trim();
1679
+ head = (0, node_child_process_1.execFileSync)('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8', env: (0, root_1.cleanSubprocessEnv)(), stdio: ['ignore', 'pipe', 'pipe'] }).trim();
1454
1680
  }
1455
1681
  catch { /* non-git */ }
1456
1682
  // Record ONLY an actually-executed, GREEN run (review C4/C7): a red suite must not stand as
@@ -1479,6 +1705,7 @@ function makeRawHandlers(store, opts) {
1479
1705
  changeSource, ...(scopeFallback ? { scopeFallback } : {}), ...(baseline ? { baselineRecorded: baseline } : {}) };
1480
1706
  },
1481
1707
  async issue_localize(a) {
1708
+ assertSpecStoreReachable('issue_localize', store, a.root); // @implements A-SPEC-419
1482
1709
  // N1: deterministic localization report — CPG lexical match fused with the RTM spec hop.
1483
1710
  // @implements A-SPEC-189 §14 (round 13) — `projectRootOf(a.root)` was called as a bare
1484
1711
  // statement: its only effect was to throw on a bad path, and the advertised contract ("a
@@ -1488,9 +1715,270 @@ function makeRawHandlers(store, opts) {
1488
1715
  const root = projectRootOf(a.root);
1489
1716
  const scanned = cachedScan(root, root);
1490
1717
  const specs = await store.list();
1491
- return (0, localize_1.localizeIssue)(a.issue, scanned, (0, spec_types_1.filterGoverned)(specs), a.topN ?? 10);
1718
+ const governed = (0, spec_types_1.filterGoverned)(specs);
1719
+ const report = (0, localize_1.localizeIssue)(a.issue, scanned, governed, a.topN ?? 10);
1720
+ // @implements A-SPEC-496 — parity with A-SPEC-478: the SAME uncited gate, the SAME math
1721
+ // (cached vectors only, set fixed, covered hits move, why-line attached). Any missing
1722
+ // signal — no tier, no key, cold cache, embed failure — leaves the report untouched;
1723
+ // localization itself never fails because of the semantic layer.
1724
+ try {
1725
+ if (report.hits.length > 1
1726
+ && (0, localize_1.citationsIn)(a.issue, new Set(governed.map((s) => s.id))).cited.length === 0) {
1727
+ const rt = (0, embedder_1.makeTierEmbedder)((0, tier_1.resolveSemanticTier)(), new vector_cache_1.VectorCache(root));
1728
+ if (rt !== null) {
1729
+ const qv = await rt.embedQuery(a.issue);
1730
+ const docTextOf = new Map(scanned.map((f) => [f.sourcePath,
1731
+ (f.sourcePath + ' ' + f.symbols.map((sy) => sy.qualifiedName).join(' ')).slice(0, 2000)]));
1732
+ (0, hit_rerank_1.rerankHitsBySemantic)(report.hits, qv, (file) => {
1733
+ const dt = docTextOf.get(file);
1734
+ return dt !== undefined ? rt.cachedDocVector(dt) : null;
1735
+ }, rt.label);
1736
+ }
1737
+ }
1738
+ }
1739
+ catch { /* semantic layer is additive — never block the report */ }
1740
+ return report;
1741
+ },
1742
+ async maintenance_analyze(a) {
1743
+ const foreign = foreignRootReason(store, a.root);
1744
+ if (foreign)
1745
+ throw new HandlerRefusal(foreign);
1746
+ const root = projectRootOf(a.root);
1747
+ // This tool is advertised as read-only. Do not attach ScanFileCache: its normal save/prune
1748
+ // behavior mutates `.ax/cpg_cache` even though the analysis itself is observational.
1749
+ const scanner = new cpg_scanner_1.CpgScanner();
1750
+ const scanned = scanner.scan(root, root);
1751
+ const skipped = scanner.scanSkipped();
1752
+ const specs = (0, spec_types_1.filterGoverned)(await store.list());
1753
+ const anchors = (0, test_scope_1.scanTestAnchors)(root);
1754
+ const detail = basisFor(root, true);
1755
+ let head = '';
1756
+ try {
1757
+ head = (0, node_child_process_1.execFileSync)('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8', env: (0, root_1.cleanSubprocessEnv)(), stdio: ['ignore', 'pipe', 'pipe'] }).trim();
1758
+ }
1759
+ catch { /* non-git */ }
1760
+ const supportedExtensions = new Set(cpg_scanner_1.SCANNABLE_EXTENSIONS);
1761
+ const knownCodeExtensions = new Set(['.rb', '.php', '.kt', '.kts', '.swift', '.scala', '.sh', '.ex', '.exs', '.lua', '.r']);
1762
+ const unsupportedLanguages = new Set();
1763
+ try {
1764
+ const files = (0, node_child_process_1.execFileSync)('git', ['-C', root, 'ls-files', '-z', '--cached', '--others', '--exclude-standard'], { encoding: 'utf8', env: (0, root_1.cleanSubprocessEnv)(), maxBuffer: 8 * 1024 * 1024 }).split('\0');
1765
+ for (const file of files) {
1766
+ const ext = path.extname(file).toLowerCase();
1767
+ if (knownCodeExtensions.has(ext) && !supportedExtensions.has(ext))
1768
+ unsupportedLanguages.add(ext);
1769
+ }
1770
+ }
1771
+ catch {
1772
+ // A non-git tree cannot be enumerated cheaply without reimplementing the scanner's ignore
1773
+ // rules. Surface the missing axis rather than claiming every language was covered.
1774
+ unsupportedLanguages.add('not-enumerated(non-git)');
1775
+ }
1776
+ const graph = new rtm_graph_1.RtmGraph();
1777
+ try {
1778
+ // @implements A-SPEC-292 — the resolution loop's own tally of what it declined to link.
1779
+ const { resolution } = (0, rtm_builder_1.buildRtm)(specs, scanned, graph);
1780
+ // @implements A-SPEC-293 — what constrains this change, not just what it touches.
1781
+ const { decisions, citations } = collectDecisions(root, scanned, specs);
1782
+ (0, rtm_builder_1.addDecisionEdges)(decisions, citations, graph);
1783
+ const common = {
1784
+ request: a.request,
1785
+ scanned,
1786
+ specs,
1787
+ graph,
1788
+ testAnchors: anchors,
1789
+ basis: {
1790
+ head,
1791
+ loadedBuild: detail.loadedBuild,
1792
+ diskBuild: detail.diskBuild ?? 'unknown',
1793
+ specFingerprint: `count:${specs.length}/fp:${detail.specs ?? 'unknown'}`,
1794
+ },
1795
+ changedFiles: a.changedFiles,
1796
+ // @implements A-SPEC-290 — the stored run record. Freshness is judged inside the analysis
1797
+ // against its own basis, so a record from another commit is reported as stale rather than
1798
+ // quietly used.
1799
+ runtimeEvidence: (0, test_evidence_1.readTestEvidence)(root),
1800
+ resolution,
1801
+ coverage: {
1802
+ scannedFiles: scanned.length,
1803
+ skippedFiles: skipped.map((entry) => entry.file),
1804
+ unsupportedLanguages: [...unsupportedLanguages],
1805
+ historyStatus: 'not-requested',
1806
+ },
1807
+ };
1808
+ // @implements A-SPEC-478 — resolve the tier and precompute the query vector at the
1809
+ // async edge; the sync analysis receives only a closure over cached vectors. Any failure
1810
+ // (no tier, no module, no key, embed error) leaves `semantic` undefined = today's behavior.
1811
+ let semantic;
1812
+ try {
1813
+ const rt = (0, embedder_1.makeTierEmbedder)((0, tier_1.resolveSemanticTier)(), new vector_cache_1.VectorCache(root));
1814
+ if (rt !== null) {
1815
+ const qv = await rt.embedQuery(a.request);
1816
+ semantic = { label: rt.label, queryVector: qv, cachedDocVector: rt.cachedDocVector };
1817
+ }
1818
+ }
1819
+ catch { /* the hot path never breaks on the semantic layer */ }
1820
+ const preliminary = (0, maintenance_analyze_1.analyzeMaintenance)({ ...common, history: [], semantic });
1821
+ const candidateFiles = preliminary.candidates.map((candidate) => candidate.file);
1822
+ const limit = Math.max(0, Math.min(100, Math.floor(a.historyLimit ?? 20)));
1823
+ const history = [];
1824
+ let historyStatus = limit === 0 || candidateFiles.length === 0 ? 'not-requested' : 'unavailable';
1825
+ if (limit > 0 && candidateFiles.length > 0) {
1826
+ try {
1827
+ const raw = (0, node_child_process_1.execFileSync)('git', ['-C', root, 'log', `-${limit}`, '--date=short', '--format=%H%x00%ad%x00%s', '--name-only', '--', ...candidateFiles], { encoding: 'utf8', env: (0, root_1.cleanSubprocessEnv)(), maxBuffer: 2 * 1024 * 1024 });
1828
+ let current = null;
1829
+ for (const line of raw.split('\n')) {
1830
+ if (line.includes('\0')) {
1831
+ const [commit, date, subject] = line.split('\0');
1832
+ current = { commit, date, subject, files: [] };
1833
+ history.push(current);
1834
+ }
1835
+ else if (current && line.trim() !== '') {
1836
+ // git C-quotes any non-ASCII pathname here (core.quotepath). Left raw, it never
1837
+ // matched a candidate file, so the rationale came back empty while historyStatus
1838
+ // still reported `available` — a silent false negative with confident provenance.
1839
+ current.files.push((0, maintenance_analyze_1.unquoteGitPath)(line.trim()));
1840
+ }
1841
+ }
1842
+ historyStatus = 'available';
1843
+ }
1844
+ catch { /* history is evidence enrichment, not a prerequisite */ }
1845
+ }
1846
+ // Preserve localization rank for context: `impacts.direct` is deliberately sorted for a
1847
+ // stable evidence contract, but sorting is not relevance. Seeding from it once selected a
1848
+ // lexically earlier vendored symbol while the first ranked hit correctly named live code.
1849
+ const directSeed = preliminary.candidates
1850
+ .flatMap((candidate) => candidate.symbols)
1851
+ .flatMap((qualifiedName) => graph.codeNodeIds(qualifiedName))[0];
1852
+ const specSeed = preliminary.candidateSpecs.find((id) => id.startsWith('A-SPEC-'));
1853
+ const seedId = directSeed ?? (specSeed ? `SPEC:${specSeed}` : undefined);
1854
+ const budget = Math.max(100, Math.min(10_000, Math.floor(a.contextBudget ?? 2_000)));
1855
+ const contextBundle = seedId
1856
+ ? new bundler_1.ContextBundler(graph, buildContentSource(specs, scanned)).getContextBundle(seedId, budget)
1857
+ : null;
1858
+ // @implements A-SPEC-378
1859
+ // Change recency over a WIDE window and ALL files — the enrichment history above is
1860
+ // deliberately narrow (candidate files, 20 commits) and cannot serve as a prior: a file the
1861
+ // lexical layer never ranked has no history there, which is precisely the file recency is
1862
+ // supposed to promote. Failure is silent and total: no prior means the previous ranking,
1863
+ // never a partial one, because a prior built from half the history would reorder on evidence
1864
+ // nobody could audit.
1865
+ const changePrior = {};
1866
+ try {
1867
+ const prior = (0, temporal_prior_1.temporalPrior)(root, 'HEAD', 300);
1868
+ for (const [file, age] of prior.recency)
1869
+ changePrior[file] = 1 + Math.pow(0.5, age / 10);
1870
+ }
1871
+ catch { /* no history, no prior — the ranking falls back to lexical evidence alone */ }
1872
+ // @implements A-SPEC-388
1873
+ // What each file's commits have SAID, normalised to 0..1. Measured on history sampled away
1874
+ // from the benchmarker's own recent work (A-SPEC-387): Top-1 0.244 -> 0.333 here and 0.400 ->
1875
+ // 0.500 on a second repository, Top-10 recall 0.393 -> 0.601 there. Precision falls on both,
1876
+ // and that is the trade, not a footnote to it.
1877
+ const commitTextBoost = {};
1878
+ try {
1879
+ const profile = (0, commit_text_1.commitTextProfile)(root, 'HEAD', 400);
1880
+ const ranked = (0, commit_text_1.rankByCommitText)(a.request, profile, 300);
1881
+ const top = ranked[0]?.score ?? 0;
1882
+ if (top > 0)
1883
+ for (const hit of ranked)
1884
+ commitTextBoost[hit.file] = hit.score / top;
1885
+ }
1886
+ catch { /* no history, no boost — the ranking falls back to lexical evidence alone */ }
1887
+ const analysis = (0, maintenance_analyze_1.analyzeMaintenance)({
1888
+ semantic,
1889
+ ...common,
1890
+ coverage: { ...common.coverage, historyStatus },
1891
+ history,
1892
+ changePrior,
1893
+ commitTextBoost,
1894
+ contextBundle,
1895
+ groundTruth: a.groundTruth,
1896
+ });
1897
+ // @implements A-SPEC-268 — persistence is OPT-IN. The tool is advertised read-only, and a
1898
+ // regression pins that a cold project gains no `.ax/cpg_cache`; writing evidence by default
1899
+ // would break that contract for every caller who only wanted to look.
1900
+ // One shape either way: `persistedTo` is present-or-undefined rather than a union, so a
1901
+ // caller never has to narrow the result to ask whether anything was written.
1902
+ const result = { ...analysis };
1903
+ // @implements A-SPEC-418 — first line, because everything below it was computed without specs.
1904
+ const blind = specStoreBlindReason(store, root, specs.length);
1905
+ if (blind !== null)
1906
+ result.unknowns = [blind, ...result.unknowns];
1907
+ if (a.persist) {
1908
+ // @implements A-SPEC-277 — record WHAT WAS ANALYSED, not just which paths. HEAD cannot see
1909
+ // an uncommitted edit to the analysed file; a content digest can.
1910
+ const fileDigests = {};
1911
+ for (const file of analysis.impacts.files) {
1912
+ const digest = fileDigestOf(root, file);
1913
+ if (digest)
1914
+ fileDigests[file] = digest;
1915
+ }
1916
+ result.persistedTo = (0, maintenance_evidence_1.writeArtifact)(path.join(root, maintenance_evidence_1.EVIDENCE_DIR), (0, maintenance_evidence_1.artifactFrom)(analysis, new Date().toISOString(), fileDigests));
1917
+ }
1918
+ return result;
1919
+ }
1920
+ finally {
1921
+ graph.close();
1922
+ }
1923
+ },
1924
+ // @implements A-SPEC-268
1925
+ async maintenance_outcome(a) {
1926
+ const foreign = foreignRootReason(store, a.root);
1927
+ if (foreign)
1928
+ throw new HandlerRefusal(foreign);
1929
+ const root = projectRootOf(a.root);
1930
+ return (0, maintenance_evidence_1.recordOutcome)(path.join(root, maintenance_evidence_1.EVIDENCE_DIR), a.digest, {
1931
+ actualFiles: a.actualFiles,
1932
+ actualSymbols: a.actualSymbols,
1933
+ actualTests: a.actualTests,
1934
+ actualClassification: a.actualClassification,
1935
+ }, new Date().toISOString());
1936
+ },
1937
+ // @implements A-SPEC-277
1938
+ async impact_gate_check(a) {
1939
+ const foreign = foreignRootReason(store, a.root);
1940
+ if (foreign)
1941
+ throw new HandlerRefusal(foreign);
1942
+ const root = projectRootOf(a.root);
1943
+ let head = '';
1944
+ try {
1945
+ head = (0, node_child_process_1.execFileSync)('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8', env: (0, root_1.cleanSubprocessEnv)(), stdio: ['ignore', 'pipe', 'pipe'] }).trim();
1946
+ }
1947
+ catch { /* non-git */ }
1948
+ let artifact = null;
1949
+ if (a.digest) {
1950
+ const file = path.join(root, maintenance_evidence_1.EVIDENCE_DIR, (0, maintenance_evidence_1.digestFilename)(a.digest));
1951
+ // A digest naming an artifact this project never stored is NOT the same as no evidence at
1952
+ // all — say which one happened rather than collapsing both into "refused".
1953
+ if (!fs.existsSync(file))
1954
+ throw new HandlerRefusal(`이 프로젝트에 저장된 분석이 아닙니다: ${a.digest}`);
1955
+ artifact = JSON.parse(fs.readFileSync(file, 'utf8'));
1956
+ }
1957
+ return (0, impact_gate_1.evaluateEditEvidence)({
1958
+ target: a.target.replace(/\\/g, '/'),
1959
+ artifact,
1960
+ currentHead: head,
1961
+ currentTargetDigest: fileDigestOf(root, a.target),
1962
+ });
1963
+ },
1964
+ // @implements A-SPEC-268
1965
+ async maintenance_calibration(a) {
1966
+ const foreign = foreignRootReason(store, a.root);
1967
+ if (foreign)
1968
+ throw new HandlerRefusal(foreign);
1969
+ const root = projectRootOf(a.root);
1970
+ const read = (0, maintenance_evidence_1.readArtifacts)(path.join(root, maintenance_evidence_1.EVIDENCE_DIR));
1971
+ // Corrupt and off-schema records travel WITH the score. A calibration that quietly dropped
1972
+ // them would report a clean number over an unstated subset of the evidence.
1973
+ return {
1974
+ ...(0, maintenance_evidence_1.computeCalibration)(read.artifacts, { minSamples: a.minSamples }),
1975
+ artifacts: read.artifacts.length,
1976
+ unreadable: read.unreadable,
1977
+ skippedSchema: read.skippedSchema,
1978
+ };
1492
1979
  },
1493
1980
  async rtm_impact(a) {
1981
+ assertSpecStoreReachable('rtm_impact', store, a.root); // @implements A-SPEC-433
1494
1982
  // @implements A-SPEC-189 §14 (round 13) — `projectRootOf(a.root)` was called as a bare
1495
1983
  // statement: its only effect was to throw on a bad path, and the advertised contract ("a
1496
1984
  // subdirectory resolves up to it") was dropped on the floor. `taint_scan` one function away
@@ -1499,15 +1987,46 @@ function makeRawHandlers(store, opts) {
1499
1987
  const root = projectRootOf(a.root);
1500
1988
  const scanned = cachedScan(root);
1501
1989
  const specs = await store.list();
1502
- const g = new rtm_graph_1.RtmGraph();
1990
+ // @implements A-SPEC-283
1991
+ // Reuse the persisted graph when its basis still holds. Measured: on the warm path the graph
1992
+ // build is ~81% of the cost and reopening is ~0ms. `scanDigest` is the field that makes this
1993
+ // safe — an uncommitted edit never moves HEAD, so a commit-only basis would happily serve a
1994
+ // graph describing content the working tree no longer has.
1995
+ let head = '';
1503
1996
  try {
1504
- (0, rtm_builder_1.buildRtm)(specs, scanned, g);
1997
+ head = (0, node_child_process_1.execFileSync)('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8', env: (0, root_1.cleanSubprocessEnv)(), stdio: ['ignore', 'pipe', 'pipe'] }).trim();
1998
+ }
1999
+ catch { /* non-git */ }
2000
+ const opened = (0, graph_store_1.openReusableGraph)(path.join(root, '.ax', 'rtm.sqlite'), {
2001
+ graphSchema: RTM_GRAPH_SCHEMA,
2002
+ extractorVersion: RTM_EXTRACTOR_VERSION,
2003
+ sourceCommit: head,
2004
+ specFingerprint: `n:${specs.length}/${specs.map((s) => `${s.id}:${s.status}`).sort().join(',')}`,
2005
+ scanDigest: (0, graph_store_1.scanDigest)(scanned),
2006
+ });
2007
+ const g = opened.graph;
2008
+ try {
2009
+ if (!opened.reusable) {
2010
+ (0, rtm_builder_1.buildRtm)(specs, scanned, g);
2011
+ opened.commitBasis();
2012
+ }
1505
2013
  // explainImpact, not impactedBy: the bounds and the breadth signal must reach the caller.
1506
2014
  // An impact set is not just a list — a broad one means "review the contract", and a consumer
1507
2015
  // that cannot tell the difference will try to bundle two hundred call sites.
1508
2016
  const { specs: impacted, reachedByDepth, stoppedAt, seedIsHub } = (0, rtm_builder_1.explainImpact)(g, a.changed);
2017
+ // @implements A-SPEC-469 — the graded FILE surface beside the spec closure, same code path
2018
+ // as the S-484 measurement (identity, not reimplementation). Seeds are the changed symbols'
2019
+ // nodes; the files that own them are excluded — a prediction naming the change itself is
2020
+ // vacuously true. The closure fields above stay exactly as they were: mass never enters a
2021
+ // gate decision.
2022
+ const riSeeds = new Map(a.changed.flatMap((qn) => g.codeNodeIds(qn)).map((id) => [id, 1]));
2023
+ const riExclude = new Set([...riSeeds.keys()]
2024
+ .map((id) => (id.includes('@') ? id.slice(id.lastIndexOf('@') + 1) : ''))
2025
+ .filter((f) => f !== ''));
2026
+ const rankedImpact = (0, assoc_arm_1.pprImpactRanked)((0, assoc_arm_1.graphViewOf)(g.dumpCanonical()), riSeeds, riExclude, assoc_arm_1.RANKED_IMPACT_K, assoc_arm_1.RANKED_IMPACT_CONFIG);
1509
2027
  return {
1510
2028
  impacted,
2029
+ rankedImpact,
1511
2030
  reachedByDepth,
1512
2031
  bounded: stoppedAt.length > 0 ? stoppedAt.slice(0, 20) : undefined,
1513
2032
  ...(seedIsHub ? {
@@ -1520,6 +2039,7 @@ function makeRawHandlers(store, opts) {
1520
2039
  }
1521
2040
  },
1522
2041
  async rtm_reindex(a) {
2042
+ assertSpecStoreReachable('rtm_reindex', store, a.root); // @implements A-SPEC-419
1523
2043
  // Removal/re-derivation of changedFiles/changedSymbols is not needed
1524
2044
  // here (rtm_reindex works off changes/specs/scanned/bySourcePath
1525
2045
  // directly), but the guard+diff+scan prologue is identical to
@@ -1566,14 +2086,33 @@ function makeRawHandlers(store, opts) {
1566
2086
  // of a FUTURE persisted-graph reindex — build the graph once, then
1567
2087
  // apply diffs across calls without a full rebuild — that will
1568
2088
  // actually depend on this path being correct. Not load-bearing today.
1569
- (0, incremental_1.applyIncremental)(g, changesToApply, { repoRoot: root, specs, scanOne });
1570
- return { changed, nodes: g.nodeCount(), edges: g.edgeCount(), changeSource };
2089
+ // @implements A-SPEC-280 the whole scan, so call edges are re-resolved and the reindexed
2090
+ // graph equals what a full rebuild would have produced. Measured: without this, rtm_reindex
2091
+ // returned a graph with no call edges at all.
2092
+ (0, incremental_1.applyIncremental)(g, changesToApply, { repoRoot: root, specs, scanOne, allScanned: () => scanned });
2093
+ // @implements A-SPEC-478 — the semantic cache WARMING lives here, in the explicit heavy
2094
+ // operation, so the ranking hot path only ever LOOKS UP vectors. Idempotent through the
2095
+ // cache; a tier of none (the shipped default) computes nothing.
2096
+ let semanticWarm;
2097
+ try {
2098
+ const tier = (0, tier_1.resolveSemanticTier)();
2099
+ const rt = (0, embedder_1.makeTierEmbedder)(tier, new vector_cache_1.VectorCache(root));
2100
+ if (rt !== null) {
2101
+ const docTexts = scanned.map((f) => (f.sourcePath + ' ' + f.symbols.map((sy) => sy.qualifiedName).join(' ')).slice(0, 2000));
2102
+ const w = await rt.warm(docTexts);
2103
+ semanticWarm = { tier: rt.label, ...w };
2104
+ }
2105
+ }
2106
+ catch { /* warming is best-effort; reindex's own result is unaffected */ }
2107
+ return { changed, nodes: g.nodeCount(), edges: g.edgeCount(), changeSource,
2108
+ ...(semanticWarm !== undefined ? { semanticWarm } : {}) };
1571
2109
  }
1572
2110
  finally {
1573
2111
  g.close(); // release native SQLite handle even if build/apply throws
1574
2112
  }
1575
2113
  },
1576
2114
  async context_bundle(a) {
2115
+ assertSpecStoreReachable('context_bundle', store, a.root); // @implements A-SPEC-433
1577
2116
  const foreign = foreignRootReason(store, a.root);
1578
2117
  if (foreign)
1579
2118
  throw new HandlerRefusal(foreign);
@@ -1591,6 +2130,7 @@ function makeRawHandlers(store, opts) {
1591
2130
  }
1592
2131
  },
1593
2132
  async review_scope(a) {
2133
+ assertSpecStoreReachable('review_scope', store, a.root); // @implements A-SPEC-419
1594
2134
  // Guard+diff+scan+changedFiles/changedSymbols prologue: see
1595
2135
  // deriveChangedContext (shared with review_prepare/rtm_reindex).
1596
2136
  const { root, specs, scanned, changedFiles, changedSymbols, changeSource, scopeFallback, anchorImpactedSpecs, changedTestFiles, unresolvedFiles } = await deriveChangedContext(store, a.root, a, 'review_scope');
@@ -1616,6 +2156,7 @@ function makeRawHandlers(store, opts) {
1616
2156
  }
1617
2157
  },
1618
2158
  async review_prepare(a) {
2159
+ assertSpecStoreReachable('review_prepare', store, a.root); // @implements A-SPEC-419
1619
2160
  // Guard+diff+scan+changedFiles/changedSymbols prologue: see
1620
2161
  // deriveChangedContext (shared with review_scope/rtm_reindex).
1621
2162
  const { root, specs, scanned, changedFiles, changedSymbols, changeSource, scopeFallback } = await deriveChangedContext(store, a.root, a, 'review_prepare');
@@ -2202,6 +2743,12 @@ function makeRawHandlers(store, opts) {
2202
2743
  }
2203
2744
  written.push(d);
2204
2745
  }
2746
+ // @implements A-SPEC-299
2747
+ // Deliberately NOT changed. This looked like the same false pass as spec_slice_approve, but an
2748
+ // approved store-integrity test pins `ok: true` for a run that refused every cluster because a
2749
+ // destination held an unreadable file: nothing was written, nothing was damaged, and `ok` here
2750
+ // means the call completed without harm. That is a defensible contract, and the case against
2751
+ // it was analogy rather than an observed failure. Evidence beats symmetry.
2205
2752
  return { ok: true, dryRun, parentReqId: req.id, clusters: report.clusters.length, drafted: written, skipped, refused };
2206
2753
  },
2207
2754
  /**
@@ -2252,13 +2799,13 @@ function makeRawHandlers(store, opts) {
2252
2799
  const reqContent = `---
2253
2800
  source:
2254
2801
  - kind: user-request
2255
- ref: ${a.title}
2802
+ ref: ${(0, yaml_scalar_1.yamlScalar)(a.title, 4)}
2256
2803
  retrieved: ${new Date().toISOString().split('T')[0]}
2257
- note: ${a.objective}
2804
+ note: ${(0, yaml_scalar_1.yamlScalar)(a.objective, 4)}
2258
2805
  created: ${new Date().toISOString()}
2259
2806
  id: ${reqId}
2260
2807
  type: REQ
2261
- title: ${a.title}
2808
+ title: ${(0, yaml_scalar_1.yamlScalar)(a.title)}
2262
2809
  status: draft
2263
2810
  depends_on: []
2264
2811
  ---
@@ -2282,7 +2829,7 @@ ${a.objective}
2282
2829
  created: ${new Date().toISOString()}
2283
2830
  id: ${hspecId}
2284
2831
  type: H-SPEC
2285
- title: Functional Specification for ${a.title}
2832
+ title: ${(0, yaml_scalar_1.yamlScalar)(`Functional Specification for ${a.title}`)}
2286
2833
  status: draft
2287
2834
  req_type: functional
2288
2835
  owner: me
@@ -2319,7 +2866,7 @@ High level design for ${a.title}.
2319
2866
  created: ${new Date().toISOString()}
2320
2867
  id: ${aspecId}
2321
2868
  type: A-SPEC
2322
- title: Architecture Specification for ${a.title}
2869
+ title: ${(0, yaml_scalar_1.yamlScalar)(`Architecture Specification for ${a.title}`)}
2323
2870
  status: draft
2324
2871
  slice: ${a.sliceName}
2325
2872
  priority: P1
@@ -2360,7 +2907,7 @@ coverage:
2360
2907
  boundary: true
2361
2908
  id: ${tspecId}
2362
2909
  type: T-SPEC
2363
- title: Test Specification for ${a.title}
2910
+ title: ${(0, yaml_scalar_1.yamlScalar)(`Test Specification for ${a.title}`)}
2364
2911
  status: draft
2365
2912
  depends_on:
2366
2913
  - ${aspecId}
@@ -2394,7 +2941,16 @@ depends_on:
2394
2941
  fs.writeFileSync(path.join(specsDir, '02_h-spec', 'functional', `${hspecId}.md`), hspecContent);
2395
2942
  fs.writeFileSync(path.join(specsDir, '03_a-spec', `${aspecId}.md`), aspecContent);
2396
2943
  fs.writeFileSync(path.join(specsDir, '05_t-spec', `${tspecId}.md`), tspecContent);
2397
- return { ok: true, specsCreated: [reqId, hspecId, aspecId, tspecId] };
2944
+ // @implements A-SPEC-299
2945
+ // Read back what was just written. S-298 fixed one cause of unparseable frontmatter; this
2946
+ // closes the class. `list()` silently skips documents it cannot parse, so "created" and
2947
+ // "present in the store" can diverge without anything throwing — which is exactly how a
2948
+ // requirement went missing on 2026-08-28 while the tool reported four specs created.
2949
+ const specsCreated = [reqId, hspecId, aspecId, tspecId];
2950
+ const unreadable = unreadableAmong(await new spec_store_1.LocalMarkdownRepository(specsDir).list(), specsCreated);
2951
+ // The files stay on disk on purpose: deleting the evidence turns a diagnosable bug into a
2952
+ // mystery, and a half-written slice is something a person should see.
2953
+ return { ok: unreadable.length === 0, specsCreated, unreadable };
2398
2954
  },
2399
2955
  async spec_slice_approve(a) {
2400
2956
  const root = a.root ? (0, root_2.resolveProjectRoot)(a.root).root : process.cwd();
@@ -2428,13 +2984,32 @@ depends_on:
2428
2984
  }
2429
2985
  const rawHandlers = makeRawHandlers(rawStore);
2430
2986
  const approvedSpecs = [];
2987
+ const refused = [];
2988
+ if (idsToApprove.length === 0) {
2989
+ // Reporting this as a success is how a caller ends up believing an unapproved slice was
2990
+ // approved. Measured 2026-08-28: a broken-frontmatter REQ made the whole slice invisible
2991
+ // here, and the tool answered `ok: true, approvedSpecs: []`.
2992
+ return { ok: false, approvedSpecs, refused: [{ id: a.sliceName, reason: 'no specs matched this slice name' }] };
2993
+ }
2431
2994
  for (const id of idsToApprove) {
2432
2995
  const res = (await rawHandlers.spec_approve({ root, id }));
2433
2996
  if (res.approved || res.ok) {
2434
2997
  approvedSpecs.push(id);
2998
+ continue;
2435
2999
  }
2436
- }
2437
- return { ok: true, approvedSpecs };
3000
+ const findings = Array.isArray(res.findings)
3001
+ ? res.findings.map((f) => f.message).filter(Boolean).join('; ')
3002
+ : '';
3003
+ refused.push({ id, reason: findings ? `${res.reason ?? 'refused'}: ${findings}` : (res.reason ?? 'refused') });
3004
+ // The chain is topological: approving a child whose parent was refused would either fail
3005
+ // again or, worse, succeed against an unsealed parent. Stop and say what is left.
3006
+ break;
3007
+ }
3008
+ for (const id of idsToApprove.slice(approvedSpecs.length + refused.length)) {
3009
+ refused.push({ id, reason: 'not attempted — an earlier spec in the chain was refused' });
3010
+ }
3011
+ // `ok` means the slice is approved. Anything less is not a success.
3012
+ return { ok: refused.length === 0 && approvedSpecs.length > 0, approvedSpecs, refused };
2438
3013
  },
2439
3014
  async spec_remediate(a) {
2440
3015
  const root = a.root ? (0, root_2.resolveProjectRoot)(a.root).root : process.cwd();
@@ -2456,7 +3031,13 @@ depends_on:
2456
3031
  if (approveRes.approvedSpecs?.length > 0) {
2457
3032
  actionsTaken.push(`Approved slice specs: ${approveRes.approvedSpecs.join(', ')}`);
2458
3033
  }
2459
- return { ok: true, actionsTaken };
3034
+ // Same false-PASS as spec_slice_approve had, one layer up: this handler's whole purpose is to
3035
+ // approve, so reporting success when approval was refused sends the caller into code with an
3036
+ // unapproved slice behind it.
3037
+ const refused = approveRes.refused ?? [];
3038
+ for (const r of refused)
3039
+ actionsTaken.push(`Refused ${r.id}: ${r.reason}`);
3040
+ return { ok: refused.length === 0, actionsTaken, refused };
2460
3041
  },
2461
3042
  };
2462
3043
  }