dotmd-cli 0.61.0 → 0.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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,38 @@ 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
+ For these, \`runlist\`/\`runlist next\` also read order from the body when there's
1230
+ no \`runlist:\` array — a \`## Ranked queue\` table or \`## Order of operations\`
1231
+ list of markdown links (the first \`.md\` link per row/item, in order).`,
1232
+
1233
+ runlists: `dotmd runlists — the coordination-hub dashboard
1234
+
1235
+ Lists every *coordination runlist*: a prose-first plan that sits above a
1236
+ cluster of others (a domain map), detected by \`execution_mode: coordination\`
1237
+ or a \`*-runlist\` / \`runlist\` slug. Each row shows the hub, its age, the rough
1238
+ size of its \`related_plans:\` cluster, a \`next → <child>\` when the hub's body
1239
+ encodes order as markdown links (\`## Ranked queue\` table / \`## Order of
1240
+ operations\` list), and a one-line descriptor.
1241
+
1242
+ This is the standalone form of the \`Runlists\` section that \`dotmd plans\`
1243
+ pins beneath the leaf-plan triage list.
1244
+
1245
+ dotmd runlists All runlists (a small bounded set), most stale first.
1246
+ dotmd runlists --sort recent Order by recency instead (age|recent|related|title|status).
1247
+ dotmd runlists --limit N Cap the list at N.
1248
+ dotmd runlists --json Structured rows (path, status, childCount, nextPickup, …).`,
1216
1249
 
