dotmd-cli 0.70.4 → 0.71.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
@@ -109,7 +109,7 @@ Common commands:
109
109
  set <status> [file] Transition status (start work, finish, archive — all via target status)
110
110
  new <type> <name> Create plan/doc/prompt (pipe stdin or @path for body)
111
111
  use [<file-or-slug>] Open a doc by type: prompt → consume, plan → start, doc → read
112
- baton [<plan>|<slug>] <@draft|-> Save a resume prompt (+ release the plan, if one is in-session)
112
+ baton [<plan>|<slug>] <@<file>|-> Save a resume prompt (+ release the plan, if one is in-session)
113
113
  (no file: consume oldest pending prompt)
114
114
  archive <file> Close out a plan (status → archived, move, update refs)
115
115
 
@@ -180,7 +180,7 @@ View & Query:
180
180
  grep <term> Keyword search incl. document bodies (query --keyword --body --all)
181
181
  plans Live plans (excludes archived; --include-archived for all)
182
182
  use [<file-or-slug>] Open a doc by type: prompt → consume, plan → start, doc → read
183
- baton [<plan>|<slug>] <@draft|-> Save a resume prompt; releases the plan + prints the commit when one is in-session
183
+ baton [<plan>|<slug>] <@<file>|-> Save a resume prompt; releases the plan + prints the commit when one is in-session
184
184
  prompts [list|show|archive|new|hold] Prompt admin (list / peek / archive / save / hold). Use \`dotmd use\` to consume.
185
185
  stale Stale docs (preset)
186
186
  actionable Docs with next steps (preset)
@@ -201,6 +201,7 @@ Analyze:
201
201
 
202
202
  Validate & Fix:
203
203
  doctor [--apply] Auto-fix everything: refs, lint, long fields, dates, index (preview by default)
204
+ doctor --transactions Report/clear wedged mutation transactions (run this if mutations refuse repo-wide)
204
205
  self-check Project/version skew diagnostic (alias: doctor --project)
205
206
  lint [--fix] Check and auto-fix frontmatter issues
206
207
  fix-refs [--dry-run] Auto-fix broken reference paths + body links
@@ -711,6 +712,16 @@ Modes:
711
712
  (F4). Use --apply (alias --yes) to actually write;
712
713
  explicit --dry-run still wins over --apply if both
713
714
  are passed (safety prevails).
715
+ --transactions Report pending mutation transactions. A transaction
716
+ abandoned mid-flight can reach \`failed-manual\`, and
717
+ recovery sweeps the whole repo on every mutation —
718
+ so ONE wedged transaction makes \`set\`, \`archive\`,
719
+ \`use\`, \`baton\`, and \`rename\` refuse repo-wide,
720
+ naming a file the failing command never touched.
721
+ Run this first when that happens. Add --apply to
722
+ clear the transactions whose files already agree on
723
+ one generation (no document content is touched);
724
+ the rest are reported for manual review.
714
725
  --statuses Read-only diagnostic: detect overloaded status
715
726
  buckets where one status holds plans pursuing
716
727
  multiple distinct unstuck-actions. Suggests how
@@ -1038,6 +1049,9 @@ Examples:
1038
1049
  dotmd prompt list # singular alias for \`dotmd prompts list\`
1039
1050
 
1040
1051
  dotmd prompts show resume-foo # peek without consuming (triage)
1052
+ dotmd prompts show --all # peek the WHOLE pending queue in one call
1053
+ dotmd prompts show --all --limit 10 # ...capped
1054
+ dotmd prompts show a b c # peek several by name
1041
1055
  dotmd prompts next --dry-run # preview without consuming
1042
1056
  dotmd prompts archive old-thing
1043
1057
  dotmd prompts new my-prompt "Body text here"`,
@@ -1068,7 +1082,7 @@ Slug mode (no plan involved — "save a resume prompt for this"):
1068
1082
  plans/docs inside the draft body.
1069
1083
 
1070
1084
  Usage:
1071
- dotmd baton [<plan-file> | <slug>] [@draft.md | - | --message "..."]
1085
+ dotmd baton [<plan-file> | <slug>] [@<draft-file> | - | --message "..."]
1072
1086
 
