--- name: pr-autofix version: 1.0.0 description: Fix PRs autonomously. Triage open PRs by tier, address thread feedback, fix CI failures, and enable auto-merge when the 4-condition Ready-to-Merge gate passes. Use when you say `pr-autofix`, `autofix this pr`, or `monitor open prs`. Do NOT use to answer review comments interactively on one PR (use pr-review), and do NOT use to run the six quality axes over your working changes (use pr-quality-all). license: MIT argument-hint: "[pull-request|mode]" allowed-tools: Bash, Read, Edit, Write, Skill, mcp__github__pull_request_read, mcp__github__issue_read, mcp__github__get_check_run, mcp__github__get_job_logs size-exception: true user-invocable: true metadata: routing: role: conditional-adjunct invoker: pr-review trigger: pr-review redirects a red check with no review comments here user-facing: true --- # PR Autofix Autonomous PR monitor and fixer. This file carries the whole protocol, including the Ready-to-Merge definition below. Two bundled dependencies are required beyond it, both from the github skill: Phase 0 runs its transport preflight, and MCP mode reads its routing reference. Everything else needed to run this skill is here. Migrated from the pr-autofix command under ADR-064, which makes skills the single user-invocable surface. The command file is gone, so its path is named here in plain text rather than as a citation to something a reader could open. ## Triggers | Trigger phrase | Operation | |----------------|-----------| | `pr-autofix` | Triage all open PRs by tier and act | | `autofix this pr` | Single-PR mode on the current branch's open PR | | `monitor open prs` | Periodic triage without merging | | `auto-merge ready prs` | Tier 1 only: enable auto-merge on land-ready PRs | | `address pr feedback` | Tier 3/4 only: walk thread lifecycle | ## Process Four phases. Tier-based dispatch decides which actions apply per PR. ### Phase 0: Transport preflight (BLOCKING, runs once) Every script this command calls reaches GitHub through `gh`. An agent sandbox that proxies egress leaves `gh` installed and `GH_TOKEN` set while refusing GitHub for the whole session, so each script fails with HTTP 403 and none of those failures says anything about the PR. Decide the transport once, before triage: ```bash # CLAUDE_PLUGIN_ROOT is set in a vendored install; falls back to .claude in-repo. SCRIPTS_DIR="${COPILOT_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-.claude}}/skills/github/scripts/utils" python3 "$SCRIPTS_DIR/check_github_transport.py" ``` Non-POSIX hosts do not run the block above. The authoritative, per-harness launcher for this one command is `check_transport` in the `scripts` map of `pr-review-config.yaml`: the `copilot` entry runs it under PowerShell and resolves the interpreter (`python3`, then `py -3`, then `python`), because a Windows host may expose only the launcher. Read it from the map for the harness you are on rather than transcribing a second copy here, which would drift. | Verdict | Action | |---------|--------| | `Transport: gh` | Continue. Every script below works as written. | | `Transport: gh_unusable` | Continue in MCP mode; see the rule block below. | | exit 3 | A quota window or a transport wobble, not a transport change. Stop and retry later. | | exit 4 | A credential fault. Stop and report it; do not switch transports around a fixable token. | **MCP mode rules.** The mapping from each script to its GitHub MCP operation is in the github skill's `references/transport-routing.md`, which also gives the per-harness spelling for the operation names. On top of it: 1. Read operations (triage, CI status, check logs, review threads, PR and issue bodies) all have MCP equivalents. Use them freely. Write operations (replies, resolving threads, arming auto-merge, merging) also have MCP equivalents, but rule 3 governs whether you may use them at all, and the grant in this file's frontmatter does not include any of them. Do not read this row as permission: it says the tool exists, not that the operation is allowed here. Pushing is not on that list and has no MCP equivalent. `push_files` builds a new remote commit out of file contents you supply; it does not transfer local commits or branch history, so it cannot stand in for `git push`. 2. `test_pr_merge_ready.py`, `check_pr_live_state.py`, `why_pr_blocked.py`, `triage_red_check.py`, `run_completion_gate.py`, `check_pr_round_cap.py`, have no MCP equivalent, because each computes a verdict from several `gh` calls plus local logic. `triage_red_check.py` is named explicitly because the CI-failure triage step below makes it mandatory before any log reading, and a blocked session must derive that verdict by hand rather than falling back to `gh`. Most of their inputs are reachable: gather the same fields with `pull_request_read` and apply the gate definitions in this file by hand. Record in the PR handoff that the verdict was derived, not scripted. One input is not reachable, and it is load-bearing. No exposed MCP operation reads a branch ruleset, so the required-context set is unavailable, which is exactly the case where a check-run list reads clean on a PR that cannot merge. `test_pr_merge_ready.py` therefore cannot be reconstructed as a PASS at all. Report the checks and merge state you did read, say the required-context set was not read, and treat merge readiness as unknown. 3. The round cap still applies in this mode: it is what stops a T3/T4 PR iterating without bound, and it is reconstructed by hand here like the other verdicts in rule 2. Concurrency between two sessions working the same branch is held by the Force-Push Safety SHA gate below, which is a hard gate rather than an advisory one: it re-reads the ref immediately before the push and pins `--force-with-lease` to an observed SHA, so a competing session's commit makes the push fail rather than silently win. Git is also a separate transport from the GitHub API (issue #3139 records a push succeeding while the API was failing), so a session refused for the API can still push, and the SHA gate is what makes that safe. 4. A transport failure is an unknown, never a verdict. Never classify a PR as T2 (CI fix), BLOCKED, or DIRTY because a call failed. Report the PR as untriaged with the transport as the reason. ### Phase 1: Triage Run `test_pr_merge_ready.py` for every open PR. Classify each into a tier (T1-T5) using the table below. Sort the queue by tier ascending. ### Phase 2: Act per tier Walk the queue. For each PR, apply the tier's action set. T1 first (land-ready), then T2 (CI fix), then T3/T4 (threads). T5 applies to bot-authored PRs that pass the merge-state gates (not BEHIND or DIRTY) but still have a failure or unresolved threads, including a BLOCKED one (issue #5549); the tier-dispatch block terminates such a PR after the auto-merge disarm gate so a human handles it (issue #5208). A bot PR that is BEHIND or DIRTY stays at that merge-state tier and the automated loop processes it. **Per-PR live-state gate (BLOCKING, issue #2455).** Before any action runs on a PR (any tier: arming auto-merge, pushing a CI fix, posting a thread reply), call `check_pr_live_state.py` and branch on the JSON envelope `Data.action` field. The session-start triage snapshot is stale by the time the walk reaches each row in a repo with heavy merge automation, and the consequences of acting on a stale row are concrete: armed auto-merge on a redundant PR, conflict merges into a closed branch, duplicate logic landed twice. ```bash # One outer fetch covers all per-PR calls; --skip-fetch keeps the loop cheap. git fetch --quiet origin "+refs/heads/main:refs/remotes/origin/main" resolve_pr_scripts_dir() { repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" for root in \ "${COPILOT_PLUGIN_ROOT:-}" \ "${CLAUDE_PLUGIN_ROOT:-}" \ "$repo_root/.claude" \ "${HOME:-}/.copilot/installed-plugins/_direct/project-toolkit" \ "${HOME:-}/.copilot/installed-plugins"/*/project-toolkit \ "${HOME:-}/.claude/plugins/cache"/*/project-toolkit; do if [ -n "$root" ] && [ -d "$root/skills/github/scripts/pr" ]; then printf '%s\n' "$root/skills/github/scripts/pr" return 0 fi done printf '%s\n' ".claude/skills/github/scripts/pr" } SCRIPTS_DIR="$(resolve_pr_scripts_dir)" # late-live-state-guard:start recheck_pr_live_state() { local late_live late_rc late_action late_reason late_state late_head late_base if late_live=$(python3 "$SCRIPTS_DIR/check_pr_live_state.py" \ --pull-request "$PR" --skip-fetch \ --expected-head-sha "${EXPECTED_HEAD_SHA:-}" \ --expected-base-ref "${EXPECTED_BASE_REF:-}" \ --expected-base-sha "${EXPECTED_BASE_SHA:-}" \ --output-format json); then late_rc=0 else late_rc=$? fi late_action=$(printf '%s' "$late_live" | jq -r '.Data.action // empty' 2>/dev/null) if [ "$late_rc" -eq 0 ] && [ "$late_action" = "ACT" ]; then return 0 fi late_reason=$(printf '%s' "$late_live" | jq -r '.Data.reason // "live-state check failed"' 2>/dev/null) late_state=$(printf '%s' "$late_live" | jq -r '.Data.state // "UNKNOWN"' 2>/dev/null) late_head=$(printf '%s' "$late_live" | jq -r '.Data.head_sha // "unknown"' 2>/dev/null) late_base=$(printf '%s' "$late_live" | jq -r '.Data.base_ref // "main"' 2>/dev/null) echo "Skipping mutation for #$PR: $late_reason" if [ "$late_state" = "MERGED" ]; then echo "Merged head SHA: $late_head" echo "Preserve unpushed commits or a net patch. Reapply them on a follow-up branch from current origin/$late_base." elif [ "$late_state" = "CLOSED" ]; then echo "Closed PR head SHA: $late_head" echo "Preserve unpushed commits or a net patch before leaving the old branch." fi return 75 } run_pr_mutation_if_live() { if recheck_pr_live_state; then "$@" return $? fi return 75 } # late-live-state-guard:end # SESSION_ID must be set before the loop (e.g. from the session log or a uuid). # Per PR, immediately before any per-tier action: # Step 1: Live-state gate (BLOCKING, issue #2455). LIVE=$(python3 "$SCRIPTS_DIR/check_pr_live_state.py" \ --pull-request "$PR" --skip-fetch --output-format json) ACTION=$(echo "$LIVE" | jq -r '.Data.action') if [ "$ACTION" = "SKIP" ]; then REASON=$(echo "$LIVE" | jq -r '.Data.reason') echo "Skipping #$PR: $REASON" # If Data.superseded_by_base.fully_superseded == true, recommend close # via the queue's close-handling path; do NOT push or merge. continue fi EXPECTED_HEAD_SHA=$(echo "$LIVE" | jq -r '.Data.head_sha // empty') EXPECTED_BASE_REF=$(echo "$LIVE" | jq -r '.Data.base_ref // empty') EXPECTED_BASE_SHA=$(echo "$LIVE" | jq -r '.Data.base_sha // empty') if [ -z "$EXPECTED_HEAD_SHA" ] || [ -z "$EXPECTED_BASE_REF" ] || [ -z "$EXPECTED_BASE_SHA" ]; then echo "Cannot bind mutation to the live PR identity for #$PR; skipping." continue fi # ACTION == "ACT": proceed with the tier's planned action set. # tier-dispatch:start # Step 2.5: Tier read, then the auto-merge disarm gate, then the round-cap # circuit breaker (BLOCKING for T3/T4, issue #5056). The two gates run in that # order for the reason given at the disarm gate below; do not swap them back. # T3/T4 PRs iterate: post a fix, wait for CI or a bot review, repeat. Nothing # capped how many times that loop could run, and prose caps have been # ignored repeatedly (the documented failure mode, not a hypothetical one): # PR #1887 ran 11+ bot review rounds over 46 hours wall clock before a human # intervened (see the pr-1887-iteration-paradox retrospective in this repo's # rjmurillo/ai-agents source, contributor-only); PRs #1965 and #1979 each ran # 18 rounds (see the CI-FEEDBACK-SUBLOOP governance doc, same source, line # 11). check_pr_round_cap.py records one round per call against a # hidden marker comment on the PR (stored as # a hidden marker comment) and returns Data.action=ESCALATE when # either the round count or the wall-clock budget is exceeded. Call it once # per pass through this loop for a T3/T4 PR, after the tier is known and after # the auto-merge disarm gate below, before any thread-lifecycle or CI-fix # action. It used to say "immediately after the tier is known", which was true # until the disarm gate moved ahead of it to close CWE-284; the disarm gate and # the unknown-tier exit now both run in between. # Tier comes from test_pr_merge_ready.py, the authoritative tier source; # check_pr_live_state.py emits no tier field, so reading $LIVE here pins TIER # at UNKNOWN. Computed once, also consumed by the auto-merge disarm gate below. # Read `.Tier`, not `.Data.Tier`: unlike the github_core.output emitters, # test_pr_merge_ready.py has no --output-format flag and prints its result # dict directly (`print(json.dumps(result, indent=2))`), so there is no Data # envelope to traverse. # A pinned UNKNOWN breaks the two gates in OPPOSITE directions, so do not # read it as "the gates turn off": this gate tests TIER = T3 or T4, which # UNKNOWN never satisfies, so it goes inert; the disarm gate below tests # TIER != T1, which UNKNOWN always satisfies, so it fires on every armed PR # and strips auto-merge from genuine T1 PRs too. # tests/commands/test_pr_autofix_field_contract.py checks every read in this # file against its producer's real schema, and # tests/commands/test_pr_autofix_tier_dispatch_runtime.py executes the block # between the tier-dispatch markers under bash with fake producers, so the two # gate directions below are asserted behavior rather than described behavior. if ! AUTHOR_CTX=$(python3 "$SCRIPTS_DIR/get_pr_context.py" --pull-request "$PR" \ --field author_is_bot --output-format json 2>/dev/null); then # Focused mode keeps this read off the review-thread pagination path. A # stale installed helper can still reject `--field`; fall back once so the # existing absent-field diagnostic keeps naming a pre-#5208 copy rather # than collapsing it into the generic unreadable branch. if ! AUTHOR_CTX=$(python3 "$SCRIPTS_DIR/get_pr_context.py" --pull-request "$PR" \ --output-format json 2>/dev/null); then AUTHOR_CTX="" fi fi # Bot-author lookup, read BEFORE the tier producer because the tier depends on # it (issue #5208). classify_tier only reaches T5 when `is_bot and # (has_ci_failures or has_threads)`, and its `is_bot` parameter defaults to # False, so a producer call that omits --is-bot cannot return T5 at all: every # affected bot PR that reached work-tier classification came back T2, T3, or T4 # and was processed in the unattended loop. T5 is reached only when the # merge state passes the earlier gates (not BEHIND or DIRTY) AND # `is_bot and (has_ci_failures or has_threads)`. BLOCKED with a failure or a # thread reaches T5 too (issue #5549). A bot PR that is BEHIND or DIRTY stays # at its merge-state tier and does not reach the T5 handoff, which is correct: # the automated loop handles it. # Author identity is stable during one pass, so this answer is kept for tier # production. The focused `--field author_is_bot` mode keeps the read down to # one `gh pr view --json author` call instead of a full context walk. Auto-merge # is mutation-sensitive and is fetched again at the disarm gate below, in the # matching `--field auto_merge_method` mode. That split is deliberate: the # one-fetch contract is retired because a stale auto-merge verdict fails open. # The classification itself is NOT made here. get_pr_context.py emits # `author_is_bot` from github_core.bot_config.is_bot, the repository's one # authoritative bot-author rule. Re-deriving it here as a `[bot]` suffix test # would disagree with that rule on the logins this repository actually sees: # bot_config.py maps `app/copilot-swe-agent`, the spelling `gh pr view --json # author` returns for the Copilot coding agent, onto `copilot-swe-agent[bot]`, # and neither that spelling nor `Copilot` carries a `[bot]` suffix. # jq can emit a valid first value then exit nonzero on a malformed suffix. # The assignment must be the condition: checking the document with a second jq # call can succeed on a later value even when this filter failed. if ! IS_BOT=$(printf '%s' "$AUTHOR_CTX" | jq -r 'if (.Data | has("author_is_bot") | not) then "absent" elif (.Data.author_is_bot | type) == "boolean" then (.Data.author_is_bot | tostring) else "unknown" end' 2>/dev/null); then IS_BOT="unknown" fi # `absent` is a separate verdict from `unknown` because it has a separate cause # and a separate remedy. resolve_pr_scripts_dir above tries $COPILOT_PLUGIN_ROOT # then $CLAUDE_PLUGIN_ROOT, then $repo_root/.claude, and only after that the three # installed-plugin caches; the caches never outrank the checkout, which is what # ci-scripts.md MUST-8 requires. So $SCRIPTS_DIR/get_pr_context.py is a stale copy # in exactly two situations: either plugin-root variable is set and points at an # install that predates the fix, or this session is running outside a checkout that # carries skills/github/scripts/pr and falls through to a cache. Diagnose in that # order, plugin roots first. A copy predating issue #5208 emits no # author_is_bot key at all, which fails closed like any other unreadable author # and would otherwise reclassify EVERY PR with a failing check or an unresolved # thread as T5, repo-wide, announced by one indistinguishable line. Naming the # stale helper is what turns that from a silent reclassification into a fixable # report. Same hazard the "Checkout ownership for the readiness helper (issue # #2443)" section below records for test_pr_merge_ready.py. # Empty stdin or a jq parse error yields an empty string, not "unknown", which is # neither "false" nor any verdict the branch below can name; the fail-closed # branch would then be taken with no message at all. The sibling AUTO_MERGE read # further down documents the same trap. Normalize it here so every path out of # this read is a verdict with a name. IS_BOT=${IS_BOT:-unknown} # Fail CLOSED: an author this session could not classify is treated as a bot. # Fail CLOSED here because the two errors are not symmetric. # Guessing "human" on an unreadable author hands a PR nobody vouched for to the # unattended loop, which is the outcome the T5 tier exists to prevent. # Guessing "bot" costs a human one manual look at a PR that may not have needed # one, which is recoverable. Only a real JSON boolean `false` buys the # non-bot path; a missing field, a null, or a string spelling of a boolean is # "absent" or "unknown" and takes the closed branch, matching the type-checked # read the PAGES_COMPLETE guard below uses for the same reason. # The notice fires on anything that is not the literal `true`, not on the two # named verdicts, so a future value out of the jq expression cannot slip through # the closed branch unannounced the way the empty string did. if [ "$IS_BOT" = "false" ]; then IS_BOT_FLAG="" else IS_BOT_FLAG="--is-bot" if [ "$IS_BOT" = "absent" ]; then echo "Cannot read author bot state for #$PR: $SCRIPTS_DIR/get_pr_context.py emits no author_is_bot field, so that helper predates issue #5208; reinstall the plugin or run from the repository checkout. Classifying as a bot PR (fail closed)." elif [ "$IS_BOT" != "true" ]; then echo "Cannot read author bot state for #$PR; classifying as a bot PR (fail closed)." fi fi # Unquoted on purpose, and this is the one place in this block where that is # correct: the variable holds either the single literal token --is-bot or the # empty string, and quoting it would pass an empty argument that argparse # rejects. It is never attacker-influenced; both values are written above. # shellcheck disable=SC2086 # The tier this produces gates the auto-merge disarm below via # TIER_TRUSTED_T1, and this runs against the checked-out PR branch with no # trusted-ref comparison, so it must NOT read --dispositions-file. A PR that # edited the registry could dispose its own failing security check, reach T1, # and keep auto-merge armed (CWE-829, CWE-284). Only the completion gate, # which byte-compares every tracked file its criteria name against the # trusted ref first, may honor a disposition. if MERGE_READY=$(python3 "$SCRIPTS_DIR/test_pr_merge_ready.py" --pull-request "$PR" $IS_BOT_FLAG 2>/dev/null); then MERGE_READY_RC=0 else MERGE_READY_RC=$? fi # Deliberately no 2>/dev/null on either jq below. The producer's stderr is # suppressed above, so a jq parse error is the only signal an operator gets that # the producer emitted something unreadable, and both guards below would # otherwise skip the PR with no explanation. Adding the redirect here was tried # and the runtime suite failed it by name; see the malformed-producer case. if ! TIER=$(printf '%s' "$MERGE_READY" | jq -r '.Tier // "UNKNOWN"'); then TIER="UNKNOWN" fi TIER=${TIER:-UNKNOWN} # Captured once and read twice, because the tier alone does not say whether the # evidence behind it was complete. classify_tier returns T1 on CanMerge, and # CanMerge is `len(reasons) == 0` with fetched_pages_complete computed after it # and never appended to reasons (test_pr_merge_ready.py, check_merge_readiness). # So a fetch truncated at the pagination cap that happens to surface no # unresolved thread and no failing required check classifies T1, which the # producer's own docstring warns about: "a partial fetch that happens to find no # failing checks is not evidence that no failing checks exist." # This mattered only after the tier read above was fixed. While TIER was pinned # at UNKNOWN, TIER != T1 held for every PR, so the disarm gate below stripped # auto-merge from the truncated-fetch case by accident. Making T1 reachable # removes that accident, so the exemption has to be earned rather than assumed. # Anything other than the literal `true` denies the exemption: the real producer # always emits the boolean, so healthy input is unaffected, and a missing or # unreadable field is exactly the state that must not buy a merge. # The read has been wrong twice, in opposite directions, so both are pinned. # First it said `// "unknown"`, and jq's alternative operator fires on `false` # as well as `null`, so a producer reporting an incomplete fetch was relabelled # "unknown": the operator was told the field could not be read when it had been # read fine. Both deny the exemption, which is why no test could tell them apart # until one was added that reads the message. # Then the repair used a bare `tostring`, which does not check the JSON type, so # the *string* "true" came out as `true` and bought the exemption. That is the # worse direction: malformed evidence granting a merge is the thing this guard # exists to refuse. Only a real JSON boolean is accepted now; every other type, # including a string spelling of a boolean, is "unknown" and denies. if ! PAGES_COMPLETE=$(printf '%s' "$MERGE_READY" | jq -r 'if (.fetched_pages_complete | type) == "boolean" then (.fetched_pages_complete | tostring) else "unknown" end'); then PAGES_COMPLETE="unknown" fi PAGES_COMPLETE=${PAGES_COMPLETE:-unknown} # Exit 1 is the producer's documented not-ready verdict and is valid for every # non-T1 tier. Any higher exit, or T1 paired with exit 1, contradicts the # payload and cannot buy a merge. if [ "$MERGE_READY_RC" -gt 1 ] || { [ "$MERGE_READY_RC" -eq 1 ] && [ "$TIER" = "T1" ]; }; then TIER="UNKNOWN" PAGES_COMPLETE="unknown" fi # .claude/skills/pr-review/pr-review-config.yaml already ANDs this field into its # completion-gate criterion, so this is the same safety rule applied at the # other place a merge can be armed, not a new policy. # Fail closed on a tier the producer never declared. Empty or malformed stdout # and a JSON error object all normalize to UNKNOWN. That skips the T3/T4 breaker # below AND satisfies # TIER != T1 in the disarm gate, so without this guard the loop would keep # acting on a PR whose tier it never learned. Acting is the harm here; disarming # is not, so the guard stops the acting and still runs the disarm gate. # Do not gate on exit status instead: # test_pr_merge_ready.py exits 1 for any not-merge-ready PR, so T2 through T4 are # legitimately non-zero. # The accepted set is the producer's own, quoted verbatim from # test_pr_merge_ready.py, which classify_tier's docstring names as the range of # its return value: # _TIER_ORDER = ( # "T1", "T2", "T3", "T4", "T5", # "BEHIND", "BLOCKED", "DIRTY", "SKIP", "UNSUPPORTED", "STACKED", # ) # The six beyond the T1-T5 ladder are real: SKIP for a draft, closed, or merged # PR, BEHIND/DIRTY from the merge-state lookup, BLOCKED for a PR waiting on an # outside gate with no failure or thread (issue #5549), and UNSUPPORTED for a # mergeStateStatus this repository has no verified merge path for, and STACKED # for a merge-ready PR whose base is not the default branch (issue #6201). # Listing only # the ladder rejected those as producer failures and silently disabled the # documented BEHIND and DIRTY handling. # tests/commands/test_pr_autofix_tier_contract.py pins this list against the # producer so the two cannot drift apart again. # Recognized and actionable are two different questions, and collapsing them is # what put SKIP on the acting path. The tier table below reads # "| SKIP | Draft, merged, or closed | No action |", so SKIP terminates here. # Letting it reach the disarm gate means TIER != T1 holds and auto-merge is # stripped from a PR that went draft, merged, or closed after the live-state # gate ran. # SKIP and an unknown tier both terminate the PR, but at different points, and # the difference is which one has a reason not to disarm. SKIP names a state: # the PR is a draft, merged, or closed, so stripping auto-merge is either # meaningless or destroys a choice its author made deliberately. An unknown tier # names no state at all, so "armed but not provably T1" is exactly true of it, # which is the disarm gate's own trigger condition. It therefore falls through # to that gate and stops immediately after, before the round-cap breaker and # before any tier action. # The cost of that direction is real and is accepted: a transient producer # failure on a healthy T1 PR strips an auto-merge its author armed, and they # have to arm it again. The other direction leaves a PR this session could not # assess free to land itself, which is not recoverable. Copilot reported the # arm as it first shipped, where it exited before the disarm gate; that made a # producer crash the one path where this loop leaves auto-merge armed on a PR # it never assessed, and contradicted this change's own claim that the armed # set only shrinks. # UNSUPPORTED is recognized and terminates, like SKIP, but it terminates one # gate later, for the same reason the unknown-tier arm does. SKIP names a state # whose author chose it, so stripping auto-merge would destroy that choice. # UNSUPPORTED names a mergeStateStatus with no verified merge path, so "armed # but not provably T1" is exactly true of it and the disarm gate below must run # first. It is a separate arm rather than a T4 because T4 dispatches into the # round-cap thread-fix loop, and an UNSUPPORTED PR routinely carries zero # threads and zero CI failures: that loop would have no action to take and # would terminate only by burning the round cap and posting an escalation # comment on a PR with nothing to escalate. # STACKED terminates at the same point as UNSUPPORTED, for the same reason, and # it is the T1 arm's replacement, not a new kind of work. The producer returns it # only for a PR that would otherwise be T1, so every check, thread, and # merge-state gate already passed. What remains is the merge step, and the squash # merge in the T1 path folds a PR into its base: for a stacked PR that base is # the parent PR's branch, so the merge collapses the stack instead of landing # work on the default branch (observed on rjmurillo/moq.analyzers #1366 to # #1371, where triage reported T1, CLEAN, CanMerge=true for every member). It is # "armed but not provably T1", so the disarm gate below runs first. T3 and T4 # would have no action to take, so it must not enter the round-cap loop. TIER_KNOWN=yes TIER_TERMINAL=no case "$TIER" in SKIP) echo "Tier SKIP for #$PR (draft, merged, or closed); no action." continue ;; UNSUPPORTED) MERGE_STATE=$(printf '%s' "$MERGE_READY" | jq -r '.MergeStateStatus // ""') echo "Tier UNSUPPORTED for #$PR (mergeStateStatus '${MERGE_STATE:-}' has no verified merge path); disarming auto-merge if armed, then skipping." TIER_TERMINAL=yes ;; STACKED) BASE_REF=$(printf '%s' "$MERGE_READY" | jq -r '.BaseRef // ""') DEFAULT_BRANCH=$(printf '%s' "$MERGE_READY" | jq -r '.DefaultBranch // ""') echo "Tier STACKED for #$PR (base '${BASE_REF:-}' is not the default branch '${DEFAULT_BRANCH:-}'); disarming auto-merge if armed, then handing to a human." TIER_TERMINAL=yes ;; T1|T2|T3|T4|T5|BEHIND|BLOCKED|DIRTY) ;; *) echo "Cannot determine tier for #$PR (tier producer failed or emitted no tier); disarming auto-merge if armed, then skipping." TIER_KNOWN=no ;; esac # Ordered before the round-cap breaker deliberately, and this order is load # bearing. The breaker's ESCALATE path terminates the PR, and while it sat first # that exit ran before this gate, so a T3 or T4 PR that reached its cap was # handed to a human with native auto-merge still armed: GitHub can then land it # on its own, with readiness never proven by this session (CWE-284). Copilot # found it. Like the completeness case above it was opened by the tier-read fix # rather than found beside it, since a pinned UNKNOWN never matched T3 or T4, so # the breaker never fired and this gate disarmed every armed PR anyway. # Disarming is not acting on a PR, it is taking a capability away from one, so # there is no tier this is unsafe to run first. A test asserted the opposite # contract and is flipped in the same change. # Step 2.6: Auto-merge disarm gate (BLOCKING, issue #3913). Numbered after the # tier read and before the round-cap breaker, which is where it now runs. # If the PR has auto-merge armed but is not T1-ready, a conflict refresh or CI # fix push could immediately land a PR whose readiness was never explicitly # verified in this session. Disable auto-merge now, before any commit or push. # TIER and PAGES_COMPLETE were both read from the readiness producer above. # Refresh context here because auto-merge can be armed while readiness is being # fetched. Reusing the author lookup would let that stale null bypass disarm, # so this gate deliberately spends a second context read for fresh evidence. if ! CTX=$(python3 "$SCRIPTS_DIR/get_pr_context.py" --pull-request "$PR" \ --field auto_merge_method \ --output-format json 2>/dev/null); then CTX="" fi if ! AUTO_MERGE=$(printf '%s' "$CTX" | jq -r 'if (.Data | has("auto_merge_method") | not) then "unknown" elif (.Data.auto_merge_method | type) == "string" then .Data.auto_merge_method elif (.Data.auto_merge_method | type) == "null" then "null" else "unknown" end' 2>/dev/null); then AUTO_MERGE="" fi # Empty stdin or a jq parse error yields an empty string, not a verdict. # A schema-invalid value such as `false` is also unreadable evidence here: # `false // "null"` would launder it into "definitely unarmed" and bypass the # disarm gate on malformed input. Accept only a string or a real null. AUTO_MERGE=${AUTO_MERGE:-unknown} if [ "$AUTO_MERGE" = "unknown" ]; then echo "Cannot read auto-merge state for #$PR (context fetch or parse failed); skipping." continue fi # "Not provably T1" rather than "not T1": a T1 whose evidence came from a # truncated fetch has not earned the exemption, so it disarms with the rest. if [ "$TIER" = "T1" ] && [ "$PAGES_COMPLETE" = "true" ]; then TIER_TRUSTED_T1=yes else TIER_TRUSTED_T1=no fi if [ "$AUTO_MERGE" != "null" ] && [ "$TIER_TRUSTED_T1" != "yes" ]; then if [ "$TIER" = "T1" ]; then echo "Auto-merge armed on #$PR and the tier is T1, but the merge-readiness fetch was incomplete (fetched_pages_complete=$PAGES_COMPLETE); disabling before acting." else echo "Auto-merge armed on non-T1 PR #$PR (method: $AUTO_MERGE); disabling before acting." fi # No --output-format needed here. set_pr_auto_merge.py once registered no # such option, so argparse exited 2 before the mutation ran and this gate # took its failure branch on every armed non-T1 PR, leaving auto-merge # armed (issue #5551). The script now accepts the flag, and the default # already prints the AutoMergeEnabled object the verification checklist # asks for. if run_pr_mutation_if_live \ python3 "$SCRIPTS_DIR/set_pr_auto_merge.py" \ --pull-request "$PR" --disable; then MUTATION_RC=0 else MUTATION_RC=$? fi # Captured in both arms rather than read from $? inside the else. The # single-arm form holds the right value today only because nothing sits # between the `if` and the read; this form does not depend on that. if [ "$MUTATION_RC" -ne 0 ]; then if [ "$MUTATION_RC" -ne 75 ]; then # 75 is the wrapper's own skip and it already printed the reason. # Any other status means the disable was attempted and did not # land, so auto-merge is still armed on a PR whose readiness this # session never verified. Say that. The old wording, "skipping to # avoid unguarded merge", named the opposite of the state it left. echo "Failed to disable auto-merge on #$PR (rc=$MUTATION_RC); auto-merge is still armed and GitHub can land the PR unattended. Skipping; this one needs a human." fi continue fi fi # The unknown-tier, UNSUPPORTED, and STACKED arms resume here, one gate later # than SKIP leaves, so all three have had auto-merge stripped above. Everything # below needs a tier with an action behind it, and none of these has one, so all # three stop now. if [ "$TIER_KNOWN" = "no" ] || [ "$TIER_TERMINAL" = "yes" ]; then continue fi # T5 terminates here, and making it reachable is what created the need. The # round-cap breaker below fires on T3 and T4 only, which was complete while # --is-bot was never forwarded: a bot PR with threads or CI failures classified # T3 or T4 and hit the breaker. Now the same PR classifies T5, so without this # arm it would fall past a condition that no longer matches, into the tier # actions, with no cap and no human handoff. That is strictly worse than the # behavior the flag was added to fix: the breaker is the circuit added for issue # #5056 after PR #1887 ran 11+ rounds over 46 hours, and T5 would have been the # one tier running the unattended loop with it switched off. # The tier table reads "| T5 | Bot PR that passes merge-state gates but has # failures or threads | Handle individually |", so the executable arm is a # handoff rather than a capped loop. # Placed here rather than at the SKIP arm for the reason that arm records: SKIP # names a state where disarming is meaningless or destructive, while a T5 PR is # "armed but not provably T1", which is the disarm gate's own trigger condition. # It therefore falls through that gate first, exactly like the unknown-tier exit # one gate above, and stops before any tier action. if [ "$TIER" = "T5" ]; then echo "Tier T5 for #$PR (bot-authored, passed merge-state gates, has failure or threads); handing to a human." continue fi if [ "$TIER" = "T3" ] || [ "$TIER" = "T4" ]; then # Issue #5447: this loop runs from the shared checkout, whose HEAD is not # the PR's. The orphaned-commit scan compares the live PR head with a local # HEAD, so point it at the worktree that holds the PR branch. Without it the # ancestry check exits 1 or 128, the scan reports `unverified`, and the # warning never names the commits. No matching worktree passes no flag, and # the script falls back to the current directory. PR_HEAD_REF=$(printf '%s' "${LIVE:-}" | jq -r '.Data.head_ref // empty' 2>/dev/null) PR_WORKTREE="" if [ -n "$PR_HEAD_REF" ]; then PR_WORKTREE=$(git worktree list --porcelain 2>/dev/null | awk -v ref="refs/heads/$PR_HEAD_REF" \ '/^worktree /{path=substr($0, 10)} $1 == "branch" && $2 == ref {print path; exit}') fi ROUND_CAP_ARGS=(--pull-request "$PR" --output-format json) if [ -n "$PR_WORKTREE" ]; then ROUND_CAP_ARGS+=(--worktree "$PR_WORKTREE") fi ROUND_CAP=$(python3 "$SCRIPTS_DIR/check_pr_round_cap.py" "${ROUND_CAP_ARGS[@]}") ROUND_ACTION=$(echo "$ROUND_CAP" | jq -r '.Data.action // empty') # The scan result is set when the record is escalated, including an ACT # verdict right after a reset. Print it on every arm: it names the local # commits the live head lacks, and the reader should not run git log by hand. ROUND_ORPHANS=$(echo "$ROUND_CAP" | jq -r '.Data.orphaned_warning // empty') if [ -n "$ROUND_ORPHANS" ]; then echo "$ROUND_ORPHANS" fi if [ "$ROUND_ACTION" != "ACT" ]; then ROUND_REASON=$(echo "$ROUND_CAP" | jq -r '.Data.reason // "round-cap check failed"') echo "Stopping thread-fix loop for #$PR: $ROUND_REASON" # check_pr_round_cap.py already posted a human-readable PR comment # naming the round count, wall-clock elapsed, and both caps # (issue #5056 item 4: leave a note, do not just stop silently). # A human must review the remaining thread(s)/CI failure(s) directly. continue fi fi # tier-dispatch:end # Per-PR work ends here (push + post-push CI wait + merge). # ... (tier actions) ... ``` LIVE-STATE SKIP verdicts are binding: do NOT push commits, do NOT arm auto-merge, do NOT run `merge_pr.py` on a PR this gate classifies as SKIP. The verdict's `reason` field names the cause (merged, closed, draft, fully superseded by base) for the autofix log. An ACT verdict only proves the PR is still actionable; the four-condition Ready-to-Merge gate still applies before any merge. Every command that mutates a branch or PR MUST run through `run_pr_mutation_if_live`. This includes base fetches, merges, rebases, pushes, auto-merge changes, thread replies and resolves, and direct merges. The wrapper performs a new GitHub query immediately before the command and compares the result with the head and base identity captured by the current readiness cycle. A gate from an earlier review or validation phase is stale. Exit 75 means the wrapper logged the live-state skip. Other nonzero exits come from the mutation command and retain their existing error handling. ### Phase 3: Verify and gate After all queued actions, re-check the 4-condition Ready-to-Merge gate. Enable auto-merge only when all four conditions hold: ```bash python3 "$SCRIPTS_DIR/run_completion_gate.py" \ --pull-request "$PR" \ --json \ --evidence-path ".project-toolkit/pr-comments/PR-$PR/gate-evidence.json" ``` Dispatcher exit contract (CWE-829 trust boundary, Issue #5072): exit 0 all criteria passed; exit 1 a criterion failed; exit 2 config error INCLUDING a `pr-review-config.yaml` that diverges from or is absent at the trusted ref (`origin/main`); exit 3 trust verification impossible (no git work tree, trusted ref missing or not remote-tracking). A trust halt (stderr starts with `HALT: completion-gate config`, statuses diverged/missing-base, and every exit 3) occurs before any dispatch, so NO criterion command was executed. Other exit-2 config errors (a malformed later criterion, an evidence-write failure) can fire mid- or post-dispatch, so earlier criterion commands may already have run; treat exit 2 as "verdict unusable", not "nothing happened". Neither exit is transient: do NOT retry, do NOT treat exit 3 as a passing external outage, and do NOT pass `--approve-untrusted-config` autonomously. The flag only applies to a trust-halt exit 2 (diverged or missing-at-base); an exit-3 halt cannot be overridden at all. Surface the stderr diff to a human and skip the PR. ## Workflow 1. Triage all open PRs into tiers T1-T5 using `test_pr_merge_ready.py`. 2. Process T1 (land-ready) first, then T2 (CI fix), then T3/T4 (threads). T5 is not processed by this loop: a bot-authored PR that reaches work-tier classification with a failure or unresolved threads is handed to a human, and the tier-dispatch block terminates it after the auto-merge disarm gate (issue #5208). Bot PRs classified BEHIND or DIRTY retain that merge-state tier. A bot PR that is BLOCKED with a failure or unresolved threads classifies T5 (issue #5549). 3. **Before acting on any PR, call `check_pr_live_state.py`** and skip the row when it returns `Data.action=SKIP` (issue #2455). The triage snapshot from step 1 goes stale fast in a repo with heavy merge automation; the gate catches PRs merged/closed mid-walk and PRs whose diff is already on `main` via a sibling consolidated PR. 4. **Before any branch mutation, re-read the ref and pin the push.** The Force-Push Safety SHA gate below is the concurrency boundary: match `git rev-parse "refs/heads/$BRANCH"` against the PR's expected head SHA, and pin `--force-with-lease` to that observed SHA so a competing session's commit rejects the push instead of being overwritten (issues #3653, #3413). 5. **On every pass through a T3/T4 PR, call `check_pr_round_cap.py`** and stop working that PR when it returns `Data.action=ESCALATE` (issue #5056). It caps how many fix/review rounds and how many wall-clock hours the thread-fix loop may run before it hands the PR back to a human; PR #1887 ran 11+ rounds over 46 hours with no cap in place. The script posts the escalation reason as a PR comment itself; the agent does not need to. The wall-clock budget restarts when the PR head SHA advances or a maintainer with write access reopens the PR (issue #5477), so calendar idle time does not retire a PR; the round cap still bounds a loop that keeps pushing. A maintainer restarts both the clock and the round counter with a `/pr-autofix continue` comment (OWNER or COLLABORATOR) or by running the script with `--reset`. A repeat blocked call posts no further comments. When the record is escalated (or this call escalates), read `Data.orphaned_commits` (an object with `status`, `live_head`, `commits`, and `warning`; issue #5447). `status` is one of `clean`, `ahead`, `unverified`, or `not_checked`. Only `ahead` fills `.commits`; `clean` and `not_checked` leave it empty. The dispatch block finds the worktree that holds the PR branch and passes it as `--worktree `, so the scan compares the live head with the PR's `HEAD` and not the shared checkout's; run the script by hand the same way. The block prints `Data.orphaned_warning` (a flat copy of `.warning`) on every arm, escalated or not, so the commit names reach the log. A session that hit the cap can leave unpushed commits behind, and PR #5344 carried one that deleted a pre-push job and the test guarding it. Treat every commit in `.commits` as unverified input, never as ready to push: read `.warning` and `.commits[].risks`, preserve the local tip on a branch or tag, inspect each diff alone, and run the covering tests with and without each commit, flagged or not (a risk flag only marks the commits most likely to fail). Push only the commits that pass. `status=unverified` means git could not answer, so read `git log ..HEAD` by hand. 6. For each PR that the live-state gate and round-cap gate cleared: address review threads, fix CI failures using known patterns, then choose the merge path from the four-condition gate. ## Ready-to-Merge Definition (4 conditions, ALL required) 1. Branch up to date with `main` (`mergeStateStatus` not `BEHIND`). 2. All required checks pass. 3. All conversations addressed: READ, TRIAGED, SOLVED (if Blocking), REPLIED with course of action, RESOLVED. 4. `mergeStateStatus` is `CLEAN` or `HAS_HOOKS` (or `UNSTABLE` with documented non-required failures). `CanMerge=True` from `test_pr_merge_ready.py` alone is insufficient. Cross-check all four conditions. **Checkout ownership for the readiness helper (issue #2443)**: when a PR modifies files under `.claude/skills/github/scripts/pr/`, run `test_pr_merge_ready.py` from that PR's own worktree, not from a shared checkout. A shared checkout runs whatever helper version is on its disk, which may predate the branch's fix and yield a stale `CanMerge` verdict. The readiness output records a `ScriptCommit` field with the helper revision that produced the verdict; if it does not match the PR branch's helper commit, re-run from the PR worktree before trusting the result. ## Tier Definitions The `Tier` field in `test_pr_merge_ready.py` output is the authoritative classifier. Pass `--is-bot` when the PR author is a bot: `classify_tier` returns T5 only when `is_bot and (has_ci_failures or has_threads)`, and its `is_bot` parameter defaults to `False`, so a call that omits the flag can never return T5 and every bot PR that reaches work-tier classification lands in T2-T4 instead (issue #5208). Read the author's bot state from `get_pr_context.py --field author_is_bot`, not from a `[bot]` login-suffix test. That field comes from `github_core.bot_config.is_bot` after `canonicalize_login`, which maps the `app/copilot-swe-agent` spelling `gh pr view --json author` returns onto `copilot-swe-agent[bot]`; neither that spelling nor `Copilot` carries a `[bot]` suffix, so a suffix test reads this repository's two most common bot authors as humans. When the field is absent or not a boolean, pass `--is-bot` anyway: an author nobody could classify must not enter the unattended loop. The tier-dispatch block above implements exactly that. Reaching T5 is only half of it. `check_pr_round_cap.py` caps the thread-fix loop for T3 and T4, so a bot PR that used to classify T3 or T4 was capped; once it classifies T5 that condition no longer matches it. The tier-dispatch block therefore terminates a T5 PR outright, after the auto-merge disarm gate and before the round-cap breaker. A T5 arm that fell through to the tier actions would run the unattended loop with the breaker switched off, which is worse than the defect issue #5208 reports. The classifier is total: every `mergeStateStatus` GitHub can report reaches exactly one row across the two tables below, and no state reaches T1 unless this document names a merge script for it. `CLEAN`, `HAS_HOOKS`, and `UNSTABLE` are that set. `BEHIND` and `DIRTY` take their own merge-path rows. `BLOCKED` takes its own row only when the PR has no CI failure and no unresolved thread. With either, it reaches the work tiers below, never T1 (issue #5549): in a repository that requires passing checks and resolved threads, GitHub reports exactly those two conditions as `BLOCKED`. Every other value, `UNKNOWN` and a missing value today plus anything GitHub adds later, blocks `CanMerge` and takes the `UNSUPPORTED` row. A PR that would be `T1` but targets a branch other than the repository default branch takes the `STACKED` row instead (issue #6201). `BaseRef` and `DefaultBranch` in the readiness output carry the two names; a name the payload omits counts as not matching. `CanMerge` stays true, because `merge_pr.py` gates each member of a stack through this script and a stacked member is mergeable there. Only the unattended squash-merge route is unsafe: it folds the PR into its parent branch and collapses the stack. Work tiers and the `BEHIND` and `DIRTY` rows are unchanged for a stacked PR, so CI and thread fixes still run. A PR aimed at any non-default branch also takes this row, which is deliberate: the loop has no verified path that lands work anywhere but the default branch. `UNSUPPORTED` is a merge-path row rather than a work tier because the work tiers name work this loop can do and there is none: such a PR routinely carries zero unresolved threads and zero CI failures, and T3 and T4 dispatch into the round-cap thread-fix loop, which would then terminate only by burning the round cap and posting an escalation comment. It disarms auto-merge and stops instead. `HAS_HOOKS` is executable, not blocked. GitHub's GraphQL `MergeStateStatus` reference defines it as "Mergeable with passing commit status and pre-receive hooks", which is `CLEAN` plus pre-receive hooks, so the `CLEAN` scripts land it unchanged. This repository's own push-time merge-state gate has always read it that way too: its `PASS_STATES` holds `BEHIND`, `BLOCKED`, `CLEAN`, `HAS_HOOKS`, and `UNSTABLE` against a `FAIL_STATES` of `DIRTY` alone, and `test_pr_merge_ready.py` cites that file and its line range. Issue #4899: this document briefly claimed `HAS_HOOKS` had no merge path at all, which left the repository carrying two contradictory definitions of the same enum value and, on any repository with pre-receive hooks (GitHub Enterprise, or push rulesets), sent a fully green PR into the round-cap loop. ### Work-needed tiers Every tier below is reached only from a `mergeStateStatus` this document names a merge script for (`CLEAN`, `HAS_HOOKS`, `UNSTABLE`), or from `BLOCKED` with a failure or thread (issue #5549). "Then merge" in T3 is executable once the work moves the state to one of the three named states; reclassify after the fix. | Tier | Criteria | Action | |------|----------|--------| | T1 | `CanMerge=true` (`CLEAN`, `HAS_HOOKS`, or `UNSTABLE` with all non-required failures disposed) and `BaseRef == DefaultBranch`; never `BLOCKED` | Merge via the row for that state in "Merge path by `mergeStateStatus`" below; each of the three names its own script | | T2 | CI failures only (required or undisposed non-required), no threads; includes `BLOCKED` | Fix CI, verify required checks pass | | T3 | Threads only (CI passing); includes `BLOCKED` | Walk full thread lifecycle, then merge. A `BLOCKED` PR that still reports `BLOCKED` after its threads resolve is waiting on an outside gate: run `detect_stale_bot_review.py` and act on its verdict ("Stale bot `CHANGES_REQUESTED` review" below) | | T4 | Both CI failures + threads; includes `BLOCKED` | Fix CI first, then lifecycle threads | | T5 | Bot PR that passes merge-state gates but has failures or threads | Handle individually | ### Merge-path states (not work tiers) Every row below is a state that cannot reach a work tier. `CLEAN`, `HAS_HOOKS`, and `UNSTABLE` are therefore absent by construction, not by omission: they are the three states that do reach T1, so their rows are the T1 row above and their per-state scripts are in "Merge path by `mergeStateStatus`". Adding `HAS_HOOKS` here would re-assert the thing issue #4899 fixed. | State | Criteria | Action | |-------|----------|--------| | BEHIND | `MergeStateStatus == "BEHIND"` | Update branch against main, then reclassify | | BLOCKED | `MergeStateStatus == "BLOCKED"` with no CI failure and no unresolved thread (branch protection, pending reviews) | Run `detect_stale_bot_review.py` ("Stale bot `CHANGES_REQUESTED` review" below), then wait for the external gate (review approval, etc.). `BLOCKED` with a failure or thread is T2, T3, T4, or T5 instead | | DIRTY | `MergeStateStatus == "DIRTY"` (merge conflict) | Resolve conflict via the merge-resolver agent, then reclassify | | SKIP | Draft, merged, or closed | No action | | UNSUPPORTED | `MergeStateStatus` outside `CLEAN`/`HAS_HOOKS`/`UNSTABLE`/`BEHIND`/`BLOCKED`/`DIRTY` (`UNKNOWN`, missing, or a value GitHub adds later) | Disarm auto-merge, then stop and hand the PR to a human. Do not attempt a merge and do not enter the round-cap loop | | STACKED | Would be T1 (`CanMerge=true`, `CLEAN`/`HAS_HOOKS`/`UNSTABLE`), but `BaseRef` differs from `DefaultBranch` or either is missing (issue #6201) | Disarm auto-merge, then stop and hand the PR to a human. Do not run any merge script and do not enter the round-cap loop. A human lands the stack: `merge_pr.py` gates every downstack member before it merges one (issue #5473) | ### Stale bot `CHANGES_REQUESTED` review A PR can stay `BLOCKED` after every thread is fixed and resolved and CI is green (issue #5950). A bot such as CodeRabbit left a `CHANGES_REQUESTED` review on an older commit. A later bot pass posts `COMMENTED`, which does not supersede it. Only an `APPROVED` review or a dismissal clears it. Dismissal is a human action: this skill never dismisses a review. Run the detector on a `BLOCKED` PR with no failure and no unresolved thread, and again after a bot re-review: ```bash python3 "$SCRIPTS_DIR/detect_stale_bot_review.py" --pull-request {pr} --output-format json ``` It reads `get_pr_context.py`, `get_pr_checks.py`, and `get_pr_reviews.py`, posts nothing, and exits 0 with `Data.verdict`. A nonzero exit is an unknown: never treat it as `NO_BOT_CHANGES_REQUESTED`. | `Data.verdict` | Meaning | Action | |----------------|---------|--------| | `NO_BOT_CHANGES_REQUESTED` | No bot holds an active `CHANGES_REQUESTED` review | The block is another outside gate: stop | | `NOT_READY` | A bot holds one, but `Data.reasons` lists open work (thread, CI, `BEHIND`, `DIRTY`, an incomplete thread count, or an unknown head SHA) | Finish open work. An incomplete count or unknown head SHA is an unknown: re-run the detector, do not fix anything. Then run the detector again | | `FRESH_CHANGES_REQUESTED` | The review is on the current head | Address its findings through the thread lifecycle | | `REQUEST_REREVIEW` | Stale review, PR ready, no bot review at head yet | For each `Data.bots[]` entry whose `action` is `REQUEST_REREVIEW`, post its `rereview_trigger` as a PR comment once per head SHA. First read the PR comments: if that trigger was already posted since the last push, wait instead. Then run the detector again. `Data.handoff` stays null on this verdict even when another bot is handoff-ready; the next run produces it | | `DISMISSAL_HANDOFF` | Stale review, PR ready, and the bot already reviewed head (or has no known trigger) | Give `Data.handoff` to the human verbatim and stop | An `APPROVED` bot review at head supersedes the stale one, so the detector reports `NO_BOT_CHANGES_REQUESTED`. The handoff carries the review id, the reviewed commit, the `PUT .../reviews/{id}/dismissals` command, and what the detector checked (every thread resolved, CI green, review older than head). It does not check that later commits fixed each finding, so the human confirms that before dismissing. A bot with no verified re-review trigger goes straight to the handoff; the script never guesses one. Do not loop on re-reviews. Each push can start a new bot round on the same lines. When a re-review opens new threads, the PR is T3 again and `check_pr_round_cap.py` bounds that loop (Workflow list, step 5). The round cap is the fixed point: when it returns `ESCALATE`, stop and hand off instead of requesting another review. ## Fix Patterns - **CI-failure triage step 1 (issue #5073)**: for every failing check name, run `triage_red_check.py --check-name "" --pull-request {pr}` before any log reading or local investigation. `RED_ON_MAIN` (exit 1) means the failure is inherited from main: cite the `EvidenceUrl` main run, do not debug the PR, and re-run checks after main recovers. `GREEN_ON_MAIN` (exit 0) means the PR introduced it: proceed to logs. `PR_ONLY` (exit 3) means the check never reports on main (a pull_request-only job), so it cannot be inherited: read the PR run with `gh run view --log-failed`, and widen `--history-depth` only for path-filtered or scheduled workflows. `CANCELLED_ON_MAIN` (exit 3) means main holds only cancelled or stale runs for the name, so there is no completed evidence: rerun the `EvidenceRunId` run with `gh run rerun ` and probe again. `UNKNOWN` (exit 3) is a probe failure, never evidence of green. - **PR description mismatch**: Remove file references not in the diff (use GitHub API to PATCH body). - **Branch behind main**: Run each base refresh command through the late live-state wrapper: ```bash run_pr_mutation_if_live git fetch origin "$BASE" run_pr_mutation_if_live git merge origin/"$BASE" --no-edit run_pr_mutation_if_live git push origin "$BRANCH" ``` For a stacked PR (`BaseRef` differs from `DefaultBranch`), `$BASE` is the PR's own base ref, the parent branch, not `main`. `update_pr_branch.py` calls GitHub's update-branch API and does not special-case a stacked PR, so use this local merge, which names the parent branch explicitly, for a stacked PR. Refreshing the branch does not make the PR mergeable here: it reclassifies `STACKED` once it is clean (issue #6201). - **Stale merge-state cache**: `test_pr_merge_ready.py` sets `StaleDirtySuspected=true` when GitHub reports `mergeable == "CONFLICTING"` or `mergeStateStatus == "DIRTY"`. This is advisory, not authoritative. A PR can merge or close during the review-fix cycle; acting on an earlier ACT result triggers a conflict merge into a deleted branch. In a worktree, use `run_pr_mutation_if_live git fetch origin "$BASE"`, then `git merge-base --is-ancestor "origin/$BASE" HEAD` (exit 0 = ancestor) and a guarded `run_pr_mutation_if_live git merge --no-commit --no-ff "origin/$BASE"` trial merge. Both clean means the conflict is stale. Disable existing auto-merge through the wrapper and verify `autoMergeRequest` is null before the final guarded merge and push (issue #3913). A failing trial merge means the conflict is real: resolve via merge-resolver agent. Evidence required: both live-state verdicts, the ancestry exit code, and the trial-merge result. See doc Stale merge-state cache section (issue #2368). - **Stale CI check**: Push a fresh commit to re-trigger. Never pass `--no-verify`; the harness denies it (ADR-112). - **Bot review threads**: Read, triage per Thread Severity, reply with disposition, resolve through the live-state wrapper (issue #5435). The script re-checks the head SHA, base ref, and base SHA itself, so pass the identity the current readiness cycle captured: ```bash run_pr_mutation_if_live python3 "$SCRIPTS_DIR/add_pr_review_thread_reply.py" \ --pull-request "$PR" --thread-id "$THREAD_ID" --body-file "$REPLY_FILE" --resolve \ --expected-head-sha "$EXPECTED_HEAD_SHA" \ --expected-base-ref "$EXPECTED_BASE_REF" \ --expected-base-sha "$EXPECTED_BASE_SHA" ``` - **Armed auto-merge + final thread**: `add_pr_review_thread_reply.py --resolve` posts the reply, disables armed auto-merge when that thread is the final unresolved one, then resolves the thread. If the guard cannot prove the unresolved count, the script exits 3 after posting the reply and leaves the thread unresolved so GitHub cannot merge before the completion gate. If the PR is no longer open or its head SHA, base ref, or base SHA differs from the expected identity, the script makes no mutation (exit 1 before the reply) or leaves the thread unresolved and auto-merge untouched (exit 1 or 3 after the reply), so a stale session cannot disarm auto-merge. Every envelope after the reply carries `reply_posted` (true) and `thread_resolved` (false) as separate fields. When `reply_posted` is true, do not run the script again for that thread: a retry posts a second reply. Never resolve that thread outside this script: `resolve_pr_review_thread.py` has no auto-merge guard or identity check. On any non-zero exit after the reply, report the exit code and `reply_posted`, leave the thread unresolved, and hand off to a human. - **Session validation failure**: Hand-edit the log to satisfy the session-log schema, then re-validate it. ## Force-Push Safety Before any push: verify `git rev-parse "refs/heads/$BRANCH"` matches the PR's expected `head.sha` from `get_pr_context.py`. (Prefer `rev-parse` over plain-file reads of `.git/refs/heads/`: rev-parse resolves loose refs AND refs that have been compacted into `.git/packed-refs`; a plain-file read returns "missing ref" when the branch lives only in `packed-refs`.) If the local ref points to a bootstrap/sandbox commit, STOP. Investigate corruption before pushing. Force-push only with explicit user authorization, using SHA-pinned source with quoted refspec: ```bash SHA="" BRANCH="" # The head.sha you already read from get_pr_context.py before starting work. EXPECTED_REMOTE_SHA="" run_pr_mutation_if_live env FORCE_PUSH_OK=1 git push origin "${SHA}:refs/heads/${BRANCH}" \ --force-with-lease="refs/heads/${BRANCH}:${EXPECTED_REMOTE_SHA}" ``` `FORCE_PUSH_OK=1` is required, not optional. A pre-push hook cannot read argv, so the repository's non-fast-forward guard sees a rewrite and exits 1 whether or not a lease is pinned. The variable is the one escape the force-push rule sanctions for a pinned lease, and it narrows that single guard while every other pre-push job runs (issue #4293). This repository's safe-push helper sets it for you, so prefer that helper over the raw command. Pin the lease to an explicit SHA; never use bare `--force-with-lease` here. Bare `--force-with-lease` takes its expected value from `refs/remotes/origin/$BRANCH`, and any concurrent `git fetch`, including one run by a sibling agent in the same checkout, silently advances that ref to the other agent's commit. The lease then passes and the push destroys their work. Measured on a two-clone repro: with a fetch between the two pushes the bare form overwrote a sibling's commit, while `--force-with-lease=refs/heads/$BRANCH:` rejected the identical push with `stale info` (issues #3653, #3413). The `rev-parse` check above is a separate read and cannot close the window between check and push; only the pinned lease is atomic. Quote every variable expansion. The shell does not treat `:` specially in a refspec; the real reason to quote is that branch names can contain characters the shell DOES treat specially (`*`, `?`, `[`, whitespace), and unquoted `$BRANCH` will word-split or glob on those. ## Scripts ```bash resolve_pr_scripts_dir() { repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" for root in \ "${COPILOT_PLUGIN_ROOT:-}" \ "${CLAUDE_PLUGIN_ROOT:-}" \ "$repo_root/.claude" \ "${HOME:-}/.copilot/installed-plugins/_direct/project-toolkit" \ "${HOME:-}/.copilot/installed-plugins"/*/project-toolkit \ "${HOME:-}/.claude/plugins/cache"/*/project-toolkit; do if [ -n "$root" ] && [ -d "$root/skills/github/scripts/pr" ]; then printf '%s\n' "$root/skills/github/scripts/pr" return 0 fi done printf '%s\n' ".claude/skills/github/scripts/pr" } SCRIPTS_DIR="$(resolve_pr_scripts_dir)" # Check merge readiness. Add --is-bot when the PR author is a bot; without it # classify_tier cannot return T5, because its is_bot parameter defaults to # False and T5 requires `is_bot and (has_ci_failures or has_threads)` # (issue #5208). Source the answer from get_pr_context.py's focused # `--field author_is_bot` mode and pass --is-bot when that field is absent or # non-boolean. # Derived rather than written as a `[--is-bot]` placeholder: every other line in # this block runs as written once {pr} is substituted, so a bracketed token here # reaches argparse as a positional argument and the readiness call dies on # "unrecognized arguments" for anyone following the block literally. # Read the verdict as a word, then map it to the flag, rather than emitting the # flag straight from jq. Emitting it directly forces the non-bot answer and a # failed fetch onto the same empty string, so an unreadable context would buy # the human path: that is the fail-OPEN direction the tier-dispatch block above # refuses, for the reasons recorded there. The absent-versus-unknown split that # block makes is a diagnostic for the unattended loop and is deliberately not # repeated here; both take the closed branch either way. if ! CTX=$(python3 "$SCRIPTS_DIR/get_pr_context.py" --pull-request {pr} --field author_is_bot --output-format json 2>/dev/null); then if ! CTX=$(python3 "$SCRIPTS_DIR/get_pr_context.py" --pull-request {pr} --output-format json 2>/dev/null); then CTX="" fi fi if ! IS_BOT=$(printf '%s' "$CTX" | jq -r 'if (.Data.author_is_bot | type) == "boolean" then (.Data.author_is_bot | tostring) else "unknown" end' 2>/dev/null); then IS_BOT="unknown" fi IS_BOT=${IS_BOT:-unknown} if [ "${IS_BOT:-unknown}" = "false" ]; then IS_BOT_FLAG="" else IS_BOT_FLAG="--is-bot" fi # shellcheck disable=SC2086 # No --dispositions-file here either, for the reason recorded at the first # tier probe above: this reads the PR branch before any trust comparison. python3 "$SCRIPTS_DIR/test_pr_merge_ready.py" --pull-request {pr} $IS_BOT_FLAG # Per-PR live-state gate (BLOCKING per Phase 2; issue #2455). Returns # exit 0 + Data.action=ACT when safe to proceed, exit 1 + Data.action=SKIP when # the PR is merged/closed/draft or fully superseded by base. python3 "$SCRIPTS_DIR/check_pr_live_state.py" --pull-request {pr} --skip-fetch --output-format json # Round-cap circuit breaker (BLOCKING for T3/T4 per Phase 2; issue #5056). # Records one round against a hidden marker comment and returns exit 0 + # Data.action=ACT when both the round-count and wall-clock caps hold, exit 1 # + Data.action=ESCALATE when either is exceeded. Defaults: 5 rounds / 4 # hours, each overridable via --max-rounds/--max-hours or # $PR_AUTOFIX_MAX_ROUNDS/$PR_AUTOFIX_MAX_ROUND_HOURS. python3 "$SCRIPTS_DIR/check_pr_round_cap.py" --pull-request {pr} --output-format json # CI-failure triage step 1 (BLOCKING, issue #5073): before reading any log or # starting any local investigation, ask whether the same check is red on # origin/main. Exit 0 = green on main (the PR introduced it; investigate the # PR). Exit 1 = red on main (inherited; cite Data.EvidenceUrl and fix or wait # out main instead of debugging the PR). Exit 3 = cannot determine; read # Data.Verdict. PR_ONLY: the check never reports on main, so read the PR run # with `gh run view --log-failed`. CANCELLED_ON_MAIN: main has only # cancelled or stale runs, so `gh run rerun ` the Data.EvidenceRunId # run. UNKNOWN: probe failure is not absence; never treat it as green. python3 "$SCRIPTS_DIR/triage_red_check.py" --check-name "" --pull-request {pr} # Stale bot CHANGES_REQUESTED review (issue #5950). Read-only classifier: exit 0 # with Data.verdict, and Data.handoff holds the human dismissal text. Exit 2, 3, # or 4 (missing lib, sibling failure, auth) is an unknown, never a verdict. It # never dismisses a review or posts a comment. python3 "$SCRIPTS_DIR/detect_stale_bot_review.py" --pull-request {pr} --output-format json # Get CI check logs (step 2, only when the check is green on main) python3 "$SCRIPTS_DIR/get_pr_checks.py" --pull-request {pr} | \ python3 "$SCRIPTS_DIR/get_pr_check_logs.py" --pull-request {pr} --checks-input - # CLEAN path: try auto-merge only when there is pending branch-protection work to wait on. # If GitHub rejects an already-CLEAN PR with "clean status", use the printed direct-merge fallback. run_pr_mutation_if_live python3 "$SCRIPTS_DIR/set_pr_auto_merge.py" --pull-request {pr} --enable --merge-method SQUASH # Direct merge: already-CLEAN fallback or UNSTABLE state with documented non-required failures. # Pin the merge to the head read at triage; a moved head is refused (ADR-112). run_pr_mutation_if_live python3 "$SCRIPTS_DIR/merge_pr.py" --pull-request {pr} --strategy squash \ --expected-head-sha "$EXPECTED_HEAD_SHA" ``` ### Merge path by `mergeStateStatus` GitHub refuses auto-merge for `UNSTABLE` PRs (issue #2439) and may also reject an already-`CLEAN` PR because there is nothing left to wait on (issue #2450). Pick the path that matches the state: | `mergeStateStatus` | Path | Script | |---|---|---| | `CLEAN` | Auto-merge when waiting is useful; direct merge if GitHub returns the already-clean rejection | Guard `set_pr_auto_merge.py --enable`, then guard the `merge_pr.py --strategy squash` fallback | | `HAS_HOOKS` | Same path as `CLEAN`. GitHub defines it as "Mergeable with passing commit status and pre-receive hooks" | Guard `set_pr_auto_merge.py --enable`, then guard the `merge_pr.py --strategy squash` fallback | | `UNSTABLE` with documented non-required failures | Direct merge (immediate) | Guard `merge_pr.py --strategy squash` | | `BEHIND` | Update branch first, then re-classify | Guard the fetch, merge, and push separately | | `DIRTY`/`CONFLICTING` | See Stale merge-state cache pattern below | merge-resolver agent if real conflict | | Base is not the default branch (`STACKED`) | A squash merge lands on the parent branch and collapses the stack. `test_pr_merge_ready.py` keeps `CanMerge=true` and reports tier `STACKED` | Disarm auto-merge, then hand to a human. No script in this table is run | | Anything else (`UNKNOWN`, missing, future) | No verified merge path in this document. `test_pr_merge_ready.py` reports `CanMerge=false` and tier `UNSUPPORTED` | Disarm auto-merge, then hand to a human. Refusing is deliberately conservative: GitHub may well accept the merge, but nothing here has been verified against that state | `set_pr_auto_merge.py` detects the `UNSTABLE` and already-`CLEAN` rejections from GitHub's GraphQL API and emits the direct-merge fallback command in its error output (exit 3) so the operator never has to translate the generic "GraphQL request failed" message themselves. ### Merge-check exit codes: `test_pr_merged.py` As of issue #2308, `test_pr_merged.py` exits **0** on any successful query and reports merge state in the JSON `merged` field. This makes the script behave like every other shell-friendly probe: exit 0 means "I answered your question". Branch on the JSON, not the exit code. Earlier history: the script used to exit **100** when the PR was merged (Skill-PR-Review-007). Treating 100 as a failure caused wasted polling loops on PRs #2240, #2269 (#2277), and made successful merge verification look failed on PR #2289 (#2308). When invoking from autofix code: ```bash PR_NUMBER="123" resolve_pr_scripts_dir() { repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" for root in \ "${COPILOT_PLUGIN_ROOT:-}" \ "${CLAUDE_PLUGIN_ROOT:-}" \ "$repo_root/.claude" \ "${HOME:-}/.copilot/installed-plugins/_direct/project-toolkit" \ "${HOME:-}/.copilot/installed-plugins"/*/project-toolkit \ "${HOME:-}/.claude/plugins/cache"/*/project-toolkit; do if [ -n "$root" ] && [ -d "$root/skills/github/scripts/pr" ]; then printf '%s\n' "$root/skills/github/scripts/pr" return 0 fi done printf '%s\n' ".claude/skills/github/scripts/pr" } SCRIPTS_DIR="$(resolve_pr_scripts_dir)" python3 "$SCRIPTS_DIR/test_pr_merged.py" --pull-request "$PR_NUMBER" | jq -e '.merged == true' ``` To restore the legacy skip-review sentinel (only for callers that already encoded "100 = merged"): ```bash PR_NUMBER="123" resolve_pr_scripts_dir() { repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" for root in \ "${COPILOT_PLUGIN_ROOT:-}" \ "${CLAUDE_PLUGIN_ROOT:-}" \ "$repo_root/.claude" \ "${HOME:-}/.copilot/installed-plugins/_direct/project-toolkit" \ "${HOME:-}/.copilot/installed-plugins"/*/project-toolkit \ "${HOME:-}/.claude/plugins/cache"/*/project-toolkit; do if [ -n "$root" ] && [ -d "$root/skills/github/scripts/pr" ]; then printf '%s\n' "$root/skills/github/scripts/pr" return 0 fi done printf '%s\n' ".claude/skills/github/scripts/pr" } SCRIPTS_DIR="$(resolve_pr_scripts_dir)" python3 "$SCRIPTS_DIR/test_pr_merged.py" --pull-request "$PR_NUMBER" --exit-100-on-merged ``` The legacy `--exit-zero-on-merged` flag (from #2277) still parses as a no-op for backward compatibility. ## Completion Gate Run after all threads resolved and CI passes: ```bash resolve_pr_scripts_dir() { repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" for root in \ "${COPILOT_PLUGIN_ROOT:-}" \ "${CLAUDE_PLUGIN_ROOT:-}" \ "$repo_root/.claude" \ "${HOME:-}/.copilot/installed-plugins/_direct/project-toolkit" \ "${HOME:-}/.copilot/installed-plugins"/*/project-toolkit \ "${HOME:-}/.claude/plugins/cache"/*/project-toolkit; do if [ -n "$root" ] && [ -d "$root/skills/github/scripts/pr" ]; then printf '%s\n' "$root/skills/github/scripts/pr" return 0 fi done printf '%s\n' ".claude/skills/github/scripts/pr" } SCRIPTS_DIR="$(resolve_pr_scripts_dir)" CONFIG_PATH="${PR_REVIEW_CONFIG_PATH:-.claude/skills/pr-review/pr-review-config.yaml}" python3 "$SCRIPTS_DIR/run_completion_gate.py" \ --config "$CONFIG_PATH" \ --pull-request {pr} --json ``` The dispatcher enforces the Issue #5072 trust boundary before dispatching anything: exit 2 also means the config diverges from or is absent at `origin/main`, and exit 3 means trust verification was impossible; in both cases no criterion command ran and the failure is not transient. `--approve-untrusted-config` requires a human who has read the surfaced diff and applies only to exit 2; an exit-3 halt (verification impossible) cannot be overridden. ## Verification Per PR processed: - [ ] Tier classification recorded (T1-T5). - [ ] Per-PR live-state gate ran immediately before the tier's action (issue #2455): `check_pr_live_state.py --pull-request $PR --skip-fetch --output-format json`. Verdict `Data.action=ACT` recorded; `Data.action=SKIP` aborted the action and recorded the reason (merged, closed, draft, or fully superseded by base). - [ ] Auto-merge disarm ran after live-state ACT on any PR that is not provably T1 (issue #3913, and issue #5094 for the completeness half): that is every non-T1 PR, and also a T1 whose `fetched_pages_complete` was not the boolean `true`, since a tier derived from a truncated fetch has not earned the exemption. `auto_merge_method` was null or `set_pr_auto_merge.py --disable` succeeded and returned `AutoMergeEnabled: false` before any push. - [ ] Round-cap circuit breaker ran on every pass through a T3/T4 PR, after the tier was known and after the auto-merge disarm gate above (issue #5056, ordering per issue #5094): `check_pr_round_cap.py --pull-request $PR --output-format json`. `Data.action=ACT` recorded and work continued; `Data.action=ESCALATE` stopped the thread-fix loop for that PR (round cap or wall-clock budget exceeded) and the script's own PR comment carries the reason, so nothing further was posted by the agent. The breaker's ESCALATE exit hands the PR to a human, so the disarm above has to have run first. - [ ] Live-state gate re-ran immediately before any base refresh or conflict resolution (issue #4349): stale gate result from the start of the session is not sufficient; the PR can merge mid-cycle. - [ ] Every base refresh, rebase, push, auto-merge change, thread reply or resolve, and direct merge ran through `run_pr_mutation_if_live` immediately before mutation (issues #4349, #5435). - [ ] Late mutation checks matched the head SHA, base ref, and base SHA captured by the current readiness cycle; any mismatch stopped mutation and restarted readiness checks. - [ ] When `StaleDirtySuspected=true`: `set_pr_auto_merge.py disable` ran and `autoMergeRequest` confirmed null before any base-ref refresh push (issue #3913). - [ ] CI-failure triage step 1 ran before any log reading (T2/T4 only, issue #5073): `triage_red_check.py --check-name ""` verdict recorded per failing check; RED_ON_MAIN failures were attributed to main with the EvidenceUrl, not investigated on the PR. - [ ] All required CI checks pass (T2/T4 only). - [ ] Every review thread is READ, TRIAGED, SOLVED (if Blocking), REPLIED with course of action, and RESOLVED (T3/T4 only). - [ ] `mergeStateStatus` is `CLEAN` or `HAS_HOOKS` (or `UNSTABLE` with documented non-required failures). - [ ] Branch is up to date with `main` (`mergeStateStatus` not `BEHIND`). - [ ] Force-push safety check ran before any push: `git rev-parse "refs/heads/$BRANCH"` matched the PR's expected `head.sha`. - [ ] Correct merge path chosen by state: `set_pr_auto_merge.py --enable` for `CLEAN` and for `HAS_HOOKS`, which takes the `CLEAN` path unchanged, `merge_pr.py --strategy squash` for `UNSTABLE` with documented non-required failures (see "Merge path by `mergeStateStatus`" table; issue #2439). - [ ] All four Ready-to-Merge conditions hold before the merge command runs (CanMerge=True is insufficient alone). ## Anti-Patterns | Avoid | Why | Instead | |-------|-----|---------| | Mutating on a readiness verdict computed earlier in the session | A PR can merge, close, or move mid-cycle, so an old verdict authorizes a push against a tree that no longer exists | Re-run the live-state gate immediately before every mutation, through `run_pr_mutation_if_live` | | Treating `TIER=UNKNOWN` as "the gates turn off" | It breaks the two gates in opposite directions: the round cap goes inert while the disarm gate fires on every armed PR and strips auto-merge from genuine T1s | Read the tier from `test_pr_merge_ready.py`, the authoritative producer, never from `check_pr_live_state.py` | | Swapping the disarm gate and the round cap back | The breaker's ESCALATE hands the PR to a human with auto-merge possibly still armed, which is the CWE-284 case the ordering closes | Keep disarm first, then the cap, as the tier-dispatch block states | | Reading CI logs before triaging the failing check | A check red on main is not this PR's failure, and investigating it on the PR spends a round on someone else's bug | Run `triage_red_check.py` first and attribute RED_ON_MAIN with its EvidenceUrl | | Letting a T3/T4 PR iterate without a cap | PR #1887 ran 11+ bot rounds over 46 hours and PRs #1965 and #1979 ran 18 each; prose caps have been ignored every time | Call `check_pr_round_cap.py` once per pass and honor ESCALATE | | Pushing local commits found ahead of an escalated PR on the strength of their messages | PR #5344: a commit titled "drop duplicate gate" deleted the lefthook job and the one test that guarded it; the suite passed 37/37 with or without it | Check `Data.orphaned_commits.status`, `.commits[].risks`, and `.warning`, preserve the tip, and re-run the covering tests with and without each listed commit before reuse | | Dismissing a stale bot `CHANGES_REQUESTED` review, or re-requesting bot reviews until one passes | Dismissal is a self-approval action, and each push restarts the bot's round on the same lines | Run `detect_stale_bot_review.py` and hand `Data.handoff` to a human | | Trusting `CanMerge=True` as the merge decision | It is one input, not the gate; all four Ready-to-Merge conditions have to hold | Evaluate the four conditions and pick the merge path from `mergeStateStatus` | | Force-pushing without re-reading the ref | A ref you read at triage is not where the branch is now, and a bare `--force-with-lease` reads a tracking ref any fetch can advance | Match `git rev-parse "refs/heads/$BRANCH"` against the PR's expected head SHA, then pin the lease to that SHA | ## Extension Points - **A new tier.** Add its row to the tier table and its branch to the tier-dispatch block. `test_pr_merge_ready.py` owns the tier set, so the producer changes first and `tests/commands/test_pr_autofix_tier_contract.py` fails until this file agrees. - **A new merge state.** Add a row to the merge-path table and to the producer's supported set together. A state with no row here must stay UNSUPPORTED rather than fall through to a default; the test that pins that is deliberate. - **A different round budget.** `check_pr_round_cap.py` owns both the count and the wall-clock budget. Change them there and every caller moves together. - **A different concurrency boundary.** The Force-Push Safety SHA gate owns it. It is a hard gate (re-read the ref, pin `--force-with-lease` to the observed SHA), so a replacement has to be at least as strong; an advisory marker is not.