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 +34 -3
- package/package.json +1 -1
- package/src/health.mjs +29 -5
- package/src/index.mjs +8 -1
- package/src/new.mjs +77 -16
- package/src/query.mjs +84 -23
- package/src/render.mjs +9 -3
- package/src/roadmap.mjs +184 -0
- package/src/runlist.mjs +133 -7
- package/src/validate.mjs +60 -1
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
|
-
|
|
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
|
|
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
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
|
|
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(
|
|
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:
|
|
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?.
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
528
|
-
//
|
|
529
|
-
// only and the status segments sum to it
|
|
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 (
|
|
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 ·
|
|
807
|
-
//
|
|
808
|
-
// `related_plans:` cluster
|
|
809
|
-
//
|
|
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?.
|
|
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 (
|
|
362
|
-
lines.push(` ${
|
|
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
|
|
package/src/roadmap.mjs
ADDED
|
@@ -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
|
-
|
|
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
|
|
147
|
-
|
|
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, {
|
|
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
|
|
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
|
|
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
|
-
|
|
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);
|