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/bin/dotmd.mjs +118 -12
- package/dotmd.config.example.mjs +21 -4
- package/package.json +1 -1
- package/src/baton.mjs +231 -0
- package/src/commands.mjs +2 -2
- package/src/completions.mjs +4 -2
- package/src/config.mjs +5 -0
- package/src/deps.mjs +4 -6
- package/src/diff.mjs +4 -7
- package/src/doctor.mjs +38 -0
- package/src/guard.mjs +156 -48
- package/src/hud.mjs +74 -15
- package/src/index.mjs +49 -1
- package/src/lifecycle.mjs +77 -47
- package/src/new.mjs +7 -1
- package/src/prompts.mjs +25 -1
- package/src/query.mjs +68 -6
- package/src/rename.mjs +3 -6
- package/src/render.mjs +13 -3
- package/src/runlist.mjs +5 -28
- package/src/summary.mjs +3 -3
- package/src/use.mjs +7 -2
- package/src/validate.mjs +2 -2
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,
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 ? [
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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,
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
//
|
|
18
|
-
//
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
26
|
-
|
|
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).
|
|
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).
|
|
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
|
|