dotmd-cli 0.69.0 → 0.70.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.
Files changed (54) hide show
  1. package/README.md +144 -964
  2. package/bin/dotmd.mjs +251 -202
  3. package/dotmd.config.example.mjs +5 -8
  4. package/package.json +6 -10
  5. package/src/agent-context.mjs +132 -0
  6. package/src/atomic-mutation.mjs +1505 -0
  7. package/src/baton.mjs +109 -114
  8. package/src/bulk-tag.mjs +7 -7
  9. package/src/check-collapse.mjs +2 -2
  10. package/src/commands.mjs +326 -12
  11. package/src/completions.mjs +38 -98
  12. package/src/config.mjs +18 -3
  13. package/src/diff.mjs +7 -3
  14. package/src/doctor.mjs +25 -15
  15. package/src/export.mjs +154 -25
  16. package/src/fix-refs.mjs +2 -0
  17. package/src/frontmatter-fix.mjs +9 -7
  18. package/src/frontmatter.mjs +3 -2
  19. package/src/git.mjs +722 -14
  20. package/src/graph.mjs +53 -25
  21. package/src/guard.mjs +163 -60
  22. package/src/hud.mjs +65 -76
  23. package/src/index-file.mjs +28 -16
  24. package/src/index.mjs +21 -13
  25. package/src/init.mjs +1 -1
  26. package/src/journal.mjs +145 -12
  27. package/src/lifecycle.mjs +596 -294
  28. package/src/lint.mjs +117 -56
  29. package/src/managed-path.mjs +192 -0
  30. package/src/migrate-prompts.mjs +2 -0
  31. package/src/migrate-template.mjs +2 -0
  32. package/src/migrate.mjs +7 -1
  33. package/src/new.mjs +135 -54
  34. package/src/output-identity.mjs +106 -0
  35. package/src/pickup-card.mjs +24 -10
  36. package/src/pickup.mjs +457 -0
  37. package/src/prompts.mjs +134 -75
  38. package/src/query.mjs +22 -10
  39. package/src/reference-planner.mjs +292 -0
  40. package/src/rename.mjs +65 -73
  41. package/src/render.mjs +24 -11
  42. package/src/runlist.mjs +109 -71
  43. package/src/section.mjs +2 -1
  44. package/src/ship.mjs +39 -20
  45. package/src/stats.mjs +1 -1
  46. package/src/status-metadata.mjs +87 -0
  47. package/src/statuses.mjs +11 -26
  48. package/src/summary.mjs +14 -3
  49. package/src/update.mjs +38 -10
  50. package/src/use.mjs +4 -1
  51. package/src/util.mjs +1 -0
  52. package/src/validate.mjs +53 -17
  53. package/src/watch.mjs +6 -1
  54. package/src/notion.mjs +0 -528
package/src/lifecycle.mjs CHANGED
@@ -1,19 +1,142 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
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, resolveRefPath, escapeRegex, nowIso, suggestCandidates, emitFilesFooter, isArchivedPath, currentSessionId } from './util.mjs';
4
+ import { asString, toRepoPath, die, warn, resolveDocPath, escapeRegex, nowIso, suggestCandidates, emitFilesFooter, isArchivedPath, currentSessionId } from './util.mjs';
5
5
  import { readJournalEntries } from './journal.mjs';
6
- import { gitMv, getGitLastModifiedBatch } from './git.mjs';
6
+ import { captureGitIndexGeneration, getGitLastModifiedBatch, getGitLastSubstantiveModifiedBatch, isTracked } from './git.mjs';
7
7
  import { buildIndex, collectDocFiles, resolveDocArg } from './index.mjs';
8
- import { renderIndexFile, writeIndex } from './index-file.mjs';
8
+ import { writeRenderedIndex } from './index-file.mjs';
9
9
  import { green, dim } from './color.mjs';
10
10
  import { isInteractive, promptChoice } from './prompt.mjs';
11
11
  import { buildCard, renderCard } from './pickup-card.mjs';
12
12
  import { walkSections, findSection } from './section.mjs';
13
+ import { authorizeManagedDestination, authorizeManagedSource, authorizeManagedSweep } from './managed-path.mjs';
14
+ import { withPathLocks, snapshotFile, replaceSnapshot, moveFileAtomic, mutateFile, mutateFileSet } from './atomic-mutation.mjs';
15
+ import { configuredReferenceFields, createReferenceIdentitySet, rewriteDocumentReferences } from './reference-planner.mjs';
16
+ import {
17
+ assertPlanMutationAuthorized,
18
+ assertHookDeliveryTakeoverSafe,
19
+ abandonClaimHookDelivery,
20
+ authoritativeSessionId,
21
+ availableSessionId,
22
+ beginClaimHookDelivery,
23
+ classifyPlanPickup,
24
+ commitPlanClaim,
25
+ finishClaimHookDelivery,
26
+ listOwnedPlans,
27
+ pickupFactsForDoc,
28
+ prepareOwnershipRelease,
29
+ readPlanOwnership,
30
+ skipClaimHookDelivery,
31
+ updateOwnershipOperation,
32
+ validatedClaimOperation,
33
+ } from './pickup.mjs';
34
+
35
+ export function renderLifecycleMutation(raw, updates, historyEntry, { createSection = false, bodyTransform = null } = {}) {
36
+ raw = normalizeEol(raw);
37
+ if (!raw.startsWith('---\n')) throw new Error('Document has no frontmatter block. Retrofit it with `dotmd bulk-tag` first.');
38
+ const endMarker = raw.indexOf('\n---\n', 4);
39
+ if (endMarker === -1) throw new Error('Document has an unclosed frontmatter block.');
40
+ let frontmatter = raw.slice(4, endMarker);
41
+ let body = raw.slice(endMarker + 5);
42
+ if (bodyTransform) body = bodyTransform(body);
43
+ for (const [key, value] of Object.entries(updates)) {
44
+ const regex = new RegExp(`^${escapeRegex(key)}:.*$`, 'm');
45
+ frontmatter = regex.test(frontmatter)
46
+ ? frontmatter.replace(regex, `${key}: ${value}`)
47
+ : `${frontmatter}\n${key}: ${value}`;
48
+ }
49
+ if (historyEntry) {
50
+ const bullet = `- **${updates.updated ?? nowIso()}** ${historyEntry}`;
51
+ const vh = findSection(walkSections(body), 'Version History');
52
+ if (vh) {
53
+ const lines = body.split('\n');
54
+ let insertAt = vh.lineStart;
55
+ while (insertAt < lines.length && lines[insertAt].trim() === '') insertAt++;
56
+ lines.splice(insertAt, 0, bullet, ...(insertAt >= lines.length || lines[insertAt]?.startsWith('#') ? [''] : []));
57
+ body = lines.join('\n');
58
+ } else if (createSection) {
59
+ body = `${body.replace(/\n+$/, '')}\n\n## Version History\n\n${bullet}\n`;
60
+ }
61
+ }
62
+ return `---\n${frontmatter}\n---\n${body}`;
63
+ }
13
64
 
