simt-dev 0.3.1 → 0.5.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 (60) hide show
  1. package/CHANGELOG.md +157 -0
  2. package/README.md +271 -62
  3. package/package.json +6 -2
  4. package/rules/core.md +1 -1
  5. package/src/advice/advice.mjs +229 -0
  6. package/src/agenthooks/policy.mjs +10 -2
  7. package/src/agenthooks/prompt.mjs +81 -0
  8. package/src/agenthooks/render.mjs +138 -17
  9. package/src/argv.mjs +28 -1
  10. package/src/bench/grade.mjs +3 -0
  11. package/src/bench/run.mjs +88 -54
  12. package/src/bench/tasks.mjs +38 -0
  13. package/src/bench/variants.mjs +456 -0
  14. package/src/brief/assemble.mjs +368 -0
  15. package/src/brief/kind.mjs +134 -0
  16. package/src/brief/packet.mjs +200 -0
  17. package/src/commands/agenthook.mjs +84 -5
  18. package/src/commands/bench.mjs +72 -15
  19. package/src/commands/brief.mjs +78 -0
  20. package/src/commands/compare.mjs +10 -2
  21. package/src/commands/decide.mjs +116 -0
  22. package/src/commands/distill.mjs +36 -0
  23. package/src/commands/help.mjs +62 -3
  24. package/src/commands/index.mjs +29 -1
  25. package/src/commands/lessons.mjs +127 -0
  26. package/src/commands/local.mjs +97 -0
  27. package/src/commands/map.mjs +42 -7
  28. package/src/commands/repoindex.mjs +4 -1
  29. package/src/commands/review.mjs +65 -0
  30. package/src/commands/rules.mjs +17 -2
  31. package/src/commands/skills.mjs +5 -50
  32. package/src/commands/skillscompile.mjs +255 -0
  33. package/src/commands/sync.mjs +15 -0
  34. package/src/commands/tests.mjs +2 -49
  35. package/src/commands/why.mjs +23 -25
  36. package/src/compare/learn.mjs +680 -0
  37. package/src/decisions/index.mjs +116 -65
  38. package/src/distill/distill.mjs +246 -0
  39. package/src/distill/engine.mjs +187 -0
  40. package/src/federation/client.mjs +188 -0
  41. package/src/federation/config.mjs +134 -0
  42. package/src/federation/query.mjs +48 -0
  43. package/src/graph/compile.mjs +475 -0
  44. package/src/index/gate.mjs +1 -13
  45. package/src/index/govern.mjs +23 -11
  46. package/src/index/map.mjs +251 -17
  47. package/src/index/persist.mjs +74 -7
  48. package/src/index/store.mjs +222 -8
  49. package/src/kernel/variants.mjs +353 -0
  50. package/src/ledger/index.mjs +36 -1
  51. package/src/local/client.mjs +482 -0
  52. package/src/local/config.mjs +269 -0
  53. package/src/mcp/tools.mjs +138 -1
  54. package/src/memory/decide.mjs +462 -0
  55. package/src/memory/lessons.mjs +858 -0
  56. package/src/projection/lock.mjs +39 -7
  57. package/src/projection/rules.mjs +32 -8
  58. package/src/resolve/body.mjs +46 -0
  59. package/src/review/critic.mjs +509 -0
  60. package/src/toolwords.mjs +6 -0
