@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,313 @@
1
+ // roadmap — MCP brain (PURE). Tool registry, read handlers, YAML-Document mutation
2
+ // functions, and the pre-write integrity gate. No IO: the server (mcp.mjs) does the fs reads,
3
+ // writes, and JSON-RPC. Mutations operate on a `yaml` Document (parseDocument) so comments and
4
+ // formatting in roadmap.yaml survive the edit; reads operate on a plain graph object.
5
+
6
+ import { flatten, detectCycle, STATUS } from "./graph.mjs";
7
+ import { buildPlan } from "./plan.mjs";
8
+ import { validateGraph } from "./validate-core.mjs";
9
+ import { normalizeExecution, suggestedConcurrency, validateExecution } from "./execution.mjs";
10
+ import { validatePriority } from "./priority.mjs";
11
+ import { checkPiOverrideAck, normalizeLinearConfig } from "./linear-core.mjs";
12
+ import { outOfCycle } from "./cycle-core.mjs";
13
+ import { setPlateDoc } from "./plate-core.mjs";
14
+
15
+ const STATUSES = Object.keys(STATUS);
16
+ const VALID_STATUS = new Set(STATUSES);
17
+ const DONE = new Set(STATUSES.filter((s) => STATUS[s].done));
18
+
19
+ // Fields a caller may set on a sprint via set_fields / add_sprint (allow-list; protects id/invoke
20
+ // integrity by omission — invoke is set only at creation, ids are structural).
21
+ const SETTABLE = new Set([
22
+ "title", "what", "status", "status_label", "est_sessions", "weight",
23
+ "deps", "touches", "owns", "gate", "gated_on", "read_order", "resume_action",
24
+ "prs", "completed_on", "optional", "execution", "track", "priority", "prompt", "kickoff_brief", "linear", "milestone", "dispatch_tier",
25
+ "shape", "risks", "estimate",
26
+ ]);
27
+
28
+ // ── tool registry ─────────────────────────────────────────────────────────────
29
+ export const TOOLS = [
30
+ { name: "plan", description: "Recommended concurrency cap + execution waves + held-on-human. Read-only.",
31
+ inputSchema: { type: "object", properties: { cap: { type: "integer", minimum: 1 }, reviewCeiling: { type: "integer", minimum: 1 } } } },
32
+ { name: "ready_wave", description: "The slices runnable right now (wave 1) under the recommended or given cap. Read-only.",
33
+ inputSchema: { type: "object", properties: { cap: { type: "integer", minimum: 1 } } } },
34
+ { name: "show", description: "One slice's detail by its invoke key. Read-only.",
35
+ inputSchema: { type: "object", required: ["invoke"], properties: { invoke: { type: "string" } } } },
36
+ { name: "validate", description: "Structural + dependency + cycle checks. Returns { ok, errors, warnings }. Read-only.",
37
+ inputSchema: { type: "object", properties: {} } },
38
+ { name: "add_pi", description: "Append a new PI (program increment). Validates and re-renders SLICES.md. A linear.granularity that conflicts with the global meta.linear.granularity requires yes_linear_override: true. Scope discipline: a PI is strategic scope — add one only when the user explicitly asked for it; follow-up work goes to backlog_add.",
39
+ inputSchema: { type: "object", required: ["id", "title"], properties: {
40
+ id: { type: "string" }, title: { type: "string" }, status: { enum: STATUSES },
41
+ theme: { type: "string" }, program_label: { type: "string" }, estimate_weeks: { type: "string" },
42
+ exit_criteria: { type: "string" }, deps: { type: "array", items: { type: "string" } },
43
+ summary: { type: "string", description: "One crisp human line — the Linear project subtitle; a PI without one lands as a shell" },
44
+ priority: { type: "object", properties: { tier: { enum: ["P0", "P1", "P2", "P3"] }, weight: { type: "number", minimum: 0, maximum: 100 }, reason: { type: "string" } } },
45
+ initiative: { type: "string" }, detail: { type: "string" }, exec_hint: { type: "string" },
46
+ start_date: { type: "string" }, target_date: { type: "string" },
47
+ linear: { type: "object", properties: { granularity: { enum: ["pis", "slices", "slices+backlog"] }, project: { type: "string" } } },
48
+ yes_linear_override: { type: "boolean" } } } },
49
+ { name: "add_sprint", description: "Append a sprint to an existing PI. Validates and re-renders SLICES.md. Scope discipline: scope decisions belong to the human — prefer backlog_add for follow-up work discovered mid-session; add sprints only when the user asked for them.",
50
+ inputSchema: { type: "object", required: ["pi", "id", "title", "invoke"], properties: {
51
+ pi: { type: "string" }, id: { type: "string" }, title: { type: "string" }, invoke: { type: "string" },
52
+ status: { enum: STATUSES }, what: { type: "string" }, est_sessions: { type: "number" },
53
+ deps: { type: "array", items: { type: "string" } }, touches: { type: "array", items: { type: "string" } },
54
+ owns: { type: "array", items: { type: "string" } }, gate: { type: "string" },
55
+ weight: { enum: ["heavy", "medium", "light"] }, gated_on: { type: "string" },
56
+ read_order: { type: "array", items: { type: "string" } }, resume_action: { type: "string" },
57
+ prompt: { type: "string" }, milestone: { type: "string" }, status_label: { type: "string" },
58
+ dispatch_tier: { type: "string" }, kickoff_brief: { type: "string" }, optional: { type: "boolean" },
59
+ track: { type: "string" }, risks: { type: "array", items: { type: "string" } },
60
+ priority: { type: "object", properties: { tier: { enum: ["P0", "P1", "P2", "P3"] }, weight: { type: "number", minimum: 0, maximum: 100 }, reason: { type: "string" } } } } } },
61
+ { name: "set_status", description: "Set a slice's status (by invoke), optionally recording PRs + completed_on. Re-renders.",
62
+ inputSchema: { type: "object", required: ["invoke", "status"], properties: {
63
+ invoke: { type: "string" }, status: { enum: STATUSES },
64
+ prs: { type: "array", items: { type: "string" } }, completed_on: { type: "string" } } } },
65
+ { name: "set_fields", description: "Set one or more allowed fields on a slice (by invoke). null value deletes a field. Re-renders.",
66
+ inputSchema: { type: "object", required: ["invoke", "fields"], properties: {
67
+ invoke: { type: "string" }, fields: { type: "object" } } } },
68
+ { name: "bulk_set", description: "Set allowed fields on MANY slices in one atomic edit (one validate, one write, one re-render — all-or-nothing). Each update mirrors set_fields: { invoke, fields }, null value deletes a field.",
69
+ inputSchema: { type: "object", required: ["updates"], properties: {
70
+ updates: { type: "array", minItems: 1, items: { type: "object", required: ["invoke", "fields"], properties: {
71
+ invoke: { type: "string" }, fields: { type: "object" } } } } } } },
72
+ { name: "prune", description: "Remove a slice (by invoke), a whole PI (by pi id), or every complete slice (scope='completed'). Re-renders SLICES.md. Validated before writing: the removal is rejected with no change made if it would orphan a dependency, duplicate an invoke key, or otherwise break the graph.",
73
+ inputSchema: { type: "object", properties: {
74
+ invoke: { type: "string" }, pi: { type: "string" }, scope: { enum: ["completed"] } } } },
75
+ { name: "plate_set", description: "Replace the plate (meta.plate) with `keys` — the curated batch you'll work NOW, projected to Linear's My Issues (assignee=you) on the next sync. The intended use from a planning session (/prioritize): load the top-ranked ready slices. Keep it under plate_max — active work auto-shows on top and completed slices auto-drain, so this is just the deliberate 'on my desk' set.",
76
+ inputSchema: { type: "object", required: ["keys"], properties: { keys: { type: "array", items: { type: "string" }, description: "slice invoke keys / backlog ids" } } } },
77
+ { name: "plate_add", description: "Add slice invoke keys / backlog ids to the plate (meta.plate) — creates it (enables the plate) if absent. For folding a newly-relevant slice into the current batch without replacing it.",
78
+ inputSchema: { type: "object", required: ["keys"], properties: { keys: { type: "array", minItems: 1, items: { type: "string" } } } } },
79
+ { name: "plate_remove", description: "Remove entries from the plate (meta.plate). Completed slices auto-drain on sync; use this to pull something off your plate early.",
80
+ inputSchema: { type: "object", required: ["keys"], properties: { keys: { type: "array", minItems: 1, items: { type: "string" } } } } },
81
+ ];
82
+
83
+ // ── read handlers (operate on a plain graph object) ────────────────────────────
84
+ export function readPlan(graph, args = {}) {
85
+ return buildPlan(graph, { cap: args.cap, reviewCeiling: args.reviewCeiling });
86
+ }
87
+ export function readReadyWave(graph, args = {}) {
88
+ const plan = buildPlan(graph, { cap: args.cap });
89
+ // Advisory cycle-lock annotation (reads never block): with cycles on, callers see which wave
90
+ // slices dispatch/fan would refuse, so planning skills steer to the election instead.
91
+ const cfg = normalizeLinearConfig(graph.meta || {});
92
+ const wave = (plan.waves[0] || []).map((n) => (outOfCycle(cfg, n.status) ? { ...n, outOfCycle: true } : n));
93
+ return { cap: plan.cap, recommended: plan.recommended, wave, held: plan.held };
94
+ }
95
+ export function readShow(graph, args) {
96
+ if (!args || !args.invoke) throw new Error("show requires invoke");
97
+ const model = flatten(graph);
98
+ const n = model.nodes.find((x) => x.invoke === args.invoke);
99
+ if (!n) throw new Error(`no slice with invoke "${args.invoke}"`);
100
+ return {
101
+ invoke: n.invoke, pi: n.piId, sprint: n.id, title: n.title, status: n.status, what: n.what,
102
+ deps: n.deps, piDeps: n.piDeps, touches: n.touches, owns: n.owns, gate: n.gate,
103
+ gatedOn: n.gatedOn, readOrder: n.readOrder, resumeAction: n.resumeAction,
104
+ estSessions: n.estSessions, prs: n.prs,
105
+ track: n.track, execution: normalizeExecution(n.execution), suggestedConcurrency: suggestedConcurrency(n),
106
+ priority: n.priority, prompt: n.prompt, linear: n.linear,
107
+ ...(n.dispatchTier ? { dispatchTier: n.dispatchTier } : {}),
108
+ };
109
+ }
110
+ export function readValidate(graph) {
111
+ const { errors, warnings, nodeCount } = validateGraph(graph);
112
+ return { ok: errors.length === 0, errors, warnings, nodeCount };
113
+ }
114
+
115
+ export const READ_HANDLERS = { plan: readPlan, ready_wave: readReadyWave, show: readShow, validate: readValidate };
116
+
117
+ // ── Document helpers (yaml AST navigation) ──────────────────────────────────────
118
+ function pisSeq(doc) {
119
+ const s = doc.get("pis");
120
+ if (!s || !s.items) throw new Error("roadmap has no pis sequence");
121
+ return s;
122
+ }
123
+ function piIndexById(doc, piId) {
124
+ const items = pisSeq(doc).items;
125
+ for (let i = 0; i < items.length; i++) if (String(items[i].get("id")) === piId) return i;
126
+ return -1;
127
+ }
128
+ function sprintLocByInvoke(doc, invoke) {
129
+ const pis = pisSeq(doc).items;
130
+ for (let i = 0; i < pis.length; i++) {
131
+ const sprints = pis[i].get("sprints");
132
+ const items = (sprints && sprints.items) || [];
133
+ for (let j = 0; j < items.length; j++) {
134
+ if (String(items[j].get("invoke")) === invoke) return { pi: i, sprint: j };
135
+ }
136
+ }
137
+ return null;
138
+ }
139
+
140
+ // ── mutation functions (operate on a yaml Document; mutate in place, return a summary) ──
141
+ // Linear-ready field sets: what a node needs so it lands on the board (and in an election)
142
+ // fully dialed instead of as a shell needing a second pass. Returned as `missing` by the add
143
+ // mutations — a nudge at the add seam, never a block (drafts are legitimate).
144
+ const PI_READY = ["summary", "priority"];
145
+ const SPRINT_READY = ["what", "gate", "est_sessions", "priority"];
146
+ export function readinessGaps(node, kind) {
147
+ return (kind === "pi" ? PI_READY : SPRINT_READY).filter((k) => node[k] == null);
148
+ }
149
+
150
+ export function addPi(doc, args) {
151
+ if (!args || !args.id || !args.title) throw new Error("add_pi requires id + title");
152
+ if (piIndexById(doc, args.id) >= 0) throw new Error(`PI "${args.id}" already exists`);
153
+ // A per-PI Linear granularity that conflicts with the global must be explicitly acked
154
+ // (mirrors --yes-spawn-autonomous); the throw happens before any Document mutation.
155
+ if (args.linear) checkPiOverrideAck(normalizeLinearConfig(doc.toJS().meta || {}), args.linear, args.yes_linear_override, args.id);
156
+ const node = { id: args.id, title: args.title, status: args.status || "scheduled" };
157
+ // Every schema-legal add-time field is copied. This list silently dropping fields the
158
+ // caller passed is exactly how a fully-specified PI landed on the live board as a shell
159
+ // (summary/priority/initiative vanished) — extend it whenever the schema grows.
160
+ for (const k of ["theme", "program_label", "estimate_weeks", "exit_criteria", "linear",
161
+ "summary", "priority", "initiative", "detail", "exec_hint", "start_date", "target_date"]) if (args[k] != null) node[k] = args[k];
162
+ if (Array.isArray(args.deps)) node.deps = args.deps;
163
+ node.sprints = [];
164
+ // createNode: addIn stores a plain object un-wrapped, which breaks later AST reads (.get)
165
+ // on the same Document (e.g. add_pi then add_sprint in one batch).
166
+ doc.addIn(["pis"], doc.createNode(node));
167
+ const missing = readinessGaps(node, "pi");
168
+ return missing.length ? { added: "pi", id: args.id, missing } : { added: "pi", id: args.id };
169
+ }
170
+
171
+ export function addSprint(doc, args) {
172
+ for (const k of ["pi", "id", "title", "invoke"]) if (!args || !args[k]) throw new Error(`add_sprint requires ${k}`);
173
+ const pi = piIndexById(doc, args.pi);
174
+ if (pi < 0) throw new Error(`no PI "${args.pi}"`);
175
+ const node = { id: args.id, title: args.title, status: args.status || "scheduled", invoke: args.invoke };
176
+ // Same silent-drop hazard as addPi: keep in step with the sprint schema.
177
+ for (const k of ["what", "est_sessions", "gate", "weight", "gated_on", "resume_action", "prompt", "priority", "linear", "milestone",
178
+ "shape", "estimate", "optional", "kickoff_brief", "track", "execution", "status_label", "dispatch_tier"]) if (args[k] != null) node[k] = args[k];
179
+ for (const k of ["deps", "touches", "owns", "read_order", "risks"]) if (Array.isArray(args[k])) node[k] = args[k];
180
+ const piMap = pisSeq(doc).items[pi];
181
+ if (!piMap.has("sprints") || !piMap.get("sprints")) doc.setIn(["pis", pi, "sprints"], doc.createNode([node]));
182
+ else doc.addIn(["pis", pi, "sprints"], doc.createNode(node));
183
+ const missing = readinessGaps(node, "sprint");
184
+ return missing.length ? { added: "sprint", pi: args.pi, invoke: args.invoke, missing } : { added: "sprint", pi: args.pi, invoke: args.invoke };
185
+ }
186
+
187
+ export function setStatus(doc, args) {
188
+ if (!args || !args.invoke || !args.status) throw new Error("set_status requires invoke + status");
189
+ if (!VALID_STATUS.has(args.status)) throw new Error(`invalid status "${args.status}"`);
190
+ const loc = sprintLocByInvoke(doc, args.invoke);
191
+ if (!loc) throw new Error(`no slice "${args.invoke}"`);
192
+ const base = ["pis", loc.pi, "sprints", loc.sprint];
193
+ doc.setIn([...base, "status"], args.status);
194
+ if (Array.isArray(args.prs)) doc.setIn([...base, "prs"], args.prs);
195
+ if (args.completed_on) doc.setIn([...base, "completed_on"], args.completed_on);
196
+ return { updated: args.invoke, status: args.status };
197
+ }
198
+
199
+ export function setFields(doc, args) {
200
+ if (!args || !args.invoke || !args.fields) throw new Error("set_fields requires invoke + fields");
201
+ const loc = sprintLocByInvoke(doc, args.invoke);
202
+ if (!loc) throw new Error(`no slice "${args.invoke}"`);
203
+ const base = ["pis", loc.pi, "sprints", loc.sprint];
204
+ const changed = [];
205
+ for (const [k, v] of Object.entries(args.fields)) {
206
+ if (!SETTABLE.has(k)) throw new Error(`field "${k}" is not settable`);
207
+ if (v === null) doc.deleteIn([...base, k]);
208
+ else doc.setIn([...base, k], v);
209
+ changed.push(k);
210
+ }
211
+ return { updated: args.invoke, fields: changed };
212
+ }
213
+
214
+ // Atomicity is the caller's single write: every update mutates the same Document, one
215
+ // validateDocOrThrow gates the write, so a bad field in update N leaves updates 1..N-1 unwritten.
216
+ export function bulkSet(doc, args) {
217
+ if (!args || !Array.isArray(args.updates) || !args.updates.length) {
218
+ throw new Error("bulk_set requires updates: [{invoke, fields}, ...]");
219
+ }
220
+ const updated = [];
221
+ for (const u of args.updates) updated.push(setFields(doc, u).updated);
222
+ return { updated, count: updated.length };
223
+ }
224
+
225
+ export function prune(doc, args = {}) {
226
+ if (args.invoke) {
227
+ const loc = sprintLocByInvoke(doc, args.invoke);
228
+ if (!loc) throw new Error(`no slice "${args.invoke}"`);
229
+ doc.deleteIn(["pis", loc.pi, "sprints", loc.sprint]);
230
+ return { pruned: [args.invoke] };
231
+ }
232
+ if (args.pi) {
233
+ const i = piIndexById(doc, args.pi);
234
+ if (i < 0) throw new Error(`no PI "${args.pi}"`);
235
+ doc.deleteIn(["pis", i]);
236
+ return { pruned: [`PI:${args.pi}`] };
237
+ }
238
+ if (args.scope === "completed") {
239
+ const graph = doc.toJS();
240
+ const doneInvokes = [];
241
+ for (const pi of graph.pis || []) for (const sp of pi.sprints || []) if (DONE.has(sp.status) && sp.invoke) doneInvokes.push(sp.invoke);
242
+ for (const inv of doneInvokes) {
243
+ const loc = sprintLocByInvoke(doc, inv); // re-find each time; indices shift as we delete
244
+ if (loc) doc.deleteIn(["pis", loc.pi, "sprints", loc.sprint]);
245
+ }
246
+ const pis = pisSeq(doc).items;
247
+ for (let i = pis.length - 1; i >= 0; i--) { // drop PIs left empty, from the end
248
+ const s = pis[i].get("sprints");
249
+ if (!s || !s.items || s.items.length === 0) doc.deleteIn(["pis", i]);
250
+ }
251
+ return { pruned: doneInvokes };
252
+ }
253
+ throw new Error("prune requires one of: invoke, pi, or scope='completed'");
254
+ }
255
+
256
+ // ── plate mutations (meta.plate → Linear My Issues) ── operate on the Document like set_fields;
257
+ // setPlateDoc writes a block-style seq. The read side (plate_list) lives in the server (needs backlog).
258
+ function plateListFromDoc(doc) {
259
+ const arr = (doc.toJS().meta || {}).plate; // via the Document (a bare node.toJS() needs the doc passed in)
260
+ return Array.isArray(arr) ? arr.filter((k) => typeof k === "string") : [];
261
+ }
262
+ export function setPlate(doc, args) {
263
+ if (!args || !Array.isArray(args.keys)) throw new Error("plate_set requires keys: [string, ...]");
264
+ const keys = [...new Set(args.keys.filter((k) => typeof k === "string"))];
265
+ setPlateDoc(doc, keys);
266
+ return { plate: "set", keys };
267
+ }
268
+ export function addPlate(doc, args) {
269
+ if (!args || !Array.isArray(args.keys) || !args.keys.length) throw new Error("plate_add requires keys: [string, ...]");
270
+ const keys = [...new Set([...plateListFromDoc(doc), ...args.keys.filter((k) => typeof k === "string")])];
271
+ setPlateDoc(doc, keys);
272
+ return { plate: "add", keys };
273
+ }
274
+ export function removePlate(doc, args) {
275
+ if (!args || !Array.isArray(args.keys) || !args.keys.length) throw new Error("plate_remove requires keys: [string, ...]");
276
+ const keys = plateListFromDoc(doc).filter((k) => !args.keys.includes(k));
277
+ setPlateDoc(doc, keys);
278
+ return { plate: "remove", keys };
279
+ }
280
+
281
+ export const MUTATION_HANDLERS = { add_pi: addPi, add_sprint: addSprint, set_status: setStatus, set_fields: setFields, bulk_set: bulkSet, prune, plate_set: setPlate, plate_add: addPlate, plate_remove: removePlate };
282
+
283
+ // ── pre-write integrity gate ────────────────────────────────────────────────────
284
+ // Throws if the edited Document would corrupt the roadmap (duplicate invoke, unresolved
285
+ // dependency, dependency cycle, or an invalid status). Returns the plain graph on success,
286
+ // so the server can hand it straight to renderMarkdown.
287
+ // Serialize a mutated Document back to YAML with options that MINIMIZE diff churn against a
288
+ // hand-authored roadmap.yaml: lineWidth 0 (never re-wrap long scalars like resume_action) and no
289
+ // flow-collection padding (match the common ["#1"] / [s1] style). Comments survive either way.
290
+ // Residual churn is irreducible when the source mixes flow styles; this picks the lower-churn side.
291
+ export function serialize(doc) {
292
+ return doc.toString({ lineWidth: 0, flowCollectionPadding: false });
293
+ }
294
+
295
+ export function validateDocOrThrow(doc) {
296
+ const graph = doc.toJS();
297
+ let model;
298
+ try {
299
+ model = flatten(graph); // throws on duplicate invoke / unresolved dep
300
+ } catch (e) {
301
+ throw new Error(`edit would corrupt the roadmap: ${e.message}`);
302
+ }
303
+ const cyc = detectCycle(model);
304
+ if (cyc) throw new Error(`edit would create a dependency cycle: ${cyc.join(" -> ")}`);
305
+ for (const n of model.nodes) {
306
+ if (!VALID_STATUS.has(n.status)) throw new Error(`invalid status "${n.status}" on slice "${n.invoke}"`);
307
+ const { errors } = validateExecution(n.execution, n.invoke);
308
+ if (errors.length) throw new Error(`edit would corrupt the roadmap: ${errors[0]}`);
309
+ const pri = validatePriority(n.priority, n.invoke);
310
+ if (pri.errors.length) throw new Error(`edit would corrupt the roadmap: ${pri.errors[0]}`);
311
+ }
312
+ return graph;
313
+ }
@@ -0,0 +1,68 @@
1
+ // roadmap — pure plan builder: roadmap graph -> the execution plan object.
2
+ // No IO: flattens, recommends a cap, computes the waves, and returns the structured plan
3
+ // (cap, recommended, binding, sys, candidates, waves[], held). scheduler.mjs prints it;
4
+ // the MCP read tools (plan / ready_wave) return it as JSON. computeWaves may throw on a
5
+ // dependency cycle; callers catch.
6
+
7
+ import { flatten, computeWaves, readyNodes, coherenceEnabled, isDone } from "./graph.mjs";
8
+ import { recommendConcurrency, nodeWeight, probeDisk } from "./recommend.mjs";
9
+ import { branchFor, worktreeFor, launchPrompt } from "./brief.mjs";
10
+ import { normalizeExecution, suggestedConcurrency } from "./execution.mjs";
11
+
12
+ const round = (x) => Math.round(x * 10) / 10;
13
+
14
+ // buildPlan(graph, { cap, useFree, reviewCeiling, disk }). cap omitted -> the recommended cap.
15
+ // disk: undefined -> probe the machine (the real surfaces); null/object -> injected (tests).
16
+ export function buildPlan(graph, opts = {}) {
17
+ const model = flatten(graph);
18
+ const ready = readyNodes(model);
19
+ const rec = recommendConcurrency(ready, graph, {
20
+ useFree: opts.useFree,
21
+ reviewCeiling: opts.reviewCeiling ?? 5,
22
+ disk: opts.disk !== undefined ? opts.disk : probeDisk(graph),
23
+ });
24
+ const cap = opts.cap != null ? Number(opts.cap) : rec.recommended;
25
+ const { waves, held } = computeWaves(model, cap, { coherence: coherenceEnabled(graph.meta) });
26
+
27
+ // Which PIs each wave CLOSES (all sprints done once the wave lands, counting earlier waves
28
+ // optimistically) — the coherence read-out: "this wave finishes auth".
29
+ const doneKeys = new Set(model.nodes.filter((n) => isDone(n.status)).map((n) => n.nodeKey));
30
+ const waveCloses = waves.map((w) => {
31
+ w.forEach((n) => doneKeys.add(n.nodeKey));
32
+ return [...new Set(w.map((n) => n.piId))].filter((pi) =>
33
+ model.nodes.filter((m) => m.piId === pi).every((m) => doneKeys.has(m.nodeKey)));
34
+ });
35
+
36
+ return {
37
+ cap,
38
+ recommended: rec.recommended,
39
+ binding: rec.binding,
40
+ sys: { cores: rec.sys.cores, totalGb: round(rec.sys.totalGb), freeGb: round(rec.sys.freeGb), platform: rec.sys.platform },
41
+ disk: rec.disk ? { perWorktreeGb: round(rec.disk.perWorktreeGb), freeGb: round(rec.disk.freeGb), cap: rec.disk.cap } : null,
42
+ candidates: rec.candidates,
43
+ waveCloses,
44
+ waves: waves.map((w) =>
45
+ w.map((n) => ({
46
+ invoke: n.invoke,
47
+ pi: n.piId,
48
+ sprint: n.id,
49
+ status: n.status,
50
+ ...(n.dispatchTier ? { dispatch_tier: n.dispatchTier } : {}),
51
+ weight: nodeWeight(n, graph),
52
+ est_sessions: n.estSessions,
53
+ branch: branchFor(n, graph),
54
+ worktree: worktreeFor(n, graph),
55
+ prompt: launchPrompt(n),
56
+ what: n.what,
57
+ track: n.track,
58
+ priority: n.priority,
59
+ execution: normalizeExecution(n.execution),
60
+ suggestedConcurrency: suggestedConcurrency(n),
61
+ }))
62
+ ),
63
+ held: {
64
+ onHuman: held.onHuman.map((n) => ({ invoke: n.invoke, gatedOn: n.gatedOn, what: n.what })),
65
+ blocked: held.blocked.map((n) => ({ invoke: n.invoke, what: n.what })),
66
+ },
67
+ };
68
+ }
@@ -0,0 +1,53 @@
1
+ // roadmap — "the plate" (PURE): the curated batch that projects to Linear's My Issues (assignee=you).
2
+ // meta.plate is an explicit list of slice invoke keys / backlog item ids; the live plate ALSO includes
3
+ // whatever you're actively working (active slices, in_progress items) so "what I'm doing" is always on it.
4
+ // The feature is OFF (byte-identical, no assignee projection) unless meta.plate is defined.
5
+ // No IO: linear.mjs fetches the viewer id + assignee snapshot and does the assign/unassign; this decides.
6
+
7
+ import { isDone } from "./graph.mjs";
8
+
9
+ // The set of keys that should be assigned to you right now, or null when the feature is off.
10
+ // = meta.plate (explicit) ∪ active slices ∪ in_progress backlog items ("active is always on the plate").
11
+ export function platedKeys(graph, backlog = null) {
12
+ const meta = graph.meta || {};
13
+ if (meta.plate == null) return null;
14
+ const keys = new Set(Array.isArray(meta.plate) ? meta.plate.filter((k) => typeof k === "string") : []);
15
+ for (const pi of graph.pis || []) for (const sp of pi.sprints || []) if (sp.status === "active" && sp.invoke) keys.add(sp.invoke);
16
+ for (const it of (backlog && backlog.items) || []) if (it.status === "in_progress" && it.id) keys.add(it.id);
17
+ return keys;
18
+ }
19
+
20
+ // Which EXPLICIT meta.plate entries should auto-drain — "complete only": the work finished (slice done,
21
+ // or item done/dropped/promoted). Blocked/paused/gated stay (a visible reminder). Active is never here.
22
+ // Returns the keys to REMOVE from meta.plate (a subset of the explicit list).
23
+ export function plateDrainKeys(graph, backlog = null) {
24
+ const list = (graph.meta && graph.meta.plate) || [];
25
+ if (!Array.isArray(list) || !list.length) return [];
26
+ const done = new Set();
27
+ for (const pi of graph.pis || []) for (const sp of pi.sprints || []) if (isDone(sp.status) && sp.invoke) done.add(sp.invoke);
28
+ for (const it of (backlog && backlog.items) || []) if (["done", "dropped", "promoted"].includes(it.status)) done.add(it.id);
29
+ return list.filter((k) => done.has(k));
30
+ }
31
+
32
+ // Set meta.plate to `keys` on a YAML Document (block style — a flow seq goes unreadable once it grows).
33
+ // Empty list is written (not deleted) so "the feature is on, batch is empty" stays distinct from "off".
34
+ export function setPlateDoc(doc, keys) {
35
+ const node = doc.createNode([...keys]);
36
+ node.flow = false;
37
+ doc.setIn(["meta", "plate"], node);
38
+ return keys.length;
39
+ }
40
+
41
+ // Structural validation + the signal-preservation cap. Reference checking (a key that matches no
42
+ // slice/item) is done at projection time (buildPushPlan reports unmatchedPlate) since it needs the backlog.
43
+ export function validatePlate(graph, plateMax) {
44
+ const errors = [], warnings = [];
45
+ const p = graph.meta && graph.meta.plate;
46
+ if (p == null) return { errors, warnings };
47
+ if (!Array.isArray(p)) { errors.push("meta.plate must be a list of slice invoke keys / backlog item ids"); return { errors, warnings }; }
48
+ for (const k of p) if (typeof k !== "string") errors.push(`meta.plate entries must be strings (got ${JSON.stringify(k)})`);
49
+ if (plateMax && p.length > plateMax) {
50
+ warnings.push(`meta.plate has ${p.length} explicit entries > plate_max ${plateMax} — trim it so My Issues stays signal (active work adds on top automatically)`);
51
+ }
52
+ return { errors, warnings };
53
+ }
@@ -0,0 +1,72 @@
1
+ // roadmap — PR-watch brain (PURE). Decides which PR changes are worth telling the lead
2
+ // about, and which branches belong to this roadmap's fanout. No IO: watch-prs.mjs polls `gh`,
3
+ // normalizes each PR, and feeds snapshots through here. The watcher stays quiet until a PR
4
+ // actually changes phase, so an always-on monitor never spams.
5
+
6
+ import { flatten } from "./graph.mjs";
7
+ import { branchFor } from "./brief.mjs";
8
+
9
+ // Reduce a PR's statusCheckRollup (raw `gh` JSON) to one of: none | passing | pending | failing.
10
+ // Pure, so the rollup-to-enum mapping that prPhase keys off is unit-testable without calling gh.
11
+ export function checksOf(pr) {
12
+ const rollup = (pr && pr.statusCheckRollup) || [];
13
+ if (!rollup.length) return "none";
14
+ const states = rollup.map((c) => String(c.conclusion || c.state || c.status || "").toUpperCase());
15
+ if (states.some((s) => ["FAILURE", "ERROR", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE"].includes(s))) return "failing";
16
+ if (states.some((s) => ["PENDING", "IN_PROGRESS", "QUEUED", "WAITING", "REQUESTED", ""].includes(s))) return "pending";
17
+ return "passing";
18
+ }
19
+
20
+ // The single phase we'd tell the lead about. Derived from the normalized PR fields
21
+ // { state, isDraft, mergeStateStatus, checks }.
22
+ export function prPhase(pr) {
23
+ if (pr.state === "MERGED") return "merged";
24
+ if (pr.state === "CLOSED") return "closed";
25
+ if (pr.isDraft) return "draft";
26
+ if (pr.mergeStateStatus === "CONFLICTING" || pr.mergeStateStatus === "DIRTY") return "conflicts";
27
+ if (pr.checks === "failing") return "checks-failing";
28
+ if (pr.checks === "pending") return "checks-pending";
29
+ return "ready"; // open, not draft, no conflicts, checks passing or none
30
+ }
31
+
32
+ const PHASE_MSG = {
33
+ merged: "merged — reconcile the roadmap (/sync or the set_status tool)",
34
+ closed: "closed without merging",
35
+ draft: "opened as a draft",
36
+ conflicts: "has merge conflicts",
37
+ "checks-failing": "checks failing",
38
+ "checks-pending": "checks running",
39
+ ready: "ready to merge",
40
+ };
41
+
42
+ // diffPrStates(prev, curr): both are { [number]: normalizedPr }. Returns one event per PR that is
43
+ // newly seen or has changed phase, each with a one-line message for the lead. Deterministic.
44
+ export function diffPrStates(prev, curr) {
45
+ const events = [];
46
+ for (const num of Object.keys(curr)) {
47
+ const pr = curr[num];
48
+ const before = prev[num];
49
+ const phase = prPhase(pr);
50
+ if (before && prPhase(before) === phase) continue; // unchanged
51
+ events.push({
52
+ number: pr.number,
53
+ headRefName: pr.headRefName,
54
+ title: pr.title,
55
+ phase,
56
+ message: `PR #${pr.number} (${pr.headRefName}) ${PHASE_MSG[phase] || phase}`,
57
+ });
58
+ }
59
+ return events;
60
+ }
61
+
62
+ // The branch names this roadmap's slices fan out onto (one per node, via branchFor).
63
+ export function roadmapBranches(graph) {
64
+ const model = flatten(graph);
65
+ return new Set(model.nodes.map((n) => branchFor(n, graph)));
66
+ }
67
+
68
+ // Is a PR's head branch one of this roadmap's fanout branches? (So the lead only hears about
69
+ // its own wave, not every PR in the repo.)
70
+ export function matchesRoadmapBranches(headRef, graph) {
71
+ return roadmapBranches(graph).has(headRef);
72
+ }
@@ -0,0 +1,43 @@
1
+ // roadmap — priority brain (PURE). Shared by roadmap sprints and backlog items:
2
+ // priority: { tier: P0..P3, weight: 0..100, reason }. Every field optional; sort
3
+ // order is DERIVED (tier asc, then weight desc), never stored.
4
+
5
+ export const TIERS = ["P0", "P1", "P2", "P3"];
6
+ const TIER_RANK = new Map(TIERS.map((t, i) => [t, i]));
7
+
8
+ // Validate an optional priority block. Absent → no errors (backward-compatible).
9
+ export function validatePriority(raw, where) {
10
+ const errors = [];
11
+ if (raw == null) return { errors };
12
+ if (typeof raw !== "object" || Array.isArray(raw)) {
13
+ return { errors: [`${where}: priority must be a mapping { tier, weight, reason }`] };
14
+ }
15
+ if (raw.tier != null && !TIER_RANK.has(raw.tier)) {
16
+ errors.push(`${where}: priority.tier "${raw.tier}" is not one of ${TIERS.join("|")}`);
17
+ }
18
+ if (raw.weight != null && (typeof raw.weight !== "number" || raw.weight < 0 || raw.weight > 100)) {
19
+ errors.push(`${where}: priority.weight must be a number 0..100 (got ${JSON.stringify(raw.weight)})`);
20
+ }
21
+ if (raw.reason != null && typeof raw.reason !== "string") {
22
+ errors.push(`${where}: priority.reason must be a string`);
23
+ }
24
+ return { errors };
25
+ }
26
+
27
+ // Sort comparator. Tier ascending (absent tier ranks AFTER P3), then weight descending
28
+ // (absent = 0). Returns 0 when both are absent — the backward-compat guarantee: an
29
+ // unprioritized graph falls through to the caller's existing ordering untouched.
30
+ export function comparePriority(a, b) {
31
+ if (a == null && b == null) return 0;
32
+ const ta = a && TIER_RANK.has(a.tier) ? TIER_RANK.get(a.tier) : TIERS.length;
33
+ const tb = b && TIER_RANK.has(b.tier) ? TIER_RANK.get(b.tier) : TIERS.length;
34
+ if (ta !== tb) return ta - tb;
35
+ const wa = (a && typeof a.weight === "number") ? a.weight : 0;
36
+ const wb = (b && typeof b.weight === "number") ? b.weight : 0;
37
+ return wb - wa;
38
+ }
39
+
40
+ // "P0" | null — the compact badge for renders and plan listings.
41
+ export function tierBadge(p) {
42
+ return p && TIER_RANK.has(p.tier) ? p.tier : null;
43
+ }