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/hud.mjs CHANGED
@@ -8,6 +8,7 @@ import { buildIndex } from './index.mjs';
8
8
  import { refreshStaleSlashCommands } from './claude-commands.mjs';
9
9
  import { readJournalEntries, journalFilePath, readMisuseEntries } from './journal.mjs';
10
10
  import { compareVersions } from './update.mjs';
11
+ import { findOwnedPlan } from './baton.mjs';
11
12
 
12
13
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
14
  const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
@@ -51,6 +52,8 @@ export function actionablePromptStatuses(config) {
51
52
  return new Set(['pending']);
52
53
  }
53
54
 
55
+ // Returns repo paths, oldest-created first — the same order no-arg `dotmd use`
56
+ // consumes them, so prompts[0] is always "the one you'd pick up next".
54
57
  function findActionablePrompts(config) {
55
58
  const roots = config.docsRoots || (config.docsRoot ? [config.docsRoot] : []);
56
59
  const archiveDir = config.archiveDir || 'archived';
@@ -80,11 +83,13 @@ function findActionablePrompts(config) {
80
83
  const fm = parseSimpleFrontmatter(frontmatter);
81
84
  if (asString(fm.type) !== 'prompt') continue;
82
85
  if (!actionable.has(asString(fm.status))) continue;
83
- found.push(toRepoPath(filePath, config.repoRoot));
86
+ found.push({ path: toRepoPath(filePath, config.repoRoot), created: asString(fm.created) ?? '' });
84
87
  }
85
88
  }
86
89
 
87
- return found.sort();
90
+ return found
91
+ .sort((a, b) => a.created.localeCompare(b.created) || a.path.localeCompare(b.path))
92
+ .map(p => p.path);
88
93
  }
89
94
 
90
95
  // F17b: hud reads journal. Three additive sections, gated on
@@ -200,8 +205,8 @@ const MISUSE_RECAP_THRESHOLD = 3;
200
205
 
201
206
  const MISUSE_CORRECTIONS = {
202
207
  'edit-status': 'never hand-edit `status:`; use `dotmd set <status> <file>`',
203
- 'cat-prompt': 'read saved prompts with `dotmd use <file>`',
204
- 'read-prompt': 'read saved prompts with `dotmd use <file>`',
208
+ 'cat-prompt': 'consume prompts with `dotmd use <file>`; peek without consuming via `dotmd prompts show <file>`',
209
+ 'read-prompt': 'consume prompts with `dotmd use <file>`; peek without consuming via `dotmd prompts show <file>`',
205
210
  'commit-prompt': 'saved prompts are session-local; never git add/commit them',
206
211
  };
207
212
 
