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/README.md +102 -13
- package/bin/dotmd.mjs +56 -5
- package/package.json +1 -1
- package/src/commands.mjs +1 -1
- package/src/completions.mjs +1 -1
- package/src/health.mjs +56 -9
- package/src/index.mjs +8 -1
- package/src/lifecycle.mjs +45 -13
- package/src/query.mjs +315 -46
- package/src/render.mjs +22 -3
- package/src/runlist.mjs +202 -7
- package/src/validate.mjs +27 -0
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
|
-
|
|
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
|
-
//
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
683
|
+
process.stdout.write(formatPlanRow(doc, maxWidth, { slug: toSlug(doc), maxSlug, showTag }) + '\n');
|
|
684
|
+
}
|
|
685
|
+
}
|
|
494
686
|
|
|
495
|
-
|
|
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
|
-
|
|
503
|
-
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = [];
|