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.
package/README.md CHANGED
@@ -259,6 +259,12 @@ The register block is the fenced block whose first line carries `statusLine`.
259
259
  The row is the question plus `--answers` (what each answer leaves in place),
260
260
  which is required when a register is configured.
261
261
 
262
+ `runlist decisions --json` gives each open or held decision its `question`,
263
+ its document's `docTitle`, and `blocks`: what its record says it blocks or
264
+ gates, then every unticked checklist item and frontmatter blocker that names
265
+ its id, alone or inside a written range (`A2 to A12`). In another document the
266
+ id has to sit beside a link to the decision's document.
267
+
262
268
  ## Flags
263
269
 
264
270
  A flag is something someone found that the person should know about when they
@@ -282,6 +288,59 @@ merged. The log is append-only, `.runlist/flags.jsonl` by default
282
288
  beside the flag, never over it. Every session start shows a count and the top
283
289
  open flags.
284
290
 
291
+ ## Local model
292
+
293
+ Summaries (`--summarize`, `runlist summary`, `runlist diff --summarize`) and
294
+ lint's status inference use one local model server, Ollama by default or any
295
+ OpenAI-compatible server. runlist never starts it or pulls a model on its own:
296
+
297
+ ```bash
298
+ runlist model # server, model, cap, free memory, what is loaded
299
+ runlist model start # ollama serve, one model and one request at a time
300
+ runlist model measure # record each pulled candidate's peak memory and speed here
301
+ runlist model use qwen3.5:9b # name a model; `auto` picks the first measured one under the cap
302
+ runlist model cap 8 # most memory a model may take; `auto` is a quarter of the machine
303
+ runlist model stop # unload it now
304
+ ```
305
+
306
+ The model unloads after 5 idle minutes. Before it loads, the memory free right
307
+ now must hold it plus 1.5 GB with memory pressure normal, or the command goes
308
+ on without it. On an 8 GB machine the cap is 2 GB and model features stay off;
309
+ point `RUNLIST_MODEL_ENDPOINT` at a server on a bigger machine instead. Settings
310
+ are per machine in `~/.runlist/model.json`.
311
+
312
+ `runlist model status --json` leads with a one-line reading for dashboards,
313
+ `running`, `name` (the model loaded now, else the one runlist would load) and
314
+ `memoryMb` (what it holds, null when nothing is loaded), followed by the full
315
+ detail.
316
+
317
+ ## One document's card
318
+
319
+ ```bash
320
+ runlist show docs/plans/shelf.md # title, status, next step, blockers, checklist, related, links
321
+ runlist show shelf bins --json # [{ path, title, status, nextStep, blockers, checklist,
322
+ # related: [{ field, path, title, status }], links: [...] }]
323
+ ```
324
+
325
+ Read-only: nothing is claimed. `related` is what the frontmatter names
326
+ (`related_plans`, `related_docs`, `supports_plans`, `parent_plan`, `runlist` and
327
+ the configured reference fields), each with its title and status; `links` is
328
+ every other document the body links to.
329
+
330
+ ## Failed commands
331
+
332
+ Every runlist command that fails, by an error or a non-zero exit, appends one
333
+ line to `~/.claude/logs/runlist-errors.log` (`RUNLIST_ERROR_LOG_DIR` moves it)
334
+ with the time, the command with secrets redacted, and the error's one-line
335
+ message. It rolls over once, to `runlist-errors.log.1`, at 5 MB or a new
336
+ runlist version. Dry runs and the session-start `hud` are never logged.
337
+
338
+ ```bash
339
+ runlist errors # the newest 20 failures, newest first
340
+ runlist errors --limit 50 # the newest N
341
+ runlist errors --json # [{ at, command, message, repo, exit }]
342
+ ```
343
+
285
344
  ## Safety Model
286
345
 
287
346
  - Mutation commands support `--dry-run` / `-n`.
package/bin/dotmd.mjs CHANGED
@@ -203,6 +203,20 @@ stop the others: every step runs, the failures are listed together, and the
203
203
  exit code is 1. A plugin whose marketplace registration is gone gets the
