@holmes-lab/holmes-kit 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/CHANGELOG.md +55 -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 -21
  13. package/dist/holmes/cli/doctor.d.ts +56 -0
  14. package/dist/holmes/cli/doctor.js +311 -5
  15. package/dist/holmes/cli/index.js +13 -1
  16. package/dist/holmes/cli/semantic-key.d.ts +19 -0
  17. package/dist/holmes/cli/semantic-key.js +93 -0
  18. package/dist/holmes/config/config.d.ts +9 -0
  19. package/dist/holmes/config/config.js +8 -1
  20. package/dist/holmes/cpg/language-capability.d.ts +65 -0
  21. package/dist/holmes/cpg/language-capability.js +145 -0
  22. package/dist/holmes/cpg/language-parser-walk.js +179 -34
  23. package/dist/holmes/cpg/language-parser.d.ts +1 -1
  24. package/dist/holmes/governance/ledger-rechain.d.ts +12 -0
  25. package/dist/holmes/governance/ledger-rechain.js +17 -2
  26. package/dist/holmes/governance/provenance-ledger.js +21 -0
  27. package/dist/holmes/guardrail/blind-spots.js +12 -1
  28. package/dist/holmes/guardrail/impact-gate.d.ts +77 -0
  29. package/dist/holmes/guardrail/impact-gate.js +263 -0
  30. package/dist/holmes/guardrail/write-target.d.ts +38 -1
  31. package/dist/holmes/guardrail/write-target.js +41 -4
  32. package/dist/holmes/hooks/adapters/antigravity.js +12 -1
  33. package/dist/holmes/hooks/corrupt-state-run.d.ts +33 -0
  34. package/dist/holmes/hooks/corrupt-state-run.js +16 -0
  35. package/dist/holmes/hooks/pre-tool-use.d.ts +26 -0
  36. package/dist/holmes/hooks/pre-tool-use.js +203 -11
  37. package/dist/holmes/hooks/stop.d.ts +65 -0
  38. package/dist/holmes/hooks/stop.js +200 -2
  39. package/dist/holmes/mcp/elicit-approval.d.ts +32 -2
  40. package/dist/holmes/mcp/elicit-approval.js +30 -2
  41. package/dist/holmes/mcp/handlers.d.ts +88 -0
  42. package/dist/holmes/mcp/handlers.js +636 -35
  43. package/dist/holmes/mcp/maintenance-analyze.d.ts +435 -0
  44. package/dist/holmes/mcp/maintenance-analyze.js +994 -0
  45. package/dist/holmes/mcp/maintenance-evidence.d.ts +140 -0
  46. package/dist/holmes/mcp/maintenance-evidence.js +253 -0
  47. package/dist/holmes/mcp/server.js +13 -10
  48. package/dist/holmes/mcp/tool-schemas.js +71 -0
  49. package/dist/holmes/project/root.js +3 -1
  50. package/dist/holmes/review/baseline-arm.d.ts +37 -0
  51. package/dist/holmes/review/baseline-arm.js +51 -0
  52. package/dist/holmes/review/captured-stdin-guard.d.ts +8 -0
  53. package/dist/holmes/review/captured-stdin-guard.js +48 -0
  54. package/dist/holmes/review/coherence-verify.d.ts +31 -0
  55. package/dist/holmes/review/coherence-verify.js +144 -0
  56. package/dist/holmes/review/commit-text.d.ts +50 -0
  57. package/dist/holmes/review/commit-text.js +76 -0
  58. package/dist/holmes/review/confidence-calibration.d.ts +39 -0
  59. package/dist/holmes/review/confidence-calibration.js +39 -0
  60. package/dist/holmes/review/content-baseline.d.ts +38 -0
  61. package/dist/holmes/review/content-baseline.js +103 -0
  62. package/dist/holmes/review/content-verify.d.ts +20 -0
  63. package/dist/holmes/review/content-verify.js +73 -0
  64. package/dist/holmes/review/dense-retrieval.d.ts +66 -0
  65. package/dist/holmes/review/dense-retrieval.js +97 -0
  66. package/dist/holmes/review/edge-quality.d.ts +44 -0
  67. package/dist/holmes/review/edge-quality.js +117 -0
  68. package/dist/holmes/review/evaluation-metrics.d.ts +138 -0
  69. package/dist/holmes/review/evaluation-metrics.js +175 -0
  70. package/dist/holmes/review/graph-verifier.d.ts +34 -0
  71. package/dist/holmes/review/graph-verifier.js +62 -0
  72. package/dist/holmes/review/hop-ablation.d.ts +100 -0
  73. package/dist/holmes/review/hop-ablation.js +89 -0
  74. package/dist/holmes/review/manual-baseline.d.ts +209 -0
  75. package/dist/holmes/review/manual-baseline.js +2846 -0
  76. package/dist/holmes/review/oracle-gap.d.ts +32 -0
  77. package/dist/holmes/review/oracle-gap.js +102 -0
  78. package/dist/holmes/review/point-in-time-replay.d.ts +41 -0
  79. package/dist/holmes/review/point-in-time-replay.js +161 -0
  80. package/dist/holmes/review/rank-diagnosis.d.ts +43 -0
  81. package/dist/holmes/review/rank-diagnosis.js +163 -0
  82. package/dist/holmes/review/replay-calibration.d.ts +62 -0
  83. package/dist/holmes/review/replay-calibration.js +83 -0
  84. package/dist/holmes/review/replay-corpus.d.ts +135 -0
  85. package/dist/holmes/review/replay-corpus.js +210 -0
  86. package/dist/holmes/review/run-replay.d.ts +260 -0
  87. package/dist/holmes/review/run-replay.js +729 -0
  88. package/dist/holmes/review/semantic-arm.d.ts +271 -0
  89. package/dist/holmes/review/semantic-arm.js +717 -0
  90. package/dist/holmes/review/semantic-retrieval.d.ts +55 -0
  91. package/dist/holmes/review/semantic-retrieval.js +156 -0
  92. package/dist/holmes/review/spec-layer-stats.d.ts +38 -0
  93. package/dist/holmes/review/spec-layer-stats.js +52 -0
  94. package/dist/holmes/review/temporal-prior.d.ts +33 -0
  95. package/dist/holmes/review/temporal-prior.js +53 -0
  96. package/dist/holmes/review/test-runner.d.ts +15 -0
  97. package/dist/holmes/review/test-runner.js +41 -4
  98. package/dist/holmes/review/test-selection-breadth.d.ts +75 -0
  99. package/dist/holmes/review/test-selection-breadth.js +57 -0
  100. package/dist/holmes/review/traceability-benchmark.d.ts +81 -0
  101. package/dist/holmes/review/traceability-benchmark.js +135 -0
  102. package/dist/holmes/review/union-verify.d.ts +12 -0
  103. package/dist/holmes/review/union-verify.js +70 -0
  104. package/dist/holmes/rtm/graph-store.d.ts +51 -0
  105. package/dist/holmes/rtm/graph-store.js +122 -0
  106. package/dist/holmes/rtm/incremental.d.ts +25 -1
  107. package/dist/holmes/rtm/incremental.js +18 -1
  108. package/dist/holmes/rtm/localize.d.ts +28 -0
  109. package/dist/holmes/rtm/localize.js +272 -14
  110. package/dist/holmes/rtm/rtm-builder.d.ts +118 -3
  111. package/dist/holmes/rtm/rtm-builder.js +265 -28
  112. package/dist/holmes/rtm/rtm-graph.d.ts +117 -2
  113. package/dist/holmes/rtm/rtm-graph.js +194 -31
  114. package/dist/holmes/rtm/taint-benchmark.d.ts +97 -0
  115. package/dist/holmes/rtm/taint-benchmark.js +141 -0
  116. package/dist/holmes/rtm/test-scope.js +24 -1
  117. package/dist/holmes/semantic/credentials.d.ts +15 -0
  118. package/dist/holmes/semantic/credentials.js +134 -0
  119. package/dist/holmes/semantic/embedder.d.ts +44 -0
  120. package/dist/holmes/semantic/embedder.js +185 -0
  121. package/dist/holmes/semantic/hit-rerank.d.ts +4 -0
  122. package/dist/holmes/semantic/hit-rerank.js +38 -0
  123. package/dist/holmes/semantic/tier.d.ts +37 -0
  124. package/dist/holmes/semantic/tier.js +54 -0
  125. package/dist/holmes/semantic/vector-cache.d.ts +11 -0
  126. package/dist/holmes/semantic/vector-cache.js +91 -0
  127. package/dist/holmes/spec/acceptance-quality.d.ts +81 -0
  128. package/dist/holmes/spec/acceptance-quality.js +169 -0
  129. package/dist/holmes/spec/validator.js +33 -1
  130. package/dist/holmes/spec/yaml-scalar.d.ts +1 -0
  131. package/dist/holmes/spec/yaml-scalar.js +43 -0
  132. 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,23 +570,93 @@ 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
