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 +34 -0
- package/bin/dotmd.mjs +21 -3
- package/package.json +1 -1
- package/src/doctor.mjs +148 -0
- package/src/migrate.mjs +32 -3
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>
|
|
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
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
|
-
|
|
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
|
|