dotmd-cli 0.87.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.
- package/README.md +59 -0
- package/bin/dotmd.mjs +86 -7
- package/package.json +1 -1
- package/runlist.config.example.mjs +1 -1
- package/src/ai.mjs +16 -42
- package/src/body-link.mjs +11 -3
- package/src/commands.mjs +11 -0
- package/src/config.mjs +2 -0
- package/src/decisions.mjs +232 -20
- package/src/diff.mjs +2 -2
- package/src/errors-read.mjs +30 -0
- package/src/journal.mjs +26 -0
- package/src/lint.mjs +2 -2
- package/src/model-request.mjs +33 -0
- package/src/model.mjs +523 -0
- package/src/show.mjs +101 -0
- package/src/summary.mjs +1 -1
- package/src/validate.mjs +1 -1
package/src/decisions.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import { die } from './util.mjs';
|
|
|
15
15
|
// section: 'Decisions', // the word a decisions heading names
|
|
16
16
|
// types: ['plan'], // which document types carry decisions
|
|
17
17
|
// paths: ['docs/plans'], // only documents under these, when set
|
|
18
|
-
// id: '[A-Z]{1,3}
|
|
18
|
+
// id: '[A-Z]{1,3}-?[A-Z]?\\d{1,3}[a-z]?(?:[-.][A-Z0-9]{1,3}\\b)?',
|
|
19
19
|
// prose: false, // also read a disposition out of prose
|
|
20
20
|
// vocabulary: { open: [...], held: [...], ruled: [...], closed: [...] },
|
|
21
21
|
// patterns: { ruled: ['regex source', ...] }, // extra prose markers per disposition
|
|
@@ -29,7 +29,9 @@ export const DEFAULTS = Object.freeze({
|
|
|
29
29
|
section: 'Decisions',
|
|
30
30
|
types: ['plan'],
|
|
31
31
|
paths: null,
|
|
32
|
-
id
|
|
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)?',
|
|
33
35
|
prose: false,
|
|
34
36
|
vocabulary: {
|
|
35
37
|
ruled: ['ruled', 'ratified', 'resolved', 'approved', 'decided', 'answered', 'settled', 'locked'],
|
|
@@ -79,7 +81,10 @@ export function isDecisionHeading(text, section = DEFAULTS.section) {
|
|
|
79
81
|
return words.some(w => w === stem || w === `${stem}s`);
|
|
80
82
|
}
|
|
81
83
|
|
|
82
|
-
|
|
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})?`;
|
|
83
88
|
return [
|
|
84
89
|
{ kind: 'register', re: new RegExp(`^(${id})\\s{1,3}(\\S.*)$`), registerOnly: true },
|
|
85
90
|
{ kind: 'table', re: new RegExp(`^\\|\\s*\\**(${id})\\**\\s*\\|(.*)$`) },
|
|
@@ -142,7 +147,11 @@ export function parseDecisionItems(text, settings = DEFAULTS) {
|
|
|
142
147
|
const level = h[1].length;
|
|
143
148
|
// A heading that shouts a disposition (`Decisions, both RESOLVED
|
|
144
149
|
// 2025-01-01`) marks the items under it, as a bold lead does.
|
|
145
|
-
|
|
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;
|
|
146
155
|
const open = items.at(-1);
|
|
147
156
|
if (open && !open.closed && open.kind === 'heading' && level > open.level) {
|
|
148
157
|
// A subheading inside a heading record is part of it.
|
|
@@ -179,7 +188,10 @@ export function parseDecisionItems(text, settings = DEFAULTS) {
|
|
|
179
188
|
matched = true;
|
|
180
189
|
break;
|
|
181
190
|
}
|
|
182
|
-
|
|
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)) {
|
|
183
195
|
closeOpen(items);
|
|
184
196
|
items.push({ id: null, line: i + 1, kind: 'unnamed', level: 0, context, lines: [line] });
|
|
185
197
|
matched = true;
|
|
@@ -321,6 +333,13 @@ export function recordParts(text, settings = DEFAULTS) {
|
|
|
321
333
|
return { prose, citation, answers };
|
|
322
334
|
}
|
|
323
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
|
+
|
|
324
343
|
/** Documents an item points at, by basename. */
|
|
325
344
|
export function pointedDocs(text) {
|
|
326
345
|
const out = new Set();
|
|
@@ -331,6 +350,171 @@ export function pointedDocs(text) {
|
|
|
331
350
|
|
|
332
351
|
// ── Assembly ─────────────────────────────────────────────────────────────────
|
|
333
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
|
+
|
|
334
518
|
/**
|
|
335
519
|
* Every item across the given documents with its disposition, its parts (its
|
|
336
520
|
* own plus a linked record's) and what it is missing. Pure.
|
|
@@ -344,10 +528,11 @@ export function analyzeDecisions(docs, settings = DEFAULTS) {
|
|
|
344
528
|
items.push({
|
|
345
529
|
...item,
|
|
346
530
|
doc: doc.path,
|
|
531
|
+
docTitle: doc.title ?? null,
|
|
347
532
|
file: path.basename(doc.path),
|
|
348
533
|
disposition: dispositionOf(item, s),
|
|
349
534
|
own: recordParts(item.text, s),
|
|
350
|
-
points:
|
|
535
|
+
points: idPointedDocs(item.text, item.id, item.kind),
|
|
351
536
|
});
|
|
352
537
|
}
|
|
353
538
|
}
|
|
@@ -377,8 +562,12 @@ export function analyzeDecisions(docs, settings = DEFAULTS) {
|
|
|
377
562
|
return items;
|
|
378
563
|
}
|
|
379
564
|
|
|
380
|
-
/**
|
|
381
|
-
|
|
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 } = {}) {
|
|
382
571
|
let rows = items.filter(i => i.id && (all || PENDING.has(i.effective)));
|
|
383
572
|
if (doc) rows = rows.filter(i => i.doc === doc);
|
|
384
573
|
const kept = [];
|
|
@@ -391,16 +580,34 @@ export function pendingRows(items, { doc = null, all = false } = {}) {
|
|
|
391
580
|
for (const k of kept) shared.set(k.id, (shared.get(k.id) ?? 0) + 1);
|
|
392
581
|
return kept
|
|
393
582
|
.sort((a, b) => a.id.localeCompare(b.id, 'en', { numeric: true }) || a.doc.localeCompare(b.doc))
|
|
394
|
-
.map(item =>
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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);
|
|
404
611
|
}
|
|
405
612
|
|
|
406
613
|
const clean = t => t
|
|
@@ -461,7 +668,7 @@ export function loadDecisionDocs(config, settings) {
|
|
|
461
668
|
// A document whose frontmatter did not parse has no type; it is read rather than dropped.
|
|
462
669
|
.filter(d => (types.has(d.type) || !d.type) && d.status !== 'archived' && !d.path.split('/').includes(archiveDir))
|
|
463
670
|
.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') }));
|
|
671
|
+
.map(d => ({ path: d.path, title: d.title ?? null, text: readFileSync(path.join(config.repoRoot, d.path), 'utf8') }));
|
|
465
672
|
}
|
|
466
673
|
|
|
467
674
|
export function runDecisions(args, config) {
|
|
@@ -494,7 +701,12 @@ export function runDecisions(args, config) {
|
|
|
494
701
|
else die(`No document or decision id matches "${target}".`);
|
|
495
702
|
}
|
|
496
703
|
|
|
497
|
-
|
|
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 });
|
|
498
710
|
if (json) {
|
|
499
711
|
process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`);
|
|
500
712
|
return;
|
package/src/diff.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
|
3
3
|
import { asString, toRepoPath, die, warn } from './util.mjs';
|
|
4
4
|
import { gitDiffSince } from './git.mjs';
|
|
5
5
|
import { buildIndex, resolveDocArg } from './index.mjs';
|
|
6
|
-
import { summarizeDiffText
|
|
6
|
+
import { summarizeDiffText } from './ai.mjs';
|
|
7
7
|
import { bold, dim } from './color.mjs';
|
|
8
8
|
|
|
9
9
|
export function runDiff(argv, config) {
|
|
@@ -12,7 +12,7 @@ export function runDiff(argv, config) {
|
|
|
12
12
|
let stat = false;
|
|
13
13
|
let sinceOverride = null;
|
|
14
14
|
let summarize = false;
|
|
15
|
-
let model
|
|
15
|
+
let model;
|
|
16
16
|
|
|
17
17
|
for (let i = 0; i < argv.length; i++) {
|
|
18
18
|
if (argv[i] === '--stat') { stat = true; continue; }
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { readGlobalErrors, globalErrorLogPath } from './journal.mjs';
|
|
2
|
+
import { die } from './util.mjs';
|
|
3
|
+
import { dim, red } from './color.mjs';
|
|
4
|
+
|
|
5
|
+
// `runlist errors` — the newest failed runlist commands, from the cross-repo
|
|
6
|
+
// error log every failing invocation appends to.
|
|
7
|
+
function parseArgs(argv) {
|
|
8
|
+
const opts = { limit: 20, repo: null, asJson: false };
|
|
9
|
+
for (let i = 0; i < argv.length; i++) {
|
|
10
|
+
const a = argv[i];
|
|
11
|
+
if (a === '--limit' || a === '--tail') {
|
|
12
|
+
const n = Number(argv[++i]);
|
|
13
|
+
if (!Number.isInteger(n) || n < 1) die(`${a} takes a whole number of entries, 1 or more.`);
|
|
14
|
+
opts.limit = n;
|
|
15
|
+
} else if (a === '--repo' && argv[i + 1]) opts.repo = argv[++i];
|
|
16
|
+
else if (a === '--json') opts.asJson = true;
|
|
17
|
+
}
|
|
18
|
+
return opts;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function runErrors(argv) {
|
|
22
|
+
const opts = parseArgs(argv);
|
|
23
|
+
const entries = readGlobalErrors({ limit: opts.limit, repo: opts.repo });
|
|
24
|
+
if (opts.asJson) { process.stdout.write(`${JSON.stringify(entries, null, 2)}\n`); return; }
|
|
25
|
+
if (!entries.length) { process.stdout.write(`No failed runlist commands recorded in ${globalErrorLogPath()}.\n`); return; }
|
|
26
|
+
for (const e of entries) {
|
|
27
|
+
const repo = e.repo ? dim(` ${e.repo.split('/').pop()}`) : '';
|
|
28
|
+
process.stdout.write(`[${e.at}]${repo} ${e.command}\n ${red(e.message ?? '(no message)')}\n`);
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/journal.mjs
CHANGED
|
@@ -322,6 +322,32 @@ export function recordGlobalError({ config, startMs, args, err, version }) {
|
|
|
322
322
|
}
|
|
323
323
|
}
|
|
324
324
|
|
|
325
|
+
// The newest failures first, as `{ at, command, message, repo, exit }`: the
|
|
326
|
+
// current file, then its one rollover. Reads what `recordGlobalError` wrote
|
|
327
|
+
// (argv and message already sanitized there); nothing else is kept per line.
|
|
328
|
+
export function readGlobalErrors({ limit = 20, repo = null } = {}) {
|
|
329
|
+
const file = globalErrorLogPath();
|
|
330
|
+
const backup = globalErrorLogBackupPath();
|
|
331
|
+
purgeLegacyTelemetry(file, backup);
|
|
332
|
+
pruneStaleBackup(backup);
|
|
333
|
+
const out = [];
|
|
334
|
+
for (const source of [file, backup]) {
|
|
335
|
+
const lines = readLogLines(source).reverse();
|
|
336
|
+
for (const e of lines) {
|
|
337
|
+
if (repo && !String(e.repo ?? '').includes(repo)) continue;
|
|
338
|
+
out.push({
|
|
339
|
+
at: e.ts ?? null,
|
|
340
|
+
command: ['runlist', ...(Array.isArray(e.argv) ? e.argv : [])].join(' '),
|
|
341
|
+
message: e.err ?? null,
|
|
342
|
+
repo: e.repo ?? null,
|
|
343
|
+
exit: e.exit ?? null,
|
|
344
|
+
});
|
|
345
|
+
if (limit && out.length >= limit) return out;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
|
|
325
351
|
// Misuse log: always-on, cross-repo, append-only record of every wrong-move the
|
|
326
352
|
// PreToolUse guard intercepts (committing a gitignored prompt, `cat`-ing a
|
|
327
353
|
// prompt instead of `dotmd use`, hand-editing a `status:` field, …). This is
|
package/src/lint.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { extractFrontmatter, parseSimpleFrontmatter, replaceFrontmatter } from '
|
|
|
4
4
|
import { asString, toRepoPath, escapeRegex, warn } from './util.mjs';
|
|
5
5
|
import { buildIndex, collectDocFiles } from './index.mjs';
|
|
6
6
|
import { updateFrontmatter } from './lifecycle.mjs';
|
|
7
|
-
import {
|
|
7
|
+
import { runModel } from './ai.mjs';
|
|
8
8
|
import { bold, green, yellow, dim } from './color.mjs';
|
|
9
9
|
import { authorizeManagedSweep, findLexicalDocsRoot } from './managed-path.mjs';
|
|
10
10
|
|
|
@@ -274,7 +274,7 @@ export function runLint(argv, config, opts = {}) {
|
|
|
274
274
|
const statusList = inferTypeSet ? [...inferTypeSet].join(', ') : config.statusOrder.join(', ');
|
|
275
275
|
const { body } = extractFrontmatter(raw);
|
|
276
276
|
const prompt = `Given this markdown document, classify it into exactly one of these statuses: ${statusList}.\nReply with ONLY the status word, nothing else.\n\nFile: ${repoPath}\n\n${(body ?? '').slice(0, 4000)}`;
|
|
277
|
-
const result =
|
|
277
|
+
const result = runModel(prompt, { maxTokens: 10 });
|
|
278
278
|
const suggested = result?.trim().toLowerCase().split(/\s+/)[0];
|
|
279
279
|
const inferValid = inferTypeSet ?? config.validStatuses;
|
|
280
280
|
if (suggested && inferValid.has(suggested)) {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// HTTP requests to the local model server, run as a child process so the
|
|
2
|
+
// synchronous callers (summaries, lint's status inference) can wait on them
|
|
3
|
+
// without every render path turning async. Reads a list of
|
|
4
|
+
// {url, method, body, timeoutMs} as JSON on stdin, sends them in order, and
|
|
5
|
+
// writes the list of {ok, status, json, error} as JSON on stdout.
|
|
6
|
+
|
|
7
|
+
const input = JSON.parse(await new Promise(resolve => {
|
|
8
|
+
let data = '';
|
|
9
|
+
process.stdin.setEncoding('utf8');
|
|
10
|
+
process.stdin.on('data', chunk => { data += chunk; });
|
|
11
|
+
process.stdin.on('end', () => resolve(data));
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
async function send(input) {
|
|
15
|
+
try {
|
|
16
|
+
const res = await fetch(input.url, {
|
|
17
|
+
method: input.method ?? 'GET',
|
|
18
|
+
headers: input.body ? { 'content-type': 'application/json' } : undefined,
|
|
19
|
+
body: input.body ? JSON.stringify(input.body) : undefined,
|
|
20
|
+
signal: AbortSignal.timeout(input.timeoutMs ?? 10000),
|
|
21
|
+
});
|
|
22
|
+
const text = await res.text();
|
|
23
|
+
let json = null;
|
|
24
|
+
try { json = JSON.parse(text); } catch { /* non-JSON body; status says enough */ }
|
|
25
|
+
return { ok: res.ok, status: res.status, json, error: res.ok ? null : (json?.error ?? text.slice(0, 200)) };
|
|
26
|
+
} catch (err) {
|
|
27
|
+
return { ok: false, status: 0, json: null, error: err.name === 'TimeoutError' ? 'timed out' : (err.cause?.code ?? err.message) };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const out = [];
|
|
32
|
+
for (const req of [input].flat()) out.push(await send(req));
|
|
33
|
+
process.stdout.write(JSON.stringify(Array.isArray(input) ? out : out[0]));
|