dotmd-cli 0.39.2 → 0.39.4

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
@@ -68,6 +68,7 @@ Create & Export:
68
68
  Setup:
69
69
  init Create starter config + docs directory
70
70
  statuses [list|add|set|remove|migrate] Manage per-project status taxonomy
71
+ help statuses Full status vocabulary + unstuck-actions + transitions
71
72
  watch [command] Re-run a command on file changes
72
73
  completions <shell> Shell completion script (bash, zsh)
73
74
  journal [--tail N|--errors|--by-command|--session id|--since iso|--json]
@@ -92,6 +93,90 @@ Options:
92
93
 
93
94
  Outputs the complete document index as JSON to stdout.`,
94
95
 
96
+ // Help topic accessed via \`dotmd help statuses\` (not a command — see dispatch
97
+ // below). Single-source-of-truth for the built-in status vocabulary across all
98
+ // three doc types. User-defined types/statuses live in config; introspect them
99
+ // with \`dotmd statuses list\`.
100
+ 'help:statuses': `dotmd help statuses — status vocabulary, unstuck-actions, and transitions
101
+
102
+ Every document has a \`type:\` field; each type has its own valid statuses.
103
+ Status validation is type-aware (type > root > global). To inspect or edit
104
+ the status taxonomy in a specific project, use \`dotmd statuses list\`.
105
+
106
+ ────────────────────────────────────────────────────────────────────
107
+ plan statuses (each maps to a distinct unstuck-action)
108
+
109
+ in-session A Claude session is working on it now.
110
+ Don't pick up unless you own it (auto-reattaches) or pass
111
+ --takeover. Stale lease cleanup: \`dotmd release --stale\`.
112
+
113
+ active Ready to be picked up.
114
+ \`dotmd pickup <file>\` → in-session.
115
+
116
+ planned Queued for future work, not yet ready to execute.
117
+ Transition to active when ready to start.
118
+
119
+ blocked External arrival wait — monitor.
120
+ Hardware, vendor delivery, third-party rollout. Quiet
121
+ (skipStale) — you can't speed it up by nagging.
122
+
123
+ partial Shipped + deferred tail — spawn successor plans.
124
+ Plan body should reference the successor plan(s). Quiet.
125
+
126
+ paused Started but stopped mid-work — re-evaluate to resume.
127
+ Short stale window (3 days) so resume-decisions don't decay.
128
+
129
+ awaiting Needs human input/decision — chase the answer.
130
+ NOT quiet — generates stale pressure so pings aren't forgotten.
131
+
132
+ queued-after Sequenced behind another plan — check predecessor.
133
+ Quiet. Can start once the predecessor ships.
134
+
135
+ archived No longer relevant; auto-moved to archive directory.
136
+
137
+ Canonical transitions:
138
+ active → in-session \`dotmd pickup <file>\`
139
+ in-session → active \`dotmd release <file>\`
140
+ in-session → partial \`dotmd status <file> partial\` (+ release)
141
+ in-session → awaiting \`dotmd status <file> awaiting\` (+ release)
142
+ any → archived \`dotmd archive <file>\`
143
+
144
+ ────────────────────────────────────────────────────────────────────
145
+ doc statuses
146
+
147
+ draft Work-in-progress reference doc.
148
+ active Living document, kept up-to-date.
149
+ review Awaiting peer review.
150
+ reference Stable canonical reference (excluded from stale checks).
151
+ deprecated Superseded but kept for history.
152
+ archived No longer relevant; moved to archive directory.
153
+
154
+ ────────────────────────────────────────────────────────────────────
155
+ prompt statuses
156
+
157
+ pending Ready for the next session to consume.
158
+ \`dotmd prompts use <file>\` prints body + archives atomically.
159
+ \`dotmd prompts next\` does the same for the oldest pending.
160
+
161
+ shelved Saved but hidden from \`hud\` / \`briefing\` / \`prompts next\`.
162
+ Still listed by \`dotmd prompts list\`.
163
+ \`dotmd prompts unshelve <file>\` → pending.
164
+
165
+ claimed Legacy intermediate state (atomic use → archived now).
166
+
167
+ archived Consumed prompt; body preserved in archive directory.
168
+
169
+ ────────────────────────────────────────────────────────────────────
170
+ Related commands:
171
+ dotmd statuses Inspect/manage per-project status taxonomy
172
+ dotmd status <f> <new> Transition a document's status
173
+ dotmd briefing See plans grouped by status
174
+ dotmd plans --status <s> Filter live plans by status
175
+ dotmd hud Two-line actionable triage (held / prompts / stuck)
176
+
177
+ Run \`dotmd statuses list --type plan\` to see the full set (including any
178
+ project-specific custom statuses) with their flags.`,
179
+
95
180
  completions: `dotmd completions <bash|zsh> — output shell completion script
96
181
 
97
182
  Add to your shell config:
@@ -170,6 +255,8 @@ If a plan is already in-session:
170
255
 
171
256
  Options:
172
257
  --takeover Force-claim a plan held by another session
258
+ --no-index Skip index regen (see \`dotmd archive --help\`)
259
+ --show-files Append \`files: …\` line to stderr (see \`dotmd archive --help\`)
173
260
  --json Output as JSON
174
261
  --dry-run, -n Preview without writing
175
262
 
@@ -191,6 +278,8 @@ Options:
191
278
  --all Release every lease in the file (administrative)
192
279
  --stale Release leases whose pid is dead or age >24h
193
280
  --force Override "not yours" refusal on a specific file
281
+ --no-index Skip index regen (see \`dotmd archive --help\`)
282
+ --show-files Append \`files: …\` line to stderr (see \`dotmd archive --help\`)
194
283
  --json Output as JSON ({ released, skipped })
195
284
  --dry-run, -n Preview without writing
196
285
 
@@ -221,6 +310,11 @@ Moves the document to the new status. If transitioning to an archive
221
310
  status, automatically moves the file to the archive directory and
222
311
  regenerates the index (if configured).
223
312
 
313
+ Options:
314
+ --no-index Skip index regen (useful in concurrent-session repos
315
+ doing path-limited commits — see \`dotmd archive --help\`).
316
+ --show-files Append \`files: …\` line to stderr (see \`dotmd archive --help\`).
317
+
224
318
  Default plan statuses (each maps to a distinct unstuck-action):
225
319
  in-session A Claude session is working on it now
226
320
  active Ready to be picked up
@@ -232,6 +326,9 @@ Default plan statuses (each maps to a distinct unstuck-action):
232
326
  queued-after Sequenced behind another plan — check predecessor
233
327
  archived No longer relevant; auto-moved to archive directory
234
328
 
329
+ Run \`dotmd help statuses\` for the full vocabulary across all doc types
330
+ (plan, doc, prompt) plus canonical transitions and related commands.
331
+
235
332
  Use --dry-run (-n) to preview changes without writing anything.`,
