@holmes-lab/holmes-kit 0.19.6 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/CHANGELOG.md +143 -0
  2. package/README.md +9 -2
  3. package/dist/.build-id +1 -1
  4. package/dist/holmes/cli/approve-status.d.ts +24 -0
  5. package/dist/holmes/cli/approve-status.js +166 -0
  6. package/dist/holmes/cli/approve-watch.d.ts +6 -0
  7. package/dist/holmes/cli/approve-watch.js +6 -0
  8. package/dist/holmes/cli/approve.d.ts +27 -0
  9. package/dist/holmes/cli/approve.js +68 -7
  10. package/dist/holmes/cli/cli-execution.d.ts +2 -0
  11. package/dist/holmes/cli/cli-execution.js +24 -0
  12. package/dist/holmes/cli/doctor-check.d.ts +15 -0
  13. package/dist/holmes/cli/doctor-check.js +2 -0
  14. package/dist/holmes/cli/doctor.d.ts +2 -8
  15. package/dist/holmes/cli/doctor.js +12 -3
  16. package/dist/holmes/cli/gitignore-merge.d.ts +0 -5
  17. package/dist/holmes/cli/gitignore-merge.js +6 -2
  18. package/dist/holmes/cli/index.js +176 -139
  19. package/dist/holmes/cli/npx-cache-check.d.ts +16 -0
  20. package/dist/holmes/cli/npx-cache-check.js +130 -0
  21. package/dist/holmes/cli/playbook-skills.d.ts +2 -22
  22. package/dist/holmes/cli/playbook-skills.js +10 -54
  23. package/dist/holmes/cli/probe-process.d.ts +17 -2
  24. package/dist/holmes/cli/probe-process.js +44 -11
  25. package/dist/holmes/governance/approval-grants.d.ts +22 -0
  26. package/dist/holmes/governance/approval-grants.js +55 -1
  27. package/dist/holmes/governance/approval-queue.d.ts +43 -4
  28. package/dist/holmes/governance/approval-queue.js +91 -13
  29. package/dist/holmes/governance/display-id-aliases.d.ts +29 -0
  30. package/dist/holmes/governance/display-id-aliases.js +86 -0
  31. package/dist/holmes/governance/ledger-store.d.ts +42 -14
  32. package/dist/holmes/governance/ledger-store.js +49 -3
  33. package/dist/holmes/governance/ledger-timeline.d.ts +3 -1
  34. package/dist/holmes/governance/ledger-timeline.js +11 -1
  35. package/dist/holmes/governance/provenance-chain.d.ts +2 -0
  36. package/dist/holmes/governance/provenance-chain.js +13 -2
  37. package/dist/holmes/governance/provenance-ledger.d.ts +28 -0
  38. package/dist/holmes/governance/provenance-ledger.js +69 -16
  39. package/dist/holmes/governance/session-context.d.ts +3 -0
  40. package/dist/holmes/governance/session-context.js +26 -8
  41. package/dist/holmes/guardrail/risk-classifier.js +2 -2
  42. package/dist/holmes/guardrail/write-target.js +2 -1
  43. package/dist/holmes/hooks/pre-tool-use.js +12 -1
  44. package/dist/holmes/hooks/stop.js +14 -3
  45. package/dist/holmes/mcp/handlers/adoption.d.ts +118 -0
  46. package/dist/holmes/mcp/handlers/adoption.js +186 -0
  47. package/dist/holmes/mcp/handlers/entity-integration.d.ts +100 -0
  48. package/dist/holmes/mcp/handlers/entity-integration.js +324 -0
  49. package/dist/holmes/mcp/handlers/entity-renumber.d.ts +137 -0
  50. package/dist/holmes/mcp/handlers/entity-renumber.js +228 -0
  51. package/dist/holmes/mcp/handlers/entity-store.d.ts +78 -0
  52. package/dist/holmes/mcp/handlers/entity-store.js +212 -0
  53. package/dist/holmes/mcp/handlers/graph-operations.d.ts +90 -0
  54. package/dist/holmes/mcp/handlers/graph-operations.js +260 -0
  55. package/dist/holmes/mcp/handlers/maintenance-analysis.d.ts +34 -0
  56. package/dist/holmes/mcp/handlers/maintenance-analysis.js +288 -0
  57. package/dist/holmes/mcp/handlers/maintenance-evidence.d.ts +40 -0
  58. package/dist/holmes/mcp/handlers/maintenance-evidence.js +104 -0
  59. package/dist/holmes/mcp/handlers/operator-inspection.d.ts +167 -0
  60. package/dist/holmes/mcp/handlers/operator-inspection.js +284 -0
  61. package/dist/holmes/mcp/handlers/phase-queries.d.ts +34 -0
  62. package/dist/holmes/mcp/handlers/phase-queries.js +86 -0
  63. package/dist/holmes/mcp/handlers/review-evidence.d.ts +47 -0
  64. package/dist/holmes/mcp/handlers/review-evidence.js +370 -0
  65. package/dist/holmes/mcp/handlers/review-queries.d.ts +68 -0
  66. package/dist/holmes/mcp/handlers/review-queries.js +79 -0
  67. package/dist/holmes/mcp/handlers/risk-assessment.d.ts +25 -0
  68. package/dist/holmes/mcp/handlers/risk-assessment.js +181 -0
  69. package/dist/holmes/mcp/handlers/slice-orchestration.d.ts +72 -0
  70. package/dist/holmes/mcp/handlers/slice-orchestration.js +373 -0
  71. package/dist/holmes/mcp/handlers/spec-approval.d.ts +105 -0
  72. package/dist/holmes/mcp/handlers/spec-approval.js +504 -0
  73. package/dist/holmes/mcp/handlers/spec-authoring.d.ts +57 -0
  74. package/dist/holmes/mcp/handlers/spec-authoring.js +326 -0
  75. package/dist/holmes/mcp/handlers/spec-lifecycle.d.ts +122 -0
  76. package/dist/holmes/mcp/handlers/spec-lifecycle.js +339 -0
  77. package/dist/holmes/mcp/handlers/spec-queries.d.ts +30 -0
  78. package/dist/holmes/mcp/handlers/spec-queries.js +74 -0
  79. package/dist/holmes/mcp/handlers/taint-screening.d.ts +48 -0
  80. package/dist/holmes/mcp/handlers/taint-screening.js +183 -0
  81. package/dist/holmes/mcp/handlers/test-execution.d.ts +63 -0
  82. package/dist/holmes/mcp/handlers/test-execution.js +210 -0
  83. package/dist/holmes/mcp/handlers/workspace-identity.d.ts +30 -0
  84. package/dist/holmes/mcp/handlers/workspace-identity.js +68 -0
  85. package/dist/holmes/mcp/handlers/workspace-queries.d.ts +77 -0
  86. package/dist/holmes/mcp/handlers/workspace-queries.js +193 -0
  87. package/dist/holmes/mcp/handlers.d.ts +661 -424
  88. package/dist/holmes/mcp/handlers.js +270 -3251
  89. package/dist/holmes/mcp/server-specs-dir.d.ts +2 -0
  90. package/dist/holmes/mcp/server-specs-dir.js +71 -0
  91. package/dist/holmes/mcp/server.js +12 -2
  92. package/dist/holmes/mcp/tool-schemas.js +43 -2
  93. package/dist/holmes/project/execution-context.d.ts +17 -0
  94. package/dist/holmes/project/execution-context.js +121 -0
  95. package/dist/holmes/project/installer-markers.d.ts +34 -0
  96. package/dist/holmes/project/installer-markers.js +65 -0
  97. package/dist/holmes/project/root.d.ts +12 -1
  98. package/dist/holmes/project/root.js +22 -3
  99. package/dist/holmes/project/workspace-identity.d.ts +29 -0
  100. package/dist/holmes/project/workspace-identity.js +181 -0
  101. package/dist/holmes/review/test-outcomes.d.ts +6 -1
  102. package/dist/holmes/review/test-outcomes.js +23 -5
  103. package/dist/holmes/review/test-runner.d.ts +18 -0
  104. package/dist/holmes/review/test-runner.js +136 -5
  105. package/dist/holmes/rtm/graph-store.d.ts +50 -1
  106. package/dist/holmes/rtm/graph-store.js +244 -3
  107. package/dist/holmes/rtm/incremental.d.ts +1 -0
  108. package/dist/holmes/rtm/incremental.js +12 -3
  109. package/dist/holmes/rtm/localize.js +7 -0
  110. package/dist/holmes/rtm/rtm-graph.d.ts +9 -0
  111. package/dist/holmes/rtm/rtm-graph.js +13 -0
  112. package/dist/holmes/rtm/test-scope.js +4 -1
  113. package/dist/holmes/semantic/vector-cache.d.ts +16 -1
  114. package/dist/holmes/semantic/vector-cache.js +155 -19
  115. package/dist/holmes/spec/approval-status.d.ts +10 -0
  116. package/dist/holmes/spec/approval-status.js +7 -3
  117. package/dist/holmes/spec/entity-git-snapshot.d.ts +50 -0
  118. package/dist/holmes/spec/entity-git-snapshot.js +276 -0
  119. package/dist/holmes/spec/entity-integration-discharge.d.ts +73 -0
  120. package/dist/holmes/spec/entity-integration-discharge.js +233 -0
  121. package/dist/holmes/spec/entity-integration-transaction.d.ts +179 -0
  122. package/dist/holmes/spec/entity-integration-transaction.js +1221 -0
  123. package/dist/holmes/spec/entity-integration.d.ts +216 -0
  124. package/dist/holmes/spec/entity-integration.js +760 -0
  125. package/dist/holmes/spec/entity-renumber-transaction.d.ts +82 -0
  126. package/dist/holmes/spec/entity-renumber-transaction.js +711 -0
  127. package/dist/holmes/spec/entity-renumber.d.ts +32 -0
  128. package/dist/holmes/spec/entity-renumber.js +156 -0
  129. package/dist/holmes/spec/entity-store.d.ts +135 -0
  130. package/dist/holmes/spec/entity-store.js +1053 -0
  131. package/dist/holmes/spec/entity-transaction.d.ts +105 -0
  132. package/dist/holmes/spec/entity-transaction.js +741 -0
  133. package/dist/holmes/spec/renumber.d.ts +58 -0
  134. package/dist/holmes/spec/renumber.js +200 -1
  135. package/dist/holmes/spec/spec-store.d.ts +3 -2
  136. package/dist/holmes/spec/spec-store.js +23 -2
  137. package/dist/holmes/spec/transition-policy.d.ts +36 -0
  138. package/dist/holmes/spec/transition-policy.js +135 -0
  139. package/package.json +5 -2
@@ -38,7 +38,16 @@ exports.isHandlerRefusal = isHandlerRefusal;
38
38
  exports.unreadableAmong = unreadableAmong;
39
39
  exports.makeHandlers = makeHandlers;
40
40
  exports.collectDecisions = collectDecisions;
41
- // @implements A-SPEC-599
41
+ // @implements A-SPEC-629, A-SPEC-628, A-SPEC-599, A-SPEC-603, A-SPEC-604, A-SPEC-605, A-SPEC-606, A-SPEC-607, A-SPEC-608, A-SPEC-609, A-SPEC-610, A-SPEC-611, A-SPEC-612, A-SPEC-613, A-SPEC-614, A-SPEC-615, A-SPEC-616, A-SPEC-617, A-SPEC-618, A-SPEC-621, A-SPEC-622, A-SPEC-624, A-SPEC-625, A-SPEC-627
42
+ // @implements A-SPEC-643
43
+ // @implements A-SPEC-642
44
+ // @implements A-SPEC-642
45
+ // @implements A-SPEC-632
46
+ const entity_integration_1 = require("./handlers/entity-integration");
47
+ const entity_renumber_1 = require("./handlers/entity-renumber");
48
+ const entity_store_1 = require("./handlers/entity-store");
49
+ const execution_context_1 = require("../project/execution-context");
50
+ const workspace_identity_1 = require("../project/workspace-identity");
42
51
  // @implements A-SPEC-293
43
52
  // @implements A-SPEC-292
44
53
  // @implements A-SPEC-290
@@ -47,46 +56,34 @@ exports.collectDecisions = collectDecisions;
47
56
  // @implements A-SPEC-269
48
57
  // @implements A-SPEC-267
49
58
  // @implements A-SPEC-573.4 — shared with the benchmark so the two cannot drift.
50
- const defuse_bound_1 = require("./defuse-bound");
51
59
  const fs = __importStar(require("node:fs"));
52
60
  const http = __importStar(require("node:http"));
53
- const assoc_arm_1 = require("../assoc/assoc-arm");
54
- // @implements A-SPEC-478 — the semantic tier runtime, resolved HERE (the async edge) and injected
55
- // into the sync analysis; the replay harness injects nothing, keeping every pin untouched.
56
- const tier_1 = require("../semantic/tier");
57
- const vector_cache_1 = require("../semantic/vector-cache");
58
- const embedder_1 = require("../semantic/embedder");
59
- // @implements A-SPEC-496 — policy parity: the direct localization path reranks with the exact
60
- // A-SPEC-478 math, extracted pure.
61
- const hit_rerank_1 = require("../semantic/hit-rerank");
62
61
  const os = __importStar(require("node:os"));
63
62
  const path = __importStar(require("node:path"));
64
63
  const crypto = __importStar(require("node:crypto"));
65
- // @implements A-SPEC-378
66
- const temporal_prior_1 = require("../review/temporal-prior");
67
- // @implements A-SPEC-388
68
- const commit_text_1 = require("../review/commit-text");
69
- const node_child_process_1 = require("node:child_process");
70
- const spec_parser_1 = require("../spec/spec-parser");
71
- const validator_1 = require("../spec/validator");
72
- const rtm_check_1 = require("../rtm/rtm-check");
73
64
  const test_scope_1 = require("../rtm/test-scope");
74
- const gap_analyzer_1 = require("../rtm/gap-analyzer");
75
- const test_runner_1 = require("../review/test-runner");
76
- const kills_1 = require("../spec/kills");
77
- const mutate_1 = require("../review/mutate");
78
- const test_evidence_1 = require("../review/test-evidence");
79
- const test_outcomes_1 = require("../review/test-outcomes");
80
- const localize_1 = require("../rtm/localize");
81
- const maintenance_analyze_1 = require("./maintenance-analyze");
82
- const maintenance_evidence_1 = require("./maintenance-evidence");
83
- const impact_gate_1 = require("../guardrail/impact-gate");
84
- const phase_1 = require("../guardrail/phase");
85
- const spec_types_1 = require("../spec/spec-types");
86
- const legacy_format_1 = require("../spec/legacy-format");
65
+ const maintenance_evidence_1 = require("./handlers/maintenance-evidence");
66
+ const spec_queries_1 = require("./handlers/spec-queries");
67
+ const spec_authoring_1 = require("./handlers/spec-authoring");
68
+ const spec_lifecycle_1 = require("./handlers/spec-lifecycle");
69
+ const spec_approval_1 = require("./handlers/spec-approval");
70
+ const workspace_identity_2 = require("./handlers/workspace-identity");
71
+ const operator_inspection_1 = require("./handlers/operator-inspection");
72
+ const phase_queries_1 = require("./handlers/phase-queries");
73
+ const risk_assessment_1 = require("./handlers/risk-assessment");
74
+ const adoption_1 = require("./handlers/adoption");
75
+ const slice_orchestration_1 = require("./handlers/slice-orchestration");
76
+ const workspace_queries_1 = require("./handlers/workspace-queries");
77
+ const test_execution_1 = require("./handlers/test-execution");
78
+ const review_evidence_1 = require("./handlers/review-evidence");
79
+ const review_queries_1 = require("./handlers/review-queries");
80
+ const graph_operations_1 = require("./handlers/graph-operations");
81
+ // @implements A-SPEC-632 — the rebuild paths discharge shares with reindex.
82
+ const graph_operations_2 = require("./handlers/graph-operations");
83
+ const maintenance_analysis_1 = require("./handlers/maintenance-analysis");
84
+ const taint_screening_1 = require("./handlers/taint-screening");
87
85
  const cpg_scanner_1 = require("../cpg/cpg-scanner");
88
86
  const scan_cache_1 = require("../cpg/scan-cache");
89
- const root_1 = require("../project/root");
90
87
  // N7: every whole-tree scan goes through the file-key incremental cache under the PROJECT root's
91
88
  // .ax/cpg_cache — unchanged files (content-hash hit) skip the tree-sitter parse entirely.
92
89
  // The cache lives under the RESOLVED project root, not the raw scan root: scanning a SUBDIRECTORY
@@ -98,73 +95,14 @@ const root_1 = require("../project/root");
98
95
  // under that tree stopped at the minted marker. This is the harm §8 closed for `review_record`,
99
96
  // left open on the scan path. When the store is bound, the cache is the bound project's; otherwise
100
97
  // only an anchored answer may be written to, and an unanchored one falls back to a temp cache.
101
- // @implements A-SPEC-534.8
102
- // The governed source file a `kills` mutation targets: one that @implements the A-SPEC AND contains
103
- // the `where` literal. Bounded walk, skips vendored/test/hidden dirs. null when none qualifies.
104
- function sourceFileWithMutation(root, aspecId, where) {
105
- if (!where)
106
- return null;
107
- const SOURCE = /\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs|py|go|rs|java|kt|cs|rb|php|swift)$/;
108
- let found = null;
109
- const walk = (d) => {
110
- if (found)
111
- return;
112
- let entries;
113
- try {
114
- entries = fs.readdirSync(d, { withFileTypes: true });
115
- }
116
- catch {
117
- return;
118
- }
119
- for (const e of entries) {
120
- if (found)
121
- return;
122
- if (e.name === 'node_modules' || e.name === 'dist' || e.name === 'reference'
123
- || e.name === 'vendor' || e.name === 'third_party' || e.name.startsWith('.'))
124
- continue;
125
- const p = path.join(d, e.name);
126
- if (e.isDirectory()) {
127
- walk(p);
128
- continue;
129
- }
130
- if (!SOURCE.test(e.name) || /\.test\./.test(e.name))
131
- continue;
132
- let text;
133
- try {
134
- text = fs.readFileSync(p, 'utf8');
135
- }
136
- catch {
137
- continue;
138
- }
139
- if (text.includes(`@implements ${aspecId}`) && text.includes(where)) {
140
- found = p;
141
- return;
142
- }
143
- }
144
- };
145
- walk(root);
146
- return found;
147
- }
148
98
  const cacheDirFor = (root) => {
149
- const r = (0, root_2.resolveProjectRoot)(root);
99
+ const r = (0, root_1.resolveProjectRoot)(root);
150
100
  if (r.marker !== 'given')
151
101
  return path.join(r.root, '.ax', 'cpg_cache');
152
102
  if (fs.existsSync(path.join(r.root, '.ax')))
153
103
  return path.join(r.root, '.ax', 'cpg_cache');
154
104
  return path.join(os.tmpdir(), `holmes-cpg-cache-${crypto.createHash('sha256').update(r.root).digest('hex').slice(0, 16)}`);
155
105
  };
156
- // @implements A-SPEC-283 — bumped whenever the graph's node/edge shape changes, so a store written
157
- // by an older build is rebuilt rather than read with new assumptions.
158
- // @implements A-SPEC-568.1 — /3: nodes gained the intent `summary` column.
159
- const RTM_GRAPH_SCHEMA = 'rtm-graph/3';
160
- // @implements A-SPEC-569.3 — how many impacted specs get their intent sentence attached. A prose
161
- // constant, never a verdict input: the impacted list itself is never truncated.
162
- const SUMMARY_CAP = 40;
163
- // @implements A-SPEC-569.1 (revision) — /2: pre-fix 0.16.0 builds could persist forged structural
164
- // characters in the summary column, and every other basis field would still match after upgrading.
165
- // A-SPEC-283's own rule applies to us too: an older build's artifact is rebuilt, never read with
166
- // new assumptions.
167
- const RTM_EXTRACTOR_VERSION = 'holmes-rtm/2';
168
106
  const cachedScan = (root, repoRoot = root) => new cpg_scanner_1.CpgScanner(undefined, new scan_cache_1.ScanFileCache(cacheDirFor(root))).scan(root, repoRoot);
169
107
  // @implements A-SPEC-131
170
108
  // Same scan, with the skip report kept: the callers that make honesty claims (cpg_scan's surface,
@@ -174,45 +112,14 @@ const cachedScanWithReport = (root, repoRoot = root) => {
174
112
  const scanned = scanner.scan(root, repoRoot);
175
113
  return { scanned, skipped: scanner.scanSkipped() };
176
114
  };
177
- const rtm_builder_1 = require("../rtm/rtm-builder");
178
- const rtm_graph_1 = require("../rtm/rtm-graph");
179
- const taint_1 = require("../rtm/taint");
180
- const incremental_1 = require("../rtm/incremental");
181
- const graph_store_1 = require("../rtm/graph-store");
182
- const hash_cache_1 = require("../cpg/hash-cache");
183
- const bundler_1 = require("../context/bundler");
184
- const scope_1 = require("../review/scope");
185
- const review_targets_1 = require("../review/review-targets");
186
- const coverage_1 = require("../review/coverage");
187
115
  const basis_1 = require("./basis");
188
116
  const write_target_1 = require("../guardrail/write-target");
189
117
  const spec_store_1 = require("../spec/spec-store");
190
- const yaml_scalar_1 = require("../spec/yaml-scalar");
191
- const findings_1 = require("../review/findings");
192
- const package_1 = require("../review/package");
193
- const risk_classifier_1 = require("../guardrail/risk-classifier");
194
118
  const risk_gate_1 = require("../guardrail/risk-gate");
195
119
  const elicit_approval_1 = require("./elicit-approval");
196
- const autonomy_1 = require("../governance/autonomy");
197
- const anchor_comment_1 = require("../rtm/anchor-comment");
198
- const consistency_lints_1 = require("../cpg/consistency-lints");
199
120
  const approval_queue_1 = require("../governance/approval-queue");
200
121
  const approval_grants_1 = require("../governance/approval-grants");
201
- const spec_digest_1 = require("../spec/spec-digest");
202
- const spec_store_2 = require("../spec/spec-store");
203
- const breaking_change_1 = require("../spec/breaking-change");
204
- const compat_impact_1 = require("../spec/compat-impact");
205
- const approval_blockers_1 = require("../spec/approval-blockers");
206
- const approval_status_1 = require("../spec/approval-status");
207
- const ledger_timeline_1 = require("../governance/ledger-timeline");
208
- const version_conflict_1 = require("../spec/version-conflict");
209
122
  const ledger_store_1 = require("../governance/ledger-store");
