@holmes-lab/holmes-kit 0.15.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +5 -1
  3. package/dist/.build-id +1 -1
  4. package/dist/holmes/cli/doctor.js +15 -1
  5. package/dist/holmes/cli/mcp-version.d.ts +4 -1
  6. package/dist/holmes/cli/mcp-version.js +5 -2
  7. package/dist/holmes/governance/approval-queue.d.ts +6 -0
  8. package/dist/holmes/governance/approval-queue.js +11 -3
  9. package/dist/holmes/governance/autonomy.js +16 -1
  10. package/dist/holmes/governance/session-context.d.ts +74 -0
  11. package/dist/holmes/governance/session-context.js +179 -0
  12. package/dist/holmes/hooks/pre-tool-use.js +5 -2
  13. package/dist/holmes/hooks/rtm-refresh-child.d.ts +1 -0
  14. package/dist/holmes/hooks/rtm-refresh-child.js +56 -0
  15. package/dist/holmes/hooks/rtm-refresh.d.ts +13 -0
  16. package/dist/holmes/hooks/rtm-refresh.js +76 -0
  17. package/dist/holmes/hooks/stop.js +42 -0
  18. package/dist/holmes/mcp/handlers.d.ts +8 -7
  19. package/dist/holmes/mcp/handlers.js +114 -5
  20. package/dist/holmes/mcp/server.js +12 -0
  21. package/dist/holmes/mcp/tool-schemas.js +1 -1
  22. package/dist/holmes/review/judgement-bundle.d.ts +49 -0
  23. package/dist/holmes/review/judgement-bundle.js +108 -0
  24. package/dist/holmes/review/run-replay.d.ts +5 -0
  25. package/dist/holmes/review/run-replay.js +32 -0
  26. package/dist/holmes/rtm/anchor-density.d.ts +43 -0
  27. package/dist/holmes/rtm/anchor-density.js +117 -0
  28. package/dist/holmes/rtm/impact-advisory.d.ts +52 -0
  29. package/dist/holmes/rtm/impact-advisory.js +182 -0
  30. package/dist/holmes/rtm/localize.js +7 -0
  31. package/dist/holmes/rtm/rtm-builder.d.ts +8 -0
  32. package/dist/holmes/rtm/rtm-builder.js +50 -1
  33. package/dist/holmes/rtm/rtm-graph.d.ts +28 -1
  34. package/dist/holmes/rtm/rtm-graph.js +61 -8
  35. package/dist/holmes/spec/compat-impact.d.ts +5 -0
  36. package/dist/holmes/spec/compat-impact.js +1 -0
  37. package/package.json +1 -1
  38. package/playbooks/publish/PLAYBOOK.md +16 -7
