dotmd-cli 0.64.3 → 0.65.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
@@ -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,7 +1212,7 @@ 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;
@@ -1225,6 +1225,29 @@ Usage:
1225
1225
  in-session + prints it). Stops if it's not in a
1226
1226
  workable status (active / planned / in-session)
1227
1227
  so you resolve the blocker first.
1228
+ dotmd runlist add <hub> <child...>
1229
+ Append children to the hub's \`runlist:\` array
1230
+ (no more hand-editing the YAML). Each child can be:
1231
+ • a bare slug (\`cleanup\`) → scaffolds a
1232
+ \`planned\` stub \`<hub>-NN-<slug>.md\` next to
1233
+ the hub (mirrors \`new plan --runlist\`), or
1234
+ • a path/slug of an existing plan → wired in by a
1235
+ hub-relative ref, with its \`parent_plan:\` set
1236
+ back at the hub.
1237
+ A plain plan gains a \`runlist:\` (becomes a hub).
1238
+ Coordination hubs (body-order) aren't handled here.
1239
+ dotmd runlist remove <hub> <child...>
1240
+ Drop children from the \`runlist:\` array. Children
1241
+ match by full path or short slug (\`cleanup\` finds
1242
+ \`<hub>-03-cleanup.md\`). \`--clear-parent\` also blanks
1243
+ each removed child's \`parent_plan:\` back-ref.
1244
+ dotmd runlist reorder <hub> <child> --before|--after <other>
1245
+ dotmd runlist reorder <hub> <c1> <c2> <c3...>
1246
+ Move one child relative to another, or pass every
1247
+ child to set a full new order.
1248
+ All three mutators take \`--dry-run\` / \`--json\` and
1249
+ keep any body \`## Order of operations\` link list in
1250
+ sync (preserving per-item ⬜/✅ markers).
1228
1251
 
1229
1252
  Flags (only meaningful with \`next\`):
1230
1253
  --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.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/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
  }
@@ -760,8 +781,15 @@ function renderHubBlock(hub, info, children, maxWidth, topMaxSlug) {
760
781
  process.stdout.write(header + '\n');
761
782
 
762
783
  if (children.length === 0) return;
784
+ // Render rows in runlist order (info.children), not the docs view's recency
785
+ // sort — a runlist reads in sequence, and a just-added child shouldn't jump to
786
+ // the top because its mtime is newest. Only children present in the current
787
+ // view are shown; any not tracked by the runlist index trail at the end.
788
+ const byPath = new Map(children.map(c => [c.path, c]));
789
+ const ordered = info.children.map(ic => byPath.get(ic.path)).filter(Boolean);
790
+ for (const c of children) if (!ordered.includes(c)) ordered.push(c);
763
791
  const childMaxSlug = Math.min(28, Math.max(...children.map(c => stripHubPrefix(toSlug(c), hubSlug).length)));
764
- for (const c of children) {
792
+ for (const c of ordered) {
765
793
  const isNext = c.path === info.nextChildPath;
766
794
  const indent = isNext ? ` ${green('→')} ` : ' ';
767
795
  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,16 +1,21 @@
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']);
@@ -164,13 +169,16 @@ function resolveRunlistRefs(refs, hubAbsPath, config) {
164
169
  const repoPath = toRepoPath(abs, config.repoRoot);
165
170
  try {
166
171
  const childRaw = readFileSync(abs, 'utf8');
167
- const { frontmatter: childFmRaw } = extractFrontmatter(childRaw);
172
+ const { frontmatter: childFmRaw, body: childBody } = extractFrontmatter(childRaw);
168
173
  const childFm = parseSimpleFrontmatter(childFmRaw);
169
174
  out.push({
170
175
  ref,
171
176
  path: repoPath,
172
177
  status: asString(childFm.status) ?? null,
173
- title: asString(childFm.title) ?? path.basename(abs, '.md'),
178
+ // Fall back to the body H1 (like the main index) before the bare
179
+ // filename — runlist child stubs carry their title as an H1, not a
180
+ // `title:` field, so this keeps the synced body order list readable.
181
+ title: asString(childFm.title) ?? extractFirstHeading(childBody) ?? path.basename(abs, '.md'),
174
182
  parentPlan: childFm.parent_plan ?? null,
175
183
  missing: false,
176
184
  });
@@ -335,11 +343,450 @@ function renderRunlist(hubRepoPath, children, opts = {}) {
335
343
  return lines.join('\n') + '\n';
336
344
  }
337
345
 
346
+ // --- `runlist add` mutation (Phase 1) -------------------------------------
347
+
348
+ // Replace a top-level frontmatter field (its `key:` line + any indented
349
+ // continuation block) with `serialized`, or append it when absent. The regex
350
+ // mirrors what `mergeBodyFrontmatter` uses so the rewritten field keeps the
351
+ // scaffold's shape. Shared by the block-array (`runlist:`) and scalar
352
+ // (`parent_plan:`, `updated:`) writers below.
353
+ function upsertFrontmatterField(fm, key, serialized) {
354
+ const re = new RegExp(`^${escapeRegex(key)}:.*(\\n[ \\t]+.*)*`, 'm');
355
+ if (re.test(fm)) return fm.replace(re, serialized);
356
+ return fm.replace(/\s*$/, '') + '\n' + serialized;
357
+ }
358
+
359
+ function serializeBlockArray(key, items) {
360
+ if (items.length === 0) return `${key}:`;
361
+ return `${key}:\n${items.map(v => ` - ${v}`).join('\n')}`;
362
+ }
363
+
364
+ // Hub-relative ref for a child path: a bare basename when the child sits in the
365
+ // hub's directory (the common case, matching how `--runlist` writes refs), else
366
+ // a relative path (forward slashes) so the ref resolves from the hub. This is
367
+ // the hub-relative resolution that lets an existing plan elsewhere be wired in
368
+ // (the thrice-parked "point a hub at an existing plan" carryover).
369
+ function hubRelativeRef(childAbs, hubDir) {
370
+ const rel = path.relative(hubDir, childAbs).split(path.sep).join('/');
371
+ return rel;
372
+ }
373
+
374
+ // Resolve one `runlist add` child token against an existing hub. Returns one of:
375
+ // { kind: 'existing', abs, repoPath, ref } — token names a plan that exists
376
+ // { kind: 'scaffold', abs, repoPath, ref, slug, title } — bare slug to create
377
+ // Dies on an unresolvable path (a `/`-bearing token that points nowhere — we
378
+ // scaffold from bare slugs only, never invent a nested path).
379
+ function classifyChildToken(token, hubDir, hubSlug, pos, config) {
380
+ // Existing plan? Try hub-relative first (Phase 3 resolution), then the shared
381
+ // resolver (repo-relative / by-basename across the index).
382
+ const hubRel = resolveRefPath(token, hubDir, config.repoRoot)
383
+ || (token.endsWith('.md') ? null : resolveRefPath(`${token}.md`, hubDir, config.repoRoot));
384
+ const abs = hubRel || resolveDocArg(token, config, { dieOnMiss: false });
385
+ if (abs) {
386
+ return {
387
+ kind: 'existing',
388
+ abs,
389
+ repoPath: toRepoPath(abs, config.repoRoot),
390
+ ref: hubRelativeRef(abs, hubDir),
391
+ };
392
+ }
393
+
394
+ // Not found. Scaffold a stub — but only from a bare slug. A `/`-bearing token
395
+ // is a path to a file that doesn't exist; refuse rather than guess.
396
+ if (token.includes('/') || token.includes(path.sep)) {
397
+ 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.`);
398
+ }
399
+ const childSlug = slugify(token.replace(/\.md$/, ''));
400
+ if (!childSlug) die(`Child token resolves to an empty slug: "${token}"`);
401
+ const nn = String(pos).padStart(2, '0');
402
+ const file = `${hubSlug}-${nn}-${childSlug}.md`;
403
+ const childAbs = path.join(hubDir, file);
404
+ return {
405
+ kind: 'scaffold',
406
+ abs: childAbs,
407
+ repoPath: toRepoPath(childAbs, config.repoRoot),
408
+ ref: file,
409
+ slug: childSlug,
410
+ title: titleize(token.replace(/\.md$/, '')),
411
+ };
412
+ }
413
+
414
+ // Set `parent_plan:` on an existing child to point back at the hub, unless it
415
+ // already resolves to the hub. Never clobbers a parent_plan that points
416
+ // elsewhere (warns instead — the child may belong to another hub). Returns
417
+ // true when it wrote, false when it left the file alone.
418
+ function setChildParentPlan(childAbs, hubAbs, config, { dryRun }) {
419
+ const raw = readFileSync(childAbs, 'utf8');
420
+ const { frontmatter: fmRaw } = extractFrontmatter(raw);
421
+ if (fmRaw == null) return false;
422
+ const fm = parseSimpleFrontmatter(fmRaw);
423
+ const childDir = path.dirname(childAbs);
424
+ const existing = asString(fm.parent_plan);
425
+ if (existing) {
426
+ const resolved = resolveRefPath(existing, childDir, config.repoRoot);
427
+ if (resolved === hubAbs) return false; // already points at this hub
428
+ warn(`${toRepoPath(childAbs, config.repoRoot)} already has parent_plan: ${existing} — left as-is (not pointing it at the hub).`);
429
+ return false;
430
+ }
431
+ const ref = path.relative(childDir, hubAbs).split(path.sep).join('/');
432
+ if (dryRun) return true;
433
+ let newFm = upsertFrontmatterField(fmRaw, 'parent_plan', `parent_plan: ${ref}`);
434
+ newFm = upsertFrontmatterField(newFm, 'updated', `updated: ${nowIso()}`);
435
+ writeFileSync(childAbs, replaceFrontmatter(raw, newFm), 'utf8');
436
+ return true;
437
+ }
438
+
439
+ // `dotmd runlist add <hub> <child...>` — append children to a hub's `runlist:`
440
+ // array, scaffolding a `planned` stub for any bare-slug child that doesn't yet
441
+ // exist (mirroring `dotmd new plan --runlist`) and wiring each child's
442
+ // `parent_plan:` back-ref. Coordination hubs (body-order, no `runlist:` array)
443
+ // are out of this path — guarded with an actionable message.
444
+ async function runRunlistAdd(positional, config, { dryRun, json }) {
445
+ const hubInput = positional[0];
446
+ const childTokens = positional.slice(1);
447
+ if (!hubInput || childTokens.length === 0) {
448
+ die('Usage: dotmd runlist add <hub-plan> <child...> (one or more child slugs or plan paths)');
449
+ }
450
+
451
+ const hubAbs = resolveHubInput(hubInput, config);
452
+ if (!hubAbs) die(`Hub plan not found: ${hubInput}`);
453
+ const hubRepoPath = toRepoPath(hubAbs, config.repoRoot);
454
+ const hubDir = path.dirname(hubAbs);
455
+ const hubSlug = path.basename(hubAbs, '.md');
456
+
457
+ const hubRaw = readFileSync(hubAbs, 'utf8');
458
+ const { frontmatter: hubFmRaw } = extractFrontmatter(hubRaw);
459
+ if (hubFmRaw == null) die(`Hub ${hubRepoPath} has no frontmatter.`);
460
+ const hubFm = parseSimpleFrontmatter(hubFmRaw);
461
+ const existingRefs = normalizeStringList(hubFm.runlist);
462
+ const isCoord = hubFm.execution_mode === 'coordination';
463
+
464
+ // Coordination hubs keep their order in the body (`## Ranked queue` /
465
+ // `## Order of operations`), not a `runlist:` array. Mutating that prose-first
466
+ // order is a separate path; for now point the user at it rather than writing a
467
+ // `runlist:` array onto a hub that deliberately doesn't use one.
468
+ if (isCoord && existingRefs.length === 0) {
469
+ die(
470
+ `${hubRepoPath} is a coordination hub (execution_mode: coordination) — it keeps its order in the body\n` +
471
+ `(\`## Ranked queue\` table or \`## Order of operations\` list), not a \`runlist:\` array.\n` +
472
+ `Add the plan as a ranked row/link there. \`runlist add\` manages sprint \`runlist:\` arrays.`,
473
+ );
474
+ }
475
+
476
+ // Resolve every token up front (so a bad token aborts before any write), then
477
+ // dedupe against refs already in the runlist (by resolved abs path).
478
+ const resolvedExisting = new Set();
479
+ for (const ref of existingRefs) {
480
+ const abs = resolveRefPath(ref, hubDir, config.repoRoot);
481
+ if (abs) resolvedExisting.add(abs);
482
+ }
483
+
484
+ const toAdd = [];
485
+ let pos = existingRefs.length;
486
+ for (const token of childTokens) {
487
+ pos += 1;
488
+ const c = classifyChildToken(token, hubDir, hubSlug, pos, config);
489
+ if (c.abs === hubAbs) { warn(`Skipping "${token}" — a hub can't list itself.`); pos -= 1; continue; }
490
+ if (resolvedExisting.has(c.abs)) { warn(`Skipping "${token}" — already in the runlist (${c.repoPath}).`); pos -= 1; continue; }
491
+ resolvedExisting.add(c.abs);
492
+ toAdd.push(c);
493
+ }
494
+ if (toAdd.length === 0) die('Nothing to add — all children were already in the runlist or skipped.');
495
+
496
+ const hubTitle = asString(hubFm.title) ?? titleize(hubSlug);
497
+ const today = nowIso();
498
+ const newRefs = [...existingRefs, ...toAdd.map(c => c.ref)];
499
+
500
+ if (json) {
501
+ process.stdout.write(JSON.stringify({
502
+ hub: hubRepoPath,
503
+ added: toAdd.map(c => ({ ref: c.ref, path: c.repoPath, scaffolded: c.kind === 'scaffold' })),
504
+ runlist: newRefs,
505
+ dryRun: !!dryRun,
506
+ }, null, 2) + '\n');
507
+ if (dryRun) return;
508
+ }
509
+
510
+ const prefix = dryRun ? `${dim('[dry-run]')} ` : '';
511
+ if (!json) process.stdout.write(bold(`${prefix}runlist add → ${hubRepoPath}`) + '\n');
512
+
513
+ for (const c of toAdd) {
514
+ if (c.kind === 'scaffold') {
515
+ if (!dryRun) {
516
+ if (existsSync(c.abs)) {
517
+ warn(`Child already exists, left as-is: ${c.repoPath}`);
518
+ } else {
519
+ writeFileSync(c.abs, runlistChildContent(c.title, hubSlug, hubTitle, 'planned', today), 'utf8');
520
+ }
521
+ }
522
+ if (!json) process.stdout.write(`${prefix} ${green('+')} ${c.repoPath} ${dim('(scaffolded · planned)')}\n`);
523
+ } else {
524
+ const wrote = setChildParentPlan(c.abs, hubAbs, config, { dryRun });
525
+ const note = wrote ? 'existing · parent_plan set' : 'existing';
526
+ if (!json) process.stdout.write(`${prefix} ${green('+')} ${c.repoPath} ${dim(`(${note})`)}\n`);
527
+ }
528
+ }
529
+
530
+ // Write the hub's `runlist:` array (+ bump `updated:`, + sync the body order
531
+ // list). The child stubs are already on disk, so title resolution works.
532
+ if (!dryRun) writeHubRunlist(hubAbs, newRefs, config, today);
533
+
534
+ if (!json) {
535
+ process.stdout.write(dim(` runlist now has ${newRefs.length} ${newRefs.length === 1 ? 'child' : 'children'}.`) + '\n');
536
+ if (!dryRun) {
537
+ process.stdout.write(dim(` Show: dotmd runlist ${hubRepoPath} · pick up next: dotmd runlist next ${hubRepoPath}`) + '\n');
538
+ }
539
+ }
540
+ }
541
+
542
+ // --- `runlist remove` / `runlist reorder` mutation (Phase 2) ---------------
543
+
544
+ // Heading alternatives that carry a `## Order of operations`-style link list
545
+ // mirroring the `runlist:` array (the `new plan --runlist` scaffold writes one).
546
+ const ORDER_SECTION_RE = /^##\s+(?:Order of operations|Runlist|Execution order|Implementation order|Plan order)\b.*$/im;
547
+
548
+ // Keep a hub's body `## Order of operations` link list in sync with the
549
+ // authoritative `runlist:` array. Regenerates the numbered link block from
550
+ // `orderedRefs`, preserving each item's display title and trailing status
551
+ // marker (⬜/✅/…) for children that remain — so a hand-checked-off item keeps
552
+ // its mark across an add/remove/reorder. No such section → body untouched (we
553
+ // never invent one). Only the contiguous run of list-item lines is rewritten;
554
+ // surrounding prose (e.g. the "pick up the next child" note) is preserved.
555
+ function syncOrderList(body, orderedRefs, titleFor) {
556
+ const m = body.match(ORDER_SECTION_RE);
557
+ if (!m || m.index === undefined) return body;
558
+ const headingEnd = m.index + m[0].length;
559
+ const rest = body.slice(headingEnd);
560
+ const nextRel = rest.search(/^##\s+/m);
561
+ const sectionEnd = nextRel >= 0 ? headingEnd + nextRel : body.length;
562
+
563
+ const lines = body.slice(headingEnd, sectionEnd).split('\n');
564
+ const isItem = (l) => /^\s*(?:\d+\.|[-*])\s+/.test(l) && /\.md(?:[#)\s]|$)/.test(l);
565
+ let start = -1, end = -1;
566
+ for (let i = 0; i < lines.length; i++) {
567
+ if (isItem(lines[i])) { if (start === -1) start = i; end = i; }
568
+ else if (start !== -1) break; // first non-item after the block ends it
569
+ }
570
+ if (start === -1) return body; // section present but no list to sync
571
+
572
+ const linkRe = /\[([^\]]+)\]\(([^)]+\.md)(?:#[^)]*)?\)/;
573
+ const prior = new Map(); // basename → { title, marker }
574
+ for (let i = start; i <= end; i++) {
575
+ const lk = linkRe.exec(lines[i]);
576
+ if (lk) {
577
+ prior.set(lk[2].split('/').pop(), { title: lk[1], marker: lines[i].slice(lk.index + lk[0].length).trim() });
578
+ } else {
579
+ const bare = /(\S+\.md)/.exec(lines[i]);
580
+ if (bare) prior.set(bare[1].split('/').pop(), { title: null, marker: lines[i].slice(bare.index + bare[0].length).trim() });
581
+ }
582
+ }
583
+
584
+ const rebuilt = orderedRefs.map((ref, i) => {
585
+ const prev = prior.get(ref.split('/').pop());
586
+ const title = prev?.title ?? titleFor(ref);
587
+ const marker = prev?.marker ? ` ${prev.marker}` : ' ⬜';
588
+ return `${i + 1}. [${title}](${ref})${marker}`;
589
+ });
590
+
591
+ const newLines = [...lines.slice(0, start), ...rebuilt, ...lines.slice(end + 1)];
592
+ return body.slice(0, headingEnd) + newLines.join('\n') + body.slice(sectionEnd);
593
+ }
594
+
595
+ // Authoritative hub write: set `runlist:` to `newRefs`, bump `updated:`, and
596
+ // resync the body order list. Shared by add/remove/reorder so all three keep
597
+ // the frontmatter array and the body link list consistent.
598
+ function writeHubRunlist(hubAbs, newRefs, config, today) {
599
+ const raw = readFileSync(hubAbs, 'utf8');
600
+ const { frontmatter: fmRaw, body } = extractFrontmatter(raw);
601
+ let newFm = upsertFrontmatterField(fmRaw, 'runlist', serializeBlockArray('runlist', newRefs));
602
+ newFm = upsertFrontmatterField(newFm, 'updated', `updated: ${today}`);
603
+
604
+ const titles = new Map();
605
+ for (const r of resolveRunlistRefs(newRefs, hubAbs, config)) {
606
+ if (r.title) titles.set(r.ref.split('/').pop(), r.title);
607
+ }
608
+ const titleFor = (ref) => titles.get(ref.split('/').pop()) ?? titleize(path.basename(ref, '.md'));
609
+ const newBody = syncOrderList(body, newRefs, titleFor);
610
+
611
+ writeFileSync(hubAbs, `---\n${newFm}\n---\n${newBody}`, 'utf8');
612
+ }
613
+
614
+ // Match a child token to one of the hub's existing runlist refs. Resolves
615
+ // hub-relative (then by slug/basename across the index), then compares against
616
+ // each ref's resolved abs path, with a basename fallback for refs that don't
617
+ // resolve to a file. Returns the matched ref string, or null.
618
+ function findRefForToken(token, existingRefs, hubDir, config) {
619
+ const tokenAbs = resolveRefPath(token, hubDir, config.repoRoot)
620
+ || (token.endsWith('.md') ? null : resolveRefPath(`${token}.md`, hubDir, config.repoRoot))
621
+ || resolveDocArg(token, config, { dieOnMiss: false });
622
+ const tokBase = token.endsWith('.md') ? token.split('/').pop() : `${token.split('/').pop()}.md`;
623
+ // Exact: resolved-path match or basename match.
624
+ for (const ref of existingRefs) {
625
+ const refAbs = resolveRefPath(ref, hubDir, config.repoRoot);
626
+ if (tokenAbs && refAbs && refAbs === tokenAbs) return ref;
627
+ if (ref.split('/').pop() === tokBase) return ref;
628
+ }
629
+ // Convenience: the short slug a sprint child was scaffolded from — e.g.
630
+ // `cleanup` matches `auth-revamp-03-cleanup.md`. Unique-or-bust so an
631
+ // ambiguous slug never silently picks the wrong child.
632
+ const slug = tokBase.replace(/\.md$/, '');
633
+ const suffixMatches = existingRefs.filter(ref => {
634
+ const base = ref.split('/').pop().replace(/\.md$/, '');
635
+ return base === slug || base.endsWith(`-${slug}`);
636
+ });
637
+ if (suffixMatches.length === 1) return suffixMatches[0];
638
+ if (suffixMatches.length > 1) {
639
+ die(`"${token}" matches multiple children: ${suffixMatches.join(', ')}. Use the full filename.`);
640
+ }
641
+ return null;
642
+ }
643
+
644
+ // Clear a removed child's `parent_plan:` when it points back at this hub (so the
645
+ // reverse link doesn't dangle). Leaves a parent_plan pointing elsewhere alone.
646
+ function clearChildParentPlan(childAbs, hubAbs, config, { dryRun }) {
647
+ let raw;
648
+ try { raw = readFileSync(childAbs, 'utf8'); } catch { return false; }
649
+ const { frontmatter: fmRaw } = extractFrontmatter(raw);
650
+ if (fmRaw == null) return false;
651
+ const fm = parseSimpleFrontmatter(fmRaw);
652
+ const existing = asString(fm.parent_plan);
653
+ if (!existing) return false;
654
+ if (resolveRefPath(existing, path.dirname(childAbs), config.repoRoot) !== hubAbs) return false;
655
+ if (dryRun) return true;
656
+ let newFm = upsertFrontmatterField(fmRaw, 'parent_plan', 'parent_plan:');
657
+ newFm = upsertFrontmatterField(newFm, 'updated', `updated: ${nowIso()}`);
658
+ writeFileSync(childAbs, replaceFrontmatter(raw, newFm), 'utf8');
659
+ return true;
660
+ }
661
+
662
+ // Shared front half of remove/reorder: resolve the hub, read its `runlist:`,
663
+ // die if it isn't a sprint hub with an array to mutate.
664
+ function loadSprintHub(hubInput, verb, config) {
665
+ if (!hubInput) die(`Usage: dotmd runlist ${verb} <hub-plan> <child...>`);
666
+ const hubAbs = resolveHubInput(hubInput, config);
667
+ if (!hubAbs) die(`Hub plan not found: ${hubInput}`);
668
+ const hubRepoPath = toRepoPath(hubAbs, config.repoRoot);
669
+ const hubDir = path.dirname(hubAbs);
670
+ const raw = readFileSync(hubAbs, 'utf8');
671
+ const { frontmatter: fmRaw } = extractFrontmatter(raw);
672
+ const fm = fmRaw == null ? {} : parseSimpleFrontmatter(fmRaw);
673
+ const existingRefs = normalizeStringList(fm.runlist);
674
+ if (existingRefs.length === 0) {
675
+ die(`${hubRepoPath} has no \`runlist:\` array to ${verb} from.` +
676
+ (fm.execution_mode === 'coordination' ? ' (It is a coordination hub — order lives in the body.)' : ''));
677
+ }
678
+ return { hubAbs, hubRepoPath, hubDir, existingRefs };
679
+ }
680
+
681
+ async function runRunlistRemove(positional, config, { dryRun, json, clearParent }) {
682
+ const { hubAbs, hubRepoPath, hubDir, existingRefs } = loadSprintHub(positional[0], 'remove', config);
683
+ const childTokens = positional.slice(1);
684
+ if (childTokens.length === 0) die('Usage: dotmd runlist remove <hub-plan> <child...>');
685
+
686
+ const removeRefs = [];
687
+ for (const token of childTokens) {
688
+ const ref = findRefForToken(token, existingRefs, hubDir, config);
689
+ if (!ref) die(`"${token}" is not in the runlist of ${hubRepoPath}.`);
690
+ if (!removeRefs.includes(ref)) removeRefs.push(ref);
691
+ }
692
+ const newRefs = existingRefs.filter(r => !removeRefs.includes(r));
693
+ const today = nowIso();
694
+
695
+ if (json) {
696
+ process.stdout.write(JSON.stringify({ hub: hubRepoPath, removed: removeRefs, runlist: newRefs, clearedParent: !!clearParent, dryRun: !!dryRun }, null, 2) + '\n');
697
+ } else {
698
+ process.stdout.write(bold(`${dryRun ? dim('[dry-run]') + ' ' : ''}runlist remove → ${hubRepoPath}`) + '\n');
699
+ for (const ref of removeRefs) process.stdout.write(`${dryRun ? dim('[dry-run]') + ' ' : ''} ${red('-')} ${ref}\n`);
700
+ }
701
+
702
+ if (!dryRun) writeHubRunlist(hubAbs, newRefs, config, today);
703
+ if (clearParent) {
704
+ for (const ref of removeRefs) {
705
+ const abs = resolveRefPath(ref, hubDir, config.repoRoot);
706
+ if (abs && clearChildParentPlan(abs, hubAbs, config, { dryRun }) && !json) {
707
+ process.stdout.write(`${dryRun ? dim('[dry-run]') + ' ' : ''} ${dim(`cleared parent_plan on ${toRepoPath(abs, config.repoRoot)}`)}\n`);
708
+ }
709
+ }
710
+ }
711
+ if (!json) process.stdout.write(dim(` runlist now has ${newRefs.length} ${newRefs.length === 1 ? 'child' : 'children'}.`) + '\n');
712
+ }
713
+
714
+ // Parse `reorder` argv: skip the subcommand + flags, capture `--before`/`--after`
715
+ // values (their operands would otherwise leak into the child list).
716
+ function parseReorderArgs(argv) {
717
+ const pos = [];
718
+ let before = null, after = null;
719
+ for (let i = 0; i < argv.length; i++) {
720
+ const a = argv[i];
721
+ if (a === 'reorder') continue;
722
+ if (a === '--before') { before = argv[++i] ?? null; continue; }
723
+ if (a === '--after') { after = argv[++i] ?? null; continue; }
724
+ if (a.startsWith('-')) continue;
725
+ pos.push(a);
726
+ }
727
+ return { hubInput: pos[0], children: pos.slice(1), before, after };
728
+ }
729
+
730
+ async function runRunlistReorder(argv, config, { dryRun, json }) {
731
+ const { hubInput, children, before, after } = parseReorderArgs(argv);
732
+ if (!hubInput || children.length === 0) {
733
+ die('Usage: dotmd runlist reorder <hub> <child> --before|--after <other>\n or: dotmd runlist reorder <hub> <child1> <child2> ... (full new order)');
734
+ }
735
+ const { hubAbs, hubRepoPath, hubDir, existingRefs } = loadSprintHub(hubInput, 'reorder', config);
736
+
737
+ let newRefs;
738
+ if (before || after) {
739
+ if (children.length !== 1) die('--before/--after move exactly one child. Pass a single child, or list every child for a full reorder.');
740
+ const moving = findRefForToken(children[0], existingRefs, hubDir, config);
741
+ if (!moving) die(`"${children[0]}" is not in the runlist of ${hubRepoPath}.`);
742
+ const anchorTok = before || after;
743
+ const anchor = findRefForToken(anchorTok, existingRefs, hubDir, config);
744
+ if (!anchor) die(`"${anchorTok}" is not in the runlist of ${hubRepoPath}.`);
745
+ if (moving === anchor) die('A child cannot be moved relative to itself.');
746
+ const without = existingRefs.filter(r => r !== moving);
747
+ const idx = without.indexOf(anchor);
748
+ const insertAt = before ? idx : idx + 1;
749
+ newRefs = [...without.slice(0, insertAt), moving, ...without.slice(insertAt)];
750
+ } else {
751
+ if (children.length !== existingRefs.length) {
752
+ die(`Full reorder needs all ${existingRefs.length} children in the new order; got ${children.length}. Use --before/--after to move just one.`);
753
+ }
754
+ newRefs = children.map(tok => {
755
+ const ref = findRefForToken(tok, existingRefs, hubDir, config);
756
+ if (!ref) die(`"${tok}" is not in the runlist of ${hubRepoPath}.`);
757
+ return ref;
758
+ });
759
+ if (new Set(newRefs).size !== newRefs.length) die('The new order repeats a child — list each exactly once.');
760
+ }
761
+
762
+ if (newRefs.join('\n') === existingRefs.join('\n')) die('New order matches the current order — nothing to do.');
763
+ const today = nowIso();
764
+
765
+ if (json) {
766
+ process.stdout.write(JSON.stringify({ hub: hubRepoPath, runlist: newRefs, dryRun: !!dryRun }, null, 2) + '\n');
767
+ } else {
768
+ process.stdout.write(bold(`${dryRun ? dim('[dry-run]') + ' ' : ''}runlist reorder → ${hubRepoPath}`) + '\n');
769
+ newRefs.forEach((ref, i) => process.stdout.write(`${dryRun ? dim('[dry-run]') + ' ' : ''} ${String(i + 1).padStart(2)}. ${ref}\n`));
770
+ }
771
+ if (!dryRun) writeHubRunlist(hubAbs, newRefs, config, today);
772
+ }
773
+
338
774
  export async function runRunlist(argv, config, opts = {}) {
339
775
  const json = argv.includes('--json');
340
776
  const positional = argv.filter(a => !a.startsWith('-'));
341
777
 
342
- // Subcommand dispatch: `runlist <hub>` (show) vs `runlist next <hub>` (pickup)
778
+ // Subcommand dispatch: mutators (`add`/`remove`/`reorder`) vs `next` (pickup)
779
+ // vs `show` (default).
780
+ if (positional[0] === 'add') {
781
+ return runRunlistAdd(positional.slice(1), config, { dryRun: opts.dryRun, json });
782
+ }
783
+ if (positional[0] === 'remove') {
784
+ return runRunlistRemove(positional.slice(1), config, { dryRun: opts.dryRun, json, clearParent: argv.includes('--clear-parent') });
785
+ }
786
+ if (positional[0] === 'reorder') {
787
+ return runRunlistReorder(argv, config, { dryRun: opts.dryRun, json });
788
+ }
789
+
343
790
  const sub = positional[0] === 'next' ? 'next' : 'show';
344
791
  const hubInput = sub === 'next' ? positional[1] : positional[0];
345
792