dotmd-cli 0.50.2 → 0.52.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 +143 -126
- package/package.json +1 -1
- package/src/claude-commands.mjs +5 -28
- package/src/commands.mjs +2 -1
- package/src/completions.mjs +1 -5
- package/src/doctor.mjs +1 -1
- package/src/git.mjs +19 -0
- package/src/guard.mjs +202 -0
- package/src/hints.mjs +1 -6
- package/src/hud.mjs +24 -25
- package/src/journal.mjs +58 -1
- package/src/lifecycle.mjs +18 -275
- package/src/misuse-read.mjs +62 -0
- package/src/new.mjs +16 -0
- 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/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 = {}) {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readMisuseEntries, globalMisuseLogPath } from './journal.mjs';
|
|
3
|
+
import { dim, red, yellow } from './color.mjs';
|
|
4
|
+
|
|
5
|
+
// `dotmd misuse` — read the cross-repo guard log. Every wrong-move the
|
|
6
|
+
// PreToolUse guard intercepts lands here; this is the operator's window into
|
|
7
|
+
// "what are sessions getting wrong, and how often."
|
|
8
|
+
function parseArgs(argv) {
|
|
9
|
+
const opts = { tail: null, byRule: false, asJson: false, repo: null };
|
|
10
|
+
for (let i = 0; i < argv.length; i++) {
|
|
11
|
+
const a = argv[i];
|
|
12
|
+
if (a === '--tail') { const n = parseInt(argv[++i], 10); opts.tail = Number.isFinite(n) && n > 0 ? n : 20; }
|
|
13
|
+
else if (a === '--by-rule') opts.byRule = true;
|
|
14
|
+
else if (a === '--json') opts.asJson = true;
|
|
15
|
+
else if (a === '--repo' && argv[i + 1]) opts.repo = argv[++i];
|
|
16
|
+
}
|
|
17
|
+
if (opts.tail === null && !opts.byRule) opts.tail = 20;
|
|
18
|
+
return opts;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function runMisuse(argv, _config) {
|
|
22
|
+
const file = globalMisuseLogPath();
|
|
23
|
+
if (!existsSync(file)) {
|
|
24
|
+
process.stderr.write(
|
|
25
|
+
'No misuse log yet. The PreToolUse guard (`dotmd guard`) writes here when it intercepts a wrong move.\n' +
|
|
26
|
+
'Wire it up: add a PreToolUse hook that runs `dotmd guard` (see `dotmd help guard`).\n',
|
|
27
|
+
);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const opts = parseArgs(argv);
|
|
32
|
+
let entries = readMisuseEntries();
|
|
33
|
+
if (opts.repo) entries = entries.filter(e => (e.repo || '').includes(opts.repo));
|
|
34
|
+
|
|
35
|
+
if (opts.byRule) {
|
|
36
|
+
const groups = new Map();
|
|
37
|
+
for (const e of entries) {
|
|
38
|
+
const key = e.rule || '(unknown)';
|
|
39
|
+
if (!groups.has(key)) groups.set(key, { rule: key, count: 0, deny: 0, warn: 0 });
|
|
40
|
+
const g = groups.get(key);
|
|
41
|
+
g.count++;
|
|
42
|
+
if (e.decision === 'deny') g.deny++; else if (e.decision === 'warn') g.warn++;
|
|
43
|
+
}
|
|
44
|
+
const rows = [...groups.values()].sort((a, b) => b.count - a.count);
|
|
45
|
+
if (opts.asJson) { process.stdout.write(JSON.stringify(rows, null, 2) + '\n'); return; }
|
|
46
|
+
if (!rows.length) { process.stdout.write('No misuse events recorded.\n'); return; }
|
|
47
|
+
for (const r of rows) {
|
|
48
|
+
process.stdout.write(`${r.rule.padEnd(16)} ${String(r.count).padStart(4)}× ${red(`${r.deny} deny`)} / ${yellow(`${r.warn} warn`)}\n`);
|
|
49
|
+
}
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (opts.tail) entries = entries.slice(-opts.tail);
|
|
54
|
+
if (opts.asJson) { process.stdout.write(JSON.stringify(entries, null, 2) + '\n'); return; }
|
|
55
|
+
if (!entries.length) { process.stdout.write('No misuse events recorded.\n'); return; }
|
|
56
|
+
|
|
57
|
+
for (const e of entries) {
|
|
58
|
+
const tag = e.decision === 'deny' ? red('DENY') : yellow('warn');
|
|
59
|
+
const repo = e.repo ? dim(` ${e.repo.split('/').pop()}`) : '';
|
|
60
|
+
process.stdout.write(`[${e.ts}] ${tag} ${e.rule || '?'}${repo} ${dim(e.detail || '')}\n`);
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/new.mjs
CHANGED
|
@@ -539,6 +539,22 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
539
539
|
process.stdout.write(`${green('Created')}: ${repoPath} ${dim(`(${typeName})`)}\n`);
|
|
540
540
|
if (rootHint) process.stdout.write(dim(rootHint));
|
|
541
541
|
|
|
542
|
+
// Post-create guidance. Prompts are the classic confusion point: agents
|
|
543
|
+
// reflexively `git add && commit` a freshly-created file, but saved prompts
|
|
544
|
+
// are session-local handoff artifacts — the next session consumes them via
|
|
545
|
+
// `dotmd use`, and the prompts dir is often gitignored (the commit then fails
|
|
546
|
+
// confusingly). Tell the agent the next step explicitly, and flag a gitignored
|
|
547
|
+
// target for any type so "why won't this commit" never happens silently.
|
|
548
|
+
if (typeName === 'prompt') {
|
|
549
|
+
process.stdout.write(dim('Session-local — no need to commit. The next session runs `dotmd use` (or `dotmd use ' + repoPath + '`) to consume it.\n'));
|
|
550
|
+
}
|
|
551
|
+
try {
|
|
552
|
+
const { isGitIgnored } = await import('./git.mjs');
|
|
553
|
+
if (isGitIgnored(filePath, config.repoRoot)) {
|
|
554
|
+
process.stdout.write(dim(`Note: ${repoPath} is gitignored — don't try to git add/commit it.\n`));
|
|
555
|
+
}
|
|
556
|
+
} catch { /* git absent / not a repo — skip the note */ }
|
|
557
|
+
|
|
542
558
|
regenIndex(config);
|
|
543
559
|
|
|
544
560
|
if (showFiles) {
|
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
|
package/src/util.mjs
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
3
4
|
import { dim } from './color.mjs';
|
|
4
5
|
|
|
6
|
+
// Stable identifier for the current shell/agent session. Used for journal
|
|
7
|
+
// attribution and hint de-duplication — not for any plan locking.
|
|
8
|
+
export function currentSessionId() {
|
|
9
|
+
if (process.env.CLAUDE_CODE_SESSION_ID) return process.env.CLAUDE_CODE_SESSION_ID;
|
|
10
|
+
if (process.env.CLAUDE_SESSION_ID) return process.env.CLAUDE_SESSION_ID;
|
|
11
|
+
if (process.env.TERM_SESSION_ID) return `term:${process.env.TERM_SESSION_ID}`;
|
|
12
|
+
return `shell:${os.userInfo().username}@${os.hostname()}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
5
15
|
export function escapeTable(value) {
|
|
6
16
|
return String(value).replace(/\|/g, '\\|');
|
|
7
17
|
}
|
package/src/validate.mjs
CHANGED
|
@@ -2,7 +2,6 @@ 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, STALE_LEASE_AGE_HOURS } from './lease.mjs';
|
|
6
5
|
|
|
7
6
|
const NOW = new Date();
|
|
8
7
|
|
|
@@ -202,31 +201,6 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
|
|
|
202
201
|
doc.warnings.push({ path: doc.path, level: 'warning', message: 'Archived plan missing `## Closeout` section.' });
|
|
203
202
|
}
|
|
204
203
|
|
|
205
|
-
// F11: `status: in-session` plans should have a matching live lease. If the
|
|
206
|
-
// lease file has no entry, the previous session crashed without releasing;
|
|
207
|
-
// if the entry is stale (> stale-threshold hours), the holder is gone.
|
|
208
|
-
// Either way the validator is the only place that knows enough to suggest
|
|
209
|
-
// the exact unstuck command, because the lease infrastructure is otherwise
|
|
210
|
-
// invisible to `dotmd check`.
|
|
211
|
-
if (doc.status === 'in-session' && !config.lifecycle.skipWarningsFor.has(doc.status)) {
|
|
212
|
-
const leases = readLeases(config);
|
|
213
|
-
const lease = leases[doc.path];
|
|
214
|
-
if (!lease) {
|
|
215
|
-
doc.warnings.push({
|
|
216
|
-
path: doc.path,
|
|
217
|
-
level: 'warning',
|
|
218
|
-
message: `\`status: in-session\` but no session is actually working on this (previous session may have crashed). Run \`dotmd set active ${doc.path}\` to clear and re-queue.`,
|
|
219
|
-
});
|
|
220
|
-
} else if (isLeaseStale(lease)) {
|
|
221
|
-
const ageHours = Math.floor((Date.now() - new Date(lease.pickedUpAt).getTime()) / (1000 * 60 * 60));
|
|
222
|
-
doc.warnings.push({
|
|
223
|
-
path: doc.path,
|
|
224
|
-
level: 'warning',
|
|
225
|
-
message: `\`status: in-session\` but last activity was ${ageHours}h ago (>${STALE_LEASE_AGE_HOURS}h, looks abandoned). Run \`dotmd set active ${doc.path}\` to clear and re-queue.`,
|
|
226
|
-
});
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
204
|
// Archive drift: a doc with an archive-flagged status (`status: archived` by
|
|
231
205
|
// default) whose parent dir is a "live" type-conventional location is
|
|
232
206
|
// misplaced — `dotmd archive` would have moved it under `<that>/archiveDir/`.
|