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 +24 -10
- package/package.json +1 -1
- package/src/hud.mjs +10 -3
- package/src/lease-scrub.mjs +49 -0
- package/src/lease.mjs +2 -1
- package/src/lifecycle.mjs +19 -5
- package/src/new.mjs +32 -8
- package/src/prompts.mjs +1 -1
- package/src/ship.mjs +11 -4
- package/src/validate.mjs +6 -5
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
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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\`
|
|
645
|
-
put the entire content on the bash command line, which (a) breaks
|
|
646
|
-
quoting for backticks/dollar-signs and (b) trips PreToolUse hooks
|
|
647
|
-
command strings for forbidden literals (destructive-git patterns,
|
|
648
|
-
\`@/tmp/foo.md\`
|
|
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
|
|
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
|
|
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
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)')}`));
|
|
@@ -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.
|
|
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 =
|
|
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/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 / --
|
|
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
|
|
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
|
-
|
|
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 |
|
|
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:
|
|
340
|
+
// Body input resolution: --body flag > positional bodyArg > auto-piped-stdin > nothing
|
|
335
341
|
let bodyInput = null;
|
|
336
342
|
let bodyInputSource = null;
|
|
337
|
-
if (
|
|
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.
|
|
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 |
|
|
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
|
|
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
|
}
|