dotmd-cli 0.16.0 → 0.17.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/README.md CHANGED
@@ -117,12 +117,12 @@ The default plan vocabulary is shaped around the **unstuck-action test**: every
117
117
  | `planned` | Wait for trigger | Queued; not yet ready to execute. |
118
118
  | `blocked` | **Monitor** | External arrival on its own schedule (hardware, vendor, third-party rollout). You can't speed it up. |
119
119
  | `partial` | **Spawn successors** | Shipped most of the plan; tail deferred. Body should reference successor plans tracking the tail. Visible but quiet (no nagging). |
120
- | `paused` | **Re-evaluate** | Intentionally set aside, no external dependency. Resume by deciding the work is still worth doing. Quiet. |
120
+ | `paused` | **Re-evaluate** | Started but stopped mid-work; needs near-term review. NOT quiet short (3-day) stale threshold so resume-decisions don't decay. |
121
121
  | `awaiting` | **Ask** | Needs a human decision or input. NOT quiet — pings get forgotten, so this status generates stale pressure to chase the answer. |
122
122
  | `queued-after` | **Check predecessor** | Sequenced behind another plan; can start once that one ships. Quiet. |
123
123
  | `archived` | — | No longer relevant; auto-moved to the archive directory on transition. |
124
124
 
125
- Each *quiet* status (`partial`, `paused`, `queued-after`, `archived`) is exempt from stale-warning pressure but still appears in active scope and metrics — quietness is a presentation flag, not a closure flag. `awaiting` deliberately stays loud so unanswered questions don't decay into invisible backlog.
125
+ Each *quiet* status (`partial`, `queued-after`, `archived`) is exempt from stale-warning pressure but still appears in active scope and metrics — quietness is a presentation flag, not a closure flag. `awaiting` and `paused` deliberately stay loud so unanswered questions and stalled mid-flight work don't decay into invisible backlog.
126
126
 
127
127
  > **Heads-up:** versions before 0.15 included a `done` plan status in the defaults. It saw effectively zero real-world use (plans went `in-session`/`active` → `archived` directly), so it was dropped from the built-in vocabulary. To finish a plan, run `dotmd archive <plan-file>` — or, if you preferred the previous behavior, add `done` back via the `types.plan.statuses` key in your config.
128
128
 
@@ -402,6 +402,62 @@ dotmd finish docs/plans/my-plan.md # set done + bump date
402
402
  dotmd finish docs/plans/my-plan.md active # back to active for more work
403
403
  ```
404
404
 
405
+ ### Session leases & unpickup
406
+
407
+ `dotmd pickup` records a lease at `<repoRoot>/.dotmd/in-session.json` that
408
+ identifies which Claude session owns the plan. The lease enables three
409
+ distinct outcomes when a plan is already `in-session`:
410
+
411
+ - **Same session re-attach.** A fresh `dotmd pickup` of a plan you already
412
+ hold (e.g., after `/clear` or auto-compaction) silently re-attaches and
413
+ re-prints the body. No conflict.
414
+ - **Cross-session conflict.** If another live session holds the plan,
415
+ pickup refuses with `Held by <host>/<session> (pid <pid>) since <time>`.
416
+ - **Stale lease.** If the holder's pid is dead (or the lease is >24h old),
417
+ pickup refuses but suggests `--takeover`.
418
+
419
+ Releasing leases:
420
+
421
+ ```bash
422
+ dotmd unpickup # release every lease owned by current session
423
+ dotmd unpickup docs/plans/foo.md # release that one (refuses cross-session)
424
+ dotmd unpickup --to planned # override target status (default: lease.oldStatus)
425
+ dotmd unpickup --stale # release leases with dead pid or >24h old
426
+ dotmd unpickup --all # release every lease (administrative)
427
+ dotmd unpickup --json # { released: [...], skipped: [...] }
428
+ ```
429
+
430
+ `finish`, `archive`, and `rename` auto-release / migrate the lease, so the
431
+ common closeout paths are covered without ceremony.
432
+
433
+ **Session id resolution** (in order, first wins):
434
+
435
+ 1. `$CLAUDE_CODE_SESSION_ID` (set by Claude Code in Bash subprocess env)
436
+ 2. `$CLAUDE_SESSION_ID` (legacy alias)
437
+ 3. `$TERM_SESSION_ID` (macOS Terminal/iTerm — stable per window)
438
+ 4. `shell:<user>@<host>` (last-resort coarse fallback)
439
+
440
+ The session id survives `/clear` and auto-compaction, so a re-attach after
441
+ either is silent.
442
+
443
+ **Auto-release on Claude Code session end** — add this to
444
+ `~/.claude/settings.json`:
445
+
446
+ ```json
447
+ {
448
+ "SessionEnd": [
449
+ { "type": "command", "command": "dotmd unpickup", "timeout": 10 }
450
+ ]
451
+ }
452
+ ```
453
+
454
+ When the Claude Code session ends, the hook runs `dotmd unpickup` with
455
+ `$CLAUDE_CODE_SESSION_ID` in the environment, releasing every lease for
456
+ that session and flipping plans back to their prior status.
457
+
458
+ `dotmd briefing` surfaces a `Stuck in-session: N` line when stale leases
459
+ exist, with a `dotmd unpickup --stale` suggestion.
460
+
405
461
  ### Touch
406
462
 
407
463
  ```bash
