dotmd-cli 0.86.0 → 0.87.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/bin/dotmd.mjs CHANGED
@@ -248,6 +248,7 @@ Analyze:
248
248
  diff [file] [--summarize] Show changes since last updated date
249
249
  summary <file> [--json] AI summary of a document
250
250
  glossary <term> [--list] [--json] Look up domain terms + related docs
251
+ decisions [doc|id] [--all|--check] Open and held decisions, read from the corpus
251
252
 
252
253
  Validate & Fix:
253
254
  doctor [--apply] Auto-fix everything: refs, membership, lint, long fields, dates, index (preview by default)
@@ -1329,6 +1330,23 @@ plans, and checklist progress. Plans-only view.
1329
1330
  Options:
1330
1331
  --json Output as JSON`,
1331
1332
 
1333
+ decisions: `runlist decisions — the open and held decisions, read from the corpus
1334
+
1335
+ runlist decisions every open and held decision, one line each
1336
+ runlist decisions <doc> one document, by path, filename or slug
1337
+ runlist decisions <ID> one record, whole, as it is written
1338
+ runlist decisions --all every disposition, not only the pending ones
1339
+ runlist decisions --json the same rows as data
1340
+ runlist decisions --check every item it could not read or that is incomplete; exit 1 if any
1341
+
1342
+ A decision is an item in a decisions section (a heading naming the section word
1343
+ in its first 4 words): a heading, bold lead, bullet or table row whose first
1344
+ token is an id. A register is a fenced block whose first line carries
1345
+ decisions.register.statusLine; its rows are \`ID text\`. A disposition is read
1346
+ from a \`Disposition:\` line or a row's opening word, then from prose when
1347
+ decisions.prose is on, then from the block or bold lead above it. Configure it
1348
+ with \`export const decisions = { ... }\` (see src/decisions.mjs).`,
1349
+
1332
1350
  glossary: `runlist glossary <term> — look up domain terms and related docs
1333
1351
 
1334
1352
  Searches the glossary table in your docs for matching terms.
@@ -1898,6 +1916,7 @@ async function main() {
1898
1916
  if (command === 'flags') { const { runFlags } = await import('../src/flags.mjs'); runFlags(restArgs, config); return; }
1899
1917
  if (command === 'flag') { const { runFlag } = await import('../src/flags.mjs'); runFlag(restArgs, config); return; }
1900
1918
  if (command === 'glossary') { const { runGlossary } = await import('../src/glossary.mjs'); runGlossary(restArgs, config); return; }
1919
+ if (command === 'decisions') { const { runDecisions } = await import('../src/decisions.mjs'); runDecisions(restArgs, config); return; }
1901
1920
  if (command === 'export') { const { runExport } = await import('../src/export.mjs'); runExport(restArgs, config, { dryRun, root: rootArg, type: typeArg }); return; }
1902
1921
 
1903
1922
  // Lifecycle commands
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.86.0",
3
+ "version": "0.87.0",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, lifecycle, and AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/commands.mjs CHANGED
@@ -88,6 +88,7 @@ const definitions = [
88
88
  form('sync <check-name> [findings]', { subcommands: ['sync'], args: positionals(1, 2), dashPositionalsAfter: 1 }),
89
89
  ]),
90
90
  command('glossary', none, 'read', [form('[term]', { args: positionals(0, 1), options: [flag('--list'), flag('--json')] })]),
91
+ command('decisions', none, 'read', [form('[doc-or-id]', { args: positionals(0, 1), options: [flag('--all'), flag('--json'), flag('--check')] })]),
91
92
  command('modules', none, 'read', [form('', { options: [value('--sort'), value('--limit'), flag('--all'), flag('--json')] })]),
92
93
  command('module', none, 'read', [form('<name>', { args: positionals(1, 1), options: [value('--sort'), flag('--json')] })]),
93
94
  command('surfaces', none, 'read', [form('', { options: [flag('--json')] })]),
@@ -0,0 +1,540 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { buildIndex, resolveDocArg } from './index.mjs';
4
+ import { die } from './util.mjs';
5
+
6
+ // `runlist decisions` — the open decisions, read from the corpus, never typed.
7
+ //
8
+ // A decision is an item inside a decisions section of a document: a heading,
9
+ // a bold lead, a list bullet or a table row whose first token is an id. A
10
+ // register is a fenced block whose first line carries the configured status
11
+ // line; its rows are `ID text` and index the records the documents hold.
12
+ //
13
+ // Config (`runlist.config.mjs`), every key optional:
14
+ // export const decisions = {
15
+ // section: 'Decisions', // the word a decisions heading names
16
+ // types: ['plan'], // which document types carry decisions
17
+ // paths: ['docs/plans'], // only documents under these, when set
18
+ // id: '[A-Z]{1,3}-?\\d{1,3}[a-z]?',
19
+ // prose: false, // also read a disposition out of prose
20
+ // vocabulary: { open: [...], held: [...], ruled: [...], closed: [...] },
21
+ // patterns: { ruled: ['regex source', ...] }, // extra prose markers per disposition
22
+ // requires: { open: ['prose', 'citation', 'answers'] },
23
+ // answers: ['regex source', ...], // extra phrases the answers detector accepts
24
+ // register: { file, statusLine },
25
+ // listHeading: 'Waiting on a decision:',
26
+ // };
27
+
28
+ export const DEFAULTS = Object.freeze({
29
+ section: 'Decisions',
30
+ types: ['plan'],
31
+ paths: null,
32
+ id: '[A-Z]{1,3}-?[A-Z]?\\d{1,3}[a-z]?',
33
+ prose: false,
34
+ vocabulary: {
35
+ ruled: ['ruled', 'ratified', 'resolved', 'approved', 'decided', 'answered', 'settled', 'locked'],
36
+ closed: ['closed', 'withdrawn', 'superseded', 'overtaken', 'retired', 'cancelled', 'canceled', 'not a decision'],
37
+ held: ['held', 'on hold', 'deferred', 'paused', 'parked', 'blocked'],
38
+ open: ['open', 'unruled', 'unratified', 'pending', 'awaiting', 'undecided', 'unanswered', 'tbd'],
39
+ },
40
+ patterns: {},
41
+ requires: {},
42
+ answers: [],
43
+ register: null,
44
+ listHeading: 'Waiting on a decision:',
45
+ });
46
+
47
+ export const PENDING = new Set(['open', 'held']);
48
+ const KINDS = ['ruled', 'closed', 'held', 'open'];
49
+ const MAX_BODY_LINES = 24;
50
+
51
+ const escapeRe = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
52
+
53
+ export function decisionSettings(raw = {}) {
54
+ const s = { ...DEFAULTS, ...raw };
55
+ s.vocabulary = { ...DEFAULTS.vocabulary, ...(raw.vocabulary ?? {}) };
56
+ s.types = [].concat(s.types ?? DEFAULTS.types);
57
+ return s;
58
+ }
59
+
60
+ // ── Grammar ──────────────────────────────────────────────────────────────────
61
+
62
+ function wordsRe(words) {
63
+ return words.map(w => escapeRe(w).replace(/\s+/g, '\\s+')).join('|');
64
+ }
65
+
66
+ // A heading opens a decisions scope when the section word is one of its first
67
+ // 4 words, after any leading enumerator or marker. A heading that only
68
+ // mentions a decision further along is about something else.
69
+ export function isDecisionHeading(text, section = DEFAULTS.section) {
70
+ const stem = section.toLowerCase().replace(/s$/, '');
71
+ const stripped = text
72
+ .replace(/^[^\p{L}\p{N}]+/u, '')
73
+ .replace(/^(?:[A-Z]|[A-Z]?\d+[A-Za-z]?(?:\.\d+)*)[.)]\s+(?:[—–-]\s+)?/, '')
74
+ .replace(/^[^\p{L}\p{N}]+/u, '');
75
+ const words = stripped.split(/\s+/)
76
+ .filter(w => /[\p{L}\p{N}]/u.test(w))
77
+ .slice(0, 4)
78
+ .map(w => w.toLowerCase().replace(/[^\p{L}\p{N}-]+$/u, ''));
79
+ return words.some(w => w === stem || w === `${stem}s`);
80
+ }
81
+
82
+ function itemPatterns(id) {
83
+ return [
84
+ { kind: 'register', re: new RegExp(`^(${id})\\s{1,3}(\\S.*)$`), registerOnly: true },
85
+ { kind: 'table', re: new RegExp(`^\\|\\s*\\**(${id})\\**\\s*\\|(.*)$`) },
86
+ { kind: 'heading', re: new RegExp(`^(#{2,6})\\s+\\**(${id})\\b[\\s,.:—-]*(.*)$`) },
87
+ { kind: 'record', re: new RegExp(`^\\*\\*(${id})\\b[\\s,.:—-]+(.*)$`) },
88
+ { kind: 'list', re: new RegExp(`^[-*]\\s+(?:\\[[ xX]\\]\\s+)?\\**(${id})\\b[\\s,.:—-]+(.*)$`) },
89
+ ];
90
+ }
91
+
92
+ // An item that presents as a decision and carries no id: a bold `Decision`
93
+ // lead inside a decisions section.
94
+ const UNNAMED = /^(?:[-*]\s+(?:\[[ xX]\]\s+)?)?\*\*Decisions?\b/i;
95
+
96
+ /**
97
+ * Every decision item in one document's text, in file order. Pure.
98
+ * Returns `{id, line, kind, level, context, lines, text}`; `id` is null for an
99
+ * unnamed item.
100
+ */
101
+ export function parseDecisionItems(text, settings = DEFAULTS) {
102
+ const s = decisionSettings(settings);
103
+ const lines = text.split('\n');
104
+ // Frontmatter is blanked so line numbers stay file coordinates.
105
+ if (lines[0] === '---') {
106
+ const end = lines.indexOf('---', 1);
107
+ if (end > 0) for (let i = 0; i <= end; i++) lines[i] = '';
108
+ }
109
+ const patterns = itemPatterns(s.id);
110
+ const statusLine = s.register?.statusLine?.trim().toLowerCase() ?? null;
111
+ const vocab = s.vocabulary;
112
+ const items = [];
113
+
114
+ let fence = null;
115
+ let inRegister = false;
116
+ let scope = 0; // the level of the decisions heading in force, 0 for none
117
+ let context = null;
118
+
119
+ for (let i = 0; i < lines.length; i++) {
120
+ const line = lines[i].trimEnd();
121
+ const f = line.match(/^\s*(`{3,}|~{3,})/);
122
+ if (f) {
123
+ if (fence === null) {
124
+ fence = f[1][0];
125
+ const first = (lines[i + 1] ?? '').trim();
126
+ inRegister = !!statusLine && first.toLowerCase().includes(statusLine);
127
+ context = inRegister ? leadingDisposition(first, vocab) : null;
128
+ if (inRegister) i++; // the status line is not a row
129
+ } else if (f[1][0] === fence) {
130
+ fence = null;
131
+ inRegister = false;
132
+ context = null;
133
+ }
134
+ closeOpen(items);
135
+ continue;
136
+ }
137
+ if (fence !== null && !inRegister) continue;
138
+
139
+ if (fence === null) {
140
+ const h = line.match(/^(#{1,6})\s+(.+)$/);
141
+ if (h) {
142
+ const level = h[1].length;
143
+ // A heading that shouts a disposition (`Decisions, both RESOLVED
144
+ // 2025-01-01`) marks the items under it, as a bold lead does.
145
+ context = s.prose ? proseDisposition(h[2], vocab, s.patterns, { shoutedOnly: true }) : null;
146
+ const open = items.at(-1);
147
+ if (open && !open.closed && open.kind === 'heading' && level > open.level) {
148
+ // A subheading inside a heading record is part of it.
149
+ } else {
150
+ closeOpen(items);
151
+ }
152
+ if (isDecisionHeading(h[2], s.section)) scope = level;
153
+ else if (scope && level <= scope) scope = 0;
154
+ }
155
+ const lead = line.match(/^\*\*([^*]{1,120})\*\*/);
156
+ if (lead && !patterns.some(p => p.re.test(line))) {
157
+ const kind = leadingDisposition(lead[1], vocab);
158
+ if (kind) context = kind;
159
+ }
160
+ }
161
+
162
+ const inScope = inRegister || scope > 0;
163
+ let matched = false;
164
+ if (inScope) {
165
+ for (const p of patterns) {
166
+ if (Boolean(p.registerOnly) !== inRegister) continue;
167
+ const m = line.match(p.re);
168
+ if (!m) continue;
169
+ closeOpen(items);
170
+ const heading = p.kind === 'heading';
171
+ items.push({
172
+ id: heading ? m[2] : m[1],
173
+ line: i + 1,
174
+ kind: p.kind,
175
+ level: heading ? m[1].length : 0,
176
+ context,
177
+ lines: [heading ? m[3] : m[2]],
178
+ });
179
+ matched = true;
180
+ break;
181
+ }
182
+ if (!matched && !inRegister && UNNAMED.test(line)) {
183
+ closeOpen(items);
184
+ items.push({ id: null, line: i + 1, kind: 'unnamed', level: 0, context, lines: [line] });
185
+ matched = true;
186
+ }
187
+ }
188
+ if (matched) continue;
189
+
190
+ const open = items.at(-1);
191
+ if (!open || open.closed) continue;
192
+ if (open.kind === 'register') { open.closed = true; continue; }
193
+ if (open.kind === 'heading') {
194
+ if (/^#{1,6}\s/.test(line) && line.match(/^(#{1,6})/)[1].length <= open.level) { open.closed = true; continue; }
195
+ open.lines.push(line);
196
+ continue;
197
+ }
198
+ if (line.trim() === '' || /^#{1,6}\s/.test(line) || open.lines.length >= MAX_BODY_LINES) {
199
+ if (line.trim() === '' && open.lines.length < MAX_BODY_LINES && !open.sawBlank) { open.sawBlank = true; continue; }
200
+ open.closed = true;
201
+ continue;
202
+ }
203
+ open.lines.push(line);
204
+ open.sawBlank = false;
205
+ }
206
+
207
+ return items.map(({ id, line, kind, level, context: ctx, lines: body }) => ({
208
+ id, line, kind, level, context: ctx, text: body.join('\n').trim(),
209
+ }));
210
+ }
211
+
212
+ function closeOpen(items) {
213
+ const open = items.at(-1);
214
+ if (open) open.closed = true;
215
+ }
216
+
217
+ // ── Disposition ──────────────────────────────────────────────────────────────
218
+
219
+ /** The disposition a line opens with (`RULED 2026-09-21: …`, `Open, waiting…`). */
220
+ export function leadingDisposition(text, vocab = DEFAULTS.vocabulary) {
221
+ const t = text.replace(/^[\s*_`]+/, '');
222
+ for (const kind of KINDS) {
223
+ if (new RegExp(`^(?:${wordsRe(vocab[kind] ?? [])})\\b`, 'i').test(t)) return kind;
224
+ }
225
+ return null;
226
+ }
227
+
228
+ /** An explicit `Disposition: RULED 2026-09-21` line, or a shouted opening word. */
229
+ export function explicitDisposition(text, vocab = DEFAULTS.vocabulary) {
230
+ const line = text.match(/\bDisposition\**\s*:\s*\**\s*(\S[^\n]*)$/im);
231
+ if (line) return leadingDisposition(line[1], vocab);
232
+ const first = text.trimStart().match(/^[A-Z][A-Z ]*\b/);
233
+ if (first) {
234
+ const kind = leadingDisposition(first[0], vocab);
235
+ if (kind) return kind;
236
+ }
237
+ return null;
238
+ }
239
+
240
+ const DATE = '20\\d{2}-\\d{2}-\\d{2}';
241
+
242
+ /**
243
+ * Every disposition word in prose, in order. A ruling word counts only with a
244
+ * date right against it; a closing word only with a reason after it.
245
+ */
246
+ export function proseMarkers(text, vocab = DEFAULTS.vocabulary, patterns = {}) {
247
+ const found = [];
248
+ const add = (kind, re, test = () => true) => {
249
+ for (const m of text.matchAll(re)) {
250
+ if (!test(m)) continue;
251
+ found.push({ kind, index: m.index, caps: /[A-Z]/.test(m[1]) && m[1] === m[1].toUpperCase() });
252
+ }
253
+ };
254
+ add('ruled', new RegExp(`\\b(${wordsRe(vocab.ruled ?? [])})\\b[,:;—-]?\\s*(?:\\([^)]{0,48}?)?(?:on\\s+|at\\s+|the\\s+)?${DATE}`, 'gi'));
255
+ add('closed', new RegExp(`\\b(${wordsRe(vocab.closed ?? [])})\\b`, 'gi'),
256
+ m => text.slice(m.index + m[0].length).replace(/[^a-z]/gi, '').length >= 12);
257
+ add('held', new RegExp(`\\b(${wordsRe(vocab.held ?? [])})\\b`, 'gi'));
258
+ add('open', new RegExp(`\\b(${wordsRe(vocab.open ?? [])})\\b`, 'gi'));
259
+ for (const kind of KINDS) {
260
+ for (const src of patterns[kind] ?? []) add(kind, new RegExp(`(${src})`, 'gi'));
261
+ }
262
+ return found.sort((a, b) => a.index - b.index);
263
+ }
264
+
265
+ /**
266
+ * Prose disposition: a shouted word is the row's own line and the last one
267
+ * wins, because rows are edited in place; with nothing shouted, an answer
268
+ * outranks a question.
269
+ */
270
+ export function proseDisposition(text, vocab = DEFAULTS.vocabulary, patterns = {}, { shoutedOnly = false } = {}) {
271
+ const markers = proseMarkers(text, vocab, patterns);
272
+ if (!markers.length) return null;
273
+ const caps = markers.filter(m => m.caps);
274
+ if (caps.length) return caps.at(-1).kind;
275
+ if (shoutedOnly) return null;
276
+ for (const kind of KINDS) if (markers.some(m => m.kind === kind)) return kind;
277
+ return null;
278
+ }
279
+
280
+ // Explicit line first, then a shouted word in the item, then the block or bold
281
+ // lead the item sits under, then any word in its prose. A lead such as
282
+ // `**Ruled 2026-07-02:**` is a deliberate mark over the rows below it; a
283
+ // lowercase `open` inside a row is as often about something else.
284
+ export function dispositionOf(item, settings = DEFAULTS) {
285
+ const s = decisionSettings(settings);
286
+ const prose = opts => (s.prose ? proseDisposition(item.text, s.vocabulary, s.patterns, opts) : null);
287
+ return explicitDisposition(item.text, s.vocabulary)
288
+ ?? prose({ shoutedOnly: true })
289
+ ?? item.context
290
+ ?? prose()
291
+ ?? null;
292
+ }
293
+
294
+ // ── The record's parts ───────────────────────────────────────────────────────
295
+
296
+ const CITATION = [
297
+ /`[^`]*\.(?:ts|tsx|swift|mjs|js|sql|json|sh|py|yaml|yml|kt|java|rs|css|graphql|go|rb)(?::\d+)?[^`]*`/,
298
+ /\b[\w@./-]+\.(?:ts|tsx|swift|mjs|sql|graphql|go|rb|py)(?::\d+(?:-\d+)?)?\b/,
299
+ /`[^`]*:\d+(?:-\d+)?`/,
300
+ /\[[^\]]+\]\([^)]*\.md[^)]*\)/,
301
+ /\b[\w-]+\.md\b/,
302
+ ];
303
+ const ANSWER_PHRASE = /\b(each answer|either answer|both answers|the alternative|what visibly (?:changes|differs))\b/i;
304
+ const OPTION_CLAUSE = /(?:^|[.;!?]\s+|\|\s*|\*\*)([A-Z][^.:;|\n]{1,70}):\s+[a-z"']/g;
305
+
306
+ export function sentences(text) {
307
+ return text.replace(/\s+/g, ' ').split(/(?<=[.?!])\s+(?=[A-Z"'`*(])/).map(t => t.trim()).filter(Boolean);
308
+ }
309
+
310
+ /** Which parts an item's own text carries, from the detectors the tool ships. */
311
+ export function recordParts(text, settings = DEFAULTS) {
312
+ const s = decisionSettings(settings);
313
+ const body = text.replace(/\s+/g, ' ').trim();
314
+ const prose = sentences(text).some(t => !t.endsWith('?') && t.replace(/[^a-z]/gi, '').length >= 40);
315
+ const citation = CITATION.some(re => re.test(text));
316
+ const extra = (s.answers ?? []).map(src => new RegExp(src, 'i'));
317
+ const answers = ANSWER_PHRASE.test(body)
318
+ || extra.some(re => re.test(body))
319
+ || [...body.matchAll(OPTION_CLAUSE)].length >= 2
320
+ || (/\bYes\b\s*[:,]/i.test(body) && /\bNo\b\s*[:,]/i.test(body));
321
+ return { prose, citation, answers };
322
+ }
323
+
324
+ /** Documents an item points at, by basename. */
325
+ export function pointedDocs(text) {
326
+ const out = new Set();
327
+ for (const m of text.matchAll(/\[[^\]]*\]\(([^)\s]*\.md)(?:#[^)]*)?\)/g)) out.add(m[1].split('/').pop());
328
+ for (const m of text.matchAll(/\b([\w.-]+\.md)\b/g)) out.add(m[1]);
329
+ return out;
330
+ }
331
+
332
+ // ── Assembly ─────────────────────────────────────────────────────────────────
333
+
334
+ /**
335
+ * Every item across the given documents with its disposition, its parts (its
336
+ * own plus a linked record's) and what it is missing. Pure.
337
+ * @param {{path: string, text: string}[]} docs
338
+ */
339
+ export function analyzeDecisions(docs, settings = DEFAULTS) {
340
+ const s = decisionSettings(settings);
341
+ const items = [];
342
+ for (const doc of docs) {
343
+ for (const item of parseDecisionItems(doc.text, s)) {
344
+ items.push({
345
+ ...item,
346
+ doc: doc.path,
347
+ file: path.basename(doc.path),
348
+ disposition: dispositionOf(item, s),
349
+ own: recordParts(item.text, s),
350
+ points: pointedDocs(item.text),
351
+ });
352
+ }
353
+ }
354
+
355
+ const byId = new Map();
356
+ for (const item of items) {
357
+ if (!item.id) continue;
358
+ if (!byId.has(item.id)) byId.set(item.id, []);
359
+ byId.get(item.id).push(item);
360
+ }
361
+ const linked = (a, b) => a !== b && a.file !== b.file && (a.points.has(b.file) || b.points.has(a.file));
362
+
363
+ for (const item of items) {
364
+ item.peers = item.id ? byId.get(item.id).filter(p => linked(item, p)) : [];
365
+ const parts = { ...item.own };
366
+ for (const peer of item.peers) for (const k of Object.keys(parts)) if (peer.own[k]) parts[k] = true;
367
+ item.parts = parts;
368
+ // A register row that indexes this record governs its disposition.
369
+ const row = item.kind === 'register' ? null : item.peers.find(p => p.kind === 'register');
370
+ item.effective = row?.disposition ?? item.disposition;
371
+ const missing = [];
372
+ if (!item.id) missing.push('id');
373
+ if (!item.disposition) missing.push('disposition');
374
+ for (const part of s.requires[item.disposition] ?? []) if (!parts[part]) missing.push(part);
375
+ item.missing = missing;
376
+ }
377
+ return items;
378
+ }
379
+
380
+ /** The pending rows, one per decision: linked pairs collapse, a register row preferred. */
381
+ export function pendingRows(items, { doc = null, all = false } = {}) {
382
+ let rows = items.filter(i => i.id && (all || PENDING.has(i.effective)));
383
+ if (doc) rows = rows.filter(i => i.doc === doc);
384
+ const kept = [];
385
+ const rank = i => (i.kind === 'register' ? 0 : 1);
386
+ for (const item of [...rows].sort((a, b) => rank(a) - rank(b))) {
387
+ const dup = kept.find(k => k.id === item.id && (k.doc === item.doc || k.peers.includes(item)));
388
+ if (!dup) kept.push(item);
389
+ }
390
+ const shared = new Map();
391
+ for (const k of kept) shared.set(k.id, (shared.get(k.id) ?? 0) + 1);
392
+ return kept
393
+ .sort((a, b) => a.id.localeCompare(b.id, 'en', { numeric: true }) || a.doc.localeCompare(b.doc))
394
+ .map(item => ({
395
+ id: item.id,
396
+ label: shared.get(item.id) > 1 ? `${item.id} (${item.file})` : item.id,
397
+ doc: item.doc,
398
+ line: item.line,
399
+ kind: item.kind,
400
+ disposition: item.effective,
401
+ missing: item.missing,
402
+ text: renderLine(item),
403
+ }));
404
+ }
405
+
406
+ const clean = t => t
407
+ .replace(/\|/g, ' ')
408
+ .replace(/\*\*/g, '')
409
+ .replace(/`/g, '')
410
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
411
+ .replace(/^\s*(?:[-*]\s+)?Disposition\s*:[^\n]*$/gim, '')
412
+ .replace(/\s+/g, ' ')
413
+ .trim();
414
+
415
+ function renderLine(item) {
416
+ const partsMissing = item.missing.filter(m => m !== 'id' && m !== 'disposition');
417
+ if (partsMissing.length) return `(record incomplete: missing ${partsMissing.join(', ')})`;
418
+ const body = clean(item.text);
419
+ if (item.kind === 'register') return body;
420
+ const parts = sentences(body);
421
+ const at = parts.findIndex(t => t.endsWith('?'));
422
+ if (at === -1) return body.slice(0, 400);
423
+ return [parts[at], ...parts.slice(at + 1, at + 4)].join(' ');
424
+ }
425
+
426
+ /** Defects, one per finding, for `--check`. */
427
+ export function decisionDefects(items) {
428
+ const out = [];
429
+ for (const item of items) {
430
+ if (item.missing.length) {
431
+ out.push({ doc: item.doc, line: item.line, id: item.id, message: `missing ${item.missing.join(', ')}` });
432
+ }
433
+ }
434
+ const seen = new Map();
435
+ for (const item of items) {
436
+ if (!item.id) continue;
437
+ const key = `${item.doc}#${item.id}#${item.kind === 'register'}`;
438
+ const prior = seen.get(key);
439
+ if (prior) out.push({ doc: item.doc, line: item.line, id: item.id, message: `id used again in this document (first at line ${prior.line})` });
440
+ else seen.set(key, item);
441
+ }
442
+ for (const item of items) {
443
+ if (!PENDING.has(item.disposition)) continue;
444
+ for (const peer of item.peers) {
445
+ if (peer.disposition === 'ruled' || peer.disposition === 'closed') {
446
+ out.push({ doc: item.doc, line: item.line, id: item.id, message: `${item.disposition} here, ${peer.disposition} at ${peer.doc}:${peer.line}` });
447
+ }
448
+ }
449
+ }
450
+ return out.sort((a, b) => a.doc.localeCompare(b.doc) || a.line - b.line);
451
+ }
452
+
453
+ // ── CLI ──────────────────────────────────────────────────────────────────────
454
+
455
+ export function loadDecisionDocs(config, settings) {
456
+ const s = decisionSettings(settings);
457
+ const types = new Set(s.types);
458
+ const archiveDir = config.archiveDir ?? 'archived';
459
+ const { docs } = buildIndex(config, { fast: true });
460
+ return docs
461
+ // A document whose frontmatter did not parse has no type; it is read rather than dropped.
462
+ .filter(d => (types.has(d.type) || !d.type) && d.status !== 'archived' && !d.path.split('/').includes(archiveDir))
463
+ .filter(d => !s.paths || [].concat(s.paths).some(p => d.path === p || d.path.startsWith(`${p.replace(/\/$/, '')}/`)))
464
+ .map(d => ({ path: d.path, text: readFileSync(path.join(config.repoRoot, d.path), 'utf8') }));
465
+ }
466
+
467
+ export function runDecisions(args, config) {
468
+ const settings = decisionSettings(config.raw?.decisions ?? {});
469
+ const json = args.includes('--json');
470
+ const all = args.includes('--all');
471
+ const check = args.includes('--check');
472
+ const target = args.find(a => !a.startsWith('-')) ?? null;
473
+
474
+ const docs = loadDecisionDocs(config, settings);
475
+ const items = analyzeDecisions(docs, settings);
476
+
477
+ if (check) {
478
+ const defects = decisionDefects(items);
479
+ if (json) process.stdout.write(`${JSON.stringify({ ok: defects.length === 0, items: items.length, defects }, null, 2)}\n`);
480
+ else {
481
+ for (const d of defects) process.stdout.write(`${d.doc}:${d.line} ${d.id ?? '(no id)'} ${d.message}\n`);
482
+ process.stdout.write(`${items.length} decision items in ${docs.length} documents; ${defects.length} defects.\n`);
483
+ }
484
+ if (defects.length) process.exitCode = 1;
485
+ return;
486
+ }
487
+
488
+ let docFilter = null;
489
+ if (target) {
490
+ const idRe = new RegExp(`^(?:${settings.id})$`);
491
+ const resolved = idRe.test(target) ? null : resolveDocArg(target, config, { dieOnMiss: false });
492
+ if (resolved) docFilter = path.relative(config.repoRoot, resolved).split(path.sep).join('/');
493
+ else if (idRe.test(target)) return showRecord(target, items, docs, json);
494
+ else die(`No document or decision id matches "${target}".`);
495
+ }
496
+
497
+ const rows = pendingRows(items, { doc: docFilter, all });
498
+ if (json) {
499
+ process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`);
500
+ return;
501
+ }
502
+ if (!rows.length) {
503
+ process.stdout.write(docFilter ? `No open or held decisions in ${docFilter}.\n` : 'No open or held decisions.\n');
504
+ } else {
505
+ process.stdout.write(`${settings.listHeading}\n\n`);
506
+ for (const row of rows) {
507
+ const tag = all ? `[${row.disposition ?? 'none'}] ` : '';
508
+ process.stdout.write(`${row.label} ${tag}${row.text}\n`);
509
+ }
510
+ }
511
+ const unread = items.filter(i => (!docFilter || i.doc === docFilter) && (!i.id || !i.disposition));
512
+ if (unread.length) {
513
+ const noId = unread.filter(i => !i.id).length;
514
+ process.stderr.write(`${unread.length} decision items could not be read (${noId} with no id, ${unread.length - noId} with no disposition); runlist decisions --check lists them.\n`);
515
+ }
516
+ }
517
+
518
+ function showRecord(id, items, docs, json) {
519
+ const hits = items.filter(i => i.id === id);
520
+ if (!hits.length) die(`No decision ${id} in any document.`);
521
+ if (json) {
522
+ process.stdout.write(`${JSON.stringify(hits.map(h => ({ id, doc: h.doc, line: h.line, kind: h.kind, disposition: h.disposition, missing: h.missing, text: h.text })), null, 2)}\n`);
523
+ return;
524
+ }
525
+ const texts = new Map(docs.map(d => [d.path, d.text.split('\n')]));
526
+ hits.forEach((h, n) => {
527
+ const lines = texts.get(h.doc);
528
+ const bodyLines = h.text.split('\n').filter(l => l.trim() !== '').length;
529
+ // The record as written: its first line through the last line of its body.
530
+ let end = h.line - 1;
531
+ let seen = 0;
532
+ while (end < lines.length && seen < bodyLines) {
533
+ if (lines[end].trim() !== '') seen++;
534
+ end++;
535
+ }
536
+ if (n) process.stdout.write('\n');
537
+ process.stdout.write(`${h.doc}:${h.line} ${h.disposition ?? 'no disposition'}\n`);
538
+ process.stdout.write(`${lines.slice(h.line - 1, end).join('\n').trimEnd()}\n`);
539
+ });
540
+ }
package/src/fix-refs.mjs CHANGED
@@ -43,9 +43,7 @@ export function fixBrokenRefs(config, opts = {}) {
43
43
 
44
44
  // ── Fix broken frontmatter references ────────────────────────────────
45
45
 
46
- const brokenRefErrors = index.errors.filter(e =>
47
- e.message.includes('does not resolve to an existing file')
48
- );
46
+ const brokenRefErrors = index.errors.filter(error => error.meta?.kind === 'ref-resolution');
49
47
 
50
48
  if (brokenRefErrors.length > 0) {
51
49
  const fixesByDoc = new Map();
@@ -101,12 +99,12 @@ export function fixBrokenRefs(config, opts = {}) {
101
99
  // ── Fix broken body links ────────────────────────────────────────────
102
100
 
103
101
  for (const doc of index.docs) {
104
- const brokenBodyWarnings = doc.warnings.filter(w =>
105
- w.meta?.kind === 'body-link-resolution'
106
- && w.meta?.targetKind === 'document'
107
- && w.meta?.reason === 'missing'
102
+ const brokenBodyErrors = doc.errors.filter(error =>
103
+ error.meta?.kind === 'body-link-resolution'
104
+ && error.meta?.targetKind === 'document'
105
+ && error.meta?.reason === 'missing'
108
106
  );
109
- if (!brokenBodyWarnings.length) continue;
107
+ if (!brokenBodyErrors.length) continue;
110
108
 
111
109
  const absPath = path.join(config.repoRoot, doc.path);
112
110
  let raw = readFileSync(absPath, 'utf8');
@@ -116,10 +114,10 @@ export function fixBrokenRefs(config, opts = {}) {
116
114
  const bodyFixes = [];
117
115
  const seenBodyTargets = new Set();
118
116
 
119
- for (const warn of brokenBodyWarnings) {
120
- const brokenHref = warn.meta.relPath;
121
- const rawHref = warn.meta.rawHref ?? brokenHref;
122
- const targetKey = `${warn.meta.angle ? 'angle' : 'plain'}:${rawHref}`;
117
+ for (const error of brokenBodyErrors) {
118
+ const brokenHref = error.meta.relPath;
119
+ const rawHref = error.meta.rawHref ?? brokenHref;
120
+ const targetKey = `${error.meta.angle ? 'angle' : 'plain'}:${rawHref}`;
123
121
  if (seenBodyTargets.has(targetKey)) continue;
124
122
  seenBodyTargets.add(targetKey);
125
123
  const brokenBasename = path.basename(brokenHref);
@@ -133,11 +131,11 @@ export function fixBrokenRefs(config, opts = {}) {
133
131
 
134
132
  const suffixAt = rawHref.search(/[?#]/);
135
133
  const suffix = suffixAt === -1 ? '' : rawHref.slice(suffixAt);
136
- const renderedHref = warn.meta.angle
134
+ const renderedHref = error.meta.angle
137
135
  ? `<${correctHref}${suffix}>`
138
136
  : `${correctHref.replace(/([\s()[\]<>])/g, '\\$1')}${suffix}`;
139
137
  const linkRegex = new RegExp(
140
- '(\\]\\(\\s*)' + (warn.meta.angle ? '<' : '') + escapeRegex(rawHref) + (warn.meta.angle ? '>' : '') + '(?=\\s|\\))',
138
+ '(\\]\\(\\s*)' + (error.meta.angle ? '<' : '') + escapeRegex(rawHref) + (error.meta.angle ? '>' : '') + '(?=\\s|\\))',
141
139
  'g'
142
140
  );
143
141
  let replacements = 0;
package/src/render.mjs CHANGED
@@ -516,15 +516,7 @@ export function renderManualFixes(index) {
516
516
  }
517
517
 
518
518
  export function buildReferenceValidationCoverage(index, config) {
519
- let checkedDocs = 0;
520
- let terminalDocsSkipped = 0;
521
- for (const doc of index.docs) {
522
- const terminal = config.lifecycle.isTerminal?.(doc.status, doc.type)
523
- ?? config.lifecycle.terminalStatuses.has(doc.status);
524
- if (terminal) terminalDocsSkipped++;
525
- else checkedDocs++;
526
- }
527
- return { checkedDocs, terminalDocsSkipped };
519
+ return { checkedDocs: index.docs.length, terminalDocsSkipped: 0 };
528
520
  }
529
521
 
530
522
  function _renderCheck(index, config, opts = {}) {
package/src/validate.mjs CHANGED
@@ -254,52 +254,45 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
254
254
  }
255
255
  }
256
256
 
257
- // Validate reference fields resolve to existing files. Terminal statuses
258
- // (archived, deprecated, etc.) document historical state — their refs may
259
- // legitimately point at moved/deleted targets and shouldn't gate the
260
- // exit code with a hard error.
257
+ // Historical documents still need navigable references. Their prose is a
258
+ // dated record, but links to moved or deleted files cannot be followed.
261
259
  const docDir = path.dirname(path.join(config.repoRoot, doc.path));
262
260
  const allRefFields = [...(config.referenceFields.bidirectional || []), ...(config.referenceFields.unidirectional || [])];
263
- const skipRefValidation = config.lifecycle.isTerminal?.(doc.status, doc.type)
264
- ?? config.lifecycle.terminalStatuses.has(doc.status);
265
- if (!skipRefValidation) {
266
- for (const field of allRefFields) {
267
- for (const relPath of (doc.refFields[field] || [])) {
268
- if (!resolveRefPath(relPath, docDir, config.repoRoot)) {
269
- doc.errors.push({
270
- path: doc.path,
271
- level: 'error',
272
- message: `${field} entry \`${relPath}\` does not resolve to an existing file.`,
273
- meta: { kind: 'ref-resolution', field, relPath },
274
- });
275
- }
276
- }
277
- }
278
-
279
- // Validate body links resolve to existing files
280
- for (const link of (doc.bodyLinks || [])) {
281
- const resolution = resolveBodyLinkTarget(link.href, docDir, config.repoRoot);
282
- if (!resolution.ok) {
283
- const shownHref = link.rawHref ?? link.href;
284
- doc.warnings.push({
261
+ for (const field of allRefFields) {
262
+ for (const relPath of (doc.refFields[field] || [])) {
263
+ if (!resolveRefPath(relPath, docDir, config.repoRoot)) {
264
+ doc.errors.push({
285
265
  path: doc.path,
286
- level: 'warning',
287
- message: resolution.reason === 'outside-repo'
288
- ? `body link \`${shownHref}\` escapes the repository.`
289
- : `body link \`${shownHref}\` does not resolve to an existing file or directory.`,
290
- meta: {
291
- kind: 'body-link-resolution',
292
- field: 'body-link',
293
- relPath: link.href,
294
- rawHref: shownHref,
295
- targetKind: link.targetKind ?? 'document',
296
- angle: link.angle === true,
297
- reason: resolution.reason,
298
- },
266
+ level: 'error',
267
+ message: `${field} entry \`${relPath}\` does not resolve to an existing file.`,
268
+ meta: { kind: 'ref-resolution', field, relPath },
299
269
  });
300
270
  }
301
271
  }
302
272
  }
273
+
274
+ for (const link of (doc.bodyLinks || [])) {
275
+ const resolution = resolveBodyLinkTarget(link.href, docDir, config.repoRoot);
276
+ if (!resolution.ok) {
277
+ const shownHref = link.rawHref ?? link.href;
278
+ doc.errors.push({
279
+ path: doc.path,
280
+ level: 'error',
281
+ message: resolution.reason === 'outside-repo'
282
+ ? `body link \`${shownHref}\` escapes the repository.`
283
+ : `body link \`${shownHref}\` does not resolve to an existing file or directory.`,
284
+ meta: {
285
+ kind: 'body-link-resolution',
286
+ field: 'body-link',
287
+ relPath: link.href,
288
+ rawHref: shownHref,
289
+ targetKind: link.targetKind ?? 'document',
290
+ angle: link.angle === true,
291
+ reason: resolution.reason,
292
+ },
293
+ });
294
+ }
295
+ }
303
296
  }
304
297
 
305
298
  // Guess which doc type a ref field expects so suggestions don't propose