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 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`, `done`, `archived` |
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 exploration # rename a status
394
- dotmd migrate module auth identity # rename a module
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> Batch update a frontmatter field value
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 exploration
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 # all plans
373
- dotmd plans --status active # active plans only
374
- dotmd plans --module auth # plans for the auth module
375
- dotmd plans --group module # all plans grouped by module
376
- dotmd plans --json # JSON output`,
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
 
@@ -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': { context: 'expanded', staleDays: 1, requiresModule: true },
37
- // 'active': { context: 'expanded', staleDays: 14, requiresModule: true },
38
- // 'planned': { context: 'listed', staleDays: 30, requiresModule: true },
39
- // 'blocked': { context: 'listed', staleDays: 30, requiresModule: true, skipStale: true },
40
- // 'done': { context: 'counted', terminal: true, skipStale: true, skipWarnings: true },
41
- // 'archived': { context: 'counted', archive: true, terminal: true, skipStale: true, skipWarnings: true },
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, skipStale: true, skipWarnings: 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', 'done', 'archived'],
61
- // context: { expanded: ['in-session', 'active'], listed: ['planned', 'blocked'], counted: ['done', 'archived'] },
62
- // staleDays: { 'in-session': 1, active: 14, planned: 30, blocked: 30 },
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', 'research', 'blocked', 'reference', 'archived'],
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
- research: 30,
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', 'research', 'reference', 'archived'],
133
+ counted: ['blocked', 'scoping', 'reference', 'archived'],
115
134
  recentDays: 3,
116
135
  recentStatuses: ['active', 'ready', 'planned'],
117
136
  recentLimit: 10,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.14.12",
3
+ "version": "0.15.1",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, Notion sync, AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/config.mjs CHANGED
@@ -22,9 +22,13 @@ const DEFAULTS = {
22
22
 
23
23
  types: {
24
24
  plan: {
25
- statuses: ['in-session', 'active', 'planned', 'blocked', 'done', 'archived'],
26
- context: { expanded: ['in-session', 'active'], listed: ['planned', 'blocked'], counted: ['done', 'archived'] },
27
- staleDays: { 'in-session': 1, active: 14, planned: 30, blocked: 30 },
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', 'research', 'blocked', 'reference', 'archived'],
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
- research: 30,
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', 'done'],
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', 'research', 'reference', 'archived'],
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,research', '--stale', '--sort', 'updated', '--all'],
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', 'research', 'archived'];
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', 'research', 'blocked', 'reference', 'archived'];
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
- process.stdout.write(`No docs found with ${bold(field)}: ${oldValue}\n`);
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) && !config.lifecycle.skipWarningsFor.has(s)))];
372
- const scoped = index.docs.filter(doc => doc.status && !config.lifecycle.terminalStatuses.has(doc.status) && !config.lifecycle.skipWarningsFor.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:|^research:|^blocked:|^archived:/i.test(state)) {
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) && !config.lifecycle.skipWarningsFor.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) && !config.lifecycle.skipWarningsFor.has(s) && !scope.includes(s)) scope.push(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));