dotmd-cli 0.48.4 → 0.49.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
@@ -188,7 +188,7 @@ dotmd actionable List docs with next steps
188
188
  dotmd index [--print] Generate/update docs.md index block
189
189
  dotmd hud Actionable triage (silent when clean — ideal SessionStart hook)
190
190
  dotmd pickup <file> Pick up a plan (in-session + print body)
191
- dotmd release [<file>] Release in-session lease (alias: unpickup)
191
+ dotmd release [<file>] Release in-session lease (aliases: unpickup, finish)
192
192
  dotmd status <file> <status> Transition document status
193
193
  dotmd archive <file> Archive (status + move + update refs)
194
194
  dotmd bulk archive <files> Archive multiple files at once
@@ -335,8 +335,10 @@ Each invocation appends one JSON line:
335
335
  `{ts, sid, pid, argv, exit, ms, v, err?}`. Writes are atomic via
336
336
  `O_APPEND` (entries are well under `PIPE_BUF`), so concurrent sessions
337
337
  interleave cleanly without locking. Lazy rotation to
338
- `.dotmd/journal.jsonl.1` at >5MB or oldest entry >30 days; one backup
339
- retained.
338
+ `.dotmd/journal.jsonl.1` on version change, at >5MB, or when the oldest
339
+ entry is >30 days; one backup retained and pruned after 30 days.
340
+ Version-change rotation keeps agent-facing journal summaries focused on the
341
+ currently installed dotmd.
340
342
 
341
343
  Read it back with `dotmd journal`:
342
344
 
@@ -583,7 +585,9 @@ dotmd status docs/plans/my-plan.md partial # shipped + tail deferred (referenc
583
585
  dotmd status docs/plans/my-plan.md awaiting # stuck on a human decision
584
586
  ```
585
587
 
586
- `finish` is a legacy command that defaults to `status: done`, which is no longer in the default plan vocabulary as of 0.16. Use `archive` (fully shipped) or `release` + `status` (anything else). If you need it back, add `done` to `types.plan.statuses` in your config.
588
+ `finish` is an alias for `release`, kept for older agent instructions that use
589
+ that verb for closeout. To fully close shipped work, archive it. To keep working
590
+ later, release it back to the prior status or use `dotmd set <status> <file>`.
587
591
 
588
592
  ### Session leases & release
589
593
 
@@ -596,8 +600,8 @@ distinct outcomes when a plan is already `in-session`:
596
600
  re-prints the body. No conflict.
597
601
  - **Cross-session conflict.** If another live session holds the plan,
598
602
  pickup refuses with `Held by <host>/<session> (pid <pid>) since <time>`.
599
- - **Stale lease.** If the holder's pid is dead (or the lease is >24h old),
600
- pickup refuses but suggests `--takeover`.
603
+ - **Reclaimable lease.** If the holder's same-host pid is dead, or the lease is
604
+ older than 4 hours, pickup can reclaim it without `--takeover`.
601
605
 
602
606
  Releasing leases (both names work; `release` is the recommended verb):
603
607
 
@@ -605,13 +609,13 @@ Releasing leases (both names work; `release` is the recommended verb):
605
609
  dotmd release # release every lease owned by current session
606
610
  dotmd release docs/plans/foo.md # release that one (refuses cross-session)
607
611
  dotmd release --to planned # override target status (default: lease.oldStatus)
608
- dotmd release --stale # release leases with dead pid or >24h old
612
+ dotmd release --stale # release leases with dead same-host pid or >4h old
609
613
  dotmd release --all # release every lease (administrative)
610
614
  dotmd release --json # { released: [...], skipped: [...] }
611
615
  ```
612
616
 
613
- `finish`, `archive`, and `rename` auto-release / migrate the lease, so the
614
- common closeout paths are covered without ceremony.
617
+ `finish` is the same as `release`. `archive` and `rename` auto-release or
618
+ migrate the lease, so the common closeout paths are covered without ceremony.
615
619
 
616
620
  **Session id resolution** (in order, first wins):
617
621
 
@@ -666,7 +670,7 @@ either is silent.
666
670
 
667
671
  `dotmd check` also catches the symmetric failure mode: a plan whose
668
672
  frontmatter claims `status: in-session` but whose lease either doesn't
