dotmd-cli 0.62.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/bin/dotmd.mjs CHANGED
@@ -1225,14 +1225,19 @@ when the hub is \`planned\`) still render on their own.
1225
1225
  Larger, prose-first "coordination" runlists (a domain map pointing at many
1226
1226
  plans, marked \`execution_mode: coordination\` or named \`*-runlist\`) aren't
1227
1227
  folded — they're lifted into a separate \`Runlists\` section in \`dotmd plans\`
1228
- and out of the active count. \`dotmd runlists\` shows that dashboard on its own.`,
1228
+ and out of the active count. \`dotmd runlists\` shows that dashboard on its own.
1229
+ For these, \`runlist\`/\`runlist next\` also read order from the body when there's
1230
+ no \`runlist:\` array — a \`## Ranked queue\` table or \`## Order of operations\`
1231
+ list of markdown links (the first \`.md\` link per row/item, in order).`,
1229
1232
 
1230
1233
  runlists: `dotmd runlists — the coordination-hub dashboard
1231
1234
 
1232
1235
  Lists every *coordination runlist*: a prose-first plan that sits above a
1233
1236
  cluster of others (a domain map), detected by \`execution_mode: coordination\`
1234
1237
  or a \`*-runlist\` / \`runlist\` slug. Each row shows the hub, its age, the rough
1235
- size of its \`related_plans:\` cluster, and a one-line descriptor.
1238
+ size of its \`related_plans:\` cluster, a \`next → <child>\` when the hub's body
1239
+ encodes order as markdown links (\`## Ranked queue\` table / \`## Order of
1240
+ operations\` list), and a one-line descriptor.
1236
1241
 
1237
1242
  This is the standalone form of the \`Runlists\` section that \`dotmd plans\`
1238
1243
  pins beneath the leaf-plan triage list.
@@ -1240,7 +1245,7 @@ pins beneath the leaf-plan triage list.
1240
1245
  dotmd runlists All runlists (a small bounded set), most stale first.
1241
1246
  dotmd runlists --sort recent Order by recency instead (age|recent|related|title|status).
1242
1247
  dotmd runlists --limit N Cap the list at N.
1243
- dotmd runlists --json Structured rows (path, status, childCount, …).`,
1248
+ dotmd runlists --json Structured rows (path, status, childCount, nextPickup, …).`,
1244
1249
 
1245
1250
  'bulk-tag': `dotmd bulk-tag [files...] — fill in type/status frontmatter on pre-existing markdown
1246
1251
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.62.0",
3
+ "version": "0.63.0",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, Notion sync, AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/health.mjs CHANGED
@@ -87,7 +87,7 @@ export function runHealth(argv, config) {
87
87
  ready: { count: readyPlans.length },
88
88
  planned: { count: plannedPlans.length },
89
89
  recentlyArchived: { count: recentlyArchived.length, last30d: recentlyArchived.map(d => path.basename(d.path, '.md')) },
90
- runlists: { count: runlistHubs.length, hubs: runlistHubs.map(d => ({ path: d.path, title: d.title, status: d.status, childCount: coordination.get(d.path)?.childCount ?? 0 })) },
90
+ runlists: { count: runlistHubs.length, hubs: runlistHubs.map(d => ({ path: d.path, title: d.title, status: d.status, childCount: coordination.get(d.path)?.childCount ?? 0, nextPickup: coordination.get(d.path)?.nextPickup ?? null })) },
91
91
  }, null, 2) + '\n');
92
92
  return;
93
93
  }
