dotmd-cli 0.87.0 → 0.87.2

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
@@ -97,20 +97,21 @@ Restart Claude Code, or run `/reload-plugins`, after a plugin update.
97
97
  ```bash
98
98
  runlist init # create config, docs/, and the generated index
99
99
  runlist new plan auth-refresh # scaffold a typed document
100
- runlist briefing # compact active-work orientation
101
- runlist plans # live plan dashboard
100
+ runlist plans # compact live plan dashboard
101
+ runlist briefing # comprehensive active-work view
102
102
  runlist check # validate schema, references, and lifecycle shape
103
103
  runlist doctor # preview repairs; add --apply to write
104
104
  ```
105
105
 
106
- `runlist briefing` is the compact orientation view. `runlist context` is the fuller
106
+ `runlist plans` is the compact orientation view. `runlist briefing` lists live
107
+ plans with next steps and grows with the corpus. `runlist context` is the fuller
107
108
  human/LLM briefing, while `runlist agent-context` emits bounded structured JSON for
108
109
  agent integrations.
109
110
 
110
111
  ## Core Workflow
111
112
 
112
113
  ```bash
113
- runlist briefing
114
+ runlist plans
114
115
  runlist use docs/plans/auth-refresh.md
115
116
  runlist set awaiting docs/plans/auth-refresh.md --note "Need API owner decision"
116
117
  runlist set active docs/plans/auth-refresh.md --note "Decision received"
@@ -259,6 +260,12 @@ The register block is the fenced block whose first line carries `statusLine`.
259
260
  The row is the question plus `--answers` (what each answer leaves in place),
260
261
  which is required when a register is configured.
261
262
 
263
+ `runlist decisions --json` gives each open or held decision its `question`,
264
+ its document's `docTitle`, and `blocks`: what its record says it blocks or
265
+ gates, then every unticked checklist item and frontmatter blocker that names
266
+ its id, alone or inside a written range (`A2 to A12`). In another document the
267
+ id has to sit beside a link to the decision's document.
268
+
262
269
  ## Flags
263
270
 
264
271
  A flag is something someone found that the person should know about when they
@@ -282,6 +289,59 @@ merged. The log is append-only, `.runlist/flags.jsonl` by default
282
289
  beside the flag, never over it. Every session start shows a count and the top
283
290
  open flags.
284
291
 
292
+ ## Local model
293
+
294
+ Summaries (`--summarize`, `runlist summary`, `runlist diff --summarize`) and
295
+ lint's status inference use one local model server, Ollama by default or any
296
+ OpenAI-compatible server. runlist never starts it or pulls a model on its own:
297
+
298
+ ```bash
299
+ runlist model # server, model, cap, free memory, what is loaded
300
+ runlist model start # ollama serve, one model and one request at a time
301
+ runlist model measure # record each pulled candidate's peak memory and speed here
302
+ runlist model use qwen3.5:9b # name a model; `auto` picks the first measured one under the cap
303
+ runlist model cap 8 # most memory a model may take; `auto` is a quarter of the machine
304
+ runlist model stop # unload it now
305
+ ```
306
+
307
+ The model unloads after 5 idle minutes. Before it loads, the memory free right
308
+ now must hold it plus 1.5 GB with memory pressure normal, or the command goes
309
+ on without it. On an 8 GB machine the cap is 2 GB and model features stay off;
310
+ point `RUNLIST_MODEL_ENDPOINT` at a server on a bigger machine instead. Settings
311
+ are per machine in `~/.runlist/model.json`.
312
+
313
+ `runlist model status --json` leads with a one-line reading for dashboards,
314
+ `running`, `name` (the model loaded now, else the one runlist would load) and
315
+ `memoryMb` (what it holds, null when nothing is loaded), followed by the full
316
+ detail.
317
+
318
+ ## One document's card
319
+
320
+ ```bash
321
+ runlist show docs/plans/shelf.md # title, status, next step, blockers, checklist, related, links
322
+ runlist show shelf bins --json # [{ path, title, status, nextStep, blockers, checklist,
323
+ # related: [{ field, path, title, status }], links: [...] }]
324
+ ```
325
+
326
+ Read-only: nothing is claimed. `related` is what the frontmatter names
327
+ (`related_plans`, `related_docs`, `supports_plans`, `parent_plan`, `runlist` and
328
+ the configured reference fields), each with its title and status; `links` is
329
+ every other document the body links to.
330
+
331
+ ## Failed commands
332
+
333
+ Every runlist command that fails, by an error or a non-zero exit, appends one
334
+ line to `~/.claude/logs/runlist-errors.log` (`RUNLIST_ERROR_LOG_DIR` moves it)
335
+ with the time, the command with secrets redacted, and the error's one-line
336
+ message. It rolls over once, to `runlist-errors.log.1`, at 5 MB or a new
337
+ runlist version. Dry runs and the session-start `hud` are never logged.
338
+
339
+ ```bash
340
+ runlist errors # the newest 20 failures, newest first
341
+ runlist errors --limit 50 # the newest N
342
+ runlist errors --json # [{ at, command, message, repo, exit }]
343
+ ```
344
+
285
345
  ## Safety Model
286
346
 
287
347
  - 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,8 +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
251
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
252
268
 
253
269
  Validate & Fix:
254
270
  doctor [--apply] Auto-fix everything: refs, membership, lint, long fields, dates, index (preview by default)
@@ -294,6 +310,7 @@ Setup:
294
310
  completions <shell> Shell completion script (bash, zsh)
295
311
  journal [--tail N|--errors|--by-command|--session id|--since iso|--json]
296
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
297
314
 
298
315
  Global Options:
299
316
  --config <path> Explicit config file path
@@ -659,10 +676,11 @@ Options:
659
676
  --json Output as JSON ({ owned, prompts, errors, previousSelf,
660
677
  fleet, recentRejections, misuseRecap, drift })`,
