dotmd-cli 0.61.0 → 0.63.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/src/runlist.mjs CHANGED
@@ -4,15 +4,145 @@ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
4
  import {
5
5
  asString,
6
6
  die,
7
+ isArchivedPath,
7
8
  normalizeStringList,
8
9
  resolveRefPath,
9
10
  toRepoPath,
11
+ toSlug,
10
12
  } from './util.mjs';
11
13
  import { resolveDocArg } from './index.mjs';
12
14
  import { bold, cyan, dim, green, red, yellow } from './color.mjs';
13
15
 
14
16
  const PICKUPABLE_STATUSES = new Set(['active', 'planned', 'in-session']);
15
17
 
18
+ // Build a hub/child map straight from the in-memory index — no disk IO. A doc
19
+ // is a runlist *hub* when its `runlist:` frontmatter (`refFields.runlist`) is
20
+ // non-empty. Each ref resolves to a doc in the index by path, falling back to
21
+ // basename so children that were archived (and physically moved into an
22
+ // archive dir) still resolve. Used by the `dotmd plans` triage view to fold
23
+ // children under their hub and tag hubs as runlists rather than plain plans.
24
+ //
25
+ // Returns:
26
+ // hubs: Map<hubPath, { hub, total, doneCount, children, nextChildPath }>
27
+ // children: [{ ref, doc, path, status, archived, missing }] in runlist order
28
+ // childToHub: Map<childPath, hubPath> (first hub wins on the rare double-claim)
29
+ export function buildRunlistIndex(index, config) {
30
+ const archiveStatuses = config.lifecycle?.archiveStatuses ?? new Set(['archived']);
31
+ const docByPath = new Map(index.docs.map(d => [d.path, d]));
32
+ const byBasename = new Map();
33
+ for (const d of index.docs) {
34
+ const base = d.path.split('/').pop();
35
+ if (!byBasename.has(base)) byBasename.set(base, d);
36
+ }
37
+
38
+ const hubs = new Map();
39
+ const childToHub = new Map();
40
+ for (const hub of index.docs) {
41
+ const refs = hub.refFields?.runlist ?? [];
42
+ if (refs.length === 0) continue;
43
+ const hubDir = path.dirname(path.join(config.repoRoot, hub.path));
44
+
45
+ const children = [];
46
+ for (const ref of refs) {
47
+ const abs = resolveRefPath(ref, hubDir, config.repoRoot);
48
+ let childDoc = abs ? docByPath.get(toRepoPath(abs, config.repoRoot)) ?? null : null;
49
+ if (!childDoc) childDoc = byBasename.get(ref.split('/').pop()) ?? null;
50
+ if (childDoc) {
51
+ const archived = archiveStatuses.has(childDoc.status) || isArchivedPath(childDoc.path, config);
52
+ children.push({ ref, doc: childDoc, path: childDoc.path, status: childDoc.status, archived, missing: false });
53
+ if (!childToHub.has(childDoc.path)) childToHub.set(childDoc.path, hub.path);
54
+ } else {
55
+ children.push({ ref, doc: null, path: null, status: null, archived: false, missing: true });
56
+ }
57
+ }
58
+
59
+ const next = children.find(c => !c.missing && !c.archived) ?? null;
60
+ hubs.set(hub.path, {
61
+ hub,
62
+ total: children.length,
63
+ doneCount: children.filter(c => c.archived).length,
64
+ children,
65
+ nextChildPath: next?.path ?? null,
66
+ });
67
+ }
68
+
69
+ return { hubs, childToHub };
70
+ }
71
+
72
+ // A *coordination hub* is a prose-first plan that sits above a cluster of other
73
+ // plans — a "runlist" in the platform sense (master-runlist, ai-runlist, …)
74
+ // rather than a strictly-ordered frontmatter `runlist:` sprint. The signal is
75
+ // already in frontmatter (`execution_mode: coordination`), with the
76
+ // `*-runlist` / `runlist` naming convention as a fallback for the few hubs that
77
+ // predate the field. These plans aren't units of executable work — they're
78
+ // navigation maps — so the triage view tags them and lifts them out of the
79
+ // leaf-plan flow rather than treating them as one more active plan.
80
+ export function isCoordinationHub(doc) {
81
+ if (!doc) return false;
82
+ if (doc.type && doc.type !== 'plan') return false;
83
+ if (doc.executionMode === 'coordination') return true;
84
+ const base = (doc.path.split('/').pop() || '').replace(/\.md$/, '');
85
+ return base === 'runlist' || base.endsWith('-runlist');
86
+ }
87
+
88
+ // Map each coordination hub to a `childCount` derived from its `related_plans:`
89
+ // cluster (resolved against the index; peers/self excluded). It's an
90
+ // approximation — `related_plans` is a *related* cluster, not a strict child
91
+ // list — so it's shown as a rough "N plans" hint, not an authoritative count.
92
+ // Each hub also gets a `nextPickup` (or null) parsed from its body order — the
93
+ // first non-archived ranked child — so prose-first hubs surface a next-pickup
94
+ // target the way sprint `runlist:` hubs do. Reads each hub's file (a small,
95
+ // bounded set), so this is no longer pure in-memory like `buildRunlistIndex`.
96
+ export function buildCoordinationIndex(index, config) {
97
+ const docByPath = new Map(index.docs.map(d => [d.path, d]));
98
+ const byBasename = new Map();
99
+ for (const d of index.docs) {
100
+ const base = d.path.split('/').pop();
101
+ if (!byBasename.has(base)) byBasename.set(base, d);
102
+ }
103
+ const archiveStatuses = config.lifecycle?.archiveStatuses ?? new Set(['archived']);
104
+
105
+ // Resolve a path-or-basename ref (from frontmatter or body) to an indexed doc.
106
+ const resolveRef = (ref, dir) => {
107
+ const abs = resolveRefPath(ref, dir, config.repoRoot);
108
+ let child = abs ? docByPath.get(toRepoPath(abs, config.repoRoot)) ?? null : null;
109
+ if (!child) child = byBasename.get(ref.split('/').pop()) ?? null;
110
+ return child;
111
+ };
112
+
113
+ const hubs = new Map();
114
+ for (const doc of index.docs) {
115
+ if (!isCoordinationHub(doc)) continue;
116
+ const dir = path.dirname(path.join(config.repoRoot, doc.path));
117
+ const refs = doc.refFields?.related_plans ?? [];
118
+ const childPaths = new Set();
119
+ for (const ref of refs) {
120
+ const child = resolveRef(ref, dir);
121
+ if (child && child.path !== doc.path && (child.type === 'plan' || child.type == null)) {
122
+ childPaths.add(child.path);
123
+ }
124
+ }
125
+ const nextPickup = resolveHubNextPickup(doc, dir, resolveRef, archiveStatuses, config);
126
+ hubs.set(doc.path, { doc, childCount: childPaths.size, childPaths, nextPickup });
127
+ }
128
+ return hubs;
129
+ }
130
+
131
+ // Conventional container dirs whose name adds no disambiguation to a hub label.
132
+ const HUB_CONTAINER_DIRS = new Set(['plans', 'prompts', 'archive', 'archived']);
133
+
134
+ // Display label for a hub. A bare basename loses context for hubs that live in
135
+ // a subdirectory (e.g. `docs/plans/pos/runlist.md` would read as just
136
+ // `runlist`), so prefix the immediate parent dir unless it's a conventional
137
+ // container. → `pos/runlist`, but `billing-runlist` stays as-is. Shared by the
138
+ // `dotmd plans` Runlists section, `dotmd runlists`, and `dotmd health` so a hub
139
+ // reads the same everywhere.
140
+ export function hubLabel(doc) {
141
+ const slug = toSlug(doc);
142
+ const parent = path.basename(path.dirname(doc.path));
143
+ return HUB_CONTAINER_DIRS.has(parent) ? slug : `${parent}/${slug}`;
144
+ }
145
+
16
146
  // Bare hub slugs resolve through the shared resolver; the caller keeps its
