dotmd-cli 0.66.0 → 0.67.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
@@ -237,6 +237,8 @@ Lifecycle:
237
237
  set <status> <file> Change a document's status (frontmatter write; archive also moves the file)
238
238
  runlist <hub> [next|add|remove|reorder] Show, walk, or mutate an ordered group of plans (see \`dotmd help runlist\`)
239
239
  runlists List coordination-hub runlists (the Runlists dashboard)
240
+ roadmap [<hub>] [next] Tier-3: show a roadmap (runlists + rolled-up progress), or pick up its next action
241
+ roadmaps List roadmap hubs (the Roadmaps dashboard)
240
242
  status <file> <status> Transition document status (deprecated; prefer \`set\`)
241
243
  archive <file> Archive (status + move + update refs)
242
244
  bulk archive <f1> <f2> ... Archive multiple files at once
@@ -853,10 +855,15 @@ Scaffolding runlists (plans only):
853
855
  --coordination Create a prose-first coordination hub: \`execution_mode:
854
856
  coordination\` + a \`## Ranked queue\` skeleton (no children).
855
857
  Surfaces in \`dotmd runlists\`, held out of the active count.
856
- (\`--runlist\` and \`--coordination\` are mutually exclusive.)
858
+ --roadmap Create a tier-3 roadmap hub: \`execution_mode: roadmap\` + a
859
+ \`## Runlists\` skeleton. A roadmap composes *runlists* (not
860
+ leaf plans) and rolls their done/total up — see
861
+ \`dotmd roadmap\`. Wire child runlists via \`related_plans:\`.
862
+ (\`--runlist\`, \`--coordination\`, \`--roadmap\` are mutually exclusive.)
857
863
 
858
864
  dotmd new plan auth-revamp --runlist extract,rewrite,cleanup
859
865
  dotmd new plan platform --coordination
866
+ dotmd new plan q3 --roadmap
860
867
 
861
868
  Plan body variants (plans only — pick one body shape):
862
869
  --lite / --minimal Trimmed plan: Problem → Phases → Version History. Drops
@@ -866,8 +873,8 @@ Plan body variants (plans only — pick one body shape):
866
873
  --audit / --findings Audit plan: Problem → Findings (ranked) → Suggested order
867
874
  → Open Questions. The "investigated X, here's what I
868
875
  found" shape, instead of build-up phases.
869
- (The body variants and \`--runlist\`/\`--coordination\` are all mutually
870
- exclusive — a plan has exactly one body shape.)
876
+ (The body variants and \`--runlist\`/\`--coordination\`/\`--roadmap\` are all
877
+ mutually exclusive — a plan has exactly one body shape.)
871
878
 
872
879
  dotmd new plan quick-fix --lite
873
880
  dotmd new plan perf-audit --audit
@@ -1515,6 +1522,30 @@ async function main() {
1515
1522
  runRunlists(index, restArgs, config);
1516
1523
  return;
1517
1524
  }
1525
+ // `dotmd roadmaps` (dashboard over every roadmap hub) and `dotmd roadmap
1526
+ // [<hub>] [next]` (one roadmap, or pick up the next action across its
1527
+ // runlists). The tier-3 layer above `runlists`.
1528
+ if (command === 'roadmaps') {
1529
+ const { buildIndex } = await import('../src/index.mjs');
1530
+ const { runRoadmaps } = await import('../src/roadmap.mjs');
1531
+ const index = buildIndex(config);
1532
+ applyIndexFilters(index);
1533
+ runRoadmaps(index, restArgs, config);
1534
+ return;
1535
+ }
1536
+ if (command === 'roadmap') {
1537
+ const { buildIndex } = await import('../src/index.mjs');
1538
+ const index = buildIndex(config);
1539
+ applyIndexFilters(index);
1540
+ if (restArgs[0] === 'next') {
1541
+ const { runRoadmapNext } = await import('../src/roadmap.mjs');
1542
+ await runRoadmapNext(index, restArgs.slice(1), config, { dryRun });
1543
+ return;
1544
+ }
1545
+ const { runRoadmap } = await import('../src/roadmap.mjs');
1546
+ runRoadmap(index, restArgs, config);
1547
+ return;
1548
+ }
1518
1549
  if (command === 'prompts') {
1519
1550
  const { runPrompts } = await import('../src/prompts.mjs');
1520
1551
  await runPrompts(restArgs, config, { dryRun, verbose });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.66.0",
3
+ "version": "0.67.0",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, Notion sync, AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/health.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import { buildIndex } from './index.mjs';
3
3
  import { bold, dim, green, yellow, red } from './color.mjs';
4
- import { buildCoordinationIndex, hubLabel } from './runlist.mjs';
4
+ import { buildCoordinationIndex, buildRoadmapIndex, isRoadmapHub, hubLabel } from './runlist.mjs';
5
5
  import { isArchivedPath } from './util.mjs';
6
6
 
7
7
  export function runHealth(argv, config) {
@@ -22,10 +22,16 @@ export function runHealth(argv, config) {
22
22
  ...(config.lifecycle?.terminalStatuses ?? []),
23
23
  ]);
24
24
  const isLiveHub = (d) => coordination.has(d.path) && !closedStatuses.has(d.status) && !isArchivedPath(d.path, config);
25
- const runlistHubs = allPlans.filter(isLiveHub)
25
+ const isLiveRoadmap = (d) => isRoadmapHub(d) && !closedStatuses.has(d.status) && !isArchivedPath(d.path, config);
26
+ const byAgeDesc = (a, b) => (b.daysSinceUpdate ?? -1) - (a.daysSinceUpdate ?? -1);
27
+ // Roadmaps (tier-3) get their own tally above Runlists — held out of the
28
+ // pipeline like coordination hubs, but pointed at via `dotmd roadmaps`.
29
+ const roadmapHubs = allPlans.filter(isLiveRoadmap).sort(byAgeDesc);
30
+ const roadmapIndex = roadmapHubs.length > 0 ? buildRoadmapIndex(index, config, { coordination }) : new Map();
31
+ const runlistHubs = allPlans.filter(d => isLiveHub(d) && !isRoadmapHub(d))
26
32
  // Most stale first — health is an aging lens, and it matches `dotmd runlists`'
27
33
  // default. Unknown-age hubs sort last so they never top the list.
28
- .sort((a, b) => (b.daysSinceUpdate ?? -1) - (a.daysSinceUpdate ?? -1));
34
+ .sort(byAgeDesc);
29
35
  const plans = allPlans.filter(d => !isLiveHub(d));
30
36
  const now = Date.now();
31
37
 
@@ -87,7 +93,8 @@ export function runHealth(argv, config) {
87
93
  ready: { count: readyPlans.length },
88
94
  planned: { count: plannedPlans.length },
89
95
  recentlyArchived: { count: recentlyArchived.length, last30d: recentlyArchived.map(d => path.basename(d.path, '.md')) },
90
- runlists: { count: runlistHubs.length, hubs: runlistHubs.map(d => ({ path: d.path, title: d.title, status: d.status, childCount: coordination.get(d.path)?.childCount ?? 0, nextPickup: coordination.get(d.path)?.nextPickup ?? null })) },
96
+ runlists: { count: runlistHubs.length, hubs: runlistHubs.map(d => { const info = coordination.get(d.path); return { path: d.path, title: d.title, status: d.status, childCount: info?.childCount ?? 0, total: info?.total ?? 0, doneCount: info?.doneCount ?? 0, parkedCount: info?.parkedCount ?? 0, nextPickup: info?.nextPickup ?? null }; }) },
97
+ roadmaps: { count: roadmapHubs.length, hubs: roadmapHubs.map(d => { const info = roadmapIndex.get(d.path); return { path: d.path, title: d.title, status: d.status, childCount: info?.childCount ?? 0, grandTotal: info?.grandTotal ?? 0, grandDone: info?.grandDone ?? 0, grandParked: info?.grandParked ?? 0 }; }) },
91
98
  }, null, 2) + '\n');
92
99
  return;
93
100
  }
@@ -114,6 +121,23 @@ export function runHealth(argv, config) {
114
121
  }
115
122
  process.stdout.write('\n');
116
123
 
