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/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}-?\\d{1,3}[a-z]?',
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: '[A-Z]{1,3}-?[A-Z]?\\d{1,3}[a-z]?',
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
- function itemPatterns(id) {
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
- context = s.prose ? proseDisposition(h[2], vocab, s.patterns, { shoutedOnly: true }) : null;
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
- if (!matched && !inRegister && UNNAMED.test(line)) {
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: pointedDocs(item.text),
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
- /** The pending rows, one per decision: linked pairs collapse, a register row preferred. */
381
- export function pendingRows(items, { doc = null, all = false } = {}) {
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
- 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
- }));
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
- const rows = pendingRows(items, { doc: docFilter, all });
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, DEFAULT_MODEL } from './ai.mjs';
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 = DEFAULT_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 { runMLX, checkUvAvailable } from './ai.mjs';
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 = runMLX(prompt, { maxTokens: 10 });
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]));