@holmes-lab/holmes-kit 0.21.0 → 0.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +8 -0
  3. package/dist/.build-id +1 -1
  4. package/dist/holmes/cli/agents.d.ts +38 -0
  5. package/dist/holmes/cli/agents.js +106 -4
  6. package/dist/holmes/cli/doctor.d.ts +1 -0
  7. package/dist/holmes/cli/doctor.js +42 -0
  8. package/dist/holmes/cli/index.js +1 -0
  9. package/dist/holmes/cli/init.js +18 -0
  10. package/dist/holmes/cpg/language-parser-walk.js +1 -0
  11. package/dist/holmes/hooks/stop.d.ts +7 -0
  12. package/dist/holmes/hooks/stop.js +54 -1
  13. package/dist/holmes/mcp/handlers/operator-inspection.d.ts +29 -1
  14. package/dist/holmes/mcp/handlers/operator-inspection.js +68 -2
  15. package/dist/holmes/mcp/handlers/spec-approval.d.ts +5 -0
  16. package/dist/holmes/mcp/handlers/spec-approval.js +59 -1
  17. package/dist/holmes/mcp/handlers/test-execution.d.ts +4 -0
  18. package/dist/holmes/mcp/handlers/test-execution.js +6 -2
  19. package/dist/holmes/mcp/handlers.d.ts +33 -1
  20. package/dist/holmes/mcp/handlers.js +1 -0
  21. package/dist/holmes/mcp/maintenance-analyze.js +1 -0
  22. package/dist/holmes/mcp/supervisor.d.ts +35 -0
  23. package/dist/holmes/mcp/supervisor.js +105 -2
  24. package/dist/holmes/mcp/tool-schemas.js +1 -0
  25. package/dist/holmes/project/ci-runs.d.ts +46 -0
  26. package/dist/holmes/project/ci-runs.js +137 -0
  27. package/dist/holmes/project/install-scripts-policy.js +1 -0
  28. package/dist/holmes/review/evaluation-metrics.js +1 -0
  29. package/dist/holmes/review/kills-check.d.ts +40 -0
  30. package/dist/holmes/review/kills-check.js +147 -0
  31. package/dist/holmes/review/manual-baseline.js +1 -0
  32. package/dist/holmes/rtm/advisory-outcomes.d.ts +137 -0
  33. package/dist/holmes/rtm/advisory-outcomes.js +314 -0
  34. package/dist/holmes/rtm/declared-scope.d.ts +28 -0
  35. package/dist/holmes/rtm/declared-scope.js +60 -0
  36. package/dist/holmes/rtm/rtm-graph.js +1 -0
  37. package/dist/holmes/rtm/taint-benchmark.js +1 -0
  38. package/dist/holmes/server/dashboard-launcher.d.ts +7 -0
  39. package/dist/holmes/server/dashboard-launcher.js +3 -0
  40. package/dist/holmes/server/dashboard.js +14 -1
  41. package/package.json +1 -1
  42. package/playbooks/author-slice/PLAYBOOK.md +19 -0
