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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.42.0",
3
+ "version": "0.42.1",
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",
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'} >24h ${dim('(run: dotmd release --stale)')}`));
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 = 24 * 60 * 60 * 1000;
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} (>24h old).\nUse --takeover to claim.\n ${repoPath}`);
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 > 24 * 60 * 60 * 1000;
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
- if (targets.length === 0 && !json) {
477
- process.stderr.write(`No leases to release${fileArg ? ` for ${fileArg}` : ` for session ${session}`}.\n`);
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: line.slice(0, 2),
53
- path: line.slice(3),
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 (>24h), the holder is gone. Either way the validator
179
- // is the only place that knows enough to suggest the exact unstuck command,
180
- // because the lease infrastructure is otherwise invisible to `dotmd check`.
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, >24h threshold). Run \`dotmd release ${doc.path}\` to clear, or \`dotmd status ${doc.path} active\` to re-queue.`,
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
  }