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.
- package/README.md +144 -964
- package/bin/dotmd.mjs +241 -197
- package/dotmd.config.example.mjs +5 -8
- package/package.json +6 -10
- package/src/agent-context.mjs +132 -0
- package/src/atomic-mutation.mjs +1505 -0
- package/src/baton.mjs +109 -87
- package/src/bulk-tag.mjs +7 -7
- package/src/commands.mjs +326 -12
- package/src/completions.mjs +38 -98
- package/src/config.mjs +18 -3
- package/src/diff.mjs +7 -3
- package/src/doctor.mjs +12 -5
- package/src/export.mjs +154 -25
- package/src/fix-refs.mjs +2 -0
- package/src/frontmatter-fix.mjs +2 -0
- package/src/frontmatter.mjs +3 -2
- package/src/git.mjs +531 -14
- package/src/graph.mjs +53 -25
- package/src/guard.mjs +163 -60
- package/src/hud.mjs +65 -76
- package/src/index-file.mjs +28 -16
- package/src/index.mjs +17 -12
- package/src/init.mjs +1 -1
- package/src/journal.mjs +145 -12
- package/src/lifecycle.mjs +554 -282
- package/src/lint.mjs +57 -9
- package/src/managed-path.mjs +192 -0
- package/src/migrate-prompts.mjs +2 -0
- package/src/migrate-template.mjs +2 -0
- package/src/migrate.mjs +7 -1
- package/src/new.mjs +135 -54
- package/src/output-identity.mjs +106 -0
- package/src/pickup-card.mjs +24 -10
- package/src/pickup.mjs +457 -0
- package/src/prompts.mjs +138 -32
- package/src/query.mjs +22 -10
- package/src/reference-planner.mjs +292 -0
- package/src/rename.mjs +65 -73
- package/src/render.mjs +17 -8
- package/src/runlist.mjs +109 -71
- package/src/section.mjs +2 -1
- package/src/ship.mjs +39 -20
- package/src/stats.mjs +1 -1
- package/src/status-metadata.mjs +87 -0
- package/src/statuses.mjs +11 -26
- package/src/summary.mjs +14 -3
- package/src/update.mjs +38 -10
- package/src/use.mjs +4 -1
- package/src/util.mjs +1 -0
- package/src/validate.mjs +14 -6
- package/src/watch.mjs +6 -1
- package/src/notion.mjs +0 -528
package/src/runlist.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
683
|
-
|
|
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
|
-
//
|
|
689
|
-
//
|
|
690
|
-
if (!dryRun)
|
|
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
|
|
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
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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
|
|
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
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
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
|
-
|
|
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
|
-
|
|
891
|
+
const clearUpdates = [];
|
|
861
892
|
if (clearParent) {
|
|
862
|
-
for (const
|
|
863
|
-
const
|
|
864
|
-
if (
|
|
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)
|
|
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
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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.
|
|
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
|
+
}
|