236
333
 
237
334
  check: `dotmd check — validate frontmatter and references
@@ -252,7 +349,17 @@ Options:
252
349
  Sets status to 'archived', moves to the archive directory, auto-updates
253
350
  references in other docs, and regenerates the index.
254
351
 
255
- Use --dry-run (-n) to preview changes without writing anything.`,
352
+ Options:
353
+ --no-index Skip index regen. Use when multiple sessions are
354
+ working concurrently and you want a path-limited
355
+ commit that doesn't pull other agents' uncommitted
356
+ index changes into your staging area. Run \`dotmd index\`
357
+ later (or wire it into a commit hook) to refresh.
358
+ --show-files Append a final \`files: a b c …\` line to stderr
359
+ listing every doc/index path the command touched
360
+ (deduped, sorted, repo-relative). Lets agents do
361
+ \`git add\` with the exact set instead of guessing.
362
+ --dry-run, -n Preview changes without writing anything.`,
256
363
 
257
364
  coverage: `dotmd coverage — metadata coverage report
258
365
 
@@ -488,6 +595,8 @@ Other options:
488
595
  --status <s> Set initial status (defaults to first valid status for the type)
489
596
  --title <t> Override the auto-derived title
490
597
  --root <name> Create in a specific docs root
598
+ --show-files Append \`files: …\` line to stderr listing what was touched
599
+ (the new doc + the index file). See \`dotmd archive --help\`.
491
600
  --list-types Show registered types (alias: --list-templates)
492
601
 
493
602
  For plans, the default status vocabulary is: in-session, active, planned,
@@ -609,8 +718,8 @@ sorted by status. Supports all query flags (--status, --module, --json,
609
718
  --sort, --group, etc.).
610
719
 
611
720
  Default plan statuses: in-session, active, planned, blocked, partial,
612
- paused, awaiting, queued-after, archived. Run \`dotmd status --help\` for
613
- the unstuck-action behind each one.
721
+ paused, awaiting, queued-after, archived. Run \`dotmd help statuses\` for
722
+ the unstuck-action behind each one and canonical transitions.
614
723
 
615
724
  Examples:
616
725
  dotmd plans # live plans (default)
@@ -650,6 +759,9 @@ Default prompt statuses: pending, shelved, claimed, archived.
650
759
 
651
760
  Examples:
652
761
  dotmd prompts # pending prompts (default)
762
+ dotmd prompts list --verbose # one row per prompt + target plan ref
763
+ # (from related_plans, parent_plan,
764
+ # or the first body .md link)
653
765
  dotmd prompts list --include-archived # all prompts including archived
654
766
  dotmd prompts list --status claimed # already-consumed prompts
655
767
  dotmd prompts --json # JSON output
@@ -680,6 +792,21 @@ Supports all query flags (--status, --json, --sort, etc.)`,
680
792
  Shows documents that reference or depend on the given file.
681
793
  Useful for impact analysis before archiving or changing a plan.
682
794
 
