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/query.mjs CHANGED
@@ -7,6 +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
11
 
11
12
  const STATUS_COLORS = {
12
13
  'in-session': (s) => bold(red(s)),
@@ -92,7 +93,10 @@ export function runQuery(index, argv, config, opts = {}) {
92
93
  }
93
94
 
94
95
  if (opts.preset === 'plans' || opts.preset === 'prompts') {
95
- renderPlansOutput(docs, filters, config, { noun: opts.preset });
96
+ // Runlist folding only applies to plans (prompts have no runlists).
97
+ const runlist = opts.preset === 'plans' ? buildRunlistIndex(index, config) : null;
98
+ const coordination = opts.preset === 'plans' ? buildCoordinationIndex(index, config) : null;
99
+ renderPlansOutput(docs, filters, config, { noun: opts.preset, runlist, coordination });
96
100
  if (docs.length === 0) writeUnknownFilterValueHint(filters, index);
97
101
  return;
98
102
  }
@@ -101,6 +105,84 @@ export function runQuery(index, argv, config, opts = {}) {
101
105
  if (docs.length === 0) writeUnknownFilterValueHint(filters, index);
102
106
  }
103
107
 
108
+ // Sort comparator for the runlists dashboard. Default `age` puts the MOST STALE
109
+ // hub first — a triage lens (which nav-map has gone longest untouched?), echoing
110
+ // `dotmd modules --sort cleanup`. `recent` is the old newest-first order.
111
+ // Unknown-age hubs sort last in both directions so they never dominate.
112
+ const RUNLIST_SORTS = new Set(['age', 'recent', 'related', 'title', 'status']);
113
+ function runlistSorter(sort, coordination, config) {
114
+ const cmpAge = (a, b, dir) => {
115
+ const av = a.daysSinceUpdate, bv = b.daysSinceUpdate;
116
+ if (av == null && bv == null) return 0;
117
+ if (av == null) return 1;
118
+ if (bv == null) return -1;
119
+ return dir * (av - bv);
120
+ };
121
+ const related = (d) => coordination.get(d.path)?.childCount ?? 0;
122
+ const byLabel = (a, b) => hubLabel(a).localeCompare(hubLabel(b));
123
+ if (sort === 'recent') return (a, b) => cmpAge(a, b, 1) || byLabel(a, b);
124
+ if (sort === 'related') return (a, b) => related(b) - related(a) || cmpAge(a, b, -1) || byLabel(a, b);
125
+ if (sort === 'title') return byLabel;
126
+ if (sort === 'status') {
127
+ return (a, b) => {
128
+ const ai = config.statusOrder.indexOf(a.status), bi = config.statusOrder.indexOf(b.status);
129
+ const aIdx = ai === -1 ? Number.MAX_SAFE_INTEGER : ai, bIdx = bi === -1 ? Number.MAX_SAFE_INTEGER : bi;
130
+ return aIdx - bIdx || cmpAge(a, b, -1) || byLabel(a, b);
131
+ };
132
+ }
133
+ return (a, b) => cmpAge(a, b, -1) || byLabel(a, b); // 'age' (default): most stale first
134
+ }
135
+
136
+ // `dotmd runlists` — the dedicated coordination-hub dashboard: the `Runlists`
137
+ // section from `dotmd plans`, on its own, showing every hub (no leaf list, no
138
+ // cap by default — runlists are a small bounded set). `--limit N` caps it,
139
+ // `--sort age|recent|related|title|status` orders it (default `age`, most stale
140
+ // first), `--json` emits structured rows.
141
+ export function runRunlists(index, argv, config) {
142
+ const json = argv.includes('--json');
143
+ let limit = Infinity;
144
+ const li = argv.indexOf('--limit');
145
+ if (li >= 0 && argv[li + 1]) { const n = Number.parseInt(argv[li + 1], 10); if (Number.isFinite(n)) limit = n; }
146
+ const si = argv.indexOf('--sort');
147
+ const sortArg = si >= 0 && argv[si + 1] ? argv[si + 1] : 'age';
148
+ if (!RUNLIST_SORTS.has(sortArg)) die(`Unknown --sort '${sortArg}'. Use one of: ${[...RUNLIST_SORTS].join(', ')}.`);
149
+
150
+ const coordination = buildCoordinationIndex(index, config);
151
+ const archived = new Set([
152
+ ...(config.lifecycle?.archiveStatuses ?? []),
153
+ ...(config.lifecycle?.terminalStatuses ?? []),
154
+ ]);
155
+ const hubs = index.docs
156
+ .filter(d => coordination.has(d.path) && !archived.has(d.status) && !isArchivedPath(d.path, config))
157
+ .sort(runlistSorter(sortArg, coordination, config));
158
+
159
+ if (json) {
160
+ const runlists = hubs.map(d => ({
161
+ path: d.path,
162
+ status: d.status,
163
+ title: d.title,
164
+ childCount: coordination.get(d.path)?.childCount ?? 0,
165
+ nextPickup: coordination.get(d.path)?.nextPickup ?? null,
166
+ updated: d.updated,
167
+ nextStep: d.nextStep ?? null,
168
+ }));
169
+ process.stdout.write(JSON.stringify({ count: runlists.length, runlists }, null, 2) + '\n');
170
+ return;
171
+ }
172
+
173
+ if (hubs.length === 0) {
174
+ process.stdout.write('No runlists found. A runlist is a plan with `execution_mode: coordination` (or a `*-runlist` slug).\n');
175
+ return;
176
+ }
177
+
178
+ const maxWidth = process.stdout.columns || 100;
179
+ const shown = hubs.slice(0, limit);
180
+ renderCoordinationSection(shown, coordination, maxWidth, hubs.length);
181
+ const hidden = hubs.length - shown.length;
182
+ if (hidden > 0) process.stdout.write(dim(` ${hidden} more · dotmd runlists --limit ${hubs.length}\n`));
183
+ process.stdout.write('\n');
184
+ }
185
+
104
186
  // When a query returns nothing AND a value-shaped filter (currently --module)
105
187
  // names a value that doesn't exist anywhere in the index, the empty result is
106
188
  // almost certainly a typo rather than a combination miss. Surface a hint so
@@ -254,6 +336,10 @@ export function filterDocs(docs, filters, config) {
254
336
  const s = d.status ?? 'unknown';
255
337
  filters._statusCounts[s] = (filters._statusCounts[s] ?? 0) + 1;
256
338
  }
339
+ // Keep a reference to the full pre-limit set so the plans header can
340
+ // reclassify runlist hubs out of the status breakdown (it needs per-doc
341
+ // identity, not just aggregate counts).
342
+ filters._matched = result;
257
343
  return filters.all ? result : result.slice(0, filters.limit);
258
344
  }
