dotmd-cli 0.68.0 → 0.70.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +144 -964
  2. package/bin/dotmd.mjs +241 -197
  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 -87
  8. package/src/bulk-tag.mjs +7 -7
  9. package/src/commands.mjs +326 -12
  10. package/src/completions.mjs +38 -98
  11. package/src/config.mjs +18 -3
  12. package/src/diff.mjs +7 -3
  13. package/src/doctor.mjs +12 -5
  14. package/src/export.mjs +154 -25
  15. package/src/fix-refs.mjs +2 -0
  16. package/src/frontmatter-fix.mjs +2 -0
  17. package/src/frontmatter.mjs +3 -2
  18. package/src/git.mjs +531 -14
  19. package/src/graph.mjs +53 -25
  20. package/src/guard.mjs +163 -60
  21. package/src/hud.mjs +65 -76
  22. package/src/index-file.mjs +28 -16
  23. package/src/index.mjs +17 -12
  24. package/src/init.mjs +1 -1
  25. package/src/journal.mjs +145 -12
  26. package/src/lifecycle.mjs +554 -282
  27. package/src/lint.mjs +57 -9
  28. package/src/managed-path.mjs +192 -0
  29. package/src/migrate-prompts.mjs +2 -0
  30. package/src/migrate-template.mjs +2 -0
  31. package/src/migrate.mjs +7 -1
  32. package/src/new.mjs +135 -54
  33. package/src/output-identity.mjs +106 -0
  34. package/src/pickup-card.mjs +24 -10
  35. package/src/pickup.mjs +457 -0
  36. package/src/prompts.mjs +138 -32
  37. package/src/query.mjs +22 -10
  38. package/src/reference-planner.mjs +292 -0
  39. package/src/rename.mjs +65 -73
  40. package/src/render.mjs +17 -8
  41. package/src/runlist.mjs +109 -71
  42. package/src/section.mjs +2 -1
  43. package/src/ship.mjs +39 -20
  44. package/src/stats.mjs +1 -1
  45. package/src/status-metadata.mjs +87 -0
  46. package/src/statuses.mjs +11 -26
  47. package/src/summary.mjs +14 -3
  48. package/src/update.mjs +38 -10
  49. package/src/use.mjs +4 -1
  50. package/src/util.mjs +1 -0
  51. package/src/validate.mjs +14 -6
  52. package/src/watch.mjs +6 -1
  53. package/src/notion.mjs +0 -528
