dotmd-cli 0.60.0 → 0.62.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 +102 -13
- package/bin/dotmd.mjs +109 -8
- package/dotmd.config.example.mjs +21 -4
- package/package.json +1 -1
- package/src/baton.mjs +231 -0
- package/src/commands.mjs +2 -2
- package/src/completions.mjs +1 -1
- package/src/doctor.mjs +38 -0
- package/src/guard.mjs +84 -32
- package/src/health.mjs +55 -9
- package/src/hud.mjs +40 -18
- package/src/index.mjs +8 -1
- package/src/lifecycle.mjs +4 -1
- package/src/new.mjs +7 -1
- package/src/prompts.mjs +25 -1
- package/src/query.mjs +305 -46
- package/src/render.mjs +22 -3
- package/src/runlist.mjs +118 -0
- package/src/validate.mjs +29 -2
package/src/render.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { extractFrontmatter } from './frontmatter.mjs';
|
|
|
5
5
|
import { summarizeDocBody } from './ai.mjs';
|
|
6
6
|
import { bold, red, yellow, green, dim } from './color.mjs';
|
|
7
7
|
import { categorizeWarnings } from './check-collapse.mjs';
|
|
8
|
+
import { buildCoordinationIndex } from './runlist.mjs';
|
|
8
9
|
|
|
9
10
|
// Render `currentState` with an `(auto)` prefix when the value was body-scraped
|
|
10
11
|
// rather than read from frontmatter. Lets a reader see at a glance which docs
|
|
@@ -322,6 +323,15 @@ export function renderBriefing(index, config) {
|
|
|
322
323
|
const research = index.docs.filter(d => d.type === 'research');
|
|
323
324
|
const untyped = index.docs.filter(d => !d.type);
|
|
324
325
|
|
|
326
|
+
// Coordination hubs (prose-first runlists) are navigation maps, not units of
|
|
327
|
+
// work — so lift them out of the live-plan status breakdown into their own
|
|
328
|
+
// `runlists` bucket and drop them from the actionable `>` list, mirroring
|
|
329
|
+
// `dotmd plans` / `dotmd runlists`. (Sprint `runlist:` hubs stay as ordinary
|
|
330
|
+
// plans here; their folding treatment is scoped to `dotmd plans`.) On a repo
|
|
331
|
+
// with no coordination hubs this is a no-op and the output is unchanged.
|
|
332
|
+
const coordination = buildCoordinationIndex(index, config);
|
|
333
|
+
const isHub = (p) => coordination.has(p.path);
|
|
334
|
+
|
|
325
335
|
if (plans.length) {
|
|
326
336
|
// Headline counts LIVE plans first — "30 plans: 25 archived, …" skims as
|
|
327
337
|
// 30 open work items when zero are. "Live" mirrors the `dotmd plans`
|
|
@@ -331,17 +341,26 @@ export function renderBriefing(index, config) {
|
|
|
331
341
|
...(config.lifecycle?.terminalStatuses ?? []),
|
|
332
342
|
]);
|
|
333
343
|
const live = plans.filter(p => !closed.has(p.status) && !isArchivedPath(p.path, config));
|
|
344
|
+
const liveHubs = live.filter(isHub);
|
|
334
345
|
const bySt = {};
|
|
335
|
-
for (const p of live) { bySt[p.status] = (bySt[p.status] ?? 0) + 1; }
|
|
336
|
-
|
|
346
|
+
for (const p of live) { if (isHub(p)) continue; bySt[p.status] = (bySt[p.status] ?? 0) + 1; }
|
|
347
|
+
// Runlists lead the breakdown (then leaf statuses), and the bucket sums back
|
|
348
|
+
// to the live total so the headline stays honest.
|
|
349
|
+
const countParts = [];
|
|
350
|
+
if (liveHubs.length) countParts.push(`${liveHubs.length} runlist${liveHubs.length === 1 ? '' : 's'}`);
|
|
351
|
+
countParts.push(...Object.entries(bySt).map(([s, n]) => `${n} ${s}`));
|
|
352
|
+
const counts = countParts.join(', ');
|
|
337
353
|
const closedCount = plans.length - live.length;
|
|
338
354
|
const closedPart = closedCount ? ` (${closedCount} archived)` : '';
|
|
339
355
|
lines.push(live.length ? `${live.length} live plans${closedPart}: ${counts}` : `0 live plans${closedPart}`);
|
|
340
|
-
const show = plans.filter(p => p.status === 'in-session' || p.status === 'active');
|
|
356
|
+
const show = plans.filter(p => (p.status === 'in-session' || p.status === 'active') && !isHub(p));
|
|
341
357
|
for (const p of show) {
|
|
342
358
|
const next = p.nextStep ? `next: ${p.nextStep}` : '(no next step)';
|
|
343
359
|
lines.push(` > ${path.basename(p.path, '.md')} (${p.status}) ${next}`);
|
|
344
360
|
}
|
|
361
|
+
if (liveHubs.length) {
|
|
362
|
+
lines.push(` ${liveHubs.length} runlist${liveHubs.length === 1 ? '' : 's'} ${dim('· dotmd runlists')}`);
|
|
363
|
+
}
|
|
345
364
|
}
|
|
346
365
|
|
|
347
366
|
const parts = [];
|
package/src/runlist.mjs
CHANGED
|
@@ -4,15 +4,133 @@ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
|
4
4
|
import {
|
|
5
5
|
asString,
|
|
6
6
|
die,
|
|
7
|
+
isArchivedPath,
|
|
7
8
|
normalizeStringList,
|
|
8
9
|
resolveRefPath,
|
|
9
10
|
toRepoPath,
|
|
11
|
+
toSlug,
|
|
10
12
|
} from './util.mjs';
|
|
11
13
|
import { resolveDocArg } from './index.mjs';
|
|
12
14
|
import { bold, cyan, dim, green, red, yellow } from './color.mjs';
|
|
13
15
|
|
|
14
16
|
const PICKUPABLE_STATUSES = new Set(['active', 'planned', 'in-session']);
|
|
15
17
|
|
|
18
|
+
// Build a hub/child map straight from the in-memory index — no disk IO. A doc
|
|
19
|
+
// is a runlist *hub* when its `runlist:` frontmatter (`refFields.runlist`) is
|
|
20
|
+
// non-empty. Each ref resolves to a doc in the index by path, falling back to
|
|
21
|
+
// basename so children that were archived (and physically moved into an
|
|
22
|
+
// archive dir) still resolve. Used by the `dotmd plans` triage view to fold
|
|
23
|
+
// children under their hub and tag hubs as runlists rather than plain plans.
|
|
24
|
+
//
|
|
25
|
+
// Returns:
|
|
26
|
+
// hubs: Map<hubPath, { hub, total, doneCount, children, nextChildPath }>
|
|
27
|
+
// children: [{ ref, doc, path, status, archived, missing }] in runlist order
|
|
28
|
+
// childToHub: Map<childPath, hubPath> (first hub wins on the rare double-claim)
|
|
29
|
+
export function buildRunlistIndex(index, config) {
|
|
30
|
+
const archiveStatuses = config.lifecycle?.archiveStatuses ?? new Set(['archived']);
|
|
31
|
+
const docByPath = new Map(index.docs.map(d => [d.path, d]));
|
|
32
|
+
const byBasename = new Map();
|
|
33
|
+
for (const d of index.docs) {
|
|
34
|
+
const base = d.path.split('/').pop();
|
|
35
|
+
if (!byBasename.has(base)) byBasename.set(base, d);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const hubs = new Map();
|
|
39
|
+
const childToHub = new Map();
|
|
40
|
+
for (const hub of index.docs) {
|
|
41
|
+
const refs = hub.refFields?.runlist ?? [];
|
|
42
|
+
if (refs.length === 0) continue;
|
|
43
|
+
const hubDir = path.dirname(path.join(config.repoRoot, hub.path));
|
|
44
|
+
|
|
45
|
+
const children = [];
|
|
46
|
+
for (const ref of refs) {
|
|
47
|
+
const abs = resolveRefPath(ref, hubDir, config.repoRoot);
|
|
48
|
+
let childDoc = abs ? docByPath.get(toRepoPath(abs, config.repoRoot)) ?? null : null;
|
|
49
|
+
if (!childDoc) childDoc = byBasename.get(ref.split('/').pop()) ?? null;
|
|
50
|
+
if (childDoc) {
|
|
51
|
+
const archived = archiveStatuses.has(childDoc.status) || isArchivedPath(childDoc.path, config);
|
|
52
|
+
children.push({ ref, doc: childDoc, path: childDoc.path, status: childDoc.status, archived, missing: false });
|
|
53
|
+
if (!childToHub.has(childDoc.path)) childToHub.set(childDoc.path, hub.path);
|
|
54
|
+
} else {
|
|
55
|
+
children.push({ ref, doc: null, path: null, status: null, archived: false, missing: true });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const next = children.find(c => !c.missing && !c.archived) ?? null;
|
|
60
|
+
hubs.set(hub.path, {
|
|
61
|
+
hub,
|
|
62
|
+
total: children.length,
|
|
63
|
+
doneCount: children.filter(c => c.archived).length,
|
|
64
|
+
children,
|
|
65
|
+
nextChildPath: next?.path ?? null,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { hubs, childToHub };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// A *coordination hub* is a prose-first plan that sits above a cluster of other
|
|
73
|
+
// plans — a "runlist" in the platform sense (master-runlist, ai-runlist, …)
|
|
74
|
+
// rather than a strictly-ordered frontmatter `runlist:` sprint. The signal is
|
|
75
|
+
// already in frontmatter (`execution_mode: coordination`), with the
|
|
76
|
+
// `*-runlist` / `runlist` naming convention as a fallback for the few hubs that
|
|
77
|
+
// predate the field. These plans aren't units of executable work — they're
|
|
78
|
+
// navigation maps — so the triage view tags them and lifts them out of the
|
|
79
|
+
// leaf-plan flow rather than treating them as one more active plan.
|
|
80
|
+
export function isCoordinationHub(doc) {
|
|
81
|
+
if (!doc) return false;
|
|
82
|
+
if (doc.type && doc.type !== 'plan') return false;
|
|
83
|
+
if (doc.executionMode === 'coordination') return true;
|
|
84
|
+
const base = (doc.path.split('/').pop() || '').replace(/\.md$/, '');
|
|
85
|
+
return base === 'runlist' || base.endsWith('-runlist');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Map each coordination hub to a `childCount` derived from its `related_plans:`
|
|
89
|
+
// cluster (resolved against the index; peers/self excluded). It's an
|
|
90
|
+
// approximation — `related_plans` is a *related* cluster, not a strict child
|
|
91
|
+
// list — so it's shown as a rough "N plans" hint, not an authoritative count.
|
|
92
|
+
export function buildCoordinationIndex(index, config) {
|
|
93
|
+
const docByPath = new Map(index.docs.map(d => [d.path, d]));
|
|
94
|
+
const byBasename = new Map();
|
|
95
|
+
for (const d of index.docs) {
|
|
96
|
+
const base = d.path.split('/').pop();
|
|
97
|
+
if (!byBasename.has(base)) byBasename.set(base, d);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const hubs = new Map();
|
|
101
|
+
for (const doc of index.docs) {
|
|
102
|
+
if (!isCoordinationHub(doc)) continue;
|
|
103
|
+
const dir = path.dirname(path.join(config.repoRoot, doc.path));
|
|
104
|
+
const refs = doc.refFields?.related_plans ?? [];
|
|
105
|
+
const childPaths = new Set();
|
|
106
|
+
for (const ref of refs) {
|
|
107
|
+
const abs = resolveRefPath(ref, dir, config.repoRoot);
|
|
108
|
+
let child = abs ? docByPath.get(toRepoPath(abs, config.repoRoot)) ?? null : null;
|
|
109
|
+
if (!child) child = byBasename.get(ref.split('/').pop()) ?? null;
|
|
110
|
+
if (child && child.path !== doc.path && (child.type === 'plan' || child.type == null)) {
|
|
111
|
+
childPaths.add(child.path);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
hubs.set(doc.path, { doc, childCount: childPaths.size, childPaths });
|
|
115
|
+
}
|
|
116
|
+
return hubs;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Conventional container dirs whose name adds no disambiguation to a hub label.
|
|
120
|
+
const HUB_CONTAINER_DIRS = new Set(['plans', 'prompts', 'archive', 'archived']);
|
|
121
|
+
|
|
122
|
+
// Display label for a hub. A bare basename loses context for hubs that live in
|
|
123
|
+
// a subdirectory (e.g. `docs/plans/pos/runlist.md` would read as just
|
|
124
|
+
// `runlist`), so prefix the immediate parent dir unless it's a conventional
|
|
125
|
+
// container. → `pos/runlist`, but `billing-runlist` stays as-is. Shared by the
|
|
126
|
+
// `dotmd plans` Runlists section, `dotmd runlists`, and `dotmd health` so a hub
|
|
127
|
+
// reads the same everywhere.
|
|
128
|
+
export function hubLabel(doc) {
|
|
129
|
+
const slug = toSlug(doc);
|
|
130
|
+
const parent = path.basename(path.dirname(doc.path));
|
|
131
|
+
return HUB_CONTAINER_DIRS.has(parent) ? slug : `${parent}/${slug}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
16
134
|
// Bare hub slugs resolve through the shared resolver; the caller keeps its
|
|
17
135
|
// runlist-specific miss message, so no die-on-miss here.
|
|
18
136
|
function resolveHubInput(input, config) {
|
package/src/validate.mjs
CHANGED
|
@@ -433,6 +433,33 @@ export function checkRunlistBackPointers(docs, config) {
|
|
|
433
433
|
return warnings;
|
|
434
434
|
}
|
|
435
435
|
|
|
436
|
+
// Coordination-hub hygiene: a plan whose slug is `*-runlist` / `runlist` reads
|
|
437
|
+
// as a coordination runlist, but `dotmd plans` only *reliably* lifts it into the
|
|
438
|
+
// Runlists section (and out of the active count) when `execution_mode:
|
|
439
|
+
// coordination` is set. Slug detection is the fallback; the frontmatter field is
|
|
440
|
+
// the canonical signal. Nudge the few hubs that lean on the slug alone to make
|
|
441
|
+
// it explicit. Skips terminal/quiet statuses like every other warning-only check.
|
|
442
|
+
export function checkCoordinationHubExecutionMode(docs, config) {
|
|
443
|
+
const warnings = [];
|
|
444
|
+
const skipStatuses = new Set([
|
|
445
|
+
...(config.lifecycle.terminalStatuses ?? []),
|
|
446
|
+
...(config.lifecycle.skipWarningsFor ?? []),
|
|
447
|
+
]);
|
|
448
|
+
for (const doc of docs) {
|
|
449
|
+
if (doc.type && doc.type !== 'plan') continue;
|
|
450
|
+
if (skipStatuses.has(doc.status)) continue;
|
|
451
|
+
if (doc.executionMode === 'coordination') continue;
|
|
452
|
+
const base = (doc.path.split('/').pop() || '').replace(/\.md$/, '');
|
|
453
|
+
if (base !== 'runlist' && !base.endsWith('-runlist')) continue;
|
|
454
|
+
warnings.push({
|
|
455
|
+
path: doc.path,
|
|
456
|
+
level: 'warning',
|
|
457
|
+
message: `reads as a coordination runlist (slug \`${base}\`) but is missing \`execution_mode: coordination\`. Add it so \`dotmd plans\` / \`dotmd runlists\` reliably treat it as a runlist, not an active plan.`,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
return warnings;
|
|
461
|
+
}
|
|
462
|
+
|
|
436
463
|
export function checkGitStaleness(docs, config) {
|
|
437
464
|
const warnings = [];
|
|
438
465
|
const gitDates = getGitLastModifiedBatch(config.repoRoot);
|
|
@@ -475,7 +502,7 @@ export function validatePlanShape(doc, body, frontmatter, config) {
|
|
|
475
502
|
doc.warnings.push({
|
|
476
503
|
path: doc.path,
|
|
477
504
|
level: 'warning',
|
|
478
|
-
message: `\`next_step\` is ${nextStep.length} chars (cap: 800).
|
|
505
|
+
message: `\`next_step\` is ${nextStep.length} chars (cap: 800). One mechanical fix: \`dotmd doctor --frontmatter-fix\` (moves the overflow into the body) — do NOT hand-trim or re-run check in a loop. Going forward, write next_step as a 1-2 sentence pointer; detail goes in the body.`,
|
|
479
506
|
});
|
|
480
507
|
}
|
|
481
508
|
|
|
@@ -488,7 +515,7 @@ export function validatePlanShape(doc, body, frontmatter, config) {
|
|
|
488
515
|
doc.warnings.push({
|
|
489
516
|
path: doc.path,
|
|
490
517
|
level: 'warning',
|
|
491
|
-
message: `\`current_state\` is ${currentState.length} chars (cap: 1500).
|
|
518
|
+
message: `\`current_state\` is ${currentState.length} chars (cap: 1500). One mechanical fix: \`dotmd doctor --frontmatter-fix\` (moves the overflow into the body) — do NOT hand-trim or re-run check in a loop. Going forward, write current_state as a 2-4 sentence summary; detail goes in the body.`,
|
|
492
519
|
});
|
|
493
520
|
}
|
|
494
521
|
|