package/bin/dotmd.mjs CHANGED
@@ -41,7 +41,8 @@ Validate & Fix:
41
41
  fix-refs [--dry-run] Auto-fix broken reference paths + body links
42
42
 
43
43
  Lifecycle:
44
- pickup <file> Pick up a plan (set in-session + print)
44
+ pickup <file> [--takeover] Pick up a plan (set in-session + print)
45
+ unpickup [<file>] [--to <s>] Release in-session lease (default: all owned by current session)
45
46
  finish <file> [done|active] Finish a plan (set done or active)
46
47
  status <file> <status> Transition document status
47
48
  archive <file> Archive (status + move + update refs)
@@ -115,15 +116,44 @@ Filters:
115
116
 
116
117
  pickup: `dotmd pickup <file> — pick up a plan and start working
117
118
 
118
- Sets the plan to in-session and prints its content.
119
- Fails if the plan is already in-session, blocked, done, or archived.
119
+ Sets the plan to in-session and prints its content. Writes a session
120
+ lease to <repoRoot>/.dotmd/in-session.json so the same Claude session
121
+ can re-attach silently after compaction or /clear.
122
+
123
+ If a plan is already in-session:
124
+ - Same session → silent re-attach (prints body, no error).
125
+ - Different session, live pid → refuses with "Held by …" message.
126
+ - Different session, dead pid or >24h old → suggests --takeover.
120
127
 
121
128
  Options:
129
+ --takeover Force-claim a plan held by another session
122
130
  --json Output as JSON
123
131
  --dry-run, -n Preview without writing
124
132
 
125
133
  If no file is given, prompts with a list of active plans.`,
126
134
 
