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
9 changes: 6 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,12 @@ export function useApproveMerge() {
*/
export function useDevelopLoop() {
const qc = useQueryClient();
return useMutation<ConsiliumLoopRow, Error, string>({
mutationFn: (id) => apiRequest("POST", `${LIST_KEY}/${id}/develop`),
onSuccess: (_data, id) => {
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.
mutationFn: ({ id, scope }) =>
apiRequest("POST", `${LIST_KEY}/${id}/develop`, scope ? { scope } : undefined),
onSuccess: (_data, { id }) => {
qc.invalidateQueries({ queryKey: [LIST_KEY, id] });
qc.invalidateQueries({ queryKey: [LIST_KEY] });
},
Expand Down
58 changes: 53 additions & 5 deletions client/src/pages/ConsiliumLoopDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1586,7 +1586,7 @@ function ReviewGateActions({

async function handleDevelop() {
try {
await developLoop.mutateAsync(loopId);
await developLoop.mutateAsync({ id: loopId });
toast({
title: "Handed off to SDLC",
description: "The developing round has started — follow the progress below.",
Expand Down Expand Up @@ -2757,6 +2757,8 @@ export default function ConsiliumLoopDetail() {
const approveMerge = useApproveMerge();
const developLoop = useDevelopLoop();
const [confirmOpen, setConfirmOpen] = useState(false);
// MR-size control: the develop-scope chooser (Only P0s vs all action points).
const [developScopeOpen, setDevelopScopeOpen] = useState(false);

if (isLoading) {
return (
Expand Down Expand Up @@ -2804,6 +2806,11 @@ 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;
const canDevelop =
isVerdictTerminalLoopState(loop.state) && latestActionPointCount > 0;

Expand Down Expand Up @@ -2877,12 +2884,19 @@ export default function ConsiliumLoopDetail() {
}
}

async function handleDevelop() {
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;
setDevelopScopeOpen(false);
try {
await developLoop.mutateAsync(id);
await developLoop.mutateAsync({ id, scope });
toast({
title: "Handed off to SDLC",
description: "The developing round has started — follow the progress below.",
description:
scope === "p0"
? `Developing only the P0 action points — merge that MR and the next round carries the rest.`
: "The developing round has started — follow the progress below.",
});
} catch (err) {
// 400 (NO_ACTION_POINTS / REPO_NOT_*) | 409 (WRONG_STATE /
Expand Down Expand Up @@ -2950,7 +2964,13 @@ export default function ConsiliumLoopDetail() {
{canDevelop && (
<Button
size="sm"
onClick={handleDevelop}
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
? setDevelopScopeOpen(true)
: handleDevelop()
}
disabled={developLoop.isPending}
>
{developLoop.isPending ? (
Expand Down Expand Up @@ -3181,6 +3201,34 @@ export default function ConsiliumLoopDetail() {
</div>

{/* Approve-merge confirm — the autonomy→production HITL gate */}
{/* MR-size control: choose the develop round's scope. One MR per round —
shipping only the P0s keeps it reviewable; the loop's NEXT round
re-derives and carries the remainder after the merge. */}
<AlertDialog open={developScopeOpen} onOpenChange={setDevelopScopeOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<Hammer className="h-5 w-5 text-primary" />
How much of the verdict should this round implement?
</AlertDialogTitle>
<AlertDialogDescription>
Each develop round produces ONE merge request. A smaller round is easier
to review — after you merge it, the next review round picks up the
remaining action points automatically.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => void handleDevelop("p0")}>
Only P0s ({latestP0Count}) — small MR
</AlertDialogAction>
<AlertDialogAction onClick={() => void handleDevelop("all")}>
All action points ({latestActionPointCount})
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
Expand Down
11 changes: 10 additions & 1 deletion server/routes/consilium-loops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,16 @@ export function registerConsiliumLoopRoutes(
app.post("/api/consilium-loops/:id/develop", async (req: Request, res: Response) => {
const auth = await authorizeConsiliumLoop(req, res, storage, String(req.params.id));
if (!auth) return;
const result = await controller.develop(auth.loop.id);
// 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"' });
}
const result =
rawScope !== undefined
? await controller.develop(auth.loop.id, { scope: rawScope })
: await controller.develop(auth.loop.id);
if (result.ok) {
const isAdmin = req.user?.role === "admin";
// 200 + the masked loop row (state is now "developing"); the client polls
Expand Down
15 changes: 13 additions & 2 deletions server/services/consilium/consilium-loop-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1255,14 +1255,25 @@ export class ConsiliumLoopController {
* developing phase runs, via a synthetic verdict carrying the FULL action-point
* list (the close-out reads only `verdict.openActionPoints`).
*/
async develop(loopId: string): Promise<DevelopResult> {
async develop(
loopId: string,
opts?: { scope?: "p0" | "all" },
): Promise<DevelopResult> {
const loop = await this.storage.getLoop(loopId);
if (!loop) return { ok: false, code: "NOT_FOUND" };
if (!isDevelopPromotable(loop.state, loop.reviewGate)) return { ok: false, code: "WRONG_STATE" };

// FULL action points (ALL priorities) — SERVER-READ from the verdict; the
// close-out reads only `openActionPoints`, but openP0 feeds the round audit.
const actionPoints = await this.resolveDevActionPoints(loop);
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;
if (actionPoints.length === 0) return { ok: false, code: "NO_ACTION_POINTS" };

// Re-validate the persisted repoPath: global allowlist THEN project workspace.
Expand Down
21 changes: 21 additions & 0 deletions tests/unit/consilium/consilium-loops-develop-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,27 @@ describe("POST /api/consilium-loops/:id/develop", () => {
expect((controller.develop as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(LOOP_ID);
});

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

it("MR-size control: an unknown scope → 400, develop NOT called", async () => {
const { app, controller } = makeApp();
const res = await request(app)
.post(`/api/consilium-loops/${LOOP_ID}/develop`)
.send({ scope: "everything" });
expect(res.status).toBe(400);
expect(String(res.body.error)).toContain("scope");
expect(controller.develop).not.toHaveBeenCalled();
});

it("admin keeps createdBy in the masked row", async () => {
const { app } = makeApp({ user: { id: "admin-x", role: "admin" } });
const res = await request(app).post(`/api/consilium-loops/${LOOP_ID}/develop`).send();
Expand Down
Loading