dotmd-cli 0.42.0 → 0.43.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/bin/dotmd.mjs CHANGED
@@ -636,16 +636,17 @@ Types and their default destinations:
636
636
  \`<name>\` is slugified for the filename.
637
637
 
638
638
  Body input (all built-in types — required for prompt, optional for plan/doc):
639
- @path Read body from a file (preferred for multi-line bodies)
640
- - Read body from stdin (heredoc-friendly for agents)
641
- --message "<text>" Explicit inline body
639
+ piped stdin Auto-consumed when stdin is piped/redirected (no flag needed)
640
+ @path Read body from a file
641
+ - Explicit stdin marker (equivalent to piped stdin)
642
+ --body "<text>" Explicit inline body (alias: --message)
642
643
  <text> Inline body as 3rd positional
643
644
 
644
- Tip for agents: prefer \`@path\` or \`-\` for multi-line bodies. Inline bodies
645
- put the entire content on the bash command line, which (a) breaks under shell
646
- quoting for backticks/dollar-signs and (b) trips PreToolUse hooks that scan
647
- command strings for forbidden literals (destructive-git patterns, etc.).
648
- \`@/tmp/foo.md\` sidesteps both.
645
+ Tip for agents: prefer piped stdin or \`@path\` for multi-line bodies. Inline
646
+ bodies put the entire content on the bash command line, which (a) breaks
647
+ under shell quoting for backticks/dollar-signs and (b) trips PreToolUse hooks
648
+ that scan command strings for forbidden literals (destructive-git patterns,
649
+ etc.). \`cat /tmp/foo.md | dotmd new …\` and \`@/tmp/foo.md\` both sidestep both.
649
650
 
650
651
  For plan/doc, a single-section body lands under the type's first scaffolded
651
652
  section (e.g. \`## Problem\` for plans). If the body already authors
@@ -656,12 +657,13 @@ the title + your body is emitted — no duplicated empty outline below
656
657
  Examples:
657
658
  dotmd new plan auth-revamp
658
659
  dotmd new prompt resume-foo @/tmp/draft.md
659
- dotmd new prompt resume-foo - <<'EOF'
660
+ cat /tmp/draft.md | dotmd new prompt resume-foo
661
+ dotmd new prompt resume-foo <<'EOF'
660
662
  multi-line
661
663
  prompt body
662
664
  EOF
663
665
  dotmd new prompt cleanup-tomorrow "look at remaining lint warnings"
664
- dotmd new plan full-spec - <<'EOF'
666
+ dotmd new plan full-spec <<'EOF'
665
667
  ## Problem
666
668
 
667
669
  ## Phases
@@ -1126,6 +1128,8 @@ async function main() {
1126
1128
  if (config.presets[command]) {
1127
1129
  const { buildIndex } = await import('../src/index.mjs');
1128
1130
  const { runQuery } = await import('../src/query.mjs');
1131
+ const { scrubStaleSilently } = await import('../src/lease-scrub.mjs');
1132
+ scrubStaleSilently(config);
1129
1133
  const index = buildIndex(config);
1130
1134
  runQuery(index, [...config.presets[command], ...restArgs], config, { preset: command });
1131
1135
  return;
@@ -1138,6 +1142,8 @@ async function main() {
1138
1142
  if (command === 'plans') {
1139
1143
  const { buildIndex } = await import('../src/index.mjs');
1140
1144
  const { runQuery } = await import('../src/query.mjs');
1145
+ const { scrubStaleSilently } = await import('../src/lease-scrub.mjs');
1146
+ scrubStaleSilently(config);
1141
1147
  const index = buildIndex(config);
1142
1148
  const sub = restArgs[0];
1143
1149
  let defaults;
@@ -1209,6 +1215,14 @@ async function main() {
1209
1215
  const { buildIndex } = await import('../src/index.mjs');
1210
1216
  const { renderCompactList, renderVerboseList, renderContext, renderBriefing, renderCheck, renderCoverage, buildCoverage } = await import('../src/render.mjs');
1211
1217
  const { runFocus, runQuery } = await import('../src/query.mjs');
1218
+ // Opportunistic stale-lease scrub for user-facing "what's actionable now"
1219
+ // views. Diagnostic commands (`check`, `coverage`, `stats`, `index`) are
1220
+ // intentionally excluded — they should surface drift, not silently fix it.
1221
+ const SCRUB_READ_COMMANDS = new Set(['list', 'briefing', 'context', 'focus', 'query', 'modules', 'module', 'surfaces']);
1222
+ if (SCRUB_READ_COMMANDS.has(command)) {
1223
+ const { scrubStaleSilently } = await import('../src/lease-scrub.mjs');
1224
+ scrubStaleSilently(config);
1225
+ }
1212
1226
  const index = buildIndex(config);
1213
1227
 
1214
1228
  // 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.43.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",
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)')}`));
@@ -135,7 +142,7 @@ export function runHud(argv, config) {
135
142
  // by `dotmd init`) so subsequent sessions stay silent.
136
143
  const primerMarker = path.join(config.repoRoot, '.dotmd', 'primer-shown');
137
144
  if (!existsSync(primerMarker)) {
138
- lines.push(dim('dotmd: managing this repo\'s docs. Use `dotmd new prompt` for handoffs (never hand-write docs/prompts/*.md). `dotmd plans` shows the queue. `dotmd --help` for more.'));
145
+ lines.push(dim('dotmd: managing this repo\'s docs. Save in one shot: `cat draft.md | dotmd new <type> <slug>` (or `dotmd new <type> <slug> @path`). Types: plan, doc, prompt. `dotmd plans` shows the queue.'));
139
146
  try {
140
147
  mkdirSync(path.dirname(primerMarker), { recursive: true });
141
148
  writeFileSync(primerMarker, '');
@@ -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/new.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, fstatSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { toRepoPath, die, warn, nowIso, emitFilesFooter } from './util.mjs';
@@ -26,7 +26,7 @@ const BUILTIN_TEMPLATES = {
26
26
  doc: {
27
27
  description: 'Reference doc, design note, module overview — build-up shape lite',
28
28
  defaultStatus: 'active',
29
- // Body input optional. When passed (inline / --message / @file / stdin),
29
+ // Body input optional. When passed (inline / --body / @file / stdin),
30
30
  // it lands in the Overview section. Without it, Overview is left blank
31
31
  // and the user fills it in.
32
32
  acceptsBody: true,
@@ -263,12 +263,18 @@ export async function runNew(argv, config, opts = {}) {
263
263
  let status = null;
264
264
  let title = null;
265
265
  let rootName = opts.root ?? null;
266
- let messageFlag = null;
266
+ let bodyFlag = null;
267
+ let bodyFlagName = null; // tracks which spelling the caller used, for error attribution
267
268
  let showFiles = opts.showFiles ?? false;
268
269
  for (let i = 0; i < argv.length; i++) {
269
270
  if (argv[i] === '--status' && argv[i + 1]) { status = argv[++i]; continue; }
270
271
  if (argv[i] === '--title' && argv[i + 1]) { title = argv[++i]; continue; }
271
- if (argv[i] === '--message' && argv[i + 1]) { messageFlag = argv[++i]; continue; }
272
+ // --body is the canonical flag; --message is a back-compat alias.
273
+ if ((argv[i] === '--body' || argv[i] === '--message') && argv[i + 1]) {
274
+ bodyFlagName = argv[i];
275
+ bodyFlag = argv[++i];
276
+ continue;
277
+ }
272
278
  if (argv[i] === '--root' && argv[i + 1]) { rootName = argv[++i]; continue; }
273
279
  if (argv[i] === '--config') { i++; continue; }
274
280
  if (argv[i] === '--show-files') { showFiles = true; continue; }
@@ -304,7 +310,7 @@ export async function runNew(argv, config, opts = {}) {
304
310
  name = await promptText(`${typeName} name: `);
305
311
  if (!name) die('No name provided.');
306
312
  } else {
307
- die(`Usage: dotmd new <type> <name> [body]\n types: ${[...knownTypes].join(', ')}\n body: inline text | "-" (stdin) | "@path" (file) | --message "..."`);
313
+ die(`Usage: dotmd new <type> <name> [body]\n types: ${[...knownTypes].join(', ')}\n body: inline text | piped stdin (auto) | "@path" (file) | --body "..."`);
308
314
  }
309
315
  }
310
316
 
@@ -331,13 +337,31 @@ export async function runNew(argv, config, opts = {}) {
331
337
  die(`Invalid status \`${status}\` for type \`${typeName}\`\nValid: ${[...effective].join(', ')}`);
332
338
  }
333
339
 
334
- // Body input resolution: messageFlag > bodyArg > nothing
340
+ // Body input resolution: --body flag > positional bodyArg > auto-piped-stdin > nothing
335
341
  let bodyInput = null;
336
342
  let bodyInputSource = null;
337
- if (messageFlag !== null) { bodyInput = readBodyInput(messageFlag); bodyInputSource = '--message'; }
343
+ if (bodyFlag !== null) { bodyInput = readBodyInput(bodyFlag); bodyInputSource = bodyFlagName; }
338
344
  else if (bodyArg !== null) {
339
345
  bodyInput = readBodyInput(bodyArg);
340
346
  bodyInputSource = bodyArg === '-' ? 'stdin (`-`)' : (bodyArg.startsWith('@') ? `file (\`${bodyArg}\`)` : 'inline body argument');
347
+ } else if (template.acceptsBody || template.requiresBody) {
348
+ // Auto-consume piped or redirected stdin so agents don't need the `-`
349
+ // placeholder for the most common pattern (`cat draft.md | dotmd new …`,
350
+ // `dotmd new … < draft.md`, or a `<<'EOF'` heredoc). We probe stdin via
351
+ // fstatSync rather than `!isTTY` so a closed/inherited fd doesn't trigger
352
+ // a blocking read of an empty stream. We accept FIFO (shell pipes), regular
353
+ // file (shell redirection / heredoc), and socket (Node spawnSync `input:`
354
+ // delivers stdin as an AF_UNIX socket).
355
+ try {
356
+ const stat = fstatSync(0);
357
+ if (stat.isFIFO() || stat.isFile() || stat.isSocket()) {
358
+ const piped = readFileSync(0, 'utf8');
359
+ if (piped.length > 0) {
360
+ bodyInput = piped;
361
+ bodyInputSource = 'piped stdin';
362
+ }
363
+ }
364
+ } catch { /* stdin not introspectable — skip auto-consume */ }
341
365
  }
342
366
 
343
367
  // If the body input has a leading `---…---` frontmatter block, lift its keys
@@ -355,7 +379,7 @@ export async function runNew(argv, config, opts = {}) {
355
379
  }
356
380
 
357
381
  if (template.requiresBody && (!bodyInput || !bodyInput.trim())) {
358
- die(`\`${typeName}\` template requires a body. Pass inline, --message "...", - for stdin, or @path for a file.`);
382
+ die(`\`${typeName}\` template requires a body. Pipe stdin (\`cat draft.md | dotmd new ${typeName} <slug>\`), pass @path, --body "...", or inline text.`);
359
383
  }
360
384
 
361
385
  // Fail-fast when the user passes body input to a template that doesn't
package/src/prompts.mjs CHANGED
@@ -248,7 +248,7 @@ function runPromptsArchive(argv, config, opts = {}) {
248
248
 
249
249
  async function runPromptsNew(argv, config, opts = {}) {
250
250
  if (!argv[0] || argv[0].startsWith('-')) {
251
- die('Usage: dotmd prompts new <slug> [body]\n body: inline text | "-" (stdin) | "@path" (file) | --message "..."');
251
+ die('Usage: dotmd prompts new <slug> [body]\n body: inline text | piped stdin (auto) | "@path" (file) | --body "..."');
252
252
  }
253
253
  return runNew(['prompt', ...argv], config, opts);
254
254
  }
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
  }