dotmd-cli 0.87.1 → 0.88.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/README.md CHANGED
@@ -97,20 +97,21 @@ Restart Claude Code, or run `/reload-plugins`, after a plugin update.
97
97
  ```bash
98
98
  runlist init # create config, docs/, and the generated index
99
99
  runlist new plan auth-refresh # scaffold a typed document
100
- runlist briefing # compact active-work orientation
101
- runlist plans # live plan dashboard
100
+ runlist plans # compact live plan dashboard
101
+ runlist briefing # comprehensive active-work view
102
102
  runlist check # validate schema, references, and lifecycle shape
103
103
  runlist doctor # preview repairs; add --apply to write
104
104
  ```
105
105
 
106
- `runlist briefing` is the compact orientation view. `runlist context` is the fuller
106
+ `runlist plans` is the compact orientation view. `runlist briefing` lists live
107
+ plans with next steps and grows with the corpus. `runlist context` is the fuller
107
108
  human/LLM briefing, while `runlist agent-context` emits bounded structured JSON for
108
109
  agent integrations.
109
110
 
110
111
  ## Core Workflow
111
112
 
112
113
  ```bash
113
- runlist briefing
114
+ runlist plans
114
115
  runlist use docs/plans/auth-refresh.md
115
116
  runlist set awaiting docs/plans/auth-refresh.md --note "Need API owner decision"
116
117
  runlist set active docs/plans/auth-refresh.md --note "Decision received"
@@ -130,7 +131,15 @@ runlist baton @/tmp/resume.md
130
131
  ```
131
132
 
132
133
  Baton refuses when a handoff for the same work is already pending, so one piece
133
- of work never has two resume prompts.
134
+ of work never has two resume prompts. Inspect the pending handoff with
135
+ `runlist prompts show <slug>`. Keep it if current; if stale, pass `--replace`
136
+ with a new draft. Baton archives the previous text and refreshes the pending
137
+ prompt in the same operation as any plan release.
138
+
139
+ Repositories with a commit wrapper can export `batonCommitCommand(message, paths)`
140
+ from `dotmd.config.mjs` and return an argv array such as
141
+ `['just', 'commit', message, ...paths]`. Baton quotes the printed command and
142
+ excludes session prompts from `paths`.
134
143
 
135
144
  Saved prompts are local session state. Consume them with `runlist use`; inspect
136
145
  without consuming via `runlist prompts show`. Consuming a baton prompt also claims
package/bin/dotmd.mjs CHANGED
@@ -676,10 +676,11 @@ Options:
676
676
  --json Output as JSON ({ owned, prompts, errors, previousSelf,
677
677
  fleet, recentRejections, misuseRecap, drift })`,
678
678
 
679
- briefing: `runlist briefing — compact summary for session start
679
+ briefing: `runlist briefing — comprehensive live-work summary
680
680
 
681
- Shows plan statuses with next steps, doc/research counts, and health
682
- in 5-10 lines. Designed for LLM context injection.
681
+ Shows every live plan with its next step, plus doc/research counts and health.
682
+ Output grows with the corpus; use \`runlist plans\` for compact orientation or
683
+ \`runlist agent-context\` for structured agent context.
683
684
 
684
685
  Options:
685
686
  --json Output as JSON`,
@@ -1270,13 +1271,21 @@ Options:
1270
1271
  --note "why" Append the reason to ## Version History (plan mode only)
1271
1272
  --message / --body Inline body (one-liners; prefer @path or stdin)
1272
1273
  --force Recover another session's plan (explicit path required)
1274
+ --replace Replace exactly one pending handoff; archive its prior text
1273
1275
  --json Structured repository/session/generated file result
1274
1276
  --dry-run, -n Preview without writing
1275
1277
 
1278
+ Repo-specific commit hint (dotmd.config.mjs):
1279
+ export function batonCommitCommand(message, paths) {
1280
+ return ['just', 'commit', message, ...paths];
1281
+ }
1282
+ The function returns argv; baton shell-quotes each argument before printing.
1283
+
1276
1284
  Examples:
1277
1285
  runlist baton @/tmp/draft.md
1278
1286
  runlist baton checkout-fixes @/tmp/draft.md
1279
1287
  runlist baton docs/plans/auth.md @/tmp/draft.md
1288
+ runlist baton docs/plans/auth.md @/tmp/new-draft.md --replace
1280
1289
  runlist baton --status paused --note "blocked on review" @/tmp/d.md
1281
1290
  cat /tmp/draft.md | runlist baton
1282
1291
 
@@ -1295,11 +1304,13 @@ cooperating transaction:
1295
1304
  grant ownership. A live pickup-hook delivery lease blocks release and force
1296
1305
  takeover; hooks are at-least-once and deduplicate the stable operationId.
1297
1306
 
1298
- Slug mode (no plan involved) saves resume-<slug> and touches nothing else: no
1299
- status change, no commit. A bare word that names a plan is treated as that plan.
1307
+ Slug mode (no plan involved) saves resume-<slug> without a status change or
1308
+ commit. A bare word that names a plan is treated as that plan.
1300
1309
 
1301
1310
  Baton saves nothing while a handoff for the same work is pending (resume-<name>,
1302
- or a pending prompt linked to the plan): consume or archive it, then re-run.
1311
+ or a pending prompt linked to the plan): inspect it with \`runlist prompts show\`.
1312
+ Keep it if current; use \`--replace\` with a new draft if stale. Replacement
1313
+ preserves the prior text under archived/ and refuses multiple matches.
1303
1314
  With no @file, \`-\` or --message, baton reads stdin only when something is piped
1304
1315
  in; an open pipe that sends nothing is given up on after a moment.`,
