@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,3943 @@
1
+ #!/usr/bin/env node
2
+ // roadmap — graph-brain test suite.
3
+ // Zero-dependency runner. Each test states WHY it matters (what breaks if it regresses),
4
+ // because this brain schedules concurrent sessions that commit/push — a wrong wave is a
5
+ // real-world collision, not a cosmetic bug. Run: node scripts/test/run.mjs (or npm test).
6
+
7
+ import {
8
+ flatten, detectCycle, computeWaves, execPlan, sessionsRemaining, resolveGate, isDone, readyNodes, coherenceEnabled,
9
+ } from "../lib/graph.mjs";
10
+ import { buildPlan } from "../lib/plan.mjs";
11
+ import { nodeWeight, recommendConcurrency, probeDisk } from "../lib/recommend.mjs";
12
+ import { synthesizeBrief, branchFor, worktreeFor, baseRefOf, baseBranchOf, remoteOf, launchPrompt, agentCmdFor, DEFAULT_AGENT_CMD } from "../lib/brief.mjs";
13
+ import { route, classify, buildArgs, findRepoRoot, missingRoadmapHelp, expandShort, REL } from "../lib/cli-core.mjs";
14
+ import { launchDecision } from "../lib/fanout-core.mjs";
15
+ import { configuredProfiles, resolveProfile, commandFor, launchDecisionForProfile, safeConfig } from "../lib/assistant-core.mjs";
16
+ import { terminalChoices, moveSelection, parseCap, buildFanArgs, autoOutName } from "../lib/wizard-core.mjs";
17
+ import { TOOLS, addSprint, setStatus, setFields, bulkSet, prune, validateDocOrThrow, readValidate, serialize } from "../lib/mcp-core.mjs";
18
+ import { parseAssignments } from "../lib/cli-core.mjs";
19
+ import { diffPrStates, matchesRoadmapBranches, checksOf } from "../lib/pr-watch-core.mjs";
20
+ import { findUnrecordedMerges, reconcileNudge, underParallelizedWarnings, sprawlWarnings, captureRatio } from "../lib/sync-core.mjs";
21
+ import {
22
+ validateExecution, suggestedConcurrency, executionDirectiveLines, normalizeExecution,
23
+ teamSize, filterByTrack, dirClusters, EXEC_MODES, EXEC_ROLES,
24
+ } from "../lib/execution.mjs";
25
+ import { renderMarkdown } from "../lib/render-core.mjs";
26
+ import { comparePriority, validatePriority, tierBadge, TIERS } from "../lib/priority.mjs";
27
+ import {
28
+ validateBacklog, addItem, setItemFields, validateBacklogDocOrThrow, sortByPriority,
29
+ openCount, renderBacklogMarkdown, backlogItemToNode, pickNext, BACKLOG_TOOLS, readBacklogList,
30
+ performPromotion,
31
+ } from "../lib/backlog-core.mjs";
32
+ import { validateGraph } from "../lib/validate-core.mjs";
33
+ import { estimationConfig, estimateArgs, parseEstimateRecord, applyEstimate, validateEstimation, timelinePlan, calendarFromMinutes, logArgs, alreadyLogged } from "../lib/estimate-core.mjs";
34
+ import { runEstimate, resolveEngine, runTimeline, runLog, resolveHistory } from "../estimate.mjs";
35
+ import { mutateRoadmap, mutateBacklog, mutateBoth } from "../lib/store.mjs";
36
+ import {
37
+ normalizeLinearConfig, effectiveGranularity, effectiveVerbosity, linearState, checkPiOverrideAck,
38
+ resolvePushState, resolveProjectStatus, pullStatusFor, priorityToLinear, LINEAR_TO_PRIORITY,
39
+ issueDescription, machineFooter, buildPushPlan, buildPullProposals, validateLinearConfig, holdsFor,
40
+ desiredLabels, projectDescription, projectSubtitleRaw, projectName, projectContent, normalizeLinearMarkdown, withinHistory, staleKeys,
41
+ projectColorFor, projectIconFor, MARKER_LABEL, PLATE_LABEL, LINEAR_PROJECT_NAME_MAX, LINEAR_PROJECT_DESC_MAX,
42
+ initiativePlan, initiativeStyle, startStampTargets, milestonePlan, HELD_STATUSES, cyclePlan,
43
+ provisionPlan, manualViewChecklist, dispatchGuidance, STANDARD_VIEWS,
44
+ } from "../lib/linear-core.mjs";
45
+ import { platedKeys, plateDrainKeys, setPlateDoc, validatePlate } from "../lib/plate-core.mjs";
46
+ import { addPi, setPlate, addPlate, removePlate } from "../lib/mcp-core.mjs";
47
+ import { runSync, runProvision, syncInitiatives, syncMilestones, readCursor, runNote, runNotes, runProjectUpdate } from "../linear.mjs";
48
+ import { noteBody, sliceForBranch, gitSnapshot, autoPostPlan } from "../lib/journal-core.mjs";
49
+ import { runDispatch, runFanCloud, resolveRoutine, fireRoutine, routineEndpoint } from "../dispatch.mjs";
50
+ import { electionPlan, outOfCycle } from "../lib/cycle-core.mjs";
51
+ import { runCyclePlan, runCycleLock } from "../cycle.mjs";
52
+ import { readReadyWave } from "../lib/mcp-core.mjs";
53
+ import { loadGraph } from "../lib/graph.mjs";
54
+ import { graphDiff, backlogDiff, reviewDigest, pisInFlight } from "../lib/review-core.mjs";
55
+ import { parseDocument } from "yaml";
56
+ import { join, resolve } from "node:path";
57
+ import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from "node:fs";
58
+ import { tmpdir } from "node:os";
59
+ import { spawnSync } from "node:child_process";
60
+
61
+ let passed = 0, failed = 0;
62
+ const pending = []; // async tests settle before the summary (see the await at the bottom)
63
+ function test(name, fn) {
64
+ try {
65
+ const r = fn();
66
+ if (r && typeof r.then === "function") {
67
+ // an async test that threw would otherwise count as a vacuous pass — await it
68
+ pending.push(r.then(
69
+ () => { passed++; console.log(` ✓ ${name}`); },
70
+ (e) => { failed++; console.error(` ✗ ${name}\n ${e.message}`); },
71
+ ));
72
+ return;
73
+ }
74
+ passed++; console.log(` ✓ ${name}`);
75
+ }
76
+ catch (e) { failed++; console.error(` ✗ ${name}\n ${e.message}`); }
77
+ }
78
+ function eq(actual, expected, msg) {
79
+ const a = JSON.stringify(actual), b = JSON.stringify(expected);
80
+ if (a !== b) throw new Error(`${msg || "not equal"} — got ${a}, expected ${b}`);
81
+ }
82
+ function ok(cond, msg) { if (!cond) throw new Error(msg || "expected truthy"); }
83
+ function throws(fn, match, msg) {
84
+ try { fn(); } catch (e) {
85
+ if (match && !e.message.includes(match)) throw new Error(`${msg}: wrong error "${e.message}" (wanted "${match}")`);
86
+ return;
87
+ }
88
+ throw new Error(msg || "expected a throw");
89
+ }
90
+
91
+ const sp = (id, o = {}) => ({ id, title: id, invoke: o.invoke || id, status: o.status || "next", ...o });
92
+
93
+ // ── dependency resolution ──────────────────────────────────────────────────
94
+ // WHY: a slice's deps decide when it becomes runnable. If sibling/PI/qualified
95
+ // forms don't resolve, the scheduler launches work before its prerequisites exist.
96
+ test("flatten resolves sibling, fully-qualified, and PI-id deps", () => {
97
+ const g = { pis: [
98
+ { id: "a", title: "A", status: "active", sprints: [
99
+ sp("s1", { status: "complete" }),
100
+ sp("s2", { deps: ["s1"] }), // sibling
101
+ sp("s3", { deps: ["a/s1"] }), // fully-qualified
102
+ ]},
103
+ { id: "b", title: "B", status: "next", sprints: [
104
+ sp("b1", { deps: ["a"] }), // whole-PI dep
105
+ ]},
106
+ ]};
107
+ const m = flatten(g);
108
+ const s2 = m.nodes.find((n) => n.id === "s2");
109
+ const s3 = m.nodes.find((n) => n.id === "s3");
110
+ const b1 = m.nodes.find((n) => n.id === "b1");
111
+ eq(s2.deps, ["a/s1"], "sibling dep");
112
+ eq(s3.deps, ["a/s1"], "qualified dep");
113
+ eq(b1.piDeps, ["a"], "PI dep");
114
+ });
115
+
116
+ // WHY: invoke keys are the /slice launch keys; a duplicate means two slices answer
117
+ // the same command and the fanout launches the wrong worktree.
118
+ test("flatten rejects duplicate invoke keys", () => {
119
+ const g = { pis: [{ id: "a", title: "A", status: "active", sprints: [
120
+ sp("s1", { invoke: "dup" }), sp("s2", { invoke: "dup" }),
121
+ ]}]};
122
+ throws(() => flatten(g), "duplicate invoke", "should reject dup invoke");
123
+ });
124
+
125
+ // WHY: a typo'd dep that silently resolves to nothing would let a gated/unbuilt
126
+ // prerequisite be treated as satisfied. Unresolved deps must be a hard error.
127
+ test("flatten rejects an unresolvable dep", () => {
128
+ const g = { pis: [{ id: "a", title: "A", status: "active", sprints: [
129
+ sp("s1", { deps: ["nope"] }),
130
+ ]}]};
131
+ throws(() => flatten(g), "matches no", "should reject unknown dep");
132
+ });
133
+
134
+ // ── cycle detection ─────────────────────────────────────────────────────────
135
+ // WHY: a dependency cycle is un-runnable; without detection the scheduler would
136
+ // loop or silently drop the cycle, hiding a broken roadmap.
137
+ test("detectCycle finds a 2-node cycle and clears an acyclic graph", () => {
138
+ const cyclic = flatten({ pis: [{ id: "a", title: "A", status: "active", sprints: [
139
+ sp("s1", { deps: ["s2"] }), sp("s2", { deps: ["s1"] }),
140
+ ]}]});
141
+ ok(detectCycle(cyclic), "should detect cycle");
142
+ const acyclic = flatten({ pis: [{ id: "a", title: "A", status: "active", sprints: [
143
+ sp("s1", {}), sp("s2", { deps: ["s1"] }),
144
+ ]}]});
145
+ eq(detectCycle(acyclic), null, "acyclic should be null");
146
+ });
147
+
148
+ // WHY: computeWaves must refuse to plan a cyclic graph rather than emit a bogus order.
149
+ test("computeWaves throws on a cycle", () => {
150
+ const m = flatten({ pis: [{ id: "a", title: "A", status: "active", sprints: [
151
+ sp("s1", { deps: ["s2"] }), sp("s2", { deps: ["s1"] }),
152
+ ]}]});
153
+ throws(() => computeWaves(m, 3), "cycle", "should throw on cycle");
154
+ });
155
+
156
+ // ── wave scheduling ─────────────────────────────────────────────────────────
157
+ // WHY: two sprints that write the same file MUST NOT run in the same wave, or the
158
+ // parallel sessions corrupt each other's checkout — the core two-wave invariant.
159
+ test("computeWaves defers shared-file contention to a later wave", () => {
160
+ const m = flatten({ pis: [{ id: "a", title: "A", status: "active", sprints: [
161
+ sp("s1", { status: "active", est_sessions: 1, touches: ["F.cs"] }),
162
+ sp("s2", { status: "active", est_sessions: 1, touches: ["F.cs"] }),
163
+ ]}]});
164
+ const { waves } = computeWaves(m, 3);
165
+ eq(waves.length, 2, "shared file → 2 waves");
166
+ eq(waves[0].length, 1, "one per wave");
167
+ });
168
+
169
+ // WHY: disjoint, independent slices should fan out together up to the cap — that's
170
+ // the whole point of the tool; under-parallelizing wastes the user's concurrency.
171
+ test("computeWaves runs disjoint slices together and respects the cap", () => {
172
+ const mk = (id) => sp(id, { status: "active", est_sessions: 1, touches: [`${id}.cs`] });
173
+ const m = flatten({ pis: [{ id: "a", title: "A", status: "active", sprints: [mk("s1"), mk("s2"), mk("s3")] }]});
174
+ eq(computeWaves(m, 3).waves[0].length, 3, "cap 3 → all 3 in wave 1");
175
+ eq(computeWaves(m, 2).waves[0].length, 2, "cap 2 → 2 in wave 1");
176
+ });
177
+
178
+ // WHY: a slice must wait for its dependency to (optimistically) complete; launching
179
+ // a dependent early is exactly the failure deps exist to prevent.
180
+ test("computeWaves orders a dependent after its dep", () => {
181
+ const m = flatten({ pis: [{ id: "a", title: "A", status: "active", sprints: [
182
+ sp("s1", { status: "active", est_sessions: 1, touches: ["x.cs"] }),
183
+ sp("s2", { status: "active", est_sessions: 1, deps: ["s1"], touches: ["y.cs"] }),
184
+ ]}]});
185
+ const { waves } = computeWaves(m, 5);
186
+ eq(waves[0].map((n) => n.id), ["s1"], "s1 first");
187
+ eq(waves[1].map((n) => n.id), ["s2"], "s2 second");
188
+ });
189
+
190
+ // WHY: a human-gated step (signing prereqs, live payment smoke) must never be
191
+ // auto-launched; it belongs in held-on-human, and its downstream cone stays parked.
192
+ test("computeWaves holds gated_on nodes and never schedules them", () => {
193
+ const m = flatten({ pis: [{ id: "a", title: "A", status: "gated", sprints: [
194
+ sp("s0", { status: "gated", gated_on: "Connor", est_sessions: 0 }),
195
+ sp("s1", { status: "scheduled", est_sessions: 1, deps: ["s0"], touches: ["z.cs"] }),
196
+ ]}]});
197
+ const { waves, held } = computeWaves(m, 3);
198
+ eq(waves.length, 0, "nothing runnable");
199
+ eq(held.onHuman.map((n) => n.id), ["s0"], "s0 held on human");
200
+ eq(held.blocked.map((n) => n.id), ["s1"], "s1 blocked behind the gate");
201
+ });
202
+
203
+ // ── derived views ───────────────────────────────────────────────────────────
204
+ // WHY: the exec-plan line is the human-facing parallelization recommendation; it
205
+ // must reflect REMAINING work (exclude done) and group independent sprints as parallel.
206
+ test("execPlan shows remaining work with parallel grouping", () => {
207
+ const pi = { sprints: [
208
+ sp("s1", { status: "complete" }),
209
+ sp("s2", { status: "active", deps: ["s1"] }),
210
+ sp("s3", { status: "next" }),
211
+ sp("s4", { status: "next", deps: ["s2", "s3"] }),
212
+ ]};
213
+ // remaining = s2,s3,s4; s2&s3 are level 0 (s2's only dep s1 is done/excluded), s4 after both
214
+ eq(execPlan(pi), "(S2 ∥ S3)→S4", "remaining exec plan");
215
+ });
216
+
217
+ // WHY: "sessions remaining" is the at-a-glance PI burn-down; it must sum only the
218
+ // not-done sprints or the user can't gauge what's left.
219
+ test("sessionsRemaining sums only not-complete sprints", () => {
220
+ const pi = { sprints: [
221
+ sp("s1", { status: "complete", est_sessions: 5 }),
222
+ sp("s2", { status: "active", est_sessions: 3 }),
223
+ sp("s3", { status: "next", est_sessions: 2 }),
224
+ ]};
225
+ eq(sessionsRemaining(pi), 5, "3+2, s1 excluded");
226
+ });
227
+
228
+ // WHY: a sprint's gate is the acceptance bar the autonomous session must pass; the
229
+ // {{default}} token must interpolate the program-wide gate, not leak literally.
230
+ test("resolveGate interpolates {{default}} and passes plain strings", () => {
231
+ const graph = { meta: { default_gate: "BUILD" } };
232
+ eq(resolveGate({ gate: "default" }, graph), "BUILD", "default → meta gate");
233
+ eq(resolveGate({ gate: "{{default}}\nPLUS x" }, graph), "BUILD\nPLUS x", "interpolated");
234
+ eq(resolveGate({ gate: "custom only" }, graph), "custom only", "plain passthrough");
235
+ });
236
+
237
+ // ── concurrency recommender ─────────────────────────────────────────────────
238
+ const recoGraph = {
239
+ meta: { default_gate: "dotnet test", branch_convention: "{pi}/{sprint}", worktree_root: "/home/c" },
240
+ pis: [{ id: "a", title: "A", status: "active", sprints: [
241
+ sp("s1", { status: "active", gate: "default", touches: ["x.cs"] }), // heavy (dotnet test)
242
+ sp("s2", { status: "active", gate: "docs only", touches: ["docs/y.md"] }),// light (docs)
243
+ sp("s3", { status: "active", gate: "dotnet build" }), // medium (a build, not a test run)
244
+ sp("s4", { status: "next", weight: "light", gate: "default" }), // explicit override
245
+ ]}],
246
+ };
247
+ const recoModel = flatten(recoGraph);
248
+ const recoReady = readyNodes(recoModel);
249
+
250
+ // WHY: weight classification drives the resource budget; a docs slice mis-tagged heavy
251
+ // would shrink the recommended cap for no reason, and a dotnet-test slice tagged light
252
+ // would over-subscribe RAM and thrash the machine.
253
+ test("nodeWeight classifies by gate + touches, with explicit override winning", () => {
254
+ const byId = (id) => recoModel.nodes.find((n) => n.id === id);
255
+ eq(nodeWeight(byId("s1"), recoGraph), "heavy", "dotnet-test gate → heavy");
256
+ eq(nodeWeight(byId("s2"), recoGraph), "light", "docs-only touches → light");
257
+ eq(nodeWeight(byId("s3"), recoGraph), "medium", "build gate → medium");
258
+ eq(nodeWeight(byId("s4"), recoGraph), "light", "explicit weight override wins");
259
+ });
260
+
261
+ // WHY: the whole feature is "don't over-subscribe the machine OR the human." Each ceiling
262
+ // must actually bind when it's the smallest — a recommender that ignores tiny RAM would
263
+ // thrash; one that ignores the review ceiling would bury the user in PRs.
264
+ test("recommendConcurrency takes the MIN ceiling and reports which binds", () => {
265
+ // Tiny RAM → RAM binds.
266
+ const tiny = recommendConcurrency(recoReady, recoGraph, { sys: { cores: 64, totalGb: 8, freeGb: 8, platform: "linux" } });
267
+ ok(tiny.binding.why.startsWith("RAM"), `expected RAM-bound, got ${tiny.binding.why}`);
268
+ ok(tiny.recommended >= 1, "at least 1");
269
+ // Big machine, few slices → work binds (only ~4 ready).
270
+ const big = recommendConcurrency(recoReady, recoGraph, { sys: { cores: 64, totalGb: 256, freeGb: 256, platform: "linux" }, reviewCeiling: 50 });
271
+ ok(big.binding.why.startsWith("work"), `expected work-bound, got ${big.binding.why}`);
272
+ eq(big.recommended, recoReady.length, "work cap = ready count");
273
+ // Big machine, lots of work → the review ceiling protects the human.
274
+ const reviewBound = recommendConcurrency(recoReady, recoGraph, { sys: { cores: 64, totalGb: 256, freeGb: 256, platform: "linux" }, reviewCeiling: 2 });
275
+ eq(reviewBound.recommended, 2, "review ceiling binds");
276
+ });
277
+
278
+ // ── kickoff brief ───────────────────────────────────────────────────────────
279
+ // WHY: a launched session "just starts" from this brief; if it omits the gate, the
280
+ // branch, or the DO-NOT-MERGE rule, an autonomous worker could merge its own PR or
281
+ // skip verification — the exact failures the handoff contract exists to prevent.
282
+ test("synthesizeBrief carries gate, branch, read-order, and DO-NOT-MERGE", () => {
283
+ const briefGraph = {
284
+ meta: { default_gate: "GATECMD", branch_convention: "{pi}/{sprint}", worktree_root: "/home/c" },
285
+ pis: [{ id: "pi", title: "PI", status: "active", sprints: [
286
+ sp("s1", { status: "active", gate: "default", invoke: "demo", read_order: ["read X first"], resume_action: "do the thing" }),
287
+ ]}],
288
+ };
289
+ const m = flatten(briefGraph);
290
+ const node = m.nodes[0];
291
+ eq(branchFor(node, briefGraph), "pi/s1", "branch convention");
292
+ eq(worktreeFor(node, briefGraph), "/home/c/pi-s1", "worktree path");
293
+ const b = synthesizeBrief(node, briefGraph);
294
+ ok(/GATECMD/.test(b), "brief includes resolved gate");
295
+ ok(/pi\/s1/.test(b), "brief includes branch");
296
+ ok(/read X first/.test(b), "brief includes read-order");
297
+ ok(/do the thing/.test(b), "brief includes next action");
298
+ ok(/NOT.{0,4}merge/i.test(b), "brief forbids merging");
299
+ });
300
+
301
+ // WHY: an inline/path kickoff_brief must pass through untouched — the author opted out
302
+ // of synthesis on purpose; silently regenerating would discard their custom brief.
303
+ test("synthesizeBrief passes through an explicit kickoff_brief", () => {
304
+ const g = { meta: {}, pis: [{ id: "pi", title: "P", status: "active", sprints: [
305
+ sp("s1", { status: "active", invoke: "x", kickoff_brief: "CUSTOM BRIEF" }),
306
+ ]}]};
307
+ eq(synthesizeBrief(flatten(g).nodes[0], g), "CUSTOM BRIEF", "explicit brief passthrough");
308
+ });
309
+
310
+ // ── generalization: multi-ecosystem classification + meta overrides ──────────
311
+ // WHY: the plugin is global — it must classify Rust/Python/Go/etc. work, not just
312
+ // .NET/JS. A Cargo or pytest suite mis-classified as light would over-subscribe RAM
313
+ // on a non-.NET repo and thrash it.
314
+ test("nodeWeight recognizes non-.NET runners (cargo/pytest/go)", () => {
315
+ const g = { meta: {}, pis: [{ id: "r", title: "R", status: "active", sprints: [
316
+ sp("a", { gate: "cargo test --all", touches: ["src/lib.rs"] }),
317
+ sp("b", { gate: "pytest -q", touches: ["app.py"] }),
318
+ sp("c", { gate: "go build ./...", touches: ["main.go"] }),
319
+ sp("d", { gate: "cd gui && vitest run", touches: ["x.ts"] }),
320
+ ]}]};
321
+ const m = flatten(g);
322
+ eq(nodeWeight(m.nodes[0], g), "heavy", "cargo test → heavy");
323
+ eq(nodeWeight(m.nodes[1], g), "heavy", "pytest → heavy");
324
+ eq(nodeWeight(m.nodes[2], g), "medium", "go build → medium");
325
+ eq(nodeWeight(m.nodes[3], g), "heavy", "vitest run (a full test-suite run) → heavy");
326
+ });
327
+
328
+ // WHY: the popular stacks (Java via Maven, C/C++ via CMake) must size correctly out of
329
+ // the box; a 'mvn verify' mis-read as light over-subscribes RAM, and a 'cmake --build'
330
+ // dismissed as free under-uses the machine. (Guards the classifier's breadth claim.)
331
+ test("nodeWeight sizes Maven (heavy) and CMake (medium) runners", () => {
332
+ const g = { meta: {}, pis: [{ id: "j", title: "J", status: "active", sprints: [
333
+ sp("a", { gate: "mvn verify", touches: ["src/Main.java"] }),
334
+ sp("b", { gate: "cmake --build build", touches: ["src/main.cpp"] }),
335
+ ]}]};
336
+ const m = flatten(g);
337
+ eq(nodeWeight(m.nodes[0], g), "heavy", "mvn verify → heavy");
338
+ eq(nodeWeight(m.nodes[1], g), "medium", "cmake --build → medium");
339
+ });
340
+
341
+ // WHY: a repo with a bespoke runner must be able to teach the classifier without
342
+ // forking the plugin — otherwise "portable" is a lie for anyone off the beaten path.
343
+ test("meta.weight_patterns extends the classifier", () => {
344
+ const g = { meta: { weight_patterns: { heavy: ["bespoke-suite"] } }, pis: [{ id: "x", title: "X", status: "active",
345
+ sprints: [sp("a", { gate: "run bespoke-suite now", touches: ["x.foo"] })] }]};
346
+ eq(nodeWeight(flatten(g).nodes[0], g), "heavy", "custom heavy pattern applied");
347
+ });
348
+
349
+ // WHY: an unrecognized runner that still touches code shouldn't be dismissed as free;
350
+ // floor it at medium so the recommender leaves headroom.
351
+ test("nodeWeight floors unknown-runner code work at medium, docs at light", () => {
352
+ const g = { meta: {}, pis: [{ id: "x", title: "X", status: "active", sprints: [
353
+ sp("a", { gate: "weirdtool ./...", touches: ["thing.zig"] }),
354
+ sp("b", { gate: "weirdtool", touches: ["README.md"] }),
355
+ ]}]};
356
+ const m = flatten(g);
357
+ eq(nodeWeight(m.nodes[0], g), "medium", "unknown runner + code touch → medium");
358
+ eq(nodeWeight(m.nodes[1], g), "light", "docs-only → light");
359
+ });
360
+
361
+ // WHY: base branch + remote are hardcodable footguns; a repo on 'master' or a fork
362
+ // remote must get correct worktree base refs + PR bases, or every launched session
363
+ // branches off the wrong commit.
364
+ test("base branch / remote default to main/origin and honor meta overrides", () => {
365
+ const def = { meta: {} };
366
+ eq(baseRefOf(def), "origin/main", "default base ref");
367
+ const over = { meta: { remote: "upstream", base_branch: "develop" } };
368
+ eq(remoteOf(over), "upstream", "remote override");
369
+ eq(baseBranchOf(over), "develop", "base branch override");
370
+ eq(baseRefOf(over), "upstream/develop", "composed base ref");
371
+ });
372
+
373
+ // ── CLI dispatcher core ──────────────────────────────────────────────────────
374
+ // WHY: the `roadmap` command is the daily entry point. If routing regresses, bare
375
+ // `roadmap` stops defaulting to plan, or `roadmap --cap 3` is read as a command — the
376
+ // tool silently does the wrong thing from the shell.
377
+ test("route: bare → plan, leading flag → plan, -h → help, word → that command", () => {
378
+ eq(route([]), { cmd: "plan", rest: [] }, "bare → plan");
379
+ eq(route(["--cap", "3"]), { cmd: "plan", rest: ["--cap", "3"] }, "leading flag → plan + flags");
380
+ eq(route(["-h"]), { cmd: "help", rest: [] }, "-h → help");
381
+ eq(route(["--help"]), { cmd: "help", rest: [] }, "--help → help");
382
+ eq(route(["fan", "--wave", "1"]), { cmd: "fan", rest: ["--wave", "1"] }, "subcommand + rest");
383
+ });
384
+
385
+ // WHY: classify decides what actually runs; a built command misrouted to 'unknown'
386
+ // breaks the CLI, and a P4 stub misrouted to 'run' would spawn a nonexistent script.
387
+ test("classify: maps built commands, flags P4 stubs, rejects unknown", () => {
388
+ eq(classify("plan").kind, "run", "plan runs");
389
+ eq(classify("plan").script, "scheduler.mjs", "plan → scheduler");
390
+ eq(classify("fan").script, "fanout.mjs", "fan → fanout");
391
+ eq(classify("validate").script, "validate.mjs", "validate → validate");
392
+ eq(classify("sync"), { kind: "notyet", phase: "P4" }, "sync is P4");
393
+ eq(classify("bogus").kind, "unknown", "unknown command");
394
+ eq(classify("help").kind, "help", "help");
395
+ });
396
+
397
+ // WHY: validate.mjs takes a POSITIONAL path while the others take --in; if buildArgs
398
+ // gets this wrong, `roadmap validate` either checks nothing or errors on a stray flag.
399
+ test("buildArgs: injects the positional path only for validate-without-one", () => {
400
+ eq(buildArgs("validate", [], "R.yaml"), ["R.yaml"], "validate w/o positional → inject");
401
+ eq(buildArgs("validate", ["--quiet"], "R.yaml"), ["R.yaml", "--quiet"], "flags-only still injects");
402
+ eq(buildArgs("validate", ["other.yaml"], "R.yaml"), ["other.yaml"], "explicit positional preserved");
403
+ eq(buildArgs("plan", ["--cap", "3"], "R.yaml"), ["--cap", "3"], "non-validate passes rest through");
404
+ });
405
+
406
+ // WHY: upward discovery is what lets you run `roadmap` from any subdir; if it stops
407
+ // walking or never terminates, the CLI fails at repo root or hangs.
408
+ test("findRepoRoot walks up to the dir holding the roadmap, else null", () => {
409
+ const target = resolve("/a/b"); // resolve() to match findRepoRoot's own resolve (drive-correct on Windows)
410
+ const exists = (p) => p === join(target, ...REL);
411
+ eq(findRepoRoot(resolve("/a/b/c/d"), exists), target, "found by walking up");
412
+ eq(findRepoRoot(resolve("/x/y"), () => false), null, "none anywhere → null (terminates at fs root)");
413
+ });
414
+
415
+ // WHY: the not-found path is a teaching moment, not a dead end — it must name WHERE the
416
+ // file goes and how to start one, or a new user is stuck. (The user asked for this.)
417
+ test("missingRoadmapHelp names the path, the cwd, and a starter", () => {
418
+ const h = missingRoadmapHelp("/some/where");
419
+ ok(h.includes(REL.join("/")), "names docs/roadmap/roadmap.yaml");
420
+ ok(h.includes("/some/where"), "echoes the cwd it searched from");
421
+ ok(/repo-root/.test(h), "says it goes at the repo root");
422
+ ok(/schema_version/.test(h), "includes a starter snippet");
423
+ });
424
+
425
+ // ── fanout launch decision ───────────────────────────────────────────────────
426
+ // WHY: launch is the DEFAULT (low-risk interactive); the only dangerous mode (headless
427
+ // autonomous commit/push/PR) must stay behind a double-ack. If this regresses, a bare
428
+ // `roadmap fan` could fire autonomous workers, or --dry could accidentally spawn.
429
+ test("launchDecision: launch by default, --dry/--out preview, autonomous double-acked", () => {
430
+ eq(launchDecision({}), { spawn: true, mode: "interactive" }, "bare → launch interactive");
431
+ eq(launchDecision({ dry: true }).spawn, false, "--dry → no spawn");
432
+ eq(launchDecision({ out: "x.sh" }).spawn, false, "--out → no spawn (wrote script)");
433
+ eq(launchDecision({ autonomous: true }), { spawn: false, mode: "autonomous-needs-ack" }, "autonomous w/o ack → held");
434
+ eq(launchDecision({ autonomous: true, okAutonomous: true }), { spawn: true, mode: "autonomous" }, "autonomous + ack → launch");
435
+ });
436
+
437
+ // WHY: a portable install must never surprise-start an agent; local authorization is the
438
+ // boundary between a useful fanout script and an unintended autonomous coding session.
439
+ test("assistant profiles default to manual and require local launch authorization", () => {
440
+ const graph = { meta: { assistants: { default: "manual" } } };
441
+ eq(resolveProfile(graph, {}, null).name, "manual", "manual is the safe default");
442
+ eq(launchDecisionForProfile(resolveProfile(graph, {}, null), { requestedLaunch: false }), { spawn: false, mode: "manual" }, "manual only emits handoffs");
443
+ const codex = resolveProfile(graph, { assistants: { codex: { launch: true, command: "codex {prompt}" } } }, "codex");
444
+ ok(launchDecisionForProfile(codex, { requestedLaunch: true }).spawn, "explicitly authorized local profile may launch");
445
+ eq(commandFor(codex, { prompt: "read brief" }), 'codex "read brief"', "template expands a quoted prompt");
446
+ throws(() => safeConfig({ token: "not-allowed" }), "credential", "local assistant config rejects secret-like fields");
447
+ ok(configuredProfiles(graph, {}).profiles.claude, "built-in profiles are discoverable");
448
+ });
449
+
450
+ // ── short flags + self-contained worker prompt ──────────────────────────────
451
+ // WHY: `-w 1 -c 2 -t warp` must expand to the long flags the scripts understand, and
452
+ // positionals/long flags must pass through untouched, or the CLI silently drops options.
453
+ test("expandShort maps single-letter flags and leaves the rest alone", () => {
454
+ eq(expandShort(["-w", "1", "-c", "2", "-t", "warp"]), ["--wave", "1", "--cap", "2", "--term", "warp"], "short → long");
455
+ eq(expandShort(["--wave", "1", "auth-sessions"]), ["--wave", "1", "auth-sessions"], "long + positional untouched");
456
+ eq(expandShort(["-r", "-f"]), ["--remove", "--force"], "cleanup shorts");
457
+ eq(expandShort(["-lc", "-wm", "acceptEdits"]), ["--lead-claude", "--worker-mode", "acceptEdits"], "multi-letter aliases");
458
+ });
459
+
460
+ // WHY: spawned worker sessions don't have the /slice skill (plugin not installed; worktrees
461
+ // don't carry gitignored skills). The prompt MUST be self-contained — read the kickoff brief —
462
+ // or every worker errors 'Unknown command: /slice' and does nothing. (Regression guard.)
463
+ test("launchPrompt is self-contained, steers to plan-then-wait, and is wt-safe", () => {
464
+ const p = launchPrompt({ invoke: "x" });
465
+ ok(/\.kickoff\.md/.test(p), "references the kickoff brief");
466
+ ok(!/\/slice/.test(p), "does NOT depend on the /slice command");
467
+ ok(/NOT.{0,4}merge/i.test(p), "still forbids merging");
468
+ ok(/plan/i.test(p) && /approv/i.test(p), "steers to present a plan and wait for approval");
469
+ ok(!p.includes(";"), "no ';' — it's wt's tab delimiter and would spawn bogus tabs");
470
+ });
471
+
472
+ // ── interactive console core ──────────────────────────────────────────────────
473
+ // WHY: bare `roadmap` opens the wizard, but `roadmap go` must force it (e.g. when a shell shim
474
+ // hides the TTY); if it doesn't route to wizard.mjs the explicit escape hatch is dead.
475
+ test("classify routes the interactive wizard commands", () => {
476
+ eq(classify("go").script, "wizard.mjs", "go → wizard");
477
+ eq(classify("wizard").script, "wizard.mjs", "wizard → wizard");
478
+ });
479
+
480
+ // WHY: the wizard's default terminal must match the platform or the very first Enter launches the
481
+ // wrong adapter (wt on Windows, tmux elsewhere); the other adapters must still be offered.
482
+ test("terminalChoices puts the platform default first and offers all adapters", () => {
483
+ eq(terminalChoices("win32")[0], "wt", "windows → wt first");
484
+ eq(terminalChoices("linux")[0], "tmux", "linux → tmux first");
485
+ eq(terminalChoices("darwin")[0], "tmux", "mac → tmux first");
486
+ const win = terminalChoices("win32");
487
+ ok(win.includes("warp") && win.includes("print") && win.includes("background"), "all adapters offered");
488
+ });
489
+
490
+ // WHY: arrow navigation must wrap, or the user hits a dead end at a list edge and thinks the UI froze.
491
+ test("moveSelection wraps at both ends and ignores unrelated keys", () => {
492
+ eq(moveSelection(0, "up", 3), 2, "up from top wraps to bottom");
493
+ eq(moveSelection(2, "down", 3), 0, "down from bottom wraps to top");
494
+ eq(moveSelection(1, "down", 3), 2, "down moves");
495
+ eq(moveSelection(1, "x", 3), 1, "unrelated key → no move");
496
+ });
497
+
498
+ // WHY: the cap field feeds the scheduler; a blank must take the recommended default, and garbage
499
+ // or out-of-range input must be rejected — not silently coerced into a thrashing or no-op cap.
500
+ test("parseCap defaults on blank and rejects non-numeric / out-of-range", () => {
501
+ eq(parseCap("", { min: 1, max: 5, def: 3 }), { value: 3 }, "blank → default");
502
+ eq(parseCap("2", { min: 1, max: 5, def: 3 }), { value: 2 }, "valid");
503
+ ok(parseCap("9", { min: 1, max: 5, def: 3 }).error, "above max → error");
504
+ ok(parseCap("0", { min: 1, max: 5, def: 3 }).error, "below min → error");
505
+ ok(parseCap("abc", { min: 1, max: 5, def: 3 }).error, "non-numeric → error");
506
+ });
507
+
508
+ // WHY: the wizard's choices must translate to the EXACT fanout flags, or "Preview" would launch for
509
+ // real, "Save" wouldn't write a script, and the lead/term/cap/wave selections would be dropped.
510
+ test("buildFanArgs maps each action + the lead toggle to fanout flags", () => {
511
+ eq(buildFanArgs({ term: "wt", cap: 2, wave: 1, lead: false, mode: "launch" }),
512
+ ["--term", "wt", "--cap", "2", "--wave", "1"], "launch → no extra flag");
513
+ eq(buildFanArgs({ term: "tmux", cap: 3, wave: 2, lead: true, mode: "dry" }),
514
+ ["--term", "tmux", "--cap", "3", "--wave", "2", "--lead-claude", "--dry"], "lead + preview");
515
+ eq(buildFanArgs({ term: "wt", cap: 1, wave: 1, lead: false, mode: "save", outName: "wave1.ps1" }),
516
+ ["--term", "wt", "--cap", "1", "--wave", "1", "--out", "wave1.ps1"], "save → --out");
517
+ });
518
+
519
+ // WHY: a saved script must carry the right extension for the shell it targets, or running a .sh in
520
+ // PowerShell (or a .ps1 in bash) silently fails.
521
+ test("autoOutName picks ps1 for wt/warp and sh otherwise", () => {
522
+ eq(autoOutName("wt", 1), "wave1.ps1", "wt → ps1");
523
+ eq(autoOutName("warp", 2), "wave2.ps1", "warp → ps1");
524
+ eq(autoOutName("tmux", 3), "wave3.sh", "tmux → sh");
525
+ eq(autoOutName("print", 1), "wave1.sh", "print → sh");
526
+ });
527
+
528
+ // ── MCP brain: tool registry + comment-preserving mutations + integrity gate ────
529
+ const MCP_FIX = `meta:
530
+ schema_version: 1
531
+ program: TEST
532
+ default_gate: npm test
533
+ pis:
534
+ - id: auth # the auth epic
535
+ title: Auth
536
+ status: active
537
+ sprints:
538
+ - id: s1
539
+ title: Login
540
+ status: complete
541
+ invoke: auth-login
542
+ prs: ["#1"]
543
+ - id: s2
544
+ title: Sessions
545
+ status: active
546
+ invoke: auth-sessions
547
+ deps: [s1]
548
+ `;
549
+
550
+ // WHY: the registry is the contract Claude sees; a tool missing a name/description/inputSchema
551
+ // is invisible or uncallable, so the whole MCP surface must stay well-formed.
552
+ test("TOOLS registry is well-formed and includes the key read + mutate tools", () => {
553
+ ok(Array.isArray(TOOLS) && TOOLS.length >= 9, "at least 9 tools");
554
+ ok(TOOLS.every((t) => t.name && t.description && t.inputSchema && t.inputSchema.type === "object"), "each tool well-formed");
555
+ for (const n of ["plan", "show", "validate", "add_sprint", "set_status", "prune"]) {
556
+ ok(TOOLS.some((t) => t.name === n), `tool ${n} present`);
557
+ }
558
+ });
559
+
560
+ // WHY: the entire reason to mutate via the Document API (not YAML.parse + re-dump) is to keep the
561
+ // human's comments. If add_sprint drops them, the roadmap's authored context is silently destroyed.
562
+ test("add_sprint appends the node AND preserves existing comments", () => {
563
+ const doc = parseDocument(MCP_FIX);
564
+ addSprint(doc, { pi: "auth", id: "s3", title: "Logout", invoke: "auth-logout", status: "next", deps: ["s2"] });
565
+ const out = doc.toString();
566
+ ok(out.includes("# the auth epic"), "inline comment survived the edit");
567
+ ok(/invoke: auth-logout/.test(out), "new sprint serialized");
568
+ const g = validateDocOrThrow(doc);
569
+ eq(g.pis[0].sprints.length, 3, "three sprints now");
570
+ });
571
+
572
+ // WHY: the write gate exists so a bad edit never lands. A duplicate invoke key would make two
573
+ // slices answer the same /slice command; it must be rejected before the file is written.
574
+ test("validateDocOrThrow rejects a duplicate invoke key", () => {
575
+ const doc = parseDocument(MCP_FIX);
576
+ addSprint(doc, { pi: "auth", id: "s3", title: "Dup", invoke: "auth-login" });
577
+ throws(() => validateDocOrThrow(doc), "corrupt", "duplicate invoke must be rejected");
578
+ });
579
+
580
+ // WHY: a cyclic dependency is un-runnable; an edit that introduces one must be refused, not written
581
+ // and discovered later when the scheduler chokes.
582
+ test("validateDocOrThrow rejects an edit that forms a dependency cycle", () => {
583
+ const doc = parseDocument(MCP_FIX);
584
+ setFields(doc, { invoke: "auth-login", fields: { deps: ["s2"] } }); // s1->s2 while s2->s1
585
+ throws(() => validateDocOrThrow(doc), "cycle", "cycle must be rejected");
586
+ });
587
+
588
+ // WHY: set_status is the merge-time workhorse (flip to complete, record the PR). It must write all
589
+ // three fields, or the Recently-completed view and sessions-remaining rollup go wrong.
590
+ test("set_status records status + prs + completed_on", () => {
591
+ const doc = parseDocument(MCP_FIX);
592
+ setStatus(doc, { invoke: "auth-sessions", status: "complete", prs: ["#9"], completed_on: "2026-06-04" });
593
+ const sp = doc.toJS().pis[0].sprints.find((s) => s.invoke === "auth-sessions");
594
+ eq(sp.status, "complete", "status set");
595
+ eq(sp.prs, ["#9"], "prs set");
596
+ eq(sp.completed_on, "2026-06-04", "completed_on set");
597
+ });
598
+
599
+ // WHY: pruning is how the roadmap stays legible over time; scope='completed' must drop finished,
600
+ // undepended slices (and leave live ones), so the graph shrinks safely.
601
+ test("prune scope=completed removes finished slices and keeps live ones", () => {
602
+ const doc = parseDocument(`meta: {schema_version: 1, program: T}
603
+ pis:
604
+ - id: p
605
+ title: P
606
+ status: active
607
+ sprints:
608
+ - {id: s1, title: Done, status: complete, invoke: p-done, prs: ["#1"]}
609
+ - {id: s2, title: Live, status: active, invoke: p-active}
610
+ `);
611
+ const r = prune(doc, { scope: "completed" });
612
+ eq(r.pruned, ["p-done"], "reported the pruned slice");
613
+ const g = validateDocOrThrow(doc);
614
+ ok(!g.pis[0].sprints.some((s) => s.invoke === "p-done"), "completed slice gone");
615
+ ok(g.pis[0].sprints.some((s) => s.invoke === "p-active"), "live slice kept");
616
+ });
617
+
618
+ // WHY: the validate read tool is the agent's pre-flight; a clean roadmap must report ok=true so an
619
+ // agent can trust it before launching, and a real error must surface as ok=false.
620
+ test("readValidate reports ok on a clean graph", () => {
621
+ const r = readValidate(parseDocument(MCP_FIX).toJS());
622
+ ok(r.ok === true, "clean fixture validates");
623
+ eq(r.errors.length, 0, "no errors");
624
+ });
625
+
626
+ // ── PR-watch monitor brain ──────────────────────────────────────────────────
627
+ const pr = (o) => ({
628
+ number: o.n, title: o.t || "T", headRefName: o.b || "auth/s1",
629
+ state: o.state || "OPEN", isDraft: !!o.draft, mergeStateStatus: o.merge || "CLEAN", checks: o.checks || "none",
630
+ });
631
+
632
+ // WHY: the monitor's whole value is telling the lead the moment a PR is actionable. If a newly
633
+ // opened, check-green PR isn't surfaced as "ready to merge", the lead is back to polling by hand.
634
+ test("diffPrStates announces a new ready PR and a draft->ready transition", () => {
635
+ const newReady = diffPrStates({}, { 1: pr({ n: 1 }) });
636
+ eq(newReady.length, 1, "one event for the new PR");
637
+ ok(/ready to merge/.test(newReady[0].message), "phrased as ready to merge");
638
+ const promoted = diffPrStates({ 1: pr({ n: 1, draft: true }) }, { 1: pr({ n: 1, draft: false }) });
639
+ eq(promoted.length, 1, "draft->ready emits");
640
+ ok(/ready to merge/.test(promoted[0].message), "ready message");
641
+ });
642
+
643
+ // WHY: noise kills a notifier. If an unchanged poll re-emits, the lead learns to ignore the
644
+ // channel; only genuine phase changes (here, open->merged) may speak.
645
+ test("diffPrStates is silent on no change and speaks on open->merged", () => {
646
+ const same = { 1: pr({ n: 1 }) };
647
+ eq(diffPrStates(same, same).length, 0, "no change, no event");
648
+ const merged = diffPrStates({ 1: pr({ n: 1 }) }, { 1: pr({ n: 1, state: "MERGED" }) });
649
+ eq(merged.length, 1, "merge emits");
650
+ ok(/merged/.test(merged[0].message), "merged message");
651
+ });
652
+
653
+ // WHY: prPhase keys off the reduced `checks` value, so a wrong rollup->enum mapping would announce
654
+ // a PR with a failing or still-running check as "ready to merge" and mislead the lead into a bad merge.
655
+ test("checksOf reduces a statusCheckRollup to none/passing/pending/failing", () => {
656
+ eq(checksOf({ statusCheckRollup: [] }), "none", "no checks -> none");
657
+ eq(checksOf({ statusCheckRollup: [{ conclusion: "SUCCESS" }, { conclusion: "NEUTRAL" }] }), "passing", "all green -> passing");
658
+ eq(checksOf({ statusCheckRollup: [{ conclusion: "SUCCESS" }, { status: "IN_PROGRESS" }] }), "pending", "any in-progress -> pending");
659
+ eq(checksOf({ statusCheckRollup: [{ conclusion: "SUCCESS" }, { conclusion: "FAILURE" }] }), "failing", "any failure -> failing");
660
+ });
661
+
662
+ // WHY: the lead must hear about ITS wave, not every PR in the repo. A branch outside the roadmap's
663
+ // fanout naming must be filtered out, or the channel fills with unrelated noise.
664
+ test("matchesRoadmapBranches keeps roadmap fanout branches and drops the rest", () => {
665
+ const g = { meta: {}, pis: [{ id: "auth", title: "A", status: "active", sprints: [sp("s1", { invoke: "auth-login" })] }] };
666
+ ok(matchesRoadmapBranches("auth/s1", g), "a roadmap branch matches");
667
+ ok(!matchesRoadmapBranches("dependabot/npm/x", g), "an unrelated branch is dropped");
668
+ });
669
+
670
+ // ── reconcile detection (the agentic-sync trigger) ──────────────────────────
671
+ // WHY: a merged PR that isn't recorded leaves the roadmap stale (a slice shows open after it
672
+ // shipped). Detection must flag exactly the open slices whose branch merged, never re-flag a
673
+ // done slice (churn) or one without a merged PR (false positive that erodes trust in the nudge).
674
+ test("findUnrecordedMerges flags only open slices whose fanout branch merged", () => {
675
+ const g = { meta: {}, pis: [{ id: "auth", title: "A", status: "active", sprints: [
676
+ sp("s1", { status: "complete", invoke: "auth-login" }),
677
+ sp("s2", { status: "active", invoke: "auth-sessions" }),
678
+ sp("s3", { status: "active", invoke: "auth-logout" }),
679
+ ]}]};
680
+ const merged = [{ number: 42, headRefName: "auth/s2" }, { number: 7, headRefName: "auth/s1" }];
681
+ const found = findUnrecordedMerges(g, merged);
682
+ eq(found.map((u) => u.invoke), ["auth-sessions"], "only the open + merged slice (s1 done, s3 no PR)");
683
+ eq(found[0].pr, 42, "carries the PR number");
684
+ ok(reconcileNudge(found).includes("auth-sessions") && /set_status|sync/.test(reconcileNudge(found)), "nudge names the slice + the action");
685
+ eq(reconcileNudge([]), "", "silent when nothing is unrecorded");
686
+ });
687
+
688
+ // ── serializer fidelity (diff-minimal mutations) ────────────────────────────
689
+ // WHY: mutations write via serialize(); if it pads flow collections or re-wraps long scalars, every
690
+ // edit churns the whole hand-authored roadmap and the diff becomes unreviewable. It must keep
691
+ // comments, leave long scalars on one line, and not pad flow collections.
692
+ test("serialize keeps comments, leaves long scalars unwrapped, and does not pad flow collections", () => {
693
+ const long = "x".repeat(120);
694
+ const doc = parseDocument(`# header\nk:\n seq: ["#1", "#2"] # inline\n long: ${long}\n`);
695
+ const out = serialize(doc);
696
+ ok(out.includes("# header") && out.includes("# inline"), "comments preserved");
697
+ ok(out.includes('["#1", "#2"]') && !out.includes('[ "#1"'), "flow seq stays unpadded");
698
+ ok(out.includes(long), "120-char scalar not wrapped");
699
+ // idempotent: re-serializing its own output is a no-op (so post-normalize mutations are clean)
700
+ eq(serialize(parseDocument(out)), out, "serialize is idempotent");
701
+ });
702
+
703
+ // ── execution strategy hint: validation ─────────────────────────────────────
704
+ // WHY: the whole point is to let an author DECLARE staffing; a typo'd mode/role or an
705
+ // impossible count must be caught at validate time, not discovered when a launched session
706
+ // reads a nonsense directive. Enum + type + bounds are the contract.
707
+ test("validateExecution rejects a bad mode, a bad role, and non-positive ints", () => {
708
+ ok(validateExecution({ mode: "swarm" }, "a/s1").errors.some((e) => /mode "swarm" invalid/.test(e)), "bad mode");
709
+ ok(validateExecution({ team: [{ role: "wizard" }] }, "a/s1").errors.some((e) => /role "wizard" invalid/.test(e)), "bad role");
710
+ ok(validateExecution({ concurrency: 0 }, "a/s1").errors.some((e) => /concurrency must be an integer/.test(e)), "concurrency 0");
711
+ ok(validateExecution({ concurrency: 2.5 }, "a/s1").errors.some((e) => /concurrency must be an integer/.test(e)), "non-int concurrency");
712
+ ok(validateExecution({ team: [{ role: "implementer", count: 0 }] }, "a/s1").errors.some((e) => /count must be an integer/.test(e)), "count 0");
713
+ eq(EXEC_MODES, ["solo", "subagents", "dynamic-workflow", "agent-team"], "mode vocabulary");
714
+ eq(EXEC_ROLES, ["verifier", "implementer", "reviewer", "researcher", "integrator"], "role vocabulary");
715
+ });
716
+
717
+ // WHY: min_concurrency is a FLOOR; if it could exceed the suggested live count the directive
718
+ // would demand more workers than the slice ever wants — an incoherent instruction.
719
+ test("validateExecution enforces min_concurrency ≤ concurrency", () => {
720
+ ok(validateExecution({ concurrency: 3, min_concurrency: 5 }, "a/s1").errors.some((e) => /min_concurrency.*≤ concurrency/.test(e)), "floor above cap → error");
721
+ eq(validateExecution({ concurrency: 5, min_concurrency: 4 }, "a/s1").errors.length, 0, "floor ≤ cap → ok");
722
+ });
723
+
724
+ // WHY: a team whose head-count disagrees with concurrency means one of the two numbers is wrong;
725
+ // the launched session can't tell which, so we refuse the ambiguity rather than mis-staff.
726
+ test("validateExecution flags team head-count inconsistent with concurrency, accepts a consistent one", () => {
727
+ const bad = validateExecution({ concurrency: 5, team: [{ role: "implementer", count: 2 }, { role: "reviewer" }] }, "a/s1");
728
+ ok(bad.errors.some((e) => /head-count \(3\) is inconsistent with concurrency \(5\)/.test(e)), "3 != 5 → error");
729
+ const good = validateExecution({ concurrency: 5, team: [{ role: "verifier" }, { role: "implementer", count: 3 }, { role: "reviewer" }] }, "a/s1");
730
+ eq(good.errors.length, 0, "1+3+1 == 5 → ok");
731
+ // team WITHOUT a concurrency: no consistency check (nothing to be inconsistent with)
732
+ eq(validateExecution({ team: [{ role: "implementer", count: 4 }] }, "a/s1").errors.length, 0, "team alone → no consistency error");
733
+ });
734
+
735
+ // WHY: backward compatibility is non-negotiable — a slice that omits the block (every existing
736
+ // roadmap) must validate with ZERO new errors, or this feature breaks every consuming repo.
737
+ test("validateExecution + validateGraph are no-ops when execution is absent (backward-compat)", () => {
738
+ eq(validateExecution(undefined, "a/s1"), { errors: [], warnings: [] }, "absent → clean");
739
+ eq(validateExecution(null, "a/s1"), { errors: [], warnings: [] }, "null → clean");
740
+ const g = { meta: { schema_version: 1, program: "T" }, pis: [{ id: "a", title: "A", status: "active",
741
+ sprints: [sp("s1", { status: "active", est_sessions: 1 })] }] };
742
+ eq(validateGraph(g).errors.length, 0, "no execution block → graph validates");
743
+ });
744
+
745
+ // WHY: a valid block is the happy path the feature exists for; if a correct full block produced
746
+ // errors the author could never declare strategy at all.
747
+ test("validateGraph accepts a full, consistent execution block", () => {
748
+ const g = { meta: { schema_version: 1, program: "T" }, pis: [{ id: "a", title: "A", status: "active",
749
+ sprints: [sp("s1", { status: "active", est_sessions: 1, execution: {
750
+ mode: "agent-team", concurrency: 5, min_concurrency: 4,
751
+ team: [{ role: "verifier" }, { role: "implementer", count: 3 }, { role: "reviewer" }],
752
+ rationale: "16 disjoint fault-class files; verifier-first; one reviewer reconciles.",
753
+ } })] }] };
754
+ eq(validateGraph(g).errors.length, 0, "full valid block validates clean");
755
+ });
756
+
757
+ // WHY: a solo slice with a team is contradictory authoring; warn (don't error) so the YAML still
758
+ // loads but the author sees the mistake.
759
+ test("validateExecution warns on a solo slice that declares a team", () => {
760
+ ok(validateExecution({ mode: "solo", team: [{ role: "implementer" }] }, "a/s1").warnings.some((w) => /solo but a team/.test(w)), "solo + team → warning");
761
+ });
762
+
763
+ // ── suggested-floor computation ──────────────────────────────────────────────
764
+ // WHY: the suggested floor is the anti-under-parallelization HINT for slices that don't pin a
765
+ // count — it must equal the number of DISJOINT top-level dir clusters (shared dirs collapse),
766
+ // capped so a sprawling slice doesn't recommend an absurd worker count.
767
+ test("suggestedConcurrency counts distinct top-level dir clusters and caps at 6", () => {
768
+ eq(dirClusters(["src/a.ts", "src/b.ts", "docs/x.md"]).size, 2, "shared dir collapses");
769
+ eq(suggestedConcurrency({ touches: ["src/a.ts", "src/b.ts", "docs/x.md"] }), 2, "two disjoint clusters");
770
+ eq(suggestedConcurrency({ touches: ["a/1", "b/2", "c/3", "d/4", "e/5", "f/6", "g/7", "h/8"] }), 6, "capped at 6");
771
+ eq(suggestedConcurrency({ touches: [] }), null, "no files → no suggestion");
772
+ eq(suggestedConcurrency({}), null, "no touches → null");
773
+ });
774
+
775
+ // ── imperative directive rendering (one canonical block, reused verbatim) ─────
776
+ // WHY: agents under-parallelize by gut; for an agent-team slice the directive MUST name the count
777
+ // + composition AND explicitly tell the session to invoke Agent Teams (the env var) — anything
778
+ // vaguer and the session falls back to a lone subagent.
779
+ test("executionDirectiveLines emits an imperative agent-team directive with count, composition, floor, and the Agent Teams instruction", () => {
780
+ const lines = executionDirectiveLines({ touches: ["a/x", "b/y"], execution: {
781
+ mode: "agent-team", concurrency: 5, min_concurrency: 4,
782
+ team: [{ role: "verifier" }, { role: "implementer", count: 3 }, { role: "reviewer" }],
783
+ rationale: "16 disjoint fault-class files; verifier-first; one reviewer reconciles.",
784
+ }});
785
+ const text = lines.join("\n");
786
+ ok(/▶ EXECUTION: agent-team — 5 workers \(1 verifier · 3 implementers · 1 reviewer\)\./.test(text), "headline names count + composition");
787
+ ok(/DO NOT run solo or fewer than 4\./.test(text), "states the floor imperatively");
788
+ ok(/Invoke Agent Teams now \(set CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1\)/.test(text), "instructs invoking Agent Teams");
789
+ ok(/Rationale: 16 disjoint fault-class files/.test(text), "carries the rationale");
790
+ });
791
+
792
+ // WHY: each mode steers to a DIFFERENT mechanism; a subagents slice must point at the CLAUDE.md
793
+ // hand-off, a dynamic-workflow at an in-slice pipeline, and a solo slice must say plainly "no
794
+ // fan-out" so the session doesn't spin up workers it shouldn't.
795
+ test("executionDirectiveLines wording is mode-specific for subagents, dynamic-workflow, and solo", () => {
796
+ const subs = executionDirectiveLines({ touches: ["a/x"], execution: { mode: "subagents", concurrency: 3, min_concurrency: 2 } }).join("\n");
797
+ ok(/Spawn 3 background subagents per CLAUDE\.md § Subagent Hand-off/.test(subs), "subagents → CLAUDE.md hand-off");
798
+ ok(/DO NOT run solo or fewer than 2\./.test(subs), "subagents floor");
799
+ const dyn = executionDirectiveLines({ execution: { mode: "dynamic-workflow", concurrency: 2 } }).join("\n");
800
+ ok(/in-slice pipeline — each step gates the next/.test(dyn), "dynamic-workflow → pipeline");
801
+ const solo = executionDirectiveLines({ execution: { mode: "solo" } }).join("\n");
802
+ ok(/▶ EXECUTION: solo — single agent, no fan-out\./.test(solo), "solo headline");
803
+ ok(/Do not spawn workers/.test(solo), "solo forbids fan-out");
804
+ ok(!/DO NOT run solo or fewer/.test(solo), "solo has no floor clause");
805
+ // no execution block → no directive at all
806
+ eq(executionDirectiveLines({ touches: ["a/x"] }), null, "absent block → null");
807
+ });
808
+
809
+ // WHY: derived count fallbacks must be sane — a team without an explicit concurrency should report
810
+ // its head-count as the worker count, so the directive isn't blank.
811
+ test("executionDirectiveLines derives the worker count from the team when concurrency is unset", () => {
812
+ const lines = executionDirectiveLines({ execution: { mode: "agent-team", team: [{ role: "implementer", count: 4 }, { role: "reviewer" }] } }).join("\n");
813
+ ok(/agent-team — 5 workers/.test(lines), "team head-count = 5 used as worker count");
814
+ eq(teamSize([{ role: "implementer", count: 4 }, { role: "reviewer" }]), 5, "teamSize sums with default 1");
815
+ });
816
+
817
+ // ── render-core: directive in SLICES.md + the byte-identical backward-compat guarantee ──
818
+ const execRenderGraph = (execution) => ({
819
+ meta: { schema_version: 1, program: "T" },
820
+ pis: [{ id: "a", title: "A", status: "active", sprints: [
821
+ { id: "s1", title: "Fan", status: "active", invoke: "fan-slice", what: "do it", est_sessions: 1,
822
+ touches: ["a/x", "b/y"], read_order: ["docs/x.md"], ...(execution ? { execution } : {}) },
823
+ ] }],
824
+ });
825
+
826
+ // WHY: the rendered SLICES.md is the human read-out; the directive must appear AT THE TOP of the
827
+ // slice's detail entry (before What) so a session staffs before it reads anything else.
828
+ test("renderMarkdown emits the execution directive at the top of a slice's detail read-out", () => {
829
+ const md = renderMarkdown(execRenderGraph({ mode: "agent-team", concurrency: 2, min_concurrency: 2,
830
+ team: [{ role: "implementer" }, { role: "reviewer" }] }));
831
+ const detail = md.slice(md.indexOf("### `fan-slice`"));
832
+ const dirIdx = detail.indexOf("▶ EXECUTION: agent-team");
833
+ const whatIdx = detail.indexOf("**What:**");
834
+ ok(dirIdx >= 0, "directive rendered in the detail entry");
835
+ ok(dirIdx < whatIdx, "directive precedes What (top of the read-out)");
836
+ ok(/> ▶ EXECUTION/.test(detail), "rendered as a blockquote callout");
837
+ });
838
+
839
+ // WHY: THE non-negotiable. A slice with no execution block must render EXACTLY as it did before the
840
+ // feature — no stray directive, no blank lines, nothing — or every existing SLICES.md churns.
841
+ test("renderMarkdown is byte-identical (no directive) when no execution block is present", () => {
842
+ const md = renderMarkdown(execRenderGraph(null));
843
+ ok(!md.includes("▶ EXECUTION"), "no directive marker anywhere");
844
+ // the detail entry goes straight from the heading to What, as before
845
+ const detail = md.slice(md.indexOf("### `fan-slice`"));
846
+ ok(/### `fan-slice`\n- \*\*What:\*\*/.test(detail), "heading immediately followed by What, unchanged");
847
+ });
848
+
849
+ // ── kickoff brief carries the directive verbatim ─────────────────────────────
850
+ // WHY: a fanned-out session reads ONLY its .kickoff.md; for an agent-team slice the brief must
851
+ // carry the directive verbatim, or the worker never learns to invoke Agent Teams and runs solo.
852
+ test("synthesizeBrief carries the execution directive verbatim for an agent-team slice", () => {
853
+ const g = { meta: { schema_version: 1, program: "T", default_gate: "npm test" }, pis: [{ id: "a", title: "A", status: "active",
854
+ sprints: [sp("s1", { status: "active", invoke: "x", touches: ["a/p", "b/q"], execution: {
855
+ mode: "agent-team", concurrency: 4, min_concurrency: 3, team: [{ role: "implementer", count: 3 }, { role: "reviewer" }],
856
+ } })] }] };
857
+ const b = synthesizeBrief(flatten(g).nodes[0], g);
858
+ ok(/## 0\. Execution strategy/.test(b), "brief has the execution section first");
859
+ ok(/▶ EXECUTION: agent-team — 4 workers/.test(b), "directive headline present");
860
+ ok(/Invoke Agent Teams now \(set CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1\)/.test(b), "Agent Teams instruction present");
861
+ // a slice WITHOUT a block gets no execution section (brief unchanged)
862
+ const g2 = { meta: {}, pis: [{ id: "a", title: "A", status: "active", sprints: [sp("s1", { status: "active", invoke: "y" })] }] };
863
+ ok(!synthesizeBrief(flatten(g2).nodes[0], g2).includes("## 0. Execution strategy"), "no block → no execution section");
864
+ });
865
+
866
+ // ── fanout --track lane filter ───────────────────────────────────────────────
867
+ // WHY: the three-track partition lets a person fan out only their lane; --track must keep exactly
868
+ // the matching slices (case-insensitive) and an unset filter must be a no-op (everyone's lanes).
869
+ test("filterByTrack keeps only the matching lane and is a no-op without a track", () => {
870
+ const wave = [
871
+ { invoke: "a", track: "A" }, { invoke: "b", track: "B" }, { invoke: "c", track: null }, { invoke: "d", track: "a" },
872
+ ];
873
+ eq(filterByTrack(wave, "A").map((n) => n.invoke), ["a", "d"], "track A (case-insensitive), untracked excluded");
874
+ eq(filterByTrack(wave, null).map((n) => n.invoke), ["a", "b", "c", "d"], "no track → full wave");
875
+ eq(filterByTrack(wave, "Z").length, 0, "no match → empty");
876
+ });
877
+
878
+ // ── post-run guardrail: under-parallelization warning for /sync ─────────
879
+ // WHY: the guardrail closes the loop — if a slice that declared a floor and touches disjoint dirs
880
+ // actually ran with fewer live workers, /sync must say so, or the under-parallelization the
881
+ // whole feature targets goes unnoticed run after run.
882
+ test("underParallelizedWarnings flags a disjoint slice that ran below its floor, and stays quiet otherwise", () => {
883
+ const g = { meta: {}, pis: [{ id: "a", title: "A", status: "active", sprints: [
884
+ sp("s1", { status: "complete", invoke: "wide", touches: ["a/x", "b/y", "c/z"], execution: { min_concurrency: 4 } }),
885
+ sp("s2", { status: "complete", invoke: "okay", touches: ["a/x", "b/y"], execution: { min_concurrency: 2 } }),
886
+ sp("s3", { status: "complete", invoke: "single", touches: ["a/x"], execution: { min_concurrency: 4 } }),
887
+ sp("s4", { status: "complete", invoke: "noexec", touches: ["a/x", "b/y"] }),
888
+ ]}]};
889
+ const warns = underParallelizedWarnings(g, [
890
+ { invoke: "wide", workers: 2 }, // disjoint + below floor → warn
891
+ { invoke: "okay", workers: 2 }, // met its floor → quiet
892
+ { invoke: "single", workers: 1 }, // below floor BUT only one cluster (couldn't parallelize) → quiet
893
+ { invoke: "noexec", workers: 1 }, // no execution block → quiet
894
+ ]);
895
+ eq(warns.length, 1, "only the genuinely under-parallelized slice warns");
896
+ ok(/slice wide ran 2 workers; min_concurrency 4 — under-parallelized/.test(warns[0]), "warning names the slice, the count, and the floor");
897
+ eq(underParallelizedWarnings(g, []), [], "no telemetry → no warnings");
898
+ });
899
+
900
+ // ── priority: comparator + validation ────────────────────────────────────────
901
+ // WHY: comparePriority returning non-zero for two absent priorities would reorder every
902
+ // existing roadmap's waves — 0-when-both-absent IS the backward-compat guarantee.
903
+ test("comparePriority orders tier before weight and returns 0 when both absent", () => {
904
+ eq(comparePriority(null, null), 0, "both absent → 0 (falls through to existing order)");
905
+ eq(comparePriority(undefined, null), 0, "undefined/null equivalent");
906
+ ok(comparePriority({ tier: "P0" }, { tier: "P1", weight: 100 }) < 0, "tier beats weight");
907
+ ok(comparePriority({ tier: "P1", weight: 80 }, { tier: "P1", weight: 20 }) < 0, "same tier: higher weight first");
908
+ ok(comparePriority({ tier: "P3" }, { weight: 100 }) < 0, "any tier beats tierless (absent tier ranks after P3)");
909
+ ok(comparePriority({ weight: 10 }, null) < 0, "weight-only still outranks nothing");
910
+ eq(tierBadge({ tier: "P2", weight: 5 }), "P2", "badge is the tier");
911
+ eq(tierBadge({ weight: 5 }), null, "no tier → no badge");
912
+ eq(TIERS.length, 4, "P0..P3");
913
+ });
914
+
915
+ // WHY: a typo'd tier or a weight of 500 must be a validation error, not a silently
916
+ // mis-sorted roadmap; and an absent block must validate clean or every old roadmap breaks.
917
+ test("validatePriority rejects bad tier / out-of-range weight; absent is clean", () => {
918
+ eq(validatePriority(null, "x").errors, [], "absent → clean");
919
+ eq(validatePriority({ tier: "P1", weight: 60, reason: "why" }, "x").errors, [], "full valid block");
920
+ ok(validatePriority({ tier: "p1" }, "x").errors[0].includes("tier"), "lowercase tier rejected");
921
+ ok(validatePriority({ weight: 500 }, "x").errors[0].includes("weight"), "weight > 100 rejected");
922
+ ok(validatePriority({ weight: -1 }, "x").errors[0].includes("weight"), "negative weight rejected");
923
+ ok(validatePriority("P0", "x").errors[0].includes("mapping"), "scalar rejected");
924
+ });
925
+
926
+ // WHY: priority exists to decide who gets a scarce cap slot. A P0 losing its slot to an
927
+ // alphabetically-earlier P2 means the urgent work waits a wave; and an unprioritized graph
928
+ // must produce today's identical waves or every existing roadmap reshuffles.
929
+ test("computeWaves packs higher-priority slices first under the cap, unchanged when none set", () => {
930
+ const g = (withPriority) => ({ pis: [{ id: "a", title: "A", status: "active", sprints: [
931
+ sp("s1", { invoke: "aardvark", touches: ["f1"] }),
932
+ sp("s2", { invoke: "urgent", touches: ["f2"], ...(withPriority ? { priority: { tier: "P0" } } : {}) }),
933
+ sp("s3", { invoke: "middling", touches: ["f3"] }),
934
+ ]}]});
935
+ const withP = computeWaves(flatten(g(true)), 1);
936
+ eq(withP.waves[0].map((n) => n.invoke), ["urgent"], "P0 takes the single cap slot");
937
+ const withoutP = computeWaves(flatten(g(false)), 1);
938
+ eq(withoutP.waves[0].map((n) => n.invoke), ["aardvark"], "no priorities → existing (status/est/alpha) order");
939
+ });
940
+
941
+ // WHY: the tier badge is how a human scanning SLICES.md spots the urgent slice; and a
942
+ // priority-free graph must render byte-identically or every existing SLICES.md churns.
943
+ test("renderMarkdown shows tier badges only when priority is present", () => {
944
+ const g = (priority) => ({
945
+ meta: { schema_version: 1, program: "T" },
946
+ pis: [{ id: "a", title: "A", status: "active", sprints: [
947
+ { id: "s1", title: "Fan", status: "active", invoke: "fan-slice", what: "do it", est_sessions: 1,
948
+ ...(priority ? { priority } : {}) },
949
+ ] }],
950
+ });
951
+ const md = renderMarkdown(g({ tier: "P0", weight: 80, reason: "prod is down" }));
952
+ ok(md.includes("**[P0]** `/slice fan-slice`"), "wave-map badge");
953
+ ok(md.includes("· **P0** |"), "status-cell badge");
954
+ ok(md.includes("- **Priority:** P0 · weight 80 — prod is down"), "detail line with reason");
955
+ const plain = renderMarkdown(g(null));
956
+ ok(!plain.includes("P0") && !plain.includes("**Priority:**"), "no priority → no badge or line anywhere");
957
+ });
958
+
959
+ // WHY: the stashed prompt is the whole point of prompt-in-slice pickup — the launched session
960
+ // must see the author's words unedited; and a prompt-free slice must produce a byte-identical
961
+ // brief or every existing .kickoff.md changes under diff review.
962
+ test("synthesizeBrief embeds prompt verbatim and is unchanged without it", () => {
963
+ const g = (prompt) => ({ meta: { schema_version: 1, program: "T", default_gate: "npm test" },
964
+ pis: [{ id: "a", title: "A", status: "active", sprints: [sp("s1", { status: "active", invoke: "x", ...(prompt ? { prompt } : {}) })] }] });
965
+ const withPrompt = synthesizeBrief(flatten(g("Fix the wtSafe escaping.\nAdd a run.mjs case.")).nodes[0], g("x"));
966
+ ok(/## 0\.5 Author instructions \(verbatim\)\nFix the wtSafe escaping\.\nAdd a run\.mjs case\./.test(withPrompt), "prompt carried verbatim in its own section");
967
+ ok(withPrompt.indexOf("## 0.5") < withPrompt.indexOf("## 1. Scope"), "prompt section precedes Scope");
968
+ const bare = synthesizeBrief(flatten(g(null)).nodes[0], g(null));
969
+ ok(!bare.includes("## 0.5"), "no prompt → no section (brief unchanged)");
970
+ });
971
+
972
+ // WHY: prompt/kickoff_brief/priority must be updatable through the same allow-listed mutation
973
+ // path as every other field — that's the "update the init prompt as new info comes in" feature;
974
+ // and a corrupt priority must be caught by the pre-write gate, not written to disk.
975
+ test("setFields accepts prompt/kickoff_brief/priority and the pre-write gate rejects a bad priority", () => {
976
+ const y = `meta:\n schema_version: 1\n program: T\npis:\n - id: a\n title: A\n status: active\n sprints:\n - id: s1\n title: S\n status: active\n invoke: x\n`;
977
+ const doc = parseDocument(y);
978
+ const r = setFields(doc, { invoke: "x", fields: { prompt: "do the thing", kickoff_brief: "custom brief", priority: { tier: "P1", weight: 40 } } });
979
+ eq(r.fields, ["prompt", "kickoff_brief", "priority"], "all three fields settable");
980
+ validateDocOrThrow(doc); // must not throw
981
+ setFields(doc, { invoke: "x", fields: { priority: { tier: "NOPE" } } });
982
+ throws(() => validateDocOrThrow(doc), "priority.tier", "bad tier caught before write");
983
+ });
984
+
985
+ // ── bulk_set: all-or-nothing multi-slice edit ────────────────────────────────
986
+ // WHY: bulk edits exist to retag/reprioritize many slices at once; if update 1 lands while
987
+ // update 2's bad field throws, the roadmap is left half-edited and no error explains which half.
988
+ test("bulkSet applies every update through one gate — a bad field aborts before any write", () => {
989
+ const y = `meta:\n schema_version: 1\n program: T\npis:\n - id: a\n title: A\n status: active\n sprints:\n - { id: s1, title: S1, status: active, invoke: one }\n - { id: s2, title: S2, status: next, invoke: two }\n`;
990
+ const doc = parseDocument(y);
991
+ const r = bulkSet(doc, { updates: [
992
+ { invoke: "one", fields: { track: "A", priority: { tier: "P1" } } },
993
+ { invoke: "two", fields: { track: "A" } },
994
+ ]});
995
+ eq(r.updated, ["one", "two"], "both slices updated");
996
+ validateDocOrThrow(doc);
997
+ // a bad field ANYWHERE in the batch throws before the caller ever reaches serialize/write
998
+ throws(() => bulkSet(parseDocument(y), { updates: [
999
+ { invoke: "one", fields: { track: "B" } },
1000
+ { invoke: "two", fields: { nope: 1 } },
1001
+ ]}), 'field "nope" is not settable', "bad field in update 2 throws (caller writes nothing)");
1002
+ throws(() => bulkSet(parseDocument(y), { updates: [] }), "bulk_set requires", "empty updates rejected");
1003
+ });
1004
+
1005
+ // WHY: `roadmap set gate=npm test -- --grep x=y` must keep everything after the FIRST '='
1006
+ // as the value, and @file / null must reach set_fields with their special semantics intact.
1007
+ test("parseAssignments splits on the first '=', marks @file, and passes null through", () => {
1008
+ const [a, b, c] = parseAssignments(["gate=npm test -- --grep x=y", "prompt=@notes.md", "track=null"]);
1009
+ eq(a, { field: "gate", raw: "npm test -- --grep x=y" }, "value keeps embedded '='");
1010
+ eq(b, { field: "prompt", fromFile: "notes.md" }, "@path marks read-from-file");
1011
+ eq(c, { field: "track", raw: "null" }, "null passes through raw (YAML.parse → delete)");
1012
+ throws(() => parseAssignments(["notanassignment"]), "expected field=value", "missing '=' rejected");
1013
+ });
1014
+
1015
+ // ── backlog: validation + mutations ──────────────────────────────────────────
1016
+ // WHY: concurrent sessions allocate ids against their own stale checkouts — five bNN
1017
+ // collisions landed in ONE day (2026-07-10), each costing a manual renumber at merge time.
1018
+ // origin_ids feeds upstream's ids into allocation: auto-ids skip past them, and an explicit
1019
+ // id upstream already holds is refused BEFORE the write instead of colliding at merge.
1020
+ test("addItem allocates past origin/main ids and refuses an explicitly-taken one", () => {
1021
+ const doc = parseDocument("meta:\n schema_version: 1\nitems:\n - { id: b2, title: Local, kind: chore, status: open }\n");
1022
+ eq(addItem(doc, { title: "A", origin_ids: ["b7", "pid-99"] }).added, "b8", "auto-id skips past upstream's max, not just local's");
1023
+ throws(() => addItem(doc, { id: "b7", title: "B", origin_ids: ["b7"] }), "already exists on origin/main", "explicit upstream-taken id refused before writing");
1024
+ eq(addItem(doc, { id: "b7x-custom", title: "C", origin_ids: ["b7"] }).added, "b7x-custom", "custom slugs unaffected by the counter");
1025
+ ok(!doc.toJS().items.find((i) => i.title === "A").origin_ids, "origin_ids is allocation input, never persisted onto the item");
1026
+ });
1027
+
1028
+ // WHY: a duplicate id makes grab/promote act on the wrong item; a typo'd kind/status silently
1029
+ // buckets work out of the open view. Both must be hard errors before write.
1030
+ test("validateBacklog rejects duplicate ids and bad kind/status; a full valid item passes", () => {
1031
+ const good = { meta: { schema_version: 1 }, items: [
1032
+ { id: "fix-x", title: "Fix X", kind: "bug", status: "open",
1033
+ priority: { tier: "P1", weight: 70, reason: "breaks fanout" },
1034
+ source: { slice: "auth-sessions", date: "2026-07-06" }, refs: ["auth-sessions"],
1035
+ touches: ["src/x.ts"], est_sessions: 0.5, gate: "default", prompt: "repro then fix" },
1036
+ ]};
1037
+ eq(validateBacklog(good).errors, [], "full item validates clean");
1038
+ ok(validateBacklog({ meta: { schema_version: 1 }, items: [
1039
+ { id: "a", title: "A", kind: "bug", status: "open" }, { id: "a", title: "B", kind: "bug", status: "open" },
1040
+ ]}).errors[0].includes("duplicate"), "duplicate id rejected");
1041
+ ok(validateBacklog({ meta: { schema_version: 1 }, items: [{ id: "a", title: "A", kind: "task", status: "open" }] })
1042
+ .errors[0].includes("kind"), "bad kind rejected");
1043
+ ok(validateBacklog({ meta: { schema_version: 1 }, items: [{ id: "a", title: "A", kind: "bug", status: "started" }] })
1044
+ .errors[0].includes("status"), "bad status rejected");
1045
+ ok(validateBacklog({ meta: {}, items: [] }).errors[0].includes("schema_version"), "missing schema_version rejected");
1046
+ const w = validateBacklog({ meta: { schema_version: 1 }, items: [{ id: "a", title: "A", kind: "bug", status: "promoted" }] });
1047
+ ok(w.warnings[0].includes("promoted_to"), "promoted without back-link warns");
1048
+ });
1049
+
1050
+ // WHY: auto-ids must never collide with existing captures (a reused id silently merges two
1051
+ // items' histories), and comment preservation is the whole reason mutations use the Document API.
1052
+ test("addItem auto-generates the next bN id and preserves comments; setItemFields honors the allow-list", () => {
1053
+ const y = `meta:\n schema_version: 1\nitems:\n # keep this comment\n - { id: b3, title: Old, kind: chore, status: open }\n`;
1054
+ const doc = parseDocument(y);
1055
+ const r = addItem(doc, { title: "New thing", kind: "bug" });
1056
+ eq(r.added, "b4", "next free bN after b3");
1057
+ validateBacklogDocOrThrow(doc);
1058
+ ok(doc.toString().includes("# keep this comment"), "comment survives the edit");
1059
+ throws(() => addItem(doc, { title: "dup", id: "b3" }), "already exists", "explicit dup id rejected");
1060
+ const s = setItemFields(doc, { id: "b4", fields: { status: "in_progress", priority: { tier: "P0" }, prompt: "go" } });
1061
+ eq(s.fields, ["status", "priority", "prompt"], "allowed fields set");
1062
+ throws(() => setItemFields(doc, { id: "b4", fields: { id: "sneaky" } }), "not settable", "id is immutable");
1063
+ setItemFields(doc, { id: "b4", fields: { status: "nope" } });
1064
+ throws(() => validateBacklogDocOrThrow(doc), "status", "pre-write gate catches a bad status");
1065
+ });
1066
+
1067
+ // WHY: BACKLOG.md is the human triage view — items must group by tier with untriaged last,
1068
+ // or a P0 buried under untriaged noise never gets picked up.
1069
+ test("renderBacklogMarkdown groups open items by tier (untriaged last) and lists closed/promoted separately", () => {
1070
+ const md = renderBacklogMarkdown({ meta: { schema_version: 1 }, items: [
1071
+ { id: "n1", title: "No tier", kind: "idea", status: "open" },
1072
+ { id: "p0", title: "Urgent", kind: "urgent", status: "open", priority: { tier: "P0", weight: 90, reason: "prod" } },
1073
+ { id: "p2", title: "Later", kind: "chore", status: "open", priority: { tier: "P2" } },
1074
+ { id: "pr", title: "Moved", kind: "followup", status: "promoted", promoted_to: "auth/s9" },
1075
+ { id: "dn", title: "Done", kind: "bug", status: "done", prs: ["#12"], completed_on: "2026-07-01" },
1076
+ ]});
1077
+ const iP0 = md.indexOf("## P0"), iP2 = md.indexOf("## P2"), iUn = md.indexOf("## Untriaged");
1078
+ ok(iP0 >= 0 && iP2 > iP0 && iUn > iP2, "tier sections in order, untriaged last");
1079
+ ok(md.includes("3 open item(s)"), "open count in the header");
1080
+ ok(md.includes("`auth/s9`"), "promoted back-link shown");
1081
+ ok(md.includes("| `dn` | Done | done | #12 | 2026-07-01 |"), "closed row with PR + date");
1082
+ ok(md.includes("_(prod)_"), "priority reason surfaces in the row");
1083
+ });
1084
+
1085
+ // WHY: grab reuses the fanout machinery via this adapter — a wrong branch/worktree shape
1086
+ // would collide a backlog session with a sprint worktree or break synthesizeBrief.
1087
+ test("backlogItemToNode yields branch backlog/<id>, worktree <root>/backlog-<id>, and a brief-ready node", () => {
1088
+ const item = { id: "fix-y", title: "Fix Y", kind: "bug", status: "open", touches: ["src/y.ts"],
1089
+ prompt: "do it carefully", gate: "default", source: { note: "start from the failing test" } };
1090
+ const node = backlogItemToNode(item);
1091
+ const g = { meta: { schema_version: 1, program: "T", default_gate: "npm test", worktree_root: "/wt" } };
1092
+ eq(branchFor(node, g), "backlog/fix-y", "branch under the backlog/ namespace");
1093
+ eq(worktreeFor(node, g), "/wt/backlog-fix-y", "worktree beside the sprint worktrees");
1094
+ const brief = synthesizeBrief(node, g);
1095
+ ok(brief.includes("## 0.5 Author instructions (verbatim)\ndo it carefully"), "item prompt embedded");
1096
+ ok(brief.includes("npm test"), "default gate inherited from the roadmap");
1097
+ ok(brief.includes("start from the failing test"), "source note becomes the next action");
1098
+ });
1099
+
1100
+ // WHY: `roadmap next` is the pickup entry point — it must pick the highest priority across
1101
+ // BOTH trackers, let the roadmap win ties (planned work outranks erratic work), and return
1102
+ // null (not crash) when there's nothing to do.
1103
+ test("pickNext picks the highest-priority ready thing across roadmap + backlog; roadmap wins ties", () => {
1104
+ const g = { pis: [{ id: "a", title: "A", status: "active", sprints: [
1105
+ sp("s1", { invoke: "planned", priority: { tier: "P1" } }),
1106
+ ]}]};
1107
+ const backlog = (tier) => ({ meta: { schema_version: 1 }, items: [
1108
+ { id: "err", title: "Erratic", kind: "urgent", status: "open", ...(tier ? { priority: { tier } } : {}) },
1109
+ ]});
1110
+ eq(pickNext(g, backlog("P0")).type, "backlog", "P0 backlog beats P1 slice");
1111
+ eq(pickNext(g, backlog("P1")).type, "slice", "tie → roadmap wins");
1112
+ eq(pickNext(g, backlog(null)).type, "slice", "untriaged backlog loses to a prioritized slice");
1113
+ eq(pickNext(g, null).type, "slice", "no backlog file → roadmap only");
1114
+ eq(pickNext({ pis: [] }, backlog("P2")).type, "backlog", "empty roadmap → backlog");
1115
+ eq(pickNext({ pis: [] }, null), null, "nothing anywhere → null");
1116
+ });
1117
+
1118
+ // WHY: equal-priority items must keep capture order (stable sort) or triage lists reshuffle
1119
+ // on every render; and backlog_list must not explode when no backlog.yaml exists yet.
1120
+ test("sortByPriority is stable for equal priorities; readBacklogList handles a missing backlog", () => {
1121
+ const items = [{ id: "first" }, { id: "second" }, { id: "third", priority: { tier: "P0" } }];
1122
+ eq(sortByPriority(items).map((i) => i.id), ["third", "first", "second"], "P0 first, capture order preserved");
1123
+ ok(readBacklogList(null).note.includes("backlog_add"), "missing file → friendly note, not a throw");
1124
+ const l = readBacklogList({ meta: { schema_version: 1 }, items: [
1125
+ { id: "a", title: "A", kind: "bug", status: "open" }, { id: "b", title: "B", kind: "bug", status: "done" },
1126
+ ]});
1127
+ eq(l.items.length, 1, "default list = open only");
1128
+ eq(openCount({ items: [{ status: "open" }, { status: "in_progress" }, { status: "done" }] }), 2, "open = open + in_progress");
1129
+ });
1130
+
1131
+ // WHY: the SLICES.md pointer is how a roadmap reader discovers the backlog exists — but a
1132
+ // backlog-free repo must render byte-identically (the render backward-compat guarantee).
1133
+ test("renderMarkdown emits the backlog pointer only when opts.backlog is given", () => {
1134
+ const g = { meta: { schema_version: 1, program: "T" }, pis: [{ id: "a", title: "A", status: "active", sprints: [
1135
+ { id: "s1", title: "S", status: "active", invoke: "x", what: "w" }] }] };
1136
+ ok(renderMarkdown(g, { backlog: { open: 4 } }).includes("**Backlog:** 4 open item(s)"), "pointer with count");
1137
+ ok(!renderMarkdown(g).includes("**Backlog:**"), "no opts → no pointer line");
1138
+ });
1139
+
1140
+ // ── promote: backlog item → roadmap sprint ────────────────────────────────────
1141
+ // WHY: promote spans two files — a promoted sprint that drops the prompt/priority loses the
1142
+ // author's context, and a missing back-link orphans the item's history. Both must carry.
1143
+ test("performPromotion creates a scheduled sprint carrying prompt/priority/touches and back-links promoted_to", () => {
1144
+ const rDoc = parseDocument(`meta:\n schema_version: 1\n program: T\npis:\n - id: auth\n title: Auth\n status: active\n sprints:\n - { id: s2, title: Old, status: complete, invoke: old }\n`);
1145
+ const bDoc = parseDocument(`meta:\n schema_version: 1\nitems:\n - id: fix-x\n title: Fix X\n kind: bug\n status: open\n priority: { tier: P1, weight: 70 }\n touches: [src/x.ts]\n est_sessions: 0.5\n prompt: repro then fix\n`);
1146
+ const r = performPromotion(rDoc, bDoc, { id: "fix-x", pi: "auth" });
1147
+ eq(r, { promoted: "fix-x", to: "auth/s3" }, "auto sprint id = next free sN");
1148
+ validateDocOrThrow(rDoc);
1149
+ validateBacklogDocOrThrow(bDoc);
1150
+ const sp3 = rDoc.toJS().pis[0].sprints[1];
1151
+ eq(sp3.invoke, "fix-x", "item id becomes the invoke key");
1152
+ eq(sp3.status, "scheduled", "lands scheduled, not active");
1153
+ eq(sp3.prompt, "repro then fix", "prompt carries");
1154
+ eq(sp3.priority.tier, "P1", "priority carries");
1155
+ eq(sp3.touches, ["src/x.ts"], "touches carry");
1156
+ const item = bDoc.toJS().items[0];
1157
+ eq(item.status, "promoted", "item marked promoted");
1158
+ eq(item.promoted_to, "auth/s3", "back-link recorded");
1159
+ });
1160
+
1161
+ // WHY: the item id becomes the invoke key — a collision with an existing slice would make
1162
+ // /slice ambiguous; the pre-write gate must reject it so neither file is written.
1163
+ test("performPromotion is rejected by the pre-write gate when the item id collides with an existing invoke", () => {
1164
+ const rDoc = parseDocument(`meta:\n schema_version: 1\n program: T\npis:\n - id: auth\n title: Auth\n status: active\n sprints:\n - { id: s1, title: A, status: active, invoke: fix-x }\n`);
1165
+ const bDoc = parseDocument(`meta:\n schema_version: 1\nitems:\n - { id: fix-x, title: Fix X, kind: bug, status: open }\n`);
1166
+ performPromotion(rDoc, bDoc, { id: "fix-x", pi: "auth" });
1167
+ throws(() => validateDocOrThrow(rDoc), "duplicate invoke", "gate rejects the collision (mutateBoth writes nothing)");
1168
+ throws(() => performPromotion(parseDocument("meta:\n schema_version: 1\nitems: []"), bDoc, { id: "nope", pi: "auth" }),
1169
+ "no backlog item", "unknown item rejected");
1170
+ const doneB = parseDocument(`meta:\n schema_version: 1\nitems:\n - { id: d1, title: D, kind: bug, status: done }\n`);
1171
+ throws(() => performPromotion(rDoc, doneB, { id: "d1", pi: "auth" }), "only open/in_progress", "closed items don't promote");
1172
+ });
1173
+
1174
+ // WHY: promoting a mapped item must TRANSFER its Linear issue to the sprint — leaving it
1175
+ // on the item orphans an open issue on the board and double-maps the identifier.
1176
+ test("performPromotion transfers the item's Linear issue to the sprint", () => {
1177
+ const rDoc = parseDocument(`meta:\n schema_version: 1\n program: T\npis:\n - id: auth\n title: Auth\n status: active\n sprints:\n - { id: s1, title: Old, status: complete, invoke: old }\n`);
1178
+ const bDoc = parseDocument(`meta:\n schema_version: 1\nitems:\n - { id: fix-z, title: Fix Z, kind: bug, status: open, linear: PID-42 }\n`);
1179
+ performPromotion(rDoc, bDoc, { id: "fix-z", pi: "auth" });
1180
+ const sprint = rDoc.toJS().pis[0].sprints[1];
1181
+ eq(sprint.linear, "PID-42", "issue identifier rides onto the sprint");
1182
+ const item = bDoc.toJS().items[0];
1183
+ eq(item.linear, undefined, "item releases the mapping");
1184
+ eq(item.promoted_to, "auth/s2", "back-link intact");
1185
+ validateDocOrThrow(rDoc); validateBacklogDocOrThrow(bDoc);
1186
+ });
1187
+
1188
+ // WHY: the transferred issue must MORPH on the next sync — slice-form description, kind
1189
+ // label dropped, and attached to the PI's project — or the board shows a stale backlog card.
1190
+ test("buildPushPlan morphs a transferred issue: description + labels + projectId in one update", () => {
1191
+ const g = {
1192
+ meta: { schema_version: 1, program: "T", linear: { team: "ENG" } },
1193
+ pis: [{ id: "auth", title: "Authentication", status: "active", linear: { project: "proj-1" }, sprints: [
1194
+ { id: "s2", title: "Fix Z", status: "scheduled", invoke: "fix-z", linear: "PID-42" },
1195
+ ]}],
1196
+ };
1197
+ const cfg = normalizeLinearConfig(g.meta);
1198
+ const itemForm = { // the issue as it looked while it was a backlog item
1199
+ id: "uuid-42", title: "Fix Z", priority: 0, stateId: "st-b", projectId: null, labelIds: ["l-mark", "l-kindbug"],
1200
+ description: issueDescription({ invoke: "fix-z", title: "Fix Z", what: "Fix Z", kind: "bug" }, cfg, { target: { type: "backlog", key: "fix-z" } }),
1201
+ };
1202
+ const morphStates = [
1203
+ { id: "st-b", name: "Backlog", type: "backlog", position: 0 },
1204
+ { id: "st-s", name: "In Progress", type: "started", position: 1 },
1205
+ { id: "st-c", name: "Done", type: "completed", position: 2 },
1206
+ ];
1207
+ const plan = buildPushPlan({ graph: g, backlog: null, cfg, teamStates: morphStates,
1208
+ existing: { projects: { "proj-1": { id: "proj-1", name: "Authentication", description: "" } }, issues: { "PID-42": itemForm } },
1209
+ labels: { roadmap: "l-mark", "kind:bug": "l-kindbug" } });
1210
+ const upd = plan.ops.find((o) => o.op === "updateIssue");
1211
+ ok(upd, "one morph update");
1212
+ ok(upd.payload.description.includes("roadmap: slice=fix-z"), "description morphs to slice form");
1213
+ eq(upd.payload.labelIds, ["l-mark"], "kind label dropped, marker kept");
1214
+ eq(upd.payload.projectId, "proj-1", "attached to the PI's project");
1215
+ });
1216
+
1217
+ // WHY: the MCP registry is the agent-facing contract — every backlog tool must be listed
1218
+ // with a schema or agents can't call it, and the combined registry must stay well-formed.
1219
+ test("BACKLOG_TOOLS registry is well-formed and covers list/add/set/promote", () => {
1220
+ const names = BACKLOG_TOOLS.map((t) => t.name);
1221
+ eq(names, ["backlog_list", "backlog_add", "backlog_set", "backlog_promote"], "all four tools");
1222
+ for (const t of BACKLOG_TOOLS) {
1223
+ ok(t.description && t.inputSchema && t.inputSchema.type === "object", `${t.name} has description + object schema`);
1224
+ }
1225
+ const combined = [...TOOLS.map((t) => t.name), ...names];
1226
+ eq(new Set(combined).size, combined.length, "no name collisions with the roadmap tools");
1227
+ ok(combined.length >= 14, "14+ tools after the expansion");
1228
+ });
1229
+
1230
+ // ── disk ceiling ──────────────────────────────────────────────────────────────
1231
+ // WHY: a fanout that exceeds free disk fails mid-checkout with worktrees half-created.
1232
+ // The ceiling must bind when it's the smallest, report cap 0 as the hard-block signal
1233
+ // (while recommended stays >= 1 for the soft path), and vanish entirely when unprobeable.
1234
+ test("disk is a fifth ceiling: binds when smallest, cap 0 signals hard-block, null skips it", () => {
1235
+ const bigSys = { sys: { cores: 64, totalGb: 256, freeGb: 256, platform: "linux" }, reviewCeiling: 50 };
1236
+ const bound = recommendConcurrency(recoReady, recoGraph, { ...bigSys, disk: { perWorktreeGb: 2, freeGb: 6 } });
1237
+ eq(bound.recommended, 2, "floor((6-2 reserve)/2) = 2 binds");
1238
+ ok(bound.binding.why.startsWith("disk"), "bound by disk");
1239
+ ok(/need ~2\.0GB\/worktree, 6\.0GB free/.test(bound.binding.why), "why names the numbers");
1240
+ eq(bound.disk.cap, 2, "cap surfaced for callers");
1241
+ const full = recommendConcurrency(recoReady, recoGraph, { ...bigSys, disk: { perWorktreeGb: 5, freeGb: 3 } });
1242
+ eq(full.disk.cap, 0, "cap 0 = even one worktree won't fit (the hard-block signal)");
1243
+ eq(full.recommended, 1, "recommended stays >= 1 — hard-blocking is the launcher's job");
1244
+ const skipped = recommendConcurrency(recoReady, recoGraph, { ...bigSys, disk: null });
1245
+ eq(skipped.candidates.length, 4, "no disk → four ceilings, unchanged");
1246
+ eq(skipped.disk, null, "no disk info surfaced");
1247
+ });
1248
+
1249
+ // WHY: meta.worktree_gb is the calibration knob for repos whose gates install per-worktree —
1250
+ // when set it must win over the ls-tree estimate; and probeDisk must degrade to null (never
1251
+ // throw) so an unprobeable environment just loses the ceiling, not the whole plan.
1252
+ test("probeDisk honors the meta.worktree_gb override and never throws", () => {
1253
+ const probed = probeDisk({ meta: { worktree_gb: 2.5 } });
1254
+ if (probed) {
1255
+ eq(probed.perWorktreeGb, 2.5, "explicit worktree_gb wins over the estimate");
1256
+ ok(probed.freeGb > 0, "free space detected");
1257
+ }
1258
+ // no-git cwd → estimate path fails → null, not a throw
1259
+ eq(probeDisk({ meta: {} }, "/nonexistent-dir-for-roadmap-test"), null, "unprobeable → null");
1260
+ });
1261
+
1262
+ // ── store.mjs: the file-write-ordering / rollback guarantees (fs-backed) ──────
1263
+ // WHY: store.mjs is the one place with data-loss blast radius — every mutating surface
1264
+ // routes through it. If a thrown validation still wrote a file, or promote wrote one file
1265
+ // of two, the "validate before write" contract is a lie the unit tests above can't catch.
1266
+ function tempRepo() {
1267
+ const root = mkdtempSync(join(tmpdir(), "roadmap-store-test-"));
1268
+ mkdirSync(join(root, "docs", "roadmap"), { recursive: true });
1269
+ writeFileSync(join(root, "docs", "roadmap", "roadmap.yaml"),
1270
+ `meta:\n schema_version: 1\n program: T\npis:\n - id: a\n title: A\n status: active\n sprints:\n - { id: s1, title: S, status: active, invoke: taken }\n`, "utf8");
1271
+ return root;
1272
+ }
1273
+
1274
+ test("mutateRoadmap leaves roadmap.yaml byte-identical when the mutation or gate throws", () => {
1275
+ const root = tempRepo();
1276
+ const yamlPath = join(root, "docs", "roadmap", "roadmap.yaml");
1277
+ const before = readFileSync(yamlPath, "utf8");
1278
+ throws(() => mutateRoadmap(root, () => { throw new Error("boom"); }), "boom", "fn throw propagates");
1279
+ eq(readFileSync(yamlPath, "utf8"), before, "fn throw → file untouched");
1280
+ throws(() => mutateRoadmap(root, (doc) => setFields(doc, { invoke: "taken", fields: { priority: { tier: "NOPE" } } })),
1281
+ "priority.tier", "pre-write gate throw propagates");
1282
+ eq(readFileSync(yamlPath, "utf8"), before, "gate throw → file untouched, no SLICES rendered");
1283
+ ok(!existsSync(join(root, "docs", "SLICES.md")), "no SLICES.md written on failure");
1284
+ rmSync(root, { recursive: true, force: true });
1285
+ });
1286
+
1287
+ test("mutateBoth writes NEITHER file when the second validation throws (promote collision)", () => {
1288
+ const root = tempRepo();
1289
+ const rPath = join(root, "docs", "roadmap", "roadmap.yaml");
1290
+ const bPath = join(root, "docs", "roadmap", "backlog.yaml");
1291
+ // an item whose id collides with the existing invoke "taken" → roadmap gate rejects
1292
+ writeFileSync(bPath, `meta:\n schema_version: 1\nitems:\n - { id: taken, title: Collides, kind: bug, status: open }\n`, "utf8");
1293
+ const rBefore = readFileSync(rPath, "utf8");
1294
+ const bBefore = readFileSync(bPath, "utf8");
1295
+ throws(() => mutateBoth(root, (rDoc, bDoc) => performPromotion(rDoc, bDoc, { id: "taken", pi: "a" })),
1296
+ "duplicate invoke", "collision rejected");
1297
+ eq(readFileSync(rPath, "utf8"), rBefore, "roadmap.yaml untouched");
1298
+ eq(readFileSync(bPath, "utf8"), bBefore, "backlog.yaml untouched (validated-both-before-either held)");
1299
+ // and the success path writes both + both renders
1300
+ const r = mutateBoth(root, (rDoc, bDoc) => {
1301
+ addItem(bDoc, { title: "ok item", id: "okid", kind: "chore" });
1302
+ return performPromotion(rDoc, bDoc, { id: "okid", pi: "a" });
1303
+ });
1304
+ eq(r.to, "a/s2", "promoted into the next free sprint id");
1305
+ ok(readFileSync(rPath, "utf8").includes("okid"), "roadmap gained the sprint");
1306
+ ok(existsSync(join(root, "docs", "BACKLOG.md")) && existsSync(join(root, "docs", "SLICES.md")), "both views rendered");
1307
+ rmSync(root, { recursive: true, force: true });
1308
+ });
1309
+
1310
+ test("mutateBacklog createIfMissing bootstraps a block-style backlog.yaml and the SLICES pointer", () => {
1311
+ const root = tempRepo();
1312
+ const bPath = join(root, "docs", "roadmap", "backlog.yaml");
1313
+ throws(() => mutateBacklog(root, (doc) => addItem(doc, { title: "x" })), "no docs/roadmap/backlog.yaml",
1314
+ "without createIfMissing a missing file is an error, not a silent create");
1315
+ const r = mutateBacklog(root, (doc) => addItem(doc, { title: "first capture", kind: "bug" }), { createIfMissing: true });
1316
+ eq(r.added, "b1", "auto-id from an empty file");
1317
+ const src = readFileSync(bPath, "utf8");
1318
+ ok(/items:\n - id: b1/.test(src), "block style from birth (not flow)");
1319
+ ok(readFileSync(join(root, "docs", "SLICES.md"), "utf8").includes("**Backlog:** 1 open item(s)"),
1320
+ "backlog mutation refreshes the SLICES.md open-count pointer");
1321
+ rmSync(root, { recursive: true, force: true });
1322
+ });
1323
+
1324
+ // ── meta.agent_cmd launch template ────────────────────────────────────────────
1325
+ // WHY: the default template MUST reproduce today's claude command byte-for-byte, or every
1326
+ // existing roadmap's fanout launches change under people's feet; and a custom template must
1327
+ // substitute both tokens or a codex user launches with a literal "{prompt}".
1328
+ test("agentCmdFor default byte-equals the current claude command; custom template substitutes both tokens", () => {
1329
+ const bare = { meta: {} };
1330
+ eq(agentCmdFor(bare, { prompt: "do the thing", mode: "plan" }),
1331
+ `claude --permission-mode plan "do the thing"`, "default, double-quoted (bash/wt sites)");
1332
+ eq(agentCmdFor(bare, { prompt: "do the thing", mode: "acceptEdits", quote: "'" }),
1333
+ `claude --permission-mode acceptEdits 'do the thing'`, "default, single-quoted (pwsh sites)");
1334
+ eq(agentCmdFor({ meta: {} }, { prompt: "p", mode: "plan" }), DEFAULT_AGENT_CMD.replace("{mode}", "plan").replace("{prompt}", '"p"'), "exported default is the template in use");
1335
+ const codex = { meta: { agent_cmd: "codex exec --sandbox {mode} {prompt}" } };
1336
+ eq(agentCmdFor(codex, { prompt: "go", mode: "plan", quote: "'" }),
1337
+ `codex exec --sandbox plan 'go'`, "custom agent template substitutes mode + quoted prompt");
1338
+ });
1339
+
1340
+ // ── linear-core: detection + config + backward compat ────────────────────────
1341
+ const L_STATES = [
1342
+ { id: "st-b", name: "Backlog", type: "backlog", position: 0 },
1343
+ { id: "st-u", name: "Todo", type: "unstarted", position: 1 },
1344
+ { id: "st-s", name: "In Progress", type: "started", position: 2 },
1345
+ { id: "st-s2", name: "Blocked", type: "started", position: 3 },
1346
+ { id: "st-c", name: "Done", type: "completed", position: 4 },
1347
+ { id: "st-x", name: "Canceled", type: "canceled", position: 5 },
1348
+ ];
1349
+ const L_CFG = normalizeLinearConfig({ linear: { team: "ENG" } });
1350
+
1351
+ // WHY: a repo without meta.linear must behave byte-identically to v0.2 — a Linear feature
1352
+ // that leaks into unconfigured repos (a probe, a branch change, a render diff) breaks everyone.
1353
+ test("linear off by default: no config → null, unauthed state, branchFor untouched without the token", () => {
1354
+ eq(normalizeLinearConfig({}), null, "no meta.linear → null (all behavior off)");
1355
+ eq(normalizeLinearConfig({ linear: { granularity: "pis" } }), null, "teamless block → still off");
1356
+ const st = linearState({ meta: {}, env: {} });
1357
+ eq([st.configured, st.authed, st.lastSync], [false, false, null], "unconfigured + unauthed");
1358
+ const wired = linearState({ meta: { linear: { team: "ENG" } }, env: { LINEAR_API_KEY: "k" }, cursor: { lastSync: "2026-07-01" } });
1359
+ eq([wired.configured, wired.authed, wired.lastSync], [true, true, "2026-07-01"], "wired state");
1360
+ // a node WITH a linear id but a convention WITHOUT the token → branch byte-identical
1361
+ const g = { meta: {} };
1362
+ eq(branchFor({ piId: "a", id: "s1", linear: "ABC-123" }, g), "a/s1", "no {linear} token → unchanged");
1363
+ });
1364
+
1365
+ // WHY: a wrong state mapping silently mis-files work on the team's board — the type
1366
+ // defaults must hold, a name override must win, and an unresolvable name must fail loudly
1367
+ // naming what IS available (not push to a random state).
1368
+ test("resolvePushState maps by type, honors status_map by name, and errors naming available states", () => {
1369
+ eq(resolvePushState("scheduled", L_CFG, L_STATES).id, "st-b", "scheduled → backlog type");
1370
+ eq(resolvePushState("active", L_CFG, L_STATES).id, "st-s", "active → first started state");
1371
+ eq(resolvePushState("blocked", L_CFG, L_STATES).id, "st-u", "held (blocked/paused/gated) → unstarted, NOT In Progress — the board's In-Progress count means real active work only");
1372
+ eq(resolvePushState("gated", L_CFG, L_STATES).id, "st-u", "gated → unstarted too (distinguished by a status:gated label, not the workflow state)");
1373
+ eq(resolvePushState("complete", L_CFG, L_STATES).id, "st-c", "complete → completed");
1374
+ const mapped = normalizeLinearConfig({ linear: { team: "ENG", status_map: { blocked: "Blocked" } } });
1375
+ eq(resolvePushState("blocked", mapped, L_STATES).id, "st-s2", "status_map name override wins");
1376
+ const bad = normalizeLinearConfig({ linear: { team: "ENG", status_map: { active: "Doing" } } });
1377
+ throws(() => resolvePushState("active", bad, L_STATES), "available: Backlog, Todo", "unresolvable name lists the real states");
1378
+ eq(pullStatusFor("unstarted"), "next", "pull inverse");
1379
+ eq(priorityToLinear({ tier: "P0" }), 1, "P0 → Urgent(1)");
1380
+ eq(priorityToLinear(null), 0, "no priority → 0");
1381
+ eq(LINEAR_TO_PRIORITY[4], "P3", "Low(4) → P3");
1382
+ });
1383
+
1384
+ // WHY: the footer is the machine contract any agent dispatched from Linear parses to
1385
+ // orient; and copying read-order/prompt into Linear is exactly the duplication this
1386
+ // integration promises not to create.
1387
+ test("issueDescription: verbosity levers, footer always last, prompt/read-order never leak", () => {
1388
+ const node = { invoke: "auth-sessions", title: "Session tokens", what: "Wire JWT refresh",
1389
+ gate: "default", estSessions: 3, priority: { tier: "P1", weight: 60, reason: "launch blocker" },
1390
+ prompt: "SECRET-INSTRUCTIONS", readOrder: ["docs/auth.md"], linear: null };
1391
+ const brief = issueDescription(node, L_CFG, { docsUrl: "https://github.com/x/y/blob/main" });
1392
+ ok(brief.endsWith(machineFooter({ type: "slice", key: "auth-sessions" }, "https://github.com/x/y/blob/main")), "footer last");
1393
+ ok(brief.includes("Wire JWT refresh"), "brief carries the what");
1394
+ ok(!brief.includes("Est:"), "est is NOT prose in the description anymore — it rides the native Linear estimate field (no duplication)");
1395
+ ok(!brief.includes("SECRET-INSTRUCTIONS") && !brief.includes("docs/auth.md"), "prompt/read-order never leak");
1396
+ ok(!brief.includes("launch blocker"), "brief verbosity omits the priority reason");
1397
+ const titleOnly = issueDescription(node, normalizeLinearConfig({ linear: { team: "ENG", verbosity: "title" } }), {});
1398
+ eq(titleOnly, machineFooter({ type: "slice", key: "auth-sessions" }, null), "title verbosity = footer only");
1399
+ const full = issueDescription(node, normalizeLinearConfig({ linear: { team: "ENG", verbosity: "full" } }), {});
1400
+ ok(full.includes("P1 · weight 60 — launch blocker"), "full verbosity carries the priority reason");
1401
+ ok(machineFooter({ type: "backlog", key: "b7" }, null).includes("roadmap grab b7"), "backlog footer says grab");
1402
+ });
1403
+
1404
+ // WHY: Linear rewrites a bare URL to "[url](<url>)" on store; if the footer pushes the bare
1405
+ // form, the description diff never converges and EVERY issue re-pushes on every sync. The
1406
+ // canonical form must already BE Linear's normalized form.
1407
+ test("machineFooter renders the docs URL in Linear's stored auto-link form (round-trips)", () => {
1408
+ const base = "https://github.com/x/y/blob/main";
1409
+ const f = machineFooter({ type: "slice", key: "auth-sessions" }, base);
1410
+ const url = `${base}/docs/SLICES.md#auth-sessions`;
1411
+ ok(f.endsWith(`[${url}](<${url}>)`), "full URL wrapped as [url](<url>)");
1412
+ // no docsUrl → relative path, which Linear does NOT auto-link → stays bare (no brackets)
1413
+ ok(machineFooter({ type: "slice", key: "auth-sessions" }, null).endsWith("\ndocs/SLICES.md#auth-sessions"), "relative path stays bare");
1414
+ });
1415
+
1416
+ // ── linear-core: push plan ────────────────────────────────────────────────────
1417
+ const pushGraph = (over = {}) => ({
1418
+ meta: { schema_version: 1, program: "T", linear: { team: "ENG", ...over } },
1419
+ pis: [
1420
+ { id: "auth", title: "Authentication", status: "active", linear: { project: "proj-1" }, sprints: [
1421
+ { id: "s1", title: "Login", status: "active", invoke: "auth-login", linear: "ENG-1" },
1422
+ { id: "s2", title: "Tokens", status: "scheduled", invoke: "auth-tokens" },
1423
+ { id: "s3", title: "Old", status: "complete", invoke: "auth-old" },
1424
+ ]},
1425
+ ],
1426
+ });
1427
+ const SNAP = (loginOverrides = {}) => ({
1428
+ projects: { "proj-1": { id: "proj-1", name: "Authentication" } },
1429
+ issues: { "ENG-1": { id: "uuid-1", title: "Login",
1430
+ description: issueDescription({ invoke: "auth-login", title: "Login", what: "Login", gate: "default", estSessions: null, priority: null }, L_CFG, { target: { type: "slice", key: "auth-login" } }),
1431
+ priority: 0, stateId: "st-s", projectId: "proj-1", ...loginOverrides } },
1432
+ });
1433
+
1434
+ // WHY: a non-idempotent push spams duplicate issues/updates on every /sync — a matching
1435
+ // snapshot must produce ZERO ops, and one changed field exactly one update.
1436
+ test("buildPushPlan is idempotent: matching snapshot → only the missing-issue create; changed title → one update", () => {
1437
+ const cfg = normalizeLinearConfig(pushGraph().meta);
1438
+ const plan = buildPushPlan({ graph: pushGraph(), backlog: null, cfg, teamStates: L_STATES, existing: SNAP() });
1439
+ eq(plan.ops.map((o) => o.op), ["createIssue"], "only the unmapped not-done sprint creates (complete unmapped skipped, mapped unchanged)");
1440
+ eq(plan.ops[0].writeBack, { kind: "sprint", invoke: "auth-tokens" }, "create writes the id back to the sprint");
1441
+ const drifted = buildPushPlan({ graph: pushGraph(), backlog: null, cfg, teamStates: L_STATES, existing: SNAP({ title: "Login (old name)" }) });
1442
+ const upd = drifted.ops.find((o) => o.op === "updateIssue");
1443
+ eq(upd.payload, { title: "Login" }, "only the drifted field is sent");
1444
+ eq(upd.id, "uuid-1", "update targets the Linear uuid");
1445
+ });
1446
+
1447
+ // ── linear-core: per-PI verbosity ─────────────────────────────────────────────
1448
+ // WHY: a silently-ignored per-PI verbosity override makes the board lie about detail level —
1449
+ // the user quiets a noisy PI to title (or richens an active one to full) and nothing changes.
1450
+ test("per-PI verbosity overrides the global for that PI's issues only", () => {
1451
+ const g = { meta: { schema_version: 1, program: "T", linear: { team: "ENG", verbosity: "title" } },
1452
+ pis: [
1453
+ { id: "loud", title: "Loud", status: "active", linear: { project: "proj-1", verbosity: "brief" }, sprints: [
1454
+ { id: "s1", title: "A", status: "next", invoke: "loud-a", what: "does a thing", gate: "gate a" } ]},
1455
+ { id: "quiet", title: "Quiet", status: "active", linear: { project: "proj-2" }, sprints: [
1456
+ { id: "s1", title: "B", status: "next", invoke: "quiet-b", what: "does b", gate: "gate b" } ]},
1457
+ ]};
1458
+ const cfg = normalizeLinearConfig(g.meta);
1459
+ eq(effectiveVerbosity(cfg, g.pis[0]), "brief", "per-PI verbosity wins");
1460
+ eq(effectiveVerbosity(cfg, g.pis[1]), "title", "no override → global");
1461
+ const existing = { projects: { "proj-1": { id: "proj-1", name: "Loud" }, "proj-2": { id: "proj-2", name: "Quiet" } }, issues: {} };
1462
+ const plan = buildPushPlan({ graph: g, backlog: null, cfg, teamStates: L_STATES, existing });
1463
+ const desc = Object.fromEntries(plan.ops.filter((o) => o.op === "createIssue").map((o) => [o.writeBack.invoke, o.payload.description]));
1464
+ ok(desc["loud-a"].includes("Gate: gate a"), "overridden PI carries brief detail (what + gate)");
1465
+ ok(!desc["quiet-b"].includes("Gate"), "global-title PI stays footer-only");
1466
+ });
1467
+
1468
+ // WHY: an unacked per-PI override is how two sessions diverge on what Linear shows — verbosity
1469
+ // must gate through the SAME ack as granularity, and validate must flag the stored mismatch.
1470
+ test("verbosity override is ack-gated and validate flags invalid/differing values", () => {
1471
+ const globalCfg = normalizeLinearConfig({ linear: { team: "ENG" } }); // verbosity default brief
1472
+ throws(() => checkPiOverrideAck(globalCfg, { verbosity: "title" }, false, "x"), 'overrides Linear verbosity ("title")');
1473
+ checkPiOverrideAck(globalCfg, { verbosity: "title" }, true, "x"); // acked → passes
1474
+ checkPiOverrideAck(globalCfg, { verbosity: "brief" }, false, "x"); // matches global → no ack needed
1475
+ const g = { meta: { schema_version: 1, program: "T", linear: { team: "ENG" } }, pis: [
1476
+ { id: "bad", title: "B", status: "active", linear: { verbosity: "loud" }, sprints: [{ id: "s1", title: "A", status: "next", invoke: "bad-a" }] },
1477
+ { id: "diff", title: "D", status: "active", linear: { verbosity: "full" }, sprints: [{ id: "s1", title: "A", status: "next", invoke: "diff-a" }] },
1478
+ ]};
1479
+ const v = validateLinearConfig(g);
1480
+ ok(v.errors.some((e) => e.includes('linear.verbosity "loud"')), "invalid per-PI verbosity is an error");
1481
+ ok(v.warnings.some((w) => w.includes("PI diff") && w.includes("verbosity")), "differing per-PI verbosity warns (override in effect)");
1482
+ });
1483
+
1484
+ // WHY: a "." inside an abbreviation ended the derived subtitle mid-parenthetical — the one line
1485
+ // humans read on the project card shipped as "…DB (incl." on a live board and read as truncation.
1486
+ test("firstSentence-derived subtitle survives abbreviations (incl., e.g.) and still splits real sentences", () => {
1487
+ const pi = { id: "net", title: "Live Network", exit_criteria: "Flock deterministically seeds every node DB (incl. Bridge sidecars) on turnkey hosts. Second sentence here." };
1488
+ eq(projectSubtitleRaw(pi), "Flock deterministically seeds every node DB (incl. Bridge sidecars) on turnkey hosts.", "abbreviation does not end the sentence; the real period does");
1489
+ const eg = { id: "x", title: "X", exit_criteria: "Covers hosts (e.g. approved VMs) end to end. More detail." };
1490
+ eq(projectSubtitleRaw(eg), "Covers hosts (e.g. approved VMs) end to end.", "e.g. survives too");
1491
+ const noEnd = { id: "y", title: "Y", exit_criteria: "Ends on an abbreviation incl." };
1492
+ eq(projectSubtitleRaw(noEnd), "Ends on an abbreviation incl.", "no real sentence end → whole line, never empty");
1493
+ });
1494
+
1495
+ // ── linear-core: horizon gate ─────────────────────────────────────────────────
1496
+ // WHY: the untiered future mass is what floods the board (live: 50 of 92 issues were far-future
1497
+ // backlog-state) — "near" must stop NEW scheduled/optionality issues while committed/held work
1498
+ // still projects, mapped far-future issues keep updating, and the absent knob stays byte-identical.
1499
+ test("horizon near: gates new far-future issues, keeps mapped ones updating, default unchanged", () => {
1500
+ const g = (horizon) => ({ meta: { schema_version: 1, program: "T", linear: { team: "ENG", ...(horizon ? { horizon } : {}) } },
1501
+ pis: [{ id: "p", title: "P", status: "active", linear: { project: "proj-1" }, sprints: [
1502
+ { id: "s1", title: "Sched", status: "scheduled", invoke: "sched" },
1503
+ { id: "s2", title: "Opt", status: "optionality", invoke: "opt" },
1504
+ { id: "s3", title: "Next", status: "next", invoke: "nxt" },
1505
+ { id: "s4", title: "Gated", status: "gated", invoke: "gtd" },
1506
+ { id: "s5", title: "Mapped sched", status: "scheduled", invoke: "mapped", linear: "ENG-7" },
1507
+ ]}]});
1508
+ const existing = { projects: { "proj-1": { id: "proj-1", name: "P" } }, issues: {
1509
+ "ENG-7": { id: "u7", title: "OLD NAME", description: "", priority: 0, stateId: "st-b", projectId: "proj-1", labelIds: [] } } };
1510
+ const near = buildPushPlan({ graph: g("near"), backlog: null, cfg: normalizeLinearConfig(g("near").meta), teamStates: L_STATES, existing });
1511
+ const creates = near.ops.filter((o) => o.op === "createIssue").map((o) => o.writeBack.invoke).sort();
1512
+ eq(creates, ["gtd", "nxt"], "near creates only committed/held work — scheduled/optionality stay YAML-only");
1513
+ const upd = near.ops.find((o) => o.op === "updateIssue" && o.identifier === "ENG-7");
1514
+ eq(upd.payload.title, "Mapped sched", "an already-mapped far-future issue still updates (create-side gate only)");
1515
+ const all = buildPushPlan({ graph: g(null), backlog: null, cfg: normalizeLinearConfig(g(null).meta), teamStates: L_STATES, existing });
1516
+ eq(all.ops.filter((o) => o.op === "createIssue").length, 4, "absent knob (default all) → every not-done slice creates, byte-identical to before");
1517
+ const v = validateLinearConfig({ meta: { schema_version: 1, program: "T", linear: { team: "ENG", horizon: "soon" } }, pis: [] });
1518
+ ok(v.errors.some((e) => e.includes('horizon "soon"')), "invalid horizon blocks at validate");
1519
+ });
1520
+
1521
+ // ── linear-core: done history ─────────────────────────────────────────────────
1522
+ // WHY: history is what makes a project's progress % real (completed/total inside the project) —
1523
+ // "off" must stay byte-identical (no Done-issue noise for existing users), "full" must project
1524
+ // shipped work as completed issues carrying the true completion date, "window" must honor
1525
+ // meta.completed_window_days, and a shipped PI's project must be created AND populated.
1526
+ test("history knob: off skips done work, full projects it Done with completedAt, window honors the meta knob", () => {
1527
+ const g = (history) => ({ meta: { schema_version: 1, program: "T", completed_window_days: 7, linear: { team: "ENG", ...(history ? { history } : {}) } },
1528
+ pis: [{ id: "shipped", title: "Shipped", status: "complete", sprints: [
1529
+ { id: "s1", title: "Old", status: "complete", invoke: "old", completed_on: "2026-07-01" },
1530
+ { id: "s2", title: "Ancient", status: "complete", invoke: "ancient", completed_on: "2026-06-01" },
1531
+ { id: "s3", title: "Undated", status: "complete", invoke: "undated" },
1532
+ ]}]});
1533
+ const NOW = "2026-07-05T12:00:00Z";
1534
+ const empty = { projects: {}, issues: {} };
1535
+ const mk = (history) => buildPushPlan({ graph: g(history), backlog: null, cfg: normalizeLinearConfig(g(history).meta), teamStates: L_STATES, existing: empty, now: NOW });
1536
+ // off (default): the fully-shipped PI earns neither project nor issues — byte-identical to before
1537
+ eq(mk(null).ops.length, 0, "history off → a fully-shipped PI stays off the board entirely");
1538
+ // full: project created AND populated with Done issues carrying completedAt
1539
+ const full = mk("full");
1540
+ eq(full.ops.filter((o) => o.op === "createProject").length, 1, "full → the shipped PI's project is created");
1541
+ const creates = full.ops.filter((o) => o.op === "createIssue");
1542
+ eq(creates.map((o) => o.writeBack.invoke).sort(), ["ancient", "old", "undated"], "full → every done slice projects");
1543
+ const old = creates.find((o) => o.writeBack.invoke === "old");
1544
+ eq(old.payload.stateId, "st-c", "done slice lands in the completed state");
1545
+ eq(old.payload.completedAt, "2026-07-01", "true completion date rides the create");
1546
+ ok(!("completedAt" in creates.find((o) => o.writeBack.invoke === "undated").payload), "no completed_on → no completedAt field");
1547
+ // window: 7-day meta knob — old (4 days ago) inside, ancient (34 days) and undated outside
1548
+ const win = mk("window");
1549
+ eq(win.ops.filter((o) => o.op === "createIssue").map((o) => o.writeBack.invoke), ["old"], "window honors meta.completed_window_days, undated never resurrects");
1550
+ // pin the inclusive boundary: exactly N days ago is inside; 1ms older is outside
1551
+ const wcfg = normalizeLinearConfig({ linear: { team: "ENG", history: "window" } });
1552
+ ok(withinHistory(wcfg, { completedOn: "2026-06-28T12:00:00Z" }, { completed_window_days: 7 }, NOW), "exact 7-day boundary is inside (<=)");
1553
+ ok(!withinHistory(wcfg, { completedOn: "2026-06-28T11:59:59.999Z" }, { completed_window_days: 7 }, NOW), "1ms past the boundary is outside");
1554
+ const v = validateLinearConfig({ meta: { schema_version: 1, program: "T", linear: { team: "ENG", history: "always" } }, pis: [] });
1555
+ ok(v.errors.some((e) => e.includes('history "always"')), "invalid history value blocks at validate");
1556
+ });
1557
+
1558
+ // ── cycle-core: the election ──────────────────────────────────────────────────
1559
+ // WHY: the capacity cap IS the discipline — packing past it, silently packing unpriced work, or
1560
+ // proposing blocked/held work puts unstartable or unbounded commitments in the week; and the
1561
+ // lock must be one validated write, not a hand-edit that skips the store's gates.
1562
+ test("electionPlan: committed-first capacity, strict priority prefix, unestimated never packed, held/blocked never candidates", () => {
1563
+ const g = { meta: { schema_version: 1, program: "T", linear: { team: "ENG", cycles: "on" } },
1564
+ pis: [{ id: "p", title: "P", status: "active", sprints: [
1565
+ { id: "s1", title: "Committed", status: "active", invoke: "committed", est_sessions: 3 },
1566
+ { id: "s2", title: "Next up", status: "next", invoke: "nextup", est_sessions: 2 },
1567
+ { id: "s3", title: "Small P1", status: "scheduled", invoke: "small", est_sessions: 2, priority: { tier: "P1" } },
1568
+ { id: "s4", title: "Big P0", status: "scheduled", invoke: "big", est_sessions: 9, priority: { tier: "P0" } },
1569
+ { id: "s5", title: "Unpriced", status: "scheduled", invoke: "unpriced" },
1570
+ { id: "s6", title: "Gated", status: "gated", invoke: "gated", est_sessions: 1 },
1571
+ { id: "s7", title: "Blocked dep", status: "scheduled", invoke: "depped", est_sessions: 1, deps: ["s6"] },
1572
+ { id: "s8", title: "Maybe", status: "optionality", invoke: "maybe", est_sessions: 1 },
1573
+ ]}]};
1574
+ const p = electionPlan(g, { capacity: 10, staleInvokes: ["committed"] });
1575
+ eq(p.elected.map((x) => x.invoke).sort(), ["committed", "nextup"], "elected = the committed set (active+next)");
1576
+ ok(p.elected.find((x) => x.invoke === "committed").stale, "the stale flag rides the elected list — reviewed first");
1577
+ eq(p.unpricedElected, [], "all committed work is priced here — no capacity blind spot");
1578
+ const gUnpriced = { ...g, pis: [{ ...g.pis[0], sprints: [{ id: "s0", title: "Mystery", status: "active", invoke: "mystery" }, ...g.pis[0].sprints] }] };
1579
+ eq(electionPlan(gUnpriced, { capacity: 10 }).unpricedElected.map((x) => x.invoke), ["mystery"], "committed-but-unpriced work is flagged, never a silent zero in the capacity math");
1580
+ eq(p.candidates.map((x) => x.invoke), ["big", "small", "unpriced"], "candidates = READY scheduled only, priority-sorted, unpriced last (gated/dep-blocked/optionality excluded)");
1581
+ // committed 5s + big 9s would blow 10 → strict prefix stops at big; small does NOT sneak past the P0
1582
+ eq(p.packed, [], "a too-big P0 at the head blocks the prefix — the signal is split-it, not skip-it");
1583
+ eq(p.overflow.map((x) => x.invoke), ["big", "small"], "everything estimable past the prefix is overflow");
1584
+ eq(p.unestimated.map((x) => x.invoke), ["unpriced"], "unpriced work is surfaced, never silently packed");
1585
+ eq(p.estUsed, 5, "usage = committed est_sessions when nothing packs");
1586
+ const roomy = electionPlan(g, { capacity: 20 });
1587
+ eq(roomy.packed.map((x) => x.invoke), ["big", "small"], "with room, the prefix packs in priority order");
1588
+ eq(roomy.estUsed, 16, "usage counts committed + packed");
1589
+ });
1590
+
1591
+ // WHY: outOfCycle is the dispatch/fan lock's whole decision — a false positive blocks legitimate
1592
+ // work, a false negative lets the cycle leak; cycles off must never lock anything (opt-in).
1593
+ test("outOfCycle: locks only non-committed statuses, only when cycles are on", () => {
1594
+ const on = normalizeLinearConfig({ linear: { team: "ENG", cycles: "on" } });
1595
+ const off = normalizeLinearConfig({ linear: { team: "ENG" } });
1596
+ ok(outOfCycle(on, "scheduled") && outOfCycle(on, "gated") && outOfCycle(on, "complete"), "non-committed statuses lock when on");
1597
+ ok(!outOfCycle(on, "active") && !outOfCycle(on, "next"), "the committed set never locks");
1598
+ ok(!outOfCycle(off, "scheduled") && !outOfCycle(null, "scheduled"), "cycles off (or no Linear) → never locks");
1599
+ });
1600
+
1601
+ // ── linear-core: staleness ────────────────────────────────────────────────────
1602
+ // WHY: a stale flag that flaps (our own push resetting the clock), flags unknown-activity work,
1603
+ // or sticks after a fresh note trains the human to ignore it — the one failure an advisory
1604
+ // indicator can't afford. Basis is journal activity; add/remove rides the ordinary label set-diff.
1605
+ test("staleKeys: journal-silence basis, committed-only scope, unknown never flags; label rides the set-diff", () => {
1606
+ const g = { meta: { schema_version: 1, program: "T", linear: { team: "ENG", stale_days: 3 } },
1607
+ pis: [{ id: "p", title: "P", status: "active", linear: { project: "proj-1" }, sprints: [
1608
+ { id: "s1", title: "A", status: "active", invoke: "a", linear: "ENG-1" }, // silent 5 days → stale
1609
+ { id: "s2", title: "B", status: "next", invoke: "b", linear: "ENG-2" }, // fresh note → not stale
1610
+ { id: "s3", title: "C", status: "scheduled", invoke: "c", linear: "ENG-3" }, // not committed → never stale
1611
+ { id: "s4", title: "D", status: "active", invoke: "d", linear: "ENG-4" }, // unknown activity → never stale
1612
+ ]}]};
1613
+ const cfg = normalizeLinearConfig(g.meta);
1614
+ const NOW = "2026-07-09T12:00:00Z";
1615
+ const activity = { "ENG-1": "2026-07-04T11:00:00Z", "ENG-2": "2026-07-09T09:00:00Z", "ENG-3": "2026-06-01T00:00:00Z" };
1616
+ eq([...staleKeys({ graph: g, cfg, activity, now: NOW })], ["a"], "silent committed work flags; fresh, uncommitted, unknown don't");
1617
+ eq(staleKeys({ graph: g, cfg: normalizeLinearConfig({ linear: { team: "ENG" } }), activity, now: NOW }).size, 0, "no stale_days → feature off");
1618
+ const labels = { roadmap: "l-r", stale: "l-s" };
1619
+ const oneSlice = { ...g, pis: [{ ...g.pis[0], sprints: [g.pis[0].sprints[0]] }] };
1620
+ const existing = { projects: { "proj-1": { id: "proj-1", name: "P" } }, issues: {
1621
+ "ENG-1": { id: "u1", title: "A", description: "x", priority: 0, stateId: "st-s", projectId: "proj-1", labelIds: ["l-r"] } } };
1622
+ const flagged = buildPushPlan({ graph: oneSlice, backlog: null, cfg, teamStates: L_STATES, existing, labels, stale: new Set(["a"]) });
1623
+ eq(flagged.ops.find((o) => o.op === "updateIssue").payload.labelIds, ["l-r", "l-s"], "flagging adds the stale label via the set-diff");
1624
+ const relabeled = { ...existing, issues: { "ENG-1": { ...existing.issues["ENG-1"], labelIds: ["l-r", "l-s"] } } };
1625
+ const cleared = buildPushPlan({ graph: oneSlice, backlog: null, cfg, teamStates: L_STATES, existing: relabeled, labels, stale: new Set() });
1626
+ eq(cleared.ops.find((o) => o.op === "updateIssue").payload.labelIds, ["l-r"], "unflagging drops it the same way");
1627
+ const pp = provisionPlan({ graph: g, teamLabels: {}, cfg });
1628
+ ok(pp.createLabels.includes("stale") && pp.views.some((v) => v.name === "Stale"), "stale label + view provisioned when stale_days set");
1629
+ const v = validateLinearConfig({ meta: { schema_version: 1, program: "T", linear: { team: "ENG", stale_days: 0 } }, pis: [] });
1630
+ ok(v.errors.some((e) => e.includes("stale_days")), "stale_days 0 rejected — a bad knob must not silently disable the guardrail");
1631
+ });
1632
+
1633
+ // ── linear-core: cycles ───────────────────────────────────────────────────────
1634
+ // WHY: the active cycle IS the elected weekly batch — if assignment oscillates, clears a human's
1635
+ // future-cycle parking, or lets demotions linger, the cycle chart lies and "this week" silently
1636
+ // re-inflates; and with no active cycle the sync must never guess one.
1637
+ test("cyclePlan assigns active/next on drift, clears only current-cycle demotions, spares future parking", () => {
1638
+ const g = { meta: { schema_version: 1, program: "T", linear: { team: "ENG", cycles: "on" } },
1639
+ pis: [{ id: "p", title: "P", status: "active", linear: { project: "proj-1" }, sprints: [
1640
+ { id: "s1", title: "A", status: "active", invoke: "a", linear: "ENG-1" }, // uncycled → assign
1641
+ { id: "s2", title: "B", status: "next", invoke: "b", linear: "ENG-2" }, // already in → no-op
1642
+ { id: "s3", title: "C", status: "scheduled", invoke: "c", linear: "ENG-3" }, // demoted, in current → clear
1643
+ { id: "s4", title: "D", status: "scheduled", invoke: "d", linear: "ENG-4" }, // parked in FUTURE cycle → untouched
1644
+ { id: "s5", title: "E", status: "complete", invoke: "e", linear: "ENG-5" }, // done → never a candidate
1645
+ { id: "s6", title: "F", status: "next", invoke: "f" }, // unmapped → not a candidate
1646
+ ]}]};
1647
+ const issues = { "ENG-1": { id: "u1", cycleId: null }, "ENG-2": { id: "u2", cycleId: "cyc-1" },
1648
+ "ENG-3": { id: "u3", cycleId: "cyc-1" }, "ENG-4": { id: "u4", cycleId: "cyc-2" }, "ENG-5": { id: "u5", cycleId: "cyc-1" } };
1649
+ const plan = cyclePlan({ graph: g, activeCycleId: "cyc-1", issues });
1650
+ eq(plan.assign.map((x) => x.invoke), ["a"], "only the drifted active/next issue assigns");
1651
+ eq(plan.clear.map((x) => x.invoke), ["c"], "only the current-cycle demotion clears — future parking and done work untouched");
1652
+ eq(cyclePlan({ graph: g, activeCycleId: null, issues }), { assign: [], clear: [] }, "no active cycle → nothing");
1653
+ ok(provisionPlan({ graph: g, teamLabels: {}, cfg: normalizeLinearConfig(g.meta) }).views.some((v) => v.name === "This cycle"), "cycles on → This cycle view offered");
1654
+ ok(!provisionPlan({ graph: g, teamLabels: {}, cfg: normalizeLinearConfig({ linear: { team: "ENG" } }) }).views.some((v) => v.name === "This cycle"), "cycles off → view not offered");
1655
+ const v = validateLinearConfig({ meta: { schema_version: 1, program: "T", linear: { team: "ENG", cycles: "weekly" } }, pis: [] });
1656
+ ok(v.errors.some((e) => e.includes('cycles "weekly"')), "invalid cycles value blocks at validate");
1657
+ const vc = validateLinearConfig({ meta: { schema_version: 1, program: "T", linear: { team: "ENG", cycle_capacity: 0 } }, pis: [] });
1658
+ ok(vc.errors.some((e) => e.includes("cycle_capacity")), "cycle_capacity 0 rejected — a bad knob must not silently disable the cap");
1659
+ });
1660
+
1661
+ // ── linear-core: PI status → project status ──────────────────────────────────
1662
+ // Mirrors the live workspace inventory (organization.projectStatuses): stock has NO paused type.
1663
+ const P_STATUSES = [
1664
+ { id: "ps-b", name: "Backlog", type: "backlog", position: 0 },
1665
+ { id: "ps-p", name: "Planned", type: "planned", position: 1 },
1666
+ { id: "ps-s", name: "In Progress", type: "started", position: 2 },
1667
+ { id: "ps-c", name: "Completed", type: "completed", position: 3 },
1668
+ { id: "ps-x", name: "Canceled", type: "canceled", position: 4 },
1669
+ ];
1670
+
1671
+ // WHY: every project reading "Backlog" regardless of PI status makes the initiative rollup — the
1672
+ // first screen a human reads — lie about what's shipped vs in flight (live board: 64/64 stuck).
1673
+ test("resolveProjectStatus maps PI status to project-status TYPE, held falls back past a missing paused", () => {
1674
+ eq(resolveProjectStatus("complete", P_STATUSES).id, "ps-c", "complete → completed");
1675
+ eq(resolveProjectStatus("active", P_STATUSES).id, "ps-s", "active → started");
1676
+ eq(resolveProjectStatus("next", P_STATUSES).id, "ps-p", "next → planned");
1677
+ eq(resolveProjectStatus("scheduled", P_STATUSES).id, "ps-b", "scheduled → backlog");
1678
+ eq(resolveProjectStatus("optionality", P_STATUSES).id, "ps-b", "optionality → backlog");
1679
+ eq(resolveProjectStatus("gated", P_STATUSES).id, "ps-p", "held → planned when the workspace has no paused type");
1680
+ const withPaused = [...P_STATUSES, { id: "ps-z", name: "Paused", type: "paused", position: 5 }];
1681
+ eq(resolveProjectStatus("gated", withPaused).id, "ps-z", "held → paused when the workspace declares it");
1682
+ eq(resolveProjectStatus("active", null), null, "null inventory → null (status projection off)");
1683
+ });
1684
+
1685
+ // WHY: a workspace whose inventory misses every type in a chain can't be silently skipped — the
1686
+ // push would keep "correcting" nothing forever; fail naming what exists, mirroring resolvePushState.
1687
+ test("resolveProjectStatus throws listing the available statuses when no chain type exists", () => {
1688
+ const weird = [{ id: "ps-only", name: "Odd", type: "triage", position: 0 }];
1689
+ throws(() => resolveProjectStatus("complete", weird), "no project status of type completed");
1690
+ throws(() => resolveProjectStatus("complete", weird), "Odd:triage");
1691
+ });
1692
+
1693
+ // WHY: statusId must diff like every other project field or every sync spams projectUpdate on
1694
+ // all 64 projects; and with a null inventory the plan must be byte-identical to the pre-feature
1695
+ // tool so a degraded fetch (or an old test fixture) changes nothing.
1696
+ test("buildPushPlan projects statusId: drift → one update, match → zero ops, null inventory → absent", () => {
1697
+ const cfg = normalizeLinearConfig(pushGraph().meta);
1698
+ const snapAt = (statusId) => {
1699
+ const s = SNAP();
1700
+ s.projects["proj-1"] = { ...s.projects["proj-1"], statusId };
1701
+ return s;
1702
+ };
1703
+ // active PI whose project sits in backlog status → exactly one updateProject carrying only statusId
1704
+ const drift = buildPushPlan({ graph: pushGraph(), backlog: null, cfg, teamStates: L_STATES, existing: snapAt("ps-b"), projectStatuses: P_STATUSES });
1705
+ const upd = drift.ops.find((o) => o.op === "updateProject");
1706
+ eq(upd.payload, { statusId: "ps-s" }, "only the drifted statusId is sent (active → started)");
1707
+ // matching status → no project op at all
1708
+ const match = buildPushPlan({ graph: pushGraph(), backlog: null, cfg, teamStates: L_STATES, existing: snapAt("ps-s"), projectStatuses: P_STATUSES });
1709
+ eq(match.ops.filter((o) => o.op === "updateProject").length, 0, "matching statusId → zero project updates");
1710
+ // null inventory (degraded fetch) → plan identical to the pre-feature shape: no statusId anywhere
1711
+ const off = buildPushPlan({ graph: pushGraph(), backlog: null, cfg, teamStates: L_STATES, existing: snapAt("ps-b"), projectStatuses: null });
1712
+ eq(off.ops.filter((o) => o.op === "updateProject").length, 0, "null inventory → no status correction attempted");
1713
+ // a new project carries its statusId on create
1714
+ const g = pushGraph(); delete g.pis[0].linear;
1715
+ const create = buildPushPlan({ graph: g, backlog: null, cfg, teamStates: L_STATES, existing: { projects: {}, issues: {} }, projectStatuses: P_STATUSES });
1716
+ eq(create.ops.find((o) => o.op === "createProject").payload.statusId, "ps-s", "create includes the mapped statusId");
1717
+ });
1718
+
1719
+ // WHY: est_sessions is the roadmap's own estimate; as prose in the description it was unsortable and
1720
+ // couldn't roll up on the board. It must ride the native `estimate` field — rounded to an integer,
1721
+ // clamped to estimate_max so an oversize slice can't push an out-of-scale value, and 0/null left
1722
+ // unestimated (never a pushed 0, which needs the team's allow-zero setting).
1723
+ test("buildPushPlan pushes est_sessions as native estimate: rounded, clamped, zero-skipped, idempotent", () => {
1724
+ const g = { meta: { schema_version: 1, program: "T", linear: { team: "ENG" } }, // estimate_max defaults to 5
1725
+ pis: [{ id: "p", title: "P", status: "active", linear: { project: "proj-1" }, sprints: [
1726
+ { id: "s1", title: "Small", status: "next", invoke: "small", est_sessions: 1.5 }, // → round → 2
1727
+ { id: "s2", title: "Huge", status: "next", invoke: "huge", est_sessions: 16 }, // → clamp → 5
1728
+ { id: "s3", title: "Zero", status: "next", invoke: "zero", est_sessions: 0 }, // → unestimated
1729
+ ]}]};
1730
+ const cfg = normalizeLinearConfig(g.meta);
1731
+ const existing = { projects: { "proj-1": { id: "proj-1", name: "P" } }, issues: {} };
1732
+ const plan = buildPushPlan({ graph: g, backlog: null, cfg, teamStates: L_STATES, existing, labels: {} });
1733
+ const est = Object.fromEntries(plan.ops.filter((o) => o.op === "createIssue").map((o) => [o.writeBack.invoke, o.payload.estimate]));
1734
+ eq(est.small, 2, "1.5 sessions rounds to 2 points");
1735
+ eq(est.huge, 5, "16 sessions clamps to estimate_max (5) — validate warns to split it");
1736
+ ok(!("estimate" in plan.ops.find((o) => o.writeBack.invoke === "zero").payload), "0 sessions → no estimate field (unestimated, not a pushed 0)");
1737
+ // idempotent: a mapped issue whose Linear estimate already equals the pushed value → no update
1738
+ const g2 = { meta: g.meta, pis: [{ id: "p", title: "P", status: "active", linear: { project: "proj-1" }, sprints: [
1739
+ { id: "s1", title: "Small", status: "next", invoke: "small", est_sessions: 2, linear: "ENG-9" } ]}]};
1740
+ const node = { invoke: "small", title: "Small", what: "Small", gate: "default" }; // flatten defaults gate → match it
1741
+ const cur = { projects: { "proj-1": { id: "proj-1", name: "P" } }, issues: { "ENG-9": {
1742
+ id: "u9", title: "Small", description: issueDescription(node, cfg, { target: { type: "slice", key: "small" } }),
1743
+ priority: 0, estimate: 2, stateId: "st-u", projectId: "proj-1", labelIds: [] } } };
1744
+ const noop = buildPushPlan({ graph: g2, backlog: null, cfg, teamStates: L_STATES, existing: cur, labels: {} });
1745
+ eq(noop.ops.filter((o) => o.op === "updateIssue").length, 0, "matching estimate → zero updates");
1746
+ // drifted estimate → exactly one update carrying ONLY estimate
1747
+ const cur3 = JSON.parse(JSON.stringify(cur)); cur3.issues["ENG-9"].estimate = 4;
1748
+ const drift = buildPushPlan({ graph: g2, backlog: null, cfg, teamStates: L_STATES, existing: cur3, labels: {} });
1749
+ eq(drift.ops.find((o) => o.op === "updateIssue").payload, { estimate: 2 }, "only the drifted estimate is sent");
1750
+ // a mapped issue that LOST its est_sessions keeps its stale Linear estimate — the points>0 guard
1751
+ // must NOT emit an update to clear it to 0 (which would churn AND needs the team's allow-zero setting)
1752
+ const g4 = { meta: g.meta, pis: [{ id: "p", title: "P", status: "active", linear: { project: "proj-1" }, sprints: [
1753
+ { id: "s1", title: "Small", status: "next", invoke: "small", linear: "ENG-9" } ]}]}; // no est_sessions
1754
+ const cur4 = JSON.parse(JSON.stringify(cur)); cur4.issues["ENG-9"].estimate = 3;
1755
+ const removed = buildPushPlan({ graph: g4, backlog: null, cfg, teamStates: L_STATES, existing: cur4, labels: {} });
1756
+ eq(removed.ops.filter((o) => o.op === "updateIssue").length, 0, "removed est_sessions → no update (stale estimate tolerated, never cleared)");
1757
+ });
1758
+
1759
+ // WHY: a slice bigger than the estimate scale can't map to one estimate point and is too big to fan
1760
+ // out as one session — validate must surface it (where you'd split it), not let it clamp silently.
1761
+ test("validate warns on a slice whose est_sessions exceeds estimate_max", () => {
1762
+ const over = { meta: { schema_version: 1, program: "T", linear: { team: "ENG" } },
1763
+ pis: [{ id: "p", title: "P", status: "active", sprints: [
1764
+ { id: "s1", title: "Big", status: "next", invoke: "big", est_sessions: 16 },
1765
+ { id: "s2", title: "Done big", status: "complete", invoke: "donebig", est_sessions: 16 }, // done → no warning
1766
+ ]}]};
1767
+ const w = validateLinearConfig(over).warnings.filter((m) => m.includes("estimate_max"));
1768
+ eq(w.length, 1, "exactly one oversize warning — the not-done slice only");
1769
+ ok(w[0].includes("p/s1") && w[0].includes("split"), "names the slice and says split it");
1770
+ // no meta.linear → no estimate concept → no warning even for a 16
1771
+ const noLinear = { meta: { schema_version: 1, program: "T" }, pis: over.pis };
1772
+ eq(validateLinearConfig(noLinear).warnings.filter((m) => m.includes("estimate_max")).length, 0, "no Linear config → no oversize warning");
1773
+ });
1774
+
1775
+ // ── the plate (My Issues hopper) ──────────────────────────────────────────────
1776
+ // WHY: the plate must be a CURATED subset (signal), never everything, and "what I'm actively working"
1777
+ // is always on it. Off without meta.plate (backward-compat). Explicit ∪ active ∪ in_progress.
1778
+ test("platedKeys: off without meta.plate; else explicit ∪ active slices ∪ in_progress items", () => {
1779
+ const pis = [{ id: "p", title: "P", status: "active", sprints: [
1780
+ { id: "s1", title: "A", status: "active", invoke: "a" },
1781
+ { id: "s2", title: "B", status: "next", invoke: "b" },
1782
+ { id: "s3", title: "C", status: "complete", invoke: "c" } ]}];
1783
+ eq(platedKeys({ meta: { schema_version: 1 }, pis }, null), null, "no meta.plate → feature off (null)");
1784
+ const bl = { items: [{ id: "x", status: "in_progress" }, { id: "y", status: "open" }] };
1785
+ eq([...platedKeys({ meta: { schema_version: 1, plate: ["b", "z"] }, pis }, bl)].sort(), ["a", "b", "x", "z"],
1786
+ "explicit(b,z) ∪ active(a) ∪ in_progress(x); complete/next/open never auto-added");
1787
+ });
1788
+
1789
+ // WHY: 'complete only' is the chosen drain breakpoint — a merged slice leaves the hopper, a blocked one
1790
+ // STAYS (visible reminder). Draining the wrong status silently loses your batch.
1791
+ test("plateDrainKeys: complete-only — drains finished explicit entries, keeps blocked/active", () => {
1792
+ const g = { meta: { plate: ["a", "b", "c", "x", "ghost"] }, pis: [{ id: "p", title: "P", status: "active", sprints: [
1793
+ { id: "s1", title: "A", status: "complete", invoke: "a" },
1794
+ { id: "s2", title: "B", status: "blocked", invoke: "b" },
1795
+ { id: "s3", title: "C", status: "active", invoke: "c" } ]}]};
1796
+ eq(plateDrainKeys(g, { items: [{ id: "x", status: "done" }] }).sort(), ["a", "x"],
1797
+ "complete slice + done item drain; blocked & active stay; unknown key left alone");
1798
+ });
1799
+
1800
+ // WHY: a malformed meta.plate silently mis-projects My Issues; structure must error and an over-cap list
1801
+ // must warn — the whole point is a signal-rich hopper.
1802
+ test("validatePlate: structural errors + the plate_max signal cap", () => {
1803
+ eq(validatePlate({ meta: {} }, 7).errors.length, 0, "absent → clean");
1804
+ ok(validatePlate({ meta: { plate: "nope" } }, 7).errors[0].includes("must be a list"), "non-array errors");
1805
+ ok(validatePlate({ meta: { plate: [1, "ok"] } }, 7).errors.some((e) => e.includes("must be strings")), "non-string entry errors");
1806
+ ok(validatePlate({ meta: { plate: ["a", "b", "c"] } }, 2).warnings[0].includes("plate_max"), "over cap warns");
1807
+ });
1808
+
1809
+ // WHY: meta.plate grows and must stay human-readable — a flow seq [a, b] is the exact unreadability the
1810
+ // block-style store guarantees elsewhere.
1811
+ test("setPlateDoc writes meta.plate as a block sequence", () => {
1812
+ const doc = parseDocument("meta:\n schema_version: 1\npis: []\n");
1813
+ setPlateDoc(doc, ["a", "b"]);
1814
+ const out = String(doc);
1815
+ ok(/plate:\n\s+- a\n\s+- b/.test(out), "block seq under meta.plate");
1816
+ ok(!out.includes("[a, b]"), "not a flow seq");
1817
+ });
1818
+
1819
+ // WHY: the plate assigns YOU a curated subset (assignee) + tags each with the plate label, so My Issues ==
1820
+ // your batch. Feature-off must stay byte-identical (no assignee ever), even with a viewer present.
1821
+ test("buildPushPlan plate: assigns viewer + plate label on create; off-plate none; feature off inert", () => {
1822
+ const g = (over) => ({ meta: { schema_version: 1, program: "T", linear: { team: "ENG" }, ...over }, pis: [
1823
+ { id: "p", title: "P", status: "active", linear: { project: "proj-1" }, sprints: [
1824
+ { id: "s1", title: "On", status: "next", invoke: "on" },
1825
+ { id: "s2", title: "Off", status: "next", invoke: "off" } ]}]});
1826
+ const existing = { projects: { "proj-1": { id: "proj-1", name: "P" } }, issues: {} };
1827
+ const LBL = { roadmap: "l-mark", plate: "l-plate" };
1828
+ const off = buildPushPlan({ graph: g(), backlog: null, cfg: normalizeLinearConfig(g().meta), teamStates: L_STATES, existing, labels: LBL, viewerId: "me" });
1829
+ ok(off.ops.filter((o) => o.op === "createIssue").every((o) => !("assigneeId" in o.payload)), "feature off → no assignee on any issue");
1830
+ ok(off.ops.filter((o) => o.op === "createIssue").every((o) => !(o.payload.labelIds || []).includes("l-plate")), "feature off → no plate label either");
1831
+ const meta = { plate: ["on", "ghost"] };
1832
+ const on = buildPushPlan({ graph: g(meta), backlog: null, cfg: normalizeLinearConfig(g(meta).meta), teamStates: L_STATES, existing, labels: LBL, viewerId: "me" });
1833
+ const onOp = on.ops.find((o) => o.writeBack && o.writeBack.invoke === "on");
1834
+ const offOp = on.ops.find((o) => o.writeBack && o.writeBack.invoke === "off");
1835
+ eq(onOp.payload.assigneeId, "me", "plated slice → assigned to the viewer");
1836
+ ok(onOp.payload.labelIds.includes("l-plate"), "plated slice → carries the plate label");
1837
+ ok(!("assigneeId" in offOp.payload) && !offOp.payload.labelIds.includes("l-plate"), "off-plate slice → neither assignee nor plate label");
1838
+ eq(on.unmatchedPlate, ["ghost"], "an explicit key matching no slice/item is reported (typo guard)");
1839
+ });
1840
+
1841
+ // WHY: the safety contract on UPDATE — an issue that fell off the plate is unassigned ONLY if WE plated it
1842
+ // (carries the label); a hand-assignment in Linear (no label) is never disturbed.
1843
+ test("buildPushPlan plate update: unassigns a fallen-off issue we labeled, spares hand-assignments", () => {
1844
+ const g = { meta: { schema_version: 1, program: "T", linear: { team: "ENG" }, plate: ["keep"] }, pis: [
1845
+ { id: "p", title: "P", status: "active", linear: { project: "proj-1" }, sprints: [
1846
+ { id: "s1", title: "S", status: "next", invoke: "keep", linear: "ENG-1" },
1847
+ { id: "s2", title: "T", status: "next", invoke: "fell", linear: "ENG-2" },
1848
+ { id: "s3", title: "U", status: "next", invoke: "hand", linear: "ENG-3" } ]}]};
1849
+ const LBL = { roadmap: "l-mark", plate: "l-plate" };
1850
+ const mk = (over) => ({ id: "u", title: "x", description: "", priority: 0, stateId: "st-u", projectId: "proj-1", assigneeId: "me", labelIds: ["l-mark"], ...over });
1851
+ const existing = { projects: { "proj-1": { id: "proj-1", name: "P" } }, issues: {
1852
+ "ENG-1": mk({ labelIds: ["l-mark", "l-plate"] }), // on the plate, already assigned+labeled → no assignee churn
1853
+ "ENG-2": mk({ labelIds: ["l-mark", "l-plate"] }), // fell off, WE labeled it → unassign
1854
+ "ENG-3": mk({ labelIds: ["l-mark"] }), // hand-assigned (no plate label) → untouched
1855
+ }};
1856
+ const plan = buildPushPlan({ graph: g, backlog: null, cfg: normalizeLinearConfig(g.meta), teamStates: L_STATES, existing, labels: LBL, viewerId: "me" });
1857
+ const byId = Object.fromEntries(plan.ops.filter((o) => o.op === "updateIssue").map((o) => [o.identifier, o.payload]));
1858
+ ok(!("assigneeId" in (byId["ENG-1"] || {})), "on-plate + already assigned → no assignee churn");
1859
+ eq(byId["ENG-2"].assigneeId, null, "fell off + carries our plate label → unassigned");
1860
+ ok(!("assigneeId" in (byId["ENG-3"] || {})), "hand-assignment (no plate label) → never touched");
1861
+ });
1862
+
1863
+ // WHY: the stated safety invariant — meta.plate ON but the viewer id unknown (fetch failed) must assign
1864
+ // and label NOTHING. A regression dropping the `!!viewerId` guard would silently assign issues to no one.
1865
+ test("buildPushPlan plate: viewer unknown → no assignee and no plate label, even on-plate", () => {
1866
+ const g = { meta: { schema_version: 1, program: "T", linear: { team: "ENG" }, plate: ["on"] }, pis: [
1867
+ { id: "p", title: "P", status: "active", linear: { project: "proj-1" }, sprints: [
1868
+ { id: "s1", title: "On", status: "next", invoke: "on" } ]}]};
1869
+ const plan = buildPushPlan({ graph: g, backlog: null, cfg: normalizeLinearConfig(g.meta), teamStates: L_STATES,
1870
+ existing: { projects: { "proj-1": { id: "proj-1", name: "P" } }, issues: {} }, labels: { roadmap: "l-mark", plate: "l-plate" }, viewerId: null });
1871
+ const onOp = plan.ops.find((o) => o.writeBack && o.writeBack.invoke === "on");
1872
+ ok(!("assigneeId" in onOp.payload), "viewer unknown → no assigneeId");
1873
+ ok(!(onOp.payload.labelIds || []).includes("l-plate"), "viewer unknown → no plate label (label never lies)");
1874
+ });
1875
+
1876
+ // WHY: the plate's safe-unassign depends on the 'plate' label existing — provision must create it when the
1877
+ // feature is on, and NOT stamp a stray label on repos that don't use the plate.
1878
+ test("provisionPlan includes the plate label only when meta.plate is defined", () => {
1879
+ const base = { meta: { schema_version: 1, program: "T", linear: { team: "ENG" } }, pis: [
1880
+ { id: "p", title: "P", status: "active", sprints: [{ id: "s1", title: "S", status: "active", invoke: "x" }] }] };
1881
+ ok(!provisionPlan({ graph: base, teamLabels: {} }).createLabels.includes("plate"), "no meta.plate → no plate label");
1882
+ ok(provisionPlan({ graph: { ...base, meta: { ...base.meta, plate: [] } }, teamLabels: {} }).createLabels.includes("plate"), "meta.plate present → plate label provisioned");
1883
+ });
1884
+
1885
+ // WHY: the plate MCP tools are how a planning session (/prioritize) curates My Issues — set replaces, add
1886
+ // unions (and enables the feature from absent), remove pulls off. They edit the Document like set_fields.
1887
+ test("plate MCP mutations: set replaces, add unions + enables, remove filters", () => {
1888
+ const base = "meta:\n schema_version: 1\n program: T\npis:\n - id: p\n title: P\n status: active\n sprints:\n - { id: s1, title: A, status: next, invoke: a }\n";
1889
+ const doc = parseDocument(base);
1890
+ eq(setPlate(doc, { keys: ["a", "b", "b"] }).keys, ["a", "b"], "set dedups + returns the new list");
1891
+ eq(doc.toJS().meta.plate, ["a", "b"], "set wrote meta.plate onto the Document");
1892
+ eq(addPlate(doc, { keys: ["b", "c"] }).keys, ["a", "b", "c"], "add unions (dedup), preserves order");
1893
+ eq(removePlate(doc, { keys: ["a"] }).keys, ["b", "c"], "remove filters the given keys");
1894
+ const fresh = parseDocument(base);
1895
+ addPlate(fresh, { keys: ["a"] });
1896
+ eq(fresh.toJS().meta.plate, ["a"], "add on a plate-less roadmap creates meta.plate (enables the feature)");
1897
+ throws(() => setPlate(doc, {}), "requires keys", "set without keys throws");
1898
+ });
1899
+
1900
+ // WHY: granularity is the leak-control lever — 'pis' must emit NO issues, and a per-PI
1901
+ // override must flip only that PI, or a public Linear team sees work it shouldn't.
1902
+ test("granularity gates issue ops globally and per-PI", () => {
1903
+ const pisOnly = pushGraph({ granularity: "pis" });
1904
+ const plan = buildPushPlan({ graph: pisOnly, backlog: null, cfg: normalizeLinearConfig(pisOnly.meta), teamStates: L_STATES, existing: SNAP() });
1905
+ eq(plan.ops.filter((o) => o.op.includes("Issue")).length, 0, "pis granularity → projects only");
1906
+ const overridden = pushGraph();
1907
+ overridden.pis[0].linear.granularity = "pis"; // per-PI override on a slices-global roadmap
1908
+ const plan2 = buildPushPlan({ graph: overridden, backlog: null, cfg: normalizeLinearConfig(overridden.meta), teamStates: L_STATES, existing: SNAP() });
1909
+ eq(plan2.ops.filter((o) => o.op.includes("Issue")).length, 0, "override suppresses that PI's issues");
1910
+ const withBacklog = pushGraph({ granularity: "slices+backlog" });
1911
+ const backlog = { meta: { schema_version: 1 }, items: [
1912
+ { id: "b1", title: "Fix", kind: "bug", status: "open" },
1913
+ { id: "b2", title: "Moved", kind: "chore", status: "promoted", promoted_to: "auth/s9" },
1914
+ ]};
1915
+ const plan3 = buildPushPlan({ graph: withBacklog, backlog, cfg: normalizeLinearConfig(withBacklog.meta), teamStates: L_STATES, existing: SNAP() });
1916
+ const itemOps = plan3.ops.filter((o) => o.writeBack && o.writeBack.kind === "item");
1917
+ eq(itemOps.length, 1, "open item pushes; promoted item skipped (its sprint carries it)");
1918
+ });
1919
+
1920
+ // WHY: Linear mints its own identifier (PID-n) AFTER creation, so the backlog number a human
1921
+ // actually talks about ("look at b60") is invisible in Linear's triage view unless the title
1922
+ // carries it — without the prefix, finding an item means opening issues one by one.
1923
+ test("backlog items push to Linear with their id prefixed into the title, without double-prefixing", () => {
1924
+ const withBacklog = pushGraph({ granularity: "slices+backlog" });
1925
+ const backlog = { meta: { schema_version: 1 }, items: [
1926
+ { id: "b7", title: "Fix the flaky thing", kind: "bug", status: "open" },
1927
+ { id: "b8", title: "b8 · Already prefixed by a round-trip", kind: "chore", status: "open" },
1928
+ ]};
1929
+ const plan = buildPushPlan({ graph: withBacklog, backlog, cfg: normalizeLinearConfig(withBacklog.meta), teamStates: L_STATES, existing: SNAP() });
1930
+ const items = plan.ops.filter((o) => o.writeBack && o.writeBack.kind === "item");
1931
+ eq(items.find((o) => o.writeBack.id === "b7").payload.title, "b7 · Fix the flaky thing", "id lands in front of the title");
1932
+ eq(items.find((o) => o.writeBack.id === "b8").payload.title, "b8 · Already prefixed by a round-trip", "an already-prefixed title is left alone");
1933
+ });
1934
+
1935
+ // WHY: addPi/addSprint copy args field-by-field; a field the copy-list omits is DROPPED
1936
+ // SILENTLY — that is exactly how a fully-specified PI (summary, priority, initiative all
1937
+ // passed by the caller) landed on the live board as a shell needing a manual repair pass.
1938
+ // The `missing` list is the add-seam nudge that makes new work Linear-ready out of the box.
1939
+ test("addPi/addSprint persist every add-time field and report Linear-readiness gaps", () => {
1940
+ const doc = parseDocument(`meta:\n schema_version: 1\n program: T\npis: []\n`);
1941
+ const r1 = addPi(doc, { id: "p1", title: "P1", status: "active", summary: "One line", priority: { tier: "P1", reason: "r" }, initiative: "Engine credibility", target_date: "2026-08-01" });
1942
+ const pi = doc.toJS().pis[0];
1943
+ eq(pi.summary, "One line", "summary persists (was silently dropped)");
1944
+ eq(pi.priority.tier, "P1", "priority persists (was silently dropped)");
1945
+ eq(pi.initiative, "Engine credibility", "initiative persists (was silently dropped)");
1946
+ eq(pi.target_date, "2026-08-01", "add-time dates persist");
1947
+ ok(!r1.missing, "fully-dialed PI reports no gaps");
1948
+ eq(addPi(doc, { id: "p2", title: "P2" }).missing, ["summary", "priority"], "shell PI reports its readiness gaps");
1949
+ const r3 = addSprint(doc, { pi: "p2", id: "s1", title: "S", invoke: "s1x", status_label: "FABLE-5 candidate — x", dispatch_tier: "fable" });
1950
+ eq(doc.toJS().pis[1].sprints[0].dispatch_tier, "fable", "newer sprint fields persist through add");
1951
+ eq(r3.missing, ["what", "gate", "est_sessions", "priority"], "bare sprint reports its gaps");
1952
+ ok(!addSprint(doc, { pi: "p2", id: "s2", title: "S2", invoke: "s2x", what: "w", gate: "g", est_sessions: 1, priority: { tier: "P2", reason: "r" } }).missing,
1953
+ "dialed sprint reports no gaps");
1954
+ });
1955
+
1956
+ // WHY: an unacked per-PI override silently reshapes what the whole team sees in Linear;
1957
+ // the ack must gate the mutation BEFORE anything is written, with the exact actionable message.
1958
+ test("addPi rejects a conflicting linear override without the ack, exact message; ack or match passes", () => {
1959
+ const y = `meta:\n schema_version: 1\n program: T\n linear:\n team: ENG\npis:\n - id: a\n title: A\n status: active\n sprints:\n - { id: s1, title: S, status: active, invoke: x }\n`;
1960
+ throws(() => addPi(parseDocument(y), { id: "platform", title: "P", linear: { granularity: "pis" } }),
1961
+ `PI "platform" overrides Linear granularity ("pis") against the global meta.linear.granularity ("slices")`,
1962
+ "conflict without ack throws the exact message");
1963
+ const doc = parseDocument(y);
1964
+ addPi(doc, { id: "platform", title: "P", linear: { granularity: "pis" }, yes_linear_override: true });
1965
+ ok(String(doc.getIn(["pis", 1, "linear", "granularity"])) === "pis", "acked override written");
1966
+ addPi(doc, { id: "match", title: "M", linear: { granularity: "slices" } }); // matches global → no ack needed
1967
+ // checkPiOverrideAck standalone: no global config → never throws
1968
+ checkPiOverrideAck(null, { granularity: "pis" }, false, "x");
1969
+ });
1970
+
1971
+ // ── linear-core: pull proposals ───────────────────────────────────────────────
1972
+ // WHY: pull must not re-import the same issue forever (double captures) and inbound edits
1973
+ // must be PROPOSALS, never silent mutations — the human confirms what enters the graph.
1974
+ test("buildPullProposals dedupes known identifiers, captures watch issues with source demarcation, proposes deltas", () => {
1975
+ const cfg = normalizeLinearConfig({ linear: { team: "ENG", pull: "propose",
1976
+ watch: [{ team: "PUB", project: "Submit an issue", kind: "bug", priority: { tier: "P3" } }] } });
1977
+ const graph = pushGraph();
1978
+ const backlog = { meta: { schema_version: 1 }, items: [{ id: "pub-9", title: "Known", kind: "bug", status: "open", linear: "PUB-9" }] };
1979
+ const inbound = [
1980
+ { identifier: "PUB-9", title: "Known", priority: 0, state: { type: "backlog" }, team: "PUB", project: "Submit an issue" }, // known item, state backlog→scheduled? (item: no delta for open)
1981
+ { identifier: "PUB-42", title: "Crash on empty config", priority: 1, state: { type: "backlog" }, team: "PUB", project: "Submit an issue" },
1982
+ { identifier: "PUB-43", title: "Watched but off-project", priority: 0, state: { type: "backlog" }, team: "PUB", project: "Other" },
1983
+ { identifier: "ENG-1", title: "Login", priority: 2, state: { type: "completed" }, team: "ENG", project: null },
1984
+ ];
1985
+ const { newItems, deltas } = buildPullProposals({ cfg, inbound, graph, backlog });
1986
+ eq(newItems.length, 1, "known + off-watch identifiers skipped; one genuine capture");
1987
+ const it = newItems[0];
1988
+ eq(it.id, "pub-42", "stable id = lowercased identifier (the cross-machine dedupe key)");
1989
+ eq(it.source.linear, { team: "PUB", project: "Submit an issue", issue: "PUB-42" }, "origin demarcation carried");
1990
+ eq(it.priority, { tier: "P0" }, "the issue's own Urgent(1) outranks the watch default P3");
1991
+ eq(it.kind, "bug", "watch default kind applied");
1992
+ const statusDelta = deltas.find((d) => d.key === "auth-login" && d.field === "status");
1993
+ eq([statusDelta.from, statusDelta.to], ["active", "complete"], "mapped-issue completion is a PROPOSAL, not a mutation");
1994
+ const priDelta = deltas.find((d) => d.key === "auth-login" && d.field === "priority.tier");
1995
+ eq([priDelta.from, priDelta.to], [null, "P1"], "priority edit proposed");
1996
+ });
1997
+
1998
+ // WHY: live-caught — Linear rejects a project name > 80 or description > 255 with a hard
1999
+ // "Argument Validation Error" that aborts the whole push. Clip at the projection layer, and
2000
+ // clip on BOTH create and the drift diff so a clipped project stays idempotent (no re-churn).
2001
+ // WHY: the subtitle (Linear's 255-char `description`) is where the board truncates with "…";
2002
+ // cramming the full exit there is why projects read thin+cut-off. The full text belongs in the
2003
+ // uncapped `content` body. If either mis-sizes, or the snapshot re-drifts, the board regresses.
2004
+ test("project: name clips to 80, subtitle is a concise capped line, content is uncapped, idempotent", () => {
2005
+ const longTitle = "Account portal UX: onboarding, empty states, settings interactivity, billing depth, team management";
2006
+ const g = { meta: { schema_version: 1, program: "T", linear: { team: "ENG" } },
2007
+ pis: [{ id: "portal", title: longTitle, theme: "ux", exit_criteria: "Portal ships guided onboarding. " + "detail ".repeat(80), status: "active", sprints: [
2008
+ { id: "s1", title: "S", status: "next", invoke: "portal-s1" } ] }] };
2009
+ const cfg = normalizeLinearConfig(g.meta);
2010
+ const pi = g.pis[0];
2011
+ const name = projectName(pi), desc = projectDescription(pi), content = projectContent(pi);
2012
+ ok(name.length <= LINEAR_PROJECT_NAME_MAX && name.endsWith("..."), "name clipped to <=80 with ellipsis");
2013
+ eq(desc, "Portal ships guided onboarding.", "subtitle = exit's first sentence (concise, not truncated)");
2014
+ ok(content.includes("detail detail") && content.length > LINEAR_PROJECT_DESC_MAX, "content holds the full body, uncapped by 255");
2015
+ const clipped = projectDescription({ title: "T", exit_criteria: "x".repeat(400) });
2016
+ ok(clipped.length <= LINEAR_PROJECT_DESC_MAX && clipped.endsWith("..."), "an unbroken long exit still clips the subtitle to 255");
2017
+ const create = buildPushPlan({ graph: g, backlog: null, cfg, teamStates: L_STATES, existing: { projects: {}, issues: {} }, labels: {} });
2018
+ const cp = create.ops.find((o) => o.op === "createProject");
2019
+ eq(cp.payload.description, desc, "create carries the subtitle");
2020
+ eq(cp.payload.content, content, "create carries the full body");
2021
+ // idempotency: a snapshot storing subtitle + content must produce ZERO project ops
2022
+ pi.linear = { project: "proj-1" };
2023
+ const noop = buildPushPlan({ graph: g, backlog: null, cfg, teamStates: L_STATES,
2024
+ existing: { projects: { "proj-1": { id: "proj-1", name, description: desc, content } }, issues: {} }, labels: {} });
2025
+ ok(!noop.ops.some((o) => o.op === "updateProject"), "stored values already in Linear → no re-update");
2026
+ });
2027
+
2028
+ // WHY: live-caught — several roadmap statuses (gated/blocked/paused → started; scheduled/
2029
+ // optionality → backlog) collapse to one Linear type on push, so reading that type back
2030
+ // proposed a false status change on every sync. A big roadmap's first sync spammed dozens
2031
+ // of gated→active / optionality→scheduled proposals. Only a DIFFERENT type is a human move.
2032
+ test("buildPullProposals suppresses round-trip status echoes, keeps genuine human moves", () => {
2033
+ const cfg = normalizeLinearConfig({ linear: { team: "ENG", pull: "propose" } });
2034
+ const graph = { meta: { schema_version: 1, program: "T", linear: { team: "ENG" } },
2035
+ pis: [{ id: "a", title: "A", status: "active", sprints: [
2036
+ { id: "s1", title: "Gated", status: "gated", gated_on: "C", invoke: "g", linear: "ENG-1" },
2037
+ { id: "s2", title: "Opt", status: "optionality", invoke: "o", linear: "ENG-2" },
2038
+ { id: "s3", title: "Active", status: "active", invoke: "act", linear: "ENG-3" },
2039
+ ]}]};
2040
+ const inbound = [
2041
+ { identifier: "ENG-1", title: "Gated", priority: 0, state: { type: "unstarted" }, team: "ENG", project: null }, // echo of our push (gated→unstarted)
2042
+ { identifier: "ENG-2", title: "Opt", priority: 0, state: { type: "backlog" }, team: "ENG", project: null }, // echo (optionality→backlog)
2043
+ { identifier: "ENG-3", title: "Active", priority: 0, state: { type: "completed" }, team: "ENG", project: null },// GENUINE human move (started→completed)
2044
+ ];
2045
+ const { deltas } = buildPullProposals({ cfg, inbound, graph, backlog: null });
2046
+ const statusDeltas = deltas.filter((d) => d.field === "status");
2047
+ eq(statusDeltas.length, 1, "only the genuine move proposes a status delta — the two echoes are silent");
2048
+ eq([statusDeltas[0].key, statusDeltas[0].to], ["act", "complete"], "the human completion survives");
2049
+ });
2050
+
2051
+ // WHY: pidgeon's board was tacky ("Headline — subhead...") because the project name was the
2052
+ // PI title verbatim. The name must be the headline (pre-dash), with the dropped subhead
2053
+ // preserved in the description so no context is lost.
2054
+ test("projectName takes the headline before the em-dash; description keeps the full title", () => {
2055
+ const pi = { title: "Oracle data foundation — acquire + consume the cross-standard data moat", theme: "data" };
2056
+ eq(projectName(pi), "Oracle data foundation", "headline only");
2057
+ ok(projectDescription(pi).includes("acquire + consume the cross-standard data moat"), "subhead preserved in description");
2058
+ eq(projectName({ title: "Stripe Projects Provider Integration" }), "Stripe Projects Provider Integration", "no dash → whole title");
2059
+ });
2060
+
2061
+ // WHY: 23 of pidgeon's 50 PIs were fully shipped → 23 bare 0-issue projects cluttering the
2062
+ // board. A PI with no projectable work must not create a project (but an already-mapped one
2063
+ // is kept in sync, never orphaned).
2064
+ test("buildPushPlan skips a project for a PI whose slices are all done", () => {
2065
+ const g = (mapped) => ({ meta: { schema_version: 1, program: "T", linear: { team: "ENG" } }, pis: [
2066
+ { id: "shipped", title: "All done", status: "complete", ...(mapped ? { linear: { project: "p-old" } } : {}), sprints: [
2067
+ { id: "s1", title: "S", status: "complete", invoke: "done-1", prs: ["#1"] } ] },
2068
+ { id: "live", title: "Has work", status: "active", sprints: [
2069
+ { id: "s1", title: "S", status: "next", invoke: "live-1" } ] },
2070
+ ]});
2071
+ const cfg = normalizeLinearConfig(g().meta);
2072
+ const plan = buildPushPlan({ graph: g(), backlog: null, cfg, teamStates: L_STATES, existing: { projects: {}, issues: {} }, labels: {} });
2073
+ const created = plan.ops.filter((o) => o.op === "createProject").map((o) => o.projectRef);
2074
+ eq(created, ["live"], "only the PI with live work earns a project");
2075
+ // an already-mapped empty PI is still reconciled (never orphaned)
2076
+ const mappedPlan = buildPushPlan({ graph: g(true), backlog: null, cfg, teamStates: L_STATES,
2077
+ existing: { projects: { "p-old": { id: "p-old", name: "stale", description: "" } }, issues: {} }, labels: {} });
2078
+ ok(mappedPlan.ops.some((o) => o.op === "updateProject" && o.id === "p-old"), "mapped empty PI kept in sync");
2079
+ });
2080
+
2081
+ // WHY: held work (blocked/paused/gated) mapped to In Progress made the board's In-Progress
2082
+ // count meaningless. Held slices carry a status:<held> label so the board stays honest AND
2083
+ // the "Held on human" view can filter — provision must create those labels + per-track views.
2084
+ test("held slices get a status label; provisionPlan creates held labels + track views", () => {
2085
+ eq(desiredLabels({ type: "slice" }, { status: "gated", track: "A" }), [MARKER_LABEL, "track:A", "status:gated"], "gated slice: marker + track + status");
2086
+ eq(desiredLabels({ type: "slice" }, { status: "active" }), [MARKER_LABEL], "active slice: marker only (no status label)");
2087
+ eq(HELD_STATUSES, ["blocked", "paused", "gated"], "the held set");
2088
+ const g = { meta: { schema_version: 1, program: "T", linear: { team: "ENG" } }, pis: [
2089
+ { id: "a", title: "A", status: "active", sprints: [
2090
+ { id: "s1", title: "G", status: "gated", gated_on: "C", invoke: "g", track: "B" },
2091
+ { id: "s2", title: "N", status: "next", invoke: "n" } ] } ]};
2092
+ const plan = provisionPlan({ graph: g, teamLabels: {} });
2093
+ ok(plan.createLabels.includes("status:gated"), "gated present → status:gated label created");
2094
+ ok(!plan.createLabels.includes("status:blocked"), "no blocked slice → no status:blocked label (from-graph, not hardcoded)");
2095
+ ok(plan.createLabels.includes("track:B"), "track:B from the graph");
2096
+ ok(plan.views.some((v) => v.name === "Track B"), "a per-track lane view");
2097
+ });
2098
+
2099
+ // WHY: the initiative IO must be idempotent — create only the missing initiative, attach only
2100
+ // the not-yet-attached project — or a re-sync duplicates initiatives and re-links projects.
2101
+ // (The GraphQL shape is unverified live; this tests OUR orchestration against a fake.)
2102
+ test("syncInitiatives creates missing initiatives, skips existing, attaches mapped projects once", async () => {
2103
+ const root = mkdtempSync(join(tmpdir(), "roadmap-init-test-"));
2104
+ mkdirSync(join(root, "docs", "roadmap"), { recursive: true });
2105
+ writeFileSync(join(root, "docs", "roadmap", "roadmap.yaml"),
2106
+ `meta:\n schema_version: 1\n program: T\npis:\n - id: a\n title: A\n initiative: Launch readiness\n status: active\n linear: { project: proj-a }\n sprints:\n - { id: s1, title: S, status: next, invoke: a1 }\n - id: b\n title: B\n initiative: Data foundation\n status: next\n linear: { project: proj-b }\n sprints:\n - { id: s1, title: S, status: next, invoke: b1 }\n`, "utf8");
2107
+ // "Launch readiness" already exists with proj-a already attached; "Data foundation" is new.
2108
+ const fake = fakeLinear({ existingInitiatives: [{ id: "init-lr", name: "Launch readiness", projects: { nodes: [{ id: "proj-a" }] } }] });
2109
+ const r = await syncInitiatives(root, { apiKey: "k", fetchImpl: fake.fetchImpl });
2110
+ eq(r.created, ["Data foundation"], "only the missing initiative is created");
2111
+ eq(r.attached, ["b → Data foundation"], "only the unattached project is linked (proj-a already in its initiative)");
2112
+ const creates = fake.calls.filter((c) => c.query.includes("initiativeCreate")).length;
2113
+ const links = fake.calls.filter((c) => c.query.includes("initiativeToProjectCreate")).length;
2114
+ eq([creates, links], [1, 1], "exactly one create + one attach — no duplicate work");
2115
+ rmSync(root, { recursive: true, force: true });
2116
+ });
2117
+
2118
+ // WHY: the initiative HEADER is the strongest grouping signal; a declared meta.initiatives style must
2119
+ // ride the create for a NEW initiative AND update an existing one on drift — but idempotently, or every
2120
+ // sync re-styles. An already-matching style must fire zero updates.
2121
+ test("syncInitiatives applies meta.initiatives style: on create, on drift, never when already matching", async () => {
2122
+ const root = mkdtempSync(join(tmpdir(), "roadmap-init-style-"));
2123
+ mkdirSync(join(root, "docs", "roadmap"), { recursive: true });
2124
+ writeFileSync(join(root, "docs", "roadmap", "roadmap.yaml"),
2125
+ `meta:\n schema_version: 1\n program: T\n initiatives:\n Launch readiness: { icon: Checklist }\n Trust surface: { icon: Shield }\n Data foundation: { icon: Database }\npis:\n - id: a\n title: A\n initiative: Launch readiness\n status: active\n linear: { project: proj-a }\n sprints: [ { id: s1, title: S, status: next, invoke: a1 } ]\n - id: b\n title: B\n initiative: Trust surface\n status: next\n linear: { project: proj-b }\n sprints: [ { id: s1, title: S, status: next, invoke: b1 } ]\n - id: c\n title: C\n initiative: Data foundation\n status: next\n linear: { project: proj-c }\n sprints: [ { id: s1, title: S, status: next, invoke: c1 } ]\n`, "utf8");
2126
+ // Launch readiness exists with a STALE icon (drift → update); Trust surface already matches (no update);
2127
+ // Data foundation is new (create carries the icon).
2128
+ const fake = fakeLinear({ existingInitiatives: [
2129
+ { id: "init-lr", name: "Launch readiness", icon: "Rocket", color: "", projects: { nodes: [{ id: "proj-a" }] } },
2130
+ { id: "init-ts", name: "Trust surface", icon: "Shield", color: "", projects: { nodes: [{ id: "proj-b" }] } },
2131
+ ]});
2132
+ const r = await syncInitiatives(root, { apiKey: "k", fetchImpl: fake.fetchImpl });
2133
+ eq(fake.calls.find((c) => c.query.includes("initiativeCreate")).variables.input, { name: "Data foundation", icon: "Database" }, "new initiative create carries its declared icon");
2134
+ const updates = fake.calls.filter((c) => c.query.includes("initiativeUpdate"));
2135
+ eq(updates.length, 1, "exactly one update — Launch readiness drift only, Trust surface already matched");
2136
+ eq(updates[0].variables.input, { icon: "Checklist" }, "update sends only the drifted icon");
2137
+ eq(r.styled.sort(), ["Data foundation", "Launch readiness"], "styled = created-with-style + drift-updated (matched one excluded)");
2138
+ rmSync(root, { recursive: true, force: true });
2139
+ });
2140
+
2141
+ // WHY: initiative icon/color is unverified Linear input — a rejection must drop the initiative to
2142
+ // unstyled and let the sync finish (create + attach still happen), never abort the whole run.
2143
+ test("syncInitiatives degrades when Linear rejects an initiative icon: creates/keeps unstyled, never throws", async () => {
2144
+ const root = mkdtempSync(join(tmpdir(), "roadmap-init-degrade-"));
2145
+ mkdirSync(join(root, "docs", "roadmap"), { recursive: true });
2146
+ writeFileSync(join(root, "docs", "roadmap", "roadmap.yaml"),
2147
+ `meta:\n schema_version: 1\n program: T\n initiatives:\n Launch readiness: { icon: Checklist }\n Data foundation: { icon: Database }\npis:\n - id: a\n title: A\n initiative: Launch readiness\n status: active\n linear: { project: proj-a }\n sprints: [ { id: s1, title: S, status: next, invoke: a1 } ]\n - id: b\n title: B\n initiative: Data foundation\n status: next\n linear: { project: proj-b }\n sprints: [ { id: s1, title: S, status: next, invoke: b1 } ]\n`, "utf8");
2148
+ // Launch readiness exists with a stale icon (would drift-update); Data foundation is new (create-with-icon).
2149
+ // Linear rejects BOTH icon mutations → both degrade to unstyled, and the new project's attach still runs.
2150
+ const fake = fakeLinear({ failOn: "initiativeStyle", existingInitiatives: [
2151
+ { id: "init-lr", name: "Launch readiness", icon: "Rocket", color: "", projects: { nodes: [{ id: "proj-a" }] } },
2152
+ ]});
2153
+ const r = await syncInitiatives(root, { apiKey: "k", fetchImpl: fake.fetchImpl }); // must NOT throw
2154
+ eq(r.created, ["Data foundation"], "new initiative still created despite the rejected icon");
2155
+ eq(r.styled, [], "both rejected styles → left unstyled (degraded, not aborted)");
2156
+ ok(fake.calls.some((c) => c.query.includes("initiativeCreate") && !c.variables.input.icon), "create retried without the icon");
2157
+ eq(r.attached, ["b → Data foundation"], "attach still runs after the style degrade");
2158
+ rmSync(root, { recursive: true, force: true });
2159
+ });
2160
+
2161
+ // WHY: 50 flat projects are unnavigable; initiatives are the grouping tier. initiativePlan
2162
+ // collects the distinct initiatives PIs declare and the pi→initiative assignments — the pure
2163
+ // input the (unverified) IO layer creates + attaches from.
2164
+ test("initiativePlan groups PIs by their declared initiative", () => {
2165
+ const g = { pis: [
2166
+ { id: "a", title: "A", initiative: "Launch readiness", status: "active", sprints: [] },
2167
+ { id: "b", title: "B", initiative: "Launch readiness", status: "next", sprints: [] },
2168
+ { id: "c", title: "C", initiative: "Data foundation", status: "next", sprints: [] },
2169
+ { id: "d", title: "D", status: "next", sprints: [] }, // no initiative → ungrouped
2170
+ ]};
2171
+ const plan = initiativePlan(g);
2172
+ eq(plan.initiatives, ["Launch readiness", "Data foundation"], "distinct initiatives, first-seen order");
2173
+ eq(plan.assignments.length, 3, "only PIs that declare an initiative are assigned");
2174
+ eq(plan.assignments.filter((a) => a.initiative === "Launch readiness").map((a) => a.pi), ["a", "b"], "both PIs grouped");
2175
+ });
2176
+
2177
+ // ── milestones (stages within a project) ──────────────────────────────────────
2178
+ // WHY: milestones are PROJECT-scoped stages (per-slice sp.milestone); the pure planner must collect distinct
2179
+ // names PER PI (same name under two PIs = two milestones) with each mapped slice, or syncMilestones mis-groups.
2180
+ test("milestonePlan: per-PI distinct milestone names + mapped-slice assignments", () => {
2181
+ const g = { meta: { schema_version: 1, program: "T" }, pis: [
2182
+ { id: "a", title: "A", status: "active", sprints: [
2183
+ { id: "s1", title: "S", status: "active", invoke: "a1", milestone: "harness", linear: "ENG-1" },
2184
+ { id: "s2", title: "T", status: "next", invoke: "a2", milestone: "harness" },
2185
+ { id: "s3", title: "U", status: "next", invoke: "a3", milestone: "ship", linear: "ENG-3" },
2186
+ { id: "s4", title: "V", status: "next", invoke: "a4" } ]}, // no milestone → excluded
2187
+ { id: "b", title: "B", status: "active", sprints: [
2188
+ { id: "s1", title: "W", status: "active", invoke: "b1", milestone: "harness" } ]} ]}; // same NAME, different PI
2189
+ const plan = milestonePlan(g);
2190
+ eq(plan.pis.length, 2, "both PIs with milestone-tagged slices");
2191
+ const a = plan.pis.find((p) => p.pi === "a");
2192
+ eq(a.milestones, ["harness", "ship"], "distinct names, first-seen order (untagged slice excluded)");
2193
+ eq(a.slices.length, 3, "3 milestone-tagged slices under a");
2194
+ eq(a.slices[0], { invoke: "a1", linear: "ENG-1", milestone: "harness" }, "assignment carries slice + linear + milestone");
2195
+ eq(plan.pis.find((p) => p.pi === "b").milestones, ["harness"], "same name under b is its own (project-scoped)");
2196
+ });
2197
+
2198
+ // WHY: a non-string milestone would break the project-scoped create; validate must catch it.
2199
+ test("validate: sp.milestone must be a string", () => {
2200
+ const g = { meta: { schema_version: 1, program: "T", linear: { team: "ENG" } }, pis: [
2201
+ { id: "p", title: "P", status: "active", sprints: [{ id: "s1", title: "S", status: "active", invoke: "x", milestone: 3 }] }] };
2202
+ ok(validateGraph(g).errors.some((e) => e.includes("milestone must be a string")), "numeric milestone errors");
2203
+ });
2204
+
2205
+ // WHY: syncMilestones must create each PI's declared milestones on its project + attach issues, and be
2206
+ // idempotent (skip existing by name, re-attach only on drift) — else every sync re-creates/re-attaches.
2207
+ test("syncMilestones: creates missing, skips existing, attaches on drift, idempotent, degrades on rejection", async () => {
2208
+ const root = mkdtempSync(join(tmpdir(), "roadmap-milestones-"));
2209
+ mkdirSync(join(root, "docs", "roadmap"), { recursive: true });
2210
+ writeFileSync(join(root, "docs", "roadmap", "roadmap.yaml"),
2211
+ `meta:\n schema_version: 1\n program: T\n linear:\n team: ENG\npis:\n - id: p\n title: P\n status: active\n linear: { project: proj-1 }\n sprints:\n - { id: s1, title: A, status: active, invoke: a1, milestone: harness, linear: ENG-1 }\n - { id: s2, title: B, status: active, invoke: a2, milestone: ship, linear: ENG-2 }\n`, "utf8");
2212
+ // "harness" already exists (pm-h) with ENG-1 already on it (no re-attach); "ship" is new; ENG-2 unattached.
2213
+ const fake = fakeLinear({
2214
+ projectMilestones: { "proj-1": [{ id: "pm-h", name: "harness" }] },
2215
+ snapshot: {
2216
+ "ENG-1": { id: "u1", identifier: "ENG-1", projectMilestone: { id: "pm-h" } },
2217
+ "ENG-2": { id: "u2", identifier: "ENG-2", projectMilestone: null },
2218
+ },
2219
+ });
2220
+ const r = await syncMilestones(root, { apiKey: "k", fetchImpl: fake.fetchImpl });
2221
+ eq(r.created, ["p/ship"], "only the missing milestone is created (harness existed)");
2222
+ eq(r.attached, ["a2 → ship"], "only the drifted issue is attached (ENG-1 already on harness)");
2223
+ eq([fake.calls.filter((c) => c.query.includes("projectMilestoneCreate")).length, fake.calls.filter((c) => c.query.includes("issueUpdate")).length], [1, 1], "one create + one attach — no redundant work");
2224
+ rmSync(root, { recursive: true, force: true });
2225
+ // degrade: a rejected create propagates so runSync's call-site try/catch records milestonesError.
2226
+ const root2 = mkdtempSync(join(tmpdir(), "roadmap-ms-degrade-"));
2227
+ mkdirSync(join(root2, "docs", "roadmap"), { recursive: true });
2228
+ writeFileSync(join(root2, "docs", "roadmap", "roadmap.yaml"),
2229
+ `meta:\n schema_version: 1\n program: T\n linear:\n team: ENG\npis:\n - id: p\n title: P\n status: active\n linear: { project: proj-1 }\n sprints: [ { id: s1, title: A, status: active, invoke: a1, milestone: new, linear: ENG-1 } ]\n`, "utf8");
2230
+ const fail = fakeLinear({ failOn: "projectMilestoneCreate", snapshot: { "ENG-1": { id: "u1", identifier: "ENG-1", projectMilestone: null } } });
2231
+ await syncMilestones(root2, { apiKey: "k", fetchImpl: fail.fetchImpl }).then(() => { throw new Error("should throw"); }, (e) => ok(/milestone/i.test(e.message), "rejected create propagates (caller degrades)"));
2232
+ rmSync(root2, { recursive: true, force: true });
2233
+ });
2234
+
2235
+ // WHY: a milestone created on a LATER run must take the next slot after existing ones (byName.size), not
2236
+ // its plan-local index — else inserting a stage between two existing milestones collides sortOrders and
2237
+ // corrupts the stage ordering the feature exists to produce.
2238
+ test("syncMilestones sortOrder appends after existing milestones (no index collision)", async () => {
2239
+ const root = mkdtempSync(join(tmpdir(), "roadmap-ms-order-"));
2240
+ mkdirSync(join(root, "docs", "roadmap"), { recursive: true });
2241
+ // plan order [ship, harness]; harness already exists. ship is NEW and FIRST in plan order (index 0) —
2242
+ // the buggy index would give sortOrder 0; correct is byName.size (1, after the existing harness).
2243
+ writeFileSync(join(root, "docs", "roadmap", "roadmap.yaml"),
2244
+ `meta:\n schema_version: 1\n program: T\n linear:\n team: ENG\npis:\n - id: p\n title: P\n status: active\n linear: { project: proj-1 }\n sprints:\n - { id: s1, title: A, status: active, invoke: a1, milestone: ship, linear: ENG-1 }\n - { id: s2, title: B, status: active, invoke: a2, milestone: harness, linear: ENG-2 }\n`, "utf8");
2245
+ const fake = fakeLinear({
2246
+ projectMilestones: { "proj-1": [{ id: "pm-h", name: "harness" }] },
2247
+ snapshot: { "ENG-1": { id: "u1", identifier: "ENG-1", projectMilestone: null }, "ENG-2": { id: "u2", identifier: "ENG-2", projectMilestone: { id: "pm-h" } } },
2248
+ });
2249
+ await syncMilestones(root, { apiKey: "k", fetchImpl: fake.fetchImpl });
2250
+ eq(fake.calls.find((c) => c.query.includes("projectMilestoneCreate")).variables.input.sortOrder, 1, "new milestone appends after the existing one (byName.size=1), not its plan index (0)");
2251
+ rmSync(root, { recursive: true, force: true });
2252
+ });
2253
+
2254
+ // WHY: validate is the only net for hand-edited YAML — bad enums and non-string ids must
2255
+ // error, and a stored PI override must at least warn so the mismatch is never invisible.
2256
+ test("validateLinearConfig: enum/team errors, PI-mismatch warning, non-string sprint linear", () => {
2257
+ ok(validateLinearConfig({ meta: {}, pis: [] }).errors.length === 0, "absent → clean");
2258
+ ok(validateLinearConfig({ meta: { linear: { granularity: "slices" } }, pis: [] }).errors[0].includes("team is required"), "teamless errors");
2259
+ ok(validateLinearConfig({ meta: { linear: { team: "E", pull: "always" } }, pis: [] }).errors[0].includes("pull"), "bad enum errors");
2260
+ ok(validateLinearConfig({ meta: { linear: { team: "E", watch: [{ project: "X" }] } }, pis: [] }).errors[0].includes("needs a team"), "watch without team errors");
2261
+ ok(validateLinearConfig({ meta: { linear: { team: "E", estimate_max: 0 } }, pis: [] }).errors.some((e) => e.includes("estimate_max")), "estimate_max < 1 errors");
2262
+ const g = { meta: { linear: { team: "E", granularity: "slices" } }, pis: [
2263
+ { id: "p", title: "P", status: "active", linear: { granularity: "pis" }, sprints: [{ id: "s1", title: "S", status: "active", invoke: "x", linear: 123 }] },
2264
+ ]};
2265
+ const r = validateLinearConfig(g);
2266
+ ok(r.warnings.some((w) => w.includes("per-PI override in effect")), "stored mismatch warns");
2267
+ ok(r.errors.some((e) => e.includes("must be a string issue identifier")), "non-string sprint linear errors");
2268
+ });
2269
+
2270
+ // WHY: a hand-authored meta.jira block would silently do nothing — a user believing it
2271
+ // syncs loses work; validate must say so until jira.mjs actually exists.
2272
+ test("validateGraph warns on the not-yet-implemented meta.jira block", () => {
2273
+ const g = { meta: { schema_version: 1, program: "T", jira: { project: "ENG" } }, pis: [
2274
+ { id: "a", title: "A", status: "active", sprints: [{ id: "s1", title: "S", status: "active", invoke: "x", est_sessions: 1 }] }] };
2275
+ const r = validateGraph(g);
2276
+ eq(r.errors, [], "warn, not error — the roadmap still works");
2277
+ ok(r.warnings.some((w) => w.includes("meta.jira is not implemented")), "the block is called out");
2278
+ });
2279
+
2280
+ // WHY: a malformed {linear} branch breaks worktree creation mid-fanout — the token must
2281
+ // produce a Linear-autolinkable branch with an id and degrade cleanly without one.
2282
+ test("branchFor {linear} token: autolinkable with an id, clean without", () => {
2283
+ const g = { meta: { branch_convention: "{pi}/{linear}-{sprint}" } };
2284
+ eq(branchFor({ piId: "platform", id: "s1", linear: "ABC-123" }, g), "platform/abc-123-s1", "id lowercased into the branch");
2285
+ eq(branchFor({ piId: "platform", id: "s1", linear: null }, g), "platform/s1", "no id → no residue");
2286
+ });
2287
+
2288
+ // ── linear.mjs: mocked-transport sync (never hits the network) ────────────────
2289
+ function linearRepo() {
2290
+ const root = mkdtempSync(join(tmpdir(), "roadmap-linear-test-"));
2291
+ mkdirSync(join(root, "docs", "roadmap"), { recursive: true });
2292
+ writeFileSync(join(root, "docs", "roadmap", "roadmap.yaml"),
2293
+ `meta:\n schema_version: 1\n program: T\n linear:\n team: ENG\n pull: propose\n watch:\n - { team: PUB, project: Submit an issue, kind: bug, priority: {tier: P3} }\npis:\n - id: auth\n title: Authentication\n status: active\n sprints:\n - { id: s1, title: Login, status: active, invoke: auth-login }\n`, "utf8");
2294
+ return root;
2295
+ }
2296
+ function fakeLinear({ failOn = null, snapshot = {}, projectSnapshot = {}, issueComments = {}, projectMilestones = {}, inboundByTeam = null, teamLabels = [], teamProjects = [], existingViews = [], existingInitiatives = [], projectStatuses = null, activeCycle = null } = {}) {
2297
+ const calls = [];
2298
+ const createdIssues = {}; // identifier → snapshot shape, so later issue(id:) lookups resolve
2299
+ const createdProjects = {}; // id → snapshot shape, so a second run's project(id:) drift diff sees what create pushed
2300
+ let created = 0;
2301
+ let milestonesCreated = 0;
2302
+ let labelsCreated = 0;
2303
+ const fetchImpl = async (url, { body }) => {
2304
+ const { query, variables } = JSON.parse(body);
2305
+ calls.push({ query, variables });
2306
+ const respond = (data) => ({ ok: true, json: async () => ({ data }) });
2307
+ if (query.includes("viewer {")) return respond({ viewer: { id: "viewer-me" } }); // plate: whose My Issues
2308
+ if (query.includes("teams(filter")) return respond({ teams: { nodes: [{ id: "team-1", key: "ENG", name: "Eng",
2309
+ activeCycle: activeCycle,
2310
+ states: { nodes: [
2311
+ { id: "st-b", name: "Backlog", type: "backlog", position: 0 },
2312
+ { id: "st-u", name: "Todo", type: "unstarted", position: 1 },
2313
+ { id: "st-s", name: "In Progress", type: "started", position: 2 },
2314
+ { id: "st-c", name: "Done", type: "completed", position: 3 },
2315
+ ] },
2316
+ labels: { nodes: teamLabels },
2317
+ projects: { nodes: teamProjects } }] } });
2318
+ if (query.includes("issueLabelCreate")) {
2319
+ if (failOn === "issueLabelCreate") throw new Error("simulated label failure");
2320
+ labelsCreated += 1;
2321
+ return respond({ issueLabelCreate: { issueLabel: { id: `lbl-new-${labelsCreated}`, name: variables.input.name } } });
2322
+ }
2323
+ if (query.includes("customViewCreate")) {
2324
+ if (failOn !== "customViewCreate") return respond({ customViewCreate: { customView: { id: `view-${calls.length}` } } });
2325
+ throw new Error("simulated view rejection");
2326
+ }
2327
+ if (query.includes("customViews(first")) return respond({ customViews: { nodes: existingViews.map((name, i) => ({ id: `v${i}`, name })) } });
2328
+ if (query.includes("initiatives(first")) return respond({ initiatives: { nodes: existingInitiatives } });
2329
+ if (query.includes("initiativeCreate")) {
2330
+ if (failOn === "initiativeStyle" && (variables.input.icon || variables.input.color)) throw new Error("argument validation error: icon"); // Linear rejects the styled input
2331
+ return respond({ initiativeCreate: { initiative: { id: `init-${calls.length}`, name: variables.input.name } } });
2332
+ }
2333
+ if (query.includes("initiativeUpdate")) {
2334
+ if (failOn === "initiativeStyle") throw new Error("argument validation error: icon");
2335
+ return respond({ initiativeUpdate: { initiative: { id: variables.id } } });
2336
+ }
2337
+ if (query.includes("initiativeToProjectCreate")) return respond({ initiativeToProjectCreate: { success: true } });
2338
+ if (query.includes("projectUpdateCreate")) {
2339
+ if (failOn === "projectUpdateCreate") throw new Error("simulated post rejection");
2340
+ return respond({ projectUpdateCreate: { projectUpdate: { id: "pu-1" } } });
2341
+ }
2342
+ if (query.includes("commentCreate")) {
2343
+ if (failOn === "commentCreate") throw new Error("simulated comment failure");
2344
+ return respond({ commentCreate: { comment: { id: "c-1" } } });
2345
+ }
2346
+ if (query.includes("createdAt comments(first:")) { // staleness activity basis (aliased, batched)
2347
+ if (failOn === "activity") throw new Error("simulated activity failure");
2348
+ const ids = [...query.matchAll(/issue\(id: "([^"]+)"\)/g)].map((m) => m[1]);
2349
+ const data = {};
2350
+ ids.forEach((id, j) => {
2351
+ const rec = snapshot[id] || createdIssues[id];
2352
+ data[`i${j}`] = rec ? { identifier: id, createdAt: rec.createdAt || "2026-07-01T00:00:00Z", comments: { nodes: issueComments[id] || [] } } : null;
2353
+ });
2354
+ return respond(data);
2355
+ }
2356
+ if (query.includes("comments(first:")) { // the journal read: issue(id){ comments { nodes } }
2357
+ const id = (query.match(/issue\(id: "([^"]+)"\)/) || [])[1];
2358
+ return respond({ issue: { comments: { nodes: issueComments[id] || [] } } });
2359
+ }
2360
+ if (query.includes("issue(id:")) { // snapshot aliases + uuid lookups — configurable per test
2361
+ const ids = [...query.matchAll(/issue\(id: "([^"]+)"\)/g)].map((m) => m[1]);
2362
+ const lookup = (id) => snapshot[id] || createdIssues[id] || null;
2363
+ if (!query.includes("i0:")) return respond({ issue: lookup(ids[0]) }); // single un-aliased lookup (dispatch)
2364
+ const data = {};
2365
+ ids.forEach((id, j) => { data[`i${j}`] = lookup(id); });
2366
+ return respond(data);
2367
+ }
2368
+ if (query.includes("projectStatuses {")) { // workspace inventory (PI status → project status)
2369
+ if (!projectStatuses) throw new Error("simulated: project-status inventory unavailable"); // degrade path
2370
+ return respond({ organization: { projectStatuses } });
2371
+ }
2372
+ if (query.includes("projectMilestones {")) { // syncMilestones: existing milestones on a project
2373
+ const id = (query.match(/project\(id: "([^"]+)"\)/) || [])[1];
2374
+ return respond({ project: { projectMilestones: { nodes: projectMilestones[id] || [] } } });
2375
+ }
2376
+ if (query.includes("projectMilestoneCreate")) {
2377
+ if (failOn === "projectMilestoneCreate") throw new Error("simulated milestone failure");
2378
+ return respond({ projectMilestoneCreate: { projectMilestone: { id: `pm-${++milestonesCreated}` } } });
2379
+ }
2380
+ if (query.includes("project(id:")) { // project drift snapshot (batched aliases) — mirrors issue(id:)
2381
+ const ids = [...query.matchAll(/project\(id: "([^"]+)"\)/g)].map((m) => m[1]);
2382
+ const lookup = (id) => projectSnapshot[id] || createdProjects[id] || null;
2383
+ const data = {};
2384
+ ids.forEach((id, j) => { data[`p${j}`] = lookup(id); });
2385
+ return respond(data);
2386
+ }
2387
+ if (query.includes("projectCreate")) {
2388
+ const p = variables.input;
2389
+ createdProjects["proj-new"] = { id: "proj-new", name: p.name, description: p.description || "", content: p.content || "",
2390
+ color: p.color || "", icon: p.icon || "", priority: p.priority || 0, startDate: p.startDate || null, targetDate: p.targetDate || null,
2391
+ status: p.statusId ? { id: p.statusId } : null }; // faithful: create persists its payload
2392
+ return respond({ projectCreate: { project: { id: "proj-new" } } });
2393
+ }
2394
+ if (query.includes("projectUpdate")) { // faithful: persist the patch so a re-fetch converges (idempotency)
2395
+ const rec = createdProjects[variables.id] || projectSnapshot[variables.id];
2396
+ if (rec) {
2397
+ Object.assign(rec, variables.input);
2398
+ if (variables.input.statusId) rec.status = { id: variables.input.statusId }; // re-fetch reads status { id }
2399
+ }
2400
+ return respond({ projectUpdate: { project: { id: variables.id } } });
2401
+ }
2402
+ if (query.includes("issueCreate")) {
2403
+ if (failOn === "issueCreate") throw new Error("simulated transport failure");
2404
+ if (failOn === "completedAt" && variables.input.completedAt) throw new Error("argument validation error: completedAt");
2405
+ created += 1;
2406
+ const identifier = `ENG-${100 + created}`;
2407
+ createdIssues[identifier] = { id: `uuid-${created}`, identifier, title: variables.input.title,
2408
+ description: variables.input.description || "", priority: variables.input.priority ?? 0,
2409
+ estimate: variables.input.estimate ?? null, // faithful: a real create persists the estimate
2410
+ state: { id: variables.input.stateId },
2411
+ project: variables.input.projectId ? { id: variables.input.projectId } : null, // faithful: real creates persist the project
2412
+ labels: { nodes: (variables.input.labelIds || []).map((id) => ({ id })) } };
2413
+ return respond({ issueCreate: { issue: { id: `uuid-${created}`, identifier } } });
2414
+ }
2415
+ if (query.includes("issueUpdate")) {
2416
+ if (failOn === "cycleUpdate" && "cycleId" in (variables.input || {})) throw new Error("simulated cycle rejection");
2417
+ if (failOn === "descriptionDocContent" && "description" in (variables.input || {})) throw new Error("Linear API: conflict on insert of DocumentContent");
2418
+ const rec = Object.values({ ...snapshot, ...createdIssues }).find((r) => r && r.id === variables.id);
2419
+ if (rec) { // faithful: persist the patch (like projectUpdate above) so a second run converges
2420
+ const inp = variables.input || {};
2421
+ for (const k of ["title", "description", "priority", "estimate"]) if (k in inp) rec[k] = inp[k];
2422
+ if ("stateId" in inp) rec.state = { id: inp.stateId };
2423
+ if ("projectId" in inp) rec.project = inp.projectId ? { id: inp.projectId } : null;
2424
+ if ("assigneeId" in inp) rec.assignee = inp.assigneeId ? { id: inp.assigneeId } : null;
2425
+ if ("cycleId" in inp) rec.cycle = inp.cycleId ? { id: inp.cycleId } : null;
2426
+ if (inp.labelIds) rec.labels = { nodes: inp.labelIds.map((id) => ({ id })) };
2427
+ }
2428
+ return respond({ issueUpdate: { issue: { id: variables.id } } });
2429
+ }
2430
+ if (query.includes("issues(filter")) {
2431
+ const team = variables.filter.team.key.eq;
2432
+ if (inboundByTeam) return respond({ issues: { nodes: inboundByTeam[team] || [] } });
2433
+ return respond({ issues: { nodes: team === "PUB" ? [
2434
+ { identifier: "PUB-42", title: "Crash on empty config", priority: 1, updatedAt: "2026-07-06T00:00:00Z",
2435
+ state: { name: "Backlog", type: "backlog" }, team: { key: "PUB" }, project: { name: "Submit an issue" } },
2436
+ ] : [] } });
2437
+ }
2438
+ throw new Error(`fake transport: unexpected query ${query.slice(0, 60)}`);
2439
+ };
2440
+ return { fetchImpl, calls };
2441
+ }
2442
+
2443
+ // WHY: this is the end-to-end contract — a sync must create the missing project+issue, write
2444
+ // the ids back INTO the YAML (the mapping's source of truth), surface inbound work as
2445
+ // proposals without mutating anything, hold the cursor while proposals are unhandled, and
2446
+ // be a no-op on the second run (idempotency is what makes /sync safe to run repeatedly).
2447
+ test("runSync (mocked transport): pushes, writes ids back, proposes inbound, idempotent second run", async () => {
2448
+ const root = linearRepo();
2449
+ const fake = fakeLinear();
2450
+ const r1 = await runSync(root, { fetchImpl: fake.fetchImpl, env: { LINEAR_API_KEY: "k" }, now: "2026-07-06T12:00:00Z" });
2451
+ const yaml = readFileSync(join(root, "docs", "roadmap", "roadmap.yaml"), "utf8");
2452
+ ok(yaml.includes("project: proj-new"), "PI project id written back");
2453
+ ok(yaml.includes("linear: ENG-101"), "issue identifier written back onto the sprint");
2454
+ eq(r1.proposals.newItems.length, 1, "inbound PUB issue proposed");
2455
+ eq(r1.proposals.newItems[0].source.linear.team, "PUB", "source demarcation carried");
2456
+ ok(!existsSync(join(root, "docs", "roadmap", "backlog.yaml")), "propose mode captured NOTHING (no silent mutation)");
2457
+ eq(r1.cursorAdvanced, false, "cursor held while the inbox is unhandled");
2458
+ eq(readCursor(root), null, "no cursor file yet");
2459
+ const r2 = await runSync(root, { fetchImpl: fake.fetchImpl, env: { LINEAR_API_KEY: "k" }, now: "2026-07-06T13:00:00Z" });
2460
+ eq(r2.pushed, [], "second run pushes nothing (idempotent)");
2461
+ rmSync(root, { recursive: true, force: true });
2462
+ });
2463
+
2464
+ // WHY: the drain is a real write-back to the user's roadmap.yaml — a completed slice must leave meta.plate
2465
+ // (and thus My Issues) on sync while the rest of the batch stays; a silent miss leaks stale batches.
2466
+ test("runSync drains a completed slice from meta.plate (write-back), keeps the rest", async () => {
2467
+ const root = mkdtempSync(join(tmpdir(), "roadmap-plate-drain-"));
2468
+ mkdirSync(join(root, "docs", "roadmap"), { recursive: true });
2469
+ writeFileSync(join(root, "docs", "roadmap", "roadmap.yaml"),
2470
+ `meta:\n schema_version: 1\n program: T\n plate: [alpha, beta]\n linear:\n team: ENG\npis:\n - id: p\n title: P\n status: active\n linear: { project: proj-1 }\n sprints:\n - { id: s1, title: Alpha, status: complete, invoke: alpha, linear: ENG-1 }\n - { id: s2, title: Beta, status: active, invoke: beta, linear: ENG-2 }\n`, "utf8");
2471
+ const iss = (idf) => ({ id: `u-${idf}`, identifier: idf, title: "X", description: "", priority: 0, estimate: null, state: { id: "st-s" }, project: { id: "proj-1" }, assignee: null, labels: { nodes: [] } });
2472
+ const fake = fakeLinear({ snapshot: { "ENG-1": iss("ENG-1"), "ENG-2": iss("ENG-2") }, teamLabels: [{ id: "l-mark", name: "roadmap" }, { id: "l-plate", name: "plate" }] });
2473
+ await runSync(root, { fetchImpl: fake.fetchImpl, env: { LINEAR_API_KEY: "k" }, now: "2026-07-08T12:00:00Z" });
2474
+ const doc = parseDocument(readFileSync(join(root, "docs", "roadmap", "roadmap.yaml"), "utf8")).toJS();
2475
+ eq(doc.meta.plate, ["beta"], "completed alpha drained from meta.plate; active beta kept");
2476
+ rmSync(root, { recursive: true, force: true });
2477
+ });
2478
+
2479
+ // WHY: a transport failure mid-push must not lose the ids Linear already assigned (or the
2480
+ // next sync duplicates those issues), and must not advance the cursor (or inbound work in
2481
+ // that window vanishes forever).
2482
+ test("runSync flushes write-backs on a mid-push throw and leaves the cursor untouched", async () => {
2483
+ const root = linearRepo();
2484
+ const fake = fakeLinear({ failOn: "issueCreate" });
2485
+ let threw = false;
2486
+ try { await runSync(root, { fetchImpl: fake.fetchImpl, env: { LINEAR_API_KEY: "k" } }); }
2487
+ catch (e) { threw = true; ok(e.message.includes("simulated transport failure"), "the transport error surfaces"); }
2488
+ ok(threw, "sync propagated the failure");
2489
+ const yaml = readFileSync(join(root, "docs", "roadmap", "roadmap.yaml"), "utf8");
2490
+ ok(yaml.includes("project: proj-new"), "the project created BEFORE the failure kept its write-back");
2491
+ ok(!yaml.includes("ENG-10"), "the failed issue wrote nothing back");
2492
+ eq(readCursor(root), null, "cursor untouched on failure");
2493
+ rmSync(root, { recursive: true, force: true });
2494
+ });
2495
+
2496
+ // WHY: PI status must flow through the LIVE sync path — create carries statusId, a second run
2497
+ // converges, and an inventory-fetch failure degrades to a recorded note without aborting or
2498
+ // distorting the push (a workspace-query hiccup must never take the sync down with it).
2499
+ test("runSync projects PI status → project status, converges, and degrades without the inventory", async () => {
2500
+ const root = linearRepo();
2501
+ const fake = fakeLinear({ projectStatuses: [
2502
+ { id: "ps-b", name: "Backlog", type: "backlog", position: 0 },
2503
+ { id: "ps-p", name: "Planned", type: "planned", position: 1 },
2504
+ { id: "ps-s", name: "In Progress", type: "started", position: 2 },
2505
+ { id: "ps-c", name: "Completed", type: "completed", position: 3 },
2506
+ ] });
2507
+ await runSync(root, { fetchImpl: fake.fetchImpl, env: { LINEAR_API_KEY: "k" }, now: "2026-07-06T12:00:00Z" });
2508
+ const create = fake.calls.find((c) => c.query.includes("projectCreate"));
2509
+ eq(create.variables.input.statusId, "ps-s", "active PI creates its project In Progress, not Backlog");
2510
+ const r2 = await runSync(root, { fetchImpl: fake.fetchImpl, env: { LINEAR_API_KEY: "k" }, now: "2026-07-06T13:00:00Z" });
2511
+ eq(r2.pushed, [], "second run converges — the projected status round-trips, no churn");
2512
+ rmSync(root, { recursive: true, force: true });
2513
+ // degrade: no inventory in this fake → the organization query fails → sync completes anyway
2514
+ const root2 = linearRepo();
2515
+ const fake2 = fakeLinear();
2516
+ const r3 = await runSync(root2, { fetchImpl: fake2.fetchImpl, env: { LINEAR_API_KEY: "k" }, now: "2026-07-06T12:00:00Z" });
2517
+ ok(r3.projectStatusError, "inventory failure recorded on the result, not thrown");
2518
+ ok(!fake2.calls.some((c) => c.query.includes("projectCreate") && c.variables.input.statusId), "degraded push sends no statusId");
2519
+ ok(readFileSync(join(root2, "docs", "roadmap", "roadmap.yaml"), "utf8").includes("project: proj-new"), "the push itself still ran");
2520
+ rmSync(root2, { recursive: true, force: true });
2521
+ });
2522
+
2523
+ // WHY: cycles must ride the live sync end-to-end — assign + clear post-push, convergence on the
2524
+ // second run (no oscillation against Linear's native rollover), a note instead of a guess when no
2525
+ // cycle is active, and an unverified-API rejection degrading without touching the core push.
2526
+ test("runSync cycles: assigns active+next, clears demotions, converges, degrades on rejection", async () => {
2527
+ const yaml = `meta:\n schema_version: 1\n program: T\n linear:\n team: ENG\n cycles: on\npis:\n - id: p\n title: P\n status: active\n linear: { project: proj-1 }\n sprints:\n - { id: s1, title: A, status: active, invoke: a, linear: ENG-1 }\n - { id: s2, title: C, status: scheduled, invoke: c, linear: ENG-3 }\n`;
2528
+ const mkRoot = () => { const root = mkdtempSync(join(tmpdir(), "roadmap-cycles-")); mkdirSync(join(root, "docs", "roadmap"), { recursive: true }); writeFileSync(join(root, "docs", "roadmap", "roadmap.yaml"), yaml, "utf8"); return root; };
2529
+ const iss = (idf, cycle) => ({ id: `u-${idf}`, identifier: idf, title: "X", description: "", priority: 0, estimate: null, state: { id: "st-s" }, project: { id: "proj-1" }, assignee: null, cycle, labels: { nodes: [] } });
2530
+ const snap = () => ({ "ENG-1": iss("ENG-1", null), "ENG-3": iss("ENG-3", { id: "cyc-1" }) });
2531
+ const proj = () => ({ "proj-1": { id: "proj-1", name: "P" } });
2532
+ const cycleOps = (f) => f.calls.filter((cl) => cl.query.includes("issueUpdate") && "cycleId" in (cl.variables.input || {}));
2533
+ const root = mkRoot();
2534
+ const fake = fakeLinear({ snapshot: snap(), projectSnapshot: proj(), activeCycle: { id: "cyc-1" } });
2535
+ const r1 = await runSync(root, { fetchImpl: fake.fetchImpl, env: { LINEAR_API_KEY: "k" }, now: "2026-07-09T12:00:00Z" });
2536
+ eq(r1.cycles, { assigned: ["a"], cleared: ["c"] }, "active joins the cycle, demoted scheduled leaves it");
2537
+ eq(cycleOps(fake).map((cl) => [cl.variables.id, cl.variables.input.cycleId]), [["u-ENG-1", "cyc-1"], ["u-ENG-3", null]], "assign carries the active cycle id; clear sends null");
2538
+ const r2 = await runSync(root, { fetchImpl: fake.fetchImpl, env: { LINEAR_API_KEY: "k" }, now: "2026-07-09T13:00:00Z" });
2539
+ eq(cycleOps(fake).length, 2, "second run adds no cycle ops (converged via the fake's persistence)");
2540
+ ok(!r2.cycles, "second run reports no cycle work");
2541
+ rmSync(root, { recursive: true, force: true });
2542
+ const root2 = mkRoot();
2543
+ const fake2 = fakeLinear({ snapshot: snap(), projectSnapshot: proj(), activeCycle: { id: "cyc-1" }, failOn: "cycleUpdate" });
2544
+ const r3 = await runSync(root2, { fetchImpl: fake2.fetchImpl, env: { LINEAR_API_KEY: "k" }, now: "2026-07-09T12:00:00Z" });
2545
+ ok(r3.cyclesError && r3.cyclesError.includes("simulated cycle rejection"), "cycle rejection recorded; sync completed");
2546
+ ok(r3.pushed.some((p) => p.includes("updateIssue")), "the core push completed before the cycle rejection");
2547
+ rmSync(root2, { recursive: true, force: true });
2548
+ const root3 = mkRoot();
2549
+ const fake3 = fakeLinear({ snapshot: snap(), projectSnapshot: proj() }); // no active cycle in Linear
2550
+ const r4 = await runSync(root3, { fetchImpl: fake3.fetchImpl, env: { LINEAR_API_KEY: "k" }, now: "2026-07-09T12:00:00Z" });
2551
+ ok(r4.cyclesNote && r4.cyclesNote.includes("no active cycle"), "no active cycle → a note, never a guess");
2552
+ eq(cycleOps(fake3).length, 0, "zero cycle mutations without an active cycle");
2553
+ rmSync(root3, { recursive: true, force: true });
2554
+ const root4 = linearRepo(); // cycles absent → even the teams QUERY must stay pre-cycles byte-identical
2555
+ const fake4 = fakeLinear();
2556
+ await runSync(root4, { fetchImpl: fake4.fetchImpl, env: { LINEAR_API_KEY: "k" }, now: "2026-07-09T12:00:00Z" });
2557
+ ok(!fake4.calls.some((c) => c.query.includes("activeCycle")), "cycles off → activeCycle never requested");
2558
+ rmSync(root4, { recursive: true, force: true });
2559
+ });
2560
+
2561
+ // WHY: Linear stores issue descriptions as DocumentContent and can refuse the write with
2562
+ // "conflict on insert of DocumentContent" (live-caught 2026-07-10 under concurrent syncs).
2563
+ // The conflict pins to ONE issue, but without the description-strip degrade it aborts every
2564
+ // op queued behind it — new issues are never created and the board freezes until a human digs.
2565
+ test("runSync updateIssue strips the description and retries when Linear refuses it as DocumentContent", async () => {
2566
+ const yaml = `meta:\n schema_version: 1\n program: T\n linear:\n team: ENG\npis:\n - id: p\n title: P\n status: active\n linear: { project: proj-1 }\n sprints:\n - { id: s1, title: A, status: active, invoke: a, linear: ENG-1, what: drifted body }\n - { id: s2, title: B, status: next, invoke: b }\n`;
2567
+ const root = mkdtempSync(join(tmpdir(), "roadmap-doccontent-"));
2568
+ mkdirSync(join(root, "docs", "roadmap"), { recursive: true });
2569
+ writeFileSync(join(root, "docs", "roadmap", "roadmap.yaml"), yaml, "utf8");
2570
+ const iss = { id: "u-ENG-1", identifier: "ENG-1", title: "A", description: "", priority: 0, estimate: null,
2571
+ state: { id: "st-s" }, project: { id: "proj-1" }, assignee: null, labels: { nodes: [] } };
2572
+ const fake = fakeLinear({ snapshot: { "ENG-1": iss }, projectSnapshot: { "proj-1": { id: "proj-1", name: "P" } }, failOn: "descriptionDocContent" });
2573
+ const r = await runSync(root, { fetchImpl: fake.fetchImpl, env: { LINEAR_API_KEY: "k" }, now: "2026-07-10T12:00:00Z" }); // must NOT throw
2574
+ const upd = fake.calls.filter((c) => c.query.includes("issueUpdate") && c.variables.id === "u-ENG-1");
2575
+ ok(upd.some((c) => "description" in (c.variables.input || {})), "first attempt carried the drifted description");
2576
+ ok(upd.some((c) => !("description" in (c.variables.input || {}))), "retried once WITHOUT the description (the conflict field, nothing else, is dropped)");
2577
+ ok(fake.calls.some((c) => c.query.includes("issueCreate")), "the create queued BEHIND the conflicted update still ran");
2578
+ ok(r.pushed.some((p) => p.includes("createIssue")), "push reports the create — the sync finished instead of aborting");
2579
+ rmSync(root, { recursive: true, force: true });
2580
+ });
2581
+
2582
+ // WHY: the history backfill must ride the LIVE pipeline — a Done issue lands with its true
2583
+ // completion date, the id write-back makes the second run a no-op (resumability IS the batching
2584
+ // strategy for a 300-issue backfill), and a rejected completedAt VALUE degrades to
2585
+ // Done-at-creation instead of failing the whole backfill.
2586
+ test("runSync history full: backfills Done with completedAt, converges, degrades on value rejection", async () => {
2587
+ const yaml = `meta:\n schema_version: 1\n program: T\n linear:\n team: ENG\n history: full\npis:\n - id: p\n title: P\n status: complete\n sprints:\n - { id: s1, title: Done thing, status: complete, invoke: done-thing, completed_on: "2026-07-01" }\n`;
2588
+ const mkRoot = () => { const root = mkdtempSync(join(tmpdir(), "roadmap-history-")); mkdirSync(join(root, "docs", "roadmap"), { recursive: true }); writeFileSync(join(root, "docs", "roadmap", "roadmap.yaml"), yaml, "utf8"); return root; };
2589
+ const root = mkRoot();
2590
+ const fake = fakeLinear();
2591
+ await runSync(root, { fetchImpl: fake.fetchImpl, env: { LINEAR_API_KEY: "k" }, now: "2026-07-09T12:00:00Z" });
2592
+ const create = fake.calls.find((c) => c.query.includes("issueCreate"));
2593
+ eq(create.variables.input.completedAt, "2026-07-01", "the true completion date rides the create");
2594
+ eq(create.variables.input.stateId, "st-c", "backfilled issue lands Done");
2595
+ ok(readFileSync(join(root, "docs", "roadmap", "roadmap.yaml"), "utf8").includes("linear: ENG-101"), "id written back — the resume point");
2596
+ const r2 = await runSync(root, { fetchImpl: fake.fetchImpl, env: { LINEAR_API_KEY: "k" }, now: "2026-07-09T13:00:00Z" });
2597
+ eq(r2.pushed, [], "second run creates nothing (backfill converged)");
2598
+ rmSync(root, { recursive: true, force: true });
2599
+ const root2 = mkRoot();
2600
+ const fake2 = fakeLinear({ failOn: "completedAt" });
2601
+ await runSync(root2, { fetchImpl: fake2.fetchImpl, env: { LINEAR_API_KEY: "k" }, now: "2026-07-09T12:00:00Z" });
2602
+ const createCalls = fake2.calls.filter((c) => c.query.includes("issueCreate"));
2603
+ eq(createCalls.length, 2, "value rejection → one retry");
2604
+ ok(!("completedAt" in createCalls[1].variables.input), "retry drops completedAt (Done-at-creation fallback)");
2605
+ ok(readFileSync(join(root2, "docs", "roadmap", "roadmap.yaml"), "utf8").includes("linear: ENG-101"), "issue still created + written back");
2606
+ rmSync(root2, { recursive: true, force: true });
2607
+ });
2608
+
2609
+ // WHY: staleness must ride the LIVE sync — journal-comment basis through the wire, the flag
2610
+ // surviving our own label push (updatedAt-based logic would flap here), the set landing in the
2611
+ // cursor for the offline election, and an activity-fetch failure degrading to a note.
2612
+ test("runSync staleness: flags on journal silence, survives its own push, persists to cursor, degrades", async () => {
2613
+ const yaml = `meta:\n schema_version: 1\n program: T\n linear:\n team: ENG\n stale_days: 3\npis:\n - id: p\n title: P\n status: active\n linear: { project: proj-1 }\n sprints:\n - { id: s1, title: A, status: active, invoke: a, linear: ENG-1 }\n`;
2614
+ const mkRoot = () => { const root = mkdtempSync(join(tmpdir(), "roadmap-stale-")); mkdirSync(join(root, "docs", "roadmap"), { recursive: true }); writeFileSync(join(root, "docs", "roadmap", "roadmap.yaml"), yaml, "utf8"); return root; };
2615
+ const mkIss = () => ({ id: "u-1", identifier: "ENG-1", title: "A", description: "", priority: 0, estimate: null, state: { id: "st-s" }, project: { id: "proj-1" }, assignee: null, createdAt: "2026-07-01T00:00:00Z", labels: { nodes: [{ id: "l-r" }] } });
2616
+ const labels = [{ id: "l-r", name: "roadmap" }, { id: "l-s", name: "stale" }];
2617
+ const comments = { "ENG-1": [{ createdAt: "2026-07-02T00:00:00Z" }] };
2618
+ const root = mkRoot();
2619
+ const fake = fakeLinear({ snapshot: { "ENG-1": mkIss() }, projectSnapshot: { "proj-1": { id: "proj-1", name: "P" } }, teamLabels: labels, issueComments: comments });
2620
+ const r1 = await runSync(root, { fetchImpl: fake.fetchImpl, env: { LINEAR_API_KEY: "k" }, now: "2026-07-09T12:00:00Z" });
2621
+ eq(r1.stale, ["a"], "journal silence past stale_days flags the slice");
2622
+ ok(fake.calls.some((c) => c.query.includes("issueUpdate") && (c.variables.input.labelIds || []).includes("l-s")), "the stale label pushed");
2623
+ const cursor1 = readCursor(root);
2624
+ eq(cursor1.stale, ["a"], "stale set persisted to the cursor — the election's offline basis");
2625
+ eq(typeof cursor1.lastSync, "string", "patch-merge kept lastSync beside stale (neither clobbers the other)");
2626
+ // the flap trap is closed STRUCTURALLY: the basis query never even requests updatedAt
2627
+ const actQ = fake.calls.find((c) => c.query.includes("createdAt comments(first:"));
2628
+ ok(actQ && !actQ.query.includes("updatedAt"), "activity basis never requests updatedAt — our own pushes cannot reset the clock");
2629
+ const r2 = await runSync(root, { fetchImpl: fake.fetchImpl, env: { LINEAR_API_KEY: "k" }, now: "2026-07-09T13:00:00Z" });
2630
+ eq(r2.stale, ["a"], "second run recomputes the same journal basis — still stale while the journal is silent");
2631
+ eq(r2.pushed, [], "still-stale second run is a no-op (label already present)");
2632
+ comments["ENG-1"].push({ createdAt: "2026-07-09T12:30:00Z" }); // a fresh journal note lands
2633
+ const r3 = await runSync(root, { fetchImpl: fake.fetchImpl, env: { LINEAR_API_KEY: "k" }, now: "2026-07-09T14:00:00Z" });
2634
+ ok(!r3.stale, "fresh note clears the flag");
2635
+ ok(fake.calls.some((c) => c.query.includes("issueUpdate") && c.variables.input.labelIds && !c.variables.input.labelIds.includes("l-s")), "the label removal pushed");
2636
+ eq(readCursor(root).stale, [], "cursor reflects the cleared set");
2637
+ rmSync(root, { recursive: true, force: true });
2638
+ const root2 = mkRoot();
2639
+ const fake2 = fakeLinear({ snapshot: { "ENG-1": mkIss() }, projectSnapshot: { "proj-1": { id: "proj-1", name: "P" } }, teamLabels: labels, issueComments: comments, failOn: "activity" });
2640
+ const r4 = await runSync(root2, { fetchImpl: fake2.fetchImpl, env: { LINEAR_API_KEY: "k" }, now: "2026-07-09T12:00:00Z" });
2641
+ ok(r4.staleError && r4.staleError.includes("simulated activity failure"), "activity failure recorded; sync completed");
2642
+ rmSync(root2, { recursive: true, force: true });
2643
+ });
2644
+
2645
+ // WHY: the LIVE-verified clobber race — push ran before pull and overwrote a human's
2646
+ // Urgent edit in Linear before it could even be proposed. Pull must run first and push
2647
+ // must hold any field with an open inbound proposal, or Linear-side edits silently lose.
2648
+ test("runSync pulls before pushing: a human's Linear priority edit becomes a delta and is NOT clobbered", async () => {
2649
+ const root = linearRepo();
2650
+ // map the sprint to ENG-1; the human set it Urgent(1) in Linear; local has no priority (→0)
2651
+ const yamlPath = join(root, "docs", "roadmap", "roadmap.yaml");
2652
+ writeFileSync(yamlPath, readFileSync(yamlPath, "utf8").replace("invoke: auth-login }", "invoke: auth-login, linear: ENG-1 }"), "utf8");
2653
+ const snapIssue = { id: "uuid-1", identifier: "ENG-1", title: "Login", description: "irrelevant", priority: 1, state: { id: "st-s" } };
2654
+ const fake = fakeLinear({
2655
+ snapshot: { "ENG-1": { ...snapIssue, stateId: undefined } },
2656
+ inboundByTeam: { ENG: [{ identifier: "ENG-1", title: "Login", priority: 1, updatedAt: "2026-07-06T00:00:00Z",
2657
+ state: { name: "In Progress", type: "started" }, team: { key: "ENG" }, project: null }], PUB: [] },
2658
+ });
2659
+ const r = await runSync(root, { fetchImpl: fake.fetchImpl, env: { LINEAR_API_KEY: "k" }, now: "2026-07-06T12:00:00Z" });
2660
+ const pri = r.proposals.deltas.find((d) => d.field === "priority.tier");
2661
+ eq([pri.from, pri.to], [null, "P0"], "the human's Urgent edit arrives as a proposal");
2662
+ const updates = fake.calls.filter((c) => c.query.includes("issueUpdate"));
2663
+ for (const u of updates) ok(!("priority" in u.variables.input), "push never touches the held priority field");
2664
+ eq(r.cursorAdvanced, false, "cursor held while the delta is unresolved");
2665
+ rmSync(root, { recursive: true, force: true });
2666
+ });
2667
+
2668
+ // WHY: unconfigured/unauthed must be actionable errors, not stack traces — these are the
2669
+ // messages the /sync skill and a bare CLI user act on.
2670
+ test("runSync errors are the setup-guidance contract", async () => {
2671
+ const root = tempRepo(); // no meta.linear
2672
+ await runSync(root, { env: {} }).then(() => { throw new Error("should have thrown"); },
2673
+ (e) => ok(e.message.includes("roadmap linear setup"), "unconfigured names the fix"));
2674
+ const lroot = linearRepo();
2675
+ await runSync(lroot, { env: {} }).then(() => { throw new Error("should have thrown"); },
2676
+ (e) => ok(e.message.includes("LINEAR_API_KEY"), "unauthed names the env var"));
2677
+ rmSync(root, { recursive: true, force: true });
2678
+ rmSync(lroot, { recursive: true, force: true });
2679
+ });
2680
+
2681
+ // ── sprawl guardrail ──────────────────────────────────────────────────────────
2682
+ // WHY: unchecked capture growth is how a roadmap silently doubles between reviews — the
2683
+ // ratio must fire above threshold, stay quiet at/below it, and never fire on an empty window.
2684
+ // WHY: composition drift is how a 64-project wall of one-slice PIs happens (capture_ratio guards
2685
+ // growth RATE, not shape) — the lint must be ONE aggregated line (signal, not a warning wall),
2686
+ // exempt finished history, stay off when unset, and reject a knob value that would silently
2687
+ // disable the guardrail.
2688
+ test("composition lint: one aggregated warning under pi_min_slices; complete PIs exempt; off when absent; 0 rejected", () => {
2689
+ const g = (discipline) => ({ meta: { schema_version: 1, program: "T", ...(discipline ? { discipline } : {}) }, pis: [
2690
+ { id: "thin1", title: "T1", status: "active", sprints: [{ id: "s1", title: "A", status: "active", invoke: "t1-a" }] },
2691
+ { id: "thin2", title: "T2", status: "scheduled", sprints: [{ id: "s1", title: "B", status: "scheduled", invoke: "t2-b" }, { id: "s2", title: "C", status: "scheduled", invoke: "t2-c" }] },
2692
+ { id: "shipped", title: "S", status: "complete", sprints: [{ id: "s1", title: "D", status: "complete", invoke: "s-d" }] },
2693
+ { id: "full", title: "F", status: "active", sprints: [
2694
+ { id: "s1", title: "E", status: "active", invoke: "f-e" }, { id: "s2", title: "G", status: "next", invoke: "f-g" }, { id: "s3", title: "H", status: "scheduled", invoke: "f-h" } ] },
2695
+ ]});
2696
+ const warns = validateGraph(g({ pi_min_slices: 3 })).warnings.filter((w) => w.startsWith("composition:"));
2697
+ eq(warns.length, 1, "one aggregated line, not one per PI");
2698
+ ok(warns[0].includes("2 non-complete PI(s)") && warns[0].includes("thin1") && warns[0].includes("thin2"), "names the thin live PIs");
2699
+ ok(!warns[0].includes("shipped"), "complete PIs are exempt — history is what it is");
2700
+ ok(!warns[0].includes("full"), "PIs at/over the floor don't warn");
2701
+ eq(validateGraph(g(null)).warnings.filter((w) => w.startsWith("composition:")).length, 0, "absent knob → lint off (no warning wall on unopted repos)");
2702
+ ok(validateGraph(g({ pi_min_slices: 0 })).errors.some((e) => e.includes("pi_min_slices")), "0 rejected — a bad knob must not silently disable the guardrail");
2703
+ });
2704
+
2705
+ test("sprawlWarnings: ratio fires above threshold, quiet at it, quiet on an empty window", () => {
2706
+ const hot = sprawlWarnings({ completed: 2, captured: 5, addedSprints: 2 });
2707
+ eq(hot.length, 1, "one ratio warning");
2708
+ eq(hot[0], `sprawl: 7 captured (5 item(s) + 2 sprint(s)) vs 2 completed since the last review — ratio 3.5 exceeds capture_ratio 2; scope is growing faster than it ships. Triage before adding more.`, "exact wording");
2709
+ eq(sprawlWarnings({ completed: 3, captured: 5, addedSprints: 1 }), [], "ratio exactly 2.0 stays quiet (threshold is exceeded, not met)");
2710
+ eq(sprawlWarnings({ completed: 0, captured: 0 }), [], "empty window → no noise");
2711
+ eq(sprawlWarnings({ completed: 1, captured: 5, ratioThreshold: 10 }), [], "meta.discipline knob raises the bar");
2712
+ eq(captureRatio({ discipline: { capture_ratio: 5 } }), 5, "captureRatio reads the knob");
2713
+ eq(captureRatio({}), 2, "default 2");
2714
+ });
2715
+
2716
+ // WHY: a PI added by an agent reshapes strategy without a human decision — it must warn
2717
+ // even when the capture ratio is perfectly healthy.
2718
+ test("sprawlWarnings: an added PI flags regardless of ratio", () => {
2719
+ const w = sprawlWarnings({ completed: 5, captured: 1, addedPis: ["billing"] });
2720
+ eq(w.length, 1, "PI flag fires with a clean ratio");
2721
+ eq(w[0], `sprawl: PI "billing" added since the last review — new PIs are strategic scope; confirm this was a human decision, not an agent capture.`, "exact wording");
2722
+ });
2723
+
2724
+ // WHY: an invalid capture_ratio would silently disable the guardrail; the review anchor
2725
+ // must be structurally sound or /debrief diffs against garbage.
2726
+ test("validateGraph checks meta.discipline and meta.last_review shapes", () => {
2727
+ const base = (meta) => ({ meta: { schema_version: 1, program: "T", ...meta }, pis: [
2728
+ { id: "a", title: "A", status: "active", sprints: [{ id: "s1", title: "S", status: "active", invoke: "x", est_sessions: 1 }] }] });
2729
+ eq(validateGraph(base({ discipline: { capture_ratio: 3, coherence: false } })).errors, [], "valid knobs pass");
2730
+ ok(validateGraph(base({ discipline: { capture_ratio: 0 } })).errors[0].includes("capture_ratio"), "zero ratio rejected");
2731
+ ok(validateGraph(base({ discipline: [] })).errors[0].includes("mapping"), "array discipline rejected");
2732
+ ok(validateGraph(base({ discipline: { coherence: "yes" } })).errors[0].includes("coherence"), "non-boolean coherence rejected");
2733
+ eq(validateGraph(base({ last_review: { date: "2026-07-06", commit: "abc123" } })).errors, [], "valid anchor passes");
2734
+ ok(validateGraph(base({ last_review: { date: "2026-07-06" } })).errors[0].includes("last_review"), "anchor missing commit rejected");
2735
+ });
2736
+
2737
+ // WHY: the brief is the only channel to a worker session — if it doesn't forbid sprint/PI
2738
+ // creation, every helpful agent files follow-up scope and the roadmap doubles.
2739
+ test("synthesizeBrief carries the temperance contract", () => {
2740
+ const g = { meta: { schema_version: 1, program: "T", default_gate: "npm test" },
2741
+ pis: [{ id: "a", title: "A", status: "active", sprints: [sp("s1", { status: "active", invoke: "x" })] }] };
2742
+ const b = synthesizeBrief(flatten(g).nodes[0], g);
2743
+ ok(b.includes("BACKLOG ONLY"), "backlog-only rule present");
2744
+ ok(b.includes("NEVER add sprints or PIs"), "scope prohibition present");
2745
+ ok(b.includes("YAGNI applies to captures too"), "temperance line present");
2746
+ });
2747
+
2748
+ // ── wave-packing coherence ────────────────────────────────────────────────────
2749
+ // WHY: a capped wave that takes one slice from each of N PIs leaves every PI half-open —
2750
+ // coherence must prefer finishing started PIs, but NEVER outrank a declared priority
2751
+ // (a P0 in a fresh PI still wins), and single-PI graphs must be untouched.
2752
+ test("computeWaves coherence: started/closest-to-done PIs win equal-priority cap slots; priority still outranks; opt-out restores old order", () => {
2753
+ const g = (opts = {}) => ({ meta: opts.meta || {}, pis: [
2754
+ { id: "started", title: "S", status: "active", sprints: [
2755
+ { id: "s1", title: "done", status: "complete", invoke: "started-done" },
2756
+ { id: "s2", title: "next", status: "next", invoke: "started-next", touches: ["f1"] },
2757
+ ]},
2758
+ { id: "fresh", title: "F", status: "next", sprints: [
2759
+ { id: "s1", title: "aaa", status: "next", invoke: "aaa-fresh", touches: ["f2"], ...(opts.freshPriority ? { priority: { tier: "P0" } } : {}) },
2760
+ ]},
2761
+ ]});
2762
+ // equal priority: the started PI's slice beats the alphabetically-earlier fresh one
2763
+ const m1 = flatten(g());
2764
+ eq(computeWaves(m1, 1).waves[0].map((n) => n.invoke), ["started-next"], "started PI wins the single slot");
2765
+ // declared priority overrides coherence — no overweighting
2766
+ const m2 = flatten(g({ freshPriority: true }));
2767
+ eq(computeWaves(m2, 1).waves[0].map((n) => n.invoke), ["aaa-fresh"], "P0 in a fresh PI still wins");
2768
+ // opt-out restores the old status/est/alpha order
2769
+ eq(computeWaves(m1, 1, { coherence: false }).waves[0].map((n) => n.invoke), ["aaa-fresh"], "coherence:false → alphabetical again");
2770
+ eq(coherenceEnabled({}), true, "default on");
2771
+ eq(coherenceEnabled({ discipline: { coherence: false } }), false, "meta opt-out");
2772
+ });
2773
+
2774
+ // WHY: among two started PIs, the one closer to done should close first — otherwise the
2775
+ // scheduler keeps N PIs perpetually at 80%.
2776
+ test("computeWaves coherence: closest-to-done started PI outranks a bigger started PI", () => {
2777
+ const g = { meta: {}, pis: [
2778
+ { id: "big", title: "B", status: "active", sprints: [
2779
+ { id: "s1", title: "d", status: "complete", invoke: "big-done" },
2780
+ { id: "s2", title: "a", status: "next", invoke: "aaa-big", touches: ["f1"] },
2781
+ { id: "s3", title: "b", status: "next", invoke: "bbb-big", touches: ["f2"] },
2782
+ { id: "s4", title: "c", status: "next", invoke: "ccc-big", touches: ["f3"] },
2783
+ ]},
2784
+ { id: "small", title: "S", status: "active", sprints: [
2785
+ { id: "s1", title: "d", status: "complete", invoke: "small-done" },
2786
+ { id: "s2", title: "z", status: "next", invoke: "zzz-small", touches: ["f4"] },
2787
+ ]},
2788
+ ]};
2789
+ eq(computeWaves(flatten(g), 1).waves[0].map((n) => n.invoke), ["zzz-small"], "one-remaining PI closes before the three-remaining PI");
2790
+ });
2791
+
2792
+ // WHY: the plan's closes annotation is the coherence read-out — a wave that finishes a PI
2793
+ // must say so, and one that doesn't must not.
2794
+ test("buildPlan waveCloses names the PIs a wave finishes", () => {
2795
+ const g = { meta: { schema_version: 1, program: "T" }, pis: [
2796
+ { id: "a", title: "A", status: "active", sprints: [
2797
+ { id: "s1", title: "d", status: "complete", invoke: "a-done" },
2798
+ { id: "s2", title: "last", status: "next", invoke: "a-last", touches: ["f1"], est_sessions: 1 },
2799
+ ]},
2800
+ { id: "b", title: "B", status: "active", sprints: [
2801
+ { id: "s1", title: "one", status: "next", invoke: "b-one", touches: ["f2"], est_sessions: 1 },
2802
+ { id: "s2", title: "two", status: "next", invoke: "b-two", touches: ["f2"], est_sessions: 1 }, // same file → later wave
2803
+ ]},
2804
+ ]};
2805
+ const plan = buildPlan(g, { cap: 3, disk: null });
2806
+ eq(plan.waveCloses[0], ["a"], "wave 1 closes PI a (b still has contended work)");
2807
+ ok(plan.waveCloses[plan.waves.length - 1].includes("b"), "the final wave closes b");
2808
+ });
2809
+
2810
+ // ── review-core: the /debrief evidence base ───────────────────────────────────
2811
+ const oldReviewGraph = {
2812
+ meta: { schema_version: 1, program: "T" },
2813
+ pis: [
2814
+ { id: "auth", title: "Auth", status: "active", sprints: [
2815
+ { id: "s1", title: "Login", status: "active", invoke: "auth-login" },
2816
+ { id: "s2", title: "Tokens", status: "next", invoke: "auth-tokens" },
2817
+ { id: "s3", title: "Old", status: "next", invoke: "auth-old" },
2818
+ { id: "s4", title: "Stuck", status: "gated", gated_on: "Connor", invoke: "auth-stuck" },
2819
+ ]},
2820
+ ],
2821
+ };
2822
+ const newReviewGraph = {
2823
+ meta: { schema_version: 1, program: "T", discipline: { capture_ratio: 2 } },
2824
+ pis: [
2825
+ { id: "auth", title: "Auth", status: "active", sprints: [
2826
+ { id: "s1", title: "Login", status: "complete", invoke: "auth-login", prs: ["#12"] }, // shipped
2827
+ { id: "s2", title: "Tokens", status: "blocked", invoke: "auth-tokens", priority: { tier: "P1" } }, // flip + priority
2828
+ { id: "s4", title: "Stuck", status: "gated", gated_on: "Connor", invoke: "auth-stuck" }, // held in both
2829
+ { id: "s5", title: "New A", status: "next", invoke: "auth-new-a" }, // added
2830
+ { id: "s6", title: "New B", status: "next", invoke: "auth-new-b" }, // added
2831
+ { id: "s7", title: "New C", status: "next", invoke: "auth-new-c" }, // added
2832
+ ]}, // s3 pruned
2833
+ { id: "billing", title: "Billing", status: "scheduled", sprints: [
2834
+ { id: "s1", title: "Seed", status: "scheduled", invoke: "billing-seed" }, // new PI
2835
+ ]},
2836
+ ],
2837
+ };
2838
+
2839
+ // WHY: the digest is the entire evidence base for /debrief — a miscounted diff bucket
2840
+ // produces wrong strategic advice with total confidence.
2841
+ test("graphDiff buckets exactly: added/completed/removed/flips/priority/held", () => {
2842
+ const gd = graphDiff(oldReviewGraph, newReviewGraph);
2843
+ eq(gd.addedPis, [{ id: "billing", title: "Billing" }], "new PI");
2844
+ eq(gd.addedSprints.map((s) => s.invoke).sort(), ["auth-new-a", "auth-new-b", "auth-new-c", "billing-seed"], "added sprints");
2845
+ eq(gd.completedSlices, [{ invoke: "auth-login", pi: "auth", title: "Login", prs: ["#12"] }], "shipped with PRs");
2846
+ eq(gd.removedSprints.map((s) => s.invoke), ["auth-old"], "pruned sprint");
2847
+ eq(gd.statusFlips, [{ invoke: "auth-tokens", from: "next", to: "blocked" }], "flip recorded; →done excluded (it's shipped)");
2848
+ eq(gd.priorityChanges, [{ invoke: "auth-tokens", from: null, to: "P1" }], "tier change");
2849
+ eq(gd.stillHeld, [{ invoke: "auth-stuck", status: "gated" }], "held in BOTH snapshots only — newly-blocked auth-tokens is a flip, not aging");
2850
+ });
2851
+
2852
+ // WHY: /debrief must work before a backlog exists and on the review that first introduces one.
2853
+ test("backlogDiff handles null snapshots and buckets captured/closed/promoted", () => {
2854
+ eq(backlogDiff(null, null), { captured: [], closed: [], promoted: [] }, "no backlog either side");
2855
+ const b = backlogDiff(
2856
+ { meta: { schema_version: 1 }, items: [
2857
+ { id: "b1", title: "Old open", kind: "bug", status: "open" },
2858
+ { id: "b2", title: "Was open", kind: "chore", status: "open" },
2859
+ { id: "b3", title: "Move me", kind: "followup", status: "open" },
2860
+ ]},
2861
+ { meta: { schema_version: 1 }, items: [
2862
+ { id: "b1", title: "Old open", kind: "bug", status: "open" },
2863
+ { id: "b2", title: "Was open", kind: "chore", status: "done" },
2864
+ { id: "b3", title: "Move me", kind: "followup", status: "promoted", promoted_to: "auth/s9" },
2865
+ { id: "b4", title: "Fresh", kind: "idea", status: "open" },
2866
+ ]});
2867
+ eq(b.captured, [{ id: "b4", title: "Fresh", kind: "idea" }], "new capture");
2868
+ eq(b.closed, [{ id: "b2", title: "Was open", status: "done" }], "closed item");
2869
+ eq(b.promoted, [{ id: "b3", promoted_to: "auth/s9" }], "promotion with back-link");
2870
+ eq(backlogDiff(null, { meta: { schema_version: 1 }, items: [{ id: "x", title: "X", kind: "bug", status: "open" }] }).captured.length, 1, "first backlog → everything captured");
2871
+ });
2872
+
2873
+ // WHY: the digest's sprawl lines must be byte-identical to /sync's (same function) or the
2874
+ // two guardrails drift apart; and pisInFlight is the fragmentation coherence exists to shrink.
2875
+ test("reviewDigest composes counts, reuses sprawlWarnings verbatim, and counts PIs in flight", () => {
2876
+ const gd = graphDiff(oldReviewGraph, newReviewGraph);
2877
+ const bd = backlogDiff(null, { meta: { schema_version: 1 }, items: [{ id: "b1", title: "Cap", kind: "bug", status: "open" }] });
2878
+ const d = reviewDigest({ gd, bd, graph: newReviewGraph });
2879
+ eq(d.netGrowth, { added: 5, completed: 1, ratio: 5 }, "1 item + 4 sprints vs 1 shipped");
2880
+ eq(d.sprawl, sprawlWarnings({ completed: 1, captured: 1, addedSprints: 4, addedPis: ["billing"], ratioThreshold: 2 }), "same function, same lines");
2881
+ eq(d.sprawl.length, 2, "ratio warning + PI flag");
2882
+ eq(d.pisInFlight, 1, "auth started with open work; billing untouched");
2883
+ eq(pisInFlight({ pis: [
2884
+ { id: "a", sprints: [{ status: "complete" }, { status: "next" }] },
2885
+ { id: "b", sprints: [{ status: "active" }, { status: "next" }] },
2886
+ { id: "c", sprints: [{ status: "next" }] },
2887
+ { id: "d", sprints: [{ status: "complete" }] },
2888
+ ]}), 2, "started+open counts; untouched and fully-done don't");
2889
+ });
2890
+
2891
+ // WHY: the CLI is the anchor→git-show→digest wiring /debrief trusts — one real-git test
2892
+ // proves the pathspec, rev resolution, and JSON contract on this platform (Windows included).
2893
+ test("review.mjs end-to-end in a real git repo: --since <sha> diffs old vs new YAML", () => {
2894
+ const root = mkdtempSync(join(tmpdir(), "roadmap-review-test-"));
2895
+ mkdirSync(join(root, "docs", "roadmap"), { recursive: true });
2896
+ const yamlPath = join(root, "docs", "roadmap", "roadmap.yaml");
2897
+ const g = (...a) => spawnSync("git", a, { cwd: root, encoding: "utf8" });
2898
+ g("init", "-q");
2899
+ g("config", "user.email", "t@t"); g("config", "user.name", "t");
2900
+ writeFileSync(yamlPath, `meta:\n schema_version: 1\n program: T\npis:\n - id: a\n title: A\n status: active\n sprints:\n - { id: s1, title: S, status: next, invoke: a-s1 }\n`, "utf8");
2901
+ g("add", "-A"); g("commit", "-qm", "v1");
2902
+ const sha = g("log", "-1", "--format=%H").stdout.trim();
2903
+ writeFileSync(yamlPath, `meta:\n schema_version: 1\n program: T\npis:\n - id: a\n title: A\n status: active\n sprints:\n - { id: s1, title: S, status: complete, invoke: a-s1, prs: ["#7"] }\n - { id: s2, title: N, status: next, invoke: a-s2 }\n`, "utf8");
2904
+ const r = spawnSync("node", [join(resolve("scripts"), "review.mjs"), "--since", sha, "--json"], { cwd: root, encoding: "utf8" });
2905
+ eq(r.status, 0, `review.mjs exited 0 (stderr: ${r.stderr})`);
2906
+ const { anchor, digest } = JSON.parse(r.stdout);
2907
+ eq(anchor.commit, sha, "anchor honored");
2908
+ eq(digest.shipped.map((s) => s.invoke), ["a-s1"], "shipped detected from the git snapshot");
2909
+ eq(digest.captured.sprints.map((s) => s.invoke), ["a-s2"], "added sprint detected");
2910
+ rmSync(root, { recursive: true, force: true });
2911
+ });
2912
+
2913
+ // ── label sync + project enrichment ───────────────────────────────────────────
2914
+ const LBL = { roadmap: "l-mark", "kind:bug": "l-bug", "track:infra": "l-infra" };
2915
+
2916
+ // WHY: label CHURN would make every sync noisy — the same label set in a different order
2917
+ // must be zero ops; a genuinely missing label exactly one update carrying only labelIds.
2918
+ test("label diff is a set compare: reordered → no op; missing one → one labelIds-only update", () => {
2919
+ const g = pushGraph();
2920
+ g.pis[0].sprints[0].track = "infra";
2921
+ const cfg = normalizeLinearConfig(g.meta);
2922
+ const desc = issueDescription({ invoke: "auth-login", title: "Login", what: "Login", gate: "default", estSessions: null, priority: null, track: "infra" }, cfg, { target: { type: "slice", key: "auth-login" } });
2923
+ const snapReordered = { projects: { "proj-1": { id: "proj-1", name: "Authentication", description: "" } },
2924
+ issues: { "ENG-1": { id: "uuid-1", title: "Login", description: desc, priority: 0, stateId: "st-s", projectId: "proj-1", labelIds: ["l-infra", "l-mark"] } } };
2925
+ const same = buildPushPlan({ graph: g, backlog: null, cfg, teamStates: L_STATES, existing: snapReordered, labels: LBL });
2926
+ ok(!same.ops.some((o) => o.op === "updateIssue"), "reordered labels → no update");
2927
+ const snapMissing = { ...snapReordered, issues: { "ENG-1": { ...snapReordered.issues["ENG-1"], labelIds: ["l-mark"] } } };
2928
+ const drifted = buildPushPlan({ graph: g, backlog: null, cfg, teamStates: L_STATES, existing: snapMissing, labels: LBL });
2929
+ const upd = drifted.ops.find((o) => o.op === "updateIssue");
2930
+ eq(upd.payload, { labelIds: ["l-infra", "l-mark"] }, "only labelIds sent, sorted");
2931
+ });
2932
+
2933
+ // WHY: an unprovisioned team (no labels yet) must degrade — no churn, no crash — and name
2934
+ // every unresolved label once so provision knows what to create.
2935
+ test("empty label map degrades: no label ops, missingLabels names each wanted label", () => {
2936
+ const g = pushGraph();
2937
+ g.pis[0].sprints[0].track = "infra";
2938
+ const cfg = normalizeLinearConfig(g.meta);
2939
+ const plan = buildPushPlan({ graph: g, backlog: null, cfg, teamStates: L_STATES, existing: SNAP(), labels: {} });
2940
+ ok(!plan.ops.some((o) => o.payload && o.payload.labelIds), "no labelIds anywhere");
2941
+ eq(plan.missingLabels, ["roadmap", "track:infra"], "unresolved names reported once, sorted");
2942
+ });
2943
+
2944
+ // WHY: kind/track routing is the triage contract — the wrong label buckets work into the
2945
+ // wrong Linear view and the board lies.
2946
+ test("desiredLabels routes marker+kind for items, marker+track for sprints", () => {
2947
+ eq(desiredLabels({ type: "backlog" }, { kind: "bug" }), [MARKER_LABEL, "kind:bug"], "item labels");
2948
+ eq(desiredLabels({ type: "slice" }, { track: "infra" }), [MARKER_LABEL, "track:infra"], "tracked sprint");
2949
+ eq(desiredLabels({ type: "slice" }, { track: null }), [MARKER_LABEL], "untracked sprint = marker only");
2950
+ });
2951
+
2952
+ // WHY: PI theme/exit_criteria is the only strategic context Linear viewers get — it must
2953
+ // land on create, update on drift, and stay quiet when matching (idempotency).
2954
+ test("project subtitle prefers exit's first sentence; content composes full body; drift + idempotent", () => {
2955
+ eq(projectDescription({ theme: "Own the login flow", exit_criteria: "Auth e2e is green. Plus MFA." }),
2956
+ "Auth e2e is green.", "subtitle = first sentence of exit");
2957
+ eq(projectDescription({ theme: "Own the login flow" }), "Own the login flow", "no exit → falls back to theme");
2958
+ eq(projectContent({ theme: "Own the login flow", exit_criteria: "Auth e2e is green.\nMFA too." }),
2959
+ "**Theme:** Own the login flow\n\n**Exit criteria**\nAuth e2e is green.\nMFA too.", "content = theme + full (un-one-lined) exit");
2960
+ const g = pushGraph();
2961
+ g.pis[0].theme = "Own the login flow";
2962
+ const cfg = normalizeLinearConfig(g.meta);
2963
+ const content = projectContent(g.pis[0]);
2964
+ const snap = (over) => ({ projects: { "proj-1": { id: "proj-1", name: "Authentication", description: "", content: "", ...over } }, issues: SNAP().issues });
2965
+ const drift = buildPushPlan({ graph: g, backlog: null, cfg, teamStates: L_STATES, existing: snap({}), labels: {} });
2966
+ eq(drift.ops.find((o) => o.op === "updateProject").payload, { description: "Own the login flow", content }, "drifted subtitle + content update together");
2967
+ const match = buildPushPlan({ graph: g, backlog: null, cfg, teamStates: L_STATES, existing: snap({ description: "Own the login flow", content }), labels: {} });
2968
+ ok(!match.ops.some((o) => o.op === "updateProject"), "matching subtitle + content → no op");
2969
+ });
2970
+
2971
+ // WHY: Linear auto-links URLs/domains in stored text; without normalizing BOTH sides the
2972
+ // description/content diff never converges and re-pushes every issue+project each sync.
2973
+ test("normalizeLinearMarkdown collapses Linear's stored auto-link form to plain text", () => {
2974
+ eq(normalizeLinearMarkdown("see [Fly.io](<http://Fly.io>) now"), "see Fly.io now", "angle-bracket auto-link → its text");
2975
+ eq(normalizeLinearMarkdown("[a](b) and [c](<d>)"), "a and c", "both markdown link forms collapse");
2976
+ eq(normalizeLinearMarkdown("plain text"), "plain text", "plain text untouched");
2977
+ });
2978
+
2979
+ // WHY: live-caught — Linear rewrites underscore-bold to asterisk-bold on store, so a gate naming
2980
+ // a __tests__ directory came back as **tests** and re-pushed the same three issues every sync
2981
+ // (the exact non-convergence normalizeLinearMarkdown exists to prevent).
2982
+ test("normalizeLinearMarkdown canonicalizes underscore-bold to asterisk-bold (both sides converge)", () => {
2983
+ eq(normalizeLinearMarkdown("run vitest in __tests__ dir"), normalizeLinearMarkdown("run vitest in **tests** dir"), "our __tests__ equals Linear's stored **tests**");
2984
+ eq(normalizeLinearMarkdown("a __b__ c __d__"), "a **b** c **d**", "every occurrence canonicalizes");
2985
+ eq(normalizeLinearMarkdown("snake_case stays_put"), "snake_case stays_put", "single underscores untouched");
2986
+ });
2987
+
2988
+ // WHY: random per-project icon/color is scattered noise; grouping by initiative is the whole
2989
+ // point. Same initiative → same color/icon; distinct initiatives (up to palette size) differ.
2990
+ test("project color/icon are deterministic by initiative index, null when ungrouped", () => {
2991
+ eq(projectColorFor(0), projectColorFor(0), "stable for the same index");
2992
+ ok(projectColorFor(0) !== projectColorFor(1), "distinct initiatives get distinct colors");
2993
+ eq(projectColorFor(-1), null, "ungrouped PI → no color override");
2994
+ eq(projectIconFor(-1), null, "ungrouped PI → no icon override");
2995
+ ok(typeof projectColorFor(0) === "string" && projectColorFor(0).startsWith("#"), "color is a hex string Linear stores verbatim");
2996
+ });
2997
+
2998
+ // WHY: a PI's STRATEGIC priority + target date fill Linear's empty Priority/Target columns; an
2999
+ // untagged PI must stay honestly "No priority" (not a faked value), and same-initiative projects
3000
+ // must share a color so the board groups.
3001
+ test("buildPushPlan enriches projects with priority, target date, and initiative color/icon", () => {
3002
+ const g = { meta: { schema_version: 1, program: "T", linear: { team: "ENG" } }, pis: [
3003
+ { id: "a", title: "A", initiative: "Copilot", priority: { tier: "P0" }, target_date: "2026-09-01", status: "active", sprints: [{ id: "s1", title: "S", status: "next", invoke: "a-s1" }] },
3004
+ { id: "b", title: "B", initiative: "Copilot", status: "active", sprints: [{ id: "s1", title: "S", status: "next", invoke: "b-s1" }] },
3005
+ ]};
3006
+ const cfg = normalizeLinearConfig(g.meta);
3007
+ const plan = buildPushPlan({ graph: g, backlog: null, cfg, teamStates: L_STATES, existing: { projects: {}, issues: {} }, labels: {} });
3008
+ const a = plan.ops.find((o) => o.op === "createProject" && o.projectRef === "a");
3009
+ const b = plan.ops.find((o) => o.op === "createProject" && o.projectRef === "b");
3010
+ eq(a.payload.priority, 1, "P0 → Linear priority 1 (Urgent)");
3011
+ eq(a.payload.targetDate, "2026-09-01", "target_date → targetDate");
3012
+ eq(a.payload.color, b.payload.color, "same initiative → same color");
3013
+ ok(a.payload.icon && a.payload.color, "grouped project gets both an icon and a color");
3014
+ ok(!("priority" in b.payload), "a PI with no priority stays No priority (not faked)");
3015
+ });
3016
+
3017
+ // WHY: a long exit_criteria first sentence truncates the 255-char Linear subtitle with "…"; an authored
3018
+ // pi.summary must win and stay whole, and the raw (pre-clip) form must be exposed so validate can warn.
3019
+ test("projectDescription: pi.summary wins over the derived subtitle; projectSubtitleRaw exposes the raw", () => {
3020
+ const pi = { title: "Big PI", exit_criteria: "A".repeat(300) + "." };
3021
+ ok(projectSubtitleRaw(pi).length > 255 && projectDescription(pi).endsWith("..."), "derived subtitle overflows → truncates with …");
3022
+ const withSummary = { ...pi, summary: "One crisp line." };
3023
+ eq(projectSubtitleRaw(withSummary), "One crisp line.", "summary is the raw subtitle");
3024
+ eq(projectDescription(withSummary), "One crisp line.", "summary wins, no truncation");
3025
+ });
3026
+
3027
+ // WHY: Linear's roadmap TIMELINE is project-level (startDate/targetDate) — a PI's start must project so the
3028
+ // Gantt view renders, and stay idempotent (matching dates → no re-push).
3029
+ test("buildPushPlan pushes project startDate + targetDate; idempotent", () => {
3030
+ const g = { meta: { schema_version: 1, program: "T", linear: { team: "ENG" } }, pis: [
3031
+ { id: "a", title: "A", status: "active", start_date: "2026-07-01", target_date: "2026-09-01", sprints: [{ id: "s1", title: "S", status: "next", invoke: "x" }] }] };
3032
+ const cfg = normalizeLinearConfig(g.meta);
3033
+ const proj = buildPushPlan({ graph: g, backlog: null, cfg, teamStates: L_STATES, existing: { projects: {}, issues: {} }, labels: {} }).ops.find((o) => o.op === "createProject");
3034
+ eq(proj.payload.startDate, "2026-07-01", "startDate on create");
3035
+ eq(proj.payload.targetDate, "2026-09-01", "targetDate on create");
3036
+ const g2 = { meta: g.meta, pis: [{ ...g.pis[0], linear: { project: "proj-1" } }] };
3037
+ const cur = { projects: { "proj-1": { id: "proj-1", name: "A", startDate: "2026-07-01", targetDate: "2026-09-01" } }, issues: {} };
3038
+ ok(!buildPushPlan({ graph: g2, backlog: null, cfg, teamStates: L_STATES, existing: cur, labels: {} }).ops.some((o) => o.op === "updateProject"), "matching start/target → no project re-push");
3039
+ });
3040
+
3041
+ // WHY: the auto-stamp DECISION is pure + unit-tested (mirrors plateDrainKeys), not buried in the IO layer —
3042
+ // only active PIs lacking an explicit start_date get stamped; explicit + non-active are excluded.
3043
+ test("startStampTargets: active PIs without an explicit start_date", () => {
3044
+ eq(startStampTargets({ pis: [
3045
+ { id: "a", status: "active", sprints: [] },
3046
+ { id: "b", status: "active", start_date: "2026-01-01", sprints: [] },
3047
+ { id: "c", status: "next", sprints: [] },
3048
+ { id: "d", status: "complete", sprints: [] },
3049
+ ] }), ["a"], "only active + no explicit start_date");
3050
+ });
3051
+
3052
+ // WHY: bad dates / an oversized subtitle must error; a DERIVED subtitle that would truncate must warn (the
3053
+ // nudge to author a summary) — a silently cut-off board subtitle is the exact bug this fixes.
3054
+ test("validate: start_date/summary shape + derived-subtitle truncation warning", () => {
3055
+ const bad = { meta: { schema_version: 1, program: "T" }, pis: [
3056
+ { id: "p", title: "P", status: "active", start_date: "07/01/2026", summary: "x".repeat(300), sprints: [{ id: "s1", title: "S", status: "active", invoke: "x" }] }] };
3057
+ const r = validateGraph(bad);
3058
+ ok(r.errors.some((e) => e.includes("start_date must be YYYY-MM-DD")), "bad start_date errors");
3059
+ ok(r.errors.some((e) => e.includes("summary must be a string of at most 255")), "oversized summary errors");
3060
+ const trunc = { meta: { schema_version: 1, program: "T", linear: { team: "ENG" } }, pis: [
3061
+ { id: "q", title: "Q", status: "active", exit_criteria: "Z".repeat(300) + ".", sprints: [{ id: "s1", title: "S", status: "active", invoke: "y" }] }] };
3062
+ ok(validateGraph(trunc).warnings.some((w) => w.includes("subtitle truncates")), "derived subtitle >255 warns to add a summary");
3063
+ });
3064
+
3065
+ // WHY: an active PI without a start_date must get one stamped on sync (the timeline needs a start), written
3066
+ // back to the YAML; an explicit date and a non-active PI must be left alone.
3067
+ test("runSync auto-stamps start_date on an active PI without one; spares explicit + non-active", async () => {
3068
+ const root = mkdtempSync(join(tmpdir(), "roadmap-startstamp-"));
3069
+ mkdirSync(join(root, "docs", "roadmap"), { recursive: true });
3070
+ writeFileSync(join(root, "docs", "roadmap", "roadmap.yaml"),
3071
+ `meta:\n schema_version: 1\n program: T\n linear:\n team: ENG\npis:\n - id: live\n title: Live\n status: active\n linear: { project: proj-live }\n sprints: [ { id: s1, title: S, status: active, invoke: a, linear: ENG-1 } ]\n - id: dated\n title: Dated\n status: active\n start_date: 2026-01-01\n linear: { project: proj-dated }\n sprints: [ { id: s1, title: T, status: active, invoke: b, linear: ENG-2 } ]\n - id: later\n title: Later\n status: scheduled\n linear: { project: proj-later }\n sprints: [ { id: s1, title: U, status: scheduled, invoke: c, linear: ENG-3 } ]\n`, "utf8");
3072
+ const iss = (idf) => ({ id: `u-${idf}`, identifier: idf, title: "X", description: "", priority: 0, estimate: null, state: { id: "st-s" }, project: { id: "proj-x" }, assignee: null, labels: { nodes: [] } });
3073
+ const proj = (id) => ({ id, name: "X", description: "", content: "", color: "", icon: "", priority: 0, startDate: null, targetDate: null });
3074
+ const fake = fakeLinear({ snapshot: { "ENG-1": iss("ENG-1"), "ENG-2": iss("ENG-2"), "ENG-3": iss("ENG-3") },
3075
+ projectSnapshot: { "proj-live": proj("proj-live"), "proj-dated": proj("proj-dated"), "proj-later": proj("proj-later") } });
3076
+ await runSync(root, { fetchImpl: fake.fetchImpl, env: { LINEAR_API_KEY: "k" }, now: "2026-07-08T00:00:00Z" });
3077
+ const pis = parseDocument(readFileSync(join(root, "docs", "roadmap", "roadmap.yaml"), "utf8")).toJS().pis;
3078
+ eq(pis.find((p) => p.id === "live").start_date, "2026-07-08", "active PI without start_date → stamped the sync date");
3079
+ eq(pis.find((p) => p.id === "dated").start_date, "2026-01-01", "explicit start_date untouched");
3080
+ ok(!pis.find((p) => p.id === "later").start_date, "non-active PI → not stamped");
3081
+ rmSync(root, { recursive: true, force: true });
3082
+ });
3083
+
3084
+ // WHY: the by-order palette makes an initiative's icon a coincidence, not a meaning — meta.initiatives
3085
+ // is what turns grouping into signal (Lumen→WritingAI). A declared style must win over the palette,
3086
+ // per-field, or the whole point of the feature (legible grouping) is lost.
3087
+ test("meta.initiatives: declared icon/color wins over the fallback palette, per-field", () => {
3088
+ eq(initiativeStyle({ initiatives: { Lumen: { icon: "WritingAI", color: "#bb87fc" } } }, "Lumen"), { icon: "WritingAI", color: "#bb87fc" }, "declared → its icon+color");
3089
+ eq(initiativeStyle({ initiatives: { Lumen: { icon: "WritingAI" } } }, "Lumen"), { icon: "WritingAI", color: null }, "color omitted → null (falls back per-field)");
3090
+ eq(initiativeStyle({}, "Lumen"), { icon: null, color: null }, "no registry → nulls");
3091
+ const g = { meta: { schema_version: 1, program: "T", linear: { team: "ENG" }, initiatives: { Lumen: { icon: "WritingAI" } } }, pis: [
3092
+ { id: "a", title: "A", initiative: "Lumen", status: "active", sprints: [{ id: "s1", title: "S", status: "next", invoke: "a-s1" }] },
3093
+ ]};
3094
+ const plan = buildPushPlan({ graph: g, backlog: null, cfg: normalizeLinearConfig(g.meta), teamStates: L_STATES, existing: { projects: {}, issues: {} }, labels: {} });
3095
+ const a = plan.ops.find((o) => o.op === "createProject" && o.projectRef === "a");
3096
+ eq(a.payload.icon, "WritingAI", "declared initiative icon lands on the project (over palette)");
3097
+ ok(a.payload.color, "color still falls back to the palette (declared only the icon)");
3098
+ });
3099
+
3100
+ // WHY: a declared style is only useful if a typo (or a half-applied rename like Copilot→Lumen) is caught
3101
+ // — a malformed entry must error, and a declared-but-unreferenced initiative must warn, not sit silent.
3102
+ test("validate: meta.initiatives shape errors; a declared-but-unreferenced initiative warns", () => {
3103
+ const bad = { meta: { schema_version: 1, program: "T", initiatives: { X: { color: "purple" } } }, pis: [
3104
+ { id: "p", title: "P", initiative: "X", status: "active", sprints: [{ id: "s1", title: "S", status: "next", invoke: "x" }] }] };
3105
+ ok(validateLinearConfig(bad).errors.some((e) => e.includes("color must be a hex")), "non-hex color errors");
3106
+ const stale = { meta: { schema_version: 1, program: "T", initiatives: { Lumen: { icon: "WritingAI" }, Copilot: { icon: "Robot" } } }, pis: [
3107
+ { id: "p", title: "P", initiative: "Lumen", status: "active", sprints: [{ id: "s1", title: "S", status: "next", invoke: "x" }] }] };
3108
+ const w = validateLinearConfig(stale).warnings.filter((m) => m.includes("Copilot"));
3109
+ eq(w.length, 1, "the renamed-away 'Copilot' entry warns (no PI references it)");
3110
+ });
3111
+
3112
+ // WHY: content carries URLs/file refs Linear auto-links on store; without the normalized compare
3113
+ // every project re-pushes its content each sync (the 92→10 idempotency class, generalized).
3114
+ test("project content re-push is suppressed when only Linear's auto-linking differs", () => {
3115
+ const g = { meta: { schema_version: 1, program: "T", linear: { team: "ENG" } }, pis: [
3116
+ { id: "a", title: "A", linear: { project: "proj-1" }, exit_criteria: "Ship at updates.pidgeon.health today.", status: "active", sprints: [{ id: "s1", title: "S", status: "next", invoke: "a-s1" }] },
3117
+ ]};
3118
+ const cfg = normalizeLinearConfig(g.meta);
3119
+ const pi = g.pis[0];
3120
+ const stored = projectContent(pi).replace("updates.pidgeon.health", "[updates.pidgeon.health](<http://updates.pidgeon.health>)");
3121
+ const plan = buildPushPlan({ graph: g, backlog: null, cfg, teamStates: L_STATES,
3122
+ existing: { projects: { "proj-1": { id: "proj-1", name: "A", description: projectDescription(pi), content: stored } }, issues: {} }, labels: {} });
3123
+ ok(!plan.ops.some((o) => o.op === "updateProject"), "auto-link-only difference in content → no re-push");
3124
+ });
3125
+
3126
+ // WHY: the plan is paper until the IO forwards it — the end-to-end must show labelIds
3127
+ // reaching the created issue's input.
3128
+ test("runSync forwards labelIds from the team's labels to issueCreate", async () => {
3129
+ const root = linearRepo();
3130
+ const fake = fakeLinear({ teamLabels: [{ id: "l-mark", name: "roadmap" }] });
3131
+ await runSync(root, { fetchImpl: fake.fetchImpl, env: { LINEAR_API_KEY: "k" }, now: "2026-07-06T12:00:00Z" });
3132
+ const create = fake.calls.find((c) => c.query.includes("issueCreate"));
3133
+ eq(create.variables.input.labelIds, ["l-mark"], "marker label id on the created issue");
3134
+ rmSync(root, { recursive: true, force: true });
3135
+ });
3136
+
3137
+ // ── provision ─────────────────────────────────────────────────────────────────
3138
+ // WHY: provisioning must be idempotent or every run duplicates labels; and track labels
3139
+ // must come from lanes actually in the graph, not a hardcoded list.
3140
+ test("runProvision creates missing labels once (idempotent) with track labels from the graph", async () => {
3141
+ const root = linearRepo();
3142
+ const yamlPath = join(root, "docs", "roadmap", "roadmap.yaml");
3143
+ writeFileSync(yamlPath, readFileSync(yamlPath, "utf8").replace("invoke: auth-login }", "invoke: auth-login, track: infra }"), "utf8");
3144
+ const run1 = fakeLinear();
3145
+ const r1 = await runProvision(root, { fetchImpl: run1.fetchImpl, env: { LINEAR_API_KEY: "k" } });
3146
+ eq(r1.labelsCreated, ["roadmap", "kind:bug", "kind:chore", "kind:followup", "kind:urgent", "kind:idea", "track:infra"], "marker + kinds + graph track");
3147
+ eq(r1.views, [...STANDARD_VIEWS.map((v) => v.name), "Track infra"], "the 5 standard views + a per-track lane view from the graph's track");
3148
+ const run2 = fakeLinear({ teamLabels: r1.labelsCreated.map((name, i) => ({ id: `l${i}`, name })) });
3149
+ const r2 = await runProvision(root, { fetchImpl: run2.fetchImpl, env: { LINEAR_API_KEY: "k" } });
3150
+ eq(r2.labelsCreated, [], "second run creates nothing");
3151
+ rmSync(root, { recursive: true, force: true });
3152
+ });
3153
+
3154
+ // ── the journal (progress notes on the mapped issue) ──────────────────────────
3155
+ // WHY: the note body must be human-scannable on pickup (kind heading + marker); the branch→slice resolver
3156
+ // and git-snapshot formatter drive the auto-post hook and must be pure + unit-tested (the PR A lesson).
3157
+ test("journal-core: noteBody format, sliceForBranch inversion, gitSnapshot skip-when-empty", () => {
3158
+ eq(noteBody({ kind: "blocker", text: " DB down " }), "**[blocker]** DB down\n\n_roadmap-note_", "kind heading + trimmed text + marker");
3159
+ ok(noteBody({ text: "x" }).startsWith("**[progress]**"), "default kind is progress");
3160
+ ok(noteBody({ kind: "bogus", text: "x" }).startsWith("**[progress]**"), "unknown kind → progress");
3161
+ const g = { meta: { schema_version: 1, program: "T" }, pis: [
3162
+ { id: "auth", title: "A", status: "active", sprints: [
3163
+ { id: "s1", title: "S", status: "active", invoke: "login" },
3164
+ { id: "s2", title: "T", status: "active", invoke: "tokens" } ]}]};
3165
+ eq(sliceForBranch(g, "auth/s1").invoke, "login", "branch → its slice (inverts branchFor)");
3166
+ eq(sliceForBranch(g, "nope/x"), null, "no match → null (hook no-ops)");
3167
+ eq(gitSnapshot({ branch: "b", commits: [], dirty: "" }), null, "no commits + clean tree → null (skip empty post)");
3168
+ const snap = gitSnapshot({ branch: "auth/s1", commits: ["feat: x", "fix: y"], dirty: " M a.js\n?? b.js" });
3169
+ ok(snap.includes("auth/s1") && snap.includes("feat: x") && snap.includes("a.js"), "snapshot names branch, commits, and dirty paths");
3170
+ });
3171
+
3172
+ // WHY: the session-end hook's guard must be pure + unit-tested (PR A lesson) — auto-post only for a
3173
+ // mapped-slice branch with real work, and skip everything else (the hook just runs git + fetch around it).
3174
+ test("autoPostPlan: posts for a mapped-slice branch with work; skips unmapped / non-slice / no-work", () => {
3175
+ const g = { meta: { schema_version: 1, program: "T", linear: { team: "ENG" } }, pis: [
3176
+ { id: "auth", title: "A", status: "active", linear: { project: "proj-1" }, sprints: [
3177
+ { id: "s1", title: "S", status: "active", invoke: "login", linear: "ENG-1" },
3178
+ { id: "s2", title: "T", status: "active", invoke: "tokens" } ]}]};
3179
+ const plan = autoPostPlan(g, { branch: "auth/s1", commits: ["feat: x"], dirty: "" });
3180
+ eq(plan.identifier, "ENG-1", "mapped-slice branch + work → post to its issue");
3181
+ ok(plan.body.includes("[auto]") && plan.body.includes("feat: x"), "body is the auto-kind git snapshot");
3182
+ eq(autoPostPlan(g, { branch: "auth/s2", commits: ["x"], dirty: "" }), null, "branch maps to an UNMAPPED slice → skip");
3183
+ eq(autoPostPlan(g, { branch: "main", commits: ["x"], dirty: "" }), null, "branch isn't a slice → skip");
3184
+ eq(autoPostPlan(g, { branch: "auth/s1", commits: [], dirty: "" }), null, "no commits + clean → skip (no empty post)");
3185
+ });
3186
+
3187
+ // WHY: `note` posts a formatted comment to the slice's MAPPED issue (reusing the dispatch transport);
3188
+ // `notes` reads the stream back for pickup; an unmapped or unknown key must error clearly, not post blind.
3189
+ test("runNote / runNotes: post to + read the mapped issue; errors on unmapped/unknown", async () => {
3190
+ const root = mkdtempSync(join(tmpdir(), "roadmap-journal-"));
3191
+ mkdirSync(join(root, "docs", "roadmap"), { recursive: true });
3192
+ writeFileSync(join(root, "docs", "roadmap", "roadmap.yaml"),
3193
+ `meta:\n schema_version: 1\n program: T\n linear:\n team: ENG\npis:\n - id: p\n title: P\n status: active\n sprints:\n - { id: s1, title: Login, status: active, invoke: login, linear: ENG-1 }\n - { id: s2, title: Tokens, status: next, invoke: tokens }\n`, "utf8");
3194
+ const fake = fakeLinear({
3195
+ snapshot: { "ENG-1": { id: "u1", identifier: "ENG-1", title: "Login", description: "", priority: 0, state: { id: "st-s" }, labels: { nodes: [] } } },
3196
+ issueComments: { "ENG-1": [{ body: "**[progress]** did X\n\n_roadmap-note_", createdAt: "2026-07-08T10:00:00Z", user: { name: "Connor" } }] },
3197
+ });
3198
+ const io = { env: { LINEAR_API_KEY: "k" }, fetchImpl: fake.fetchImpl };
3199
+ const r = await runNote(root, "login", { kind: "blocker", text: "stuck on auth" }, io);
3200
+ eq(r.identifier, "ENG-1", "note resolved the slice to its mapped issue");
3201
+ const posted = fake.calls.find((c) => c.query.includes("commentCreate"));
3202
+ ok(posted.variables.input.body.includes("[blocker]") && posted.variables.input.body.includes("stuck on auth"), "posted body carries kind + text");
3203
+ const read = await runNotes(root, "login", io);
3204
+ eq(read.notes.length, 1, "notes returns the issue's comment stream");
3205
+ eq(read.notes[0].author, "Connor", "note author mapped");
3206
+ await runNote(root, "ghost", { text: "x" }, io).then(() => { throw new Error("should throw"); }, (e) => ok(e.message.includes('no slice or backlog item "ghost"'), "unknown key errors"));
3207
+ eq((await runNote(root, "tokens", { text: "x" }, io)).skipped, "unmapped", "known-but-unmapped slice → soft-skip (no throw, no blind post)");
3208
+ rmSync(root, { recursive: true, force: true });
3209
+ });
3210
+
3211
+ // WHY: the PI digest posts a Linear project update, degradation-guarded — a Linear rejection is a skip
3212
+ // (posted:false), never a throw that would break a sync/session.
3213
+ test("runProjectUpdate: posts a project update; degrades on rejection; errors on unmapped PI", async () => {
3214
+ const root = mkdtempSync(join(tmpdir(), "roadmap-digest-"));
3215
+ mkdirSync(join(root, "docs", "roadmap"), { recursive: true });
3216
+ writeFileSync(join(root, "docs", "roadmap", "roadmap.yaml"),
3217
+ `meta:\n schema_version: 1\n program: T\n linear:\n team: ENG\npis:\n - id: p\n title: P\n status: active\n linear: { project: proj-1 }\n sprints: [ { id: s1, title: S, status: active, invoke: x } ]\n`, "utf8");
3218
+ const io = (fail) => { const f = fakeLinear({ failOn: fail || null }); return { env: { LINEAR_API_KEY: "k" }, fetchImpl: f.fetchImpl }; };
3219
+ eq((await runProjectUpdate(root, "p", "shipped the thing", io())).posted, true, "posts a project update");
3220
+ eq((await runProjectUpdate(root, "p", "x", io("projectUpdateCreate"))).posted, false, "Linear rejection → posted:false (degraded, no throw)");
3221
+ await runProjectUpdate(root, "nope", "x", io()).then(() => { throw new Error("should throw"); }, (e) => ok(e.message.includes("no linear.project"), "unmapped PI errors"));
3222
+ rmSync(root, { recursive: true, force: true });
3223
+ });
3224
+
3225
+ // WHY: customViewCreate is unverified — a rejection must degrade to the manual checklist,
3226
+ // never abort the labels that DID provision.
3227
+ test("runProvision degrades to the manual checklist when customViewCreate is rejected", async () => {
3228
+ const root = linearRepo();
3229
+ const fake = fakeLinear({ failOn: "customViewCreate" });
3230
+ const r = await runProvision(root, { fetchImpl: fake.fetchImpl, env: { LINEAR_API_KEY: "k" } });
3231
+ ok(r.labelsCreated.length >= 6, "labels still created");
3232
+ eq(r.views, [], "no views claimed");
3233
+ ok(r.viewChecklist.rejected.includes("simulated view rejection"), "rejection named");
3234
+ ok(r.viewChecklist.checklist.includes('□ New view "Ready wave"'), "checklist lists the manual steps");
3235
+ eq(manualViewChecklist().split("\n").length, STANDARD_VIEWS.length, "one line per view");
3236
+ rmSync(root, { recursive: true, force: true });
3237
+ });
3238
+
3239
+ // WHY: dispatchGuidance is the contract every cloud-delegated agent reads — losing the
3240
+ // merge prohibition or the temperance rule turns delegation into drift.
3241
+ test("dispatchGuidance carries the dispatch contract", () => {
3242
+ const g = dispatchGuidance();
3243
+ ok(g.includes("NEVER merge"), "merge prohibition");
3244
+ ok(g.includes("BACKLOG ONLY"), "temperance rule");
3245
+ ok(g.includes("roadmap: slice=<key>"), "footer parse instruction");
3246
+ ok(g.includes("YAML is canonical"), "canonicality stated");
3247
+ const plan = provisionPlan({ graph: { pis: [] }, teamLabels: { roadmap: "x" } });
3248
+ ok(!plan.createLabels.includes("roadmap") && plan.existingLabels.includes("roadmap"), "provisionPlan respects existing labels");
3249
+ });
3250
+
3251
+ // ── live-caught regressions (v0.4 verification day) ──────────────────────────
3252
+ // WHY: live-caught — the createProject executor forwarded only name+teamIds, dropping the
3253
+ // description, so every project was born bare and the NEXT sync healed it (perpetual
3254
+ // first-run churn). The executor must spread the whole planned payload.
3255
+ test("createProject forwards the full payload (description included)", async () => {
3256
+ const root = linearRepo();
3257
+ const yamlPath = join(root, "docs", "roadmap", "roadmap.yaml");
3258
+ writeFileSync(yamlPath, readFileSync(yamlPath, "utf8").replace("title: Authentication", "title: Authentication\n theme: Own the login flow"), "utf8");
3259
+ const fake = fakeLinear();
3260
+ await runSync(root, { fetchImpl: fake.fetchImpl, env: { LINEAR_API_KEY: "k" }, now: "2026-07-06T12:00:00Z" });
3261
+ const create = fake.calls.find((c) => c.query.includes("projectCreate"));
3262
+ eq(create.variables.input.description, "Own the login flow", "description reaches the wire on create");
3263
+ eq(create.variables.input.teamIds, ["team-1"], "teamIds still attached");
3264
+ rmSync(root, { recursive: true, force: true });
3265
+ });
3266
+
3267
+ // WHY: live-caught — provision re-created the five views on every run (duplicate boards).
3268
+ // Existing view names must be skipped.
3269
+ test("runProvision skips views that already exist", async () => {
3270
+ const root = linearRepo();
3271
+ const fake = fakeLinear({ existingViews: STANDARD_VIEWS.map((v) => v.name) });
3272
+ const r = await runProvision(root, { fetchImpl: fake.fetchImpl, env: { LINEAR_API_KEY: "k" } });
3273
+ eq(r.views, [], "nothing created");
3274
+ eq(r.viewsExisting, STANDARD_VIEWS.map((v) => v.name), "all five recognized as present");
3275
+ ok(!fake.calls.some((c) => c.query.includes("customViewCreate")), "no create mutation fired");
3276
+ rmSync(root, { recursive: true, force: true });
3277
+ });
3278
+
3279
+ // ── cloud dispatch (v0.5 stub) ───────────────────────────────────────────────
3280
+ // WHY: dispatching an unmapped slice must push-map FIRST or the @-mention comment lands
3281
+ // nowhere; and the capsule must carry the machine footer any delegated agent parses.
3282
+ // WHY: the cycle lock is the "bars any other work" contract — out-of-cycle dispatch must refuse
3283
+ // with the elect-first path, --force must be the only bypass, the lock write must be atomic and
3284
+ // refuse nonsense transitions, and with cycles off nothing changes for existing users.
3285
+ test("cycle lock end-to-end: lock writes atomically with pre-checks; dispatch refuses out-of-cycle unless --force", async () => {
3286
+ const yaml = `meta:\n schema_version: 1\n program: T\n linear:\n team: ENG\n cycles: on\npis:\n - id: p\n title: P\n status: active\n sprints:\n - { id: s1, title: A, status: active, invoke: a }\n - { id: s2, title: B, status: next, invoke: b }\n - { id: s3, title: C, status: scheduled, invoke: c }\n - { id: s4, title: D, status: scheduled, invoke: d }\n`;
3287
+ const root = mkdtempSync(join(tmpdir(), "roadmap-cyclelock-"));
3288
+ mkdirSync(join(root, "docs", "roadmap"), { recursive: true });
3289
+ writeFileSync(join(root, "docs", "roadmap", "roadmap.yaml"), yaml, "utf8");
3290
+ // pre-checks refuse nonsense with actionable messages, and nothing is written
3291
+ throws(() => runCycleLock(root, { promote: ["a"] }), "can't promote");
3292
+ throws(() => runCycleLock(root, { demote: ["c"] }), "can't demote");
3293
+ throws(() => runCycleLock(root, { promote: ["ghost"] }), 'no slice "ghost"');
3294
+ throws(() => runCycleLock(root, {}), "needs --promote and/or --demote");
3295
+ // the real lock: one atomic write, both directions
3296
+ const r = runCycleLock(root, { promote: ["c"], demote: ["b"] });
3297
+ eq(r, { promoted: ["c"], demoted: ["b"] }, "lock reports both directions");
3298
+ const doc = parseDocument(readFileSync(join(root, "docs", "roadmap", "roadmap.yaml"), "utf8")).toJS();
3299
+ const st = Object.fromEntries(doc.pis[0].sprints.map((s) => [s.invoke, s.status]));
3300
+ eq([st.c, st.b], ["next", "scheduled"], "promotion and demotion landed in one write");
3301
+ // dispatch guard: still-scheduled d refuses with the elect-first path; --force bypasses;
3302
+ // the elected c (now next) passes without force
3303
+ const fake = fakeLinear();
3304
+ await runDispatch(root, "d", { to: "claude", fetchImpl: fake.fetchImpl, env: { LINEAR_API_KEY: "k" } }).then(
3305
+ () => { throw new Error("should have thrown"); },
3306
+ (e) => { ok(e.message.includes("out of the current cycle") && e.message.includes("roadmap cycle plan"), "refusal names the election path"); });
3307
+ const forced = await runDispatch(root, "d", { to: "claude", force: true, fetchImpl: fake.fetchImpl, env: { LINEAR_API_KEY: "k" } });
3308
+ eq(forced.dispatched, "d", "--force is the explicit escape hatch");
3309
+ const elected = await runDispatch(root, "c", { to: "claude", fetchImpl: fake.fetchImpl, env: { LINEAR_API_KEY: "k" } });
3310
+ eq(elected.dispatched, "c", "elected work dispatches without force");
3311
+ // ready_wave annotates instead of blocking (reads never block)
3312
+ const g2 = loadGraph(join(root, "docs", "roadmap", "roadmap.yaml"));
3313
+ const rw = readReadyWave(g2, { cap: 4 });
3314
+ const flags = Object.fromEntries(rw.wave.map((n) => [n.invoke, !!n.outOfCycle]));
3315
+ ok(flags.d === true, "out-of-cycle wave slice is annotated");
3316
+ ok(rw.wave.filter((n) => !n.outOfCycle).length >= 1, "in-cycle wave slices carry no flag");
3317
+ // the plan CLI reads the stale set from the sync cursor — zero network for the ritual
3318
+ writeFileSync(join(root, ".roadmap-linear-state.json"), JSON.stringify({ version: 1, stale: ["a"] }), "utf8");
3319
+ const cp = runCyclePlan(root, {});
3320
+ eq(cp.capacity, 10, "capacity defaults from meta.linear.cycle_capacity");
3321
+ ok(cp.elected.find((x) => x.invoke === "a").stale, "the cursor's stale set flags the elected list offline");
3322
+ rmSync(root, { recursive: true, force: true });
3323
+ });
3324
+
3325
+ // WHY: at wave scale the lock must FILTER (reported, never silent) rather than throw — a fanout
3326
+ // that dies on the first out-of-cycle slice strands the rest; all=true is the explicit include.
3327
+ test("runFanCloud excludes out-of-cycle slices from the wave, reports them, includes with all", async () => {
3328
+ const yaml = `meta:\n schema_version: 1\n program: T\n linear:\n team: ENG\n cycles: on\npis:\n - id: p\n title: P\n status: active\n sprints:\n - { id: s1, title: A, status: next, invoke: a }\n - { id: s2, title: C, status: scheduled, invoke: c }\n`;
3329
+ const root = mkdtempSync(join(tmpdir(), "roadmap-fancycle-"));
3330
+ mkdirSync(join(root, "docs", "roadmap"), { recursive: true });
3331
+ writeFileSync(join(root, "docs", "roadmap", "roadmap.yaml"), yaml, "utf8");
3332
+ const dispatch = { env: {}, profiles: { p: { account: "a@b.c", routines: { default: { trigger: "t", token: "k" } } } }, accountEmail: "a@b.c", repoSlug: null };
3333
+ const p = await runFanCloud(root, { dispatch });
3334
+ eq(p.slices.map((s) => s.invoke), ["a"], "only in-cycle work in the launch selection");
3335
+ ok(p.slices.every((s) => s.resolvable === true), "pre-flight resolves the untiered slice");
3336
+ eq(p.excludedOutOfCycle, ["c"], "exclusions reported, never silent");
3337
+ const all = await runFanCloud(root, { all: true, dispatch });
3338
+ eq(all.slices.map((s) => s.invoke).sort(), ["a", "c"], "all=true includes out-of-cycle explicitly");
3339
+ ok(!all.excludedOutOfCycle, "explicit include reports no exclusions");
3340
+ rmSync(root, { recursive: true, force: true });
3341
+ });
3342
+
3343
+ test("runDispatch push-maps an unmapped slice, then comments the capsule with the footer", async () => {
3344
+ const root = linearRepo();
3345
+ const fake = fakeLinear();
3346
+ const r = await runDispatch(root, "auth-login", { to: "claude", fetchImpl: fake.fetchImpl, env: { LINEAR_API_KEY: "k" } });
3347
+ eq(r.pushed, true, "pushed before commenting");
3348
+ eq(r.agent, "@Claude", "mention agent");
3349
+ const createIdx = fake.calls.findIndex((c) => c.query.includes("issueCreate"));
3350
+ const commentIdx = fake.calls.findIndex((c) => c.query.includes("commentCreate"));
3351
+ ok(createIdx >= 0 && commentIdx > createIdx, "issueCreate strictly before commentCreate");
3352
+ const body = fake.calls[commentIdx].variables.input.body;
3353
+ ok(body.startsWith("@Claude"), "@-mention leads the comment");
3354
+ ok(body.includes("roadmap: slice=auth-login"), "machine footer in the capsule");
3355
+ ok(body.includes("never merge"), "merge prohibition in the capsule");
3356
+ rmSync(root, { recursive: true, force: true });
3357
+ });
3358
+
3359
+ // WHY: the transport-verified gate is the whole safety story of the stub — a failure must
3360
+ // name what succeeded and what didn't, so verification day knows exactly where it broke.
3361
+ test("runDispatch failure names both stages when commentCreate is rejected", async () => {
3362
+ const root = linearRepo();
3363
+ const fake = fakeLinear({ failOn: "commentCreate" });
3364
+ await runDispatch(root, "auth-login", { to: "claude", fetchImpl: fake.fetchImpl, env: { LINEAR_API_KEY: "k" } }).then(
3365
+ () => { throw new Error("should have thrown"); },
3366
+ (e) => {
3367
+ ok(e.message.includes("push-map (pushed"), "names the stage that worked");
3368
+ ok(e.message.includes("commentCreate") && e.message.includes("simulated comment failure"), "names the stage that failed");
3369
+ ok(e.message.includes("not attempted (unverified)"), "delegate-field honesty");
3370
+ });
3371
+ rmSync(root, { recursive: true, force: true });
3372
+ });
3373
+
3374
+ // ── cloud PR matcher ─────────────────────────────────────────────────────────
3375
+ // WHY: cloud sessions push unpredictable claude/-prefixed branches, so branch matching
3376
+ // misses every cloud PR and merged cloud work silently stays "open" on the roadmap. The
3377
+ // marker line in the PR description is the deterministic hook we control end-to-end.
3378
+ test("findUnrecordedMerges layer 2: cloud PRs match by the roadmap marker in title/body", () => {
3379
+ const g = { meta: {}, pis: [{ id: "a", title: "A", status: "active", sprints: [
3380
+ sp("s1", { status: "active", invoke: "auth-login" }),
3381
+ sp("s2", { status: "active", invoke: "auth-tokens" }),
3382
+ sp("s3", { status: "complete", invoke: "auth-done" }),
3383
+ ]}]};
3384
+ const found = findUnrecordedMerges(g, [
3385
+ { number: 7, headRefName: "a/s1" }, // layer 1: convention branch
3386
+ { number: 8, headRefName: "claude/fix-tokens-x9", body: "…\nroadmap: slice=auth-tokens\n…" }, // layer 2: cloud marker
3387
+ { number: 9, headRefName: "claude/old-work", body: "roadmap: slice=auth-done" }, // done slice → ignored
3388
+ { number: 10, headRefName: "claude/unrelated", body: "roadmap: slice=nonexistent" }, // unknown key → ignored
3389
+ ]);
3390
+ eq(found.map((f) => [f.invoke, f.pr]), [["auth-login", 7], ["auth-tokens", 8]], "both layers matched, done + unknown ignored");
3391
+ ok(found[1].branch.includes("cloud"), "cloud matches are labeled");
3392
+ // a slice matched by BOTH layers reports once (branch wins)
3393
+ const both = findUnrecordedMerges(g, [
3394
+ { number: 11, headRefName: "a/s1", body: "roadmap: slice=auth-login" },
3395
+ ]);
3396
+ eq(both.length, 1, "no double report");
3397
+ });
3398
+
3399
+ // WHY: the marker only works if every PR-producing surface plants it — brief (local
3400
+ // fanout), dispatch capsules (cloud + mention), and the repo guidance must all carry it.
3401
+ test("every PR-producing surface plants the reconcile marker instruction", () => {
3402
+ const g = { meta: { schema_version: 1, program: "T", default_gate: "npm test" },
3403
+ pis: [{ id: "a", title: "A", status: "active", sprints: [sp("s1", { status: "active", invoke: "x" })] }] };
3404
+ ok(synthesizeBrief(flatten(g).nodes[0], g).includes("roadmap: slice=x"), "kickoff brief carries the marker");
3405
+ ok(dispatchGuidance().includes("roadmap: slice=<key>"), "repo dispatch guidance carries it");
3406
+ });
3407
+
3408
+ // ── claude-cloud transport ────────────────────────────────────────────────────
3409
+ const PROFILES = {
3410
+ connor: { account: "connor@x.com", routines: {
3411
+ default: { trigger: "trig_c_def", token: "tok_c_def" },
3412
+ "acme/app": { trigger: "trig_c_app", token: "tok_c_app" },
3413
+ "acme/app#fable": { trigger: "trig_c_fable", token: "tok_c_fable" },
3414
+ "default#fable": { trigger: "trig_c_fable_def", token: "tok_c_fable_def" },
3415
+ }},
3416
+ alex: { account: "alex@y.com", routines: { default: { trigger: "trig_a", token: "tok_a" } } },
3417
+ };
3418
+
3419
+ // WHY: the whole multi-account promise is "fires on whoever is authed" — a wrong precedence
3420
+ // order silently burns the other person's usage limits. Env override > explicit pin >
3421
+ // authed-account match; repo-bound routine > default; every miss is an actionable error.
3422
+ test("resolveRoutine precedence: env > profile pin > authed account; repo routine > default", () => {
3423
+ eq(resolveRoutine({ env: { CLAUDE_ROUTINE_TRIGGER: "t", CLAUDE_ROUTINE_TOKEN: "k" }, profiles: PROFILES }).source, "env", "env pair wins outright");
3424
+ const pinned = resolveRoutine({ env: { CLAUDE_ROUTINE_PROFILE: "alex" }, profiles: PROFILES, accountEmail: "connor@x.com" });
3425
+ eq(pinned.trigger, "trig_a", "explicit pin beats the authed account");
3426
+ const hot = resolveRoutine({ profiles: PROFILES, accountEmail: "CONNOR@X.COM", repoSlug: "acme/app" });
3427
+ eq(hot.trigger, "trig_c_app", "authed-account match (case-insensitive) + repo-bound routine");
3428
+ eq(hot.account, "connor@x.com", "resolved identity surfaced");
3429
+ eq(resolveRoutine({ profiles: PROFILES, accountEmail: "connor@x.com", repoSlug: "other/repo" }).trigger, "trig_c_def", "unknown repo falls back to default");
3430
+ throws(() => resolveRoutine({ profiles: PROFILES, accountEmail: "nobody@z.com" }), "no routines profile matches", "unmatched account is actionable");
3431
+ throws(() => resolveRoutine({ env: { CLAUDE_ROUTINE_PROFILE: "ghost" }, profiles: PROFILES }), 'not in the routines file', "bad pin is actionable");
3432
+ throws(() => resolveRoutine({ profiles: null }), "no claude-cloud routine configured", "nothing configured is actionable");
3433
+ throws(() => resolveRoutine({ profiles: { p: { account: "a@b.c", routines: {} } }, accountEmail: "a@b.c", repoSlug: "x/y" }), "no routine for x/y", "empty profile is actionable");
3434
+ });
3435
+
3436
+ // WHY: a slice marked dispatch_tier gets frontier intelligence or it gets NOTHING — a silent
3437
+ // fallback to the standard routine would run a judgment-heavy slice on the wrong model and
3438
+ // report success, which is worse than failing loudly.
3439
+ test("resolveRoutine tier: repo#tier > default#tier; a missing tier throws instead of downgrading", () => {
3440
+ const t = resolveRoutine({ profiles: PROFILES, accountEmail: "connor@x.com", repoSlug: "acme/app", tier: "fable" });
3441
+ eq(t.trigger, "trig_c_fable", "repo-bound tier routine wins");
3442
+ eq(t.source, "profile:connor:acme/app#fable", "source names the tier for the dispatch log");
3443
+ eq(resolveRoutine({ profiles: PROFILES, accountEmail: "connor@x.com", repoSlug: "other/repo", tier: "fable" }).trigger,
3444
+ "trig_c_fable_def", "unknown repo falls back to default#tier, never to the untiered routine");
3445
+ throws(() => resolveRoutine({ profiles: { p: { account: "a@b.c", routines: { default: { trigger: "t", token: "k" } } } }, accountEmail: "a@b.c", repoSlug: "x/y", tier: "fable" }),
3446
+ 'no "fable"-tier routine', "unconfigured tier throws (no silent downgrade to default)");
3447
+ eq(resolveRoutine({ env: { CLAUDE_ROUTINE_TRIGGER: "t", CLAUDE_ROUTINE_TOKEN: "k" }, profiles: PROFILES, tier: "fable" }).source,
3448
+ "env", "explicit env pair still wins outright (CI/manual override)");
3449
+ });
3450
+
3451
+ // WHY: claude-cloud is the Linear-FREE transport — it must dispatch from a repo with no
3452
+ // meta.linear at all, hit the beta endpoint with the exact headers, and carry the capsule.
3453
+ test("runDispatch --to claude-cloud fires the routine without any Linear config", async () => {
3454
+ const root = tempRepo(); // fixture has NO meta.linear
3455
+ const fires = [];
3456
+ const fakeFetch = async (url, init) => {
3457
+ fires.push({ url, init });
3458
+ return { ok: true, json: async () => ({ claude_code_session_id: "sess_1", claude_code_session_url: "https://claude.ai/code/sess_1" }) };
3459
+ };
3460
+ const r = await runDispatch(root, "taken", {
3461
+ to: "claude-cloud", fetchImpl: fakeFetch, env: {},
3462
+ profiles: PROFILES, accountEmail: "connor@x.com", repoSlug: "other/repo",
3463
+ });
3464
+ eq(r.transport, "claude-cloud", "cloud transport");
3465
+ eq(r.sessionUrl, "https://claude.ai/code/sess_1", "session url returned");
3466
+ eq(fires.length, 1, "exactly one network call — no Linear traffic");
3467
+ ok(fires[0].url.includes("/claude_code/routines/trig_c_def/fire"), "fires the resolved trigger");
3468
+ eq(fires[0].init.headers["anthropic-beta"], "experimental-cc-routine-2026-04-01", "beta header");
3469
+ eq(fires[0].init.headers.Authorization, "Bearer tok_c_def", "bearer token");
3470
+ const body = JSON.parse(fires[0].init.body);
3471
+ ok(body.text.includes("roadmap: slice=taken") && body.text.includes("NEVER merge"), "capsule carries footer + contract");
3472
+ rmSync(root, { recursive: true, force: true });
3473
+ });
3474
+
3475
+ // WHY: the API-trigger modal shows a URL, never a labeled trigger id — users will paste
3476
+ // whatever they copied. Both forms must resolve to the same /fire endpoint.
3477
+ test("routineEndpoint accepts a bare trig id or the full modal URL", () => {
3478
+ eq(routineEndpoint("trig_01ABC"), "https://api.anthropic.com/v1/claude_code/routines/trig_01ABC/fire", "bare id");
3479
+ eq(routineEndpoint("https://api.anthropic.com/v1/claude_code/routines/trig_01ABC/fire"), "https://api.anthropic.com/v1/claude_code/routines/trig_01ABC/fire", "full fire URL verbatim");
3480
+ eq(routineEndpoint("https://api.anthropic.com/v1/claude_code/routines/trig_01ABC/"), "https://api.anthropic.com/v1/claude_code/routines/trig_01ABC/fire", "URL without /fire gains it");
3481
+ });
3482
+
3483
+ // WHY: a failed fire must be actionable (beta API — 401/404 have specific meanings), and a
3484
+ // mapped-issue dispatch should link the session on the board WITHOUT depending on it.
3485
+ test("fireRoutine errors are actionable; a mapped dispatch comments the session link best-effort", async () => {
3486
+ await fireRoutine({ trigger: "t", token: "k" }, "x", async () => ({ ok: false, status: 401 })).then(
3487
+ () => { throw new Error("should have thrown"); },
3488
+ (e) => ok(e.message.includes("401") && e.message.includes("token invalid"), "401 names the fix"));
3489
+ const root = linearRepo(); // Linear-configured fixture
3490
+ const yamlPath = join(root, "docs", "roadmap", "roadmap.yaml");
3491
+ writeFileSync(yamlPath, readFileSync(yamlPath, "utf8").replace("invoke: auth-login }", "invoke: auth-login, linear: ENG-1 }"), "utf8");
3492
+ const linearFake = fakeLinear({ snapshot: { "ENG-1": { id: "uuid-1", identifier: "ENG-1", title: "Login", description: "", priority: 0, state: { id: "st-s" }, labels: { nodes: [] } } } });
3493
+ const routedFetch = async (url, init) => url.includes("anthropic.com")
3494
+ ? { ok: true, json: async () => ({ claude_code_session_id: "s2", claude_code_session_url: "https://claude.ai/code/s2" }) }
3495
+ : linearFake.fetchImpl(url, init);
3496
+ const r = await runDispatch(root, "auth-login", {
3497
+ to: "claude-cloud", fetchImpl: routedFetch, env: { LINEAR_API_KEY: "k" },
3498
+ profiles: PROFILES, accountEmail: "alex@y.com", repoSlug: null,
3499
+ });
3500
+ eq(r.linearComment, "ENG-1", "session link commented on the mapped issue");
3501
+ const comment = linearFake.calls.find((c) => c.query.includes("commentCreate"));
3502
+ ok(comment.variables.input.body.includes("https://claude.ai/code/s2"), "comment carries the session url");
3503
+ rmSync(root, { recursive: true, force: true });
3504
+ });
3505
+
3506
+ // ── cloud fanout (the conductor pattern) ─────────────────────────────────────
3507
+ // WHY: fan_cloud spends plan usage and opens real PRs — firing on the DEFAULT call instead
3508
+ // of previewing would surprise an orchestrating session into an unintended cloud wave.
3509
+ test("runFanCloud previews by default (fires nothing), fires only on confirm", async () => {
3510
+ const root = mkdtempSync(join(tmpdir(), "roadmap-fancloud-test-"));
3511
+ mkdirSync(join(root, "docs", "roadmap"), { recursive: true });
3512
+ writeFileSync(join(root, "docs", "roadmap", "roadmap.yaml"),
3513
+ `meta:\n schema_version: 1\n program: T\npis:\n - id: a\n title: A\n status: active\n sprints:\n - { id: s1, title: One, status: next, invoke: one, touches: [f1] }\n - { id: s2, title: Two, status: next, invoke: two, touches: [f2] }\n`, "utf8");
3514
+ const fires = [];
3515
+ const fakeFetch = async (url) => { fires.push(url); return { ok: true, json: async () => ({ claude_code_session_id: `s${fires.length}`, claude_code_session_url: `https://claude.ai/code/s${fires.length}` }) }; };
3516
+ const dispatch = { fetchImpl: fakeFetch, env: {}, profiles: { p: { account: "a@b.c", routines: { default: { trigger: "trig_x", token: "tok_x" } } } }, accountEmail: "a@b.c", repoSlug: null };
3517
+
3518
+ const preview = await runFanCloud(root, { wave: 1, dispatch });
3519
+ eq(preview.preview, true, "default = preview");
3520
+ eq(preview.slices.map((s) => s.invoke).sort(), ["one", "two"], "the ready wave is listed");
3521
+ ok(preview.slices.every((s) => s.resolvable === true && s.tier === null), "pre-flight: untiered slices resolve against the fixture profile");
3522
+ eq(fires.length, 0, "preview fires NOTHING");
3523
+
3524
+ const fired = await runFanCloud(root, { wave: 1, confirm: true, dispatch });
3525
+ eq(fired.fired, 2, "both slices fired");
3526
+ eq(fired.of, 2, "of the two in the wave");
3527
+ ok(fired.results.every((r) => r.ok && r.sessionUrl), "each result carries a session url");
3528
+ eq(fires.filter((u) => u.includes("anthropic.com")).length, 2, "two routine fires, one per slice");
3529
+ rmSync(root, { recursive: true, force: true });
3530
+ });
3531
+
3532
+ // WHY: a wave mixing tiers must show, BEFORE confirm, which slices cannot resolve their
3533
+ // routine — otherwise a frontier wave burns real launches discovering config gaps mid-fire,
3534
+ // and the throw-on-missing tier semantics would sink slices one by one after money is spent.
3535
+ test("runFanCloud preview pre-flights tiers: unresolvable tier flagged with its routine key, nothing fired", async () => {
3536
+ const root = mkdtempSync(join(tmpdir(), "roadmap-fantier-"));
3537
+ mkdirSync(join(root, "docs", "roadmap"), { recursive: true });
3538
+ writeFileSync(join(root, "docs", "roadmap", "roadmap.yaml"),
3539
+ `meta:\n schema_version: 1\n program: T\npis:\n - id: a\n title: A\n status: active\n sprints:\n - { id: s1, title: One, status: next, invoke: plain, touches: [f1] }\n - { id: s2, title: Two, status: next, invoke: ftier, dispatch_tier: fable, touches: [f2] }\n`, "utf8");
3540
+ const fires = [];
3541
+ const fakeFetch = async (url) => { fires.push(url); return { ok: true, json: async () => ({ claude_code_session_id: "s", claude_code_session_url: "u" }) }; };
3542
+ const noFable = { p: { account: "a@b.c", routines: { default: { trigger: "t", token: "k" } } } };
3543
+ const p = await runFanCloud(root, { dispatch: { fetchImpl: fakeFetch, env: {}, profiles: noFable, accountEmail: "a@b.c", repoSlug: null } });
3544
+ const fab = p.slices.find((s) => s.invoke === "ftier");
3545
+ eq(fab.tier, "fable", "the slice's declared tier is surfaced");
3546
+ eq(fab.resolvable, false, "missing tier routine → resolvable false, not a throw");
3547
+ ok(fab.error.includes("default#fable"), "the error names the routine key it looked for");
3548
+ eq(p.slices.find((s) => s.invoke === "plain").resolvable, true, "the untiered slice still resolves");
3549
+ ok(p.note.includes("CANNOT resolve"), "the note flags the unresolvable slice");
3550
+ eq(fires.length, 0, "pre-flight is read-only — nothing fired");
3551
+ const withFable = { p: { account: "a@b.c", routines: { default: { trigger: "t", token: "k" }, "default#fable": { trigger: "tf", token: "kf" } } } };
3552
+ const p2 = await runFanCloud(root, { dispatch: { env: {}, profiles: withFable, accountEmail: "a@b.c", repoSlug: null } });
3553
+ ok(p2.slices.every((s) => s.resolvable === true), "fully-configured profile → every slice resolvable");
3554
+ rmSync(root, { recursive: true, force: true });
3555
+ });
3556
+
3557
+ // WHY: the YAML dispatch_tier field is the DURABLE routing (--tier is per-launch) — this is
3558
+ // the end-to-end proof the field reaches the routine choice. It was silently broken at
3559
+ // landing: flatten never mapped dispatch_tier, so the node read undefined forever and every
3560
+ // "fable" slice would have fired on the standard routine without a word.
3561
+ test("slice dispatch_tier in roadmap.yaml routes the dispatch to the tier routine end-to-end", async () => {
3562
+ const root = mkdtempSync(join(tmpdir(), "roadmap-tierroute-"));
3563
+ mkdirSync(join(root, "docs", "roadmap"), { recursive: true });
3564
+ writeFileSync(join(root, "docs", "roadmap", "roadmap.yaml"),
3565
+ `meta:\n schema_version: 1\n program: T\npis:\n - id: a\n title: A\n status: active\n sprints:\n - { id: s1, title: F, status: next, invoke: ftier, dispatch_tier: fable }\n`, "utf8");
3566
+ const fires = [];
3567
+ const fakeFetch = async (url) => { fires.push(url); return { ok: true, json: async () => ({ claude_code_session_id: "s", claude_code_session_url: "u" }) }; };
3568
+ const profiles = { p: { account: "a@b.c", routines: { default: { trigger: "trig_std", token: "k" }, "default#fable": { trigger: "trig_fable", token: "kf" } } } };
3569
+ const r = await runDispatch(root, "ftier", { fetchImpl: fakeFetch, env: {}, profiles, accountEmail: "a@b.c", repoSlug: null });
3570
+ ok(fires[0].includes("trig_fable"), "fired the fable routine, not the standard one");
3571
+ ok(r.routine.includes("#fable"), "the result names the tier source for the dispatch log");
3572
+ rmSync(root, { recursive: true, force: true });
3573
+ });
3574
+
3575
+ // WHY: backlog items are dispatchable work too — dispatch_tier must survive add → validate →
3576
+ // dispatch, or tier routing silently applies to slices only and a frontier backlog item runs cheap.
3577
+ test("backlog item dispatch_tier round-trips add → validate → tier resolution", async () => {
3578
+ const root = mkdtempSync(join(tmpdir(), "roadmap-tieritem-"));
3579
+ mkdirSync(join(root, "docs", "roadmap"), { recursive: true });
3580
+ writeFileSync(join(root, "docs", "roadmap", "roadmap.yaml"), `meta:\n schema_version: 1\n program: T\npis: []\n`, "utf8");
3581
+ const bdoc = parseDocument(`meta:\n schema_version: 1\nitems: []\n`);
3582
+ addItem(bdoc, { title: "Frontier item", kind: "chore", dispatch_tier: "fable" });
3583
+ const js = bdoc.toJS();
3584
+ eq(js.items[0].dispatch_tier, "fable", "add persists the tier");
3585
+ eq(validateBacklog(js).errors, [], "schema-shaped backlog with dispatch_tier validates clean");
3586
+ writeFileSync(join(root, "docs", "roadmap", "backlog.yaml"), String(bdoc), "utf8");
3587
+ const fires = [];
3588
+ const fakeFetch = async (url) => { fires.push(url); return { ok: true, json: async () => ({ claude_code_session_id: "s", claude_code_session_url: "u" }) }; };
3589
+ const profiles = { p: { account: "a@b.c", routines: { default: { trigger: "trig_std", token: "k" }, "default#fable": { trigger: "trig_fable", token: "kf" } } } };
3590
+ await runDispatch(root, js.items[0].id, { fetchImpl: fakeFetch, env: {}, profiles, accountEmail: "a@b.c", repoSlug: null });
3591
+ ok(fires[0].includes("trig_fable"), "the item's tier routed to the fable routine");
3592
+ rmSync(root, { recursive: true, force: true });
3593
+ });
3594
+
3595
+ // WHY: the tier must be VISIBLE where humans read the board file — and slices WITHOUT it
3596
+ // must render byte-identically, or landing the feature churns every roadmap in every repo.
3597
+ test("SLICES.md renders a dispatch badge only for tiered slices", () => {
3598
+ const graph = { meta: { schema_version: 1, program: "T" }, pis: [{ id: "a", title: "A", status: "active", sprints: [
3599
+ { id: "s1", title: "One", status: "next", invoke: "plain" },
3600
+ { id: "s2", title: "Two", status: "next", invoke: "ftier", dispatch_tier: "fable" },
3601
+ ] }] };
3602
+ const md = renderMarkdown(graph);
3603
+ const rows = md.split("\n").filter((l) => l.startsWith("|") && l.includes("/slice "));
3604
+ ok(rows.find((l) => l.includes("ftier")).includes("`dispatch: fable`"), "tiered slice carries the compact badge");
3605
+ ok(!rows.find((l) => l.includes("plain")).includes("dispatch:"), "untiered slice renders with no badge (byte-stable)");
3606
+ });
3607
+
3608
+ // WHY: one slice failing mid-wave (bad routine, transport error) must not sink the rest —
3609
+ // the conductor needs a per-slice ledger, not an all-or-nothing throw.
3610
+ test("runFanCloud isolates a per-slice failure and reports it", async () => {
3611
+ const root = mkdtempSync(join(tmpdir(), "roadmap-fancloud-fail-"));
3612
+ mkdirSync(join(root, "docs", "roadmap"), { recursive: true });
3613
+ writeFileSync(join(root, "docs", "roadmap", "roadmap.yaml"),
3614
+ `meta:\n schema_version: 1\n program: T\npis:\n - id: a\n title: A\n status: active\n sprints:\n - { id: s1, title: One, status: next, invoke: one, touches: [f1] }\n - { id: s2, title: Two, status: next, invoke: two, touches: [f2] }\n`, "utf8");
3615
+ let n = 0;
3616
+ const fakeFetch = async () => { n += 1; if (n === 1) return { ok: false, status: 401 }; return { ok: true, json: async () => ({ claude_code_session_id: "s", claude_code_session_url: "https://claude.ai/code/s" }) }; };
3617
+ const dispatch = { fetchImpl: fakeFetch, env: {}, profiles: { p: { account: "a@b.c", routines: { default: { trigger: "t", token: "k" } } } }, accountEmail: "a@b.c", repoSlug: null };
3618
+ const r = await runFanCloud(root, { confirm: true, dispatch });
3619
+ eq(r.fired, 1, "the healthy slice still fired");
3620
+ eq(r.results.filter((x) => !x.ok).length, 1, "the failed slice is recorded, not thrown");
3621
+ ok(r.results.find((x) => !x.ok).error.includes("401"), "failure reason captured");
3622
+ rmSync(root, { recursive: true, force: true });
3623
+ });
3624
+
3625
+ // ── agent-time estimation bridge (Phase 1) ───────────────────────────────────
3626
+ // A fake estimator stdout: the markdown block `estimate --json` prints, then the pretty JSON
3627
+ // record LAST — exactly what parseEstimateRecord must dig the trailing object out of.
3628
+ const fakeEstimatorOut = (taskId, mins) => [
3629
+ "## Agent Time Estimate", "", `Task id: \`${taskId}\``, "Task shape: localized-feature",
3630
+ "Wall-clock estimate:", `* Expected: ${mins.expected} min`, "", "",
3631
+ JSON.stringify({ type: "estimate", task_id: taskId, ts: "2026-07-08T00:00:00+00:00",
3632
+ summary: "x", shape: "localized-feature", est_minutes: mins,
3633
+ calibration_basis: "static prior · widened", confidence: "low-medium" }, null, 2),
3634
+ ].join("\n");
3635
+
3636
+ // WHY: the estimator call is the whole bridge — a wrong argv (missing --json, dropped risks, or
3637
+ // estimating an unclassified slice) means a wrong or absent number silently feeds the timeline.
3638
+ test("estimateArgs: fresh estimate call with summary/shape/risks/model; requires a shape", () => {
3639
+ const cfg = estimationConfig({});
3640
+ eq(estimateArgs({ invoke: "b", title: "Build", shape: "localized-feature", risks: ["external-api", "slow-flaky-tests"] }, cfg),
3641
+ ["estimate", "--summary", "Build", "--shape", "localized-feature", "--json", "--risks", "external-api,slow-flaky-tests", "--model", "opus-4.8"], "risks joined, model appended");
3642
+ eq(estimateArgs({ invoke: "b", title: "Build", shape: "refactor" }, cfg),
3643
+ ["estimate", "--summary", "Build", "--shape", "refactor", "--json", "--model", "opus-4.8"], "no risks → no --risks flag");
3644
+ throws(() => estimateArgs({ invoke: "x" }, cfg), "has no shape", "an unclassified slice can't be estimated");
3645
+ });
3646
+
3647
+ // WHY: agent-time prints prose THEN the JSON record; a parser that grabs the wrong block (or misses it)
3648
+ // caches garbage or nothing. The trailing-object rule is the contract between the two tools.
3649
+ test("parseEstimateRecord: pulls the trailing JSON past the markdown block", () => {
3650
+ const rec = parseEstimateRecord(fakeEstimatorOut("t-9", { low: 1, expected: 2, high: 3 }));
3651
+ eq(rec.task_id, "t-9", "record recovered");
3652
+ eq(rec.est_minutes, { low: 1, expected: 2, high: 3 }, "est_minutes recovered");
3653
+ throws(() => parseEstimateRecord("just prose, no record"), "no JSON record", "missing record errors, not silently empty");
3654
+ // Trust boundary: a status-0 response with non-numeric minutes must be rejected, not cached as a bogus estimate.
3655
+ const junk = JSON.stringify({ type: "estimate", task_id: "t-x", ts: "2026-07-08T00:00:00+00:00", est_minutes: {} });
3656
+ throws(() => parseEstimateRecord(junk), "non-numeric est_minutes", "empty est_minutes rejected");
3657
+ });
3658
+
3659
+ // WHY: the cached block is what the timeline reads and what the calibration loop keys on — dropping
3660
+ // task_id would sever the loop; mis-mapping minutes would mis-date the roadmap.
3661
+ test("applyEstimate: maps the estimator record to the compact cached block", () => {
3662
+ eq(applyEstimate({ est_minutes: { low: 1, expected: 2, high: 3 }, confidence: "low-medium", task_id: "t-1", ts: "2026-07-08T00:00:00+00:00", calibration_basis: "static · widened" }),
3663
+ { minutes: { low: 1, expected: 2, high: 3 }, confidence: "low-medium", task_id: "t-1", at: "2026-07-08T00:00:00+00:00", basis: "static · widened" }, "ts→at, calibration_basis→basis");
3664
+ });
3665
+
3666
+ // WHY: the config drives the calendar math (hours_per_day, point) and the engine call (python, model);
3667
+ // a bad override silently falling through to a wrong value would skew every projected date.
3668
+ test("estimationConfig: defaults, and meta.estimation overrides (bad values fall back)", () => {
3669
+ eq(estimationConfig({}), { engine: null, python: "python3", hours_per_day: 6, point: "expected", model: "opus-4.8" }, "absent → defaults");
3670
+ eq(estimationConfig({ estimation: { engine: "/e.py", python: "python", hours_per_day: 8, point: "high", model: "sonnet-5" } }),
3671
+ { engine: "/e.py", python: "python", hours_per_day: 8, point: "high", model: "sonnet-5" }, "overrides applied");
3672
+ eq(estimationConfig({ estimation: { point: "bogus", hours_per_day: -1 } }).point, "expected", "bad point → default");
3673
+ eq(estimationConfig({ estimation: { hours_per_day: -1 } }).hours_per_day, 6, "non-positive hours_per_day → default");
3674
+ });
3675
+
3676
+ // WHY: a typo in the config or a hand-mangled estimate field must surface at validate, not corrupt a
3677
+ // sync or a rollup downstream where the failure is opaque.
3678
+ test("validateEstimation: flags a malformed config + bad risks; a clean config passes", () => {
3679
+ const bad = validateEstimation({ meta: { estimation: { hours_per_day: 0, point: "soon" } }, pis: [
3680
+ { id: "p", sprints: [{ id: "s1", invoke: "x", risks: "external-api" }] }] });
3681
+ ok(bad.errors.some((e) => e.includes("hours_per_day must be a number > 0")), "bad hours_per_day errors");
3682
+ ok(bad.errors.some((e) => e.includes('point must be "expected" or "high"')), "bad point errors");
3683
+ ok(bad.errors.some((e) => e.includes("risks must be an array of strings")), "string risks errors");
3684
+ const good = validateEstimation({ meta: { estimation: { point: "high", hours_per_day: 5 } }, pis: [
3685
+ { id: "p", sprints: [{ id: "s1", invoke: "x", shape: "refactor", risks: ["external-api"] }] }] });
3686
+ eq(good.errors, [], "clean config + fields → no errors");
3687
+ });
3688
+
3689
+ // WHY: the engine must resolve deterministically (explicit > env > skill) and fail LOUD when absent —
3690
+ // a silent miss would leave slices unestimated and the timeline quietly hollow.
3691
+ test("resolveEngine: explicit engine wins over env; throws with guidance when none exist", () => {
3692
+ eq(resolveEngine({ engine: "/x/estimator.py" }, { AGENT_TIME_ENGINE: "/e.py" }, (p) => p === "/x/estimator.py"), "/x/estimator.py", "explicit engine preferred");
3693
+ eq(resolveEngine({ engine: null }, { AGENT_TIME_ENGINE: "/e.py" }, (p) => p === "/e.py"), "/e.py", "env fallback");
3694
+ throws(() => resolveEngine({ engine: null }, {}, () => false), "estimator not found", "none present → actionable error");
3695
+ });
3696
+
3697
+ // WHY: this is the write-back the whole feature hangs on — the estimate must land on the slice (with the
3698
+ // task_id the loop needs), estimating must be idempotent (no re-logging on every run), and --force must refresh.
3699
+ test("runEstimate caches est_minutes + task_id, skips already-estimated, --force refreshes", () => {
3700
+ const root = mkdtempSync(join(tmpdir(), "roadmap-estimate-"));
3701
+ mkdirSync(join(root, "docs", "roadmap"), { recursive: true });
3702
+ writeFileSync(join(root, "docs", "roadmap", "roadmap.yaml"),
3703
+ `meta:\n schema_version: 1\n program: T\npis:\n - id: a\n title: A\n status: active\n sprints:\n - { id: s1, title: Build the thing, status: next, invoke: build, shape: localized-feature, risks: [external-api] }\n - { id: s2, title: Untyped, status: next, invoke: untyped }\n`, "utf8");
3704
+ const calls = [];
3705
+ let tid = 0;
3706
+ const runEstimator = (args, o) => { calls.push({ args, cwd: o.cwd }); tid += 1; return { status: 0, stdout: fakeEstimatorOut(`t-${tid}`, { low: 19, expected: 45, high: 93 }), stderr: "" }; };
3707
+
3708
+ const r1 = runEstimate(root, { all: true, runEstimator });
3709
+ eq(r1.estimated.map((e) => e.invoke), ["build"], "only the classified slice is a candidate under --all");
3710
+ ok(calls[0].args.includes("localized-feature") && calls[0].args.join(" ").includes("--risks external-api"), "shape + risks reach the estimator");
3711
+ const sp1 = parseDocument(readFileSync(join(root, "docs", "roadmap", "roadmap.yaml"), "utf8")).toJS().pis[0].sprints[0];
3712
+ eq(sp1.estimate.minutes, { low: 19, expected: 45, high: 93 }, "est_minutes cached on the slice");
3713
+ eq(sp1.estimate.task_id, "t-1", "task_id cached for the calibration loop");
3714
+
3715
+ const r2 = runEstimate(root, { invoke: "build", runEstimator });
3716
+ eq(r2.estimated.length, 0, "already-estimated slice isn't re-logged");
3717
+ ok(r2.skipped.some((s) => s.invoke === "build"), "skip recorded (idempotent)");
3718
+
3719
+ const before = tid;
3720
+ const r3 = runEstimate(root, { invoke: "build", force: true, runEstimator });
3721
+ eq(r3.estimated.length, 1, "force re-estimates");
3722
+ ok(tid > before, "a fresh estimator call was made");
3723
+ const sp1b = parseDocument(readFileSync(join(root, "docs", "roadmap", "roadmap.yaml"), "utf8")).toJS().pis[0].sprints[0];
3724
+ eq(sp1b.estimate.task_id, `t-${tid}`, "force refreshed the cached estimate + task_id");
3725
+
3726
+ const rU = runEstimate(root, { invoke: "untyped", runEstimator });
3727
+ ok(rU.skipped.some((s) => s.invoke === "untyped" && s.why.includes("no shape")), "an explicitly-named unclassified slice is skipped, not estimated");
3728
+ rmSync(root, { recursive: true, force: true });
3729
+ });
3730
+
3731
+ // WHY: one bad estimator exit must not corrupt the YAML or sink the command — the failure is recorded,
3732
+ // nothing is written, and it never throws through a sync/MCP call.
3733
+ test("runEstimate isolates an estimator failure: records it, writes nothing, never throws", () => {
3734
+ const root = mkdtempSync(join(tmpdir(), "roadmap-estimate-fail-"));
3735
+ mkdirSync(join(root, "docs", "roadmap"), { recursive: true });
3736
+ writeFileSync(join(root, "docs", "roadmap", "roadmap.yaml"),
3737
+ `meta:\n schema_version: 1\n program: T\npis:\n - id: a\n title: A\n status: active\n sprints:\n - { id: s1, title: Build, status: next, invoke: build, shape: refactor }\n`, "utf8");
3738
+ const runEstimator = () => ({ status: 2, stdout: "", stderr: "estimator boom" });
3739
+ const r = runEstimate(root, { all: true, runEstimator });
3740
+ eq(r.estimated.length, 0, "nothing estimated");
3741
+ ok(r.errors.some((x) => x.invoke === "build" && x.error.includes("boom")), "failure reason captured");
3742
+ const sp = parseDocument(readFileSync(join(root, "docs", "roadmap", "roadmap.yaml"), "utf8")).toJS().pis[0].sprints[0];
3743
+ ok(!sp.estimate, "no estimate written on failure");
3744
+ rmSync(root, { recursive: true, force: true });
3745
+ });
3746
+
3747
+ // WHY: in a batch, one slice's estimator failure must not deny the healthy slices their estimates —
3748
+ // the successes must still be written while the failure is isolated (independent success/error ledgers).
3749
+ test("runEstimate writes the successful slices in a mixed batch, isolates only the failure", () => {
3750
+ const root = mkdtempSync(join(tmpdir(), "roadmap-estimate-mixed-"));
3751
+ mkdirSync(join(root, "docs", "roadmap"), { recursive: true });
3752
+ writeFileSync(join(root, "docs", "roadmap", "roadmap.yaml"),
3753
+ `meta:\n schema_version: 1\n program: T\npis:\n - id: a\n title: A\n status: active\n sprints:\n - { id: s1, title: Good, status: next, invoke: good, shape: localized-feature }\n - { id: s2, title: Bad, status: next, invoke: bad, shape: refactor }\n`, "utf8");
3754
+ // Fail only the "bad" slice (keyed off its --summary), succeed the rest.
3755
+ const runEstimator = (args) => args.includes("Bad")
3756
+ ? { status: 2, stdout: "", stderr: "estimator boom" }
3757
+ : { status: 0, stdout: fakeEstimatorOut("t-good", { low: 5, expected: 12, high: 30 }), stderr: "" };
3758
+ const r = runEstimate(root, { all: true, runEstimator });
3759
+ eq(r.estimated.map((e) => e.invoke), ["good"], "the healthy slice is estimated");
3760
+ ok(r.errors.some((x) => x.invoke === "bad" && x.error.includes("boom")), "only the failure is recorded");
3761
+ const sprints = parseDocument(readFileSync(join(root, "docs", "roadmap", "roadmap.yaml"), "utf8")).toJS().pis[0].sprints;
3762
+ eq(sprints[0].estimate.minutes, { low: 5, expected: 12, high: 30 }, "the good slice's estimate is written");
3763
+ ok(!sprints[1].estimate, "the failed slice gets no estimate");
3764
+ rmSync(root, { recursive: true, force: true });
3765
+ });
3766
+
3767
+ // ── agent-time timeline rollup (Phase 2) ─────────────────────────────────────
3768
+ // WHY: the calendar conversion is the one bit of genuinely new date math — a wrong rate or a
3769
+ // floor-instead-of-ceil would silently under-promise every projected date on the board.
3770
+ test("calendarFromMinutes: calendar days at hours_per_day, ceil, from the anchor", () => {
3771
+ eq(calendarFromMinutes(0, { hoursPerDay: 6, anchor: "2026-07-01" }), "2026-07-01", "zero → the anchor itself");
3772
+ eq(calendarFromMinutes(360, { hoursPerDay: 6, anchor: "2026-07-01" }), "2026-07-02", "one full 6h day → +1");
3773
+ eq(calendarFromMinutes(361, { hoursPerDay: 6, anchor: "2026-07-01" }), "2026-07-03", "a spill over the day → ceil to +2");
3774
+ eq(calendarFromMinutes(720, { hoursPerDay: 6, anchor: "2026-07-31" }), "2026-08-02", "crosses a month boundary");
3775
+ });
3776
+
3777
+ // WHY: the rollup is the whole macro-timeline claim — a wave must count as its MAX slice (parallel), not
3778
+ // the sum, and waves must accumulate (sequential). Get this wrong and every dated commitment is fiction.
3779
+ test("timelinePlan: wave span is the max (parallel), accumulates across waves (sequential)", () => {
3780
+ const g = { meta: { schema_version: 1, program: "T", default_concurrency: 3 }, pis: [
3781
+ { id: "a", title: "A", status: "active", start_date: "2026-07-01", sprints: [
3782
+ { id: "s1", title: "P1", status: "next", invoke: "a1", estimate: { minutes: { low: 100, expected: 300, high: 900 } } },
3783
+ { id: "s2", title: "P2", status: "next", invoke: "a2", estimate: { minutes: { low: 100, expected: 300, high: 900 } } },
3784
+ { id: "s3", title: "D", status: "next", invoke: "a3", deps: ["a1"], estimate: { minutes: { low: 100, expected: 300, high: 900 } } },
3785
+ ]},
3786
+ ]};
3787
+ const r = timelinePlan(g, {});
3788
+ eq(r.anchor, "2026-07-01", "anchored on the active PI's start_date");
3789
+ // wave1 = {a1 ∥ a2} span 300 (MAX, not 600); wave2 = {a3} span 300 → 600 min → ceil(600/360)=2 days.
3790
+ eq(r.pis, [{ pi: "a", projected_target_date: "2026-07-03" }], "max-within-wave + accumulate-across-waves → +2 days");
3791
+ eq(r.unpriced, [], "all priced");
3792
+ });
3793
+
3794
+ // WHY: an unpriced slice is scheduled work of UNKNOWN length — letting its PI still emit a concrete
3795
+ // date pushes a silently-optimistic commitment to Linear. A PI is dated ONLY when every remaining
3796
+ // slice is priced; a single unestimated slice suppresses the PI's date and is surfaced instead.
3797
+ test("timelinePlan: any unpriced remaining slice suppresses its PI's date; unpriced surfaced; now-anchor fallback", () => {
3798
+ const g = { meta: { schema_version: 1, program: "T", default_concurrency: 3 }, pis: [
3799
+ { id: "full", title: "Full", status: "next", sprints: [
3800
+ { id: "s1", title: "priced", status: "next", invoke: "f1", estimate: { minutes: { low: 60, expected: 180, high: 360 } } },
3801
+ ]},
3802
+ { id: "mixed", title: "Mixed", status: "next", sprints: [
3803
+ { id: "s1", title: "priced", status: "next", invoke: "m1", estimate: { minutes: { low: 60, expected: 180, high: 360 } } },
3804
+ { id: "s2", title: "unpriced", status: "next", invoke: "m2" },
3805
+ ]},
3806
+ { id: "none", title: "None", status: "next", sprints: [
3807
+ { id: "s1", title: "unpriced", status: "next", invoke: "n1" },
3808
+ ]},
3809
+ ]};
3810
+ const r = timelinePlan(g, { now: "2026-07-08T00:00:00Z" });
3811
+ eq(r.anchor, "2026-07-08", "no active start_date → anchored on now");
3812
+ eq(r.pis.map((p) => p.pi), ["full"], "only the fully-priced PI is dated; one unpriced slice suppresses its PI (mixed + none)");
3813
+ eq([...r.unpriced].sort(), ["m2", "n1"], "every unpriced remaining slice surfaced for the coverage gap");
3814
+ });
3815
+
3816
+ // WHY: held (blocked/gated) work isn't schedulable, so it must be EXCLUDED from the makespan and
3817
+ // surfaced in `held` — not folded in as a zero. The PI still dates from its schedulable slices (a
3818
+ // labelled optimistic frontier), so held work never silently inflates or fabricates the number.
3819
+ test("timelinePlan: a held slice is excluded from the makespan and surfaced; the PI dates from its schedulable work", () => {
3820
+ const g = { meta: { schema_version: 1, program: "T", default_concurrency: 3 }, pis: [
3821
+ { id: "a", title: "A", status: "active", start_date: "2026-07-01", sprints: [
3822
+ { id: "s1", title: "go", status: "next", invoke: "a1", estimate: { minutes: { low: 60, expected: 360, high: 720 } } },
3823
+ { id: "s2", title: "wait", status: "gated", gated_on: "Connor", invoke: "a2", estimate: { minutes: { low: 60, expected: 360, high: 720 } } },
3824
+ ]},
3825
+ ]};
3826
+ const r = timelinePlan(g, {});
3827
+ ok(r.held.includes("a2"), "the gated slice surfaces in held");
3828
+ eq(r.pis, [{ pi: "a", projected_target_date: "2026-07-02" }], "date from the schedulable slice (360 min → +1 day); the held slice is excluded, not zeroed");
3829
+ });
3830
+
3831
+ // WHY: a degenerate 0-minute estimate must project to the anchor (zero work → done at the start), never
3832
+ // crash the whole timeline run with an Invalid Date throw — a real crash-on-edge at the estimate boundary.
3833
+ test("timelinePlan: a zero-minute priced slice projects to the anchor, never NaN/throw", () => {
3834
+ const g = { meta: { schema_version: 1, program: "T" }, pis: [
3835
+ { id: "a", title: "A", status: "active", start_date: "2026-07-01", sprints: [
3836
+ { id: "s1", title: "trivial", status: "next", invoke: "a1", estimate: { minutes: { low: 0, expected: 0, high: 0 } } },
3837
+ ]},
3838
+ ]};
3839
+ eq(timelinePlan(g, {}).pis, [{ pi: "a", projected_target_date: "2026-07-01" }], "zero work → the anchor date, not an Invalid Date");
3840
+ });
3841
+
3842
+ // WHY: the write-back must fill the projection WITHOUT ever touching an explicit target_date (the human
3843
+ // commitment), and must be idempotent — a second identical run rewriting the file would be churn/noise.
3844
+ test("runTimeline writes projected_target_date, spares explicit target_date, idempotent", () => {
3845
+ const root = mkdtempSync(join(tmpdir(), "roadmap-timeline-"));
3846
+ mkdirSync(join(root, "docs", "roadmap"), { recursive: true });
3847
+ writeFileSync(join(root, "docs", "roadmap", "roadmap.yaml"),
3848
+ `meta:\n schema_version: 1\n program: T\n default_concurrency: 3\npis:\n - id: a\n title: A\n status: active\n start_date: 2026-07-01\n sprints:\n - { id: s1, title: One, status: next, invoke: a1, estimate: { minutes: { low: 60, expected: 360, high: 720 } } }\n - id: b\n title: B\n status: active\n start_date: 2026-07-01\n target_date: 2026-09-01\n sprints:\n - { id: s1, title: Two, status: next, invoke: b1, estimate: { minutes: { low: 60, expected: 360, high: 720 } } }\n`, "utf8");
3849
+ const r1 = runTimeline(root, { now: "2026-07-01T00:00:00Z" });
3850
+ const pis1 = parseDocument(readFileSync(join(root, "docs", "roadmap", "roadmap.yaml"), "utf8")).toJS().pis;
3851
+ ok(pis1.find((p) => p.id === "a").projected_target_date, "a got a projected date");
3852
+ eq(pis1.find((p) => p.id === "b").target_date, "2026-09-01", "explicit target_date untouched");
3853
+ ok(pis1.find((p) => p.id === "b").projected_target_date, "b still gets a projection (informational; Linear prefers the explicit)");
3854
+ ok(r1.changed.includes("a") && r1.changed.includes("b"), "both PIs reported changed on the first run");
3855
+ const r2 = runTimeline(root, { now: "2026-07-01T00:00:00Z" });
3856
+ eq(r2.changed, [], "second identical run changes nothing (idempotent — no rewrite)");
3857
+ rmSync(root, { recursive: true, force: true });
3858
+ });
3859
+
3860
+ // WHY: the derived date only reaches Linear's timeline if the projection falls back to it — and it must
3861
+ // NEVER shadow an explicit commitment. This is the one-line contract that makes the whole rollup visible.
3862
+ test("Linear projection: projected_target_date fills targetDate; an explicit target_date wins", () => {
3863
+ const g = { meta: { schema_version: 1, program: "T", linear: { team: "ENG" } }, pis: [
3864
+ { id: "a", title: "A", status: "active", projected_target_date: "2026-08-01", sprints: [{ id: "s1", title: "S", status: "next", invoke: "a1" }] },
3865
+ { id: "b", title: "B", status: "active", target_date: "2026-09-01", projected_target_date: "2026-08-15", sprints: [{ id: "s1", title: "S", status: "next", invoke: "b1" }] },
3866
+ ]};
3867
+ const plan = buildPushPlan({ graph: g, backlog: null, cfg: normalizeLinearConfig(g.meta), teamStates: L_STATES, existing: { projects: {}, issues: {} }, labels: {} });
3868
+ eq(plan.ops.find((o) => o.op === "createProject" && o.projectRef === "a").payload.targetDate, "2026-08-01", "projected fills the blank targetDate");
3869
+ eq(plan.ops.find((o) => o.op === "createProject" && o.projectRef === "b").payload.targetDate, "2026-09-01", "explicit commitment wins over the projection");
3870
+ });
3871
+
3872
+ // ── agent-time calibration loop (Phase 3) ────────────────────────────────────
3873
+ // WHY: the outcome argv is the whole feedback signal — a wrong task_id or logging an unestimated slice
3874
+ // would either mis-calibrate agent-time or crash the loop; a bad status would be rejected downstream.
3875
+ test("logArgs: builds the calibration argv; requires an estimated slice; validates status", () => {
3876
+ const sp = { invoke: "b", estimate: { task_id: "t-1" } };
3877
+ eq(logArgs(sp, "pass"), ["log", "--task-id", "t-1", "--status", "pass"], "task_id + status");
3878
+ eq(logArgs(sp, "partial", { actualMinutes: 50, actualRounds: 17 }), ["log", "--task-id", "t-1", "--status", "partial", "--actual-minutes", "50", "--actual-rounds", "17"], "optional actuals appended");
3879
+ eq(logArgs(sp), ["log", "--task-id", "t-1", "--status", "pass"], "default status is pass");
3880
+ throws(() => logArgs({ invoke: "x" }, "pass"), "has no estimate.task_id", "an unestimated slice can't be logged");
3881
+ throws(() => logArgs(sp, "bogus"), "status must be one of", "a bad status is rejected before it reaches agent-time");
3882
+ });
3883
+
3884
+ // WHY: idempotency keys the whole auto-loop — the Stop hook re-fires on every session end, so a false
3885
+ // negative here would double-count outcomes and skew calibration; a false positive would drop a real one.
3886
+ test("alreadyLogged: true only for an outcome record matching the task_id", () => {
3887
+ const hist = [
3888
+ JSON.stringify({ type: "estimate", task_id: "t-1" }), // a pending estimate is NOT an outcome
3889
+ JSON.stringify({ type: "outcome", task_id: "t-2" }),
3890
+ "not json at all", // junk lines are skipped, not fatal
3891
+ JSON.stringify({ type: "outcome", task_id: "t-1" }),
3892
+ ].join("\n");
3893
+ ok(alreadyLogged(hist, "t-1"), "an outcome for t-1 is found past the pending record + junk");
3894
+ ok(!alreadyLogged(hist, "t-3"), "no outcome for t-3 → false");
3895
+ ok(!alreadyLogged(JSON.stringify({ type: "estimate", task_id: "t-9" }), "t-9"), "a pending estimate alone is not 'logged'");
3896
+ ok(!alreadyLogged("", "t-1"), "empty history → false");
3897
+ });
3898
+
3899
+ // WHY: this is the loop's write path — the outcome must fire once with the right task_id, NEVER double-fire
3900
+ // (idempotent per task_id, since the Stop hook re-runs), and refuse an unestimated slice rather than crash.
3901
+ test("runLog fires the outcome, is idempotent per task_id, --force re-logs, errors on unestimated", () => {
3902
+ const root = mkdtempSync(join(tmpdir(), "roadmap-log-"));
3903
+ mkdirSync(join(root, "docs", "roadmap"), { recursive: true });
3904
+ writeFileSync(join(root, "docs", "roadmap", "roadmap.yaml"),
3905
+ `meta:\n schema_version: 1\n program: T\npis:\n - id: a\n title: A\n status: active\n sprints:\n - { id: s1, title: Done, status: complete, invoke: done, estimate: { minutes: { low: 1, expected: 2, high: 3 }, task_id: t-abc } }\n - { id: s2, title: Raw, status: next, invoke: raw }\n`, "utf8");
3906
+ const calls = [];
3907
+ const runEstimator = (args) => { calls.push(args); return { status: 0, stdout: "", stderr: "" }; };
3908
+
3909
+ const r1 = runLog(root, { invoke: "done", status: "pass", runEstimator });
3910
+ eq(r1.invoke, "done", "the outcome fired for this slice");
3911
+ ok(r1.logged, "logged flag set");
3912
+ ok(calls[0].join(" ").includes("log --task-id t-abc --status pass"), "estimator log carries the slice's task_id");
3913
+
3914
+ // agent-time would append the outcome; simulate that, then a re-fire must skip (the hook re-runs).
3915
+ const hist = resolveHistory(root);
3916
+ mkdirSync(join(root, ".claude", "agent-time"), { recursive: true });
3917
+ writeFileSync(hist, JSON.stringify({ type: "outcome", task_id: "t-abc", status: "pass" }) + "\n", "utf8");
3918
+ const before = calls.length;
3919
+ ok(runLog(root, { invoke: "done", status: "pass", runEstimator }).skipped, "already-logged task_id → skipped (idempotent)");
3920
+ eq(calls.length, before, "no second estimator fire");
3921
+
3922
+ eq(runLog(root, { invoke: "done", status: "pass", force: true, runEstimator }).invoke, "done", "--force re-logs past the guard");
3923
+ throws(() => runLog(root, { invoke: "raw", runEstimator }), "has no estimate.task_id", "an unestimated slice can't be logged");
3924
+ rmSync(root, { recursive: true, force: true });
3925
+ });
3926
+
3927
+ // WHY: agent-time REJECTS an outcome with no actuals (no round-counter data ran) — that real failure must
3928
+ // be RETURNED, never thrown, or the Stop hook firing runLog on every session end would crash session end.
3929
+ test("runLog surfaces a rejected log's error and never throws (the no-actuals degradation path)", () => {
3930
+ const root = mkdtempSync(join(tmpdir(), "roadmap-log-fail-"));
3931
+ mkdirSync(join(root, "docs", "roadmap"), { recursive: true });
3932
+ writeFileSync(join(root, "docs", "roadmap", "roadmap.yaml"),
3933
+ `meta:\n schema_version: 1\n program: T\npis:\n - id: a\n title: A\n status: active\n sprints:\n - { id: s1, title: Done, status: complete, invoke: done, estimate: { minutes: { low: 1, expected: 2, high: 3 }, task_id: t-x } }\n`, "utf8");
3934
+ const runEstimator = () => ({ status: 2, stdout: "", stderr: "error: --actual-rounds is required" });
3935
+ const r = runLog(root, { invoke: "done", status: "pass", runEstimator });
3936
+ ok(r.error && r.error.includes("--actual-rounds is required"), "the rejection reason is surfaced, not thrown");
3937
+ ok(!r.logged, "nothing is recorded as logged on failure");
3938
+ rmSync(root, { recursive: true, force: true });
3939
+ });
3940
+
3941
+ await Promise.all(pending);
3942
+ console.log(`\n${passed} passed, ${failed} failed`);
3943
+ process.exit(failed ? 1 : 0);