@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,355 @@
1
+ #!/usr/bin/env node
2
+ // roadmap — fanout launcher.
3
+ // Computes the ready wave (auto-capped by the resource/purpose recommender unless
4
+ // --cap is given) and launches each slice in its own git worktree via a terminal
5
+ // adapter. Default terminal is tmux: a LEAD pane (your review/merge session) plus
6
+ // one pane per slice, each cd'd into its worktree and running its kickoff.
7
+ //
8
+ // SAFETY: dry by default — prints the launch script and spawns NOTHING. --launch
9
+ // runs it. Autonomous (headless claude -p) additionally requires --yes-spawn-autonomous.
10
+ //
11
+ // Usage:
12
+ // node fanout.mjs [--in roadmap.yaml] [--cap N] [--term tmux|print|warp|wt|background]
13
+ // [--wave N] [--lane max|api] [--lead-claude] [--autonomous]
14
+ // [--launch] [--yes-spawn-autonomous] [--out file]
15
+
16
+ import { writeFileSync } from "node:fs";
17
+ import { spawn, spawnSync } from "node:child_process";
18
+ import os from "node:os";
19
+ import { join, dirname } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+ import { loadGraph, flatten, computeWaves, readyNodes, coherenceEnabled } from "./lib/graph.mjs";
22
+ import { recommendConcurrency, probeDisk } from "./lib/recommend.mjs";
23
+ import { synthesizeBrief, branchFor, worktreeFor, launchPrompt, baseRefOf, remoteOf, agentCmdFor } from "./lib/brief.mjs";
24
+ import { launchDecision, bashWorktreeLines, pwshWorktreeLines, diskBlockLines } from "./lib/fanout-core.mjs";
25
+ import { terminalChoices } from "./lib/wizard-core.mjs";
26
+ import { filterByTrack } from "./lib/execution.mjs";
27
+ import { readLocalConfig, resolveProfile, commandFor, launchDecisionForProfile } from "./lib/assistant-core.mjs";
28
+
29
+ const args = process.argv.slice(2);
30
+ const val = (n, d) => { const i = args.indexOf(n); return i >= 0 && args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : d; };
31
+ const has = (n) => args.includes(n);
32
+
33
+ const inPath = val("--in", "docs/roadmap/roadmap.yaml");
34
+ const waveIdx = Number(val("--wave", 1));
35
+ const track = val("--track", null); // forward-compat: fan out only one lane of the three-track partition
36
+ const lane = val("--lane", "max"); // max (subscription) | api (ANTHROPIC_API_KEY)
37
+ const autonomous = has("--autonomous"); // headless claude -p (else interactive, watchable)
38
+ const dry = has("--dry") || has("--print"); // preview only — launch is the DEFAULT
39
+ const okAutonomous = has("--yes-spawn-autonomous");
40
+ const leadClaude = has("--lead-claude"); // launch claude in the lead pane (else a shell)
41
+ const requestedAssistant = val("--assistant", null);
42
+ const requestedLaunch = has("--launch");
43
+ const outFile = val("--out", null);
44
+ // The lead pane's claude prompt (only with --lead-claude). It coordinates; it cannot see the
45
+ // workers' context (separate processes) but observes their PRs/branches and merges.
46
+ const LEAD_PROMPT = "You are the LEAD for this fanout wave. The other panes are independent worker sessions - each owns one slice in its own git worktree and opens a PR. You cannot see their context, but you can observe their work: run gh pr list to see PRs, use git to inspect branches and worktrees, and review then merge each PR in dependency order as it lands. Only you merge - workers never do. Do not write slice code yourself.";
47
+
48
+ const graph = loadGraph(inPath);
49
+ const wtRootOverride = val("--worktree-root", null);
50
+ if (wtRootOverride) (graph.meta ||= {}).worktree_root = wtRootOverride;
51
+ const model = flatten(graph);
52
+ // Terminal default is platform-aware (no machine-specifics in the committed YAML):
53
+ // Windows → Windows Terminal tabs; elsewhere → tmux panes. terminalChoices() owns that rule.
54
+ const term = val("--term", (graph.meta && graph.meta.terminal) || terminalChoices(os.platform())[0]);
55
+ // Worker permission mode: flag > meta.worker_mode > 'plan'. The lead session uses the same mode.
56
+ const workerMode = val("--worker-mode", (graph.meta && graph.meta.worker_mode) || "plan");
57
+ const { config: localConfig } = readLocalConfig(process.cwd());
58
+ const profile = resolveProfile(graph, localConfig, requestedAssistant);
59
+
60
+ // ── cloud fanout: dispatch the wave to CLOUD agents via Linear instead of local worktrees.
61
+ // Placed BEFORE all worktree/disk/terminal logic on purpose — no disk ceiling, no checkout:
62
+ // the bottleneck moves from this machine to the agent plan's limits. (v0.5 seam — dispatch
63
+ // itself is pending live verification; see scripts/dispatch.mjs.)
64
+ if (has("--cloud")) {
65
+ // Machine ceilings vanish; the REVIEW ceiling doesn't — a human still merges the PRs.
66
+ const cloudCap = has("--cap") ? Number(val("--cap", 5)) : Number(val("--review-ceiling", 5));
67
+ const { waves: cloudWaves } = computeWaves(model, cloudCap, { coherence: coherenceEnabled(graph.meta) });
68
+ const wave = filterByTrack(cloudWaves[waveIdx - 1] || [], track);
69
+ if (!wave.length) { console.error(`No runnable slices in wave ${waveIdx} (cap ${cloudCap}).`); process.exit(0); }
70
+ console.error(`cloud fanout: wave ${waveIdx}, ${wave.length} slice(s) → roadmap dispatch (no worktrees, no disk ceiling; cap = review ceiling ${cloudCap})`);
71
+ const scriptsDir = dirname(fileURLToPath(import.meta.url));
72
+ let failed = 0;
73
+ for (const n of wave) {
74
+ const r = spawnSync("node", [join(scriptsDir, "dispatch.mjs"), n.invoke, ...(val("--to") ? ["--to", val("--to")] : [])], { stdio: "inherit" });
75
+ if ((r.status ?? 1) !== 0) failed += 1;
76
+ }
77
+ process.exit(failed ? 1 : 0);
78
+ }
79
+
80
+ const ready = readyNodes(model);
81
+ const rec = recommendConcurrency(ready, graph, { reviewCeiling: Number(val("--review-ceiling", 5)), disk: probeDisk(graph) });
82
+ // Disk hard-block: auto-dialing handles the soft path (recommended >= 1), but when even ONE
83
+ // worktree won't fit, launching would fail mid-checkout — refuse before creating anything.
84
+ if (rec.disk && rec.disk.cap < 1) {
85
+ diskBlockLines(rec.disk).forEach((l) => console.error(l));
86
+ process.exit(1);
87
+ }
88
+ const cap = has("--cap") ? Number(val("--cap", rec.recommended)) : rec.recommended;
89
+
90
+ let waves;
91
+ try { ({ waves } = computeWaves(model, cap, { coherence: coherenceEnabled(graph.meta) })); }
92
+ catch (e) { console.error(`✗ ${e.message}`); process.exit(1); }
93
+
94
+ const fullWave = waves[waveIdx - 1] || [];
95
+ // Optional --track filter: a person fans out only their lane (slices whose `track` matches).
96
+ const wave = filterByTrack(fullWave, track);
97
+ if (!wave.length) {
98
+ const trackNote = track ? ` on track ${track} (of ${fullWave.length} in the wave)` : "";
99
+ console.error(`No runnable slices in wave ${waveIdx} (cap ${cap})${trackNote}.`);
100
+ process.exit(0);
101
+ }
102
+
103
+ // claude invocation per session. Interactive workers START IN PLAN MODE (--permission-mode
104
+ // plan) so each plans its slice before touching anything; autonomous workers run headless.
105
+ function claudeCmd(node) {
106
+ const prompt = launchPrompt(node);
107
+ if (profile.name === "manual") return `echo "${node.invoke}: worktree and .kickoff.md ready; start your configured assistant here."`;
108
+ // meta.agent_cmd remains the legacy Claude compatibility path. New local profiles win.
109
+ const base = profile.command
110
+ ? commandFor(profile, { prompt, mode: autonomous ? "acceptEdits" : workerMode })
111
+ : agentCmdFor(graph, { prompt, mode: workerMode });
112
+ const withLane = lane === "api"
113
+ ? `ANTHROPIC_API_KEY="$ROADMAP_API_KEY" ${base}` // api overflow lane (rarely used)
114
+ : base; // max: inherit the logged-in subscription
115
+ return withLane;
116
+ }
117
+
118
+ const repoRoot = process.cwd();
119
+
120
+ // ── adapters ─────────────────────────────────────────────────────────────────
121
+ function tmuxScript() {
122
+ const session = "roadmap";
123
+ const L = [];
124
+ L.push(`#!/usr/bin/env bash`);
125
+ L.push(`# roadmap fanout — wave ${waveIdx}, cap ${cap}, ${wave.length} slice(s), terminal=tmux, lane=${lane}, ${autonomous ? "autonomous" : "interactive"}`);
126
+ L.push(`set -euo pipefail`);
127
+ L.push(`git fetch ${remoteOf(graph)} --quiet`);
128
+ L.push(``);
129
+ L.push(`# 1) one worktree + uncommitted kickoff brief per slice`);
130
+ for (const n of wave) {
131
+ L.push(...bashWorktreeLines(worktreeFor(n, graph), branchFor(n, graph), baseRefOf(graph), synthesizeBrief(n, graph)));
132
+ }
133
+ L.push(``);
134
+ L.push(`# 2) tmux: lead pane (review/merge) + one pane per slice`);
135
+ L.push(`tmux kill-session -t ${session} 2>/dev/null || true`);
136
+ L.push(`tmux new-session -d -s ${session} -n wave${waveIdx} -c "${repoRoot}"`);
137
+ L.push(`tmux set -g pane-border-status top 2>/dev/null || true`);
138
+ L.push(`tmux select-pane -t ${session} -T "LEAD — review + merge PRs (workers never merge)"`);
139
+ L.push(leadClaude
140
+ ? `tmux send-keys -t ${session} '${agentCmdFor(graph, { prompt: LEAD_PROMPT, mode: workerMode })}' C-m`
141
+ : `tmux send-keys -t ${session} 'echo "LEAD pane - review + merge each slice PR as it lands. Workers do NOT merge."' C-m`);
142
+ for (const n of wave) {
143
+ const wt = worktreeFor(n, graph);
144
+ L.push(`tmux split-window -t ${session} -c "${wt}"`);
145
+ L.push(`tmux select-pane -t ${session} -T "${n.invoke}"`);
146
+ L.push(`tmux send-keys -t ${session} '${claudeCmd(n)}' C-m`);
147
+ L.push(`tmux select-layout -t ${session} tiled >/dev/null`);
148
+ }
149
+ L.push(`tmux select-layout -t ${session} main-vertical`);
150
+ L.push(`tmux attach -t ${session}`);
151
+ return L.join("\n") + "\n";
152
+ }
153
+
154
+ function printCommands() {
155
+ const out = [];
156
+ out.push(`# fanout wave ${waveIdx} — cap ${cap}, ${wave.length} slice(s), lane=${lane}, ${autonomous ? "autonomous" : "interactive"}`);
157
+ out.push(`git fetch ${remoteOf(graph)} --quiet`);
158
+ for (const n of wave) {
159
+ out.push(`git worktree add "${worktreeFor(n, graph)}" -b "${branchFor(n, graph)}" ${baseRefOf(graph)} # ${n.invoke}`);
160
+ out.push(`(cd "${worktreeFor(n, graph)}" && ${claudeCmd(n)})`);
161
+ }
162
+ return out.join("\n") + "\n";
163
+ }
164
+
165
+ function basicTerminalScript(kind) {
166
+ // warp / wt / background — minimal per-node launchers (full adapters are P3 polish).
167
+ const out = [`# fanout wave ${waveIdx} via ${kind} (basic adapter)`];
168
+ for (const n of wave) {
169
+ const wt = worktreeFor(n, graph), cmd = claudeCmd(n);
170
+ out.push(`git worktree add "${wt}" -b "${branchFor(n, graph)}" ${baseRefOf(graph)} 2>/dev/null || true # ${n.invoke}`);
171
+ if (kind === "wt") out.push(`wt new-tab --title "${n.invoke}" -d "${wt}" powershell -NoExit -Command '${cmd}'`);
172
+ else if (kind === "warp") out.push(`# Warp: open a tab at ${wt} running: ${cmd} (Warp launch-config adapter is P3)`);
173
+ else out.push(`(cd "${wt}" && ${cmd}) & # background`);
174
+ }
175
+ return out.join("\n") + "\n";
176
+ }
177
+
178
+ // claude invocation for a PowerShell tab (single-quote the prompt so the outer -Command "" needs no escaping).
179
+ // Interactive workers start in PLAN MODE; autonomous run headless.
180
+ function claudeCmdPwsh(node) {
181
+ const prompt = launchPrompt(node);
182
+ if (profile.name === "manual") return `Write-Host '${node.invoke}: worktree and .kickoff.md ready; start your configured assistant here.'`;
183
+ return profile.command
184
+ ? commandFor(profile, { prompt, mode: autonomous ? "acceptEdits" : workerMode })
185
+ : agentCmdFor(graph, { prompt, mode: workerMode, quote: "'" });
186
+ }
187
+
188
+ // Windows Terminal adapter: a self-contained PowerShell script — worktree + brief per slice,
189
+ // then one `wt` window with a LEAD tab + one tab per slice (each cd'd into its worktree).
190
+ function wtScript() {
191
+ const L = [];
192
+ L.push(`# roadmap fanout — wave ${waveIdx}, cap ${cap}, ${wave.length} slice(s), terminal=wt, lane=${lane}, ${autonomous ? "autonomous" : "interactive"}`);
193
+ if (lane === "api") L.push(`# note: --lane api is not yet wired for the wt adapter; using the logged-in (max) session.`);
194
+ L.push(`$ErrorActionPreference = 'Continue'`); // git writes progress to stderr; 'Stop' would abort on it
195
+ L.push(`git fetch ${remoteOf(graph)} --quiet`);
196
+ L.push(``);
197
+ L.push(`# 1) one worktree + uncommitted kickoff brief per slice`);
198
+ for (const n of wave) {
199
+ L.push(...pwshWorktreeLines(worktreeFor(n, graph), branchFor(n, graph), baseRefOf(graph), synthesizeBrief(n, graph)));
200
+ }
201
+ L.push(``);
202
+ L.push(`# 2) Windows Terminal: a LEAD tab + one tab per slice`);
203
+ // ';' is wt's tab delimiter — it splits on ';' even inside quotes, so NEVER let one reach wt
204
+ // inside a tab command (a ';' in a prompt would spawn bogus tabs). Replace with a comma.
205
+ const wtSafe = (s) => s.replace(/;/g, ",");
206
+ const lead = leadClaude
207
+ ? agentCmdFor(graph, { prompt: LEAD_PROMPT, mode: workerMode, quote: "'" })
208
+ : `Write-Host 'LEAD tab - review + merge each slice PR as it lands. Workers do NOT merge.'`;
209
+ const parts = [`new-tab --title "LEAD" -d "${repoRoot}" powershell -NoExit -Command "${wtSafe(lead)}"`];
210
+ for (const n of wave) {
211
+ parts.push(`new-tab --title "${n.invoke}" -d "${worktreeFor(n, graph)}" powershell -NoExit -Command "${wtSafe(claudeCmdPwsh(n))}"`);
212
+ }
213
+ // Launch via Start-Process so ShellExecute resolves the 'wt' App Execution Alias — bare `wt`
214
+ // name-resolution fails from a non-interactive script (the alias is a 0-byte reparse point).
215
+ // The full command line is a literal here-string (no quote-escaping); tabs are ';'-separated.
216
+ L.push(`$wtArgs = @'`);
217
+ L.push(parts.join(" ; "));
218
+ L.push(`'@`);
219
+ L.push(`Start-Process wt -ArgumentList $wtArgs`);
220
+ return L.join("\n") + "\n";
221
+ }
222
+
223
+ // Warp adapter: Warp HAS a scriptable launch — the warp://tab_config/<name> deeplink
224
+ // (added 2026-05-18, the registered warp:// URI handler). So we set everything up
225
+ // (worktrees + briefs), write a Warp Tab Config (TOML) with a lead pane + one pane per slice,
226
+ // then fire the deeplink to open it — no manual keystroke.
227
+ const normPath = (p) => String(p).replace(/\\/g, "/");
228
+ function tomlSplit(id, split, children) {
229
+ return `[[panes]]\nid = "${id}"\nsplit = "${split}"\nchildren = [${children.map((c) => `"${c}"`).join(", ")}]\n`;
230
+ }
231
+ function tomlLeaf(id, dir, cmd, focused) {
232
+ // directory: single-quoted TOML literal (no escaping; Windows paths have no '). command:
233
+ // double-quoted TOML basic string — claude/echo commands use only single quotes inside.
234
+ // shell=powershell forces a WINDOWS shell so the pane's git matches the (Windows-created)
235
+ // worktree — otherwise Warp's default shell (often WSL bash) reads the C:\ gitdir and fails.
236
+ return `[[panes]]\nid = "${id}"\ntype = "terminal"\nshell = "powershell"\ndirectory = '${normPath(dir)}'\ncommands = ["${cmd}"]${focused ? `\nis_focused = true` : ""}\n`;
237
+ }
238
+ function warpTabConfigToml() {
239
+ const ids = wave.map((_, i) => `s${i}`);
240
+ const L = [`name = "roadmap-wave${waveIdx}"`, `color = "blue"`, ``];
241
+ // lead on the left; slices stacked on the right (one pane each)
242
+ L.push(tomlSplit("root", "horizontal", wave.length === 1 ? ["lead", "s0"] : ["lead", "slices"]));
243
+ const leadCmd = leadClaude ? agentCmdFor(graph, { prompt: LEAD_PROMPT, mode: workerMode, quote: "'" }) : `echo 'LEAD - review + merge each slice PR; workers do NOT merge'`;
244
+ L.push(tomlLeaf("lead", repoRoot, leadCmd, true));
245
+ if (wave.length > 1) L.push(tomlSplit("slices", "vertical", ids));
246
+ wave.forEach((n, i) => L.push(tomlLeaf(`s${i}`, worktreeFor(n, graph), claudeCmdPwsh(n), false)));
247
+ return L.join("\n");
248
+ }
249
+ function warpScript() {
250
+ const stem = `roadmap-wave${waveIdx}`;
251
+ const L = [];
252
+ L.push(`# roadmap fanout — wave ${waveIdx}, terminal=warp (Tab Config + warp://tab_config deeplink)`);
253
+ L.push(`$ErrorActionPreference = 'Continue'`); // git writes progress to stderr; 'Stop' would abort on it
254
+ L.push(`git fetch ${remoteOf(graph)} --quiet`);
255
+ L.push(``);
256
+ L.push(`# 1) one worktree + uncommitted kickoff brief per slice`);
257
+ for (const n of wave) {
258
+ L.push(...pwshWorktreeLines(worktreeFor(n, graph), branchFor(n, graph), baseRefOf(graph), synthesizeBrief(n, graph)));
259
+ }
260
+ L.push(``);
261
+ L.push(`# 2) write the Warp Tab Config, then open it via the warp:// deeplink`);
262
+ L.push(`$cfgDir = Join-Path $env:APPDATA 'warp\\Warp\\data\\tab_configs'`);
263
+ L.push(`New-Item -ItemType Directory -Force -Path $cfgDir | Out-Null`);
264
+ L.push(`Set-Content -LiteralPath (Join-Path $cfgDir '${stem}.toml') -Encoding utf8 -Value @'`);
265
+ L.push(warpTabConfigToml().trimEnd());
266
+ L.push(`'@`);
267
+ L.push(`Start-Process "warp://tab_config/${stem}?new_window=true"`);
268
+ L.push(`Write-Host "Opened Warp tab config '${stem}' - lead + ${wave.length} slice pane(s)."`);
269
+ return L.join("\n") + "\n";
270
+ }
271
+
272
+ // ── render the artifact ───────────────────────────────────────────────────────
273
+ let artifact;
274
+ if (term === "tmux") artifact = tmuxScript();
275
+ else if (term === "wt") artifact = wtScript();
276
+ else if (term === "warp") artifact = warpScript();
277
+ else if (term === "print") artifact = printCommands();
278
+ else artifact = basicTerminalScript(term); // background
279
+
280
+ // PowerShell scripts (wt/warp) embed non-ASCII (briefs: → ✅ × §). Windows PowerShell reads
281
+ // -File as ANSI unless the file has a UTF-8 BOM — so write those with a BOM. bash (tmux) must NOT
282
+ // get a BOM (it would break the shebang).
283
+ const psScript = term === "wt" || term === "warp";
284
+ const withBom = (s) => (psScript ? "" : "") + s;
285
+
286
+ // Manual is the default. A locally authorized profile plus --launch is required to spawn.
287
+ let decision;
288
+ try {
289
+ decision = outFile ? { spawn: false, mode: "wrote-script" } : dry ? { spawn: false, mode: "dry" }
290
+ : launchDecisionForProfile(profile, { requestedLaunch, autonomous });
291
+ if (autonomous && decision.spawn && !okAutonomous) decision = { spawn: false, mode: "autonomous-needs-ack" };
292
+ } catch (e) { console.error(`fanout: ${e.message}`); process.exit(2); }
293
+
294
+ console.error(`fanout: wave ${waveIdx}/${waves.length} · cap ${cap} (recommended ${rec.recommended}, bound by ${rec.binding.why.split(" — ")[0]}) · term=${term} · lane=${lane}${track ? ` · track=${track}` : ""} · ${decision.mode}`);
295
+ console.error(`slices: ${wave.map((n) => n.invoke).join(", ")}`);
296
+
297
+ if (outFile) {
298
+ writeFileSync(outFile, withBom(artifact), "utf8");
299
+ console.error(`\n✓ wrote launch script → ${outFile} (not launched — run it yourself, or drop --out to launch)`);
300
+ process.exit(0);
301
+ }
302
+
303
+ if (!decision.spawn) {
304
+ if (decision.mode === "autonomous-needs-ack") {
305
+ console.error(`\n⚠ --autonomous launches headless sessions that commit/push/open PRs unattended.`);
306
+ console.error(` Re-run with --yes-spawn-autonomous to actually launch. (Workers still never merge.)`);
307
+ }
308
+ process.stdout.write(artifact);
309
+ if (decision.mode === "dry") console.error(`\n(--dry — nothing spawned. Drop --dry to launch; --out <file> to save the script.)`);
310
+ process.exit(0);
311
+ }
312
+
313
+ // LAUNCH (default).
314
+ if (term === "tmux" || term === "background") {
315
+ if (term === "tmux" && !commandExists("tmux")) {
316
+ process.stdout.write(artifact);
317
+ console.error(`\n⚠ tmux not found on PATH from here (are you in PowerShell? tmux lives in WSL).`);
318
+ console.error(` Above is the launch script. Run it in a tmux-capable shell, e.g.:`);
319
+ console.error(` roadmap fan --wave ${waveIdx} --out wave${waveIdx}.sh # then, in WSL: bash wave${waveIdx}.sh`);
320
+ process.exit(0);
321
+ }
322
+ const p = spawn("bash", ["-c", artifact], { stdio: "inherit" });
323
+ p.on("exit", (code) => process.exit(code ?? 0));
324
+ } else if (term === "wt" || term === "warp") {
325
+ // Both run a PowerShell script. wt opens the tabs directly; warp writes a launch config
326
+ // (no scriptable launch) + prints the one-keystroke open instruction.
327
+ if (term === "wt" && !commandExistsWin("wt")) {
328
+ process.stdout.write(artifact);
329
+ console.error(`\n⚠ Windows Terminal (wt) not found. Above is the PowerShell launch script —`);
330
+ console.error(` install Windows Terminal, or 'roadmap fan --out wave${waveIdx}.ps1' and run it yourself.`);
331
+ process.exit(0);
332
+ }
333
+ const tmp = join(os.tmpdir(), `roadmap-wave${waveIdx}.ps1`);
334
+ writeFileSync(tmp, withBom(artifact), "utf8");
335
+ const p = spawn("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", tmp], { stdio: "inherit" });
336
+ p.on("exit", (code) => process.exit(code ?? 0));
337
+ } else {
338
+ // print / background → just print
339
+ process.stdout.write(artifact);
340
+ }
341
+
342
+ function commandExists(bin) {
343
+ try { return spawnSync("bash", ["-c", `command -v ${bin}`], { stdio: "ignore" }).status === 0; }
344
+ catch { return false; }
345
+ }
346
+ function commandExistsWin(bin) {
347
+ // `where`/`Get-Command` miss Store App Execution Aliases (e.g. wt.exe lives in
348
+ // %LOCALAPPDATA%\Microsoft\WindowsApps and is a reparse point), so also Test-Path it.
349
+ try {
350
+ const r = spawnSync("powershell.exe", ["-NoProfile", "-Command",
351
+ `if (Get-Command ${bin} -ErrorAction SilentlyContinue) { exit 0 }; if (Test-Path (Join-Path $env:LOCALAPPDATA ('Microsoft\\WindowsApps\\${bin}.exe'))) { exit 0 }; exit 1`],
352
+ { stdio: "ignore" });
353
+ return r.status === 0;
354
+ } catch { return false; }
355
+ }
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env node
2
+ // roadmap grab <backlog-id> — launch ONE backlog item in its own worktree + session.
3
+ // The single-target sibling of `fan`: worktree <root>/backlog-<id>, branch backlog/<id>,
4
+ // a synthesized .kickoff.md (the item's prompt embedded verbatim), one terminal target.
5
+ // Marks the item in_progress on launch (not on --dry).
6
+ //
7
+ // Usage: node grab.mjs <id> [--term wt|tmux|print] [--dry] [--worker-mode m]
8
+
9
+ import { writeFileSync } from "node:fs";
10
+ import { spawn, spawnSync } from "node:child_process";
11
+ import os from "node:os";
12
+ import { join } from "node:path";
13
+ import { loadGraph } from "./lib/graph.mjs";
14
+ import { loadBacklog, mutateBacklog } from "./lib/store.mjs";
15
+ import { backlogItemToNode, setItemFields } from "./lib/backlog-core.mjs";
16
+ import { synthesizeBrief, branchFor, worktreeFor, launchPrompt, baseRefOf, remoteOf, agentCmdFor } from "./lib/brief.mjs";
17
+ import { probeDisk } from "./lib/recommend.mjs";
18
+ import { bashWorktreeLines, pwshWorktreeLines, diskBlockLines } from "./lib/fanout-core.mjs";
19
+ import { terminalChoices } from "./lib/wizard-core.mjs";
20
+
21
+ const args = process.argv.slice(2);
22
+ const val = (n, d) => { const i = args.indexOf(n); return i >= 0 && args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : d; };
23
+ const id = args.find((a) => !a.startsWith("-"));
24
+ const dry = args.includes("--dry");
25
+
26
+ if (!id) { console.error("usage: roadmap grab <backlog-id> [--term wt|tmux|print] [--dry] [--worker-mode m]"); process.exit(2); }
27
+
28
+ const backlog = loadBacklog(process.cwd());
29
+ if (!backlog) { console.error("✗ no docs/roadmap/backlog.yaml — capture something first ('roadmap backlog add')"); process.exit(1); }
30
+ const item = (backlog.items || []).find((it) => it.id === id);
31
+ if (!item) {
32
+ const open = (backlog.items || []).filter((it) => it.status === "open" || it.status === "in_progress").map((it) => it.id);
33
+ console.error(`✗ no backlog item "${id}". Open items:\n ${open.join("\n ") || "(none)"}`);
34
+ process.exit(1);
35
+ }
36
+ if (item.status !== "open" && item.status !== "in_progress") {
37
+ console.error(`✗ backlog item "${id}" is ${item.status} — only open/in_progress items launch`);
38
+ process.exit(1);
39
+ }
40
+
41
+ const graph = loadGraph("docs/roadmap/roadmap.yaml");
42
+
43
+ // Disk hard-block (skipped on --dry: previewing costs nothing).
44
+ if (!dry) {
45
+ const disk = probeDisk(graph);
46
+ if (disk && disk.freeGb - 2 < disk.perWorktreeGb) {
47
+ diskBlockLines(disk).forEach((l) => console.error(l));
48
+ process.exit(1);
49
+ }
50
+ }
51
+
52
+ const node = backlogItemToNode(item);
53
+ const term = val("--term", (graph.meta && graph.meta.terminal) || terminalChoices(os.platform())[0]);
54
+ const workerMode = val("--worker-mode", (graph.meta && graph.meta.worker_mode) || "plan");
55
+ const wt = worktreeFor(node, graph);
56
+ const br = branchFor(node, graph);
57
+ const brief = synthesizeBrief(node, graph).trimEnd();
58
+ const claudeCmd = (q) => agentCmdFor(graph, { prompt: launchPrompt(node), mode: workerMode, quote: q });
59
+
60
+ let script;
61
+ if (term === "tmux") {
62
+ script = [
63
+ `#!/usr/bin/env bash`,
64
+ `# roadmap grab — ${id} (${item.kind}), terminal=tmux`,
65
+ `set -euo pipefail`,
66
+ `git fetch ${remoteOf(graph)} --quiet`,
67
+ ...bashWorktreeLines(wt, br, baseRefOf(graph), brief),
68
+ `tmux new-window -c "${wt}" -n "${id}" '${claudeCmd('"')}' 2>/dev/null || tmux new-session -s "grab-${id}" -c "${wt}" '${claudeCmd('"')}'`,
69
+ ].join("\n") + "\n";
70
+ } else if (term === "wt") {
71
+ script = [
72
+ `# roadmap grab — ${id} (${item.kind}), terminal=wt`,
73
+ `$ErrorActionPreference = 'Continue'`,
74
+ `git fetch ${remoteOf(graph)} --quiet`,
75
+ ...pwshWorktreeLines(wt, br, baseRefOf(graph), brief),
76
+ // ';' is wt's tab delimiter even inside quotes — the launch prompt contains none, keep it that way.
77
+ `Start-Process wt -ArgumentList 'new-tab --title "${id}" -d "${wt}" powershell -NoExit -Command "${claudeCmd("'")}"'`,
78
+ ].join("\n") + "\n";
79
+ } else {
80
+ script = [
81
+ `# roadmap grab — ${id} (${item.kind})`,
82
+ `git fetch ${remoteOf(graph)} --quiet`,
83
+ `git worktree add "${wt}" -b "${br}" ${baseRefOf(graph)}`,
84
+ `# write ${wt}/.kickoff.md (brief below), then:`,
85
+ `(cd "${wt}" && ${claudeCmd('"')})`,
86
+ ``,
87
+ `# --- .kickoff.md ---`,
88
+ brief,
89
+ ].join("\n") + "\n";
90
+ }
91
+
92
+ console.error(`grab: ${id} (${item.kind}) · branch ${br} · worktree ${wt} · term=${term} · ${dry ? "dry" : "launch"}`);
93
+
94
+ if (dry || term === "print") {
95
+ process.stdout.write(script);
96
+ if (dry) console.error(`\n(--dry — nothing spawned. Drop --dry to launch.)`);
97
+ process.exit(0);
98
+ }
99
+
100
+ if (term === "tmux") {
101
+ const p = spawn("bash", ["-c", script], { stdio: "inherit" });
102
+ p.on("exit", (code) => { if ((code ?? 0) === 0) markInProgress(); process.exit(code ?? 0); });
103
+ } else {
104
+ // wt: PowerShell script with a UTF-8 BOM (briefs contain non-ASCII).
105
+ const tmp = join(os.tmpdir(), `roadmap-grab-${id}.ps1`);
106
+ writeFileSync(tmp, "" + script, "utf8");
107
+ const p = spawn("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", tmp], { stdio: "inherit" });
108
+ p.on("exit", (code) => { if ((code ?? 0) === 0) markInProgress(); process.exit(code ?? 0); });
109
+ }
110
+
111
+ function markInProgress() {
112
+ if (item.status === "in_progress") return;
113
+ try {
114
+ mutateBacklog(process.cwd(), (doc) => setItemFields(doc, { id, fields: { status: "in_progress" } }));
115
+ console.error(`✓ ${id} marked in_progress`);
116
+ } catch (e) {
117
+ console.error(`⚠ launched, but couldn't mark ${id} in_progress: ${e.message}`);
118
+ }
119
+ }
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ // Safe bootstrap: no machine-level client setting is changed unless --apply is supplied.
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { join, dirname } from "node:path";
5
+ import { homedir } from "node:os";
6
+ import { fileURLToPath } from "node:url";
7
+ import { LOCAL_CONFIG_REL, BUILTIN_PROFILES } from "./lib/assistant-core.mjs";
8
+
9
+ const args = process.argv.slice(2);
10
+ const has = (v) => args.includes(v);
11
+ const value = (v, d = null) => { const i = args.indexOf(v); return i >= 0 ? args[i + 1] || d : d; };
12
+ const root = process.cwd();
13
+ const roadmap = join(root, "docs", "roadmap", "roadmap.yaml");
14
+ const local = join(root, LOCAL_CONFIG_REL);
15
+ const assistant = value("--assistant", "manual");
16
+ const client = value("--client", assistant === "codex" ? "codex" : null);
17
+ if (!BUILTIN_PROFILES[assistant]) { console.error(`unknown assistant ${assistant}; choose ${Object.keys(BUILTIN_PROFILES).join(", ")}`); process.exit(2); }
18
+
19
+ if (!existsSync(roadmap)) {
20
+ if (!has("--yes")) {
21
+ console.log(`roadmap init preview\n would create ${roadmap}\n would create ${local}\nRe-run with --yes to create the minimal roadmap.`);
22
+ process.exit(0);
23
+ }
24
+ mkdirSync(join(root, "docs", "roadmap"), { recursive: true });
25
+ writeFileSync(roadmap, `meta:\n schema_version: 1\n program: ${value("--program", "MYPROJ")}\n assistants:\n default: manual\npis:\n - id: first\n title: First initiative\n status: active\n sprints:\n - { id: s1, title: First slice, status: next, invoke: first-s1, est_sessions: 1 }\n`, "utf8");
26
+ console.error(`created ${roadmap}`);
27
+ }
28
+
29
+ if (!existsSync(local)) {
30
+ const command = BUILTIN_PROFILES[assistant].command;
31
+ const config = `# Machine-local assistant commands and launch authority. Never put secrets here.\nversion: 1\nassistants:\n ${assistant}:\n launch: false\n${command ? ` command: '${command}'\n` : ""}`;
32
+ if (has("--write-local") || has("--yes")) {
33
+ mkdirSync(join(root, ".roadmap"), { recursive: true });
34
+ writeFileSync(local, config, "utf8");
35
+ console.error(`created ${local} (launch remains disabled)`);
36
+ } else console.log(`local profile preview (${local}):\n${config}`);
37
+ }
38
+
39
+ const ignore = join(root, ".gitignore");
40
+ const ignoreLine = ".roadmap/config.local.yaml";
41
+ const ignoreText = existsSync(ignore) ? readFileSync(ignore, "utf8") : "";
42
+ if (!ignoreText.split(/\r?\n/).includes(ignoreLine)) {
43
+ if (has("--yes")) writeFileSync(ignore, `${ignoreText.trimEnd()}${ignoreText.trim() ? "\n" : ""}${ignoreLine}\n`, "utf8");
44
+ else console.log(`gitignore preview: add ${ignoreLine}`);
45
+ }
46
+
47
+ const mcpPath = join(dirname(fileURLToPath(import.meta.url)), "mcp.mjs");
48
+ if (client === "codex") {
49
+ const codexConfig = join(process.env.CODEX_HOME || join(homedir(), ".codex"), "config.toml");
50
+ const section = `[mcp_servers.roadmap]\ncommand = ${JSON.stringify(process.execPath)}\nargs = [${JSON.stringify(mcpPath)}]\n\n[mcp_servers.roadmap.env]\nCODEX_PROJECT_DIR = ${JSON.stringify(root)}\n`;
51
+ console.log(`Codex MCP preview (${codexConfig}):\n${section}`);
52
+ if (has("--apply")) {
53
+ const current = existsSync(codexConfig) ? readFileSync(codexConfig, "utf8") : "";
54
+ if (current.includes("[mcp_servers.roadmap]")) console.log("Codex MCP entry already exists; no change made.");
55
+ else { writeFileSync(codexConfig, `${current.trimEnd()}\n\n${section}`, "utf8"); console.log(`applied Codex MCP entry to ${codexConfig}`); }
56
+ }
57
+ } else {
58
+ console.log(`MCP preview: node ${mcpPath} (set CODEX_PROJECT_DIR or CLAUDE_PROJECT_DIR to ${root})`);
59
+ if (has("--apply")) console.log("No writer for this client; copy the preview into its user-level MCP settings.");
60
+ }
@@ -0,0 +1,64 @@
1
+ // Portable assistant-profile configuration. Canonical roadmap data may select a
2
+ // safe default; machine commands and launch authority always live in .roadmap.
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { parse } from "yaml";
6
+
7
+ export const LOCAL_CONFIG_REL = join(".roadmap", "config.local.yaml");
8
+ export const BUILTIN_PROFILES = {
9
+ manual: { launch: false, autonomous: false, description: "Create worktrees and briefs only." },
10
+ claude: { launch: false, autonomous: true, command: "claude --permission-mode {mode} {prompt}", description: "Claude Code in a terminal." },
11
+ codex: { launch: false, autonomous: false, command: "codex {prompt}", description: "Codex CLI in a terminal." },
12
+ custom: { launch: false, autonomous: false, description: "A locally configured command template." },
13
+ };
14
+
15
+ const SECRET = /(api[_-]?key|token|secret|password|credential|bearer|authorization)/i;
16
+ export function safeConfig(value, path = "config") {
17
+ if (typeof value === "string" && SECRET.test(path)) throw new Error(`${path} must not contain a credential; use the environment instead`);
18
+ if (Array.isArray(value)) value.forEach((v, i) => safeConfig(v, `${path}[${i}]`));
19
+ if (value && typeof value === "object") Object.entries(value).forEach(([k, v]) => safeConfig(v, `${path}.${k}`));
20
+ return value;
21
+ }
22
+
23
+ export function readLocalConfig(root, read = readFileSync, exists = existsSync) {
24
+ const path = join(root, LOCAL_CONFIG_REL);
25
+ if (!exists(path)) return { path, config: { version: 1, assistants: {} } };
26
+ let config;
27
+ try { config = parse(read(path, "utf8")) || {}; } catch (e) { throw new Error(`could not parse ${LOCAL_CONFIG_REL}: ${e.message}`); }
28
+ if (!config || typeof config !== "object" || Array.isArray(config)) throw new Error(`${LOCAL_CONFIG_REL} must be a YAML mapping`);
29
+ safeConfig(config);
30
+ return { path, config };
31
+ }
32
+
33
+ export function configuredProfiles(graph, local = {}) {
34
+ const selected = graph.meta?.assistants || {};
35
+ const localProfiles = local.assistants || {};
36
+ const names = new Set(["manual", ...Object.keys(BUILTIN_PROFILES), ...Object.keys(selected.profiles || {}), ...Object.keys(localProfiles)]);
37
+ const profiles = {};
38
+ for (const name of names) {
39
+ const base = BUILTIN_PROFILES[name] || {};
40
+ profiles[name] = { name, ...base, ...(selected.profiles?.[name] || {}), ...(localProfiles[name] || {}) };
41
+ }
42
+ return { defaultProfile: selected.default || "manual", profiles };
43
+ }
44
+
45
+ export function resolveProfile(graph, local, requested) {
46
+ const all = configuredProfiles(graph, local);
47
+ const name = requested || all.defaultProfile || "manual";
48
+ const profile = all.profiles[name];
49
+ if (!profile) throw new Error(`unknown assistant profile "${name}"; run 'roadmap assistant list'`);
50
+ return profile;
51
+ }
52
+
53
+ export function commandFor(profile, { prompt, mode = "plan" }) {
54
+ if (!profile.command) throw new Error(`assistant profile "${profile.name}" has no local command configured`);
55
+ if (!profile.command.includes("{prompt}")) throw new Error(`assistant profile "${profile.name}" command must contain {prompt}`);
56
+ return profile.command.replaceAll("{mode}", mode).replaceAll("{prompt}", `"${prompt}"`);
57
+ }
58
+
59
+ export function launchDecisionForProfile(profile, { requestedLaunch = false, autonomous = false } = {}) {
60
+ if (!requestedLaunch || profile.name === "manual") return { spawn: false, mode: "manual" };
61
+ if (!profile.launch) throw new Error(`assistant profile "${profile.name}" is not authorized to launch; configure it locally first`);
62
+ if (autonomous && !profile.autonomous) throw new Error(`assistant profile "${profile.name}" does not support autonomous launch`);
63
+ return { spawn: true, mode: autonomous ? "autonomous" : "interactive" };
64
+ }