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/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,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
|
|
@@ -1096,7 +1113,7 @@ Options:
|
|
|
1096
1113
|
Generates an AI-powered summary using a local model.
|
|
1097
1114
|
|
|
1098
1115
|
Options:
|
|
1099
|
-
--model <name> Model to use (default:
|
|
1116
|
+
--model <name> Model to use (default: the one \`runlist model\` shows)
|
|
1100
1117
|
--max-tokens <n> Max tokens for generation (default: 200)
|
|
1101
1118
|
--json Output as JSON`,
|
|
1102
1119
|
|
|
@@ -1109,7 +1126,7 @@ Options:
|
|
|
1109
1126
|
--stat Summary only (files changed, insertions/deletions)
|
|
1110
1127
|
--since <date> Override: diff since this date instead of frontmatter
|
|
1111
1128
|
--summarize Generate AI summary using local model
|
|
1112
|
-
--model <name> Model to use (default:
|
|
1129
|
+
--model <name> Model to use (default: the one \`runlist model\` shows)`,
|
|
1113
1130
|
|
|
1114
1131
|
lint: `runlist lint [--fix] — check and auto-fix frontmatter issues
|
|
1115
1132
|
|
|
@@ -1336,7 +1353,8 @@ Options:
|
|
|
1336
1353
|
runlist decisions <doc> one document, by path, filename or slug
|
|
1337
1354
|
runlist decisions <ID> one record, whole, as it is written
|
|
1338
1355
|
runlist decisions --all every disposition, not only the pending ones
|
|
1339
|
-
runlist decisions --json the same rows as data
|
|
1356
|
+
runlist decisions --json the same rows as data, each with its question, its
|
|
1357
|
+
document's title and what it blocks
|
|
1340
1358
|
runlist decisions --check every item it could not read or that is incomplete; exit 1 if any
|
|
1341
1359
|
|
|
1342
1360
|
A decision is an item in a decisions section (a heading naming the section word
|
|
@@ -1345,7 +1363,53 @@ token is an id. A register is a fenced block whose first line carries
|
|
|
1345
1363
|
decisions.register.statusLine; its rows are \`ID text\`. A disposition is read
|
|
1346
1364
|
from a \`Disposition:\` line or a row's opening word, then from prose when
|
|
1347
1365
|
decisions.prose is on, then from the block or bold lead above it. Configure it
|
|
1348
|
-
with \`export const decisions = { ... }\` (see src/decisions.mjs)
|
|
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).`,
|
|
1349
1413
|
|
|
1350
1414
|
glossary: `runlist glossary <term> — look up domain terms and related docs
|
|
1351
1415
|
|
|
@@ -1650,7 +1714,8 @@ async function main() {
|
|
|
1650
1714
|
if (HELP[key]) { process.stdout.write(`${HELP[key]}\n`); return; }
|
|
1651
1715
|
if (HELP[topic]) { process.stdout.write(`${HELP[topic]}\n`); return; }
|
|
1652
1716
|
process.stderr.write(`Unknown help topic: ${topic}\n\nAvailable topics: all, statuses\nPer-command help: runlist <cmd> --help\n`);
|
|
1653
|
-
process.
|
|
1717
|
+
process.exitCode = 1;
|
|
1718
|
+
return;
|
|
1654
1719
|
}
|
|
1655
1720
|
process.stdout.write(`${HELP._main}\n`);
|
|
1656
1721
|
return;
|
|
@@ -1916,6 +1981,8 @@ async function main() {
|
|
|
1916
1981
|
if (command === 'flags') { const { runFlags } = await import('../src/flags.mjs'); runFlags(restArgs, config); return; }
|
|
1917
1982
|
if (command === 'flag') { const { runFlag } = await import('../src/flags.mjs'); runFlag(restArgs, config); return; }
|
|
1918
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; }
|
|
1919
1986
|
if (command === 'decisions') { const { runDecisions } = await import('../src/decisions.mjs'); runDecisions(restArgs, config); return; }
|
|
1920
1987
|
if (command === 'export') { const { runExport } = await import('../src/export.mjs'); runExport(restArgs, config, { dryRun, root: rootArg, type: typeArg }); return; }
|
|
1921
1988
|
|
|
@@ -1930,6 +1997,7 @@ async function main() {
|
|
|
1930
1997
|
if (command === 'guard') { const { runGuard } = await import('../src/guard.mjs'); await runGuard(restArgs, config, { dryRun }); return; }
|
|
1931
1998
|
if (command === 'update') { const { runUpdate } = await import('../src/update.mjs'); runUpdate(restArgs, config, { dryRun }); return; }
|
|
1932
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; }
|
|
1933
2001
|
if (command === 'misuse') { const { runMisuse } = await import('../src/misuse-read.mjs'); runMisuse(restArgs, config); return; }
|
|
1934
2002
|
if (command === 'journal') { const { runJournal } = await import('../src/journal-read.mjs'); runJournal(restArgs, config); return; }
|
|
1935
2003
|
if (command === 'pickup' || command === 'unpickup' || command === 'release' || command === 'finish') {
|
|
@@ -2362,19 +2430,30 @@ function _journalExit(err) {
|
|
|
2362
2430
|
version: pkg.version,
|
|
2363
2431
|
});
|
|
2364
2432
|
} catch { /* never break exit on journal failure */ }
|
|
2365
|
-
|
|
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) {
|
|
2366
2438
|
try {
|
|
2367
2439
|
recordGlobalError({
|
|
2368
2440
|
config: _resolvedConfig,
|
|
2369
2441
|
startMs: _startMs,
|
|
2370
2442
|
args: _invocationArgs,
|
|
2371
|
-
err,
|
|
2443
|
+
err: failure,
|
|
2372
2444
|
version: pkg.version,
|
|
2373
2445
|
});
|
|
2374
2446
|
} catch { /* never break exit on error-log failure */ }
|
|
2375
2447
|
}
|
|
2376
2448
|
}
|
|
2377
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
|
+
|
|
2378
2457
|
main()
|
|
2379
2458
|
.then(() => { _journalExit(null); })
|
|
2380
2459
|
.catch(err => {
|
package/package.json
CHANGED
|
@@ -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
|
|
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 {
|
|
2
|
-
import { warn } from './util.mjs';
|
|
3
|
-
import { dim } from './color.mjs';
|
|
1
|
+
import { generate } from './model.mjs';
|
|
4
2
|
|
|
5
|
-
|
|
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
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
|
|
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 =
|
|
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)}
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|