dotmd-cli 0.15.0 → 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
@@ -244,8 +244,30 @@ Shows: status counts, staleness, errors/warnings, freshness (today/week/month),
244
244
  ```bash
245
245
  dotmd doctor # fix refs → lint → sync git dates → regen index
246
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
247
249
  ```
248
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
+
249
271
  ### Graph
250
272
 
251
273
  ```bash
@@ -412,8 +434,20 @@ dotmd rename old-name.md new-name # renames + updates refs
412
434
  ```bash
413
435
  dotmd migrate status research scoping # rename a status (e.g. for the 0.15 default-vocab change)
414
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
415
442
  ```
416
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
+
417
451
  ### Preset Aliases
418
452
 
419
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)
@@ -236,6 +236,16 @@ Options:
236
236
  Runs in sequence: fix broken references, lint --fix, sync dates from
237
237
  git, regenerate index, then show remaining issues.
238
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
+
239
249
  Use --dry-run (-n) to preview all changes without writing anything.`,
240
250
 
241
251
  'fix-refs': `dotmd fix-refs — auto-fix broken reference paths
@@ -359,14 +369,22 @@ in other docs that point to the old filename.
359
369
  Body markdown links are warned about but not auto-fixed.
360
370
  Use --dry-run (-n) to preview changes without writing anything.`,
361
371
 
362
- 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
363
373
 
364
374
  Finds all docs where the given field equals old-value and updates it
365
- 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.
366
383
 
367
384
  Examples:
368
385
  dotmd migrate status research scoping
369
386
  dotmd migrate module auth identity
387
+ dotmd migrate status backlog paused docs/plans/foo.md docs/plans/bar.md
370
388
 
371
389
  Use --dry-run (-n) to preview changes without writing anything.`,
372
390
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.15.0",
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/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/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