471
644
  // The in-session approval channel: ask ONLY when (a) an elicitor was injected (the server wires
472
645
  // one iff the client advertised the elicitation capability — handlers never see the server), and
473
- // (b) the kind is in the conservative allow-list. Every failure mode (throw, timeout folded to
474
- // null by the wiring, malformed answers folded to null by interpretElicitResult) returns null,
475
- // which callers treat as "the channel gave no answer" the refusal that follows is byte-identical
476
- // to the pre-elicitation one, so nothing is ever worse than before the channel existed.
646
+ // (b) the kind is in the conservative allow-list.
647
+ // @implements A-SPEC-497.1 the outcome taxonomy rides through unchanged: `answered` carries the
648
+ // human's decision, `expired` carries the timeout so the refusal can say a dialog died, and every
649
+ // other failure mode (no channel, wrong kind, a throwing elicitor) is `silent`, whose refusal
650
+ // stays byte-identical to the pre-elicitation one — nothing is ever worse than before the
651
+ // channel existed.
477
652
  const tryElicit = async (kind, target, summary) => {
478
653
  if (!opts?.elicit || !elicit_approval_1.ELICITABLE_KINDS.has(kind))
479
- return null;
654
+ return { kind: 'silent' };
480
655
  try {
481
656
  return await opts.elicit({ kind, target, summary });
482
657
  }
483
658
  catch {
484
- return null;
659
+ return { kind: 'silent' };
485
660
  }
486
661
  };
487
662
  // A granted answer becomes a synthesized Approval that rides the EXISTING seal path unchanged —
@@ -579,6 +754,42 @@ function makeRawHandlers(store, opts) {
579
754
  };
580
755
  }
581
756
  }
757
+ // @implements A-SPEC-420
758
+ // The branch above steps aside when the server's spec directory does not exist yet, so that a
759
+ // project can create its first spec. Right intent, but the gap let something else through:
760
+ // measured against the INSTALLED server from a cwd outside any project, `spec_create` with
761
+ // `root` naming an established other project wrote REQ-990 into the SERVER's directory and
762
+ // answered `{"created":"REQ-990"}`. The caller's project gained nothing and was told it had.
763
+ //
764
+ // Layout-independent on purpose: the test is not "<root>/.ax/specs equals my store" — that
765
+ // hardcodes the default layout and, as the note above records, made `--specs-dir docs/specs`
766
+ // deployments refuse their own root. It is "you named a project that already exists, and my
767
+ // store is not inside it", which is true whatever the specs directory is called.
768
+ if (typeof reqRoot === 'string' && reqRoot !== '' && derivedForCreate === null && boundSpecsRoot !== null) {
769
+ const real = (p2) => { try {
770
+ return fs.realpathSync(p2);
771
+ }
772
+ catch {
773
+ return path.resolve(p2);
774
+ } };
775
+ let askedRoot = null;
776
+ try {
777
+ askedRoot = real(projectRootOf(reqRoot));
778
+ }
779
+ catch {
780
+ askedRoot = null;
781
+ }
782
+ if (askedRoot !== null && fs.existsSync(path.join(askedRoot, '.ax'))
783
+ && !real(boundSpecsRoot).startsWith(askedRoot + path.sep)) {
784
+ return {
785
+ ok: false,
786
+ reason: `이 서버의 스펙 저장소 ${boundSpecsRoot} 는 아직 만들어지지 않았고 ${askedRoot} 안에도`
787
+ + ' 있지 않습니다 — 요청한 root 는 이미 스펙 저장소를 가진 다른 프로젝트입니다.'
788
+ + ' 여기서 만들면 파일은 호출자가 지정한 곳이 아니라 이 서버 쪽에 생깁니다.'
789
+ + ' 서버를 그 프로젝트에서 시작하거나 HOLMES_SPECS 로 그 프로젝트의 스펙 경로를 지정하십시오.',
790
+ };
791
+ }
792
+ }
582
793
  // @implements A-SPEC-174
583
794
  // Refuse what can never become valid; accept what is merely not filled in yet.
584
795
  //
