dotmd-cli 0.50.1 → 0.51.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/src/commands.mjs CHANGED
@@ -4,7 +4,7 @@
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', 'agent-context', 'hud',
7
- 'focus', 'query', 'plans', 'prompts', 'stale', 'actionable', 'index', 'pickup', 'release', 'finish', 'status', 'set', 'use', 'next', 'archive', 'bulk', 'bulk-tag', 'touch', 'doctor', 'runlist',
7
+ 'focus', 'query', 'plans', 'prompts', 'stale', 'actionable', 'index', 'status', 'set', 'use', 'next', 'archive', 'bulk', 'bulk-tag', 'touch', 'doctor', 'runlist',
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',
@@ -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', 'release', 'finish', 'status', 'set', 'archive', 'bulk', 'touch', 'doctor', 'lint', 'rename', 'migrate',
5
+ 'plans', 'stale', 'actionable', 'index', '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
 
@@ -33,10 +33,6 @@ const COMMAND_FLAGS = {
33
33
  stale: ['--json', '--sort', '--limit', '--all'],
34
34
  actionable: ['--json', '--sort', '--limit', '--all'],
35
35
  briefing: ['--json'],
36
- pickup: ['--json', '--takeover'],
37
- unpickup: ['--json', '--all', '--stale', '--to', '--force', '--no-index', '--show-files'],
38
- release: ['--json', '--all', '--stale', '--to', '--force', '--no-index', '--show-files'],
39
- finish: ['--json', '--all', '--stale', '--to', '--force', '--no-index', '--show-files'],
40
36
  status: [],
41
37
  archive: [],
42
38
  doctor: [],
package/src/doctor.mjs CHANGED
@@ -146,7 +146,7 @@ function findDeprecatedCommandMentions(config) {
146
146
  for (const filePath of docs) {
147
147
  let raw = '';
148
148
  try { raw = readFileSync(filePath, 'utf8'); } catch { continue; }
149
- if (/\bdotmd status\b/.test(raw) || /\bdotmd pickup\b/.test(raw)) {
149
+ if (/\bdotmd status\b/.test(raw) || /\bdotmd (pickup|unpickup|release|finish)\b/.test(raw)) {
150
150
  matches.push(toRepoPath(filePath, config.repoRoot));
151
151
  }
152
152
  }
package/src/hints.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { readJournalEntries, isJournalEnabled, journalFilePath } from './journal.mjs';
3
- import { currentSessionId } from './lease.mjs';
3
+ import { currentSessionId } from './util.mjs';
4
4
 
5
5
  // F17c: repeat-failure hints. When an agent runs the same broken invocation
6
6
  // twice in the same session within HINT_WINDOW_MS, the second die() output is
@@ -38,11 +38,6 @@ const TEMPLATES = [
38
38
  hint: ({ count, argv }) =>
39
39
  `${count}× pointing at a path that doesn't exist. Confirm the file with \`dotmd query\` or \`dotmd plans\` — paths resolve relative to repo root or doc roots, not the cwd.`,
40
40
  },
41
- {
42
- match: /Lease conflict|in-session|held by/i,
43
- hint: ({ count }) =>
44
- `${count}× lease conflict in this session. Run \`dotmd plans --status in-session\` to see what's held; pass \`--takeover\` if the holder is stale, or close the other session first.`,
45
- },
46
41
  {
47
42
  match: /Unknown status|Unknown surface/i,
48
43
  hint: ({ count }) =>
package/src/hud.mjs CHANGED
@@ -1,9 +1,7 @@
1
1
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { readLeases, findStaleLeases, currentSessionId, isLeaseStale, STALE_LEASE_AGE_MS } from './lease.mjs';
4
- import { scrubStaleSilently } from './lease-scrub.mjs';
5
3
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
6
- import { asString, toRepoPath } from './util.mjs';
4
+ import { asString, toRepoPath, currentSessionId } from './util.mjs';
7
5
  import { dim } from './color.mjs';
8
6
  import { buildIndex } from './index.mjs';
9
7
  import { refreshStaleSlashCommands } from './claude-commands.mjs';
@@ -105,13 +103,6 @@ export function buildJournalSections(config, now = Date.now()) {
105
103
  if (!entries.length) return { previousSelf: [], fleet: [], recentRejections: [] };
106
104
 
107
105
  const sid = currentSessionId();
108
- const leases = readLeases(config);
109
- const leaseBySession = new Map();
110
- for (const lease of Object.values(leases)) {
111
- if (!lease?.session) continue;
112
- if (!leaseBySession.has(lease.session)) leaseBySession.set(lease.session, []);
113
- leaseBySession.get(lease.session).push(lease);
114
- }
115
106
 
116
107
  // 1. Previous self: this sid's last N entries (excluding the current
117
108
  // invocation, which is recorded only at process exit so it isn't in the
@@ -140,14 +131,10 @@ export function buildJournalSections(config, now = Date.now()) {
140
131
  if (t > row.lastTs) row.lastTs = t;
141
132
  }
142
133
  const fleet = [...bySid.entries()].map(([otherSid, row]) => {
143
- const myLeases = leaseBySession.get(otherSid) ?? [];
144
- const stalest = myLeases.find(isLeaseStale);
145
134
  return {
146
135
  sid: otherSid,
147
136
  cmds: row.count,
148
137
  lastAgo: relTime(new Date(row.lastTs).toISOString(), now),
149
- holding: myLeases.map(l => l.path),
150
- stale: Boolean(stalest),
151
138
  };
152
139
  }).sort((a, b) => b.cmds - a.cmds).slice(0, FLEET_CAP);
153
140
 
@@ -174,16 +161,6 @@ export function buildJournalSections(config, now = Date.now()) {
174
161
  }
175
162
 
176
163
  export function buildHud(config) {
177
- // Drop stale lease entries (and flip their plan frontmatter back to
178
- // oldStatus) before reading anything. Without this, hud would surface
179
- // zombie in-session plans from crashed sessions as "you hold N plans" if
180
- // the SessionStart hook sees its own (now-stale) lease from a previous
181
- // session that shared the same env-supplied session id.
182
- try { scrubStaleSilently(config); } catch { /* hot-path: never break hud */ }
183
- const session = currentSessionId();
184
- const leases = readLeases(config);
185
- const owned = Object.values(leases).filter(l => l.session === session).map(l => l.path);
186
- const stale = findStaleLeases(config).map(l => l.path);
187
164
  const prompts = findActionablePrompts(config);
188
165
 
189
166
  // Validation error count — hud's "silent when clean" contract should treat
@@ -205,7 +182,7 @@ export function buildHud(config) {
205
182
 
206
183
  const { previousSelf, fleet, recentRejections } = buildJournalSections(config);
207
184
 
208
- return { owned, stale, prompts, errors, previousSelf, fleet, recentRejections };
185
+ return { prompts, errors, previousSelf, fleet, recentRejections };
209
186
  }
210
187
 
211
188
  export function runHud(argv, config) {
package/src/journal.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, appendFileSync, statSync, renameSync, readFileSync, unlinkSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
- import { currentSessionId } from './lease.mjs';
4
+ import { currentSessionId } from './util.mjs';
5
5
 
6
6
  const JOURNAL_DIR = '.dotmd';
7
7
  const JOURNAL_FILE = 'journal.jsonl';
package/src/lifecycle.mjs CHANGED
@@ -5,19 +5,8 @@ import { asString, toRepoPath, die, warn, resolveDocPath, resolveRefPath, escape
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';
8
- import { green, dim, yellow } from './color.mjs';
8
+ import { green, dim } from './color.mjs';
9
9
  import { isInteractive, promptChoice } from './prompt.mjs';
10
- import {
11
- acquireLease,
12
- releaseLease,
13
- releaseAllForSession,
14
- releaseStale,
15
- readLeases,
16
- currentSessionId,
17
- migrateLease,
18
- isLeaseReclaimable,
19
- STALE_LEASE_AGE_HOURS,
20
- } from './lease.mjs';
21
10
  import { buildCard, renderCard } from './pickup-card.mjs';
22
11
  import { walkSections, findSection } from './section.mjs';
23
12
 
@@ -297,29 +286,20 @@ export async function runStatus(argv, config, opts = {}) {
297
286
  }); } catch (err) { warn(`Hook 'onStatusChange' threw: ${err.message}`); }
298
287
  }
299
288
 
300
- export async function runPickup(argv, config, opts = {}) {
289
+ // Open a plan for work: flip its frontmatter status to `in-session` and print
290
+ // its card (body + related + next steps). No lease, no claiming — just a
291
+ // status write. Backs `dotmd use <plan>` and `dotmd runlist next`.
292
+ export async function startPlan(argv, config, opts = {}) {
301
293
  const { dryRun } = opts;
302
294
  const json = argv.includes('--json');
303
- const takeover = argv.includes('--takeover');
304
295
  const fullBody = argv.includes('--full');
305
296
  const noIndex = argv.includes('--no-index') || opts.noIndex;
306
297
  const showFiles = argv.includes('--show-files') || opts.showFiles;
307
298
  let input = argv.find(a => !a.startsWith('-'));
308
299
 
309
- // Opportunistic stale-lease scrub before pickup runs its conflict check.
310
- // Without this, a stale lease from a crashed prior session would still
311
- // produce 'conflict-stale' and force the agent to pass --takeover even
312
- // though we already know the holder is gone.
313
- if (!dryRun && !takeover) {
314
- try {
315
- const { scrubStaleSilently } = await import('./lease-scrub.mjs');
316
- scrubStaleSilently(config);
317
- } catch { /* best-effort — never block pickup on scrub failure */ }
318
- }
319
-
320
300
  // Interactive: pick from active/planned plans
321
301
  if (!input) {
322
- if (!isInteractive()) die('Usage: dotmd pickup <file>');
302
+ if (!isInteractive()) die('Usage: dotmd use <plan>');
323
303
  const index = buildIndex(config);
324
304
  const candidates = index.docs.filter(d =>
325
305
  d.type === 'plan' && (d.status === 'active' || d.status === 'planned')
@@ -351,63 +331,20 @@ export async function runPickup(argv, config, opts = {}) {
351
331
  die(`Plan is blocked: ${blockers}\n ${repoPath}`);
352
332
  }
353
333
 
354
- // If frontmatter says we're not in-session, any lingering lease is orphaned —
355
- // drop it so a fresh acquire below doesn't see a phantom conflict.
356
- if (oldStatus !== 'in-session') {
357
- if (readLeases(config)[repoPath]) {
358
- releaseLease(config, repoPath, { force: true });
359
- }
360
- }
361
-
362
- const pickupable = new Set(['active', 'planned', 'in-session']);
363
- if (oldStatus && !pickupable.has(oldStatus)) {
364
- die(
365
- `Cannot start work on a plan with status '${oldStatus}'. Must be active or planned.\n` +
366
- ` ${repoPath}\n` +
367
- `\n` +
368
- `Recover with:\n` +
369
- ` dotmd set active ${repoPath} && dotmd set in-session ${repoPath}`,
370
- );
371
- }
372
-
373
334
  const today = nowIso();
374
- const leaseOldStatus = oldStatus === 'in-session' ? 'active' : (oldStatus ?? 'active');
375
- let leaseOutcome = 'acquired';
376
-
377
335
  if (dryRun) {
378
336
  if (oldStatus === 'in-session') {
379
- process.stderr.write(`${dim('[dry-run]')} Would acquire lease (status already in-session)\n`);
337
+ process.stderr.write(`${dim('[dry-run]')} Already in-session: ${repoPath}\n`);
380
338
  } else {
381
339
  process.stderr.write(`${dim('[dry-run]')} Would update: status: ${oldStatus} → in-session, updated: ${today}\n`);
382
340
  }
383
- } else {
384
- const result = acquireLease(config, repoPath, leaseOldStatus, { takeover });
385
- leaseOutcome = result.outcome;
386
- if (result.outcome === 'conflict-alive') {
387
- const c = result.conflict;
388
- die(`Plan is in-session with ${c.host}/${c.session} (pid ${c.pid}) since ${c.pickedUpAt}.\nUse --takeover to override.\n ${repoPath}`);
389
- }
390
- if (result.outcome === 'conflict-stale') {
391
- const c = result.conflict;
392
- die(`Plan flagged in-session by ${c.host}/${c.session} since ${c.pickedUpAt} (>${STALE_LEASE_AGE_HOURS}h ago, looks abandoned).\nUse --takeover to claim.\n ${repoPath}`);
393
- }
394
- if (oldStatus !== 'in-session') {
395
- updateFrontmatter(filePath, { status: 'in-session', updated: today });
396
- if (noIndex) {
397
- process.stderr.write(dim('(index not regenerated — run `dotmd index` to refresh)\n'));
398
- } else {
399
- regenIndex(config);
400
- }
401
- }
402
- // VH append per lease outcome:
403
- // acquired → `Picked up (<old> → in-session).`
404
- // taken-over → `Took over from <session>.`
405
- // reattached → no entry (same-session noise)
406
- if (leaseOutcome === 'acquired') {
407
- appendVersionHistory(filePath, `Picked up (${oldStatus ?? 'unknown'} → in-session).`);
408
- } else if (leaseOutcome === 'taken-over') {
409
- const fromSession = result.conflict?.session ?? 'unknown';
410
- appendVersionHistory(filePath, `Took over from ${fromSession}.`);
341
+ } else if (oldStatus !== 'in-session') {
342
+ updateFrontmatter(filePath, { status: 'in-session', updated: today });
343
+ appendVersionHistory(filePath, `Started (${oldStatus ?? 'unknown'} → in-session).`);
344
+ if (noIndex) {
345
+ process.stderr.write(dim('(index not regenerated — run `dotmd index` to refresh)\n'));
346
+ } else {
347
+ regenIndex(config);
411
348
  }
412
349
  }
413
350
 
@@ -415,19 +352,11 @@ export async function runPickup(argv, config, opts = {}) {
415
352
  const card = buildCard(filePath, raw, config);
416
353
  process.stdout.write(JSON.stringify({
417
354
  path: repoPath, oldStatus, newStatus: 'in-session', title,
418
- reattached: leaseOutcome === 'reattached',
419
- takenOver: leaseOutcome === 'taken-over',
420
355
  body: body?.trim() ?? '',
421
356
  card,
422
357
  }, null, 2) + '\n');
423
358
  } else {
424
- if (leaseOutcome === 'reattached') {
425
- process.stderr.write(`${green('▶ Re-attached')}: ${repoPath}\n\n`);
426
- } else if (leaseOutcome === 'taken-over') {
427
- process.stderr.write(`${green('▶ Took over')}: ${repoPath} (was ${oldStatus ?? 'unset'} → in-session)\n\n`);
428
- } else {
429
- process.stderr.write(`${green('▶ Picked up')}: ${repoPath} (${oldStatus ?? 'unset'} → in-session)\n\n`);
430
- }
359
+ process.stderr.write(`${green('▶ Started')}: ${repoPath} (${oldStatus ?? 'unset'} → in-session)\n\n`);
431
360
  if (fullBody) {
432
361
  const header = `[dotmd] in-session: ${repoPath} — close with: dotmd set <status> ${repoPath}\n---\n`;
433
362
  process.stdout.write(header);
@@ -448,151 +377,6 @@ export async function runPickup(argv, config, opts = {}) {
448
377
  try { config.hooks.onPickup?.({ path: repoPath, oldStatus, newStatus: 'in-session' }); } catch (err) { warn(`Hook 'onPickup' threw: ${err.message}`); }
449
378
  }
450
379
 
451
- export async function runUnpickup(argv, config, opts = {}) {
452
- const { dryRun } = opts;
453
- const json = argv.includes('--json');
454
- const all = argv.includes('--all');
455
- const stale = argv.includes('--stale');
456
- const force = argv.includes('--force');
457
- const noIndex = argv.includes('--no-index') || opts.noIndex;
458
- const showFiles = argv.includes('--show-files') || opts.showFiles;
459
- const toIdx = argv.indexOf('--to');
460
- const toStatus = toIdx >= 0 ? argv[toIdx + 1] : null;
461
- const positional = argv.filter((a, i) => !a.startsWith('-') && argv[i - 1] !== '--to');
462
- const fileArg = positional[0];
463
- const touched = [];
464
-
465
- const session = currentSessionId();
466
- const released = [];
467
- const skipped = [];
468
-
469
- // Decide which leases to act on
470
- let targets = [];
471
- const leases = readLeases(config);
472
- if (fileArg) {
473
- const filePath = resolveDocPath(fileArg, config);
474
- if (!filePath) die(`File not found: ${fileArg}`);
475
- const repoPath = toRepoPath(filePath, config.repoRoot);
476
- if (leases[repoPath]) {
477
- targets.push(leases[repoPath]);
478
- } else {
479
- // Manual-edit fallback: status may be in-session with no lease.
480
- const raw = readFileSync(filePath, 'utf8');
481
- const { frontmatter: fmRaw } = extractFrontmatter(raw);
482
- const parsedFm = parseSimpleFrontmatter(fmRaw);
483
- if (asString(parsedFm.status) === 'in-session') {
484
- targets.push({ path: repoPath, oldStatus: null, session: null, pid: null, host: null, pickedUpAt: null, _orphan: true });
485
- } else {
486
- die(`Not in-session: ${repoPath}`);
487
- }
488
- }
489
- } else if (all) {
490
- targets = Object.values(leases);
491
- } else if (stale) {
492
- // releaseStale handled separately below — set a marker
493
- targets = null;
494
- } else {
495
- // Default: release all owned by current session
496
- targets = Object.values(leases).filter(l => l.session === session);
497
- }
498
-
499
- const targetStatus = (lease) => toStatus || lease.oldStatus || 'active';
500
-
501
- function flipFrontmatter(repoPath, newStatus) {
502
- const filePath = resolveDocPath(repoPath, config);
503
- if (!filePath) {
504
- warn(`Lease points at ${repoPath} but file not found — releasing lease without frontmatter update.`);
505
- return;
506
- }
507
- try {
508
- const raw = readFileSync(filePath, 'utf8');
509
- const { frontmatter: fmRaw } = extractFrontmatter(raw);
510
- const parsedFm = parseSimpleFrontmatter(fmRaw);
511
- const cur = asString(parsedFm.status);
512
- if (cur === 'in-session') {
513
- const today = nowIso();
514
- updateFrontmatter(filePath, { status: newStatus, updated: today });
515
- appendVersionHistory(filePath, `Released (in-session → ${newStatus}).`);
516
- touched.push(filePath);
517
- if (noIndex) {
518
- process.stderr.write(dim('(index not regenerated — run `dotmd index` to refresh)\n'));
519
- } else {
520
- regenIndex(config);
521
- }
522
- }
523
- // If frontmatter is no longer in-session (manual flip), leave it alone.
524
- } catch (err) {
525
- warn(`Could not update frontmatter for ${repoPath}: ${err.message}`);
526
- }
527
- }
528
-
529
- if (targets === null) {
530
- // --stale path
531
- if (dryRun) {
532
- const staleLeases = Object.values(leases).filter(l => isLeaseReclaimable(l, { currentSession: session }));
533
- for (const l of staleLeases) {
534
- process.stderr.write(`${dim('[dry-run]')} Would release stale: ${l.path} (${l.session})\n`);
535
- }
536
- } else {
537
- const result = releaseStale(config);
538
- for (const l of result.released) {
539
- flipFrontmatter(l.path, targetStatus(l));
540
- released.push({ path: l.path, oldStatus: l.oldStatus, newStatus: targetStatus(l), session: l.session, stale: true });
541
- try { config.hooks.onUnpickup?.({ path: l.path, oldStatus: 'in-session', newStatus: targetStatus(l) }); } catch (err) { warn(`Hook 'onUnpickup' threw: ${err.message}`); }
542
- }
543
- }
544
- } else {
545
- // Silent no-op: when the session has nothing to release (already
546
- // auto-released by archive, or never held), exit 0 with no output.
547
- // Only print when work was actually done. The fileArg path can't reach
548
- // here with targets.length === 0 — it would have died at lookup.
549
- for (const lease of targets) {
550
- const newStatus = targetStatus(lease);
551
- if (dryRun) {
552
- process.stderr.write(`${dim('[dry-run]')} Would release: ${lease.path} (${lease.oldStatus ?? '?'} → ${newStatus})\n`);
553
- continue;
554
- }
555
- if (lease._orphan) {
556
- // Manual-edit fallback: no lease entry, just flip frontmatter.
557
- flipFrontmatter(lease.path, newStatus);
558
- warn(`No lease found for ${lease.path}; flipped status manually.`);
559
- released.push({ path: lease.path, oldStatus: 'in-session', newStatus, session: null, orphan: true });
560
- try { config.hooks.onUnpickup?.({ path: lease.path, oldStatus: 'in-session', newStatus }); } catch (err) { warn(`Hook 'onUnpickup' threw: ${err.message}`); }
561
- continue;
562
- }
563
- const isMine = lease.session === session;
564
- if (!isMine && !force && !all && !stale) {
565
- skipped.push({ path: lease.path, reason: 'not-yours', session: lease.session });
566
- continue;
567
- }
568
- const r = releaseLease(config, lease.path, { force: true });
569
- if (r.released) {
570
- flipFrontmatter(lease.path, newStatus);
571
- released.push({ path: lease.path, oldStatus: lease.oldStatus, newStatus, session: lease.session });
572
- try { config.hooks.onUnpickup?.({ path: lease.path, oldStatus: 'in-session', newStatus }); } catch (err) { warn(`Hook 'onUnpickup' threw: ${err.message}`); }
573
- }
574
- }
575
- }
576
-
577
- if (json) {
578
- process.stdout.write(JSON.stringify({ released, skipped }, null, 2) + '\n');
579
- } else {
580
- for (const r of released) {
581
- const tag = r.stale ? ' (stale)' : (r.orphan ? ' (orphan)' : '');
582
- process.stdout.write(`${green('↩ Unpicked')}: ${r.path} (in-session → ${r.newStatus})${tag}\n`);
583
- }
584
- for (const s of skipped) {
585
- process.stderr.write(`${yellow('⚠ Skipped')}: ${s.path} (held by ${s.session}; use --force to override)\n`);
586
- }
587
- }
588
-
589
- if (showFiles && touched.length > 0) {
590
- const all = [...touched];
591
- if (config.indexPath && !noIndex) all.push(config.indexPath);
592
- emitFilesFooter(all, config);
593
- }
594
- }
595
-
596
380
  export function runArchive(argv, config, opts = {}) {
597
381
  const { dryRun, out = process.stdout } = opts;
598
382
  const noIndex = argv.includes('--no-index') || opts.noIndex;
@@ -675,11 +459,6 @@ export function runArchive(argv, config, opts = {}) {
675
459
  out.write(`${prefix} Would update references in ${refCount} file(s)\n`);
676
460
  }
677
461
 
678
- // Preview lease release (only if a lease exists for this plan)
679
- if (readLeases(config)[oldRepoPath]) {
680
- out.write(`${prefix} Would release in-session lease: ${oldRepoPath}\n`);
681
- }
682
-
683
462
  // Preview onArchive hook fire
684
463
  if (config.hooks?.onArchive) {
685
464
  out.write(`${prefix} Would fire hook: onArchive\n`);
@@ -718,8 +497,6 @@ export function runArchive(argv, config, opts = {}) {
718
497
  if (config.indexPath && !noIndex) out.write('Index regenerated.\n');
719
498
  if (config.indexPath && noIndex) out.write(dim('(index not regenerated — run `dotmd index` to refresh)\n'));
720
499
 
721
- try { releaseLease(config, oldRepoPath, { force: true }); } catch (err) { warn(`Could not release lease for ${oldRepoPath}: ${err.message}`); }
722
-
723
500
  const touched = [oldRepoPath, newRepoPath, ...refTouchedPaths];
724
501
  if (config.indexPath && !noIndex) touched.push(config.indexPath);
725
502
  if (showFiles) emitFilesFooter(touched, config);
@@ -757,42 +534,14 @@ export async function runSet(argv, config, opts = {}) {
757
534
  argv = argv.filter(a => a !== '--no-index' && a !== '--show-files');
758
535
 
759
536
  const newStatus = argv[0];
760
- let input = argv[1];
761
-
762
- if (!newStatus) die('Usage: dotmd set <status> [<path>]');
763
-
764
- // `set in-session` acquires a lease + prints the plan body (same as the
765
- // legacy `pickup` verb). Delegated so `set` is the single status verb.
766
- if (newStatus === 'in-session') {
767
- const pickArgs = input ? [input] : [];
768
- if (argv.includes('--takeover')) pickArgs.push('--takeover');
769
- if (noIndex) pickArgs.push('--no-index');
770
- if (showFiles) pickArgs.push('--show-files');
771
- return runPickup(pickArgs, config, { dryRun });
772
- }
537
+ const input = argv[1];
773
538
 
774
- if (!input) {
775
- const leases = readLeases(config);
776
- const sid = currentSessionId();
777
- const owned = Object.entries(leases).filter(([_, l]) => l.session === sid);
778
- if (owned.length === 0) {
779
- die('No <path> given and no held lease to infer from.\nUsage: dotmd set <status> <path>');
780
- }
781
- if (owned.length > 1) {
782
- const paths = owned.map(([p]) => ` - ${p}`).join('\n');
783
- die(`No <path> given; you hold ${owned.length} leases:\n${paths}\nSpecify <path> explicitly.`);
784
- }
785
- input = owned[0][0];
786
- }
539
+ if (!newStatus) die('Usage: dotmd set <status> <path>');
540
+ if (!input) die('Usage: dotmd set <status> <path>');
787
541
 
788
542
  const filePath = resolveDocPath(input, config);
789
543
  if (!filePath) die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`);
790
544
 
791
- const raw = readFileSync(filePath, 'utf8');
792
- const { frontmatter: fmRaw } = extractFrontmatter(raw);
793
- const parsedFm = parseSimpleFrontmatter(fmRaw);
794
- const oldStatus = asString(parsedFm.status);
795
-
796
545
  const inArchive = isArchivedPath(toRepoPath(filePath, config.repoRoot), config);
797
546
 
798
547
  if (config.lifecycle.archiveStatuses.has(newStatus) && !inArchive) {
@@ -806,12 +555,6 @@ export async function runSet(argv, config, opts = {}) {
806
555
  if (noIndex) statusArgs.push('--no-index');
807
556
  if (showFiles) statusArgs.push('--show-files');
808
557
  await runStatus(statusArgs, config, { dryRun, suppressDeprecation: true });
809
-
810
- if (oldStatus === 'in-session' && newStatus !== 'in-session' && !dryRun) {
811
- const repoPath = toRepoPath(filePath, config.repoRoot);
812
- try { releaseLease(config, repoPath, { force: false }); }
813
- catch (err) { warn(`Could not release lease for ${repoPath}: ${err.message}`); }
814
- }
815
558
  }
816
559
 
817
560
  export function runBulkArchive(argv, config, opts = {}) {
package/src/rename.mjs CHANGED
@@ -1,7 +1,6 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { toRepoPath, resolveDocPath, die, warn } from './util.mjs';
4
- import { migrateLease } from './lease.mjs';
5
4
  import { collectDocFiles } from './index.mjs';
6
5
  import { regenIndex } from './lifecycle.mjs';
7
6
  import { gitMv } from './git.mjs';
@@ -100,8 +99,6 @@ export async function runRename(argv, config, opts = {}) {
100
99
  }
101
100
  }
102
101
 
103
- try { migrateLease(config, oldRepoPath, newRepoPath); } catch (err) { warn(`Could not migrate lease ${oldRepoPath} → ${newRepoPath}: ${err.message}`); }
104
-
105
102
  regenIndex(config);
106
103
 
107
104
  process.stdout.write(`${green('Renamed')}: ${oldRepoPath} → ${newRepoPath}\n`);
package/src/render.mjs CHANGED
@@ -4,7 +4,6 @@ import { capitalize, toSlug, truncate, warn } from './util.mjs';
4
4
  import { extractFrontmatter } from './frontmatter.mjs';
5
5
  import { summarizeDocBody } from './ai.mjs';
6
6
  import { bold, red, yellow, green, dim } from './color.mjs';
7
- import { findStaleLeases } from './lease.mjs';
8
7
  import { categorizeWarnings } from './check-collapse.mjs';
9
8
 
10
9
  // Render `currentState` with an `(auto)` prefix when the value was body-scraped
@@ -357,13 +356,6 @@ export function renderBriefing(index, config) {
357
356
  : `Errors: ${errorCount}`;
358
357
  lines.push(`Stale: ${stale} | ${errorPart} | Warnings: ${index.warnings.length}`);
359
358
 
360
- try {
361
- const staleLeases = findStaleLeases(config);
362
- if (staleLeases.length > 0) {
363
- lines.push(yellow(`Stuck in-session: ${staleLeases.length} (>4h or dead same-host pid, run \`dotmd release --stale\`)`));
364
- }
365
- } catch {}
366
-
367
359
  return lines.join('\n') + '\n';
368
360
  }
369
361
 
package/src/runlist.mjs CHANGED
@@ -217,20 +217,19 @@ export async function runRunlist(argv, config, opts = {}) {
217
217
  die(
218
218
  `Next child in runlist ${hubRepoPath} is ${target.path} (status: ${target.status}).\n` +
219
219
  `Resolve the blocker before continuing the runlist.\n` +
220
- ` dotmd status ${target.path} active # if ready to resume\n` +
221
- ` dotmd pickup ${target.path} # to inspect`,
220
+ ` dotmd set active ${target.path} # if ready to resume\n` +
221
+ ` dotmd use ${target.path} # to inspect`,
222
222
  );
223
223
  }
224
224
 
225
- // Delegate to runPickup — same lease semantics, same VH append, same card
226
- // render. Dynamic import to avoid circular module-load cost when the
227
- // runlist command isn't used.
228
- const { runPickup } = await import('./lifecycle.mjs');
229
- const pickupArgs = [target.path];
230
- if (argv.includes('--takeover')) pickupArgs.push('--takeover');
231
- if (argv.includes('--full')) pickupArgs.push('--full');
232
- if (argv.includes('--no-index')) pickupArgs.push('--no-index');
233
- if (argv.includes('--show-files')) pickupArgs.push('--show-files');
234
- if (json) pickupArgs.push('--json');
235
- await runPickup(pickupArgs, config, opts);
225
+ // Open the next child: set it in-session (frontmatter) and render its card.
226
+ // Dynamic import to avoid circular module-load cost when the runlist command
227
+ // isn't used.
228
+ const { startPlan } = await import('./lifecycle.mjs');
229
+ const startArgs = [target.path];
230
+ if (argv.includes('--full')) startArgs.push('--full');
231
+ if (argv.includes('--no-index')) startArgs.push('--no-index');
232
+ if (argv.includes('--show-files')) startArgs.push('--show-files');
233
+ if (json) startArgs.push('--json');
234
+ await startPlan(startArgs, config, opts);
236
235
  }
package/src/ship.mjs CHANGED
@@ -4,7 +4,6 @@ import path from 'node:path';
4
4
  import { die, warn, toRepoPath } from './util.mjs';
5
5
  import { green, dim, yellow } from './color.mjs';
6
6
  import { scaffoldClaudeCommands } from './claude-commands.mjs';
7
- import { readLeases, currentSessionId } from './lease.mjs';
8
7
 
9
8
  // Files dotmd ship will auto-stage when they're dirty. Anything outside this
10
9
  // allowlist stays in the working tree — user has to `git add` it explicitly,
@@ -62,14 +61,6 @@ function listDirtyFiles(repoRoot) {
62
61
  });
63
62
  }
64
63
 
65
- function findHeldPlanTitle(config) {
66
- const leases = readLeases(config);
67
- const sid = currentSessionId();
68
- const owned = Object.entries(leases).filter(([_, l]) => l.session === sid);
69
- if (owned.length !== 1) return null;
70
- return path.basename(owned[0][0], '.md');
71
- }
72
-
73
64
  export async function runShip(argv, config, opts = {}) {
74
65
  const { dryRun } = opts;
75
66
  const positional = argv.filter(a => !a.startsWith('-'));
@@ -127,10 +118,7 @@ export async function runShip(argv, config, opts = {}) {
127
118
  const add = spawnSync('git', ['add', '--', ...allToStage], { cwd: config.repoRoot, encoding: 'utf8' });
128
119
  if (add.status !== 0) die(`git add failed: ${add.stderr}`);
129
120
 
130
- const planTitle = findHeldPlanTitle(config);
131
- const subject = planTitle
132
- ? `chore: release ${target} (${planTitle})`
133
- : `chore: release ${target}`;
121
+ const subject = `chore: release ${target}`;
134
122
  const body = `Auto-staged by \`dotmd ship\`:\n${allToStage.map(p => `- ${p}`).join('\n')}`;
135
123
  const commitMsg = `${subject}\n\n${body}`;
136
124
  const commit = spawnSync('git', ['commit', '-m', commitMsg], { cwd: config.repoRoot, encoding: 'utf8' });
package/src/use.mjs CHANGED
@@ -2,13 +2,13 @@ import { readFileSync } from 'node:fs';
2
2
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
3
3
  import { asString, die, resolveDocPath, toRepoPath } from './util.mjs';
4
4
  import { consumePrompt, pendingPromptsOldestFirst, resolvePromptInput } from './prompts.mjs';
5
- import { runPickup } from './lifecycle.mjs';
5
+ import { startPlan } from './lifecycle.mjs';
6
6
 
7
7
  // Top-level `dotmd use [file]` — the single "start engaging with this doc"
8
8
  // verb. Dispatches by the target doc's `type:` so agents don't have to know
9
9
  // the verb-per-type rule:
10
10
  // - prompt → print body + archive (one-shot consume)
11
- // - plan → acquire lease + print body (same as `set in-session`)
11
+ // - plan → set status in-session + print the plan card
12
12
  // - doc → print body (read-only)
13
13
  // With no argument, consumes the oldest pending prompt.
14
14
  export async function runUse(argv, config, opts = {}) {
@@ -34,8 +34,8 @@ export async function runUse(argv, config, opts = {}) {
34
34
  return consumePrompt(filePath, config, opts);
35
35
  }
36
36
  if (type === 'plan') {
37
- // Delegate to the lease-acquisition path. Same flow as `set in-session`.
38
- return runPickup([filePath, ...argv.filter(a => a !== positional)], config, opts);
37
+ // Mark in-session (pure frontmatter) and print the plan card.
38
+ return startPlan([filePath, ...argv.filter(a => a !== positional)], config, opts);
39
39
  }
40
40
  // Anything else (doc, untyped, custom): print the body. The frontmatter
41
41
  // already names everything an agent needs to know about lifecycle for that