@holmes-lab/holmes-kit 0.19.6 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +96 -0
- package/README.md +8 -1
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/approve-status.d.ts +24 -0
- package/dist/holmes/cli/approve-status.js +166 -0
- package/dist/holmes/cli/approve-watch.d.ts +6 -0
- package/dist/holmes/cli/approve-watch.js +6 -0
- package/dist/holmes/cli/approve.d.ts +27 -0
- package/dist/holmes/cli/approve.js +68 -7
- package/dist/holmes/cli/cli-execution.d.ts +2 -0
- package/dist/holmes/cli/cli-execution.js +24 -0
- package/dist/holmes/cli/gitignore-merge.d.ts +0 -5
- package/dist/holmes/cli/gitignore-merge.js +6 -2
- package/dist/holmes/cli/index.js +176 -139
- package/dist/holmes/cli/playbook-skills.d.ts +2 -22
- package/dist/holmes/cli/playbook-skills.js +10 -54
- package/dist/holmes/governance/approval-grants.d.ts +22 -0
- package/dist/holmes/governance/approval-grants.js +55 -1
- package/dist/holmes/governance/approval-queue.d.ts +43 -4
- package/dist/holmes/governance/approval-queue.js +86 -12
- package/dist/holmes/governance/display-id-aliases.d.ts +29 -0
- package/dist/holmes/governance/display-id-aliases.js +86 -0
- package/dist/holmes/governance/ledger-store.d.ts +42 -14
- package/dist/holmes/governance/ledger-store.js +49 -3
- package/dist/holmes/governance/ledger-timeline.d.ts +3 -1
- package/dist/holmes/governance/ledger-timeline.js +11 -1
- package/dist/holmes/governance/provenance-chain.d.ts +2 -0
- package/dist/holmes/governance/provenance-chain.js +13 -2
- package/dist/holmes/governance/provenance-ledger.d.ts +28 -0
- package/dist/holmes/governance/provenance-ledger.js +69 -16
- package/dist/holmes/governance/session-context.d.ts +3 -0
- package/dist/holmes/governance/session-context.js +26 -8
- package/dist/holmes/guardrail/risk-classifier.js +2 -2
- package/dist/holmes/guardrail/write-target.js +2 -1
- package/dist/holmes/hooks/pre-tool-use.js +12 -1
- package/dist/holmes/hooks/stop.js +14 -3
- package/dist/holmes/mcp/handlers/adoption.d.ts +118 -0
- package/dist/holmes/mcp/handlers/adoption.js +186 -0
- package/dist/holmes/mcp/handlers/entity-integration.d.ts +100 -0
- package/dist/holmes/mcp/handlers/entity-integration.js +324 -0
- package/dist/holmes/mcp/handlers/entity-renumber.d.ts +137 -0
- package/dist/holmes/mcp/handlers/entity-renumber.js +228 -0
- package/dist/holmes/mcp/handlers/entity-store.d.ts +78 -0
- package/dist/holmes/mcp/handlers/entity-store.js +212 -0
- package/dist/holmes/mcp/handlers/graph-operations.d.ts +90 -0
- package/dist/holmes/mcp/handlers/graph-operations.js +260 -0
- package/dist/holmes/mcp/handlers/maintenance-analysis.d.ts +34 -0
- package/dist/holmes/mcp/handlers/maintenance-analysis.js +288 -0
- package/dist/holmes/mcp/handlers/maintenance-evidence.d.ts +40 -0
- package/dist/holmes/mcp/handlers/maintenance-evidence.js +104 -0
- package/dist/holmes/mcp/handlers/operator-inspection.d.ts +167 -0
- package/dist/holmes/mcp/handlers/operator-inspection.js +284 -0
- package/dist/holmes/mcp/handlers/phase-queries.d.ts +34 -0
- package/dist/holmes/mcp/handlers/phase-queries.js +86 -0
- package/dist/holmes/mcp/handlers/review-evidence.d.ts +47 -0
- package/dist/holmes/mcp/handlers/review-evidence.js +370 -0
- package/dist/holmes/mcp/handlers/review-queries.d.ts +68 -0
- package/dist/holmes/mcp/handlers/review-queries.js +79 -0
- package/dist/holmes/mcp/handlers/risk-assessment.d.ts +25 -0
- package/dist/holmes/mcp/handlers/risk-assessment.js +181 -0
- package/dist/holmes/mcp/handlers/slice-orchestration.d.ts +55 -0
- package/dist/holmes/mcp/handlers/slice-orchestration.js +324 -0
- package/dist/holmes/mcp/handlers/spec-approval.d.ts +105 -0
- package/dist/holmes/mcp/handlers/spec-approval.js +504 -0
- package/dist/holmes/mcp/handlers/spec-authoring.d.ts +57 -0
- package/dist/holmes/mcp/handlers/spec-authoring.js +326 -0
- package/dist/holmes/mcp/handlers/spec-lifecycle.d.ts +122 -0
- package/dist/holmes/mcp/handlers/spec-lifecycle.js +339 -0
- package/dist/holmes/mcp/handlers/spec-queries.d.ts +30 -0
- package/dist/holmes/mcp/handlers/spec-queries.js +74 -0
- package/dist/holmes/mcp/handlers/taint-screening.d.ts +48 -0
- package/dist/holmes/mcp/handlers/taint-screening.js +183 -0
- package/dist/holmes/mcp/handlers/test-execution.d.ts +63 -0
- package/dist/holmes/mcp/handlers/test-execution.js +210 -0
- package/dist/holmes/mcp/handlers/workspace-identity.d.ts +30 -0
- package/dist/holmes/mcp/handlers/workspace-identity.js +68 -0
- package/dist/holmes/mcp/handlers/workspace-queries.d.ts +77 -0
- package/dist/holmes/mcp/handlers/workspace-queries.js +193 -0
- package/dist/holmes/mcp/handlers.d.ts +653 -424
- package/dist/holmes/mcp/handlers.js +267 -3250
- package/dist/holmes/mcp/server.js +2 -1
- package/dist/holmes/mcp/tool-schemas.js +43 -2
- package/dist/holmes/project/execution-context.d.ts +17 -0
- package/dist/holmes/project/execution-context.js +120 -0
- package/dist/holmes/project/installer-markers.d.ts +34 -0
- package/dist/holmes/project/installer-markers.js +65 -0
- package/dist/holmes/project/root.d.ts +3 -1
- package/dist/holmes/project/root.js +4 -3
- package/dist/holmes/project/workspace-identity.d.ts +29 -0
- package/dist/holmes/project/workspace-identity.js +181 -0
- package/dist/holmes/review/test-outcomes.d.ts +6 -1
- package/dist/holmes/review/test-outcomes.js +23 -5
- package/dist/holmes/review/test-runner.d.ts +18 -0
- package/dist/holmes/review/test-runner.js +132 -4
- package/dist/holmes/rtm/graph-store.d.ts +50 -1
- package/dist/holmes/rtm/graph-store.js +244 -3
- package/dist/holmes/rtm/incremental.d.ts +1 -0
- package/dist/holmes/rtm/incremental.js +12 -3
- package/dist/holmes/rtm/localize.js +7 -0
- package/dist/holmes/rtm/rtm-graph.d.ts +9 -0
- package/dist/holmes/rtm/rtm-graph.js +13 -0
- package/dist/holmes/semantic/vector-cache.d.ts +16 -1
- package/dist/holmes/semantic/vector-cache.js +155 -19
- package/dist/holmes/spec/approval-status.d.ts +10 -0
- package/dist/holmes/spec/approval-status.js +7 -3
- package/dist/holmes/spec/entity-git-snapshot.d.ts +50 -0
- package/dist/holmes/spec/entity-git-snapshot.js +276 -0
- package/dist/holmes/spec/entity-integration-discharge.d.ts +73 -0
- package/dist/holmes/spec/entity-integration-discharge.js +233 -0
- package/dist/holmes/spec/entity-integration-transaction.d.ts +179 -0
- package/dist/holmes/spec/entity-integration-transaction.js +1221 -0
- package/dist/holmes/spec/entity-integration.d.ts +207 -0
- package/dist/holmes/spec/entity-integration.js +747 -0
- package/dist/holmes/spec/entity-renumber-transaction.d.ts +82 -0
- package/dist/holmes/spec/entity-renumber-transaction.js +711 -0
- package/dist/holmes/spec/entity-renumber.d.ts +32 -0
- package/dist/holmes/spec/entity-renumber.js +156 -0
- package/dist/holmes/spec/entity-store.d.ts +135 -0
- package/dist/holmes/spec/entity-store.js +1051 -0
- package/dist/holmes/spec/entity-transaction.d.ts +89 -0
- package/dist/holmes/spec/entity-transaction.js +701 -0
- package/dist/holmes/spec/renumber.d.ts +58 -0
- package/dist/holmes/spec/renumber.js +200 -1
- package/dist/holmes/spec/spec-store.d.ts +3 -2
- package/dist/holmes/spec/spec-store.js +23 -2
- package/dist/holmes/spec/transition-policy.d.ts +36 -0
- package/dist/holmes/spec/transition-policy.js +135 -0
- package/package.json +1 -1
|
@@ -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
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
-
const
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
const
|
|
85
|
-
const
|
|
86
|
-
const
|
|
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,
|
|
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
|
|
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,
|
|
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,
|
|
291
|
+
return (0, root_1.canonicalPath)(p);
|
|
382
292
|
}
|
|
383
293
|
catch {
|
|
384
294
|
return path.resolve(p);
|
|
@@ -448,13 +358,13 @@ function boundNonceLedger(store) {
|
|
|
448
358
|
// `--specs-dir docs/specs` an ordinary review_record could still plant `docs/specs/.ax` and move
|
|
449
359
|
// every consumed nonce, every audit line, and spec_approve's notion of "this project" with it.
|
|
450
360
|
const storeReal = (() => { try {
|
|
451
|
-
return (0,
|
|
361
|
+
return (0, root_1.canonicalPath)(store.specsRoot);
|
|
452
362
|
}
|
|
453
363
|
catch {
|
|
454
364
|
return store.specsRoot;
|
|
455
365
|
} })();
|
|
456
366
|
const derivedReal = (() => { try {
|
|
457
|
-
return (0,
|
|
367
|
+
return (0, root_1.canonicalPath)(derived);
|
|
458
368
|
}
|
|
459
369
|
catch {
|
|
460
370
|
return derived;
|
|
@@ -463,7 +373,9 @@ function boundNonceLedger(store) {
|
|
|
463
373
|
return null;
|
|
464
374
|
if (!fs.existsSync(path.join(derived, '.ax')))
|
|
465
375
|
return null;
|
|
466
|
-
|
|
376
|
+
// @implements A-SPEC-643 — the writer's replica chain, not the legacy single file: two machines
|
|
377
|
+
// consuming into `provenance.jsonl` cannot converge through Git (measured: CONFLICT add/add).
|
|
378
|
+
return (0, ledger_store_1.writerChainFile)(path.join(derived, '.ax', 'ledger'));
|
|
467
379
|
}
|
|
468
380
|
catch {
|
|
469
381
|
return null;
|
|
@@ -487,13 +399,13 @@ function boundFindingsLedger(store, root) {
|
|
|
487
399
|
try {
|
|
488
400
|
const derived = projectRootOf(store.specsRoot);
|
|
489
401
|
const storeReal = (() => { try {
|
|
490
|
-
return (0,
|
|
402
|
+
return (0, root_1.canonicalPath)(store.specsRoot);
|
|
491
403
|
}
|
|
492
404
|
catch {
|
|
493
405
|
return store.specsRoot;
|
|
494
406
|
} })();
|
|
495
407
|
const derivedReal = (() => { try {
|
|
496
|
-
return (0,
|
|
408
|
+
return (0, root_1.canonicalPath)(derived);
|
|
497
409
|
}
|
|
498
410
|
catch {
|
|
499
411
|
return derived;
|
|
@@ -552,6 +464,10 @@ function buildContentSource(specs, scanned) {
|
|
|
552
464
|
// the review/graph. `toolName` is passed through to assertRepoTopLevel so
|
|
553
465
|
// the thrown error names the actual caller.
|
|
554
466
|
async function deriveChangedContext(store, rootArg, a, toolName) {
|
|
467
|
+
// @implements A-SPEC-614 — bind specs and source before discovering changes or writing evidence.
|
|
468
|
+
const foreign = foreignRootReason(store, rootArg);
|
|
469
|
+
if (foreign)
|
|
470
|
+
throw new HandlerRefusal(foreign);
|
|
555
471
|
const root = projectRootOf(rootArg);
|
|
556
472
|
const source = makeChangeSource(root, a);
|
|
557
473
|
const resolved = source.changes();
|
|
@@ -613,8 +529,21 @@ function unreadableAmong(listed, created) {
|
|
|
613
529
|
function makeHandlers(store, opts) {
|
|
614
530
|
const raw = makeRawHandlers(store, opts);
|
|
615
531
|
const wrapped = {};
|
|
532
|
+
const run = (0, execution_context_1.createExecutionScope)(store instanceof spec_store_1.LocalMarkdownRepository ? store.specsRoot : undefined, opts?.observeInvocation);
|
|
616
533
|
for (const [name, fn] of Object.entries(raw)) {
|
|
617
|
-
|
|
534
|
+
const scoped = name === 'workspace_identity' ? fn : async (a) => {
|
|
535
|
+
try {
|
|
536
|
+
return await run(a?.root, () => fn(a));
|
|
537
|
+
}
|
|
538
|
+
catch (e) {
|
|
539
|
+
if (e instanceof workspace_identity_1.WorkspaceIdentityError)
|
|
540
|
+
return { ok: false, code: e.code, reason: e.message };
|
|
541
|
+
if (e instanceof ledger_store_1.LedgerConflictRefusal)
|
|
542
|
+
return { ok: false, code: e.code, reason: e.message }; // A-SPEC-642
|
|
543
|
+
throw e;
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
wrapped[name] = (0, basis_1.withBasis)(scoped, (a) => basisFor(a?.root));
|
|
618
547
|
}
|
|
619
548
|
wrapped.basis_detail = async (a) => {
|
|
620
549
|
const b = basisFor(a?.root, true);
|
|
@@ -744,7 +673,69 @@ function collectDecisions(root, scanned, specs) {
|
|
|
744
673
|
return { decisions, citations };
|
|
745
674
|
}
|
|
746
675
|
function makeRawHandlers(store, opts) {
|
|
676
|
+
// @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
|
|
677
|
+
// @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
|
|
678
|
+
const testExecution = (0, test_execution_1.createTestExecutionHandlers)({
|
|
679
|
+
defaultBaseline: DEFAULT_BASELINE,
|
|
680
|
+
deriveChangedContext: (root, args, tool) => deriveChangedContext(store, root, args, tool),
|
|
681
|
+
});
|
|
682
|
+
const reviewQueries = (0, review_queries_1.createReviewQueryHandlers)({
|
|
683
|
+
listSpecs: () => store.list(),
|
|
684
|
+
assertStoreReachable: (tool, root) => assertSpecStoreReachable(tool, store, root),
|
|
685
|
+
foreignRootReason: root => foreignRootReason(store, root),
|
|
686
|
+
refusal: reason => new HandlerRefusal(reason),
|
|
687
|
+
projectRootOf,
|
|
688
|
+
cachedScan,
|
|
689
|
+
buildContentSource,
|
|
690
|
+
deriveChangedContext: (root, args, tool) => deriveChangedContext(store, root, args, tool),
|
|
691
|
+
});
|
|
692
|
+
// @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
|
|
693
|
+
const graphOperations = (0, graph_operations_1.createGraphOperationsHandlers)({
|
|
694
|
+
listSpecs: () => store.list(),
|
|
695
|
+
assertStoreReachable: (tool, root) => assertSpecStoreReachable(tool, store, root),
|
|
696
|
+
foreignRootReason: root => foreignRootReason(store, root),
|
|
697
|
+
refusal: reason => new HandlerRefusal(reason),
|
|
698
|
+
projectRootOf,
|
|
699
|
+
cachedScan,
|
|
700
|
+
cacheDirFor,
|
|
701
|
+
deriveChangedContext: (root, args, tool) => deriveChangedContext(store, root, args, tool),
|
|
702
|
+
});
|
|
703
|
+
// @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
|
|
704
|
+
const maintenanceAnalysis = (0, maintenance_analysis_1.createMaintenanceAnalysisHandlers)({
|
|
705
|
+
listSpecs: () => store.list(),
|
|
706
|
+
foreignRootReason: root => foreignRootReason(store, root),
|
|
707
|
+
refusal: reason => new HandlerRefusal(reason),
|
|
708
|
+
projectRootOf,
|
|
709
|
+
basisFor,
|
|
710
|
+
collectDecisions,
|
|
711
|
+
buildContentSource,
|
|
712
|
+
specStoreBlindReason: (root, count) => specStoreBlindReason(store, root, count),
|
|
713
|
+
fileDigestOf,
|
|
714
|
+
});
|
|
747
715
|
const resolver = (specs) => (id) => specs.find((s) => s.id === id) ?? null;
|
|
716
|
+
// @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
|
|
717
|
+
const taintScreening = (0, taint_screening_1.createTaintScreeningHandlers)({
|
|
718
|
+
listSpecs: () => store.list(),
|
|
719
|
+
foreignRootReason: root => foreignRootReason(store, root),
|
|
720
|
+
projectRootOf,
|
|
721
|
+
cachedScan,
|
|
722
|
+
});
|
|
723
|
+
// @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
|
|
724
|
+
const workspaceQueries = (0, workspace_queries_1.createWorkspaceQueryHandlers)({
|
|
725
|
+
listSpecs: () => store.list(),
|
|
726
|
+
foreignRootReason: root => foreignRootReason(store, root),
|
|
727
|
+
projectRootOf,
|
|
728
|
+
cachedScan,
|
|
729
|
+
cachedScanWithReport,
|
|
730
|
+
assertStoreReachable: (tool, root) => assertSpecStoreReachable(tool, store, root),
|
|
731
|
+
});
|
|
732
|
+
// @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
|
|
733
|
+
const specAuthoring = (0, spec_authoring_1.createSpecAuthoringHandlers)({ store, projectRootOf, resolver });
|
|
734
|
+
const specQueries = (0, spec_queries_1.createSpecQueryHandlers)({
|
|
735
|
+
listSpecs: () => store.list(),
|
|
736
|
+
assertStoreReachable: (tool, root) => assertSpecStoreReachable(tool, store, root),
|
|
737
|
+
resolver,
|
|
738
|
+
});
|
|
748
739
|
// @implements A-SPEC-263.1
|
|
749
740
|
// The in-session approval channel: ask ONLY when (a) an elicitor was injected (the server wires
|
|
750
741
|
// one iff the client advertised the elicitation capability — handlers never see the server), and
|
|
@@ -777,6 +768,20 @@ function makeRawHandlers(store, opts) {
|
|
|
777
768
|
token: crypto.randomUUID(),
|
|
778
769
|
rationale: reason ?? 'elicitation grant',
|
|
779
770
|
});
|
|
771
|
+
// @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
|
|
772
|
+
const reviewEvidence = (0, review_evidence_1.createReviewEvidenceHandlers)({
|
|
773
|
+
foreignRootReason: root => foreignRootReason(store, root),
|
|
774
|
+
refusal: reason => new HandlerRefusal(reason),
|
|
775
|
+
boundFindingsLedger: root => boundFindingsLedger(store, root),
|
|
776
|
+
boundNonceLedger: () => boundNonceLedger(store),
|
|
777
|
+
projectRootOf,
|
|
778
|
+
basisFor,
|
|
779
|
+
hasElicitor: () => Boolean(opts?.elicit),
|
|
780
|
+
tryElicit,
|
|
781
|
+
elicitApproval,
|
|
782
|
+
resolveHandlerApproval: (root, approval, action, now) => resolveHandlerApproval(root, store, approval, action, now),
|
|
783
|
+
refusalQueueHint: (root, request) => refusalQueueHint(root, store, request),
|
|
784
|
+
});
|
|
780
785
|
// @implements A-SPEC-532.2 — the autonomous channel: the SAME synthesized-Approval shape that
|
|
781
786
|
// rides the existing seal path, but its actor names `autonomous:<client>` so an audit can tell a
|
|
782
787
|
// self-approved seal from a human-approved (elicitation) or operator (env/grant) one. Single-use
|
|
@@ -787,6 +792,13 @@ function makeRawHandlers(store, opts) {
|
|
|
787
792
|
// @implements A-SPEC-587 — an upstream grade is DERIVED; the audit line names the A-SPECs it came from.
|
|
788
793
|
rationale: `autonomous grant (${'HOLMES_AUTONOMOUS_APPROVAL'} enabled, spec grade auto${derivedFrom && derivedFrom.length > 0 ? ` — derived from ${derivedFrom.join(', ')}` : ''})`,
|
|
789
794
|
});
|
|
795
|
+
// @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
|
|
796
|
+
const specApproval = (0, spec_approval_1.createSpecApprovalHandlers)({
|
|
797
|
+
store, resolver, projectRootOf, cachedScan, tryElicit, elicitApproval, autonomousApproval,
|
|
798
|
+
resolveHandlerApproval: (root, approval, action, now) => resolveHandlerApproval(root, store, approval, action, now),
|
|
799
|
+
refusalQueueHint: (root, request) => refusalQueueHint(root, store, request),
|
|
800
|
+
foreignRootReason: (root) => foreignRootReason(store, root),
|
|
801
|
+
});
|
|
790
802
|
/**
|
|
791
803
|
* Where the audit record for a governance act belongs — resolved BEFORE the act writes anything.
|
|
792
804
|
*
|
|
@@ -826,3119 +838,124 @@ function makeRawHandlers(store, opts) {
|
|
|
826
838
|
}
|
|
827
839
|
return { ok: true, root: projectRootOf(root) };
|
|
828
840
|
};
|
|
841
|
+
// @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
|
|
842
|
+
// These contracts also depend on the root/store/fetch capabilities still implemented here.
|
|
843
|
+
// Keep this composition obligation as well as the moved method-body anchors: a shared-helper
|
|
844
|
+
// change must retain their existing contract tests even when call-graph resolution is incomplete.
|
|
845
|
+
// @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
|
|
846
|
+
const specLifecycle = (0, spec_lifecycle_1.createSpecLifecycleHandlers)({
|
|
847
|
+
store,
|
|
848
|
+
resolveLedgerRoot,
|
|
849
|
+
resolveHandlerApproval: (root, approval, action, now) => resolveHandlerApproval(root, store, approval, action, now),
|
|
850
|
+
refusalQueueHint: (root, request) => refusalQueueHint(root, store, request),
|
|
851
|
+
});
|
|
852
|
+
const operatorInspection = (0, operator_inspection_1.createOperatorInspectionHandlers)({
|
|
853
|
+
listSpecs: () => store.list(),
|
|
854
|
+
readSpec: (id) => store.read(id),
|
|
855
|
+
specsRoot: () => store.specsRoot,
|
|
856
|
+
assertStoreReachable: (tool, root) => assertSpecStoreReachable(tool, store, root),
|
|
857
|
+
resolver,
|
|
858
|
+
resolveLedgerRoot,
|
|
859
|
+
fetchJson,
|
|
860
|
+
resolveHandlerApproval: (root, approval, action, now) => resolveHandlerApproval(root, store, approval, action, now),
|
|
861
|
+
refusalQueueHint: (root, request) => refusalQueueHint(root, store, request),
|
|
862
|
+
});
|
|
863
|
+
// @implements A-SPEC-189, A-SPEC-100.2 — shared store/root capabilities retain phase contracts.
|
|
864
|
+
const phaseQueries = (0, phase_queries_1.createPhaseQueryHandlers)({
|
|
865
|
+
listSpecs: () => store.list(),
|
|
866
|
+
boundSpecsRoot: () => store instanceof spec_store_1.LocalMarkdownRepository ? store.specsRoot : undefined,
|
|
867
|
+
projectRootOf,
|
|
868
|
+
});
|
|
869
|
+
// @implements A-SPEC-125.4, A-SPEC-128, A-SPEC-133, A-SPEC-189, A-SPEC-191, A-SPEC-225
|
|
870
|
+
// Risk contracts still depend on the shared store, root, scan and bound-ledger capabilities.
|
|
871
|
+
const riskAssessment = (0, risk_assessment_1.createRiskAssessmentHandlers)({
|
|
872
|
+
listSpecs: () => store.list(),
|
|
873
|
+
projectRootOf,
|
|
874
|
+
cachedScan,
|
|
875
|
+
boundNonceLedger: () => boundNonceLedger(store),
|
|
876
|
+
});
|
|
877
|
+
// @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
|
|
878
|
+
const sliceOrchestration = (0, slice_orchestration_1.createSliceOrchestrationHandlers)({
|
|
879
|
+
listSpecs: () => store.list(),
|
|
880
|
+
unreadableAmong,
|
|
881
|
+
rawHandlersForStore: rawStore => makeRawHandlers(rawStore),
|
|
882
|
+
});
|
|
883
|
+
// @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
|
|
884
|
+
// Adoption contracts retain their shared root and configured-store obligations here.
|
|
885
|
+
const adoption = (0, adoption_1.createAdoptionHandlers)({
|
|
886
|
+
listSpecs: () => store.list(),
|
|
887
|
+
writeSpec: (...args) => store.write(...args),
|
|
888
|
+
assertReadableRoot,
|
|
889
|
+
occupiedTarget: (specs) => store instanceof spec_store_1.LocalMarkdownRepository
|
|
890
|
+
? specs.map((s) => store.targetPathFor(s)).find((p) => fs.existsSync(p))
|
|
891
|
+
: undefined,
|
|
892
|
+
});
|
|
829
893
|
return {
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
//
|
|
886
|
-
//
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
const
|
|
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) {
|
|
894
|
+
spec_create: specAuthoring.spec_create,
|
|
895
|
+
spec_validate: specQueries.spec_validate,
|
|
896
|
+
spec_upgrade: specAuthoring.spec_upgrade,
|
|
897
|
+
spec_retire: specLifecycle.spec_retire,
|
|
898
|
+
spec_renumber: specLifecycle.spec_renumber,
|
|
899
|
+
spec_unseal: specLifecycle.spec_unseal,
|
|
900
|
+
approval_status: operatorInspection.approval_status,
|
|
901
|
+
ledger_timeline: operatorInspection.ledger_timeline,
|
|
902
|
+
rtm_dashboard: operatorInspection.rtm_dashboard,
|
|
903
|
+
spec_approve: specApproval.spec_approve,
|
|
904
|
+
spec_list: specQueries.spec_list,
|
|
905
|
+
spec_next: specQueries.spec_next,
|
|
906
|
+
rtm_check: workspaceQueries.rtm_check,
|
|
907
|
+
citation_pin: specApproval.citation_pin,
|
|
908
|
+
phase_status: phaseQueries.phase_status,
|
|
909
|
+
phase_check: phaseQueries.phase_check,
|
|
910
|
+
cpg_scan: workspaceQueries.cpg_scan,
|
|
911
|
+
taint_scan: taintScreening.taint_scan,
|
|
912
|
+
test_run: testExecution.test_run,
|
|
913
|
+
issue_localize: workspaceQueries.issue_localize,
|
|
914
|
+
maintenance_analyze: maintenanceAnalysis.maintenance_analyze,
|
|
915
|
+
...(0, maintenance_evidence_1.createMaintenanceEvidenceHandlers)({
|
|
916
|
+
foreignRootReason: root => foreignRootReason(store, root),
|
|
917
|
+
projectRootOf,
|
|
918
|
+
refusal: reason => new HandlerRefusal(reason),
|
|
919
|
+
fileDigestOf,
|
|
920
|
+
}),
|
|
921
|
+
// @implements A-SPEC-642 — placed in the one slot no public-key-order pin covers (the handler suites pin
|
|
922
|
+
// spec_create..spec_upgrade, spec_retire..spec_unseal, spec_unseal+5, spec_approve+4, spec_next+3,
|
|
923
|
+
// citation_pin+4, phase_check+3, cpg_scan+3, taint_scan..issue_localize, test_run+3, issue_localize+3,
|
|
924
|
+
// rtm_impact+4, rtm_reindex..review_record, review_prepare..risk_check, risk_check±, and the tail).
|
|
925
|
+
ledger_reconcile: operatorInspection.ledger_reconcile,
|
|
926
|
+
rtm_impact: graphOperations.rtm_impact,
|
|
927
|
+
rtm_reindex: graphOperations.rtm_reindex,
|
|
928
|
+
context_bundle: reviewQueries.context_bundle,
|
|
929
|
+
review_scope: reviewQueries.review_scope,
|
|
930
|
+
review_prepare: reviewQueries.review_prepare,
|
|
931
|
+
review_record: reviewEvidence.review_record,
|
|
932
|
+
review_status: reviewEvidence.review_status,
|
|
933
|
+
risk_check: riskAssessment.risk_check,
|
|
934
|
+
reverse_scan: adoption.reverse_scan,
|
|
935
|
+
reverse_draft: adoption.reverse_draft,
|
|
936
|
+
reverse_anchor: adoption.reverse_anchor,
|
|
937
|
+
spec_slice_init: sliceOrchestration.spec_slice_init,
|
|
938
|
+
spec_slice_approve: sliceOrchestration.spec_slice_approve,
|
|
939
|
+
spec_remediate: sliceOrchestration.spec_remediate,
|
|
940
|
+
// @implements A-SPEC-623 — append the capability while preserving existing tool neighbors.
|
|
941
|
+
...(0, workspace_identity_2.createWorkspaceIdentityHandlers)({
|
|
942
|
+
foreignRootReason: (root) => foreignRootReason(store, root),
|
|
943
|
+
storeRoot: store instanceof spec_store_1.LocalMarkdownRepository ? store.specsRoot : undefined,
|
|
944
|
+
}),
|
|
945
|
+
...(0, entity_store_1.createEntityStoreHandlers)({ storeRoot: store instanceof spec_store_1.LocalMarkdownRepository ? store.specsRoot : undefined }),
|
|
946
|
+
...(0, entity_renumber_1.createEntityRenumberHandlers)({ storeRoot: store instanceof spec_store_1.LocalMarkdownRepository ? store.specsRoot : undefined }),
|
|
947
|
+
...(0, entity_integration_1.createEntityIntegrationHandlers)({
|
|
948
|
+
storeRoot: store instanceof spec_store_1.LocalMarkdownRepository ? store.specsRoot : undefined,
|
|
949
|
+
// @implements A-SPEC-632 — discharge rebuilds through the same publication and warming paths
|
|
950
|
+
// rtm_reindex uses; the impact population (approved-only) so the next rtm_impact reuses it.
|
|
951
|
+
rebuildFor: (root) => {
|
|
952
|
+
let scanned;
|
|
953
|
+
const scan = () => (scanned ??= cachedScan(root));
|
|
1141
954
|
return {
|
|
1142
|
-
|
|
1143
|
-
|
|
955
|
+
publishGraph: async () => (0, graph_operations_2.publishCurrentGraph)(root, (await store.list()).filter((s) => s.status === 'approved'), scan()),
|
|
956
|
+
warmSemantic: () => (0, graph_operations_2.warmSemanticCache)(root, scan()),
|
|
1144
957
|
};
|
|
1145
|
-
}
|
|
1146
|
-
|
|
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
|
-
},
|
|
958
|
+
},
|
|
959
|
+
}),
|
|
3943
960
|
};
|
|
3944
961
|
}
|