dotmd-cli 0.32.0 → 0.33.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.32.0",
3
+ "version": "0.33.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/graph.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path';
2
- import { toSlug, toRepoPath, warn } from './util.mjs';
2
+ import { toSlug, toRepoPath, warn, resolveRefPath } from './util.mjs';
3
3
  import { bold, red, green, dim } from './color.mjs';
4
4
 
5
5
  const STATUS_COLORS = {
@@ -60,7 +60,7 @@ export function buildGraph(index, config, filters = {}) {
60
60
 
61
61
  for (const field of allRefFields) {
62
62
  for (const relPath of (doc.refFields[field] || [])) {
63
- const resolved = path.resolve(docDir, relPath);
63
+ const resolved = resolveRefPath(relPath, docDir, config.repoRoot) ?? path.resolve(docDir, relPath);
64
64
  const targetPath = toRepoPath(resolved, config.repoRoot);
65
65
  const edgeKey = `${doc.path}|${targetPath}|${field}`;
66
66
  if (edgeKeys.has(edgeKey)) continue;
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/lifecycle.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter, replaceFrontmatter } from './frontmatter.mjs';
4
- import { asString, toRepoPath, die, warn, resolveDocPath, escapeRegex, nowIso } from './util.mjs';
4
+ import { asString, toRepoPath, die, warn, resolveDocPath, resolveRefPath, escapeRegex, nowIso } from './util.mjs';
5
5
  import { gitMv, getGitLastModified, getGitLastModifiedBatch } from './git.mjs';
6
6
  import { buildIndex, collectDocFiles } from './index.mjs';
7
7
  import { renderIndexFile, writeIndex } from './index-file.mjs';
@@ -717,12 +717,17 @@ function updateRefsFromMovedFile(oldPath, newPath, config) {
717
717
  let raw = readFileSync(newPath, 'utf8');
718
718
  const { frontmatter, body } = extractFrontmatter(raw);
719
719
 
720
- // Fix frontmatter ref fields (YAML list items like - ./path.md)
720
+ // Fix frontmatter ref fields (YAML list items like - ./path.md).
721
+ // Resolve doc-relative first, then repo-root-relative — so a ref like
722
+ // `docs/foo/bar.md` written from any nesting level gets rewritten correctly
723
+ // when the source moves. Without the repo-root fallback, repo-relative refs
724
+ // silently skipped rewriting (existsSync on the doubled doc-relative path
725
+ // returned false).
721
726
  let newFm = frontmatter;
722
727
  const refRegex = /^(\s+-\s+)(\S+\.md)$/gm;
723
728
  newFm = newFm.replace(refRegex, (match, prefix, refPath) => {
724
- const absTarget = path.resolve(oldDir, refPath);
725
- if (!existsSync(absTarget)) return match;
729
+ const absTarget = resolveRefPath(refPath, oldDir, config.repoRoot);
730
+ if (!absTarget) return match;
726
731
  const newRelPath = path.relative(newDir, absTarget).split(path.sep).join('/');
727
732
  return `${prefix}${newRelPath}`;
728
733
  });
@@ -732,8 +737,8 @@ function updateRefsFromMovedFile(oldPath, newPath, config) {
732
737
  const linkRegex = /(\[[^\]]*\]\()([^)]+\.md)(\))/g;
733
738
  newBody = newBody.replace(linkRegex, (match, pre, href, post) => {
734
739
  if (href.startsWith('http')) return match;
735
- const absTarget = path.resolve(oldDir, href);
736
- if (!existsSync(absTarget)) return match;
740
+ const absTarget = resolveRefPath(href, oldDir, config.repoRoot);
741
+ if (!absTarget) return match;
737
742
  const newHref = path.relative(newDir, absTarget).split(path.sep).join('/');
738
743
  return `${pre}${newHref}${post}`;
739
744
  });
package/src/validate.mjs CHANGED
@@ -106,7 +106,7 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
106
106
  doc.errors.push({ path: doc.path, level: 'error', message: '`module` is required for active/ready/planned/blocked docs; use a real module, `platform`, or `none`. Accepts singular `module:` or plural `modules:` list.' });
107
107
  }
108
108
 
109
- if (config.validSurfaces) {
109
+ if (config.validSurfaces && !config.lifecycle.skipWarningsFor.has(doc.status)) {
110
110
  for (const surface of doc.surfaces) {
111
111
  if (!config.validSurfaces.has(surface)) {
112
112
  doc.warnings.push({ path: doc.path, level: 'warning', message: `Unknown surface \`${surface}\`; expected a known surface taxonomy value.` });
@@ -164,21 +164,28 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
164
164
  }
165
165
  }
166
166
 
167
- // Validate reference fields resolve to existing files
167
+ // Validate reference fields resolve to existing files. Terminal statuses
168
+ // (archived, deprecated, etc.) document historical state — their refs may
169
+ // legitimately point at moved/deleted targets and shouldn't gate the
170
+ // exit code with a hard error.
168
171
  const docDir = path.dirname(path.join(config.repoRoot, doc.path));
169
172
  const allRefFields = [...(config.referenceFields.bidirectional || []), ...(config.referenceFields.unidirectional || [])];
170
- for (const field of allRefFields) {
171
- for (const relPath of (doc.refFields[field] || [])) {
172
- if (!resolveRefPath(relPath, docDir, config.repoRoot)) {
173
- doc.errors.push({ path: doc.path, level: 'error', message: `${field} entry \`${relPath}\` does not resolve to an existing file.` });
173
+ const skipRefValidation = config.lifecycle.terminalStatuses.has(doc.status)
174
+ || config.lifecycle.skipWarningsFor.has(doc.status);
175
+ if (!skipRefValidation) {
176
+ for (const field of allRefFields) {
177
+ for (const relPath of (doc.refFields[field] || [])) {
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.` });
180
+ }
174
181
  }
175
182
  }
176
- }
177
183
 
178
- // Validate body links resolve to existing files
179
- for (const link of (doc.bodyLinks || [])) {
180
- if (!resolveRefPath(link.href, docDir, config.repoRoot)) {
181
- doc.warnings.push({ path: doc.path, level: 'warning', message: `body link \`${link.href}\` does not resolve to an existing file.` });
184
+ // Validate body links resolve to existing files
185
+ for (const link of (doc.bodyLinks || [])) {
186
+ 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.` });
188
+ }
182
189
  }
183
190
  }
184
191
  }
@@ -271,19 +278,24 @@ export function validatePlanShape(doc, body, frontmatter, config) {
271
278
  });
272
279
  }
273
280
 
274
- // 3. surface AND surfaces both populated
275
- if (frontmatter.surface && Array.isArray(frontmatter.surfaces) && frontmatter.surfaces.length > 0) {
281
+ // 3. surface AND surfaces both populated with DIVERGENT values. When the
282
+ // singular value is already a member of the plural array, src/index.mjs
283
+ // merges them transparently — warning would be noise. Only divergence
284
+ // actually risks data loss when readers consult one form vs. the other.
285
+ if (frontmatter.surface && Array.isArray(frontmatter.surfaces) && frontmatter.surfaces.length > 0
286
+ && !frontmatter.surfaces.includes(frontmatter.surface)) {
276
287
  doc.warnings.push({
277
288
  path: doc.path,
278
289
  level: 'warning',
279
- message: 'Both `surface` (singular) and `surfaces` (array) are set. Pick one — prefer `surfaces` array form.',
290
+ message: `Both \`surface\` (singular: \`${frontmatter.surface}\`) and \`surfaces\` (array) are set with different values. Pick one — prefer \`surfaces\` array form.`,
280
291
  });
281
292
  }
282
- if (frontmatter.module && Array.isArray(frontmatter.modules) && frontmatter.modules.length > 0) {
293
+ if (frontmatter.module && Array.isArray(frontmatter.modules) && frontmatter.modules.length > 0
294
+ && !frontmatter.modules.includes(frontmatter.module)) {
283
295
  doc.warnings.push({
284
296
  path: doc.path,
285
297
  level: 'warning',
286
- message: 'Both `module` (singular) and `modules` (array) are set. Pick one — prefer `modules` array form.',
298
+ message: `Both \`module\` (singular: \`${frontmatter.module}\`) and \`modules\` (array) are set with different values. Pick one — prefer \`modules\` array form.`,
287
299
  });
288
300
  }
289
301