259
345
 
@@ -394,6 +480,16 @@ function renderPlansOutput(docs, filters, config, opts = {}) {
394
480
  return;
395
481
  }
396
482
 
483
+ const maxWidth = process.stdout.columns || 100;
484
+ const grouped = filters.sort === 'status' || filters.group;
485
+ // Runlist treatment (sprint-hub folding + the coordination-hub section) is
486
+ // scoped to the flat triage view; grouped views keep their existing shape.
487
+ const runlist = !grouped ? opts.runlist : null;
488
+ const coordination = !grouped ? opts.coordination : null;
489
+ // A doc is a "runlist" for header/section purposes if it's either a
490
+ // frontmatter-`runlist:` sprint hub or an `execution_mode: coordination` hub.
491
+ const isHub = (p) => Boolean(runlist?.hubs.has(p) || coordination?.has(p));
492
+
397
493
  // Summary line: middle-dot separator, ALWAYS based on the full pre-limit
398
494
  // pipeline so the top-of-page numbers stay honest when --limit is applied.
399
495
  const totalShown = docs.length;
@@ -403,9 +499,27 @@ function renderPlansOutput(docs, filters, config, opts = {}) {
403
499
  for (const d of docs) { counts[d.status] = (counts[d.status] ?? 0) + 1; }
404
500
  return counts;
405
501
  })();
