dotmd-cli 0.33.0 → 0.35.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)
@@ -757,6 +757,12 @@ export const referenceFields = {
757
757
  bidirectional: ['related_plans'], // warn if A→B but B↛A
758
758
  unidirectional: ['supports_plans'], // one-way, no symmetry check
759
759
  };
760
+ // Per-ref opt-out: prefix any value with `>` to mark that specific ref one-way
761
+ // without changing the field's default. Useful for leaf→upstream-parent refs
762
+ // (audits, hub docs) where a back-ref would force editing a stable parent.
763
+ // related_docs:
764
+ // - docs/sibling-design.md # bidirectional (default for the field)
765
+ // - "> docs/audit-beyond-platform.md" # one-way upstream — no back-ref expected
760
766
 
761
767
  export const index = {
762
768
  path: 'docs/docs.md',
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.35.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
 
@@ -169,10 +175,30 @@ export function parseDocFile(filePath, config) {
169
175
  const bodyLinks = extractBodyLinks(body);
170
176
  const hasCloseout = /^##\s+Closeout/m.test(body);
171
177
 
172
- // Dynamic reference field extraction
178
+ // Dynamic reference field extraction. A leading `>` on a value (e.g.
179
+ // `"> docs/audit-beyond-platform.md"`) marks that single ref as one-way —
180
+ // the prefix is stripped so path resolution still works, and the direction
181
+ // is recorded on a parallel `refFieldDirections[field]` array indexed the
182
+ // same as `refFields[field]`. Bidirectional reciprocity checks consume the
183
+ // directions to skip outbound entries that opted out of expecting a back-ref.
173
184
  const refFields = {};
185
+ const refFieldDirections = {};
174
186
  for (const field of [...(config.referenceFields.bidirectional || []), ...(config.referenceFields.unidirectional || [])]) {
175
- refFields[field] = normalizeStringList(parsedFrontmatter[field]);
187
+ const raw = normalizeStringList(parsedFrontmatter[field]);
188
+ const paths = [];
189
+ const directions = [];
190
+ for (const entry of raw) {
191
+ const oneWay = entry.match(/^>\s*(.+)$/);
192
+ if (oneWay) {
193
+ paths.push(oneWay[1].trim());
194
+ directions.push('one-way');
195
+ } else {
196
+ paths.push(entry);
197
+ directions.push('two-way');
198
+ }
199
+ }
200
+ refFields[field] = paths;
201
+ refFieldDirections[field] = directions;
176
202
  }
177
203
 
178
204
  // Tag doc with its root
@@ -209,6 +235,7 @@ export function parseDocFile(filePath, config) {
209
235
  checklist,
210
236
  bodyLinks,
211
237
  refFields,
238
+ refFieldDirections,
212
239
  checklistCompletionRate: computeChecklistCompletionRate(checklist),
213
240
  hasCloseout,
214
241
  hasNextStep: Boolean(nextStep),
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,37 +189,111 @@ 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 || [];
196
259
  if (!biFields.length) return { warnings, errors: [] };
197
260
 
261
+ // refMap stores `Set<targetPath>` keyed by the source doc path — used to
262
+ // answer "does B reference A?" via .has(). oneWayMap stores the subset of A's
263
+ // outbound refs that opted out of expecting a back-ref (via the `>` prefix in
264
+ // frontmatter, parsed in src/index.mjs:parseDocFile). We split these so the
265
+ // membership check stays cheap (Set.has) while the per-ref directionality
266
+ // filter only consults oneWayMap when iterating outbound edges.
198
267
  const refMap = new Map();
268
+ const oneWayMap = new Map();
199
269
  for (const doc of docs) {
200
270
  const docDir = path.dirname(path.join(config.repoRoot, doc.path));
201
271
  const refs = new Set();
272
+ const oneWay = new Set();
202
273
  for (const field of biFields) {
203
- for (const relPath of (doc.refFields[field] || [])) {
274
+ const entries = doc.refFields[field] || [];
275
+ const dirs = doc.refFieldDirections?.[field] || [];
276
+ for (let i = 0; i < entries.length; i++) {
277
+ const relPath = entries[i];
204
278
  // Use the same doc-relative-then-repo-root fallback as validateDoc so
205
279
  // both styles produce identical refMap keys; otherwise an entry like
206
280
  // `docs/foo.md` (repo-root style) gets keyed as
207
281
  // `<doc-parent>/docs/foo.md` and never matches the target's repo path.
208
282
  const resolved = resolveRefPath(relPath, docDir, config.repoRoot)
209
283
  ?? path.resolve(docDir, relPath);
210
- refs.add(toRepoPath(resolved, config.repoRoot));
284
+ const targetPath = toRepoPath(resolved, config.repoRoot);
285
+ refs.add(targetPath);
286
+ if (dirs[i] === 'one-way') oneWay.add(targetPath);
211
287
  }
212
288
  }
213
289
  refMap.set(doc.path, refs);
290
+ oneWayMap.set(doc.path, oneWay);
214
291
  }
215
292
 
216
293
  for (const [docPath, refs] of refMap) {
294
+ const oneWay = oneWayMap.get(docPath);
217
295
  for (const targetPath of refs) {
296
+ if (oneWay.has(targetPath)) continue;
218
297
  const targetRefs = refMap.get(targetPath);
219
298
  if (targetRefs && !targetRefs.has(docPath)) {
220
299
  warnings.push({ path: docPath, level: 'warning',