1217
1250
  'bulk-tag': `dotmd bulk-tag [files...] — fill in type/status frontmatter on pre-existing markdown
1218
1251
 
@@ -1372,6 +1405,16 @@ async function main() {
1372
1405
  runQuery(index, [...defaults, ...extras], config, { preset: 'plans' });
1373
1406
  return;
1374
1407
  }
1408
+ // `dotmd runlists` (plural) — the coordination-hub dashboard (the `Runlists`
1409
+ // section of `dotmd plans`, standalone). Distinct from `dotmd runlist <hub>`
1410
+ // (singular), which walks one hub's children.
1411
+ if (command === 'runlists') {
1412
+ const { buildIndex } = await import('../src/index.mjs');
1413
+ const { runRunlists } = await import('../src/query.mjs');
1414
+ const index = buildIndex(config);
1415
+ runRunlists(index, restArgs, config);
1416
+ return;
1417
+ }
1375
1418
  if (command === 'prompts') {
1376
1419
  const { runPrompts } = await import('../src/prompts.mjs');
1377
1420
  await runPrompts(restArgs, config, { dryRun, verbose });
@@ -1722,8 +1765,16 @@ async function main() {
1722
1765
  const docs = index.docs.filter(d => d.type === 'doc');
1723
1766
  const research = index.docs.filter(d => d.type === 'research');
1724
1767
  const stale = index.docs.filter(d => d.isStale && !config.lifecycle.skipStaleFor.has(d.status)).length;
1768
+ // Coordination hubs are runlists, not actionable plans — split them out of
1769
+ // inSession/active into their own `runlists` array so the JSON mirrors the
1770
+ // rendered briefing. Empty on repos with no coordination hubs.
1771
+ const { buildCoordinationIndex } = await import('../src/runlist.mjs');
1772
+ const coordination = buildCoordinationIndex(index, config);
1773
+ const isHub = (d) => coordination.has(d.path);
1774
+ const closedStatuses = new Set([...config.lifecycle.archiveStatuses, ...config.lifecycle.terminalStatuses]);
1775
+ const isLiveHub = (d) => isHub(d) && !closedStatuses.has(d.status) && !isArchivedPath(d.path, config);
1725
1776
  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 })) },
1777
+ 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
1778
  docs: { total: docs.length, active: docs.filter(d => !config.lifecycle.terminalStatuses.has(d.status)).length },
1728
1779
  research: { total: research.length, active: research.filter(d => d.status === 'active').length },
1729
1780
  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.63.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,52 @@ 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, nextPickup: coordination.get(d.path)?.nextPickup ?? null })) },
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 info = coordination.get(doc.path);
126
+ const relStr = info?.childCount ? ` ${dim(`${info.childCount} related`)}` : '';
127
+ const nextStr = info?.nextPickup ? ` ${green('→')} ${info.nextPickup.label}` : '';
128
+ process.stdout.write(` ${slug} ${dim(age.padStart(4))}${relStr}${nextStr}\n`);
129
+ }
130
+ if (runlistHubs.length > 8) {
131
+ process.stdout.write(` ${dim(`...and ${runlistHubs.length - 8} more`)}\n`);
132
+ }
133
+ process.stdout.write('\n');
134
+ }
135
+
89
136
  // Active plan health
90
137
  if (activePlans.length > 0) {
91
138
  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/lifecycle.mjs CHANGED
@@ -236,6 +236,10 @@ export async function runStatus(argv, config, opts = {}) {
236
236
  process.stdout.write(`${prefix} Would unfile: ${toRepoPath(filePath, config.repoRoot)} → ${toRepoPath(targetPath, config.repoRoot)}\n`);
237
237
  finalPath = targetPath;
238
238
  }
239
+ if (finalPath !== filePath) {
240
+ const refCount = countRefsToUpdate(filePath, finalPath, config);
241
+ if (refCount > 0) process.stdout.write(`${prefix} Would update references in ${refCount} file(s)\n`);
242
+ }
239
243
  if ((isArchiving || isUnarchiving || isFiling || isUnfiling) && config.indexPath) {
240
244
  process.stdout.write(`${prefix} Would regenerate index\n`);
241
245
  }
@@ -286,6 +290,22 @@ export async function runStatus(argv, config, opts = {}) {
286
290
  finalPath = targetPath;
287
291
  }
288
292
 
293
+ // Any of the four moves above shifts the file's directory, which breaks
294
+ // relative refs in both directions — links FROM the moved file and inbound
295
+ // refs TO it from other docs. runArchive repairs both; mirror that here so
296
+ // the deprecated `dotmd status <file> archived` path and the `dotmd set`
297
+ // unarchive/file/unfile transitions (which route through runStatus, not
298
+ // runArchive) don't silently leave dangling links.
299
+ let selfRefsFixed = false;
300
+ let inboundRefCount = 0;
301
+ let inboundRefPaths = [];
302
+ if (finalPath !== filePath) {
303
+ selfRefsFixed = updateRefsFromMovedFile(filePath, finalPath, config) > 0;
304
+ const inbound = updateRefsAfterMove(filePath, finalPath, config);
305
+ inboundRefCount = inbound.count;
306
+ inboundRefPaths = inbound.paths;
307
+ }
308
+
289
309
  // Regen the index on every status change — `active → planned` etc. drift
290
310
  // the per-status sections just as much as archive crossings. Archive paths
291
311
  // also benefit (replaces the previously-gated regen). `--no-index` skips
@@ -298,10 +318,13 @@ export async function runStatus(argv, config, opts = {}) {
298
318
  }
299
319
 
300
320
  process.stdout.write(`${green(toRepoPath(finalPath, config.repoRoot))}: ${oldStatus ?? 'unknown'} → ${newStatus}\n`);
321
+ if (selfRefsFixed) process.stdout.write('Updated references in moved file.\n');
322
+ if (inboundRefCount > 0) process.stdout.write(`Updated references in ${inboundRefCount} file(s).\n`);
301
323
 
302
324
  if (showFiles) {
303
325
  const touched = [filePath];
304
326
  if (finalPath !== filePath) touched.push(finalPath);
327
+ touched.push(...inboundRefPaths);
305
328
  if (config.indexPath && !noIndex) touched.push(config.indexPath);
306
329
  emitFilesFooter(touched, config);
307
330
  }
@@ -749,6 +772,26 @@ export function runTouch(argv, config, opts = {}) {
749
772
  try { config.hooks.onTouch?.({ path: toRepoPath(filePath, config.repoRoot) }, { path: toRepoPath(filePath, config.repoRoot), date: today }); } catch (err) { warn(`Hook 'onTouch' threw: ${err.message}`); }
750
773
  }
751
774
 
775
+ // Rewrite every frontmatter ref token (a `*.md` path in a YAML list item or an
776
+ // inline scalar, quoted or `>`-prefixed) that points at `oldPath` so it points
777
+ // at `newPath`. Each token is resolved doc-relative *and* repo-relative and
778
+ // compared to oldPath by absolute path — mirroring how the body-link branch and
779
+ // `updateRefsFromMovedFile` resolve refs. This replaces an older substring
780
+ // rewrite (`fm.split(oldRelPath).join(newRelPath)`) that only knew doc-relative
781
+ // paths, so it: left repo-relative cross-dir refs (`docs/plans/child.md` from
782
+ // `docs/rfcs/spec.md`) broken; mangled same-dir repo-relative refs into
783
+ // `docs/plans/../archived/child.md`; and could corrupt a `grandchild.md` ref
784
+ // when archiving `child.md` (suffix match). oldPath no longer exists on disk
785
+ // post-`git mv`, so existsSync-based resolveRefPath can't be used here.
786
+ function rewriteFrontmatterRefs(fm, docDir, oldPath, newPath, repoRoot) {
787
+ return fm.replace(/[^\s"'<>:]+\.md\b/g, (token) => {
788
+ const docRelAbs = path.resolve(docDir, token);
789
+ const repoRelAbs = path.resolve(repoRoot, token);
790
+ if (docRelAbs !== oldPath && repoRelAbs !== oldPath) return token;
791
+ return path.relative(docDir, newPath).split(path.sep).join('/');
792
+ });
793
+ }
794
+
752
795
  /**
753
796
  * After a file moves (archive/unarchive), update frontmatter references in all
754
797
  * docs that pointed to the old location so they point to the new one.
@@ -766,17 +809,7 @@ function updateRefsAfterMove(oldPath, newPath, config) {
766
809
  if (!fm) continue;
767
810
 
768
811
  const docDir = path.dirname(docFile);
769
- const oldRelPath = path.relative(docDir, oldPath).split(path.sep).join('/');
770
- const newRelPath = path.relative(docDir, newPath).split(path.sep).join('/');
771
-
772
- let newFm = fm;
773
- if (newFm.includes(oldRelPath)) {
774
- newFm = newFm.split(oldRelPath).join(newRelPath);
775
- }
776
- const dotSlashOld = './' + oldRelPath;
777
- if (newFm.includes(dotSlashOld)) {
778
- newFm = newFm.split(dotSlashOld).join(newRelPath);
779
- }
812
+ const newFm = rewriteFrontmatterRefs(fm, docDir, oldPath, newPath, config.repoRoot);
780
813
 
781
814
  // Body markdown links [text](path.md) or [text](path.md#anchor) pointing
782
815
  // at oldPath. resolveRefPath can't be used here: oldPath no longer exists
@@ -856,8 +889,7 @@ function countRefsToUpdate(oldPath, newPath, config) {
856
889
  if (!fm) continue;
857
890
 
858
891
  const docDir = path.dirname(docFile);
859
- const oldRelPath = path.relative(docDir, oldPath).split(path.sep).join('/');
860
- const fmHit = fm.includes(oldRelPath) || fm.includes('./' + oldRelPath);
892
+ const fmHit = rewriteFrontmatterRefs(fm, docDir, oldPath, newPath, config.repoRoot) !== fm;
861
893
 
862
894
  let bodyHit = false;
863
895
  if (!fmHit) {