204
204
  marketplace re-added before the update (see \`runlist install claude\`).`,
205
205
 
206
+ errors: `runlist errors — the newest failed runlist commands, newest first
207
+
208
+ Every runlist command that fails (an error, or a non-zero exit such as
209
+ \`check\` finding errors) appends one line to ~/.claude/logs/runlist-errors.log
210
+ (RUNLIST_ERROR_LOG_DIR moves it): when, the command with secrets redacted, and
211
+ the error's one-line message. It rolls over to runlist-errors.log.1 at 5 MB or
212
+ a new runlist version; this reads both. Dry runs and the session-start hud are
213
+ never logged.
214
+
215
+ runlist errors last 20
216
+ runlist errors --limit 50 last N (--tail is the same)
217
+ runlist errors --repo <name> only failures in a matching repo
218
+ runlist errors --json [{ at, command, message, repo, exit }]`,
219
+
206
220
  misuse: `runlist misuse — read the cross-repo guard log (~/.claude/logs/runlist-misuse.log,
207
221
  merged by time with the legacy dotmd-misuse.log that older CLIs wrote)
208
222
 
@@ -247,7 +261,10 @@ Analyze:
247
261
  unblocks <file> [--json] Show what completes when this doc ships
248
262
  diff [file] [--summarize] Show changes since last updated date
249
263
  summary <file> [--json] AI summary of a document
264
+ model [start|stop|use|cap] The local model: server, which model, memory cap
250
265
  glossary <term> [--list] [--json] Look up domain terms + related docs
266
+ decisions [doc|id] [--all|--check] Open and held decisions, read from the corpus
267
+ show <file...> [--json] One document's card: status, next step, related plans and docs, links
251
268
 
252
269
  Validate & Fix:
253
270
  doctor [--apply] Auto-fix everything: refs, membership, lint, long fields, dates, index (preview by default)
@@ -293,6 +310,7 @@ Setup:
293
310
  completions <shell> Shell completion script (bash, zsh)
294
311
  journal [--tail N|--errors|--by-command|--session id|--since iso|--json]
295
312
  View opt-in JSONL command journal (enable: RUNLIST_JOURNAL=1 or journal: true)
313
+ errors [--limit N] [--json] The newest failed runlist commands, from the cross-repo error log
296
314
 
297
315
  Global Options:
298
316
  --config <path> Explicit config file path
@@ -1095,7 +1113,7 @@ Options:
1095
1113
  Generates an AI-powered summary using a local model.
1096
1114
 
1097
1115
  Options:
1098
- --model <name> Model to use (default: mlx-community/Llama-3.2-3B-Instruct-4bit)
1116
+ --model <name> Model to use (default: the one \`runlist model\` shows)
1099
1117
  --max-tokens <n> Max tokens for generation (default: 200)
1100
1118
  --json Output as JSON`,
1101
1119
 
@@ -1108,7 +1126,7 @@ Options:
1108
1126
  --stat Summary only (files changed, insertions/deletions)
1109
1127
  --since <date> Override: diff since this date instead of frontmatter
1110
1128
  --summarize Generate AI summary using local model
1111
- --model <name> Model to use (default: mlx-community/Llama-3.2-3B-Instruct-4bit)`,
1129
+ --model <name> Model to use (default: the one \`runlist model\` shows)`,
1112
1130
 
1113
1131
  lint: `runlist lint [--fix] — check and auto-fix frontmatter issues
1114
1132
 
@@ -1329,6 +1347,70 @@ plans, and checklist progress. Plans-only view.
1329
1347
  Options:
1330
1348
  --json Output as JSON`,
1331
1349
 
1350
+ decisions: `runlist decisions — the open and held decisions, read from the corpus
1351
+
1352
+ runlist decisions every open and held decision, one line each
1353
+ runlist decisions <doc> one document, by path, filename or slug
1354
+ runlist decisions <ID> one record, whole, as it is written
1355
+ runlist decisions --all every disposition, not only the pending ones
1356
+ runlist decisions --json the same rows as data, each with its question, its
1357
+ document's title and what it blocks
1358
+ runlist decisions --check every item it could not read or that is incomplete; exit 1 if any
1359
+
1360
+ A decision is an item in a decisions section (a heading naming the section word
1361
+ in its first 4 words): a heading, bold lead, bullet or table row whose first
1362
+ token is an id. A register is a fenced block whose first line carries
1363
+ decisions.register.statusLine; its rows are \`ID text\`. A disposition is read
1364
+ from a \`Disposition:\` line or a row's opening word, then from prose when
1365
+ decisions.prose is on, then from the block or bold lead above it. Configure it
1366
+ with \`export const decisions = { ... }\` (see src/decisions.mjs).
1367
+
1368
+ What a decision blocks, in --json as \`blocks: [{ doc, line, kind, section, text }]\`:
1369
+ what its record says it blocks or gates (kind "stated"), and every unticked
1370
+ checklist item (kind "item") or frontmatter blocker (kind "blocker") that
1371
+ names its id, alone or inside a written range such as \`A2 to A12\`. In
1372
+ another document the id has to sit beside a link to the decision's document.`,
1373
+
1374
+ show: `runlist show <file...> — one document's card, read-only
1375
+
1376
+ runlist show <file> title, status, next step, blockers, checklist,
1377
+ related plans and docs, documents linked from the body
1378
+ runlist show <a> <b> --json [{ path, type, title, status, summary, currentState,
1379
+ nextStep, blockers, checklist, updated,
1380
+ related: [{ field, ref, path, exists, title, status, type }],
1381
+ links: [{ path, title, status, type }] }]
1382
+
1383
+ A file is a path, filename or slug, as for \`use\`. Nothing is claimed or
1384
+ written. With --json a file that is not found is { path, error } rather than
1385
+ an exit.`,
1386
+
1387
+ model: `runlist model — the local model behind summaries and lint
1388
+
1389
+ One local server (Ollama by default) holds one model, serves one request at a
1390
+ time and unloads it after it sits idle. runlist never starts the server or
1391
+ pulls a model on its own.
1392
+
1393
+ Subcommands:
1394
+ status [--json] Default. Server, model, cap, what is loaded and its memory
1395
+ start [--force] Start \`ollama serve\` (one model, one request at a time);
1396
+ refused where no model fits unless forced
1397
+ stop [--all] Unload runlist's model now; --all unloads every loaded model
1398
+ measure [name...] Load each pulled candidate (or the named ones) in turn,
1399
+ summarise the corpus's largest documents at lower
1400
+ priority, and record peak memory and speed for this
1401
+ machine; a model is picked only once measured here
1402
+ use <name|auto> Name the model; auto takes the first measured candidate
1403
+ under the cap
1404
+ cap <gb|auto> The most memory a model may take; auto (the default) is a
1405
+ quarter of this machine's memory, at most 12 GB
1406
+
1407
+ Before a model loads here, the memory free now must hold it plus 1.5 GB and
1408
+ memory pressure must be normal; otherwise the command goes on without it.
1409
+
1410
+ Settings live in ~/.runlist/model.json. RUNLIST_MODEL, RUNLIST_MODEL_CAP_GB and
1411
+ RUNLIST_MODEL_ENDPOINT override them; \`runtime: "openai"\` there points at any
1412
+ OpenAI-compatible server (mlx_lm.server, llama-server, LM Studio).`,
1413
+
1332
1414
  glossary: `runlist glossary <term> — look up domain terms and related docs
1333
1415
 
1334
1416
  Searches the glossary table in your docs for matching terms.
@@ -1632,7 +1714,8 @@ async function main() {
1632
1714
  if (HELP[key]) { process.stdout.write(`${HELP[key]}\n`); return; }
1633
1715
  if (HELP[topic]) { process.stdout.write(`${HELP[topic]}\n`); return; }
1634
1716
  process.stderr.write(`Unknown help topic: ${topic}\n\nAvailable topics: all, statuses\nPer-command help: runlist <cmd> --help\n`);
1635
- process.exit(1);
1717
+ process.exitCode = 1;
1718
+ return;
1636
1719
  }
1637
1720
  process.stdout.write(`${HELP._main}\n`);
1638
1721
  return;
@@ -1898,6 +1981,9 @@ async function main() {
1898
1981
  if (command === 'flags') { const { runFlags } = await import('../src/flags.mjs'); runFlags(restArgs, config); return; }
1899
1982
  if (command === 'flag') { const { runFlag } = await import('../src/flags.mjs'); runFlag(restArgs, config); return; }
1900
1983
  if (command === 'glossary') { const { runGlossary } = await import('../src/glossary.mjs'); runGlossary(restArgs, config); return; }
1984
+ if (command === 'model') { const { runModel } = await import('../src/model.mjs'); await runModel(restArgs, config); return; }
1985
+ if (command === 'show') { const { runShow } = await import('../src/show.mjs'); runShow(restArgs, config); return; }
1986
+ if (command === 'decisions') { const { runDecisions } = await import('../src/decisions.mjs'); runDecisions(restArgs, config); return; }
1901
1987
  if (command === 'export') { const { runExport } = await import('../src/export.mjs'); runExport(restArgs, config, { dryRun, root: rootArg, type: typeArg }); return; }
1902
1988
 
1903
1989
  // Lifecycle commands
@@ -1911,6 +1997,7 @@ async function main() {
1911
1997
  if (command === 'guard') { const { runGuard } = await import('../src/guard.mjs'); await runGuard(restArgs, config, { dryRun }); return; }
1912
1998
  if (command === 'update') { const { runUpdate } = await import('../src/update.mjs'); runUpdate(restArgs, config, { dryRun }); return; }
1913
1999
  if (command === 'install') { const { runInstall } = await import('../src/install.mjs'); runInstall(restArgs, config, { dryRun }); return; }
2000
+ if (command === 'errors') { const { runErrors } = await import('../src/errors-read.mjs'); runErrors(restArgs); return; }
1914
2001
  if (command === 'misuse') { const { runMisuse } = await import('../src/misuse-read.mjs'); runMisuse(restArgs, config); return; }
1915
2002
  if (command === 'journal') { const { runJournal } = await import('../src/journal-read.mjs'); runJournal(restArgs, config); return; }
1916
2003
  if (command === 'pickup' || command === 'unpickup' || command === 'release' || command === 'finish') {
@@ -2343,19 +2430,30 @@ function _journalExit(err) {
2343
2430
  version: pkg.version,
2344
2431
  });
2345
2432
  } catch { /* never break exit on journal failure */ }
2346
- if (err) {
2433
+ // A command that reports its own failure through the exit code (check with
2434
+ // errors, a failed update step) is a failure too.
2435
+ const code = Number(process.exitCode ?? 0);
2436
+ const failure = err ?? (code !== 0 ? { name: 'ExitStatus', message: `exited with status ${code}` } : null);
2437
+ if (failure) {
2347
2438
  try {
2348
2439
  recordGlobalError({
2349
2440
  config: _resolvedConfig,
2350
2441
  startMs: _startMs,
2351
2442
  args: _invocationArgs,
2352
- err,
2443
+ err: failure,
2353
2444
  version: pkg.version,
2354
2445
  });
2355
2446
  } catch { /* never break exit on error-log failure */ }
2356
2447
  }
2357
2448
  }
2358
2449
 
2450
+ // A reader that stops early (`runlist flags | head`) closes the pipe; the rest
2451
+ // of the output has nowhere to go, which is not a failure.
2452
+ process.stdout.on('error', err => {
2453
+ if (err.code === 'EPIPE') process.exit(0);
2454
+ throw err;
2455
+ });
2456
+
2359
2457
  main()
2360
2458
  .then(() => { _journalExit(null); })
2361
2459
  .catch(err => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.86.0",
3
+ "version": "0.87.1",
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",
@@ -329,6 +329,6 @@ export const presets = {
329
329
  // by age, and an expired lease retries only when its owner is demonstrably dead.
330
330
  // export function onPickup({ path, oldStatus, newStatus, operationId }) {}
331
331
 
332
- // AI hooks — override summarization (replaces local MLX model).
332
+ // AI hooks — override summarization (replaces the local model server; see `runlist model`).
333
333
  // export function summarizeDoc(body, meta) { return 'Custom summary'; }
334
334
  // export function summarizeDiff(diffOutput, filePath) { return 'Custom diff summary'; }
package/src/ai.mjs CHANGED
@@ -1,56 +1,30 @@
1
- import { spawnSync } from 'node:child_process';
2
- import { warn } from './util.mjs';
3
- import { dim } from './color.mjs';
1
+ import { generate } from './model.mjs';
4
2
 
5
- let uvChecked = null;
3
+ // Model-backed text for summaries and lint. The server applies each model's
4
+ // own chat template, so prompts are plain system and user messages.
5
+ // `runlist model` shows which model runs and where.
6
6
 
7
- export function checkUvAvailable() {
8
- if (uvChecked !== null) return uvChecked;
9
- const result = spawnSync('uv', ['--version'], { encoding: 'utf8' });
10
- uvChecked = !result.error && result.status === 0;
11
- if (!uvChecked) warn('uv is not installed. Install it to enable AI summaries: https://docs.astral.sh/uv/');
12
- return uvChecked;
13
- }
14
-
15
- export const DEFAULT_MODEL = 'mlx-community/Llama-3.2-3B-Instruct-4bit';
16
-
17
- export function runMLX(prompt, opts = {}) {
18
- const { model = DEFAULT_MODEL, maxTokens = 200, timeout = 120000 } = opts;
19
- if (!checkUvAvailable()) return null;
20
-
21
- process.stderr.write(dim(' generating...'));
22
- const result = spawnSync('uv', [
23
- 'run', '--with', 'mlx-lm',
24
- 'python3', '-m', 'mlx_lm', 'generate',
25
- '--model', model,
26
- '--prompt', prompt,
27
- '--max-tokens', String(maxTokens),
28
- '--verbose', 'false',
29
- ], { encoding: 'utf8', timeout });
30
-
31
- process.stderr.write('\r\x1b[K');
32
- if (result.status !== 0) return null;
33
-
34
- const output = result.stdout.trim();
35
- const lines = output.split('\n')
36
- .filter(l => !l.includes('Fetching') && !l.includes('Warning:') && !l.includes('=========='));
37
- const text = lines.join(' ').trim();
7
+ export function runModel(prompt, opts = {}) {
8
+ const { system, ...rest } = opts;
9
+ const messages = system ? [{ role: 'system', content: system }, { role: 'user', content: prompt }] : [{ role: 'user', content: prompt }];
10
+ const text = generate(messages, rest);
38
11
  return text ? text.replace(/\*\*/g, '').replace(/^#+\s*/gm, '').trim() : null;
39
12
  }
40
13
 
41
14
  export function summarizeDocBody(bodyText, meta, opts = {}) {
42
15
  if (!bodyText?.trim()) return null;
43
- const prompt = `<|begin_of_text|><|start_header_id|>system<|end_header_id|>
44
- You write brief, direct summaries. No preamble. No filler. Start with the subject immediately.<|eot_id|><|start_header_id|>user<|end_header_id|>
45
- Write a 2-3 sentence plain text summary of this document. State what it covers, its current state (${meta.status}), and what remains to be done. No markdown formatting. No bold, headers, or bullets. Do not start with "This document" or "Here is".
16
+ const prompt = `Write a 2-3 sentence plain text summary of this document. State what it covers, its current state (${meta.status}), and what remains to be done. No markdown formatting. No bold, headers, or bullets. Do not start with "This document" or "Here is".
46
17
 
47
18
  Title: ${meta.title}
48
- ${bodyText.slice(0, 6000)}<|eot_id|><|start_header_id|>assistant<|end_header_id|>
49
- `;
50
- return runMLX(prompt, { maxTokens: 200, ...opts });
19
+ ${bodyText.slice(0, 6000)}`;
20
+ return runModel(prompt, {
21
+ system: 'You write brief, direct summaries. No preamble. No filler. Start with the subject immediately.',
22
+ maxTokens: 200,
23
+ ...opts,
24
+ });
51
25
  }
52
26
 
53
27
  export function summarizeDiffText(diffText, filePath, model) {
54
28
  const prompt = `Summarize this git diff in 1-2 sentences. Focus on what changed semantically, not line counts.\n\nFile: ${filePath}\n\n${diffText.slice(0, 4000)}`;
55
- return runMLX(prompt, { model, maxTokens: 150 });
29
+ return runModel(prompt, { model, maxTokens: 150 });
56
30
  }
package/src/body-link.mjs CHANGED
@@ -9,12 +9,14 @@ function isWithin(root, candidate) {
9
9
  // Markdown body links are filesystem paths relative to the document that
10
10
  // contains them. They deliberately do not inherit frontmatter references'
11
11
  // repo-root fallback: that fallback can make a broken Markdown link look valid.
12
- export function resolveBodyLinkTarget(href, docDir, repoRoot) {
12
+ export function resolveBodyLinkTarget(href, docDir, repoRoot, externalRoots = []) {
13
13
  if (!href) return { ok: false, reason: 'missing' };
14
14
 
15
15
  const root = path.resolve(repoRoot);
16
16
  const candidate = path.resolve(docDir, href);
17
- if (!isWithin(root, candidate)) return { ok: false, reason: 'outside-repo' };
17
+ const allowedRoots = externalRoots.map(entry => path.resolve(root, entry));
18
+ const allowedLexically = isWithin(root, candidate) || allowedRoots.some(entry => isWithin(entry, candidate));
19
+ if (!allowedLexically) return { ok: false, reason: 'outside-repo' };
18
20
  if (!existsSync(candidate)) return { ok: false, reason: 'missing' };
19
21
 
20
22
  let canonicalRoot;
@@ -25,7 +27,13 @@ export function resolveBodyLinkTarget(href, docDir, repoRoot) {
25
27
  } catch {
26
28
  return { ok: false, reason: 'unreadable' };
27
29
  }
28
- if (!isWithin(canonicalRoot, canonicalTarget)) return { ok: false, reason: 'outside-repo' };
30
+ const canonicalAllowedRoots = allowedRoots.flatMap(entry => {
31
+ try { return [realpathSync(entry)]; } catch { return []; }
32
+ });
33
+ if (!isWithin(canonicalRoot, canonicalTarget)
34
+ && !canonicalAllowedRoots.some(entry => isWithin(entry, canonicalTarget))) {
35
+ return { ok: false, reason: 'outside-repo' };
36
+ }
29
37
 
30
38
  try {
31
39
  const stat = statSync(canonicalTarget);
package/src/commands.mjs CHANGED
@@ -88,10 +88,22 @@ 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('model', mutates('~/.runlist/model.json and the local model server'), 'read', [
92
+ form('', { options: [flag('--json')] }),
93
+ form('status', { subcommands: ['status'], options: [flag('--json')] }),
94
+ form('start', { subcommands: ['start'], options: [flag('--force')] }),
95
+ form('stop', { subcommands: ['stop'], options: [flag('--all')] }),
96
+ form('measure [name...]', { subcommands: ['measure'], args: positionals(0, Infinity) }),
97
+ form('use <name>', { subcommands: ['use'], args: positionals(1, 1) }),
98
+ form('cap <gb>', { subcommands: ['cap'], args: positionals(1, 1) }),
99
+ ]),
100
+ command('show', none, 'read', [form('<file...>', { args: positionals(1, Infinity), options: [flag('--json')] })]),
101
+ command('decisions', none, 'read', [form('[doc-or-id]', { args: positionals(0, 1), options: [flag('--all'), flag('--json'), flag('--check')] })]),
91
102
  command('modules', none, 'read', [form('', { options: [value('--sort'), value('--limit'), flag('--all'), flag('--json')] })]),
92
103
  command('module', none, 'read', [form('<name>', { args: positionals(1, 1), options: [value('--sort'), flag('--json')] })]),
93
104
  command('surfaces', none, 'read', [form('', { options: [flag('--json')] })]),
94
105
  command('journal', none, 'read', [form('', { options: [value('--tail'), flag('--errors'), value('--session'), value('--since'), flag('--by-command'), flag('--json')] })]),
106
+ command('errors', none, 'read', [form('', { options: [flag('--json'), value('--limit'), value('--tail'), value('--repo')] })]),
95
107
  command('misuse', none, 'read', [form('', { options: [flag('--json'), value('--tail'), flag('--by-rule'), value('--repo')] })]),
96
108
 
97
109
  command('roadmap', mutates('managed source when `next`; otherwise read-only'), 'workflow', [
package/src/config.mjs CHANGED
@@ -19,6 +19,7 @@ const DEFAULTS = {
19
19
  root: '.',
20
20
  archiveDir: 'archived',
21
21
  excludeDirs: [],
22
+ externalBodyLinkRoots: [],
22
23
  // Floor under the scan surface; null = off. See `applyScanFloor` in validate.mjs.
23
24
  minDocs: null,
24
25
 
@@ -640,6 +641,7 @@ export async function resolveConfig(cwd, explicitConfigPath) {
640
641
  configFound: Boolean(configPath),
641
642
  archiveDir: config.archiveDir,
642
643
  excludeDirs: new Set(config.excludeDirs),
644
+ externalBodyLinkRoots: Array.isArray(config.externalBodyLinkRoots) ? config.externalBodyLinkRoots : [],
643
645
  docsRootPrefix,
644
646
  minDocs,
645
647