dotmd-cli 0.72.1 → 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/bin/dotmd.mjs CHANGED
@@ -205,6 +205,7 @@ Validate & Fix:
205
205
  self-check Project/version skew diagnostic (alias: doctor --project)
206
206
  lint [--fix] Check and auto-fix frontmatter issues
207
207
  fix-refs [--dry-run] Auto-fix broken reference paths + body links
208
+ sync-status [<hub>...] [--adopt] Rewrite hub table rows whose printed status drifted from the plan
208
209
 
209
210
  Lifecycle:
210
211
  use <file> Open a plan (mark in-session + print it) or consume a prompt
@@ -772,6 +773,29 @@ Modes:
772
773
  Sub-modes (--statuses, --migrate-*, --frontmatter-fix, --project) keep their
773
774
  existing contracts: they write by default and honor --dry-run.`,
774
775
 
776
+ 'sync-status': `dotmd sync-status — rewrite hub rows whose printed status drifted
777
+
778
+ A runlist / coordination / roadmap hub rows its children in a table and prints
779
+ each child's status by hand. This sweeps every hub, compares each row's status
780
+ word against the plan it links to, and rewrites the ones that drifted. Case is
781
+ preserved (\`Active\` stays capitalized), and nothing else in the cell is touched.
782
+
783
+ dotmd sync-status every hub in the repo (the normal case)
784
+ dotmd sync-status <hub>... narrow to named hubs
785
+ dotmd sync-status --adopt also wrap managed status words in <!--s-->…<!--/s-->
786
+ dotmd sync-status --dry-run --json
787
+
788
+ The status word is found positionally — comments stripped, cell's leading token,
789
+ matched against the vocabulary the CHILD's type declares — so no marker is
790
+ needed. A marker pins the span for the rows position can't read (a status sitting
791
+ behind a bolded headline). \`dotmd check\` warns on positional drift and ERRORS on
792
+ marked drift: the marker is the author saying dotmd owns that word.
793
+
794
+ Rows under a status column with no readable status word are reported by
795
+ \`dotmd check\` and left alone here; rows in a table with no status column at all
796
+ are not findings. Not to be confused with \`dotmd set <status>\`, which changes a
797
+ document's OWN status — this only rewrites what a hub prints about others.`,
798
+
775
799
  'fix-refs': `dotmd fix-refs — auto-fix broken reference paths
776
800
 
777
801
  Scans all docs for reference fields that point to non-existent files,
@@ -1701,6 +1725,7 @@ async function main() {
1701
1725
  if (command === 'rename') { const { runRename } = await import('../src/rename.mjs'); await runRename(restArgs, config, { dryRun }); return; }
1702
1726
  if (command === 'migrate') { const { runMigrate } = await import('../src/migrate.mjs'); runMigrate(restArgs, config, { dryRun }); return; }
1703
1727
  if (command === 'fix-refs') { const { runFixRefs } = await import('../src/fix-refs.mjs'); runFixRefs(restArgs, config, { dryRun }); return; }
1728
+ if (command === 'sync-status') { const { runSyncStatus } = await import('../src/sync-status.mjs'); await runSyncStatus(restArgs, config, { dryRun }); return; }
1704
1729
  if (command === 'self-check') {
1705
1730
  const { runDoctor } = await import('../src/doctor.mjs');
1706
1731
  runDoctor(['--project', ...restArgs], config, { dryRun });
@@ -1811,8 +1836,12 @@ async function main() {
1811
1836
  // Auto-fix: broken refs, then lint, then rebuild index
1812
1837
  const { fixBrokenRefs } = await import('../src/fix-refs.mjs');
1813
1838
  const { runLint } = await import('../src/lint.mjs');
1839
+ const { syncHubStatuses } = await import('../src/sync-status.mjs');
1814
1840
  fixBrokenRefs(config, { dryRun, quiet: false });
1815
1841
  runLint(['--fix'], config, { dryRun });
1842
+ // Rewrites drifted status TOKENS only. Adding markers is a content edit to
1843
+ // prose the user wrote, so it stays opt-in behind `sync-status --adopt`.
1844
+ syncHubStatuses(config, { docs: buildIndex(config).docs, dryRun, quiet: false });
1816
1845
  if (config.indexPath) {
1817
1846
  if (!dryRun) {
1818
1847
  const { writeRenderedIndex } = await import('../src/index-file.mjs');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.72.1",
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",
package/src/commands.mjs CHANGED
@@ -133,6 +133,7 @@ const definitions = [
133
133
  command('rename', mutates('managed source, same-root destination, and rewrite sweep'), 'mutate', [form('<old> [new]', { args: positionals(1, 2), options: [flag('--show-files')] })]),
134
134
  command('migrate', mutates('managed source sweep'), 'mutate', [form('<field> <old> <new> [files...]', { args: positionals(3, Infinity), options: [flag('--show-files')] })]),
135
135
  command('fix-refs', mutates('managed source sweep'), 'mutate', [form('', { options: [flag('--show-files')] })]),
136
+ command('sync-status', mutates('managed source sweep'), 'mutate', [form('[hubs...]', { args: positionals(0, Infinity), options: [flag('--adopt'), flag('--json')] })]),
136
137
  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('--json'), flag('--include-archived')] })]),
137
138
  command('statuses', mutates('project config path; document scan is read-only'), 'mutate', [
138
139
  form('list', { subcommands: ['list'], options: [value('--type'), flag('--json')] }),
package/src/doctor.mjs CHANGED
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fixBrokenRefs } from './fix-refs.mjs';
4
4
  import { runLint } from './lint.mjs';
5
+ import { syncHubStatuses } from './sync-status.mjs';
5
6
  import { runTouch } from './lifecycle.mjs';
6
7
  import { buildIndex, collectDocFiles } from './index.mjs';
7
8
  import { writeRenderedIndex } from './index-file.mjs';
@@ -157,13 +158,22 @@ export function runDoctor(argv, config, opts = {}) {
157
158
  process.stdout.write('\n' + bold('3. Fixing long frontmatter...') + '\n');
158
159
  runFrontmatterFix(config, { dryRun });
159
160
 
160
- // Step 4: Sync dates from git
161
- process.stdout.write('\n' + bold('4. Syncing dates from git...') + '\n');
161
+ // Step 4: Rewrite hub rows whose printed status drifted from the plan they
162
+ // link to. Tokens only — adding markers is a content edit to prose the user
163
+ // wrote, so it stays opt-in behind `dotmd sync-status --adopt`.
164
+ process.stdout.write('\n' + bold('4. Syncing hub status rows...') + '\n');
165
+ const hubSync = syncHubStatuses(config, { docs: buildIndex(config).docs, dryRun });
166
+ if (hubSync.fixed === 0 && hubSync.adopted === 0 && hubSync.unreadable === 0) {
167
+ process.stdout.write('Hub status rows are in sync.\n');
168
+ }
169
+
170
+ // Step 5: Sync dates from git
171
+ process.stdout.write('\n' + bold('5. Syncing dates from git...') + '\n');
162
172
  runTouch(['--git'], config, { dryRun });
163
173
 
164
- // Step 5: Regenerate index. Heading always prints so numbering remains
174
+ // Step 6: Regenerate index. Heading always prints so numbering remains
165
175
  // contiguous even when `index.path` isn't configured.
166
- process.stdout.write('\n' + bold('5. Regenerating index...') + '\n');
176
+ process.stdout.write('\n' + bold('6. Regenerating index...') + '\n');
167
177
  if (!config.indexPath) {
168
178
  process.stdout.write('No index path configured (skip).\n');
169
179
  } else if (dryRun) {
@@ -173,11 +183,11 @@ export function runDoctor(argv, config, opts = {}) {
173
183
  process.stdout.write('Index updated.\n');
174
184
  }
175
185
 
176
- // Step 6: Clean up retired Claude Code command scaffolding. The per-repo
186
+ // Step 7: Clean up retired Claude Code command scaffolding. The per-repo
177
187
  // `.claude/commands/{plans,docs}.md` files are superseded by the dotmd plugin
178
188
  // skill; doctor sweeps any leftover banner-stamped (dotmd-generated) files.
179
189
  // Always print the heading so the numbering remains contiguous.
180
- process.stdout.write('\n' + bold('6. Claude Code commands:') + '\n');
190
+ process.stdout.write('\n' + bold('7. Claude Code commands:') + '\n');
181
191
  if (dryRun) {
182
192
  const wouldRemove = removeGeneratedSlashCommands(config.repoRoot, { dryRun: true });
183
193
  if (wouldRemove.length === 0) {
@@ -198,8 +208,8 @@ export function runDoctor(argv, config, opts = {}) {
198
208
  }
199
209
  }
200
210
 
201
- // Step 7: Show remaining check
202
- const issueLabel = dryRun ? '7. Remaining issues in current tree (preview fixes above were not applied):' : '7. Remaining issues:';
211
+ // Step 8: Show remaining check
212
+ const issueLabel = dryRun ? '8. Remaining issues in current tree (preview fixes above were not applied):' : '8. Remaining issues:';
203
213
  process.stdout.write('\n' + bold(issueLabel) + '\n');
204
214
  const freshIndex = buildIndex(config);
205
215
  process.stdout.write(renderCheck(freshIndex, config));
@@ -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 ADDED
@@ -0,0 +1,270 @@
1
+ // Hub primitives: what counts as a hub, and how to read a hub's body tables.
2
+ //
3
+ // This module is deliberately a LEAF — it imports nothing from dotmd. The hub
4
+ // predicates used to live in `runlist.mjs`, but `runlist.mjs` imports
5
+ // `index.mjs` (for `resolveDocArg`), and the hub-status check has to run FROM
6
+ // `index.mjs`. Keeping the predicates here is what lets both sides share one
7
+ // definition of "hub" instead of re-deriving it — the exact duplication the
8
+ // hub-status-drift plan exists to avoid. `runlist.mjs` re-exports them so every
9
+ // existing importer is unaffected.
10
+
11
+ // A *coordination hub* is a prose-first plan that sits above a cluster of other
12
+ // plans — a "runlist" in the platform sense (master-runlist, ai-runlist, …)
13
+ // rather than a strictly-ordered frontmatter `runlist:` sprint. The signal is
14
+ // already in frontmatter (`execution_mode: coordination`), with the
15
+ // `*-runlist` / `runlist` naming convention as a fallback for the few hubs that
16
+ // predate the field. These plans aren't units of executable work — they're
17
+ // navigation maps — so the triage view tags them and lifts them out of the
18
+ // leaf-plan flow rather than treating them as one more active plan.
19
+ export function isCoordinationHub(doc) {
20
+ if (!doc) return false;
21
+ if (doc.type && doc.type !== 'plan') return false;
22
+ // Broad "held-out navigational hub" predicate: both coordination hubs and the
23
+ // tier-3 roadmap (`execution_mode: roadmap`) are lifted out of the active count
24
+ // and into a hub section. `isRoadmapHub` is the finer split the tier-3 views
25
+ // use to promote a roadmap above the Runlists section; here a roadmap counts as
26
+ // a coordination hub so all the existing held-out plumbing covers it for free.
27
+ if (doc.executionMode === 'coordination' || doc.executionMode === 'roadmap') return true;
28
+ const base = (doc.path.split('/').pop() || '').replace(/\.md$/, '');
29
+ return base === 'runlist' || base.endsWith('-runlist');
30
+ }
31
+
32
+ // A *roadmap* is the tier-3 hub: a coordination hub whose children are themselves
33
+ // hubs (runlists / coordination hubs), with progress rolled up across them. The
34
+ // signal is explicit — `execution_mode: roadmap` — with NO slug-convention
35
+ // fallback (unlike coordination hubs' `*-runlist`): there's no naming convention
36
+ // for roadmaps, and `dotmd check` nudges the structural case (a coordination hub
37
+ // that points at other hubs) toward the explicit field rather than auto-promoting.
38
+ export function isRoadmapHub(doc) {
39
+ if (!doc) return false;
40
+ if (doc.type && doc.type !== 'plan') return false;
41
+ return doc.executionMode === 'roadmap';
42
+ }
43
+
44
+ // A *sprint hub* declares its ordered children in frontmatter (`runlist:`).
45
+ export function isSprintHub(doc) {
46
+ return (doc?.refFields?.runlist ?? []).length > 0;
47
+ }
48
+
49
+ // Every shape of hub: a frontmatter sprint, a coordination map, or a roadmap.
50
+ // This is the set whose body tables the hub-status guard reads.
51
+ export function isHubDoc(doc) {
52
+ return isSprintHub(doc) || isCoordinationHub(doc);
53
+ }
54
+
55
+ // The row→target anchor: the first `.md` link in a table row is the plan that
56
+ // row is ABOUT. Shared by next-pickup detection (`detectBodyRunlistRefs`) and
57
+ // the hub-status guard, so the two can never disagree about which plan a given
58
+ // row names.
59
+ const ROW_LINK_RE = /\[[^\]]+\]\(([^)]+\.md(?:#[^)]+)?)\)/;
60
+
61
+ export function firstRowLink(line) {
62
+ const match = ROW_LINK_RE.exec(line);
63
+ return match ? match[1] : null;
64
+ }
65
+
66
+ // ─── Reading the status word out of a hub row ──────────────────────────────
67
+ //
68
+ // A hub row prints its child's status by hand. Two ways to find that word:
69
+ //
70
+ // position — strip HTML comments, take the cell's LEADING token, match it
71
+ // against the vocabulary the child's own type declares. No marker
72
+ // needed, so this works on tables that already exist. Measured on
73
+ // 472 real rows: agrees with an explicitly marked span 96% of the
74
+ // time and picks a wrong token zero times. Every miss leaves a
75
+ // leading word outside the vocabulary, so it declines (and the row
76
+ // is reported as unreadable) rather than rewriting confidently.
77
+ // marker — `<!--s-->active<!--/s-->` pins the span when position can't find
78
+ // it (the measured 4%: a status sitting behind a bolded headline).
79
+ //
80
+ // The marker is 19 characters against 62 for dotmd's block grammar
81
+ // (`<!-- GENERATED:dotmd:start -->`). That difference only matters because this
82
+ // one recurs hundreds of times per estate. It makes four comment grammars in
83
+ // dotmd; the other three are block-level and correct at block level. Do NOT
84
+ // "unify" them, and do not let the count grow again.
85
+ export const MARKER_OPEN = '<!--s-->';
86
+ export const MARKER_CLOSE = '<!--/s-->';
87
+
88
+ const MARKED_SPAN_RE = /<!--\s*s\s*-->([\s\S]*?)<!--\s*\/s\s*-->/;
89
+ const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
90
+ const FENCE_RE = /^\s{0,3}(`{3,}|~{3,})/;
91
+ // Statuses are hyphenated single words (`in-session`, `queued-after`). The
92
+ // optional emphasis prefix lets `**active**` read as a leading token while
93
+ // `**Phase 1 gate** — active` still declines: the leading word there is `Phase`.
94
+ const LEADING_TOKEN_RE = /^(\s*)(\*\*|__|\*|_|`)?([A-Za-z][A-Za-z0-9-]*)/;
95
+
96
+ // Split a markdown table row into cells, keeping each cell's offsets into the
97
+ // line so a fix can rewrite one word without touching the rest of the row.
98
+ // Honors `\|` escapes and pipes inside inline code.
99
+ export function splitRowCells(line) {
100
+ const cells = [];
101
+ let start = 0;
102
+ let inCode = false;
103
+ for (let i = 0; i < line.length; i++) {
104
+ const ch = line[i];
105
+ if (ch === '\\') { i++; continue; }
106
+ if (ch === '`') { inCode = !inCode; continue; }
107
+ if (ch === '|' && !inCode) {
108
+ cells.push({ raw: line.slice(start, i), start, end: i });
109
+ start = i + 1;
110
+ }
111
+ }
112
+ cells.push({ raw: line.slice(start), start, end: line.length });
113
+ // Rows are conventionally pipe-delimited on both ends; drop the empty edge
114
+ // segments those produce so column indexes line up with the header's.
115
+ if (cells.length && cells[0].raw.trim() === '') cells.shift();
116
+ if (cells.length && cells[cells.length - 1].raw.trim() === '') cells.pop();
117
+ return cells;
118
+ }
119
+
120
+ function isDelimiterRow(cells) {
121
+ return cells.length > 0 && cells.every(cell => /^\s*:?-+:?\s*$/.test(cell.raw));
122
+ }
123
+
124
+ // A table declares a status column when a header cell IS "status"/"state".
125
+ // Deliberately exact (after stripping emphasis): a header that only mentions
126
+ // status in passing is not a promise that the column holds one.
127
+ function isStatusHeader(raw) {
128
+ const normalized = raw.replace(/[*_`]/g, '').trim().toLowerCase();
129
+ return normalized === 'status' || normalized === 'state';
130
+ }
131
+
132
+ export function findMarkedSpan(line) {
133
+ const match = MARKED_SPAN_RE.exec(line);
134
+ if (!match) return null;
135
+ const innerStart = match.index + match[0].indexOf('-->') + 3;
136
+ const inner = match[1];
137
+ const lead = inner.length - inner.trimStart().length;
138
+ const text = inner.trim();
139
+ if (!text) return null;
140
+ return { start: innerStart + lead, end: innerStart + lead + text.length, text, marked: true };
141
+ }
142
+
143
+ // Positional read of a cell: leading token, comments stripped, vocabulary-gated.
144
+ // `isKnownStatus` is the caller's vocabulary test — it depends on the CHILD's
145
+ // type, which only the caller can resolve, so it is injected rather than
146
+ // guessed here. Returns line-absolute offsets, like `findMarkedSpan`.
147
+ export function readPositionalToken(cell, isKnownStatus) {
148
+ if (!cell) return null;
149
+ // Blank out comments instead of deleting them so offsets stay line-absolute.
150
+ const masked = cell.raw.replace(HTML_COMMENT_RE, comment => ' '.repeat(comment.length));
151
+ const match = LEADING_TOKEN_RE.exec(masked);
152
+ if (!match) return null;
153
+ const word = match[3];
154
+ if (!isKnownStatus(word)) return null;
155
+ const start = cell.start + match[1].length + (match[2]?.length ?? 0);
156
+ return { start, end: start + word.length, text: word, marked: false };
157
+ }
158
+
159
+ // Walk a hub body's markdown tables and return one entry per row that names a
160
+ // child (`.md` link). Pure: no vocabulary, no resolution, no IO — the caller
161
+ // resolves the target and then reads the status word, because the vocabulary
162
+ // depends on the child's own type.
163
+ //
164
+ // Returns [{ lineIndex, ref, hasStatusColumn, statusCell, marked }] where
165
+ // `lineIndex` is 0-based into `body.split('\n')` and `statusCell`/`marked`
166
+ // carry line-absolute offsets.
167
+ export function scanHubStatusRows(body) {
168
+ if (!body) return [];
169
+ const lines = body.split('\n');
170
+ const rows = [];
171
+ let fence = null;
172
+ let table = null;
173
+
174
+ for (let i = 0; i < lines.length; i++) {
175
+ const line = lines[i];
176
+ const fenceMatch = FENCE_RE.exec(line);
177
+ if (fence) {
178
+ if (fenceMatch && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length) fence = null;
179
+ continue;
180
+ }
181
+ // Fenced examples routinely contain sample hub rows (this plan's own body
182
+ // does). Reading them would report drift against a fictional child.
183
+ if (fenceMatch) { fence = fenceMatch[1]; table = null; continue; }
184
+
185
+ if (!line.trim().startsWith('|')) { table = null; continue; }
186
+ const cells = splitRowCells(line);
187
+
188
+ if (!table) {
189
+ // A pipe line is a table header only when a delimiter row follows it.
190
+ const next = i + 1 < lines.length ? lines[i + 1] : '';
191
+ if (next.trim().startsWith('|') && isDelimiterRow(splitRowCells(next))) {
192
+ table = { statusColumn: cells.findIndex(cell => isStatusHeader(cell.raw)) };
193
+ }
194
+ continue;
195
+ }
196
+ if (isDelimiterRow(cells)) continue;
197
+
198
+ const ref = firstRowLink(line);
199
+ if (!ref) continue;
200
+ rows.push({
201
+ lineIndex: i,
202
+ ref,
203
+ hasStatusColumn: table.statusColumn >= 0,
204
+ statusCell: table.statusColumn >= 0 ? cells[table.statusColumn] ?? null : null,
205
+ marked: findMarkedSpan(line),
206
+ });
207
+ }
208
+ return rows;
209
+ }
210
+
211
+ // Write `replacement` in the case style the author used, so a fix never
212
+ // restyles a table (`Active` stays capitalized, `ACTIVE` stays shouted).
213
+ export function applyStatusCase(sample, replacement) {
214
+ if (/[A-Z]/.test(sample) && sample === sample.toUpperCase()) return replacement.toUpperCase();
215
+ if (/^[A-Z]/.test(sample)) return replacement.charAt(0).toUpperCase() + replacement.slice(1);
216
+ return replacement;
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
@@ -9,6 +9,8 @@ import { checkIndex } from './index-file.mjs';
9
9
  import { checkClaudeCommands } from './claude-commands.mjs';
10
10
  import { checkGlossaryConfig } from './glossary-check.mjs';
11
11
  import { checkSkillDrift } from './skill-drift.mjs';
12
+ import { checkHubStatusDrift } from './sync-status.mjs';
13
+ import { checkHubMembershipDrift } from './hub-membership.mjs';
12
14
 
13
15
  // `fast: true` skips every pass that produces warnings/errors — the rendered
14
16
  // index file consumes only status/title/snapshot/etc., not the validation
@@ -117,6 +119,20 @@ export function buildIndex(config, opts = {}) {
117
119
  errors.push(...indexCheck.errors);
118
120
  }
119
121
 
122
+ // Hub status drift produces ERRORS (a drifted marked span), so it runs in
123
+ // errorsOnly mode too — that's what keeps `dotmd hud`'s error count equal to
124
+ // `dotmd check`'s. Its warnings still obey the warning-only gate.
125
+ if (!fast) {
126
+ const hubStatus = checkHubStatusDrift(transformedDocs, config);
127
+ errors.push(...hubStatus.errors);
128
+ if (!skipWarningOnlyChecks) warnings.push(...hubStatus.warnings);
129
+ for (const entry of [...hubStatus.errors, ...(skipWarningOnlyChecks ? [] : hubStatus.warnings)]) {
130
+ const hub = transformedDocs.find(d => d.path === entry.path);
131
+ if (!hub) continue;
132
+ (entry.level === 'error' ? hub.errors : hub.warnings).push(entry);
133
+ }
134
+ }
135
+
120
136
  if (!skipWarningOnlyChecks) {
121
137
  const refCheck = checkBidirectionalReferences(transformedDocs, config);
122
138
  warnings.push(...refCheck.warnings);
@@ -128,6 +144,13 @@ export function buildIndex(config, opts = {}) {
128
144
  if (child) child.warnings.push(w);
129
145
  }
130
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
+
131
154
  const coordHubWarnings = checkCoordinationHubExecutionMode(transformedDocs, config);
132
155
  warnings.push(...coordHubWarnings);
133
156
  for (const w of coordHubWarnings) {
package/src/render.mjs CHANGED
@@ -416,6 +416,16 @@ export function classifyIssueAction(issue) {
416
416
  const message = issue?.message ?? '';
417
417
  const file = issue?.path ?? '<file>';
418
418
 
419
+ // Hub status rows carry a `meta.kind`, so classify them by that rather than by
420
+ // re-matching their prose. Drift is mechanically fixable; an unreadable cell
421
+ // is not — nothing can guess which word in it was meant to be the status.
422
+ if (issue?.meta?.kind === 'hub-status-drift') {
423
+ return { action: 'dotmd sync-status', fixable: true, label: 'hub status drift' };
424
+ }
425
+ if (issue?.meta?.kind === 'hub-status-unreadable') {
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
+ }
428
+
419
429
  if (/Missing frontmatter `status`/.test(message)) {
420
430
  return { action: `dotmd bulk-tag ${file}`, fixable: false, label: 'missing status' };
421
431
  }
package/src/runlist.mjs CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  toSlug,
15
15
  warn,
16
16
  } from './util.mjs';
17
+ import { detectBodyRunlistRefs, isCoordinationHub, isRoadmapHub } from './hub.mjs';
17
18
  import { resolveDocArg } from './index.mjs';
18
19
  import { runlistChildContent, slugify, titleize } from './new.mjs';
19
20
  import { bold, cyan, dim, green, red, yellow } from './color.mjs';
@@ -96,38 +97,11 @@ export function buildRunlistIndex(index, config) {
96
97
  return { hubs, childToHub };
97
98
  }
98
99
 
99
- // A *coordination hub* is a prose-first plan that sits above a cluster of other
100
- // plans — a "runlist" in the platform sense (master-runlist, ai-runlist, …)
101
- // rather than a strictly-ordered frontmatter `runlist:` sprint. The signal is
102
- // already in frontmatter (`execution_mode: coordination`), with the
103
- // `*-runlist` / `runlist` naming convention as a fallback for the few hubs that
104
- // predate the field. These plans aren't units of executable work — they're
105
- // navigation maps — so the triage view tags them and lifts them out of the
106
- // leaf-plan flow rather than treating them as one more active plan.
107
- export function isCoordinationHub(doc) {
108
- if (!doc) return false;
109
- if (doc.type && doc.type !== 'plan') return false;
110
- // Broad "held-out navigational hub" predicate: both coordination hubs and the
111
- // tier-3 roadmap (`execution_mode: roadmap`) are lifted out of the active count
112
- // and into a hub section. `isRoadmapHub` is the finer split the tier-3 views
113
- // use to promote a roadmap above the Runlists section; here a roadmap counts as
114
- // a coordination hub so all the existing held-out plumbing covers it for free.
115
- if (doc.executionMode === 'coordination' || doc.executionMode === 'roadmap') return true;
116
- const base = (doc.path.split('/').pop() || '').replace(/\.md$/, '');
117
- return base === 'runlist' || base.endsWith('-runlist');
118
- }
119
-
120
- // A *roadmap* is the tier-3 hub: a coordination hub whose children are themselves
121
- // hubs (runlists / coordination hubs), with progress rolled up across them. The
122
- // signal is explicit — `execution_mode: roadmap` — with NO slug-convention
123
- // fallback (unlike coordination hubs' `*-runlist`): there's no naming convention
124
- // for roadmaps, and `dotmd check` nudges the structural case (a coordination hub
125
- // that points at other hubs) toward the explicit field rather than auto-promoting.
126
- export function isRoadmapHub(doc) {
127
- if (!doc) return false;
128
- if (doc.type && doc.type !== 'plan') return false;
129
- return doc.executionMode === 'roadmap';
130
- }
100
+ // The hub predicates live in `hub.mjs` — a leaf module, so the check pipeline
101
+ // (which runs from `index.mjs`, and can't import this file without a cycle) can
102
+ // share ONE definition of "hub" with the views here. Re-exported so every
103
+ // existing importer keeps reaching them at `runlist.mjs`.
104
+ export { isCoordinationHub, isRoadmapHub };
131
105
 
132
106
  // Map each coordination hub to a `childCount` derived from its `related_plans:`
133
107
  // cluster (resolved against the index; peers/self excluded). It's an
@@ -338,53 +312,6 @@ function resolveRunlistRefs(refs, hubAbsPath, config) {
338
312
  return out;
339
313
  }
340
314
 
341
- // Extract ordered plan refs from a hub's body prose. Two shapes:
342
- // - link-list sections (`## Order of operations`, `## Runlist`, …) — every
343
- // `.md` link or checklist item, in document order.
344
- // - ranked-queue tables (`## Ranked queue`, …) — the first `.md` link in each
345
- // table row (the ranked plan); header/separator rows contribute none.
346
- // Coordination hubs encode their next-pickup order in the table shape; sprint-
347
- // ish hubs use the link list. Deduped, first occurrence wins, order preserved.
348
- function detectBodyRunlistRefs(body) {
349
- if (!body) return [];
350
- const refs = [];
351
- const linkRe = /\[[^\]]+\]\(([^)]+\.md(?:#[^)]+)?)\)/;
352
- const sliceSection = (start) => {
353
- const rest = body.slice(start);
354
- const next = rest.search(/^##\s+/m);
355
- return next >= 0 ? rest.slice(0, next) : rest;
356
- };
357
-
358
- const linkSectionRe = /^##\s+(?:Order of operations|Runlist|Execution order|Implementation order|Plan order)\b.*$/gim;
359
- let match;
360
- while ((match = linkSectionRe.exec(body)) !== null) {
361
- const section = sliceSection(match.index + match[0].length);
362
- const allLinks = new RegExp(linkRe.source, 'g');
363
- let link;
364
- while ((link = allLinks.exec(section)) !== null) refs.push(link[1]);
365
-
366
- const checklistRe = /^\s*[-*]\s+\[[ xX]\]\s+([^\s)]+\.md(?:#[^\s)]+)?)/gm;
367
- let item;
368
- while ((item = checklistRe.exec(section)) !== null) refs.push(item[1]);
369
- }
370
-
371
- // Ranked-queue tables: the first `.md` link per row is the ranked plan. A
372
- // header (`| Rank | Plan | … |`) and separator (`|---|`) carry no link and are
373
- // skipped naturally. Heading may carry trailing text (`## Ranked queue (next
374
- // pickup)`), so match the leading words, not an exact line.
375
- const queueSectionRe = /^##\s+(?:Ranked queue|Queue|Pickup order|Heads)\b.*$/gim;
376
- while ((match = queueSectionRe.exec(body)) !== null) {
377
- const section = sliceSection(match.index + match[0].length);
378
- for (const rawLine of section.split('\n')) {
379
- const line = rawLine.trim();
380
- if (!line.startsWith('|')) continue;
381
- const link = linkRe.exec(line);
382
- if (link) refs.push(link[1]);
383
- }
384
- }
385
-
386
- return [...new Set(refs)];
387
- }
388
315
 
389
316
  // Label for a hub's next-pickup child: its slug with the hub's leading module
390
317
  // segment stripped when shared (so `founder-runlist` → `founder-brand-conflicts`
@@ -0,0 +1,314 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { extractFrontmatter, normalizeEol } from './frontmatter.mjs';
4
+ import { die, isArchivedPath, resolveRefPath, toRepoPath } from './util.mjs';
5
+ import { cyan, dim, green, red, yellow } from './color.mjs';
6
+ import { authorizeManagedSweep } from './managed-path.mjs';
7
+ import {
8
+ MARKER_CLOSE,
9
+ MARKER_OPEN,
10
+ applyStatusCase,
11
+ isHubDoc,
12
+ readPositionalToken,
13
+ scanHubStatusRows,
14
+ } from './hub.mjs';
15
+
16
+ // Hub status drift: a hub rows its children in a table and prints each child's
17
+ // status by hand. Nothing keeps that word honest — the child goes `archived`,
18
+ // the hub still says `active`, and every later reader plans against a status
19
+ // that stopped being true.
20
+ //
21
+ // dotmd already reads those rows to compute next-pickup, so the guard is
22
+ // standing next to the data it needs. Everything it needs is already owned:
23
+ // `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
26
+ // archive-dir-outranks-frontmatter for archived children.
27
+ //
28
+ // The three-way split below is the part that is easy to get wrong. A row with
29
+ // no readable status token is NOT automatically a finding:
30
+ //
31
+ // pointer — the table has no status column at all. Legitimate; many hubs
32
+ // row a child just to say "related". SILENT.
33
+ // unreadable — the table HAS a `Status`/`State` column but no status word can
34
+ // be read from the cell. WARNING: the row opts out of the
35
+ // invariant invisibly, and that is exactly where real drift hides.
36
+ // manageable — a token was found (positionally or in a marker). Compare it.
37
+ //
38
+ // Collapsing those (treating every unmatched row as a finding) floods perfectly
39
+ // correct hubs with false positives.
40
+
41
+ // Warning when inferred, error when marked — no config knob. `dotmd check`
42
+ // exits 0 on warnings and 1 on errors, and that asymmetry is the whole point: a
43
+ // positional match is dotmd INFERRING from prose, so it nudges; a marked span is
44
+ // the author declaring "dotmd owns this word", so drifting it fails the check.
45
+ // No repo that never asked for this feature starts failing builds over an
46
+ // inference.
47
+ const DRIFT_KIND = 'hub-status-drift';
48
+ const UNREADABLE_KIND = 'hub-status-unreadable';
49
+
50
+ // Mirror `isValidStatus` in validate.mjs: a doc's own type owns its vocabulary,
51
+ // falling back to the root set and then the global union for untyped docs.
52
+ function statusVocabulary(doc, config) {
53
+ if (doc.type) {
54
+ const typeSet = config.typeStatuses?.get(doc.type);
55
+ if (typeSet?.size) return typeSet;
56
+ }
57
+ return config.rootValidStatuses?.get(doc.root) ?? config.validStatuses ?? new Set();
58
+ }
59
+
60
+ // A doc physically living under the archive dir is archived, whatever its
61
+ // frontmatter says — the same precedence the rest of dotmd applies. (The
62
+ // frontmatter itself is already an error from `validateDoc`; the hub row should
63
+ // still be told the truth rather than agreeing with the stale word.)
64
+ function effectiveStatus(doc, config) {
65
+ const archiveStatuses = config.lifecycle?.archiveStatuses ?? new Set(['archived']);
66
+ if (isArchivedPath(doc.path, config) && !archiveStatuses.has(doc.status)) {
67
+ return [...archiveStatuses][0] ?? 'archived';
68
+ }
69
+ return doc.status ?? null;
70
+ }
71
+
72
+ function truncate(text, max = 60) {
73
+ const squeezed = text.trim().replace(/\s+/g, ' ');
74
+ return squeezed.length > max ? `${squeezed.slice(0, max - 1)}…` : squeezed;
75
+ }
76
+
77
+ // Read every hub's rows and classify each one. `hubPaths` narrows the sweep to
78
+ // named hubs; when given, quiet/terminal hubs are included too (the user asked
79
+ // for that hub by name, so the noise-control rule doesn't apply).
80
+ export function collectHubStatusRows(docs, config, { hubPaths = null } = {}) {
81
+ 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
+ // linking `BILLING-A.md` resolves to the file that is indexed as
84
+ // `billing-a.md` — a path string the exact map can't answer. Fold as a
85
+ // fallback, and only when the fold is unambiguous: on a case-SENSITIVE
86
+ // filesystem those really are two different docs.
87
+ const docByFoldedPath = new Map();
88
+ for (const doc of docs) {
89
+ const key = doc.path.toLowerCase();
90
+ docByFoldedPath.set(key, docByFoldedPath.has(key) ? null : doc);
91
+ }
92
+ const quiet = new Set([
93
+ ...(config.lifecycle?.terminalStatuses ?? []),
94
+ ...(config.lifecycle?.skipWarningsFor ?? []),
95
+ ]);
96
+ const out = [];
97
+
98
+ for (const hub of docs) {
99
+ if (!isHubDoc(hub)) continue;
100
+ if (hubPaths) { if (!hubPaths.has(hub.path)) continue; }
101
+ else if (quiet.has(hub.status)) continue;
102
+
103
+ let raw;
104
+ try { raw = readFileSync(path.join(config.repoRoot, hub.path), 'utf8'); } catch { continue; }
105
+ const { body, bodyLineOffset } = extractFrontmatter(raw);
106
+ const hubDir = path.dirname(path.join(config.repoRoot, hub.path));
107
+
108
+ const rows = [];
109
+ for (const row of scanHubStatusRows(body)) {
110
+ const href = row.ref.replace(/#.*$/, '');
111
+ const abs = resolveRefPath(href, hubDir, config.repoRoot);
112
+ // A row whose link is broken is already a body-link finding. Reporting it
113
+ // again as unreadable status would double-report one problem.
114
+ if (!abs) continue;
115
+ const repoPath = toRepoPath(abs, config.repoRoot);
116
+ const target = docByPath.get(repoPath) ?? docByFoldedPath.get(repoPath.toLowerCase()) ?? null;
117
+ if (!target || target.path === hub.path) continue;
118
+ const actual = effectiveStatus(target, config);
119
+ if (!actual) continue; // missing `status:` is the child's own error
120
+
121
+ const vocabulary = statusVocabulary(target, config);
122
+ const span = row.marked
123
+ ?? readPositionalToken(row.statusCell, word => vocabulary.has(word.toLowerCase()));
124
+ const base = {
125
+ lineIndex: row.lineIndex,
126
+ line: bodyLineOffset + row.lineIndex + 1,
127
+ target: target.path,
128
+ actual,
129
+ };
130
+ if (!span) {
131
+ // No status column → pointer row → silent. Status column → the row opts
132
+ // out of the invariant invisibly, which is a finding.
133
+ if (row.hasStatusColumn) {
134
+ rows.push({ ...base, state: 'unreadable', marked: false, span: null, printed: null,
135
+ cell: row.statusCell ? truncate(row.statusCell.raw) : '' });
136
+ }
137
+ continue;
138
+ }
139
+ rows.push({
140
+ ...base,
141
+ state: span.text.toLowerCase() === String(actual).toLowerCase() ? 'ok' : 'drift',
142
+ marked: Boolean(row.marked),
143
+ span,
144
+ printed: span.text,
145
+ });
146
+ }
147
+ if (rows.length) out.push({ hub, rows, bodyLineOffset });
148
+ }
149
+ return out;
150
+ }
151
+
152
+ // The check-pipeline pass. Returns index-level warnings/errors; `src/index.mjs`
153
+ // pushes them and attaches them to the owning hub doc.
154
+ export function checkHubStatusDrift(docs, config) {
155
+ const warnings = [];
156
+ const errors = [];
157
+ for (const { hub, rows } of collectHubStatusRows(docs, config)) {
158
+ for (const row of rows) {
159
+ if (row.state === 'drift') {
160
+ const entry = {
161
+ path: hub.path,
162
+ level: row.marked ? 'error' : 'warning',
163
+ message: row.marked
164
+ ? `line ${row.line} rows \`${row.target}\` as \`${row.printed}\` inside a \`${MARKER_OPEN}…${MARKER_CLOSE}\` marker, but that doc's status is \`${row.actual}\`. The marker means dotmd owns that word — run \`dotmd sync-status\` to rewrite it, or change the doc's status.`
165
+ : `line ${row.line} rows \`${row.target}\` as \`${row.printed}\`, but that doc's status is \`${row.actual}\`. Run \`dotmd sync-status\` to rewrite the row, or change the doc's status.`,
166
+ meta: { kind: DRIFT_KIND, target: row.target, printed: row.printed, actual: row.actual, line: row.line, marked: row.marked },
167
+ };
168
+ (row.marked ? errors : warnings).push(entry);
169
+ } else if (row.state === 'unreadable') {
170
+ warnings.push({
171
+ path: hub.path,
172
+ level: 'warning',
173
+ message: `line ${row.line} rows \`${row.target}\` under a status column, but no status word could be read from the cell (\`${row.cell}\`). Lead the cell with the status, or wrap the status in \`${MARKER_OPEN}…${MARKER_CLOSE}\`, so drift in this row can be caught.`,
174
+ meta: { kind: UNREADABLE_KIND, target: row.target, line: row.line },
175
+ });
176
+ }
177
+ }
178
+ }
179
+ return { warnings, errors };
180
+ }
181
+
182
+ // Rewrite drifted status words, and (with `adopt`) wrap managed positional
183
+ // tokens in markers. `adopt` is separate because wrapping a word in markers is a
184
+ // content edit to prose the user wrote: `check --fix` / `doctor` rewrite status
185
+ // TOKENS, marked or positional; only `--adopt` ADDS markers.
186
+ export function syncHubStatuses(config, { docs, dryRun = false, adopt = false, hubPaths = null, quiet = false } = {}) {
187
+ const hubs = collectHubStatusRows(docs, config, { hubPaths });
188
+ if (hubs.length) {
189
+ authorizeManagedSweep(hubs.map(h => path.join(config.repoRoot, h.hub.path)), config, { kind: 'Hub status sync source' });
190
+ }
191
+
192
+ const prefix = dryRun ? dim('[dry-run] ') : '';
193
+ const result = { fixed: 0, adopted: 0, unreadable: 0, hubs: [], skippedLineStart: 0 };
194
+
195
+ for (const { hub, bodyLineOffset, rows } of hubs) {
196
+ const absPath = path.join(config.repoRoot, hub.path);
197
+ const raw = normalizeEol(readFileSync(absPath, 'utf8'));
198
+ const lines = raw.split('\n');
199
+ const applied = [];
200
+ let unreadable = 0;
201
+
202
+ for (const row of rows) {
203
+ if (row.state === 'unreadable') { unreadable++; continue; }
204
+ const drifted = row.state === 'drift';
205
+ let wrap = adopt && !row.marked;
206
+ // A marker must never begin a line: `reference-planner.mjs` returns any
207
+ // line starting with `<!--` unmodified (CommonMark HTML-block rule), so a
208
+ // link sharing that line would stop being rewritten by moves. A table row
209
+ // always starts with `|`, so this cannot bite in the intended use — the
210
+ // guard is here so a "markers on their own line" idea fails loudly.
211
+ if (wrap && !lines[bodyLineOffset + row.lineIndex].slice(0, row.span.start).trim()) {
212
+ wrap = false;
213
+ result.skippedLineStart++;
214
+ }
215
+ if (!drifted && !wrap) continue;
216
+
217
+ const replacement = drifted ? applyStatusCase(row.printed, row.actual) : row.printed;
218
+ const lineIdx = bodyLineOffset + row.lineIndex;
219
+ const line = lines[lineIdx];
220
+ lines[lineIdx] = line.slice(0, row.span.start)
221
+ + (wrap ? MARKER_OPEN : '') + replacement + (wrap ? MARKER_CLOSE : '')
222
+ + line.slice(row.span.end);
223
+ applied.push({ line: row.line, target: row.target, from: row.printed, to: replacement, drifted, wrapped: wrap, marked: row.marked });
224
+ if (drifted) result.fixed++;
225
+ if (wrap) result.adopted++;
226
+ }
227
+
228
+ result.unreadable += unreadable;
229
+ if (applied.length === 0) {
230
+ if (unreadable > 0) result.hubs.push({ path: hub.path, changes: [], unreadable });
231
+ continue;
232
+ }
233
+ if (!dryRun) writeFileSync(absPath, lines.join('\n'), 'utf8');
234
+ result.hubs.push({ path: hub.path, changes: applied, unreadable });
235
+
236
+ if (!quiet) {
237
+ process.stdout.write(`${prefix}${green('Synced')}: ${hub.path} (${applied.length} row${applied.length === 1 ? '' : 's'})\n`);
238
+ for (const change of applied) {
239
+ const what = change.drifted
240
+ ? `${red(change.from)} → ${green(change.to)}`
241
+ : `${dim('marked')} ${cyan(change.to)}`;
242
+ const wrapNote = change.wrapped && change.drifted ? dim(' + marker') : '';
243
+ process.stdout.write(`${prefix} ${dim(`line ${change.line}`)} ${what}${wrapNote} ${dim(`(${change.target})`)}\n`);
244
+ }
245
+ }
246
+ }
247
+
248
+ if (!quiet) {
249
+ if (result.fixed > 0 || result.adopted > 0) {
250
+ const parts = [];
251
+ if (result.fixed) parts.push(`${result.fixed} row${result.fixed === 1 ? '' : 's'} rewritten`);
252
+ if (result.adopted) parts.push(`${result.adopted} marker${result.adopted === 1 ? '' : 's'} added`);
253
+ process.stdout.write(`\n${prefix}${parts.join(', ')}.\n`);
254
+ }
255
+ if (result.unreadable > 0) {
256
+ process.stdout.write(yellow(`${result.unreadable} row(s) sit under a status column with no readable status word`)
257
+ + ' — lead the cell with the status, or wrap it in '
258
+ + `${MARKER_OPEN}…${MARKER_CLOSE}. Run \`dotmd check\` to list them.\n`);
259
+ }
260
+ }
261
+ return result;
262
+ }
263
+
264
+ // Resolve `dotmd sync-status <hub...>` arguments against the index. Matching by
265
+ // path, path+`.md`, or basename slug — the same handles every other verb takes —
266
+ // with a hub-aware miss message (a plain plan named here is a mistake worth
267
+ // naming, not a silent no-op).
268
+ function resolveHubArgs(args, docs) {
269
+ const paths = new Set();
270
+ for (const arg of args) {
271
+ const slug = arg.replace(/\.md$/, '');
272
+ const matches = docs.filter(doc =>
273
+ doc.path === arg || doc.path === `${slug}.md`
274
+ || path.basename(doc.path, '.md') === path.basename(slug));
275
+ if (matches.length === 0) die(`No doc matches "${arg}".`);
276
+ if (matches.length > 1) {
277
+ die(`Multiple docs match "${arg}":\n${matches.map(m => ` ${m.path}`).join('\n')}`);
278
+ }
279
+ const [match] = matches;
280
+ if (!isHubDoc(match)) {
281
+ die(`${match.path} is not a hub — it has no \`runlist:\` and no \`execution_mode: coordination|roadmap\`. `
282
+ + 'Run `dotmd sync-status` with no argument to sweep every hub.');
283
+ }
284
+ paths.add(match.path);
285
+ }
286
+ return paths;
287
+ }
288
+
289
+ export async function runSyncStatus(argv, config, opts = {}) {
290
+ // Dynamic so this module stays importable FROM index.mjs (which needs
291
+ // `checkHubStatusDrift`) without closing an import cycle.
292
+ const { buildIndex } = await import('./index.mjs');
293
+
294
+ const adopt = argv.includes('--adopt');
295
+ const json = argv.includes('--json');
296
+ const hubArgs = argv.filter(arg => !arg.startsWith('-'));
297
+ const docs = buildIndex(config).docs;
298
+ const hubPaths = hubArgs.length ? resolveHubArgs(hubArgs, docs) : null;
299
+
300
+ const result = syncHubStatuses(config, { docs, dryRun: opts.dryRun, adopt, hubPaths, quiet: json });
301
+ if (json) {
302
+ process.stdout.write(`${JSON.stringify({ dryRun: Boolean(opts.dryRun), adopt, ...result }, null, 2)}\n`);
303
+ return;
304
+ }
305
+ if (result.fixed === 0 && result.adopted === 0 && result.unreadable === 0) {
306
+ process.stdout.write(green('Hub status rows are in sync.') + '\n');
307
+ }
308
+ if (adopt && result.skippedLineStart > 0) {
309
+ process.stdout.write(dim(`${result.skippedLineStart} token(s) left unmarked: a marker may never begin a line.\n`));
310
+ }
311
+ }
312
+
313
+ export { MARKER_CLOSE, MARKER_OPEN };
314
+ export const HUB_STATUS_KINDS = Object.freeze({ drift: DRIFT_KIND, unreadable: UNREADABLE_KIND });