dotmd-cli 0.76.6 → 0.76.8

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.6",
3
+ "version": "0.76.8",
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",
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));
@@ -1,3 +1,5 @@
1
+ import { maskInlineCodeSpans } from './markdown-code-spans.mjs';
2
+
1
3
  export function extractFirstHeading(body) {
2
4
  return body.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? null;
3
5
  }
@@ -49,9 +51,7 @@ export function extractBodyLinks(body) {
49
51
  // every one of them was also never checked for breakage. Masking to same-length
50
52
  // filler keeps the link matchable while still neutralizing a link that is
51
53
  // itself inside code (`[fake](x.md)` stays unmatched), and preserves offsets.
52
- const stripped = body
53
- .replace(/^```[\s\S]*?^```/gm, '')
54
- .replace(/`[^`]+`/g, match => 'x'.repeat(match.length));
54
+ const stripped = maskInlineCodeSpans(body.replace(/^```[\s\S]*?^```/gm, ''));
55
55
  const links = [];
56
56
  // Supported inline destinations are a whitespace-free token (with Markdown
57
57
  // backslash escapes) or an angle-bracket destination, plus an optional
@@ -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
+ }
@@ -1,7 +1,7 @@
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
6
  import { resolveBodyLinkTarget } from './body-link.mjs';
7
7
 
@@ -38,6 +38,153 @@ import { resolveBodyLinkTarget } from './body-link.mjs';
38
38
  const ORPHAN_KIND = 'hub-membership-orphan';
39
39
  const BACKREF_KIND = 'hub-membership-backref';
40
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
+
41
188
  export function checkHubMembershipDrift(docs, config) {
42
189
  const warnings = [];
43
190
  // Per-doc: warning suppression is type-scoped, so a status name quiet for one
@@ -119,37 +266,16 @@ export function checkHubMembershipDrift(docs, config) {
119
266
  // it can't see; children already covered there are skipped, so one missing
120
267
  // back-ref is never reported twice. Warns on the CHILD, matching that check:
121
268
  // it's the file that needs the edit.
122
- for (const hub of docs) {
123
- if (!isHubDoc(hub) || quiet(hub)) continue;
124
- let body;
125
- try { ({ body } = extractFrontmatter(readFileSync(path.join(config.repoRoot, hub.path), 'utf8'))); }
126
- catch { continue; }
127
- const ranked = detectBodyRunlistRefs(body);
128
- if (ranked.length === 0) continue;
129
- const dir = dirOf(hub);
130
- const inFrontmatterRunlist = new Set();
131
- for (const ref of (hub.refFields?.runlist ?? [])) {
132
- const target = resolve(ref, dir);
133
- if (target) inFrontmatterRunlist.add(target.path);
134
- }
135
-
136
- const seen = new Set();
137
- for (const ref of ranked) {
138
- const child = resolve(ref, dir);
139
- if (!child || child.path === hub.path || seen.has(child.path)) continue;
140
- seen.add(child.path);
141
- if (quiet(child)) continue; // closed work is normal history
142
- if (inFrontmatterRunlist.has(child.path)) continue; // checkRunlistBackPointers owns it
143
- if (isHubDoc(child)) continue; // a hub under a hub is the roadmap tier
144
- if (child.type && child.type !== 'plan') continue; // `parent_plan` is a plan relationship
145
- if ((child.refFields?.parent_plan ?? []).length > 0) continue;
146
- warnings.push({
147
- path: child.path,
148
- level: 'warning',
149
- 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.`,
150
- meta: { kind: BACKREF_KIND, hub: hub.path },
151
- });
152
- }
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
+ });
153
279
  }
154
280
 
155
281
  return warnings;
