dotmd-cli 0.33.0 → 0.34.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 +1 -1
- package/bin/dotmd.mjs +11 -11
- package/package.json +1 -1
- package/src/glossary.mjs +4 -2
- package/src/index.mjs +7 -1
- package/src/lifecycle.mjs +1 -1
- package/src/new.mjs +7 -3
- package/src/query.mjs +22 -1
- package/src/util.mjs +36 -0
- package/src/validate.mjs +66 -3
package/README.md
CHANGED
|
@@ -179,7 +179,7 @@ dotmd query [filters] Filtered search
|
|
|
179
179
|
dotmd plans List all plans
|
|
180
180
|
dotmd stale List stale docs
|
|
181
181
|
dotmd actionable List docs with next steps
|
|
182
|
-
dotmd index [--
|
|
182
|
+
dotmd index [--print] Generate/update docs.md index block
|
|
183
183
|
dotmd hud Actionable triage (silent when clean — ideal SessionStart hook)
|
|
184
184
|
dotmd pickup <file> Pick up a plan (in-session + print body)
|
|
185
185
|
dotmd release [<file>] Release in-session lease (alias: unpickup)
|
package/bin/dotmd.mjs
CHANGED
|
@@ -58,7 +58,7 @@ Lifecycle:
|
|
|
58
58
|
|
|
59
59
|
Create & Export:
|
|
60
60
|
new <type> <name> [body] Create doc of given type (plan, doc, prompt)
|
|
61
|
-
index [--
|
|
61
|
+
index [--print] Generate/update docs.md index block
|
|
62
62
|
export [--format md|html|json] Export docs as markdown, HTML, or JSON
|
|
63
63
|
notion import|export|sync [db-id] Notion database integration
|
|
64
64
|
|
|
@@ -354,12 +354,12 @@ date to match the last git commit date, fixing date drift warnings.
|
|
|
354
354
|
|
|
355
355
|
Use --dry-run (-n) to preview changes without writing anything.`,
|
|
356
356
|
|
|
357
|
-
index: `dotmd index [--
|
|
357
|
+
index: `dotmd index [--print] — generate/update docs.md index
|
|
358
358
|
|
|
359
|
-
|
|
360
|
-
|
|
359
|
+
Updates the configured index file in place (writes by default as of 0.34.0).
|
|
360
|
+
Use --print to dump the regenerated content to stdout without writing.
|
|
361
361
|
|
|
362
|
-
Use --dry-run (-n)
|
|
362
|
+
Use --dry-run (-n) to preview without writing.`,
|
|
363
363
|
|
|
364
364
|
new: `dotmd new <type> <name> [body] — create a new document
|
|
365
365
|
|
|
@@ -954,16 +954,16 @@ async function main() {
|
|
|
954
954
|
if (!config.indexPath) {
|
|
955
955
|
die('Index generation is not configured. Add an `index` section to your dotmd.config.mjs.');
|
|
956
956
|
}
|
|
957
|
-
const
|
|
957
|
+
const print = args.includes('--print');
|
|
958
958
|
const { renderIndexFile, writeIndex } = await import('../src/index-file.mjs');
|
|
959
959
|
const rendered = renderIndexFile(index, config);
|
|
960
|
-
if (
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
} else if (write && dryRun) {
|
|
960
|
+
if (print) {
|
|
961
|
+
process.stdout.write(rendered);
|
|
962
|
+
} else if (dryRun) {
|
|
964
963
|
process.stdout.write(`[dry-run] Would update ${config.indexPath}\n`);
|
|
965
964
|
} else {
|
|
966
|
-
|
|
965
|
+
writeIndex(rendered, config);
|
|
966
|
+
process.stdout.write(`Updated ${config.indexPath}\n`);
|
|
967
967
|
}
|
|
968
968
|
return;
|
|
969
969
|
}
|
package/package.json
CHANGED
package/src/glossary.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync, existsSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { buildIndex } from './index.mjs';
|
|
4
|
-
import { die, warn } from './util.mjs';
|
|
4
|
+
import { die, warn, suggestCandidates } from './util.mjs';
|
|
5
5
|
import { bold, dim, green, yellow } from './color.mjs';
|
|
6
6
|
|
|
7
7
|
function parseGlossaryTable(content, sectionHeading) {
|
|
@@ -217,7 +217,9 @@ export function runGlossary(argv, config) {
|
|
|
217
217
|
}
|
|
218
218
|
|
|
219
219
|
if (matches.length === 0) {
|
|
220
|
-
|
|
220
|
+
const suggestions = suggestCandidates(term, entries.map(e => e.term));
|
|
221
|
+
const hint = suggestions.length ? ` Did you mean: ${suggestions.join(', ')}?` : '';
|
|
222
|
+
process.stdout.write(dim(`No glossary match for "${term}".${hint}`) + '\n');
|
|
221
223
|
return;
|
|
222
224
|
}
|
|
223
225
|
|
package/src/index.mjs
CHANGED
|
@@ -3,12 +3,18 @@ import path from 'node:path';
|
|
|
3
3
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
4
|
import { extractFirstHeading, extractSummary, extractStatusSnapshot, extractNextStep, extractChecklistCounts, extractBodyLinks } from './extractors.mjs';
|
|
5
5
|
import { asString, normalizeStringList, normalizeBlockers, mergeUniqueStrings, toRepoPath, warn } from './util.mjs';
|
|
6
|
-
import { validateDoc, validatePlanShape, validateDocShape, checkBidirectionalReferences, checkGitStaleness, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate } from './validate.mjs';
|
|
6
|
+
import { validateDoc, validatePlanShape, validateDocShape, checkBidirectionalReferences, checkGitStaleness, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate, enrichRefErrorSuggestions } from './validate.mjs';
|
|
7
7
|
import { checkIndex } from './index-file.mjs';
|
|
8
8
|
import { checkClaudeCommands } from './claude-commands.mjs';
|
|
9
9
|
|
|
10
10
|
export function buildIndex(config) {
|
|
11
11
|
const docs = collectDocFiles(config).map(f => parseDocFile(f, config));
|
|
12
|
+
// Per-file validation (validateDoc) ran during parse without sibling
|
|
13
|
+
// visibility. Now that the full index is materialized, enrich
|
|
14
|
+
// unresolved-ref entries with "Did you mean..." candidates drawn from the
|
|
15
|
+
// index — mutates doc.errors/doc.warnings in place so the aggregations
|
|
16
|
+
// below pick up the enriched messages.
|
|
17
|
+
enrichRefErrorSuggestions(docs, config);
|
|
12
18
|
const warnings = [];
|
|
13
19
|
const errors = [];
|
|
14
20
|
|
package/src/lifecycle.mjs
CHANGED
|
@@ -35,7 +35,7 @@ export function regenIndex(config) {
|
|
|
35
35
|
const index = buildIndex(config);
|
|
36
36
|
writeIndex(renderIndexFile(index, config), config);
|
|
37
37
|
} catch (err) {
|
|
38
|
-
warn(`Could not regenerate index (run \`dotmd index
|
|
38
|
+
warn(`Could not regenerate index (run \`dotmd index\`): ${err.message}`);
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
package/src/new.mjs
CHANGED
|
@@ -51,6 +51,9 @@ ${ctx?.bodyInput?.trim() ?? ''}
|
|
|
51
51
|
dir: 'plans',
|
|
52
52
|
targetRoot: 'plans',
|
|
53
53
|
defaultStatus: 'active',
|
|
54
|
+
// Body input lands in the Problem section. Plans don't have an Overview;
|
|
55
|
+
// Problem is the established opening section in the build-up shape.
|
|
56
|
+
acceptsBody: true,
|
|
54
57
|
frontmatter: (s, d) => [
|
|
55
58
|
'type: plan',
|
|
56
59
|
`status: ${s}`,
|
|
@@ -73,7 +76,7 @@ ${ctx?.bodyInput?.trim() ?? ''}
|
|
|
73
76
|
|
|
74
77
|
## Problem
|
|
75
78
|
|
|
76
|
-
|
|
79
|
+
${ctx?.bodyInput?.trim() ?? ''}
|
|
77
80
|
|
|
78
81
|
## Goals
|
|
79
82
|
|
|
@@ -242,8 +245,9 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
242
245
|
|
|
243
246
|
// Fail-fast when the user passes body input to a template that doesn't
|
|
244
247
|
// consume it — silently discarding heredoc content is the worst UX.
|
|
245
|
-
// Templates opt in via `acceptsBody: true` or `requiresBody: true`.
|
|
246
|
-
//
|
|
248
|
+
// Templates opt in via `acceptsBody: true` or `requiresBody: true`. All
|
|
249
|
+
// built-in templates (doc, plan, prompt) accept body; this guard fires
|
|
250
|
+
// only for custom templates that opt out.
|
|
247
251
|
if (bodyInput !== null && !template.acceptsBody && !template.requiresBody) {
|
|
248
252
|
const accepting = Object.entries(BUILTIN_TEMPLATES)
|
|
249
253
|
.filter(([, t]) => t.acceptsBody || t.requiresBody)
|
package/src/query.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { capitalize, toSlug, truncate, warn } from './util.mjs';
|
|
3
|
+
import { capitalize, toSlug, truncate, warn, suggestCandidates } from './util.mjs';
|
|
4
4
|
import { renderProgressBar, formatCurrentState } from './render.mjs';
|
|
5
5
|
import { computeDaysSinceUpdate, computeIsStale } from './validate.mjs';
|
|
6
6
|
import { getGitLastModifiedBatch } from './git.mjs';
|
|
@@ -90,10 +90,31 @@ export function runQuery(index, argv, config, opts = {}) {
|
|
|
90
90
|
|
|
91
91
|
if (opts.preset === 'plans' || opts.preset === 'prompts') {
|
|
92
92
|
renderPlansOutput(docs, filters, config, { noun: opts.preset });
|
|
93
|
+
if (docs.length === 0) writeUnknownFilterValueHint(filters, index);
|
|
93
94
|
return;
|
|
94
95
|
}
|
|
95
96
|
|
|
96
97
|
renderQueryResults(docs, filters, config);
|
|
98
|
+
if (docs.length === 0) writeUnknownFilterValueHint(filters, index);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// When a query returns nothing AND a value-shaped filter (currently --module)
|
|
102
|
+
// names a value that doesn't exist anywhere in the index, the empty result is
|
|
103
|
+
// almost certainly a typo rather than a combination miss. Surface a hint so
|
|
104
|
+
// the agent doesn't have to grep modules to discover the right spelling.
|
|
105
|
+
function writeUnknownFilterValueHint(filters, index) {
|
|
106
|
+
if (filters.module) {
|
|
107
|
+
const allModules = new Set();
|
|
108
|
+
for (const d of index.docs) {
|
|
109
|
+
for (const m of (d.modules ?? [])) allModules.add(m);
|
|
110
|
+
}
|
|
111
|
+
const exists = [...allModules].some(m => m.toLowerCase() === filters.module.toLowerCase());
|
|
112
|
+
if (!exists) {
|
|
113
|
+
const suggestions = suggestCandidates(filters.module, [...allModules]);
|
|
114
|
+
const hint = suggestions.length ? ` Did you mean: ${suggestions.join(', ')}?` : '';
|
|
115
|
+
process.stdout.write(dim(`No module \`${filters.module}\` in index.${hint}`) + '\n');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
97
118
|
}
|
|
98
119
|
|
|
99
120
|
export function parseQueryArgs(argv) {
|
package/src/util.mjs
CHANGED
|
@@ -89,6 +89,42 @@ export function levenshtein(a, b) {
|
|
|
89
89
|
return matrix[b.length][a.length];
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
// Top-N candidates from a list, ranked for "did you mean" hints. Substring
|
|
93
|
+
// match wins (cheap and intent-revealing for typos that share a prefix or
|
|
94
|
+
// stem); Levenshtein distance ≤3 catches transpositions and small edits.
|
|
95
|
+
// Results are deduped and capped. Empty input or empty candidate list returns
|
|
96
|
+
// an empty array — callers should suppress the hint line in that case.
|
|
97
|
+
export function suggestCandidates(query, candidates, max = 3) {
|
|
98
|
+
if (!query || !candidates?.length) return [];
|
|
99
|
+
const lower = String(query).toLowerCase();
|
|
100
|
+
const scored = new Map();
|
|
101
|
+
|
|
102
|
+
for (const cand of candidates) {
|
|
103
|
+
if (!cand) continue;
|
|
104
|
+
const candLower = String(cand).toLowerCase();
|
|
105
|
+
if (candLower === lower) continue;
|
|
106
|
+
if (candLower.includes(lower) || lower.includes(candLower)) {
|
|
107
|
+
// Substring hits sort first; shorter candidates rank higher within that bucket.
|
|
108
|
+
scored.set(cand, Math.min(scored.get(cand) ?? Infinity, candLower.length));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (scored.size < max) {
|
|
113
|
+
for (const cand of candidates) {
|
|
114
|
+
if (!cand || scored.has(cand)) continue;
|
|
115
|
+
const candLower = String(cand).toLowerCase();
|
|
116
|
+
if (candLower === lower) continue;
|
|
117
|
+
const dist = levenshtein(lower, candLower);
|
|
118
|
+
if (dist <= 3) scored.set(cand, 1000 + dist);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return [...scored.entries()]
|
|
123
|
+
.sort((a, b) => a[1] - b[1])
|
|
124
|
+
.slice(0, max)
|
|
125
|
+
.map(([cand]) => cand);
|
|
126
|
+
}
|
|
127
|
+
|
|
92
128
|
export function resolveDocPath(input, config) {
|
|
93
129
|
if (!input) return null;
|
|
94
130
|
if (path.isAbsolute(input)) return existsSync(input) ? input : null;
|
package/src/validate.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import { asString, resolveRefPath } from './util.mjs';
|
|
2
|
+
import { asString, resolveRefPath, suggestCandidates } from './util.mjs';
|
|
3
3
|
import { getGitLastModified, getGitLastModifiedBatch } from './git.mjs';
|
|
4
4
|
import { toRepoPath } from './util.mjs';
|
|
5
5
|
|
|
@@ -176,7 +176,12 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
|
|
|
176
176
|
for (const field of allRefFields) {
|
|
177
177
|
for (const relPath of (doc.refFields[field] || [])) {
|
|
178
178
|
if (!resolveRefPath(relPath, docDir, config.repoRoot)) {
|
|
179
|
-
doc.errors.push({
|
|
179
|
+
doc.errors.push({
|
|
180
|
+
path: doc.path,
|
|
181
|
+
level: 'error',
|
|
182
|
+
message: `${field} entry \`${relPath}\` does not resolve to an existing file.`,
|
|
183
|
+
meta: { kind: 'ref-resolution', field, relPath },
|
|
184
|
+
});
|
|
180
185
|
}
|
|
181
186
|
}
|
|
182
187
|
}
|
|
@@ -184,12 +189,70 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
|
|
|
184
189
|
// Validate body links resolve to existing files
|
|
185
190
|
for (const link of (doc.bodyLinks || [])) {
|
|
186
191
|
if (!resolveRefPath(link.href, docDir, config.repoRoot)) {
|
|
187
|
-
doc.warnings.push({
|
|
192
|
+
doc.warnings.push({
|
|
193
|
+
path: doc.path,
|
|
194
|
+
level: 'warning',
|
|
195
|
+
message: `body link \`${link.href}\` does not resolve to an existing file.`,
|
|
196
|
+
meta: { kind: 'ref-resolution', field: 'body-link', relPath: link.href },
|
|
197
|
+
});
|
|
188
198
|
}
|
|
189
199
|
}
|
|
190
200
|
}
|
|
191
201
|
}
|
|
192
202
|
|
|
203
|
+
// Guess which doc type a ref field expects so suggestions don't propose
|
|
204
|
+
// nonsense (a `related_plans` ref shouldn't suggest doc filenames). Heuristic:
|
|
205
|
+
// match common substrings in the field name. When ambiguous, return null so
|
|
206
|
+
// the caller suggests across all types.
|
|
207
|
+
function inferRefFieldType(field) {
|
|
208
|
+
if (!field) return null;
|
|
209
|
+
const f = field.toLowerCase();
|
|
210
|
+
if (f.includes('plan')) return 'plan';
|
|
211
|
+
if (f.includes('doc')) return 'doc';
|
|
212
|
+
if (f.includes('prompt')) return 'prompt';
|
|
213
|
+
if (f.includes('research')) return 'research';
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function candidatePathsForType(docs, type) {
|
|
218
|
+
// When the field implies a type, exclude docs that explicitly declare a
|
|
219
|
+
// different type. Untyped docs (no frontmatter `type:`) are kept — we
|
|
220
|
+
// don't know they aren't the target, so suggesting them is still useful.
|
|
221
|
+
const matches = type ? docs.filter(d => !d.type || d.type === type) : docs;
|
|
222
|
+
// Suggest by basename (the friendly handle agents/humans actually type) and
|
|
223
|
+
// by repo-relative path (covers cases where multiple files share a basename
|
|
224
|
+
// and the disambiguator is the directory).
|
|
225
|
+
const out = new Set();
|
|
226
|
+
for (const d of matches) {
|
|
227
|
+
out.add(path.basename(d.path));
|
|
228
|
+
out.add(d.path);
|
|
229
|
+
}
|
|
230
|
+
return [...out];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Post-pass enrichment: walk every doc's errors/warnings for unresolved-ref
|
|
234
|
+
// entries and append `Did you mean: a, b, c?` using the full index. Runs
|
|
235
|
+
// after parsing so it has the global doc list (validateDoc runs per-file and
|
|
236
|
+
// can't see siblings). Filters candidates by ref-field type when the field
|
|
237
|
+
// name implies one (e.g. `related_plans` → plans only).
|
|
238
|
+
export function enrichRefErrorSuggestions(docs, config) {
|
|
239
|
+
const enrich = (entry) => {
|
|
240
|
+
if (!entry?.meta || entry.meta.kind !== 'ref-resolution') return;
|
|
241
|
+
if (entry._suggested) return;
|
|
242
|
+
const inferred = inferRefFieldType(entry.meta.field);
|
|
243
|
+
const candidates = candidatePathsForType(docs, inferred);
|
|
244
|
+
const suggestions = suggestCandidates(path.basename(entry.meta.relPath), candidates);
|
|
245
|
+
entry._suggested = true;
|
|
246
|
+
if (suggestions.length === 0) return;
|
|
247
|
+
entry.message = `${entry.message} Did you mean: ${suggestions.join(', ')}?`;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
for (const doc of docs) {
|
|
251
|
+
for (const e of (doc.errors || [])) enrich(e);
|
|
252
|
+
for (const w of (doc.warnings || [])) enrich(w);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
193
256
|
export function checkBidirectionalReferences(docs, config) {
|
|
194
257
|
const warnings = [];
|
|
195
258
|
const biFields = config.referenceFields.bidirectional || [];
|