135
+ unpickup: `dotmd unpickup [<file>] — release a plan from in-session
136
+
137
+ With no file: releases every lease owned by the current session.
138
+ This is the form intended for a Claude Code SessionEnd hook.
139
+
140
+ With <file>: releases that one. Refuses if held by another session
141
+ (use --force to override).
142
+
143
+ Flips the plan's frontmatter status from in-session back to its
144
+ prior status (recorded by pickup), or whatever --to specifies.
145
+
146
+ Options:
147
+ --to <status> Override target status (default: lease.oldStatus → fallback active)
148
+ --all Release every lease in the file (administrative)
149
+ --stale Release leases whose pid is dead or age >24h
150
+ --force Override "not yours" refusal on a specific file
151
+ --json Output as JSON ({ released, skipped })
152
+ --dry-run, -n Preview without writing
153
+
154
+ Manual-edit fallback: if the plan's status is in-session but no lease
155
+ exists, --to <status> flips it anyway with a warning.`,
156
+
127
157
  finish: `dotmd finish <file> [done|active] — finish working on a plan
128
158
 
129
159
  Sets the plan status to done (default) or back to active.
@@ -606,6 +636,7 @@ async function main() {
606
636
 
607
637
  // Lifecycle commands
608
638
  if (command === 'pickup') { const { runPickup } = await import('../src/lifecycle.mjs'); await runPickup(restArgs, config, { dryRun }); return; }
639
+ if (command === 'unpickup') { const { runUnpickup } = await import('../src/lifecycle.mjs'); await runUnpickup(restArgs, config, { dryRun }); return; }
609
640
  if (command === 'finish') { const { runFinish } = await import('../src/lifecycle.mjs'); await runFinish(restArgs, config, { dryRun }); return; }
610
641
  if (command === 'status') { const { runStatus } = await import('../src/lifecycle.mjs'); await runStatus(restArgs, config, { dryRun }); return; }
611
642
  if (command === 'archive') { const { runArchive } = await import('../src/lifecycle.mjs'); runArchive(restArgs, config, { dryRun }); return; }
@@ -50,7 +50,7 @@ export const excludeDirs = ['evidence'];
50
50
  // 'planned': { context: 'listed', staleDays: 30, requiresModule: true },
51
51
  // 'blocked': { context: 'listed', staleDays: 30, requiresModule: true },
52
52
  // 'partial': { context: 'expanded', requiresModule: true, quiet: true }, // shipped + deferred tail; visible, no nagging
53
- // 'paused': { context: 'listed', requiresModule: true, quiet: true }, // intentionally set aside, no external dep
53
+ // 'paused': { context: 'listed', staleDays: 3, requiresModule: true }, // stopped mid-work, near-term review — loud (short stale threshold)
54
54
  // 'awaiting': { context: 'listed', staleDays: 14, requiresModule: true }, // human input/decision wait — NOT quiet (pings get forgotten)
55
55
  // 'queued-after': { context: 'counted', requiresModule: true, quiet: true }, // sequenced behind another plan
56
56
  // 'archived': { context: 'counted', archive: true, terminal: true, quiet: true },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, Notion sync, AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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', 'finish', 'status', 'archive', 'bulk', 'touch', 'doctor', 'lint', 'rename', 'migrate',
5
+ 'plans', 'stale', 'actionable', 'index', 'pickup', 'unpickup', 'finish', 'status', 'archive', 'bulk', 'touch', 'doctor', 'lint', 'rename', 'migrate',
6
6
  'fix-refs', 'notion', 'export', 'summary', 'watch', 'diff', 'init', 'new', 'completions',
7
7
  ];
8
8
 
@@ -33,7 +33,8 @@ const COMMAND_FLAGS = {
33
33
  stale: ['--json', '--sort', '--limit', '--all'],
34
34
  actionable: ['--json', '--sort', '--limit', '--all'],
35
35
  briefing: ['--json'],
36
- pickup: ['--json'],
36
+ pickup: ['--json', '--takeover'],
37
+ unpickup: ['--json', '--all', '--stale', '--to', '--force'],
37
38
  finish: ['--json'],
38
39
  status: [],
39
40
  archive: [],
package/src/config.mjs CHANGED
@@ -28,7 +28,7 @@ const DEFAULTS = {
28
28
  listed: ['planned', 'blocked', 'paused', 'awaiting'],
29
29
  counted: ['queued-after', 'archived'],
30
30
  },
31
- staleDays: { 'in-session': 1, active: 14, planned: 30, blocked: 30, awaiting: 14 },
31
+ staleDays: { 'in-session': 1, active: 14, planned: 30, blocked: 30, paused: 3, awaiting: 14 },
32
32
  },
33
33
  doc: {
34
34
  statuses: ['draft', 'active', 'review', 'reference', 'deprecated', 'archived'],
@@ -55,8 +55,8 @@ const DEFAULTS = {
55
55
 
56
56
  lifecycle: {
57
57
  archiveStatuses: ['archived'],
58
- skipStaleFor: ['archived', 'reference', 'partial', 'paused', 'queued-after'],
59
- skipWarningsFor: ['archived', 'partial', 'paused', 'queued-after'],
58
+ skipStaleFor: ['archived', 'reference', 'partial', 'queued-after'],
59
+ skipWarningsFor: ['archived', 'partial', 'queued-after'],
60
60
  terminalStatuses: ['archived', 'deprecated', 'reference'],
61
61
  },
62
62
 
package/src/init.mjs CHANGED
@@ -139,6 +139,24 @@ export function runInit(cwd, config) {
139
139
  process.stdout.write(` ${green('create')} docs/docs.md\n`);
140
140
  }
141
141
 
142
+ // .gitignore: ensure .dotmd/ is ignored (session leases live there)
143
+ const gitignorePath = path.join(cwd, '.gitignore');
144
+ const ignoreLine = '.dotmd/';
145
+ if (existsSync(gitignorePath)) {
146
+ const current = readFileSync(gitignorePath, 'utf8');
147
+ const has = current.split('\n').some(l => l.trim() === ignoreLine || l.trim() === '.dotmd');
148
+ if (!has) {
149
+ const sep = current.endsWith('\n') ? '' : '\n';
150
+ writeFileSync(gitignorePath, `${current}${sep}${ignoreLine}\n`, 'utf8');
151
+ process.stdout.write(` ${green('update')} .gitignore (+${ignoreLine})\n`);
152
+ } else {
153
+ process.stdout.write(` ${dim('exists')} .gitignore\n`);
154
+ }
155
+ } else {
156
+ writeFileSync(gitignorePath, `${ignoreLine}\n`, 'utf8');
157
+ process.stdout.write(` ${green('create')} .gitignore\n`);
158
+ }
159
+
142
160
  // Claude Code integration — auto-detect .claude/ directory
143
161
  if (config) {
144
162
  const results = scaffoldClaudeCommands(cwd, config);
package/src/lease.mjs ADDED
@@ -0,0 +1,221 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, openSync, closeSync, unlinkSync, statSync, renameSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { warn } from './util.mjs';
5
+
6
+ const LEASE_DIR = '.dotmd';
7
+ const LEASE_FILE = 'in-session.json';
8
+ const LOCK_FILE = 'in-session.lock';
9
+ const LOCK_STALE_MS = 5_000;
10
+ const LOCK_RETRY_MS = 50;
11
+ const LOCK_MAX_WAIT_MS = 2_000;
12
+ const STALE_LEASE_AGE_MS = 24 * 60 * 60 * 1000;
13
+
14
+ const _sleepBuf = new Int32Array(new SharedArrayBuffer(4));
15
+ function syncSleep(ms) { Atomics.wait(_sleepBuf, 0, 0, ms); }
16
+
17
+ export function currentSessionId() {
18
+ if (process.env.CLAUDE_CODE_SESSION_ID) return process.env.CLAUDE_CODE_SESSION_ID;
19
+ if (process.env.CLAUDE_SESSION_ID) return process.env.CLAUDE_SESSION_ID;
20
+ if (process.env.TERM_SESSION_ID) return `term:${process.env.TERM_SESSION_ID}`;
21
+ return `shell:${os.userInfo().username}@${os.hostname()}`;
22
+ }
23
+
24
+ function dirFor(config) { return path.join(config.repoRoot, LEASE_DIR); }
25
+ function leaseFilePath(config) { return path.join(dirFor(config), LEASE_FILE); }
26
+ function lockFilePath(config) { return path.join(dirFor(config), LOCK_FILE); }
27
+
28
+ export function readLeases(config) {
29
+ const file = leaseFilePath(config);
30
+ if (!existsSync(file)) return {};
31
+ try {
32
+ const raw = readFileSync(file, 'utf8');
33
+ if (!raw.trim()) return {};
34
+ const parsed = JSON.parse(raw);
35
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
36
+ return {};
37
+ } catch (err) {
38
+ warn(`Lease file at ${file} is corrupt (${err.message}); treating as empty.`);
39
+ return {};
40
+ }
41
+ }
42
+
43
+ export function writeLeases(config, leases) {
44
+ const dir = dirFor(config);
45
+ mkdirSync(dir, { recursive: true });
46
+ const file = leaseFilePath(config);
47
+ if (Object.keys(leases).length === 0) {
48
+ if (existsSync(file)) {
49
+ try { unlinkSync(file); } catch {}
50
+ }
51
+ return;
52
+ }
53
+ const tmp = `${file}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
54
+ writeFileSync(tmp, JSON.stringify(leases, null, 2) + '\n', 'utf8');
55
+ renameSync(tmp, file);
56
+ }
57
+
58
+ export function withLeaseLock(config, fn) {
59
+ const dir = dirFor(config);
60
+ mkdirSync(dir, { recursive: true });
61
+ const lock = lockFilePath(config);
62
+ const start = Date.now();
63
+ let fd = null;
64
+ while (true) {
65
+ try {
66
+ fd = openSync(lock, 'wx');
67
+ break;
68
+ } catch (err) {
69
+ if (err.code !== 'EEXIST') throw err;
70
+ try {
71
+ const st = statSync(lock);
72
+ if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
73
+ try { unlinkSync(lock); } catch {}
74
+ continue;
75
+ }
76
+ } catch {}
77
+ if (Date.now() - start > LOCK_MAX_WAIT_MS) {
78
+ throw new Error(`Could not acquire lease lock at ${lock} within ${LOCK_MAX_WAIT_MS}ms`);
79
+ }
80
+ syncSleep(LOCK_RETRY_MS);
81
+ }
82
+ }
83
+ try {
84
+ return fn();
85
+ } finally {
86
+ try { closeSync(fd); } catch {}
87
+ try { unlinkSync(lock); } catch {}
88
+ }
89
+ }
90
+
91
+ export function isPidAlive(pid, host) {
92
+ if (!Number.isInteger(pid) || pid <= 0) return false;
93
+ if (host && host !== os.hostname()) return true;
94
+ try {
95
+ process.kill(pid, 0);
96
+ return true;
97
+ } catch (err) {
98
+ return err.code === 'EPERM';
99
+ }
100
+ }
101
+
102
+ export function isLeaseStale(lease) {
103
+ const t = new Date(lease.pickedUpAt).getTime();
104
+ if (Number.isNaN(t)) return true;
105
+ return Date.now() - t > STALE_LEASE_AGE_MS;
106
+ // Note: pid liveness is intentionally NOT used here. dotmd's own CLI pid is
107
+ // dead the moment the process exits, so it's not a useful signal for "is the
108
+ // session that wrote this lease still active." Use age and explicit takeover.
109
+ }
110
+
111
+ export function findStaleLeases(config) {
112
+ const leases = readLeases(config);
113
+ return Object.values(leases).filter(isLeaseStale);
114
+ }
115
+
116
+ export function acquireLease(config, repoPath, oldStatus, opts = {}) {
117
+ return withLeaseLock(config, () => {
118
+ const leases = readLeases(config);
119
+ const existing = leases[repoPath];
120
+ const session = opts.session ?? currentSessionId();
121
+
122
+ if (existing && existing.session === session) {
123
+ existing.pickedUpAt = new Date().toISOString();
124
+ existing.pid = process.pid;
125
+ leases[repoPath] = existing;
126
+ writeLeases(config, leases);
127
+ return { outcome: 'reattached', lease: existing, conflict: null };
128
+ }
129
+
130
+ if (existing && !opts.takeover) {
131
+ const ageMs = Date.now() - new Date(existing.pickedUpAt).getTime();
132
+ const stale = Number.isNaN(ageMs) || ageMs > STALE_LEASE_AGE_MS;
133
+ return {
134
+ outcome: stale ? 'conflict-stale' : 'conflict-alive',
135
+ conflict: existing,
136
+ lease: null,
137
+ };
138
+ }
139
+
140
+ const newLease = {
141
+ path: repoPath,
142
+ oldStatus: oldStatus ?? 'active',
143
+ pid: process.pid,
144
+ host: os.hostname(),
145
+ session,
146
+ pickedUpAt: new Date().toISOString(),
147
+ };
148
+ if (existing && opts.takeover) {
149
+ newLease.takenOverFrom = {
150
+ session: existing.session,
151
+ pid: existing.pid,
152
+ pickedUpAt: existing.pickedUpAt,
153
+ };
154
+ }
155
+ leases[repoPath] = newLease;
156
+ writeLeases(config, leases);
157
+ return { outcome: existing ? 'taken-over' : 'acquired', lease: newLease, conflict: existing ?? null };
158
+ });
159
+ }
160
+
161
+ export function releaseLease(config, repoPath, opts = {}) {
162
+ return withLeaseLock(config, () => {
163
+ const leases = readLeases(config);
164
+ const existing = leases[repoPath];
165
+ if (!existing) return { released: false, lease: null, reason: 'no-lease' };
166
+ const session = opts.session ?? currentSessionId();
167
+ if (existing.session !== session && !opts.force) {
168
+ return { released: false, lease: existing, reason: 'not-yours' };
169
+ }
170
+ delete leases[repoPath];
171
+ writeLeases(config, leases);
172
+ return { released: true, lease: existing, reason: null };
173
+ });
174
+ }
175
+
176
+ export function releaseAllForSession(config, sessionId, opts = {}) {
177
+ return withLeaseLock(config, () => {
178
+ const leases = readLeases(config);
179
+ const released = [];
180
+ for (const [key, lease] of Object.entries(leases)) {
181
+ if (lease.session === sessionId) {
182
+ released.push(lease);
183
+ delete leases[key];
184
+ }
185
+ }
186
+ if (released.length > 0) writeLeases(config, leases);
187
+ return { released };
188
+ });
189
+ }
190
+
191
+ export function releaseStale(config) {
192
+ return withLeaseLock(config, () => {
193
+ const leases = readLeases(config);
194
+ const released = [];
195
+ for (const [key, lease] of Object.entries(leases)) {
196
+ if (isLeaseStale(lease)) {
197
+ released.push(lease);
198
+ delete leases[key];
199
+ }
200
+ }
201
+ if (released.length > 0) writeLeases(config, leases);
202
+ return { released };
203
+ });
204
+ }
205
+
206
+ export function migrateLease(config, oldPath, newPath) {
207
+ return withLeaseLock(config, () => {
208
+ const leases = readLeases(config);
209
+ if (!leases[oldPath]) return false;
210
+ const lease = leases[oldPath];
211
+ lease.path = newPath;
212
+ leases[newPath] = lease;
213
+ delete leases[oldPath];
214
+ writeLeases(config, leases);
215
+ return true;
216
+ });
217
+ }
218
+
219
+ export function leasePathFor(config) {
220
+ return leaseFilePath(config);
221
+ }
package/src/lifecycle.mjs CHANGED
@@ -7,6 +7,15 @@ import { buildIndex, collectDocFiles } from './index.mjs';
7
7
  import { renderIndexFile, writeIndex } from './index-file.mjs';