@@ -0,0 +1,47 @@
1
+ function maskRange(value, start, end) {
2
+ return value.slice(0, start)
3
+ + value.slice(start, end).replace(/[^\n]/g, 'x')
4
+ + value.slice(end);
5
+ }
6
+
7
+ // Markdown code spans close only on a backtick run of the same length as their
8
+ // opener. The mask is deliberately the same length as the source so consumers
9
+ // can inspect neutralized Markdown while applying any edits at source offsets.
10
+ // `state` lets the conservative multiline behavior used by the reference
11
+ // rewriter survive: after an unmatched opener, nothing is treated as prose
12
+ // until a compatible closer appears.
13
+ export function maskInlineCodeLine(line, state = { run: null }) {
14
+ const ranges = [];
15
+ const runs = [...line.matchAll(/`+/g)];
16
+ let index = 0;
17
+
18
+ if (state.run !== null) {
19
+ const closingIndex = runs.findIndex(candidate => candidate[0].length === state.run);
20
+ if (closingIndex === -1) return maskRange(line, 0, line.length);
21
+ const closing = runs[closingIndex];
22
+ ranges.push([0, closing.index + closing[0].length]);
23
+ index = closingIndex + 1;
24
+ state.run = null;
25
+ }
26
+
27
+ for (; index < runs.length; index++) {
28
+ const opening = runs[index];
29
+ const closingIndex = runs.findIndex((candidate, candidateIndex) =>
30
+ candidateIndex > index && candidate[0].length === opening[0].length);
31
+ if (closingIndex === -1) {
32
+ ranges.push([opening.index, line.length]);
33
+ state.run = opening[0].length;
34
+ break;
35
+ }
36
+ const closing = runs[closingIndex];
37
+ ranges.push([opening.index, closing.index + closing[0].length]);
38
+ index = closingIndex;
39
+ }
40
+
41
+ return ranges.reduceRight((masked, [start, end]) => maskRange(masked, start, end), line);
42
+ }
43
+
44
+ export function maskInlineCodeSpans(value) {
45
+ const state = { run: null };
46
+ return value.split('\n').map(line => maskInlineCodeLine(line, state)).join('\n');
47
+ }
@@ -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
+ }
@@ -1,6 +1,7 @@
1
1
  import { realpathSync, statSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter } from './frontmatter.mjs';
4
+ import { maskInlineCodeLine } from './markdown-code-spans.mjs';
4
5
 
5
6
  function slash(value) { return value.split(path.sep).join('/'); }
6
7
 
@@ -233,29 +234,37 @@ function rewriteDestination(raw, args) {
233
234
  return parsed.angle ? `<${rendered}>` : rendered;
234
235
  }
235
236
 
236
- function rewriteMarkdownSegment(segment, args) {
237
- // Inline links: angle destinations, escaped whitespace, optional titles.
238
- let next = segment.replace(/(\[[^\]]*\]\(\s*)(<[^>\n]+>|(?:\\.|[^\s()])+)(\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?(\s*\))/g,
239
- (match, prefix, destination, title = '', close, offset) => {
240
- let escapes = 0;
241
- for (let index = offset - 1; index >= 0 && segment[index] === '\\'; index--) escapes++;
242
- if (escapes % 2 === 1) return match;
243
- const rewritten = rewriteDestination(destination, args);
244
- return rewritten === destination ? match : `${prefix}${rewritten}${title}${close}`;
245
- });
237
+ function rewriteMarkdownLine(line, masked, args) {
238
+ const edits = [];
239
+ const inline = /(\[[^\]]*\]\(\s*)(<[^>\n]+>|(?:\\.|[^\s()])+)(\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?(\s*\))/g;
240
+ let match;
241
+ while ((match = inline.exec(masked)) !== null) {
242
+ let escapes = 0;
243
+ for (let index = match.index - 1; index >= 0 && line[index] === '\\'; index--) escapes++;
244
+ if (escapes % 2 === 1) continue;
245
+ const start = match.index + match[1].length;
246
+ const destination = line.slice(start, start + match[2].length);
247
+ const rewritten = rewriteDestination(destination, args);
248
+ if (rewritten !== destination) edits.push({ start, end: start + destination.length, value: rewritten });
249
+ }
250
+
246
251
  // Reference definitions preserve labels, spacing, destinations, and titles.
247
- next = next.replace(/^(\s{0,3}\[[^\]]+\]:\s*)(<[^>\n]+>|(?:\\.|[^\s])+)(.*)$/,
248
- (match, prefix, destination, tail) => {
249
- const rewritten = rewriteDestination(destination, args);
250
- return rewritten === destination ? match : `${prefix}${rewritten}${tail}`;
251
- });
252
- return next;
252
+ const definition = /^(\s{0,3}\[[^\]]+\]:\s*)(<[^>\n]+>|(?:\\.|[^\s])+)(.*)$/.exec(masked);
253
+ if (definition) {
254
+ const start = definition[1].length;
255
+ const destination = line.slice(start, start + definition[2].length);
256
+ const rewritten = rewriteDestination(destination, args);
257
+ if (rewritten !== destination) edits.push({ start, end: start + destination.length, value: rewritten });
258
+ }
259
+
260
+ return edits.sort((left, right) => right.start - left.start)
261
+ .reduce((next, edit) => next.slice(0, edit.start) + edit.value + next.slice(edit.end), line);
253
262
  }
254
263
 
255
264
  function rewriteMarkdown(body, args) {
256
265
  const lines = body.split('\n');
257
266
  let fence = null;
258
- let inlineRun = null;
267
+ const inlineState = { run: null };
259
268
  let indentedCode = false;
260
269
  let htmlBlock = null;
261
270
  const containerContext = line => {
@@ -317,36 +326,7 @@ function rewriteMarkdown(body, args) {
317
326
  fence = { char: opener[1][0], length: opener[1].length, prefixes: container.prefixes };
318
327
  return line;
319
328
  }
320
- let output = '';
321
- let cursor = 0;
322
- const runs = [...line.matchAll(/`+/g)];
323
- let index = 0;
324
- if (inlineRun !== null) {
325
- const closingIndex = runs.findIndex(candidate => candidate[0].length === inlineRun);
326
- if (closingIndex === -1) return line;
327
- const closing = runs[closingIndex];
328
- output += line.slice(0, closing.index + closing[0].length);
329
- cursor = closing.index + closing[0].length;
330
- index = closingIndex + 1;
331
- inlineRun = null;
332
- }
333
- for (; index < runs.length; index++) {
334
- const opening = runs[index];
335
- const closingIndex = runs.findIndex((candidate, candidateIndex) => candidateIndex > index && candidate[0].length === opening[0].length);
336
- if (closingIndex === -1) {
337
- output += rewriteMarkdownSegment(line.slice(cursor, opening.index), args);
338
- output += line.slice(opening.index);
339
- inlineRun = opening[0].length;
340
- cursor = line.length;
341
- break;
342
- }
343
- const closing = runs[closingIndex];
344
- output += rewriteMarkdownSegment(line.slice(cursor, opening.index), args);
345
- output += line.slice(opening.index, closing.index + closing[0].length);
346
- cursor = closing.index + closing[0].length;
347
- index = closingIndex;
348
- }
349
- return output + rewriteMarkdownSegment(line.slice(cursor), args);
329
+ return rewriteMarkdownLine(line, maskInlineCodeLine(line, inlineState), args);
350
330
  }).join('\n');
351
331
  }
352
332
 
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' };
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`);
package/src/validate.mjs CHANGED
@@ -453,6 +453,11 @@ export function checkRunlistBackPointers(docs, config) {
453
453
  path: child.path,
454
454
  level: 'warning',
455
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
+ },
456
461
  });
457
462
  }
458
463
  }