#!/usr/bin/env node // PreToolUse(Agent) — block dispatching a per-ticket merger until the // quality-reviewer has recorded APPROVED for that ticket. With no SendMessage // handshake, nothing else stops the orchestrator from going developer -> merger // directly and skipping review; this gate enforces the // dev -> reviewer -> (APPROVED) -> merger ordering structurally. // // Verdicts are recorded by record-review-verdict.mjs (SubagentStop) as flags // under /reviews/-. Skipped for the SIMPLE flow, the // migration round, promotion-only, and rollback dispatches (no per-ticket // review), and when the ticket can't be identified (fail open, never wedge the flow). import { existsSync, readFileSync } from "node:fs"; import { createHookContext } from "./lib/context.mjs"; import { parseDispatch } from "./lib/dispatch-parse.mjs"; import { REVIEW_ROLES, reviewFlag } from "./lib/reviews.mjs"; const input = JSON.parse(readFileSync(0, "utf8")); const ctx = createHookContext(input, "block-merger-without-review"); const d = parseDispatch(input); if (d.subagentType !== "merger") process.exit(0); // only gate merger dispatches if (d.mode === "promote") process.exit(0); // promotion-only carries no per-ticket review if (["SIMPLE", "MIGRATION", "PROMOTE", "ROLLBACK"].includes(d.taskId)) process.exit(0); if (!/^TASK-\d+$/.test(d.taskId)) process.exit(0); // can't identify a ticket → fail open const missing = REVIEW_ROLES.filter( (role) => !existsSync(reviewFlag(ctx, d.taskId, role)), ); if (missing.length === 0) process.exit(0); // APPROVED → allow the merge ctx.fail( `Refusing to dispatch the merger for ${d.taskId}: no APPROVED verdict from ${missing.join(" and ")} yet.\n` + "The flow is: developer -> quality-reviewer -> (APPROVED) -> merger.\n" + `Dispatch quality-reviewer-${d.taskId} first (STATE B transition), then dispatch the merger only after it returns APPROVED.`, { log: `BLOCK ${d.taskId} missing=${missing.join(",")}` }, );