669
- exist (last session crashed before releasing) or is stale (>24h since
673
+ exist (last session crashed before releasing) or is stale (>4h since
670
674
  pickup). Each warning names the exact unstuck command
671
675
  (`dotmd release <plan>` or `dotmd status <plan> active`), so plans
672
676
  don't sit stuck in-session indefinitely. Always-on — legit concurrent
package/bin/dotmd.mjs CHANGED
@@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import path from 'node:path';
6
6
  import { resolveConfig } from '../src/config.mjs';
7
- import { die, warn, levenshtein } from '../src/util.mjs';
7
+ import { die, warn, levenshtein, isArchivedPath } from '../src/util.mjs';
8
8
  import { recordCliInvocation, recordGlobalError } from '../src/journal.mjs';
9
9
  import { findRepeatFailureHint } from '../src/hints.mjs';
10
10
 
@@ -12,12 +12,62 @@ const __filename = fileURLToPath(import.meta.url);
12
12
  const __dirname = path.dirname(__filename);
13
13
  const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
14
14
 
15
+ const QUERY_FLAGS = new Set([
16
+ '--type', '--status', '--keyword', '--owner', '--surface', '--module',
17
+ '--domain', '--audience', '--execution-mode', '--updated-since', '--limit',
18
+ '--sort', '--group', '--all', '--include-archived', '--exclude-archived',
19
+ '--stale', '--has-next-step', '--has-blockers', '--checklist-open', '--json',
20
+ '--git', '--summarize', '--summarize-limit', '--model',
21
+ ]);
22
+ const QUERY_VALUE_FLAGS = new Set([
23
+ '--type', '--status', '--keyword', '--owner', '--surface', '--module',
24
+ '--domain', '--audience', '--execution-mode', '--updated-since', '--limit',
25
+ '--sort', '--group', '--summarize-limit', '--model',
26
+ ]);
27
+
28
+ const FLAG_SPECS = {
29
+ plans: { flags: QUERY_FLAGS, values: QUERY_VALUE_FLAGS, subcommands: new Set(['status']) },
30
+ query: { flags: QUERY_FLAGS, values: QUERY_VALUE_FLAGS },
31
+ stale: { flags: QUERY_FLAGS, values: QUERY_VALUE_FLAGS },
32
+ actionable: { flags: QUERY_FLAGS, values: QUERY_VALUE_FLAGS },
33
+ list: { flags: new Set(['--json', '--verbose']), values: new Set() },
34
+ briefing: { flags: new Set(['--json']), values: new Set() },
35
+ context: { flags: new Set(['--json', '--compact', '--summarize', '--model']), values: new Set(['--model']) },
36
+ 'agent-context': { flags: new Set(['--json']), values: new Set() },
37
+ hud: { flags: new Set(['--json']), values: new Set() },
38
+ check: { flags: new Set(['--fix', '--errors-only', '--no-collapse', '--json', '--verbose']), values: new Set() },
39
+ doctor: { flags: new Set(['--apply', '--yes', '--dry-run', '-n', '--statuses', '--migrate-template', '--migrate-prompts', '--frontmatter-fix', '--project', '--json', '--include-archived']), values: new Set() },
40
+ runlist: { flags: new Set(['--json', '--takeover', '--full', '--no-index', '--show-files']), values: new Set(), subcommands: new Set(['next']) },
41
+ release: { flags: new Set(['--json', '--all', '--stale', '--to', '--force', '--no-index', '--show-files']), values: new Set(['--to']) },
42
+ unpickup: { flags: new Set(['--json', '--all', '--stale', '--to', '--force', '--no-index', '--show-files']), values: new Set(['--to']) },
43
+ finish: { flags: new Set(['--json', '--all', '--stale', '--to', '--force', '--no-index', '--show-files']), values: new Set(['--to']) },
44
+ prompts: {
45
+ flags: new Set(['--json', '--status', '--include-archived', '--sort', '--limit', '--all', '--no-index', '--show-files', '--body', '--message', '--title']),
46
+ values: new Set(['--status', '--sort', '--limit', '--body', '--message', '--title']),
47
+ subcommands: new Set(['list', 'next', 'use', 'resume', 'archive', 'new', 'shelve', 'unshelve', 'status']),
48
+ },
49
+ };
50
+
51
+ function validateKnownFlags(command, argv, config) {
52
+ const spec = FLAG_SPECS[command] ?? (config?.presets?.[command] ? { flags: QUERY_FLAGS, values: QUERY_VALUE_FLAGS } : null);
53
+ if (!spec) return;
54
+ for (let i = 0; i < argv.length; i++) {
55
+ const arg = argv[i];
56
+ if (spec.subcommands?.has(arg)) continue;
57
+ if (!arg.startsWith('-')) continue;
58
+ if (!spec.flags.has(arg)) die(`Unknown flag for \`dotmd ${command}\`: ${arg}`);
59
+ if (spec.values.has(arg)) i += 1;
60
+ }
61
+ }
62
+
15
63
  const HELP = {
16
64
  _main: `dotmd v${pkg.version} — frontmatter markdown document manager
17
65
 
18
66
  Common commands:
19
67
  plans Live plans (excludes archived)
68
+ prompts Prompt queue/admin (list, next, archive, new, shelve)
20
69
  briefing Full briefing with plan counts + next steps
70
+ agent-context Compact bounded JSON context for agents
21
71
  set <status> [file] Transition status (start work, finish, archive — all via target status)
22
72
  new <type> <name> Create plan/doc/prompt (pipe stdin or @path for body)
23
73
  use [<file>] Open a doc by type: prompt → consume, plan → start, doc → read
@@ -40,7 +90,8 @@ View & Query:
40
90
  hud [--json] Two-line actionable triage (held / prompts / stuck) — silent when clean
41
91
  list [--verbose] [--json] List docs grouped by status (default command)
42
92
  briefing [--json] Full briefing with plan status counts + next steps
43
- context [--summarize] [--json] Full briefing (LLM-oriented)
93
+ context [--summarize] [--json] Full briefing (LLM-oriented; use --json --compact for bounded JSON)
94
+ agent-context [--json] Compact bounded JSON context for agents
44
95
  focus [status] [--json] Detailed view for one status group
45
96
  query [filters] [--json] Filtered search (--status, --keyword, --stale, etc.)
46
97
  plans Live plans (excludes archived; --include-archived for all)
@@ -65,12 +116,16 @@ Analyze:
65
116
 
66
117
  Validate & Fix:
67
118
  doctor [--apply] Auto-fix everything: refs, lint, dates, index (preview by default)
119
+ self-check Project/version skew diagnostic (alias: doctor --project)
68
120
  lint [--fix] Check and auto-fix frontmatter issues
69
121
  fix-refs [--dry-run] Auto-fix broken reference paths + body links
70
122
 
71
123
  Lifecycle:
124
+ pickup <file> Acquire an in-session lease and start work
125
+ release [<file>] Release held in-session work (alias: unpickup, finish)
72
126
  set <status> [<file>] Unified transition: start work, change status, close out, archive — all via target status
73
127
  runlist <hub> [next] Show or walk an ordered group of plans (see \`dotmd help runlist\`)
128
+ unpickup [<file>] Release held in-session work
74
129
  status <file> <status> Transition document status (deprecated; prefer \`set\`)
75
130
  archive <file> Archive (status + move + update refs)
76
131
  bulk archive <f1> <f2> ... Archive multiple files at once
@@ -228,8 +283,10 @@ Reader options:
228
283
  --json Emit selected entries as a JSON array
229
284
 
230
285
  Storage:
231
- Rotates to .dotmd/journal.jsonl.1 at >5MB or oldest entry >30 days.
232
- Single backup retained; older history is dropped on rotation.
286
+ Rotates to .dotmd/journal.jsonl.1 on dotmd version change, at >5MB,
287
+ or when the oldest entry is >30 days.
288
+ Single backup retained for up to 30 days; older history is dropped on
289
+ rotation or pruned after the retention window.
233
290
 
234
291
  Examples:
235
292
  DOTMD_JOURNAL=1 dotmd plans
@@ -315,6 +372,11 @@ status. With no file, releases every lease owned by the current session.
315
372
  Identical behavior to \`dotmd unpickup\`; both names route to the same
316
373
  implementation. See \`dotmd unpickup --help\` for full option list.`,
317
374
 
375
+ finish: `dotmd finish [<file>] [--to <s>] — alias of dotmd release
376
+
377
+ Compatibility alias for docs and agent loops that use "finish" for releasing
378
+ in-session work. Same behavior as \`dotmd release\` / \`dotmd unpickup\`.`,
379
+
318
380
  ship: `dotmd ship [patch|minor|major] — regen + commit + bump in one step
319
381
 
320
382
  Bundles the multi-step release dance into a single command:
@@ -479,13 +541,21 @@ Options:
479
541
 
480
542
  context: `dotmd context — full briefing (LLM-oriented)
481
543
 
482
- Generates a compact status briefing designed for AI/LLM consumption.
544
+ Generates a status briefing designed for AI/LLM consumption. The default
545
+ JSON form is the full index grouped by type/status; use --compact for bounded
546
+ agent-safe JSON.
483
547
 
484
548
  Options:
485
549
  --json Output as JSON
550
+ --compact With --json, return counts + bounded next-action lists
486
551
  --summarize Add AI summaries for expanded docs
487
552
  --model <name> Model for AI summaries`,
488
553
 
554
+ 'agent-context': `dotmd agent-context — compact bounded JSON for agents
555
+
556
+ Equivalent to \`dotmd context --json --compact\`. Returns counts,
557
+ validation totals, pending prompt next item, and bounded plan action lists.`,
558
+
489
559
  stats: `dotmd stats — doc health dashboard
490
560
 
491
561
  Shows aggregated metrics: status counts, staleness, errors/warnings,
@@ -615,9 +685,11 @@ Modes:
615
685
  / \`## Next Step\` body section (created above
616
686
  the first H2 if absent, appended otherwise).
617
687
  Plans only; honors --dry-run.
688
+ --project Report CLI/project version skew, generated command
689
+ drift, and detectable deprecated command mentions.
618
690
 
619
691
  --apply (or --yes) opts into writes for the default auto-fix pass.
620
- Sub-modes (--statuses, --migrate-*, --frontmatter-fix) keep their
692
+ Sub-modes (--statuses, --migrate-*, --frontmatter-fix, --project) keep their
621
693
  existing contracts: they write by default and honor --dry-run.`,
622
694
 
623
695
  'fix-refs': `dotmd fix-refs — auto-fix broken reference paths
@@ -1143,6 +1215,8 @@ async function main() {
1143
1215
  process.stderr.write(`Repo root: ${config.repoRoot}\n`);
1144
1216
  }
1145
1217
 
1218
+ validateKnownFlags(command, restArgs, config);
1219
+
1146
1220
  // Preset aliases (user config can override built-in commands below)
1147
1221
  if (config.presets[command]) {
1148
1222
  const { buildIndex } = await import('../src/index.mjs');
@@ -1214,7 +1288,7 @@ async function main() {
1214
1288
  if (command === 'hud') { const { runHud } = await import('../src/hud.mjs'); runHud(restArgs, config); return; }
1215
1289
  if (command === 'journal') { const { runJournal } = await import('../src/journal-read.mjs'); runJournal(restArgs, config); return; }
1216
1290
  if (command === 'pickup') { const { runPickup } = await import('../src/lifecycle.mjs'); await runPickup(restArgs, config, { dryRun }); return; }
1217
- if (command === 'unpickup' || command === 'release') { const { runUnpickup } = await import('../src/lifecycle.mjs'); await runUnpickup(restArgs, config, { dryRun }); return; }
1291
+ if (command === 'unpickup' || command === 'release' || command === 'finish') { const { runUnpickup } = await import('../src/lifecycle.mjs'); await runUnpickup(restArgs, config, { dryRun }); return; }
1218
1292
  if (command === 'runlist') { const { runRunlist } = await import('../src/runlist.mjs'); await runRunlist(restArgs, config, { dryRun }); return; }
1219
1293
  if (command === 'handoff') { die('`dotmd handoff` was removed in 0.31.0. Use `dotmd prompts new <name>` to create a saved prompt instead. The .dotmd/handoffs/ sidecar mechanism no longer exists; see CHANGELOG.'); }
1220
1294
  if (command === 'status') { const { runStatus } = await import('../src/lifecycle.mjs'); await runStatus(restArgs, config, { dryRun }); return; }
@@ -1230,6 +1304,11 @@ async function main() {
1230
1304
  if (command === 'rename') { const { runRename } = await import('../src/rename.mjs'); await runRename(restArgs, config, { dryRun }); return; }
1231
1305
  if (command === 'migrate') { const { runMigrate } = await import('../src/migrate.mjs'); runMigrate(restArgs, config, { dryRun }); return; }
1232
1306
  if (command === 'fix-refs') { const { runFixRefs } = await import('../src/fix-refs.mjs'); runFixRefs(restArgs, config, { dryRun }); return; }
1307
+ if (command === 'self-check') {
1308
+ const { runDoctor } = await import('../src/doctor.mjs');
1309
+ runDoctor(['--project', ...restArgs], config, { dryRun });
1310
+ return;
1311
+ }
1233
1312
  if (command === 'doctor') {
1234
1313
  // 0.37.0 (F4): the default auto-fix loop previews by default; --apply
1235
1314
  // (alias --yes) writes. Explicit --dry-run still works and wins over
@@ -1237,7 +1316,7 @@ async function main() {
1237
1316
  // auto-fix path — sub-modes (--statuses, --migrate-template,
1238
1317
  // --migrate-prompts) keep their existing "write unless --dry-run"
1239
1318
  // contract because they're explicit one-shots the user opted into.
1240
- const subMode = args.includes('--statuses') || args.includes('--migrate-template') || args.includes('--migrate-prompts') || args.includes('--frontmatter-fix');
1319
+ const subMode = args.includes('--statuses') || args.includes('--migrate-template') || args.includes('--migrate-prompts') || args.includes('--frontmatter-fix') || args.includes('--project');
1241
1320
  const explicitApply = args.includes('--apply') || args.includes('--yes');
1242
1321
  const explicitDryRun = args.includes('--dry-run') || args.includes('-n');
1243
1322
  const doctorDryRun = subMode ? dryRun : (explicitDryRun || !explicitApply);
@@ -1255,7 +1334,7 @@ async function main() {
1255
1334
  // Opportunistic stale-lease scrub for user-facing "what's actionable now"
1256
1335
  // views. Diagnostic commands (`check`, `coverage`, `stats`, `index`) are
1257
1336
  // intentionally excluded — they should surface drift, not silently fix it.
1258
- const SCRUB_READ_COMMANDS = new Set(['list', 'briefing', 'context', 'focus', 'query', 'modules', 'module', 'surfaces']);
1337
+ const SCRUB_READ_COMMANDS = new Set(['list', 'briefing', 'context', 'agent-context', 'focus', 'query', 'modules', 'module', 'surfaces']);
1259
1338
  if (SCRUB_READ_COMMANDS.has(command)) {
1260
1339
  const { scrubStaleSilently } = await import('../src/lease-scrub.mjs');
1261
1340
  scrubStaleSilently(config);
@@ -1436,6 +1515,55 @@ async function main() {
1436
1515
  runSurfaces(restArgs, config);
1437
1516
  return;
1438
1517
  }
1518
+
1519
+ function compactDoc(d) {
1520
+ return {
1521
+ path: d.path,
1522
+ title: d.title,
1523
+ status: d.status,
1524
+ type: d.type,
1525
+ nextStep: d.nextStep ?? null,
1526
+ blockers: d.blockers ?? [],
1527
+ daysSinceUpdate: d.daysSinceUpdate ?? null,
1528
+ };
1529
+ }
1530
+
1531
+ function buildCompactAgentContext(idx) {
1532
+ const activeStatuses = new Set(['in-session', 'active', 'ready', 'planned', 'awaiting', 'blocked']);
1533
+ const active = idx.docs.filter(d => d.type === 'plan' && activeStatuses.has(d.status));
1534
+ const stale = idx.docs.filter(d => d.isStale && !config.lifecycle.skipStaleFor.has(d.status));
1535
+ const awaiting = idx.docs.filter(d => d.status === 'awaiting');
1536
+ const blocked = idx.docs.filter(d => d.status === 'blocked' || d.blockers?.length);
1537
+ const pendingPrompts = idx.docs
1538
+ .filter(d => d.type === 'prompt' && d.status === 'pending' && !isArchivedPath(d.path, config))
1539
+ .sort((a, b) => (a.created ?? '').localeCompare(b.created ?? '') || (a.updated ?? '').localeCompare(b.updated ?? ''));
1540
+ return {
1541
+ generatedAt: new Date().toISOString(),
1542
+ countsByStatus: idx.countsByStatus,
1543
+ countsByType: idx.countsByType,
1544
+ errors: {
1545
+ count: idx.errors.length,
1546
+ items: idx.errors.slice(0, 10).map(e => ({ path: e.path, message: e.message })),
1547
+ },
1548
+ warnings: { count: idx.warnings.length },
1549
+ prompts: {
1550
+ pending: pendingPrompts.length,
1551
+ next: pendingPrompts[0] ? compactDoc(pendingPrompts[0]) : null,
1552
+ },
1553
+ plans: {
1554
+ active: active.slice(0, 12).map(compactDoc),
1555
+ awaiting: awaiting.slice(0, 8).map(compactDoc),
1556
+ blocked: blocked.slice(0, 8).map(compactDoc),
1557
+ stale: stale.slice(0, 12).map(compactDoc),
1558
+ },
1559
+ };
1560
+ }
1561
+
1562
+ if (command === 'agent-context') {
1563
+ process.stdout.write(JSON.stringify(buildCompactAgentContext(index), null, 2) + '\n');
1564
+ return;
1565
+ }
1566
+
1439
1567
  if (command === 'briefing') {
1440
1568
  if (args.includes('--json')) {
1441
1569
  const plans = index.docs.filter(d => d.type === 'plan');
@@ -1456,10 +1584,15 @@ async function main() {
1456
1584
 
1457
1585
  if (command === 'context') {
1458
1586
  const summarize = args.includes('--summarize');
1587
+ const compact = args.includes('--compact');
1459
1588
  const modelIdx = args.indexOf('--model');
1460
1589
  const model = modelIdx !== -1 && args[modelIdx + 1] ? args[modelIdx + 1] : undefined;
1461
1590
 
1462
1591
  if (args.includes('--json')) {
1592
+ if (compact) {
1593
+ process.stdout.write(JSON.stringify(buildCompactAgentContext(index), null, 2) + '\n');
1594
+ return;
1595
+ }
1463
1596
  const byStatus = {};
1464
1597
  for (const doc of index.docs) {
1465
1598
  const s = doc.status ?? 'unknown';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.48.4",
3
+ "version": "0.49.1",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, Notion sync, AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/commands.mjs CHANGED
@@ -3,10 +3,10 @@
3
3
  // that asserts every `dotmd <verb>` reference in generated slash-command
4
4
  // templates points at a real command.
5
5
  export const KNOWN_COMMANDS = [
6
- 'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'briefing', 'context', 'hud',
7
- 'focus', 'query', 'plans', 'prompts', 'stale', 'actionable', 'index', 'pickup', 'release', 'status', 'set', 'use', 'next', 'archive', 'bulk', 'bulk-tag', 'touch', 'doctor', 'runlist',
6
+ 'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'briefing', 'context', 'agent-context', 'hud',
7
+ 'focus', 'query', 'plans', 'prompts', 'stale', 'actionable', 'index', 'pickup', 'release', 'finish', 'status', 'set', 'use', 'next', 'archive', 'bulk', 'bulk-tag', 'touch', 'doctor', 'runlist',
8
8
  'unblocks', 'health', 'glossary', 'modules', 'module',
9
9
  'fix-refs', 'lint', 'rename', 'migrate', 'notion', 'export', 'summary',
10
10
  'watch', 'diff', 'new', 'init', 'completions', 'statuses', 'journal',
11
- 'ship',
11
+ 'ship', 'self-check',
12
12
  ];
@@ -2,7 +2,7 @@ import { die } from './util.mjs';
2
2
 
3
3
  const COMMANDS = [
4
4
  'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'unblocks', 'health', 'glossary', 'briefing', 'context', 'focus', 'query',
5
- 'plans', 'stale', 'actionable', 'index', 'pickup', 'unpickup', 'status', 'set', 'archive', 'bulk', 'touch', 'doctor', 'lint', 'rename', 'migrate',
5
+ 'plans', 'stale', 'actionable', 'index', 'pickup', 'unpickup', 'release', 'finish', 'status', 'set', 'archive', 'bulk', 'touch', 'doctor', 'lint', 'rename', 'migrate',
6
6
  'fix-refs', 'notion', 'export', 'summary', 'watch', 'diff', 'init', 'new', 'completions', 'journal',
7
7
  ];
8
8
 
@@ -34,8 +34,9 @@ const COMMAND_FLAGS = {
34
34
  actionable: ['--json', '--sort', '--limit', '--all'],
35
35
  briefing: ['--json'],
36
36
  pickup: ['--json', '--takeover'],
37
- unpickup: ['--json', '--all', '--stale', '--to', '--force'],
38
- finish: ['--json'],
37
+ unpickup: ['--json', '--all', '--stale', '--to', '--force', '--no-index', '--show-files'],
38
+ release: ['--json', '--all', '--stale', '--to', '--force', '--no-index', '--show-files'],
39
+ finish: ['--json', '--all', '--stale', '--to', '--force', '--no-index', '--show-files'],
39
40
  status: [],
40
41
  archive: [],
41
42
  doctor: [],
package/src/doctor.mjs CHANGED
@@ -1,14 +1,17 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
1
3
  import { fixBrokenRefs } from './fix-refs.mjs';
2
4
  import { runLint } from './lint.mjs';
3
5
  import { runTouch } from './lifecycle.mjs';
4
- import { buildIndex } from './index.mjs';
6
+ import { buildIndex, collectDocFiles } from './index.mjs';
5
7
  import { renderIndexFile, writeIndex } from './index-file.mjs';
6
- import { renderCheck } from './render.mjs';
8
+ import { renderCheck, renderManualFixes } from './render.mjs';
7
9
  import { bold, dim, green, yellow } from './color.mjs';
8
- import { scaffoldClaudeCommands } from './claude-commands.mjs';
10
+ import { checkClaudeCommands, scaffoldClaudeCommands } from './claude-commands.mjs';
9
11
  import { runMigrateTemplate } from './migrate-template.mjs';
10
12
  import { runMigratePrompts } from './migrate-prompts.mjs';
11
13
  import { runFrontmatterFix } from './frontmatter-fix.mjs';
14
+ import { toRepoPath } from './util.mjs';
12
15
 
13
16
  // Tunable thresholds for `dotmd doctor --statuses` conflation detection.
14
17
  // MIN_BUCKET_SIZE: only flag buckets with at least this many docs (small buckets aren't worth nagging).
@@ -40,6 +43,10 @@ const CUE_LABELS = {
40
43
  };
41
44
 
42
45
  export function runDoctor(argv, config, opts = {}) {
46
+ if (argv.includes('--project')) {
47
+ runDoctorProject(config, { json: argv.includes('--json') });
48
+ return;
49
+ }
43
50
  if (argv.includes('--statuses')) {
44
51
  runDoctorStatuses(config, { json: argv.includes('--json') });
45
52
  return;
@@ -118,6 +125,67 @@ export function runDoctor(argv, config, opts = {}) {
118
125
  process.stdout.write('\n' + bold('6. Remaining issues:') + '\n');
119
126
  const freshIndex = buildIndex(config);
120
127
  process.stdout.write(renderCheck(freshIndex, config));
128
+ const manual = renderManualFixes(freshIndex);
129
+ if (manual.trim()) {
130
+ process.stdout.write('\n' + bold('Closeout guidance') + '\n');
131
+ process.stdout.write(manual);
132
+ }
133
+ }
134
+
135
+ function readJsonIfPresent(filePath) {
136
+ try {
137
+ return JSON.parse(readFileSync(filePath, 'utf8'));
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
143
+ function findDeprecatedCommandMentions(config) {
144
+ const docs = collectDocFiles(config);
145
+ const matches = [];
146
+ for (const filePath of docs) {
147
+ let raw = '';
148
+ try { raw = readFileSync(filePath, 'utf8'); } catch { continue; }
149
+ if (/\bdotmd status\b/.test(raw) || /\bdotmd pickup\b/.test(raw)) {
150
+ matches.push(toRepoPath(filePath, config.repoRoot));
151
+ }
152
+ }
153
+ return matches;
154
+ }
155
+
156
+ function runDoctorProject(config, { json = false } = {}) {
157
+ const cliPackage = readJsonIfPresent(new URL('../package.json', import.meta.url));
158
+ const repoPackage = readJsonIfPresent(path.join(config.repoRoot, 'package.json'));
159
+ const depVersion = repoPackage?.dependencies?.['dotmd-cli']
160
+ ?? repoPackage?.devDependencies?.['dotmd-cli']
161
+ ?? repoPackage?.dependencies?.dotmd
162
+ ?? repoPackage?.devDependencies?.dotmd
163
+ ?? null;
164
+ const claudeCommandWarnings = checkClaudeCommands(config.repoRoot);
165
+ const deprecatedCommandMentions = findDeprecatedCommandMentions(config);
166
+ const result = {
167
+ cliVersion: cliPackage?.version ?? null,
168
+ packageDependency: depVersion,
169
+ claudeCommandWarnings,
170
+ deprecatedCommandMentions,
171
+ };
172
+
173
+ if (json) {
174
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
175
+ return result;
176
+ }
177
+
178
+ process.stdout.write(bold('dotmd doctor --project') + '\n\n');
179
+ process.stdout.write(`- running CLI version: ${result.cliVersion ?? 'unknown'}\n`);
180
+ process.stdout.write(`- package dependency: ${result.packageDependency ?? '(none found)'}\n`);
181
+ process.stdout.write(`- stale Claude commands: ${claudeCommandWarnings.length}\n`);
182
+ if (deprecatedCommandMentions.length) {
183
+ process.stdout.write(`- docs mentioning deprecated commands: ${deprecatedCommandMentions.length}\n`);
184
+ for (const file of deprecatedCommandMentions.slice(0, 10)) process.stdout.write(` - ${file}\n`);
185
+ } else {
186
+ process.stdout.write('- docs mentioning deprecated commands: 0\n');
187
+ }
188
+ return result;
121
189
  }
122
190
 
123
191
  export function analyzeStatusBuckets(docs) {
@@ -0,0 +1,35 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { suggestCandidates } from './util.mjs';
4
+
5
+ function sectionHeadingRegex(sectionHeading) {
6
+ return new RegExp(`^##\\s+${sectionHeading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'm');
7
+ }
8
+
9
+ export function checkGlossaryConfig(config) {
10
+ const glossaryConfig = config.raw?.glossary;
11
+ if (!glossaryConfig?.path) return [];
12
+
13
+ const filePath = path.resolve(config.repoRoot, glossaryConfig.path);
14
+ if (!existsSync(filePath)) {
15
+ return [{
16
+ path: glossaryConfig.path,
17
+ level: 'warning',
18
+ message: `Glossary file configured at \`${glossaryConfig.path}\` but the file does not exist.`,
19
+ }];
20
+ }
21
+
22
+ const content = readFileSync(filePath, 'utf8');
23
+ const section = glossaryConfig.section ?? 'Terminology';
24
+ if (sectionHeadingRegex(section).test(content)) return [];
25
+
26
+ const headings = [...content.matchAll(/^##\s+(.+?)\s*$/gm)].map(m => m[1].trim());
27
+ const suggestions = suggestCandidates(section, headings, 3);
28
+ const nearby = suggestions.length ? suggestions : headings.slice(0, 3);
29
+ const hint = nearby.length ? ` Nearby headings: ${nearby.map(s => `\`${s}\``).join(', ')}.` : '';
30
+ return [{
31
+ path: glossaryConfig.path,
32
+ level: 'warning',
33
+ message: `Glossary config points at section \`## ${section}\`, but that heading is missing in \`${glossaryConfig.path}\`.${hint} Update \`glossary.path\` / \`glossary.section\` or restore the heading.`,
34
+ }];
35
+ }
package/src/glossary.mjs CHANGED
@@ -4,8 +4,12 @@ import { buildIndex } from './index.mjs';
4
4
  import { die, warn, suggestCandidates } from './util.mjs';
