dotmd-cli 0.68.0 → 0.70.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.
Files changed (53) hide show
  1. package/README.md +144 -964
  2. package/bin/dotmd.mjs +241 -197
  3. package/dotmd.config.example.mjs +5 -8
  4. package/package.json +6 -10
  5. package/src/agent-context.mjs +132 -0
  6. package/src/atomic-mutation.mjs +1505 -0
  7. package/src/baton.mjs +109 -87
  8. package/src/bulk-tag.mjs +7 -7
  9. package/src/commands.mjs +326 -12
  10. package/src/completions.mjs +38 -98
  11. package/src/config.mjs +18 -3
  12. package/src/diff.mjs +7 -3
  13. package/src/doctor.mjs +12 -5
  14. package/src/export.mjs +154 -25
  15. package/src/fix-refs.mjs +2 -0
  16. package/src/frontmatter-fix.mjs +2 -0
  17. package/src/frontmatter.mjs +3 -2
  18. package/src/git.mjs +531 -14
  19. package/src/graph.mjs +53 -25
  20. package/src/guard.mjs +163 -60
  21. package/src/hud.mjs +65 -76
  22. package/src/index-file.mjs +28 -16
  23. package/src/index.mjs +17 -12
  24. package/src/init.mjs +1 -1
  25. package/src/journal.mjs +145 -12
  26. package/src/lifecycle.mjs +554 -282
  27. package/src/lint.mjs +57 -9
  28. package/src/managed-path.mjs +192 -0
  29. package/src/migrate-prompts.mjs +2 -0
  30. package/src/migrate-template.mjs +2 -0
  31. package/src/migrate.mjs +7 -1
  32. package/src/new.mjs +135 -54
  33. package/src/output-identity.mjs +106 -0
  34. package/src/pickup-card.mjs +24 -10
  35. package/src/pickup.mjs +457 -0
  36. package/src/prompts.mjs +138 -32
  37. package/src/query.mjs +22 -10
  38. package/src/reference-planner.mjs +292 -0
  39. package/src/rename.mjs +65 -73
  40. package/src/render.mjs +17 -8
  41. package/src/runlist.mjs +109 -71
  42. package/src/section.mjs +2 -1
  43. package/src/ship.mjs +39 -20
  44. package/src/stats.mjs +1 -1
  45. package/src/status-metadata.mjs +87 -0
  46. package/src/statuses.mjs +11 -26
  47. package/src/summary.mjs +14 -3
  48. package/src/update.mjs +38 -10
  49. package/src/use.mjs +4 -1
  50. package/src/util.mjs +1 -0
  51. package/src/validate.mjs +14 -6
  52. package/src/watch.mjs +6 -1
  53. package/src/notion.mjs +0 -528
package/src/runlist.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { readFileSync, writeFileSync, existsSync } from 'node:fs';
1
+ import { readFileSync, existsSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter, replaceFrontmatter } from './frontmatter.mjs';
4
4
  import { extractFirstHeading } from './extractors.mjs';