406
- // Sort statuses by count desc for a stable visual.
407
- const counts = Object.entries(bySt).sort((a, b) => b[1] - a[1]).map(([s, n]) => `${n} ${s}`).join(' · ');
408
- const header = `${totalAll} ${noun}${counts ? ' · ' + counts : ''}`;
502
+ // With runlists present, pull hubs out of the per-status breakdown into a
503
+ // dedicated `N runlist` bucket so a hub reads as an active *runlist*, not one
504
+ // more active plan. Needs per-doc identity, so recompute from the pre-limit
505
+ // matched set rather than the aggregate counts.
506
+ let hubCount = 0;
507
+ let counts;
508
+ if ((runlist?.hubs.size || coordination?.size) && filters._matched) {
509
+ const reclassed = {};
510
+ for (const d of filters._matched) {
511
+ if (isHub(d.path)) { hubCount += 1; continue; }
512
+ const s = d.status ?? 'unknown';
513
+ reclassed[s] = (reclassed[s] ?? 0) + 1;
514
+ }
515
+ counts = Object.entries(reclassed).sort((a, b) => b[1] - a[1]).map(([s, n]) => `${n} ${s}`);
516
+ } else {
517
+ counts = Object.entries(bySt).sort((a, b) => b[1] - a[1]).map(([s, n]) => `${n} ${s}`);
518
+ }
519
+ const headerParts = [];
520
+ if (hubCount) headerParts.push(`${hubCount} runlist${hubCount === 1 ? '' : 's'}`);
521
+ headerParts.push(...counts);
522
+ const header = `${totalAll} ${noun}${headerParts.length ? ' · ' + headerParts.join(' · ') : ''}`;
409
523
  process.stdout.write(dim(header) + '\n');
410
524
 
411
525
  // Active filter note