124
+ // Roadmaps (tier-3) — pinned above Runlists with the recursive grand total.
125
+ if (roadmapHubs.length > 0) {
126
+ process.stdout.write(`${bold('Roadmaps:')} ${roadmapHubs.length} ${dim('· dotmd roadmap')}\n`);
127
+ for (const doc of roadmapHubs.slice(0, 8)) {
128
+ const slug = hubLabel(doc).padEnd(28);
129
+ const age = doc.daysSinceUpdate != null ? `${doc.daysSinceUpdate}d` : '?d';
130
+ const info = roadmapIndex.get(doc.path);
131
+ const rollStr = info ? ` ${dim(`${info.grandDone}/${info.grandTotal}`)}` : '';
132
+ const kidStr = info ? ` ${dim(`${info.childCount} runlists`)}` : '';
133
+ process.stdout.write(` ${slug} ${dim(age.padStart(4))}${rollStr}${kidStr}\n`);
134
+ }
135
+ if (roadmapHubs.length > 8) {
136
+ process.stdout.write(` ${dim(`...and ${roadmapHubs.length - 8} more`)}\n`);
137
+ }
138
+ process.stdout.write('\n');
139
+ }
140
+
117
141
  // Runlists (coordination hubs) — held out of the leaf-plan pipeline above and
118
142
  // surfaced as their own tally so they don't inflate the active count. Newest
119
143
  // first, mirroring `dotmd runlists`; capped with a "more" footer.
@@ -123,7 +147,7 @@ export function runHealth(argv, config) {
123
147
  const slug = hubLabel(doc).padEnd(28);
124
148
  const age = doc.daysSinceUpdate != null ? `${doc.daysSinceUpdate}d` : '?d';
125
149
  const info = coordination.get(doc.path);
126
- const relStr = info?.childCount ? ` ${dim(`${info.childCount} related`)}` : '';
150
+ const relStr = info?.total ? ` ${dim(`${info.doneCount}/${info.total}`)}` : '';
127
151
  const nextStr = info?.nextPickup ? ` ${green('→')} ${info.nextPickup.label}` : '';
128
152
  process.stdout.write(` ${slug} ${dim(age.padStart(4))}${relStr}${nextStr}\n`);
129
153
  }
package/src/index.mjs CHANGED
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
4
  import { extractFirstHeading, extractSummary, extractStatusSnapshot, extractNextStep, extractChecklistCounts, extractBodyLinks } from './extractors.mjs';
5
5
  import { asString, normalizeStringList, normalizeBlockers, mergeUniqueStrings, toRepoPath, warn, die, resolveDocPath, suggestCandidates } from './util.mjs';
6
- import { validateDoc, validatePlanShape, validateDocShape, checkBidirectionalReferences, checkGitStaleness, checkRunlistBackPointers, checkCoordinationHubExecutionMode, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate, enrichRefErrorSuggestions } from './validate.mjs';
6
+ import { validateDoc, validatePlanShape, validateDocShape, checkBidirectionalReferences, checkGitStaleness, checkRunlistBackPointers, checkCoordinationHubExecutionMode, checkRoadmapHubExecutionMode, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate, enrichRefErrorSuggestions } from './validate.mjs';
7
7
  import { checkIndex } from './index-file.mjs';
8
8
  import { checkClaudeCommands } from './claude-commands.mjs';
9
9
  import { checkGlossaryConfig } from './glossary-check.mjs';
@@ -128,6 +128,13 @@ export function buildIndex(config, opts = {}) {
128
128
  if (hub) hub.warnings.push(w);
129
129
  }
130
130
 
131
+ const roadmapHubWarnings = checkRoadmapHubExecutionMode(transformedDocs, config);
132
+ warnings.push(...roadmapHubWarnings);
133
+ for (const w of roadmapHubWarnings) {
134
+ const hub = transformedDocs.find(d => d.path === w.path);
135
+ if (hub) hub.warnings.push(w);
136
+ }
137
+
131
138
  const gitWarnings = checkGitStaleness(transformedDocs, config);
132
139
  warnings.push(...gitWarnings);
133
140
 
package/src/new.mjs CHANGED
@@ -39,6 +39,25 @@ function modulesScaffold(ctx, kind /* 'doc' | 'plan' */) {
39
39
  return 'modules:';
40
40
  }
41
41
 