@@ -0,0 +1,314 @@
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.ADVISORY_KINDS = void 0;
37
+ exports.canonical = canonical;
38
+ exports.advisoryId = advisoryId;
39
+ exports.foldAdvisoryState = foldAdvisoryState;
40
+ exports.judgeAdvisories = judgeAdvisories;
41
+ exports.dismissAdvisories = dismissAdvisories;
42
+ exports.advisoryCensus = advisoryCensus;
43
+ exports.appendAdvisoryOutcomes = appendAdvisoryOutcomes;
44
+ exports.readAdvisoryOutcomes = readAdvisoryOutcomes;
45
+ exports.traceGapAdvisories = traceGapAdvisories;
46
+ exports.currentAdvisoryKeys = currentAdvisoryKeys;
47
+ exports.recordAdvisoryOutcomes = recordAdvisoryOutcomes;
48
+ exports.traceGapFiles = traceGapFiles;
49
+ exports.gitHeadOf = gitHeadOf;
50
+ exports.traceGapFor = traceGapFor;
51
+ // @implements A-SPEC-663
52
+ /**
53
+ * The advisory REACTION ledger — the numerator every promotion decision has been missing.
54
+ *
55
+ * This repository already issues five advisory kinds (impact-advisory, anchor-density, ftt-fulfilment,
56
+ * trace-gap, kills-unapplicable) and records the ISSUE of most of them. Measured 2026-09-18: 77
57
+ * impact-advisory rows, 76 anchor-density rows, and not one row anywhere saying what the author did
58
+ * next. Tricorder's "not useful" button and Google's "productive mutant" metric count the reaction,
59
+ * not the emission; without that count "promote to a hard gate once the ledger answers the false-
60
+ * positive rate" has no ledger to ask.
61
+ *
62
+ * Pure: identity, judgement and folding take rows in and give rows out. The append/read helpers are
63
+ * the only I/O and follow the other advisory ledgers (per-replica jsonl, repo-relative content only).
64
+ */
65
+ const crypto = __importStar(require("node:crypto"));
66
+ const node_child_process_1 = require("node:child_process");
67
+ const fs = __importStar(require("node:fs"));
68
+ const path = __importStar(require("node:path"));
69
+ const replica_id_1 = require("../governance/replica-id");
70
+ const ftt_fulfilment_1 = require("./ftt-fulfilment");
71
+ const trace_gaps_1 = require("./trace-gaps");
72
+ /** Canonical JSON: sorted keys, arrays kept in order — the same payload always gives the same id. */
73
+ function canonical(value) {
74
+ if (Array.isArray(value))
75
+ return `[${value.map(canonical).join(',')}]`;
76
+ if (value && typeof value === 'object') {
77
+ const o = value;
78
+ return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${canonical(o[k])}`).join(',')}}`;
79
+ }
80
+ return JSON.stringify(value);
81
+ }
82
+ /** Deterministic identity of one finding: kind + spec + canonical payload → sha256, first 16 hex. */
83
+ function advisoryId(kind, aspec, payload) {
84
+ return crypto.createHash('sha256').update(`${kind}|${aspec}|${canonical(payload)}`).digest('hex').slice(0, 16);
85
+ }
86
+ /** Latest state per id, in first-seen order. `dismissed` and `resolved` are terminal until re-issued. */
87
+ function foldAdvisoryState(rows) {
88
+ const byId = new Map();
89
+ for (const r of rows) {
90
+ const cur = byId.get(r.id);
91
+ if (!cur) {
92
+ byId.set(r.id, { id: r.id, kind: r.kind, aspec: r.aspec, firstSeen: r.firstSeen, last: r.outcome, lastAt: r.judgedAt, rows: 1 });
93
+ continue;
94
+ }
95
+ cur.rows += 1;
96
+ if (r.judgedAt >= cur.lastAt) {
97
+ cur.last = r.outcome;
98
+ cur.lastAt = r.judgedAt;
99
+ }
100
+ }
101
+ return [...byId.values()];
102
+ }
103
+ const day = (iso) => iso.slice(0, 10);
104
+ /**
105
+ * Judge the advisories a re-evaluation produced against what the ledger already knows.
106
+ * - a current finding with no state, or whose last state is resolved/dismissed → `issued`
107
+ * - a current finding whose last state is issued/persisted → `persisted`
108
+ * - an open (issued/persisted) finding of the judged scope that is no longer current → `resolved`
109
+ * One row per (id, outcome) per UTC day: re-running the same query the same day adds nothing.
110
+ * `scope` names which open findings this evaluation could have seen (so an unrelated spec's finding
111
+ * is never resolved by a query that did not look at it).
112
+ */
113
+ function judgeAdvisories(state, current, scope, now, head, recent = [], opts = {}) {
114
+ const issue = opts.issue !== false;
115
+ const out = [];
116
+ const today = recent.filter((r) => day(r.judgedAt) === day(now));
117
+ const seen = new Set(today.map((r) => `${r.id}|${r.outcome}`));
118
+ // `persisted` is a heartbeat, not an event: at most one row per id per day, and never on the day
119
+ // the finding was issued — the same query repeated the same day must add nothing.
120
+ const touchedToday = new Set(today.map((r) => r.id));
121
+ const push = (k, outcome, firstSeen) => {
122
+ if (seen.has(`${k.id}|${outcome}`))
123
+ return;
124
+ if (outcome === 'persisted' && touchedToday.has(k.id))
125
+ return;
126
+ seen.add(`${k.id}|${outcome}`);
127
+ touchedToday.add(k.id);
128
+ out.push({ id: k.id, kind: k.kind, aspec: k.aspec, outcome, firstSeen, judgedAt: now, head });
129
+ };
130
+ const byId = new Map(state.map((s) => [s.id, s]));
131
+ const currentIds = new Set(current.map((c) => c.id));
132
+ for (const c of current) {
133
+ const s = byId.get(c.id);
134
+ if (!s || s.last === 'resolved' || s.last === 'dismissed') {
135
+ if (issue)
136
+ push(c, 'issued', now);
137
+ }
138
+ else
139
+ push(c, 'persisted', s.firstSeen);
140
+ }
141
+ for (const s of state) {
142
+ if (!scope(s) || currentIds.has(s.id))
143
+ continue;
144
+ if (s.last === 'issued' || s.last === 'persisted')
145
+ push(s, 'resolved', s.firstSeen);
146
+ }
147
+ return out;
148
+ }
149
+ /** Explicit dismissal by the author: only for findings the ledger knows and that are still open. */
150
+ function dismissAdvisories(state, ids, now, head) {
151
+ const byId = new Map(state.map((s) => [s.id, s]));
152
+ const out = [];
153
+ for (const id of ids) {
154
+ const s = byId.get(id);
155
+ if (!s || (s.last !== 'issued' && s.last !== 'persisted'))
156
+ continue;
157
+ out.push({ id: s.id, kind: s.kind, aspec: s.aspec, outcome: 'dismissed', firstSeen: s.firstSeen, judgedAt: now, head });
158
+ }
159
+ return out;
160
+ }
161
+ exports.ADVISORY_KINDS = ['impact-advisory', 'anchor-density', 'ftt-fulfilment', 'trace-gap', 'kills-unapplicable'];
162
+ /** Per kind: how many findings were ever issued, and how many stand resolved / persisted / dismissed now. */
163
+ function advisoryCensus(rows) {
164
+ const out = Object.fromEntries(exports.ADVISORY_KINDS.map((k) => [k, { issued: 0, resolved: 0, persisted: 0, dismissed: 0 }]));
165
+ for (const s of foldAdvisoryState(rows)) {
166
+ const c = out[s.kind];
167
+ if (!c)
168
+ continue;
169
+ c.issued += 1;
170
+ if (s.last === 'resolved')
171
+ c.resolved += 1;
172
+ else if (s.last === 'persisted')
173
+ c.persisted += 1;
174
+ else if (s.last === 'dismissed')
175
+ c.dismissed += 1;
176
+ }
177
+ return out;
178
+ }
179
+ // Scanned files carry ABSOLUTE paths (CpgScanner); Files to Touch are repo-relative. Compare in one spelling.
180
+ function relPosix(p, root) {
181
+ const rel = root && path.isAbsolute(p) ? path.relative(root, p) : p;
182
+ return rel.split(path.sep).join('/').replace(/\\/g, '/');
183
+ }
184
+ const FILE_RE = /^advisory-outcomes\.([^.]+)\.jsonl$/;
185
+ function appendAdvisoryOutcomes(root, rows) {
186
+ if (rows.length === 0)
187
+ return true;
188
+ try {
189
+ if (!fs.existsSync(path.join(root, '.ax')))
190
+ return false;
191
+ let replica = 'local';
192
+ try {
193
+ replica = (0, replica_id_1.resolveReplicaId)(root) || 'local';
194
+ }
195
+ catch { /* keep the fallback */ }
196
+ const file = path.join(root, '.ax', 'ledger', `advisory-outcomes.${replica}.jsonl`);
197
+ fs.mkdirSync(path.dirname(file), { recursive: true });
198
+ fs.appendFileSync(file, rows.map((r) => `${JSON.stringify({ ...r, replica })}\n`).join(''));
199
+ return true;
200
+ }
201
+ catch {
202
+ return false;
203
+ }
204
+ }
205
+ function readAdvisoryOutcomes(root) {
206
+ const dir = path.join(root, '.ax', 'ledger');
207
+ let names;
208
+ try {
209
+ names = fs.readdirSync(dir).filter((n) => FILE_RE.test(n)).sort();
210
+ }
211
+ catch {
212
+ return [];
213
+ }
214
+ const out = [];
215
+ for (const n of names) {
216
+ let text;
217
+ try {
218
+ text = fs.readFileSync(path.join(dir, n), 'utf8');
219
+ }
220
+ catch {
221
+ continue;
222
+ }
223
+ for (const line of text.split('\n')) {
224
+ if (!line.trim())
225
+ continue;
226
+ try {
227
+ out.push(JSON.parse(line));
228
+ }
229
+ catch { /* a torn line is not a record */ }
230
+ }
231
+ }
232
+ return out.sort((a, b) => (a.judgedAt < b.judgedAt ? -1 : a.judgedAt > b.judgedAt ? 1 : 0));
233
+ }
234
+ /**
235
+ * One trace-gap finding per unlinked approved A-SPEC that declares at least one SCANNED production
236
+ * file in its Files to Touch — the same population REQ-658 reports per change, taken whole. The
237
+ * payload is the sorted file list, so moving one anchor changes the id (a smaller gap is a new
238
+ * finding, the old one resolved) — honest, if noisy; the census counts findings, not specs.
239
+ */
240
+ function traceGapAdvisories(specs, scanned, root = '') {
241
+ const scannedPaths = new Set(scanned.map((f) => relPosix(f.path, root)));
242
+ const out = [];
243
+ for (const u of (0, trace_gaps_1.unlinkedApproved)(specs, scanned)) {
244
+ const files = [...new Set((0, ftt_fulfilment_1.fttItems)(u.ftt).map((i) => i.path).filter((p) => scannedPaths.has(p)))].sort();
245
+ if (files.length === 0)
246
+ continue;
247
+ out.push({ id: advisoryId('trace-gap', u.id, { files }), kind: 'trace-gap', aspec: u.id });
248
+ }
249
+ return out;
250
+ }
251
+ function currentAdvisoryKeys(aspec, c) {
252
+ const keys = [];
253
+ const ids = {};
254
+ const add = (kind, payload) => { const id = advisoryId(kind, aspec, payload); keys.push({ id, kind, aspec }); ids[kind] = id; };
255
+ if (c.impactAdvisory && c.impactAdvisory.files.length > 0)
256
+ add('impact-advisory', { files: c.impactAdvisory.files.map((f) => f.path) });
257
+ if (c.anchorDensity && c.anchorDensity.length > 0)
258
+ add('anchor-density', { files: c.anchorDensity.map((f) => f.path) });
259
+ if (c.fttFulfilment)
260
+ add('ftt-fulfilment', { missing: c.fttFulfilment.missing.map((m) => m.path), moved: c.fttFulfilment.moved.map((m) => m.path), alternatives: c.fttFulfilment.alternatives.map((m) => m.path) });
261
+ if (c.killsAdvisory && c.killsAdvisory.unapplicable.length > 0)
262
+ add('kills-unapplicable', { unapplicable: c.killsAdvisory.unapplicable.map((u) => ({ where: u.where, reason: u.reason })) });
263
+ if (c.traceGap && c.traceGap.files.length > 0)
264
+ add('trace-gap', { files: [...c.traceGap.files].sort() });
265
+ return { keys, ids };
266
+ }
267
+ /**
268
+ * One act: read the ledger, judge `current` against it within `scope`, apply explicit dismissals,
269
+ * append, and return what the response should carry. I/O failures leave the response without the
270
+ * fields (the seal or the query is unaffected). Never throws.
271
+ */
272
+ function recordAdvisoryOutcomes(root, aspec, current, scope, head, dismiss = [], now = new Date().toISOString(), opts = {}) {
273
+ try {
274
+ const rows = readAdvisoryOutcomes(root);
275
+ const state = foldAdvisoryState(rows);
276
+ const judged = judgeAdvisories(state, current, scope, now, head, rows, opts);
277
+ const after = foldAdvisoryState([...rows, ...judged]);
278
+ const dismissed = dismissAdvisories(after, dismiss, now, head);
279
+ const known = new Set(dismissed.map((d) => d.id));
280
+ const dismissUnknown = dismiss.filter((id) => !known.has(id));
281
+ appendAdvisoryOutcomes(root, [...judged, ...dismissed]);
282
+ const history = foldAdvisoryState([...rows, ...judged, ...dismissed]).filter((s) => s.aspec === aspec);
283
+ return { history, dismissUnknown };
284
+ }
285
+ catch {
286
+ return { history: [], dismissUnknown: dismiss };
287
+ }
288
+ }
289
+ /** The scanned production files an A-SPEC declares — the trace-gap payload for one spec. */
290
+ function traceGapFiles(spec, scanned, root = '') {
291
+ const scannedPaths = new Set(scanned.map((f) => relPosix(f.path, root)));
292
+ return [...new Set((0, ftt_fulfilment_1.fttItems)(String(spec.sections?.['Files to Touch'] ?? '')).map((i) => i.path).filter((p) => scannedPaths.has(p)))].sort();
293
+ }
294
+ /** The commit a reaction row is judged at; 'nogit' when git cannot say. Never throws. */
295
+ function gitHeadOf(root) {
296
+ try {
297
+ return (0, node_child_process_1.execFileSync)('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || 'nogit';
298
+ }
299
+ catch {
300
+ return 'nogit';
301
+ }
302
+ }
303
+ /**
304
+ * The trace-gap finding for ONE spec, status aside: the scanned production files it declares, or
305
+ * null when a scanned file already anchors it or it declares none. Used at the seal (where the
306
+ * spec is approved by the time this runs) and in the preview.
307
+ */
308
+ function traceGapFor(specId, spec, scanned, root = '') {
309
+ for (const f of scanned)
310
+ if ((f.implementsSpecs ?? []).includes(specId))
311
+ return null;
312
+ const files = traceGapFiles(spec, scanned, root);
313
+ return files.length > 0 ? { files } : null;
314
+ }
@@ -0,0 +1,28 @@
1
+ export type DeclaredScope = 'scanned-source' | 'file-anchor-target' | 'test-target' | 'unreachable-target' | 'no-declaration';
2
+ export interface ScopePredicates {
3
+ isScanned(rel: string): boolean;
4
+ isFileAnchorable(rel: string): boolean;
5
+ isTest(rel: string): boolean;
6
+ }
7
+ export declare const DECLARED_SCOPES: readonly DeclaredScope[];
8
+ export declare function emptyByScope(): Record<DeclaredScope, number>;
9
+ /**
10
+ * One label per spec, by the STRONGEST reachability its declaration offers: a scanned source beats a
11
+ * file-anchor target beats a test file beats nothing any mechanism reads. `unreachable` is filled
12
+ * whatever the label is, because a single label hides the `docs/x.md` sitting beside a test file —
13
+ * measured here, 21 of the 52 unlinked specs declare at least one such path.
14
+ * The Files-to-Touch parser is REQ-656's, so path extraction has exactly one definition.
15
+ */
16
+ export declare function declaredScopeOf(fttText: string, p: ScopePredicates): {
17
+ scope: DeclaredScope;
18
+ unreachable: string[];
19
+ };
20
+ /** Every entry labelled exactly once, so the aggregate sums to the population it was given. */
21
+ export declare function declaredScopeCensus(entries: ReadonlyArray<{
22
+ id: string;
23
+ ftt: string;
24
+ }>, p: ScopePredicates): {
25
+ byId: Record<string, DeclaredScope>;
26
+ byScope: Record<DeclaredScope, number>;
27
+ unreachableDeclaring: number;
28
+ };
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DECLARED_SCOPES = void 0;
4
+ exports.emptyByScope = emptyByScope;
5
+ exports.declaredScopeOf = declaredScopeOf;
6
+ exports.declaredScopeCensus = declaredScopeCensus;
7
+ // @implements A-SPEC-665
8
+ /**
9
+ * The DECLARATION axis of an unlinked approved A-SPEC.
10
+ *
11
+ * REQ-655's census answers "where is this spec anchored" (`UnlinkedReason`). It cannot answer the
12
+ * question a reader actually has when `codeLinkedPct` reads 91.9: is there production code for this
13
+ * spec to be linked to at all? Measured 2026-09-18, after REQ-664's S3 moved 21 anchors: of the 52
14
+ * unlinked approved specs, ZERO declare a file the scanner parses. They are unlinked because they
15
+ * have no such file, not because an anchor is missing.
16
+ *
17
+ * The two axes sit side by side and never share a name. This one names what a spec DECLARED — a
18
+ * fact — and says nothing about whether that is acceptable; `traceGaps` remains the reader that
19
+ * calls one of these situations a gap.
20
+ *
21
+ * Pure: every reachability question is injected, so the classification follows the mechanisms'
22
+ * own definitions rather than a second copy of them.
23
+ */
24
+ const ftt_fulfilment_1 = require("./ftt-fulfilment");
25
+ exports.DECLARED_SCOPES = ['scanned-source', 'file-anchor-target', 'test-target', 'unreachable-target', 'no-declaration'];
26
+ function emptyByScope() {
27
+ return { 'scanned-source': 0, 'file-anchor-target': 0, 'test-target': 0, 'unreachable-target': 0, 'no-declaration': 0 };
28
+ }
29
+ /**
30
+ * One label per spec, by the STRONGEST reachability its declaration offers: a scanned source beats a
31
+ * file-anchor target beats a test file beats nothing any mechanism reads. `unreachable` is filled
32
+ * whatever the label is, because a single label hides the `docs/x.md` sitting beside a test file —
33
+ * measured here, 21 of the 52 unlinked specs declare at least one such path.
34
+ * The Files-to-Touch parser is REQ-656's, so path extraction has exactly one definition.
35
+ */
36
+ function declaredScopeOf(fttText, p) {
37
+ const items = [...new Set((0, ftt_fulfilment_1.fttItems)(String(fttText ?? '')).map((i) => i.path))];
38
+ if (items.length === 0)
39
+ return { scope: 'no-declaration', unreachable: [] };
40
+ const unreachable = items.filter((rel) => !p.isScanned(rel) && !p.isFileAnchorable(rel) && !p.isTest(rel)).sort();
41
+ const scope = items.some((rel) => p.isScanned(rel)) ? 'scanned-source'
42
+ : items.some((rel) => p.isFileAnchorable(rel)) ? 'file-anchor-target'
43
+ : items.some((rel) => p.isTest(rel)) ? 'test-target'
44
+ : 'unreachable-target';
45
+ return { scope, unreachable };
46
+ }
47
+ /** Every entry labelled exactly once, so the aggregate sums to the population it was given. */
48
+ function declaredScopeCensus(entries, p) {
49
+ const byId = {};
50
+ const byScope = emptyByScope();
51
+ let unreachableDeclaring = 0;
52
+ for (const e of entries) {
53
+ const r = declaredScopeOf(e.ftt, p);
54
+ byId[e.id] = r.scope;
55
+ byScope[r.scope] += 1;
56
+ if (r.unreachable.length > 0)
57
+ unreachableDeclaring += 1;
58
+ }
59
+ return { byId, byScope, unreachableDeclaring };
60
+ }
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.RtmGraph = void 0;
7
+ // @implements A-SPEC-588
7
8
  // @implements A-SPEC-293
8
9
  // @implements A-SPEC-644
9
10
  // @implements A-SPEC-289
@@ -38,6 +38,7 @@ exports.runTaintBenchmarkAsync = runTaintBenchmarkAsync;
38
38
  exports.compareEngines = compareEngines;
39
39
  exports.runTaintBenchmark = runTaintBenchmark;
40
40
  exports.taintOnRealCorpus = taintOnRealCorpus;
41
+ // @implements A-SPEC-355
41
42
  // @implements A-SPEC-354
42
43
  const language_parser_1 = require("../cpg/language-parser");
43
44
  const dataflow_taint_1 = require("./dataflow-taint");
@@ -1,4 +1,5 @@
1
1
  import { UnlinkedReason } from '../rtm/link-census';
2
+ import { DeclaredScope } from '../rtm/declared-scope';
2
3
  /** Honesty summary of what a launched dashboard shows — derived from the endpoints it serves. */
3
4
  export interface DashboardCensus {
4
5
  reqCount: number;
@@ -26,6 +27,12 @@ export interface DashboardCensusExtended extends DashboardCensus {
26
27
  unmapped: number;
27
28
  nonSpec: number;
28
29
  };
30
+ /**
31
+ * @implements A-SPEC-665 — the declaration axis beside the anchor axis: what the unlinked specs
32
+ * DECLARED, next to where they are anchored. A payload without it folds to zeros like the rest.
33
+ */
34
+ declaredScopeByScope: Record<DeclaredScope, number>;
35
+ unreachableDeclaringCount: number;
29
36
  }
30
37
  /**
31
38
  * Pure: the pinned census plus the code-link axis from `/api/rtm`'s `linkCensus`, `coveragePct`
@@ -7,6 +7,7 @@ exports._resetLauncher = _resetLauncher;
7
7
  exports._stopAll = _stopAll;
8
8
  // @implements A-SPEC-545.3
9
9
  const link_census_1 = require("../rtm/link-census");
10
+ const declared_scope_1 = require("../rtm/declared-scope");
10
11
  /**
11
12
  * @implements A-SPEC-545.3
12
13
  * Pure: fold the /api/rtm and /api/rtm/heatmap payloads into a census. Missing fields read as 0/false;
@@ -45,6 +46,8 @@ function dashboardCensusExtended(rtm, heatmap) {
45
46
  unlinkedCount: Array.isArray(lc?.unlinked) ? lc.unlinked.length : 0,
46
47
  unlinkedByReason: { ...(0, link_census_1.emptyByReason)(), ...(lc?.byReason ?? {}) },
47
48
  excluded: { total: retired + unmapped + nonSpec, retired, unmapped, nonSpec },
49
+ declaredScopeByScope: { ...(0, declared_scope_1.emptyByScope)(), ...(rtm?.declaredScope?.byScope ?? {}) },
50
+ unreachableDeclaringCount: typeof rtm?.declaredScope?.unreachableDeclaring === 'number' ? rtm.declaredScope.unreachableDeclaring : 0,
48
51
  };
49
52
  }
50
53
  const live = new Map();
@@ -58,6 +58,8 @@ const cfg_view_1 = require("./cfg-view");
58
58
  const link_census_1 = require("../rtm/link-census");
59
59
  const test_scope_1 = require("../rtm/test-scope");
60
60
  const file_anchors_1 = require("../rtm/file-anchors");
61
+ const declared_scope_1 = require("../rtm/declared-scope");
62
+ const test_files_1 = require("../cpg/test-files");
61
63
  /**
62
64
  * Start a lightweight standalone Node.js HTTP server for interactive dashboard & RTM visualization.
63
65
  *
@@ -130,11 +132,20 @@ async function startDashboardServer(options) {
130
132
  }
131
133
  const census = (0, link_census_1.linkCensus)({ approvedIds: approvedAspecs.map((s) => s.id), implemented: implementedSpecIds, testAnchors, fileAnchors, weakMentions });
132
134
  const reasonOf = new Map(census.unlinked.map((u) => [u.id, u.reason]));
135
+ // @implements A-SPEC-665 — the second axis. The anchor axis above says WHERE a spec is
136
+ // anchored; this one says what it DECLARED, which is the only way to tell "an anchor is
137
+ // missing" from "there is no production file to anchor". Predicates come from the mechanisms
138
+ // themselves (the scan's own paths, the file-anchor predicate, the test predicate) so the two
139
+ // readers cannot drift apart.
140
+ const scannedPaths = new Set(files.map((f) => f.sourcePath));
141
+ const fttOf = new Map(specs.map((s) => [s.id, String(s.sections?.['Files to Touch'] ?? '')]));
142
+ const declaredScope = (0, declared_scope_1.declaredScopeCensus)(census.unlinked.map((u) => ({ id: u.id, ftt: fttOf.get(u.id) ?? '' })), { isScanned: (rel) => scannedPaths.has(rel), isFileAnchorable: (rel) => (0, file_anchors_1.isFileAnchorPath)(rel), isTest: (rel) => (0, test_files_1.isTestFile)(rel) });
133
143
  const enrichedSpecs = specs.map((s) => {
134
144
  const isA = s.id.startsWith('A-SPEC');
135
145
  const covered = isA ? implementedSpecIds.has(s.id) : s.status === 'approved';
136
146
  const reason = reasonOf.get(s.id);
137
- return { ...s, covered, legacyStatus: !isCanonicalStatus(s.status), ...(reason ? { unlinkedReason: reason } : {}) };
147
+ const scope = declaredScope.byId[s.id];
148
+ return { ...s, covered, legacyStatus: !isCanonicalStatus(s.status), ...(reason ? { unlinkedReason: reason } : {}), ...(scope ? { declaredScope: scope } : {}) };
138
149
  });
139
150
  // Two axes, never one number. Approval (a spec was signed off) and implementation (code is
140
151
  // anchored to it) answer different questions, and a spec whose status is outside the
@@ -194,6 +205,8 @@ async function startDashboardServer(options) {
194
205
  approval,
195
206
  implementation,
196
207
  linkCensus: census,
208
+ // @implements A-SPEC-665
209
+ declaredScope: { byScope: declaredScope.byScope, unreachableDeclaring: declaredScope.unreachableDeclaring },
197
210
  unmapped,
198
211
  nonSpec,
199
212
  retired,
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.21.0",
4
+ "version": "0.23.1",
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",
@@ -124,6 +124,25 @@ T-SPEC 필수 섹션은 4분면 그대로다: **Normal · Corner · Negative ·
124
124
  케이스인지**가 드러나게 쓴다. 그리고 frontmatter `depends_on: [대상 A-SPEC]` — 이 간선이 없으면
125
125
  승인해도 코드 게이트가 열리지 않는다 — 그때 게이트가 `depends_on`을 지목한다(`promote-slice`의 「T-SPEC 거부는 상태를 구별해 말한다」).
126
126
 
127
+ ### `kills:` 문법 — `where`는 소스 조각이지 파일 경로가 아니다
128
+
129
+ T-SPEC frontmatter의 `kills`는 판별력을 스펙 필드로 승격한 것이다: 엔진(`test_run --mutate`)이 A-SPEC을
130
+ `@implements`하는 **프로덕션 소스 안의 리터럴 `where`를 `mutate`로 치환**하고, 커버 테스트가 붉어지는지
131
+ 본다. 그래서 `where`는 그 소스에 **글자 그대로 존재하는 조각**이어야 한다.
132
+
133
+ ```yaml
134
+ kills:
135
+ - where: "if (reason === '')"
136
+ mutate: "if (false)"
137
+ ```
138
+
139
+ `where: src/holmes/foo.ts`처럼 파일 경로를 적거나 `mutate`에 산문("검사를 제거한다")을 적으면 아무것도
140
+ 적용되지 않는다 — 2026-09-17 실측에서 이 저장소의 22개 엔트리 전부가 그 모양이었다. 이제 두 곳이 그것을
141
+ 말한다: T-SPEC 승인(`spec_approve`·`approval_status`)이 적용 불가 엔트리를 `killsAdvisory`
142
+ (`kills-unapplicable`) **소견으로 보고**하고(봉인은 그대로 된다), `test_run --mutate`는 적용되지 않은
143
+ 엔트리를 `unapplied`로 `survivors`와 구별해 싣는다. `survivors: []`는 `unapplied`가 비어 있을 때만 "생존
144
+ 변이 없음"이다.
145
+
127
146
  H-SPEC에 `[observability]` 의무가 있으면 T-SPEC은 **기록→조회 왕복** 케이스를
128
147
  `- [observability] Given … When … Then …`으로 적는다: 쓰기 API를 부르고, **읽는 쪽이 실제로 쓰는 읽기
129
148
  API**로 같은 좌표를 조회해 행이 있음을 단언한다. 태그는 줄 시작에서만 인식된다(`nonfunctional.ts`).