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 +17 -3
- package/package.json +1 -1
- package/src/atomic-mutation.mjs +114 -7
- package/src/commands.mjs +3 -3
- package/src/config-edit.mjs +6 -3
- package/src/doctor.mjs +64 -0
- package/src/durable-rename.mjs +55 -0
- package/src/git.mjs +4 -1
- package/src/guard.mjs +2 -1
- package/src/hud.mjs +1 -1
- package/src/lifecycle.mjs +4 -1
- package/src/new.mjs +24 -1
- package/src/prompts.mjs +67 -10
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>]
|
|
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>]
|
|
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>] [
|
|
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
package/src/atomic-mutation.mjs
CHANGED
|
@@ -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
|
-
|
|
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}\
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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]
|
|
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/config-edit.mjs
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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>]
|
|
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
|
-
|
|
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))
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
if (
|
|
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 = {}) {
|