@holmes-lab/holmes-kit 0.1.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 (107) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE +21 -0
  3. package/README.md +102 -0
  4. package/bin/holmes-hook-antigravity.js +31 -0
  5. package/bin/holmes-kit.js +23 -0
  6. package/bin/holmes-mcp.js +34 -0
  7. package/bin/holmes-stop-antigravity.js +29 -0
  8. package/dist/.build-id +1 -0
  9. package/dist/holmes/cli/agents.js +168 -0
  10. package/dist/holmes/cli/doctor.js +625 -0
  11. package/dist/holmes/cli/gitignore-merge.js +84 -0
  12. package/dist/holmes/cli/governed-precondition.js +157 -0
  13. package/dist/holmes/cli/index.js +384 -0
  14. package/dist/holmes/cli/init.js +462 -0
  15. package/dist/holmes/cli/playbook-skills.js +711 -0
  16. package/dist/holmes/cli/roles-readme.js +134 -0
  17. package/dist/holmes/cli/settings-merge.js +122 -0
  18. package/dist/holmes/config/config.js +70 -0
  19. package/dist/holmes/context/bundler.js +114 -0
  20. package/dist/holmes/context/render.js +29 -0
  21. package/dist/holmes/context/tiers.js +110 -0
  22. package/dist/holmes/context/tokens.js +8 -0
  23. package/dist/holmes/cpg/cpg-scanner.js +213 -0
  24. package/dist/holmes/cpg/hash-cache.js +86 -0
  25. package/dist/holmes/cpg/language-parser-walk.js +917 -0
  26. package/dist/holmes/cpg/language-parser-worker.js +81 -0
  27. package/dist/holmes/cpg/language-parser.js +234 -0
  28. package/dist/holmes/cpg/scan-cache.js +108 -0
  29. package/dist/holmes/cpg/source-path.js +44 -0
  30. package/dist/holmes/cpg/test-files.js +84 -0
  31. package/dist/holmes/governance/constitution-debt.js +73 -0
  32. package/dist/holmes/governance/constitution-report.js +25 -0
  33. package/dist/holmes/governance/constitution.js +129 -0
  34. package/dist/holmes/governance/identity.js +30 -0
  35. package/dist/holmes/governance/ledger-lock.js +165 -0
  36. package/dist/holmes/governance/ledger-store.conformance.js +90 -0
  37. package/dist/holmes/governance/ledger-store.js +106 -0
  38. package/dist/holmes/governance/progress-ledger.js +83 -0
  39. package/dist/holmes/governance/provenance-chain.js +365 -0
  40. package/dist/holmes/governance/provenance-ledger.js +0 -0
  41. package/dist/holmes/governance/provenance-schema.js +47 -0
  42. package/dist/holmes/governance/replica-id.js +106 -0
  43. package/dist/holmes/governance/role-policy.js +137 -0
  44. package/dist/holmes/governance/trust-score.js +43 -0
  45. package/dist/holmes/guardrail/anchors.js +31 -0
  46. package/dist/holmes/guardrail/blind-spots.js +38 -0
  47. package/dist/holmes/guardrail/decision-ledger.js +107 -0
  48. package/dist/holmes/guardrail/executable-artifact.js +129 -0
  49. package/dist/holmes/guardrail/governance-history.js +101 -0
  50. package/dist/holmes/guardrail/phase.js +169 -0
  51. package/dist/holmes/guardrail/risk-classifier.js +450 -0
  52. package/dist/holmes/guardrail/risk-gate.js +160 -0
  53. package/dist/holmes/guardrail/risk-types.js +6 -0
  54. package/dist/holmes/guardrail/tspec-state.js +392 -0
  55. package/dist/holmes/guardrail/write-target.js +224 -0
  56. package/dist/holmes/hooks/adapters/antigravity.js +194 -0
  57. package/dist/holmes/hooks/pre-tool-use.js +1262 -0
  58. package/dist/holmes/hooks/stop.js +416 -0
  59. package/dist/holmes/mcp/basis.js +162 -0
  60. package/dist/holmes/mcp/handlers.js +1831 -0
  61. package/dist/holmes/mcp/server.js +71 -0
  62. package/dist/holmes/mcp/stdio-client.js +165 -0
  63. package/dist/holmes/mcp/supervisor.js +178 -0
  64. package/dist/holmes/mcp/tool-schemas.js +394 -0
  65. package/dist/holmes/mcp/validate-args.js +281 -0
  66. package/dist/holmes/messages/registry.js +50 -0
  67. package/dist/holmes/project/baseline.js +210 -0
  68. package/dist/holmes/project/change-source.js +233 -0
  69. package/dist/holmes/project/ignore.js +145 -0
  70. package/dist/holmes/project/root.js +113 -0
  71. package/dist/holmes/reverse/anchor.js +162 -0
  72. package/dist/holmes/reverse/cluster.js +187 -0
  73. package/dist/holmes/reverse/draft.js +151 -0
  74. package/dist/holmes/reverse/dynamic-wiring.js +47 -0
  75. package/dist/holmes/reverse/scan.js +194 -0
  76. package/dist/holmes/reverse/surface.js +154 -0
  77. package/dist/holmes/reverse/test-map.js +263 -0
  78. package/dist/holmes/review/coverage.js +33 -0
  79. package/dist/holmes/review/findings.js +123 -0
  80. package/dist/holmes/review/package.js +40 -0
  81. package/dist/holmes/review/review-targets.js +92 -0
  82. package/dist/holmes/review/scope.js +57 -0
  83. package/dist/holmes/review/test-evidence.js +77 -0
  84. package/dist/holmes/review/test-runner.js +572 -0
  85. package/dist/holmes/rtm/dataflow-taint.js +262 -0
  86. package/dist/holmes/rtm/gap-analyzer.js +27 -0
  87. package/dist/holmes/rtm/git-changes.js +72 -0
  88. package/dist/holmes/rtm/incremental.js +45 -0
  89. package/dist/holmes/rtm/localize.js +100 -0
  90. package/dist/holmes/rtm/rtm-builder.js +191 -0
  91. package/dist/holmes/rtm/rtm-check.js +89 -0
  92. package/dist/holmes/rtm/rtm-graph.js +232 -0
  93. package/dist/holmes/rtm/taint.js +92 -0
  94. package/dist/holmes/rtm/test-scope.js +336 -0
  95. package/dist/holmes/spec/approval-blockers.js +204 -0
  96. package/dist/holmes/spec/breaking-change.js +89 -0
  97. package/dist/holmes/spec/legacy-format.js +87 -0
  98. package/dist/holmes/spec/spec-digest.js +71 -0
  99. package/dist/holmes/spec/spec-parser.js +106 -0
  100. package/dist/holmes/spec/spec-store.conformance.js +118 -0
  101. package/dist/holmes/spec/spec-store.js +331 -0
  102. package/dist/holmes/spec/spec-types.js +177 -0
  103. package/dist/holmes/spec/validator.js +280 -0
  104. package/package.json +76 -0
  105. package/playbooks/adopt/PLAYBOOK.md +125 -0
  106. package/playbooks/author-slice/PLAYBOOK.md +119 -0
  107. package/playbooks/promote-slice/PLAYBOOK.md +134 -0