795
+ The dependency edge is read from each plan's \`blockers:\` frontmatter
796
+ (a YAML list of plan slugs or paths). \`blocked_by:\` is accepted as
797
+ an alias since 0.39.3 — both populate the same index field, so use
798
+ whichever name reads better.
799
+
800
+ Frontmatter shape:
801
+
802
+ ---
803
+ type: plan
804
+ status: blocked
805
+ blockers:
806
+ - foo-plan.md
807
+ - docs/plans/bar-plan.md
808
+ ---
809
+
683
810
  Options:
684
811
  --json Output as JSON`,
685
812
 
@@ -789,6 +916,14 @@ async function main() {
789
916
  }
790
917
 
791
918
  if (command === 'help' || command === '--help' || command === '-h') {
919
+ const topic = args[1];
920
+ if (topic) {
921
+ const key = `help:${topic}`;
922
+ if (HELP[key]) { process.stdout.write(`${HELP[key]}\n`); return; }
923
+ if (HELP[topic]) { process.stdout.write(`${HELP[topic]}\n`); return; }
924
+ process.stderr.write(`Unknown help topic: ${topic}\n\nAvailable topics: statuses\nPer-command help: dotmd <cmd> --help\n`);
925
+ process.exit(1);
926
+ }
792
927
  process.stdout.write(`${HELP._main}\n`);
793
928
  return;
794
929
  }
@@ -891,7 +1026,7 @@ async function main() {
891
1026
  }
892
1027
  if (command === 'prompts') {
893
1028
  const { runPrompts } = await import('../src/prompts.mjs');
894
- await runPrompts(restArgs, config, { dryRun });
1029
+ await runPrompts(restArgs, config, { dryRun, verbose });
895
1030
  return;
896
1031
  }
897
1032
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.39.2",
3
+ "version": "0.39.4",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, Notion sync, AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.mjs CHANGED
@@ -175,7 +175,13 @@ export function parseDocFile(filePath, config, opts = {}) {
175
175
  }
176
176
  }
177
177
  const nextStep = asString(parsedFrontmatter.next_step) ?? extractNextStep(body) ?? null;
178
- const blockers = normalizeBlockers(parsedFrontmatter.blockers);
178
+ // `blocked_by` is accepted as an alias for `blockers` since 0.39.3 — agents
179
+ // filing tickets naturally reach for the JIRA/Linear name. If both are set,
180
+ // they're merged (de-duped via normalizeBlockers → mergeUniqueStrings).
181
+ const blockers = mergeUniqueStrings(
182
+ normalizeBlockers(parsedFrontmatter.blockers),
183
+ normalizeBlockers(parsedFrontmatter.blocked_by),
184
+ );
179
185
  const surface = asString(parsedFrontmatter.surface) ?? null;
180
186
  const surfaces = normalizeStringList(parsedFrontmatter.surfaces);
181
187
  const moduleName = asString(parsedFrontmatter.module) ?? null;
package/src/lifecycle.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter, replaceFrontmatter } from './frontmatter.mjs';
4
- import { asString, toRepoPath, die, warn, resolveDocPath, resolveRefPath, escapeRegex, nowIso, suggestCandidates } from './util.mjs';
4
+ import { asString, toRepoPath, die, warn, resolveDocPath, resolveRefPath, escapeRegex, nowIso, suggestCandidates, emitFilesFooter } from './util.mjs';
5
5
  import { gitMv, getGitLastModified, getGitLastModifiedBatch } from './git.mjs';
6
6
  import { buildIndex, collectDocFiles } from './index.mjs';
7
7
  import { renderIndexFile, writeIndex } from './index-file.mjs';
