dotmd-cli 0.42.0 → 0.42.1
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/bin/dotmd.mjs +12 -0
- package/package.json +1 -1
- package/src/hud.mjs +9 -2
- package/src/lease-scrub.mjs +49 -0
- package/src/lease.mjs +2 -1
- package/src/lifecycle.mjs +19 -5
- package/src/ship.mjs +11 -4
- package/src/validate.mjs +6 -5
package/bin/dotmd.mjs
CHANGED
|
@@ -1126,6 +1126,8 @@ async function main() {
|
|
|
1126
1126
|
if (config.presets[command]) {
|
|
1127
1127
|
const { buildIndex } = await import('../src/index.mjs');
|
|
1128
1128
|
const { runQuery } = await import('../src/query.mjs');
|
|
1129
|
+
const { scrubStaleSilently } = await import('../src/lease-scrub.mjs');
|
|
1130
|
+
scrubStaleSilently(config);
|
|
1129
1131
|
const index = buildIndex(config);
|
|
1130
1132
|
runQuery(index, [...config.presets[command], ...restArgs], config, { preset: command });
|
|
1131
1133
|
return;
|
|
@@ -1138,6 +1140,8 @@ async function main() {
|
|
|
1138
1140
|
if (command === 'plans') {
|
|
1139
1141
|
const { buildIndex } = await import('../src/index.mjs');
|
|
1140
1142
|
const { runQuery } = await import('../src/query.mjs');
|
|
1143
|
+
const { scrubStaleSilently } = await import('../src/lease-scrub.mjs');
|
|
1144
|
+
scrubStaleSilently(config);
|
|
1141
1145
|
const index = buildIndex(config);
|
|
1142
1146
|
const sub = restArgs[0];
|
|
1143
1147
|
let defaults;
|
|
@@ -1209,6 +1213,14 @@ async function main() {
|
|
|
1209
1213
|
const { buildIndex } = await import('../src/index.mjs');
|
|
1210
1214
|
const { renderCompactList, renderVerboseList, renderContext, renderBriefing, renderCheck, renderCoverage, buildCoverage } = await import('../src/render.mjs');
|
|
1211
1215
|
const { runFocus, runQuery } = await import('../src/query.mjs');
|
|
1216
|
+
// Opportunistic stale-lease scrub for user-facing "what's actionable now"
|
|
1217
|
+
// views. Diagnostic commands (`check`, `coverage`, `stats`, `index`) are
|
|
1218
|
+
// intentionally excluded — they should surface drift, not silently fix it.
|
|
1219
|
+
const SCRUB_READ_COMMANDS = new Set(['list', 'briefing', 'context', 'focus', 'query', 'modules', 'module', 'surfaces']);
|
|
1220
|
+
if (SCRUB_READ_COMMANDS.has(command)) {
|
|
1221
|
+
const { scrubStaleSilently } = await import('../src/lease-scrub.mjs');
|
|
1222
|
+
scrubStaleSilently(config);
|
|
1223
|
+
}
|
|
1212
1224
|
const index = buildIndex(config);
|
|
1213
1225
|
|
|
1214
1226
|
// Apply --root and --type filters
|
package/package.json
CHANGED
package/src/hud.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { readLeases, findStaleLeases, currentSessionId } from './lease.mjs';
|
|
3
|
+
import { readLeases, findStaleLeases, currentSessionId, STALE_LEASE_AGE_HOURS } from './lease.mjs';
|
|
4
|
+
import { scrubStaleSilently } from './lease-scrub.mjs';
|
|
4
5
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
5
6
|
import { asString, toRepoPath } from './util.mjs';
|
|
6
7
|
import { green, yellow, red, dim } from './color.mjs';
|
|
@@ -67,6 +68,12 @@ function findActionablePrompts(config) {
|
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
export function buildHud(config) {
|
|
71
|
+
// Drop stale lease entries (and flip their plan frontmatter back to
|
|
72
|
+
// oldStatus) before reading anything. Without this, hud would surface
|
|
73
|
+
// zombie in-session plans from crashed sessions as "you hold N plans" if
|
|
74
|
+
// the SessionStart hook sees its own (now-stale) lease from a previous
|
|
75
|
+
// session that shared the same env-supplied session id.
|
|
76
|
+
try { scrubStaleSilently(config); } catch { /* hot-path: never break hud */ }
|
|
70
77
|
const session = currentSessionId();
|
|
71
78
|
const leases = readLeases(config);
|
|
72
79
|
const owned = Object.values(leases).filter(l => l.session === session).map(l => l.path);
|
|
@@ -115,7 +122,7 @@ export function runHud(argv, config) {
|
|
|
115
122
|
lines.push(green(`▶ ${hud.prompts.length} pending prompt${hud.prompts.length === 1 ? '' : 's'}: ${previewList(hud.prompts)} ${dim('(consume: `dotmd prompts use <file>` — do not cat/read)')}`));
|
|
116
123
|
}
|
|
117
124
|
if (hud.stale.length > 0) {
|
|
118
|
-
lines.push(yellow(`⚠ ${hud.stale.length} stuck lease${hud.stale.length === 1 ? '' : 's'}
|
|
125
|
+
lines.push(yellow(`⚠ ${hud.stale.length} stuck lease${hud.stale.length === 1 ? '' : 's'} >${STALE_LEASE_AGE_HOURS}h ${dim('(run: dotmd release --stale)')}`));
|
|
119
126
|
}
|
|
120
127
|
if (hud.errors > 0) {
|
|
121
128
|
lines.push(red(`✗ ${hud.errors} validation error${hud.errors === 1 ? '' : 's'} ${dim('(run: dotmd check)')}`));
|
|
@@ -0,0 +1,49 @@
|
|
|
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
CHANGED
|
@@ -9,7 +9,8 @@ const LOCK_FILE = 'in-session.lock';
|
|
|
9
9
|
const LOCK_STALE_MS = 5_000;
|
|
10
10
|
const LOCK_RETRY_MS = 50;
|
|
11
11
|
const LOCK_MAX_WAIT_MS = 2_000;
|
|
12
|
-
const STALE_LEASE_AGE_MS =
|
|
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);
|
|
13
14
|
|
|
14
15
|
const _sleepBuf = new Int32Array(new SharedArrayBuffer(4));
|
|
15
16
|
function syncSleep(ms) { Atomics.wait(_sleepBuf, 0, 0, ms); }
|
package/src/lifecycle.mjs
CHANGED
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
readLeases,
|
|
16
16
|
currentSessionId,
|
|
17
17
|
migrateLease,
|
|
18
|
+
STALE_LEASE_AGE_MS,
|
|
19
|
+
STALE_LEASE_AGE_HOURS,
|
|
18
20
|
} from './lease.mjs';
|
|
19
21
|
import { buildCard, renderCard } from './pickup-card.mjs';
|
|
20
22
|
import { walkSections, findSection } from './section.mjs';
|
|
@@ -245,6 +247,17 @@ export async function runPickup(argv, config, opts = {}) {
|
|
|
245
247
|
const showFiles = argv.includes('--show-files') || opts.showFiles;
|
|
246
248
|
let input = argv.find(a => !a.startsWith('-'));
|
|
247
249
|
|
|
250
|
+
// Opportunistic stale-lease scrub before pickup runs its conflict check.
|
|
251
|
+
// Without this, a stale lease from a crashed prior session would still
|
|
252
|
+
// produce 'conflict-stale' and force the agent to pass --takeover even
|
|
253
|
+
// though we already know the holder is gone.
|
|
254
|
+
if (!dryRun) {
|
|
255
|
+
try {
|
|
256
|
+
const { scrubStaleSilently } = await import('./lease-scrub.mjs');
|
|
257
|
+
scrubStaleSilently(config);
|
|
258
|
+
} catch { /* best-effort — never block pickup on scrub failure */ }
|
|
259
|
+
}
|
|
260
|
+
|
|
248
261
|
// Interactive: pick from active/planned plans
|
|
249
262
|
if (!input) {
|
|
250
263
|
if (!isInteractive()) die('Usage: dotmd pickup <file>');
|
|
@@ -317,7 +330,7 @@ export async function runPickup(argv, config, opts = {}) {
|
|
|
317
330
|
}
|
|
318
331
|
if (result.outcome === 'conflict-stale') {
|
|
319
332
|
const c = result.conflict;
|
|
320
|
-
die(`Stale in-session lease from ${c.host}/${c.session} since ${c.pickedUpAt} (
|
|
333
|
+
die(`Stale in-session lease from ${c.host}/${c.session} since ${c.pickedUpAt} (>${STALE_LEASE_AGE_HOURS}h old).\nUse --takeover to claim.\n ${repoPath}`);
|
|
321
334
|
}
|
|
322
335
|
if (oldStatus !== 'in-session') {
|
|
323
336
|
updateFrontmatter(filePath, { status: 'in-session', updated: today });
|
|
@@ -459,7 +472,7 @@ export async function runUnpickup(argv, config, opts = {}) {
|
|
|
459
472
|
if (dryRun) {
|
|
460
473
|
const staleLeases = Object.values(leases).filter(l => {
|
|
461
474
|
const age = Date.now() - new Date(l.pickedUpAt).getTime();
|
|
462
|
-
return Number.isNaN(age) || age >
|
|
475
|
+
return Number.isNaN(age) || age > STALE_LEASE_AGE_MS;
|
|
463
476
|
});
|
|
464
477
|
for (const l of staleLeases) {
|
|
465
478
|
process.stderr.write(`${dim('[dry-run]')} Would release stale: ${l.path} (${l.session})\n`);
|
|
@@ -473,9 +486,10 @@ export async function runUnpickup(argv, config, opts = {}) {
|
|
|
473
486
|
}
|
|
474
487
|
}
|
|
475
488
|
} else {
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
489
|
+
// Silent no-op: when the session has nothing to release (already
|
|
490
|
+
// auto-released by archive, or never held), exit 0 with no output.
|
|
491
|
+
// Only print when work was actually done. The fileArg path can't reach
|
|
492
|
+
// here with targets.length === 0 — it would have died at lookup.
|
|
479
493
|
for (const lease of targets) {
|
|
480
494
|
const newStatus = targetStatus(lease);
|
|
481
495
|
if (dryRun) {
|
package/src/ship.mjs
CHANGED
|
@@ -48,10 +48,17 @@ function listDirtyFiles(repoRoot) {
|
|
|
48
48
|
return result.stdout
|
|
49
49
|
.split('\n')
|
|
50
50
|
.filter(Boolean)
|
|
51
|
-
.map(line =>
|
|
52
|
-
status
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
.map(line => {
|
|
52
|
+
const status = line.slice(0, 2);
|
|
53
|
+
let rawPath = line.slice(3);
|
|
54
|
+
// Renames/copies render as `R orig -> new` (and `C orig -> new`); only
|
|
55
|
+
// the destination is a real file we can `git add`. Without splitting on
|
|
56
|
+
// ` -> `, the literal "orig -> new" string is handed to git, which fails
|
|
57
|
+
// with "did not match any files" and aborts the ship.
|
|
58
|
+
const arrow = rawPath.indexOf(' -> ');
|
|
59
|
+
if (arrow !== -1) rawPath = rawPath.slice(arrow + 4);
|
|
60
|
+
return { status, path: rawPath };
|
|
61
|
+
});
|
|
55
62
|
}
|
|
56
63
|
|
|
57
64
|
function findHeldPlanTitle(config) {
|
package/src/validate.mjs
CHANGED
|
@@ -2,7 +2,7 @@ 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 } from './lease.mjs';
|
|
5
|
+
import { readLeases, isLeaseStale, STALE_LEASE_AGE_HOURS } from './lease.mjs';
|
|
6
6
|
|
|
7
7
|
const NOW = new Date();
|
|
8
8
|
|
|
@@ -175,9 +175,10 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
|
|
|
175
175
|
|
|
176
176
|
// F11: `status: in-session` plans should have a matching live lease. If the
|
|
177
177
|
// lease file has no entry, the previous session crashed without releasing;
|
|
178
|
-
// if the entry is stale (>
|
|
179
|
-
// is the only place that knows enough to suggest
|
|
180
|
-
// because the lease infrastructure is otherwise
|
|
178
|
+
// if the entry is stale (> stale-threshold hours), the holder is gone.
|
|
179
|
+
// Either way the validator is the only place that knows enough to suggest
|
|
180
|
+
// the exact unstuck command, because the lease infrastructure is otherwise
|
|
181
|
+
// invisible to `dotmd check`.
|
|
181
182
|
if (doc.status === 'in-session' && !config.lifecycle.skipWarningsFor.has(doc.status)) {
|
|
182
183
|
const leases = readLeases(config);
|
|
183
184
|
const lease = leases[doc.path];
|
|
@@ -192,7 +193,7 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
|
|
|
192
193
|
doc.warnings.push({
|
|
193
194
|
path: doc.path,
|
|
194
195
|
level: 'warning',
|
|
195
|
-
message: `\`status: in-session\` but lease is stale (last touched ${ageHours}h ago,
|
|
196
|
+
message: `\`status: in-session\` but lease is stale (last touched ${ageHours}h ago, >${STALE_LEASE_AGE_HOURS}h threshold). Run \`dotmd release ${doc.path}\` to clear, or \`dotmd status ${doc.path} active\` to re-queue.`,
|
|
196
197
|
});
|
|
197
198
|
}
|
|
198
199
|
}
|