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/README.md +22 -80
- package/bin/dotmd.mjs +143 -126
- package/package.json +1 -1
- package/src/claude-commands.mjs +5 -28
- package/src/commands.mjs +2 -1
- package/src/completions.mjs +1 -5
- package/src/doctor.mjs +1 -1
- package/src/git.mjs +19 -0
- package/src/guard.mjs +202 -0
- package/src/hints.mjs +1 -6
- package/src/hud.mjs +24 -25
- package/src/journal.mjs +58 -1
- package/src/lifecycle.mjs +18 -275
- package/src/misuse-read.mjs +62 -0
- package/src/new.mjs +16 -0
- package/src/rename.mjs +0 -3
- package/src/render.mjs +0 -8
- package/src/runlist.mjs +12 -13
- package/src/ship.mjs +1 -13
- package/src/use.mjs +4 -4
- package/src/util.mjs +10 -0
- package/src/validate.mjs +0 -26
- package/src/lease-scrub.mjs +0 -49
- package/src/lease.mjs +0 -232
package/src/lease-scrub.mjs
DELETED
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { releaseStale } from './lease.mjs';
|
|
4
|
-
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
5
|
-
import { asString, nowIso } from './util.mjs';
|
|
6
|
-
|
|
7
|
-
// Inline status flip, kept local so this module doesn't have to pull in
|
|
8
|
-
// lifecycle.mjs (and the rest of its dep graph) on the SessionStart-hook hot
|
|
9
|
-
// path. Matches the shape of lifecycle.updateFrontmatter for these two keys.
|
|
10
|
-
function flipStatus(filePath, newStatus) {
|
|
11
|
-
const raw = readFileSync(filePath, 'utf8');
|
|
12
|
-
if (!raw.startsWith('---\n')) return;
|
|
13
|
-
const endMarker = raw.indexOf('\n---\n', 4);
|
|
14
|
-
if (endMarker === -1) return;
|
|
15
|
-
let fm = raw.slice(4, endMarker);
|
|
16
|
-
const body = raw.slice(endMarker + 5);
|
|
17
|
-
const today = nowIso();
|
|
18
|
-
const statusRe = /^status:.*$/m;
|
|
19
|
-
const updatedRe = /^updated:.*$/m;
|
|
20
|
-
fm = statusRe.test(fm) ? fm.replace(statusRe, `status: ${newStatus}`) : `${fm}\nstatus: ${newStatus}`;
|
|
21
|
-
fm = updatedRe.test(fm) ? fm.replace(updatedRe, `updated: ${today}`) : `${fm}\nupdated: ${today}`;
|
|
22
|
-
writeFileSync(filePath, `---\n${fm}\n---\n${body}`, 'utf8');
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// Opportunistic stale-lease scrub for read-side commands. Drops any lease
|
|
26
|
-
// entries past STALE_LEASE_AGE_MS and best-effort flips the plan's frontmatter
|
|
27
|
-
// from in-session back to the lease's oldStatus. Silent: no stderr, no warn,
|
|
28
|
-
// no index regen. Returns array of scrubbed lease paths (empty in the common
|
|
29
|
-
// no-op case, which is the only thing that matters for cost).
|
|
30
|
-
export function scrubStaleSilently(config) {
|
|
31
|
-
const result = releaseStale(config);
|
|
32
|
-
if (result.released.length === 0) return [];
|
|
33
|
-
for (const lease of result.released) {
|
|
34
|
-
const newStatus = lease.oldStatus || 'active';
|
|
35
|
-
const filePath = path.join(config.repoRoot, lease.path);
|
|
36
|
-
try {
|
|
37
|
-
const raw = readFileSync(filePath, 'utf8');
|
|
38
|
-
const { frontmatter: fmRaw } = extractFrontmatter(raw);
|
|
39
|
-
const parsedFm = parseSimpleFrontmatter(fmRaw);
|
|
40
|
-
if (asString(parsedFm.status) === 'in-session') {
|
|
41
|
-
flipStatus(filePath, newStatus);
|
|
42
|
-
}
|
|
43
|
-
} catch {
|
|
44
|
-
// Best-effort: the lease entry is already gone; a missing or unreadable
|
|
45
|
-
// file is fine for an opportunistic backstop.
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
return result.released.map(l => l.path);
|
|
49
|
-
}
|
package/src/lease.mjs
DELETED
|
@@ -1,232 +0,0 @@
|
|
|
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
|
-
export const STALE_LEASE_AGE_MS = 4 * 60 * 60 * 1000;
|
|
13
|
-
export const STALE_LEASE_AGE_HOURS = STALE_LEASE_AGE_MS / (60 * 60 * 1000);
|
|
14
|
-
|
|
15
|
-
const _sleepBuf = new Int32Array(new SharedArrayBuffer(4));
|
|
16
|
-
function syncSleep(ms) { Atomics.wait(_sleepBuf, 0, 0, ms); }
|
|
17
|
-
|
|
18
|
-
export function currentSessionId() {
|
|
19
|
-
if (process.env.CLAUDE_CODE_SESSION_ID) return process.env.CLAUDE_CODE_SESSION_ID;
|
|
20
|
-
if (process.env.CLAUDE_SESSION_ID) return process.env.CLAUDE_SESSION_ID;
|
|
21
|
-
if (process.env.TERM_SESSION_ID) return `term:${process.env.TERM_SESSION_ID}`;
|
|
22
|
-
return `shell:${os.userInfo().username}@${os.hostname()}`;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function dirFor(config) { return path.join(config.repoRoot, LEASE_DIR); }
|
|
26
|
-
function leaseFilePath(config) { return path.join(dirFor(config), LEASE_FILE); }
|
|
27
|
-
function lockFilePath(config) { return path.join(dirFor(config), LOCK_FILE); }
|
|
28
|
-
|
|
29
|
-
export function readLeases(config) {
|
|
30
|
-
const file = leaseFilePath(config);
|
|
31
|
-
if (!existsSync(file)) return {};
|
|
32
|
-
try {
|
|
33
|
-
const raw = readFileSync(file, 'utf8');
|
|
34
|
-
if (!raw.trim()) return {};
|
|
35
|
-
const parsed = JSON.parse(raw);
|
|
36
|
-
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
|
|
37
|
-
return {};
|
|
38
|
-
} catch (err) {
|
|
39
|
-
warn(`Lease file at ${file} is corrupt (${err.message}); treating as empty.`);
|
|
40
|
-
return {};
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function writeLeases(config, leases) {
|
|
45
|
-
const dir = dirFor(config);
|
|
46
|
-
mkdirSync(dir, { recursive: true });
|
|
47
|
-
const file = leaseFilePath(config);
|
|
48
|
-
if (Object.keys(leases).length === 0) {
|
|
49
|
-
if (existsSync(file)) {
|
|
50
|
-
try { unlinkSync(file); } catch {}
|
|
51
|
-
}
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
const tmp = `${file}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
|
|
55
|
-
writeFileSync(tmp, JSON.stringify(leases, null, 2) + '\n', 'utf8');
|
|
56
|
-
renameSync(tmp, file);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function withLeaseLock(config, fn) {
|
|
60
|
-
const dir = dirFor(config);
|
|
61
|
-
mkdirSync(dir, { recursive: true });
|
|
62
|
-
const lock = lockFilePath(config);
|
|
63
|
-
const start = Date.now();
|
|
64
|
-
let fd = null;
|
|
65
|
-
while (true) {
|
|
66
|
-
try {
|
|
67
|
-
fd = openSync(lock, 'wx');
|
|
68
|
-
break;
|
|
69
|
-
} catch (err) {
|
|
70
|
-
if (err.code !== 'EEXIST') throw err;
|
|
71
|
-
try {
|
|
72
|
-
const st = statSync(lock);
|
|
73
|
-
if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
|
|
74
|
-
try { unlinkSync(lock); } catch {}
|
|
75
|
-
continue;
|
|
76
|
-
}
|
|
77
|
-
} catch {}
|
|
78
|
-
if (Date.now() - start > LOCK_MAX_WAIT_MS) {
|
|
79
|
-
throw new Error(`Could not acquire lease lock at ${lock} within ${LOCK_MAX_WAIT_MS}ms`);
|
|
80
|
-
}
|
|
81
|
-
syncSleep(LOCK_RETRY_MS);
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
try {
|
|
85
|
-
return fn();
|
|
86
|
-
} finally {
|
|
87
|
-
try { closeSync(fd); } catch {}
|
|
88
|
-
try { unlinkSync(lock); } catch {}
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export function isPidAlive(pid, host) {
|
|
93
|
-
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
94
|
-
if (host && host !== os.hostname()) return true;
|
|
95
|
-
try {
|
|
96
|
-
process.kill(pid, 0);
|
|
97
|
-
return true;
|
|
98
|
-
} catch (err) {
|
|
99
|
-
return err.code === 'EPERM';
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export function isLeasePidDead(lease) {
|
|
104
|
-
if (!lease || lease.host !== os.hostname()) return false;
|
|
105
|
-
if (!Number.isInteger(lease.pid) || lease.pid <= 0) return false;
|
|
106
|
-
return !isPidAlive(lease.pid, lease.host);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
export function isLeaseStale(lease) {
|
|
110
|
-
const t = new Date(lease.pickedUpAt).getTime();
|
|
111
|
-
if (Number.isNaN(t)) return true;
|
|
112
|
-
return Date.now() - t > STALE_LEASE_AGE_MS;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
export function isLeaseReclaimable(lease, opts = {}) {
|
|
116
|
-
if (isLeaseStale(lease)) return true;
|
|
117
|
-
const currentSession = opts.currentSession ?? currentSessionId();
|
|
118
|
-
if (lease?.session === currentSession) return false;
|
|
119
|
-
return isLeasePidDead(lease);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
export function findStaleLeases(config, opts = {}) {
|
|
123
|
-
const leases = readLeases(config);
|
|
124
|
-
return Object.values(leases).filter(l => isLeaseReclaimable(l, opts));
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
export function acquireLease(config, repoPath, oldStatus, opts = {}) {
|
|
128
|
-
return withLeaseLock(config, () => {
|
|
129
|
-
const leases = readLeases(config);
|
|
130
|
-
const existing = leases[repoPath];
|
|
131
|
-
const session = opts.session ?? currentSessionId();
|
|
132
|
-
|
|
133
|
-
if (existing && existing.session === session) {
|
|
134
|
-
existing.pickedUpAt = new Date().toISOString();
|
|
135
|
-
existing.pid = process.pid;
|
|
136
|
-
leases[repoPath] = existing;
|
|
137
|
-
writeLeases(config, leases);
|
|
138
|
-
return { outcome: 'reattached', lease: existing, conflict: null };
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
if (existing && !opts.takeover) {
|
|
142
|
-
const stale = isLeaseReclaimable(existing, { currentSession: session });
|
|
143
|
-
return {
|
|
144
|
-
outcome: stale ? 'conflict-stale' : 'conflict-alive',
|
|
145
|
-
conflict: existing,
|
|
146
|
-
lease: null,
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const newLease = {
|
|
151
|
-
path: repoPath,
|
|
152
|
-
oldStatus: oldStatus ?? 'active',
|
|
153
|
-
pid: process.pid,
|
|
154
|
-
host: os.hostname(),
|
|
155
|
-
session,
|
|
156
|
-
pickedUpAt: new Date().toISOString(),
|
|
157
|
-
};
|
|
158
|
-
if (existing && opts.takeover) {
|
|
159
|
-
newLease.takenOverFrom = {
|
|
160
|
-
session: existing.session,
|
|
161
|
-
pid: existing.pid,
|
|
162
|
-
pickedUpAt: existing.pickedUpAt,
|
|
163
|
-
};
|
|
164
|
-
}
|
|
165
|
-
leases[repoPath] = newLease;
|
|
166
|
-
writeLeases(config, leases);
|
|
167
|
-
return { outcome: existing ? 'taken-over' : 'acquired', lease: newLease, conflict: existing ?? null };
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
export function releaseLease(config, repoPath, opts = {}) {
|
|
172
|
-
return withLeaseLock(config, () => {
|
|
173
|
-
const leases = readLeases(config);
|
|
174
|
-
const existing = leases[repoPath];
|
|
175
|
-
if (!existing) return { released: false, lease: null, reason: 'no-lease' };
|
|
176
|
-
const session = opts.session ?? currentSessionId();
|
|
177
|
-
if (existing.session !== session && !opts.force) {
|
|
178
|
-
return { released: false, lease: existing, reason: 'not-yours' };
|
|
179
|
-
}
|
|
180
|
-
delete leases[repoPath];
|
|
181
|
-
writeLeases(config, leases);
|
|
182
|
-
return { released: true, lease: existing, reason: null };
|
|
183
|
-
});
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
export function releaseAllForSession(config, sessionId, opts = {}) {
|
|
187
|
-
return withLeaseLock(config, () => {
|
|
188
|
-
const leases = readLeases(config);
|
|
189
|
-
const released = [];
|
|
190
|
-
for (const [key, lease] of Object.entries(leases)) {
|
|
191
|
-
if (lease.session === sessionId) {
|
|
192
|
-
released.push(lease);
|
|
193
|
-
delete leases[key];
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
if (released.length > 0) writeLeases(config, leases);
|
|
197
|
-
return { released };
|
|
198
|
-
});
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
export function releaseStale(config, opts = {}) {
|
|
202
|
-
return withLeaseLock(config, () => {
|
|
203
|
-
const leases = readLeases(config);
|
|
204
|
-
const released = [];
|
|
205
|
-
const currentSession = opts.currentSession ?? currentSessionId();
|
|
206
|
-
for (const [key, lease] of Object.entries(leases)) {
|
|
207
|
-
if (isLeaseReclaimable(lease, { currentSession })) {
|
|
208
|
-
released.push(lease);
|
|
209
|
-
delete leases[key];
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
if (released.length > 0) writeLeases(config, leases);
|
|
213
|
-
return { released };
|
|
214
|
-
});
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
export function migrateLease(config, oldPath, newPath) {
|
|
218
|
-
return withLeaseLock(config, () => {
|
|
219
|
-
const leases = readLeases(config);
|
|
220
|
-
if (!leases[oldPath]) return false;
|
|
221
|
-
const lease = leases[oldPath];
|
|
222
|
-
lease.path = newPath;
|
|
223
|
-
leases[newPath] = lease;
|
|
224
|
-
delete leases[oldPath];
|
|
225
|
-
writeLeases(config, leases);
|
|
226
|
-
return true;
|
|
227
|
-
});
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
export function leasePathFor(config) {
|
|
231
|
-
return leaseFilePath(config);
|
|
232
|
-
}
|