@@ -917,6 +1128,7 @@ function makeRawHandlers(store, opts) {
917
1128
  // @implements A-SPEC-263.1 — asked ONLY after the existing channels failed to cover (an open
918
1129
  // door never summons a human), and only about a spec that exists (a question about a missing
919
1130
  // id helps no one — the standard refusal handles it).
1131
+ let elicitExpiredMs;
920
1132
  if (approveResolved === undefined) {
921
1133
  const target = await store.read(a.id).catch(() => null);
922
1134
  if (target) {
@@ -924,20 +1136,27 @@ function makeRawHandlers(store, opts) {
924
1136
  // The MODEL text is capped BEFORE the server markers are appended (round-2): a ~185+ char
925
1137
  // title pushed '(재봉인)' past the dialog's 200-char summary cap, dressing a re-seal (the
926
1138
  // more consequential act) as a first approval. The cap cuts the title, never the marker.
927
- const d = await tryElicit('spec-approve', a.id, `${a.id} — ${target.spec.title.slice(0, 120)}${resealing ? ' (재봉인)' : ''}`);
928
- if (d?.granted) {
929
- approveResolved = { approval: elicitApproval(d.reason), source: 'elicitation' };
1139
+ const out = await tryElicit('spec-approve', a.id, `${a.id} — ${target.spec.title.slice(0, 120)}${resealing ? ' (재봉인)' : ''}`);
1140
+ if (out.kind === 'answered' && out.decision.granted) {
1141
+ approveResolved = { approval: elicitApproval(out.decision.reason), source: 'elicitation' };
930
1142
  }
931
- else if (d) {
1143
+ else if (out.kind === 'answered') {
932
1144
  // The human ANSWERED (deny/question/decline): the answer is the message, and no queue
933
1145
  // entry is filed — a decided request is not a pending one (REQ-246 visibility).
934
- return { ok: false, reason: `spec_approve: 세션에서 거부됨 — ${d.reason ?? '(사유 없음)'}. 사유를 해소한 뒤 다시 시도하십시오.` };
1146
+ return { ok: false, reason: `spec_approve: 세션에서 거부됨 — ${out.decision.reason ?? '(사유 없음)'}. 사유를 해소한 뒤 다시 시도하십시오.` };
1147
+ }
1148
+ else if (out.kind === 'expired') {
1149
+ // @implements A-SPEC-497.1 — only the expiry earns a name: the notice LEADS the same
1150
+ // fail-closed refusal + queue path, so the semantics stay refusal+queue and only the
1151
+ // message learned to say what happened.
1152
+ elicitExpiredMs = out.waitedMs;
935
1153
  }
936
- // d === null: the channel gave no answer — fall through to the byte-identical refusal.
1154
+ // silent: the channel gave no answer — fall through to the byte-identical refusal.
937
1155
  }
938
1156
  }
939
1157
  if (approveResolved === undefined) {
940
- return { ok: false, reason: 'spec_approve requires an out-of-band HOLMES_APPROVAL that COVERS this act — a request-payload approval is not a channel, and an expired or elsewhere-scoped token does not open this door (scoped approvals need kind "spec-approve"). (fail-closed)'
1158
+ return { ok: false, reason: (elicitExpiredMs !== undefined ? (0, elicit_approval_1.expiredNotice)(elicitExpiredMs) : '')
1159
+ + 'spec_approve requires an out-of-band HOLMES_APPROVAL that COVERS this act — a request-payload approval is not a channel, and an expired or elsewhere-scoped token does not open this door (scoped approvals need kind "spec-approve"). (fail-closed)'
941
1160
  + refusalQueueHint(a.root, store, { kind: 'spec-approve', target: a.id, why: '스펙 봉인 승인' }) };
942
1161
  }
943
1162
  // @implements A-SPEC-188 — destination BEFORE seal.
@@ -1159,6 +1378,7 @@ function makeRawHandlers(store, opts) {
1159
1378
  return { approved: a.id, digest };
1160
1379
  },
1161
1380
  async spec_list(a) {
1381
+ assertSpecStoreReachable('spec_list', store, a.root); // @implements A-SPEC-419
1162
1382
  const specs = await store.list();
1163
1383
  // @implements A-SPEC-186
1164
1384
  // The parent travels with the id because a bare one cannot be read. Measured: a reader took
@@ -1215,6 +1435,15 @@ function makeRawHandlers(store, opts) {
1215
1435
  return { next: null };
1216
1436
  },
1217
1437
  async rtm_check(a) {
1438
+ // @implements A-SPEC-461 — a root that names a different project would check THIS project's
1439
+ // specs against THAT project's files and report clean — A-SPEC-191 §15. foreignRootReason
1440
+ // returns null for an absent or matching root, so the spec-only and correct-root paths are
1441
+ // unaffected. Shared guard (citation_pin/maintenance_analyze use it), not a copy (A-SPEC-449).
1442
+ {
1443
+ const foreign = foreignRootReason(store, a?.root);
1444
+ if (foreign)
1445
+ return { ok: false, reason: foreign };
1446
+ }
1218
1447
  const specs = await store.list();
1219
1448
  // rtm_check runs on the GOVERNED set only (excludes archived legacy +
1220
1449
  // JOB specs, which otherwise flood orphan/dangling results; see
@@ -1403,6 +1632,13 @@ function makeRawHandlers(store, opts) {
1403
1632
  * never "vulnerable".
1404
1633
  */
1405
1634
  async taint_scan(a) {
1635
+ // @implements A-SPEC-461 — same §15 defence as rtm_check: refuse a root that is not this
1636
+ // server's project rather than scanning it against the bound store's specs.
1637
+ {
1638
+ const foreign = foreignRootReason(store, a.root);
1639
+ if (foreign)
1640
+ return { ok: false, reason: foreign };
1641
+ }
1406
1642
  const root = projectRootOf(a.root);
1407
1643
  const scanned = cachedScan(root, root);
1408
1644
  const g = new rtm_graph_1.RtmGraph();
@@ -1450,7 +1686,7 @@ function makeRawHandlers(store, opts) {
1450
1686
  const executedByAspec = (0, test_evidence_1.computeExecutedByAspec)(result.executedByFile ?? {}, anchors);
1451
1687
  let head = '';
1452
1688
  try {
1453
- head = (0, node_child_process_1.execFileSync)('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8', env: (0, root_1.cleanSubprocessEnv)() }).trim();
1689
+ 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
1690
  }
1455
1691
  catch { /* non-git */ }
1456
1692
  // Record ONLY an actually-executed, GREEN run (review C4/C7): a red suite must not stand as
@@ -1479,6 +1715,7 @@ function makeRawHandlers(store, opts) {
1479
1715
  changeSource, ...(scopeFallback ? { scopeFallback } : {}), ...(baseline ? { baselineRecorded: baseline } : {}) };
1480
1716
  },
1481
1717
  async issue_localize(a) {
1718
+ assertSpecStoreReachable('issue_localize', store, a.root); // @implements A-SPEC-419
1482
1719
  // N1: deterministic localization report — CPG lexical match fused with the RTM spec hop.
1483
1720
  // @implements A-SPEC-189 §14 (round 13) — `projectRootOf(a.root)` was called as a bare
1484
1721
  // statement: its only effect was to throw on a bad path, and the advertised contract ("a
@@ -1488,9 +1725,270 @@ function makeRawHandlers(store, opts) {
1488
1725
  const root = projectRootOf(a.root);
1489
1726
  const scanned = cachedScan(root, root);
1490
1727
  const specs = await store.list();
1491
- return (0, localize_1.localizeIssue)(a.issue, scanned, (0, spec_types_1.filterGoverned)(specs), a.topN ?? 10);
1728
+ const governed = (0, spec_types_1.filterGoverned)(specs);
1729
+ const report = (0, localize_1.localizeIssue)(a.issue, scanned, governed, a.topN ?? 10);
1730
+ // @implements A-SPEC-496 — parity with A-SPEC-478: the SAME uncited gate, the SAME math
1731
+ // (cached vectors only, set fixed, covered hits move, why-line attached). Any missing
1732
+ // signal — no tier, no key, cold cache, embed failure — leaves the report untouched;
1733
+ // localization itself never fails because of the semantic layer.
1734
+ try {
1735
+ if (report.hits.length > 1
1736
+ && (0, localize_1.citationsIn)(a.issue, new Set(governed.map((s) => s.id))).cited.length === 0) {
1737
+ const rt = (0, embedder_1.makeTierEmbedder)((0, tier_1.resolveSemanticTier)(), new vector_cache_1.VectorCache(root));
1738
+ if (rt !== null) {
1739
+ const qv = await rt.embedQuery(a.issue);
1740
+ const docTextOf = new Map(scanned.map((f) => [f.sourcePath,
1741
+ (f.sourcePath + ' ' + f.symbols.map((sy) => sy.qualifiedName).join(' ')).slice(0, 2000)]));
1742
+ (0, hit_rerank_1.rerankHitsBySemantic)(report.hits, qv, (file) => {
1743
+ const dt = docTextOf.get(file);
1744
+ return dt !== undefined ? rt.cachedDocVector(dt) : null;
1745
+ }, rt.label);
1746
+ }
1747
+ }
1748
+ }
1749
+ catch { /* semantic layer is additive — never block the report */ }
1750
+ return report;
1751
+ },
1752
+ async maintenance_analyze(a) {
1753
+ const foreign = foreignRootReason(store, a.root);
1754
+ if (foreign)
1755
+ throw new HandlerRefusal(foreign);
1756
+ const root = projectRootOf(a.root);
1757
+ // This tool is advertised as read-only. Do not attach ScanFileCache: its normal save/prune
1758
+ // behavior mutates `.ax/cpg_cache` even though the analysis itself is observational.
1759
+ const scanner = new cpg_scanner_1.CpgScanner();
1760
+ const scanned = scanner.scan(root, root);
1761
+ const skipped = scanner.scanSkipped();
1762
+ const specs = (0, spec_types_1.filterGoverned)(await store.list());
1763
+ const anchors = (0, test_scope_1.scanTestAnchors)(root);
1764
+ const detail = basisFor(root, true);
1765
+ let head = '';
1766
+ try {
1767
+ 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();
1768
+ }
1769
+ catch { /* non-git */ }
1770
+ const supportedExtensions = new Set(cpg_scanner_1.SCANNABLE_EXTENSIONS);
1771
+ const knownCodeExtensions = new Set(['.rb', '.php', '.kt', '.kts', '.swift', '.scala', '.sh', '.ex', '.exs', '.lua', '.r']);
1772
+ const unsupportedLanguages = new Set();
1773
+ try {
1774
+ 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');
1775
+ for (const file of files) {
1776
+ const ext = path.extname(file).toLowerCase();
1777
+ if (knownCodeExtensions.has(ext) && !supportedExtensions.has(ext))
1778
+ unsupportedLanguages.add(ext);
1779
+ }
1780
+ }
1781
+ catch {
1782
+ // A non-git tree cannot be enumerated cheaply without reimplementing the scanner's ignore
1783
+ // rules. Surface the missing axis rather than claiming every language was covered.
1784
+ unsupportedLanguages.add('not-enumerated(non-git)');
1785
+ }
1786
+ const graph = new rtm_graph_1.RtmGraph();
1787
+ try {
1788
+ // @implements A-SPEC-292 — the resolution loop's own tally of what it declined to link.
1789
+ const { resolution } = (0, rtm_builder_1.buildRtm)(specs, scanned, graph);
1790
+ // @implements A-SPEC-293 — what constrains this change, not just what it touches.
1791
+ const { decisions, citations } = collectDecisions(root, scanned, specs);
1792
+ (0, rtm_builder_1.addDecisionEdges)(decisions, citations, graph);
1793
+ const common = {
1794
+ request: a.request,
1795
+ scanned,
1796
+ specs,
1797
+ graph,
1798
+ testAnchors: anchors,
1799
+ basis: {
1800
+ head,
1801
+ loadedBuild: detail.loadedBuild,
1802
+ diskBuild: detail.diskBuild ?? 'unknown',
1803
+ specFingerprint: `count:${specs.length}/fp:${detail.specs ?? 'unknown'}`,
1804
+ },
1805
+ changedFiles: a.changedFiles,
1806
+ // @implements A-SPEC-290 — the stored run record. Freshness is judged inside the analysis
1807
+ // against its own basis, so a record from another commit is reported as stale rather than
1808
+ // quietly used.
1809
+ runtimeEvidence: (0, test_evidence_1.readTestEvidence)(root),
1810
+ resolution,
1811
+ coverage: {
1812
+ scannedFiles: scanned.length,
1813
+ skippedFiles: skipped.map((entry) => entry.file),
1814
+ unsupportedLanguages: [...unsupportedLanguages],
1815
+ historyStatus: 'not-requested',
1816
+ },
1817
+ };
1818
+ // @implements A-SPEC-478 — resolve the tier and precompute the query vector at the
1819
+ // async edge; the sync analysis receives only a closure over cached vectors. Any failure
1820
+ // (no tier, no module, no key, embed error) leaves `semantic` undefined = today's behavior.
1821
+ let semantic;
1822
+ try {
1823
+ const rt = (0, embedder_1.makeTierEmbedder)((0, tier_1.resolveSemanticTier)(), new vector_cache_1.VectorCache(root));
1824
+ if (rt !== null) {
1825
+ const qv = await rt.embedQuery(a.request);
1826
+ semantic = { label: rt.label, queryVector: qv, cachedDocVector: rt.cachedDocVector };
1827
+ }
1828
+ }
1829
+ catch { /* the hot path never breaks on the semantic layer */ }
1830
+ const preliminary = (0, maintenance_analyze_1.analyzeMaintenance)({ ...common, history: [], semantic });
1831
+ const candidateFiles = preliminary.candidates.map((candidate) => candidate.file);
1832
+ const limit = Math.max(0, Math.min(100, Math.floor(a.historyLimit ?? 20)));
1833
+ const history = [];
1834
+ let historyStatus = limit === 0 || candidateFiles.length === 0 ? 'not-requested' : 'unavailable';
1835
+ if (limit > 0 && candidateFiles.length > 0) {
1836
+ try {
1837
+ 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 });
1838
+ let current = null;
1839
+ for (const line of raw.split('\n')) {
1840
+ if (line.includes('\0')) {
1841
+ const [commit, date, subject] = line.split('\0');
1842
+ current = { commit, date, subject, files: [] };
1843
+ history.push(current);
1844
+ }
1845
+ else if (current && line.trim() !== '') {
1846
+ // git C-quotes any non-ASCII pathname here (core.quotepath). Left raw, it never
1847
+ // matched a candidate file, so the rationale came back empty while historyStatus
1848
+ // still reported `available` — a silent false negative with confident provenance.
1849
+ current.files.push((0, maintenance_analyze_1.unquoteGitPath)(line.trim()));
1850
+ }
1851
+ }
1852
+ historyStatus = 'available';
1853
+ }
1854
+ catch { /* history is evidence enrichment, not a prerequisite */ }
1855
+ }
1856
+ // Preserve localization rank for context: `impacts.direct` is deliberately sorted for a
1857
+ // stable evidence contract, but sorting is not relevance. Seeding from it once selected a
1858
+ // lexically earlier vendored symbol while the first ranked hit correctly named live code.
1859
+ const directSeed = preliminary.candidates
1860
+ .flatMap((candidate) => candidate.symbols)
1861
+ .flatMap((qualifiedName) => graph.codeNodeIds(qualifiedName))[0];
1862
+ const specSeed = preliminary.candidateSpecs.find((id) => id.startsWith('A-SPEC-'));
1863
+ const seedId = directSeed ?? (specSeed ? `SPEC:${specSeed}` : undefined);
1864
+ const budget = Math.max(100, Math.min(10_000, Math.floor(a.contextBudget ?? 2_000)));
1865
+ const contextBundle = seedId
1866
+ ? new bundler_1.ContextBundler(graph, buildContentSource(specs, scanned)).getContextBundle(seedId, budget)
1867
+ : null;
1868
+ // @implements A-SPEC-378
1869
+ // Change recency over a WIDE window and ALL files — the enrichment history above is
1870
+ // deliberately narrow (candidate files, 20 commits) and cannot serve as a prior: a file the
1871
+ // lexical layer never ranked has no history there, which is precisely the file recency is
1872
+ // supposed to promote. Failure is silent and total: no prior means the previous ranking,
1873
+ // never a partial one, because a prior built from half the history would reorder on evidence
1874
+ // nobody could audit.
1875
+ const changePrior = {};
1876
+ try {
1877
+ const prior = (0, temporal_prior_1.temporalPrior)(root, 'HEAD', 300);
1878
+ for (const [file, age] of prior.recency)
1879
+ changePrior[file] = 1 + Math.pow(0.5, age / 10);
1880
+ }
1881
+ catch { /* no history, no prior — the ranking falls back to lexical evidence alone */ }
1882
+ // @implements A-SPEC-388
1883
+ // What each file's commits have SAID, normalised to 0..1. Measured on history sampled away
1884
+ // from the benchmarker's own recent work (A-SPEC-387): Top-1 0.244 -> 0.333 here and 0.400 ->
1885
+ // 0.500 on a second repository, Top-10 recall 0.393 -> 0.601 there. Precision falls on both,
1886
+ // and that is the trade, not a footnote to it.
1887
+ const commitTextBoost = {};
1888
+ try {
1889
+ const profile = (0, commit_text_1.commitTextProfile)(root, 'HEAD', 400);
1890
+ const ranked = (0, commit_text_1.rankByCommitText)(a.request, profile, 300);
1891
+ const top = ranked[0]?.score ?? 0;
1892
+ if (top > 0)
1893
+ for (const hit of ranked)
1894
+ commitTextBoost[hit.file] = hit.score / top;
1895
+ }
1896
+ catch { /* no history, no boost — the ranking falls back to lexical evidence alone */ }
1897
+ const analysis = (0, maintenance_analyze_1.analyzeMaintenance)({
1898
+ semantic,
1899
+ ...common,
1900
+ coverage: { ...common.coverage, historyStatus },
1901
+ history,
1902
+ changePrior,
1903
+ commitTextBoost,
1904
+ contextBundle,
1905
+ groundTruth: a.groundTruth,
1906
+ });
1907
+ // @implements A-SPEC-268 — persistence is OPT-IN. The tool is advertised read-only, and a
1908
+ // regression pins that a cold project gains no `.ax/cpg_cache`; writing evidence by default
1909
+ // would break that contract for every caller who only wanted to look.
1910
+ // One shape either way: `persistedTo` is present-or-undefined rather than a union, so a
1911
+ // caller never has to narrow the result to ask whether anything was written.
1912
+ const result = { ...analysis };
1913
+ // @implements A-SPEC-418 — first line, because everything below it was computed without specs.
1914
+ const blind = specStoreBlindReason(store, root, specs.length);
1915
+ if (blind !== null)
1916
+ result.unknowns = [blind, ...result.unknowns];
1917
+ if (a.persist) {
1918
+ // @implements A-SPEC-277 — record WHAT WAS ANALYSED, not just which paths. HEAD cannot see
1919
+ // an uncommitted edit to the analysed file; a content digest can.
1920
+ const fileDigests = {};
1921
+ for (const file of analysis.impacts.files) {
1922
+ const digest = fileDigestOf(root, file);
1923
+ if (digest)
1924
+ fileDigests[file] = digest;
1925
+ }
1926
+ 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));
1927
+ }
1928
+ return result;
1929
+ }
1930
+ finally {
1931
+ graph.close();
1932
+ }
1933
+ },
1934
+ // @implements A-SPEC-268
1935
+ async maintenance_outcome(a) {
1936
+ const foreign = foreignRootReason(store, a.root);
1937
+ if (foreign)
1938
+ throw new HandlerRefusal(foreign);
1939
+ const root = projectRootOf(a.root);
1940
+ return (0, maintenance_evidence_1.recordOutcome)(path.join(root, maintenance_evidence_1.EVIDENCE_DIR), a.digest, {
1941
+ actualFiles: a.actualFiles,
1942
+ actualSymbols: a.actualSymbols,
1943
+ actualTests: a.actualTests,
1944
+ actualClassification: a.actualClassification,
1945
+ }, new Date().toISOString());
1946
+ },
1947
+ // @implements A-SPEC-277
1948
+ async impact_gate_check(a) {
1949
+ const foreign = foreignRootReason(store, a.root);
1950
+ if (foreign)
1951
+ throw new HandlerRefusal(foreign);
1952
+ const root = projectRootOf(a.root);
1953
+ let head = '';
1954
+ try {
1955
+ 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();
1956
+ }
1957
+ catch { /* non-git */ }
1958
+ let artifact = null;
1959
+ if (a.digest) {
1960
+ const file = path.join(root, maintenance_evidence_1.EVIDENCE_DIR, (0, maintenance_evidence_1.digestFilename)(a.digest));
1961
+ // A digest naming an artifact this project never stored is NOT the same as no evidence at
1962
+ // all — say which one happened rather than collapsing both into "refused".
1963
+ if (!fs.existsSync(file))
1964
+ throw new HandlerRefusal(`이 프로젝트에 저장된 분석이 아닙니다: ${a.digest}`);
1965
+ artifact = JSON.parse(fs.readFileSync(file, 'utf8'));
1966
+ }
1967
+ return (0, impact_gate_1.evaluateEditEvidence)({
1968
+ target: a.target.replace(/\\/g, '/'),
1969
+ artifact,
1970
+ currentHead: head,
1971
+ currentTargetDigest: fileDigestOf(root, a.target),
1972
+ });
1973
+ },
1974
+ // @implements A-SPEC-268
1975
+ async maintenance_calibration(a) {
1976
+ const foreign = foreignRootReason(store, a.root);
1977
+ if (foreign)
1978
+ throw new HandlerRefusal(foreign);
1979
+ const root = projectRootOf(a.root);
1980
+ const read = (0, maintenance_evidence_1.readArtifacts)(path.join(root, maintenance_evidence_1.EVIDENCE_DIR));
1981
+ // Corrupt and off-schema records travel WITH the score. A calibration that quietly dropped
1982
+ // them would report a clean number over an unstated subset of the evidence.
1983
+ return {
1984
+ ...(0, maintenance_evidence_1.computeCalibration)(read.artifacts, { minSamples: a.minSamples }),
1985
+ artifacts: read.artifacts.length,
1986
+ unreadable: read.unreadable,
1987
+ skippedSchema: read.skippedSchema,
1988
+ };
1492
1989
  },
1493
1990
  async rtm_impact(a) {
1991
+ assertSpecStoreReachable('rtm_impact', store, a.root); // @implements A-SPEC-433
1494
1992
  // @implements A-SPEC-189 §14 (round 13) — `projectRootOf(a.root)` was called as a bare
1495
1993
  // statement: its only effect was to throw on a bad path, and the advertised contract ("a
1496
1994
  // subdirectory resolves up to it") was dropped on the floor. `taint_scan` one function away
@@ -1499,15 +1997,46 @@ function makeRawHandlers(store, opts) {
1499
1997
  const root = projectRootOf(a.root);
1500
1998
  const scanned = cachedScan(root);
1501
1999
  const specs = await store.list();
1502
- const g = new rtm_graph_1.RtmGraph();
2000
+ // @implements A-SPEC-283
2001
+ // Reuse the persisted graph when its basis still holds. Measured: on the warm path the graph
2002
+ // build is ~81% of the cost and reopening is ~0ms. `scanDigest` is the field that makes this
2003
+ // safe — an uncommitted edit never moves HEAD, so a commit-only basis would happily serve a
2004
+ // graph describing content the working tree no longer has.
2005
+ let head = '';
1503
2006
  try {
1504
- (0, rtm_builder_1.buildRtm)(specs, scanned, g);
2007
+ 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();
2008
+ }
2009
+ catch { /* non-git */ }
2010
+ const opened = (0, graph_store_1.openReusableGraph)(path.join(root, '.ax', 'rtm.sqlite'), {
2011
+ graphSchema: RTM_GRAPH_SCHEMA,
2012
+ extractorVersion: RTM_EXTRACTOR_VERSION,
2013
+ sourceCommit: head,
2014
+ specFingerprint: `n:${specs.length}/${specs.map((s) => `${s.id}:${s.status}`).sort().join(',')}`,
2015
+ scanDigest: (0, graph_store_1.scanDigest)(scanned),
2016
+ });
2017
+ const g = opened.graph;
2018
+ try {
2019
+ if (!opened.reusable) {
2020
+ (0, rtm_builder_1.buildRtm)(specs, scanned, g);
2021
+ opened.commitBasis();
2022
+ }
1505
2023
  // explainImpact, not impactedBy: the bounds and the breadth signal must reach the caller.
1506
2024
  // An impact set is not just a list — a broad one means "review the contract", and a consumer
1507
2025
  // that cannot tell the difference will try to bundle two hundred call sites.
1508
2026
  const { specs: impacted, reachedByDepth, stoppedAt, seedIsHub } = (0, rtm_builder_1.explainImpact)(g, a.changed);
2027
+ // @implements A-SPEC-469 — the graded FILE surface beside the spec closure, same code path
2028
+ // as the S-484 measurement (identity, not reimplementation). Seeds are the changed symbols'
2029
+ // nodes; the files that own them are excluded — a prediction naming the change itself is
2030
+ // vacuously true. The closure fields above stay exactly as they were: mass never enters a
2031
+ // gate decision.
2032
+ const riSeeds = new Map(a.changed.flatMap((qn) => g.codeNodeIds(qn)).map((id) => [id, 1]));
2033
+ const riExclude = new Set([...riSeeds.keys()]
2034
+ .map((id) => (id.includes('@') ? id.slice(id.lastIndexOf('@') + 1) : ''))
2035
+ .filter((f) => f !== ''));
2036
+ 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
2037
  return {
1510
2038
  impacted,
2039
+ rankedImpact,
1511
2040
  reachedByDepth,
1512
2041
  bounded: stoppedAt.length > 0 ? stoppedAt.slice(0, 20) : undefined,
1513
2042
  ...(seedIsHub ? {
@@ -1520,6 +2049,7 @@ function makeRawHandlers(store, opts) {
1520
2049
  }
1521
2050
  },
1522
2051
  async rtm_reindex(a) {
2052
+ assertSpecStoreReachable('rtm_reindex', store, a.root); // @implements A-SPEC-419
1523
2053
  // Removal/re-derivation of changedFiles/changedSymbols is not needed
1524
2054
  // here (rtm_reindex works off changes/specs/scanned/bySourcePath
1525
2055
  // directly), but the guard+diff+scan prologue is identical to
@@ -1566,14 +2096,33 @@ function makeRawHandlers(store, opts) {
1566
2096
  // of a FUTURE persisted-graph reindex — build the graph once, then
1567
2097
  // apply diffs across calls without a full rebuild — that will
1568
2098
  // 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 };
2099
+ // @implements A-SPEC-280 the whole scan, so call edges are re-resolved and the reindexed
2100
+ // graph equals what a full rebuild would have produced. Measured: without this, rtm_reindex
2101
+ // returned a graph with no call edges at all.
2102
+ (0, incremental_1.applyIncremental)(g, changesToApply, { repoRoot: root, specs, scanOne, allScanned: () => scanned });
2103
+ // @implements A-SPEC-478 — the semantic cache WARMING lives here, in the explicit heavy
2104
+ // operation, so the ranking hot path only ever LOOKS UP vectors. Idempotent through the
2105
+ // cache; a tier of none (the shipped default) computes nothing.
2106
+ let semanticWarm;
2107
+ try {
2108
+ const tier = (0, tier_1.resolveSemanticTier)();
2109
+ const rt = (0, embedder_1.makeTierEmbedder)(tier, new vector_cache_1.VectorCache(root));
2110
+ if (rt !== null) {
2111
+ const docTexts = scanned.map((f) => (f.sourcePath + ' ' + f.symbols.map((sy) => sy.qualifiedName).join(' ')).slice(0, 2000));
2112
+ const w = await rt.warm(docTexts);
2113
+ semanticWarm = { tier: rt.label, ...w };
2114
+ }
2115
+ }
2116
+ catch { /* warming is best-effort; reindex's own result is unaffected */ }
2117
+ return { changed, nodes: g.nodeCount(), edges: g.edgeCount(), changeSource,
2118
+ ...(semanticWarm !== undefined ? { semanticWarm } : {}) };
1571
2119
  }
1572
2120
  finally {
1573
2121
  g.close(); // release native SQLite handle even if build/apply throws
1574
2122
  }
1575
2123
  },
1576
2124
  async context_bundle(a) {
2125
+ assertSpecStoreReachable('context_bundle', store, a.root); // @implements A-SPEC-433
1577
2126
  const foreign = foreignRootReason(store, a.root);
1578
2127
  if (foreign)
1579
2128
  throw new HandlerRefusal(foreign);
@@ -1591,6 +2140,7 @@ function makeRawHandlers(store, opts) {
1591
2140
  }
1592
2141
  },
1593
2142
  async review_scope(a) {
2143
+ assertSpecStoreReachable('review_scope', store, a.root); // @implements A-SPEC-419
1594
2144
  // Guard+diff+scan+changedFiles/changedSymbols prologue: see
1595
2145
  // deriveChangedContext (shared with review_prepare/rtm_reindex).
1596
2146
  const { root, specs, scanned, changedFiles, changedSymbols, changeSource, scopeFallback, anchorImpactedSpecs, changedTestFiles, unresolvedFiles } = await deriveChangedContext(store, a.root, a, 'review_scope');
@@ -1616,6 +2166,7 @@ function makeRawHandlers(store, opts) {
1616
2166
  }
1617
2167
  },
1618
2168
  async review_prepare(a) {
2169
+ assertSpecStoreReachable('review_prepare', store, a.root); // @implements A-SPEC-419
1619
2170
  // Guard+diff+scan+changedFiles/changedSymbols prologue: see
1620
2171
  // deriveChangedContext (shared with review_scope/rtm_reindex).
1621
2172
  const { root, specs, scanned, changedFiles, changedSymbols, changeSource, scopeFallback } = await deriveChangedContext(store, a.root, a, 'review_prepare');
@@ -1687,6 +2238,10 @@ function makeRawHandlers(store, opts) {
1687
2238
  // the human's "no" must not abort a batch that never needed the question. The deny bites only
1688
2239
  // at the in-lock site, and only if the lift is ACTUALLY needed there.
1689
2240
  const elicitDenials = new Map();
2241
+ // @implements A-SPEC-497.1 — an expiry is carried as data like a denial: it bites only at the
2242
+ // in-lock refusal site, where it prefixes the standard message so the agent can tell the user
2243
+ // a session dialog died (instead of the anonymous "no approval" the old null-fold produced).
2244
+ const elicitExpiries = new Map();
1690
2245
  if (opts?.elicit) {
1691
2246
  try {
1692
2247
  const snapshot = new Map();
@@ -1706,11 +2261,13 @@ function makeRawHandlers(store, opts) {
1706
2261
  }
1707
2262
  if (resolveHandlerApproval(a.root, store, env0, { kind: 'review-resolve', target: f.id }, new Date().toISOString()) !== undefined)
1708
2263
  continue; // an open door never summons a human
1709
- const d = await tryElicit('review-resolve', f.id, `열린 치명 발견 ${f.id} 의 해소 기록`);
1710
- if (d?.granted)
1711
- elicitGrants.set(f.id, elicitApproval(d.reason));
1712
- else if (d)
1713
- elicitDenials.set(f.id, d.reason ?? '(사유 없음)');
2264
+ const out = await tryElicit('review-resolve', f.id, `열린 치명 발견 ${f.id} 의 해소 기록`);
2265
+ if (out.kind === 'answered' && out.decision.granted)
2266
+ elicitGrants.set(f.id, elicitApproval(out.decision.reason));
2267
+ else if (out.kind === 'answered')
2268
+ elicitDenials.set(f.id, out.decision.reason ?? '(사유 없음)');
2269
+ else if (out.kind === 'expired')
2270
+ elicitExpiries.set(f.id, out.waitedMs);
1714
2271
  }
1715
2272
  }
1716
2273
  catch {
@@ -1784,7 +2341,11 @@ function makeRawHandlers(store, opts) {
1784
2341
  if (denied !== undefined) {
1785
2342
  throw new HandlerRefusal(`review_record: 세션에서 거부됨 — ${denied}. 사유를 해소한 뒤 다시 기록하십시오.`);
1786
2343
  }
1787
- throw new HandlerRefusal(`review_record: id ${f.id} 의 열린 치명 발견을 해소하는 기록은 이 행위를 덮는 유효한 대역외 승인이 필요합니다 차단당한 쪽이 스스로 이빨을 뽑을 없어야 하고, 만료·다른 범위의 승인은 덮지 않습니다. HOLMES_APPROVAL='{"actor":"<you>","token":"<any>","rationale":"<why fixed>"}' (범위를 쓰면 kind "review-resolve") 를 서버 환경에 설정하고 다시 기록하십시오`
2344
+ // @implements A-SPEC-497.1an expired dialog leads the refusal by name; silence
2345
+ // keeps the pre-elicitation face.
2346
+ const expiredMs = elicitExpiries.get(f.id);
2347
+ throw new HandlerRefusal((expiredMs !== undefined ? (0, elicit_approval_1.expiredNotice)(expiredMs) : '')
2348
+ + `review_record: id ${f.id} 의 열린 치명 발견을 해소하는 기록은 이 행위를 덮는 유효한 대역외 승인이 필요합니다 — 차단당한 쪽이 스스로 이빨을 뽑을 수 없어야 하고, 만료·다른 범위의 승인은 덮지 않습니다. HOLMES_APPROVAL='{"actor":"<you>","token":"<any>","rationale":"<why fixed>"}' (범위를 쓰면 kind "review-resolve") 를 서버 환경에 설정하고 다시 기록하십시오`
1788
2349
  + refusalQueueHint(a.root, store, { kind: 'review-resolve', target: f.id, why: '열린 치명 발견의 해소 기록' }));
1789
2350
  }
1790
2351
  // Grant-file consumption is DEFERRED past the loop (round-2): consuming here burned the
@@ -2202,6 +2763,12 @@ function makeRawHandlers(store, opts) {
2202
2763
  }
2203
2764
  written.push(d);
2204
2765
  }
2766
+ // @implements A-SPEC-299
2767
+ // Deliberately NOT changed. This looked like the same false pass as spec_slice_approve, but an
2768
+ // approved store-integrity test pins `ok: true` for a run that refused every cluster because a
2769
+ // destination held an unreadable file: nothing was written, nothing was damaged, and `ok` here
2770
+ // means the call completed without harm. That is a defensible contract, and the case against
2771
+ // it was analogy rather than an observed failure. Evidence beats symmetry.
2205
2772
  return { ok: true, dryRun, parentReqId: req.id, clusters: report.clusters.length, drafted: written, skipped, refused };
2206
2773
  },
2207
2774
  /**
@@ -2252,13 +2819,13 @@ function makeRawHandlers(store, opts) {
2252
2819
  const reqContent = `---
2253
2820
  source:
2254
2821
  - kind: user-request
2255
- ref: ${a.title}
2822
+ ref: ${(0, yaml_scalar_1.yamlScalar)(a.title, 4)}
2256
2823
  retrieved: ${new Date().toISOString().split('T')[0]}
2257
- note: ${a.objective}
2824
+ note: ${(0, yaml_scalar_1.yamlScalar)(a.objective, 4)}
2258
2825
  created: ${new Date().toISOString()}
2259
2826
  id: ${reqId}
2260
2827
  type: REQ
2261
- title: ${a.title}
2828
+ title: ${(0, yaml_scalar_1.yamlScalar)(a.title)}
2262
2829
  status: draft
2263
2830
  depends_on: []
2264
2831
  ---
@@ -2282,7 +2849,7 @@ ${a.objective}
2282
2849
  created: ${new Date().toISOString()}
2283
2850
  id: ${hspecId}
2284
2851
  type: H-SPEC
2285
- title: Functional Specification for ${a.title}
2852
+ title: ${(0, yaml_scalar_1.yamlScalar)(`Functional Specification for ${a.title}`)}
2286
2853
  status: draft
2287
2854
  req_type: functional
2288
2855
  owner: me
@@ -2319,7 +2886,7 @@ High level design for ${a.title}.
2319
2886
  created: ${new Date().toISOString()}
2320
2887
  id: ${aspecId}
2321
2888
  type: A-SPEC
2322
- title: Architecture Specification for ${a.title}
2889
+ title: ${(0, yaml_scalar_1.yamlScalar)(`Architecture Specification for ${a.title}`)}
2323
2890
  status: draft
2324
2891
  slice: ${a.sliceName}
2325
2892
  priority: P1
@@ -2360,7 +2927,7 @@ coverage:
2360
2927
  boundary: true
2361
2928
  id: ${tspecId}
2362
2929
  type: T-SPEC
2363
- title: Test Specification for ${a.title}
2930
+ title: ${(0, yaml_scalar_1.yamlScalar)(`Test Specification for ${a.title}`)}
2364
2931
  status: draft
2365
2932
  depends_on:
2366
2933
  - ${aspecId}
@@ -2394,7 +2961,16 @@ depends_on:
2394
2961
  fs.writeFileSync(path.join(specsDir, '02_h-spec', 'functional', `${hspecId}.md`), hspecContent);
2395
2962
  fs.writeFileSync(path.join(specsDir, '03_a-spec', `${aspecId}.md`), aspecContent);
2396
2963
  fs.writeFileSync(path.join(specsDir, '05_t-spec', `${tspecId}.md`), tspecContent);
2397
- return { ok: true, specsCreated: [reqId, hspecId, aspecId, tspecId] };
2964
+ // @implements A-SPEC-299
2965
+ // Read back what was just written. S-298 fixed one cause of unparseable frontmatter; this
2966
+ // closes the class. `list()` silently skips documents it cannot parse, so "created" and
2967
+ // "present in the store" can diverge without anything throwing — which is exactly how a
2968
+ // requirement went missing on 2026-08-28 while the tool reported four specs created.
2969
+ const specsCreated = [reqId, hspecId, aspecId, tspecId];
2970
+ const unreadable = unreadableAmong(await new spec_store_1.LocalMarkdownRepository(specsDir).list(), specsCreated);
2971
+ // The files stay on disk on purpose: deleting the evidence turns a diagnosable bug into a
2972
+ // mystery, and a half-written slice is something a person should see.
2973
+ return { ok: unreadable.length === 0, specsCreated, unreadable };
2398
2974
  },
2399
2975
  async spec_slice_approve(a) {
2400
2976
  const root = a.root ? (0, root_2.resolveProjectRoot)(a.root).root : process.cwd();
@@ -2428,13 +3004,32 @@ depends_on:
2428
3004
  }
2429
3005
  const rawHandlers = makeRawHandlers(rawStore);
2430
3006
  const approvedSpecs = [];
3007
+ const refused = [];
3008
+ if (idsToApprove.length === 0) {
3009
+ // Reporting this as a success is how a caller ends up believing an unapproved slice was
3010
+ // approved. Measured 2026-08-28: a broken-frontmatter REQ made the whole slice invisible
3011
+ // here, and the tool answered `ok: true, approvedSpecs: []`.
3012
+ return { ok: false, approvedSpecs, refused: [{ id: a.sliceName, reason: 'no specs matched this slice name' }] };
3013
+ }
2431
3014
  for (const id of idsToApprove) {
2432
3015
  const res = (await rawHandlers.spec_approve({ root, id }));
2433
3016
  if (res.approved || res.ok) {
2434
3017
  approvedSpecs.push(id);
3018
+ continue;
2435
3019
  }
2436
- }
2437
- return { ok: true, approvedSpecs };
3020
+ const findings = Array.isArray(res.findings)
3021
+ ? res.findings.map((f) => f.message).filter(Boolean).join('; ')
3022
+ : '';
3023
+ refused.push({ id, reason: findings ? `${res.reason ?? 'refused'}: ${findings}` : (res.reason ?? 'refused') });
3024
+ // The chain is topological: approving a child whose parent was refused would either fail
3025
+ // again or, worse, succeed against an unsealed parent. Stop and say what is left.
3026
+ break;
3027
+ }
3028
+ for (const id of idsToApprove.slice(approvedSpecs.length + refused.length)) {
3029
+ refused.push({ id, reason: 'not attempted — an earlier spec in the chain was refused' });
3030
+ }
3031
+ // `ok` means the slice is approved. Anything less is not a success.
3032
+ return { ok: refused.length === 0 && approvedSpecs.length > 0, approvedSpecs, refused };
2438
3033
  },
2439
3034
  async spec_remediate(a) {
2440
3035
  const root = a.root ? (0, root_2.resolveProjectRoot)(a.root).root : process.cwd();
@@ -2456,7 +3051,13 @@ depends_on:
2456
3051
  if (approveRes.approvedSpecs?.length > 0) {
2457
3052
  actionsTaken.push(`Approved slice specs: ${approveRes.approvedSpecs.join(', ')}`);
2458
3053
  }
2459
- return { ok: true, actionsTaken };
3054
+ // Same false-PASS as spec_slice_approve had, one layer up: this handler's whole purpose is to
3055
+ // approve, so reporting success when approval was refused sends the caller into code with an
3056
+ // unapproved slice behind it.
3057
+ const refused = approveRes.refused ?? [];
3058
+ for (const r of refused)
3059
+ actionsTaken.push(`Refused ${r.id}: ${r.reason}`);
3060
+ return { ok: refused.length === 0, actionsTaken, refused };
2460
3061
  },
2461
3062
  };
2462
3063
  }