17
147
  // runlist-specific miss message, so no die-on-miss here.
18
148
  function resolveHubInput(input, config) {
@@ -51,28 +181,93 @@ function resolveRunlistRefs(refs, hubAbsPath, config) {
51
181
  return out;
52
182
  }
53
183
 
184
+ // Extract ordered plan refs from a hub's body prose. Two shapes:
185
+ // - link-list sections (`## Order of operations`, `## Runlist`, …) — every
186
+ // `.md` link or checklist item, in document order.
187
+ // - ranked-queue tables (`## Ranked queue`, …) — the first `.md` link in each
188
+ // table row (the ranked plan); header/separator rows contribute none.
189
+ // Coordination hubs encode their next-pickup order in the table shape; sprint-
190
+ // ish hubs use the link list. Deduped, first occurrence wins, order preserved.
54
191
  function detectBodyRunlistRefs(body) {
55
192
  if (!body) return [];
56
- const sectionRe = /^##\s+(Order of operations|Runlist|Execution order|Implementation order|Plan order)\s*$/gim;
57
193
  const refs = [];
58
- let match;
59
- while ((match = sectionRe.exec(body)) !== null) {
60
- const start = match.index + match[0].length;
194
+ const linkRe = /\[[^\]]+\]\(([^)]+\.md(?:#[^)]+)?)\)/;
195
+ const sliceSection = (start) => {
61
196
  const rest = body.slice(start);
62
197
  const next = rest.search(/^##\s+/m);
63
- const section = next >= 0 ? rest.slice(0, next) : rest;
198
+ return next >= 0 ? rest.slice(0, next) : rest;
199
+ };
64
200
 
65
- const linkRe = /\[[^\]]+\]\(([^)]+\.md(?:#[^)]+)?)\)/g;
201
+ const linkSectionRe = /^##\s+(?:Order of operations|Runlist|Execution order|Implementation order|Plan order)\b.*$/gim;
202
+ let match;
203
+ while ((match = linkSectionRe.exec(body)) !== null) {
204
+ const section = sliceSection(match.index + match[0].length);
205
+ const allLinks = new RegExp(linkRe.source, 'g');
66
206
  let link;
67
- while ((link = linkRe.exec(section)) !== null) refs.push(link[1]);
207
+ while ((link = allLinks.exec(section)) !== null) refs.push(link[1]);
68
208
 
69
209
  const checklistRe = /^\s*[-*]\s+\[[ xX]\]\s+([^\s)]+\.md(?:#[^\s)]+)?)/gm;
70
210
  let item;
71
211
  while ((item = checklistRe.exec(section)) !== null) refs.push(item[1]);
72
212
  }
213
+
214
+ // Ranked-queue tables: the first `.md` link per row is the ranked plan. A
215
+ // header (`| Rank | Plan | … |`) and separator (`|---|`) carry no link and are
216
+ // skipped naturally. Heading may carry trailing text (`## Ranked queue (next
217
+ // pickup)`), so match the leading words, not an exact line.
218
+ const queueSectionRe = /^##\s+(?:Ranked queue|Queue|Pickup order|Heads)\b.*$/gim;
219
+ while ((match = queueSectionRe.exec(body)) !== null) {
220
+ const section = sliceSection(match.index + match[0].length);
221
+ for (const rawLine of section.split('\n')) {
222
+ const line = rawLine.trim();
223
+ if (!line.startsWith('|')) continue;
224
+ const link = linkRe.exec(line);
225
+ if (link) refs.push(link[1]);
226
+ }
227
+ }
228
+
73
229
  return [...new Set(refs)];
74
230
  }
75
231
 
232
+ // Label for a hub's next-pickup child: its slug with the hub's leading module
233
+ // segment stripped when shared (so `founder-runlist` → `founder-brand-conflicts`
234
+ // reads as `brand-conflicts`), mirroring how sprint children drop the hub
235
+ // prefix. Falls back to the full slug when there's no shared leading segment.
236
+ function coordinationChildLabel(childDoc, hubDoc) {
237
+ const childSlug = toSlug(childDoc);
238
+ const seg = toSlug(hubDoc).split('-')[0];
239
+ if (seg.length >= 2 && childSlug.startsWith(`${seg}-`) && childSlug.length > seg.length + 1) {
240
+ return childSlug.slice(seg.length + 1);
241
+ }
242
+ return childSlug;
243
+ }
244
+
245
+ // Read a coordination hub's body order (a `## Ranked queue` table or a
246
+ // `## Order of operations` link list) and return its NEXT PICKUP: the first
247
+ // ranked child that isn't archived, resolved to its live status from the index.
248
+ // Prose-first hubs keep their sequence in the body, invisible to the
249
+ // frontmatter-only index — this surfaces `next → <child>` the way sprint
250
+ // `runlist:` hubs already do. Returns null when the hub has no parseable body
251
+ // order or every ranked child is archived. Best-effort: a read failure degrades
252
+ // to null, never throws.
253
+ function resolveHubNextPickup(hubDoc, hubDir, resolveRef, archiveStatuses, config) {
254
+ let body;
255
+ try {
256
+ ({ body } = extractFrontmatter(readFileSync(path.join(config.repoRoot, hubDoc.path), 'utf8')));
257
+ } catch {
258
+ return null;
259
+ }
260
+ for (const ref of detectBodyRunlistRefs(body)) {
261
+ const child = resolveRef(ref, hubDir);
262
+ if (!child || child.path === hubDoc.path) continue;
263
+ if (child.type && child.type !== 'plan') continue;
264
+ const archived = archiveStatuses.has(child.status) || isArchivedPath(child.path, config);
265
+ if (archived) continue;
266
+ return { path: child.path, status: child.status ?? null, label: coordinationChildLabel(child, hubDoc) };
267
+ }
268
+ return null;
269
+ }
270
+
76
271
  function readRunlistChildren(hubAbsPath, config) {
77
272
  const raw = readFileSync(hubAbsPath, 'utf8');
78
273
  const { frontmatter: fmRaw, body } = extractFrontmatter(raw);
package/src/validate.mjs CHANGED
@@ -433,6 +433,33 @@ export function checkRunlistBackPointers(docs, config) {
433
433
  return warnings;
434
434
  }
435
435
 
436
+ // Coordination-hub hygiene: a plan whose slug is `*-runlist` / `runlist` reads
437
+ // as a coordination runlist, but `dotmd plans` only *reliably* lifts it into the
438
+ // Runlists section (and out of the active count) when `execution_mode:
439
+ // coordination` is set. Slug detection is the fallback; the frontmatter field is
440
+ // the canonical signal. Nudge the few hubs that lean on the slug alone to make
441
+ // it explicit. Skips terminal/quiet statuses like every other warning-only check.
442
+ export function checkCoordinationHubExecutionMode(docs, config) {
443
+ const warnings = [];
444
+ const skipStatuses = new Set([
445
+ ...(config.lifecycle.terminalStatuses ?? []),
446
+ ...(config.lifecycle.skipWarningsFor ?? []),
447
+ ]);
448
+ for (const doc of docs) {
449
+ if (doc.type && doc.type !== 'plan') continue;
450
+ if (skipStatuses.has(doc.status)) continue;
451
+ if (doc.executionMode === 'coordination') continue;
452
+ const base = (doc.path.split('/').pop() || '').replace(/\.md$/, '');
453
+ if (base !== 'runlist' && !base.endsWith('-runlist')) continue;
454
+ warnings.push({
455
+ path: doc.path,
456
+ level: 'warning',
457
+ message: `reads as a coordination runlist (slug \`${base}\`) but is missing \`execution_mode: coordination\`. Add it so \`dotmd plans\` / \`dotmd runlists\` reliably treat it as a runlist, not an active plan.`,
458
+ });
459
+ }
460
+ return warnings;
461
+ }
462
+
436
463
  export function checkGitStaleness(docs, config) {
437
464
  const warnings = [];
438
465
  const gitDates = getGitLastModifiedBatch(config.repoRoot);