dotmd-cli 0.50.2 → 0.52.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/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 = {}) {
@@ -0,0 +1,62 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readMisuseEntries, globalMisuseLogPath } from './journal.mjs';
3
+ import { dim, red, yellow } from './color.mjs';
4
+
5
+ // `dotmd misuse` — read the cross-repo guard log. Every wrong-move the
6
+ // PreToolUse guard intercepts lands here; this is the operator's window into
7
+ // "what are sessions getting wrong, and how often."
8
+ function parseArgs(argv) {
9
+ const opts = { tail: null, byRule: false, asJson: false, repo: null };
10
+ for (let i = 0; i < argv.length; i++) {
11
+ const a = argv[i];
12
+ if (a === '--tail') { const n = parseInt(argv[++i], 10); opts.tail = Number.isFinite(n) && n > 0 ? n : 20; }
13
+ else if (a === '--by-rule') opts.byRule = true;
14
+ else if (a === '--json') opts.asJson = true;
15
+ else if (a === '--repo' && argv[i + 1]) opts.repo = argv[++i];
16
+ }
17
+ if (opts.tail === null && !opts.byRule) opts.tail = 20;
18
+ return opts;
19
+ }
20
+
21
+ export function runMisuse(argv, _config) {
22
+ const file = globalMisuseLogPath();
23
+ if (!existsSync(file)) {
24
+ process.stderr.write(
25
+ 'No misuse log yet. The PreToolUse guard (`dotmd guard`) writes here when it intercepts a wrong move.\n' +
26
+ 'Wire it up: add a PreToolUse hook that runs `dotmd guard` (see `dotmd help guard`).\n',
27
+ );
28
+ return;
29
+ }
30
+
31
+ const opts = parseArgs(argv);
32
+ let entries = readMisuseEntries();
33
+ if (opts.repo) entries = entries.filter(e => (e.repo || '').includes(opts.repo));
34
+
35
+ if (opts.byRule) {
36
+ const groups = new Map();
37
+ for (const e of entries) {
38
+ const key = e.rule || '(unknown)';
39
+ if (!groups.has(key)) groups.set(key, { rule: key, count: 0, deny: 0, warn: 0 });
40
+ const g = groups.get(key);
41
+ g.count++;
42
+ if (e.decision === 'deny') g.deny++; else if (e.decision === 'warn') g.warn++;
43
+ }
44
+ const rows = [...groups.values()].sort((a, b) => b.count - a.count);
45
+ if (opts.asJson) { process.stdout.write(JSON.stringify(rows, null, 2) + '\n'); return; }
46
+ if (!rows.length) { process.stdout.write('No misuse events recorded.\n'); return; }
47
+ for (const r of rows) {
48
+ process.stdout.write(`${r.rule.padEnd(16)} ${String(r.count).padStart(4)}× ${red(`${r.deny} deny`)} / ${yellow(`${r.warn} warn`)}\n`);
49
+ }
50
+ return;
51
+ }
52
+
53
+ if (opts.tail) entries = entries.slice(-opts.tail);
54
+ if (opts.asJson) { process.stdout.write(JSON.stringify(entries, null, 2) + '\n'); return; }
55
+ if (!entries.length) { process.stdout.write('No misuse events recorded.\n'); return; }
56
+
57
+ for (const e of entries) {
58
+ const tag = e.decision === 'deny' ? red('DENY') : yellow('warn');
59
+ const repo = e.repo ? dim(` ${e.repo.split('/').pop()}`) : '';
60
+ process.stdout.write(`[${e.ts}] ${tag} ${e.rule || '?'}${repo} ${dim(e.detail || '')}\n`);
61
+ }
62
+ }
package/src/new.mjs CHANGED
@@ -539,6 +539,22 @@ export async function runNew(argv, config, opts = {}) {
539
539
  process.stdout.write(`${green('Created')}: ${repoPath} ${dim(`(${typeName})`)}\n`);
540
540
  if (rootHint) process.stdout.write(dim(rootHint));
541
541
 
542
+ // Post-create guidance. Prompts are the classic confusion point: agents
543
+ // reflexively `git add && commit` a freshly-created file, but saved prompts
544
+ // are session-local handoff artifacts — the next session consumes them via
545
+ // `dotmd use`, and the prompts dir is often gitignored (the commit then fails
546
+ // confusingly). Tell the agent the next step explicitly, and flag a gitignored
547
+ // target for any type so "why won't this commit" never happens silently.
548
+ if (typeName === 'prompt') {
549
+ process.stdout.write(dim('Session-local — no need to commit. The next session runs `dotmd use` (or `dotmd use ' + repoPath + '`) to consume it.\n'));
550
+ }
551
+ try {
552
+ const { isGitIgnored } = await import('./git.mjs');
553
+ if (isGitIgnored(filePath, config.repoRoot)) {
554
+ process.stdout.write(dim(`Note: ${repoPath} is gitignored — don't try to git add/commit it.\n`));
555
+ }
556
+ } catch { /* git absent / not a repo — skip the note */ }
557
+
542
558
  regenIndex(config);
543
559
 
544
560
  if (showFiles) {
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
package/src/util.mjs CHANGED
@@ -1,7 +1,17 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import os from 'node:os';
3
4
  import { dim } from './color.mjs';
4
5
 
6
+ // Stable identifier for the current shell/agent session. Used for journal
7
+ // attribution and hint de-duplication — not for any plan locking.
8
+ export function currentSessionId() {
9
+ if (process.env.CLAUDE_CODE_SESSION_ID) return process.env.CLAUDE_CODE_SESSION_ID;
10
+ if (process.env.CLAUDE_SESSION_ID) return process.env.CLAUDE_SESSION_ID;
11
+ if (process.env.TERM_SESSION_ID) return `term:${process.env.TERM_SESSION_ID}`;
12
+ return `shell:${os.userInfo().username}@${os.hostname()}`;
13
+ }
14
+
5
15
  export function escapeTable(value) {
6
16
  return String(value).replace(/\|/g, '\\|');
7
17
  }
package/src/validate.mjs CHANGED
@@ -2,7 +2,6 @@ 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, STALE_LEASE_AGE_HOURS } from './lease.mjs';
6
5
 
7
6
  const NOW = new Date();
8
7
 
@@ -202,31 +201,6 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
202
201
  doc.warnings.push({ path: doc.path, level: 'warning', message: 'Archived plan missing `## Closeout` section.' });
203
202
  }
