@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,780 @@
1
+ #!/usr/bin/env node
2
+ // roadmap linear <status|auth|setup|sync> — the ONLY file that talks to Linear's API.
3
+ // The brain is lib/linear-core.mjs (pure); this layer does GraphQL IO (global fetch,
4
+ // injectable for tests), the sync cursor, and the YAML write-backs via lib/store.mjs.
5
+ //
6
+ // roadmap linear status [--probe] [--json] state check (probe = one networked viewer query)
7
+ // roadmap linear auth how to set LINEAR_API_KEY (never stored in files)
8
+ // roadmap linear setup --team KEY [...] write meta.linear (queries your teams first)
9
+ // roadmap linear provision shape the workspace: labels, views, guidance texts
10
+ // roadmap linear sync [--dry] [--push-only] [--pull-only]
11
+ // roadmap linear post-update --pi <id> --body <text|@file> digest → Linear project update
12
+
13
+ import { readFileSync, writeFileSync } from "node:fs";
14
+ import { spawnSync } from "node:child_process";
15
+ import { join, resolve } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import { loadGraph, flatten } from "./lib/graph.mjs";
18
+ import { setFields } from "./lib/mcp-core.mjs";
19
+ import { addItem, setItemFields } from "./lib/backlog-core.mjs";
20
+ import { mutateRoadmap, mutateBacklog, loadBacklog, roadmapPaths } from "./lib/store.mjs";
21
+ import { plateDrainKeys, setPlateDoc } from "./lib/plate-core.mjs";
22
+ import { noteBody } from "./lib/journal-core.mjs";
23
+ import {
24
+ normalizeLinearConfig, linearState, linearStatusLine, buildPushPlan, buildPullProposals, holdsFor,
25
+ provisionPlan, manualViewChecklist, agentGuidanceText, dispatchGuidance, initiativePlan, initiativeStyle,
26
+ startStampTargets, milestonePlan, cyclePlan, cycleCandidates, CYCLE_STATUSES, staleKeys,
27
+ } from "./lib/linear-core.mjs";
28
+
29
+ const ENDPOINT = "https://api.linear.app/graphql";
30
+ const CURSOR_FILE = ".roadmap-linear-state.json";
31
+
32
+ // ── transport (injectable; deliberately NOT exported — every consumer goes through the
33
+ // run* operations so this stays the only file that talks to the API) ──────────────────
34
+ async function gql(query, variables, { apiKey, fetchImpl = fetch }) {
35
+ const res = await fetchImpl(ENDPOINT, {
36
+ method: "POST",
37
+ headers: { "Content-Type": "application/json", Authorization: apiKey }, // personal key: bare, no Bearer
38
+ body: JSON.stringify({ query, variables }),
39
+ });
40
+ if (!res.ok) throw new Error(`Linear API HTTP ${res.status}${res.status === 401 ? " — is LINEAR_API_KEY valid?" : ""}`);
41
+ const body = await res.json();
42
+ if (body.errors && body.errors.length) throw new Error(`Linear API: ${body.errors.map((e) => e.message).join("; ")}`);
43
+ return body.data;
44
+ }
45
+
46
+ // ── cursor ────────────────────────────────────────────────────────────────────
47
+ export function readCursor(root) {
48
+ try { return JSON.parse(readFileSync(join(root, CURSOR_FILE), "utf8")); } catch { return null; }
49
+ }
50
+ // Patch-merge: lastSync (pull window) and stale (the election CLI's offline basis) advance
51
+ // independently — persisting one never clobbers the other.
52
+ function writeCursor(root, patch) {
53
+ const cur = readCursor(root) || { version: 1 };
54
+ writeFileSync(join(root, CURSOR_FILE), JSON.stringify({ ...cur, ...patch }, null, 2) + "\n", "utf8");
55
+ }
56
+
57
+ // ── queries ───────────────────────────────────────────────────────────────────
58
+ // withCycle: only the cycles feature asks for activeCycle — with cycles off the query is
59
+ // byte-identical to the pre-cycles tool (the off-path contract every knob keeps).
60
+ async function fetchTeamBundle(teamKey, io, withCycle = false) {
61
+ const data = await gql(
62
+ `query($key: String!) { teams(filter: { key: { eq: $key } }) { nodes {
63
+ id key name
64
+ ${withCycle ? "activeCycle { id }\n " : ""}states { nodes { id name type position } }
65
+ labels { nodes { id name } } } } }`,
66
+ { key: teamKey }, io);
67
+ const team = data.teams.nodes[0];
68
+ if (!team) throw new Error(`no Linear team with key "${teamKey}" (check meta.linear.team)`);
69
+ return {
70
+ id: team.id,
71
+ activeCycleId: (withCycle && team.activeCycle && team.activeCycle.id) || null, // null: cycles off in Linear, or between cycles
72
+ states: [...team.states.nodes].sort((a, b) => a.position - b.position),
73
+ labels: Object.fromEntries(((team.labels && team.labels.nodes) || []).map((l) => [l.name, l.id])),
74
+ };
75
+ }
76
+
77
+ // The one issueUpdate literal — four call sites (push, milestone attach, cycle assign/clear)
78
+ // share the identical mutation shape.
79
+ const updateIssue = (id, input, io) =>
80
+ gql(`mutation($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { issue { id } } }`, { id, input }, io);
81
+
82
+ // Project drift snapshot, keyed by the mapped project ids only (NOT all team projects). `content`
83
+ // is a heavy rich-text field — fetching it for every project inside the team bundle blows Linear's
84
+ // 10k query-complexity ceiling, so it lives here, batched small, exactly like fetchIssueSnapshot.
85
+ const mappedProjectIds = (graph) => (graph.pis || []).map((p) => p.linear && p.linear.project).filter(Boolean);
86
+ async function fetchProjectSnapshot(ids, io) {
87
+ const projects = {};
88
+ for (let i = 0; i < ids.length; i += 10) {
89
+ const chunk = ids.slice(i, i + 10);
90
+ const q = `query { ${chunk.map((id, j) => `p${j}: project(id: "${id}") { id name description content color icon priority startDate targetDate status { id } }`).join(" ")} }`;
91
+ const data = await gql(q, {}, io);
92
+ chunk.forEach((_, j) => {
93
+ const p = data[`p${j}`];
94
+ if (p) projects[p.id] = { id: p.id, name: p.name, description: p.description || "", content: p.content || "",
95
+ color: p.color || "", icon: p.icon || "", priority: p.priority || 0, startDate: p.startDate || null, targetDate: p.targetDate || null,
96
+ statusId: p.status ? p.status.id : null };
97
+ });
98
+ }
99
+ return projects;
100
+ }
101
+
102
+ // Workspace project-status inventory (live-verified location: organization.projectStatuses).
103
+ // A failure degrades to null — projects simply keep their current Linear status this sync —
104
+ // recorded on the result by the caller, never fatal.
105
+ async function fetchProjectStatuses(io) {
106
+ const data = await gql(`query { organization { projectStatuses { id name type position } } }`, {}, io);
107
+ return [...data.organization.projectStatuses].sort((a, b) => a.position - b.position);
108
+ }
109
+
110
+ // Snapshot of our mapped issues, batched via aliases (identifiers are valid issue(id:) args).
111
+ async function fetchIssueSnapshot(identifiers, io) {
112
+ const issues = {};
113
+ for (let i = 0; i < identifiers.length; i += 50) {
114
+ const chunk = identifiers.slice(i, i + 50);
115
+ const q = `query { ${chunk.map((id, j) => `i${j}: issue(id: "${id}") { id identifier title description priority estimate state { id } project { id } assignee { id } labels { nodes { id } } }`).join(" ")} }`;
116
+ const data = await gql(q, {}, io);
117
+ chunk.forEach((_, j) => {
118
+ const iss = data[`i${j}`];
119
+ if (iss) issues[iss.identifier] = { id: iss.id, title: iss.title, description: iss.description || "", priority: iss.priority, estimate: iss.estimate ?? null, stateId: iss.state.id,
120
+ projectId: iss.project ? iss.project.id : null,
121
+ assigneeId: iss.assignee ? iss.assignee.id : null,
122
+ labelIds: ((iss.labels && iss.labels.nodes) || []).map((l) => l.id) };
123
+ });
124
+ }
125
+ return issues;
126
+ }
127
+
128
+ async function fetchInbound(cfg, since, io) {
129
+ const sources = [{ team: cfg.team, project: null }, ...cfg.watch];
130
+ const out = [];
131
+ for (const src of sources) {
132
+ const filter = { team: { key: { eq: src.team } }, ...(since ? { updatedAt: { gt: since } } : {}), ...(src.project ? { project: { name: { eq: src.project } } } : {}) };
133
+ // ponytail: 100 issues per source per sync — cursor-windowed, so backpressure self-heals next run.
134
+ const data = await gql(
135
+ `query($filter: IssueFilter) { issues(filter: $filter, first: 100) { nodes {
136
+ identifier title priority updatedAt state { name type } team { key } project { name } } } }`,
137
+ { filter }, io);
138
+ for (const n of data.issues.nodes) {
139
+ out.push({ identifier: n.identifier, title: n.title, priority: n.priority,
140
+ state: n.state, team: n.team.key, project: n.project ? n.project.name : null, updatedAt: n.updatedAt });
141
+ }
142
+ }
143
+ return out;
144
+ }
145
+
146
+ // ── the one sync implementation (CLI + MCP both call this) ───────────────────
147
+ export async function runSync(root, opts = {}) {
148
+ const env = opts.env || process.env;
149
+ const graph = loadGraph(roadmapPaths(root).yaml);
150
+ const cfg = normalizeLinearConfig(graph.meta || {});
151
+ const state = linearState({ meta: graph.meta, env, cursor: readCursor(root) });
152
+ if (!state.configured) throw new Error("Linear isn't configured for this roadmap — add meta.linear or run 'roadmap linear setup --team <KEY>'");
153
+ if (!state.authed) throw new Error("Linear is configured but LINEAR_API_KEY isn't set ('roadmap linear auth' explains)");
154
+ const io = { apiKey: env.LINEAR_API_KEY, fetchImpl: opts.fetchImpl || fetch };
155
+ const now = opts.now || new Date().toISOString();
156
+
157
+ const backlog = loadBacklog(root);
158
+ const team = await fetchTeamBundle(cfg.team, io, cfg.cycles === "on");
159
+ const mapped = collectIdentifiers(graph, backlog);
160
+ const existing = { issues: await fetchIssueSnapshot(mapped, io), projects: await fetchProjectSnapshot(mappedProjectIds(graph), io) };
161
+ const docsUrl = repoDocsUrl(root, graph);
162
+
163
+ const result = { pushed: [], proposals: null, cursorAdvanced: false, dry: !!opts.dry };
164
+
165
+ // Project-status inventory — lets the push map PI status → project status. Degrades to null
166
+ // (no status projection) so a workspace/API hiccup can never abort the sync.
167
+ let projectStatuses = null;
168
+ try { projectStatuses = await fetchProjectStatuses(io); }
169
+ catch (e) { result.projectStatusError = e.message; }
170
+
171
+ // ── pull FIRST (live-verified ordering): inbound is read before any push executes, so a
172
+ // human's Linear edit can never be clobbered by the projection while it's still an open
173
+ // proposal — push holds those fields until the proposal is resolved. In auto mode the
174
+ // deltas apply to the YAML here, so the subsequent push naturally agrees with them.
175
+ let holds = new Set();
176
+ let inboxEmpty = true;
177
+ if (!opts.pushOnly && cfg.pull !== "off") {
178
+ const inbound = await fetchInbound(cfg, state.lastSync, io);
179
+ const proposals = buildPullProposals({ cfg, inbound, graph, backlog });
180
+ result.proposals = proposals;
181
+ inboxEmpty = !proposals.newItems.length && !proposals.deltas.length;
182
+ if (!opts.dry && cfg.pull === "auto") {
183
+ for (const item of proposals.newItems) mutateBacklog(root, (doc) => addItem(doc, item), { createIfMissing: true });
184
+ for (const d of proposals.deltas) {
185
+ if (d.to == null) continue; // canceled-slice flags stay human decisions even on auto
186
+ const fields = d.field === "status" ? { status: d.to } : { priority: { ...(currentPriority(root, d) || {}), tier: d.to } };
187
+ if (d.kind === "slice") mutateRoadmap(root, (doc) => setFields(doc, { invoke: d.key, fields }));
188
+ else mutateBacklog(root, (doc) => setItemFields(doc, { id: d.key, fields }));
189
+ }
190
+ result.applied = proposals;
191
+ } else {
192
+ holds = holdsFor(proposals.deltas);
193
+ }
194
+ }
195
+ // Re-read after auto-apply so the push plan reflects the accepted inbound edits.
196
+ let pushGraph = result.applied ? loadGraph(roadmapPaths(root).yaml) : graph;
197
+ const pushBacklog = result.applied ? loadBacklog(root) : backlog;
198
+
199
+ // ── auto-stamp project start dates ── a PI that's active without an explicit start_date gets one (the
200
+ // sync date ≈ when it was picked up), so the Linear roadmap timeline has a start. Explicit wins; stamped
201
+ // once then stable. Write-back BEFORE the push so the new startDate projects this run.
202
+ const toStamp = opts.dry ? [] : startStampTargets(pushGraph); // pure decision (linear-core); IO write below
203
+ if (toStamp.length) {
204
+ const today = now.slice(0, 10);
205
+ mutateRoadmap(root, (doc) => {
206
+ const pis = doc.toJS().pis || [];
207
+ for (const id of toStamp) { const idx = pis.findIndex((p) => p.id === id); if (idx >= 0) doc.setIn(["pis", idx, "start_date"], today); }
208
+ return { startStamped: toStamp.length };
209
+ });
210
+ pushGraph = loadGraph(roadmapPaths(root).yaml);
211
+ result.startStamped = toStamp;
212
+ }
213
+
214
+ // ── plate auto-drain (complete-only) ── a finished slice leaves My Issues. Write-back BEFORE the push
215
+ // so the projection unassigns it. Skipped on dry runs (no writes) and when the feature is off.
216
+ if (!opts.dry && Array.isArray(pushGraph.meta && pushGraph.meta.plate) && pushGraph.meta.plate.length) {
217
+ const drop = plateDrainKeys(pushGraph, pushBacklog);
218
+ if (drop.length) {
219
+ const keep = pushGraph.meta.plate.filter((k) => !drop.includes(k));
220
+ mutateRoadmap(root, (doc) => { setPlateDoc(doc, keep); return { plateDrained: drop.length }; });
221
+ pushGraph = loadGraph(roadmapPaths(root).yaml);
222
+ result.plateDrained = drop;
223
+ }
224
+ }
225
+ // viewer id — only when the plate feature is on; a fetch failure just disables the assignee projection.
226
+ let viewerId = null;
227
+ if (pushGraph.meta && pushGraph.meta.plate != null) {
228
+ try { viewerId = (await gql(`query { viewer { id } }`, {}, io)).viewer.id; } catch { /* no viewer → no assignee ops */ }
229
+ }
230
+
231
+ // ── staleness basis ── committed work (CYCLE_STATUSES) with journal silence past stale_days
232
+ // gets the stale label via the push's ordinary label set-diff. Advisory: a fetch failure is a
233
+ // note, never a failed sync. The set persists to the cursor so the election CLI reads it offline.
234
+ // Push-adjacent, so pull-only skips it — persisting a stale set the push never applied would
235
+ // let the cursor drift from Linear's real label state.
236
+ let stale = new Set();
237
+ if (cfg.stale_days && !opts.pullOnly) {
238
+ try {
239
+ const committed = cycleCandidates(pushGraph).filter((n) => CYCLE_STATUSES.includes(n.status));
240
+ if (committed.length) {
241
+ const activity = await fetchActivityBasis(committed.map((n) => n.linear), io);
242
+ stale = staleKeys({ graph: pushGraph, cfg, activity, now });
243
+ }
244
+ if (stale.size) result.stale = [...stale].sort();
245
+ if (!opts.dry) writeCursor(root, { stale: [...stale].sort() });
246
+ } catch (e) { result.staleError = e.message; }
247
+ }
248
+
249
+ // ── push ──
250
+ if (!opts.pullOnly) {
251
+ const { ops, missingLabels, unmatchedPlate } = buildPushPlan({ graph: pushGraph, backlog: pushBacklog, cfg, teamStates: team.states, existing, docsUrl, holds, labels: team.labels, viewerId, projectStatuses, now, stale });
252
+ if (missingLabels.length) result.missingLabels = missingLabels;
253
+ if (unmatchedPlate && unmatchedPlate.length) result.unmatchedPlate = unmatchedPlate;
254
+ if (opts.dry) {
255
+ result.pushPlan = ops;
256
+ } else if (ops.length) {
257
+ const projectIds = projectIdsByPi(pushGraph);
258
+ const writeBacks = { pis: [], sprints: [], items: [] };
259
+ // Unverified-VALUE fields (project icon names, issue completedAt) would abort the whole
260
+ // push on rejection. Shared degrade: retry ONCE without that one field, else rethrow.
261
+ // Icon self-heals on a later sync (it's re-diffed); completedAt is create-only, so its
262
+ // degrade is one-way — the issue stays Done-at-creation-time (acceptable: it's history).
263
+ const isIconErr = (e) => /icon|argument validation/i.test(e.message || "");
264
+ const isCompletedAtErr = (e) => /completedAt|argument validation/i.test(e.message || "");
265
+ // Linear stores issue descriptions as DocumentContent and can refuse the write with
266
+ // "conflict on insert of DocumentContent" (live-caught 2026-07-10 under concurrent
267
+ // syncs). The conflict pins to one issue but aborts every op queued behind it.
268
+ // Description is re-diffed on the next sync, so stripping it self-heals like icon.
269
+ const isDocContentErr = (e) => /DocumentContent/i.test(e.message || "");
270
+ const withFieldStripRetry = async (mk, payload, field, isRetryable) => {
271
+ try { return await mk(payload); }
272
+ catch (e) {
273
+ if (payload[field] == null || !isRetryable(e)) throw e;
274
+ const { [field]: _stripped, ...rest } = payload;
275
+ return mk(rest);
276
+ }
277
+ };
278
+ // finally-flush: if an op throws mid-push, everything Linear already created still gets
279
+ // its id written back — otherwise the next sync would create duplicates for those nodes.
280
+ try {
281
+ for (const op of ops) {
282
+ if (op.op === "createProject") {
283
+ const mk = (payload) => gql(`mutation($input: ProjectCreateInput!) { projectCreate(input: $input) { project { id } } }`,
284
+ { input: { ...payload, teamIds: [team.id] } }, io); // spread: dropping fields here caused live churn (description)
285
+ const d = await withFieldStripRetry(mk, op.payload, "icon", isIconErr);
286
+ projectIds[op.projectRef] = d.projectCreate.project.id;
287
+ writeBacks.pis.push({ pi: op.writeBack.pi, project: d.projectCreate.project.id });
288
+ } else if (op.op === "updateProject") {
289
+ const mk = (payload) => gql(`mutation($id: String!, $input: ProjectUpdateInput!) { projectUpdate(id: $id, input: $input) { project { id } } }`,
290
+ { id: op.id, input: payload }, io);
291
+ await withFieldStripRetry(mk, op.payload, "icon", isIconErr);
292
+ } else if (op.op === "createIssue") {
293
+ const input = { teamId: team.id, ...op.payload, ...(op.projectRef && projectIds[op.projectRef] ? { projectId: projectIds[op.projectRef] } : {}) };
294
+ const mkIssue = (inp) => gql(`mutation($input: IssueCreateInput!) { issueCreate(input: $input) { issue { id identifier } } }`,
295
+ { input: inp }, io);
296
+ const d = await withFieldStripRetry(mkIssue, input, "completedAt", isCompletedAtErr);
297
+ const identifier = d.issueCreate.issue.identifier;
298
+ if (op.writeBack.kind === "sprint") writeBacks.sprints.push({ invoke: op.writeBack.invoke, identifier });
299
+ else writeBacks.items.push({ id: op.writeBack.id, identifier });
300
+ } else if (op.op === "updateIssue") {
301
+ await withFieldStripRetry((payload) => updateIssue(op.id, payload, io), op.payload, "description", isDocContentErr);
302
+ }
303
+ result.pushed.push(`${op.op}${op.identifier ? ` ${op.identifier}` : op.writeBack ? ` ${op.writeBack.invoke || op.writeBack.id || op.writeBack.pi}` : ""}`);
304
+ }
305
+ } finally {
306
+ // one write-back batch per file, through the store's validated path
307
+ if (writeBacks.pis.length || writeBacks.sprints.length) {
308
+ mutateRoadmap(root, (doc) => {
309
+ for (const wb of writeBacks.pis) {
310
+ const idx = doc.toJS().pis.findIndex((p) => p.id === wb.pi);
311
+ doc.setIn(["pis", idx, "linear", "project"], wb.project);
312
+ }
313
+ for (const wb of writeBacks.sprints) setFields(doc, { invoke: wb.invoke, fields: { linear: wb.identifier } });
314
+ return { writeBack: writeBacks.pis.length + writeBacks.sprints.length };
315
+ });
316
+ }
317
+ if (writeBacks.items.length) {
318
+ mutateBacklog(root, (doc) => {
319
+ for (const wb of writeBacks.items) setItemFields(doc, { id: wb.id, fields: { linear: wb.identifier } });
320
+ return { writeBack: writeBacks.items.length };
321
+ });
322
+ }
323
+ }
324
+ }
325
+ }
326
+
327
+ // ── initiatives ── group projects under their declared Linear initiatives. Runs AFTER the
328
+ // push write-backs so the project ids exist. Best-effort + graceful: a failure (the
329
+ // initiative API is not yet live-verified) records a note and never fails the sync.
330
+ if (!opts.dry && !opts.pullOnly) {
331
+ try { const ir = await syncInitiatives(root, io); if (ir.initiatives.length) result.initiatives = ir; }
332
+ catch (e) { result.initiativesError = e.message; }
333
+ // milestones run AFTER initiatives (both post-push): the project ids exist and issues are mapped.
334
+ try { const mr = await syncMilestones(root, io); if (mr.milestones.length) result.milestones = mr; }
335
+ catch (e) { result.milestonesError = e.message; }
336
+ // cycles run last: the team's ACTIVE cycle mirrors active+next slice status (the elected batch).
337
+ if (cfg.cycles === "on") {
338
+ if (!team.activeCycleId) result.cyclesNote = "cycles on but the team has no active cycle in Linear — check Team Settings → Cycles";
339
+ else {
340
+ try { const cr = await syncCycles(root, io, team.activeCycleId); if (cr.assigned.length || cr.cleared.length) result.cycles = cr; }
341
+ catch (e) { result.cyclesError = e.message; }
342
+ }
343
+ }
344
+ }
345
+
346
+ // ── cursor ── advance only when the inbox is handled (auto) or empty — in propose mode a
347
+ // non-empty inbox stays in the window so unhandled proposals reappear rather than vanish.
348
+ if (!opts.dry && !opts.pushOnly) {
349
+ if (cfg.pull === "off" || cfg.pull === "auto" || inboxEmpty) { writeCursor(root, { lastSync: now }); result.cursorAdvanced = true; }
350
+ }
351
+ return result;
352
+ }
353
+
354
+ // Ensure each declared initiative exists in Linear, carries its meta.initiatives icon/color, and each
355
+ // mapped PI's project is attached. UNVERIFIED API (initiativeCreate/Update/ToProjectCreate) — the caller
356
+ // catches and degrades. Idempotent: skips existing initiatives, re-applies style only on drift (the fetch
357
+ // reads current icon/color back), and skips already-attached projects.
358
+ export async function syncInitiatives(root, io) {
359
+ const graph = loadGraph(roadmapPaths(root).yaml);
360
+ const plan = initiativePlan(graph);
361
+ if (!plan.initiatives.length) return { initiatives: [] };
362
+ const data = await gql(`query { initiatives(first: 250) { nodes { id name icon color projects { nodes { id } } } } }`, {}, io);
363
+ const byName = new Map((data.initiatives.nodes || []).map((i) => [i.name, i]));
364
+ // icon/color are newly-exercised initiative input — degrade like project icons do: a bad name or an
365
+ // unsupported field drops the initiative to unstyled instead of aborting the whole initiative sync.
366
+ const isStyleErr = (e) => /icon|color|argument validation/i.test(e.message || "");
367
+ const created = [], styled = [];
368
+ for (const name of plan.initiatives) {
369
+ const style = initiativeStyle(graph.meta, name);
370
+ if (!byName.has(name)) {
371
+ const full = { name, ...(style.icon ? { icon: style.icon } : {}), ...(style.color ? { color: style.color } : {}) };
372
+ const mk = (input) => gql(`mutation($input: InitiativeCreateInput!) { initiativeCreate(input: $input) { initiative { id name } } }`, { input }, io);
373
+ let d;
374
+ try { d = await mk(full); if (full.icon || full.color) styled.push(name); }
375
+ catch (e) { if ((full.icon || full.color) && isStyleErr(e)) d = await mk({ name }); else throw e; }
376
+ byName.set(name, { ...d.initiativeCreate.initiative, projects: { nodes: [] } });
377
+ created.push(name);
378
+ continue;
379
+ }
380
+ // existing → apply declared style only when it drifts (idempotent via the fetched icon/color)
381
+ const cur = byName.get(name);
382
+ const patch = {};
383
+ if (style.icon && cur.icon !== style.icon) patch.icon = style.icon;
384
+ if (style.color && (cur.color || "") !== style.color) patch.color = style.color;
385
+ if (Object.keys(patch).length) {
386
+ try {
387
+ await gql(`mutation($id: String!, $input: InitiativeUpdateInput!) { initiativeUpdate(id: $id, input: $input) { initiative { id } } }`, { id: cur.id, input: patch }, io);
388
+ styled.push(name);
389
+ } catch (e) { if (!isStyleErr(e)) throw e; } // best-effort: unsupported update → leave unstyled
390
+ }
391
+ }
392
+ const attached = [];
393
+ for (const a of plan.assignments) {
394
+ const pi = (graph.pis || []).find((p) => p.id === a.pi);
395
+ const projectId = pi && pi.linear && pi.linear.project;
396
+ if (!projectId) continue; // PI has no project (empty/skipped) — nothing to group
397
+ const init = byName.get(a.initiative);
398
+ if (((init.projects && init.projects.nodes) || []).some((p) => p.id === projectId)) continue; // already attached
399
+ await gql(`mutation($input: InitiativeToProjectCreateInput!) { initiativeToProjectCreate(input: $input) { success } }`, { input: { initiativeId: init.id, projectId } }, io);
400
+ (init.projects || (init.projects = { nodes: [] })).nodes.push({ id: projectId }); // local dedupe within this run
401
+ attached.push(`${a.pi} → ${a.initiative}`);
402
+ }
403
+ return { initiatives: plan.initiatives, created, styled, attached };
404
+ }
405
+
406
+ // Mapped issues' current milestone, batched (identifier → { id: uuid, milestoneId }) — like fetchIssueSnapshot.
407
+ async function fetchIssueMilestones(identifiers, io) {
408
+ const out = {};
409
+ for (let i = 0; i < identifiers.length; i += 50) {
410
+ const chunk = identifiers.slice(i, i + 50);
411
+ const q = `query { ${chunk.map((id, j) => `i${j}: issue(id: "${id}") { id identifier projectMilestone { id } }`).join(" ")} }`;
412
+ const data = await gql(q, {}, io);
413
+ chunk.forEach((_, j) => { const iss = data[`i${j}`]; if (iss) out[iss.identifier] = { id: iss.id, milestoneId: iss.projectMilestone ? iss.projectMilestone.id : null }; });
414
+ }
415
+ return out;
416
+ }
417
+
418
+ // Ensure each PI's declared milestones (sp.milestone) exist on its Linear project and each mapped issue is
419
+ // attached to its milestone. Mirrors syncInitiatives one level down (issues within a project). UNVERIFIED
420
+ // API (projectMilestoneCreate) — the caller catches and degrades. Idempotent: skips existing milestones by
421
+ // name, and re-attaches an issue only when its projectMilestoneId drifts from the target.
422
+ export async function syncMilestones(root, io) {
423
+ const graph = loadGraph(roadmapPaths(root).yaml);
424
+ const plan = milestonePlan(graph);
425
+ if (!plan.pis.length) return { milestones: [] };
426
+ const milestones = [], created = [], attached = [];
427
+ for (const p of plan.pis) {
428
+ const pi = (graph.pis || []).find((x) => x.id === p.pi);
429
+ const projectId = pi && pi.linear && pi.linear.project;
430
+ if (!projectId) continue; // PI has no project — nothing to attach milestones to
431
+ const data = await gql(`query { project(id: "${projectId}") { projectMilestones { nodes { id name } } } }`, {}, io);
432
+ const byName = new Map(((data.project && data.project.projectMilestones && data.project.projectMilestones.nodes) || []).map((m) => [m.name, m.id]));
433
+ for (const name of p.milestones) {
434
+ milestones.push(`${p.pi}/${name}`);
435
+ if (byName.has(name)) continue;
436
+ // sortOrder = current milestone count (append after existing), NOT the plan-local index — else a
437
+ // milestone added between two existing ones on a later run would collide with an existing sortOrder.
438
+ const d = await gql(`mutation($input: ProjectMilestoneCreateInput!) { projectMilestoneCreate(input: $input) { projectMilestone { id } } }`,
439
+ { input: { projectId, name, sortOrder: byName.size } }, io);
440
+ byName.set(name, d.projectMilestoneCreate.projectMilestone.id);
441
+ created.push(`${p.pi}/${name}`);
442
+ }
443
+ const mapped = p.slices.filter((s) => s.linear);
444
+ if (!mapped.length) continue;
445
+ const cur = await fetchIssueMilestones(mapped.map((s) => s.linear), io);
446
+ for (const s of mapped) {
447
+ const targetId = byName.get(s.milestone);
448
+ const c = cur[s.linear];
449
+ if (!c || !targetId || c.milestoneId === targetId) continue; // unmapped-in-snapshot or already attached
450
+ await updateIssue(c.id, { projectMilestoneId: targetId }, io);
451
+ attached.push(`${s.invoke} → ${s.milestone}`);
452
+ }
453
+ }
454
+ return { milestones, created, attached };
455
+ }
456
+
457
+ // Mapped issues' current cycle, batched (identifier → { id: uuid, cycleId }) — like fetchIssueMilestones.
458
+ async function fetchIssueCycles(identifiers, io) {
459
+ const out = {};
460
+ for (let i = 0; i < identifiers.length; i += 50) {
461
+ const chunk = identifiers.slice(i, i + 50);
462
+ const q = `query { ${chunk.map((id, j) => `i${j}: issue(id: "${id}") { id identifier cycle { id } }`).join(" ")} }`;
463
+ const data = await gql(q, {}, io);
464
+ chunk.forEach((_, j) => { const iss = data[`i${j}`]; if (iss) out[iss.identifier] = { id: iss.id, cycleId: iss.cycle ? iss.cycle.id : null }; });
465
+ }
466
+ return out;
467
+ }
468
+
469
+ // Activity basis for staleness: the newest journal comment's createdAt (max over first 50 —
470
+ // no ordering assumption; same window the journal read uses), falling back to the issue's own
471
+ // createdAt. NEVER issue updatedAt — our own pushes bump that (the stale label would flap).
472
+ async function fetchActivityBasis(identifiers, io) {
473
+ const out = {};
474
+ for (let i = 0; i < identifiers.length; i += 50) {
475
+ const chunk = identifiers.slice(i, i + 50);
476
+ const q = `query { ${chunk.map((id, j) => `i${j}: issue(id: "${id}") { identifier createdAt comments(first: 50) { nodes { createdAt } } }`).join(" ")} }`;
477
+ const data = await gql(q, {}, io);
478
+ chunk.forEach((_, j) => {
479
+ const iss = data[`i${j}`];
480
+ if (!iss) return;
481
+ const stamps = ((iss.comments && iss.comments.nodes) || []).map((c) => c.createdAt).filter(Boolean);
482
+ out[iss.identifier] = stamps.length ? stamps.sort()[stamps.length - 1] : iss.createdAt;
483
+ });
484
+ }
485
+ return out;
486
+ }
487
+
488
+ // Keep the team's ACTIVE cycle = the projection of active+next (CYCLE_STATUSES). Runs post-push,
489
+ // so issues created this run (fresh write-backs) get their cycle in the same sync. UNVERIFIED
490
+ // mutation surface (issueUpdate cycleId) — the caller catches and degrades like initiatives/
491
+ // milestones. Idempotent: assign only on drift; clear only CURRENT-cycle membership (a human's
492
+ // future-cycle parking is deliberate planning and stays untouched — cyclePlan's contract).
493
+ export async function syncCycles(root, io, activeCycleId) {
494
+ const graph = loadGraph(roadmapPaths(root).yaml);
495
+ const candidates = cycleCandidates(graph);
496
+ if (!candidates.length) return { assigned: [], cleared: [] };
497
+ const issues = await fetchIssueCycles(candidates.map((n) => n.linear), io);
498
+ const plan = cyclePlan({ graph, activeCycleId, issues });
499
+ const assigned = [], cleared = [];
500
+ const ops = [...plan.assign.map((a) => ({ ...a, cycleId: activeCycleId })), ...plan.clear.map((c) => ({ ...c, cycleId: null }))];
501
+ for (const o of ops) {
502
+ if (!o.id) continue; // mapped but not in Linear (deleted?) — leave for the human
503
+ await updateIssue(o.id, { cycleId: o.cycleId }, io);
504
+ (o.cycleId === null ? cleared : assigned).push(o.invoke);
505
+ }
506
+ return { assigned, cleared };
507
+ }
508
+
509
+ // ── dispatch transport (the only-network-file rule: dispatch.mjs owns the capsule, this
510
+ // owns the wire) — resolve the issue uuid and post the @-mention comment.
511
+ export async function postDispatchComment(identifier, body, io) {
512
+ const d = await gql(`query { issue(id: "${identifier}") { id identifier } }`, {}, io);
513
+ if (!d.issue) throw new Error(`mapped issue ${identifier} not found in Linear (deleted?)`);
514
+ await gql(`mutation($input: CommentCreateInput!) { commentCreate(input: $input) { comment { id } } }`,
515
+ { input: { issueId: d.issue.id, body } }, io);
516
+ }
517
+
518
+ // ── the journal (progress notes on the mapped issue) ──────────────────────────
519
+ // A slice invoke key / backlog id → its mapped Linear issue identifier (null when unmapped). Mirrors
520
+ // runDispatch's resolver; used by note/notes so an agent can journal against the work it's picking up.
521
+ function resolveMapped(graph, root, key) {
522
+ const node = flatten(graph).nodes.find((n) => n.invoke === key);
523
+ if (node) return { identifier: node.linear };
524
+ const it = ((loadBacklog(root) || {}).items || []).find((i) => i.id === key);
525
+ if (it) return { identifier: it.linear };
526
+ return null;
527
+ }
528
+ // Load the graph + build the API io, gating on the SAME configured+authed check runSync/runProvision use
529
+ // (one auth-state source, one error wording). Returns { graph, io }.
530
+ function journalContext(root, opts) {
531
+ const env = opts.env || process.env;
532
+ const graph = loadGraph(roadmapPaths(root).yaml);
533
+ const st = linearState({ meta: graph.meta, env });
534
+ if (!st.configured || !st.authed) throw new Error(linearStatusLine(st));
535
+ return { graph, io: { apiKey: env.LINEAR_API_KEY, fetchImpl: opts.fetchImpl || fetch } };
536
+ }
537
+
538
+ // Post a progress note to a slice/backlog item's mapped issue. Reuses postDispatchComment's transport.
539
+ // Unknown key → error; a known-but-UNMAPPED slice → soft-skip (best-effort; matches the auto-hook, and
540
+ // keeps journaling frictionless for a worker whose slice just isn't on the tracker).
541
+ export async function runNote(root, key, { kind, text }, opts = {}) {
542
+ const { graph, io } = journalContext(root, opts);
543
+ const m = resolveMapped(graph, root, key);
544
+ if (!m) throw new Error(`no slice or backlog item "${key}"`);
545
+ if (!m.identifier) return { key, skipped: "unmapped" };
546
+ await postDispatchComment(m.identifier, noteBody({ kind, text }), io);
547
+ return { key, identifier: m.identifier };
548
+ }
549
+
550
+ // Read the mapped issue's comment stream (chronological), so a resuming agent sees where it left off.
551
+ export async function runNotes(root, key, opts = {}) {
552
+ const { graph, io } = journalContext(root, opts);
553
+ const m = resolveMapped(graph, root, key);
554
+ if (!m) throw new Error(`no slice or backlog item "${key}"`);
555
+ if (!m.identifier) return { key, skipped: "unmapped", notes: [] };
556
+ const d = await gql(`query { issue(id: "${m.identifier}") { comments(first: 50) { nodes { body createdAt user { name } } } } }`, {}, io);
557
+ const nodes = (d.issue && d.issue.comments && d.issue.comments.nodes) || [];
558
+ return { identifier: m.identifier, notes: nodes.map((n) => ({ author: n.user ? n.user.name : "?", createdAt: n.createdAt, body: n.body })) };
559
+ }
560
+
561
+ // PI digest → a Linear project update (the "where this bet stands" rollup). Degradation-guarded: the
562
+ // projectUpdateCreate shape is unverified, so a rejection is a skip, not a failure.
563
+ export async function runProjectUpdate(root, pi, body, opts = {}) {
564
+ const { graph, io } = journalContext(root, opts);
565
+ const piObj = (graph.pis || []).find((p) => p.id === pi);
566
+ if (!piObj || !piObj.linear || !piObj.linear.project) throw new Error(`PI "${pi}" has no linear.project mapping — push first ('roadmap linear sync')`);
567
+ try {
568
+ await gql(`mutation($input: ProjectUpdateCreateInput!) { projectUpdateCreate(input: $input) { projectUpdate { id } } }`,
569
+ { input: { projectId: piObj.linear.project, body } }, io);
570
+ return { pi, posted: true };
571
+ } catch (e) {
572
+ return { pi, posted: false, error: e.message };
573
+ }
574
+ }
575
+
576
+ // ── provision: shape the workspace (idempotent) ───────────────────────────────
577
+ export async function runProvision(root, opts = {}) {
578
+ const env = opts.env || process.env;
579
+ const graph = loadGraph(roadmapPaths(root).yaml);
580
+ const state = linearState({ meta: graph.meta, env });
581
+ if (!state.configured || !state.authed) throw new Error(linearStatusLine(state));
582
+ const io = { apiKey: env.LINEAR_API_KEY, fetchImpl: opts.fetchImpl || fetch };
583
+ const team = await fetchTeamBundle(state.cfg.team, io);
584
+
585
+ const plan = provisionPlan({ graph, teamLabels: team.labels, cfg: state.cfg });
586
+ const result = { labelsCreated: [], labelsExisting: plan.existingLabels, views: [], viewChecklist: null };
587
+
588
+ for (const name of plan.createLabels) {
589
+ try {
590
+ await gql(`mutation($input: IssueLabelCreateInput!) { issueLabelCreate(input: $input) { issueLabel { id name } } }`,
591
+ { input: { name, teamId: team.id } }, io);
592
+ result.labelsCreated.push(name);
593
+ } catch (e) {
594
+ if (/already exists|duplicate/i.test(e.message)) result.labelsExisting.push(name); // creation race
595
+ else throw e;
596
+ }
597
+ }
598
+
599
+ // Views (live-verified mutation). Idempotency: skip names that already exist — a re-run
600
+ // must not duplicate the board (live-caught failure mode). Any rejection degrades to the
601
+ // manual checklist (the designed fallback, not a failure).
602
+ let existingViews = new Set();
603
+ try {
604
+ const vd = await gql(`query { customViews(first: 100) { nodes { id name } } }`, {}, io);
605
+ existingViews = new Set(vd.customViews.nodes.map((v) => v.name));
606
+ } catch { /* view listing unavailable → fall through to create-and-let-it-ride */ }
607
+ for (const v of plan.views) {
608
+ if (existingViews.has(v.name)) { result.viewsExisting = [...(result.viewsExisting || []), v.name]; continue; }
609
+ try {
610
+ await gql(`mutation($input: CustomViewCreateInput!) { customViewCreate(input: $input) { customView { id } } }`,
611
+ { input: { name: v.name, teamId: team.id, description: v.hint } }, io);
612
+ result.views.push(v.name);
613
+ } catch (e) {
614
+ result.viewChecklist = { rejected: e.message, checklist: manualViewChecklist(plan.views.filter((x) => !result.views.includes(x.name) && !existingViews.has(x.name))) };
615
+ break;
616
+ }
617
+ }
618
+ return result;
619
+ }
620
+
621
+ function collectIdentifiers(graph, backlog) {
622
+ const ids = [];
623
+ for (const pi of graph.pis || []) for (const sp of pi.sprints || []) if (sp.linear) ids.push(sp.linear);
624
+ for (const it of (backlog && backlog.items) || []) if (it.linear) ids.push(it.linear);
625
+ return ids;
626
+ }
627
+ function projectIdsByPi(graph) {
628
+ const out = {};
629
+ for (const pi of graph.pis || []) if (pi.linear && pi.linear.project) out[pi.id] = pi.linear.project;
630
+ return out;
631
+ }
632
+ function currentPriority(root, d) {
633
+ if (d.kind === "slice") {
634
+ for (const pi of loadGraph(roadmapPaths(root).yaml).pis || []) for (const sp of pi.sprints || []) if (sp.invoke === d.key) return sp.priority;
635
+ } else {
636
+ const bl = loadBacklog(root);
637
+ for (const it of (bl && bl.items) || []) if (it.id === d.key) return it.priority;
638
+ }
639
+ return null;
640
+ }
641
+ // https://github.com/<owner>/<repo>/blob/<base_branch> — the docs-link base for issue
642
+ // footers. Unknown/non-GitHub remote → null → footers fall back to the relative path.
643
+ export function repoDocsUrl(root, graph) {
644
+ try {
645
+ const r = spawnSync("git", ["remote", "get-url", (graph.meta && graph.meta.remote) || "origin"], { cwd: root, encoding: "utf8" });
646
+ if (r.status !== 0) return null;
647
+ const m = /github\.com[:/]([^/]+)\/([^/\s]+?)(?:\.git)?\s*$/.exec(r.stdout);
648
+ if (!m) return null;
649
+ return `https://github.com/${m[1]}/${m[2]}/blob/${(graph.meta && graph.meta.base_branch) || "main"}`;
650
+ } catch { return null; }
651
+ }
652
+
653
+ // ── CLI ───────────────────────────────────────────────────────────────────────
654
+ const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
655
+ if (isMain) {
656
+ const args = process.argv.slice(2);
657
+ const sub = args[0] && !args[0].startsWith("-") ? args[0] : "status";
658
+ const has = (n) => args.includes(n);
659
+ const val = (n) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : undefined; };
660
+ const root = process.cwd();
661
+
662
+ try {
663
+ if (sub === "status") {
664
+ const graph = loadGraph(roadmapPaths(root).yaml);
665
+ const st = linearState({ meta: graph.meta, env: process.env, cursor: readCursor(root) });
666
+ if (has("--json")) { console.log(JSON.stringify({ configured: st.configured, authed: st.authed, lastSync: st.lastSync })); process.exit(0); }
667
+ if (!st.configured) { console.log("Linear: not configured (add meta.linear or 'roadmap linear setup --team <KEY>')."); process.exit(0); }
668
+ if (!st.authed) { console.log(`Linear: configured (team ${st.cfg.team}) but unauthed — set LINEAR_API_KEY ('roadmap linear auth' explains).`); process.exit(0); }
669
+ console.log(`Linear: wired (team ${st.cfg.team} · granularity ${st.cfg.granularity} · pull ${st.cfg.pull} · last sync ${st.lastSync || "never"}).`);
670
+ if (has("--probe")) {
671
+ const d = await gql(`query { viewer { name email } }`, {}, { apiKey: process.env.LINEAR_API_KEY });
672
+ console.log(` probe ok — authed as ${d.viewer.name}`);
673
+ }
674
+ } else if (sub === "auth") {
675
+ console.log([
676
+ "Linear auth uses a personal API key in the LINEAR_API_KEY env var — never stored in any file.",
677
+ " 1. Linear → Settings → Security & access → Personal API keys → create one.",
678
+ " 2. PowerShell: [Environment]::SetEnvironmentVariable('LINEAR_API_KEY','<key>','User')",
679
+ " bash/zsh: echo 'export LINEAR_API_KEY=<key>' >> ~/.bashrc",
680
+ " 3. New shell, then: roadmap linear status --probe",
681
+ ].join("\n"));
682
+ } else if (sub === "setup") {
683
+ const key = process.env.LINEAR_API_KEY;
684
+ if (!key) { console.error("✗ setup needs auth first — 'roadmap linear auth' explains."); process.exit(1); }
685
+ const teamKey = val("--team");
686
+ if (!teamKey) {
687
+ const d = await gql(`query { teams(first: 50) { nodes { key name } } }`, {}, { apiKey: key });
688
+ console.log("Your Linear teams:");
689
+ for (const t of d.teams.nodes) console.log(` ${t.key} ${t.name}`);
690
+ console.log(`\nPick the push target: roadmap linear setup --team <KEY> [--granularity pis|slices|slices+backlog] [--pull off|propose|auto]`);
691
+ process.exit(0);
692
+ }
693
+ await fetchTeamBundle(teamKey, { apiKey: key }); // validates the key exists before writing
694
+ const cfg = { team: teamKey, granularity: val("--granularity") || "slices", pull: val("--pull") || "propose" };
695
+ mutateRoadmap(root, (doc) => { doc.setIn(["meta", "linear"], doc.createNode(cfg)); return { setup: teamKey }; });
696
+ console.log(`✓ meta.linear written (team ${teamKey}, granularity ${cfg.granularity}, pull ${cfg.pull}).`);
697
+ console.log(` Add to .gitignore: ${CURSOR_FILE}`);
698
+ console.log(` Optional: branch_convention: "{pi}/{linear}-{sprint}" makes Linear auto-link fanout PRs.`);
699
+ console.log(` Then: roadmap linear sync --dry`);
700
+ } else if (sub === "provision") {
701
+ const r = await runProvision(root);
702
+ console.log(`labels: ${r.labelsCreated.length ? `created ${r.labelsCreated.join(", ")}` : "all present"}${r.labelsExisting.length ? ` (existing: ${r.labelsExisting.join(", ")})` : ""}`);
703
+ if (r.views.length) console.log(`views created: ${r.views.join(", ")}`);
704
+ if (r.viewsExisting && r.viewsExisting.length) console.log(`views already present: ${r.viewsExisting.join(", ")}`);
705
+ if (r.viewChecklist) {
706
+ console.log(`customViewCreate rejected (${r.viewChecklist.rejected}) — pending live verification; manual checklist (~60s in Linear):`);
707
+ console.log(r.viewChecklist.checklist);
708
+ }
709
+ console.log(`\n── Workspace agent guidance (paste into Linear's agent-guidance setting) ──\n${agentGuidanceText()}`);
710
+ console.log(`\n── Repo dispatch guidance (paste into CLAUDE.md, AGENTS.md, or a skills.md your dispatch agents read) ──\n${dispatchGuidance()}`);
711
+ } else if (sub === "note") {
712
+ const rest = args.slice(1);
713
+ const positional = [];
714
+ for (let i = 0; i < rest.length; i++) { if (rest[i] === "--kind") { i++; continue; } if (!rest[i].startsWith("-")) positional.push(rest[i]); }
715
+ const [key, text] = positional;
716
+ if (!key || !text) { console.error(`usage: roadmap linear note <slice-or-id> "<text>" [--kind progress|blocker|done]`); process.exit(2); }
717
+ const r = await runNote(root, key, { kind: val("--kind"), text }, {});
718
+ console.log(r.skipped ? `- ${key} isn't tracker-mapped yet — note skipped (run 'roadmap linear sync' to map it).` : `✓ note posted to ${r.identifier} (${key}).`);
719
+ } else if (sub === "notes") {
720
+ const key = args.slice(1).find((a) => !a.startsWith("-"));
721
+ if (!key) { console.error("usage: roadmap linear notes <slice-or-id>"); process.exit(2); }
722
+ const r = await runNotes(root, key, {});
723
+ if (r.skipped) { console.log(`${key} isn't tracker-mapped yet — no notes ('roadmap linear sync' to map it).`); process.exit(0); }
724
+ console.log(`Notes on ${r.identifier} (${key}) — ${r.notes.length}:`);
725
+ if (!r.notes.length) console.log(" (none yet)");
726
+ for (const n of r.notes) console.log(` • ${n.createdAt ? n.createdAt.slice(0, 16).replace("T", " ") : "?"} ${n.author}: ${n.body.replace(/\s+/g, " ").trim().slice(0, 160)}`);
727
+ } else if (sub === "post-update") {
728
+ const pi = val("--pi");
729
+ const bodyArg = val("--body");
730
+ if (!pi || !bodyArg) { console.error("usage: roadmap linear post-update --pi <id> --body <text|@file>"); process.exit(2); }
731
+ const body = bodyArg.startsWith("@") ? readFileSync(bodyArg.slice(1), "utf8") : bodyArg;
732
+ const r = await runProjectUpdate(root, pi, body, {});
733
+ console.log(r.posted ? `✓ posted a project update on ${pi}.` : `projectUpdateCreate rejected (${r.error}) — digest not posted; pending live verification.`);
734
+ } else if (sub === "sync") {
735
+ const r = await runSync(root, { dry: has("--dry"), pushOnly: has("--push-only"), pullOnly: has("--pull-only") });
736
+ if (r.pushPlan) {
737
+ console.log(`push plan (${r.pushPlan.length} op(s), dry):`);
738
+ for (const op of r.pushPlan) console.log(` ${op.op} ${op.identifier || (op.writeBack && (op.writeBack.invoke || op.writeBack.id || op.writeBack.pi)) || ""}${op.payload && op.payload.title ? ` — ${op.payload.title}` : ""}`);
739
+ } else if (r.pushed.length) {
740
+ console.log(`pushed ${r.pushed.length} op(s): ${r.pushed.join(", ")}`);
741
+ } else {
742
+ console.log("push: nothing to do (in sync).");
743
+ }
744
+ if (r.proposals) {
745
+ const { newItems, deltas } = r.proposals;
746
+ if (!newItems.length && !deltas.length) console.log("pull: inbox empty.");
747
+ else if (r.applied) console.log(`pull (auto): captured ${newItems.length} item(s), applied ${deltas.filter((d) => d.to != null).length} delta(s).`);
748
+ else {
749
+ console.log(`pull inbox (${newItems.length} new, ${deltas.length} delta(s)) — proposals only, nothing applied:`);
750
+ for (const it of newItems) console.log(` + ${it.id} (${it.kind}${it.priority ? ` · ${it.priority.tier}` : ""}) — ${it.title} [from ${it.source.linear.team}${it.source.linear.project ? `/${it.source.linear.project}` : ""}]`);
751
+ for (const d of deltas) console.log(` ~ ${d.kind} ${d.key}: ${d.field} ${d.from} → ${d.to ?? `(${d.note})`}`);
752
+ console.log(` Apply keeps via /sync (it walks this inbox), backlog_add/set tools, or 'roadmap backlog add/set'.`);
753
+ }
754
+ }
755
+ if (r.missingLabels && r.missingLabels.length) {
756
+ console.log(`labels missing in team: ${r.missingLabels.join(", ")} — run 'roadmap linear provision' to create them.`);
757
+ }
758
+ if (r.initiatives) console.log(`initiatives: ${r.initiatives.initiatives.length} grouped${r.initiatives.created.length ? ` (created ${r.initiatives.created.join(", ")})` : ""}${r.initiatives.styled && r.initiatives.styled.length ? ` · styled ${r.initiatives.styled.length}` : ""}${r.initiatives.attached.length ? ` · attached ${r.initiatives.attached.length} project(s)` : ""}`);
759
+ if (r.initiativesError) console.log(`initiatives skipped: ${r.initiativesError} — pending live verification of the initiative API.`);
760
+ if (r.projectStatusError) console.log(`project status skipped: ${r.projectStatusError} — projects keep their current Linear status this sync.`);
761
+ if (r.milestones) console.log(`milestones: ${r.milestones.milestones.length} across projects${r.milestones.created.length ? ` (created ${r.milestones.created.length})` : ""}${r.milestones.attached.length ? ` · attached ${r.milestones.attached.length} issue(s)` : ""}`);
762
+ if (r.milestonesError) console.log(`milestones skipped: ${r.milestonesError} — pending live verification of the projectMilestone API.`);
763
+ if (r.cycles) console.log(`cycle: assigned ${r.cycles.assigned.join(", ") || "none"}${r.cycles.cleared.length ? ` · cleared ${r.cycles.cleared.join(", ")}` : ""} — the active cycle mirrors active+next.`);
764
+ if (r.cyclesError) console.log(`cycles skipped: ${r.cyclesError} — pending live verification of the cycle API.`);
765
+ if (r.cyclesNote) console.log(`cycles: ${r.cyclesNote}`);
766
+ if (r.stale && r.stale.length) console.log(`stale: ${r.stale.join(", ")} — committed work past stale_days with no journal note; the election reviews these first.`);
767
+ if (r.staleError) console.log(`staleness skipped: ${r.staleError} — advisory only, the push was unaffected.`);
768
+ if (r.startStamped && r.startStamped.length) console.log(`start dates: stamped ${r.startStamped.length} active PI(s) (${r.startStamped.join(", ")}) — the Linear timeline now has a start.`);
769
+ if (r.plateDrained && r.plateDrained.length) console.log(`plate: drained ${r.plateDrained.length} completed (${r.plateDrained.join(", ")}) — off My Issues.`);
770
+ if (r.unmatchedPlate && r.unmatchedPlate.length) console.log(`plate: ${r.unmatchedPlate.join(", ")} match no slice/backlog item — typo in meta.plate? ('roadmap plate' lists it).`);
771
+ console.log(r.cursorAdvanced ? `cursor advanced.` : `cursor unchanged${r.dry ? " (dry)" : " (inbox pending)"}.`);
772
+ } else {
773
+ console.error(`roadmap linear: unknown subcommand "${sub}" (status | auth | setup | provision | sync | note | notes | post-update)`);
774
+ process.exit(2);
775
+ }
776
+ } catch (e) {
777
+ console.error(`✗ ${e.message}`);
778
+ process.exit(1);
779
+ }
780
+ }