5
5
  import { bold, dim, green, yellow } from './color.mjs';
6
6
 
7
+ function sectionHeadingRegex(sectionHeading) {
8
+ return new RegExp(`^##\\s+${sectionHeading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'm');
9
+ }
10
+
7
11
  function parseGlossaryTable(content, sectionHeading) {
8
- const headingRegex = new RegExp(`^##\\s+${sectionHeading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'm');
12
+ const headingRegex = sectionHeadingRegex(sectionHeading);
9
13
  const match = content.match(headingRegex);
10
14
  if (!match) return { found: false, entries: [] };
11
15
 
package/src/hud.mjs CHANGED
@@ -246,6 +246,13 @@ export function runHud(argv, config) {
246
246
  // one line, the minimum verb set.
247
247
  lines.push(dim('dotmd: plans|briefing set <status> [<file>] new <type> <slug> use [<file>] archive <file> (use [no-arg] → oldest pending prompt)'));
248
248
 
249
+ const state = [];
250
+ if (hud.owned?.length) state.push(`held: ${hud.owned.length} (${previewList(hud.owned)})`);
251
+ if (hud.prompts?.length) state.push(`prompts: ${hud.prompts.length} (${previewList(hud.prompts)})`);
252
+ if (hud.stale?.length) state.push(`stuck: ${hud.stale.length} (${previewList(hud.stale)})`);
253
+ if (hud.errors > 0) state.push(`errors: ${hud.errors} (run dotmd check)`);
254
+ if (state.length) lines.push(yellow(state.join(' · ')));
255
+
249
256
  if (refreshed.length > 0) {
250
257
  const from = refreshed[0].from;
251
258
  const to = refreshed[0].to;
package/src/index.mjs CHANGED
@@ -6,6 +6,7 @@ import { asString, normalizeStringList, normalizeBlockers, mergeUniqueStrings, t
6
6
  import { validateDoc, validatePlanShape, validateDocShape, checkBidirectionalReferences, checkGitStaleness, checkRunlistBackPointers, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate, enrichRefErrorSuggestions } from './validate.mjs';
7
7
  import { checkIndex } from './index-file.mjs';
8
8
  import { checkClaudeCommands } from './claude-commands.mjs';
9
+ import { checkGlossaryConfig } from './glossary-check.mjs';
9
10
 
10
11
  // `fast: true` skips every pass that produces warnings/errors — the rendered
11
12
  // index file consumes only status/title/snapshot/etc., not the validation
@@ -124,6 +125,9 @@ export function buildIndex(config, opts = {}) {
124
125
 
125
126
  const claudeWarnings = checkClaudeCommands(config.repoRoot);
126
127
  warnings.push(...claudeWarnings);
128
+
129
+ const glossaryWarnings = checkGlossaryConfig(config);
130
+ warnings.push(...glossaryWarnings);
127
131
  }
128
132
 
129
133
  return {
package/src/journal.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, appendFileSync, statSync, renameSync, readFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, appendFileSync, statSync, renameSync, readFileSync, unlinkSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
4
  import { currentSessionId } from './lease.mjs';
@@ -8,6 +8,7 @@ const JOURNAL_FILE = 'journal.jsonl';
8
8
  const JOURNAL_BACKUP = 'journal.jsonl.1';
9
9
  const ROTATE_SIZE_BYTES = 5 * 1024 * 1024;
10
10
  const ROTATE_AGE_MS = 30 * 24 * 60 * 60 * 1000;
11
+ const BACKUP_RETENTION_MS = ROTATE_AGE_MS;
11
12
 
12
13
  const ERROR_LOG_FILE = 'dotmd-errors.log';
13
14
  const ERROR_LOG_BACKUP = 'dotmd-errors.log.1';
@@ -26,10 +27,31 @@ export function journalBackupPath(config) {
26
27
  return path.join(config.repoRoot, JOURNAL_DIR, JOURNAL_BACKUP);
27
28
  }
28
29
 
29
- function maybeRotate(file, backup) {
30
+ function firstEntryVersion(file) {
31
+ try {
32
+ const sample = readFileSync(file, 'utf8');
33
+ const nl = sample.indexOf('\n');
34
+ const first = nl >= 0 ? sample.slice(0, nl) : sample;
35
+ if (!first.trim()) return null;
36
+ const obj = JSON.parse(first);
37
+ return obj?.v == null ? null : String(obj.v);
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ function maybeRotate(file, backup, nextEntry = null) {
44
+ pruneStaleBackup(backup);
30
45
  if (!existsSync(file)) return;
31
46
  let st;
32
47
  try { st = statSync(file); } catch { return; }
48
+ if (nextEntry?.v) {
49
+ const existingVersion = firstEntryVersion(file);
50
+ if (existingVersion !== String(nextEntry.v)) {
51
+ try { renameSync(file, backup); } catch {}
52
+ return;
53
+ }
54
+ }
33
55
  if (st.size > ROTATE_SIZE_BYTES) {
34
56
  try { renameSync(file, backup); } catch {}
35
57
  return;
@@ -50,6 +72,16 @@ function maybeRotate(file, backup) {
50
72
  } catch {}
51
73
  }
52
74
 
75
+ function pruneStaleBackup(backup) {
76
+ if (!existsSync(backup)) return;
77
+ try {
78
+ const st = statSync(backup);
79
+ if ((Date.now() - st.mtimeMs) > BACKUP_RETENTION_MS) {
80
+ try { unlinkSync(backup); } catch {}
81
+ }
82
+ } catch {}
83
+ }
84
+
53
85
  export function appendJournalEntry(config, entry) {
54
86
  if (!isJournalEnabled(config)) return;
55
87
  if (!config?.repoRoot) return;
@@ -57,7 +89,7 @@ export function appendJournalEntry(config, entry) {
57
89
  const dir = path.join(config.repoRoot, JOURNAL_DIR);
58
90
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
59
91
  const file = journalFilePath(config);
60
- maybeRotate(file, journalBackupPath(config));
92
+ maybeRotate(file, journalBackupPath(config), entry);
61
93
  // O_APPEND is atomic for writes under PIPE_BUF (4KB on Linux, 512B on
62
94
  // macOS). Entries are well under either threshold, so concurrent CLI
63
95
  // invocations interleave cleanly without locking.
@@ -143,7 +175,7 @@ export function recordGlobalError({ config, startMs, args, err, version }) {
143
175
  const dir = globalErrorLogDir();
144
176
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
145
177
  const file = globalErrorLogPath();
146
- maybeRotate(file, globalErrorLogBackupPath());
178
+ maybeRotate(file, globalErrorLogBackupPath(), entry);
147
179
  appendFileSync(file, JSON.stringify(entry) + '\n', { flag: 'a' });
148
180
  } catch {
149
181
  // Logging must never break exit.
package/src/lease.mjs CHANGED
@@ -100,18 +100,28 @@ export function isPidAlive(pid, host) {
100
100
  }
101
101
  }
102
102
 
103
+ export function isLeasePidDead(lease) {
104
+ if (!lease || lease.host !== os.hostname()) return false;
105
+ if (!Number.isInteger(lease.pid) || lease.pid <= 0) return false;
106
+ return !isPidAlive(lease.pid, lease.host);
107
+ }
108
+
103
109
  export function isLeaseStale(lease) {
104
110
  const t = new Date(lease.pickedUpAt).getTime();
105
111
  if (Number.isNaN(t)) return true;
106
112
  return Date.now() - t > STALE_LEASE_AGE_MS;
107
- // Note: pid liveness is intentionally NOT used here. dotmd's own CLI pid is
108
- // dead the moment the process exits, so it's not a useful signal for "is the
109
- // session that wrote this lease still active." Use age and explicit takeover.
110
113
  }
111
114
 
112
- export function findStaleLeases(config) {
115
+ export function isLeaseReclaimable(lease, opts = {}) {
116
+ if (isLeaseStale(lease)) return true;
117
+ const currentSession = opts.currentSession ?? currentSessionId();
118
+ if (lease?.session === currentSession) return false;
119
+ return isLeasePidDead(lease);
120
+ }
121
+
122
+ export function findStaleLeases(config, opts = {}) {
113
123
  const leases = readLeases(config);
114
- return Object.values(leases).filter(isLeaseStale);
124
+ return Object.values(leases).filter(l => isLeaseReclaimable(l, opts));
115
125
  }
116
126
 
117
127
  export function acquireLease(config, repoPath, oldStatus, opts = {}) {
@@ -129,8 +139,7 @@ export function acquireLease(config, repoPath, oldStatus, opts = {}) {
129
139
  }
130
140
 
131
141
  if (existing && !opts.takeover) {
132
- const ageMs = Date.now() - new Date(existing.pickedUpAt).getTime();
133
- const stale = Number.isNaN(ageMs) || ageMs > STALE_LEASE_AGE_MS;
142
+ const stale = isLeaseReclaimable(existing, { currentSession: session });
134
143
  return {
135
144
  outcome: stale ? 'conflict-stale' : 'conflict-alive',
136
145
  conflict: existing,
@@ -189,12 +198,13 @@ export function releaseAllForSession(config, sessionId, opts = {}) {
189
198
  });
190
199
  }
191
200
 
192
- export function releaseStale(config) {
201
+ export function releaseStale(config, opts = {}) {
193
202
  return withLeaseLock(config, () => {
194
203
  const leases = readLeases(config);
195
204
  const released = [];
205
+ const currentSession = opts.currentSession ?? currentSessionId();
196
206
  for (const [key, lease] of Object.entries(leases)) {
197
- if (isLeaseStale(lease)) {
207
+ if (isLeaseReclaimable(lease, { currentSession })) {
198
208
  released.push(lease);
199
209
  delete leases[key];
200
210
  }
package/src/lifecycle.mjs CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  readLeases,
16
16
  currentSessionId,
17
17
  migrateLease,
18
- STALE_LEASE_AGE_MS,
18
+ isLeaseReclaimable,
19
19
  STALE_LEASE_AGE_HOURS,
20
20
  } from './lease.mjs';
21
21
  import { buildCard, renderCard } from './pickup-card.mjs';
@@ -292,7 +292,7 @@ export async function runPickup(argv, config, opts = {}) {
292
292
  // Without this, a stale lease from a crashed prior session would still
293
293
  // produce 'conflict-stale' and force the agent to pass --takeover even
294
294
  // though we already know the holder is gone.
295
- if (!dryRun) {
295
+ if (!dryRun && !takeover) {
296
296
  try {
297
297
  const { scrubStaleSilently } = await import('./lease-scrub.mjs');
298
298
  scrubStaleSilently(config);
@@ -511,10 +511,7 @@ export async function runUnpickup(argv, config, opts = {}) {
511
511
  if (targets === null) {
512
512
  // --stale path
513
513
  if (dryRun) {
514
- const staleLeases = Object.values(leases).filter(l => {
515
- const age = Date.now() - new Date(l.pickedUpAt).getTime();
516
- return Number.isNaN(age) || age > STALE_LEASE_AGE_MS;
517
- });
514
+ const staleLeases = Object.values(leases).filter(l => isLeaseReclaimable(l, { currentSession: session }));
518
515
  for (const l of staleLeases) {
519
516
  process.stderr.write(`${dim('[dry-run]')} Would release stale: ${l.path} (${l.session})\n`);
520
517
  }
package/src/prompts.mjs CHANGED
@@ -38,12 +38,19 @@ function runPromptsList(argv, config, opts = {}) {
38
38
  const hasStatusFlag = argv.includes('--status');
39
39
  const includeArchived = argv.includes('--include-archived');
40
40
  const sub = argv[0];
41
+ const json = argv.includes('--json');
41
42
 
42
- if (opts.verbose && !argv.includes('--json')) {
43
+ if (opts.verbose && !json) {
43
44
  renderPromptsVerbose(index, config, { hasStatusFlag, includeArchived });
44
45
  return;
45
46
  }
46
47
 
48
+ const hasPositionalFilter = argv.some(a => !a.startsWith('-') && a !== 'list');
49
+ if (!json && !hasStatusFlag && !includeArchived && !hasPositionalFilter && sub !== 'status' && !argv.some(a => a.startsWith('--sort') || a.startsWith('--limit') || a === '--all')) {
50
+ renderPromptQueueList(index, config);
51
+ return;
52
+ }
53
+
47
54
  let defaults;
48
55
  let extras = argv;
49
56
  if (sub === 'status') {
@@ -57,6 +64,34 @@ function runPromptsList(argv, config, opts = {}) {
57
64
  runQuery(index, [...defaults, ...extras], config, { preset: 'prompts' });
58
65
  }
59
66
 
67
+ function renderPromptQueueList(index, config) {
68
+ const queue = pendingPromptsOldestFirst(config);
69
+ const queuedPaths = new Set(queue.map(q => q.doc.path));
70
+ const others = index.docs
71
+ .filter(d => d.type === 'prompt' && !queuedPaths.has(d.path) && !isArchivedPath(d.path, config) && d.status !== 'archived')
72
+ .sort((a, b) => (b.updated ?? '').localeCompare(a.updated ?? '') || (a.title ?? a.path).localeCompare(b.title ?? b.path));
73
+ const prompts = [...queue.map(q => q.doc), ...others];
74
+
75
+ if (prompts.length === 0) {
76
+ process.stdout.write('No prompts.\n');
77
+ return;
78
+ }
79
+
80
+ const counts = {};
81
+ for (const p of prompts) counts[p.status ?? 'unknown'] = (counts[p.status ?? 'unknown'] ?? 0) + 1;
82
+ const summary = Object.entries(counts).map(([s, n]) => `${n} ${s}`).join(' · ');
83
+ process.stdout.write(dim(`${prompts.length} prompts · ${summary}`) + '\n\n');
84
+
85
+ const maxSlug = Math.min(36, Math.max(...prompts.map(p => path.basename(p.path, '.md').length)));
86
+ for (let i = 0; i < prompts.length; i++) {
87
+ const p = prompts[i];
88
+ const slug = path.basename(p.path, '.md').padEnd(maxSlug);
89
+ const marker = i === 0 && p.status === 'pending' ? green('[NEXT]') : ' ';
90
+ const status = `[${(p.status ?? 'unknown').toUpperCase()}]`;
91
+ process.stdout.write(` ${marker} ${slug} ${status}\n`);
92
+ }
93
+ }
94
+
60
95
  // Resolve a prompt's "target plan" for `prompts list --verbose`. Order:
61
96
  // 1. frontmatter `related_plans:` (first entry — assumed plan slug)
62
97
  // 2. frontmatter `parent_plan:`
package/src/render.mjs CHANGED
@@ -360,7 +360,7 @@ export function renderBriefing(index, config) {
360
360
  try {
361
361
  const staleLeases = findStaleLeases(config);
362
362
  if (staleLeases.length > 0) {
363
- lines.push(yellow(`Stuck in-session: ${staleLeases.length} (>1d or dead pid, run \`dotmd release --stale\`)`));
363
+ lines.push(yellow(`Stuck in-session: ${staleLeases.length} (>4h or dead same-host pid, run \`dotmd release --stale\`)`));
364
364
  }
365
365
  } catch {}
366
366
 
@@ -376,6 +376,80 @@ export function renderCheck(index, config, opts = {}) {
376
376
  return defaultRenderer(index);
377
377
  }
378
378
 
379
+ export function classifyIssueAction(issue) {
380
+ const message = issue?.message ?? '';
381
+ const file = issue?.path ?? '<file>';
382
+
383
+ if (/Missing frontmatter `status`/.test(message)) {
384
+ return { action: `dotmd bulk-tag ${file}`, fixable: false, label: 'missing status' };
385
+ }
386
+ if (/Missing frontmatter `updated`/.test(message) || /frontmatter `updated: .*` is behind git history/.test(message)) {
387
+ return { action: 'dotmd touch --git', fixable: true, label: 'dates' };
388
+ }
389
+ if (/`(?:module|surface):` \(singular\) is deprecated/.test(message) || /camelCase|nextStep|currentState|auditLevel/.test(message)) {
390
+ return { action: 'dotmd lint --fix', fixable: true, label: 'frontmatter migrations' };
391
+ }
392
+ if (/Unknown surface/.test(message)) {
393
+ return { action: 'dotmd surfaces', fixable: false, label: 'taxonomy' };
394
+ }
395
+ if (/Glossary config points at section|Glossary file configured/.test(message)) {
396
+ return { action: 'edit dotmd.config.mjs glossary.path/glossary.section', fixable: false, label: 'glossary config' };
397
+ }
398
+ if (/under `.*\/` but `status: .*` is not an archive status/.test(message)) {
399
+ return { action: message.match(/Run `([^`]+)`/)?.[1] ?? `dotmd set archived ${file}`, fixable: true, label: 'archive drift' };
400
+ }
401
+ if (/`status: .*` but file is a direct child/.test(message)) {
402
+ return { action: `dotmd archive ${file}`, fixable: true, label: 'archive drift' };
403
+ }
404
+ if (/`current_state` is \d+ chars|`next_step` is \d+ chars/.test(message)) {
405
+ return { action: 'dotmd doctor --frontmatter-fix', fixable: true, label: 'long frontmatter' };
406
+ }
407
+ if (/`modules` is required/.test(message)) {
408
+ return { action: `edit ${file} modules: or run dotmd lint --fix if scaffolded empty`, fixable: false, label: 'module metadata' };
409
+ }
410
+ if (/entry `.*` does not resolve|body link `.*` does not resolve/.test(message)) {
411
+ return { action: 'dotmd fix-refs --dry-run', fixable: true, label: 'references' };
412
+ }
413
+
414
+ return { action: `edit ${file}`, fixable: false, label: 'manual review' };
415
+ }
416
+
417
+ export function renderManualFixes(index) {
418
+ const issues = [...(index.errors ?? []), ...(index.warnings ?? [])];
419
+ const groups = new Map();
420
+ for (const issue of issues) {
421
+ const item = classifyIssueAction(issue);
422
+ const key = `${item.fixable ? 'fixable' : 'manual'}:${item.action}`;
423
+ if (!groups.has(key)) groups.set(key, { ...item, paths: new Set() });
424
+ groups.get(key).paths.add(issue.path);
425
+ }
426
+ if (groups.size === 0) return '';
427
+
428
+ const manual = [...groups.values()].filter(g => !g.fixable);
429
+ const fixable = [...groups.values()].filter(g => g.fixable);
430
+ const lines = [];
431
+
432
+ if (fixable.length > 0) {
433
+ lines.push('Fixable actions');
434
+ for (const group of fixable) {
435
+ const count = group.paths.size;
436
+ lines.push(`- ${group.action} (${count} ${count === 1 ? 'file' : 'files'}: ${[...group.paths].slice(0, 3).join(', ')}${count > 3 ? ', ...' : ''})`);
437
+ }
438
+ lines.push('');
439
+ }
440
+
441
+ if (manual.length > 0) {
442
+ lines.push('Manual fixes remaining');
443
+ for (const group of manual) {
444
+ const count = group.paths.size;
445
+ lines.push(`- ${group.action} (${count} ${count === 1 ? 'file' : 'files'}: ${[...group.paths].slice(0, 3).join(', ')}${count > 3 ? ', ...' : ''})`);
446
+ }
447
+ lines.push('');
448
+ }
449
+
450
+ return lines.join('\n');
451
+ }
452
+
379
453
  function _renderCheck(index, opts = {}) {
380
454
  const { errorsOnly, noCollapse, verbose } = opts;
381
455
  const lines = ['Check', ''];
@@ -390,6 +464,10 @@ function _renderCheck(index, opts = {}) {
390
464
  lines.push(`- ${issue.path}: ${issue.message}`);
391
465
  }
392
466
  lines.push('');
467
+ const actions = renderManualFixes({ errors: index.errors, warnings: [] }).trimEnd();
468
+ if (actions) {
469
+ lines.push(actions);
470
+ }
393
471
  }
394
472
 
395
473
  // Warnings: terse by default — print count + pointer. The full per-doc list
@@ -415,7 +493,7 @@ function _renderCheck(index, opts = {}) {
415
493
  }
416
494
  lines.push('');
417
495
  } else {
418
- lines.push(dim(`Run \`dotmd check --verbose\` for per-doc detail, or \`dotmd doctor\` to auto-fix where possible.`));
496
+ lines.push(dim(`Run \`dotmd check --verbose\` for per-doc detail. \`dotmd doctor\` auto-fixes supported issues; remaining issues need the suggested manual command.`));
419
497
  lines.push('');
420
498
  }
421
499
  }
package/src/runlist.mjs CHANGED
@@ -45,12 +45,7 @@ function resolveHubInput(input, config) {
45
45
  // Read a hub plan's `runlist:` and resolve each entry to a repo-relative path
46
46
  // plus its current status. Missing files are reported with `missing: true`;
47
47
  // callers decide how to render them. Pure: no IO beyond file reads.
48
- function readRunlistChildren(hubAbsPath, config) {
49
- const raw = readFileSync(hubAbsPath, 'utf8');
50
- const { frontmatter: fmRaw } = extractFrontmatter(raw);
51
- const fm = parseSimpleFrontmatter(fmRaw);
52
- const refs = normalizeStringList(fm.runlist);
53
-
48
+ function resolveRunlistRefs(refs, hubAbsPath, config) {
54
49
  const hubDir = path.dirname(hubAbsPath);
55
50
  const out = [];
56
51
  for (const ref of refs) {
@@ -79,6 +74,45 @@ function readRunlistChildren(hubAbsPath, config) {
79
74
  return out;
80
75
  }
81
76
 
77
+ function detectBodyRunlistRefs(body) {
78
+ if (!body) return [];
79
+ const sectionRe = /^##\s+(Order of operations|Runlist|Execution order|Implementation order|Plan order)\s*$/gim;
80
+ const refs = [];
81
+ let match;
82
+ while ((match = sectionRe.exec(body)) !== null) {
83
+ const start = match.index + match[0].length;
84
+ const rest = body.slice(start);
85
+ const next = rest.search(/^##\s+/m);
86
+ const section = next >= 0 ? rest.slice(0, next) : rest;
87
+
88
+ const linkRe = /\[[^\]]+\]\(([^)]+\.md(?:#[^)]+)?)\)/g;
89
+ let link;
90
+ while ((link = linkRe.exec(section)) !== null) refs.push(link[1]);
91
+
92
+ const checklistRe = /^\s*[-*]\s+\[[ xX]\]\s+([^\s)]+\.md(?:#[^\s)]+)?)/gm;
93
+ let item;
94
+ while ((item = checklistRe.exec(section)) !== null) refs.push(item[1]);
95
+ }
96
+ return [...new Set(refs)];
97
+ }
98
+
99
+ function readRunlistChildren(hubAbsPath, config) {
100
+ const raw = readFileSync(hubAbsPath, 'utf8');
101
+ const { frontmatter: fmRaw, body } = extractFrontmatter(raw);
102
+ const fm = parseSimpleFrontmatter(fmRaw);
103
+ const refs = normalizeStringList(fm.runlist);
104
+
105
+ if (refs.length > 0) {
106
+ return { children: resolveRunlistRefs(refs, hubAbsPath, config), source: 'frontmatter' };
107
+ }
108
+
109
+ const bodyRefs = detectBodyRunlistRefs(body);
110
+ return {
111
+ children: resolveRunlistRefs(bodyRefs, hubAbsPath, config),
112
+ source: bodyRefs.length > 0 ? 'body' : 'empty',
113
+ };
114
+ }
115
+
82
116
  const STATUS_TAG_COLORS = {
83
117
  'in-session': (s) => bold(red(s)),
84
118
  'active': green,
@@ -100,9 +134,12 @@ function renderRunlist(hubRepoPath, children, opts = {}) {
100
134
  const lines = [];
101
135
  lines.push(bold(`runlist: ${hubRepoPath}`));
102
136
  if (children.length === 0) {
103
- lines.push(dim(' (empty — add child plan paths to the hub plan\'s `runlist:` field)'));
137
+ lines.push(dim(' (empty — add child plan paths to the hub plan\'s `runlist:` field, or add markdown links under `## Order of operations`)'));
104
138
  return lines.join('\n') + '\n';
105
139
  }
140
+ if (opts.source === 'body') {
141
+ lines.push(dim(' (from body links — add these paths to frontmatter `runlist:` to make the order canonical)'));
142
+ }
106
143
 
107
144
  const archiveStatuses = opts.archiveStatuses ?? new Set(['archived']);
108
145
  let nextPicked = false;
@@ -144,18 +181,20 @@ export async function runRunlist(argv, config, opts = {}) {
144
181
  if (!hubAbs) die(`Hub plan not found: ${hubInput}`);
145
182
  const hubRepoPath = toRepoPath(hubAbs, config.repoRoot);
146
183
 
147
- const children = readRunlistChildren(hubAbs, config);
184
+ const runlist = readRunlistChildren(hubAbs, config);
185
+ const { children, source } = runlist;
148
186
  const archiveStatuses = config.lifecycle?.archiveStatuses ?? new Set(['archived']);
149
187
 
150
188
  if (sub === 'show') {
151
189
  if (json) {
152
190
  process.stdout.write(JSON.stringify({
153
191
  hub: hubRepoPath,
192
+ source,
154
193
  children,
155
194
  }, null, 2) + '\n');
156
195
  return;
157
196
  }
158
- process.stdout.write(renderRunlist(hubRepoPath, children, { archiveStatuses }));
197
+ process.stdout.write(renderRunlist(hubRepoPath, children, { archiveStatuses, source }));
159
198
  return;
160
199
  }
161
200