#!/usr/bin/env node // PreToolUse(Bash): a Stage-A-only merger (a per-ticket wave merge, or any // dispatch carrying STAGE: a-only) must NEVER run Stage B: promoting the session // branch into the base branch. Runtime enforcement of merger.md ("TASK-XXX -> // Stage A only; promotion runs once at the end via a separate MODE: promote dispatch"). // // Root cause it closes (forensic, session 7ba88e76): a wave merger dispatched as // `TASK_ID: TASK-001` (no MODE, no STAGE) ran `git checkout -f ` + // `git merge --no-ff session/ -m "merge(session)"`, promoting partial wave-1 // work onto the base branch. The merger never checks the persona, so this is // mode-agnostic: in a NORMAL run it would corrupt the real base branch (or main) // mid-flow. A technical-only guard would merely mask it, so the guard is universal. // // Authorization is recorded at dispatch by record-merger-stage.mjs into // /merger-stage.json. Fails CLOSED: a missing/unreadable marker blocks // the promotion: the running merger cannot prove it is authorized. import { readFileSync } from "node:fs"; import { join } from "node:path"; import { createHookContext } from "./lib/context.mjs"; import { isMerger } from "./lib/teams.mjs"; import { sessionBranch } from "./lib/topology.mjs"; const input = JSON.parse(readFileSync(0, "utf8")); const ctx = createHookContext(input, "block-wave-merger-promote"); // Only the merger runs Stage B; every other agent is out of scope for this guard. if (![ctx.agentName, ctx.agentType].some(isMerger)) process.exit(0); const cmd = input.tool_input?.command || ""; if (!cmd) process.exit(0); // Stage-B signature: a `git merge` that pulls in the session branch (promotion), // or the promotion commit message. Stage A merges a /TASK-XXX branch INTO // the session branch and never names session/ as a merge argument, so it // does not match. const session = sessionBranch(ctx); // session/ const isStageBPromotion = /\bgit\s+merge\b/.test(cmd) && (cmd.includes(session) || /merge\(session\)/.test(cmd)); if (!isStageBPromotion) process.exit(0); // Read the dispatch-time authorization marker. Fail CLOSED. let promote = false; let markerSeen = false; try { const marker = JSON.parse( readFileSync(join(ctx.sessionDir, "merger-stage.json"), "utf8"), ); markerSeen = true; promote = marker.promote === true; } catch { promote = false; // no/unreadable marker -> not authorized } if (promote) process.exit(0); ctx.fail( "Blocked: this merger is a Stage-A-only dispatch and must NOT promote the session branch to the base branch.\n\n" + `It tried to run Stage B (promotion):\n ${cmd}\n\n` + (markerSeen ? "Its dispatch is not authorized to promote (a per-ticket TASK-XXX wave merge, or STAGE: a-only)." : "No promotion-authorization marker was found for this session (fail-closed).") + "\n\nStage B runs ONLY via a dedicated `MODE: promote` merger after the LAST wave, or a SIMPLE / MIGRATION single-shot. " + `A per-ticket wave merger runs Stage A ONLY: merge /TASK-XXX into ${session} and stop. ` + 'See merger.md ("TASK-XXX -> Stage A only") and orchestrator.md\'s promotion block.', { log: `BLOCK stage-A merger tried Stage B (markerSeen=${markerSeen})` }, );