dotmd-cli 0.87.2 → 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
@@ -131,7 +131,15 @@ runlist baton @/tmp/resume.md
131
131
  ```
132
132
 
133
133
  Baton refuses when a handoff for the same work is already pending, so one piece
134
- 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`.
135
143
 
136
144
  Saved prompts are local session state. Consume them with `runlist use`; inspect
137
145
  without consuming via `runlist prompts show`. Consuming a baton prompt also claims
package/bin/dotmd.mjs CHANGED
@@ -1271,13 +1271,21 @@ Options:
1271
1271
  --note "why" Append the reason to ## Version History (plan mode only)
1272
1272
  --message / --body Inline body (one-liners; prefer @path or stdin)
1273
1273
  --force Recover another session's plan (explicit path required)
1274
+ --replace Replace exactly one pending handoff; archive its prior text
1274
1275
  --json Structured repository/session/generated file result
1275
1276
  --dry-run, -n Preview without writing
1276
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
+
1277
1284
  Examples:
1278
1285
  runlist baton @/tmp/draft.md
1279
1286
  runlist baton checkout-fixes @/tmp/draft.md
1280
1287
  runlist baton docs/plans/auth.md @/tmp/draft.md
1288
+ runlist baton docs/plans/auth.md @/tmp/new-draft.md --replace
1281
1289
  runlist baton --status paused --note "blocked on review" @/tmp/d.md
1282
1290
  cat /tmp/draft.md | runlist baton
1283
1291
 
@@ -1296,11 +1304,13 @@ cooperating transaction:
1296
1304
  grant ownership. A live pickup-hook delivery lease blocks release and force
1297
1305
  takeover; hooks are at-least-once and deduplicate the stable operationId.
1298
1306
 
1299
- Slug mode (no plan involved) saves resume-<slug> and touches nothing else: no
1300
- 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.
1301
1309
 
1302
1310
  Baton saves nothing while a handoff for the same work is pending (resume-<name>,
1303
- 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.
1304
1314
  With no @file, \`-\` or --message, baton reads stdin only when something is piped
1305
1315
  in; an open pipe that sends nothing is given up on after a moment.`,
1306
1316
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.87.2",
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}\nInspect it with \`runlist prompts show ${slug}\`. If it is current, keep it and do not hand off again. If it is stale, run \`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/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
  });