@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 +72 -0
- package/README.md +5 -1
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/doctor.js +15 -1
- package/dist/holmes/cli/mcp-version.d.ts +4 -1
- package/dist/holmes/cli/mcp-version.js +5 -2
- package/dist/holmes/governance/autonomy.js +16 -1
- package/dist/holmes/mcp/handlers.d.ts +7 -5
- package/dist/holmes/mcp/handlers.js +75 -21
- package/dist/holmes/rtm/anchor-density.d.ts +43 -0
- package/dist/holmes/rtm/anchor-density.js +117 -0
- package/dist/holmes/rtm/impact-advisory.d.ts +4 -0
- package/dist/holmes/rtm/impact-advisory.js +9 -2
- package/dist/holmes/rtm/rtm-builder.js +16 -8
- package/dist/holmes/rtm/rtm-graph.d.ts +12 -0
- package/dist/holmes/rtm/rtm-graph.js +29 -4
- package/package.json +1 -1
- package/playbooks/publish/PLAYBOOK.md +16 -7
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
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
|
-
|
|
1714
|
-
}
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
375
|
-
...edges.map((e) => `E\t${e.src}\t${e.dst}\t${e.rel}\t${e.source_path
|
|
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.
|
|
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
|
-
타르볼에 문서가 **포함**됐는지가 아니라 **최신인지**를
|
|
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
|
-
|
|
45
|
-
- `
|
|
46
|
-
|
|
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
|
|