@@ -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
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The impact advisory: what the Files-to-Touch declaration MISSES, computed from the call graph at
3
+ * the moment of sealing. jarvis proposal #1, admitted on our own measured record — of fourteen
4
+ * graph mechanisms, the only win was the IMPACT axis (PPR, three corpora), and REQ-565 established
5
+ * the "declaration vs machine" gate pattern this extends.
6
+ *
7
+ * ONE HOP, NEVER CLOSURE: caller closure measured 59 extra files and zero answers across twelve
8
+ * commits ("closure is impact, not localization"). ADVISORY, NEVER VERDICT: nothing here can change
9
+ * ok/refuse/grant (Judgments must not be budgeted). NO GUESSING: an unreadable graph yields null,
10
+ * not an estimate.
11
+ */
12
+ export interface GraphLike {
13
+ nodeIdsInFile(relPath: string): string[];
14
+ callersOf(id: string): string[];
15
+ /** @implements A-SPEC-568.2 — the node's intent sentence, or null; information only, never a verdict input. */
16
+ summaryOf(id: string): string | null;
17
+ }
18
+ export interface ImpactAdvisory {
19
+ files: Array<{
20
+ path: string;
21
+ anchors: Array<{
22
+ id: string;
23
+ summary: string | null;
24
+ }>;
25
+ /** @implements A-SPEC-569.3 — anchors beyond ANCHOR_CAP, counted rather than silently dropped. */
26
+ anchorsOmitted?: number;
27
+ }>;
28
+ more: number;
29
+ graphAsOf?: string;
30
+ }
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;
34
+ export declare function declaredImpactGap(fttFiles: string[], graph: GraphLike, readFile: (rel: string) => string | null, opts?: {
35
+ cap?: number;
36
+ }): ImpactAdvisory | null;
37
+ /**
38
+ * @implements A-SPEC-566.2
39
+ * The observation ledger: every advisory the act produced, so its false-positive rate can be
40
+ * MEASURED before anyone proposes promoting it to a hard gate (the August rtm_reindex lesson).
41
+ * Repo-relative paths, spec ids and integers only — nothing else has a field.
42
+ */
43
+ export interface AdvisoryRecord {
44
+ aspec: string;
45
+ files: string[];
46
+ more: number;
47
+ graphAsOf?: string;
48
+ ts: string;
49
+ replica?: string;
50
+ }
51
+ export declare function appendImpactAdvisory(root: string, rec: AdvisoryRecord): boolean;
52
+ export declare function readImpactAdvisories(root: string): AdvisoryRecord[];
@@ -0,0 +1,182 @@
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.ANCHOR_CAP = exports.ADVISORY_CAP = void 0;
37
+ exports.declaredImpactGap = declaredImpactGap;
38
+ exports.appendImpactAdvisory = appendImpactAdvisory;
39
+ exports.readImpactAdvisories = readImpactAdvisories;
40
+ // @implements A-SPEC-566.1
41
+ // @implements A-SPEC-566.2
42
+ const fs = __importStar(require("node:fs"));
43
+ const path = __importStar(require("node:path"));
44
+ const replica_id_1 = require("../governance/replica-id");
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;
48
+ /** The ONLY shape allowed into the ledger: `seg/seg/…` of word characters, dots and dashes —
49
+ * no leading slash, no drive letter, no backslash, no empty or `..` segment. */
50
+ function isRepoRelative(p) {
51
+ if (p === '' || p.includes('\\') || p.includes(':'))
52
+ return false;
53
+ const segs = p.split('/');
54
+ // Round-4: '.' segments made `src/./b.ts` dodge a declared `src/b.ts`, and the ASCII-only \w
55
+ // silently dropped findings in non-ASCII filenames (the 한글파일 evidence discipline of 563.3).
56
+ // Unicode letters/digits are leak-safe; dot-segments are not path characters, they are aliases.
57
+ return segs.every((s) => /^[\p{L}\p{N}_.-]+$/u.test(s) && s !== '..' && s !== '.');
58
+ }
59
+ /** `@implements <spec-id>` ids out of a file's text — the anchor idiom, read-only. */
60
+ function anchorsIn(text) {
61
+ const out = [];
62
+ for (const m of text.matchAll(/@implements\s+((?:A|T|H|C)-SPEC-[\d.]+|REQ-\d+)/g)) {
63
+ if (!out.includes(m[1]))
64
+ out.push(m[1]);
65
+ }
66
+ return out;
67
+ }
68
+ function declaredImpactGap(fttFiles, graph, readFile, opts) {
69
+ try {
70
+ const cap = opts?.cap ?? exports.ADVISORY_CAP;
71
+ const declared = new Set(fttFiles.map((f) => f.replace(/\\/g, '/')));
72
+ const outside = new Set();
73
+ for (const ftt of declared) {
74
+ for (const nodeId of graph.nodeIdsInFile(ftt)) {
75
+ for (const caller of graph.callersOf(nodeId)) {
76
+ const at = caller.lastIndexOf('@');
77
+ if (at === -1)
78
+ continue; // a node without a file cannot be a finding
79
+ const file = caller.slice(at + 1);
80
+ // Adversarial rounds 2-3: the record's no-secret contract says REPO-RELATIVE ONLY. A deny
81
+ // list (round-2: '/', 'C:', '..') let UNC paths through one round later — so this is an
82
+ // ALLOW list: plain slash-separated word segments, no '..' segment, nothing else. What
83
+ // does not look like an in-repo relative path is dropped, never normalized into one.
84
+ if (!isRepoRelative(file))
85
+ continue;
86
+ if (!declared.has(file))
87
+ outside.add(file);
88
+ }
89
+ }
90
+ }
91
+ if (outside.size === 0)
92
+ return null;
93
+ const sorted = [...outside].sort();
94
+ const shown = sorted.slice(0, cap);
95
+ return {
96
+ files: shown.map((path) => {
97
+ let ids = [];
98
+ try {
99
+ const text = readFile(path);
100
+ if (typeof text === 'string')
101
+ ids = anchorsIn(text);
102
+ }
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;
108
+ // @implements A-SPEC-568.2 — the anchor's intent sentence rides BESIDE the id, read from the
109
+ // graph S1 built. Information only: nothing below this line feeds files/more/ordering, and a
110
+ // failing lookup downgrades to null rather than killing the finding (an advisory never guesses).
111
+ return {
112
+ path,
113
+ anchors: shownIds.map((id) => {
114
+ let summary = null;
115
+ try {
116
+ summary = graph.summaryOf(`SPEC:${id}`);
117
+ }
118
+ catch { /* summary stays null */ }
119
+ return { id, summary };
120
+ }),
121
+ ...(anchorsOmitted > 0 ? { anchorsOmitted } : {}),
122
+ };
123
+ }),
124
+ more: sorted.length - shown.length,
125
+ };
126
+ }
127
+ catch {
128
+ return null;
129
+ } // an advisory never guesses
130
+ }
131
+ const ADVISORY_FILE_RE = /^impact-advisories\.([^.]+)\.jsonl$/;
132
+ function appendImpactAdvisory(root, rec) {
133
+ try {
134
+ if (!fs.existsSync(path.join(root, '.ax')))
135
+ return false;
136
+ let replica = 'local';
137
+ try {
138
+ replica = (0, replica_id_1.resolveReplicaId)(root) || 'local';
139
+ }
140
+ catch { /* keep the fallback */ }
141
+ const file = path.join(root, '.ax', 'ledger', `impact-advisories.${replica}.jsonl`);
142
+ fs.mkdirSync(path.dirname(file), { recursive: true });
143
+ fs.appendFileSync(file, `${JSON.stringify({ ...rec, replica })}\n`);
144
+ return true;
145
+ }
146
+ catch {
147
+ return false;
148
+ }
149
+ }
150
+ function readImpactAdvisories(root) {
151
+ const dir = path.join(root, '.ax', 'ledger');
152
+ let names;
153
+ try {
154
+ names = fs.readdirSync(dir).filter((n) => ADVISORY_FILE_RE.test(n)).sort();
155
+ }
156
+ catch {
157
+ return [];
158
+ }
159
+ const out = [];
160
+ for (const name of names) {
161
+ let text;
162
+ try {
163
+ text = fs.readFileSync(path.join(dir, name), 'utf8');
164
+ }
165
+ catch {
166
+ continue;
167
+ }
168
+ for (const line of text.split('\n')) {
169
+ const s = line.trim();
170
+ if (!s)
171
+ continue;
172
+ try {
173
+ const r = JSON.parse(s);
174
+ if (r && typeof r === 'object' && typeof r.aspec === 'string' && Array.isArray(r.files) && typeof r.ts === 'string') {
175
+ out.push(r);
176
+ }
177
+ }
178
+ catch { /* a corrupt line never breaks the read */ }
179
+ }
180
+ }
181
+ return out;
182
+ }
@@ -223,6 +223,13 @@ function localizeIssue(issueText, scanned, specs, topN = 10) {
223
223
  for (const id of citations.cited)
224
224
  if (!matchedSpecs.includes(id))
225
225
  matchedSpecs.push(id);
226
+ // A spec-intent-vector injection was wired here (REQ-568 S3) and REVERTED on its pre-registered
227
+ // replay: over 441 held-out traceability cases the assist was byte-identical to this lexical
228
+ // path — long queries already match ~371 specs lexically (the semantic top-3 was a subset in
229
+ // 23/23 samples), and in the lexical-zero segment the A-SPEC-399 reorder-not-admit rule forbids
230
+ // exactly the admission recovery would need. The S-491 embedding win belongs to direct
231
+ // query→file ranking, not to this layer. Do not re-add without a NEW pre-registered corpus of
232
+ // short uncited requests AND an admission rule of its own.
226
233
  const matchedSpecSet = new Set(matchedSpecs);
227
234
  // Vendored/mirrored trees (review D4): an identical copy under reference/vendor must not outrank —
228
235
  // or lexically tie-break above — the live source. Their scores are halved and ties break toward
@@ -57,6 +57,14 @@ export interface BuildRtmOptions {
57
57
  */
58
58
  fileDigest?: (sourcePath: string) => string | undefined;
59
59
  }
60
+ /**
61
+ * @implements A-SPEC-568.1
62
+ * `"<title> — <intent first sentence>"`, EXTRACTED — never generated. Same spec store, same bytes:
63
+ * the whole function is whitespace folding, one regex, one slice. A missing, blank or still-TODO
64
+ * intent section degrades to the title alone (spec_create stubs sections as `TODO`, and "TODO" as
65
+ * an intent sentence would be noise wearing a dash).
66
+ */
67
+ export declare function specSummary(s: Spec): string;
60
68
  /**
61
69
  * Adds one scanned file's CODE nodes and `implements` edges to the graph,
62
70
  * tagged with that file's sourcePath so RtmGraph.removeBySource(f.sourcePath)
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.DEFAULT_IMPACT = void 0;
37
+ exports.specSummary = specSummary;
37
38
  exports.buildFileSubgraph = buildFileSubgraph;
38
39
  exports.buildRtm = buildRtm;
39
40
  exports.addCallEdges = addCallEdges;
@@ -62,6 +63,52 @@ function fact(opts, sourceLocation, derivation, confidence, evidenceDigest = nul
62
63
  derivation,
63
64
  };
64
65
  }
66
+ /**
67
+ * @implements A-SPEC-568.1
68
+ * Which section states a spec's INTENT, per kind. Only the three kinds whose required sections
69
+ * carry a prose intent are mapped; every other kind (T-SPEC's quadrants, C-SPEC) summarizes as its
70
+ * title alone — a rule deferred until a consumer measures the lack (H-SPEC-568 Open Questions).
71
+ */
72
+ const INTENT_SECTION = {
73
+ 'REQ': 'Problem / Need',
74
+ 'H-SPEC': 'Intent',
75
+ 'A-SPEC': 'Objective',
76
+ };
77
+ /** Longest intent sentence carried into the graph — a bound, applied deterministically. */
78
+ const SUMMARY_SENTENCE_CAP = 200;
79
+ /**
80
+ * @implements A-SPEC-568.1
81
+ * `"<title> — <intent first sentence>"`, EXTRACTED — never generated. Same spec store, same bytes:
82
+ * the whole function is whitespace folding, one regex, one slice. A missing, blank or still-TODO
83
+ * intent section degrades to the title alone (spec_create stubs sections as `TODO`, and "TODO" as
84
+ * an intent sentence would be noise wearing a dash).
85
+ */
86
+ function specSummary(s) {
87
+ // The Spec type promises strings, but runtime records disagree (test fixtures without a title;
88
+ // 20 legacy store documents with title ''). Extraction is fail-open: it degrades, never throws.
89
+ const title = String(s.title ?? '').trim();
90
+ const section = INTENT_SECTION[s.type];
91
+ const text = (section ? (s.sections?.[section] ?? '') : '').replace(/\s+/g, ' ').trim();
92
+ // Measured 2026-09-07 on this store: 20 legacy specs carry title '' — the summary falls back to
93
+ // the sentence alone, then to the id, because SC1 admits no empty summary on any path.
94
+ let result;
95
+ if (text === '' || text === 'TODO')
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();
111
+ }
65
112
  /**
66
113
  * Adds one scanned file's CODE nodes and `implements` edges to the graph,
67
114
  * tagged with that file's sourcePath so RtmGraph.removeBySource(f.sourcePath)
@@ -96,7 +143,9 @@ function buildRtm(specs, scanned, graph, opts) {
96
143
  // Add spec nodes and dependencies
97
144
  for (const s of specs) {
98
145
  const sourcePath = opts?.specSourcePath?.(s.id);
99
- graph.addNode(`SPEC:${s.id}`, s.type, sourcePath, fact(opts, sourcePath ?? null, 'spec-store', 1));
146
+ // @implements A-SPEC-568.1 the intent property rides in with the node; CODE/FILE/COMMIT/
147
+ // DECISION nodes pass nothing and stay null.
148
+ graph.addNode(`SPEC:${s.id}`, s.type, sourcePath, fact(opts, sourcePath ?? null, 'spec-store', 1), specSummary(s));
100
149
  // DECLARED in frontmatter.
101
150
  for (const p of s.dependsOn) {
102
151
  graph.addEdge(`SPEC:${s.id}`, `SPEC:${p}`, 'depends_on', sourcePath, fact(opts, sourcePath ?? null, 'frontmatter', 1));
@@ -51,8 +51,19 @@ export declare class RtmGraph {
51
51
  constructor(dbPath?: string);
52
52
  private nodeStmt?;
53
53
  private edgeStmt?;
54
- addNode(id: string, kind: string, sourcePath?: string, provenance?: Provenance): void;
54
+ addNode(id: string, kind: string, sourcePath?: string, provenance?: Provenance, summary?: string | null): void;
55
+ /**
56
+ * @implements A-SPEC-568.1
57
+ * The node's intent sentence, extracted from the spec store at build time — or null: for a CODE
58
+ * node (no prose was extracted), for a node written before rtm-graph/3, for an unknown id. The
59
+ * summary is INFORMATION, never a verdict input (REQ-568 c6 rule) — nothing in this class or its
60
+ * consumers ranks, gates or filters on it.
61
+ */
62
+ summaryOf(id: string): string | null;
55
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;
56
67
  /** @implements A-SPEC-281 — how this node got here, or null if the node is unknown. */
57
68
  provenanceOfNode(id: string): Provenance | null;
58
69
  /** @implements A-SPEC-281 — how this edge got here, or null if the edge is unknown. */
@@ -72,6 +83,13 @@ export declare class RtmGraph {
72
83
  * this and not `incoming`, which would also drag in `implements` and `depends_on` and turn a
73
84
  * "who calls me" question into "what is attached to me in any way at all".
74
85
  */
86
+ /**
87
+ * @implements A-SPEC-566.1
88
+ * Every node the graph holds for one source file — the impact advisory's entry point. Exact
89
+ * `source_path` equality on the indexed column (idx_nodes_source): no LIKE, so % and _ in a path
90
+ * are literal by construction and `a.ts` can never absorb `xa.ts`.
91
+ */
92
+ nodeIdsInFile(relPath: string): string[];
75
93
  callersOf(id: string): string[];
76
94
  /**
77
95
  * @implements A-SPEC-288
@@ -197,6 +215,15 @@ export declare class RtmGraph {
197
215
  clear(): void;
198
216
  /** @implements A-SPEC-283 — the journal mode actually in force, so the pragma can be asserted. */
199
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
+ }>;
200
227
  nodeCount(): number;
201
228
  edgeCount(): number;
202
229
  close(): void;
@@ -75,7 +75,7 @@ class RtmGraph {
75
75
  this.db.pragma('journal_mode = WAL');
76
76
  this.db.pragma('busy_timeout = 5000');
77
77
  }
78
- this.db.exec(`CREATE TABLE IF NOT EXISTS nodes (id TEXT PRIMARY KEY, kind TEXT NOT NULL, source_path TEXT);
78
+ this.db.exec(`CREATE TABLE IF NOT EXISTS nodes (id TEXT PRIMARY KEY, kind TEXT NOT NULL, source_path TEXT, summary TEXT);
79
79
  CREATE TABLE IF NOT EXISTS edges (src TEXT NOT NULL, dst TEXT NOT NULL, rel TEXT NOT NULL, source_path TEXT, PRIMARY KEY (src,dst,rel));
80
80
  -- The PK covers src-prefixed lookups; nothing covered dst or source_path, so every reverse
81
81
  -- traversal and every removeBySource was a full table scan. Measured on a 240k-node graph:
@@ -103,6 +103,11 @@ class RtmGraph {
103
103
  const type = column === 'confidence' ? 'REAL' : 'TEXT';
104
104
  this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
105
105
  }
106
+ // @implements A-SPEC-568.1 — same in-place migration rule as provenance: an rtm-graph/2 file
107
+ // must keep OPENING (openReusableGraph decides reuse; an unreadable file would look corrupt).
108
+ if (table === 'nodes' && !present.has('summary')) {
109
+ this.db.exec('ALTER TABLE nodes ADD COLUMN summary TEXT');
110
+ }
106
111
  }
107
112
  }
