@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,30 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { stringify } from "yaml";
5
+ import { loadGraph } from "./lib/graph.mjs";
6
+ import { LOCAL_CONFIG_REL, BUILTIN_PROFILES, readLocalConfig, configuredProfiles } from "./lib/assistant-core.mjs";
7
+
8
+ const args = process.argv.slice(2);
9
+ const action = args[0] || "list";
10
+ const graph = loadGraph("docs/roadmap/roadmap.yaml");
11
+ const root = process.cwd();
12
+ const { path, config } = readLocalConfig(root);
13
+ if (action === "list") {
14
+ const all = configuredProfiles(graph, config);
15
+ console.log(`default: ${all.defaultProfile}`);
16
+ for (const p of Object.values(all.profiles)) console.log(`${p.name}\tlaunch=${Boolean(p.launch)}\tautonomous=${Boolean(p.autonomous)}\t${p.description || ""}`);
17
+ } else if (action === "doctor") {
18
+ const all = configuredProfiles(graph, config);
19
+ console.log(existsSync(path) ? `ok: ${LOCAL_CONFIG_REL} found` : `warning: ${LOCAL_CONFIG_REL} absent; manual profile is active`);
20
+ for (const p of Object.values(all.profiles)) if (p.launch && !p.command) console.log(`error: ${p.name} is launch-enabled but has no command`);
21
+ console.log("ok: no credentials found in local assistant configuration");
22
+ } else if (action === "configure") {
23
+ const name = args[1];
24
+ if (!name || !BUILTIN_PROFILES[name]) { console.error(`usage: roadmap assistant configure <${Object.keys(BUILTIN_PROFILES).join("|")}> [--enable-launch]`); process.exit(2); }
25
+ mkdirSync(join(root, ".roadmap"), { recursive: true });
26
+ const command = BUILTIN_PROFILES[name].command || "REPLACE_ME {prompt}";
27
+ const next = { version: 1, ...config, assistants: { ...(config.assistants || {}), [name]: { ...(config.assistants?.[name] || {}), command, launch: args.includes("--enable-launch") } } };
28
+ writeFileSync(path, `# Machine-local. Do not commit secrets.\n${stringify(next)}`, "utf8");
29
+ console.log(`configured ${name}; launch=${args.includes("--enable-launch")}`);
30
+ } else { console.error("assistant: expected list | configure | doctor"); process.exit(2); }
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+ // roadmap backlog [list|add|set] — the erratic-work tracker CLI.
3
+ // roadmap backlog priority-sorted open items (--all includes closed)
4
+ // roadmap backlog add "title" [-k kind] [--id slug] [--tier P1] [--weight N] [--note s] [--slice invoke] [--est N]
5
+ // roadmap backlog set <id> field=value ... (field=@file | field=null; same semantics as roadmap set)
6
+ // First `add` creates docs/roadmap/backlog.yaml. Every mutation re-renders BACKLOG.md (+ the
7
+ // SLICES.md open-count pointer).
8
+
9
+ import { readFileSync } from "node:fs";
10
+ import YAML from "yaml";
11
+ import { parseAssignments } from "./lib/cli-core.mjs";
12
+ import { loadBacklog, mutateBacklog, originBacklogIds } from "./lib/store.mjs";
13
+ import { addItem, setItemFields, sortByPriority, openCount, KINDS } from "./lib/backlog-core.mjs";
14
+ import { tierBadge } from "./lib/priority.mjs";
15
+
16
+ const args = process.argv.slice(2);
17
+ const sub = args[0] && !args[0].startsWith("-") ? args[0] : "list";
18
+ const rest = sub === args[0] ? args.slice(1) : args;
19
+ const val = (...names) => {
20
+ for (const n of names) {
21
+ const i = rest.indexOf(n);
22
+ if (i >= 0 && rest[i + 1] != null) return rest[i + 1];
23
+ }
24
+ return undefined;
25
+ };
26
+
27
+ try {
28
+ if (sub === "list") {
29
+ const backlog = loadBacklog(process.cwd());
30
+ if (!backlog) {
31
+ console.log(`No backlog yet. Capture the first item:\n roadmap backlog add "fix the thing" -k bug --tier P1`);
32
+ process.exit(0);
33
+ }
34
+ const all = rest.includes("--all");
35
+ const items = sortByPriority(all ? backlog.items || [] : (backlog.items || []).filter((it) => it.status === "open" || it.status === "in_progress"));
36
+ console.log(`Backlog — ${openCount(backlog)} open item(s)${all ? ` (${(backlog.items || []).length} total)` : ""}`);
37
+ if (!items.length) console.log(" (empty)");
38
+ for (const it of items) {
39
+ const badge = tierBadge(it.priority);
40
+ const bits = [
41
+ badge ? `[${badge}${it.priority.weight != null ? ` ${it.priority.weight}` : ""}]` : null,
42
+ `${it.id}`, `(${it.kind}${all ? `, ${it.status}` : ""})`, `— ${it.title}`,
43
+ it.source && it.source.slice ? `· from ${it.source.slice}` : null,
44
+ ].filter(Boolean);
45
+ console.log(` ${bits.join(" ")}`);
46
+ if (it.priority && it.priority.reason) console.log(` why: ${it.priority.reason}`);
47
+ }
48
+ console.log(`\nGrab one: roadmap grab <id> · promote one: roadmap promote <id> --pi <pi>`);
49
+ } else if (sub === "add") {
50
+ const title = rest.find((a) => !a.startsWith("-") && a !== val("-k", "--kind") && a !== val("--id") && a !== val("--tier") && a !== val("--weight") && a !== val("--why") && a !== val("--note") && a !== val("--slice") && a !== val("--est"));
51
+ if (!title) { console.error(`usage: roadmap backlog add "title" [-k ${KINDS.join("|")}] [--id slug] [--tier P0-P3] [--weight 0-100] [--why reason] [--note s] [--slice invoke] [--est N]`); process.exit(2); }
52
+ const tier = val("--tier"), weight = val("--weight"), why = val("--why"), note = val("--note"), slice = val("--slice"), est = val("--est");
53
+ const item = {
54
+ title,
55
+ id: val("--id"),
56
+ kind: val("-k", "--kind"),
57
+ est_sessions: est != null ? Number(est) : undefined,
58
+ };
59
+ if (tier || weight || why) item.priority = { ...(tier ? { tier } : {}), ...(weight != null ? { weight: Number(weight) } : {}), ...(why ? { reason: why } : {}) };
60
+ if (note || slice) item.source = { ...(slice ? { slice } : {}), date: new Date().toISOString().slice(0, 10), ...(note ? { note } : {}) };
61
+ else item.source = { date: new Date().toISOString().slice(0, 10) };
62
+ const r = mutateBacklog(process.cwd(), (doc) => addItem(doc, { ...item, origin_ids: originBacklogIds(process.cwd()) }), { createIfMissing: true });
63
+ console.log(`✓ added ${r.added} (re-rendered ${r.rerendered})`);
64
+ } else if (sub === "set") {
65
+ const id = rest.find((a) => !a.includes("=") && !a.startsWith("-"));
66
+ const assigns = rest.filter((a) => a.includes("="));
67
+ if (!id || !assigns.length) { console.error("usage: roadmap backlog set <id> field=value [...] (field=@file | field=null)"); process.exit(2); }
68
+ const fields = {};
69
+ for (const a of parseAssignments(assigns)) {
70
+ fields[a.field] = a.fromFile !== undefined ? readFileSync(a.fromFile, "utf8") : YAML.parse(a.raw);
71
+ }
72
+ const r = mutateBacklog(process.cwd(), (doc) => setItemFields(doc, { id, fields }));
73
+ console.log(`✓ ${id}: set ${r.fields.join(", ")} (re-rendered ${r.rerendered})`);
74
+ } else {
75
+ console.error(`roadmap backlog: unknown subcommand "${sub}" (list | add | set)`);
76
+ process.exit(2);
77
+ }
78
+ } catch (e) {
79
+ console.error(`✗ ${e.message}`);
80
+ process.exit(1);
81
+ }
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env node
2
+ // roadmap cleanup — prune fanout worktrees whose branch is merged to <remote>/<base> and
3
+ // whose tree is clean. DRY by default (lists the plan); --remove acts; --force includes
4
+ // unmerged/dirty. ONLY touches worktrees under the configured worktree_root (the fanout's
5
+ // own) — never the main checkout or your manual worktrees.
6
+
7
+ import { spawnSync } from "node:child_process";
8
+ import { resolve, sep } from "node:path";
9
+ import { loadGraph } from "./lib/graph.mjs";
10
+
11
+ const args = process.argv.slice(2);
12
+ const has = (n) => args.includes(n);
13
+ const doRemove = has("--remove");
14
+ const force = has("--force");
15
+
16
+ const git = (...a) => spawnSync("git", a, { encoding: "utf8" });
17
+
18
+ let meta = {};
19
+ try { meta = loadGraph("docs/roadmap/roadmap.yaml").meta || {}; } catch { /* no roadmap — fall back to defaults */ }
20
+ const remote = meta.remote || "origin";
21
+ const base = meta.base_branch || "main";
22
+ const wtRoot = resolve(meta.worktree_root || resolve(process.cwd(), "..", "_worktrees"));
23
+
24
+ git("fetch", remote, "--quiet");
25
+
26
+ // Parse `git worktree list --porcelain` into {path, branch}.
27
+ const porcelain = (git("worktree", "list", "--porcelain").stdout || "").trim();
28
+ const worktrees = (porcelain ? porcelain.split(/\n\n+/) : []).map((b) => ({
29
+ path: (b.match(/^worktree (.+)$/m) || [])[1],
30
+ branch: (b.match(/^branch refs\/heads\/(.+)$/m) || [])[1] || null,
31
+ })).filter((w) => w.path);
32
+
33
+ const mergedOut = git("branch", "--merged", `${remote}/${base}`, "--format=%(refname:short)").stdout || "";
34
+ const merged = new Set(mergedOut.split("\n").map((s) => s.trim()).filter(Boolean));
35
+
36
+ const underRoot = (p) => { const rp = resolve(p); return rp === wtRoot || rp.startsWith(wtRoot + sep); };
37
+ const candidates = worktrees.filter((w) => underRoot(w.path));
38
+
39
+ if (!candidates.length) { console.log(`No fanout worktrees under ${wtRoot}.`); process.exit(0); }
40
+
41
+ const plan = candidates.map((w) => {
42
+ const dirty = ((git("-C", w.path, "status", "--porcelain").stdout) || "").trim().length > 0;
43
+ const isMerged = w.branch ? merged.has(w.branch) : false;
44
+ return { ...w, dirty, isMerged, removable: isMerged && !dirty };
45
+ });
46
+
47
+ console.log(`Fanout worktrees under ${wtRoot} (merged into ${remote}/${base}?):`);
48
+ for (const p of plan) {
49
+ const flags = `${p.isMerged ? "merged" : "UNMERGED"}, ${p.dirty ? "DIRTY" : "clean"}`;
50
+ const action = (p.removable || force) ? (doRemove ? "→ removing" : "→ would remove") : "→ keep";
51
+ console.log(` ${(p.branch || "(detached)").padEnd(40)} [${flags}] ${action}`);
52
+ console.log(` ${p.path}`);
53
+ }
54
+
55
+ if (!doRemove) {
56
+ console.log(`\n(dry — nothing removed. 'roadmap cleanup --remove' prunes merged+clean; add --force for unmerged/dirty.)`);
57
+ process.exit(0);
58
+ }
59
+
60
+ let removed = 0;
61
+ for (const p of plan) {
62
+ if (!(p.removable || force)) continue;
63
+ const rm = git("worktree", "remove", ...(p.dirty || !p.isMerged ? ["--force"] : []), p.path);
64
+ if (rm.status !== 0) { console.error(` ✗ ${p.path}: ${(rm.stderr || "").trim()}`); continue; }
65
+ if (p.branch) git("branch", force ? "-D" : "-d", p.branch);
66
+ console.log(` ✓ removed ${p.path}`);
67
+ removed++;
68
+ }
69
+ console.log(`\nremoved ${removed} worktree(s).`);
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env node
2
+ // roadmap — the roadmap shell CLI.
3
+ // Dispatches `roadmap <command> [args]` from ANYWHERE inside a repo: it walks up from cwd
4
+ // to find docs/roadmap/roadmap.yaml and runs the target script with cwd = that repo root,
5
+ // so every relative default (--in, --out) just works. Pure logic lives in lib/cli-core.mjs.
6
+
7
+ import { spawnSync } from "node:child_process";
8
+ import { dirname, join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { route, classify, buildArgs, findRepoRoot, missingRoadmapHelp, expandShort, REL } from "./lib/cli-core.mjs";
11
+
12
+ const SCRIPTS = dirname(fileURLToPath(import.meta.url));
13
+
14
+ const HELP = `roadmap — roadmap CLI (run from anywhere inside a repo with ${REL.join("/")})
15
+
16
+ USAGE
17
+ roadmap <command> [options] bare 'roadmap' = interactive console (TTY) / plan (piped)
18
+
19
+ COMMANDS
20
+ (no command) interactive console — walk through terminal / wave / cap, then launch (in a TTY)
21
+ go the same interactive console (force it when TTY detection is off)
22
+ plan recommended concurrency cap + execution waves
23
+ next the single highest-priority ready thing across roadmap + backlog
24
+ show <name> one slice's detail (what / priority / prompt / read-order / next / gate / branch)
25
+ set <name> f=v edit a slice's fields (f=@file for multiline, f=null deletes)
26
+ render regenerate docs/SLICES.md (+ docs/BACKLOG.md when a backlog exists)
27
+ fan launch a wave — a lead + one pane/tab per slice, each in its own worktree
28
+ (--cloud dispatches the wave to CLOUD agents via Linear instead — no worktrees)
29
+ dispatch <key> fire a Claude Code CLOUD session for one slice/backlog item (default --to claude-cloud;
30
+ --to claude|codex|oz posts a Linear @-mention capsule instead)
31
+ backlog erratic-work tracker: list | add "title" [-k kind --tier PN] | set <id> f=v
32
+ plate the My Issues hopper: list | add/rm/set <key>... | clear (curated batch → assignee=you)
33
+ cycle the weekly election: plan [--capacity N] [--json] (stale first, capacity-packed
34
+ candidates) | lock --promote a,b [--demote x,y] (scheduled↔next, one atomic write)
35
+ grab <id> launch ONE backlog item in its own worktree + session
36
+ promote <id> promote a backlog item into a roadmap sprint (--pi <pi> [--id sN])
37
+ cleanup prune fanout worktrees merged into the base branch + clean
38
+ validate structural + dependency + cycle checks
39
+ mcp run the MCP server (stdio); read + mutate tools over JSON-RPC
40
+ watch watch fanout PRs and print a line as each lands (lead notifications)
41
+ review date-anchored review digest: what shipped vs what grew since meta.last_review
42
+ linear optional Linear sync: status [--probe] | auth | setup --team KEY | provision | sync [--dry]
43
+ | note <key> "<text>" [--kind progress|blocker|done] | notes <key> | post-update
44
+ init create a minimal roadmap or configure portable assistant profiles
45
+ assistant list | configure | doctor local assistant profiles
46
+ help this help
47
+
48
+ OPTIONS (short | long)
49
+ -w | --wave N which wave (fan, plan)
50
+ -c | --cap N max concurrent sessions (fan, plan, render)
51
+ -t | --term <adapter> wt | warp | tmux | print | background (fan)
52
+ -d | --dry fan: preview only, spawn nothing
53
+ -o | --out <file> write the launch script / SLICES.md to a file
54
+ -a | --autonomous fan: headless 'claude -p' workers (needs -y)
55
+ -y | --yes-spawn-autonomous fan: acknowledge autonomous spawning
56
+ -l | --lane <max|api> fan: credential lane (default max)
57
+ -j | --json plan: emit the plan as JSON
58
+ -s | --stdout render: print instead of writing the file
59
+ -r | --remove cleanup: actually remove (otherwise dry)
60
+ -f | --force cleanup: include unmerged/dirty worktrees
61
+ -i | --in <yaml> override the roadmap path (auto-discovered otherwise)
62
+ -wm | --worker-mode <mode> fan: worker + lead permission mode (-> claude --permission-mode).
63
+ Default comes from meta.worker_mode in roadmap.yaml (falls back to
64
+ plan if unset); this flag overrides it for one run.
65
+ plan = read-only research, plan gates edits
66
+ auto = auto-approve tool/bash/MCP calls w/ safety checks
67
+ (the "auto mode" toggle — NOT a bypass)
68
+ acceptEdits = auto-accept file edits only (still asks for bash/MCP)
69
+ bypassPermissions = skip ALL prompts (avoid). Tip: a committed
70
+ .claude/settings.json permissions.allow is inherited by every
71
+ worktree. The launch prompt steers the worker to plan + wait first.
72
+ -lc | --lead-claude fan: make the lead pane a Claude coordinator (reviews PRs + merges;
73
+ it can't see workers' context, but observes via gh/git)
74
+ --worktree-root <dir> fan: override the worktree parent dir
75
+ --review-ceiling N plan/fan: human review cap (default 5)
76
+ --assistant <name> fan: manual | claude | codex | custom profile (manual is default)
77
+ --launch fan: explicitly launch a locally authorized assistant profile
78
+
79
+ EXAMPLES
80
+ roadmap # where am I / what's runnable
81
+ roadmap fan -w 1 -c 2 -t warp # launch wave 1, 2 sessions, in Warp
82
+ roadmap fan -w 1 -d # preview the launch script (spawn nothing)
83
+ roadmap show auth-sessions
84
+ roadmap cleanup -r # prune merged+clean worktrees
85
+
86
+ PLATFORM
87
+ Terminal defaults per OS: Windows -> wt, macOS/Linux -> tmux. Run 'fan' from the shell
88
+ where your terminal lives (tmux in WSL/macOS/Linux; wt or warp in Windows PowerShell).
89
+ Install per environment with 'npm link' (once in each Node you use, e.g. Windows + WSL).`;
90
+
91
+ // Bare `roadmap` in an interactive terminal → the wizard (it hot-loads this repo's roadmap and
92
+ // walks you through terminal/wave/cap). Bare + non-TTY keeps printing the plan, so pipes and
93
+ // scripts (roadmap | cat, CI) are unaffected. `roadmap go` forces the wizard regardless.
94
+ const RAW = process.argv.slice(2);
95
+ if (RAW.length === 0 && process.stdin.isTTY) {
96
+ const root = findRepoRoot(process.cwd());
97
+ if (!root) { console.error(missingRoadmapHelp(process.cwd())); process.exit(2); }
98
+ const r = spawnSync("node", [join(SCRIPTS, "wizard.mjs")], { stdio: "inherit", cwd: root });
99
+ process.exit(r.status ?? 0);
100
+ }
101
+
102
+ // Normal dispatch — reached only when args are present, or bare + non-TTY (the wizard branch above
103
+ // has already handled bare + TTY and exited). The findRepoRoot below is the single root walk on
104
+ // this path (the wizard branch's own walk only runs in the early-exit case).
105
+ const { cmd, rest } = route(process.argv.slice(2));
106
+ const action = classify(cmd);
107
+
108
+ if (action.kind === "help") { console.log(HELP); process.exit(0); }
109
+ if (action.kind === "notyet") {
110
+ console.error(`roadmap ${cmd}: not built yet (lands in ${action.phase}). For now: edit ${REL.join("/")}, then 'roadmap render'.`);
111
+ process.exit(2);
112
+ }
113
+ if (action.kind === "unknown") { console.error(`roadmap: unknown command "${cmd}".\n\n${HELP}`); process.exit(2); }
114
+
115
+ const root = findRepoRoot(process.cwd());
116
+ if (!root && cmd !== "init") { console.error(missingRoadmapHelp(process.cwd())); process.exit(2); }
117
+
118
+ const r = spawnSync("node", [join(SCRIPTS, action.script), ...buildArgs(cmd, expandShort(rest))], { stdio: "inherit", cwd: root || process.cwd() });
119
+ process.exit(r.status ?? 0);
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env node
2
+ // roadmap cycle — the weekly election surface.
3
+ // roadmap cycle plan [--capacity N] [--json] zero-network: graph + sync cursor (stale set)
4
+ // roadmap cycle lock --promote a,b [--demote x,y] one atomic validated write (scheduled↔next)
5
+ // The interview lives in the /cycle skill; this owns the data and the write. Statuses are the
6
+ // bookkeeping: promote = scheduled→next (committed this cycle), demote = next→scheduled. The
7
+ // Linear cycle itself follows on the next sync (cyclePlan mirrors active+next).
8
+
9
+ import { resolve } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { loadGraph, flatten } from "./lib/graph.mjs";
12
+ import { roadmapPaths, mutateRoadmap } from "./lib/store.mjs";
13
+ import { normalizeLinearConfig } from "./lib/linear-core.mjs";
14
+ import { electionPlan } from "./lib/cycle-core.mjs";
15
+ import { bulkSet } from "./lib/mcp-core.mjs";
16
+ import { readCursor } from "./linear.mjs";
17
+
18
+ export function runCyclePlan(root, opts = {}) {
19
+ const graph = loadGraph(roadmapPaths(root).yaml);
20
+ const cfg = normalizeLinearConfig(graph.meta || {});
21
+ const capacity = opts.capacity || (cfg && cfg.cycle_capacity) || 10;
22
+ const cursor = readCursor(root);
23
+ return electionPlan(graph, { capacity, staleInvokes: (cursor && cursor.stale) || [] });
24
+ }
25
+
26
+ // One atomic validated write via bulkSet — all promotions/demotions land together or not at
27
+ // all. Pre-checks give the human a clear refusal instead of a store validation error.
28
+ export function runCycleLock(root, { promote = [], demote = [] } = {}) {
29
+ if (!promote.length && !demote.length) throw new Error("cycle lock needs --promote and/or --demote invoke keys");
30
+ const graph = loadGraph(roadmapPaths(root).yaml);
31
+ const statusOf = new Map(flatten(graph).nodes.map((n) => [n.invoke, n.status]));
32
+ for (const k of promote) {
33
+ const s = statusOf.get(k);
34
+ if (s == null) throw new Error(`no slice "${k}"`);
35
+ if (s !== "scheduled" && s !== "optionality") throw new Error(`can't promote "${k}" (status ${s}) — the election promotes scheduled/optionality to next`);
36
+ }
37
+ for (const k of demote) {
38
+ const s = statusOf.get(k);
39
+ if (s == null) throw new Error(`no slice "${k}"`);
40
+ if (s !== "next") throw new Error(`can't demote "${k}" (status ${s}) — only next (committed, unstarted) demotes back to scheduled`);
41
+ }
42
+ const updates = [
43
+ ...promote.map((invoke) => ({ invoke, fields: { status: "next" } })),
44
+ ...demote.map((invoke) => ({ invoke, fields: { status: "scheduled" } })),
45
+ ];
46
+ mutateRoadmap(root, (doc) => bulkSet(doc, { updates }));
47
+ return { promoted: promote, demoted: demote };
48
+ }
49
+
50
+ // ── CLI ───────────────────────────────────────────────────────────────────────
51
+ const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
52
+ if (isMain) {
53
+ const args = process.argv.slice(2);
54
+ const sub = args[0];
55
+ const val = (n) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : undefined; };
56
+ const list = (n) => (val(n) ? val(n).split(",").map((s) => s.trim()).filter(Boolean) : []);
57
+ const root = process.cwd();
58
+ try {
59
+ if (sub === "plan" || sub == null) {
60
+ const p = runCyclePlan(root, { capacity: val("--capacity") ? Number(val("--capacity")) : undefined });
61
+ if (args.includes("--json")) { console.log(JSON.stringify(p, null, 2)); process.exit(0); }
62
+ const line = (x) => ` ${x.stale ? "⚠ STALE " : ""}${x.invoke} (${x.status}${x.est != null ? ` · ${x.est}s` : " · unestimated"}${x.priority && x.priority.tier ? ` · ${x.priority.tier}` : ""}) — ${x.title}`;
63
+ const staleElected = p.elected.filter((x) => x.stale);
64
+ if (staleElected.length) {
65
+ console.log(`STALE — review these FIRST (journal silence past stale_days):`);
66
+ for (const x of staleElected) console.log(line(x));
67
+ }
68
+ console.log(`committed (${p.elected.length}, ${p.elected.reduce((s, x) => s + (x.est || 0), 0)}s of ${p.capacity}s capacity):`);
69
+ for (const x of p.elected) console.log(line(x));
70
+ console.log(`fits on top (greedy prefix, priority order):`);
71
+ for (const x of p.packed) console.log(line(x));
72
+ if (!p.packed.length) console.log(` (nothing — capacity full or no estimated ready candidates)`);
73
+ if (p.overflow.length) console.log(`over capacity (ready, estimated, doesn't fit): ${p.overflow.map((x) => x.invoke).join(", ")}`);
74
+ if (p.unestimated.length) console.log(`unestimated (never auto-packed — price or pass): ${p.unestimated.map((x) => x.invoke).join(", ")}`);
75
+ if (p.unpricedElected.length) console.log(`⚠ committed but unpriced (counts as ZERO in capacity — price these): ${p.unpricedElected.map((x) => x.invoke).join(", ")}`);
76
+ console.log(`lock with: roadmap cycle lock --promote <a,b,...> [--demote <x,y,...>] · then 'roadmap linear sync' projects the cycle`);
77
+ } else if (sub === "lock") {
78
+ const r = runCycleLock(root, { promote: list("--promote"), demote: list("--demote") });
79
+ console.log(`locked: promoted ${r.promoted.length ? r.promoted.join(", ") : "none"} → next${r.demoted.length ? ` · demoted ${r.demoted.join(", ")} → scheduled` : ""}.`);
80
+ console.log(`run 'roadmap linear sync' to project the cycle (active+next join the active Linear cycle).`);
81
+ } else {
82
+ console.error(`usage: roadmap cycle plan [--capacity N] [--json] | roadmap cycle lock --promote a,b [--demote x,y]`);
83
+ process.exit(2);
84
+ }
85
+ } catch (e) {
86
+ console.error(`✗ ${e.message}`);
87
+ process.exit(1);
88
+ }
89
+ }