dotmd-cli 0.48.3 → 0.49.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 +157 -9
- package/package.json +1 -1
- package/src/claude-commands.mjs +3 -0
- package/src/commands.mjs +3 -3
- package/src/completions.mjs +4 -3
- package/src/doctor.mjs +71 -3
- package/src/glossary-check.mjs +35 -0
- package/src/glossary.mjs +5 -1
- package/src/hud.mjs +12 -1
- package/src/index-file.mjs +23 -2
- package/src/index.mjs +14 -2
- package/src/journal.mjs +57 -4
- package/src/lease.mjs +19 -9
- package/src/lifecycle.mjs +3 -6
- package/src/prompts.mjs +44 -2
- package/src/render.mjs +80 -2
- package/src/runlist.mjs +48 -9
package/bin/dotmd.mjs
CHANGED
|
@@ -4,20 +4,70 @@ import { readFileSync } from 'node:fs';
|
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { resolveConfig } from '../src/config.mjs';
|
|
7
|
-
import { die, warn, levenshtein } from '../src/util.mjs';
|
|
8
|
-
import { recordCliInvocation } from '../src/journal.mjs';
|
|
7
|
+
import { die, warn, levenshtein, isArchivedPath } from '../src/util.mjs';
|
|
8
|
+
import { recordCliInvocation, recordGlobalError } from '../src/journal.mjs';
|
|
9
9
|
import { findRepeatFailureHint } from '../src/hints.mjs';
|
|
10
10
|
|
|
11
11
|
const __filename = fileURLToPath(import.meta.url);
|
|
12
12
|
const __dirname = path.dirname(__filename);
|
|
13
13
|
const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
14
14
|
|
|
15
|
+
const QUERY_FLAGS = new Set([
|
|
16
|
+
'--type', '--status', '--keyword', '--owner', '--surface', '--module',
|
|
17
|
+
'--domain', '--audience', '--execution-mode', '--updated-since', '--limit',
|
|
18
|
+
'--sort', '--group', '--all', '--include-archived', '--exclude-archived',
|
|
19
|
+
'--stale', '--has-next-step', '--has-blockers', '--checklist-open', '--json',
|
|
20
|
+
'--git', '--summarize', '--summarize-limit', '--model',
|
|
21
|
+
]);
|
|
22
|
+
const QUERY_VALUE_FLAGS = new Set([
|
|
23
|
+
'--type', '--status', '--keyword', '--owner', '--surface', '--module',
|
|
24
|
+
'--domain', '--audience', '--execution-mode', '--updated-since', '--limit',
|
|
25
|
+
'--sort', '--group', '--summarize-limit', '--model',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const FLAG_SPECS = {
|
|
29
|
+
plans: { flags: QUERY_FLAGS, values: QUERY_VALUE_FLAGS, subcommands: new Set(['status']) },
|
|
30
|
+
query: { flags: QUERY_FLAGS, values: QUERY_VALUE_FLAGS },
|
|
31
|
+
stale: { flags: QUERY_FLAGS, values: QUERY_VALUE_FLAGS },
|
|
32
|
+
actionable: { flags: QUERY_FLAGS, values: QUERY_VALUE_FLAGS },
|
|
33
|
+
list: { flags: new Set(['--json', '--verbose']), values: new Set() },
|
|
34
|
+
briefing: { flags: new Set(['--json']), values: new Set() },
|
|
35
|
+
context: { flags: new Set(['--json', '--compact', '--summarize', '--model']), values: new Set(['--model']) },
|
|
36
|
+
'agent-context': { flags: new Set(['--json']), values: new Set() },
|
|
37
|
+
hud: { flags: new Set(['--json']), values: new Set() },
|
|
38
|
+
check: { flags: new Set(['--fix', '--errors-only', '--no-collapse', '--json', '--verbose']), values: new Set() },
|
|
39
|
+
doctor: { flags: new Set(['--apply', '--yes', '--dry-run', '-n', '--statuses', '--migrate-template', '--migrate-prompts', '--frontmatter-fix', '--project', '--json', '--include-archived']), values: new Set() },
|
|
40
|
+
runlist: { flags: new Set(['--json', '--takeover', '--full', '--no-index', '--show-files']), values: new Set(), subcommands: new Set(['next']) },
|
|
41
|
+
release: { flags: new Set(['--json', '--all', '--stale', '--to', '--force', '--no-index', '--show-files']), values: new Set(['--to']) },
|
|
42
|
+
unpickup: { flags: new Set(['--json', '--all', '--stale', '--to', '--force', '--no-index', '--show-files']), values: new Set(['--to']) },
|
|
43
|
+
finish: { flags: new Set(['--json', '--all', '--stale', '--to', '--force', '--no-index', '--show-files']), values: new Set(['--to']) },
|
|
44
|
+
prompts: {
|
|
45
|
+
flags: new Set(['--json', '--status', '--include-archived', '--sort', '--limit', '--all', '--no-index', '--show-files', '--body', '--message', '--title']),
|
|
46
|
+
values: new Set(['--status', '--sort', '--limit', '--body', '--message', '--title']),
|
|
47
|
+
subcommands: new Set(['list', 'next', 'use', 'resume', 'archive', 'new', 'shelve', 'unshelve', 'status']),
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function validateKnownFlags(command, argv, config) {
|
|
52
|
+
const spec = FLAG_SPECS[command] ?? (config?.presets?.[command] ? { flags: QUERY_FLAGS, values: QUERY_VALUE_FLAGS } : null);
|
|
53
|
+
if (!spec) return;
|
|
54
|
+
for (let i = 0; i < argv.length; i++) {
|
|
55
|
+
const arg = argv[i];
|
|
56
|
+
if (spec.subcommands?.has(arg)) continue;
|
|
57
|
+
if (!arg.startsWith('-')) continue;
|
|
58
|
+
if (!spec.flags.has(arg)) die(`Unknown flag for \`dotmd ${command}\`: ${arg}`);
|
|
59
|
+
if (spec.values.has(arg)) i += 1;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
15
63
|
const HELP = {
|
|
16
64
|
_main: `dotmd v${pkg.version} — frontmatter markdown document manager
|
|
17
65
|
|
|
18
66
|
Common commands:
|
|
19
67
|
plans Live plans (excludes archived)
|
|
68
|
+
prompts Prompt queue/admin (list, next, archive, new, shelve)
|
|
20
69
|
briefing Full briefing with plan counts + next steps
|
|
70
|
+
agent-context Compact bounded JSON context for agents
|
|
21
71
|
set <status> [file] Transition status (start work, finish, archive — all via target status)
|
|
22
72
|
new <type> <name> Create plan/doc/prompt (pipe stdin or @path for body)
|
|
23
73
|
use [<file>] Open a doc by type: prompt → consume, plan → start, doc → read
|
|
@@ -40,7 +90,8 @@ View & Query:
|
|
|
40
90
|
hud [--json] Two-line actionable triage (held / prompts / stuck) — silent when clean
|
|
41
91
|
list [--verbose] [--json] List docs grouped by status (default command)
|
|
42
92
|
briefing [--json] Full briefing with plan status counts + next steps
|
|
43
|
-
context [--summarize] [--json] Full briefing (LLM-oriented)
|
|
93
|
+
context [--summarize] [--json] Full briefing (LLM-oriented; use --json --compact for bounded JSON)
|
|
94
|
+
agent-context [--json] Compact bounded JSON context for agents
|
|
44
95
|
focus [status] [--json] Detailed view for one status group
|
|
45
96
|
query [filters] [--json] Filtered search (--status, --keyword, --stale, etc.)
|
|
46
97
|
plans Live plans (excludes archived; --include-archived for all)
|
|
@@ -65,12 +116,16 @@ Analyze:
|
|
|
65
116
|
|
|
66
117
|
Validate & Fix:
|
|
67
118
|
doctor [--apply] Auto-fix everything: refs, lint, dates, index (preview by default)
|
|
119
|
+
self-check Project/version skew diagnostic (alias: doctor --project)
|
|
68
120
|
lint [--fix] Check and auto-fix frontmatter issues
|
|
69
121
|
fix-refs [--dry-run] Auto-fix broken reference paths + body links
|
|
70
122
|
|
|
71
123
|
Lifecycle:
|
|
124
|
+
pickup <file> Acquire an in-session lease and start work
|
|
125
|
+
release [<file>] Release held in-session work (alias: unpickup, finish)
|
|
72
126
|
set <status> [<file>] Unified transition: start work, change status, close out, archive — all via target status
|
|
73
127
|
runlist <hub> [next] Show or walk an ordered group of plans (see \`dotmd help runlist\`)
|
|
128
|
+
unpickup [<file>] Release held in-session work
|
|
74
129
|
status <file> <status> Transition document status (deprecated; prefer \`set\`)
|
|
75
130
|
archive <file> Archive (status + move + update refs)
|
|
76
131
|
bulk archive <f1> <f2> ... Archive multiple files at once
|
|
@@ -315,6 +370,11 @@ status. With no file, releases every lease owned by the current session.
|
|
|
315
370
|
Identical behavior to \`dotmd unpickup\`; both names route to the same
|
|
316
371
|
implementation. See \`dotmd unpickup --help\` for full option list.`,
|
|
317
372
|
|
|
373
|
+
finish: `dotmd finish [<file>] [--to <s>] — alias of dotmd release
|
|
374
|
+
|
|
375
|
+
Compatibility alias for docs and agent loops that use "finish" for releasing
|
|
376
|
+
in-session work. Same behavior as \`dotmd release\` / \`dotmd unpickup\`.`,
|
|
377
|
+
|
|
318
378
|
ship: `dotmd ship [patch|minor|major] — regen + commit + bump in one step
|
|
319
379
|
|
|
320
380
|
Bundles the multi-step release dance into a single command:
|
|
@@ -479,13 +539,21 @@ Options:
|
|
|
479
539
|
|
|
480
540
|
context: `dotmd context — full briefing (LLM-oriented)
|
|
481
541
|
|
|
482
|
-
Generates a
|
|
542
|
+
Generates a status briefing designed for AI/LLM consumption. The default
|
|
543
|
+
JSON form is the full index grouped by type/status; use --compact for bounded
|
|
544
|
+
agent-safe JSON.
|
|
483
545
|
|
|
484
546
|
Options:
|
|
485
547
|
--json Output as JSON
|
|
548
|
+
--compact With --json, return counts + bounded next-action lists
|
|
486
549
|
--summarize Add AI summaries for expanded docs
|
|
487
550
|
--model <name> Model for AI summaries`,
|
|
488
551
|
|
|
552
|
+
'agent-context': `dotmd agent-context — compact bounded JSON for agents
|
|
553
|
+
|
|
554
|
+
Equivalent to \`dotmd context --json --compact\`. Returns counts,
|
|
555
|
+
validation totals, pending prompt next item, and bounded plan action lists.`,
|
|
556
|
+
|
|
489
557
|
stats: `dotmd stats — doc health dashboard
|
|
490
558
|
|
|
491
559
|
Shows aggregated metrics: status counts, staleness, errors/warnings,
|
|
@@ -615,9 +683,11 @@ Modes:
|
|
|
615
683
|
/ \`## Next Step\` body section (created above
|
|
616
684
|
the first H2 if absent, appended otherwise).
|
|
617
685
|
Plans only; honors --dry-run.
|
|
686
|
+
--project Report CLI/project version skew, generated command
|
|
687
|
+
drift, and detectable deprecated command mentions.
|
|
618
688
|
|
|
619
689
|
--apply (or --yes) opts into writes for the default auto-fix pass.
|
|
620
|
-
Sub-modes (--statuses, --migrate-*, --frontmatter-fix) keep their
|
|
690
|
+
Sub-modes (--statuses, --migrate-*, --frontmatter-fix, --project) keep their
|
|
621
691
|
existing contracts: they write by default and honor --dry-run.`,
|
|
622
692
|
|
|
623
693
|
'fix-refs': `dotmd fix-refs — auto-fix broken reference paths
|
|
@@ -1143,6 +1213,8 @@ async function main() {
|
|
|
1143
1213
|
process.stderr.write(`Repo root: ${config.repoRoot}\n`);
|
|
1144
1214
|
}
|
|
1145
1215
|
|
|
1216
|
+
validateKnownFlags(command, restArgs, config);
|
|
1217
|
+
|
|
1146
1218
|
// Preset aliases (user config can override built-in commands below)
|
|
1147
1219
|
if (config.presets[command]) {
|
|
1148
1220
|
const { buildIndex } = await import('../src/index.mjs');
|
|
@@ -1214,7 +1286,7 @@ async function main() {
|
|
|
1214
1286
|
if (command === 'hud') { const { runHud } = await import('../src/hud.mjs'); runHud(restArgs, config); return; }
|
|
1215
1287
|
if (command === 'journal') { const { runJournal } = await import('../src/journal-read.mjs'); runJournal(restArgs, config); return; }
|
|
1216
1288
|
if (command === 'pickup') { const { runPickup } = await import('../src/lifecycle.mjs'); await runPickup(restArgs, config, { dryRun }); return; }
|
|
1217
|
-
if (command === 'unpickup' || command === 'release') { const { runUnpickup } = await import('../src/lifecycle.mjs'); await runUnpickup(restArgs, config, { dryRun }); return; }
|
|
1289
|
+
if (command === 'unpickup' || command === 'release' || command === 'finish') { const { runUnpickup } = await import('../src/lifecycle.mjs'); await runUnpickup(restArgs, config, { dryRun }); return; }
|
|
1218
1290
|
if (command === 'runlist') { const { runRunlist } = await import('../src/runlist.mjs'); await runRunlist(restArgs, config, { dryRun }); return; }
|
|
1219
1291
|
if (command === 'handoff') { die('`dotmd handoff` was removed in 0.31.0. Use `dotmd prompts new <name>` to create a saved prompt instead. The .dotmd/handoffs/ sidecar mechanism no longer exists; see CHANGELOG.'); }
|
|
1220
1292
|
if (command === 'status') { const { runStatus } = await import('../src/lifecycle.mjs'); await runStatus(restArgs, config, { dryRun }); return; }
|
|
@@ -1230,6 +1302,11 @@ async function main() {
|
|
|
1230
1302
|
if (command === 'rename') { const { runRename } = await import('../src/rename.mjs'); await runRename(restArgs, config, { dryRun }); return; }
|
|
1231
1303
|
if (command === 'migrate') { const { runMigrate } = await import('../src/migrate.mjs'); runMigrate(restArgs, config, { dryRun }); return; }
|
|
1232
1304
|
if (command === 'fix-refs') { const { runFixRefs } = await import('../src/fix-refs.mjs'); runFixRefs(restArgs, config, { dryRun }); return; }
|
|
1305
|
+
if (command === 'self-check') {
|
|
1306
|
+
const { runDoctor } = await import('../src/doctor.mjs');
|
|
1307
|
+
runDoctor(['--project', ...restArgs], config, { dryRun });
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1233
1310
|
if (command === 'doctor') {
|
|
1234
1311
|
// 0.37.0 (F4): the default auto-fix loop previews by default; --apply
|
|
1235
1312
|
// (alias --yes) writes. Explicit --dry-run still works and wins over
|
|
@@ -1237,7 +1314,7 @@ async function main() {
|
|
|
1237
1314
|
// auto-fix path — sub-modes (--statuses, --migrate-template,
|
|
1238
1315
|
// --migrate-prompts) keep their existing "write unless --dry-run"
|
|
1239
1316
|
// contract because they're explicit one-shots the user opted into.
|
|
1240
|
-
const subMode = args.includes('--statuses') || args.includes('--migrate-template') || args.includes('--migrate-prompts') || args.includes('--frontmatter-fix');
|
|
1317
|
+
const subMode = args.includes('--statuses') || args.includes('--migrate-template') || args.includes('--migrate-prompts') || args.includes('--frontmatter-fix') || args.includes('--project');
|
|
1241
1318
|
const explicitApply = args.includes('--apply') || args.includes('--yes');
|
|
1242
1319
|
const explicitDryRun = args.includes('--dry-run') || args.includes('-n');
|
|
1243
1320
|
const doctorDryRun = subMode ? dryRun : (explicitDryRun || !explicitApply);
|
|
@@ -1255,12 +1332,18 @@ async function main() {
|
|
|
1255
1332
|
// Opportunistic stale-lease scrub for user-facing "what's actionable now"
|
|
1256
1333
|
// views. Diagnostic commands (`check`, `coverage`, `stats`, `index`) are
|
|
1257
1334
|
// intentionally excluded — they should surface drift, not silently fix it.
|
|
1258
|
-
const SCRUB_READ_COMMANDS = new Set(['list', 'briefing', 'context', 'focus', 'query', 'modules', 'module', 'surfaces']);
|
|
1335
|
+
const SCRUB_READ_COMMANDS = new Set(['list', 'briefing', 'context', 'agent-context', 'focus', 'query', 'modules', 'module', 'surfaces']);
|
|
1259
1336
|
if (SCRUB_READ_COMMANDS.has(command)) {
|
|
1260
1337
|
const { scrubStaleSilently } = await import('../src/lease-scrub.mjs');
|
|
1261
1338
|
scrubStaleSilently(config);
|
|
1262
1339
|
}
|
|
1263
|
-
|
|
1340
|
+
// `dotmd check` is the one shared-buildIndex command that should auto-heal a
|
|
1341
|
+
// drifted index block (frontmatter edits by direct Edit/Write, `lint --fix`,
|
|
1342
|
+
// etc. leave the README out of sync; demanding the user run `dotmd index`
|
|
1343
|
+
// each time was pure noise). Print/dry-run/read-only callers (`json`, `list`,
|
|
1344
|
+
// `query`, `index --print`, ...) stay opt-out so they never mutate disk.
|
|
1345
|
+
const AUTO_HEAL_INDEX_COMMANDS = new Set(['check']);
|
|
1346
|
+
const index = buildIndex(config, { autoHealIndex: AUTO_HEAL_INDEX_COMMANDS.has(command) });
|
|
1264
1347
|
|
|
1265
1348
|
// Apply --root and --type filters
|
|
1266
1349
|
const rootFilter = rootArg;
|
|
@@ -1430,6 +1513,55 @@ async function main() {
|
|
|
1430
1513
|
runSurfaces(restArgs, config);
|
|
1431
1514
|
return;
|
|
1432
1515
|
}
|
|
1516
|
+
|
|
1517
|
+
function compactDoc(d) {
|
|
1518
|
+
return {
|
|
1519
|
+
path: d.path,
|
|
1520
|
+
title: d.title,
|
|
1521
|
+
status: d.status,
|
|
1522
|
+
type: d.type,
|
|
1523
|
+
nextStep: d.nextStep ?? null,
|
|
1524
|
+
blockers: d.blockers ?? [],
|
|
1525
|
+
daysSinceUpdate: d.daysSinceUpdate ?? null,
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
function buildCompactAgentContext(idx) {
|
|
1530
|
+
const activeStatuses = new Set(['in-session', 'active', 'ready', 'planned', 'awaiting', 'blocked']);
|
|
1531
|
+
const active = idx.docs.filter(d => d.type === 'plan' && activeStatuses.has(d.status));
|
|
1532
|
+
const stale = idx.docs.filter(d => d.isStale && !config.lifecycle.skipStaleFor.has(d.status));
|
|
1533
|
+
const awaiting = idx.docs.filter(d => d.status === 'awaiting');
|
|
1534
|
+
const blocked = idx.docs.filter(d => d.status === 'blocked' || d.blockers?.length);
|
|
1535
|
+
const pendingPrompts = idx.docs
|
|
1536
|
+
.filter(d => d.type === 'prompt' && d.status === 'pending' && !isArchivedPath(d.path, config))
|
|
1537
|
+
.sort((a, b) => (a.created ?? '').localeCompare(b.created ?? '') || (a.updated ?? '').localeCompare(b.updated ?? ''));
|
|
1538
|
+
return {
|
|
1539
|
+
generatedAt: new Date().toISOString(),
|
|
1540
|
+
countsByStatus: idx.countsByStatus,
|
|
1541
|
+
countsByType: idx.countsByType,
|
|
1542
|
+
errors: {
|
|
1543
|
+
count: idx.errors.length,
|
|
1544
|
+
items: idx.errors.slice(0, 10).map(e => ({ path: e.path, message: e.message })),
|
|
1545
|
+
},
|
|
1546
|
+
warnings: { count: idx.warnings.length },
|
|
1547
|
+
prompts: {
|
|
1548
|
+
pending: pendingPrompts.length,
|
|
1549
|
+
next: pendingPrompts[0] ? compactDoc(pendingPrompts[0]) : null,
|
|
1550
|
+
},
|
|
1551
|
+
plans: {
|
|
1552
|
+
active: active.slice(0, 12).map(compactDoc),
|
|
1553
|
+
awaiting: awaiting.slice(0, 8).map(compactDoc),
|
|
1554
|
+
blocked: blocked.slice(0, 8).map(compactDoc),
|
|
1555
|
+
stale: stale.slice(0, 12).map(compactDoc),
|
|
1556
|
+
},
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
if (command === 'agent-context') {
|
|
1561
|
+
process.stdout.write(JSON.stringify(buildCompactAgentContext(index), null, 2) + '\n');
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1433
1565
|
if (command === 'briefing') {
|
|
1434
1566
|
if (args.includes('--json')) {
|
|
1435
1567
|
const plans = index.docs.filter(d => d.type === 'plan');
|
|
@@ -1450,10 +1582,15 @@ async function main() {
|
|
|
1450
1582
|
|
|
1451
1583
|
if (command === 'context') {
|
|
1452
1584
|
const summarize = args.includes('--summarize');
|
|
1585
|
+
const compact = args.includes('--compact');
|
|
1453
1586
|
const modelIdx = args.indexOf('--model');
|
|
1454
1587
|
const model = modelIdx !== -1 && args[modelIdx + 1] ? args[modelIdx + 1] : undefined;
|
|
1455
1588
|
|
|
1456
1589
|
if (args.includes('--json')) {
|
|
1590
|
+
if (compact) {
|
|
1591
|
+
process.stdout.write(JSON.stringify(buildCompactAgentContext(index), null, 2) + '\n');
|
|
1592
|
+
return;
|
|
1593
|
+
}
|
|
1457
1594
|
const byStatus = {};
|
|
1458
1595
|
for (const doc of index.docs) {
|
|
1459
1596
|
const s = doc.status ?? 'unknown';
|
|
@@ -1552,6 +1689,17 @@ function _journalExit(err) {
|
|
|
1552
1689
|
version: pkg.version,
|
|
1553
1690
|
});
|
|
1554
1691
|
} catch { /* never break exit on journal failure */ }
|
|
1692
|
+
if (err) {
|
|
1693
|
+
try {
|
|
1694
|
+
recordGlobalError({
|
|
1695
|
+
config: _resolvedConfig,
|
|
1696
|
+
startMs: _startMs,
|
|
1697
|
+
args: _invocationArgs,
|
|
1698
|
+
err,
|
|
1699
|
+
version: pkg.version,
|
|
1700
|
+
});
|
|
1701
|
+
} catch { /* never break exit on error-log failure */ }
|
|
1702
|
+
}
|
|
1555
1703
|
}
|
|
1556
1704
|
|
|
1557
1705
|
main()
|
package/package.json
CHANGED
package/src/claude-commands.mjs
CHANGED
|
@@ -76,6 +76,8 @@ function generatePlansCommand(config, version) {
|
|
|
76
76
|
lines.push('- `dotmd unblocks <file>` — what depends on / is blocked by a plan');
|
|
77
77
|
lines.push('- `dotmd actionable` — ready plans with next steps (what to promote)');
|
|
78
78
|
lines.push('- `dotmd query --keyword <term>` — find plans by keyword');
|
|
79
|
+
lines.push('- `dotmd runlist <hub>` — show ordered children of a runlist hub (→ marks next pickup)');
|
|
80
|
+
lines.push('- `dotmd runlist next <hub>` — pick up the next non-archived child of a runlist hub');
|
|
79
81
|
|
|
80
82
|
if (config.raw?.glossary) {
|
|
81
83
|
lines.push('- `dotmd glossary <term>` — domain term lookup with related plans');
|
|
@@ -86,6 +88,7 @@ function generatePlansCommand(config, version) {
|
|
|
86
88
|
lines.push('');
|
|
87
89
|
lines.push('If the user asks to change a plan\'s status, use `dotmd set <status> <file>`.');
|
|
88
90
|
lines.push('If the user asks to archive a plan, use `dotmd set archived <file>` (or `dotmd archive <file>`).');
|
|
91
|
+
lines.push('If the user references a runlist by name — e.g. "what\'s next on <X> runlist", "<X> runlist status", "pick up the next in <X>" — use `dotmd runlist next <X>` (or `dotmd runlist <X>` first to inspect the ordering). Do NOT fall back to `dotmd context` for runlist-scoped questions.');
|
|
89
92
|
lines.push('');
|
|
90
93
|
lines.push('**Saved prompts (`docs/prompts/*.md`):** if the user references a file under `docs/prompts/` — e.g. "resume via docs/prompts/foo.md", "use this prompt", "load that one" — consume it with `dotmd use <file>` (atomically prints the body and archives the prompt so it cannot be double-consumed). Do NOT `cat` it, read it with the file-reading tool, or copy its body into chat. To pick the oldest pending prompt without naming a file, run `dotmd use` with no arg.');
|
|
91
94
|
lines.push('');
|
package/src/commands.mjs
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
// that asserts every `dotmd <verb>` reference in generated slash-command
|
|
4
4
|
// templates points at a real command.
|
|
5
5
|
export const KNOWN_COMMANDS = [
|
|
6
|
-
'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'briefing', 'context', 'hud',
|
|
7
|
-
'focus', 'query', 'plans', 'prompts', 'stale', 'actionable', 'index', 'pickup', 'release', 'status', 'set', 'use', 'next', 'archive', 'bulk', 'bulk-tag', 'touch', 'doctor',
|
|
6
|
+
'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'briefing', 'context', 'agent-context', 'hud',
|
|
7
|
+
'focus', 'query', 'plans', 'prompts', 'stale', 'actionable', 'index', 'pickup', 'release', 'finish', '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',
|
|
11
|
-
'ship',
|
|
11
|
+
'ship', 'self-check',
|
|
12
12
|
];
|
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', 'pickup', 'unpickup', 'status', 'set', 'archive', 'bulk', 'touch', 'doctor', 'lint', 'rename', 'migrate',
|
|
5
|
+
'plans', 'stale', 'actionable', 'index', 'pickup', 'unpickup', 'release', 'finish', '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
|
|
|
@@ -34,8 +34,9 @@ const COMMAND_FLAGS = {
|
|
|
34
34
|
actionable: ['--json', '--sort', '--limit', '--all'],
|
|
35
35
|
briefing: ['--json'],
|
|
36
36
|
pickup: ['--json', '--takeover'],
|
|
37
|
-
unpickup: ['--json', '--all', '--stale', '--to', '--force'],
|
|
38
|
-
|
|
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'],
|
|
39
40
|
status: [],
|
|
40
41
|
archive: [],
|
|
41
42
|
doctor: [],
|
package/src/doctor.mjs
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
1
3
|
import { fixBrokenRefs } from './fix-refs.mjs';
|
|
2
4
|
import { runLint } from './lint.mjs';
|
|
3
5
|
import { runTouch } from './lifecycle.mjs';
|
|
4
|
-
import { buildIndex } from './index.mjs';
|
|
6
|
+
import { buildIndex, collectDocFiles } from './index.mjs';
|
|
5
7
|
import { renderIndexFile, writeIndex } from './index-file.mjs';
|
|
6
|
-
import { renderCheck } from './render.mjs';
|
|
8
|
+
import { renderCheck, renderManualFixes } from './render.mjs';
|
|
7
9
|
import { bold, dim, green, yellow } from './color.mjs';
|
|
8
|
-
import { scaffoldClaudeCommands } from './claude-commands.mjs';
|
|
10
|
+
import { checkClaudeCommands, scaffoldClaudeCommands } from './claude-commands.mjs';
|
|
9
11
|
import { runMigrateTemplate } from './migrate-template.mjs';
|
|
10
12
|
import { runMigratePrompts } from './migrate-prompts.mjs';
|
|
11
13
|
import { runFrontmatterFix } from './frontmatter-fix.mjs';
|
|
14
|
+
import { toRepoPath } from './util.mjs';
|
|
12
15
|
|
|
13
16
|
// Tunable thresholds for `dotmd doctor --statuses` conflation detection.
|
|
14
17
|
// MIN_BUCKET_SIZE: only flag buckets with at least this many docs (small buckets aren't worth nagging).
|
|
@@ -40,6 +43,10 @@ const CUE_LABELS = {
|
|
|
40
43
|
};
|
|
41
44
|
|
|
42
45
|
export function runDoctor(argv, config, opts = {}) {
|
|
46
|
+
if (argv.includes('--project')) {
|
|
47
|
+
runDoctorProject(config, { json: argv.includes('--json') });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
43
50
|
if (argv.includes('--statuses')) {
|
|
44
51
|
runDoctorStatuses(config, { json: argv.includes('--json') });
|
|
45
52
|
return;
|
|
@@ -118,6 +125,67 @@ export function runDoctor(argv, config, opts = {}) {
|
|
|
118
125
|
process.stdout.write('\n' + bold('6. Remaining issues:') + '\n');
|
|
119
126
|
const freshIndex = buildIndex(config);
|
|
120
127
|
process.stdout.write(renderCheck(freshIndex, config));
|
|
128
|
+
const manual = renderManualFixes(freshIndex);
|
|
129
|
+
if (manual.trim()) {
|
|
130
|
+
process.stdout.write('\n' + bold('Closeout guidance') + '\n');
|
|
131
|
+
process.stdout.write(manual);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function readJsonIfPresent(filePath) {
|
|
136
|
+
try {
|
|
137
|
+
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function findDeprecatedCommandMentions(config) {
|
|
144
|
+
const docs = collectDocFiles(config);
|
|
145
|
+
const matches = [];
|
|
146
|
+
for (const filePath of docs) {
|
|
147
|
+
let raw = '';
|
|
148
|
+
try { raw = readFileSync(filePath, 'utf8'); } catch { continue; }
|
|
149
|
+
if (/\bdotmd status\b/.test(raw) || /\bdotmd pickup\b/.test(raw)) {
|
|
150
|
+
matches.push(toRepoPath(filePath, config.repoRoot));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return matches;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function runDoctorProject(config, { json = false } = {}) {
|
|
157
|
+
const cliPackage = readJsonIfPresent(new URL('../package.json', import.meta.url));
|
|
158
|
+
const repoPackage = readJsonIfPresent(path.join(config.repoRoot, 'package.json'));
|
|
159
|
+
const depVersion = repoPackage?.dependencies?.['dotmd-cli']
|
|
160
|
+
?? repoPackage?.devDependencies?.['dotmd-cli']
|
|
161
|
+
?? repoPackage?.dependencies?.dotmd
|
|
162
|
+
?? repoPackage?.devDependencies?.dotmd
|
|
163
|
+
?? null;
|
|
164
|
+
const claudeCommandWarnings = checkClaudeCommands(config.repoRoot);
|
|
165
|
+
const deprecatedCommandMentions = findDeprecatedCommandMentions(config);
|
|
166
|
+
const result = {
|
|
167
|
+
cliVersion: cliPackage?.version ?? null,
|
|
168
|
+
packageDependency: depVersion,
|
|
169
|
+
claudeCommandWarnings,
|
|
170
|
+
deprecatedCommandMentions,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
if (json) {
|
|
174
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
175
|
+
return result;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
process.stdout.write(bold('dotmd doctor --project') + '\n\n');
|
|
179
|
+
process.stdout.write(`- running CLI version: ${result.cliVersion ?? 'unknown'}\n`);
|
|
180
|
+
process.stdout.write(`- package dependency: ${result.packageDependency ?? '(none found)'}\n`);
|
|
181
|
+
process.stdout.write(`- stale Claude commands: ${claudeCommandWarnings.length}\n`);
|
|
182
|
+
if (deprecatedCommandMentions.length) {
|
|
183
|
+
process.stdout.write(`- docs mentioning deprecated commands: ${deprecatedCommandMentions.length}\n`);
|
|
184
|
+
for (const file of deprecatedCommandMentions.slice(0, 10)) process.stdout.write(` - ${file}\n`);
|
|
185
|
+
} else {
|
|
186
|
+
process.stdout.write('- docs mentioning deprecated commands: 0\n');
|
|
187
|
+
}
|
|
188
|
+
return result;
|
|
121
189
|
}
|
|
122
190
|
|
|
123
191
|
export function analyzeStatusBuckets(docs) {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { suggestCandidates } from './util.mjs';
|
|
4
|
+
|
|
5
|
+
function sectionHeadingRegex(sectionHeading) {
|
|
6
|
+
return new RegExp(`^##\\s+${sectionHeading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'm');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function checkGlossaryConfig(config) {
|
|
10
|
+
const glossaryConfig = config.raw?.glossary;
|
|
11
|
+
if (!glossaryConfig?.path) return [];
|
|
12
|
+
|
|
13
|
+
const filePath = path.resolve(config.repoRoot, glossaryConfig.path);
|
|
14
|
+
if (!existsSync(filePath)) {
|
|
15
|
+
return [{
|
|
16
|
+
path: glossaryConfig.path,
|
|
17
|
+
level: 'warning',
|
|
18
|
+
message: `Glossary file configured at \`${glossaryConfig.path}\` but the file does not exist.`,
|
|
19
|
+
}];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const content = readFileSync(filePath, 'utf8');
|
|
23
|
+
const section = glossaryConfig.section ?? 'Terminology';
|
|
24
|
+
if (sectionHeadingRegex(section).test(content)) return [];
|
|
25
|
+
|
|
26
|
+
const headings = [...content.matchAll(/^##\s+(.+?)\s*$/gm)].map(m => m[1].trim());
|
|
27
|
+
const suggestions = suggestCandidates(section, headings, 3);
|
|
28
|
+
const nearby = suggestions.length ? suggestions : headings.slice(0, 3);
|
|
29
|
+
const hint = nearby.length ? ` Nearby headings: ${nearby.map(s => `\`${s}\``).join(', ')}.` : '';
|
|
30
|
+
return [{
|
|
31
|
+
path: glossaryConfig.path,
|
|
32
|
+
level: 'warning',
|
|
33
|
+
message: `Glossary config points at section \`## ${section}\`, but that heading is missing in \`${glossaryConfig.path}\`.${hint} Update \`glossary.path\` / \`glossary.section\` or restore the heading.`,
|
|
34
|
+
}];
|
|
35
|
+
}
|
package/src/glossary.mjs
CHANGED
|
@@ -4,8 +4,12 @@ import { buildIndex } from './index.mjs';
|
|
|
4
4
|
import { die, warn, suggestCandidates } from './util.mjs';
|
|
5
5
|
import { bold, dim, green, yellow } from './color.mjs';
|
|
6
6
|
|
|
7
|
+
function sectionHeadingRegex(sectionHeading) {
|
|
8
|
+
return new RegExp(`^##\\s+${sectionHeading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'm');
|
|
9
|
+
}
|
|
10
|
+
|
|
7
11
|
function parseGlossaryTable(content, sectionHeading) {
|
|
8
|
-
const headingRegex =
|
|
12
|
+
const headingRegex = sectionHeadingRegex(sectionHeading);
|
|
9
13
|
const match = content.match(headingRegex);
|
|
10
14
|
if (!match) return { found: false, entries: [] };
|
|
11
15
|
|
package/src/hud.mjs
CHANGED
|
@@ -205,7 +205,11 @@ export function buildHud(config) {
|
|
|
205
205
|
// still run, so the error count matches `dotmd check`'s.
|
|
206
206
|
let errors = 0;
|
|
207
207
|
try {
|
|
208
|
-
|
|
208
|
+
// `autoHealIndex: true` mirrors `dotmd check` — drift from non-regen
|
|
209
|
+
// mutation paths (`lint --fix`, direct file edits, etc.) heals silently
|
|
210
|
+
// at SessionStart so the agent doesn't open every session with a
|
|
211
|
+
// spurious "Run `dotmd index`" error in the hud error count.
|
|
212
|
+
const index = buildIndex(config, { errorsOnly: true, autoHealIndex: true });
|
|
209
213
|
errors = index.errors.length;
|
|
210
214
|
} catch { /* swallow — bad config shouldn't break the SessionStart hook */ }
|
|
211
215
|
|
|
@@ -242,6 +246,13 @@ export function runHud(argv, config) {
|
|
|
242
246
|
// one line, the minimum verb set.
|
|
243
247
|
lines.push(dim('dotmd: plans|briefing set <status> [<file>] new <type> <slug> use [<file>] archive <file> (use [no-arg] → oldest pending prompt)'));
|
|
244
248
|
|
|
249
|
+
const state = [];
|
|
250
|
+
if (hud.owned?.length) state.push(`held: ${hud.owned.length} (${previewList(hud.owned)})`);
|
|
251
|
+
if (hud.prompts?.length) state.push(`prompts: ${hud.prompts.length} (${previewList(hud.prompts)})`);
|
|
252
|
+
if (hud.stale?.length) state.push(`stuck: ${hud.stale.length} (${previewList(hud.stale)})`);
|
|
253
|
+
if (hud.errors > 0) state.push(`errors: ${hud.errors} (run dotmd check)`);
|
|
254
|
+
if (state.length) lines.push(yellow(state.join(' · ')));
|
|
255
|
+
|
|
245
256
|
if (refreshed.length > 0) {
|
|
246
257
|
const from = refreshed[0].from;
|
|
247
258
|
const to = refreshed[0].to;
|
package/src/index-file.mjs
CHANGED
|
@@ -80,7 +80,19 @@ export function writeIndex(content, config) {
|
|
|
80
80
|
writeFileSync(config.indexPath, content, 'utf8');
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
// `autoHeal: true` rewrites the index in place when drift is detected and
|
|
84
|
+
// downgrades the result to a warning ("Auto-regenerated stale index block").
|
|
85
|
+
// Drift happens when frontmatter changes (status/title/current_state/module)
|
|
86
|
+
// arrive via paths that don't call `regenIndex` — direct file edits, `lint
|
|
87
|
+
// --fix`, `frontmatter-fix`, `bulk-tag`, etc. The README block is fully
|
|
88
|
+
// generated content; treating drift as an error forced the user to run
|
|
89
|
+
// `dotmd index` themselves every session. Callers inside `buildIndex` pass
|
|
90
|
+
// `autoHeal: true` because the docs there are always the canonical full set
|
|
91
|
+
// (filtering happens later in the CLI dispatcher). Direct callers with a
|
|
92
|
+
// filtered/synthetic docs list omit it to keep the old error semantics —
|
|
93
|
+
// auto-overwriting from a partial doc list would clobber valid content.
|
|
94
|
+
export function checkIndex(docs, config, opts = {}) {
|
|
95
|
+
const { autoHeal = false } = opts;
|
|
84
96
|
const warnings = [];
|
|
85
97
|
const errors = [];
|
|
86
98
|
|
|
@@ -98,7 +110,16 @@ export function checkIndex(docs, config) {
|
|
|
98
110
|
const index = { docs };
|
|
99
111
|
const expected = renderIndexFile(index, config);
|
|
100
112
|
if (expected !== current) {
|
|
101
|
-
|
|
113
|
+
if (autoHeal) {
|
|
114
|
+
try {
|
|
115
|
+
writeFileSync(config.indexPath, expected, 'utf8');
|
|
116
|
+
warnings.push({ path: config.indexPath, level: 'warning', message: 'Auto-regenerated stale index block.' });
|
|
117
|
+
} catch (err) {
|
|
118
|
+
errors.push({ path: config.indexPath, level: 'error', message: `Could not auto-regenerate stale index block: ${err.message}` });
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
errors.push({ path: config.indexPath, level: 'error', message: 'Generated index block is stale. Run `dotmd index`.' });
|
|
122
|
+
}
|
|
102
123
|
}
|
|
103
124
|
|
|
104
125
|
return { warnings, errors };
|
package/src/index.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { asString, normalizeStringList, normalizeBlockers, mergeUniqueStrings, t
|
|
|
6
6
|
import { validateDoc, validatePlanShape, validateDocShape, checkBidirectionalReferences, checkGitStaleness, checkRunlistBackPointers, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate, enrichRefErrorSuggestions } from './validate.mjs';
|
|
7
7
|
import { checkIndex } from './index-file.mjs';
|
|
8
8
|
import { checkClaudeCommands } from './claude-commands.mjs';
|
|
9
|
+
import { checkGlossaryConfig } from './glossary-check.mjs';
|
|
9
10
|
|
|
10
11
|
// `fast: true` skips every pass that produces warnings/errors — the rendered
|
|
11
12
|
// index file consumes only status/title/snapshot/etc., not the validation
|
|
@@ -21,7 +22,7 @@ import { checkClaudeCommands } from './claude-commands.mjs';
|
|
|
21
22
|
// error COUNT, so the warning-only passes are pure overhead there. Preserves
|
|
22
23
|
// the invariant that hud's "✗ N validation errors" line matches `dotmd check`.
|
|
23
24
|
export function buildIndex(config, opts = {}) {
|
|
24
|
-
const { fast = false, errorsOnly = false } = opts;
|
|
25
|
+
const { fast = false, errorsOnly = false, autoHealIndex = false } = opts;
|
|
25
26
|
const skipWarningOnlyChecks = fast || errorsOnly;
|
|
26
27
|
const docs = collectDocFiles(config).map(f => parseDocFile(f, config, { fast }));
|
|
27
28
|
if (!fast) {
|
|
@@ -95,7 +96,15 @@ export function buildIndex(config, opts = {}) {
|
|
|
95
96
|
}
|
|
96
97
|
|
|
97
98
|
if (!fast && config.indexPath) {
|
|
98
|
-
|
|
99
|
+
// `autoHealIndex` is opt-in from the caller (currently `dotmd check` and
|
|
100
|
+
// `dotmd hud`). When true, drift triggers an in-place rewrite and a
|
|
101
|
+
// warning instead of the old "Run `dotmd index`" error — closing the
|
|
102
|
+
// class of nags produced by mutation paths that skip `regenIndex`
|
|
103
|
+
// (`lint --fix`, direct file edits, etc). `transformedDocs` here is
|
|
104
|
+
// always the canonical full set; CLI-level `--root`/`--type` filtering
|
|
105
|
+
// runs after `buildIndex` returns, so a rewrite is safe. Off by default
|
|
106
|
+
// so dry-run / print modes never mutate disk as a side effect.
|
|
107
|
+
const indexCheck = checkIndex(transformedDocs, config, { autoHeal: autoHealIndex });
|
|
99
108
|
warnings.push(...indexCheck.warnings);
|
|
100
109
|
errors.push(...indexCheck.errors);
|
|
101
110
|
}
|
|
@@ -116,6 +125,9 @@ export function buildIndex(config, opts = {}) {
|
|
|
116
125
|
|
|
117
126
|
const claudeWarnings = checkClaudeCommands(config.repoRoot);
|
|
118
127
|
warnings.push(...claudeWarnings);
|
|
128
|
+
|
|
129
|
+
const glossaryWarnings = checkGlossaryConfig(config);
|
|
130
|
+
warnings.push(...glossaryWarnings);
|
|
119
131
|
}
|
|
120
132
|
|
|
121
133
|
return {
|
package/src/journal.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, appendFileSync, statSync, renameSync, readFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
3
4
|
import { currentSessionId } from './lease.mjs';
|
|
4
5
|
|
|
5
6
|
const JOURNAL_DIR = '.dotmd';
|
|
@@ -8,6 +9,9 @@ const JOURNAL_BACKUP = 'journal.jsonl.1';
|
|
|
8
9
|
const ROTATE_SIZE_BYTES = 5 * 1024 * 1024;
|
|
9
10
|
const ROTATE_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
10
11
|
|
|
12
|
+
const ERROR_LOG_FILE = 'dotmd-errors.log';
|
|
13
|
+
const ERROR_LOG_BACKUP = 'dotmd-errors.log.1';
|
|
14
|
+
|
|
11
15
|
export function isJournalEnabled(config) {
|
|
12
16
|
if (process.env.DOTMD_JOURNAL === '1') return true;
|
|
13
17
|
if (process.env.DOTMD_JOURNAL === '0') return false;
|
|
@@ -22,12 +26,12 @@ export function journalBackupPath(config) {
|
|
|
22
26
|
return path.join(config.repoRoot, JOURNAL_DIR, JOURNAL_BACKUP);
|
|
23
27
|
}
|
|
24
28
|
|
|
25
|
-
function maybeRotate(file,
|
|
29
|
+
function maybeRotate(file, backup) {
|
|
26
30
|
if (!existsSync(file)) return;
|
|
27
31
|
let st;
|
|
28
32
|
try { st = statSync(file); } catch { return; }
|
|
29
33
|
if (st.size > ROTATE_SIZE_BYTES) {
|
|
30
|
-
try { renameSync(file,
|
|
34
|
+
try { renameSync(file, backup); } catch {}
|
|
31
35
|
return;
|
|
32
36
|
}
|
|
33
37
|
if (st.size === 0) return;
|
|
@@ -41,7 +45,7 @@ function maybeRotate(file, config) {
|
|
|
41
45
|
const obj = JSON.parse(first);
|
|
42
46
|
const t = new Date(obj.ts).getTime();
|
|
43
47
|
if (!Number.isNaN(t) && (Date.now() - t) > ROTATE_AGE_MS) {
|
|
44
|
-
try { renameSync(file,
|
|
48
|
+
try { renameSync(file, backup); } catch {}
|
|
45
49
|
}
|
|
46
50
|
} catch {}
|
|
47
51
|
}
|
|
@@ -53,7 +57,7 @@ export function appendJournalEntry(config, entry) {
|
|
|
53
57
|
const dir = path.join(config.repoRoot, JOURNAL_DIR);
|
|
54
58
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
55
59
|
const file = journalFilePath(config);
|
|
56
|
-
maybeRotate(file, config);
|
|
60
|
+
maybeRotate(file, journalBackupPath(config));
|
|
57
61
|
// O_APPEND is atomic for writes under PIPE_BUF (4KB on Linux, 512B on
|
|
58
62
|
// macOS). Entries are well under either threshold, so concurrent CLI
|
|
59
63
|
// invocations interleave cleanly without locking.
|
|
@@ -96,3 +100,52 @@ export function recordCliInvocation({ config, startMs, args, err, version }) {
|
|
|
96
100
|
}
|
|
97
101
|
appendJournalEntry(config, entry);
|
|
98
102
|
}
|
|
103
|
+
|
|
104
|
+
// Global error log: always-on, cross-repo, captured per failed invocation.
|
|
105
|
+
// Independent of `isJournalEnabled` so silent failures stop disappearing.
|
|
106
|
+
// DOTMD_ERROR_LOG_DIR overrides the default location (for tests, or for
|
|
107
|
+
// users who want the log somewhere other than ~/.claude/logs).
|
|
108
|
+
|
|
109
|
+
export function globalErrorLogDir() {
|
|
110
|
+
return process.env.DOTMD_ERROR_LOG_DIR || path.join(os.homedir(), '.claude', 'logs');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function globalErrorLogPath() {
|
|
114
|
+
return path.join(globalErrorLogDir(), ERROR_LOG_FILE);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function globalErrorLogBackupPath() {
|
|
118
|
+
return path.join(globalErrorLogDir(), ERROR_LOG_BACKUP);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function recordGlobalError({ config, startMs, args, err, version }) {
|
|
122
|
+
if (!err) return;
|
|
123
|
+
const flatMsg = String(err.message ?? err).replace(/\s+/g, ' ').trim();
|
|
124
|
+
const entry = {
|
|
125
|
+
ts: new Date().toISOString(),
|
|
126
|
+
repo: config?.repoRoot || process.cwd(),
|
|
127
|
+
sid: currentSessionId(),
|
|
128
|
+
pid: process.pid,
|
|
129
|
+
argv: args,
|
|
130
|
+
exit: process.exitCode ?? 1,
|
|
131
|
+
ms: typeof startMs === 'number' ? Date.now() - startMs : null,
|
|
132
|
+
v: version,
|
|
133
|
+
err: flatMsg.length > 500 ? flatMsg.slice(0, 497) + '...' : flatMsg,
|
|
134
|
+
};
|
|
135
|
+
if (err && err.name) entry.errName = err.name;
|
|
136
|
+
if (err && err.stack) {
|
|
137
|
+
// Keep the first few frames; stacks for DotmdError are short anyway and
|
|
138
|
+
// for unexpected exceptions five frames is usually enough to localize.
|
|
139
|
+
const stack = String(err.stack).split('\n').slice(0, 6).join('\n');
|
|
140
|
+
entry.stack = stack.length > 1000 ? stack.slice(0, 997) + '...' : stack;
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
const dir = globalErrorLogDir();
|
|
144
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
145
|
+
const file = globalErrorLogPath();
|
|
146
|
+
maybeRotate(file, globalErrorLogBackupPath());
|
|
147
|
+
appendFileSync(file, JSON.stringify(entry) + '\n', { flag: 'a' });
|
|
148
|
+
} catch {
|
|
149
|
+
// Logging must never break exit.
|
|
150
|
+
}
|
|
151
|
+
}
|
package/src/lease.mjs
CHANGED
|
@@ -100,18 +100,28 @@ export function isPidAlive(pid, host) {
|
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
export function isLeasePidDead(lease) {
|
|
104
|
+
if (!lease || lease.host !== os.hostname()) return false;
|
|
105
|
+
if (!Number.isInteger(lease.pid) || lease.pid <= 0) return false;
|
|
106
|
+
return !isPidAlive(lease.pid, lease.host);
|
|
107
|
+
}
|
|
108
|
+
|
|
103
109
|
export function isLeaseStale(lease) {
|
|
104
110
|
const t = new Date(lease.pickedUpAt).getTime();
|
|
105
111
|
if (Number.isNaN(t)) return true;
|
|
106
112
|
return Date.now() - t > STALE_LEASE_AGE_MS;
|
|
107
|
-
// Note: pid liveness is intentionally NOT used here. dotmd's own CLI pid is
|
|
108
|
-
// dead the moment the process exits, so it's not a useful signal for "is the
|
|
109
|
-
// session that wrote this lease still active." Use age and explicit takeover.
|
|
110
113
|
}
|
|
111
114
|
|
|
112
|
-
export function
|
|
115
|
+
export function isLeaseReclaimable(lease, opts = {}) {
|
|
116
|
+
if (isLeaseStale(lease)) return true;
|
|
117
|
+
const currentSession = opts.currentSession ?? currentSessionId();
|
|
118
|
+
if (lease?.session === currentSession) return false;
|
|
119
|
+
return isLeasePidDead(lease);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function findStaleLeases(config, opts = {}) {
|
|
113
123
|
const leases = readLeases(config);
|
|
114
|
-
return Object.values(leases).filter(
|
|
124
|
+
return Object.values(leases).filter(l => isLeaseReclaimable(l, opts));
|
|
115
125
|
}
|
|
116
126
|
|
|
117
127
|
export function acquireLease(config, repoPath, oldStatus, opts = {}) {
|
|
@@ -129,8 +139,7 @@ export function acquireLease(config, repoPath, oldStatus, opts = {}) {
|
|
|
129
139
|
}
|
|
130
140
|
|
|
131
141
|
if (existing && !opts.takeover) {
|
|
132
|
-
const
|
|
133
|
-
const stale = Number.isNaN(ageMs) || ageMs > STALE_LEASE_AGE_MS;
|
|
142
|
+
const stale = isLeaseReclaimable(existing, { currentSession: session });
|
|
134
143
|
return {
|
|
135
144
|
outcome: stale ? 'conflict-stale' : 'conflict-alive',
|
|
136
145
|
conflict: existing,
|
|
@@ -189,12 +198,13 @@ export function releaseAllForSession(config, sessionId, opts = {}) {
|
|
|
189
198
|
});
|
|
190
199
|
}
|
|
191
200
|
|
|
192
|
-
export function releaseStale(config) {
|
|
201
|
+
export function releaseStale(config, opts = {}) {
|
|
193
202
|
return withLeaseLock(config, () => {
|
|
194
203
|
const leases = readLeases(config);
|
|
195
204
|
const released = [];
|
|
205
|
+
const currentSession = opts.currentSession ?? currentSessionId();
|
|
196
206
|
for (const [key, lease] of Object.entries(leases)) {
|
|
197
|
-
if (
|
|
207
|
+
if (isLeaseReclaimable(lease, { currentSession })) {
|
|
198
208
|
released.push(lease);
|
|
199
209
|
delete leases[key];
|
|
200
210
|
}
|
package/src/lifecycle.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
readLeases,
|
|
16
16
|
currentSessionId,
|
|
17
17
|
migrateLease,
|
|
18
|
-
|
|
18
|
+
isLeaseReclaimable,
|
|
19
19
|
STALE_LEASE_AGE_HOURS,
|
|
20
20
|
} from './lease.mjs';
|
|
21
21
|
import { buildCard, renderCard } from './pickup-card.mjs';
|
|
@@ -292,7 +292,7 @@ export async function runPickup(argv, config, opts = {}) {
|
|
|
292
292
|
// Without this, a stale lease from a crashed prior session would still
|
|
293
293
|
// produce 'conflict-stale' and force the agent to pass --takeover even
|
|
294
294
|
// though we already know the holder is gone.
|
|
295
|
-
if (!dryRun) {
|
|
295
|
+
if (!dryRun && !takeover) {
|
|
296
296
|
try {
|
|
297
297
|
const { scrubStaleSilently } = await import('./lease-scrub.mjs');
|
|
298
298
|
scrubStaleSilently(config);
|
|
@@ -511,10 +511,7 @@ export async function runUnpickup(argv, config, opts = {}) {
|
|
|
511
511
|
if (targets === null) {
|
|
512
512
|
// --stale path
|
|
513
513
|
if (dryRun) {
|
|
514
|
-
const staleLeases = Object.values(leases).filter(l => {
|
|
515
|
-
const age = Date.now() - new Date(l.pickedUpAt).getTime();
|
|
516
|
-
return Number.isNaN(age) || age > STALE_LEASE_AGE_MS;
|
|
517
|
-
});
|
|
514
|
+
const staleLeases = Object.values(leases).filter(l => isLeaseReclaimable(l, { currentSession: session }));
|
|
518
515
|
for (const l of staleLeases) {
|
|
519
516
|
process.stderr.write(`${dim('[dry-run]')} Would release stale: ${l.path} (${l.session})\n`);
|
|
520
517
|
}
|
package/src/prompts.mjs
CHANGED
|
@@ -38,12 +38,19 @@ function runPromptsList(argv, config, opts = {}) {
|
|
|
38
38
|
const hasStatusFlag = argv.includes('--status');
|
|
39
39
|
const includeArchived = argv.includes('--include-archived');
|
|
40
40
|
const sub = argv[0];
|
|
41
|
+
const json = argv.includes('--json');
|
|
41
42
|
|
|
42
|
-
if (opts.verbose && !
|
|
43
|
+
if (opts.verbose && !json) {
|
|
43
44
|
renderPromptsVerbose(index, config, { hasStatusFlag, includeArchived });
|
|
44
45
|
return;
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
const hasPositionalFilter = argv.some(a => !a.startsWith('-') && a !== 'list');
|
|
49
|
+
if (!json && !hasStatusFlag && !includeArchived && !hasPositionalFilter && sub !== 'status' && !argv.some(a => a.startsWith('--sort') || a.startsWith('--limit') || a === '--all')) {
|
|
50
|
+
renderPromptQueueList(index, config);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
47
54
|
let defaults;
|
|
48
55
|
let extras = argv;
|
|
49
56
|
if (sub === 'status') {
|
|
@@ -57,6 +64,34 @@ function runPromptsList(argv, config, opts = {}) {
|
|
|
57
64
|
runQuery(index, [...defaults, ...extras], config, { preset: 'prompts' });
|
|
58
65
|
}
|
|
59
66
|
|
|
67
|
+
function renderPromptQueueList(index, config) {
|
|
68
|
+
const queue = pendingPromptsOldestFirst(config);
|
|
69
|
+
const queuedPaths = new Set(queue.map(q => q.doc.path));
|
|
70
|
+
const others = index.docs
|
|
71
|
+
.filter(d => d.type === 'prompt' && !queuedPaths.has(d.path) && !isArchivedPath(d.path, config) && d.status !== 'archived')
|
|
72
|
+
.sort((a, b) => (b.updated ?? '').localeCompare(a.updated ?? '') || (a.title ?? a.path).localeCompare(b.title ?? b.path));
|
|
73
|
+
const prompts = [...queue.map(q => q.doc), ...others];
|
|
74
|
+
|
|
75
|
+
if (prompts.length === 0) {
|
|
76
|
+
process.stdout.write('No prompts.\n');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const counts = {};
|
|
81
|
+
for (const p of prompts) counts[p.status ?? 'unknown'] = (counts[p.status ?? 'unknown'] ?? 0) + 1;
|
|
82
|
+
const summary = Object.entries(counts).map(([s, n]) => `${n} ${s}`).join(' · ');
|
|
83
|
+
process.stdout.write(dim(`${prompts.length} prompts · ${summary}`) + '\n\n');
|
|
84
|
+
|
|
85
|
+
const maxSlug = Math.min(36, Math.max(...prompts.map(p => path.basename(p.path, '.md').length)));
|
|
86
|
+
for (let i = 0; i < prompts.length; i++) {
|
|
87
|
+
const p = prompts[i];
|
|
88
|
+
const slug = path.basename(p.path, '.md').padEnd(maxSlug);
|
|
89
|
+
const marker = i === 0 && p.status === 'pending' ? green('[NEXT]') : ' ';
|
|
90
|
+
const status = `[${(p.status ?? 'unknown').toUpperCase()}]`;
|
|
91
|
+
process.stdout.write(` ${marker} ${slug} ${status}\n`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
60
95
|
// Resolve a prompt's "target plan" for `prompts list --verbose`. Order:
|
|
61
96
|
// 1. frontmatter `related_plans:` (first entry — assumed plan slug)
|
|
62
97
|
// 2. frontmatter `parent_plan:`
|
|
@@ -226,10 +261,17 @@ export function consumePrompt(filePath, config, opts) {
|
|
|
226
261
|
return;
|
|
227
262
|
}
|
|
228
263
|
|
|
264
|
+
// Archive BEFORE emitting the body. If runArchive throws (git mv failure,
|
|
265
|
+
// hook crash, anything), the body must not have already gone to stdout —
|
|
266
|
+
// otherwise `claude "$(dotmd prompts next)"` consumes the prompt without it
|
|
267
|
+
// ever being archived, and the next session sees the same prompt as pending.
|
|
268
|
+
// Body is already in memory from extractFrontmatter, so the source file
|
|
269
|
+
// can move out from under us safely.
|
|
270
|
+
runArchive([filePath], config, { noIndex, showFiles, out: process.stderr });
|
|
271
|
+
|
|
229
272
|
process.stdout.write(body);
|
|
230
273
|
if (!body.endsWith('\n')) process.stdout.write('\n');
|
|
231
274
|
|
|
232
|
-
runArchive([filePath], config, { noIndex, showFiles, out: process.stderr });
|
|
233
275
|
process.stderr.write(`${green('✓ Consumed')}: ${repoPath}\n`);
|
|
234
276
|
}
|
|
235
277
|
|
package/src/render.mjs
CHANGED
|
@@ -360,7 +360,7 @@ export function renderBriefing(index, config) {
|
|
|
360
360
|
try {
|
|
361
361
|
const staleLeases = findStaleLeases(config);
|
|
362
362
|
if (staleLeases.length > 0) {
|
|
363
|
-
lines.push(yellow(`Stuck in-session: ${staleLeases.length} (>
|
|
363
|
+
lines.push(yellow(`Stuck in-session: ${staleLeases.length} (>4h or dead same-host pid, run \`dotmd release --stale\`)`));
|
|
364
364
|
}
|
|
365
365
|
} catch {}
|
|
366
366
|
|
|
@@ -376,6 +376,80 @@ export function renderCheck(index, config, opts = {}) {
|
|
|
376
376
|
return defaultRenderer(index);
|
|
377
377
|
}
|
|
378
378
|
|
|
379
|
+
export function classifyIssueAction(issue) {
|
|
380
|
+
const message = issue?.message ?? '';
|
|
381
|
+
const file = issue?.path ?? '<file>';
|
|
382
|
+
|
|
383
|
+
if (/Missing frontmatter `status`/.test(message)) {
|
|
384
|
+
return { action: `dotmd bulk-tag ${file}`, fixable: false, label: 'missing status' };
|
|
385
|
+
}
|
|
386
|
+
if (/Missing frontmatter `updated`/.test(message) || /frontmatter `updated: .*` is behind git history/.test(message)) {
|
|
387
|
+
return { action: 'dotmd touch --git', fixable: true, label: 'dates' };
|
|
388
|
+
}
|
|
389
|
+
if (/`(?:module|surface):` \(singular\) is deprecated/.test(message) || /camelCase|nextStep|currentState|auditLevel/.test(message)) {
|
|
390
|
+
return { action: 'dotmd lint --fix', fixable: true, label: 'frontmatter migrations' };
|
|
391
|
+
}
|
|
392
|
+
if (/Unknown surface/.test(message)) {
|
|
393
|
+
return { action: 'dotmd surfaces', fixable: false, label: 'taxonomy' };
|
|
394
|
+
}
|
|
395
|
+
if (/Glossary config points at section|Glossary file configured/.test(message)) {
|
|
396
|
+
return { action: 'edit dotmd.config.mjs glossary.path/glossary.section', fixable: false, label: 'glossary config' };
|
|
397
|
+
}
|
|
398
|
+
if (/under `.*\/` but `status: .*` is not an archive status/.test(message)) {
|
|
399
|
+
return { action: message.match(/Run `([^`]+)`/)?.[1] ?? `dotmd set archived ${file}`, fixable: true, label: 'archive drift' };
|
|
400
|
+
}
|
|
401
|
+
if (/`status: .*` but file is a direct child/.test(message)) {
|
|
402
|
+
return { action: `dotmd archive ${file}`, fixable: true, label: 'archive drift' };
|
|
403
|
+
}
|
|
404
|
+
if (/`current_state` is \d+ chars|`next_step` is \d+ chars/.test(message)) {
|
|
405
|
+
return { action: 'dotmd doctor --frontmatter-fix', fixable: true, label: 'long frontmatter' };
|
|
406
|
+
}
|
|
407
|
+
if (/`modules` is required/.test(message)) {
|
|
408
|
+
return { action: `edit ${file} modules: or run dotmd lint --fix if scaffolded empty`, fixable: false, label: 'module metadata' };
|
|
409
|
+
}
|
|
410
|
+
if (/entry `.*` does not resolve|body link `.*` does not resolve/.test(message)) {
|
|
411
|
+
return { action: 'dotmd fix-refs --dry-run', fixable: true, label: 'references' };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return { action: `edit ${file}`, fixable: false, label: 'manual review' };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export function renderManualFixes(index) {
|
|
418
|
+
const issues = [...(index.errors ?? []), ...(index.warnings ?? [])];
|
|
419
|
+
const groups = new Map();
|
|
420
|
+
for (const issue of issues) {
|
|
421
|
+
const item = classifyIssueAction(issue);
|
|
422
|
+
const key = `${item.fixable ? 'fixable' : 'manual'}:${item.action}`;
|
|
423
|
+
if (!groups.has(key)) groups.set(key, { ...item, paths: new Set() });
|
|
424
|
+
groups.get(key).paths.add(issue.path);
|
|
425
|
+
}
|
|
426
|
+
if (groups.size === 0) return '';
|
|
427
|
+
|
|
428
|
+
const manual = [...groups.values()].filter(g => !g.fixable);
|
|
429
|
+
const fixable = [...groups.values()].filter(g => g.fixable);
|
|
430
|
+
const lines = [];
|
|
431
|
+
|
|
432
|
+
if (fixable.length > 0) {
|
|
433
|
+
lines.push('Fixable actions');
|
|
434
|
+
for (const group of fixable) {
|
|
435
|
+
const count = group.paths.size;
|
|
436
|
+
lines.push(`- ${group.action} (${count} ${count === 1 ? 'file' : 'files'}: ${[...group.paths].slice(0, 3).join(', ')}${count > 3 ? ', ...' : ''})`);
|
|
437
|
+
}
|
|
438
|
+
lines.push('');
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (manual.length > 0) {
|
|
442
|
+
lines.push('Manual fixes remaining');
|
|
443
|
+
for (const group of manual) {
|
|
444
|
+
const count = group.paths.size;
|
|
445
|
+
lines.push(`- ${group.action} (${count} ${count === 1 ? 'file' : 'files'}: ${[...group.paths].slice(0, 3).join(', ')}${count > 3 ? ', ...' : ''})`);
|
|
446
|
+
}
|
|
447
|
+
lines.push('');
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return lines.join('\n');
|
|
451
|
+
}
|
|
452
|
+
|
|
379
453
|
function _renderCheck(index, opts = {}) {
|
|
380
454
|
const { errorsOnly, noCollapse, verbose } = opts;
|
|
381
455
|
const lines = ['Check', ''];
|
|
@@ -390,6 +464,10 @@ function _renderCheck(index, opts = {}) {
|
|
|
390
464
|
lines.push(`- ${issue.path}: ${issue.message}`);
|
|
391
465
|
}
|
|
392
466
|
lines.push('');
|
|
467
|
+
const actions = renderManualFixes({ errors: index.errors, warnings: [] }).trimEnd();
|
|
468
|
+
if (actions) {
|
|
469
|
+
lines.push(actions);
|
|
470
|
+
}
|
|
393
471
|
}
|
|
394
472
|
|
|
395
473
|
// Warnings: terse by default — print count + pointer. The full per-doc list
|
|
@@ -415,7 +493,7 @@ function _renderCheck(index, opts = {}) {
|
|
|
415
493
|
}
|
|
416
494
|
lines.push('');
|
|
417
495
|
} else {
|
|
418
|
-
lines.push(dim(`Run \`dotmd check --verbose\` for per-doc detail
|
|
496
|
+
lines.push(dim(`Run \`dotmd check --verbose\` for per-doc detail. \`dotmd doctor\` auto-fixes supported issues; remaining issues need the suggested manual command.`));
|
|
419
497
|
lines.push('');
|
|
420
498
|
}
|
|
421
499
|
}
|
package/src/runlist.mjs
CHANGED
|
@@ -45,12 +45,7 @@ function resolveHubInput(input, config) {
|
|
|
45
45
|
// Read a hub plan's `runlist:` and resolve each entry to a repo-relative path
|
|
46
46
|
// plus its current status. Missing files are reported with `missing: true`;
|
|
47
47
|
// callers decide how to render them. Pure: no IO beyond file reads.
|
|
48
|
-
function
|
|
49
|
-
const raw = readFileSync(hubAbsPath, 'utf8');
|
|
50
|
-
const { frontmatter: fmRaw } = extractFrontmatter(raw);
|
|
51
|
-
const fm = parseSimpleFrontmatter(fmRaw);
|
|
52
|
-
const refs = normalizeStringList(fm.runlist);
|
|
53
|
-
|
|
48
|
+
function resolveRunlistRefs(refs, hubAbsPath, config) {
|
|
54
49
|
const hubDir = path.dirname(hubAbsPath);
|
|
55
50
|
const out = [];
|
|
56
51
|
for (const ref of refs) {
|
|
@@ -79,6 +74,45 @@ function readRunlistChildren(hubAbsPath, config) {
|
|
|
79
74
|
return out;
|
|
80
75
|
}
|
|
81
76
|
|
|
77
|
+
function detectBodyRunlistRefs(body) {
|
|
78
|
+
if (!body) return [];
|
|
79
|
+
const sectionRe = /^##\s+(Order of operations|Runlist|Execution order|Implementation order|Plan order)\s*$/gim;
|
|
80
|
+
const refs = [];
|
|
81
|
+
let match;
|
|
82
|
+
while ((match = sectionRe.exec(body)) !== null) {
|
|
83
|
+
const start = match.index + match[0].length;
|
|
84
|
+
const rest = body.slice(start);
|
|
85
|
+
const next = rest.search(/^##\s+/m);
|
|
86
|
+
const section = next >= 0 ? rest.slice(0, next) : rest;
|
|
87
|
+
|
|
88
|
+
const linkRe = /\[[^\]]+\]\(([^)]+\.md(?:#[^)]+)?)\)/g;
|
|
89
|
+
let link;
|
|
90
|
+
while ((link = linkRe.exec(section)) !== null) refs.push(link[1]);
|
|
91
|
+
|
|
92
|
+
const checklistRe = /^\s*[-*]\s+\[[ xX]\]\s+([^\s)]+\.md(?:#[^\s)]+)?)/gm;
|
|
93
|
+
let item;
|
|
94
|
+
while ((item = checklistRe.exec(section)) !== null) refs.push(item[1]);
|
|
95
|
+
}
|
|
96
|
+
return [...new Set(refs)];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function readRunlistChildren(hubAbsPath, config) {
|
|
100
|
+
const raw = readFileSync(hubAbsPath, 'utf8');
|
|
101
|
+
const { frontmatter: fmRaw, body } = extractFrontmatter(raw);
|
|
102
|
+
const fm = parseSimpleFrontmatter(fmRaw);
|
|
103
|
+
const refs = normalizeStringList(fm.runlist);
|
|
104
|
+
|
|
105
|
+
if (refs.length > 0) {
|
|
106
|
+
return { children: resolveRunlistRefs(refs, hubAbsPath, config), source: 'frontmatter' };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const bodyRefs = detectBodyRunlistRefs(body);
|
|
110
|
+
return {
|
|
111
|
+
children: resolveRunlistRefs(bodyRefs, hubAbsPath, config),
|
|
112
|
+
source: bodyRefs.length > 0 ? 'body' : 'empty',
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
82
116
|
const STATUS_TAG_COLORS = {
|
|
83
117
|
'in-session': (s) => bold(red(s)),
|
|
84
118
|
'active': green,
|
|
@@ -100,9 +134,12 @@ function renderRunlist(hubRepoPath, children, opts = {}) {
|
|
|
100
134
|
const lines = [];
|
|
101
135
|
lines.push(bold(`runlist: ${hubRepoPath}`));
|
|
102
136
|
if (children.length === 0) {
|
|
103
|
-
lines.push(dim(' (empty — add child plan paths to the hub plan\'s `runlist:` field)'));
|
|
137
|
+
lines.push(dim(' (empty — add child plan paths to the hub plan\'s `runlist:` field, or add markdown links under `## Order of operations`)'));
|
|
104
138
|
return lines.join('\n') + '\n';
|
|
105
139
|
}
|
|
140
|
+
if (opts.source === 'body') {
|
|
141
|
+
lines.push(dim(' (from body links — add these paths to frontmatter `runlist:` to make the order canonical)'));
|
|
142
|
+
}
|
|
106
143
|
|
|
107
144
|
const archiveStatuses = opts.archiveStatuses ?? new Set(['archived']);
|
|
108
145
|
let nextPicked = false;
|
|
@@ -144,18 +181,20 @@ export async function runRunlist(argv, config, opts = {}) {
|
|
|
144
181
|
if (!hubAbs) die(`Hub plan not found: ${hubInput}`);
|
|
145
182
|
const hubRepoPath = toRepoPath(hubAbs, config.repoRoot);
|
|
146
183
|
|
|
147
|
-
const
|
|
184
|
+
const runlist = readRunlistChildren(hubAbs, config);
|
|
185
|
+
const { children, source } = runlist;
|
|
148
186
|
const archiveStatuses = config.lifecycle?.archiveStatuses ?? new Set(['archived']);
|
|
149
187
|
|
|
150
188
|
if (sub === 'show') {
|
|
151
189
|
if (json) {
|
|
152
190
|
process.stdout.write(JSON.stringify({
|
|
153
191
|
hub: hubRepoPath,
|
|
192
|
+
source,
|
|
154
193
|
children,
|
|
155
194
|
}, null, 2) + '\n');
|
|
156
195
|
return;
|
|
157
196
|
}
|
|
158
|
-
process.stdout.write(renderRunlist(hubRepoPath, children, { archiveStatuses }));
|
|
197
|
+
process.stdout.write(renderRunlist(hubRepoPath, children, { archiveStatuses, source }));
|
|
159
198
|
return;
|
|
160
199
|
}
|
|
161
200
|
|