108
113
  // @implements A-SPEC-139
@@ -113,14 +118,34 @@ class RtmGraph {
113
118
  // Semantics are unchanged: same signatures, same INSERT OR IGNORE idempotence, same columns.
114
119
  nodeStmt;
115
120
  edgeStmt;
116
- addNode(id, kind, sourcePath, provenance) {
117
- this.nodeStmt ??= this.db.prepare(`INSERT OR IGNORE INTO nodes (id,kind,source_path,${PROVENANCE_COLUMNS.join(',')}) VALUES (?,?,?,?,?,?,?,?,?,?,?)`);
118
- this.nodeStmt.run(id, kind, sourcePath ?? null, ...provenanceRow(provenance));
121
+ addNode(id, kind, sourcePath, provenance, summary) {
122
+ this.nodeStmt ??= this.db.prepare(`INSERT OR IGNORE INTO nodes (id,kind,source_path,summary,${PROVENANCE_COLUMNS.join(',')}) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`);
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));
128
+ }
129
+ /**
130
+ * @implements A-SPEC-568.1
131
+ * The node's intent sentence, extracted from the spec store at build time — or null: for a CODE
132
+ * node (no prose was extracted), for a node written before rtm-graph/3, for an unknown id. The
133
+ * summary is INFORMATION, never a verdict input (REQ-568 c6 rule) — nothing in this class or its
134
+ * consumers ranks, gates or filters on it.
135
+ */
136
+ summaryOf(id) {
137
+ const row = this.db.prepare('SELECT summary FROM nodes WHERE id = ?').get(id);
138
+ return row?.summary ?? null;
119
139
  }
