dotmd-cli 0.61.0 → 0.63.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 +56 -5
- package/package.json +1 -1
- package/src/commands.mjs +1 -1
- package/src/completions.mjs +1 -1
- package/src/health.mjs +56 -9
- package/src/index.mjs +8 -1
- package/src/lifecycle.mjs +45 -13
- package/src/query.mjs +315 -46
- package/src/render.mjs +22 -3
- package/src/runlist.mjs +202 -7
- package/src/validate.mjs +27 -0
package/src/runlist.mjs
CHANGED
|
@@ -4,15 +4,145 @@ 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
|
+
// Each hub also gets a `nextPickup` (or null) parsed from its body order — the
|
|
93
|
+
// first non-archived ranked child — so prose-first hubs surface a next-pickup
|
|
94
|
+
// target the way sprint `runlist:` hubs do. Reads each hub's file (a small,
|
|
95
|
+
// bounded set), so this is no longer pure in-memory like `buildRunlistIndex`.
|
|
96
|
+
export function buildCoordinationIndex(index, config) {
|
|
97
|
+
const docByPath = new Map(index.docs.map(d => [d.path, d]));
|
|
98
|
+
const byBasename = new Map();
|
|
99
|
+
for (const d of index.docs) {
|
|
100
|
+
const base = d.path.split('/').pop();
|
|
101
|
+
if (!byBasename.has(base)) byBasename.set(base, d);
|
|
102
|
+
}
|
|
103
|
+
const archiveStatuses = config.lifecycle?.archiveStatuses ?? new Set(['archived']);
|
|
104
|
+
|
|
105
|
+
// Resolve a path-or-basename ref (from frontmatter or body) to an indexed doc.
|
|
106
|
+
const resolveRef = (ref, dir) => {
|
|
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
|
+
return child;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const hubs = new Map();
|
|
114
|
+
for (const doc of index.docs) {
|
|
115
|
+
if (!isCoordinationHub(doc)) continue;
|
|
116
|
+
const dir = path.dirname(path.join(config.repoRoot, doc.path));
|
|
117
|
+
const refs = doc.refFields?.related_plans ?? [];
|
|
118
|
+
const childPaths = new Set();
|
|
119
|
+
for (const ref of refs) {
|
|
120
|
+
const child = resolveRef(ref, dir);
|
|
121
|
+
if (child && child.path !== doc.path && (child.type === 'plan' || child.type == null)) {
|
|
122
|
+
childPaths.add(child.path);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const nextPickup = resolveHubNextPickup(doc, dir, resolveRef, archiveStatuses, config);
|
|
126
|
+
hubs.set(doc.path, { doc, childCount: childPaths.size, childPaths, nextPickup });
|
|
127
|
+
}
|
|
128
|
+
return hubs;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Conventional container dirs whose name adds no disambiguation to a hub label.
|
|
132
|
+
const HUB_CONTAINER_DIRS = new Set(['plans', 'prompts', 'archive', 'archived']);
|
|
133
|
+
|
|
134
|
+
// Display label for a hub. A bare basename loses context for hubs that live in
|
|
135
|
+
// a subdirectory (e.g. `docs/plans/pos/runlist.md` would read as just
|
|
136
|
+
// `runlist`), so prefix the immediate parent dir unless it's a conventional
|
|
137
|
+
// container. → `pos/runlist`, but `billing-runlist` stays as-is. Shared by the
|
|
138
|
+
// `dotmd plans` Runlists section, `dotmd runlists`, and `dotmd health` so a hub
|
|
139
|
+
// reads the same everywhere.
|
|
140
|
+
export function hubLabel(doc) {
|
|
141
|
+
const slug = toSlug(doc);
|
|
142
|
+
const parent = path.basename(path.dirname(doc.path));
|
|
143
|
+
return HUB_CONTAINER_DIRS.has(parent) ? slug : `${parent}/${slug}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
16
146
|
// Bare hub slugs resolve through the shared resolver; the caller keeps its
|
|
17
147
|
// runlist-specific miss message, so no die-on-miss here.
|
|
18
148
|
function resolveHubInput(input, config) {
|
|
@@ -51,28 +181,93 @@ function resolveRunlistRefs(refs, hubAbsPath, config) {
|
|
|
51
181
|
return out;
|
|
52
182
|
}
|
|
53
183
|
|
|
184
|
+
// Extract ordered plan refs from a hub's body prose. Two shapes:
|
|
185
|
+
// - link-list sections (`## Order of operations`, `## Runlist`, …) — every
|
|
186
|
+
// `.md` link or checklist item, in document order.
|
|
187
|
+
// - ranked-queue tables (`## Ranked queue`, …) — the first `.md` link in each
|
|
188
|
+
// table row (the ranked plan); header/separator rows contribute none.
|
|
189
|
+
// Coordination hubs encode their next-pickup order in the table shape; sprint-
|
|
190
|
+
// ish hubs use the link list. Deduped, first occurrence wins, order preserved.
|
|
54
191
|
function detectBodyRunlistRefs(body) {
|
|
55
192
|
if (!body) return [];
|
|
56
|
-
const sectionRe = /^##\s+(Order of operations|Runlist|Execution order|Implementation order|Plan order)\s*$/gim;
|
|
57
193
|
const refs = [];
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const start = match.index + match[0].length;
|
|
194
|
+
const linkRe = /\[[^\]]+\]\(([^)]+\.md(?:#[^)]+)?)\)/;
|
|
195
|
+
const sliceSection = (start) => {
|
|
61
196
|
const rest = body.slice(start);
|
|
62
197
|
const next = rest.search(/^##\s+/m);
|
|
63
|
-
|
|
198
|
+
return next >= 0 ? rest.slice(0, next) : rest;
|
|
199
|
+
};
|
|
64
200
|
|
|
65
|
-
|
|
201
|
+
const linkSectionRe = /^##\s+(?:Order of operations|Runlist|Execution order|Implementation order|Plan order)\b.*$/gim;
|
|
202
|
+
let match;
|
|
203
|
+
while ((match = linkSectionRe.exec(body)) !== null) {
|
|
204
|
+
const section = sliceSection(match.index + match[0].length);
|
|
205
|
+
const allLinks = new RegExp(linkRe.source, 'g');
|
|
66
206
|
let link;
|
|
67
|
-
while ((link =
|
|
207
|
+
while ((link = allLinks.exec(section)) !== null) refs.push(link[1]);
|
|
68
208
|
|
|
69
209
|
const checklistRe = /^\s*[-*]\s+\[[ xX]\]\s+([^\s)]+\.md(?:#[^\s)]+)?)/gm;
|
|
70
210
|
let item;
|
|
71
211
|
while ((item = checklistRe.exec(section)) !== null) refs.push(item[1]);
|
|
72
212
|
}
|
|
213
|
+
|
|
214
|
+
// Ranked-queue tables: the first `.md` link per row is the ranked plan. A
|
|
215
|
+
// header (`| Rank | Plan | … |`) and separator (`|---|`) carry no link and are
|
|
216
|
+
// skipped naturally. Heading may carry trailing text (`## Ranked queue (next
|
|
217
|
+
// pickup)`), so match the leading words, not an exact line.
|
|
218
|
+
const queueSectionRe = /^##\s+(?:Ranked queue|Queue|Pickup order|Heads)\b.*$/gim;
|
|
219
|
+
while ((match = queueSectionRe.exec(body)) !== null) {
|
|
220
|
+
const section = sliceSection(match.index + match[0].length);
|
|
221
|
+
for (const rawLine of section.split('\n')) {
|
|
222
|
+
const line = rawLine.trim();
|
|
223
|
+
if (!line.startsWith('|')) continue;
|
|
224
|
+
const link = linkRe.exec(line);
|
|
225
|
+
if (link) refs.push(link[1]);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
73
229
|
return [...new Set(refs)];
|
|
74
230
|
}
|
|
75
231
|
|
|
232
|
+
// Label for a hub's next-pickup child: its slug with the hub's leading module
|
|
233
|
+
// segment stripped when shared (so `founder-runlist` → `founder-brand-conflicts`
|
|
234
|
+
// reads as `brand-conflicts`), mirroring how sprint children drop the hub
|
|
235
|
+
// prefix. Falls back to the full slug when there's no shared leading segment.
|
|
236
|
+
function coordinationChildLabel(childDoc, hubDoc) {
|
|
237
|
+
const childSlug = toSlug(childDoc);
|
|
238
|
+
const seg = toSlug(hubDoc).split('-')[0];
|
|
239
|
+
if (seg.length >= 2 && childSlug.startsWith(`${seg}-`) && childSlug.length > seg.length + 1) {
|
|
240
|
+
return childSlug.slice(seg.length + 1);
|
|
241
|
+
}
|
|
242
|
+
return childSlug;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Read a coordination hub's body order (a `## Ranked queue` table or a
|
|
246
|
+
// `## Order of operations` link list) and return its NEXT PICKUP: the first
|
|
247
|
+
// ranked child that isn't archived, resolved to its live status from the index.
|
|
248
|
+
// Prose-first hubs keep their sequence in the body, invisible to the
|
|
249
|
+
// frontmatter-only index — this surfaces `next → <child>` the way sprint
|
|
250
|
+
// `runlist:` hubs already do. Returns null when the hub has no parseable body
|
|
251
|
+
// order or every ranked child is archived. Best-effort: a read failure degrades
|
|
252
|
+
// to null, never throws.
|
|
253
|
+
function resolveHubNextPickup(hubDoc, hubDir, resolveRef, archiveStatuses, config) {
|
|
254
|
+
let body;
|
|
255
|
+
try {
|
|
256
|
+
({ body } = extractFrontmatter(readFileSync(path.join(config.repoRoot, hubDoc.path), 'utf8')));
|
|
257
|
+
} catch {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
for (const ref of detectBodyRunlistRefs(body)) {
|
|
261
|
+
const child = resolveRef(ref, hubDir);
|
|
262
|
+
if (!child || child.path === hubDoc.path) continue;
|
|
263
|
+
if (child.type && child.type !== 'plan') continue;
|
|
264
|
+
const archived = archiveStatuses.has(child.status) || isArchivedPath(child.path, config);
|
|
265
|
+
if (archived) continue;
|
|
266
|
+
return { path: child.path, status: child.status ?? null, label: coordinationChildLabel(child, hubDoc) };
|
|
267
|
+
}
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
|
|
76
271
|
function readRunlistChildren(hubAbsPath, config) {
|
|
77
272
|
const raw = readFileSync(hubAbsPath, 'utf8');
|
|
78
273
|
const { frontmatter: fmRaw, body } = extractFrontmatter(raw);
|
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);
|