1073
1087
  Options:
1074
1088
  --status <s> Target status for the plan (default: active; plan mode only)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.70.4",
3
+ "version": "0.71.1",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, lifecycle, and AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -194,7 +194,10 @@ function transactionRepairMessage(manifestPath, manifest, reason) {
194
194
  manifest.gitIndex?.prepared?.tempPath,
195
195
  ...(manifest.gitIndex?.retainedPaths ?? []),
196
196
  ].filter(Boolean);
197
- return `${reason}\nTransaction recovery refused to guess. Manifest: ${manifestPath}\nInspect the canonical files and recovery artifacts, then restore one complete generation and remove the manifest:\n${artifacts.map(item => ` ${item}`).join('\n') || ' (no content artifacts)'}`;
197
+ return `${reason}\nTransaction recovery refused to guess. Manifest: ${manifestPath}\n` +
198
+ `Start with \`dotmd doctor --transactions\` — it reports this transaction's state and resolves it when the canonical files agree on one generation.\n` +
199
+ `If it reports the generations as ambiguous, inspect the canonical files and recovery artifacts, then restore one complete generation and remove the manifest:\n` +
200
+ `${artifacts.map(item => ` ${item}`).join('\n') || ' (no content artifacts)'}`;
198
201
  }
199
202
 
