dotmd-cli 0.14.12 → 0.15.1
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 +57 -3
- package/bin/dotmd.mjs +48 -9
- package/dotmd.config.example.mjs +33 -14
- package/package.json +1 -1
- package/src/config.mjs +21 -13
- package/src/doctor.mjs +148 -0
- package/src/health.mjs +1 -1
- package/src/init.mjs +1 -1
- package/src/migrate.mjs +32 -3
- package/src/render.mjs +3 -3
- package/src/stats.mjs +2 -2
package/README.md
CHANGED
|
@@ -88,7 +88,7 @@ Every document can have a `type` field in its frontmatter. Types determine which
|
|
|
88
88
|
|
|
89
89
|
| Type | Purpose | Valid Statuses |
|
|
90
90
|
|------|---------|----------------|
|
|
91
|
-
| `plan` | Execution plans | `in-session`, `active`, `planned`, `blocked`, `
|
|
91
|
+
| `plan` | Execution plans | `in-session`, `active`, `planned`, `blocked`, `partial`, `paused`, `awaiting`, `queued-after`, `archived` |
|
|
92
92
|
| `doc` | Design docs, specs, ADRs, RFCs | `draft`, `active`, `review`, `reference`, `deprecated`, `archived` |
|
|
93
93
|
| `research` | Investigations, audits, analysis | `active`, `reference`, `archived` |
|
|
94
94
|
|
|
@@ -106,6 +106,26 @@ dotmd export --type research # export research only
|
|
|
106
106
|
|
|
107
107
|
Customize types and their statuses in config with the `types` key. See [`dotmd.config.example.mjs`](dotmd.config.example.mjs).
|
|
108
108
|
|
|
109
|
+
### What each plan status means
|
|
110
|
+
|
|
111
|
+
The default plan vocabulary is shaped around the **unstuck-action test**: every stop-status should map to a distinct next move. If two statuses have the same unstuck-action, one is dead weight; if a single status covers several different actions, it's overloaded.
|
|
112
|
+
|
|
113
|
+
| Status | Unstuck-action | When to use |
|
|
114
|
+
|--------|----------------|-------------|
|
|
115
|
+
| `in-session` | — | A Claude session is working on it right now. Don't pick up. |
|
|
116
|
+
| `active` | Pick up | Ready to be worked on. |
|
|
117
|
+
| `planned` | Wait for trigger | Queued; not yet ready to execute. |
|
|
118
|
+
| `blocked` | **Monitor** | External arrival on its own schedule (hardware, vendor, third-party rollout). You can't speed it up. |
|
|
119
|
+
| `partial` | **Spawn successors** | Shipped most of the plan; tail deferred. Body should reference successor plans tracking the tail. Visible but quiet (no nagging). |
|
|
120
|
+
| `paused` | **Re-evaluate** | Intentionally set aside, no external dependency. Resume by deciding the work is still worth doing. Quiet. |
|
|
121
|
+
| `awaiting` | **Ask** | Needs a human decision or input. NOT quiet — pings get forgotten, so this status generates stale pressure to chase the answer. |
|
|
122
|
+
| `queued-after` | **Check predecessor** | Sequenced behind another plan; can start once that one ships. Quiet. |
|
|
123
|
+
| `archived` | — | No longer relevant; auto-moved to the archive directory on transition. |
|
|
124
|
+
|
|
125
|
+
Each *quiet* status (`partial`, `paused`, `queued-after`, `archived`) is exempt from stale-warning pressure but still appears in active scope and metrics — quietness is a presentation flag, not a closure flag. `awaiting` deliberately stays loud so unanswered questions don't decay into invisible backlog.
|
|
126
|
+
|
|
127
|
+
> **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.
|
|
128
|
+
|
|
109
129
|
## Commands
|
|
110
130
|
|
|
111
131
|
```
|
|
@@ -224,8 +244,30 @@ Shows: status counts, staleness, errors/warnings, freshness (today/week/month),
|
|
|
224
244
|
```bash
|
|
225
245
|
dotmd doctor # fix refs → lint → sync git dates → regen index
|
|
226
246
|
dotmd doctor --dry-run # preview all changes
|
|
247
|
+
dotmd doctor --statuses # detect overloaded status buckets (read-only)
|
|
248
|
+
dotmd doctor --statuses --json # machine-readable suggestions
|
|
227
249
|
```
|
|
228
250
|
|
|
251
|
+
`--statuses` is a read-only diagnostic. It scans each status with at least
|
|
252
|
+
10 plans and groups their `current_state` / `next_step` text against cue
|
|
253
|
+
keywords for `partial`, `paused`, `awaiting`, `queued-after`, and `blocked`.
|
|
254
|
+
When a single bucket lands plans in two or more cue groups (each above 15%
|
|
255
|
+
of the bucket), it prints a split suggestion:
|
|
256
|
+
|
|
257
|
+
```
|
|
258
|
+
47 plan/backlog plans cluster across 4 patterns — consider splitting:
|
|
259
|
+
~22 → partial (cues: "shipped", "landed", "tail", "deferred")
|
|
260
|
+
~15 → paused (cues: "paused", "on hold", "set aside")
|
|
261
|
+
~ 6 → queued-after (cues: "after", "once", "depends on", "waiting on <plan>")
|
|
262
|
+
~ 4 → (kept in backlog — no clear pattern match)
|
|
263
|
+
|
|
264
|
+
Heuristic — verify before migrating.
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
The heuristic is intentionally conservative: small buckets are skipped, plans
|
|
268
|
+
that match no cues stay in the original bucket, and the output is always a
|
|
269
|
+
suggestion — never a verdict.
|
|
270
|
+
|
|
229
271
|
### Graph
|
|
230
272
|
|
|
231
273
|
```bash
|
|
@@ -390,10 +432,22 @@ dotmd rename old-name.md new-name # renames + updates refs
|
|
|
390
432
|
### Migrate
|
|
391
433
|
|
|
392
434
|
```bash
|
|
393
|
-
dotmd migrate status research
|
|
394
|
-
dotmd migrate module auth identity
|
|
435
|
+
dotmd migrate status research scoping # rename a status (e.g. for the 0.15 default-vocab change)
|
|
436
|
+
dotmd migrate module auth identity # rename a module
|
|
437
|
+
|
|
438
|
+
# Per-file form: split one overloaded status into several distinct ones.
|
|
439
|
+
# Only the listed files are rewritten; every other doc with the old value is left alone.
|
|
440
|
+
dotmd migrate status backlog paused docs/plans/foo.md docs/plans/bar.md
|
|
441
|
+
dotmd migrate status backlog partial docs/plans/payments-future.md # one at a time also works
|
|
395
442
|
```
|
|
396
443
|
|
|
444
|
+
With no file args, `migrate` rewrites every doc whose field matches
|
|
445
|
+
`<old-value>` (whole-bucket rename). Pass file args to scope the
|
|
446
|
+
rewrite — useful when one status has been doing several jobs and you
|
|
447
|
+
want to split it across the new vocabulary. File args match the same
|
|
448
|
+
way as `bulk archive`: exact path first, then substring fallback
|
|
449
|
+
against full path or basename.
|
|
450
|
+
|
|
397
451
|
### Preset Aliases
|
|
398
452
|
|
|
399
453
|
Built-in presets: `plans`, `stale`, `actionable`. Add your own in config:
|
package/bin/dotmd.mjs
CHANGED
|
@@ -49,7 +49,7 @@ Lifecycle:
|
|
|
49
49
|
touch <file> Bump updated date
|
|
50
50
|
touch --git Bulk-sync dates from git history
|
|
51
51
|
rename <old> <new> Rename doc and update all references
|
|
52
|
-
migrate <field> <old> <new>
|
|
52
|
+
migrate <field> <old> <new> [f...]Batch update a frontmatter field value (optional file filter)
|
|
53
53
|
|
|
54
54
|
Create & Export:
|
|
55
55
|
new <name> [--template <t>] Create doc from template (plan, adr, rfc, audit, design)
|
|
@@ -140,6 +140,17 @@ Moves the document to the new status. If transitioning to an archive
|
|
|
140
140
|
status, automatically moves the file to the archive directory and
|
|
141
141
|
regenerates the index (if configured).
|
|
142
142
|
|
|
143
|
+
Default plan statuses (each maps to a distinct unstuck-action):
|
|
144
|
+
in-session A Claude session is working on it now
|
|
145
|
+
active Ready to be picked up
|
|
146
|
+
planned Queued for future work
|
|
147
|
+
blocked External arrival wait — monitor (hardware, vendor, rollout)
|
|
148
|
+
partial Shipped + deferred tail — spawn successor plans
|
|
149
|
+
paused Intentionally set aside — re-evaluate to resume
|
|
150
|
+
awaiting Needs human input/decision — chase the answer
|
|
151
|
+
queued-after Sequenced behind another plan — check predecessor
|
|
152
|
+
archived No longer relevant; auto-moved to archive directory
|
|
153
|
+
|
|
143
154
|
Use --dry-run (-n) to preview changes without writing anything.`,
|
|
144
155
|
|
|
145
156
|
check: `dotmd check — validate frontmatter and references
|
|
@@ -225,6 +236,16 @@ Options:
|
|
|
225
236
|
Runs in sequence: fix broken references, lint --fix, sync dates from
|
|
226
237
|
git, regenerate index, then show remaining issues.
|
|
227
238
|
|
|
239
|
+
Modes:
|
|
240
|
+
(default) Auto-fix pass (writes by default; honors --dry-run)
|
|
241
|
+
--statuses Read-only diagnostic: detect overloaded status
|
|
242
|
+
buckets where one status holds plans pursuing
|
|
243
|
+
multiple distinct unstuck-actions. Suggests how
|
|
244
|
+
a bucket might split (e.g. backlog → partial /
|
|
245
|
+
paused / queued-after). Heuristic only — verify
|
|
246
|
+
before migrating.
|
|
247
|
+
--statuses --json Machine-readable suggestion shape for tooling.
|
|
248
|
+
|
|
228
249
|
Use --dry-run (-n) to preview all changes without writing anything.`,
|
|
229
250
|
|
|
230
251
|
'fix-refs': `dotmd fix-refs — auto-fix broken reference paths
|
|
@@ -262,6 +283,10 @@ Options:
|
|
|
262
283
|
--root <name> Create in a specific docs root
|
|
263
284
|
--list-templates Show available templates
|
|
264
285
|
|
|
286
|
+
For plans, the default status vocabulary is: in-session, active, planned,
|
|
287
|
+
blocked, partial, paused, awaiting, queued-after, archived. Run
|
|
288
|
+
\`dotmd status --help\` for what each one means.
|
|
289
|
+
|
|
265
290
|
The filename is derived from <name> by slugifying it.
|
|
266
291
|
Use --dry-run (-n) to preview without creating the file.`,
|
|
267
292
|
|
|
@@ -344,14 +369,22 @@ in other docs that point to the old filename.
|
|
|
344
369
|
Body markdown links are warned about but not auto-fixed.
|
|
345
370
|
Use --dry-run (-n) to preview changes without writing anything.`,
|
|
346
371
|
|
|
347
|
-
migrate: `dotmd migrate <field> <old-value> <new-value> — batch update a frontmatter field
|
|
372
|
+
migrate: `dotmd migrate <field> <old-value> <new-value> [files...] — batch update a frontmatter field
|
|
348
373
|
|
|
349
374
|
Finds all docs where the given field equals old-value and updates it
|
|
350
|
-
to new-value.
|
|
375
|
+
to new-value. With no file args, every matching doc in the project is
|
|
376
|
+
rewritten (whole-bucket rename).
|
|
377
|
+
|
|
378
|
+
Pass one or more file args to scope the rewrite — only those files
|
|
379
|
+
are considered. This is how you split one overloaded status into
|
|
380
|
+
several distinct ones (e.g. moving some \`backlog\` plans to
|
|
381
|
+
\`paused\` and others to \`partial\`). File args use the same matching
|
|
382
|
+
as \`bulk archive\`: exact path, then substring fallback.
|
|
351
383
|
|
|
352
384
|
Examples:
|
|
353
|
-
dotmd migrate status research
|
|
385
|
+
dotmd migrate status research scoping
|
|
354
386
|
dotmd migrate module auth identity
|
|
387
|
+
dotmd migrate status backlog paused docs/plans/foo.md docs/plans/bar.md
|
|
355
388
|
|
|
356
389
|
Use --dry-run (-n) to preview changes without writing anything.`,
|
|
357
390
|
|
|
@@ -368,12 +401,18 @@ modules, and reference fields to pre-populate the config.`,
|
|
|
368
401
|
Shows all documents with type: plan, sorted by status.
|
|
369
402
|
Supports all query flags (--status, --module, --json, --sort, --group, etc.)
|
|
370
403
|
|
|
404
|
+
Default plan statuses: in-session, active, planned, blocked, partial,
|
|
405
|
+
paused, awaiting, queued-after, archived. Run \`dotmd status --help\` for
|
|
406
|
+
the unstuck-action behind each one.
|
|
407
|
+
|
|
371
408
|
Examples:
|
|
372
|
-
dotmd plans
|
|
373
|
-
dotmd plans --status active
|
|
374
|
-
dotmd plans --
|
|
375
|
-
dotmd plans --
|
|
376
|
-
dotmd plans --
|
|
409
|
+
dotmd plans # all plans
|
|
410
|
+
dotmd plans --status active # active plans only
|
|
411
|
+
dotmd plans --status awaiting # plans waiting on a human decision
|
|
412
|
+
dotmd plans --status partial,paused # shipped-tail and parked plans
|
|
413
|
+
dotmd plans --module auth # plans for the auth module
|
|
414
|
+
dotmd plans --group module # all plans grouped by module
|
|
415
|
+
dotmd plans --json # JSON output`,
|
|
377
416
|
|
|
378
417
|
stale: `dotmd stale — list stale documents
|
|
379
418
|
|
package/dotmd.config.example.mjs
CHANGED
|
@@ -25,20 +25,35 @@ export const excludeDirs = ['evidence'];
|
|
|
25
25
|
// context: 'expanded' | 'listed' | 'counted' (default: 'counted')
|
|
26
26
|
// staleDays: number | null — stale threshold (default: null = never stale)
|
|
27
27
|
// requiresModule: boolean — require `module` frontmatter (default: false)
|
|
28
|
-
// terminal: boolean — skip current_state/next_step warnings (default: false)
|
|
29
28
|
// archive: boolean — auto-move to archiveDir on transition (default: false)
|
|
29
|
+
// terminal: boolean — closed state; excluded from active-work stats/coverage scope (default: false)
|
|
30
30
|
// skipStale: boolean — exempt from stale checks (default: false)
|
|
31
31
|
// skipWarnings: boolean — exempt from validation warnings (default: false)
|
|
32
|
+
// quiet: boolean — sugar for `skipStale: true, skipWarnings: true`. Use for visible-but-quiet
|
|
33
|
+
// statuses (e.g. `partial`) where you want no nagging but DO want them in scope.
|
|
34
|
+
// Setting `skipStale: false` or `skipWarnings: false` explicitly overrides the sugar.
|
|
35
|
+
//
|
|
36
|
+
// `terminal` and `quiet` are orthogonal. Mark a status `terminal` only when it represents closure
|
|
37
|
+
// (excluded from active-work scope). Use `quiet` for noise suppression without closure semantics.
|
|
38
|
+
//
|
|
39
|
+
// Each plan stop-status maps to a distinct unstuck-action — the test for whether
|
|
40
|
+
// the vocabulary earns its weight. blocked = monitor (external arrival on its own
|
|
41
|
+
// schedule), awaiting = ask (chase the human/decision), queued-after = check the
|
|
42
|
+
// predecessor, paused = re-evaluate, partial = spawn successor plans for the
|
|
43
|
+
// deferred tail.
|
|
32
44
|
//
|
|
33
45
|
// export const types = {
|
|
34
46
|
// plan: {
|
|
35
47
|
// statuses: {
|
|
36
|
-
// 'in-session':
|
|
37
|
-
// 'active':
|
|
38
|
-
// 'planned':
|
|
39
|
-
// 'blocked':
|
|
40
|
-
// '
|
|
41
|
-
// '
|
|
48
|
+
// 'in-session': { context: 'expanded', staleDays: 1, requiresModule: true },
|
|
49
|
+
// 'active': { context: 'expanded', staleDays: 14, requiresModule: true },
|
|
50
|
+
// 'planned': { context: 'listed', staleDays: 30, requiresModule: true },
|
|
51
|
+
// 'blocked': { context: 'listed', staleDays: 30, requiresModule: true },
|
|
52
|
+
// 'partial': { context: 'expanded', requiresModule: true, quiet: true }, // shipped + deferred tail; visible, no nagging
|
|
53
|
+
// 'paused': { context: 'listed', requiresModule: true, quiet: true }, // intentionally set aside, no external dep
|
|
54
|
+
// 'awaiting': { context: 'listed', staleDays: 14, requiresModule: true }, // human input/decision wait — NOT quiet (pings get forgotten)
|
|
55
|
+
// 'queued-after': { context: 'counted', requiresModule: true, quiet: true }, // sequenced behind another plan
|
|
56
|
+
// 'archived': { context: 'counted', archive: true, terminal: true, quiet: true },
|
|
42
57
|
// },
|
|
43
58
|
// },
|
|
44
59
|
// doc: {
|
|
@@ -48,7 +63,7 @@ export const excludeDirs = ['evidence'];
|
|
|
48
63
|
// 'review': { context: 'listed', staleDays: 14 },
|
|
49
64
|
// 'reference': { context: 'counted', skipStale: true },
|
|
50
65
|
// 'deprecated': { context: 'counted', terminal: true, skipStale: true },
|
|
51
|
-
// 'archived': { context: 'counted', archive: true, terminal: true,
|
|
66
|
+
// 'archived': { context: 'counted', archive: true, terminal: true, quiet: true },
|
|
52
67
|
// },
|
|
53
68
|
// },
|
|
54
69
|
// };
|
|
@@ -57,16 +72,20 @@ export const excludeDirs = ['evidence'];
|
|
|
57
72
|
// When using array form, define behavior in separate statuses/lifecycle/taxonomy sections.
|
|
58
73
|
// export const types = {
|
|
59
74
|
// plan: {
|
|
60
|
-
// statuses: ['in-session', 'active', 'planned', 'blocked', '
|
|
61
|
-
// context: {
|
|
62
|
-
//
|
|
75
|
+
// statuses: ['in-session', 'active', 'planned', 'blocked', 'partial', 'paused', 'awaiting', 'queued-after', 'archived'],
|
|
76
|
+
// context: {
|
|
77
|
+
// expanded: ['in-session', 'active', 'partial'],
|
|
78
|
+
// listed: ['planned', 'blocked', 'paused', 'awaiting'],
|
|
79
|
+
// counted: ['queued-after', 'archived'],
|
|
80
|
+
// },
|
|
81
|
+
// staleDays: { 'in-session': 1, active: 14, planned: 30, blocked: 30, awaiting: 14 },
|
|
63
82
|
// },
|
|
64
83
|
// };
|
|
65
84
|
|
|
66
85
|
// Status workflow — fallback for docs without a type field. Order determines display grouping.
|
|
67
86
|
// When using rich status definitions, statuses.order and staleDays are derived automatically.
|
|
68
87
|
export const statuses = {
|
|
69
|
-
order: ['active', 'ready', 'planned', '
|
|
88
|
+
order: ['active', 'ready', 'planned', 'scoping', 'blocked', 'reference', 'archived'],
|
|
70
89
|
// Additional statuses valid only in specific roots (merged with order)
|
|
71
90
|
// Useful when different doc areas track different things (e.g. plans vs module docs)
|
|
72
91
|
// rootStatuses: {
|
|
@@ -79,7 +98,7 @@ export const statuses = {
|
|
|
79
98
|
ready: 14,
|
|
80
99
|
planned: 30,
|
|
81
100
|
blocked: 30,
|
|
82
|
-
|
|
101
|
+
scoping: 30,
|
|
83
102
|
},
|
|
84
103
|
};
|
|
85
104
|
|
|
@@ -111,7 +130,7 @@ export const index = {
|
|
|
111
130
|
export const context = {
|
|
112
131
|
expanded: ['active'],
|
|
113
132
|
listed: ['ready', 'planned'],
|
|
114
|
-
counted: ['blocked', '
|
|
133
|
+
counted: ['blocked', 'scoping', 'reference', 'archived'],
|
|
115
134
|
recentDays: 3,
|
|
116
135
|
recentStatuses: ['active', 'ready', 'planned'],
|
|
117
136
|
recentLimit: 10,
|
package/package.json
CHANGED
package/src/config.mjs
CHANGED
|
@@ -22,9 +22,13 @@ const DEFAULTS = {
|
|
|
22
22
|
|
|
23
23
|
types: {
|
|
24
24
|
plan: {
|
|
25
|
-
statuses: ['in-session', 'active', 'planned', 'blocked', '
|
|
26
|
-
context: {
|
|
27
|
-
|
|
25
|
+
statuses: ['in-session', 'active', 'planned', 'blocked', 'partial', 'paused', 'awaiting', 'queued-after', 'archived'],
|
|
26
|
+
context: {
|
|
27
|
+
expanded: ['in-session', 'active', 'partial'],
|
|
28
|
+
listed: ['planned', 'blocked', 'paused', 'awaiting'],
|
|
29
|
+
counted: ['queued-after', 'archived'],
|
|
30
|
+
},
|
|
31
|
+
staleDays: { 'in-session': 1, active: 14, planned: 30, blocked: 30, awaiting: 14 },
|
|
28
32
|
},
|
|
29
33
|
doc: {
|
|
30
34
|
statuses: ['draft', 'active', 'review', 'reference', 'deprecated', 'archived'],
|
|
@@ -39,26 +43,26 @@ const DEFAULTS = {
|
|
|
39
43
|
},
|
|
40
44
|
|
|
41
45
|
statuses: {
|
|
42
|
-
order: ['active', 'ready', 'planned', '
|
|
46
|
+
order: ['active', 'ready', 'planned', 'scoping', 'blocked', 'reference', 'archived'],
|
|
43
47
|
staleDays: {
|
|
44
48
|
active: 14,
|
|
45
49
|
ready: 14,
|
|
46
50
|
planned: 30,
|
|
47
51
|
blocked: 30,
|
|
48
|
-
|
|
52
|
+
scoping: 30,
|
|
49
53
|
},
|
|
50
54
|
},
|
|
51
55
|
|
|
52
56
|
lifecycle: {
|
|
53
57
|
archiveStatuses: ['archived'],
|
|
54
|
-
skipStaleFor: ['archived', 'reference'],
|
|
55
|
-
skipWarningsFor: ['archived'],
|
|
56
|
-
terminalStatuses: ['archived', 'deprecated', 'reference'
|
|
58
|
+
skipStaleFor: ['archived', 'reference', 'partial', 'paused', 'queued-after'],
|
|
59
|
+
skipWarningsFor: ['archived', 'partial', 'paused', 'queued-after'],
|
|
60
|
+
terminalStatuses: ['archived', 'deprecated', 'reference'],
|
|
57
61
|
},
|
|
58
62
|
|
|
59
63
|
taxonomy: {
|
|
60
64
|
surfaces: null,
|
|
61
|
-
moduleRequiredFor: [],
|
|
65
|
+
moduleRequiredFor: ['partial', 'paused', 'awaiting', 'queued-after'],
|
|
62
66
|
},
|
|
63
67
|
|
|
64
68
|
index: null,
|
|
@@ -66,7 +70,7 @@ const DEFAULTS = {
|
|
|
66
70
|
context: {
|
|
67
71
|
expanded: ['active'],
|
|
68
72
|
listed: ['ready', 'planned'],
|
|
69
|
-
counted: ['blocked', '
|
|
73
|
+
counted: ['blocked', 'scoping', 'reference', 'archived'],
|
|
70
74
|
recentDays: 3,
|
|
71
75
|
recentStatuses: ['active', 'ready', 'planned'],
|
|
72
76
|
recentLimit: 10,
|
|
@@ -92,7 +96,7 @@ const DEFAULTS = {
|
|
|
92
96
|
|
|
93
97
|
presets: {
|
|
94
98
|
plans: ['--type', 'plan', '--sort', 'status', '--all'],
|
|
95
|
-
stale: ['--status', 'active,ready,planned,blocked,
|
|
99
|
+
stale: ['--status', 'active,ready,planned,blocked,scoping', '--stale', '--sort', 'updated', '--all'],
|
|
96
100
|
actionable: ['--status', 'active,ready', '--has-next-step', '--sort', 'updated', '--all'],
|
|
97
101
|
},
|
|
98
102
|
};
|
|
@@ -147,9 +151,13 @@ function normalizeRichStatuses(config, userConfig) {
|
|
|
147
151
|
derived.staleDays[name] = p.staleDays;
|
|
148
152
|
}
|
|
149
153
|
|
|
154
|
+
// `quiet: true` is sugar for skipStale + skipWarnings unless those are explicitly false.
|
|
155
|
+
const quietImpliesSkipStale = p.quiet && p.skipStale !== false;
|
|
156
|
+
const quietImpliesSkipWarnings = p.quiet && p.skipWarnings !== false;
|
|
157
|
+
|
|
150
158
|
if (p.archive && !derived.archiveStatuses.includes(name)) derived.archiveStatuses.push(name);
|
|
151
|
-
if (p.skipStale && !derived.skipStaleFor.includes(name)) derived.skipStaleFor.push(name);
|
|
152
|
-
if (p.skipWarnings && !derived.skipWarningsFor.includes(name)) derived.skipWarningsFor.push(name);
|
|
159
|
+
if ((p.skipStale || quietImpliesSkipStale) && !derived.skipStaleFor.includes(name)) derived.skipStaleFor.push(name);
|
|
160
|
+
if ((p.skipWarnings || quietImpliesSkipWarnings) && !derived.skipWarningsFor.includes(name)) derived.skipWarningsFor.push(name);
|
|
153
161
|
if (p.terminal && !derived.terminalStatuses.includes(name)) derived.terminalStatuses.push(name);
|
|
154
162
|
if (p.requiresModule && !derived.moduleRequiredFor.includes(name)) derived.moduleRequiredFor.push(name);
|
|
155
163
|
|
package/src/doctor.mjs
CHANGED
|
@@ -7,7 +7,41 @@ import { renderCheck } from './render.mjs';
|
|
|
7
7
|
import { bold, dim, green, yellow } from './color.mjs';
|
|
8
8
|
import { scaffoldClaudeCommands } from './claude-commands.mjs';
|
|
9
9
|
|
|
10
|
+
// Tunable thresholds for `dotmd doctor --statuses` conflation detection.
|
|
11
|
+
// MIN_BUCKET_SIZE: only flag buckets with at least this many docs (small buckets aren't worth nagging).
|
|
12
|
+
// CUE_FLOOR_PCT: a target cue must claim at least this fraction of the bucket to be suggested.
|
|
13
|
+
// A bucket is overloaded only when ≥2 distinct target cues each clear the floor.
|
|
14
|
+
const MIN_BUCKET_SIZE = 10;
|
|
15
|
+
const CUE_FLOOR_PCT = 0.15;
|
|
16
|
+
|
|
17
|
+
// Cue patterns map keyword groups to candidate target statuses.
|
|
18
|
+
// A doc is scored by counting regex hits in its current_state + next_step text;
|
|
19
|
+
// the highest-scoring cue (if any) becomes its suggested bucket. Ties broken by
|
|
20
|
+
// the iteration order below (deterministic). Patterns are intentionally simple
|
|
21
|
+
// and tunable — false positives are fine, false confidence is not.
|
|
22
|
+
const CUE_PATTERNS = {
|
|
23
|
+
partial: /\b(shipped|landed|merged|complete|tail|deferred|follow[- ]?up|left[- ]?over|remaining)\b/i,
|
|
24
|
+
paused: /\b(paused?|on hold|set aside|park(?:ed|ing)?|shelv(?:ed|ing)?|frozen|hibernat)/i,
|
|
25
|
+
'queued-after': /\b(after|once|when|depends on|behind|sequenced|wait(?:ing)? for [a-z\- ]+ to (?:ship|land|merge))\b/i,
|
|
26
|
+
awaiting: /\b(awaiting|need(?:s)? (?:input|decision|approval|sign[- ]?off)|pending (?:review|approval)|asked? (?:for|about))\b/i,
|
|
27
|
+
blocked: /\b(hardware|vendor|third[- ]?party|firmware|delivery|arrival|rollout)\b/i,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Human-readable cue lists for the suggestion table.
|
|
31
|
+
const CUE_LABELS = {
|
|
32
|
+
partial: '"shipped", "landed", "tail", "deferred"',
|
|
33
|
+
paused: '"paused", "on hold", "set aside"',
|
|
34
|
+
'queued-after': '"after", "once", "depends on", "waiting on <plan>"',
|
|
35
|
+
awaiting: '"awaiting", "needs decision", "pending review"',
|
|
36
|
+
blocked: '"hardware", "vendor", "third-party", "rollout"',
|
|
37
|
+
};
|
|
38
|
+
|
|
10
39
|
export function runDoctor(argv, config, opts = {}) {
|
|
40
|
+
if (argv.includes('--statuses')) {
|
|
41
|
+
runDoctorStatuses(config, { json: argv.includes('--json') });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
11
45
|
const { dryRun } = opts;
|
|
12
46
|
process.stdout.write(bold('dotmd doctor') + '\n\n');
|
|
13
47
|
|
|
@@ -53,3 +87,117 @@ export function runDoctor(argv, config, opts = {}) {
|
|
|
53
87
|
const freshIndex = buildIndex(config);
|
|
54
88
|
process.stdout.write(renderCheck(freshIndex, config));
|
|
55
89
|
}
|
|
90
|
+
|
|
91
|
+
export function analyzeStatusBuckets(docs) {
|
|
92
|
+
const buckets = new Map();
|
|
93
|
+
for (const doc of docs) {
|
|
94
|
+
if (!doc.status) continue;
|
|
95
|
+
const key = `${doc.type ?? 'unknown'}::${doc.status}`;
|
|
96
|
+
if (!buckets.has(key)) {
|
|
97
|
+
buckets.set(key, { type: doc.type ?? null, status: doc.status, docs: [] });
|
|
98
|
+
}
|
|
99
|
+
buckets.get(key).docs.push(doc);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const suggestions = [];
|
|
103
|
+
|
|
104
|
+
for (const bucket of buckets.values()) {
|
|
105
|
+
if (bucket.docs.length < MIN_BUCKET_SIZE) continue;
|
|
106
|
+
const floor = Math.max(1, Math.ceil(bucket.docs.length * CUE_FLOOR_PCT));
|
|
107
|
+
|
|
108
|
+
const targetCounts = {};
|
|
109
|
+
let unmatchedCount = 0;
|
|
110
|
+
|
|
111
|
+
for (const doc of bucket.docs) {
|
|
112
|
+
const text = `${doc.currentState ?? ''}\n${doc.nextStep ?? ''}`;
|
|
113
|
+
let bestCue = null;
|
|
114
|
+
let bestScore = 0;
|
|
115
|
+
|
|
116
|
+
for (const [cue, pattern] of Object.entries(CUE_PATTERNS)) {
|
|
117
|
+
if (cue === bucket.status) continue;
|
|
118
|
+
const globalPat = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g');
|
|
119
|
+
const matches = text.match(globalPat);
|
|
120
|
+
const score = matches ? matches.length : 0;
|
|
121
|
+
if (score > bestScore) {
|
|
122
|
+
bestScore = score;
|
|
123
|
+
bestCue = cue;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (bestCue == null) {
|
|
128
|
+
unmatchedCount++;
|
|
129
|
+
} else {
|
|
130
|
+
targetCounts[bestCue] = (targetCounts[bestCue] ?? 0) + 1;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const aboveFloor = Object.entries(targetCounts)
|
|
135
|
+
.filter(([, n]) => n >= floor)
|
|
136
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
137
|
+
|
|
138
|
+
if (aboveFloor.length < 2) continue;
|
|
139
|
+
|
|
140
|
+
const splitCount = aboveFloor.reduce((s, [, n]) => s + n, 0);
|
|
141
|
+
const kept = bucket.docs.length - splitCount;
|
|
142
|
+
|
|
143
|
+
suggestions.push({
|
|
144
|
+
type: bucket.type,
|
|
145
|
+
status: bucket.status,
|
|
146
|
+
total: bucket.docs.length,
|
|
147
|
+
splits: aboveFloor.map(([target, count]) => ({
|
|
148
|
+
target,
|
|
149
|
+
count,
|
|
150
|
+
cues: CUE_LABELS[target] ?? '',
|
|
151
|
+
})),
|
|
152
|
+
kept,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
suggestions.sort((a, b) => {
|
|
157
|
+
if ((a.type ?? '') !== (b.type ?? '')) return (a.type ?? '').localeCompare(b.type ?? '');
|
|
158
|
+
return a.status.localeCompare(b.status);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return suggestions;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function runDoctorStatuses(config, { json = false } = {}) {
|
|
165
|
+
const index = buildIndex(config);
|
|
166
|
+
const suggestions = analyzeStatusBuckets(index.docs);
|
|
167
|
+
|
|
168
|
+
if (json) {
|
|
169
|
+
process.stdout.write(JSON.stringify({
|
|
170
|
+
thresholds: { minBucketSize: MIN_BUCKET_SIZE, cueFloorPct: CUE_FLOOR_PCT },
|
|
171
|
+
suggestions,
|
|
172
|
+
}, null, 2) + '\n');
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
process.stdout.write(bold('dotmd doctor --statuses') + '\n\n');
|
|
177
|
+
|
|
178
|
+
if (suggestions.length === 0) {
|
|
179
|
+
process.stdout.write(`No overloaded status buckets detected (min bucket size: ${MIN_BUCKET_SIZE}).\n`);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
for (const s of suggestions) {
|
|
184
|
+
const typeLabel = s.type ? `${s.type}/` : '';
|
|
185
|
+
const patternCount = s.splits.length + (s.kept > 0 ? 1 : 0);
|
|
186
|
+
process.stdout.write(
|
|
187
|
+
bold(`${s.total} ${typeLabel}${s.status} plans cluster across ${patternCount} patterns — consider splitting:`) + '\n'
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const targetWidth = Math.max(...s.splits.map(x => x.target.length), 'kept'.length);
|
|
191
|
+
for (const split of s.splits) {
|
|
192
|
+
const target = green(split.target.padEnd(targetWidth));
|
|
193
|
+
process.stdout.write(` ~${String(split.count).padStart(3)} → ${target} (cues: ${split.cues})\n`);
|
|
194
|
+
}
|
|
195
|
+
if (s.kept > 0) {
|
|
196
|
+
const tail = dim(`(kept in ${s.status} — no clear pattern match)`);
|
|
197
|
+
process.stdout.write(` ~${String(s.kept).padStart(3)} → ${' '.repeat(targetWidth)} ${tail}\n`);
|
|
198
|
+
}
|
|
199
|
+
process.stdout.write('\n');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
process.stdout.write(yellow('Heuristic — verify before migrating.') + '\n');
|
|
203
|
+
}
|
package/src/health.mjs
CHANGED
|
@@ -76,7 +76,7 @@ export function runHealth(argv, config) {
|
|
|
76
76
|
|
|
77
77
|
// Pipeline
|
|
78
78
|
process.stdout.write(bold('Pipeline:') + '\n');
|
|
79
|
-
const pipeline = ['active', 'paused', 'ready', 'planned', 'blocked', '
|
|
79
|
+
const pipeline = ['active', 'paused', 'ready', 'planned', 'blocked', 'scoping', 'archived'];
|
|
80
80
|
for (const s of pipeline) {
|
|
81
81
|
const count = byStatus[s] || 0;
|
|
82
82
|
if (count > 0) {
|
package/src/init.mjs
CHANGED
|
@@ -68,7 +68,7 @@ function generateDetectedConfig(scan, rootPath) {
|
|
|
68
68
|
lines.push(`export const root = '${rootPath}';`);
|
|
69
69
|
lines.push('');
|
|
70
70
|
|
|
71
|
-
const defaultOrder = ['active', 'ready', 'planned', '
|
|
71
|
+
const defaultOrder = ['active', 'ready', 'planned', 'scoping', 'blocked', 'reference', 'archived'];
|
|
72
72
|
const ordered = defaultOrder.filter(s => scan.statuses.has(s));
|
|
73
73
|
const extra = [...scan.statuses].filter(s => !defaultOrder.includes(s)).sort();
|
|
74
74
|
const allStatuses = [...ordered, ...extra];
|
package/src/migrate.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
3
|
-
import { asString, toRepoPath, die } from './util.mjs';
|
|
4
|
+
import { asString, toRepoPath, resolveDocPath, die } from './util.mjs';
|
|
4
5
|
import { collectDocFiles } from './index.mjs';
|
|
5
6
|
import { updateFrontmatter } from './lifecycle.mjs';
|
|
6
7
|
import { bold, green, dim } from './color.mjs';
|
|
@@ -18,15 +19,42 @@ export function runMigrate(argv, config, opts = {}) {
|
|
|
18
19
|
const field = positional[0];
|
|
19
20
|
const oldValue = positional[1];
|
|
20
21
|
const newValue = positional[2];
|
|
22
|
+
const fileArgs = positional.slice(3);
|
|
21
23
|
|
|
22
24
|
if (!field || !oldValue || !newValue) {
|
|
23
|
-
die('Usage: dotmd migrate <field> <old-value> <new-value>');
|
|
25
|
+
die('Usage: dotmd migrate <field> <old-value> <new-value> [files...]');
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
const allFiles = collectDocFiles(config);
|
|
29
|
+
|
|
30
|
+
// When file args are passed, resolve them to a filter set (mirrors runBulkArchive).
|
|
31
|
+
let fileFilter = null;
|
|
32
|
+
if (fileArgs.length > 0) {
|
|
33
|
+
const matched = [];
|
|
34
|
+
const unresolved = [];
|
|
35
|
+
for (const input of fileArgs) {
|
|
36
|
+
const filePath = resolveDocPath(input, config);
|
|
37
|
+
if (filePath) {
|
|
38
|
+
matched.push(filePath);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const hits = allFiles.filter(f => f.includes(input) || path.basename(f).includes(input));
|
|
42
|
+
if (hits.length === 0) {
|
|
43
|
+
unresolved.push(input);
|
|
44
|
+
} else {
|
|
45
|
+
matched.push(...hits);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (unresolved.length > 0) {
|
|
49
|
+
die(`No matching file(s) for: ${unresolved.join(', ')}`);
|
|
50
|
+
}
|
|
51
|
+
fileFilter = new Set(matched);
|
|
52
|
+
}
|
|
53
|
+
|
|
27
54
|
const matches = [];
|
|
28
55
|
|
|
29
56
|
for (const filePath of allFiles) {
|
|
57
|
+
if (fileFilter && !fileFilter.has(filePath)) continue;
|
|
30
58
|
const raw = readFileSync(filePath, 'utf8');
|
|
31
59
|
const { frontmatter } = extractFrontmatter(raw);
|
|
32
60
|
if (!frontmatter) continue;
|
|
@@ -38,7 +66,8 @@ export function runMigrate(argv, config, opts = {}) {
|
|
|
38
66
|
}
|
|
39
67
|
|
|
40
68
|
if (matches.length === 0) {
|
|
41
|
-
|
|
69
|
+
const scope = fileFilter ? ` in the specified file(s)` : '';
|
|
70
|
+
process.stdout.write(`No docs found with ${bold(field)}: ${oldValue}${scope}\n`);
|
|
42
71
|
return;
|
|
43
72
|
}
|
|
44
73
|
|
package/src/render.mjs
CHANGED
|
@@ -368,8 +368,8 @@ export function renderCoverage(index, config) {
|
|
|
368
368
|
}
|
|
369
369
|
|
|
370
370
|
export function buildCoverage(index, config) {
|
|
371
|
-
const scope = [...new Set(index.docs.map(d => d.status).filter(s => s && !config.lifecycle.terminalStatuses.has(s)
|
|
372
|
-
const scoped = index.docs.filter(doc => doc.status && !config.lifecycle.terminalStatuses.has(doc.status)
|
|
371
|
+
const scope = [...new Set(index.docs.map(d => d.status).filter(s => s && !config.lifecycle.terminalStatuses.has(s)))];
|
|
372
|
+
const scoped = index.docs.filter(doc => doc.status && !config.lifecycle.terminalStatuses.has(doc.status));
|
|
373
373
|
const missingSurface = scoped.filter(doc => !doc.surface);
|
|
374
374
|
const missingModule = scoped.filter(doc => !doc.module);
|
|
375
375
|
const modulePlatform = scoped.filter(doc => doc.module === 'platform');
|
|
@@ -403,7 +403,7 @@ export function formatSnapshot(doc, config) {
|
|
|
403
403
|
|
|
404
404
|
function _formatSnapshot(doc) {
|
|
405
405
|
const state = doc.currentState ?? 'No current_state set';
|
|
406
|
-
if (/^active:|^ready:|^planned:|^
|
|
406
|
+
if (/^active:|^ready:|^planned:|^scoping:|^blocked:|^archived:/i.test(state)) {
|
|
407
407
|
return state;
|
|
408
408
|
}
|
|
409
409
|
return `${capitalize(doc.status ?? 'unknown')}: ${state}`;
|
package/src/stats.mjs
CHANGED
|
@@ -8,10 +8,10 @@ function pct(n, total) {
|
|
|
8
8
|
|
|
9
9
|
export function buildStats(index, config) {
|
|
10
10
|
const docs = index.docs;
|
|
11
|
-
const scope = config.statusOrder.filter(s => !config.lifecycle.terminalStatuses.has(s)
|
|
11
|
+
const scope = config.statusOrder.filter(s => !config.lifecycle.terminalStatuses.has(s));
|
|
12
12
|
for (const typeSet of (config.typeStatuses?.values() ?? [])) {
|
|
13
13
|
for (const s of typeSet) {
|
|
14
|
-
if (!config.lifecycle.terminalStatuses.has(s) && !
|
|
14
|
+
if (!config.lifecycle.terminalStatuses.has(s) && !scope.includes(s)) scope.push(s);
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
17
|
const scoped = docs.filter(d => scope.includes(d.status));
|