dotmd-cli 0.31.1 → 0.31.3

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
@@ -690,10 +690,11 @@ async function main() {
690
690
 
691
691
  const config = await resolveConfig(process.cwd(), explicitConfig);
692
692
 
693
- // Init — now has access to config for Claude command generation
693
+ // Init — runInit re-resolves the config from disk internally (after any
694
+ // starter-config write), so we don't need to pre-pass it.
694
695
  if (command === 'init') {
695
696
  const { runInit } = await import('../src/init.mjs');
696
- runInit(process.cwd(), config.configFound ? config : null);
697
+ await runInit(process.cwd(), config, { dryRun });
697
698
  return;
698
699
  }
699
700
 
@@ -1036,14 +1037,8 @@ async function main() {
1036
1037
  }
1037
1038
 
1038
1039
  // 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
1040
+ const { KNOWN_COMMANDS } = await import('../src/commands.mjs');
1041
+ const matches = KNOWN_COMMANDS
1047
1042
  .map(c => ({ cmd: c, dist: levenshtein(command, c) }))
1048
1043
  .sort((a, b) => a.dist - b.dist);
1049
1044
  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.3",
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/doctor.mjs CHANGED
@@ -67,27 +67,39 @@ export function runDoctor(argv, config, opts = {}) {
67
67
  process.stdout.write('\n' + bold('3. Syncing dates from git...') + '\n');
68
68
  runTouch(['--git'], config, { dryRun });
69
69
 
70
- // Step 4: Regenerate index
71
- if (config.indexPath) {
72
- process.stdout.write('\n' + bold('4. Regenerating index...') + '\n');
73
- if (!dryRun) {
74
- const index = buildIndex(config);
75
- writeIndex(renderIndexFile(index, config), config);
76
- process.stdout.write('Index updated.\n');
77
- } else {
78
- process.stdout.write('[dry-run] Would regenerate index.\n');
79
- }
70
+ // Step 4: Regenerate index. Heading always prints so the numbering stays
71
+ // `1,2,3,4,5,6` even when `index.path` isn't configured — pre-fix this was
72
+ // gated on `config.indexPath`, producing `1,2,3,5,6` on repos with no index.
73
+ process.stdout.write('\n' + bold('4. Regenerating index...') + '\n');
74
+ if (!config.indexPath) {
75
+ process.stdout.write('No index path configured (skip).\n');
76
+ } else if (dryRun) {
77
+ process.stdout.write('[dry-run] Would regenerate index.\n');
78
+ } else {
79
+ const index = buildIndex(config);
80
+ writeIndex(renderIndexFile(index, config), config);
81
+ process.stdout.write('Index updated.\n');
80
82
  }
81
83
 
82
- // Step 5: Refresh Claude Code commands
83
- const claudeResults = dryRun ? [] : scaffoldClaudeCommands(config.repoRoot, config);
84
- if (claudeResults.some(r => r.action !== 'current' && r.action !== 'skipped')) {
85
- process.stdout.write('\n' + bold('5. Claude Code commands:') + '\n');
86
- for (const r of claudeResults) {
87
- if (r.action === 'updated') {
88
- process.stdout.write(`${green('Updated')} .claude/commands/${r.name} (v${r.from} → v${r.to})\n`);
89
- } else if (r.action === 'created') {
90
- process.stdout.write(`${green('Created')} .claude/commands/${r.name}\n`);
84
+ // Step 5: Refresh Claude Code commands. Always print the heading so the
85
+ // numbering stays `1,2,3,4,5,6` pre-fix it was conditional, so a doctor
86
+ // run where everything was already current printed `1,2,3,4,6` with `5.`
87
+ // silently missing.
88
+ process.stdout.write('\n' + bold('5. Claude Code commands:') + '\n');
89
+ if (dryRun) {
90
+ process.stdout.write('[dry-run] Would refresh .claude/commands/ if outdated.\n');
91
+ } else {
92
+ const claudeResults = scaffoldClaudeCommands(config.repoRoot, config);
93
+ const changes = claudeResults.filter(r => r.action === 'updated' || r.action === 'created');
94
+ if (changes.length === 0) {
95
+ process.stdout.write('Nothing to refresh.\n');
96
+ } else {
97
+ for (const r of changes) {
98
+ if (r.action === 'updated') {
99
+ process.stdout.write(`${green('Updated')} .claude/commands/${r.name} (v${r.from} → v${r.to})\n`);
100
+ } else if (r.action === 'created') {
101
+ process.stdout.write(`${green('Created')} .claude/commands/${r.name}\n`);
102
+ }
91
103
  }
92
104
  }
93
105
  }
package/src/init.mjs CHANGED
@@ -4,6 +4,7 @@ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
4
  import { green, dim, yellow } from './color.mjs';
5
5
  import { warn } from './util.mjs';
6
6
  import { scaffoldClaudeCommands } from './claude-commands.mjs';
7
+ import { resolveConfig } from './config.mjs';
7
8
 
8
9
  // Subdirectories scaffolded under docsRoot and tracked separately during scans.
9
10
  // Each maps to a builtin type (plan, prompt). New types added here should also
@@ -21,6 +22,14 @@ export const index = {
21
22
  endMarker: '<!-- GENERATED:dotmd:end -->',
22
23
  archivedLimit: 8,
23
24
  };
25
+
26
+ // Frontmatter fields graph / deps / unblocks / pickup's Related: resolver
27
+ // traverse. Defaults match what the built-in plan/doc/prompt templates scaffold.
28
+ // Add field names here (and to your templates) to track more relationships.
29
+ export const referenceFields = {
30
+ bidirectional: ['related_plans', 'related_docs'],
31
+ unidirectional: ['parent_plan'],
32
+ };
24
33
  `;
25
34
 
26
35
  const STARTER_INDEX = `# Docs
@@ -143,32 +152,38 @@ function generateDetectedConfig(scan, rootPath) {
143
152
  return lines.join('\n');
144
153
  }
145
154
 
146
- export function runInit(cwd, config) {
155
+ export async function runInit(cwd, config, opts = {}) {
156
+ const { dryRun = false } = opts;
147
157
  const configPath = path.join(cwd, 'dotmd.config.mjs');
148
158
  const docsDir = path.join(cwd, 'docs');
149
159
  const indexPath = path.join(docsDir, 'docs.md');
150
160
 
161
+ // Prefix every reported line during dry-run so the user can't mistake the
162
+ // preview for a real run. Without this, every write below would silently
163
+ // execute — runInit previously ignored the `--dry-run` flag entirely.
164
+ const dryTag = dryRun ? `${dim('[dry-run]')} ` : '';
165
+
151
166
  process.stdout.write('\n');
152
167
 
153
168
  const scan = existsSync(docsDir) ? scanExistingDocs(docsDir) : null;
154
169
 
155
170
  if (existsSync(configPath)) {
156
- process.stdout.write(` ${dim('exists')} dotmd.config.mjs\n`);
171
+ process.stdout.write(` ${dryTag}${dim('exists')} dotmd.config.mjs\n`);
157
172
  } else {
158
173
  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`);
174
+ if (!dryRun) writeFileSync(configPath, generateDetectedConfig(scan, 'docs'), 'utf8');
175
+ process.stdout.write(` ${dryTag}${green('create')} dotmd.config.mjs (detected ${scan.docCount} docs)\n`);
161
176
  } else {
162
- writeFileSync(configPath, STARTER_CONFIG, 'utf8');
163
- process.stdout.write(` ${green('create')} dotmd.config.mjs\n`);
177
+ if (!dryRun) writeFileSync(configPath, STARTER_CONFIG, 'utf8');
178
+ process.stdout.write(` ${dryTag}${green('create')} dotmd.config.mjs\n`);
164
179
  }
165
180
  }
166
181
 
167
182
  if (existsSync(docsDir)) {
168
- process.stdout.write(` ${dim('exists')} docs/\n`);
183
+ process.stdout.write(` ${dryTag}${dim('exists')} docs/\n`);
169
184
  } else {
170
- mkdirSync(docsDir, { recursive: true });
171
- process.stdout.write(` ${green('create')} docs/\n`);
185
+ if (!dryRun) mkdirSync(docsDir, { recursive: true });
186
+ process.stdout.write(` ${dryTag}${green('create')} docs/\n`);
172
187
  }
173
188
 
174
189
  // Inspect root-level siblings (e.g. ./plans/, ./prompts/) before scaffolding.
@@ -192,25 +207,25 @@ export function runInit(cwd, config) {
192
207
  const counts = scan?.subdirCounts?.[sub];
193
208
  const total = counts ? counts.withFrontmatter + counts.withoutFrontmatter : 0;
194
209
  if (siblingSet.has(sub) && !existsSync(subPath)) {
195
- process.stdout.write(` ${yellow('skip')} docs/${sub}/ (root-level ./${sub}/ already holds content)\n`);
210
+ process.stdout.write(` ${dryTag}${yellow('skip')} docs/${sub}/ (root-level ./${sub}/ already holds content)\n`);
196
211
  continue;
197
212
  }
198
213
  if (existsSync(subPath)) {
199
214
  const detail = total > 0
200
215
  ? ` (${counts.withFrontmatter} dotmd-tracked, ${counts.withoutFrontmatter} plain .md)`
201
216
  : '';
202
- process.stdout.write(` ${dim('exists')} docs/${sub}/${detail}\n`);
217
+ process.stdout.write(` ${dryTag}${dim('exists')} docs/${sub}/${detail}\n`);
203
218
  } else {
204
- mkdirSync(subPath, { recursive: true });
205
- process.stdout.write(` ${green('create')} docs/${sub}/\n`);
219
+ if (!dryRun) mkdirSync(subPath, { recursive: true });
220
+ process.stdout.write(` ${dryTag}${green('create')} docs/${sub}/\n`);
206
221
  }
207
222
  }
208
223
 
209
224
  if (existsSync(indexPath)) {
210
- process.stdout.write(` ${dim('exists')} docs/docs.md\n`);
225
+ process.stdout.write(` ${dryTag}${dim('exists')} docs/docs.md\n`);
211
226
  } else {
212
- writeFileSync(indexPath, STARTER_INDEX, 'utf8');
213
- process.stdout.write(` ${green('create')} docs/docs.md\n`);
227
+ if (!dryRun) writeFileSync(indexPath, STARTER_INDEX, 'utf8');
228
+ process.stdout.write(` ${dryTag}${green('create')} docs/docs.md\n`);
214
229
  }
215
230
 
216
231
  if (siblingsWithContent.length > 0) {
@@ -235,24 +250,41 @@ export function runInit(cwd, config) {
235
250
  const has = current.split('\n').some(l => l.trim() === ignoreLine || l.trim() === '.dotmd');
236
251
  if (!has) {
237
252
  const sep = current.endsWith('\n') ? '' : '\n';
238
- writeFileSync(gitignorePath, `${current}${sep}${ignoreLine}\n`, 'utf8');
239
- process.stdout.write(` ${green('update')} .gitignore (+${ignoreLine})\n`);
253
+ if (!dryRun) writeFileSync(gitignorePath, `${current}${sep}${ignoreLine}\n`, 'utf8');
254
+ process.stdout.write(` ${dryTag}${green('update')} .gitignore (+${ignoreLine})\n`);
240
255
  } else {
241
- process.stdout.write(` ${dim('exists')} .gitignore\n`);
256
+ process.stdout.write(` ${dryTag}${dim('exists')} .gitignore\n`);
242
257
  }
243
258
  } else {
244
- writeFileSync(gitignorePath, `${ignoreLine}\n`, 'utf8');
245
- process.stdout.write(` ${green('create')} .gitignore\n`);
259
+ if (!dryRun) writeFileSync(gitignorePath, `${ignoreLine}\n`, 'utf8');
260
+ process.stdout.write(` ${dryTag}${green('create')} .gitignore\n`);
246
261
  }
247
262
 
248
- // Claude Code integration — auto-detect .claude/ directory
249
- if (config) {
250
- const results = scaffoldClaudeCommands(cwd, config);
263
+ // Claude Code integration — auto-detect .claude/ directory.
264
+ // Re-resolve config so the scaffold sees whatever we (may have) just written.
265
+ // Pre-fix: the dispatcher passed `null` to runInit on a fresh repo because
266
+ // resolveConfig was called before init wrote the starter, so the `if (config)`
267
+ // gate below silently skipped slash-command scaffolding entirely on first init.
268
+ // Re-resolving picks up STARTER_CONFIG (or any pre-existing config) in the
269
+ // real-run path; in dry-run with no on-disk config, it returns the merged
270
+ // DEFAULTS, which is enough for the preview line (`would create…`).
271
+ //
272
+ // Reports all four scaffold outcomes so the user can't be surprised by
273
+ // either a silent regenerate (pre-fix: `updated` was unreported) or by
274
+ // dotmd skipping a user-managed file (pre-fix: `skipped` was unreported).
275
+ const scaffoldConfig = await resolveConfig(cwd);
276
+ if (scaffoldConfig) {
277
+ const results = scaffoldClaudeCommands(cwd, scaffoldConfig, { dryRun });
251
278
  for (const r of results) {
279
+ const filename = `.claude/commands/${r.name}`;
252
280
  if (r.action === 'created') {
253
- process.stdout.write(` ${green('create')} .claude/commands/${r.name}\n`);
281
+ process.stdout.write(` ${dryTag}${green('create')} ${filename}\n`);
282
+ } else if (r.action === 'updated') {
283
+ process.stdout.write(` ${dryTag}${green('update')} ${filename} (v${r.from} → v${r.to})\n`);
254
284
  } else if (r.action === 'current') {
255
- process.stdout.write(` ${dim('current')} .claude/commands/${r.name}\n`);
285
+ process.stdout.write(` ${dryTag}${dim('exists')} ${filename}\n`);
286
+ } else if (r.action === 'skipped') {
287
+ process.stdout.write(` ${dryTag}${yellow('skip')} ${filename} (no version marker — user-managed)\n`);
256
288
  }
257
289
  }
258
290
  }
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
 
@@ -1,7 +1,7 @@
1
1
  import { readFileSync, existsSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
- import { asString, toRepoPath, resolveDocPath } from './util.mjs';
4
+ import { asString, toRepoPath, resolveDocPath, resolveRefPath } from './util.mjs';
5
5
  import { walkSections, findSection, findActivePhase, summarizePhases, isPhaseHeading, detectMarker } from './section.mjs';
6
6
  import { dim, green } from './color.mjs';
7
7
 
@@ -29,7 +29,14 @@ function statusSummary(counts) {
29
29
  return order.filter(k => counts[k]).map(k => `${counts[k]}${icons[k]}`).join(' ');
30
30
  }
31
31
 
32
- function readRelatedSummary(rawList, config) {
32
+ // `docDir` is the directory of the doc whose frontmatter we're reading.
33
+ // Pre-fix: this used `resolveDocPath` which only tries repo-root and docsRoots-
34
+ // relative, NOT doc-relative — so a bare basename like `sibling-plan.md` written
35
+ // in `docs/plans/foo.md`'s `related_plans:` always showed `(missing)`, even
36
+ // though graph/validate resolve the same ref fine via `resolveRefPath`. Now
37
+ // matches graph's resolver semantics: doc-relative first, then repo-relative;
38
+ // docsRoots-relative is kept as a final fallback for legacy refs.
39
+ function readRelatedSummary(rawList, config, docDir) {
33
40
  const list = Array.isArray(rawList) ? rawList : (typeof rawList === 'string' && rawList.trim() ? [rawList] : []);
34
41
  const out = [];
35
42
  for (const ref of list) {
@@ -37,7 +44,10 @@ function readRelatedSummary(rawList, config) {
37
44
  const refStr = String(ref).trim();
38
45
  if (!refStr) continue;
39
46
  let abs = null;
40
- try { abs = resolveDocPath(refStr, config); } catch { abs = null; }
47
+ try {
48
+ abs = resolveRefPath(refStr, docDir, config.repoRoot)
49
+ ?? resolveDocPath(refStr, config);
50
+ } catch { abs = null; }
41
51
  if (!abs || !existsSync(abs)) {
42
52
  out.push({ ref: refStr, status: null, missing: true });
43
53
  continue;
@@ -96,10 +106,13 @@ export function buildCard(filePath, raw, config) {
96
106
  const currentState = truncate(cleanInline(fm.current_state), CAPS.currentState);
97
107
  const nextStep = truncate(cleanInline(fm.next_step), CAPS.nextStep);
98
108
 
99
- // Related plans (compressed: slug + status only — show all, don't cap count)
109
+ // Related plans (compressed: slug + status only — show all, don't cap count).
110
+ // docDir lets the resolver try same-dir basenames first — graph/validate do this
111
+ // already; pickup-card now matches.
112
+ const docDir = path.dirname(filePath);
100
113
  const related = [
101
- ...readRelatedSummary(fm.parent_plan, config).map(r => ({ ...r, kind: 'parent' })),
102
- ...readRelatedSummary(fm.related_plans, config).map(r => ({ ...r, kind: 'related' })),
114
+ ...readRelatedSummary(fm.parent_plan, config, docDir).map(r => ({ ...r, kind: 'parent' })),
115
+ ...readRelatedSummary(fm.related_plans, config, docDir).map(r => ({ ...r, kind: 'related' })),
103
116
  ];
104
117
 
105
118
  // Phases summary + active phase (pointer only, no body)
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/render.mjs CHANGED
@@ -297,7 +297,13 @@ export function renderBriefing(index, config) {
297
297
  if (parts.length) lines.push(parts.join(' | '));
298
298
 
299
299
  const stale = index.docs.filter(d => d.isStale && !config.lifecycle.skipStaleFor.has(d.status)).length;
300
- lines.push(`Stale: ${stale} | Errors: ${index.errors.length} | Warnings: ${index.warnings.length}`);
300
+ // Append a hint when errors are present — otherwise the user sees `Errors: 1`
301
+ // with no clue what or where. `dotmd check` is the canonical detail view.
302
+ const errorCount = index.errors.length;
303
+ const errorPart = errorCount > 0
304
+ ? `Errors: ${errorCount} ${dim('(run `dotmd check` to see)')}`
305
+ : `Errors: ${errorCount}`;
306
+ lines.push(`Stale: ${stale} | ${errorPart} | Warnings: ${index.warnings.length}`);
301
307
 
302
308
  try {
303
309
  const staleLeases = findStaleLeases(config);
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