dotmd-cli 0.31.1 → 0.31.2

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/bin/dotmd.mjs CHANGED
@@ -693,7 +693,7 @@ async function main() {
693
693
  // Init — now has access to config for Claude command generation
694
694
  if (command === 'init') {
695
695
  const { runInit } = await import('../src/init.mjs');
696
- runInit(process.cwd(), config.configFound ? config : null);
696
+ runInit(process.cwd(), config.configFound ? config : null, { dryRun });
697
697
  return;
698
698
  }
699
699
 
@@ -1036,14 +1036,8 @@ async function main() {
1036
1036
  }
1037
1037
 
1038
1038
  // Unknown command — suggest closest match
1039
- const allCommands = [
1040
- 'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'briefing', 'context', 'hud',
1041
- 'focus', 'query', 'plans', 'prompts', 'stale', 'actionable', 'index', 'pickup', 'release', 'finish', 'status', 'archive', 'bulk', 'touch', 'doctor',
1042
- 'unblocks', 'health', 'glossary',
1043
- 'fix-refs', 'lint', 'rename', 'migrate', 'notion', 'export', 'summary',
1044
- 'watch', 'diff', 'new', 'init', 'completions', 'statuses',
1045
- ];
1046
- const matches = allCommands
1039
+ const { KNOWN_COMMANDS } = await import('../src/commands.mjs');
1040
+ const matches = KNOWN_COMMANDS
1047
1041
  .map(c => ({ cmd: c, dist: levenshtein(command, c) }))
1048
1042
  .sort((a, b) => a.dist - b.dist);
1049
1043
  if (matches[0] && matches[0].dist <= 3) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.31.1",
3
+ "version": "0.31.2",
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",
@@ -20,7 +20,7 @@ function generatePlansCommand(config) {
20
20
  lines.push('- `dotmd release` — release current session\'s leases (alias: unpickup)');
21
21
  lines.push('- `dotmd health` — plan velocity, aging, checklist progress, pipeline view');
22
22
  lines.push('- `dotmd unblocks <file>` — what depends on / is blocked by a plan');
23
- lines.push('- `dotmd next` — ready plans with next steps (what to promote)');
23
+ lines.push('- `dotmd actionable` — ready plans with next steps (what to promote)');
24
24
  lines.push('- `dotmd new plan <name>` — scaffold with full phase structure');
25
25
  lines.push('- `dotmd prompts new <name> "<body>"` — save a resume-prompt to docs/prompts/');
26
26
  lines.push('- `dotmd prompts next` — consume oldest pending prompt (prints body, auto-archives)');
@@ -107,7 +107,8 @@ function getInstalledVersion(filePath) {
107
107
  return match ? match[1] : null;
108
108
  }
109
109
 
110
- export function scaffoldClaudeCommands(cwd, config) {
110
+ export function scaffoldClaudeCommands(cwd, config, opts = {}) {
111
+ const { dryRun = false } = opts;
111
112
  const claudeDir = path.join(cwd, '.claude');
112
113
  if (!existsSync(claudeDir)) return [];
113
114
 
@@ -127,13 +128,17 @@ export function scaffoldClaudeCommands(cwd, config) {
127
128
  results.push({ name, action: 'current' });
128
129
  } else if (installedVersion) {
129
130
  // Outdated — regenerate
130
- mkdirSync(commandsDir, { recursive: true });
131
- writeFileSync(filePath, generate(), 'utf8');
131
+ if (!dryRun) {
132
+ mkdirSync(commandsDir, { recursive: true });
133
+ writeFileSync(filePath, generate(), 'utf8');
134
+ }
132
135
  results.push({ name, action: 'updated', from: installedVersion, to: pkg.version });
133
136
  } else if (!existsSync(filePath)) {
134
137
  // New — create
135
- mkdirSync(commandsDir, { recursive: true });
136
- writeFileSync(filePath, generate(), 'utf8');
138
+ if (!dryRun) {
139
+ mkdirSync(commandsDir, { recursive: true });
140
+ writeFileSync(filePath, generate(), 'utf8');
141
+ }
137
142
  results.push({ name, action: 'created' });
138
143
  } else {
139
144
  // File exists but no version marker — user-managed, don't touch
@@ -0,0 +1,11 @@
1
+ // Canonical list of CLI verbs the dispatcher in bin/dotmd.mjs handles.
2
+ // Source of truth for both the unknown-command suggester and the self-check
3
+ // that asserts every `dotmd <verb>` reference in generated slash-command
4
+ // templates points at a real command.
5
+ export const KNOWN_COMMANDS = [
6
+ 'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'briefing', 'context', 'hud',
7
+ 'focus', 'query', 'plans', 'prompts', 'stale', 'actionable', 'index', 'pickup', 'release', 'finish', 'status', 'archive', 'bulk', 'touch', 'doctor',
8
+ 'unblocks', 'health', 'glossary',
9
+ 'fix-refs', 'lint', 'rename', 'migrate', 'notion', 'export', 'summary',
10
+ 'watch', 'diff', 'new', 'init', 'completions', 'statuses',
11
+ ];
package/src/config.mjs CHANGED
@@ -84,8 +84,12 @@ const DEFAULTS = {
84
84
  },
85
85
 
86
86
  referenceFields: {
87
- bidirectional: [],
88
- unidirectional: [],
87
+ // Defaults match the fields the built-in `plan`, `doc`, and `prompt`
88
+ // templates scaffold, so graph / deps / unblocks / pickup's Related:
89
+ // resolver work without config. Override by setting `referenceFields`
90
+ // in your config — your value fully replaces (no per-field merge).
91
+ bidirectional: ['related_plans', 'related_docs'],
92
+ unidirectional: ['parent_plan'],
89
93
  },
90
94
 
91
95
  templates: {},
package/src/init.mjs CHANGED
@@ -21,6 +21,14 @@ export const index = {
21
21
  endMarker: '<!-- GENERATED:dotmd:end -->',
22
22
  archivedLimit: 8,
23
23
  };
24
+
25
+ // Frontmatter fields graph / deps / unblocks / pickup's Related: resolver
26
+ // traverse. Defaults match what the built-in plan/doc/prompt templates scaffold.
27
+ // Add field names here (and to your templates) to track more relationships.
28
+ export const referenceFields = {
29
+ bidirectional: ['related_plans', 'related_docs'],
30
+ unidirectional: ['parent_plan'],
31
+ };
24
32
  `;
25
33
 
26
34
  const STARTER_INDEX = `# Docs
@@ -143,32 +151,38 @@ function generateDetectedConfig(scan, rootPath) {
143
151
  return lines.join('\n');
144
152
  }
145
153
 
146
- export function runInit(cwd, config) {
154
+ export function runInit(cwd, config, opts = {}) {
155
+ const { dryRun = false } = opts;
147
156
  const configPath = path.join(cwd, 'dotmd.config.mjs');
148
157
  const docsDir = path.join(cwd, 'docs');
149
158
  const indexPath = path.join(docsDir, 'docs.md');
150
159
 
160
+ // Prefix every reported line during dry-run so the user can't mistake the
161
+ // preview for a real run. Without this, every write below would silently
162
+ // execute — runInit previously ignored the `--dry-run` flag entirely.
163
+ const dryTag = dryRun ? `${dim('[dry-run]')} ` : '';
164
+
151
165
  process.stdout.write('\n');
152
166
 
153
167
  const scan = existsSync(docsDir) ? scanExistingDocs(docsDir) : null;
154
168
 
155
169
  if (existsSync(configPath)) {
156
- process.stdout.write(` ${dim('exists')} dotmd.config.mjs\n`);
170
+ process.stdout.write(` ${dryTag}${dim('exists')} dotmd.config.mjs\n`);
157
171
  } else {
158
172
  if (scan && scan.docCount > 0) {
159
- writeFileSync(configPath, generateDetectedConfig(scan, 'docs'), 'utf8');
160
- process.stdout.write(` ${green('create')} dotmd.config.mjs (detected ${scan.docCount} docs)\n`);
173
+ if (!dryRun) writeFileSync(configPath, generateDetectedConfig(scan, 'docs'), 'utf8');
174
+ process.stdout.write(` ${dryTag}${green('create')} dotmd.config.mjs (detected ${scan.docCount} docs)\n`);
161
175
  } else {
162
- writeFileSync(configPath, STARTER_CONFIG, 'utf8');
163
- process.stdout.write(` ${green('create')} dotmd.config.mjs\n`);
176
+ if (!dryRun) writeFileSync(configPath, STARTER_CONFIG, 'utf8');
177
+ process.stdout.write(` ${dryTag}${green('create')} dotmd.config.mjs\n`);
164
178
  }
165
179
  }
166
180
 
167
181
  if (existsSync(docsDir)) {
168
- process.stdout.write(` ${dim('exists')} docs/\n`);
182
+ process.stdout.write(` ${dryTag}${dim('exists')} docs/\n`);
169
183
  } else {
170
- mkdirSync(docsDir, { recursive: true });
171
- process.stdout.write(` ${green('create')} docs/\n`);
184
+ if (!dryRun) mkdirSync(docsDir, { recursive: true });
185
+ process.stdout.write(` ${dryTag}${green('create')} docs/\n`);
172
186
  }
173
187
 
174
188
  // Inspect root-level siblings (e.g. ./plans/, ./prompts/) before scaffolding.
@@ -192,25 +206,25 @@ export function runInit(cwd, config) {
192
206
  const counts = scan?.subdirCounts?.[sub];
193
207
  const total = counts ? counts.withFrontmatter + counts.withoutFrontmatter : 0;
194
208
  if (siblingSet.has(sub) && !existsSync(subPath)) {
195
- process.stdout.write(` ${yellow('skip')} docs/${sub}/ (root-level ./${sub}/ already holds content)\n`);
209
+ process.stdout.write(` ${dryTag}${yellow('skip')} docs/${sub}/ (root-level ./${sub}/ already holds content)\n`);
196
210
  continue;
197
211
  }
198
212
  if (existsSync(subPath)) {
199
213
  const detail = total > 0
200
214
  ? ` (${counts.withFrontmatter} dotmd-tracked, ${counts.withoutFrontmatter} plain .md)`
201
215
  : '';
202
- process.stdout.write(` ${dim('exists')} docs/${sub}/${detail}\n`);
216
+ process.stdout.write(` ${dryTag}${dim('exists')} docs/${sub}/${detail}\n`);
203
217
  } else {
204
- mkdirSync(subPath, { recursive: true });
205
- process.stdout.write(` ${green('create')} docs/${sub}/\n`);
218
+ if (!dryRun) mkdirSync(subPath, { recursive: true });
219
+ process.stdout.write(` ${dryTag}${green('create')} docs/${sub}/\n`);
206
220
  }
207
221
  }
208
222
 
209
223
  if (existsSync(indexPath)) {
210
- process.stdout.write(` ${dim('exists')} docs/docs.md\n`);
224
+ process.stdout.write(` ${dryTag}${dim('exists')} docs/docs.md\n`);
211
225
  } else {
212
- writeFileSync(indexPath, STARTER_INDEX, 'utf8');
213
- process.stdout.write(` ${green('create')} docs/docs.md\n`);
226
+ if (!dryRun) writeFileSync(indexPath, STARTER_INDEX, 'utf8');
227
+ process.stdout.write(` ${dryTag}${green('create')} docs/docs.md\n`);
214
228
  }
215
229
 
216
230
  if (siblingsWithContent.length > 0) {
@@ -235,24 +249,32 @@ export function runInit(cwd, config) {
235
249
  const has = current.split('\n').some(l => l.trim() === ignoreLine || l.trim() === '.dotmd');
236
250
  if (!has) {
237
251
  const sep = current.endsWith('\n') ? '' : '\n';
238
- writeFileSync(gitignorePath, `${current}${sep}${ignoreLine}\n`, 'utf8');
239
- process.stdout.write(` ${green('update')} .gitignore (+${ignoreLine})\n`);
252
+ if (!dryRun) writeFileSync(gitignorePath, `${current}${sep}${ignoreLine}\n`, 'utf8');
253
+ process.stdout.write(` ${dryTag}${green('update')} .gitignore (+${ignoreLine})\n`);
240
254
  } else {
241
- process.stdout.write(` ${dim('exists')} .gitignore\n`);
255
+ process.stdout.write(` ${dryTag}${dim('exists')} .gitignore\n`);
242
256
  }
243
257
  } else {
244
- writeFileSync(gitignorePath, `${ignoreLine}\n`, 'utf8');
245
- process.stdout.write(` ${green('create')} .gitignore\n`);
258
+ if (!dryRun) writeFileSync(gitignorePath, `${ignoreLine}\n`, 'utf8');
259
+ process.stdout.write(` ${dryTag}${green('create')} .gitignore\n`);
246
260
  }
247
261
 
248
- // Claude Code integration — auto-detect .claude/ directory
262
+ // Claude Code integration — auto-detect .claude/ directory.
263
+ // Reports all four scaffold outcomes so the user can't be surprised by
264
+ // either a silent regenerate (pre-fix: `updated` was unreported) or by
265
+ // dotmd skipping a user-managed file (pre-fix: `skipped` was unreported).
249
266
  if (config) {
250
- const results = scaffoldClaudeCommands(cwd, config);
267
+ const results = scaffoldClaudeCommands(cwd, config, { dryRun });
251
268
  for (const r of results) {
269
+ const filename = `.claude/commands/${r.name}`;
252
270
  if (r.action === 'created') {
253
- process.stdout.write(` ${green('create')} .claude/commands/${r.name}\n`);
271
+ process.stdout.write(` ${dryTag}${green('create')} ${filename}\n`);
272
+ } else if (r.action === 'updated') {
273
+ process.stdout.write(` ${dryTag}${green('update')} ${filename} (v${r.from} → v${r.to})\n`);
254
274
  } else if (r.action === 'current') {
255
- process.stdout.write(` ${dim('current')} .claude/commands/${r.name}\n`);
275
+ process.stdout.write(` ${dryTag}${dim('exists')} ${filename}\n`);
276
+ } else if (r.action === 'skipped') {
277
+ process.stdout.write(` ${dryTag}${yellow('skip')} ${filename} (no version marker — user-managed)\n`);
256
278
  }
257
279
  }
258
280
  }
package/src/lifecycle.mjs CHANGED
@@ -24,6 +24,21 @@ function findFileRoot(filePath, config) {
24
24
  return roots.find(r => filePath.startsWith(r + '/')) ?? config.docsRoot;
25
25
  }
26
26
 
27
+ // Best-effort index regen for any doc-set or doc-status mutation. The
28
+ // generated block groups by status and embeds per-doc snapshots, so any
29
+ // change that affects what would render leaves the index stale. Wrapped
30
+ // in try/catch — a regen failure shouldn't undo the successful mutation,
31
+ // only warn with the recovery command.
32
+ export function regenIndex(config) {
33
+ if (!config.indexPath) return;
34
+ try {
35
+ const index = buildIndex(config);
36
+ writeIndex(renderIndexFile(index, config), config);
37
+ } catch (err) {
38
+ warn(`Could not regenerate index (run \`dotmd index --write\`): ${err.message}`);
39
+ }
40
+ }
41
+
27
42
  // Pick an archive destination that won't clobber an existing record. If
28
43
  // `<dir>/<basename>` is free, returns it unchanged; otherwise appends a UTC
29
44
  // timestamp (and a counter on the vanishingly rare same-second collision) so
@@ -142,10 +157,10 @@ export async function runStatus(argv, config, opts = {}) {
142
157
  finalPath = targetPath;
143
158
  }
144
159
 
145
- if ((isArchiving || isUnarchiving) && config.indexPath) {
146
- const index = buildIndex(config);
147
- writeIndex(renderIndexFile(index, config), config);
148
- }
160
+ // Regen the index on every status change — `active → planned` etc. drift
161
+ // the per-status sections just as much as archive crossings. Archive paths
162
+ // also benefit (replaces the previously-gated regen).
163
+ regenIndex(config);
149
164
 
150
165
  process.stdout.write(`${green(toRepoPath(finalPath, config.repoRoot))}: ${oldStatus ?? 'unknown'} → ${newStatus}\n`);
151
166
 
@@ -230,6 +245,7 @@ export async function runPickup(argv, config, opts = {}) {
230
245
  }
231
246
  if (oldStatus !== 'in-session') {
232
247
  updateFrontmatter(filePath, { status: 'in-session', updated: today });
248
+ regenIndex(config);
233
249
  }
234
250
  // VH append per lease outcome:
235
251
  // acquired → `Picked up (<old> → in-session).`
@@ -336,6 +352,7 @@ export async function runUnpickup(argv, config, opts = {}) {
336
352
  const today = nowIso();
337
353
  updateFrontmatter(filePath, { status: newStatus, updated: today });
338
354
  appendVersionHistory(filePath, `Released (in-session → ${newStatus}).`);
355
+ regenIndex(config);
339
356
  }
340
357
  // If frontmatter is no longer in-session (manual flip), leave it alone.
341
358
  } catch (err) {
@@ -450,6 +467,7 @@ export async function runFinish(argv, config, opts = {}) {
450
467
  process.stderr.write(`${dim('[dry-run]')} Would update: status: in-session → ${targetStatus}, updated: ${today}\n`);
451
468
  } else {
452
469
  updateFrontmatter(filePath, { status: targetStatus, updated: today });
470
+ regenIndex(config);
453
471
  }
454
472
 
455
473
  if (json) {
@@ -517,10 +535,7 @@ export function runArchive(argv, config, opts = {}) {
517
535
  // Auto-update references in other docs
518
536
  const updatedRefCount = updateRefsAfterMove(filePath, targetPath, config);
519
537
 
520
- if (config.indexPath) {
521
- const index = buildIndex(config);
522
- writeIndex(renderIndexFile(index, config), config);
523
- }
538
+ regenIndex(config);
524
539
 
525
540
  out.write(`${green('Archived')}: ${oldRepoPath} → ${newRepoPath}\n`);
526
541
  if (selfRefsFixed) out.write('Updated references in archived file.\n');
package/src/new.mjs CHANGED
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
4
4
  import { toRepoPath, die, warn, nowIso } from './util.mjs';
5
5
  import { green, dim, bold } from './color.mjs';
6
6
  import { isInteractive, promptText } from './prompt.mjs';
7
+ import { regenIndex } from './lifecycle.mjs';
7
8
 
8
9
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
10
  const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
@@ -133,6 +134,7 @@ Status markers (put in heading text):
133
134
  'type: prompt',
134
135
  `status: ${s}`,
135
136
  `created: ${d}`,
137
+ `updated: ${d}`,
136
138
  `dotmd_version: ${pkg.version}`,
137
139
  `context: ${ctx?.title ? `"${ctx.title.replace(/"/g, '\\"')}"` : ''}`,
138
140
  'related_plans:',
@@ -328,6 +330,8 @@ export async function runNew(argv, config, opts = {}) {
328
330
  writeFileSync(filePath, content, 'utf8');
329
331
  process.stdout.write(`${green('Created')}: ${repoPath} ${dim(`(${typeName})`)}\n`);
330
332
 
333
+ regenIndex(config);
334
+
331
335
  try { config.hooks.onNew?.({ path: repoPath, status, title: docTitle, type: typeName }); } catch (err) { warn(`Hook 'onNew' threw: ${err.message}`); }
332
336
  }
333
337
 
package/src/rename.mjs CHANGED
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { toRepoPath, resolveDocPath, die, warn } from './util.mjs';
4
4
  import { migrateLease } from './lease.mjs';
5
5
  import { collectDocFiles } from './index.mjs';
6
+ import { regenIndex } from './lifecycle.mjs';
6
7
  import { gitMv } from './git.mjs';
7
8
  import { green, dim } from './color.mjs';
8
9
  import { isInteractive, promptText } from './prompt.mjs';
@@ -101,6 +102,8 @@ export async function runRename(argv, config, opts = {}) {
101
102
 
102
103
  try { migrateLease(config, oldRepoPath, newRepoPath); } catch (err) { warn(`Could not migrate lease ${oldRepoPath} → ${newRepoPath}: ${err.message}`); }
103
104
 
105
+ regenIndex(config);
106
+
104
107
  process.stdout.write(`${green('Renamed')}: ${oldRepoPath} → ${newRepoPath}\n`);
105
108
  if (updatedCount > 0) {
106
109
  process.stdout.write(`Updated references in ${updatedCount} file(s).\n`);
package/src/validate.mjs CHANGED
@@ -114,11 +114,16 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
114
114
  }
115
115
  }
116
116
 
117
- if (!headingTitle && !asString(frontmatter.title)) {
117
+ // Prompts are intentionally body-only one-shot artifacts: the slug names the
118
+ // prompt, the body IS the payload. Forcing a `title` or blockquote `summary`
119
+ // is friction the prompt format was designed around.
120
+ const skipTitleSummary = doc.type === 'prompt';
121
+
122
+ if (!skipTitleSummary && !headingTitle && !asString(frontmatter.title)) {
118
123
  doc.warnings.push({ path: doc.path, level: 'warning', message: 'Missing `title` and no H1 found for fallback.' });
119
124
  }
120
125
 
121
- if (!config.lifecycle.skipWarningsFor.has(doc.status) && !asString(frontmatter.summary) && !doc.summary) {
126
+ if (!skipTitleSummary && !config.lifecycle.skipWarningsFor.has(doc.status) && !asString(frontmatter.summary) && !doc.summary) {
122
127
  doc.warnings.push({ path: doc.path, level: 'warning', message: 'Missing `summary` and no blockquote fallback found.' });
123
128
  }
124
129