dotmd-cli 0.76.5 → 0.76.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/dotmd.mjs CHANGED
@@ -238,12 +238,13 @@ Analyze:
238
238
  glossary <term> [--list] [--json] Look up domain terms + related docs
239
239
 
240
240
  Validate & Fix:
241
- doctor [--apply] Auto-fix everything: refs, lint, long fields, dates, index (preview by default)
241
+ doctor [--apply] Auto-fix everything: refs, membership, lint, long fields, dates, index (preview by default)
242
242
  doctor --transactions Report/clear wedged mutation transactions (run this if mutations refuse repo-wide)
243
243
  doctor --claims Report/release plan claims held by sessions that are gone ("busy in another session")
244
244
  self-check Project/version skew diagnostic (alias: doctor --project)
245
245
  lint [--fix] Check and auto-fix frontmatter issues
246
246
  fix-refs [--dry-run] Auto-fix broken reference paths + body links
247
+ fix-membership [<hub>...] Add unambiguous missing child parent_plan back-references
247
248
  sync-status [<hub>...] [--adopt] Rewrite hub table rows whose printed status drifted from the plan
248
249
 
249
250
  Lifecycle:
@@ -749,9 +750,10 @@ the command says so instead of printing an empty list.`,
749
750
 
750
751
  doctor: `dotmd doctor — auto-fix everything in one pass
751
752
 
752
- Runs in sequence: fix broken references, lint --fix, move over-cap
753
- frontmatter prose into body sections, sync dates from git, regenerate
754
- the index, then show remaining issues.
753
+ Runs in sequence: fix broken references, repair unambiguous membership
754
+ back-references, lint --fix, move over-cap frontmatter prose into body sections,
755
+ sync hub status rows, sync dates from git, regenerate the index, then show
756
+ remaining issues.
755
757
 
756
758
  Modes:
757
759
  (default) Auto-fix pass — previews by default since 0.37.0
@@ -861,6 +863,21 @@ Rows under a status column with no readable status word are reported by
861
863
  are not findings. Not to be confused with \`dotmd set <status>\`, which changes a
862
864
  document's OWN status — this only rewrites what a hub prints about others.`,
863
865
 
