dotmd-cli 0.60.0 → 0.62.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/render.mjs CHANGED
@@ -5,6 +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
9
 
9
10
  // Render `currentState` with an `(auto)` prefix when the value was body-scraped
10
11
  // rather than read from frontmatter. Lets a reader see at a glance which docs
@@ -322,6 +323,15 @@ export function renderBriefing(index, config) {
322
323
  const research = index.docs.filter(d => d.type === 'research');
323
324
  const untyped = index.docs.filter(d => !d.type);
324
325
 
326
+ // Coordination hubs (prose-first runlists) are navigation maps, not units of
327
+ // work — so lift them out of the live-plan status breakdown into their own
328
+ // `runlists` bucket and drop them from the actionable `>` list, mirroring
329
+ // `dotmd plans` / `dotmd runlists`. (Sprint `runlist:` hubs stay as ordinary
330
+ // plans here; their folding treatment is scoped to `dotmd plans`.) On a repo
331
+ // with no coordination hubs this is a no-op and the output is unchanged.
332
+ const coordination = buildCoordinationIndex(index, config);
333
+ const isHub = (p) => coordination.has(p.path);
334
+
325
335
  if (plans.length) {
326
336
  // Headline counts LIVE plans first — "30 plans: 25 archived, …" skims as
327
337
  // 30 open work items when zero are. "Live" mirrors the `dotmd plans`
@@ -331,17 +341,26 @@ export function renderBriefing(index, config) {
331
341
  ...(config.lifecycle?.terminalStatuses ?? []),
332
342
  ]);
333
343
  const live = plans.filter(p => !closed.has(p.status) && !isArchivedPath(p.path, config));
344
+ const liveHubs = live.filter(isHub);
334
345
  const bySt = {};
335
- for (const p of live) { bySt[p.status] = (bySt[p.status] ?? 0) + 1; }
336
- const counts = Object.entries(bySt).map(([s, n]) => `${n} ${s}`).join(', ');
346
+ for (const p of live) { if (isHub(p)) continue; bySt[p.status] = (bySt[p.status] ?? 0) + 1; }
347
+ // Runlists lead the breakdown (then leaf statuses), and the bucket sums back
348
+ // to the live total so the headline stays honest.
349
+ const countParts = [];
350
+ if (liveHubs.length) countParts.push(`${liveHubs.length} runlist${liveHubs.length === 1 ? '' : 's'}`);
351
+ countParts.push(...Object.entries(bySt).map(([s, n]) => `${n} ${s}`));
352
+ const counts = countParts.join(', ');
337
353
  const closedCount = plans.length - live.length;
338
354
  const closedPart = closedCount ? ` (${closedCount} archived)` : '';
339
355
  lines.push(live.length ? `${live.length} live plans${closedPart}: ${counts}` : `0 live plans${closedPart}`);
340
- const show = plans.filter(p => p.status === 'in-session' || p.status === 'active');
356
+ const show = plans.filter(p => (p.status === 'in-session' || p.status === 'active') && !isHub(p));
341
357
  for (const p of show) {
342
358
  const next = p.nextStep ? `next: ${p.nextStep}` : '(no next step)';
343
359
  lines.push(` > ${path.basename(p.path, '.md')} (${p.status}) ${next}`);
344
360
  }
361
+ if (liveHubs.length) {
362
+ lines.push(` ${liveHubs.length} runlist${liveHubs.length === 1 ? '' : 's'} ${dim('· dotmd runlists')}`);
363
+ }
345
364
  }
346
365
 
347
366
  const parts = [];
package/src/runlist.mjs CHANGED
@@ -4,15 +4,133 @@ 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
+ export function buildCoordinationIndex(index, config) {
93
+ const docByPath = new Map(index.docs.map(d => [d.path, d]));
94
+ const byBasename = new Map();
95
+ for (const d of index.docs) {
96
+ const base = d.path.split('/').pop();
97
+ if (!byBasename.has(base)) byBasename.set(base, d);
98
+ }
99
+
100
+ const hubs = new Map();
101
+ for (const doc of index.docs) {
102
+ if (!isCoordinationHub(doc)) continue;
103
+ const dir = path.dirname(path.join(config.repoRoot, doc.path));
104
+ const refs = doc.refFields?.related_plans ?? [];
105
+ const childPaths = new Set();
106
+ for (const ref of refs) {
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
+ if (child && child.path !== doc.path && (child.type === 'plan' || child.type == null)) {
111
+ childPaths.add(child.path);
112
+ }
113
+ }
114
+ hubs.set(doc.path, { doc, childCount: childPaths.size, childPaths });
115
+ }
116
+ return hubs;
117
+ }
118
+
119
+ // Conventional container dirs whose name adds no disambiguation to a hub label.
120
+ const HUB_CONTAINER_DIRS = new Set(['plans', 'prompts', 'archive', 'archived']);
121
+
122
+ // Display label for a hub. A bare basename loses context for hubs that live in
123
+ // a subdirectory (e.g. `docs/plans/pos/runlist.md` would read as just
124
+ // `runlist`), so prefix the immediate parent dir unless it's a conventional
125
+ // container. → `pos/runlist`, but `billing-runlist` stays as-is. Shared by the
126
+ // `dotmd plans` Runlists section, `dotmd runlists`, and `dotmd health` so a hub
127
+ // reads the same everywhere.
128
+ export function hubLabel(doc) {
129
+ const slug = toSlug(doc);
130
+ const parent = path.basename(path.dirname(doc.path));
131
+ return HUB_CONTAINER_DIRS.has(parent) ? slug : `${parent}/${slug}`;
132
+ }
133
+
16
134
  // Bare hub slugs resolve through the shared resolver; the caller keeps its
17
135
  // runlist-specific miss message, so no die-on-miss here.
18
136
  function resolveHubInput(input, config) {
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);
@@ -475,7 +502,7 @@ export function validatePlanShape(doc, body, frontmatter, config) {
475
502
  doc.warnings.push({
476
503
  path: doc.path,
477
504
  level: 'warning',
478
- message: `\`next_step\` is ${nextStep.length} chars (cap: 800). Long prose belongs in the body — keep next_step as a 1-2 sentence pointer.`,
505
+ message: `\`next_step\` is ${nextStep.length} chars (cap: 800). One mechanical fix: \`dotmd doctor --frontmatter-fix\` (moves the overflow into the body) — do NOT hand-trim or re-run check in a loop. Going forward, write next_step as a 1-2 sentence pointer; detail goes in the body.`,
479
506
  });
480
507
  }
481
508
 
@@ -488,7 +515,7 @@ export function validatePlanShape(doc, body, frontmatter, config) {
488
515
  doc.warnings.push({
489
516
  path: doc.path,
490
517
  level: 'warning',
491
- message: `\`current_state\` is ${currentState.length} chars (cap: 1500). Long prose belongs in the body.`,
518
+ message: `\`current_state\` is ${currentState.length} chars (cap: 1500). One mechanical fix: \`dotmd doctor --frontmatter-fix\` (moves the overflow into the body) — do NOT hand-trim or re-run check in a loop. Going forward, write current_state as a 2-4 sentence summary; detail goes in the body.`,
492
519
  });
493
520
  }
494
521