200
203
  function assertString(value, label, { nullable = false } = {}) {
@@ -445,8 +448,19 @@ export function recoverAbandonedTransactions(repoRoot, options = {}) {
445
448
  throw new MutationConflictError(transactionRepairMessage(manifestPath, manifest, 'Canonical generations are ambiguous.'));
446
449
  }
447
450
  const rollForward = states.every(state => state === 'new');
451
+ // Lock acquisition is the one step here that fails for a reason that is not
452
+ // evidence of damage: another process holds these paths, almost always
453
+ // because it is recovering this same abandoned transaction. Nothing inside
454
+ // the callback acquires a lock, so a MutationLockError always means the
455
+ // callback never ran and no generation was touched — the manifest is exactly
456
+ // as it was. Marking it failed-manual on that (see the catch below, which
457
+ // is otherwise correct to retain evidence) turned transient contention into
458
+ // a permanent repo-wide brick: every later mutation hit the failed-manual
459
+ // check above and refused, for a transaction unrelated to it.
460
+ let locked = false;
448
461
  try {
449
462
  withPathLocks(manifest.participants.map(item => item.path), { repoRoot, ...options }, () => {
463
+ locked = true;
450
464
  const lockedStates = manifest.participants.map(participantState);
451
465
  if (lockedStates.includes('unknown')) {
452
466
  throw new MutationConflictError(transactionRepairMessage(manifestPath, manifest, 'Canonical generations changed while recovery acquired locks.'));
@@ -538,6 +552,13 @@ export function recoverAbandonedTransactions(repoRoot, options = {}) {
538
552
  retainedGitPaths,
539
553
  });
540
554
  } catch (err) {
555
+ if (!locked && err instanceof MutationLockError) {
556
+ // Whoever holds the lock finishes (or re-abandons) this transaction;
557
+ // either way it stays recoverable. The caller's own withPathLocks still
558
+ // guards the paths it actually mutates, so proceeding is safe.
559
+ recovered.push({ id: manifest.id, result: 'deferred-locked' });
560
+ continue;
561
+ }
541
562
  try {
542
563
  manifest.phase = 'failed-manual';
543
564
  manifest.result = 'failed-manual';
@@ -550,6 +571,87 @@ export function recoverAbandonedTransactions(repoRoot, options = {}) {
550
571
  return recovered;
551
572
  }
552
573
 
574
+ // Report on every transaction manifest without mutating anything, so a wedged
575
+ // repo can be diagnosed. `recoverAbandonedTransactions` deliberately refuses to
576
+ // guess and throws; that left no way to even SEE the state short of reading
577
+ // JSON by hand, and a single failed-manual manifest blocks every mutation in
578
+ // the repo. Never throws for a manifest it cannot parse — an unreadable one is
579
+ // itself the finding.
580
+ export function inspectTransactions(repoRoot, options = {}) {
581
+ const root = transactionRoot(repoRoot, options);
582
+ if (!existsSync(root)) return [];
583
+ const report = [];
584
+ for (const entry of readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
585
+ if (!entry.isDirectory() || entry.isSymbolicLink()) {
586
+ report.push({ id: entry.name, readable: false, reason: 'Unsafe entry in transaction root', resolvable: false });
587
+ continue;
588
+ }
589
+ const directory = path.join(root, entry.name);
590
+ const manifestPath = path.join(directory, 'manifest.json');
591
+ let manifest;
592
+ try { manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); }
593
+ catch (err) {
594
+ report.push({ id: entry.name, directory, manifestPath, readable: false, reason: err?.code === 'ENOENT' ? 'No manifest' : `Unreadable manifest: ${err.message}`, resolvable: false });
595
+ continue;
596
+ }
597
+ try { validateManifest(manifest, manifestPath, directory, repoRoot, options); }
598
+ catch (err) {
599
+ report.push({ id: entry.name, directory, manifestPath, readable: false, reason: err.message, resolvable: false });
600
+ continue;
601
+ }
602
+ const participants = manifest.participants.map(item => ({ path: item.path, state: participantState(item) }));
603
+ const states = new Set(participants.map(item => item.state));
604
+ const retainedGitPaths = (manifest.gitIndex?.retainedPaths ?? []).filter(item => existsSync(item));
605
+ const retainedDirectories = manifest.createdDirectories.filter(item => existsSync(item.path)).map(item => item.path);
606
+ // Safe to clear only when the filesystem already shows one uniform
607
+ // generation: that IS the atomicity the transaction existed to guarantee,
608
+ // so the manifest is bookkeeping for work that is fully done or fully
609
+ // undone. Anything mixed, unknown, or with retained artifacts stays put.
610
+ const uniform = states.size === 1 && (states.has('old') || states.has('new'));
611
+ report.push({
612
+ id: manifest.id,
613
+ directory,
614
+ manifestPath,
615
+ readable: true,
616
+ status: manifest.status,
617
+ phase: manifest.phase,
618
+ result: manifest.result,
619
+ operation: manifest.operation,
620
+ createdAt: manifest.createdAt,
621
+ ownerLiveness: processOwnerLiveness(manifest.owner),
622
+ participants,
623
+ generation: uniform ? [...states][0] : null,
624
+ retainedGitPaths,
625
+ retainedDirectories,
626
+ resolvable: uniform && retainedGitPaths.length === 0 && retainedDirectories.length === 0,
627
+ reason: uniform
628
+ ? (retainedGitPaths.length || retainedDirectories.length ? 'Retained artifacts need manual review' : null)
629
+ : 'Canonical files do not agree on one generation',
630
+ });
631
+ }
632
+ return report;
633
+ }
634
+
635
+ // Clear the manifests inspectTransactions marked resolvable. Touches no
636
+ // canonical file — those already agree on one generation; this only removes the
637
+ // bookkeeping that is wedging later mutations.
638
+ export function resolveTransactions(repoRoot, options = {}) {
639
+ const cleared = [];
640
+ for (const item of inspectTransactions(repoRoot, options)) {
641
+ if (!item.resolvable) continue;
642
+ let manifest;
643
+ try { manifest = JSON.parse(readFileSync(item.manifestPath, 'utf8')); } catch { continue; }
644
+ withPathLocks(manifest.participants.map(entry => entry.path), { repoRoot, ...options }, () => {
645
+ // Re-check under the lock: nothing may have moved since inspection.
646
+ const states = new Set(manifest.participants.map(participantState));
647
+ if (states.size !== 1 || !(states.has('old') || states.has('new'))) return;
648
+ cleanupTransactionDirectory(item.directory, manifest, options);
649
+ cleared.push({ id: item.id, generation: [...states][0] });
650
+ });
651
+ }
652
+ return cleared;
653
+ }
654
+
553
655
  function ensureDirectoryDurable(directory, options, phase) {
554
656
  if (existsSync(directory)) return false;
555
657
  const parent = path.dirname(directory);
package/src/commands.mjs CHANGED
@@ -94,7 +94,7 @@ const definitions = [
94
94
  form('list', { subcommands: ['list', 'status'], options: [flag('--json'), value('--status'), flag('--include-archived'), value('--sort'), value('--limit'), flag('--all')] }),
95
95
  form('next', { subcommands: ['next'] }),
96
96
  form('use [file]', { subcommands: ['use', 'resume'], args: positionals(0, 1), options: [flag('--no-index'), flag('--show-files'), flag('--force')] }),
97
- form('show [file]', { subcommands: ['show', 'peek'], args: positionals(0, 1), options: [flag('--json')] }),
97
+ form('show [file...]', { subcommands: ['show', 'peek'], args: positionals(0, Infinity), options: [flag('--json'), flag('--all'), value('--limit')] }),
98
98
  form('archive <file>', { subcommands: ['archive'], args: positionals(1, 1), options: [flag('--no-index'), flag('--show-files')] }),
99
99
  form('new <slug> [body...]', { subcommands: ['new'], args: positionals(1, Infinity), options: [value('--body', '--message'), value('--title'), value('--status')] }),
100
100
  form('hold <file>', { subcommands: ['hold', 'shelve'], args: positionals(1, 1) }),
@@ -102,7 +102,7 @@ const definitions = [
102
102
  ], { aliases: ['prompt'] }),
103
103
  command('use', mutates('managed source when starting/consuming; docs remain read-only'), 'workflow', [form('[file]', { args: positionals(0, 1), options: [flag('--json'), flag('--full'), flag('--no-index'), flag('--show-files'), flag('--force')] })]),
104
104
  command('next', mutates('managed prompt source and same-root archive destination'), 'workflow', [form('', { options: [flag('--json'), flag('--no-index'), flag('--show-files'), flag('--force')] })]),
105
- command('baton', mutates('managed plan/prompt sources and managed prompt destination'), 'workflow', [form('[plan|slug] <@draft|->', { args: positionals(0, 2), options: [value('--status'), value('--note'), value('--body', '--message'), flag('--force'), flag('--json')] })]),
105
+ command('baton', mutates('managed plan/prompt sources and managed prompt destination'), 'workflow', [form('[plan|slug] <@<file>|->', { args: positionals(0, 2), options: [value('--status'), value('--note'), value('--body', '--message'), flag('--force'), flag('--json')] })]),
106
106
  command('runlist', mutates('managed hubs/children and managed scaffold destinations'), 'workflow', [
107
107
  form('<hub>', { args: positionals(1, 1), options: [flag('--json')] }),
108
108
  form('next <hub>', { subcommands: ['next'], args: positionals(1, 1), options: [flag('--json'), flag('--full'), flag('--no-index'), flag('--show-files')] }),
@@ -133,7 +133,7 @@ const definitions = [
133
133
  command('rename', mutates('managed source, same-root destination, and rewrite sweep'), 'mutate', [form('<old> [new]', { args: positionals(1, 2), options: [flag('--show-files')] })]),
134
134
  command('migrate', mutates('managed source sweep'), 'mutate', [form('<field> <old> <new> [files...]', { args: positionals(3, Infinity), options: [flag('--show-files')] })]),
135
135
  command('fix-refs', mutates('managed source sweep'), 'mutate', [form('', { options: [flag('--show-files')] })]),
136
- command('doctor', mutates('managed sweeps, repo index, and maintenance config paths by mode'), 'mutate', [form('[path]', { args: positionals(0, 1), options: [flag('--apply', '--yes'), flag('--statuses'), optionalValue('--migrate-template'), flag('--migrate-prompts'), flag('--frontmatter-fix'), flag('--project'), flag('--json'), flag('--include-archived')] })]),
136
+ command('doctor', mutates('managed sweeps, repo index, and maintenance config paths by mode'), 'mutate', [form('[path]', { args: positionals(0, 1), options: [flag('--apply', '--yes'), flag('--statuses'), optionalValue('--migrate-template'), flag('--migrate-prompts'), flag('--frontmatter-fix'), flag('--project'), flag('--transactions'), flag('--json'), flag('--include-archived')] })]),
137
137
  command('statuses', mutates('project config path; document scan is read-only'), 'mutate', [
138
138
  form('list', { subcommands: ['list'], options: [value('--type'), flag('--json')] }),
139
139
  form('add <name>', { subcommands: ['add'], args: positionals(1, 1), options: STATUS_PROPERTY_OPTIONS }),
package/src/doctor.mjs CHANGED
@@ -14,6 +14,7 @@ import { runMigratePrompts } from './migrate-prompts.mjs';
14
14
  import { runFrontmatterFix } from './frontmatter-fix.mjs';
15
15
  import { normalizeEol } from './frontmatter.mjs';
16
16
  import { toRepoPath } from './util.mjs';
17
+ import { inspectTransactions, resolveTransactions } from './atomic-mutation.mjs';
17
18
 
18
19
  // Tunable thresholds for `dotmd doctor --statuses` conflation detection.
19
20
  // MIN_BUCKET_SIZE: only flag buckets with at least this many docs (small buckets aren't worth nagging).
@@ -44,6 +45,65 @@ const CUE_LABELS = {
44
45
  blocked: '"hardware", "vendor", "third-party", "rollout"',
45
46
  };
46
47
 
48
+ // A wedged repo reports here. One failed-manual manifest makes every `set`,
49
+ // `archive`, `use`, `baton`, and `rename` in the repo refuse — and the error
50
+ // names a file the command never touched, because recovery sweeps the whole
51
+ // transaction root. This is the only surface that can see and clear that state.
52
+ function runDoctorTransactions(argv, config, opts = {}) {
53
+ const json = argv.includes('--json');
54
+ // The dispatcher strips --apply/--yes and folds them into opts.dryRun, which
55
+ // for doctor already means "preview unless the user asked to write".
56
+ const apply = !opts.dryRun;
57
+ const report = inspectTransactions(config.repoRoot);
58
+
59
+ const cleared = apply ? resolveTransactions(config.repoRoot) : [];
60
+ const clearedIds = new Set(cleared.map(item => item.id));
61
+
62
+ if (json) {
63
+ process.stdout.write(JSON.stringify({ transactions: report, cleared }, null, 2) + '\n');
64
+ return;
65
+ }
66
+
67
+ if (report.length === 0) {
68
+ process.stdout.write(green('✓') + ' No pending transactions — nothing is wedging mutations.\n');
69
+ return;
70
+ }
71
+
72
+ process.stdout.write(bold(`Transactions (${report.length})\n`));
73
+ for (const item of report) {
74
+ if (!item.readable) {
75
+ process.stdout.write(` ${yellow('?')} ${item.id} — ${item.reason}\n`);
76
+ continue;
77
+ }
78
+ const blocking = item.status === 'failed-manual';
79
+ const mark = clearedIds.has(item.id) ? green('✓') : blocking ? yellow('!') : dim('·');
80
+ const note = clearedIds.has(item.id)
81
+ ? 'cleared'
82
+ : item.resolvable ? 'resolvable' : (item.reason ?? item.status);
83
+ process.stdout.write(` ${mark} ${item.id} [${item.status}] ${item.operation} — owner ${item.ownerLiveness}, ${note}\n`);
84
+ for (const participant of item.participants) {
85
+ process.stdout.write(dim(` ${participant.state.padEnd(9)} ${toRepoPath(participant.path, config.repoRoot)}\n`));
86
+ }
87
+ for (const retained of item.retainedGitPaths) {
88
+ process.stdout.write(dim(` retained ${retained}\n`));
89
+ }
90
+ }
91
+
92
+ const resolvable = report.filter(item => item.resolvable && !clearedIds.has(item.id));
93
+ if (cleared.length) {
94
+ process.stdout.write(green(`\n✓ Cleared ${cleared.length} transaction${cleared.length === 1 ? '' : 's'} whose files already agreed on one generation.\n`));
95
+ }
96
+ if (resolvable.length) {
97
+ process.stdout.write(`\n${resolvable.length} resolvable — the canonical files already agree on one generation.\n`);
98
+ process.stdout.write(dim('Run `dotmd doctor --transactions --apply` to clear them (no document content is touched).\n'));
99
+ }
100
+ const stuck = report.filter(item => !item.resolvable && !clearedIds.has(item.id) && (item.status === 'failed-manual' || !item.readable));
101
+ if (stuck.length) {
102
+ process.stdout.write(yellow(`\n${stuck.length} need manual review — generations disagree, so clearing them could lose work.\n`));
103
+ process.stdout.write(dim('Inspect the participant paths above against the artifacts in each transaction directory.\n'));
104
+ }
105
+ }
106
+
47
107
  export function runDoctor(argv, config, opts = {}) {
48
108
  if (argv.includes('--project')) {
49
109
  runDoctorProject(config, { json: argv.includes('--json') });
@@ -65,6 +125,10 @@ export function runDoctor(argv, config, opts = {}) {
65
125
  runFrontmatterFix(config, opts);
66
126
  return;
67
127
  }
128
+ if (argv.includes('--transactions')) {
129
+ runDoctorTransactions(argv, config, opts);
130
+ return;
131
+ }
68
132
 
69
133
  const { dryRun, testHooks } = opts;
70
134
  // 0.37.0 (F4): the mode banner makes it impossible to mistake a preview run
package/src/guard.mjs CHANGED
@@ -305,7 +305,8 @@ function evalRead(filePath, config) {
305
305
  detail: filePath,
306
306
  reason:
307
307
  `${filePath} is a saved dotmd prompt. To start work from it, run \`dotmd use ${filePath}\` — it commits archive/claim before at-most-once body output so it can't be double-consumed. ` +
308
- `Just peeking or triaging (not consuming)? \`dotmd prompts show ${filePath}\` reads it without archiving.`,
308
+ `Just peeking or triaging (not consuming)? \`dotmd prompts show ${filePath}\` reads it without archiving. ` +
309
+ `Surveying the whole queue? \`dotmd prompts show --all\` peeks every pending prompt in one call — don't Read them file by file.`,
309
310
  };
310
311
  }
311
312
 
package/src/hud.mjs CHANGED
@@ -326,7 +326,7 @@ export function runHud(argv, config) {
326
326
  // Global in-session counts never provide a fallback.
327
327
  // The misuse recap stays for the same reason: a repeat-offense rule means
328
328
  // the primer alone isn't landing, so name the habit to break.
329
- process.stdout.write(dim('dotmd: plans|briefing set <status> [<file>] new <type> <slug> use [<file>] archive <file> baton [<slug>] <@draft|-> (save a resume prompt; releases the in-session plan if any) (use [no-arg] → oldest pending prompt)') + '\n');
329
+ process.stdout.write(dim('dotmd: plans|briefing set <status> [<file>] new <type> <slug> use [<file>] archive <file> baton [<slug>] <@<file>|-> (save a resume prompt; releases the in-session plan if any) (use [no-arg] → oldest pending prompt)') + '\n');
330
330
  process.stdout.write(dim(buildPlanStatusPrimer(config)) + '\n');
331
331
  if (hud.owned && hud.owned.via === 'ownership') {
332
332
  process.stdout.write(yellow(`[dotmd] in-session (yours): ${hud.owned.path} — continue it; hand off with \`dotmd baton @/tmp/draft.md\` before stopping.`) + '\n');
package/src/lifecycle.mjs CHANGED
@@ -1036,7 +1036,10 @@ export async function runSet(argv, config, opts = {}) {
1036
1036
  warn('partial usually references the successor plan tracking the tail — add a link to the body, or rerun with --note "tail tracked in <plan>".');
1037
1037
  }
1038
1038
  if (batonNudge) {
1039
- warn(`wrapping up? leave a baton so the next session picks up cleanly — \`dotmd baton ${path.basename(filePath, '.md')} @draft\` saves a resume prompt (no copy-paste into chat).`);
1039
+ // `@<draft-file>` stays an obvious metavariable: the old `@draft` read as
1040
+ // a runnable path and got copied verbatim into failing batons. If it is
1041
+ // copied anyway, readBodyInput recognizes the <…> form and says so.
1042
+ warn(`wrapping up? leave a baton so the next session picks up cleanly — write your resume notes to a file, then \`dotmd baton ${path.basename(filePath, '.md')} @<draft-file>\` saves a resume prompt (no copy-paste into chat).`);
1040
1043
  }
1041
1044
  }
1042
1045
  return result;
package/src/new.mjs CHANGED
@@ -271,13 +271,37 @@ function mergeBodyFrontmatter(scaffoldFm, overrides, cliType) {
271
271
  return fm;
272
272
  }
273
273
 
274
+ // Metavariables that ship in help text, `dotmd baton`'s signature, and the
275
+ // wrap-up nudge. Agents copy them verbatim, and the bare `Body file not found:
276
+ // draft` that resulted read like a missing file rather than an unsubstituted
277
+ // placeholder — so the retry was usually another guess at the path. Only
278
+ // consulted after existsSync fails, so a real file by any of these names wins.
279
+ const BODY_PLACEHOLDER_NAMES = new Set([
280
+ 'draft', 'draft.md', '/tmp/draft.md', 'tmp/draft.md',
281
+ '/tmp/baton.md', '/tmp/my-draft.md',
282
+ 'path', 'file', 'body', 'FILE', 'PATH', 'BODY',
283
+ ]);
284
+
285
+ function isBodyPlaceholder(file) {
286
+ if (/^<.+>$/.test(file)) return true; // <draft-file>, <path>, …
287
+ return BODY_PLACEHOLDER_NAMES.has(file);
288
+ }
289
+
274
290
  export function readBodyInput(source) {
275
291
  if (source === '-') {
276
292
  try { return readFileSync(0, 'utf8'); } catch (err) { die(`Could not read body from stdin: ${err.message}`); }
277
293
  }
278
294
  if (typeof source === 'string' && source.startsWith('@')) {
279
295
  const file = source.slice(1);
280
- if (!existsSync(file)) die(`Body file not found: ${file}`);
296
+ if (!existsSync(file)) {
297
+ if (isBodyPlaceholder(file)) {
298
+ die(`\`@${file}\` is a placeholder, not a real path — write the body to a file first, then pass it:\n` +
299
+ ` @/tmp/my-draft.md read the body from that file\n` +
300
+ ` - read the body from stdin (\`cat notes.md | dotmd …\`)\n` +
301
+ ` --message "..." short one-liners only`);
302
+ }
303
+ die(`Body file not found: ${file}`);
304
+ }
281
305
  return readFileSync(file, 'utf8');
282
306
  }
283
307
  return source;
package/src/prompts.mjs CHANGED
@@ -397,23 +397,80 @@ function prepareLinkedPromptClaim(planRef, config) {
397
397
  // Read-only peek: print the body WITHOUT consuming. The sanctioned triage path
398
398
  // — surveying pending prompts must not archive them (that's `use`'s job), and
399
399
  // it must not require raw cat/Read (which the guard warns about).
400
- function runPromptsShow(argv, config) {
401
- const input = argv.find(a => !a.startsWith('-'));
402
- if (!input) die('Usage: dotmd prompts show <file-or-slug>');
403
- const filePath = resolvePromptInput(input, config);
404
-
400
+ // Read one prompt for peeking. Returns null rather than dying so a bulk sweep
401
+ // isn't aborted by a single bad doc.
402
+ function readPromptForShow(filePath, config, { strict }) {
405
403
  const raw = readFileSync(filePath, 'utf8');
406
404
  const { frontmatter, body } = extractFrontmatter(raw);
407
405
  const parsed = parseSimpleFrontmatter(frontmatter);
408
406
  const repoPath = toRepoPath(filePath, config.repoRoot);
409
407
  if (asString(parsed.type) !== 'prompt') {
410
- die(`Not a prompt (type: ${asString(parsed.type) ?? 'unknown'}): ${repoPath}`);
408
+ if (strict) die(`Not a prompt (type: ${asString(parsed.type) ?? 'unknown'}): ${repoPath}`);
409
+ return null;
410
+ }
411
+ return { repoPath, body, status: asString(parsed.status) ?? 'unknown' };
412
+ }
413
+
414
+ // Triage the queue without consuming it. Bulk mode exists because surveying a
415
+ // backlog was the one thing `show` could not do: sessions sweeping 25-28 saved
416
+ // prompts had to fall back to Read, which the guard then warned about once per
417
+ // file. `--all` and multiple paths make the supported route the cheap one.
418
+ function runPromptsShow(argv, config) {
419
+ const all = argv.includes('--all');
420
+ const limitIdx = argv.indexOf('--limit');
421
+ const limit = limitIdx >= 0 ? Number.parseInt(argv[limitIdx + 1], 10) : null;
422
+ if (limitIdx >= 0 && (!Number.isInteger(limit) || limit < 1)) {
423
+ die('--limit takes a positive integer.');
411
424
  }
425
+ // Skip the value that belongs to --limit; it is not a prompt name. Guard the
426
+ // absent case explicitly — limitIdx is -1 there, and -1 + 1 would drop argv[0].
427
+ const limitValueIdx = limitIdx >= 0 ? limitIdx + 1 : -1;
428
+ const inputs = argv.filter((a, i) => !a.startsWith('-') && i !== limitValueIdx);
412
429
 
413
- const status = asString(parsed.status) ?? 'unknown';
414
- process.stderr.write(dim(`${repoPath} [${status}] — read-only peek; \`dotmd use ${repoPath}\` to consume\n`));
415
- process.stdout.write(body);
416
- if (!body.endsWith('\n')) process.stdout.write('\n');
430
+ if (!inputs.length && !all) {
431
+ die('Usage: dotmd prompts show <file-or-slug>...\n dotmd prompts show --all [--limit N] # peek the whole pending queue');
432
+ }
433
+ if (inputs.length && all) die('Pass either prompt names or --all, not both.');
434
+
435
+ let targets;
436
+ if (all) {
437
+ const queue = pendingPromptsOldestFirst(config);
438
+ if (!queue.length) die('No pending prompts.');
439
+ targets = queue.filter(entry => entry.abs).map(entry => entry.abs);
440
+ } else {
441
+ targets = inputs.map(input => resolvePromptInput(input, config));
442
+ }
443
+ const total = targets.length;
444
+ if (limit !== null) targets = targets.slice(0, limit);
445
+
446
+ // Single-target output is unchanged: bare body on stdout, one dim header on
447
+ // stderr. Only a multi-target sweep adds separators, so scripts piping one
448
+ // prompt keep working.
449
+ const multi = targets.length > 1;
450
+ let shown = 0;
451
+ for (const filePath of targets) {
452
+ const prompt = readPromptForShow(filePath, config, { strict: !all && !multi });
453
+ if (!prompt) continue;
454
+ const { repoPath, body, status } = prompt;
455
+ if (multi) {
456
+ if (shown > 0) process.stdout.write('\n');
457
+ process.stdout.write(dim(`──── ${repoPath} [${status}] ────\n`));
458
+ } else {
459
+ process.stderr.write(dim(`${repoPath} [${status}] — read-only peek; \`dotmd use ${repoPath}\` to consume\n`));
460
+ }
461
+ process.stdout.write(body);
462
+ if (!body.endsWith('\n')) process.stdout.write('\n');
463
+ shown++;
464
+ }
465
+
466
+ // Footer whenever the sweep showed more than one OR hid some: a truncated
467
+ // --limit 1 collapses to single-prompt formatting, and silently dropping the
468
+ // rest would read as "that's the whole queue".
469
+ const truncated = total > targets.length;
470
+ if (multi || truncated) {
471
+ const suffix = truncated ? ` of ${total} (use --limit to change)` : '';
472
+ process.stderr.write(dim(`\n${shown} prompt${shown === 1 ? '' : 's'}${suffix} — read-only peek; \`dotmd use <file>\` to consume one\n`));
473
+ }
417
474
  }
418
475
 
419
476
  function runPromptsArchive(argv, config, opts = {}) {