@connorbritain/roadmap 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +495 -0
  2. package/docs/DEPLOYMENT.md +195 -0
  3. package/package.json +46 -0
  4. package/schema/backlog.schema.json +65 -0
  5. package/schema/roadmap.schema.json +273 -0
  6. package/scripts/assistant.mjs +30 -0
  7. package/scripts/backlog.mjs +81 -0
  8. package/scripts/cleanup.mjs +69 -0
  9. package/scripts/cli.mjs +119 -0
  10. package/scripts/cycle.mjs +89 -0
  11. package/scripts/dispatch.mjs +302 -0
  12. package/scripts/estimate.mjs +201 -0
  13. package/scripts/fanout.mjs +355 -0
  14. package/scripts/grab.mjs +119 -0
  15. package/scripts/init.mjs +60 -0
  16. package/scripts/lib/assistant-core.mjs +64 -0
  17. package/scripts/lib/backlog-core.mjs +318 -0
  18. package/scripts/lib/brief.mjs +123 -0
  19. package/scripts/lib/cli-core.mjs +97 -0
  20. package/scripts/lib/cycle-core.mjs +58 -0
  21. package/scripts/lib/estimate-core.mjs +204 -0
  22. package/scripts/lib/execution.mjs +186 -0
  23. package/scripts/lib/fanout-core.mjs +39 -0
  24. package/scripts/lib/graph.mjs +346 -0
  25. package/scripts/lib/journal-core.mjs +48 -0
  26. package/scripts/lib/linear-core.mjs +812 -0
  27. package/scripts/lib/mcp-core.mjs +313 -0
  28. package/scripts/lib/plan.mjs +68 -0
  29. package/scripts/lib/plate-core.mjs +53 -0
  30. package/scripts/lib/pr-watch-core.mjs +72 -0
  31. package/scripts/lib/priority.mjs +43 -0
  32. package/scripts/lib/recommend.mjs +178 -0
  33. package/scripts/lib/render-core.mjs +215 -0
  34. package/scripts/lib/review-core.mjs +104 -0
  35. package/scripts/lib/store.mjs +113 -0
  36. package/scripts/lib/sync-core.mjs +89 -0
  37. package/scripts/lib/validate-core.mjs +130 -0
  38. package/scripts/lib/wizard-core.mjs +50 -0
  39. package/scripts/linear.mjs +780 -0
  40. package/scripts/mcp.mjs +193 -0
  41. package/scripts/next.mjs +43 -0
  42. package/scripts/plate.mjs +77 -0
  43. package/scripts/promote.mjs +27 -0
  44. package/scripts/prompt.mjs +124 -0
  45. package/scripts/render.mjs +39 -0
  46. package/scripts/review.mjs +79 -0
  47. package/scripts/scheduler.mjs +78 -0
  48. package/scripts/set.mjs +31 -0
  49. package/scripts/show.mjs +53 -0
  50. package/scripts/test/run.mjs +3943 -0
  51. package/scripts/validate.mjs +28 -0
  52. package/scripts/watch-prs.mjs +72 -0
  53. package/scripts/wizard.mjs +97 -0
