Merge-When-Confident (MWC) Session Grant
mwc (“merge when confident”) is an explicit, session-scoped user grant that authorizes the AI assistant to merge fully-clean pull requests autonomously for the duration of the current session, without asking confirmation before every merge.
Standing Scope & Policy
- Baseline Prohibition: AI assistants MUST NOT merge PRs/MRs without explicit user instruction for that specific PR. Pushing, building, or driving a PR to 100% clean CI DOES NOT grant permission to merge. Two repositories are exempted standing — see “The standing per-repository grant” below — and so are infra-only PRs in any Morrison-Lab repository — see “The standing infra-PR grant”. The Scope Limit binds both.
- MWC Override Scope: When the user explicitly issues
/mwc, the bare wordmwc, “merge when confident”, “merge at will”, or “maw”, that baseline prohibition is suspended for the current session only. The bare word is listed here, not only inCLAUDE.md’s general “Bare keyword directives” convention, so this file is self-contained: a slash command is routed to this skill by the harness itself, while a bare word in prose is a convention the model must recognize on its own, and it recognizes it most reliably when the file governing the mechanics (enable-mwc,check-mwc, the Scope Limit) names the exact form it will see rather than only implying it maps here. - Scope Limit: An MWC grant applies ONLY to PRs that are 100% clean (all CI checks passing, automated review verdicts from all available providers in the quorum clean, no unresolved comments, no open block labels). It NEVER authorizes merging a PR with failing CI, unresolved findings, pending reviews, or a missing/skipped quorum review. Nor does it authorize merging a PR that changes a rendered document (Word, PDF, slides, a manuscript) before that document has been viewed page by page at the current head, with the evidence and an independent referee read of that render posted on the PR (
review-rendered-documents). A clean automated review from every available provider evaluating the current HEAD commit is strictly required for autonomous merge under MWC; a reviewer skip notice (e.g. for quota exhaustion or workflow edits) or a fallback self-review does NOT waive this requirement, grant merge authority under MWC, or clear missing external review. A PR/MR does not count as clean for MWC if there are reviews still running. If one reviewer has reviewed a commit and reported clean, but another review is still running (for example, Copilot review running after Claude finishes, an in-progress review check run, or a pending review request), you must wait for that review to complete and see what it says. Consensus clean verdicts across all active and dispatched reviewers are strictly required to merge; a clean verdict from one reviewer never authorizes merge while another review is in flight (Morrison-Lab/ai-config#3570, citing #3469 where Claude reported clean while Copilot was still running and subsequently recommended changes). A disagreement among reviews is unresolved findings. If one review is all-clear and another raises blocking issues, nits, or any other flagged items, MWC does not authorize a merge. ARD every item from every review, then request fresh reviews.check-pr-fully-clean.pyfails that state (ai-config#2274). Run that instrument; do not hand-check the Scope Limit in its place (user directive, 2026-08-29, ai-config#2441). Checking the axes by hand is unauditable and unreproducible, and it is exactly the multi-signal judgment where the axis you are least expecting is the one dropped. In a remote/web session, where theghCLI does not exist, gather the PR’s state via MCP and pass it to--from-json, rather than treating the instrument as unavailable — seefully-cleanfor the payload keys. A later all-clear from a different reviewer does not supersede a standing not-clean; only a later clean from the same reviewer does. On GitHub, usecheck-pr-fully-clean.py; on GitLab, usescripts/check-mr-fully-clean.pywith the MR IID and project ID/path. The GitLab instrument reads every pipeline, paginated note and discussion, proves target currency, and re-reads the head before printing the pinned SHA. Merge withsha=<pinned-sha>andauto_merge=false, passing--quorum <number-of-reachable-providers>to the checker; if currency fails, rebase throughPUT /projects/:id/merge_requests/:iid/rebase, poll withinclude_rebase_in_progress=trueuntilrebase_in_progressclears, and rerun the whole gate on the new head. On GitHub, recordheadRefOidandbaseRefNamebefore the instrument runs and require both live values to equal them immediately before every direct merge, so a retarget at the same tip cannot pass with an old verdict and a concurrent push that already contains the base cannot ride a currency-only check past a verdict it never received. After the instrument passes, run the base-currency check thatfully-cleanstates in its stale-base rule (the Do bullets beginning “for a direct merge”) before the merge command, by merge mode: thegit merge-baseform in a local session, the compare endpoint’sbehind_byin a remote session withoutgit, no merge at all from a session that can run neither until #2982 wires it into the instrument, and a stop on a base that requires a merge queue until #3030 lands. On a direct merge a stale merge-base means an update pinned to the recorded head (PUT .../pulls/<N>/update-branchwithexpected_head_sha, or the MCP tool’sexpectedHeadSha; a422whose message names an expected-head mismatch (match on the substringexpected head sha, since the live text carries a curly apostrophe and a trailing period that this ASCII rendering cannot show) means another writer moved the head only if the liveheadRefOidno longer equals the SHA you pinned — re-read it and compare before settling ownership, since a correctly-lengthed but wrong-content SHA (most often one guessed or padded from an abbreviation instead of read in full) produces the byte-identical message with no other writer involved, and any other422is a failed update to stop on), a wait of a few minutes at most untilheadRefOidchanges (the update is asynchronous; expiry is a failed update to stop on and report), recording that SHA, a currency check on it, a full re-run of the gate pinned to it, then a check immediately before the merge command that the live head is still that SHA,baseRefNameis unchanged, and the base tip still equals the<pinned-tip>the currency check printed (recorded before the gate reran), repeating the cycle when any moved during the gate, and--match-head-commit "<pinned-sha>"(orexpectedHeadShaon the MCP merge tool) on the merge command itself, so the API refuses a push that lands after the read. No merge API pins the base, so on a repository without a merge queue or an up-to-date-branch requirement the base can still move between that read and the merge; where that must not happen, the server-side gate is the only closure, perfully-clean. A repeat names the moving ref: when the base moved twice it outruns the gate, so merge under strict up-to-date protection instead (or through a merge queue once #3030 lands), and when the head moved another writer is on the branch, so settle ownership perclaim-prbefore rerunning, perfully-clean. - Session Duration: The grant expires automatically when the session ends or when explicitly revoked via
/mwc revokeordisable-mwc. - The grant covers the merge decision, not what happens on
mainafter. A repo can run a workflow onpushthat never runs on the PR at all — a full or PDF render, a deploy — so a PR merged fully-clean undermwccan still turnmainred.post-merge’s step 1.1 (verify-merge-commit-ci) is what checks that, and it is not optional undermwc: an autonomous merge is not done until the merge commit’s own workflow runs are read and green.
Another session’s PR: clean, and clean for twenty minutes
Every rule above settles when a PR may be merged. None of them settles whose.
That gap has a real case behind it. A session driving its own PRs finds a peer’s sitting instrument-clean — sometimes one that directly unblocks its own work — and by the letter of the repo grant, merging it is allowed. It is still the wrong default, because a peer may have further commits planned, and merging out from under a live session destroys work it was about to push.
A peer’s PR may be merged once it has been ready to merge for more than twenty minutes (maintainer directive, 2026-08-27).
That peer’s PR must itself pass memories/reviewing-prs.md’s scope test first: opened by or assigned to the invoking user, explicitly requested, or authored by the GitHub Actions app. A peer session running under your own login satisfies the author arm, which is the case this section was written for. Another lab member’s PR that fails the test does not become yours by going quiet: it gets neither the warning comment nor the merge, and is reported to the user instead.
The interval is what does the work here, and it is doing something specific rather than being a polite pause. A session actively driving a PR pushes again within a few minutes of a clean verdict, so twenty minutes of quiet distinguishes waiting on a human from mid-round — which is the only thing you actually need to know, and the one thing you cannot ask the peer for reliably.
Start the clock at the clean verdict on the current head, not at the PR’s updatedAt, which any comment bumps, and not at the head’s commit time, which precedes the review. A push resets it: a new head means a new verdict, so a PR that was clean for an hour is freshly clean the moment it moves.
Derive the age rather than judging it, per algorithmatize-checks:
gh api repos/<owner>/<repo>/issues/<N>/comments --paginate \
| jq -s -r '[.[][] | select(.body | test("\\*\\*Claude finished"))] | last | .created_at'against date -u +%Y-%m-%dT%H:%M:%SZ.
The -s and the double flatten are both load-bearing, and their absence is invisible on any PR small enough to test on. --paginate emits one array per page rather than one combined array, so without -s the filter runs once per page and prints one timestamp per page. A PR under 100 comments has a single page and gives the right answer either way; a longer one prints several timestamps into a step that expects exactly one, which is the shape that silently produces a wrong comparison rather than an error. The same pattern is already written correctly in CLAUDE.md’s review-verdict query, and matching it is the point. Run check-pr-fully-clean.py first regardless: the threshold is a second condition, never a substitute for the clean reading, and an old not-clean PR is exactly as unmergeable as a new one.
Warn the peer before you merge, and give it five minutes to object
The twenty-minute threshold is an inference about whether a session is still working. It is a good one and it is still a guess, so it gets a cheap confirmation step rather than being trusted alone.
Ask the session directly when you can reach it. ListAgents shows in-process subagents, teammates, and peer sessions, and SendMessage reaches any of them by name. A direct answer settles the question outright and costs one round trip, so it beats any inference from timestamps.
When you cannot reach it, post a comment on the PR saying you intend to merge, then wait five more minutes before doing so. That is the asynchronous equivalent: it gives the owning session a place to say hold off on a surface it is already watching, since a session driving a PR reads its comments. Say what you are merging and why, so the objection can be specific.
The five minutes run after the comment, not concurrently with the twenty — the point is to leave a window the peer could actually notice, and a warning posted at minute nineteen has given nobody anything.
- Do: merge a peer’s PR that has been instrument-clean for over twenty minutes, and say in your report whose PR it was and how old the verdict was.
- Do: measure the verdict’s age, and state the two timestamps you compared.
- Do: message the owning session directly when
ListAgentsreaches it, rather than falling straight to the comment-and-wait. - Do: post the warning comment and wait a further five minutes when you cannot reach the session, and honour a hold-off reply.
- Don’t: merge a peer’s PR that just went clean, however much it unblocks you — that is precisely when the temptation is strongest and the peer most likely still working.
- Don’t: read the PR’s
updatedAtas the readiness time; a comment of your own can bump it, which makes a stale PR look fresh and a fresh one look older than it is. - Don’t: count the warning window inside the twenty minutes, or skip it because the PR has been quiet for hours — the whole cost is five minutes, and it is paid once.
(ai-config#2460. Measured 2026-08-27: a session driving #2433 and #2447 found #2448 — a peer’s PR fixing the very artifact blocking #2447 — at exit 0, five minutes after its clean verdict. It held, and the peer then pushed a further commit fourteen minutes later before merging the PR itself. So the counterfactual is not hypothetical: merging at the five-minute mark would have discarded a commit that did not yet exist, and nothing about the PR at that moment distinguished it from one that was finished. That is the whole argument for the interval, and for the warning step on top of it.)
Broadcast to every listed peer rather than guessing the owner from a branch name, and read replies for risk flags too, not only hold-offs. ListAgents names every peer session. Messaging only the peer whose branch name looks like a match skips the peers who are not the owner but still know something: another session tracking the same base, or one that already merged its own PR and can say so.
- Do: send the merge-intent notice to every peer
ListAgentslists, not only the branch’s apparent owner. - Do: read each reply for a substantive risk flag (a correction on
mainnot in the PR’s base, a stale-red check from a known CI bug), not only for an explicit hold-off. - Don’t: message only the single peer a branch name suggests.
- Don’t: treat a reply that names a risk as clearance because it contains no hold-off.
(Measured 2026-09-02 on Morrison-Lab/ai-config. Three peer-owned PRs, #2999, #3010, and #3029, had each been clean for over twenty minutes. The session posted a merge-intent comment on each PR, sent the same notice to all seven local peer sessions via ListAgents and SendMessage, and waited about five minutes. Six of the seven peers replied. Four replied inside the window: the owner of #2999 had merged that PR in the meantime, one peer owned an unrelated draft, one had nothing to add, and one flagged a real merge risk, a mid-file correction on main that #3029’s base did not carry. Two replied after the merges had landed: the owner of #3010 and #3029, with no hold-off, and a peer warning that red review checks from that afternoon’s reusable-workflow bug stay red after the fix and need a full re-run rather than a --failed one. Neither late reply would have changed the merge decision, and a five-minute window is expected to miss a slow reply now and then.)
Derive that a peer is gone; never assert it.
The twenty-minute threshold and the five-minute window are both inferences about a session you cannot reach, and both are written above as inferences. “The session that opened it is no longer running on this machine” is not one. It is a claim about the world, asserted in the very sentence that invites the peer to refute it, and no reader of that comment can check it.
A branch’s last push is the cheapest liveness signal available, and it is the one a live session moves. A branch pushed twenty minutes ago is a session that was working twenty minutes ago, whatever the verdict age says — and the verdict age can be comfortably past the threshold while the push is not, because a session that pushes, gets a clean verdict, and then keeps working leaves the verdict clock running and the push clock short. So read both, state both, and phrase the conclusion as what you measured: “no push in N minutes, verdict clean for M” rather than “the session is gone”.
Read the push time from the forge, which records it, rather than from git, which does not. GitHub’s repository-activity endpoint carries a real per-push timestamp:
gh api "repos/<owner>/<repo>/activity?ref=refs/heads/<branch>&per_page=10" \
--jq '.[] | "\(.timestamp) \(.activity_type) \(.after[0:8])"'2026-09-15T06:59:42Z push b00d37b2
2026-09-15T06:49:12Z branch_creation 57643c49
Those two rows are this branch’s real activity, read from that endpoint on 2026-09-15 in a session without gh on PATH; the gh api spelling above is the same request, not the call that produced them.
The obvious local substitute, git log -1 --format=%cI origin/<branch>, is wrong twice over, and both errors run in the same direction: they make a live peer look gone. That is the direction this whole section exists to guard, so the substitute fails exactly where it is being relied on.
%cI is the tip commit’s committer date, not its push time. A commit is always made before it is pushed, so the reading is a lower bound that is loose by however long the session held the commit — unbounded above, zero below. Measured on this branch, where the commit and the push were seconds apart: 57643c49’s committer date is 06:48:41Z against a 06:49:12Z branch creation, a 31-second gap that grows without limit for a branch pushed the morning after it was written.
origin/<branch> is a remote-tracking ref, which a bare git log does not refresh. Where the peer has pushed since your last fetch, the command reports your stale copy’s tip — older than reality again, and silently, since a remote-tracking ref that no longer matches the remote looks identical to one that does. check-before-pushing makes the same point about --force-with-lease: a ref you have not just fetched is a measurement of a moment that has passed.
If no forge route is available, git fetch origin <branch> first and then read %cI as what it is — a lower bound on the push time, stated as one.
A hold-off ends the window; it does not shorten it.
Above, “honour a hold-off reply” is the whole of what is said, which leaves the commonest shape unaddressed: the hold-off arrives and the five minutes keep running. They do not. The window exists to collect an objection, so an objection collected ends it, and no amount of subsequent silence converts a hold-off into consent.
The same applies to anything else that lands inside the window. The grant is conditioned on a fully clean reading (see fully-clean), and that reading is a snapshot rather than a standing guarantee, so a NOT_CLEAN result posted after the announcement invalidates it exactly as a check flipping red would. The window is precisely when such a thing arrives, since announcing an intention to merge is what prompts the owner to publish what it has. Re-read the PR immediately before merging, not the reading that opened the window.
- Do: derive a peer’s liveness from the forge’s own push timestamp before writing anything about it, and put that timestamp in the comment.
- Do:
git fetch origin <branch>first, and call%cIa lower bound, when the forge route is unavailable. - Do: state the inference as an inference, naming both clocks you read.
- Do: treat a hold-off as terminating the window at the moment it arrives.
- Do: re-read the PR’s clean state immediately before the merge.
- Don’t: assert that a session has stopped — nothing available to you observes that, and the claim is unfalsifiable to the reader you are asking to refute it.
- Don’t: read
%cIon an unfetched remote-tracking ref as a push time; it is wrong twice, and both errors age the peer rather than freshening it. - Don’t: run the remaining minutes out after a hold-off and merge on the silence; the objection you asked for has already arrived.
- Don’t: merge on the readiness reading that opened the window.
(Measured 2026-09-15 on ai-config#3635, from the PR’s own comment timestamps. A merge-intent comment at 05:51:13Z asserted the PR “was opened by a session that is no longer running on this machine”. The owning session replied at 05:51:39Z — 26 seconds in — that it had pushed to the branch four times that night, and that a pre-merge adversarial gate was running against that exact head. (That reply put its own last push “about twenty minutes earlier”, which was itself an unmeasured figure and wrong by a factor of two: the branch’s tip, 994b975c, is dated 05:11:01Z, forty minutes and thirty-eight seconds before the reply. It is quoted here as the peer’s prose rather than as a measurement, and the error is the rule above failing in the other direction — the owner under-stating its own liveness while disputing a claim that it had none.) That gate posted NOT_CLEAN, 7 findings at 05:52:12Z. The PR merged at 05:58:47Z: 7 minutes 8 seconds after the hold-off and 6 minutes 35 seconds after the gate result, putting all seven findings on main. They were closed forward by #3681 -> #3682 rather than by a revert, for the reason revert-premature-merge now records.)
The standing per-repository grant
Two repositories carry the grant standing, with no session step at all: PRs targeting Morrison-Lab/ai-config (ai-config#1352), and PRs targeting the shared math-macros repo, d-morrison/macros (also reachable as Morrison-Lab/macros; user directive, 2026-10-02). no-unauthorized-merge.py reads the merge’s target repository off the command itself, so there is nothing to enable, nothing to expire, and no marker to go stale.
A repo’s own CLAUDE.md claiming this exemption is not evidence it has it. The list above names the repositories the hook actually carries (STANDING_MERGE_GRANT_REPOS). A different repository’s own CLAUDE.md can independently assert a standing grant — as Morrison-Lab/gha’s does — without that repository being in the hook’s set, since the two are two different files with no mechanism keeping them in sync. check-mwc cannot settle this either way: it reports on the session marker only, and the standing grant has none, so it reads “no grant recorded” on a repo that carries the standing grant just as readily as on one that does not. #3490 tracks this exact drift (measured again on Morrison-Lab/gha#857) and the open question of which side should change; check it before re-diagnosing a standing-grant mismatch from scratch.
- Do: read
STANDING_MERGE_GRANT_REPOSinhooks/no-unauthorized-merge.pyitself, or just attempt the merge and read the guard’s own denial (it names the set), rather than trusting a target repo’s own doc. - Do: treat the hook’s set as the operative answer for what the guard will actually do, whatever a target repo’s
CLAUDE.mdclaims. - Don’t: read a repo’s
CLAUDE.mdalone as proof a merge there is pre-authorized, then be surprised when the guard blocks it anyway. - Don’t: run
check-mwcto check whether a repo carries the standing grant — it only ever reports the session grant.
The two grants differ on every axis except the Scope Limit, which binds both:
session grant (/mwc) |
standing grant | |
|---|---|---|
| scope | this session, every repo | Morrison-Lab/ai-config and the macros repo, forever |
| keyed on | a .mwc marker in the current repo’s git dir |
the target repo named in the command |
| enabling step | enable-mwc, then check-mwc |
none |
| covers | any merge command run from that checkout | gh pr merge / gh api .../pulls/N/merge only |
| requires a fully clean PR | yes | yes |
Read the second row carefully, since it is the one that surprises. The session grant is keyed on where you are and the standing grant on what you are merging, so the standing one is the tighter of the two: an active /mwc in an ai-config checkout authorizes gh pr merge -R other/repo, and the standing grant never does.
Three things the standing grant deliberately does not cover, each of which keeps the baseline prohibition:
- A merge with no repo named in the command. The target is read from the command text only, never from the working directory —
offendingsplits on&&, socd ../other && gh pr merge 1would otherwise resolve to ai-config.hooks/require-gh-repo-flag.pyalready refuses agh pr mergewithout-R, so this costs nothing in practice. - A repository branch merge (
gh api -X POST repos/<owner>/<name>/merges). That writes to the default branch with no PR, no review and no required check, and the grant is for PRs. - A GraphQL
mergePullRequestmutation, which names its target by node id, so no repository is derivable from the command at all.
Those last two are excluded by every interpretation the segment matches, not by the first one. The merge patterns are unanchored scans over the whole segment, so one command line can satisfy several at once — and the pulls/N/merge forms are tried before the repos/<owner>/<name>/merges ones. A real branch merge carrying a forged pulls/1/merge substring in an unmasked flag (-H, --jq) is therefore labelled a PR merge, and both the forged and the real repos/<owner>/<name>/ path name the same granted repo, so a first-label reading grants a direct push to the default branch. Reported and reproduced on ai-config#1353.
So the guard runs the same ambiguity test on two axes — what kind of merge this is, and which repository it lands in — and either one coming back undetermined denies.
What the standing grant removes is the need to ask, not the judgment about whether the PR is done. ardi’s loop still terminates by reporting the PR ready, so the grant changes what happens at that moment and not what has to be true before you get there.
Adding a repository to the grant is a one-line diff to STANDING_MERGE_GRANT_REPOS in the hook. It is deliberately not settable from the environment: ALLOW_MERGE=1 already covers the one-off case from inside the command text, and an env-settable allowlist would widen a security guard from ambient state a reader of the command cannot see.
A bare gh pr merge <N> refuses, and the refusal may diagnose the wrong thing
The uncovered case named above as “a merge with no repo named in the command” closes with “so this costs nothing in practice”. That claim is about design cost — requiring an explicit target adds no burden the corpus was not already imposing — and it is correct. What it does not say is what the operator sees, which is where the real cost lands.
With no session grant in play, both guards deny the bare form, with different messages, and the one surfaced is not necessarily the one that names the fix. Measured 2026-08-17, feeding each hook the exact payload for fully-clean PR #1598:
| command | require-gh-repo-flag.py |
no-unauthorized-merge.py |
|---|---|---|
gh pr merge 1598 --squash --delete-branch |
deny — names the missing -R, gives the fix |
deny — names permission and STANDING_MERGE_GRANT_REPOS |
gh pr merge 1598 -R Morrison-Lab/ai-config --squash --delete-branch |
allow (has -R) |
allow (exit 0) |
Nothing about the two commands differs except the explicit -R.
The session that hit this saw no-unauthorized-merge.py’s message, which talks about permission and the standing grant and says nothing about a missing flag. So the natural readings are “the grant lapsed”, “this PR does not qualify”, or “the hook is broken” — and each sends you somewhere useless: re-reading the grant, re-running check-pr-fully-clean.py, or inspecting the hook. The fix is one flag.
Which of the two messages a session sees is harness behaviour rather than anything these hooks decide, so treat the observation above as one reading and not as a rule about ordering. Either message is possible; only one of them is self-diagnosing.
It is worst exactly where it is most likely. A session working in the ai-config checkout has the least reason to name the repo, because every other gh command in that directory infers it correctly — so the habit that works everywhere else is the one that fails here.
- Do: write
-R Morrison-Lab/ai-configinto the merge command, since the grant attaches to the target named in the command text and to nothing else. - Do: re-issue with
-Ras the first response to any merge refusal in a repo that carries a grant, before investigating anything. - Don’t: read the refusal as the grant not applying, the PR not qualifying, or the hook being broken — the commonest cause is an underspecified command, and none of those three readings is checkable against it.
- Don’t: infer the target from the working directory the way the rest of
ghdoes; that is precisely the inference the guard refuses to make.
The standing infra-PR grant
A second standing grant covers infra PRs in any Morrison-Lab/* repository. An infra PR is one where every changed path is tooling or agent configuration (user directive, 2026-09-28: “let’s create a standing infra-PR mwc grant in ai-config”). INFRA_PATH_PATTERNS in hooks/no-unauthorized-merge.py is the operative list:
.github/**: CI workflows, reusable actions and their scripts, dependabot, and Copilot’s instructions.claude/**CLAUDE.md,AGENTS.mdandGEMINI.md, at any depth.lintr,.lintr.R,lychee.toml,_typos.tomlandinst/WORDLIST
A top-level scripts/ is deliberately absent, because in a content repository it can hold analysis code.
The grant is decided by what the PR changes, so the guard reads the PR’s file list from GitHub (gh api .../pulls/N/files, or direct REST via urllib when gh is not on PATH). It checks every changed path, and a renamed file’s previous path as well, so moving a content file under .github/ does not qualify. Every doubt denies:
- no single bare PR number in the command;
- a failed or timed-out fetch;
- an empty file list, or one that disagrees with the PR’s
changed_files; - a list at the API’s 3000-file cap;
- any one path outside the list;
- no pinned head commit SHA matching the PR’s head commit, or an ambiguous SHA.
The target and merge-type ambiguity tests are the per-repository grant’s, unchanged.
The PR number and pinned head commit SHA are read only from quote-masked text, so a value inside a --body or -t cannot stand in for the real one. Without that, gh pr merge -R o/r --body "see 12" would check PR 12’s files while merging the current branch’s PR. The merge must pin the exact head commit SHA (--match-head-commit <sha>, sha=<sha> in REST, or expectedHeadSha on the MCP merge tool) matching the PR’s head commit SHA fetched alongside the file list. This guarantees that if a push lands on the PR between the guard’s inspection and the merge, GitHub refuses the merge. gh pr merge --auto and the MCP auto-merge tools are not covered, because auto-merge merges whatever the PR holds once its checks pass, not what the guard just read.
The Scope Limit binds this grant exactly as it binds the other one: the guard checks what the PR changes, not whether it is fully clean. Run check-pr-fully-clean.py first.
- Do: merge a fully clean infra PR with the PR number,
-R, and--match-head-commit <sha>in the command (gh pr merge 15 -R Morrison-Lab/pds --squash --match-head-commit <sha>), or through the MCP merge tool withowner,repo,pullNumber, andexpectedHeadSha. - Do: pin the exact head commit SHA to guarantee no push landed between the clean check, the guard’s inspection, and the merge.
- Don’t: run an unpinned merge under the infra grant; unpinned merges deny because the guard cannot verify that the merged commit matches what it inspected.
- Don’t: read a refusal as the PR not being infra until the command names one PR number, one target, and pins the matching head commit SHA.
- Don’t: expect this grant to clear Claude Code’s own auto-mode permission checker, which is separate from this hook and may still need a permission rule.
NO_UNAUTHORIZED_MERGE_DISABLE_INFRA_GRANT=1 turns the grant off; nothing turns it on. The hook’s test suite sets it so that no subprocess case depends on a live PR’s files.
Session Lock & Hook Integration
no-unauthorized-merge.py enforces the baseline merge prohibition at the PreToolUse hook level, blocking gh pr merge, glab mr merge, gh api .../merge, and glab api .../merge.
When MWC is enabled for a session: 1. ai-session.sh enable-mwc creates a <sanitized-session-id>.mwc marker file in the repository’s git common directory ($(git rev-parse --git-common-dir)/ai-sessions/). 2. no-unauthorized-merge.py checks for the active session’s .mwc marker file and validates that the session is alive (is_session_alive()). 3. If an active .mwc marker exists for the current session, no-unauthorized-merge.py allows merge tool executions. 4. ai-session.sh disable-mwc removes the .mwc marker file, restoring the strict prohibition immediately.
The session id has to match on both sides, and that is the part that breaks. The guard resolves which session it is running under from the hook payload’s own session_id field, then the transcript filename stem, then AI_SESSION_ID / CLAUDE_SESSION_ID. It used to read only the two environment variables, and the hook process inherits neither, so a grant made the sanctioned way was invisible to it and every merge was blocked no matter what the user had granted (ai-config#1279). Pass the harness session id explicitly when granting, since the shell script has no payload to read.
check-mwc distinguishes three outcomes rather than reporting one sentence for all of them, because “never granted” and “granted but the session reads dead” want opposite responses:
| Exit | Meaning | What to do |
|---|---|---|
| 0 | active | nothing; the guard will honour it |
| 1 | no grant recorded | enable-mwc --id <id> |
| 2 | granted, but not currently honourable | the message names which case and the fix |
It is a query: it never prunes and never deletes the marker, so a stale read cannot silently revoke a grant, and a heartbeat restores one. Use disable-mwc, release, or prune to actually remove a grant.
Procedure for Agents Handling /mwc
When the user gives an MWC grant (e.g. /mwc or “merge when confident”):
- Run the enabling step first, and confirm it took.
skills/session-lock/scripts/ai-session.sh enable-mwc --id "<session id>"(or"${CLAUDE_PLUGIN_ROOT:+$CLAUDE_PLUGIN_ROOT/skills/session-lock/scripts/ai-session.sh}"/~/.claude/skills/session-lock/scripts/ai-session.sh) sets the session merge-permission flagno-unauthorized-merge.pyreads. This step is what makes the grant real: without the.mwcmarker it creates,no-unauthorized-merge.pycannot see the grant and correctly keeps blocking. A grant acknowledged only in prose is not a grant the machinery can see, so skipping this step leaves you believing you hold a permission that does not exist — and then reading the resulting block as an obstacle rather than as the accurate answer it is. Only then acknowledge the grant in one sentence, so the user knows it’s active for the session, and what it does and doesn’t cover. Pass--idexplicitly unlessAI_SESSION_IDorCLAUDE_SESSION_IDis set in the shell: without one the script cannot resolve an id and dies with “no session id”. Its value is the harness session id, which is the transcript filename stem. Then confirm withcheck-mwc --id "<session id>", which exits 0 only when the guard will actually honour the grant. Do not skip that confirmation: anenable-mwcthat reports success still leaves the guard blocking if the two sides resolved different ids, which is exactly what ai-config#1279 was. - Proceed with the task (e.g. driving PRs to clean via
ardi). - When a PR reaches 100% clean state, merge it immediately (default:
gh pr merge "<number>" -R "<owner>/<repo>" --squash --delete-branch --match-head-commit "<pinned-sha>", the pin being theheadRefOidrecorded before the instrument ran; on a base that requires a merge queue, stop and report instead, per #3030), verify the merge landed on GitHub/GitLab, and run the post-merge skill (post-merge/ums). - If the user revokes the grant, run
skills/session-lock/scripts/ai-session.sh disable-mwcimmediately.
What a re-grant does NOT mean
The grant is conditional, and the condition is the Scope Limit above. Re-issuing a conditional permission does not make its condition true. That distinction collapses in one specific situation, so it is worth naming mechanically rather than trusting judgment in the moment: a merge command is blocked, you ask whether to retry, and the user answers with the keyword.
Read as an answer to “should I retry?”, the keyword looks like “yes, merge that PR.” It is not. It re-states a standing, conditional policy, and the PR in front of you is precisely the one that failed the condition — otherwise nothing would have blocked. The framing of your own question is what makes the keyword look like an instruction about one specific PR.
Three rules follow, and they hold whatever the block turned out to be:
- A conditional grant re-issued in response to a blocked action is not authorization for that action. Re-check the condition before acting, and say which specific PR you are claiming the grant for and why it qualifies — naming it forces the check that the keyword bypassed.
- A permission whose enforcement is mechanized has an enabling step. Run it when the grant is given. If you did not, a block is evidence the grant is not active — not an obstacle to retry.
- Never retry a denied merge on the strength of a keyword. The denial and the grant are about different things: one is a guard’s state, the other is the user’s intent, and only the guard’s state gates the action.
Five properties of the guard worth knowing before you trust it
The denial you hit may not be this guard. no-unauthorized-merge.py is a PreToolUse hook, and a hook only runs if it is registered — which is a separate question from whether its file exists. Claude Code’s own auto-mode permission classifier blocks merge commands too, and the two are independent mechanisms. They differ in exactly the way that matters here: the hook reads a marker file, so re-asking cannot move it, while the classifier reads the conversation, so re-stating intent can. So a denial that clears on a retry was, by that fact, probably not this guard. Check registration rather than assuming, per CLAUDE.md’s “Keep ai-config and repo checkouts fresh”:
python3 <ai-config-checkout>/scripts/install-hooks.py # report only; --fix bindsMeasured on one machine, 2026-08-07: registered=0 missing=15, with enabledPlugins unset in ~/.claude/settings.json — so every guard in hooks/hooks.json, this one included, was placed but unbound. The guard’s own logic was fine: fed a gh pr merge payload directly it returned permissionDecision: deny, and with a valid marker it allowed. It simply never ran.
The denial you hit may not be a guard at all, and a merge command can stack three of these mechanisms in one shot. The paragraph above names two. There is a third, independent of both: hooks/require-gh-repo-flag.py refuses a mutating gh command — gh pr merge included — that omits an explicit -R/--repo, and it is a separate PreToolUse registration from no-unauthorized-merge.py’s. All three can refuse the same command, in whatever order the harness happens to evaluate them, each with its own remedy, and each one’s refusal text names only itself:
no-unauthorized-merge.pyandrequire-gh-repo-flag.py, the two corpusPreToolUsehooks — see “A baregh pr merge <N>refuses, and the refusal may diagnose the wrong thing” above for their measured interaction and remedies (ALLOW_MERGE=1, an active/mwcgrant, the standing per-repository grant, or the-Rflag each of those needs to resolve a single target).- Claude Code’s own auto-mode permission classifier (not a corpus hook at all) — remedy is neither of the above. No environment variable, no
/mwcgrant, and no in-conversation user instruction clears it, because it is a harness permission-mode decision about the session rather than a question of who authorized what.remind-retry-before-declaring-blocked.pycovers what a classifier denial does and does not license (an identical retry is still worth one try; varying the command or switching to a different tool for the same goal is the move Pattern 43 inmemories/mistake-patterns.mdwarns feeds the classifier’s own suspicion); past a second denial of the same goal, hand the decision to the user rather than continuing to probe.
Refusals from these three arrive one at a time, and each looks like “the merge guard” to a reader who is going by symptom (a blocked merge) rather than by the message text. Reading which one actually spoke, and applying that mechanism’s own remedy, is the fix — not re-issuing the last thing that worked for a different refusal. verify-the-right-artifact.md states the same rule at the general level (added in Morrison-Lab/ai-config#3301): answer a guard’s refusal from the guard, reading the property its message names and its discharge condition in source before checking any state outside it. Here that mechanism is itself one of at least three candidates, so the first read has to identify which.
- Do: read which of the three mechanisms produced the refusal text, and apply that mechanism’s own remedy —
ALLOW_MERGE=1/-Rfor the two corpus hooks, escalation to the user for the classifier. - Don’t: read a merge refusal as a statement about authorization in general. A corpus hook’s denial is a statement about authorization; the classifier’s is a claim about the session’s permission mode that no amount of granted authority inside the conversation changes.
The marker is per-repository, so a grant in one repo authorizes nothing in another. check_mwc_active() looks for <git-common-dir>/ai-sessions/<session>.mwc, resolved from the session’s working directory (cwd) and CLAUDE_PROJECT_DIR (where the merge command executes), which may differ from the repository named in a -R target. Enabling MWC while working in repo A therefore leaves a merge issued from repo B blocked, which is correct and easy to misread as the guard malfunctioning. It also requires AI_SESSION_ID or CLAUDE_SESSION_ID to be set; with neither set the function returns False and the guard denies even with a valid marker. Run enable-mwc and check-mwc from the repository of the session’s working directory (where the merge command runs), not merely once per session or from the -R target repository.
The grant does not expire, but its LIVENESS PROOF does, and a long session loses merge authority mid-session because of it. This is the property most likely to bite, because the two facts that produce it are individually reassuring.
The marker file is durable: check-mwc never prunes it, and the section above says a stale read “cannot silently revoke a grant”. Both true. What neither says is that the guard consults session liveness as well as the marker, and liveness goes stale after 1800 seconds without a heartbeat. So a session that grants MWC, merges happily for twenty minutes, then works on something else for an hour, finds its next merge refused — with the marker still sitting on disk exactly where it was written.
Measured 2026-08-22 on Lacaedemon/sparta. The grant was made at 19:16 and four PRs merged between 19:17 and 19:20 without incident. At 20:25 the fifth merge was refused, and check-mwc reported it precisely:
mwc is NOT active for session <id>: grant recorded, but the session reads unknown.
Marker: <git-common-dir>/ai-sessions/<id>.mwc
Last heartbeat 2026-08-22 19:16; a session goes stale after 1800s.
If the session IS live: ai-session.sh heartbeat --id '<id>'
One heartbeat --id <id> restored it and the merge went through unchanged.
The refusal is easy to misread, and every natural reading sends you somewhere useless. It looks like the grant lapsing (it did not), like the PR failing the Scope Limit (it did not), or like the guard being broken (it is working exactly as designed). The section on re-grants above is the relevant discipline here: a block is evidence the grant is not active, and the fix is to find out why rather than to retry. check-mwc answers that question in one read and names the remedy in its own output, which is why it is worth running before diagnosing anything else.
- Do: run
check-mwcfirst on any merge refusal in a session that has been running a while — its message distinguishes “never granted” from “granted but stale”. - Do:
heartbeaton a long session that will merge later, rather than waiting for the refusal. - Don’t: read a refusal after a successful earlier merge as the grant having been revoked — nothing revoked it, and the marker is still there.
- Don’t: re-run
enable-mwcto fix this. It is not what is missing, and it obscures which of the two states you were in.
Read check-mwc’s exit status from the script, not from the end of a pipe. Its three-way status (0 active, 1 no grant, 2 granted-but-not-honourable) is the whole point of the command. Piping it through tail and echoing $? reports tail’s status instead, which is 0 whatever the script said. That is how the stale grant above first read as “active” when the script had actually exited 2, and the misreading survived until the merge was refused a second time.
- Do: run the bare invocation and branch on
$?, or redirect its output to a file and echo$?from the unpiped command. - Don’t: read
$?after a pipe — it belongs to the last stage, so every three-way status collapses to whatevertail,headorgrepreturned.
Quick Reference
| Command | Effect |
|---|---|
skills/session-lock/scripts/ai-session.sh enable-mwc --id "<id>" |
Enables session-wide merge grant |
skills/session-lock/scripts/ai-session.sh disable-mwc --id "<id>" |
Revokes session-wide merge grant |
skills/session-lock/scripts/ai-session.sh check-mwc --id "<id>" |
Checks the grant, exiting 0 / 1 / 2 (see above) |
--id is optional only when AI_SESSION_ID or CLAUDE_SESSION_ID is set in the shell, which in a Claude Code session it generally is not.