@@ -17,8 +17,9 @@ import {
17
17
  import { resolveDocArg } from './index.mjs';
18
18
  import { runlistChildContent, slugify, titleize } from './new.mjs';
19
19
  import { bold, cyan, dim, green, red, yellow } from './color.mjs';
20
-
21
- const PICKUPABLE_STATUSES = new Set(['active', 'planned', 'in-session']);
20
+ import { authorizeManagedDestination, authorizeManagedSource } from './managed-path.mjs';
21
+ import { mutateFileSet, MutationConflictError } from './atomic-mutation.mjs';
22
+ import { pickupFactsForDoc } from './pickup.mjs';
22
23
 
23
24
  // A child is the runlist's NEXT PICKUP only when a session could start it right
24
25
  // now — i.e. its status is one `dotmd use` accepts. The "parked" statuses
@@ -30,8 +31,9 @@ const PICKUPABLE_STATUSES = new Set(['active', 'planned', 'in-session']);
30
31
  // never counts toward `done/total` — that tally tracks closed (archived) only.
31
32
  // This keeps the `→` marker in agreement with `runlist next`, which already
32
33
  // gates on PICKUPABLE_STATUSES.
33
- function isPickupable(status) {
34
- return PICKUPABLE_STATUSES.has(status);
34
+ function isPickupable(status, doc, config) {
35
+ if (!doc || !config) return false;
36
+ return pickupFactsForDoc({ ...doc, status }, config).pickupable;
35
37
  }
36
38
 
37
39
  // Build a hub/child map straight from the in-memory index — no disk IO. A doc
@@ -77,7 +79,7 @@ export function buildRunlistIndex(index, config) {
77
79
 
78
80
  // Next pickup = first child a session can actually start. Skip archived
79
81
  // (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;
82
+ const next = children.find(c => !c.missing && !c.archived && isPickupable(c.status, c.doc, config)) ?? null;
81
83
  hubs.set(hub.path, {
82
84
  hub,
83
85
  total: children.length,
@@ -85,7 +87,7 @@ export function buildRunlistIndex(index, config) {
85
87
  // Live-but-not-startable children (parked: blocked/partial/paused/…). Lets
86
88
  // the `dotmd plans` fold say "N parked" instead of mislabelling a hub with
87
89
  // a parked-but-unfinished child as "all archived".
88
- parkedCount: children.filter(c => !c.missing && !c.archived && !isPickupable(c.status)).length,
90
+ parkedCount: children.filter(c => !c.missing && !c.archived && !isPickupable(c.status, c.doc, config)).length,
89
91
  children,
90
92
  nextChildPath: next?.path ?? null,
91
93
  });
@@ -172,13 +174,13 @@ export function buildCoordinationIndex(index, config) {
172
174
  if (childPaths.has(child.path)) continue;
173
175
  childPaths.add(child.path);
174
176
  const archived = archiveStatuses.has(child.status) || isArchivedPath(child.path, config);
175
- children.push({ path: child.path, status: child.status ?? null, archived });
177
+ children.push({ path: child.path, status: child.status ?? null, archived, doc: child });
176
178
  }
177
179
  // Rollup, mirroring buildRunlistIndex: done = archived; parked = live but not
178
180
  // startable (blocked/partial/paused/awaiting/queued-after). `childCount`
179
181
  // stays an alias of `total` so existing callers (sorters, JSON) keep working.
180
182
  const doneCount = children.filter(c => c.archived).length;
181
- const parkedCount = children.filter(c => !c.archived && !isPickupable(c.status)).length;
183
+ const parkedCount = children.filter(c => !c.archived && !isPickupable(c.status, c.doc, config)).length;
182
184
  const nextPickup = resolveHubNextPickup(doc, dir, resolveRef, archiveStatuses, config);
183
185
  hubs.set(doc.path, {
184
186
  doc,
@@ -249,8 +251,8 @@ export function buildRoadmapIndex(index, config, precomputed = {}) {
249
251
  }
250
252
  // A leaf-plan child is its own next action when it's startable.
251
253
  const archived = archiveStatuses.has(child.status) || isArchivedPath(child.path, config);
252
- const parked = !archived && !isPickupable(child.status);
253
- const pickupable = !archived && isPickupable(child.status);
254
+ const parked = !archived && !isPickupable(child.status, child, config);
255
+ const pickupable = !archived && isPickupable(child.status, child, config);
254
256
  return { kind: 'plan', total: 1, doneCount: archived ? 1 : 0, parkedCount: parked ? 1 : 0,
255
257
  nextPath: pickupable ? child.path : null, nextLabel: pickupable ? toSlug(child) : null };
256
258
  };
@@ -321,6 +323,7 @@ function resolveRunlistRefs(refs, hubAbsPath, config) {
321
323
  ref,
322
324
  path: repoPath,
323
325
  status: asString(childFm.status) ?? null,
326
+ type: asString(childFm.type) ?? null,
324
327
  // Fall back to the body H1 (like the main index) before the bare
325
328
  // filename — runlist child stubs carry their title as an H1, not a
326
329
  // `title:` field, so this keeps the synced body order list readable.
@@ -421,7 +424,7 @@ function resolveHubNextPickup(hubDoc, hubDir, resolveRef, archiveStatuses, confi
421
424
  // queued-after) — the hub's next-pickup is the first startable plan, the
422
425
  // same gate sprint runlists use, so a hub never points `→` at a child a
423
426
  // session can't actually pick up.
424
- if (!isPickupable(child.status)) continue;
427
+ if (!isPickupable(child.status, child, config)) continue;
425
428
  return { path: child.path, status: child.status ?? null, label: coordinationChildLabel(child, hubDoc) };
426
429
  }
427
430
  return null;
@@ -483,7 +486,7 @@ function renderRunlist(hubRepoPath, children, opts = {}) {
483
486
  }
484
487
  // → marks the first child a session can actually start: skip archived and
485
488
  // parked (blocked/partial/paused/awaiting/queued-after) alike.
486
- const isNext = !nextPicked && isPickupable(c.status);
489
+ const isNext = !nextPicked && isPickupable(c.status, c, opts.config);
487
490
  if (isNext) nextPicked = true;
488
491
  const marker = isNext ? green('→') : ' ';
489
492
  const statusTag = `[${colorStatus(c.status)}]`;
@@ -573,25 +576,31 @@ function classifyChildToken(token, hubDir, hubSlug, pos, config) {
573
576
  // already resolves to the hub. Never clobbers a parent_plan that points
574
577
  // elsewhere (warns instead — the child may belong to another hub). Returns
575
578
  // true when it wrote, false when it left the file alone.
576
- function setChildParentPlan(childAbs, hubAbs, config, { dryRun }) {
579
+ function planChildParentUpdate(childAbs, hubAbs, config) {
577
580
  const raw = readFileSync(childAbs, 'utf8');
578
581
  const { frontmatter: fmRaw } = extractFrontmatter(raw);
579
- if (fmRaw == null) return false;
582
+ if (fmRaw == null) return { wrote: false };
580
583
  const fm = parseSimpleFrontmatter(fmRaw);
581
584
  const childDir = path.dirname(childAbs);
582
585
  const existing = asString(fm.parent_plan);
583
586
  if (existing) {
584
587
  const resolved = resolveRefPath(existing, childDir, config.repoRoot);
585
- if (resolved === hubAbs) return false; // already points at this hub
588
+ if (resolved === hubAbs) return { wrote: false }; // already points at this hub
586
589
  warn(`${toRepoPath(childAbs, config.repoRoot)} already has parent_plan: ${existing} — left as-is (not pointing it at the hub).`);
587
- return false;
590
+ return { wrote: false };
588
591
  }
589
592
  const ref = path.relative(childDir, hubAbs).split(path.sep).join('/');
590
- if (dryRun) return true;
591
- let newFm = upsertFrontmatterField(fmRaw, 'parent_plan', `parent_plan: ${ref}`);
592
- newFm = upsertFrontmatterField(newFm, 'updated', `updated: ${nowIso()}`);
593
- writeFileSync(childAbs, replaceFrontmatter(raw, newFm), 'utf8');
594
- return true;
593
+ return { wrote: true, update: { path: childAbs, expectedContent: raw, render: current => {
594
+ const { frontmatter: currentFm } = extractFrontmatter(current);
595
+ const currentParsed = parseSimpleFrontmatter(currentFm);
596
+ const currentParent = asString(currentParsed.parent_plan);
597
+ if (currentParent && resolveRefPath(currentParent, childDir, config.repoRoot) !== hubAbs) {
598
+ throw new MutationConflictError(`Child parent_plan changed while the runlist mutation was being prepared: ${toRepoPath(childAbs, config.repoRoot)}`);
599
+ }
600
+ let updatedFm = upsertFrontmatterField(currentFm, 'parent_plan', `parent_plan: ${ref}`);
601
+ updatedFm = upsertFrontmatterField(updatedFm, 'updated', `updated: ${nowIso()}`);
602
+ return replaceFrontmatter(current, updatedFm);
603
+ } } };
595
604
  }
596
605
 
597
606
  // `dotmd runlist add <hub> <child...>` — append children to a hub's `runlist:`
@@ -599,15 +608,17 @@ function setChildParentPlan(childAbs, hubAbs, config, { dryRun }) {
599
608
  // exist (mirroring `dotmd new plan --runlist`) and wiring each child's
600
609
  // `parent_plan:` back-ref. Coordination hubs (body-order, no `runlist:` array)
601
610
  // are out of this path — guarded with an actionable message.
602
- async function runRunlistAdd(positional, config, { dryRun, json }) {
611
+ async function runRunlistAdd(positional, config, { dryRun, json, testHooks }) {
603
612
  const hubInput = positional[0];
604
613
  const childTokens = positional.slice(1);
605
614
  if (!hubInput || childTokens.length === 0) {
606
615
  die('Usage: dotmd runlist add <hub-plan> <child...> (one or more child slugs or plan paths)');
607
616
  }
608
617
 
609
- const hubAbs = resolveHubInput(hubInput, config);
618
+ let hubAbs = resolveHubInput(hubInput, config);
610
619
  if (!hubAbs) die(`Hub plan not found: ${hubInput}`);
620
+ const hubAuthorization = authorizeManagedSource(hubAbs, config, { kind: 'Runlist hub source' });
621
+ hubAbs = hubAuthorization.path;
611
622
  const hubRepoPath = toRepoPath(hubAbs, config.repoRoot);
612
623
  const hubDir = path.dirname(hubAbs);
613
624
  const hubSlug = path.basename(hubAbs, '.md');
@@ -644,6 +655,11 @@ async function runRunlistAdd(positional, config, { dryRun, json }) {
644
655
  for (const token of childTokens) {
645
656
  pos += 1;
646
657
  const c = classifyChildToken(token, hubDir, hubSlug, pos, config);
658
+ if (c.kind === 'existing') {
659
+ authorizeManagedSource(c.abs, config, { kind: 'Runlist child source' });
660
+ } else {
661
+ authorizeManagedDestination(c.abs, config, { root: hubAuthorization.root, kind: 'Runlist child scaffold destination' });
662
+ }
647
663
  if (c.abs === hubAbs) { warn(`Skipping "${token}" — a hub can't list itself.`); pos -= 1; continue; }
648
664
  if (resolvedExisting.has(c.abs)) { warn(`Skipping "${token}" — already in the runlist (${c.repoPath}).`); pos -= 1; continue; }
649
665
  resolvedExisting.add(c.abs);
@@ -668,26 +684,26 @@ async function runRunlistAdd(positional, config, { dryRun, json }) {
668
684
  const prefix = dryRun ? `${dim('[dry-run]')} ` : '';
669
685
  if (!json) process.stdout.write(bold(`${prefix}runlist add → ${hubRepoPath}`) + '\n');
670
686
 
687
+ const updates = [];
688
+ const creations = [];
671
689
  for (const c of toAdd) {
672
690
  if (c.kind === 'scaffold') {
673
- if (!dryRun) {
674
- if (existsSync(c.abs)) {
675
- warn(`Child already exists, left as-is: ${c.repoPath}`);
676
- } else {
677
- writeFileSync(c.abs, runlistChildContent(c.title, hubSlug, hubTitle, 'planned', today), 'utf8');
678
- }
679
- }
691
+ creations.push({ path: c.abs, content: runlistChildContent(c.title, hubSlug, hubTitle, 'planned', today) });
680
692
  if (!json) process.stdout.write(`${prefix} ${green('+')} ${c.repoPath} ${dim('(scaffolded · planned)')}\n`);
681
693
  } else {
682
- const wrote = setChildParentPlan(c.abs, hubAbs, config, { dryRun });
683
- const note = wrote ? 'existing · parent_plan set' : 'existing';
694
+ const planned = planChildParentUpdate(c.abs, hubAbs, config);
695
+ if (planned.update) updates.push(planned.update);
696
+ const note = planned.wrote ? 'existing · parent_plan set' : 'existing';
684
697
  if (!json) process.stdout.write(`${prefix} ${green('+')} ${c.repoPath} ${dim(`(${note})`)}\n`);
685
698
  }
686
699
  }
687
700
 
688
- // Write the hub's `runlist:` array (+ bump `updated:`, + sync the body order
689
- // list). The child stubs are already on disk, so title resolution works.
690
- if (!dryRun) writeHubRunlist(hubAbs, newRefs, config, today);
701
+ // Preflight every child and the hub under one ordered lock set, then publish
702
+ // with rollback so a conflict cannot leave one side updated without the other.
703
+ if (!dryRun) {
704
+ updates.push(hubRunlistUpdate(hubAbs, newRefs, config, today, hubRaw, toAdd));
705
+ mutateFileSet({ updates, creations }, { repoRoot: config.repoRoot, testHooks });
706
+ }
691
707
 
692
708
  if (!json) {
693
709
  process.stdout.write(dim(` runlist now has ${newRefs.length} ${newRefs.length === 1 ? 'child' : 'children'}.`) + '\n');
@@ -753,20 +769,20 @@ function syncOrderList(body, orderedRefs, titleFor) {
753
769
  // Authoritative hub write: set `runlist:` to `newRefs`, bump `updated:`, and
754
770
  // resync the body order list. Shared by add/remove/reorder so all three keep
755
771
  // the frontmatter array and the body link list consistent.
756
- function writeHubRunlist(hubAbs, newRefs, config, today) {
757
- const raw = readFileSync(hubAbs, 'utf8');
758
- const { frontmatter: fmRaw, body } = extractFrontmatter(raw);
759
- let newFm = upsertFrontmatterField(fmRaw, 'runlist', serializeBlockArray('runlist', newRefs));
760
- newFm = upsertFrontmatterField(newFm, 'updated', `updated: ${today}`);
761
-
772
+ function hubRunlistUpdate(hubAbs, newRefs, config, today, expectedRaw, added = []) {
762
773
  const titles = new Map();
763
774
  for (const r of resolveRunlistRefs(newRefs, hubAbs, config)) {
764
775
  if (r.title) titles.set(r.ref.split('/').pop(), r.title);
765
776
  }
777
+ for (const child of added) titles.set(child.ref.split('/').pop(), child.title);
766
778
  const titleFor = (ref) => titles.get(ref.split('/').pop()) ?? titleize(path.basename(ref, '.md'));
767
- const newBody = syncOrderList(body, newRefs, titleFor);
768
-
769
- writeFileSync(hubAbs, `---\n${newFm}\n---\n${newBody}`, 'utf8');
779
+ return { path: hubAbs, expectedContent: expectedRaw, render: raw => {
780
+ const { frontmatter: fmRaw, body } = extractFrontmatter(raw);
781
+ let newFm = upsertFrontmatterField(fmRaw, 'runlist', serializeBlockArray('runlist', newRefs));
782
+ newFm = upsertFrontmatterField(newFm, 'updated', `updated: ${today}`);
783
+ const newBody = syncOrderList(body, newRefs, titleFor);
784
+ return `---\n${newFm}\n---\n${newBody}`;
785
+ } };
770
786
  }
771
787
 
772
788
  // Match a child token to one of the hub's existing runlist refs. Resolves
@@ -801,28 +817,34 @@ function findRefForToken(token, existingRefs, hubDir, config) {
801
817
 
802
818
  // Clear a removed child's `parent_plan:` when it points back at this hub (so the
803
819
  // reverse link doesn't dangle). Leaves a parent_plan pointing elsewhere alone.
804
- function clearChildParentPlan(childAbs, hubAbs, config, { dryRun }) {
820
+ function planClearChildParent(childAbs, hubAbs, config) {
805
821
  let raw;
806
- try { raw = readFileSync(childAbs, 'utf8'); } catch { return false; }
822
+ try { raw = readFileSync(childAbs, 'utf8'); } catch { return { wrote: false }; }
807
823
  const { frontmatter: fmRaw } = extractFrontmatter(raw);
808
- if (fmRaw == null) return false;
824
+ if (fmRaw == null) return { wrote: false };
809
825
  const fm = parseSimpleFrontmatter(fmRaw);
810
826
  const existing = asString(fm.parent_plan);
811
- if (!existing) return false;
812
- if (resolveRefPath(existing, path.dirname(childAbs), config.repoRoot) !== hubAbs) return false;
813
- if (dryRun) return true;
814
- let newFm = upsertFrontmatterField(fmRaw, 'parent_plan', 'parent_plan:');
815
- newFm = upsertFrontmatterField(newFm, 'updated', `updated: ${nowIso()}`);
816
- writeFileSync(childAbs, replaceFrontmatter(raw, newFm), 'utf8');
817
- return true;
827
+ if (!existing) return { wrote: false };
828
+ if (resolveRefPath(existing, path.dirname(childAbs), config.repoRoot) !== hubAbs) return { wrote: false };
829
+ return { wrote: true, update: { path: childAbs, expectedContent: raw, render: current => {
830
+ const { frontmatter: currentFm } = extractFrontmatter(current);
831
+ const currentParent = asString(parseSimpleFrontmatter(currentFm).parent_plan);
832
+ if (!currentParent || resolveRefPath(currentParent, path.dirname(childAbs), config.repoRoot) !== hubAbs) {
833
+ throw new MutationConflictError(`Child parent_plan changed while the runlist mutation was being prepared: ${toRepoPath(childAbs, config.repoRoot)}`);
834
+ }
835
+ let updatedFm = upsertFrontmatterField(currentFm, 'parent_plan', 'parent_plan:');
836
+ updatedFm = upsertFrontmatterField(updatedFm, 'updated', `updated: ${nowIso()}`);
837
+ return replaceFrontmatter(current, updatedFm);
838
+ } } };
818
839
  }
819
840
 
820
841
  // Shared front half of remove/reorder: resolve the hub, read its `runlist:`,
821
842
  // die if it isn't a sprint hub with an array to mutate.
822
843
  function loadSprintHub(hubInput, verb, config) {
823
844
  if (!hubInput) die(`Usage: dotmd runlist ${verb} <hub-plan> <child...>`);
824
- const hubAbs = resolveHubInput(hubInput, config);
845
+ let hubAbs = resolveHubInput(hubInput, config);
825
846
  if (!hubAbs) die(`Hub plan not found: ${hubInput}`);
847
+ hubAbs = authorizeManagedSource(hubAbs, config, { kind: `Runlist ${verb} hub source` }).path;
826
848
  const hubRepoPath = toRepoPath(hubAbs, config.repoRoot);
827
849
  const hubDir = path.dirname(hubAbs);
828
850
  const raw = readFileSync(hubAbs, 'utf8');
@@ -833,11 +855,11 @@ function loadSprintHub(hubInput, verb, config) {
833
855
  die(`${hubRepoPath} has no \`runlist:\` array to ${verb} from.` +
834
856
  (fm.execution_mode === 'coordination' ? ' (It is a coordination hub — order lives in the body.)' : ''));
835
857
  }
836
- return { hubAbs, hubRepoPath, hubDir, existingRefs };
858
+ return { hubAbs, hubRepoPath, hubDir, existingRefs, raw };
837
859
  }
838
860
 
839
- async function runRunlistRemove(positional, config, { dryRun, json, clearParent }) {
840
- const { hubAbs, hubRepoPath, hubDir, existingRefs } = loadSprintHub(positional[0], 'remove', config);
861
+ async function runRunlistRemove(positional, config, { dryRun, json, clearParent, testHooks }) {
862
+ const { hubAbs, hubRepoPath, hubDir, existingRefs, raw: hubRaw } = loadSprintHub(positional[0], 'remove', config);
841
863
  const childTokens = positional.slice(1);
842
864
  if (childTokens.length === 0) die('Usage: dotmd runlist remove <hub-plan> <child...>');
843
865
 
@@ -849,6 +871,15 @@ async function runRunlistRemove(positional, config, { dryRun, json, clearParent
849
871
  }
850
872
  const newRefs = existingRefs.filter(r => !removeRefs.includes(r));
851
873
  const today = nowIso();
874
+ const clearTargets = [];
875
+ if (clearParent) {
876
+ for (const ref of removeRefs) {
877
+ const abs = resolveRefPath(ref, hubDir, config.repoRoot);
878
+ if (!abs) die(`Cannot clear parent_plan: runlist child does not resolve: ${ref}`);
879
+ const authorized = authorizeManagedSource(abs, config, { kind: 'Runlist removed child source' });
880
+ clearTargets.push({ ref, abs: authorized.path });
881
+ }
882
+ }
852
883
 
853
884
  if (json) {
854
885
  process.stdout.write(JSON.stringify({ hub: hubRepoPath, removed: removeRefs, runlist: newRefs, clearedParent: !!clearParent, dryRun: !!dryRun }, null, 2) + '\n');
@@ -857,15 +888,22 @@ async function runRunlistRemove(positional, config, { dryRun, json, clearParent
857
888
  for (const ref of removeRefs) process.stdout.write(`${dryRun ? dim('[dry-run]') + ' ' : ''} ${red('-')} ${ref}\n`);
858
889
  }
859
890
 
860
- if (!dryRun) writeHubRunlist(hubAbs, newRefs, config, today);
891
+ const clearUpdates = [];
861
892
  if (clearParent) {
862
- for (const ref of removeRefs) {
863
- const abs = resolveRefPath(ref, hubDir, config.repoRoot);
864
- if (abs && clearChildParentPlan(abs, hubAbs, config, { dryRun }) && !json) {
893
+ for (const { abs } of clearTargets) {
894
+ const planned = planClearChildParent(abs, hubAbs, config);
895
+ if (planned.update) clearUpdates.push(planned.update);
896
+ if (planned.wrote && !json) {
865
897
  process.stdout.write(`${dryRun ? dim('[dry-run]') + ' ' : ''} ${dim(`cleared parent_plan on ${toRepoPath(abs, config.repoRoot)}`)}\n`);
866
898
  }
867
899
  }
868
900
  }
901
+ if (!dryRun) {
902
+ mutateFileSet({ updates: [hubRunlistUpdate(hubAbs, newRefs, config, today, hubRaw), ...clearUpdates] }, {
903
+ repoRoot: config.repoRoot,
904
+ testHooks,
905
+ });
906
+ }
869
907
  if (!json) process.stdout.write(dim(` runlist now has ${newRefs.length} ${newRefs.length === 1 ? 'child' : 'children'}.`) + '\n');
870
908
  }
871
909
 
@@ -885,12 +923,12 @@ function parseReorderArgs(argv) {
885
923
  return { hubInput: pos[0], children: pos.slice(1), before, after };
886
924
  }
887
925
 
888
- async function runRunlistReorder(argv, config, { dryRun, json }) {
926
+ async function runRunlistReorder(argv, config, { dryRun, json, testHooks }) {
889
927
  const { hubInput, children, before, after } = parseReorderArgs(argv);
890
928
  if (!hubInput || children.length === 0) {
891
929
  die('Usage: dotmd runlist reorder <hub> <child> --before|--after <other>\n or: dotmd runlist reorder <hub> <child1> <child2> ... (full new order)');
892
930
  }
893
- const { hubAbs, hubRepoPath, hubDir, existingRefs } = loadSprintHub(hubInput, 'reorder', config);
931
+ const { hubAbs, hubRepoPath, hubDir, existingRefs, raw: hubRaw } = loadSprintHub(hubInput, 'reorder', config);
894
932
 
895
933
  let newRefs;
896
934
  if (before || after) {
@@ -926,7 +964,7 @@ async function runRunlistReorder(argv, config, { dryRun, json }) {
926
964
  process.stdout.write(bold(`${dryRun ? dim('[dry-run]') + ' ' : ''}runlist reorder → ${hubRepoPath}`) + '\n');
927
965
  newRefs.forEach((ref, i) => process.stdout.write(`${dryRun ? dim('[dry-run]') + ' ' : ''} ${String(i + 1).padStart(2)}. ${ref}\n`));
928
966
  }
929
- if (!dryRun) writeHubRunlist(hubAbs, newRefs, config, today);
967
+ if (!dryRun) mutateFileSet({ updates: [hubRunlistUpdate(hubAbs, newRefs, config, today, hubRaw)] }, { repoRoot: config.repoRoot, testHooks });
930
968
  }
931
969
 
932
970
  export async function runRunlist(argv, config, opts = {}) {
@@ -936,13 +974,13 @@ export async function runRunlist(argv, config, opts = {}) {
936
974
  // Subcommand dispatch: mutators (`add`/`remove`/`reorder`) vs `next` (pickup)
937
975
  // vs `show` (default).
938
976
  if (positional[0] === 'add') {
939
- return runRunlistAdd(positional.slice(1), config, { dryRun: opts.dryRun, json });
977
+ return runRunlistAdd(positional.slice(1), config, { dryRun: opts.dryRun, json, testHooks: opts.testHooks });
940
978
  }
941
979
  if (positional[0] === 'remove') {
942
- return runRunlistRemove(positional.slice(1), config, { dryRun: opts.dryRun, json, clearParent: argv.includes('--clear-parent') });
980
+ return runRunlistRemove(positional.slice(1), config, { dryRun: opts.dryRun, json, clearParent: argv.includes('--clear-parent'), testHooks: opts.testHooks });
943
981
  }
944
982
  if (positional[0] === 'reorder') {
945
- return runRunlistReorder(argv, config, { dryRun: opts.dryRun, json });
983
+ return runRunlistReorder(argv, config, { dryRun: opts.dryRun, json, testHooks: opts.testHooks });
946
984
  }
947
985
 
948
986
  const sub = positional[0] === 'next' ? 'next' : 'show';
@@ -971,7 +1009,7 @@ export async function runRunlist(argv, config, opts = {}) {
971
1009
  }, null, 2) + '\n');
972
1010
  return;
973
1011
  }
974
- process.stdout.write(renderRunlist(hubRepoPath, children, { archiveStatuses, source }));
1012
+ process.stdout.write(renderRunlist(hubRepoPath, children, { archiveStatuses, source, config }));
975
1013
  return;
976
1014
  }
977
1015
 
@@ -979,7 +1017,7 @@ export async function runRunlist(argv, config, opts = {}) {
979
1017
  // Skip both archived (done) and parked (blocked/partial/paused/awaiting/
980
1018
  // queued-after) children: the runlist advances to the first pickup-able one,
981
1019
  // so the picked target is guaranteed in a `dotmd use`-able status.
982
- const target = children.find(c => !c.missing && !archiveStatuses.has(c.status) && isPickupable(c.status));
1020
+ const target = children.find(c => !c.missing && !archiveStatuses.has(c.status) && isPickupable(c.status, c, config));
983
1021
  if (!target) {
984
1022
  if (children.length === 0) die(`Hub ${hubRepoPath} has empty \`runlist:\` — nothing to pick up.`);
985
1023
  // Live (non-archived, non-missing) children that exist but aren't startable.
package/src/section.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  // Pure markdown section walker. Regex-walks H1-H6 headings respecting fenced
2
2
  // code blocks (``` and ~~~). Returns flat list of sections with body content
3
- // and absolute line numbers (1-indexed, matches Read tool's `offset`).
3
+ // and body-relative line numbers (1-indexed). Callers with stripped frontmatter
4
+ // add extractFrontmatter().bodyLineOffset before exposing file coordinates.
4
5
 
5
6
  export function walkSections(body) {
6
7
  const lines = body.split('\n');
package/src/ship.mjs CHANGED
@@ -2,6 +2,7 @@ import { readFileSync, existsSync } from 'node:fs';
2
2
  import { spawnSync } from 'node:child_process';
3
3
  import path from 'node:path';
4
4
  import { die, warn, toRepoPath } from './util.mjs';
5
+ import { assertGitIndex } from './git.mjs';
5
6
  import { green, dim, yellow } from './color.mjs';
6
7
 
7
8
  // Files dotmd ship will auto-stage when they're dirty. Anything outside this
@@ -44,26 +45,28 @@ export function isAllowed(repoPath) {
44
45
  return ALLOWLIST_PATTERNS.some(re => re.test(repoPath));
45
46
  }
46
47
 
47
- function listDirtyFiles(repoRoot) {
48
- // -u expands untracked directories into individual file entries; without it,
49
- // a fresh `docs/` shows up as a single `?? docs/` line and the allowlist
50
- // check sees no per-file paths to whitelist.
51
- const result = spawnSync('git', ['status', '--porcelain', '-u'], { cwd: repoRoot, encoding: 'utf8' });
48
+ export function listDirtyFiles(repoRoot) {
49
+ const result = spawnSync('git', ['status', '--porcelain=v1', '-z', '--untracked-files=all'], {
50
+ cwd: repoRoot,
51
+ encoding: 'utf8',
52
+ });
52
53
  if (result.status !== 0) die(`git status failed: ${result.stderr}`);
53
- return result.stdout
54
- .split('\n')
55
- .filter(Boolean)
56
- .map(line => {
57
- const status = line.slice(0, 2);
58
- let rawPath = line.slice(3);
59
- // Renames/copies render as `R orig -> new` (and `C orig -> new`); only
60
- // the destination is a real file we can `git add`. Without splitting on
61
- // ` -> `, the literal "orig -> new" string is handed to git, which fails
62
- // with "did not match any files" and aborts the ship.
63
- const arrow = rawPath.indexOf(' -> ');
64
- if (arrow !== -1) rawPath = rawPath.slice(arrow + 4);
65
- return { status, path: rawPath };
66
- });
54
+ const fields = result.stdout.split('\0');
55
+ const files = [];
56
+ for (let i = 0; i < fields.length; i++) {
57
+ const record = fields[i];
58
+ if (!record) continue;
59
+ const status = record.slice(0, 2);
60
+ const destination = record.slice(3);
61
+ files.push({ status, path: destination });
62
+ if (status.includes('R')) {
63
+ const source = fields[++i];
64
+ if (source) files.push({ status, path: source });
65
+ } else if (status.includes('C')) {
66
+ i++; // Porcelain -z includes the unchanged copy source as a second field.
67
+ }
68
+ }
69
+ return files;
67
70
  }
68
71
 
69
72
  export async function runShip(argv, config, opts = {}) {
@@ -82,6 +85,12 @@ export async function runShip(argv, config, opts = {}) {
82
85
 
83
86
  process.stdout.write(`${green('→')} Shipping ${current} → ${target} (${bump})\n`);
84
87
 
88
+ try {
89
+ assertGitIndex(config.repoRoot);
90
+ } catch (err) {
91
+ die(`Refusing to ship with inherited staged files. ${err.message}`);
92
+ }
93
+
85
94
  // Per-repo slash-command scaffolding is retired (the dotmd plugin's SKILL.md
86
95
  // is canonical now), so there is nothing to regenerate at ship time. Any
87
96
  // stale generated files are swept by `dotmd hud` / `dotmd doctor`.
@@ -114,10 +123,20 @@ export async function runShip(argv, config, opts = {}) {
114
123
  return;
115
124
  }
116
125
 
126
+ if (allSkipped.length > 0) {
127
+ die('Refusing to create a release preparation commit while skipped files are dirty. Commit, stash, or remove them first.');
128
+ }
129
+
117
130
  if (allToStage.length > 0) {
118
131
  const add = spawnSync('git', ['add', '--', ...allToStage], { cwd: config.repoRoot, encoding: 'utf8' });
119
132
  if (add.status !== 0) die(`git add failed: ${add.stderr}`);
120
133
 
134
+ try {
135
+ assertGitIndex(config.repoRoot, allToStage);
136
+ } catch (err) {
137
+ die(`Refusing to commit an unexpected Git index. ${err.message}`);
138
+ }
139
+
121
140
  const subject = `chore: release ${target}`;
122
141
  const body = `Auto-staged by \`dotmd ship\`:\n${allToStage.map(p => `- ${p}`).join('\n')}`;
123
142
  const commitMsg = `${subject}\n\n${body}`;
@@ -137,7 +156,7 @@ export async function runShip(argv, config, opts = {}) {
137
156
  stdio: 'inherit',
138
157
  });
139
158
  if (npmResult.status !== 0) {
140
- warn('`npm version` failed. The bump commit + tag may already exist locally. Inspect with `git log -1` and `git tag --sort=-creatordate | head` before retrying.');
159
+ warn('`npm version` failed. If the target tag exists, run `npm run release:resume`; otherwise follow the rollback/rerun guidance from the version lifecycle.');
141
160
  process.exit(npmResult.status ?? 1);
142
161
  }
143
162
 
package/src/stats.mjs CHANGED
@@ -86,7 +86,7 @@ export function buildStats(index, config) {
86
86
 
87
87
  export function renderStats(stats, config) {
88
88
  const defaultRenderer = (s) => _renderStats(s, config);
89
- if (config.hooks.renderStats) {
89
+ if (!config._execution?.suppressSideEffects && config.hooks.renderStats) {
90
90
  try { return config.hooks.renderStats(stats, defaultRenderer); }
91
91
  catch (err) { warn(`Hook 'renderStats' threw: ${err.message}`); }
92
92
  }
@@ -0,0 +1,87 @@
1
+ // Ordered, type-aware view of the effective status configuration. Config loading
2
+ // still resolves compatibility overrides; consumers use this module instead of
3
+ // reconstructing behavior from several global/type-specific fields.
4
+ const CACHE = new WeakMap();
5
+
6
+ export function resolveStatusMetadata(config) {
7
+ const cached = CACHE.get(config);
8
+ if (cached) return cached;
9
+ const byType = {};
10
+ const typeOrder = [...(config.validTypes ?? [])];
11
+
12
+ for (const type of typeOrder) {
13
+ const statuses = [...(config.typeStatuses?.get(type) ?? [])];
14
+ const typeDef = config.raw?.types?.[type] ?? {};
15
+ const context = config.typeContextConfig?.get(type) ?? typeDef.context ?? {};
16
+ const contextByStatus = new Map();
17
+ for (const bucket of ['expanded', 'listed', 'counted']) {
18
+ for (const status of context[bucket] ?? []) {
19
+ if (!contextByStatus.has(status)) contextByStatus.set(status, bucket);
20
+ }
21
+ }
22
+
23
+ byType[type] = statuses.map((name, rank) => {
24
+ const skipStale = config.lifecycle.skipStaleFor.has(name);
25
+ const skipWarnings = config.lifecycle.skipWarningsFor.has(name);
26
+ const hasTypeStaleDays = Object.prototype.hasOwnProperty.call(typeDef.staleDays ?? {}, name);
27
+ return {
28
+ name,
29
+ rank,
30
+ context: contextByStatus.get(name) ?? 'counted',
31
+ staleDays: hasTypeStaleDays ? typeDef.staleDays[name] : (config.staleDaysByStatus?.[name] ?? null),
32
+ startable: config.lifecycle.startableStatuses.has(name),
33
+ terminal: config.lifecycle.terminalStatuses.has(name),
34
+ archive: config.lifecycle.archiveStatuses.has(name),
35
+ filed: config.lifecycle.filedStatuses.get(name) ?? null,
36
+ skipStale,
37
+ skipWarnings,
38
+ quiet: skipStale && skipWarnings,
39
+ requiresModule: config.moduleRequiredStatuses.has(name),
40
+ };
41
+ });
42
+ }
43
+
44
+ const resolved = { typeOrder, byType };
45
+ CACHE.set(config, resolved);
46
+ return resolved;
47
+ }
48
+
49
+ export function statusMetadataFor(config, type, status) {
50
+ if (!type || !status) return null;
51
+ return resolveStatusMetadata(config).byType[type]?.find(item => item.name === status) ?? null;
52
+ }
53
+
54
+ export function statusesForContext(config, type, bucket) {
55
+ return (resolveStatusMetadata(config).byType[type] ?? [])
56
+ .filter(item => item.context === bucket)
57
+ .map(item => item.name);
58
+ }
59
+
60
+ export function statusHasContext(config, type, status, bucket) {
61
+ return statusMetadataFor(config, type, status)?.context === bucket;
62
+ }
63
+
64
+ export function actionablePromptStatuses(config) {
65
+ const expanded = statusesForContext(config, 'prompt', 'expanded');
66
+ return new Set(expanded.length > 0 ? expanded : ['pending']);
67
+ }
68
+
69
+ export function compareStrings(a, b) {
70
+ return a < b ? -1 : a > b ? 1 : 0;
71
+ }
72
+
73
+ // Oldest actionable prompt first. Missing dates sort last; path is the stable
74
+ // final tie-breaker shared by HUD, agent-context, and no-arg `dotmd use`.
75
+ export function comparePromptDocs(a, b) {
76
+ const aCreated = a.created ?? '';
77
+ const bCreated = b.created ?? '';
78
+ if (aCreated && bCreated && aCreated !== bCreated) return compareStrings(aCreated, bCreated);
79
+ if (aCreated && !bCreated) return -1;
80
+ if (!aCreated && bCreated) return 1;
81
+ const aUpdated = a.updated ?? '';
82
+ const bUpdated = b.updated ?? '';
83
+ if (aUpdated && bUpdated && aUpdated !== bUpdated) return compareStrings(aUpdated, bUpdated);
84
+ if (aUpdated && !bUpdated) return -1;
85
+ if (!aUpdated && bUpdated) return 1;
86
+ return compareStrings(a.path ?? '', b.path ?? '');
87
+ }