@@ -235,6 +240,10 @@ export function buildHud(config) {
235
240
  // SessionStart for platform-scale corpora. Per-file validation + checkIndex
236
241
  // still run, so the error count matches `dotmd check`'s.
237
242
  let errors = 0;
243
+ // `owned` answers "which plan is THIS session's?" for programmatic callers
244
+ // (the baton flow reads it) — derived from the journal, falling back to the
245
+ // only in-session plan. Null when there's no defensible answer.
246
+ let owned = null;
238
247
  try {
239
248
  // `autoHealIndex: true` mirrors `dotmd check` — drift from non-regen
240
249
  // mutation paths (`lint --fix`, direct file edits, etc.) heals silently
@@ -242,12 +251,14 @@ export function buildHud(config) {
242
251
  // spurious "Run `dotmd index`" error in the hud error count.
243
252
  const index = buildIndex(config, { errorsOnly: true, autoHealIndex: true });
244
253
  errors = index.errors.length;
254
+ const o = findOwnedPlan(config, index);
255
+ if (o.plan) owned = { path: o.plan.path, title: o.plan.title ?? null, via: o.via };
245
256
  } catch { /* swallow — bad config shouldn't break the SessionStart hook */ }
246
257
 
247
258
  const { previousSelf, fleet, recentRejections } = buildJournalSections(config);
248
259
  const misuseRecap = buildMisuseRecap(config);
249
260
 
250
- return { prompts, errors, previousSelf, fleet, recentRejections, misuseRecap };
261
+ return { owned, prompts, errors, previousSelf, fleet, recentRejections, misuseRecap };
251
262
  }
252
263
 
253
264
  // Subagent primer: a spawned subagent (Explore, Plan, general-purpose) starts
@@ -311,19 +322,30 @@ export function runHud(argv, config) {
311
322
  return;
312
323
  }
313
324
 
314
- // SessionStart contract: emit ONLY the command primer — the verb cheat-sheet
315
- // that tells the agent which dotmd verbs exist. Everything else hud used to
316
- // print (held/prompts/stuck/errors state, slash-command refresh notices, and
317
- // the journal-aware previous-self / fleet / recent-rejections sections) is
318
- // deliberately suppressed here: those signals nudged agents into phantom
319
- // follow-up work — e.g. "errors: 1 (run dotmd check)" prompting a check run
320
- // for state that belongs inside its own command. Each of those signals lives
321
- // in its proper command (`plans`, `prompts`, `check`) and stays available via
322
- // `dotmd hud --json` for programmatic callers. The hook's job is purely to
323
- // teach the verbs, never to report status. The misuse recap below is the one
324
- // exception because it IS teaching: a repeat-offense rule means the primer
325
- // alone isn't landing, so name the specific habit to break.
326
- process.stdout.write(dim('dotmd: plans|briefing set <status> [<file>] new <type> <slug> use [<file>] archive <file> (use [no-arg] → oldest pending prompt)') + '\n');
325
+ // SessionStart contract: the command primer, plus ONLY signals that carry a
326
+ // direct instruction for this session. Passive state (error counts,
327
+ // slash-command refresh notices, previous-self / fleet / recent-rejections)
328
+ // stays suppressed — those nudged agents into phantom follow-up work (e.g.
329
+ // "errors: 1" prompting a check run) and live in their proper commands and
330
+ // `dotmd hud --json`. Two signals ARE instructions and must print, because
331
+ // the handoff loop dies without them (sessions were saving batons that no
332
+ // next session ever picked up):
333
+ // - pending prompts: the previous session queued work for THIS one;
334
+ // consuming it is the very next action.
335
+ // - an in-session plan attributed to this sid via the journal: this
336
+ // session (pre-compaction) owns it and should continue or hand it off.
337
+ // The single-in-session fallback is deliberately NOT printed — at
338
+ // SessionStart that plan likely belongs to another live session.
339
+ // The misuse recap stays for the same reason: a repeat-offense rule means
340
+ // the primer alone isn't landing, so name the habit to break.
341
+ process.stdout.write(dim('dotmd: plans|briefing set <status> [<file>] new <type> <slug> use [<file>] archive <file> baton [<slug>] <@draft|-> (save a resume prompt; releases the in-session plan if any) (use [no-arg] → oldest pending prompt)') + '\n');
342
+ if (hud.owned && hud.owned.via === 'journal') {
343
+ process.stdout.write(yellow(`[dotmd] in-session (yours): ${hud.owned.path} — continue it; hand off with \`dotmd baton @/tmp/draft.md\` before stopping.`) + '\n');
344
+ }
345
+ if (hud.prompts.length > 0) {
346
+ const n = hud.prompts.length;
347
+ process.stdout.write(yellow(`[dotmd] ${n} pending prompt${n === 1 ? '' : 's'} queued for this session — unless the user asks for something else, start by running \`dotmd use\` to consume the oldest (${hud.prompts[0]}) and act on it. Peek first: \`dotmd prompts show <file>\`; list: \`dotmd prompts\`.`) + '\n');
348
+ }
327
349
  if (hud.misuseRecap) process.stdout.write(yellow(`[dotmd] ${hud.misuseRecap}`) + '\n');
328
350
  if (drift) process.stdout.write(yellow(drift) + '\n');
329
351
  }
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, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate, enrichRefErrorSuggestions } from './validate.mjs';
6
+ import { validateDoc, validatePlanShape, validateDocShape, checkBidirectionalReferences, checkGitStaleness, checkRunlistBackPointers, checkCoordinationHubExecutionMode, 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';
@@ -120,6 +120,13 @@ export function buildIndex(config, opts = {}) {
120
120
  if (child) child.warnings.push(w);
121
121
  }
122
122
 
123
+ const coordHubWarnings = checkCoordinationHubExecutionMode(transformedDocs, config);
124
+ warnings.push(...coordHubWarnings);
125
+ for (const w of coordHubWarnings) {
126
+ const hub = transformedDocs.find(d => d.path === w.path);
127
+ if (hub) hub.warnings.push(w);
128
+ }
129
+
123
130
  const gitWarnings = checkGitStaleness(transformedDocs, config);
124
131
  warnings.push(...gitWarnings);
125
132
 
package/src/lifecycle.mjs CHANGED
@@ -928,7 +928,10 @@ export function appendVersionHistory(filePath, entry, { createSection = false }
928
928
 
929
929
  export function updateFrontmatter(filePath, updates) {
930
930
  const raw = readFileSync(filePath, 'utf8');
931
- if (!raw.startsWith('---\n')) throw new Error(`${filePath} has no frontmatter block.`);
931
+ // Name the remedy in the error: this is where every status verb lands when a
932
+ // doc was created outside dotmd, and "no frontmatter block" alone left
933
+ // sessions retrying other verbs instead of fixing the doc.
934
+ if (!raw.startsWith('---\n')) throw new Error(`${filePath} has no frontmatter block. Retrofit it first: dotmd bulk-tag ${filePath} --type <type> --status <status>`);
932
935
 
933
936
  const endMarker = raw.indexOf('\n---\n', 4);
934
937
  if (endMarker === -1) throw new Error(`${filePath} has unclosed frontmatter block.`);
package/src/new.mjs CHANGED
@@ -256,7 +256,7 @@ function mergeBodyFrontmatter(scaffoldFm, overrides, cliType) {
256
256
  return fm;
257
257
  }
258
258
 
259
- function readBodyInput(source) {
259
+ export function readBodyInput(source) {
260
260
  if (source === '-') {
261
261
  try { return readFileSync(0, 'utf8'); } catch (err) { die(`Could not read body from stdin: ${err.message}`); }
262
262
  }
@@ -548,6 +548,12 @@ export async function runNew(argv, config, opts = {}) {
548
548
  if (typeName === 'prompt') {
549
549
  process.stdout.write(dim('Session-local — no need to commit. The next session runs `dotmd use` (or `dotmd use ' + repoPath + '`) to consume it.\n'));
550
550
  }
551
+ // Teach the field-length contract at the moment the fields get written —
552
+ // learning it from a cap warning later sends sessions into hand-trim /
553
+ // re-check loops.
554
+ if (typeName === 'plan') {
555
+ process.stdout.write(dim('current_state = 2-4 sentence summary (cap 1500 chars); next_step = 1-2 sentence pointer (cap 800). Detail goes in the body, not frontmatter.\n'));
556
+ }
551
557
  try {
552
558
  const { isGitIgnored } = await import('./git.mjs');
553
559
  if (isGitIgnored(filePath, config.repoRoot)) {
package/src/prompts.mjs CHANGED
@@ -11,7 +11,7 @@ import { green, dim } from './color.mjs';
11
11
  // `resume` is an alias for `use` — agents reach for "resume" when continuing a
12
12
  // session; `use` reads as internal mechanics. Both names stay valid; the
13
13
  // canonical output ("Consumed: …") is unchanged.
14
- const SUBCOMMANDS = new Set(['list', 'next', 'use', 'resume', 'archive', 'new', 'hold', 'unhold', 'shelve', 'unshelve']);
14
+ const SUBCOMMANDS = new Set(['list', 'next', 'use', 'resume', 'show', 'peek', 'archive', 'new', 'hold', 'unhold', 'shelve', 'unshelve']);
15
15
 
16
16
  export async function runPrompts(argv, config, opts = {}) {
17
17
  const sub = argv[0];
@@ -26,6 +26,8 @@ export async function runPrompts(argv, config, opts = {}) {
26
26
  case 'next': return runPromptsNext(rest, config, opts);
27
27
  case 'use': return runPromptsUse(rest, config, opts);
28
28
  case 'resume': return runPromptsUse(rest, config, opts);
29
+ case 'show': return runPromptsShow(rest, config);
30
+ case 'peek': return runPromptsShow(rest, config);
29
31
  case 'archive': return runPromptsArchive(rest, config, opts);
30
32
  case 'new': return runPromptsNew(rest, config, opts);
31
33
  case 'hold': return runPromptsHold(rest, config, opts);
@@ -283,6 +285,28 @@ export function consumePrompt(filePath, config, opts) {
283
285
  process.stderr.write(`${green('✓ Consumed')}: ${consumedPath}\n`);
284
286
  }
285
287
 
288
+ // Read-only peek: print the body WITHOUT consuming. The sanctioned triage path
289
+ // — surveying pending prompts must not archive them (that's `use`'s job), and
290
+ // it must not require raw cat/Read (which the guard warns about).
291
+ function runPromptsShow(argv, config) {
292
+ const input = argv.find(a => !a.startsWith('-'));
293
+ if (!input) die('Usage: dotmd prompts show <file-or-slug>');
294
+ const filePath = resolvePromptInput(input, config);
295
+
296
+ const raw = readFileSync(filePath, 'utf8');
297
+ const { frontmatter, body } = extractFrontmatter(raw);
298
+ const parsed = parseSimpleFrontmatter(frontmatter);
299
+ const repoPath = toRepoPath(filePath, config.repoRoot);
300
+ if (asString(parsed.type) !== 'prompt') {
301
+ die(`Not a prompt (type: ${asString(parsed.type) ?? 'unknown'}): ${repoPath}`);
302
+ }
303
+
304
+ const status = asString(parsed.status) ?? 'unknown';
305
+ process.stderr.write(dim(`${repoPath} [${status}] — read-only peek; \`dotmd use ${repoPath}\` to consume\n`));
306
+ process.stdout.write(body);
307
+ if (!body.endsWith('\n')) process.stdout.write('\n');
308
+ }
309
+
286
310
  function runPromptsArchive(argv, config, opts = {}) {
287
311
  const input = argv.find(a => !a.startsWith('-'));
288
312
  if (!input) die('Usage: dotmd prompts archive <file-or-slug>');
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,83 @@ 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
+ updated: d.updated,
166
+ nextStep: d.nextStep ?? null,
167
+ }));
168
+ process.stdout.write(JSON.stringify({ count: runlists.length, runlists }, null, 2) + '\n');
169
+ return;
170
+ }
171
+
172
+ if (hubs.length === 0) {
173
+ process.stdout.write('No runlists found. A runlist is a plan with `execution_mode: coordination` (or a `*-runlist` slug).\n');
174
+ return;
175
+ }
176
+
177
+ const maxWidth = process.stdout.columns || 100;
178
+ const shown = hubs.slice(0, limit);
179
+ renderCoordinationSection(shown, coordination, maxWidth, hubs.length);
180
+ const hidden = hubs.length - shown.length;
181
+ if (hidden > 0) process.stdout.write(dim(` ${hidden} more · dotmd runlists --limit ${hubs.length}\n`));
182
+ process.stdout.write('\n');
183
+ }
184
+
104
185
  // When a query returns nothing AND a value-shaped filter (currently --module)
105
186
  // names a value that doesn't exist anywhere in the index, the empty result is
106
187
  // almost certainly a typo rather than a combination miss. Surface a hint so
@@ -254,6 +335,10 @@ export function filterDocs(docs, filters, config) {
254
335
  const s = d.status ?? 'unknown';
255
336
  filters._statusCounts[s] = (filters._statusCounts[s] ?? 0) + 1;
256
337
  }
338
+ // Keep a reference to the full pre-limit set so the plans header can
339
+ // reclassify runlist hubs out of the status breakdown (it needs per-doc
340
+ // identity, not just aggregate counts).
341
+ filters._matched = result;
257
342
  return filters.all ? result : result.slice(0, filters.limit);
258
343
  }
259
344
 
@@ -394,6 +479,16 @@ function renderPlansOutput(docs, filters, config, opts = {}) {
394
479
  return;
395
480
  }
396
481
 
482
+ const maxWidth = process.stdout.columns || 100;
483
+ const grouped = filters.sort === 'status' || filters.group;
484
+ // Runlist treatment (sprint-hub folding + the coordination-hub section) is
485
+ // scoped to the flat triage view; grouped views keep their existing shape.
486
+ const runlist = !grouped ? opts.runlist : null;
487
+ const coordination = !grouped ? opts.coordination : null;
488
+ // A doc is a "runlist" for header/section purposes if it's either a
489
+ // frontmatter-`runlist:` sprint hub or an `execution_mode: coordination` hub.
490
+ const isHub = (p) => Boolean(runlist?.hubs.has(p) || coordination?.has(p));
491
+
397
492
  // Summary line: middle-dot separator, ALWAYS based on the full pre-limit
398
493
  // pipeline so the top-of-page numbers stay honest when --limit is applied.
399
494
  const totalShown = docs.length;
@@ -403,9 +498,27 @@ function renderPlansOutput(docs, filters, config, opts = {}) {
403
498
  for (const d of docs) { counts[d.status] = (counts[d.status] ?? 0) + 1; }
404
499
  return counts;
405
500
  })();
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 : ''}`;
501
+ // With runlists present, pull hubs out of the per-status breakdown into a
502
+ // dedicated `N runlist` bucket so a hub reads as an active *runlist*, not one
503
+ // more active plan. Needs per-doc identity, so recompute from the pre-limit
504
+ // matched set rather than the aggregate counts.
505
+ let hubCount = 0;
506
+ let counts;
507
+ if ((runlist?.hubs.size || coordination?.size) && filters._matched) {
508
+ const reclassed = {};
509
+ for (const d of filters._matched) {
510
+ if (isHub(d.path)) { hubCount += 1; continue; }
511
+ const s = d.status ?? 'unknown';
512
+ reclassed[s] = (reclassed[s] ?? 0) + 1;
513
+ }
514
+ counts = Object.entries(reclassed).sort((a, b) => b[1] - a[1]).map(([s, n]) => `${n} ${s}`);
515
+ } else {
516
+ counts = Object.entries(bySt).sort((a, b) => b[1] - a[1]).map(([s, n]) => `${n} ${s}`);
517
+ }
518
+ const headerParts = [];
519
+ if (hubCount) headerParts.push(`${hubCount} runlist${hubCount === 1 ? '' : 's'}`);
520
+ headerParts.push(...counts);
521
+ const header = `${totalAll} ${noun}${headerParts.length ? ' · ' + headerParts.join(' · ') : ''}`;
409
522
  process.stdout.write(dim(header) + '\n');
410
523
 
411
524
  // Active filter note
@@ -420,9 +533,6 @@ function renderPlansOutput(docs, filters, config, opts = {}) {
420
533
  if (filters.hasBlockers) activeFilters.push('has blockers');
421
534
  if (activeFilters.length) process.stdout.write(dim(` filtered: ${activeFilters.join(' | ')}`) + '\n');
422
535
 
423
- const maxWidth = process.stdout.columns || 100;
424
- const grouped = filters.sort === 'status' || filters.group;
425
-
426
536
  if (filters.group === 'module') {
427
537
  process.stdout.write('\n');
428
538
  renderPlansByGroup(docs, d => d.modules?.length ? d.modules : ['(none)'], filters, maxWidth);
@@ -447,12 +557,47 @@ function renderPlansOutput(docs, filters, config, opts = {}) {
447
557
  renderPlanRows(group, filters, maxWidth, { showTag: false });
448
558
  }
449
559
  } else {
450
- // Triage view: flat, sorted by recency, tag on right.
560
+ // Flat triage view, "capped like leaves": work from the full pre-limit
561
+ // matched set and cap each kind independently. Leaf plans (+ frontmatter
562
+ // `runlist:` sprint hubs, which still fold inline) fill the main list;
563
+ // coordination hubs are lifted into their own `Runlists` section. Each
564
+ // section caps at `--limit` with its own "N more" footer; `--all` lifts
565
+ // both caps. The Runlists section is pinned — it shows whenever hubs exist,
566
+ // independent of how the leaf list fills up.
567
+ const matched = filters._matched ?? docs;
568
+ const coordAll = [];
569
+ const mainAll = [];
570
+ for (const d of matched) {
571
+ if (coordination?.has(d.path)) coordAll.push(d);
572
+ else mainAll.push(d);
573
+ }
574
+ const mainShown = filters.all ? mainAll : mainAll.slice(0, filters.limit);
575
+ const coordShown = filters.all ? coordAll : coordAll.slice(0, filters.limit);
576
+
451
577
  process.stdout.write('\n');
452
- renderPlanRows(docs, filters, maxWidth, { showTag: true });
578
+ if (mainShown.length) {
579
+ if (runlist?.hubs.size) renderTriageWithRunlists(mainShown, runlist, maxWidth);
580
+ else renderPlanRows(mainShown, filters, maxWidth, { showTag: true });
581
+ }
582
+ const mainHidden = mainAll.length - mainShown.length;
583
+ if (mainHidden > 0) {
584
+ process.stdout.write('\n');
585
+ process.stdout.write(dim(` ${mainHidden} more ${noun} · dotmd ${noun} --all · dotmd ${noun} status\n`));
586
+ }
587
+
588
+ if (coordShown.length) {
589
+ renderCoordinationSection(coordShown, coordination, maxWidth, coordAll.length);
590
+ const coordHidden = coordAll.length - coordShown.length;
591
+ if (coordHidden > 0) {
592
+ process.stdout.write(dim(` ${coordHidden} more runlists · dotmd ${noun} --all\n`));
593
+ }
594
+ }
595
+
596
+ process.stdout.write('\n');
597
+ return;
453
598
  }
454
599
 
455
- // Footer when the result was capped — emit for every view shape.
600
+ // Footer (grouped views) — emit when the result was capped.
456
601
  const hidden = totalAll - totalShown;
457
602
  if (hidden > 0) {
458
603
  process.stdout.write('\n');
@@ -483,49 +628,163 @@ function renderPlansByGroup(docs, keyFn, filters, maxWidth) {
483
628
  // next-step column when right-aligning tags.
484
629
  const MAX_TAG_WIDTH = '[QUEUED-AFTER]'.length;
485
630
 
486
- function renderPlanRows(group, filters, maxWidth, opts = {}) {
631
+ // Format a single triage row: `<indent><slug> <age> <pct> <next-step>` with
632
+ // an optional right-aligned tag. `indent` carries the left gutter (and, for
633
+ // runlist children, the `→` next-pickup marker), so its visible length must be
634
+ // stable across sibling rows for the slug column to align. `tag` overrides the
635
+ // status tag (used for the `[RUNLIST]` hub tag).
636
+ function formatPlanRow(doc, maxWidth, { slug, indent = ' ', maxSlug, showTag = false, tag } = {}) {
637
+ const slugCell = slug.padEnd(maxSlug ?? slug.length);
638
+ const age = doc.daysSinceUpdate != null ? `${doc.daysSinceUpdate}d` : '—';
639
+ const ageStr = doc.daysSinceUpdate != null && doc.isStale ? red(age.padStart(4)) : dim(age.padStart(4));
640
+
641
+ // Compact percentage cell (always 5 chars: "100% " / " 99% " / " 5% " / " ")
642
+ let pctCell = ' ';
643
+ if (doc.checklist?.total) {
644
+ const pct = Math.round((doc.checklist.completed / doc.checklist.total) * 100);
645
+ pctCell = `${pct.toString().padStart(3)}% `;
646
+ }
647
+
648
+ const leftBlock = `${indent}${slugCell} ${ageStr} ${dim(pctCell)}`;
649
+ const leftLen = visibleLen(leftBlock);
650
+
651
+ // Next-step / blocker text. Budget = maxWidth - leftLen - separator - (tag column if shown).
652
+ let nextText = '';
653
+ if (doc.blockers?.length && doc.status === 'blocked') {
654
+ nextText = `blocked by ${doc.blockers.join('; ')}`;
655
+ } else if (doc.nextStep) {
656
+ nextText = doc.nextStep;
657
+ }
658
+
659
+ const tagBudget = showTag ? MAX_TAG_WIDTH + 2 : 0; // 2 = gap before tag
660
+ const nextBudget = Math.max(10, maxWidth - leftLen - 2 - tagBudget); // -2 for ` ` separator
661
+ let nextRendered = nextText;
662
+ if (nextText.length > nextBudget) nextRendered = nextText.slice(0, nextBudget - 3) + '...';
663
+
664
+ // Coloring for "blocked by" stays yellow.
665
+ if (doc.blockers?.length && doc.status === 'blocked') nextRendered = yellow(nextRendered);
666
+
667
+ let line = `${leftBlock} ${nextRendered}`;
668
+ if (showTag) {
669
+ // Pad next column to push tag to the right column boundary.
670
+ const consumed = visibleLen(line);
671
+ const targetCol = maxWidth - MAX_TAG_WIDTH;
672
+ const padCount = Math.max(2, targetCol - consumed);
673
+ line = `${line}${' '.repeat(padCount)}${tag ?? colorTag(doc.status)}`;
674
+ }
675
+ return line;
676
+ }
677
+
678
+ function renderPlanRows(group, _filters, maxWidth, opts = {}) {
487
679
  const { showTag = false } = opts;
488
680
  const maxSlug = Math.min(30, Math.max(...group.map(d => toSlug(d).length)));
489
-
490
681
  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));
682
+ process.stdout.write(formatPlanRow(doc, maxWidth, { slug: toSlug(doc), maxSlug, showTag }) + '\n');
683
+ }
684
+ }
494
685
 
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
- }
686
+ const RUNLIST_TAG = bold(cyan('[RUNLIST]'));
501
687
 
502
- const leftBlock = ` ${slug} ${ageStr} ${dim(pctCell)}`;
503
- const leftLen = visibleLen(leftBlock);
688
+ // Drop a hub's slug prefix off a child slug so a sprint's children read as
689
+ // `01-extract` rather than `auth-revamp-01-extract`. Falls back to the full
690
+ // slug when there's no shared prefix.
691
+ function stripHubPrefix(childSlug, hubSlug) {
692
+ return childSlug.startsWith(`${hubSlug}-`) ? childSlug.slice(hubSlug.length + 1) : childSlug;
693
+ }
504
694
 
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
- }
695
+ // Flat triage view, runlist-aware. Standalone plans render as before; each hub
696
+ // becomes a `[RUNLIST]` header with its (filtered) children folded underneath
697
+ // in runlist order, the next pickup marked `→`. A child whose hub is absent
698
+ // from this filtered set (e.g. `--status active` hid the hub) renders
699
+ // standalone so it still surfaces. Render units sort by their most-recent
700
+ // member so an actively-worked sprint stays near the top.
701
+ function renderTriageWithRunlists(docs, runlist, maxWidth) {
702
+ const { hubs, childToHub } = runlist;
703
+ const docPathSet = new Set(docs.map(d => d.path));
704
+
705
+ const folded = new Set();
706
+ for (const d of docs) {
707
+ const hubPath = childToHub.get(d.path);
708
+ if (hubPath && docPathSet.has(hubPath)) folded.add(d.path);
709
+ }
512
710
 
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)}`;
711
+ const units = [];
712
+ for (const d of docs) {
713
+ if (folded.has(d.path)) continue; // emitted under its hub
714
+ const info = hubs.get(d.path);
715
+ if (info) {
716
+ const children = docs.filter(c => childToHub.get(c.path) === d.path);
717
+ const ages = [d.daysSinceUpdate, ...children.map(c => c.daysSinceUpdate)].filter(n => n != null);
718
+ units.push({ anchor: ages.length ? Math.min(...ages) : Infinity, kind: 'hub', hub: d, info, children });
719
+ } else {
720
+ units.push({ anchor: d.daysSinceUpdate ?? Infinity, kind: 'plan', doc: d });
721
+ }
722
+ }
723
+ // Stable sort by most-recent member ascending (docs arrive already sorted by
724
+ // `updated`, so equal anchors keep their incoming order).
725
+ units.sort((a, b) => a.anchor - b.anchor);
726
+
727
+ // Shared slug width for the left-most rows (standalone plans + hub headers).
728
+ const topSlugs = units.map(u => u.kind === 'hub' ? toSlug(u.hub) : toSlug(u.doc));
729
+ const topMaxSlug = Math.min(30, Math.max(...topSlugs.map(s => s.length)));
730
+
731
+ for (const u of units) {
732
+ if (u.kind === 'plan') {
733
+ process.stdout.write(formatPlanRow(u.doc, maxWidth, { slug: toSlug(u.doc), maxSlug: topMaxSlug, showTag: true }) + '\n');
734
+ } else {
735
+ renderHubBlock(u.hub, u.info, u.children, maxWidth, topMaxSlug);
528
736
  }
529
- process.stdout.write(line + '\n');
737
+ }
738
+ }
739
+
740
+ function renderHubBlock(hub, info, children, maxWidth, topMaxSlug) {
741
+ const hubSlug = toSlug(hub);
742
+ const nextDoc = info.nextChildPath ? info.children.find(c => c.path === info.nextChildPath)?.doc : null;
743
+ const nextLabel = nextDoc ? stripHubPrefix(toSlug(nextDoc), hubSlug) : null;
744
+ const descr = nextLabel
745
+ ? `runlist · ${info.doneCount}/${info.total} · next → ${nextLabel}`
746
+ : `runlist · ${info.doneCount}/${info.total} · all archived`;
747
+
748
+ // Header row: hub slug + descriptor, with `[RUNLIST]` right-aligned like a tag.
749
+ const slugCell = hubSlug.padEnd(topMaxSlug);
750
+ let header = ` ${slugCell} ${dim(descr)}`;
751
+ const targetCol = maxWidth - MAX_TAG_WIDTH;
752
+ const pad = Math.max(2, targetCol - visibleLen(header));
753
+ header = `${header}${' '.repeat(pad)}${RUNLIST_TAG}`;
754
+ process.stdout.write(header + '\n');
755
+
756
+ if (children.length === 0) return;
757
+ const childMaxSlug = Math.min(28, Math.max(...children.map(c => stripHubPrefix(toSlug(c), hubSlug).length)));
758
+ for (const c of children) {
759
+ const isNext = c.path === info.nextChildPath;
760
+ const indent = isNext ? ` ${green('→')} ` : ' ';
761
+ process.stdout.write(formatPlanRow(c, maxWidth, {
762
+ slug: stripHubPrefix(toSlug(c), hubSlug), indent, maxSlug: childMaxSlug, showTag: true,
763
+ }) + '\n');
764
+ }
765
+ }
766
+
767
+ // Coordination hubs (prose-first runlists) render in their own compact section:
768
+ // label · age · rough related-cluster size · one-line descriptor. No fold, no
769
+ // per-row tag — the section header is the signal. The count is the resolved
770
+ // `related_plans:` cluster, which includes peer/parent runlists, so it's
771
+ // labelled `related` (not `plans`) to stay honest. Status shows only when it's
772
+ // not the expected `active` (e.g. a `partial` hub).
773
+ function renderCoordinationSection(coordDocs, coordination, maxWidth, total) {
774
+ process.stdout.write(`\n${bold(`Runlists (${total ?? coordDocs.length})`)}\n`);
775
+ const maxSlug = Math.min(34, Math.max(...coordDocs.map(d => hubLabel(d).length)));
776
+ for (const doc of coordDocs) {
777
+ const info = coordination.get(doc.path);
778
+ const slug = hubLabel(doc).padEnd(maxSlug);
779
+ const age = doc.daysSinceUpdate != null ? `${doc.daysSinceUpdate}d` : '—';
780
+ const ageStr = doc.daysSinceUpdate != null && doc.isStale ? red(age.padStart(4)) : dim(age.padStart(4));
781
+ const count = info?.childCount ? `${String(info.childCount).padStart(2)} related` : ' ';
782
+ const statusTag = doc.status && doc.status !== 'active' ? ` ${colorTag(doc.status)}` : '';
783
+ const desc = (doc.nextStep || doc.currentState || doc.title || '').replace(/\s+/g, ' ').trim();
784
+
785
+ const left = ` ${slug} ${ageStr} ${dim(count)} `;
786
+ const budget = Math.max(10, maxWidth - visibleLen(left) - visibleLen(statusTag) - 2);
787
+ const descR = desc.length > budget ? desc.slice(0, budget - 3) + '...' : desc;
788
+ process.stdout.write(`${left}${dim(descR)}${statusTag}\n`);
530
789
  }
531
790
  }