42
+ // Full-body shortcut shared by the default plan scaffold and every plan body
43
+ // variant (--lite / --audit / --runlist / --coordination hubs). When the
44
+ // author's body input already carries its own `## Section` headings, it's a
45
+ // complete body they wrote start-to-finish — return it verbatim (honoring a
46
+ // leading `# Title`, otherwise prepending the scaffold title) rather than
47
+ // nesting it inside the builder's single slot (`## Problem` / `## Scope`) and
48
+ // appending skeleton sections after it. Without this, a full coordination/lite/
49
+ // audit/runlist body got duplicated: the whole document landed under `## Scope`
50
+ // and the skeleton's own `## Ranked queue` / `## Version History` were appended
51
+ // below (leaving stray `## Closeout` etc.). Returns the verbatim body string,
52
+ // or null when the input is section-content (no `## ` heading) and should flow
53
+ // into the builder's slot as before.
54
+ function fullBodyShortcut(title, bodyInput) {
55
+ const b = (bodyInput ?? '').trim();
56
+ if (!b || !/^##\s+\S/m.test(b)) return null;
57
+ const hasOwnTitle = /^#\s+\S/.test(b);
58
+ return hasOwnTitle ? `\n${b}\n` : `\n# ${title}\n\n${b}\n`;
59
+ }
60
+
42
61
  const BUILTIN_TEMPLATES = {
43
62
  doc: {
44
63
  description: 'Reference doc, design note, module overview — build-up shape lite',
@@ -100,18 +119,12 @@ ${ctx?.bodyInput?.trim() ?? ''}
100
119
  'next_step:',
101
120
  ].join('\n'),
102
121
  body: (t, ctx) => {
122
+ // Full-body shortcut (shared with the plan variants): a body that already
123
+ // authors `## Section` headings is a complete plan the user/agent wrote
124
+ // start-to-finish — return it verbatim and drop the scaffold ladder.
125
+ const authored = fullBodyShortcut(t, ctx?.bodyInput);
126
+ if (authored !== null) return authored;
103
127
  const bodyInput = ctx?.bodyInput?.trim() ?? '';
104
- // Full-body shortcut: if the input already authors `## Section` headings,
105
- // it's a complete plan body the user/agent wrote start-to-finish. Drop
106
- // the scaffold's later sections to avoid duplicate empty `## Goals`,
107
- // `## Phases`, etc. below the user's already-filled versions. A bare title
108
- // (`# X`) at the head of the body is honored — we don't double-print the
109
- // scaffold's title. Otherwise emit the scaffold and slot the body into
110
- // `## Problem` as before (section-content mode).
111
- if (/^##\s+\S/m.test(bodyInput)) {
112
- const hasOwnTitle = /^#\s+\S/.test(bodyInput);
113
- return hasOwnTitle ? `\n${bodyInput}\n` : `\n# ${t}\n\n${bodyInput}\n`;
114
- }
115
128
  return `
116
129
  # ${t}
117
130
 
@@ -309,7 +322,7 @@ function runlistHubBody(title, hubSlug, children, bodyInput, today) {
309
322
 
310
323
  ${bodyInput?.trim() ?? ''}
311
324
 
312
- ## Order of operations
325
+ ## Order of Operations
313
326
 
314
327
  ${steps}
315
328
 
@@ -334,7 +347,7 @@ function coordinationHubBody(title, bodyInput, today) {
334
347
 
335
348
  ${bodyInput?.trim() ?? ''}
336
349
 
337
- ## Ranked queue
350
+ ## Ranked Queue
338
351
 
339
352
  <!-- One row per coordinated plan, in pickup order; the gating column explains
340
353
  dependencies. Wire each plan into related_plans: so the "N related" count and
@@ -350,6 +363,39 @@ graph pick it up. -->
350
363
  `;
351
364
  }
352
365
 
366
+ // Body for a roadmap hub: the tier-3 hub that composes *runlists* (not leaf
367
+ // plans) and rolls their progress up. Mirrors the coordination-hub shape but its
368
+ // ranked rows point at runlists, and `dotmd roadmap` reads it. Children are wired
369
+ // via related_plans: (each should be a runlist / coordination hub).
370
+ function roadmapHubBody(title, hubSlug, bodyInput, today) {
371
+ return `
372
+ # ${title}
373
+
374
+ > One-paragraph: the domain this roadmap composes. A roadmap points at *runlists*
375
+ > (not leaf plans) and rolls their done/total up — see \`dotmd roadmap ${hubSlug}\`.
376
+
377
+ ## Scope
378
+
379
+ ${bodyInput?.trim() ?? ''}
380
+
381
+ ## Runlists
382
+
383
+ <!-- One row per child runlist, in priority order. Wire each into related_plans:
384
+ so the rollup + graph pick it up. Children should be runlists / coordination hubs
385
+ (execution_mode: coordination) or sprint runlist: hubs — not leaf plans.
386
+ Optional horizon flavor: replace this table with ## Now / ## Next / ## Later /
387
+ ## Icebox sections, each linking the runlists in that horizon. -->
388
+
389
+ | # | Runlist | Why / gating | Progress |
390
+ |---|---------|--------------|----------|
391
+ | 1 | \`<name>-runlist.md\` | | |
392
+
393
+ ## Version History
394
+
395
+ - **${today}** Created (roadmap hub).
396
+ `;
397
+ }
398
+
353
399
  // Body for a `--lite` plan: the full build-up scaffold (Goals / Non-Goals /
354
400
  // What Exists Today / Constraints / Decisions / Open Questions / Deferred /
355
401
  // Closeout) is stripped down to the essentials — Problem → Phases → Version
@@ -393,7 +439,7 @@ function auditPlanBody(title, bodyInput, today) {
393
439
 
394
440
  ${bodyInput?.trim() ?? ''}
395
441
 
396
- ## Findings (ranked)
442
+ ## Findings (Ranked)
397
443
 
398
444
  ### 1. <finding> [impact]
399
445
 
@@ -403,7 +449,7 @@ ${bodyInput?.trim() ?? ''}
403
449
 
404
450
 
405
451
 
406
- ## Suggested order
452
+ ## Suggested Order
407
453
 
408
454
  1. <which finding to act on first, and why>
409
455
 
@@ -469,6 +515,7 @@ export async function runNew(argv, config, opts = {}) {
469
515
  let showFiles = opts.showFiles ?? false;
470
516
  let runlistArg = null; // --runlist a,b,c → sprint hub + child stubs
471
517
  let coordination = false; // --coordination → coordination hub skeleton
518
+ let roadmap = false; // --roadmap → tier-3 roadmap hub skeleton
472
519
  let lite = false; // --lite/--minimal → trimmed plan body
473
520
  let audit = false; // --audit/--findings → ranked-findings plan body
474
521
  for (let i = 0; i < argv.length; i++) {
@@ -476,6 +523,7 @@ export async function runNew(argv, config, opts = {}) {
476
523
  if (argv[i] === '--title' && argv[i + 1]) { title = argv[++i]; continue; }
477
524
  if (argv[i] === '--runlist' && argv[i + 1]) { runlistArg = argv[++i]; continue; }
478
525
  if (argv[i] === '--coordination') { coordination = true; continue; }
526
+ if (argv[i] === '--roadmap') { roadmap = true; continue; }
479
527
  if (argv[i] === '--lite' || argv[i] === '--minimal') { lite = true; continue; }
480
528
  if (argv[i] === '--audit' || argv[i] === '--findings') { audit = true; continue; }
481
529
  // --body is the canonical flag; --message is a back-compat alias.
@@ -552,11 +600,13 @@ export async function runNew(argv, config, opts = {}) {
552
600
  // audit are mutually exclusive).
553
601
  const isRunlistHub = runlistArg !== null;
554
602
  const isCoordinationHub = coordination;
603
+ const isRoadmap = roadmap;
555
604
  const isLite = lite;
556
605
  const isAudit = audit;
557
606
  const planShapes = [
558
607
  ['--runlist', isRunlistHub],
559
608
  ['--coordination', isCoordinationHub],
609
+ ['--roadmap', isRoadmap],
560
610
  ['--lite', isLite],
561
611
  ['--audit', isAudit],
562
612
  ].filter(([, on]) => on);
@@ -732,9 +782,19 @@ export async function runNew(argv, config, opts = {}) {
732
782
  // on top of the standard plan scaffold, then swap in a purpose-built body.
733
783
  if (isRunlistHub) fm = mergeBodyFrontmatter(fm, { runlist: runlistChildren.map(c => c.file) }, typeName);
734
784
  if (isCoordinationHub) fm = mergeBodyFrontmatter(fm, { execution_mode: 'coordination' }, typeName);
785
+ if (isRoadmap) fm = mergeBodyFrontmatter(fm, { execution_mode: 'roadmap' }, typeName);
735
786
  let body;
736
- if (isRunlistHub) body = runlistHubBody(docTitle, slug, runlistChildren, bodyInput, today);
787
+ // A full authored body (own `## Section` headings) wins over every variant
788
+ // skeleton too — otherwise the whole document gets nested in the builder's
789
+ // single slot and the skeleton is appended below it (duplicate Scope /
790
+ // Ranked queue / Version History). The default plan body applies the same
791
+ // shortcut inside template.body, so only the variant branches need it here.
792
+ const variantBody = isRunlistHub || isCoordinationHub || isRoadmap || isLite || isAudit;
793
+ const authored = variantBody ? fullBodyShortcut(docTitle, bodyInput) : null;
794
+ if (authored !== null) body = authored;
795
+ else if (isRunlistHub) body = runlistHubBody(docTitle, slug, runlistChildren, bodyInput, today);
737
796
  else if (isCoordinationHub) body = coordinationHubBody(docTitle, bodyInput, today);
797
+ else if (isRoadmap) body = roadmapHubBody(docTitle, slug, bodyInput, today);
738
798
  else if (isLite) body = litePlanBody(docTitle, bodyInput, today);
739
799
  else if (isAudit) body = auditPlanBody(docTitle, bodyInput, today);
740
800
  else body = template.body(docTitle, tmplCtx);
@@ -756,6 +816,7 @@ export async function runNew(argv, config, opts = {}) {
756
816
 
757
817
  const hubKind = isRunlistHub ? ' (runlist hub)'
758
818
  : isCoordinationHub ? ' (coordination hub)'
819
+ : isRoadmap ? ' (roadmap hub)'
759
820
  : isLite ? ' (lite plan)'
760
821
  : isAudit ? ' (audit plan)'
761
822
  : '';
package/src/query.mjs CHANGED
@@ -7,7 +7,7 @@ import { getGitLastModifiedBatch } from './git.mjs';
7
7
  import { extractFrontmatter } from './frontmatter.mjs';
8
8
  import { summarizeDocBody } from './ai.mjs';
9
9
  import { bold, dim, yellow, red, green, blue, magenta, cyan, brightYellow } from './color.mjs';
10
- import { buildRunlistIndex, buildCoordinationIndex, hubLabel } from './runlist.mjs';
10
+ import { buildRunlistIndex, buildCoordinationIndex, buildRoadmapIndex, isRoadmapHub, hubLabel } from './runlist.mjs';
11
11
 
12
12
  const STATUS_COLORS = {
13
13
  'in-session': (s) => bold(red(s)),
@@ -101,7 +101,8 @@ export function runQuery(index, argv, config, opts = {}) {
101
101
  // Runlist folding only applies to plans (prompts have no runlists).
102
102
  const runlist = opts.preset === 'plans' ? buildRunlistIndex(index, config) : null;
103
103
  const coordination = opts.preset === 'plans' ? buildCoordinationIndex(index, config) : null;
104
- renderPlansOutput(docs, filters, config, { noun: opts.preset, runlist, coordination });
104
+ const roadmap = opts.preset === 'plans' ? buildRoadmapIndex(index, config, { coordination, runlist }) : null;
105
+ renderPlansOutput(docs, filters, config, { noun: opts.preset, runlist, coordination, roadmap });
105
106
  if (docs.length === 0) writeUnknownFilterValueHint(filters, index);
106
107
  return;
107
108
  }
@@ -157,26 +158,41 @@ export function runRunlists(index, argv, config) {
157
158
  ...(config.lifecycle?.archiveStatuses ?? []),
158
159
  ...(config.lifecycle?.terminalStatuses ?? []),
159
160
  ]);
161
+ // Roadmaps live in `coordination` (held-out-hub plumbing) but are a tier above
162
+ // runlists — exclude them here; they get their own `dotmd roadmaps` dashboard.
160
163
  const hubs = index.docs
161
- .filter(d => coordination.has(d.path) && !archived.has(d.status) && !isArchivedPath(d.path, config))
164
+ .filter(d => coordination.has(d.path) && !isRoadmapHub(d) && !archived.has(d.status) && !isArchivedPath(d.path, config))
162
165
  .sort(runlistSorter(sortArg, coordination, config));
166
+ const roadmapCount = index.docs
167
+ .filter(d => isRoadmapHub(d) && !archived.has(d.status) && !isArchivedPath(d.path, config)).length;
163
168
 
164
169
  if (json) {
165
- const runlists = hubs.map(d => ({
166
- path: d.path,
167
- status: d.status,
168
- title: d.title,
169
- childCount: coordination.get(d.path)?.childCount ?? 0,
170
- nextPickup: coordination.get(d.path)?.nextPickup ?? null,
171
- updated: d.updated,
172
- nextStep: d.nextStep ?? null,
173
- }));
170
+ const runlists = hubs.map(d => {
171
+ const info = coordination.get(d.path);
172
+ return {
173
+ path: d.path,
174
+ status: d.status,
175
+ title: d.title,
176
+ childCount: info?.childCount ?? 0,
177
+ total: info?.total ?? 0,
178
+ doneCount: info?.doneCount ?? 0,
179
+ parkedCount: info?.parkedCount ?? 0,
180
+ nextPickup: info?.nextPickup ?? null,
181
+ updated: d.updated,
182
+ nextStep: d.nextStep ?? null,
183
+ };
184
+ });
174
185
  process.stdout.write(JSON.stringify({ count: runlists.length, runlists }, null, 2) + '\n');
175
186
  return;
176
187
  }
177
188
 
189
+ const roadmapPointer = roadmapCount > 0
190
+ ? dim(` ${roadmapCount} roadmap${roadmapCount === 1 ? '' : 's'} · dotmd roadmaps\n`)
191
+ : '';
192
+
178
193
  if (hubs.length === 0) {
179
194
  process.stdout.write('No runlists found. A runlist is a plan with `execution_mode: coordination` (or a `*-runlist` slug).\n');
195
+ if (roadmapPointer) process.stdout.write(roadmapPointer);
180
196
  return;
181
197
  }
182
198
 
@@ -185,6 +201,7 @@ export function runRunlists(index, argv, config) {
185
201
  renderCoordinationSection(shown, coordination, maxWidth, hubs.length);
186
202
  const hidden = hubs.length - shown.length;
187
203
  if (hidden > 0) process.stdout.write(dim(` ${hidden} more · dotmd runlists --limit ${hubs.length}\n`));
204
+ if (roadmapPointer) process.stdout.write(roadmapPointer);
188
205
  process.stdout.write('\n');
189
206
  }
190
207
 
@@ -491,9 +508,14 @@ function renderPlansOutput(docs, filters, config, opts = {}) {
491
508
  // scoped to the flat triage view; grouped views keep their existing shape.
492
509
  const runlist = !grouped ? opts.runlist : null;
493
510
  const coordination = !grouped ? opts.coordination : null;
511
+ const roadmap = !grouped ? opts.roadmap : null;
494
512
  // A doc is a "runlist" for header/section purposes if it's either a
495
513
  // frontmatter-`runlist:` sprint hub or an `execution_mode: coordination` hub.
514
+ // Roadmaps are also in `coordination` (held-out-hub plumbing), so they read as
515
+ // hubs here too — but `isRoadmap` is checked first wherever counts/sections
516
+ // split, lifting them into their own tier above runlists.
496
517
  const isHub = (p) => Boolean(runlist?.hubs.has(p) || coordination?.has(p));
518
+ const isRoadmap = (p) => Boolean(roadmap?.has(p));
497
519
 
498
520
  // Summary line: middle-dot separator, ALWAYS based on the full pre-limit
499
521
  // pipeline so the top-of-page numbers stay honest when --limit is applied.
@@ -509,10 +531,12 @@ function renderPlansOutput(docs, filters, config, opts = {}) {
509
531
  // more active plan. Needs per-doc identity, so recompute from the pre-limit
510
532
  // matched set rather than the aggregate counts.
511
533
  let hubCount = 0;
534
+ let roadmapCount = 0;
512
535
  let counts;
513
536
  if ((runlist?.hubs.size || coordination?.size) && filters._matched) {
514
537
  const reclassed = {};
515
538
  for (const d of filters._matched) {
539
+ if (isRoadmap(d.path)) { roadmapCount += 1; continue; }
516
540
  if (isHub(d.path)) { hubCount += 1; continue; }
517
541
  const s = d.status ?? 'unknown';
518
542
  reclassed[s] = (reclassed[s] ?? 0) + 1;
@@ -522,12 +546,13 @@ function renderPlansOutput(docs, filters, config, opts = {}) {
522
546
  counts = Object.entries(bySt).sort((a, b) => b[1] - a[1]).map(([s, n]) => `${n} ${s}`);
523
547
  }
524
548
  const headerParts = [];
549
+ if (roadmapCount) headerParts.push(`${roadmapCount} roadmap${roadmapCount === 1 ? '' : 's'}`);
525
550
  if (hubCount) headerParts.push(`${hubCount} runlist${hubCount === 1 ? '' : 's'}`);
526
551
  headerParts.push(...counts);
527
- // Hubs are held OUT of the headline plan count — they read as a separate
528
- // `N runlist` sibling, never as actionable plans. So "N plans" counts leaves
529
- // only and the status segments sum to it (the runlist sibling sits apart).
530
- const headlineTotal = totalAll - hubCount;
552
+ // Hubs (runlists + roadmaps) are held OUT of the headline plan count — they
553
+ // read as separate siblings, never as actionable plans. So "N plans" counts
554
+ // leaves only and the status segments sum to it.
555
+ const headlineTotal = totalAll - hubCount - roadmapCount;
531
556
  const header = `${headlineTotal} ${noun}${headerParts.length ? ' · ' + headerParts.join(' · ') : ''}`;
532
557
  process.stdout.write(dim(header) + '\n');
533
558
 
@@ -575,14 +600,17 @@ function renderPlansOutput(docs, filters, config, opts = {}) {
575
600
  // both caps. The Runlists section is pinned — it shows whenever hubs exist,
576
601
  // independent of how the leaf list fills up.
577
602
  const matched = filters._matched ?? docs;
603
+ const roadmapAll = [];
578
604
  const coordAll = [];
579
605
  const mainAll = [];
580
606
  for (const d of matched) {
581
- if (coordination?.has(d.path)) coordAll.push(d);
607
+ if (isRoadmap(d.path)) roadmapAll.push(d);
608
+ else if (coordination?.has(d.path)) coordAll.push(d);
582
609
  else mainAll.push(d);
583
610
  }
584
611
  const mainShown = filters.all ? mainAll : mainAll.slice(0, filters.limit);
585
612
  const coordShown = filters.all ? coordAll : coordAll.slice(0, filters.limit);
613
+ const roadmapShown = filters.all ? roadmapAll : roadmapAll.slice(0, filters.limit);
586
614
 
587
615
  process.stdout.write('\n');
588
616
  if (mainShown.length) {
@@ -595,6 +623,15 @@ function renderPlansOutput(docs, filters, config, opts = {}) {
595
623
  process.stdout.write(dim(` ${mainHidden} more ${noun} · dotmd ${noun} --all · dotmd ${noun} status\n`));
596
624
  }
597
625
 
626
+ // Roadmaps tier — pinned above Runlists, with the recursive grand total.
627
+ if (roadmapShown.length) {
628
+ renderRoadmapsSection(roadmapShown, roadmap, maxWidth, roadmapAll.length);
629
+ const roadmapHidden = roadmapAll.length - roadmapShown.length;
630
+ if (roadmapHidden > 0) {
631
+ process.stdout.write(dim(` ${roadmapHidden} more roadmaps · dotmd roadmaps\n`));
632
+ }
633
+ }
634
+
598
635
  if (coordShown.length) {
599
636
  renderCoordinationSection(coordShown, coordination, maxWidth, coordAll.length);
600
637
  const coordHidden = coordAll.length - coordShown.length;
@@ -802,12 +839,36 @@ function renderHubBlock(hub, info, children, maxWidth, topMaxSlug) {
802
839
  }
803
840
  }
804
841
 
842
+ // Roadmap hubs (tier-3) render in their own pinned section above Runlists:
843
+ // label · age · recursive grand done/total · child-runlist count · descriptor.
844
+ // The done/total here is the SUM across the roadmap's child runlists (the real
845
+ // bird's-eye), distinct from the Runlists section's per-hub rollup. `dotmd
846
+ // roadmap <hub>` expands one into its child rows.
847
+ function renderRoadmapsSection(roadmapDocs, roadmap, maxWidth, total) {
848
+ process.stdout.write(`\n${bold(`Roadmaps (${total ?? roadmapDocs.length})`)} ${dim('· dotmd roadmap')}\n`);
849
+ const maxSlug = Math.min(34, Math.max(...roadmapDocs.map(d => hubLabel(d).length)));
850
+ for (const doc of roadmapDocs) {
851
+ const info = roadmap?.get(doc.path);
852
+ const slug = hubLabel(doc).padEnd(maxSlug);
853
+ const age = doc.daysSinceUpdate != null ? `${doc.daysSinceUpdate}d` : '—';
854
+ const ageStr = doc.daysSinceUpdate != null && doc.isStale ? red(age.padStart(4)) : dim(age.padStart(4));
855
+ const roll = (info ? `${info.grandDone}/${info.grandTotal}` : '').padStart(8);
856
+ const kids = info ? `${info.childCount} ${info.childCount === 1 ? 'runlist' : 'runlists'}` : '';
857
+ const statusTag = doc.status && doc.status !== 'active' ? ` ${colorTag(doc.status)}` : '';
858
+ const desc = (doc.nextStep || doc.currentState || doc.title || '').replace(/\s+/g, ' ').trim();
859
+ const left = ` ${slug} ${ageStr} ${dim(roll)} ${dim(kids.padEnd(11))} `;
860
+ const budget = Math.max(10, maxWidth - visibleLen(left) - visibleLen(statusTag) - 2);
861
+ const descR = desc.length > budget ? desc.slice(0, Math.max(0, budget - 3)) + '...' : desc;
862
+ process.stdout.write(`${left}${dim(descR)}${statusTag}\n`);
863
+ }
864
+ }
865
+
805
866
  // Coordination hubs (prose-first runlists) render in their own compact section:
806
- // label · age · rough related-cluster size · one-line descriptor. No fold, no
807
- // per-row tag — the section header is the signal. The count is the resolved
808
- // `related_plans:` cluster, which includes peer/parent runlists, so it's
809
- // labelled `related` (not `plans`) to stay honest. Status shows only when it's
810
- // not the expected `active` (e.g. a `partial` hub).
867
+ // label · age · done/total rollup · one-line descriptor. No fold, no per-row tag
868
+ // — the section header is the signal. The rollup counts archived vs. resolved
869
+ // `related_plans:` children; that cluster can include peer/parent runlists, so
870
+ // it's a progress hint, not a contract (see buildCoordinationIndex). Status shows
871
+ // only when it's not the expected `active` (e.g. a `partial` hub).
811
872
  function renderCoordinationSection(coordDocs, coordination, maxWidth, total) {
812
873
  process.stdout.write(`\n${bold(`Runlists (${total ?? coordDocs.length})`)}\n`);
813
874
  const maxSlug = Math.min(34, Math.max(...coordDocs.map(d => hubLabel(d).length)));
@@ -816,7 +877,7 @@ function renderCoordinationSection(coordDocs, coordination, maxWidth, total) {
816
877
  const slug = hubLabel(doc).padEnd(maxSlug);
817
878
  const age = doc.daysSinceUpdate != null ? `${doc.daysSinceUpdate}d` : '—';
818
879
  const ageStr = doc.daysSinceUpdate != null && doc.isStale ? red(age.padStart(4)) : dim(age.padStart(4));
819
- const count = info?.childCount ? `${String(info.childCount).padStart(2)} related` : ' ';
880
+ const count = (info?.total ? `${info.doneCount}/${info.total}` : '').padStart(7);
820
881
  const statusTag = doc.status && doc.status !== 'active' ? ` ${colorTag(doc.status)}` : '';
821
882
  const desc = (doc.nextStep || doc.currentState || doc.title || '').replace(/\s+/g, ' ').trim();
822
883
 
package/src/render.mjs CHANGED
@@ -5,7 +5,7 @@ import { extractFrontmatter } from './frontmatter.mjs';
5
5
  import { summarizeDocBody } from './ai.mjs';
6
6
  import { bold, red, yellow, green, dim } from './color.mjs';
7
7
  import { categorizeWarnings } from './check-collapse.mjs';
8
- import { buildCoordinationIndex } from './runlist.mjs';
8
+ import { buildCoordinationIndex, isRoadmapHub } from './runlist.mjs';
9
9
 
10
10
  // Render `currentState` with an `(auto)` prefix when the value was body-scraped
11
11
  // rather than read from frontmatter. Lets a reader see at a glance which docs
@@ -342,6 +342,9 @@ export function renderBriefing(index, config) {
342
342
  ]);
343
343
  const live = plans.filter(p => !closed.has(p.status) && !isArchivedPath(p.path, config));
344
344
  const liveHubs = live.filter(isHub);
345
+ // Roadmaps (tier-3) are held out like runlists but pointed at separately.
346
+ const liveRoadmaps = live.filter(p => isRoadmapHub(p));
347
+ const liveRunlists = liveHubs.length - liveRoadmaps.length;
345
348
  const liveLeaves = live.length - liveHubs.length;
346
349
  const bySt = {};
347
350
  for (const p of live) { if (isHub(p)) continue; bySt[p.status] = (bySt[p.status] ?? 0) + 1; }
@@ -358,8 +361,11 @@ export function renderBriefing(index, config) {
358
361
  const next = p.nextStep ? `next: ${p.nextStep}` : '(no next step)';
359
362
  lines.push(` > ${path.basename(p.path, '.md')} (${p.status}) ${next}`);
360
363
  }
361
- if (liveHubs.length) {
362
- lines.push(` ${liveHubs.length} runlist${liveHubs.length === 1 ? '' : 's'} ${dim('· dotmd runlists')}`);
364
+ if (liveRoadmaps.length) {
365
+ lines.push(` ${liveRoadmaps.length} roadmap${liveRoadmaps.length === 1 ? '' : 's'} ${dim('· dotmd roadmaps')}`);
366
+ }
367
+ if (liveRunlists) {
368
+ lines.push(` ${liveRunlists} runlist${liveRunlists === 1 ? '' : 's'} ${dim('· dotmd runlists')}`);
363
369
  }
364
370
  }
365
371
 
@@ -0,0 +1,184 @@
1
+ import { buildRoadmapIndex, hubLabel } from './runlist.mjs';
2
+ import { resolveDocArg } from './index.mjs';
3
+ import { toRepoPath, die } from './util.mjs';
4
+ import { bold, dim, green } from './color.mjs';
5
+
6
+ // Strip ANSI for width math (query.mjs has its own copy but doesn't export it).
7
+ function visibleLen(s) {
8
+ // eslint-disable-next-line no-control-regex
9
+ return s.replace(/\x1b\[[0-9;]*m/g, '').length;
10
+ }
11
+
12
+ // Structured form of one roadmap, shared by every `--json` path.
13
+ function roadmapJson(info) {
14
+ return {
15
+ path: info.doc.path,
16
+ title: info.doc.title ?? null,
17
+ status: info.doc.status ?? null,
18
+ childCount: info.childCount,
19
+ grandTotal: info.grandTotal,
20
+ grandDone: info.grandDone,
21
+ grandParked: info.grandParked,
22
+ children: info.children.map(c => ({
23
+ path: c.path,
24
+ label: hubLabel(c.doc),
25
+ kind: c.kind,
26
+ total: c.total,
27
+ doneCount: c.doneCount,
28
+ parkedCount: c.parkedCount,
29
+ nextPath: c.nextPath,
30
+ nextLabel: c.nextLabel,
31
+ })),
32
+ };
33
+ }
34
+
35
+ // One child-runlist row: label · done/total · next → · one-line descriptor.
36
+ function roadmapChildRow(child, maxSlug, maxWidth) {
37
+ const label = hubLabel(child.doc).padEnd(maxSlug);
38
+ const roll = `${child.doneCount}/${child.total}`.padStart(7);
39
+ const next = child.nextLabel ? `${green('→')} ${child.nextLabel} ` : '';
40
+ const status = child.doc.status && child.doc.status !== 'active' ? ` ${dim(`[${child.doc.status}]`)}` : '';
41
+ const desc = (child.doc.nextStep || child.doc.currentState || child.doc.title || '').replace(/\s+/g, ' ').trim();
42
+ const left = ` ${label} ${dim(roll)} `;
43
+ const budget = Math.max(10, maxWidth - visibleLen(left) - visibleLen(next) - visibleLen(status) - 2);
44
+ const descR = desc.length > budget ? desc.slice(0, Math.max(0, budget - 3)) + '...' : desc;
45
+ return `${left}${next}${dim(descR)}${status}`;
46
+ }
47
+
48
+ // Full single-roadmap view: header with the recursive grand total, then one row
49
+ // per child runlist (its own done/total + that runlist's next pickup).
50
+ function renderRoadmap(info, maxWidth) {
51
+ const lines = [];
52
+ const pct = info.grandTotal > 0 ? Math.round((info.grandDone / info.grandTotal) * 100) : 0;
53
+ const parked = info.grandParked > 0 ? ` ${dim(`${info.grandParked} parked`)}` : '';
54
+ lines.push(`${bold(`Roadmap: ${info.doc.title || hubLabel(info.doc)}`)} ${bold(`${info.grandDone}/${info.grandTotal}`)} ${dim(`(${pct}%)`)}${parked}`);
55
+ if (info.children.length === 0) {
56
+ lines.push(dim(' (no child runlists — wire runlists into related_plans:)'));
57
+ return lines.join('\n') + '\n';
58
+ }
59
+ const maxSlug = Math.min(34, Math.max(...info.children.map(c => hubLabel(c.doc).length)));
60
+ for (const c of info.children) lines.push(roadmapChildRow(c, maxSlug, maxWidth));
61
+ return lines.join('\n') + '\n';
62
+ }
63
+
64
+ const NO_ROADMAPS = 'No roadmaps found. A roadmap is a plan with `execution_mode: roadmap` that composes runlists.\nScaffold one: dotmd new plan <name> --roadmap\n';
65
+
66
+ // `dotmd roadmaps` — the dashboard over every roadmap hub (mirrors `dotmd
67
+ // runlists`): one row per roadmap with its recursive grand total + child count.
68
+ export function runRoadmaps(index, argv, config) {
69
+ const json = argv.includes('--json');
70
+ const roadmaps = [...buildRoadmapIndex(index, config).values()]
71
+ .sort((a, b) => (b.doc.daysSinceUpdate ?? 0) - (a.doc.daysSinceUpdate ?? 0));
72
+
73
+ if (json) {
74
+ process.stdout.write(JSON.stringify({ count: roadmaps.length, roadmaps: roadmaps.map(roadmapJson) }, null, 2) + '\n');
75
+ return;
76
+ }
77
+ if (roadmaps.length === 0) {
78
+ process.stdout.write(NO_ROADMAPS);
79
+ return;
80
+ }
81
+ process.stdout.write(`\n${bold(`Roadmaps (${roadmaps.length})`)}\n`);
82
+ const maxSlug = Math.min(34, Math.max(...roadmaps.map(r => hubLabel(r.doc).length)));
83
+ for (const info of roadmaps) {
84
+ const slug = hubLabel(info.doc).padEnd(maxSlug);
85
+ const age = info.doc.daysSinceUpdate != null ? `${info.doc.daysSinceUpdate}d` : '—';
86
+ const roll = `${info.grandDone}/${info.grandTotal}`.padStart(8);
87
+ const kids = dim(`${info.childCount} ${info.childCount === 1 ? 'runlist' : 'runlists'}`);
88
+ process.stdout.write(` ${slug} ${dim(age.padStart(4))} ${dim(roll)} ${kids}\n`);
89
+ }
90
+ process.stdout.write('\n');
91
+ }
92
+
93
+ // `dotmd roadmap [<hub>]` — the single-roadmap view. No arg: show the sole
94
+ // roadmap, or fall back to the `roadmaps` dashboard when there are several.
95
+ export function runRoadmap(index, argv, config) {
96
+ const json = argv.includes('--json');
97
+ const positional = argv.filter(a => !a.startsWith('-') && a !== 'next');
98
+ const hubArg = positional[0] ?? null;
99
+
100
+ const roadmaps = buildRoadmapIndex(index, config);
101
+
102
+ if (hubArg) {
103
+ const abs = resolveDocArg(hubArg, config, { dieOnMiss: false });
104
+ if (!abs) die(`Roadmap not found: ${hubArg}`);
105
+ const repoPath = toRepoPath(abs, config.repoRoot);
106
+ const info = roadmaps.get(repoPath);
107
+ if (!info) {
108
+ die(`${repoPath} is not a roadmap hub (needs \`execution_mode: roadmap\`).\n` +
109
+ `See \`dotmd roadmaps\` for roadmaps, or \`dotmd runlist ${hubArg}\` if it's a runlist.`);
110
+ }
111
+ if (json) { process.stdout.write(JSON.stringify(roadmapJson(info), null, 2) + '\n'); return; }
112
+ process.stdout.write('\n' + renderRoadmap(info, process.stdout.columns || 100) + '\n');
113
+ return;
114
+ }
115
+
116
+ if (roadmaps.size === 0) {
117
+ if (json) { process.stdout.write(JSON.stringify({ count: 0, roadmaps: [] }, null, 2) + '\n'); return; }
118
+ process.stdout.write(NO_ROADMAPS);
119
+ return;
120
+ }
121
+ if (roadmaps.size === 1) {
122
+ const info = [...roadmaps.values()][0];
123
+ if (json) { process.stdout.write(JSON.stringify(roadmapJson(info), null, 2) + '\n'); return; }
124
+ process.stdout.write('\n' + renderRoadmap(info, process.stdout.columns || 100) + '\n');
125
+ return;
126
+ }
127
+ // Several roadmaps and no target named → the dashboard.
128
+ return runRoadmaps(index, argv, config);
129
+ }
130
+
131
+ // Resolve which roadmap a `next` / pickup verb targets: an explicit hub arg, or
132
+ // the sole roadmap when there's exactly one. Dies with an actionable message
133
+ // otherwise (none → scaffold; several → name one).
134
+ function resolveRoadmapTarget(roadmaps, hubArg, config) {
135
+ if (hubArg) {
136
+ const abs = resolveDocArg(hubArg, config, { dieOnMiss: false });
137
+ if (!abs) die(`Roadmap not found: ${hubArg}`);
138
+ const info = roadmaps.get(toRepoPath(abs, config.repoRoot));
139
+ if (!info) die(`${hubArg} is not a roadmap hub (needs \`execution_mode: roadmap\`).`);
140
+ return info;
141
+ }
142
+ if (roadmaps.size === 0) die('No roadmaps found. Scaffold one: dotmd new plan <name> --roadmap');
143
+ if (roadmaps.size > 1) {
144
+ const listed = [...roadmaps.values()].map(r => ` ${hubLabel(r.doc)}`).join('\n');
145
+ die(`Multiple roadmaps — name one: dotmd roadmap next <hub>\n${listed}`);
146
+ }
147
+ return [...roadmaps.values()][0];
148
+ }
149
+
150
+ // `dotmd roadmap [<hub>] next` — the cross-runlist next-pickup. Walk the
151
+ // roadmap's child runlists in `related_plans` (priority) order and open the FIRST
152
+ // startable plan found inside any of them — the "what do I do next across the
153
+ // whole roadmap?" verb. Each child's `nextPath` was already resolved by
154
+ // buildRoadmapIndex (sprint via its runlist order, coordination via its body
155
+ // order, a leaf-plan child = itself). When nothing is startable anywhere, list
156
+ // each child runlist with why, so the blocker is visible.
157
+ export async function runRoadmapNext(index, argv, config, opts = {}) {
158
+ const json = argv.includes('--json');
159
+ const hubArg = argv.find(a => !a.startsWith('-')) ?? null;
160
+ const roadmaps = buildRoadmapIndex(index, config);
161
+ const info = resolveRoadmapTarget(roadmaps, hubArg, config);
162
+
163
+ const target = info.children.find(c => c.nextPath);
164
+ if (!target) {
165
+ const lines = info.children.map(c => {
166
+ const state = c.total === 0 ? 'empty'
167
+ : c.doneCount >= c.total ? 'all done'
168
+ : c.parkedCount > 0 ? `${c.parkedCount} parked` : 'no pickup-able child';
169
+ return ` ${hubLabel(c.doc)} (${c.doneCount}/${c.total} · ${state})`;
170
+ }).join('\n');
171
+ die(`No pickup-able plan across roadmap ${hubLabel(info.doc)} — every child runlist is done or parked:\n${lines}\n` +
172
+ `Unstick one (e.g. \`dotmd set active <child>\`), or inspect a runlist: \`dotmd runlist <hub>\`.`);
173
+ }
174
+
175
+ if (!json) {
176
+ process.stdout.write(dim(`roadmap ${hubLabel(info.doc)} → ${hubLabel(target.doc)} → next pickup:\n`));
177
+ }
178
+ const { startPlan } = await import('./lifecycle.mjs');
179
+ const startArgs = [target.nextPath];
180
+ if (argv.includes('--full')) startArgs.push('--full');
181
+ if (argv.includes('--no-index')) startArgs.push('--no-index');
182
+ if (json) startArgs.push('--json');
183
+ await startPlan(startArgs, config, opts);
184
+ }
package/src/runlist.mjs CHANGED
@@ -105,11 +105,28 @@ export function buildRunlistIndex(index, config) {
105
105
  export function isCoordinationHub(doc) {
106
106
  if (!doc) return false;
107
107
  if (doc.type && doc.type !== 'plan') return false;
108
- if (doc.executionMode === 'coordination') return true;
108
+ // Broad "held-out navigational hub" predicate: both coordination hubs and the
109
+ // tier-3 roadmap (`execution_mode: roadmap`) are lifted out of the active count
110
+ // and into a hub section. `isRoadmapHub` is the finer split the tier-3 views
111
+ // use to promote a roadmap above the Runlists section; here a roadmap counts as
112
+ // a coordination hub so all the existing held-out plumbing covers it for free.
113
+ if (doc.executionMode === 'coordination' || doc.executionMode === 'roadmap') return true;
109
114
  const base = (doc.path.split('/').pop() || '').replace(/\.md$/, '');
110
115
  return base === 'runlist' || base.endsWith('-runlist');
111
116
  }
112
117
 
118
+ // A *roadmap* is the tier-3 hub: a coordination hub whose children are themselves
119
+ // hubs (runlists / coordination hubs), with progress rolled up across them. The
120
+ // signal is explicit — `execution_mode: roadmap` — with NO slug-convention
121
+ // fallback (unlike coordination hubs' `*-runlist`): there's no naming convention
122
+ // for roadmaps, and `dotmd check` nudges the structural case (a coordination hub
123
+ // that points at other hubs) toward the explicit field rather than auto-promoting.
124
+ export function isRoadmapHub(doc) {
125
+ if (!doc) return false;
126
+ if (doc.type && doc.type !== 'plan') return false;
127
+ return doc.executionMode === 'roadmap';
128
+ }
129
+
113
130
  // Map each coordination hub to a `childCount` derived from its `related_plans:`
114
131
  // cluster (resolved against the index; peers/self excluded). It's an
115
132
  // approximation — `related_plans` is a *related* cluster, not a strict child
@@ -140,19 +157,128 @@ export function buildCoordinationIndex(index, config) {
140
157
  if (!isCoordinationHub(doc)) continue;
141
158
  const dir = path.dirname(path.join(config.repoRoot, doc.path));
142
159
  const refs = doc.refFields?.related_plans ?? [];
160
+ // Resolve the `related_plans` cluster to child plan docs (deduped by path;
161
+ // self and non-plans excluded). This is the membership set the rollup counts
162
+ // over — `related_plans` is the only child signal a prose-first coordination
163
+ // hub carries in frontmatter (its *body* order drives next-pickup, not
164
+ // membership). It's an approximation — a *related* cluster can include peer
165
+ // or parent runlists — so the done/total is a progress hint, not a contract.
143
166
  const childPaths = new Set();
167
+ const children = [];
144
168
  for (const ref of refs) {
145
169
  const child = resolveRef(ref, dir);
146
- if (child && child.path !== doc.path && (child.type === 'plan' || child.type == null)) {
147
- childPaths.add(child.path);
148
- }
170
+ if (!child || child.path === doc.path) continue;
171
+ if (!(child.type === 'plan' || child.type == null)) continue;
172
+ if (childPaths.has(child.path)) continue;
173
+ childPaths.add(child.path);
174
+ const archived = archiveStatuses.has(child.status) || isArchivedPath(child.path, config);
175
+ children.push({ path: child.path, status: child.status ?? null, archived });
149
176
  }
177
+ // Rollup, mirroring buildRunlistIndex: done = archived; parked = live but not
178
+ // startable (blocked/partial/paused/awaiting/queued-after). `childCount`
179
+ // stays an alias of `total` so existing callers (sorters, JSON) keep working.
180
+ const doneCount = children.filter(c => c.archived).length;
181
+ const parkedCount = children.filter(c => !c.archived && !isPickupable(c.status)).length;
150
182
  const nextPickup = resolveHubNextPickup(doc, dir, resolveRef, archiveStatuses, config);
151
- hubs.set(doc.path, { doc, childCount: childPaths.size, childPaths, nextPickup });
183
+ hubs.set(doc.path, {
184
+ doc,
185
+ childCount: childPaths.size,
186
+ total: childPaths.size,
187
+ doneCount,
188
+ parkedCount,
189
+ childPaths,
190
+ children,
191
+ nextPickup,
192
+ });
152
193
  }
153
194
  return hubs;
154
195
  }
155
196
 
197
+ // Build the tier-3 rollup. For each roadmap hub, resolve its children (the
198
+ // `related_plans:` cluster, exactly like a coordination hub's membership) and
199
+ // roll each child's own done/total up into a grand total. A child that is itself
200
+ // a hub contributes its hub rollup — sprint via `buildRunlistIndex`, coordination
201
+ // via `buildCoordinationIndex`; a plain-plan child contributes one unit. The
202
+ // grand total is the SUM of the children's totals (not a deduped union), so it
203
+ // always equals the sum of the per-child rows the dashboard renders — two child
204
+ // runlists that share a plan double-count it, the same "progress hint, not a
205
+ // contract" approximation coordination-hub rollup already carries.
206
+ //
207
+ // Reuses precomputed `coordination` / `runlist` indexes when the caller has them
208
+ // (the views build coordination already); otherwise builds what it needs. Returns
209
+ // Map<roadmapPath, { doc, children, childCount, grandTotal, grandDone, grandParked }>
210
+ // with each child = { doc, path, kind: 'runlist'|'coordination'|'plan', total,
211
+ // doneCount, parkedCount, nextPath, nextLabel } in `related_plans` order.
212
+ export function buildRoadmapIndex(index, config, precomputed = {}) {
213
+ const roadmaps = index.docs.filter(isRoadmapHub);
214
+ if (roadmaps.length === 0) return new Map();
215
+
216
+ const coordination = precomputed.coordination ?? buildCoordinationIndex(index, config);
217
+ const runlist = precomputed.runlist ?? buildRunlistIndex(index, config);
218
+ const archiveStatuses = config.lifecycle?.archiveStatuses ?? new Set(['archived']);
219
+
220
+ const docByPath = new Map(index.docs.map(d => [d.path, d]));
221
+ const byBasename = new Map();
222
+ for (const d of index.docs) {
223
+ const base = d.path.split('/').pop();
224
+ if (!byBasename.has(base)) byBasename.set(base, d);
225
+ }
226
+ const resolveRef = (ref, dir) => {
227
+ const abs = resolveRefPath(ref, dir, config.repoRoot);
228
+ let child = abs ? docByPath.get(toRepoPath(abs, config.repoRoot)) ?? null : null;
229
+ if (!child) child = byBasename.get(ref.split('/').pop()) ?? null;
230
+ return child;
231
+ };
232
+
233
+ // Rollup numbers for one child of a roadmap, dispatched by what the child IS:
234
+ // a sprint runlist hub, a coordination hub, or a plain leaf plan. `nextPath` /
235
+ // `nextLabel` give a uniform per-child next-pickup target — the first startable
236
+ // plan *inside* that child — feeding both the `dotmd roadmap` view and the
237
+ // Phase-4 cross-runlist `dotmd roadmap next` (walk children → first nextPath).
238
+ const childRollup = (child) => {
239
+ if (runlist.hubs.has(child.path)) {
240
+ const h = runlist.hubs.get(child.path);
241
+ const nextPath = h.nextChildPath ?? null;
242
+ return { kind: 'runlist', total: h.total, doneCount: h.doneCount, parkedCount: h.parkedCount,
243
+ nextPath, nextLabel: nextPath ? path.basename(nextPath, '.md') : null };
244
+ }
245
+ if (coordination.has(child.path)) {
246
+ const h = coordination.get(child.path);
247
+ return { kind: 'coordination', total: h.total, doneCount: h.doneCount, parkedCount: h.parkedCount,
248
+ nextPath: h.nextPickup?.path ?? null, nextLabel: h.nextPickup?.label ?? null };
249
+ }
250
+ // A leaf-plan child is its own next action when it's startable.
251
+ const archived = archiveStatuses.has(child.status) || isArchivedPath(child.path, config);
252
+ const parked = !archived && !isPickupable(child.status);
253
+ const pickupable = !archived && isPickupable(child.status);
254
+ return { kind: 'plan', total: 1, doneCount: archived ? 1 : 0, parkedCount: parked ? 1 : 0,
255
+ nextPath: pickupable ? child.path : null, nextLabel: pickupable ? toSlug(child) : null };
256
+ };
257
+
258
+ const out = new Map();
259
+ for (const doc of roadmaps) {
260
+ const dir = path.dirname(path.join(config.repoRoot, doc.path));
261
+ const refs = doc.refFields?.related_plans ?? [];
262
+ const seen = new Set();
263
+ const children = [];
264
+ let grandTotal = 0, grandDone = 0, grandParked = 0;
265
+ for (const ref of refs) {
266
+ const child = resolveRef(ref, dir);
267
+ if (!child || child.path === doc.path) continue;
268
+ if (!(child.type === 'plan' || child.type == null)) continue;
269
+ if (seen.has(child.path)) continue;
270
+ seen.add(child.path);
271
+ const roll = childRollup(child);
272
+ grandTotal += roll.total;
273
+ grandDone += roll.doneCount;
274
+ grandParked += roll.parkedCount;
275
+ children.push({ doc: child, path: child.path, ...roll });
276
+ }
277
+ out.set(doc.path, { doc, children, childCount: children.length, grandTotal, grandDone, grandParked });
278
+ }
279
+ return out;
280
+ }
281
+
156
282
  // Conventional container dirs whose name adds no disambiguation to a hub label.
157
283
  const HUB_CONTAINER_DIRS = new Set(['plans', 'prompts', 'archive', 'archived']);
158
284
 
@@ -339,7 +465,7 @@ function renderRunlist(hubRepoPath, children, opts = {}) {
339
465
  const lines = [];
340
466
  lines.push(bold(`runlist: ${hubRepoPath}`));
341
467
  if (children.length === 0) {
342
- lines.push(dim(' (empty — add child plan paths to the hub plan\'s `runlist:` field, or add markdown links under `## Order of operations`)'));
468
+ lines.push(dim(' (empty — add child plan paths to the hub plan\'s `runlist:` field, or add markdown links under `## Order of Operations`)'));
343
469
  return lines.join('\n') + '\n';
344
470
  }
345
471
  if (opts.source === 'body') {
@@ -500,7 +626,7 @@ async function runRunlistAdd(positional, config, { dryRun, json }) {
500
626
  if (isCoord && existingRefs.length === 0) {
501
627
  die(
502
628
  `${hubRepoPath} is a coordination hub (execution_mode: coordination) — it keeps its order in the body\n` +
503
- `(\`## Ranked queue\` table or \`## Order of operations\` list), not a \`runlist:\` array.\n` +
629
+ `(\`## Ranked Queue\` table or \`## Order of Operations\` list), not a \`runlist:\` array.\n` +
504
630
  `Add the plan as a ranked row/link there. \`runlist add\` manages sprint \`runlist:\` arrays.`,
505
631
  );
506
632
  }
package/src/validate.mjs CHANGED
@@ -448,7 +448,10 @@ export function checkCoordinationHubExecutionMode(docs, config) {
448
448
  for (const doc of docs) {
449
449
  if (doc.type && doc.type !== 'plan') continue;
450
450
  if (skipStatuses.has(doc.status)) continue;
451
- if (doc.executionMode === 'coordination') continue;
451
+ // A roadmap (`execution_mode: roadmap`) is already an explicit held-out hub —
452
+ // just a tier up. Don't nudge it toward `coordination` even when its slug is
453
+ // `*-runlist` (e.g. a `master-runlist` promoted to a roadmap).
454
+ if (doc.executionMode === 'coordination' || doc.executionMode === 'roadmap') continue;
452
455
  const base = (doc.path.split('/').pop() || '').replace(/\.md$/, '');
453
456
  if (base !== 'runlist' && !base.endsWith('-runlist')) continue;
454
457
  warnings.push({
@@ -460,6 +463,62 @@ export function checkCoordinationHubExecutionMode(docs, config) {
460
463
  return warnings;
461
464
  }
462
465
 
466
+ // Roadmap-hub nudge: a coordination hub whose `related_plans:` children are
467
+ // *themselves* hubs (runlists / coordination hubs) is structurally a tier-3
468
+ // roadmap — dotmd renders it flat (a hub among its own children, no recursive
469
+ // rollup) until `execution_mode: roadmap` is set. Nudge, never auto-promote: the
470
+ // explicit field beats structural magic for a primitive (Open Q in the
471
+ // roadmap-layer plan). Fires only when ≥2 children AND a majority are hubs, so a
472
+ // coordination hub that merely references one sibling runlist isn't mislabelled.
473
+ export function checkRoadmapHubExecutionMode(docs, config) {
474
+ const warnings = [];
475
+ const skipStatuses = new Set([
476
+ ...(config.lifecycle.terminalStatuses ?? []),
477
+ ...(config.lifecycle.skipWarningsFor ?? []),
478
+ ]);
479
+ const byPath = new Map(docs.map(d => [d.path, d]));
480
+ const byBasename = new Map();
481
+ for (const d of docs) {
482
+ const base = d.path.split('/').pop();
483
+ if (!byBasename.has(base)) byBasename.set(base, d);
484
+ }
485
+ const looksLikeHub = (d) => {
486
+ if (!d) return false;
487
+ if (d.executionMode === 'coordination' || d.executionMode === 'roadmap') return true;
488
+ if (Array.isArray(d.refFields?.runlist) && d.refFields.runlist.length > 0) return true;
489
+ const b = (d.path.split('/').pop() || '').replace(/\.md$/, '');
490
+ return b === 'runlist' || b.endsWith('-runlist');
491
+ };
492
+ for (const doc of docs) {
493
+ if (doc.type && doc.type !== 'plan') continue;
494
+ if (skipStatuses.has(doc.status)) continue;
495
+ if (doc.executionMode === 'roadmap') continue; // already a roadmap
496
+ if (doc.executionMode !== 'coordination') continue; // only nudge explicit coordination hubs
497
+ const refs = doc.refFields?.related_plans ?? [];
498
+ if (refs.length === 0) continue;
499
+ const dir = path.dirname(path.join(config.repoRoot, doc.path));
500
+ let resolved = 0, hubChildren = 0;
501
+ const seen = new Set();
502
+ for (const ref of refs) {
503
+ const abs = resolveRefPath(ref, dir, config.repoRoot);
504
+ let child = abs ? byPath.get(toRepoPath(abs, config.repoRoot)) ?? null : null;
505
+ if (!child) child = byBasename.get(ref.split('/').pop()) ?? null;
506
+ if (!child || child.path === doc.path || seen.has(child.path)) continue;
507
+ seen.add(child.path);
508
+ resolved++;
509
+ if (looksLikeHub(child)) hubChildren++;
510
+ }
511
+ if (hubChildren >= 2 && hubChildren > resolved / 2) {
512
+ warnings.push({
513
+ path: doc.path,
514
+ level: 'warning',
515
+ message: `is a coordination hub whose ${hubChildren} of ${resolved} \`related_plans:\` children are themselves runlists — that's structurally a tier-3 roadmap. Set \`execution_mode: roadmap\` so \`dotmd roadmap\` rolls their progress up (recursive done/total) instead of rendering it flat among them.`,
516
+ });
517
+ }
518
+ }
519
+ return warnings;
520
+ }
521
+
463
522
  export function checkGitStaleness(docs, config) {
464
523
  const warnings = [];
465
524
  const gitDates = getGitLastModifiedBatch(config.repoRoot);