14
- function findFileRoot(filePath, config) {
15
- const roots = config.docsRoots || [config.docsRoot];
16
- return roots.find(r => filePath.startsWith(r + '/')) ?? config.docsRoot;
65
+ function commitLifecycleMutation(filePath, targetPath, config, updates, historyForOldStatus, options = {}) {
66
+ const render = sourceContent => {
67
+ const currentFm = parseSimpleFrontmatter(extractFrontmatter(sourceContent).frontmatter);
68
+ const currentOldStatus = asString(currentFm.status) ?? 'unknown';
69
+ let content = renderLifecycleMutation(sourceContent, updates, historyForOldStatus(currentOldStatus), options);
70
+ const beforeSelfRefs = content;
71
+ if (targetPath) content = renderMovedFileRefs(content, filePath, targetPath, config);
72
+ return {
73
+ oldStatus: currentOldStatus,
74
+ content,
75
+ selfRefsFixed: content !== beforeSelfRefs,
76
+ };
77
+ };
78
+ if (targetPath) {
79
+ let result;
80
+ const tracked = isTracked(filePath, config.repoRoot);
81
+ const gitIndex = tracked ? captureGitIndexGeneration(config.repoRoot) : null;
82
+ const companionPaths = new Set((options.additionalUpdates ?? []).map(item => path.resolve(item.path)));
83
+ const allFiles = collectDocFiles(config).filter(candidate => candidate !== filePath && candidate !== targetPath && !companionPaths.has(path.resolve(candidate)));
84
+ authorizeManagedSweep(allFiles, config, { kind: 'Reference rewrite source' });
85
+ const identities = createReferenceIdentitySet([filePath, ...allFiles]);
86
+ const referenceFields = configuredReferenceFields(config);
87
+ const moveResult = moveFileAtomic(filePath, targetPath, sourceContent => {
88
+ result = render(sourceContent);
89
+ return result.content;
90
+ }, {
91
+ repoRoot: config.repoRoot,
92
+ config,
93
+ updates: [...allFiles.map(docFile => ({
94
+ path: docFile,
95
+ render: raw => rewriteDocumentReferences(raw, {
96
+ sourcePath: docFile, repoRoot: config.repoRoot, identities, oldPath: filePath, newPath: targetPath, referenceFields,
97
+ }),
98
+ })), ...(options.additionalUpdates ?? []).map(item => ({
99
+ ...item,
100
+ content: item.content === undefined ? undefined : rewriteDocumentReferences(item.content, {
101
+ sourcePath: item.path, repoRoot: config.repoRoot, identities, oldPath: filePath, newPath: targetPath, referenceFields,
102
+ }),
103
+ }))],
104
+ creations: options.creations ?? [],
105
+ gitMove: tracked,
106
+ gitIndex,
107
+ operation: 'lifecycle-move',
108
+ sessionId: availableSessionId(),
109
+ testHooks: options.testHooks,
110
+ });
111
+ return { ...result, sourceContent: moveResult.source.content, updatedPaths: moveResult.updatedPaths };
112
+ }
113
+ if ((options.additionalUpdates?.length ?? 0) > 0 || (options.creations?.length ?? 0) > 0) {
114
+ const sourceContent = readFileSync(filePath, 'utf8');
115
+ const result = render(sourceContent);
116
+ mutateFileSet({
117
+ updates: [{ path: filePath, expectedContent: sourceContent, content: result.content }, ...(options.additionalUpdates ?? [])],
118
+ creations: options.creations ?? [],
119
+ }, { repoRoot: config.repoRoot, testHooks: options.testHooks });
120
+ return { ...result, sourceContent, updatedPaths: [] };
121
+ }
122
+ return withPathLocks([filePath], { repoRoot: config.repoRoot }, () => {
123
+ const sourceSnapshot = snapshotFile(filePath);
124
+ const result = render(sourceSnapshot.content);
125
+ replaceSnapshot(sourceSnapshot, result.content, { repoRoot: config.repoRoot, locked: true });
126
+ return { ...result, sourceContent: sourceSnapshot.content, updatedPaths: [] };
127
+ });
128
+ }
129
+
130
+ export function updateFrontmatterAtomic(filePath, updates, config, options = {}) {
131
+ return mutateFile(filePath, { repoRoot: config.repoRoot }, raw => {
132
+ if (options.expected) {
133
+ const current = parseSimpleFrontmatter(extractFrontmatter(raw).frontmatter);
134
+ for (const [key, value] of Object.entries(options.expected)) {
135
+ if (asString(current[key]) !== value) throw new Error(`${key} changed while the mutation was being prepared.`);
136
+ }
137
+ }
138
+ return renderLifecycleMutation(raw, updates, null);
139
+ });
17
140
  }
18
141
 
19
142
  function defaultTypeDir(docType, config) {
@@ -49,20 +172,109 @@ function archiveBaseFor(filePath, fileRoot, docType, config) {
49
172
  // change that affects what would render leaves the index stale. Wrapped
50
173
  // in try/catch — a regen failure shouldn't undo the successful mutation,
51
174
  // only warn with the recovery command.
52
- export function regenIndex(config) {
53
- if (!config.indexPath) return;
175
+ export function regenIndex(config, options = {}) {
176
+ if (!config.indexPath) return false;
54
177
  try {
55
178
  // Fast path: skip validation/git-staleness/ref-checking — the rendered
56
179
  // index file only consumes status/title/snapshot/etc. Validation runs on
57
180
  // explicit `dotmd check` / `dotmd index`. This keeps lifecycle commands
58
181
  // snappy on repos with huge git history or heavy `validate` hooks.
59
- const index = buildIndex(config, { fast: true });
60
- writeIndex(renderIndexFile(index, config), config);
182
+ options.testHooks?.beforeClaimIndex?.();
183
+ writeRenderedIndex(() => buildIndex(config, { fast: true }), config, { testHooks: options.testHooks });
184
+ return true;
61
185
  } catch (err) {
186
+ if (options.throwOnError) throw err;
62
187
  warn(`Could not regenerate index (run \`dotmd index\`): ${err.message}`);
188
+ return false;
189
+ }
190
+ }
191
+
192
+ function reconcileClaimOperation(repoPath, config, opts = {}) {
193
+ let current = validatedClaimOperation(repoPath, config);
194
+ if (!current) return { indexRegenerated: false, ownershipChanged: false, hook: 'none', pending: false };
195
+ let indexRegenerated = false;
196
+ let ownershipChanged = false;
197
+ const binding = current.binding;
198
+ if (current.operation.index === 'pending') {
199
+ regenIndex(config, { throwOnError: true, testHooks: opts.testHooks });
200
+ indexRegenerated = true;
201
+ updateOwnershipOperation(repoPath, config, binding, op => { op.index = 'done'; });
202
+ ownershipChanged = true;
203
+ current = validatedClaimOperation(repoPath, config, binding);
204
+ if (!current) throw new Error(`Claim completion was superseded for ${repoPath}.`);
205
+ }
206
+ if (['pending', 'delivering'].includes(current.operation.hook)) {
207
+ if (current.operation.hook === 'pending' && !config.hooks.onPickup) {
208
+ updateOwnershipOperation(repoPath, config, binding, op => {
209
+ op.hook = 'skipped';
210
+ delete op.hookDeliveryToken;
211
+ delete op.hookDeliveryStartedAt;
212
+ delete op.hookDeliveryOwner;
213
+ });
214
+ return { indexRegenerated, ownershipChanged: true, hook: 'skipped', pending: false };
215
+ }
216
+ // Durable outbox contract: retries reuse operationId until the hook returns.
217
+ // Hooks that perform external side effects must deduplicate by operationId.
218
+ // No local protocol can distinguish a crash after the external side effect
219
+ // from a crash before the completion marker is persisted.
220
+ opts.testHooks?.beforeClaimHookInvoke?.({ repoPath, binding });
221
+ const lease = beginClaimHookDelivery(repoPath, config, binding, {
222
+ now: opts.hookLeaseNow,
223
+ leaseMs: opts.hookLeaseMs,
224
+ ownerLiveness: opts.hookOwnerLiveness,
225
+ });
226
+ if (!lease) return { indexRegenerated, ownershipChanged, hook: current.operation.hook, pending: true };
227
+ if (lease.busy) throw new Error(`Claim hook delivery is already in progress for ${repoPath}.`);
228
+ if (!config.hooks.onPickup) {
229
+ skipClaimHookDelivery(repoPath, config, binding, lease.token);
230
+ return { indexRegenerated, ownershipChanged: true, hook: 'skipped', pending: false };
231
+ }
232
+ try {
233
+ const operation = lease.operation;
234
+ const event = { path: repoPath, oldStatus: operation.oldStatus, newStatus: 'in-session', operationId: operation.id };
235
+ config.hooks.onPickup(event);
236
+ } catch (err) {
237
+ abandonClaimHookDelivery(repoPath, config, binding, lease.token);
238
+ throw err;
239
+ }
240
+ finishClaimHookDelivery(repoPath, config, binding, lease.token);
241
+ ownershipChanged = true;
242
+ }
243
+ const after = validatedClaimOperation(repoPath, config, binding);
244
+ return { indexRegenerated, ownershipChanged, hook: after?.operation?.hook ?? 'done', pending: Boolean(after && (after.operation.index === 'pending' || ['pending', 'delivering'].includes(after.operation.hook))) };
245
+ }
246
+
247
+ export function completePlanClaim(repoPath, config, opts = {}) {
248
+ const current = validatedClaimOperation(repoPath, config);
249
+ let skipped = false;
250
+ if (opts.noIndex && current) {
251
+ updateOwnershipOperation(repoPath, config, current.binding, op => { op.index = 'skipped'; });
252
+ skipped = true;
253
+ }
254
+ const result = reconcileClaimOperation(repoPath, config, opts);
255
+ if (skipped) result.ownershipChanged = true;
256
+ return result;
257
+ }
258
+
259
+ export function ensurePlanCompletionBeforeRelease(repoPath, config, opts = {}) {
260
+ const before = validatedClaimOperation(repoPath, config);
261
+ if (!before) return;
262
+ reconcileClaimOperation(repoPath, config, opts);
263
+ const after = validatedClaimOperation(repoPath, config, before.binding);
264
+ if (after && (after.operation.index === 'pending' || ['pending', 'delivering'].includes(after.operation.hook))) {
265
+ throw new Error(`Cannot release ${repoPath}; claim completion is still pending.`);
63
266
  }
64
267
  }
65
268
 
269
+ export function planHasPendingCompletion(repoPath, config) {
270
+ const current = validatedClaimOperation(repoPath, config);
271
+ return Boolean(current && (current.operation.index === 'pending' || ['pending', 'delivering'].includes(current.operation.hook)));
272
+ }
273
+
274
+ export function pickupCandidates(index, config, sessionId) {
275
+ return index.docs.filter(doc => pickupFactsForDoc(doc, config, { sessionId }).pickupable);
276
+ }
277
+
66
278
  // Pick an archive destination that won't clobber an existing record. If
67
279
  // `<dir>/<basename>` is free, returns it unchanged; otherwise appends a
68
280
  // numeric suffix (`-2`, `-3`, …) so the slug → path mapping stays readable
@@ -145,14 +357,16 @@ export async function runStatus(argv, config, opts = {}) {
145
357
 
146
358
  if (!input) { die('Usage: dotmd status <file> <new-status>'); }
147
359
 
148
- const filePath = resolveDocArg(input, config);
360
+ let filePath = resolveDocArg(input, config);
361
+ const sourceAuthorization = authorizeManagedSource(filePath, config, { kind: 'Status source' });
362
+ filePath = sourceAuthorization.path;
149
363
 
150
364
  // Determine type-specific or root-specific valid statuses
151
365
  const raw = readFileSync(filePath, 'utf8');
152
366
  const { frontmatter: fmRaw } = extractFrontmatter(raw);
153
367
  const parsedFm = parseSimpleFrontmatter(fmRaw);
154
368
  const docType = asString(parsedFm.type) ?? null;
155
- const fileRoot = findFileRoot(filePath, config);
369
+ const fileRoot = sourceAuthorization.root.lexicalPath;
156
370
  const rootLabel = path.relative(config.repoRoot, fileRoot).split(path.sep).join('/');
157
371
 
158
372
  // Build effective valid status set: type > root > global
@@ -182,9 +396,24 @@ export async function runStatus(argv, config, opts = {}) {
182
396
  die(`Invalid status: ${newStatus}\nValid: ${[...effectiveValid].join(', ')}${hint}`);
183
397
  }
184
398
 
399
+ if (!opts.suppressDeprecation) {
400
+ const delegated = [newStatus, input];
401
+ if (note) delegated.push('--note', note);
402
+ if (noIndex) delegated.push('--no-index');
403
+ if (showFiles) delegated.push('--show-files');
404
+ if (argv.includes('--force')) delegated.push('--force');
405
+ return runSet(delegated, config, { ...opts, suppressDeprecation: true });
406
+ }
407
+
185
408
  const oldStatus = asString(parsedFm.status);
186
409
 
187
410
  if (oldStatus === newStatus) {
411
+ if (!dryRun && (opts.additionalUpdates?.length || opts.creations?.length)) {
412
+ mutateFileSet({ updates: opts.additionalUpdates ?? [], creations: opts.creations ?? [] }, {
413
+ repoRoot: config.repoRoot,
414
+ testHooks: opts.testHooks,
415
+ });
416
+ }
188
417
  process.stdout.write(`${toRepoPath(filePath, config.repoRoot)}: already ${newStatus}, no changes made.\n`);
189
418
  return;
190
419
  }
@@ -212,30 +441,42 @@ export async function runStatus(argv, config, opts = {}) {
212
441
  const currentBucket = relSegments.length > 1 ? relSegments[0] : null;
213
442
  const isFiling = !isArchiving && !isUnarchiving && newFiledDir && currentBucket !== newFiledDir;
214
443
  const isUnfiling = !isArchiving && !isUnarchiving && !newFiledDir && oldFiledDir && currentBucket === oldFiledDir;
215
- let finalPath = filePath;
444
+ const authorizeTarget = targetPath => authorizeManagedDestination(targetPath, config, {
445
+ root: sourceAuthorization.root,
446
+ kind: 'Status move destination',
447
+ }).path;
448
+ let targetDir = null;
449
+ let targetPath = null;
450
+ if (isArchiving) {
451
+ targetDir = archiveDir;
452
+ targetPath = authorizeTarget(uniqueArchiveTarget(targetDir, path.basename(filePath)));
453
+ } else if (isUnarchiving) {
454
+ targetPath = authorizeTarget(path.join(archiveBase, path.basename(filePath)));
455
+ } else if (isFiling) {
456
+ targetDir = path.join(filingRoot, newFiledDir);
457
+ targetPath = authorizeTarget(path.join(targetDir, path.basename(filePath)));
458
+ } else if (isUnfiling) {
459
+ targetPath = authorizeTarget(path.join(filingRoot, path.basename(filePath)));
460
+ }
461
+ if (targetPath && !isArchiving && existsSync(targetPath)) {
462
+ die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`);
463
+ }
464
+ let finalPath = targetPath ?? filePath;
216
465
 
217
466
  if (dryRun) {
218
467
  const prefix = dim('[dry-run]');
219
468
  process.stdout.write(`${prefix} Would update frontmatter: status: ${oldStatus ?? 'unknown'} → ${newStatus}, updated: ${today}\n`);
220
469
  if (isArchiving) {
221
- const targetPath = uniqueArchiveTarget(archiveDir, path.basename(filePath));
222
470
  process.stdout.write(`${prefix} Would move: ${toRepoPath(filePath, config.repoRoot)} → ${toRepoPath(targetPath, config.repoRoot)}\n`);
223
- finalPath = targetPath;
224
471
  }
225
472
  if (isUnarchiving) {
226
- const targetPath = path.join(archiveBase, path.basename(filePath));
227
473
  process.stdout.write(`${prefix} Would move: ${toRepoPath(filePath, config.repoRoot)} → ${toRepoPath(targetPath, config.repoRoot)}\n`);
228
- finalPath = targetPath;
229
474
  }
230
475
  if (isFiling) {
231
- const targetPath = path.join(filingRoot, newFiledDir, path.basename(filePath));
232
476
  process.stdout.write(`${prefix} Would file: ${toRepoPath(filePath, config.repoRoot)} → ${toRepoPath(targetPath, config.repoRoot)}\n`);
233
- finalPath = targetPath;
234
477
  }
235
478
  if (isUnfiling) {
236
- const targetPath = path.join(filingRoot, path.basename(filePath));
237
479
  process.stdout.write(`${prefix} Would unfile: ${toRepoPath(filePath, config.repoRoot)} → ${toRepoPath(targetPath, config.repoRoot)}\n`);
238
- finalPath = targetPath;
239
480
  }
240
481
  if (finalPath !== filePath) {
241
482
  const refCount = countRefsToUpdate(filePath, finalPath, config);
@@ -251,45 +492,15 @@ export async function runStatus(argv, config, opts = {}) {
251
492
  return;
252
493
  }
253
494
 
254
- updateFrontmatter(filePath, { status: newStatus, updated: today });
255
- const transition = `Status: ${oldStatus ?? 'unknown'} → ${newStatus}`;
256
- // A --note must land even when the doc has no Version History section yet;
257
- // the plain transition entry stays best-effort (bare docs skip it).
258
- appendVersionHistory(filePath, note ? `${transition} — ${note}` : `${transition}.`, { createSection: Boolean(note) });
259
-
260
- if (isArchiving) {
261
- mkdirSync(archiveDir, { recursive: true });
262
- const targetPath = uniqueArchiveTarget(archiveDir, path.basename(filePath));
263
- const result = gitMv(filePath, targetPath, config.repoRoot);
264
- if (result.status !== 0) { die(result.stderr || 'git mv failed.'); }
265
- finalPath = targetPath;
266
- }
267
-
268
- if (isUnarchiving) {
269
- const targetPath = path.join(archiveBase, path.basename(filePath));
270
- if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); }
271
- const result = gitMv(filePath, targetPath, config.repoRoot);
272
- if (result.status !== 0) { die(result.stderr || 'git mv failed.'); }
273
- finalPath = targetPath;
274
- }
275
-
276
- if (isFiling) {
277
- const targetDir = path.join(filingRoot, newFiledDir);
278
- mkdirSync(targetDir, { recursive: true });
279
- const targetPath = path.join(targetDir, path.basename(filePath));
280
- if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); }
281
- const result = gitMv(filePath, targetPath, config.repoRoot);
282
- if (result.status !== 0) { die(result.stderr || 'git mv failed.'); }
283
- finalPath = targetPath;
284
- }
285
-
286
- if (isUnfiling) {
287
- const targetPath = path.join(filingRoot, path.basename(filePath));
288
- if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); }
289
- const result = gitMv(filePath, targetPath, config.repoRoot);
290
- if (result.status !== 0) { die(result.stderr || 'git mv failed.'); }
291
- finalPath = targetPath;
292
- }
495
+ const mutationResult = commitLifecycleMutation(filePath, targetPath, config, { status: newStatus, updated: today }, currentOld => {
496
+ const currentTransition = `Status: ${currentOld} → ${newStatus}`;
497
+ return note ? `${currentTransition} — ${note}` : `${currentTransition}.`;
498
+ }, {
499
+ createSection: Boolean(note),
500
+ additionalUpdates: opts.additionalUpdates,
501
+ creations: opts.creations,
502
+ testHooks: opts.testHooks,
503
+ });
293
504
 
294
505
  // Any of the four moves above shifts the file's directory, which breaks
295
506
  // relative refs in both directions — links FROM the moved file and inbound
@@ -297,15 +508,9 @@ export async function runStatus(argv, config, opts = {}) {
297
508
  // the deprecated `dotmd status <file> archived` path and the `dotmd set`
298
509
  // unarchive/file/unfile transitions (which route through runStatus, not
299
510
  // runArchive) don't silently leave dangling links.
300
- let selfRefsFixed = false;
301
- let inboundRefCount = 0;
302
- let inboundRefPaths = [];
303
- if (finalPath !== filePath) {
304
- selfRefsFixed = updateRefsFromMovedFile(filePath, finalPath, config) > 0;
305
- const inbound = updateRefsAfterMove(filePath, finalPath, config);
306
- inboundRefCount = inbound.count;
307
- inboundRefPaths = inbound.paths;
308
- }
511
+ const selfRefsFixed = Boolean(mutationResult.selfRefsFixed);
512
+ const inboundRefPaths = mutationResult.updatedPaths ?? [];
513
+ const inboundRefCount = inboundRefPaths.length;
309
514
 
310
515
  // Regen the index on every status change — `active → planned` etc. drift
311
516
  // the per-status sections just as much as archive crossings. Archive paths
@@ -314,7 +519,7 @@ export async function runStatus(argv, config, opts = {}) {
314
519
  // each other's uncommitted index changes into the staging area.
315
520
  if (noIndex) {
316
521
  process.stderr.write(dim('(index not regenerated — run `dotmd index` to refresh)\n'));
317
- } else {
522
+ } else if (!opts.deferIndex) {
318
523
  regenIndex(config);
319
524
  }
320
525
 
@@ -326,7 +531,7 @@ export async function runStatus(argv, config, opts = {}) {
326
531
  const touched = [filePath];
327
532
  if (finalPath !== filePath) touched.push(finalPath);
328
533
  touched.push(...inboundRefPaths);
329
- if (config.indexPath && !noIndex) touched.push(config.indexPath);
534
+ if (config.indexPath && !noIndex && !opts.deferIndex) touched.push(config.indexPath);
330
535
  emitFilesFooter(touched, config);
331
536
  }
332
537
 
@@ -334,27 +539,32 @@ export async function runStatus(argv, config, opts = {}) {
334
539
  oldPath: toRepoPath(filePath, config.repoRoot),
335
540
  newPath: toRepoPath(finalPath, config.repoRoot),
336
541
  }); } catch (err) { warn(`Hook 'onStatusChange' threw: ${err.message}`); }
542
+ return {
543
+ action: 'status-changed',
544
+ oldRepoPath: toRepoPath(filePath, config.repoRoot),
545
+ newRepoPath: toRepoPath(finalPath, config.repoRoot),
546
+ touched: [toRepoPath(filePath, config.repoRoot), ...(finalPath !== filePath ? [toRepoPath(finalPath, config.repoRoot)] : []), ...inboundRefPaths.map(item => toRepoPath(item, config.repoRoot))],
547
+ };
337
548
  }
338
549
 
339
- // Open a plan for work: flip its frontmatter status to `in-session` and print
340
- // its card (body + related + next steps). No lease, no claiming — just a
341
- // status write. Backs `dotmd use <plan>` and `dotmd runlist next`.
550
+ // Atomically claim a plan for this session, then reconcile its generated index
551
+ // and pickup hook before printing the card. Backs direct use and next selectors.
342
552
  export async function startPlan(argv, config, opts = {}) {
343
553
  const { dryRun } = opts;
344
554
  const json = argv.includes('--json');
345
555
  const fullBody = argv.includes('--full');
346
556
  const noIndex = argv.includes('--no-index') || opts.noIndex;
347
557
  const showFiles = argv.includes('--show-files') || opts.showFiles;
558
+ const force = argv.includes('--force') || opts.force;
348
559
  let input = argv.find(a => !a.startsWith('-'));
349
560
 
350
561
  // Interactive: pick from active/planned plans
351
562
  if (!input) {
352
563
  if (!isInteractive()) die('Usage: dotmd use <plan>');
353
564
  const index = buildIndex(config);
354
- const candidates = index.docs.filter(d =>
355
- d.type === 'plan' && (d.status === 'active' || d.status === 'planned')
356
- );
357
- if (candidates.length === 0) die('No active/planned plans.');
565
+ const sessionId = authoritativeSessionId();
566
+ const candidates = pickupCandidates(index, config, sessionId);
567
+ if (candidates.length === 0) die('No pickup-able plans.');
358
568
  const labelFor = (d) => `${d.title} (${d.status}) — ${d.path}`;
359
569
  const choice = await promptChoice('Pick a plan:', candidates.map(labelFor));
360
570
  if (!choice) die('No plan selected.');
@@ -363,41 +573,87 @@ export async function startPlan(argv, config, opts = {}) {
363
573
  input = candidates[idx].path;
364
574
  }
365
575
 
366
- const filePath = resolveDocArg(input, config);
576
+ let filePath = resolveDocArg(input, config);
577
+ filePath = authorizeManagedSource(filePath, config, { kind: 'Plan start source' }).path;
367
578
 
368
579
  const raw = readFileSync(filePath, 'utf8');
369
- const { frontmatter: fmRaw, body } = extractFrontmatter(raw);
370
- const parsedFm = parseSimpleFrontmatter(fmRaw);
580
+ let fmRaw, body, parsedFm;
581
+ try {
582
+ ({ frontmatter: fmRaw, body } = extractFrontmatter(raw));
583
+ if (!fmRaw) throw new Error('missing frontmatter');
584
+ parsedFm = parseSimpleFrontmatter(fmRaw);
585
+ } catch {
586
+ die(`Malformed plan document: ${toRepoPath(filePath, config.repoRoot)}`);
587
+ }
371
588
  const docType = asString(parsedFm.type) ?? null;
372
589
  const oldStatus = asString(parsedFm.status);
373
590
  const title = asString(parsedFm.title) ?? path.basename(filePath, '.md');
374
591
  const repoPath = toRepoPath(filePath, config.repoRoot);
375
592
 
376
- if (docType && docType !== 'plan') warn(`${repoPath} has type '${docType}', not 'plan'.`);
377
-
378
- if (oldStatus === 'blocked') {
379
- const blockers = parsedFm.blockers ? (Array.isArray(parsedFm.blockers) ? parsedFm.blockers.join(', ') : String(parsedFm.blockers)) : 'unknown';
380
- die(`Plan is blocked: ${blockers}\n ${repoPath}`);
593
+ const sessionId = dryRun ? (availableSessionId() ?? '__dry-run-no-session__') : authoritativeSessionId();
594
+ const ownership = readPlanOwnership(repoPath, config);
595
+ if (force) assertHookDeliveryTakeoverSafe(ownership, {
596
+ now: opts.hookLeaseNow,
597
+ leaseMs: opts.hookLeaseMs,
598
+ ownerLiveness: opts.hookOwnerLiveness,
599
+ });
600
+ let disposition = classifyPlanPickup({
601
+ type: docType,
602
+ status: oldStatus,
603
+ validStatuses: config.typeStatuses?.get('plan') ?? config.validStatuses,
604
+ startableStatuses: config.lifecycle.startableStatuses,
605
+ terminalStatuses: config.lifecycle.terminalStatuses,
606
+ archiveStatuses: config.lifecycle.archiveStatuses,
607
+ physicallyArchived: isArchivedPath(repoPath, config),
608
+ ownership,
609
+ sessionId,
610
+ malformed: false,
611
+ });
612
+ if (force && ['busy', 'ownership-corrupt'].includes(disposition.kind)) {
613
+ disposition = classifyPlanPickup({
614
+ type: docType, status: oldStatus,
615
+ validStatuses: config.typeStatuses?.get('plan') ?? config.validStatuses,
616
+ startableStatuses: config.lifecycle.startableStatuses,
617
+ terminalStatuses: config.lifecycle.terminalStatuses,
618
+ archiveStatuses: config.lifecycle.archiveStatuses,
619
+ physicallyArchived: isArchivedPath(repoPath, config), ownership: null, sessionId, malformed: false,
620
+ });
621
+ }
622
+ if (!disposition.pickupable) {
623
+ const detail = disposition.kind === 'busy' ? ` (owned by ${disposition.owner})` : '';
624
+ die(`Plan cannot be picked up: ${disposition.kind}${detail}\n ${repoPath}`);
381
625
  }
382
-
383
626
  const today = nowIso();
384
627
  if (dryRun) {
385
- if (oldStatus === 'in-session') {
628
+ if (disposition.kind === 'resume') {
386
629
  process.stderr.write(`${dim('[dry-run]')} Already in-session: ${repoPath}\n`);
630
+ } else if (disposition.kind === 'adopt') {
631
+ process.stderr.write(`${dim('[dry-run]')} Would adopt unowned in-session plan: ${repoPath}\n`);
387
632
  } else {
388
633
  process.stderr.write(`${dim('[dry-run]')} Would update: status: ${oldStatus} → in-session, updated: ${today}\n`);
389
634
  }
390
- } else if (oldStatus !== 'in-session') {
391
- updateFrontmatter(filePath, { status: 'in-session', updated: today });
392
- appendVersionHistory(filePath, `Started (${oldStatus ?? 'unknown'} → in-session).`);
635
+ } else if (disposition.kind !== 'resume') {
636
+ const history = opts.note
637
+ ? `Started (${oldStatus} → in-session) — ${opts.note}`
638
+ : `Started (${oldStatus} → in-session).`;
639
+ const rendered = disposition.kind === 'start'
640
+ ? renderLifecycleMutation(raw, { status: 'in-session', updated: today }, history, { createSection: true })
641
+ : null;
642
+ commitPlanClaim({ filePath, repoPath, sourceContent: raw, renderedContent: rendered,
643
+ ownership, sessionId, now: today, config, testHooks: opts.testHooks });
644
+ }
645
+
646
+ if (!dryRun) {
393
647
  if (noIndex) {
394
- process.stderr.write(dim('(index not regenerated — run `dotmd index` to refresh)\n'));
395
- } else {
396
- regenIndex(config);
648
+ const current = validatedClaimOperation(repoPath, config);
649
+ if (current) updateOwnershipOperation(repoPath, config, current.binding, op => { op.index = 'skipped'; });
397
650
  }
651
+ reconcileClaimOperation(repoPath, config, { ...opts, oldStatus });
398
652
  }
399
653
 
400
- if (json) {
654
+ if (opts.quiet) {
655
+ // Prompt consume owns the user-facing output; the transition is identical.
656
+ } else if (json) {
401
657
  const card = buildCard(filePath, raw, config);
402
658
  process.stdout.write(JSON.stringify({
403
659
  path: repoPath, oldStatus, newStatus: 'in-session', title,
@@ -417,21 +673,22 @@ export async function startPlan(argv, config, opts = {}) {
417
673
  }
418
674
  }
419
675
 
420
- if (showFiles && oldStatus !== 'in-session') {
676
+ if (showFiles && disposition.kind === 'start') {
421
677
  const touched = [filePath];
422
- if (config.indexPath && !noIndex) touched.push(config.indexPath);
678
+ if (config.indexPath && !noIndex && !opts.deferIndex) touched.push(config.indexPath);
423
679
  emitFilesFooter(touched, config);
424
680
  }
425
681
 
426
- try { config.hooks.onPickup?.({ path: repoPath, oldStatus, newStatus: 'in-session' }); } catch (err) { warn(`Hook 'onPickup' threw: ${err.message}`); }
682
+ return { path: repoPath, oldStatus, disposition: disposition.kind };
427
683
  }
428
684
 
429
685
  export function runArchive(argv, config, opts = {}) {
430
686
  const { dryRun, out = process.stdout } = opts;
687
+ const force = argv.includes('--force') || opts.force;
431
688
  const noIndex = argv.includes('--no-index') || opts.noIndex;
432
689
  const showFiles = argv.includes('--show-files') || opts.showFiles;
433
690
  const closeoutTemplate = argv.includes('--closeout-template');
434
- argv = argv.filter(a => a !== '--no-index' && a !== '--show-files' && a !== '--closeout-template');
691
+ argv = argv.filter(a => a !== '--no-index' && a !== '--show-files' && a !== '--closeout-template' && a !== '--force');
435
692
  let note = opts.note ?? null;
436
693
  const noteIdx = argv.indexOf('--note');
437
694
  if (noteIdx !== -1) {
@@ -443,9 +700,11 @@ export function runArchive(argv, config, opts = {}) {
443
700
 
444
701
  if (!input) { die('Usage: dotmd archive <file>'); }
445
702
 
446
- const filePath = resolveDocArg(input, config);
703
+ let filePath = resolveDocArg(input, config);
704
+ const sourceAuthorization = authorizeManagedSource(filePath, config, { kind: 'Archive source' });
705
+ filePath = sourceAuthorization.path;
447
706
 
448
- const archiveFileRoot = findFileRoot(filePath, config);
707
+ const archiveFileRoot = sourceAuthorization.root.lexicalPath;
449
708
  const relFromRoot = path.relative(archiveFileRoot, filePath);
450
709
  // Segment-membership covers both single-root (`<root>/archived/foo.md`) and
451
710
  // multi-root (`<type-root>/archived/foo.md`) layouts. The older
@@ -457,6 +716,19 @@ export function runArchive(argv, config, opts = {}) {
457
716
  const { frontmatter, body } = extractFrontmatter(raw);
458
717
  const parsed = parseSimpleFrontmatter(frontmatter);
459
718
  const oldStatus = asString(parsed.status) ?? 'unknown';
719
+ const archiveRepoPath = toRepoPath(filePath, config.repoRoot);
720
+ const archiveOwnership = asString(parsed.type) === 'plan' ? readPlanOwnership(archiveRepoPath, config) : null;
721
+ const releasingOwnership = asString(parsed.type) === 'plan'
722
+ && (oldStatus === 'in-session' || archiveOwnership?.state === 'owned' || archiveOwnership?.corrupt);
723
+ const releaseSessionId = releasingOwnership ? authoritativeSessionId() : null;
724
+ if (releasingOwnership) assertPlanMutationAuthorized(archiveRepoPath, config, { sessionId: releaseSessionId, force });
725
+ if (releasingOwnership && !dryRun) ensurePlanCompletionBeforeRelease(archiveRepoPath, config, { testHooks: opts.testHooks });
726
+ if (releasingOwnership && dryRun && planHasPendingCompletion(archiveRepoPath, config)) {
727
+ out.write(`${dim('[dry-run]')} Pending claim completion would block this release.\n`);
728
+ }
729
+ const releaseUpdate = releasingOwnership
730
+ ? prepareOwnershipRelease(archiveRepoPath, config, { sessionId: releaseSessionId, force })
731
+ : null;
460
732
 
461
733
  // Preserve a configured custom archive status (e.g. `done` with archive:true)
462
734
  // when one is threaded through from `dotmd set <archive-status>`. Fall back to
@@ -484,13 +756,18 @@ export function runArchive(argv, config, opts = {}) {
484
756
  out.write(`${prefix} Would skip git mv (file already under \`${config.archiveDir}/\`)\n`);
485
757
  return;
486
758
  }
487
- updateFrontmatter(filePath, { status: targetStatus, updated: today });
488
- const healEntry = `Archived (frontmatter healed in place from \`${oldStatus}\`)${note ? ` — ${note}` : '.'}`;
489
- appendVersionHistory(filePath, healEntry, { createSection: Boolean(note) });
490
- if (!noIndex) regenIndex(config);
759
+ commitLifecycleMutation(filePath, null, config, { status: targetStatus, updated: today },
760
+ currentOld => `Archived (frontmatter healed in place from \`${currentOld}\`)${note ? ` — ${note}` : '.'}`,
761
+ {
762
+ createSection: Boolean(note),
763
+ additionalUpdates: [...(opts.additionalUpdates ?? []), ...(releaseUpdate ? [releaseUpdate] : [])],
764
+ creations: opts.creations,
765
+ testHooks: opts.testHooks,
766
+ });
767
+ if (!noIndex && !opts.deferIndex) regenIndex(config);
491
768
  out.write(`${green('✓ Healed')}: ${repoPathHeal} (${oldStatus} → ${targetStatus}; file already under \`${config.archiveDir}/\`)\n`);
492
769
  const touched = [repoPathHeal];
493
- if (config.indexPath && !noIndex) touched.push(config.indexPath);
770
+ if (config.indexPath && !noIndex && !opts.deferIndex) touched.push(config.indexPath);
494
771
  if (showFiles) emitFilesFooter(touched, config);
495
772
  return {
496
773
  action: 'healed',
@@ -501,12 +778,16 @@ export function runArchive(argv, config, opts = {}) {
501
778
  }
502
779
 
503
780
  const closeoutAction = closeoutTemplate ? planCloseoutInjection(body) : null;
781
+ let committedCloseoutAction = closeoutAction;
504
782
 
505
783
  const today = nowIso();
506
784
  // Type-aware: prompts archive under docs/prompts/archived/ by default (see
507
785
  // archiveBaseFor); plans/docs keep the shared <root>/archived/.
508
786
  const targetDir = path.join(archiveBaseFor(filePath, archiveFileRoot, asString(parsed.type), config), config.archiveDir);
509
- const targetPath = uniqueArchiveTarget(targetDir, path.basename(filePath));
787
+ const targetPath = authorizeManagedDestination(uniqueArchiveTarget(targetDir, path.basename(filePath)), config, {
788
+ root: sourceAuthorization.root,
789
+ kind: 'Archive destination',
790
+ }).path;
510
791
  const oldRepoPath = toRepoPath(filePath, config.repoRoot);
511
792
  const newRepoPath = toRepoPath(targetPath, config.repoRoot);
512
793
 
@@ -522,7 +803,7 @@ export function runArchive(argv, config, opts = {}) {
522
803
  out.write(`${prefix} Would append Version History: - **${today}** Archived — ${note}\n`);
523
804
  }
524
805
  out.write(`${prefix} Would move: ${oldRepoPath} → ${newRepoPath}\n`);
525
- if (config.indexPath && !noIndex) out.write(`${prefix} Would regenerate index\n`);
806
+ if (config.indexPath && !noIndex && !opts.deferIndex) out.write(`${prefix} Would regenerate index\n`);
526
807
  if (config.indexPath && noIndex) out.write(`${prefix} Would skip index regen (--no-index)\n`);
527
808
 
528
809
  // Preview reference updates
@@ -538,39 +819,37 @@ export function runArchive(argv, config, opts = {}) {
538
819
  return;
539
820
  }
540
821
 
541
- if (closeoutAction?.action === 'inject') {
542
- writeFileSync(filePath, `---\n${frontmatter}\n---\n${closeoutAction.newBody}`, 'utf8');
543
- }
544
-
545
- updateFrontmatter(filePath, { status: targetStatus, updated: today });
546
- appendVersionHistory(filePath, note ? `Archived — ${note}` : 'Archived.', { createSection: Boolean(note) });
547
-
548
- mkdirSync(targetDir, { recursive: true });
549
-
550
- const result = gitMv(filePath, targetPath, config.repoRoot);
551
- if (result.status !== 0) { die(result.stderr || 'git mv failed.'); }
552
-
553
- // Fix refs FROM the archived file (relative paths shifted by move)
554
- const selfRefsFixed = updateRefsFromMovedFile(filePath, targetPath, config);
822
+ const mutationResult = commitLifecycleMutation(filePath, targetPath, config, { status: targetStatus, updated: today },
823
+ () => note ? `Archived — ${note}` : 'Archived.', {
824
+ createSection: Boolean(note),
825
+ testHooks: opts.testHooks,
826
+ additionalUpdates: [...(opts.additionalUpdates ?? []), ...(releaseUpdate ? [releaseUpdate] : [])],
827
+ creations: opts.creations,
828
+ bodyTransform: closeoutTemplate ? currentBody => {
829
+ committedCloseoutAction = planCloseoutInjection(currentBody);
830
+ return committedCloseoutAction.action === 'inject' ? committedCloseoutAction.newBody : currentBody;
831
+ } : null,
832
+ });
555
833
 
556
- // Auto-update references in other docs
557
- const { count: updatedRefCount, paths: refTouchedPaths } = updateRefsAfterMove(filePath, targetPath, config);
834
+ const selfRefsFixed = mutationResult.selfRefsFixed;
835
+ const refTouchedPaths = mutationResult.updatedPaths;
836
+ const updatedRefCount = refTouchedPaths.length;
558
837
 
559
- if (!noIndex) regenIndex(config);
838
+ const indexRegenerated = !noIndex && !opts.deferIndex ? regenIndex(config) : false;
560
839
 
561
840
  out.write(`${green('Archived')}: ${oldRepoPath} → ${newRepoPath}\n`);
562
- if (closeoutAction?.action === 'inject') {
841
+ if (committedCloseoutAction?.action === 'inject') {
563
842
  out.write(`Injected \`## Closeout\` template — fill in: outcomes, key commits, deferrals.\n`);
564
- } else if (closeoutAction?.action === 'skip') {
843
+ } else if (committedCloseoutAction?.action === 'skip') {
565
844
  out.write(dim('(closeout template skipped — `## Closeout` section already present)\n'));
566
845
  }
567
846
  if (selfRefsFixed) out.write('Updated references in archived file.\n');
568
847
  if (updatedRefCount > 0) out.write(`Updated references in ${updatedRefCount} file(s).\n`);
569
- if (config.indexPath && !noIndex) out.write('Index regenerated.\n');
848
+ if (config.indexPath && indexRegenerated) out.write('Index regenerated.\n');
570
849
  if (config.indexPath && noIndex) out.write(dim('(index not regenerated — run `dotmd index` to refresh)\n'));
571
850
 
572
851
  const touched = [oldRepoPath, newRepoPath, ...refTouchedPaths];
573
- if (config.indexPath && !noIndex) touched.push(config.indexPath);
852
+ if (config.indexPath && indexRegenerated) touched.push(config.indexPath);
574
853
  if (showFiles) emitFilesFooter(touched, config);
575
854
 
576
855
  try { config.hooks.onArchive?.({ path: newRepoPath, oldStatus }, { oldPath: oldRepoPath, newPath: newRepoPath }); } catch (err) { warn(`Hook 'onArchive' threw: ${err.message}`); }
@@ -580,6 +859,10 @@ export function runArchive(argv, config, opts = {}) {
580
859
  oldRepoPath,
581
860
  newRepoPath,
582
861
  touched,
862
+ referencePaths: refTouchedPaths.map(item => toRepoPath(item, config.repoRoot)),
863
+ indexRegenerated,
864
+ consumedBody: extractFrontmatter(mutationResult.sourceContent).body,
865
+ consumedFrontmatter: parseSimpleFrontmatter(extractFrontmatter(mutationResult.sourceContent).frontmatter),
583
866
  };
584
867
  }
585
868
 
@@ -587,18 +870,17 @@ export function runArchive(argv, config, opts = {}) {
587
870
  // signature — `dotmd set <status> [<path>]` — and dispatches to the right
588
871
  // plumbing based on the *target* status:
589
872
  // - target in archiveStatuses (and file not already archived) → runArchive
590
- // (gets us ref-fixing + auto lease release + closeout-template offer)
873
+ // (gets us ref-fixing + atomic ownership release + closeout-template offer)
591
874
  // - source = in-session, target != in-session → runStatus +
592
- // auto-release of the held lease (so users don't have to chain `release`)
875
+ // atomic release of the ownership record
593
876
  // - everything else (incl. unarchive, plain transitions) → runStatus
594
877
  //
595
- // Path is inferred from the calling session's held lease when omitted. With
596
- // zero leases or >1 leases, we refuse and ask for explicit `<path>` instead
878
+ // Path is inferred from the calling session's valid ownership record when omitted. With
879
+ // zero records, ambiguity, or corruption, we refuse and ask for explicit `<path>` instead
597
880
  // of guessing.
598
881
  //
599
- // `dotmd set in-session <path>` is refused — acquiring a lease is asymmetric
600
- // enough to deserve its own verb (`dotmd pickup`), and silently routing here
601
- // would skip the lease-acquisition path entirely.
882
+ // `dotmd set in-session <path>` routes through the exact same claim transition
883
+ // as `dotmd use`, including history, ownership, index, and hook completion.
602
884
 
603
885
  // Did THIS session already hand off via `dotmd baton`? The journal records the
604
886
  // top-level argv of every invocation; a successful `baton …` means a resume
@@ -621,7 +903,8 @@ export async function runSet(argv, config, opts = {}) {
621
903
  const { dryRun } = opts;
622
904
  const noIndex = argv.includes('--no-index');
623
905
  const showFiles = argv.includes('--show-files');
624
- argv = argv.filter(a => a !== '--no-index' && a !== '--show-files');
906
+ const force = argv.includes('--force') || opts.force;
907
+ argv = argv.filter(a => a !== '--no-index' && a !== '--show-files' && a !== '--force');
625
908
  let note = opts.note ?? null;
626
909
  const noteIdx = argv.indexOf('--note');
627
910
  if (noteIdx !== -1) {
@@ -631,12 +914,43 @@ export async function runSet(argv, config, opts = {}) {
631
914
  }
632
915
 
633
916
  const newStatus = argv[0];
634
- const input = argv[1];
917
+ let input = argv[1];
635
918
 
636
- if (!newStatus) die('Usage: dotmd set <status> <path>');
637
- if (!input) die('Usage: dotmd set <status> <path>');
919
+ if (!newStatus) die('Usage: dotmd set <status> [<path>]');
920
+ let sessionId = null;
921
+ if (!input) {
922
+ sessionId = authoritativeSessionId();
923
+ const owned = listOwnedPlans(config, sessionId);
924
+ if (owned.length !== 1 || owned.diagnostics?.length) {
925
+ const diagnostics = owned.diagnostics?.length ? `\nIgnored ownership records:\n${owned.diagnostics.map(d => ` ${d}`).join('\n')}` : '';
926
+ die(`No-target set requires exactly one valid in-session plan owned by this session; found ${owned.length}. Pass an explicit path.${diagnostics}`);
927
+ }
928
+ input = owned[0].plan;
929
+ }
638
930
 
639
- const filePath = resolveDocArg(input, config);
931
+ let filePath = resolveDocArg(input, config);
932
+ filePath = authorizeManagedSource(filePath, config, { kind: 'Set source' }).path;
933
+ const repoPath = toRepoPath(filePath, config.repoRoot);
934
+
935
+ if (newStatus === 'in-session') {
936
+ const args = [filePath];
937
+ if (noIndex) args.push('--no-index');
938
+ if (showFiles) args.push('--show-files');
939
+ if (force) args.push('--force');
940
+ return startPlan(args, config, { ...opts, force, note });
941
+ }
942
+
943
+ let oldFm = null;
944
+ try { oldFm = parseSimpleFrontmatter(extractFrontmatter(readFileSync(filePath, 'utf8')).frontmatter); } catch { /* runStatus reports malformed docs */ }
945
+ const oldOwnership = asString(oldFm?.type) === 'plan' ? readPlanOwnership(repoPath, config) : null;
946
+ const releasing = asString(oldFm?.type) === 'plan'
947
+ && (asString(oldFm?.status) === 'in-session' || oldOwnership?.state === 'owned' || oldOwnership?.corrupt);
948
+ if (releasing) {
949
+ sessionId ??= authoritativeSessionId();
950
+ assertPlanMutationAuthorized(repoPath, config, { sessionId, force });
951
+ if (!dryRun) ensurePlanCompletionBeforeRelease(repoPath, config, { testHooks: opts.testHooks });
952
+ else if (planHasPendingCompletion(repoPath, config)) process.stderr.write(`${dim('[dry-run]')} Pending claim completion would block this release.\n`);
953
+ }
640
954
 
641
955
  const inArchive = isArchivedPath(toRepoPath(filePath, config.repoRoot), config);
642
956
 
@@ -647,7 +961,11 @@ export async function runSet(argv, config, opts = {}) {
647
961
  // Preserve the exact target status — a config may name its archive status
648
962
  // `done` (with archive:true) rather than `archived`. Without this, runArchive
649
963
  // would silently rewrite it to `archived`.
650
- return runArchive(archiveArgs, config, { dryRun, note, archiveStatus: newStatus });
964
+ return runArchive(archiveArgs, config, {
965
+ dryRun, note, archiveStatus: newStatus, force, testHooks: opts.testHooks, deferIndex: opts.deferIndex,
966
+ additionalUpdates: opts.additionalUpdates,
967
+ creations: opts.creations,
968
+ });
651
969
  }
652
970
 
653
971
  // Two advisory reminders, both computed from one pre-transition frontmatter
@@ -691,7 +1009,18 @@ export async function runSet(argv, config, opts = {}) {
691
1009
  const statusArgs = [filePath, newStatus];
692
1010
  if (noIndex) statusArgs.push('--no-index');
693
1011
  if (showFiles) statusArgs.push('--show-files');
694
- await runStatus(statusArgs, config, { dryRun, suppressDeprecation: true, note });
1012
+ const releaseUpdate = releasing
1013
+ ? prepareOwnershipRelease(repoPath, config, { sessionId, force })
1014
+ : null;
1015
+ const result = await runStatus(statusArgs, config, {
1016
+ dryRun,
1017
+ suppressDeprecation: true,
1018
+ note,
1019
+ additionalUpdates: releaseUpdate ? [releaseUpdate] : [],
1020
+ creations: opts.creations,
1021
+ testHooks: opts.testHooks,
1022
+ deferIndex: opts.deferIndex,
1023
+ });
695
1024
 
696
1025
  if (!dryRun) {
697
1026
  if (partialReminder) {
@@ -701,10 +1030,12 @@ export async function runSet(argv, config, opts = {}) {
701
1030
  warn(`wrapping up? leave a baton so the next session picks up cleanly — \`dotmd baton ${path.basename(filePath, '.md')} @draft\` saves a resume prompt (no copy-paste into chat).`);
702
1031
  }
703
1032
  }
1033
+ return result;
704
1034
  }
705
1035
 
706
1036
  export function runBulkArchive(argv, config, opts = {}) {
707
1037
  const { dryRun } = opts;
1038
+ const json = argv.includes('--json');
708
1039
  const noIndex = argv.includes('--no-index') || opts.noIndex;
709
1040
  const showFiles = argv.includes('--show-files') || opts.showFiles;
710
1041
  const inputs = argv.filter(a => !a.startsWith('-'));
@@ -726,34 +1057,48 @@ export function runBulkArchive(argv, config, opts = {}) {
726
1057
 
727
1058
  const unique = [...new Set(matched)].filter(f => !isArchivedPath(toRepoPath(f, config.repoRoot), config));
728
1059
  if (unique.length === 0) die('No matching files found (already-archived files are excluded).');
1060
+ authorizeManagedSweep(unique, config, { kind: 'Bulk archive source' });
729
1061
 
730
- process.stdout.write(`${unique.length} file(s) to archive:\n`);
731
- for (const f of unique) {
732
- process.stdout.write(` ${toRepoPath(f, config.repoRoot)}\n`);
1062
+ if (!json) {
1063
+ process.stdout.write(`${unique.length} file(s) to archive (independent per-item transactions):\n`);
1064
+ for (const f of unique) process.stdout.write(` ${toRepoPath(f, config.repoRoot)}\n`);
733
1065
  }
734
1066
 
735
1067
  if (dryRun) {
736
- process.stdout.write(dim('\n[dry-run] No changes made.\n'));
737
- return;
1068
+ const result = { operation: 'bulk-archive', atomicity: 'per-item', dryRun: true, items: unique.map(file => ({ path: toRepoPath(file, config.repoRoot), result: 'would-archive' })) };
1069
+ if (json) process.stdout.write(JSON.stringify(result, null, 2) + '\n');
1070
+ else process.stdout.write(dim('\n[dry-run] No changes made.\n'));
1071
+ return result;
738
1072
  }
739
1073
 
740
- process.stdout.write('\n');
1074
+ if (!json) process.stdout.write('\n');
741
1075
  // Bulk archives always defer index regen to the end — N individual regens
742
1076
  // is wasteful and the final state is the same. `--no-index` skips even
743
1077
  // the final one.
744
1078
  const bulkTouched = [];
1079
+ const items = [];
1080
+ let indexRegenerated = false;
1081
+ let indexError = null;
1082
+ let succeeded = 0;
1083
+ const originalStdoutWrite = process.stdout.write;
1084
+ if (json) process.stdout.write = () => true;
1085
+ try {
745
1086
  for (const f of unique) {
746
1087
  const relPath = toRepoPath(f, config.repoRoot);
747
1088
  try {
748
1089
  const result = runArchive([relPath], config, { ...opts, noIndex: true, showFiles: false });
749
1090
  if (result?.touched) bulkTouched.push(...result.touched);
1091
+ items.push({ path: relPath, result: 'archived', newPath: result?.newRepoPath ?? null, repositoryFiles: result?.touched ?? [] });
750
1092
  } catch (err) {
751
- warn(`Failed to archive ${relPath}: ${err.message}`);
1093
+ items.push({ path: relPath, result: 'failed', error: err.message });
1094
+ if (!json) warn(`Failed to archive ${relPath}: ${err.message}`);
752
1095
  }
753
1096
  }
754
- if (!noIndex) {
755
- regenIndex(config);
756
- if (config.indexPath) process.stdout.write('Index regenerated.\n');
1097
+ succeeded = items.filter(item => item.result === 'archived').length;
1098
+ if (!noIndex && succeeded > 0) {
1099
+ try { indexRegenerated = regenIndex(config, { throwOnError: true, testHooks: opts.testHooks }); }
1100
+ catch (err) { indexError = err.message; }
1101
+ if (config.indexPath && indexRegenerated) process.stdout.write('Index regenerated.\n');
757
1102
  } else if (config.indexPath) {
758
1103
  process.stdout.write(dim('(index not regenerated — run `dotmd index` to refresh)\n'));
759
1104
  }
@@ -762,6 +1107,25 @@ export function runBulkArchive(argv, config, opts = {}) {
762
1107
  if (config.indexPath && !noIndex) all.push(config.indexPath);
763
1108
  emitFilesFooter(all, config);
764
1109
  }
1110
+ } finally {
1111
+ if (json) process.stdout.write = originalStdoutWrite;
1112
+ }
1113
+ const normalize = item => path.isAbsolute(item) ? toRepoPath(item, config.repoRoot) : item.split(path.sep).join('/');
1114
+ for (const item of items) if (item.repositoryFiles) item.repositoryFiles = [...new Set(item.repositoryFiles.map(normalize))];
1115
+ const deferredGeneratedFiles = config.indexPath && succeeded > 0 && (noIndex || indexError) ? [toRepoPath(config.indexPath, config.repoRoot)] : [];
1116
+ const result = {
1117
+ operation: 'bulk-archive', atomicity: 'per-item', items,
1118
+ repositoryFiles: [...new Set(bulkTouched.map(normalize))],
1119
+ generatedFiles: config.indexPath && indexRegenerated ? [toRepoPath(config.indexPath, config.repoRoot)] : [],
1120
+ deferredGeneratedFiles,
1121
+ index: !config.indexPath ? { status: 'not-configured' }
1122
+ : indexRegenerated ? { status: 'generated', path: toRepoPath(config.indexPath, config.repoRoot) }
1123
+ : indexError ? { status: 'failed', path: toRepoPath(config.indexPath, config.repoRoot), error: indexError }
1124
+ : { status: noIndex ? 'deferred' : 'skipped', path: toRepoPath(config.indexPath, config.repoRoot) },
1125
+ };
1126
+ if (json) process.stdout.write(JSON.stringify(result, null, 2) + '\n');
1127
+ if (items.some(item => item.result === 'failed') || indexError) process.exitCode = 1;
1128
+ return result;
765
1129
  }
766
1130
 
767
1131
  export function runTouch(argv, config, opts = {}) {
@@ -773,38 +1137,80 @@ export function runTouch(argv, config, opts = {}) {
773
1137
  if (argv[i].startsWith('-')) continue;
774
1138
  positional.push(argv[i]);
775
1139
  }
1140
+ const inputs = positional;
776
1141
  const input = positional[0];
777
1142
 
778
1143
  // --git mode: bulk-sync frontmatter dates from git history
779
1144
  if (useGit) {
780
- const allFiles = input ? [resolveDocArg(input, config)] : collectDocFiles(config);
781
-
782
- const prefix = dryRun ? dim('[dry-run] ') : '';
783
- let synced = 0;
784
- const gitDates = getGitLastModifiedBatch(config.repoRoot);
1145
+ const allFiles = inputs.length > 0
1146
+ ? [...new Set(inputs.map(item => resolveDocArg(item, config)))]
1147
+ : collectDocFiles(config);
1148
+ authorizeManagedSweep(allFiles, config, { kind: 'Touch --git source' });
785
1149
 
1150
+ const records = [];
786
1151
  for (const filePath of allFiles) {
787
- const repoPath = toRepoPath(filePath, config.repoRoot);
788
1152
  const raw = readFileSync(filePath, 'utf8');
789
1153
  const { frontmatter } = extractFrontmatter(raw);
790
1154
  if (!frontmatter) continue;
791
-
792
1155
  const parsed = parseSimpleFrontmatter(frontmatter);
793
- const status = asString(parsed.status);
794
- if (config.lifecycle.skipStaleFor.has(status)) continue;
1156
+ if (config.lifecycle.skipStaleFor.has(asString(parsed.status))) continue;
1157
+ records.push({
1158
+ filePath,
1159
+ repoPath: toRepoPath(filePath, config.repoRoot),
1160
+ fmUpdated: asString(parsed.updated),
1161
+ });
1162
+ }
795
1163
 
796
- const fmUpdated = asString(parsed.updated);
797
- const gitDate = gitDates.get(repoPath) ?? null;
1164
+ const prefix = dryRun ? dim('[dry-run] ') : '';
1165
+ let synced = 0;
1166
+ const repoPaths = records.map(item => item.repoPath);
1167
+ const rootPathspecs = inputs.length > 0 ? null : (config.docsRoots || [config.docsRoot])
1168
+ .map(root => toRepoPath(root, config.repoRoot) || '.');
1169
+ const gitMetadata = getGitLastModifiedBatch(config.repoRoot, repoPaths, {
1170
+ ...(rootPathspecs ? { pathspecs: rootPathspecs } : {}),
1171
+ ...opts.gitMetadataOptions,
1172
+ });
1173
+ if (!gitMetadata.complete) {
1174
+ die(`Cannot touch from incomplete Git metadata (${gitMetadata.reason}); no files were changed.`);
1175
+ }
1176
+ const candidates = [];
1177
+ for (const { filePath, repoPath, fmUpdated } of records) {
1178
+ const gitDate = gitMetadata.dates.get(repoPath) ?? null;
798
1179
  if (!gitDate) continue;
799
-
800
1180
  const gitDay = gitDate.slice(0, 10);
801
1181
  if (fmUpdated === gitDay) continue;
802
-
803
1182
  // Only sync if git is newer than frontmatter (compare date strings)
804
1183
  if (fmUpdated && fmUpdated >= gitDay) continue;
1184
+ candidates.push({ filePath, repoPath, fmUpdated });
1185
+ }
1186
+
1187
+ const candidatePaths = candidates.map(item => item.repoPath);
1188
+ const substantiveMetadata = getGitLastSubstantiveModifiedBatch(config.repoRoot, candidatePaths, {
1189
+ dates: new Map(candidatePaths.filter(p => gitMetadata.dates.has(p)).map(p => [p, gitMetadata.dates.get(p)])),
1190
+ commits: new Map(candidatePaths.filter(p => gitMetadata.commits?.has(p)).map(p => [p, gitMetadata.commits.get(p)])),
1191
+ history: new Map(candidatePaths.filter(p => gitMetadata.history?.has(p)).map(p => [p, gitMetadata.history.get(p)])),
1192
+ complete: gitMetadata.complete,
1193
+ reason: gitMetadata.reason,
1194
+ }, opts.gitMetadataOptions);
1195
+ if (!substantiveMetadata.complete) {
1196
+ die(`Cannot touch from incomplete Git metadata (${substantiveMetadata.reason}); no files were changed.`);
1197
+ }
1198
+
1199
+ for (const { filePath, repoPath, fmUpdated } of candidates) {
1200
+ const gitDate = substantiveMetadata.dates.get(repoPath) ?? null;
1201
+ if (!gitDate) continue;
1202
+ const gitDay = gitDate.slice(0, 10);
1203
+ if (fmUpdated && fmUpdated >= gitDay) continue;
805
1204
 
806
1205
  if (!dryRun) {
807
- updateFrontmatter(filePath, { updated: gitDay });
1206
+ const result = mutateFile(filePath, { repoRoot: config.repoRoot, testHooks: opts.testHooks }, current => {
1207
+ const currentFm = parseSimpleFrontmatter(extractFrontmatter(current).frontmatter);
1208
+ if (config.lifecycle.skipStaleFor.has(asString(currentFm.status))) return current;
1209
+ const currentUpdated = asString(currentFm.updated);
1210
+ if (currentUpdated && currentUpdated >= gitDay) return current;
1211
+ return renderLifecycleMutation(current, { updated: gitDay }, null);
1212
+ });
1213
+ if (!result.changed) continue;
808
1214
  }
809
1215
  process.stdout.write(`${prefix}${green('Synced')}: ${repoPath} (updated → ${gitDay})\n`);
810
1216
  synced++;
@@ -818,9 +1224,11 @@ export function runTouch(argv, config, opts = {}) {
818
1224
  return;
819
1225
  }
820
1226
 
1227
+ if (inputs.length > 1) die('Multiple files require `dotmd touch --git <file...>`.');
821
1228
  if (!input) { die('Usage: dotmd touch <file>\n dotmd touch --git Bulk-sync dates from git history'); }
822
1229
 
823
- const filePath = resolveDocArg(input, config);
1230
+ let filePath = resolveDocArg(input, config);
1231
+ filePath = authorizeManagedSource(filePath, config, { kind: 'Touch source' }).path;
824
1232
 
825
1233
  const today = nowIso();
826
1234
 
@@ -829,7 +1237,7 @@ export function runTouch(argv, config, opts = {}) {
829
1237
  return;
830
1238
  }
831
1239
 
832
- updateFrontmatter(filePath, { updated: today });
1240
+ mutateFile(filePath, { repoRoot: config.repoRoot, testHooks: opts.testHooks }, current => renderLifecycleMutation(current, { updated: today }, null));
833
1241
  process.stdout.write(`${green('Touched')}: ${toRepoPath(filePath, config.repoRoot)} (updated → ${today})\n`);
834
1242
 
835
1243
  try { config.hooks.onTouch?.({ path: toRepoPath(filePath, config.repoRoot) }, { path: toRepoPath(filePath, config.repoRoot), date: today }); } catch (err) { warn(`Hook 'onTouch' threw: ${err.message}`); }
@@ -846,136 +1254,30 @@ export function runTouch(argv, config, opts = {}) {
846
1254
  // `docs/plans/../archived/child.md`; and could corrupt a `grandchild.md` ref
847
1255
  // when archiving `child.md` (suffix match). oldPath no longer exists on disk
848
1256
  // post-`git mv`, so existsSync-based resolveRefPath can't be used here.
849
- function rewriteFrontmatterRefs(fm, docDir, oldPath, newPath, repoRoot) {
850
- // Exclude [ ] , from the token so flow-array elements (`refs: [a.md, b.md]`)
851
- // match individually rather than swallowing the bracket and failing to resolve.
852
- return fm.replace(/[^\s"'<>:[\],]+\.md\b/g, (token) => {
853
- const docRelAbs = path.resolve(docDir, token);
854
- const repoRelAbs = path.resolve(repoRoot, token);
855
- if (docRelAbs !== oldPath && repoRelAbs !== oldPath) return token;
856
- return path.relative(docDir, newPath).split(path.sep).join('/');
857
- });
858
- }
859
-
860
- /**
861
- * After a file moves (archive/unarchive), update frontmatter references in all
862
- * docs that pointed to the old location so they point to the new one.
863
- */
864
- function updateRefsAfterMove(oldPath, newPath, config) {
865
- const basename = path.basename(oldPath);
866
- const allFiles = collectDocFiles(config);
867
- const touched = [];
868
-
869
- for (const docFile of allFiles) {
870
- if (docFile === newPath) continue;
871
- const raw = readFileSync(docFile, 'utf8');
872
- if (!raw.includes(basename)) continue;
873
- const { frontmatter: fm, body } = extractFrontmatter(raw);
874
- if (!fm) continue;
875
-
876
- const docDir = path.dirname(docFile);
877
- const newFm = rewriteFrontmatterRefs(fm, docDir, oldPath, newPath, config.repoRoot);
878
-
879
- // Body markdown links [text](path.md) or [text](path.md#anchor) pointing
880
- // at oldPath. resolveRefPath can't be used here: oldPath no longer exists
881
- // on disk (git mv already ran), so its existsSync probe would fail. Match
882
- // by resolving the href manually and comparing absolute paths instead.
883
- const linkRegex = /(\[[^\]]*\]\()([^)#]+\.md)(#[^)]*)?(\))/g;
884
- const newBody = body.replace(linkRegex, (match, pre, href, frag, post) => {
885
- if (/^https?:/i.test(href)) return match;
886
- const docRelAbs = path.resolve(docDir, href);
887
- const repoRelAbs = path.resolve(config.repoRoot, href);
888
- if (docRelAbs !== oldPath && repoRelAbs !== oldPath) return match;
889
- const newHref = path.relative(docDir, newPath).split(path.sep).join('/');
890
- return `${pre}${newHref}${frag ?? ''}${post}`;
891
- });
892
-
893
- if (newFm !== fm || newBody !== body) {
894
- writeFileSync(docFile, `---\n${newFm}\n---\n${newBody}`, 'utf8');
895
- touched.push(docFile);
896
- }
897
- }
898
-
899
- return { count: touched.length, paths: touched };
900
- }
901
-
902
- function updateRefsFromMovedFile(oldPath, newPath, config) {
903
- const oldDir = path.dirname(oldPath);
904
- const newDir = path.dirname(newPath);
905
- if (oldDir === newDir) return 0;
906
-
907
- let raw = readFileSync(newPath, 'utf8');
908
- const { frontmatter, body } = extractFrontmatter(raw);
909
-
910
- // Fix frontmatter ref fields (YAML list items like - ./path.md).
911
- // Resolve doc-relative first, then repo-root-relative — so a ref like
912
- // `docs/foo/bar.md` written from any nesting level gets rewritten correctly
913
- // when the source moves. Without the repo-root fallback, repo-relative refs
914
- // silently skipped rewriting (existsSync on the doubled doc-relative path
915
- // returned false).
916
- // Token-based: rewrite every `*.md` path that resolved to a real file from
917
- // the old location, regardless of YAML shape — block-sequence list items
918
- // (` - ./path.md`), inline scalars (`parent_plan: hub.md`), and flow arrays
919
- // (`related_plans: [a.md, b.md]`). Quotes sit outside the matched token, so
920
- // `"./path.md"` rewrites in place. Mirrors rewriteFrontmatterRefs (inbound).
921
- let newFm = frontmatter;
922
- newFm = newFm.replace(/[^\s"'<>:[\],]+\.md\b/g, (token) => {
923
- const absTarget = resolveRefPath(token, oldDir, config.repoRoot);
924
- if (!absTarget) return token;
925
- return path.relative(newDir, absTarget).split(path.sep).join('/');
926
- });
927
-
928
- // Fix body markdown links [text](path.md) and [text](path.md#anchor) — the
929
- // trailing fragment is preserved across the rewrite.
930
- let newBody = body;
931
- const linkRegex = /(\[[^\]]*\]\()([^)#]+\.md)(#[^)]*)?(\))/g;
932
- newBody = newBody.replace(linkRegex, (match, pre, href, frag, post) => {
933
- if (/^https?:/i.test(href)) return match;
934
- const absTarget = resolveRefPath(href, oldDir, config.repoRoot);
935
- if (!absTarget) return match;
936
- const newHref = path.relative(newDir, absTarget).split(path.sep).join('/');
937
- return `${pre}${newHref}${frag ?? ''}${post}`;
1257
+ function renderMovedFileRefs(raw, oldPath, newPath, config) {
1258
+ const files = collectDocFiles(config);
1259
+ return rewriteDocumentReferences(raw, {
1260
+ sourcePath: oldPath,
1261
+ outputPath: newPath,
1262
+ repoRoot: config.repoRoot,
1263
+ identities: createReferenceIdentitySet(files),
1264
+ referenceFields: configuredReferenceFields(config),
1265
+ oldPath,
1266
+ newPath,
1267
+ rebaseAll: true,
938
1268
  });
939
-
940
- if (newFm !== frontmatter || newBody !== body) {
941
- writeFileSync(newPath, `---\n${newFm}\n---\n${newBody}`, 'utf8');
942
- return 1;
943
- }
944
-
945
- return 0;
946
1269
  }
947
1270
 
948
1271
  function countRefsToUpdate(oldPath, newPath, config) {
949
- const basename = path.basename(oldPath);
950
1272
  const allFiles = collectDocFiles(config);
951
- let count = 0;
952
-
953
- for (const docFile of allFiles) {
954
- if (docFile === newPath) continue;
1273
+ const identities = createReferenceIdentitySet(allFiles);
1274
+ return allFiles.filter(docFile => {
1275
+ if (docFile === oldPath || docFile === newPath) return false;
955
1276
  const raw = readFileSync(docFile, 'utf8');
956
- if (!raw.includes(basename)) continue;
957
- const { frontmatter: fm, body } = extractFrontmatter(raw);
958
- if (!fm) continue;
959
-
960
- const docDir = path.dirname(docFile);
961
- const fmHit = rewriteFrontmatterRefs(fm, docDir, oldPath, newPath, config.repoRoot) !== fm;
962
-
963
- let bodyHit = false;
964
- if (!fmHit) {
965
- const linkRegex = /\[[^\]]*\]\(([^)#]+\.md)(?:#[^)]*)?\)/g;
966
- for (const match of body.matchAll(linkRegex)) {
967
- const href = match[1];
968
- if (/^https?:/i.test(href)) continue;
969
- const docRelAbs = path.resolve(docDir, href);
970
- const repoRelAbs = path.resolve(config.repoRoot, href);
971
- if (docRelAbs === oldPath || repoRelAbs === oldPath) { bodyHit = true; break; }
972
- }
973
- }
974
-
975
- if (fmHit || bodyHit) count++;
976
- }
977
-
978
- return count;
1277
+ return rewriteDocumentReferences(raw, {
1278
+ sourcePath: docFile, repoRoot: config.repoRoot, identities, oldPath, newPath, referenceFields: configuredReferenceFields(config),
1279
+ }) !== raw;
1280
+ }).length;
979
1281
  }
980
1282
 
981
1283
  // Append a one-line dated bullet to the file's `## Version History` section.