dotmd-cli 0.70.3 → 0.71.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 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.3",
3
+ "version": "0.71.0",
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",
@@ -23,10 +23,15 @@ import os from 'node:os';
23
23
  import path from 'node:path';
24
24
  import { captureGitIndexGeneration, reclaimPreparedGitIndex, restoreGitIndexCas, sameGitIndexGeneration, stageMovePathsCas } from './git.mjs';
25
25
  import { authorizeManagedDestination, authorizeManagedSource, authorizeRepoGeneratedPath } from './managed-path.mjs';
26
+ import { commitRename } from './durable-rename.mjs';
26
27
 
27
28
  const sleepBuffer = new Int32Array(new SharedArrayBuffer(4));
28
29
  let tempSequence = 0;
29
30
 
31
+ // How long a peer waits to acquire a path lock before MutationLockError. Any
32
+ // retry budget spent while the lock is held has to fit well inside this.
33
+ export const MUTATION_LOCK_TIMEOUT_MS = 2000;
34
+
30
35
  export function processStartIdentity(pid) {
31
36
  try {
32
37
  const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
@@ -111,7 +116,7 @@ function transactionRoot(repoRoot, options = {}) {
111
116
  function durableJson(filePath, value, options = {}) {
112
117
  const content = JSON.stringify(value, null, 2) + '\n';
113
118
  const temp = writeCompleteTemp(filePath, content, 0o600);
114
- renameSync(temp.path, filePath);
119
+ commitRename(temp.path, filePath, options.testHooks);
115
120
  fsyncDirectory(path.dirname(filePath), options, 'transaction-manifest');
116
121
  }
117
122
 
@@ -189,7 +194,10 @@ function transactionRepairMessage(manifestPath, manifest, reason) {
189
194
  manifest.gitIndex?.prepared?.tempPath,
190
195
  ...(manifest.gitIndex?.retainedPaths ?? []),
191
196
  ].filter(Boolean);
192
- 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)'}`;
193
201
  }
194
202
 
195
203
  function assertString(value, label, { nullable = false } = {}) {
@@ -440,8 +448,19 @@ export function recoverAbandonedTransactions(repoRoot, options = {}) {
440
448
  throw new MutationConflictError(transactionRepairMessage(manifestPath, manifest, 'Canonical generations are ambiguous.'));
441
449
  }
442
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;
443
461
  try {
444
462
  withPathLocks(manifest.participants.map(item => item.path), { repoRoot, ...options }, () => {
463
+ locked = true;
445
464
  const lockedStates = manifest.participants.map(participantState);
446
465
  if (lockedStates.includes('unknown')) {
447
466
  throw new MutationConflictError(transactionRepairMessage(manifestPath, manifest, 'Canonical generations changed while recovery acquired locks.'));
@@ -533,6 +552,13 @@ export function recoverAbandonedTransactions(repoRoot, options = {}) {
533
552
  retainedGitPaths,
534
553
  });
535
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
+ }
536
562
  try {
537
563
  manifest.phase = 'failed-manual';
538
564
  manifest.result = 'failed-manual';
@@ -545,6 +571,87 @@ export function recoverAbandonedTransactions(repoRoot, options = {}) {
545
571
  return recovered;
546
572
  }
547
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
+
548
655
  function ensureDirectoryDurable(directory, options, phase) {
549
656
  if (existsSync(directory)) return false;
550
657
  const parent = path.dirname(directory);
@@ -841,7 +948,7 @@ function reclaimLock(lockPath, lockRoot) {
841
948
  }
842
949
 
843
950
  export function withPathLocks(filePaths, options, callback) {
844
- const { repoRoot, timeoutMs = 2000, retryMs = 20 } = options;
951
+ const { repoRoot, timeoutMs = MUTATION_LOCK_TIMEOUT_MS, retryMs = 20 } = options;
845
952
  if (!repoRoot) throw new Error('withPathLocks requires repoRoot.');
846
953
  const canonicals = [...new Set(filePaths.map(canonicalPath))].sort();
847
954
  const lockRoot = safeGeneratedPath(path.join(path.resolve(repoRoot), '.dotmd', 'locks'), repoRoot, options, 'Lock root');
@@ -1036,7 +1143,7 @@ export function replaceSnapshot(snapshot, newContent, options = {}) {
1036
1143
  try {
1037
1144
  options.testHooks?.beforeReplacePublish?.({ snapshot, tempPath: prepared.path });
1038
1145
  assertSnapshotCurrent(snapshot);
1039
- renameSync(prepared.path, snapshot.path);
1146
+ commitRename(prepared.path, snapshot.path, options.testHooks);
1040
1147
  committed = committedGeneration(prepared, snapshot.path);
1041
1148
  options.testHooks?.afterPublicationBeforePathOpen?.('replace', snapshot.path);
1042
1149
  try { fsyncDirectory(path.dirname(snapshot.path), options, 'replace-publish'); }
@@ -1097,7 +1204,7 @@ export function createFileExclusive(filePath, content, options) {
1097
1204
  options.testHooks?.afterReservationAcquired?.(filePath);
1098
1205
  options.testHooks?.afterCreateReservation?.(filePath);
1099
1206
  assertSnapshotCurrent(reservation);
1100
- renameSync(prepared.path, filePath);
1207
+ commitRename(prepared.path, filePath, options.testHooks);
1101
1208
  tempPublished = true;
1102
1209
  committed = committedGeneration(prepared, filePath);
1103
1210
  options.testHooks?.afterPublicationBeforePathOpen?.('create-rename', filePath);
@@ -1210,7 +1317,7 @@ export function moveFileAtomic(sourcePath, targetPath, render, options) {
1210
1317
  // step that can permit an injected or external source edit.
1211
1318
  assertSnapshotCurrent(source);
1212
1319
  for (const item of preparedUpdates) assertSnapshotCurrent(item.snapshot);
1213
- renameSync(sourcePath, backup);
1320
+ commitRename(sourcePath, backup, testHooks);
1214
1321
  moved = true;
1215
1322
  backupSnapshot = { ...source, path: backup, identityKind: 'inode-content' };
1216
1323
  fsyncDirectory(path.dirname(sourcePath));
@@ -1218,7 +1325,7 @@ export function moveFileAtomic(sourcePath, targetPath, render, options) {
1218
1325
 
1219
1326
  testHooks?.afterSourceMove?.({ backup, sourcePath, targetPath });
1220
1327
  assertSnapshotCurrent(reservation);
1221
- renameSync(stagedPath, targetPath);
1328
+ commitRename(stagedPath, targetPath, testHooks);
1222
1329
  published = true;
1223
1330
  reserved = false;
1224
1331
  publishedSnapshot = committedGeneration(prepared, targetPath);
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 }),
@@ -7,7 +7,7 @@
7
7
  // 2. fs.writeFileSync(<path>.tmp, new)
8
8
  // 3. import the tmp via file:// + cache-bust query — must parse
9
9
  // 4. resolveConfig(tmp) — must not surface new warnings
10
- // 5. fs.renameSync(tmp, real) only on full success
10
+ // 5. commitRename(tmp, real) only on full success
11
11
  // 6. on any failure: unlink tmp, surface error, real file untouched
12
12
  //
13
13
  // Pulling in @babel/parser would force a heavy dep on a project with two
@@ -15,9 +15,10 @@
15
15
  // `templates` section is 264 lines of arrow functions with multi-line
16
16
  // template literals). Line surgery is the conservative choice.
17
17
 
18
- import { existsSync, readFileSync, writeFileSync, unlinkSync, renameSync } from 'node:fs';
18
+ import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
19
19
  import { pathToFileURL } from 'node:url';
20
20
  import { resolveConfig } from './config.mjs';
21
+ import { commitRename } from './durable-rename.mjs';
21
22
 
22
23
  const STATUS_NAME_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
23
24
  const RESERVED_NAMES = new Set([
@@ -595,7 +596,9 @@ export async function writeConfigAtomic(configPath, newContent, cwd) {
595
596
  throw new ConfigEditError(`Generated config surfaces new warnings:\n - ${novel.join('\n - ')}`);
596
597
  }
597
598
 
598
- renameSync(tmpPath, configPath);
599
+ // The live config is very likely open in the user's editor; on Windows that
600
+ // makes the rename fail transiently, so publish through the retrying helper.
601
+ commitRename(tmpPath, configPath);
599
602
  } catch (err) {
600
603
  try { unlinkSync(tmpPath); } catch { /* ignore */ }
601
604
  throw err;
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
@@ -0,0 +1,55 @@
1
+ // Retrying rename for publishing a prepared temp over a live path.
2
+ //
3
+ // Lives in its own leaf module rather than in atomic-mutation.mjs because
4
+ // atomic-mutation.mjs already imports git.mjs, and git.mjs needs this helper for
5
+ // its .git/index publish — exporting it from atomic-mutation.mjs would close an
6
+ // import cycle.
7
+ import { renameSync } from 'node:fs';
8
+
9
+ const sleepBuffer = new Int32Array(new SharedArrayBuffer(4));
10
+
11
+ const RENAME_RETRY_CODES = ['EPERM', 'EBUSY', 'EACCES'];
12
+ const RENAME_RETRY_ATTEMPTS = 10;
13
+ const RENAME_RETRY_BACKOFF_MS = 10;
14
+
15
+ // Total the backoff can sleep across a fully exhausted retry — the sum of
16
+ // RENAME_RETRY_BACKOFF_MS × (1 … attempts-1). Exported so the budget can be
17
+ // checked against MUTATION_LOCK_TIMEOUT_MS deterministically, rather than by
18
+ // timing a run on a shared CI runner.
19
+ export const RENAME_RETRY_SLEEP_BUDGET_MS =
20
+ (RENAME_RETRY_BACKOFF_MS * (RENAME_RETRY_ATTEMPTS - 1) * RENAME_RETRY_ATTEMPTS) / 2;
21
+
22
+ // Windows refuses to rename onto — or away from — a path another process holds
23
+ // open, surfacing EPERM/EBUSY/EACCES. That holder is by construction a
24
+ // non-cooperating one (editor, AV, Search Indexer, `git`): dotmd's path lock is
25
+ // advisory and only excludes other dotmd processes, so a retry here is never
26
+ // waiting on a peer we would have serialized against anyway. POSIX never fails a
27
+ // rename this way, so retrying there would mask a genuinely different fault.
28
+ //
29
+ // The budget is bounded by withPathLocks' timeoutMs (2000ms default): these
30
+ // renames run while the lock is held, so a longer budget would trade a rare
31
+ // transient EPERM for common peer MutationLockErrors. 10 attempts with a 10ms
32
+ // linear backoff is ~450ms of sleep — comfortable headroom under 2s. Do not
33
+ // raise it past ~1s without also raising timeoutMs.
34
+ export function commitRename(from, to, testHooks) {
35
+ // Single seam for the platform check so the retry is reachable in tests on the
36
+ // POSIX machines that actually run them.
37
+ const windowsRenameSemantics = testHooks?.forceWindowsRenameSemantics ?? process.platform === 'win32';
38
+ for (let attempt = 0; ; attempt++) {
39
+ try {
40
+ const injected = testHooks?.forceRenameError?.(attempt);
41
+ if (injected) {
42
+ const error = new Error(`injected rename ${injected}`);
43
+ error.code = injected;
44
+ throw error;
45
+ }
46
+ renameSync(from, to);
47
+ return;
48
+ } catch (err) {
49
+ if (!windowsRenameSemantics) throw err;
50
+ if (!RENAME_RETRY_CODES.includes(err?.code)) throw err;
51
+ if (attempt >= RENAME_RETRY_ATTEMPTS - 1) throw err;
52
+ Atomics.wait(sleepBuffer, 0, 0, RENAME_RETRY_BACKOFF_MS * (attempt + 1));
53
+ }
54
+ }
55
+ }
package/src/git.mjs CHANGED
@@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process';
2
2
  import { chmodSync, closeSync, existsSync, fsyncSync, linkSync, lstatSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { createHash, randomUUID } from 'node:crypto';
5
+ import { commitRename } from './durable-rename.mjs';
5
6
 
6
7
  // Best-effort `git check-ignore` for a path. Returns true only when git
7
8
  // definitively reports the path is ignored; any failure (not a repo, git
@@ -675,7 +676,9 @@ function publishIndexGeneration(repoRoot, expected, desired, prepared, testHooks
675
676
  if (!sameGitIndexGeneration(current, expected)) throw new Error('Git index changed before transaction publication; current staging was preserved.');
676
677
  testHooks.afterGitIndexCompare?.({ lockPath, current, desired });
677
678
  if (desired.exists) {
678
- renameSync(lockPath, indexPath);
679
+ // .git/index is routinely held open by concurrent git processes and IDE git
680
+ // extensions, so on Windows this publish needs the retry.
681
+ commitRename(lockPath, indexPath, testHooks);
679
682
  lockOwned = false;
680
683
  } else {
681
684
  if (existsSync(indexPath)) unlinkSync(indexPath);
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,36 @@ 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
+ 'path', 'file', 'body', 'FILE', 'PATH', 'BODY',
282
+ ]);
283
+
284
+ function isBodyPlaceholder(file) {
285
+ if (/^<.+>$/.test(file)) return true; // <draft-file>, <path>, …
286
+ return BODY_PLACEHOLDER_NAMES.has(file);
287
+ }
288
+
274
289
  export function readBodyInput(source) {
275
290
  if (source === '-') {
276
291
  try { return readFileSync(0, 'utf8'); } catch (err) { die(`Could not read body from stdin: ${err.message}`); }
277
292
  }
278
293
  if (typeof source === 'string' && source.startsWith('@')) {
279
294
  const file = source.slice(1);
280
- if (!existsSync(file)) die(`Body file not found: ${file}`);
295
+ if (!existsSync(file)) {
296
+ if (isBodyPlaceholder(file)) {
297
+ die(`\`@${file}\` is a placeholder, not a real path — write the body to a file first, then pass it:\n` +
298
+ ` @/tmp/my-draft.md read the body from that file\n` +
299
+ ` - read the body from stdin (\`cat notes.md | dotmd …\`)\n` +
300
+ ` --message "..." short one-liners only`);
301
+ }
302
+ die(`Body file not found: ${file}`);
303
+ }
281
304
  return readFileSync(file, 'utf8');
282
305
  }
283
306
  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 = {}) {