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 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 [--write] Generate/update docs.md index block
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 [--write] Generate/update docs.md index block
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 [--write] — generate/update docs.md index
357
+ index: `dotmd index [--print] — generate/update docs.md index
358
358
 
359
- Without --write, prints the generated content to stdout.
360
- With --write, updates the configured index file in place.
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) with --write to preview without writing.`,
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 write = args.includes('--write');
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 (write && !dryRun) {
961
- writeIndex(rendered, config);
962
- process.stdout.write(`Updated ${config.indexPath}\n`);
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
- process.stdout.write(rendered);
965
+ writeIndex(rendered, config);
966
+ process.stdout.write(`Updated ${config.indexPath}\n`);
967
967
  }
968
968
  return;
969
969
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
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/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
- process.stdout.write(dim(`No glossary match for "${term}".`) + '\n');
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 --write\`): ${err.message}`);
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`. Built-in
246
- // `prompt` is the only template that consumes body by default.
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({ path: doc.path, level: 'error', message: `${field} entry \`${relPath}\` does not resolve to an existing file.` });
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({ path: doc.path, level: 'warning', message: `body link \`${link.href}\` does not resolve to an existing file.` });
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 || [];