@@ -0,0 +1,204 @@
1
+ // roadmap — estimation brain (PURE: no fs, no network, no spawn). Bridges agent-time's
2
+ // estimator.py into the roadmap: builds the estimator CLI args from a slice, parses its
3
+ // JSON record, shapes the compact `estimate` block cached on the slice, and validates the
4
+ // estimation config + fields. The IO (spawning python, YAML write-back) lives in
5
+ // scripts/estimate.mjs; the timeline rollup (Phase 2) adds to this file.
6
+ //
7
+ // agent-time owns the shape/risk vocabulary — its SHAPES/RISKS tables validate and reject
8
+ // unknown values — so the roadmap deliberately does NOT duplicate the enum (no drift).
9
+
10
+ import { flatten, computeWaves, isDone } from "./graph.mjs";
11
+
12
+ const DEFAULTS = { python: "python3", hours_per_day: 6, point: "expected", model: "opus-4.8" };
13
+
14
+ // meta.estimation → a filled config (defaults applied). Estimation is an explicit command,
15
+ // so this returns a usable config even when meta.estimation is absent; the block just
16
+ // overrides defaults. `engine: null` → the IO layer resolves the default estimator path.
17
+ export function estimationConfig(meta) {
18
+ const raw = (meta && meta.estimation) || {};
19
+ return {
20
+ engine: raw.engine || null,
21
+ python: raw.python || DEFAULTS.python,
22
+ hours_per_day: typeof raw.hours_per_day === "number" && raw.hours_per_day > 0 ? raw.hours_per_day : DEFAULTS.hours_per_day,
23
+ point: raw.point === "high" ? "high" : DEFAULTS.point,
24
+ model: raw.model || DEFAULTS.model,
25
+ };
26
+ }
27
+
28
+ // argv for `estimator.py estimate` from a slice. A FRESH estimate every call: agent-time's
29
+ // reestimate needs rounds-spent from a live session, which batch planning doesn't have, so
30
+ // re-estimation is a fresh estimate (via --force), not a reestimate. Throws when the slice
31
+ // isn't classified — an explicit shape is required (no silent heuristic).
32
+ export function estimateArgs(sprint, cfg) {
33
+ if (!sprint || !sprint.shape) {
34
+ throw new Error(`slice "${(sprint && sprint.invoke) || "?"}" has no shape — set shape (+ optional risks) before estimating`);
35
+ }
36
+ const args = ["estimate", "--summary", sprint.title || sprint.invoke, "--shape", sprint.shape, "--json"];
37
+ const risks = Array.isArray(sprint.risks) ? sprint.risks.filter((r) => typeof r === "string" && r) : [];
38
+ if (risks.length) args.push("--risks", risks.join(","));
39
+ if (cfg && cfg.model) args.push("--model", cfg.model);
40
+ return args;
41
+ }
42
+
43
+ // `estimate --json` prints a markdown block, a blank line, then the pretty JSON record LAST.
44
+ // Pull the trailing top-level object (the record starts on its own `{` line).
45
+ export function parseEstimateRecord(stdout) {
46
+ const s = String(stdout || "").replace(/\r\n/g, "\n"); // Windows python emits CRLF
47
+ const at = s.lastIndexOf("\n{\n");
48
+ const jsonText = at >= 0 ? s.slice(at + 1) : (s.trimStart().startsWith("{") ? s : null);
49
+ if (!jsonText) throw new Error("no JSON record in estimator output (was --json passed?)");
50
+ let rec;
51
+ try { rec = JSON.parse(jsonText.trim()); } catch { throw new Error("could not parse the estimator's JSON record"); }
52
+ if (!rec || rec.type !== "estimate" || !rec.est_minutes) throw new Error("estimator record missing est_minutes");
53
+ // Trust boundary: the estimator is an external process. A status-0 response with a present-but-
54
+ // non-numeric est_minutes would otherwise cache a bogus block that only warns on validate — reject it.
55
+ const m = rec.est_minutes;
56
+ if (["low", "expected", "high"].some((k) => typeof m[k] !== "number")) throw new Error("estimator record has non-numeric est_minutes");
57
+ return rec;
58
+ }
59
+
60
+ // The compact block cached on the slice (schema: sprint.estimate).
61
+ export function applyEstimate(record) {
62
+ const m = (record && record.est_minutes) || {};
63
+ return {
64
+ minutes: { low: m.low, expected: m.expected, high: m.high },
65
+ confidence: record.confidence,
66
+ task_id: record.task_id,
67
+ at: record.ts,
68
+ basis: record.calibration_basis,
69
+ };
70
+ }
71
+
72
+ export const LOG_STATUSES = ["pass", "fail", "partial", "abandoned"]; // agent-time's outcome statuses (single source)
73
+
74
+ // argv for `estimator.py log` — the calibration outcome for a completed slice. agent-time auto-fills
75
+ // actual rounds/minutes from its own session tracking when present; we always pass --status, and the
76
+ // optional actuals for a manual entry. Throws when the slice was never estimated (no task_id to key on).
77
+ export function logArgs(sprint, status, opts = {}) {
78
+ const tid = sprint && sprint.estimate && sprint.estimate.task_id;
79
+ if (!tid) throw new Error(`slice "${(sprint && sprint.invoke) || "?"}" has no estimate.task_id — run 'roadmap estimate ${(sprint && sprint.invoke) || "<slice>"}' first`);
80
+ const st = status || "pass";
81
+ if (!LOG_STATUSES.includes(st)) throw new Error(`status must be one of ${LOG_STATUSES.join("|")} (got "${st}")`);
82
+ const args = ["log", "--task-id", tid, "--status", st];
83
+ if (typeof opts.actualMinutes === "number") args.push("--actual-minutes", String(opts.actualMinutes));
84
+ if (typeof opts.actualRounds === "number") args.push("--actual-rounds", String(opts.actualRounds));
85
+ return args;
86
+ }
87
+
88
+ // Has agent-time already logged an outcome for this task_id? Idempotency for the calibration loop —
89
+ // reads agent-time's own JSONL history (its store, not the roadmap's), so re-firing never double-counts.
90
+ export function alreadyLogged(historyText, taskId) {
91
+ if (!historyText || !taskId) return false;
92
+ for (const line of String(historyText).split("\n")) {
93
+ const t = line.trim();
94
+ if (!t) continue;
95
+ let rec; try { rec = JSON.parse(t); } catch { continue; }
96
+ if (rec && rec.type === "outcome" && rec.task_id === taskId) return true;
97
+ }
98
+ return false;
99
+ }
100
+
101
+ // A calendar date `offsetMinutes` of agent wall-clock after `anchor`, at `hoursPerDay`
102
+ // productive hours/day. Calendar days (ceil), not business days (a deferred knob). `anchor`
103
+ // is a fixed YYYY-MM-DD string, so this stays deterministic (no wall-clock read).
104
+ export function calendarFromMinutes(offsetMinutes, { hoursPerDay, anchor }) {
105
+ const days = Math.ceil(Math.max(0, offsetMinutes) / (hoursPerDay * 60));
106
+ const d = new Date(`${anchor}T00:00:00Z`);
107
+ d.setUTCDate(d.getUTCDate() + days);
108
+ return d.toISOString().slice(0, 10);
109
+ }
110
+
111
+ // Roll per-slice durations up into a projected target date per PI. Uses the SAME wave
112
+ // schedule the fanout runs (computeWaves: dependency + concurrency + file-contention order),
113
+ // so the projection matches how work actually flows. A wave runs concurrently → its span is
114
+ // the MAX slice duration in it (not the sum); waves are sequential → spans accumulate. Each PI's
115
+ // finish is its last scheduled slice's cumulative offset, laid on the calendar from `anchor`.
116
+ //
117
+ // Honest bounds (surfaced, never a silent hole in the DATE):
118
+ // · Unpriced slice (no estimate) → it's scheduled work of unknown length, so it SUPPRESSES its
119
+ // PI's date entirely (a PI is dated only when every remaining slice is priced) and is listed in
120
+ // `unpriced`. We never emit a date that quietly omits it.
121
+ // · Held slice (blocked / gated-on-human) → not schedulable, so computeWaves excludes it from the
122
+ // makespan; it's listed in `held`. The date reflects the schedulable frontier, deliberately
123
+ // optimistic there — but that's a labelled, surfaced exclusion, not a zero folded into the number.
124
+ export function timelinePlan(graph, opts = {}) {
125
+ const cfg = estimationConfig(graph.meta || {});
126
+ const point = opts.point || cfg.point;
127
+ const hoursPerDay = opts.hoursPerDay || cfg.hours_per_day;
128
+ const concurrency = opts.concurrency || (graph.meta && graph.meta.default_concurrency) || 3;
129
+ const activeStarts = (graph.pis || []).filter((p) => p.status === "active" && p.start_date).map((p) => p.start_date).sort();
130
+ const anchor = opts.anchor || activeStarts[0] || (opts.now ? opts.now.slice(0, 10) : null);
131
+ if (!anchor) throw new Error("timelinePlan needs an anchor: no active start_date and no `now` supplied");
132
+
133
+ const minsAt = (minutes) => (minutes && typeof minutes[point] === "number") ? minutes[point] : null;
134
+
135
+ // Pricing gate from the GRAPH (authoritative — covers held slices too, which never reach the waves):
136
+ // a PI earns a date only when it has a priced remaining slice AND no unpriced one.
137
+ const unpriced = [];
138
+ const hasPriced = new Set();
139
+ const hasUnpriced = new Set();
140
+ for (const pi of graph.pis || []) {
141
+ for (const sp of pi.sprints || []) {
142
+ if (isDone(sp.status)) continue;
143
+ if (minsAt(sp.estimate && sp.estimate.minutes) == null) { unpriced.push(sp.invoke); hasUnpriced.add(pi.id); }
144
+ else hasPriced.add(pi.id);
145
+ }
146
+ }
147
+
148
+ // Makespan from the wave schedule: span = slowest slice (parallel), waves accumulate (sequential).
149
+ // Every scheduled node registers its PI's finish (Math.max, so a 0-min wave still records offset 0).
150
+ const model = flatten(graph);
151
+ const { waves, held } = computeWaves(model, concurrency);
152
+ const finishByPi = new Map();
153
+ let cumulative = 0;
154
+ for (const wave of waves) {
155
+ let span = 0;
156
+ for (const n of wave) { const m = minsAt(n.estMinutes); if (m != null && m > span) span = m; }
157
+ cumulative += span;
158
+ for (const n of wave) finishByPi.set(n.piId, Math.max(finishByPi.get(n.piId) ?? 0, cumulative));
159
+ }
160
+
161
+ const pis = [];
162
+ for (const pi of graph.pis || []) {
163
+ if (!hasPriced.has(pi.id) || hasUnpriced.has(pi.id)) continue; // needs a basis AND no unpriced hole
164
+ if (!finishByPi.has(pi.id)) continue; // priced but every slice is held (none scheduled) → no makespan
165
+ pis.push({ pi: pi.id, projected_target_date: calendarFromMinutes(finishByPi.get(pi.id), { hoursPerDay, anchor }) });
166
+ }
167
+ const heldInvokes = [...(held.onHuman || []), ...(held.blocked || [])].map((n) => n.invoke);
168
+ return { anchor, concurrency, hoursPerDay, point, pis, unpriced, held: heldInvokes };
169
+ }
170
+
171
+ // Optional estimation config + per-slice estimate fields. Absent → no-op. Mirrors
172
+ // validateLinearConfig: returns { errors, warnings } folded into the main validator.
173
+ export function validateEstimation(graph) {
174
+ const errors = [];
175
+ const warnings = [];
176
+ const raw = graph.meta && graph.meta.estimation;
177
+ if (raw != null) {
178
+ if (typeof raw !== "object" || Array.isArray(raw)) errors.push("meta.estimation must be a mapping");
179
+ else {
180
+ if (raw.hours_per_day != null && !(typeof raw.hours_per_day === "number" && raw.hours_per_day > 0)) {
181
+ errors.push("meta.estimation.hours_per_day must be a number > 0");
182
+ }
183
+ if (raw.point != null && raw.point !== "expected" && raw.point !== "high") {
184
+ errors.push('meta.estimation.point must be "expected" or "high"');
185
+ }
186
+ }
187
+ }
188
+ for (const pi of graph.pis || []) {
189
+ for (const sp of pi.sprints || []) {
190
+ const where = `${pi.id}/${sp.id || "?"}`;
191
+ if (sp.shape != null && typeof sp.shape !== "string") errors.push(`${where}: shape must be a string`);
192
+ if (sp.risks != null && (!Array.isArray(sp.risks) || sp.risks.some((r) => typeof r !== "string"))) {
193
+ errors.push(`${where}: risks must be an array of strings`);
194
+ }
195
+ if (sp.estimate != null) {
196
+ if (typeof sp.estimate !== "object" || Array.isArray(sp.estimate)) errors.push(`${where}: estimate must be a mapping`);
197
+ else if (sp.estimate.minutes != null && typeof sp.estimate.minutes.expected !== "number") {
198
+ warnings.push(`${where}: estimate.minutes has no numeric expected — re-run 'roadmap estimate ${sp.invoke} --force'`);
199
+ }
200
+ }
201
+ }
202
+ }
203
+ return { errors, warnings };
204
+ }
@@ -0,0 +1,186 @@
1
+ // roadmap — execution strategy brain (PURE).
2
+ // A per-slice `execution:` block declares HOW to staff the slice: the fan-out topology
3
+ // (solo / subagents / dynamic-workflow / agent-team), a suggested LIVE worker count, a
4
+ // floor, and the team composition. Agents chronically under-parallelize (a lone subagent
5
+ // where a team fits); this turns the author's intent into an IMPERATIVE directive the
6
+ // launched session reads, instead of leaving the call to gut feel.
7
+ //
8
+ // No IO. The block is OPTIONAL and BACKWARD-COMPATIBLE: a slice without it behaves exactly
9
+ // as before. validate-core checks it, render-core / show / brief render the directive, and
10
+ // fanout honors the topology.
11
+
12
+ export const EXEC_MODES = ["solo", "subagents", "dynamic-workflow", "agent-team"];
13
+ export const EXEC_ROLES = ["verifier", "implementer", "reviewer", "researcher", "integrator"];
14
+
15
+ // Cap the auto-suggested floor so a slice touching dozens of dirs doesn't recommend an
16
+ // absurd worker count — past ~6 the review/merge cost dominates the parallelism win.
17
+ export const CLUSTER_CAP = 6;
18
+
19
+ // Distinct disjoint top-level dir clusters from a file list. "src/a.ts" and "src/b.ts"
20
+ // share the `src` cluster (one shared lane); "src/x" + "docs/y" are two disjoint lanes.
21
+ export function dirClusters(files = []) {
22
+ const set = new Set();
23
+ for (const f of files || []) {
24
+ const s = String(f).trim();
25
+ if (!s) continue;
26
+ set.add(s.split("/")[0]);
27
+ }
28
+ return set;
29
+ }
30
+
31
+ // Suggested concurrency FLOOR derived from a slice's touched files = the count of distinct
32
+ // disjoint top-level dir clusters (capped). A HINT, never a hard default. null when the
33
+ // slice declares no files to reason about.
34
+ export function suggestedConcurrency(node) {
35
+ const files = [...((node && node.touches) || []), ...((node && node.owns) || [])];
36
+ const n = dirClusters(files).size;
37
+ if (n <= 0) return null;
38
+ return Math.min(CLUSTER_CAP, Math.max(1, n));
39
+ }
40
+
41
+ // Normalize a raw sprint.execution block to a stable shape (or null when absent). team[].count
42
+ // defaults to 1. Does NOT validate — validateExecution does that; this just shapes for rendering.
43
+ export function normalizeExecution(raw) {
44
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
45
+ const team = Array.isArray(raw.team)
46
+ ? raw.team
47
+ .filter((t) => t && typeof t === "object")
48
+ .map((t) => ({ role: t.role, count: Number.isInteger(t.count) ? t.count : 1 }))
49
+ : null;
50
+ return {
51
+ mode: raw.mode || null,
52
+ concurrency: raw.concurrency != null ? raw.concurrency : null,
53
+ minConcurrency: raw.min_concurrency != null ? raw.min_concurrency : null,
54
+ team: team && team.length ? team : null,
55
+ rationale: raw.rationale || null,
56
+ };
57
+ }
58
+
59
+ // Total declared head-count across the team composition (null when no team).
60
+ export function teamSize(team) {
61
+ if (!team || !team.length) return null;
62
+ return team.reduce((a, t) => a + (Number.isInteger(t.count) ? t.count : 1), 0);
63
+ }
64
+
65
+ // Validate a raw execution block. Returns { errors, warnings } (both arrays, possibly empty).
66
+ // Absent block → no errors (backward-compatible). `where` is the "pi/sprint" locator for messages.
67
+ export function validateExecution(raw, where) {
68
+ const errors = [];
69
+ const warnings = [];
70
+ if (raw == null) return { errors, warnings };
71
+ if (typeof raw !== "object" || Array.isArray(raw)) {
72
+ errors.push(`${where}: execution must be a mapping`);
73
+ return { errors, warnings };
74
+ }
75
+
76
+ if (raw.mode != null && !EXEC_MODES.includes(raw.mode)) {
77
+ errors.push(`${where}: execution.mode "${raw.mode}" invalid (one of ${EXEC_MODES.join(" | ")})`);
78
+ }
79
+
80
+ for (const k of ["concurrency", "min_concurrency"]) {
81
+ if (raw[k] != null && (!Number.isInteger(raw[k]) || raw[k] < 1)) {
82
+ errors.push(`${where}: execution.${k} must be an integer ≥1 (got ${JSON.stringify(raw[k])})`);
83
+ }
84
+ }
85
+
86
+ if (
87
+ Number.isInteger(raw.concurrency) && Number.isInteger(raw.min_concurrency) &&
88
+ raw.min_concurrency > raw.concurrency
89
+ ) {
90
+ errors.push(`${where}: execution.min_concurrency (${raw.min_concurrency}) must be ≤ concurrency (${raw.concurrency})`);
91
+ }
92
+
93
+ if (raw.team != null) {
94
+ if (!Array.isArray(raw.team)) {
95
+ errors.push(`${where}: execution.team must be a list of { role, count? }`);
96
+ } else {
97
+ let sum = 0;
98
+ let summable = raw.team.length > 0;
99
+ raw.team.forEach((t, i) => {
100
+ if (!t || typeof t !== "object" || Array.isArray(t)) {
101
+ errors.push(`${where}: execution.team[${i}] must be a mapping { role, count? }`);
102
+ summable = false;
103
+ return;
104
+ }
105
+ if (!EXEC_ROLES.includes(t.role)) {
106
+ errors.push(`${where}: execution.team[${i}].role "${t.role}" invalid (one of ${EXEC_ROLES.join(" | ")})`);
107
+ }
108
+ if (t.count != null && (!Number.isInteger(t.count) || t.count < 1)) {
109
+ errors.push(`${where}: execution.team[${i}].count must be an integer ≥1`);
110
+ summable = false;
111
+ }
112
+ sum += t.count != null ? (Number.isInteger(t.count) ? t.count : 0) : 1;
113
+ });
114
+ // Head-count consistency: when BOTH a team and a concurrency are given, they must agree.
115
+ if (summable && Number.isInteger(raw.concurrency) && sum !== raw.concurrency) {
116
+ errors.push(`${where}: execution.team head-count (${sum}) is inconsistent with concurrency (${raw.concurrency})`);
117
+ }
118
+ if (raw.mode === "solo" && raw.team.length) {
119
+ warnings.push(`${where}: execution.mode is solo but a team is declared (a solo slice does not fan out)`);
120
+ }
121
+ }
122
+ }
123
+
124
+ return { errors, warnings };
125
+ }
126
+
127
+ // The IMPERATIVE directive lines for a slice — the single canonical block reused verbatim by
128
+ // SLICES.md, `roadmap show`/`/slice`, and the kickoff brief. Returns null when no execution block.
129
+ // Example (agent-team):
130
+ // ▶ EXECUTION: agent-team — 5 workers (1 verifier · 3 implementers · 1 reviewer).
131
+ // The touched files are disjoint. DO NOT run solo or fewer than 4. Invoke Agent Teams now (set CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1).
132
+ // Rationale: 16 disjoint fault-class files; verifier-first; one reviewer reconciles.
133
+ export function executionDirectiveLines(node) {
134
+ const exec = normalizeExecution(node && node.execution);
135
+ if (!exec) return null;
136
+
137
+ const mode = exec.mode || "subagents";
138
+ const suggested = suggestedConcurrency(node);
139
+ const workers = exec.concurrency ?? teamSize(exec.team) ?? suggested ?? (mode === "solo" ? 1 : null);
140
+ const floor = exec.minConcurrency ?? exec.concurrency ?? suggested ?? null;
141
+ const comp = composition(exec.team);
142
+ const lines = [];
143
+
144
+ // Line 1 — headline: mode + worker count + composition.
145
+ if (mode === "solo") {
146
+ lines.push(`▶ EXECUTION: solo — single agent, no fan-out.`);
147
+ } else {
148
+ const wk = workers != null ? `${workers} worker${workers === 1 ? "" : "s"}` : `worker count TBD`;
149
+ lines.push(`▶ EXECUTION: ${mode} — ${wk}${comp ? ` (${comp})` : ""}.`);
150
+ }
151
+
152
+ // Line 2 — the imperative instruction, per topology.
153
+ const floorClause = mode !== "solo" && floor != null ? ` DO NOT run solo or fewer than ${floor}.` : "";
154
+ if (mode === "agent-team") {
155
+ lines.push(` The touched files are disjoint.${floorClause} Invoke Agent Teams now (set CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1).`);
156
+ } else if (mode === "subagents") {
157
+ const n = workers != null ? `${workers} ` : "";
158
+ lines.push(` Spawn ${n}background subagents per CLAUDE.md § Subagent Hand-off (disjoint files; the lead merges).${floorClause}`);
159
+ } else if (mode === "dynamic-workflow") {
160
+ lines.push(` Run an in-slice pipeline — each step gates the next; do not collapse it to a single pass.${floorClause}`);
161
+ } else if (mode === "solo") {
162
+ lines.push(` Single agent, no fan-out — atomic/exploratory/branching-sequential. Do not spawn workers.`);
163
+ }
164
+
165
+ if (exec.rationale) lines.push(` Rationale: ${exec.rationale}`);
166
+ return lines;
167
+ }
168
+
169
+ // "1 verifier · 3 implementers · 1 reviewer" from a normalized team (null when none).
170
+ export function composition(team) {
171
+ if (!team || !team.length) return null;
172
+ return team
173
+ .map((t) => {
174
+ const c = Number.isInteger(t.count) ? t.count : 1;
175
+ return `${c} ${t.role}${c === 1 ? "" : "s"}`;
176
+ })
177
+ .join(" · ");
178
+ }
179
+
180
+ // Keep only the wave nodes whose track matches `track` (case-insensitive). No-op when track
181
+ // is falsy. Forward-compat with the three-track partition: a person fans out only their lane.
182
+ export function filterByTrack(nodes, track) {
183
+ if (!track) return nodes;
184
+ const want = String(track).toLowerCase();
185
+ return nodes.filter((n) => n.track != null && String(n.track).toLowerCase() === want);
186
+ }
@@ -0,0 +1,39 @@
1
+ // Pure launch decision for the fanout.
2
+ // Default is to LAUNCH (interactive, watchable) — that matches the proven fanout pattern
3
+ // and is low-risk (you're at the keyboard). Preview without spawning via --dry / --out.
4
+ // Autonomous (headless claude -p that commits/pushes/PRs) is the only dangerous mode and
5
+ // additionally requires the --yes-spawn-autonomous double-ack.
6
+ export function launchDecision({ dry = false, out = null, autonomous = false, okAutonomous = false } = {}) {
7
+ if (out) return { spawn: false, mode: "wrote-script" };
8
+ if (dry) return { spawn: false, mode: "dry" };
9
+ if (autonomous && !okAutonomous) return { spawn: false, mode: "autonomous-needs-ack" };
10
+ return { spawn: true, mode: autonomous ? "autonomous" : "interactive" };
11
+ }
12
+
13
+ // Per-node launch-script fragments shared by fanout.mjs (waves) and grab.mjs (one backlog
14
+ // item): the worktree-add-or-reuse line plus the .kickoff.md write, in bash and PowerShell
15
+ // forms — so a quoting/format fix lands once, not in two scripts.
16
+ export function bashWorktreeLines(wt, br, baseRef, brief) {
17
+ return [
18
+ `git worktree add "${wt}" -b "${br}" ${baseRef} 2>/dev/null || echo "worktree ${wt} exists, reusing"`,
19
+ `cat > "${wt}/.kickoff.md" <<'KICKOFF_EOF'`,
20
+ brief.trimEnd(),
21
+ `KICKOFF_EOF`,
22
+ ];
23
+ }
24
+ export function pwshWorktreeLines(wt, br, baseRef, brief) {
25
+ return [
26
+ `git worktree add "${wt}" -b "${br}" ${baseRef} 2>$null; if ($LASTEXITCODE -ne 0) { Write-Host "worktree ${wt} exists, reusing" }`,
27
+ `Set-Content -LiteralPath "${wt}/.kickoff.md" -Encoding utf8 -Value @'`,
28
+ brief.trimEnd(),
29
+ `'@`,
30
+ ];
31
+ }
32
+
33
+ // The disk hard-block refusal, shared by fan and grab.
34
+ export function diskBlockLines(disk) {
35
+ return [
36
+ `✗ not enough disk for even one worktree: need ~${disk.perWorktreeGb.toFixed(1)}GB, ${disk.freeGb.toFixed(1)}GB free on the worktree volume.`,
37
+ ` Free space, point meta.worktree_root at a roomier volume, or calibrate meta.worktree_gb if the estimate is off.`,
38
+ ];
39
+ }