120
140
  addEdge(src, dst, rel, sourcePath, provenance) {
121
141
  this.edgeStmt ??= this.db.prepare(`INSERT OR IGNORE INTO edges (src,dst,rel,source_path,${PROVENANCE_COLUMNS.join(',')}) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`);
122
142
  this.edgeStmt.run(src, dst, rel, sourcePath ?? null, ...provenanceRow(provenance));
123
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
+ }
124
149
  /** @implements A-SPEC-281 — how this node got here, or null if the node is unknown. */
125
150
  provenanceOfNode(id) {
126
151
  return provenanceOf(this.db.prepare('SELECT * FROM nodes WHERE id = ?').get(id));
@@ -149,6 +174,15 @@ class RtmGraph {
149
174
  * this and not `incoming`, which would also drag in `implements` and `depends_on` and turn a
150
175
  * "who calls me" question into "what is attached to me in any way at all".
151
176
  */
177
+ /**
178
+ * @implements A-SPEC-566.1
179
+ * Every node the graph holds for one source file — the impact advisory's entry point. Exact
180
+ * `source_path` equality on the indexed column (idx_nodes_source): no LIKE, so % and _ in a path
181
+ * are literal by construction and `a.ts` can never absorb `xa.ts`.
182
+ */
183
+ nodeIdsInFile(relPath) {
184
+ return this.db.prepare('SELECT id FROM nodes WHERE source_path = ? ORDER BY id ASC').all(relPath).map((x) => x.id);
185
+ }
152
186
  callersOf(id) {
153
187
  return this.db.prepare("SELECT DISTINCT src FROM edges WHERE dst=? AND rel='calls' ORDER BY src ASC").all(id).map(x => x.src);
154
188
  }
@@ -339,12 +373,21 @@ class RtmGraph {
339
373
  // @implements A-SPEC-281 — provenance is part of the value, so a provenance-only divergence
340
374
  // between a full rebuild and an incremental update is caught rather than passing as equal.
341
375
  const cols = PROVENANCE_COLUMNS.join(', ');
342
- const prov = (r) => PROVENANCE_COLUMNS.map((c) => (r[c] === null || r[c] === undefined ? '' : String(r[c]))).join('\t');
343
- const nodes = this.db.prepare(`SELECT id, kind, source_path, ${cols} FROM nodes ORDER BY id`).all();
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');
383
+ const nodes = this.db.prepare(`SELECT id, kind, source_path, summary, ${cols} FROM nodes ORDER BY id`).all();
344
384
  const edges = this.db.prepare(`SELECT src, dst, rel, source_path, ${cols} FROM edges ORDER BY src, dst, rel`).all();
345
385
  return [
346
- ...nodes.map((n) => `N\t${n.id}\t${n.kind}\t${n.source_path ?? ''}\t${prov(n)}`),
347
- ...edges.map((e) => `E\t${e.src}\t${e.dst}\t${e.rel}\t${e.source_path ?? ''}\t${prov(e)}`),
386
+ // @implements A-SPEC-568.1 — summary is part of the VALUE (appended last so every positional
387
+ // consumer of the earlier columns is untouched); a summary-only divergence between two builds
388
+ // must fail the convergence comparison rather than pass as equal.
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)}`),
348
391
  ].join('\n');
349
392
  }
350
393
  /**
@@ -388,6 +431,16 @@ class RtmGraph {
388
431
  journalMode() {
389
432
  return String(this.db.pragma('journal_mode', { simple: true }) ?? '');
390
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
+ }
391
444
  nodeCount() { return this.db.prepare('SELECT COUNT(*) AS c FROM nodes').get().c; }
392
445
  edgeCount() { return this.db.prepare('SELECT COUNT(*) AS c FROM edges').get().c; }
393
446
  close() { this.db.close(); }
@@ -23,4 +23,9 @@ export interface CompatCheckOpts {
23
23
  /** Repo-relative reader for the OS cross-check; null = file absent (skip, fail-open). */
24
24
  readFile?: (rel: string) => string | null;
25
25
  }
26
+ /** `src/…` path tokens out of the Files to Touch section — backticks, bullets and commas tolerated.
27
+ * Backslashes normalize to `/` FIRST (adversarial round-1): a Windows author legitimately writes
28
+ * `src\holmes\hooks\stop.ts`, and un-normalized it walked straight past the surface prefixes —
29
+ * an OS-compat gate defeated by an OS path convention would be its own counterexample. */
30
+ export declare function filesToTouch(spec: Spec): string[];
26
31
  export declare function checkCompatDeclared(spec: Spec, opts?: CompatCheckOpts): string | null;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.CELL_VERDICTS = exports.OS_CELLS = exports.HARNESS_CELLS = void 0;
4
+ exports.filesToTouch = filesToTouch;
4
5
  exports.checkCompatDeclared = checkCompatDeclared;
5
6
  /**
6
7
  * REQ-565's enforcement device: the obligation to declare HARNESS (claude/codex/agy) and OS
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.15.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",