@@ -68,6 +68,9 @@ function uniqueArchiveTarget(targetDir, basename) {
68
68
 
69
69
  export async function runStatus(argv, config, opts = {}) {
70
70
  const { dryRun } = opts;
71
+ const noIndex = argv.includes('--no-index') || opts.noIndex;
72
+ const showFiles = argv.includes('--show-files') || opts.showFiles;
73
+ argv = argv.filter(a => a !== '--no-index' && a !== '--show-files');
71
74
  const input = argv[0];
72
75
  let newStatus = argv[1];
73
76
 
@@ -167,11 +170,24 @@ export async function runStatus(argv, config, opts = {}) {
167
170
 
168
171
  // Regen the index on every status change — `active → planned` etc. drift
169
172
  // the per-status sections just as much as archive crossings. Archive paths
170
- // also benefit (replaces the previously-gated regen).
171
- regenIndex(config);
173
+ // also benefit (replaces the previously-gated regen). `--no-index` skips
174
+ // this so concurrent agents can do path-limited commits without pulling
175
+ // each other's uncommitted index changes into the staging area.
176
+ if (noIndex) {
177
+ process.stderr.write(dim('(index not regenerated — run `dotmd index` to refresh)\n'));
178
+ } else {
179
+ regenIndex(config);
180
+ }
172
181
 
173
182
  process.stdout.write(`${green(toRepoPath(finalPath, config.repoRoot))}: ${oldStatus ?? 'unknown'} → ${newStatus}\n`);
174
183
 
184
+ if (showFiles) {
185
+ const touched = [filePath];
186
+ if (finalPath !== filePath) touched.push(finalPath);
187
+ if (config.indexPath && !noIndex) touched.push(config.indexPath);
188
+ emitFilesFooter(touched, config);
189
+ }
190
+
175
191
  try { config.hooks.onStatusChange?.({ path: toRepoPath(finalPath, config.repoRoot), oldStatus, newStatus }, {
176
192
  oldPath: toRepoPath(filePath, config.repoRoot),
177
193
  newPath: toRepoPath(finalPath, config.repoRoot),
@@ -183,6 +199,8 @@ export async function runPickup(argv, config, opts = {}) {
183
199
  const json = argv.includes('--json');
184
200
  const takeover = argv.includes('--takeover');
185
201
  const fullBody = argv.includes('--full');
202
+ const noIndex = argv.includes('--no-index') || opts.noIndex;
203
+ const showFiles = argv.includes('--show-files') || opts.showFiles;
186
204
  let input = argv.find(a => !a.startsWith('-'));
187
205
 
188
206
  // Interactive: pick from active/planned plans
@@ -228,7 +246,15 @@ export async function runPickup(argv, config, opts = {}) {
228
246
  }
229
247
 
230
248
  const pickupable = new Set(['active', 'planned', 'in-session']);
231
- if (oldStatus && !pickupable.has(oldStatus)) die(`Cannot pick up a plan with status '${oldStatus}'. Must be active or planned.\n ${repoPath}`);
249
+ if (oldStatus && !pickupable.has(oldStatus)) {
250
+ die(
251
+ `Cannot pick up a plan with status '${oldStatus}'. Must be active or planned.\n` +
252
+ ` ${repoPath}\n` +
253
+ `\n` +
254
+ `Recover with:\n` +
255
+ ` dotmd status ${repoPath} active && dotmd pickup ${repoPath}`,
256
+ );
257
+ }
232
258
 
233
259
  const today = nowIso();
234
260
  const leaseOldStatus = oldStatus === 'in-session' ? 'active' : (oldStatus ?? 'active');
@@ -253,7 +279,11 @@ export async function runPickup(argv, config, opts = {}) {
253
279
  }
254
280
  if (oldStatus !== 'in-session') {
255
281
  updateFrontmatter(filePath, { status: 'in-session', updated: today });
256
- regenIndex(config);
282
+ if (noIndex) {
283
+ process.stderr.write(dim('(index not regenerated — run `dotmd index` to refresh)\n'));
284
+ } else {
285
+ regenIndex(config);
286
+ }
257
287
  }
258
288
  // VH append per lease outcome:
259
289
  // acquired → `Picked up (<old> → in-session).`
@@ -295,6 +325,12 @@ export async function runPickup(argv, config, opts = {}) {
295
325
  }
296
326
  }
297
327
 
328
+ if (showFiles && oldStatus !== 'in-session') {
329
+ const touched = [filePath];
330
+ if (config.indexPath && !noIndex) touched.push(config.indexPath);
331
+ emitFilesFooter(touched, config);
332
+ }
333
+
298
334
  try { config.hooks.onPickup?.({ path: repoPath, oldStatus, newStatus: 'in-session' }); } catch (err) { warn(`Hook 'onPickup' threw: ${err.message}`); }
299
335
  }
300
336
 
@@ -304,10 +340,13 @@ export async function runUnpickup(argv, config, opts = {}) {
304
340
  const all = argv.includes('--all');
305
341
  const stale = argv.includes('--stale');
306
342
  const force = argv.includes('--force');
343
+ const noIndex = argv.includes('--no-index') || opts.noIndex;
344
+ const showFiles = argv.includes('--show-files') || opts.showFiles;
307
345
  const toIdx = argv.indexOf('--to');
308
346
  const toStatus = toIdx >= 0 ? argv[toIdx + 1] : null;
309
347
  const positional = argv.filter((a, i) => !a.startsWith('-') && argv[i - 1] !== '--to');
310
348
  const fileArg = positional[0];
349
+ const touched = [];
311
350
 
312
351
  const session = currentSessionId();
313
352
  const released = [];
@@ -360,7 +399,12 @@ export async function runUnpickup(argv, config, opts = {}) {
360
399
  const today = nowIso();
361
400
  updateFrontmatter(filePath, { status: newStatus, updated: today });
362
401
  appendVersionHistory(filePath, `Released (in-session → ${newStatus}).`);
363
- regenIndex(config);
402
+ touched.push(filePath);
403
+ if (noIndex) {
404
+ process.stderr.write(dim('(index not regenerated — run `dotmd index` to refresh)\n'));
405
+ } else {
406
+ regenIndex(config);
407
+ }
364
408
  }
365
409
  // If frontmatter is no longer in-session (manual flip), leave it alone.
366
410
  } catch (err) {
@@ -429,6 +473,12 @@ export async function runUnpickup(argv, config, opts = {}) {
429
473
  process.stderr.write(`${yellow('⚠ Skipped')}: ${s.path} (held by ${s.session}; use --force to override)\n`);
430
474
  }
431
475
  }
476
+
477
+ if (showFiles && touched.length > 0) {
478
+ const all = [...touched];
479
+ if (config.indexPath && !noIndex) all.push(config.indexPath);
480
+ emitFilesFooter(all, config);
481
+ }
432
482
  }
433
483
 
434
484
  export async function runFinish(argv, config, opts = {}) {
@@ -493,6 +543,9 @@ export async function runFinish(argv, config, opts = {}) {
493
543
 
494
544
  export function runArchive(argv, config, opts = {}) {
495
545
  const { dryRun, out = process.stdout } = opts;
546
+ const noIndex = argv.includes('--no-index') || opts.noIndex;
547
+ const showFiles = argv.includes('--show-files') || opts.showFiles;
548
+ argv = argv.filter(a => a !== '--no-index' && a !== '--show-files');
496
549
  const input = argv[0];
497
550
 
498
551
  if (!input) { die('Usage: dotmd archive <file>'); }
@@ -519,13 +572,24 @@ export function runArchive(argv, config, opts = {}) {
519
572
  const prefix = dim('[dry-run]');
520
573
  out.write(`${prefix} Would update frontmatter: status: ${oldStatus} → archived, updated: ${today}\n`);
521
574
  out.write(`${prefix} Would move: ${oldRepoPath} → ${newRepoPath}\n`);
522
- if (config.indexPath) out.write(`${prefix} Would regenerate index\n`);
575
+ if (config.indexPath && !noIndex) out.write(`${prefix} Would regenerate index\n`);
576
+ if (config.indexPath && noIndex) out.write(`${prefix} Would skip index regen (--no-index)\n`);
523
577
 
524
578
  // Preview reference updates
525
579
  const refCount = countRefsToUpdate(filePath, targetPath, config);
526
580
  if (refCount > 0) {
527
581
  out.write(`${prefix} Would update references in ${refCount} file(s)\n`);
528
582
  }
583
+
584
+ // Preview lease release (only if a lease exists for this plan)
585
+ if (readLeases(config)[oldRepoPath]) {
586
+ out.write(`${prefix} Would release in-session lease: ${oldRepoPath}\n`);
587
+ }
588
+
589
+ // Preview onArchive hook fire
590
+ if (config.hooks?.onArchive) {
591
+ out.write(`${prefix} Would fire hook: onArchive\n`);
592
+ }
529
593
  return;
530
594
  }
531
595
 
@@ -541,22 +605,31 @@ export function runArchive(argv, config, opts = {}) {
541
605
  const selfRefsFixed = updateRefsFromMovedFile(filePath, targetPath, config);
542
606
 
543
607
  // Auto-update references in other docs
544
- const updatedRefCount = updateRefsAfterMove(filePath, targetPath, config);
608
+ const { count: updatedRefCount, paths: refTouchedPaths } = updateRefsAfterMove(filePath, targetPath, config);
545
609
 
546
- regenIndex(config);
610
+ if (!noIndex) regenIndex(config);
547
611
 
548
612
  out.write(`${green('Archived')}: ${oldRepoPath} → ${newRepoPath}\n`);
549
613
  if (selfRefsFixed) out.write('Updated references in archived file.\n');
550
614
  if (updatedRefCount > 0) out.write(`Updated references in ${updatedRefCount} file(s).\n`);
551
- if (config.indexPath) out.write('Index regenerated.\n');
615
+ if (config.indexPath && !noIndex) out.write('Index regenerated.\n');
616
+ if (config.indexPath && noIndex) out.write(dim('(index not regenerated — run `dotmd index` to refresh)\n'));
552
617
 
553
618
  try { releaseLease(config, oldRepoPath, { force: true }); } catch (err) { warn(`Could not release lease for ${oldRepoPath}: ${err.message}`); }
554
619
 
620
+ const touched = [oldRepoPath, newRepoPath, ...refTouchedPaths];
621
+ if (config.indexPath && !noIndex) touched.push(config.indexPath);
622
+ if (showFiles) emitFilesFooter(touched, config);
623
+
555
624
  try { config.hooks.onArchive?.({ path: newRepoPath, oldStatus }, { oldPath: oldRepoPath, newPath: newRepoPath }); } catch (err) { warn(`Hook 'onArchive' threw: ${err.message}`); }
625
+
626
+ return { touched };
556
627
  }
557
628
 
558
629
  export function runBulkArchive(argv, config, opts = {}) {
559
630
  const { dryRun } = opts;
631
+ const noIndex = argv.includes('--no-index') || opts.noIndex;
632
+ const showFiles = argv.includes('--show-files') || opts.showFiles;
560
633
  const inputs = argv.filter(a => !a.startsWith('-'));
561
634
  if (inputs.length === 0) die('Usage: dotmd bulk archive <file1> <file2> ... or <glob>');
562
635
 
@@ -592,14 +665,30 @@ export function runBulkArchive(argv, config, opts = {}) {
592
665
  }
593
666
 
594
667
  process.stdout.write('\n');
668
+ // Bulk archives always defer index regen to the end — N individual regens
669
+ // is wasteful and the final state is the same. `--no-index` skips even
670
+ // the final one.
671
+ const bulkTouched = [];
595
672
  for (const f of unique) {
596
673
  const relPath = toRepoPath(f, config.repoRoot);
597
674
  try {
598
- runArchive([relPath], config, opts);
675
+ const result = runArchive([relPath], config, { ...opts, noIndex: true, showFiles: false });
676
+ if (result?.touched) bulkTouched.push(...result.touched);
599
677
  } catch (err) {
600
678
  warn(`Failed to archive ${relPath}: ${err.message}`);
601
679
  }
602
680
  }
681
+ if (!noIndex) {
682
+ regenIndex(config);
683
+ if (config.indexPath) process.stdout.write('Index regenerated.\n');
684
+ } else if (config.indexPath) {
685
+ process.stdout.write(dim('(index not regenerated — run `dotmd index` to refresh)\n'));
686
+ }
687
+ if (showFiles) {
688
+ const all = [...bulkTouched];
689
+ if (config.indexPath && !noIndex) all.push(config.indexPath);
690
+ emitFilesFooter(all, config);
691
+ }
603
692
  }
604
693
 
605
694
  export function runTouch(argv, config, opts = {}) {
@@ -682,7 +771,7 @@ export function runTouch(argv, config, opts = {}) {
682
771
  function updateRefsAfterMove(oldPath, newPath, config) {
683
772
  const basename = path.basename(oldPath);
684
773
  const allFiles = collectDocFiles(config);
685
- let updatedCount = 0;
774
+ const touched = [];
686
775
 
687
776
  for (const docFile of allFiles) {
688
777
  if (docFile === newPath) continue;
@@ -710,11 +799,11 @@ function updateRefsAfterMove(oldPath, newPath, config) {
710
799
  if (newFm !== fm) {
711
800
  raw = replaceFrontmatter(raw, newFm);
712
801
  writeFileSync(docFile, raw, 'utf8');
713
- updatedCount++;
802
+ touched.push(docFile);
714
803
  }
715
804
  }
716
805
 
717
- return updatedCount;
806
+ return { count: touched.length, paths: touched };
718
807
  }
719
808
 
720
809
  function updateRefsFromMovedFile(oldPath, newPath, config) {
package/src/new.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
- import { toRepoPath, die, warn, nowIso } from './util.mjs';
4
+ import { toRepoPath, die, warn, nowIso, emitFilesFooter } from './util.mjs';
5
5
  import { green, dim, bold } from './color.mjs';
6
6
  import { isInteractive, promptText } from './prompt.mjs';
7
7
  import { regenIndex } from './lifecycle.mjs';
@@ -185,12 +185,14 @@ export async function runNew(argv, config, opts = {}) {
185
185
  let title = null;
186
186
  let rootName = opts.root ?? null;
187
187
  let messageFlag = null;
188
+ let showFiles = opts.showFiles ?? false;
188
189
  for (let i = 0; i < argv.length; i++) {
189
190
  if (argv[i] === '--status' && argv[i + 1]) { status = argv[++i]; continue; }
190
191
  if (argv[i] === '--title' && argv[i + 1]) { title = argv[++i]; continue; }
191
192
  if (argv[i] === '--message' && argv[i + 1]) { messageFlag = argv[++i]; continue; }
192
193
  if (argv[i] === '--root' && argv[i + 1]) { rootName = argv[++i]; continue; }
193
194
  if (argv[i] === '--config') { i++; continue; }
195
+ if (argv[i] === '--show-files') { showFiles = true; continue; }
194
196
  if (argv[i] === '--list-templates' || argv[i] === '--list-types') {
195
197
  listTemplates(config);
196
198
  return;
@@ -395,6 +397,12 @@ export async function runNew(argv, config, opts = {}) {
395
397
 
396
398
  regenIndex(config);
397
399
 
400
+ if (showFiles) {
401
+ const touched = [filePath];
402
+ if (config.indexPath) touched.push(config.indexPath);
403
+ emitFilesFooter(touched, config);
404
+ }
405
+
398
406
  try { config.hooks.onNew?.({ path: repoPath, status, title: docTitle, type: typeName }); } catch (err) { warn(`Hook 'onNew' threw: ${err.message}`); }
399
407
  }
400
408
 
package/src/prompts.mjs CHANGED
@@ -29,12 +29,17 @@ export async function runPrompts(argv, config, opts = {}) {
29
29
  }
30
30
  }
31
31
 
32
- function runPromptsList(argv, config) {
32
+ function runPromptsList(argv, config, opts = {}) {
33
33
  const index = buildIndex(config);
34
34
  const hasStatusFlag = argv.includes('--status');
35
35
  const includeArchived = argv.includes('--include-archived');
36
36
  const sub = argv[0];
37
37
 
38
+ if (opts.verbose && !argv.includes('--json')) {
39
+ renderPromptsVerbose(index, config, { hasStatusFlag, includeArchived });
40
+ return;
41
+ }
42
+
38
43
  let defaults;
39
44
  let extras = argv;
40
45
  if (sub === 'status') {
@@ -48,6 +53,67 @@ function runPromptsList(argv, config) {
48
53
  runQuery(index, [...defaults, ...extras], config, { preset: 'prompts' });
49
54
  }
50
55
 
56
+ // Resolve a prompt's "target plan" for `prompts list --verbose`. Order:
57
+ // 1. frontmatter `related_plans:` (first entry — assumed plan slug)
58
+ // 2. frontmatter `parent_plan:`
59
+ // 3. first body markdown link to a .md file
60
+ // Returns a repo-relative display path or null.
61
+ function findPromptTarget(promptDoc, config) {
62
+ const refs = promptDoc.refFields ?? {};
63
+ const fmTargets = [...(refs.related_plans ?? []), ...(refs.parent_plan ?? [])];
64
+ for (const t of fmTargets) {
65
+ if (typeof t === 'string' && t.trim()) return slugToPlanPath(t.trim(), config);
66
+ }
67
+
68
+ const links = promptDoc.bodyLinks ?? [];
69
+ const mdLink = links.find(l => /\.md(?:#|$)/.test(l.href ?? ''));
70
+ if (mdLink) return resolveBodyLink(mdLink.href, promptDoc.path);
71
+ return null;
72
+ }
73
+
74
+ // Plan slugs in frontmatter (e.g. `related_plans: [foo-bar]`) resolve to
75
+ // <docs-root>/plans/<slug>.md.
76
+ function slugToPlanPath(s, config) {
77
+ const cleaned = s.replace(/#.*$/, '').replace(/^\.\//, '');
78
+ if (cleaned.includes('/') || cleaned.endsWith('.md')) return cleaned;
79
+ return `${config.docsRootPrefix || 'docs/'}plans/${cleaned}.md`;
80
+ }
81
+
82
+ // Resolve a markdown body link relative to the prompt's location so e.g.
83
+ // `../plans/foo.md` from docs/prompts/x.md → docs/plans/foo.md.
84
+ function resolveBodyLink(link, promptRepoPath) {
85
+ const cleaned = link.replace(/#.*$/, '');
86
+ if (cleaned.startsWith('/')) return cleaned.replace(/^\/+/, '');
87
+ const promptDir = path.dirname(promptRepoPath);
88
+ return path.normalize(path.join(promptDir, cleaned));
89
+ }
90
+
91
+ function renderPromptsVerbose(index, config, { hasStatusFlag, includeArchived }) {
92
+ let prompts = index.docs.filter(d => d.type === 'prompt');
93
+ if (!hasStatusFlag && !includeArchived) {
94
+ prompts = prompts.filter(d => d.status !== 'archived');
95
+ }
96
+ if (prompts.length === 0) {
97
+ process.stdout.write('No prompts.\n');
98
+ return;
99
+ }
100
+
101
+ prompts.sort((a, b) => (b.updated ?? '').localeCompare(a.updated ?? ''));
102
+
103
+ const counts = {};
104
+ for (const p of prompts) counts[p.status ?? 'unknown'] = (counts[p.status ?? 'unknown'] ?? 0) + 1;
105
+ const summary = Object.entries(counts).map(([s, n]) => `${n} ${s}`).join(' · ');
106
+ process.stdout.write(`${prompts.length} prompt${prompts.length === 1 ? '' : 's'} · ${summary}\n\n`);
107
+
108
+ for (const p of prompts) {
109
+ const slug = path.basename(p.path, '.md');
110
+ const target = findPromptTarget(p, config);
111
+ const status = (p.status ?? 'unknown').toUpperCase();
112
+ const arrow = target ? ` ${dim('→')} ${target}` : ` ${dim('→ (no target plan)')}`;
113
+ process.stdout.write(` ${green(slug)} [${status}]\n${arrow}\n`);
114
+ }
115
+ }
116
+
51
117
  function pendingPromptsOldestFirst(config) {
52
118
  const index = buildIndex(config);
53
119
  const prompts = index.docs.filter(d => d.type === 'prompt' && d.status === 'pending');
@@ -116,12 +182,14 @@ function resolvePromptInput(input, config) {
116
182
  function runPromptsUse(argv, config, opts = {}) {
117
183
  const input = argv.find(a => !a.startsWith('-'));
118
184
  if (!input) die('Usage: dotmd prompts use <file-or-slug>');
185
+ const noIndex = argv.includes('--no-index') || opts.noIndex;
186
+ const showFiles = argv.includes('--show-files') || opts.showFiles;
119
187
  const filePath = resolvePromptInput(input, config);
120
- consumePrompt(filePath, config, opts);
188
+ consumePrompt(filePath, config, { ...opts, noIndex, showFiles });
121
189
  }
122
190
 
123
191
  function consumePrompt(filePath, config, opts) {
124
- const { dryRun } = opts;
192
+ const { dryRun, noIndex, showFiles } = opts;
125
193
  const raw = readFileSync(filePath, 'utf8');
126
194
  const { frontmatter, body } = extractFrontmatter(raw);
127
195
  const parsed = parseSimpleFrontmatter(frontmatter);
@@ -137,21 +205,31 @@ function consumePrompt(filePath, config, opts) {
137
205
  }
138
206
 
139
207
  if (dryRun) {
140
- process.stderr.write(`${dim('[dry-run]')} Would emit body and archive: ${repoPath} (${status ?? 'unknown'} → archived)\n`);
141
- runArchive([filePath], config, { dryRun: true, out: process.stderr });
208
+ const prefix = dim('[dry-run]');
209
+ process.stderr.write(`${prefix} Would emit body and archive: ${repoPath} (${status ?? 'unknown'} → archived)\n`);
210
+ const bytes = Buffer.byteLength(body, 'utf8');
211
+ const lines = body.split('\n').length;
212
+ process.stderr.write(`${prefix} body preview (${bytes}B, ${lines} lines):\n`);
213
+ process.stderr.write(`${dim('---8<---')}\n`);
214
+ process.stderr.write(body);
215
+ if (!body.endsWith('\n')) process.stderr.write('\n');
216
+ process.stderr.write(`${dim('--->8---')}\n`);
217
+ runArchive([filePath], config, { dryRun: true, noIndex, out: process.stderr });
142
218
  return;
143
219
  }
144
220
 
145
221
  process.stdout.write(body);
146
222
  if (!body.endsWith('\n')) process.stdout.write('\n');
147
223
 
148
- runArchive([filePath], config, { out: process.stderr });
224
+ runArchive([filePath], config, { noIndex, showFiles, out: process.stderr });
149
225
  process.stderr.write(`${green('✓ Consumed')}: ${repoPath}\n`);
150
226
  }
151
227
 
152
228
  function runPromptsArchive(argv, config, opts = {}) {
153
229
  const input = argv.find(a => !a.startsWith('-'));
154
230
  if (!input) die('Usage: dotmd prompts archive <file-or-slug>');
231
+ const noIndex = argv.includes('--no-index') || opts.noIndex;
232
+ const showFiles = argv.includes('--show-files') || opts.showFiles;
155
233
  const filePath = resolvePromptInput(input, config);
156
234
 
157
235
  const raw = readFileSync(filePath, 'utf8');
@@ -161,7 +239,7 @@ function runPromptsArchive(argv, config, opts = {}) {
161
239
  die(`Not a prompt: ${toRepoPath(filePath, config.repoRoot)}`);
162
240
  }
163
241
 
164
- runArchive([filePath], config, opts);
242
+ runArchive([filePath], config, { ...opts, noIndex, showFiles });
165
243
  }
166
244
 
167
245
  async function runPromptsNew(argv, config, opts = {}) {
package/src/util.mjs CHANGED
@@ -55,6 +55,18 @@ export function toRepoPath(absolutePath, repoRoot) {
55
55
  return path.relative(repoRoot, absolutePath).split(path.sep).join('/');
56
56
  }
57
57
 
58
+ // Emit a `files: a b c` line to stderr listing every doc / index path
59
+ // the command touched (deduped, sorted, repo-relative). Lets agents do
60
+ // `git add` with the exact set instead of guessing. Opt-in via
61
+ // `--show-files` on lifecycle commands; default off to keep output stable.
62
+ export function emitFilesFooter(paths, config) {
63
+ const rel = [...new Set(paths.filter(Boolean))]
64
+ .map(p => path.isAbsolute(p) ? toRepoPath(p, config.repoRoot) : p)
65
+ .sort();
66
+ if (rel.length === 0) return;
67
+ process.stderr.write(`files: ${rel.join(' ')}\n`);
68
+ }
69
+
58
70
  export function nowIso() {
59
71
  return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
60
72
  }
package/src/validate.mjs CHANGED
@@ -95,6 +95,10 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
95
95
  doc.errors.push({ path: doc.path, level: 'error', message: '`blockers` must be a YAML list when present.' });
96
96
  }
97
97
 
98
+ if (Object.prototype.hasOwnProperty.call(frontmatter, 'blocked_by') && !Array.isArray(frontmatter.blocked_by)) {
99
+ doc.errors.push({ path: doc.path, level: 'error', message: '`blocked_by` must be a YAML list when present.' });
100
+ }
101
+
98
102
  if (Object.prototype.hasOwnProperty.call(frontmatter, 'surfaces') && !Array.isArray(frontmatter.surfaces)) {
99
103
  doc.errors.push({ path: doc.path, level: 'error', message: '`surfaces` must be a YAML list when present.' });
100
104
  }