210
- const provenance_chain_1 = require("../governance/provenance-chain");
211
- // @implements A-SPEC-574.3 — the caller owns the store and hands the chain the question.
212
- const ledger_store_2 = require("../governance/ledger-store");
213
- const ledger_lock_1 = require("../governance/ledger-lock");
214
- const decision_ledger_1 = require("../guardrail/decision-ledger");
215
- const cspec_change_1 = require("../guardrail/cspec-change");
216
123
  /**
217
124
  * @implements A-SPEC-189 §7 (round 10)
218
125
  * A DELIBERATE refusal, distinguished by construction rather than by class.
@@ -249,17 +156,11 @@ function isHandlerRefusal(e) {
249
156
  return !!e && typeof e === 'object' && e.holmesRefusal === true;
250
157
  }
251
158
  // @implements A-SPEC-128
252
- const root_2 = require("../project/root");
159
+ const root_1 = require("../project/root");
253
160
  const change_source_1 = require("../project/change-source");
254
- const baseline_1 = require("../project/baseline");
255
161
  const ignore_1 = require("../project/ignore");
256
162
  // @implements A-SPEC-126
257
- const scan_1 = require("../reverse/scan");
258
- const draft_1 = require("../reverse/draft");
259
- const renumber_1 = require("../spec/renumber");
260
163
  const adr_refs_1 = require("../spec/adr-refs");
261
- const spec_id_guard_1 = require("./spec-id-guard");
262
- const anchor_1 = require("../reverse/anchor");
263
164
  // @implements A-SPEC-128
264
165
  // Where a project begins, for tools that only walk the tree.
265
166
  //
@@ -294,6 +195,15 @@ function resolveHandlerApproval(rootArg, store, envApproval, action, now) {
294
195
  if (root === undefined) {
295
196
  return (0, risk_gate_1.approvalCovers)(envApproval, action, now) ? { approval: envApproval, source: 'env' } : undefined;
296
197
  }
198
+ // @implements A-SPEC-642 — no channel is consulted while the merged ledger holds an unreconciled
199
+ // cross-replica double-spend: the authority channel itself is what is in question, so every act
200
+ // that would spend authority (seal, unseal, retire, review resolution, entity apply/recover/abandon)
201
+ // refuses with the nonce and the reconciliation act. The reconciliation act is the one exit.
202
+ if (action.kind !== 'ledger-reconcile') {
203
+ const conflict = (0, ledger_store_1.ledgerConflictReason)(path.join(root, '.ax', 'ledger'));
204
+ if (conflict)
205
+ throw new ledger_store_1.LedgerConflictRefusal(conflict);
206
+ }
297
207
  const r = (0, approval_grants_1.resolveApproval)(root, envApproval, action, now);
298
208
  return r ? { ...r, root } : undefined;
299
209
  }
@@ -312,7 +222,7 @@ function refusalQueueHint(rootArg, store, req) {
312
222
  }
313
223
  }
314
224
  function projectRootOf(root) {
315
- return (0, root_2.resolveProjectRoot)(root).root;
225
+ return (0, root_1.resolveProjectRoot)(root).root;
316
226
  }
317
227
  // @implements A-SPEC-191 §10 — WHERE a single-use approval is spent must not be a caller's choice.
318
228
  // r8 measured all three escapes at once: a second `root`, a `root` naming a subdirectory, and an
@@ -378,7 +288,7 @@ function specStoreBlindReason(store, root, loaded) {
378
288
  // Compared through realpath: on macOS a temp root is `/var/...` while the same directory resolves
379
289
  // to `/private/var/...`, and a plain string compare calls the correctly-bound store foreign.
380
290
  const real = (p) => { try {
381
- return (0, root_2.canonicalPath)(p);
291
+ return (0, root_1.canonicalPath)(p);
382
292
  }
383
293
  catch {
384
294
  return path.resolve(p);
@@ -416,7 +326,9 @@ function assertSpecStoreReachable(tool, store, root) {
416
326
  // The tool name is NOT prefixed here: the raw-handler wrapper already prefixes it, and doing it
417
327
  // twice produced `spec_list: spec_list: ...` in the installed server (seen in the probe).
418
328
  void tool;
419
- throw new HandlerRefusal(`이 서버가 바인딩된 스펙 디렉터리 ${store.specsRoot} 존재하지 않습니다 —`
329
+ // @implements A-SPEC-652.2 the ABSOLUTE path, so the operator can see which directory was tried
330
+ // (a relative `.ax/specs` in this message told nobody where the server had looked, 2026-09-14).
331
+ throw new HandlerRefusal(`이 서버가 바인딩된 스펙 디렉터리 ${path.resolve(store.specsRoot)} 가 존재하지 않습니다 —`
420
332
  + ` 요청한 root ${root} 에는 스펙 저장소가 있습니다. 빈 답을 돌려주면 '스펙이 없다'로 읽히므로`
421
333
  + ' 거부합니다. 서버를 프로젝트 루트에서 시작하거나 HOLMES_SPECS 로 스펙 경로를 지정하십시오.');
422
334
  }
@@ -448,13 +360,13 @@ function boundNonceLedger(store) {
448
360
  // `--specs-dir docs/specs` an ordinary review_record could still plant `docs/specs/.ax` and move
449
361
  // every consumed nonce, every audit line, and spec_approve's notion of "this project" with it.
450
362
  const storeReal = (() => { try {
451
- return (0, root_2.canonicalPath)(store.specsRoot);
363
+ return (0, root_1.canonicalPath)(store.specsRoot);
452
364
  }
453
365
  catch {
454
366
  return store.specsRoot;
455
367
  } })();
456
368
  const derivedReal = (() => { try {
457
- return (0, root_2.canonicalPath)(derived);
369
+ return (0, root_1.canonicalPath)(derived);
458
370
  }
459
371
  catch {
460
372
  return derived;
@@ -463,7 +375,9 @@ function boundNonceLedger(store) {
463
375
  return null;
464
376
  if (!fs.existsSync(path.join(derived, '.ax')))
465
377
  return null;
466
- return path.join(derived, '.ax', 'ledger', 'provenance.jsonl');
378
+ // @implements A-SPEC-643 — the writer's replica chain, not the legacy single file: two machines
379
+ // consuming into `provenance.jsonl` cannot converge through Git (measured: CONFLICT add/add).
380
+ return (0, ledger_store_1.writerChainFile)(path.join(derived, '.ax', 'ledger'));
467
381
  }
468
382
  catch {
469
383
  return null;
@@ -487,13 +401,13 @@ function boundFindingsLedger(store, root) {
487
401
  try {
488
402
  const derived = projectRootOf(store.specsRoot);
489
403
  const storeReal = (() => { try {
490
- return (0, root_2.canonicalPath)(store.specsRoot);
404
+ return (0, root_1.canonicalPath)(store.specsRoot);
491
405
  }
492
406
  catch {
493
407
  return store.specsRoot;
494
408
  } })();
495
409
  const derivedReal = (() => { try {
496
- return (0, root_2.canonicalPath)(derived);
410
+ return (0, root_1.canonicalPath)(derived);
497
411
  }
498
412
  catch {
499
413
  return derived;
@@ -552,6 +466,10 @@ function buildContentSource(specs, scanned) {
552
466
  // the review/graph. `toolName` is passed through to assertRepoTopLevel so
553
467
  // the thrown error names the actual caller.
554
468
  async function deriveChangedContext(store, rootArg, a, toolName) {
469
+ // @implements A-SPEC-614 — bind specs and source before discovering changes or writing evidence.
470
+ const foreign = foreignRootReason(store, rootArg);
471
+ if (foreign)
472
+ throw new HandlerRefusal(foreign);
555
473
  const root = projectRootOf(rootArg);
556
474
  const source = makeChangeSource(root, a);
557
475
  const resolved = source.changes();
@@ -613,8 +531,21 @@ function unreadableAmong(listed, created) {
613
531
  function makeHandlers(store, opts) {
614
532
  const raw = makeRawHandlers(store, opts);
615
533
  const wrapped = {};
534
+ const run = (0, execution_context_1.createExecutionScope)(store instanceof spec_store_1.LocalMarkdownRepository ? store.specsRoot : undefined, opts?.observeInvocation);
616
535
  for (const [name, fn] of Object.entries(raw)) {
617
- wrapped[name] = (0, basis_1.withBasis)(fn, (a) => basisFor(a?.root));
536
+ const scoped = name === 'workspace_identity' ? fn : async (a) => {
537
+ try {
538
+ return await run(a?.root, () => fn(a));
539
+ }
540
+ catch (e) {
541
+ if (e instanceof workspace_identity_1.WorkspaceIdentityError)
542
+ return { ok: false, code: e.code, reason: e.message };
543
+ if (e instanceof ledger_store_1.LedgerConflictRefusal)
544
+ return { ok: false, code: e.code, reason: e.message }; // A-SPEC-642
545
+ throw e;
546
+ }
547
+ };
548
+ wrapped[name] = (0, basis_1.withBasis)(scoped, (a) => basisFor(a?.root));
618
549
  }
619
550
  wrapped.basis_detail = async (a) => {
620
551
  const b = basisFor(a?.root, true);
@@ -744,7 +675,69 @@ function collectDecisions(root, scanned, specs) {
744
675
  return { decisions, citations };
745
676
  }
746
677
  function makeRawHandlers(store, opts) {
678
+ // @implements A-SPEC-100.2, A-SPEC-102.1, A-SPEC-111.1, A-SPEC-121.2, A-SPEC-121.3, A-SPEC-121.4, A-SPEC-128, A-SPEC-130, A-SPEC-131, A-SPEC-189, A-SPEC-233, A-SPEC-419, A-SPEC-433, A-SPEC-614
679
+ // @implements A-SPEC-616, A-SPEC-100.2, A-SPEC-102.1, A-SPEC-121.2, A-SPEC-121.3, A-SPEC-128, A-SPEC-130, A-SPEC-189, A-SPEC-233, A-SPEC-419, A-SPEC-433, A-SPEC-534.5, A-SPEC-534.8, A-SPEC-578.4, A-SPEC-614
680
+ const testExecution = (0, test_execution_1.createTestExecutionHandlers)({
681
+ defaultBaseline: DEFAULT_BASELINE,
682
+ deriveChangedContext: (root, args, tool) => deriveChangedContext(store, root, args, tool),
683
+ });
684
+ const reviewQueries = (0, review_queries_1.createReviewQueryHandlers)({
685
+ listSpecs: () => store.list(),
686
+ assertStoreReachable: (tool, root) => assertSpecStoreReachable(tool, store, root),
687
+ foreignRootReason: root => foreignRootReason(store, root),
688
+ refusal: reason => new HandlerRefusal(reason),
689
+ projectRootOf,
690
+ cachedScan,
691
+ buildContentSource,
692
+ deriveChangedContext: (root, args, tool) => deriveChangedContext(store, root, args, tool),
693
+ });
694
+ // @implements A-SPEC-100.2, A-SPEC-121.2, A-SPEC-128, A-SPEC-139, A-SPEC-189, A-SPEC-280, A-SPEC-282, A-SPEC-283, A-SPEC-419, A-SPEC-433, A-SPEC-469, A-SPEC-478, A-SPEC-568.1, A-SPEC-568.2, A-SPEC-569.1, A-SPEC-569.2, A-SPEC-569.3, A-SPEC-589
695
+ const graphOperations = (0, graph_operations_1.createGraphOperationsHandlers)({
696
+ listSpecs: () => store.list(),
697
+ assertStoreReachable: (tool, root) => assertSpecStoreReachable(tool, store, root),
698
+ foreignRootReason: root => foreignRootReason(store, root),
699
+ refusal: reason => new HandlerRefusal(reason),
700
+ projectRootOf,
701
+ cachedScan,
702
+ cacheDirFor,
703
+ deriveChangedContext: (root, args, tool) => deriveChangedContext(store, root, args, tool),
704
+ });
705
+ // @implements A-SPEC-100.2, A-SPEC-128, A-SPEC-189, A-SPEC-267, A-SPEC-268, A-SPEC-269, A-SPEC-277, A-SPEC-283, A-SPEC-290, A-SPEC-292, A-SPEC-293, A-SPEC-378, A-SPEC-388, A-SPEC-418, A-SPEC-478, A-SPEC-571.2, A-SPEC-573.4, A-SPEC-578.5
706
+ const maintenanceAnalysis = (0, maintenance_analysis_1.createMaintenanceAnalysisHandlers)({
707
+ listSpecs: () => store.list(),
708
+ foreignRootReason: root => foreignRootReason(store, root),
709
+ refusal: reason => new HandlerRefusal(reason),
710
+ projectRootOf,
711
+ basisFor,
712
+ collectDecisions,
713
+ buildContentSource,
714
+ specStoreBlindReason: (root, count) => specStoreBlindReason(store, root, count),
715
+ fileDigestOf,
716
+ });
747
717
  const resolver = (specs) => (id) => specs.find((s) => s.id === id) ?? null;
718
+ // @implements A-SPEC-100.2, A-SPEC-128, A-SPEC-138, A-SPEC-140.2, A-SPEC-189, A-SPEC-461, A-SPEC-512.2, A-SPEC-513.1, A-SPEC-524.1
719
+ const taintScreening = (0, taint_screening_1.createTaintScreeningHandlers)({
720
+ listSpecs: () => store.list(),
721
+ foreignRootReason: root => foreignRootReason(store, root),
722
+ projectRootOf,
723
+ cachedScan,
724
+ });
725
+ // @implements A-SPEC-121.2, A-SPEC-128, A-SPEC-131, A-SPEC-146, A-SPEC-189, A-SPEC-419, A-SPEC-461, A-SPEC-496, A-SPEC-506.1, A-SPEC-100.2
726
+ const workspaceQueries = (0, workspace_queries_1.createWorkspaceQueryHandlers)({
727
+ listSpecs: () => store.list(),
728
+ foreignRootReason: root => foreignRootReason(store, root),
729
+ projectRootOf,
730
+ cachedScan,
731
+ cachedScanWithReport,
732
+ assertStoreReachable: (tool, root) => assertSpecStoreReachable(tool, store, root),
733
+ });
734
+ // @implements A-SPEC-618, A-SPEC-100.2, A-SPEC-128, A-SPEC-151, A-SPEC-169, A-SPEC-189, A-SPEC-420, A-SPEC-174, A-SPEC-252, A-SPEC-188, A-SPEC-146, A-SPEC-184
735
+ const specAuthoring = (0, spec_authoring_1.createSpecAuthoringHandlers)({ store, projectRootOf, resolver });
736
+ const specQueries = (0, spec_queries_1.createSpecQueryHandlers)({
737
+ listSpecs: () => store.list(),
738
+ assertStoreReachable: (tool, root) => assertSpecStoreReachable(tool, store, root),
739
+ resolver,
740
+ });
748
741
  // @implements A-SPEC-263.1
749
742
  // The in-session approval channel: ask ONLY when (a) an elicitor was injected (the server wires
750
743
  // one iff the client advertised the elicitation capability — handlers never see the server), and
@@ -777,6 +770,20 @@ function makeRawHandlers(store, opts) {
777
770
  token: crypto.randomUUID(),
778
771
  rationale: reason ?? 'elicitation grant',
779
772
  });
773
+ // @implements A-SPEC-617, A-SPEC-100.2, A-SPEC-102.1, A-SPEC-133, A-SPEC-135, A-SPEC-141, A-SPEC-157, A-SPEC-160, A-SPEC-189, A-SPEC-191, A-SPEC-244, A-SPEC-245, A-SPEC-263.1, A-SPEC-497.1
774
+ const reviewEvidence = (0, review_evidence_1.createReviewEvidenceHandlers)({
775
+ foreignRootReason: root => foreignRootReason(store, root),
776
+ refusal: reason => new HandlerRefusal(reason),
777
+ boundFindingsLedger: root => boundFindingsLedger(store, root),
778
+ boundNonceLedger: () => boundNonceLedger(store),
779
+ projectRootOf,
780
+ basisFor,
781
+ hasElicitor: () => Boolean(opts?.elicit),
782
+ tryElicit,
783
+ elicitApproval,
784
+ resolveHandlerApproval: (root, approval, action, now) => resolveHandlerApproval(root, store, approval, action, now),
785
+ refusalQueueHint: (root, request) => refusalQueueHint(root, store, request),
786
+ });
780
787
  // @implements A-SPEC-532.2 — the autonomous channel: the SAME synthesized-Approval shape that
781
788
  // rides the existing seal path, but its actor names `autonomous:<client>` so an audit can tell a
782
789
  // self-approved seal from a human-approved (elicitation) or operator (env/grant) one. Single-use
@@ -787,6 +794,13 @@ function makeRawHandlers(store, opts) {
787
794
  // @implements A-SPEC-587 — an upstream grade is DERIVED; the audit line names the A-SPECs it came from.
788
795
  rationale: `autonomous grant (${'HOLMES_AUTONOMOUS_APPROVAL'} enabled, spec grade auto${derivedFrom && derivedFrom.length > 0 ? ` — derived from ${derivedFrom.join(', ')}` : ''})`,
789
796
  });
797
+ // @implements A-SPEC-622, A-SPEC-100.2, A-SPEC-128, A-SPEC-132, A-SPEC-133, A-SPEC-151, A-SPEC-188, A-SPEC-189, A-SPEC-244, A-SPEC-245
798
+ const specApproval = (0, spec_approval_1.createSpecApprovalHandlers)({
799
+ store, resolver, projectRootOf, cachedScan, tryElicit, elicitApproval, autonomousApproval,
800
+ resolveHandlerApproval: (root, approval, action, now) => resolveHandlerApproval(root, store, approval, action, now),
801
+ refusalQueueHint: (root, request) => refusalQueueHint(root, store, request),
802
+ foreignRootReason: (root) => foreignRootReason(store, root),
803
+ });
790
804
  /**
791
805
  * Where the audit record for a governance act belongs — resolved BEFORE the act writes anything.
792
806
  *
@@ -826,3119 +840,124 @@ function makeRawHandlers(store, opts) {
826
840
  }
827
841
  return { ok: true, root: projectRootOf(root) };
828
842
  };
843
+ // @implements A-SPEC-538.2, A-SPEC-538.3, A-SPEC-545.3, A-SPEC-572.1, A-SPEC-574.2, A-SPEC-574.5
844
+ // These contracts also depend on the root/store/fetch capabilities still implemented here.
845
+ // Keep this composition obligation as well as the moved method-body anchors: a shared-helper
846
+ // change must retain their existing contract tests even when call-graph resolution is incomplete.
847
+ // @implements A-SPEC-621, A-SPEC-100.2, A-SPEC-128, A-SPEC-151, A-SPEC-222, A-SPEC-538.1, A-SPEC-255, A-SPEC-133, A-SPEC-188, A-SPEC-189, A-SPEC-244, A-SPEC-245
848
+ const specLifecycle = (0, spec_lifecycle_1.createSpecLifecycleHandlers)({
849
+ store,
850
+ resolveLedgerRoot,
851
+ resolveHandlerApproval: (root, approval, action, now) => resolveHandlerApproval(root, store, approval, action, now),
852
+ refusalQueueHint: (root, request) => refusalQueueHint(root, store, request),
853
+ });
854
+ const operatorInspection = (0, operator_inspection_1.createOperatorInspectionHandlers)({
855
+ listSpecs: () => store.list(),
856
+ readSpec: (id) => store.read(id),
857
+ specsRoot: () => store.specsRoot,
858
+ assertStoreReachable: (tool, root) => assertSpecStoreReachable(tool, store, root),
859
+ resolver,
860
+ resolveLedgerRoot,
861
+ fetchJson,
862
+ resolveHandlerApproval: (root, approval, action, now) => resolveHandlerApproval(root, store, approval, action, now),
863
+ refusalQueueHint: (root, request) => refusalQueueHint(root, store, request),
864
+ });
865
+ // @implements A-SPEC-189, A-SPEC-100.2 — shared store/root capabilities retain phase contracts.
866
+ const phaseQueries = (0, phase_queries_1.createPhaseQueryHandlers)({
867
+ listSpecs: () => store.list(),
868
+ boundSpecsRoot: () => store instanceof spec_store_1.LocalMarkdownRepository ? store.specsRoot : undefined,
869
+ projectRootOf,
870
+ });
871
+ // @implements A-SPEC-125.4, A-SPEC-128, A-SPEC-133, A-SPEC-189, A-SPEC-191, A-SPEC-225
872
+ // Risk contracts still depend on the shared store, root, scan and bound-ledger capabilities.
873
+ const riskAssessment = (0, risk_assessment_1.createRiskAssessmentHandlers)({
874
+ listSpecs: () => store.list(),
875
+ projectRootOf,
876
+ cachedScan,
877
+ boundNonceLedger: () => boundNonceLedger(store),
878
+ });
879
+ // @implements A-SPEC-203, A-SPEC-204, A-SPEC-298, A-SPEC-299, A-SPEC-536.1, A-SPEC-546.1, A-SPEC-565.2, A-SPEC-587.2, A-SPEC-501.1, A-SPEC-100.2, A-SPEC-156
880
+ const sliceOrchestration = (0, slice_orchestration_1.createSliceOrchestrationHandlers)({
881
+ listSpecs: () => store.list(),
882
+ unreadableAmong,
883
+ rawHandlersForStore: rawStore => makeRawHandlers(rawStore),
884
+ });
885
+ // @implements A-SPEC-126, A-SPEC-129, A-SPEC-181, A-SPEC-182, A-SPEC-184, A-SPEC-188, A-SPEC-189, A-SPEC-299
886
+ // Adoption contracts retain their shared root and configured-store obligations here.
887
+ const adoption = (0, adoption_1.createAdoptionHandlers)({
888
+ listSpecs: () => store.list(),
889
+ writeSpec: (...args) => store.write(...args),
890
+ assertReadableRoot,
891
+ occupiedTarget: (specs) => store instanceof spec_store_1.LocalMarkdownRepository
892
+ ? specs.map((s) => store.targetPathFor(s)).find((p) => fs.existsSync(p))
893
+ : undefined,
894
+ });
829
895
  return {
830
- async spec_create(a) {
831
- // @implements A-SPEC-169
832
- // `root` is a CONTROL argument. Without excluding it, the catch-all below stores it as
833
- // frontmatter and a machine-local absolute path lands in a git-shared document. A blacklist,
834
- // not a whitelist: domain fields (`priority`, `slice`, …) must keep flowing through without
835
- // anyone maintaining a list of them.
836
- const { type, id, title, depends_on = [], root: reqRoot, ...extra } = a;
837
- // The server binds its spec store once, so a `root` naming another project cannot be honoured.
838
- // Silently writing to this one is a data-integrity problem; refusing says so. Compared by
839
- // resolved identity (A-SPEC-163) — `<root>//.` is the same directory, not a different project.
840
- // @implements A-SPEC-189 §9 (round 11) — the question is "same PROJECT?", and this asked "does
841
- // `<root>/.ax/specs` equal the bound store?" — the default layout hardcoded. On the first-class
842
- // `--specs-dir docs/specs` deployment no root could ever satisfy it: measured, the project's own
843
- // root was refused as another project's, so `spec_create` was unusable there. `spec_approve`
844
- // learned this in round 3 and compares at project level; this is the same comparison.
845
- // @implements A-SPEC-189 §16 (round 13) — the siblings all ask "did the derivation actually
846
- // FIND a marker?" before speaking for a project (spec_approve, phase_status,
847
- // foreignRootReason). Without it, a store whose derivation lands on itself (an absolute
848
- // `--specs-dir` outside any `.ax` tree) calls itself the bound project and refuses the
849
- // project's real root — the inverse defect rounds 3/9/11 fixed elsewhere.
850
- // 파생은 이 분기 안에서만, 그리고 실패는 '표지를 못 찾았다'로 읽는다 — 스펙 트리를 이제
851
- // 만들려는 참이라 스토어 경로가 아직 없을 수 있고, 그것이 예외가 되어선 안 된다.
852
- const boundSpecsRoot = store instanceof spec_store_1.LocalMarkdownRepository ? store.specsRoot : null;
853
- const derivedForCreate = (() => {
854
- if (boundSpecsRoot === null)
855
- return null;
856
- try {
857
- const d = projectRootOf(boundSpecsRoot);
858
- return fs.existsSync(path.join(d, '.ax')) ? d : null;
859
- }
860
- catch {
861
- return null;
862
- }
863
- })();
864
- if (typeof reqRoot === 'string' && reqRoot !== '' && derivedForCreate !== null) {
865
- const asked = (0, write_target_1.resolveTarget)(projectRootOf(reqRoot), '.');
866
- const bound = (0, write_target_1.resolveTarget)(derivedForCreate, '.');
867
- if (asked !== bound) {
868
- return {
869
- ok: false,
870
- // A-SPEC-169: 두 경로를 모두 이름한다 — 어느 저장소가 묶여 있는지 호출자가 보아야 한다.
871
- reason: `이 서버는 ${bound} 프로젝트(스펙 저장소 ${boundSpecsRoot})에 바인딩되어 있습니다`
872
- + ` — 요청한 root ${reqRoot}는 ${asked}를 가리킵니다.`
873
- + ' 다른 프로젝트를 조용히 수정하지 않기 위해 거부합니다.',
874
- };
875
- }
876
- }
877
- // @implements A-SPEC-420
878
- // The branch above steps aside when the server's spec directory does not exist yet, so that a
879
- // project can create its first spec. Right intent, but the gap let something else through:
880
- // measured against the INSTALLED server from a cwd outside any project, `spec_create` with
881
- // `root` naming an established other project wrote REQ-990 into the SERVER's directory and
882
- // answered `{"created":"REQ-990"}`. The caller's project gained nothing and was told it had.
883
- //
884
- // Layout-independent on purpose: the test is not "<root>/.ax/specs equals my store" that
885
- // hardcodes the default layout and, as the note above records, made `--specs-dir docs/specs`
886
- // deployments refuse their own root. It is "you named a project that already exists, and my
887
- // store is not inside it", which is true whatever the specs directory is called.
888
- if (typeof reqRoot === 'string' && reqRoot !== '' && derivedForCreate === null && boundSpecsRoot !== null) {
889
- const real = (p2) => { try {
890
- return fs.realpathSync(p2);
891
- }
892
- catch {
893
- return path.resolve(p2);
894
- } };
895
- let askedRoot = null;
896
- try {
897
- askedRoot = real(projectRootOf(reqRoot));
898
- }
899
- catch {
900
- askedRoot = null;
901
- }
902
- if (askedRoot !== null && fs.existsSync(path.join(askedRoot, '.ax'))
903
- && !real(boundSpecsRoot).startsWith(askedRoot + path.sep)) {
904
- return {
905
- ok: false,
906
- reason: `이 서버의 스펙 저장소 ${boundSpecsRoot} 는 아직 만들어지지 않았고 ${askedRoot} 안에도`
907
- + ' 있지 않습니다 — 요청한 root 는 이미 스펙 저장소를 가진 다른 프로젝트입니다.'
908
- + ' 여기서 만들면 파일은 호출자가 지정한 곳이 아니라 이 서버 쪽에 생깁니다.'
909
- + ' 서버를 그 프로젝트에서 시작하거나 HOLMES_SPECS 로 그 프로젝트의 스펙 경로를 지정하십시오.',
910
- };
911
- }
912
- }
913
- // @implements A-SPEC-174
914
- // Refuse what can never become valid; accept what is merely not filled in yet.
915
- //
916
- // Measured 2026-08-12 against the installed server: `REQ-7`, `hello`, `A-SPEC-7.1` and four
917
- // more were all created and are all refused at approval on `bad-id`. The id IS the filename,
918
- // so the author's only repair is to delete the spec and start over — after writing the body.
919
- // The stubs below are the opposite case: they are placeholders BY DESIGN, and blocking
920
- // approval until someone answers them is what they are for (A-SPEC-146).
921
- //
922
- // Every judgement here quotes SPEC_TYPES, which is what approval reads. A second statement of
923
- // the rule drifts, and the drift is either this defect again or its worse inverse — refused
924
- // at creation, accepted at approval.
925
- const def = spec_types_1.SPEC_TYPES[type];
926
- if (!def) {
927
- return { ok: false, reason: `알 수 없는 스펙 타입 "${String(type)}" — 유효한 타입: ${spec_types_1.SPEC_ORDER.join(', ')}` };
928
- }
929
- if (!def.idRegex.test(String(id))) {
930
- return {
931
- ok: false,
932
- reason: `id "${String(id)}"는 ${def.type}의 형식 ${def.idRegex}에 맞지 않습니다 — 예: ${def.example}.`
933
- + ' 승인 시점에 거부될 값이므로 지금 거부합니다(id는 파일명이라 나중에 고칠 수 없습니다).',
934
- };
935
- }
936
- for (const pid of depends_on) {
937
- // The parent is judged WITHOUT resolving it: not existing yet is a legitimate state an
938
- // author reaches by creating the child first. A wrong type is wrong forever.
939
- const ptype = (0, spec_types_1.specTypeOfId)(String(pid));
940
- if (ptype === null) {
941
- return { ok: false, reason: `depends_on "${String(pid)}"는 어떤 스펙 타입의 id 형식에도 맞지 않습니다 — 그 id를 가진 스펙은 존재할 수 없습니다.` };
942
- }
943
- if (!def.parents.includes(ptype)) {
944
- return { ok: false, reason: `depends_on "${String(pid)}"는 ${ptype}입니다 — ${def.type}의 부모는 ${def.parents.join('|') || '없음(빈 depends_on)'}이어야 합니다.` };
945
- }
946
- }
947
- // @implements A-SPEC-252
948
- // A new id may not LEAP past the sequence. spec_slice_init assigns nextId = max(base)+1, which
949
- // is only predictable while numbering stays sequential; a hand-picked wild id (REQ-1403) once
950
- // dragged max to 1403 and every slice after jumped to 1404. Refused here, at the one door that
951
- // lets a manual id in — so max+1 can never be poisoned again. After the shape check on purpose:
952
- // an id whose base cannot be read is the shape check's to name, not this guard's.
953
- const corpus = await store.list();
954
- const seq = (0, spec_id_guard_1.sequentialIdVerdict)(String(id), corpus.map((s) => s.id));
955
- if (!seq.ok)
956
- return { ok: false, reason: seq.reason };
957
- // @implements A-SPEC-188
958
- // Creation creates. Probed before this check existed: spec_create over an approved+sealed
959
- // REQ returned {"created"} while the disk went draft / seal gone / prose gone — the sanctioned
960
- // door destroying what risk_check guards every shell path against. Checked AFTER the format
961
- // refusals on purpose: a malformed id must keep its own message (order is pinned by test).
962
- // Legacy (typeless) documents count as existing too — overwriting one makes the store's
963
- // orphan removal delete the differently-named original file along with its history.
964
- const existing = corpus.find((s) => s.id === id);
965
- if (existing) {
966
- const sealed = existing.status === 'approved' || Boolean(existing.frontmatter?.approved_digest);
967
- const kind = existing.type ? `${existing.type} (status: ${existing.status})` : `옛 형식 문서 (status: ${existing.status})`;
968
- return {
969
- ok: false,
970
- reason: `${id}은(는) 이미 존재합니다 — ${kind}. `
971
- + (sealed
972
- ? '봉인(approved)된 문서이므로, 내용을 바꾸려면 파일을 편집한 뒤 spec_approve로 재봉인하십시오. '
973
- : '그 문서를 고치려는 것이면 파일을 직접 편집하십시오. ')
974
- + '새 문서를 만들려는 것이면 다른 id를 쓰십시오. 생성은 아무것도 덮어쓰지 않습니다.',
975
- };
976
- }
977
- const sections = Object.fromEntries(def.requiredSections.map((s) => [s, spec_types_1.FIELD_PLACEHOLDER]));
978
- // Stub the required FRONTMATTER too, not just the sections. Without this every created spec
979
- // fails the project's own `spec_validate` on `missing-field` the moment it is written, and
980
- // the author has to rediscover the per-type field list by reading spec-types.ts. Caller-
981
- // supplied values win; `coverage` needs a shaped default because T-SPEC reads its keys.
982
- // `source` is a citation LIST, so a bare 'TODO' string would fail the shape check the moment
983
- // the spec is written. The stub is validly shaped but obviously unfilled: it validates while
984
- // the REQ is drafted and blocks approval until a real origin replaces the placeholder.
985
- const stub = (f) => {
986
- if (f === 'coverage')
987
- return { normal: false, corner: false, negative: false, boundary: false };
988
- if (f === 'source')
989
- return [{ kind: 'other', ref: spec_types_1.CITATION_PLACEHOLDER, note: 'cite the real origin before approval' }];
990
- return spec_types_1.FIELD_PLACEHOLDER;
991
- };
992
- const required = Object.fromEntries(def.requiredFields.filter((f) => !(f in extra)).map((f) => [f, stub(f)]));
993
- // @implements A-SPEC-146
994
- // Scaffolded but not required by `validateSpec` — see SpecTypeDef.stubOnlyFields for why the
995
- // gap exists. The stub value is `TODO`, which is NOT an accepted grade, so the field is
996
- // visible to the author while the approval still refuses until it is actually answered.
997
- // Pre-filling `none` would be easier and would make the formulaic answer the default, which is
998
- // exactly what ADR-013 recorded as its revisit trigger.
999
- const stubOnly = Object.fromEntries((def.stubOnlyFields ?? []).filter((f) => !(f in extra)).map((f) => [f, stub(f)]));
1000
- const spec = { id, type, title, status: 'draft', dependsOn: depends_on, frontmatter: { ...required, ...stubOnly, ...extra }, sections };
1001
- // @implements A-SPEC-188 — existence is a PATH question, not only a list() question.
1002
- // Round-2 review probed the gap: list() drops unparseable files, so a prose note or
1003
- // broken-YAML document sitting at the exact target path was invisible to the id check above
1004
- // and got silently overwritten — with the refusal text elsewhere promising the opposite.
1005
- // A file the store cannot read as this id is a file a human must look at first.
1006
- if (store instanceof spec_store_1.LocalMarkdownRepository) {
1007
- const target = store.targetPathFor(spec);
1008
- if (fs.existsSync(target)) {
1009
- return {
1010
- ok: false,
1011
- reason: `${id}의 목적지(${target})에 파일이 이미 있는데 스토어가 이 id의 스펙으로 읽지 못합니다`
1012
- + ' — 손으로 쓰던 초안이거나 깨진 문서일 수 있습니다. 사람이 확인해 옮기거나 고치기 전까지 덮어쓰지 않습니다'
1013
- + ' (doctor가 읽을 수 없는 스펙 파일을 보고합니다).',
1014
- };
1015
- }
1016
- }
1017
- try {
1018
- await store.write(spec);
1019
- }
1020
- catch (e) {
1021
- // The pre-write path check above makes this a narrow race backstop; the store guard is the
1022
- // authority and this act relays it in the same wording the other writers use.
1023
- if (e instanceof spec_store_1.TargetPathOccupiedError) {
1024
- return {
1025
- ok: false,
1026
- reason: `${id}의 목적지(${e.occupiedPath})에 스토어가 읽지 못하는 파일이 이미 있습니다 — 덮어쓰지 않습니다.`
1027
- + ' 사람이 확인해 옮기거나 고친 뒤 다시 시도하십시오.',
1028
- };
1029
- }
1030
- throw e;
1031
- }
1032
- return { created: id };
1033
- },
1034
- async spec_validate(a) {
1035
- const specs = await store.list();
1036
- const spec = specs.find((s) => s.id === a.id);
1037
- if (!spec)
1038
- return { ok: false, findings: [{ level: 'error', code: 'not-found', message: a.id }] };
1039
- return (0, validator_1.validateSpec)(spec, resolver(specs));
1040
- },
1041
- /**
1042
- * @implements A-SPEC-132
1043
- * Approval as an ACT: validate → seal → flip → ledger, in one call. This is the designed
1044
- * reversal of "no approval tool exists" (which promote-slice documented while it was true) —
1045
- * the act now includes digest computation a hand edit cannot perform honestly. Fail-closed on
1046
- * the SERVER-environment approval: nothing in the request payload can substitute, because the
1047
- * agent authors the payload and the operator authors the environment.
1048
- */
1049
- /**
1050
- * @implements A-SPEC-184
1051
- * Raise ONE named document from an older spec format to the current one.
1052
- *
1053
- * The compatibility policy this implements: older documents are read and left alone by default,
1054
- * and rise only when a human points at the one they intend to use. There is deliberately no bulk
1055
- * path — 155 documents here and 90 in the measured adoption target, and which of them are still
1056
- * live specifications is a judgement only a person holds.
1057
- *
1058
- * It declares a KIND. It does not confer approval: 37 of this repository's legacy documents read
1059
- * `status: Approved`, and carrying that across would mint approvals that never passed the sealing
1060
- * act. The old value is preserved as evidence and the document restarts at `draft`.
1061
- *
1062
- * Takes no `root`: the store is bound at server construction, exactly as `spec_create` is.
1063
- */
1064
- async spec_upgrade(a) {
1065
- // @implements A-SPEC-188 — same read-then-write shape as spec_approve, so the same window
1066
- // AND the same duplicate hazard: read() resolves the last-walked copy, and the write's orphan
1067
- // removal would delete the other. Refuse rather than pick a side.
1068
- if ((await store.list()).filter((s) => s.id === a.id).length > 1) {
1069
- return {
1070
- ok: false,
1071
- reason: `${a.id}이(가) 스토어에 두 번 이상 존재합니다 — 어느 사본이 진본인지 도구가 고를 수 없어 보강 전에 거부합니다.`
1072
- + ' doctor로 중복 파일을 확인해 하나로 정리한 뒤 다시 시도하십시오.',
1073
- };
1074
- }
1075
- const cur = await store.read(a.id);
1076
- if (!cur)
1077
- return { ok: false, reason: `spec ${a.id} not found` };
1078
- const spec = cur.spec;
1079
- const plan = (0, legacy_format_1.upgradePlan)(spec);
1080
- if (!plan) {
1081
- const why = (0, legacy_format_1.legacyMessage)(spec);
1082
- // No plan for two opposite reasons. Already current is success and writes nothing — a second
1083
- // run must not churn the file. An unsupported kind is a refusal, and its wording must not
1084
- // send the holder after a type that does not exist.
1085
- return why ? { ok: false, reason: why } : { ok: true, upgraded: false, reason: `${a.id}은(는) 이미 현행 양식입니다` };
1086
- }
1087
- const upgraded = {
1088
- ...spec,
1089
- type: plan.type,
1090
- status: 'draft',
1091
- frontmatter: (0, legacy_format_1.upgradedFrontmatter)(spec, plan),
1092
- };
1093
- try {
1094
- await store.write(upgraded, { expectedVersion: cur.version });
1095
- }
1096
- catch (e) {
1097
- if (e instanceof spec_store_2.SpecVersionConflictError) {
1098
- return {
1099
- ok: false,
1100
- reason: `보강 진행 중 ${a.id}이(가) 바뀌었습니다 — 바뀐 내용을 확인하고 다시 시도하십시오.`
1101
- + ' 이번 보강은 아무것도 쓰지 않았습니다.',
1102
- };
1103
- }
1104
- // @implements A-SPEC-188 — the upgrade RELOCATES by design (legacy filename → canonical),
1105
- // so an unreadable file at the canonical path is the store's occupied-target refusal.
1106
- if (e instanceof spec_store_1.TargetPathOccupiedError) {
1107
- return {
1108
- ok: false,
1109
- reason: `${a.id}의 목적지(${e.occupiedPath})에 스토어가 읽지 못하는 파일이 이미 있습니다 — 덮어쓰지 않습니다.`
1110
- + ' 사람이 확인해 옮기거나 고친 뒤 다시 시도하십시오. 이번 보강은 아무것도 쓰지 않았습니다.',
1111
- };
1112
- }
1113
- throw e;
1114
- }
1115
- // Report what still stands between this document and approval. Silence would read as "done",
1116
- // and a legacy document typically lacks most required sections.
1117
- const after = await store.list();
1118
- const remaining = (0, validator_1.validateSpec)(upgraded, resolver(after)).findings.filter((f) => f.level === 'error');
1119
- return { ok: true, upgraded: true, type: plan.type, legacyStatus: plan.legacyStatus, remaining };
1120
- },
1121
- /**
1122
- * Move a document to `outdated` — the only path there.
1123
- *
1124
- * @implements A-SPEC-222
1125
- * `outdated` was in SPEC_STATUSES with no code writing it and no document carrying it: a
1126
- * declared state nothing could reach. That mattered the moment 23 deprecated REQs needed
1127
- * cleaning up, because the only transition tool sends everything to `draft`, and calling a
1128
- * retired document "draft" is a worse lie than the non-canonical word it replaced.
1129
- *
1130
- * Retirement withdraws authority rather than granting it, so it does NOT need the approval key
1131
- * by default. Two cases invert that, and the threshold follows the RISK rather than the name of
1132
- * the act:
1133
- *
1134
- * - A sealed document. The code gate demands an approved T-SPEC naming the target A-SPEC;
1135
- * retiring that T-SPEC removes the demand. Unguarded, retirement is an approval bypass.
1136
- * - A document an APPROVED spec depends on. That chain is holding something up right now.
1137
- */
1138
- async spec_retire(a) {
1139
- const all = await store.list();
1140
- if (all.filter((s) => s.id === a.id).length > 1) {
896
+ spec_create: specAuthoring.spec_create,
897
+ spec_validate: specQueries.spec_validate,
898
+ spec_upgrade: specAuthoring.spec_upgrade,
899
+ spec_retire: specLifecycle.spec_retire,
900
+ spec_renumber: specLifecycle.spec_renumber,
901
+ spec_unseal: specLifecycle.spec_unseal,
902
+ approval_status: operatorInspection.approval_status,
903
+ ledger_timeline: operatorInspection.ledger_timeline,
904
+ rtm_dashboard: operatorInspection.rtm_dashboard,
905
+ spec_approve: specApproval.spec_approve,
906
+ spec_list: specQueries.spec_list,
907
+ spec_next: specQueries.spec_next,
908
+ rtm_check: workspaceQueries.rtm_check,
909
+ citation_pin: specApproval.citation_pin,
910
+ phase_status: phaseQueries.phase_status,
911
+ phase_check: phaseQueries.phase_check,
912
+ cpg_scan: workspaceQueries.cpg_scan,
913
+ taint_scan: taintScreening.taint_scan,
914
+ test_run: testExecution.test_run,
915
+ issue_localize: workspaceQueries.issue_localize,
916
+ maintenance_analyze: maintenanceAnalysis.maintenance_analyze,
917
+ ...(0, maintenance_evidence_1.createMaintenanceEvidenceHandlers)({
918
+ foreignRootReason: root => foreignRootReason(store, root),
919
+ projectRootOf,
920
+ refusal: reason => new HandlerRefusal(reason),
921
+ fileDigestOf,
922
+ }),
923
+ // @implements A-SPEC-642 — placed in the one slot no public-key-order pin covers (the handler suites pin
924
+ // spec_create..spec_upgrade, spec_retire..spec_unseal, spec_unseal+5, spec_approve+4, spec_next+3,
925
+ // citation_pin+4, phase_check+3, cpg_scan+3, taint_scan..issue_localize, test_run+3, issue_localize+3,
926
+ // rtm_impact+4, rtm_reindex..review_record, review_prepare..risk_check, risk_check±, and the tail).
927
+ ledger_reconcile: operatorInspection.ledger_reconcile,
928
+ rtm_impact: graphOperations.rtm_impact,
929
+ rtm_reindex: graphOperations.rtm_reindex,
930
+ context_bundle: reviewQueries.context_bundle,
931
+ review_scope: reviewQueries.review_scope,
932
+ review_prepare: reviewQueries.review_prepare,
933
+ review_record: reviewEvidence.review_record,
934
+ review_status: reviewEvidence.review_status,
935
+ risk_check: riskAssessment.risk_check,
936
+ reverse_scan: adoption.reverse_scan,
937
+ reverse_draft: adoption.reverse_draft,
938
+ reverse_anchor: adoption.reverse_anchor,
939
+ spec_slice_init: sliceOrchestration.spec_slice_init,
940
+ spec_slice_approve: sliceOrchestration.spec_slice_approve,
941
+ spec_remediate: sliceOrchestration.spec_remediate,
942
+ // @implements A-SPEC-623 — append the capability while preserving existing tool neighbors.
943
+ ...(0, workspace_identity_2.createWorkspaceIdentityHandlers)({
944
+ foreignRootReason: (root) => foreignRootReason(store, root),
945
+ storeRoot: store instanceof spec_store_1.LocalMarkdownRepository ? store.specsRoot : undefined,
946
+ }),
947
+ ...(0, entity_store_1.createEntityStoreHandlers)({ storeRoot: store instanceof spec_store_1.LocalMarkdownRepository ? store.specsRoot : undefined }),
948
+ ...(0, entity_renumber_1.createEntityRenumberHandlers)({ storeRoot: store instanceof spec_store_1.LocalMarkdownRepository ? store.specsRoot : undefined }),
949
+ ...(0, entity_integration_1.createEntityIntegrationHandlers)({
950
+ storeRoot: store instanceof spec_store_1.LocalMarkdownRepository ? store.specsRoot : undefined,
951
+ // @implements A-SPEC-632 discharge rebuilds through the same publication and warming paths
952
+ // rtm_reindex uses; the impact population (approved-only) so the next rtm_impact reuses it.
953
+ rebuildFor: (root) => {
954
+ let scanned;
955
+ const scan = () => (scanned ??= cachedScan(root));
1141
956
  return {
1142
- ok: false,
1143
- reason: `${a.id}이() 스토어에 이상 존재합니다 — 어느 사본이 진본인지 도구가 고를 수 없어 폐기 전에 거부합니다.`,
957
+ publishGraph: async () => (0, graph_operations_2.publishCurrentGraph)(root, (await store.list()).filter((s) => s.status === 'approved'), scan()),
958
+ warmSemantic: () => (0, graph_operations_2.warmSemanticCache)(root, scan()),
1144
959
  };
1145
- }
1146
- const cur = await store.read(a.id);
1147
- if (!cur)
1148
- return { ok: false, reason: `spec ${a.id} not found` };
1149
- const spec = cur.spec;
1150
- // @implements A-SPEC-222 — a document with no `type:` cannot be filed: the store derives its
1151
- // folder from the kind, and there is none. Refuse rather than infer, because a wrongly
1152
- // inferred kind MOVES the document into another folder. Measured 2026-08-21: without this the
1153
- // store threw `Cannot read properties of undefined (reading 'folder')` on the first of the 23
1154
- // legacy documents this tool was built for — every unit fixture carried `type:`, which is how
1155
- // it got through. Declaring the kind is `spec_upgrade`'s job, so the refusal names it.
1156
- if (!spec.type) {
1157
- return {
1158
- ok: false,
1159
- reason: `${a.id}에는 \`type:\` 선언이 없습니다(구형식 문서) — 어느 폴더에 속하는지 알 수 없어 폐기할 수 없습니다.`
1160
- + ' 종류를 추론하지 않는 이유는 잘못 추론한 종류가 문서를 다른 폴더로 옮기기 때문입니다.'
1161
- + ` 먼저 spec_upgrade({ id: "${a.id}" })로 형식을 올린 뒤 다시 폐기하십시오 — 그 도구가 이전 status를 legacy_status로 보존합니다.`,
1162
- };
1163
- }
1164
- // Idempotent, and it writes NOTHING on the second call: overwriting the first retirement's
1165
- // reason would make the record graffiti rather than history.
1166
- if (spec.status === 'outdated') {
1167
- return { ok: true, retired: false, id: a.id, dependents: [], reason: `${a.id}은(는) 이미 outdated 입니다` };
1168
- }
1169
- const approvalRaw = process.env.HOLMES_APPROVAL;
1170
- let approval;
1171
- try {
1172
- approval = approvalRaw ? JSON.parse(approvalRaw) : undefined;
1173
- }
1174
- catch {
1175
- approval = undefined;
1176
- }
1177
- const retireResolved = resolveHandlerApproval(a.root, store, approval, { kind: 'spec-approve', target: a.id }, new Date().toISOString());
1178
- const covered = retireResolved !== undefined;
1179
- const sealed = typeof spec.frontmatter.approved_digest === 'string';
1180
- if (sealed && !covered) {
1181
- return {
1182
- ok: false,
1183
- reason: `${a.id}은(는) 봉인된 문서입니다 — 폐기는 이 행위를 덮는 유효한 대역외 HOLMES_APPROVAL 이 필요합니다.`
1184
- + ' 코드 게이트를 막고 있는 approved T-SPEC 을 폐기하면 그 게이트가 열리므로, 폐기가 승인 우회 경로가 되지 않도록 fail-closed 로 막습니다.'
1185
- + ' (범위를 쓰면 kind "spec-approve")'
1186
- + refusalQueueHint(a.root, store, { kind: 'spec-approve', target: a.id, why: '봉인된 스펙의 폐기' }),
1187
- };
1188
- }
1189
- // @implements A-SPEC-245 — a grant that authorized breaking a seal is spent by it.
1190
- if (sealed && retireResolved?.source === 'grant' && retireResolved.root && retireResolved.approval.nonce) {
1191
- (0, approval_grants_1.consumeGrantFile)(retireResolved.root, retireResolved.approval.nonce);
1192
- }
1193
- const dependents = all.filter((s) => s.id !== a.id && s.dependsOn.includes(a.id));
1194
- const blocking = dependents.filter((s) => s.status === 'approved').map((s) => s.id);
1195
- if (blocking.length > 0) {
1196
- return {
1197
- ok: false,
1198
- reason: `${a.id}을(를) 폐기하면 approved 문서 ${blocking.join(', ')}의 사슬이 끊깁니다 — 지금 무언가를 지탱하고 있는 사슬이므로 거부합니다.`
1199
- + ' 해당 문서를 먼저 폐기하거나 부모를 다른 문서로 옮기십시오.',
1200
- };
1201
- }
1202
- // Destination BEFORE the write, the order A-SPEC-188 fixed for spec_approve: a resolution
1203
- // failure must leave nothing behind.
1204
- const dest = resolveLedgerRoot(a.root);
1205
- if (!dest.ok)
1206
- return { ok: false, reason: dest.reason };
1207
- const ledgerRoot = dest.root;
1208
- const fm = { ...spec.frontmatter };
1209
- // Blank is omitted, never written: `retired_reason: ""` asserts a ground that does not exist,
1210
- // the same rule `legacy_status` follows. A document already carrying `superseded_by` has
1211
- // answered "why" already and is not asked twice.
1212
- const reason = typeof a.reason === 'string' ? a.reason.trim() : '';
1213
- if (reason && fm.superseded_by === undefined)
1214
- fm.retired_reason = reason;
1215
- try {
1216
- await store.write({ ...spec, status: 'outdated', frontmatter: fm }, { expectedVersion: cur.version });
1217
- }
1218
- catch (e) {
1219
- if (e instanceof spec_store_2.SpecVersionConflictError) {
1220
- return { ok: false, reason: `폐기 진행 중 ${a.id}이(가) 바뀌었습니다 — 확인 후 다시 시도하십시오. 이번 폐기는 아무것도 쓰지 않았습니다.` };
1221
- }
1222
- throw e;
1223
- }
1224
- new ledger_store_1.FileLedgerStore(path.join(ledgerRoot, '.ax', 'ledger')).append({
1225
- ts: new Date().toISOString(),
1226
- actor: approval?.actor ?? 'unattributed',
1227
- kind: 'spec-retired',
1228
- summary: `retired ${a.id}${reason ? ` — ${reason}` : ''}`,
1229
- inputs: [a.id],
1230
- rationale: reason || (approval?.rationale ?? 'retired'),
1231
- ...(approval ? { authorization: (0, provenance_chain_1.authorizationRef)(approval.actor, approval.token) } : {}),
1232
- });
1233
- return { ok: true, retired: true, id: a.id, dependents: dependents.map((s) => s.id) };
1234
- },
1235
- /**
1236
- * @implements A-SPEC-538.1
1237
- * The inverse of spec_approve: returns a SEALED (approved) spec to an editable `draft`, clearing
1238
- * `approved_digest` and `parent_digests` in ONE act, and records `spec-unsealed`. Hand-editing
1239
- * only `status` leaves seal residue that later trips validation; this atomizes the reverse.
1240
- *
1241
- * Un-sealing WITHDRAWS a seal, so — like retiring a sealed document — it demands a covering
1242
- * out-of-band HOLMES_APPROVAL (fail-closed): un-sealing an approved T-SPEC removes the code
1243
- * gate's demand, so an unguarded un-seal would be an approval bypass. And it refuses when an
1244
- * APPROVED spec depends on the target, because that dependent's `parent_digests` — the snapshot
1245
- * drift detection compares against — would silently go stale.
1246
- */
1247
- // @implements A-SPEC-255 — the WIRING only. The judgment is `planRenumber`, which is pure and
1248
- // tested directly; this handler adds no rules of its own. Re-sealing is deliberately absent:
1249
- // `spec_approve` is the only sealer, so the plan reports the two ORDERS and the caller runs them.
1250
- // @implements A-SPEC-255 — WIRING only. The judgment lives in `planRenumber`, which is pure and
1251
- // tested directly; nothing here adds a rule. Re-sealing is deliberately absent: `spec_approve`
1252
- // is the only sealer (a second sealer becomes a second truth), so the plan reports the two
1253
- // ORDERS and the caller runs them. `dryRun` defaults to true — a renumber is read before it runs.
1254
- async spec_renumber(a) {
1255
- const specsRoot = store.specsRoot;
1256
- if (typeof specsRoot !== 'string')
1257
- return { ok: false, reason: '파일 스토어에 묶인 서버에서만 리넘버할 수 있습니다.' };
1258
- const projectRoot = path.resolve(specsRoot, '..', '..');
1259
- const plan = (0, renumber_1.planRenumber)({
1260
- specs: (0, renumber_1.readSpecsForRenumber)(specsRoot),
1261
- sources: (0, renumber_1.readSourcesForRenumber)(projectRoot),
1262
- oldBase: String(a.oldBase), newBase: String(a.newBase),
1263
- });
1264
- if (plan.refusal)
1265
- return { ok: false, reason: plan.refusal };
1266
- if (a.dryRun !== false)
1267
- return { ok: true, dryRun: true, plan };
1268
- const movedSpecs = (0, renumber_1.applyRenumber)(specsRoot, { ...plan, anchors: [] });
1269
- (0, renumber_1.applyRenumber)(projectRoot, { ...plan, moves: [], dependsOn: [], slices: [] });
1270
- new ledger_store_1.FileLedgerStore(path.join(projectRoot, '.ax', 'ledger')).append({
1271
- ts: new Date().toISOString(),
1272
- actor: 'spec_renumber',
1273
- kind: 'spec-renumbered',
1274
- summary: `renumbered base ${a.oldBase} -> ${a.newBase}: ${movedSpecs} spec(s), ${plan.anchors.length} anchored file(s), ${plan.proseCandidates.length} prose candidate(s) left for a human`,
1275
- inputs: plan.moves.map((m) => `${m.oldId}->${m.newId}`),
1276
- });
1277
- return { ok: true, dryRun: false, movedSpecs, plan };
1278
- },
1279
- async spec_unseal(a) {
1280
- const all = await store.list();
1281
- if (all.filter((s) => s.id === a.id).length > 1) {
1282
- return {
1283
- ok: false,
1284
- reason: `${a.id}이(가) 스토어에 두 번 이상 존재합니다 — 어느 사본이 진본인지 도구가 고를 수 없어 봉인 해제 전에 거부합니다.`,
1285
- };
1286
- }
1287
- const cur = await store.read(a.id);
1288
- if (!cur) {
1289
- const dir = store.specsRoot;
1290
- const unreadable = typeof dir === 'string' ? (0, spec_store_1.unreadableSpecFiles)(dir) : [];
1291
- return { ok: false, reason: (0, spec_store_1.notFoundReason)(a.id, unreadable) };
1292
- }
1293
- const spec = cur.spec;
1294
- if (!spec.type) {
1295
- return {
1296
- ok: false,
1297
- reason: `${a.id}에는 \`type:\` 선언이 없습니다(구형식 문서) — 어느 폴더에 속하는지 알 수 없어 봉인 해제할 수 없습니다.`
1298
- + ` 먼저 spec_upgrade({ id: "${a.id}" })로 형식을 올린 뒤 다시 시도하십시오.`,
1299
- };
1300
- }
1301
- // Sealed ⇔ carries approved_digest. Un-sealing an already-draft spec writes NOTHING (a second
1302
- // un-seal is history, not graffiti) — mirrors spec_retire's idempotent no-op.
1303
- const sealed = typeof spec.frontmatter.approved_digest === 'string';
1304
- if (!sealed) {
1305
- return { ok: true, unsealed: false, id: a.id, dependents: [], reason: `${a.id}은(는) 이미 미봉인(draft) 상태입니다` };
1306
- }
1307
- const approvalRaw = process.env.HOLMES_APPROVAL;
1308
- let approval;
1309
- try {
1310
- approval = approvalRaw ? JSON.parse(approvalRaw) : undefined;
1311
- }
1312
- catch {
1313
- approval = undefined;
1314
- }
1315
- const resolved = resolveHandlerApproval(a.root, store, approval, { kind: 'spec-approve', target: a.id }, new Date().toISOString());
1316
- if (resolved === undefined) {
1317
- return {
1318
- ok: false,
1319
- reason: `${a.id}은(는) 봉인된 문서입니다 — 봉인 해제는 이 행위를 덮는 유효한 대역외 HOLMES_APPROVAL 이 필요합니다.`
1320
- + ' 봉인을 해제하면 코드 게이트가 요구하던 approved 스펙의 봉인이 사라지므로, 해제가 승인 우회 경로가 되지 않도록 fail-closed 로 막습니다.'
1321
- + ' (범위를 쓰면 kind "spec-approve")'
1322
- + refusalQueueHint(a.root, store, { kind: 'spec-approve', target: a.id, why: '봉인된 스펙의 해제' }),
1323
- };
1324
- }
1325
- // @implements A-SPEC-245 — a grant that authorized breaking a seal is spent by it.
1326
- if (resolved.source === 'grant' && resolved.root && resolved.approval.nonce) {
1327
- (0, approval_grants_1.consumeGrantFile)(resolved.root, resolved.approval.nonce);
1328
- }
1329
- const dependents = all.filter((s) => s.id !== a.id && s.dependsOn.includes(a.id));
1330
- const blocking = dependents.filter((s) => s.status === 'approved').map((s) => s.id);
1331
- if (blocking.length > 0) {
1332
- return {
1333
- ok: false,
1334
- reason: `${a.id}의 봉인을 해제하면 approved 문서 ${blocking.join(', ')}의 parent_digests 가 stale 이 됩니다 — 지금 그 사슬을 지탱하고 있으므로 거부합니다.`
1335
- + ' 해당 문서를 먼저 해제/폐기하거나 부모를 다른 문서로 옮기십시오.',
1336
- };
1337
- }
1338
- // Destination BEFORE the write (A-SPEC-188 order): a resolution failure leaves nothing behind.
1339
- const dest = resolveLedgerRoot(a.root);
1340
- if (!dest.ok)
1341
- return { ok: false, reason: dest.reason };
1342
- const ledgerRoot = dest.root;
1343
- const fm = { ...spec.frontmatter };
1344
- delete fm.approved_digest; // the seal this act withdraws
1345
- delete fm.parent_digests;
1346
- try {
1347
- await store.write({ ...spec, status: 'draft', frontmatter: fm }, { expectedVersion: cur.version });
1348
- }
1349
- catch (e) {
1350
- if (e instanceof spec_store_2.SpecVersionConflictError) {
1351
- return { ok: false, reason: `봉인 해제 진행 중 ${a.id}이(가) 바뀌었습니다 — 확인 후 다시 시도하십시오. 이번 해제는 아무것도 쓰지 않았습니다.` };
1352
- }
1353
- throw e;
1354
- }
1355
- new ledger_store_1.FileLedgerStore(path.join(ledgerRoot, '.ax', 'ledger')).append({
1356
- ts: new Date().toISOString(),
1357
- actor: approval?.actor ?? 'unattributed',
1358
- kind: 'spec-unsealed',
1359
- summary: `unsealed ${a.id}`,
1360
- inputs: [a.id],
1361
- rationale: approval?.rationale ?? 'unsealed',
1362
- ...(approval ? { authorization: (0, provenance_chain_1.authorizationRef)(approval.actor, approval.token) } : {}),
1363
- });
1364
- return { ok: true, unsealed: true, id: a.id, dependents: dependents.map((s) => s.id) };
1365
- },
1366
- /**
1367
- * @implements A-SPEC-538.2
1368
- * Read-only: report a spec's approval/seal state — sealed?, approved_digest, each parent's seal
1369
- * state, and the concrete blockers still standing between it and approval — so a caller need not
1370
- * parse files to ask "what is the approval state right now". Reuses `sealOf` and `approvalBlockers`
1371
- * (the same predicates the code gate and spec_approve read) so the report cannot drift from the
1372
- * acts it describes. No writes, no ledger append.
1373
- */
1374
- async approval_status(a) {
1375
- assertSpecStoreReachable('approval_status', store, a.root); // @implements A-SPEC-419
1376
- const all = await store.list();
1377
- if (all.filter((s) => s.id === a.id).length > 1) {
1378
- return {
1379
- ok: false,
1380
- reason: `${a.id}이(가) 스토어에 두 번 이상 존재합니다 — 어느 사본의 상태를 물었는지 도구가 고를 수 없어 거부합니다.`,
1381
- };
1382
- }
1383
- const cur = await store.read(a.id);
1384
- if (!cur) {
1385
- const dir = store.specsRoot;
1386
- const unreadable = typeof dir === 'string' ? (0, spec_store_1.unreadableSpecFiles)(dir) : [];
1387
- return { ok: false, reason: (0, spec_store_1.notFoundReason)(a.id, unreadable) };
1388
- }
1389
- // @implements A-SPEC-572.1
1390
- // The graph advisory, delivered at DESIGN time. Measured on our own work: A-SPEC-571.1's
1391
- // sealing advisory named the exact cluster three regressions then landed in — and by then
1392
- // the design was done. The calculation was never the gap; the delivery time was. So the
1393
- // read-only "what is blocking this right now" tool also answers "what will this scope leak,
1394
- // and where is it dense" — using the SAME functions spec_approve calls, so the preview can
1395
- // never disagree with the seal. Read-only stays read-only: no scan, no build, no ledger
1396
- // append (a query must not pollute the observation denominator), and every failure degrades
1397
- // to an absent field on an otherwise identical response.
1398
- let graphPreview;
1399
- try {
1400
- if (cur.spec.type === 'A-SPEC' && a.root) {
1401
- const dbPath = path.join(a.root, '.ax', 'rtm.sqlite');
1402
- if (fs.existsSync(dbPath)) {
1403
- const { declaredImpactGap } = require('../rtm/impact-advisory');
1404
- const { anchorDensityFindings } = require('../rtm/anchor-density');
1405
- const { filesToTouch } = require('../spec/compat-impact');
1406
- const { RtmGraph } = require('../rtm/rtm-graph');
1407
- const graph = new RtmGraph(dbPath);
1408
- try {
1409
- const ftt = filesToTouch(cur.spec);
1410
- const impact = declaredImpactGap(ftt, graph, (rel) => { try {
1411
- return fs.readFileSync(path.join(a.root, rel), 'utf8');
1412
- }
1413
- catch {
1414
- return null;
1415
- } });
1416
- const density = anchorDensityFindings(ftt, graph.implementsAnchorCounts());
1417
- // @implements A-SPEC-574.2 — the computation lives in cycle-detect; this file holds
1418
- // the wiring only (measured: 85 anchors here against a p90 of 7).
1419
- const { cycleAdvisory, classifyEdgeByTarget, CYCLE_ADVISORY_NOTE } = require('../cpg/cycle-detect');
1420
- const readCache = new Map();
1421
- const readSource = (rel) => {
1422
- const hit = readCache.get(rel);
1423
- if (hit !== undefined)
1424
- return hit;
1425
- // A read failure degrades to the conservative kind rather than losing the finding.
1426
- let text = '';
1427
- try {
1428
- text = fs.readFileSync(path.join(a.root, rel), 'utf8');
1429
- }
1430
- catch {
1431
- text = '';
1432
- }
1433
- readCache.set(rel, text);
1434
- return text;
1435
- };
1436
- const cycleFindings = cycleAdvisory(ftt, graph.importEdges().map((edge) => ({
1437
- ...edge,
1438
- kind: classifyEdgeByTarget(readSource(edge.from), edge.from, edge.to),
1439
- })));
1440
- // @implements A-SPEC-574.5 — the same import edges the cycle pass already read, plus
1441
- // the parent-time symbol spans the graph already holds. No new scan, no new parse.
1442
- const { architectureObservation } = require('../cpg/arch-observe');
1443
- const { TreeSitterTsParser } = require('../cpg/language-parser');
1444
- const { langForPath } = require('../cpg/cpg-scanner');
1445
- // Bounded to the DECLARED files, whose text is read once and used for both numbers.
1446
- const archParser = new TreeSitterTsParser();
1447
- const archText = new Map();
1448
- const readArch = (f) => {
1449
- if (!archText.has(f)) {
1450
- try {
1451
- archText.set(f, fs.readFileSync(path.join(a.root, f), 'utf8'));
1452
- }
1453
- catch {
1454
- archText.set(f, null);
1455
- }
1456
- }
1457
- return archText.get(f) ?? null;
1458
- };
1459
- const spans = new Map(ftt.map((f) => {
1460
- const text = readArch(f);
1461
- if (text === null)
1462
- return [f, []];
1463
- try {
1464
- return [f, archParser.extractSymbols(text, langForPath(f))
1465
- .filter((sy) => sy.kind !== 'class')
1466
- .map((sy) => ({ startLine: sy.startLine, endLine: sy.endLine }))];
1467
- }
1468
- catch {
1469
- return [f, []];
1470
- }
1471
- }));
1472
- const arch = architectureObservation(ftt, spans, graph.importEdges(), (f) => { const t = readArch(f); return t === null ? null : t.split('\n').length; });
1473
- if (impact || density.length > 0 || cycleFindings.length > 0 || arch.length > 0) {
1474
- const graphAsOf = (() => { try {
1475
- return fs.statSync(dbPath).mtime.toISOString();
1476
- }
1477
- catch {
1478
- return undefined;
1479
- } })();
1480
- graphPreview = {
1481
- ...(impact ? { impact } : {}),
1482
- ...(density.length > 0 ? { density } : {}),
1483
- ...(cycleFindings.length > 0
1484
- ? { cycles: { findings: cycleFindings, note: CYCLE_ADVISORY_NOTE } } : {}),
1485
- ...(arch.length > 0 ? { architecture: arch } : {}),
1486
- ...(graphAsOf ? { graphAsOf } : {}),
1487
- };
1488
- }
1489
- }
1490
- finally {
1491
- graph.close();
1492
- }
1493
- }
1494
- }
1495
- }
1496
- catch {
1497
- graphPreview = undefined;
1498
- }
1499
- return { ok: true, ...(0, approval_status_1.describeApproval)(cur.spec, resolver(all)), ...(graphPreview ? { graphPreview } : {}) };
1500
- },
1501
- /**
1502
- * @implements A-SPEC-538.3
1503
- * Read-only: return the provenance ledger's events in time order (optionally narrowed to one
1504
- * spec) so the governance history — approved / unsealed / retired / review-needed … — is legible
1505
- * at a glance without reading raw JSONL. Reuses FileLedgerStore.loadAll(); the ordering/filtering/
1506
- * projection is the pure `timelineFrom`. No writes. An absent ledger is an empty history, not an
1507
- * error.
1508
- */
1509
- async ledger_timeline(a) {
1510
- const dest = resolveLedgerRoot(a.root);
1511
- if (!dest.ok)
1512
- return { ok: false, reason: dest.reason };
1513
- const dir = path.join(dest.root, '.ax', 'ledger');
1514
- const events = fs.existsSync(dir) ? new ledger_store_1.FileLedgerStore(dir).loadAll() : [];
1515
- return { ok: true, events: (0, ledger_timeline_1.timelineFrom)(events, a.id) };
1516
- },
1517
- // @implements A-SPEC-545.3 — "show me the RTM dashboard" launches (idempotently) the server the
1518
- // agent would otherwise start by hand, and returns its URL plus an honesty census of what it shows.
1519
- async rtm_dashboard(a) {
1520
- const dest = resolveLedgerRoot(a.root);
1521
- if (!dest.ok)
1522
- return { ok: false, reason: dest.reason };
1523
- try {
1524
- const { startDashboardServer } = await Promise.resolve().then(() => __importStar(require('../server/dashboard')));
1525
- const { ensureDashboard, dashboardCensus } = await Promise.resolve().then(() => __importStar(require('../server/dashboard-launcher')));
1526
- const launch = await ensureDashboard(dest.root, a.port, (opts) => startDashboardServer(opts));
1527
- const rtm = await fetchJson(`${launch.url}/api/rtm`);
1528
- const heatmap = await fetchJson(`${launch.url}/api/rtm/heatmap`);
1529
- return { ok: true, url: launch.url, running: launch.running, census: dashboardCensus(rtm, heatmap) };
1530
- }
1531
- catch (err) {
1532
- return { ok: false, reason: `대시보드 기동 실패: ${err?.message ?? String(err)}` };
1533
- }
1534
- },
1535
- async spec_approve(a) {
1536
- const approvalRaw = process.env.HOLMES_APPROVAL;
1537
- let approval;
1538
- try {
1539
- approval = approvalRaw ? JSON.parse(approvalRaw) : undefined;
1540
- }
1541
- catch {
1542
- approval = undefined;
1543
- }
1544
- // covers, not merely well-formed (round-3 escalation: a token scoped to review-resolve — or
1545
- // expired outright — completed a FULL SEAL here, so A-SPEC-133's narrowing was decorative at
1546
- // the most consequential consumer). Unscoped {actor,token,rationale} stays the session key.
1547
- let approveResolved = resolveHandlerApproval(a.root, store, approval, { kind: 'spec-approve', target: a.id }, new Date().toISOString());
1548
- // @implements A-SPEC-263.1 — asked ONLY after the existing channels failed to cover (an open
1549
- // door never summons a human), and only about a spec that exists (a question about a missing
1550
- // id helps no one — the standard refusal handles it).
1551
- let elicitExpiredMs;
1552
- if (approveResolved === undefined) {
1553
- const target = await store.read(a.id).catch(() => null);
1554
- if (target) {
1555
- // @implements A-SPEC-532.2 — the autonomous gate sits BEFORE the human ask: when the
1556
- // out-of-band autonomy switch is on AND the spec is low/mid-risk (never gate-behavior, an
1557
- // architecture/taint file, or an upstream REQ/H/C — those stay human), the agent seals it
1558
- // itself. The switch is env-only and an agent cannot set it (pre-tool-use blocks that,
1559
- // A-SPEC-532.2). Off, or a hitl-classed spec, falls straight through to the elicitor
1560
- // unchanged — the autonomous-OFF path is byte-identical to before.
1561
- // @implements A-SPEC-553.1 — autonomy is the out-of-band env switch OR a valid, non-expired
1562
- // session envelope marker under this project's `.ax/state/` (which an agent cannot write).
1563
- const autonomyOn = (0, autonomy_1.autonomousApprovalEnabled)(process.env, a.root, new Date().toISOString());
1564
- // @implements A-SPEC-587 — an upstream REQ/H-SPEC is graded by the A-SPECs beneath it, so
1565
- // the store is consulted for its descendants (only then: the list is a cost the auto-grade
1566
- // A-SPEC/T-SPEC path does not pay). Unknown scope (none yet) keeps the human answer.
1567
- const upstream = target.spec.type === 'REQ' || target.spec.type === 'H-SPEC';
1568
- const below = autonomyOn && upstream ? (0, autonomy_1.descendantAspecs)(target.spec, await store.list()) : undefined;
1569
- if (autonomyOn
1570
- && (0, autonomy_1.specApprovalAutonomy)(target.spec, resolver([target.spec]), below) === 'auto') {
1571
- approveResolved = { approval: autonomousApproval(below?.map((s) => s.id)), source: 'autonomous' };
1572
- }
1573
- else if (autonomyOn) {
1574
- // @implements A-SPEC-551.1 — hitl-grade spec under autonomy: the in-session elicitation
1575
- // dialog is auto-acceptable by an auto-mode client (the protocol cannot tell a human
1576
- // from an auto-accept), so a governance-critical spec is never offered it. Do nothing
1577
- // here — approveResolved stays undefined and the fail-closed refuse+enqueue path below
1578
- // routes the act to the out-of-band human queue (holmes-kit approve). The A-SPEC-532.1
1579
- // bound ("governance-critical specs never leave the human channel") thus becomes
1580
- // ENFORCED, not aspirational. Autonomy OFF (the else) and the auto-grade branch above
1581
- // stay byte-identical to before.
1582
- }
1583
- else {
1584
- const resealing = typeof target.spec.frontmatter.approved_digest === 'string';
1585
- // The MODEL text is capped BEFORE the server markers are appended (round-2): a ~185+ char
1586
- // title pushed '(재봉인)' past the dialog's 200-char summary cap, dressing a re-seal (the
1587
- // more consequential act) as a first approval. The cap cuts the title, never the marker.
1588
- const out = await tryElicit('spec-approve', a.id, `${a.id} — ${target.spec.title.slice(0, 120)}${resealing ? ' (재봉인)' : ''}`);
1589
- if (out.kind === 'answered' && out.decision.granted) {
1590
- approveResolved = { approval: elicitApproval(out.decision.reason), source: 'elicitation' };
1591
- }
1592
- else if (out.kind === 'answered') {
1593
- // The human ANSWERED (deny/question/decline): the answer is the message, and no queue
1594
- // entry is filed — a decided request is not a pending one (REQ-246 visibility).
1595
- return { ok: false, reason: `spec_approve: 세션에서 거부됨 — ${out.decision.reason ?? '(사유 없음)'}. 사유를 해소한 뒤 다시 시도하십시오.` };
1596
- }
1597
- else if (out.kind === 'expired') {
1598
- // @implements A-SPEC-497.1 — only the expiry earns a name: the notice LEADS the same
1599
- // fail-closed refusal + queue path, so the semantics stay refusal+queue and only the
1600
- // message learned to say what happened.
1601
- elicitExpiredMs = out.waitedMs;
1602
- }
1603
- // silent: the channel gave no answer — fall through to the byte-identical refusal.
1604
- }
1605
- }
1606
- }
1607
- if (approveResolved === undefined) {
1608
- return { ok: false, reason: (elicitExpiredMs !== undefined ? (0, elicit_approval_1.expiredNotice)(elicitExpiredMs) : '')
1609
- + '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)'
1610
- + refusalQueueHint(a.root, store, { kind: 'spec-approve', target: a.id, why: '스펙 봉인 승인' }) };
1611
- }
1612
- // @implements A-SPEC-188 — destination BEFORE seal.
1613
- // The old order (seal at :481, resolve the ledger at :483) produced both measured harms: a
1614
- // bad root left a standing seal with no ledger entry anywhere while the CALLER WAS TOLD IT
1615
- // FAILED, and a foreign root filed the only audit record in another project's ledger. This
1616
- // repository lived the first one — REQ-182's chain was approved rootless on 2026-08-13 and
1617
- // the missing entries were found two review rounds later.
1618
- //
1619
- // The destination is DERIVED from the store the server was bound to (same principle as
1620
- // spec_create's A-SPEC-163 guard); a supplied `root` is a confirmation that must match. Any
1621
- // failure here is a refusal with nothing written.
1622
- let ledgerRoot;
1623
- try {
1624
- if (store instanceof spec_store_1.LocalMarkdownRepository) {
1625
- // Compared at PROJECT level, through the same walk-up the old code used: a root pointing
1626
- // anywhere INSIDE this project (the specs dir, a subdirectory) resolves to the same
1627
- // project and is accepted — round-2 review caught the first cut refusing those with a
1628
- // message that printed the identical path on both sides. Only a root resolving to a
1629
- // DIFFERENT project is refused, and the message names both projects.
1630
- //
1631
- // Round-3 caught the fallback: resolveProjectRoot returns its INPUT when no .ax ancestor
1632
- // exists, so a custom HOLMES_SPECS outside any .ax tree "derived" the specs dir itself as
1633
- // the project — a rootless approval then minted .ax/ledger INSIDE the spec store, and the
1634
- // correct explicit root was refused as a "different project". Derivation only counts when
1635
- // the walk actually found a marker; otherwise the old contract stands: root is required
1636
- // and names the project.
1637
- const derived = projectRootOf(store.specsRoot);
1638
- const derivationFoundMarker = fs.existsSync(path.join(derived, '.ax'));
1639
- if (derivationFoundMarker) {
1640
- ledgerRoot = derived;
1641
- if (typeof a.root === 'string' && a.root !== '') {
1642
- const askedProject = (0, write_target_1.resolveTarget)(projectRootOf(a.root), '.');
1643
- const boundProject = (0, write_target_1.resolveTarget)(ledgerRoot, '.');
1644
- if (askedProject !== boundProject) {
1645
- return {
1646
- ok: false,
1647
- reason: `이 서버는 ${boundProject} 프로젝트에 바인딩되어 있습니다 — 요청한 root ${a.root}는 ${askedProject} 프로젝트를 가리킵니다.`
1648
- + ' 다른 프로젝트의 원장에 기록하지 않기 위해 봉인 전에 거부합니다.',
1649
- };
1650
- }
1651
- }
1652
- }
1653
- else if (typeof a.root === 'string' && a.root !== '') {
1654
- ledgerRoot = projectRootOf(a.root);
1655
- }
1656
- else {
1657
- return {
1658
- ok: false,
1659
- reason: '스토어 위치에서 프로젝트를 파생할 수 없습니다(.ax 상위 디렉터리 없음) — 원장을 어디에 둘지 알 수 없어 봉인 전에 거부합니다. root를 지정하십시오.',
1660
- };
1661
- }
1662
- }
1663
- else {
1664
- if (typeof a.root !== 'string' || a.root === '') {
1665
- return { ok: false, reason: 'root가 없고 스토어에서 원장 위치를 파생할 수도 없습니다 — 봉인 전에 거부합니다. root를 지정하십시오.' };
1666
- }
1667
- ledgerRoot = projectRootOf(a.root);
1668
- }
1669
- }
1670
- catch (e) {
1671
- return {
1672
- ok: false,
1673
- reason: `원장 위치를 확정할 수 없어 봉인 전에 거부합니다: ${String(e.message)}.`
1674
- + ' 올바른 root를 지정하거나, 파일 스토어에 바인딩된 서버에서는 root를 생략하십시오.',
1675
- };
1676
- }
1677
- const specs = await store.list();
1678
- // @implements A-SPEC-188 — with DUPLICATE ids the tool cannot know which copy is canonical:
1679
- // read() resolves the last-walked file while list().find sees the first, so approving would
1680
- // seal a stray's content and delete the canonical file — success reported, edit destroyed
1681
- // (round-2 review reproduced exactly that). Duplicates are a recognized invalid state
1682
- // (doctor detects them); the act refuses rather than picking a side.
1683
- // @implements A-SPEC-188 — the spec this act validates and seals is the one read NOW, with
1684
- // its version captured for the optimistic write below. Basing the candidate on the list()
1685
- // element instead leaves a window in which an external edit is silently destroyed and the
1686
- // STALE content gets sealed (measured: 17 of 40 concurrent edits lost, 35-55ms window).
1687
- const cur = await store.read(a.id);
1688
- // @implements A-SPEC-536.1 — BUG-1: a spec whose YAML is broken is dropped by read()/list(),
1689
- // so a bare "not found" hid that the file EXISTS but cannot be parsed. Surface the skipped
1690
- // files when there are any; byte-identical to the legacy message when there are none. The
1691
- // store's specsRoot is read through the same cast the reachability checks use (A-SPEC-169).
1692
- if (!cur) {
1693
- const dir = store.specsRoot;
1694
- const unreadable = typeof dir === 'string' ? (0, spec_store_1.unreadableSpecFiles)(dir) : [];
1695
- return { ok: false, reason: (0, spec_store_1.notFoundReason)(a.id, unreadable) };
1696
- }
1697
- const spec = cur.spec;
1698
- // @implements A-SPEC-188 — duplicates make the id ambiguous, for the SPEC and for its
1699
- // PARENTS alike. Round-3 probed the parent half: with a stray duplicate of the parent
1700
- // walking first, the child's parent_digests sealed the stray's (forged) digest — the very
1701
- // snapshot drift detection compares against — while the refusal principle stated one field
1702
- // away was "the tool does not pick a copy". Refuse for every ambiguous id in the act.
1703
- const ambiguous = [a.id, ...spec.dependsOn].filter((id) => specs.filter((s) => s.id === id).length > 1);
1704
- if (ambiguous.length > 0) {
1705
- return {
1706
- ok: false,
1707
- reason: `${[...new Set(ambiguous)].join(', ')}이(가) 스토어에 두 번 이상 존재합니다 — 어느 사본이 진본인지 도구가 고를 수 없어 봉인 전에 거부합니다.`
1708
- + ' doctor로 중복 파일을 확인해 하나로 정리한 뒤 다시 승인하십시오.',
1709
- };
1710
- }
1711
- // @implements A-SPEC-168
1712
- // A child may not be approved under a parent that is not. The chain's meaning is that each
1713
- // layer is justified by the one above it, and measured 2026-08-08 that failed in four places —
1714
- // including A-SPEC-100.1/100.2, the core MCP guardrail, whose design H-SPEC-100 nobody ever
1715
- // approved. The obligation is on the ACT: existing approvals are untouched, because making it
1716
- // a state rule is what REQ-146 measured turning 302 specs into violations at once.
1717
- // @implements A-SPEC-182
1718
- // Shared with the gate rather than restated. Adversarial review found this check living ONLY
1719
- // here: the gate reported "nothing blocks" for a draft under a draft parent — the modal state
1720
- // of top-down authoring — and the author learned otherwise only by attempting the approval.
1721
- const parentIssues = (0, approval_blockers_1.parentBlockers)(spec, resolver(specs));
1722
- if (parentIssues.length > 0)
1723
- return { ok: false, reason: parentIssues[0] };
1724
- // Validate AS IT WILL BE — approved. Approval-gated checks (placeholder citations, 4-quadrant
1725
- // GWT) must fire NOW, not one turn after the seal exists.
1726
- const candidate = { ...spec, status: 'approved', frontmatter: { ...spec.frontmatter } };
1727
- delete candidate.frontmatter.approved_digest; // seals are recomputed by this act, never inherited
1728
- delete candidate.frontmatter.parent_digests;
1729
- const prevalidation = (0, validator_1.validateSpec)(candidate, resolver(specs));
1730
- const blocking = prevalidation.findings.filter((f) => f.level === 'error' && !approval_blockers_1.SUPPLIED_BY_APPROVAL.has(f.code) // this act supplies exactly those
1731
- );
1732
- if (blocking.length > 0)
1733
- return { ok: false, reason: 'validation errors block approval', findings: blocking };
1734
- // @implements A-SPEC-146
1735
- // ADR-013's duty is owed by the ACT, not by the document's static validity. Measured: putting
1736
- // this in `validateSpec`'s requiredFields produced 38 ART-3 violations and a Stop hook that
1737
- // blocked every turn, because all 38 governed A-SPECs are already approved. Here it constrains
1738
- // the future without invalidating the past — and a re-approval, which by definition means the
1739
- // content changed, is exactly when the question is due.
1740
- const breakingIssue = (0, breaking_change_1.checkBreakingChangeDeclared)(candidate);
1741
- if (breakingIssue)
1742
- return { ok: false, reason: breakingIssue };
1743
- // @implements A-SPEC-565.1 — the compat declaration duty rides the SAME act (REQ-565): sealing
1744
- // is when "did you consider the three harnesses and the three OSes" is due, and act-time is
1745
- // what keeps 512 already-approved specs out of retroactive violation (the 38-violation incident
1746
- // above). The bound reader feeds the OS cross-check from this root's working tree.
1747
- const compatIssue = (0, compat_impact_1.checkCompatDeclared)(candidate, {
1748
- // No root → no working tree to read: the OS cross-check skips file-by-file (fail-open),
1749
- // while the declaration syntax itself is still enforced — the duty never depends on `root`.
1750
- readFile: (rel) => { try {
1751
- return a.root ? fs.readFileSync(path.join(a.root, rel), 'utf8') : null;
1752
- }
1753
- catch {
1754
- return null;
1755
- } },
1756
- });
1757
- if (compatIssue)
1758
- return { ok: false, reason: compatIssue };
1759
- // @implements A-SPEC-182
1760
- // A document whose prose is still the generator's placeholder must not be sealed. Measured
1761
- // 2026-08-13 on a brownfield adoption: H-SPEC-100 took `status: approved` and an
1762
- // `approved_digest` with all seven prose sections reading "TODO — a human writes this" —
1763
- // while its own first line said approving is the point a description becomes normative.
1764
- // Placed on the ACT, not in `validateSpec`, for the reason A-SPEC-146 measured: a static
1765
- // predicate turns every already-approved spec into a violation and bricks the harness.
1766
- const stubs = (0, approval_blockers_1.placeholderSections)(candidate);
1767
- if (stubs.length > 0) {
1768
- return { ok: false, reason: (0, approval_blockers_1.placeholderMessage)(stubs) };
1769
- }
1770
- // @implements A-SPEC-505.1 — acceptance substance, judged at the ACT on the same candidate
1771
- // the seal would freeze, like the placeholder gate above (154/428 approved REQs here are
1772
- // non-stated legacy; a validateSpec predicate would brick them all). Judged HERE and not
1773
- // before the approval channels: an earlier extra read shifts the optimistic-concurrency
1774
- // window and lets a mid-approval edit get sealed (store-integrity contract: edits win,
1775
- // approvals lose). The post-grant refusal it costs is the standing property of every
1776
- // act-time blocker in this block (breaking_change, placeholder) — one ordering, one truth.
1777
- const unactionable = (0, approval_blockers_1.unactionableCriteriaBlocker)(candidate);
1778
- if (unactionable)
1779
- return { ok: false, reason: unactionable };
1780
- // Parents-first: a sealed child snapshotting an unsealed parent would pin nothing. This loop
1781
- // exists to COLLECT the digests; the refusal inside it is now a backstop, because
1782
- // `parentBlockers` above already returns for the same condition with the same sentence. Kept
1783
- // rather than deleted so a future change to that function cannot silently let an unsealed
1784
- // parent through — but note the wording lives there, and only there, if it is ever edited.
1785
- const parentDigests = {};
1786
- for (const pid of spec.dependsOn) {
1787
- const parent = specs.find((s) => s.id === pid);
1788
- if (parent && parent.status === 'approved') {
1789
- const parentSeal = (0, spec_digest_1.sealOf)(parent).approvedDigest;
1790
- if (!parentSeal)
1791
- return { ok: false, reason: (0, approval_blockers_1.unsealedParentMessage)(pid) };
1792
- parentDigests[pid] = parentSeal;
1793
- }
1794
- }
1795
- // @implements A-SPEC-135
1796
- // Detect seal MOVEMENT before overwriting: the prior on-disk seal, if any, is what "moved from".
1797
- const priorDigest = (0, spec_digest_1.sealOf)(spec).approvedDigest;
1798
- const digest = (0, spec_digest_1.specDigest)(candidate);
1799
- candidate.frontmatter.approved_digest = digest;
1800
- candidate.frontmatter.parent_digests = parentDigests;
1801
- // @implements A-SPEC-188 — the seal is written only at the version this act READ. On
1802
- // conflict the EDIT wins and the APPROVAL loses: the edit is what a person just wrote; the
1803
- // approval is an act that can simply be retried. The reverse (the old behaviour) sealed
1804
- // stale content over a destroyed edit with no notice.
1805
- try {
1806
- await store.write(candidate, { expectedVersion: cur.version });
1807
- }
1808
- catch (e) {
1809
- if (e instanceof spec_store_2.SpecVersionConflictError) {
1810
- // @implements A-SPEC-538.4 — the refusal is unchanged (refuse, write nothing, the edit
1811
- // wins); it now also CARRIES the conflict: the version this act read, the version now on
1812
- // disk, and what to retry. Re-read to learn the current version (null if it vanished).
1813
- const now = await store.read(a.id).catch(() => null);
1814
- return {
1815
- ok: false,
1816
- reason: `승인 진행 중 ${a.id}이(가) 바뀌었습니다 — 바뀐 내용을 확인하고 다시 승인하십시오.`
1817
- + ' 이번 승인은 아무것도 쓰지 않았습니다.',
1818
- conflict: (0, version_conflict_1.conflictDetail)({ id: a.id, expected: cur.version, current: now?.version ?? null }),
1819
- };
1820
- }
1821
- // @implements A-SPEC-188 — approval can RELOCATE the file (req_type classification moves
1822
- // an H-SPEC into its subfolder). Round-3 probed that landing on an unreadable human note
1823
- // destroyed it with success reported; the store now refuses, and this act relays why.
1824
- if (e instanceof spec_store_1.TargetPathOccupiedError) {
1825
- return {
1826
- ok: false,
1827
- reason: `${a.id}의 목적지(${e.occupiedPath})에 스토어가 읽지 못하는 파일이 이미 있습니다 — 덮어쓰지 않습니다.`
1828
- + ' 사람이 확인해 옮기거나 고친 뒤 다시 승인하십시오. 이번 승인은 아무것도 쓰지 않았습니다.',
1829
- };
1830
- }
1831
- throw e;
1832
- }
1833
- // @implements A-SPEC-148 — writes go to THIS replica's chain; the legacy file is read-only now.
1834
- const chain = new ledger_store_1.FileLedgerStore(path.join(ledgerRoot, '.ax', 'ledger'));
1835
- chain.append({
1836
- ts: new Date().toISOString(),
1837
- actor: approveResolved.approval.actor,
1838
- kind: 'spec-approved',
1839
- summary: `approved ${a.id} sealing ${digest}`,
1840
- inputs: [a.id, digest],
1841
- rationale: approveResolved.approval.rationale,
1842
- authorization: (0, provenance_chain_1.authorizationRef)(approveResolved.approval.actor, approveResolved.approval.token),
1843
- });
1844
- // @implements A-SPEC-135
1845
- // P4 routing signal: a re-approval whose content CHANGED (prior seal existed and differs) mints
1846
- // a `review-needed` entry naming the anchored source files — the exact set a targeted review must
1847
- // re-examine against the new content. A first approval or an idempotent re-seal is not drift and
1848
- // mints nothing. This ROUTES review; it never verdicts the code. Best-effort: a missed route is
1849
- // not a broken seal, so an append failure does not fail the approval.
1850
- if (priorDigest && priorDigest !== digest) {
1851
- try {
1852
- const files = (0, review_targets_1.anchoredForReview)(a.id, cachedScan(ledgerRoot, ledgerRoot), specs);
1853
- chain.append({
1854
- ts: new Date().toISOString(), actor: approveResolved.approval.actor, kind: 'review-needed',
1855
- summary: `targeted review needed: ${a.id} content moved — re-examine ${files.length} anchored file(s) against the new spec content`,
1856
- inputs: [a.id, priorDigest, digest, ...files],
1857
- rationale: approveResolved.approval.rationale, authorization: '',
1858
- });
1859
- }
1860
- catch { /* a missed routing signal is not a broken seal */ }
1861
- }
1862
- // @implements A-SPEC-245 — single-use: the grant is spent by the seal it authorized.
1863
- if (approveResolved.source === 'grant' && approveResolved.root && approveResolved.approval.nonce) {
1864
- (0, approval_grants_1.consumeGrantFile)(approveResolved.root, approveResolved.approval.nonce);
1865
- }
1866
- // @implements A-SPEC-566.2 — the impact advisory rides the SUCCESS, after the seal is done:
1867
- // the verdict is already committed, so nothing here can change it (advisory, never gate —
1868
- // Judgments must not be budgeted). Reuses the persisted graph READ-ONLY; it never scans,
1869
- // parses or builds (scan:build measured 20~38x — an approval must not pay that), and every
1870
- // failure below degrades to "no advisory field" on an otherwise identical response.
1871
- let impactAdvisory;
1872
- let anchorDensity;
1873
- try {
1874
- if (spec.type === 'A-SPEC' && a.root) {
1875
- const dbPath = path.join(a.root, '.ax', 'rtm.sqlite');
1876
- if (fs.existsSync(dbPath)) {
1877
- const { declaredImpactGap, appendImpactAdvisory } = require('../rtm/impact-advisory');
1878
- const { filesToTouch } = require('../spec/compat-impact');
1879
- const { RtmGraph } = require('../rtm/rtm-graph');
1880
- // Closed in finally (high-effort review F4): this handler lives in a long-running MCP
1881
- // server, and an unclosed native handle per approval accumulates for the process
1882
- // lifetime — and on Windows can hold rtm.sqlite locked against the next rebuild.
1883
- const graph = new RtmGraph(dbPath);
1884
- try {
1885
- const ftt = filesToTouch(candidate);
1886
- const gap = declaredImpactGap(ftt, graph, (rel) => { try {
1887
- return fs.readFileSync(path.join(a.root, rel), 'utf8');
1888
- }
1889
- catch {
1890
- return null;
1891
- } });
1892
- if (gap) {
1893
- const graphAsOf = (() => { try {
1894
- return fs.statSync(dbPath).mtime.toISOString();
1895
- }
1896
- catch {
1897
- return undefined;
1898
- } })();
1899
- impactAdvisory = { ...gap, ...(graphAsOf ? { graphAsOf } : {}) };
1900
- appendImpactAdvisory(a.root, {
1901
- aspec: a.id, files: gap.files.map((f) => f.path), more: gap.more,
1902
- ...(graphAsOf ? { graphAsOf } : {}), ts: new Date().toISOString(),
1903
- });
1904
- }
1905
- // @implements A-SPEC-569.5 — anchor-density OBSERVATION, same reopened graph, same
1906
- // no-scan contract, same lifecycle as the advisory above (observe → ledger → measure
1907
- // before anyone proposes promotion). Never a verdict input: the seal is already done,
1908
- // and its own failure degrades to "no field" on an otherwise identical response.
1909
- try {
1910
- const { anchorDensityFindings, appendAnchorDensity } = require('../rtm/anchor-density');
1911
- const findings = anchorDensityFindings(ftt, graph.implementsAnchorCounts());
1912
- if (findings.length > 0) {
1913
- anchorDensity = findings;
1914
- appendAnchorDensity(a.root, {
1915
- aspec: a.id, files: findings.map((f) => ({ path: f.path, anchors: f.anchors })),
1916
- p90: findings[0].p90, ts: new Date().toISOString(),
1917
- });
1918
- }
1919
- }
1920
- catch {
1921
- anchorDensity = undefined;
1922
- }
1923
- }
1924
- finally {
1925
- graph.close();
1926
- }
1927
- }
1928
- }
1929
- }
1930
- catch {
1931
- impactAdvisory = undefined;
1932
- anchorDensity = undefined;
1933
- }
1934
- return { approved: a.id, digest, ...(impactAdvisory ? { impactAdvisory } : {}), ...(anchorDensity ? { anchorDensity } : {}) };
1935
- },
1936
- async spec_list(a) {
1937
- assertSpecStoreReachable('spec_list', store, a.root); // @implements A-SPEC-419
1938
- const specs = await store.list();
1939
- // @implements A-SPEC-186
1940
- // The parent travels with the id because a bare one cannot be read. Measured: a reader took
1941
- // `T-SPEC-1841` for a count of 1,841 documents. The old-habit ids this repository once held
1942
- // have since all been renamed or upgraded (measured 2026-08-15: zero remain), but ADOPTED
1943
- // repositories still write them, so the parent keeps traveling alongside.
1944
- //
1945
- // Read from `depends_on`, NEVER parsed out of the id — parsing would answer `A-SPEC-129` for
1946
- // a `T-SPEC-1291` that actually hangs off something else, which is the tool committing the
1947
- // very misreading this exists to prevent. Omitted rather than emptied when there is none:
1948
- // an empty value cannot be told apart from "we looked and failed".
1949
- const parentOf = (s) => {
1950
- // @implements A-SPEC-192 §6R (round 10) — the row became format-aware in the `legacy` field
1951
- // only; `parent` still keyed off the DECLARED type, so an old-format document that never
1952
- // declared one showed no parent even when its depends_on names one. The inferred kind
1953
- // answers the same question the legacy mark already asks.
1954
- const state = (0, legacy_format_1.formatStateOf)({ id: s.id, type: s.type });
1955
- const kind = s.type ?? (state.kind === 'legacy' ? state.inferred : undefined);
1956
- const kinds = (0, spec_types_1.specTypeDef)(kind)?.parents ?? [];
1957
- return s.dependsOn?.find((d) => kinds.some((k) => d.startsWith(`${k}-`)));
1958
- };
1959
- // @implements A-SPEC-192 — a non-canonical status must not dress like a governed row:
1960
- // measured 155 of 483 rows ('Approved', 'Proposed', 'deprecated'…) rendered identically to canonical
1961
- // ones, and an adopter read capital-A 'Approved' as an approval. Absence of the key marks the
1962
- // canonical row (the same omission convention as `parent` above). The set derives from the
1963
- // SPEC_STATUSES runtime list — a hand-copy here was a second truth the compiler cannot police.
1964
- const CANONICAL_STATUSES = new Set(spec_types_1.SPEC_STATUSES);
1965
- return {
1966
- specs: specs
1967
- .filter((s) => !a?.type || s.type === a.type)
1968
- .map((s) => {
1969
- const parent = parentOf(s);
1970
- // @implements A-SPEC-192 §5R (round 9) — the mark asks 'is this row a GOVERNED spec?',
1971
- // and status alone could not answer it: an old-format document that never declared a
1972
- // `type` came through with a lone `approved` and dressed exactly like a canonical row —
1973
- // the misreading this REQ exists to prevent. Format and status are both grounds.
1974
- const legacyFormat = (0, legacy_format_1.formatStateOf)({ id: s.id, type: s.type }).kind !== 'current';
1975
- return {
1976
- id: s.id, type: s.type, status: s.status,
1977
- ...(parent ? { parent } : {}),
1978
- ...(CANONICAL_STATUSES.has(s.status) && !legacyFormat ? {} : { legacy: true }),
1979
- };
1980
- }),
1981
- };
1982
- },
1983
- async spec_next() {
1984
- const specs = await store.list();
1985
- // 다음에 작성 가능한 첫 미승인 단계
1986
- for (const t of spec_types_1.SPEC_ORDER) {
1987
- const has = specs.some((s) => s.type === t && s.status === 'approved');
1988
- if (!has)
1989
- return { next: t };
1990
- }
1991
- return { next: null };
1992
- },
1993
- async rtm_check(a) {
1994
- // @implements A-SPEC-461 — a root that names a different project would check THIS project's
1995
- // specs against THAT project's files and report clean — A-SPEC-191 §15. foreignRootReason
1996
- // returns null for an absent or matching root, so the spec-only and correct-root paths are
1997
- // unaffected. Shared guard (citation_pin/maintenance_analyze use it), not a copy (A-SPEC-449).
1998
- {
1999
- const foreign = foreignRootReason(store, a?.root);
2000
- if (foreign)
2001
- return { ok: false, reason: foreign };
2002
- }
2003
- const specs = await store.list();
2004
- // rtm_check runs on the GOVERNED set only (excludes archived legacy +
2005
- // JOB specs, which otherwise flood orphan/dangling results; see
2006
- // ADR-010).
2007
- const issues = (0, rtm_check_1.rtmCheck)((0, spec_types_1.filterGoverned)(specs));
2008
- // @implements A-SPEC-146 — a REPORT, not a verdict: ADR-013 named formulaic `none` a
2009
- // revisit trigger for a human, and `(unset)` shows how far the corpus has converged.
2010
- const breakingChangeDistribution_ = (0, breaking_change_1.breakingChangeDistribution)((0, spec_types_1.filterGoverned)(specs));
2011
- if (a?.root) {
2012
- // @implements A-SPEC-189 §14 (round 13) — the derivation was computed and discarded, so the
2013
- // advertised "a subdirectory resolves up to it" held for `taint_scan` and no one else.
2014
- const root = projectRootOf(a.root);
2015
- // Citation drift: a REQ's cited source may have been edited since it was read, which silently
2016
- // invalidates the requirement derived from it. Only refs that resolve to a file INSIDE the
2017
- // repo are re-hashed — an unresolvable ref (external ticket, no adapter configured) is not
2018
- // evidence of drift, and a path escaping the repo is not ours to read.
2019
- const readLocal = (ref) => {
2020
- const abs = path.resolve(root, ref);
2021
- if (!abs.startsWith(path.resolve(root) + path.sep))
2022
- return null;
2023
- try {
2024
- return fs.readFileSync(abs, 'utf8');
2025
- }
2026
- catch {
2027
- return null;
2028
- }
2029
- };
2030
- for (const s of specs) {
2031
- if (s.type !== 'REQ' || s.frontmatter.source == null)
2032
- continue;
2033
- for (const d of (0, validator_1.verifyCitationDigests)(s.frontmatter.source, readLocal)) {
2034
- issues.push({ level: d.level, code: d.code, message: `${s.id}: ${d.message}` });
2035
- }
2036
- }
2037
- const scanned = cachedScan(root, root);
2038
- // checkImplements checks @implements A-SPEC refs against the FULL
2039
- // spec id set (not filterGoverned) — it's an existence check: any
2040
- // existing spec id is a valid anchor target.
2041
- issues.push(...(0, rtm_check_1.checkImplements)(scanned, specs));
2042
- // L6 convergence gaps (ADVISORY, not issues): where implementation and the governed spec set
2043
- // have drifted apart — unimplemented approved specs, unanchored source, untested specs. Feeds
2044
- // the convergence loop's "pick next work"; an unimplemented spec is normal mid-development.
2045
- const gaps = (0, gap_analyzer_1.computeConvergenceGaps)(scanned, (0, spec_types_1.filterGoverned)(specs), (0, test_scope_1.scanTestAnchors)(root));
2046
- return { issues, gaps, breakingChangeDistribution: breakingChangeDistribution_ };
2047
- }
2048
- return { issues, breakingChangeDistribution: breakingChangeDistribution_ };
2049
- },
2050
- /**
2051
- * Pin a REQ's citations: compute the content digest of every cited source that resolves inside
2052
- * the repo and record it as `rev`, so an author never hashes a file by hand and the digest is
2053
- * always derived from what is actually on disk.
2054
- *
2055
- * Explicit-invocation only, dry-run by DEFAULT, and never overwrites an existing digest — a
2056
- * re-pin would replace evidence of upstream drift with a fresh-looking value, converting the
2057
- * detector into a concealer.
2058
- */
2059
- async citation_pin(a) {
2060
- // @implements A-SPEC-189 §15 (round 13) — every sibling that reads or writes on behalf of a
2061
- // project refuses a foreign root (spec_create, phase_status, review_record, review_status,
2062
- // spec_approve). `citation_pin` did not, and it SEALS what it read: the sha256 of another
2063
- // project's file was written into this store's REQ frontmatter as evidence, permanently —
2064
- // the design deliberately never overwrites an existing digest.
2065
- const foreignPin = foreignRootReason(store, a.root);
2066
- if (foreignPin !== null)
2067
- return { ok: false, reason: `citation_pin: ${foreignPin}` };
2068
- // @implements A-SPEC-189 §14 (round 13) — the derivation was computed and discarded, so the
2069
- // advertised "a subdirectory resolves up to it" held for `taint_scan` and no one else.
2070
- const root = projectRootOf(a.root);
2071
- // @implements A-SPEC-188 — the third writer, same duplicate hazard as approve/upgrade:
2072
- // read() resolves the last-walked copy and the write's orphan sweep deletes the other.
2073
- if ((await store.list()).filter((s) => s.id === a.id).length > 1) {
2074
- return {
2075
- ok: false,
2076
- reason: `${a.id}이(가) 스토어에 두 번 이상 존재합니다 — 어느 사본이 진본인지 도구가 고를 수 없어 기록 전에 거부합니다.`
2077
- + ' doctor로 중복 파일을 확인해 하나로 정리한 뒤 다시 시도하십시오.',
2078
- };
2079
- }
2080
- // @implements A-SPEC-151
2081
- // `read` now yields the spec together with the version it was read at, so a write can say
2082
- // "only if nobody touched this since". citation_pin uses it: the pin is derived from what was
2083
- // read, and writing it back over someone else's edit would silently discard their change.
2084
- const found = await store.read(a.id);
2085
- if (!found)
2086
- return { ok: false, reason: `spec ${a.id} not found` };
2087
- const { spec, version } = found;
2088
- if (spec.type !== 'REQ')
2089
- return { ok: false, reason: `citations live on REQ; ${a.id} is ${spec.type}` };
2090
- if (spec.frontmatter.source == null)
2091
- return { ok: false, reason: `${a.id} has no source to pin` };
2092
- const readLocal = (ref) => {
2093
- const abs = path.resolve(root, ref);
2094
- // A ref that escapes the repository is not ours to read, and following it would let a spec
2095
- // pull arbitrary host files into the provenance record.
2096
- if (!abs.startsWith(path.resolve(root) + path.sep))
2097
- return null;
2098
- try {
2099
- return fs.readFileSync(abs, 'utf8');
2100
- }
2101
- catch {
2102
- return null;
2103
- }
2104
- };
2105
- const { citations, pinned, findings } = (0, validator_1.pinCitations)(spec.frontmatter.source, readLocal);
2106
- const dryRun = a.dryRun !== false; // opt IN to writing
2107
- if (!dryRun && pinned.length > 0) {
2108
- try {
2109
- try {
2110
- await store.write({ ...spec, frontmatter: { ...spec.frontmatter, source: citations } }, { expectedVersion: version });
2111
- }
2112
- catch (e) {
2113
- if (e instanceof spec_store_1.TargetPathOccupiedError) {
2114
- return {
2115
- ok: false,
2116
- reason: `${a.id}의 목적지(${e.occupiedPath})에 스토어가 읽지 못하는 파일이 이미 있습니다 — 덮어쓰지 않습니다.`
2117
- + ' 사람이 확인해 옮기거나 고친 뒤 다시 시도하십시오.',
2118
- };
2119
- }
2120
- throw e;
2121
- }
2122
- }
2123
- catch (err) {
2124
- if (err instanceof spec_store_2.SpecVersionConflictError) {
2125
- return { ok: false, reason: `${a.id} changed while its citations were being pinned — re-read and retry (${err.message})` };
2126
- }
2127
- throw err;
2128
- }
2129
- }
2130
- return { ok: true, id: a.id, dryRun, pinned, findings, citations };
2131
- },
2132
- async phase_status(a) {
2133
- // @implements A-SPEC-189 §6 — `root` was advertised and never read, so a call naming another
2134
- // project (or a path that does not exist) got THIS server's corpus in a success shape and the
2135
- // caller derived its phase from someone else's specs. Same anchor discipline as spec_approve.
2136
- if (typeof a?.root === 'string' && a.root !== '' && store instanceof spec_store_1.LocalMarkdownRepository) {
2137
- let asked;
2138
- let bound;
2139
- let derivationFoundMarker = false;
2140
- try {
2141
- asked = (0, write_target_1.resolveTarget)(projectRootOf(a.root), '.');
2142
- const derived = projectRootOf(store.specsRoot);
2143
- derivationFoundMarker = fs.existsSync(path.join(derived, '.ax'));
2144
- bound = (0, write_target_1.resolveTarget)(derived, '.');
2145
- }
2146
- catch (e) {
2147
- return { ok: false, reason: `root를 해석할 수 없습니다: ${String(e.message)}` };
2148
- }
2149
- // @implements A-SPEC-189 §7 (round 10) — spec_approve's round-3 lesson, copied here at last:
2150
- // resolveProjectRoot returns its INPUT when no `.ax` ancestor exists, so a custom
2151
- // HOLMES_SPECS outside any project "derived" the spec store itself as the project and this
2152
- // guard then refused the CORRECT root. Only a derivation that actually found a marker
2153
- // speaks for a project.
2154
- if (derivationFoundMarker && asked !== bound) {
2155
- return {
2156
- ok: false,
2157
- reason: `이 서버는 ${bound} 프로젝트에 바인딩되어 있습니다 — 요청한 root ${a.root}는 ${asked} 를 가리킵니다.`
2158
- + ' 다른 프로젝트의 코퍼스를 이 서버가 대신 답하지 않습니다.',
2159
- };
2160
- }
2161
- }
2162
- const specs = await store.list();
2163
- return { specs: specs.map((s) => ({ id: s.id, type: s.type, status: s.status })), note: 'derive phase via phase_check' };
2164
- },
2165
- async phase_check(a) {
2166
- const specs = await store.list();
2167
- const action = a.action ?? (0, phase_1.classifyAction)(a.target);
2168
- if (!action)
2169
- return { decision: 'allow', note: 'unclassified target' };
2170
- return (0, phase_1.phaseCheck)(action, { specs, targetAspecId: a.targetAspecId });
2171
- },
2172
- async cpg_scan(a) {
2173
- // @implements A-SPEC-189 §14 (round 13) — the derivation was computed and discarded, so the
2174
- // advertised "a subdirectory resolves up to it" held for `taint_scan` and no one else.
2175
- const root = projectRootOf(a.root);
2176
- // @implements A-SPEC-131
2177
- // The skip report is part of the answer: a caller cannot distinguish "clean tree" from "tree
2178
- // with casualties" by counts alone. Bounded listing (50), complete count.
2179
- const { scanned, skipped } = cachedScanWithReport(root);
2180
- const symbols = scanned.reduce((n, f) => n + f.symbols.length, 0);
2181
- const base = { files: scanned.length, symbols, skipped: skipped.slice(0, 50), skippedCount: skipped.length };
2182
- if (!a.lints)
2183
- return base;
2184
- // @implements A-SPEC-506.1 — opt-in consistency lints (P-G/P-J field incidents): WARNING
2185
- // signals only, additive field only, Python only; the default path above is byte-identical.
2186
- const defined = new Set();
2187
- for (const f of scanned)
2188
- for (const s of f.symbols) {
2189
- defined.add(s.name);
2190
- const last = s.name.split('.').pop();
2191
- if (last)
2192
- defined.add(last);
2193
- }
2194
- const dynamicRef = [];
2195
- const asyncBlocking = [];
2196
- for (const f of scanned) {
2197
- if (!f.sourcePath.endsWith('.py'))
2198
- continue;
2199
- let code;
2200
- try {
2201
- code = fs.readFileSync(path.join(root, f.sourcePath), 'utf8');
2202
- }
2203
- catch {
2204
- continue;
2205
- }
2206
- for (const hit of (0, consistency_lints_1.dynamicRefFindings)(code, defined))
2207
- dynamicRef.push({ file: f.sourcePath, ...hit });
2208
- for (const hit of (0, consistency_lints_1.asyncBlockingFindings)(code))
2209
- asyncBlocking.push({ file: f.sourcePath, ...hit });
2210
- }
2211
- return { ...base, lints: { dynamicRef, asyncBlocking, limits: [...consistency_lints_1.LINT_LIMITS] } };
2212
- },
2213
- /**
2214
- * @implements A-SPEC-138
2215
- * Call-graph taint REACHABILITY screen: source-named functions that reach sink-named functions
2216
- * through call edges. A SCREENING signal that routes a security review, NOT a data-flow proof —
2217
- * the result always carries the honesty envelope (kind + limits) and phrases pairs as "reaches",
2218
- * never "vulnerable".
2219
- */
2220
- async taint_scan(a) {
2221
- // @implements A-SPEC-461 — same §15 defence as rtm_check: refuse a root that is not this
2222
- // server's project rather than scanning it against the bound store's specs.
2223
- {
2224
- const foreign = foreignRootReason(store, a.root);
2225
- if (foreign)
2226
- return { ok: false, reason: foreign };
2227
- }
2228
- const root = projectRootOf(a.root);
2229
- const scanned = cachedScan(root, root);
2230
- const g = new rtm_graph_1.RtmGraph();
2231
- try {
2232
- (0, rtm_builder_1.buildRtm)(await store.list(), scanned, g);
2233
- const cfg = taint_1.DEFAULT_TAINT_CONFIG;
2234
- const { pairs, truncated } = (0, taint_1.taintReachability)(g, cfg);
2235
- const base = { kind: 'call-reachability', limits: [...taint_1.TAINT_LIMITS], maxPaths: cfg.maxPaths, truncated, pairs };
2236
- if (a.dataFlow !== true)
2237
- return base;
2238
- // @implements A-SPEC-512.2
2239
- // OPT-IN def-use lane. Measured 2026-09-02: extracting data-flow facts over this
2240
- // repository costs 7.6s on top of the scan, which is why this follows the `cpg_scan
2241
- // lints:true` precedent instead of running by default. Before this slice the engine
2242
- // (A-SPEC-140.1, with its own 13-case benchmark) had NO caller at all — capability
2243
- // present, unreachable. Findings are filtered by reaching definitions: inter-procedural
2244
- // arrival is preserved, and only what the DDG positively refutes is dropped.
2245
- const { TreeSitterTsParser } = require('../cpg/language-parser');
2246
- const { taintDataFlow, DATAFLOW_LIMITS } = require('../rtm/dataflow-taint');
2247
- const { filterFindingsForFile } = require('../rtm/reaching-def-filter');
2248
- // @implements A-SPEC-513.1 — vocabulary is SELECTED per file language, never unioned:
2249
- // Python's `input(` must not taint a TypeScript `input`, and `req.body` means nothing in
2250
- // Python. Measured on jarvis: the shipped (JS) list missed `subprocess.run`, `os.environ`
2251
- // and `input(` entirely, so a Python repository could only ever report zero.
2252
- const { vocabularyFor } = require('../rtm/taint-vocabulary');
2253
- const parser = new TreeSitterTsParser();
2254
- const factSet = [];
2255
- // @implements A-SPEC-524.1 — every non-TS language with a CFG takes the flow-sensitive
2256
- // lane, each judged with ITS OWN vocabulary. The TS family keeps the facts+reaching-defs
2257
- // lane (its interprocedural propagation lives there).
2258
- const FLOW_EXT = [
2259
- [/\.py$/i, 'python'], [/\.go$/i, 'go'], [/\.rs$/i, 'rust'],
2260
- [/\.java$/i, 'java'], [/\.cs$/i, 'csharp'], [/\.(cpp|cc|cxx|hpp|h)$/i, 'cpp'],
2261
- ];
2262
- const flowLangFiles = new Map();
2263
- for (const f of scanned) {
2264
- const vocab = vocabularyFor(f.sourcePath);
2265
- if (!vocab)
2266
- continue;
2267
- const flowLang = FLOW_EXT.find(([re]) => re.test(f.sourcePath))?.[1];
2268
- if (flowLang !== undefined) {
2269
- flowLangFiles.set(flowLang, [...(flowLangFiles.get(flowLang) ?? []), f.sourcePath]);
2270
- continue;
2271
- }
2272
- let src;
2273
- try {
2274
- src = fs.readFileSync(path.join(root, f.sourcePath), 'utf8');
2275
- }
2276
- catch {
2277
- continue;
2278
- }
2279
- const facts = parser.extractDataFlow(src, f.sourcePath.endsWith('x') ? 'tsx' : 'typescript');
2280
- if (facts)
2281
- factSet.push({ file: f.sourcePath, facts });
2282
- }
2283
- const tsVocab = vocabularyFor('x.ts');
2284
- const raw = taintDataFlow(factSet, tsVocab);
2285
- const byFile = new Map();
2286
- for (const fnd of raw.findings)
2287
- byFile.set(fnd.file, [...(byFile.get(fnd.file) ?? []), fnd]);
2288
- const kept = [];
2289
- let refuted = 0;
2290
- for (const [rel, fs2] of byFile) {
2291
- const out = await filterFindingsForFile(root, rel, fs2, tsVocab);
2292
- kept.push(...out.kept.map((f) => ({ ...f, lane: 'facts+reaching-defs' })));
2293
- refuted += out.removed.length;
2294
- }
2295
- // @implements A-SPEC-513.1 / A-SPEC-524.1 — the flow-sensitive lane, generalized from
2296
- // Python-only to every language whose CFG/DDG landed in P2. The judgement engine is
2297
- // language-agnostic; only the vocabulary is selected per language.
2298
- const flowLangsAnalysed = [];
2299
- if (flowLangFiles.size > 0) {
2300
- const { parseAst } = require('../cpg/foundation/ast-store');
2301
- const { cfgOf, functionsIn } = require('../cpg/foundation/cfg');
2302
- const { ddgOf } = require('../cpg/foundation/ddg');
2303
- const { flowSensitiveTaint } = require('../rtm/flow-sensitive-taint');
2304
- for (const [flowLang, files] of flowLangFiles) {
2305
- flowLangsAnalysed.push(flowLang);
2306
- for (const rel of files) {
2307
- let src;
2308
- try {
2309
- src = fs.readFileSync(path.join(root, rel), 'utf8');
2310
- }
2311
- catch {
2312
- continue;
2313
- }
2314
- const vocab = vocabularyFor(rel);
2315
- const ast = await parseAst(src, rel);
2316
- if (!ast || ast.errorCount > 0)
2317
- continue;
2318
- for (const fn of functionsIn(ast)) {
2319
- const fcfg = cfgOf(ast, fn, src);
2320
- if ('unsupported' in fcfg)
2321
- continue;
2322
- const fddg = ddgOf(ast, fcfg, fn, src);
2323
- if ('unsupported' in fddg)
2324
- continue;
2325
- const r = flowSensitiveTaint({ ast, cfg: fcfg, ddg: fddg, source: src, fnName: rel, config: vocab });
2326
- for (const f of r.findings)
2327
- kept.push({ ...f, file: rel, lane: 'flow-sensitive' });
2328
- }
2329
- }
2330
- }
2331
- }
2332
- return {
2333
- ...base,
2334
- dataFlow: {
2335
- findings: kept,
2336
- /** Findings the reaching-definition evidence positively refuted (dead or fully sanitized). */
2337
- refutedByReachingDefs: refuted,
2338
- limits: [...DATAFLOW_LIMITS],
2339
- converged: raw.converged,
2340
- truncated: raw.truncated,
2341
- /** Which languages this run could actually judge, so a zero is readable. */
2342
- languagesAnalysed: [...new Set([factSet.length > 0 ? 'typescript' : null, ...flowLangsAnalysed].filter(Boolean))],
2343
- },
2344
- };
2345
- }
2346
- finally {
2347
- g.close();
2348
- }
2349
- },
2350
- async test_run(a) {
2351
- // Closes the decision->execution loop: scope -> run -> durable per-A-SPEC EXECUTION evidence
2352
- // (what the constitution's ART-4 prefers over the syntactic count).
2353
- const { root, specs, scanned, changedFiles, changedSymbols, changeSource, scopeFallback, anchorImpactedSpecs, changedTestFiles, unresolvedFiles } = await deriveChangedContext(store, a.root, a, 'test_run');
2354
- // @implements A-SPEC-534.8 — `mutate`: run a T-SPEC's declared `kills` mutations against the
2355
- // A-SPEC's source and report which SURVIVED (a discriminating-power gap). Selective, opt-in via
2356
- // the argument; absent → the ordinary run below is untouched.
2357
- if (a.mutate) {
2358
- const tspec = specs.find((s) => s.id === a.mutate && s.type === 'T-SPEC');
2359
- const kills = tspec ? (0, kills_1.parseKills)(tspec.frontmatter) : [];
2360
- const aspecId = tspec?.dependsOn[0];
2361
- const anchors = (0, test_scope_1.scanTestAnchors)(root);
2362
- const coveringFiles = aspecId
2363
- ? Object.entries(anchors).filter(([, ids]) => ids.includes(aspecId)).map(([f]) => f) : [];
2364
- const results = kills.map((m) => {
2365
- const src = aspecId ? sourceFileWithMutation(root, aspecId, m.where) : null;
2366
- if (!src)
2367
- return { mutation: m, applied: false, reason: 'no governed source anchors this A-SPEC and contains `where`' };
2368
- const r = (0, mutate_1.runKillsOnFile)(src, m, coveringFiles, (files) => (0, test_runner_1.runJestOutcomes)(files, root));
2369
- return { mutation: m, ...r };
2370
- });
2371
- const survivors = results.filter((r) => r.verdict === 'survived').map((r) => r.mutation);
2372
- return { mutate: { tspec: a.mutate, aspec: aspecId, coveringFiles, results, survivors } };
2373
- }
2374
- const g = new rtm_graph_1.RtmGraph();
2375
- let testScope;
2376
- try {
2377
- (0, rtm_builder_1.buildRtm)(specs, scanned, g);
2378
- const scope = (0, scope_1.computeReviewScope)(g, specs, changedSymbols);
2379
- // @implements A-SPEC-130
2380
- // Lane 2 widens the impacted set with file-anchor edges the symbol walk cannot see; lanes 3
2381
- // and the unresolved remainder ride in through extras. A nonempty change set can end in a
2382
- // scoped run or a full run — never in "nothing to do".
2383
- const widenedImpacted = [...new Set([...scope.impactedSpecs, ...anchorImpactedSpecs])];
2384
- // @implements A-SPEC-233 — the architecture axis of `assessRisk` already calls these changes
2385
- // `hard-hitl`; until now nothing on the test side consumed that verdict, so the most
2386
- // dangerous change could receive the narrowest run (measured 2026-08-22: tier `unit`, one
2387
- // test file, against `hard-hitl`).
2388
- testScope = (0, test_scope_1.computeTestScope)(widenedImpacted, (0, test_scope_1.scanTestAnchors)(root), specs, scope.coverageGaps, undefined, { changedTestFiles, unresolvedFiles, architectureTouched: (0, test_scope_1.architectureGoverningFiles)(changedFiles), obligationGaps: scope.obligationGaps });
2389
- // @implements A-SPEC-128
2390
- // No change set means no basis for narrowing. Widening is the same fail-safe direction
2391
- // computeTestScope already takes when no anchored test resolves — never a silently narrow run.
2392
- if (scopeFallback === 'full') {
2393
- testScope = { ...testScope, tier: 'full', reason: `${changeSource.reason ?? 'no change set'} — cannot narrow safely, full regression` };
2394
- }
2395
- }
2396
- finally {
2397
- g.close();
2398
- }
2399
- const result = (0, test_runner_1.runTestScope)(testScope, root);
2400
- const anchors = (0, test_scope_1.scanTestAnchors)(root);
2401
- const executedByAspec = (0, test_evidence_1.computeExecutedByAspec)(result.executedByFile ?? {}, anchors);
2402
- let head = '';
2403
- try {
2404
- head = (0, node_child_process_1.execFileSync)('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8', env: (0, root_1.cleanSubprocessEnv)(), stdio: ['ignore', 'pipe', 'pipe'] }).trim();
2405
- }
2406
- catch { /* non-git */ }
2407
- // Record ONLY an actually-executed, GREEN run (review C4/C7): a red suite must not stand as
2408
- // coverage evidence at the moment the code is broken, and a no-op run must not overwrite a real
2409
- // record with a dishonest `passed: true` over an empty result.
2410
- // @implements A-SPEC-578.4 — read BEFORE the write below replaces it: the calibration window
2411
- // is "since the previous evidence run", and this line is the only moment that value exists.
2412
- const previousEvidenceTs = (0, test_evidence_1.readTestEvidence)(root)?.ts;
2413
- const verified = (0, baseline_1.shouldRecordBaseline)(result);
2414
- if (verified) {
2415
- (0, test_evidence_1.writeTestEvidence)(root, { ts: new Date().toISOString(), head, tier: testScope.tier, passed: true, executedByAspec });
2416
- }
2417
- // @implements A-SPEC-534.5 — RED-first outcome evidence for ART-8. Recorded UNCONDITIONALLY,
2418
- // unlike the green-only baseline above: a red-assertion recorded before the code is exactly what
2419
- // the red→green sequence needs. Append-only, one record per (A-SPEC, outcome) at this HEAD.
2420
- if (result.outcomeByFile) {
2421
- (0, test_outcomes_1.appendOutcomes)(root, (0, test_outcomes_1.buildOutcomeRecords)(result.outcomeByFile, anchors, head, new Date().toISOString()));
2422
- }
2423
- // @implements A-SPEC-128
2424
- // The baseline is written under EXACTLY the condition that already gates evidence: a run that
2425
- // actually executed and passed. A red or skipped run must never become the reference point for
2426
- // "since the last verified state" — that would silently narrow every later scope against a
2427
- // state nobody verified.
2428
- // @implements A-SPEC-578.4 — close the calibration loop on real work.
2429
- // The window is the PREVIOUS evidence run's timestamp, read before this run overwrote it —
2430
- // an analysis older than that belonged to a previous slice, and attributing today's changes
2431
- // to it would be contamination rather than measurement. Its own try: an observation that
2432
- // could change `passed` would be a gate wearing another name.
2433
- let calibrationClosed;
2434
- try {
2435
- const closed = (0, maintenance_evidence_1.closeOpenArtifacts)(path.join(root, maintenance_evidence_1.EVIDENCE_DIR), { files: changedFiles }, new Date().toISOString(), previousEvidenceTs).closed;
2436
- if (closed.length > 0)
2437
- calibrationClosed = closed;
2438
- }
2439
- catch { /* the loop is observation; it never touches the verdict */ }
2440
- // @implements A-SPEC-128
2441
- let baseline;
2442
- if (verified) {
2443
- baseline = a.mark ?? DEFAULT_BASELINE;
2444
- // A-SPEC-256.2 round-4 — surfaced to the report below
2445
- const ig = (0, ignore_1.loadIgnore)(root); // parse .gitignore ONCE, not per file (round-10)
2446
- (0, baseline_1.writeBaseline)(root, baseline, (0, change_source_1.hashTree)(root, { isIgnored: (p) => ig.isIgnored(p) }));
2447
- }
2448
- return { tier: testScope.tier, mode: result.mode, passed: result.passed, skipped: result.skipped,
2449
- ranFiles: result.ranFiles, executedByAspec, tail: result.tail,
2450
- // @implements A-SPEC-130 — the remediation rides in the answer: these are the files to anchor.
2451
- unresolvedFiles: testScope.unresolvedFiles,
2452
- changeSource, ...(scopeFallback ? { scopeFallback } : {}), ...(baseline ? { baselineRecorded: baseline } : {}),
2453
- ...(calibrationClosed ? { calibrationClosed } : {}) };
2454
- },
2455
- async issue_localize(a) {
2456
- assertSpecStoreReachable('issue_localize', store, a.root); // @implements A-SPEC-419
2457
- // N1: deterministic localization report — CPG lexical match fused with the RTM spec hop.
2458
- // @implements A-SPEC-189 §14 (round 13) — `projectRootOf(a.root)` was called as a bare
2459
- // statement: its only effect was to throw on a bad path, and the advertised contract ("a
2460
- // subdirectory resolves up to it") was dropped on the floor. `taint_scan` one function away
2461
- // honours it, so the same argument answered about two different trees depending on which tool
2462
- // was asked. Bind the derivation and use it.
2463
- const root = projectRootOf(a.root);
2464
- const scanned = cachedScan(root, root);
2465
- const specs = await store.list();
2466
- const governed = (0, spec_types_1.filterGoverned)(specs);
2467
- const report = (0, localize_1.localizeIssue)(a.issue, scanned, governed, a.topN ?? 10);
2468
- // @implements A-SPEC-496 — parity with A-SPEC-478: the SAME uncited gate, the SAME math
2469
- // (cached vectors only, set fixed, covered hits move, why-line attached). Any missing
2470
- // signal — no tier, no key, cold cache, embed failure — leaves the report untouched;
2471
- // localization itself never fails because of the semantic layer.
2472
- //
2473
- // A spec-intent-vector assist (REQ-568 S3) was wired ahead of localizeIssue here and REVERTED
2474
- // on its pre-registered replay — see rtm/localize.ts at the matchedSpecs join for the numbers.
2475
- try {
2476
- if (report.hits.length > 1
2477
- && (0, localize_1.citationsIn)(a.issue, new Set(governed.map((s) => s.id))).cited.length === 0) {
2478
- const rt = (0, embedder_1.makeTierEmbedder)((0, tier_1.resolveSemanticTier)(), new vector_cache_1.VectorCache(root));
2479
- if (rt !== null) {
2480
- const qv = await rt.embedQuery(a.issue);
2481
- const docTextOf = new Map(scanned.map((f) => [f.sourcePath,
2482
- (f.sourcePath + ' ' + f.symbols.map((sy) => sy.qualifiedName).join(' ')).slice(0, 2000)]));
2483
- (0, hit_rerank_1.rerankHitsBySemantic)(report.hits, qv, (file) => {
2484
- const dt = docTextOf.get(file);
2485
- return dt !== undefined ? rt.cachedDocVector(dt) : null;
2486
- }, rt.label);
2487
- }
2488
- }
2489
- }
2490
- catch { /* semantic layer is additive — never block the report */ }
2491
- return report;
2492
- },
2493
- async maintenance_analyze(a) {
2494
- const foreign = foreignRootReason(store, a.root);
2495
- if (foreign)
2496
- throw new HandlerRefusal(foreign);
2497
- const root = projectRootOf(a.root);
2498
- // This tool is advertised as read-only. Do not attach ScanFileCache: its normal save/prune
2499
- // behavior mutates `.ax/cpg_cache` even though the analysis itself is observational.
2500
- const scanner = new cpg_scanner_1.CpgScanner();
2501
- const scanned = scanner.scan(root, root);
2502
- const skipped = scanner.scanSkipped();
2503
- const specs = (0, spec_types_1.filterGoverned)(await store.list());
2504
- const anchors = (0, test_scope_1.scanTestAnchors)(root);
2505
- const detail = basisFor(root, true);
2506
- let head = '';
2507
- try {
2508
- head = (0, node_child_process_1.execFileSync)('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8', env: (0, root_1.cleanSubprocessEnv)(), stdio: ['ignore', 'pipe', 'pipe'] }).trim();
2509
- }
2510
- catch { /* non-git */ }
2511
- const supportedExtensions = new Set(cpg_scanner_1.SCANNABLE_EXTENSIONS);
2512
- const knownCodeExtensions = new Set(['.rb', '.php', '.kt', '.kts', '.swift', '.scala', '.sh', '.ex', '.exs', '.lua', '.r']);
2513
- const unsupportedLanguages = new Set();
2514
- try {
2515
- const files = (0, node_child_process_1.execFileSync)('git', ['-C', root, 'ls-files', '-z', '--cached', '--others', '--exclude-standard'], { encoding: 'utf8', env: (0, root_1.cleanSubprocessEnv)(), maxBuffer: 8 * 1024 * 1024 }).split('\0');
2516
- for (const file of files) {
2517
- const ext = path.extname(file).toLowerCase();
2518
- if (knownCodeExtensions.has(ext) && !supportedExtensions.has(ext))
2519
- unsupportedLanguages.add(ext);
2520
- }
2521
- }
2522
- catch {
2523
- // A non-git tree cannot be enumerated cheaply without reimplementing the scanner's ignore
2524
- // rules. Surface the missing axis rather than claiming every language was covered.
2525
- unsupportedLanguages.add('not-enumerated(non-git)');
2526
- }
2527
- const graph = new rtm_graph_1.RtmGraph();
2528
- try {
2529
- // @implements A-SPEC-292 — the resolution loop's own tally of what it declined to link.
2530
- const { resolution } = (0, rtm_builder_1.buildRtm)(specs, scanned, graph);
2531
- // @implements A-SPEC-293 — what constrains this change, not just what it touches.
2532
- const { decisions, citations } = collectDecisions(root, scanned, specs);
2533
- (0, rtm_builder_1.addDecisionEdges)(decisions, citations, graph);
2534
- const common = {
2535
- request: a.request,
2536
- scanned,
2537
- specs,
2538
- graph,
2539
- testAnchors: anchors,
2540
- basis: {
2541
- head,
2542
- loadedBuild: detail.loadedBuild,
2543
- diskBuild: detail.diskBuild ?? 'unknown',
2544
- specFingerprint: `count:${specs.length}/fp:${detail.specs ?? 'unknown'}`,
2545
- },
2546
- changedFiles: a.changedFiles,
2547
- // @implements A-SPEC-290 — the stored run record. Freshness is judged inside the analysis
2548
- // against its own basis, so a record from another commit is reported as stale rather than
2549
- // quietly used.
2550
- runtimeEvidence: (0, test_evidence_1.readTestEvidence)(root),
2551
- resolution,
2552
- coverage: {
2553
- scannedFiles: scanned.length,
2554
- skippedFiles: skipped.map((entry) => entry.file),
2555
- unsupportedLanguages: [...unsupportedLanguages],
2556
- historyStatus: 'not-requested',
2557
- },
2558
- };
2559
- // @implements A-SPEC-478 — resolve the tier and precompute the query vector at the
2560
- // async edge; the sync analysis receives only a closure over cached vectors. Any failure
2561
- // (no tier, no module, no key, embed error) leaves `semantic` undefined = today's behavior.
2562
- let semantic;
2563
- try {
2564
- const rt = (0, embedder_1.makeTierEmbedder)((0, tier_1.resolveSemanticTier)(), new vector_cache_1.VectorCache(root));
2565
- if (rt !== null) {
2566
- const qv = await rt.embedQuery(a.request);
2567
- semantic = { label: rt.label, queryVector: qv, cachedDocVector: rt.cachedDocVector };
2568
- }
2569
- }
2570
- catch { /* the hot path never breaks on the semantic layer */ }
2571
- const preliminary = (0, maintenance_analyze_1.analyzeMaintenance)({ ...common, history: [], semantic });
2572
- const candidateFiles = preliminary.candidates.map((candidate) => candidate.file);
2573
- const limit = Math.max(0, Math.min(100, Math.floor(a.historyLimit ?? 20)));
2574
- const history = [];
2575
- let historyStatus = limit === 0 || candidateFiles.length === 0 ? 'not-requested' : 'unavailable';
2576
- if (limit > 0 && candidateFiles.length > 0) {
2577
- try {
2578
- const raw = (0, node_child_process_1.execFileSync)('git', ['-C', root, 'log', `-${limit}`, '--date=short', '--format=%H%x00%ad%x00%s', '--name-only', '--', ...candidateFiles], { encoding: 'utf8', env: (0, root_1.cleanSubprocessEnv)(), maxBuffer: 2 * 1024 * 1024 });
2579
- let current = null;
2580
- for (const line of raw.split('\n')) {
2581
- if (line.includes('\0')) {
2582
- const [commit, date, subject] = line.split('\0');
2583
- current = { commit, date, subject, files: [] };
2584
- history.push(current);
2585
- }
2586
- else if (current && line.trim() !== '') {
2587
- // git C-quotes any non-ASCII pathname here (core.quotepath). Left raw, it never
2588
- // matched a candidate file, so the rationale came back empty while historyStatus
2589
- // still reported `available` — a silent false negative with confident provenance.
2590
- current.files.push((0, maintenance_analyze_1.unquoteGitPath)(line.trim()));
2591
- }
2592
- }
2593
- historyStatus = 'available';
2594
- }
2595
- catch { /* history is evidence enrichment, not a prerequisite */ }
2596
- }
2597
- // Preserve localization rank for context: `impacts.direct` is deliberately sorted for a
2598
- // stable evidence contract, but sorting is not relevance. Seeding from it once selected a
2599
- // lexically earlier vendored symbol while the first ranked hit correctly named live code.
2600
- const directSeed = preliminary.candidates
2601
- .flatMap((candidate) => candidate.symbols)
2602
- .flatMap((qualifiedName) => graph.codeNodeIds(qualifiedName))[0];
2603
- const specSeed = preliminary.candidateSpecs.find((id) => id.startsWith('A-SPEC-'));
2604
- const seedId = directSeed ?? (specSeed ? `SPEC:${specSeed}` : undefined);
2605
- const budget = Math.max(100, Math.min(10_000, Math.floor(a.contextBudget ?? 2_000)));
2606
- const contextBundle = seedId
2607
- ? new bundler_1.ContextBundler(graph, buildContentSource(specs, scanned)).getContextBundle(seedId, budget)
2608
- : null;
2609
- // @implements A-SPEC-378
2610
- // Change recency over a WIDE window and ALL files — the enrichment history above is
2611
- // deliberately narrow (candidate files, 20 commits) and cannot serve as a prior: a file the
2612
- // lexical layer never ranked has no history there, which is precisely the file recency is
2613
- // supposed to promote. Failure is silent and total: no prior means the previous ranking,
2614
- // never a partial one, because a prior built from half the history would reorder on evidence
2615
- // nobody could audit.
2616
- const changePrior = {};
2617
- try {
2618
- const prior = (0, temporal_prior_1.temporalPrior)(root, 'HEAD', 300);
2619
- for (const [file, age] of prior.recency)
2620
- changePrior[file] = 1 + Math.pow(0.5, age / 10);
2621
- }
2622
- catch { /* no history, no prior — the ranking falls back to lexical evidence alone */ }
2623
- // @implements A-SPEC-388
2624
- // What each file's commits have SAID, normalised to 0..1. Measured on history sampled away
2625
- // from the benchmarker's own recent work (A-SPEC-387): Top-1 0.244 -> 0.333 here and 0.400 ->
2626
- // 0.500 on a second repository, Top-10 recall 0.393 -> 0.601 there. Precision falls on both,
2627
- // and that is the trade, not a footnote to it.
2628
- const commitTextBoost = {};
2629
- try {
2630
- const profile = (0, commit_text_1.commitTextProfile)(root, 'HEAD', 400);
2631
- const ranked = (0, commit_text_1.rankByCommitText)(a.request, profile, 300);
2632
- const top = ranked[0]?.score ?? 0;
2633
- if (top > 0)
2634
- for (const hit of ranked)
2635
- commitTextBoost[hit.file] = hit.score / top;
2636
- }
2637
- catch { /* no history, no boost — the ranking falls back to lexical evidence alone */ }
2638
- const analyzeWith = (defUse) => (0, maintenance_analyze_1.analyzeMaintenance)({
2639
- semantic,
2640
- ...common,
2641
- coverage: { ...common.coverage, historyStatus },
2642
- history,
2643
- changePrior,
2644
- commitTextBoost,
2645
- contextBundle,
2646
- groundTruth: a.groundTruth,
2647
- defUse,
2648
- });
2649
- // @implements A-SPEC-573.4 — def-use for the TOP CANDIDATES ONLY. Extracting it for the whole
2650
- // repository costs +88.8% (measured 2026-09-08), well past this slice's budget; the first pass
2651
- // says which handful of files are worth parsing, and the second pass reads their data flow.
2652
- // Every step is fail-open: a parse failure, an unsupported language or a missing file leaves
2653
- // the candidate's symbols exactly as the first pass produced them.
2654
- const firstPass = analyzeWith();
2655
- const analysis = (() => {
2656
- const targets = firstPass.candidates.slice(0, defuse_bound_1.DEFUSE_TOP_FILES).map((c) => c.file);
2657
- if (targets.length === 0)
2658
- return firstPass;
2659
- const defUse = {};
2660
- try {
2661
- const { TreeSitterTsParser, hasDataFlowWalk } = require('../cpg/language-parser');
2662
- const { langForPath } = require('../cpg/cpg-scanner');
2663
- const parser = new TreeSitterTsParser();
2664
- for (const file of targets) {
2665
- try {
2666
- // The LANGUAGE matters: the first wiring omitted it and parsed Python as TypeScript,
2667
- // which produced wrong facts and cost the second corpus 0.2376 -> 0.1741 on the
2668
- // symbol axis. A language with no walk is skipped rather than guessed at.
2669
- const lang = langForPath(file);
2670
- if (!hasDataFlowWalk(lang))
2671
- continue;
2672
- const facts = parser.extractDataFlow(fs.readFileSync(path.join(root, file), 'utf8'), lang);
2673
- if (facts !== undefined)
2674
- defUse[file] = facts;
2675
- }
2676
- catch { /* one unreadable or unparseable file must not cost the other nine */ }
2677
- }
2678
- }
2679
- catch {
2680
- return firstPass;
2681
- }
2682
- return Object.keys(defUse).length === 0 ? firstPass : analyzeWith(defUse);
2683
- })();
2684
- // @implements A-SPEC-268 — persistence is OPT-IN. The tool is advertised read-only, and a
2685
- // regression pins that a cold project gains no `.ax/cpg_cache`; writing evidence by default
2686
- // would break that contract for every caller who only wanted to look.
2687
- // One shape either way: `persistedTo` is present-or-undefined rather than a union, so a
2688
- // caller never has to narrow the result to ask whether anything was written.
2689
- const result = { ...analysis };
2690
- // @implements A-SPEC-418 — first line, because everything below it was computed without specs.
2691
- const blind = specStoreBlindReason(store, root, specs.length);
2692
- if (blind !== null)
2693
- result.unknowns = [blind, ...result.unknowns];
2694
- if (a.persist) {
2695
- // @implements A-SPEC-277 — record WHAT WAS ANALYSED, not just which paths. HEAD cannot see
2696
- // an uncommitted edit to the analysed file; a content digest can.
2697
- const fileDigests = {};
2698
- for (const file of analysis.impacts.files) {
2699
- const digest = fileDigestOf(root, file);
2700
- if (digest)
2701
- fileDigests[file] = digest;
2702
- }
2703
- // The artifact takes the WHOLE analysis, never the bounded one below: a file has no
2704
- // context window, and the calibration that scores this prediction must score what the
2705
- // product actually predicted.
2706
- result.persistedTo = (0, maintenance_evidence_1.writeArtifact)(path.join(root, maintenance_evidence_1.EVIDENCE_DIR), (0, maintenance_evidence_1.artifactFrom)(analysis, new Date().toISOString(), fileDigests));
2707
- }
2708
- // @implements A-SPEC-578.5 — the response, and only the response, is bounded. Measured
2709
- // 2026-09-09: the full shape is 187,174 characters (~47k tokens) and the harness refuses
2710
- // it, which is why AGENTS.md step 3 had never once been obeyed with `persist: true`.
2711
- // `persistedTo` rides along so the caller knows where the whole thing is.
2712
- const bounded = (0, maintenance_analyze_1.boundAnalysis)(result);
2713
- if (JSON.stringify(bounded).length !== JSON.stringify(result).length)
2714
- bounded.bounded = true;
2715
- return bounded;
2716
- }
2717
- finally {
2718
- graph.close();
2719
- }
2720
- },
2721
- // @implements A-SPEC-268
2722
- async maintenance_outcome(a) {
2723
- const foreign = foreignRootReason(store, a.root);
2724
- if (foreign)
2725
- throw new HandlerRefusal(foreign);
2726
- const root = projectRootOf(a.root);
2727
- return (0, maintenance_evidence_1.recordOutcome)(path.join(root, maintenance_evidence_1.EVIDENCE_DIR), a.digest, {
2728
- actualFiles: a.actualFiles,
2729
- actualSymbols: a.actualSymbols,
2730
- actualTests: a.actualTests,
2731
- actualClassification: a.actualClassification,
2732
- }, new Date().toISOString());
2733
- },
2734
- // @implements A-SPEC-277
2735
- async impact_gate_check(a) {
2736
- const foreign = foreignRootReason(store, a.root);
2737
- if (foreign)
2738
- throw new HandlerRefusal(foreign);
2739
- const root = projectRootOf(a.root);
2740
- let head = '';
2741
- try {
2742
- head = (0, node_child_process_1.execFileSync)('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8', env: (0, root_1.cleanSubprocessEnv)(), stdio: ['ignore', 'pipe', 'pipe'] }).trim();
2743
- }
2744
- catch { /* non-git */ }
2745
- let artifact = null;
2746
- if (a.digest) {
2747
- const file = path.join(root, maintenance_evidence_1.EVIDENCE_DIR, (0, maintenance_evidence_1.digestFilename)(a.digest));
2748
- // A digest naming an artifact this project never stored is NOT the same as no evidence at
2749
- // all — say which one happened rather than collapsing both into "refused".
2750
- if (!fs.existsSync(file))
2751
- throw new HandlerRefusal(`이 프로젝트에 저장된 분석이 아닙니다: ${a.digest}`);
2752
- artifact = JSON.parse(fs.readFileSync(file, 'utf8'));
2753
- }
2754
- return (0, impact_gate_1.evaluateEditEvidence)({
2755
- target: a.target.replace(/\\/g, '/'),
2756
- artifact,
2757
- currentHead: head,
2758
- currentTargetDigest: fileDigestOf(root, a.target),
2759
- });
2760
- },
2761
- // @implements A-SPEC-268
2762
- async maintenance_calibration(a) {
2763
- const foreign = foreignRootReason(store, a.root);
2764
- if (foreign)
2765
- throw new HandlerRefusal(foreign);
2766
- const root = projectRootOf(a.root);
2767
- const read = (0, maintenance_evidence_1.readArtifacts)(path.join(root, maintenance_evidence_1.EVIDENCE_DIR));
2768
- // Corrupt and off-schema records travel WITH the score. A calibration that quietly dropped
2769
- // them would report a clean number over an unstated subset of the evidence.
2770
- return {
2771
- ...(0, maintenance_evidence_1.computeCalibration)(read.artifacts, { minSamples: a.minSamples }),
2772
- artifacts: read.artifacts.length,
2773
- unreadable: read.unreadable,
2774
- skippedSchema: read.skippedSchema,
2775
- };
2776
- },
2777
- async rtm_impact(a) {
2778
- assertSpecStoreReachable('rtm_impact', store, a.root); // @implements A-SPEC-433
2779
- // @implements A-SPEC-189 §14 (round 13) — `projectRootOf(a.root)` was called as a bare
2780
- // statement: its only effect was to throw on a bad path, and the advertised contract ("a
2781
- // subdirectory resolves up to it") was dropped on the floor. `taint_scan` one function away
2782
- // honours it, so the same argument answered about two different trees depending on which tool
2783
- // was asked. Bind the derivation and use it.
2784
- const root = projectRootOf(a.root);
2785
- const scanned = cachedScan(root);
2786
- // @implements A-SPEC-569.2 — the impact/advisory graph is APPROVED-ONLY. A draft needs no
2787
- // approval to exist, and the 0.16.0 adversarial round showed one reaching the agent-visible
2788
- // channels (impacted closure, advisory anchor summaries) — the trust boundary for those
2789
- // channels is the act of approval. NOT filterGoverned: that predicate passes drafts (it only
2790
- // drops outdated/legacy), which is exactly what let this in. Diagnosis (rtm_check) and
2791
- // matching (issue_localize / maintenance_analyze) keep their own populations — the replay
2792
- // pins were measured on them.
2793
- const specs = (await store.list()).filter((s) => s.status === 'approved');
2794
- // @implements A-SPEC-283
2795
- // Reuse the persisted graph when its basis still holds. Measured: on the warm path the graph
2796
- // build is ~81% of the cost and reopening is ~0ms. `scanDigest` is the field that makes this
2797
- // safe — an uncommitted edit never moves HEAD, so a commit-only basis would happily serve a
2798
- // graph describing content the working tree no longer has.
2799
- let head = '';
2800
- try {
2801
- head = (0, node_child_process_1.execFileSync)('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8', env: (0, root_1.cleanSubprocessEnv)(), stdio: ['ignore', 'pipe', 'pipe'] }).trim();
2802
- }
2803
- catch { /* non-git */ }
2804
- const opened = (0, graph_store_1.openReusableGraph)(path.join(root, '.ax', 'rtm.sqlite'), {
2805
- graphSchema: RTM_GRAPH_SCHEMA,
2806
- extractorVersion: RTM_EXTRACTOR_VERSION,
2807
- sourceCommit: head,
2808
- specFingerprint: (0, graph_store_1.specFingerprint)(specs),
2809
- scanDigest: (0, graph_store_1.scanDigest)(scanned),
2810
- });
2811
- const g = opened.graph;
2812
- try {
2813
- if (!opened.reusable) {
2814
- (0, rtm_builder_1.buildRtm)(specs, scanned, g);
2815
- opened.commitBasis();
2816
- }
2817
- // explainImpact, not impactedBy: the bounds and the breadth signal must reach the caller.
2818
- // An impact set is not just a list — a broad one means "review the contract", and a consumer
2819
- // that cannot tell the difference will try to bundle two hundred call sites.
2820
- const { specs: impactedRaw, reachedByDepth, stoppedAt, seedIsHub } = (0, rtm_builder_1.explainImpact)(g, a.changed);
2821
- // @implements A-SPEC-569.2 — the closure walks EDGES, and an implements edge is owned by
2822
- // the code file, so an anchor naming a draft (or a spec nobody wrote) still emits one —
2823
- // deliberately, for rtm_check's dangling diagnosis. The CHANNEL filter is node existence:
2824
- // approved-only specs were given nodes above, so only sealed intent reaches the caller.
2825
- const impacted = impactedRaw.filter((id) => g.hasNode(id));
2826
- // @implements A-SPEC-469 — the graded FILE surface beside the spec closure, same code path
2827
- // as the S-484 measurement (identity, not reimplementation). Seeds are the changed symbols'
2828
- // nodes; the files that own them are excluded — a prediction naming the change itself is
2829
- // vacuously true. The closure fields above stay exactly as they were: mass never enters a
2830
- // gate decision.
2831
- const riSeeds = new Map(a.changed.flatMap((qn) => g.codeNodeIds(qn)).map((id) => [id, 1]));
2832
- const riExclude = new Set([...riSeeds.keys()]
2833
- .map((id) => (id.includes('@') ? id.slice(id.lastIndexOf('@') + 1) : ''))
2834
- .filter((f) => f !== ''));
2835
- const rankedImpact = (0, assoc_arm_1.pprImpactRanked)((0, assoc_arm_1.graphViewOf)(g.dumpCanonical()), riSeeds, riExclude, assoc_arm_1.RANKED_IMPACT_K, assoc_arm_1.RANKED_IMPACT_CONFIG);
2836
- // @implements A-SPEC-568.2 — the intent sentence beside every impacted spec id, same order
2837
- // as `impacted` (which stays a bare id list for its existing consumers). Information only:
2838
- // nothing reads it back into the walk, the ranking or any gate.
2839
- // @implements A-SPEC-569.3 — capped: measured on this repository, uncapped summaries were
2840
- // 94% of a 104,706-byte response (a hub-grade impact of 339 specs). The omission is COUNTED,
2841
- // never silent, and `impacted` itself stays complete — only the annotation is bounded.
2842
- const shownSummaries = impacted.slice(0, SUMMARY_CAP);
2843
- const impactedSummaries = shownSummaries.map((id) => {
2844
- let summary = null;
2845
- try {
2846
- summary = g.summaryOf(id);
2847
- }
2848
- catch { /* summary stays null */ }
2849
- return { id, summary };
2850
- });
2851
- const summariesOmitted = impacted.length - shownSummaries.length;
2852
- return {
2853
- impacted,
2854
- impactedSummaries,
2855
- ...(summariesOmitted > 0 ? { summariesOmitted } : {}),
2856
- rankedImpact,
2857
- reachedByDepth,
2858
- bounded: stoppedAt.length > 0 ? stoppedAt.slice(0, 20) : undefined,
2859
- ...(seedIsHub ? {
2860
- 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`,
2861
- } : {}),
2862
- };
2863
- }
2864
- finally {
2865
- g.close(); // release native SQLite handle even if build/query throws
2866
- }
2867
- },
2868
- async rtm_reindex(a) {
2869
- assertSpecStoreReachable('rtm_reindex', store, a.root); // @implements A-SPEC-419
2870
- // Removal/re-derivation of changedFiles/changedSymbols is not needed
2871
- // here (rtm_reindex works off changes/specs/scanned/bySourcePath
2872
- // directly), but the guard+diff+scan prologue is identical to
2873
- // review_scope/review_prepare — see deriveChangedContext.
2874
- const { root, changes, specs, scanned, bySourcePath, changeSource } = await deriveChangedContext(store, a.root, a, 'rtm_reindex');
2875
- const changed = changes.added.length + changes.modified.length + changes.deleted.length + changes.renamed.length;
2876
- const scanOne = (relPath) => bySourcePath.get(relPath) ?? null;
2877
- // HashCache skip: for add/modify entries, if the file's current content
2878
- // hash matches what's on record, the file is dropped from the set
2879
- // handed to applyIncremental — its subgraph is left untouched rather
2880
- // than being torn down and rebuilt for no reason (e.g. a spurious
2881
- // `git diff` entry, a mode-only change, or a re-run over the same
2882
- // range). Deletes and renames are always applied: a delete has no
2883
- // "content" to compare, and a rename's `from` side must be removed
2884
- // regardless of whether `to`'s content matches something previously
2885
- // hashed under a different path.
2886
- const cache = new hash_cache_1.HashCache(cacheDirFor(root)); // @implements A-SPEC-128 — project-root cache, no subdir leak
2887
- const skipUnchanged = (files) => files.filter((relPath) => {
2888
- let content;
2889
- try {
2890
- content = fs.readFileSync(path.join(root, relPath), 'utf8');
2891
- }
2892
- catch {
2893
- return true; // unreadable (e.g. already gone) — let applyIncremental/scanOne handle it
2894
- }
2895
- if (cache.unchanged(relPath, content))
2896
- return false; // skip: content identical to last recorded hash
2897
- cache.put(relPath, content);
2898
- return true;
2899
- });
2900
- const changesToApply = {
2901
- added: skipUnchanged(changes.added),
2902
- modified: skipUnchanged(changes.modified),
2903
- deleted: changes.deleted,
2904
- renamed: changes.renamed,
2905
- };
2906
- let head = '';
2907
- try {
2908
- head = (0, node_child_process_1.execFileSync)('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8', env: (0, root_1.cleanSubprocessEnv)(), stdio: ['ignore', 'pipe', 'pipe'] }).trim();
2909
- }
2910
- catch { /* non-git */ }
2911
- const graphPath = path.join(root, '.ax', 'rtm.sqlite');
2912
- const tempGraphPath = `${graphPath}.tmp-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
2913
- const opened = (0, graph_store_1.openReusableGraph)(tempGraphPath, {
2914
- graphSchema: RTM_GRAPH_SCHEMA,
2915
- extractorVersion: RTM_EXTRACTOR_VERSION,
2916
- sourceCommit: head,
2917
- specFingerprint: (0, graph_store_1.specFingerprint)(specs),
2918
- scanDigest: (0, graph_store_1.scanDigest)(scanned),
2919
- });
2920
- const g = opened.graph;
2921
- let result;
2922
- try {
2923
- // Explicit reindex is a persistence operation: discard the previous contents, rebuild the
2924
- // complete graph, then commit the matching basis before releasing the file (A-SPEC-589).
2925
- g.clear();
2926
- (0, rtm_builder_1.buildRtm)(specs, scanned, g);
2927
- // Scaffold note: `g` was just full-built at HEAD, so applying the
2928
- // diff on top of it is currently an idempotent no-op in practice
2929
- // (the affected subgraphs are already correct). This call exists to
2930
- // exercise/validate the incremental machinery end-to-end now, ahead
2931
- // of a FUTURE persisted-graph reindex — build the graph once, then
2932
- // apply diffs across calls without a full rebuild — that will
2933
- // actually depend on this path being correct. Not load-bearing today.
2934
- // @implements A-SPEC-280 — the whole scan, so call edges are re-resolved and the reindexed
2935
- // graph equals what a full rebuild would have produced. Measured: without this, rtm_reindex
2936
- // returned a graph with no call edges at all.
2937
- (0, incremental_1.applyIncremental)(g, changesToApply, { repoRoot: root, specs, scanOne, allScanned: () => scanned });
2938
- opened.commitBasis();
2939
- // @implements A-SPEC-478 — the semantic cache WARMING lives here, in the explicit heavy
2940
- // operation, so the ranking hot path only ever LOOKS UP vectors. Idempotent through the
2941
- // cache; a tier of none (the shipped default) computes nothing.
2942
- let semanticWarm;
2943
- try {
2944
- const tier = (0, tier_1.resolveSemanticTier)();
2945
- const rt = (0, embedder_1.makeTierEmbedder)(tier, new vector_cache_1.VectorCache(root));
2946
- if (rt !== null) {
2947
- const docTexts = scanned.map((f) => (f.sourcePath + ' ' + f.symbols.map((sy) => sy.qualifiedName).join(' ')).slice(0, 2000));
2948
- const w = await rt.warm(docTexts);
2949
- semanticWarm = { tier: rt.label, ...w };
2950
- }
2951
- }
2952
- catch { /* warming is best-effort; reindex's own result is unaffected */ }
2953
- result = { changed, nodes: g.nodeCount(), edges: g.edgeCount(), changeSource,
2954
- ...(semanticWarm !== undefined ? { semanticWarm } : {}) };
2955
- }
2956
- finally {
2957
- g.close(); // release native SQLite handle even if build/apply throws
2958
- }
2959
- // Publish only after the complete temporary graph is closed. Rename the old file aside so a
2960
- // Windows reader either keeps the old complete graph or sees the new complete graph; it never
2961
- // observes a partially built database or a basis that belongs to another file (A-SPEC-589).
2962
- const backupPath = `${graphPath}.bak-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
2963
- const hadOld = fs.existsSync(graphPath);
2964
- try {
2965
- if (hadOld)
2966
- fs.renameSync(graphPath, backupPath);
2967
- fs.renameSync(tempGraphPath, graphPath);
2968
- if (hadOld)
2969
- fs.rmSync(backupPath, { force: true });
2970
- }
2971
- catch (error) {
2972
- try {
2973
- if (!fs.existsSync(graphPath) && fs.existsSync(backupPath))
2974
- fs.renameSync(backupPath, graphPath);
2975
- }
2976
- catch { /* preserve the publication error; the old path is reported as unavailable */ }
2977
- throw error;
2978
- }
2979
- finally {
2980
- try {
2981
- fs.rmSync(tempGraphPath, { force: true });
2982
- }
2983
- catch { /* already renamed or retained for diagnosis */ }
2984
- }
2985
- return result;
2986
- },
2987
- async context_bundle(a) {
2988
- assertSpecStoreReachable('context_bundle', store, a.root); // @implements A-SPEC-433
2989
- const foreign = foreignRootReason(store, a.root);
2990
- if (foreign)
2991
- throw new HandlerRefusal(foreign);
2992
- const root = projectRootOf(a.root);
2993
- const specs = await store.list();
2994
- const scanned = cachedScan(root, root);
2995
- const content = buildContentSource(specs, scanned);
2996
- const g = new rtm_graph_1.RtmGraph();
2997
- try {
2998
- (0, rtm_builder_1.buildRtm)(specs, scanned, g);
2999
- return new bundler_1.ContextBundler(g, content).getContextBundle(a.seedId, a.budget);
3000
- }
3001
- finally {
3002
- g.close(); // release native SQLite handle even if bundling throws
3003
- }
3004
- },
3005
- async review_scope(a) {
3006
- assertSpecStoreReachable('review_scope', store, a.root); // @implements A-SPEC-419
3007
- // Guard+diff+scan+changedFiles/changedSymbols prologue: see
3008
- // deriveChangedContext (shared with review_prepare/rtm_reindex).
3009
- const { root, specs, scanned, changedFiles, changedSymbols, changeSource, scopeFallback, anchorImpactedSpecs, changedTestFiles, unresolvedFiles } = await deriveChangedContext(store, a.root, a, 'review_scope');
3010
- const g = new rtm_graph_1.RtmGraph();
3011
- try {
3012
- (0, rtm_builder_1.buildRtm)(specs, scanned, g);
3013
- const scope = (0, scope_1.computeReviewScope)(g, specs, changedSymbols);
3014
- // Intelligent regression test-scope (target-arch §5). Test files are scanned SEPARATELY for
3015
- // their @implements anchors (the CPG scan excludes tests), coverage gaps feed the safety
3016
- // fallback, and impactedSpecs are the graph's SPEC:-prefixed ids (stripped inside).
3017
- // @implements A-SPEC-130 — the same widened set as test_run, so review packaging and test
3018
- // scoping cannot disagree about what a change touches.
3019
- const widenedImpacted = [...new Set([...scope.impactedSpecs, ...anchorImpactedSpecs])];
3020
- const testScope = (0, test_scope_1.computeTestScope)(widenedImpacted, (0, test_scope_1.scanTestAnchors)(root), specs, scope.coverageGaps, undefined, { changedTestFiles, unresolvedFiles, architectureTouched: (0, test_scope_1.architectureGoverningFiles)(changedFiles), obligationGaps: scope.obligationGaps });
3021
- // Honest signal (REQ-124 gate 2a): which changed files the CpgScanner
3022
- // did NOT ingest (e.g. non-.ts files), computed from the same
3023
- // changedFiles/scanned already derived above — no new git/scan calls.
3024
- const { unscanned: unscannedChangedFiles } = (0, coverage_1.partitionChangedFiles)(changedFiles, scanned.map((f) => f.sourcePath));
3025
- return { ...scope, unscannedChangedFiles, testScope, changeSource, ...(scopeFallback ? { scopeFallback } : {}) };
3026
- }
3027
- finally {
3028
- g.close(); // release native SQLite handle even if scope computation throws
3029
- }
3030
- },
3031
- async review_prepare(a) {
3032
- assertSpecStoreReachable('review_prepare', store, a.root); // @implements A-SPEC-419
3033
- // Guard+diff+scan+changedFiles/changedSymbols prologue: see
3034
- // deriveChangedContext (shared with review_scope/rtm_reindex).
3035
- const { root, specs, scanned, changedFiles, changedSymbols, changeSource, scopeFallback } = await deriveChangedContext(store, a.root, a, 'review_prepare');
3036
- // ContentSource factory shared with context_bundle: see buildContentSource.
3037
- const content = buildContentSource(specs, scanned);
3038
- const g = new rtm_graph_1.RtmGraph();
3039
- try {
3040
- (0, rtm_builder_1.buildRtm)(specs, scanned, g);
3041
- const pkg = (0, package_1.assembleReviewPackage)(g, specs, changedSymbols, content, a.budget ?? 4000);
3042
- // Honest signal (REQ-124 gate 2a): which changed files the CpgScanner
3043
- // did NOT ingest, computed from the same changedFiles/scanned already
3044
- // derived above — no new git/scan calls.
3045
- const { unscanned: unscannedChangedFiles } = (0, coverage_1.partitionChangedFiles)(changedFiles, scanned.map((f) => f.sourcePath));
3046
- return { package: pkg, unscannedChangedFiles, changeSource, ...(scopeFallback ? { scopeFallback } : {}) };
3047
- }
3048
- finally {
3049
- g.close(); // release native SQLite handle even if assembly throws
3050
- }
3051
- },
3052
- async review_record(a) {
3053
- // @implements A-SPEC-189 §8 (round 11) — §15 shut the door the merge gate READS through and
3054
- // left the one it is WRITTEN through wide open. Measured: `review_record` joined the caller's
3055
- // `root` straight into a path and created `.ax/ledger/findings.jsonl` in any directory on the
3056
- // machine, answering `{recorded: N}` — while `review_status`, bound to this server's project,
3057
- // could never see those findings. An open critical filed at the wrong root is worse than none:
3058
- // it reads as recorded and gates nothing. The two doors now ask the same question.
3059
- const foreignWrite = foreignRootReason(store, a.root);
3060
- if (foreignWrite !== null)
3061
- throw new HandlerRefusal(`review_record: ${foreignWrite}`);
3062
- // MCP input is unvalidated JSON: an out-of-enum severity or status
3063
- // (e.g. a typo like "blocker") would land in the ledger as a stray
3064
- // value that review_status's counts never match, so it counts toward
3065
- // neither its own bucket nor `blocked` — a mislabeled critical could
3066
- // silently pass the gate. Validate up front so the gate's counts stay
3067
- // trustworthy.
3068
- const validSeverities = new Set(['critical', 'important', 'minor']);
3069
- const validStatuses = new Set(['open', 'resolved']);
3070
- for (const f of a.findings) {
3071
- if (!validSeverities.has(f.severity)) {
3072
- throw new HandlerRefusal(`review_record: finding ${f.id} has invalid severity ${JSON.stringify(f.severity)} (must be one of critical|important|minor)`);
3073
- }
3074
- if (!validStatuses.has(f.status)) {
3075
- throw new HandlerRefusal(`review_record: finding ${f.id} has invalid status ${JSON.stringify(f.status)} (must be one of open|resolved)`);
3076
- }
3077
- }
3078
- // @implements A-SPEC-191 (§4e) — the ledger's meaning is guarded at RECORD time, where the
3079
- // collision actually happens. Measured: conventional ids (C1…) recur across review rounds, so
3080
- // one round's resolve of "C1" silently lifted the gate over ANOTHER round's unfixed critical;
3081
- // and a severity-downgrade re-open cleared ART-7 with no resolution at all. Both die at one
3082
- // door: an id whose latest state is open cannot be opened again — resolve first, then
3083
- // re-record. Lifting an open CRITICAL requires the out-of-band approval channel (ART-5's
3084
- // principle): the blocked party must not be able to pull its own teeth in-band. A resolved
3085
- // record for an id never opened lifts nothing and stays approval-free (found-and-fixed audit
3086
- // records keep working).
3087
- // The guard and the append hold ONE lock (round-6: the read→judge→append section was a
3088
- // cross-process TOCTOU — two sessions both recorded open under the same id, silencing an
3089
- // open critical at both readers; consumeNonceExclusively already earned this discipline).
3090
- const findingsFile = boundFindingsLedger(store, a.root);
3091
- // @implements A-SPEC-263.1 — elicitation happens BEFORE the lock: the human may take up to
3092
- // the wiring's timeout to answer, and holding the findings ledger lock for that long starves
3093
- // every other writer (the lock callback is synchronous by design). This is a best-effort
3094
- // PRE-SCAN over an unlocked snapshot: for each finding that LOOKS like an open-critical lift
3095
- // not covered by env/grant, ask now and carry the grant into the lock as data. TOCTOU folds
3096
- // fail-closed — state moved so the lift is no longer needed → the grant is simply unused;
3097
- // state moved so a lift IS needed that the snapshot missed → no grant → the standard refusal.
3098
- const elicitGrants = new Map();
3099
- // A DENY at pre-scan is carried as data too (round-1): throwing here judged a stale snapshot —
3100
- // a finding concurrently resolved by someone else no longer needs a lift inside the lock, and
3101
- // the human's "no" must not abort a batch that never needed the question. The deny bites only
3102
- // at the in-lock site, and only if the lift is ACTUALLY needed there.
3103
- const elicitDenials = new Map();
3104
- // @implements A-SPEC-497.1 — an expiry is carried as data like a denial: it bites only at the
3105
- // in-lock refusal site, where it prefixes the standard message so the agent can tell the user
3106
- // a session dialog died (instead of the anonymous "no approval" the old null-fold produced).
3107
- const elicitExpiries = new Map();
3108
- if (opts?.elicit) {
3109
- try {
3110
- const snapshot = new Map();
3111
- for (const f of new findings_1.FindingsLedger(findingsFile).list())
3112
- snapshot.set(f.id, f);
3113
- for (const f of a.findings) {
3114
- const last = snapshot.get(f.id);
3115
- if (!(f.status === 'resolved' && last?.status === 'open' && last.severity === 'critical'))
3116
- continue;
3117
- const raw0 = process.env.HOLMES_APPROVAL;
3118
- let env0;
3119
- try {
3120
- env0 = raw0 ? JSON.parse(raw0) : undefined;
3121
- }
3122
- catch {
3123
- env0 = undefined;
3124
- }
3125
- if (resolveHandlerApproval(a.root, store, env0, { kind: 'review-resolve', target: f.id }, new Date().toISOString()) !== undefined)
3126
- continue; // an open door never summons a human
3127
- const out = await tryElicit('review-resolve', f.id, `열린 치명 발견 ${f.id} 의 해소 기록`);
3128
- if (out.kind === 'answered' && out.decision.granted)
3129
- elicitGrants.set(f.id, elicitApproval(out.decision.reason));
3130
- else if (out.kind === 'answered')
3131
- elicitDenials.set(f.id, out.decision.reason ?? '(사유 없음)');
3132
- else if (out.kind === 'expired')
3133
- elicitExpiries.set(f.id, out.waitedMs);
3134
- }
3135
- }
3136
- catch {
3137
- // A pre-scan failure is never worse than no channel: fall through with no grants.
3138
- }
3139
- }
3140
- (0, ledger_lock_1.withLedgerLock)(findingsFile, () => {
3141
- const ledger = new findings_1.FindingsLedger(findingsFile);
3142
- const latest = new Map();
3143
- const liftedCriticals = [];
3144
- const envLiftedCriticals = []; // lifts the ENV approval authorized (round-1: nonce charges only these)
3145
- const grantConsumptions = []; // spent AFTER the batch validates (round-2)
3146
- const elicitationLifts = []; // ledgered per lift (round-5: the promised audit trace)
3147
- for (const f of ledger.list())
3148
- latest.set(f.id, f);
3149
- for (const f of a.findings) {
3150
- if (!f.id || !f.id.trim()) {
3151
- throw new HandlerRefusal('review_record: finding id 가 비어 있습니다 — 모든 발견은 원장에서 유일하게 식별될 id 가 필요합니다');
3152
- }
3153
- // Round-5: pre-round-4 ads declared `message`, but the ledger whitelist persists only
3154
- // `summary` — clients following the OLD ad lost their text with zero signal. A pointed
3155
- // refusal turns that silent loss into the one-line fix it needs.
3156
- // Round-9: `=== undefined` let `summary: null` through, so the ledger dropped the text
3157
- // and the caller saw success — the very silence this guard exists to end. Absence is
3158
- // null OR undefined here, as everywhere else in this validator's vocabulary.
3159
- if ('message' in f && (f.summary === undefined || f.summary === null)) {
3160
- throw new HandlerRefusal(`review_record: finding ${f.id} 의 message 는 폐지된 광고 키입니다 — 원장은 summary 만 보존합니다. 같은 내용을 summary 로 보내십시오`);
3161
- }
3162
- const last = latest.get(f.id);
3163
- if (f.status === 'open' && last?.status === 'open') {
3164
- throw new HandlerRefusal(`review_record: id ${f.id} 는 이미 미해소(open) 상태입니다 — 다른 발견이면 새 id 를 쓰고, 같은 발견의 갱신이면 먼저 resolved 를 기록한 뒤 재기록하십시오 (id 충돌이 남의 critical 을 침묵시키는 것을 막는 문입니다)`);
3165
- }
3166
- if (f.status === 'resolved' && last?.status === 'open' && last.severity === 'critical') {
3167
- // @implements A-SPEC-191 §11 — checked BEFORE any approval is resolved or consumed
3168
- // (round-2): with the guard after resolution, a single-use elicitation grant (or grant
3169
- // file) was spent on the FIRST lift, so the second occurrence saw "no approval" and
3170
- // surfaced a bogus set-HOLMES_APPROVAL message plus a queue entry for an act a human
3171
- // already decided. The double-lift is named honestly on EVERY channel, and nothing
3172
- // single-use is touched by a batch that dies here.
3173
- if (liftedCriticals.includes(f.id)) {
3174
- throw new HandlerRefusal(`review_record: 한 배치에서 같은 발견(${f.id})의 치명 해소를 두 번 들 수 없습니다 — 승인은 행위마다 필요합니다`);
3175
- }
3176
- const raw = process.env.HOLMES_APPROVAL;
3177
- let approval;
3178
- try {
3179
- approval = raw ? JSON.parse(raw) : undefined;
3180
- }
3181
- catch {
3182
- approval = undefined;
3183
- }
3184
- // covers, not merely well-formed (round-2): A-SPEC-133 built the seam so one token is
3185
- // not a master key — an EXPIRED or elsewhere-scoped approval must not lift a critical.
3186
- // An unscoped {actor,token,rationale} stays the operator's session key (unchanged).
3187
- const nowTs = new Date().toISOString();
3188
- let rrResolved = resolveHandlerApproval(a.root, store, approval, { kind: 'review-resolve', target: f.id }, nowTs);
3189
- // @implements A-SPEC-263.1 — the second (and last) elicitable kind. The HUMAN was asked
3190
- // BEFORE the lock (pre-scan above — a lock must not wait on a person); in here the grant
3191
- // is plain data, consumed synchronously and at most once per id.
3192
- if (rrResolved === undefined) {
3193
- const g = elicitGrants.get(f.id);
3194
- if (g) {
3195
- elicitGrants.delete(f.id);
3196
- rrResolved = { approval: g, source: 'elicitation' };
3197
- elicitationLifts.push({ id: f.id, approval: g });
3198
- }
3199
- }
3200
- if (rrResolved === undefined) {
3201
- // A pre-scan DENY surfaces HERE — only when the lift is genuinely needed at lock time
3202
- // (round-1). The human decided, so the answer is the message and nothing is queued.
3203
- const denied = elicitDenials.get(f.id);
3204
- if (denied !== undefined) {
3205
- throw new HandlerRefusal(`review_record: 세션에서 거부됨 — ${denied}. 사유를 해소한 뒤 다시 기록하십시오.`);
3206
- }
3207
- // @implements A-SPEC-497.1 — an expired dialog leads the refusal by name; silence
3208
- // keeps the pre-elicitation face.
3209
- const expiredMs = elicitExpiries.get(f.id);
3210
- throw new HandlerRefusal((expiredMs !== undefined ? (0, elicit_approval_1.expiredNotice)(expiredMs) : '')
3211
- + `review_record: id ${f.id} 의 열린 치명 발견을 해소하는 기록은 이 행위를 덮는 유효한 대역외 승인이 필요합니다 — 차단당한 쪽이 스스로 이빨을 뽑을 수 없어야 하고, 만료·다른 범위의 승인은 덮지 않습니다. HOLMES_APPROVAL='{"actor":"<you>","token":"<any>","rationale":"<why fixed>"}' (범위를 쓰면 kind "review-resolve") 를 서버 환경에 설정하고 다시 기록하십시오`
3212
- + refusalQueueHint(a.root, store, { kind: 'review-resolve', target: f.id, why: '열린 치명 발견의 해소 기록' }));
3213
- }
3214
- // Grant-file consumption is DEFERRED past the loop (round-2): consuming here burned the
3215
- // single-use file when a LATER finding in the batch failed validation — nothing was
3216
- // recorded, yet the operator's legitimate grant was gone and the retry refused. Same
3217
- // harm class the env nonce fixed in its round-4; collected now, spent only once the
3218
- // whole batch has validated (directly before the append, like the nonce block).
3219
- if (rrResolved.source === 'grant' && rrResolved.root && rrResolved.approval.nonce) {
3220
- grantConsumptions.push({ root: rrResolved.root, nonce: rrResolved.approval.nonce });
3221
- }
3222
- liftedCriticals.push(f.id);
3223
- // The nonce block below enforces single-use on the ENV approval — so it must key on the
3224
- // lifts the ENV approval actually authorized (round-1): before elicitation existed,
3225
- // reaching it implied env/grant coverage, but an elicitation-authorized lift flows past
3226
- // it with a possibly unrelated env token in the environment. Charging THAT token burned
3227
- // an innocent nonce, misattributed the ledger line, and a stale env nonce made a
3228
- // human-approved lift permanently refusable.
3229
- if (rrResolved.source === 'env')
3230
- envLiftedCriticals.push(f.id);
3231
- }
3232
- latest.set(f.id, f); // 한 배치 안의 순서도 기록 순서다
3233
- }
3234
- // A-SPEC-133 promises nonce = single-use "consumed on the ledger, a replay is denied".
3235
- // Consumed AFTER the whole batch validates and ONCE per call (round-4: consuming inside
3236
- // the loop burned the grant when a LATER row failed validation — the audit trail asserted
3237
- // an act that never happened, the retry was refused, and two resolves in one batch would
3238
- // have double-spent). Consumption directly precedes the append; the only failure between
3239
- // them is the append itself, which throws loudly.
3240
- // ONE NONCE, ONE ACT — on the grant channel too (round-4): the round-2 deferral removed the
3241
- // arity the in-loop spend had accidentally enforced, so a wide-scoped single-use grant
3242
- // backed N lifts in one batch while the same lifts split across calls refused after the
3243
- // first (the §13 batch-shape dependence). Checked BEFORE anything is spent (round-3 order
3244
- // doctrine: every throwable validation precedes every single-use spend).
3245
- {
3246
- const liftsPerNonce = new Map();
3247
- for (const g of grantConsumptions)
3248
- liftsPerNonce.set(g.nonce, (liftsPerNonce.get(g.nonce) ?? 0) + 1);
3249
- for (const [, n] of liftsPerNonce) {
3250
- if (n > 1) {
3251
- throw new HandlerRefusal(`review_record: 단일 사용 승인(grant)은 한 건의 치명 해소만 authorize 합니다 — 이 배치는 한 grant 로 ${n}건을 듭니다. 한 건씩 보내십시오`);
3252
- }
3253
- }
3254
- }
3255
- // The elicitation channel's AUDIT TRACE (round-5, repositioned round-6): the trust model
3256
- // admits review-resolve at a boundary closer than env/grant BECAUSE every grant is ledgered
3257
- // under an `elicitation:<client>` actor. This append is throwable I/O (chain lock
3258
- // contention, disk faults), so it stands BEFORE every single-use spend — round-5 placed it
3259
- // between the env-nonce spend and the findings append, and a lock-contended mixed batch
3260
- // burned the operator's env nonce with nothing recorded (the §7 doctrine regression). A
3261
- // failure HERE burns nothing; an orphaned event left when a LATER step throws stays honest,
3262
- // because it records the AUTHORIZATION, which already happened at pre-scan.
3263
- if (elicitationLifts.length > 0) {
3264
- const chain = new ledger_store_1.FileLedgerStore(path.join(projectRootOf(a.root), '.ax', 'ledger'));
3265
- for (const lift of elicitationLifts) {
3266
- chain.append({
3267
- ts: new Date().toISOString(), actor: lift.approval.actor, kind: 'review-resolve-authorized',
3268
- summary: `elicitation grant authorized lifting open critical ${lift.id}`.slice(0, 200),
3269
- inputs: [lift.id], rationale: lift.approval.rationale,
3270
- authorization: (0, provenance_chain_1.authorizationRef)(lift.approval.actor, lift.approval.token),
3271
- });
3272
- }
3273
- }
3274
- // Keyed on the lifts the ENV approval AUTHORIZED, not on the ambient env var (round-1):
3275
- // an elicitation- or grant-authorized lift must not charge — or be blocked by — an env
3276
- // token that never covered the act. When env authorized nothing, its nonce is untouched.
3277
- if (envLiftedCriticals.length > 0) {
3278
- const raw = process.env.HOLMES_APPROVAL;
3279
- let approval;
3280
- try {
3281
- approval = raw ? JSON.parse(raw) : undefined;
3282
- }
3283
- catch {
3284
- approval = undefined;
3285
- }
3286
- if ((0, provenance_chain_1.blankNonce)(approval?.nonce)) {
3287
- throw new HandlerRefusal('review_record: 승인이 단일 사용(nonce)을 선언했으나 값이 비어 있습니다 — 1회성을 집행할 수 없어 거부합니다');
3288
- }
3289
- // @implements A-SPEC-191 §13 — 1회용 승인은 한 번의 행위를 authorize 한다. 9라운드
3290
- // 실측: 서로 다른 id 의 치명 해소 N 건이 한 배치에서 nonce 한 장으로 통과했고,
3291
- // 같은 세 건을 세 호출로 나누면 두 번째부터 거부됐다 — 판정이 묶음 방식에 의존했다.
3292
- if ((0, provenance_chain_1.nonceDeclared)(approval?.nonce) && envLiftedCriticals.length > 1) {
3293
- throw new HandlerRefusal(`review_record: 단일 사용 승인(nonce)은 한 건의 치명 해소만 authorize 합니다 — 이 배치는 ${envLiftedCriticals.length}건(${envLiftedCriticals.join(', ')})을 듭니다. 한 건씩 보내십시오`);
3294
- }
3295
- if ((0, provenance_chain_1.nonceDeclared)(approval?.nonce)) {
3296
- // @implements A-SPEC-191 §10 — the same bound anchor as risk_check: two consumers must
3297
- // spend into ONE ledger, or a nonce spent here reopens the gate there (and back).
3298
- const ledgerFile = boundNonceLedger(store);
3299
- if (ledgerFile === null) {
3300
- throw new HandlerRefusal('review_record: 단일 사용 승인(nonce)을 기록할 프로젝트 원장을 확정할 수 없습니다 — 1회성을 보장할 수 없어 거부합니다');
3301
- }
3302
- const won = (0, provenance_chain_1.consumeNonceExclusively)(String(approval.nonce), ledgerFile, {
3303
- ts: new Date().toISOString(), actor: approval.actor, kind: 'nonce-consumed',
3304
- summary: `consumed single-use approval for: review-resolve ${envLiftedCriticals.join(', ')}`.slice(0, 200),
3305
- inputs: [(0, provenance_chain_1.nonceFingerprint)(String(approval.nonce))], rationale: approval.rationale,
3306
- authorization: (0, provenance_chain_1.authorizationRef)(approval.actor, approval.token),
3307
- }, { isNonceConsumed: (0, ledger_store_2.nonceConsumedIn)(ledgerFile) });
3308
- if (!won) {
3309
- throw new HandlerRefusal(`review_record: 단일 사용 승인(nonce)이 이미 소비되었습니다 — 재사용은 거부됩니다. 새 승인을 발급받으십시오`);
3310
- }
3311
- }
3312
- }
3313
- // Deferred single-use spends — LAST, after every throwable validation including the
3314
- // env-nonce block above (round-3: placed before it, a mixed batch's env-nonce refusal
3315
- // burned the grant with nothing recorded — the exact harm the round-2 deferral was written
3316
- // to fix, reintroduced through the env throw path). Only the append itself follows, and it
3317
- // fails loudly.
3318
- for (const g of grantConsumptions)
3319
- (0, approval_grants_1.consumeGrantFile)(g.root, g.nonce);
3320
- // @implements A-SPEC-157 — the server's OWN observation, not anything the caller sent.
3321
- // @implements A-SPEC-160 — the ONLY caller that reads the on-disk build, so the divergence
3322
- // marker can be sealed. Append INSIDE the same lock as the guard (round-6 TOCTOU).
3323
- ledger.record(a.findings, (0, basis_1.basisDigest)(basisFor(a.root, true)));
3324
- });
3325
- return { recorded: a.findings.length };
3326
- },
3327
- async review_status(a) {
3328
- // @implements A-SPEC-191 §15 — the merge gate must not answer about a project it did not read.
3329
- const foreign = foreignRootReason(store, a.root);
3330
- if (foreign !== null)
3331
- throw new HandlerRefusal(`review_status: ${foreign}`);
3332
- // @implements A-SPEC-191 (§4f) — counted on the LATEST record per id, the same folding the
3333
- // constitution's ART-7 uses. Line-based counting could never decrease on an append-only
3334
- // ledger, so the canonical exit (a resolved line) left `blocked: true` forever while the Stop
3335
- // gate passed — two official verdicts about one ledger, disagreeing permanently (measured).
3336
- const all = new findings_1.FindingsLedger(boundFindingsLedger(store, a.root)).list();
3337
- const latest = new Map();
3338
- // Severity of the LATEST open row per id — a forever-sticky "ever critical" set resurrected
3339
- // a properly-closed critical when a later minor re-finding got a diverged resolve (r6).
3340
- const lastOpenCritical = new Map();
3341
- for (const f of all) {
3342
- latest.set(f.id, f);
3343
- if (f.status === 'open')
3344
- lastOpenCritical.set(f.id, f.severity === 'critical');
3345
- }
3346
- // SAME ledger meaning as ART-7 (round-3: the two official readers disagreed): a resolution
3347
- // sealed on a diverged build does not close a critical here either. What it keeps open is
3348
- // the CRITICAL it failed to close — bucketing by the resolved line's own severity let a
3349
- // severity-downgraded diverged resolve report blocked:false while the Stop gate blocked
3350
- // (round-4 HIGH), and a severity-less row minted an "undefined" bucket.
3351
- const counts = { critical: 0, important: 0, minor: 0 };
3352
- for (const f of latest.values()) {
3353
- if (f.status === 'open') {
3354
- if (Object.prototype.hasOwnProperty.call(counts, f.severity))
3355
- counts[f.severity]++; // in-체크는 프로토타입 키(toString)로 새었다(r6)
3356
- continue;
3357
- }
3358
- if (f.status === 'resolved' && lastOpenCritical.get(f.id) === true && (0, basis_1.basisDiverged)(f.basis))
3359
- counts.critical++;
3360
- }
3361
- // @implements A-SPEC-135
3362
- // Surface outstanding P4 routing signals: specs whose approved content moved, with the anchored
3363
- // files awaiting targeted review. This is a backlog to route human attention, NOT a hard block —
3364
- // the review layer decides severity — so it does not touch `blocked`.
3365
- // @implements A-SPEC-189 §12 (round 12) — the findings moved onto the bound project in §8 and
3366
- // this line stayed on the caller's raw root, so the SAME project answered with or without a
3367
- // P4 routing backlog depending on how its root was spelled (a subdirectory passes the
3368
- // project-level foreign check). One question, one tree.
3369
- const reviewNeeded = (0, review_targets_1.readReviewNeeded)(path.join(path.dirname(boundFindingsLedger(store, a.root)), 'provenance.jsonl'));
3370
- return { blocked: counts.critical > 0 || counts.important > 0, open: counts, reviewNeeded };
3371
- },
3372
- // @implements A-SPEC-125.4
3373
- async risk_check(a) {
3374
- const action = { ...a.action };
3375
- if (a.root && a.changedSymbols && a.changedSymbols.length) {
3376
- // @implements A-SPEC-189 §14 (round 13) — the derivation was computed and discarded, so the
3377
- // advertised "a subdirectory resolves up to it" held for `taint_scan` and no one else.
3378
- const root = projectRootOf(a.root);
3379
- const specs = await store.list();
3380
- const scanned = cachedScan(root, root);
3381
- const g = new rtm_graph_1.RtmGraph();
3382
- try {
3383
- (0, rtm_builder_1.buildRtm)(specs, scanned, g);
3384
- const impacted = (0, rtm_builder_1.impactedBy)(g, a.changedSymbols);
3385
- action.blastRadius = { impactedSpecCount: impacted.length, hitsFoundational: impacted.some((id) => id.startsWith('SPEC:REQ-')) };
3386
- }
3387
- finally {
3388
- g.close(); // release native SQLite handle even if build/query throws
3389
- }
3390
- }
3391
- // @implements A-SPEC-225 — the flag that raises this action's risk is COMPUTED here, not
3392
- // taken from the party being judged. Measured 2026-08-22: nothing in the repository ever set
3393
- // `cspecConstraintChange`, so the hard-hitl branch guarding C-SPEC constraints could only fire
3394
- // if the actor changing them volunteered that they had. The precedent is one field away —
3395
- // `blastRadius` above is derived from the store rather than accepted from the caller — and the
3396
- // principle is the one `findings.ts` states for `basis`: a judge that stamps its own evidence
3397
- // turns the field into self-report.
3398
- //
3399
- // OR, never assignment: a caller may raise its own risk (that is their business) but may not
3400
- // lower the server's verdict.
3401
- const cspecId = (0, cspec_change_1.cspecTargetId)(typeof action.target === 'string' ? action.target : '');
3402
- if (cspecId !== null) {
3403
- const stored = (await store.list()).find((sp) => sp.id === cspecId);
3404
- const current = stored ? (0, spec_parser_1.serializeSpec)(stored) : null;
3405
- const proposed = typeof action.stagedContent === 'string' ? action.stagedContent : null;
3406
- action.cspecConstraintChange = (0, cspec_change_1.cspecConstraintChanged)(current, proposed) || action.cspecConstraintChange === true;
3407
- }
3408
- const assessment = (0, risk_classifier_1.assessRisk)(action);
3409
- // @implements A-SPEC-133 — coverage (scope + expiry), not mere well-formedness, decides whether
3410
- // this approval authorizes THIS action. An out-of-scope or expired token no longer unblocks.
3411
- const coverTarget = { kind: action.kind, target: typeof action.target === 'string' ? action.target : String(action.target ?? '') };
3412
- let gate = (0, risk_gate_1.riskGate)(assessment, a.approval, a.enforcement, { action: coverTarget, now: a.ts });
3413
- const covers = (0, risk_gate_1.approvalCovers)(a.approval, coverTarget, a.ts);
3414
- // @implements A-SPEC-133 — the hard-hitl branch HONORS a nonce by SPENDING it (r7-191 HIGH:
3415
- // this consumer accepted single-use approvals and spent nothing — 'N-RISK' unblocked three
3416
- // times; a replayed approval must deny exactly as an absent one would).
3417
- // @implements A-SPEC-191 §13 — 승인이 실제로 문을 여는 경우에만 승인의 하자를 따진다.
3418
- // 9라운드 실측: 승인이 아예 필요 없던 낮은 판정까지 환경에 놓인 토큰의 모양 때문에
3419
- // 차단됐다 — 승인을 쥔 쪽이 안 쥔 쪽보다 나빠지는 역전이다.
3420
- const wouldBlockWithout = (0, risk_gate_1.riskGate)(assessment, undefined, a.enforcement, { action: coverTarget, now: a.ts }).blocked;
3421
- if (!gate.blocked && covers && wouldBlockWithout && (0, provenance_chain_1.blankNonce)(a.approval?.nonce)) {
3422
- gate = { ...gate, blocked: true, reasons: [...gate.reasons, '승인이 단일 사용(nonce)을 선언했으나 값이 비어 있거나 문자열이 아닙니다 — 1회성을 집행할 수 없어 거부합니다'] };
3423
- }
3424
- // 묶인 프로젝트가 없으면 개방을 기록할 수 없다 — 기록되지 않는 개방은 개방하지 않는다.
3425
- if (!gate.blocked && covers && wouldBlockWithout && boundNonceLedger(store) === null) {
3426
- gate = { ...gate, blocked: true, reasons: [...gate.reasons, '개방을 기록할 프로젝트 원장을 확정할 수 없습니다 — 기록 없는 개방은 하지 않습니다(.ax 를 가진 프로젝트에 바인딩된 서버에서 호출하십시오)'] };
3427
- }
3428
- if (assessment.level === 'hard-hitl' && !gate.blocked && covers && (0, provenance_chain_1.nonceDeclared)(a.approval?.nonce)) {
3429
- const ledgerFile = boundNonceLedger(store);
3430
- let won = false;
3431
- try {
3432
- won = ledgerFile !== null && (0, provenance_chain_1.consumeNonceExclusively)(String(a.approval.nonce), ledgerFile, {
3433
- ts: a.ts, actor: a.approval.actor, kind: 'nonce-consumed',
3434
- summary: `consumed single-use approval for: ${coverTarget.kind} ${(0, provenance_chain_1.redactTarget)('command', coverTarget.target)}`.slice(0, 200),
3435
- inputs: [(0, provenance_chain_1.nonceFingerprint)(String(a.approval.nonce))], rationale: a.approval.rationale,
3436
- authorization: (0, provenance_chain_1.authorizationRef)(a.approval.actor, a.approval.token),
3437
- }, { isNonceConsumed: (0, ledger_store_2.nonceConsumedIn)(ledgerFile) });
3438
- }
3439
- catch {
3440
- won = false;
3441
- } // 배타 확보 실패 = 허용 불가(fail-closed)
3442
- if (!won) {
3443
- gate = { ...gate, blocked: true, reasons: [...gate.reasons, ledgerFile === null
3444
- ? '단일 사용 승인(nonce)을 기록할 프로젝트 원장을 확정할 수 없습니다 — 1회성을 보장할 수 없어 거부합니다(.ax 를 가진 프로젝트에 바인딩된 서버에서 호출하십시오)'
3445
- : '단일 사용 승인(nonce)이 이미 소비되었습니다 — 재사용은 부재와 같이 거부됩니다'] };
3446
- }
3447
- }
3448
- // Honest provenance: a confirm-level action that was NOT blocked but received no COVERING
3449
- // approval must NOT be recorded as 'approved' (nobody authorized it for this action) — it
3450
- // 'proceeded unconfirmed'. Only a genuine covering out-of-band approval yields 'approved'.
3451
- const decision = gate.blocked
3452
- ? 'blocked'
3453
- : covers
3454
- ? 'approved'
3455
- : gate.requiresApproval
3456
- ? 'proceeded-unconfirmed'
3457
- : 'auto';
3458
- 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 };
3459
- // @implements A-SPEC-191 §10 — a bound server files its decisions in the project it is bound
3460
- // to. r8: the audit line for a nonce-replay opening landed in the caller's scratch directory,
3461
- // so the project whose gate was opened held no trace of it.
3462
- const bound = boundNonceLedger(store);
3463
- const decisionsFile = bound !== null
3464
- ? path.join(path.dirname(bound), 'decisions.jsonl')
3465
- : path.join(a.root ?? '.', '.ax', 'ledger', 'decisions.jsonl');
3466
- // @implements A-SPEC-191 §11 — an audit write must not swallow a verdict the caller already
3467
- // earned (the hook has said this since N3). The nonce is spent for an opening that DOES reach
3468
- // the caller; a disk failure here loses the record, not the decision.
3469
- try {
3470
- new decision_ledger_1.DecisionLedger(decisionsFile).record([evt]);
3471
- }
3472
- catch { /* 기록 실패가 이미 계산된 판정을 뒤집지 않는다 */ }
3473
- // @implements A-SPEC-133 — 'Every gate opening records a provenance approved-action entry.'
3474
- // r8: the promise was kept in the hook and broken here — a covering approval that unblocked a
3475
- // hard-hitl risk_check left only `nonce-consumed` (and nothing at all when it carried no
3476
- // nonce), so the master key's use was invisible in the chain the audit reads.
3477
- const wouldBlock = wouldBlockWithout;
3478
- const bnl = boundNonceLedger(store);
3479
- if (!gate.blocked && covers && wouldBlock && bnl !== null) {
3480
- try {
3481
- new provenance_chain_1.ProvenanceChain(bnl).append({
3482
- ts: a.ts, actor: a.approval?.actor ?? 'unknown', kind: 'approved-action',
3483
- summary: `approved action: risk_check ${coverTarget.kind} ${(0, provenance_chain_1.redactTarget)('command', coverTarget.target)}`.slice(0, 300),
3484
- inputs: ['risk_check', (0, provenance_chain_1.redactTarget)('command', coverTarget.target), (0, provenance_chain_1.approvalMarkers)(a.approval)],
3485
- rationale: a.approval?.rationale ?? '',
3486
- authorization: a.approval ? (0, provenance_chain_1.authorizationRef)(a.approval.actor, a.approval.token) : undefined,
3487
- });
3488
- }
3489
- catch { /* 감사 기록 실패가 판정을 바꾸지 않는다 */ }
3490
- }
3491
- return { assessment, gate };
3492
- },
3493
- // @implements A-SPEC-126
3494
- // Brownfield reverse engineering. Three explicitly-invoked tools, nothing running as a side
3495
- // effect of an ordinary session, and every write path opt-in: scan writes nothing at all, draft
3496
- // and anchor are dry-run by DEFAULT.
3497
- /**
3498
- * Read-only inventory of a brownfield target: candidate clusters, coverage, and what the scan
3499
- * could not resolve. Deliberately does NOT call assertRepoTopLevel — a target that is not a git
3500
- * repository is a supported case here, reported as `isGit: false`.
3501
- */
3502
- async reverse_scan(a) {
3503
- // @implements A-SPEC-189 §10 (round 11) — `reverse_scan`/`reverse_draft` never call
3504
- // `projectRootOf`, so §7's refusal marker never reached them and their own POINTED sentence
3505
- // ("<root> is not a directory") arrived at the wire as a raw -32603 fault. A refusal about the
3506
- // caller's own argument is a refusal wherever it is authored.
3507
- assertReadableRoot('reverse_scan', a.root);
3508
- // `surfaceByCluster` is drafting evidence, not reading material. On the calibration target it
3509
- // is 11,460 characters across 15 clusters, and this response enters context on every scan —
3510
- // paying that to READ what only DRAFTING consumes. `reverse_draft` reads it in-process instead.
3511
- const { surfaceByCluster: _drafting, ...response } = (0, scan_1.reverseScan)(a.root, { maxFlagged: a.maxFlagged });
3512
- return response;
3513
- },
3514
- /**
3515
- * Draft H-SPEC/A-SPEC/T-SPEC documents for the recovered clusters under an EXISTING parent REQ.
3516
- *
3517
- * The REQ is the human's to write and this tool refuses without one — a requirement states
3518
- * business intent, which is not in the code. Everything emitted is `status: draft`, and writing
3519
- * requires an explicit `dryRun: false`.
3520
- *
3521
- * WHERE THE DOCUMENTS LAND: in the SERVER's configured spec store (`HOLMES_SPECS`, default
3522
- * `.ax/specs` relative to the server's working directory) — not inside `root`. In the intended
3523
- * adoption the two are the same directory, because the server runs inside the target it governs;
3524
- * pointing `root` at a different repository drafts that repository's slices into THIS store,
3525
- * which is a governance decision the caller has to make deliberately.
3526
- */
3527
- async reverse_draft(a) {
3528
- assertReadableRoot('reverse_draft', a.root);
3529
- if (typeof a.parentReqId !== 'string' || a.parentReqId.trim() === '') {
3530
- return {
3531
- ok: false,
3532
- reason: 'parentReqId is required. A REQ states business intent, which does not exist in the code ' +
3533
- 'and cannot be recovered from it — write the REQ first, then re-run with its id.',
3534
- };
3535
- }
3536
- const parentReqId = a.parentReqId.trim();
3537
- const specs = await store.list();
3538
- const req = specs.find((s) => s.id === parentReqId);
3539
- // An unresolvable parent would emit an orphan H-SPEC: a chain that only looks complete.
3540
- if (!req)
3541
- return { ok: false, reason: `parent REQ ${parentReqId} was not found in the spec store — create it first` };
3542
- // @implements A-SPEC-184
3543
- // A document with no `type:` is not a wrong-kind parent — it is an older-format one, and saying
3544
- // "is a undefined" left the adopter holding a visible document with nowhere to go. When the type
3545
- // IS declared, `legacyMessage` returns null and the original wording stands: that path was
3546
- // already accurate and REQ-184 does not touch it.
3547
- const formatWhy = (0, legacy_format_1.legacyMessage)(req);
3548
- if (formatWhy)
3549
- return { ok: false, reason: formatWhy };
3550
- if (req.type !== 'REQ')
3551
- return { ok: false, reason: `${parentReqId} is a ${req.type}; drafts must hang off a REQ` };
3552
- const report = (0, scan_1.reverseScan)(a.root);
3553
- // A cluster already drafted is recognised by the `reverse_cluster` key its documents carry, so
3554
- // re-running over the same tree never duplicates a slice.
3555
- const alreadyDrafted = new Set(specs.map((s) => s.frontmatter.reverse_cluster).filter((k) => typeof k === 'string'));
3556
- const selected = a.cluster ? report.clusters.filter((c) => (0, draft_1.clusterKeyOf)(c) === a.cluster) : report.clusters;
3557
- // A key that matches nothing must not read as success: "drafted 0" is indistinguishable from
3558
- // "everything was already drafted", so a mistyped key would silently skip the work.
3559
- if (a.cluster && selected.length === 0) {
3560
- const keys = report.clusters.map(draft_1.clusterKeyOf);
3561
- return {
3562
- ok: false,
3563
- reason: `no cluster matches key "${a.cluster}" — reverse_scan reports ${keys.length} cluster(s)`,
3564
- availableClusters: keys.slice(0, 50),
3565
- };
3566
- }
3567
- let base = (0, draft_1.nextIdBase)(specs.map((s) => s.id));
3568
- const drafted = [];
3569
- const skipped = [];
3570
- const refused = [];
3571
- for (const cluster of selected) {
3572
- const key = (0, draft_1.clusterKeyOf)(cluster);
3573
- if (alreadyDrafted.has(key)) {
3574
- skipped.push({ clusterKey: key, reason: 'already drafted' });
3575
- continue;
3576
- }
3577
- const d = (0, draft_1.draftSpecs)(cluster, req.id, String(base), {
3578
- surface: report.surfaceByCluster[key] ?? [],
3579
- testFiles: cluster.testFiles ?? [],
3580
- testsUnmatched: report.coverage.testsUnmatched,
3581
- });
3582
- if (!d.ok) {
3583
- refused.push({ clusterKey: key, reason: d.reason });
3584
- continue;
3585
- }
3586
- // Validate BEFORE writing: a draft that fails the project's own validator is not evidence of
3587
- // anything, and emitting one would put a broken document into the chain.
3588
- const known = [...specs, ...drafted.flatMap((x) => x.specs), ...d.specs];
3589
- const resolve = (id) => known.find((s) => s.id === id) ?? null;
3590
- const errors = d.specs.flatMap((s) => (0, validator_1.validateSpec)(s, resolve).findings.filter((f) => f.level === 'error').map((f) => ({ spec: s.id, ...f })));
3591
- if (errors.length) {
3592
- refused.push({ clusterKey: key, reason: 'drafted documents failed validation', findings: errors });
3593
- continue;
3594
- }
3595
- drafted.push(d);
3596
- base++;
3597
- }
3598
- const dryRun = a.dryRun !== false; // opt IN to writing
3599
- // @implements A-SPEC-188 — the minted id sits above every PARSEABLE id, but a file list()
3600
- // cannot read is invisible to nextIdBase, so its path can collide with a fresh draft. Same
3601
- // rule as spec_create: a path already occupied is a human's to look at, never overwritten.
3602
- // The whole cluster moves to `refused` — a partially-written chain only looks complete.
3603
- // Judged in the DRY RUN too (round-3): a preview that lists a cluster as drafted which the
3604
- // real run would refuse is a preview that lies.
3605
- const written = [];
3606
- for (const d of drafted) {
3607
- const clash = store instanceof spec_store_1.LocalMarkdownRepository
3608
- ? d.specs.map((s) => store.targetPathFor(s)).find((p) => fs.existsSync(p))
3609
- : undefined;
3610
- if (clash) {
3611
- refused.push({ clusterKey: d.clusterKey, reason: `초안 목적지(${clash})에 스토어가 읽지 못하는 파일이 이미 있습니다 — 덮어쓰지 않습니다. 사람이 확인해 옮기거나 고친 뒤 다시 실행하십시오.` });
3612
- continue;
3613
- }
3614
- if (!dryRun) {
3615
- try {
3616
- for (const s of d.specs)
3617
- await store.write(s);
3618
- }
3619
- catch (e) {
3620
- if (e instanceof spec_store_1.TargetPathOccupiedError) {
3621
- refused.push({ clusterKey: d.clusterKey, reason: `초안 목적지(${e.occupiedPath})에 스토어가 읽지 못하는 파일이 이미 있습니다 — 덮어쓰지 않습니다. 사람이 확인해 옮기거나 고친 뒤 다시 실행하십시오.` });
3622
- continue;
3623
- }
3624
- throw e;
3625
- }
3626
- }
3627
- written.push(d);
3628
- }
3629
- // @implements A-SPEC-299
3630
- // Deliberately NOT changed. This looked like the same false pass as spec_slice_approve, but an
3631
- // approved store-integrity test pins `ok: true` for a run that refused every cluster because a
3632
- // destination held an unreadable file: nothing was written, nothing was damaged, and `ok` here
3633
- // means the call completed without harm. That is a defensible contract, and the case against
3634
- // it was analogy rather than an observed failure. Evidence beats symmetry.
3635
- return { ok: true, dryRun, parentReqId: req.id, clusters: report.clusters.length, drafted: written, skipped, refused };
3636
- },
3637
- /**
3638
- * Insert `@implements` anchors into source files. Dry-run by DEFAULT, and anchoring to a
3639
- * non-approved A-SPEC is refused — a reverse-engineered draft describes code; description does
3640
- * not confer approval.
3641
- */
3642
- async reverse_anchor(a) {
3643
- const specs = await store.list();
3644
- const approved = specs.filter((s) => s.type === 'A-SPEC' && s.status === 'approved').map((s) => s.id);
3645
- const plan = (0, anchor_1.planAnchors)(a.root, a.mapping ?? [], approved);
3646
- // @implements A-SPEC-182
3647
- // The third place the harness refuses over an unapproved A-SPEC. Enriched HERE rather than
3648
- // inside planAnchors, which is a pure planner holding only the approved-id list — this is the
3649
- // boundary that has the spec objects, so the planner stays testable without them.
3650
- const byId = new Map(specs.map((s) => [s.id, s]));
3651
- // ONE payload per distinct A-SPEC, on a sibling field — not appended to every refused entry.
3652
- // Review measured the first attempt, which memoized only the COMPUTATION: 149 files mapped to
3653
- // one draft A-SPEC still concatenated the same 361-character sentence 149 times, 48 KB of pure
3654
- // duplication in a single tool result. The comment claimed a property the code did not have,
3655
- // which is how it survived a round of review — hence the size assertion in the test.
3656
- const blockers = {};
3657
- for (const r of plan.refused) {
3658
- if (!/is not approved$/.test(r.reason) || blockers[r.aspec] !== undefined)
3659
- continue;
3660
- const why = (0, approval_blockers_1.blockerSummary)(byId.get(r.aspec), (id) => byId.get(id) ?? null, r.aspec);
3661
- if (why)
3662
- blockers[r.aspec] = why.trim();
3663
- }
3664
- const applied = (0, anchor_1.applyAnchors)(a.root, plan.edits, { dryRun: a.dryRun !== false });
3665
- return { ...plan, ...applied, blockers };
3666
- },
3667
- async spec_slice_init(a) {
3668
- const root = a.root ? (0, root_2.resolveProjectRoot)(a.root).root : process.cwd();
3669
- const specsDir = path.join(root, '.ax', 'specs');
3670
- const existing = await store.list();
3671
- // @implements A-SPEC-546.1 — align with the sibling reverse-draft path (nextIdBase, floor 100)
3672
- // instead of a hard-coded 200 floor, so a fresh project starts at REQ-100, not REQ-201. Existing
3673
- // projects are unchanged: nextIdBase returns max(existing)+1, which dominates the floor.
3674
- const nextId = (0, draft_1.nextIdBase)(existing.map((s) => s.id));
3675
- const reqId = `REQ-${nextId}`;
3676
- const hspecId = `H-SPEC-${nextId}`;
3677
- const aspecId = `A-SPEC-${nextId}`;
3678
- const tspecId = `T-SPEC-${nextId}`;
3679
- const reqContent = `---
3680
- source:
3681
- - kind: user-request
3682
- ref: ${(0, yaml_scalar_1.yamlScalar)(a.title, 4)}
3683
- retrieved: ${new Date().toISOString().split('T')[0]}
3684
- note: ${(0, yaml_scalar_1.yamlScalar)(a.objective, 4)}
3685
- created: ${new Date().toISOString()}
3686
- id: ${reqId}
3687
- type: REQ
3688
- title: ${(0, yaml_scalar_1.yamlScalar)(a.title)}
3689
- status: draft
3690
- depends_on: []
3691
- ---
3692
-
3693
- ## Problem / Need
3694
- ${a.objective}
3695
-
3696
- ## Desired Outcome
3697
- ${a.objective}
3698
-
3699
- ## Constraints
3700
- - Standard project governance rules.
3701
-
3702
- ## Success Criteria
3703
-
3704
- ## Out of Scope
3705
- - Unrelated feature changes.
3706
- `;
3707
- const hspecContent = `---
3708
- created: ${new Date().toISOString()}
3709
- id: ${hspecId}
3710
- type: H-SPEC
3711
- title: ${(0, yaml_scalar_1.yamlScalar)(`Functional Specification for ${a.title}`)}
3712
- status: draft
3713
- req_type: functional
3714
- owner: me
3715
- depends_on:
3716
- - ${reqId}
3717
- ---
3718
-
3719
- ## Intent
3720
- Implement ${a.title}.
3721
-
3722
- ## Scope (In / Out)
3723
- In Scope: ${a.title}.
3724
- Out of Scope: None.
3725
-
3726
- ## Design Overview
3727
- High level design for ${a.title}.
3728
-
3729
- ## Interfaces / Contracts
3730
- - Target files: ${a.filesToTouch.join(', ')}
3731
-
3732
- ## Acceptance Criteria
3733
- - Code written and tests passing.
3734
-
3735
- ## Non-Functional
3736
- - Performance and stability maintained.
3737
-
3738
- ## Assumptions
3739
- - Environment configured properly.
3740
-
3741
- ## Open Questions
3742
- - None.
3743
- `;
3744
- const aspecContent = `---
3745
- created: ${new Date().toISOString()}
3746
- id: ${aspecId}
3747
- type: A-SPEC
3748
- title: ${(0, yaml_scalar_1.yamlScalar)(`Architecture Specification for ${a.title}`)}
3749
- status: draft
3750
- slice: ${(0, yaml_scalar_1.yamlScalar)(a.sliceName)}
3751
- priority: P1
3752
- independent_test: true
3753
- depends_on:
3754
- - ${hspecId}
3755
- breaking_change: 'none'
3756
- harness_impact: 'none: TODO — 3하네스(claude/codex/agy) 영향 검토 후 기술'
3757
- os_impact: 'none: TODO — 3OS(windows/mac/linux) 영향 검토 후 기술'
3758
- ---
3759
-
3760
- ## Objective
3761
- ${a.objective}
3762
-
3763
- ## Component Design
3764
- 1. Target Component:
3765
- - Modifies ${a.filesToTouch.join(', ')}.
3766
-
3767
- ## Inputs / Outputs
3768
- - Inputs: Tool calls / developer modifications.
3769
- - Outputs: Working implementation.
3770
-
3771
- ## Behavior
3772
- - Implements desired behavior cleanly.
3773
-
3774
- ## Test Points
3775
- - Unit tests in test suite.
3776
-
3777
- ## Files to Touch
3778
- ${a.filesToTouch.map((f) => `- ${f}`).join('\n')}
3779
-
3780
- ## Done When
3781
- - All tests pass 100%.
3782
- `;
3783
- const tspecContent = `---
3784
- coverage:
3785
- normal: true
3786
- corner: true
3787
- negative: true
3788
- boundary: true
3789
- id: ${tspecId}
3790
- type: T-SPEC
3791
- title: ${(0, yaml_scalar_1.yamlScalar)(`Test Specification for ${a.title}`)}
3792
- status: draft
3793
- depends_on:
3794
- - ${aspecId}
3795
- ---
3796
-
3797
- ## Normal Cases
3798
- - Given valid inputs
3799
- When operation is performed
3800
- Then correct behavior is observed
3801
-
3802
- ## Corner Cases
3803
- - Given edge cases
3804
- When operation is performed
3805
- Then system handles gracefully
3806
-
3807
- ## Negative Cases
3808
- - Given invalid inputs
3809
- When operation is performed
3810
- Then appropriate error is returned
3811
-
3812
- ## Boundary Cases
3813
- - Given boundary conditions
3814
- When operation is performed
3815
- Then boundary limits are respected
3816
- `;
3817
- fs.mkdirSync(path.join(specsDir, '01_req'), { recursive: true });
3818
- fs.mkdirSync(path.join(specsDir, '02_h-spec', 'functional'), { recursive: true });
3819
- fs.mkdirSync(path.join(specsDir, '03_a-spec'), { recursive: true });
3820
- fs.mkdirSync(path.join(specsDir, '05_t-spec'), { recursive: true });
3821
- fs.writeFileSync(path.join(specsDir, '01_req', `${reqId}.md`), reqContent);
3822
- fs.writeFileSync(path.join(specsDir, '02_h-spec', 'functional', `${hspecId}.md`), hspecContent);
3823
- fs.writeFileSync(path.join(specsDir, '03_a-spec', `${aspecId}.md`), aspecContent);
3824
- fs.writeFileSync(path.join(specsDir, '05_t-spec', `${tspecId}.md`), tspecContent);
3825
- // @implements A-SPEC-299
3826
- // Read back what was just written. S-298 fixed one cause of unparseable frontmatter; this
3827
- // closes the class. `list()` silently skips documents it cannot parse, so "created" and
3828
- // "present in the store" can diverge without anything throwing — which is exactly how a
3829
- // requirement went missing on 2026-08-28 while the tool reported four specs created.
3830
- const specsCreated = [reqId, hspecId, aspecId, tspecId];
3831
- const unreadable = unreadableAmong(await new spec_store_1.LocalMarkdownRepository(specsDir).list(), specsCreated);
3832
- // The files stay on disk on purpose: deleting the evidence turns a diagnosable bug into a
3833
- // mystery, and a half-written slice is something a person should see.
3834
- return { ok: unreadable.length === 0, specsCreated, unreadable };
3835
- },
3836
- async spec_slice_approve(a) {
3837
- const root = a.root ? (0, root_2.resolveProjectRoot)(a.root).root : process.cwd();
3838
- const rawStore = new spec_store_1.LocalMarkdownRepository(path.join(root, '.ax', 'specs'));
3839
- const specs = await rawStore.list();
3840
- const inSlice = specs.filter((s) => {
3841
- if (s.frontmatter?.slice === a.sliceName)
3842
- return true;
3843
- if (s.id === a.sliceName)
3844
- return true;
3845
- return false;
3846
- });
3847
- const idsToApprove = [];
3848
- if (inSlice.length > 0) {
3849
- const aspec = inSlice.find((s) => s.type === 'A-SPEC') ?? inSlice[0];
3850
- const mainId = (id) => id.split('.')[0].replace(/\D/g, '');
3851
- const req = specs.find((s) => aspec.dependsOn.includes(s.id) || mainId(s.id) === mainId(aspec.id));
3852
- const hspec = specs.find((s) => s.type === 'H-SPEC' && (s.dependsOn.includes(req?.id ?? '') || mainId(s.id) === mainId(aspec.id)));
3853
- const tspec = specs.find((s) => s.type === 'T-SPEC' && (s.dependsOn.includes(aspec.id) || mainId(s.id) === mainId(aspec.id)));
3854
- if (req)
3855
- idsToApprove.push(req.id);
3856
- if (hspec)
3857
- idsToApprove.push(hspec.id);
3858
- idsToApprove.push(aspec.id);
3859
- if (tspec)
3860
- idsToApprove.push(tspec.id);
3861
- }
3862
- else {
3863
- const matches = specs.filter((s) => s.id.includes(a.sliceName));
3864
- idsToApprove.push(...matches.map((s) => s.id));
3865
- }
3866
- const rawHandlers = makeRawHandlers(rawStore);
3867
- const approvedSpecs = [];
3868
- const alreadySealed = [];
3869
- const refused = [];
3870
- if (idsToApprove.length === 0) {
3871
- // Reporting this as a success is how a caller ends up believing an unapproved slice was
3872
- // approved. Measured 2026-08-28: a broken-frontmatter REQ made the whole slice invisible
3873
- // here, and the tool answered `ok: true, approvedSpecs: []`.
3874
- return { ok: false, approvedSpecs, alreadySealed, refused: [{ id: a.sliceName, reason: 'no specs matched this slice name' }] };
3875
- }
3876
- // @implements A-SPEC-587.2 — a sealed spec is SKIPPED, not re-sealed: re-approving asked the
3877
- // human the same question again (same request id back in the queue) and stopped the chain
3878
- // there, so the H-SPEC was never even attempted (measured 2026-09-10, three slices).
3879
- const sealed = (id) => {
3880
- const s = specs.find((x) => x.id === id);
3881
- return s?.status === 'approved' && typeof s.frontmatter?.approved_digest === 'string';
3882
- };
3883
- for (const id of idsToApprove) {
3884
- if (sealed(id)) {
3885
- alreadySealed.push(id);
3886
- continue;
3887
- }
3888
- const res = (await rawHandlers.spec_approve({ root, id }));
3889
- if (res.approved || res.ok) {
3890
- approvedSpecs.push(id);
3891
- continue;
3892
- }
3893
- const findings = Array.isArray(res.findings)
3894
- ? res.findings.map((f) => f.message).filter(Boolean).join('; ')
3895
- : '';
3896
- refused.push({ id, reason: findings ? `${res.reason ?? 'refused'}: ${findings}` : (res.reason ?? 'refused') });
3897
- // The chain is topological: approving a child whose parent was refused would either fail
3898
- // again or, worse, succeed against an unsealed parent. Stop and say what is left.
3899
- break;
3900
- }
3901
- for (const id of idsToApprove.slice(approvedSpecs.length + alreadySealed.length + refused.length)) {
3902
- refused.push({ id, reason: 'not attempted — an earlier spec in the chain was refused' });
3903
- }
3904
- // `ok` means the slice is approved — freshly or already. Anything less is not a success.
3905
- return { ok: refused.length === 0 && approvedSpecs.length + alreadySealed.length > 0, approvedSpecs, alreadySealed, refused };
3906
- },
3907
- async spec_remediate(a) {
3908
- const root = a.root ? (0, root_2.resolveProjectRoot)(a.root).root : process.cwd();
3909
- const actionsTaken = [];
3910
- if (a.targetFile && a.aspecId) {
3911
- const fullPath = path.isAbsolute(a.targetFile) ? a.targetFile : path.join(root, a.targetFile);
3912
- if (fs.existsSync(fullPath)) {
3913
- // @implements A-SPEC-501.1 — BUG-4 (dogfooded twice): the old unconditional
3914
- // `// @implements` broke Python/YAML targets outright AND produced anchors the scanner
3915
- // could not read. The syntax comes from the one map the scanner is aligned with, an
3916
- // unknown syntax is an honest refusal, and the containment check is form-agnostic so a
3917
- // `#` anchor is not double-injected.
3918
- const anchorTag = (0, anchor_comment_1.anchorLineFor)(fullPath, a.aspecId);
3919
- const content = fs.readFileSync(fullPath, 'utf8');
3920
- if (anchorTag === null) {
3921
- actionsTaken.push(`앵커 주입 거부: ${path.basename(fullPath)} — 이 확장자의 주석 문법을 모릅니다. 파일 관례에 맞는 주석으로 1행에 '@implements ${a.aspecId}'를 직접 추가하십시오`);
3922
- }
3923
- else if (!content.includes(`@implements ${a.aspecId}`)) {
3924
- fs.writeFileSync(fullPath, `${anchorTag}\n${content}`);
3925
- actionsTaken.push(`Injected ${anchorTag} on line 1 of ${a.targetFile}`);
3926
- }
3927
- }
3928
- }
3929
- const rawStore = new spec_store_1.LocalMarkdownRepository(path.join(root, '.ax', 'specs'));
3930
- const rawHandlers = makeRawHandlers(rawStore);
3931
- const approveRes = await rawHandlers.spec_slice_approve({ root, sliceName: a.aspecId ?? 'slice' });
3932
- if (approveRes.approvedSpecs?.length > 0) {
3933
- actionsTaken.push(`Approved slice specs: ${approveRes.approvedSpecs.join(', ')}`);
3934
- }
3935
- // Same false-PASS as spec_slice_approve had, one layer up: this handler's whole purpose is to
3936
- // approve, so reporting success when approval was refused sends the caller into code with an
3937
- // unapproved slice behind it.
3938
- const refused = approveRes.refused ?? [];
3939
- for (const r of refused)
3940
- actionsTaken.push(`Refused ${r.id}: ${r.reason}`);
3941
- return { ok: refused.length === 0, actionsTaken, refused };
3942
- },
960
+ },
961
+ }),
3943
962
  };
3944
963
  }