dotmd-cli 0.61.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/README.md CHANGED
@@ -33,7 +33,7 @@ dotmd new my-feature # scaffold a new doc with frontmatter
33
33
  dotmd list # index all docs grouped by status
34
34
  dotmd check # validate frontmatter and references
35
35
  dotmd context # compact briefing (great for LLM context)
36
- dotmd doctor # auto-fix everything in one pass
36
+ dotmd doctor # preview fixes for everything (--apply to write)
37
37
  ```
38
38
 
39
39
  ### Shell Completion
@@ -83,7 +83,9 @@ Explicit frontmatter always wins. Body extraction is a cushion for partially-tag
83
83
  ## What It Does
84
84
 
85
85
  - **Index** — group docs by status, with auto-detected progress bars (from `- [ ]` checklists) and next steps
86
- - **Query** — filter by status, keyword, module, surface, owner, staleness
86
+ - **Query** — filter by status, keyword, module, surface, owner, staleness; `dotmd grep` searches document bodies too
87
+ - **Resume handoff** — `dotmd baton` saves a resume prompt for the next session and releases the in-session plan in one verb
88
+ - **Runlists** — group plans into an ordered sequence on a hub plan; `dotmd runlist next` picks up the next one
87
89
  - **Validate** — check for missing fields, broken references, broken body links, stale dates
88
90
  - **Stats** — health dashboard with staleness, completeness, audit coverage
89
91
  - **Graph** — visualize document relationships as text, Graphviz DOT, or JSON
@@ -129,7 +131,7 @@ Design doc content here...
129
131
  - [ ] Add tests
130
132
  ```
131
133
 
132
- The only required field is `status`. Everything else is optional but unlocks more features. The `type` field (`plan`, `doc`, or `research`) enables type-specific statuses and smarter context briefings.
134
+ The only required field is `status`. Everything else is optional but unlocks more features. The `type` field (`plan`, `doc`, or `prompt`) enables type-specific statuses and smarter context briefings.
133
135
 
134
136
  > **Note:** `module:` and `surface:` (singular) are deprecated as of 0.36.3 — use the plural array forms (`modules:`, `surfaces:`). Run `dotmd lint --fix` to migrate existing docs.
135
137
 
@@ -177,6 +179,34 @@ Each *quiet* status (`partial`, `queued-after`, `archived`) is exempt from stale
177
179
 
178
180
  > **Heads-up:** versions before 0.15 included a `done` plan status in the defaults. It saw effectively zero real-world use (plans went `in-session`/`active` → `archived` directly), so it was dropped from the built-in vocabulary. To finish a plan, run `dotmd archive <plan-file>` — or, if you preferred the previous behavior, add `done` back via the `types.plan.statuses` key in your config.
179
181
 
182
+ ### Runlists: ordered groups of plans
183
+
184
+ When several plans must ship in a known order (an "auth revamp" sprint with extract → rewrite → cleanup phases), declare a `runlist:` array on a hub plan instead of chaining `queued-after` per pair or keeping the order in prose:
185
+
186
+ ```yaml
187
+ ---
188
+ type: plan
189
+ status: active
190
+ title: Auth Revamp
191
+ runlist:
192
+ - auth-revamp-01-extract.md
193
+ - auth-revamp-02-rewrite.md
194
+ - auth-revamp-03-cleanup.md
195
+ ---
196
+ ```
197
+
198
+ ```bash
199
+ dotmd runlist <hub> # children + statuses in order; first non-archived child marked →
200
+ dotmd runlist next <hub> # pick up the next child (marks in-session + prints it)
201
+ dotmd runlists # dashboard of coordination-hub runlists (--json, --limit N)
202
+ ```
203
+
204
+ `runlist next` stops with a runlist-aware error if the next child isn't in a workable status (`active` / `planned` / `in-session`), so you resolve the blocker before continuing. Each child should set `parent_plan:` pointing back at the hub — `dotmd check` warns when it doesn't. There's no separate doc type: a runlist hub is just a plan with the array.
205
+
206
+ In `dotmd plans`, hubs are tagged `[RUNLIST]` (not `[ACTIVE]`) with their children folded underneath — the hub row shows `done/total` progress and the next-pickup `→`, so a multi-plan sprint reads as one runlist instead of cluttering the triage list. A child whose hub is filtered out of the view (e.g. `--status active` when the hub is `planned`) still renders on its own.
207
+
208
+ **Coordination runlists.** A `runlist:` array fits a small ordered *sprint*. For a large, prose-first *coordination map* (a domain hub pointing at many plans, with gating/sequence rationale, sometimes unordered), set `execution_mode: coordination` instead — or just name it `*-runlist`. These aren't folded: `dotmd plans` lifts them into a pinned `Runlists` section and out of the active count, and `dotmd runlists` shows that dashboard standalone. `dotmd briefing` and `dotmd health` do the same — coordination hubs are pulled out of the live/active counts into a `runlists` bucket (briefing) and a held-out `Runlists:` tally (health), so they don't inflate the actionable-plan or aging numbers. The per-hub "N related" count comes from `related_plans:`. `dotmd check` nudges a `*-runlist` hub missing `execution_mode: coordination`.
209
+
180
210
  ## Commands