@@ -420,9 +534,6 @@ function renderPlansOutput(docs, filters, config, opts = {}) {
420
534
  if (filters.hasBlockers) activeFilters.push('has blockers');
421
535
  if (activeFilters.length) process.stdout.write(dim(` filtered: ${activeFilters.join(' | ')}`) + '\n');
422
536
 
423
- const maxWidth = process.stdout.columns || 100;
424
- const grouped = filters.sort === 'status' || filters.group;
425
-
426
537
  if (filters.group === 'module') {
427
538
  process.stdout.write('\n');
428
539
  renderPlansByGroup(docs, d => d.modules?.length ? d.modules : ['(none)'], filters, maxWidth);
@@ -447,12 +558,47 @@ function renderPlansOutput(docs, filters, config, opts = {}) {
447
558
  renderPlanRows(group, filters, maxWidth, { showTag: false });
448
559
  }
449
560
  } else {
450
- // Triage view: flat, sorted by recency, tag on right.
561
+ // Flat triage view, "capped like leaves": work from the full pre-limit
562
+ // matched set and cap each kind independently. Leaf plans (+ frontmatter
563
+ // `runlist:` sprint hubs, which still fold inline) fill the main list;
564
+ // coordination hubs are lifted into their own `Runlists` section. Each
565
+ // section caps at `--limit` with its own "N more" footer; `--all` lifts
566
+ // both caps. The Runlists section is pinned — it shows whenever hubs exist,
567
+ // independent of how the leaf list fills up.
568
+ const matched = filters._matched ?? docs;
569
+ const coordAll = [];
570
+ const mainAll = [];
571
+ for (const d of matched) {
572
+ if (coordination?.has(d.path)) coordAll.push(d);
573
+ else mainAll.push(d);
574
+ }
575
+ const mainShown = filters.all ? mainAll : mainAll.slice(0, filters.limit);
576
+ const coordShown = filters.all ? coordAll : coordAll.slice(0, filters.limit);
577
+
451
578
  process.stdout.write('\n');
452
- renderPlanRows(docs, filters, maxWidth, { showTag: true });
579
+ if (mainShown.length) {
580
+ if (runlist?.hubs.size) renderTriageWithRunlists(mainShown, runlist, maxWidth);
581
+ else renderPlanRows(mainShown, filters, maxWidth, { showTag: true });
582
+ }
583
+ const mainHidden = mainAll.length - mainShown.length;
584
+ if (mainHidden > 0) {
585
+ process.stdout.write('\n');
586
+ process.stdout.write(dim(` ${mainHidden} more ${noun} · dotmd ${noun} --all · dotmd ${noun} status\n`));
587
+ }
588
+
589
+ if (coordShown.length) {
590
+ renderCoordinationSection(coordShown, coordination, maxWidth, coordAll.length);
591
+ const coordHidden = coordAll.length - coordShown.length;
592
+ if (coordHidden > 0) {
593
+ process.stdout.write(dim(` ${coordHidden} more runlists · dotmd ${noun} --all\n`));
594
+ }
595
+ }
596
+
597
+ process.stdout.write('\n');
598
+ return;
453
599
  }
454
600
 
455
- // Footer when the result was capped — emit for every view shape.
601
+ // Footer (grouped views) — emit when the result was capped.
456
602
  const hidden = totalAll - totalShown;
457
603
  if (hidden > 0) {
458
604
  process.stdout.write('\n');
@@ -483,49 +629,172 @@ function renderPlansByGroup(docs, keyFn, filters, maxWidth) {
483
629
  // next-step column when right-aligning tags.
484
630
  const MAX_TAG_WIDTH = '[QUEUED-AFTER]'.length;
485
631
 
486
- function renderPlanRows(group, filters, maxWidth, opts = {}) {
632
+ // Format a single triage row: `<indent><slug> <age> <pct> <next-step>` with
633
+ // an optional right-aligned tag. `indent` carries the left gutter (and, for
634
+ // runlist children, the `→` next-pickup marker), so its visible length must be
635
+ // stable across sibling rows for the slug column to align. `tag` overrides the
636
+ // status tag (used for the `[RUNLIST]` hub tag).
637
+ function formatPlanRow(doc, maxWidth, { slug, indent = ' ', maxSlug, showTag = false, tag } = {}) {
638
+ const slugCell = slug.padEnd(maxSlug ?? slug.length);
639
+ const age = doc.daysSinceUpdate != null ? `${doc.daysSinceUpdate}d` : '—';
640
+ const ageStr = doc.daysSinceUpdate != null && doc.isStale ? red(age.padStart(4)) : dim(age.padStart(4));
641
+
642
+ // Compact percentage cell (always 5 chars: "100% " / " 99% " / " 5% " / " ")
643
+ let pctCell = ' ';
644
+ if (doc.checklist?.total) {
645
+ const pct = Math.round((doc.checklist.completed / doc.checklist.total) * 100);
646
+ pctCell = `${pct.toString().padStart(3)}% `;
647
+ }
648
+
649
+ const leftBlock = `${indent}${slugCell} ${ageStr} ${dim(pctCell)}`;
650
+ const leftLen = visibleLen(leftBlock);
651
+
652
+ // Next-step / blocker text. Budget = maxWidth - leftLen - separator - (tag column if shown).
653
+ let nextText = '';
654
+ if (doc.blockers?.length && doc.status === 'blocked') {
655
+ nextText = `blocked by ${doc.blockers.join('; ')}`;
656
+ } else if (doc.nextStep) {
657
+ nextText = doc.nextStep;
658
+ }
659
+
660
+ const tagBudget = showTag ? MAX_TAG_WIDTH + 2 : 0; // 2 = gap before tag
661
+ const nextBudget = Math.max(10, maxWidth - leftLen - 2 - tagBudget); // -2 for ` ` separator
662
+ let nextRendered = nextText;
663
+ if (nextText.length > nextBudget) nextRendered = nextText.slice(0, nextBudget - 3) + '...';
664
+
665
+ // Coloring for "blocked by" stays yellow.
666
+ if (doc.blockers?.length && doc.status === 'blocked') nextRendered = yellow(nextRendered);
667
+
668
+ let line = `${leftBlock} ${nextRendered}`;
669
+ if (showTag) {
670
+ // Pad next column to push tag to the right column boundary.
671
+ const consumed = visibleLen(line);
672
+ const targetCol = maxWidth - MAX_TAG_WIDTH;
673
+ const padCount = Math.max(2, targetCol - consumed);
674
+ line = `${line}${' '.repeat(padCount)}${tag ?? colorTag(doc.status)}`;
675
+ }
676
+ return line;
677
+ }
678
+
679
+ function renderPlanRows(group, _filters, maxWidth, opts = {}) {
487
680
  const { showTag = false } = opts;
488
681
  const maxSlug = Math.min(30, Math.max(...group.map(d => toSlug(d).length)));
489
-
490
682
  for (const doc of group) {
491
- const slug = toSlug(doc).padEnd(maxSlug);
492
- const age = doc.daysSinceUpdate != null ? `${doc.daysSinceUpdate}d` : '—';
493
- const ageStr = doc.daysSinceUpdate != null && doc.isStale ? red(age.padStart(4)) : dim(age.padStart(4));
683
+ process.stdout.write(formatPlanRow(doc, maxWidth, { slug: toSlug(doc), maxSlug, showTag }) + '\n');
684
+ }
685
+ }
494
686
 
495
- // Compact percentage cell (always 5 chars: "100% " / " 99% " / " 5% " / " ")
496
- let pctCell = ' ';
497
- if (doc.checklist?.total) {
498
- const pct = Math.round((doc.checklist.completed / doc.checklist.total) * 100);
499
- pctCell = `${pct.toString().padStart(3)}% `;
500
- }
687
+ const RUNLIST_TAG = bold(cyan('[RUNLIST]'));
501
688
 
502
- const leftBlock = ` ${slug} ${ageStr} ${dim(pctCell)}`;
503
- const leftLen = visibleLen(leftBlock);
689
+ // Drop a hub's slug prefix off a child slug so a sprint's children read as
690
+ // `01-extract` rather than `auth-revamp-01-extract`. Falls back to the full
691
+ // slug when there's no shared prefix.
692
+ function stripHubPrefix(childSlug, hubSlug) {
693
+ return childSlug.startsWith(`${hubSlug}-`) ? childSlug.slice(hubSlug.length + 1) : childSlug;
694
+ }
504
695
 
505
- // Next-step / blocker text. Budget = maxWidth - leftLen - separator - (tag column if shown).
506
- let nextText = '';
507
- if (doc.blockers?.length && doc.status === 'blocked') {
508
- nextText = `blocked by ${doc.blockers.join('; ')}`;
509
- } else if (doc.nextStep) {
510
- nextText = doc.nextStep;
511
- }
696
+ // Flat triage view, runlist-aware. Standalone plans render as before; each hub
697
+ // becomes a `[RUNLIST]` header with its (filtered) children folded underneath
698
+ // in runlist order, the next pickup marked `→`. A child whose hub is absent
699
+ // from this filtered set (e.g. `--status active` hid the hub) renders
700
+ // standalone so it still surfaces. Render units sort by their most-recent
701
+ // member so an actively-worked sprint stays near the top.
702
+ function renderTriageWithRunlists(docs, runlist, maxWidth) {
703
+ const { hubs, childToHub } = runlist;
704
+ const docPathSet = new Set(docs.map(d => d.path));
705
+
706
+ const folded = new Set();
707
+ for (const d of docs) {
708
+ const hubPath = childToHub.get(d.path);
709
+ if (hubPath && docPathSet.has(hubPath)) folded.add(d.path);
710
+ }
512
711
 
513
- const tagBudget = showTag ? MAX_TAG_WIDTH + 2 : 0; // 2 = gap before tag
514
- const nextBudget = Math.max(10, maxWidth - leftLen - 2 - tagBudget); // -2 for ` ` separator
515
- let nextRendered = nextText;
516
- if (nextText.length > nextBudget) nextRendered = nextText.slice(0, nextBudget - 3) + '...';
517
-
518
- // Coloring for "blocked by" stays yellow.
519
- if (doc.blockers?.length && doc.status === 'blocked') nextRendered = yellow(nextRendered);
520
-
521
- let line = `${leftBlock} ${nextRendered}`;
522
- if (showTag) {
523
- // Pad next column to push tag to the right column boundary.
524
- const consumed = visibleLen(line);
525
- const targetCol = maxWidth - MAX_TAG_WIDTH;
526
- const padCount = Math.max(2, targetCol - consumed);
527
- line = `${line}${' '.repeat(padCount)}${colorTag(doc.status)}`;
712
+ const units = [];
713
+ for (const d of docs) {
714
+ if (folded.has(d.path)) continue; // emitted under its hub
715
+ const info = hubs.get(d.path);
716
+ if (info) {
717
+ const children = docs.filter(c => childToHub.get(c.path) === d.path);
718
+ const ages = [d.daysSinceUpdate, ...children.map(c => c.daysSinceUpdate)].filter(n => n != null);
719
+ units.push({ anchor: ages.length ? Math.min(...ages) : Infinity, kind: 'hub', hub: d, info, children });
720
+ } else {
721
+ units.push({ anchor: d.daysSinceUpdate ?? Infinity, kind: 'plan', doc: d });
722
+ }
723
+ }
724
+ // Stable sort by most-recent member ascending (docs arrive already sorted by
725
+ // `updated`, so equal anchors keep their incoming order).
726
+ units.sort((a, b) => a.anchor - b.anchor);
727
+
728
+ // Shared slug width for the left-most rows (standalone plans + hub headers).
729
+ const topSlugs = units.map(u => u.kind === 'hub' ? toSlug(u.hub) : toSlug(u.doc));
730
+ const topMaxSlug = Math.min(30, Math.max(...topSlugs.map(s => s.length)));
731
+
732
+ for (const u of units) {
733
+ if (u.kind === 'plan') {
734
+ process.stdout.write(formatPlanRow(u.doc, maxWidth, { slug: toSlug(u.doc), maxSlug: topMaxSlug, showTag: true }) + '\n');
735
+ } else {
736
+ renderHubBlock(u.hub, u.info, u.children, maxWidth, topMaxSlug);
528
737
  }
529
- process.stdout.write(line + '\n');
738
+ }
739
+ }
740
+
741
+ function renderHubBlock(hub, info, children, maxWidth, topMaxSlug) {
742
+ const hubSlug = toSlug(hub);
743
+ const nextDoc = info.nextChildPath ? info.children.find(c => c.path === info.nextChildPath)?.doc : null;
744
+ const nextLabel = nextDoc ? stripHubPrefix(toSlug(nextDoc), hubSlug) : null;
745
+ const descr = nextLabel
746
+ ? `runlist · ${info.doneCount}/${info.total} · next → ${nextLabel}`
747
+ : `runlist · ${info.doneCount}/${info.total} · all archived`;
748
+
749
+ // Header row: hub slug + descriptor, with `[RUNLIST]` right-aligned like a tag.
750
+ const slugCell = hubSlug.padEnd(topMaxSlug);
751
+ let header = ` ${slugCell} ${dim(descr)}`;
752
+ const targetCol = maxWidth - MAX_TAG_WIDTH;
753
+ const pad = Math.max(2, targetCol - visibleLen(header));
754
+ header = `${header}${' '.repeat(pad)}${RUNLIST_TAG}`;
755
+ process.stdout.write(header + '\n');
756
+
757
+ if (children.length === 0) return;
758
+ const childMaxSlug = Math.min(28, Math.max(...children.map(c => stripHubPrefix(toSlug(c), hubSlug).length)));
759
+ for (const c of children) {
760
+ const isNext = c.path === info.nextChildPath;
761
+ const indent = isNext ? ` ${green('→')} ` : ' ';
762
+ process.stdout.write(formatPlanRow(c, maxWidth, {
763
+ slug: stripHubPrefix(toSlug(c), hubSlug), indent, maxSlug: childMaxSlug, showTag: true,
764
+ }) + '\n');
765
+ }
766
+ }
767
+
768
+ // Coordination hubs (prose-first runlists) render in their own compact section:
769
+ // label · age · rough related-cluster size · one-line descriptor. No fold, no
770
+ // per-row tag — the section header is the signal. The count is the resolved
771
+ // `related_plans:` cluster, which includes peer/parent runlists, so it's
772
+ // labelled `related` (not `plans`) to stay honest. Status shows only when it's
773
+ // not the expected `active` (e.g. a `partial` hub).
774
+ function renderCoordinationSection(coordDocs, coordination, maxWidth, total) {
775
+ process.stdout.write(`\n${bold(`Runlists (${total ?? coordDocs.length})`)}\n`);
776
+ const maxSlug = Math.min(34, Math.max(...coordDocs.map(d => hubLabel(d).length)));
777
+ for (const doc of coordDocs) {
778
+ const info = coordination.get(doc.path);
779
+ const slug = hubLabel(doc).padEnd(maxSlug);
780
+ const age = doc.daysSinceUpdate != null ? `${doc.daysSinceUpdate}d` : '—';
781
+ const ageStr = doc.daysSinceUpdate != null && doc.isStale ? red(age.padStart(4)) : dim(age.padStart(4));
782
+ const count = info?.childCount ? `${String(info.childCount).padStart(2)} related` : ' ';
783
+ const statusTag = doc.status && doc.status !== 'active' ? ` ${colorTag(doc.status)}` : '';
784
+ const desc = (doc.nextStep || doc.currentState || doc.title || '').replace(/\s+/g, ' ').trim();
785
+
786
+ // Next-pickup (first non-archived ranked child from the body) leads the
787
+ // description column when one resolves — it's the most actionable cell. Its
788
+ // status is shown only when it's not the expected `active`.
789
+ const next = info?.nextPickup;
790
+ const nextStr = next
791
+ ? `${green('→')} ${next.label}${next.status && next.status !== 'active' ? dim(` (${next.status})`) : ''}`
792
+ : '';
793
+ const nextPart = nextStr ? `${nextStr} ` : '';
794
+
795
+ const left = ` ${slug} ${ageStr} ${dim(count)} `;
796
+ const budget = Math.max(10, maxWidth - visibleLen(left) - visibleLen(nextPart) - visibleLen(statusTag) - 2);
797
+ const descR = desc.length > budget ? desc.slice(0, budget - 3) + '...' : desc;
798
+ process.stdout.write(`${left}${nextPart}${dim(descR)}${statusTag}\n`);
530
799
  }
531
800
  }
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 = [];