8
8
  import { green, dim, yellow } 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
+ } from './lease.mjs';
10
19
 
11
20
  function findFileRoot(filePath, config) {
12
21
  const roots = config.docsRoots || [config.docsRoot];
@@ -124,6 +133,7 @@ export async function runStatus(argv, config, opts = {}) {
124
133
  export async function runPickup(argv, config, opts = {}) {
125
134
  const { dryRun } = opts;
126
135
  const json = argv.includes('--json');
136
+ const takeover = argv.includes('--takeover');
127
137
  let input = argv.find(a => !a.startsWith('-'));
128
138
 
129
139
  // Interactive: pick from active plans
@@ -152,32 +162,200 @@ export async function runPickup(argv, config, opts = {}) {
152
162
 
153
163
  if (docType && docType !== 'plan') warn(`${repoPath} has type '${docType}', not 'plan'.`);
154
164
 
155
- if (oldStatus === 'in-session') die(`Already in-session — another Claude instance may be working on this.\n ${repoPath}`);
156
165
  if (oldStatus === 'blocked') {
157
166
  const blockers = parsedFm.blockers ? (Array.isArray(parsedFm.blockers) ? parsedFm.blockers.join(', ') : String(parsedFm.blockers)) : 'unknown';
158
167
  die(`Plan is blocked: ${blockers}\n ${repoPath}`);
159
168
  }
160
- const pickupable = new Set(['active', 'planned']);
169
+
170
+ // If frontmatter says we're not in-session, any lingering lease is orphaned —
171
+ // drop it so a fresh acquire below doesn't see a phantom conflict.
172
+ if (oldStatus !== 'in-session') {
173
+ if (readLeases(config)[repoPath]) {
174
+ releaseLease(config, repoPath, { force: true });
175
+ }
176
+ }
177
+
178
+ const pickupable = new Set(['active', 'planned', 'in-session']);
161
179
  if (oldStatus && !pickupable.has(oldStatus)) die(`Cannot pick up a plan with status '${oldStatus}'. Must be active or planned.\n ${repoPath}`);
162
180
 
163
181
  const today = new Date().toISOString().slice(0, 10);
182
+ const leaseOldStatus = oldStatus === 'in-session' ? 'active' : (oldStatus ?? 'active');
183
+ let leaseOutcome = 'acquired';
164
184
 
165
185
  if (dryRun) {
166
- process.stderr.write(`${dim('[dry-run]')} Would update: status: ${oldStatus} in-session, updated: ${today}\n`);
186
+ if (oldStatus === 'in-session') {
187
+ process.stderr.write(`${dim('[dry-run]')} Would acquire lease (status already in-session)\n`);
188
+ } else {
189
+ process.stderr.write(`${dim('[dry-run]')} Would update: status: ${oldStatus} → in-session, updated: ${today}\n`);
190
+ }
167
191
  } else {
168
- updateFrontmatter(filePath, { status: 'in-session', updated: today });
192
+ const result = acquireLease(config, repoPath, leaseOldStatus, { takeover });
193
+ leaseOutcome = result.outcome;
194
+ if (result.outcome === 'conflict-alive') {
195
+ const c = result.conflict;
196
+ die(`Held by ${c.host}/${c.session} (pid ${c.pid}) since ${c.pickedUpAt}.\nUse --takeover to override.\n ${repoPath}`);
197
+ }
198
+ if (result.outcome === 'conflict-stale') {
199
+ const c = result.conflict;
200
+ die(`Stale in-session lease from ${c.host}/${c.session} since ${c.pickedUpAt} (>24h old).\nUse --takeover to claim.\n ${repoPath}`);
201
+ }
202
+ if (oldStatus !== 'in-session') {
203
+ updateFrontmatter(filePath, { status: 'in-session', updated: today });
204
+ }
169
205
  }
170
206
 
171
207
  if (json) {
172
- process.stdout.write(JSON.stringify({ path: repoPath, oldStatus, newStatus: 'in-session', title, body: body?.trim() ?? '' }, null, 2) + '\n');
208
+ process.stdout.write(JSON.stringify({
209
+ path: repoPath, oldStatus, newStatus: 'in-session', title,
210
+ reattached: leaseOutcome === 'reattached',
211
+ takenOver: leaseOutcome === 'taken-over',
212
+ body: body?.trim() ?? '',
213
+ }, null, 2) + '\n');
173
214
  } else {
174
- process.stderr.write(`${green('▶ Picked up')}: ${repoPath} (${oldStatus} → in-session)\n\n`);
215
+ if (leaseOutcome === 'reattached') {
216
+ process.stderr.write(`${green('▶ Re-attached')}: ${repoPath}\n\n`);
217
+ } else if (leaseOutcome === 'taken-over') {
218
+ process.stderr.write(`${green('▶ Took over')}: ${repoPath} (was ${oldStatus ?? 'unset'} → in-session)\n\n`);
219
+ } else {
220
+ process.stderr.write(`${green('▶ Picked up')}: ${repoPath} (${oldStatus ?? 'unset'} → in-session)\n\n`);
221
+ }
175
222
  if (body?.trim()) process.stdout.write(body.trim() + '\n');
176
223
  }
177
224
 
178
225
  try { config.hooks.onPickup?.({ path: repoPath, oldStatus, newStatus: 'in-session' }); } catch (err) { warn(`Hook 'onPickup' threw: ${err.message}`); }
179
226
  }
180
227
 
228
+ export async function runUnpickup(argv, config, opts = {}) {
229
+ const { dryRun } = opts;
230
+ const json = argv.includes('--json');
231
+ const all = argv.includes('--all');
232
+ const stale = argv.includes('--stale');
233
+ const force = argv.includes('--force');
234
+ const toIdx = argv.indexOf('--to');
235
+ const toStatus = toIdx >= 0 ? argv[toIdx + 1] : null;
236
+ const positional = argv.filter((a, i) => !a.startsWith('-') && argv[i - 1] !== '--to');
237
+ const fileArg = positional[0];
238
+
239
+ const session = currentSessionId();
240
+ const released = [];
241
+ const skipped = [];
242
+
243
+ // Decide which leases to act on
244
+ let targets = [];
245
+ const leases = readLeases(config);
246
+ if (fileArg) {
247
+ const filePath = resolveDocPath(fileArg, config);
248
+ if (!filePath) die(`File not found: ${fileArg}`);
249
+ const repoPath = toRepoPath(filePath, config.repoRoot);
250
+ if (leases[repoPath]) {
251
+ targets.push(leases[repoPath]);
252
+ } else {
253
+ // Manual-edit fallback: status may be in-session with no lease.
254
+ const raw = readFileSync(filePath, 'utf8');
255
+ const { frontmatter: fmRaw } = extractFrontmatter(raw);
256
+ const parsedFm = parseSimpleFrontmatter(fmRaw);
257
+ if (asString(parsedFm.status) === 'in-session') {
258
+ targets.push({ path: repoPath, oldStatus: null, session: null, pid: null, host: null, pickedUpAt: null, _orphan: true });
259
+ } else {
260
+ die(`Not in-session: ${repoPath}`);
261
+ }
262
+ }
263
+ } else if (all) {
264
+ targets = Object.values(leases);
265
+ } else if (stale) {
266
+ // releaseStale handled separately below — set a marker
267
+ targets = null;
268
+ } else {
269
+ // Default: release all owned by current session
270
+ targets = Object.values(leases).filter(l => l.session === session);
271
+ }
272
+
273
+ const targetStatus = (lease) => toStatus || lease.oldStatus || 'active';
274
+
275
+ function flipFrontmatter(repoPath, newStatus) {
276
+ const filePath = resolveDocPath(repoPath, config);
277
+ if (!filePath) {
278
+ warn(`Lease points at ${repoPath} but file not found — releasing lease without frontmatter update.`);
279
+ return;
280
+ }
281
+ try {
282
+ const raw = readFileSync(filePath, 'utf8');
283
+ const { frontmatter: fmRaw } = extractFrontmatter(raw);
284
+ const parsedFm = parseSimpleFrontmatter(fmRaw);
285
+ const cur = asString(parsedFm.status);
286
+ if (cur === 'in-session') {
287
+ const today = new Date().toISOString().slice(0, 10);
288
+ updateFrontmatter(filePath, { status: newStatus, updated: today });
289
+ }
290
+ // If frontmatter is no longer in-session (manual flip), leave it alone.
291
+ } catch (err) {
292
+ warn(`Could not update frontmatter for ${repoPath}: ${err.message}`);
293
+ }
294
+ }
295
+
296
+ if (targets === null) {
297
+ // --stale path
298
+ if (dryRun) {
299
+ const staleLeases = Object.values(leases).filter(l => {
300
+ const age = Date.now() - new Date(l.pickedUpAt).getTime();
301
+ return Number.isNaN(age) || age > 24 * 60 * 60 * 1000;
302
+ });
303
+ for (const l of staleLeases) {
304
+ process.stderr.write(`${dim('[dry-run]')} Would release stale: ${l.path} (${l.session})\n`);
305
+ }
306
+ } else {
307
+ const result = releaseStale(config);
308
+ for (const l of result.released) {
309
+ flipFrontmatter(l.path, targetStatus(l));
310
+ released.push({ path: l.path, oldStatus: l.oldStatus, newStatus: targetStatus(l), session: l.session, stale: true });
311
+ try { config.hooks.onUnpickup?.({ path: l.path, oldStatus: 'in-session', newStatus: targetStatus(l) }); } catch (err) { warn(`Hook 'onUnpickup' threw: ${err.message}`); }
312
+ }
313
+ }
314
+ } else {
315
+ if (targets.length === 0 && !json) {
316
+ process.stderr.write(`No leases to release${fileArg ? ` for ${fileArg}` : ` for session ${session}`}.\n`);
317
+ }
318
+ for (const lease of targets) {
319
+ const newStatus = targetStatus(lease);
320
+ if (dryRun) {
321
+ process.stderr.write(`${dim('[dry-run]')} Would release: ${lease.path} (${lease.oldStatus ?? '?'} → ${newStatus})\n`);
322
+ continue;
323
+ }
324
+ if (lease._orphan) {
325
+ // Manual-edit fallback: no lease entry, just flip frontmatter.
326
+ flipFrontmatter(lease.path, newStatus);
327
+ warn(`No lease found for ${lease.path}; flipped status manually.`);
328
+ released.push({ path: lease.path, oldStatus: 'in-session', newStatus, session: null, orphan: true });
329
+ try { config.hooks.onUnpickup?.({ path: lease.path, oldStatus: 'in-session', newStatus }); } catch (err) { warn(`Hook 'onUnpickup' threw: ${err.message}`); }
330
+ continue;
331
+ }
332
+ const isMine = lease.session === session;
333
+ if (!isMine && !force && !all && !stale) {
334
+ skipped.push({ path: lease.path, reason: 'not-yours', session: lease.session });
335
+ continue;
336
+ }
337
+ const r = releaseLease(config, lease.path, { force: true });
338
+ if (r.released) {
339
+ flipFrontmatter(lease.path, newStatus);
340
+ released.push({ path: lease.path, oldStatus: lease.oldStatus, newStatus, session: lease.session });
341
+ try { config.hooks.onUnpickup?.({ path: lease.path, oldStatus: 'in-session', newStatus }); } catch (err) { warn(`Hook 'onUnpickup' threw: ${err.message}`); }
342
+ }
343
+ }
344
+ }
345
+
346
+ if (json) {
347
+ process.stdout.write(JSON.stringify({ released, skipped }, null, 2) + '\n');
348
+ } else {
349
+ for (const r of released) {
350
+ const tag = r.stale ? ' (stale)' : (r.orphan ? ' (orphan)' : '');
351
+ process.stdout.write(`${green('↩ Unpicked')}: ${r.path} (in-session → ${r.newStatus})${tag}\n`);
352
+ }
353
+ for (const s of skipped) {
354
+ process.stderr.write(`${yellow('⚠ Skipped')}: ${s.path} (held by ${s.session}; use --force to override)\n`);
355
+ }
356
+ }
357
+ }
358
+
181
359
  export async function runFinish(argv, config, opts = {}) {
182
360
  const { dryRun } = opts;
183
361
  const json = argv.includes('--json');
@@ -230,6 +408,10 @@ export async function runFinish(argv, config, opts = {}) {
230
408
  process.stdout.write(`${green('✓ Finished')}: ${repoPath} (in-session → ${targetStatus})\n`);
231
409
  }
232
410
 
411
+ if (!dryRun) {
412
+ try { releaseLease(config, repoPath, { force: true }); } catch (err) { warn(`Could not release lease for ${repoPath}: ${err.message}`); }
413
+ }
414
+
233
415
  try { config.hooks.onFinish?.({ path: repoPath, oldStatus, newStatus: targetStatus }); } catch (err) { warn(`Hook 'onFinish' threw: ${err.message}`); }
234
416
  }
235
417
 
@@ -296,6 +478,8 @@ export function runArchive(argv, config, opts = {}) {
296
478
  if (updatedRefCount > 0) process.stdout.write(`Updated references in ${updatedRefCount} file(s).\n`);
297
479
  if (config.indexPath) process.stdout.write('Index regenerated.\n');
298
480
 
481
+ try { releaseLease(config, oldRepoPath, { force: true }); } catch (err) { warn(`Could not release lease for ${oldRepoPath}: ${err.message}`); }
482
+
299
483
  try { config.hooks.onArchive?.({ path: newRepoPath, oldStatus }, { oldPath: oldRepoPath, newPath: newRepoPath }); } catch (err) { warn(`Hook 'onArchive' threw: ${err.message}`); }
300
484
  }
301
485
 
package/src/rename.mjs CHANGED
@@ -1,6 +1,7 @@
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';
4
5
  import { collectDocFiles } from './index.mjs';
5
6
  import { gitMv } from './git.mjs';
6
7
  import { green, dim } from './color.mjs';
@@ -98,6 +99,8 @@ export async function runRename(argv, config, opts = {}) {
98
99
  }
99
100
  }
100
101
 
102
+ try { migrateLease(config, oldRepoPath, newRepoPath); } catch (err) { warn(`Could not migrate lease ${oldRepoPath} → ${newRepoPath}: ${err.message}`); }
103
+
101
104
  process.stdout.write(`${green('Renamed')}: ${oldRepoPath} → ${newRepoPath}\n`);
102
105
  if (updatedCount > 0) {
103
106
  process.stdout.write(`Updated references in ${updatedCount} file(s).\n`);
package/src/render.mjs CHANGED
@@ -4,6 +4,7 @@ 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';
7
8
 
8
9
  export function renderCompactList(index, config) {
9
10
  const defaultRenderer = (idx) => _renderCompactList(idx, config);
@@ -297,6 +298,13 @@ export function renderBriefing(index, config) {
297
298
  const stale = index.docs.filter(d => d.isStale && !config.lifecycle.skipStaleFor.has(d.status)).length;
298
299
  lines.push(`Stale: ${stale} | Errors: ${index.errors.length} | Warnings: ${index.warnings.length}`);
299
300
 
301
+ try {
302
+ const staleLeases = findStaleLeases(config);
303
+ if (staleLeases.length > 0) {
304
+ lines.push(yellow(`Stuck in-session: ${staleLeases.length} (>1d or dead pid, run \`dotmd unpickup --stale\`)`));
305
+ }
306
+ } catch {}
307
+
300
308
  return lines.join('\n') + '\n';
301
309
  }
302
310