dotmd-cli 0.26.0 → 0.27.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/bin/dotmd.mjs CHANGED
@@ -21,7 +21,8 @@ View & Query:
21
21
  focus [status] [--json] Detailed view for one status group
22
22
  query [filters] [--json] Filtered search (--status, --keyword, --stale, etc.)
23
23
  plans Live plans (excludes archived; --include-archived for all)
24
- prompts Pending prompts in docs/prompts/
24
+ prompts [list|next|use|archive|new]
25
+ Manage saved prompts (default: list pending)
25
26
  stale Stale docs (preset)
26
27
  actionable Docs with next steps (preset)
27
28
 
@@ -260,24 +261,29 @@ Shows detailed info for all docs matching the given status (default: active).
260
261
  Options:
261
262
  --json Output as JSON`,
262
263
 
263
- hud: `dotmd hud — three-line actionable triage
264
+ hud: `dotmd hud — actionable triage for session start
264
265
 
265
- Prints up to three lines, in order:
266
+ Prints up to four lines, in order:
266
267
  ▶ You hold N plans: <slugs> (leases owned by current session)
267
268
  ▶ N handoffs queued: <slugs> (resume-prompt sidecars waiting)
269
+ ▶ N pending prompts: <slugs> (saved prompts in docs/prompts/)
268
270
  ⚠ N stuck leases >24h (suggest \`dotmd release --stale\`)
269
271
 
270
- Silent when all three are empty — designed for SessionStart hooks where
272
+ Silent when all four are empty — designed for SessionStart hooks where
271
273
  zero noise is the right default. Distinct from \`dotmd briefing\`, which
272
274
  dumps the full plan-status pipeline and per-plan next_step bodies (kilobytes
273
275
  on large repos). Use hud for ergonomic session boot; use briefing for
274
276
  explicit "give me the full picture."
275
277
 
278
+ The pending-prompts line tells Claude to consume them via
279
+ \`dotmd prompts use <file>\` rather than reading/cat'ing — that atomically
280
+ prints the body and archives the prompt so it cannot be double-consumed.
281
+
276
282
  Recommended SessionStart hook (in ~/.claude/settings.json):
277
283
  "SessionStart": [{ "hooks": [{ "type": "command", "command": "dotmd hud", "timeout": 5 }] }]
278
284
 
279
285
  Options:
280
- --json Output as JSON ({ owned, queued, stale })`,
286
+ --json Output as JSON ({ owned, queued, prompts, stale })`,
281
287
 
282
288
  briefing: `dotmd briefing — compact summary for session start
283
289
 
@@ -555,19 +561,36 @@ Examples:
555
561
  dotmd plans --group module # plans grouped by module
556
562
  dotmd plans --json # JSON output`,
557
563
 
558
- prompts: `dotmd prompts — list saved prompts (excludes archived by default)
564
+ prompts: `dotmd prompts — manage saved prompts (subcommand namespace)
565
+
566
+ Prompts are documents with \`type: prompt\`, typically saved under
567
+ docs/prompts/. They seed future Claude sessions; consuming a prompt
568
+ prints its body to stdout and atomically archives it (one-shot).
559
569
 
560
- Shows documents with type: prompt, typically saved under docs/prompts/.
561
- Prompts are created with \`dotmd new prompt <name> "<body>"\` and seed
562
- future Claude sessions via \`claude "$(cat docs/prompts/<name>.md)"\`.
570
+ Subcommands:
571
+ list List pending prompts (default)
572
+ next Consume the oldest pending prompt:
573
+ print body to stdout, flip status to archived
574
+ use <file> Consume a specific prompt (same as next, but
575
+ targets <file> instead of picking oldest)
576
+ archive <file> Archive a prompt without printing its body
577
+ new <slug> [body] Create a new prompt (alias for
578
+ \`dotmd new prompt <slug> [body]\`)
563
579
 
564
580
  Default prompt statuses: pending, claimed, archived.
565
581
 
566
582
  Examples:
567
583
  dotmd prompts # pending prompts (default)
568
- dotmd prompts --include-archived # all prompts including archived
569
- dotmd prompts --status claimed # already-consumed prompts
570
- dotmd prompts --json # JSON output`,
584
+ dotmd prompts list --include-archived # all prompts including archived
585
+ dotmd prompts list --status claimed # already-consumed prompts
586
+ dotmd prompts --json # JSON output
587
+
588
+ claude "$(dotmd prompts next)" # consume oldest pending + run claude
589
+ claude "$(dotmd prompts use docs/prompts/foo.md)"
590
+
591
+ dotmd prompts next --dry-run # preview without consuming
592
+ dotmd prompts archive docs/prompts/old.md
593
+ dotmd prompts new my-prompt "Body text here"`,
571
594
 
572
595
  stale: `dotmd stale — list stale documents