181
211
 
182
212
  ```
@@ -191,27 +221,36 @@ dotmd unblocks <file> Show what depends on this doc
191
221
  dotmd health [--json] Plan velocity, aging, and pipeline
192
222
  dotmd briefing Compact summary for session start
193
223
  dotmd context [--summarize] Full briefing (LLM-oriented)
224
+ dotmd agent-context Compact bounded JSON context for agents
194
225
  dotmd focus [status] Detailed view for one status group
195
- dotmd query [filters] Filtered search
196
- dotmd plans List all plans
226
+ dotmd query [filters] Filtered search (--body scans document bodies)
227
+ dotmd grep <term> Keyword search incl. bodies — "which doc discussed X?"
228
+ dotmd plans List live plans (excludes archived)
197
229
  dotmd modules Module dashboard (plans grouped by module)
198
230
  dotmd module <name> Plans for one module, grouped by status
231
+ dotmd surfaces List configured surface taxonomy
199
232
  dotmd stale List stale docs
200
233
  dotmd actionable List docs with next steps
201
234
  dotmd index [--print] Generate/update docs.md index block
202
235
  dotmd hud Actionable triage (silent when clean — ideal SessionStart hook)
203
- dotmd use <file> Open a plan (in-session + print it) or consume a prompt
204
- dotmd set <status> <file> Change a document's status (frontmatter write)
236
+ dotmd use [<file-or-slug>] Open by type: prompt → consume, plan → start, doc → read
237
+ (no arg: consume the oldest pending prompt)
238
+ dotmd set <status> <file> Change a document's status (--note appends why to Version History)
239
+ dotmd baton [<plan>|<slug>] <@draft|-> Save a resume prompt; releases the in-session plan
240
+ dotmd runlist <hub> [next] Show or walk an ordered group of plans
205
241
  dotmd status <file> <status> Transition document status (deprecated; prefer set)
206
242
  dotmd archive <file> Archive (status + move + update refs)
207
243
  dotmd bulk archive <files> Archive multiple files at once
244
+ dotmd bulk-tag [files] Tag pre-existing untagged .md files
208
245
  dotmd touch <file> Bump updated date
209
246
  dotmd touch --git Bulk-sync dates from git history
210
- dotmd doctor Auto-fix everything in one pass
247
+ dotmd doctor [--apply] Fix refs, lint, dates, index (previews by default)
248
+ dotmd self-check Project/version skew diagnostic
211
249
  dotmd fix-refs Auto-fix broken reference paths
212
250
  dotmd lint [--fix] Check and auto-fix frontmatter issues
213
251
  dotmd rename <old> <new> Rename doc and update references
214
252
  dotmd migrate <f> <old> <new> Batch update a frontmatter field
253
+ dotmd ship [patch|minor|major] Regen + commit + bump in one step
215
254
  dotmd notion <sub> [db-id] Notion import/export/sync
216
255
  dotmd export [file] Export docs as md, html, or json
217
256
  dotmd summary <file> AI summary of a document
@@ -219,19 +258,22 @@ dotmd glossary <term> Look up domain terms + related docs
219
258
  dotmd watch [command] Re-run a command on file changes
220
259
  dotmd diff [file] Show changes since last updated date
221
260
  dotmd new <type> <name> Create a new doc (type: doc, plan, or prompt)
222
- dotmd prompts [sub] Manage saved prompts (list, next, use, hold, archive, new)
261
+ dotmd prompts [sub] Manage saved prompts (list, show, hold, archive, new)
262
+ dotmd statuses [sub] Manage per-project status taxonomy
223
263
  dotmd journal [flags] View opt-in command-usage journal (DOTMD_JOURNAL=1)
224
264
  dotmd init Create starter config + docs directory
225
265
  dotmd completions <shell> Output shell completion script (bash, zsh)
226
266
  ```
