@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,318 @@
1
+ // roadmap — backlog brain (PURE). The erratic-work tracker beside the roadmap: validation,
2
+ // yaml-Document mutations (comment-preserving, mirroring mcp-core), the BACKLOG.md renderer,
3
+ // the item→node adapter that lets `grab` reuse the fanout brief machinery, and pickNext —
4
+ // the highest-priority ready thing across roadmap AND backlog. No IO.
5
+
6
+ import { flatten, readyNodes } from "./graph.mjs";
7
+ import { comparePriority, tierBadge, validatePriority, TIERS } from "./priority.mjs";
8
+ import { addSprint } from "./mcp-core.mjs";
9
+
10
+ export const KINDS = ["bug", "chore", "followup", "urgent", "idea"];
11
+ export const ITEM_STATUSES = ["open", "in_progress", "promoted", "done", "dropped"];
12
+ const OPEN = new Set(["open", "in_progress"]);
13
+ const SLUG = /^[a-z0-9][a-z0-9-]*$/;
14
+
15
+ // Fields a caller may set on an item via backlog_set (id is structural, set only at add).
16
+ const ITEM_SETTABLE = new Set([
17
+ "title", "kind", "status", "priority", "source", "refs", "touches",
18
+ "est_sessions", "gate", "prompt", "prs", "completed_on", "promoted_to", "linear", "dispatch_tier",
19
+ ]);
20
+
21
+ // ── validation (plain object) ───────────────────────────────────────────────
22
+ export function validateBacklog(backlog) {
23
+ const errors = [];
24
+ const warnings = [];
25
+ const meta = (backlog && backlog.meta) || {};
26
+ if (meta.schema_version !== 1) errors.push(`backlog meta.schema_version must be 1 (got ${JSON.stringify(meta.schema_version)})`);
27
+ const items = (backlog && backlog.items) || [];
28
+ const seen = new Set();
29
+ for (const it of items) {
30
+ const where = `backlog/${it.id || "?"}`;
31
+ if (!it.id || !SLUG.test(it.id)) errors.push(`${where}: id required (lowercase slug)`);
32
+ else if (seen.has(it.id)) errors.push(`duplicate backlog id "${it.id}"`);
33
+ seen.add(it.id);
34
+ if (!it.title) errors.push(`${where}: title required`);
35
+ if (!KINDS.includes(it.kind)) errors.push(`${where}: kind "${it.kind}" is not one of ${KINDS.join("|")}`);
36
+ if (!ITEM_STATUSES.includes(it.status)) errors.push(`${where}: status "${it.status}" is not one of ${ITEM_STATUSES.join("|")}`);
37
+ for (const e of validatePriority(it.priority, where).errors) errors.push(e);
38
+ if (it.est_sessions != null && (typeof it.est_sessions !== "number" || it.est_sessions < 0)) {
39
+ errors.push(`${where}: est_sessions must be a number >= 0`);
40
+ }
41
+ if (it.status === "promoted" && !it.promoted_to) warnings.push(`${where}: promoted but promoted_to is unset (no back-link)`);
42
+ }
43
+ return { errors, warnings, itemCount: items.length };
44
+ }
45
+
46
+ // ── Document helpers + mutations (yaml AST; mirror mcp-core's style) ─────────
47
+ function itemsSeq(doc) {
48
+ const s = doc.get("items");
49
+ if (!s || !s.items) throw new Error("backlog has no items sequence");
50
+ return s;
51
+ }
52
+ function itemLocById(doc, id) {
53
+ const items = itemsSeq(doc).items;
54
+ for (let i = 0; i < items.length; i++) if (String(items[i].get("id")) === id) return i;
55
+ return -1;
56
+ }
57
+
58
+ // Next free auto-id b1..bN. Scans the local doc AND any caller-supplied id list — the IO
59
+ // layers pass origin/main's ids (store.originBacklogIds) so concurrent sessions allocating
60
+ // against stale checkouts stop minting the same bNN (five live collisions on 2026-07-10).
61
+ // Custom slugs don't collide with the counter.
62
+ function nextAutoId(doc, alsoIds = []) {
63
+ let max = 0;
64
+ const bump = (id) => { const m = /^b(\d+)$/.exec(String(id)); if (m) max = Math.max(max, Number(m[1])); };
65
+ for (const it of itemsSeq(doc).items) bump(it.get("id"));
66
+ for (const id of alsoIds) bump(id);
67
+ return `b${max + 1}`;
68
+ }
69
+
70
+ export function addItem(doc, args) {
71
+ if (!args || !args.title) throw new Error("backlog_add requires title");
72
+ const originIds = args.origin_ids || [];
73
+ const id = args.id || nextAutoId(doc, originIds);
74
+ if (itemLocById(doc, id) >= 0) throw new Error(`backlog item "${id}" already exists`);
75
+ // An explicit id that origin/main already holds is the OTHER half of the id race — the
76
+ // local file hasn't pulled it yet, so the local-duplicate check above can't see it.
77
+ if (args.id && originIds.includes(String(args.id))) {
78
+ throw new Error(`backlog item "${args.id}" already exists on origin/main — pull/rebase first, or choose another id`);
79
+ }
80
+ const node = { id, title: args.title, kind: args.kind || "chore", status: args.status || "open" };
81
+ for (const k of ["priority", "source", "refs", "touches", "est_sessions", "gate", "prompt", "linear", "dispatch_tier"]) {
82
+ if (args[k] != null) node[k] = args[k];
83
+ }
84
+ // createNode: addIn stores a plain object un-wrapped, which breaks later AST reads (.get)
85
+ // on the same Document (e.g. a second add in one mutation batch).
86
+ doc.addIn(["items"], doc.createNode(node));
87
+ return { added: id };
88
+ }
89
+
90
+ export function setItemFields(doc, args) {
91
+ if (!args || !args.id || !args.fields) throw new Error("backlog_set requires id + fields");
92
+ const i = itemLocById(doc, args.id);
93
+ if (i < 0) throw new Error(`no backlog item "${args.id}"`);
94
+ const changed = [];
95
+ for (const [k, v] of Object.entries(args.fields)) {
96
+ if (!ITEM_SETTABLE.has(k)) throw new Error(`field "${k}" is not settable on a backlog item`);
97
+ if (v === null) doc.deleteIn(["items", i, k]);
98
+ else doc.setIn(["items", i, k], v);
99
+ changed.push(k);
100
+ }
101
+ return { updated: args.id, fields: changed };
102
+ }
103
+
104
+ // Pre-write gate: throws if the edited Document would corrupt the backlog; returns the
105
+ // plain backlog on success so the caller hands it straight to renderBacklogMarkdown.
106
+ export function validateBacklogDocOrThrow(doc) {
107
+ const backlog = doc.toJS();
108
+ const { errors } = validateBacklog(backlog);
109
+ if (errors.length) throw new Error(`edit would corrupt the backlog: ${errors[0]}`);
110
+ return backlog;
111
+ }
112
+
113
+ // ── ordering + counts ────────────────────────────────────────────────────────
114
+ // Stable priority sort (JS sort is stable: equal priorities keep capture order).
115
+ export function sortByPriority(items) {
116
+ return [...items].sort((a, b) => comparePriority(a.priority, b.priority));
117
+ }
118
+ export function openItems(backlog) {
119
+ return ((backlog && backlog.items) || []).filter((it) => OPEN.has(it.status));
120
+ }
121
+ export function openCount(backlog) {
122
+ return openItems(backlog).length;
123
+ }
124
+
125
+ // ── BACKLOG.md renderer (pure: backlog -> markdown) ──────────────────────────
126
+ export function renderBacklogMarkdown(backlog) {
127
+ const items = (backlog && backlog.items) || [];
128
+ const lines = [];
129
+ const w = (s = "") => lines.push(s);
130
+ const B = "`";
131
+
132
+ w("<!-- GENERATED from docs/roadmap/backlog.yaml (roadmap backlog / the backlog_* MCP tools). Do not edit this file directly — edit the YAML and re-render. -->");
133
+ w("# Backlog — the erratic-work tracker");
134
+ w("");
135
+ w(`> ${openCount(backlog)} open item(s). Roadmap = planned feature/value work; backlog = the follow-up, trivial, or urgent work that surfaces erratically. Capture with ${B}/backlog${B} or ${B}roadmap backlog add${B}; launch a small item directly with ${B}roadmap grab <id>${B}; promote a bigger one into the roadmap with ${B}roadmap promote <id> --pi <pi>${B}.`);
136
+ w("");
137
+
138
+ const open = sortByPriority(items.filter((it) => it.status === "open"));
139
+ const tierOf = (it) => tierBadge(it.priority) || "Untriaged";
140
+ for (const tier of [...TIERS, "Untriaged"]) {
141
+ const group = open.filter((it) => tierOf(it) === tier);
142
+ if (!group.length) continue;
143
+ w(`## ${tier}`);
144
+ w("");
145
+ table(group);
146
+ }
147
+ if (!open.length) {
148
+ w("_No open items._");
149
+ w("");
150
+ }
151
+
152
+ const inProgress = sortByPriority(items.filter((it) => it.status === "in_progress"));
153
+ if (inProgress.length) {
154
+ w("## In progress");
155
+ w("");
156
+ table(inProgress);
157
+ }
158
+ const promoted = items.filter((it) => it.status === "promoted");
159
+ if (promoted.length) {
160
+ w("## Promoted to the roadmap");
161
+ w("");
162
+ w("| Id | Title | Now at |");
163
+ w("|---|---|---|");
164
+ for (const it of promoted) w(`| ${B}${it.id}${B} | ${esc(it.title)} | ${it.promoted_to ? B + it.promoted_to + B : "—"} |`);
165
+ w("");
166
+ }
167
+ const closed = items.filter((it) => it.status === "done" || it.status === "dropped");
168
+ if (closed.length) {
169
+ w("## Recently closed");
170
+ w("");
171
+ w("| Id | Title | Status | PRs | Closed |");
172
+ w("|---|---|---|---|---|");
173
+ for (const it of closed) w(`| ${B}${it.id}${B} | ${esc(it.title)} | ${it.status} | ${(it.prs || []).join(" ") || "—"} | ${it.completed_on || "—"} |`);
174
+ w("");
175
+ }
176
+ return lines.join("\n") + "\n";
177
+
178
+ function table(group) {
179
+ w("| Id | Kind | Wt | Title | Est | Source | Refs |");
180
+ w("|---|---|---|---|---|---|---|");
181
+ for (const it of group) {
182
+ const wt = it.priority && it.priority.weight != null ? it.priority.weight : "—";
183
+ const reason = it.priority && it.priority.reason ? ` _(${esc(it.priority.reason)})_` : "";
184
+ const src = it.source ? [it.source.slice && B + it.source.slice + B, it.source.date].filter(Boolean).join(" ") : "—";
185
+ const refs = (it.refs || []).map((r) => B + r + B).join(", ") || "—";
186
+ w(`| ${B}${it.id}${B} | ${it.kind} | ${wt} | ${esc(it.title)}${reason} | ${it.est_sessions != null ? "~" + it.est_sessions : "?"} | ${src || "—"} | ${refs} |`);
187
+ }
188
+ w("");
189
+ }
190
+ function esc(s) {
191
+ return String(s).replace(/\|/g, "\\|").replace(/\s*\n\s*/g, " ");
192
+ }
193
+ }
194
+
195
+ // ── item → node adapter ──────────────────────────────────────────────────────
196
+ // Shapes a backlog item like a flattened sprint node so brief.mjs (branchFor / worktreeFor /
197
+ // synthesizeBrief / launchPrompt) works unchanged: branch backlog/<id>, worktree <root>/backlog-<id>.
198
+ // ponytail: collides only if a real PI is literally named "backlog" AND has a sprint id == item id.
199
+ export function backlogItemToNode(item) {
200
+ return {
201
+ nodeKey: `backlog/${item.id}`,
202
+ piId: "backlog",
203
+ piTitle: "Backlog",
204
+ programLabel: "BACKLOG",
205
+ id: item.id,
206
+ invoke: item.id,
207
+ title: item.title,
208
+ status: item.status === "in_progress" ? "active" : "next",
209
+ statusLabel: null,
210
+ what: item.title,
211
+ estSessions: typeof item.est_sessions === "number" ? item.est_sessions : null,
212
+ deps: [], piDeps: [], rawDeps: [],
213
+ touches: item.touches || [],
214
+ owns: [],
215
+ gate: item.gate || "default",
216
+ gatedOn: null,
217
+ optional: false,
218
+ execution: null,
219
+ track: null,
220
+ priority: item.priority || null,
221
+ prompt: item.prompt || null,
222
+ readOrder: [],
223
+ resumeAction: (item.source && item.source.note) || "",
224
+ kickoffBrief: "brief",
225
+ prs: item.prs || [],
226
+ completedOn: item.completed_on || null,
227
+ };
228
+ }
229
+
230
+ // ── pickNext: the single highest-priority ready thing across both trackers ───
231
+ // Roadmap wins full ties (planned value work outranks erratic work at equal priority).
232
+ // backlog may be null (no backlog.yaml). Returns { type: "slice"|"backlog", node|item } or null.
233
+ export function pickNext(graph, backlog) {
234
+ const ready = readyNodes(flatten(graph));
235
+ const topSlice = ready.length
236
+ ? [...ready].sort((a, b) => {
237
+ const pc = comparePriority(a.priority, b.priority);
238
+ if (pc) return pc;
239
+ return a.invoke.localeCompare(b.invoke);
240
+ })[0]
241
+ : null;
242
+ const open = backlog ? sortByPriority(((backlog.items) || []).filter((it) => it.status === "open")) : [];
243
+ const topItem = open[0] || null;
244
+ if (!topSlice && !topItem) return null;
245
+ if (!topItem) return { type: "slice", node: topSlice };
246
+ if (!topSlice) return { type: "backlog", item: topItem };
247
+ return comparePriority(topItem.priority, topSlice.priority) < 0
248
+ ? { type: "backlog", item: topItem }
249
+ : { type: "slice", node: topSlice };
250
+ }
251
+
252
+ // ── promote: backlog item → roadmap sprint (both Documents, caller writes) ───
253
+ // The item's id becomes the sprint's invoke key (a collision with an existing invoke is
254
+ // rejected by the roadmap's pre-write gate). The caller (store.mutateBoth) validates BOTH
255
+ // documents before writing either.
256
+ export function performPromotion(rDoc, bDoc, { id, pi, sprint_id } = {}) {
257
+ if (!id || !pi) throw new Error("promote requires a backlog id and a --pi target");
258
+ const item = ((bDoc.toJS().items) || []).find((it) => it.id === id);
259
+ if (!item) throw new Error(`no backlog item "${id}"`);
260
+ if (item.status !== "open" && item.status !== "in_progress") {
261
+ throw new Error(`backlog item "${id}" is ${item.status} — only open/in_progress items promote`);
262
+ }
263
+ const piObj = ((rDoc.toJS().pis) || []).find((p) => p.id === pi);
264
+ if (!piObj) throw new Error(`no PI "${pi}"`);
265
+ let sid = sprint_id;
266
+ if (!sid) {
267
+ let max = 0;
268
+ for (const s of piObj.sprints || []) {
269
+ const m = /^s(\d+)$/.exec(String(s.id));
270
+ if (m) max = Math.max(max, Number(m[1]));
271
+ }
272
+ sid = `s${max + 1}`;
273
+ }
274
+ const args = { pi, id: sid, title: item.title, invoke: item.id, status: "scheduled", what: item.title };
275
+ for (const k of ["est_sessions", "gate", "prompt", "priority"]) if (item[k] != null) args[k] = item[k];
276
+ if (Array.isArray(item.touches) && item.touches.length) args.touches = item.touches;
277
+ // The item's Linear issue TRANSFERS to the sprint (same issue continues life as roadmap
278
+ // work — next sync morphs its description/labels/project). Leaving it on the item would
279
+ // orphan an open issue on the board and double-map the identifier.
280
+ if (item.linear) args.linear = item.linear;
281
+ addSprint(rDoc, args);
282
+ setItemFields(bDoc, { id, fields: { status: "promoted", promoted_to: `${pi}/${sid}`, ...(item.linear ? { linear: null } : {}) } });
283
+ return { promoted: id, to: `${pi}/${sid}` };
284
+ }
285
+
286
+ // ── MCP tool registry (spread into TOOLS by mcp.mjs) ─────────────────────────
287
+ const PRIORITY_SCHEMA = { type: "object", properties: {
288
+ tier: { enum: ["P0", "P1", "P2", "P3"] }, weight: { type: "number", minimum: 0, maximum: 100 }, reason: { type: "string" } } };
289
+
290
+ export const BACKLOG_TOOLS = [
291
+ { name: "backlog_list", description: "Backlog items, priority-sorted. Read-only. all=true includes promoted/done/dropped.",
292
+ inputSchema: { type: "object", properties: { all: { type: "boolean" } } } },
293
+ { name: "backlog_add", description: "Add a backlog item (erratic/follow-up/urgent work). Creates docs/roadmap/backlog.yaml on first use; re-renders BACKLOG.md. id auto-assigned (b1..bN) when omitted.",
294
+ inputSchema: { type: "object", required: ["title"], properties: {
295
+ title: { type: "string" }, id: { type: "string" }, kind: { enum: KINDS },
296
+ priority: PRIORITY_SCHEMA,
297
+ source: { type: "object", properties: { slice: { type: "string" }, date: { type: "string" }, note: { type: "string" },
298
+ linear: { type: "object", properties: { team: { type: "string" }, project: { type: "string" }, issue: { type: "string" } } } } },
299
+ refs: { type: "array", items: { type: "string" } }, touches: { type: "array", items: { type: "string" } },
300
+ est_sessions: { type: "number" }, gate: { type: "string" }, prompt: { type: "string" }, linear: { type: "string" },
301
+ dispatch_tier: { type: "string" },
302
+ origin_ids: { type: "array", items: { type: "string" }, description: "ids already taken upstream (the MCP server injects origin/main's ids automatically; callers normally omit this)" } } } },
303
+ { name: "backlog_set", description: "Set allowed fields on a backlog item (by id). null value deletes a field. Re-renders BACKLOG.md.",
304
+ inputSchema: { type: "object", required: ["id", "fields"], properties: {
305
+ id: { type: "string" }, fields: { type: "object" } } } },
306
+ { name: "backlog_promote", description: "Promote a backlog item into a roadmap sprint (item id becomes the invoke key; carries title/est/gate/touches/prompt/priority; back-links promoted_to). Both YAMLs validated before either is written; re-renders both generated views.",
307
+ inputSchema: { type: "object", required: ["id", "pi"], properties: {
308
+ id: { type: "string" }, pi: { type: "string" }, sprint_id: { type: "string" } } } },
309
+ ];
310
+
311
+ export function readBacklogList(backlog, args = {}) {
312
+ if (!backlog) return { items: [], note: "no docs/roadmap/backlog.yaml yet — backlog_add creates it" };
313
+ const items = args.all ? backlog.items || [] : openItems(backlog);
314
+ return { items: sortByPriority(items), open: openCount(backlog) };
315
+ }
316
+
317
+ export const BACKLOG_READ_HANDLERS = { backlog_list: readBacklogList };
318
+ export const BACKLOG_MUTATION_HANDLERS = { backlog_add: addItem, backlog_set: setItemFields };
@@ -0,0 +1,123 @@
1
+ // roadmap — kickoff-brief synthesizer.
2
+ // Turns a sprint node into the self-contained brief a launched session reads to
3
+ // "just start". Mirrors the 6-part subagent-handoff contract (target/scope, reference,
4
+ // gate+commands, branch/commit/PR, DO NOT MERGE, report-back) so an autonomous or
5
+ // watched session owns its atomic sequence and never merges its own work.
6
+
7
+ import { resolveGate } from "./graph.mjs";
8
+ import { executionDirectiveLines } from "./execution.mjs";
9
+ import { resolve } from "node:path";
10
+
11
+ // Repo conventions (default to git's common defaults; overridable in meta).
12
+ export const remoteOf = (graph) => (graph.meta && graph.meta.remote) || "origin";
13
+ export const baseBranchOf = (graph) => (graph.meta && graph.meta.base_branch) || "main";
14
+ export const baseRefOf = (graph) => `${remoteOf(graph)}/${baseBranchOf(graph)}`;
15
+
16
+ export function branchFor(node, graph) {
17
+ const conv = (graph.meta && graph.meta.branch_convention) || "{pi}/{sprint}";
18
+ let br = conv.replace("{pi}", node.piId).replace("{sprint}", node.id);
19
+ // {linear} = the node's Linear issue identifier, lowercased — a branch containing it is
20
+ // auto-linked to the issue by Linear. Cleanup runs ONLY when the token was used, so
21
+ // conventions without it stay byte-identical; a node without an id degrades gracefully
22
+ // ({pi}/{linear}-{sprint} -> platform/s1, no "/-" or "--" residue).
23
+ if (conv.includes("{linear}")) {
24
+ br = br.replace("{linear}", String(node.linear || "").toLowerCase())
25
+ .replace(/--+/g, "-").replace(/\/-/g, "/").replace(/-+$/, "");
26
+ }
27
+ return br;
28
+ }
29
+
30
+ // Launch-command template for INTERACTIVE worker/lead sessions. meta.agent_cmd swaps the
31
+ // agent (e.g. a codex invocation) — {mode} = permission mode, {prompt} = the prompt wrapped
32
+ // in the call site's quote char (each shell context picks its own). The default template
33
+ // produces today's claude command byte-for-byte.
34
+ // ponytail: interactive-only lever; autonomous headless launches stay hardcoded to claude —
35
+ // add a second template when someone actually runs a non-claude headless agent.
36
+ export const DEFAULT_AGENT_CMD = "claude --permission-mode {mode} {prompt}";
37
+ export function agentCmdFor(graph, { prompt, mode, quote = '"' }) {
38
+ const tpl = (graph.meta && graph.meta.agent_cmd) || DEFAULT_AGENT_CMD;
39
+ return tpl.replace("{mode}", mode).replace("{prompt}", `${quote}${prompt}${quote}`);
40
+ }
41
+
42
+ export function worktreeFor(node, graph) {
43
+ // Default to an absolute sibling of the repo (<cwd>/../_worktrees) when unset — the CLI
44
+ // runs with cwd = repo root, so this is portable + machine-specific-free in the committed YAML.
45
+ const root = (graph.meta && graph.meta.worktree_root) || resolve(process.cwd(), "..", "_worktrees");
46
+ return `${root}/${node.piId}-${node.id}`;
47
+ }
48
+
49
+ // The prompt each launched session starts with. SELF-CONTAINED: it reads the kickoff brief
50
+ // written into the worktree, so it works whether or not the roadmap plugin/skill is
51
+ // installed in the spawned session. ASCII + no quote chars (it's embedded in shell quotes).
52
+ export function launchPrompt(node) {
53
+ // Steers to research -> plan -> WAIT -> implement. Combined with a permissive --worker-mode
54
+ // (acceptEdits / bypassPermissions) the research runs without approval prompts, but the agent
55
+ // still pauses for you to approve the plan before it changes anything. No ';' (wt's delimiter).
56
+ return `Read the .kickoff.md file in this directory. First research autonomously - read the brief read-order and explore the code, without asking - to build a thorough plan. Then present the plan and STOP: wait for my approval before you implement anything. After I approve, carry it out - build, test, commit, push, open a PR. Do NOT merge.`;
57
+ }
58
+
59
+ // The full brief written to <worktree>/.kickoff.md (uncommitted).
60
+ export function synthesizeBrief(node, graph) {
61
+ if (node.kickoffBrief && node.kickoffBrief !== "brief") {
62
+ // inline brief or a path — pass through (caller resolves a path if needed)
63
+ return node.kickoffBrief;
64
+ }
65
+ const gate = resolveGate(node, graph) || "(see SLICES.md default gate)";
66
+ const branch = branchFor(node, graph);
67
+ const owns = [...(node.owns || []), ...(node.touches || [])];
68
+ const ro = (node.readOrder || []).map((r, i) => `${i + 1}. ${r}`).join("\n") || "_(see the slice's detail entry in docs/SLICES.md)_";
69
+
70
+ // Carry the imperative execution directive VERBATIM (only when the slice declares one), so the
71
+ // launched session staffs at the declared topology — an agent-team slice invokes Agent Teams
72
+ // rather than running solo. Section 0 = the very first thing the session reads.
73
+ const execLines = executionDirectiveLines(node);
74
+ const execSection = execLines
75
+ ? `## 0. Execution strategy — staff this BEFORE you start
76
+ ${execLines.join("\n")}
77
+
78
+ `
79
+ : "";
80
+
81
+ // Author-stashed pickup instructions, carried VERBATIM (only when the slice declares one —
82
+ // a slice without a prompt gets a byte-identical brief).
83
+ const promptSection = node.prompt
84
+ ? `## 0.5 Author instructions (verbatim)
85
+ ${String(node.prompt).trimEnd()}
86
+
87
+ `
88
+ : "";
89
+
90
+ return `# Kickoff — ${node.invoke} (${node.programLabel} · ${node.id.toUpperCase()})
91
+
92
+ > Uncommitted brief for this fanout session. You own the atomic sequence
93
+ > **read → plan → build → test → commit → push → open PR**. Do **NOT** merge — the lead merges.
94
+
95
+ **Slice:** \`${node.invoke}\` · **Branch:** \`${branch}\`
96
+ **What:** ${node.what || node.title}
97
+
98
+ **Resuming?** \`roadmap linear notes ${node.invoke}\` shows any prior-session notes on this slice's tracker issue — read them first if present (that's where a dead session left off).
99
+
100
+ ${execSection}${promptSection}## 1. Scope / target
101
+ ${owns.length ? owns.map((f) => `- \`${f}\``).join("\n") : "- (scope to this slice only; see read-order)"}
102
+
103
+ ## 2. Read-order (orient first — paths are relative to \`docs/\`; you're at the repo root, so e.g. \`sprints/...\` = \`docs/sprints/...\`, and \`../STATUS.md\` = \`STATUS.md\`)
104
+ ${ro}
105
+
106
+ ## 3. Next action
107
+ ${node.resumeAction || "(see the slice detail entry)"}
108
+
109
+ ## 4. Verification gate (must pass before commit)
110
+ \`\`\`
111
+ ${gate}
112
+ \`\`\`
113
+
114
+ ## 5. Commit + PR
115
+ - Commit style: \`<area>: <what> (${node.piId}-${node.id.toUpperCase()})\`, professional, no AI attribution.
116
+ - Open a PR \`--base ${baseBranchOf(graph)} --head ${branch}\` whose description includes the exact line \`roadmap: slice=${node.invoke}\` (the reconcile marker). **Do NOT merge.**
117
+
118
+ ## 6. Report back
119
+ LOC delta · file inventory · gate result (pass/fail with output) · commit SHA · PR # · 2–3 line retro.
120
+ Journal as you go: at each checkpoint — a gate cleared, a blocker hit, a logical unit done — post \`roadmap linear note ${node.invoke} "…" [--kind progress|blocker|done]\` (a no-op if this slice isn't tracker-mapped), so if this session dies the next one resumes from the trail, not from zero.
121
+ Leftovers: before opening the PR, file anything that genuinely blocks or follows this slice (discovered bugs, broken contracts, deferred gate items) to the BACKLOG ONLY — the \`backlog_add\` MCP tool if available, else \`roadmap backlog add "<title>" -k followup --slice ${node.invoke}\`, else a **Leftovers** heading in the PR body (the lead's /sync harvests it). NEVER add sprints or PIs from this session — scope decisions belong to the human. Skip speculative ideas entirely (YAGNI applies to captures too).
122
+ `;
123
+ }
@@ -0,0 +1,97 @@
1
+ // roadmap CLI — pure core (no side effects on import, so it's unit-testable).
2
+ // cli.mjs wires these to process.argv / spawn / process.exit.
3
+
4
+ import { existsSync } from "node:fs";
5
+ import { dirname, join, resolve } from "node:path";
6
+
7
+ export const REL = ["docs", "roadmap", "roadmap.yaml"];
8
+ const MAP = { plan: "scheduler.mjs", render: "render.mjs", fan: "fanout.mjs", fanout: "fanout.mjs", validate: "validate.mjs", show: "show.mjs", cleanup: "cleanup.mjs", wizard: "wizard.mjs", go: "wizard.mjs", mcp: "mcp.mjs", watch: "watch-prs.mjs", set: "set.mjs", backlog: "backlog.mjs", grab: "grab.mjs", promote: "promote.mjs", next: "next.mjs", linear: "linear.mjs", review: "review.mjs", dispatch: "dispatch.mjs", plate: "plate.mjs", estimate: "estimate.mjs", cycle: "cycle.mjs", init: "init.mjs", assistant: "assistant.mjs" };
9
+ const NOT_YET = { sync: "P4" };
10
+
11
+ // Walk up from `start` to the first dir containing docs/roadmap/roadmap.yaml; null if none.
12
+ // `exists` is injectable for testing.
13
+ export function findRepoRoot(start, exists = existsSync) {
14
+ let dir = resolve(start);
15
+ for (;;) {
16
+ if (exists(join(dir, ...REL))) return dir;
17
+ const up = dirname(dir);
18
+ if (up === dir) return null;
19
+ dir = up;
20
+ }
21
+ }
22
+
23
+ // Parse argv (already sliced past node + script) into { cmd, rest }.
24
+ // Bare → plan; a leading flag → plan with those flags; -h/--help → help.
25
+ export function route(argv) {
26
+ if (argv.length === 0) return { cmd: "plan", rest: [] };
27
+ if (argv[0].startsWith("-")) {
28
+ if (argv[0] === "-h" || argv[0] === "--help") return { cmd: "help", rest: [] };
29
+ return { cmd: "plan", rest: argv };
30
+ }
31
+ return { cmd: argv[0], rest: argv.slice(1) };
32
+ }
33
+
34
+ // Classify a command into an action the wrapper acts on.
35
+ export function classify(cmd) {
36
+ if (cmd === "help") return { kind: "help" };
37
+ if (NOT_YET[cmd]) return { kind: "notyet", phase: NOT_YET[cmd] };
38
+ if (MAP[cmd]) return { kind: "run", script: MAP[cmd] };
39
+ return { kind: "unknown" };
40
+ }
41
+
42
+ // validate.mjs takes a positional path; the others take relative defaults from repo root.
43
+ export function buildArgs(cmd, rest, relPath = join(...REL)) {
44
+ if (cmd === "validate" && !rest.some((a) => !a.startsWith("-"))) return [relPath, ...rest];
45
+ return rest;
46
+ }
47
+
48
+ // Split `field=value` CLI assignments (roadmap set / backlog set). Splits on the FIRST '='
49
+ // (values may contain '='). `@path` marks read-value-from-file (multiline prompts/briefs);
50
+ // everything else is YAML-parsed by the caller, so `null` deletes per set_fields semantics.
51
+ export function parseAssignments(args) {
52
+ return args.map((a) => {
53
+ const i = a.indexOf("=");
54
+ if (i <= 0) throw new Error(`expected field=value, got "${a}"`);
55
+ const field = a.slice(0, i), raw = a.slice(i + 1);
56
+ return raw.startsWith("@") ? { field, fromFile: raw.slice(1) } : { field, raw };
57
+ });
58
+ }
59
+
60
+ // Single-letter flag aliases, expanded to their long form before the target script sees them.
61
+ const SHORT = {
62
+ "-w": "--wave", "-c": "--cap", "-t": "--term", "-o": "--out", "-i": "--in",
63
+ "-d": "--dry", "-j": "--json", "-a": "--autonomous", "-y": "--yes-spawn-autonomous",
64
+ "-f": "--force", "-s": "--stdout", "-l": "--lane", "-r": "--remove",
65
+ "-lc": "--lead-claude", "-wm": "--worker-mode",
66
+ };
67
+ export function expandShort(args) {
68
+ return args.map((a) => SHORT[a] || a);
69
+ }
70
+
71
+ // Friendly guidance when no roadmap.yaml is found — say WHERE it goes + how to start one,
72
+ // instead of a terse error. Shown for any command (incl. bare `roadmap`).
73
+ export function missingRoadmapHelp(cwd) {
74
+ const rel = REL.join("/");
75
+ return [
76
+ `roadmap: couldn't find ${rel} at or above`,
77
+ ` ${cwd}`,
78
+ ``,
79
+ `The CLI walks UP from your current directory looking for the roadmap graph,`,
80
+ `so run it from anywhere inside a repo whose root has:`,
81
+ ` <repo-root>/${rel}`,
82
+ ``,
83
+ `Don't have one yet? A minimal starter:`,
84
+ ` meta:`,
85
+ ` schema_version: 1`,
86
+ ` program: MYPROJ`,
87
+ ` pis:`,
88
+ ` - id: first`,
89
+ ` title: First initiative`,
90
+ ` status: active`,
91
+ ` sprints:`,
92
+ ` - { id: s1, title: First sprint, status: next, invoke: first-s1, est_sessions: 1 }`,
93
+ ``,
94
+ `Save that to ${rel}, then 'roadmap validate' and 'roadmap render'.`,
95
+ `Run 'roadmap init' from the repo root for a safe bootstrap preview.`,
96
+ ].join("\n");
97
+ }
@@ -0,0 +1,58 @@
1
+ // roadmap — cycle election brain (PURE). The weekly lock: what's committed (active/next),
2
+ // what's electable (ready scheduled work), what fits the capacity, and what's out of the
3
+ // cycle (the dispatch guard's question). No IO — cycle.mjs (CLI) and dispatch.mjs consume
4
+ // this; linear-core.mjs owns CYCLE_STATUSES (the committed set IS the cycle's semantic).
5
+
6
+ import { flatten, readyNodes, isDone } from "./graph.mjs";
7
+ import { comparePriority } from "./priority.mjs";
8
+ import { CYCLE_STATUSES } from "./linear-core.mjs";
9
+
10
+ // The dispatch/fan lock: with cycles on, work outside the committed set doesn't launch
11
+ // without an explicit override. Null/absent cfg or cycles off → never locks (opt-in).
12
+ export function outOfCycle(cfg, status) {
13
+ return !!cfg && cfg.cycles === "on" && !CYCLE_STATUSES.includes(status);
14
+ }
15
+
16
+ const summarize = (stale) => (n) => ({
17
+ invoke: n.invoke, title: n.title, status: n.status, pi: n.piId,
18
+ est: typeof n.estSessions === "number" ? n.estSessions : null,
19
+ priority: n.priority || null,
20
+ stale: stale.has(n.invoke),
21
+ });
22
+
23
+ // The election picture. elected = the current committed set (active/next) with the stale set
24
+ // marked — the ritual reviews those FIRST. candidates = ready (deps satisfied, not held)
25
+ // scheduled slices, priority-sorted then smallest-first; optionality is hand-promotable, never
26
+ // auto-proposed. packed = the greedy candidate PREFIX that fits capacity on top of what's
27
+ // already committed — priority order is honored strictly (no skip-ahead knapsack: a big P0
28
+ // blocking the prefix is a signal to split it, not to sneak P2s past it). Slices with no
29
+ // est_sessions are never silently packed (the honesty gate, mirroring the timeline rollup's
30
+ // unpriced rule) — they land in `unestimated` for the human to price or pass on.
31
+ export function electionPlan(graph, { capacity = 10, staleInvokes = [] } = {}) {
32
+ const model = flatten(graph);
33
+ const stale = new Set(staleInvokes);
34
+ const brief = summarize(stale);
35
+
36
+ const elected = model.nodes.filter((n) => !isDone(n.status) && CYCLE_STATUSES.includes(n.status)).map(brief);
37
+ // Elected-but-unpriced work can't hide from the capacity math as a silent zero — it's
38
+ // surfaced beside the candidate-side honesty gate so the ritual prices it or calls it out.
39
+ const unpricedElected = elected.filter((x) => x.est == null);
40
+ let used = elected.reduce((s, x) => s + (x.est || 0), 0);
41
+
42
+ const candidates = readyNodes(model)
43
+ .filter((n) => n.status === "scheduled")
44
+ .sort((a, b) => comparePriority(a.priority, b.priority) || (a.estSessions ?? Infinity) - (b.estSessions ?? Infinity))
45
+ .map(brief);
46
+ const unestimated = candidates.filter((c) => c.est == null);
47
+ const estimable = candidates.filter((c) => c.est != null);
48
+
49
+ const packed = [];
50
+ for (const c of estimable) {
51
+ if (used + c.est > capacity) break;
52
+ packed.push(c);
53
+ used += c.est;
54
+ }
55
+ const overflow = estimable.slice(packed.length);
56
+
57
+ return { elected, unpricedElected, candidates, packed, overflow, unestimated, capacity, estUsed: used };
58
+ }