dotmd-cli 0.32.1 → 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.32.1",
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",
@@ -46,6 +46,22 @@ function generatePlansCommand(config) {
46
46
  return lines.join('\n');
47
47
  }
48
48
 
49
+ function generateBatonCommand() {
50
+ const lines = [VERSION_MARKER, ''];
51
+ lines.push('You are wrapping this session. Hand the baton cleanly to the next one.');
52
+ lines.push('');
53
+ lines.push('1. **Update the in-flight plan.** Find it via `dotmd plans --status in-session`. Edit its `current_state:` / `next_step:` frontmatter so they reflect where things actually stand. If status should change (shipped → archive, stuck on a human decision → awaiting, etc.), transition with `dotmd status <file> <status>` — or `dotmd archive <file>` if work is done.');
54
+ lines.push('');
55
+ lines.push('2. **Save ONE lean handoff prompt.** Run `dotmd new prompt resume-<plan-slug>` with a body of ~10-20 lines: point at the plan file, name the next concrete decision, flag any gotchas. Do NOT recap the plan body (the plan is for that). Do NOT print the handoff into chat for the user to copy-paste — the saved prompt is the handoff.');
56
+ lines.push('');
57
+ lines.push('3. **Release the lease.** `dotmd release` (skip if `dotmd archive` already closed out — archive auto-releases).');
58
+ lines.push('');
59
+ lines.push('The next session\'s `dotmd hud` (SessionStart hook) surfaces the pending prompt automatically.');
60
+ lines.push('');
61
+
62
+ return lines.join('\n');
63
+ }
64
+
49
65
  function generateDocsCommand(config) {
50
66
  const roots = Array.isArray(config.raw?.root) ? config.raw.root : [config.raw?.root ?? 'docs'];
51
67
  const rootCount = roots.length;
@@ -118,6 +134,7 @@ export function scaffoldClaudeCommands(cwd, config, opts = {}) {
118
134
  const files = [
119
135
  { name: 'plans.md', generate: () => generatePlansCommand(config) },
120
136
  { name: 'docs.md', generate: () => generateDocsCommand(config) },
137
+ { name: 'baton.md', generate: () => generateBatonCommand() },
121
138
  ];
122
139
 
123
140
  for (const { name, generate } of files) {
@@ -149,12 +166,24 @@ export function scaffoldClaudeCommands(cwd, config, opts = {}) {
149
166
  return results;
150
167
  }
151
168
 
169
+ // Self-heal: regen any slash-command file whose banner is older than pkg.version.
170
+ // Designed for runHud to call at SessionStart — closes the gap between "user
171
+ // upgraded dotmd" and "slash-command body reflects the new version" without
172
+ // requiring a manual `dotmd doctor`. Returns only the entries that actually
173
+ // changed so the caller can surface a one-line note; an empty array means the
174
+ // hud silent-clean contract is preserved. `skipped` (user-managed, no banner)
175
+ // and `current` entries are filtered out — callers don't care about them.
176
+ export function refreshStaleSlashCommands(config) {
177
+ const results = scaffoldClaudeCommands(config.repoRoot, config);
178
+ return results.filter(r => r.action === 'updated');
179
+ }
180
+
152
181
  export function checkClaudeCommands(cwd) {
153
182
  const commandsDir = path.join(cwd, '.claude', 'commands');
154
183
  if (!existsSync(commandsDir)) return [];
155
184
 
156
185
  const warnings = [];
157
- for (const name of ['plans.md', 'docs.md']) {
186
+ for (const name of ['plans.md', 'docs.md', 'baton.md']) {
158
187
  const filePath = path.join(commandsDir, name);
159
188
  const installedVersion = getInstalledVersion(filePath);
160
189
  if (installedVersion && installedVersion !== pkg.version) {
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/hud.mjs CHANGED
@@ -5,6 +5,7 @@ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
5
5
  import { asString, toRepoPath } from './util.mjs';
6
6
  import { green, yellow, red, dim } from './color.mjs';
7
7
  import { buildIndex } from './index.mjs';
8
+ import { refreshStaleSlashCommands } from './claude-commands.mjs';
8
9
 
9
10
  const MAX_PREVIEW = 5;
10
11
 
@@ -89,6 +90,15 @@ export function runHud(argv, config) {
89
90
  const json = argv.includes('--json');
90
91
  const hud = buildHud(config);
91
92
 
93
+ // Self-heal stale slash-command files. Wrapped: a broken scaffolder must
94
+ // never kill the SessionStart hook (would block every session). Skipped in
95
+ // --json mode to keep the structured shape stable for programmatic callers.
96
+ let refreshed = [];
97
+ if (!json) {
98
+ try { refreshed = refreshStaleSlashCommands(config); }
99
+ catch { /* swallow — see comment above */ }
100
+ }
101
+
92
102
  if (json) {
93
103
  process.stdout.write(JSON.stringify(hud, null, 2) + '\n');
94
104
  return;
@@ -107,6 +117,12 @@ export function runHud(argv, config) {
107
117
  if (hud.errors > 0) {
108
118
  lines.push(red(`✗ ${hud.errors} validation error${hud.errors === 1 ? '' : 's'} ${dim('(run: dotmd check)')}`));
109
119
  }
120
+ if (refreshed.length > 0) {
121
+ const from = refreshed[0].from;
122
+ const to = refreshed[0].to;
123
+ const names = refreshed.map(r => r.name).join(', ');
124
+ lines.push(dim(`↻ slash commands refreshed (v${from} → v${to}): ${names}`));
125
+ }
110
126
 
111
127
  if (lines.length === 0) return; // silent when clean
112
128
  process.stdout.write(lines.join('\n') + '\n');
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 || [];