227
267
 
268
+ Run `dotmd help all` for the always-current version of this list, `dotmd help statuses` for the status vocabulary, and `dotmd <cmd> --help` for per-command details.
269
+
228
270
  ### Global Flags
229
271
 
230
272
  ```
231
273
  --config <path> Explicit config file path
232
274
  --dry-run, -n Preview changes without writing anything
233
275
  --root <name> Filter to a specific docs root
234
- --type <t1,t2> Filter by document type (plan, doc, research)
276
+ --type <t1,t2> Filter by document type (plan, doc, prompt, or custom)
235
277
  --verbose Show resolved config details
236
278
  --help, -h Show help (per-command with: dotmd <cmd> --help)
237
279
  --version, -v Show version
@@ -246,9 +288,12 @@ dotmd query --stale --sort updated --all
246
288
  dotmd query --surface backend --checklist-open
247
289
  dotmd query --status active --summarize # AI summaries
248
290
  dotmd query --status active --summarize --summarize-limit 3
291
+ dotmd query --keyword "retries" --body # scan document bodies too
249
292
  ```
250
293
 
251
- Flags: `--type`, `--status`, `--keyword`, `--module`, `--surface`, `--domain`, `--owner`, `--updated-since`, `--stale`, `--has-next-step`, `--has-blockers`, `--checklist-open`, `--sort`, `--limit`, `--all`, `--git`, `--json`, `--summarize`, `--summarize-limit`, `--model`.
294
+ Flags: `--type`, `--status`, `--keyword`, `--body`, `--module`, `--surface`, `--domain`, `--owner`, `--updated-since`, `--stale`, `--has-next-step`, `--has-blockers`, `--checklist-open`, `--sort`, `--limit`, `--all`, `--git`, `--json`, `--summarize`, `--summarize-limit`, `--model`.
295
+
296
+ `dotmd grep <term>` is the "which doc discussed X?" shorthand — an alias for `dotmd query --keyword <term> --body --all` that prints doc cards plus line-numbered excerpts per body hit. Bodies are read lazily (frontmatter filters run first), and it composes with the usual query flags.
252
297
 
253
298
  ### Create Documents
254
299
 
@@ -311,9 +356,19 @@ dotmd new prompt from-file @/tmp/draft.md
311
356
 
312
357
  Manage them with the `prompts` command family:
313
358
 
