@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.
- package/README.md +495 -0
- package/docs/DEPLOYMENT.md +195 -0
- package/package.json +46 -0
- package/schema/backlog.schema.json +65 -0
- package/schema/roadmap.schema.json +273 -0
- package/scripts/assistant.mjs +30 -0
- package/scripts/backlog.mjs +81 -0
- package/scripts/cleanup.mjs +69 -0
- package/scripts/cli.mjs +119 -0
- package/scripts/cycle.mjs +89 -0
- package/scripts/dispatch.mjs +302 -0
- package/scripts/estimate.mjs +201 -0
- package/scripts/fanout.mjs +355 -0
- package/scripts/grab.mjs +119 -0
- package/scripts/init.mjs +60 -0
- package/scripts/lib/assistant-core.mjs +64 -0
- package/scripts/lib/backlog-core.mjs +318 -0
- package/scripts/lib/brief.mjs +123 -0
- package/scripts/lib/cli-core.mjs +97 -0
- package/scripts/lib/cycle-core.mjs +58 -0
- package/scripts/lib/estimate-core.mjs +204 -0
- package/scripts/lib/execution.mjs +186 -0
- package/scripts/lib/fanout-core.mjs +39 -0
- package/scripts/lib/graph.mjs +346 -0
- package/scripts/lib/journal-core.mjs +48 -0
- package/scripts/lib/linear-core.mjs +812 -0
- package/scripts/lib/mcp-core.mjs +313 -0
- package/scripts/lib/plan.mjs +68 -0
- package/scripts/lib/plate-core.mjs +53 -0
- package/scripts/lib/pr-watch-core.mjs +72 -0
- package/scripts/lib/priority.mjs +43 -0
- package/scripts/lib/recommend.mjs +178 -0
- package/scripts/lib/render-core.mjs +215 -0
- package/scripts/lib/review-core.mjs +104 -0
- package/scripts/lib/store.mjs +113 -0
- package/scripts/lib/sync-core.mjs +89 -0
- package/scripts/lib/validate-core.mjs +130 -0
- package/scripts/lib/wizard-core.mjs +50 -0
- package/scripts/linear.mjs +780 -0
- package/scripts/mcp.mjs +193 -0
- package/scripts/next.mjs +43 -0
- package/scripts/plate.mjs +77 -0
- package/scripts/promote.mjs +27 -0
- package/scripts/prompt.mjs +124 -0
- package/scripts/render.mjs +39 -0
- package/scripts/review.mjs +79 -0
- package/scripts/scheduler.mjs +78 -0
- package/scripts/set.mjs +31 -0
- package/scripts/show.mjs +53 -0
- package/scripts/test/run.mjs +3943 -0
- package/scripts/validate.mjs +28 -0
- package/scripts/watch-prs.mjs +72 -0
- package/scripts/wizard.mjs +97 -0
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
// roadmap — Linear brain (PURE). Config normalization, zero-network state detection, the
|
|
2
|
+
// PI-override ack, status/priority mapping, issue-description building, and the diff-based
|
|
3
|
+
// push plan + pull proposals. NO network, NO fs: scripts/linear.mjs does the IO and injects
|
|
4
|
+
// snapshots. Shapes are tracker-neutral where possible so a later jira.mjs reuses the diff
|
|
5
|
+
// engine — Linear-specific names live in the payloads this module emits and the IO layer.
|
|
6
|
+
|
|
7
|
+
import { flatten, isDone, HELD_STATUSES } from "./graph.mjs";
|
|
8
|
+
import { validatePriority } from "./priority.mjs";
|
|
9
|
+
import { platedKeys, validatePlate } from "./plate-core.mjs";
|
|
10
|
+
|
|
11
|
+
// ── config ────────────────────────────────────────────────────────────────────
|
|
12
|
+
export const GRANULARITIES = ["pis", "slices", "slices+backlog"];
|
|
13
|
+
export const VERBOSITIES = ["title", "brief", "full"];
|
|
14
|
+
export const PULL_MODES = ["off", "propose", "auto"];
|
|
15
|
+
export const HORIZONS = ["all", "near"];
|
|
16
|
+
// The statuses "near" keeps off the board: far-future work that hasn't been elected yet.
|
|
17
|
+
const HORIZON_FUTURE_STATUSES = ["scheduled", "optionality"];
|
|
18
|
+
|
|
19
|
+
// meta.linear with defaults applied, or null when absent/teamless (Linear off).
|
|
20
|
+
export function normalizeLinearConfig(meta) {
|
|
21
|
+
const raw = meta && meta.linear;
|
|
22
|
+
if (!raw || !raw.team) return null;
|
|
23
|
+
return {
|
|
24
|
+
team: raw.team,
|
|
25
|
+
granularity: raw.granularity || "slices",
|
|
26
|
+
horizon: raw.horizon || "all",
|
|
27
|
+
verbosity: raw.verbosity || "brief",
|
|
28
|
+
cycles: raw.cycles || "off",
|
|
29
|
+
cycle_capacity: raw.cycle_capacity != null ? raw.cycle_capacity : 10, // est_sessions per cycle — the election's cap
|
|
30
|
+
history: raw.history || "off",
|
|
31
|
+
pull: raw.pull || "off",
|
|
32
|
+
push_on: raw.push_on || "sync",
|
|
33
|
+
estimate_max: raw.estimate_max != null ? raw.estimate_max : 5, // Linear estimate scale max (linear=5, extended=7)
|
|
34
|
+
plate_max: raw.plate_max != null ? raw.plate_max : 7, // explicit meta.plate cap — My Issues signal
|
|
35
|
+
stale_days: raw.stale_days != null ? raw.stale_days : null, // staleness threshold; presence enables (meta.plate pattern)
|
|
36
|
+
status_map: raw.status_map || {},
|
|
37
|
+
watch: (raw.watch || []).map((w) => ({
|
|
38
|
+
team: w.team, project: w.project || null, capture: w.capture || "backlog",
|
|
39
|
+
kind: w.kind || "idea", priority: w.priority || null,
|
|
40
|
+
})),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function effectiveGranularity(cfg, pi) {
|
|
45
|
+
return (pi && pi.linear && pi.linear.granularity) || cfg.granularity;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function effectiveVerbosity(cfg, pi) {
|
|
49
|
+
return (pi && pi.linear && pi.linear.verbosity) || cfg.verbosity;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Validation for validate-core (errors block, warnings surface). Watch `kind` is NOT
|
|
53
|
+
// validated here — an invalid kind is caught by the backlog pre-write gate at capture time.
|
|
54
|
+
export function validateLinearConfig(graph) {
|
|
55
|
+
const errors = [];
|
|
56
|
+
const warnings = [];
|
|
57
|
+
const raw = graph.meta && graph.meta.linear;
|
|
58
|
+
if (raw != null) {
|
|
59
|
+
if (typeof raw !== "object" || Array.isArray(raw)) errors.push("meta.linear must be a mapping");
|
|
60
|
+
else {
|
|
61
|
+
if (!raw.team) errors.push("meta.linear.team is required (the push-target team key)");
|
|
62
|
+
if (raw.granularity != null && !GRANULARITIES.includes(raw.granularity)) errors.push(`meta.linear.granularity "${raw.granularity}" is not one of ${GRANULARITIES.join("|")}`);
|
|
63
|
+
if (raw.horizon != null && !HORIZONS.includes(raw.horizon)) errors.push(`meta.linear.horizon "${raw.horizon}" is not one of ${HORIZONS.join("|")}`);
|
|
64
|
+
if (raw.cycles != null && !["off", "on"].includes(raw.cycles)) errors.push(`meta.linear.cycles "${raw.cycles}" is not off|on`);
|
|
65
|
+
if (raw.cycle_capacity != null && !(Number.isInteger(raw.cycle_capacity) && raw.cycle_capacity >= 1)) errors.push("meta.linear.cycle_capacity must be an integer >= 1 (est_sessions the election packs per cycle; default 10)");
|
|
66
|
+
if (raw.history != null && !["off", "window", "full"].includes(raw.history)) errors.push(`meta.linear.history "${raw.history}" is not off|window|full`);
|
|
67
|
+
if (raw.verbosity != null && !VERBOSITIES.includes(raw.verbosity)) errors.push(`meta.linear.verbosity "${raw.verbosity}" is not one of ${VERBOSITIES.join("|")}`);
|
|
68
|
+
if (raw.pull != null && !PULL_MODES.includes(raw.pull)) errors.push(`meta.linear.pull "${raw.pull}" is not one of ${PULL_MODES.join("|")}`);
|
|
69
|
+
if (raw.push_on != null && !["sync", "manual"].includes(raw.push_on)) errors.push(`meta.linear.push_on "${raw.push_on}" is not sync|manual`);
|
|
70
|
+
if (raw.estimate_max != null && !(Number.isInteger(raw.estimate_max) && raw.estimate_max >= 1)) errors.push("meta.linear.estimate_max must be an integer >= 1 (the Linear estimate scale max; default 5)");
|
|
71
|
+
if (raw.plate_max != null && !(Number.isInteger(raw.plate_max) && raw.plate_max >= 1)) errors.push("meta.linear.plate_max must be an integer >= 1 (the My Issues explicit-plate cap; default 7)");
|
|
72
|
+
if (raw.stale_days != null && !(Number.isInteger(raw.stale_days) && raw.stale_days >= 1)) errors.push("meta.linear.stale_days must be an integer >= 1 (days of journal silence before an in-cycle issue is flagged stale)");
|
|
73
|
+
for (const w of raw.watch || []) {
|
|
74
|
+
if (!w.team) errors.push("meta.linear.watch: every entry needs a team key");
|
|
75
|
+
if (w.capture != null && w.capture !== "backlog") errors.push(`meta.linear.watch: capture "${w.capture}" is not supported (only "backlog")`);
|
|
76
|
+
for (const e of validatePriority(w.priority, "meta.linear.watch").errors) errors.push(e);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Optional meta.initiatives styling registry: a map of initiative-name → { icon, color }. Structural
|
|
81
|
+
// (runs regardless of Linear config), plus a typo/stale-rename guard — a declared initiative no PI
|
|
82
|
+
// references is almost always a rename that only got half-applied.
|
|
83
|
+
const inits = graph.meta && graph.meta.initiatives;
|
|
84
|
+
if (inits != null) {
|
|
85
|
+
if (typeof inits !== "object" || Array.isArray(inits)) errors.push("meta.initiatives must be a mapping of initiative-name → { icon, color }");
|
|
86
|
+
else {
|
|
87
|
+
const referenced = new Set((graph.pis || []).map((p) => p.initiative).filter(Boolean));
|
|
88
|
+
for (const [name, v] of Object.entries(inits)) {
|
|
89
|
+
if (v == null || typeof v !== "object" || Array.isArray(v)) { errors.push(`meta.initiatives["${name}"] must be a mapping with optional icon/color`); continue; }
|
|
90
|
+
if (v.icon != null && typeof v.icon !== "string") errors.push(`meta.initiatives["${name}"].icon must be a string (a Linear icon name)`);
|
|
91
|
+
if (v.color != null && !/^#[0-9a-fA-F]{6}$/.test(v.color)) errors.push(`meta.initiatives["${name}"].color must be a hex color like #5e6ad2`);
|
|
92
|
+
if (!referenced.has(name)) warnings.push(`meta.initiatives["${name}"] is declared but no PI has initiative: ${name} — typo or stale rename?`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const cfg = normalizeLinearConfig(graph.meta || {});
|
|
97
|
+
// meta.plate (the My Issues hopper): structural + cap. Assigns Linear issues, so it's inert without Linear.
|
|
98
|
+
const plateVal = validatePlate(graph, cfg ? cfg.plate_max : 7);
|
|
99
|
+
for (const e of plateVal.errors) errors.push(e);
|
|
100
|
+
for (const w of plateVal.warnings) warnings.push(w);
|
|
101
|
+
if (graph.meta && graph.meta.plate != null && !cfg) warnings.push("meta.plate is set but Linear isn't configured — the plate assigns Linear issues, so it's a no-op until meta.linear.team is set");
|
|
102
|
+
for (const pi of graph.pis || []) {
|
|
103
|
+
// One loop per override field — same shape as checkPiOverrideAck, so adding a third
|
|
104
|
+
// per-PI Linear override means extending these two field lists, nothing else.
|
|
105
|
+
for (const [f, allowed] of [["granularity", GRANULARITIES], ["verbosity", VERBOSITIES]]) {
|
|
106
|
+
if (!pi.linear || pi.linear[f] == null) continue;
|
|
107
|
+
if (!allowed.includes(pi.linear[f])) errors.push(`PI ${pi.id}: linear.${f} "${pi.linear[f]}" is not one of ${allowed.join("|")}`);
|
|
108
|
+
else if (cfg && pi.linear[f] !== cfg[f]) {
|
|
109
|
+
warnings.push(`PI ${pi.id}: linear.${f} "${pi.linear[f]}" differs from meta.linear.${f} "${cfg[f]}" — per-PI override in effect`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// The Linear subtitle truncates at 255 with "…"; when it's DERIVED (no pi.summary) and the exit's
|
|
113
|
+
// first sentence overflows, nudge to author a summary. An explicit over-length summary errors (validate-core).
|
|
114
|
+
if (cfg && !pi.summary && projectSubtitleRaw(pi).length > LINEAR_PROJECT_DESC_MAX) {
|
|
115
|
+
warnings.push(`PI ${pi.id}: Linear subtitle truncates at ${LINEAR_PROJECT_DESC_MAX} chars — add a "summary" (one crisp line) so the board subtitle isn't cut off`);
|
|
116
|
+
}
|
|
117
|
+
for (const sp of pi.sprints || []) {
|
|
118
|
+
if (sp.linear != null && typeof sp.linear !== "string") errors.push(`${pi.id}/${sp.id}: linear must be a string issue identifier (e.g. ABC-123)`);
|
|
119
|
+
if (sp.milestone != null && typeof sp.milestone !== "string") errors.push(`${pi.id}/${sp.id}: milestone must be a string (a Linear project-milestone / stage name)`);
|
|
120
|
+
// The forcing function: a slice bigger than the estimate scale can't map to one estimate point
|
|
121
|
+
// and is too big to fan out as a single agent session — surface it here (where you'd split it)
|
|
122
|
+
// rather than silently clamping it on the board. Only when Linear's configured (cfg present).
|
|
123
|
+
if (cfg && !isDone(sp.status) && typeof sp.est_sessions === "number" && Math.round(sp.est_sessions) > cfg.estimate_max) {
|
|
124
|
+
warnings.push(`${pi.id}/${sp.id}: est_sessions ${sp.est_sessions} exceeds estimate_max ${cfg.estimate_max} — too big to fan out as one slice; split it (its Linear estimate clamps to ${cfg.estimate_max})`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return { errors, warnings };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── detection (zero network) ──────────────────────────────────────────────────
|
|
132
|
+
export function linearState({ meta, env = {}, cursor = null } = {}) {
|
|
133
|
+
const cfg = normalizeLinearConfig(meta || {});
|
|
134
|
+
return {
|
|
135
|
+
configured: !!cfg,
|
|
136
|
+
authed: !!(env.LINEAR_API_KEY && String(env.LINEAR_API_KEY).trim()),
|
|
137
|
+
lastSync: (cursor && cursor.lastSync) || null,
|
|
138
|
+
cfg,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// The ONE canonical status sentence — every surface (CLI status, MCP, the session-start
|
|
143
|
+
// hook, runSync's errors) prints this, so the wording can't drift across four files.
|
|
144
|
+
export function linearStatusLine(state) {
|
|
145
|
+
if (!state.configured) return "Linear: not configured — add meta.linear or run 'roadmap linear setup --team <KEY>'.";
|
|
146
|
+
if (!state.authed) return `Linear: configured (team ${state.cfg.team}) but unauthed — set LINEAR_API_KEY ('roadmap linear auth' explains).`;
|
|
147
|
+
return `Linear: wired (team ${state.cfg.team} · granularity ${state.cfg.granularity} · pull ${state.cfg.pull} · last sync ${state.lastSync || "never"}).`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── PI-override ack (mirrors the --yes-spawn-autonomous double-ack) ───────────
|
|
151
|
+
// Covers every per-PI Linear override field (granularity, verbosity) with one ack.
|
|
152
|
+
export function checkPiOverrideAck(globalLinear, piLinear, acked, piId) {
|
|
153
|
+
if (!globalLinear || !piLinear || acked) return;
|
|
154
|
+
for (const f of ["granularity", "verbosity"]) {
|
|
155
|
+
if (piLinear[f] == null || piLinear[f] === globalLinear[f]) continue;
|
|
156
|
+
throw new Error(
|
|
157
|
+
`PI "${piId}" overrides Linear ${f} ("${piLinear[f]}") against the global ` +
|
|
158
|
+
`meta.linear.${f} ("${globalLinear[f]}") — this PI will push to Linear differently ` +
|
|
159
|
+
`from the rest of the roadmap. Re-run with yes_linear_override: true to confirm the override, ` +
|
|
160
|
+
`or drop linear.${f} from the PI to inherit the global. (Nothing was written.)`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── status / priority mapping ─────────────────────────────────────────────────
|
|
166
|
+
// roadmap tier <-> Linear priority int (0=None, 1=Urgent, 2=High, 3=Medium, 4=Low).
|
|
167
|
+
export const PRIORITY_TO_LINEAR = { P0: 1, P1: 2, P2: 3, P3: 4 };
|
|
168
|
+
export const LINEAR_TO_PRIORITY = { 1: "P0", 2: "P1", 3: "P2", 4: "P3" };
|
|
169
|
+
export const priorityToLinear = (p) => (p && PRIORITY_TO_LINEAR[p.tier]) || 0;
|
|
170
|
+
|
|
171
|
+
// roadmap status -> Linear workflow-state TYPE (types are stable across teams; names aren't).
|
|
172
|
+
// Only `active` maps to "started" (In Progress) so the board's In-Progress count means real,
|
|
173
|
+
// live work. Held statuses (blocked/paused/gated) are NOT being worked → "unstarted" (Todo),
|
|
174
|
+
// distinguished from plain `next` by a status:<held> label so the "Held on human" view filters.
|
|
175
|
+
const STATUS_TYPE_MAP = {
|
|
176
|
+
scheduled: "backlog", optionality: "backlog", next: "unstarted",
|
|
177
|
+
active: "started", blocked: "unstarted", paused: "unstarted", gated: "unstarted",
|
|
178
|
+
complete: "completed",
|
|
179
|
+
};
|
|
180
|
+
// Held roadmap statuses (blocked/paused/gated) carry a status:<s> label on their issue.
|
|
181
|
+
// HELD_STATUSES is re-exported from graph.mjs — the single source of truth for the set.
|
|
182
|
+
export { HELD_STATUSES };
|
|
183
|
+
// backlog item status -> state type. promoted items are skipped (the sprint carries them).
|
|
184
|
+
const ITEM_TYPE_MAP = { open: "backlog", in_progress: "started", done: "completed", dropped: "canceled" };
|
|
185
|
+
|
|
186
|
+
// teamStates: [{ id, name, type, position }] sorted by position (IO layer sorts).
|
|
187
|
+
export function resolvePushState(status, cfg, teamStates, typeMap = STATUS_TYPE_MAP) {
|
|
188
|
+
const name = cfg.status_map[status];
|
|
189
|
+
if (name) {
|
|
190
|
+
const s = teamStates.find((x) => x.name === name);
|
|
191
|
+
if (!s) {
|
|
192
|
+
throw new Error(`meta.linear.status_map maps "${status}" to "${name}", which is not a workflow state of team ${cfg.team} (available: ${teamStates.map((x) => x.name).join(", ")})`);
|
|
193
|
+
}
|
|
194
|
+
return s;
|
|
195
|
+
}
|
|
196
|
+
const type = typeMap[status] || "backlog";
|
|
197
|
+
const s = teamStates.find((x) => x.type === type);
|
|
198
|
+
if (!s) throw new Error(`team ${cfg.team} has no workflow state of type "${type}"`);
|
|
199
|
+
return s;
|
|
200
|
+
}
|
|
201
|
+
export const resolveItemPushState = (status, cfg, teamStates) => resolvePushState(status, cfg, teamStates, ITEM_TYPE_MAP);
|
|
202
|
+
|
|
203
|
+
// PI status -> Linear PROJECT status (the project-level analog of resolvePushState). Project-status
|
|
204
|
+
// TYPES are stable; the inventory is workspace-custom and the stock inventory has NO "paused"
|
|
205
|
+
// (it's an optional add in Linear settings), so each roadmap status carries a fallback CHAIN —
|
|
206
|
+
// a held PI degrades to planned rather than erroring on a stock workspace. Without this mapping
|
|
207
|
+
// every project reads "Backlog" forever and the initiative rollup lies (live-verified on a
|
|
208
|
+
// 64-project board: 64/64 stuck in backlog, 34 of them fully shipped).
|
|
209
|
+
const PI_STATUS_PROJECT_TYPES = {
|
|
210
|
+
complete: ["completed"],
|
|
211
|
+
active: ["started"],
|
|
212
|
+
next: ["planned", "backlog"],
|
|
213
|
+
scheduled: ["backlog"],
|
|
214
|
+
optionality: ["backlog"],
|
|
215
|
+
blocked: ["paused", "planned", "backlog"],
|
|
216
|
+
paused: ["paused", "planned", "backlog"],
|
|
217
|
+
gated: ["paused", "planned", "backlog"],
|
|
218
|
+
};
|
|
219
|
+
// projectStatuses: [{ id, name, type, position }] from organization.projectStatuses (IO layer),
|
|
220
|
+
// or null when the fetch failed/feature unavailable → returns null (no status projected).
|
|
221
|
+
export function resolveProjectStatus(piStatus, projectStatuses) {
|
|
222
|
+
if (!projectStatuses || !projectStatuses.length) return null;
|
|
223
|
+
const chain = PI_STATUS_PROJECT_TYPES[piStatus] || ["backlog"];
|
|
224
|
+
for (const t of chain) {
|
|
225
|
+
const s = projectStatuses.find((x) => x.type === t);
|
|
226
|
+
if (s) return s;
|
|
227
|
+
}
|
|
228
|
+
throw new Error(`workspace has no project status of type ${chain.join("/")} (available: ${projectStatuses.map((x) => `${x.name}:${x.type}`).join(", ")})`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Linear state TYPE -> roadmap status, for pull DELTAS (deliberately lossy in reverse;
|
|
232
|
+
// canceled maps to "dropped" for items and is flagged for slices by the proposal builder).
|
|
233
|
+
export function pullStatusFor(stateType) {
|
|
234
|
+
return ({ backlog: "scheduled", unstarted: "next", started: "active", completed: "complete", canceled: "canceled" })[stateType] || null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── issue descriptions (the no-duplication contract) ──────────────────────────
|
|
238
|
+
// target: { type: "slice"|"backlog", key }. Never copies read-order/prompt — the footer
|
|
239
|
+
// links agents and humans back to the canonical render instead.
|
|
240
|
+
export function machineFooter(target, docsUrl) {
|
|
241
|
+
const pickup = target.type === "slice" ? `/slice ${target.key}` : `roadmap grab ${target.key}`;
|
|
242
|
+
const doc = target.type === "slice" ? "SLICES.md" : "BACKLOG.md";
|
|
243
|
+
const link = docsUrl ? `${docsUrl}/docs/${doc}#${target.key}` : `docs/${doc}#${target.key}`;
|
|
244
|
+
// Linear normalizes a bare URL to "[url](<url>)" on store; emit that exact form (same reason
|
|
245
|
+
// as the "---\n\n" rule in issueDescription) so the description round-trips instead of
|
|
246
|
+
// re-pushing every issue each sync. Relative paths (no docsUrl) aren't auto-linked → stay bare.
|
|
247
|
+
const rendered = docsUrl ? `[${link}](<${link}>)` : link;
|
|
248
|
+
return `roadmap: ${target.type}=${target.key} · pick up: ${pickup}\n${rendered}`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const oneLine = (s) => String(s).replace(/\s*\n\s*/g, " ").trim();
|
|
252
|
+
|
|
253
|
+
export function issueDescription(node, cfg, { docsUrl = null, target } = {}) {
|
|
254
|
+
const tgt = target || { type: "slice", key: node.invoke };
|
|
255
|
+
const footer = machineFooter(tgt, docsUrl);
|
|
256
|
+
// Live-verified: Linear normalizes stored markdown to "---\n\n" after a horizontal rule —
|
|
257
|
+
// our canonical form must BE that normalized form or the exact-string diff updates every
|
|
258
|
+
// issue on every sync. Footer-only descriptions skip the rule entirely.
|
|
259
|
+
if (cfg.verbosity === "title") return footer;
|
|
260
|
+
const lines = [];
|
|
261
|
+
if (node.what && node.what !== node.title) lines.push(oneLine(node.what));
|
|
262
|
+
if (node.gate) lines.push(`Gate: ${node.gate === "default" ? "default gate" : oneLine(node.gate)}`);
|
|
263
|
+
// est_sessions is NOT written here — it rides the issue's native `estimate` field (see buildPushPlan),
|
|
264
|
+
// so it stays sortable/roll-up-able on the board instead of being unsearchable prose.
|
|
265
|
+
if (cfg.verbosity === "full") {
|
|
266
|
+
if (node.priority && (node.priority.tier || node.priority.reason)) {
|
|
267
|
+
lines.push(`Priority: ${[node.priority.tier, node.priority.weight != null ? `weight ${node.priority.weight}` : null].filter(Boolean).join(" · ")}${node.priority.reason ? ` — ${oneLine(node.priority.reason)}` : ""}`);
|
|
268
|
+
}
|
|
269
|
+
if (node.source) {
|
|
270
|
+
const src = node.source.linear
|
|
271
|
+
? `Linear ${node.source.linear.team}${node.source.linear.project ? `/${node.source.linear.project}` : ""} (${node.source.linear.issue})`
|
|
272
|
+
: node.source.slice ? `slice ${node.source.slice}` : null;
|
|
273
|
+
if (src) lines.push(`Source: ${src}`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return (lines.length ? lines.join("\n") + "\n\n---\n\n" : "") + footer;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── labels + project enrichment ───────────────────────────────────────────────
|
|
280
|
+
// Every synced issue carries the marker label (distinguishes roadmap-managed issues from
|
|
281
|
+
// hand-made ones); backlog items add kind:<kind>, sprints add track:<lane> when tracked.
|
|
282
|
+
// Tier is NOT a label — Linear's native priority already carries it (no duplication).
|
|
283
|
+
export const MARKER_LABEL = "roadmap";
|
|
284
|
+
export const PLATE_LABEL = "plate"; // marks an issue WE assigned to you (the plate) — never touch a hand-assignment
|
|
285
|
+
export const STALE_LABEL = "stale"; // journal silence on committed work — rides the same set-diff, so add/remove is idempotent
|
|
286
|
+
export const KIND_LABELS = ["bug", "chore", "followup", "urgent", "idea"].map((k) => `kind:${k}`);
|
|
287
|
+
|
|
288
|
+
export function desiredLabels(target, node, onPlate = false, isStale = false) {
|
|
289
|
+
const plate = onPlate ? [PLATE_LABEL] : [];
|
|
290
|
+
if (target.type === "backlog") return [MARKER_LABEL, ...(node.kind ? [`kind:${node.kind}`] : []), ...plate];
|
|
291
|
+
return [
|
|
292
|
+
MARKER_LABEL,
|
|
293
|
+
...(node.track ? [`track:${node.track}`] : []),
|
|
294
|
+
...(HELD_STATUSES.includes(node.status) ? [`status:${node.status}`] : []), // held distinction on the board
|
|
295
|
+
...(isStale ? [STALE_LABEL] : []),
|
|
296
|
+
...plate,
|
|
297
|
+
];
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Linear's ProjectCreateInput field limits (live-verified: name 80, description 255). Exceeding
|
|
301
|
+
// either is a hard "Argument Validation Error", so we clip at the projection layer — the SAME
|
|
302
|
+
// clipped value is used on create AND in the drift diff, so a clipped project stays idempotent
|
|
303
|
+
// (the full text lives in the canonical roadmap.yaml regardless). "…" is one char.
|
|
304
|
+
export const LINEAR_PROJECT_NAME_MAX = 80;
|
|
305
|
+
export const LINEAR_PROJECT_DESC_MAX = 255;
|
|
306
|
+
const clip = (s, max) => (s.length > max ? s.slice(0, max - 3) + "..." : s);
|
|
307
|
+
|
|
308
|
+
// Project name = the PI's HEADLINE. Roadmap titles are often authored "Headline — subhead
|
|
309
|
+
// explanation"; the subhead is context, not a name, and makes the board tacky. Take the part
|
|
310
|
+
// before the first em/en-dash separator (falling back to the whole title), then clip.
|
|
311
|
+
export function projectName(pi) {
|
|
312
|
+
const headline = String(pi.title).split(/\s+[—–]\s+/)[0].trim() || String(pi.title);
|
|
313
|
+
return clip(headline, LINEAR_PROJECT_NAME_MAX);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Linear rewrites bare URLs and domain-shaped tokens ("Fly.io", "install.rs") to the markdown
|
|
317
|
+
// auto-link form "[X](<...>)" on store, so an exact-string diff of a description/content never
|
|
318
|
+
// converges and re-pushes every sync. Collapse that form back to its text on BOTH sides before
|
|
319
|
+
// comparing — matching Linear's fuzzy heuristic token-by-token is a dead end, this sidesteps it.
|
|
320
|
+
export const normalizeLinearMarkdown = (s) =>
|
|
321
|
+
String(s || "")
|
|
322
|
+
.replace(/\[([^\]]*)\]\(<[^>]*>\)/g, "$1")
|
|
323
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
324
|
+
// Linear also rewrites underscore-bold to asterisk-bold on store (live-caught: a gate naming
|
|
325
|
+
// a __tests__ directory came back as **tests**, re-pushing forever). Canonicalize both sides;
|
|
326
|
+
// single-underscore italics left alone until observed live.
|
|
327
|
+
.replace(/__([^_]+)__/g, "**$1**");
|
|
328
|
+
|
|
329
|
+
// A "." that ends a common abbreviation is not a sentence end — without this the derived
|
|
330
|
+
// subtitle for "…seeds every node DB (incl. Flock…)" ships as the fragment "…DB (incl."
|
|
331
|
+
// (live-hit on a real board: reads like truncation, fires no over-length warning).
|
|
332
|
+
const ABBREV_END = /\b(?:incl|etc|vs|approx|cf|e\.g|i\.e)\.$/i;
|
|
333
|
+
const firstSentence = (s) => {
|
|
334
|
+
const t = oneLine(s);
|
|
335
|
+
const re = /[.!?](?=\s|$)/g;
|
|
336
|
+
for (let m; (m = re.exec(t)); ) {
|
|
337
|
+
const candidate = t.slice(0, m.index + 1);
|
|
338
|
+
if (!ABBREV_END.test(candidate)) return candidate.trim();
|
|
339
|
+
}
|
|
340
|
+
return t.trim();
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
// The RAW (pre-clip) subtitle. An explicit `pi.summary` wins (it's authored to be the subtitle);
|
|
344
|
+
// otherwise derive from the exit's first sentence / the title subhead / theme. Exposed so validate can
|
|
345
|
+
// warn when the DERIVED form would truncate at 255 (the fix: author a `summary`).
|
|
346
|
+
export function projectSubtitleRaw(pi) {
|
|
347
|
+
if (pi.summary) return oneLine(pi.summary);
|
|
348
|
+
const subhead = projectName(pi) !== clip(String(pi.title), LINEAR_PROJECT_NAME_MAX)
|
|
349
|
+
? String(pi.title).split(/\s+[—–]\s+/).slice(1).join(" — ").trim() : "";
|
|
350
|
+
return (pi.exit_criteria ? firstSentence(pi.exit_criteria) : "") || subhead || pi.theme || "";
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Project SUBTITLE (Linear's short `description`, hard-capped at 255 and truncated with "…" in the UI).
|
|
354
|
+
// One crisp essence line: an authored `pi.summary` if present, else the derived line — both clipped.
|
|
355
|
+
export function projectDescription(pi) {
|
|
356
|
+
return clip(projectSubtitleRaw(pi), LINEAR_PROJECT_DESC_MAX);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// PIs that should get an auto-stamped start_date on sync: active, but no explicit start_date yet. PURE
|
|
360
|
+
// decision (mirrors plateDrainKeys) — the sync (IO) supplies the date + does the write-back. So a PI's
|
|
361
|
+
// startDate ≈ when it was first picked up into active, giving the Linear roadmap timeline a start.
|
|
362
|
+
export function startStampTargets(graph) {
|
|
363
|
+
return (graph.pis || []).filter((pi) => pi.status === "active" && !pi.start_date).map((pi) => pi.id);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Project BODY (Linear's rich `content`, effectively unbounded) — the full strategic context the
|
|
367
|
+
// 255-char subtitle can't hold. This is where the whole exit criteria + theme + deps live.
|
|
368
|
+
export function projectContent(pi) {
|
|
369
|
+
const nameIsSplit = projectName(pi) !== clip(String(pi.title), LINEAR_PROJECT_NAME_MAX);
|
|
370
|
+
const blocks = [
|
|
371
|
+
nameIsSplit ? `**${String(pi.title)}**` : null,
|
|
372
|
+
pi.theme ? `**Theme:** ${oneLine(pi.theme)}` : null,
|
|
373
|
+
pi.exit_criteria ? `**Exit criteria**\n${String(pi.exit_criteria).trim()}` : null,
|
|
374
|
+
pi.estimate_weeks ? `**Estimate:** ${oneLine(pi.estimate_weeks)}` : null,
|
|
375
|
+
Array.isArray(pi.deps) && pi.deps.length ? `**Depends on:** ${pi.deps.join(", ")}` : null,
|
|
376
|
+
].filter(Boolean);
|
|
377
|
+
return blocks.join("\n\n");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Deterministic project color + icon BY INITIATIVE, so a Linear viewer reads grouping from the
|
|
381
|
+
// visual (every "Copilot" project shares a hue) instead of Linear's random per-project assignment.
|
|
382
|
+
// Indexed by the initiative's first-seen position → distinct for up to PALETTE-length initiatives.
|
|
383
|
+
// This is the FALLBACK for initiatives not explicitly styled via meta.initiatives (see initiativeStyle).
|
|
384
|
+
// Icon names must be from Linear's fixed set; these are drawn from Linear's icon picker (believed-valid),
|
|
385
|
+
// and a bad name degrades to "no icon" regardless — the icon surface is unverified against a live API.
|
|
386
|
+
const PROJECT_COLORS = ["#5e6ad2", "#26b5ce", "#4cb782", "#f2c94c", "#f2994a", "#eb5757", "#bb87fc", "#95a2b3", "#db6e9a", "#82b536"];
|
|
387
|
+
const PROJECT_ICONS = ["Rocket", "Gears", "Robot", "Network", "Shield", "Database", "Compass", "Bolt", "Box", "Chart"];
|
|
388
|
+
export const projectColorFor = (idx) => (idx < 0 || idx == null ? null : PROJECT_COLORS[idx % PROJECT_COLORS.length]);
|
|
389
|
+
export const projectIconFor = (idx) => (idx < 0 || idx == null ? null : PROJECT_ICONS[idx % PROJECT_ICONS.length]);
|
|
390
|
+
|
|
391
|
+
// Explicit per-initiative styling from meta.initiatives — the MEANINGFUL layer: "Lumen" → WritingAI,
|
|
392
|
+
// "Trust surface" → Shield. Wins over the by-order palette so a viewer reads intent, not luck. Returns
|
|
393
|
+
// { icon, color } with nulls when undeclared, so callers can fall back per-field.
|
|
394
|
+
export function initiativeStyle(meta, name) {
|
|
395
|
+
const e = name && meta && meta.initiatives && meta.initiatives[name];
|
|
396
|
+
return e && typeof e === "object" ? { icon: e.icon || null, color: e.color || null } : { icon: null, color: null };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ── done history (shipped work stays visible in the system) ───────────────────
|
|
400
|
+
// Which DONE slices still project as completed issues. "off" = none (pre-history behavior);
|
|
401
|
+
// "full" = all — what makes a project's progress % real (completed/total inside the project);
|
|
402
|
+
// "window" = completed_on within meta.completed_window_days (default 14) of the sync time.
|
|
403
|
+
// Unknown dates never resurrect: window without completed_on (or without now) stays off-board.
|
|
404
|
+
export function withinHistory(cfg, node, meta, now = null) {
|
|
405
|
+
if (cfg.history === "full") return true;
|
|
406
|
+
if (cfg.history !== "window") return false;
|
|
407
|
+
if (!node.completedOn || !now) return false;
|
|
408
|
+
const days = (meta && meta.completed_window_days) || 14;
|
|
409
|
+
return Date.parse(now) - Date.parse(node.completedOn) <= days * 86400000;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ── cycles (the weekly work-unit filter) ──────────────────────────────────────
|
|
413
|
+
// The current Linear cycle is a PROJECTION of slice status: active + next ARE the elected
|
|
414
|
+
// batch ("next" = committed this cycle — the election ritual is what promotes scheduled→next).
|
|
415
|
+
// No separate bookkeeping list; demote a slice and it leaves the cycle on the next sync.
|
|
416
|
+
export const CYCLE_STATUSES = ["active", "next"];
|
|
417
|
+
|
|
418
|
+
// Mapped, not-done slices — the only issues cycle sync ever touches.
|
|
419
|
+
export const cycleCandidates = (graph) => flatten(graph).nodes.filter((n) => n.linear && !isDone(n.status));
|
|
420
|
+
|
|
421
|
+
// PURE plan: which mapped issues to assign to / clear from the ACTIVE cycle.
|
|
422
|
+
// issues: { [identifier]: { id, cycleId } } (cycleId null when uncycled). Only CURRENT-cycle
|
|
423
|
+
// membership is ever cleared — an issue a human parked in a FUTURE cycle is deliberate planning
|
|
424
|
+
// and stays untouched. Converges with Linear's native rollover + auto-add: a still-active issue
|
|
425
|
+
// rolled forward gets re-assigned the same cycle (no-op); a demoted one gets cleared.
|
|
426
|
+
export function cyclePlan({ graph, activeCycleId, issues = {} }) {
|
|
427
|
+
if (!activeCycleId) return { assign: [], clear: [] };
|
|
428
|
+
const assign = [];
|
|
429
|
+
const clear = [];
|
|
430
|
+
for (const n of cycleCandidates(graph)) {
|
|
431
|
+
const cur = issues[n.linear] || {};
|
|
432
|
+
const cycleId = cur.cycleId || null;
|
|
433
|
+
if (CYCLE_STATUSES.includes(n.status)) {
|
|
434
|
+
if (cycleId !== activeCycleId) assign.push({ invoke: n.invoke, identifier: n.linear, id: cur.id || null });
|
|
435
|
+
} else if (cycleId === activeCycleId) {
|
|
436
|
+
clear.push({ invoke: n.invoke, identifier: n.linear, id: cur.id || null });
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return { assign, clear };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ── staleness (drift on committed work) ───────────────────────────────────────
|
|
443
|
+
// An issue in the committed set (CYCLE_STATUSES — the same set that populates the cycle; works
|
|
444
|
+
// with cycles off) whose activity basis is older than stale_days gets the stale label. The basis
|
|
445
|
+
// is the LATEST JOURNAL COMMENT (fallback: issue createdAt), supplied by the IO layer — never
|
|
446
|
+
// issue updatedAt, which our own pushes bump (the label itself would reset the clock and flap).
|
|
447
|
+
// activity: { [identifier]: ISO timestamp }. Unknown basis → never flagged (advisory feature,
|
|
448
|
+
// no false reds). Returns a Set of invoke keys.
|
|
449
|
+
export function staleKeys({ graph, cfg, activity = {}, now }) {
|
|
450
|
+
if (!cfg.stale_days || !now) return new Set();
|
|
451
|
+
const out = new Set();
|
|
452
|
+
for (const n of cycleCandidates(graph)) {
|
|
453
|
+
if (!CYCLE_STATUSES.includes(n.status)) continue;
|
|
454
|
+
const basis = activity[n.linear];
|
|
455
|
+
if (!basis) continue;
|
|
456
|
+
if (Date.parse(now) - Date.parse(basis) > cfg.stale_days * 86400000) out.add(n.invoke);
|
|
457
|
+
}
|
|
458
|
+
return out;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ── initiatives (the grouping tier above projects) ───────────────────────────
|
|
462
|
+
// A PI declares its initiative via `pi.initiative` (a display name). Sync ensures each distinct
|
|
463
|
+
// initiative exists in Linear and each mapped PI's project is attached to it — turning a flat
|
|
464
|
+
// wall of 50 projects into a handful of navigable strategic groups. PURE: returns the distinct
|
|
465
|
+
// initiative names + the pi→initiative assignments. The IO layer (linear.mjs syncInitiatives)
|
|
466
|
+
// creates + attaches, behind graceful degradation (initiativeCreate is not yet live-verified).
|
|
467
|
+
export function initiativePlan(graph) {
|
|
468
|
+
const names = new Set();
|
|
469
|
+
const assignments = [];
|
|
470
|
+
for (const pi of graph.pis || []) {
|
|
471
|
+
if (!pi.initiative) continue;
|
|
472
|
+
names.add(pi.initiative);
|
|
473
|
+
assignments.push({ pi: pi.id, initiative: pi.initiative });
|
|
474
|
+
}
|
|
475
|
+
return { initiatives: [...names], assignments };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ── milestones (stages WITHIN a project) ─────────────────────────────────────
|
|
479
|
+
// A slice declares its milestone via `sp.milestone` (a stage name). Milestones are PROJECT-scoped, so
|
|
480
|
+
// the same name under two PIs is two milestones. PURE: per-PI distinct names (first-seen order) + the
|
|
481
|
+
// mapped slices to attach. The IO layer (linear.mjs syncMilestones) creates them on the PI's project and
|
|
482
|
+
// sets each issue's projectMilestoneId, behind graceful degradation (projectMilestone API not yet live-verified).
|
|
483
|
+
export function milestonePlan(graph) {
|
|
484
|
+
const pis = [];
|
|
485
|
+
for (const pi of graph.pis || []) {
|
|
486
|
+
const names = [];
|
|
487
|
+
const slices = [];
|
|
488
|
+
for (const sp of pi.sprints || []) {
|
|
489
|
+
if (!sp.milestone) continue;
|
|
490
|
+
if (!names.includes(sp.milestone)) names.push(sp.milestone);
|
|
491
|
+
slices.push({ invoke: sp.invoke, linear: sp.linear || null, milestone: sp.milestone });
|
|
492
|
+
}
|
|
493
|
+
if (names.length) pis.push({ pi: pi.id, milestones: names, slices });
|
|
494
|
+
}
|
|
495
|
+
return { pis };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ── provisioning (the "Linear as the board" layer) ───────────────────────────
|
|
499
|
+
// Standard views. The filter hints are HUMAN instructions — customViewCreate's input shape
|
|
500
|
+
// is UNVERIFIED against a live workspace, so provision attempts each and degrades to the
|
|
501
|
+
// manual checklist below on the first rejection.
|
|
502
|
+
export const STANDARD_VIEWS = [
|
|
503
|
+
{ name: "Ready wave", hint: "unstarted (Todo) issues labeled roadmap, EXCLUDING status:* (held), sorted by priority — what fanout/dispatch launches next" },
|
|
504
|
+
{ name: "In flight", hint: "started (In Progress) issues labeled roadmap — genuinely active work only" },
|
|
505
|
+
{ name: "Held on human", hint: "issues labeled status:gated (or status:blocked / status:paused) — held, not being worked" },
|
|
506
|
+
{ name: "Backlog triage", hint: "backlog-state issues labeled kind:* — the /prioritize queue" },
|
|
507
|
+
{ name: "Recently shipped", hint: "completed in the last 14 days, labeled roadmap" },
|
|
508
|
+
];
|
|
509
|
+
|
|
510
|
+
// Per-track lane views are generated from the tracks actually present in the graph.
|
|
511
|
+
function trackViews(graph) {
|
|
512
|
+
const tracks = [...new Set(flatten(graph).nodes.map((n) => n.track).filter(Boolean))].sort();
|
|
513
|
+
return tracks.map((t) => ({ name: `Track ${t}`, hint: `issues labeled track:${t} — the ${t} fanout lane` }));
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// The cycle-era views — offered only when their feature is configured (provision degrades like STANDARD_VIEWS).
|
|
517
|
+
const THIS_CYCLE_VIEW = { name: "This cycle", hint: "current-cycle issues labeled roadmap — the elected batch the sync maintains (active + next)" };
|
|
518
|
+
const STALE_VIEW = { name: "Stale", hint: "issues labeled stale — committed work with no journal note in stale_days; the cycle election reviews these FIRST" };
|
|
519
|
+
|
|
520
|
+
export function provisionPlan({ graph, teamLabels, cfg = null }) {
|
|
521
|
+
const have = new Set(Object.keys(teamLabels || {}));
|
|
522
|
+
const nodes = flatten(graph).nodes;
|
|
523
|
+
const tracks = new Set(nodes.map((n) => n.track).filter(Boolean));
|
|
524
|
+
const heldPresent = HELD_STATUSES.filter((s) => nodes.some((n) => n.status === s));
|
|
525
|
+
const wanted = [
|
|
526
|
+
MARKER_LABEL, ...KIND_LABELS,
|
|
527
|
+
...[...tracks].sort().map((t) => `track:${t}`),
|
|
528
|
+
...heldPresent.map((s) => `status:${s}`),
|
|
529
|
+
...(graph.meta && graph.meta.plate != null ? [PLATE_LABEL] : []), // marks tool-plated issues (safe unassign)
|
|
530
|
+
...(cfg && cfg.stale_days ? [STALE_LABEL] : []),
|
|
531
|
+
];
|
|
532
|
+
return {
|
|
533
|
+
createLabels: wanted.filter((n) => !have.has(n)),
|
|
534
|
+
existingLabels: wanted.filter((n) => have.has(n)),
|
|
535
|
+
views: [...STANDARD_VIEWS,
|
|
536
|
+
...(cfg && cfg.cycles === "on" ? [THIS_CYCLE_VIEW] : []),
|
|
537
|
+
...(cfg && cfg.stale_days ? [STALE_VIEW] : []),
|
|
538
|
+
...trackViews(graph)],
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
export function manualViewChecklist(views = STANDARD_VIEWS) {
|
|
543
|
+
return views.map((v) => ` □ New view "${v.name}" — ${v.hint}`).join("\n");
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Workspace-level guidance (paste into Linear's agent-guidance setting).
|
|
547
|
+
export function agentGuidanceText(cfg) {
|
|
548
|
+
return [
|
|
549
|
+
`This workspace is managed by the roadmap tool. Issues labeled "${MARKER_LABEL}" are PROJECTIONS`,
|
|
550
|
+
`of a repo's canonical docs/roadmap/roadmap.yaml + backlog.yaml — the YAML is the source of`,
|
|
551
|
+
`truth; edit status/priority here and the repo's /sync proposes it back. Each managed issue's`,
|
|
552
|
+
`description ends with a machine footer (roadmap: slice=<key> · pick up: ...) that names the`,
|
|
553
|
+
`slice and the pickup command. kind:* labels bucket backlog captures; track:* labels are fanout`,
|
|
554
|
+
`lanes; Linear priority IS the roadmap tier (Urgent=P0 … Low=P3).`,
|
|
555
|
+
].join("\n");
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// The repo-side dispatch contract — paste into CLAUDE.md / AGENTS.md / skills.md so any
|
|
559
|
+
// agent delegated a Linear issue (Claude Code coding session, Codex, Oz, …) self-orients.
|
|
560
|
+
export function dispatchGuidance() {
|
|
561
|
+
return [
|
|
562
|
+
"## Working a roadmap-dispatched Linear issue",
|
|
563
|
+
"1. Read the issue's roadmap footer (`roadmap: slice=<key>` or `backlog=<key>`) — the machine contract — THEN read the issue's existing notes (`roadmap linear notes <key>`): if a prior session left off mid-flight, that comment stream is where it stands.",
|
|
564
|
+
"2. Open docs/SLICES.md#<key> and the slice's entry (including its `prompt`) in docs/roadmap/roadmap.yaml. The YAML is canonical; the Linear issue is a projection.",
|
|
565
|
+
"3. Honor the slice's verification gate before committing.",
|
|
566
|
+
"4. Journal as you go: at each checkpoint — a gate cleared, a blocker hit, a logical unit done, session end — post a note with `roadmap linear note <key> \"…\" [--kind progress|blocker|done]`, so the work survives a dead session.",
|
|
567
|
+
"5. Open a PR whose DESCRIPTION includes the exact line `roadmap: slice=<key>` (or `backlog=<key>`) — that marker is how /sync reconciles cloud PRs, whose branch names don't follow the repo convention. NEVER merge — the lead merges.",
|
|
568
|
+
"6. Leftovers go to the BACKLOG ONLY (`roadmap backlog add \"<title>\" -k followup --slice <key>`, or a **Leftovers** heading in the PR body). Never add sprints or PIs; skip speculative ideas (YAGNI applies to captures too).",
|
|
569
|
+
].join("\n");
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// ── push plan (diff-based, idempotent) ────────────────────────────────────────
|
|
573
|
+
// existing: the fetched Linear snapshot — { issues: { [identifier]: { id, title, description,
|
|
574
|
+
// priority, stateId } }, projects: { [projectId]: { id, name } } }. Ops reference projects
|
|
575
|
+
// symbolically (projectRef = pi id); the executor resolves refs after creating projects.
|
|
576
|
+
// holds: Set of "IDENTIFIER:field" (field = projection key: priority | stateId) for issues
|
|
577
|
+
// with an OPEN inbound proposal — push skips those fields so a human's Linear edit is never
|
|
578
|
+
// clobbered while the proposal is unresolved (live-verified failure mode).
|
|
579
|
+
// labels: name→id from the team bundle (fresh each sync, no YAML caching). Unresolvable
|
|
580
|
+
// names are dropped from payloads and reported once via missingLabels (fix = provision).
|
|
581
|
+
export function buildPushPlan({ graph, backlog, cfg, teamStates, existing, docsUrl = null, holds = new Set(), labels = {}, viewerId = null, projectStatuses = null, now = null, stale = new Set() }) {
|
|
582
|
+
const ops = [];
|
|
583
|
+
const missing = new Set();
|
|
584
|
+
const model = flatten(graph);
|
|
585
|
+
const initiativeOrder = initiativePlan(graph).initiatives; // first-seen order → stable color/icon index
|
|
586
|
+
// "the plate" → My Issues (assignee=you). null when the feature is off. plateLabelId lets us tell an
|
|
587
|
+
// issue WE plated (safe to unassign when it drops off) from one you hand-assigned in Linear (never touch).
|
|
588
|
+
const plate = platedKeys(graph, backlog);
|
|
589
|
+
const plateLabelId = labels[PLATE_LABEL] || null;
|
|
590
|
+
|
|
591
|
+
for (const pi of graph.pis || []) {
|
|
592
|
+
const gran = effectiveGranularity(cfg, pi);
|
|
593
|
+
const projId = pi.linear && pi.linear.project;
|
|
594
|
+
const piNodes = model.nodes.filter((n) => n.piId === pi.id);
|
|
595
|
+
// A PI earns a project only if it has PROJECTABLE work — a slice that will get an issue
|
|
596
|
+
// (not-done, already mapped, or done-within-history) — or granularity is 'pis' (the project
|
|
597
|
+
// is the deliverable). Without this, a fully-shipped PI creates a bare 0-issue project
|
|
598
|
+
// (46% of pidgeon's board). With history on, a shipped PI's project is created AND populated.
|
|
599
|
+
const hasWork = gran === "pis" || piNodes.some((n) => n.linear || !isDone(n.status) || withinHistory(cfg, n, graph.meta, now));
|
|
600
|
+
const name = projectName(pi);
|
|
601
|
+
const desc = projectDescription(pi);
|
|
602
|
+
const content = projectContent(pi);
|
|
603
|
+
const iIdx = pi.initiative ? initiativeOrder.indexOf(pi.initiative) : -1;
|
|
604
|
+
const declared = initiativeStyle(graph.meta, pi.initiative); // explicit per-initiative style wins per-field
|
|
605
|
+
const color = declared.color || projectColorFor(iIdx);
|
|
606
|
+
const icon = declared.icon || projectIconFor(iIdx);
|
|
607
|
+
const priority = pi.priority ? priorityToLinear(pi.priority) : null; // null → leave Linear's (No priority)
|
|
608
|
+
const startDate = pi.start_date || null; // explicit, or auto-stamped when the PI went active (sync write-back)
|
|
609
|
+
const targetDate = pi.target_date || pi.projected_target_date || null; // explicit commitment wins; else the estimate-derived projection ('roadmap estimate timeline')
|
|
610
|
+
const projStatus = resolveProjectStatus(pi.status, projectStatuses); // null → status projection off (fetch degraded)
|
|
611
|
+
const desired = { // the full projection; create takes it whole, update diffs field-by-field
|
|
612
|
+
name,
|
|
613
|
+
...(desc ? { description: desc } : {}),
|
|
614
|
+
...(content ? { content } : {}),
|
|
615
|
+
...(color ? { color } : {}),
|
|
616
|
+
...(icon ? { icon } : {}),
|
|
617
|
+
...(priority != null ? { priority } : {}),
|
|
618
|
+
...(startDate ? { startDate } : {}),
|
|
619
|
+
...(targetDate ? { targetDate } : {}),
|
|
620
|
+
...(projStatus ? { statusId: projStatus.id } : {}),
|
|
621
|
+
};
|
|
622
|
+
if (!projId) {
|
|
623
|
+
if (hasWork) ops.push({ op: "createProject", payload: desired, projectRef: pi.id,
|
|
624
|
+
writeBack: { kind: "pi", pi: pi.id, field: "project" } });
|
|
625
|
+
} else if (existing.projects[projId]) { // already mapped → keep it in sync (never orphan)
|
|
626
|
+
const cur = existing.projects[projId];
|
|
627
|
+
const changed = {};
|
|
628
|
+
if (cur.name !== name) changed.name = name;
|
|
629
|
+
if ((cur.description || "") !== desc) changed.description = desc;
|
|
630
|
+
// content compares under markdown-normalization (Linear auto-links URLs/domains on store)
|
|
631
|
+
if (content && normalizeLinearMarkdown(cur.content || "") !== normalizeLinearMarkdown(content)) changed.content = content;
|
|
632
|
+
if (color && (cur.color || "") !== color) changed.color = color;
|
|
633
|
+
if (icon && (cur.icon || "") !== icon) changed.icon = icon;
|
|
634
|
+
if (priority != null && (cur.priority || 0) !== priority) changed.priority = priority;
|
|
635
|
+
if (startDate && (cur.startDate || null) !== startDate) changed.startDate = startDate;
|
|
636
|
+
if (targetDate && (cur.targetDate || null) !== targetDate) changed.targetDate = targetDate;
|
|
637
|
+
if (projStatus && (cur.statusId || null) !== projStatus.id) changed.statusId = projStatus.id;
|
|
638
|
+
if (Object.keys(changed).length) ops.push({ op: "updateProject", id: projId, payload: changed });
|
|
639
|
+
}
|
|
640
|
+
if (gran === "pis") continue; // projects only — no issue leaks for this PI
|
|
641
|
+
|
|
642
|
+
// Per-PI verbosity override rides the same cfg the description builder already reads;
|
|
643
|
+
// identical → the shared cfg object (no spread), so the default path stays byte-identical.
|
|
644
|
+
const effVerb = effectiveVerbosity(cfg, pi);
|
|
645
|
+
const descCfg = effVerb === cfg.verbosity ? cfg : { ...cfg, verbosity: effVerb };
|
|
646
|
+
for (const node of piNodes) {
|
|
647
|
+
// create not-done work, plus done work inside the history window (Done issues are what
|
|
648
|
+
// make progress % real); always update mapped.
|
|
649
|
+
if (!node.linear && isDone(node.status) && !withinHistory(cfg, node, graph.meta, now)) continue;
|
|
650
|
+
// horizon "near": far-future work (scheduled/optionality) stays YAML-only until elected —
|
|
651
|
+
// no NEW issues. Already-mapped ones keep updating: never orphan, never let a visible issue rot.
|
|
652
|
+
if (!node.linear && cfg.horizon === "near" && HORIZON_FUTURE_STATUSES.includes(node.status)) continue;
|
|
653
|
+
pushIssueOp(node, { type: "slice", key: node.invoke }, node.status, resolvePushState, pi.id, projId || null, descCfg);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (cfg.granularity === "slices+backlog" && backlog) {
|
|
658
|
+
for (const it of backlog.items || []) {
|
|
659
|
+
if (it.status === "promoted") continue; // the promoted sprint carries it
|
|
660
|
+
if (!it.linear && (it.status === "done" || it.status === "dropped")) continue;
|
|
661
|
+
// Backlog items surface in Linear prefixed with their roadmap id ("b60 · …"): the
|
|
662
|
+
// Linear identifier (PID-n) is minted at creation and can't carry the backlog number,
|
|
663
|
+
// so without the prefix "find b60" means opening issues one by one. Prefixed titles
|
|
664
|
+
// sort in backlog order in any alphanumeric view. Guard: a round-tripped title that
|
|
665
|
+
// already carries its prefix is not double-prefixed.
|
|
666
|
+
const node = { invoke: it.id, title: it.title.startsWith(`${it.id} · `) ? it.title : `${it.id} · ${it.title}`,
|
|
667
|
+
what: it.title, gate: it.gate || null,
|
|
668
|
+
estSessions: it.est_sessions ?? null, priority: it.priority || null, source: it.source || null,
|
|
669
|
+
kind: it.kind, linear: it.linear || null };
|
|
670
|
+
pushIssueOp(node, { type: "backlog", key: it.id }, it.status, resolveItemPushState, null);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
// Explicit meta.plate keys that match no slice/item at all → typos (active-derived keys always match).
|
|
674
|
+
let unmatchedPlate = [];
|
|
675
|
+
if (plate) {
|
|
676
|
+
const validKeys = new Set([...model.nodes.map((n) => n.invoke), ...((backlog && backlog.items) || []).map((it) => it.id)]);
|
|
677
|
+
const explicit = Array.isArray(graph.meta && graph.meta.plate) ? graph.meta.plate.filter((k) => typeof k === "string") : [];
|
|
678
|
+
unmatchedPlate = explicit.filter((k) => !validKeys.has(k));
|
|
679
|
+
}
|
|
680
|
+
return { ops, missingLabels: [...missing].sort(), ...(unmatchedPlate.length ? { unmatchedPlate } : {}) };
|
|
681
|
+
|
|
682
|
+
function labelIdsFor(target, node, onPlate) {
|
|
683
|
+
const ids = [];
|
|
684
|
+
for (const name of desiredLabels(target, node, onPlate, stale.has(target.key))) {
|
|
685
|
+
if (labels[name]) ids.push(labels[name]);
|
|
686
|
+
else missing.add(name);
|
|
687
|
+
}
|
|
688
|
+
return ids.sort();
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function pushIssueOp(node, target, status, resolver, projectRef, projectId = null, descCfg = cfg) {
|
|
692
|
+
const state = resolver(status, cfg, teamStates);
|
|
693
|
+
const onPlate = plate ? plate.has(target.key) : false;
|
|
694
|
+
const plated = onPlate && !!viewerId; // we will actually assign + label (label never lies about assignment)
|
|
695
|
+
const labelIds = labelIdsFor(target, node, plated);
|
|
696
|
+
// est_sessions → native estimate: rounded to an integer, clamped to the scale max so an oversize
|
|
697
|
+
// slice never pushes an out-of-scale value (validate warns to split it). 0/null → unestimated (no
|
|
698
|
+
// field), never a pushed 0 (which needs the team's allow-zero setting). Use the "linear" scale.
|
|
699
|
+
const points = node.estSessions != null ? Math.min(Math.round(node.estSessions), cfg.estimate_max) : 0;
|
|
700
|
+
const projection = {
|
|
701
|
+
title: node.title,
|
|
702
|
+
description: issueDescription(node, descCfg, { docsUrl, target }), // descCfg carries a per-PI verbosity override
|
|
703
|
+
priority: priorityToLinear(node.priority),
|
|
704
|
+
stateId: state.id,
|
|
705
|
+
labelIds,
|
|
706
|
+
...(points > 0 ? { estimate: points } : {}),
|
|
707
|
+
...(plated ? { assigneeId: viewerId } : {}),
|
|
708
|
+
};
|
|
709
|
+
if (!node.linear) {
|
|
710
|
+
const payload = { ...projection };
|
|
711
|
+
if (!labelIds.length) delete payload.labelIds;
|
|
712
|
+
// History backfill: carry the true completion date (live-verified IssueCreateInput field;
|
|
713
|
+
// the IO layer strip-retries if the VALUE is rejected — worst case: Done at creation time).
|
|
714
|
+
if (isDone(status) && node.completedOn) payload.completedAt = node.completedOn;
|
|
715
|
+
ops.push({ op: "createIssue", payload, projectRef,
|
|
716
|
+
writeBack: target.type === "slice" ? { kind: "sprint", invoke: target.key } : { kind: "item", id: target.key } });
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
const cur = existing.issues[node.linear];
|
|
720
|
+
if (!cur) return; // mapped but not in snapshot (deleted in Linear?) — leave for the human
|
|
721
|
+
const changed = {};
|
|
722
|
+
for (const k of ["title", "description", "priority", "stateId"]) {
|
|
723
|
+
if (holds.has(`${node.linear}:${k}`)) continue; // pending inbound proposal owns this field
|
|
724
|
+
// description compares under markdown-normalization (Linear auto-links URLs/domains on store)
|
|
725
|
+
const same = k === "description"
|
|
726
|
+
? normalizeLinearMarkdown(projection[k]) === normalizeLinearMarkdown(cur[k])
|
|
727
|
+
: projection[k] === cur[k];
|
|
728
|
+
if (!same) changed[k] = projection[k];
|
|
729
|
+
}
|
|
730
|
+
// labels compare as SETS (order-insensitive — Linear returns them in its own order)
|
|
731
|
+
if (labelIds.length && labelIds.join(",") !== [...(cur.labelIds || [])].sort().join(",")) {
|
|
732
|
+
changed.labelIds = labelIds;
|
|
733
|
+
}
|
|
734
|
+
// estimate: only when we have a positive value that differs. A removed est_sessions leaving a
|
|
735
|
+
// stale estimate is acceptable (never clear to 0 — that needs the team's allow-zero setting).
|
|
736
|
+
if (points > 0 && (cur.estimate || 0) !== points) changed.estimate = points;
|
|
737
|
+
// assignee (the plate → My Issues): assign you when on the plate; unassign ONLY an issue WE plated
|
|
738
|
+
// (carries the plate label) that has fallen off — a hand-assignment in Linear (no plate label) is
|
|
739
|
+
// never touched. Skipped entirely when the feature is off (plate null) or the viewer is unknown.
|
|
740
|
+
if (plate && viewerId) {
|
|
741
|
+
if (onPlate) { if (cur.assigneeId !== viewerId) changed.assigneeId = viewerId; }
|
|
742
|
+
else if (plateLabelId && (cur.labelIds || []).includes(plateLabelId) && cur.assigneeId) changed.assigneeId = null;
|
|
743
|
+
}
|
|
744
|
+
// project attach — how a transferred issue (backlog item promoted to a sprint) moves
|
|
745
|
+
// into its PI's project. Only when the project id is already known (post first push).
|
|
746
|
+
if (projectId && cur.projectId !== projectId) changed.projectId = projectId;
|
|
747
|
+
if (Object.keys(changed).length) ops.push({ op: "updateIssue", id: cur.id, identifier: node.linear, payload: changed });
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// ── pull proposals (never mutations) ──────────────────────────────────────────
|
|
752
|
+
// inbound: [{ identifier, title, priority, state: { type, name }, team, project }] — mapped-
|
|
753
|
+
// issue edits AND watch-source issues, both fetched since the cursor by the IO layer.
|
|
754
|
+
// Returns { newItems: [backlog_add args], deltas: [{kind, key, field, from, to, note?}] }.
|
|
755
|
+
export function buildPullProposals({ cfg, inbound, graph, backlog }) {
|
|
756
|
+
const model = flatten(graph);
|
|
757
|
+
const byIdentifier = new Map();
|
|
758
|
+
for (const n of model.nodes) if (n.linear) byIdentifier.set(n.linear, { kind: "slice", key: n.invoke, status: n.status, priority: n.priority });
|
|
759
|
+
for (const it of (backlog && backlog.items) || []) if (it.linear) byIdentifier.set(it.linear, { kind: "item", key: it.id, status: it.status, priority: it.priority });
|
|
760
|
+
|
|
761
|
+
const newItems = [];
|
|
762
|
+
const deltas = [];
|
|
763
|
+
const seen = new Set();
|
|
764
|
+
for (const iss of inbound || []) {
|
|
765
|
+
if (seen.has(iss.identifier)) continue; // same issue can arrive via two fetch windows
|
|
766
|
+
seen.add(iss.identifier);
|
|
767
|
+
const known = byIdentifier.get(iss.identifier);
|
|
768
|
+
if (known) {
|
|
769
|
+
// Only propose a status delta when Linear's state TYPE differs from the type WE would
|
|
770
|
+
// push for the node's CURRENT roadmap status. Several roadmap statuses collapse to one
|
|
771
|
+
// Linear type on push (gated/blocked/paused → "started"; scheduled/optionality →
|
|
772
|
+
// "backlog"), so reading that type back is the round-trip echo of our own push — not a
|
|
773
|
+
// human move — and proposing it would spam false gated→active / optionality→scheduled
|
|
774
|
+
// deltas on every sync of a large roadmap. A genuine human move lands in a DIFFERENT type.
|
|
775
|
+
const pushedType = (known.kind === "item" ? ITEM_TYPE_MAP : STATUS_TYPE_MAP)[known.status];
|
|
776
|
+
const inboundType = iss.state && iss.state.type;
|
|
777
|
+
if (inboundType && inboundType !== pushedType) {
|
|
778
|
+
const to = pullStatusFor(inboundType);
|
|
779
|
+
if (to === "canceled") {
|
|
780
|
+
if (known.kind === "item") { if (known.status !== "dropped") deltas.push({ kind: known.kind, key: known.key, identifier: iss.identifier, field: "status", from: known.status, to: "dropped" }); }
|
|
781
|
+
else deltas.push({ kind: known.kind, key: known.key, identifier: iss.identifier, field: "status", from: known.status, to: null, note: `canceled in Linear — no roadmap equivalent; decide by hand` });
|
|
782
|
+
} else if (to && to !== known.status && !(known.kind === "item" && to === "active" && known.status === "in_progress")) {
|
|
783
|
+
deltas.push({ kind: known.kind, key: known.key, identifier: iss.identifier, field: "status", from: known.status, to: known.kind === "item" && to === "active" ? "in_progress" : to });
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
const tier = LINEAR_TO_PRIORITY[iss.priority] || null;
|
|
787
|
+
const curTier = (known.priority && known.priority.tier) || null;
|
|
788
|
+
if (tier !== curTier) deltas.push({ kind: known.kind, key: known.key, identifier: iss.identifier, field: "priority.tier", from: curTier, to: tier });
|
|
789
|
+
continue;
|
|
790
|
+
}
|
|
791
|
+
// Unknown issue from a watch source → proposed backlog capture. Stable id = the
|
|
792
|
+
// lowercased identifier, which is also the dedupe key across machines/cursor loss.
|
|
793
|
+
const watch = cfg.watch.find((w) => w.team === iss.team && (!w.project || w.project === iss.project));
|
|
794
|
+
if (!watch) continue; // an edit on a non-watched, non-mapped issue — not ours
|
|
795
|
+
const tier = LINEAR_TO_PRIORITY[iss.priority] || null;
|
|
796
|
+
newItems.push({
|
|
797
|
+
id: iss.identifier.toLowerCase(),
|
|
798
|
+
title: iss.title,
|
|
799
|
+
kind: watch.kind,
|
|
800
|
+
linear: iss.identifier,
|
|
801
|
+
...(tier ? { priority: { tier } } : watch.priority ? { priority: watch.priority } : {}),
|
|
802
|
+
source: { linear: { team: iss.team, ...(iss.project ? { project: iss.project } : {}), issue: iss.identifier } },
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
return { newItems, deltas };
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// The push-side field holds for a set of pending deltas (see buildPushPlan's `holds`).
|
|
809
|
+
export function holdsFor(deltas) {
|
|
810
|
+
const map = { status: "stateId", "priority.tier": "priority" };
|
|
811
|
+
return new Set((deltas || []).filter((d) => d.to != null && d.identifier).map((d) => `${d.identifier}:${map[d.field] || d.field}`));
|
|
812
|
+
}
|