1305
1316
 
@@ -1983,7 +1994,15 @@ async function main() {
1983
1994
  if (command === 'glossary') { const { runGlossary } = await import('../src/glossary.mjs'); runGlossary(restArgs, config); return; }
1984
1995
  if (command === 'model') { const { runModel } = await import('../src/model.mjs'); await runModel(restArgs, config); return; }
1985
1996
  if (command === 'show') { const { runShow } = await import('../src/show.mjs'); runShow(restArgs, config); return; }
1986
- if (command === 'decisions') { const { runDecisions } = await import('../src/decisions.mjs'); runDecisions(restArgs, config); return; }
1997
+ if (command === 'decisions') {
1998
+ const { runDecisions } = await import('../src/decisions.mjs');
1999
+ const result = runDecisions(restArgs, config);
2000
+ if (result?.defects?.length) {
2001
+ const first = result.defects[0];
2002
+ _exitFailureMessage = `${result.defects.length} decision defect(s); first: ${first.doc}:${first.line} ${first.message}`;
2003
+ }
2004
+ return;
2005
+ }
1987
2006
  if (command === 'export') { const { runExport } = await import('../src/export.mjs'); runExport(restArgs, config, { dryRun, root: rootArg, type: typeArg }); return; }
1988
2007
 
1989
2008
  // Lifecycle commands
@@ -2175,7 +2194,10 @@ async function main() {
2175
2194
  writeCheckPreviewNote();
2176
2195
  process.stdout.write('\n' + renderCheck(freshIndex, config, { errorsOnly, noCollapse, verbose }));
2177
2196
  }
2178
- if (freshIndex.errors.length > 0) process.exitCode = 1;
2197
+ if (freshIndex.errors.length > 0) {
2198
+ process.exitCode = 1;
2199
+ _exitFailureMessage = checkFailureSummary(freshIndex.errors);
2200
+ }
2179
2201
  return;
2180
2202
  }
2181
2203
 
@@ -2185,13 +2207,19 @@ async function main() {
2185
2207
 
2186
2208
  if (args.includes('--json')) {
2187
2209
  process.stdout.write(JSON.stringify(checkJson(index), null, 2) + '\n');
2188
- if (index.errors.length > 0) process.exitCode = 1;
2210
+ if (index.errors.length > 0) {
2211
+ process.exitCode = 1;
2212
+ _exitFailureMessage = checkFailureSummary(index.errors);
2213
+ }
2189
2214
  return;
2190
2215
  }
2191
2216
 
2192
2217
  writeCheckPreviewNote();
2193
2218
  process.stdout.write(renderCheck(index, config, { errorsOnly, noCollapse, verbose }));
2194
- if (index.errors.length > 0) process.exitCode = 1;
2219
+ if (index.errors.length > 0) {
2220
+ process.exitCode = 1;
2221
+ _exitFailureMessage = checkFailureSummary(index.errors);
2222
+ }
2195
2223
  return;
2196
2224
  }
2197
2225
 