359
+ Consume one with `dotmd use` — it atomically prints the body and archives the prompt so it can't be double-consumed:
360
+
361
+ ```bash
362
+ dotmd use # consume the oldest pending prompt
363
+ dotmd use <file-or-slug> # consume a specific prompt
364
+ ```
365
+
366
+ Admin verbs live under the `prompts` namespace:
367
+
314
368
  ```bash
315
369
  dotmd prompts # list pending prompts (default)
316
370
  dotmd prompts list --all # all statuses
371
+ dotmd prompts show <file> # read-only peek: print the body WITHOUT consuming
317
372
  dotmd prompts next # print body of oldest pending + auto-archive (one-shot)
318
373
  dotmd prompts use <file> # print body of a specific prompt + auto-archive
319
374
  dotmd prompts hold <file> # park a prompt (status → held) under prompts/held/:
@@ -328,6 +383,31 @@ dotmd prompts new <name> [body] # alias for `dotmd new prompt`
328
383
 
329
384
  Statuses: `pending` (drafted, awaiting a session), `held` (saved but parked under `prompts/held/` — visible in `prompts list`, hidden from `hud`/`briefing`, skipped by `prompts next`), `archived` (consumed or filed away). `shelved` is a legacy spelling accepted for older files; `claimed` is reserved for a future "in-flight" state but is currently a synonym for archived in practice.
330
385
 
386
+ ### Baton: resume prompts & session handoff
387
+
388
+ `dotmd baton` is the "save a resume prompt" verb — the way one session hands work to the next without pasting resume text into chat. Write a short draft (the next concrete decision plus any gotchas, not a recap), then:
389
+
390
+ ```bash
391
+ dotmd baton @/tmp/draft.md # plan mode: a plan is in-session
392
+ ```
393
+
394
+ In plan mode, baton does the whole closeout in one call:
395
+
396
+ 1. Saves a resume prompt named `resume-<plan-slug>` (collision-safe: `-2`, `-3`, …) under `docs/prompts/` with `status: pending`.
397
+ 2. Releases the plan: one status flip, `in-session` → `active` by default (`--status paused|awaiting|partial|blocked` to override, `--note "why"` to record the reason in `## Version History`).
398
+ 3. Prints the exact `git commit` command for the plan's frontmatter change — the prompt stays out of the pathspec, because saved prompts are session-local.
399
+
400
+ Baton resolves *your* plan via the command journal (or takes it explicitly: `dotmd baton <plan-file> @draft`), falling back to the only in-session plan.
401
+
402
+ No plan involved? Slug mode saves the prompt and touches nothing else:
403
+
404
+ ```bash
405
+ dotmd baton checkout-fixes @/tmp/draft.md # saves resume-checkout-fixes; no status changes
406
+ cat /tmp/draft.md | dotmd baton # body from stdin
407
+ ```
408
+
409
+ Either way, the next session's `dotmd hud` surfaces the pending prompt, and `dotmd use` consumes it.
410
+
331
411
  ### Command Journal (opt-in)
332
412
 
333
413
  dotmd's primary user is an agent. Every CLI invocation can be journaled
@@ -403,8 +483,8 @@ Shows: status counts, staleness, errors/warnings, freshness (today/week/month),
403
483
  ### Doctor
404
484
 
405
485
  ```bash
406
- dotmd doctor # fix refs → lint → sync git dates → regen index
407
- dotmd doctor --dry-run # preview all changes
486
+ dotmd doctor # preview: fix refs → lint → sync git dates → regen index
487
+ dotmd doctor --apply # actually write the fixes (previews by default since 0.37.0)
408
488
  dotmd doctor --statuses # detect overloaded status buckets (read-only)
409
489
  dotmd doctor --statuses --json # machine-readable suggestions
410
490
  ```
@@ -607,6 +687,15 @@ dotmd archive docs/plans/my-plan.md --closeout-template # also inject ## Close
607
687
  `in-session` is a status like any other — `dotmd set <status> <file>` writes it
608
688
  to the file's frontmatter and does nothing else.
609
689
 
