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 +58 -2
- package/bin/dotmd.mjs +34 -3
- package/dotmd.config.example.mjs +1 -1
- package/package.json +1 -1
- package/src/completions.mjs +3 -2
- package/src/config.mjs +3 -3
- package/src/init.mjs +18 -0
- package/src/lease.mjs +221 -0
- package/src/lifecycle.mjs +190 -6
- package/src/rename.mjs +3 -0
- package/src/render.mjs +8 -0
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** |
|
|
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`, `
|
|
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>
|
|
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
|
-
|
|
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; }
|
package/dotmd.config.example.mjs
CHANGED
|
@@ -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',
|
|
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
package/src/completions.mjs
CHANGED
|
@@ -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', '
|
|
59
|
-
skipWarningsFor: ['archived', 'partial', '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
|