dotmd-cli 0.41.0 → 0.42.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/bin/dotmd.mjs CHANGED
@@ -51,11 +51,11 @@ Lifecycle:
51
51
  pickup <file> [--takeover] Pick up a plan (set in-session + print body)
52
52
  release [<file>] [--to <s>] Release in-session lease (alias: unpickup)
53
53
  runlist <hub> [next] Show or walk an ordered group of plans (see \`dotmd help runlist\`)
54
- finish <file> [done|active] Finish a plan (set done or active)
55
- status <file> <status> Transition document status
54
+ status <file> <status> Transition document status (deprecated; prefer \`set\`)
56
55
  set <status> [<file>] Unified transition: archive/release/transition in one verb
57
56
  archive <file> Archive (status + move + update refs)
58
57
  bulk archive <f1> <f2> ... Archive multiple files at once
58
+ ship [patch|minor|major] Regen + commit + bump in one step (default: patch)
59
59
  bulk-tag [files...] Tag pre-existing untagged .md files
60
60
  touch <file> Bump updated date
61
61
  touch --git Bulk-sync dates from git history
@@ -139,10 +139,10 @@ plan statuses (each maps to a distinct unstuck-action)
139
139
 
140
140
  Canonical transitions:
141
141
  active → in-session \`dotmd pickup <file>\`
142
- in-session → active \`dotmd release <file>\`
143
- in-session → partial \`dotmd status <file> partial\` (+ release)
144
- in-session → awaiting \`dotmd status <file> awaiting\` (+ release)
145
- any → archived \`dotmd archive <file>\`
142
+ in-session → active \`dotmd set active\` (auto-releases lease)
143
+ in-session → partial \`dotmd set partial\` (auto-releases lease)
144
+ in-session → awaiting \`dotmd set awaiting\` (auto-releases lease)
145
+ any → archived \`dotmd set archived <file>\` (or \`dotmd archive\`)
146
146
 
147
147
  ────────────────────────────────────────────────────────────────────
148
148
  doc statuses
@@ -296,16 +296,30 @@ status. With no file, releases every lease owned by the current session.
296
296
  Identical behavior to \`dotmd unpickup\`; both names route to the same
297
297
  implementation. See \`dotmd unpickup --help\` for full option list.`,
298
298
 
299
- finish: `dotmd finish <file> [done|active] — finish working on a plan
300
-
301
- Sets the plan status to done (default) or back to active.
302
- Only works on plans currently in-session.
299
+ ship: `dotmd ship [patch|minor|major] — regen + commit + bump in one step
300
+
301
+ Bundles the multi-step release dance into a single command:
302
+ 1. Regenerate \`.claude/commands/*.md\` with the TARGET version stamp
303
+ (the post-bump version, so the slash-command files match the new
304
+ release and no dirty tree lingers after).
305
+ 2. Auto-stage every dirty file matching the release allowlist
306
+ (src/, test/, bin/, docs/, .claude/commands/, package*.json,
307
+ dotmd.config*.mjs, README.md, CLAUDE.md, .gitignore). Anything
308
+ outside the allowlist is left dirty — secrets, sibling-session
309
+ WIP, etc. never get bundled in.
310
+ 3. Commit with an auto-generated message including the held plan
311
+ title (if any).
312
+ 4. Run \`npm version <bump>\` to bump package.json, tag, push, run
313
+ the publish workflow, and reinstall locally.
303
314
 
304
315
  Options:
305
- --json Output as JSON
306
- --dry-run, -n Preview without writing
316
+ --dry-run, -n Show what would happen without staging or bumping.
317
+
318
+ Defaults to patch. Pass \`minor\` or \`major\` to bump those instead.
307
319
 
308
- If no file is given, prompts with a list of in-session plans.`,
320
+ Network failures mid-bump (e.g. \`git push\` fails) leave the local
321
+ commit + tag intact. Inspect with \`git log -1\` and rerun
322
+ \`git push origin main --tags\` to recover.`,
309
323
 
310
324
  set: `dotmd set <status> [<file>] — unified status-transition verb
311
325
 
@@ -1160,9 +1174,9 @@ async function main() {
1160
1174
  if (command === 'unpickup' || command === 'release') { const { runUnpickup } = await import('../src/lifecycle.mjs'); await runUnpickup(restArgs, config, { dryRun }); return; }
1161
1175
  if (command === 'runlist') { const { runRunlist } = await import('../src/runlist.mjs'); await runRunlist(restArgs, config, { dryRun }); return; }
1162
1176
  if (command === 'handoff') { die('`dotmd handoff` was removed in 0.31.0. Use `dotmd prompts new <name>` to create a saved prompt instead. The .dotmd/handoffs/ sidecar mechanism no longer exists; see CHANGELOG.'); }
1163
- if (command === 'finish') { const { runFinish } = await import('../src/lifecycle.mjs'); await runFinish(restArgs, config, { dryRun }); return; }
1164
1177
  if (command === 'status') { const { runStatus } = await import('../src/lifecycle.mjs'); await runStatus(restArgs, config, { dryRun }); return; }
1165
1178
  if (command === 'set') { const { runSet } = await import('../src/lifecycle.mjs'); await runSet(restArgs, config, { dryRun }); return; }
1179
+ if (command === 'ship') { const { runShip } = await import('../src/ship.mjs'); await runShip(restArgs, config, { dryRun }); return; }
1166
1180
  if (command === 'archive') { const { runArchive } = await import('../src/lifecycle.mjs'); runArchive(restArgs, config, { dryRun }); return; }
1167
1181
  if (command === 'bulk' && restArgs[0] === 'archive') { const { runBulkArchive } = await import('../src/lifecycle.mjs'); runBulkArchive(restArgs.slice(1), config, { dryRun }); return; }
1168
1182
  if (command === 'bulk' && restArgs[0] === 'tag') { const { runBulkTag } = await import('../src/bulk-tag.mjs'); runBulkTag(restArgs.slice(1), config, { dryRun }); return; }
@@ -268,7 +268,6 @@ export const presets = {
268
268
  // export function onRename({ oldPath, newPath, referencesUpdated }) {}
269
269
  // export function onLint({ path, fixes }) {}
270
270
  // export function onPickup({ path, oldStatus, newStatus }) {}
271
- // export function onFinish({ path, oldStatus, newStatus }) {}
272
271
 
273
272
  // AI hooks — override summarization (replaces local MLX model).
274
273
  // export function summarizeDoc(body, meta) { return 'Custom summary'; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.41.0",
3
+ "version": "0.42.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",
@@ -5,13 +5,13 @@ import { green, dim, yellow } from './color.mjs';
5
5
 
6
6
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
7
  const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
8
- const VERSION_MARKER = `<!-- dotmd-generated: ${pkg.version} -->`;
9
8
  // Marker is no longer pinned to line 1 — it now lives below the YAML
10
9
  // frontmatter that Claude Code surfaces as the slash command's description.
11
10
  // The regex is intentionally non-anchored so getInstalledVersion finds it
12
11
  // wherever it sits, and the marker string is specific enough that a false
13
12
  // positive elsewhere in a user-edited file is not a realistic concern.
14
13
  const VERSION_REGEX = /<!-- dotmd-generated: ([\d.]+) -->/;
14
+ function markerFor(version) { return `<!-- dotmd-generated: ${version} -->`; }
15
15
 
16
16
  // Trigger sentences surfaced by Claude Code's available-skills system reminder.
17
17
  // Front-load the "when to reach for it" cue so Claude can route to the right
@@ -55,11 +55,11 @@ function frontmatterFor(name, config) {
55
55
  return ['---', `description: ${description}`, '---'];
56
56
  }
57
57
 
58
- function generatePlansCommand(config) {
59
- const lines = [...frontmatterFor('plans', config), VERSION_MARKER, ''];
58
+ function generatePlansCommand(config, version) {
59
+ const lines = [...frontmatterFor('plans', config), markerFor(version), ''];
60
60
  lines.push('Run `dotmd context` to get the current plans briefing, then use it to orient yourself.');
61
61
  lines.push('');
62
- lines.push(`Plans are managed by **dotmd** (v${pkg.version}). Config at \`dotmd.config.mjs\`. Always use \`dotmd\` directly.`);
62
+ lines.push(`Plans are managed by **dotmd** (v${version}). Config at \`dotmd.config.mjs\`. Always use \`dotmd\` directly.`);
63
63
  lines.push('');
64
64
  lines.push('Plan-specific commands:');
65
65
  lines.push('- `dotmd context` — briefing with active/paused/ready plans, age tags, next steps');
@@ -94,8 +94,8 @@ function generatePlansCommand(config) {
94
94
  return lines.join('\n');
95
95
  }
96
96
 
97
- function generateBatonCommand(config) {
98
- const lines = [...frontmatterFor('baton', config), VERSION_MARKER, ''];
97
+ function generateBatonCommand(config, version) {
98
+ const lines = [...frontmatterFor('baton', config), markerFor(version), ''];
99
99
  lines.push('Wrap this session. Minimum required (two commands):');
100
100
  lines.push('');
101
101
  lines.push('1. **Save the resume prompt.** `dotmd new prompt resume-<plan-slug>` with a 10-20 line body via heredoc: the next concrete decision plus any gotchas. NOT a recap of the plan body. The saved prompt IS the handoff — never print it into chat for copy-paste.');
@@ -114,12 +114,12 @@ function generateBatonCommand(config) {
114
114
  return lines.join('\n');
115
115
  }
116
116
 
117
- function generateDocsCommand(config) {
117
+ function generateDocsCommand(config, version) {
118
118
  const roots = Array.isArray(config.raw?.root) ? config.raw.root : [config.raw?.root ?? 'docs'];
119
119
  const rootCount = roots.length;
120
120
 
121
- const lines = [...frontmatterFor('docs', config), VERSION_MARKER, ''];
122
- lines.push(`All documentation in this repo is managed by **dotmd** (v${pkg.version}). Docs across ${rootCount} root${rootCount > 1 ? 's' : ''}: ${roots.join(', ')}. Config at \`dotmd.config.mjs\`.`);
121
+ const lines = [...frontmatterFor('docs', config), markerFor(version), ''];
122
+ lines.push(`All documentation in this repo is managed by **dotmd** (v${version}). Docs across ${rootCount} root${rootCount > 1 ? 's' : ''}: ${roots.join(', ')}. Config at \`dotmd.config.mjs\`.`);
123
123
  lines.push('');
124
124
 
125
125
  // Document types from config
@@ -177,7 +177,7 @@ function getInstalledVersion(filePath) {
177
177
  }
178
178
 
179
179
  export function scaffoldClaudeCommands(cwd, config, opts = {}) {
180
- const { dryRun = false } = opts;
180
+ const { dryRun = false, version = pkg.version } = opts;
181
181
  const claudeDir = path.join(cwd, '.claude');
182
182
  if (!existsSync(claudeDir)) return [];
183
183
 
@@ -185,16 +185,16 @@ export function scaffoldClaudeCommands(cwd, config, opts = {}) {
185
185
  const results = [];
186
186
 
187
187
  const files = [
188
- { name: 'plans.md', generate: () => generatePlansCommand(config) },
189
- { name: 'docs.md', generate: () => generateDocsCommand(config) },
190
- { name: 'baton.md', generate: () => generateBatonCommand(config) },
188
+ { name: 'plans.md', generate: () => generatePlansCommand(config, version) },
189
+ { name: 'docs.md', generate: () => generateDocsCommand(config, version) },
190
+ { name: 'baton.md', generate: () => generateBatonCommand(config, version) },
191
191
  ];
192
192
 
193
193
  for (const { name, generate } of files) {
194
194
  const filePath = path.join(commandsDir, name);
195
195
  const installedVersion = getInstalledVersion(filePath);
196
196
 
197
- if (installedVersion === pkg.version) {
197
+ if (installedVersion === version) {
198
198
  results.push({ name, action: 'current' });
199
199
  } else if (installedVersion) {
200
200
  // Outdated — regenerate
@@ -202,7 +202,7 @@ export function scaffoldClaudeCommands(cwd, config, opts = {}) {
202
202
  mkdirSync(commandsDir, { recursive: true });
203
203
  writeFileSync(filePath, generate(), 'utf8');
204
204
  }
205
- results.push({ name, action: 'updated', from: installedVersion, to: pkg.version });
205
+ results.push({ name, action: 'updated', from: installedVersion, to: version });
206
206
  } else if (!existsSync(filePath)) {
207
207
  // New — create
208
208
  if (!dryRun) {
@@ -231,7 +231,8 @@ export function refreshStaleSlashCommands(config) {
231
231
  return results.filter(r => r.action === 'updated');
232
232
  }
233
233
 
234
- export function checkClaudeCommands(cwd) {
234
+ export function checkClaudeCommands(cwd, opts = {}) {
235
+ const { version = pkg.version } = opts;
235
236
  const commandsDir = path.join(cwd, '.claude', 'commands');
236
237
  if (!existsSync(commandsDir)) return [];
237
238
 
@@ -239,11 +240,11 @@ export function checkClaudeCommands(cwd) {
239
240
  for (const name of ['plans.md', 'docs.md', 'baton.md']) {
240
241
  const filePath = path.join(commandsDir, name);
241
242
  const installedVersion = getInstalledVersion(filePath);
242
- if (installedVersion && installedVersion !== pkg.version) {
243
+ if (installedVersion && installedVersion !== version) {
243
244
  warnings.push({
244
245
  path: `.claude/commands/${name}`,
245
246
  level: 'warning',
246
- message: `Claude command outdated (v${installedVersion} → v${pkg.version}). Run \`dotmd doctor\` to update.`,
247
+ message: `Claude command outdated (v${installedVersion} → v${version}). Run \`dotmd doctor\` to update.`,
247
248
  });
248
249
  }
249
250
  }
package/src/commands.mjs CHANGED
@@ -4,8 +4,9 @@
4
4
  // templates points at a real command.
5
5
  export const KNOWN_COMMANDS = [
6
6
  'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'briefing', 'context', 'hud',
7
- 'focus', 'query', 'plans', 'prompts', 'stale', 'actionable', 'index', 'pickup', 'release', 'finish', 'status', 'set', 'archive', 'bulk', 'bulk-tag', 'touch', 'doctor',
7
+ 'focus', 'query', 'plans', 'prompts', 'stale', 'actionable', 'index', 'pickup', 'release', 'status', 'set', 'archive', 'bulk', 'bulk-tag', 'touch', 'doctor',
8
8
  'unblocks', 'health', 'glossary', 'modules', 'module',
9
9
  'fix-refs', 'lint', 'rename', 'migrate', 'notion', 'export', 'summary',
10
10
  'watch', 'diff', 'new', 'init', 'completions', 'statuses', 'journal',
11
+ 'ship',
11
12
  ];
@@ -2,7 +2,7 @@ import { die } from './util.mjs';
2
2
 
3
3
  const COMMANDS = [
4
4
  'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'unblocks', 'health', 'glossary', 'briefing', 'context', 'focus', 'query',
5
- 'plans', 'stale', 'actionable', 'index', 'pickup', 'unpickup', 'finish', 'status', 'archive', 'bulk', 'touch', 'doctor', 'lint', 'rename', 'migrate',
5
+ 'plans', 'stale', 'actionable', 'index', 'pickup', 'unpickup', 'status', 'set', 'archive', 'bulk', 'touch', 'doctor', 'lint', 'rename', 'migrate',
6
6
  'fix-refs', 'notion', 'export', 'summary', 'watch', 'diff', 'init', 'new', 'completions', 'journal',
7
7
  ];
8
8
 
package/src/lifecycle.mjs CHANGED
@@ -112,6 +112,10 @@ export async function runStatus(argv, config, opts = {}) {
112
112
  const input = argv[0];
113
113
  let newStatus = argv[1];
114
114
 
115
+ if (!opts.suppressDeprecation) {
116
+ process.stderr.write(dim('`dotmd status <file> <status>` is deprecated; prefer `dotmd set <status> [<file>]` (note: <status> first, <file> optional when a lease is held). Removed in a future major.\n'));
117
+ }
118
+
115
119
  if (!input) { die('Usage: dotmd status <file> <new-status>'); }
116
120
 
117
121
  const filePath = resolveDocPath(input, config);
@@ -519,66 +523,6 @@ export async function runUnpickup(argv, config, opts = {}) {
519
523
  }
520
524
  }
521
525
 
522
- export async function runFinish(argv, config, opts = {}) {
523
- const { dryRun } = opts;
524
- const json = argv.includes('--json');
525
- const positional = argv.filter(a => !a.startsWith('-'));
526
- let input = positional[0];
527
- const targetStatus = positional[1] ?? 'done';
528
-
529
- if (!['done', 'active'].includes(targetStatus)) die(`Invalid finish status: ${targetStatus}. Use 'done' or 'active'.`);
530
-
531
- // Interactive: pick from in-session plans
532
- if (!input) {
533
- if (!isInteractive()) die('Usage: dotmd finish <file> [done|active]');
534
- const index = buildIndex(config);
535
- const inSession = index.docs.filter(d => d.status === 'in-session');
536
- if (inSession.length === 0) die('No plans currently in-session.');
537
- if (inSession.length === 1) {
538
- input = inSession[0].path;
539
- process.stderr.write(`${dim(`Auto-selected: ${input}`)}\n`);
540
- } else {
541
- const choice = await promptChoice('Finish which plan:', inSession.map(d => `${d.title} — ${d.path}`));
542
- if (!choice) die('No plan selected.');
543
- const idx = inSession.findIndex((_, i) => choice === `${inSession[i].title} — ${inSession[i].path}`);
544
- if (idx === -1) die('No plan selected.');
545
- input = inSession[idx].path;
546
- }
547
- }
548
-
549
- const filePath = resolveDocPath(input, config);
550
- if (!filePath) die(`File not found: ${input}`);
551
-
552
- const raw = readFileSync(filePath, 'utf8');
553
- const { frontmatter: fmRaw } = extractFrontmatter(raw);
554
- const parsedFm = parseSimpleFrontmatter(fmRaw);
555
- const oldStatus = asString(parsedFm.status);
556
- const repoPath = toRepoPath(filePath, config.repoRoot);
557
-
558
- if (oldStatus !== 'in-session') die(`Plan is not in-session (current: ${oldStatus}).\n ${repoPath}`);
559
-
560
- const today = nowIso();
561
-
562
- if (dryRun) {
563
- process.stderr.write(`${dim('[dry-run]')} Would update: status: in-session → ${targetStatus}, updated: ${today}\n`);
564
- } else {
565
- updateFrontmatter(filePath, { status: targetStatus, updated: today });
566
- regenIndex(config);
567
- }
568
-
569
- if (json) {
570
- process.stdout.write(JSON.stringify({ path: repoPath, oldStatus, newStatus: targetStatus }, null, 2) + '\n');
571
- } else {
572
- process.stdout.write(`${green('✓ Finished')}: ${repoPath} (in-session → ${targetStatus})\n`);
573
- }
574
-
575
- if (!dryRun) {
576
- try { releaseLease(config, repoPath, { force: true }); } catch (err) { warn(`Could not release lease for ${repoPath}: ${err.message}`); }
577
- }
578
-
579
- try { config.hooks.onFinish?.({ path: repoPath, oldStatus, newStatus: targetStatus }); } catch (err) { warn(`Hook 'onFinish' threw: ${err.message}`); }
580
- }
581
-
582
526
  export function runArchive(argv, config, opts = {}) {
583
527
  const { dryRun, out = process.stdout } = opts;
584
528
  const noIndex = argv.includes('--no-index') || opts.noIndex;
@@ -747,7 +691,7 @@ export async function runSet(argv, config, opts = {}) {
747
691
  const statusArgs = [filePath, newStatus];
748
692
  if (noIndex) statusArgs.push('--no-index');
749
693
  if (showFiles) statusArgs.push('--show-files');
750
- await runStatus(statusArgs, config, { dryRun });
694
+ await runStatus(statusArgs, config, { dryRun, suppressDeprecation: true });
751
695
 
752
696
  if (oldStatus === 'in-session' && newStatus !== 'in-session' && !dryRun) {
753
697
  const repoPath = toRepoPath(filePath, config.repoRoot);
package/src/ship.mjs ADDED
@@ -0,0 +1,149 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { spawnSync } from 'node:child_process';
3
+ import path from 'node:path';
4
+ import { die, warn, toRepoPath } from './util.mjs';
5
+ import { green, dim, yellow } from './color.mjs';
6
+ import { scaffoldClaudeCommands } from './claude-commands.mjs';
7
+ import { readLeases, currentSessionId } from './lease.mjs';
8
+
9
+ // Files dotmd ship will auto-stage when they're dirty. Anything outside this
10
+ // allowlist stays in the working tree — user has to `git add` it explicitly,
11
+ // so secrets / .env / sibling-session WIP never get bundled into a release.
12
+ const ALLOWLIST_PATTERNS = [
13
+ /^src\//,
14
+ /^test\//,
15
+ /^bin\//,
16
+ /^docs\//,
17
+ /^\.claude\/commands\//,
18
+ /^dotmd\.config\.example\.mjs$/,
19
+ /^dotmd\.config\.mjs$/,
20
+ /^package(?:-lock)?\.json$/,
21
+ /^README\.md$/,
22
+ /^CLAUDE\.md$/,
23
+ /^\.gitignore$/,
24
+ ];
25
+
26
+ export function bumpVersion(current, bump) {
27
+ const parts = current.split('.').map(Number);
28
+ if (parts.length !== 3 || parts.some(Number.isNaN)) {
29
+ die(`Cannot parse current version: ${current}`);
30
+ }
31
+ const [maj, min, pat] = parts;
32
+ if (bump === 'major') return `${maj + 1}.0.0`;
33
+ if (bump === 'minor') return `${maj}.${min + 1}.0`;
34
+ if (bump === 'patch') return `${maj}.${min}.${pat + 1}`;
35
+ die(`Invalid bump: ${bump}. Use patch | minor | major.`);
36
+ }
37
+
38
+ export function isAllowed(repoPath) {
39
+ return ALLOWLIST_PATTERNS.some(re => re.test(repoPath));
40
+ }
41
+
42
+ function listDirtyFiles(repoRoot) {
43
+ // -u expands untracked directories into individual file entries; without it,
44
+ // a fresh `docs/` shows up as a single `?? docs/` line and the allowlist
45
+ // check sees no per-file paths to whitelist.
46
+ const result = spawnSync('git', ['status', '--porcelain', '-u'], { cwd: repoRoot, encoding: 'utf8' });
47
+ if (result.status !== 0) die(`git status failed: ${result.stderr}`);
48
+ return result.stdout
49
+ .split('\n')
50
+ .filter(Boolean)
51
+ .map(line => ({
52
+ status: line.slice(0, 2),
53
+ path: line.slice(3),
54
+ }));
55
+ }
56
+
57
+ function findHeldPlanTitle(config) {
58
+ const leases = readLeases(config);
59
+ const sid = currentSessionId();
60
+ const owned = Object.entries(leases).filter(([_, l]) => l.session === sid);
61
+ if (owned.length !== 1) return null;
62
+ return path.basename(owned[0][0], '.md');
63
+ }
64
+
65
+ export async function runShip(argv, config, opts = {}) {
66
+ const { dryRun } = opts;
67
+ const positional = argv.filter(a => !a.startsWith('-'));
68
+ const bump = positional[0] ?? 'patch';
69
+ if (!['patch', 'minor', 'major'].includes(bump)) {
70
+ die(`Invalid bump: ${bump}. Usage: dotmd ship [patch|minor|major]`);
71
+ }
72
+
73
+ const pkgPath = path.join(config.repoRoot, 'package.json');
74
+ if (!existsSync(pkgPath)) die(`No package.json at ${toRepoPath(pkgPath, config.repoRoot)}`);
75
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
76
+ const current = pkg.version;
77
+ const target = bumpVersion(current, bump);
78
+
79
+ process.stdout.write(`${green('→')} Shipping ${current} → ${target} (${bump})\n`);
80
+
81
+ // 1. Regen slash commands at the *target* version so the resulting commit
82
+ // matches the post-bump state and no dirty tree lingers after release.
83
+ const regenResults = scaffoldClaudeCommands(config.repoRoot, config, { version: target, dryRun });
84
+ const refreshed = regenResults.filter(r => r.action === 'updated' || r.action === 'created');
85
+ if (refreshed.length > 0) {
86
+ const verb = dryRun ? 'Would regenerate' : 'Regenerated';
87
+ process.stdout.write(`${green('→')} ${verb} slash commands @ ${target}: ${refreshed.map(r => r.name).join(', ')}\n`);
88
+ }
89
+
90
+ // 2. Identify dirty tracked files. Anything matching the allowlist gets
91
+ // staged; everything else is left dirty so the user can handle it.
92
+ const dirty = listDirtyFiles(config.repoRoot);
93
+ const untracked = dirty.filter(d => d.status === '??');
94
+ const tracked = dirty.filter(d => d.status !== '??');
95
+
96
+ const toStage = tracked.filter(d => isAllowed(d.path)).map(d => d.path);
97
+ const skipped = tracked.filter(d => !isAllowed(d.path)).map(d => d.path);
98
+
99
+ // Untracked files matching the allowlist (e.g. a fresh new plan) are also
100
+ // safe to add — that's the common case of "scaffolded a plan, now shipping."
101
+ const newAllowed = untracked.filter(d => isAllowed(d.path)).map(d => d.path);
102
+ const newSkipped = untracked.filter(d => !isAllowed(d.path)).map(d => d.path);
103
+
104
+ const allToStage = [...toStage, ...newAllowed];
105
+ const allSkipped = [...skipped, ...newSkipped];
106
+
107
+ if (allSkipped.length > 0) {
108
+ process.stderr.write(`${dim(`Not staging (outside allowlist): ${allSkipped.join(', ')}`)}\n`);
109
+ }
110
+
111
+ if (dryRun) {
112
+ process.stdout.write(`${dim('[dry-run]')} Would stage ${allToStage.length} file(s):\n`);
113
+ for (const p of allToStage) process.stdout.write(` ${p}\n`);
114
+ process.stdout.write(`${dim('[dry-run]')} Would commit and run \`npm version ${bump}\`\n`);
115
+ return;
116
+ }
117
+
118
+ if (allToStage.length > 0) {
119
+ const add = spawnSync('git', ['add', '--', ...allToStage], { cwd: config.repoRoot, encoding: 'utf8' });
120
+ if (add.status !== 0) die(`git add failed: ${add.stderr}`);
121
+
122
+ const planTitle = findHeldPlanTitle(config);
123
+ const subject = planTitle
124
+ ? `chore: release ${target} (${planTitle})`
125
+ : `chore: release ${target}`;
126
+ const body = `Auto-staged by \`dotmd ship\`:\n${allToStage.map(p => `- ${p}`).join('\n')}`;
127
+ const commitMsg = `${subject}\n\n${body}`;
128
+ const commit = spawnSync('git', ['commit', '-m', commitMsg], { cwd: config.repoRoot, encoding: 'utf8' });
129
+ if (commit.status !== 0) die(`git commit failed: ${commit.stderr || commit.stdout}`);
130
+ process.stdout.write(`${green('→')} Committed: ${subject}\n`);
131
+ } else {
132
+ process.stdout.write(`${dim('→ Nothing to commit before bump.')}\n`);
133
+ }
134
+
135
+ // 3. npm version <bump> — handles package.json bump, tag, push, GH release,
136
+ // npm publish, and local reinstall via the existing pre/postversion
137
+ // scripts. We stream its output so the user sees CI progress live.
138
+ process.stdout.write(`${green('→')} Running \`npm version ${bump}\`…\n`);
139
+ const npmResult = spawnSync('npm', ['version', bump], {
140
+ cwd: config.repoRoot,
141
+ stdio: 'inherit',
142
+ });
143
+ if (npmResult.status !== 0) {
144
+ warn('`npm version` failed. The bump commit + tag may already exist locally. Inspect with `git log -1` and `git tag --sort=-creatordate | head` before retrying.');
145
+ process.exit(npmResult.status ?? 1);
146
+ }
147
+
148
+ process.stdout.write(`${green('✓')} Shipped ${target}\n`);
149
+ }