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 +102 -13
- package/bin/dotmd.mjs +51 -5
- package/package.json +1 -1
- package/src/commands.mjs +1 -1
- package/src/completions.mjs +1 -1
- package/src/health.mjs +55 -9
- package/src/index.mjs +8 -1
- package/src/query.mjs +305 -46
- package/src/render.mjs +22 -3
- package/src/runlist.mjs +118 -0
- package/src/validate.mjs +27 -0
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 #
|
|
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 `
|
|
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
|
|
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>
|
|
204
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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 --
|
|
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,
|
|
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,
|
|
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,
|
|
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
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',
|
package/src/completions.mjs
CHANGED
|
@@ -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
|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
682
|
+
process.stdout.write(formatPlanRow(doc, maxWidth, { slug: toSlug(doc), maxSlug, showTag }) + '\n');
|
|
683
|
+
}
|
|
684
|
+
}
|
|
494
685
|
|
|
495
|
-
|
|
496
|
-
let pctCell = ' ';
|
|
497
|
-
if (doc.checklist?.total) {
|
|
498
|
-
const pct = Math.round((doc.checklist.completed / doc.checklist.total) * 100);
|
|
499
|
-
pctCell = `${pct.toString().padStart(3)}% `;
|
|
500
|
-
}
|
|
686
|
+
const RUNLIST_TAG = bold(cyan('[RUNLIST]'));
|
|
501
687
|
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|