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