dotmd-cli 0.73.0 → 0.74.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.73.0",
3
+ "version": "0.74.0",
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,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/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`