dotmd-cli 0.59.0 → 0.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lifecycle.mjs CHANGED
@@ -2,8 +2,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
4
  import { asString, toRepoPath, die, warn, resolveDocPath, resolveRefPath, escapeRegex, nowIso, suggestCandidates, emitFilesFooter, isArchivedPath } from './util.mjs';
5
- import { gitMv, getGitLastModified, getGitLastModifiedBatch } from './git.mjs';
6
- import { buildIndex, collectDocFiles } from './index.mjs';
5
+ import { gitMv, getGitLastModifiedBatch } from './git.mjs';
6
+ import { buildIndex, collectDocFiles, resolveDocArg } from './index.mjs';
7
7
  import { renderIndexFile, writeIndex } from './index-file.mjs';
8
8
  import { green, dim } from './color.mjs';
9
9
  import { isInteractive, promptChoice } from './prompt.mjs';
@@ -15,29 +15,6 @@ function findFileRoot(filePath, config) {
15
15
  return roots.find(r => filePath.startsWith(r + '/')) ?? config.docsRoot;
16
16
  }
17
17
 
18
- // Resolve an archive target like `dotmd use` resolves a pickup target: an exact
19
- // path wins (the resolveDocPath fast path), but a bare slug / basename falls
20
- // back to a recursive basename match under the doc roots. Mirrors
21
- // resolvePromptInput's basename pass so the closure verb is as forgiving about
22
- // naming as `use`/`prompts archive`. A basename shared by two files errors with
23
- // the candidate list rather than guessing which one to move.
24
- function resolveArchiveTarget(input, config) {
25
- const direct = resolveDocPath(input, config);
26
- if (direct) return direct;
27
- if (!input.endsWith('.md')) {
28
- const withExt = resolveDocPath(input + '.md', config);
29
- if (withExt) return withExt;
30
- }
31
-
32
- const slug = input.replace(/\.md$/, '');
33
- const byBasename = collectDocFiles(config).filter(f => path.basename(f, '.md') === slug);
34
- if (byBasename.length === 1) return byBasename[0];
35
- if (byBasename.length > 1) {
36
- die(`Multiple docs match "${input}" by basename:\n${byBasename.map(f => ' ' + toRepoPath(f, config.repoRoot)).join('\n')}`);
37
- }
38
- return null;
39
- }
40
-
41
18
  function defaultTypeDir(docType, config) {
42
19
  if (docType === 'plan') return 'plans';
43
20
  if (docType === 'prompt') return 'prompts';
@@ -151,6 +128,13 @@ export async function runStatus(argv, config, opts = {}) {
151
128
  const noIndex = argv.includes('--no-index') || opts.noIndex;
152
129
  const showFiles = argv.includes('--show-files') || opts.showFiles;
153
130
  argv = argv.filter(a => a !== '--no-index' && a !== '--show-files');
131
+ let note = opts.note ?? null;
132
+ const noteIdx = argv.indexOf('--note');
133
+ if (noteIdx !== -1) {
134
+ note = note ?? argv[noteIdx + 1] ?? null;
135
+ if (!note || note.startsWith('--')) die('--note requires a value: --note "what changed and why"');
136
+ argv = argv.filter((_, i) => i !== noteIdx && i !== noteIdx + 1);
137
+ }
154
138
  const input = argv[0];
155
139
  let newStatus = argv[1];
156
140
 
@@ -160,8 +144,7 @@ export async function runStatus(argv, config, opts = {}) {
160
144
 
161
145
  if (!input) { die('Usage: dotmd status <file> <new-status>'); }
162
146
 
163
- const filePath = resolveDocPath(input, config);
164
- if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`); }
147
+ const filePath = resolveDocArg(input, config);
165
148
 
166
149
  // Determine type-specific or root-specific valid statuses
167
150
  const raw = readFileSync(filePath, 'utf8');
@@ -256,12 +239,18 @@ export async function runStatus(argv, config, opts = {}) {
256
239
  if ((isArchiving || isUnarchiving || isFiling || isUnfiling) && config.indexPath) {
257
240
  process.stdout.write(`${prefix} Would regenerate index\n`);
258
241
  }
242
+ if (note) {
243
+ process.stdout.write(`${prefix} Would append Version History: - **${today}** Status: ${oldStatus ?? 'unknown'} → ${newStatus} — ${note}\n`);
244
+ }
259
245
  process.stdout.write(`${prefix} ${toRepoPath(finalPath, config.repoRoot)}: ${oldStatus ?? 'unknown'} → ${newStatus}\n`);
260
246
  return;
261
247
  }
262
248
 
263
249
  updateFrontmatter(filePath, { status: newStatus, updated: today });
264
- appendVersionHistory(filePath, `Status: ${oldStatus ?? 'unknown'} → ${newStatus}.`);
250
+ const transition = `Status: ${oldStatus ?? 'unknown'} → ${newStatus}`;
251
+ // A --note must land even when the doc has no Version History section yet;
252
+ // the plain transition entry stays best-effort (bare docs skip it).
253
+ appendVersionHistory(filePath, note ? `${transition} — ${note}` : `${transition}.`, { createSection: Boolean(note) });
265
254
 
266
255
  if (isArchiving) {
267
256
  mkdirSync(archiveDir, { recursive: true });
@@ -350,8 +339,7 @@ export async function startPlan(argv, config, opts = {}) {
350
339
  input = candidates[idx].path;
351
340
  }
352
341
 
353
- const filePath = resolveDocPath(input, config);
354
- if (!filePath) die(`File not found: ${input}`);
342
+ const filePath = resolveDocArg(input, config);
355
343
 
356
344
  const raw = readFileSync(filePath, 'utf8');
357
345
  const { frontmatter: fmRaw, body } = extractFrontmatter(raw);
@@ -420,12 +408,18 @@ export function runArchive(argv, config, opts = {}) {
420
408
  const showFiles = argv.includes('--show-files') || opts.showFiles;
421
409
  const closeoutTemplate = argv.includes('--closeout-template');
422
410
  argv = argv.filter(a => a !== '--no-index' && a !== '--show-files' && a !== '--closeout-template');
411
+ let note = opts.note ?? null;
412
+ const noteIdx = argv.indexOf('--note');
413
+ if (noteIdx !== -1) {
414
+ note = note ?? argv[noteIdx + 1] ?? null;
415
+ if (!note || note.startsWith('--')) die('--note requires a value: --note "what shipped / why closed"');
416
+ argv = argv.filter((_, i) => i !== noteIdx && i !== noteIdx + 1);
417
+ }
423
418
  const input = argv[0];
424
419
 
425
420
  if (!input) { die('Usage: dotmd archive <file>'); }
426
421
 
427
- const filePath = resolveArchiveTarget(input, config);
428
- if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`); }
422
+ const filePath = resolveDocArg(input, config);
429
423
 
430
424
  const archiveFileRoot = findFileRoot(filePath, config);
431
425
  const relFromRoot = path.relative(archiveFileRoot, filePath);
@@ -456,7 +450,8 @@ export function runArchive(argv, config, opts = {}) {
456
450
  return;
457
451
  }
458
452
  updateFrontmatter(filePath, { status: 'archived', updated: today });
459
- appendVersionHistory(filePath, `Archived (frontmatter healed in place from \`${oldStatus}\`).`);
453
+ const healEntry = `Archived (frontmatter healed in place from \`${oldStatus}\`)${note ? ` — ${note}` : '.'}`;
454
+ appendVersionHistory(filePath, healEntry, { createSection: Boolean(note) });
460
455
  if (!noIndex) regenIndex(config);
461
456
  out.write(`${green('✓ Healed')}: ${repoPathHeal} (${oldStatus} → archived; file already under \`${config.archiveDir}/\`)\n`);
462
457
  const touched = [repoPathHeal];
@@ -488,6 +483,9 @@ export function runArchive(argv, config, opts = {}) {
488
483
  out.write(`${prefix} \`## Closeout\` section already present — no injection\n`);
489
484
  }
490
485
  out.write(`${prefix} Would update frontmatter: status: ${oldStatus} → archived, updated: ${today}\n`);
486
+ if (note) {
487
+ out.write(`${prefix} Would append Version History: - **${today}** Archived — ${note}\n`);
488
+ }
491
489
  out.write(`${prefix} Would move: ${oldRepoPath} → ${newRepoPath}\n`);
492
490
  if (config.indexPath && !noIndex) out.write(`${prefix} Would regenerate index\n`);
493
491
  if (config.indexPath && noIndex) out.write(`${prefix} Would skip index regen (--no-index)\n`);
@@ -510,7 +508,7 @@ export function runArchive(argv, config, opts = {}) {
510
508
  }
511
509
 
512
510
  updateFrontmatter(filePath, { status: 'archived', updated: today });
513
- appendVersionHistory(filePath, 'Archived.');
511
+ appendVersionHistory(filePath, note ? `Archived — ${note}` : 'Archived.', { createSection: Boolean(note) });
514
512
 
515
513
  mkdirSync(targetDir, { recursive: true });
516
514
 
@@ -571,6 +569,13 @@ export async function runSet(argv, config, opts = {}) {
571
569
  const noIndex = argv.includes('--no-index');
572
570
  const showFiles = argv.includes('--show-files');
573
571
  argv = argv.filter(a => a !== '--no-index' && a !== '--show-files');
572
+ let note = opts.note ?? null;
573
+ const noteIdx = argv.indexOf('--note');
574
+ if (noteIdx !== -1) {
575
+ note = note ?? argv[noteIdx + 1] ?? null;
576
+ if (!note || note.startsWith('--')) die('--note requires a value: --note "what changed and why"');
577
+ argv = argv.filter((_, i) => i !== noteIdx && i !== noteIdx + 1);
578
+ }
574
579
 
575
580
  const newStatus = argv[0];
576
581
  const input = argv[1];
@@ -578,8 +583,7 @@ export async function runSet(argv, config, opts = {}) {
578
583
  if (!newStatus) die('Usage: dotmd set <status> <path>');
579
584
  if (!input) die('Usage: dotmd set <status> <path>');
580
585
 
581
- const filePath = resolveDocPath(input, config);
582
- if (!filePath) die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`);
586
+ const filePath = resolveDocArg(input, config);
583
587
 
584
588
  const inArchive = isArchivedPath(toRepoPath(filePath, config.repoRoot), config);
585
589
 
@@ -587,13 +591,30 @@ export async function runSet(argv, config, opts = {}) {
587
591
  const archiveArgs = [filePath];
588
592
  if (noIndex) archiveArgs.push('--no-index');
589
593
  if (showFiles) archiveArgs.push('--show-files');
590
- return runArchive(archiveArgs, config, { dryRun });
594
+ return runArchive(archiveArgs, config, { dryRun, note });
595
+ }
596
+
597
+ // `partial` promises a successor tracking the deferred tail. When neither a
598
+ // --note nor any doc reference exists to point at it, remind — advisory
599
+ // only, computed before the transition (filing may move the file).
600
+ let partialReminder = false;
601
+ if (newStatus === 'partial' && !note) {
602
+ try {
603
+ const { frontmatter: fmRaw, body } = extractFrontmatter(readFileSync(filePath, 'utf8'));
604
+ const related = parseSimpleFrontmatter(fmRaw).related_plans;
605
+ const hasRelated = Array.isArray(related) ? related.length > 0 : Boolean(asString(related)?.trim());
606
+ partialReminder = !hasRelated && !/[\w./-]+\.md\b/.test(body);
607
+ } catch { /* advisory only */ }
591
608
  }
592
609
 
593
610
  const statusArgs = [filePath, newStatus];
594
611
  if (noIndex) statusArgs.push('--no-index');
595
612
  if (showFiles) statusArgs.push('--show-files');
596
- await runStatus(statusArgs, config, { dryRun, suppressDeprecation: true });
613
+ await runStatus(statusArgs, config, { dryRun, suppressDeprecation: true, note });
614
+
615
+ if (partialReminder && !dryRun) {
616
+ warn('partial usually references the successor plan tracking the tail — add a link to the body, or rerun with --note "tail tracked in <plan>".');
617
+ }
597
618
  }
598
619
 
599
620
  export function runBulkArchive(argv, config, opts = {}) {
@@ -670,8 +691,7 @@ export function runTouch(argv, config, opts = {}) {
670
691
 
671
692
  // --git mode: bulk-sync frontmatter dates from git history
672
693
  if (useGit) {
673
- const allFiles = input ? [resolveDocPath(input, config)].filter(Boolean) : collectDocFiles(config);
674
- if (input && allFiles.length === 0) { die(`File not found: ${input}`); }
694
+ const allFiles = input ? [resolveDocArg(input, config)] : collectDocFiles(config);
675
695
 
676
696
  const prefix = dryRun ? dim('[dry-run] ') : '';
677
697
  let synced = 0;
@@ -714,8 +734,7 @@ export function runTouch(argv, config, opts = {}) {
714
734
 
715
735
  if (!input) { die('Usage: dotmd touch <file>\n dotmd touch --git Bulk-sync dates from git history'); }
716
736
 
717
- const filePath = resolveDocPath(input, config);
718
- if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`); }
737
+ const filePath = resolveDocArg(input, config);
719
738
 
720
739
  const today = nowIso();
721
740
 
@@ -862,7 +881,7 @@ function countRefsToUpdate(oldPath, newPath, config) {
862
881
  // Newest-first ordering: inserted at the top of the section, right after the
863
882
  // heading + blank-line gap. If the section is missing, this is a silent no-op
864
883
  // — never auto-creates the section (don't surprise users on old plans/docs).
865
- export function appendVersionHistory(filePath, entry) {
884
+ export function appendVersionHistory(filePath, entry, { createSection = false } = {}) {
866
885
  let raw;
867
886
  try { raw = readFileSync(filePath, 'utf8'); } catch { return false; }
868
887
  if (!raw.startsWith('---\n')) return false;
@@ -872,10 +891,18 @@ export function appendVersionHistory(filePath, entry) {
872
891
  const frontmatter = raw.slice(4, endMarker);
873
892
  const body = raw.slice(endMarker + 5);
874
893
 
875
- const vh = findSection(walkSections(body), 'Version History');
876
- if (!vh) return false;
877
-
878
894
  const bullet = `- **${nowIso()}** ${entry}`;
895
+
896
+ const vh = findSection(walkSections(body), 'Version History');
897
+ if (!vh) {
898
+ // Bare docs (no scaffold) have no Version History; transitions silently
899
+ // skip the worklog. But an explicit `--note` must not be dropped — the
900
+ // caller opts into creating the section at the end of the body.
901
+ if (!createSection) return false;
902
+ const trimmed = body.replace(/\n+$/, '');
903
+ writeFileSync(filePath, `---\n${frontmatter}\n---\n${trimmed}\n\n## Version History\n\n${bullet}\n`, 'utf8');
904
+ return true;
905
+ }
879
906
  const lines = body.split('\n');
880
907
 
881
908
  // vh.lineStart is 1-indexed for the heading line. The line immediately
@@ -901,7 +928,10 @@ export function appendVersionHistory(filePath, entry) {
901
928
 
902
929
  export function updateFrontmatter(filePath, updates) {
903
930
  const raw = readFileSync(filePath, 'utf8');
904
- if (!raw.startsWith('---\n')) throw new Error(`${filePath} has no frontmatter block.`);
931
+ // Name the remedy in the error: this is where every status verb lands when a
932
+ // doc was created outside dotmd, and "no frontmatter block" alone left
933
+ // sessions retrying other verbs instead of fixing the doc.
934
+ if (!raw.startsWith('---\n')) throw new Error(`${filePath} has no frontmatter block. Retrofit it first: dotmd bulk-tag ${filePath} --type <type> --status <status>`);
905
935
 
906
936
  const endMarker = raw.indexOf('\n---\n', 4);
907
937
  if (endMarker === -1) throw new Error(`${filePath} has unclosed frontmatter block.`);
package/src/new.mjs CHANGED
@@ -256,7 +256,7 @@ function mergeBodyFrontmatter(scaffoldFm, overrides, cliType) {
256
256
  return fm;
257
257
  }
258
258
 
259
- function readBodyInput(source) {
259
+ export function readBodyInput(source) {
260
260
  if (source === '-') {
261
261
  try { return readFileSync(0, 'utf8'); } catch (err) { die(`Could not read body from stdin: ${err.message}`); }
262
262
  }
@@ -548,6 +548,12 @@ export async function runNew(argv, config, opts = {}) {
548
548
  if (typeName === 'prompt') {
549
549
  process.stdout.write(dim('Session-local — no need to commit. The next session runs `dotmd use` (or `dotmd use ' + repoPath + '`) to consume it.\n'));
550
550
  }
551
+ // Teach the field-length contract at the moment the fields get written —
552
+ // learning it from a cap warning later sends sessions into hand-trim /
553
+ // re-check loops.
554
+ if (typeName === 'plan') {
555
+ process.stdout.write(dim('current_state = 2-4 sentence summary (cap 1500 chars); next_step = 1-2 sentence pointer (cap 800). Detail goes in the body, not frontmatter.\n'));
556
+ }
551
557
  try {
552
558
  const { isGitIgnored } = await import('./git.mjs');
553
559
  if (isGitIgnored(filePath, config.repoRoot)) {
package/src/prompts.mjs CHANGED
@@ -11,7 +11,7 @@ import { green, dim } from './color.mjs';
11
11
  // `resume` is an alias for `use` — agents reach for "resume" when continuing a
12
12
  // session; `use` reads as internal mechanics. Both names stay valid; the
13
13
  // canonical output ("Consumed: …") is unchanged.
14
- const SUBCOMMANDS = new Set(['list', 'next', 'use', 'resume', 'archive', 'new', 'hold', 'unhold', 'shelve', 'unshelve']);
14
+ const SUBCOMMANDS = new Set(['list', 'next', 'use', 'resume', 'show', 'peek', 'archive', 'new', 'hold', 'unhold', 'shelve', 'unshelve']);
15
15
 
16
16
  export async function runPrompts(argv, config, opts = {}) {
17
17
  const sub = argv[0];
@@ -26,6 +26,8 @@ export async function runPrompts(argv, config, opts = {}) {
26
26
  case 'next': return runPromptsNext(rest, config, opts);
27
27
  case 'use': return runPromptsUse(rest, config, opts);
28
28
  case 'resume': return runPromptsUse(rest, config, opts);
29
+ case 'show': return runPromptsShow(rest, config);
30
+ case 'peek': return runPromptsShow(rest, config);
29
31
  case 'archive': return runPromptsArchive(rest, config, opts);
30
32
  case 'new': return runPromptsNew(rest, config, opts);
31
33
  case 'hold': return runPromptsHold(rest, config, opts);
@@ -283,6 +285,28 @@ export function consumePrompt(filePath, config, opts) {
283
285
  process.stderr.write(`${green('✓ Consumed')}: ${consumedPath}\n`);
284
286
  }
285
287
 
288
+ // Read-only peek: print the body WITHOUT consuming. The sanctioned triage path
289
+ // — surveying pending prompts must not archive them (that's `use`'s job), and
290
+ // it must not require raw cat/Read (which the guard warns about).
291
+ function runPromptsShow(argv, config) {
292
+ const input = argv.find(a => !a.startsWith('-'));
293
+ if (!input) die('Usage: dotmd prompts show <file-or-slug>');
294
+ const filePath = resolvePromptInput(input, config);
295
+
296
+ const raw = readFileSync(filePath, 'utf8');
297
+ const { frontmatter, body } = extractFrontmatter(raw);
298
+ const parsed = parseSimpleFrontmatter(frontmatter);
299
+ const repoPath = toRepoPath(filePath, config.repoRoot);
300
+ if (asString(parsed.type) !== 'prompt') {
301
+ die(`Not a prompt (type: ${asString(parsed.type) ?? 'unknown'}): ${repoPath}`);
302
+ }
303
+
304
+ const status = asString(parsed.status) ?? 'unknown';
305
+ process.stderr.write(dim(`${repoPath} [${status}] — read-only peek; \`dotmd use ${repoPath}\` to consume\n`));
306
+ process.stdout.write(body);
307
+ if (!body.endsWith('\n')) process.stdout.write('\n');
308
+ }
309
+
286
310
  function runPromptsArchive(argv, config, opts = {}) {
287
311
  const input = argv.find(a => !a.startsWith('-'));
288
312
  if (!input) die('Usage: dotmd prompts archive <file-or-slug>');
package/src/query.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { capitalize, toSlug, truncate, warn, suggestCandidates, isArchivedPath } from './util.mjs';
3
+ import { capitalize, toSlug, truncate, warn, die, suggestCandidates, isArchivedPath } from './util.mjs';
4
4
  import { renderProgressBar, formatCurrentState } from './render.mjs';
5
5
  import { computeDaysSinceUpdate, computeIsStale } from './validate.mjs';
6
6
  import { getGitLastModifiedBatch } from './git.mjs';
@@ -76,6 +76,9 @@ export function runFocus(index, argv, config) {
76
76
 
77
77
  export function runQuery(index, argv, config, opts = {}) {
78
78
  const filters = parseQueryArgs(argv);
79
+ if (filters.body && !filters.keyword) {
80
+ die('`--body` extends a keyword search into document bodies — pass `--keyword <term>` (or use `dotmd grep <term>`).');
81
+ }
79
82
  const docs = filterDocs(index.docs, filters, config);
80
83
 
81
84
  if (filters.json) {
@@ -119,7 +122,7 @@ function writeUnknownFilterValueHint(filters, index) {
119
122
 
120
123
  export function parseQueryArgs(argv) {
121
124
  const filters = {
122
- types: null, statuses: null, keyword: null, owner: null, surface: null,
125
+ types: null, statuses: null, keyword: null, body: false, owner: null, surface: null,
123
126
  module: null, domain: null, audience: null, executionMode: null,
124
127
  updatedSince: null, limit: 20, all: false, sort: 'updated',
125
128
  group: null,
@@ -136,6 +139,7 @@ export function parseQueryArgs(argv) {
136
139
  if (arg === '--type' && next) { filters.types = next.split(',').map(v => v.trim()).filter(Boolean); i += 1; continue; }
137
140
  if (arg === '--status' && next) { filters.statuses = next.split(',').map(v => v.trim()).filter(Boolean); i += 1; continue; }
138
141
  if (arg === '--keyword' && next) { filters.keyword = next; i += 1; continue; }
142
+ if (arg === '--body') { filters.body = true; continue; }
139
143
  if (arg === '--owner' && next) { filters.owner = next; i += 1; continue; }
140
144
  if (arg === '--surface' && next) { filters.surface = next; i += 1; continue; }
141
145
  if (arg === '--module' && next) { filters.module = next; i += 1; continue; }
@@ -187,9 +191,12 @@ export function filterDocs(docs, filters, config) {
187
191
  result = result.filter(d => !archived.has(d.status) && !isArchivedPath(d.path, config));
188
192
  }
189
193
 
190
- if (filters.keyword) {
191
- const needle = filters.keyword.toLowerCase();
192
- result = result.filter(d => [d.title, d.summary, d.currentState, d.nextStep, d.path, ...(d.blockers ?? [])].filter(Boolean).join(' ').toLowerCase().includes(needle));
194
+ const keywordNeedle = filters.keyword ? filters.keyword.toLowerCase() : null;
195
+ const matchesFrontmatter = d => [d.title, d.summary, d.currentState, d.nextStep, d.path, ...(d.blockers ?? [])].filter(Boolean).join(' ').toLowerCase().includes(keywordNeedle);
196
+ // With --body the keyword filter is deferred to after the cheap frontmatter
197
+ // filters (below) so body files are only read for surviving candidates.
198
+ if (keywordNeedle && !filters.body) {
199
+ result = result.filter(matchesFrontmatter);
193
200
  }
194
201
 
195
202
  // Positional substring filter: AND match against slug + title.
@@ -224,6 +231,19 @@ export function filterDocs(docs, filters, config) {
224
231
  if (filters.hasBlockers) result = result.filter(d => d.hasBlockers);
225
232
  if (filters.checklistOpen) result = result.filter(d => (d.checklist?.open ?? 0) > 0);
226
233
 
234
+ // Lazy body scan: docs already matching on frontmatter fields keep their spot
235
+ // without a file read; only the rest get their bodies scanned. Hits carry
236
+ // 1-2 matching-line excerpts so the caller can rank without opening files.
237
+ if (keywordNeedle && filters.body) {
238
+ result = result.filter(d => {
239
+ if (matchesFrontmatter(d)) return true;
240
+ const matches = scanBodyForKeyword(d, keywordNeedle, config);
241
+ if (!matches.length) return false;
242
+ d.bodyMatches = matches;
243
+ return true;
244
+ });
245
+ }
246
+
227
247
  result.sort(buildSorter(filters.sort, config));
228
248
  // Stash pre-limit count and per-status breakdown on filters so renderers
229
249
  // can show "N more" footers and accurate pipeline summaries even when the
@@ -237,6 +257,45 @@ export function filterDocs(docs, filters, config) {
237
257
  return filters.all ? result : result.slice(0, filters.limit);
238
258
  }
239
259
 
260
+ // Read one doc's body and return up to MAX_BODY_MATCHES matching-line
261
+ // excerpts: { line: <1-based file line>, text: <trimmed, windowed around the
262
+ // match> }. Line numbers are file-absolute (frontmatter included) so they can
263
+ // feed straight into a Read offset.
264
+ const MAX_BODY_MATCHES = 2;
265
+ const EXCERPT_WIDTH = 120;
266
+
267
+ function scanBodyForKeyword(doc, needle, config) {
268
+ let raw;
269
+ try {
270
+ raw = readFileSync(path.resolve(config.repoRoot, doc.path), 'utf8');
271
+ } catch (err) {
272
+ warn(`Could not read ${doc.path}: ${err.message}`);
273
+ return [];
274
+ }
275
+ const { body } = extractFrontmatter(raw);
276
+ if (!body || !body.toLowerCase().includes(needle)) return [];
277
+
278
+ // body is a suffix of raw — the slice before it is the frontmatter block.
279
+ const bodyStartLine = raw.slice(0, raw.length - body.length).split('\n').length;
280
+ const lines = body.split('\n');
281
+ const matches = [];
282
+ for (let i = 0; i < lines.length && matches.length < MAX_BODY_MATCHES; i++) {
283
+ const text = lines[i].trim();
284
+ const at = text.toLowerCase().indexOf(needle);
285
+ if (at === -1) continue;
286
+ matches.push({ line: bodyStartLine + i, text: excerptAround(text, at, needle.length) });
287
+ }
288
+ return matches;
289
+ }
290
+
291
+ // Window a long line around the match so the needle is always visible.
292
+ function excerptAround(text, at, needleLen) {
293
+ if (text.length <= EXCERPT_WIDTH) return text;
294
+ const start = Math.max(0, Math.min(at - Math.floor((EXCERPT_WIDTH - needleLen) / 2), text.length - EXCERPT_WIDTH));
295
+ const slice = text.slice(start, start + EXCERPT_WIDTH);
296
+ return `${start > 0 ? '…' : ''}${slice}${start + EXCERPT_WIDTH < text.length ? '…' : ''}`;
297
+ }
298
+
240
299
  function getDocSummary(doc, config) {
241
300
  try {
242
301
  const absPath = path.resolve(config.repoRoot, doc.path);
@@ -261,7 +320,7 @@ function renderQueryResults(docs, filters, config) {
261
320
  }
262
321
  if (filters.types?.length) process.stdout.write(`- type: ${filters.types.join(', ')}\n`);
263
322
  if (filters.statuses?.length) process.stdout.write(`- status: ${filters.statuses.join(', ')}\n`);
264
- if (filters.keyword) process.stdout.write(`- keyword: ${filters.keyword}\n`);
323
+ if (filters.keyword) process.stdout.write(`- keyword: ${filters.keyword}${filters.body ? ' (bodies scanned)' : ''}\n`);
265
324
  if (filters.owner) process.stdout.write(`- owner: ${filters.owner}\n`);
266
325
  if (filters.surface) process.stdout.write(`- surface: ${filters.surface}\n`);
267
326
  if (filters.module) process.stdout.write(`- module: ${filters.module}\n`);
@@ -299,6 +358,9 @@ function renderQueryResults(docs, filters, config) {
299
358
  if (doc.executionMode) process.stdout.write(` execution-mode: ${doc.executionMode}\n`);
300
359
  if (doc.blockers?.length) process.stdout.write(` blockers: ${doc.blockers.join('; ')}\n`);
301
360
  if (doc.checklist?.total) process.stdout.write(` checklist: ${doc.checklist.completed}/${doc.checklist.total} complete\n`);
361
+ for (const m of doc.bodyMatches ?? []) {
362
+ process.stdout.write(` match: ${dim(`L${m.line}:`)} ${m.text}\n`);
363
+ }
302
364
  if (filters.summarize && idx < filters.summarizeLimit) {
303
365
  const summary = getDocSummary(doc, config);
304
366
  if (summary) process.stdout.write(` ${dim('ai-summary:')} ${summary}\n`);
package/src/rename.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { toRepoPath, resolveDocPath, die, warn } from './util.mjs';
4
- import { collectDocFiles } from './index.mjs';
3
+ import { toRepoPath, die, warn } from './util.mjs';
4
+ import { collectDocFiles, resolveDocArg } from './index.mjs';
5
5
  import { regenIndex } from './lifecycle.mjs';
6
6
  import { gitMv } from './git.mjs';
7
7
  import { green, dim } from './color.mjs';
@@ -31,10 +31,7 @@ export async function runRename(argv, config, opts = {}) {
31
31
  }
32
32
 
33
33
  // Resolve old path
34
- const oldPath = resolveDocPath(oldInput, config);
35
- if (!oldPath) {
36
- die(`File not found: ${oldInput}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`);
37
- }
34
+ const oldPath = resolveDocArg(oldInput, config);
38
35
 
39
36
  // Compute new path — cross-directory if input has slashes, same directory otherwise
40
37
  let newPath;
package/src/render.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { capitalize, toSlug, truncate, warn } from './util.mjs';
3
+ import { capitalize, toSlug, truncate, warn, isArchivedPath } from './util.mjs';
4
4
  import { extractFrontmatter } from './frontmatter.mjs';
5
5
  import { summarizeDocBody } from './ai.mjs';
6
6
  import { bold, red, yellow, green, dim } from './color.mjs';
@@ -323,10 +323,20 @@ export function renderBriefing(index, config) {
323
323
  const untyped = index.docs.filter(d => !d.type);
324
324
 
325
325
  if (plans.length) {
326
+ // Headline counts LIVE plans first — "30 plans: 25 archived, …" skims as
327
+ // 30 open work items when zero are. "Live" mirrors the `dotmd plans`
328
+ // filter: not in an archive/terminal status and not filed under archived/.
329
+ const closed = new Set([
330
+ ...(config.lifecycle?.archiveStatuses ?? []),
331
+ ...(config.lifecycle?.terminalStatuses ?? []),
332
+ ]);
333
+ const live = plans.filter(p => !closed.has(p.status) && !isArchivedPath(p.path, config));
326
334
  const bySt = {};
327
- for (const p of plans) { bySt[p.status] = (bySt[p.status] ?? 0) + 1; }
335
+ for (const p of live) { bySt[p.status] = (bySt[p.status] ?? 0) + 1; }
328
336
  const counts = Object.entries(bySt).map(([s, n]) => `${n} ${s}`).join(', ');
329
- lines.push(`${plans.length} plans: ${counts}`);
337
+ const closedCount = plans.length - live.length;
338
+ const closedPart = closedCount ? ` (${closedCount} archived)` : '';
339
+ lines.push(live.length ? `${live.length} live plans${closedPart}: ${counts}` : `0 live plans${closedPart}`);
330
340
  const show = plans.filter(p => p.status === 'in-session' || p.status === 'active');
331
341
  for (const p of show) {
332
342
  const next = p.nextStep ? `next: ${p.nextStep}` : '(no next step)';
package/src/runlist.mjs CHANGED
@@ -1,45 +1,22 @@
1
- import { existsSync, readFileSync } from 'node:fs';
1
+ import { readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
4
  import {
5
5
  asString,
6
6
  die,
7
7
  normalizeStringList,
8
- resolveDocPath,
9
8
  resolveRefPath,
10
9
  toRepoPath,
11
- toSlug,
12
10
  } from './util.mjs';
11
+ import { resolveDocArg } from './index.mjs';
13
12
  import { bold, cyan, dim, green, red, yellow } from './color.mjs';
14
13
 
15
14
  const PICKUPABLE_STATUSES = new Set(['active', 'planned', 'in-session']);
16
15
 
17
- // resolveDocPath only tries cwd / repo-root / docs-root joins, so bare plan
18
- // slugs like `clear-the-deck` don't resolve when plans live under
19
- // `<docsRoot>/plans/`. The other commands (`dotmd set <slug>`, `dotmd plans`)
20
- // take slugs — runlist should too. Try the direct resolve first, then
21
- // fall back to `<root>/plans/<slug>.md` under each configured doc root.
16
+ // Bare hub slugs resolve through the shared resolver; the caller keeps its
17
+ // runlist-specific miss message, so no die-on-miss here.
22
18
  function resolveHubInput(input, config) {
23
- const direct = resolveDocPath(input, config);
24
- if (direct) return direct;
25
-
26
- if (!input.endsWith('.md')) {
27
- const withExt = resolveDocPath(input + '.md', config);
28
- if (withExt) return withExt;
29
- }
30
-
31
- const slugFile = input.endsWith('.md') ? input : `${input}.md`;
32
- const roots = config.docsRoots || (config.docsRoot ? [config.docsRoot] : []);
33
- for (const root of roots) {
34
- const candidate = path.join(root, 'plans', slugFile);
35
- if (existsSync(candidate)) return candidate;
36
- // Multi-root layouts may already have a `plans` root, so also try the
37
- // root itself.
38
- const rootCandidate = path.join(root, slugFile);
39
- if (existsSync(rootCandidate)) return rootCandidate;
40
- }
41
-
42
- return null;
19
+ return resolveDocArg(input, config, { dieOnMiss: false });
43
20
  }
44
21
 
45
22
  // Read a hub plan's `runlist:` and resolve each entry to a repo-relative path
package/src/summary.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
- import { asString, toRepoPath, resolveDocPath, die, warn } from './util.mjs';
4
+ import { asString, toRepoPath, die, warn } from './util.mjs';
5
+ import { resolveDocArg } from './index.mjs';
5
6
  import { summarizeDocBody } from './ai.mjs';
6
7
  import { bold, dim } from './color.mjs';
7
8
 
@@ -23,8 +24,7 @@ export function runSummary(argv, config) {
23
24
  const input = positional[0];
24
25
  if (!input) { die('Usage: dotmd summary <file> [--model <name>] [--json]'); }
25
26
 
26
- const filePath = resolveDocPath(input, config);
27
- if (!filePath) { die(`File not found: ${input}`); }
27
+ const filePath = resolveDocArg(input, config);
28
28
 
29
29
  const raw = readFileSync(filePath, 'utf8');
30
30
  const { frontmatter, body } = extractFrontmatter(raw);
package/src/use.mjs CHANGED
@@ -3,6 +3,7 @@ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
3
3
  import { asString, die, resolveDocPath, toRepoPath } from './util.mjs';
4
4
  import { consumePrompt, pendingPromptsOldestFirst, resolvePromptInput } from './prompts.mjs';
5
5
  import { startPlan } from './lifecycle.mjs';
6
+ import { resolveDocArg } from './index.mjs';
6
7
 
7
8
  // Top-level `dotmd use [file]` — the single "start engaging with this doc"
8
9
  // verb. Dispatches by the target doc's `type:` so agents don't have to know
@@ -22,8 +23,12 @@ export async function runUse(argv, config, opts = {}) {
22
23
  return consumePrompt(head.abs, config, opts);
23
24
  }
24
25
 
25
- const filePath = resolveDocPath(positional, config) ?? resolvePromptInput(positional, config, { dieOnMiss: false });
26
- if (!filePath) die(`File not found: ${positional}`);
26
+ // Exact path first, then prompt slugs (they keep precedence on a slug
27
+ // collision — consuming a prompt is the more common intent), then the
28
+ // shared resolver for plan/doc slugs, which dies with did-you-mean on miss.
29
+ const filePath = resolveDocPath(positional, config)
30
+ ?? resolvePromptInput(positional, config, { dieOnMiss: false })
31
+ ?? resolveDocArg(positional, config);
27
32
 
28
33
  const raw = readFileSync(filePath, 'utf8');
29
34
  const { frontmatter } = extractFrontmatter(raw);
package/src/validate.mjs CHANGED
@@ -475,7 +475,7 @@ export function validatePlanShape(doc, body, frontmatter, config) {
475
475
  doc.warnings.push({
476
476
  path: doc.path,
477
477
  level: 'warning',
478
- message: `\`next_step\` is ${nextStep.length} chars (cap: 800). Long prose belongs in the body — keep next_step as a 1-2 sentence pointer.`,
478
+ message: `\`next_step\` is ${nextStep.length} chars (cap: 800). One mechanical fix: \`dotmd doctor --frontmatter-fix\` (moves the overflow into the body) — do NOT hand-trim or re-run check in a loop. Going forward, write next_step as a 1-2 sentence pointer; detail goes in the body.`,
479
479
  });
480
480
  }
481
481
 
@@ -488,7 +488,7 @@ export function validatePlanShape(doc, body, frontmatter, config) {
488
488
  doc.warnings.push({
489
489
  path: doc.path,
490
490
  level: 'warning',
491
- message: `\`current_state\` is ${currentState.length} chars (cap: 1500). Long prose belongs in the body.`,
491
+ message: `\`current_state\` is ${currentState.length} chars (cap: 1500). One mechanical fix: \`dotmd doctor --frontmatter-fix\` (moves the overflow into the body) — do NOT hand-trim or re-run check in a loop. Going forward, write current_state as a 2-4 sentence summary; detail goes in the body.`,
492
492
  });
493
493
  }
494
494