866
+ 'fix-membership': `dotmd fix-membership — repair unambiguous missing parent_plan back-references
867
+
868
+ A repair is safe only when one live hub has already declared the relationship
869
+ in its frontmatter runlist or body execution order and the live child plan has
870
+ no parent_plan at all. The command writes the child-relative back-reference and
871
+ updated date atomically.
872
+
873
+ It never creates or edits hub prose, never overwrites another parent, and skips
874
+ a parentless child ranked by multiple hubs as ambiguous.
875
+
876
+ dotmd fix-membership every hub in the repo
877
+ dotmd fix-membership <hub>... narrow to named hubs
878
+ dotmd fix-membership --dry-run preview without writing
879
+ dotmd fix-membership --dry-run --json`,
880
+
864
881
  'fix-refs': `dotmd fix-refs — auto-fix broken reference paths
865
882
 
866
883
  Scans all docs for reference fields that point to non-existent files,
@@ -1806,6 +1823,7 @@ async function main() {
1806
1823
  if (command === 'rename') { const { runRename } = await import('../src/rename.mjs'); await runRename(restArgs, config, { dryRun }); return; }
1807
1824
  if (command === 'migrate') { const { runMigrate } = await import('../src/migrate.mjs'); runMigrate(restArgs, config, { dryRun }); return; }
1808
1825
  if (command === 'fix-refs') { const { runFixRefs } = await import('../src/fix-refs.mjs'); runFixRefs(restArgs, config, { dryRun }); return; }
1826
+ if (command === 'fix-membership') { const { runFixMembership } = await import('../src/fix-membership.mjs'); await runFixMembership(restArgs, config, { dryRun }); return; }
1809
1827
  if (command === 'sync-status') { const { runSyncStatus } = await import('../src/sync-status.mjs'); await runSyncStatus(restArgs, config, { dryRun }); return; }
1810
1828
  if (command === 'self-check') {
1811
1829
  const { runDoctor } = await import('../src/doctor.mjs');
@@ -1917,11 +1935,14 @@ async function main() {
1917
1935
  }
1918
1936
 
1919
1937
  if (fix) {
1920
- // Auto-fix: broken refs, then lint, then rebuild index
1938
+ // Auto-fix: broken refs, mechanical membership back-references, lint,
1939
+ // hub status tokens, then rebuild the index.
1921
1940
  const { fixBrokenRefs } = await import('../src/fix-refs.mjs');
1941
+ const { fixMembershipBackrefs } = await import('../src/fix-membership.mjs');
1922
1942
  const { runLint } = await import('../src/lint.mjs');
1923
1943
  const { syncHubStatuses } = await import('../src/sync-status.mjs');
1924
1944
  fixBrokenRefs(config, { dryRun, quiet: false });
1945
+ fixMembershipBackrefs(config, { docs: buildIndex(config).docs, dryRun, quiet: false });
1925
1946
  runLint(['--fix'], config, { dryRun });
1926
1947
  // Rewrites drifted status TOKENS only. Adding markers is a content edit to
1927
1948
  // prose the user wrote, so it stays opt-in behind `sync-status --adopt`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.76.5",
3
+ "version": "0.76.7",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, lifecycle, and AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,40 @@
1
+ import { existsSync, realpathSync, statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ function isWithin(root, candidate) {
5
+ const relative = path.relative(root, candidate);
6
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
7
+ }
8
+
9
+ // Markdown body links are filesystem paths relative to the document that
10
+ // contains them. They deliberately do not inherit frontmatter references'
11
+ // repo-root fallback: that fallback can make a broken Markdown link look valid.
12
+ export function resolveBodyLinkTarget(href, docDir, repoRoot) {
13
+ if (!href) return { ok: false, reason: 'missing' };
14
+
15
+ const root = path.resolve(repoRoot);
16
+ const candidate = path.resolve(docDir, href);
17
+ if (!isWithin(root, candidate)) return { ok: false, reason: 'outside-repo' };
18
+ if (!existsSync(candidate)) return { ok: false, reason: 'missing' };
19
+
20
+ let canonicalRoot;
21
+ let canonicalTarget;
22
+ try {
23
+ canonicalRoot = realpathSync(root);
24
+ canonicalTarget = realpathSync(candidate);
25
+ } catch {
26
+ return { ok: false, reason: 'unreadable' };
27
+ }
28
+ if (!isWithin(canonicalRoot, canonicalTarget)) return { ok: false, reason: 'outside-repo' };
29
+
30
+ try {
31
+ const stat = statSync(canonicalTarget);
32
+ if (!stat.isFile() && !stat.isDirectory()) return { ok: false, reason: 'unsupported-target' };
33
+ } catch {
34
+ return { ok: false, reason: 'unreadable' };
35
+ }
36
+ // Return the authored lexical route after using the canonical route only for
37
+ // containment. Callers index documents by their repo-relative spelling (and
38
+ // may intentionally include an in-repo symlink alias).
39
+ return { ok: true, path: candidate, realPath: canonicalTarget };
40
+ }
package/src/commands.mjs CHANGED
@@ -136,6 +136,7 @@ const definitions = [
136
136
  command('rename', mutates('managed source, same-root destination, and rewrite sweep'), 'mutate', [form('<old> [new]', { args: positionals(1, 2), options: [flag('--show-files')] })]),
137
137
  command('migrate', mutates('managed source sweep'), 'mutate', [form('<field> <old> <new> [files...]', { args: positionals(3, Infinity), options: [flag('--show-files')] })]),
138
138
  command('fix-refs', mutates('managed source sweep'), 'mutate', [form('', { options: [flag('--show-files')] })]),
139
+ command('fix-membership', mutates('managed source sweep'), 'mutate', [form('[hubs...]', { args: positionals(0, Infinity), options: [flag('--json')] })]),
139
140
  command('sync-status', mutates('managed source sweep'), 'mutate', [form('[hubs...]', { args: positionals(0, Infinity), options: [flag('--adopt'), flag('--json')] })]),
140
141
  command('doctor', mutates('managed sweeps, repo index, and maintenance config paths by mode'), 'mutate', [form('[path]', { args: positionals(0, 1), options: [flag('--apply', '--yes'), flag('--statuses'), optionalValue('--migrate-template'), flag('--migrate-prompts'), flag('--frontmatter-fix'), flag('--project'), flag('--transactions'), flag('--claims'), flag('--session'), value('--older-than'), flag('--json'), flag('--include-archived')] })]),
141
142
  command('statuses', mutates('project config path; document scan is read-only'), 'mutate', [
package/src/doctor.mjs CHANGED
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { fixBrokenRefs } from './fix-refs.mjs';
4
4
  import { runLint } from './lint.mjs';
5
5
  import { syncHubStatuses } from './sync-status.mjs';
6
+ import { fixMembershipBackrefs } from './fix-membership.mjs';
6
7
  import { runSet, runTouch } from './lifecycle.mjs';
7
8
  import { buildIndex, collectDocFiles } from './index.mjs';
8
9
  import { writeRenderedIndex } from './index-file.mjs';
@@ -285,30 +286,35 @@ export function runDoctor(argv, config, opts = {}) {
285
286
  process.stdout.write(bold('1. Fixing broken references...') + '\n');
286
287
  fixBrokenRefs(config, { dryRun });
287
288
 
288
- // Step 2: Lint --fix
289
- process.stdout.write('\n' + bold('2. Fixing frontmatter issues...') + '\n');
289
+ // Step 2: Add only child back-references already authorized by one
290
+ // unambiguous hub. Hub prose is never generated or edited here.
291
+ process.stdout.write('\n' + bold('2. Fixing membership back-references...') + '\n');
292
+ fixMembershipBackrefs(config, { docs: buildIndex(config).docs, dryRun, testHooks });
293
+
294
+ // Step 3: Lint --fix
295
+ process.stdout.write('\n' + bold('3. Fixing frontmatter issues...') + '\n');
290
296
  runLint(['--fix'], config, { dryRun });
291
297
 
292
- // Step 3: Move over-cap status prose into body sections.
293
- process.stdout.write('\n' + bold('3. Fixing long frontmatter...') + '\n');
298
+ // Step 4: Move over-cap status prose into body sections.
299
+ process.stdout.write('\n' + bold('4. Fixing long frontmatter...') + '\n');
294
300
  runFrontmatterFix(config, { dryRun });
295
301
 
296
- // Step 4: Rewrite hub rows whose printed status drifted from the plan they
302
+ // Step 5: Rewrite hub rows whose printed status drifted from the plan they
297
303
  // link to. Tokens only — adding markers is a content edit to prose the user
298
304
  // wrote, so it stays opt-in behind `dotmd sync-status --adopt`.
299
- process.stdout.write('\n' + bold('4. Syncing hub status rows...') + '\n');
305
+ process.stdout.write('\n' + bold('5. Syncing hub status rows...') + '\n');
300
306
  const hubSync = syncHubStatuses(config, { docs: buildIndex(config).docs, dryRun });
301
307
  if (hubSync.fixed === 0 && hubSync.adopted === 0 && hubSync.unreadable === 0) {
302
308
  process.stdout.write('Hub status rows are in sync.\n');
303
309
  }
304
310
 
305
- // Step 5: Sync dates from git
306
- process.stdout.write('\n' + bold('5. Syncing dates from git...') + '\n');
311
+ // Step 6: Sync dates from git
312
+ process.stdout.write('\n' + bold('6. Syncing dates from git...') + '\n');
307
313
  runTouch(['--git'], config, { dryRun });
308
314
 
309
- // Step 6: Regenerate index. Heading always prints so numbering remains
315
+ // Step 7: Regenerate index. Heading always prints so numbering remains
310
316
  // contiguous even when `index.path` isn't configured.
311
- process.stdout.write('\n' + bold('6. Regenerating index...') + '\n');
317
+ process.stdout.write('\n' + bold('7. Regenerating index...') + '\n');
312
318
  if (!config.indexPath) {
313
319
  process.stdout.write('No index path configured (skip).\n');
314
320
  } else if (dryRun) {
@@ -318,11 +324,11 @@ export function runDoctor(argv, config, opts = {}) {
318
324
  process.stdout.write('Index updated.\n');
319
325
  }
320
326
 
321
- // Step 7: Clean up retired Claude Code command scaffolding. The per-repo
327
+ // Step 8: Clean up retired Claude Code command scaffolding. The per-repo
322
328
  // `.claude/commands/{plans,docs}.md` files are superseded by the dotmd plugin
323
329
  // skill; doctor sweeps any leftover banner-stamped (dotmd-generated) files.
324
330
  // Always print the heading so the numbering remains contiguous.
325
- process.stdout.write('\n' + bold('7. Claude Code commands:') + '\n');
331
+ process.stdout.write('\n' + bold('8. Claude Code commands:') + '\n');
326
332
  if (dryRun) {
327
333
  const wouldRemove = removeGeneratedSlashCommands(config.repoRoot, { dryRun: true });
328
334
  if (wouldRemove.length === 0) {
@@ -343,8 +349,8 @@ export function runDoctor(argv, config, opts = {}) {
343
349
  }
344
350
  }
345
351
 
346
- // Step 8: Show remaining check
347
- const issueLabel = dryRun ? '8. Remaining issues in current tree (preview fixes above were not applied):' : '8. Remaining issues:';
352
+ // Step 9: Show remaining check
353
+ const issueLabel = dryRun ? '9. Remaining issues in current tree (preview fixes above were not applied):' : '9. Remaining issues:';
348
354
  process.stdout.write('\n' + bold(issueLabel) + '\n');
349
355
  const freshIndex = buildIndex(config);
350
356
  process.stdout.write(renderCheck(freshIndex, config));
@@ -53,16 +53,28 @@ export function extractBodyLinks(body) {
53
53
  .replace(/^```[\s\S]*?^```/gm, '')
54
54
  .replace(/`[^`]+`/g, match => 'x'.repeat(match.length));
55
55
  const links = [];
