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/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 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;
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
- hubs.set(doc.path, { doc, childCount: childPaths.size, childPaths });
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
- let match;
177
- while ((match = sectionRe.exec(body)) !== null) {
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
- const section = next >= 0 ? rest.slice(0, next) : rest;
198
+ return next >= 0 ? rest.slice(0, next) : rest;
199
+ };
182
200
 
183
- const linkRe = /\[[^\]]+\]\(([^)]+\.md(?:#[^)]+)?)\)/g;
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 = linkRe.exec(section)) !== null) refs.push(link[1]);
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$/,