204
203
 
205
- // F11: `status: in-session` plans should have a matching live lease. If the
206
- // lease file has no entry, the previous session crashed without releasing;
207
- // if the entry is stale (> stale-threshold hours), the holder is gone.
208
- // Either way the validator is the only place that knows enough to suggest
209
- // the exact unstuck command, because the lease infrastructure is otherwise
210
- // invisible to `dotmd check`.
211
- if (doc.status === 'in-session' && !config.lifecycle.skipWarningsFor.has(doc.status)) {
212
- const leases = readLeases(config);
213
- const lease = leases[doc.path];
214
- if (!lease) {
215
- doc.warnings.push({
216
- path: doc.path,
217
- level: 'warning',
218
- message: `\`status: in-session\` but no session is actually working on this (previous session may have crashed). Run \`dotmd set active ${doc.path}\` to clear and re-queue.`,
219
- });
220
- } else if (isLeaseStale(lease)) {
221
- const ageHours = Math.floor((Date.now() - new Date(lease.pickedUpAt).getTime()) / (1000 * 60 * 60));
222
- doc.warnings.push({
223
- path: doc.path,
224
- level: 'warning',
225
- message: `\`status: in-session\` but last activity was ${ageHours}h ago (>${STALE_LEASE_AGE_HOURS}h, looks abandoned). Run \`dotmd set active ${doc.path}\` to clear and re-queue.`,
226
- });
227
- }
228
- }
229
-
230
204
  // Archive drift: a doc with an archive-flagged status (`status: archived` by
231
205
  // default) whose parent dir is a "live" type-conventional location is
232
206
  // misplaced — `dotmd archive` would have moved it under `<that>/archiveDir/`.