dotmd-cli 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -0
- package/bin/dotmd.mjs +71 -4
- package/dotmd.config.example.mjs +14 -7
- package/package.json +1 -1
- package/src/config-edit.mjs +603 -0
- package/src/doctor.mjs +148 -0
- package/src/migrate.mjs +32 -3
- package/src/statuses.mjs +736 -0
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
|
|