@holmes-lab/holmes-kit 0.16.0 → 0.17.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,78 @@ 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.17.0] - 2026-09-07
9
+
10
+ An adversarial review of 0.16.0's own new surfaces, run against the shipped tarball the day it
11
+ went out, drove this release: the newest repair is always the next target, and this time the
12
+ findings were sealed before anyone else could find them. The release gate itself also gets the
13
+ fix for the incident that let 0.16.0 ship with a frozen README.
14
+
15
+ ### Fixed
16
+ - **Graph-row forgery via spec prose (REQ-569 S1, HIGH)** — a DRAFT spec (no approval needed to
17
+ exist) whose YAML double-quoted title carried `\n`/`\t` escapes could forge rows in the graph's
18
+ canonical dump, which the PPR view parsed as **real call edges** — poisoning `rankedImpact` and
19
+ `maintenance_analyze` (reproduced against the shipped 0.16.0 artifact, then killed). Sealed at
20
+ BOTH boundaries independently: `specSummary` now folds whitespace over the whole summary (title
21
+ included — the sentence-only fold was the hole), and `RtmGraph.addNode` folds structural
22
+ characters at the storage boundary so no future caller can break a dump row either. Each face
23
+ verified alone. A high-effort review of this very fix then widened it: tabs riding in via spec
24
+ `id:`/`depends_on:` still shifted dump columns, so `dumpCanonical` now folds EVERY text column
25
+ at emission (single choke point; the 13-cell row invariant is pinned by test), and the graph
26
+ extractor version bumped to `holmes-rtm/2` so a persisted store written by unfixed 0.16.0 —
27
+ whose summary column may already carry forged rows — is force-rebuilt instead of reused.
28
+ - **Grader/doctor parity pair (REQ-569 S4)** — `isHighRiskPath` folds `.` path segments before
29
+ judging (`src/holmes/./governance/x.ts` no longer dodges the risk roots; `..` is deliberately
30
+ NOT folded — the grader widens sight, never impersonates path resolution, and the enforcer
31
+ stays byte-literal). The codex doctor's npx-pin branch now compares the pin against the
32
+ installed version and WARNs on a stale pin (parity with the `.mcp.json` drift check) — an old
33
+ fixture that had enshrined the gap as PASS was repinned to the current version. Review
34
+ follow-ups sealed in the same release: a `..`-bearing Files-to-Touch token is now FLAGGED
35
+ high-risk (the hook does not canonicalize relative paths, so a `src/app/../holmes/...` spelling
36
+ stayed admissible at the byte-literal enforcer while the grader called it benign — flagging the
37
+ ambiguous ascent token closes the self-approval hole without impersonating path resolution);
38
+ non-semver pins (`@latest`, `^x.y.z`) judge `unknown` instead of an inverted "stale pin" WARN;
39
+ and the sealing-time advisory now closes its SQLite handle (a per-approval native-handle leak in
40
+ the long-lived MCP server, and a file-lock risk on Windows).
41
+ - **The docs-currency gate is bidirectional now (REQ-570)** — 0.16.0 shipped with README's
42
+ feature list frozen at "v0.14.x" and zero 0.16.0 entries, because the gate's instruction was
43
+ "grep for stale phrases": a feature never written produces zero hits, and zero hits read as
44
+ "no drift". The publish playbook now demands three checks per user-facing change — ADDITION
45
+ (the entry must exist; zero grep hits are a missing-entry signal, not a pass), drift (the old
46
+ wording), and stale markers (version-pinned section labels are drift generators and get
47
+ removed, not policed). The incident is recorded in the playbook itself, the README repaired
48
+ retroactively, and the new wording pinned by tests.
49
+
50
+ ### Changed
51
+ - **The impact/advisory graph is approved-only (REQ-569 S2)** — the persisted RTM graph that
52
+ feeds `rtm_impact`, the sealing-time advisory and the intent summaries now builds from SEALED
53
+ specs only: a draft needs no approval to exist, so it can no longer reach the agent-visible
54
+ channels (that was the forgery's delivery vehicle). The closure walks edges, so the channel
55
+ filter is node existence — an anchor naming a draft keeps its id but carries no prose until
56
+ approval. Diagnosis (`rtm_check`) and matching (`issue_localize`) keep their existing
57
+ populations; old `rtm.sqlite` files rebuild automatically on basis drift. Replay pins unmoved
58
+ to the digit.
59
+ - **Annotation caps (REQ-569 S3)** — `impactedSummaries` caps at 40 (`summariesOmitted` counts
60
+ the rest; `impacted` itself is never truncated) and advisory anchors cap at 10 per file
61
+ (`anchorsOmitted`). Measured on this repository's hub-grade impact (342 specs): the response
62
+ shrank **104,706 → 18,708 bytes (−82%)**, and the omitted tail costs no summary lookups.
63
+
64
+ ### Added
65
+ - **Anchor-density advisory (REQ-569 S5, observation only)** — sealing an A-SPEC whose
66
+ Files-to-Touch contains an anchor-dense file (live `implements` count ≥ max(8, p90 of the
67
+ store's distribution)) annotates the response with `anchorDensity: [{path, anchors, p90}]` and
68
+ records it to `anchor-density.<replica>.jsonl` (paths and integers only). Grounded in the
69
+ measured precision tax of anchor accumulation (authoring one spec moved replay recall
70
+ 0.5476→0.5060); a count GATE was considered and refused — the thresholds are prose constants,
71
+ and promotion or rejection will be decided by this ledger, the impactAdvisory lifecycle.
72
+
73
+ ### Notes
74
+ - One inherited finding was honestly killed instead of "fixed": the recorded
75
+ "scanTestAnchors only sees a file's first anchor" defect does **not reproduce** at HEAD (all
76
+ standalone-comment anchors are consumed by ART-4 and the execution-evidence attribution alike);
77
+ the only non-recognition is the trailing-comment form, which is the sealed anchor idiom rule.
78
+ Stale findings get re-measured, not re-fixed.
79
+
8
80
  ## [0.16.0] - 2026-09-07
9
81
 
10
82
  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,11 @@
14
14
 
15
15
  ---
16
16
 
17
- ### 🛡️ Currently Supported Features (v0.14.x Production Features)
17
+ ### 🛡️ Currently Supported Features (Production Features)
18
+
19
+ - 📣 **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.
20
+ - 🗣️ **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.
21
+ - 📇 **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
22
 
19
23
  - 📋 **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
24
  - 🔴 **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
+ 20f5f64-mtrggtxw
@@ -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;
@@ -272,6 +272,7 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
272
272
  conflict: import("../spec/version-conflict").ConflictDetail;
273
273
  findings?: undefined;
274
274
  } | {
275
+ anchorDensity?: import("../rtm/anchor-density").AnchorDensityFinding[] | undefined;
275
276
  impactAdvisory?: import("../rtm/impact-advisory").ImpactAdvisory | undefined;
276
277
  approved: string;
277
278
  digest: string;
@@ -530,11 +531,6 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
530
531
  changed: string[];
531
532
  }): Promise<{
532
533
  breadthWarning?: string | undefined;
533
- impacted: string[];
534
- impactedSummaries: {
535
- id: string;
536
- summary: string | null;
537
- }[];
538
534
  rankedImpact: {
539
535
  file: string;
540
536
  score: number;
@@ -545,6 +541,12 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
545
541
  reason: "hub" | "depth";
546
542
  inDegree?: number;
547
543
  }[] | undefined;
544
+ summariesOmitted?: number | undefined;
545
+ impacted: string[];
546
+ impactedSummaries: {
547
+ id: string;
548
+ summary: string | null;
549
+ }[];
548
550
  }>;
549
551
  rtm_reindex(a: {
550
552
  root: string;
@@ -153,7 +153,14 @@ const cacheDirFor = (root) => {
153
153
  // by an older build is rebuilt rather than read with new assumptions.
154
154
  // @implements A-SPEC-568.1 — /3: nodes gained the intent `summary` column.
155
155
  const RTM_GRAPH_SCHEMA = 'rtm-graph/3';
156
- const RTM_EXTRACTOR_VERSION = 'holmes-rtm/1';
156
+ // @implements A-SPEC-569.3 — how many impacted specs get their intent sentence attached. A prose
157
+ // constant, never a verdict input: the impacted list itself is never truncated.
158
+ const SUMMARY_CAP = 40;
159
+ // @implements A-SPEC-569.1 (revision) — /2: pre-fix 0.16.0 builds could persist forged structural
160
+ // characters in the summary column, and every other basis field would still match after upgrading.
161
+ // A-SPEC-283's own rule applies to us too: an older build's artifact is rebuilt, never read with
162
+ // new assumptions.
163
+ const RTM_EXTRACTOR_VERSION = 'holmes-rtm/2';
157
164
  const cachedScan = (root, repoRoot = root) => new cpg_scanner_1.CpgScanner(undefined, new scan_cache_1.ScanFileCache(cacheDirFor(root))).scan(root, repoRoot);
158
165
  // @implements A-SPEC-131
159
166
  // Same scan, with the skip report kept: the callers that make honesty claims (cpg_scan's surface,
@@ -1691,6 +1698,7 @@ function makeRawHandlers(store, opts) {
1691
1698
  // parses or builds (scan:build measured 20~38x — an approval must not pay that), and every
1692
1699
  // failure below degrades to "no advisory field" on an otherwise identical response.
1693
1700
  let impactAdvisory;
1701
+ let anchorDensity;
1694
1702
  try {
1695
1703
  if (spec.type === 'A-SPEC' && a.root) {
1696
1704
  const dbPath = path.join(a.root, '.ax', 'rtm.sqlite');
@@ -1698,33 +1706,61 @@ function makeRawHandlers(store, opts) {
1698
1706
  const { declaredImpactGap, appendImpactAdvisory } = require('../rtm/impact-advisory');
1699
1707
  const { filesToTouch } = require('../spec/compat-impact');
1700
1708
  const { RtmGraph } = require('../rtm/rtm-graph');
1709
+ // Closed in finally (high-effort review F4): this handler lives in a long-running MCP
1710
+ // server, and an unclosed native handle per approval accumulates for the process
1711
+ // lifetime — and on Windows can hold rtm.sqlite locked against the next rebuild.
1701
1712
  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();
1713
+ try {
1714
+ const ftt = filesToTouch(candidate);
1715
+ const gap = declaredImpactGap(ftt, graph, (rel) => { try {
1716
+ return fs.readFileSync(path.join(a.root, rel), 'utf8');
1717
+ }
1718
+ catch {
1719
+ return null;
1720
+ } });
1721
+ if (gap) {
1722
+ const graphAsOf = (() => { try {
1723
+ return fs.statSync(dbPath).mtime.toISOString();
1724
+ }
1725
+ catch {
1726
+ return undefined;
1727
+ } })();
1728
+ impactAdvisory = { ...gap, ...(graphAsOf ? { graphAsOf } : {}) };
1729
+ appendImpactAdvisory(a.root, {
1730
+ aspec: a.id, files: gap.files.map((f) => f.path), more: gap.more,
1731
+ ...(graphAsOf ? { graphAsOf } : {}), ts: new Date().toISOString(),
1732
+ });
1733
+ }
1734
+ // @implements A-SPEC-569.5 — anchor-density OBSERVATION, same reopened graph, same
1735
+ // no-scan contract, same lifecycle as the advisory above (observe → ledger → measure
1736
+ // before anyone proposes promotion). Never a verdict input: the seal is already done,
1737
+ // and its own failure degrades to "no field" on an otherwise identical response.
1738
+ try {
1739
+ const { anchorDensityFindings, appendAnchorDensity } = require('../rtm/anchor-density');
1740
+ const findings = anchorDensityFindings(ftt, graph.implementsAnchorCounts());
1741
+ if (findings.length > 0) {
1742
+ anchorDensity = findings;
1743
+ appendAnchorDensity(a.root, {
1744
+ aspec: a.id, files: findings.map((f) => ({ path: f.path, anchors: f.anchors })),
1745
+ p90: findings[0].p90, ts: new Date().toISOString(),
1746
+ });
1747
+ }
1711
1748
  }
1712
1749
  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
- });
1750
+ anchorDensity = undefined;
1751
+ }
1752
+ }
1753
+ finally {
1754
+ graph.close();
1720
1755
  }
1721
1756
  }
1722
1757
  }
1723
1758
  }
1724
1759
  catch {
1725
1760
  impactAdvisory = undefined;
1761
+ anchorDensity = undefined;
1726
1762
  }
1727
- return { approved: a.id, digest, ...(impactAdvisory ? { impactAdvisory } : {}) };
1763
+ return { approved: a.id, digest, ...(impactAdvisory ? { impactAdvisory } : {}), ...(anchorDensity ? { anchorDensity } : {}) };
1728
1764
  },
1729
1765
  async spec_list(a) {
1730
1766
  assertSpecStoreReachable('spec_list', store, a.root); // @implements A-SPEC-419
@@ -2513,7 +2549,14 @@ function makeRawHandlers(store, opts) {
2513
2549
  // was asked. Bind the derivation and use it.
2514
2550
  const root = projectRootOf(a.root);
2515
2551
  const scanned = cachedScan(root);
2516
- const specs = await store.list();
2552
+ // @implements A-SPEC-569.2 the impact/advisory graph is APPROVED-ONLY. A draft needs no
2553
+ // approval to exist, and the 0.16.0 adversarial round showed one reaching the agent-visible
2554
+ // channels (impacted closure, advisory anchor summaries) — the trust boundary for those
2555
+ // channels is the act of approval. NOT filterGoverned: that predicate passes drafts (it only
2556
+ // drops outdated/legacy), which is exactly what let this in. Diagnosis (rtm_check) and
2557
+ // matching (issue_localize / maintenance_analyze) keep their own populations — the replay
2558
+ // pins were measured on them.
2559
+ const specs = (await store.list()).filter((s) => s.status === 'approved');
2517
2560
  // @implements A-SPEC-283
2518
2561
  // Reuse the persisted graph when its basis still holds. Measured: on the warm path the graph
2519
2562
  // build is ~81% of the cost and reopening is ~0ms. `scanDigest` is the field that makes this
@@ -2540,7 +2583,12 @@ function makeRawHandlers(store, opts) {
2540
2583
  // explainImpact, not impactedBy: the bounds and the breadth signal must reach the caller.
2541
2584
  // An impact set is not just a list — a broad one means "review the contract", and a consumer
2542
2585
  // 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);
2586
+ const { specs: impactedRaw, reachedByDepth, stoppedAt, seedIsHub } = (0, rtm_builder_1.explainImpact)(g, a.changed);
2587
+ // @implements A-SPEC-569.2 — the closure walks EDGES, and an implements edge is owned by
2588
+ // the code file, so an anchor naming a draft (or a spec nobody wrote) still emits one —
2589
+ // deliberately, for rtm_check's dangling diagnosis. The CHANNEL filter is node existence:
2590
+ // approved-only specs were given nodes above, so only sealed intent reaches the caller.
2591
+ const impacted = impactedRaw.filter((id) => g.hasNode(id));
2544
2592
  // @implements A-SPEC-469 — the graded FILE surface beside the spec closure, same code path
2545
2593
  // as the S-484 measurement (identity, not reimplementation). Seeds are the changed symbols'
2546
2594
  // nodes; the files that own them are excluded — a prediction naming the change itself is
@@ -2554,7 +2602,11 @@ function makeRawHandlers(store, opts) {
2554
2602
  // @implements A-SPEC-568.2 — the intent sentence beside every impacted spec id, same order
2555
2603
  // as `impacted` (which stays a bare id list for its existing consumers). Information only:
2556
2604
  // nothing reads it back into the walk, the ranking or any gate.
2557
- const impactedSummaries = impacted.map((id) => {
2605
+ // @implements A-SPEC-569.3 capped: measured on this repository, uncapped summaries were
2606
+ // 94% of a 104,706-byte response (a hub-grade impact of 339 specs). The omission is COUNTED,
2607
+ // never silent, and `impacted` itself stays complete — only the annotation is bounded.
2608
+ const shownSummaries = impacted.slice(0, SUMMARY_CAP);
2609
+ const impactedSummaries = shownSummaries.map((id) => {
2558
2610
  let summary = null;
2559
2611
  try {
2560
2612
  summary = g.summaryOf(id);
@@ -2562,9 +2614,11 @@ function makeRawHandlers(store, opts) {
2562
2614
  catch { /* summary stays null */ }
2563
2615
  return { id, summary };
2564
2616
  });
2617
+ const summariesOmitted = impacted.length - shownSummaries.length;
2565
2618
  return {
2566
2619
  impacted,
2567
2620
  impactedSummaries,
2621
+ ...(summariesOmitted > 0 ? { summariesOmitted } : {}),
2568
2622
  rankedImpact,
2569
2623
  reachedByDepth,
2570
2624
  bounded: stoppedAt.length > 0 ? stoppedAt.slice(0, 20) : undefined,
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Anchor-density advisory — OBSERVATION ONLY, never a gate.
3
+ *
4
+ * The measured ground: anchor density taxes localization precision (authoring ONE spec moved
5
+ * replay recall 0.5476→0.5060; A-SPEC-270's √-dilution exists because a 113-anchor file brushes
6
+ * some spec for almost any request). A count GATE was considered and REFUSED (position-dependent
7
+ * refusals, an incentive to stop anchoring, mechanical file splits) — so this surfaces the fact at
8
+ * sealing time and records it, and the observation ledger decides any future promotion, exactly
9
+ * the impactAdvisory lifecycle. The thresholds below are prose constants: nothing reads them into
10
+ * a verdict (judgments must not be budgeted).
11
+ */
12
+ export interface AnchorDensityFinding {
13
+ path: string;
14
+ anchors: number;
15
+ p90: number;
16
+ }
17
+ /** Files below this live-anchor count are never flagged, whatever the distribution — a floor. */
18
+ export declare const MIN_ANCHORS = 8;
19
+ /**
20
+ * FtT files whose live anchor count sits at or above max(MIN_ANCHORS, p90 of the distribution).
21
+ * p90 is the value at index ceil(0.9·n)-1 of the ascending counts — deterministic, no interpolation.
22
+ * Pure: same inputs, same findings, in sorted path order.
23
+ */
24
+ export declare function anchorDensityFindings(fttFiles: string[], counts: Array<{
25
+ sourcePath: string;
26
+ anchors: number;
27
+ }>): AnchorDensityFinding[];
28
+ /**
29
+ * The observation ledger — paths and integers only, no prose, no secrets: what a future
30
+ * promotion/rejection judgment will be measured on.
31
+ */
32
+ export interface AnchorDensityRecord {
33
+ aspec: string;
34
+ files: Array<{
35
+ path: string;
36
+ anchors: number;
37
+ }>;
38
+ p90: number;
39
+ ts: string;
40
+ replica?: string;
41
+ }
42
+ export declare function appendAnchorDensity(root: string, rec: AnchorDensityRecord): boolean;
43
+ export declare function readAnchorDensity(root: string): AnchorDensityRecord[];
@@ -0,0 +1,117 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.MIN_ANCHORS = void 0;
37
+ exports.anchorDensityFindings = anchorDensityFindings;
38
+ exports.appendAnchorDensity = appendAnchorDensity;
39
+ exports.readAnchorDensity = readAnchorDensity;
40
+ // @implements A-SPEC-569.5
41
+ const fs = __importStar(require("node:fs"));
42
+ const path = __importStar(require("node:path"));
43
+ const replica_id_1 = require("../governance/replica-id");
44
+ /** Files below this live-anchor count are never flagged, whatever the distribution — a floor. */
45
+ exports.MIN_ANCHORS = 8;
46
+ /**
47
+ * FtT files whose live anchor count sits at or above max(MIN_ANCHORS, p90 of the distribution).
48
+ * p90 is the value at index ceil(0.9·n)-1 of the ascending counts — deterministic, no interpolation.
49
+ * Pure: same inputs, same findings, in sorted path order.
50
+ */
51
+ function anchorDensityFindings(fttFiles, counts) {
52
+ if (counts.length === 0 || fttFiles.length === 0)
53
+ return [];
54
+ const sorted = counts.map((c) => c.anchors).sort((a, b) => a - b);
55
+ const p90 = sorted[Math.ceil(0.9 * sorted.length) - 1];
56
+ const threshold = Math.max(exports.MIN_ANCHORS, p90);
57
+ const byPath = new Map(counts.map((c) => [c.sourcePath, c.anchors]));
58
+ const out = [];
59
+ for (const f of [...new Set(fttFiles.map((p) => p.replace(/\\/g, '/')))].sort()) {
60
+ const anchors = byPath.get(f);
61
+ if (anchors !== undefined && anchors >= threshold)
62
+ out.push({ path: f, anchors, p90 });
63
+ }
64
+ return out;
65
+ }
66
+ const DENSITY_FILE_RE = /^anchor-density\.([^.]+)\.jsonl$/;
67
+ function appendAnchorDensity(root, rec) {
68
+ try {
69
+ if (!fs.existsSync(path.join(root, '.ax')))
70
+ return false;
71
+ let replica = 'local';
72
+ try {
73
+ replica = (0, replica_id_1.resolveReplicaId)(root) || 'local';
74
+ }
75
+ catch { /* keep the fallback */ }
76
+ const file = path.join(root, '.ax', 'ledger', `anchor-density.${replica}.jsonl`);
77
+ fs.mkdirSync(path.dirname(file), { recursive: true });
78
+ fs.appendFileSync(file, `${JSON.stringify({ ...rec, replica })}\n`);
79
+ return true;
80
+ }
81
+ catch {
82
+ return false;
83
+ }
84
+ }
85
+ function readAnchorDensity(root) {
86
+ const dir = path.join(root, '.ax', 'ledger');
87
+ let names;
88
+ try {
89
+ names = fs.readdirSync(dir).filter((n) => DENSITY_FILE_RE.test(n)).sort();
90
+ }
91
+ catch {
92
+ return [];
93
+ }
94
+ const out = [];
95
+ for (const name of names) {
96
+ let text;
97
+ try {
98
+ text = fs.readFileSync(path.join(dir, name), 'utf8');
99
+ }
100
+ catch {
101
+ continue;
102
+ }
103
+ for (const line of text.split('\n')) {
104
+ const s = line.trim();
105
+ if (!s)
106
+ continue;
107
+ try {
108
+ const r = JSON.parse(s);
109
+ if (r && typeof r === 'object' && typeof r.aspec === 'string' && Array.isArray(r.files) && typeof r.ts === 'string') {
110
+ out.push(r);
111
+ }
112
+ }
113
+ catch { /* a corrupt line never breaks the read */ }
114
+ }
115
+ }
116
+ return out;
117
+ }
@@ -22,11 +22,15 @@ export interface ImpactAdvisory {
22
22
  id: string;
23
23
  summary: string | null;
24
24
  }>;
25
+ /** @implements A-SPEC-569.3 — anchors beyond ANCHOR_CAP, counted rather than silently dropped. */
26
+ anchorsOmitted?: number;
25
27
  }>;
26
28
  more: number;
27
29
  graphAsOf?: string;
28
30
  }
29
31
  export declare const ADVISORY_CAP = 10;
32
+ /** @implements A-SPEC-569.3 — anchors annotated per advisory file; a prose constant, never a verdict input. */
33
+ export declare const ANCHOR_CAP = 10;
30
34
  export declare function declaredImpactGap(fttFiles: string[], graph: GraphLike, readFile: (rel: string) => string | null, opts?: {
31
35
  cap?: number;
32
36
  }): ImpactAdvisory | null;
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.ADVISORY_CAP = void 0;
36
+ exports.ANCHOR_CAP = exports.ADVISORY_CAP = void 0;
37
37
  exports.declaredImpactGap = declaredImpactGap;
38
38
  exports.appendImpactAdvisory = appendImpactAdvisory;
39
39
  exports.readImpactAdvisories = readImpactAdvisories;
@@ -43,6 +43,8 @@ const fs = __importStar(require("node:fs"));
43
43
  const path = __importStar(require("node:path"));
44
44
  const replica_id_1 = require("../governance/replica-id");
45
45
  exports.ADVISORY_CAP = 10;
46
+ /** @implements A-SPEC-569.3 — anchors annotated per advisory file; a prose constant, never a verdict input. */
47
+ exports.ANCHOR_CAP = 10;
46
48
  /** The ONLY shape allowed into the ledger: `seg/seg/…` of word characters, dots and dashes —
47
49
  * no leading slash, no drive letter, no backslash, no empty or `..` segment. */
48
50
  function isRepoRelative(p) {
@@ -99,12 +101,16 @@ function declaredImpactGap(fttFiles, graph, readFile, opts) {
99
101
  ids = anchorsIn(text);
100
102
  }
101
103
  catch { /* anchors stay [] */ }
104
+ // @implements A-SPEC-569.3 — capped BEFORE the summary lookups, so the omitted tail costs
105
+ // no SELECTs either; the omission is counted, never silent.
106
+ const shownIds = ids.slice(0, exports.ANCHOR_CAP);
107
+ const anchorsOmitted = ids.length - shownIds.length;
102
108
  // @implements A-SPEC-568.2 — the anchor's intent sentence rides BESIDE the id, read from the
103
109
  // graph S1 built. Information only: nothing below this line feeds files/more/ordering, and a
104
110
  // failing lookup downgrades to null rather than killing the finding (an advisory never guesses).
105
111
  return {
106
112
  path,
107
- anchors: ids.map((id) => {
113
+ anchors: shownIds.map((id) => {
108
114
  let summary = null;
109
115
  try {
110
116
  summary = graph.summaryOf(`SPEC:${id}`);
@@ -112,6 +118,7 @@ function declaredImpactGap(fttFiles, graph, readFile, opts) {
112
118
  catch { /* summary stays null */ }
113
119
  return { id, summary };
114
120
  }),
121
+ ...(anchorsOmitted > 0 ? { anchorsOmitted } : {}),
115
122
  };
116
123
  }),
117
124
  more: sorted.length - shown.length,
@@ -91,15 +91,23 @@ function specSummary(s) {
91
91
  const text = (section ? (s.sections?.[section] ?? '') : '').replace(/\s+/g, ' ').trim();
92
92
  // Measured 2026-09-07 on this store: 20 legacy specs carry title '' — the summary falls back to
93
93
  // the sentence alone, then to the id, because SC1 admits no empty summary on any path.
94
+ let result;
94
95
  if (text === '' || text === 'TODO')
95
- return title !== '' ? title : s.id;
96
- // First sentence: up to the first . ! or ? that ends a word — the lookahead keeps `A-SPEC-129.2`
97
- // whole, because its dot is followed by a digit, not by whitespace or the end.
98
- const m = /^(.*?[.!?])(?=\s|$)/.exec(text);
99
- let sentence = m ? m[1] : text;
100
- if (sentence.length > SUMMARY_SENTENCE_CAP)
101
- sentence = `${sentence.slice(0, SUMMARY_SENTENCE_CAP)}…`;
102
- return title !== '' ? `${title} — ${sentence}` : sentence;
96
+ result = title !== '' ? title : s.id;
97
+ else {
98
+ // First sentence: up to the first . ! or ? that ends a word — the lookahead keeps `A-SPEC-129.2`
99
+ // whole, because its dot is followed by a digit, not by whitespace or the end.
100
+ const m = /^(.*?[.!?])(?=\s|$)/.exec(text);
101
+ let sentence = m ? m[1] : text;
102
+ if (sentence.length > SUMMARY_SENTENCE_CAP)
103
+ sentence = `${sentence.slice(0, SUMMARY_SENTENCE_CAP)}…`;
104
+ result = title !== '' ? `${title} — ${sentence}` : sentence;
105
+ }
106
+ // @implements A-SPEC-569.1 — the WHOLE summary folds, title included. The first cut normalized
107
+ // only the sentence, and a YAML double-quoted title carried \n/\t straight into dumpCanonical,
108
+ // where graphViewOf parsed the forged row as a real call edge (0.16.0 adversarial reproduction).
109
+ // Prose must never carry the dump's structural characters.
110
+ return result.replace(/\s+/g, ' ').trim();
103
111
  }
104
112
  /**
105
113
  * Adds one scanned file's CODE nodes and `implements` edges to the graph,
@@ -61,6 +61,9 @@ export declare class RtmGraph {
61
61
  */
62
62
  summaryOf(id: string): string | null;
63
63
  addEdge(src: string, dst: string, rel: string, sourcePath?: string, provenance?: Provenance): void;
64
+ /** @implements A-SPEC-569.1 — does the graph hold this node? SELECT 1, no row materialization:
65
+ * the approved-only channel filter (A-SPEC-569.2) asks exactly this and nothing more. */
66
+ hasNode(id: string): boolean;
64
67
  /** @implements A-SPEC-281 — how this node got here, or null if the node is unknown. */
65
68
  provenanceOfNode(id: string): Provenance | null;
66
69
  /** @implements A-SPEC-281 — how this edge got here, or null if the edge is unknown. */
@@ -212,6 +215,15 @@ export declare class RtmGraph {
212
215
  clear(): void;
213
216
  /** @implements A-SPEC-283 — the journal mode actually in force, so the pragma can be asserted. */
214
217
  journalMode(): string;
218
+ /**
219
+ * @implements A-SPEC-569.5
220
+ * Live anchors per source file — distinct SPEC targets of `implements` edges, one GROUP BY.
221
+ * Feeds the anchor-density OBSERVATION at sealing time; nothing reads it into a verdict.
222
+ */
223
+ implementsAnchorCounts(): Array<{
224
+ sourcePath: string;
225
+ anchors: number;
226
+ }>;
215
227
  nodeCount(): number;
216
228
  edgeCount(): number;
217
229
  close(): void;
@@ -120,7 +120,11 @@ class RtmGraph {
120
120
  edgeStmt;
121
121
  addNode(id, kind, sourcePath, provenance, summary) {
122
122
  this.nodeStmt ??= this.db.prepare(`INSERT OR IGNORE INTO nodes (id,kind,source_path,summary,${PROVENANCE_COLUMNS.join(',')}) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`);
123
- this.nodeStmt.run(id, kind, sourcePath ?? null, summary ?? null, ...provenanceRow(provenance));
123
+ // @implements A-SPEC-569.1 the STORAGE boundary folds structural characters too, so a future
124
+ // caller that never went through specSummary still cannot forge dumpCanonical rows. Second,
125
+ // independent face of the seal (each face is verified alone).
126
+ const foldedSummary = summary == null ? null : summary.replace(/\s+/g, ' ').trim();
127
+ this.nodeStmt.run(id, kind, sourcePath ?? null, foldedSummary, ...provenanceRow(provenance));
124
128
  }
125
129
  /**
126
130
  * @implements A-SPEC-568.1
@@ -137,6 +141,11 @@ class RtmGraph {
137
141
  this.edgeStmt ??= this.db.prepare(`INSERT OR IGNORE INTO edges (src,dst,rel,source_path,${PROVENANCE_COLUMNS.join(',')}) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`);
138
142
  this.edgeStmt.run(src, dst, rel, sourcePath ?? null, ...provenanceRow(provenance));
139
143
  }
144
+ /** @implements A-SPEC-569.1 — does the graph hold this node? SELECT 1, no row materialization:
145
+ * the approved-only channel filter (A-SPEC-569.2) asks exactly this and nothing more. */
146
+ hasNode(id) {
147
+ return this.db.prepare('SELECT 1 FROM nodes WHERE id = ?').get(id) !== undefined;
148
+ }
140
149
  /** @implements A-SPEC-281 — how this node got here, or null if the node is unknown. */
141
150
  provenanceOfNode(id) {
142
151
  return provenanceOf(this.db.prepare('SELECT * FROM nodes WHERE id = ?').get(id));
@@ -364,15 +373,21 @@ class RtmGraph {
364
373
  // @implements A-SPEC-281 — provenance is part of the value, so a provenance-only divergence
365
374
  // between a full rebuild and an incremental update is caught rather than passing as equal.
366
375
  const cols = PROVENANCE_COLUMNS.join(', ');
367
- const prov = (r) => PROVENANCE_COLUMNS.map((c) => (r[c] === null || r[c] === undefined ? '' : String(r[c]))).join('\t');
376
+ // @implements A-SPEC-569.1 (revision) EVERY text column folds at emission. Sealing columns
377
+ // one at a time (title → summary) was whack-a-mole: high-effort review found id/depends_on
378
+ // carrying tabs into these rows and shifting columns under graphViewOf. The dump's structural
379
+ // characters are the dump's own concern, so this is the single choke point; the row-shape
380
+ // invariant (exactly 13 cells, structural-char-free) is pinned by test.
381
+ const fold = (v) => (v === null || v === undefined ? '' : String(v).replace(/[\t\n\r]+/g, ' '));
382
+ const prov = (r) => PROVENANCE_COLUMNS.map((c) => fold(r[c])).join('\t');
368
383
  const nodes = this.db.prepare(`SELECT id, kind, source_path, summary, ${cols} FROM nodes ORDER BY id`).all();
369
384
  const edges = this.db.prepare(`SELECT src, dst, rel, source_path, ${cols} FROM edges ORDER BY src, dst, rel`).all();
370
385
  return [
371
386
  // @implements A-SPEC-568.1 — summary is part of the VALUE (appended last so every positional
372
387
  // consumer of the earlier columns is untouched); a summary-only divergence between two builds
373
388
  // must fail the convergence comparison rather than pass as equal.
374
- ...nodes.map((n) => `N\t${n.id}\t${n.kind}\t${n.source_path ?? ''}\t${prov(n)}\t${n.summary ?? ''}`),
375
- ...edges.map((e) => `E\t${e.src}\t${e.dst}\t${e.rel}\t${e.source_path ?? ''}\t${prov(e)}`),
389
+ ...nodes.map((n) => `N\t${fold(n.id)}\t${fold(n.kind)}\t${fold(n.source_path)}\t${prov(n)}\t${fold(n.summary)}`),
390
+ ...edges.map((e) => `E\t${fold(e.src)}\t${fold(e.dst)}\t${fold(e.rel)}\t${fold(e.source_path)}\t${prov(e)}`),
376
391
  ].join('\n');
377
392
  }
378
393
  /**
@@ -416,6 +431,16 @@ class RtmGraph {
416
431
  journalMode() {
417
432
  return String(this.db.pragma('journal_mode', { simple: true }) ?? '');
418
433
  }
434
+ /**
435
+ * @implements A-SPEC-569.5
436
+ * Live anchors per source file — distinct SPEC targets of `implements` edges, one GROUP BY.
437
+ * Feeds the anchor-density OBSERVATION at sealing time; nothing reads it into a verdict.
438
+ */
439
+ implementsAnchorCounts() {
440
+ return this.db.prepare(`SELECT source_path AS sourcePath, COUNT(DISTINCT dst) AS anchors FROM edges
441
+ WHERE rel='implements' AND source_path IS NOT NULL GROUP BY source_path ORDER BY source_path ASC`)
442
+ .all();
443
+ }
419
444
  nodeCount() { return this.db.prepare('SELECT COUNT(*) AS c FROM nodes').get().c; }
420
445
  edgeCount() { return this.db.prepare('SELECT COUNT(*) AS c FROM edges').get().c; }
421
446
  close() { this.db.close(); }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "@implements A-SPEC-209",
3
3
  "name": "@holmes-lab/holmes-kit",
4
- "version": "0.16.0",
4
+ "version": "0.17.0",
5
5
  "description": "Holmes-Kit — deterministic Agentic Software Engineering (ASE) harness with causal traceability (spec chain + D-CPG + RTM + phase guardrail)",
6
6
  "main": "dist/holmes/mcp/server.js",
7
7
  "types": "dist/holmes/mcp/server.d.ts",
@@ -37,15 +37,24 @@ npm publish 는 **비가역·외부노출**이라 기본은 HITL(사람 승인)
37
37
  ---
38
38
 
39
39
  ### 2.5단계: 문서 정합성 게이트 (Docs Currency Gate) — 배포는 정직한 고지다
40
- 타르볼에 문서가 **포함**됐는지가 아니라 **최신인지**를 diff로 검사한다(이 단계 없이 폐기된 동작이 README에
41
- 현재형으로 남는 사고가 실제로 있었다):
40
+ 타르볼에 문서가 **포함**됐는지가 아니라 **최신인지**를 검사한다. 부정직은 방향이다: 거짓을
41
+ 남기는 것(drift)과 참을 숨기는 것(absence) — 둘 다 차단한다. (사고 이력 둘: 폐기된 동작이
42
+ README 에 현재형으로 남은 사고, 그리고 **0.16.0 — 신기능 3건이 README 기능 목록에 아예 없는데
43
+ "옛 문구 grep 0건"이 통과로 읽혀 그대로 배포된 사고.**)
42
44
  1. 직전 릴리스 태그 이후 승인된 스펙 열거: `git log <last-tag>..HEAD --name-only -- .ax/specs/03_a-spec/`.
43
- 2. 각 A-SPEC 중 **사용자-대면**(CLI 명령/플래그, 동작 변경, env 스위치, 게이트 행동)인 것마다:
44
- - `CHANGELOG.md` 이번 버전 항목이 변화를 기술하는가.
45
- - `README.md` 기능 목록/CLI 치트시트가 명령·플래그를 담고, **폐기된 동작을 현재형으로 서술하지
46
- 않는가**(바뀐 동작의 문구를 `grep` 으로 점검).
45
+ 2. 각 A-SPEC 중 **사용자-대면**(CLI 명령/플래그, MCP 응답 필드, 동작 변경, env 스위치, 게이트
46
+ 행동, 신규 원장/아티팩트)인 것마다 **세 검사를 모두**:
47
+ - **(2a) 추가-검사(absence)**: `CHANGELOG.md` 이번 버전 항목과 `README.md` 기능 목록에
48
+ 변화의 항목이 **존재**하는가. 기능 이름으로 `grep` 해서 **0건이면 그것은 '통과'가 아니라
49
+ 누락 신호다** — 쓴 적 없는 기능은 옛 문구도 없다.
50
+ - **(2b) drift-검사**: 바뀐 동작의 **옛 문구**를 `grep` 으로 점검 — 폐기된 동작을 현재형으로
51
+ 서술하지 않는가.
52
+ - **(2c) 스테일 마커**: 기능 섹션 제목 등의 **버전-고정 라벨**("vX.Y.x Features" 류)은 매
53
+ 릴리스 수동 갱신을 요구하는 drift 발생기다 — 발견 즉시 버전-무관 표현으로 제거한다(항목별
54
+ *(new in X.Y.Z)* 가 시점을 말한다).
47
55
  > [!CAUTION]
48
- > 사용자-대면 변화가 CHANGELOG/README 에 반영되지 않았으면 배포 중단 — 문서 drift 거짓 주장이다.
56
+ > 사용자-대면 변화가 CHANGELOG/README 에 반영되지 않았으면 배포 중단 — 문서 drift 도, 문서
57
+ > 누락도 거짓 주장이다.
49
58
 
50
59
  ---
51
60