@@ -0,0 +1,462 @@
1
+ // Decision assistant (plan 0003 R34): scaffold ADRs with structured frontmatter
2
+ // (`simt-affects` from the diff and `simt-supersedes: [{adr, ruling}]`), parse
3
+ // frontmatter first for `rules why` and `why`, and fail lint on partial
4
+ // supersessions stated only in prose.
5
+
6
+ import { execFile } from 'node:child_process';
7
+ import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
8
+ import { join } from 'node:path';
9
+ import { promisify } from 'node:util';
10
+
11
+ const exec = promisify(execFile);
12
+
13
+ export const DECISION_KEYS = ['simt-affects', 'simt-supersedes'];
14
+
15
+ const VALID_KINDS = new Set(['superseded', 'narrowed', 'withdrawn']);
16
+
17
+ export const PARTIAL_CLAUSE =
18
+ /\b(?:one ruling|rulings?\s+(\d+(?:\s*(?:,|and)\s*\d+)*))\s+(superseded|narrowed|withdrawn)\s+by\s+(ADR-\d{3,}|the same record)\b/gi;
19
+
20
+ const ACTIVE_PARTIAL_AFTER_RE =
21
+ /\b(supersedes?|narrowed|narrows|withdrawn|withdraws|amends?|refines?)\s+(?:\[?(ADR-\d{3,})\]?(?:\([^)]*\))?)\s+rulings?\s+(\d+(?:\s*(?:,|and)\s*\d+)*)\b/gi;
22
+
23
+ const ACTIVE_PARTIAL_BEFORE_RE =
24
+ /\b(supersedes?|narrowed|narrows|withdrawn|withdraws|amends?|refines?)\s+rulings?\s+(\d+(?:\s*(?:,|and)\s*\d+)*)\s+of\s+(?:\[?(ADR-\d{3,})\]?(?:\([^)]*\))?)\b/gi;
25
+
26
+ function normaliseKind(verb) {
27
+ const v = String(verb ?? 'superseded').toLowerCase();
28
+ if (v.startsWith('narrow')) return 'narrowed';
29
+ if (v.startsWith('withdraw')) return 'withdrawn';
30
+ return 'superseded';
31
+ }
32
+
33
+ function unquote(value) {
34
+ const t = String(value ?? '').trim();
35
+ return (t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))
36
+ ? t.slice(1, -1)
37
+ : t;
38
+ }
39
+
40
+ /** Strip leading YAML frontmatter (`---\n...\n---`) from a markdown document. */
41
+ export function splitFrontmatter(text) {
42
+ const raw = String(text ?? '');
43
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(raw);
44
+ return match ? { front: match[1], body: raw.slice(match[0].length) } : { front: null, body: raw };
45
+ }
46
+
47
+ function parseInlineStringList(inner) {
48
+ return inner.trim() ? inner.split(',').map(unquote).filter(Boolean) : [];
49
+ }
50
+
51
+ function parseObjectLiteral(rawObj) {
52
+ const inner = rawObj.trim().replace(/^\{/, '').replace(/\}$/, '').trim();
53
+ if (!inner) return {};
54
+ const out = {};
55
+ for (const pair of inner.split(',')) {
56
+ const colon = pair.indexOf(':');
57
+ if (colon < 0) continue;
58
+ const key = unquote(pair.slice(0, colon));
59
+ const valRaw = pair.slice(colon + 1).trim();
60
+ out[key] = /^null$/i.test(valRaw) ? null : /^-?\d+$/.test(valRaw) ? Number(valRaw) : unquote(valRaw);
61
+ }
62
+ return out;
63
+ }
64
+
65
+ function normaliseSupersedesEntry(obj, errors) {
66
+ if (!obj || typeof obj !== 'object') {
67
+ errors.push('simt-supersedes entry must be an object {adr, ruling}');
68
+ return null;
69
+ }
70
+ const rawAdr = obj.adr !== undefined && obj.adr !== null ? String(obj.adr).trim().toUpperCase() : '';
71
+ if (!/^ADR-\d{3,}$/.test(rawAdr)) {
72
+ errors.push(`simt-supersedes entry has invalid adr "${obj.adr ?? ''}" (expected ADR-NNN)`);
73
+ return null;
74
+ }
75
+ let ruling = null;
76
+ if (obj.ruling !== undefined && obj.ruling !== null && obj.ruling !== '') {
77
+ const num = Number(obj.ruling);
78
+ if (!Number.isInteger(num) || num < 1) {
79
+ errors.push(`simt-supersedes entry for ${rawAdr} has invalid ruling "${obj.ruling}"`);
80
+ return null;
81
+ }
82
+ ruling = num;
83
+ }
84
+ const rawKind = obj.kind !== undefined && obj.kind !== null ? String(obj.kind).trim().toLowerCase() : 'superseded';
85
+ if (!VALID_KINDS.has(rawKind)) {
86
+ errors.push(`simt-supersedes entry for ${rawAdr} has unknown kind "${obj.kind}"`);
87
+ return null;
88
+ }
89
+ return { adr: rawAdr, ruling, kind: rawKind };
90
+ }
91
+
92
+ /** Parse `simt-affects` and `simt-supersedes` from an ADR's YAML frontmatter. */
93
+ export function parseDecisionFrontmatter(text) {
94
+ const { front } = splitFrontmatter(text);
95
+ if (front === null) {
96
+ return { hasFrontmatter: false, hasAffects: false, hasSupersedes: false, affects: [], supersedes: [], errors: [] };
97
+ }
98
+ const errors = [];
99
+ let hasAffects = false;
100
+ let hasSupersedes = false;
101
+ const affects = [];
102
+ const supersedes = [];
103
+ const lines = front.split(/\r?\n/);
104
+
105
+ for (let i = 0; i < lines.length; i += 1) {
106
+ const line = lines[i];
107
+ const affectsMatch = /^simt-affects:\s*(.*)$/.exec(line);
108
+ if (affectsMatch) {
109
+ hasAffects = true;
110
+ const rest = affectsMatch[1].trim();
111
+ if (rest.startsWith('[')) {
112
+ const close = rest.lastIndexOf(']');
113
+ affects.push(...parseInlineStringList(close >= 0 ? rest.slice(1, close) : rest.slice(1)));
114
+ } else if (rest === '') {
115
+ while (i + 1 < lines.length && /^\s*-\s+/.test(lines[i + 1])) {
116
+ i += 1;
117
+ const val = unquote(lines[i].replace(/^\s*-\s+/, ''));
118
+ if (val) affects.push(val);
119
+ }
120
+ } else if (unquote(rest)) {
121
+ affects.push(unquote(rest));
122
+ }
123
+ continue;
124
+ }
125
+
126
+ const supMatch = /^simt-supersedes:\s*(.*)$/.exec(line);
127
+ if (supMatch) {
128
+ hasSupersedes = true;
129
+ const rest = supMatch[1].trim();
130
+ if (rest.startsWith('[')) {
131
+ let combined = rest;
132
+ while (!combined.includes(']') && i + 1 < lines.length) {
133
+ i += 1;
134
+ combined += ` ${lines[i].trim()}`;
135
+ }
136
+ const close = combined.lastIndexOf(']');
137
+ const inner = (close >= 0 ? combined.slice(1, close) : combined.slice(1)).trim();
138
+ if (inner) {
139
+ const objMatches = [...inner.matchAll(/\{[^{}]*\}/g)];
140
+ if (objMatches.length === 0) errors.push('simt-supersedes list must contain {adr, ruling} objects');
141
+ for (const m of objMatches) {
142
+ const parsed = normaliseSupersedesEntry(parseObjectLiteral(m[0]), errors);
143
+ if (parsed) supersedes.push(parsed);
144
+ }
145
+ }
146
+ } else if (rest === '') {
147
+ let currentObj = null;
148
+ const flush = () => {
149
+ if (!currentObj) return;
150
+ const parsed = normaliseSupersedesEntry(currentObj, errors);
151
+ if (parsed) supersedes.push(parsed);
152
+ currentObj = null;
153
+ };
154
+ while (i + 1 < lines.length && /^\s+(?:-\s+|[a-z]+:)/i.test(lines[i + 1])) {
155
+ i += 1;
156
+ const sub = lines[i].trim();
157
+ if (sub.startsWith('-')) {
158
+ flush();
159
+ const afterDash = sub.replace(/^-\s*/, '').trim();
160
+ if (afterDash.startsWith('{')) {
161
+ const parsed = normaliseSupersedesEntry(parseObjectLiteral(afterDash), errors);
162
+ if (parsed) supersedes.push(parsed);
163
+ } else if (afterDash.includes(':')) {
164
+ const [k, ...vParts] = afterDash.split(':');
165
+ currentObj = { [unquote(k)]: unquote(vParts.join(':')) };
166
+ }
167
+ } else if (currentObj && sub.includes(':')) {
168
+ const [k, ...vParts] = sub.split(':');
169
+ currentObj[unquote(k)] = unquote(vParts.join(':'));
170
+ }
171
+ }
172
+ flush();
173
+ } else if (rest.startsWith('{')) {
174
+ const parsed = normaliseSupersedesEntry(parseObjectLiteral(rest), errors);
175
+ if (parsed) supersedes.push(parsed);
176
+ }
177
+ }
178
+ }
179
+
180
+ return { hasFrontmatter: true, hasAffects, hasSupersedes, affects, supersedes, errors };
181
+ }
182
+
183
+ /** Parse CLI or API `--supersedes` values into structured `[{adr, ruling, kind}]` entries. */
184
+ export function parseSupersedesSpec(input) {
185
+ if (input === undefined || input === null) return { entries: [], errors: [] };
186
+ const rawList = Array.isArray(input) ? input : [input];
187
+ const entries = [];
188
+ const errors = [];
189
+
190
+ for (const item of rawList) {
191
+ if (item && typeof item === 'object') {
192
+ const norm = normaliseSupersedesEntry(item, errors);
193
+ if (norm) entries.push(norm);
194
+ continue;
195
+ }
196
+ for (const part of String(item).split(',')) {
197
+ const token = part.trim();
198
+ if (!token) continue;
199
+ const colonMatch = /^(ADR-\d{3,})(?:[:#](\d+))?(?::(superseded|narrowed|withdrawn))?$/i.exec(token);
200
+ if (colonMatch) {
201
+ entries.push({
202
+ adr: colonMatch[1].toUpperCase(),
203
+ ruling: colonMatch[2] ? Number(colonMatch[2]) : null,
204
+ kind: colonMatch[3] ? colonMatch[3].toLowerCase() : 'superseded',
205
+ });
206
+ continue;
207
+ }
208
+ const proseMatch = /^(ADR-\d{3,})\s+ruling\s+(\d+)(?:\s+\(?(superseded|narrowed|withdrawn)\)?)?$/i.exec(token);
209
+ if (proseMatch) {
210
+ entries.push({
211
+ adr: proseMatch[1].toUpperCase(),
212
+ ruling: Number(proseMatch[2]),
213
+ kind: proseMatch[3] ? proseMatch[3].toLowerCase() : 'superseded',
214
+ });
215
+ continue;
216
+ }
217
+ errors.push(`invalid --supersedes spec "${token}" (expected ADR-NNN or ADR-NNN:<ruling>[:<kind>])`);
218
+ }
219
+ }
220
+ return { entries, errors };
221
+ }
222
+
223
+ /** Slugify an ADR title into a filename suffix. */
224
+ function slugifyTitle(title) {
225
+ const slug = String(title ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 72);
226
+ return slug || 'decision';
227
+ }
228
+
229
+ /** Determine the next free ADR number in a decisions directory. */
230
+ export async function nextDecisionNumber(decisionsDir) {
231
+ const names = await readdir(decisionsDir).catch(() => []);
232
+ let max = 0;
233
+ for (const name of names) {
234
+ const m = /^ADR-(\d+)-.*\.md$/i.exec(name);
235
+ if (m && Number(m[1]) > max) max = Number(m[1]);
236
+ }
237
+ const next = max + 1;
238
+ return { number: next, id: `ADR-${String(next).padStart(3, '0')}` };
239
+ }
240
+
241
+ /** Collect affected repository paths from the working tree / staged diff (R25, R34). */
242
+ export async function diffAffectedPaths(root) {
243
+ const paths = new Set();
244
+ const addLines = (stdout) => {
245
+ for (const line of String(stdout ?? '').split(/\r?\n/)) {
246
+ const trimmed = line.trim();
247
+ if (trimmed && !trimmed.startsWith('.simt-dev/')) paths.add(trimmed);
248
+ }
249
+ };
250
+ for (const args of [
251
+ ['diff', '--name-only', '--cached'],
252
+ ['diff', '--name-only', 'HEAD'],
253
+ ['diff', '--name-only'],
254
+ ]) {
255
+ try {
256
+ const { stdout } = await exec('git', args, { cwd: root });
257
+ addLines(stdout);
258
+ } catch {
259
+ // Ignore if not a git repo or HEAD absent.
260
+ }
261
+ }
262
+ if (paths.size === 0) {
263
+ try {
264
+ const { stdout } = await exec('git', ['diff-tree', '--no-commit-id', '--name-only', '-r', 'HEAD'], { cwd: root });
265
+ addLines(stdout);
266
+ } catch {
267
+ // Ignore.
268
+ }
269
+ }
270
+ return [...paths].sort();
271
+ }
272
+
273
+ function formatSupersedesFrontmatter(supersedes) {
274
+ if (!Array.isArray(supersedes) || supersedes.length === 0) return '[]';
275
+ const items = supersedes.map((entry) => {
276
+ const parts = [`adr: "${entry.adr}"`, `ruling: ${entry.ruling === null ? 'null' : entry.ruling}`];
277
+ if (entry.kind && entry.kind !== 'superseded') parts.push(`kind: "${entry.kind}"`);
278
+ return `{ ${parts.join(', ')} }`;
279
+ });
280
+ return `[${items.join(', ')}]`;
281
+ }
282
+
283
+ function formatAffectsFrontmatter(affects) {
284
+ return Array.isArray(affects) && affects.length > 0
285
+ ? `[${affects.map((p) => JSON.stringify(String(p))).join(', ')}]`
286
+ : '[]';
287
+ }
288
+
289
+ /** Render an ADR markdown document with structured frontmatter, a Status line, and a rulings skeleton. */
290
+ function renderDecisionScaffold({
291
+ id,
292
+ title,
293
+ date = new Date().toISOString().slice(0, 10),
294
+ status = 'accepted',
295
+ affects = [],
296
+ supersedes = [],
297
+ }) {
298
+ const cleanTitle = String(title ?? 'Untitled decision').trim() || 'Untitled decision';
299
+ const wholeSupersedes = supersedes.filter((s) => s.ruling === null).map((s) => s.adr);
300
+ const partialSupersedes = supersedes.filter((s) => s.ruling !== null);
301
+ const statusClauses = [`${status} · ${date}`];
302
+ if (wholeSupersedes.length > 0) statusClauses.push(`supersedes ${wholeSupersedes.join(', ')}`);
303
+ for (const p of partialSupersedes) {
304
+ const verb = p.kind === 'narrowed' ? 'narrows' : p.kind === 'withdrawn' ? 'withdraws' : 'supersedes';
305
+ statusClauses.push(`${verb} ${p.adr} ruling ${p.ruling}`);
306
+ }
307
+
308
+ return [
309
+ '---',
310
+ `simt-affects: ${formatAffectsFrontmatter(affects)}`,
311
+ `simt-supersedes: ${formatSupersedesFrontmatter(supersedes)}`,
312
+ '---',
313
+ `# ${id} — ${cleanTitle}`,
314
+ '',
315
+ `Status: ${statusClauses.join(' · ')}`,
316
+ '',
317
+ '## Context',
318
+ '',
319
+ 'State the context and the measurement that prompted this decision.',
320
+ '',
321
+ '## Rulings',
322
+ '',
323
+ '1. **First ruling.** State the first constraint or invariant clearly so it can be cited by number.',
324
+ '',
325
+ '2. **Second ruling.** State the second constraint or invariant clearly so it can be cited by number.',
326
+ '',
327
+ '## Consequences',
328
+ '',
329
+ '- State what changes in the repository or projection as a result of these rulings.',
330
+ '',
331
+ '## Rejected',
332
+ '',
333
+ '- **Alternative considered.** Rejected: state why this option was refused.',
334
+ '',
335
+ ].join('\n');
336
+ }
337
+
338
+ /** Scaffold a new ADR in `<root>/docs/decisions` with structured frontmatter. */
339
+ export async function scaffoldDecision(root, options = {}) {
340
+ const decisionsDir = options.decisionsDir ?? join(root, 'docs', 'decisions');
341
+ const { number, id } = await nextDecisionNumber(decisionsDir);
342
+ const title = String(options.title ?? 'Untitled decision').trim() || 'Untitled decision';
343
+ const slug = slugifyTitle(title);
344
+ const file = `${id}-${slug}.md`;
345
+ const relPath = ['docs', 'decisions', file].join('/');
346
+ const fullPath = join(decisionsDir, file);
347
+ const affects = Array.isArray(options.affects)
348
+ ? options.affects.map((p) => String(p).trim()).filter(Boolean)
349
+ : await diffAffectedPaths(root);
350
+
351
+ const { entries: supersedes, errors } = parseSupersedesSpec(options.supersedes);
352
+ if (errors.length > 0) return { ok: false, errors };
353
+
354
+ const text = renderDecisionScaffold({
355
+ id,
356
+ title,
357
+ date: options.date,
358
+ status: options.status,
359
+ affects,
360
+ supersedes,
361
+ });
362
+
363
+ if (options.write) {
364
+ await mkdir(decisionsDir, { recursive: true });
365
+ await writeFile(fullPath, text, 'utf8');
366
+ }
367
+
368
+ return {
369
+ ok: true,
370
+ id,
371
+ number,
372
+ title,
373
+ slug,
374
+ file,
375
+ relPath,
376
+ path: fullPath,
377
+ affects,
378
+ supersedes,
379
+ text,
380
+ written: Boolean(options.write),
381
+ };
382
+ }
383
+
384
+ /** Extract partial supersession claims stated in prose (`Status:` and header paragraphs). */
385
+ function prosePartialSupersessions(body) {
386
+ const claims = [];
387
+ const headSection = String(body ?? '').split(/^##\s+/m)[0] ?? '';
388
+ const clean = headSection.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/[*`]/g, '').replace(/\s+/g, ' ');
389
+
390
+ let lastNamed = null;
391
+ for (const m of clean.matchAll(PARTIAL_CLAUSE)) {
392
+ const [raw, list, kind, ref] = m;
393
+ const adr = /^ADR-/i.test(ref) ? ref.toUpperCase() : lastNamed;
394
+ if (/^ADR-/i.test(ref)) lastNamed = ref.toUpperCase();
395
+ const rulings = list ? list.split(/\D+/).filter(Boolean).map(Number) : [null];
396
+ for (const ruling of rulings) claims.push({ adr, ruling, kind: normaliseKind(kind), raw });
397
+ }
398
+ for (const m of clean.matchAll(ACTIVE_PARTIAL_AFTER_RE)) {
399
+ const [raw, verb, adrRaw, list] = m;
400
+ for (const ruling of list.split(/\D+/).filter(Boolean).map(Number)) {
401
+ claims.push({ adr: adrRaw.toUpperCase(), ruling, kind: normaliseKind(verb), raw });
402
+ }
403
+ }
404
+ for (const m of clean.matchAll(ACTIVE_PARTIAL_BEFORE_RE)) {
405
+ const [raw, verb, list, adrRaw] = m;
406
+ for (const ruling of list.split(/\D+/).filter(Boolean).map(Number)) {
407
+ claims.push({ adr: adrRaw.toUpperCase(), ruling, kind: normaliseKind(verb), raw });
408
+ }
409
+ }
410
+ return claims;
411
+ }
412
+
413
+ /** Lint a single ADR document for structured supersession conformance (plan 0003 R34). */
414
+ export function lintDecision(text, { file = 'ADR-000.md', allowHistorical = false } = {}) {
415
+ const fileMatch = file !== 'ADR-000.md' ? /^(ADR-\d{3,})/i.exec(file) : null;
416
+ const headMatch = /^#\s+(ADR-\d{3,})\b/m.exec(String(text ?? ''));
417
+ const id = (fileMatch?.[1] ?? headMatch?.[1] ?? /^(ADR-\d{3,})/i.exec(file)?.[1] ?? '').toUpperCase() || null;
418
+ const num = id ? Number(id.replace(/^ADR-/, '')) : null;
419
+
420
+ const fm = parseDecisionFrontmatter(text);
421
+ const { body } = splitFrontmatter(text);
422
+ const findings = fm.errors.map((err) => ({
423
+ file,
424
+ id,
425
+ status: 'invalid-decision-frontmatter',
426
+ detail: err,
427
+ remedy: 'fix simt-supersedes frontmatter to use [{ adr: "ADR-NNN", ruling: N }]',
428
+ }));
429
+
430
+ if (allowHistorical && !fm.hasFrontmatter && num !== null && num > 0 && num <= 96) {
431
+ return { ok: findings.length === 0, findings };
432
+ }
433
+
434
+ for (const claim of prosePartialSupersessions(body)) {
435
+ const covered = fm.supersedes.some(
436
+ (e) => e.ruling === claim.ruling && (claim.adr === null || e.adr === claim.adr || (id !== null && e.adr === id)),
437
+ );
438
+ if (!covered) {
439
+ const targetAdr = claim.adr ?? id ?? 'ADR-NNN';
440
+ const rulingLabel = claim.ruling !== null ? `ruling ${claim.ruling}` : 'unnumbered ruling';
441
+ findings.push({
442
+ file,
443
+ id,
444
+ status: 'prose-partial-supersession',
445
+ detail: `partial supersession (${targetAdr} ${rulingLabel}) is stated only in prose ("${claim.raw}")`,
446
+ remedy: `declare simt-supersedes: [{ adr: "${targetAdr}", ruling: ${claim.ruling ?? 1} }] in the ADR frontmatter`,
447
+ });
448
+ }
449
+ }
450
+ return { ok: findings.length === 0, findings };
451
+ }
452
+
453
+ /** Lint all ADR records in a decisions directory. */
454
+ export async function lintDecisions(decisionsDir, { allowHistorical = false } = {}) {
455
+ const names = (await readdir(decisionsDir).catch(() => [])).filter((n) => /^ADR-\d{3,}-.*\.md$/i.test(n)).sort();
456
+ const findings = [];
457
+ for (const file of names) {
458
+ const text = await readFile(join(decisionsDir, file), 'utf8');
459
+ findings.push(...lintDecision(text, { file, allowHistorical }).findings);
460
+ }
461
+ return { ok: findings.length === 0, findings };
462
+ }