Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions client/src/hooks/use-consilium-loops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -518,9 +518,9 @@ export function useApproveMerge() {
*/
export function useDevelopLoop() {
const qc = useQueryClient();
return useMutation<ConsiliumLoopRow, Error, { id: string; scope?: "p0" | "all" }>({
// MR-size control: `scope: "p0"` develops only the P0s (small reviewable MR);
// omitted/"all" keeps the historical full-verdict round.
return useMutation<ConsiliumLoopRow, Error, { id: string; scope?: "p0" | "top" | "all" }>({
// MR-size control: `scope: "top"` develops only the highest remaining priority
// tier (small reviewable MR); omitted/"all" keeps the historical full round.
mutationFn: ({ id, scope }) =>
apiRequest("POST", `${LIST_KEY}/${id}/develop`, scope ? { scope } : undefined),
onSuccess: (_data, { id }) => {
Expand Down
30 changes: 19 additions & 11 deletions client/src/pages/ConsiliumLoopDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2806,11 +2806,18 @@ export default function ConsiliumLoopDetail() {
const latestActionPointCount = Array.isArray(latestRound?.openActionPoints)
? latestRound!.openActionPoints.length
: 0;
// MR-size control: P0 slice of the latest verdict — offered as a smaller round.
const latestP0Count = Array.isArray(latestRound?.openActionPoints)
? (latestRound!.openActionPoints as ActionPoint[]).filter((ap) => ap.priority === "P0")
.length
: 0;
// MR-size control (ADR-005 Phase 1): the HIGHEST priority tier still present in
// the latest verdict — offered as a smaller round (mirrors server filterDevScope).
const latestAps: ActionPoint[] = Array.isArray(latestRound?.openActionPoints)
? (latestRound!.openActionPoints as ActionPoint[])
: [];
const tierRankOf = (p?: string): number =>
({ P0: 0, P1: 1, P2: 2, P3: 3 } as Record<string, number>)[p ?? ""] ?? 4;
const topTierRank = latestAps.length
? Math.min(...latestAps.map((ap) => tierRankOf(ap.priority)))
: 4;
const topTierLabel = ["P0", "P1", "P2", "P3", "unprioritized"][topTierRank];
const topTierCount = latestAps.filter((ap) => tierRankOf(ap.priority) === topTierRank).length;
const canDevelop =
isVerdictTerminalLoopState(loop.state) && latestActionPointCount > 0;

Expand Down Expand Up @@ -2887,7 +2894,8 @@ export default function ConsiliumLoopDetail() {
async function handleDevelop(scopeArg?: unknown) {
// Normalize: plain onClick callers pass a click event — only the two literal
// scopes ride to the server; anything else means "all" (historical behaviour).
const scope = scopeArg === "p0" || scopeArg === "all" ? scopeArg : undefined;
const scope =
scopeArg === "p0" || scopeArg === "top" || scopeArg === "all" ? scopeArg : undefined;
setDevelopScopeOpen(false);
try {
await developLoop.mutateAsync({ id, scope });
Expand Down Expand Up @@ -2965,9 +2973,9 @@ export default function ConsiliumLoopDetail() {
<Button
size="sm"
onClick={() =>
// A meaningful scope choice exists only when the verdict has BOTH
// P0s and non-P0s — otherwise hand off the full list directly.
latestP0Count > 0 && latestP0Count < latestActionPointCount
// A meaningful scope choice exists only when the top tier is a
// strict subset of the verdict — otherwise hand off directly.
topTierCount > 0 && topTierCount < latestActionPointCount
? setDevelopScopeOpen(true)
: handleDevelop()
}
Expand Down Expand Up @@ -3219,8 +3227,8 @@ export default function ConsiliumLoopDetail() {
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => void handleDevelop("p0")}>
Only P0s ({latestP0Count}) — small MR
<AlertDialogAction onClick={() => void handleDevelop("top")}>
Only {topTierLabel} tier ({topTierCount}) — small MR
</AlertDialogAction>
<AlertDialogAction onClick={() => void handleDevelop("all")}>
All action points ({latestActionPointCount})
Expand Down
64 changes: 64 additions & 0 deletions docs/adr/ADR-005-mr-splitting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# ADR-005: Splitting a large verdict into several small, well-sized MRs

- **Status**: accepted (Phase 1 implemented; Phases 2–3 proposed)
- **Date**: 2026-07-29
- **Context anchor**: PDO-819 develop round shipped ALL 6 action points in one MR
(~100 changed files across many components) — the operator closed it as
unreviewable. ADR-004 fixed *what* an MR is (a result); this ADR fixes *how big*
one may be.

## Problem

A develop round implements the WHOLE verdict in one branch → one MR. A verdict
routinely carries 5–10 heavyweight action points across priority tiers (P0..P3),
so the single MR grows beyond human review capacity. The loop's value collapses
exactly at its human gate.

## Decision

An MR must be a REVIEWABLE unit. The loop reaches the full verdict through
multiple small MRs, using its own round mechanism as the splitter.

### Phase 1 — tier-per-round (implemented)

`develop(loopId, { scope })` with `scope: "top"` filters the verdict to the
HIGHEST priority tier still present (P0 → P1 → P2 → P3 → unprioritized;
`filterDevScope`, pure + unit-tested). Flow:

```
verdict (2×P0 + 3×P1 + 2×P2)
→ develop scope:"top" → MR₁ = the 2 P0s → review → merge
→ next review round (auto) → verdict = remainder
→ develop scope:"top" → MR₂ = the 3 P1s → review → merge
→ …until convergence
```

The UI's "Hand off to SDLC" opens a chooser — "Only <tier> (n) — small MR" vs
"All action points (m)" — whenever the top tier is a strict subset. Sequential by
design: each tier waits for the previous merge, which keeps every MR based on
reviewed, merged code (no stacked-conflict class).

### Phase 2 — stacked MRs in one round (proposed)

For operators who want the whole verdict IN FLIGHT at once: the executor splits
the verdict into tier chunks, builds them in one worktree lineage, and pushes a
BRANCH STACK — MR₁ (P0 → main), MR₂ (P1 → branch₁), MR₃ (P2 → branch₂) — reviewed
and merged bottom-up (Graphite-style). Requires FSM surgery: `prRef` becomes a
stack, `awaiting_merge` gates each level, partial-merge states. Deliberately a
separate change; Phase 1 covers the need sequentially.

### Phase 3 — size-aware grouping ("правильно оценена")

Priority is a proxy; the real budget is blast radius. The planner estimates the
files each action point touches (repo-map assisted) and groups APs into MR-sized
chunks under a byte/file budget (e.g. ≤20 files per MR), splitting a tier or
merging small tiers as needed. The verdict then presents estimated MR sizes
BEFORE the operator commits to a scope.

## Consequences

- Phase 1 changes no FSM semantics: absent scope stays byte-identical; each round
still produces exactly one MR, only its contents narrow.
- Multi-round convergence takes more wall-clock (review+merge per tier) — the
accepted cost of reviewability.
- Phases 2–3 are additive follow-ups; nothing in Phase 1 blocks them.
4 changes: 2 additions & 2 deletions server/routes/consilium-loops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,8 +403,8 @@ export function registerConsiliumLoopRoutes(
// MR-size control: optional `scope` narrows the round to the P0s — the operator
// ships a small reviewable MR and lets the next round carry the remainder.
const rawScope = (req.body as { scope?: unknown } | undefined)?.scope;
if (rawScope !== undefined && rawScope !== "p0" && rawScope !== "all") {
return res.status(400).json({ error: 'scope must be "p0" or "all"' });
if (rawScope !== undefined && rawScope !== "p0" && rawScope !== "top" && rawScope !== "all") {
return res.status(400).json({ error: 'scope must be "p0", "top" or "all"' });
}
const result =
rawScope !== undefined
Expand Down
37 changes: 28 additions & 9 deletions server/services/consilium/consilium-loop-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,28 @@ const SDLC_DEV_MAX_ACTION_POINTS = 24;
*/
export const SDLC_DEV_REDRIVE_GRACE_MS = SDLC_DEV_GRACE_MS * SDLC_DEV_MAX_ACTION_POINTS;

/**
* MR-size control (ADR-005 Phase 1): the develop-scope filter. `"top"` keeps ONLY
* the highest-priority tier still present in the verdict (P0 → P1 → P2 → P3 →
* unprioritized), so each round ships one small, reviewable MR and the loop's next
* round naturally carries the following tier. `"p0"` is the narrower historical
* scope (empty once the P0s are merged); `"all"`/absent is the full verdict.
* Pure + exported for direct unit-testing.
*/
export function filterDevScope(
actionPoints: readonly ActionPoint[],
scope?: "p0" | "top" | "all",
): ActionPoint[] {
if (scope === "p0") return actionPoints.filter((ap) => ap.priority === P0_PRIORITY);
if (scope === "top") {
const TIER_RANK: Record<string, number> = { P0: 0, P1: 1, P2: 2, P3: 3 };
const rank = (ap: ActionPoint): number => TIER_RANK[ap.priority ?? ""] ?? 4;
const best = Math.min(...actionPoints.map(rank));
return actionPoints.filter((ap) => rank(ap) === best);
}
return [...actionPoints];
}

/**
* Liveness heartbeat: minimum interval between `updated_at` touches of the loop
* row from the dispatchSdlc progress sink. A LIVE multi-AP round beats at every
Expand Down Expand Up @@ -1257,7 +1279,7 @@ export class ConsiliumLoopController {
*/
async develop(
loopId: string,
opts?: { scope?: "p0" | "all" },
opts?: { scope?: "p0" | "top" | "all" },
): Promise<DevelopResult> {
const loop = await this.storage.getLoop(loopId);
if (!loop) return { ok: false, code: "NOT_FOUND" };
Expand All @@ -1266,14 +1288,11 @@ export class ConsiliumLoopController {
// FULL action points (ALL priorities) — SERVER-READ from the verdict; the
// close-out reads only `openActionPoints`, but openP0 feeds the round audit.
const allActionPoints = await this.resolveDevActionPoints(loop);
// MR-SIZE CONTROL: `scope: "p0"` develops ONLY the P0s — one small, reviewable
// MR per round; after its merge the loop's next review round re-derives the
// remainder and the operator ships it as the NEXT round's MR. Default "all"
// (absent opts) is byte-identical to the historical full-list handoff.
const actionPoints =
opts?.scope === "p0"
? allActionPoints.filter((ap) => ap.priority === P0_PRIORITY)
: allActionPoints;
// MR-SIZE CONTROL (ADR-005 Phase 1): scope the round — `"top"` ships only the
// highest remaining priority tier as one small MR; after its merge the next
// review round re-derives the remainder (next tier). Default "all" (absent
// opts) is byte-identical to the historical full-list handoff.
const actionPoints = filterDevScope(allActionPoints, opts?.scope);
if (actionPoints.length === 0) return { ok: false, code: "NO_ACTION_POINTS" };

// Re-validate the persisted repoPath: global allowlist THEN project workspace.
Expand Down
11 changes: 11 additions & 0 deletions tests/unit/consilium/consilium-loops-develop-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,17 @@ describe("POST /api/consilium-loops/:id/develop", () => {
});
});

it('MR-size control: scope:"top" passes through (highest remaining tier)', async () => {
const { app, controller } = makeApp();
const res = await request(app)
.post(`/api/consilium-loops/${LOOP_ID}/develop`)
.send({ scope: "top" });
expect(res.status).toBe(200);
expect(controller.develop as ReturnType<typeof vi.fn>).toHaveBeenCalledWith(LOOP_ID, {
scope: "top",
});
});

it("MR-size control: an unknown scope → 400, develop NOT called", async () => {
const { app, controller } = makeApp();
const res = await request(app)
Expand Down
21 changes: 21 additions & 0 deletions tests/unit/consilium/loop-fsm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
pickJudgeOutput,
composeCancelExplanation,
ConsiliumLoopController,
filterDevScope,
MAX_CONCURRENT_DEV_HANDOFFS,
SDLC_DEV_REDRIVE_GRACE_MS,
type LoopEvent,
Expand Down Expand Up @@ -1448,3 +1449,23 @@ describe("developing liveness heartbeat — one-coder-sized redrive (no babysitt
await flush();
});
});

describe("filterDevScope (ADR-005 Phase 1 — MR-size control)", () => {
const aps = [
{ title: "a", priority: "P1" },
{ title: "b", priority: "P2" },
{ title: "c", priority: "P1" },
{ title: "d" }, // unprioritized
];
it('"top" keeps only the highest tier still present (P1 here — the P0s are merged)', () => {
expect(filterDevScope(aps, "top").map((ap) => ap.title)).toEqual(["a", "c"]);
});
it('"top" falls through to the unprioritized bucket when nothing carries a tier', () => {
expect(filterDevScope([{ title: "x" }, { title: "y" }], "top")).toHaveLength(2);
});
it('"p0" / "all" / absent keep their historical shapes', () => {
expect(filterDevScope(aps, "p0")).toHaveLength(0);
expect(filterDevScope(aps, "all")).toHaveLength(4);
expect(filterDevScope(aps)).toHaveLength(4);
});
});
Loading