runlist 0.0.0 → 0.94.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/LICENSE +21 -0
- package/README.md +1114 -6
- package/assets/app/app.css +283 -0
- package/assets/app/app.mjs +381 -0
- package/assets/app/block-editor.mjs +182 -0
- package/assets/app/block-model.mjs +52 -0
- package/assets/app/document-create.mjs +86 -0
- package/assets/app/document-lifecycle.mjs +36 -0
- package/assets/app/document-stages.mjs +22 -0
- package/assets/app/document-views.mjs +33 -0
- package/assets/app/document-yardstick.mjs +258 -0
- package/assets/app/editor-navigation.mjs +51 -0
- package/assets/app/filing-navigation.mjs +138 -0
- package/assets/app/git-changes.mjs +80 -0
- package/assets/app/git-commit.mjs +46 -0
- package/assets/app/index.html +70 -0
- package/assets/app/library-navigation.mjs +93 -0
- package/assets/app/outline.mjs +18 -0
- package/assets/app/quick-navigation.mjs +52 -0
- package/assets/app/record-navigation.mjs +78 -0
- package/assets/app/recovery-center.mjs +53 -0
- package/assets/app/semantic-search.mjs +14 -0
- package/assets/app/settings.mjs +15 -0
- package/assets/app/shared.mjs +104 -0
- package/assets/app/stage-source.mjs +75 -0
- package/assets/app/template-editor.mjs +47 -0
- package/assets/app/theme.mjs +25 -0
- package/assets/app/transport.mjs +43 -0
- package/assets/contracts/source-editing-v1.md +232 -0
- package/assets/opencode/plugin.js +162 -0
- package/assets/schemas/runlist-record-v1.schema.json +912 -0
- package/bin/dotmd.mjs +5 -0
- package/bin/runlist.mjs +2714 -0
- package/package.json +53 -6
- package/plugins/runlist-codex/.codex-plugin/plugin.json +18 -0
- package/plugins/runlist-codex/bin/runlist-hook +86 -0
- package/plugins/runlist-codex/hooks/hooks.json +16 -0
- package/plugins/runlist-codex/skills/runlist/SKILL.md +25 -0
- package/runlist.config.example.mjs +370 -0
- package/scripts/postinstall.mjs +56 -0
- package/src/agent-context.mjs +201 -0
- package/src/ai.mjs +30 -0
- package/src/app-create.mjs +169 -0
- package/src/app-filing.mjs +139 -0
- package/src/app-git-commits.mjs +48 -0
- package/src/app-git.mjs +180 -0
- package/src/app-library.mjs +152 -0
- package/src/app-lifecycle-worker.mjs +12 -0
- package/src/app-lifecycle.mjs +79 -0
- package/src/app-records.mjs +134 -0
- package/src/app-search.mjs +80 -0
- package/src/app-semantic-search.mjs +105 -0
- package/src/app-service.mjs +208 -0
- package/src/app-yardstick.mjs +269 -0
- package/src/app.mjs +102 -0
- package/src/atomic-mutation.mjs +1736 -0
- package/src/baton.mjs +435 -0
- package/src/body-link.mjs +48 -0
- package/src/bulk-tag.mjs +174 -0
- package/src/check-collapse.mjs +75 -0
- package/src/claude-commands.mjs +75 -0
- package/src/cli-editor.mjs +8 -0
- package/src/code-refs.mjs +545 -0
- package/src/codex-integration.mjs +81 -0
- package/src/color.mjs +14 -0
- package/src/commands.mjs +374 -0
- package/src/completions.mjs +93 -0
- package/src/config-edit.mjs +606 -0
- package/src/config.mjs +705 -0
- package/src/decision-action.mjs +58 -0
- package/src/decision.mjs +220 -0
- package/src/decisions.mjs +787 -0
- package/src/deps.mjs +317 -0
- package/src/desktop-install.mjs +158 -0
- package/src/desktop-runtime.mjs +4 -0
- package/src/desktop.mjs +25 -0
- package/src/diff.mjs +101 -0
- package/src/doctor.mjs +591 -0
- package/src/durable-rename.mjs +55 -0
- package/src/errors-read.mjs +44 -0
- package/src/export.mjs +496 -0
- package/src/extractors.mjs +101 -0
- package/src/filing.mjs +255 -0
- package/src/fix-membership.mjs +134 -0
- package/src/fix-refs.mjs +174 -0
- package/src/flags.mjs +318 -0
- package/src/frontmatter-fix.mjs +195 -0
- package/src/frontmatter.mjs +252 -0
- package/src/git-commit-filters.mjs +32 -0
- package/src/git-commit-protocol.mjs +198 -0
- package/src/git-commit-signing.mjs +75 -0
- package/src/git-commit-store.mjs +26 -0
- package/src/git-commit-support.mjs +16 -0
- package/src/git-commit-worker.mjs +32 -0
- package/src/git.mjs +909 -0
- package/src/glossary-check.mjs +35 -0
- package/src/glossary.mjs +243 -0
- package/src/graph.mjs +298 -0
- package/src/guard.mjs +488 -0
- package/src/health.mjs +212 -0
- package/src/hints.mjs +129 -0
- package/src/host-integration.mjs +339 -0
- package/src/hub-membership.mjs +284 -0
- package/src/hub.mjs +292 -0
- package/src/hud.mjs +436 -0
- package/src/index-file.mjs +163 -0
- package/src/index.mjs +478 -0
- package/src/init.mjs +512 -0
- package/src/install.mjs +222 -0
- package/src/journal-read.mjs +116 -0
- package/src/journal.mjs +472 -0
- package/src/lifecycle.mjs +1444 -0
- package/src/lint.mjs +386 -0
- package/src/managed-path.mjs +192 -0
- package/src/markdown-code-spans.mjs +58 -0
- package/src/migrate-prompts.mjs +171 -0
- package/src/migrate-template.mjs +170 -0
- package/src/migrate.mjs +90 -0
- package/src/misuse-read.mjs +61 -0
- package/src/model-request.mjs +33 -0
- package/src/model.mjs +523 -0
- package/src/modules.mjs +355 -0
- package/src/naming.mjs +55 -0
- package/src/native-action.mjs +59 -0
- package/src/native-record.mjs +244 -0
- package/src/new.mjs +1321 -0
- package/src/output-identity.mjs +111 -0
- package/src/parent-plan.mjs +58 -0
- package/src/parse-cache.mjs +99 -0
- package/src/path-containment.mjs +15 -0
- package/src/pickup-card.mjs +293 -0
- package/src/pickup.mjs +671 -0
- package/src/prompt.mjs +25 -0
- package/src/prompts.mjs +585 -0
- package/src/query.mjs +977 -0
- package/src/record-action.mjs +11 -0
- package/src/record-create.mjs +82 -0
- package/src/reference-planner.mjs +388 -0
- package/src/reference-values.mjs +11 -0
- package/src/rename.mjs +107 -0
- package/src/render.mjs +678 -0
- package/src/roadmap.mjs +184 -0
- package/src/runlist.mjs +944 -0
- package/src/section.mjs +225 -0
- package/src/semantic-search-client.mjs +58 -0
- package/src/ship.mjs +166 -0
- package/src/show.mjs +101 -0
- package/src/skill-drift.mjs +98 -0
- package/src/source-editor.mjs +491 -0
- package/src/stages.mjs +73 -0
- package/src/state-migration.mjs +123 -0
- package/src/stats.mjs +200 -0
- package/src/status-metadata.mjs +87 -0
- package/src/statuses.mjs +721 -0
- package/src/summary.mjs +74 -0
- package/src/surfaces.mjs +28 -0
- package/src/sync-status.mjs +316 -0
- package/src/template-store.mjs +62 -0
- package/src/update.mjs +338 -0
- package/src/use.mjs +58 -0
- package/src/util.mjs +337 -0
- package/src/validate.mjs +794 -0
- package/src/watch.mjs +56 -0
- package/src/xref.mjs +406 -0
- package/src/yardstick-command.mjs +178 -0
- package/src/yardstick.mjs +112 -0
package/src/runlist.mjs
ADDED
|
@@ -0,0 +1,944 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { extractFrontmatter, parseSimpleFrontmatter, replaceFrontmatter } from './frontmatter.mjs';
|
|
4
|
+
import { extractFirstHeading } from './extractors.mjs';
|
|
5
|
+
import {
|
|
6
|
+
asString,
|
|
7
|
+
die,
|
|
8
|
+
isArchivedPath,
|
|
9
|
+
normalizeStringList,
|
|
10
|
+
nowIso,
|
|
11
|
+
resolveRefPath,
|
|
12
|
+
toRepoPath,
|
|
13
|
+
toSlug,
|
|
14
|
+
warn,
|
|
15
|
+
} from './util.mjs';
|
|
16
|
+
import { detectBodyRunlistRefs, isCoordinationHub, isRoadmapHub } from './hub.mjs';
|
|
17
|
+
import { resolveDocArg } from './index.mjs';
|
|
18
|
+
import { runlistChildContent, slugify, titleize } from './new.mjs';
|
|
19
|
+
import { bold, cyan, dim, green, red, yellow } from './color.mjs';
|
|
20
|
+
import { authorizeManagedDestination, authorizeManagedSource } from './managed-path.mjs';
|
|
21
|
+
import { mutateFileSet, MutationConflictError } from './atomic-mutation.mjs';
|
|
22
|
+
import { pickupFactsForDoc } from './pickup.mjs';
|
|
23
|
+
import { planChildParentUpdate, upsertFrontmatterField } from './parent-plan.mjs';
|
|
24
|
+
|
|
25
|
+
// A child is the runlist's NEXT PICKUP only when a session could start it right
|
|
26
|
+
// now — i.e. its status is one `runlist use` accepts. The "parked" statuses
|
|
27
|
+
// (blocked/partial/paused/awaiting/queued-after) are deliberately NOT
|
|
28
|
+
// pickup-able: each needs its own unstuck action (monitor / spawn successor /
|
|
29
|
+
// re-evaluate / ask / check predecessor) before work resumes. So next-pickup
|
|
30
|
+
// resolution skips them and advances to the first child that's actually
|
|
31
|
+
// startable. Skipping ≠ done, though: a parked child is not archived, so it
|
|
32
|
+
// never counts toward `done/total` — that tally tracks closed (archived) only.
|
|
33
|
+
// This keeps the `→` marker in agreement with `runlist next`, which already
|
|
34
|
+
// gates on PICKUPABLE_STATUSES.
|
|
35
|
+
function isPickupable(status, doc, config) {
|
|
36
|
+
if (!doc || !config) return false;
|
|
37
|
+
return pickupFactsForDoc({ ...doc, status }, config).pickupable;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Build a hub/child map straight from the in-memory index — no disk IO. A doc
|
|
41
|
+
// is a runlist *hub* when its `runlist:` frontmatter (`refFields.runlist`) is
|
|
42
|
+
// non-empty. Each ref resolves to a doc in the index by path, falling back to
|
|
43
|
+
// basename so children that were archived (and physically moved into an
|
|
44
|
+
// archive dir) still resolve. Used by the `runlist plans` triage view to fold
|
|
45
|
+
// children under their hub and tag hubs as runlists rather than plain plans.
|
|
46
|
+
//
|
|
47
|
+
// Returns:
|
|
48
|
+
// hubs: Map<hubPath, { hub, total, doneCount, children, nextChildPath }>
|
|
49
|
+
// children: [{ ref, doc, path, status, archived, missing }] in runlist order
|
|
50
|
+
// childToHub: Map<childPath, hubPath> (first hub wins on the rare double-claim)
|
|
51
|
+
export function buildRunlistIndex(index, config) {
|
|
52
|
+
const archiveStatuses = config.lifecycle?.archiveStatuses ?? new Set(['archived']);
|
|
53
|
+
const docByPath = new Map(index.docs.map(d => [d.path, d]));
|
|
54
|
+
const byBasename = new Map();
|
|
55
|
+
for (const d of index.docs) {
|
|
56
|
+
const base = d.path.split('/').pop();
|
|
57
|
+
if (!byBasename.has(base)) byBasename.set(base, d);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const hubs = new Map();
|
|
61
|
+
const childToHub = new Map();
|
|
62
|
+
for (const hub of index.docs) {
|
|
63
|
+
const refs = hub.refFields?.runlist ?? [];
|
|
64
|
+
if (refs.length === 0) continue;
|
|
65
|
+
const hubDir = path.dirname(path.join(config.repoRoot, hub.path));
|
|
66
|
+
|
|
67
|
+
const children = [];
|
|
68
|
+
for (const ref of refs) {
|
|
69
|
+
const abs = resolveRefPath(ref, hubDir, config.repoRoot);
|
|
70
|
+
let childDoc = abs ? docByPath.get(toRepoPath(abs, config.repoRoot)) ?? null : null;
|
|
71
|
+
if (!childDoc) childDoc = byBasename.get(ref.split('/').pop()) ?? null;
|
|
72
|
+
if (childDoc) {
|
|
73
|
+
const archived = archiveStatuses.has(childDoc.status) || isArchivedPath(childDoc.path, config);
|
|
74
|
+
children.push({ ref, doc: childDoc, path: childDoc.path, status: childDoc.status, archived, missing: false });
|
|
75
|
+
if (!childToHub.has(childDoc.path)) childToHub.set(childDoc.path, hub.path);
|
|
76
|
+
} else {
|
|
77
|
+
children.push({ ref, doc: null, path: null, status: null, archived: false, missing: true });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Next pickup = first child a session can actually start. Skip archived
|
|
82
|
+
// (done) AND parked children alike; advance to the first pickup-able one.
|
|
83
|
+
const next = children.find(c => !c.missing && !c.archived && isPickupable(c.status, c.doc, config)) ?? null;
|
|
84
|
+
hubs.set(hub.path, {
|
|
85
|
+
hub,
|
|
86
|
+
total: children.length,
|
|
87
|
+
doneCount: children.filter(c => c.archived).length,
|
|
88
|
+
// Live-but-not-startable children (parked: blocked/partial/paused/…). Lets
|
|
89
|
+
// the `runlist plans` fold say "N parked" instead of mislabelling a hub with
|
|
90
|
+
// a parked-but-unfinished child as "all archived".
|
|
91
|
+
parkedCount: children.filter(c => !c.missing && !c.archived && !isPickupable(c.status, c.doc, config)).length,
|
|
92
|
+
children,
|
|
93
|
+
nextChildPath: next?.path ?? null,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { hubs, childToHub };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The hub predicates live in `hub.mjs` — a leaf module, so the check pipeline
|
|
101
|
+
// (which runs from `index.mjs`, and can't import this file without a cycle) can
|
|
102
|
+
// share ONE definition of "hub" with the views here. Re-exported so every
|
|
103
|
+
// existing importer keeps reaching them at `runlist.mjs`.
|
|
104
|
+
export { isCoordinationHub, isRoadmapHub };
|
|
105
|
+
|
|
106
|
+
// Map each coordination hub to a `childCount` derived from its `related_plans:`
|
|
107
|
+
// cluster (resolved against the index; peers/self excluded). It's an
|
|
108
|
+
// approximation — `related_plans` is a *related* cluster, not a strict child
|
|
109
|
+
// list — so it's shown as a rough "N plans" hint, not an authoritative count.
|
|
110
|
+
// Each hub also gets a `nextPickup` (or null) parsed from its body order — the
|
|
111
|
+
// first non-archived ranked child — so prose-first hubs surface a next-pickup
|
|
112
|
+
// target the way sprint `runlist:` hubs do. Reads each hub's file (a small,
|
|
113
|
+
// bounded set), so this is no longer pure in-memory like `buildRunlistIndex`.
|
|
114
|
+
export function buildCoordinationIndex(index, config) {
|
|
115
|
+
const docByPath = new Map(index.docs.map(d => [d.path, d]));
|
|
116
|
+
const byBasename = new Map();
|
|
117
|
+
for (const d of index.docs) {
|
|
118
|
+
const base = d.path.split('/').pop();
|
|
119
|
+
if (!byBasename.has(base)) byBasename.set(base, d);
|
|
120
|
+
}
|
|
121
|
+
const archiveStatuses = config.lifecycle?.archiveStatuses ?? new Set(['archived']);
|
|
122
|
+
|
|
123
|
+
// Resolve a path-or-basename ref (from frontmatter or body) to an indexed doc.
|
|
124
|
+
const resolveRef = (ref, dir) => {
|
|
125
|
+
const abs = resolveRefPath(ref, dir, config.repoRoot);
|
|
126
|
+
let child = abs ? docByPath.get(toRepoPath(abs, config.repoRoot)) ?? null : null;
|
|
127
|
+
if (!child) child = byBasename.get(ref.split('/').pop()) ?? null;
|
|
128
|
+
return child;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const hubs = new Map();
|
|
132
|
+
for (const doc of index.docs) {
|
|
133
|
+
if (!isCoordinationHub(doc)) continue;
|
|
134
|
+
const dir = path.dirname(path.join(config.repoRoot, doc.path));
|
|
135
|
+
const refs = doc.refFields?.related_plans ?? [];
|
|
136
|
+
// Resolve the `related_plans` cluster to child plan docs (deduped by path;
|
|
137
|
+
// self and non-plans excluded). This is the membership set the rollup counts
|
|
138
|
+
// over — `related_plans` is the only child signal a prose-first coordination
|
|
139
|
+
// hub carries in frontmatter (its *body* order drives next-pickup, not
|
|
140
|
+
// membership). It's an approximation — a *related* cluster can include peer
|
|
141
|
+
// or parent runlists — so the done/total is a progress hint, not a contract.
|
|
142
|
+
const childPaths = new Set();
|
|
143
|
+
const children = [];
|
|
144
|
+
for (const ref of refs) {
|
|
145
|
+
const child = resolveRef(ref, dir);
|
|
146
|
+
if (!child || child.path === doc.path) continue;
|
|
147
|
+
if (!(child.type === 'plan' || child.type == null)) continue;
|
|
148
|
+
if (childPaths.has(child.path)) continue;
|
|
149
|
+
childPaths.add(child.path);
|
|
150
|
+
const archived = archiveStatuses.has(child.status) || isArchivedPath(child.path, config);
|
|
151
|
+
children.push({ path: child.path, status: child.status ?? null, archived, doc: child });
|
|
152
|
+
}
|
|
153
|
+
// Rollup, mirroring buildRunlistIndex: done = archived; parked = live but not
|
|
154
|
+
// startable (blocked/partial/paused/awaiting/queued-after). `childCount`
|
|
155
|
+
// stays an alias of `total` so existing callers (sorters, JSON) keep working.
|
|
156
|
+
const doneCount = children.filter(c => c.archived).length;
|
|
157
|
+
const parkedCount = children.filter(c => !c.archived && !isPickupable(c.status, c.doc, config)).length;
|
|
158
|
+
const nextPickup = resolveHubNextPickup(doc, dir, resolveRef, archiveStatuses, config);
|
|
159
|
+
hubs.set(doc.path, {
|
|
160
|
+
doc,
|
|
161
|
+
childCount: childPaths.size,
|
|
162
|
+
total: childPaths.size,
|
|
163
|
+
doneCount,
|
|
164
|
+
parkedCount,
|
|
165
|
+
childPaths,
|
|
166
|
+
children,
|
|
167
|
+
nextPickup,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
return hubs;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Build the tier-3 rollup. For each roadmap hub, resolve its children (the
|
|
174
|
+
// `related_plans:` cluster, exactly like a coordination hub's membership) and
|
|
175
|
+
// roll each child's own done/total up into a grand total. A child that is itself
|
|
176
|
+
// a hub contributes its hub rollup — sprint via `buildRunlistIndex`, coordination
|
|
177
|
+
// via `buildCoordinationIndex`; a plain-plan child contributes one unit. The
|
|
178
|
+
// grand total is the SUM of the children's totals (not a deduped union), so it
|
|
179
|
+
// always equals the sum of the per-child rows the dashboard renders — two child
|
|
180
|
+
// runlists that share a plan double-count it, the same "progress hint, not a
|
|
181
|
+
// contract" approximation coordination-hub rollup already carries.
|
|
182
|
+
//
|
|
183
|
+
// Reuses precomputed `coordination` / `runlist` indexes when the caller has them
|
|
184
|
+
// (the views build coordination already); otherwise builds what it needs. Returns
|
|
185
|
+
// Map<roadmapPath, { doc, children, childCount, grandTotal, grandDone, grandParked }>
|
|
186
|
+
// with each child = { doc, path, kind: 'runlist'|'coordination'|'plan', total,
|
|
187
|
+
// doneCount, parkedCount, nextPath, nextLabel } in `related_plans` order.
|
|
188
|
+
export function buildRoadmapIndex(index, config, precomputed = {}) {
|
|
189
|
+
const roadmaps = index.docs.filter(isRoadmapHub);
|
|
190
|
+
if (roadmaps.length === 0) return new Map();
|
|
191
|
+
|
|
192
|
+
const coordination = precomputed.coordination ?? buildCoordinationIndex(index, config);
|
|
193
|
+
const runlist = precomputed.runlist ?? buildRunlistIndex(index, config);
|
|
194
|
+
const archiveStatuses = config.lifecycle?.archiveStatuses ?? new Set(['archived']);
|
|
195
|
+
|
|
196
|
+
const docByPath = new Map(index.docs.map(d => [d.path, d]));
|
|
197
|
+
const byBasename = new Map();
|
|
198
|
+
for (const d of index.docs) {
|
|
199
|
+
const base = d.path.split('/').pop();
|
|
200
|
+
if (!byBasename.has(base)) byBasename.set(base, d);
|
|
201
|
+
}
|
|
202
|
+
const resolveRef = (ref, dir) => {
|
|
203
|
+
const abs = resolveRefPath(ref, dir, config.repoRoot);
|
|
204
|
+
let child = abs ? docByPath.get(toRepoPath(abs, config.repoRoot)) ?? null : null;
|
|
205
|
+
if (!child) child = byBasename.get(ref.split('/').pop()) ?? null;
|
|
206
|
+
return child;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// Rollup numbers for one child of a roadmap, dispatched by what the child IS:
|
|
210
|
+
// a sprint runlist hub, a coordination hub, or a plain leaf plan. `nextPath` /
|
|
211
|
+
// `nextLabel` give a uniform per-child next-pickup target — the first startable
|
|
212
|
+
// plan *inside* that child — feeding both the `runlist roadmap` view and the
|
|
213
|
+
// Phase-4 cross-runlist `runlist roadmap next` (walk children → first nextPath).
|
|
214
|
+
const childRollup = (child) => {
|
|
215
|
+
if (runlist.hubs.has(child.path)) {
|
|
216
|
+
const h = runlist.hubs.get(child.path);
|
|
217
|
+
const nextPath = h.nextChildPath ?? null;
|
|
218
|
+
return { kind: 'runlist', total: h.total, doneCount: h.doneCount, parkedCount: h.parkedCount,
|
|
219
|
+
nextPath, nextLabel: nextPath ? path.basename(nextPath, '.md') : null };
|
|
220
|
+
}
|
|
221
|
+
if (coordination.has(child.path)) {
|
|
222
|
+
const h = coordination.get(child.path);
|
|
223
|
+
return { kind: 'coordination', total: h.total, doneCount: h.doneCount, parkedCount: h.parkedCount,
|
|
224
|
+
nextPath: h.nextPickup?.path ?? null, nextLabel: h.nextPickup?.label ?? null };
|
|
225
|
+
}
|
|
226
|
+
// A leaf-plan child is its own next action when it's startable.
|
|
227
|
+
const archived = archiveStatuses.has(child.status) || isArchivedPath(child.path, config);
|
|
228
|
+
const parked = !archived && !isPickupable(child.status, child, config);
|
|
229
|
+
const pickupable = !archived && isPickupable(child.status, child, config);
|
|
230
|
+
return { kind: 'plan', total: 1, doneCount: archived ? 1 : 0, parkedCount: parked ? 1 : 0,
|
|
231
|
+
nextPath: pickupable ? child.path : null, nextLabel: pickupable ? toSlug(child) : null };
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const out = new Map();
|
|
235
|
+
for (const doc of roadmaps) {
|
|
236
|
+
const dir = path.dirname(path.join(config.repoRoot, doc.path));
|
|
237
|
+
const refs = doc.refFields?.related_plans ?? [];
|
|
238
|
+
const seen = new Set();
|
|
239
|
+
const children = [];
|
|
240
|
+
let grandTotal = 0, grandDone = 0, grandParked = 0;
|
|
241
|
+
for (const ref of refs) {
|
|
242
|
+
const child = resolveRef(ref, dir);
|
|
243
|
+
if (!child || child.path === doc.path) continue;
|
|
244
|
+
if (!(child.type === 'plan' || child.type == null)) continue;
|
|
245
|
+
if (seen.has(child.path)) continue;
|
|
246
|
+
seen.add(child.path);
|
|
247
|
+
const roll = childRollup(child);
|
|
248
|
+
grandTotal += roll.total;
|
|
249
|
+
grandDone += roll.doneCount;
|
|
250
|
+
grandParked += roll.parkedCount;
|
|
251
|
+
children.push({ doc: child, path: child.path, ...roll });
|
|
252
|
+
}
|
|
253
|
+
out.set(doc.path, { doc, children, childCount: children.length, grandTotal, grandDone, grandParked });
|
|
254
|
+
}
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Conventional container dirs whose name adds no disambiguation to a hub label.
|
|
259
|
+
const HUB_CONTAINER_DIRS = new Set(['plans', 'prompts', 'archive', 'archived']);
|
|
260
|
+
|
|
261
|
+
// Display label for a hub. A bare basename loses context for hubs that live in
|
|
262
|
+
// a subdirectory (e.g. `docs/plans/pos/runlist.md` would read as just
|
|
263
|
+
// `runlist`), so prefix the immediate parent dir unless it's a conventional
|
|
264
|
+
// container. → `pos/runlist`, but `billing-runlist` stays as-is. Shared by the
|
|
265
|
+
// `runlist plans` Runlists section, `runlist runlists`, and `runlist health` so a hub
|
|
266
|
+
// reads the same everywhere.
|
|
267
|
+
export function hubLabel(doc) {
|
|
268
|
+
const slug = toSlug(doc);
|
|
269
|
+
const parent = path.basename(path.dirname(doc.path));
|
|
270
|
+
return HUB_CONTAINER_DIRS.has(parent) ? slug : `${parent}/${slug}`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Bare hub slugs resolve through the shared resolver; the caller keeps its
|
|
274
|
+
// runlist-specific miss message, so no die-on-miss here.
|
|
275
|
+
function resolveHubInput(input, config) {
|
|
276
|
+
return resolveDocArg(input, config, { dieOnMiss: false });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Read a hub plan's `runlist:` and resolve each entry to a repo-relative path
|
|
280
|
+
// plus its current status. Missing files are reported with `missing: true`;
|
|
281
|
+
// callers decide how to render them. Pure: no IO beyond file reads.
|
|
282
|
+
function resolveRunlistRefs(refs, hubAbsPath, config) {
|
|
283
|
+
const hubDir = path.dirname(hubAbsPath);
|
|
284
|
+
const out = [];
|
|
285
|
+
for (const ref of refs) {
|
|
286
|
+
const abs = resolveRefPath(ref, hubDir, config.repoRoot);
|
|
287
|
+
if (!abs) {
|
|
288
|
+
out.push({ ref, path: null, status: null, title: null, missing: true });
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
const repoPath = toRepoPath(abs, config.repoRoot);
|
|
292
|
+
try {
|
|
293
|
+
const childRaw = readFileSync(abs, 'utf8');
|
|
294
|
+
const { frontmatter: childFmRaw, body: childBody } = extractFrontmatter(childRaw);
|
|
295
|
+
const childFm = parseSimpleFrontmatter(childFmRaw);
|
|
296
|
+
out.push({
|
|
297
|
+
ref,
|
|
298
|
+
path: repoPath,
|
|
299
|
+
status: asString(childFm.status) ?? null,
|
|
300
|
+
type: asString(childFm.type) ?? null,
|
|
301
|
+
// Fall back to the body H1 (like the main index) before the bare
|
|
302
|
+
// filename — runlist child stubs carry their title as an H1, not a
|
|
303
|
+
// `title:` field, so this keeps the synced body order list readable.
|
|
304
|
+
title: asString(childFm.title) ?? extractFirstHeading(childBody) ?? path.basename(abs, '.md'),
|
|
305
|
+
parentPlan: childFm.parent_plan ?? null,
|
|
306
|
+
missing: false,
|
|
307
|
+
});
|
|
308
|
+
} catch {
|
|
309
|
+
out.push({ ref, path: repoPath, status: null, title: null, missing: true });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return out;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
// Label for a hub's next-pickup child: its slug with the hub's leading module
|
|
317
|
+
// segment stripped when shared (so `founder-runlist` → `founder-brand-conflicts`
|
|
318
|
+
// reads as `brand-conflicts`), mirroring how sprint children drop the hub
|
|
319
|
+
// prefix. Falls back to the full slug when there's no shared leading segment.
|
|
320
|
+
function coordinationChildLabel(childDoc, hubDoc) {
|
|
321
|
+
const childSlug = toSlug(childDoc);
|
|
322
|
+
const seg = toSlug(hubDoc).split('-')[0];
|
|
323
|
+
if (seg.length >= 2 && childSlug.startsWith(`${seg}-`) && childSlug.length > seg.length + 1) {
|
|
324
|
+
return childSlug.slice(seg.length + 1);
|
|
325
|
+
}
|
|
326
|
+
return childSlug;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Read a coordination hub's body order (a `## Ranked queue` table or a
|
|
330
|
+
// `## Order of operations` link list) and return its NEXT PICKUP: the first
|
|
331
|
+
// ranked child that isn't archived, resolved to its live status from the index.
|
|
332
|
+
// Prose-first hubs keep their sequence in the body, invisible to the
|
|
333
|
+
// frontmatter-only index — this surfaces `next → <child>` the way sprint
|
|
334
|
+
// `runlist:` hubs already do. Returns null when the hub has no parseable body
|
|
335
|
+
// order or every ranked child is archived. Best-effort: a read failure degrades
|
|
336
|
+
// to null, never throws.
|
|
337
|
+
function resolveHubNextPickup(hubDoc, hubDir, resolveRef, archiveStatuses, config) {
|
|
338
|
+
let body;
|
|
339
|
+
try {
|
|
340
|
+
({ body } = extractFrontmatter(readFileSync(path.join(config.repoRoot, hubDoc.path), 'utf8')));
|
|
341
|
+
} catch {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
for (const ref of detectBodyRunlistRefs(body)) {
|
|
345
|
+
const child = resolveRef(ref, hubDir);
|
|
346
|
+
if (!child || child.path === hubDoc.path) continue;
|
|
347
|
+
if (child.type && child.type !== 'plan') continue;
|
|
348
|
+
const archived = archiveStatuses.has(child.status) || isArchivedPath(child.path, config);
|
|
349
|
+
if (archived) continue;
|
|
350
|
+
// Skip parked ranked children too (blocked/partial/paused/awaiting/
|
|
351
|
+
// queued-after) — the hub's next-pickup is the first startable plan, the
|
|
352
|
+
// same gate sprint runlists use, so a hub never points `→` at a child a
|
|
353
|
+
// session can't actually pick up.
|
|
354
|
+
if (!isPickupable(child.status, child, config)) continue;
|
|
355
|
+
return { path: child.path, status: child.status ?? null, label: coordinationChildLabel(child, hubDoc) };
|
|
356
|
+
}
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function readRunlistChildren(hubAbsPath, config) {
|
|
361
|
+
const raw = readFileSync(hubAbsPath, 'utf8');
|
|
362
|
+
const { frontmatter: fmRaw, body } = extractFrontmatter(raw);
|
|
363
|
+
const fm = parseSimpleFrontmatter(fmRaw);
|
|
364
|
+
const refs = normalizeStringList(fm.runlist);
|
|
365
|
+
|
|
366
|
+
if (refs.length > 0) {
|
|
367
|
+
return { children: resolveRunlistRefs(refs, hubAbsPath, config), source: 'frontmatter' };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const bodyRefs = detectBodyRunlistRefs(body);
|
|
371
|
+
return {
|
|
372
|
+
children: resolveRunlistRefs(bodyRefs, hubAbsPath, config),
|
|
373
|
+
source: bodyRefs.length > 0 ? 'body' : 'empty',
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const STATUS_TAG_COLORS = {
|
|
378
|
+
'in-session': (s) => bold(red(s)),
|
|
379
|
+
'active': green,
|
|
380
|
+
'planned': (s) => s,
|
|
381
|
+
'blocked': yellow,
|
|
382
|
+
'partial': (s) => dim(green(s)),
|
|
383
|
+
'paused': (s) => yellow(s),
|
|
384
|
+
'awaiting': yellow,
|
|
385
|
+
'queued-after': (s) => dim(cyan(s)),
|
|
386
|
+
'archived': dim,
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
function colorStatus(status) {
|
|
390
|
+
const fn = STATUS_TAG_COLORS[status] ?? ((s) => s);
|
|
391
|
+
return fn(status ?? 'unknown');
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function renderRunlist(hubRepoPath, children, opts = {}) {
|
|
395
|
+
const lines = [];
|
|
396
|
+
lines.push(bold(`runlist: ${hubRepoPath}`));
|
|
397
|
+
if (children.length === 0) {
|
|
398
|
+
lines.push(dim(' (empty — add child plan paths to the hub plan\'s `runlist:` field, or add markdown links under `## Order of Operations`)'));
|
|
399
|
+
return lines.join('\n') + '\n';
|
|
400
|
+
}
|
|
401
|
+
if (opts.source === 'body') {
|
|
402
|
+
lines.push(dim(' (from body links — add these paths to frontmatter `runlist:` to make the order canonical)'));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const archiveStatuses = opts.archiveStatuses ?? new Set(['archived']);
|
|
406
|
+
let nextPicked = false;
|
|
407
|
+
for (let i = 0; i < children.length; i++) {
|
|
408
|
+
const c = children[i];
|
|
409
|
+
const idx = String(i + 1).padStart(2);
|
|
410
|
+
if (c.missing) {
|
|
411
|
+
lines.push(` ${idx}. ${red('missing')} ${c.ref}`);
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
// → marks the first child a session can actually start: skip archived and
|
|
415
|
+
// parked (blocked/partial/paused/awaiting/queued-after) alike.
|
|
416
|
+
const isNext = !nextPicked && isPickupable(c.status, c, opts.config);
|
|
417
|
+
if (isNext) nextPicked = true;
|
|
418
|
+
const marker = isNext ? green('→') : ' ';
|
|
419
|
+
const statusTag = `[${colorStatus(c.status)}]`;
|
|
420
|
+
lines.push(` ${marker} ${idx}. ${statusTag} ${c.path}`);
|
|
421
|
+
}
|
|
422
|
+
if (!nextPicked) {
|
|
423
|
+
lines.push('');
|
|
424
|
+
// No pickup-able child. Distinguish "done — ready to archive" from "stuck —
|
|
425
|
+
// a parked child needs unsticking" so the agent knows which it is.
|
|
426
|
+
const parked = children.filter(c => !c.missing && !archiveStatuses.has(c.status));
|
|
427
|
+
lines.push(dim(parked.length === 0
|
|
428
|
+
? ' All children archived. Hub is ready for archive.'
|
|
429
|
+
: ` No pickup-able child — ${parked.length} parked. Unstick one (e.g. \`runlist set active <child>\`) to continue.`));
|
|
430
|
+
}
|
|
431
|
+
return lines.join('\n') + '\n';
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// --- `runlist add` mutation (Phase 1) -------------------------------------
|
|
435
|
+
|
|
436
|
+
function serializeBlockArray(key, items) {
|
|
437
|
+
if (items.length === 0) return `${key}:`;
|
|
438
|
+
return `${key}:\n${items.map(v => ` - ${v}`).join('\n')}`;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Hub-relative ref for a child path: a bare basename when the child sits in the
|
|
442
|
+
// hub's directory (the common case, matching how `--runlist` writes refs), else
|
|
443
|
+
// a relative path (forward slashes) so the ref resolves from the hub. This is
|
|
444
|
+
// the hub-relative resolution that lets an existing plan elsewhere be wired in
|
|
445
|
+
// (the thrice-parked "point a hub at an existing plan" carryover).
|
|
446
|
+
function hubRelativeRef(childAbs, hubDir) {
|
|
447
|
+
const rel = path.relative(hubDir, childAbs).split(path.sep).join('/');
|
|
448
|
+
return rel;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Resolve one `runlist add` child token against an existing hub. Returns one of:
|
|
452
|
+
// { kind: 'existing', abs, repoPath, ref } — token names a plan that exists
|
|
453
|
+
// { kind: 'scaffold', abs, repoPath, ref, slug, title } — bare slug to create
|
|
454
|
+
// Dies on an unresolvable path (a `/`-bearing token that points nowhere — we
|
|
455
|
+
// scaffold from bare slugs only, never invent a nested path).
|
|
456
|
+
function classifyChildToken(token, hubDir, hubSlug, pos, config) {
|
|
457
|
+
// Existing plan? Try hub-relative first (Phase 3 resolution), then the shared
|
|
458
|
+
// resolver (repo-relative / by-basename across the index).
|
|
459
|
+
const hubRel = resolveRefPath(token, hubDir, config.repoRoot)
|
|
460
|
+
|| (token.endsWith('.md') ? null : resolveRefPath(`${token}.md`, hubDir, config.repoRoot));
|
|
461
|
+
const abs = hubRel || resolveDocArg(token, config, { dieOnMiss: false });
|
|
462
|
+
if (abs) {
|
|
463
|
+
return {
|
|
464
|
+
kind: 'existing',
|
|
465
|
+
abs,
|
|
466
|
+
repoPath: toRepoPath(abs, config.repoRoot),
|
|
467
|
+
ref: hubRelativeRef(abs, hubDir),
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Not found. Scaffold a stub — but only from a bare slug. A `/`-bearing token
|
|
472
|
+
// is a path to a file that doesn't exist; refuse rather than guess.
|
|
473
|
+
if (token.includes('/') || token.includes(path.sep)) {
|
|
474
|
+
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.`);
|
|
475
|
+
}
|
|
476
|
+
const childSlug = slugify(token.replace(/\.md$/, ''));
|
|
477
|
+
if (!childSlug) die(`Child token resolves to an empty slug: "${token}"`);
|
|
478
|
+
const nn = String(pos).padStart(2, '0');
|
|
479
|
+
const file = `${hubSlug}-${nn}-${childSlug}.md`;
|
|
480
|
+
const childAbs = path.join(hubDir, file);
|
|
481
|
+
return {
|
|
482
|
+
kind: 'scaffold',
|
|
483
|
+
abs: childAbs,
|
|
484
|
+
repoPath: toRepoPath(childAbs, config.repoRoot),
|
|
485
|
+
ref: file,
|
|
486
|
+
slug: childSlug,
|
|
487
|
+
title: titleize(token.replace(/\.md$/, '')),
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// `runlist runlist add <hub> <child...>` — append children to a hub's `runlist:`
|
|
492
|
+
// array, scaffolding a `planned` stub for any bare-slug child that doesn't yet
|
|
493
|
+
// exist (mirroring `runlist new plan --runlist`) and wiring each child's
|
|
494
|
+
// `parent_plan:` back-ref. Coordination hubs (body-order, no `runlist:` array)
|
|
495
|
+
// are out of this path — guarded with an actionable message.
|
|
496
|
+
async function runRunlistAdd(positional, config, { dryRun, json, testHooks }) {
|
|
497
|
+
const hubInput = positional[0];
|
|
498
|
+
const childTokens = positional.slice(1);
|
|
499
|
+
if (!hubInput || childTokens.length === 0) {
|
|
500
|
+
die('Usage: runlist runlist add <hub-plan> <child...> (one or more child slugs or plan paths)');
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
let hubAbs = resolveHubInput(hubInput, config);
|
|
504
|
+
if (!hubAbs) die(`Hub plan not found: ${hubInput}`);
|
|
505
|
+
const hubAuthorization = authorizeManagedSource(hubAbs, config, { kind: 'Runlist hub source' });
|
|
506
|
+
hubAbs = hubAuthorization.path;
|
|
507
|
+
const hubRepoPath = toRepoPath(hubAbs, config.repoRoot);
|
|
508
|
+
const hubDir = path.dirname(hubAbs);
|
|
509
|
+
const hubSlug = path.basename(hubAbs, '.md');
|
|
510
|
+
|
|
511
|
+
const hubRaw = readFileSync(hubAbs, 'utf8');
|
|
512
|
+
const { frontmatter: hubFmRaw } = extractFrontmatter(hubRaw);
|
|
513
|
+
if (hubFmRaw == null) die(`Hub ${hubRepoPath} has no frontmatter.`);
|
|
514
|
+
const hubFm = parseSimpleFrontmatter(hubFmRaw);
|
|
515
|
+
const existingRefs = normalizeStringList(hubFm.runlist);
|
|
516
|
+
const isCoord = hubFm.execution_mode === 'coordination';
|
|
517
|
+
|
|
518
|
+
// Coordination hubs keep their order in the body (`## Ranked queue` /
|
|
519
|
+
// `## Order of operations`), not a `runlist:` array. Mutating that prose-first
|
|
520
|
+
// order is a separate path; for now point the user at it rather than writing a
|
|
521
|
+
// `runlist:` array onto a hub that deliberately doesn't use one.
|
|
522
|
+
if (isCoord && existingRefs.length === 0) {
|
|
523
|
+
die(
|
|
524
|
+
`${hubRepoPath} is a coordination hub (execution_mode: coordination) — it keeps its order in the body\n` +
|
|
525
|
+
`(\`## Ranked Queue\` table or \`## Order of Operations\` list), not a \`runlist:\` array.\n` +
|
|
526
|
+
`Add the plan as a ranked row/link there. \`runlist add\` manages sprint \`runlist:\` arrays.`,
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Resolve every token up front (so a bad token aborts before any write), then
|
|
531
|
+
// dedupe against refs already in the runlist (by resolved abs path).
|
|
532
|
+
const resolvedExisting = new Set();
|
|
533
|
+
for (const ref of existingRefs) {
|
|
534
|
+
const abs = resolveRefPath(ref, hubDir, config.repoRoot);
|
|
535
|
+
if (abs) resolvedExisting.add(abs);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const toAdd = [];
|
|
539
|
+
let pos = existingRefs.length;
|
|
540
|
+
for (const token of childTokens) {
|
|
541
|
+
pos += 1;
|
|
542
|
+
const c = classifyChildToken(token, hubDir, hubSlug, pos, config);
|
|
543
|
+
if (c.kind === 'existing') {
|
|
544
|
+
authorizeManagedSource(c.abs, config, { kind: 'Runlist child source' });
|
|
545
|
+
} else {
|
|
546
|
+
authorizeManagedDestination(c.abs, config, { root: hubAuthorization.root, kind: 'Runlist child scaffold destination' });
|
|
547
|
+
}
|
|
548
|
+
if (c.abs === hubAbs) { warn(`Skipping "${token}" — a hub can't list itself.`); pos -= 1; continue; }
|
|
549
|
+
if (resolvedExisting.has(c.abs)) { warn(`Skipping "${token}" — already in the runlist (${c.repoPath}).`); pos -= 1; continue; }
|
|
550
|
+
resolvedExisting.add(c.abs);
|
|
551
|
+
toAdd.push(c);
|
|
552
|
+
}
|
|
553
|
+
if (toAdd.length === 0) die('Nothing to add — all children were already in the runlist or skipped.');
|
|
554
|
+
|
|
555
|
+
const hubTitle = asString(hubFm.title) ?? titleize(hubSlug);
|
|
556
|
+
const today = nowIso();
|
|
557
|
+
const newRefs = [...existingRefs, ...toAdd.map(c => c.ref)];
|
|
558
|
+
|
|
559
|
+
if (json) {
|
|
560
|
+
process.stdout.write(JSON.stringify({
|
|
561
|
+
hub: hubRepoPath,
|
|
562
|
+
added: toAdd.map(c => ({ ref: c.ref, path: c.repoPath, scaffolded: c.kind === 'scaffold' })),
|
|
563
|
+
runlist: newRefs,
|
|
564
|
+
dryRun: !!dryRun,
|
|
565
|
+
}, null, 2) + '\n');
|
|
566
|
+
if (dryRun) return;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const prefix = dryRun ? `${dim('[dry-run]')} ` : '';
|
|
570
|
+
if (!json) process.stdout.write(bold(`${prefix}runlist add → ${hubRepoPath}`) + '\n');
|
|
571
|
+
|
|
572
|
+
const updates = [];
|
|
573
|
+
const creations = [];
|
|
574
|
+
for (const c of toAdd) {
|
|
575
|
+
if (c.kind === 'scaffold') {
|
|
576
|
+
creations.push({ path: c.abs, content: runlistChildContent(c.title, hubSlug, hubTitle, 'planned', today) });
|
|
577
|
+
if (!json) process.stdout.write(`${prefix} ${green('+')} ${c.repoPath} ${dim('(scaffolded · planned)')}\n`);
|
|
578
|
+
} else {
|
|
579
|
+
const planned = planChildParentUpdate(c.abs, hubAbs, config);
|
|
580
|
+
if (planned.reason === 'different-parent') {
|
|
581
|
+
warn(`${c.repoPath} already has parent_plan: ${planned.existing} — left as-is (not pointing it at the hub).`);
|
|
582
|
+
}
|
|
583
|
+
if (planned.update) updates.push(planned.update);
|
|
584
|
+
const note = planned.wrote ? 'existing · parent_plan set' : 'existing';
|
|
585
|
+
if (!json) process.stdout.write(`${prefix} ${green('+')} ${c.repoPath} ${dim(`(${note})`)}\n`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Preflight every child and the hub under one ordered lock set, then publish
|
|
590
|
+
// with rollback so a conflict cannot leave one side updated without the other.
|
|
591
|
+
if (!dryRun) {
|
|
592
|
+
updates.push(hubRunlistUpdate(hubAbs, newRefs, config, today, hubRaw, toAdd));
|
|
593
|
+
mutateFileSet({ updates, creations }, { repoRoot: config.repoRoot, testHooks });
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (!json) {
|
|
597
|
+
process.stdout.write(dim(` runlist now has ${newRefs.length} ${newRefs.length === 1 ? 'child' : 'children'}.`) + '\n');
|
|
598
|
+
if (!dryRun) {
|
|
599
|
+
process.stdout.write(dim(` Show: runlist runlist ${hubRepoPath} · pick up next: runlist runlist next ${hubRepoPath}`) + '\n');
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// --- `runlist remove` / `runlist reorder` mutation (Phase 2) ---------------
|
|
605
|
+
|
|
606
|
+
// Heading alternatives that carry a `## Order of operations`-style link list
|
|
607
|
+
// mirroring the `runlist:` array (the `new plan --runlist` scaffold writes one).
|
|
608
|
+
const ORDER_SECTION_RE = /^##\s+(?:Order of operations|Runlist|Execution order|Implementation order|Plan order)\b.*$/im;
|
|
609
|
+
|
|
610
|
+
// Keep a hub's body `## Order of operations` link list in sync with the
|
|
611
|
+
// authoritative `runlist:` array. Regenerates the numbered link block from
|
|
612
|
+
// `orderedRefs`, preserving each item's display title and trailing status
|
|
613
|
+
// marker (⬜/✅/…) for children that remain — so a hand-checked-off item keeps
|
|
614
|
+
// its mark across an add/remove/reorder. No such section → body untouched (we
|
|
615
|
+
// never invent one). Only the contiguous run of list-item lines is rewritten;
|
|
616
|
+
// surrounding prose (e.g. the "pick up the next child" note) is preserved.
|
|
617
|
+
function syncOrderList(body, orderedRefs, titleFor) {
|
|
618
|
+
const m = body.match(ORDER_SECTION_RE);
|
|
619
|
+
if (!m || m.index === undefined) return body;
|
|
620
|
+
const headingEnd = m.index + m[0].length;
|
|
621
|
+
const rest = body.slice(headingEnd);
|
|
622
|
+
const nextRel = rest.search(/^##\s+/m);
|
|
623
|
+
const sectionEnd = nextRel >= 0 ? headingEnd + nextRel : body.length;
|
|
624
|
+
|
|
625
|
+
const lines = body.slice(headingEnd, sectionEnd).split('\n');
|
|
626
|
+
const isItem = (l) => /^\s*(?:\d+\.|[-*])\s+/.test(l) && /\.md(?:[#)\s]|$)/.test(l);
|
|
627
|
+
let start = -1, end = -1;
|
|
628
|
+
for (let i = 0; i < lines.length; i++) {
|
|
629
|
+
if (isItem(lines[i])) { if (start === -1) start = i; end = i; }
|
|
630
|
+
else if (start !== -1) break; // first non-item after the block ends it
|
|
631
|
+
}
|
|
632
|
+
if (start === -1) return body; // section present but no list to sync
|
|
633
|
+
|
|
634
|
+
const linkRe = /\[([^\]]+)\]\(([^)]+\.md)(?:#[^)]*)?\)/;
|
|
635
|
+
const prior = new Map(); // basename → { title, marker }
|
|
636
|
+
for (let i = start; i <= end; i++) {
|
|
637
|
+
const lk = linkRe.exec(lines[i]);
|
|
638
|
+
if (lk) {
|
|
639
|
+
prior.set(lk[2].split('/').pop(), { title: lk[1], marker: lines[i].slice(lk.index + lk[0].length).trim() });
|
|
640
|
+
} else {
|
|
641
|
+
const bare = /(\S+\.md)/.exec(lines[i]);
|
|
642
|
+
if (bare) prior.set(bare[1].split('/').pop(), { title: null, marker: lines[i].slice(bare.index + bare[0].length).trim() });
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const rebuilt = orderedRefs.map((ref, i) => {
|
|
647
|
+
const prev = prior.get(ref.split('/').pop());
|
|
648
|
+
const title = prev?.title ?? titleFor(ref);
|
|
649
|
+
const marker = prev?.marker ? ` ${prev.marker}` : ' ⬜';
|
|
650
|
+
return `${i + 1}. [${title}](${ref})${marker}`;
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
const newLines = [...lines.slice(0, start), ...rebuilt, ...lines.slice(end + 1)];
|
|
654
|
+
return body.slice(0, headingEnd) + newLines.join('\n') + body.slice(sectionEnd);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Authoritative hub write: set `runlist:` to `newRefs`, bump `updated:`, and
|
|
658
|
+
// resync the body order list. Shared by add/remove/reorder so all three keep
|
|
659
|
+
// the frontmatter array and the body link list consistent.
|
|
660
|
+
function hubRunlistUpdate(hubAbs, newRefs, config, today, expectedRaw, added = []) {
|
|
661
|
+
const titles = new Map();
|
|
662
|
+
for (const r of resolveRunlistRefs(newRefs, hubAbs, config)) {
|
|
663
|
+
if (r.title) titles.set(r.ref.split('/').pop(), r.title);
|
|
664
|
+
}
|
|
665
|
+
for (const child of added) titles.set(child.ref.split('/').pop(), child.title);
|
|
666
|
+
const titleFor = (ref) => titles.get(ref.split('/').pop()) ?? titleize(path.basename(ref, '.md'));
|
|
667
|
+
return { path: hubAbs, expectedContent: expectedRaw, render: raw => {
|
|
668
|
+
const { frontmatter: fmRaw, body } = extractFrontmatter(raw);
|
|
669
|
+
let newFm = upsertFrontmatterField(fmRaw, 'runlist', serializeBlockArray('runlist', newRefs));
|
|
670
|
+
newFm = upsertFrontmatterField(newFm, 'updated', `updated: ${today}`);
|
|
671
|
+
const newBody = syncOrderList(body, newRefs, titleFor);
|
|
672
|
+
return `---\n${newFm}\n---\n${newBody}`;
|
|
673
|
+
} };
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Match a child token to one of the hub's existing runlist refs. Resolves
|
|
677
|
+
// hub-relative (then by slug/basename across the index), then compares against
|
|
678
|
+
// each ref's resolved abs path, with a basename fallback for refs that don't
|
|
679
|
+
// resolve to a file. Returns the matched ref string, or null.
|
|
680
|
+
function findRefForToken(token, existingRefs, hubDir, config) {
|
|
681
|
+
const tokenAbs = resolveRefPath(token, hubDir, config.repoRoot)
|
|
682
|
+
|| (token.endsWith('.md') ? null : resolveRefPath(`${token}.md`, hubDir, config.repoRoot))
|
|
683
|
+
|| resolveDocArg(token, config, { dieOnMiss: false });
|
|
684
|
+
const tokBase = token.endsWith('.md') ? token.split('/').pop() : `${token.split('/').pop()}.md`;
|
|
685
|
+
// Exact: resolved-path match or basename match.
|
|
686
|
+
for (const ref of existingRefs) {
|
|
687
|
+
const refAbs = resolveRefPath(ref, hubDir, config.repoRoot);
|
|
688
|
+
if (tokenAbs && refAbs && refAbs === tokenAbs) return ref;
|
|
689
|
+
if (ref.split('/').pop() === tokBase) return ref;
|
|
690
|
+
}
|
|
691
|
+
// Convenience: the short slug a sprint child was scaffolded from — e.g.
|
|
692
|
+
// `cleanup` matches `auth-revamp-03-cleanup.md`. Unique-or-bust so an
|
|
693
|
+
// ambiguous slug never silently picks the wrong child.
|
|
694
|
+
const slug = tokBase.replace(/\.md$/, '');
|
|
695
|
+
const suffixMatches = existingRefs.filter(ref => {
|
|
696
|
+
const base = ref.split('/').pop().replace(/\.md$/, '');
|
|
697
|
+
return base === slug || base.endsWith(`-${slug}`);
|
|
698
|
+
});
|
|
699
|
+
if (suffixMatches.length === 1) return suffixMatches[0];
|
|
700
|
+
if (suffixMatches.length > 1) {
|
|
701
|
+
die(`"${token}" matches multiple children: ${suffixMatches.join(', ')}. Use the full filename.`);
|
|
702
|
+
}
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Clear a removed child's `parent_plan:` when it points back at this hub (so the
|
|
707
|
+
// reverse link doesn't dangle). Leaves a parent_plan pointing elsewhere alone.
|
|
708
|
+
function planClearChildParent(childAbs, hubAbs, config) {
|
|
709
|
+
let raw;
|
|
710
|
+
try { raw = readFileSync(childAbs, 'utf8'); } catch { return { wrote: false }; }
|
|
711
|
+
const { frontmatter: fmRaw } = extractFrontmatter(raw);
|
|
712
|
+
if (fmRaw == null) return { wrote: false };
|
|
713
|
+
const fm = parseSimpleFrontmatter(fmRaw);
|
|
714
|
+
const existing = asString(fm.parent_plan);
|
|
715
|
+
if (!existing) return { wrote: false };
|
|
716
|
+
if (resolveRefPath(existing, path.dirname(childAbs), config.repoRoot) !== hubAbs) return { wrote: false };
|
|
717
|
+
return { wrote: true, update: { path: childAbs, expectedContent: raw, render: current => {
|
|
718
|
+
const { frontmatter: currentFm } = extractFrontmatter(current);
|
|
719
|
+
const currentParent = asString(parseSimpleFrontmatter(currentFm).parent_plan);
|
|
720
|
+
if (!currentParent || resolveRefPath(currentParent, path.dirname(childAbs), config.repoRoot) !== hubAbs) {
|
|
721
|
+
throw new MutationConflictError(`Child parent_plan changed while the runlist mutation was being prepared: ${toRepoPath(childAbs, config.repoRoot)}`);
|
|
722
|
+
}
|
|
723
|
+
let updatedFm = upsertFrontmatterField(currentFm, 'parent_plan', 'parent_plan:');
|
|
724
|
+
updatedFm = upsertFrontmatterField(updatedFm, 'updated', `updated: ${nowIso()}`);
|
|
725
|
+
return replaceFrontmatter(current, updatedFm);
|
|
726
|
+
} } };
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Shared front half of remove/reorder: resolve the hub, read its `runlist:`,
|
|
730
|
+
// die if it isn't a sprint hub with an array to mutate.
|
|
731
|
+
function loadSprintHub(hubInput, verb, config) {
|
|
732
|
+
if (!hubInput) die(`Usage: runlist runlist ${verb} <hub-plan> <child...>`);
|
|
733
|
+
let hubAbs = resolveHubInput(hubInput, config);
|
|
734
|
+
if (!hubAbs) die(`Hub plan not found: ${hubInput}`);
|
|
735
|
+
hubAbs = authorizeManagedSource(hubAbs, config, { kind: `Runlist ${verb} hub source` }).path;
|
|
736
|
+
const hubRepoPath = toRepoPath(hubAbs, config.repoRoot);
|
|
737
|
+
const hubDir = path.dirname(hubAbs);
|
|
738
|
+
const raw = readFileSync(hubAbs, 'utf8');
|
|
739
|
+
const { frontmatter: fmRaw } = extractFrontmatter(raw);
|
|
740
|
+
const fm = fmRaw == null ? {} : parseSimpleFrontmatter(fmRaw);
|
|
741
|
+
const existingRefs = normalizeStringList(fm.runlist);
|
|
742
|
+
if (existingRefs.length === 0) {
|
|
743
|
+
die(`${hubRepoPath} has no \`runlist:\` array to ${verb} from.` +
|
|
744
|
+
(fm.execution_mode === 'coordination' ? ' (It is a coordination hub — order lives in the body.)' : ''));
|
|
745
|
+
}
|
|
746
|
+
return { hubAbs, hubRepoPath, hubDir, existingRefs, raw };
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
async function runRunlistRemove(positional, config, { dryRun, json, clearParent, testHooks }) {
|
|
750
|
+
const { hubAbs, hubRepoPath, hubDir, existingRefs, raw: hubRaw } = loadSprintHub(positional[0], 'remove', config);
|
|
751
|
+
const childTokens = positional.slice(1);
|
|
752
|
+
if (childTokens.length === 0) die('Usage: runlist runlist remove <hub-plan> <child...>');
|
|
753
|
+
|
|
754
|
+
const removeRefs = [];
|
|
755
|
+
for (const token of childTokens) {
|
|
756
|
+
const ref = findRefForToken(token, existingRefs, hubDir, config);
|
|
757
|
+
if (!ref) die(`"${token}" is not in the runlist of ${hubRepoPath}.`);
|
|
758
|
+
if (!removeRefs.includes(ref)) removeRefs.push(ref);
|
|
759
|
+
}
|
|
760
|
+
const newRefs = existingRefs.filter(r => !removeRefs.includes(r));
|
|
761
|
+
const today = nowIso();
|
|
762
|
+
const clearTargets = [];
|
|
763
|
+
if (clearParent) {
|
|
764
|
+
for (const ref of removeRefs) {
|
|
765
|
+
const abs = resolveRefPath(ref, hubDir, config.repoRoot);
|
|
766
|
+
if (!abs) die(`Cannot clear parent_plan: runlist child does not resolve: ${ref}`);
|
|
767
|
+
const authorized = authorizeManagedSource(abs, config, { kind: 'Runlist removed child source' });
|
|
768
|
+
clearTargets.push({ ref, abs: authorized.path });
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
if (json) {
|
|
773
|
+
process.stdout.write(JSON.stringify({ hub: hubRepoPath, removed: removeRefs, runlist: newRefs, clearedParent: !!clearParent, dryRun: !!dryRun }, null, 2) + '\n');
|
|
774
|
+
} else {
|
|
775
|
+
process.stdout.write(bold(`${dryRun ? dim('[dry-run]') + ' ' : ''}runlist remove → ${hubRepoPath}`) + '\n');
|
|
776
|
+
for (const ref of removeRefs) process.stdout.write(`${dryRun ? dim('[dry-run]') + ' ' : ''} ${red('-')} ${ref}\n`);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const clearUpdates = [];
|
|
780
|
+
if (clearParent) {
|
|
781
|
+
for (const { abs } of clearTargets) {
|
|
782
|
+
const planned = planClearChildParent(abs, hubAbs, config);
|
|
783
|
+
if (planned.update) clearUpdates.push(planned.update);
|
|
784
|
+
if (planned.wrote && !json) {
|
|
785
|
+
process.stdout.write(`${dryRun ? dim('[dry-run]') + ' ' : ''} ${dim(`cleared parent_plan on ${toRepoPath(abs, config.repoRoot)}`)}\n`);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
if (!dryRun) {
|
|
790
|
+
mutateFileSet({ updates: [hubRunlistUpdate(hubAbs, newRefs, config, today, hubRaw), ...clearUpdates] }, {
|
|
791
|
+
repoRoot: config.repoRoot,
|
|
792
|
+
testHooks,
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
if (!json) process.stdout.write(dim(` runlist now has ${newRefs.length} ${newRefs.length === 1 ? 'child' : 'children'}.`) + '\n');
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Parse `reorder` argv: skip the subcommand + flags, capture `--before`/`--after`
|
|
799
|
+
// values (their operands would otherwise leak into the child list).
|
|
800
|
+
function parseReorderArgs(argv) {
|
|
801
|
+
const pos = [];
|
|
802
|
+
let before = null, after = null;
|
|
803
|
+
for (let i = 0; i < argv.length; i++) {
|
|
804
|
+
const a = argv[i];
|
|
805
|
+
if (a === 'reorder') continue;
|
|
806
|
+
if (a === '--before') { before = argv[++i] ?? null; continue; }
|
|
807
|
+
if (a === '--after') { after = argv[++i] ?? null; continue; }
|
|
808
|
+
if (a.startsWith('-')) continue;
|
|
809
|
+
pos.push(a);
|
|
810
|
+
}
|
|
811
|
+
return { hubInput: pos[0], children: pos.slice(1), before, after };
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
async function runRunlistReorder(argv, config, { dryRun, json, testHooks }) {
|
|
815
|
+
const { hubInput, children, before, after } = parseReorderArgs(argv);
|
|
816
|
+
if (!hubInput || children.length === 0) {
|
|
817
|
+
die('Usage: runlist runlist reorder <hub> <child> --before|--after <other>\n or: runlist runlist reorder <hub> <child1> <child2> ... (full new order)');
|
|
818
|
+
}
|
|
819
|
+
const { hubAbs, hubRepoPath, hubDir, existingRefs, raw: hubRaw } = loadSprintHub(hubInput, 'reorder', config);
|
|
820
|
+
|
|
821
|
+
let newRefs;
|
|
822
|
+
if (before || after) {
|
|
823
|
+
if (children.length !== 1) die('--before/--after move exactly one child. Pass a single child, or list every child for a full reorder.');
|
|
824
|
+
const moving = findRefForToken(children[0], existingRefs, hubDir, config);
|
|
825
|
+
if (!moving) die(`"${children[0]}" is not in the runlist of ${hubRepoPath}.`);
|
|
826
|
+
const anchorTok = before || after;
|
|
827
|
+
const anchor = findRefForToken(anchorTok, existingRefs, hubDir, config);
|
|
828
|
+
if (!anchor) die(`"${anchorTok}" is not in the runlist of ${hubRepoPath}.`);
|
|
829
|
+
if (moving === anchor) die('A child cannot be moved relative to itself.');
|
|
830
|
+
const without = existingRefs.filter(r => r !== moving);
|
|
831
|
+
const idx = without.indexOf(anchor);
|
|
832
|
+
const insertAt = before ? idx : idx + 1;
|
|
833
|
+
newRefs = [...without.slice(0, insertAt), moving, ...without.slice(insertAt)];
|
|
834
|
+
} else {
|
|
835
|
+
if (children.length !== existingRefs.length) {
|
|
836
|
+
die(`Full reorder needs all ${existingRefs.length} children in the new order; got ${children.length}. Use --before/--after to move just one.`);
|
|
837
|
+
}
|
|
838
|
+
newRefs = children.map(tok => {
|
|
839
|
+
const ref = findRefForToken(tok, existingRefs, hubDir, config);
|
|
840
|
+
if (!ref) die(`"${tok}" is not in the runlist of ${hubRepoPath}.`);
|
|
841
|
+
return ref;
|
|
842
|
+
});
|
|
843
|
+
if (new Set(newRefs).size !== newRefs.length) die('The new order repeats a child — list each exactly once.');
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
if (newRefs.join('\n') === existingRefs.join('\n')) die('New order matches the current order — nothing to do.');
|
|
847
|
+
const today = nowIso();
|
|
848
|
+
|
|
849
|
+
if (json) {
|
|
850
|
+
process.stdout.write(JSON.stringify({ hub: hubRepoPath, runlist: newRefs, dryRun: !!dryRun }, null, 2) + '\n');
|
|
851
|
+
} else {
|
|
852
|
+
process.stdout.write(bold(`${dryRun ? dim('[dry-run]') + ' ' : ''}runlist reorder → ${hubRepoPath}`) + '\n');
|
|
853
|
+
newRefs.forEach((ref, i) => process.stdout.write(`${dryRun ? dim('[dry-run]') + ' ' : ''} ${String(i + 1).padStart(2)}. ${ref}\n`));
|
|
854
|
+
}
|
|
855
|
+
if (!dryRun) mutateFileSet({ updates: [hubRunlistUpdate(hubAbs, newRefs, config, today, hubRaw)] }, { repoRoot: config.repoRoot, testHooks });
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
export async function runRunlist(argv, config, opts = {}) {
|
|
859
|
+
const json = argv.includes('--json');
|
|
860
|
+
const positional = argv.filter(a => !a.startsWith('-'));
|
|
861
|
+
|
|
862
|
+
// Subcommand dispatch: mutators (`add`/`remove`/`reorder`) vs `next` (pickup)
|
|
863
|
+
// vs `show` (default).
|
|
864
|
+
if (positional[0] === 'add') {
|
|
865
|
+
return runRunlistAdd(positional.slice(1), config, { dryRun: opts.dryRun, json, testHooks: opts.testHooks });
|
|
866
|
+
}
|
|
867
|
+
if (positional[0] === 'remove') {
|
|
868
|
+
return runRunlistRemove(positional.slice(1), config, { dryRun: opts.dryRun, json, clearParent: argv.includes('--clear-parent'), testHooks: opts.testHooks });
|
|
869
|
+
}
|
|
870
|
+
if (positional[0] === 'reorder') {
|
|
871
|
+
return runRunlistReorder(argv, config, { dryRun: opts.dryRun, json, testHooks: opts.testHooks });
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
const sub = positional[0] === 'next' ? 'next' : 'show';
|
|
875
|
+
const hubInput = sub === 'next' ? positional[1] : positional[0];
|
|
876
|
+
|
|
877
|
+
if (!hubInput) {
|
|
878
|
+
die(sub === 'next'
|
|
879
|
+
? 'Usage: runlist runlist next <hub-plan>'
|
|
880
|
+
: 'Usage: runlist runlist <hub-plan>');
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const hubAbs = resolveHubInput(hubInput, config);
|
|
884
|
+
if (!hubAbs) die(`Hub plan not found: ${hubInput}`);
|
|
885
|
+
const hubRepoPath = toRepoPath(hubAbs, config.repoRoot);
|
|
886
|
+
|
|
887
|
+
const runlist = readRunlistChildren(hubAbs, config);
|
|
888
|
+
const { children, source } = runlist;
|
|
889
|
+
const archiveStatuses = config.lifecycle?.archiveStatuses ?? new Set(['archived']);
|
|
890
|
+
|
|
891
|
+
if (sub === 'show') {
|
|
892
|
+
if (json) {
|
|
893
|
+
process.stdout.write(JSON.stringify({
|
|
894
|
+
hub: hubRepoPath,
|
|
895
|
+
source,
|
|
896
|
+
children,
|
|
897
|
+
}, null, 2) + '\n');
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
process.stdout.write(renderRunlist(hubRepoPath, children, { archiveStatuses, source, config }));
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// sub === 'next' — pick up the first child a session can actually start.
|
|
905
|
+
// Skip both archived (done) and parked (blocked/partial/paused/awaiting/
|
|
906
|
+
// queued-after) children: the runlist advances to the first pickup-able one,
|
|
907
|
+
// so the picked target is guaranteed in a `runlist use`-able status.
|
|
908
|
+
const target = children.find(c => !c.missing && !archiveStatuses.has(c.status) && isPickupable(c.status, c, config));
|
|
909
|
+
if (!target) {
|
|
910
|
+
if (children.length === 0) die(`Hub ${hubRepoPath} has empty \`runlist:\` — nothing to pick up.`);
|
|
911
|
+
// Live (non-archived, non-missing) children that exist but aren't startable.
|
|
912
|
+
const parked = children.filter(c => !c.missing && !archiveStatuses.has(c.status));
|
|
913
|
+
if (parked.length > 0) {
|
|
914
|
+
// Every remaining child is parked — surface them with statuses + the
|
|
915
|
+
// unstick verbs so the agent can resume one instead of being told a
|
|
916
|
+
// generic "no pickup" with no path forward.
|
|
917
|
+
const listed = parked.map(c => ` ${c.path} (status: ${c.status})`).join('\n');
|
|
918
|
+
die(
|
|
919
|
+
`No pickup-able child in runlist ${hubRepoPath} — every remaining child is parked:\n` +
|
|
920
|
+
`${listed}\n` +
|
|
921
|
+
`Unstick one before continuing:\n` +
|
|
922
|
+
` runlist set active <child> # if ready to resume\n` +
|
|
923
|
+
` runlist use <child> # to inspect`,
|
|
924
|
+
);
|
|
925
|
+
}
|
|
926
|
+
const allArchived = children.some(c => !c.missing) && children.every(c => c.missing || archiveStatuses.has(c.status));
|
|
927
|
+
if (allArchived) {
|
|
928
|
+
die(`All children in runlist ${hubRepoPath} are archived. Hub is ready for \`runlist archive ${hubRepoPath}\`.`);
|
|
929
|
+
}
|
|
930
|
+
const missing = children.filter(c => c.missing).map(c => c.ref);
|
|
931
|
+
die(`No pickup-able child in runlist ${hubRepoPath}. Unresolved refs: ${missing.join(', ')}`);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// Open the next child: set it in-session (frontmatter) and render its card.
|
|
935
|
+
// Dynamic import to avoid circular module-load cost when the runlist command
|
|
936
|
+
// isn't used.
|
|
937
|
+
const { startPlan } = await import('./lifecycle.mjs');
|
|
938
|
+
const startArgs = [target.path];
|
|
939
|
+
if (argv.includes('--full')) startArgs.push('--full');
|
|
940
|
+
if (argv.includes('--no-index')) startArgs.push('--no-index');
|
|
941
|
+
if (argv.includes('--show-files')) startArgs.push('--show-files');
|
|
942
|
+
if (json) startArgs.push('--json');
|
|
943
|
+
await startPlan(startArgs, config, opts);
|
|
944
|
+
}
|