@connorbritain/roadmap 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/schema/backlog.schema.json +13 -1
- package/schema/roadmap.schema.json +16 -2
- package/scripts/cli.mjs +12 -10
- package/scripts/doctor.mjs +87 -0
- package/scripts/fanout.mjs +36 -31
- package/scripts/lib/backlog-core.mjs +10 -5
- package/scripts/lib/cli-core.mjs +3 -3
- package/scripts/lib/doctor-core.mjs +63 -0
- package/scripts/lib/external-state.mjs +67 -0
- package/scripts/lib/graph.mjs +69 -1
- package/scripts/lib/linear-core.mjs +7 -1
- package/scripts/lib/mcp-core.mjs +3 -3
- package/scripts/lib/plan.mjs +9 -4
- package/scripts/lib/priority.mjs +8 -0
- package/scripts/lib/recommend.mjs +31 -3
- package/scripts/lib/render-core.mjs +15 -3
- package/scripts/lib/review-core.mjs +9 -1
- package/scripts/lib/validate-core.mjs +63 -11
- package/scripts/next.mjs +1 -1
- package/scripts/review.mjs +1 -0
- package/scripts/scheduler.mjs +16 -2
- package/scripts/test/run.mjs +412 -25
package/scripts/test/run.mjs
CHANGED
|
@@ -6,13 +6,15 @@
|
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
flatten, detectCycle, computeWaves, execPlan, sessionsRemaining, resolveGate, isDone, readyNodes, coherenceEnabled,
|
|
9
|
+
commandLaneMembers, commandLaneActive,
|
|
9
10
|
} from "../lib/graph.mjs";
|
|
11
|
+
import { parseWorktrees } from "../lib/external-state.mjs";
|
|
10
12
|
import { buildPlan } from "../lib/plan.mjs";
|
|
11
|
-
import { nodeWeight, recommendConcurrency, probeDisk } from "../lib/recommend.mjs";
|
|
13
|
+
import { nodeWeight, recommendConcurrency, probeDisk, probeReviewDebt } from "../lib/recommend.mjs";
|
|
12
14
|
import { synthesizeBrief, branchFor, worktreeFor, baseRefOf, baseBranchOf, remoteOf, launchPrompt, agentCmdFor, DEFAULT_AGENT_CMD } from "../lib/brief.mjs";
|
|
13
15
|
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 { launchDecision } from "../lib/fanout-core.mjs";
|
|
17
|
+
import { configuredProfiles, resolveProfile, commandFor, launchDecisionForProfile, safeConfig } from "../lib/assistant-core.mjs";
|
|
16
18
|
import { terminalChoices, moveSelection, parseCap, buildFanArgs, autoOutName } from "../lib/wizard-core.mjs";
|
|
17
19
|
import { TOOLS, addSprint, setStatus, setFields, bulkSet, prune, validateDocOrThrow, readValidate, serialize } from "../lib/mcp-core.mjs";
|
|
18
20
|
import { parseAssignments } from "../lib/cli-core.mjs";
|
|
@@ -23,7 +25,7 @@ import {
|
|
|
23
25
|
teamSize, filterByTrack, dirClusters, EXEC_MODES, EXEC_ROLES,
|
|
24
26
|
} from "../lib/execution.mjs";
|
|
25
27
|
import { renderMarkdown } from "../lib/render-core.mjs";
|
|
26
|
-
import { comparePriority, validatePriority, tierBadge, TIERS } from "../lib/priority.mjs";
|
|
28
|
+
import { comparePriority, laneComparator, validatePriority, tierBadge, TIERS } from "../lib/priority.mjs";
|
|
27
29
|
import {
|
|
28
30
|
validateBacklog, addItem, setItemFields, validateBacklogDocOrThrow, sortByPriority,
|
|
29
31
|
openCount, renderBacklogMarkdown, backlogItemToNode, pickNext, BACKLOG_TOOLS, readBacklogList,
|
|
@@ -52,6 +54,7 @@ import { runCyclePlan, runCycleLock } from "../cycle.mjs";
|
|
|
52
54
|
import { readReadyWave } from "../lib/mcp-core.mjs";
|
|
53
55
|
import { loadGraph } from "../lib/graph.mjs";
|
|
54
56
|
import { graphDiff, backlogDiff, reviewDigest, pisInFlight } from "../lib/review-core.mjs";
|
|
57
|
+
import { doctorReport } from "../lib/doctor-core.mjs";
|
|
55
58
|
import { parseDocument } from "yaml";
|
|
56
59
|
import { join, resolve } from "node:path";
|
|
57
60
|
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from "node:fs";
|
|
@@ -426,26 +429,26 @@ test("missingRoadmapHelp names the path, the cwd, and a starter", () => {
|
|
|
426
429
|
// WHY: launch is the DEFAULT (low-risk interactive); the only dangerous mode (headless
|
|
427
430
|
// autonomous commit/push/PR) must stay behind a double-ack. If this regresses, a bare
|
|
428
431
|
// `roadmap fan` could fire autonomous workers, or --dry could accidentally spawn.
|
|
429
|
-
test("launchDecision: launch by default, --dry/--out preview, autonomous double-acked", () => {
|
|
432
|
+
test("launchDecision: launch by default, --dry/--out preview, autonomous double-acked", () => {
|
|
430
433
|
eq(launchDecision({}), { spawn: true, mode: "interactive" }, "bare → launch interactive");
|
|
431
434
|
eq(launchDecision({ dry: true }).spawn, false, "--dry → no spawn");
|
|
432
435
|
eq(launchDecision({ out: "x.sh" }).spawn, false, "--out → no spawn (wrote script)");
|
|
433
436
|
eq(launchDecision({ autonomous: true }), { spawn: false, mode: "autonomous-needs-ack" }, "autonomous w/o ack → held");
|
|
434
437
|
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
|
-
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// WHY: a portable install must never surprise-start an agent; local authorization is the
|
|
441
|
+
// boundary between a useful fanout script and an unintended autonomous coding session.
|
|
442
|
+
test("assistant profiles default to manual and require local launch authorization", () => {
|
|
443
|
+
const graph = { meta: { assistants: { default: "manual" } } };
|
|
444
|
+
eq(resolveProfile(graph, {}, null).name, "manual", "manual is the safe default");
|
|
445
|
+
eq(launchDecisionForProfile(resolveProfile(graph, {}, null), { requestedLaunch: false }), { spawn: false, mode: "manual" }, "manual only emits handoffs");
|
|
446
|
+
const codex = resolveProfile(graph, { assistants: { codex: { launch: true, command: "codex {prompt}" } } }, "codex");
|
|
447
|
+
ok(launchDecisionForProfile(codex, { requestedLaunch: true }).spawn, "explicitly authorized local profile may launch");
|
|
448
|
+
eq(commandFor(codex, { prompt: "read brief" }), 'codex "read brief"', "template expands a quoted prompt");
|
|
449
|
+
throws(() => safeConfig({ token: "not-allowed" }), "credential", "local assistant config rejects secret-like fields");
|
|
450
|
+
ok(configuredProfiles(graph, {}).profiles.claude, "built-in profiles are discoverable");
|
|
451
|
+
});
|
|
449
452
|
|
|
450
453
|
// ── short flags + self-contained worker prompt ──────────────────────────────
|
|
451
454
|
// WHY: `-w 1 -c 2 -t warp` must expand to the long flags the scripts understand, and
|
|
@@ -1259,6 +1262,56 @@ test("probeDisk honors the meta.worktree_gb override and never throws", () => {
|
|
|
1259
1262
|
eq(probeDisk({ meta: {} }, "/nonexistent-dir-for-roadmap-test"), null, "unprobeable → null");
|
|
1260
1263
|
});
|
|
1261
1264
|
|
|
1265
|
+
// ── review-debt backpressure ────────────────────────────────────────────────
|
|
1266
|
+
// WHY: review-debt backpressure is the "don't over-subscribe the HUMAN" half of the recommender —
|
|
1267
|
+
// each stuck PR / unresolved worktree must lower the review ceiling, or a growing merge queue keeps
|
|
1268
|
+
// getting fed new fanout it can never drain. reviewDebt is INJECTED (the core stays pure, like disk),
|
|
1269
|
+
// modifies the EXISTING review candidate (adds none), and can never floor concurrency below 1.
|
|
1270
|
+
test("reviewDebt lowers the review ceiling and clamps recommended (injected, floors at 1)", () => {
|
|
1271
|
+
const bigSys = { sys: { cores: 64, totalGb: 256, freeGb: 256, platform: "linux" } };
|
|
1272
|
+
const bound = recommendConcurrency(recoReady, recoGraph, { ...bigSys, reviewCeiling: 8, reviewDebt: 5 });
|
|
1273
|
+
eq(bound.recommended, 3, "8 base − 5 debt = review ceiling 3 binds (smaller than cpu/ram/work)");
|
|
1274
|
+
ok(bound.binding.why.startsWith("review"), `expected review-bound, got ${bound.binding.why}`);
|
|
1275
|
+
ok(/−5 review debt/.test(bound.binding.why), "why names the debt that dropped the ceiling");
|
|
1276
|
+
eq(bound.candidates.length, 4, "debt modifies the review candidate, adds none — still four ceilings");
|
|
1277
|
+
const floored = recommendConcurrency(recoReady, recoGraph, { ...bigSys, reviewCeiling: 2, reviewDebt: 99 });
|
|
1278
|
+
eq(floored.recommended, 1, "debt floors the review ceiling at 1, never 0 (soft ceiling)");
|
|
1279
|
+
const none = recommendConcurrency(recoReady, recoGraph, { ...bigSys, reviewCeiling: 8, reviewDebt: 0 });
|
|
1280
|
+
eq(none.recommended, recoReady.length, "zero debt leaves the base ceiling → work (4) binds, unchanged");
|
|
1281
|
+
});
|
|
1282
|
+
|
|
1283
|
+
// WHY: the command-lane cap applies "finish the committed outcome before discovering the next" to
|
|
1284
|
+
// CONCURRENCY — while a dated objective is active, the machine must not fan wider than the lane has
|
|
1285
|
+
// ready, or the wave dilutes across off-lane slices. Gated on active: released → the candidate vanishes.
|
|
1286
|
+
test("command-lane cap clamps concurrency to the ready lane-slice count while active; none when released", () => {
|
|
1287
|
+
const g = {
|
|
1288
|
+
meta: { schema_version: 1, program: "T", default_gate: "dotnet build",
|
|
1289
|
+
command_lane: { objective: "ship auth", pi: "a", until: "2026-07-15" } },
|
|
1290
|
+
pis: [
|
|
1291
|
+
{ id: "a", title: "A", status: "active", sprints: [
|
|
1292
|
+
sp("s1", { status: "active", invoke: "a-1" }), sp("s2", { status: "active", invoke: "a-2" }) ] },
|
|
1293
|
+
{ id: "b", title: "B", status: "active", sprints: [
|
|
1294
|
+
sp("s1", { status: "active", invoke: "b-1" }), sp("s2", { status: "active", invoke: "b-2" }),
|
|
1295
|
+
sp("s3", { status: "active", invoke: "b-3" }) ] },
|
|
1296
|
+
],
|
|
1297
|
+
};
|
|
1298
|
+
const ready = readyNodes(flatten(g)); // 5 ready overall; 2 are lane members (a-1, a-2)
|
|
1299
|
+
const bigSys = { sys: { cores: 64, totalGb: 256, freeGb: 256, platform: "linux" }, reviewCeiling: 50 };
|
|
1300
|
+
const active = recommendConcurrency(ready, g, { ...bigSys, today: "2026-07-13" });
|
|
1301
|
+
eq(active.recommended, 2, "capped to the 2 READY lane slices, not the 5 ready overall");
|
|
1302
|
+
ok(active.binding.why.startsWith("command-lane"), `expected command-lane-bound, got ${active.binding.why}`);
|
|
1303
|
+
ok(active.candidates.some((c) => c.why === "command-lane — cap to lane"), "lane candidate present when active");
|
|
1304
|
+
const released = recommendConcurrency(ready, g, { ...bigSys, today: "2026-07-16" });
|
|
1305
|
+
eq(released.recommended, ready.length, "past the until date → lane releases → work cap (5) binds again");
|
|
1306
|
+
ok(!released.candidates.some((c) => c.why === "command-lane — cap to lane"), "no lane candidate once released");
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
// WHY: probeReviewDebt runs at the real IO surfaces (plan, fanout) where gh/git may be absent, slow,
|
|
1310
|
+
// or outside a repo; it MUST degrade to 0 (no backpressure) rather than throw and take down the plan.
|
|
1311
|
+
test("probeReviewDebt degrades to 0 when gh/git can't answer (guarded)", () => {
|
|
1312
|
+
eq(probeReviewDebt("/nonexistent-dir-for-roadmap-test", { meta: {} }), 0, "unprobeable → 0, never throws");
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1262
1315
|
// ── store.mjs: the file-write-ordering / rollback guarantees (fs-backed) ──────
|
|
1263
1316
|
// WHY: store.mjs is the one place with data-loss blast radius — every mutating surface
|
|
1264
1317
|
// routes through it. If a thrown validation still wrote a file, or promote wrote one file
|
|
@@ -2048,6 +2101,24 @@ test("buildPullProposals suppresses round-trip status echoes, keeps genuine huma
|
|
|
2048
2101
|
eq([statusDeltas[0].key, statusDeltas[0].to], ["act", "complete"], "the human completion survives");
|
|
2049
2102
|
});
|
|
2050
2103
|
|
|
2104
|
+
// WHY: a shipped slice (roadmap `complete`) whose Linear issue is stale at In Progress must NOT get an
|
|
2105
|
+
// un-completion proposal. That delta holds the stateId push (buildPushPlan skips held fields), and since
|
|
2106
|
+
// pull is propose-only the delta never applies — so the issue is stuck non-Done in the cycle forever.
|
|
2107
|
+
// Real 2026-07 incident: PID-552/553/435/538 sat In Progress despite shipping; direct API was the only fix.
|
|
2108
|
+
test("buildPullProposals never proposes un-completing a roadmap-complete slice from a stale Linear state", () => {
|
|
2109
|
+
const cfg = normalizeLinearConfig({ linear: { team: "ENG", pull: "propose" } });
|
|
2110
|
+
const graph = { meta: { schema_version: 1, program: "T", linear: { team: "ENG" } },
|
|
2111
|
+
pis: [{ id: "a", title: "A", status: "active", sprints: [
|
|
2112
|
+
{ id: "s1", title: "Shipped", status: "complete", invoke: "done-slice", linear: "ENG-1" },
|
|
2113
|
+
]}]};
|
|
2114
|
+
const inbound = [
|
|
2115
|
+
{ identifier: "ENG-1", title: "Shipped", priority: 0, state: { type: "started" }, team: "ENG", project: null }, // stale In Progress
|
|
2116
|
+
];
|
|
2117
|
+
const { deltas } = buildPullProposals({ cfg, inbound, graph, backlog: null });
|
|
2118
|
+
eq(deltas.filter((d) => d.field === "status").length, 0, "no un-completion delta → the Done push is not held");
|
|
2119
|
+
eq(holdsFor(deltas).has("ENG-1:stateId"), false, "stateId is free to push, breaking the deadlock");
|
|
2120
|
+
});
|
|
2121
|
+
|
|
2051
2122
|
// WHY: pidgeon's board was tacky ("Headline — subhead...") because the project name was the
|
|
2052
2123
|
// PI title verbatim. The name must be the headline (pre-dash), with the dropped subhead
|
|
2053
2124
|
// preserved in the description so no context is lost.
|
|
@@ -2702,6 +2773,143 @@ test("composition lint: one aggregated warning under pi_min_slices; complete PIs
|
|
|
2702
2773
|
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
2774
|
});
|
|
2704
2775
|
|
|
2776
|
+
// WHY: too many concurrently-active PIs is how a portfolio stops finishing and starts sprawling. The
|
|
2777
|
+
// cap turns "finish before you start" into a checkable rule — the governing principle in one knob.
|
|
2778
|
+
test("WIP cap: warns when active PIs exceed active_pi_cap; off when absent; only active counts; 0 rejected", () => {
|
|
2779
|
+
const g = (discipline) => ({ meta: { schema_version: 1, program: "T", ...(discipline ? { discipline } : {}) }, pis: [
|
|
2780
|
+
{ id: "a1", title: "A1", status: "active", sprints: [{ id: "s", title: "x", status: "active", invoke: "a1-s" }] },
|
|
2781
|
+
{ id: "a2", title: "A2", status: "active", sprints: [{ id: "s", title: "x", status: "active", invoke: "a2-s" }] },
|
|
2782
|
+
{ id: "a3", title: "A3", status: "active", sprints: [{ id: "s", title: "x", status: "active", invoke: "a3-s" }] },
|
|
2783
|
+
{ id: "done", title: "D", status: "complete", sprints: [{ id: "s", title: "x", status: "complete", invoke: "d-s" }] },
|
|
2784
|
+
{ id: "sched", title: "S", status: "scheduled", sprints: [{ id: "s", title: "x", status: "scheduled", invoke: "sc-s" }] },
|
|
2785
|
+
]});
|
|
2786
|
+
const warns = validateGraph(g({ active_pi_cap: 2 })).warnings.filter((w) => w.startsWith("WIP:"));
|
|
2787
|
+
eq(warns.length, 1, "one aggregated WIP line");
|
|
2788
|
+
ok(warns[0].includes("3 active PI(s) exceed the cap of 2") && warns[0].includes("a1") && warns[0].includes("a3"), "names the active PIs over the cap");
|
|
2789
|
+
ok(!warns[0].includes("done") && !warns[0].includes("sched"), "only active status counts against the cap");
|
|
2790
|
+
eq(validateGraph(g({ active_pi_cap: 3 })).warnings.filter((w) => w.startsWith("WIP:")).length, 0, "at the cap → quiet");
|
|
2791
|
+
eq(validateGraph(g(null)).warnings.filter((w) => w.startsWith("WIP:")).length, 0, "absent knob → off (no wall on unopted repos)");
|
|
2792
|
+
ok(validateGraph(g({ active_pi_cap: 0 })).errors.some((e) => e.includes("active_pi_cap")), "0 rejected — a bad knob must not silently disable the guardrail");
|
|
2793
|
+
});
|
|
2794
|
+
|
|
2795
|
+
// WHY: an "active" PI with no runnable work makes the board lie about what's in flight and the
|
|
2796
|
+
// concurrency planner over-recommend — the exact failure that dispatched sessions while a human-gated
|
|
2797
|
+
// deadline lane sat "active". Both stale-active and held-only-active must surface; real work is spared.
|
|
2798
|
+
test("active-integrity: flags a stale-active (all-complete) PI and a held-only 'active' lane; spares real work", () => {
|
|
2799
|
+
const g = (pis) => ({ meta: { schema_version: 1, program: "T" }, pis });
|
|
2800
|
+
const stale = validateGraph(g([
|
|
2801
|
+
{ id: "stale", title: "S", status: "active", sprints: [{ id: "s", title: "x", status: "complete", invoke: "stale-s" }] },
|
|
2802
|
+
])).warnings.filter((w) => w.startsWith("active-integrity:"));
|
|
2803
|
+
eq(stale.length, 1, "stale-active flagged");
|
|
2804
|
+
ok(stale[0].includes("stale") && stale[0].includes("every sprint is complete"), "names the PI + the rollup miss");
|
|
2805
|
+
const lane = validateGraph(g([
|
|
2806
|
+
{ id: "lane", title: "L", status: "active", sprints: [
|
|
2807
|
+
{ id: "s1", title: "x", status: "gated", gated_on: "human", invoke: "lane-s1" },
|
|
2808
|
+
{ id: "s2", title: "y", status: "complete", invoke: "lane-s2" },
|
|
2809
|
+
{ id: "s3", title: "z", status: "optionality", invoke: "lane-s3" } ] },
|
|
2810
|
+
])).warnings.filter((w) => w.startsWith("active-integrity:"));
|
|
2811
|
+
eq(lane.length, 1, "held-only active lane flagged");
|
|
2812
|
+
ok(lane[0].includes("no agent-runnable slice") && lane[0].includes("1 held") && lane[0].includes("1 optionality"), "counts held + parked");
|
|
2813
|
+
const real = validateGraph(g([
|
|
2814
|
+
{ id: "ok", title: "O", status: "active", sprints: [
|
|
2815
|
+
{ id: "s1", title: "x", status: "active", invoke: "ok-s1" },
|
|
2816
|
+
{ id: "s2", title: "y", status: "gated", gated_on: "h", invoke: "ok-s2" } ] },
|
|
2817
|
+
])).warnings.filter((w) => w.startsWith("active-integrity:"));
|
|
2818
|
+
eq(real.length, 0, "an active PI with even one runnable slice is fine");
|
|
2819
|
+
});
|
|
2820
|
+
|
|
2821
|
+
// WHY: the command-lane's membership + active-gate are the foundation the sort-boost AND the
|
|
2822
|
+
// concurrency-cap both read; if membership or the date/completion release is wrong, the override
|
|
2823
|
+
// either never fires or never lets go — the roadmap stops returning to normal priority after the ship.
|
|
2824
|
+
test("commandLaneMembers + commandLaneActive: membership by pi/slices; releases on date-past or all-done", () => {
|
|
2825
|
+
const g = (command_lane, pis) => ({ meta: { schema_version: 1, program: "T", ...(command_lane ? { command_lane } : {}) }, pis });
|
|
2826
|
+
const pis = [
|
|
2827
|
+
{ id: "P", title: "P", status: "active", sprints: [
|
|
2828
|
+
{ id: "s1", title: "a", status: "active", invoke: "p-a" },
|
|
2829
|
+
{ id: "s2", title: "b", status: "complete", invoke: "p-b" } ] },
|
|
2830
|
+
{ id: "Q", title: "Q", status: "active", sprints: [{ id: "s1", title: "c", status: "active", invoke: "q-c" }] },
|
|
2831
|
+
];
|
|
2832
|
+
eq([...commandLaneMembers(g({ objective: "x", pi: "P" }, pis))].sort(), ["p-a", "p-b"], "pi → all its sprint invokes");
|
|
2833
|
+
eq([...commandLaneMembers(g({ objective: "x", slices: ["q-c"] }, pis))], ["q-c"], "slices → the listed invokes");
|
|
2834
|
+
eq(commandLaneMembers(g(null, pis)).size, 0, "no command_lane → empty membership");
|
|
2835
|
+
eq(commandLaneActive(g({ objective: "x", pi: "P", until: "2026-07-15" }, pis), "2026-07-13"), true, "open member + before until → active");
|
|
2836
|
+
eq(commandLaneActive(g({ objective: "x", pi: "P", until: "2026-07-15" }, pis), "2026-07-16"), false, "past until → released");
|
|
2837
|
+
const doneP = [{ id: "P", title: "P", status: "active", sprints: [{ id: "s1", title: "a", status: "complete", invoke: "p-a" }] }];
|
|
2838
|
+
eq(commandLaneActive(g({ objective: "x", pi: "P", until: "2026-12-31" }, doneP), "2026-07-13"), false, "all members complete → released");
|
|
2839
|
+
eq(commandLaneActive(g(null, pis), "2026-07-13"), false, "no command_lane → never active");
|
|
2840
|
+
});
|
|
2841
|
+
|
|
2842
|
+
// WHY: laneComparator is the boost primitive both sort sites share — if it ranks members first while
|
|
2843
|
+
// INACTIVE it would silently override priority on every graph; if it stays neutral while ACTIVE the
|
|
2844
|
+
// command lane never fires. The active/inactive split IS the feature and the backward-compat guarantee.
|
|
2845
|
+
test("laneComparator ranks in-lane slices first only when active", () => {
|
|
2846
|
+
const members = new Set(["in"]);
|
|
2847
|
+
const inNode = { invoke: "in" }, outNode = { invoke: "out" };
|
|
2848
|
+
const active = laneComparator(members, true);
|
|
2849
|
+
ok(active(inNode, outNode) < 0, "active → member sorts before non-member");
|
|
2850
|
+
ok(active(outNode, inNode) > 0, "active → non-member sorts after member");
|
|
2851
|
+
eq(active({ invoke: "out1" }, { invoke: "out2" }), 0, "active → two non-members tie (fall through to next key)");
|
|
2852
|
+
const inactive = laneComparator(members, false);
|
|
2853
|
+
eq(inactive(inNode, outNode), 0, "inactive → always 0, ordering untouched");
|
|
2854
|
+
});
|
|
2855
|
+
|
|
2856
|
+
// WHY: an active command lane must float its member slices ahead of even a declared P0 — that IS the
|
|
2857
|
+
// finishing override. And an absent/expired lane must reorder nothing, or every existing plan reshuffles.
|
|
2858
|
+
test("computeWaves floats command-lane members to the top when active; untouched when inactive/absent", () => {
|
|
2859
|
+
const build = (lane) => ({ meta: { schema_version: 1, program: "T", ...(lane ? { command_lane: lane } : {}) }, pis: [
|
|
2860
|
+
{ id: "a", title: "A", status: "active", sprints: [
|
|
2861
|
+
sp("s1", { invoke: "aaa", touches: ["f1"], priority: { tier: "P0" } }), // highest declared priority
|
|
2862
|
+
sp("s2", { invoke: "zzz-lane", touches: ["f2"] }), // lane member, no priority
|
|
2863
|
+
]}]});
|
|
2864
|
+
const lane = { objective: "ship it", until: "2026-12-31", slices: ["zzz-lane"] };
|
|
2865
|
+
const gl = build(lane);
|
|
2866
|
+
eq(computeWaves(flatten(gl), 1, { meta: gl.meta, today: "2026-07-13" }).waves[0].map((n) => n.invoke), ["zzz-lane"], "active lane beats the P0");
|
|
2867
|
+
eq(computeWaves(flatten(gl), 1, { meta: gl.meta, today: "2027-06-01" }).waves[0].map((n) => n.invoke), ["aaa"], "past until → P0 wins again");
|
|
2868
|
+
eq(computeWaves(flatten(build(null)), 1).waves[0].map((n) => n.invoke), ["aaa"], "no command_lane → priority order, byte-identical");
|
|
2869
|
+
});
|
|
2870
|
+
|
|
2871
|
+
// WHY: `next` is the single-pick entry — a live command-lane slice must surface there ahead of an
|
|
2872
|
+
// alphabetically-earlier ordinary slice, and fall straight back once the lane expires or is absent.
|
|
2873
|
+
test("pickNext floats a command-lane slice to the pick when active; falls back when inactive/absent", () => {
|
|
2874
|
+
const build = (lane) => ({ meta: { ...(lane ? { command_lane: lane } : {}) }, pis: [
|
|
2875
|
+
{ id: "a", title: "A", status: "active", sprints: [
|
|
2876
|
+
sp("s1", { invoke: "aaa" }), // alphabetically first, no priority
|
|
2877
|
+
sp("s2", { invoke: "zzz-lane" }), // lane member
|
|
2878
|
+
]}]});
|
|
2879
|
+
const lane = { objective: "ship it", until: "2026-12-31", slices: ["zzz-lane"] };
|
|
2880
|
+
eq(pickNext(build(lane), null, "2026-07-13").node.invoke, "zzz-lane", "active lane wins the slice pick");
|
|
2881
|
+
eq(pickNext(build(lane), null, "2027-06-01").node.invoke, "aaa", "expired lane → alpha order");
|
|
2882
|
+
eq(pickNext(build(null), null).node.invoke, "aaa", "no lane / no today → alpha order, unchanged");
|
|
2883
|
+
});
|
|
2884
|
+
|
|
2885
|
+
// WHY: a command_lane pointing at a PI or slice that doesn't exist looks armed but boosts nothing —
|
|
2886
|
+
// a dangling ref must WARN (not silently cover zero slices), and a broken shape must ERROR.
|
|
2887
|
+
test("validateGraph checks meta.command_lane shape and warns on dangling refs", () => {
|
|
2888
|
+
const base = (lane) => ({ meta: { schema_version: 1, program: "T", command_lane: lane }, pis: [
|
|
2889
|
+
{ id: "a", title: "A", status: "active", sprints: [{ id: "s1", title: "S", status: "active", invoke: "real", est_sessions: 1 }] }] });
|
|
2890
|
+
eq(validateGraph(base({ objective: "ship", until: "2026-12-31", pi: "a" })).errors, [], "valid lane (pi resolves) passes");
|
|
2891
|
+
eq(validateGraph(base({ objective: "ship", until: "2026-12-31", slices: ["real"] })).errors, [], "valid lane (slice resolves) passes");
|
|
2892
|
+
ok(validateGraph(base({ objective: "ship", until: "next-week" })).errors.some((e) => e.includes("until")), "non-date until rejected");
|
|
2893
|
+
ok(validateGraph(base({ until: "2026-12-31" })).errors.some((e) => e.includes("objective")), "missing objective rejected");
|
|
2894
|
+
ok(validateGraph(base({ objective: "ship", until: "2026-12-31", pi: "ghost" })).warnings.some((w) => w.includes("command_lane")), "dangling pi warns");
|
|
2895
|
+
ok(validateGraph(base({ objective: "ship", until: "2026-12-31", slices: ["nope"] })).warnings.some((w) => w.includes("command_lane")), "dangling slice warns");
|
|
2896
|
+
});
|
|
2897
|
+
|
|
2898
|
+
// WHY: review-debt backpressure + drift-doctor both count unresolved worktrees off this parse; a wrong
|
|
2899
|
+
// branch/merged mapping would hide a stuck worktree (no backpressure) or flag a clean one as debt.
|
|
2900
|
+
test("parseWorktrees: maps porcelain to path/branch/isMerged; empty porcelain is guarded", () => {
|
|
2901
|
+
const porcelain = [
|
|
2902
|
+
"worktree /repo\nbranch refs/heads/main",
|
|
2903
|
+
"worktree /wt/a\nbranch refs/heads/feat-a",
|
|
2904
|
+
"worktree /wt/b\nbranch refs/heads/feat-b",
|
|
2905
|
+
].join("\n\n");
|
|
2906
|
+
const all = parseWorktrees(porcelain, { mergedSet: new Set(["feat-a"]) });
|
|
2907
|
+
eq(all.length, 3, "all worktrees parsed when no wtRoot filter");
|
|
2908
|
+
eq(all.find((w) => w.branch === "feat-a").isMerged, true, "merged branch flagged from the set");
|
|
2909
|
+
eq(all.find((w) => w.branch === "feat-b").isMerged, false, "unmerged branch not flagged");
|
|
2910
|
+
eq(parseWorktrees("", {}).length, 0, "empty porcelain → no worktrees (guarded)");
|
|
2911
|
+
});
|
|
2912
|
+
|
|
2705
2913
|
test("sprawlWarnings: ratio fires above threshold, quiet at it, quiet on an empty window", () => {
|
|
2706
2914
|
const hot = sprawlWarnings({ completed: 2, captured: 5, addedSprints: 2 });
|
|
2707
2915
|
eq(hot.length, 1, "one ratio warning");
|
|
@@ -2734,6 +2942,74 @@ test("validateGraph checks meta.discipline and meta.last_review shapes", () => {
|
|
|
2734
2942
|
ok(validateGraph(base({ last_review: { date: "2026-07-06" } })).errors[0].includes("last_review"), "anchor missing commit rejected");
|
|
2735
2943
|
});
|
|
2736
2944
|
|
|
2945
|
+
// ── finishing discipline: receipts + outcome rollups ─────────────────────────
|
|
2946
|
+
// WHY: receipts are optional finishing evidence surfaced in the PI table beside PRs. A slice with
|
|
2947
|
+
// NO receipts must render byte-identically to today or every existing SLICES.md churns under diff
|
|
2948
|
+
// review; a slice WITH receipts must show them, or the evidence a founder-review relies on is invisible.
|
|
2949
|
+
test("renderMarkdown shows receipts beside PRs only when present", () => {
|
|
2950
|
+
const g = (receipts) => ({
|
|
2951
|
+
meta: { schema_version: 1, program: "T" },
|
|
2952
|
+
pis: [{ id: "a", title: "A", status: "active", sprints: [
|
|
2953
|
+
{ id: "s1", title: "Ship", status: "complete", invoke: "ship-it", what: "ship", ...(receipts ? { receipts } : {}) },
|
|
2954
|
+
] }],
|
|
2955
|
+
});
|
|
2956
|
+
ok(renderMarkdown(g({ build: "#12", test: "green" })).includes("📎 build,test"), "present receipts render beside the row");
|
|
2957
|
+
ok(!renderMarkdown(g(null)).includes("📎"), "no receipts → no marker (row byte-identical to today)");
|
|
2958
|
+
});
|
|
2959
|
+
|
|
2960
|
+
// WHY: "done" must mean PROVEN done — a complete slice missing a required receipt is a finishing
|
|
2961
|
+
// lie the warn must surface. But the check is opt-in: an unopted repo (no required_receipts) stays
|
|
2962
|
+
// silent, in-flight slices are exempt (evidence lands at completion), and a bad knob value must
|
|
2963
|
+
// ERROR rather than silently disable the guardrail (mirrors the milestone/composition validate tests).
|
|
2964
|
+
test("required_receipts warns a complete slice missing evidence; off when unset; guards its shape", () => {
|
|
2965
|
+
const g = (discipline, receipts, status = "complete") => ({
|
|
2966
|
+
meta: { schema_version: 1, program: "T", ...(discipline ? { discipline } : {}) },
|
|
2967
|
+
pis: [{ id: "a", title: "A", status: "active", sprints: [
|
|
2968
|
+
{ id: "s1", title: "S", status, invoke: "x", ...(receipts ? { receipts } : {}) },
|
|
2969
|
+
] }],
|
|
2970
|
+
});
|
|
2971
|
+
const rw = (v) => validateGraph(v).warnings.filter((w) => w.startsWith("receipt:"));
|
|
2972
|
+
const miss = rw(g({ required_receipts: ["build", "signoff"] }, { build: "#1" }));
|
|
2973
|
+
eq(miss.length, 1, "one receipt warn for the slice");
|
|
2974
|
+
ok(miss[0].includes("a/s1") && miss[0].includes("signoff") && !miss[0].includes("build"), "names the slice + only the ABSENT required key");
|
|
2975
|
+
eq(rw(g({ required_receipts: ["build"] }, { build: "#1" })).length, 0, "complete + all required present → quiet");
|
|
2976
|
+
eq(rw(g(null, null)).length, 0, "no required_receipts knob → check off (unopted repo stays silent)");
|
|
2977
|
+
eq(rw(g({ required_receipts: ["build"] }, null, "active")).length, 0, "in-flight slice exempt (evidence lands at completion)");
|
|
2978
|
+
ok(validateGraph(g({ required_receipts: ["nope"] }, null)).errors.some((e) => e.includes("required_receipts")), "unknown key rejected — a bad knob must not silently disable the check");
|
|
2979
|
+
ok(validateGraph(g({ required_receipts: "build" }, null)).errors.some((e) => e.includes("required_receipts")), "non-array rejected");
|
|
2980
|
+
});
|
|
2981
|
+
|
|
2982
|
+
// WHY: receipts + outcome are only useful if agents can WRITE them through the same allow-listed path
|
|
2983
|
+
// as every other field — set_fields/bulk_set for slices, backlog_set for grab-launched items — and the
|
|
2984
|
+
// allow-list must stay a GATE: an unknown field still throws "not settable" (no free-for-all write path).
|
|
2985
|
+
test("setFields/bulkSet/setItemFields accept receipts + outcome; the allow-list still gates", () => {
|
|
2986
|
+
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: S, status: active, invoke: x }\n - { id: s2, title: T, status: next, invoke: y }\n`;
|
|
2987
|
+
const doc = parseDocument(y);
|
|
2988
|
+
eq(setFields(doc, { invoke: "x", fields: { receipts: { build: "#9" }, outcome: "launch" } }).fields, ["receipts", "outcome"], "both route through SETTABLE");
|
|
2989
|
+
eq(bulkSet(parseDocument(y), { updates: [{ invoke: "x", fields: { outcome: "launch" } }, { invoke: "y", fields: { receipts: { publish: "npm" } } }] }).updated, ["x", "y"], "bulk_set routes receipts + outcome too");
|
|
2990
|
+
validateDocOrThrow(doc);
|
|
2991
|
+
throws(() => setFields(parseDocument(y), { invoke: "x", fields: { nope: 1 } }), "not settable", "the gate still rejects an unknown field");
|
|
2992
|
+
const b = parseDocument("meta:\n schema_version: 1\nitems:\n - { id: b1, title: T, kind: chore, status: open }\n");
|
|
2993
|
+
eq(setItemFields(b, { id: "b1", fields: { receipts: { publish: "npm" } } }).fields, ["receipts"], "grab-launched backlog items carry receipts");
|
|
2994
|
+
});
|
|
2995
|
+
|
|
2996
|
+
// WHY: outcome rollups collapse many slices into ONE founder-review line (done/total) so a wave reads
|
|
2997
|
+
// as one shippable outcome. A slice with NO outcome must not change the render (byte-identical), and
|
|
2998
|
+
// siblings sharing an outcome must group into a SINGLE line — not one per slice, or the rollup is noise.
|
|
2999
|
+
test("renderMarkdown rolls slices up into one OUTCOME line, absent → none", () => {
|
|
3000
|
+
const g = (withOutcome) => ({
|
|
3001
|
+
meta: { schema_version: 1, program: "T" },
|
|
3002
|
+
pis: [{ id: "a", title: "A", status: "active", sprints: [
|
|
3003
|
+
{ id: "s1", title: "One", status: "complete", invoke: "one", ...(withOutcome ? { outcome: "launch" } : {}) },
|
|
3004
|
+
{ id: "s2", title: "Two", status: "active", invoke: "two", ...(withOutcome ? { outcome: "launch" } : {}) },
|
|
3005
|
+
] }],
|
|
3006
|
+
});
|
|
3007
|
+
const md = renderMarkdown(g(true));
|
|
3008
|
+
ok(md.includes("> OUTCOME launch: 1/2 ✅"), "two slices, one done → single 1/2 rollup line");
|
|
3009
|
+
eq((md.match(/OUTCOME launch/g) || []).length, 1, "grouped into ONE line, not one per slice");
|
|
3010
|
+
ok(!renderMarkdown(g(false)).includes("OUTCOME"), "no outcome → no rollup line (byte-identical)");
|
|
3011
|
+
});
|
|
3012
|
+
|
|
2737
3013
|
// WHY: the brief is the only channel to a worker session — if it doesn't forbid sprint/PI
|
|
2738
3014
|
// creation, every helpful agent files follow-up scope and the roadmap doubles.
|
|
2739
3015
|
test("synthesizeBrief carries the temperance contract", () => {
|
|
@@ -2802,11 +3078,33 @@ test("buildPlan waveCloses names the PIs a wave finishes", () => {
|
|
|
2802
3078
|
{ id: "s2", title: "two", status: "next", invoke: "b-two", touches: ["f2"], est_sessions: 1 }, // same file → later wave
|
|
2803
3079
|
]},
|
|
2804
3080
|
]};
|
|
2805
|
-
const plan = buildPlan(g, { cap: 3, disk: null });
|
|
3081
|
+
const plan = buildPlan(g, { cap: 3, disk: null, reviewDebt: 0 });
|
|
2806
3082
|
eq(plan.waveCloses[0], ["a"], "wave 1 closes PI a (b still has contended work)");
|
|
2807
3083
|
ok(plan.waveCloses[plan.waves.length - 1].includes("b"), "the final wave closes b");
|
|
2808
3084
|
});
|
|
2809
3085
|
|
|
3086
|
+
// WHY: the command-lane sort primitive is unit-tested in computeWaves, but `plan` only honors it if
|
|
3087
|
+
// buildPlan threads meta+today into computeWaves — the wiring that connects the feature to the actual
|
|
3088
|
+
// command. Without it an active command lane silently boosts nothing (the exact gap this closes).
|
|
3089
|
+
test("buildPlan honors an active command_lane: the lane slice floats above a higher-priority non-lane slice", () => {
|
|
3090
|
+
const g = (command_lane) => ({
|
|
3091
|
+
meta: { schema_version: 1, program: "T", ...(command_lane ? { command_lane } : {}) },
|
|
3092
|
+
pis: [
|
|
3093
|
+
{ id: "a", title: "A", status: "active", sprints: [
|
|
3094
|
+
{ id: "s1", title: "hi", status: "active", invoke: "a-hi", priority: { tier: "P0" }, touches: ["f1"], est_sessions: 1 } ] },
|
|
3095
|
+
{ id: "b", title: "B", status: "active", sprints: [
|
|
3096
|
+
{ id: "s1", title: "lane", status: "active", invoke: "b-lane", priority: { tier: "P3" }, touches: ["f2"], est_sessions: 1 } ] },
|
|
3097
|
+
],
|
|
3098
|
+
});
|
|
3099
|
+
const lane = { objective: "ship b", until: "2026-12-31", slices: ["b-lane"] };
|
|
3100
|
+
const active = buildPlan(g(lane), { cap: 3, disk: null, reviewDebt: 0, today: "2026-07-13" });
|
|
3101
|
+
eq(active.waves[0][0].invoke, "b-lane", "active lane floats its slice above the P0 non-lane slice");
|
|
3102
|
+
const expired = buildPlan(g(lane), { cap: 3, disk: null, reviewDebt: 0, today: "2027-01-01" });
|
|
3103
|
+
eq(expired.waves[0][0].invoke, "a-hi", "past `until` → priority wins again (lane released)");
|
|
3104
|
+
const none = buildPlan(g(null), { cap: 3, disk: null, reviewDebt: 0, today: "2026-07-13" });
|
|
3105
|
+
eq(none.waves[0][0].invoke, "a-hi", "no command_lane → unchanged (P0 first)");
|
|
3106
|
+
});
|
|
3107
|
+
|
|
2810
3108
|
// ── review-core: the /debrief evidence base ───────────────────────────────────
|
|
2811
3109
|
const oldReviewGraph = {
|
|
2812
3110
|
meta: { schema_version: 1, program: "T" },
|
|
@@ -2823,15 +3121,15 @@ const newReviewGraph = {
|
|
|
2823
3121
|
meta: { schema_version: 1, program: "T", discipline: { capture_ratio: 2 } },
|
|
2824
3122
|
pis: [
|
|
2825
3123
|
{ 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" },
|
|
3124
|
+
{ id: "s1", title: "Login", status: "complete", invoke: "auth-login", prs: ["#12"], est_sessions: 5 }, // shipped — est excluded (done)
|
|
3125
|
+
{ id: "s2", title: "Tokens", status: "blocked", invoke: "auth-tokens", priority: { tier: "P1" }, est_sessions: 2 }, // flip + priority
|
|
3126
|
+
{ id: "s4", title: "Stuck", status: "gated", gated_on: "Connor", invoke: "auth-stuck" }, // held in both — no est (null-safe 0)
|
|
3127
|
+
{ id: "s5", title: "New A", status: "next", invoke: "auth-new-a", est_sessions: 3 }, // added
|
|
2830
3128
|
{ id: "s6", title: "New B", status: "next", invoke: "auth-new-b" }, // added
|
|
2831
3129
|
{ id: "s7", title: "New C", status: "next", invoke: "auth-new-c" }, // added
|
|
2832
3130
|
]}, // s3 pruned
|
|
2833
3131
|
{ id: "billing", title: "Billing", status: "scheduled", sprints: [
|
|
2834
|
-
{ id: "s1", title: "Seed", status: "scheduled", invoke: "billing-seed" },
|
|
3132
|
+
{ id: "s1", title: "Seed", status: "scheduled", invoke: "billing-seed", est_sessions: 1 }, // new PI
|
|
2835
3133
|
]},
|
|
2836
3134
|
],
|
|
2837
3135
|
};
|
|
@@ -2888,6 +3186,29 @@ test("reviewDigest composes counts, reuses sprawlWarnings verbatim, and counts P
|
|
|
2888
3186
|
]}), 2, "started+open counts; untouched and fully-done don't");
|
|
2889
3187
|
});
|
|
2890
3188
|
|
|
3189
|
+
// WHY: a closure budget that only counts PI births hides consolidation — a killed or merged-away
|
|
3190
|
+
// PI must register as a DEATH, or the review over-reports open scope and finishing looks harder
|
|
3191
|
+
// than it is. removedPis must be symmetric to addedPis, not silently zero.
|
|
3192
|
+
test("graphDiff detects removedPis when a PI disappears (symmetric to addedPis)", () => {
|
|
3193
|
+
const gd = graphDiff(newReviewGraph, oldReviewGraph); // billing present in old-arg, gone in new-arg
|
|
3194
|
+
eq(gd.removedPis, [{ id: "billing", title: "Billing" }], "billing died");
|
|
3195
|
+
eq(gd.addedPis, [], "no PI born in this direction");
|
|
3196
|
+
eq(graphDiff(oldReviewGraph, newReviewGraph).removedPis, [], "forward window: billing born, none died");
|
|
3197
|
+
});
|
|
3198
|
+
|
|
3199
|
+
// WHY: the closure budget's "open sessions remaining" is the finish-line number — it must EXCLUDE
|
|
3200
|
+
// already-shipped slices and survive slices carrying no estimate. Counting a done slice inflates
|
|
3201
|
+
// the runway; NaN-ing on a null est_sessions poisons the whole sum. Either makes finishing look
|
|
3202
|
+
// unreachable when it isn't.
|
|
3203
|
+
test("reviewDigest.estOpenSessions sums only non-done est_sessions (null-safe); ratio unchanged", () => {
|
|
3204
|
+
const gd = graphDiff(oldReviewGraph, newReviewGraph);
|
|
3205
|
+
const bd = backlogDiff(null, { meta: { schema_version: 1 }, items: [{ id: "b1", title: "Cap", kind: "bug", status: "open" }] });
|
|
3206
|
+
const d = reviewDigest({ gd, bd, graph: newReviewGraph });
|
|
3207
|
+
eq(d.estOpenSessions, 6, "s2(2)+s5(3)+billing(1)=6; done s1(5) excluded, sprints w/o est count 0");
|
|
3208
|
+
eq(d.removedPis, [], "nothing died this window");
|
|
3209
|
+
eq(d.netGrowth.ratio, 5, "capture-to-ship ratio unchanged by the new rollup");
|
|
3210
|
+
});
|
|
3211
|
+
|
|
2891
3212
|
// WHY: the CLI is the anchor→git-show→digest wiring /debrief trusts — one real-git test
|
|
2892
3213
|
// proves the pathspec, rev resolution, and JSON contract on this platform (Windows included).
|
|
2893
3214
|
test("review.mjs end-to-end in a real git repo: --since <sha> diffs old vs new YAML", () => {
|
|
@@ -3938,6 +4259,72 @@ test("runLog surfaces a rejected log's error and never throws (the no-actuals de
|
|
|
3938
4259
|
rmSync(root, { recursive: true, force: true });
|
|
3939
4260
|
});
|
|
3940
4261
|
|
|
4262
|
+
// ── doctor (drift reconciliation) ────────────────────────────────────────────
|
|
4263
|
+
// A structurally-clean graph so the STRUCTURAL section stays empty — every drift signal in
|
|
4264
|
+
// this test comes from an injected input, proving each detector maps to its own section.
|
|
4265
|
+
const docG = { meta: { schema_version: 1, program: "T" }, pis: [
|
|
4266
|
+
{ id: "a", title: "A", status: "active", sprints: [sp("s1", { invoke: "alpha", status: "next", est_sessions: 1 })] },
|
|
4267
|
+
]};
|
|
4268
|
+
|
|
4269
|
+
// WHY: doctor is the finishing-discipline safety net — if a real drift signal (a merged-but-open
|
|
4270
|
+
// slice, stale docs, a Linear disagreement, a stuck PR, a parked worktree) fails to surface, the
|
|
4271
|
+
// human trusts a roadmap that has silently diverged from reality. Every class must land in the report.
|
|
4272
|
+
test("doctorReport surfaces each drift class as its own section and counts them", () => {
|
|
4273
|
+
const branch = branchFor(flatten(docG).nodes[0], docG); // the slice's real fanout branch
|
|
4274
|
+
const report = doctorReport({
|
|
4275
|
+
graph: docG,
|
|
4276
|
+
mergedPrs: [{ number: 7, headRefName: "x", title: "t", body: "roadmap: slice=alpha" }], // shipped-but-open (marker match)
|
|
4277
|
+
allPrs: [{ number: 9, headRefName: branch, state: "OPEN", isDraft: true, mergeStateStatus: "CLEAN", statusCheckRollup: [] }], // stuck (draft)
|
|
4278
|
+
worktrees: [
|
|
4279
|
+
{ branch: "a/s1", path: "/wt/x", dirty: true, isMerged: false }, // parked + dirty → flagged
|
|
4280
|
+
{ branch: "a/s2", path: "/wt/y", dirty: false, isMerged: true }, // merged + clean → MUST be excluded
|
|
4281
|
+
],
|
|
4282
|
+
renderedVsDisk: { staleDocs: ["docs/SLICES.md"] }, // docs behind the YAML
|
|
4283
|
+
linearDeltas: [{ kind: "slice", key: "alpha", field: "status", from: "next", to: "active", note: null }],
|
|
4284
|
+
});
|
|
4285
|
+
const titles = report.sections.map((s) => s.title);
|
|
4286
|
+
const section = (t) => report.sections.find((s) => s.title === t);
|
|
4287
|
+
ok(titles.includes("Shipped but not marked complete"), "unrecorded merge surfaced");
|
|
4288
|
+
ok(titles.includes("Generated docs stale"), "stale docs surfaced");
|
|
4289
|
+
ok(titles.includes("Linear disagrees with the roadmap"), "linear delta surfaced");
|
|
4290
|
+
ok(titles.includes("Open PRs needing attention"), "stuck PR surfaced");
|
|
4291
|
+
ok(titles.includes("Stale fanout worktrees"), "dirty worktree surfaced");
|
|
4292
|
+
ok(!titles.includes("Structural validation"), "clean graph → no structural noise");
|
|
4293
|
+
ok(section("Shipped but not marked complete").items[0].includes("alpha"), "names the slice");
|
|
4294
|
+
eq(section("Stale fanout worktrees").items.length, 1, "only the unmerged/dirty worktree — the merged+clean one is excluded");
|
|
4295
|
+
eq(report.driftCount, 5, "one signal per class");
|
|
4296
|
+
});
|
|
4297
|
+
|
|
4298
|
+
// WHY: prPhase keys off the NORMALIZED `checks` field, not the raw statusCheckRollup — so doctor must
|
|
4299
|
+
// run each PR through checksOf first, or the "checks-failing" signal can NEVER fire and a red-CI PR
|
|
4300
|
+
// reads as "ready" (the arch-review blocker). A failing non-draft PR must surface as checks-failing.
|
|
4301
|
+
test("doctorReport normalizes the rollup so a non-draft PR with FAILING checks surfaces", () => {
|
|
4302
|
+
const branch = branchFor(flatten(docG).nodes[0], docG);
|
|
4303
|
+
const report = doctorReport({
|
|
4304
|
+
graph: docG,
|
|
4305
|
+
allPrs: [{ number: 11, headRefName: branch, state: "OPEN", isDraft: false, mergeStateStatus: "CLEAN",
|
|
4306
|
+
statusCheckRollup: [{ conclusion: "FAILURE" }] }],
|
|
4307
|
+
});
|
|
4308
|
+
const sec = report.sections.find((s) => s.title === "Open PRs needing attention");
|
|
4309
|
+
ok(sec && sec.items[0].includes("checks-failing"), "red-CI non-draft PR lands as checks-failing, not silently 'ready'");
|
|
4310
|
+
});
|
|
4311
|
+
|
|
4312
|
+
// WHY: a clean roadmap must read as clean — a doctor that cries drift on a reconciled repo trains
|
|
4313
|
+
// the human to ignore it, and the one real signal later gets ignored with the noise.
|
|
4314
|
+
test("doctorReport reports zero drift for a reconciled roadmap", () => {
|
|
4315
|
+
const report = doctorReport({ graph: docG, mergedPrs: [], allPrs: [], worktrees: [], renderedVsDisk: { staleDocs: [] }, linearDeltas: null });
|
|
4316
|
+
eq(report.driftCount, 0, "no inputs → no drift");
|
|
4317
|
+
eq(report.sections.length, 0, "clean → no sections");
|
|
4318
|
+
});
|
|
4319
|
+
|
|
4320
|
+
// WHY: Linear is optional; when it's unconfigured/unreachable the gatherer passes null, and doctor
|
|
4321
|
+
// must SKIP that section rather than emit an empty/garbage one — otherwise an off-Linear repo can
|
|
4322
|
+
// never reach zero drift.
|
|
4323
|
+
test("doctorReport skips the Linear section when deltas are null", () => {
|
|
4324
|
+
const report = doctorReport({ graph: docG, linearDeltas: null });
|
|
4325
|
+
ok(!report.sections.some((s) => s.title === "Linear disagrees with the roadmap"), "null linearDeltas → no section");
|
|
4326
|
+
});
|
|
4327
|
+
|
|
3941
4328
|
await Promise.all(pending);
|
|
3942
4329
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
3943
4330
|
process.exit(failed ? 1 : 0);
|