dotmd-cli 0.73.0 → 0.74.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.73.0",
3
+ "version": "0.74.1",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, lifecycle, and AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1279,9 +1279,9 @@ export function createFileExclusive(filePath, content, options) {
1279
1279
  }
1280
1280
 
1281
1281
  export function moveFileAtomic(sourcePath, targetPath, render, options) {
1282
- const { repoRoot, finalize, rollbackFinalize, testHooks, updates = [], creations = [], deletions = [] } = options;
1282
+ const { repoRoot, finalize, rollbackFinalize, testHooks, updates = [], creations = [], deletions = [], guards = [] } = options;
1283
1283
  recoverAbandonedTransactions(repoRoot, options);
1284
- return withPathLocks([sourcePath, targetPath, ...updates.map(item => item.path), ...creations.map(item => item.path), ...deletions.map(item => item.path)], options, () => {
1284
+ return withPathLocks([sourcePath, targetPath, ...updates.map(item => item.path), ...creations.map(item => item.path), ...deletions.map(item => item.path), ...guards.map(item => item.path)], options, () => {
1285
1285
  testHooks?.afterTransactionPhase?.('lock', { sourcePath, targetPath });
1286
1286
  testHooks?.beforeMoveSnapshot?.({ sourcePath, targetPath });
1287
1287
  const source = snapshotFile(sourcePath);
@@ -1304,6 +1304,15 @@ export function moveFileAtomic(sourcePath, targetPath, render, options) {
1304
1304
  }
1305
1305
  return { ...item, snapshot };
1306
1306
  });
1307
+ // Read-only participants (see `mutateFileSet`). Checked before the
1308
+ // transaction manifest exists, so a guard conflict can never leave a
1309
+ // transaction to recover from.
1310
+ for (const guard of guards) {
1311
+ const snapshot = snapshotFile(guard.path);
1312
+ if (guard.expectedContent !== undefined && snapshot.content !== guard.expectedContent) {
1313
+ throw new MutationConflictError(`File changed while the move mutation set was being prepared: ${snapshot.path}`);
1314
+ }
1315
+ }
1307
1316
  for (const item of creations) {
1308
1317
  if (existsSync(item.path)) throw new MutationConflictError(`Destination already exists: ${path.resolve(item.path)}`);
1309
1318
  }
@@ -1627,9 +1636,30 @@ export function moveFileAtomic(sourcePath, targetPath, render, options) {
1627
1636
  });
1628
1637
  }
1629
1638
 
1630
- export function mutateFileSet({ updates = [], creations = [] }, options) {
1631
- const paths = [...updates.map(item => item.path), ...creations.map(item => item.path)];
1639
+ // `guards` are read-only participants: files the mutation's VALIDITY depends on
1640
+ // but that it never writes. They take part in locking and in the same
1641
+ // compare-and-swap as `updates`, so a mutation decided from a file it doesn't
1642
+ // modify cannot land after that file changed underneath it.
1643
+ //
1644
+ // Without this, a decision read and the write it justifies are two separate
1645
+ // steps with nothing holding the gap. A claim that adopts an already-in-session
1646
+ // plan writes only the ownership record — so a concurrent `set` releasing that
1647
+ // plan could win the status write while the claim still took ownership, leaving
1648
+ // a record that owns a plan the file says is `active`. A no-op update on the
1649
+ // plan file would close the gap too, but it would rewrite bytes and report the
1650
+ // plan as changed to anything counting touched files. A guard says what is meant.
1651
+ export function mutateFileSet({ updates = [], creations = [], guards = [] }, options) {
1652
+ const paths = [...updates.map(item => item.path), ...creations.map(item => item.path), ...guards.map(item => item.path)];
1632
1653
  return withPathLocks(paths, options, () => {
1654
+ // Guards are pure preconditions, so they are checked before anything at all
1655
+ // is created — a guard conflict must leave the tree byte-identical, not even
1656
+ // an empty directory behind.
1657
+ for (const guard of guards) {
1658
+ const snapshot = snapshotFile(guard.path);
1659
+ if (guard.expectedContent !== undefined && snapshot.content !== guard.expectedContent) {
1660
+ throw new MutationConflictError(`File changed while the mutation set was being prepared: ${snapshot.path}`);
1661
+ }
1662
+ }
1633
1663
  const createdDirectories = [];
1634
1664
  for (const item of creations) {
1635
1665
  const directory = path.dirname(item.path);
@@ -1654,7 +1684,7 @@ export function mutateFileSet({ updates = [], creations = [] }, options) {
1654
1684
  for (const item of creations) {
1655
1685
  if (existsSync(item.path)) throw new MutationConflictError(`Destination already exists: ${path.resolve(item.path)}`);
1656
1686
  }
1657
- options.testHooks?.afterSetPreflight?.({ updates: preparedUpdates, creations });
1687
+ options.testHooks?.afterSetPreflight?.({ updates: preparedUpdates, creations, guards });
1658
1688
 
1659
1689
  const committedUpdates = [];
1660
1690
  const committedCreations = [];
@@ -0,0 +1,151 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { extractFrontmatter } from './frontmatter.mjs';
4
+ import { resolveRefPath, toRepoPath } from './util.mjs';
5
+ import { detectBodyRunlistRefs, isHubDoc } from './hub.mjs';
6
+
7
+ // Membership drift: a hub's list of children and the plans that claim it via
8
+ // `parent_plan:` are two halves of one relationship, and either half can go
9
+ // stale on its own. dotmd already checks one direction — `checkRunlistBackPointers`
10
+ // warns when a `runlist:` child lacks the back-ref. These are the two arrows it
11
+ // doesn't cover.
12
+ //
13
+ // Membership only. The row is NEVER generated — same constraint as the status
14
+ // guard: the prose beside a row is why the row exists.
15
+ //
16
+ // ── What counts as a membership claim (measured, not assumed) ───────────────
17
+ //
18
+ // A body table row is NOT one. dotmd's own estate has an aggregator hub whose
19
+ // ranked queue draws plans from three other programs, and whose other tables row
20
+ // children purely to say "related" — the same pointer-row shape the status guard
21
+ // already declines to judge. Treating every rowed link as membership would flood
22
+ // correct hubs.
23
+ //
24
+ // What IS a claim: `runlist:` (frontmatter order) and the hub's BODY ORDER
25
+ // (`## Ranked queue` / `## Order of operations`) — the list `dotmd runlist next`
26
+ // actually walks. A plan there is one this hub would hand a session.
27
+ //
28
+ // ── Why "points at a different hub" is deliberately silent ──────────────────
29
+ //
30
+ // The scaffolded plan wanted a warning when a rowed child "moved to another
31
+ // hub". Measured against a real estate, sharing is legitimate and common: an
32
+ // aggregator hub ranks plans whose `parent_plan:` names the program hub that
33
+ // owns them, and demanding exclusivity would fire on every one of those rows
34
+ // with no fix that doesn't break the other hub's claim. So the guard fires only
35
+ // on the unambiguous half — a ranked plan claiming NO parent at all.
36
+
37
+ const ORPHAN_KIND = 'hub-membership-orphan';
38
+ const BACKREF_KIND = 'hub-membership-backref';
39
+
40
+ export function checkHubMembershipDrift(docs, config) {
41
+ const warnings = [];
42
+ const quiet = new Set([
43
+ ...(config.lifecycle?.terminalStatuses ?? []),
44
+ ...(config.lifecycle?.skipWarningsFor ?? []),
45
+ ]);
46
+ const byPath = new Map(docs.map(doc => [doc.path, doc]));
47
+ const refFields = [
48
+ ...(config.referenceFields?.bidirectional ?? []),
49
+ ...(config.referenceFields?.unidirectional ?? []),
50
+ ];
51
+
52
+ const dirOf = (doc) => path.dirname(path.join(config.repoRoot, doc.path));
53
+ const resolve = (ref, dir) => {
54
+ const abs = resolveRefPath(String(ref).replace(/#.*$/, ''), dir, config.repoRoot);
55
+ return abs ? byPath.get(toRepoPath(abs, config.repoRoot)) ?? null : null;
56
+ };
57
+
58
+ // Everything a hub says about other docs: every configured reference field
59
+ // plus every body link. Config-driven rather than a hardcoded field list, so a
60
+ // repo that renames its reference fields keeps working.
61
+ const knownTo = (hub) => {
62
+ const dir = dirOf(hub);
63
+ const known = new Set();
64
+ for (const field of refFields) {
65
+ for (const ref of (hub.refFields?.[field] ?? [])) {
66
+ const target = resolve(ref, dir);
67
+ if (target) known.add(target.path);
68
+ }
69
+ }
70
+ for (const link of (hub.bodyLinks ?? [])) {
71
+ const target = resolve(link.href, dir);
72
+ if (target) known.add(target.path);
73
+ }
74
+ return known;
75
+ };
76
+
77
+ // ── Arrow 1: a claim only the child makes ────────────────────────────────
78
+ // The plan says `parent_plan: <hub>` and the hub references it NOWHERE — not
79
+ // in a reference field, not as a body link. One side of the relationship
80
+ // silently dropped the other, and every hub view (fold, rollup, next-pickup)
81
+ // is computed from the hub's side, so the child is invisible where it thinks
82
+ // it lives. Warns on the HUB: the hub's list is the half that lost the entry.
83
+ const knownCache = new Map();
84
+ for (const child of docs) {
85
+ if (quiet.has(child.status)) continue;
86
+ const parents = child.refFields?.parent_plan ?? [];
87
+ if (parents.length === 0) continue;
88
+ const dir = dirOf(child);
89
+ for (const ref of parents) {
90
+ const hub = resolve(ref, dir);
91
+ // Only hubs are asked to carry a membership list. A plain plan named as a
92
+ // parent rows nothing, and demanding a link back there would be a new
93
+ // opinion rather than a drift check.
94
+ if (!hub || hub.path === child.path || !isHubDoc(hub)) continue;
95
+ if (quiet.has(hub.status)) continue;
96
+ if (!knownCache.has(hub.path)) knownCache.set(hub.path, knownTo(hub));
97
+ if (knownCache.get(hub.path).has(child.path)) continue;
98
+ warnings.push({
99
+ path: hub.path,
100
+ level: 'warning',
101
+ message: `\`${child.path}\` claims \`parent_plan: ${ref}\` but this hub references it nowhere — no reference field, no body link. Add it to the hub's list, or fix the child's \`parent_plan:\`. A membership only one side records is invisible to every hub view (fold, rollup, next-pickup), which all read the hub's half.`,
102
+ meta: { kind: ORPHAN_KIND, child: child.path, ref },
103
+ });
104
+ }
105
+ }
106
+
107
+ // ── Arrow 2: a claim only the hub makes ──────────────────────────────────
108
+ // The hub's BODY ORDER ranks a plan — the list `dotmd runlist next <hub>`
109
+ // walks, so this hub would hand a session that plan — and the plan carries no
110
+ // `parent_plan:` at all. This is the same finding `checkRunlistBackPointers`
111
+ // makes for frontmatter `runlist:` children, extended to the body-order hubs
112
+ // it can't see; children already covered there are skipped, so one missing
113
+ // back-ref is never reported twice. Warns on the CHILD, matching that check:
114
+ // it's the file that needs the edit.
115
+ for (const hub of docs) {
116
+ if (!isHubDoc(hub) || quiet.has(hub.status)) 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.has(child.status)) 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
+ }
146
+ }
147
+
148
+ return warnings;
149
+ }
150
+
151
+ export const HUB_MEMBERSHIP_KINDS = Object.freeze({ orphan: ORPHAN_KIND, backref: BACKREF_KIND });
package/src/hub.mjs CHANGED
@@ -215,3 +215,56 @@ export function applyStatusCase(sample, replacement) {
215
215
  if (/^[A-Z]/.test(sample)) return replacement.charAt(0).toUpperCase() + replacement.slice(1);
216
216
  return replacement;
217
217
  }
218
+
219
+ // Extract ordered plan refs from a hub's body prose. Two shapes:
220
+ // - link-list sections (`## Order of operations`, `## Runlist`, …) — every
221
+ // `.md` link or checklist item, in document order.
222
+ // - ranked-queue tables (`## Ranked queue`, …) — the first `.md` link in each
223
+ // table row (the ranked plan); header/separator rows contribute none.
224
+ // Coordination hubs encode their next-pickup order in the table shape; sprint-
225
+ // ish hubs use the link list. Deduped, first occurrence wins, order preserved.
226
+ //
227
+ // This is a hub's BODY MEMBERSHIP claim, not merely a set of links: it is the
228
+ // order `dotmd runlist <hub>` / `runlist next <hub>` walk, so a plan listed here
229
+ // is one this hub would hand a session. The membership guard leans on exactly
230
+ // that — a plan in some other table is a pointer, a plan in this order is a claim.
231
+ export function detectBodyRunlistRefs(body) {
232
+ if (!body) return [];
233
+ const refs = [];
234
+ const linkRe = /\[[^\]]+\]\(([^)]+\.md(?:#[^)]+)?)\)/;
235
+ const sliceSection = (start) => {
236
+ const rest = body.slice(start);
237
+ const next = rest.search(/^##\s+/m);
238
+ return next >= 0 ? rest.slice(0, next) : rest;
239
+ };
240
+
241
+ const linkSectionRe = /^##\s+(?:Order of operations|Runlist|Execution order|Implementation order|Plan order)\b.*$/gim;
242
+ let match;
243
+ while ((match = linkSectionRe.exec(body)) !== null) {
244
+ const section = sliceSection(match.index + match[0].length);
245
+ const allLinks = new RegExp(linkRe.source, 'g');
246
+ let link;
247
+ while ((link = allLinks.exec(section)) !== null) refs.push(link[1]);
248
+
249
+ const checklistRe = /^\s*[-*]\s+\[[ xX]\]\s+([^\s)]+\.md(?:#[^\s)]+)?)/gm;
250
+ let item;
251
+ while ((item = checklistRe.exec(section)) !== null) refs.push(item[1]);
252
+ }
253
+
254
+ // Ranked-queue tables: the first `.md` link per row is the ranked plan. A
255
+ // header (`| Rank | Plan | … |`) and separator (`|---|`) carry no link and are
256
+ // skipped naturally. Heading may carry trailing text (`## Ranked queue (next
257
+ // pickup)`), so match the leading words, not an exact line.
258
+ const queueSectionRe = /^##\s+(?:Ranked queue|Queue|Pickup order|Heads)\b.*$/gim;
259
+ while ((match = queueSectionRe.exec(body)) !== null) {
260
+ const section = sliceSection(match.index + match[0].length);
261
+ for (const rawLine of section.split('\n')) {
262
+ const line = rawLine.trim();
263
+ if (!line.startsWith('|')) continue;
264
+ const link = firstRowLink(line);
265
+ if (link) refs.push(link);
266
+ }
267
+ }
268
+
269
+ return [...new Set(refs)];
270
+ }
package/src/index.mjs CHANGED
@@ -10,6 +10,7 @@ import { checkClaudeCommands } from './claude-commands.mjs';
10
10
  import { checkGlossaryConfig } from './glossary-check.mjs';
11
11
  import { checkSkillDrift } from './skill-drift.mjs';
12
12
  import { checkHubStatusDrift } from './sync-status.mjs';
13
+ import { checkHubMembershipDrift } from './hub-membership.mjs';
13
14
 
14
15
  // `fast: true` skips every pass that produces warnings/errors — the rendered
15
16
  // index file consumes only status/title/snapshot/etc., not the validation
@@ -143,6 +144,13 @@ export function buildIndex(config, opts = {}) {
143
144
  if (child) child.warnings.push(w);
144
145
  }
145
146
 
147
+ const membershipWarnings = checkHubMembershipDrift(transformedDocs, config);
148
+ warnings.push(...membershipWarnings);
149
+ for (const w of membershipWarnings) {
150
+ const owner = transformedDocs.find(d => d.path === w.path);
151
+ if (owner) owner.warnings.push(w);
152
+ }
153
+
146
154
  const coordHubWarnings = checkCoordinationHubExecutionMode(transformedDocs, config);
147
155
  warnings.push(...coordHubWarnings);
148
156
  for (const w of coordHubWarnings) {
package/src/lifecycle.mjs CHANGED
@@ -107,6 +107,7 @@ function commitLifecycleMutation(filePath, targetPath, config, updates, historyF
107
107
  }),
108
108
  })), ...additionalUpdates],
109
109
  creations: options.creations ?? [],
110
+ guards: options.guards ?? [],
110
111
  gitMove: tracked,
111
112
  gitIndex,
112
113
  operation: 'lifecycle-move',
@@ -115,12 +116,13 @@ function commitLifecycleMutation(filePath, targetPath, config, updates, historyF
115
116
  });
116
117
  return { ...result, sourceContent: moveResult.source.content, updatedPaths: moveResult.updatedPaths };
117
118
  }
118
- if ((options.additionalUpdates?.length ?? 0) > 0 || (options.creations?.length ?? 0) > 0) {
119
+ if ((options.additionalUpdates?.length ?? 0) > 0 || (options.creations?.length ?? 0) > 0 || (options.guards?.length ?? 0) > 0) {
119
120
  const sourceContent = readFileSync(filePath, 'utf8');
120
121
  const result = render(sourceContent);
121
122
  mutateFileSet({
122
123
  updates: [{ path: filePath, expectedContent: sourceContent, content: result.content }, ...(options.additionalUpdates ?? [])],
123
124
  creations: options.creations ?? [],
125
+ guards: options.guards ?? [],
124
126
  }, { repoRoot: config.repoRoot, testHooks: options.testHooks });
125
127
  return { ...result, sourceContent, updatedPaths: [] };
126
128
  }
@@ -767,6 +769,7 @@ export function runArchive(argv, config, opts = {}) {
767
769
  createSection: Boolean(note),
768
770
  additionalUpdates: [...(opts.additionalUpdates ?? []), ...(releaseUpdate ? [releaseUpdate] : [])],
769
771
  creations: opts.creations,
772
+ guards: opts.guards,
770
773
  testHooks: opts.testHooks,
771
774
  });
772
775
  if (!noIndex && !opts.deferIndex) regenIndex(config);
@@ -833,6 +836,7 @@ export function runArchive(argv, config, opts = {}) {
833
836
  testHooks: opts.testHooks,
834
837
  additionalUpdates: [...(opts.additionalUpdates ?? []), ...(releaseUpdate ? [releaseUpdate] : [])],
835
838
  creations: opts.creations,
839
+ guards: opts.guards,
836
840
  skipInboundRefs: opts.skipInboundRefs,
837
841
  bodyTransform: closeoutTemplate ? currentBody => {
838
842
  committedCloseoutAction = planCloseoutInjection(currentBody);
package/src/pickup.mjs CHANGED
@@ -258,19 +258,28 @@ export function preparePlanClaim({ filePath, sourceContent, renderedContent, own
258
258
  const content = recordContent({ identity, sessionId, state: 'owned', now,
259
259
  claimedAt: ownership && !ownership.corrupt ? ownership.claimedAt : null, operation });
260
260
  const updates = [];
261
+ const guards = [];
261
262
  if (renderedContent !== null && renderedContent !== sourceContent) {
262
263
  updates.push({ path: filePath, expectedContent: sourceContent, content: renderedContent });
264
+ } else {
265
+ // An `adopt` claim writes only the ownership record — the plan is already
266
+ // `in-session`, so there is nothing to render. The DECISION still rests on
267
+ // the plan's status, which was read before this mutation was prepared, so
268
+ // the plan file joins as a read-only guard. Without it, a concurrent
269
+ // `set active` could win the status write while this claim takes ownership,
270
+ // leaving a record that owns a plan the file calls `active`.
271
+ guards.push({ path: filePath, expectedContent: sourceContent });
263
272
  }
264
273
  const creations = [];
265
274
  if (ownership) updates.push({ path: recordPath, expectedContent: ownership.raw, content });
266
275
  else creations.push({ path: recordPath, content });
267
- return { identity, recordPath, updates, creations, operationId };
276
+ return { identity, recordPath, updates, creations, guards, operationId };
268
277
  }
269
278
 
270
279
  export function commitPlanClaim(args) {
271
280
  const prepared = preparePlanClaim(args);
272
281
  mkdirSync(path.dirname(prepared.recordPath), { recursive: true });
273
- mutateFileSet({ updates: prepared.updates, creations: prepared.creations }, {
282
+ mutateFileSet({ updates: prepared.updates, creations: prepared.creations, guards: prepared.guards }, {
274
283
  repoRoot: args.config.repoRoot,
275
284
  testHooks: args.testHooks,
276
285
  });
package/src/prompts.mjs CHANGED
@@ -289,6 +289,10 @@ export async function consumePrompt(filePath, config, opts) {
289
289
  skipInboundRefs: true,
290
290
  additionalUpdates: linkedClaim?.prepared?.updates,
291
291
  creations: linkedClaim?.prepared?.creations,
292
+ // An adopt-shaped claim writes no plan content, so the plan file rides
293
+ // along as a read-only guard — the claim is only valid while the plan still
294
+ // says what the disposition was read from.
295
+ guards: linkedClaim?.prepared?.guards,
292
296
  });
293
297
  const consumedBody = archiveResult?.consumedBody ?? body;
294
298
 
package/src/runlist.mjs CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  toSlug,
15
15
  warn,
16
16
  } from './util.mjs';
17
- import { firstRowLink, isCoordinationHub, isRoadmapHub } from './hub.mjs';
17
+ import { detectBodyRunlistRefs, isCoordinationHub, isRoadmapHub } from './hub.mjs';
18
18
  import { resolveDocArg } from './index.mjs';
19
19
  import { runlistChildContent, slugify, titleize } from './new.mjs';
20
20
  import { bold, cyan, dim, green, red, yellow } from './color.mjs';
@@ -312,56 +312,6 @@ function resolveRunlistRefs(refs, hubAbsPath, config) {
312
312
  return out;
313
313
  }
314
314
 
315
- // Extract ordered plan refs from a hub's body prose. Two shapes:
316
- // - link-list sections (`## Order of operations`, `## Runlist`, …) — every
317
- // `.md` link or checklist item, in document order.
318
- // - ranked-queue tables (`## Ranked queue`, …) — the first `.md` link in each
319
- // table row (the ranked plan); header/separator rows contribute none.
320
- // Coordination hubs encode their next-pickup order in the table shape; sprint-
321
- // ish hubs use the link list. Deduped, first occurrence wins, order preserved.
322
- function detectBodyRunlistRefs(body) {
323
- if (!body) return [];
324
- const refs = [];
325
- const linkRe = /\[[^\]]+\]\(([^)]+\.md(?:#[^)]+)?)\)/;
326
- const sliceSection = (start) => {
327
- const rest = body.slice(start);
328
- const next = rest.search(/^##\s+/m);
329
- return next >= 0 ? rest.slice(0, next) : rest;
330
- };
331
-
332
- const linkSectionRe = /^##\s+(?:Order of operations|Runlist|Execution order|Implementation order|Plan order)\b.*$/gim;
333
- let match;
334
- while ((match = linkSectionRe.exec(body)) !== null) {
335
- const section = sliceSection(match.index + match[0].length);
336
- const allLinks = new RegExp(linkRe.source, 'g');
337
- let link;
338
- while ((link = allLinks.exec(section)) !== null) refs.push(link[1]);
339
-
340
- const checklistRe = /^\s*[-*]\s+\[[ xX]\]\s+([^\s)]+\.md(?:#[^\s)]+)?)/gm;
341
- let item;
342
- while ((item = checklistRe.exec(section)) !== null) refs.push(item[1]);
343
- }
344
-
345
- // Ranked-queue tables: the first `.md` link per row is the ranked plan. A
346
- // header (`| Rank | Plan | … |`) and separator (`|---|`) carry no link and are
347
- // skipped naturally. Heading may carry trailing text (`## Ranked queue (next
348
- // pickup)`), so match the leading words, not an exact line.
349
- const queueSectionRe = /^##\s+(?:Ranked queue|Queue|Pickup order|Heads)\b.*$/gim;
350
- while ((match = queueSectionRe.exec(body)) !== null) {
351
- const section = sliceSection(match.index + match[0].length);
352
- for (const rawLine of section.split('\n')) {
353
- const line = rawLine.trim();
354
- if (!line.startsWith('|')) continue;
355
- // `firstRowLink` is the shared row→target anchor: the hub-status guard
356
- // reads the same link out of the same row, so next-pickup and drift
357
- // detection can never disagree about which plan a row names.
358
- const link = firstRowLink(line);
359
- if (link) refs.push(link);
360
- }
361
- }
362
-
363
- return [...new Set(refs)];
364
- }
365
315
 
366
316
  // Label for a hub's next-pickup child: its slug with the hub's leading module
367
317
  // segment stripped when shared (so `founder-runlist` → `founder-brand-conflicts`