dotmd-cli 0.41.1 → 0.42.1

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
@@ -55,6 +55,7 @@ Lifecycle:
55
55
  set <status> [<file>] Unified transition: archive/release/transition in one verb
56
56
  archive <file> Archive (status + move + update refs)
57
57
  bulk archive <f1> <f2> ... Archive multiple files at once
58
+ ship [patch|minor|major] Regen + commit + bump in one step (default: patch)
58
59
  bulk-tag [files...] Tag pre-existing untagged .md files
59
60
  touch <file> Bump updated date
60
61
  touch --git Bulk-sync dates from git history
@@ -295,6 +296,31 @@ status. With no file, releases every lease owned by the current session.
295
296
  Identical behavior to \`dotmd unpickup\`; both names route to the same
296
297
  implementation. See \`dotmd unpickup --help\` for full option list.`,
297
298
 
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.
314
+
315
+ Options:
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.
319
+
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.`,
323
+
298
324
  set: `dotmd set <status> [<file>] — unified status-transition verb
299
325
 
300
326
  Routes to the right plumbing based on the target status:
@@ -1100,6 +1126,8 @@ async function main() {
1100
1126
  if (config.presets[command]) {
1101
1127
  const { buildIndex } = await import('../src/index.mjs');
1102
1128
  const { runQuery } = await import('../src/query.mjs');
1129
+ const { scrubStaleSilently } = await import('../src/lease-scrub.mjs');
1130
+ scrubStaleSilently(config);
1103
1131
  const index = buildIndex(config);
1104
1132
  runQuery(index, [...config.presets[command], ...restArgs], config, { preset: command });
1105
1133
  return;
@@ -1112,6 +1140,8 @@ async function main() {
1112
1140
  if (command === 'plans') {
1113
1141
  const { buildIndex } = await import('../src/index.mjs');
1114
1142
  const { runQuery } = await import('../src/query.mjs');
1143
+ const { scrubStaleSilently } = await import('../src/lease-scrub.mjs');
1144
+ scrubStaleSilently(config);
1115
1145
  const index = buildIndex(config);
1116
1146
  const sub = restArgs[0];
1117
1147
  let defaults;
@@ -1150,6 +1180,7 @@ async function main() {
1150
1180
  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.'); }
1151
1181
  if (command === 'status') { const { runStatus } = await import('../src/lifecycle.mjs'); await runStatus(restArgs, config, { dryRun }); return; }
1152
1182
  if (command === 'set') { const { runSet } = await import('../src/lifecycle.mjs'); await runSet(restArgs, config, { dryRun }); return; }
1183
+ if (command === 'ship') { const { runShip } = await import('../src/ship.mjs'); await runShip(restArgs, config, { dryRun }); return; }
1153
1184
  if (command === 'archive') { const { runArchive } = await import('../src/lifecycle.mjs'); runArchive(restArgs, config, { dryRun }); return; }
1154
1185
  if (command === 'bulk' && restArgs[0] === 'archive') { const { runBulkArchive } = await import('../src/lifecycle.mjs'); runBulkArchive(restArgs.slice(1), config, { dryRun }); return; }
1155
1186
  if (command === 'bulk' && restArgs[0] === 'tag') { const { runBulkTag } = await import('../src/bulk-tag.mjs'); runBulkTag(restArgs.slice(1), config, { dryRun }); return; }
@@ -1182,6 +1213,14 @@ async function main() {
1182
1213
  const { buildIndex } = await import('../src/index.mjs');
1183
1214
  const { renderCompactList, renderVerboseList, renderContext, renderBriefing, renderCheck, renderCoverage, buildCoverage } = await import('../src/render.mjs');
1184
1215
  const { runFocus, runQuery } = await import('../src/query.mjs');
1216
+ // Opportunistic stale-lease scrub for user-facing "what's actionable now"
1217
+ // views. Diagnostic commands (`check`, `coverage`, `stats`, `index`) are
1218
+ // intentionally excluded — they should surface drift, not silently fix it.
1219
+ const SCRUB_READ_COMMANDS = new Set(['list', 'briefing', 'context', 'focus', 'query', 'modules', 'module', 'surfaces']);
1220
+ if (SCRUB_READ_COMMANDS.has(command)) {
1221
+ const { scrubStaleSilently } = await import('../src/lease-scrub.mjs');
1222
+ scrubStaleSilently(config);
1223
+ }
1185
1224
  const index = buildIndex(config);
1186
1225
 
1187
1226
  // Apply --root and --type filters
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.41.1",
3
+ "version": "0.42.1",
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
@@ -8,4 +8,5 @@ export const KNOWN_COMMANDS = [
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
  ];
package/src/hud.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { readLeases, findStaleLeases, currentSessionId } from './lease.mjs';
3
+ import { readLeases, findStaleLeases, currentSessionId, STALE_LEASE_AGE_HOURS } from './lease.mjs';
4
+ import { scrubStaleSilently } from './lease-scrub.mjs';
4
5
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
5
6
  import { asString, toRepoPath } from './util.mjs';
6
7
  import { green, yellow, red, dim } from './color.mjs';
@@ -67,6 +68,12 @@ function findActionablePrompts(config) {
67
68
  }
68
69
 
69
70
  export function buildHud(config) {
71
+ // Drop stale lease entries (and flip their plan frontmatter back to
72
+ // oldStatus) before reading anything. Without this, hud would surface
73
+ // zombie in-session plans from crashed sessions as "you hold N plans" if
74
+ // the SessionStart hook sees its own (now-stale) lease from a previous
75
+ // session that shared the same env-supplied session id.
76
+ try { scrubStaleSilently(config); } catch { /* hot-path: never break hud */ }
70
77
  const session = currentSessionId();
71
78
  const leases = readLeases(config);
72
79
  const owned = Object.values(leases).filter(l => l.session === session).map(l => l.path);
@@ -115,7 +122,7 @@ export function runHud(argv, config) {
115
122
  lines.push(green(`▶ ${hud.prompts.length} pending prompt${hud.prompts.length === 1 ? '' : 's'}: ${previewList(hud.prompts)} ${dim('(consume: `dotmd prompts use <file>` — do not cat/read)')}`));
116
123
  }
117
124
  if (hud.stale.length > 0) {
118
- lines.push(yellow(`⚠ ${hud.stale.length} stuck lease${hud.stale.length === 1 ? '' : 's'} >24h ${dim('(run: dotmd release --stale)')}`));
125
+ lines.push(yellow(`⚠ ${hud.stale.length} stuck lease${hud.stale.length === 1 ? '' : 's'} >${STALE_LEASE_AGE_HOURS}h ${dim('(run: dotmd release --stale)')}`));
119
126
  }
120
127
  if (hud.errors > 0) {
121
128
  lines.push(red(`✗ ${hud.errors} validation error${hud.errors === 1 ? '' : 's'} ${dim('(run: dotmd check)')}`));
@@ -0,0 +1,49 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { releaseStale } from './lease.mjs';
4
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
5
+ import { asString, nowIso } from './util.mjs';
6
+
7
+ // Inline status flip, kept local so this module doesn't have to pull in
8
+ // lifecycle.mjs (and the rest of its dep graph) on the SessionStart-hook hot
9
+ // path. Matches the shape of lifecycle.updateFrontmatter for these two keys.
10
+ function flipStatus(filePath, newStatus) {
11
+ const raw = readFileSync(filePath, 'utf8');
12
+ if (!raw.startsWith('---\n')) return;
13
+ const endMarker = raw.indexOf('\n---\n', 4);
14
+ if (endMarker === -1) return;
15
+ let fm = raw.slice(4, endMarker);
16
+ const body = raw.slice(endMarker + 5);
17
+ const today = nowIso();
18
+ const statusRe = /^status:.*$/m;
19
+ const updatedRe = /^updated:.*$/m;
20
+ fm = statusRe.test(fm) ? fm.replace(statusRe, `status: ${newStatus}`) : `${fm}\nstatus: ${newStatus}`;
21
+ fm = updatedRe.test(fm) ? fm.replace(updatedRe, `updated: ${today}`) : `${fm}\nupdated: ${today}`;
22
+ writeFileSync(filePath, `---\n${fm}\n---\n${body}`, 'utf8');
23
+ }
24
+
25
+ // Opportunistic stale-lease scrub for read-side commands. Drops any lease
26
+ // entries past STALE_LEASE_AGE_MS and best-effort flips the plan's frontmatter
27
+ // from in-session back to the lease's oldStatus. Silent: no stderr, no warn,
28
+ // no index regen. Returns array of scrubbed lease paths (empty in the common
29
+ // no-op case, which is the only thing that matters for cost).
30
+ export function scrubStaleSilently(config) {
31
+ const result = releaseStale(config);
32
+ if (result.released.length === 0) return [];
33
+ for (const lease of result.released) {
34
+ const newStatus = lease.oldStatus || 'active';
35
+ const filePath = path.join(config.repoRoot, lease.path);
36
+ try {
37
+ const raw = readFileSync(filePath, 'utf8');
38
+ const { frontmatter: fmRaw } = extractFrontmatter(raw);
39
+ const parsedFm = parseSimpleFrontmatter(fmRaw);
40
+ if (asString(parsedFm.status) === 'in-session') {
41
+ flipStatus(filePath, newStatus);
42
+ }
43
+ } catch {
44
+ // Best-effort: the lease entry is already gone; a missing or unreadable
45
+ // file is fine for an opportunistic backstop.
46
+ }
47
+ }
48
+ return result.released.map(l => l.path);
49
+ }
package/src/lease.mjs CHANGED
@@ -9,7 +9,8 @@ const LOCK_FILE = 'in-session.lock';
9
9
  const LOCK_STALE_MS = 5_000;
10
10
  const LOCK_RETRY_MS = 50;
11
11
  const LOCK_MAX_WAIT_MS = 2_000;
12
- const STALE_LEASE_AGE_MS = 24 * 60 * 60 * 1000;
12
+ export const STALE_LEASE_AGE_MS = 4 * 60 * 60 * 1000;
13
+ export const STALE_LEASE_AGE_HOURS = STALE_LEASE_AGE_MS / (60 * 60 * 1000);
13
14
 
14
15
  const _sleepBuf = new Int32Array(new SharedArrayBuffer(4));
15
16
  function syncSleep(ms) { Atomics.wait(_sleepBuf, 0, 0, ms); }
package/src/lifecycle.mjs CHANGED
@@ -15,6 +15,8 @@ import {
15
15
  readLeases,
16
16
  currentSessionId,
17
17
  migrateLease,
18
+ STALE_LEASE_AGE_MS,
19
+ STALE_LEASE_AGE_HOURS,
18
20
  } from './lease.mjs';
19
21
  import { buildCard, renderCard } from './pickup-card.mjs';
20
22
  import { walkSections, findSection } from './section.mjs';
@@ -245,6 +247,17 @@ export async function runPickup(argv, config, opts = {}) {
245
247
  const showFiles = argv.includes('--show-files') || opts.showFiles;
246
248
  let input = argv.find(a => !a.startsWith('-'));
247
249
 
250
+ // Opportunistic stale-lease scrub before pickup runs its conflict check.
251
+ // Without this, a stale lease from a crashed prior session would still
252
+ // produce 'conflict-stale' and force the agent to pass --takeover even
253
+ // though we already know the holder is gone.
254
+ if (!dryRun) {
255
+ try {
256
+ const { scrubStaleSilently } = await import('./lease-scrub.mjs');
257
+ scrubStaleSilently(config);
258
+ } catch { /* best-effort — never block pickup on scrub failure */ }
259
+ }
260
+
248
261
  // Interactive: pick from active/planned plans
249
262
  if (!input) {
250
263
  if (!isInteractive()) die('Usage: dotmd pickup <file>');
@@ -317,7 +330,7 @@ export async function runPickup(argv, config, opts = {}) {
317
330
  }
318
331
  if (result.outcome === 'conflict-stale') {
319
332
  const c = result.conflict;
320
- die(`Stale in-session lease from ${c.host}/${c.session} since ${c.pickedUpAt} (>24h old).\nUse --takeover to claim.\n ${repoPath}`);
333
+ die(`Stale in-session lease from ${c.host}/${c.session} since ${c.pickedUpAt} (>${STALE_LEASE_AGE_HOURS}h old).\nUse --takeover to claim.\n ${repoPath}`);
321
334
  }
322
335
  if (oldStatus !== 'in-session') {
323
336
  updateFrontmatter(filePath, { status: 'in-session', updated: today });
@@ -459,7 +472,7 @@ export async function runUnpickup(argv, config, opts = {}) {
459
472
  if (dryRun) {
460
473
  const staleLeases = Object.values(leases).filter(l => {
461
474
  const age = Date.now() - new Date(l.pickedUpAt).getTime();
462
- return Number.isNaN(age) || age > 24 * 60 * 60 * 1000;
475
+ return Number.isNaN(age) || age > STALE_LEASE_AGE_MS;
463
476
  });
464
477
  for (const l of staleLeases) {
465
478
  process.stderr.write(`${dim('[dry-run]')} Would release stale: ${l.path} (${l.session})\n`);
@@ -473,9 +486,10 @@ export async function runUnpickup(argv, config, opts = {}) {
473
486
  }
474
487
  }
475
488
  } else {
476
- if (targets.length === 0 && !json) {
477
- process.stderr.write(`No leases to release${fileArg ? ` for ${fileArg}` : ` for session ${session}`}.\n`);
478
- }
489
+ // Silent no-op: when the session has nothing to release (already
490
+ // auto-released by archive, or never held), exit 0 with no output.
491
+ // Only print when work was actually done. The fileArg path can't reach
492
+ // here with targets.length === 0 — it would have died at lookup.
479
493
  for (const lease of targets) {
480
494
  const newStatus = targetStatus(lease);
481
495
  if (dryRun) {
package/src/ship.mjs ADDED
@@ -0,0 +1,156 @@
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
+ const status = line.slice(0, 2);
53
+ let rawPath = line.slice(3);
54
+ // Renames/copies render as `R orig -> new` (and `C orig -> new`); only
55
+ // the destination is a real file we can `git add`. Without splitting on
56
+ // ` -> `, the literal "orig -> new" string is handed to git, which fails
57
+ // with "did not match any files" and aborts the ship.
58
+ const arrow = rawPath.indexOf(' -> ');
59
+ if (arrow !== -1) rawPath = rawPath.slice(arrow + 4);
60
+ return { status, path: rawPath };
61
+ });
62
+ }
63
+
64
+ function findHeldPlanTitle(config) {
65
+ const leases = readLeases(config);
66
+ const sid = currentSessionId();
67
+ const owned = Object.entries(leases).filter(([_, l]) => l.session === sid);
68
+ if (owned.length !== 1) return null;
69
+ return path.basename(owned[0][0], '.md');
70
+ }
71
+
72
+ export async function runShip(argv, config, opts = {}) {
73
+ const { dryRun } = opts;
74
+ const positional = argv.filter(a => !a.startsWith('-'));
75
+ const bump = positional[0] ?? 'patch';
76
+ if (!['patch', 'minor', 'major'].includes(bump)) {
77
+ die(`Invalid bump: ${bump}. Usage: dotmd ship [patch|minor|major]`);
78
+ }
79
+
80
+ const pkgPath = path.join(config.repoRoot, 'package.json');
81
+ if (!existsSync(pkgPath)) die(`No package.json at ${toRepoPath(pkgPath, config.repoRoot)}`);
82
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
83
+ const current = pkg.version;
84
+ const target = bumpVersion(current, bump);
85
+
86
+ process.stdout.write(`${green('→')} Shipping ${current} → ${target} (${bump})\n`);
87
+
88
+ // 1. Regen slash commands at the *target* version so the resulting commit
89
+ // matches the post-bump state and no dirty tree lingers after release.
90
+ const regenResults = scaffoldClaudeCommands(config.repoRoot, config, { version: target, dryRun });
91
+ const refreshed = regenResults.filter(r => r.action === 'updated' || r.action === 'created');
92
+ if (refreshed.length > 0) {
93
+ const verb = dryRun ? 'Would regenerate' : 'Regenerated';
94
+ process.stdout.write(`${green('→')} ${verb} slash commands @ ${target}: ${refreshed.map(r => r.name).join(', ')}\n`);
95
+ }
96
+
97
+ // 2. Identify dirty tracked files. Anything matching the allowlist gets
98
+ // staged; everything else is left dirty so the user can handle it.
99
+ const dirty = listDirtyFiles(config.repoRoot);
100
+ const untracked = dirty.filter(d => d.status === '??');
101
+ const tracked = dirty.filter(d => d.status !== '??');
102
+
103
+ const toStage = tracked.filter(d => isAllowed(d.path)).map(d => d.path);
104
+ const skipped = tracked.filter(d => !isAllowed(d.path)).map(d => d.path);
105
+
106
+ // Untracked files matching the allowlist (e.g. a fresh new plan) are also
107
+ // safe to add — that's the common case of "scaffolded a plan, now shipping."
108
+ const newAllowed = untracked.filter(d => isAllowed(d.path)).map(d => d.path);
109
+ const newSkipped = untracked.filter(d => !isAllowed(d.path)).map(d => d.path);
110
+
111
+ const allToStage = [...toStage, ...newAllowed];
112
+ const allSkipped = [...skipped, ...newSkipped];
113
+
114
+ if (allSkipped.length > 0) {
115
+ process.stderr.write(`${dim(`Not staging (outside allowlist): ${allSkipped.join(', ')}`)}\n`);
116
+ }
117
+
118
+ if (dryRun) {
119
+ process.stdout.write(`${dim('[dry-run]')} Would stage ${allToStage.length} file(s):\n`);
120
+ for (const p of allToStage) process.stdout.write(` ${p}\n`);
121
+ process.stdout.write(`${dim('[dry-run]')} Would commit and run \`npm version ${bump}\`\n`);
122
+ return;
123
+ }
124
+
125
+ if (allToStage.length > 0) {
126
+ const add = spawnSync('git', ['add', '--', ...allToStage], { cwd: config.repoRoot, encoding: 'utf8' });
127
+ if (add.status !== 0) die(`git add failed: ${add.stderr}`);
128
+
129
+ const planTitle = findHeldPlanTitle(config);
130
+ const subject = planTitle
131
+ ? `chore: release ${target} (${planTitle})`
132
+ : `chore: release ${target}`;
133
+ const body = `Auto-staged by \`dotmd ship\`:\n${allToStage.map(p => `- ${p}`).join('\n')}`;
134
+ const commitMsg = `${subject}\n\n${body}`;
135
+ const commit = spawnSync('git', ['commit', '-m', commitMsg], { cwd: config.repoRoot, encoding: 'utf8' });
136
+ if (commit.status !== 0) die(`git commit failed: ${commit.stderr || commit.stdout}`);
137
+ process.stdout.write(`${green('→')} Committed: ${subject}\n`);
138
+ } else {
139
+ process.stdout.write(`${dim('→ Nothing to commit before bump.')}\n`);
140
+ }
141
+
142
+ // 3. npm version <bump> — handles package.json bump, tag, push, GH release,
143
+ // npm publish, and local reinstall via the existing pre/postversion
144
+ // scripts. We stream its output so the user sees CI progress live.
145
+ process.stdout.write(`${green('→')} Running \`npm version ${bump}\`…\n`);
146
+ const npmResult = spawnSync('npm', ['version', bump], {
147
+ cwd: config.repoRoot,
148
+ stdio: 'inherit',
149
+ });
150
+ if (npmResult.status !== 0) {
151
+ 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.');
152
+ process.exit(npmResult.status ?? 1);
153
+ }
154
+
155
+ process.stdout.write(`${green('✓')} Shipped ${target}\n`);
156
+ }
package/src/validate.mjs CHANGED
@@ -2,7 +2,7 @@ import path from 'node:path';
2
2
  import { asString, resolveRefPath, suggestCandidates } from './util.mjs';
3
3
  import { getGitLastModified, getGitLastModifiedBatch } from './git.mjs';
4
4
  import { toRepoPath } from './util.mjs';
5
- import { readLeases, isLeaseStale } from './lease.mjs';
5
+ import { readLeases, isLeaseStale, STALE_LEASE_AGE_HOURS } from './lease.mjs';
6
6
 
7
7
  const NOW = new Date();
8
8
 
@@ -175,9 +175,10 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
175
175
 
176
176
  // F11: `status: in-session` plans should have a matching live lease. If the
177
177
  // lease file has no entry, the previous session crashed without releasing;
178
- // if the entry is stale (>24h), the holder is gone. Either way the validator
179
- // is the only place that knows enough to suggest the exact unstuck command,
180
- // because the lease infrastructure is otherwise invisible to `dotmd check`.
178
+ // if the entry is stale (> stale-threshold hours), the holder is gone.
179
+ // Either way the validator is the only place that knows enough to suggest
180
+ // the exact unstuck command, because the lease infrastructure is otherwise
181
+ // invisible to `dotmd check`.
181
182
  if (doc.status === 'in-session' && !config.lifecycle.skipWarningsFor.has(doc.status)) {
182
183
  const leases = readLeases(config);
183
184
  const lease = leases[doc.path];
@@ -192,7 +193,7 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
192
193
  doc.warnings.push({
193
194
  path: doc.path,
194
195
  level: 'warning',
195
- message: `\`status: in-session\` but lease is stale (last touched ${ageHours}h ago, >24h threshold). Run \`dotmd release ${doc.path}\` to clear, or \`dotmd status ${doc.path} active\` to re-queue.`,
196
+ message: `\`status: in-session\` but lease is stale (last touched ${ageHours}h ago, >${STALE_LEASE_AGE_HOURS}h threshold). Run \`dotmd release ${doc.path}\` to clear, or \`dotmd status ${doc.path} active\` to re-queue.`,
196
197
  });
197
198
  }
198
199
  }