@@ -122,9 +122,10 @@ export function runHealth(argv, config) {
122
122
  for (const doc of runlistHubs.slice(0, 8)) {
123
123
  const slug = hubLabel(doc).padEnd(28);
124
124
  const age = doc.daysSinceUpdate != null ? `${doc.daysSinceUpdate}d` : '?d';
125
- const rel = coordination.get(doc.path)?.childCount;
126
- const relStr = rel ? ` ${dim(`${rel} related`)}` : '';
127
- process.stdout.write(` ${slug} ${dim(age.padStart(4))}${relStr}\n`);
125
+ const info = coordination.get(doc.path);
126
+ const relStr = info?.childCount ? ` ${dim(`${info.childCount} related`)}` : '';
127
+ const nextStr = info?.nextPickup ? ` ${green('→')} ${info.nextPickup.label}` : '';
128
+ process.stdout.write(` ${slug} ${dim(age.padStart(4))}${relStr}${nextStr}\n`);
128
129
  }
129
130
  if (runlistHubs.length > 8) {
130
131
  process.stdout.write(` ${dim(`...and ${runlistHubs.length - 8} more`)}\n`);
package/src/lifecycle.mjs CHANGED
@@ -236,6 +236,10 @@ export async function runStatus(argv, config, opts = {}) {
236
236
  process.stdout.write(`${prefix} Would unfile: ${toRepoPath(filePath, config.repoRoot)} → ${toRepoPath(targetPath, config.repoRoot)}\n`);
237
237
  finalPath = targetPath;
238
238
  }
239
+ if (finalPath !== filePath) {
240
+ const refCount = countRefsToUpdate(filePath, finalPath, config);
241
+ if (refCount > 0) process.stdout.write(`${prefix} Would update references in ${refCount} file(s)\n`);
242
+ }
239
243
  if ((isArchiving || isUnarchiving || isFiling || isUnfiling) && config.indexPath) {
240
244
  process.stdout.write(`${prefix} Would regenerate index\n`);
241
245
  }
@@ -286,6 +290,22 @@ export async function runStatus(argv, config, opts = {}) {
286
290
  finalPath = targetPath;
287
291
  }
288
292
 
293
+ // Any of the four moves above shifts the file's directory, which breaks
294
+ // relative refs in both directions — links FROM the moved file and inbound
295
+ // refs TO it from other docs. runArchive repairs both; mirror that here so
296
+ // the deprecated `dotmd status <file> archived` path and the `dotmd set`
297
+ // unarchive/file/unfile transitions (which route through runStatus, not
298
+ // runArchive) don't silently leave dangling links.
299
+ let selfRefsFixed = false;
300
+ let inboundRefCount = 0;
301
+ let inboundRefPaths = [];
302
+ if (finalPath !== filePath) {
303
+ selfRefsFixed = updateRefsFromMovedFile(filePath, finalPath, config) > 0;
304
+ const inbound = updateRefsAfterMove(filePath, finalPath, config);
305
+ inboundRefCount = inbound.count;
306
+ inboundRefPaths = inbound.paths;
307
+ }
308
+
289
309
  // Regen the index on every status change — `active → planned` etc. drift
290
310
  // the per-status sections just as much as archive crossings. Archive paths
291
311
  // also benefit (replaces the previously-gated regen). `--no-index` skips
@@ -298,10 +318,13 @@ export async function runStatus(argv, config, opts = {}) {
298
318
  }
299
319
 
300
320
  process.stdout.write(`${green(toRepoPath(finalPath, config.repoRoot))}: ${oldStatus ?? 'unknown'} → ${newStatus}\n`);
321
+ if (selfRefsFixed) process.stdout.write('Updated references in moved file.\n');
322
+ if (inboundRefCount > 0) process.stdout.write(`Updated references in ${inboundRefCount} file(s).\n`);
301
323
 
302
324
  if (showFiles) {
303
325
  const touched = [filePath];
304
326
  if (finalPath !== filePath) touched.push(finalPath);
327
+ touched.push(...inboundRefPaths);
305
328
  if (config.indexPath && !noIndex) touched.push(config.indexPath);
306
329
  emitFilesFooter(touched, config);
307
330
  }
@@ -749,6 +772,26 @@ export function runTouch(argv, config, opts = {}) {
749
772
  try { config.hooks.onTouch?.({ path: toRepoPath(filePath, config.repoRoot) }, { path: toRepoPath(filePath, config.repoRoot), date: today }); } catch (err) { warn(`Hook 'onTouch' threw: ${err.message}`); }
750
773
  }
751
774
 
775
+ // Rewrite every frontmatter ref token (a `*.md` path in a YAML list item or an
776
+ // inline scalar, quoted or `>`-prefixed) that points at `oldPath` so it points
777
+ // at `newPath`. Each token is resolved doc-relative *and* repo-relative and
778
+ // compared to oldPath by absolute path — mirroring how the body-link branch and
779
+ // `updateRefsFromMovedFile` resolve refs. This replaces an older substring
780
+ // rewrite (`fm.split(oldRelPath).join(newRelPath)`) that only knew doc-relative
781
+ // paths, so it: left repo-relative cross-dir refs (`docs/plans/child.md` from
782
+ // `docs/rfcs/spec.md`) broken; mangled same-dir repo-relative refs into
783
+ // `docs/plans/../archived/child.md`; and could corrupt a `grandchild.md` ref
784
+ // when archiving `child.md` (suffix match). oldPath no longer exists on disk
785
+ // post-`git mv`, so existsSync-based resolveRefPath can't be used here.
786
+ function rewriteFrontmatterRefs(fm, docDir, oldPath, newPath, repoRoot) {
787
+ return fm.replace(/[^\s"'<>:]+\.md\b/g, (token) => {
788
+ const docRelAbs = path.resolve(docDir, token);
789
+ const repoRelAbs = path.resolve(repoRoot, token);
790
+ if (docRelAbs !== oldPath && repoRelAbs !== oldPath) return token;
791
+ return path.relative(docDir, newPath).split(path.sep).join('/');
792
+ });
793
+ }
794
+
752
795
  /**
753
796
  * After a file moves (archive/unarchive), update frontmatter references in all
754
797
  * docs that pointed to the old location so they point to the new one.
@@ -766,17 +809,7 @@ function updateRefsAfterMove(oldPath, newPath, config) {
766
809
  if (!fm) continue;
767
810
 
768
811
  const docDir = path.dirname(docFile);
769
- const oldRelPath = path.relative(docDir, oldPath).split(path.sep).join('/');
770
- const newRelPath = path.relative(docDir, newPath).split(path.sep).join('/');
771
-
772
- let newFm = fm;
773
- if (newFm.includes(oldRelPath)) {
774
- newFm = newFm.split(oldRelPath).join(newRelPath);
775
- }
776
- const dotSlashOld = './' + oldRelPath;
777
- if (newFm.includes(dotSlashOld)) {
778
- newFm = newFm.split(dotSlashOld).join(newRelPath);
779
- }
812
+ const newFm = rewriteFrontmatterRefs(fm, docDir, oldPath, newPath, config.repoRoot);
780
813
 
781
814
  // Body markdown links [text](path.md) or [text](path.md#anchor) pointing
782
815
  // at oldPath. resolveRefPath can't be used here: oldPath no longer exists
@@ -856,8 +889,7 @@ function countRefsToUpdate(oldPath, newPath, config) {
856
889
  if (!fm) continue;
857
890
 
858
891
  const docDir = path.dirname(docFile);
859
- const oldRelPath = path.relative(docDir, oldPath).split(path.sep).join('/');
860
- const fmHit = fm.includes(oldRelPath) || fm.includes('./' + oldRelPath);
892
+ const fmHit = rewriteFrontmatterRefs(fm, docDir, oldPath, newPath, config.repoRoot) !== fm;
861
893
 
862
894
  let bodyHit = false;
863
895
  if (!fmHit) {
package/src/query.mjs CHANGED
@@ -162,6 +162,7 @@ export function runRunlists(index, argv, config) {
162
162
  status: d.status,
163
163
  title: d.title,
164
164
  childCount: coordination.get(d.path)?.childCount ?? 0,
165
+ nextPickup: coordination.get(d.path)?.nextPickup ?? null,
165
166
  updated: d.updated,
166
167
  nextStep: d.nextStep ?? null,
167
168
  }));
@@ -782,9 +783,18 @@ function renderCoordinationSection(coordDocs, coordination, maxWidth, total) {
782
783
  const statusTag = doc.status && doc.status !== 'active' ? ` ${colorTag(doc.status)}` : '';
783
784
  const desc = (doc.nextStep || doc.currentState || doc.title || '').replace(/\s+/g, ' ').trim();
784
785
 
786
+ // Next-pickup (first non-archived ranked child from the body) leads the
787
+ // description column when one resolves — it's the most actionable cell. Its
788
+ // status is shown only when it's not the expected `active`.
789
+ const next = info?.nextPickup;
790
+ const nextStr = next
791
+ ? `${green('→')} ${next.label}${next.status && next.status !== 'active' ? dim(` (${next.status})`) : ''}`
792
+ : '';
793
+ const nextPart = nextStr ? `${nextStr} ` : '';
794
+
785
795
  const left = ` ${slug} ${ageStr} ${dim(count)} `;
786
- const budget = Math.max(10, maxWidth - visibleLen(left) - visibleLen(statusTag) - 2);
796
+ const budget = Math.max(10, maxWidth - visibleLen(left) - visibleLen(nextPart) - visibleLen(statusTag) - 2);
787
797
  const descR = desc.length > budget ? desc.slice(0, budget - 3) + '...' : desc;
788
- process.stdout.write(`${left}${dim(descR)}${statusTag}\n`);
798
+ process.stdout.write(`${left}${nextPart}${dim(descR)}${statusTag}\n`);
789
799
  }
790
800
  }
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);