dotmd-cli 0.62.0 → 0.64.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 +26 -5
- package/bin/dotmd.mjs +110 -76
- package/dotmd.config.example.mjs +2 -0
- package/package.json +2 -2
- package/scripts/postinstall.mjs +6 -1
- package/src/commands.mjs +1 -1
- package/src/completions.mjs +53 -28
- package/src/config.mjs +8 -2
- package/src/health.mjs +5 -4
- package/src/init.mjs +36 -4
- package/src/lifecycle.mjs +83 -31
- package/src/new.mjs +166 -4
- package/src/query.mjs +17 -2
- package/src/runlist.mjs +88 -11
- package/src/ship.mjs +6 -0
package/src/runlist.mjs
CHANGED
|
@@ -89,6 +89,10 @@ export function isCoordinationHub(doc) {
|
|
|
89
89
|
// cluster (resolved against the index; peers/self excluded). It's an
|
|
90
90
|
// approximation — `related_plans` is a *related* cluster, not a strict child
|
|
91
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`.
|
|
92
96
|
export function buildCoordinationIndex(index, config) {
|
|
93
97
|
const docByPath = new Map(index.docs.map(d => [d.path, d]));
|
|
94
98
|
const byBasename = new Map();
|
|
@@ -96,6 +100,15 @@ export function buildCoordinationIndex(index, config) {
|
|
|
96
100
|
const base = d.path.split('/').pop();
|
|
97
101
|
if (!byBasename.has(base)) byBasename.set(base, d);
|
|
98
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
|
+
};
|
|
99
112
|
|
|
100
113
|
const hubs = new Map();
|
|
101
114
|
for (const doc of index.docs) {
|
|
@@ -104,14 +117,13 @@ export function buildCoordinationIndex(index, config) {
|
|
|
104
117
|
const refs = doc.refFields?.related_plans ?? [];
|
|
105
118
|
const childPaths = new Set();
|
|
106
119
|
for (const ref of refs) {
|
|
107
|
-
const
|
|
108
|
-
let child = abs ? docByPath.get(toRepoPath(abs, config.repoRoot)) ?? null : null;
|
|
109
|
-
if (!child) child = byBasename.get(ref.split('/').pop()) ?? null;
|
|
120
|
+
const child = resolveRef(ref, dir);
|
|
110
121
|
if (child && child.path !== doc.path && (child.type === 'plan' || child.type == null)) {
|
|
111
122
|
childPaths.add(child.path);
|
|
112
123
|
}
|
|
113
124
|
}
|
|
114
|
-
|
|
125
|
+
const nextPickup = resolveHubNextPickup(doc, dir, resolveRef, archiveStatuses, config);
|
|
126
|
+
hubs.set(doc.path, { doc, childCount: childPaths.size, childPaths, nextPickup });
|
|
115
127
|
}
|
|
116
128
|
return hubs;
|
|
117
129
|
}
|
|
@@ -169,28 +181,93 @@ function resolveRunlistRefs(refs, hubAbsPath, config) {
|
|
|
169
181
|
return out;
|
|
170
182
|
}
|
|
171
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.
|
|
172
191
|
function detectBodyRunlistRefs(body) {
|
|
173
192
|
if (!body) return [];
|
|
174
|
-
const sectionRe = /^##\s+(Order of operations|Runlist|Execution order|Implementation order|Plan order)\s*$/gim;
|
|
175
193
|
const refs = [];
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const start = match.index + match[0].length;
|
|
194
|
+
const linkRe = /\[[^\]]+\]\(([^)]+\.md(?:#[^)]+)?)\)/;
|
|
195
|
+
const sliceSection = (start) => {
|
|
179
196
|
const rest = body.slice(start);
|
|
180
197
|
const next = rest.search(/^##\s+/m);
|
|
181
|
-
|
|
198
|
+
return next >= 0 ? rest.slice(0, next) : rest;
|
|
199
|
+
};
|
|
182
200
|
|
|
183
|
-
|
|
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');
|
|
184
206
|
let link;
|
|
185
|
-
while ((link =
|
|
207
|
+
while ((link = allLinks.exec(section)) !== null) refs.push(link[1]);
|
|
186
208
|
|
|
187
209
|
const checklistRe = /^\s*[-*]\s+\[[ xX]\]\s+([^\s)]+\.md(?:#[^\s)]+)?)/gm;
|
|
188
210
|
let item;
|
|
189
211
|
while ((item = checklistRe.exec(section)) !== null) refs.push(item[1]);
|
|
190
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
|
+
|
|
191
229
|
return [...new Set(refs)];
|
|
192
230
|
}
|
|
193
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
|
+
|
|
194
271
|
function readRunlistChildren(hubAbsPath, config) {
|
|
195
272
|
const raw = readFileSync(hubAbsPath, 'utf8');
|
|
196
273
|
const { frontmatter: fmRaw, body } = extractFrontmatter(raw);
|
package/src/ship.mjs
CHANGED
|
@@ -12,6 +12,12 @@ const ALLOWLIST_PATTERNS = [
|
|
|
12
12
|
/^test\//,
|
|
13
13
|
/^bin\//,
|
|
14
14
|
/^docs\//,
|
|
15
|
+
// Plugin artifacts ship in lockstep with the CLI (the plugin-based workflow
|
|
16
|
+
// is canonical), so a dirty SKILL.md / command / hook / manifest is a release
|
|
17
|
+
// change. `.claude/commands/` stays for repos that still hand-author slash
|
|
18
|
+
// commands — harmless, and dropping it would un-stage their edits.
|
|
19
|
+
/^plugins\//,
|
|
20
|
+
/^\.claude-plugin\//,
|
|
15
21
|
/^\.claude\/commands\//,
|
|
16
22
|
/^dotmd\.config\.example\.mjs$/,
|
|
17
23
|
/^dotmd\.config\.mjs$/,
|