dotmd-cli 0.64.3 → 0.65.1

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
@@ -43,7 +43,7 @@ const FLAG_SPECS = {
43
43
  update: { flags: new Set(['--check', '--cli-only', '--plugin-only']), values: new Set() },
44
44
  check: { flags: new Set(['--fix', '--errors-only', '--no-collapse', '--json', '--verbose']), values: new Set() },
45
45
  doctor: { flags: new Set(['--apply', '--yes', '--dry-run', '-n', '--statuses', '--migrate-template', '--migrate-prompts', '--frontmatter-fix', '--project', '--json', '--include-archived']), values: new Set() },
46
- runlist: { flags: new Set(['--json', '--full', '--no-index', '--show-files']), values: new Set(), subcommands: new Set(['next']) },
46
+ runlist: { flags: new Set(['--json', '--full', '--no-index', '--show-files', '--clear-parent', '--before', '--after']), values: new Set(['--before', '--after']), subcommands: new Set(['next', 'add', 'remove', 'reorder']) },
47
47
  runlists: { flags: new Set(['--json', '--limit', '--sort']), values: new Set(['--limit', '--sort']) },
48
48
  prompts: {
49
49
  flags: new Set(['--json', '--status', '--include-archived', '--sort', '--limit', '--all', '--no-index', '--show-files', '--body', '--message', '--title']),
@@ -235,7 +235,7 @@ Validate & Fix:
235
235
  Lifecycle:
236
236
  use <file> Open a plan (mark in-session + print it) or consume a prompt
237
237
  set <status> <file> Change a document's status (frontmatter write; archive also moves the file)
238
- runlist <hub> [next] Show or walk an ordered group of plans (see \`dotmd help runlist\`)
238
+ runlist <hub> [next|add|remove|reorder] Show, walk, or mutate an ordered group of plans (see \`dotmd help runlist\`)
239
239
  runlists List coordination-hub runlists (the Runlists dashboard)
240
240
  status <file> <status> Transition document status (deprecated; prefer \`set\`)
241
241
  archive <file> Archive (status + move + update refs)
@@ -1212,19 +1212,49 @@ directory, updates references, and regenerates the index.
1212
1212
 
1213
1213
  Use --dry-run (-n) to preview changes without writing anything.`,
1214
1214
 
1215
- runlist: `dotmd runlist <hub> [next] — work with an ordered group of plans
1215
+ runlist: `dotmd runlist <hub> [next|add|remove|reorder] — work with an ordered group of plans
1216
1216
 
1217
1217
  A "runlist" is just a plan with a \`runlist:\` array of child plan paths in its
1218
1218
  frontmatter — there is no separate doc type. The hub plan can have any status;
1219
1219
  the order of the children comes from the array.
1220
1220
 
1221
1221
  Usage:
1222
- dotmd runlist <hub> Show children + their statuses, in order.
1223
- The first non-archived child is marked \`→\`.
1224
- dotmd runlist next <hub> Open the first non-archived child (marks it
1225
- in-session + prints it). Stops if it's not in a
1226
- workable status (active / planned / in-session)
1227
- so you resolve the blocker first.
1222
+ dotmd runlist <hub> Show children + their statuses, in order. The
1223
+ first pickup-able child (active / planned /
1224
+ in-session) is marked \`→\`. Archived (done) and
1225
+ parked children (blocked / partial / paused /
1226
+ awaiting / queued-after) are skipped — \`→\`
1227
+ advances to the first child you can actually
1228
+ start. Parked ≠ done: they don't count toward
1229
+ done/total.
1230
+ dotmd runlist next <hub> Open the first pickup-able child (marks it
1231
+ in-session + prints it), advancing past archived
1232
+ and parked children. If every remaining child is
1233
+ parked, stops and lists them + the unstick verbs
1234
+ so you resolve a blocker first.
1235
+ dotmd runlist add <hub> <child...>
1236
+ Append children to the hub's \`runlist:\` array
1237
+ (no more hand-editing the YAML). Each child can be:
1238
+ • a bare slug (\`cleanup\`) → scaffolds a
1239
+ \`planned\` stub \`<hub>-NN-<slug>.md\` next to
1240
+ the hub (mirrors \`new plan --runlist\`), or
1241
+ • a path/slug of an existing plan → wired in by a
1242
+ hub-relative ref, with its \`parent_plan:\` set
1243
+ back at the hub.
1244
+ A plain plan gains a \`runlist:\` (becomes a hub).
1245
+ Coordination hubs (body-order) aren't handled here.
1246
+ dotmd runlist remove <hub> <child...>
1247
+ Drop children from the \`runlist:\` array. Children
1248
+ match by full path or short slug (\`cleanup\` finds
1249
+ \`<hub>-03-cleanup.md\`). \`--clear-parent\` also blanks
1250
+ each removed child's \`parent_plan:\` back-ref.
1251
+ dotmd runlist reorder <hub> <child> --before|--after <other>
1252
+ dotmd runlist reorder <hub> <c1> <c2> <c3...>
1253
+ Move one child relative to another, or pass every
1254
+ child to set a full new order.
1255
+ All three mutators take \`--dry-run\` / \`--json\` and
1256
+ keep any body \`## Order of operations\` link list in
1257
+ sync (preserving per-item ⬜/✅ markers).
1228
1258
 
1229
1259
  Flags (only meaningful with \`next\`):
1230
1260
  --full Print full plan body instead of the card.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.64.3",
3
+ "version": "0.65.1",
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/new.mjs CHANGED
@@ -269,10 +269,10 @@ export function readBodyInput(source) {
269
269
  }
270
270
 
271
271
  // Slug/title helpers shared by name resolution and runlist child generation.
272
- function slugify(s) {
272
+ export function slugify(s) {
273
273
  return s.toLowerCase().replace(/[\s_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
274
274
  }
275
- function titleize(s) {
275
+ export function titleize(s) {
276
276
  return s.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
277
277
  }
278
278
 
@@ -420,7 +420,7 @@ ${bodyInput?.trim() ?? ''}
420
420
  // Minimal child plan stub for a scaffolded runlist child. parent_plan points
421
421
  // back at the hub (same dir) so \`dotmd doctor\` is satisfied and the reverse
422
422
  // link/graph work; status starts `planned` (queued behind the hub).
423
- function runlistChildContent(childTitle, hubSlug, hubTitle, childStatus, today) {
423
+ export function runlistChildContent(childTitle, hubSlug, hubTitle, childStatus, today) {
424
424
  return `---
425
425
  type: plan
426
426
  status: ${childStatus}
package/src/query.mjs CHANGED
@@ -524,7 +524,11 @@ function renderPlansOutput(docs, filters, config, opts = {}) {
524
524
  const headerParts = [];
525
525
  if (hubCount) headerParts.push(`${hubCount} runlist${hubCount === 1 ? '' : 's'}`);
526
526
  headerParts.push(...counts);
527
- const header = `${totalAll} ${noun}${headerParts.length ? ' · ' + headerParts.join(' · ') : ''}`;
527
+ // Hubs are held OUT of the headline plan count — they read as a separate
528
+ // `N runlist` sibling, never as actionable plans. So "N plans" counts leaves
529
+ // only and the status segments sum to it (the runlist sibling sits apart).
530
+ const headlineTotal = totalAll - hubCount;
531
+ const header = `${headlineTotal} ${noun}${headerParts.length ? ' · ' + headerParts.join(' · ') : ''}`;
528
532
  process.stdout.write(dim(header) + '\n');
529
533
 
530
534
  // Active filter note
@@ -599,6 +603,23 @@ function renderPlansOutput(docs, filters, config, opts = {}) {
599
603
  }
600
604
  }
601
605
 
606
+ // Runlist nav stays discoverable under a narrowing filter. The Runlists
607
+ // section respects the active filter (a coordination hub shows only when its
608
+ // own status matches), so `--status blocked` legitimately hides an `active`
609
+ // hub. Rather than silently dropping the map, point at `dotmd runlists` when
610
+ // a filter hid live hubs — honest count, filter respected, map never lost.
611
+ if (coordination?.size && activeFilters.length) {
612
+ const archiveStatuses = config.lifecycle?.archiveStatuses ?? new Set(['archived']);
613
+ const shownHubs = new Set(coordAll.map(d => d.path));
614
+ const filterHidden = [...coordination.values()].filter(v =>
615
+ !shownHubs.has(v.doc.path)
616
+ && !archiveStatuses.has(v.doc.status)
617
+ && !isArchivedPath(v.doc.path, config)).length;
618
+ if (filterHidden > 0) {
619
+ process.stdout.write(dim(` ${filterHidden} runlist${filterHidden === 1 ? '' : 's'} hidden by filter · dotmd runlists\n`));
620
+ }
621
+ }
622
+
602
623
  process.stdout.write('\n');
603
624
  return;
604
625
  }
@@ -747,9 +768,13 @@ function renderHubBlock(hub, info, children, maxWidth, topMaxSlug) {
747
768
  const hubSlug = toSlug(hub);
748
769
  const nextDoc = info.nextChildPath ? info.children.find(c => c.path === info.nextChildPath)?.doc : null;
749
770
  const nextLabel = nextDoc ? stripHubPrefix(toSlug(nextDoc), hubSlug) : null;
771
+ // No pickup-able child: "N parked" when a live-but-unstartable child remains
772
+ // (the runlist is stuck, not finished), else "all archived" (truly done).
750
773
  const descr = nextLabel
751
774
  ? `runlist · ${info.doneCount}/${info.total} · next → ${nextLabel}`
752
- : `runlist · ${info.doneCount}/${info.total} · all archived`;
775
+ : info.parkedCount > 0
776
+ ? `runlist · ${info.doneCount}/${info.total} · ${info.parkedCount} parked`
777
+ : `runlist · ${info.doneCount}/${info.total} · all archived`;
753
778
 
754
779
  // Header row: hub slug + descriptor, with `[RUNLIST]` right-aligned like a tag.
755
780
  const slugCell = hubSlug.padEnd(topMaxSlug);
@@ -760,8 +785,15 @@ function renderHubBlock(hub, info, children, maxWidth, topMaxSlug) {
760
785
  process.stdout.write(header + '\n');
761
786
 
762
787
  if (children.length === 0) return;
788
+ // Render rows in runlist order (info.children), not the docs view's recency
789
+ // sort — a runlist reads in sequence, and a just-added child shouldn't jump to
790
+ // the top because its mtime is newest. Only children present in the current
791
+ // view are shown; any not tracked by the runlist index trail at the end.
792
+ const byPath = new Map(children.map(c => [c.path, c]));
793
+ const ordered = info.children.map(ic => byPath.get(ic.path)).filter(Boolean);
794
+ for (const c of children) if (!ordered.includes(c)) ordered.push(c);
763
795
  const childMaxSlug = Math.min(28, Math.max(...children.map(c => stripHubPrefix(toSlug(c), hubSlug).length)));
764
- for (const c of children) {
796
+ for (const c of ordered) {
765
797
  const isNext = c.path === info.nextChildPath;
766
798
  const indent = isNext ? ` ${green('→')} ` : ' ';
767
799
  process.stdout.write(formatPlanRow(c, maxWidth, {
package/src/render.mjs CHANGED
@@ -342,17 +342,17 @@ export function renderBriefing(index, config) {
342
342
  ]);
343
343
  const live = plans.filter(p => !closed.has(p.status) && !isArchivedPath(p.path, config));
344
344
  const liveHubs = live.filter(isHub);
345
+ const liveLeaves = live.length - liveHubs.length;
345
346
  const bySt = {};
346
347
  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(', ');
348
+ // Coordination hubs are navigation maps, not units of work — held OUT of the
349
+ // headline plan count entirely (they get their own `N runlists · dotmd
350
+ // runlists` pointer line below). The breakdown is leaf statuses only and sums
351
+ // back to the leaf count, so "N live plans" means N things to actually work on.
352
+ const counts = Object.entries(bySt).map(([s, n]) => `${n} ${s}`).join(', ');
353
353
  const closedCount = plans.length - live.length;
354
354
  const closedPart = closedCount ? ` (${closedCount} archived)` : '';
355
- lines.push(live.length ? `${live.length} live plans${closedPart}: ${counts}` : `0 live plans${closedPart}`);
355
+ lines.push(liveLeaves ? `${liveLeaves} live plans${closedPart}: ${counts}` : `0 live plans${closedPart}`);
356
356
  const show = plans.filter(p => (p.status === 'in-session' || p.status === 'active') && !isHub(p));
357
357
  for (const p of show) {
358
358
  const next = p.nextStep ? `next: ${p.nextStep}` : '(no next step)';
package/src/runlist.mjs CHANGED
@@ -1,20 +1,39 @@
1
- import { readFileSync } from 'node:fs';
1
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
3
+ import { extractFrontmatter, parseSimpleFrontmatter, replaceFrontmatter } from './frontmatter.mjs';
4
+ import { extractFirstHeading } from './extractors.mjs';
4
5
  import {
5
6
  asString,
6
7
  die,
8
+ escapeRegex,
7
9
  isArchivedPath,
8
10
  normalizeStringList,
11
+ nowIso,
9
12
  resolveRefPath,
10
13
  toRepoPath,
11
14
  toSlug,
15
+ warn,
12
16
  } from './util.mjs';
13
17
  import { resolveDocArg } from './index.mjs';
18
+ import { runlistChildContent, slugify, titleize } from './new.mjs';
14
19
  import { bold, cyan, dim, green, red, yellow } from './color.mjs';
15
20
 
16
21
  const PICKUPABLE_STATUSES = new Set(['active', 'planned', 'in-session']);
17
22
 
23
+ // A child is the runlist's NEXT PICKUP only when a session could start it right
24
+ // now — i.e. its status is one `dotmd use` accepts. The "parked" statuses
25
+ // (blocked/partial/paused/awaiting/queued-after) are deliberately NOT
26
+ // pickup-able: each needs its own unstuck action (monitor / spawn successor /
27
+ // re-evaluate / ask / check predecessor) before work resumes. So next-pickup
28
+ // resolution skips them and advances to the first child that's actually
29
+ // startable. Skipping ≠ done, though: a parked child is not archived, so it
30
+ // never counts toward `done/total` — that tally tracks closed (archived) only.
31
+ // This keeps the `→` marker in agreement with `runlist next`, which already
32
+ // gates on PICKUPABLE_STATUSES.
33
+ function isPickupable(status) {
34
+ return PICKUPABLE_STATUSES.has(status);
35
+ }
36
+
18
37
  // Build a hub/child map straight from the in-memory index — no disk IO. A doc
19
38
  // is a runlist *hub* when its `runlist:` frontmatter (`refFields.runlist`) is
20
39
  // non-empty. Each ref resolves to a doc in the index by path, falling back to
@@ -56,11 +75,17 @@ export function buildRunlistIndex(index, config) {
56
75
  }
57
76
  }
58
77
 
59
- const next = children.find(c => !c.missing && !c.archived) ?? null;
78
+ // Next pickup = first child a session can actually start. Skip archived
79
+ // (done) AND parked children alike; advance to the first pickup-able one.
80
+ const next = children.find(c => !c.missing && !c.archived && isPickupable(c.status)) ?? null;
60
81
  hubs.set(hub.path, {
61
82
  hub,
62
83
  total: children.length,
63
84
  doneCount: children.filter(c => c.archived).length,
85
+ // Live-but-not-startable children (parked: blocked/partial/paused/…). Lets
86
+ // the `dotmd plans` fold say "N parked" instead of mislabelling a hub with
87
+ // a parked-but-unfinished child as "all archived".
88
+ parkedCount: children.filter(c => !c.missing && !c.archived && !isPickupable(c.status)).length,
64
89
  children,
65
90
  nextChildPath: next?.path ?? null,
66
91
  });
@@ -164,13 +189,16 @@ function resolveRunlistRefs(refs, hubAbsPath, config) {
164
189
  const repoPath = toRepoPath(abs, config.repoRoot);
165
190
  try {
166
191
  const childRaw = readFileSync(abs, 'utf8');
167
- const { frontmatter: childFmRaw } = extractFrontmatter(childRaw);
192
+ const { frontmatter: childFmRaw, body: childBody } = extractFrontmatter(childRaw);
168
193
  const childFm = parseSimpleFrontmatter(childFmRaw);
169
194
  out.push({
170
195
  ref,
171
196
  path: repoPath,
172
197
  status: asString(childFm.status) ?? null,
173
- title: asString(childFm.title) ?? path.basename(abs, '.md'),
198
+ // Fall back to the body H1 (like the main index) before the bare
199
+ // filename — runlist child stubs carry their title as an H1, not a
200
+ // `title:` field, so this keeps the synced body order list readable.
201
+ title: asString(childFm.title) ?? extractFirstHeading(childBody) ?? path.basename(abs, '.md'),
174
202
  parentPlan: childFm.parent_plan ?? null,
175
203
  missing: false,
176
204
  });
@@ -263,6 +291,11 @@ function resolveHubNextPickup(hubDoc, hubDir, resolveRef, archiveStatuses, confi
263
291
  if (child.type && child.type !== 'plan') continue;
264
292
  const archived = archiveStatuses.has(child.status) || isArchivedPath(child.path, config);
265
293
  if (archived) continue;
294
+ // Skip parked ranked children too (blocked/partial/paused/awaiting/
295
+ // queued-after) — the hub's next-pickup is the first startable plan, the
296
+ // same gate sprint runlists use, so a hub never points `→` at a child a
297
+ // session can't actually pick up.
298
+ if (!isPickupable(child.status)) continue;
266
299
  return { path: child.path, status: child.status ?? null, label: coordinationChildLabel(child, hubDoc) };
267
300
  }
268
301
  return null;
@@ -322,7 +355,9 @@ function renderRunlist(hubRepoPath, children, opts = {}) {
322
355
  lines.push(` ${idx}. ${red('missing')} ${c.ref}`);
323
356
  continue;
324
357
  }
325
- const isNext = !nextPicked && !archiveStatuses.has(c.status);
358
+ // → marks the first child a session can actually start: skip archived and
359
+ // parked (blocked/partial/paused/awaiting/queued-after) alike.
360
+ const isNext = !nextPicked && isPickupable(c.status);
326
361
  if (isNext) nextPicked = true;
327
362
  const marker = isNext ? green('→') : ' ';
328
363
  const statusTag = `[${colorStatus(c.status)}]`;
@@ -330,16 +365,460 @@ function renderRunlist(hubRepoPath, children, opts = {}) {
330
365
  }
331
366
  if (!nextPicked) {
332
367
  lines.push('');
333
- lines.push(dim(' All children archived. Hub is ready for archive.'));
368
+ // No pickup-able child. Distinguish "done — ready to archive" from "stuck —
369
+ // a parked child needs unsticking" so the agent knows which it is.
370
+ const parked = children.filter(c => !c.missing && !archiveStatuses.has(c.status));
371
+ lines.push(dim(parked.length === 0
372
+ ? ' All children archived. Hub is ready for archive.'
373
+ : ` No pickup-able child — ${parked.length} parked. Unstick one (e.g. \`dotmd set active <child>\`) to continue.`));
334
374
  }
335
375
  return lines.join('\n') + '\n';
336
376
  }
337
377
 
378
+ // --- `runlist add` mutation (Phase 1) -------------------------------------
379
+
380
+ // Replace a top-level frontmatter field (its `key:` line + any indented
381
+ // continuation block) with `serialized`, or append it when absent. The regex
382
+ // mirrors what `mergeBodyFrontmatter` uses so the rewritten field keeps the
383
+ // scaffold's shape. Shared by the block-array (`runlist:`) and scalar
384
+ // (`parent_plan:`, `updated:`) writers below.
385
+ function upsertFrontmatterField(fm, key, serialized) {
386
+ const re = new RegExp(`^${escapeRegex(key)}:.*(\\n[ \\t]+.*)*`, 'm');
387
+ if (re.test(fm)) return fm.replace(re, serialized);
388
+ return fm.replace(/\s*$/, '') + '\n' + serialized;
389
+ }
390
+
391
+ function serializeBlockArray(key, items) {
392
+ if (items.length === 0) return `${key}:`;
393
+ return `${key}:\n${items.map(v => ` - ${v}`).join('\n')}`;
394
+ }
395
+
396
+ // Hub-relative ref for a child path: a bare basename when the child sits in the
397
+ // hub's directory (the common case, matching how `--runlist` writes refs), else
398
+ // a relative path (forward slashes) so the ref resolves from the hub. This is
399
+ // the hub-relative resolution that lets an existing plan elsewhere be wired in
400
+ // (the thrice-parked "point a hub at an existing plan" carryover).
401
+ function hubRelativeRef(childAbs, hubDir) {
402
+ const rel = path.relative(hubDir, childAbs).split(path.sep).join('/');
403
+ return rel;
404
+ }
405
+
406
+ // Resolve one `runlist add` child token against an existing hub. Returns one of:
407
+ // { kind: 'existing', abs, repoPath, ref } — token names a plan that exists
408
+ // { kind: 'scaffold', abs, repoPath, ref, slug, title } — bare slug to create
409
+ // Dies on an unresolvable path (a `/`-bearing token that points nowhere — we
410
+ // scaffold from bare slugs only, never invent a nested path).
411
+ function classifyChildToken(token, hubDir, hubSlug, pos, config) {
412
+ // Existing plan? Try hub-relative first (Phase 3 resolution), then the shared
413
+ // resolver (repo-relative / by-basename across the index).
414
+ const hubRel = resolveRefPath(token, hubDir, config.repoRoot)
415
+ || (token.endsWith('.md') ? null : resolveRefPath(`${token}.md`, hubDir, config.repoRoot));
416
+ const abs = hubRel || resolveDocArg(token, config, { dieOnMiss: false });
417
+ if (abs) {
418
+ return {
419
+ kind: 'existing',
420
+ abs,
421
+ repoPath: toRepoPath(abs, config.repoRoot),
422
+ ref: hubRelativeRef(abs, hubDir),
423
+ };
424
+ }
425
+
426
+ // Not found. Scaffold a stub — but only from a bare slug. A `/`-bearing token
427
+ // is a path to a file that doesn't exist; refuse rather than guess.
428
+ if (token.includes('/') || token.includes(path.sep)) {
429
+ die(`No plan found at "${token}". Pass a bare slug (e.g. \`cleanup\`) to scaffold a new child, or a path to an existing plan.`);
430
+ }
431
+ const childSlug = slugify(token.replace(/\.md$/, ''));
432
+ if (!childSlug) die(`Child token resolves to an empty slug: "${token}"`);
433
+ const nn = String(pos).padStart(2, '0');
434
+ const file = `${hubSlug}-${nn}-${childSlug}.md`;
435
+ const childAbs = path.join(hubDir, file);
436
+ return {
437
+ kind: 'scaffold',
438
+ abs: childAbs,
439
+ repoPath: toRepoPath(childAbs, config.repoRoot),
440
+ ref: file,
441
+ slug: childSlug,
442
+ title: titleize(token.replace(/\.md$/, '')),
443
+ };
444
+ }
445
+
446
+ // Set `parent_plan:` on an existing child to point back at the hub, unless it
447
+ // already resolves to the hub. Never clobbers a parent_plan that points
448
+ // elsewhere (warns instead — the child may belong to another hub). Returns
449
+ // true when it wrote, false when it left the file alone.
450
+ function setChildParentPlan(childAbs, hubAbs, config, { dryRun }) {
451
+ const raw = readFileSync(childAbs, 'utf8');
452
+ const { frontmatter: fmRaw } = extractFrontmatter(raw);
453
+ if (fmRaw == null) return false;
454
+ const fm = parseSimpleFrontmatter(fmRaw);
455
+ const childDir = path.dirname(childAbs);
456
+ const existing = asString(fm.parent_plan);
457
+ if (existing) {
458
+ const resolved = resolveRefPath(existing, childDir, config.repoRoot);
459
+ if (resolved === hubAbs) return false; // already points at this hub
460
+ warn(`${toRepoPath(childAbs, config.repoRoot)} already has parent_plan: ${existing} — left as-is (not pointing it at the hub).`);
461
+ return false;
462
+ }
463
+ const ref = path.relative(childDir, hubAbs).split(path.sep).join('/');
464
+ if (dryRun) return true;
465
+ let newFm = upsertFrontmatterField(fmRaw, 'parent_plan', `parent_plan: ${ref}`);
466
+ newFm = upsertFrontmatterField(newFm, 'updated', `updated: ${nowIso()}`);
467
+ writeFileSync(childAbs, replaceFrontmatter(raw, newFm), 'utf8');
468
+ return true;
469
+ }
470
+
471
+ // `dotmd runlist add <hub> <child...>` — append children to a hub's `runlist:`
472
+ // array, scaffolding a `planned` stub for any bare-slug child that doesn't yet
473
+ // exist (mirroring `dotmd new plan --runlist`) and wiring each child's
474
+ // `parent_plan:` back-ref. Coordination hubs (body-order, no `runlist:` array)
475
+ // are out of this path — guarded with an actionable message.
476
+ async function runRunlistAdd(positional, config, { dryRun, json }) {
477
+ const hubInput = positional[0];
478
+ const childTokens = positional.slice(1);
479
+ if (!hubInput || childTokens.length === 0) {
480
+ die('Usage: dotmd runlist add <hub-plan> <child...> (one or more child slugs or plan paths)');
481
+ }
482
+
483
+ const hubAbs = resolveHubInput(hubInput, config);
484
+ if (!hubAbs) die(`Hub plan not found: ${hubInput}`);
485
+ const hubRepoPath = toRepoPath(hubAbs, config.repoRoot);
486
+ const hubDir = path.dirname(hubAbs);
487
+ const hubSlug = path.basename(hubAbs, '.md');
488
+
489
+ const hubRaw = readFileSync(hubAbs, 'utf8');
490
+ const { frontmatter: hubFmRaw } = extractFrontmatter(hubRaw);
491
+ if (hubFmRaw == null) die(`Hub ${hubRepoPath} has no frontmatter.`);
492
+ const hubFm = parseSimpleFrontmatter(hubFmRaw);
493
+ const existingRefs = normalizeStringList(hubFm.runlist);
494
+ const isCoord = hubFm.execution_mode === 'coordination';
495
+
496
+ // Coordination hubs keep their order in the body (`## Ranked queue` /
497
+ // `## Order of operations`), not a `runlist:` array. Mutating that prose-first
498
+ // order is a separate path; for now point the user at it rather than writing a
499
+ // `runlist:` array onto a hub that deliberately doesn't use one.
500
+ if (isCoord && existingRefs.length === 0) {
501
+ die(
502
+ `${hubRepoPath} is a coordination hub (execution_mode: coordination) — it keeps its order in the body\n` +
503
+ `(\`## Ranked queue\` table or \`## Order of operations\` list), not a \`runlist:\` array.\n` +
504
+ `Add the plan as a ranked row/link there. \`runlist add\` manages sprint \`runlist:\` arrays.`,
505
+ );
506
+ }
507
+
508
+ // Resolve every token up front (so a bad token aborts before any write), then
509
+ // dedupe against refs already in the runlist (by resolved abs path).
510
+ const resolvedExisting = new Set();
511
+ for (const ref of existingRefs) {
512
+ const abs = resolveRefPath(ref, hubDir, config.repoRoot);
513
+ if (abs) resolvedExisting.add(abs);
514
+ }
515
+
516
+ const toAdd = [];
517
+ let pos = existingRefs.length;
518
+ for (const token of childTokens) {
519
+ pos += 1;
520
+ const c = classifyChildToken(token, hubDir, hubSlug, pos, config);
521
+ if (c.abs === hubAbs) { warn(`Skipping "${token}" — a hub can't list itself.`); pos -= 1; continue; }
522
+ if (resolvedExisting.has(c.abs)) { warn(`Skipping "${token}" — already in the runlist (${c.repoPath}).`); pos -= 1; continue; }
523
+ resolvedExisting.add(c.abs);
524
+ toAdd.push(c);
525
+ }
526
+ if (toAdd.length === 0) die('Nothing to add — all children were already in the runlist or skipped.');
527
+
528
+ const hubTitle = asString(hubFm.title) ?? titleize(hubSlug);
529
+ const today = nowIso();
530
+ const newRefs = [...existingRefs, ...toAdd.map(c => c.ref)];
531
+
532
+ if (json) {
533
+ process.stdout.write(JSON.stringify({
534
+ hub: hubRepoPath,
535
+ added: toAdd.map(c => ({ ref: c.ref, path: c.repoPath, scaffolded: c.kind === 'scaffold' })),
536
+ runlist: newRefs,
537
+ dryRun: !!dryRun,
538
+ }, null, 2) + '\n');
539
+ if (dryRun) return;
540
+ }
541
+
542
+ const prefix = dryRun ? `${dim('[dry-run]')} ` : '';
543
+ if (!json) process.stdout.write(bold(`${prefix}runlist add → ${hubRepoPath}`) + '\n');
544
+
545
+ for (const c of toAdd) {
546
+ if (c.kind === 'scaffold') {
547
+ if (!dryRun) {
548
+ if (existsSync(c.abs)) {
549
+ warn(`Child already exists, left as-is: ${c.repoPath}`);
550
+ } else {
551
+ writeFileSync(c.abs, runlistChildContent(c.title, hubSlug, hubTitle, 'planned', today), 'utf8');
552
+ }
553
+ }
554
+ if (!json) process.stdout.write(`${prefix} ${green('+')} ${c.repoPath} ${dim('(scaffolded · planned)')}\n`);
555
+ } else {
556
+ const wrote = setChildParentPlan(c.abs, hubAbs, config, { dryRun });
557
+ const note = wrote ? 'existing · parent_plan set' : 'existing';
558
+ if (!json) process.stdout.write(`${prefix} ${green('+')} ${c.repoPath} ${dim(`(${note})`)}\n`);
559
+ }
560
+ }
561
+
562
+ // Write the hub's `runlist:` array (+ bump `updated:`, + sync the body order
563
+ // list). The child stubs are already on disk, so title resolution works.
564
+ if (!dryRun) writeHubRunlist(hubAbs, newRefs, config, today);
565
+
566
+ if (!json) {
567
+ process.stdout.write(dim(` runlist now has ${newRefs.length} ${newRefs.length === 1 ? 'child' : 'children'}.`) + '\n');
568
+ if (!dryRun) {
569
+ process.stdout.write(dim(` Show: dotmd runlist ${hubRepoPath} · pick up next: dotmd runlist next ${hubRepoPath}`) + '\n');
570
+ }
571
+ }
572
+ }
573
+
574
+ // --- `runlist remove` / `runlist reorder` mutation (Phase 2) ---------------
575
+
576
+ // Heading alternatives that carry a `## Order of operations`-style link list
577
+ // mirroring the `runlist:` array (the `new plan --runlist` scaffold writes one).
578
+ const ORDER_SECTION_RE = /^##\s+(?:Order of operations|Runlist|Execution order|Implementation order|Plan order)\b.*$/im;
579
+
580
+ // Keep a hub's body `## Order of operations` link list in sync with the
581
+ // authoritative `runlist:` array. Regenerates the numbered link block from
582
+ // `orderedRefs`, preserving each item's display title and trailing status
583
+ // marker (⬜/✅/…) for children that remain — so a hand-checked-off item keeps
584
+ // its mark across an add/remove/reorder. No such section → body untouched (we
585
+ // never invent one). Only the contiguous run of list-item lines is rewritten;
586
+ // surrounding prose (e.g. the "pick up the next child" note) is preserved.
587
+ function syncOrderList(body, orderedRefs, titleFor) {
588
+ const m = body.match(ORDER_SECTION_RE);
589
+ if (!m || m.index === undefined) return body;
590
+ const headingEnd = m.index + m[0].length;
591
+ const rest = body.slice(headingEnd);
592
+ const nextRel = rest.search(/^##\s+/m);
593
+ const sectionEnd = nextRel >= 0 ? headingEnd + nextRel : body.length;
594
+
595
+ const lines = body.slice(headingEnd, sectionEnd).split('\n');
596
+ const isItem = (l) => /^\s*(?:\d+\.|[-*])\s+/.test(l) && /\.md(?:[#)\s]|$)/.test(l);
597
+ let start = -1, end = -1;
598
+ for (let i = 0; i < lines.length; i++) {
599
+ if (isItem(lines[i])) { if (start === -1) start = i; end = i; }
600
+ else if (start !== -1) break; // first non-item after the block ends it
601
+ }
602
+ if (start === -1) return body; // section present but no list to sync
603
+
604
+ const linkRe = /\[([^\]]+)\]\(([^)]+\.md)(?:#[^)]*)?\)/;
605
+ const prior = new Map(); // basename → { title, marker }
606
+ for (let i = start; i <= end; i++) {
607
+ const lk = linkRe.exec(lines[i]);
608
+ if (lk) {
609
+ prior.set(lk[2].split('/').pop(), { title: lk[1], marker: lines[i].slice(lk.index + lk[0].length).trim() });
610
+ } else {
611
+ const bare = /(\S+\.md)/.exec(lines[i]);
612
+ if (bare) prior.set(bare[1].split('/').pop(), { title: null, marker: lines[i].slice(bare.index + bare[0].length).trim() });
613
+ }
614
+ }
615
+
616
+ const rebuilt = orderedRefs.map((ref, i) => {
617
+ const prev = prior.get(ref.split('/').pop());
618
+ const title = prev?.title ?? titleFor(ref);
619
+ const marker = prev?.marker ? ` ${prev.marker}` : ' ⬜';
620
+ return `${i + 1}. [${title}](${ref})${marker}`;
621
+ });
622
+
623
+ const newLines = [...lines.slice(0, start), ...rebuilt, ...lines.slice(end + 1)];
624
+ return body.slice(0, headingEnd) + newLines.join('\n') + body.slice(sectionEnd);
625
+ }
626
+
627
+ // Authoritative hub write: set `runlist:` to `newRefs`, bump `updated:`, and
628
+ // resync the body order list. Shared by add/remove/reorder so all three keep
629
+ // the frontmatter array and the body link list consistent.
630
+ function writeHubRunlist(hubAbs, newRefs, config, today) {
631
+ const raw = readFileSync(hubAbs, 'utf8');
632
+ const { frontmatter: fmRaw, body } = extractFrontmatter(raw);
633
+ let newFm = upsertFrontmatterField(fmRaw, 'runlist', serializeBlockArray('runlist', newRefs));
634
+ newFm = upsertFrontmatterField(newFm, 'updated', `updated: ${today}`);
635
+
636
+ const titles = new Map();
637
+ for (const r of resolveRunlistRefs(newRefs, hubAbs, config)) {
638
+ if (r.title) titles.set(r.ref.split('/').pop(), r.title);
639
+ }
640
+ const titleFor = (ref) => titles.get(ref.split('/').pop()) ?? titleize(path.basename(ref, '.md'));
641
+ const newBody = syncOrderList(body, newRefs, titleFor);
642
+
643
+ writeFileSync(hubAbs, `---\n${newFm}\n---\n${newBody}`, 'utf8');
644
+ }
645
+
646
+ // Match a child token to one of the hub's existing runlist refs. Resolves
647
+ // hub-relative (then by slug/basename across the index), then compares against
648
+ // each ref's resolved abs path, with a basename fallback for refs that don't
649
+ // resolve to a file. Returns the matched ref string, or null.
650
+ function findRefForToken(token, existingRefs, hubDir, config) {
651
+ const tokenAbs = resolveRefPath(token, hubDir, config.repoRoot)
652
+ || (token.endsWith('.md') ? null : resolveRefPath(`${token}.md`, hubDir, config.repoRoot))
653
+ || resolveDocArg(token, config, { dieOnMiss: false });
654
+ const tokBase = token.endsWith('.md') ? token.split('/').pop() : `${token.split('/').pop()}.md`;
655
+ // Exact: resolved-path match or basename match.
656
+ for (const ref of existingRefs) {
657
+ const refAbs = resolveRefPath(ref, hubDir, config.repoRoot);
658
+ if (tokenAbs && refAbs && refAbs === tokenAbs) return ref;
659
+ if (ref.split('/').pop() === tokBase) return ref;
660
+ }
661
+ // Convenience: the short slug a sprint child was scaffolded from — e.g.
662
+ // `cleanup` matches `auth-revamp-03-cleanup.md`. Unique-or-bust so an
663
+ // ambiguous slug never silently picks the wrong child.
664
+ const slug = tokBase.replace(/\.md$/, '');
665
+ const suffixMatches = existingRefs.filter(ref => {
666
+ const base = ref.split('/').pop().replace(/\.md$/, '');
667
+ return base === slug || base.endsWith(`-${slug}`);
668
+ });
669
+ if (suffixMatches.length === 1) return suffixMatches[0];
670
+ if (suffixMatches.length > 1) {
671
+ die(`"${token}" matches multiple children: ${suffixMatches.join(', ')}. Use the full filename.`);
672
+ }
673
+ return null;
674
+ }
675
+
676
+ // Clear a removed child's `parent_plan:` when it points back at this hub (so the
677
+ // reverse link doesn't dangle). Leaves a parent_plan pointing elsewhere alone.
678
+ function clearChildParentPlan(childAbs, hubAbs, config, { dryRun }) {
679
+ let raw;
680
+ try { raw = readFileSync(childAbs, 'utf8'); } catch { return false; }
681
+ const { frontmatter: fmRaw } = extractFrontmatter(raw);
682
+ if (fmRaw == null) return false;
683
+ const fm = parseSimpleFrontmatter(fmRaw);
684
+ const existing = asString(fm.parent_plan);
685
+ if (!existing) return false;
686
+ if (resolveRefPath(existing, path.dirname(childAbs), config.repoRoot) !== hubAbs) return false;
687
+ if (dryRun) return true;
688
+ let newFm = upsertFrontmatterField(fmRaw, 'parent_plan', 'parent_plan:');
689
+ newFm = upsertFrontmatterField(newFm, 'updated', `updated: ${nowIso()}`);
690
+ writeFileSync(childAbs, replaceFrontmatter(raw, newFm), 'utf8');
691
+ return true;
692
+ }
693
+
694
+ // Shared front half of remove/reorder: resolve the hub, read its `runlist:`,
695
+ // die if it isn't a sprint hub with an array to mutate.
696
+ function loadSprintHub(hubInput, verb, config) {
697
+ if (!hubInput) die(`Usage: dotmd runlist ${verb} <hub-plan> <child...>`);
698
+ const hubAbs = resolveHubInput(hubInput, config);
699
+ if (!hubAbs) die(`Hub plan not found: ${hubInput}`);
700
+ const hubRepoPath = toRepoPath(hubAbs, config.repoRoot);
701
+ const hubDir = path.dirname(hubAbs);
702
+ const raw = readFileSync(hubAbs, 'utf8');
703
+ const { frontmatter: fmRaw } = extractFrontmatter(raw);
704
+ const fm = fmRaw == null ? {} : parseSimpleFrontmatter(fmRaw);
705
+ const existingRefs = normalizeStringList(fm.runlist);
706
+ if (existingRefs.length === 0) {
707
+ die(`${hubRepoPath} has no \`runlist:\` array to ${verb} from.` +
708
+ (fm.execution_mode === 'coordination' ? ' (It is a coordination hub — order lives in the body.)' : ''));
709
+ }
710
+ return { hubAbs, hubRepoPath, hubDir, existingRefs };
711
+ }
712
+
713
+ async function runRunlistRemove(positional, config, { dryRun, json, clearParent }) {
714
+ const { hubAbs, hubRepoPath, hubDir, existingRefs } = loadSprintHub(positional[0], 'remove', config);
715
+ const childTokens = positional.slice(1);
716
+ if (childTokens.length === 0) die('Usage: dotmd runlist remove <hub-plan> <child...>');
717
+
718
+ const removeRefs = [];
719
+ for (const token of childTokens) {
720
+ const ref = findRefForToken(token, existingRefs, hubDir, config);
721
+ if (!ref) die(`"${token}" is not in the runlist of ${hubRepoPath}.`);
722
+ if (!removeRefs.includes(ref)) removeRefs.push(ref);
723
+ }
724
+ const newRefs = existingRefs.filter(r => !removeRefs.includes(r));
725
+ const today = nowIso();
726
+
727
+ if (json) {
728
+ process.stdout.write(JSON.stringify({ hub: hubRepoPath, removed: removeRefs, runlist: newRefs, clearedParent: !!clearParent, dryRun: !!dryRun }, null, 2) + '\n');
729
+ } else {
730
+ process.stdout.write(bold(`${dryRun ? dim('[dry-run]') + ' ' : ''}runlist remove → ${hubRepoPath}`) + '\n');
731
+ for (const ref of removeRefs) process.stdout.write(`${dryRun ? dim('[dry-run]') + ' ' : ''} ${red('-')} ${ref}\n`);
732
+ }
733
+
734
+ if (!dryRun) writeHubRunlist(hubAbs, newRefs, config, today);
735
+ if (clearParent) {
736
+ for (const ref of removeRefs) {
737
+ const abs = resolveRefPath(ref, hubDir, config.repoRoot);
738
+ if (abs && clearChildParentPlan(abs, hubAbs, config, { dryRun }) && !json) {
739
+ process.stdout.write(`${dryRun ? dim('[dry-run]') + ' ' : ''} ${dim(`cleared parent_plan on ${toRepoPath(abs, config.repoRoot)}`)}\n`);
740
+ }
741
+ }
742
+ }
743
+ if (!json) process.stdout.write(dim(` runlist now has ${newRefs.length} ${newRefs.length === 1 ? 'child' : 'children'}.`) + '\n');
744
+ }
745
+
746
+ // Parse `reorder` argv: skip the subcommand + flags, capture `--before`/`--after`
747
+ // values (their operands would otherwise leak into the child list).
748
+ function parseReorderArgs(argv) {
749
+ const pos = [];
750
+ let before = null, after = null;
751
+ for (let i = 0; i < argv.length; i++) {
752
+ const a = argv[i];
753
+ if (a === 'reorder') continue;
754
+ if (a === '--before') { before = argv[++i] ?? null; continue; }
755
+ if (a === '--after') { after = argv[++i] ?? null; continue; }
756
+ if (a.startsWith('-')) continue;
757
+ pos.push(a);
758
+ }
759
+ return { hubInput: pos[0], children: pos.slice(1), before, after };
760
+ }
761
+
762
+ async function runRunlistReorder(argv, config, { dryRun, json }) {
763
+ const { hubInput, children, before, after } = parseReorderArgs(argv);
764
+ if (!hubInput || children.length === 0) {
765
+ die('Usage: dotmd runlist reorder <hub> <child> --before|--after <other>\n or: dotmd runlist reorder <hub> <child1> <child2> ... (full new order)');
766
+ }
767
+ const { hubAbs, hubRepoPath, hubDir, existingRefs } = loadSprintHub(hubInput, 'reorder', config);
768
+
769
+ let newRefs;
770
+ if (before || after) {
771
+ if (children.length !== 1) die('--before/--after move exactly one child. Pass a single child, or list every child for a full reorder.');
772
+ const moving = findRefForToken(children[0], existingRefs, hubDir, config);
773
+ if (!moving) die(`"${children[0]}" is not in the runlist of ${hubRepoPath}.`);
774
+ const anchorTok = before || after;
775
+ const anchor = findRefForToken(anchorTok, existingRefs, hubDir, config);
776
+ if (!anchor) die(`"${anchorTok}" is not in the runlist of ${hubRepoPath}.`);
777
+ if (moving === anchor) die('A child cannot be moved relative to itself.');
778
+ const without = existingRefs.filter(r => r !== moving);
779
+ const idx = without.indexOf(anchor);
780
+ const insertAt = before ? idx : idx + 1;
781
+ newRefs = [...without.slice(0, insertAt), moving, ...without.slice(insertAt)];
782
+ } else {
783
+ if (children.length !== existingRefs.length) {
784
+ die(`Full reorder needs all ${existingRefs.length} children in the new order; got ${children.length}. Use --before/--after to move just one.`);
785
+ }
786
+ newRefs = children.map(tok => {
787
+ const ref = findRefForToken(tok, existingRefs, hubDir, config);
788
+ if (!ref) die(`"${tok}" is not in the runlist of ${hubRepoPath}.`);
789
+ return ref;
790
+ });
791
+ if (new Set(newRefs).size !== newRefs.length) die('The new order repeats a child — list each exactly once.');
792
+ }
793
+
794
+ if (newRefs.join('\n') === existingRefs.join('\n')) die('New order matches the current order — nothing to do.');
795
+ const today = nowIso();
796
+
797
+ if (json) {
798
+ process.stdout.write(JSON.stringify({ hub: hubRepoPath, runlist: newRefs, dryRun: !!dryRun }, null, 2) + '\n');
799
+ } else {
800
+ process.stdout.write(bold(`${dryRun ? dim('[dry-run]') + ' ' : ''}runlist reorder → ${hubRepoPath}`) + '\n');
801
+ newRefs.forEach((ref, i) => process.stdout.write(`${dryRun ? dim('[dry-run]') + ' ' : ''} ${String(i + 1).padStart(2)}. ${ref}\n`));
802
+ }
803
+ if (!dryRun) writeHubRunlist(hubAbs, newRefs, config, today);
804
+ }
805
+
338
806
  export async function runRunlist(argv, config, opts = {}) {
339
807
  const json = argv.includes('--json');
340
808
  const positional = argv.filter(a => !a.startsWith('-'));
341
809
 
342
- // Subcommand dispatch: `runlist <hub>` (show) vs `runlist next <hub>` (pickup)
810
+ // Subcommand dispatch: mutators (`add`/`remove`/`reorder`) vs `next` (pickup)
811
+ // vs `show` (default).
812
+ if (positional[0] === 'add') {
813
+ return runRunlistAdd(positional.slice(1), config, { dryRun: opts.dryRun, json });
814
+ }
815
+ if (positional[0] === 'remove') {
816
+ return runRunlistRemove(positional.slice(1), config, { dryRun: opts.dryRun, json, clearParent: argv.includes('--clear-parent') });
817
+ }
818
+ if (positional[0] === 'reorder') {
819
+ return runRunlistReorder(argv, config, { dryRun: opts.dryRun, json });
820
+ }
821
+
343
822
  const sub = positional[0] === 'next' ? 'next' : 'show';
344
823
  const hubInput = sub === 'next' ? positional[1] : positional[0];
345
824
 
@@ -370,11 +849,29 @@ export async function runRunlist(argv, config, opts = {}) {
370
849
  return;
371
850
  }
372
851
 
373
- // sub === 'next' — find first non-archived non-missing child and pick it up.
374
- const target = children.find(c => !c.missing && !archiveStatuses.has(c.status));
852
+ // sub === 'next' — pick up the first child a session can actually start.
853
+ // Skip both archived (done) and parked (blocked/partial/paused/awaiting/
854
+ // queued-after) children: the runlist advances to the first pickup-able one,
855
+ // so the picked target is guaranteed in a `dotmd use`-able status.
856
+ const target = children.find(c => !c.missing && !archiveStatuses.has(c.status) && isPickupable(c.status));
375
857
  if (!target) {
376
858
  if (children.length === 0) die(`Hub ${hubRepoPath} has empty \`runlist:\` — nothing to pick up.`);
377
- const allArchived = children.every(c => !c.missing && archiveStatuses.has(c.status));
859
+ // Live (non-archived, non-missing) children that exist but aren't startable.
860
+ const parked = children.filter(c => !c.missing && !archiveStatuses.has(c.status));
861
+ if (parked.length > 0) {
862
+ // Every remaining child is parked — surface them with statuses + the
863
+ // unstick verbs so the agent can resume one instead of being told a
864
+ // generic "no pickup" with no path forward.
865
+ const listed = parked.map(c => ` ${c.path} (status: ${c.status})`).join('\n');
866
+ die(
867
+ `No pickup-able child in runlist ${hubRepoPath} — every remaining child is parked:\n` +
868
+ `${listed}\n` +
869
+ `Unstick one before continuing:\n` +
870
+ ` dotmd set active <child> # if ready to resume\n` +
871
+ ` dotmd use <child> # to inspect`,
872
+ );
873
+ }
874
+ const allArchived = children.some(c => !c.missing) && children.every(c => c.missing || archiveStatuses.has(c.status));
378
875
  if (allArchived) {
379
876
  die(`All children in runlist ${hubRepoPath} are archived. Hub is ready for \`dotmd archive ${hubRepoPath}\`.`);
380
877
  }
@@ -382,18 +879,6 @@ export async function runRunlist(argv, config, opts = {}) {
382
879
  die(`No pickup-able child in runlist ${hubRepoPath}. Unresolved refs: ${missing.join(', ')}`);
383
880
  }
384
881
 
385
- // Pre-check status: pickup will die on non-pickup-able statuses, but with
386
- // a generic message. Surface the runlist context first so the agent knows
387
- // which list is blocked and on which item.
388
- if (!PICKUPABLE_STATUSES.has(target.status)) {
389
- die(
390
- `Next child in runlist ${hubRepoPath} is ${target.path} (status: ${target.status}).\n` +
391
- `Resolve the blocker before continuing the runlist.\n` +
392
- ` dotmd set active ${target.path} # if ready to resume\n` +
393
- ` dotmd use ${target.path} # to inspect`,
394
- );
395
- }
396
-
397
882
  // Open the next child: set it in-session (frontmatter) and render its card.
398
883
  // Dynamic import to avoid circular module-load cost when the runlist command
399
884
  // isn't used.