661
678
 
662
- briefing: `runlist briefing — compact summary for session start
679
+ briefing: `runlist briefing — comprehensive live-work summary
663
680
 
664
- Shows plan statuses with next steps, doc/research counts, and health
665
- in 5-10 lines. Designed for LLM context injection.
681
+ Shows every live plan with its next step, plus doc/research counts and health.
682
+ Output grows with the corpus; use \`runlist plans\` for compact orientation or
683
+ \`runlist agent-context\` for structured agent context.
666
684
 
667
685
  Options:
668
686
  --json Output as JSON`,
@@ -1096,7 +1114,7 @@ Options:
1096
1114
  Generates an AI-powered summary using a local model.
1097
1115
 
1098
1116
  Options:
1099
- --model <name> Model to use (default: mlx-community/Llama-3.2-3B-Instruct-4bit)
1117
+ --model <name> Model to use (default: the one \`runlist model\` shows)
1100
1118
  --max-tokens <n> Max tokens for generation (default: 200)
1101
1119
  --json Output as JSON`,
1102
1120
 
@@ -1109,7 +1127,7 @@ Options:
1109
1127
  --stat Summary only (files changed, insertions/deletions)
1110
1128
  --since <date> Override: diff since this date instead of frontmatter
1111
1129
  --summarize Generate AI summary using local model
1112
- --model <name> Model to use (default: mlx-community/Llama-3.2-3B-Instruct-4bit)`,
1130
+ --model <name> Model to use (default: the one \`runlist model\` shows)`,
1113
1131
 
1114
1132
  lint: `runlist lint [--fix] — check and auto-fix frontmatter issues
1115
1133
 
@@ -1336,7 +1354,8 @@ Options:
1336
1354
  runlist decisions <doc> one document, by path, filename or slug
1337
1355
  runlist decisions <ID> one record, whole, as it is written
1338
1356
  runlist decisions --all every disposition, not only the pending ones
1339
- runlist decisions --json the same rows as data
1357
+ runlist decisions --json the same rows as data, each with its question, its
1358
+ document's title and what it blocks
1340
1359
  runlist decisions --check every item it could not read or that is incomplete; exit 1 if any
1341
1360
 
1342
1361
  A decision is an item in a decisions section (a heading naming the section word
@@ -1345,7 +1364,53 @@ token is an id. A register is a fenced block whose first line carries
1345
1364
  decisions.register.statusLine; its rows are \`ID text\`. A disposition is read
1346
1365
  from a \`Disposition:\` line or a row's opening word, then from prose when
1347
1366
  decisions.prose is on, then from the block or bold lead above it. Configure it
1348
- with \`export const decisions = { ... }\` (see src/decisions.mjs).`,
1367
+ with \`export const decisions = { ... }\` (see src/decisions.mjs).
1368
+
1369
+ What a decision blocks, in --json as \`blocks: [{ doc, line, kind, section, text }]\`:
1370
+ what its record says it blocks or gates (kind "stated"), and every unticked
1371
+ checklist item (kind "item") or frontmatter blocker (kind "blocker") that
1372
+ names its id, alone or inside a written range such as \`A2 to A12\`. In
1373
+ another document the id has to sit beside a link to the decision's document.`,
1374
+
1375
+ show: `runlist show <file...> — one document's card, read-only
1376
+
1377
+ runlist show <file> title, status, next step, blockers, checklist,
1378
+ related plans and docs, documents linked from the body
1379
+ runlist show <a> <b> --json [{ path, type, title, status, summary, currentState,
1380
+ nextStep, blockers, checklist, updated,
1381
+ related: [{ field, ref, path, exists, title, status, type }],
1382
+ links: [{ path, title, status, type }] }]
1383
+
1384
+ A file is a path, filename or slug, as for \`use\`. Nothing is claimed or
1385
+ written. With --json a file that is not found is { path, error } rather than
1386
+ an exit.`,
1387
+
1388
+ model: `runlist model — the local model behind summaries and lint
1389
+
1390
+ One local server (Ollama by default) holds one model, serves one request at a
1391
+ time and unloads it after it sits idle. runlist never starts the server or
1392
+ pulls a model on its own.
1393
+
1394
+ Subcommands:
1395
+ status [--json] Default. Server, model, cap, what is loaded and its memory
1396
+ start [--force] Start \`ollama serve\` (one model, one request at a time);
1397
+ refused where no model fits unless forced
1398
+ stop [--all] Unload runlist's model now; --all unloads every loaded model
1399
+ measure [name...] Load each pulled candidate (or the named ones) in turn,
1400
+ summarise the corpus's largest documents at lower
1401
+ priority, and record peak memory and speed for this
1402
+ machine; a model is picked only once measured here
1403
+ use <name|auto> Name the model; auto takes the first measured candidate
1404
+ under the cap
1405
+ cap <gb|auto> The most memory a model may take; auto (the default) is a
1406
+ quarter of this machine's memory, at most 12 GB
1407
+
1408
+ Before a model loads here, the memory free now must hold it plus 1.5 GB and
1409
+ memory pressure must be normal; otherwise the command goes on without it.
1410
+
1411
+ Settings live in ~/.runlist/model.json. RUNLIST_MODEL, RUNLIST_MODEL_CAP_GB and
1412
+ RUNLIST_MODEL_ENDPOINT override them; \`runtime: "openai"\` there points at any
1413
+ OpenAI-compatible server (mlx_lm.server, llama-server, LM Studio).`,
1349
1414
 
1350
1415
  glossary: `runlist glossary <term> — look up domain terms and related docs
1351
1416
 
@@ -1650,7 +1715,8 @@ async function main() {
1650
1715
  if (HELP[key]) { process.stdout.write(`${HELP[key]}\n`); return; }
1651
1716
  if (HELP[topic]) { process.stdout.write(`${HELP[topic]}\n`); return; }
1652
1717
  process.stderr.write(`Unknown help topic: ${topic}\n\nAvailable topics: all, statuses\nPer-command help: runlist <cmd> --help\n`);
1653
- process.exit(1);
1718
+ process.exitCode = 1;
1719
+ return;
1654
1720
  }
1655
1721
  process.stdout.write(`${HELP._main}\n`);
1656
1722
  return;
@@ -1916,7 +1982,17 @@ async function main() {
1916
1982
  if (command === 'flags') { const { runFlags } = await import('../src/flags.mjs'); runFlags(restArgs, config); return; }
1917
1983
  if (command === 'flag') { const { runFlag } = await import('../src/flags.mjs'); runFlag(restArgs, config); return; }
1918
1984
  if (command === 'glossary') { const { runGlossary } = await import('../src/glossary.mjs'); runGlossary(restArgs, config); return; }
1919
- if (command === 'decisions') { const { runDecisions } = await import('../src/decisions.mjs'); runDecisions(restArgs, config); return; }
1985
+ if (command === 'model') { const { runModel } = await import('../src/model.mjs'); await runModel(restArgs, config); return; }
1986
+ if (command === 'show') { const { runShow } = await import('../src/show.mjs'); runShow(restArgs, config); return; }
1987
+ if (command === 'decisions') {
1988
+ const { runDecisions } = await import('../src/decisions.mjs');
1989
+ const result = runDecisions(restArgs, config);
1990
+ if (result?.defects?.length) {
1991
+ const first = result.defects[0];
1992
+ _exitFailureMessage = `${result.defects.length} decision defect(s); first: ${first.doc}:${first.line} ${first.message}`;
1993
+ }
1994
+ return;
1995
+ }
1920
1996
  if (command === 'export') { const { runExport } = await import('../src/export.mjs'); runExport(restArgs, config, { dryRun, root: rootArg, type: typeArg }); return; }
1921
1997
 
1922
1998
  // Lifecycle commands
@@ -1930,6 +2006,7 @@ async function main() {
1930
2006
  if (command === 'guard') { const { runGuard } = await import('../src/guard.mjs'); await runGuard(restArgs, config, { dryRun }); return; }
1931
2007
  if (command === 'update') { const { runUpdate } = await import('../src/update.mjs'); runUpdate(restArgs, config, { dryRun }); return; }
1932
2008
  if (command === 'install') { const { runInstall } = await import('../src/install.mjs'); runInstall(restArgs, config, { dryRun }); return; }
2009
+ if (command === 'errors') { const { runErrors } = await import('../src/errors-read.mjs'); runErrors(restArgs); return; }
1933
2010
  if (command === 'misuse') { const { runMisuse } = await import('../src/misuse-read.mjs'); runMisuse(restArgs, config); return; }
1934
2011
  if (command === 'journal') { const { runJournal } = await import('../src/journal-read.mjs'); runJournal(restArgs, config); return; }
1935
2012
  if (command === 'pickup' || command === 'unpickup' || command === 'release' || command === 'finish') {
@@ -2107,7 +2184,10 @@ async function main() {
2107
2184
  writeCheckPreviewNote();
2108
2185
  process.stdout.write('\n' + renderCheck(freshIndex, config, { errorsOnly, noCollapse, verbose }));
2109
2186
  }
2110
- if (freshIndex.errors.length > 0) process.exitCode = 1;
2187
+ if (freshIndex.errors.length > 0) {
2188
+ process.exitCode = 1;
2189
+ _exitFailureMessage = checkFailureSummary(freshIndex.errors);
2190
+ }
2111
2191
  return;
2112
2192
  }
2113
2193
 
@@ -2117,13 +2197,19 @@ async function main() {
2117
2197
 
2118
2198
  if (args.includes('--json')) {
2119
2199
  process.stdout.write(JSON.stringify(checkJson(index), null, 2) + '\n');
2120
- if (index.errors.length > 0) process.exitCode = 1;
2200
+ if (index.errors.length > 0) {
2201
+ process.exitCode = 1;
2202
+ _exitFailureMessage = checkFailureSummary(index.errors);
2203
+ }
2121
2204
  return;
2122
2205
  }
2123
2206
 
2124
2207
  writeCheckPreviewNote();
2125
2208
  process.stdout.write(renderCheck(index, config, { errorsOnly, noCollapse, verbose }));
2126
- if (index.errors.length > 0) process.exitCode = 1;
2209
+ if (index.errors.length > 0) {
2210
+ process.exitCode = 1;
2211
+ _exitFailureMessage = checkFailureSummary(index.errors);
2212
+ }
2127
2213
  return;
2128
2214
  }
2129
2215
 
@@ -2348,9 +2434,15 @@ async function main() {
2348
2434
  let _resolvedConfig = null;
2349
2435
  let _resolvedCommand = null;
2350
2436
  let _suppressObservability = false;
2437
+ let _exitFailureMessage = null;
2351
2438
  const _startMs = Date.now();
2352
2439
  const _invocationArgs = process.argv.slice(2);
2353
2440
 
2441
+ function checkFailureSummary(errors) {
2442
+ const first = errors[0];
2443
+ return `${errors.length} check error(s); first: ${first.path ? `${first.path}: ` : ''}${first.message}`;
2444
+ }
2445
+
2354
2446
  function _journalExit(err) {
2355
2447
  if (_suppressObservability || _resolvedCommand === 'hud' || _invocationArgs.includes('--dry-run') || _invocationArgs.includes('-n')) return;
2356
2448
  try {
@@ -2362,19 +2454,30 @@ function _journalExit(err) {
2362
2454
  version: pkg.version,
2363
2455
  });
2364
2456
  } catch { /* never break exit on journal failure */ }
2365
- if (err) {
2457
+ // A command that reports its own failure through the exit code (check with
2458
+ // errors, a failed update step) is a failure too.
2459
+ const code = Number(process.exitCode ?? 0);
2460
+ const failure = err ?? (code !== 0 ? { name: 'ExitStatus', message: _exitFailureMessage ?? `exited with status ${code}` } : null);
2461
+ if (failure) {
2366
2462
  try {
2367
2463
  recordGlobalError({
2368
2464
  config: _resolvedConfig,
2369
2465
  startMs: _startMs,
2370
2466
  args: _invocationArgs,
2371
- err,
2467
+ err: failure,
2372
2468
  version: pkg.version,
2373
2469
  });
2374
2470
  } catch { /* never break exit on error-log failure */ }
2375
2471
  }
2376
2472
  }
2377
2473
 
2474
+ // A reader that stops early (`runlist flags | head`) closes the pipe; the rest
2475
+ // of the output has nowhere to go, which is not a failure.
2476
+ process.stdout.on('error', err => {
2477
+ if (err.code === 'EPIPE') process.exit(0);
2478
+ throw err;
2479
+ });
2480
+
2378
2481
  main()
2379
2482
  .then(() => { _journalExit(null); })
2380
2483
  .catch(err => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.87.0",
3
+ "version": "0.87.2",
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/baton.mjs CHANGED
@@ -65,7 +65,7 @@ function pendingHandoffs(promptPath, planPath, config) {
65
65
  function refusePendingHandoff(pending) {
66
66
  const lines = pending.map(p => ` ${p}`).join('\n');
67
67
  const slug = path.basename(pending[0], '.md');
68
- die(`Nothing saved: a handoff for this is already pending:\n${lines}\nUse it (\`runlist use ${slug}\`) or archive it (\`runlist prompts archive ${pending[0]}\`), then run baton again.`);
68
+ die(`Nothing saved: a handoff for this is already pending:\n${lines}\nInspect it with \`runlist prompts show ${slug}\`. If it is current, keep it and do not hand off again. If it is stale, run \`runlist prompts archive ${pending[0]}\`, then run baton again.`);
69
69
  }
70
70
 
71
71
  // Is this positional a filesystem reference (must resolve, typos die) or a
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,11 +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')] })]),
91
101
  command('decisions', none, 'read', [form('[doc-or-id]', { args: positionals(0, 1), options: [flag('--all'), flag('--json'), flag('--check')] })]),
92
102
  command('modules', none, 'read', [form('', { options: [value('--sort'), value('--limit'), flag('--all'), flag('--json')] })]),
93
103
  command('module', none, 'read', [form('<name>', { args: positionals(1, 1), options: [value('--sort'), flag('--json')] })]),
94
104
  command('surfaces', none, 'read', [form('', { options: [flag('--json')] })]),
95
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')] })]),
96
107
  command('misuse', none, 'read', [form('', { options: [flag('--json'), value('--tail'), flag('--by-rule'), value('--repo')] })]),
97
108
 
98
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