573
596
 
@@ -769,19 +792,8 @@ async function main() {
769
792
  return;
770
793
  }
771
794
  if (command === 'prompts') {
772
- const { buildIndex } = await import('../src/index.mjs');
773
- const { runQuery } = await import('../src/query.mjs');
774
- const index = buildIndex(config);
775
- const sub = restArgs[0];
776
- let defaults;
777
- let extras = restArgs;
778
- if (sub === 'status') {
779
- defaults = ['--type', 'prompt', '--exclude-archived', '--sort', 'status', '--all'];
780
- extras = restArgs.slice(1);
781
- } else {
782
- defaults = ['--type', 'prompt', '--exclude-archived', '--sort', 'updated', '--limit', '10'];
783
- }
784
- runQuery(index, [...defaults, ...extras], config, { preset: 'prompts' });
795
+ const { runPrompts } = await import('../src/prompts.mjs');
796
+ await runPrompts(restArgs, config, { dryRun });
785
797
  return;
786
798
  }
787
799
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.26.0",
3
+ "version": "0.27.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",
@@ -23,7 +23,9 @@ function generatePlansCommand(config) {
23
23
  lines.push('- `dotmd unblocks <file>` — what depends on / is blocked by a plan');
24
24
  lines.push('- `dotmd next` — ready plans with next steps (what to promote)');
25
25
  lines.push('- `dotmd new plan <name>` — scaffold with full phase structure');
26
- lines.push('- `dotmd new prompt <name> "<body>"` — save a resume-prompt to docs/prompts/');
26
+ lines.push('- `dotmd prompts new <name> "<body>"` — save a resume-prompt to docs/prompts/');
27
+ lines.push('- `dotmd prompts next` — consume oldest pending prompt (prints body, auto-archives)');
28
+ lines.push('- `dotmd prompts use <file>` — consume a specific prompt (prints body, auto-archives)');
27
29
  lines.push('- `dotmd archive <file>` — archive with auto ref-fixing (both directions)');
28
30
  lines.push('- `dotmd bulk archive <files>` — archive multiple at once');
29
31
  lines.push('- `dotmd status <file> <status>` — transition status');
@@ -39,6 +41,8 @@ function generatePlansCommand(config) {
39
41
  lines.push('If the user asks to change a plan\'s status, use `dotmd status <file> <status>`.');
40
42
  lines.push('If the user asks to archive a plan, use `dotmd archive <file>`.');
41
43
  lines.push('');
44
+ lines.push('**Saved prompts (`docs/prompts/*.md`):** if the user references a file under `docs/prompts/` — e.g. "resume via docs/prompts/foo.md", "use this prompt", "load that one" — consume it with `dotmd prompts use <file>` (atomically prints the body and archives the prompt so it cannot be double-consumed). Do NOT `cat` it, read it with the file-reading tool, or copy its body into chat. To pick the oldest pending prompt without naming a file, use `dotmd prompts next`.');
45
+ lines.push('');
42
46
 
43
47
  return lines.join('\n');
44
48
  }
@@ -118,7 +122,9 @@ function generateDocsCommand(config) {
118
122
  lines.push('Lifecycle:');
119
123
  lines.push('- `dotmd new plan <name>` — scaffold new plan');
120
124
  lines.push('- `dotmd new doc <name>` — scaffold reference doc');
121
- lines.push('- `dotmd new prompt <name> "<body>"` — save a resume-prompt');
125
+ lines.push('- `dotmd prompts new <name> "<body>"` — save a resume-prompt');
126
+ lines.push('- `dotmd prompts next` — consume oldest pending prompt (prints body, auto-archives)');
127
+ lines.push('- `dotmd prompts use <file>` — consume a specific prompt (prints body, auto-archives)');
122
128
  lines.push('- `dotmd status <file> <status>` — transition status');
123
129
  lines.push('- `dotmd archive <file>` — archive with auto ref-fixing');
124
130
  lines.push('- `dotmd bulk archive <files>` — archive multiple at once');
@@ -127,6 +133,8 @@ function generateDocsCommand(config) {
127
133
  lines.push('- `dotmd fix-refs` — repair broken references and body links');
128
134
  lines.push('- `dotmd rename <old> <new>` — rename doc + update all references');
129
135
  lines.push('');
136
+ lines.push('**Saved prompts (`docs/prompts/*.md`):** if the user references a file under `docs/prompts/` — e.g. "resume via docs/prompts/foo.md", "use this prompt" — consume it with `dotmd prompts use <file>` (prints the body and archives atomically). Do NOT `cat` it or read it with the file-reading tool. To pick the oldest pending prompt without naming a file, use `dotmd prompts next`.');
137
+ lines.push('');
130
138
 
131
139
  return lines.join('\n');
132
140
  }
package/src/hud.mjs CHANGED
@@ -1,6 +1,9 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
1
2
  import path from 'node:path';
2
3
  import { readLeases, findStaleLeases, currentSessionId } from './lease.mjs';
3
4
  import { listQueuedHandoffs } from './handoff.mjs';
5
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
6
+ import { asString, toRepoPath } from './util.mjs';
4
7
  import { green, yellow, dim } from './color.mjs';
5
8
 
6
9
  const MAX_PREVIEW = 5;
@@ -13,14 +16,50 @@ function previewList(items, max = MAX_PREVIEW) {
13
16
  return slugs.join(', ') + more;
14
17
  }
15
18
 
19
+ function findPendingPrompts(config) {
20
+ const roots = config.docsRoots || (config.docsRoot ? [config.docsRoot] : []);
21
+ const archiveDir = config.archiveDir || 'archived';
22
+ const found = [];
23
+ const seen = new Set();
24
+
25
+ for (const root of roots) {
26
+ // A root may either contain a prompts/ subdir (the common case, e.g. root=docs)
27
+ // or be the prompts/ dir itself (e.g. root=docs/prompts — see #6).
28
+ const dir = path.basename(root) === 'prompts' ? root : path.join(root, 'prompts');
29
+ if (seen.has(dir)) continue;
30
+ seen.add(dir);
31
+ if (!existsSync(dir)) continue;
32
+ let entries;
33
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { continue; }
34
+ for (const entry of entries) {
35
+ if (entry.isDirectory()) continue;
36
+ if (!entry.name.endsWith('.md')) continue;
37
+ const filePath = path.join(dir, entry.name);
38
+ // Skip any nested archived/ collisions just in case
39
+ if (filePath.includes(`/${archiveDir}/`)) continue;
40
+ let raw;
41
+ try { raw = readFileSync(filePath, 'utf8'); } catch { continue; }
42
+ const { frontmatter } = extractFrontmatter(raw);
43
+ if (!frontmatter) continue;
44
+ const fm = parseSimpleFrontmatter(frontmatter);
45
+ if (asString(fm.type) !== 'prompt') continue;
46
+ if (asString(fm.status) !== 'pending') continue;
47
+ found.push(toRepoPath(filePath, config.repoRoot));
48
+ }
49
+ }
50
+
51
+ return found.sort();
52
+ }
53
+
16
54
  export function buildHud(config) {
17
55
  const session = currentSessionId();
18
56
  const leases = readLeases(config);
19
57
  const owned = Object.values(leases).filter(l => l.session === session).map(l => l.path);
20
58
  const queued = listQueuedHandoffs(config).map(h => h.repoPath);
21
59
  const stale = findStaleLeases(config).map(l => l.path);
60
+ const prompts = findPendingPrompts(config);
22
61
 
23
- return { owned, queued, stale };
62
+ return { owned, queued, stale, prompts };
24
63
  }
25
64
 
26
65
  export function runHud(argv, config) {
@@ -39,6 +78,9 @@ export function runHud(argv, config) {
39
78
  if (hud.queued.length > 0) {
40
79
  lines.push(green(`▶ ${hud.queued.length} handoff${hud.queued.length === 1 ? '' : 's'} queued: ${previewList(hud.queued)} ${dim('(resume: dotmd pickup)')}`));
41
80
  }
81
+ if (hud.prompts.length > 0) {
82
+ lines.push(green(`▶ ${hud.prompts.length} pending prompt${hud.prompts.length === 1 ? '' : 's'}: ${previewList(hud.prompts)} ${dim('(consume: `dotmd prompts use <file>` — do not cat/read)')}`));
83
+ }
42
84
  if (hud.stale.length > 0) {
43
85
  lines.push(yellow(`⚠ ${hud.stale.length} stuck lease${hud.stale.length === 1 ? '' : 's'} >24h ${dim('(run: dotmd release --stale)')}`));
44
86
  }
package/src/lifecycle.mjs CHANGED
@@ -460,7 +460,7 @@ export async function runFinish(argv, config, opts = {}) {
460
460
  }
461
461
 
462
462
  export function runArchive(argv, config, opts = {}) {
463
- const { dryRun } = opts;
463
+ const { dryRun, out = process.stdout } = opts;
464
464
  const input = argv[0];
465
465
 
466
466
  if (!input) { die('Usage: dotmd archive <file>'); }
@@ -485,15 +485,15 @@ export function runArchive(argv, config, opts = {}) {
485
485
 
486
486
  if (dryRun) {
487
487
  const prefix = dim('[dry-run]');
488
- process.stdout.write(`${prefix} Would update frontmatter: status: ${oldStatus} → archived, updated: ${today}\n`);
488
+ out.write(`${prefix} Would update frontmatter: status: ${oldStatus} → archived, updated: ${today}\n`);
489
489
  if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); }
490
- process.stdout.write(`${prefix} Would move: ${oldRepoPath} → ${newRepoPath}\n`);
491
- if (config.indexPath) process.stdout.write(`${prefix} Would regenerate index\n`);
490
+ out.write(`${prefix} Would move: ${oldRepoPath} → ${newRepoPath}\n`);
491
+ if (config.indexPath) out.write(`${prefix} Would regenerate index\n`);
492
492
 
493
493
  // Preview reference updates
494
494
  const refCount = countRefsToUpdate(filePath, targetPath, config);
495
495
  if (refCount > 0) {
496
- process.stdout.write(`${prefix} Would update references in ${refCount} file(s)\n`);
496
+ out.write(`${prefix} Would update references in ${refCount} file(s)\n`);
497
497
  }
498
498
  return;
499
499
  }
@@ -518,10 +518,10 @@ export function runArchive(argv, config, opts = {}) {
518
518
  writeIndex(renderIndexFile(index, config), config);
519
519
  }
520
520
 
521
- process.stdout.write(`${green('Archived')}: ${oldRepoPath} → ${newRepoPath}\n`);
522
- if (selfRefsFixed) process.stdout.write('Updated references in archived file.\n');
523
- if (updatedRefCount > 0) process.stdout.write(`Updated references in ${updatedRefCount} file(s).\n`);
524
- if (config.indexPath) process.stdout.write('Index regenerated.\n');
521
+ out.write(`${green('Archived')}: ${oldRepoPath} → ${newRepoPath}\n`);
522
+ if (selfRefsFixed) out.write('Updated references in archived file.\n');
523
+ if (updatedRefCount > 0) out.write(`Updated references in ${updatedRefCount} file(s).\n`);
524
+ if (config.indexPath) out.write('Index regenerated.\n');
525
525
 
526
526
  try { releaseLease(config, oldRepoPath, { force: true }); } catch (err) { warn(`Could not release lease for ${oldRepoPath}: ${err.message}`); }
527
527
 
@@ -0,0 +1,135 @@
1
+ import { readFileSync, statSync } from 'node:fs';
2
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
3
+ import { asString, toRepoPath, die, resolveDocPath } from './util.mjs';
4
+ import { buildIndex } from './index.mjs';
5
+ import { runQuery } from './query.mjs';
6
+ import { runArchive } from './lifecycle.mjs';
7
+ import { runNew } from './new.mjs';
8
+ import { green, dim } from './color.mjs';
9
+
10
+ const SUBCOMMANDS = new Set(['list', 'next', 'use', 'archive', 'new']);
11
+
12
+ export async function runPrompts(argv, config, opts = {}) {
13
+ const sub = argv[0];
14
+
15
+ if (!sub || !SUBCOMMANDS.has(sub)) {
16
+ return runPromptsList(argv, config, opts);
17
+ }
18
+
19
+ const rest = argv.slice(1);
20
+ switch (sub) {
21
+ case 'list': return runPromptsList(rest, config, opts);
22
+ case 'next': return runPromptsNext(rest, config, opts);
23
+ case 'use': return runPromptsUse(rest, config, opts);
24
+ case 'archive': return runPromptsArchive(rest, config, opts);
25
+ case 'new': return runPromptsNew(rest, config, opts);
26
+ }
27
+ }
28
+
29
+ function runPromptsList(argv, config) {
30
+ const index = buildIndex(config);
31
+ const hasStatusFlag = argv.includes('--status');
32
+ const includeArchived = argv.includes('--include-archived');
33
+ const sub = argv[0];
34
+
35
+ let defaults;
36
+ let extras = argv;
37
+ if (sub === 'status') {
38
+ defaults = ['--type', 'prompt', '--exclude-archived', '--sort', 'status', '--all'];
39
+ extras = argv.slice(1);
40
+ } else if (hasStatusFlag || includeArchived) {
41
+ defaults = ['--type', 'prompt', '--sort', 'updated', '--limit', '10'];
42
+ } else {
43
+ defaults = ['--type', 'prompt', '--exclude-archived', '--sort', 'updated', '--limit', '10'];
44
+ }
45
+ runQuery(index, [...defaults, ...extras], config, { preset: 'prompts' });
46
+ }
47
+
48
+ function pendingPromptsOldestFirst(config) {
49
+ const index = buildIndex(config);
50
+ const prompts = index.docs.filter(d => d.type === 'prompt' && d.status === 'pending');
51
+
52
+ return prompts
53
+ .map(d => {
54
+ const abs = resolveDocPath(d.path, config);
55
+ let mtime = 0;
56
+ try { mtime = abs ? statSync(abs).mtimeMs : 0; } catch { mtime = 0; }
57
+ return { doc: d, abs, created: d.created ?? '', mtime };
58
+ })
59
+ .sort((a, b) => {
60
+ if (a.created && b.created && a.created !== b.created) return a.created.localeCompare(b.created);
61
+ if (a.created && !b.created) return -1;
62
+ if (!a.created && b.created) return 1;
63
+ return a.mtime - b.mtime;
64
+ });
65
+ }
66
+
67
+ function runPromptsNext(argv, config, opts = {}) {
68
+ const queue = pendingPromptsOldestFirst(config);
69
+ if (queue.length === 0) {
70
+ die('No pending prompts.');
71
+ }
72
+ const head = queue[0];
73
+ if (!head.abs) die(`Could not resolve path: ${head.doc.path}`);
74
+ consumePrompt(head.abs, config, opts);
75
+ }
76
+
77
+ function runPromptsUse(argv, config, opts = {}) {
78
+ const input = argv.find(a => !a.startsWith('-'));
79
+ if (!input) die('Usage: dotmd prompts use <file>');
80
+ const filePath = resolveDocPath(input, config);
81
+ if (!filePath) die(`File not found: ${input}`);
82
+ consumePrompt(filePath, config, opts);
83
+ }
84
+
85
+ function consumePrompt(filePath, config, opts) {
86
+ const { dryRun } = opts;
87
+ const raw = readFileSync(filePath, 'utf8');
88
+ const { frontmatter, body } = extractFrontmatter(raw);
89
+ const parsed = parseSimpleFrontmatter(frontmatter);
90
+ const docType = asString(parsed.type);
91
+ const status = asString(parsed.status);
92
+ const repoPath = toRepoPath(filePath, config.repoRoot);
93
+
94
+ if (docType !== 'prompt') {
95
+ die(`Not a prompt (type: ${docType ?? 'unknown'}): ${repoPath}`);
96
+ }
97
+ if (status === 'archived') {
98
+ die(`Already consumed: ${repoPath}`);
99
+ }
100
+
101
+ if (dryRun) {
102
+ process.stderr.write(`${dim('[dry-run]')} Would emit body and archive: ${repoPath} (${status ?? 'unknown'} → archived)\n`);
103
+ runArchive([filePath], config, { dryRun: true, out: process.stderr });
104
+ return;
105
+ }
106
+
107
+ process.stdout.write(body);
108
+ if (!body.endsWith('\n')) process.stdout.write('\n');
109
+
110
+ runArchive([filePath], config, { out: process.stderr });
111
+ process.stderr.write(`${green('✓ Consumed')}: ${repoPath}\n`);
112
+ }
113
+
114
+ function runPromptsArchive(argv, config, opts = {}) {
115
+ const input = argv.find(a => !a.startsWith('-'));
116
+ if (!input) die('Usage: dotmd prompts archive <file>');
117
+ const filePath = resolveDocPath(input, config);
118
+ if (!filePath) die(`File not found: ${input}`);
119
+
120
+ const raw = readFileSync(filePath, 'utf8');
121
+ const { frontmatter } = extractFrontmatter(raw);
122
+ const parsed = parseSimpleFrontmatter(frontmatter);
123
+ if (asString(parsed.type) !== 'prompt') {
124
+ die(`Not a prompt: ${toRepoPath(filePath, config.repoRoot)}`);
125
+ }
126
+
127
+ runArchive([filePath], config, opts);
128
+ }
129
+
130
+ async function runPromptsNew(argv, config, opts = {}) {
131
+ if (!argv[0] || argv[0].startsWith('-')) {
132
+ die('Usage: dotmd prompts new <slug> [body]\n body: inline text | "-" (stdin) | "@path" (file) | --message "..."');
133
+ }
134
+ return runNew(['prompt', ...argv], config, opts);
135
+ }