@holmes-lab/holmes-kit 0.16.0 → 0.18.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 CHANGED
@@ -5,6 +5,136 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
  <!-- @implements A-SPEC-209 -->
8
+ ## [0.18.0] - 2026-09-08
9
+
10
+ Decisions become governed. A consuming project's operational decisions had been leaking into agent
11
+ memory outside the gate — because Holmes-Kit accepted functional contracts (REQ→H→A→T) but had no
12
+ slot for a **decision**. Now it does, and it reuses machinery already present rather than inventing
13
+ a channel.
14
+
15
+ ### Added
16
+ - **ADR as a first-class governed spec type (REQ-571)** — `spec_create(type: "ADR")` (refused as
17
+ "unknown spec type" through 0.17.0) now scaffolds a root decision document under `06_adr`
18
+ (sections Context / Decision / Consequences / Alternatives; fields `decided` / `decider`) and
19
+ inherits the store's full authoring governance: validate → `spec_approve` seal
20
+ (`approved_digest` + ledger) → post-seal tamper-block. Three deliberate boundaries: an ADR
21
+ carries **no** T-SPEC / `@implements` / Files-to-Touch duty (a decision is not a functional
22
+ contract — a separate axis from No-Spec-No-Code); it lives in its **own number space** (a
23
+ consuming project's existing `ADR-0001` neither blocks nor is blocked by the functional chain's
24
+ max); and its seal is **hitl-only** (autonomy never self-approves a decision, like an upstream
25
+ REQ/H-SPEC/C-SPEC).
26
+ - **Store ADRs join the existing decision surface with zero new edge kinds (REQ-571)** — a store
27
+ ADR merges into the `collectDecisions` population (store wins on id collision with a legacy
28
+ `.ax/decisions/` entry, which coexists for back-compat), so `ADR-XXXX` citations in specs and
29
+ source become `constrained_by` edges, `supersedes` chains link, and the DECISION node appears —
30
+ all through machinery that already shipped. The graph's `SPEC:ADR` node carries the Decision
31
+ line as its intent summary (intent-layer parity), and the citation system recognizes `ADR-\d+`
32
+ (recognition only — the lexical match-scoring population stays A-SPEC-limited, so an ADR never
33
+ admits or reorders a file). A migration guide for consuming projects ships at
34
+ `docs/adr-migration.md`.
35
+
36
+ - **The graph advisory now arrives at DESIGN time (REQ-572)** — measured on our own work: the
37
+ sealing advisory for a slice named `validator.ts` / `legacy-format.ts` / `rtm-check.ts` /
38
+ `constitution.ts`, three regressions landed in exactly that cluster, and the agent read none of
39
+ it until the suite went red. The calculation was never the gap; the **delivery time** was. So the
40
+ read-only `approval_status` — already the "what is blocking this right now" tool — also answers
41
+ `graphPreview`: `impact` (files calling INTO your declared Files-to-Touch from outside, each
42
+ anchor carrying its spec's intent sentence) and `density` (anchor-dense files inside the scope),
43
+ computed by the **same functions `spec_approve` calls**, so a preview can never disagree with the
44
+ seal. Read-only stays read-only: no scan, no build, and **no ledger append** (a query must not
45
+ pollute the observation denominator); every failure degrades to an absent field. Both authoring
46
+ playbooks now carry the step — read the impact before freezing Files-to-Touch, then either widen
47
+ the declaration, narrow the design, or leave it knowingly — pinned by test so the instruction
48
+ cannot evaporate. It stays a **discipline, not a gate** (hard-gate promotion waits on the
49
+ observation ledger's false-positive rate).
50
+ - **Root-cause analysis carries the decision context (REQ-572)** — `maintenance_analyze` candidates
51
+ now ride with `decisionContext`: the ADRs constraining that file (directly, or through the specs
52
+ it anchors) and each decision's own sentence. That is the order a person diagnoses in — what
53
+ broke, then why it was left this way — and it needs no new mechanism: ADRs entered the graph with
54
+ REQ-571 and `constrained_by` has been built from citations since A-SPEC-293. Information only,
55
+ capped, never an input to the ranking.
56
+
57
+ ### Notes
58
+ - Version bump is **minor** carrying one `gate-behavior` change (the new ADR type is accepted where
59
+ it was refused); the release classifier routes this to HITL by design. The four existing spec
60
+ types are unchanged — verdicts pinned across the suite.
61
+ - REQ-572's own last slice was authored **through** the new design-time read: the preview reported
62
+ `maintenance-analyze.ts` at 35 anchors and `handlers.ts` at 83 (p90 = 7), so the decision-context
63
+ logic went into a new pure module instead of thickening either file. First recorded case of the
64
+ advisory changing a design before the code was written.
65
+
66
+ ## [0.17.0] - 2026-09-07
67
+
68
+ An adversarial review of 0.16.0's own new surfaces, run against the shipped tarball the day it
69
+ went out, drove this release: the newest repair is always the next target, and this time the
70
+ findings were sealed before anyone else could find them. The release gate itself also gets the
71
+ fix for the incident that let 0.16.0 ship with a frozen README.
72
+
73
+ ### Fixed
74
+ - **Graph-row forgery via spec prose (REQ-569 S1, HIGH)** — a DRAFT spec (no approval needed to
75
+ exist) whose YAML double-quoted title carried `\n`/`\t` escapes could forge rows in the graph's
76
+ canonical dump, which the PPR view parsed as **real call edges** — poisoning `rankedImpact` and
77
+ `maintenance_analyze` (reproduced against the shipped 0.16.0 artifact, then killed). Sealed at
78
+ BOTH boundaries independently: `specSummary` now folds whitespace over the whole summary (title
79
+ included — the sentence-only fold was the hole), and `RtmGraph.addNode` folds structural
80
+ characters at the storage boundary so no future caller can break a dump row either. Each face
81
+ verified alone. A high-effort review of this very fix then widened it: tabs riding in via spec
82
+ `id:`/`depends_on:` still shifted dump columns, so `dumpCanonical` now folds EVERY text column
83
+ at emission (single choke point; the 13-cell row invariant is pinned by test), and the graph
84
+ extractor version bumped to `holmes-rtm/2` so a persisted store written by unfixed 0.16.0 —
85
+ whose summary column may already carry forged rows — is force-rebuilt instead of reused.
86
+ - **Grader/doctor parity pair (REQ-569 S4)** — `isHighRiskPath` folds `.` path segments before
87
+ judging (`src/holmes/./governance/x.ts` no longer dodges the risk roots; `..` is deliberately
88
+ NOT folded — the grader widens sight, never impersonates path resolution, and the enforcer
89
+ stays byte-literal). The codex doctor's npx-pin branch now compares the pin against the
90
+ installed version and WARNs on a stale pin (parity with the `.mcp.json` drift check) — an old
91
+ fixture that had enshrined the gap as PASS was repinned to the current version. Review
92
+ follow-ups sealed in the same release: a `..`-bearing Files-to-Touch token is now FLAGGED
93
+ high-risk (the hook does not canonicalize relative paths, so a `src/app/../holmes/...` spelling
94
+ stayed admissible at the byte-literal enforcer while the grader called it benign — flagging the
95
+ ambiguous ascent token closes the self-approval hole without impersonating path resolution);
96
+ non-semver pins (`@latest`, `^x.y.z`) judge `unknown` instead of an inverted "stale pin" WARN;
97
+ and the sealing-time advisory now closes its SQLite handle (a per-approval native-handle leak in
98
+ the long-lived MCP server, and a file-lock risk on Windows).
99
+ - **The docs-currency gate is bidirectional now (REQ-570)** — 0.16.0 shipped with README's
100
+ feature list frozen at "v0.14.x" and zero 0.16.0 entries, because the gate's instruction was
101
+ "grep for stale phrases": a feature never written produces zero hits, and zero hits read as
102
+ "no drift". The publish playbook now demands three checks per user-facing change — ADDITION
103
+ (the entry must exist; zero grep hits are a missing-entry signal, not a pass), drift (the old
104
+ wording), and stale markers (version-pinned section labels are drift generators and get
105
+ removed, not policed). The incident is recorded in the playbook itself, the README repaired
106
+ retroactively, and the new wording pinned by tests.
107
+
108
+ ### Changed
109
+ - **The impact/advisory graph is approved-only (REQ-569 S2)** — the persisted RTM graph that
110
+ feeds `rtm_impact`, the sealing-time advisory and the intent summaries now builds from SEALED
111
+ specs only: a draft needs no approval to exist, so it can no longer reach the agent-visible
112
+ channels (that was the forgery's delivery vehicle). The closure walks edges, so the channel
113
+ filter is node existence — an anchor naming a draft keeps its id but carries no prose until
114
+ approval. Diagnosis (`rtm_check`) and matching (`issue_localize`) keep their existing
115
+ populations; old `rtm.sqlite` files rebuild automatically on basis drift. Replay pins unmoved
116
+ to the digit.
117
+ - **Annotation caps (REQ-569 S3)** — `impactedSummaries` caps at 40 (`summariesOmitted` counts
118
+ the rest; `impacted` itself is never truncated) and advisory anchors cap at 10 per file
119
+ (`anchorsOmitted`). Measured on this repository's hub-grade impact (342 specs): the response
120
+ shrank **104,706 → 18,708 bytes (−82%)**, and the omitted tail costs no summary lookups.
121
+
122
+ ### Added
123
+ - **Anchor-density advisory (REQ-569 S5, observation only)** — sealing an A-SPEC whose
124
+ Files-to-Touch contains an anchor-dense file (live `implements` count ≥ max(8, p90 of the
125
+ store's distribution)) annotates the response with `anchorDensity: [{path, anchors, p90}]` and
126
+ records it to `anchor-density.<replica>.jsonl` (paths and integers only). Grounded in the
127
+ measured precision tax of anchor accumulation (authoring one spec moved replay recall
128
+ 0.5476→0.5060); a count GATE was considered and refused — the thresholds are prose constants,
129
+ and promotion or rejection will be decided by this ledger, the impactAdvisory lifecycle.
130
+
131
+ ### Notes
132
+ - One inherited finding was honestly killed instead of "fixed": the recorded
133
+ "scanTestAnchors only sees a file's first anchor" defect does **not reproduce** at HEAD (all
134
+ standalone-comment anchors are consumed by ART-4 and the execution-evidence attribution alike);
135
+ the only non-recognition is the trailing-comment form, which is the sealed anchor idiom rule.
136
+ Stale findings get re-measured, not re-fixed.
137
+
8
138
  ## [0.16.0] - 2026-09-07
9
139
 
10
140
  The graph learns to speak intent, and the call graph learns to speak up at sealing time. An A-SPEC
package/README.md CHANGED
@@ -14,7 +14,14 @@
14
14
 
15
15
  ---
16
16
 
17
- ### 🛡️ Currently Supported Features (v0.14.x Production Features)
17
+ ### 🛡️ Currently Supported Features (Production Features)
18
+
19
+ - 🧭 **The graph speaks BEFORE you commit to a scope** *(new in 0.18.0)*: the read-only `approval_status` now also answers `graphPreview` — `impact` (files that call INTO your declared Files-to-Touch from outside it, each anchor carrying its spec's intent sentence) and `density` (anchor-dense files inside the scope) — computed by the **same functions the sealing advisory uses**, so the preview can never disagree with the seal. Read the impact, then widen the declaration, narrow the design, or leave it knowingly; the authoring playbooks carry the step (pinned by test) and it stays a discipline, not a gate. Root-cause work gets the other half: `maintenance_analyze` candidates ride with `decisionContext` — the ADRs constraining that file and each decision's own sentence — which is the order a person diagnoses in (what broke, then why it was left this way). Both are information only: value tests pin that no ranking, score or gate reads them.
20
+ - 📜 **ADR as a first-class governed document** *(new in 0.18.0)*: decisions stop leaking into agent memory outside the gate (a measured incident on a consuming project drove this). `spec_create(type: "ADR")` scaffolds a root decision document (Context / Decision / Consequences / Alternatives, `decided`/`decider`) under the store's full authoring governance — validate, `spec_approve` seal, ledger, tamper-block — with its **own number space** (your existing `ADR-0001` just works) and a **hitl-only seal** (autonomy never self-approves a decision). Store ADRs join the existing decision surface with zero new edge kinds: `ADR-XXXX` citations in specs/code become `constrained_by` edges, `supersedes` chains link, the graph's SPEC:ADR node carries the Decision line as its intent summary, and legacy `.ax/decisions/` entries coexist (store wins on id collision). A migration guide ships at `docs/adr-migration.md`.
21
+
22
+ - 📣 **Impact Advisory at sealing time** *(new in 0.16.0)*: approving an A-SPEC now returns what your Files-to-Touch declaration *missed* — files whose symbols **call into** the declared scope from outside it (1-hop, capped, repo-relative allow-list), computed from the persisted RTM graph at the moment of sealing. Advisory, never verdict: it rides the response *after* the seal commits, degrades to absence on any failure, and every emission lands in an observation ledger so its false-positive rate is **measured before** anyone proposes a hard gate. The graph keeps itself fresh — `rtm_impact` rebuilds on basis drift and the Stop hook spawns a TTL-gated detached reindex (staleness was measured as the advisory's quality factor: 7 findings on an 8-day-old graph, 17 after a fresh one). *(0.17.0 hardening)*: the advisory/impact graph is **approved-only** (a draft needs no approval to exist, so it can no longer reach these agent-visible channels), summary prose can't forge graph rows (structural characters fold at both the extraction and storage boundaries), and annotations are capped with explicit omission counts (a hub-grade response shrank 104.7KB → 18.7KB, −82%). Sealing also gains an **anchor-density advisory** (observation-only): an A-SPEC whose Files-to-Touch contains an anchor-dense file (live anchors ≥ max(8, p90)) is annotated with `anchorDensity: [{path, anchors, p90}]` and ledgered — grounded in the measured precision tax of anchor accumulation; a count *gate* was considered and refused.
23
+ - 🗣️ **The graph speaks intent** *(new in 0.16.0)*: every SPEC node stores a one-sentence intent summary (`"<title> — <first sentence of its intent section>"`, schema `rtm-graph/3`, old stores rebuild automatically) — extracted deterministically, **never generated** (same store, byte-identical graph; measured cost +6.4% build time / +4.2% file size). Advisory anchors arrive as `{id, summary}` and `rtm_impact` adds `impactedSummaries`, so the reader sees *which intent* is at risk without a spec-store round trip. Information only: value tests pin that no verdict, ranking or gate reads the prose.
24
+ - 📇 **Session-context observability** *(new in 0.16.0)*: the ledger records which agent/model drove a session and what the governance overhead cost, per replica (`session-context.<replica>.jsonl`), grounding field reports in machine attribution instead of guesswork.
18
25
 
19
26
  - 📋 **Requirements & Specification Governance**: Strict **"No Spec, No Code"** enforcement with 4-tier spec chain traceability (`REQ ➔ H-SPEC ➔ A-SPEC ➔ T-SPEC`) and `// @implements A-SPEC-XXX` code anchors (comma-lists and every anchor in a file participate in the gate).
20
27
  - 🔴 **Inbuilt TDD — RED-first, enforced not asked** *(new in 0.9.0)*: the test-first discipline is a holmes-installed `holmes-tdd-slice` skill **and** a new constitution article **ART-8**. A changed A-SPEC must show a recorded `red-assertion → green` sequence in the ledger; a `red-error` (a test that could not run) is not a valid RED, so "the covering test failed *correctly*" is judged mechanically, not on trust. `test_run` classifies each covered file (`red-assertion`/`red-error`/`green`) and records per-A-SPEC outcomes the Stop hook reads. Ships at `redFirstEvidence: track` (observe-first, non-blocking; `strict`/`off` per repo), evidence-gated and jest-only for now. A T-SPEC may also declare `kills:` mutations and `test_run --mutate` reports which SURVIVED (a coverage gap). Where superpowers *asks* for RED-first and discriminating power, holmes-kit *proves* them.
package/dist/.build-id CHANGED
@@ -1 +1 @@
1
- b540876-mtqeiitb
1
+ cc24589-mtrjegw0
@@ -897,7 +897,21 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
897
897
  }
898
898
  else {
899
899
  const pin = (0, mcp_version_1.mcpLaunchVersion)({ command: entry.command, args: entry.args });
900
- add('codex wiring', pin !== null ? 'PASS' : 'FAIL', pin !== null ? `resolves via the npx pin ${pin}` : `cannot read a launch version from the wiring: ${entry.command} ${entry.args.join(' ')}`, pin !== null ? undefined : 'Rewire with holmes-kit init --target <dir> --agent codex.');
900
+ // @implements A-SPEC-569.4 parity with the .mcp.json drift check (A-SPEC-251.2): a pin
901
+ // that RESOLVES is not enough, because a stale pin quietly keeps launching yesterday's
902
+ // gate. Same verdict function (non-semver pins like @latest judge 'unknown' → PASS, not
903
+ // an inverted "stale" WARN), same fix shape; an unreadable pin stays FAIL as before.
904
+ const cliVer = (0, mcp_launcher_1.readPackageVersion)(packageRoot); // same accessor as the .mcp.json check — one source of truth
905
+ const drift = cliVer ? (0, mcp_version_1.versionDriftVerdict)(pin, cliVer) : 'unknown';
906
+ if (pin === null) {
907
+ add('codex wiring', 'FAIL', `cannot read a launch version from the wiring: ${entry.command} ${entry.args.join(' ')}`, 'Rewire with holmes-kit init --target <dir> --agent codex.');
908
+ }
909
+ else if (drift === 'drift') {
910
+ add('codex wiring', 'WARN', `resolves via the npx pin ${pin}, but this install is ${cliVer} — a stale pin keeps launching the old server`, 'Rewire with holmes-kit init --target <dir> --agent codex --force to refresh the pin.');
911
+ }
912
+ else {
913
+ add('codex wiring', 'PASS', `resolves via the npx pin ${pin}`);
914
+ }
901
915
  }
902
916
  // @implements A-SPEC-442 (was A-SPEC-423)
903
917
  // Codex CAN hard-enforce, but only from an INSTALLED plugin: it loads plugins from
@@ -19,5 +19,8 @@ export interface McpEntryShape {
19
19
  */
20
20
  export declare function mcpLaunchVersion(entry: McpEntryShape, readVersion?: (packageDir: string) => string | undefined): string | null;
21
21
  export type DriftVerdict = 'match' | 'drift' | 'unknown';
22
- /** launch 를 못 뽑았으면 unknown(거짓 drift 를 만들지 않는다). 같으면 match, 다르면 drift. */
22
+ /** launch 를 못 뽑았으면 unknown(거짓 drift 를 만들지 않는다). 같으면 match, 다르면 drift.
23
+ * @implements A-SPEC-569.4 — 비-semver 런치 문자열(`latest`·`next`·`^0.16.0`)도 unknown:
24
+ * `@latest` 핀은 최신을 띄우는데 "구 서버를 계속 띄운다"는 drift 경고는 역진단이었다(고강도
25
+ * 리뷰 F5). 정확 semver 만 오프라인에서 비교 가능하다 — 판정 불능은 결함으로 둔갑시키지 않는다. */
23
26
  export declare function versionDriftVerdict(launchVersion: string | null, cliVersion: string): DriftVerdict;
@@ -74,9 +74,12 @@ function mcpLaunchVersion(entry, readVersion) {
74
74
  }
75
75
  return null;
76
76
  }
77
- /** launch 를 못 뽑았으면 unknown(거짓 drift 를 만들지 않는다). 같으면 match, 다르면 drift. */
77
+ /** launch 를 못 뽑았으면 unknown(거짓 drift 를 만들지 않는다). 같으면 match, 다르면 drift.
78
+ * @implements A-SPEC-569.4 — 비-semver 런치 문자열(`latest`·`next`·`^0.16.0`)도 unknown:
79
+ * `@latest` 핀은 최신을 띄우는데 "구 서버를 계속 띄운다"는 drift 경고는 역진단이었다(고강도
80
+ * 리뷰 F5). 정확 semver 만 오프라인에서 비교 가능하다 — 판정 불능은 결함으로 둔갑시키지 않는다. */
78
81
  function versionDriftVerdict(launchVersion, cliVersion) {
79
- if (launchVersion === null)
82
+ if (launchVersion === null || !/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(launchVersion))
80
83
  return 'unknown';
81
84
  return launchVersion === cliVersion ? 'match' : 'drift';
82
85
  }
@@ -80,7 +80,22 @@ const TAINT_MARKERS = ['taint', 'dataflow-taint', 'flow-sensitive'];
80
80
  // purpose, because a glob spanning the gate surface must never self-approve.
81
81
  const GLOB_RISK_ROOTS = [...HIGH_RISK_PREFIXES, 'src/holmes/rtm/'];
82
82
  function isHighRiskPath(p) {
83
- const raw = p.replace(/^\.\//, '').replace(/^["'`]|["'`]$/g, '');
83
+ // @implements A-SPEC-569.4 '.' segments fold BEFORE the verdict: `src/holmes/./governance/x`
84
+ // dodged every prefix check while naming the gate surface exactly (REQ-556's remaining edge).
85
+ // Deliberately NOT `..`: folding that would impersonate path resolution this grader cannot do
86
+ // (shape is not location) — over-inclusion is allowed here, invented precision is not. The
87
+ // enforcer (matchesFtt) stays byte-literal, so a /./-token still admits nothing: only the
88
+ // grader's sight widened, never the gate's admission.
89
+ const unquoted = p.replace(/^["'`]|["'`]$/g, '');
90
+ // @implements A-SPEC-569.4 (revision, high-effort review F3) — a `..` segment is NOT folded
91
+ // (folding would impersonate path resolution this grader cannot do) but it IS flagged: the hook
92
+ // does not canonicalize relative paths, so `src/app/../holmes/governance/x.ts` stays admissible
93
+ // at the byte-literal enforcer under its own spelling while a fold-blind grader called it
94
+ // benign. An ascent token is ambiguous about where it lands, and ambiguity over the gate
95
+ // surface grades high-risk — over-inclusive on purpose, the rule this module already owns.
96
+ if (/(^|\/)\.\.(\/|$)/.test(unquoted))
97
+ return true;
98
+ const raw = unquoted.replace(/\/\.(?=\/|$)/g, '').replace(/^(\.\/)+/, '');
84
99
  const s = raw.toLowerCase();
85
100
  if (HIGH_RISK_PREFIXES.some((pre) => s.startsWith(pre)))
86
101
  return true;
@@ -118,6 +133,7 @@ function specApprovalAutonomy(spec, _resolveParent) {
118
133
  case 'REQ':
119
134
  case 'H-SPEC':
120
135
  case 'C-SPEC':
136
+ case 'ADR': // @implements A-SPEC-571.1 — a decision is the human's to seal
121
137
  return 'hitl'; // wide blast radius / structural constraint
122
138
  case 'A-SPEC': {
123
139
  const grade = breakingGrade(spec);
@@ -1,6 +1,9 @@
1
1
  import { SpecStore } from '../spec/spec-store';
2
+ import { Spec } from '../spec/spec-parser';
2
3
  import { MaintenanceGroundTruth, MaintenanceAnalysis } from './maintenance-analyze';
3
4
  import { Action } from '../guardrail/phase';
5
+ import { ScannedFile } from '../cpg/cpg-scanner';
6
+ import { type DecisionRecord, type DecisionCitation } from '../rtm/rtm-builder';
4
7
  import { Basis } from './basis';
5
8
  import { Finding } from '../review/findings';
6
9
  import { Approval, Enforcement } from '../guardrail/risk-gate';
@@ -53,6 +56,16 @@ export declare function makeHandlers(store: SpecStore, opts?: ElicitOpts): RawHa
53
56
  }>;
54
57
  };
55
58
  type RawHandlers = ReturnType<typeof makeRawHandlers>;
59
+ /**
60
+ * @implements A-SPEC-293
61
+ * Read `.ax/decisions/*.md` and find who cites them. Citation is the link, chosen by measurement:
62
+ * `governs` resolves 9 of 65 names to symbols while 37 source files and 71 spec files cite an ADR.
63
+ * Failure here is never fatal — a project with no decisions directory simply has no decisions.
64
+ */
65
+ export declare function collectDecisions(root: string, scanned: readonly ScannedFile[], specs: readonly Spec[]): {
66
+ decisions: DecisionRecord[];
67
+ citations: DecisionCitation[];
68
+ };
56
69
  declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
57
70
  spec_create(a: any): Promise<{
58
71
  ok: boolean;
@@ -207,6 +220,11 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
207
220
  ok: boolean;
208
221
  reason: string;
209
222
  } | {
223
+ graphPreview?: {
224
+ impact?: import("../rtm/impact-advisory").ImpactAdvisory;
225
+ density?: import("../rtm/anchor-density").AnchorDensityFinding[];
226
+ graphAsOf?: string;
227
+ } | undefined;
210
228
  id: string;
211
229
  type?: string;
212
230
  status: string;
@@ -272,6 +290,7 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
272
290
  conflict: import("../spec/version-conflict").ConflictDetail;
273
291
  findings?: undefined;
274
292
  } | {
293
+ anchorDensity?: import("../rtm/anchor-density").AnchorDensityFinding[] | undefined;
275
294
  impactAdvisory?: import("../rtm/impact-advisory").ImpactAdvisory | undefined;
276
295
  approved: string;
277
296
  digest: string;
@@ -530,11 +549,6 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
530
549
  changed: string[];
531
550
  }): Promise<{
532
551
  breadthWarning?: string | undefined;
533
- impacted: string[];
534
- impactedSummaries: {
535
- id: string;
536
- summary: string | null;
537
- }[];
538
552
  rankedImpact: {
539
553
  file: string;
540
554
  score: number;
@@ -545,6 +559,12 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
545
559
  reason: "hub" | "depth";
546
560
  inDegree?: number;
547
561
  }[] | undefined;
562
+ summariesOmitted?: number | undefined;
563
+ impacted: string[];
564
+ impactedSummaries: {
565
+ id: string;
566
+ summary: string | null;
567
+ }[];
548
568
  }>;
549
569
  rtm_reindex(a: {
550
570
  root: string;
@@ -37,6 +37,7 @@ exports.HandlerRefusal = void 0;
37
37
  exports.isHandlerRefusal = isHandlerRefusal;
38
38
  exports.unreadableAmong = unreadableAmong;
39
39
  exports.makeHandlers = makeHandlers;
40
+ exports.collectDecisions = collectDecisions;
40
41
  // @implements A-SPEC-293
41
42
  // @implements A-SPEC-292
42
43
  // @implements A-SPEC-290
@@ -153,7 +154,14 @@ const cacheDirFor = (root) => {
153
154
  // by an older build is rebuilt rather than read with new assumptions.
154
155
  // @implements A-SPEC-568.1 — /3: nodes gained the intent `summary` column.
155
156
  const RTM_GRAPH_SCHEMA = 'rtm-graph/3';
156
- const RTM_EXTRACTOR_VERSION = 'holmes-rtm/1';
157
+ // @implements A-SPEC-569.3 — how many impacted specs get their intent sentence attached. A prose
158
+ // constant, never a verdict input: the impacted list itself is never truncated.
159
+ const SUMMARY_CAP = 40;
160
+ // @implements A-SPEC-569.1 (revision) — /2: pre-fix 0.16.0 builds could persist forged structural
161
+ // characters in the summary column, and every other basis field would still match after upgrading.
162
+ // A-SPEC-283's own rule applies to us too: an older build's artifact is rebuilt, never read with
163
+ // new assumptions.
164
+ const RTM_EXTRACTOR_VERSION = 'holmes-rtm/2';
157
165
  const cachedScan = (root, repoRoot = root) => new cpg_scanner_1.CpgScanner(undefined, new scan_cache_1.ScanFileCache(cacheDirFor(root))).scan(root, repoRoot);
158
166
  // @implements A-SPEC-131
159
167
  // Same scan, with the skip report kept: the callers that make honesty claims (cpg_scan's surface,
@@ -692,8 +700,24 @@ function collectDecisions(root, scanned, specs) {
692
700
  });
693
701
  }
694
702
  }
695
- catch {
696
- return { decisions: [], citations: [] };
703
+ catch { /* @implements A-SPEC-571.2 — no legacy .ax/decisions dir is not "no decisions": store
704
+ ADRs below are still a source. An unreadable dir degrades to the empty legacy set, not an early
705
+ return that would skip the store population. */
706
+ }
707
+ // @implements A-SPEC-571.2 — store ADRs JOIN the decisions population, so the existing consumers
708
+ // (DECISION nodes, constrained_by citations, supersedes) light up with no new edge kind. A store
709
+ // ADR is the canon: same id in legacy .ax/decisions is replaced (store wins).
710
+ for (const spec of specs) {
711
+ if (spec.type !== 'ADR')
712
+ continue;
713
+ const sup = spec.frontmatter?.supersedes;
714
+ const supersedes = typeof sup === 'string' && sup.trim() && sup.trim() !== 'null' ? sup.trim() : null;
715
+ const idx = decisions.findIndex((d) => d.id === spec.id);
716
+ const rec = { id: spec.id, title: spec.title, status: spec.status, supersedes };
717
+ if (idx >= 0)
718
+ decisions[idx] = rec;
719
+ else
720
+ decisions.push(rec);
697
721
  }
698
722
  const ids = new Set(decisions.map((d) => d.id));
699
723
  // @implements A-SPEC-546.1 — recognise ADR-\d{3,} (4-digit ADRs no longer invisible), via a pure fn.
@@ -1323,7 +1347,58 @@ function makeRawHandlers(store, opts) {
1323
1347
  const unreadable = typeof dir === 'string' ? (0, spec_store_1.unreadableSpecFiles)(dir) : [];
1324
1348
  return { ok: false, reason: (0, spec_store_1.notFoundReason)(a.id, unreadable) };
1325
1349
  }
1326
- return { ok: true, ...(0, approval_status_1.describeApproval)(cur.spec, resolver(all)) };
1350
+ // @implements A-SPEC-572.1
1351
+ // The graph advisory, delivered at DESIGN time. Measured on our own work: A-SPEC-571.1's
1352
+ // sealing advisory named the exact cluster three regressions then landed in — and by then
1353
+ // the design was done. The calculation was never the gap; the delivery time was. So the
1354
+ // read-only "what is blocking this right now" tool also answers "what will this scope leak,
1355
+ // and where is it dense" — using the SAME functions spec_approve calls, so the preview can
1356
+ // never disagree with the seal. Read-only stays read-only: no scan, no build, no ledger
1357
+ // append (a query must not pollute the observation denominator), and every failure degrades
1358
+ // to an absent field on an otherwise identical response.
1359
+ let graphPreview;
1360
+ try {
1361
+ if (cur.spec.type === 'A-SPEC' && a.root) {
1362
+ const dbPath = path.join(a.root, '.ax', 'rtm.sqlite');
1363
+ if (fs.existsSync(dbPath)) {
1364
+ const { declaredImpactGap } = require('../rtm/impact-advisory');
1365
+ const { anchorDensityFindings } = require('../rtm/anchor-density');
1366
+ const { filesToTouch } = require('../spec/compat-impact');
1367
+ const { RtmGraph } = require('../rtm/rtm-graph');
1368
+ const graph = new RtmGraph(dbPath);
1369
+ try {
1370
+ const ftt = filesToTouch(cur.spec);
1371
+ const impact = declaredImpactGap(ftt, graph, (rel) => { try {
1372
+ return fs.readFileSync(path.join(a.root, rel), 'utf8');
1373
+ }
1374
+ catch {
1375
+ return null;
1376
+ } });
1377
+ const density = anchorDensityFindings(ftt, graph.implementsAnchorCounts());
1378
+ if (impact || density.length > 0) {
1379
+ const graphAsOf = (() => { try {
1380
+ return fs.statSync(dbPath).mtime.toISOString();
1381
+ }
1382
+ catch {
1383
+ return undefined;
1384
+ } })();
1385
+ graphPreview = {
1386
+ ...(impact ? { impact } : {}),
1387
+ ...(density.length > 0 ? { density } : {}),
1388
+ ...(graphAsOf ? { graphAsOf } : {}),
1389
+ };
1390
+ }
1391
+ }
1392
+ finally {
1393
+ graph.close();
1394
+ }
1395
+ }
1396
+ }
1397
+ }
1398
+ catch {
1399
+ graphPreview = undefined;
1400
+ }
1401
+ return { ok: true, ...(0, approval_status_1.describeApproval)(cur.spec, resolver(all)), ...(graphPreview ? { graphPreview } : {}) };
1327
1402
  },
1328
1403
  /**
1329
1404
  * @implements A-SPEC-538.3
@@ -1691,6 +1766,7 @@ function makeRawHandlers(store, opts) {
1691
1766
  // parses or builds (scan:build measured 20~38x — an approval must not pay that), and every
1692
1767
  // failure below degrades to "no advisory field" on an otherwise identical response.
1693
1768
  let impactAdvisory;
1769
+ let anchorDensity;
1694
1770
  try {
1695
1771
  if (spec.type === 'A-SPEC' && a.root) {
1696
1772
  const dbPath = path.join(a.root, '.ax', 'rtm.sqlite');
@@ -1698,33 +1774,61 @@ function makeRawHandlers(store, opts) {
1698
1774
  const { declaredImpactGap, appendImpactAdvisory } = require('../rtm/impact-advisory');
1699
1775
  const { filesToTouch } = require('../spec/compat-impact');
1700
1776
  const { RtmGraph } = require('../rtm/rtm-graph');
1777
+ // Closed in finally (high-effort review F4): this handler lives in a long-running MCP
1778
+ // server, and an unclosed native handle per approval accumulates for the process
1779
+ // lifetime — and on Windows can hold rtm.sqlite locked against the next rebuild.
1701
1780
  const graph = new RtmGraph(dbPath);
1702
- const gap = declaredImpactGap(filesToTouch(candidate), graph, (rel) => { try {
1703
- return fs.readFileSync(path.join(a.root, rel), 'utf8');
1704
- }
1705
- catch {
1706
- return null;
1707
- } });
1708
- if (gap) {
1709
- const graphAsOf = (() => { try {
1710
- return fs.statSync(dbPath).mtime.toISOString();
1781
+ try {
1782
+ const ftt = filesToTouch(candidate);
1783
+ const gap = declaredImpactGap(ftt, graph, (rel) => { try {
1784
+ return fs.readFileSync(path.join(a.root, rel), 'utf8');
1711
1785
  }
1712
1786
  catch {
1713
- return undefined;
1714
- } })();
1715
- impactAdvisory = { ...gap, ...(graphAsOf ? { graphAsOf } : {}) };
1716
- appendImpactAdvisory(a.root, {
1717
- aspec: a.id, files: gap.files.map((f) => f.path), more: gap.more,
1718
- ...(graphAsOf ? { graphAsOf } : {}), ts: new Date().toISOString(),
1719
- });
1787
+ return null;
1788
+ } });
1789
+ if (gap) {
1790
+ const graphAsOf = (() => { try {
1791
+ return fs.statSync(dbPath).mtime.toISOString();
1792
+ }
1793
+ catch {
1794
+ return undefined;
1795
+ } })();
1796
+ impactAdvisory = { ...gap, ...(graphAsOf ? { graphAsOf } : {}) };
1797
+ appendImpactAdvisory(a.root, {
1798
+ aspec: a.id, files: gap.files.map((f) => f.path), more: gap.more,
1799
+ ...(graphAsOf ? { graphAsOf } : {}), ts: new Date().toISOString(),
1800
+ });
1801
+ }
1802
+ // @implements A-SPEC-569.5 — anchor-density OBSERVATION, same reopened graph, same
1803
+ // no-scan contract, same lifecycle as the advisory above (observe → ledger → measure
1804
+ // before anyone proposes promotion). Never a verdict input: the seal is already done,
1805
+ // and its own failure degrades to "no field" on an otherwise identical response.
1806
+ try {
1807
+ const { anchorDensityFindings, appendAnchorDensity } = require('../rtm/anchor-density');
1808
+ const findings = anchorDensityFindings(ftt, graph.implementsAnchorCounts());
1809
+ if (findings.length > 0) {
1810
+ anchorDensity = findings;
1811
+ appendAnchorDensity(a.root, {
1812
+ aspec: a.id, files: findings.map((f) => ({ path: f.path, anchors: f.anchors })),
1813
+ p90: findings[0].p90, ts: new Date().toISOString(),
1814
+ });
1815
+ }
1816
+ }
1817
+ catch {
1818
+ anchorDensity = undefined;
1819
+ }
1820
+ }
1821
+ finally {
1822
+ graph.close();
1720
1823
  }
1721
1824
  }
1722
1825
  }
1723
1826
  }
1724
1827
  catch {
1725
1828
  impactAdvisory = undefined;
1829
+ anchorDensity = undefined;
1726
1830
  }
1727
- return { approved: a.id, digest, ...(impactAdvisory ? { impactAdvisory } : {}) };
1831
+ return { approved: a.id, digest, ...(impactAdvisory ? { impactAdvisory } : {}), ...(anchorDensity ? { anchorDensity } : {}) };
1728
1832
  },
1729
1833
  async spec_list(a) {
1730
1834
  assertSpecStoreReachable('spec_list', store, a.root); // @implements A-SPEC-419
@@ -2513,7 +2617,14 @@ function makeRawHandlers(store, opts) {
2513
2617
  // was asked. Bind the derivation and use it.
2514
2618
  const root = projectRootOf(a.root);
2515
2619
  const scanned = cachedScan(root);
2516
- const specs = await store.list();
2620
+ // @implements A-SPEC-569.2 the impact/advisory graph is APPROVED-ONLY. A draft needs no
2621
+ // approval to exist, and the 0.16.0 adversarial round showed one reaching the agent-visible
2622
+ // channels (impacted closure, advisory anchor summaries) — the trust boundary for those
2623
+ // channels is the act of approval. NOT filterGoverned: that predicate passes drafts (it only
2624
+ // drops outdated/legacy), which is exactly what let this in. Diagnosis (rtm_check) and
2625
+ // matching (issue_localize / maintenance_analyze) keep their own populations — the replay
2626
+ // pins were measured on them.
2627
+ const specs = (await store.list()).filter((s) => s.status === 'approved');
2517
2628
  // @implements A-SPEC-283
2518
2629
  // Reuse the persisted graph when its basis still holds. Measured: on the warm path the graph
2519
2630
  // build is ~81% of the cost and reopening is ~0ms. `scanDigest` is the field that makes this
@@ -2540,7 +2651,12 @@ function makeRawHandlers(store, opts) {
2540
2651
  // explainImpact, not impactedBy: the bounds and the breadth signal must reach the caller.
2541
2652
  // An impact set is not just a list — a broad one means "review the contract", and a consumer
2542
2653
  // that cannot tell the difference will try to bundle two hundred call sites.
2543
- const { specs: impacted, reachedByDepth, stoppedAt, seedIsHub } = (0, rtm_builder_1.explainImpact)(g, a.changed);
2654
+ const { specs: impactedRaw, reachedByDepth, stoppedAt, seedIsHub } = (0, rtm_builder_1.explainImpact)(g, a.changed);
2655
+ // @implements A-SPEC-569.2 — the closure walks EDGES, and an implements edge is owned by
2656
+ // the code file, so an anchor naming a draft (or a spec nobody wrote) still emits one —
2657
+ // deliberately, for rtm_check's dangling diagnosis. The CHANNEL filter is node existence:
2658
+ // approved-only specs were given nodes above, so only sealed intent reaches the caller.
2659
+ const impacted = impactedRaw.filter((id) => g.hasNode(id));
2544
2660
  // @implements A-SPEC-469 — the graded FILE surface beside the spec closure, same code path
2545
2661
  // as the S-484 measurement (identity, not reimplementation). Seeds are the changed symbols'
2546
2662
  // nodes; the files that own them are excluded — a prediction naming the change itself is
@@ -2554,7 +2670,11 @@ function makeRawHandlers(store, opts) {
2554
2670
  // @implements A-SPEC-568.2 — the intent sentence beside every impacted spec id, same order
2555
2671
  // as `impacted` (which stays a bare id list for its existing consumers). Information only:
2556
2672
  // nothing reads it back into the walk, the ranking or any gate.
2557
- const impactedSummaries = impacted.map((id) => {
2673
+ // @implements A-SPEC-569.3 capped: measured on this repository, uncapped summaries were
2674
+ // 94% of a 104,706-byte response (a hub-grade impact of 339 specs). The omission is COUNTED,
2675
+ // never silent, and `impacted` itself stays complete — only the annotation is bounded.
2676
+ const shownSummaries = impacted.slice(0, SUMMARY_CAP);
2677
+ const impactedSummaries = shownSummaries.map((id) => {
2558
2678
  let summary = null;
2559
2679
  try {
2560
2680
  summary = g.summaryOf(id);
@@ -2562,9 +2682,11 @@ function makeRawHandlers(store, opts) {
2562
2682
  catch { /* summary stays null */ }
2563
2683
  return { id, summary };
2564
2684
  });
2685
+ const summariesOmitted = impacted.length - shownSummaries.length;
2565
2686
  return {
2566
2687
  impacted,
2567
2688
  impactedSummaries,
2689
+ ...(summariesOmitted > 0 ? { summariesOmitted } : {}),
2568
2690
  rankedImpact,
2569
2691
  reachedByDepth,
2570
2692
  bounded: stoppedAt.length > 0 ? stoppedAt.slice(0, 20) : undefined,
@@ -3,6 +3,7 @@ import type { Spec } from '../spec/spec-parser';
3
3
  import type { RtmGraph } from '../rtm/rtm-graph';
4
4
  import type { ContextBundle } from '../context/bundler';
5
5
  import { type TestScope } from '../rtm/test-scope';
6
+ import { type DecisionContextEntry } from '../rtm/decision-context';
6
7
  import type { ResolutionReport } from '../rtm/rtm-builder';
7
8
  import { type TestEvidence } from '../review/test-evidence';
8
9
  import { type LanguageGap } from '../cpg/language-capability';
@@ -212,6 +213,8 @@ export interface MaintenanceAnalysis {
212
213
  score: number;
213
214
  symbols: string[];
214
215
  evidence: string[];
216
+ /** @implements A-SPEC-572.3 — decisions constraining this candidate; information only. */
217
+ decisionContext?: DecisionContextEntry[];
215
218
  }>;
216
219
  /**
217
220
  * @implements A-SPEC-494 — the semantic ALTERNATES: top-3 cached-vector cosines among files
@@ -26,6 +26,11 @@ const scope_1 = require("../review/scope");
26
26
  // the measured numbers transfer to the shipped surface.
27
27
  const assoc_arm_1 = require("../assoc/assoc-arm");
28
28
  const acceptance_quality_1 = require("../spec/acceptance-quality");
29
+ // @implements A-SPEC-572.3 — the decision lookup lives in its own pure module: this file
30
+ // already carries 35 anchors (p90 is 7), and the design-time density advisory said so BEFORE
31
+ // this slice fixed its Files-to-Touch. Adding logic here would have made the file denser; the
32
+ // call site is one line, the logic is next door.
33
+ const decision_context_1 = require("../rtm/decision-context");
29
34
  const taint_1 = require("../rtm/taint");
30
35
  const test_evidence_1 = require("../review/test-evidence");
31
36
  const language_capability_1 = require("../cpg/language-capability");
@@ -377,6 +382,21 @@ function analyzeMaintenance(input) {
377
382
  symbols: sortedUnique(hit.matchedSymbols),
378
383
  evidence: [...hit.why],
379
384
  }));
385
+ // @implements A-SPEC-572.3
386
+ // "What broke" then "why is it this way" — the order a person diagnoses in. The decisions are
387
+ // already in the graph (REQ-571 put store ADRs there; A-SPEC-293 builds `constrained_by` from
388
+ // citations), so this is a lookup, not a new mechanism. Information only: it rides beside a
389
+ // candidate and never enters the score or the ordering above.
390
+ {
391
+ const anchorsOf = new Map(scanned.map((f) => [f.sourcePath, f.implementsSpecs ?? []]));
392
+ const ctx = (0, decision_context_1.decisionContextFor)(candidates.map((c) => c.file), (file) => anchorsOf.get(file) ?? [], input.graph);
393
+ for (const candidate of candidates) {
394
+ const entries = ctx.get(candidate.file);
395
+ if (entries && entries.length > 0) {
396
+ candidate.decisionContext = entries;
397
+ }
398
+ }
399
+ }
380
400
  // @implements A-SPEC-478 — the uncited semantic head rerank, exactly the arm S-495 measured:
381
401
  // same math (cosine over cached doc vectors, pool FIXED), same gate (the request cited no
382
402
  // spec). The gate is load-bearing both ways — uncited corpora gained +26%/+93% at the head,