dotmd-cli 0.72.1 → 0.73.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.73.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));
package/src/hub.mjs ADDED
@@ -0,0 +1,217 @@
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
+ }
package/src/index.mjs CHANGED
@@ -9,6 +9,7 @@ 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';
12
13
 
13
14
  // `fast: true` skips every pass that produces warnings/errors — the rendered
14
15
  // index file consumes only status/title/snapshot/etc., not the validation
@@ -117,6 +118,20 @@ export function buildIndex(config, opts = {}) {
117
118
  errors.push(...indexCheck.errors);
118
119
  }
119
120
 
121
+ // Hub status drift produces ERRORS (a drifted marked span), so it runs in
122
+ // errorsOnly mode too — that's what keeps `dotmd hud`'s error count equal to
123
+ // `dotmd check`'s. Its warnings still obey the warning-only gate.
124
+ if (!fast) {
125
+ const hubStatus = checkHubStatusDrift(transformedDocs, config);
126
+ errors.push(...hubStatus.errors);
127
+ if (!skipWarningOnlyChecks) warnings.push(...hubStatus.warnings);
128
+ for (const entry of [...hubStatus.errors, ...(skipWarningOnlyChecks ? [] : hubStatus.warnings)]) {
129
+ const hub = transformedDocs.find(d => d.path === entry.path);
130
+ if (!hub) continue;
131
+ (entry.level === 'error' ? hub.errors : hub.warnings).push(entry);
132
+ }
133
+ }
134
+
120
135
  if (!skipWarningOnlyChecks) {
121
136
  const refCheck = checkBidirectionalReferences(transformedDocs, config);
122
137
  warnings.push(...refCheck.warnings);
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 { firstRowLink, 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
@@ -378,8 +352,11 @@ function detectBodyRunlistRefs(body) {
378
352
  for (const rawLine of section.split('\n')) {
379
353
  const line = rawLine.trim();
380
354
  if (!line.startsWith('|')) continue;
381
- const link = linkRe.exec(line);
382
- if (link) refs.push(link[1]);
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);
383
360
  }
384
361
  }
385
362
 
@@ -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 });