@@ -2416,9 +2444,15 @@ async function main() {
2416
2444
  let _resolvedConfig = null;
2417
2445
  let _resolvedCommand = null;
2418
2446
  let _suppressObservability = false;
2447
+ let _exitFailureMessage = null;
2419
2448
  const _startMs = Date.now();
2420
2449
  const _invocationArgs = process.argv.slice(2);
2421
2450
 
2451
+ function checkFailureSummary(errors) {
2452
+ const first = errors[0];
2453
+ return `${errors.length} check error(s); first: ${first.path ? `${first.path}: ` : ''}${first.message}`;
2454
+ }
2455
+
2422
2456
  function _journalExit(err) {
2423
2457
  if (_suppressObservability || _resolvedCommand === 'hud' || _invocationArgs.includes('--dry-run') || _invocationArgs.includes('-n')) return;
2424
2458
  try {
@@ -2433,7 +2467,7 @@ function _journalExit(err) {
2433
2467
  // A command that reports its own failure through the exit code (check with
2434
2468
  // errors, a failed update step) is a failure too.
2435
2469
  const code = Number(process.exitCode ?? 0);
2436
- const failure = err ?? (code !== 0 ? { name: 'ExitStatus', message: `exited with status ${code}` } : null);
2470
+ const failure = err ?? (code !== 0 ? { name: 'ExitStatus', message: _exitFailureMessage ?? `exited with status ${code}` } : null);
2437
2471
  if (failure) {
2438
2472
  try {
2439
2473
  recordGlobalError({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.87.1",
3
+ "version": "0.88.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",
@@ -1310,6 +1310,10 @@ export function moveFileAtomic(sourcePath, targetPath, render, options) {
1310
1310
  // transaction manifest exists, so a guard conflict can never leave a
1311
1311
  // transaction to recover from.
1312
1312
  for (const guard of guards) {
1313
+ if (guard.absent) {
1314
+ if (existsSync(guard.path)) throw new MutationConflictError(`File appeared while the move mutation set was being prepared: ${path.resolve(guard.path)}`);
1315
+ continue;
1316
+ }
1313
1317
  const snapshot = snapshotFile(guard.path);
1314
1318
  if (guard.expectedContent !== undefined && snapshot.content !== guard.expectedContent) {
1315
1319
  throw new MutationConflictError(`File changed while the move mutation set was being prepared: ${snapshot.path}`);
@@ -1657,6 +1661,10 @@ export function mutateFileSet({ updates = [], creations = [], guards = [] }, opt
1657
1661
  // is created — a guard conflict must leave the tree byte-identical, not even
1658
1662
  // an empty directory behind.
1659
1663
  for (const guard of guards) {
1664
+ if (guard.absent) {
1665
+ if (existsSync(guard.path)) throw new MutationConflictError(`File appeared while the mutation set was being prepared: ${path.resolve(guard.path)}`);
1666
+ continue;
1667
+ }
1660
1668
  const snapshot = snapshotFile(guard.path);
1661
1669
  if (guard.expectedContent !== undefined && snapshot.content !== guard.expectedContent) {
1662
1670
  throw new MutationConflictError(`File changed while the mutation set was being prepared: ${snapshot.path}`);
package/src/baton.mjs CHANGED
@@ -1,12 +1,13 @@
1
- import { readFileSync, existsSync, writeFileSync, realpathSync } from 'node:fs';
1
+ import { readFileSync, existsSync, realpathSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
- import { asString, toRepoPath, die, warn, isArchivedPath, resolveRefPath } from './util.mjs';
4
+ import { asString, toRepoPath, die, warn, isArchivedPath, resolveRefPath, nowIso } from './util.mjs';
5
5
  import { buildIndex, resolveDocArg } from './index.mjs';
6
6
  import { preparePromptDocument, runNew, readBodyInput, readPipedBodyInput } from './new.mjs';
7
- import { ensurePlanCompletionBeforeRelease, planHasPendingCompletion, runSet } from './lifecycle.mjs';
7
+ import { ensurePlanCompletionBeforeRelease, planHasPendingCompletion, renderLifecycleMutation, runSet } from './lifecycle.mjs';
8
8
  import { green, dim } from './color.mjs';
9
- import { authorizeManagedSource } from './managed-path.mjs';
9
+ import { authorizeManagedDestination, authorizeManagedSource } from './managed-path.mjs';
10
+ import { mutateFileSet } from './atomic-mutation.mjs';
10
11
  import { assertPlanMutationAuthorized, authoritativeSessionId, listOwnedPlans, readPlanOwnership } from './pickup.mjs';
11
12
 
12
13
  // `dotmd baton` is the one-command handoff: save the resume prompt AND release
@@ -56,7 +57,8 @@ function pendingHandoffs(promptPath, planPath, config) {
56
57
  try { planRef = asString(parseSimpleFrontmatter(extractFrontmatter(readFileSync(abs, 'utf8')).frontmatter).plan); }
57
58
  catch { continue; }
58
59
  const linked = planRef ? resolveRefPath(planRef, path.dirname(abs), config.repoRoot) : null;
59
- if (linked && realpathSync(linked) === planReal) found.add(doc.path);
60
+ try { if (linked && realpathSync(linked) === planReal) found.add(doc.path); }
61
+ catch { /* stale plan link is not evidence this prompt belongs to the plan */ }
60
62
  }
61
63
  }
62
64
  return [...found];
@@ -65,7 +67,36 @@ function pendingHandoffs(promptPath, planPath, config) {
65
67
  function refusePendingHandoff(pending) {
66
68
  const lines = pending.map(p => ` ${p}`).join('\n');
67
69
  const slug = path.basename(pending[0], '.md');
68
- die(`Nothing saved: a handoff for this is already pending:\n${lines}\nUse it (\`runlist use ${slug}\`) or archive it (\`runlist prompts archive ${pending[0]}\`), then run baton again.`);
70
+ die(`Nothing saved: a handoff for this is already pending:\n${lines}\nInspect it with \`runlist prompts show ${slug}\`. If it is current, keep it and do not hand off again. If it is stale, re-run baton with \`--replace\` to preserve the old handoff under archived/.`);
71
+ }
72
+
73
+ function archivedCopyPath(promptPath, config) {
74
+ const dir = path.join(path.dirname(promptPath), config.archiveDir);
75
+ const ext = path.extname(promptPath);
76
+ const stem = path.basename(promptPath, ext);
77
+ let candidate = path.join(dir, `${stem}${ext}`);
78
+ for (let n = 2; existsSync(candidate); n++) candidate = path.join(dir, `${stem}-${n}${ext}`);
79
+ return authorizeManagedDestination(candidate, config, { kind: 'Baton replacement archive' }).path;
80
+ }
81
+
82
+ function shellQuote(value) {
83
+ const raw = String(value);
84
+ return /^[A-Za-z0-9_./:@+-]+$/.test(raw) ? raw : `"${raw.replace(/["\\$`]/g, '\\$&')}"`;
85
+ }
86
+
87
+ function commitCommand(config, message, paths) {
88
+ const fallback = ['git', 'commit', '-m', message, '--', ...paths];
89
+ let argv = fallback;
90
+ if (typeof config.hooks.batonCommitCommand === 'function') {
91
+ try {
92
+ const custom = config.hooks.batonCommitCommand(message, [...paths]);
93
+ if (!Array.isArray(custom) || custom.length === 0 || !custom.every(part => typeof part === 'string' && part.length > 0)) {
94
+ throw new Error('return a nonempty string argv array');
95
+ }
96
+ argv = custom;
97
+ } catch (err) { warn(`batonCommitCommand failed (${err.message}); printing the default git command.`); }
98
+ }
99
+ return argv.map(shellQuote).join(' ');
69
100
  }
70
101
 
71
102
  // Is this positional a filesystem reference (must resolve, typos die) or a
@@ -83,6 +114,7 @@ export async function runBaton(argv, config, opts = {}) {
83
114
  let note = null;
84
115
  let bodyFlag = null;
85
116
  let force = false;
117
+ let replace = false;
86
118
  const positionals = [];
87
119
  for (let i = 0; i < argv.length; i++) {
88
120
  const a = argv[i];
@@ -90,6 +122,7 @@ export async function runBaton(argv, config, opts = {}) {
90
122
  if (a === '--note' && argv[i + 1]) { note = argv[++i]; continue; }
91
123
  if ((a === '--body' || a === '--message') && argv[i + 1]) { bodyFlag = argv[++i]; continue; }
92
124
  if (a === '--force') { force = true; continue; }
125
+ if (a === '--replace') { replace = true; continue; }
93
126
  if (a === '--json') continue;
94
127
  if (!a.startsWith('-') || a === '-' || a.startsWith('@')) { positionals.push(a); continue; }
95
128
  die(`Unknown flag for \`runlist baton\`: ${a}`);
@@ -152,16 +185,45 @@ export async function runBaton(argv, config, opts = {}) {
152
185
 
153
186
  const nameBase = planPath ? path.basename(planPath, '.md') : promptSlug;
154
187
  const slugBase = nameBase.startsWith('resume-') ? nameBase : `resume-${nameBase}`;
155
- const refuseIfPending = () => {
188
+ let replacement = null;
189
+ const resolvePending = () => {
156
190
  const target = preparePromptDocument(slugBase, body, config, { dryRun: true });
157
191
  const pending = pendingHandoffs(target.filePath, planPath, config);
158
- if (pending.length) refusePendingHandoff(pending);
192
+ if (!replace) {
193
+ if (pending.length) refusePendingHandoff(pending);
194
+ return;
195
+ }
196
+ if (pending.length !== 1) {
197
+ const detail = pending.length ? `Found ${pending.length}:\n${pending.map(p => ` ${p}`).join('\n')}` : 'No pending handoff matches this work.';
198
+ die(`Nothing replaced: --replace requires exactly one pending handoff. ${detail}`);
199
+ }
200
+ const oldRepoPath = pending[0];
201
+ const oldPath = authorizeManagedSource(path.resolve(config.repoRoot, oldRepoPath), config, { kind: 'Baton replacement source' }).path;
202
+ const oldRaw = readFileSync(oldPath, 'utf8');
203
+ const { frontmatter, body: oldBody } = extractFrontmatter(oldRaw);
204
+ const fm = parseSimpleFrontmatter(frontmatter);
205
+ if (asString(fm.type) !== 'prompt' || asString(fm.status) !== 'pending') {
206
+ die(`Nothing replaced: ${oldRepoPath} is not a pending prompt.`);
207
+ }
208
+ const priorPlanRef = asString(fm.plan);
209
+ const priorPlanPath = priorPlanRef ? resolveRefPath(priorPlanRef, path.dirname(oldPath), config.repoRoot) : null;
210
+ if (!planPath && priorPlanRef) {
211
+ die(`Nothing replaced: ${oldRepoPath} links a plan (${priorPlanRef}). Use \`runlist baton <plan-file> @<draft-file> --replace\` so the plan link and release stay together.`);
212
+ }
213
+ replacement = {
214
+ oldPath, oldRepoPath, oldRaw,
215
+ archivedPath: archivedCopyPath(oldPath, config),
216
+ alreadyCurrent: oldBody.trim() === body.trim()
217
+ && (!planPath || (priorPlanPath && path.resolve(priorPlanPath) === path.resolve(planPath))),
218
+ canonicalPath: target.filePath,
219
+ };
159
220
  };
160
221
 
161
222
  let repoPath = null;
162
223
  let oldStatus = null;
163
224
  let ownershipPath = null;
164
225
  let planGuard = null;
226
+ let wouldReleaseClaim = false;
165
227
  if (planPath) {
166
228
  planPath = authorizeManagedSource(planPath, config, { kind: 'Baton plan source' }).path;
167
229
  repoPath = toRepoPath(planPath, config.repoRoot);
@@ -187,6 +249,7 @@ export async function runBaton(argv, config, opts = {}) {
187
249
  const sessionId = authoritativeSessionId();
188
250
  const ownership = assertPlanMutationAuthorized(repoPath, config, { sessionId, force });
189
251
  const ownedHere = ownership?.state === 'owned' && ownership.sessionId === sessionId;
252
+ wouldReleaseClaim = ownership?.state === 'owned';
190
253
  // Baton's release only means something when there is a claim to release. A
191
254
  // plan that is neither in-session nor owned here carries a status someone
192
255
  // chose on purpose (`awaiting`, `blocked`, a repo's own `awaiting-testing`),
@@ -208,16 +271,36 @@ export async function runBaton(argv, config, opts = {}) {
208
271
  if (note) warn('--note ignored — the plan\'s status is unchanged, so there is no transition to record.');
209
272
  }
210
273
  // Before the plan-completion step: a refusal must leave nothing changed.
211
- refuseIfPending();
274
+ resolvePending();
275
+ // A status transition may file the plan at a new path. Refreshing the
276
+ // prompt inside that transaction keeps its plan link valid even when its
277
+ // body text happened to be identical to the previous handoff.
278
+ if (replacement && status !== oldStatus) replacement.alreadyCurrent = false;
212
279
  ownershipPath = readPlanOwnership(repoPath, config)?.recordPath ?? null;
213
280
  if (!dryRun) ensurePlanCompletionBeforeRelease(repoPath, config, { testHooks: opts.testHooks });
214
281
  else if (planHasPendingCompletion(repoPath, config)) process.stderr.write(`${dim('[dry-run]')} Pending claim completion would block this release.\n`);
215
282
  } else {
216
- refuseIfPending();
283
+ resolvePending();
217
284
  if (statusFlag) warn(`--status ignored — no plan involved in this handoff (saving the prompt only).`);
218
285
  if (note) warn(`--note ignored — no plan involved in this handoff (notes land in a plan's Version History).`);
219
286
  }
220
287
 
288
+ const preparedReplacement = prepared => {
289
+ if (!replacement || replacement.alreadyCurrent) return { updates: [], creations: [], guards: [] };
290
+ let archivedContent = renderLifecycleMutation(replacement.oldRaw,
291
+ { status: 'archived', updated: nowIso() }, () => 'Archived.');
292
+ if (repoPath) archivedContent = archivedContent.replace(/^plan:[ \t]*\S.*$/m, `plan: ${repoPath}`);
293
+ return {
294
+ updates: [{ path: replacement.oldPath, expectedContent: replacement.oldRaw, content: prepared.content }],
295
+ creations: [{ path: replacement.archivedPath, content: archivedContent }],
296
+ guards: path.resolve(replacement.canonicalPath) === path.resolve(replacement.oldPath)
297
+ ? [] : [{ path: replacement.canonicalPath, absent: true }],
298
+ };
299
+ };
300
+ if (dryRun && !json && replacement && !replacement.alreadyCurrent) {
301
+ process.stdout.write(`${dim('[dry-run]')} Would preserve old handoff: ${replacement.oldRepoPath} → ${toRepoPath(replacement.archivedPath, config.repoRoot)}\n`);
302
+ }
303
+
221
304
  // Plan mode publishes the already-stamped prompt, status/history update, and
222
305
  // ownership release in one transaction. Slug mode has no plan transaction.
223
306
  let createdSlug = null;
@@ -230,29 +313,39 @@ export async function runBaton(argv, config, opts = {}) {
230
313
  if (json) process.stdout.write = chunk => { muted.push(String(chunk)); return true; };
231
314
  try {
232
315
  if (!planPath) {
233
- const prepared = preparePromptDocument(slugBase, body, config, { dryRun });
234
- try {
235
- newResult = await runNew(['prompt', slugBase, '--body', body], config, { dryRun, deferIndex: true });
236
- } catch (err) {
237
- // Lost a race with another baton between the pending check and the write.
238
- if (/File already exists/.test(String(err?.message))) refusePendingHandoff([prepared.repoPath]);
239
- throw err;
316
+ const promptName = replacement ? path.basename(replacement.oldPath, '.md') : slugBase;
317
+ const prepared = preparePromptDocument(promptName, body, config, { dryRun });
318
+ if (replacement) {
319
+ const mutation = preparedReplacement(prepared);
320
+ if (!dryRun && !replacement.alreadyCurrent) mutateFileSet(mutation, { repoRoot: config.repoRoot, testHooks: opts.testHooks });
321
+ newResult = { sessionFiles: [replacement.oldRepoPath, ...(replacement.alreadyCurrent ? [] : [toRepoPath(replacement.archivedPath, config.repoRoot)])] };
322
+ } else {
323
+ try {
324
+ newResult = await runNew(['prompt', slugBase, '--body', body], config, { dryRun, deferIndex: true });
325
+ } catch (err) {
326
+ // Lost a race with another baton between the pending check and the write.
327
+ if (/File already exists/.test(String(err?.message))) refusePendingHandoff([prepared.repoPath]);
328
+ throw err;
329
+ }
240
330
  }
241
- createdSlug = slugBase;
242
- promptRepoPath = prepared.repoPath;
331
+ createdSlug = promptName;
332
+ promptRepoPath = replacement?.oldRepoPath ?? prepared.repoPath;
243
333
  } else {
244
- const prepared = preparePromptDocument(slugBase, body, config, { plan: repoPath, dryRun });
334
+ const promptName = replacement ? path.basename(replacement.oldPath, '.md') : slugBase;
335
+ const prepared = preparePromptDocument(promptName, body, config, { plan: repoPath, dryRun });
336
+ const mutation = preparedReplacement(prepared);
245
337
  const setArgs = [status, planPath];
246
338
  if (force) setArgs.push('--force');
247
339
  if (note && !planGuard) setArgs.push('--note', note);
248
340
  try {
249
- if (dryRun) process.stdout.write(`${dim('[dry-run]')} Would create: ${prepared.repoPath}\n`);
341
+ if (dryRun) process.stdout.write(`${dim('[dry-run]')} Would ${replacement ? (replacement.alreadyCurrent ? 'keep' : 'replace') : 'create'}: ${replacement?.oldRepoPath ?? prepared.repoPath}\n`);
250
342
  archiveResult = await runSet(setArgs, config, {
251
343
  dryRun,
252
344
  viaBaton: true,
253
345
  testHooks: opts.testHooks,
254
- creations: dryRun ? [] : [{ path: prepared.filePath, content: prepared.content }],
255
- guards: planGuard && !dryRun ? [planGuard] : [],
346
+ additionalUpdates: dryRun ? [] : mutation.updates,
347
+ creations: dryRun ? [] : replacement ? mutation.creations : [{ path: prepared.filePath, content: prepared.content }],
348
+ guards: dryRun ? [] : [...mutation.guards, ...(planGuard ? [planGuard] : [])],
256
349
  deferIndex: true,
257
350
  });
258
351
  } catch (err) {
@@ -260,9 +353,9 @@ export async function runBaton(argv, config, opts = {}) {
260
353
  throw err;
261
354
  }
262
355
  createdSlug = prepared.slug;
263
- promptRepoPath = prepared.repoPath;
356
+ promptRepoPath = replacement?.oldRepoPath ?? prepared.repoPath;
264
357
  statusChanged = oldStatus !== status;
265
- if (!dryRun) {
358
+ if (!dryRun && !replacement) {
266
359
  try { config.hooks.onNew?.({ path: prepared.repoPath, status: 'pending', title: prepared.slug, type: 'prompt' }); }
267
360
  catch (err) { warn(`Hook 'onNew' threw: ${err.message}`); }
268
361
  }
@@ -271,24 +364,12 @@ export async function runBaton(argv, config, opts = {}) {
271
364
  if (json) process.stdout.write = originalStdoutWrite;
272
365
  }
273
366
 
274
- // A release status can FILE the plan into a bucket (`lifecycle.filedStatuses`,
275
- // e.g. paused → docs/plans/held/). The prompt is created inside the same
276
- // transaction as that move, so its `plan:` link necessarily holds the
277
- // pre-move path and is stale the instant it lands. The move's own reference
278
- // rewrite does not cover it: `plan` is deliberately not a `referenceFields`
279
- // entry, so nothing validates or repoints it. Retarget it here.
280
- if (!dryRun && promptRepoPath && archiveResult?.newRepoPath && archiveResult.newRepoPath !== repoPath) {
281
- const promptPath = path.join(config.repoRoot, promptRepoPath);
282
- try {
283
- const { frontmatter, body } = extractFrontmatter(readFileSync(promptPath, 'utf8'));
284
- // Rewritten in place rather than through replaceFrontmatterField, which
285
- // always emits a folded block scalar — right for prose fields, wrong for
286
- // a path every other prompt carries on one line. Baton wrote this line
287
- // itself moments ago, so the single-line form is guaranteed.
288
- const rewritten = frontmatter.replace(/^plan:[ \t]*\S.*$/m, `plan: ${archiveResult.newRepoPath}`);
289
- if (rewritten !== frontmatter) writeFileSync(promptPath, `---\n${rewritten}\n---\n${body}`, 'utf8');
290
- }
291
- catch (err) { warn(`Saved the prompt, but could not repoint its plan link to ${archiveResult.newRepoPath}: ${err.message}`); }
367
+ if (!dryRun && replacement && !replacement.alreadyCurrent) {
368
+ const archivedRepoPath = toRepoPath(replacement.archivedPath, config.repoRoot);
369
+ try { config.hooks.onArchive?.({ path: archivedRepoPath, oldStatus: 'pending' }, { oldPath: replacement.oldRepoPath, newPath: archivedRepoPath }); }
370
+ catch (err) { warn(`Hook 'onArchive' threw: ${err.message}`); }
371
+ try { config.hooks.onNew?.({ path: replacement.oldRepoPath, status: 'pending', title: createdSlug, type: 'prompt' }); }
372
+ catch (err) { warn(`Hook 'onNew' threw: ${err.message}`); }
292
373
  }
293
374
 
294
375
  const normalizeRepoPath = candidate => {
@@ -297,13 +378,14 @@ export async function runBaton(argv, config, opts = {}) {
297
378
  };
298
379
  const touched = (archiveResult?.touched ?? (planPath && statusChanged ? [repoPath] : [])).map(normalizeRepoPath);
299
380
  const ownershipRepoPath = normalizeRepoPath(ownershipPath);
300
- const repositoryFiles = [...new Set(touched.filter(candidate => candidate && candidate !== ownershipRepoPath && candidate !== promptRepoPath && candidate !== normalizeRepoPath(config.indexPath)))];
301
- const sessionFiles = [...new Set([...(newResult?.sessionFiles ?? []), promptRepoPath, ownershipRepoPath].filter(Boolean))];
381
+ const archivedPromptRepoPath = replacement && !replacement.alreadyCurrent ? toRepoPath(replacement.archivedPath, config.repoRoot) : null;
382
+ const repositoryFiles = [...new Set(touched.filter(candidate => candidate && candidate !== ownershipRepoPath && candidate !== promptRepoPath && candidate !== archivedPromptRepoPath && candidate !== normalizeRepoPath(config.indexPath)))];
383
+ const sessionFiles = [...new Set([...(newResult?.sessionFiles ?? []), promptRepoPath, archivedPromptRepoPath, ownershipRepoPath].filter(Boolean))];
302
384
  const deferredGeneratedFiles = [...new Set(newResult?.deferredGeneratedFiles ?? (config.indexPath ? [normalizeRepoPath(config.indexPath)] : []))];
303
385
  const operationResult = {
304
386
  operation: 'baton',
305
387
  dryRun: Boolean(dryRun),
306
- disposition: dryRun ? 'would-change' : 'applied',
388
+ disposition: replacement?.alreadyCurrent && !statusChanged && !wouldReleaseClaim ? 'already-current' : dryRun ? 'would-change' : 'applied',
307
389
  wouldChange: Boolean(dryRun),
308
390
  mode: planPath ? 'plan' : 'slug',
309
391
  status: planPath ? { from: oldStatus, to: status, changed: statusChanged } : null,
@@ -313,6 +395,14 @@ export async function runBaton(argv, config, opts = {}) {
313
395
  deferredGeneratedFiles,
314
396
  prompt: promptRepoPath,
315
397
  plan: archiveResult?.newRepoPath ?? repoPath,
398
+ planMovement: planPath ? { from: repoPath, to: archiveResult?.newRepoPath ?? repoPath } : null,
399
+ replacement: replacement ? {
400
+ previousPrompt: replacement.oldRepoPath,
401
+ archivedPrompt: archivedPromptRepoPath,
402
+ pendingPrompt: promptRepoPath,
403
+ alreadyCurrent: replacement.alreadyCurrent,
404
+ } : null,
405
+ claimRelease: planPath ? { wouldRelease: wouldReleaseClaim, released: !dryRun && wouldReleaseClaim } : null,
316
406
  };
317
407
 
318
408
  const prefix = dryRun ? dim('[dry-run] ') : '';
@@ -320,13 +410,14 @@ export async function runBaton(argv, config, opts = {}) {
320
410
  process.stdout.write(JSON.stringify(operationResult, null, 2) + '\n');
321
411
  return operationResult;
322
412
  }
323
- process.stderr.write(`\n${prefix}${green('✓ Baton passed')}: ${createdSlug} (the next session's hud surfaces it — nothing to paste into chat)\n`);
413
+ const verdict = replacement?.alreadyCurrent ? '✓ Handoff kept' : replacement ? '✓ Baton replaced' : '✓ Baton passed';
414
+ process.stderr.write(`\n${prefix}${green(verdict)}: ${createdSlug} (the next session's hud surfaces it — nothing to paste into chat)\n`);
415
+ if (archivedPromptRepoPath) process.stderr.write(dim(`${prefix}Previous handoff preserved: ${archivedPromptRepoPath}\n`));
324
416
  if (planGuard) {
325
417
  process.stderr.write(dim(`${repoPath} left at \`${oldStatus}\` — it was not in-session, so there was no claim to release.\n`));
326
418
  process.stderr.write(dim(`Meant to change it? runlist set <status> ${repoPath}\n`));
327
419
  }
328
420
  if (statusChanged) {
329
- const pathspec = operationResult.repositoryFiles.join(' ');
330
421
  let gitignored = false;
331
422
  try {
332
423
  const { isGitIgnored } = await import('./git.mjs');
@@ -336,7 +427,7 @@ export async function runBaton(argv, config, opts = {}) {
336
427
  process.stderr.write(dim(`${repoPath} is gitignored — no commit needed.\n`));
337
428
  } else {
338
429
  process.stderr.write(`${prefix}Commit the repository files (session files stay OUT of the pathspec):\n`);
339
- process.stderr.write(`${prefix} git commit -m "baton: ${path.basename(planPath, '.md')} ${oldStatus} → ${status}" -- ${pathspec}\n`);
430
+ process.stderr.write(`${prefix} ${commitCommand(config, `baton: ${path.basename(planPath, '.md')} ${oldStatus} → ${status}`, operationResult.repositoryFiles)}\n`);
340
431
  }
341
432
  }
342
433
  if (operationResult.deferredGeneratedFiles.length) process.stderr.write(dim(`Generated index deferred: ${operationResult.deferredGeneratedFiles.join(', ')}\n`));
package/src/commands.mjs CHANGED
@@ -123,7 +123,7 @@ const definitions = [
123
123
  ], { aliases: ['prompt'] }),
124
124
  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'), flag('--no-claim')] })]),
125
125
  command('next', mutates('managed prompt source and same-root archive destination'), 'workflow', [form('', { options: [flag('--json'), flag('--no-index'), flag('--show-files'), flag('--force'), flag('--no-claim')] })]),
126
- 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')] })]),
126
+ 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('--replace'), flag('--json')] })]),
127
127
  command('runlist', mutates('managed hubs/children and managed scaffold destinations'), 'workflow', [
128
128
  form('<hub>', { args: positionals(1, 1), options: [flag('--json')] }),
129
129
  form('next <hub>', { subcommands: ['next'], args: positionals(1, 1), options: [flag('--json'), flag('--full'), flag('--no-index'), flag('--show-files')] }),
package/src/decisions.mjs CHANGED
@@ -689,7 +689,7 @@ export function runDecisions(args, config) {
689
689
  process.stdout.write(`${items.length} decision items in ${docs.length} documents; ${defects.length} defects.\n`);
690
690
  }
691
691
  if (defects.length) process.exitCode = 1;
692
- return;
692
+ return { defects };
693
693
  }
694
694
 
695
695
  let docFilter = null;
package/src/lifecycle.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter, normalizeEol } from './frontmatter.mjs';
4
- import { asString, toRepoPath, die, warn, resolveDocPath, escapeRegex, nowIso, suggestCandidates, emitFilesFooter, isArchivedPath, currentSessionId } from './util.mjs';
4
+ import { asString, toRepoPath, die, warn, resolveDocPath, resolveRefPath, escapeRegex, nowIso, suggestCandidates, emitFilesFooter, isArchivedPath, currentSessionId } from './util.mjs';
5
5
  import { readJournalEntries } from './journal.mjs';
6
6
  import { captureGitIndexGeneration, getGitLastModifiedBatch, getGitLastSubstantiveModifiedBatch, isTracked } from './git.mjs';
7
7
  import { buildIndex, collectDocFiles, resolveDocArg } from './index.mjs';
@@ -78,6 +78,22 @@ function commitLifecycleMutation(filePath, targetPath, config, updates, historyF
78
78
  };
79
79
  };
80
80
  if (targetPath) {
81
+ // A companion prompt created or refreshed by baton links this plan before
82
+ // its status transition decides whether to file it elsewhere. Stamp the
83
+ // destination into that companion before publishing any transaction path.
84
+ const newRepoPath = toRepoPath(targetPath, config.repoRoot);
85
+ const retargetPrompt = item => {
86
+ if (typeof item.content !== 'string') return item;
87
+ const { frontmatter, body } = extractFrontmatter(item.content);
88
+ if (!frontmatter) return item;
89
+ const fm = parseSimpleFrontmatter(frontmatter);
90
+ const linked = asString(fm.plan);
91
+ const resolved = linked ? resolveRefPath(linked, path.dirname(item.path), config.repoRoot) : null;
92
+ if (asString(fm.type) !== 'prompt' || !resolved || path.resolve(resolved) !== path.resolve(filePath)) return item;
93
+ const rewritten = frontmatter.replace(/^plan:[ \t]*\S.*$/m, `plan: ${newRepoPath}`);
94
+ return { ...item, content: `---\n${rewritten}\n---\n${body}` };
95
+ };
96
+ const creations = (options.creations ?? []).map(retargetPrompt);
81
97
  let result;
82
98
  const tracked = isTracked(filePath, config.repoRoot);
83
99
  const gitIndex = tracked ? captureGitIndexGeneration(config.repoRoot) : null;
@@ -89,8 +105,8 @@ function commitLifecycleMutation(filePath, targetPath, config, updates, historyF
89
105
  const identities = createReferenceIdentitySet([filePath, ...allFiles]);
90
106
  const referenceFields = configuredReferenceFields(config);
91
107
  const additionalUpdates = options.skipInboundRefs
92
- ? (options.additionalUpdates ?? [])
93
- : (options.additionalUpdates ?? []).map(item => ({
108
+ ? (options.additionalUpdates ?? []).map(retargetPrompt)
109
+ : (options.additionalUpdates ?? []).map(item => retargetPrompt({
94
110
  ...item,
95
111
  content: item.content === undefined ? undefined : rewriteDocumentReferences(item.content, {
96
112
  sourcePath: item.path, repoRoot: config.repoRoot, identities, oldPath: filePath, newPath: targetPath, referenceFields,
@@ -108,7 +124,7 @@ function commitLifecycleMutation(filePath, targetPath, config, updates, historyF
108
124
  sourcePath: docFile, repoRoot: config.repoRoot, identities, oldPath: filePath, newPath: targetPath, referenceFields,
109
125
  }),
110
126
  })), ...additionalUpdates],
111
- creations: options.creations ?? [],
127
+ creations,
112
128
  guards: options.guards ?? [],
113
129
  gitMove: tracked,
114
130
  gitIndex,
@@ -506,7 +522,7 @@ export async function runStatus(argv, config, opts = {}) {
506
522
  process.stdout.write(`${prefix} Would append Version History: - **${today}** Status: ${oldStatus ?? 'unknown'} → ${newStatus} — ${note}\n`);
507
523
  }
508
524
  process.stdout.write(`${prefix} ${toRepoPath(finalPath, config.repoRoot)}: ${oldStatus ?? 'unknown'} → ${newStatus}\n`);
509
- return;
525
+ return { dryRun: true, oldRepoPath: toRepoPath(filePath, config.repoRoot), newRepoPath: toRepoPath(finalPath, config.repoRoot), touched: [] };
510
526
  }
511
527
 
512
528
  const mutationResult = commitLifecycleMutation(filePath, targetPath, config, { status: newStatus, updated: today }, currentOld => {
@@ -516,6 +532,7 @@ export async function runStatus(argv, config, opts = {}) {
516
532
  createSection: Boolean(note),
517
533
  additionalUpdates: opts.additionalUpdates,
518
534
  creations: opts.creations,
535
+ guards: opts.guards,
519
536
  testHooks: opts.testHooks,
520
537
  });
521
538
 
@@ -845,7 +862,7 @@ export function runArchive(argv, config, opts = {}) {
845
862
  if (config.hooks?.onArchive) {
846
863
  out.write(`${prefix} Would fire hook: onArchive\n`);
847
864
  }
848
- return;
865
+ return { dryRun: true, oldRepoPath, newRepoPath, touched: [] };
849
866
  }
850
867
 
851
868
  const mutationResult = commitLifecycleMutation(filePath, targetPath, config, { status: targetStatus, updated: today },
@@ -1008,6 +1025,7 @@ export async function runSet(argv, config, opts = {}) {
1008
1025
  dryRun, note, archiveStatus: newStatus, force, testHooks: opts.testHooks, deferIndex: opts.deferIndex,
1009
1026
  additionalUpdates: opts.additionalUpdates,
1010
1027
  creations: opts.creations,
1028
+ guards: opts.guards,
1011
1029
  });
1012
1030
  }
1013
1031
 
@@ -1059,8 +1077,9 @@ export async function runSet(argv, config, opts = {}) {
1059
1077
  dryRun,
1060
1078
  suppressDeprecation: true,
1061
1079
  note,
1062
- additionalUpdates: releaseUpdate ? [releaseUpdate] : [],
1080
+ additionalUpdates: [...(opts.additionalUpdates ?? []), ...(releaseUpdate ? [releaseUpdate] : [])],
1063
1081
  creations: opts.creations,
1082
+ guards: opts.guards,
1064
1083
  testHooks: opts.testHooks,
1065
1084
  deferIndex: opts.deferIndex,
1066
1085
  });