56
- // Match [text](path.md) or [text](path.md#anchor), skip images (preceded by !)
57
- const regex = /(?<!!)\[([^\]]+)\]\(([^)]+\.md(?:#[^)]*)?)\)/g;
56
+ // Supported inline destinations are a whitespace-free token (with Markdown
57
+ // backslash escapes) or an angle-bracket destination, plus an optional
58
+ // quoted/parenthesized title. Reference links, HTML links, and nested
59
+ // unescaped parentheses remain deliberately outside this lightweight parser.
60
+ const regex = /(?<!!)\[([^\]\n]+)\]\(\s*(<[^>\n]+>|(?:\\.|[^\s()])+)(?:\s+(?:"[^"\n]*"|'[^'\n]*'|\([^\n)]*\)))?\s*\)/g;
58
61
  let match;
59
62
  while ((match = regex.exec(stripped)) !== null) {
60
- const href = match[2];
61
- // Skip external URLs
62
- if (/^https?:\/\//i.test(href)) continue;
63
- // Strip anchor fragment for path resolution
64
- const cleanHref = href.replace(/#.*$/, '');
65
- links.push({ text: match[1], href: cleanHref });
63
+ const destination = match[2];
64
+ const angle = destination.startsWith('<') && destination.endsWith('>');
65
+ const rawHref = angle ? destination.slice(1, -1) : destination;
66
+ // These destinations are meaningful to a renderer or another application,
67
+ // not portable repo-local filesystem targets.
68
+ if (/^(?:#|\/|[a-z][a-z\d+.-]*:)/i.test(rawHref)) continue;
69
+
70
+ const rawPath = rawHref.replace(/[?#].*$/, '');
71
+ if (!rawPath) continue;
72
+ const unescaped = rawPath.replace(/\\([\s()[\]<>])/g, '$1');
73
+ let href;
74
+ try { href = decodeURIComponent(unescaped); }
75
+ catch { href = unescaped; }
76
+ const targetKind = /\.md$/i.test(href) ? 'document' : 'file';
77
+ links.push({ text: match[1], rawHref, href, targetKind, angle });
66
78
  }
67
79
  return links;
68
80
  }
@@ -0,0 +1,132 @@
1
+ import path from 'node:path';
2
+ import { collectMembershipBackrefCandidates } from './hub-membership.mjs';
3
+ import { planChildParentUpdate } from './parent-plan.mjs';
4
+ import { mutateFileSet, MutationConflictError } from './atomic-mutation.mjs';
5
+ import { authorizeManagedSweep } from './managed-path.mjs';
6
+ import { isHubDoc } from './hub.mjs';
7
+ import { bold, dim, green, yellow } from './color.mjs';
8
+ import { die } from './util.mjs';
9
+
10
+ function resolveHubArgs(args, docs) {
11
+ const paths = new Set();
12
+ for (const arg of args) {
13
+ const slug = arg.replace(/\.md$/, '');
14
+ const matches = docs.filter(doc =>
15
+ doc.path === arg || doc.path === `${slug}.md`
16
+ || path.basename(doc.path, '.md') === path.basename(slug));
17
+ if (matches.length === 0) die(`No doc matches "${arg}".`);
18
+ if (matches.length > 1) {
19
+ die(`Multiple docs match "${arg}":\n${matches.map(match => ` ${match.path}`).join('\n')}`);
20
+ }
21
+ const [match] = matches;
22
+ if (!isHubDoc(match)) {
23
+ die(`${match.path} is not a hub — it has no \`runlist:\` and no \`execution_mode: coordination|roadmap\`. `
24
+ + 'Run `dotmd fix-membership` with no argument to sweep every hub.');
25
+ }
26
+ paths.add(match.path);
27
+ }
28
+ return paths;
29
+ }
30
+
31
+ export function fixMembershipBackrefs(config, {
32
+ docs,
33
+ dryRun = false,
34
+ hubPaths = null,
35
+ quiet = false,
36
+ testHooks,
37
+ } = {}) {
38
+ const planned = collectMembershipBackrefCandidates(docs, config, { hubPaths });
39
+ const updates = [];
40
+ const changes = [];
41
+ const skipped = [];
42
+ const guardByPath = new Map();
43
+
44
+ testHooks?.afterMembershipCandidates?.(planned);
45
+ for (const candidate of planned.candidates) {
46
+ if (candidate.childType !== 'plan') {
47
+ skipped.push({ path: candidate.childPath, hub: candidate.hubPath, reason: 'untyped-child' });
48
+ continue;
49
+ }
50
+ const update = planChildParentUpdate(candidate.childAbs, candidate.hubAbs, config);
51
+ if (update.raw !== candidate.childRaw) {
52
+ throw new MutationConflictError(`Child changed while membership repair was being prepared: ${candidate.childPath}`);
53
+ }
54
+ if (!update.update) {
55
+ skipped.push({
56
+ path: candidate.childPath,
57
+ hub: candidate.hubPath,
58
+ reason: update.reason,
59
+ ...(update.existing ? { existing: update.existing } : {}),
60
+ });
61
+ continue;
62
+ }
63
+ updates.push(update.update);
64
+ guardByPath.set(candidate.hubAbs, { path: candidate.hubAbs, expectedContent: candidate.hubRaw });
65
+ changes.push({
66
+ path: candidate.childPath,
67
+ hub: candidate.hubPath,
68
+ ref: update.ref,
69
+ sources: candidate.sources,
70
+ });
71
+ }
72
+
73
+ const ambiguous = planned.ambiguous.map(item => ({ path: item.childPath, hubs: item.hubPaths }));
74
+ const managed = [
75
+ ...updates.map(update => update.path),
76
+ ...guardByPath.values().map(guard => guard.path),
77
+ ];
78
+ if (managed.length) authorizeManagedSweep(managed, config, { kind: 'Membership repair source' });
79
+
80
+ testHooks?.afterMembershipPlan?.({ changes, ambiguous, skipped });
81
+ if (!dryRun && updates.length > 0) {
82
+ mutateFileSet({ updates, guards: [...guardByPath.values()] }, { repoRoot: config.repoRoot, testHooks });
83
+ }
84
+
85
+ const result = {
86
+ fixed: changes.length,
87
+ skipped: skipped.length + ambiguous.length,
88
+ ambiguous: ambiguous.length,
89
+ changes,
90
+ skippedDetails: skipped,
91
+ ambiguousDetails: ambiguous,
92
+ };
93
+
94
+ if (!quiet) {
95
+ const prefix = dryRun ? `${dim('[dry-run]')} ` : '';
96
+ for (const change of changes) {
97
+ process.stdout.write(`${prefix}${green(dryRun ? 'Would set' : 'Set')}: ${change.path} `
98
+ + `${dim(`parent_plan: ${change.ref} (stated by ${change.hub})`)}\n`);
99
+ }
100
+ for (const item of ambiguous) {
101
+ process.stdout.write(yellow(`Skipped ambiguous membership: ${item.path} is ranked by ${item.hubs.join(', ')}.`) + '\n');
102
+ }
103
+ for (const item of skipped) {
104
+ process.stdout.write(yellow(`Skipped membership repair: ${item.path} (${item.reason}).`) + '\n');
105
+ }
106
+ if (changes.length > 0) {
107
+ process.stdout.write(`\n${bold(`${prefix}${changes.length} membership back-reference${changes.length === 1 ? '' : 's'} ${dryRun ? 'would be repaired' : 'repaired'}.`)}\n`);
108
+ } else if (ambiguous.length === 0 && skipped.length === 0) {
109
+ process.stdout.write(green('Membership back-references are in sync.') + '\n');
110
+ }
111
+ }
112
+ return result;
113
+ }
114
+
115
+ export async function runFixMembership(argv, config, opts = {}) {
116
+ const { buildIndex } = await import('./index.mjs');
117
+ const json = argv.includes('--json');
118
+ const hubArgs = argv.filter(arg => !arg.startsWith('-'));
119
+ const docs = buildIndex(config).docs;
120
+ const hubPaths = hubArgs.length ? resolveHubArgs(hubArgs, docs) : null;
121
+ const result = fixMembershipBackrefs(config, {
122
+ docs,
123
+ dryRun: opts.dryRun,
124
+ hubPaths,
125
+ quiet: json,
126
+ testHooks: opts.testHooks,
127
+ });
128
+ if (json) {
129
+ process.stdout.write(`${JSON.stringify({ dryRun: Boolean(opts.dryRun), ...result }, null, 2)}\n`);
130
+ }
131
+ return result;
132
+ }
package/src/fix-refs.mjs CHANGED
@@ -102,7 +102,9 @@ export function fixBrokenRefs(config, opts = {}) {
102
102
 
103
103
  for (const doc of index.docs) {
104
104
  const brokenBodyWarnings = doc.warnings.filter(w =>
105
- w.message.startsWith('body link') && w.message.includes('does not resolve')
105
+ w.meta?.kind === 'body-link-resolution'
106
+ && w.meta?.targetKind === 'document'
107
+ && w.meta?.reason === 'missing'
106
108
  );
107
109
  if (!brokenBodyWarnings.length) continue;
108
110
 
@@ -112,12 +114,14 @@ export function fixBrokenRefs(config, opts = {}) {
112
114
  let newBody = body;
113
115
  const docDir = path.dirname(absPath);
114
116
  const bodyFixes = [];
117
+ const seenBodyTargets = new Set();
115
118
 
116
119
  for (const warn of brokenBodyWarnings) {
117
- const match = warn.message.match(/body link `([^`]+)` does not resolve/);
118
- if (!match) continue;
119
-
120
- const brokenHref = match[1];
120
+ const brokenHref = warn.meta.relPath;
121
+ const rawHref = warn.meta.rawHref ?? brokenHref;
122
+ const targetKey = `${warn.meta.angle ? 'angle' : 'plain'}:${rawHref}`;
123
+ if (seenBodyTargets.has(targetKey)) continue;
124
+ seenBodyTargets.add(targetKey);
121
125
  const brokenBasename = path.basename(brokenHref);
122
126
 
123
127
  if (duplicateBasenames.has(brokenBasename)) continue;
@@ -127,14 +131,21 @@ export function fixBrokenRefs(config, opts = {}) {
127
131
  const correctHref = path.relative(docDir, resolved).split(path.sep).join('/');
128
132
  if (correctHref === brokenHref) continue;
129
133
 
134
+ const suffixAt = rawHref.search(/[?#]/);
135
+ const suffix = suffixAt === -1 ? '' : rawHref.slice(suffixAt);
136
+ const renderedHref = warn.meta.angle
137
+ ? `<${correctHref}${suffix}>`
138
+ : `${correctHref.replace(/([\s()[\]<>])/g, '\\$1')}${suffix}`;
130
139
  const linkRegex = new RegExp(
131
- '(?<!!)\\[([^\\]]+)\\]\\(' + escapeRegex(brokenHref) + '(#[^)]*)?\\)',
140
+ '(\\]\\(\\s*)' + (warn.meta.angle ? '<' : '') + escapeRegex(rawHref) + (warn.meta.angle ? '>' : '') + '(?=\\s|\\))',
132
141
  'g'
133
142
  );
134
- newBody = newBody.replace(linkRegex, (_, text, anchor) =>
135
- `[${text}](${correctHref}${anchor ?? ''})`
136
- );
137
- bodyFixes.push({ brokenHref, correctHref });
143
+ let replacements = 0;
144
+ newBody = newBody.replace(linkRegex, (_, prefix) => {
145
+ replacements++;
146
+ return `${prefix}${renderedHref}`;
147
+ });
148
+ for (let i = 0; i < replacements; i++) bodyFixes.push({ brokenHref, correctHref });
138
149
  }
139
150
 
140
151
  if (bodyFixes.length > 0 && newBody !== body) {
@@ -1,8 +1,9 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { extractFrontmatter } from './frontmatter.mjs';
4
- import { resolveRefPath, toRepoPath } from './util.mjs';
3
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
+ import { asString, normalizeStringList, resolveRefPath, toRepoPath } from './util.mjs';
5
5
  import { detectBodyRunlistRefs, isHubDoc } from './hub.mjs';
6
+ import { resolveBodyLinkTarget } from './body-link.mjs';
6
7
 
7
8
  // Membership drift: a hub's list of children and the plans that claim it via
8
9
  // `parent_plan:` are two halves of one relationship, and either half can go
@@ -37,11 +38,159 @@ import { detectBodyRunlistRefs, isHubDoc } from './hub.mjs';
37
38
  const ORPHAN_KIND = 'hub-membership-orphan';
38
39
  const BACKREF_KIND = 'hub-membership-backref';
39
40
 
41
+ function quietDoc(status, type, config) {
42
+ return (config.lifecycle?.isTerminal?.(status, type)
43
+ ?? config.lifecycle?.terminalStatuses?.has(status))
44
+ || config.lifecycle?.skipsWarnings(status, type);
45
+ }
46
+
47
+ function configuredRunlist(parsed, config) {
48
+ const fields = [
49
+ ...(config.referenceFields?.bidirectional ?? []),
50
+ ...(config.referenceFields?.unidirectional ?? []),
51
+ ];
52
+ if (!fields.includes('runlist')) return { paths: [], directions: [] };
53
+ const paths = [];
54
+ const directions = [];
55
+ for (const entry of normalizeStringList(parsed.runlist)) {
56
+ const oneWay = entry.match(/^>\s*(.+)$/);
57
+ paths.push(oneWay ? oneWay[1].trim() : entry);
58
+ directions.push(oneWay ? 'one-way' : 'two-way');
59
+ }
60
+ return { paths, directions };
61
+ }
62
+
63
+ // One shared definition of mechanically repairable membership evidence. It
64
+ // reads the exact hub/child bytes it returns so callers can bind those snapshots
65
+ // into an atomic mutation: frontmatter `runlist:` (except `>` one-way entries)
66
+ // plus the body order read by `dotmd runlist next`. Ordinary body links remain
67
+ // pointers, and an existing parent — same or different — is never a candidate.
68
+ export function collectMembershipBackrefCandidates(docs, config, { hubPaths = null } = {}) {
69
+ const byPath = new Map(docs.map(doc => [doc.path, doc]));
70
+ const evidence = new Map();
71
+
72
+ const resolve = (ref, dir) => {
73
+ const abs = resolveRefPath(String(ref).replace(/#.*$/, ''), dir, config.repoRoot);
74
+ return abs ? byPath.get(toRepoPath(abs, config.repoRoot)) ?? null : null;
75
+ };
76
+
77
+ for (const indexedHub of docs) {
78
+ if (hubPaths && !hubPaths.has(indexedHub.path)) continue;
79
+ // The index is the discovery snapshot. Re-read only docs it identified as
80
+ // hubs; current bytes below can still demote one safely, while a doc that
81
+ // became a hub after indexing is simply discovered on the next run.
82
+ if (!isHubDoc(indexedHub)) continue;
83
+ const hubAbs = path.join(config.repoRoot, indexedHub.path);
84
+ let hubRaw;
85
+ let hubFrontmatter;
86
+ let body;
87
+ try {
88
+ hubRaw = readFileSync(hubAbs, 'utf8');
89
+ ({ frontmatter: hubFrontmatter, body } = extractFrontmatter(hubRaw));
90
+ } catch { continue; }
91
+ const parsedHub = parseSimpleFrontmatter(hubFrontmatter);
92
+ const runlist = configuredRunlist(parsedHub, config);
93
+ const currentHub = {
94
+ ...indexedHub,
95
+ type: asString(parsedHub.type) ?? null,
96
+ status: asString(parsedHub.status) ?? null,
97
+ executionMode: asString(parsedHub.execution_mode) ?? null,
98
+ refFields: { ...indexedHub.refFields, runlist: runlist.paths },
99
+ refFieldDirections: { ...indexedHub.refFieldDirections, runlist: runlist.directions },
100
+ };
101
+ if (!isHubDoc(currentHub) || quietDoc(currentHub.status, currentHub.type, config)) continue;
102
+
103
+ const hubDir = path.dirname(hubAbs);
104
+ const runlistTargets = new Map();
105
+ for (let index = 0; index < runlist.paths.length; index++) {
106
+ const child = resolve(runlist.paths[index], hubDir);
107
+ if (!child || child.path === currentHub.path) continue;
108
+ runlistTargets.set(child.path, runlist.directions[index]);
109
+ if (runlist.directions[index] === 'one-way') continue;
110
+ addEvidence(child, 'frontmatter-runlist', runlist.paths[index]);
111
+ }
112
+ for (const ref of detectBodyRunlistRefs(body)) {
113
+ const child = resolve(ref, hubDir);
114
+ if (!child || child.path === currentHub.path) continue;
115
+ // A frontmatter entry owns this pair. A `>` entry is an explicit opt-out;
116
+ // a normal entry was already recorded above. Either way, body order must
117
+ // not create a second or contradictory authorization.
118
+ if (runlistTargets.has(child.path)) continue;
119
+ addEvidence(child, 'body-order', ref);
120
+ }
121
+
122
+ function addEvidence(indexedChild, source, ref) {
123
+ const childAbs = path.join(config.repoRoot, indexedChild.path);
124
+ let childRaw;
125
+ let childFrontmatter;
126
+ try {
127
+ childRaw = readFileSync(childAbs, 'utf8');
128
+ ({ frontmatter: childFrontmatter } = extractFrontmatter(childRaw));
129
+ } catch { return; }
130
+ const parsedChild = parseSimpleFrontmatter(childFrontmatter);
131
+ const childType = asString(parsedChild.type) ?? null;
132
+ const childStatus = asString(parsedChild.status) ?? null;
133
+ if (quietDoc(childStatus, childType, config)) return;
134
+ // Preserve the validator's brownfield behavior: an untyped legacy doc is
135
+ // still plan-like enough to diagnose. The writer applies the stricter
136
+ // type: plan boundary before it mutates anything.
137
+ if (childType && childType !== 'plan') return;
138
+ const childRunlist = configuredRunlist(parsedChild, config);
139
+ const currentChild = {
140
+ ...indexedChild,
141
+ type: childType,
142
+ status: childStatus,
143
+ executionMode: asString(parsedChild.execution_mode) ?? null,
144
+ refFields: { ...indexedChild.refFields, runlist: childRunlist.paths },
145
+ };
146
+ if (isHubDoc(currentChild)) return;
147
+ if (normalizeStringList(parsedChild.parent_plan).length > 0) return;
148
+
149
+ const key = `${indexedChild.path}\0${currentHub.path}`;
150
+ const existing = evidence.get(key);
151
+ if (existing) {
152
+ if (!existing.sources.includes(source)) existing.sources.push(source);
153
+ if (!existing.refs.includes(ref)) existing.refs.push(ref);
154
+ return;
155
+ }
156
+ evidence.set(key, {
157
+ child: indexedChild,
158
+ hub: currentHub,
159
+ childPath: indexedChild.path,
160
+ hubPath: currentHub.path,
161
+ childAbs,
162
+ hubAbs,
163
+ childRaw,
164
+ childType,
165
+ hubRaw,
166
+ sources: [source],
167
+ refs: [ref],
168
+ });
169
+ }
170
+ }
171
+
172
+ const byChild = new Map();
173
+ for (const item of evidence.values()) {
174
+ if (!byChild.has(item.childPath)) byChild.set(item.childPath, []);
175
+ byChild.get(item.childPath).push(item);
176
+ }
177
+ const candidates = [];
178
+ const ambiguous = [];
179
+ for (const [childPath, items] of byChild) {
180
+ if (items.length === 1) candidates.push(items[0]);
181
+ else ambiguous.push({ childPath, hubPaths: items.map(item => item.hubPath).sort(), evidence: items });
182
+ }
183
+ candidates.sort((a, b) => a.childPath.localeCompare(b.childPath));
184
+ ambiguous.sort((a, b) => a.childPath.localeCompare(b.childPath));
185
+ return { candidates, ambiguous, evidence: [...evidence.values()] };
186
+ }
187
+
40
188
  export function checkHubMembershipDrift(docs, config) {
41
189
  const warnings = [];
42
190
  // Per-doc: warning suppression is type-scoped, so a status name quiet for one
43
191
  // type stays loud for another that declared the same name.
44
- const quiet = (d) => config.lifecycle?.terminalStatuses?.has(d.status)
192
+ const quiet = (d) => (config.lifecycle?.isTerminal?.(d.status, d.type)
193
+ ?? config.lifecycle?.terminalStatuses?.has(d.status))
45
194
  || config.lifecycle?.skipsWarnings(d.status, d.type);
46
195
  const byPath = new Map(docs.map(doc => [doc.path, doc]));
47
196
  const refFields = [
@@ -54,6 +203,11 @@ export function checkHubMembershipDrift(docs, config) {
54
203
  const abs = resolveRefPath(String(ref).replace(/#.*$/, ''), dir, config.repoRoot);
55
204
  return abs ? byPath.get(toRepoPath(abs, config.repoRoot)) ?? null : null;
56
205
  };
206
+ const resolveBody = (link, dir) => {
207
+ if (link.targetKind && link.targetKind !== 'document') return null;
208
+ const result = resolveBodyLinkTarget(link.href, dir, config.repoRoot);
209
+ return result.ok ? byPath.get(toRepoPath(result.path, config.repoRoot)) ?? null : null;
210
+ };
57
211
 
58
212
  // Everything a hub says about other docs: every configured reference field
59
213
  // plus every body link. Config-driven rather than a hardcoded field list, so a
@@ -68,7 +222,7 @@ export function checkHubMembershipDrift(docs, config) {
68
222
  }
69
223
  }
70
224
  for (const link of (hub.bodyLinks ?? [])) {
71
- const target = resolve(link.href, dir);
225
+ const target = resolveBody(link, dir);
72
226
  if (target) known.add(target.path);
73
227
  }
74
228
  return known;
@@ -112,37 +266,16 @@ export function checkHubMembershipDrift(docs, config) {
112
266
  // it can't see; children already covered there are skipped, so one missing
113
267
  // back-ref is never reported twice. Warns on the CHILD, matching that check:
114
268
  // it's the file that needs the edit.
115
- for (const hub of docs) {
116
- if (!isHubDoc(hub) || quiet(hub)) continue;
117
- let body;
118
- try { ({ body } = extractFrontmatter(readFileSync(path.join(config.repoRoot, hub.path), 'utf8'))); }
119
- catch { continue; }
120
- const ranked = detectBodyRunlistRefs(body);
121
- if (ranked.length === 0) continue;
122
- const dir = dirOf(hub);
123
- const inFrontmatterRunlist = new Set();
124
- for (const ref of (hub.refFields?.runlist ?? [])) {
125
- const target = resolve(ref, dir);
126
- if (target) inFrontmatterRunlist.add(target.path);
127
- }
128
-
129
- const seen = new Set();
130
- for (const ref of ranked) {
131
- const child = resolve(ref, dir);
132
- if (!child || child.path === hub.path || seen.has(child.path)) continue;
133
- seen.add(child.path);
134
- if (quiet(child)) continue; // closed work is normal history
135
- if (inFrontmatterRunlist.has(child.path)) continue; // checkRunlistBackPointers owns it
136
- if (isHubDoc(child)) continue; // a hub under a hub is the roadmap tier
137
- if (child.type && child.type !== 'plan') continue; // `parent_plan` is a plan relationship
138
- if ((child.refFields?.parent_plan ?? []).length > 0) continue;
139
- warnings.push({
140
- path: child.path,
141
- level: 'warning',
142
- message: `is ranked in the body order of \`${hub.path}\` (the list \`dotmd runlist next\` walks) but has no \`parent_plan:\`. Add \`parent_plan: ${hub.path}\` so reverse-link tooling (pickup-card Related:, graph) stays consistent.`,
143
- meta: { kind: BACKREF_KIND, hub: hub.path },
144
- });
145
- }
269
+ const { evidence } = collectMembershipBackrefCandidates(docs, config);
270
+ for (const item of evidence) {
271
+ if (!item.sources.includes('body-order')) continue;
272
+ const { child, hub } = item;
273
+ warnings.push({
274
+ path: child.path,
275
+ level: 'warning',
276
+ message: `is ranked in the body order of \`${hub.path}\` (the list \`dotmd runlist next\` walks) but has no \`parent_plan:\`. Add \`parent_plan: ${hub.path}\` so reverse-link tooling (pickup-card Related:, graph) stays consistent.`,
277
+ meta: { kind: BACKREF_KIND, hub: hub.path, source: 'body-order' },
278
+ });
146
279
  }
147
280
 
148
281
  return warnings;
@@ -0,0 +1,58 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { extractFrontmatter, parseSimpleFrontmatter, replaceFrontmatter } from './frontmatter.mjs';
4
+ import { MutationConflictError } from './atomic-mutation.mjs';
5
+ import { escapeRegex, normalizeStringList, nowIso, resolveRefPath, toRepoPath } from './util.mjs';
6
+
7
+ // Replace a top-level frontmatter field (its `key:` line + any indented
8
+ // continuation block) with `serialized`, or append it when absent. Shared by
9
+ // runlist mutations and the membership back-reference fixer so both paths
10
+ // serialize `parent_plan:` identically.
11
+ export function upsertFrontmatterField(fm, key, serialized) {
12
+ const re = new RegExp(`^${escapeRegex(key)}:.*(\\n[ \\t]+.*)*`, 'm');
13
+ if (re.test(fm)) return fm.replace(re, serialized);
14
+ return fm.replace(/\s*$/, '') + '\n' + serialized;
15
+ }
16
+
17
+ // Plan an atomic child-side `parent_plan:` update. The caller owns the evidence
18
+ // that authorizes the relationship (a runlist entry or body-order row); this
19
+ // helper owns the byte-level invariant: never clobber another parent, write a
20
+ // child-relative forward-slash ref, bump `updated:`, and compare-and-swap the
21
+ // exact child snapshot used to make the decision.
22
+ export function planChildParentUpdate(childAbs, hubAbs, config) {
23
+ const raw = readFileSync(childAbs, 'utf8');
24
+ const { frontmatter: fmRaw } = extractFrontmatter(raw);
25
+ if (fmRaw == null) return { wrote: false, reason: 'missing-frontmatter', raw };
26
+ const fm = parseSimpleFrontmatter(fmRaw);
27
+ const childDir = path.dirname(childAbs);
28
+ const existingRefs = normalizeStringList(fm.parent_plan);
29
+ if (existingRefs.length > 0) {
30
+ const resolved = existingRefs.map(ref => resolveRefPath(ref, childDir, config.repoRoot));
31
+ if (existingRefs.length === 1 && resolved[0] === hubAbs) {
32
+ return { wrote: false, reason: 'already-set', raw };
33
+ }
34
+ return { wrote: false, reason: 'different-parent', existing: existingRefs.join(', '), raw };
35
+ }
36
+ const ref = path.relative(childDir, hubAbs).split(path.sep).join('/');
37
+ return {
38
+ wrote: true,
39
+ ref,
40
+ raw,
41
+ update: {
42
+ path: childAbs,
43
+ expectedContent: raw,
44
+ render: current => {
45
+ const { frontmatter: currentFm } = extractFrontmatter(current);
46
+ const currentParsed = parseSimpleFrontmatter(currentFm);
47
+ const currentParents = normalizeStringList(currentParsed.parent_plan);
48
+ if (currentParents.length > 0
49
+ && !(currentParents.length === 1 && resolveRefPath(currentParents[0], childDir, config.repoRoot) === hubAbs)) {
50
+ throw new MutationConflictError(`Child parent_plan changed while the membership mutation was being prepared: ${toRepoPath(childAbs, config.repoRoot)}`);
51
+ }
52
+ let updatedFm = upsertFrontmatterField(currentFm, 'parent_plan', `parent_plan: ${ref}`);
53
+ updatedFm = upsertFrontmatterField(updatedFm, 'updated', `updated: ${nowIso()}`);
54
+ return replaceFrontmatter(current, updatedFm);
55
+ },
56
+ },
57
+ };
58
+ }
package/src/prompts.mjs CHANGED
@@ -118,7 +118,7 @@ function findPromptTarget(promptDoc, config) {
118
118
  }
119
119
 
120
120
  const links = promptDoc.bodyLinks ?? [];
121
- const mdLink = links.find(l => /\.md(?:#|$)/.test(l.href ?? ''));
121
+ const mdLink = links.find(l => l.targetKind === 'document');
122
122
  if (mdLink) return resolveBodyLink(mdLink.href, promptDoc.path);
123
123
  return null;
124
124
  }
package/src/render.mjs CHANGED
@@ -425,6 +425,15 @@ export function classifyIssueAction(issue) {
425
425
  if (issue?.meta?.kind === 'hub-status-unreadable') {
426
426
  return { action: `edit ${file}: lead the status cell with the status word, or wrap it in <!--s-->…<!--/s-->`, fixable: false, label: 'hub status rows' };
427
427
  }
428
+ if (issue?.meta?.kind === 'hub-membership-backref') {
429
+ return { action: 'dotmd fix-membership', fixable: true, label: 'membership back-references' };
430
+ }
431
+ if (issue?.meta?.kind === 'hub-membership-orphan') {
432
+ return { action: `edit ${file}: add the child to the hub's meaningful list, or correct the child's parent_plan`, fixable: false, label: 'orphan membership claims' };
433
+ }
434
+ if (issue?.meta?.kind === 'hub-membership-conflict') {
435
+ return { action: `edit ${file}: reconcile its existing parent_plan with ${issue.meta.hub}`, fixable: false, label: 'membership parent conflicts' };
436
+ }
428
437
 
429
438
  if (/Missing frontmatter `status`/.test(message)) {
430
439
  return { action: `dotmd bulk-tag ${file}`, fixable: false, label: 'missing status' };
@@ -454,7 +463,13 @@ export function classifyIssueAction(issue) {
454
463
  if (/`modules` is required/.test(message)) {
455
464
  return { action: `edit ${file} modules: or run dotmd lint --fix if scaffolded empty`, fixable: false, label: 'module metadata' };
456
465
  }
457
- if (/entry `.*` does not resolve|body link `.*` does not resolve/.test(message)) {
466
+ if (issue?.meta?.kind === 'body-link-resolution') {
467
+ if (issue.meta.targetKind === 'document' && issue.meta.reason === 'missing') {
468
+ return { action: 'dotmd fix-refs --dry-run', fixable: true, label: 'document links' };
469
+ }
470
+ return { action: `edit ${file}: correct the linked file, asset, or directory`, fixable: false, label: 'file links' };
471
+ }
472
+ if (/entry `.*` does not resolve/.test(message)) {
458
473
  return { action: 'dotmd fix-refs --dry-run', fixable: true, label: 'references' };
459
474
  }
460
475
 
package/src/runlist.mjs CHANGED
@@ -5,7 +5,6 @@ import { extractFirstHeading } from './extractors.mjs';
5
5
  import {
6
6
  asString,
7
7
  die,
8
- escapeRegex,
9
8
  isArchivedPath,
10
9
  normalizeStringList,
11
10
  nowIso,
@@ -21,6 +20,7 @@ import { bold, cyan, dim, green, red, yellow } from './color.mjs';
21
20
  import { authorizeManagedDestination, authorizeManagedSource } from './managed-path.mjs';
22
21
  import { mutateFileSet, MutationConflictError } from './atomic-mutation.mjs';
23
22
  import { pickupFactsForDoc } from './pickup.mjs';
23
+ import { planChildParentUpdate, upsertFrontmatterField } from './parent-plan.mjs';
24
24
 
25
25
  // A child is the runlist's NEXT PICKUP only when a session could start it right
26
26
  // now — i.e. its status is one `dotmd use` accepts. The "parked" statuses
@@ -433,17 +433,6 @@ function renderRunlist(hubRepoPath, children, opts = {}) {
433
433
 
434
434
  // --- `runlist add` mutation (Phase 1) -------------------------------------
435
435
 
436
- // Replace a top-level frontmatter field (its `key:` line + any indented
437
- // continuation block) with `serialized`, or append it when absent. The regex
438
- // mirrors what `mergeBodyFrontmatter` uses so the rewritten field keeps the
439
- // scaffold's shape. Shared by the block-array (`runlist:`) and scalar
440
- // (`parent_plan:`, `updated:`) writers below.
441
- function upsertFrontmatterField(fm, key, serialized) {
442
- const re = new RegExp(`^${escapeRegex(key)}:.*(\\n[ \\t]+.*)*`, 'm');
443
- if (re.test(fm)) return fm.replace(re, serialized);
444
- return fm.replace(/\s*$/, '') + '\n' + serialized;
445
- }
446
-
447
436
  function serializeBlockArray(key, items) {
448
437
  if (items.length === 0) return `${key}:`;
449
438
  return `${key}:\n${items.map(v => ` - ${v}`).join('\n')}`;
@@ -499,37 +488,6 @@ function classifyChildToken(token, hubDir, hubSlug, pos, config) {
499
488
  };
500
489
  }
501
490
 
502
- // Set `parent_plan:` on an existing child to point back at the hub, unless it
503
- // already resolves to the hub. Never clobbers a parent_plan that points
504
- // elsewhere (warns instead — the child may belong to another hub). Returns
505
- // true when it wrote, false when it left the file alone.
506
- function planChildParentUpdate(childAbs, hubAbs, config) {
507
- const raw = readFileSync(childAbs, 'utf8');
508
- const { frontmatter: fmRaw } = extractFrontmatter(raw);
509
- if (fmRaw == null) return { wrote: false };
510
- const fm = parseSimpleFrontmatter(fmRaw);
511
- const childDir = path.dirname(childAbs);
512
- const existing = asString(fm.parent_plan);
513
- if (existing) {
514
- const resolved = resolveRefPath(existing, childDir, config.repoRoot);
515
- if (resolved === hubAbs) return { wrote: false }; // already points at this hub
516
- warn(`${toRepoPath(childAbs, config.repoRoot)} already has parent_plan: ${existing} — left as-is (not pointing it at the hub).`);
517
- return { wrote: false };
518
- }
519
- const ref = path.relative(childDir, hubAbs).split(path.sep).join('/');
520
- return { wrote: true, update: { path: childAbs, expectedContent: raw, render: current => {
521
- const { frontmatter: currentFm } = extractFrontmatter(current);
522
- const currentParsed = parseSimpleFrontmatter(currentFm);
523
- const currentParent = asString(currentParsed.parent_plan);
524
- if (currentParent && resolveRefPath(currentParent, childDir, config.repoRoot) !== hubAbs) {
525
- throw new MutationConflictError(`Child parent_plan changed while the runlist mutation was being prepared: ${toRepoPath(childAbs, config.repoRoot)}`);
526
- }
527
- let updatedFm = upsertFrontmatterField(currentFm, 'parent_plan', `parent_plan: ${ref}`);
528
- updatedFm = upsertFrontmatterField(updatedFm, 'updated', `updated: ${nowIso()}`);
529
- return replaceFrontmatter(current, updatedFm);
530
- } } };
531
- }
532
-
533
491
  // `dotmd runlist add <hub> <child...>` — append children to a hub's `runlist:`
534
492
  // array, scaffolding a `planned` stub for any bare-slug child that doesn't yet
535
493
  // exist (mirroring `dotmd new plan --runlist`) and wiring each child's
@@ -619,6 +577,9 @@ async function runRunlistAdd(positional, config, { dryRun, json, testHooks }) {
619
577
  if (!json) process.stdout.write(`${prefix} ${green('+')} ${c.repoPath} ${dim('(scaffolded · planned)')}\n`);
620
578
  } else {
621
579
  const planned = planChildParentUpdate(c.abs, hubAbs, config);
580
+ if (planned.reason === 'different-parent') {
581
+ warn(`${c.repoPath} already has parent_plan: ${planned.existing} — left as-is (not pointing it at the hub).`);
582
+ }
622
583
  if (planned.update) updates.push(planned.update);
623
584
  const note = planned.wrote ? 'existing · parent_plan set' : 'existing';
624
585
  if (!json) process.stdout.write(`${prefix} ${green('+')} ${c.repoPath} ${dim(`(${note})`)}\n`);
@@ -1,9 +1,10 @@
1
1
  import { readFileSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, normalizeEol } from './frontmatter.mjs';
4
- import { die, isArchivedPath, resolveRefPath, toRepoPath } from './util.mjs';
4
+ import { die, isArchivedPath, toRepoPath } from './util.mjs';
5
5
  import { cyan, dim, green, red, yellow } from './color.mjs';
6
6
  import { authorizeManagedSweep } from './managed-path.mjs';
7
+ import { resolveBodyLinkTarget } from './body-link.mjs';
7
8
  import {
8
9
  MARKER_CLOSE,
9
10
  MARKER_OPEN,
@@ -21,8 +22,8 @@ import {
21
22
  // dotmd already reads those rows to compute next-pickup, so the guard is
22
23
  // standing next to the data it needs. Everything it needs is already owned:
23
24
  // `config.types[<type>].statuses` for the vocabulary (type-aware, so a `doc`
24
- // rowed in a plan hub is judged by the doc vocabulary), `resolveRefPath` for
25
- // the link (case-fold-aware), the index for the child's real status, and
25
+ // rowed in a plan hub is judged by the doc vocabulary), the strict Markdown
26
+ // body-link resolver, the index for the child's real status, and
26
27
  // archive-dir-outranks-frontmatter for archived children.
27
28
  //
28
29
  // The three-way split below is the part that is easy to get wrong. A row with
@@ -79,7 +80,7 @@ function truncate(text, max = 60) {
79
80
  // for that hub by name, so the noise-control rule doesn't apply).
80
81
  export function collectHubStatusRows(docs, config, { hubPaths = null } = {}) {
81
82
  const docByPath = new Map(docs.map(doc => [doc.path, doc]));
82
- // `resolveRefPath` is case-fold-aware, so on a case-folding filesystem a row
83
+ // The filesystem resolver is case-fold-aware, so on a case-folding filesystem a row
83
84
  // linking `BILLING-A.md` resolves to the file that is indexed as
84
85
  // `billing-a.md` — a path string the exact map can't answer. Fold as a
85
86
  // fallback, and only when the fold is unambiguous: on a case-SENSITIVE
@@ -91,7 +92,8 @@ export function collectHubStatusRows(docs, config, { hubPaths = null } = {}) {
91
92
  }
92
93
  // Per-doc: warning suppression is type-scoped, so a status name quiet for one
93
94
  // type stays loud for another that declared the same name.
94
- const quiet = (d) => config.lifecycle?.terminalStatuses?.has(d.status)
95
+ const quiet = (d) => (config.lifecycle?.isTerminal?.(d.status, d.type)
96
+ ?? config.lifecycle?.terminalStatuses?.has(d.status))
95
97
  || config.lifecycle?.skipsWarnings(d.status, d.type);
96
98
  const out = [];
97
99
 
@@ -108,11 +110,11 @@ export function collectHubStatusRows(docs, config, { hubPaths = null } = {}) {
108
110
  const rows = [];
109
111
  for (const row of scanHubStatusRows(body)) {
110
112
  const href = row.ref.replace(/#.*$/, '');
111
- const abs = resolveRefPath(href, hubDir, config.repoRoot);
113
+ const resolution = resolveBodyLinkTarget(href, hubDir, config.repoRoot);
112
114
  // A row whose link is broken is already a body-link finding. Reporting it
113
115
  // again as unreadable status would double-report one problem.
114
- if (!abs) continue;
115
- const repoPath = toRepoPath(abs, config.repoRoot);
116
+ if (!resolution.ok) continue;
117
+ const repoPath = toRepoPath(resolution.path, config.repoRoot);
116
118
  const target = docByPath.get(repoPath) ?? docByFoldedPath.get(repoPath.toLowerCase()) ?? null;
117
119
  if (!target || target.path === hub.path) continue;
118
120
  const actual = effectiveStatus(target, config);
package/src/validate.mjs CHANGED
@@ -3,6 +3,7 @@ import { asString, resolveRefPath, suggestCandidates } from './util.mjs';
3
3
  import { getGitLastModified, getGitLastModifiedBatch, getGitLastSubstantiveModifiedBatch } from './git.mjs';
4
4
  import { toRepoPath } from './util.mjs';
5
5
  import { detectMarker, isPhaseHeading, phaseMarkerConflict, walkSections } from './section.mjs';
6
+ import { resolveBodyLinkTarget } from './body-link.mjs';
6
7
 
7
8
  const NOW = new Date();
8
9
 
@@ -277,12 +278,24 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
277
278
 
278
279
  // Validate body links resolve to existing files
279
280
  for (const link of (doc.bodyLinks || [])) {
280
- if (!resolveRefPath(link.href, docDir, config.repoRoot)) {
281
+ const resolution = resolveBodyLinkTarget(link.href, docDir, config.repoRoot);
282
+ if (!resolution.ok) {
283
+ const shownHref = link.rawHref ?? link.href;
281
284
  doc.warnings.push({
282
285
  path: doc.path,
283
286
  level: 'warning',
284
- message: `body link \`${link.href}\` does not resolve to an existing file.`,
285
- meta: { kind: 'ref-resolution', field: 'body-link', relPath: link.href },
287
+ message: resolution.reason === 'outside-repo'
288
+ ? `body link \`${shownHref}\` escapes the repository.`
289
+ : `body link \`${shownHref}\` does not resolve to an existing file or directory.`,
290
+ meta: {
291
+ kind: 'body-link-resolution',
292
+ field: 'body-link',
293
+ relPath: link.href,
294
+ rawHref: shownHref,
295
+ targetKind: link.targetKind ?? 'document',
296
+ angle: link.angle === true,
297
+ reason: resolution.reason,
298
+ },
286
299
  });
287
300
  }
288
301
  }
@@ -326,7 +339,9 @@ function candidatePathsForType(docs, type) {
326
339
  // name implies one (e.g. `related_plans` → plans only).
327
340
  export function enrichRefErrorSuggestions(docs, config) {
328
341
  const enrich = (entry) => {
329
- if (!entry?.meta || entry.meta.kind !== 'ref-resolution') return;
342
+ if (!entry?.meta || !['ref-resolution', 'body-link-resolution'].includes(entry.meta.kind)) return;
343
+ if (entry.meta.kind === 'body-link-resolution'
344
+ && (entry.meta.targetKind !== 'document' || entry.meta.reason !== 'missing')) return;
330
345
  if (entry._suggested) return;
331
346
  const inferred = inferRefFieldType(entry.meta.field);
332
347
  const candidates = candidatePathsForType(docs, inferred);
@@ -438,6 +453,11 @@ export function checkRunlistBackPointers(docs, config) {
438
453
  path: child.path,
439
454
  level: 'warning',
440
455
  message: `appears in runlist of \`${hub.path}\` but \`parent_plan:\` does not point back at it. Add \`parent_plan: ${hub.path}\` so reverse-link tooling (pickup-card Related:, graph) stays consistent.`,
456
+ meta: {
457
+ kind: childParents.length === 0 ? 'hub-membership-backref' : 'hub-membership-conflict',
458
+ hub: hub.path,
459
+ source: 'frontmatter-runlist',
460
+ },
441
461
  });
442
462
  }
443
463
  }