dotmd-cli 0.86.0 → 0.87.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.
@@ -0,0 +1,752 @@
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}-?[A-Z]?\\d{1,3}[a-z]?(?:[-.][A-Z0-9]{1,3}\\b)?',
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
+ // A compound id (`P4-D2`, `D1-R`, `D1.2`) is read whole; `D1.` at a
33
+ // sentence end is still `D1`.
34
+ id: '[A-Z]{1,3}-?[A-Z]?\\d{1,3}[a-z]?(?:[-.][A-Z0-9]{1,3}\\b)?',
35
+ prose: false,
36
+ vocabulary: {
37
+ ruled: ['ruled', 'ratified', 'resolved', 'approved', 'decided', 'answered', 'settled', 'locked'],
38
+ closed: ['closed', 'withdrawn', 'superseded', 'overtaken', 'retired', 'cancelled', 'canceled', 'not a decision'],
39
+ held: ['held', 'on hold', 'deferred', 'paused', 'parked', 'blocked'],
40
+ open: ['open', 'unruled', 'unratified', 'pending', 'awaiting', 'undecided', 'unanswered', 'tbd'],
41
+ },
42
+ patterns: {},
43
+ requires: {},
44
+ answers: [],
45
+ register: null,
46
+ listHeading: 'Waiting on a decision:',
47
+ });
48
+
49
+ export const PENDING = new Set(['open', 'held']);
50
+ const KINDS = ['ruled', 'closed', 'held', 'open'];
51
+ const MAX_BODY_LINES = 24;
52
+
53
+ const escapeRe = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
54
+
55
+ export function decisionSettings(raw = {}) {
56
+ const s = { ...DEFAULTS, ...raw };
57
+ s.vocabulary = { ...DEFAULTS.vocabulary, ...(raw.vocabulary ?? {}) };
58
+ s.types = [].concat(s.types ?? DEFAULTS.types);
59
+ return s;
60
+ }
61
+
62
+ // ── Grammar ──────────────────────────────────────────────────────────────────
63
+
64
+ function wordsRe(words) {
65
+ return words.map(w => escapeRe(w).replace(/\s+/g, '\\s+')).join('|');
66
+ }
67
+
68
+ // A heading opens a decisions scope when the section word is one of its first
69
+ // 4 words, after any leading enumerator or marker. A heading that only
70
+ // mentions a decision further along is about something else.
71
+ export function isDecisionHeading(text, section = DEFAULTS.section) {
72
+ const stem = section.toLowerCase().replace(/s$/, '');
73
+ const stripped = text
74
+ .replace(/^[^\p{L}\p{N}]+/u, '')
75
+ .replace(/^(?:[A-Z]|[A-Z]?\d+[A-Za-z]?(?:\.\d+)*)[.)]\s+(?:[—–-]\s+)?/, '')
76
+ .replace(/^[^\p{L}\p{N}]+/u, '');
77
+ const words = stripped.split(/\s+/)
78
+ .filter(w => /[\p{L}\p{N}]/u.test(w))
79
+ .slice(0, 4)
80
+ .map(w => w.toLowerCase().replace(/[^\p{L}\p{N}-]+$/u, ''));
81
+ return words.some(w => w === stem || w === `${stem}s`);
82
+ }
83
+
84
+ // A row may index a run of decisions written as a range, `A2 to A12`; the
85
+ // range is its id.
86
+ function itemPatterns(single) {
87
+ const id = `${single}(?:\\s+(?:to|through)\\s+${single})?`;
88
+ return [
89
+ { kind: 'register', re: new RegExp(`^(${id})\\s{1,3}(\\S.*)$`), registerOnly: true },
90
+ { kind: 'table', re: new RegExp(`^\\|\\s*\\**(${id})\\**\\s*\\|(.*)$`) },
91
+ { kind: 'heading', re: new RegExp(`^(#{2,6})\\s+\\**(${id})\\b[\\s,.:—-]*(.*)$`) },
92
+ { kind: 'record', re: new RegExp(`^\\*\\*(${id})\\b[\\s,.:—-]+(.*)$`) },
93
+ { kind: 'list', re: new RegExp(`^[-*]\\s+(?:\\[[ xX]\\]\\s+)?\\**(${id})\\b[\\s,.:—-]+(.*)$`) },
94
+ ];
95
+ }
96
+
97
+ // An item that presents as a decision and carries no id: a bold `Decision`
98
+ // lead inside a decisions section.
99
+ const UNNAMED = /^(?:[-*]\s+(?:\[[ xX]\]\s+)?)?\*\*Decisions?\b/i;
100
+
101
+ /**
102
+ * Every decision item in one document's text, in file order. Pure.
103
+ * Returns `{id, line, kind, level, context, lines, text}`; `id` is null for an
104
+ * unnamed item.
105
+ */
106
+ export function parseDecisionItems(text, settings = DEFAULTS) {
107
+ const s = decisionSettings(settings);
108
+ const lines = text.split('\n');
109
+ // Frontmatter is blanked so line numbers stay file coordinates.
110
+ if (lines[0] === '---') {
111
+ const end = lines.indexOf('---', 1);
112
+ if (end > 0) for (let i = 0; i <= end; i++) lines[i] = '';
113
+ }
114
+ const patterns = itemPatterns(s.id);
115
+ const statusLine = s.register?.statusLine?.trim().toLowerCase() ?? null;
116
+ const vocab = s.vocabulary;
117
+ const items = [];
118
+
119
+ let fence = null;
120
+ let inRegister = false;
121
+ let scope = 0; // the level of the decisions heading in force, 0 for none
122
+ let context = null;
123
+
124
+ for (let i = 0; i < lines.length; i++) {
125
+ const line = lines[i].trimEnd();
126
+ const f = line.match(/^\s*(`{3,}|~{3,})/);
127
+ if (f) {
128
+ if (fence === null) {
129
+ fence = f[1][0];
130
+ const first = (lines[i + 1] ?? '').trim();
131
+ inRegister = !!statusLine && first.toLowerCase().includes(statusLine);
132
+ context = inRegister ? leadingDisposition(first, vocab) : null;
133
+ if (inRegister) i++; // the status line is not a row
134
+ } else if (f[1][0] === fence) {
135
+ fence = null;
136
+ inRegister = false;
137
+ context = null;
138
+ }
139
+ closeOpen(items);
140
+ continue;
141
+ }
142
+ if (fence !== null && !inRegister) continue;
143
+
144
+ if (fence === null) {
145
+ const h = line.match(/^(#{1,6})\s+(.+)$/);
146
+ if (h) {
147
+ const level = h[1].length;
148
+ // A heading that shouts a disposition (`Decisions, both RESOLVED
149
+ // 2025-01-01`) marks the items under it, as a bold lead does.
150
+ // So does one that records a dated ruling (`Decisions, ruled by the
151
+ // owner 2025-01-01`): a date makes the ruling unambiguous in lowercase.
152
+ context = s.prose
153
+ ? proseDisposition(h[2], vocab, s.patterns, { shoutedOnly: true }) ?? datedRulingHeading(h[2], vocab, s.patterns)
154
+ : null;
155
+ const open = items.at(-1);
156
+ if (open && !open.closed && open.kind === 'heading' && level > open.level) {
157
+ // A subheading inside a heading record is part of it.
158
+ } else {
159
+ closeOpen(items);
160
+ }
161
+ if (isDecisionHeading(h[2], s.section)) scope = level;
162
+ else if (scope && level <= scope) scope = 0;
163
+ }
164
+ const lead = line.match(/^\*\*([^*]{1,120})\*\*/);
165
+ if (lead && !patterns.some(p => p.re.test(line))) {
166
+ const kind = leadingDisposition(lead[1], vocab);
167
+ if (kind) context = kind;
168
+ }
169
+ }
170
+
171
+ const inScope = inRegister || scope > 0;
172
+ let matched = false;
173
+ if (inScope) {
174
+ for (const p of patterns) {
175
+ if (Boolean(p.registerOnly) !== inRegister) continue;
176
+ const m = line.match(p.re);
177
+ if (!m) continue;
178
+ closeOpen(items);
179
+ const heading = p.kind === 'heading';
180
+ items.push({
181
+ id: heading ? m[2] : m[1],
182
+ line: i + 1,
183
+ kind: p.kind,
184
+ level: heading ? m[1].length : 0,
185
+ context,
186
+ lines: [heading ? m[3] : m[2]],
187
+ });
188
+ matched = true;
189
+ break;
190
+ }
191
+ // A bold `Decision:` field inside a heading record is part of that record.
192
+ const last = items.at(-1);
193
+ const insideRecord = last && !last.closed && last.kind === 'heading';
194
+ if (!matched && !inRegister && !insideRecord && UNNAMED.test(line)) {
195
+ closeOpen(items);
196
+ items.push({ id: null, line: i + 1, kind: 'unnamed', level: 0, context, lines: [line] });
197
+ matched = true;
198
+ }
199
+ }
200
+ if (matched) continue;
201
+
202
+ const open = items.at(-1);
203
+ if (!open || open.closed) continue;
204
+ if (open.kind === 'register') { open.closed = true; continue; }
205
+ if (open.kind === 'heading') {
206
+ if (/^#{1,6}\s/.test(line) && line.match(/^(#{1,6})/)[1].length <= open.level) { open.closed = true; continue; }
207
+ open.lines.push(line);
208
+ continue;
209
+ }
210
+ if (line.trim() === '' || /^#{1,6}\s/.test(line) || open.lines.length >= MAX_BODY_LINES) {
211
+ if (line.trim() === '' && open.lines.length < MAX_BODY_LINES && !open.sawBlank) { open.sawBlank = true; continue; }
212
+ open.closed = true;
213
+ continue;
214
+ }
215
+ open.lines.push(line);
216
+ open.sawBlank = false;
217
+ }
218
+
219
+ return items.map(({ id, line, kind, level, context: ctx, lines: body }) => ({
220
+ id, line, kind, level, context: ctx, text: body.join('\n').trim(),
221
+ }));
222
+ }
223
+
224
+ function closeOpen(items) {
225
+ const open = items.at(-1);
226
+ if (open) open.closed = true;
227
+ }
228
+
229
+ // ── Disposition ──────────────────────────────────────────────────────────────
230
+
231
+ /** The disposition a line opens with (`RULED 2026-09-21: …`, `Open, waiting…`). */
232
+ export function leadingDisposition(text, vocab = DEFAULTS.vocabulary) {
233
+ const t = text.replace(/^[\s*_`]+/, '');
234
+ for (const kind of KINDS) {
235
+ if (new RegExp(`^(?:${wordsRe(vocab[kind] ?? [])})\\b`, 'i').test(t)) return kind;
236
+ }
237
+ return null;
238
+ }
239
+
240
+ /** An explicit `Disposition: RULED 2026-09-21` line, or a shouted opening word. */
241
+ export function explicitDisposition(text, vocab = DEFAULTS.vocabulary) {
242
+ const line = text.match(/\bDisposition\**\s*:\s*\**\s*(\S[^\n]*)$/im);
243
+ if (line) return leadingDisposition(line[1], vocab);
244
+ const first = text.trimStart().match(/^[A-Z][A-Z ]*\b/);
245
+ if (first) {
246
+ const kind = leadingDisposition(first[0], vocab);
247
+ if (kind) return kind;
248
+ }
249
+ return null;
250
+ }
251
+
252
+ const DATE = '20\\d{2}-\\d{2}-\\d{2}';
253
+
254
+ /**
255
+ * Every disposition word in prose, in order. A ruling word counts only with a
256
+ * date right against it; a closing word only with a reason after it.
257
+ */
258
+ export function proseMarkers(text, vocab = DEFAULTS.vocabulary, patterns = {}) {
259
+ const found = [];
260
+ const add = (kind, re, test = () => true) => {
261
+ for (const m of text.matchAll(re)) {
262
+ if (!test(m)) continue;
263
+ found.push({ kind, index: m.index, caps: /[A-Z]/.test(m[1]) && m[1] === m[1].toUpperCase() });
264
+ }
265
+ };
266
+ add('ruled', new RegExp(`\\b(${wordsRe(vocab.ruled ?? [])})\\b[,:;—-]?\\s*(?:\\([^)]{0,48}?)?(?:on\\s+|at\\s+|the\\s+)?${DATE}`, 'gi'));
267
+ add('closed', new RegExp(`\\b(${wordsRe(vocab.closed ?? [])})\\b`, 'gi'),
268
+ m => text.slice(m.index + m[0].length).replace(/[^a-z]/gi, '').length >= 12);
269
+ add('held', new RegExp(`\\b(${wordsRe(vocab.held ?? [])})\\b`, 'gi'));
270
+ add('open', new RegExp(`\\b(${wordsRe(vocab.open ?? [])})\\b`, 'gi'));
271
+ for (const kind of KINDS) {
272
+ for (const src of patterns[kind] ?? []) add(kind, new RegExp(`(${src})`, 'gi'));
273
+ }
274
+ return found.sort((a, b) => a.index - b.index);
275
+ }
276
+
277
+ /**
278
+ * Prose disposition: a shouted word is the row's own line and the last one
279
+ * wins, because rows are edited in place; with nothing shouted, an answer
280
+ * outranks a question.
281
+ */
282
+ export function proseDisposition(text, vocab = DEFAULTS.vocabulary, patterns = {}, { shoutedOnly = false } = {}) {
283
+ const markers = proseMarkers(text, vocab, patterns);
284
+ if (!markers.length) return null;
285
+ const caps = markers.filter(m => m.caps);
286
+ if (caps.length) return caps.at(-1).kind;
287
+ if (shoutedOnly) return null;
288
+ for (const kind of KINDS) if (markers.some(m => m.kind === kind)) return kind;
289
+ return null;
290
+ }
291
+
292
+ // Explicit line first, then a shouted word in the item, then the block or bold
293
+ // lead the item sits under, then any word in its prose. A lead such as
294
+ // `**Ruled 2026-07-02:**` is a deliberate mark over the rows below it; a
295
+ // lowercase `open` inside a row is as often about something else.
296
+ export function dispositionOf(item, settings = DEFAULTS) {
297
+ const s = decisionSettings(settings);
298
+ const prose = opts => (s.prose ? proseDisposition(item.text, s.vocabulary, s.patterns, opts) : null);
299
+ return explicitDisposition(item.text, s.vocabulary)
300
+ ?? prose({ shoutedOnly: true })
301
+ ?? item.context
302
+ ?? prose()
303
+ ?? null;
304
+ }
305
+
306
+ // ── The record's parts ───────────────────────────────────────────────────────
307
+
308
+ const CITATION = [
309
+ /`[^`]*\.(?:ts|tsx|swift|mjs|js|sql|json|sh|py|yaml|yml|kt|java|rs|css|graphql|go|rb)(?::\d+)?[^`]*`/,
310
+ /\b[\w@./-]+\.(?:ts|tsx|swift|mjs|sql|graphql|go|rb|py)(?::\d+(?:-\d+)?)?\b/,
311
+ /`[^`]*:\d+(?:-\d+)?`/,
312
+ /\[[^\]]+\]\([^)]*\.md[^)]*\)/,
313
+ /\b[\w-]+\.md\b/,
314
+ ];
315
+ const ANSWER_PHRASE = /\b(each answer|either answer|both answers|the alternative|what visibly (?:changes|differs))\b/i;
316
+ const OPTION_CLAUSE = /(?:^|[.;!?]\s+|\|\s*|\*\*)([A-Z][^.:;|\n]{1,70}):\s+[a-z"']/g;
317
+
318
+ export function sentences(text) {
319
+ return text.replace(/\s+/g, ' ').split(/(?<=[.?!])\s+(?=[A-Z"'`*(])/).map(t => t.trim()).filter(Boolean);
320
+ }
321
+
322
+ /** Which parts an item's own text carries, from the detectors the tool ships. */
323
+ export function recordParts(text, settings = DEFAULTS) {
324
+ const s = decisionSettings(settings);
325
+ const body = text.replace(/\s+/g, ' ').trim();
326
+ const prose = sentences(text).some(t => !t.endsWith('?') && t.replace(/[^a-z]/gi, '').length >= 40);
327
+ const citation = CITATION.some(re => re.test(text));
328
+ const extra = (s.answers ?? []).map(src => new RegExp(src, 'i'));
329
+ const answers = ANSWER_PHRASE.test(body)
330
+ || extra.some(re => re.test(body))
331
+ || [...body.matchAll(OPTION_CLAUSE)].length >= 2
332
+ || (/\bYes\b\s*[:,]/i.test(body) && /\bNo\b\s*[:,]/i.test(body));
333
+ return { prose, citation, answers };
334
+ }
335
+
336
+ /** A heading that records a ruling with its date, and no other disposition. */
337
+ function datedRulingHeading(text, vocab, patterns) {
338
+ const dated = new RegExp(`\\b(?:${wordsRe(vocab.ruled ?? [])})\\b[^.;?!\\n]{0,40}?${DATE}`, 'i');
339
+ if (!dated.test(text)) return null;
340
+ return proseMarkers(text, vocab, patterns).every(m => m.kind === 'ruled') ? 'ruled' : null;
341
+ }
342
+
343
+ /** Documents an item points at, by basename. */
344
+ export function pointedDocs(text) {
345
+ const out = new Set();
346
+ for (const m of text.matchAll(/\[[^\]]*\]\(([^)\s]*\.md)(?:#[^)]*)?\)/g)) out.add(m[1].split('/').pop());
347
+ for (const m of text.matchAll(/\b([\w.-]+\.md)\b/g)) out.add(m[1]);
348
+ return out;
349
+ }
350
+
351
+ // ── Assembly ─────────────────────────────────────────────────────────────────
352
+
353
+ const LINK = /\[[^\]]*\]\(([^)\s]*\.md)(?:#[^)]*)?\)|\b([\w.-]+\.md)\b/g;
354
+ const BESIDE = 24;
355
+
356
+ /**
357
+ * Documents an item points at about its own id, by basename. A register or
358
+ * table row is one line indexing one id, so any document it names counts;
359
+ * elsewhere the id has to sit beside the link on its line (`other.md`
360
+ * Decisions, D1, or D1 in [other](other.md)). A record that cites another
361
+ * document for something else does not make that document's same id its peer.
362
+ */
363
+ export function idPointedDocs(text, id, kind) {
364
+ if (!id) return new Set();
365
+ if (kind === 'register' || kind === 'table') return pointedDocs(text);
366
+ const out = new Set();
367
+ // The item's text starts after its id; the id is put back on its first line.
368
+ for (const line of `${id} ${text}`.split('\n')) {
369
+ for (const file of linksBesideId(line, id)) out.add(file);
370
+ }
371
+ return out;
372
+ }
373
+
374
+ const idPattern = id => new RegExp(`(?<![\\w.-])${escapeRe(id)}(?![\\w]|[-.][A-Z0-9])`, 'g');
375
+
376
+ /** The documents, by basename, linked within a few characters of `id` on one line. */
377
+ function linksBesideId(line, id) {
378
+ const out = new Set();
379
+ const at = [...line.matchAll(idPattern(id))].map(m => [m.index, m.index + m[0].length]);
380
+ if (!at.length) return out;
381
+ for (const m of line.matchAll(LINK)) {
382
+ const start = m.index;
383
+ const end = m.index + m[0].length;
384
+ if (at.some(([a, b]) => (a >= end && a - end <= BESIDE) || (b <= start && start - b <= BESIDE))) {
385
+ out.add((m[1] ?? m[2]).split('/').pop());
386
+ }
387
+ }
388
+ return out;
389
+ }
390
+
391
+ // ── What each decision blocks ────────────────────────────────────────────────
392
+
393
+ // A range written out (`A2 to A12`, `A2-A12`, `A2 through 12`) names every id in it.
394
+ const RANGE = /(?<![\w.-])([A-Z]{1,3}-?)(\d{1,3})\s*(?:to|through|thru|–|—|-)\s*(?:\1)?(\d{1,3})(?![\w])/g;
395
+
396
+ /** Whether a line names `id`, on its own or inside a written range. An id
397
+ * that is itself a range (`A2 to A12`) is named when any id in it is. */
398
+ export function namesId(line, id, ranges = writtenRanges(line)) {
399
+ const span = id.match(/^([A-Z]{1,3}-?)(\d{1,3})\s+(?:to|through)\s+\1?(\d{1,3})$/);
400
+ if (span) {
401
+ if (line.includes(id)) return true;
402
+ const [lo, hi] = [Number(span[2]), Number(span[3])];
403
+ for (let n = lo; n <= hi && n - lo < 100; n++) if (namesId(line, `${span[1]}${n}`, ranges)) return true;
404
+ return false;
405
+ }
406
+ if (line.includes(id) && idPattern(id).test(line)) return true;
407
+ if (!ranges.length) return false;
408
+ const m = id.match(/^([A-Z]{1,3}-?)(\d{1,3})$/);
409
+ if (!m) return false;
410
+ const n = Number(m[2]);
411
+ return ranges.some(([prefix, lo, hi]) => prefix === m[1] && lo <= n && n <= hi);
412
+ }
413
+
414
+ function writtenRanges(line) {
415
+ return [...line.matchAll(RANGE)].map(r => [r[1], Number(r[2]), Number(r[3])]);
416
+ }
417
+
418
+ // What the record says it holds up: `blocks Phase 5`, `gates the sizing`,
419
+ // `which switch sizing waits on`.
420
+ // The verb has to open a clause (after a comma, a colon, `it`, `this`, `which`
421
+ // or `and`), so a noun such as "code blocks" is not read as one.
422
+ const STATED = [
423
+ /(?:^|[,;:(]\s*|\b(?:it|this|which|and|that)\s+)(?:blocks|gates|is blocking|holds up)\s+(?!on\b|nothing\b|none\b)([^.;:,()\n|]{3,80})/gi,
424
+ /\bwhich\s+([^.;:,()\n|]{3,60}?)\s+waits?\s+on\b/gi,
425
+ ];
426
+
427
+ export function statedBlocks(text) {
428
+ const body = clean(text);
429
+ const out = [];
430
+ for (const re of STATED) {
431
+ for (const m of body.matchAll(re)) {
432
+ const what = m[1].trim().replace(/\s+(?:and|or|until|unless|while|when)$/i, '');
433
+ if (what && !out.includes(what)) out.push(what);
434
+ }
435
+ }
436
+ return out;
437
+ }
438
+
439
+ const OPEN_BOX = /^\s*[-*+]\s+\[ \]\s+(.*)$/;
440
+ const BLOCKER_KEYS = /^(?:blockers|blocked_by):\s*(.*)$/;
441
+
442
+ /**
443
+ * The open work in one document, one entry per line that can wait on a
444
+ * decision: an unticked checklist item, or a frontmatter blocker. Each carries
445
+ * the heading it sits under. Pure.
446
+ */
447
+ export function openWork(text) {
448
+ const lines = text.split('\n');
449
+ const out = [];
450
+ let i = 0;
451
+ if (lines[0] === '---') {
452
+ const end = lines.indexOf('---', 1);
453
+ let inBlockers = false;
454
+ for (i = 1; i < (end > 0 ? end : 0); i++) {
455
+ const line = lines[i];
456
+ const key = line.match(/^([A-Za-z_][\w-]*):/);
457
+ if (key) {
458
+ const b = line.match(BLOCKER_KEYS);
459
+ inBlockers = Boolean(b);
460
+ if (b && b[1].trim() && !/^\[\s*\]$/.test(b[1].trim())) out.push({ line: i + 1, kind: 'blocker', section: null, text: b[1] });
461
+ continue;
462
+ }
463
+ const item = inBlockers && line.match(/^\s+-\s+(.*)$/);
464
+ if (item) out.push({ line: i + 1, kind: 'blocker', section: null, text: item[1].replace(/^["']|["']$/g, '') });
465
+ }
466
+ if (end > 0) i = end + 1;
467
+ }
468
+ let section = null;
469
+ let fence = null;
470
+ for (; i < lines.length; i++) {
471
+ const line = lines[i];
472
+ const f = line.match(/^\s*(`{3,}|~{3,})/);
473
+ if (f) { fence = fence === null ? f[1][0] : (f[1][0] === fence ? null : fence); continue; }
474
+ if (fence !== null) continue;
475
+ const h = line.match(/^#{1,6}\s+(.+)$/);
476
+ if (h) { section = clean(h[1]).replace(/\s*[⬜✅🟡🟢🔴⏳]\uFE0F?(?:\s*(?:todo|done|wip|in progress|partial))?\s*$/iu, ''); continue; }
477
+ const box = line.match(OPEN_BOX);
478
+ if (box) out.push({ line: i + 1, kind: 'item', section, text: box[1] });
479
+ }
480
+ return out;
481
+ }
482
+
483
+ const clip = (t, n) => (t.length > n ? `${t.slice(0, n - 1).trimEnd()}…` : t);
484
+
485
+ /**
486
+ * What waits on each pending decision: what its record says it blocks, and
487
+ * every unticked checklist item or frontmatter blocker that names it. In its
488
+ * own document an entry naming the id counts; in another document the id has
489
+ * to sit beside a link to the decision's document, the rule records pair by.
490
+ * `items` are the ones asked about; `all` is every item read, so no record is
491
+ * counted as waiting on another. Returns a Map from item to
492
+ * `[{ doc, line, kind, section, text }]`.
493
+ */
494
+ export function blocksOf(items, docs, all = items) {
495
+ const work = docs.map(d => ({
496
+ path: d.path,
497
+ entries: openWork(d.text).map(e => ({ ...e, ranges: writtenRanges(e.text) })),
498
+ }));
499
+ // An entry that is itself a decision item is the record, not what waits on it.
500
+ const starts = new Set(all.map(i => `${i.doc}:${i.line}`));
501
+ const out = new Map();
502
+ for (const item of items) {
503
+ if (!item.id) continue;
504
+ const found = statedBlocks(item.text).map(text => ({ doc: item.doc, line: item.line, kind: 'stated', section: null, text }));
505
+ for (const d of work) {
506
+ const own = d.path === item.doc;
507
+ for (const e of d.entries) {
508
+ if (starts.has(`${d.path}:${e.line}`) || !namesId(e.text, item.id, e.ranges)) continue;
509
+ if (!own && !linksBesideId(e.text, item.id).has(item.file)) continue;
510
+ found.push({ doc: d.path, line: e.line, kind: e.kind, section: e.section, text: clip(clean(e.text), 240) });
511
+ }
512
+ }
513
+ out.set(item, found);
514
+ }
515
+ return out;
516
+ }
517
+
518
+ /**
519
+ * Every item across the given documents with its disposition, its parts (its
520
+ * own plus a linked record's) and what it is missing. Pure.
521
+ * @param {{path: string, text: string}[]} docs
522
+ */
523
+ export function analyzeDecisions(docs, settings = DEFAULTS) {
524
+ const s = decisionSettings(settings);
525
+ const items = [];
526
+ for (const doc of docs) {
527
+ for (const item of parseDecisionItems(doc.text, s)) {
528
+ items.push({
529
+ ...item,
530
+ doc: doc.path,
531
+ docTitle: doc.title ?? null,
532
+ file: path.basename(doc.path),
533
+ disposition: dispositionOf(item, s),
534
+ own: recordParts(item.text, s),
535
+ points: idPointedDocs(item.text, item.id, item.kind),
536
+ });
537
+ }
538
+ }
539
+
540
+ const byId = new Map();
541
+ for (const item of items) {
542
+ if (!item.id) continue;
543
+ if (!byId.has(item.id)) byId.set(item.id, []);
544
+ byId.get(item.id).push(item);
545
+ }
546
+ const linked = (a, b) => a !== b && a.file !== b.file && (a.points.has(b.file) || b.points.has(a.file));
547
+
548
+ for (const item of items) {
549
+ item.peers = item.id ? byId.get(item.id).filter(p => linked(item, p)) : [];
550
+ const parts = { ...item.own };
551
+ for (const peer of item.peers) for (const k of Object.keys(parts)) if (peer.own[k]) parts[k] = true;
552
+ item.parts = parts;
553
+ // A register row that indexes this record governs its disposition.
554
+ const row = item.kind === 'register' ? null : item.peers.find(p => p.kind === 'register');
555
+ item.effective = row?.disposition ?? item.disposition;
556
+ const missing = [];
557
+ if (!item.id) missing.push('id');
558
+ if (!item.disposition) missing.push('disposition');
559
+ for (const part of s.requires[item.disposition] ?? []) if (!parts[part]) missing.push(part);
560
+ item.missing = missing;
561
+ }
562
+ return items;
563
+ }
564
+
565
+ /**
566
+ * The pending rows, one per decision: linked pairs collapse, a register row
567
+ * preferred. With `blocks` (from `blocksOf`), each row carries what waits on
568
+ * it, gathered across the record and every peer it collapsed.
569
+ */
570
+ export function pendingRows(items, { doc = null, all = false, blocks = null } = {}) {
571
+ let rows = items.filter(i => i.id && (all || PENDING.has(i.effective)));
572
+ if (doc) rows = rows.filter(i => i.doc === doc);
573
+ const kept = [];
574
+ const rank = i => (i.kind === 'register' ? 0 : 1);
575
+ for (const item of [...rows].sort((a, b) => rank(a) - rank(b))) {
576
+ const dup = kept.find(k => k.id === item.id && (k.doc === item.doc || k.peers.includes(item)));
577
+ if (!dup) kept.push(item);
578
+ }
579
+ const shared = new Map();
580
+ for (const k of kept) shared.set(k.id, (shared.get(k.id) ?? 0) + 1);
581
+ return kept
582
+ .sort((a, b) => a.id.localeCompare(b.id, 'en', { numeric: true }) || a.doc.localeCompare(b.doc))
583
+ .map(item => {
584
+ const row = {
585
+ id: item.id,
586
+ label: shared.get(item.id) > 1 ? `${item.id} (${item.file})` : item.id,
587
+ doc: item.doc,
588
+ docTitle: item.docTitle ?? null,
589
+ line: item.line,
590
+ kind: item.kind,
591
+ disposition: item.effective,
592
+ missing: item.missing,
593
+ question: questionOf(item),
594
+ text: renderLine(item),
595
+ };
596
+ if (blocks) {
597
+ const seen = new Set();
598
+ row.blocks = [item, ...item.peers].flatMap(i => blocks.get(i) ?? [])
599
+ .filter(b => { const k = `${b.doc}:${b.line}:${b.text}`; return !seen.has(k) && seen.add(k); });
600
+ }
601
+ return row;
602
+ });
603
+ }
604
+
605
+ /** The record's question, even when the record is incomplete: its first
606
+ * sentence ending in `?`, else its first sentence. */
607
+ function questionOf(item) {
608
+ const parts = sentences(clean(item.text).replace(/^(?:open|held|ruled|closed)\b[^.:]*[.:]\s*/i, ''));
609
+ const q = parts.find(t => t.endsWith('?')) ?? parts[0] ?? '';
610
+ return clip(q.replace(/^[^\p{L}\p{N}"'(]+/u, ''), 300);
611
+ }
612
+
613
+ const clean = t => t
614
+ .replace(/\|/g, ' ')
615
+ .replace(/\*\*/g, '')
616
+ .replace(/`/g, '')
617
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
618
+ .replace(/^\s*(?:[-*]\s+)?Disposition\s*:[^\n]*$/gim, '')
619
+ .replace(/\s+/g, ' ')
620
+ .trim();
621
+
622
+ function renderLine(item) {
623
+ const partsMissing = item.missing.filter(m => m !== 'id' && m !== 'disposition');
624
+ if (partsMissing.length) return `(record incomplete: missing ${partsMissing.join(', ')})`;
625
+ const body = clean(item.text);
626
+ if (item.kind === 'register') return body;
627
+ const parts = sentences(body);
628
+ const at = parts.findIndex(t => t.endsWith('?'));
629
+ if (at === -1) return body.slice(0, 400);
630
+ return [parts[at], ...parts.slice(at + 1, at + 4)].join(' ');
631
+ }
632
+
633
+ /** Defects, one per finding, for `--check`. */
634
+ export function decisionDefects(items) {
635
+ const out = [];
636
+ for (const item of items) {
637
+ if (item.missing.length) {
638
+ out.push({ doc: item.doc, line: item.line, id: item.id, message: `missing ${item.missing.join(', ')}` });
639
+ }
640
+ }
641
+ const seen = new Map();
642
+ for (const item of items) {
643
+ if (!item.id) continue;
644
+ const key = `${item.doc}#${item.id}#${item.kind === 'register'}`;
645
+ const prior = seen.get(key);
646
+ 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})` });
647
+ else seen.set(key, item);
648
+ }
649
+ for (const item of items) {
650
+ if (!PENDING.has(item.disposition)) continue;
651
+ for (const peer of item.peers) {
652
+ if (peer.disposition === 'ruled' || peer.disposition === 'closed') {
653
+ out.push({ doc: item.doc, line: item.line, id: item.id, message: `${item.disposition} here, ${peer.disposition} at ${peer.doc}:${peer.line}` });
654
+ }
655
+ }
656
+ }
657
+ return out.sort((a, b) => a.doc.localeCompare(b.doc) || a.line - b.line);
658
+ }
659
+
660
+ // ── CLI ──────────────────────────────────────────────────────────────────────
661
+
662
+ export function loadDecisionDocs(config, settings) {
663
+ const s = decisionSettings(settings);
664
+ const types = new Set(s.types);
665
+ const archiveDir = config.archiveDir ?? 'archived';
666
+ const { docs } = buildIndex(config, { fast: true });
667
+ return docs
668
+ // A document whose frontmatter did not parse has no type; it is read rather than dropped.
669
+ .filter(d => (types.has(d.type) || !d.type) && d.status !== 'archived' && !d.path.split('/').includes(archiveDir))
670
+ .filter(d => !s.paths || [].concat(s.paths).some(p => d.path === p || d.path.startsWith(`${p.replace(/\/$/, '')}/`)))
671
+ .map(d => ({ path: d.path, title: d.title ?? null, text: readFileSync(path.join(config.repoRoot, d.path), 'utf8') }));
672
+ }
673
+
674
+ export function runDecisions(args, config) {
675
+ const settings = decisionSettings(config.raw?.decisions ?? {});
676
+ const json = args.includes('--json');
677
+ const all = args.includes('--all');
678
+ const check = args.includes('--check');
679
+ const target = args.find(a => !a.startsWith('-')) ?? null;
680
+
681
+ const docs = loadDecisionDocs(config, settings);
682
+ const items = analyzeDecisions(docs, settings);
683
+
684
+ if (check) {
685
+ const defects = decisionDefects(items);
686
+ if (json) process.stdout.write(`${JSON.stringify({ ok: defects.length === 0, items: items.length, defects }, null, 2)}\n`);
687
+ else {
688
+ for (const d of defects) process.stdout.write(`${d.doc}:${d.line} ${d.id ?? '(no id)'} ${d.message}\n`);
689
+ process.stdout.write(`${items.length} decision items in ${docs.length} documents; ${defects.length} defects.\n`);
690
+ }
691
+ if (defects.length) process.exitCode = 1;
692
+ return;
693
+ }
694
+
695
+ let docFilter = null;
696
+ if (target) {
697
+ const idRe = new RegExp(`^(?:${settings.id})$`);
698
+ const resolved = idRe.test(target) ? null : resolveDocArg(target, config, { dieOnMiss: false });
699
+ if (resolved) docFilter = path.relative(config.repoRoot, resolved).split(path.sep).join('/');
700
+ else if (idRe.test(target)) return showRecord(target, items, docs, json);
701
+ else die(`No document or decision id matches "${target}".`);
702
+ }
703
+
704
+ let blocks = null;
705
+ if (json) {
706
+ const asked = items.filter(i => i.id && (all || PENDING.has(i.effective)) && (!docFilter || i.doc === docFilter));
707
+ blocks = blocksOf([...new Set(asked.flatMap(i => [i, ...i.peers]))], docs, items);
708
+ }
709
+ const rows = pendingRows(items, { doc: docFilter, all, blocks });
710
+ if (json) {
711
+ process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`);
712
+ return;
713
+ }
714
+ if (!rows.length) {
715
+ process.stdout.write(docFilter ? `No open or held decisions in ${docFilter}.\n` : 'No open or held decisions.\n');
716
+ } else {
717
+ process.stdout.write(`${settings.listHeading}\n\n`);
718
+ for (const row of rows) {
719
+ const tag = all ? `[${row.disposition ?? 'none'}] ` : '';
720
+ process.stdout.write(`${row.label} ${tag}${row.text}\n`);
721
+ }
722
+ }
723
+ const unread = items.filter(i => (!docFilter || i.doc === docFilter) && (!i.id || !i.disposition));
724
+ if (unread.length) {
725
+ const noId = unread.filter(i => !i.id).length;
726
+ 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`);
727
+ }
728
+ }
729
+
730
+ function showRecord(id, items, docs, json) {
731
+ const hits = items.filter(i => i.id === id);
732
+ if (!hits.length) die(`No decision ${id} in any document.`);
733
+ if (json) {
734
+ 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`);
735
+ return;
736
+ }
737
+ const texts = new Map(docs.map(d => [d.path, d.text.split('\n')]));
738
+ hits.forEach((h, n) => {
739
+ const lines = texts.get(h.doc);
740
+ const bodyLines = h.text.split('\n').filter(l => l.trim() !== '').length;
741
+ // The record as written: its first line through the last line of its body.
742
+ let end = h.line - 1;
743
+ let seen = 0;
744
+ while (end < lines.length && seen < bodyLines) {
745
+ if (lines[end].trim() !== '') seen++;
746
+ end++;
747
+ }
748
+ if (n) process.stdout.write('\n');
749
+ process.stdout.write(`${h.doc}:${h.line} ${h.disposition ?? 'no disposition'}\n`);
750
+ process.stdout.write(`${lines.slice(h.line - 1, end).join('\n').trimEnd()}\n`);
751
+ });
752
+ }