690
+ Add `--note "why"` to any `set` or `archive` to append the reason to the doc's
691
+ `## Version History` section in the same call (creates the section if missing) —
692
+ it saves the status-change + worklog-edit round-trip. `set partial` without a
693
+ note or successor link prints a reminder.
694
+
695
+ To stop mid-work and hand off to a future session, use `dotmd baton` (see
696
+ [Baton](#baton-resume-prompts--session-handoff)) — it saves the resume prompt
697
+ and releases the plan in one verb.
698
+
610
699
  **Recommended Claude Code hook** — add to `~/.claude/settings.json`
611
700
  (or your project's `.claude/settings.json`):
612
701
 
package/bin/dotmd.mjs CHANGED
@@ -44,6 +44,7 @@ const FLAG_SPECS = {
44
44
  check: { flags: new Set(['--fix', '--errors-only', '--no-collapse', '--json', '--verbose']), values: new Set() },
45
45
  doctor: { flags: new Set(['--apply', '--yes', '--dry-run', '-n', '--statuses', '--migrate-template', '--migrate-prompts', '--frontmatter-fix', '--project', '--json', '--include-archived']), values: new Set() },
46
46
  runlist: { flags: new Set(['--json', '--full', '--no-index', '--show-files']), values: new Set(), subcommands: new Set(['next']) },
47
+ runlists: { flags: new Set(['--json', '--limit', '--sort']), values: new Set(['--limit', '--sort']) },
47
48
  prompts: {
48
49
  flags: new Set(['--json', '--status', '--include-archived', '--sort', '--limit', '--all', '--no-index', '--show-files', '--body', '--message', '--title']),
49
50
  values: new Set(['--status', '--sort', '--limit', '--body', '--message', '--title']),
@@ -235,6 +236,7 @@ Lifecycle:
235
236
  use <file> Open a plan (mark in-session + print it) or consume a prompt
236
237
  set <status> <file> Change a document's status (frontmatter write; archive also moves the file)
237
238
  runlist <hub> [next] Show or walk an ordered group of plans (see \`dotmd help runlist\`)
239
+ runlists List coordination-hub runlists (the Runlists dashboard)
238
240
  status <file> <status> Transition document status (deprecated; prefer \`set\`)
239
241
  archive <file> Archive (status + move + update refs)
240
242
  bulk archive <f1> <f2> ... Archive multiple files at once
@@ -263,7 +265,7 @@ Setup:
263
265
  Global Options:
264
266
  --config <path> Explicit config file path
265
267
  --root <name> Filter to a specific docs root
266
- --type <t1,t2> Filter by document type (plan, doc, research)
268
+ --type <t1,t2> Filter by document type (plan, doc, prompt)
267
269
  --dry-run, -n Preview changes without writing anything
268
270
  --verbose Show config details and doc count
269
271
  --help, -h Show help (per-command: dotmd <cmd> --help)
@@ -410,7 +412,7 @@ Examples:
410
412
  query: `dotmd query — filtered document search
411
413
 
412
414
  Filters:
413
- --type <t1,t2> Filter by type (plan, doc, research)
415
+ --type <t1,t2> Filter by type (plan, doc, prompt)
414
416
  --status <s1,s2> Filter by status (comma-separated)
415
417
  --keyword <term> Search title, summary, state, path
416
418
  --body Extend --keyword into document bodies (lazy scan, shows matching-line excerpts)
@@ -887,7 +889,7 @@ Options:
887
889
  --format <md|html|json> Output format (default: md)
888
890
  --output <path> Write to file/directory (default: stdout for md/json)
889
891
  --status <s1,s2> Filter by status
890
- --type <t1,t2> Filter by type (plan, doc, research)
892
+ --type <t1,t2> Filter by type (plan, doc, prompt)
891
893
  --module <name> Filter by module
892
894
  --root <name> Filter by root
893
895
  --dry-run, -n Preview without writing`,
@@ -1212,7 +1214,33 @@ Common shape:
1212
1214
  ---
1213
1215
 
1214
1216
  Child plans should set \`parent_plan:\` back at the hub — \`dotmd check\` warns
1215
- when they don't.`,
1217
+ when they don't.
1218
+
1219
+ In \`dotmd plans\`, a hub is tagged \`[RUNLIST]\` (not \`[ACTIVE]\`) and its
1220
+ children fold underneath it — progress (\`done/total\`) and the next pickup
1221
+ \`→\` show on the hub row, so a sprint reads as one runlist instead of N loose
1222
+ plans. Children whose hub is filtered out of the view (e.g. \`--status active\`
1223
+ when the hub is \`planned\`) still render on their own.
1224
+
1225
+ Larger, prose-first "coordination" runlists (a domain map pointing at many
1226
+ plans, marked \`execution_mode: coordination\` or named \`*-runlist\`) aren't
1227
+ folded — they're lifted into a separate \`Runlists\` section in \`dotmd plans\`
1228
+ and out of the active count. \`dotmd runlists\` shows that dashboard on its own.`,
1229
+
1230
+ runlists: `dotmd runlists — the coordination-hub dashboard
1231
+
1232
+ Lists every *coordination runlist*: a prose-first plan that sits above a
1233
+ cluster of others (a domain map), detected by \`execution_mode: coordination\`
1234
+ or a \`*-runlist\` / \`runlist\` slug. Each row shows the hub, its age, the rough
1235
+ size of its \`related_plans:\` cluster, and a one-line descriptor.
1236
+
1237
+ This is the standalone form of the \`Runlists\` section that \`dotmd plans\`
1238
+ pins beneath the leaf-plan triage list.
1239
+
1240
+ dotmd runlists All runlists (a small bounded set), most stale first.
1241
+ dotmd runlists --sort recent Order by recency instead (age|recent|related|title|status).
1242
+ dotmd runlists --limit N Cap the list at N.
1243
+ dotmd runlists --json Structured rows (path, status, childCount, …).`,
1216
1244
 
1217
1245
  'bulk-tag': `dotmd bulk-tag [files...] — fill in type/status frontmatter on pre-existing markdown
1218
1246
 
@@ -1372,6 +1400,16 @@ async function main() {
1372
1400
  runQuery(index, [...defaults, ...extras], config, { preset: 'plans' });
1373
1401
  return;
1374
1402
  }
1403
+ // `dotmd runlists` (plural) — the coordination-hub dashboard (the `Runlists`
1404
+ // section of `dotmd plans`, standalone). Distinct from `dotmd runlist <hub>`
1405
+ // (singular), which walks one hub's children.
1406
+ if (command === 'runlists') {
1407
+ const { buildIndex } = await import('../src/index.mjs');
1408
+ const { runRunlists } = await import('../src/query.mjs');
1409
+ const index = buildIndex(config);
1410
+ runRunlists(index, restArgs, config);
1411
+ return;
1412
+ }
1375
1413
  if (command === 'prompts') {
1376
1414
  const { runPrompts } = await import('../src/prompts.mjs');
1377
1415
  await runPrompts(restArgs, config, { dryRun, verbose });
@@ -1722,8 +1760,16 @@ async function main() {
1722
1760
  const docs = index.docs.filter(d => d.type === 'doc');
1723
1761
  const research = index.docs.filter(d => d.type === 'research');
1724
1762
  const stale = index.docs.filter(d => d.isStale && !config.lifecycle.skipStaleFor.has(d.status)).length;
1763
+ // Coordination hubs are runlists, not actionable plans — split them out of
1764
+ // inSession/active into their own `runlists` array so the JSON mirrors the
1765
+ // rendered briefing. Empty on repos with no coordination hubs.
1766
+ const { buildCoordinationIndex } = await import('../src/runlist.mjs');
1767
+ const coordination = buildCoordinationIndex(index, config);
1768
+ const isHub = (d) => coordination.has(d.path);
1769
+ const closedStatuses = new Set([...config.lifecycle.archiveStatuses, ...config.lifecycle.terminalStatuses]);
1770
+ const isLiveHub = (d) => isHub(d) && !closedStatuses.has(d.status) && !isArchivedPath(d.path, config);
1725
1771
  process.stdout.write(JSON.stringify({
1726
- plans: { total: plans.length, inSession: plans.filter(d => d.status === 'in-session').map(d => ({ path: d.path, title: d.title, nextStep: d.nextStep })), active: plans.filter(d => d.status === 'active').map(d => ({ path: d.path, title: d.title, nextStep: d.nextStep })) },
1772
+ plans: { total: plans.length, inSession: plans.filter(d => d.status === 'in-session' && !isHub(d)).map(d => ({ path: d.path, title: d.title, nextStep: d.nextStep })), active: plans.filter(d => d.status === 'active' && !isHub(d)).map(d => ({ path: d.path, title: d.title, nextStep: d.nextStep })), runlists: plans.filter(isLiveHub).map(d => ({ path: d.path, title: d.title, status: d.status, childCount: coordination.get(d.path)?.childCount ?? 0 })) },
1727
1773
  docs: { total: docs.length, active: docs.filter(d => !config.lifecycle.terminalStatuses.has(d.status)).length },
1728
1774
  research: { total: research.length, active: research.filter(d => d.status === 'active').length },
1729
1775
  stale, errorCount: index.errors.length, warningCount: index.warnings.length,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.61.0",
3
+ "version": "0.62.0",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, Notion sync, AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/commands.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  // templates points at a real command.
5
5
  export const KNOWN_COMMANDS = [
6
6
  'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'briefing', 'context', 'agent-context', 'hud',
7
- 'focus', 'query', 'grep', 'plans', 'prompts', 'stale', 'actionable', 'index', 'status', 'set', 'use', 'next', 'archive', 'bulk', 'bulk-tag', 'touch', 'doctor', 'runlist',
7
+ 'focus', 'query', 'grep', 'plans', 'prompts', 'stale', 'actionable', 'index', 'status', 'set', 'use', 'next', 'archive', 'bulk', 'bulk-tag', 'touch', 'doctor', 'runlist', 'runlists',
8
8
  'unblocks', 'health', 'glossary', 'modules', 'module',
9
9
  'fix-refs', 'lint', 'rename', 'migrate', 'notion', 'export', 'summary',
10
10
  'watch', 'diff', 'new', 'init', 'completions', 'statuses', 'journal',
@@ -2,7 +2,7 @@ import { die } from './util.mjs';
2
2
 
3
3
  const COMMANDS = [
4
4
  'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'unblocks', 'health', 'glossary', 'briefing', 'context', 'focus', 'query', 'grep',
5
- 'plans', 'stale', 'actionable', 'index', 'status', 'set', 'archive', 'bulk', 'touch', 'doctor', 'lint', 'rename', 'migrate',
5
+ 'plans', 'runlist', 'runlists', 'stale', 'actionable', 'index', 'status', 'set', 'archive', 'bulk', 'touch', 'doctor', 'lint', 'rename', 'migrate',
6
6
  'fix-refs', 'notion', 'export', 'summary', 'watch', 'diff', 'init', 'new', 'completions', 'journal',
7
7
  ];
8
8
 
package/src/health.mjs CHANGED
@@ -1,13 +1,32 @@
1
1
  import path from 'node:path';
2
2
  import { buildIndex } from './index.mjs';
3
3
  import { bold, dim, green, yellow, red } from './color.mjs';
4
+ import { buildCoordinationIndex, hubLabel } from './runlist.mjs';
5
+ import { isArchivedPath } from './util.mjs';
4
6
 
5
7
  export function runHealth(argv, config) {
6
8
  const json = argv.includes('--json');
7
9
  const index = buildIndex(config);
8
10
 
9
11
  // Only plans (type: plan or untyped docs in plans root)
10
- const plans = index.docs.filter(d => d.type === 'plan' || (!d.type && d.root?.includes('plan')));
12
+ const allPlans = index.docs.filter(d => d.type === 'plan' || (!d.type && d.root?.includes('plan')));
13
+ // Coordination hubs (prose-first runlists) are navigation maps, not execution
14
+ // units — they carry no checklist and skew active-plan aging — so lift the
15
+ // LIVE ones out of the pipeline + active set into a dedicated Runlists tally,
16
+ // mirroring `dotmd plans` / `dotmd runlists`. Archived hubs stay in `plans` so
17
+ // the archived/velocity counts are unchanged. No coordination hubs → `plans`
18
+ // equals the full set and every count below is identical to before.
19
+ const coordination = buildCoordinationIndex(index, config);
20
+ const closedStatuses = new Set([
21
+ ...(config.lifecycle?.archiveStatuses ?? []),
22
+ ...(config.lifecycle?.terminalStatuses ?? []),
23
+ ]);
24
+ const isLiveHub = (d) => coordination.has(d.path) && !closedStatuses.has(d.status) && !isArchivedPath(d.path, config);
25
+ const runlistHubs = allPlans.filter(isLiveHub)
26
+ // Most stale first — health is an aging lens, and it matches `dotmd runlists`'
27
+ // default. Unknown-age hubs sort last so they never top the list.
28
+ .sort((a, b) => (b.daysSinceUpdate ?? -1) - (a.daysSinceUpdate ?? -1));
29
+ const plans = allPlans.filter(d => !isLiveHub(d));
11
30
  const now = Date.now();
12
31
 
13
32
  // Status distribution
@@ -68,24 +87,51 @@ export function runHealth(argv, config) {
68
87
  ready: { count: readyPlans.length },
69
88
  planned: { count: plannedPlans.length },
70
89
  recentlyArchived: { count: recentlyArchived.length, last30d: recentlyArchived.map(d => path.basename(d.path, '.md')) },
90
+ runlists: { count: runlistHubs.length, hubs: runlistHubs.map(d => ({ path: d.path, title: d.title, status: d.status, childCount: coordination.get(d.path)?.childCount ?? 0 })) },
71
91
  }, null, 2) + '\n');
72
92
  return;
73
93
  }
74
94
 
75
95
  process.stdout.write(bold('Plan Health') + '\n\n');
76
96
 
77
- // Pipeline
97
+ // Pipeline — ordered by the configured status vocab, then any present-but-
98
+ // unconfigured statuses (custom ones a repo defines, by count). Deriving from
99
+ // the live status set means in-session/partial/awaiting/etc. all show, and a
100
+ // dead status never leaves an empty row — unlike the old hand-kept list that
101
+ // drifted out of sync with the vocabulary.
78
102
  process.stdout.write(bold('Pipeline:') + '\n');
79
- const pipeline = ['active', 'paused', 'ready', 'planned', 'blocked', 'scoping', 'archived'];
80
- for (const s of pipeline) {
81
- const count = byStatus[s] || 0;
82
- if (count > 0) {
83
- const bar = '█'.repeat(Math.min(count, 40));
84
- process.stdout.write(` ${s.padEnd(10)} ${String(count).padStart(4)} ${dim(bar)}\n`);
85
- }
103
+ const statusOrder = config.statusOrder ?? [];
104
+ const present = Object.keys(byStatus).filter(s => byStatus[s] > 0);
105
+ const ordered = [
106
+ ...statusOrder.filter(s => present.includes(s)),
107
+ ...present.filter(s => !statusOrder.includes(s)).sort((a, b) => byStatus[b] - byStatus[a]),
108
+ ];
109
+ const pad = Math.max(10, ...ordered.map(s => s.length));
110
+ for (const s of ordered) {
111
+ const count = byStatus[s];
112
+ const bar = '█'.repeat(Math.min(count, 40));
113
+ process.stdout.write(` ${s.padEnd(pad)} ${String(count).padStart(4)} ${dim(bar)}\n`);
86
114
  }
87
115
  process.stdout.write('\n');
88
116
 
117
+ // Runlists (coordination hubs) — held out of the leaf-plan pipeline above and
118
+ // surfaced as their own tally so they don't inflate the active count. Newest
119
+ // first, mirroring `dotmd runlists`; capped with a "more" footer.
120
+ if (runlistHubs.length > 0) {
121
+ process.stdout.write(`${bold('Runlists:')} ${runlistHubs.length} ${dim('· dotmd runlists')}\n`);
122
+ for (const doc of runlistHubs.slice(0, 8)) {
123
+ const slug = hubLabel(doc).padEnd(28);
124
+ const age = doc.daysSinceUpdate != null ? `${doc.daysSinceUpdate}d` : '?d';
125
+ const rel = coordination.get(doc.path)?.childCount;
126
+ const relStr = rel ? ` ${dim(`${rel} related`)}` : '';
127
+ process.stdout.write(` ${slug} ${dim(age.padStart(4))}${relStr}\n`);
128
+ }
129
+ if (runlistHubs.length > 8) {
130
+ process.stdout.write(` ${dim(`...and ${runlistHubs.length - 8} more`)}\n`);
131
+ }
132
+ process.stdout.write('\n');
133
+ }
134
+
89
135
  // Active plan health
90
136
  if (activePlans.length > 0) {
91
137
  process.stdout.write(bold('Active plans:') + '\n');
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/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
  }
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);