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