package/src/prompts.mjs CHANGED
@@ -1,12 +1,20 @@
1
- import { readFileSync, statSync } from 'node:fs';
1
+ import { readFileSync, existsSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
4
  import { asString, toRepoPath, die, resolveDocPath, isArchivedPath } from './util.mjs';
5
- import { buildIndex } from './index.mjs';
5
+ import { buildIndex, resolveDocArg } from './index.mjs';
6
6
  import { runQuery } from './query.mjs';
7
- import { runArchive, runStatus } from './lifecycle.mjs';
7
+ import { completePlanClaim, regenIndex, renderLifecycleMutation, runArchive, runStatus } from './lifecycle.mjs';
8
8
  import { runNew } from './new.mjs';
9
9
  import { green, dim } from './color.mjs';
10
+ import { authorizeManagedSource } from './managed-path.mjs';
11
+ import {
12
+ authoritativeSessionId,
13
+ classifyPlanPickup,
14
+ preparePlanClaim,
15
+ readPlanOwnership,
16
+ } from './pickup.mjs';
17
+ import { actionablePromptStatuses, comparePromptDocs } from './status-metadata.mjs';
10
18
 
11
19
  // `resume` is an alias for `use` — agents reach for "resume" when continuing a
12
20
  // session; `use` reads as internal mechanics. Both names stay valid; the
@@ -125,10 +133,10 @@ function slugToPlanPath(s, config) {
125
133
  // Resolve a markdown body link relative to the prompt's location so e.g.
126
134
  // `../plans/foo.md` from docs/prompts/x.md → docs/plans/foo.md.
127
135
  function resolveBodyLink(link, promptRepoPath) {
128
- const cleaned = link.replace(/#.*$/, '');
136
+ const cleaned = link.replace(/#.*$/, '').replaceAll('\\', '/');
129
137
  if (cleaned.startsWith('/')) return cleaned.replace(/^\/+/, '');
130
- const promptDir = path.dirname(promptRepoPath);
131
- return path.normalize(path.join(promptDir, cleaned));
138
+ const promptDir = path.posix.dirname(promptRepoPath.replaceAll('\\', '/'));
139
+ return path.posix.normalize(path.posix.join(promptDir, cleaned));
132
140
  }
133
141
 
134
142
  function renderPromptsVerbose(index, config, { hasStatusFlag, includeArchived }) {
@@ -158,36 +166,27 @@ function renderPromptsVerbose(index, config, { hasStatusFlag, includeArchived })
158
166
  }
159
167
 
160
168
  export function pendingPromptsOldestFirst(config) {
161
- const index = buildIndex(config);
169
+ const index = buildIndex(config, { fast: true, invokeHooks: false });
170
+ const actionable = actionablePromptStatuses(config);
162
171
  const prompts = index.docs.filter(d =>
163
172
  d.type === 'prompt'
164
- && d.status === 'pending'
173
+ && actionable.has(d.status)
165
174
  && !isArchivedPath(d.path, config),
166
175
  );
167
176
 
168
177
  return prompts
169
- .map(d => {
170
- const abs = resolveDocPath(d.path, config);
171
- let mtime = 0;
172
- try { mtime = abs ? statSync(abs).mtimeMs : 0; } catch { mtime = 0; }
173
- return { doc: d, abs, created: d.created ?? '', mtime };
174
- })
175
- .sort((a, b) => {
176
- if (a.created && b.created && a.created !== b.created) return a.created.localeCompare(b.created);
177
- if (a.created && !b.created) return -1;
178
- if (!a.created && b.created) return 1;
179
- return a.mtime - b.mtime;
180
- });
178
+ .sort(comparePromptDocs)
179
+ .map(d => ({ doc: d, abs: resolveDocPath(d.path, config), created: d.created ?? '' }));
181
180
  }
182
181
 
183
- function runPromptsNext(argv, config, opts = {}) {
182
+ async function runPromptsNext(argv, config, opts = {}) {
184
183
  const queue = pendingPromptsOldestFirst(config);
185
184
  if (queue.length === 0) {
186
185
  die('No pending prompts.');
187
186
  }
188
187
  const head = queue[0];
189
188
  if (!head.abs) die(`Could not resolve path: ${head.doc.path}`);
190
- consumePrompt(head.abs, config, opts);
189
+ return consumePrompt(head.abs, config, opts);
191
190
  }
192
191
 
193
192
  // Resolve user input to a prompt path. Tries (in order): exact path,
@@ -204,7 +203,7 @@ export function resolvePromptInput(input, config, options = {}) {
204
203
  if (withExt) return withExt;
205
204
  }
206
205
 
207
- const index = buildIndex(config);
206
+ const index = buildIndex(config, { fast: true, invokeHooks: false });
208
207
  const prompts = index.docs.filter(d => d.type === 'prompt');
209
208
  if (prompts.length === 0) {
210
209
  if (dieOnMiss) die(`No prompts in the index.`);
@@ -231,17 +230,18 @@ export function resolvePromptInput(input, config, options = {}) {
231
230
  return null;
232
231
  }
233
232
 
234
- function runPromptsUse(argv, config, opts = {}) {
233
+ async function runPromptsUse(argv, config, opts = {}) {
235
234
  const input = argv.find(a => !a.startsWith('-'));
236
235
  if (!input) die('Usage: dotmd prompts use <file-or-slug>');
237
236
  const noIndex = argv.includes('--no-index') || opts.noIndex;
238
237
  const showFiles = argv.includes('--show-files') || opts.showFiles;
239
238
  const filePath = resolvePromptInput(input, config);
240
- consumePrompt(filePath, config, { ...opts, noIndex, showFiles });
239
+ return consumePrompt(filePath, config, { ...opts, noIndex, showFiles });
241
240
  }
242
241
 
243
- export function consumePrompt(filePath, config, opts) {
242
+ export async function consumePrompt(filePath, config, opts) {
244
243
  const { dryRun, noIndex, showFiles } = opts;
244
+ filePath = authorizeManagedSource(filePath, config, { kind: 'Prompt consumption source' }).path;
245
245
  const raw = readFileSync(filePath, 'utf8');
246
246
  const { frontmatter, body } = extractFrontmatter(raw);
247
247
  const parsed = parseSimpleFrontmatter(frontmatter);
@@ -256,6 +256,10 @@ export function consumePrompt(filePath, config, opts) {
256
256
  die(`Already consumed: ${repoPath}`);
257
257
  }
258
258
 
259
+ const planRef = asString(parsed.plan);
260
+ let linkedClaim = null;
261
+ if (planRef) linkedClaim = prepareLinkedPromptClaim(planRef, config);
262
+
259
263
  if (dryRun) {
260
264
  const prefix = dim('[dry-run]');
261
265
  process.stderr.write(`${prefix} Would emit body and archive: ${repoPath} (${status ?? 'unknown'} → archived)\n`);
@@ -274,15 +278,117 @@ export function consumePrompt(filePath, config, opts) {
274
278
  // hook crash, anything), the body must not have already gone to stdout —
275
279
  // otherwise `claude "$(dotmd prompts next)"` consumes the prompt without it
276
280
  // ever being archived, and the next session sees the same prompt as pending.
277
- // Body is already in memory from extractFrontmatter, so the source file
278
- // can move out from under us safely.
279
- const archiveResult = runArchive([filePath], config, { noIndex, showFiles, out: process.stderr });
280
-
281
- process.stdout.write(body);
282
- if (!body.endsWith('\n')) process.stdout.write('\n');
281
+ const archiveResult = runArchive([filePath], config, {
282
+ noIndex,
283
+ showFiles,
284
+ out: process.stderr,
285
+ testHooks: opts.testHooks,
286
+ deferIndex: Boolean(linkedClaim),
287
+ additionalUpdates: linkedClaim?.prepared?.updates,
288
+ creations: linkedClaim?.prepared?.creations,
289
+ });
290
+ const consumedBody = archiveResult?.consumedBody ?? body;
283
291
 
284
292
  const consumedPath = archiveResult?.newRepoPath ?? repoPath;
293
+ // Consume output is at-most-once: archive/claim commits before stdout. A
294
+ // downstream failure cannot roll the transaction back or make it consumable
295
+ // again; the archived path remains available through `prompts show`.
296
+ await writeConsumedBody(consumedBody, consumedPath, opts.writeBody, linkedClaim?.repoPath);
297
+ let completion = { indexRegenerated: false, ownershipChanged: false, hook: 'none', pending: false };
298
+ if (linkedClaim) {
299
+ try {
300
+ completion = completePlanClaim(linkedClaim.repoPath, config, opts);
301
+ if (!noIndex && config.indexPath && !completion.indexRegenerated) {
302
+ regenIndex(config, { throwOnError: true, testHooks: opts.testHooks });
303
+ completion.indexRegenerated = true;
304
+ }
305
+ } catch (err) {
306
+ throw new Error(`Prompt consumed and body delivered; linked claim completion remains pending for ${linkedClaim.repoPath}: ${err.message}`);
307
+ }
308
+ process.stderr.write(`${green('→ Claimed')}: ${linkedClaim.repoPath} (in-session)\n`);
309
+ }
285
310
  process.stderr.write(`${green('✓ Consumed')}: ${consumedPath}\n`);
311
+ const ownershipRecordPath = linkedClaim?.prepared?.recordPath ?? (linkedClaim ? readPlanOwnership(linkedClaim.repoPath, config)?.recordPath : null);
312
+ const ownershipPath = ownershipRecordPath ? toRepoPath(ownershipRecordPath, config.repoRoot) : null;
313
+ const normalize = candidate => path.isAbsolute(candidate) ? toRepoPath(candidate, config.repoRoot) : candidate.split(path.sep).join('/');
314
+ const resultPaths = [
315
+ ...(linkedClaim?.planChanged ? [linkedClaim.repoPath] : []),
316
+ ...(archiveResult?.referencePaths ?? []),
317
+ ].filter(Boolean).map(normalize);
318
+ const ownershipResultPaths = resultPaths.filter(candidate => candidate.startsWith('.dotmd/ownership/'));
319
+ const repositoryFiles = [...new Set(resultPaths.filter(candidate => !candidate.startsWith('.dotmd/ownership/')))];
320
+ const sessionFiles = [...new Set([
321
+ repoPath,
322
+ consumedPath,
323
+ (linkedClaim?.prepared || completion.ownershipChanged) ? ownershipPath : null,
324
+ ...ownershipResultPaths,
325
+ ].filter(Boolean).map(normalize))];
326
+ return {
327
+ operation: 'consume',
328
+ status: { from: status ?? null, to: 'archived', changed: true },
329
+ repositoryFiles,
330
+ sessionFiles,
331
+ generatedFiles: config.indexPath && !noIndex && (completion.indexRegenerated || archiveResult?.indexRegenerated) ? [toRepoPath(config.indexPath, config.repoRoot)] : [],
332
+ deferredGeneratedFiles: config.indexPath && noIndex ? [toRepoPath(config.indexPath, config.repoRoot)] : [],
333
+ claim: linkedClaim ? { plan: linkedClaim.repoPath, changed: linkedClaim.planChanged, pendingCompletion: completion.pending, hook: completion.hook } : null,
334
+ };
335
+ }
336
+
337
+ export async function writeConsumedBody(body, archivedPath, write = null, linkedPlan = null) {
338
+ const content = body.endsWith('\n') ? body : `${body}\n`;
339
+ try {
340
+ if (write) {
341
+ const accepted = await write(content);
342
+ if (accepted === false) throw Object.assign(new Error('writer reported unresolved backpressure'), { code: 'BACKPRESSURE' });
343
+ } else {
344
+ await new Promise((resolve, reject) => process.stdout.write(content, err => err ? reject(err) : resolve()));
345
+ }
346
+ }
347
+ catch (err) {
348
+ const completion = linkedPlan ? ` Then complete the linked claim with \`dotmd use ${linkedPlan}\`.` : '';
349
+ throw new Error(`Prompt was consumed but body output failed (${err.code ?? err.message}). It will not be emitted again; recover it with \`dotmd prompts show ${archivedPath}\`.${completion}`);
350
+ }
351
+ }
352
+
353
+ function prepareLinkedPromptClaim(planRef, config) {
354
+ let planPath = resolveDocPath(planRef, config) ?? resolveDocArg(planRef, config, { dieOnMiss: false });
355
+ if (!planPath || !existsSync(planPath)) die(`Linked plan is missing; prompt was not consumed: ${planRef}`);
356
+ planPath = authorizeManagedSource(planPath, config, { kind: 'Prompt linked plan source' }).path;
357
+ const raw = readFileSync(planPath, 'utf8');
358
+ let parsed;
359
+ try { parsed = parseSimpleFrontmatter(extractFrontmatter(raw).frontmatter); }
360
+ catch { die(`Linked plan is malformed; prompt was not consumed: ${planRef}`); }
361
+ const repoPath = toRepoPath(planPath, config.repoRoot);
362
+ const sessionId = authoritativeSessionId();
363
+ const ownership = readPlanOwnership(repoPath, config);
364
+ const oldStatus = asString(parsed.status);
365
+ const disposition = classifyPlanPickup({
366
+ type: asString(parsed.type),
367
+ status: oldStatus,
368
+ validStatuses: config.typeStatuses?.get('plan') ?? config.validStatuses,
369
+ startableStatuses: config.lifecycle.startableStatuses,
370
+ terminalStatuses: config.lifecycle.terminalStatuses,
371
+ archiveStatuses: config.lifecycle.archiveStatuses,
372
+ physicallyArchived: isArchivedPath(repoPath, config),
373
+ ownership,
374
+ sessionId,
375
+ malformed: false,
376
+ });
377
+ if (!disposition.pickupable) die(`Linked plan cannot be claimed (${disposition.kind}); prompt was not consumed: ${repoPath}`);
378
+ if (disposition.kind === 'resume') return { planPath, repoPath, prepared: null, planChanged: false, disposition: disposition.kind };
379
+ const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
380
+ const rendered = disposition.kind === 'start'
381
+ ? renderLifecycleMutation(raw, { status: 'in-session', updated: now }, `Started (${oldStatus} → in-session).`, { createSection: true })
382
+ : null;
383
+ const prepared = preparePlanClaim({ filePath: planPath, sourceContent: raw, renderedContent: rendered,
384
+ ownership, sessionId, now, config });
385
+ return {
386
+ planPath,
387
+ repoPath,
388
+ prepared,
389
+ planChanged: prepared.updates.some(item => path.resolve(item.path) === path.resolve(planPath)),
390
+ disposition: disposition.kind,
391
+ };
286
392
  }
287
393
 
288
394
  // Read-only peek: print the body WITHOUT consuming. The sanctioned triage path
package/src/query.mjs CHANGED
@@ -85,15 +85,21 @@ export function runQuery(index, argv, config, opts = {}) {
85
85
  if (filters.body && !filters.keyword) {
86
86
  die('`--body` extends a keyword search into document bodies — pass `--keyword <term>` (or use `dotmd grep <term>`).');
87
87
  }
88
- const docs = filterDocs(index.docs, filters, config);
88
+ const docs = filterDocs(index.docs, filters, config, opts.gitMetadataOptions);
89
+ const summaryPreviewSkipped = filters.summarize && config._execution?.suppressSideEffects;
89
90
 
90
91
  if (filters.json) {
91
- if (filters.summarize) {
92
+ if (filters.summarize && !summaryPreviewSkipped) {
92
93
  for (let i = 0; i < docs.length && i < filters.summarizeLimit; i++) {
93
94
  docs[i].aiSummary = getDocSummary(docs[i], config);
94
95
  }
95
96
  }
96
- process.stdout.write(`${JSON.stringify({ filters, count: docs.length, docs }, null, 2)}\n`);
97
+ process.stdout.write(`${JSON.stringify({
98
+ filters,
99
+ count: docs.length,
100
+ docs,
101
+ ...(summaryPreviewSkipped ? { summaryPreview: { status: 'skipped-preview', reason: 'side-effect-free preview' } } : {}),
102
+ }, null, 2)}\n`);
97
103
  return;
98
104
  }
99
105
 
@@ -279,7 +285,7 @@ export function parseQueryArgs(argv) {
279
285
  return filters;
280
286
  }
281
287
 
282
- export function filterDocs(docs, filters, config) {
288
+ export function filterDocs(docs, filters, config, gitMetadataOptions) {
283
289
  let result = [...docs];
284
290
 
285
291
  if (filters.types?.length) result = result.filter(d => filters.types.includes(d.type));
@@ -320,14 +326,18 @@ export function filterDocs(docs, filters, config) {
320
326
  if (filters.updatedSince) result = result.filter(d => d.updated && d.updated >= filters.updatedSince);
321
327
 
322
328
  if (filters.git) {
323
- const gitDates = getGitLastModifiedBatch(config.repoRoot);
329
+ const gitMetadata = getGitLastModifiedBatch(config.repoRoot, result.map(doc => doc.path), gitMetadataOptions);
330
+ const gitDates = gitMetadata.dates;
324
331
  for (const doc of result) {
325
332
  const gitDate = gitDates.get(doc.path) ?? null;
326
333
  if (gitDate) {
327
334
  doc.daysSinceUpdate = computeDaysSinceUpdate(gitDate);
328
- doc.isStale = computeIsStale(doc.status, gitDate, config);
335
+ doc.isStale = computeIsStale(doc.status, gitDate, config, doc.type);
329
336
  }
330
337
  }
338
+ if (!gitMetadata.complete) {
339
+ warn(`Git metadata is incomplete (${gitMetadata.reason}); query results use known dates only.`);
340
+ }
331
341
  }
332
342
 
333
343
  if (filters.stale) result = result.filter(d => d.isStale);
@@ -380,18 +390,16 @@ function scanBodyForKeyword(doc, needle, config) {
380
390
  warn(`Could not read ${doc.path}: ${err.message}`);
381
391
  return [];
382
392
  }
383
- const { body } = extractFrontmatter(raw);
393
+ const { body, bodyLineOffset } = extractFrontmatter(raw);
384
394
  if (!body || !body.toLowerCase().includes(needle)) return [];
385
395
 
386
- // body is a suffix of raw — the slice before it is the frontmatter block.
387
- const bodyStartLine = raw.slice(0, raw.length - body.length).split('\n').length;
388
396
  const lines = body.split('\n');
389
397
  const matches = [];
390
398
  for (let i = 0; i < lines.length && matches.length < MAX_BODY_MATCHES; i++) {
391
399
  const text = lines[i].trim();
392
400
  const at = text.toLowerCase().indexOf(needle);
393
401
  if (at === -1) continue;
394
- matches.push({ line: bodyStartLine + i, text: excerptAround(text, at, needle.length) });
402
+ matches.push({ line: bodyLineOffset + i + 1, text: excerptAround(text, at, needle.length) });
395
403
  }
396
404
  return matches;
397
405
  }
@@ -411,6 +419,7 @@ function getDocSummary(doc, config) {
411
419
  const { body } = extractFrontmatter(raw);
412
420
  if (!body?.trim()) return null;
413
421
  const meta = { title: doc.title, status: doc.status, path: doc.path };
422
+ if (config._execution?.suppressSideEffects) return null;
414
423
  return config.hooks.summarizeDoc
415
424
  ? config.hooks.summarizeDoc(body, meta)
416
425
  : summarizeDocBody(body, meta);
@@ -442,6 +451,9 @@ function renderQueryResults(docs, filters, config) {
442
451
  if (filters.hasNextStep) process.stdout.write('- has-next-step: true\n');
443
452
  if (filters.hasBlockers) process.stdout.write('- has-blockers: true\n');
444
453
  if (filters.checklistOpen) process.stdout.write('- checklist-open: true\n');
454
+ if (filters.summarize && config._execution?.suppressSideEffects) {
455
+ process.stdout.write('- summaries: skipped in side-effect-free preview\n');
456
+ }
445
457
  process.stdout.write('\n');
446
458
 
447
459
  if (docs.length === 0) { process.stdout.write('No matching docs.\n'); return; }
@@ -0,0 +1,292 @@
1
+ import { realpathSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { extractFrontmatter } from './frontmatter.mjs';
4
+
5
+ function slash(value) { return value.split(path.sep).join('/'); }
6
+
7
+ function canonicalExisting(filePath) {
8
+ try { return realpathSync(filePath); }
9
+ catch {
10
+ const suffix = [];
11
+ let cursor = path.resolve(filePath);
12
+ while (true) {
13
+ try { return path.join(realpathSync(cursor), ...suffix); }
14
+ catch {
15
+ const parent = path.dirname(cursor);
16
+ if (parent === cursor) return path.resolve(filePath);
17
+ suffix.unshift(path.basename(cursor));
18
+ cursor = parent;
19
+ }
20
+ }
21
+ }
22
+ }
23
+
24
+ export class AmbiguousReferenceError extends Error {
25
+ constructor(token, sourcePath, local, repository) {
26
+ super(`Ambiguous reference '${token}' in ${sourcePath}: document-relative resolves to ${local}, repository-relative resolves to ${repository}. Use an explicit unambiguous path.`);
27
+ this.name = 'AmbiguousReferenceError';
28
+ this.code = 'DOTMD_AMBIGUOUS_REFERENCE';
29
+ }
30
+ }
31
+
32
+ export function configuredReferenceFields(config) {
33
+ return [...new Set([
34
+ ...(config.referenceFields?.bidirectional ?? []),
35
+ ...(config.referenceFields?.unidirectional ?? []),
36
+ ])];
37
+ }
38
+
39
+ export function createReferenceIdentitySet(filePaths) {
40
+ const identities = new Set();
41
+ identities.paths = new Map();
42
+ for (const filePath of filePaths) {
43
+ const identity = canonicalExisting(filePath);
44
+ identities.add(identity);
45
+ identities.paths.set(identity, path.resolve(filePath));
46
+ }
47
+ return identities;
48
+ }
49
+
50
+ // Both interpretations are evaluated. A local document wins only when the
51
+ // repo-relative spelling is absent or names the same identity; disagreement is
52
+ // rejected rather than guessed.
53
+ export function resolveReferenceIdentity(token, documentPath, repoRoot, identities) {
54
+ if (!token || /^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(token)) return null;
55
+ const clean = token.replace(/[?#].*$/, '').replace(/\\([\s()[\]<>])/g, '$1');
56
+ const local = canonicalExisting(path.resolve(path.dirname(documentPath), clean));
57
+ const repository = canonicalExisting(path.resolve(repoRoot, clean.replace(/^\/+/, '')));
58
+ const localExists = identities.has(local);
59
+ const repositoryExists = identities.has(repository);
60
+ if (localExists && repositoryExists && local !== repository) {
61
+ throw new AmbiguousReferenceError(token, documentPath, local, repository);
62
+ }
63
+ return localExists ? local : (repositoryExists ? repository : null);
64
+ }
65
+
66
+ function rewriteToken(token, sourcePath, outputPath, repoRoot, identities, oldIdentity, newPath, rebaseAll, format = 'plain') {
67
+ const target = resolveReferenceIdentity(token, sourcePath, repoRoot, identities);
68
+ if (!target || (!rebaseAll && target !== oldIdentity)) return token;
69
+ const destination = target === oldIdentity ? path.resolve(newPath) : (identities.paths?.get(target) ?? target);
70
+ const relative = slash(path.relative(path.dirname(outputPath), destination)) || path.basename(destination);
71
+ return format === 'escaped' ? relative.replace(/([\s()[\]<>])/g, '\\$1') : relative;
72
+ }
73
+
74
+ function rewritePathTokens(value, args) {
75
+ let quote = null;
76
+ let commentAt = -1;
77
+ for (let index = 0; index < value.length; index++) {
78
+ const char = value[index];
79
+ if (quote) {
80
+ if (char === quote && value[index - 1] !== '\\') quote = null;
81
+ continue;
82
+ }
83
+ if (char === '"' || char === "'") { quote = char; continue; }
84
+ if (char === '#' && index > 0 && /\s/.test(value[index - 1])) { commentAt = index - 1; break; }
85
+ }
86
+ const editable = commentAt === -1 ? value : value.slice(0, commentAt);
87
+ const comment = commentAt === -1 ? '' : value.slice(commentAt);
88
+ const quoted = editable.replace(/(["'])((?:\\.|(?!\1).)*)\1/g, (match, delimiter, token) => {
89
+ if (!/^.+\.md(?:[?#].*)?$/i.test(token)) return match;
90
+ const suffixAt = token.search(/[?#]/);
91
+ const bare = suffixAt === -1 ? token : token.slice(0, suffixAt);
92
+ const suffix = suffixAt === -1 ? '' : token.slice(suffixAt);
93
+ const rewritten = rewriteToken(bare, ...args);
94
+ return rewritten === bare ? match : `${delimiter}${rewritten}${suffix}${delimiter}`;
95
+ });
96
+ const rewritten = quoted.replace(/[^\s"'<>:[\],()#?]+\.md(?=$|[\s"'<>:[\],()#?])/g, token => rewriteToken(token, ...args));
97
+ return rewritten + comment;
98
+ }
99
+
100
+ function rewriteFrontmatter(frontmatter, fields, args) {
101
+ const allowed = new Set(fields);
102
+ const lines = frontmatter.split('\n');
103
+ let active = false;
104
+ return lines.map(line => {
105
+ const key = /^([A-Za-z_][\w-]*):(?:\s*(.*))?$/.exec(line);
106
+ if (key) {
107
+ active = allowed.has(key[1]);
108
+ if (!active) return line;
109
+ const colon = line.indexOf(':');
110
+ return line.slice(0, colon + 1) + rewritePathTokens(line.slice(colon + 1), args);
111
+ }
112
+ if (/^[^\s#][^:]*:/.test(line)) active = false;
113
+ if (!active || /^\s*#/.test(line)) return line;
114
+ return rewritePathTokens(line, args);
115
+ }).join('\n');
116
+ }
117
+
118
+ function destinationParts(raw) {
119
+ const angle = raw.startsWith('<') && raw.endsWith('>');
120
+ const inner = angle ? raw.slice(1, -1) : raw;
121
+ const suffixAt = inner.search(/[?#]/);
122
+ return {
123
+ angle,
124
+ path: suffixAt === -1 ? inner : inner.slice(0, suffixAt),
125
+ suffix: suffixAt === -1 ? '' : inner.slice(suffixAt),
126
+ };
127
+ }
128
+
129
+ function rewriteDestination(raw, args) {
130
+ const parsed = destinationParts(raw);
131
+ if (!/\.md$/i.test(parsed.path.replace(/\\./g, 'x'))) return raw;
132
+ const next = rewriteToken(parsed.path, ...args, parsed.angle ? 'plain' : 'escaped');
133
+ if (next === parsed.path) return raw;
134
+ const rendered = `${next}${parsed.suffix}`;
135
+ return parsed.angle ? `<${rendered}>` : rendered;
136
+ }
137
+
138
+ function rewriteMarkdownSegment(segment, args) {
139
+ // Inline links: angle destinations, escaped whitespace, optional titles.
140
+ let next = segment.replace(/(\[[^\]]*\]\(\s*)(<[^>\n]+>|(?:\\.|[^\s()])+)(\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?(\s*\))/g,
141
+ (match, prefix, destination, title = '', close, offset) => {
142
+ let escapes = 0;
143
+ for (let index = offset - 1; index >= 0 && segment[index] === '\\'; index--) escapes++;
144
+ if (escapes % 2 === 1) return match;
145
+ const rewritten = rewriteDestination(destination, args);
146
+ return rewritten === destination ? match : `${prefix}${rewritten}${title}${close}`;
147
+ });
148
+ // Reference definitions preserve labels, spacing, destinations, and titles.
149
+ next = next.replace(/^(\s{0,3}\[[^\]]+\]:\s*)(<[^>\n]+>|(?:\\.|[^\s])+)(.*)$/,
150
+ (match, prefix, destination, tail) => {
151
+ const rewritten = rewriteDestination(destination, args);
152
+ return rewritten === destination ? match : `${prefix}${rewritten}${tail}`;
153
+ });
154
+ return next;
155
+ }
156
+
157
+ function rewriteMarkdown(body, args) {
158
+ const lines = body.split('\n');
159
+ let fence = null;
160
+ let inlineRun = null;
161
+ let indentedCode = false;
162
+ let htmlBlock = null;
163
+ const containerContext = line => {
164
+ let rest = line;
165
+ let quotePrefix = '';
166
+ while (true) {
167
+ const quote = /^( {0,3}>[ \t]?)/.exec(rest);
168
+ if (!quote) break;
169
+ quotePrefix += quote[1];
170
+ rest = rest.slice(quote[1].length);
171
+ }
172
+ const list = /^( {0,3}(?:[-+*]|\d+[.)]))([ \t]+)/.exec(rest);
173
+ if (list) {
174
+ const padding = !list[2].includes('\t') && list[2].length <= 4 ? list[2] : list[2][0];
175
+ const listPrefix = `${list[1]}${padding}`;
176
+ rest = rest.slice(listPrefix.length);
177
+ return { content: rest, prefixes: [`${quotePrefix}${listPrefix}`, `${quotePrefix}${' '.repeat(listPrefix.length)}`] };
178
+ }
179
+ return { content: rest, prefixes: quotePrefix ? [quotePrefix] : [''] };
180
+ };
181
+ return lines.map(line => {
182
+ if (fence) {
183
+ const close = new RegExp(`^ {0,3}${fence.char === '`' ? '`' : '~'}{${fence.length},}[ \\t]*$`);
184
+ const compatible = fence.prefixes.find(prefix => line.startsWith(prefix));
185
+ if (compatible !== undefined && close.test(line.slice(compatible.length))) fence = null;
186
+ return line;
187
+ }
188
+ if (htmlBlock) {
189
+ if (htmlBlock === 'comment' && line.includes('-->')) htmlBlock = null;
190
+ else if (htmlBlock !== 'generic' && new RegExp(`</${htmlBlock}\\s*>`, 'i').test(line)) htmlBlock = null;
191
+ else if (htmlBlock === 'generic' && line.trim() === '') htmlBlock = null;
192
+ return line;
193
+ }
194
+ const trimmed = line.trimStart();
195
+ if (trimmed.startsWith('<!--')) {
196
+ if (!trimmed.includes('-->')) htmlBlock = 'comment';
197
+ return line;
198
+ }
199
+ const rawTag = /^<(pre|code|script|style)(?:\s|>|$)/i.exec(trimmed)?.[1]?.toLowerCase();
200
+ if (rawTag) {
201
+ if (!new RegExp(`</${rawTag}\\s*>`, 'i').test(trimmed)) htmlBlock = rawTag;
202
+ return line;
203
+ }
204
+ if (/^<\/?(?:address|article|aside|base|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|nav|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul)(?:\s|>|\/)/i.test(trimmed)) {
205
+ htmlBlock = 'generic';
206
+ return line;
207
+ }
208
+ if (trimmed.startsWith('<')) return line;
209
+ const container = containerContext(line);
210
+ if (indentedCode) {
211
+ if (container.content === '' || /^(?: {4}|\t)/.test(container.content)) return line;
212
+ indentedCode = false;
213
+ } else if (/^(?: {4}|\t)/.test(container.content)) {
214
+ indentedCode = true;
215
+ return line;
216
+ }
217
+ const opener = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(container.content);
218
+ if (opener) {
219
+ fence = { char: opener[1][0], length: opener[1].length, prefixes: container.prefixes };
220
+ return line;
221
+ }
222
+ let output = '';
223
+ let cursor = 0;
224
+ const runs = [...line.matchAll(/`+/g)];
225
+ let index = 0;
226
+ if (inlineRun !== null) {
227
+ const closingIndex = runs.findIndex(candidate => candidate[0].length === inlineRun);
228
+ if (closingIndex === -1) return line;
229
+ const closing = runs[closingIndex];
230
+ output += line.slice(0, closing.index + closing[0].length);
231
+ cursor = closing.index + closing[0].length;
232
+ index = closingIndex + 1;
233
+ inlineRun = null;
234
+ }
235
+ for (; index < runs.length; index++) {
236
+ const opening = runs[index];
237
+ const closingIndex = runs.findIndex((candidate, candidateIndex) => candidateIndex > index && candidate[0].length === opening[0].length);
238
+ if (closingIndex === -1) {
239
+ output += rewriteMarkdownSegment(line.slice(cursor, opening.index), args);
240
+ output += line.slice(opening.index);
241
+ inlineRun = opening[0].length;
242
+ cursor = line.length;
243
+ break;
244
+ }
245
+ const closing = runs[closingIndex];
246
+ output += rewriteMarkdownSegment(line.slice(cursor, opening.index), args);
247
+ output += line.slice(opening.index, closing.index + closing[0].length);
248
+ cursor = closing.index + closing[0].length;
249
+ index = closingIndex;
250
+ }
251
+ return output + rewriteMarkdownSegment(line.slice(cursor), args);
252
+ }).join('\n');
253
+ }
254
+
255
+ export function rewriteDocumentReferences(content, {
256
+ sourcePath,
257
+ outputPath = sourcePath,
258
+ repoRoot,
259
+ identities,
260
+ oldPath,
261
+ newPath,
262
+ referenceFields = [],
263
+ rebaseAll = false,
264
+ }) {
265
+ const { frontmatter, body } = extractFrontmatter(content);
266
+ if (!frontmatter) return content;
267
+ const oldIdentity = canonicalExisting(oldPath);
268
+ const args = [sourcePath, outputPath, repoRoot, identities, oldIdentity, newPath, rebaseAll];
269
+ const nextFrontmatter = rewriteFrontmatter(frontmatter, referenceFields, args);
270
+ const nextBody = rewriteMarkdown(body, args);
271
+ return nextFrontmatter === frontmatter && nextBody === body ? content : `---\n${nextFrontmatter}\n---\n${nextBody}`;
272
+ }
273
+
274
+ export function planReferenceMove({ documents, oldPath, newPath, repoRoot, referenceFields = [] }) {
275
+ const identities = createReferenceIdentitySet(documents.map(document => document.path));
276
+ const oldIdentity = canonicalExisting(oldPath);
277
+ identities.add(oldIdentity);
278
+ const source = documents.find(document => path.resolve(document.path) === path.resolve(oldPath));
279
+ if (!source) throw new Error(`Reference move plan is missing source content: ${oldPath}`);
280
+ const movedContent = rewriteDocumentReferences(source.content, {
281
+ sourcePath: oldPath, outputPath: newPath, repoRoot, identities, oldPath, newPath, referenceFields, rebaseAll: true,
282
+ });
283
+ const updates = [];
284
+ for (const document of documents) {
285
+ if (path.resolve(document.path) === path.resolve(oldPath)) continue;
286
+ const content = rewriteDocumentReferences(document.content, {
287
+ sourcePath: document.path, repoRoot, identities, oldPath, newPath, referenceFields,
288
+ });
289
+ if (content !== document.content) updates.push({ path: document.path, expectedContent: document.content, content });
290
+ }
291
+ return { movedContent, updates, identities };
292
+ }