@@ -0,0 +1,1831 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.HandlerRefusal = void 0;
37
+ exports.isHandlerRefusal = isHandlerRefusal;
38
+ exports.makeHandlers = makeHandlers;
39
+ const fs = __importStar(require("node:fs"));
40
+ const os = __importStar(require("node:os"));
41
+ const path = __importStar(require("node:path"));
42
+ const crypto = __importStar(require("node:crypto"));
43
+ const node_child_process_1 = require("node:child_process");
44
+ const validator_1 = require("../spec/validator");
45
+ const rtm_check_1 = require("../rtm/rtm-check");
46
+ const test_scope_1 = require("../rtm/test-scope");
47
+ const gap_analyzer_1 = require("../rtm/gap-analyzer");
48
+ const test_runner_1 = require("../review/test-runner");
49
+ const test_evidence_1 = require("../review/test-evidence");
50
+ const localize_1 = require("../rtm/localize");
51
+ const phase_1 = require("../guardrail/phase");
52
+ const spec_types_1 = require("../spec/spec-types");
53
+ const legacy_format_1 = require("../spec/legacy-format");
54
+ const cpg_scanner_1 = require("../cpg/cpg-scanner");
55
+ const scan_cache_1 = require("../cpg/scan-cache");
56
+ // N7: every whole-tree scan goes through the file-key incremental cache under the PROJECT root's
57
+ // .ax/cpg_cache — unchanged files (content-hash hit) skip the tree-sitter parse entirely.
58
+ // The cache lives under the RESOLVED project root, not the raw scan root: scanning a SUBDIRECTORY
59
+ // otherwise littered a second `.ax/cpg_cache` under that subdirectory (measured leak — `src/.ax/`),
60
+ // and the .gitignore only anchors the root `.ax/cpg_cache/`. Resolving to the .ax-bearing ancestor
61
+ // keeps one cache per project and matches where git-ignore expects it.
62
+ // @implements A-SPEC-189 §13 (round 12) — `resolveProjectRoot` returns the input when it finds no
63
+ // marker, so a read-looking scan of any directory CREATED `.ax` there and every later resolution
64
+ // under that tree stopped at the minted marker. This is the harm §8 closed for `review_record`,
65
+ // left open on the scan path. When the store is bound, the cache is the bound project's; otherwise
66
+ // only an anchored answer may be written to, and an unanchored one falls back to a temp cache.
67
+ const cacheDirFor = (root) => {
68
+ const r = (0, root_1.resolveProjectRoot)(root);
69
+ if (r.marker !== 'given')
70
+ return path.join(r.root, '.ax', 'cpg_cache');
71
+ if (fs.existsSync(path.join(r.root, '.ax')))
72
+ return path.join(r.root, '.ax', 'cpg_cache');
73
+ return path.join(os.tmpdir(), `holmes-cpg-cache-${crypto.createHash('sha256').update(r.root).digest('hex').slice(0, 16)}`);
74
+ };
75
+ const cachedScan = (root, repoRoot = root) => new cpg_scanner_1.CpgScanner(undefined, new scan_cache_1.ScanFileCache(cacheDirFor(root))).scan(root, repoRoot);
76
+ // @implements A-SPEC-131
77
+ // Same scan, with the skip report kept: the callers that make honesty claims (cpg_scan's surface,
78
+ // the changed-file accounting) need to know what the scan could NOT ingest, not just what it did.
79
+ const cachedScanWithReport = (root, repoRoot = root) => {
80
+ const scanner = new cpg_scanner_1.CpgScanner(undefined, new scan_cache_1.ScanFileCache(cacheDirFor(root)));
81
+ const scanned = scanner.scan(root, repoRoot);
82
+ return { scanned, skipped: scanner.scanSkipped() };
83
+ };
84
+ const rtm_builder_1 = require("../rtm/rtm-builder");
85
+ const rtm_graph_1 = require("../rtm/rtm-graph");
86
+ const taint_1 = require("../rtm/taint");
87
+ const incremental_1 = require("../rtm/incremental");
88
+ const hash_cache_1 = require("../cpg/hash-cache");
89
+ const bundler_1 = require("../context/bundler");
90
+ const scope_1 = require("../review/scope");
91
+ const review_targets_1 = require("../review/review-targets");
92
+ const coverage_1 = require("../review/coverage");
93
+ const basis_1 = require("./basis");
94
+ const write_target_1 = require("../guardrail/write-target");
95
+ const spec_store_1 = require("../spec/spec-store");
96
+ const findings_1 = require("../review/findings");
97
+ const package_1 = require("../review/package");
98
+ const risk_classifier_1 = require("../guardrail/risk-classifier");
99
+ const risk_gate_1 = require("../guardrail/risk-gate");
100
+ const spec_digest_1 = require("../spec/spec-digest");
101
+ const spec_store_2 = require("../spec/spec-store");
102
+ const breaking_change_1 = require("../spec/breaking-change");
103
+ const approval_blockers_1 = require("../spec/approval-blockers");
104
+ const ledger_store_1 = require("../governance/ledger-store");
105
+ const provenance_chain_1 = require("../governance/provenance-chain");
106
+ const ledger_lock_1 = require("../governance/ledger-lock");
107
+ const decision_ledger_1 = require("../guardrail/decision-ledger");
108
+ /**
109
+ * @implements A-SPEC-189 §7 (round 10)
110
+ * A DELIBERATE refusal, distinguished by construction rather than by class.
111
+ *
112
+ * Round 9 converted thrown errors into `{ok:false, reason}` at the wire and told them apart with
113
+ * `e.constructor === Error`. Measured: Node's own fs/exec failures (ENOENT, EISDIR, EACCES) are
114
+ * exactly `Error`, so a CRASH became an orderly refusal — and for `review_status` that refusal had
115
+ * no `blocked` field at all, i.e. a gate reader's `if (out.blocked)` read undefined and passed.
116
+ * The check that used to stop the caller loudly (-32603) became a silent gate pass. A refusal is
117
+ * something a handler chooses to say; nothing infers it from a class.
118
+ */
119
+ class HandlerRefusal extends Error {
120
+ holmesRefusal = true;
121
+ constructor(message) { super(message); this.name = 'HandlerRefusal'; }
122
+ }
123
+ exports.HandlerRefusal = HandlerRefusal;
124
+ /** Marker test that survives realm boundaries (jest's instanceof does not). */
125
+ function isHandlerRefusal(e) {
126
+ return !!e && typeof e === 'object' && e.holmesRefusal === true;
127
+ }
128
+ // @implements A-SPEC-128
129
+ const root_1 = require("../project/root");
130
+ const change_source_1 = require("../project/change-source");
131
+ const baseline_1 = require("../project/baseline");
132
+ const ignore_1 = require("../project/ignore");
133
+ // @implements A-SPEC-126
134
+ const scan_1 = require("../reverse/scan");
135
+ const draft_1 = require("../reverse/draft");
136
+ const anchor_1 = require("../reverse/anchor");
137
+ // @implements A-SPEC-128
138
+ // Where a project begins, for tools that only walk the tree.
139
+ //
140
+ // This REPLACES `assertRepoTopLevel`, which demanded `root === git rev-parse --show-toplevel` and
141
+ // threw otherwise — imposed uniformly, including on tools that never look at a diff. Measured, that
142
+ // made cpg_scan, issue_localize, rtm_check(root), test_run and review_scope all throw on a directory
143
+ // with no `.git`, while reverse_scan (which asks git only for a candidacy refinement) carried on.
144
+ //
145
+ // The hazard that guard addressed is real but belongs to the GIT CHANGE SOURCE: gitChangedFiles
146
+ // yields top-level-relative paths while scan(root, root) tags root-relative ones, so a disagreement
147
+ // silently produces wrong impact sets. `GitChangeSource` now enforces exactly that, and only there.
148
+ function projectRootOf(root) {
149
+ return (0, root_1.resolveProjectRoot)(root).root;
150
+ }
151
+ // @implements A-SPEC-191 §10 — WHERE a single-use approval is spent must not be a caller's choice.
152
+ // r8 measured all three escapes at once: a second `root`, a `root` naming a subdirectory, and an
153
+ // omitted `root` (cwd-relative) each handed the same nonce a fresh, empty ledger — 'N-RISK' opened a
154
+ // hard-hitl door three times over, the round-7 fix notwithstanding, and the audit line for each
155
+ // reopening was filed in the attacker's directory. The destination is DERIVED from the store this
156
+ // server was bound to (spec_approve's A-SPEC-188 principle). A server that cannot derive a project
157
+ // cannot promise single use at all, so it says so instead of pretending: null is a refusal, never a
158
+ // fallback to whatever the caller named.
159
+ /**
160
+ * @implements A-SPEC-191 §15 (round 11)
161
+ * Does this caller's `root` name the project this server is bound to? A tool that ADVERTISES root
162
+ * and neither reads nor checks it answers about somewhere else in a success shape: a single typo in
163
+ * `root` made review_status — the merge gate's own reader — report `blocked: false` for a project
164
+ * whose ledger it never opened. Returns a refusal reason, or null when the root is fine.
165
+ */
166
+ function foreignRootReason(store, root) {
167
+ if (typeof root !== 'string' || root === '' || !(store instanceof spec_store_1.LocalMarkdownRepository))
168
+ return null;
169
+ // An unresolvable STORE is not the caller's mistake — there is simply nothing to compare against.
170
+ let derived;
171
+ try {
172
+ derived = projectRootOf(store.specsRoot);
173
+ if (!fs.existsSync(path.join(derived, '.ax')))
174
+ return null; // 파생이 표지를 찾지 못하면 대조할 것이 없다
175
+ }
176
+ catch {
177
+ return null;
178
+ }
179
+ try {
180
+ const asked = (0, write_target_1.resolveTarget)(projectRootOf(root), '.');
181
+ const bound = (0, write_target_1.resolveTarget)(derived, '.');
182
+ if (asked === bound)
183
+ return null;
184
+ return `이 서버는 ${bound} 프로젝트에 바인딩되어 있습니다 — 요청한 root ${root}는 ${asked} 를 가리킵니다.`
185
+ + ' 다른 프로젝트에 대해 이 서버가 대신 답하지 않습니다.';
186
+ }
187
+ catch (e) {
188
+ return `root를 해석할 수 없습니다: ${String(e.message)}`;
189
+ }
190
+ }
191
+ /**
192
+ * A root the caller named that this process cannot read is the CALLER'S mistake, not a fault.
193
+ * @implements A-SPEC-189 §10 (round 11)
194
+ */
195
+ function assertReadableRoot(tool, root) {
196
+ if (typeof root !== 'string' || root === '') {
197
+ throw new HandlerRefusal(`${tool}: root 는 프로젝트 루트의 절대 경로여야 합니다 (받은 값: ${JSON.stringify(root)})`);
198
+ }
199
+ let stat;
200
+ try {
201
+ stat = fs.statSync(root);
202
+ }
203
+ catch {
204
+ throw new HandlerRefusal(`${tool}: ${root} 를 읽을 수 없습니다 — 그 경로가 존재하는지 확인하십시오`);
205
+ }
206
+ if (!stat.isDirectory())
207
+ throw new HandlerRefusal(`${tool}: ${root} 는 디렉터리가 아닙니다`);
208
+ }
209
+ function boundNonceLedger(store) {
210
+ if (!(store instanceof spec_store_1.LocalMarkdownRepository))
211
+ return null;
212
+ try {
213
+ const derived = projectRootOf(store.specsRoot);
214
+ // @implements A-SPEC-191 §14 (round 10) — a project root INSIDE the spec store is not a project
215
+ // root, whatever it is spelled. The `.ax` exclusion only covers the default layout; with
216
+ // `--specs-dir docs/specs` an ordinary review_record could still plant `docs/specs/.ax` and move
217
+ // every consumed nonce, every audit line, and spec_approve's notion of "this project" with it.
218
+ const storeReal = (() => { try {
219
+ return fs.realpathSync(store.specsRoot);
220
+ }
221
+ catch {
222
+ return store.specsRoot;
223
+ } })();
224
+ const derivedReal = (() => { try {
225
+ return fs.realpathSync(derived);
226
+ }
227
+ catch {
228
+ return derived;
229
+ } })();
230
+ if (derivedReal === storeReal || derivedReal.startsWith(storeReal + path.sep))
231
+ return null;
232
+ if (!fs.existsSync(path.join(derived, '.ax')))
233
+ return null;
234
+ return path.join(derived, '.ax', 'ledger', 'provenance.jsonl');
235
+ }
236
+ catch {
237
+ return null;
238
+ }
239
+ }
240
+ /**
241
+ * Where the FINDINGS ledger of this server's project lives.
242
+ *
243
+ * @implements A-SPEC-189 §8 (round 11) — `review_record` joined the caller's `root` straight into a
244
+ * path, so a subdirectory root wrote `<root>/pkg/.ax/ledger/findings.jsonl` — and that write MINTED a
245
+ * project marker at `pkg`, after which the same subdirectory read as its own project (measured: the
246
+ * second call to the very same root was then refused as foreign). `review_status` and the Stop gate
247
+ * read the project's ledger, so those findings gated nothing while reporting `{recorded: N}`.
248
+ * The ledger's location is the bound project's fact, not an argument.
249
+ */
250
+ function boundFindingsLedger(store, root) {
251
+ const projRoot = projectRootOf(root);
252
+ const fallback = path.join(projRoot, '.ax', 'ledger', 'findings.jsonl');
253
+ if (!(store instanceof spec_store_1.LocalMarkdownRepository))
254
+ return fallback;
255
+ try {
256
+ const derived = projectRootOf(store.specsRoot);
257
+ const storeReal = (() => { try {
258
+ return fs.realpathSync(store.specsRoot);
259
+ }
260
+ catch {
261
+ return store.specsRoot;
262
+ } })();
263
+ const derivedReal = (() => { try {
264
+ return fs.realpathSync(derived);
265
+ }
266
+ catch {
267
+ return derived;
268
+ } })();
269
+ if (derivedReal === storeReal || derivedReal.startsWith(storeReal + path.sep))
270
+ return fallback;
271
+ if (!fs.existsSync(path.join(derived, '.ax')))
272
+ return fallback;
273
+ return path.join(derived, '.ax', 'ledger', 'findings.jsonl');
274
+ }
275
+ catch {
276
+ return fallback;
277
+ }
278
+ }
279
+ /**
280
+ * Build the change source a diff-consuming tool should use, from what the caller supplied.
281
+ *
282
+ * Precedence is explicit-over-implicit: an explicit git range wins, then a named baseline, then the
283
+ * default baseline — so a caller who passes nothing on a project that has been verified before still
284
+ * gets a scoped answer, and a caller who passes a range still gets git's rename detection.
285
+ */
286
+ const DEFAULT_BASELINE = 'last-green';
287
+ function makeChangeSource(root, a) {
288
+ if (a.base && a.head)
289
+ return new change_source_1.GitChangeSource(root, a.base, a.head);
290
+ // @implements A-SPEC-134 — verify against the provenance chain so a tampered baseline degrades to
291
+ // full scope instead of narrowing against a forged reference point.
292
+ return new change_source_1.SnapshotChangeSource(root, a.since ?? DEFAULT_BASELINE, {
293
+ isIgnored: (p) => (0, ignore_1.loadIgnore)(root).isIgnored(p),
294
+ verify: path.join(root, '.ax', 'ledger', 'provenance.jsonl'),
295
+ });
296
+ }
297
+ // ContentSource factory shared by context_bundle and review_prepare: specMap
298
+ // SPEC:<id> -> title + section text, codeMap CODE:<qn>@<sourcePath> -> signature,
299
+ // merged `?? null`.
300
+ function buildContentSource(specs, scanned) {
301
+ const specMap = new Map(specs.map((s) => [
302
+ `SPEC:${s.id}`,
303
+ [s.title, ...Object.entries(s.sections).map(([k, v]) => `## ${k}\n${v}`)].join('\n'),
304
+ ]));
305
+ const codeMap = new Map();
306
+ for (const f of scanned) {
307
+ for (const sym of f.symbols) {
308
+ // A-SPEC-121.4: key by the FULL (source_path-qualified) node id — see
309
+ // RtmGraph/rtm-builder's A-SPEC-121.3 note — so this matches the actual
310
+ // graph node id and bundle content resolves instead of silently
311
+ // falling through to `?? null`.
312
+ codeMap.set(`CODE:${sym.qualifiedName}@${f.sourcePath}`, `${sym.qualifiedName} (${sym.kind}) @ ${f.sourcePath}`);
313
+ }
314
+ }
315
+ return (nodeId) => specMap.get(nodeId) ?? codeMap.get(nodeId) ?? null;
316
+ }
317
+ // Shared prologue of review_scope/review_prepare (and the guard+git+scan
318
+ // part of rtm_reindex): guard the root, diff base..head, load specs, scan
319
+ // the tree, and derive the changed-files/changed-symbols sets used to scope
320
+ // the review/graph. `toolName` is passed through to assertRepoTopLevel so
321
+ // the thrown error names the actual caller.
322
+ async function deriveChangedContext(store, rootArg, a, toolName) {
323
+ const root = projectRootOf(rootArg);
324
+ const source = makeChangeSource(root, a);
325
+ const resolved = source.changes();
326
+ const changeSource = source.describe();
327
+ // UNAVAILABLE IS NOT "NOTHING CHANGED". Reading a missing reference point as an empty change set
328
+ // would narrow the scope against a baseline that was never taken — silently, and in the dangerous
329
+ // direction. The caller widens to full instead, the way computeTestScope already does when no
330
+ // anchored test resolves.
331
+ const changes = resolved ?? { added: [], modified: [], deleted: [], renamed: [] };
332
+ const specs = await store.list();
333
+ const { scanned, skipped } = cachedScanWithReport(root, root);
334
+ const bySourcePath = new Map(scanned.map((f) => [f.sourcePath, f]));
335
+ // Deletions have no post-change content to scan for symbols; renames
336
+ // are keyed by their post-change (`to`) path, matching scan()'s
337
+ // sourcePath tagging.
338
+ const changedFiles = [...changes.added, ...changes.modified, ...changes.renamed.map((r) => r.to)];
339
+ const changedSymbols = [
340
+ ...new Set(changedFiles.flatMap((relPath) => bySourcePath.get(relPath)?.symbols.map((s) => s.qualifiedName) ?? [])),
341
+ ];
342
+ // @implements A-SPEC-130
343
+ // Three-lane accounting: the symbol derivation above (lane 1) drops changed files that parse to
344
+ // zero symbols or are excluded from the scan — measured to zero out the impact set on a tree with
345
+ // five changed sources. Lanes 2/3 and the unresolved remainder are derived from the SAME
346
+ // collections; no new scan, no new I/O.
347
+ const account = (0, test_scope_1.accountChangedFiles)(changedFiles, (rel) => {
348
+ const s = bySourcePath.get(rel);
349
+ return s ? { implementsSpecs: s.implementsSpecs, symbolCount: s.symbols.length } : undefined;
350
+ });
351
+ // @implements A-SPEC-131
352
+ // A changed file the scan REPORTED as skipped is exactly a file whose impact cannot be proven —
353
+ // it joins the same unresolved lane, so the tier widens and the name rides in the answer. (The
354
+ // account already catches never-scanned scannable files; this catches ones with a stated reason.)
355
+ const skippedSet = new Set(skipped.map((s) => s.file));
356
+ const unresolvedFiles = [...new Set([...account.unresolvedFiles, ...changedFiles.filter((f) => skippedSet.has(f))])].sort();
357
+ return {
358
+ changes, changeSource, root, specs, scanned, bySourcePath, changedFiles, changedSymbols,
359
+ anchorImpactedSpecs: account.anchorImpactedSpecs,
360
+ changedTestFiles: account.changedTestFiles,
361
+ unresolvedFiles,
362
+ ...(resolved === null ? { scopeFallback: 'full' } : {}),
363
+ };
364
+ }
365
+ // @implements A-SPEC-100.2
366
+ /**
367
+ * @implements A-SPEC-156
368
+ * The build this PROCESS loaded, resolved exactly once. Per-call hashing would buy I/O for no new
369
+ * information, and mtime would manufacture divergence on a checkout or a copy.
370
+ */
371
+ const LOADED_BUILD = (0, basis_1.loadedBuildId)(path.resolve(__dirname, '..', '..', '..'));
372
+ /**
373
+ * @implements A-SPEC-156
374
+ * Basis is attached HERE, in one place, rather than inside each handler. Measured reasoning: 25
375
+ * handlers edited by hand is 25 chances to forget, and the one that forgets is invisible — which is
376
+ * precisely how the surface came to answer confidently with nothing backing it. The coverage test
377
+ * walks the tool list for the same reason.
378
+ */
379
+ function makeHandlers(store) {
380
+ const raw = makeRawHandlers(store);
381
+ const wrapped = {};
382
+ for (const [name, fn] of Object.entries(raw)) {
383
+ wrapped[name] = (0, basis_1.withBasis)(fn, (a) => basisFor(a?.root));
384
+ }
385
+ wrapped.basis_detail = async (a) => {
386
+ const b = basisFor(a?.root, true);
387
+ return { ...b, basis: (0, basis_1.basisDigest)(b) };
388
+ };
389
+ // The cast restores the per-handler signatures the loop erased. Without it every handler collapses
390
+ // to `(a: any) => Promise<unknown>`, so a caller writing `h.rtm_check()` — legal before this
391
+ // change — stops compiling. That is an ADR-013 tier-③ break, and adding a diagnostic field is not
392
+ // a licence to take one.
393
+ return wrapped;
394
+ }
395
+ /**
396
+ * Collects the axes. `withDisk` reads the on-disk build id — done only for `basis_detail` so a stat
397
+ * is not charged to every call. The comparison itself is left to the caller: a server that rendered
398
+ * its own staleness verdict would be running that verdict from the stale build.
399
+ */
400
+ function basisFor(root, withDisk = false) {
401
+ const ctx = { loadedBuild: LOADED_BUILD };
402
+ if (withDisk)
403
+ ctx.root = path.resolve(__dirname, '..', '..', '..');
404
+ if (root) {
405
+ // Both axes are constant-I/O by construction. A semantic file count would cost a 69ms tree walk
406
+ // per call (measured), and a graph rebuild more — this module must not make every tool slower in
407
+ // order to describe itself.
408
+ ctx.scanFp = () => fs.statSync(path.join(root, '.ax', 'cpg_cache', 'scan-cache.json')).size;
409
+ // A COUNT would have missed the divergence that matters most: editing a spec leaves the count
410
+ // identical, so two answers computed on different corpora would carry the same basis. Folding
411
+ // size and mtime in costs 3ms against 2ms (measured) — one millisecond for the axis to mean what
412
+ // it claims.
413
+ ctx.specs = () => {
414
+ const dir = path.join(root, '.ax', 'specs');
415
+ let acc = 0;
416
+ for (const f of fs.readdirSync(dir, { recursive: true })) {
417
+ try {
418
+ const st = fs.statSync(path.join(dir, f));
419
+ acc = (acc + st.size + Math.floor(st.mtimeMs)) >>> 0;
420
+ }
421
+ catch { /* a file that vanished mid-walk contributes nothing; absence is not an error here */ }
422
+ }
423
+ return acc;
424
+ };
425
+ }
426
+ return (0, basis_1.collectBasis)(ctx);
427
+ }
428
+ function makeRawHandlers(store) {
429
+ const resolver = (specs) => (id) => specs.find((s) => s.id === id) ?? null;
430
+ return {
431
+ async spec_create(a) {
432
+ // @implements A-SPEC-169
433
+ // `root` is a CONTROL argument. Without excluding it, the catch-all below stores it as
434
+ // frontmatter and a machine-local absolute path lands in a git-shared document. A blacklist,
435
+ // not a whitelist: domain fields (`priority`, `slice`, …) must keep flowing through without
436
+ // anyone maintaining a list of them.
437
+ const { type, id, title, depends_on = [], root: reqRoot, ...extra } = a;
438
+ // The server binds its spec store once, so a `root` naming another project cannot be honoured.
439
+ // Silently writing to this one is a data-integrity problem; refusing says so. Compared by
440
+ // resolved identity (A-SPEC-163) — `<root>//.` is the same directory, not a different project.
441
+ // @implements A-SPEC-189 §9 (round 11) — the question is "same PROJECT?", and this asked "does
442
+ // `<root>/.ax/specs` equal the bound store?" — the default layout hardcoded. On the first-class
443
+ // `--specs-dir docs/specs` deployment no root could ever satisfy it: measured, the project's own
444
+ // root was refused as another project's, so `spec_create` was unusable there. `spec_approve`
445
+ // learned this in round 3 and compares at project level; this is the same comparison.
446
+ // @implements A-SPEC-189 §16 (round 13) — the siblings all ask "did the derivation actually
447
+ // FIND a marker?" before speaking for a project (spec_approve, phase_status,
448
+ // foreignRootReason). Without it, a store whose derivation lands on itself (an absolute
449
+ // `--specs-dir` outside any `.ax` tree) calls itself the bound project and refuses the
450
+ // project's real root — the inverse defect rounds 3/9/11 fixed elsewhere.
451
+ // 파생은 이 분기 안에서만, 그리고 실패는 '표지를 못 찾았다'로 읽는다 — 스펙 트리를 이제
452
+ // 만들려는 참이라 스토어 경로가 아직 없을 수 있고, 그것이 예외가 되어선 안 된다.
453
+ const boundSpecsRoot = store instanceof spec_store_1.LocalMarkdownRepository ? store.specsRoot : null;
454
+ const derivedForCreate = (() => {
455
+ if (boundSpecsRoot === null)
456
+ return null;
457
+ try {
458
+ const d = projectRootOf(boundSpecsRoot);
459
+ return fs.existsSync(path.join(d, '.ax')) ? d : null;
460
+ }
461
+ catch {
462
+ return null;
463
+ }
464
+ })();
465
+ if (typeof reqRoot === 'string' && reqRoot !== '' && derivedForCreate !== null) {
466
+ const asked = (0, write_target_1.resolveTarget)(projectRootOf(reqRoot), '.');
467
+ const bound = (0, write_target_1.resolveTarget)(derivedForCreate, '.');
468
+ if (asked !== bound) {
469
+ return {
470
+ ok: false,
471
+ // A-SPEC-169: 두 경로를 모두 이름한다 — 어느 저장소가 묶여 있는지 호출자가 보아야 한다.
472
+ reason: `이 서버는 ${bound} 프로젝트(스펙 저장소 ${boundSpecsRoot})에 바인딩되어 있습니다`
473
+ + ` — 요청한 root ${reqRoot}는 ${asked}를 가리킵니다.`
474
+ + ' 다른 프로젝트를 조용히 수정하지 않기 위해 거부합니다.',
475
+ };
476
+ }
477
+ }
478
+ // @implements A-SPEC-174
479
+ // Refuse what can never become valid; accept what is merely not filled in yet.
480
+ //
481
+ // Measured 2026-08-12 against the installed server: `REQ-7`, `hello`, `A-SPEC-7.1` and four
482
+ // more were all created and are all refused at approval on `bad-id`. The id IS the filename,
483
+ // so the author's only repair is to delete the spec and start over — after writing the body.
484
+ // The stubs below are the opposite case: they are placeholders BY DESIGN, and blocking
485
+ // approval until someone answers them is what they are for (A-SPEC-146).
486
+ //
487
+ // Every judgement here quotes SPEC_TYPES, which is what approval reads. A second statement of
488
+ // the rule drifts, and the drift is either this defect again or its worse inverse — refused
489
+ // at creation, accepted at approval.
490
+ const def = spec_types_1.SPEC_TYPES[type];
491
+ if (!def) {
492
+ return { ok: false, reason: `알 수 없는 스펙 타입 "${String(type)}" — 유효한 타입: ${spec_types_1.SPEC_ORDER.join(', ')}` };
493
+ }
494
+ if (!def.idRegex.test(String(id))) {
495
+ return {
496
+ ok: false,
497
+ reason: `id "${String(id)}"는 ${def.type}의 형식 ${def.idRegex}에 맞지 않습니다 — 예: ${def.example}.`
498
+ + ' 승인 시점에 거부될 값이므로 지금 거부합니다(id는 파일명이라 나중에 고칠 수 없습니다).',
499
+ };
500
+ }
501
+ for (const pid of depends_on) {
502
+ // The parent is judged WITHOUT resolving it: not existing yet is a legitimate state an
503
+ // author reaches by creating the child first. A wrong type is wrong forever.
504
+ const ptype = (0, spec_types_1.specTypeOfId)(String(pid));
505
+ if (ptype === null) {
506
+ return { ok: false, reason: `depends_on "${String(pid)}"는 어떤 스펙 타입의 id 형식에도 맞지 않습니다 — 그 id를 가진 스펙은 존재할 수 없습니다.` };
507
+ }
508
+ if (!def.parents.includes(ptype)) {
509
+ return { ok: false, reason: `depends_on "${String(pid)}"는 ${ptype}입니다 — ${def.type}의 부모는 ${def.parents.join('|') || '없음(빈 depends_on)'}이어야 합니다.` };
510
+ }
511
+ }
512
+ // @implements A-SPEC-188
513
+ // Creation creates. Probed before this check existed: spec_create over an approved+sealed
514
+ // REQ returned {"created"} while the disk went draft / seal gone / prose gone — the sanctioned
515
+ // door destroying what risk_check guards every shell path against. Checked AFTER the format
516
+ // refusals on purpose: a malformed id must keep its own message (order is pinned by test).
517
+ // Legacy (typeless) documents count as existing too — overwriting one makes the store's
518
+ // orphan removal delete the differently-named original file along with its history.
519
+ const existing = (await store.list()).find((s) => s.id === id);
520
+ if (existing) {
521
+ const sealed = existing.status === 'approved' || Boolean(existing.frontmatter?.approved_digest);
522
+ const kind = existing.type ? `${existing.type} (status: ${existing.status})` : `옛 형식 문서 (status: ${existing.status})`;
523
+ return {
524
+ ok: false,
525
+ reason: `${id}은(는) 이미 존재합니다 — ${kind}. `
526
+ + (sealed
527
+ ? '봉인(approved)된 문서이므로, 내용을 바꾸려면 파일을 편집한 뒤 spec_approve로 재봉인하십시오. '
528
+ : '그 문서를 고치려는 것이면 파일을 직접 편집하십시오. ')
529
+ + '새 문서를 만들려는 것이면 다른 id를 쓰십시오. 생성은 아무것도 덮어쓰지 않습니다.',
530
+ };
531
+ }
532
+ const sections = Object.fromEntries(def.requiredSections.map((s) => [s, spec_types_1.FIELD_PLACEHOLDER]));
533
+ // Stub the required FRONTMATTER too, not just the sections. Without this every created spec
534
+ // fails the project's own `spec_validate` on `missing-field` the moment it is written, and
535
+ // the author has to rediscover the per-type field list by reading spec-types.ts. Caller-
536
+ // supplied values win; `coverage` needs a shaped default because T-SPEC reads its keys.
537
+ // `source` is a citation LIST, so a bare 'TODO' string would fail the shape check the moment
538
+ // the spec is written. The stub is validly shaped but obviously unfilled: it validates while
539
+ // the REQ is drafted and blocks approval until a real origin replaces the placeholder.
540
+ const stub = (f) => {
541
+ if (f === 'coverage')
542
+ return { normal: false, corner: false, negative: false, boundary: false };
543
+ if (f === 'source')
544
+ return [{ kind: 'other', ref: spec_types_1.CITATION_PLACEHOLDER, note: 'cite the real origin before approval' }];
545
+ return spec_types_1.FIELD_PLACEHOLDER;
546
+ };
547
+ const required = Object.fromEntries(def.requiredFields.filter((f) => !(f in extra)).map((f) => [f, stub(f)]));
548
+ // @implements A-SPEC-146
549
+ // Scaffolded but not required by `validateSpec` — see SpecTypeDef.stubOnlyFields for why the
550
+ // gap exists. The stub value is `TODO`, which is NOT an accepted grade, so the field is
551
+ // visible to the author while the approval still refuses until it is actually answered.
552
+ // Pre-filling `none` would be easier and would make the formulaic answer the default, which is
553
+ // exactly what ADR-013 recorded as its revisit trigger.
554
+ const stubOnly = Object.fromEntries((def.stubOnlyFields ?? []).filter((f) => !(f in extra)).map((f) => [f, stub(f)]));
555
+ const spec = { id, type, title, status: 'draft', dependsOn: depends_on, frontmatter: { ...required, ...stubOnly, ...extra }, sections };
556
+ // @implements A-SPEC-188 — existence is a PATH question, not only a list() question.
557
+ // Round-2 review probed the gap: list() drops unparseable files, so a prose note or
558
+ // broken-YAML document sitting at the exact target path was invisible to the id check above
559
+ // and got silently overwritten — with the refusal text elsewhere promising the opposite.
560
+ // A file the store cannot read as this id is a file a human must look at first.
561
+ if (store instanceof spec_store_1.LocalMarkdownRepository) {
562
+ const target = store.targetPathFor(spec);
563
+ if (fs.existsSync(target)) {
564
+ return {
565
+ ok: false,
566
+ reason: `${id}의 목적지(${target})에 파일이 이미 있는데 스토어가 이 id의 스펙으로 읽지 못합니다`
567
+ + ' — 손으로 쓰던 초안이거나 깨진 문서일 수 있습니다. 사람이 확인해 옮기거나 고치기 전까지 덮어쓰지 않습니다'
568
+ + ' (doctor가 읽을 수 없는 스펙 파일을 보고합니다).',
569
+ };
570
+ }
571
+ }
572
+ try {
573
+ await store.write(spec);
574
+ }
575
+ catch (e) {
576
+ // The pre-write path check above makes this a narrow race backstop; the store guard is the
577
+ // authority and this act relays it in the same wording the other writers use.
578
+ if (e instanceof spec_store_1.TargetPathOccupiedError) {
579
+ return {
580
+ ok: false,
581
+ reason: `${id}의 목적지(${e.occupiedPath})에 스토어가 읽지 못하는 파일이 이미 있습니다 — 덮어쓰지 않습니다.`
582
+ + ' 사람이 확인해 옮기거나 고친 뒤 다시 시도하십시오.',
583
+ };
584
+ }
585
+ throw e;
586
+ }
587
+ return { created: id };
588
+ },
589
+ async spec_validate(a) {
590
+ const specs = await store.list();
591
+ const spec = specs.find((s) => s.id === a.id);
592
+ if (!spec)
593
+ return { ok: false, findings: [{ level: 'error', code: 'not-found', message: a.id }] };
594
+ return (0, validator_1.validateSpec)(spec, resolver(specs));
595
+ },
596
+ /**
597
+ * @implements A-SPEC-132
598
+ * Approval as an ACT: validate → seal → flip → ledger, in one call. This is the designed
599
+ * reversal of "no approval tool exists" (which promote-slice documented while it was true) —
600
+ * the act now includes digest computation a hand edit cannot perform honestly. Fail-closed on
601
+ * the SERVER-environment approval: nothing in the request payload can substitute, because the
602
+ * agent authors the payload and the operator authors the environment.
603
+ */
604
+ /**
605
+ * @implements A-SPEC-184
606
+ * Raise ONE named document from an older spec format to the current one.
607
+ *
608
+ * The compatibility policy this implements: older documents are read and left alone by default,
609
+ * and rise only when a human points at the one they intend to use. There is deliberately no bulk
610
+ * path — 155 documents here and 90 in the measured adoption target, and which of them are still
611
+ * live specifications is a judgement only a person holds.
612
+ *
613
+ * It declares a KIND. It does not confer approval: 37 of this repository's legacy documents read
614
+ * `status: Approved`, and carrying that across would mint approvals that never passed the sealing
615
+ * act. The old value is preserved as evidence and the document restarts at `draft`.
616
+ *
617
+ * Takes no `root`: the store is bound at server construction, exactly as `spec_create` is.
618
+ */
619
+ async spec_upgrade(a) {
620
+ // @implements A-SPEC-188 — same read-then-write shape as spec_approve, so the same window
621
+ // AND the same duplicate hazard: read() resolves the last-walked copy, and the write's orphan
622
+ // removal would delete the other. Refuse rather than pick a side.
623
+ if ((await store.list()).filter((s) => s.id === a.id).length > 1) {
624
+ return {
625
+ ok: false,
626
+ reason: `${a.id}이(가) 스토어에 두 번 이상 존재합니다 — 어느 사본이 진본인지 도구가 고를 수 없어 보강 전에 거부합니다.`
627
+ + ' doctor로 중복 파일을 확인해 하나로 정리한 뒤 다시 시도하십시오.',
628
+ };
629
+ }
630
+ const cur = await store.read(a.id);
631
+ if (!cur)
632
+ return { ok: false, reason: `spec ${a.id} not found` };
633
+ const spec = cur.spec;
634
+ const plan = (0, legacy_format_1.upgradePlan)(spec);
635
+ if (!plan) {
636
+ const why = (0, legacy_format_1.legacyMessage)(spec);
637
+ // No plan for two opposite reasons. Already current is success and writes nothing — a second
638
+ // run must not churn the file. An unsupported kind is a refusal, and its wording must not
639
+ // send the holder after a type that does not exist.
640
+ return why ? { ok: false, reason: why } : { ok: true, upgraded: false, reason: `${a.id}은(는) 이미 현행 양식입니다` };
641
+ }
642
+ const upgraded = {
643
+ ...spec,
644
+ type: plan.type,
645
+ status: 'draft',
646
+ frontmatter: (0, legacy_format_1.upgradedFrontmatter)(spec, plan),
647
+ };
648
+ try {
649
+ await store.write(upgraded, { expectedVersion: cur.version });
650
+ }
651
+ catch (e) {
652
+ if (e instanceof spec_store_2.SpecVersionConflictError) {
653
+ return {
654
+ ok: false,
655
+ reason: `보강 진행 중 ${a.id}이(가) 바뀌었습니다 — 바뀐 내용을 확인하고 다시 시도하십시오.`
656
+ + ' 이번 보강은 아무것도 쓰지 않았습니다.',
657
+ };
658
+ }
659
+ // @implements A-SPEC-188 — the upgrade RELOCATES by design (legacy filename → canonical),
660
+ // so an unreadable file at the canonical path is the store's occupied-target refusal.
661
+ if (e instanceof spec_store_1.TargetPathOccupiedError) {
662
+ return {
663
+ ok: false,
664
+ reason: `${a.id}의 목적지(${e.occupiedPath})에 스토어가 읽지 못하는 파일이 이미 있습니다 — 덮어쓰지 않습니다.`
665
+ + ' 사람이 확인해 옮기거나 고친 뒤 다시 시도하십시오. 이번 보강은 아무것도 쓰지 않았습니다.',
666
+ };
667
+ }
668
+ throw e;
669
+ }
670
+ // Report what still stands between this document and approval. Silence would read as "done",
671
+ // and a legacy document typically lacks most required sections.
672
+ const after = await store.list();
673
+ const remaining = (0, validator_1.validateSpec)(upgraded, resolver(after)).findings.filter((f) => f.level === 'error');
674
+ return { ok: true, upgraded: true, type: plan.type, legacyStatus: plan.legacyStatus, remaining };
675
+ },
676
+ async spec_approve(a) {
677
+ const approvalRaw = process.env.HOLMES_APPROVAL;
678
+ let approval;
679
+ try {
680
+ approval = approvalRaw ? JSON.parse(approvalRaw) : undefined;
681
+ }
682
+ catch {
683
+ approval = undefined;
684
+ }
685
+ // covers, not merely well-formed (round-3 escalation: a token scoped to review-resolve — or
686
+ // expired outright — completed a FULL SEAL here, so A-SPEC-133's narrowing was decorative at
687
+ // the most consequential consumer). Unscoped {actor,token,rationale} stays the session key.
688
+ if (!(0, risk_gate_1.approvalCovers)(approval, { kind: 'spec-approve', target: a.id }, new Date().toISOString())) {
689
+ 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)' };
690
+ }
691
+ // @implements A-SPEC-188 — destination BEFORE seal.
692
+ // The old order (seal at :481, resolve the ledger at :483) produced both measured harms: a
693
+ // bad root left a standing seal with no ledger entry anywhere while the CALLER WAS TOLD IT
694
+ // FAILED, and a foreign root filed the only audit record in another project's ledger. This
695
+ // repository lived the first one — REQ-182's chain was approved rootless on 2026-08-13 and
696
+ // the missing entries were found two review rounds later.
697
+ //
698
+ // The destination is DERIVED from the store the server was bound to (same principle as
699
+ // spec_create's A-SPEC-163 guard); a supplied `root` is a confirmation that must match. Any
700
+ // failure here is a refusal with nothing written.
701
+ let ledgerRoot;
702
+ try {
703
+ if (store instanceof spec_store_1.LocalMarkdownRepository) {
704
+ // Compared at PROJECT level, through the same walk-up the old code used: a root pointing
705
+ // anywhere INSIDE this project (the specs dir, a subdirectory) resolves to the same
706
+ // project and is accepted — round-2 review caught the first cut refusing those with a
707
+ // message that printed the identical path on both sides. Only a root resolving to a
708
+ // DIFFERENT project is refused, and the message names both projects.
709
+ //
710
+ // Round-3 caught the fallback: resolveProjectRoot returns its INPUT when no .ax ancestor
711
+ // exists, so a custom HOLMES_SPECS outside any .ax tree "derived" the specs dir itself as
712
+ // the project — a rootless approval then minted .ax/ledger INSIDE the spec store, and the
713
+ // correct explicit root was refused as a "different project". Derivation only counts when
714
+ // the walk actually found a marker; otherwise the old contract stands: root is required
715
+ // and names the project.
716
+ const derived = projectRootOf(store.specsRoot);
717
+ const derivationFoundMarker = fs.existsSync(path.join(derived, '.ax'));
718
+ if (derivationFoundMarker) {
719
+ ledgerRoot = derived;
720
+ if (typeof a.root === 'string' && a.root !== '') {
721
+ const askedProject = (0, write_target_1.resolveTarget)(projectRootOf(a.root), '.');
722
+ const boundProject = (0, write_target_1.resolveTarget)(ledgerRoot, '.');
723
+ if (askedProject !== boundProject) {
724
+ return {
725
+ ok: false,
726
+ reason: `이 서버는 ${boundProject} 프로젝트에 바인딩되어 있습니다 — 요청한 root ${a.root}는 ${askedProject} 프로젝트를 가리킵니다.`
727
+ + ' 다른 프로젝트의 원장에 기록하지 않기 위해 봉인 전에 거부합니다.',
728
+ };
729
+ }
730
+ }
731
+ }
732
+ else if (typeof a.root === 'string' && a.root !== '') {
733
+ ledgerRoot = projectRootOf(a.root);
734
+ }
735
+ else {
736
+ return {
737
+ ok: false,
738
+ reason: '스토어 위치에서 프로젝트를 파생할 수 없습니다(.ax 상위 디렉터리 없음) — 원장을 어디에 둘지 알 수 없어 봉인 전에 거부합니다. root를 지정하십시오.',
739
+ };
740
+ }
741
+ }
742
+ else {
743
+ if (typeof a.root !== 'string' || a.root === '') {
744
+ return { ok: false, reason: 'root가 없고 스토어에서 원장 위치를 파생할 수도 없습니다 — 봉인 전에 거부합니다. root를 지정하십시오.' };
745
+ }
746
+ ledgerRoot = projectRootOf(a.root);
747
+ }
748
+ }
749
+ catch (e) {
750
+ return {
751
+ ok: false,
752
+ reason: `원장 위치를 확정할 수 없어 봉인 전에 거부합니다: ${String(e.message)}.`
753
+ + ' 올바른 root를 지정하거나, 파일 스토어에 바인딩된 서버에서는 root를 생략하십시오.',
754
+ };
755
+ }
756
+ const specs = await store.list();
757
+ // @implements A-SPEC-188 — with DUPLICATE ids the tool cannot know which copy is canonical:
758
+ // read() resolves the last-walked file while list().find sees the first, so approving would
759
+ // seal a stray's content and delete the canonical file — success reported, edit destroyed
760
+ // (round-2 review reproduced exactly that). Duplicates are a recognized invalid state
761
+ // (doctor detects them); the act refuses rather than picking a side.
762
+ // @implements A-SPEC-188 — the spec this act validates and seals is the one read NOW, with
763
+ // its version captured for the optimistic write below. Basing the candidate on the list()
764
+ // element instead leaves a window in which an external edit is silently destroyed and the
765
+ // STALE content gets sealed (measured: 17 of 40 concurrent edits lost, 35-55ms window).
766
+ const cur = await store.read(a.id);
767
+ if (!cur)
768
+ return { ok: false, reason: `spec ${a.id} not found` };
769
+ const spec = cur.spec;
770
+ // @implements A-SPEC-188 — duplicates make the id ambiguous, for the SPEC and for its
771
+ // PARENTS alike. Round-3 probed the parent half: with a stray duplicate of the parent
772
+ // walking first, the child's parent_digests sealed the stray's (forged) digest — the very
773
+ // snapshot drift detection compares against — while the refusal principle stated one field
774
+ // away was "the tool does not pick a copy". Refuse for every ambiguous id in the act.
775
+ const ambiguous = [a.id, ...spec.dependsOn].filter((id) => specs.filter((s) => s.id === id).length > 1);
776
+ if (ambiguous.length > 0) {
777
+ return {
778
+ ok: false,
779
+ reason: `${[...new Set(ambiguous)].join(', ')}이(가) 스토어에 두 번 이상 존재합니다 — 어느 사본이 진본인지 도구가 고를 수 없어 봉인 전에 거부합니다.`
780
+ + ' doctor로 중복 파일을 확인해 하나로 정리한 뒤 다시 승인하십시오.',
781
+ };
782
+ }
783
+ // @implements A-SPEC-168
784
+ // A child may not be approved under a parent that is not. The chain's meaning is that each
785
+ // layer is justified by the one above it, and measured 2026-08-08 that failed in four places —
786
+ // including A-SPEC-100.1/100.2, the core MCP guardrail, whose design H-SPEC-100 nobody ever
787
+ // approved. The obligation is on the ACT: existing approvals are untouched, because making it
788
+ // a state rule is what REQ-146 measured turning 302 specs into violations at once.
789
+ // @implements A-SPEC-182
790
+ // Shared with the gate rather than restated. Adversarial review found this check living ONLY
791
+ // here: the gate reported "nothing blocks" for a draft under a draft parent — the modal state
792
+ // of top-down authoring — and the author learned otherwise only by attempting the approval.
793
+ const parentIssues = (0, approval_blockers_1.parentBlockers)(spec, resolver(specs));
794
+ if (parentIssues.length > 0)
795
+ return { ok: false, reason: parentIssues[0] };
796
+ // Validate AS IT WILL BE — approved. Approval-gated checks (placeholder citations, 4-quadrant
797
+ // GWT) must fire NOW, not one turn after the seal exists.
798
+ const candidate = { ...spec, status: 'approved', frontmatter: { ...spec.frontmatter } };
799
+ delete candidate.frontmatter.approved_digest; // seals are recomputed by this act, never inherited
800
+ delete candidate.frontmatter.parent_digests;
801
+ const prevalidation = (0, validator_1.validateSpec)(candidate, resolver(specs));
802
+ const blocking = prevalidation.findings.filter((f) => f.level === 'error' && !approval_blockers_1.SUPPLIED_BY_APPROVAL.has(f.code) // this act supplies exactly those
803
+ );
804
+ if (blocking.length > 0)
805
+ return { ok: false, reason: 'validation errors block approval', findings: blocking };
806
+ // @implements A-SPEC-146
807
+ // ADR-013's duty is owed by the ACT, not by the document's static validity. Measured: putting
808
+ // this in `validateSpec`'s requiredFields produced 38 ART-3 violations and a Stop hook that
809
+ // blocked every turn, because all 38 governed A-SPECs are already approved. Here it constrains
810
+ // the future without invalidating the past — and a re-approval, which by definition means the
811
+ // content changed, is exactly when the question is due.
812
+ const breakingIssue = (0, breaking_change_1.checkBreakingChangeDeclared)(candidate);
813
+ if (breakingIssue)
814
+ return { ok: false, reason: breakingIssue };
815
+ // @implements A-SPEC-182
816
+ // A document whose prose is still the generator's placeholder must not be sealed. Measured
817
+ // 2026-08-13 on a brownfield adoption: H-SPEC-100 took `status: approved` and an
818
+ // `approved_digest` with all seven prose sections reading "TODO — a human writes this" —
819
+ // while its own first line said approving is the point a description becomes normative.
820
+ // Placed on the ACT, not in `validateSpec`, for the reason A-SPEC-146 measured: a static
821
+ // predicate turns every already-approved spec into a violation and bricks the harness.
822
+ const stubs = (0, approval_blockers_1.placeholderSections)(candidate);
823
+ if (stubs.length > 0) {
824
+ return { ok: false, reason: (0, approval_blockers_1.placeholderMessage)(stubs) };
825
+ }
826
+ // Parents-first: a sealed child snapshotting an unsealed parent would pin nothing. This loop
827
+ // exists to COLLECT the digests; the refusal inside it is now a backstop, because
828
+ // `parentBlockers` above already returns for the same condition with the same sentence. Kept
829
+ // rather than deleted so a future change to that function cannot silently let an unsealed
830
+ // parent through — but note the wording lives there, and only there, if it is ever edited.
831
+ const parentDigests = {};
832
+ for (const pid of spec.dependsOn) {
833
+ const parent = specs.find((s) => s.id === pid);
834
+ if (parent && parent.status === 'approved') {
835
+ const parentSeal = (0, spec_digest_1.sealOf)(parent).approvedDigest;
836
+ if (!parentSeal)
837
+ return { ok: false, reason: (0, approval_blockers_1.unsealedParentMessage)(pid) };
838
+ parentDigests[pid] = parentSeal;
839
+ }
840
+ }
841
+ // @implements A-SPEC-135
842
+ // Detect seal MOVEMENT before overwriting: the prior on-disk seal, if any, is what "moved from".
843
+ const priorDigest = (0, spec_digest_1.sealOf)(spec).approvedDigest;
844
+ const digest = (0, spec_digest_1.specDigest)(candidate);
845
+ candidate.frontmatter.approved_digest = digest;
846
+ candidate.frontmatter.parent_digests = parentDigests;
847
+ // @implements A-SPEC-188 — the seal is written only at the version this act READ. On
848
+ // conflict the EDIT wins and the APPROVAL loses: the edit is what a person just wrote; the
849
+ // approval is an act that can simply be retried. The reverse (the old behaviour) sealed
850
+ // stale content over a destroyed edit with no notice.
851
+ try {
852
+ await store.write(candidate, { expectedVersion: cur.version });
853
+ }
854
+ catch (e) {
855
+ if (e instanceof spec_store_2.SpecVersionConflictError) {
856
+ return {
857
+ ok: false,
858
+ reason: `승인 진행 중 ${a.id}이(가) 바뀌었습니다 — 바뀐 내용을 확인하고 다시 승인하십시오.`
859
+ + ' 이번 승인은 아무것도 쓰지 않았습니다.',
860
+ };
861
+ }
862
+ // @implements A-SPEC-188 — approval can RELOCATE the file (req_type classification moves
863
+ // an H-SPEC into its subfolder). Round-3 probed that landing on an unreadable human note
864
+ // destroyed it with success reported; the store now refuses, and this act relays why.
865
+ if (e instanceof spec_store_1.TargetPathOccupiedError) {
866
+ return {
867
+ ok: false,
868
+ reason: `${a.id}의 목적지(${e.occupiedPath})에 스토어가 읽지 못하는 파일이 이미 있습니다 — 덮어쓰지 않습니다.`
869
+ + ' 사람이 확인해 옮기거나 고친 뒤 다시 승인하십시오. 이번 승인은 아무것도 쓰지 않았습니다.',
870
+ };
871
+ }
872
+ throw e;
873
+ }
874
+ // @implements A-SPEC-148 — writes go to THIS replica's chain; the legacy file is read-only now.
875
+ const chain = new ledger_store_1.FileLedgerStore(path.join(ledgerRoot, '.ax', 'ledger'));
876
+ chain.append({
877
+ ts: new Date().toISOString(),
878
+ actor: approval.actor,
879
+ kind: 'spec-approved',
880
+ summary: `approved ${a.id} sealing ${digest}`,
881
+ inputs: [a.id, digest],
882
+ rationale: approval.rationale,
883
+ authorization: (0, provenance_chain_1.authorizationRef)(approval.actor, approval.token),
884
+ });
885
+ // @implements A-SPEC-135
886
+ // P4 routing signal: a re-approval whose content CHANGED (prior seal existed and differs) mints
887
+ // a `review-needed` entry naming the anchored source files — the exact set a targeted review must
888
+ // re-examine against the new content. A first approval or an idempotent re-seal is not drift and
889
+ // mints nothing. This ROUTES review; it never verdicts the code. Best-effort: a missed route is
890
+ // not a broken seal, so an append failure does not fail the approval.
891
+ if (priorDigest && priorDigest !== digest) {
892
+ try {
893
+ const files = (0, review_targets_1.anchoredForReview)(a.id, cachedScan(ledgerRoot, ledgerRoot), specs);
894
+ chain.append({
895
+ ts: new Date().toISOString(), actor: approval.actor, kind: 'review-needed',
896
+ summary: `targeted review needed: ${a.id} content moved — re-examine ${files.length} anchored file(s) against the new spec content`,
897
+ inputs: [a.id, priorDigest, digest, ...files],
898
+ rationale: approval.rationale, authorization: '',
899
+ });
900
+ }
901
+ catch { /* a missed routing signal is not a broken seal */ }
902
+ }
903
+ return { approved: a.id, digest };
904
+ },
905
+ async spec_list(a) {
906
+ const specs = await store.list();
907
+ // @implements A-SPEC-186
908
+ // The parent travels with the id because a bare one cannot be read. Measured: a reader took
909
+ // `T-SPEC-1841` for a count of 1,841 documents. The old-habit ids this repository once held
910
+ // have since all been renamed or upgraded (measured 2026-08-15: zero remain), but ADOPTED
911
+ // repositories still write them, so the parent keeps traveling alongside.
912
+ //
913
+ // Read from `depends_on`, NEVER parsed out of the id — parsing would answer `A-SPEC-129` for
914
+ // a `T-SPEC-1291` that actually hangs off something else, which is the tool committing the
915
+ // very misreading this exists to prevent. Omitted rather than emptied when there is none:
916
+ // an empty value cannot be told apart from "we looked and failed".
917
+ const parentOf = (s) => {
918
+ // @implements A-SPEC-192 §6R (round 10) — the row became format-aware in the `legacy` field
919
+ // only; `parent` still keyed off the DECLARED type, so an old-format document that never
920
+ // declared one showed no parent even when its depends_on names one. The inferred kind
921
+ // answers the same question the legacy mark already asks.
922
+ const state = (0, legacy_format_1.formatStateOf)({ id: s.id, type: s.type });
923
+ const kind = s.type ?? (state.kind === 'legacy' ? state.inferred : undefined);
924
+ const kinds = (0, spec_types_1.specTypeDef)(kind)?.parents ?? [];
925
+ return s.dependsOn?.find((d) => kinds.some((k) => d.startsWith(`${k}-`)));
926
+ };
927
+ // @implements A-SPEC-192 — a non-canonical status must not dress like a governed row:
928
+ // measured 155 of 483 rows ('Approved', 'Proposed', 'deprecated'…) rendered identically to canonical
929
+ // ones, and an adopter read capital-A 'Approved' as an approval. Absence of the key marks the
930
+ // canonical row (the same omission convention as `parent` above). The set derives from the
931
+ // SPEC_STATUSES runtime list — a hand-copy here was a second truth the compiler cannot police.
932
+ const CANONICAL_STATUSES = new Set(spec_types_1.SPEC_STATUSES);
933
+ return {
934
+ specs: specs
935
+ .filter((s) => !a?.type || s.type === a.type)
936
+ .map((s) => {
937
+ const parent = parentOf(s);
938
+ // @implements A-SPEC-192 §5R (round 9) — the mark asks 'is this row a GOVERNED spec?',
939
+ // and status alone could not answer it: an old-format document that never declared a
940
+ // `type` came through with a lone `approved` and dressed exactly like a canonical row —
941
+ // the misreading this REQ exists to prevent. Format and status are both grounds.
942
+ const legacyFormat = (0, legacy_format_1.formatStateOf)({ id: s.id, type: s.type }).kind !== 'current';
943
+ return {
944
+ id: s.id, type: s.type, status: s.status,
945
+ ...(parent ? { parent } : {}),
946
+ ...(CANONICAL_STATUSES.has(s.status) && !legacyFormat ? {} : { legacy: true }),
947
+ };
948
+ }),
949
+ };
950
+ },
951
+ async spec_next() {
952
+ const specs = await store.list();
953
+ // 다음에 작성 가능한 첫 미승인 단계
954
+ for (const t of spec_types_1.SPEC_ORDER) {
955
+ const has = specs.some((s) => s.type === t && s.status === 'approved');
956
+ if (!has)
957
+ return { next: t };
958
+ }
959
+ return { next: null };
960
+ },
961
+ async rtm_check(a) {
962
+ const specs = await store.list();
963
+ // rtm_check runs on the GOVERNED set only (excludes archived legacy +
964
+ // JOB specs, which otherwise flood orphan/dangling results; see
965
+ // ADR-010).
966
+ const issues = (0, rtm_check_1.rtmCheck)((0, spec_types_1.filterGoverned)(specs));
967
+ // @implements A-SPEC-146 — a REPORT, not a verdict: ADR-013 named formulaic `none` a
968
+ // revisit trigger for a human, and `(unset)` shows how far the corpus has converged.
969
+ const breakingChangeDistribution_ = (0, breaking_change_1.breakingChangeDistribution)((0, spec_types_1.filterGoverned)(specs));
970
+ if (a?.root) {
971
+ // @implements A-SPEC-189 §14 (round 13) — the derivation was computed and discarded, so the
972
+ // advertised "a subdirectory resolves up to it" held for `taint_scan` and no one else.
973
+ const root = projectRootOf(a.root);
974
+ // Citation drift: a REQ's cited source may have been edited since it was read, which silently
975
+ // invalidates the requirement derived from it. Only refs that resolve to a file INSIDE the
976
+ // repo are re-hashed — an unresolvable ref (external ticket, no adapter configured) is not
977
+ // evidence of drift, and a path escaping the repo is not ours to read.
978
+ const readLocal = (ref) => {
979
+ const abs = path.resolve(root, ref);
980
+ if (!abs.startsWith(path.resolve(root) + path.sep))
981
+ return null;
982
+ try {
983
+ return fs.readFileSync(abs, 'utf8');
984
+ }
985
+ catch {
986
+ return null;
987
+ }
988
+ };
989
+ for (const s of specs) {
990
+ if (s.type !== 'REQ' || s.frontmatter.source == null)
991
+ continue;
992
+ for (const d of (0, validator_1.verifyCitationDigests)(s.frontmatter.source, readLocal)) {
993
+ issues.push({ level: d.level, code: d.code, message: `${s.id}: ${d.message}` });
994
+ }
995
+ }
996
+ const scanned = cachedScan(root, root);
997
+ // checkImplements checks @implements A-SPEC refs against the FULL
998
+ // spec id set (not filterGoverned) — it's an existence check: any
999
+ // existing spec id is a valid anchor target.
1000
+ issues.push(...(0, rtm_check_1.checkImplements)(scanned, specs));
1001
+ // L6 convergence gaps (ADVISORY, not issues): where implementation and the governed spec set
1002
+ // have drifted apart — unimplemented approved specs, unanchored source, untested specs. Feeds
1003
+ // the convergence loop's "pick next work"; an unimplemented spec is normal mid-development.
1004
+ const gaps = (0, gap_analyzer_1.computeConvergenceGaps)(scanned, (0, spec_types_1.filterGoverned)(specs), (0, test_scope_1.scanTestAnchors)(root));
1005
+ return { issues, gaps, breakingChangeDistribution: breakingChangeDistribution_ };
1006
+ }
1007
+ return { issues, breakingChangeDistribution: breakingChangeDistribution_ };
1008
+ },
1009
+ /**
1010
+ * Pin a REQ's citations: compute the content digest of every cited source that resolves inside
1011
+ * the repo and record it as `rev`, so an author never hashes a file by hand and the digest is
1012
+ * always derived from what is actually on disk.
1013
+ *
1014
+ * Explicit-invocation only, dry-run by DEFAULT, and never overwrites an existing digest — a
1015
+ * re-pin would replace evidence of upstream drift with a fresh-looking value, converting the
1016
+ * detector into a concealer.
1017
+ */
1018
+ async citation_pin(a) {
1019
+ // @implements A-SPEC-189 §15 (round 13) — every sibling that reads or writes on behalf of a
1020
+ // project refuses a foreign root (spec_create, phase_status, review_record, review_status,
1021
+ // spec_approve). `citation_pin` did not, and it SEALS what it read: the sha256 of another
1022
+ // project's file was written into this store's REQ frontmatter as evidence, permanently —
1023
+ // the design deliberately never overwrites an existing digest.
1024
+ const foreignPin = foreignRootReason(store, a.root);
1025
+ if (foreignPin !== null)
1026
+ return { ok: false, reason: `citation_pin: ${foreignPin}` };
1027
+ // @implements A-SPEC-189 §14 (round 13) — the derivation was computed and discarded, so the
1028
+ // advertised "a subdirectory resolves up to it" held for `taint_scan` and no one else.
1029
+ const root = projectRootOf(a.root);
1030
+ // @implements A-SPEC-188 — the third writer, same duplicate hazard as approve/upgrade:
1031
+ // read() resolves the last-walked copy and the write's orphan sweep deletes the other.
1032
+ if ((await store.list()).filter((s) => s.id === a.id).length > 1) {
1033
+ return {
1034
+ ok: false,
1035
+ reason: `${a.id}이(가) 스토어에 두 번 이상 존재합니다 — 어느 사본이 진본인지 도구가 고를 수 없어 기록 전에 거부합니다.`
1036
+ + ' doctor로 중복 파일을 확인해 하나로 정리한 뒤 다시 시도하십시오.',
1037
+ };
1038
+ }
1039
+ // @implements A-SPEC-151
1040
+ // `read` now yields the spec together with the version it was read at, so a write can say
1041
+ // "only if nobody touched this since". citation_pin uses it: the pin is derived from what was
1042
+ // read, and writing it back over someone else's edit would silently discard their change.
1043
+ const found = await store.read(a.id);
1044
+ if (!found)
1045
+ return { ok: false, reason: `spec ${a.id} not found` };
1046
+ const { spec, version } = found;
1047
+ if (spec.type !== 'REQ')
1048
+ return { ok: false, reason: `citations live on REQ; ${a.id} is ${spec.type}` };
1049
+ if (spec.frontmatter.source == null)
1050
+ return { ok: false, reason: `${a.id} has no source to pin` };
1051
+ const readLocal = (ref) => {
1052
+ const abs = path.resolve(root, ref);
1053
+ // A ref that escapes the repository is not ours to read, and following it would let a spec
1054
+ // pull arbitrary host files into the provenance record.
1055
+ if (!abs.startsWith(path.resolve(root) + path.sep))
1056
+ return null;
1057
+ try {
1058
+ return fs.readFileSync(abs, 'utf8');
1059
+ }
1060
+ catch {
1061
+ return null;
1062
+ }
1063
+ };
1064
+ const { citations, pinned, findings } = (0, validator_1.pinCitations)(spec.frontmatter.source, readLocal);
1065
+ const dryRun = a.dryRun !== false; // opt IN to writing
1066
+ if (!dryRun && pinned.length > 0) {
1067
+ try {
1068
+ try {
1069
+ await store.write({ ...spec, frontmatter: { ...spec.frontmatter, source: citations } }, { expectedVersion: version });
1070
+ }
1071
+ catch (e) {
1072
+ if (e instanceof spec_store_1.TargetPathOccupiedError) {
1073
+ return {
1074
+ ok: false,
1075
+ reason: `${a.id}의 목적지(${e.occupiedPath})에 스토어가 읽지 못하는 파일이 이미 있습니다 — 덮어쓰지 않습니다.`
1076
+ + ' 사람이 확인해 옮기거나 고친 뒤 다시 시도하십시오.',
1077
+ };
1078
+ }
1079
+ throw e;
1080
+ }
1081
+ }
1082
+ catch (err) {
1083
+ if (err instanceof spec_store_2.SpecVersionConflictError) {
1084
+ return { ok: false, reason: `${a.id} changed while its citations were being pinned — re-read and retry (${err.message})` };
1085
+ }
1086
+ throw err;
1087
+ }
1088
+ }
1089
+ return { ok: true, id: a.id, dryRun, pinned, findings, citations };
1090
+ },
1091
+ async phase_status(a) {
1092
+ // @implements A-SPEC-189 §6 — `root` was advertised and never read, so a call naming another
1093
+ // project (or a path that does not exist) got THIS server's corpus in a success shape and the
1094
+ // caller derived its phase from someone else's specs. Same anchor discipline as spec_approve.
1095
+ if (typeof a?.root === 'string' && a.root !== '' && store instanceof spec_store_1.LocalMarkdownRepository) {
1096
+ let asked;
1097
+ let bound;
1098
+ let derivationFoundMarker = false;
1099
+ try {
1100
+ asked = (0, write_target_1.resolveTarget)(projectRootOf(a.root), '.');
1101
+ const derived = projectRootOf(store.specsRoot);
1102
+ derivationFoundMarker = fs.existsSync(path.join(derived, '.ax'));
1103
+ bound = (0, write_target_1.resolveTarget)(derived, '.');
1104
+ }
1105
+ catch (e) {
1106
+ return { ok: false, reason: `root를 해석할 수 없습니다: ${String(e.message)}` };
1107
+ }
1108
+ // @implements A-SPEC-189 §7 (round 10) — spec_approve's round-3 lesson, copied here at last:
1109
+ // resolveProjectRoot returns its INPUT when no `.ax` ancestor exists, so a custom
1110
+ // HOLMES_SPECS outside any project "derived" the spec store itself as the project and this
1111
+ // guard then refused the CORRECT root. Only a derivation that actually found a marker
1112
+ // speaks for a project.
1113
+ if (derivationFoundMarker && asked !== bound) {
1114
+ return {
1115
+ ok: false,
1116
+ reason: `이 서버는 ${bound} 프로젝트에 바인딩되어 있습니다 — 요청한 root ${a.root}는 ${asked} 를 가리킵니다.`
1117
+ + ' 다른 프로젝트의 코퍼스를 이 서버가 대신 답하지 않습니다.',
1118
+ };
1119
+ }
1120
+ }
1121
+ const specs = await store.list();
1122
+ return { specs: specs.map((s) => ({ id: s.id, type: s.type, status: s.status })), note: 'derive phase via phase_check' };
1123
+ },
1124
+ async phase_check(a) {
1125
+ const specs = await store.list();
1126
+ const action = a.action ?? (0, phase_1.classifyAction)(a.target);
1127
+ if (!action)
1128
+ return { decision: 'allow', note: 'unclassified target' };
1129
+ return (0, phase_1.phaseCheck)(action, { specs, targetAspecId: a.targetAspecId });
1130
+ },
1131
+ async cpg_scan(a) {
1132
+ // @implements A-SPEC-189 §14 (round 13) — the derivation was computed and discarded, so the
1133
+ // advertised "a subdirectory resolves up to it" held for `taint_scan` and no one else.
1134
+ const root = projectRootOf(a.root);
1135
+ // @implements A-SPEC-131
1136
+ // The skip report is part of the answer: a caller cannot distinguish "clean tree" from "tree
1137
+ // with casualties" by counts alone. Bounded listing (50), complete count.
1138
+ const { scanned, skipped } = cachedScanWithReport(root);
1139
+ const symbols = scanned.reduce((n, f) => n + f.symbols.length, 0);
1140
+ return { files: scanned.length, symbols, skipped: skipped.slice(0, 50), skippedCount: skipped.length };
1141
+ },
1142
+ /**
1143
+ * @implements A-SPEC-138
1144
+ * Call-graph taint REACHABILITY screen: source-named functions that reach sink-named functions
1145
+ * through call edges. A SCREENING signal that routes a security review, NOT a data-flow proof —
1146
+ * the result always carries the honesty envelope (kind + limits) and phrases pairs as "reaches",
1147
+ * never "vulnerable".
1148
+ */
1149
+ async taint_scan(a) {
1150
+ const root = projectRootOf(a.root);
1151
+ const scanned = cachedScan(root, root);
1152
+ const g = new rtm_graph_1.RtmGraph();
1153
+ try {
1154
+ (0, rtm_builder_1.buildRtm)(await store.list(), scanned, g);
1155
+ const cfg = taint_1.DEFAULT_TAINT_CONFIG;
1156
+ const { pairs, truncated } = (0, taint_1.taintReachability)(g, cfg);
1157
+ return { kind: 'call-reachability', limits: [...taint_1.TAINT_LIMITS], maxPaths: cfg.maxPaths, truncated, pairs };
1158
+ }
1159
+ finally {
1160
+ g.close();
1161
+ }
1162
+ },
1163
+ async test_run(a) {
1164
+ // Closes the decision->execution loop: scope -> run -> durable per-A-SPEC EXECUTION evidence
1165
+ // (what the constitution's ART-4 prefers over the syntactic count).
1166
+ const { root, specs, scanned, changedSymbols, changeSource, scopeFallback, anchorImpactedSpecs, changedTestFiles, unresolvedFiles } = await deriveChangedContext(store, a.root, a, 'test_run');
1167
+ const g = new rtm_graph_1.RtmGraph();
1168
+ let testScope;
1169
+ try {
1170
+ (0, rtm_builder_1.buildRtm)(specs, scanned, g);
1171
+ const scope = (0, scope_1.computeReviewScope)(g, specs, changedSymbols);
1172
+ // @implements A-SPEC-130
1173
+ // Lane 2 widens the impacted set with file-anchor edges the symbol walk cannot see; lanes 3
1174
+ // and the unresolved remainder ride in through extras. A nonempty change set can end in a
1175
+ // scoped run or a full run — never in "nothing to do".
1176
+ const widenedImpacted = [...new Set([...scope.impactedSpecs, ...anchorImpactedSpecs])];
1177
+ testScope = (0, test_scope_1.computeTestScope)(widenedImpacted, (0, test_scope_1.scanTestAnchors)(root), specs, scope.coverageGaps, undefined, { changedTestFiles, unresolvedFiles });
1178
+ // @implements A-SPEC-128
1179
+ // No change set means no basis for narrowing. Widening is the same fail-safe direction
1180
+ // computeTestScope already takes when no anchored test resolves — never a silently narrow run.
1181
+ if (scopeFallback === 'full') {
1182
+ testScope = { ...testScope, tier: 'full', reason: `${changeSource.reason ?? 'no change set'} — cannot narrow safely, full regression` };
1183
+ }
1184
+ }
1185
+ finally {
1186
+ g.close();
1187
+ }
1188
+ const result = (0, test_runner_1.runTestScope)(testScope, root);
1189
+ const anchors = (0, test_scope_1.scanTestAnchors)(root);
1190
+ const executedByAspec = (0, test_evidence_1.computeExecutedByAspec)(result.executedByFile ?? {}, anchors);
1191
+ let head = '';
1192
+ try {
1193
+ head = (0, node_child_process_1.execFileSync)('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
1194
+ }
1195
+ catch { /* non-git */ }
1196
+ // Record ONLY an actually-executed, GREEN run (review C4/C7): a red suite must not stand as
1197
+ // coverage evidence at the moment the code is broken, and a no-op run must not overwrite a real
1198
+ // record with a dishonest `passed: true` over an empty result.
1199
+ const verified = (0, baseline_1.shouldRecordBaseline)(result);
1200
+ if (verified) {
1201
+ (0, test_evidence_1.writeTestEvidence)(root, { ts: new Date().toISOString(), head, tier: testScope.tier, passed: true, executedByAspec });
1202
+ }
1203
+ // @implements A-SPEC-128
1204
+ // The baseline is written under EXACTLY the condition that already gates evidence: a run that
1205
+ // actually executed and passed. A red or skipped run must never become the reference point for
1206
+ // "since the last verified state" — that would silently narrow every later scope against a
1207
+ // state nobody verified.
1208
+ let baseline;
1209
+ if (verified) {
1210
+ baseline = a.mark ?? DEFAULT_BASELINE;
1211
+ (0, baseline_1.writeBaseline)(root, baseline, (0, change_source_1.hashTree)(root, { isIgnored: (p) => (0, ignore_1.loadIgnore)(root).isIgnored(p) }));
1212
+ }
1213
+ return { tier: testScope.tier, mode: result.mode, passed: result.passed, skipped: result.skipped,
1214
+ ranFiles: result.ranFiles, executedByAspec, tail: result.tail,
1215
+ // @implements A-SPEC-130 — the remediation rides in the answer: these are the files to anchor.
1216
+ unresolvedFiles: testScope.unresolvedFiles,
1217
+ changeSource, ...(scopeFallback ? { scopeFallback } : {}), ...(baseline ? { baselineRecorded: baseline } : {}) };
1218
+ },
1219
+ async issue_localize(a) {
1220
+ // N1: deterministic localization report — CPG lexical match fused with the RTM spec hop.
1221
+ // @implements A-SPEC-189 §14 (round 13) — `projectRootOf(a.root)` was called as a bare
1222
+ // statement: its only effect was to throw on a bad path, and the advertised contract ("a
1223
+ // subdirectory resolves up to it") was dropped on the floor. `taint_scan` one function away
1224
+ // honours it, so the same argument answered about two different trees depending on which tool
1225
+ // was asked. Bind the derivation and use it.
1226
+ const root = projectRootOf(a.root);
1227
+ const scanned = cachedScan(root, root);
1228
+ const specs = await store.list();
1229
+ return (0, localize_1.localizeIssue)(a.issue, scanned, (0, spec_types_1.filterGoverned)(specs), a.topN ?? 10);
1230
+ },
1231
+ async rtm_impact(a) {
1232
+ // @implements A-SPEC-189 §14 (round 13) — `projectRootOf(a.root)` was called as a bare
1233
+ // statement: its only effect was to throw on a bad path, and the advertised contract ("a
1234
+ // subdirectory resolves up to it") was dropped on the floor. `taint_scan` one function away
1235
+ // honours it, so the same argument answered about two different trees depending on which tool
1236
+ // was asked. Bind the derivation and use it.
1237
+ const root = projectRootOf(a.root);
1238
+ const scanned = cachedScan(root);
1239
+ const specs = await store.list();
1240
+ const g = new rtm_graph_1.RtmGraph();
1241
+ try {
1242
+ (0, rtm_builder_1.buildRtm)(specs, scanned, g);
1243
+ // explainImpact, not impactedBy: the bounds and the breadth signal must reach the caller.
1244
+ // An impact set is not just a list — a broad one means "review the contract", and a consumer
1245
+ // that cannot tell the difference will try to bundle two hundred call sites.
1246
+ const { specs: impacted, reachedByDepth, stoppedAt, seedIsHub } = (0, rtm_builder_1.explainImpact)(g, a.changed);
1247
+ return {
1248
+ impacted,
1249
+ reachedByDepth,
1250
+ bounded: stoppedAt.length > 0 ? stoppedAt.slice(0, 20) : undefined,
1251
+ ...(seedIsHub ? {
1252
+ breadthWarning: `a changed symbol has ${seedIsHub.callers} callers (hub threshold ${seedIsHub.threshold}) — this is a CONTRACT change; review the contract and its tests rather than bundling every impacted spec`,
1253
+ } : {}),
1254
+ };
1255
+ }
1256
+ finally {
1257
+ g.close(); // release native SQLite handle even if build/query throws
1258
+ }
1259
+ },
1260
+ async rtm_reindex(a) {
1261
+ // Removal/re-derivation of changedFiles/changedSymbols is not needed
1262
+ // here (rtm_reindex works off changes/specs/scanned/bySourcePath
1263
+ // directly), but the guard+diff+scan prologue is identical to
1264
+ // review_scope/review_prepare — see deriveChangedContext.
1265
+ const { root, changes, specs, scanned, bySourcePath, changeSource } = await deriveChangedContext(store, a.root, a, 'rtm_reindex');
1266
+ const changed = changes.added.length + changes.modified.length + changes.deleted.length + changes.renamed.length;
1267
+ const scanOne = (relPath) => bySourcePath.get(relPath) ?? null;
1268
+ // HashCache skip: for add/modify entries, if the file's current content
1269
+ // hash matches what's on record, the file is dropped from the set
1270
+ // handed to applyIncremental — its subgraph is left untouched rather
1271
+ // than being torn down and rebuilt for no reason (e.g. a spurious
1272
+ // `git diff` entry, a mode-only change, or a re-run over the same
1273
+ // range). Deletes and renames are always applied: a delete has no
1274
+ // "content" to compare, and a rename's `from` side must be removed
1275
+ // regardless of whether `to`'s content matches something previously
1276
+ // hashed under a different path.
1277
+ const cache = new hash_cache_1.HashCache(cacheDirFor(root)); // @implements A-SPEC-128 — project-root cache, no subdir leak
1278
+ const skipUnchanged = (files) => files.filter((relPath) => {
1279
+ let content;
1280
+ try {
1281
+ content = fs.readFileSync(path.join(root, relPath), 'utf8');
1282
+ }
1283
+ catch {
1284
+ return true; // unreadable (e.g. already gone) — let applyIncremental/scanOne handle it
1285
+ }
1286
+ if (cache.unchanged(relPath, content))
1287
+ return false; // skip: content identical to last recorded hash
1288
+ cache.put(relPath, content);
1289
+ return true;
1290
+ });
1291
+ const changesToApply = {
1292
+ added: skipUnchanged(changes.added),
1293
+ modified: skipUnchanged(changes.modified),
1294
+ deleted: changes.deleted,
1295
+ renamed: changes.renamed,
1296
+ };
1297
+ const g = new rtm_graph_1.RtmGraph();
1298
+ try {
1299
+ (0, rtm_builder_1.buildRtm)(specs, scanned, g);
1300
+ // Scaffold note: `g` was just full-built at HEAD, so applying the
1301
+ // diff on top of it is currently an idempotent no-op in practice
1302
+ // (the affected subgraphs are already correct). This call exists to
1303
+ // exercise/validate the incremental machinery end-to-end now, ahead
1304
+ // of a FUTURE persisted-graph reindex — build the graph once, then
1305
+ // apply diffs across calls without a full rebuild — that will
1306
+ // actually depend on this path being correct. Not load-bearing today.
1307
+ (0, incremental_1.applyIncremental)(g, changesToApply, { repoRoot: root, specs, scanOne });
1308
+ return { changed, nodes: g.nodeCount(), edges: g.edgeCount(), changeSource };
1309
+ }
1310
+ finally {
1311
+ g.close(); // release native SQLite handle even if build/apply throws
1312
+ }
1313
+ },
1314
+ async context_bundle(a) {
1315
+ const foreign = foreignRootReason(store, a.root);
1316
+ if (foreign)
1317
+ throw new HandlerRefusal(foreign);
1318
+ const root = projectRootOf(a.root);
1319
+ const specs = await store.list();
1320
+ const scanned = cachedScan(root, root);
1321
+ const content = buildContentSource(specs, scanned);
1322
+ const g = new rtm_graph_1.RtmGraph();
1323
+ try {
1324
+ (0, rtm_builder_1.buildRtm)(specs, scanned, g);
1325
+ return new bundler_1.ContextBundler(g, content).getContextBundle(a.seedId, a.budget);
1326
+ }
1327
+ finally {
1328
+ g.close(); // release native SQLite handle even if bundling throws
1329
+ }
1330
+ },
1331
+ async review_scope(a) {
1332
+ // Guard+diff+scan+changedFiles/changedSymbols prologue: see
1333
+ // deriveChangedContext (shared with review_prepare/rtm_reindex).
1334
+ const { root, specs, scanned, changedFiles, changedSymbols, changeSource, scopeFallback, anchorImpactedSpecs, changedTestFiles, unresolvedFiles } = await deriveChangedContext(store, a.root, a, 'review_scope');
1335
+ const g = new rtm_graph_1.RtmGraph();
1336
+ try {
1337
+ (0, rtm_builder_1.buildRtm)(specs, scanned, g);
1338
+ const scope = (0, scope_1.computeReviewScope)(g, specs, changedSymbols);
1339
+ // Intelligent regression test-scope (target-arch §5). Test files are scanned SEPARATELY for
1340
+ // their @implements anchors (the CPG scan excludes tests), coverage gaps feed the safety
1341
+ // fallback, and impactedSpecs are the graph's SPEC:-prefixed ids (stripped inside).
1342
+ // @implements A-SPEC-130 — the same widened set as test_run, so review packaging and test
1343
+ // scoping cannot disagree about what a change touches.
1344
+ const widenedImpacted = [...new Set([...scope.impactedSpecs, ...anchorImpactedSpecs])];
1345
+ const testScope = (0, test_scope_1.computeTestScope)(widenedImpacted, (0, test_scope_1.scanTestAnchors)(root), specs, scope.coverageGaps, undefined, { changedTestFiles, unresolvedFiles });
1346
+ // Honest signal (REQ-124 gate 2a): which changed files the CpgScanner
1347
+ // did NOT ingest (e.g. non-.ts files), computed from the same
1348
+ // changedFiles/scanned already derived above — no new git/scan calls.
1349
+ const { unscanned: unscannedChangedFiles } = (0, coverage_1.partitionChangedFiles)(changedFiles, scanned.map((f) => f.sourcePath));
1350
+ return { ...scope, unscannedChangedFiles, testScope, changeSource, ...(scopeFallback ? { scopeFallback } : {}) };
1351
+ }
1352
+ finally {
1353
+ g.close(); // release native SQLite handle even if scope computation throws
1354
+ }
1355
+ },
1356
+ async review_prepare(a) {
1357
+ // Guard+diff+scan+changedFiles/changedSymbols prologue: see
1358
+ // deriveChangedContext (shared with review_scope/rtm_reindex).
1359
+ const { root, specs, scanned, changedFiles, changedSymbols, changeSource, scopeFallback } = await deriveChangedContext(store, a.root, a, 'review_prepare');
1360
+ // ContentSource factory shared with context_bundle: see buildContentSource.
1361
+ const content = buildContentSource(specs, scanned);
1362
+ const g = new rtm_graph_1.RtmGraph();
1363
+ try {
1364
+ (0, rtm_builder_1.buildRtm)(specs, scanned, g);
1365
+ const pkg = (0, package_1.assembleReviewPackage)(g, specs, changedSymbols, content, a.budget ?? 4000);
1366
+ // Honest signal (REQ-124 gate 2a): which changed files the CpgScanner
1367
+ // did NOT ingest, computed from the same changedFiles/scanned already
1368
+ // derived above — no new git/scan calls.
1369
+ const { unscanned: unscannedChangedFiles } = (0, coverage_1.partitionChangedFiles)(changedFiles, scanned.map((f) => f.sourcePath));
1370
+ return { package: pkg, unscannedChangedFiles, changeSource, ...(scopeFallback ? { scopeFallback } : {}) };
1371
+ }
1372
+ finally {
1373
+ g.close(); // release native SQLite handle even if assembly throws
1374
+ }
1375
+ },
1376
+ async review_record(a) {
1377
+ // @implements A-SPEC-189 §8 (round 11) — §15 shut the door the merge gate READS through and
1378
+ // left the one it is WRITTEN through wide open. Measured: `review_record` joined the caller's
1379
+ // `root` straight into a path and created `.ax/ledger/findings.jsonl` in any directory on the
1380
+ // machine, answering `{recorded: N}` — while `review_status`, bound to this server's project,
1381
+ // could never see those findings. An open critical filed at the wrong root is worse than none:
1382
+ // it reads as recorded and gates nothing. The two doors now ask the same question.
1383
+ const foreignWrite = foreignRootReason(store, a.root);
1384
+ if (foreignWrite !== null)
1385
+ throw new HandlerRefusal(`review_record: ${foreignWrite}`);
1386
+ // MCP input is unvalidated JSON: an out-of-enum severity or status
1387
+ // (e.g. a typo like "blocker") would land in the ledger as a stray
1388
+ // value that review_status's counts never match, so it counts toward
1389
+ // neither its own bucket nor `blocked` — a mislabeled critical could
1390
+ // silently pass the gate. Validate up front so the gate's counts stay
1391
+ // trustworthy.
1392
+ const validSeverities = new Set(['critical', 'important', 'minor']);
1393
+ const validStatuses = new Set(['open', 'resolved']);
1394
+ for (const f of a.findings) {
1395
+ if (!validSeverities.has(f.severity)) {
1396
+ throw new HandlerRefusal(`review_record: finding ${f.id} has invalid severity ${JSON.stringify(f.severity)} (must be one of critical|important|minor)`);
1397
+ }
1398
+ if (!validStatuses.has(f.status)) {
1399
+ throw new HandlerRefusal(`review_record: finding ${f.id} has invalid status ${JSON.stringify(f.status)} (must be one of open|resolved)`);
1400
+ }
1401
+ }
1402
+ // @implements A-SPEC-191 (§4e) — the ledger's meaning is guarded at RECORD time, where the
1403
+ // collision actually happens. Measured: conventional ids (C1…) recur across review rounds, so
1404
+ // one round's resolve of "C1" silently lifted the gate over ANOTHER round's unfixed critical;
1405
+ // and a severity-downgrade re-open cleared ART-7 with no resolution at all. Both die at one
1406
+ // door: an id whose latest state is open cannot be opened again — resolve first, then
1407
+ // re-record. Lifting an open CRITICAL requires the out-of-band approval channel (ART-5's
1408
+ // principle): the blocked party must not be able to pull its own teeth in-band. A resolved
1409
+ // record for an id never opened lifts nothing and stays approval-free (found-and-fixed audit
1410
+ // records keep working).
1411
+ // The guard and the append hold ONE lock (round-6: the read→judge→append section was a
1412
+ // cross-process TOCTOU — two sessions both recorded open under the same id, silencing an
1413
+ // open critical at both readers; consumeNonceExclusively already earned this discipline).
1414
+ const findingsFile = boundFindingsLedger(store, a.root);
1415
+ (0, ledger_lock_1.withLedgerLock)(findingsFile, () => {
1416
+ const ledger = new findings_1.FindingsLedger(findingsFile);
1417
+ const latest = new Map();
1418
+ const liftedCriticals = [];
1419
+ for (const f of ledger.list())
1420
+ latest.set(f.id, f);
1421
+ for (const f of a.findings) {
1422
+ if (!f.id || !f.id.trim()) {
1423
+ throw new HandlerRefusal('review_record: finding id 가 비어 있습니다 — 모든 발견은 원장에서 유일하게 식별될 id 가 필요합니다');
1424
+ }
1425
+ // Round-5: pre-round-4 ads declared `message`, but the ledger whitelist persists only
1426
+ // `summary` — clients following the OLD ad lost their text with zero signal. A pointed
1427
+ // refusal turns that silent loss into the one-line fix it needs.
1428
+ // Round-9: `=== undefined` let `summary: null` through, so the ledger dropped the text
1429
+ // and the caller saw success — the very silence this guard exists to end. Absence is
1430
+ // null OR undefined here, as everywhere else in this validator's vocabulary.
1431
+ if ('message' in f && (f.summary === undefined || f.summary === null)) {
1432
+ throw new HandlerRefusal(`review_record: finding ${f.id} 의 message 는 폐지된 광고 키입니다 — 원장은 summary 만 보존합니다. 같은 내용을 summary 로 보내십시오`);
1433
+ }
1434
+ const last = latest.get(f.id);
1435
+ if (f.status === 'open' && last?.status === 'open') {
1436
+ throw new HandlerRefusal(`review_record: id ${f.id} 는 이미 미해소(open) 상태입니다 — 다른 발견이면 새 id 를 쓰고, 같은 발견의 갱신이면 먼저 resolved 를 기록한 뒤 재기록하십시오 (id 충돌이 남의 critical 을 침묵시키는 것을 막는 문입니다)`);
1437
+ }
1438
+ if (f.status === 'resolved' && last?.status === 'open' && last.severity === 'critical') {
1439
+ const raw = process.env.HOLMES_APPROVAL;
1440
+ let approval;
1441
+ try {
1442
+ approval = raw ? JSON.parse(raw) : undefined;
1443
+ }
1444
+ catch {
1445
+ approval = undefined;
1446
+ }
1447
+ // covers, not merely well-formed (round-2): A-SPEC-133 built the seam so one token is
1448
+ // not a master key — an EXPIRED or elsewhere-scoped approval must not lift a critical.
1449
+ // An unscoped {actor,token,rationale} stays the operator's session key (unchanged).
1450
+ const nowTs = new Date().toISOString();
1451
+ if (!(0, risk_gate_1.approvalCovers)(approval, { kind: 'review-resolve', target: f.id }, nowTs)) {
1452
+ throw new HandlerRefusal(`review_record: id ${f.id} 의 열린 치명 발견을 해소하는 기록은 이 행위를 덮는 유효한 대역외 승인이 필요합니다 — 차단당한 쪽이 스스로 이빨을 뽑을 수 없어야 하고, 만료·다른 범위의 승인은 덮지 않습니다. HOLMES_APPROVAL='{"actor":"<you>","token":"<any>","rationale":"<why fixed>"}' (범위를 쓰면 kind "review-resolve") 를 서버 환경에 설정하고 다시 기록하십시오`);
1453
+ }
1454
+ // @implements A-SPEC-191 §11 — 한 배치가 같은 id 를 다시 열고 다시 닫으면 lift 는 두 번
1455
+ // 일어난다. 소비는 호출당 1회이므로, 세 번의 호출이면 거부됐을 일이 한 배치에서는
1456
+ // 통과했다(실측: 최종 상태와 게이트 판정이 갈렸다).
1457
+ if (liftedCriticals.includes(f.id)) {
1458
+ throw new HandlerRefusal(`review_record: 한 배치에서 같은 발견(${f.id})의 치명 해소를 두 번 들 수 없습니다 — 승인은 행위마다 필요합니다`);
1459
+ }
1460
+ liftedCriticals.push(f.id);
1461
+ }
1462
+ latest.set(f.id, f); // 한 배치 안의 순서도 기록 순서다
1463
+ }
1464
+ // A-SPEC-133 promises nonce = single-use "consumed on the ledger, a replay is denied".
1465
+ // Consumed AFTER the whole batch validates and ONCE per call (round-4: consuming inside
1466
+ // the loop burned the grant when a LATER row failed validation — the audit trail asserted
1467
+ // an act that never happened, the retry was refused, and two resolves in one batch would
1468
+ // have double-spent). Consumption directly precedes the append; the only failure between
1469
+ // them is the append itself, which throws loudly.
1470
+ if (liftedCriticals.length > 0) {
1471
+ const raw = process.env.HOLMES_APPROVAL;
1472
+ let approval;
1473
+ try {
1474
+ approval = raw ? JSON.parse(raw) : undefined;
1475
+ }
1476
+ catch {
1477
+ approval = undefined;
1478
+ }
1479
+ if ((0, provenance_chain_1.blankNonce)(approval?.nonce)) {
1480
+ throw new HandlerRefusal('review_record: 승인이 단일 사용(nonce)을 선언했으나 값이 비어 있습니다 — 1회성을 집행할 수 없어 거부합니다');
1481
+ }
1482
+ // @implements A-SPEC-191 §13 — 1회용 승인은 한 번의 행위를 authorize 한다. 9라운드
1483
+ // 실측: 서로 다른 id 의 치명 해소 N 건이 한 배치에서 nonce 한 장으로 통과했고,
1484
+ // 같은 세 건을 세 호출로 나누면 두 번째부터 거부됐다 — 판정이 묶음 방식에 의존했다.
1485
+ if ((0, provenance_chain_1.nonceDeclared)(approval?.nonce) && liftedCriticals.length > 1) {
1486
+ throw new HandlerRefusal(`review_record: 단일 사용 승인(nonce)은 한 건의 치명 해소만 authorize 합니다 — 이 배치는 ${liftedCriticals.length}건(${liftedCriticals.join(', ')})을 듭니다. 한 건씩 보내십시오`);
1487
+ }
1488
+ if ((0, provenance_chain_1.nonceDeclared)(approval?.nonce)) {
1489
+ // @implements A-SPEC-191 §10 — the same bound anchor as risk_check: two consumers must
1490
+ // spend into ONE ledger, or a nonce spent here reopens the gate there (and back).
1491
+ const ledgerFile = boundNonceLedger(store);
1492
+ if (ledgerFile === null) {
1493
+ throw new HandlerRefusal('review_record: 단일 사용 승인(nonce)을 기록할 프로젝트 원장을 확정할 수 없습니다 — 1회성을 보장할 수 없어 거부합니다');
1494
+ }
1495
+ const won = (0, provenance_chain_1.consumeNonceExclusively)(String(approval.nonce), ledgerFile, {
1496
+ ts: new Date().toISOString(), actor: approval.actor, kind: 'nonce-consumed',
1497
+ summary: `consumed single-use approval for: review-resolve ${liftedCriticals.join(', ')}`.slice(0, 200),
1498
+ inputs: [(0, provenance_chain_1.nonceFingerprint)(String(approval.nonce))], rationale: approval.rationale,
1499
+ authorization: (0, provenance_chain_1.authorizationRef)(approval.actor, approval.token),
1500
+ });
1501
+ if (!won) {
1502
+ throw new HandlerRefusal(`review_record: 단일 사용 승인(nonce)이 이미 소비되었습니다 — 재사용은 거부됩니다. 새 승인을 발급받으십시오`);
1503
+ }
1504
+ }
1505
+ }
1506
+ // @implements A-SPEC-157 — the server's OWN observation, not anything the caller sent.
1507
+ // @implements A-SPEC-160 — the ONLY caller that reads the on-disk build, so the divergence
1508
+ // marker can be sealed. Append INSIDE the same lock as the guard (round-6 TOCTOU).
1509
+ ledger.record(a.findings, (0, basis_1.basisDigest)(basisFor(a.root, true)));
1510
+ });
1511
+ return { recorded: a.findings.length };
1512
+ },
1513
+ async review_status(a) {
1514
+ // @implements A-SPEC-191 §15 — the merge gate must not answer about a project it did not read.
1515
+ const foreign = foreignRootReason(store, a.root);
1516
+ if (foreign !== null)
1517
+ throw new HandlerRefusal(`review_status: ${foreign}`);
1518
+ // @implements A-SPEC-191 (§4f) — counted on the LATEST record per id, the same folding the
1519
+ // constitution's ART-7 uses. Line-based counting could never decrease on an append-only
1520
+ // ledger, so the canonical exit (a resolved line) left `blocked: true` forever while the Stop
1521
+ // gate passed — two official verdicts about one ledger, disagreeing permanently (measured).
1522
+ const all = new findings_1.FindingsLedger(boundFindingsLedger(store, a.root)).list();
1523
+ const latest = new Map();
1524
+ // Severity of the LATEST open row per id — a forever-sticky "ever critical" set resurrected
1525
+ // a properly-closed critical when a later minor re-finding got a diverged resolve (r6).
1526
+ const lastOpenCritical = new Map();
1527
+ for (const f of all) {
1528
+ latest.set(f.id, f);
1529
+ if (f.status === 'open')
1530
+ lastOpenCritical.set(f.id, f.severity === 'critical');
1531
+ }
1532
+ // SAME ledger meaning as ART-7 (round-3: the two official readers disagreed): a resolution
1533
+ // sealed on a diverged build does not close a critical here either. What it keeps open is
1534
+ // the CRITICAL it failed to close — bucketing by the resolved line's own severity let a
1535
+ // severity-downgraded diverged resolve report blocked:false while the Stop gate blocked
1536
+ // (round-4 HIGH), and a severity-less row minted an "undefined" bucket.
1537
+ const counts = { critical: 0, important: 0, minor: 0 };
1538
+ for (const f of latest.values()) {
1539
+ if (f.status === 'open') {
1540
+ if (Object.prototype.hasOwnProperty.call(counts, f.severity))
1541
+ counts[f.severity]++; // in-체크는 프로토타입 키(toString)로 새었다(r6)
1542
+ continue;
1543
+ }
1544
+ if (f.status === 'resolved' && lastOpenCritical.get(f.id) === true && (0, basis_1.basisDiverged)(f.basis))
1545
+ counts.critical++;
1546
+ }
1547
+ // @implements A-SPEC-135
1548
+ // Surface outstanding P4 routing signals: specs whose approved content moved, with the anchored
1549
+ // files awaiting targeted review. This is a backlog to route human attention, NOT a hard block —
1550
+ // the review layer decides severity — so it does not touch `blocked`.
1551
+ // @implements A-SPEC-189 §12 (round 12) — the findings moved onto the bound project in §8 and
1552
+ // this line stayed on the caller's raw root, so the SAME project answered with or without a
1553
+ // P4 routing backlog depending on how its root was spelled (a subdirectory passes the
1554
+ // project-level foreign check). One question, one tree.
1555
+ const reviewNeeded = (0, review_targets_1.readReviewNeeded)(path.join(path.dirname(boundFindingsLedger(store, a.root)), 'provenance.jsonl'));
1556
+ return { blocked: counts.critical > 0 || counts.important > 0, open: counts, reviewNeeded };
1557
+ },
1558
+ // @implements A-SPEC-125.4
1559
+ async risk_check(a) {
1560
+ const action = { ...a.action };
1561
+ if (a.root && a.changedSymbols && a.changedSymbols.length) {
1562
+ // @implements A-SPEC-189 §14 (round 13) — the derivation was computed and discarded, so the
1563
+ // advertised "a subdirectory resolves up to it" held for `taint_scan` and no one else.
1564
+ const root = projectRootOf(a.root);
1565
+ const specs = await store.list();
1566
+ const scanned = cachedScan(root, root);
1567
+ const g = new rtm_graph_1.RtmGraph();
1568
+ try {
1569
+ (0, rtm_builder_1.buildRtm)(specs, scanned, g);
1570
+ const impacted = (0, rtm_builder_1.impactedBy)(g, a.changedSymbols);
1571
+ action.blastRadius = { impactedSpecCount: impacted.length, hitsFoundational: impacted.some((id) => id.startsWith('SPEC:REQ-')) };
1572
+ }
1573
+ finally {
1574
+ g.close(); // release native SQLite handle even if build/query throws
1575
+ }
1576
+ }
1577
+ const assessment = (0, risk_classifier_1.assessRisk)(action);
1578
+ // @implements A-SPEC-133 — coverage (scope + expiry), not mere well-formedness, decides whether
1579
+ // this approval authorizes THIS action. An out-of-scope or expired token no longer unblocks.
1580
+ const coverTarget = { kind: action.kind, target: typeof action.target === 'string' ? action.target : String(action.target ?? '') };
1581
+ let gate = (0, risk_gate_1.riskGate)(assessment, a.approval, a.enforcement, { action: coverTarget, now: a.ts });
1582
+ const covers = (0, risk_gate_1.approvalCovers)(a.approval, coverTarget, a.ts);
1583
+ // @implements A-SPEC-133 — the hard-hitl branch HONORS a nonce by SPENDING it (r7-191 HIGH:
1584
+ // this consumer accepted single-use approvals and spent nothing — 'N-RISK' unblocked three
1585
+ // times; a replayed approval must deny exactly as an absent one would).
1586
+ // @implements A-SPEC-191 §13 — 승인이 실제로 문을 여는 경우에만 승인의 하자를 따진다.
1587
+ // 9라운드 실측: 승인이 아예 필요 없던 낮은 판정까지 환경에 놓인 토큰의 모양 때문에
1588
+ // 차단됐다 — 승인을 쥔 쪽이 안 쥔 쪽보다 나빠지는 역전이다.
1589
+ const wouldBlockWithout = (0, risk_gate_1.riskGate)(assessment, undefined, a.enforcement, { action: coverTarget, now: a.ts }).blocked;
1590
+ if (!gate.blocked && covers && wouldBlockWithout && (0, provenance_chain_1.blankNonce)(a.approval?.nonce)) {
1591
+ gate = { ...gate, blocked: true, reasons: [...gate.reasons, '승인이 단일 사용(nonce)을 선언했으나 값이 비어 있거나 문자열이 아닙니다 — 1회성을 집행할 수 없어 거부합니다'] };
1592
+ }
1593
+ // 묶인 프로젝트가 없으면 개방을 기록할 수 없다 — 기록되지 않는 개방은 개방하지 않는다.
1594
+ if (!gate.blocked && covers && wouldBlockWithout && boundNonceLedger(store) === null) {
1595
+ gate = { ...gate, blocked: true, reasons: [...gate.reasons, '개방을 기록할 프로젝트 원장을 확정할 수 없습니다 — 기록 없는 개방은 하지 않습니다(.ax 를 가진 프로젝트에 바인딩된 서버에서 호출하십시오)'] };
1596
+ }
1597
+ if (assessment.level === 'hard-hitl' && !gate.blocked && covers && (0, provenance_chain_1.nonceDeclared)(a.approval?.nonce)) {
1598
+ const ledgerFile = boundNonceLedger(store);
1599
+ let won = false;
1600
+ try {
1601
+ won = ledgerFile !== null && (0, provenance_chain_1.consumeNonceExclusively)(String(a.approval.nonce), ledgerFile, {
1602
+ ts: a.ts, actor: a.approval.actor, kind: 'nonce-consumed',
1603
+ summary: `consumed single-use approval for: ${coverTarget.kind} ${(0, provenance_chain_1.redactTarget)('command', coverTarget.target)}`.slice(0, 200),
1604
+ inputs: [(0, provenance_chain_1.nonceFingerprint)(String(a.approval.nonce))], rationale: a.approval.rationale,
1605
+ authorization: (0, provenance_chain_1.authorizationRef)(a.approval.actor, a.approval.token),
1606
+ });
1607
+ }
1608
+ catch {
1609
+ won = false;
1610
+ } // 배타 확보 실패 = 허용 불가(fail-closed)
1611
+ if (!won) {
1612
+ gate = { ...gate, blocked: true, reasons: [...gate.reasons, ledgerFile === null
1613
+ ? '단일 사용 승인(nonce)을 기록할 프로젝트 원장을 확정할 수 없습니다 — 1회성을 보장할 수 없어 거부합니다(.ax 를 가진 프로젝트에 바인딩된 서버에서 호출하십시오)'
1614
+ : '단일 사용 승인(nonce)이 이미 소비되었습니다 — 재사용은 부재와 같이 거부됩니다'] };
1615
+ }
1616
+ }
1617
+ // Honest provenance: a confirm-level action that was NOT blocked but received no COVERING
1618
+ // approval must NOT be recorded as 'approved' (nobody authorized it for this action) — it
1619
+ // 'proceeded unconfirmed'. Only a genuine covering out-of-band approval yields 'approved'.
1620
+ const decision = gate.blocked
1621
+ ? 'blocked'
1622
+ : covers
1623
+ ? 'approved'
1624
+ : gate.requiresApproval
1625
+ ? 'proceeded-unconfirmed'
1626
+ : 'auto';
1627
+ const evt = { ts: a.ts, actor: a.actor ?? a.approval?.actor ?? 'unknown', action, level: assessment.level, decision, rationale: a.rationale ?? a.approval?.rationale ?? '', reasons: assessment.reasons };
1628
+ // @implements A-SPEC-191 §10 — a bound server files its decisions in the project it is bound
1629
+ // to. r8: the audit line for a nonce-replay opening landed in the caller's scratch directory,
1630
+ // so the project whose gate was opened held no trace of it.
1631
+ const bound = boundNonceLedger(store);
1632
+ const decisionsFile = bound !== null
1633
+ ? path.join(path.dirname(bound), 'decisions.jsonl')
1634
+ : path.join(a.root ?? '.', '.ax', 'ledger', 'decisions.jsonl');
1635
+ // @implements A-SPEC-191 §11 — an audit write must not swallow a verdict the caller already
1636
+ // earned (the hook has said this since N3). The nonce is spent for an opening that DOES reach
1637
+ // the caller; a disk failure here loses the record, not the decision.
1638
+ try {
1639
+ new decision_ledger_1.DecisionLedger(decisionsFile).record([evt]);
1640
+ }
1641
+ catch { /* 기록 실패가 이미 계산된 판정을 뒤집지 않는다 */ }
1642
+ // @implements A-SPEC-133 — 'Every gate opening records a provenance approved-action entry.'
1643
+ // r8: the promise was kept in the hook and broken here — a covering approval that unblocked a
1644
+ // hard-hitl risk_check left only `nonce-consumed` (and nothing at all when it carried no
1645
+ // nonce), so the master key's use was invisible in the chain the audit reads.
1646
+ const wouldBlock = wouldBlockWithout;
1647
+ const bnl = boundNonceLedger(store);
1648
+ if (!gate.blocked && covers && wouldBlock && bnl !== null) {
1649
+ try {
1650
+ new provenance_chain_1.ProvenanceChain(bnl).append({
1651
+ ts: a.ts, actor: a.approval?.actor ?? 'unknown', kind: 'approved-action',
1652
+ summary: `approved action: risk_check ${coverTarget.kind} ${(0, provenance_chain_1.redactTarget)('command', coverTarget.target)}`.slice(0, 300),
1653
+ inputs: ['risk_check', (0, provenance_chain_1.redactTarget)('command', coverTarget.target), (0, provenance_chain_1.approvalMarkers)(a.approval)],
1654
+ rationale: a.approval?.rationale ?? '',
1655
+ authorization: a.approval ? (0, provenance_chain_1.authorizationRef)(a.approval.actor, a.approval.token) : undefined,
1656
+ });
1657
+ }
1658
+ catch { /* 감사 기록 실패가 판정을 바꾸지 않는다 */ }
1659
+ }
1660
+ return { assessment, gate };
1661
+ },
1662
+ // @implements A-SPEC-126
1663
+ // Brownfield reverse engineering. Three explicitly-invoked tools, nothing running as a side
1664
+ // effect of an ordinary session, and every write path opt-in: scan writes nothing at all, draft
1665
+ // and anchor are dry-run by DEFAULT.
1666
+ /**
1667
+ * Read-only inventory of a brownfield target: candidate clusters, coverage, and what the scan
1668
+ * could not resolve. Deliberately does NOT call assertRepoTopLevel — a target that is not a git
1669
+ * repository is a supported case here, reported as `isGit: false`.
1670
+ */
1671
+ async reverse_scan(a) {
1672
+ // @implements A-SPEC-189 §10 (round 11) — `reverse_scan`/`reverse_draft` never call
1673
+ // `projectRootOf`, so §7's refusal marker never reached them and their own POINTED sentence
1674
+ // ("<root> is not a directory") arrived at the wire as a raw -32603 fault. A refusal about the
1675
+ // caller's own argument is a refusal wherever it is authored.
1676
+ assertReadableRoot('reverse_scan', a.root);
1677
+ // `surfaceByCluster` is drafting evidence, not reading material. On the calibration target it
1678
+ // is 11,460 characters across 15 clusters, and this response enters context on every scan —
1679
+ // paying that to READ what only DRAFTING consumes. `reverse_draft` reads it in-process instead.
1680
+ const { surfaceByCluster: _drafting, ...response } = (0, scan_1.reverseScan)(a.root, { maxFlagged: a.maxFlagged });
1681
+ return response;
1682
+ },
1683
+ /**
1684
+ * Draft H-SPEC/A-SPEC/T-SPEC documents for the recovered clusters under an EXISTING parent REQ.
1685
+ *
1686
+ * The REQ is the human's to write and this tool refuses without one — a requirement states
1687
+ * business intent, which is not in the code. Everything emitted is `status: draft`, and writing
1688
+ * requires an explicit `dryRun: false`.
1689
+ *
1690
+ * WHERE THE DOCUMENTS LAND: in the SERVER's configured spec store (`HOLMES_SPECS`, default
1691
+ * `.ax/specs` relative to the server's working directory) — not inside `root`. In the intended
1692
+ * adoption the two are the same directory, because the server runs inside the target it governs;
1693
+ * pointing `root` at a different repository drafts that repository's slices into THIS store,
1694
+ * which is a governance decision the caller has to make deliberately.
1695
+ */
1696
+ async reverse_draft(a) {
1697
+ assertReadableRoot('reverse_draft', a.root);
1698
+ if (typeof a.parentReqId !== 'string' || a.parentReqId.trim() === '') {
1699
+ return {
1700
+ ok: false,
1701
+ reason: 'parentReqId is required. A REQ states business intent, which does not exist in the code ' +
1702
+ 'and cannot be recovered from it — write the REQ first, then re-run with its id.',
1703
+ };
1704
+ }
1705
+ const parentReqId = a.parentReqId.trim();
1706
+ const specs = await store.list();
1707
+ const req = specs.find((s) => s.id === parentReqId);
1708
+ // An unresolvable parent would emit an orphan H-SPEC: a chain that only looks complete.
1709
+ if (!req)
1710
+ return { ok: false, reason: `parent REQ ${parentReqId} was not found in the spec store — create it first` };
1711
+ // @implements A-SPEC-184
1712
+ // A document with no `type:` is not a wrong-kind parent — it is an older-format one, and saying
1713
+ // "is a undefined" left the adopter holding a visible document with nowhere to go. When the type
1714
+ // IS declared, `legacyMessage` returns null and the original wording stands: that path was
1715
+ // already accurate and REQ-184 does not touch it.
1716
+ const formatWhy = (0, legacy_format_1.legacyMessage)(req);
1717
+ if (formatWhy)
1718
+ return { ok: false, reason: formatWhy };
1719
+ if (req.type !== 'REQ')
1720
+ return { ok: false, reason: `${parentReqId} is a ${req.type}; drafts must hang off a REQ` };
1721
+ const report = (0, scan_1.reverseScan)(a.root);
1722
+ // A cluster already drafted is recognised by the `reverse_cluster` key its documents carry, so
1723
+ // re-running over the same tree never duplicates a slice.
1724
+ const alreadyDrafted = new Set(specs.map((s) => s.frontmatter.reverse_cluster).filter((k) => typeof k === 'string'));
1725
+ const selected = a.cluster ? report.clusters.filter((c) => (0, draft_1.clusterKeyOf)(c) === a.cluster) : report.clusters;
1726
+ // A key that matches nothing must not read as success: "drafted 0" is indistinguishable from
1727
+ // "everything was already drafted", so a mistyped key would silently skip the work.
1728
+ if (a.cluster && selected.length === 0) {
1729
+ const keys = report.clusters.map(draft_1.clusterKeyOf);
1730
+ return {
1731
+ ok: false,
1732
+ reason: `no cluster matches key "${a.cluster}" — reverse_scan reports ${keys.length} cluster(s)`,
1733
+ availableClusters: keys.slice(0, 50),
1734
+ };
1735
+ }
1736
+ let base = (0, draft_1.nextIdBase)(specs.map((s) => s.id));
1737
+ const drafted = [];
1738
+ const skipped = [];
1739
+ const refused = [];
1740
+ for (const cluster of selected) {
1741
+ const key = (0, draft_1.clusterKeyOf)(cluster);
1742
+ if (alreadyDrafted.has(key)) {
1743
+ skipped.push({ clusterKey: key, reason: 'already drafted' });
1744
+ continue;
1745
+ }
1746
+ const d = (0, draft_1.draftSpecs)(cluster, req.id, String(base), {
1747
+ surface: report.surfaceByCluster[key] ?? [],
1748
+ testFiles: cluster.testFiles ?? [],
1749
+ testsUnmatched: report.coverage.testsUnmatched,
1750
+ });
1751
+ if (!d.ok) {
1752
+ refused.push({ clusterKey: key, reason: d.reason });
1753
+ continue;
1754
+ }
1755
+ // Validate BEFORE writing: a draft that fails the project's own validator is not evidence of
1756
+ // anything, and emitting one would put a broken document into the chain.
1757
+ const known = [...specs, ...drafted.flatMap((x) => x.specs), ...d.specs];
1758
+ const resolve = (id) => known.find((s) => s.id === id) ?? null;
1759
+ const errors = d.specs.flatMap((s) => (0, validator_1.validateSpec)(s, resolve).findings.filter((f) => f.level === 'error').map((f) => ({ spec: s.id, ...f })));
1760
+ if (errors.length) {
1761
+ refused.push({ clusterKey: key, reason: 'drafted documents failed validation', findings: errors });
1762
+ continue;
1763
+ }
1764
+ drafted.push(d);
1765
+ base++;
1766
+ }
1767
+ const dryRun = a.dryRun !== false; // opt IN to writing
1768
+ // @implements A-SPEC-188 — the minted id sits above every PARSEABLE id, but a file list()
1769
+ // cannot read is invisible to nextIdBase, so its path can collide with a fresh draft. Same
1770
+ // rule as spec_create: a path already occupied is a human's to look at, never overwritten.
1771
+ // The whole cluster moves to `refused` — a partially-written chain only looks complete.
1772
+ // Judged in the DRY RUN too (round-3): a preview that lists a cluster as drafted which the
1773
+ // real run would refuse is a preview that lies.
1774
+ const written = [];
1775
+ for (const d of drafted) {
1776
+ const clash = store instanceof spec_store_1.LocalMarkdownRepository
1777
+ ? d.specs.map((s) => store.targetPathFor(s)).find((p) => fs.existsSync(p))
1778
+ : undefined;
1779
+ if (clash) {
1780
+ refused.push({ clusterKey: d.clusterKey, reason: `초안 목적지(${clash})에 스토어가 읽지 못하는 파일이 이미 있습니다 — 덮어쓰지 않습니다. 사람이 확인해 옮기거나 고친 뒤 다시 실행하십시오.` });
1781
+ continue;
1782
+ }
1783
+ if (!dryRun) {
1784
+ try {
1785
+ for (const s of d.specs)
1786
+ await store.write(s);
1787
+ }
1788
+ catch (e) {
1789
+ if (e instanceof spec_store_1.TargetPathOccupiedError) {
1790
+ refused.push({ clusterKey: d.clusterKey, reason: `초안 목적지(${e.occupiedPath})에 스토어가 읽지 못하는 파일이 이미 있습니다 — 덮어쓰지 않습니다. 사람이 확인해 옮기거나 고친 뒤 다시 실행하십시오.` });
1791
+ continue;
1792
+ }
1793
+ throw e;
1794
+ }
1795
+ }
1796
+ written.push(d);
1797
+ }
1798
+ return { ok: true, dryRun, parentReqId: req.id, clusters: report.clusters.length, drafted: written, skipped, refused };
1799
+ },
1800
+ /**
1801
+ * Insert `@implements` anchors into source files. Dry-run by DEFAULT, and anchoring to a
1802
+ * non-approved A-SPEC is refused — a reverse-engineered draft describes code; description does
1803
+ * not confer approval.
1804
+ */
1805
+ async reverse_anchor(a) {
1806
+ const specs = await store.list();
1807
+ const approved = specs.filter((s) => s.type === 'A-SPEC' && s.status === 'approved').map((s) => s.id);
1808
+ const plan = (0, anchor_1.planAnchors)(a.root, a.mapping ?? [], approved);
1809
+ // @implements A-SPEC-182
1810
+ // The third place the harness refuses over an unapproved A-SPEC. Enriched HERE rather than
1811
+ // inside planAnchors, which is a pure planner holding only the approved-id list — this is the
1812
+ // boundary that has the spec objects, so the planner stays testable without them.
1813
+ const byId = new Map(specs.map((s) => [s.id, s]));
1814
+ // ONE payload per distinct A-SPEC, on a sibling field — not appended to every refused entry.
1815
+ // Review measured the first attempt, which memoized only the COMPUTATION: 149 files mapped to
1816
+ // one draft A-SPEC still concatenated the same 361-character sentence 149 times, 48 KB of pure
1817
+ // duplication in a single tool result. The comment claimed a property the code did not have,
1818
+ // which is how it survived a round of review — hence the size assertion in the test.
1819
+ const blockers = {};
1820
+ for (const r of plan.refused) {
1821
+ if (!/is not approved$/.test(r.reason) || blockers[r.aspec] !== undefined)
1822
+ continue;
1823
+ const why = (0, approval_blockers_1.blockerSummary)(byId.get(r.aspec), (id) => byId.get(id) ?? null, r.aspec);
1824
+ if (why)
1825
+ blockers[r.aspec] = why.trim();
1826
+ }
1827
+ const applied = (0, anchor_1.applyAnchors)(a.root, plan.edits, { dryRun: a.dryRun !== false });
1828
+ return { ...plan, ...applied, blockers };
1829
+ },
1830
+ };
1831
+ }