dotmd-cli 0.50.1 → 0.51.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 +112 -125
- package/package.json +2 -2
- package/src/claude-commands.mjs +5 -28
- package/src/commands.mjs +1 -1
- package/src/completions.mjs +1 -5
- package/src/doctor.mjs +1 -1
- package/src/hints.mjs +1 -6
- package/src/hud.mjs +2 -25
- package/src/journal.mjs +1 -1
- package/src/lifecycle.mjs +18 -275
- 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/commands.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// templates points at a real command.
|
|
5
5
|
export const KNOWN_COMMANDS = [
|
|
6
6
|
'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'briefing', 'context', 'agent-context', 'hud',
|
|
7
|
-
'focus', 'query', 'plans', 'prompts', 'stale', 'actionable', 'index', '
|
|
7
|
+
'focus', 'query', 'plans', 'prompts', 'stale', 'actionable', 'index', 'status', 'set', 'use', 'next', 'archive', 'bulk', 'bulk-tag', 'touch', 'doctor', 'runlist',
|
|
8
8
|
'unblocks', 'health', 'glossary', 'modules', 'module',
|
|
9
9
|
'fix-refs', 'lint', 'rename', 'migrate', 'notion', 'export', 'summary',
|
|
10
10
|
'watch', 'diff', 'new', 'init', 'completions', 'statuses', 'journal',
|
package/src/completions.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { die } from './util.mjs';
|
|
|
2
2
|
|
|
3
3
|
const COMMANDS = [
|
|
4
4
|
'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'unblocks', 'health', 'glossary', 'briefing', 'context', 'focus', 'query',
|
|
5
|
-
'plans', 'stale', 'actionable', 'index', '
|
|
5
|
+
'plans', 'stale', 'actionable', 'index', 'status', 'set', 'archive', 'bulk', 'touch', 'doctor', 'lint', 'rename', 'migrate',
|
|
6
6
|
'fix-refs', 'notion', 'export', 'summary', 'watch', 'diff', 'init', 'new', 'completions', 'journal',
|
|
7
7
|
];
|
|
8
8
|
|
|
@@ -33,10 +33,6 @@ const COMMAND_FLAGS = {
|
|
|
33
33
|
stale: ['--json', '--sort', '--limit', '--all'],
|
|
34
34
|
actionable: ['--json', '--sort', '--limit', '--all'],
|
|
35
35
|
briefing: ['--json'],
|
|
36
|
-
pickup: ['--json', '--takeover'],
|
|
37
|
-
unpickup: ['--json', '--all', '--stale', '--to', '--force', '--no-index', '--show-files'],
|
|
38
|
-
release: ['--json', '--all', '--stale', '--to', '--force', '--no-index', '--show-files'],
|
|
39
|
-
finish: ['--json', '--all', '--stale', '--to', '--force', '--no-index', '--show-files'],
|
|
40
36
|
status: [],
|
|
41
37
|
archive: [],
|
|
42
38
|
doctor: [],
|
package/src/doctor.mjs
CHANGED
|
@@ -146,7 +146,7 @@ function findDeprecatedCommandMentions(config) {
|
|
|
146
146
|
for (const filePath of docs) {
|
|
147
147
|
let raw = '';
|
|
148
148
|
try { raw = readFileSync(filePath, 'utf8'); } catch { continue; }
|
|
149
|
-
if (/\bdotmd status\b/.test(raw) || /\bdotmd pickup\b/.test(raw)) {
|
|
149
|
+
if (/\bdotmd status\b/.test(raw) || /\bdotmd (pickup|unpickup|release|finish)\b/.test(raw)) {
|
|
150
150
|
matches.push(toRepoPath(filePath, config.repoRoot));
|
|
151
151
|
}
|
|
152
152
|
}
|
package/src/hints.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { readJournalEntries, isJournalEnabled, journalFilePath } from './journal.mjs';
|
|
3
|
-
import { currentSessionId } from './
|
|
3
|
+
import { currentSessionId } from './util.mjs';
|
|
4
4
|
|
|
5
5
|
// F17c: repeat-failure hints. When an agent runs the same broken invocation
|
|
6
6
|
// twice in the same session within HINT_WINDOW_MS, the second die() output is
|
|
@@ -38,11 +38,6 @@ const TEMPLATES = [
|
|
|
38
38
|
hint: ({ count, argv }) =>
|
|
39
39
|
`${count}× pointing at a path that doesn't exist. Confirm the file with \`dotmd query\` or \`dotmd plans\` — paths resolve relative to repo root or doc roots, not the cwd.`,
|
|
40
40
|
},
|
|
41
|
-
{
|
|
42
|
-
match: /Lease conflict|in-session|held by/i,
|
|
43
|
-
hint: ({ count }) =>
|
|
44
|
-
`${count}× lease conflict in this session. Run \`dotmd plans --status in-session\` to see what's held; pass \`--takeover\` if the holder is stale, or close the other session first.`,
|
|
45
|
-
},
|
|
46
41
|
{
|
|
47
42
|
match: /Unknown status|Unknown surface/i,
|
|
48
43
|
hint: ({ count }) =>
|
package/src/hud.mjs
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { readLeases, findStaleLeases, currentSessionId, isLeaseStale, STALE_LEASE_AGE_MS } from './lease.mjs';
|
|
4
|
-
import { scrubStaleSilently } from './lease-scrub.mjs';
|
|
5
3
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
6
|
-
import { asString, toRepoPath } from './util.mjs';
|
|
4
|
+
import { asString, toRepoPath, currentSessionId } from './util.mjs';
|
|
7
5
|
import { dim } from './color.mjs';
|
|
8
6
|
import { buildIndex } from './index.mjs';
|
|
9
7
|
import { refreshStaleSlashCommands } from './claude-commands.mjs';
|
|
@@ -105,13 +103,6 @@ export function buildJournalSections(config, now = Date.now()) {
|
|
|
105
103
|
if (!entries.length) return { previousSelf: [], fleet: [], recentRejections: [] };
|
|
106
104
|
|
|
107
105
|
const sid = currentSessionId();
|
|
108
|
-
const leases = readLeases(config);
|
|
109
|
-
const leaseBySession = new Map();
|
|
110
|
-
for (const lease of Object.values(leases)) {
|
|
111
|
-
if (!lease?.session) continue;
|
|
112
|
-
if (!leaseBySession.has(lease.session)) leaseBySession.set(lease.session, []);
|
|
113
|
-
leaseBySession.get(lease.session).push(lease);
|
|
114
|
-
}
|
|
115
106
|
|
|
116
107
|
// 1. Previous self: this sid's last N entries (excluding the current
|
|
117
108
|
// invocation, which is recorded only at process exit so it isn't in the
|
|
@@ -140,14 +131,10 @@ export function buildJournalSections(config, now = Date.now()) {
|
|
|
140
131
|
if (t > row.lastTs) row.lastTs = t;
|
|
141
132
|
}
|
|
142
133
|
const fleet = [...bySid.entries()].map(([otherSid, row]) => {
|
|
143
|
-
const myLeases = leaseBySession.get(otherSid) ?? [];
|
|
144
|
-
const stalest = myLeases.find(isLeaseStale);
|
|
145
134
|
return {
|
|
146
135
|
sid: otherSid,
|
|
147
136
|
cmds: row.count,
|
|
148
137
|
lastAgo: relTime(new Date(row.lastTs).toISOString(), now),
|
|
149
|
-
holding: myLeases.map(l => l.path),
|
|
150
|
-
stale: Boolean(stalest),
|
|
151
138
|
};
|
|
152
139
|
}).sort((a, b) => b.cmds - a.cmds).slice(0, FLEET_CAP);
|
|
153
140
|
|
|
@@ -174,16 +161,6 @@ export function buildJournalSections(config, now = Date.now()) {
|
|
|
174
161
|
}
|
|
175
162
|
|
|
176
163
|
export function buildHud(config) {
|
|
177
|
-
// Drop stale lease entries (and flip their plan frontmatter back to
|
|
178
|
-
// oldStatus) before reading anything. Without this, hud would surface
|
|
179
|
-
// zombie in-session plans from crashed sessions as "you hold N plans" if
|
|
180
|
-
// the SessionStart hook sees its own (now-stale) lease from a previous
|
|
181
|
-
// session that shared the same env-supplied session id.
|
|
182
|
-
try { scrubStaleSilently(config); } catch { /* hot-path: never break hud */ }
|
|
183
|
-
const session = currentSessionId();
|
|
184
|
-
const leases = readLeases(config);
|
|
185
|
-
const owned = Object.values(leases).filter(l => l.session === session).map(l => l.path);
|
|
186
|
-
const stale = findStaleLeases(config).map(l => l.path);
|
|
187
164
|
const prompts = findActionablePrompts(config);
|
|
188
165
|
|
|
189
166
|
// Validation error count — hud's "silent when clean" contract should treat
|
|
@@ -205,7 +182,7 @@ export function buildHud(config) {
|
|
|
205
182
|
|
|
206
183
|
const { previousSelf, fleet, recentRejections } = buildJournalSections(config);
|
|
207
184
|
|
|
208
|
-
return {
|
|
185
|
+
return { prompts, errors, previousSelf, fleet, recentRejections };
|
|
209
186
|
}
|
|
210
187
|
|
|
211
188
|
export function runHud(argv, config) {
|
package/src/journal.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, appendFileSync, statSync, renameSync, readFileSync, unlinkSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import os from 'node:os';
|
|
4
|
-
import { currentSessionId } from './
|
|
4
|
+
import { currentSessionId } from './util.mjs';
|
|
5
5
|
|
|
6
6
|
const JOURNAL_DIR = '.dotmd';
|
|
7
7
|
const JOURNAL_FILE = 'journal.jsonl';
|
package/src/lifecycle.mjs
CHANGED
|
@@ -5,19 +5,8 @@ import { asString, toRepoPath, die, warn, resolveDocPath, resolveRefPath, escape
|
|
|
5
5
|
import { gitMv, getGitLastModified, getGitLastModifiedBatch } from './git.mjs';
|
|
6
6
|
import { buildIndex, collectDocFiles } from './index.mjs';
|
|
7
7
|
import { renderIndexFile, writeIndex } from './index-file.mjs';
|
|
8
|
-
import { green, dim
|
|
8
|
+
import { green, dim } from './color.mjs';
|
|
9
9
|
import { isInteractive, promptChoice } from './prompt.mjs';
|
|
10
|
-
import {
|
|
11
|
-
acquireLease,
|
|
12
|
-
releaseLease,
|
|
13
|
-
releaseAllForSession,
|
|
14
|
-
releaseStale,
|
|
15
|
-
readLeases,
|
|
16
|
-
currentSessionId,
|
|
17
|
-
migrateLease,
|
|
18
|
-
isLeaseReclaimable,
|
|
19
|
-
STALE_LEASE_AGE_HOURS,
|
|
20
|
-
} from './lease.mjs';
|
|
21
10
|
import { buildCard, renderCard } from './pickup-card.mjs';
|
|
22
11
|
import { walkSections, findSection } from './section.mjs';
|
|
23
12
|
|
|
@@ -297,29 +286,20 @@ export async function runStatus(argv, config, opts = {}) {
|
|
|
297
286
|
}); } catch (err) { warn(`Hook 'onStatusChange' threw: ${err.message}`); }
|
|
298
287
|
}
|
|
299
288
|
|
|
300
|
-
|
|
289
|
+
// Open a plan for work: flip its frontmatter status to `in-session` and print
|
|
290
|
+
// its card (body + related + next steps). No lease, no claiming — just a
|
|
291
|
+
// status write. Backs `dotmd use <plan>` and `dotmd runlist next`.
|
|
292
|
+
export async function startPlan(argv, config, opts = {}) {
|
|
301
293
|
const { dryRun } = opts;
|
|
302
294
|
const json = argv.includes('--json');
|
|
303
|
-
const takeover = argv.includes('--takeover');
|
|
304
295
|
const fullBody = argv.includes('--full');
|
|
305
296
|
const noIndex = argv.includes('--no-index') || opts.noIndex;
|
|
306
297
|
const showFiles = argv.includes('--show-files') || opts.showFiles;
|
|
307
298
|
let input = argv.find(a => !a.startsWith('-'));
|
|
308
299
|
|
|
309
|
-
// Opportunistic stale-lease scrub before pickup runs its conflict check.
|
|
310
|
-
// Without this, a stale lease from a crashed prior session would still
|
|
311
|
-
// produce 'conflict-stale' and force the agent to pass --takeover even
|
|
312
|
-
// though we already know the holder is gone.
|
|
313
|
-
if (!dryRun && !takeover) {
|
|
314
|
-
try {
|
|
315
|
-
const { scrubStaleSilently } = await import('./lease-scrub.mjs');
|
|
316
|
-
scrubStaleSilently(config);
|
|
317
|
-
} catch { /* best-effort — never block pickup on scrub failure */ }
|
|
318
|
-
}
|
|
319
|
-
|
|
320
300
|
// Interactive: pick from active/planned plans
|
|
321
301
|
if (!input) {
|
|
322
|
-
if (!isInteractive()) die('Usage: dotmd
|
|
302
|
+
if (!isInteractive()) die('Usage: dotmd use <plan>');
|
|
323
303
|
const index = buildIndex(config);
|
|
324
304
|
const candidates = index.docs.filter(d =>
|
|
325
305
|
d.type === 'plan' && (d.status === 'active' || d.status === 'planned')
|
|
@@ -351,63 +331,20 @@ export async function runPickup(argv, config, opts = {}) {
|
|
|
351
331
|
die(`Plan is blocked: ${blockers}\n ${repoPath}`);
|
|
352
332
|
}
|
|
353
333
|
|
|
354
|
-
// If frontmatter says we're not in-session, any lingering lease is orphaned —
|
|
355
|
-
// drop it so a fresh acquire below doesn't see a phantom conflict.
|
|
356
|
-
if (oldStatus !== 'in-session') {
|
|
357
|
-
if (readLeases(config)[repoPath]) {
|
|
358
|
-
releaseLease(config, repoPath, { force: true });
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
const pickupable = new Set(['active', 'planned', 'in-session']);
|
|
363
|
-
if (oldStatus && !pickupable.has(oldStatus)) {
|
|
364
|
-
die(
|
|
365
|
-
`Cannot start work on a plan with status '${oldStatus}'. Must be active or planned.\n` +
|
|
366
|
-
` ${repoPath}\n` +
|
|
367
|
-
`\n` +
|
|
368
|
-
`Recover with:\n` +
|
|
369
|
-
` dotmd set active ${repoPath} && dotmd set in-session ${repoPath}`,
|
|
370
|
-
);
|
|
371
|
-
}
|
|
372
|
-
|
|
373
334
|
const today = nowIso();
|
|
374
|
-
const leaseOldStatus = oldStatus === 'in-session' ? 'active' : (oldStatus ?? 'active');
|
|
375
|
-
let leaseOutcome = 'acquired';
|
|
376
|
-
|
|
377
335
|
if (dryRun) {
|
|
378
336
|
if (oldStatus === 'in-session') {
|
|
379
|
-
process.stderr.write(`${dim('[dry-run]')}
|
|
337
|
+
process.stderr.write(`${dim('[dry-run]')} Already in-session: ${repoPath}\n`);
|
|
380
338
|
} else {
|
|
381
339
|
process.stderr.write(`${dim('[dry-run]')} Would update: status: ${oldStatus} → in-session, updated: ${today}\n`);
|
|
382
340
|
}
|
|
383
|
-
} else {
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
if (
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
if (result.outcome === 'conflict-stale') {
|
|
391
|
-
const c = result.conflict;
|
|
392
|
-
die(`Plan flagged in-session by ${c.host}/${c.session} since ${c.pickedUpAt} (>${STALE_LEASE_AGE_HOURS}h ago, looks abandoned).\nUse --takeover to claim.\n ${repoPath}`);
|
|
393
|
-
}
|
|
394
|
-
if (oldStatus !== 'in-session') {
|
|
395
|
-
updateFrontmatter(filePath, { status: 'in-session', updated: today });
|
|
396
|
-
if (noIndex) {
|
|
397
|
-
process.stderr.write(dim('(index not regenerated — run `dotmd index` to refresh)\n'));
|
|
398
|
-
} else {
|
|
399
|
-
regenIndex(config);
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
// VH append per lease outcome:
|
|
403
|
-
// acquired → `Picked up (<old> → in-session).`
|
|
404
|
-
// taken-over → `Took over from <session>.`
|
|
405
|
-
// reattached → no entry (same-session noise)
|
|
406
|
-
if (leaseOutcome === 'acquired') {
|
|
407
|
-
appendVersionHistory(filePath, `Picked up (${oldStatus ?? 'unknown'} → in-session).`);
|
|
408
|
-
} else if (leaseOutcome === 'taken-over') {
|
|
409
|
-
const fromSession = result.conflict?.session ?? 'unknown';
|
|
410
|
-
appendVersionHistory(filePath, `Took over from ${fromSession}.`);
|
|
341
|
+
} else if (oldStatus !== 'in-session') {
|
|
342
|
+
updateFrontmatter(filePath, { status: 'in-session', updated: today });
|
|
343
|
+
appendVersionHistory(filePath, `Started (${oldStatus ?? 'unknown'} → in-session).`);
|
|
344
|
+
if (noIndex) {
|
|
345
|
+
process.stderr.write(dim('(index not regenerated — run `dotmd index` to refresh)\n'));
|
|
346
|
+
} else {
|
|
347
|
+
regenIndex(config);
|
|
411
348
|
}
|
|
412
349
|
}
|
|
413
350
|
|
|
@@ -415,19 +352,11 @@ export async function runPickup(argv, config, opts = {}) {
|
|
|
415
352
|
const card = buildCard(filePath, raw, config);
|
|
416
353
|
process.stdout.write(JSON.stringify({
|
|
417
354
|
path: repoPath, oldStatus, newStatus: 'in-session', title,
|
|
418
|
-
reattached: leaseOutcome === 'reattached',
|
|
419
|
-
takenOver: leaseOutcome === 'taken-over',
|
|
420
355
|
body: body?.trim() ?? '',
|
|
421
356
|
card,
|
|
422
357
|
}, null, 2) + '\n');
|
|
423
358
|
} else {
|
|
424
|
-
|
|
425
|
-
process.stderr.write(`${green('▶ Re-attached')}: ${repoPath}\n\n`);
|
|
426
|
-
} else if (leaseOutcome === 'taken-over') {
|
|
427
|
-
process.stderr.write(`${green('▶ Took over')}: ${repoPath} (was ${oldStatus ?? 'unset'} → in-session)\n\n`);
|
|
428
|
-
} else {
|
|
429
|
-
process.stderr.write(`${green('▶ Picked up')}: ${repoPath} (${oldStatus ?? 'unset'} → in-session)\n\n`);
|
|
430
|
-
}
|
|
359
|
+
process.stderr.write(`${green('▶ Started')}: ${repoPath} (${oldStatus ?? 'unset'} → in-session)\n\n`);
|
|
431
360
|
if (fullBody) {
|
|
432
361
|
const header = `[dotmd] in-session: ${repoPath} — close with: dotmd set <status> ${repoPath}\n---\n`;
|
|
433
362
|
process.stdout.write(header);
|
|
@@ -448,151 +377,6 @@ export async function runPickup(argv, config, opts = {}) {
|
|
|
448
377
|
try { config.hooks.onPickup?.({ path: repoPath, oldStatus, newStatus: 'in-session' }); } catch (err) { warn(`Hook 'onPickup' threw: ${err.message}`); }
|
|
449
378
|
}
|
|
450
379
|
|
|
451
|
-
export async function runUnpickup(argv, config, opts = {}) {
|
|
452
|
-
const { dryRun } = opts;
|
|
453
|
-
const json = argv.includes('--json');
|
|
454
|
-
const all = argv.includes('--all');
|
|
455
|
-
const stale = argv.includes('--stale');
|
|
456
|
-
const force = argv.includes('--force');
|
|
457
|
-
const noIndex = argv.includes('--no-index') || opts.noIndex;
|
|
458
|
-
const showFiles = argv.includes('--show-files') || opts.showFiles;
|
|
459
|
-
const toIdx = argv.indexOf('--to');
|
|
460
|
-
const toStatus = toIdx >= 0 ? argv[toIdx + 1] : null;
|
|
461
|
-
const positional = argv.filter((a, i) => !a.startsWith('-') && argv[i - 1] !== '--to');
|
|
462
|
-
const fileArg = positional[0];
|
|
463
|
-
const touched = [];
|
|
464
|
-
|
|
465
|
-
const session = currentSessionId();
|
|
466
|
-
const released = [];
|
|
467
|
-
const skipped = [];
|
|
468
|
-
|
|
469
|
-
// Decide which leases to act on
|
|
470
|
-
let targets = [];
|
|
471
|
-
const leases = readLeases(config);
|
|
472
|
-
if (fileArg) {
|
|
473
|
-
const filePath = resolveDocPath(fileArg, config);
|
|
474
|
-
if (!filePath) die(`File not found: ${fileArg}`);
|
|
475
|
-
const repoPath = toRepoPath(filePath, config.repoRoot);
|
|
476
|
-
if (leases[repoPath]) {
|
|
477
|
-
targets.push(leases[repoPath]);
|
|
478
|
-
} else {
|
|
479
|
-
// Manual-edit fallback: status may be in-session with no lease.
|
|
480
|
-
const raw = readFileSync(filePath, 'utf8');
|
|
481
|
-
const { frontmatter: fmRaw } = extractFrontmatter(raw);
|
|
482
|
-
const parsedFm = parseSimpleFrontmatter(fmRaw);
|
|
483
|
-
if (asString(parsedFm.status) === 'in-session') {
|
|
484
|
-
targets.push({ path: repoPath, oldStatus: null, session: null, pid: null, host: null, pickedUpAt: null, _orphan: true });
|
|
485
|
-
} else {
|
|
486
|
-
die(`Not in-session: ${repoPath}`);
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
} else if (all) {
|
|
490
|
-
targets = Object.values(leases);
|
|
491
|
-
} else if (stale) {
|
|
492
|
-
// releaseStale handled separately below — set a marker
|
|
493
|
-
targets = null;
|
|
494
|
-
} else {
|
|
495
|
-
// Default: release all owned by current session
|
|
496
|
-
targets = Object.values(leases).filter(l => l.session === session);
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
const targetStatus = (lease) => toStatus || lease.oldStatus || 'active';
|
|
500
|
-
|
|
501
|
-
function flipFrontmatter(repoPath, newStatus) {
|
|
502
|
-
const filePath = resolveDocPath(repoPath, config);
|
|
503
|
-
if (!filePath) {
|
|
504
|
-
warn(`Lease points at ${repoPath} but file not found — releasing lease without frontmatter update.`);
|
|
505
|
-
return;
|
|
506
|
-
}
|
|
507
|
-
try {
|
|
508
|
-
const raw = readFileSync(filePath, 'utf8');
|
|
509
|
-
const { frontmatter: fmRaw } = extractFrontmatter(raw);
|
|
510
|
-
const parsedFm = parseSimpleFrontmatter(fmRaw);
|
|
511
|
-
const cur = asString(parsedFm.status);
|
|
512
|
-
if (cur === 'in-session') {
|
|
513
|
-
const today = nowIso();
|
|
514
|
-
updateFrontmatter(filePath, { status: newStatus, updated: today });
|
|
515
|
-
appendVersionHistory(filePath, `Released (in-session → ${newStatus}).`);
|
|
516
|
-
touched.push(filePath);
|
|
517
|
-
if (noIndex) {
|
|
518
|
-
process.stderr.write(dim('(index not regenerated — run `dotmd index` to refresh)\n'));
|
|
519
|
-
} else {
|
|
520
|
-
regenIndex(config);
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
// If frontmatter is no longer in-session (manual flip), leave it alone.
|
|
524
|
-
} catch (err) {
|
|
525
|
-
warn(`Could not update frontmatter for ${repoPath}: ${err.message}`);
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
if (targets === null) {
|
|
530
|
-
// --stale path
|
|
531
|
-
if (dryRun) {
|
|
532
|
-
const staleLeases = Object.values(leases).filter(l => isLeaseReclaimable(l, { currentSession: session }));
|
|
533
|
-
for (const l of staleLeases) {
|
|
534
|
-
process.stderr.write(`${dim('[dry-run]')} Would release stale: ${l.path} (${l.session})\n`);
|
|
535
|
-
}
|
|
536
|
-
} else {
|
|
537
|
-
const result = releaseStale(config);
|
|
538
|
-
for (const l of result.released) {
|
|
539
|
-
flipFrontmatter(l.path, targetStatus(l));
|
|
540
|
-
released.push({ path: l.path, oldStatus: l.oldStatus, newStatus: targetStatus(l), session: l.session, stale: true });
|
|
541
|
-
try { config.hooks.onUnpickup?.({ path: l.path, oldStatus: 'in-session', newStatus: targetStatus(l) }); } catch (err) { warn(`Hook 'onUnpickup' threw: ${err.message}`); }
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
} else {
|
|
545
|
-
// Silent no-op: when the session has nothing to release (already
|
|
546
|
-
// auto-released by archive, or never held), exit 0 with no output.
|
|
547
|
-
// Only print when work was actually done. The fileArg path can't reach
|
|
548
|
-
// here with targets.length === 0 — it would have died at lookup.
|
|
549
|
-
for (const lease of targets) {
|
|
550
|
-
const newStatus = targetStatus(lease);
|
|
551
|
-
if (dryRun) {
|
|
552
|
-
process.stderr.write(`${dim('[dry-run]')} Would release: ${lease.path} (${lease.oldStatus ?? '?'} → ${newStatus})\n`);
|
|
553
|
-
continue;
|
|
554
|
-
}
|
|
555
|
-
if (lease._orphan) {
|
|
556
|
-
// Manual-edit fallback: no lease entry, just flip frontmatter.
|
|
557
|
-
flipFrontmatter(lease.path, newStatus);
|
|
558
|
-
warn(`No lease found for ${lease.path}; flipped status manually.`);
|
|
559
|
-
released.push({ path: lease.path, oldStatus: 'in-session', newStatus, session: null, orphan: true });
|
|
560
|
-
try { config.hooks.onUnpickup?.({ path: lease.path, oldStatus: 'in-session', newStatus }); } catch (err) { warn(`Hook 'onUnpickup' threw: ${err.message}`); }
|
|
561
|
-
continue;
|
|
562
|
-
}
|
|
563
|
-
const isMine = lease.session === session;
|
|
564
|
-
if (!isMine && !force && !all && !stale) {
|
|
565
|
-
skipped.push({ path: lease.path, reason: 'not-yours', session: lease.session });
|
|
566
|
-
continue;
|
|
567
|
-
}
|
|
568
|
-
const r = releaseLease(config, lease.path, { force: true });
|
|
569
|
-
if (r.released) {
|
|
570
|
-
flipFrontmatter(lease.path, newStatus);
|
|
571
|
-
released.push({ path: lease.path, oldStatus: lease.oldStatus, newStatus, session: lease.session });
|
|
572
|
-
try { config.hooks.onUnpickup?.({ path: lease.path, oldStatus: 'in-session', newStatus }); } catch (err) { warn(`Hook 'onUnpickup' threw: ${err.message}`); }
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
if (json) {
|
|
578
|
-
process.stdout.write(JSON.stringify({ released, skipped }, null, 2) + '\n');
|
|
579
|
-
} else {
|
|
580
|
-
for (const r of released) {
|
|
581
|
-
const tag = r.stale ? ' (stale)' : (r.orphan ? ' (orphan)' : '');
|
|
582
|
-
process.stdout.write(`${green('↩ Unpicked')}: ${r.path} (in-session → ${r.newStatus})${tag}\n`);
|
|
583
|
-
}
|
|
584
|
-
for (const s of skipped) {
|
|
585
|
-
process.stderr.write(`${yellow('⚠ Skipped')}: ${s.path} (held by ${s.session}; use --force to override)\n`);
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
if (showFiles && touched.length > 0) {
|
|
590
|
-
const all = [...touched];
|
|
591
|
-
if (config.indexPath && !noIndex) all.push(config.indexPath);
|
|
592
|
-
emitFilesFooter(all, config);
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
|
|
596
380
|
export function runArchive(argv, config, opts = {}) {
|
|
597
381
|
const { dryRun, out = process.stdout } = opts;
|
|
598
382
|
const noIndex = argv.includes('--no-index') || opts.noIndex;
|
|
@@ -675,11 +459,6 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
675
459
|
out.write(`${prefix} Would update references in ${refCount} file(s)\n`);
|
|
676
460
|
}
|
|
677
461
|
|
|
678
|
-
// Preview lease release (only if a lease exists for this plan)
|
|
679
|
-
if (readLeases(config)[oldRepoPath]) {
|
|
680
|
-
out.write(`${prefix} Would release in-session lease: ${oldRepoPath}\n`);
|
|
681
|
-
}
|
|
682
|
-
|
|
683
462
|
// Preview onArchive hook fire
|
|
684
463
|
if (config.hooks?.onArchive) {
|
|
685
464
|
out.write(`${prefix} Would fire hook: onArchive\n`);
|
|
@@ -718,8 +497,6 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
718
497
|
if (config.indexPath && !noIndex) out.write('Index regenerated.\n');
|
|
719
498
|
if (config.indexPath && noIndex) out.write(dim('(index not regenerated — run `dotmd index` to refresh)\n'));
|
|
720
499
|
|
|
721
|
-
try { releaseLease(config, oldRepoPath, { force: true }); } catch (err) { warn(`Could not release lease for ${oldRepoPath}: ${err.message}`); }
|
|
722
|
-
|
|
723
500
|
const touched = [oldRepoPath, newRepoPath, ...refTouchedPaths];
|
|
724
501
|
if (config.indexPath && !noIndex) touched.push(config.indexPath);
|
|
725
502
|
if (showFiles) emitFilesFooter(touched, config);
|
|
@@ -757,42 +534,14 @@ export async function runSet(argv, config, opts = {}) {
|
|
|
757
534
|
argv = argv.filter(a => a !== '--no-index' && a !== '--show-files');
|
|
758
535
|
|
|
759
536
|
const newStatus = argv[0];
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
if (!newStatus) die('Usage: dotmd set <status> [<path>]');
|
|
763
|
-
|
|
764
|
-
// `set in-session` acquires a lease + prints the plan body (same as the
|
|
765
|
-
// legacy `pickup` verb). Delegated so `set` is the single status verb.
|
|
766
|
-
if (newStatus === 'in-session') {
|
|
767
|
-
const pickArgs = input ? [input] : [];
|
|
768
|
-
if (argv.includes('--takeover')) pickArgs.push('--takeover');
|
|
769
|
-
if (noIndex) pickArgs.push('--no-index');
|
|
770
|
-
if (showFiles) pickArgs.push('--show-files');
|
|
771
|
-
return runPickup(pickArgs, config, { dryRun });
|
|
772
|
-
}
|
|
537
|
+
const input = argv[1];
|
|
773
538
|
|
|
774
|
-
if (!
|
|
775
|
-
|
|
776
|
-
const sid = currentSessionId();
|
|
777
|
-
const owned = Object.entries(leases).filter(([_, l]) => l.session === sid);
|
|
778
|
-
if (owned.length === 0) {
|
|
779
|
-
die('No <path> given and no held lease to infer from.\nUsage: dotmd set <status> <path>');
|
|
780
|
-
}
|
|
781
|
-
if (owned.length > 1) {
|
|
782
|
-
const paths = owned.map(([p]) => ` - ${p}`).join('\n');
|
|
783
|
-
die(`No <path> given; you hold ${owned.length} leases:\n${paths}\nSpecify <path> explicitly.`);
|
|
784
|
-
}
|
|
785
|
-
input = owned[0][0];
|
|
786
|
-
}
|
|
539
|
+
if (!newStatus) die('Usage: dotmd set <status> <path>');
|
|
540
|
+
if (!input) die('Usage: dotmd set <status> <path>');
|
|
787
541
|
|
|
788
542
|
const filePath = resolveDocPath(input, config);
|
|
789
543
|
if (!filePath) die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`);
|
|
790
544
|
|
|
791
|
-
const raw = readFileSync(filePath, 'utf8');
|
|
792
|
-
const { frontmatter: fmRaw } = extractFrontmatter(raw);
|
|
793
|
-
const parsedFm = parseSimpleFrontmatter(fmRaw);
|
|
794
|
-
const oldStatus = asString(parsedFm.status);
|
|
795
|
-
|
|
796
545
|
const inArchive = isArchivedPath(toRepoPath(filePath, config.repoRoot), config);
|
|
797
546
|
|
|
798
547
|
if (config.lifecycle.archiveStatuses.has(newStatus) && !inArchive) {
|
|
@@ -806,12 +555,6 @@ export async function runSet(argv, config, opts = {}) {
|
|
|
806
555
|
if (noIndex) statusArgs.push('--no-index');
|
|
807
556
|
if (showFiles) statusArgs.push('--show-files');
|
|
808
557
|
await runStatus(statusArgs, config, { dryRun, suppressDeprecation: true });
|
|
809
|
-
|
|
810
|
-
if (oldStatus === 'in-session' && newStatus !== 'in-session' && !dryRun) {
|
|
811
|
-
const repoPath = toRepoPath(filePath, config.repoRoot);
|
|
812
|
-
try { releaseLease(config, repoPath, { force: false }); }
|
|
813
|
-
catch (err) { warn(`Could not release lease for ${repoPath}: ${err.message}`); }
|
|
814
|
-
}
|
|
815
558
|
}
|
|
816
559
|
|
|
817
560
|
export function runBulkArchive(argv, config, opts = {}) {
|
package/src/rename.mjs
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { toRepoPath, resolveDocPath, die, warn } from './util.mjs';
|
|
4
|
-
import { migrateLease } from './lease.mjs';
|
|
5
4
|
import { collectDocFiles } from './index.mjs';
|
|
6
5
|
import { regenIndex } from './lifecycle.mjs';
|
|
7
6
|
import { gitMv } from './git.mjs';
|
|
@@ -100,8 +99,6 @@ export async function runRename(argv, config, opts = {}) {
|
|
|
100
99
|
}
|
|
101
100
|
}
|
|
102
101
|
|
|
103
|
-
try { migrateLease(config, oldRepoPath, newRepoPath); } catch (err) { warn(`Could not migrate lease ${oldRepoPath} → ${newRepoPath}: ${err.message}`); }
|
|
104
|
-
|
|
105
102
|
regenIndex(config);
|
|
106
103
|
|
|
107
104
|
process.stdout.write(`${green('Renamed')}: ${oldRepoPath} → ${newRepoPath}\n`);
|
package/src/render.mjs
CHANGED
|
@@ -4,7 +4,6 @@ import { capitalize, toSlug, truncate, warn } from './util.mjs';
|
|
|
4
4
|
import { extractFrontmatter } from './frontmatter.mjs';
|
|
5
5
|
import { summarizeDocBody } from './ai.mjs';
|
|
6
6
|
import { bold, red, yellow, green, dim } from './color.mjs';
|
|
7
|
-
import { findStaleLeases } from './lease.mjs';
|
|
8
7
|
import { categorizeWarnings } from './check-collapse.mjs';
|
|
9
8
|
|
|
10
9
|
// Render `currentState` with an `(auto)` prefix when the value was body-scraped
|
|
@@ -357,13 +356,6 @@ export function renderBriefing(index, config) {
|
|
|
357
356
|
: `Errors: ${errorCount}`;
|
|
358
357
|
lines.push(`Stale: ${stale} | ${errorPart} | Warnings: ${index.warnings.length}`);
|
|
359
358
|
|
|
360
|
-
try {
|
|
361
|
-
const staleLeases = findStaleLeases(config);
|
|
362
|
-
if (staleLeases.length > 0) {
|
|
363
|
-
lines.push(yellow(`Stuck in-session: ${staleLeases.length} (>4h or dead same-host pid, run \`dotmd release --stale\`)`));
|
|
364
|
-
}
|
|
365
|
-
} catch {}
|
|
366
|
-
|
|
367
359
|
return lines.join('\n') + '\n';
|
|
368
360
|
}
|
|
369
361
|
|
package/src/runlist.mjs
CHANGED
|
@@ -217,20 +217,19 @@ export async function runRunlist(argv, config, opts = {}) {
|
|
|
217
217
|
die(
|
|
218
218
|
`Next child in runlist ${hubRepoPath} is ${target.path} (status: ${target.status}).\n` +
|
|
219
219
|
`Resolve the blocker before continuing the runlist.\n` +
|
|
220
|
-
` dotmd
|
|
221
|
-
` dotmd
|
|
220
|
+
` dotmd set active ${target.path} # if ready to resume\n` +
|
|
221
|
+
` dotmd use ${target.path} # to inspect`,
|
|
222
222
|
);
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
const {
|
|
229
|
-
const
|
|
230
|
-
if (argv.includes('--
|
|
231
|
-
if (argv.includes('--
|
|
232
|
-
if (argv.includes('--
|
|
233
|
-
if (
|
|
234
|
-
|
|
235
|
-
await runPickup(pickupArgs, config, opts);
|
|
225
|
+
// Open the next child: set it in-session (frontmatter) and render its card.
|
|
226
|
+
// Dynamic import to avoid circular module-load cost when the runlist command
|
|
227
|
+
// isn't used.
|
|
228
|
+
const { startPlan } = await import('./lifecycle.mjs');
|
|
229
|
+
const startArgs = [target.path];
|
|
230
|
+
if (argv.includes('--full')) startArgs.push('--full');
|
|
231
|
+
if (argv.includes('--no-index')) startArgs.push('--no-index');
|
|
232
|
+
if (argv.includes('--show-files')) startArgs.push('--show-files');
|
|
233
|
+
if (json) startArgs.push('--json');
|
|
234
|
+
await startPlan(startArgs, config, opts);
|
|
236
235
|
}
|
package/src/ship.mjs
CHANGED
|
@@ -4,7 +4,6 @@ import path from 'node:path';
|
|
|
4
4
|
import { die, warn, toRepoPath } from './util.mjs';
|
|
5
5
|
import { green, dim, yellow } from './color.mjs';
|
|
6
6
|
import { scaffoldClaudeCommands } from './claude-commands.mjs';
|
|
7
|
-
import { readLeases, currentSessionId } from './lease.mjs';
|
|
8
7
|
|
|
9
8
|
// Files dotmd ship will auto-stage when they're dirty. Anything outside this
|
|
10
9
|
// allowlist stays in the working tree — user has to `git add` it explicitly,
|
|
@@ -62,14 +61,6 @@ function listDirtyFiles(repoRoot) {
|
|
|
62
61
|
});
|
|
63
62
|
}
|
|
64
63
|
|
|
65
|
-
function findHeldPlanTitle(config) {
|
|
66
|
-
const leases = readLeases(config);
|
|
67
|
-
const sid = currentSessionId();
|
|
68
|
-
const owned = Object.entries(leases).filter(([_, l]) => l.session === sid);
|
|
69
|
-
if (owned.length !== 1) return null;
|
|
70
|
-
return path.basename(owned[0][0], '.md');
|
|
71
|
-
}
|
|
72
|
-
|
|
73
64
|
export async function runShip(argv, config, opts = {}) {
|
|
74
65
|
const { dryRun } = opts;
|
|
75
66
|
const positional = argv.filter(a => !a.startsWith('-'));
|
|
@@ -127,10 +118,7 @@ export async function runShip(argv, config, opts = {}) {
|
|
|
127
118
|
const add = spawnSync('git', ['add', '--', ...allToStage], { cwd: config.repoRoot, encoding: 'utf8' });
|
|
128
119
|
if (add.status !== 0) die(`git add failed: ${add.stderr}`);
|
|
129
120
|
|
|
130
|
-
const
|
|
131
|
-
const subject = planTitle
|
|
132
|
-
? `chore: release ${target} (${planTitle})`
|
|
133
|
-
: `chore: release ${target}`;
|
|
121
|
+
const subject = `chore: release ${target}`;
|
|
134
122
|
const body = `Auto-staged by \`dotmd ship\`:\n${allToStage.map(p => `- ${p}`).join('\n')}`;
|
|
135
123
|
const commitMsg = `${subject}\n\n${body}`;
|
|
136
124
|
const commit = spawnSync('git', ['commit', '-m', commitMsg], { cwd: config.repoRoot, encoding: 'utf8' });
|
package/src/use.mjs
CHANGED
|
@@ -2,13 +2,13 @@ import { readFileSync } from 'node:fs';
|
|
|
2
2
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
3
3
|
import { asString, die, resolveDocPath, toRepoPath } from './util.mjs';
|
|
4
4
|
import { consumePrompt, pendingPromptsOldestFirst, resolvePromptInput } from './prompts.mjs';
|
|
5
|
-
import {
|
|
5
|
+
import { startPlan } from './lifecycle.mjs';
|
|
6
6
|
|
|
7
7
|
// Top-level `dotmd use [file]` — the single "start engaging with this doc"
|
|
8
8
|
// verb. Dispatches by the target doc's `type:` so agents don't have to know
|
|
9
9
|
// the verb-per-type rule:
|
|
10
10
|
// - prompt → print body + archive (one-shot consume)
|
|
11
|
-
// - plan →
|
|
11
|
+
// - plan → set status in-session + print the plan card
|
|
12
12
|
// - doc → print body (read-only)
|
|
13
13
|
// With no argument, consumes the oldest pending prompt.
|
|
14
14
|
export async function runUse(argv, config, opts = {}) {
|
|
@@ -34,8 +34,8 @@ export async function runUse(argv, config, opts = {}) {
|
|
|
34
34
|
return consumePrompt(filePath, config, opts);
|
|
35
35
|
}
|
|
36
36
|
if (type === 'plan') {
|
|
37
|
-
//
|
|
38
|
-
return
|
|
37
|
+
// Mark in-session (pure frontmatter) and print the plan card.
|
|
38
|
+
return startPlan([filePath, ...argv.filter(a => a !== positional)], config, opts);
|
|
39
39
|
}
|
|
40
40
|
// Anything else (doc, untyped, custom): print the body. The frontmatter
|
|
41
41
|
// already names everything an agent needs to know about lifecycle for that
|