[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M7355RRVTEEFJ16QVM86EK7X":3,"skill-same:01M7355RRVTEEFJ16QVM86EK7X":51},{"skillNo":4,"name":5,"nameZh":6,"description":7,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":16,"categories":17,"keywords":18,"publishedAt":19,"linkOnly":20,"markdownContent":21,"markdownBody":22,"frontMatter":23,"refName":30,"commitSha":31,"skillDirectory":32,"forksCount":33,"repositoryHtmlUrl":34,"repositoryHomepageUrl":35,"ownerHtmlUrl":36,"ownerBlogUrl":6,"ownerDescription":6,"updatedAt":37,"install":38,"resources":50},"01M7355RRVTEEFJ16QVM86EK7X","mwc",null,"Session merge grant with confidence gate.","Morrison-Lab\u002Fai-config","Morrison-Lab","https:\u002F\u002Favatars.githubusercontent.com\u002Fu\u002F104235520?v=4","01M734G0451S2G35MN4HFQK7XP","Morrison Lab","01M734G059BYFDFQA8YTPDGPMH",4,"MIT","https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fblob\u002Fmain\u002Fskills\u002Fmwc\u002FSKILL.md",[],[],"2026-11-11 15:11:14",false,"---\nname: mwc\ndescription: \"Session merge grant with confidence gate.\"\nuser-invocable: true\nallowed-tools:\n  - Bash\n  - Read\n  - Edit\n  - Write\n---\n\n# Merge-When-Confident (MWC) Session Grant\n\n`mwc` (\"merge when confident\") is an explicit, session-scoped user grant\nthat authorizes the AI assistant to merge fully-clean pull requests autonomously\nfor the duration of the current session,\nwithout asking confirmation before every merge.\n\n## Standing Scope & Policy\n\n- **Baseline Prohibition**: AI assistants MUST NOT merge PRs\u002FMRs\n  without explicit user instruction for that specific PR.\n  Pushing, building, or driving a PR to 100% clean CI\n  DOES NOT grant permission to merge.\n  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\".\n  The Scope Limit binds both.\n- **MWC Override Scope**: When the user explicitly issues `\u002Fmwc`, the bare word `mwc`, \"merge when confident\", \"merge at will\", or \"maw\", that baseline prohibition is suspended for the current session only.\n  So are their plain-English equivalents from the user (Ezra, 2026-10-23): \"merge when ready\", \"merge them all\", \"merge it\", \"go ahead and merge\", a bare \"merge\", and \"mwc them all\".\n  `hooks\u002Fremind-plain-english-merge-word.py` recognizes these mechanically and injects the reminder, so a user never has to retype `mwc`.\n  The bare word is listed here, not only in `CLAUDE.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.\n- **A bare `\u002Fmwc` means `\u002Fmwc all`** (Ezra, 2026-10-16).\n  The grant covers every in-scope PR of the session or thread --- every PR the assistant has just named, opened, or been driving --- that becomes fully clean.\n  Never answer the keyword by asking for PR numbers: the numbers are already in the thread, and the question reads as stalling.\n  Ask only when no PR is in scope at all.\n  The gates still run on each PR: the fully-clean instrument, the stale-base update, and every check finished on the current head.\n  Neither `enable-mwc` nor `no-unauthorized-merge.py` takes a PR number: the grant is session-wide, so run `enable-mwc` on the bare keyword and never refuse it for lacking one.\n- **A grant relayed from another thread counts when it carries the user's message id** (Ezra, 2026-10-16).\n  A coordinator or project-thread session may forward the user's own `\u002Fmwc`, `\u002Fmwc all`, or \"merge it\" to a working session.\n  Treat the relay as the user's grant when it quotes or cites the user's message id, and name that id in the acknowledgement so the grant is auditable.\n  A relay that paraphrases the user without an id is not a merge grant, and neither are a peer session's own words.\n  A `daytb` or `away` grant is not one either: `daytb` hands back one decision and never extends to a merge, per `CLAUDE.md`'s judgment-grants section.\n  An agent cannot verify an id itself, so name it in the acknowledgement and let the owner audit the thread.\n- **Scope Limit**: An MWC grant applies ONLY to PRs that are 100% clean\n  (all CI checks passing, automated review verdicts from all available providers in the quorum clean, no unresolved comments, no open block labels).\n  It NEVER authorizes merging a PR with failing CI, unresolved findings, pending reviews, or a missing\u002Fskipped quorum review.\n  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`](..\u002F..\u002Fshared\u002Fworkflow\u002Freview-rendered-documents.md)).\n  A clean automated review from every available provider evaluating the current HEAD commit is strictly required for autonomous merge under MWC;\n  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.\n  **A cross-model, cross-harness adversarial review evaluating the shipping head is required before merge, unless no qualifying reviewer can run.**\n  When none can (host blocked by the network policy, backend down, CLI or credential absent), enumerate every route and try each once, then treat the gate as waived: the `mwc` grant and a clean Claude review plus green CI still apply, and the PR states that the review was waived, which reviewer was attempted, and the error (Ezra, 2026-10-16;\n  [`the waiver`](..\u002F..\u002Fshared\u002Fworkflow\u002Fadversarial-self-review.md#waiver-when-no-cross-model-reviewer-can-run)).\n  A reviewer that ran and returned not-clean is not unavailable and still blocks.\n  An author-dispatched review from a reviewer differing in BOTH model and harness from the authoring session must return a 100% all-clear verdict on the shipping head before merging,\n  per [`adversarial-self-review`](..\u002F..\u002Fshared\u002Fworkflow\u002Fadversarial-self-review.md).\n  An `Agent` subagent in Claude Code and the CI `claude-review` workflow are both Claude and do NOT satisfy this requirement.\n  In local sessions, dispatch to `opencode`, `agy` CLI, or `codex` (via `dto`, `dtc`, or `adv`);\n  in cloud sessions (e.g. claude.ai), run the review via `curl` to OpenCode Go (`https:\u002F\u002Fopencode.ai\u002Fzen\u002Fgo\u002Fv1\u002Fchat\u002Fcompletions`) streaming into `skills\u002Fdelegate-to-opencode\u002Fscripts\u002Fparse-sse.py`, looping over candidate models (`deepseek-v4-pro`, `kimi-k3`, `qwen3.8-max`, `glm-5.3`) on 502s per [`delegate-to-opencode`](..\u002Fdelegate-to-opencode\u002FSKILL.md).\n  **A PR\u002FMR does not count as clean for MWC if there are reviews still running.**\n  A pending review request counts, and a per-PR `mwc` from the requested reviewer does not withdraw it\n  ([`fully-clean`](..\u002F..\u002Fshared\u002Fworkflow\u002Ffully-clean.md#a-pending-review-request-is-a-second-independent-blocker)).\n  If one reviewer has reviewed a commit and reported clean,\n  but another review is still running\n  (for example, Copilot review running after Claude finishes,\n  an in-progress review check run,\n  or a pending review request),\n  you must wait for that review to complete and see what it says.\n  Consensus clean verdicts across all active and dispatched reviewers are strictly required to merge;\n  a clean verdict from one reviewer never authorizes merge while another review is in flight\n  (Morrison-Lab\u002Fai-config#3570, citing #3469 where Claude reported clean while Copilot was still running and subsequently recommended changes).\n  **A disagreement among reviews is unresolved findings.**\n  If one review is all-clear and another raises blocking issues, nits, or any\n  other flagged items, MWC does not authorize a merge.\n  ARD every item from every review, then request fresh reviews.\n  `check-pr-fully-clean.py` fails that state (ai-config#2274).\n  **Run that instrument; do not hand-check the Scope Limit in its place**\n  (user directive, 2026-08-29, ai-config#2441).\n  Checking the axes by hand is unauditable and unreproducible, and it is\n  exactly the multi-signal judgment where the axis you are least expecting is\n  the one dropped.\n  In a remote\u002Fweb session, where the `gh` CLI does not exist, gather the PR's\n  state via MCP and pass it to `--from-json`, rather than treating the\n  instrument as unavailable --- see\n  [`fully-clean`](..\u002F..\u002Fshared\u002Fworkflow\u002Ffully-clean.md) for the payload keys.\n  A later all-clear from a different reviewer does not supersede a standing\n  not-clean; only a later clean from the same reviewer does.\n  On GitHub, use `check-pr-fully-clean.py`; on GitLab, use\n  `scripts\u002Fcheck-mr-fully-clean.py` with the MR IID and project ID\u002Fpath.\n  The GitLab instrument reads every pipeline, paginated note and discussion,\n  proves target currency, and re-reads the head before printing the pinned SHA.\n  Merge with `sha=\u003Cpinned-sha>` and `auto_merge=false`, passing\n  `--quorum \u003Cnumber-of-reachable-providers>` to the checker; if currency\n  fails, rebase through `PUT \u002Fprojects\u002F:id\u002Fmerge_requests\u002F:iid\u002Frebase`, poll\n  with `include_rebase_in_progress=true` until `rebase_in_progress` clears,\n  and rerun the whole gate on the new head.\n  On GitHub,\n  record `headRefOid` and `baseRefName` before the instrument runs and\n  require both live values to equal them immediately before every direct\n  merge, so a retarget at the same tip cannot pass with an old verdict and a\n  concurrent push that already contains the base cannot ride a\n  currency-only check past a verdict it never received.\n  After the instrument passes, run the base-currency check that\n  [`fully-clean`](..\u002F..\u002Fshared\u002Fworkflow\u002Ffully-clean.md) states in its stale-base rule\n  (the Do bullets beginning \"for a direct merge\") before the merge command,\n  by merge mode: the `git merge-base` form in a local session,\n  the compare endpoint's `behind_by` in a remote session without `git`,\n  no merge at all from a session that can run neither until\n  [#2982](https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fissues\u002F2982) wires it\n  into the instrument, and a stop on a base that requires a merge\n  queue until [#3030](https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fissues\u002F3030)\n  lands.\n  On a direct merge a stale merge-base means an update pinned to the\n  recorded head (`PUT ...\u002Fpulls\u002F\u003CN>\u002Fupdate-branch` with\n  `expected_head_sha`, or the MCP tool's `expectedHeadSha`; a `422`\n  whose message names an expected-head mismatch (match on the substring `expected head sha`, since the live text carries a curly apostrophe and a trailing period that this ASCII rendering cannot show)\n  means another writer moved the head only if the live `headRefOid` no longer\n  equals the SHA you pinned --- re-read it and compare before settling\n  ownership, since a correctly-lengthed but wrong-content SHA (most often one\n  guessed or padded from an abbreviation instead of read in full) produces\n  the byte-identical message with no other writer involved,\n  and any other `422` is a failed update to stop on),\n  a wait of a few minutes at most until `headRefOid` changes (the update is\n  asynchronous; expiry is a failed update to stop on and report),\n  recording that SHA,\n  a currency check on it,\n  a full re-run of the gate pinned to it,\n  then a check immediately before the merge command that the live head\n  is still that SHA, `baseRefName` is unchanged, and the base tip\n  still equals the `\u003Cpinned-tip>` the currency check printed (recorded\n  before the gate reran), repeating the cycle when any moved during the\n  gate,\n  and `--match-head-commit \"\u003Cpinned-sha>\"` (or `expectedHeadSha` on the\n  MCP merge tool) on the merge command itself, so the API refuses a\n  push that lands after the read.\n  No merge API pins the base, so on a repository without a merge queue\n  or an up-to-date-branch requirement the base can still move between\n  that read and the merge; where that must not happen, the server-side\n  gate is the only closure, per\n  [`fully-clean`](..\u002F..\u002Fshared\u002Fworkflow\u002Ffully-clean.md).\n  A repeat names the moving ref: when the base moved twice it outruns\n  the gate, so merge under strict up-to-date protection instead (or\n  through a merge queue once [#3030](https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fissues\u002F3030)\n  lands), and when the head moved another writer is on the branch, so\n  settle ownership per [`claim-pr`](..\u002Fclaim-pr\u002FSKILL.md) before\n  rerunning, per [`fully-clean`](..\u002F..\u002Fshared\u002Fworkflow\u002Ffully-clean.md).\n- **Session Duration**: The grant expires automatically when the session ends\n  or when explicitly revoked via `\u002Fmwc revoke` or `disable-mwc`.\n- **The grant covers the merge decision, not what happens on `main` after.**\n  A repo can run a workflow on `push` that never runs on the PR at all --- a\n  full or PDF render, a deploy ---\n  so a PR merged fully-clean under `mwc` can still turn `main` red.\n  `post-merge`'s step 1.1\n  ([`verify-merge-commit-ci`](..\u002F..\u002Fshared\u002Fworkflow\u002Fverify-merge-commit-ci.md))\n  is what checks that, and it is not optional under `mwc`:\n  an autonomous merge is not done until the merge commit's own workflow runs are read and green.\n\n## Another session's PR: clean, and clean for twenty minutes\n\nEvery rule above settles *when* a PR may be merged.\nNone of them settles *whose*.\n\nThat gap has a real case behind it.\nA 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.\nIt 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.\n\n**A peer's PR may be merged once it has been ready to merge for more than twenty minutes** (maintainer directive, 2026-08-27).\n\nThat peer's PR must itself pass `memories\u002Freviewing-prs.md`'s scope test first: opened by or assigned to the invoking user, explicitly requested, or authored by the GitHub Actions app.\nA peer session running under your own login satisfies the author arm, which is the case this section was written for.\nAnother 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.\n\nThe interval is what does the work here, and it is doing something specific rather than being a polite pause.\nA 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.\n\n**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.\nA 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.\n\nDerive the age rather than judging it, per [`algorithmatize-checks`](..\u002F..\u002Fshared\u002Fworkflow\u002Falgorithmatize-checks.md):\n\n```bash\ngh api repos\u002F\u003Cowner>\u002F\u003Crepo>\u002Fissues\u002F\u003CN>\u002Fcomments --paginate \\\n  | jq -s -r '[.[][] | select(.body | test(\"\\\\*\\\\*Claude finished\"))] | last | .created_at'\n```\n\nagainst `date -u +%Y-%m-%dT%H:%M:%SZ`.\n\nThe `-s` and the double flatten are both load-bearing, and their absence is invisible on any PR small enough to test on.\n`--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.\nA PR under 100 comments has a single page and gives the right answer either way;\na 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.\nThe same pattern is already written correctly in `CLAUDE.md`'s review-verdict query, and matching it is the point.\nRun `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.\n\n### Warn the peer before you merge, and give it five minutes to object\n\nThe twenty-minute threshold is an *inference* about whether a session is still working.\nIt is a good one and it is still a guess, so it gets a cheap confirmation step rather than being trusted alone.\n\n**Ask the session directly when you can reach it.**\n`ListAgents` shows in-process subagents, teammates, and peer sessions, and `SendMessage` reaches any of them by name.\nA direct answer settles the question outright and costs one round trip, so it beats any inference from timestamps.\n\n**When you cannot reach it, post a comment on the PR saying you intend to merge, then wait five more minutes** before doing so.\nThat 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.\nSay what you are merging and why, so the objection can be specific.\n\nThe 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.\n\n- **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.\n- **Do:** measure the verdict's age, and state the two timestamps you compared.\n- **Do:** message the owning session directly when `ListAgents` reaches it, rather than falling straight to the comment-and-wait.\n- **Do:** post the warning comment and wait a further five minutes when you cannot reach the session, and honour a hold-off reply.\n- **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.\n- **Don't:** read the PR's `updatedAt` as the readiness time;\n  a comment of your own can bump it, which makes a stale PR look fresh and a fresh one look older than it is.\n- **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.\n\n(ai-config#2460.\nMeasured 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.\nIt held, and the peer then pushed a further commit fourteen minutes later before merging the PR itself.\nSo 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.\nThat is the whole argument for the interval, and for the warning step on top of it.)\n\n**Broadcast to every listed peer rather than guessing the owner from a branch name,\nand read replies for risk flags too, not only hold-offs.**\n`ListAgents` names every peer session.\nMessaging only the peer whose branch name looks like a match\nskips the peers who are not the owner but still know something:\nanother session tracking the same base,\nor one that already merged its own PR and can say so.\n\n- **Do:** send the merge-intent notice to every peer `ListAgents` lists,\n  not only the branch's apparent owner.\n- **Do:** read each reply for a substantive risk flag\n  (a correction on `main` not in the PR's base, a stale-red check from a known CI bug),\n  not only for an explicit hold-off.\n- **Don't:** message only the single peer a branch name suggests.\n- **Don't:** treat a reply that names a risk as clearance because it contains no hold-off.\n\n(Measured 2026-09-02 on `Morrison-Lab\u002Fai-config`.\nThree peer-owned PRs,\n[#2999](https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fpull\u002F2999),\n[#3010](https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fpull\u002F3010),\nand [#3029](https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fpull\u002F3029),\nhad each been clean for over twenty minutes.\nThe session posted a merge-intent comment on each PR,\nsent the same notice to all seven local peer sessions via `ListAgents` and `SendMessage`,\nand waited about five minutes.\nSix of the seven peers replied.\nFour replied inside the window:\nthe owner of [#2999](https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fpull\u002F2999) had merged that PR in the meantime,\none peer owned an unrelated draft,\none had nothing to add,\nand one flagged a real merge risk,\na mid-file correction on `main` that [#3029](https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fpull\u002F3029)'s base did not carry.\nTwo replied after the merges had landed:\nthe owner of [#3010](https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fpull\u002F3010) and [#3029](https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fpull\u002F3029), with no hold-off,\nand a peer warning that red review checks from that afternoon's reusable-workflow bug\nstay red after the fix and need a full re-run rather than a `--failed` one.\nNeither late reply would have changed the merge decision,\nand a five-minute window is expected to miss a slow reply now and then.)\n\n**Derive that a peer is gone; never assert it.**\n\nThe twenty-minute threshold and the five-minute window are both inferences\nabout a session you cannot reach, and both are written above as inferences.\n\"The session that opened it is no longer running on this machine\" is not one.\nIt is a claim about the world, asserted in the very sentence that invites the\npeer to refute it, and no reader of that comment can check it.\n\nA branch's last **push** is the cheapest liveness signal available, and it is\nthe one a live session moves.\nA branch pushed twenty minutes ago is a session that was working twenty minutes\nago, whatever the verdict age says --- and the verdict age can be comfortably\npast the threshold while the push is not, because a session that pushes, gets a\nclean verdict, and then keeps working leaves the verdict clock running and the\npush clock short.\nSo read both, state both, and phrase the conclusion as what you measured:\n\"no push in N minutes, verdict clean for M\" rather than \"the session is gone\".\n\n**Read the push time from the forge, which records it, rather than from git,\nwhich does not.**\nGitHub's repository-activity endpoint carries a real per-push timestamp:\n\n```bash\ngh api \"repos\u002F\u003Cowner>\u002F\u003Crepo>\u002Factivity?ref=refs\u002Fheads\u002F\u003Cbranch>&per_page=10\" \\\n  --jq '.[] | \"\\(.timestamp)  \\(.activity_type)  \\(.after[0:8])\"'\n```\n\n```\n2026-09-15T06:59:42Z  push             b00d37b2\n2026-09-15T06:49:12Z  branch_creation  57643c49\n```\n\nThose two rows are this branch's real activity, read from that endpoint on\n2026-09-15 in a session without `gh` on `PATH`;\nthe `gh api` spelling above is the same request, not the call that produced\nthem.\n\nThe obvious local substitute, `git log -1 --format=%cI origin\u002F\u003Cbranch>`, is\nwrong twice over, and **both errors run in the same direction: they make a live\npeer look gone.**\nThat is the direction this whole section exists to guard, so the substitute\nfails exactly where it is being relied on.\n\n`%cI` is the tip commit's **committer date**, not its push time.\nA commit is always made before it is pushed, so the reading is a lower bound\nthat is loose by however long the session held the commit --- unbounded above,\nzero below.\nMeasured on this branch, where the commit and the push were seconds apart:\n`57643c49`'s committer date is `06:48:41Z` against a `06:49:12Z` branch\ncreation, a 31-second gap that grows without limit for a branch pushed the\nmorning after it was written.\n\n`origin\u002F\u003Cbranch>` is a **remote-tracking ref**, which a bare `git log` does not\nrefresh.\nWhere the peer has pushed since your last fetch, the command reports your stale\ncopy's tip --- older than reality again, and silently, since a remote-tracking\nref that no longer matches the remote looks identical to one that does.\n[`check-before-pushing`](..\u002F..\u002Fshared\u002Fworkflow\u002Fcheck-before-pushing.md) makes\nthe same point about `--force-with-lease`: a ref you have not just fetched is a\nmeasurement of a moment that has passed.\n\nIf no forge route is available, `git fetch origin \u003Cbranch>` first and then read\n`%cI` as what it is --- **a lower bound on the push time**, stated as one.\n\n**A hold-off ends the window; it does not shorten it.**\n\nAbove, \"honour a hold-off reply\" is the whole of what is said, which leaves the\ncommonest shape unaddressed: the hold-off arrives and the five minutes keep\nrunning.\nThey do not.\nThe window exists to collect an objection, so an objection collected ends it,\nand no amount of subsequent silence converts a hold-off into consent.\n\nThe same applies to anything else that lands inside the window.\nThe grant is conditioned on a **fully clean** reading (see\n[`fully-clean`](..\u002F..\u002Fshared\u002Fworkflow\u002Ffully-clean.md)), and that reading is a\nsnapshot rather than a standing guarantee, so a NOT_CLEAN result posted after\nthe announcement invalidates it exactly as a check flipping red would.\nThe window is precisely when such a thing arrives, since announcing an\nintention to merge is what prompts the owner to publish what it has.\nRe-read the PR immediately before merging, not the reading that opened the\nwindow.\n\n- **Do:** derive a peer's liveness from the forge's own push timestamp before\n  writing anything about it, and put that timestamp in the comment.\n- **Do:** `git fetch origin \u003Cbranch>` first, and call `%cI` a lower bound, when\n  the forge route is unavailable.\n- **Do:** state the inference as an inference, naming both clocks you read.\n- **Do:** treat a hold-off as terminating the window at the moment it arrives.\n- **Do:** re-read the PR's clean state immediately before the merge.\n- **Don't:** assert that a session has stopped --- nothing available to you\n  observes that, and the claim is unfalsifiable to the reader you are asking to\n  refute it.\n- **Don't:** read `%cI` on an unfetched remote-tracking ref as a push time;\n  it is wrong twice, and both errors age the peer rather than freshening it.\n- **Don't:** run the remaining minutes out after a hold-off and merge on the\n  silence;\n  the objection you asked for has already arrived.\n- **Don't:** merge on the readiness reading that opened the window.\n\n(Measured 2026-09-15 on\n[ai-config#3635](https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fpull\u002F3635), from the\nPR's own comment timestamps.\nA merge-intent comment at `05:51:13Z` asserted the PR \"was opened by a session\nthat is no longer running on this machine\".\nThe owning session replied at `05:51:39Z` --- 26 seconds in --- that it had\npushed to the branch four times that night, and that a pre-merge adversarial\ngate was running against that exact head.\n(That reply put its own last push \"about twenty minutes earlier\", which was\nitself an unmeasured figure and wrong by a factor of two: the branch's tip,\n`994b975c`, is dated `05:11:01Z`, forty minutes and thirty-eight seconds before\nthe reply.\nIt is quoted here as the peer's prose rather than as a measurement, and the\nerror is the rule above failing in the *other* direction --- the owner\nunder-stating its own liveness while disputing a claim that it had none.)\nThat gate posted **NOT_CLEAN, 7 findings** at `05:52:12Z`.\nThe PR merged at `05:58:47Z`: 7 minutes 8 seconds after the hold-off and 6\nminutes 35 seconds after the gate result, putting all seven findings on `main`.\nThey were closed forward by\n[#3681](https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fissues\u002F3681) ->\n[#3682](https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fpull\u002F3682) rather than by a\nrevert, for the reason\n[`revert-premature-merge`](..\u002F..\u002Fshared\u002Fworkflow\u002Frevert-premature-merge.md)\nnow records.)\n\n### `ListAgents` does not reach a project-thread session, and the author field cannot tell you whose PR it is\n\nBoth mechanics above assume a peer you can enumerate and a PR you can attribute.\nIn a Claude Code **project thread** session neither holds, and both fail quietly rather than erroring.\n\n**`ListAgents` names in-process subagents and local peer sessions, and returns nothing for a sibling thread session in the same project.**\nSo the \"ask the session directly\" step reads as unavailable when it is merely being asked through the wrong tool, and a session that stops there falls straight to comment-and-wait for no reason.\nThe route that does reach them is `mcp__hearthbot__list_thread_sessions` to enumerate, then `mcp__claude-code-remote__send_message` to the returned `session_id`.\n\n**Every thread session posts as `claude[bot]`, so the PR's `user.login` is the same for yours and the peer's.**\nThe author arm of the scope test is satisfied by all of them at once, which makes it useless for telling them apart.\nThe `head.ref` does distinguish: a thread's own branch is `claude\u002Fproject-thread-\u003Cslug>`, and a named feature branch belongs to whoever cut it.\n\n- **Do:** enumerate peers with `list_thread_sessions` and message them with `send_message` before falling back to comment-and-wait.\n- **Do:** compare the PR's `head.ref` against your own branch to decide whether a PR is yours.\n- **Don't:** read an empty `ListAgents` in a thread session as \"no peer is reachable\".\n- **Don't:** use the PR's author to decide whose PR it is under a shared bot identity.\n\n(Measured 2026-09-18 on [ai-config#3737](https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fpull\u002F3737), as a violation of the rule above rather than an application of it.\nA thread session merged that peer-owned PR nine minutes after its clean verdict landed, with no hold-off comment and no message to the session driving it.\nNothing was lost --- the PR was fully clean and the maintainer had told that session to merge when ready --- but its own merge call had failed with a `409 Head branch was modified` seconds earlier, so it was actively working the PR at the moment it was taken.\nThe scorer is what made this feel authorized: `check-pr-fully-clean.py` answers *is this PR mergeable*, which is a different question from *is this PR mine to merge*, and nothing in it knows the second --- so a clean exit reads as complete authorization when it is half of it.\nA clean scorer exit is not the hold-off, and neither is a standing merge grant.)\n\n## The standing per-repository grant\n\nThree repositories carry the grant **standing**, with no session step at all: PRs targeting `Morrison-Lab\u002Fai-config` (ai-config#1352), PRs targeting the shared math-macros repo, `d-morrison\u002Fmacros` (also reachable as `Morrison-Lab\u002Fmacros`;\nuser directive, 2026-10-02), and PRs targeting `Morrison-Lab\u002Fgha` (user directive, 2026-10-16).\n`no-unauthorized-merge.py` reads the merge's target repository off the command\nitself, so there is nothing to enable, nothing to expire, and no marker to go\nstale.\n\n**A repo's own `CLAUDE.md` claiming this exemption is not evidence it has it.**\nThe list above names the repositories the hook actually carries (`STANDING_MERGE_GRANT_REPOS`).\nA different repository's own `CLAUDE.md` can independently assert a standing grant --- as `Morrison-Lab\u002Fgha`'s did before the hook carried it --- without that repository being in the hook's set, since the two are two different files with no mechanism keeping them in sync.\n`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.\n[#3490](https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fissues\u002F3490) tracks this exact drift (measured again on `Morrison-Lab\u002Fgha#857`) and the open question of which side should change;\ncheck it before re-diagnosing a standing-grant mismatch from scratch.\n\n- **Do:** read `STANDING_MERGE_GRANT_REPOS` in `hooks\u002Fno-unauthorized-merge.py` itself, 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.\n- **Do:** treat the hook's set as the operative answer for what the guard will actually do, whatever a target repo's `CLAUDE.md` claims.\n- **Don't:** read a repo's `CLAUDE.md` alone as proof a merge there is pre-authorized, then be surprised when the guard blocks it anyway.\n- **Don't:** run `check-mwc` to check whether a repo carries the *standing* grant --- it only ever reports the session grant.\n\nThe two grants differ on every axis except the Scope Limit, which binds both:\n\n| | session grant (`\u002Fmwc`) | standing grant |\n| :--- | :--- | :--- |\n| scope | this session, every repo | `Morrison-Lab\u002Fai-config` and the macros repo, forever |\n| keyed on | a `.mwc` marker in the **current** repo's git dir | the **target** repo named in the command |\n| enabling step | `enable-mwc`, then `check-mwc` | none |\n| covers | any merge command run from that checkout | `gh pr merge` \u002F `gh api ...\u002Fpulls\u002FN\u002Fmerge` only |\n| requires a fully clean PR | yes | yes |\n\nRead the second row carefully, since it is the one that surprises.\nThe session grant is keyed on **where you are** and the standing grant on\n**what you are merging**, so the standing one is the tighter of the two: an\nactive `\u002Fmwc` in an ai-config checkout authorizes `gh pr merge -R other\u002Frepo`,\nand the standing grant never does.\n\nThree things the standing grant deliberately does not cover, each of which\nkeeps the baseline prohibition:\n\n- **A merge with no repo named in the command.**\n  The target is read from the command text only, never from the working\n  directory --- `offending` splits on `&&`, so `cd ..\u002Fother && gh pr merge 1`\n  would otherwise resolve to ai-config.\n  `hooks\u002Frequire-gh-repo-flag.py` already refuses a `gh pr merge` without `-R`,\n  so this costs nothing in practice.\n- **A repository branch merge** (`gh api -X POST repos\u002F\u003Cowner>\u002F\u003Cname>\u002Fmerges`).\n  That writes to the default branch with no PR, no review and no required\n  check, and the grant is for PRs.\n- **A GraphQL `mergePullRequest` mutation**, which names its target by node id,\n  so no repository is derivable from the command at all.\n\nThose last two are excluded by **every** interpretation the segment matches,\nnot by the first one.\nThe merge patterns are unanchored scans over the whole segment, so one command\nline can satisfy several at once --- and the `pulls\u002FN\u002Fmerge` forms are tried\nbefore the `repos\u002F\u003Cowner>\u002F\u003Cname>\u002Fmerges` ones.\nA real branch merge carrying a forged `pulls\u002F1\u002Fmerge` substring in an unmasked\nflag (`-H`, `--jq`) is therefore *labelled* a PR merge, and both the forged and\nthe real `repos\u002F\u003Cowner>\u002F\u003Cname>\u002F` path name the same granted repo, so a\nfirst-label reading grants a direct push to the default branch.\nReported and reproduced on ai-config#1353.\n\nSo the guard runs the same ambiguity test on two axes --- **what kind** of\nmerge this is, and **which repository** it lands in --- and either one coming\nback undetermined denies.\n\nWhat the standing grant removes is the need to **ask**, not the judgment about\nwhether the PR is done.\n[`ardi`](..\u002F..\u002Fshared\u002Fworkflow\u002Fardi.md)'s loop still terminates by reporting the\nPR ready, so the grant changes what happens at that moment and not what has to\nbe true before you get there.\n\nAdding a repository to the grant is a one-line diff to\n`STANDING_MERGE_GRANT_REPOS` in the hook.\nIt is deliberately not settable from the environment: `ALLOW_MERGE=1` already\ncovers the one-off case from inside the command text, and an env-settable\nallowlist would widen a security guard from ambient state a reader of the\ncommand cannot see.\n\n### A bare `gh pr merge \u003CN>` refuses, and the refusal may diagnose the wrong thing\n\nThe uncovered case named above as \"a merge with no repo named in the command\" closes with \"so this costs nothing in practice\".\nThat claim is about **design cost** --- requiring an explicit target adds no burden the corpus was not already imposing --- and it is correct.\nWhat it does not say is what the *operator* sees, which is where the real cost lands.\n\nWith 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.\nMeasured 2026-08-17, feeding each hook the exact payload for fully-clean PR #1598:\n\n| command | `require-gh-repo-flag.py` | `no-unauthorized-merge.py` |\n| :--- | :--- | :--- |\n| `gh pr merge 1598 --squash --delete-branch` | deny --- names the missing `-R`, gives the fix | deny --- names permission and `STANDING_MERGE_GRANT_REPOS` |\n| `gh pr merge 1598 -R Morrison-Lab\u002Fai-config --squash --delete-branch` | allow (has `-R`) | allow (exit 0) |\n\nNothing about the two commands differs except the explicit `-R`.\n\nThe 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.\nSo 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.\nThe fix is one flag.\n\nWhich 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.\nEither message is possible; only one of them is self-diagnosing.\n\nIt is worst exactly where it is most likely.\nA 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.\n\n- **Do:** write `-R Morrison-Lab\u002Fai-config` into the merge command, since the grant attaches to the target named in the command text and to nothing else.\n- **Do:** re-issue with `-R` as the first response to any merge refusal in a repo that carries a grant, before investigating anything.\n- **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.\n- **Don't:** infer the target from the working directory the way the rest of `gh` does; that is precisely the inference the guard refuses to make.\n\n## The standing infra-PR grant\n\nA second standing grant covers **infra PRs** in any `Morrison-Lab\u002F*` repository.\nAn infra PR is one where every changed path is tooling or agent configuration\n(user directive, 2026-09-28: \"let's create a standing infra-PR mwc grant in ai-config\").\n`INFRA_PATH_PATTERNS` in `hooks\u002Fno-unauthorized-merge.py` is the operative list:\n\n- `.github\u002F**`: CI workflows, reusable actions and their scripts, dependabot,\n  and Copilot's instructions\n- `.claude\u002F**`\n- `CLAUDE.md`, `AGENTS.md` and `GEMINI.md`, at any depth\n- `.lintr`, `.lintr.R`, `lychee.toml`, `_typos.toml` and `inst\u002FWORDLIST`\n- `tools\u002Fcheck_*`, `tools\u002Fcheck-*` and `tools\u002Ftests\u002F**`\n- preview and CI helpers under `tools\u002F`, by name (`skip-pdf-in-previews`, `rerun_timed_out`): each changes only what a PR preview or a CI run does, never the published site (ai-config#5293)\n\nA top-level `scripts\u002F` is deliberately absent, because in a content repository it can hold analysis code.\nSo are the other `tools\u002F` files: figure, data and content generators, and build steps that shape the published site.\n`scripts\u002Fmerge-infra-pr` reads this same list from a fresh clone of ai-config's `main`, adding only `changelog.d\u002F`.\nA change to the list therefore takes effect for the wrapper once it merges, not on its own PR.\n\nThe grant is decided by what the PR changes,\nso the guard reads the PR's file list from GitHub (`gh api ...\u002Fpulls\u002FN\u002Ffiles`,\nor direct REST via `urllib` when `gh` is not on `PATH`).\nIt checks every changed path, and a renamed file's previous path as well, so moving a content file under `.github\u002F` does not qualify.\nEvery doubt denies:\n\n- no single bare PR number in the command;\n- a failed or timed-out fetch;\n- an empty file list, or one that disagrees with the PR's `changed_files`;\n- a list at the API's 3000-file cap;\n- any one path outside the list;\n- no pinned head commit SHA matching the PR's head commit, or an ambiguous SHA.\n\nThe target and merge-type ambiguity tests are the per-repository grant's, unchanged.\n\nThe 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.\nWithout that, `gh pr merge -R o\u002Fr --body \"see 12\"` would check PR 12's files while merging the current branch's PR.\nThe merge must pin the exact head commit SHA (`--match-head-commit \u003Csha>`, `sha=\u003Csha>` in REST, or `expectedHeadSha` on the MCP merge tool) matching the PR's head commit SHA fetched alongside the file list.\nThis guarantees that if a push lands on the PR between the guard's inspection and the merge, GitHub refuses the merge.\n`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.\n\nThe 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.\nRun `check-pr-fully-clean.py` first.\n\n- **Do:** merge a fully clean infra PR with the PR number, `-R`, and `--match-head-commit \u003Csha>` in the command (`gh pr merge 15 -R Morrison-Lab\u002Fpds --squash --match-head-commit \u003Csha>`), or through the MCP merge tool with `owner`, `repo`, `pullNumber`, and `expectedHeadSha`.\n- **Do:** pin the exact head commit SHA to guarantee no push landed between the clean check, the guard's inspection, and the merge.\n- **Don't:** run an unpinned merge under the infra grant;\n  unpinned merges deny because the guard cannot verify that the merged commit matches what it inspected.\n- **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.\n- **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.\n\n`NO_UNAUTHORIZED_MERGE_DISABLE_INFRA_GRANT=1` turns the grant off; nothing turns it on.\nThe hook's test suite sets it so that no subprocess case depends on a live PR's files.\n\n## Session Lock & Hook Integration\n\n`no-unauthorized-merge.py` enforces the baseline merge prohibition at the `PreToolUse` hook level,\nblocking `gh pr merge`, `glab mr merge`, `gh api ...\u002Fmerge`, and `glab api ...\u002Fmerge`.\n\nWhen MWC is enabled for a session:\n1. `ai-session.sh enable-mwc` creates a `\u003Csanitized-session-id>.mwc` marker file\n   in the repository's git common directory (`$(git rev-parse --git-common-dir)\u002Fai-sessions\u002F`).\n2. `no-unauthorized-merge.py` checks for the active session's `.mwc` marker file\n   and validates that the session is alive (`is_session_alive()`).\n3. If an active `.mwc` marker exists for the current session, `no-unauthorized-merge.py` allows merge tool executions.\n4. `ai-session.sh disable-mwc` removes the `.mwc` marker file, restoring the strict prohibition immediately.\n\n**The session id has to match on both sides, and that is the part that breaks.**\nThe guard resolves which session it is running under from the hook payload's own\n`session_id` field, then the transcript filename stem, then `AI_SESSION_ID` \u002F\n`CLAUDE_SESSION_ID`.\nIt used to read only the two environment variables, and the hook process inherits\nneither, so a grant made the sanctioned way was invisible to it and every merge was\nblocked no matter what the user had granted (ai-config#1279).\nPass the harness session id explicitly when granting, since the shell script has no\npayload to read.\n\n`check-mwc` distinguishes three outcomes rather than reporting one sentence for all\nof them, because \"never granted\" and \"granted but the session reads dead\" want\nopposite responses:\n\n| Exit | Meaning | What to do |\n| :--- | :--- | :--- |\n| 0 | active | nothing; the guard will honour it |\n| 1 | no grant recorded | `enable-mwc --id \u003Cid>` |\n| 2 | granted, but not currently honourable | the message names which case and the fix; one case is an `--id` that is neither the harness session id nor a transcript stem, which the guard never reads (ai-config#4903) |\n\nIt is a **query**: it never prunes and never deletes the marker, so a stale read\ncannot silently revoke a grant, and a `heartbeat` restores one.\nUse `disable-mwc`, `release`, or `prune` to actually remove a grant.\n\n## Procedure for Agents Handling `\u002Fmwc`\n\nWhen the user gives an MWC grant (e.g. `\u002Fmwc` or \"merge when confident\"):\n\n1. **Run the enabling step first, and confirm it took.**\n   `skills\u002Fsession-lock\u002Fscripts\u002Fai-session.sh enable-mwc --id \"\u003Csession id>\"`\n   (or `\"${CLAUDE_PLUGIN_ROOT:+$CLAUDE_PLUGIN_ROOT\u002Fskills\u002Fsession-lock\u002Fscripts\u002Fai-session.sh}\"` \u002F `~\u002F.claude\u002Fskills\u002Fsession-lock\u002Fscripts\u002Fai-session.sh`)\n   sets the session merge-permission flag `no-unauthorized-merge.py` reads.\n   This step is what makes the grant real: without the `.mwc` marker it creates,\n   `no-unauthorized-merge.py` cannot see the grant and correctly keeps blocking.\n   **A grant acknowledged only in prose is not a grant the machinery can see**,\n   so skipping this step leaves you believing you hold a permission that does not\n   exist --- and then reading the resulting block as an obstacle rather than as\n   the accurate answer it is.\n   Only then acknowledge the grant in one sentence,\n   so the user knows it's active for the session,\n   and what it does and doesn't cover.\n   Pass `--id` explicitly unless `AI_SESSION_ID` or `CLAUDE_SESSION_ID` is set in\n   the shell: without one the script cannot resolve an id and dies with\n   \"no session id\".\n   Its value is the harness session id, which is the transcript filename stem.\n   Then confirm with `check-mwc --id \"\u003Csession id>\"`,\n   which exits 0 only when the guard will actually honour the grant.\n   Do not skip that confirmation:\n   an `enable-mwc` that reports success still leaves the guard blocking if the\n   two sides resolved different ids, which is exactly what ai-config#1279 was.\n2. Proceed with the task (e.g. driving PRs to clean via `ardi`).\n3. When a PR reaches 100% clean state:\n   - **Verify or run cross-model, cross-harness adversarial review**:\n     Confirm the shipping HEAD commit has received a 100% all-clear verdict from an author-dispatched cross-model, cross-harness reviewer,\n     per [Standing Scope & Policy](#standing-scope--policy) above and [`adversarial-self-review`](..\u002F..\u002Fshared\u002Fworkflow\u002Fadversarial-self-review.md)\n     (local: `dto`\u002F`dtc`\u002F`adv`; cloud: `curl` to OpenCode Go with candidate model fallback per [`delegate-to-opencode`](..\u002Fdelegate-to-opencode\u002FSKILL.md)).\n     Never proceed to the merge command before this verdict is clean, except under [the waiver](..\u002F..\u002Fshared\u002Fworkflow\u002Fadversarial-self-review.md#waiver-when-no-cross-model-reviewer-can-run) when no qualifying reviewer can run (every route tried once, error recorded in the PR).\n   - **Verify base currency and pre-merge pins**:\n     Run the base-currency check that [`fully-clean`](..\u002F..\u002Fshared\u002Fworkflow\u002Ffully-clean.md) states in its stale-base rule before the merge command.\n   - **Merge**:\n     `gh pr merge \"\u003Cnumber>\" -R \"\u003Cowner>\u002F\u003Crepo>\" --squash --delete-branch --match-head-commit \"\u003Cpinned-sha>\"`,\n     the pin being the `headRefOid` recorded before the instrument ran;\n     on a base that requires a merge queue, stop and report instead, per\n     [#3030](https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fissues\u002F3030).\n   - **Wrap up**:\n     Verify the merge landed on GitHub\u002FGitLab,\n     verify CI passed on the merge commit itself (`scripts\u002Fcheck-merge-commit-ci.py`),\n     and run the post-merge skill (`post-merge` \u002F `ums`).\n4. If the user revokes the grant, run `skills\u002Fsession-lock\u002Fscripts\u002Fai-session.sh disable-mwc` immediately.\n\n## An mwc carries to a new head only for a named PR\n\nAn mwc line covers the PRs it names.\nIt carries to a new head of a named PR when the content is unchanged beyond a main merge.\nIt never extends to a PR that no mwc line names, however unchanged that PR's content is\n(the coordinator told the runner an earlier mwc covered an unnamed PR on 2026-11-10, ai-config#5275).\nNo hook can enforce this: whether a line names a PR is a reading of Ezra's wording.\nSo cite the naming line when you report the merge.\n\n- **Do:** find the mwc line that names the PR before treating it as granted.\n- **Don't:** infer a grant from unchanged content, a shared branch or a stack position.\n\n## What a re-grant does NOT mean\n\nThe grant is **conditional**, and the condition is the Scope Limit above.\nRe-issuing a conditional permission does not make its condition true.\nThat distinction collapses in one specific situation, so it is worth naming\nmechanically rather than trusting judgment in the moment:\na merge command is blocked, you ask whether to retry, and the user answers with\nthe keyword.\n\nRead as an answer to \"should I retry?\", the keyword looks like \"yes, merge that\nPR.\"\nIt is not.\nIt re-states a standing, conditional policy, and the PR in front of you is\nprecisely the one that failed the condition --- otherwise nothing would have\nblocked.\nThe framing of your own question is what makes the keyword look like an\ninstruction about one specific PR.\n\nThree rules follow, and they hold whatever the block turned out to be:\n\n- **A conditional grant re-issued in response to a blocked action is not\n  authorization for that action.**\n  Re-check the condition before acting, and say which specific PR you are\n  claiming the grant for and why it qualifies --- naming it forces the check\n  that the keyword bypassed.\n- **A permission whose enforcement is mechanized has an enabling step.**\n  Run it when the grant is given.\n  If you did not, a block is evidence the grant is **not active** --- not an\n  obstacle to retry.\n- **Never retry a denied merge on the strength of a keyword.**\n  The denial and the grant are about different things: one is a guard's state,\n  the other is the user's intent, and only the guard's state gates the action.\n\n## Five properties of the guard worth knowing before you trust it\n\n**The denial you hit may not be this guard.**\n`no-unauthorized-merge.py` is a `PreToolUse` hook, and a hook only runs if it is\n**registered** --- which is a separate question from whether its file exists.\nClaude Code's own auto-mode permission classifier blocks merge commands too, and\nthe two are independent mechanisms.\nThey differ in exactly the way that matters here: the hook reads a **marker\nfile**, so re-asking cannot move it, while the classifier reads the\n**conversation**, so re-stating intent can.\nSo a denial that clears on a retry was, by that fact, probably not this guard.\nCheck registration rather than assuming, per `CLAUDE.md`'s\n\"Keep ai-config and repo checkouts fresh\":\n\n```bash\npython3 \u003Cai-config-checkout>\u002Fscripts\u002Finstall-hooks.py   # report only; --fix binds\n```\n\nMeasured on one machine, 2026-08-07: `registered=0 missing=15`, with\n`enabledPlugins` unset in `~\u002F.claude\u002Fsettings.json` --- so every guard in\n`hooks\u002Fhooks.json`, this one included, was placed but unbound.\nThe guard's own logic was fine: fed a `gh pr merge` payload directly it returned\n`permissionDecision: deny`, and with a valid marker it allowed.\nIt simply never ran.\n\n**The denial you hit may not be a guard at all, and a merge command can stack three of these mechanisms in one shot.**\nThe paragraph above names two.\nThere is a third, independent of both: `hooks\u002Frequire-gh-repo-flag.py` refuses a mutating `gh` command --- `gh pr merge` included --- that omits an explicit `-R`\u002F`--repo`, and it is a separate `PreToolUse` registration from `no-unauthorized-merge.py`'s.\nAll 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:\n\n1. `no-unauthorized-merge.py` and `require-gh-repo-flag.py`, the two corpus `PreToolUse` hooks --- see \"A bare `gh pr merge \u003CN>` refuses, and the refusal may diagnose the wrong thing\" above for their measured interaction and remedies (`ALLOW_MERGE=1`, an active `\u002Fmwc` grant, the standing per-repository grant, or the `-R` flag each of those needs to resolve a single target).\n2. Claude Code's own auto-mode permission classifier (not a corpus hook at all) --- remedy is neither of the above.\n   No environment variable, no `\u002Fmwc` grant, 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.\n   [`remind-retry-before-declaring-blocked.py`](..\u002F..\u002Fhooks\u002Fremind-retry-before-declaring-blocked.py) covers what a classifier denial does and does not license (an identical retry is still worth one try;\n   varying the command or switching to a different tool for the same goal is the move Pattern 43 in `memories\u002Fmistake-patterns.md` warns feeds the classifier's own suspicion);\n   past a second denial of the same goal, hand the decision to the user rather than continuing to probe.\n\nRefusals 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.\nReading 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.\n[`verify-the-right-artifact.md`](..\u002F..\u002Fshared\u002Fworkflow\u002Fverify-the-right-artifact.md) states the same rule at the general level (added in Morrison-Lab\u002Fai-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.\nHere that mechanism is itself one of at least three candidates, so the first read has to identify *which*.\n\n- **Do:** read which of the three mechanisms produced the refusal text, and apply that mechanism's own remedy --- `ALLOW_MERGE=1`\u002F`-R` for the two corpus hooks, escalation to the user for the classifier.\n- **Don't:** read a merge refusal as a statement about authorization in general.\n  A corpus hook's denial is a statement about authorization;\n  the classifier's is a claim about the session's permission mode that no amount of granted authority inside the conversation changes.\n\n**The marker is per-repository, so a grant in one repo authorizes nothing in another.**\n`check_mwc_active()` looks for `\u003Cgit-common-dir>\u002Fai-sessions\u002F\u003Csession>.mwc`,\nresolved from the session's working directory (`cwd`) and `CLAUDE_PROJECT_DIR`\n(where the merge command executes), which may differ from the repository named in a `-R` target.\nEnabling MWC while working in repo A therefore leaves a merge issued from repo B blocked,\nwhich is correct and easy to misread as the guard malfunctioning.\nIt also requires `AI_SESSION_ID` or `CLAUDE_SESSION_ID` to be set; with neither\nset the function returns `False` and the guard denies even with a valid marker.\nRun `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.\n\n**The grant does not expire, but its LIVENESS PROOF does, and a long session loses merge authority mid-session because of it.**\nThis is the property most likely to bite, because the two facts that produce it are individually reassuring.\n\nThe marker file is durable: `check-mwc` never prunes it, and the section above says a stale read \"cannot silently revoke a grant\".\nBoth true.\nWhat neither says is that the guard consults **session liveness** as well as the marker, and liveness goes stale after **1800 seconds** without a `heartbeat`.\nSo 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.\n\nMeasured 2026-08-22 on `Lacaedemon\u002Fsparta`.\nThe grant was made at 19:16 and four PRs merged between 19:17 and 19:20 without incident.\nAt 20:25 the fifth merge was refused, and `check-mwc` reported it precisely:\n\n```\nmwc is NOT active for session \u003Cid>: grant recorded, but the session reads unknown.\n  Marker: \u003Cgit-common-dir>\u002Fai-sessions\u002F\u003Cid>.mwc\n  Last heartbeat 2026-08-22 19:16; a session goes stale after 1800s.\n  If the session IS live:  ai-session.sh heartbeat --id '\u003Cid>'\n```\n\nOne `heartbeat --id \u003Cid>` restored it and the merge went through unchanged.\n\nThe refusal is easy to misread, and every natural reading sends you somewhere useless.\nIt 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).\nThe 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.\n`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.\n\n- **Do:** run `check-mwc` first on any merge refusal in a session that has been running a while --- its message distinguishes \"never granted\" from \"granted but stale\".\n- **Do:** `heartbeat` on a long session that will merge later, rather than waiting for the refusal.\n- **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.\n- **Don't:** re-run `enable-mwc` to fix this.\n  It is not what is missing, and it obscures which of the two states you were in.\n\n**Read `check-mwc`'s exit status from the script, not from the end of a pipe.**\nIts three-way status (0 active, 1 no grant, 2 granted-but-not-honourable) is the whole point of the command.\nPiping it through `tail` and echoing `$?` reports **`tail`'s** status instead, which is `0` whatever the script said.\nThat 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.\n\n- **Do:** run the bare invocation and branch on `$?`, or redirect its output to a file and echo `$?` from the unpiped command.\n- **Don't:** read `$?` after a pipe --- it belongs to the last stage, so every three-way status collapses to whatever `tail`, `head` or `grep` returned.\n\n## Quick Reference\n\n| Command | Effect |\n| :--- | :--- |\n| `skills\u002Fsession-lock\u002Fscripts\u002Fai-session.sh enable-mwc --id \"\u003Cid>\"` | Enables session-wide merge grant |\n| `skills\u002Fsession-lock\u002Fscripts\u002Fai-session.sh disable-mwc --id \"\u003Cid>\"` | Revokes session-wide merge grant |\n| `skills\u002Fsession-lock\u002Fscripts\u002Fai-session.sh check-mwc --id \"\u003Cid>\"` | Checks the grant, exiting 0 \u002F 1 \u002F 2 (see above) |\n\n`--id` is optional only when `AI_SESSION_ID` or `CLAUDE_SESSION_ID` is set in the\nshell, which in a Claude Code session it generally is not.\n","# Merge-When-Confident (MWC) Session Grant\n\n`mwc` (\"merge when confident\") is an explicit, session-scoped user grant\nthat authorizes the AI assistant to merge fully-clean pull requests autonomously\nfor the duration of the current session,\nwithout asking confirmation before every merge.\n\n## Standing Scope & Policy\n\n- **Baseline Prohibition**: AI assistants MUST NOT merge PRs\u002FMRs\n  without explicit user instruction for that specific PR.\n  Pushing, building, or driving a PR to 100% clean CI\n  DOES NOT grant permission to merge.\n  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\".\n  The Scope Limit binds both.\n- **MWC Override Scope**: When the user explicitly issues `\u002Fmwc`, the bare word `mwc`, \"merge when confident\", \"merge at will\", or \"maw\", that baseline prohibition is suspended for the current session only.\n  So are their plain-English equivalents from the user (Ezra, 2026-10-23): \"merge when ready\", \"merge them all\", \"merge it\", \"go ahead and merge\", a bare \"merge\", and \"mwc them all\".\n  `hooks\u002Fremind-plain-english-merge-word.py` recognizes these mechanically and injects the reminder, so a user never has to retype `mwc`.\n  The bare word is listed here, not only in `CLAUDE.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.\n- **A bare `\u002Fmwc` means `\u002Fmwc all`** (Ezra, 2026-10-16).\n  The grant covers every in-scope PR of the session or thread --- every PR the assistant has just named, opened, or been driving --- that becomes fully clean.\n  Never answer the keyword by asking for PR numbers: the numbers are already in the thread, and the question reads as stalling.\n  Ask only when no PR is in scope at all.\n  The gates still run on each PR: the fully-clean instrument, the stale-base update, and every check finished on the current head.\n  Neither `enable-mwc` nor `no-unauthorized-merge.py` takes a PR number: the grant is session-wide, so run `enable-mwc` on the bare keyword and never refuse it for lacking one.\n- **A grant relayed from another thread counts when it carries the user's message id** (Ezra, 2026-10-16).\n  A coordinator or project-thread session may forward the user's own `\u002Fmwc`, `\u002Fmwc all`, or \"merge it\" to a working session.\n  Treat the relay as the user's grant when it quotes or cites the user's message id, and name that id in the acknowledgement so the grant is auditable.\n  A relay that paraphrases the user without an id is not a merge grant, and neither are a peer session's own words.\n  A `daytb` or `away` grant is not one either: `daytb` hands back one decision and never extends to a merge, per `CLAUDE.md`'s judgment-grants section.\n  An agent cannot verify an id itself, so name it in the acknowledgement and let the owner audit the thread.\n- **Scope Limit**: An MWC grant applies ONLY to PRs that are 100% clean\n  (all CI checks passing, automated review verdicts from all available providers in the quorum clean, no unresolved comments, no open block labels).\n  It NEVER authorizes merging a PR with failing CI, unresolved findings, pending reviews, or a missing\u002Fskipped quorum review.\n  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`](..\u002F..\u002Fshared\u002Fworkflow\u002Freview-rendered-documents.md)).\n  A clean automated review from every available provider evaluating the current HEAD commit is strictly required for autonomous merge under MWC;\n  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.\n  **A cross-model, cross-harness adversarial review evaluating the shipping head is required before merge, unless no qualifying reviewer can run.**\n  When none can (host blocked by the network policy, backend down, CLI or credential absent), enumerate every route and try each once, then treat the gate as waived: the `mwc` grant and a clean Claude review plus green CI still apply, and the PR states that the review was waived, which reviewer was attempted, and the error (Ezra, 2026-10-16;\n  [`the waiver`](..\u002F..\u002Fshared\u002Fworkflow\u002Fadversarial-self-review.md#waiver-when-no-cross-model-reviewer-can-run)).\n  A reviewer that ran and returned not-clean is not unavailable and still blocks.\n  An author-dispatched review from a reviewer differing in BOTH model and harness from the authoring session must return a 100% all-clear verdict on the shipping head before merging,\n  per [`adversarial-self-review`](..\u002F..\u002Fshared\u002Fworkflow\u002Fadversarial-self-review.md).\n  An `Agent` subagent in Claude Code and the CI `claude-review` workflow are both Claude and do NOT satisfy this requirement.\n  In local sessions, dispatch to `opencode`, `agy` CLI, or `codex` (via `dto`, `dtc`, or `adv`);\n  in cloud sessions (e.g. claude.ai), run the review via `curl` to OpenCode Go (`https:\u002F\u002Fopencode.ai\u002Fzen\u002Fgo\u002Fv1\u002Fchat\u002Fcompletions`) streaming into `skills\u002Fdelegate-to-opencode\u002Fscripts\u002Fparse-sse.py`, looping over candidate models (`deepseek-v4-pro`, `kimi-k3`, `qwen3.8-max`, `glm-5.3`) on 502s per [`delegate-to-opencode`](..\u002Fdelegate-to-opencode\u002FSKILL.md).\n  **A PR\u002FMR does not count as clean for MWC if there are reviews still running.**\n  A pending review request counts, and a per-PR `mwc` from the requested reviewer does not withdraw it\n  ([`fully-clean`](..\u002F..\u002Fshared\u002Fworkflow\u002Ffully-clean.md#a-pending-review-request-is-a-second-independent-blocker)).\n  If one reviewer has reviewed a commit and reported clean,\n  but another review is still running\n  (for example, Copilot review running after Claude finishes,\n  an in-progress review check run,\n  or a pending review request),\n  you must wait for that review to complete and see what it says.\n  Consensus clean verdicts across all active and dispatched reviewers are strictly required to merge;\n  a clean verdict from one reviewer never authorizes merge while another review is in flight\n  (Morrison-Lab\u002Fai-config#3570, citing #3469 where Claude reported clean while Copilot was still running and subsequently recommended changes).\n  **A disagreement among reviews is unresolved findings.**\n  If one review is all-clear and another raises blocking issues, nits, or any\n  other flagged items, MWC does not authorize a merge.\n  ARD every item from every review, then request fresh reviews.\n  `check-pr-fully-clean.py` fails that state (ai-config#2274).\n  **Run that instrument; do not hand-check the Scope Limit in its place**\n  (user directive, 2026-08-29, ai-config#2441).\n  Checking the axes by hand is unauditable and unreproducible, and it is\n  exactly the multi-signal judgment where the axis you are least expecting is\n  the one dropped.\n  In a remote\u002Fweb session, where the `gh` CLI does not exist, gather the PR's\n  state via MCP and pass it to `--from-json`, rather than treating the\n  instrument as unavailable --- see\n  [`fully-clean`](..\u002F..\u002Fshared\u002Fworkflow\u002Ffully-clean.md) for the payload keys.\n  A later all-clear from a different reviewer does not supersede a standing\n  not-clean; only a later clean from the same reviewer does.\n  On GitHub, use `check-pr-fully-clean.py`; on GitLab, use\n  `scripts\u002Fcheck-mr-fully-clean.py` with the MR IID and project ID\u002Fpath.\n  The GitLab instrument reads every pipeline, paginated note and discussion,\n  proves target currency, and re-reads the head before printing the pinned SHA.\n  Merge with `sha=\u003Cpinned-sha>` and `auto_merge=false`, passing\n  `--quorum \u003Cnumber-of-reachable-providers>` to the checker; if currency\n  fails, rebase through `PUT \u002Fprojects\u002F:id\u002Fmerge_requests\u002F:iid\u002Frebase`, poll\n  with `include_rebase_in_progress=true` until `rebase_in_progress` clears,\n  and rerun the whole gate on the new head.\n  On GitHub,\n  record `headRefOid` and `baseRefName` before the instrument runs and\n  require both live values to equal them immediately before every direct\n  merge, so a retarget at the same tip cannot pass with an old verdict and a\n  concurrent push that already contains the base cannot ride a\n  currency-only check past a verdict it never received.\n  After the instrument passes, run the base-currency check that\n  [`fully-clean`](..\u002F..\u002Fshared\u002Fworkflow\u002Ffully-clean.md) states in its stale-base rule\n  (the Do bullets beginning \"for a direct merge\") before the merge command,\n  by merge mode: the `git merge-base` form in a local session,\n  the compare endpoint's `behind_by` in a remote session without `git`,\n  no merge at all from a session that can run neither until\n  [#2982](https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fissues\u002F2982) wires it\n  into the instrument, and a stop on a base that requires a merge\n  queue until [#3030](https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fissues\u002F3030)\n  lands.\n  On a direct merge a stale merge-base means an update pinned to the\n  recorded head (`PUT ...\u002Fpulls\u002F\u003CN>\u002Fupdate-branch` with\n  `expected_head_sha`, or the MCP tool's `expectedHeadSha`; a `422`\n  whose message names an expected-head mismatch (match on the substring `expected head sha`, since the live text carries a curly apostrophe and a trailing period that this ASCII rendering cannot show)\n  means another writer moved the head only if the live `headRefOid` no longer\n  equals the SHA you pinned --- re-read it and compare before settling\n  ownership, since a correctly-lengthed but wrong-content SHA (most often one\n  guessed or padded from an abbreviation instead of read in full) produces\n  the byte-identical message with no other writer involved,\n  and any other `422` is a failed update to stop on),\n  a wait of a few minutes at most until `headRefOid` changes (the update is\n  asynchronous; expiry is a failed update to stop on and report),\n  recording that SHA,\n  a currency check on it,\n  a full re-run of the gate pinned to it,\n  then a check immediately before the merge command that the live head\n  is still that SHA, `baseRefName` is unchanged, and the base tip\n  still equals the `\u003Cpinned-tip>` the currency check printed (recorded\n  before the gate reran), repeating the cycle when any moved during the\n  gate,\n  and `--match-head-commit \"\u003Cpinned-sha>\"` (or `expectedHeadSha` on the\n  MCP merge tool) on the merge command itself, so the API refuses a\n  push that lands after the read.\n  No merge API pins the base, so on a repository without a merge queue\n  or an up-to-date-branch requirement the base can still move between\n  that read and the merge; where that must not happen, the server-side\n  gate is the only closure, per\n  [`fully-clean`](..\u002F..\u002Fshared\u002Fworkflow\u002Ffully-clean.md).\n  A repeat names the moving ref: when the base moved twice it outruns\n  the gate, so merge under strict up-to-date protection instead (or\n  through a merge queue once [#3030](https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fissues\u002F3030)\n  lands), and when the head moved another writer is on the branch, so\n  settle ownership per [`claim-pr`](..\u002Fclaim-pr\u002FSKILL.md) before\n  rerunning, per [`fully-clean`](..\u002F..\u002Fshared\u002Fworkflow\u002Ffully-clean.md).\n- **Session Duration**: The grant expires automatically when the session ends\n  or when explicitly revoked via `\u002Fmwc revoke` or `disable-mwc`.\n- **The grant covers the merge decision, not what happens on `main` after.**\n  A repo can run a workflow on `push` that never runs on the PR at all --- a\n  full or PDF render, a deploy ---\n  so a PR merged fully-clean under `mwc` can still turn `main` red.\n  `post-merge`'s step 1.1\n  ([`verify-merge-commit-ci`](..\u002F..\u002Fshared\u002Fworkflow\u002Fverify-merge-commit-ci.md))\n  is what checks that, and it is not optional under `mwc`:\n  an autonomous merge is not done until the merge commit's own workflow runs are read and green.\n\n## Another session's PR: clean, and clean for twenty minutes\n\nEvery rule above settles *when* a PR may be merged.\nNone of them settles *whose*.\n\nThat gap has a real case behind it.\nA 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.\nIt 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.\n\n**A peer's PR may be merged once it has been ready to merge for more than twenty minutes** (maintainer directive, 2026-08-27).\n\nThat peer's PR must itself pass `memories\u002Freviewing-prs.md`'s scope test first: opened by or assigned to the invoking user, explicitly requested, or authored by the GitHub Actions app.\nA peer session running under your own login satisfies the author arm, which is the case this section was written for.\nAnother 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.\n\nThe interval is what does the work here, and it is doing something specific rather than being a polite pause.\nA 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.\n\n**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.\nA 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.\n\nDerive the age rather than judging it, per [`algorithmatize-checks`](..\u002F..\u002Fshared\u002Fworkflow\u002Falgorithmatize-checks.md):\n\n```bash\ngh api repos\u002F\u003Cowner>\u002F\u003Crepo>\u002Fissues\u002F\u003CN>\u002Fcomments --paginate \\\n  | jq -s -r '[.[][] | select(.body | test(\"\\\\*\\\\*Claude finished\"))] | last | .created_at'\n```\n\nagainst `date -u +%Y-%m-%dT%H:%M:%SZ`.\n\nThe `-s` and the double flatten are both load-bearing, and their absence is invisible on any PR small enough to test on.\n`--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.\nA PR under 100 comments has a single page and gives the right answer either way;\na 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.\nThe same pattern is already written correctly in `CLAUDE.md`'s review-verdict query, and matching it is the point.\nRun `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.\n\n### Warn the peer before you merge, and give it five minutes to object\n\nThe twenty-minute threshold is an *inference* about whether a session is still working.\nIt is a good one and it is still a guess, so it gets a cheap confirmation step rather than being trusted alone.\n\n**Ask the session directly when you can reach it.**\n`ListAgents` shows in-process subagents, teammates, and peer sessions, and `SendMessage` reaches any of them by name.\nA direct answer settles the question outright and costs one round trip, so it beats any inference from timestamps.\n\n**When you cannot reach it, post a comment on the PR saying you intend to merge, then wait five more minutes** before doing so.\nThat 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.\nSay what you are merging and why, so the objection can be specific.\n\nThe 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.\n\n- **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.\n- **Do:** measure the verdict's age, and state the two timestamps you compared.\n- **Do:** message the owning session directly when `ListAgents` reaches it, rather than falling straight to the comment-and-wait.\n- **Do:** post the warning comment and wait a further five minutes when you cannot reach the session, and honour a hold-off reply.\n- **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.\n- **Don't:** read the PR's `updatedAt` as the readiness time;\n  a comment of your own can bump it, which makes a stale PR look fresh and a fresh one look older than it is.\n- **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.\n\n(ai-config#2460.\nMeasured 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.\nIt held, and the peer then pushed a further commit fourteen minutes later before merging the PR itself.\nSo 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.\nThat is the whole argument for the interval, and for the warning step on top of it.)\n\n**Broadcast to every listed peer rather than guessing the owner from a branch name,\nand read replies for risk flags too, not only hold-offs.**\n`ListAgents` names every peer session.\nMessaging only the peer whose branch name looks like a match\nskips the peers who are not the owner but still know something:\nanother session tracking the same base,\nor one that already merged its own PR and can say so.\n\n- **Do:** send the merge-intent notice to every peer `ListAgents` lists,\n  not only the branch's apparent owner.\n- **Do:** read each reply for a substantive risk flag\n  (a correction on `main` not in the PR's base, a stale-red check from a known CI bug),\n  not only for an explicit hold-off.\n- **Don't:** message only the single peer a branch name suggests.\n- **Don't:** treat a reply that names a risk as clearance because it contains no hold-off.\n\n(Measured 2026-09-02 on `Morrison-Lab\u002Fai-config`.\nThree peer-owned PRs,\n[#2999](https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fpull\u002F2999),\n[#3010](https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fpull\u002F3010),\nand [#3029](https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fpull\u002F3029),\nhad each been clean for over twenty minutes.\nThe session posted a merge-intent comment on each PR,\nsent the same notice to all seven local peer sessions via `ListAgents` and `SendMessage`,\nand waited about five minutes.\nSix of the seven peers replied.\nFour replied inside the window:\nthe owner of [#2999](https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fpull\u002F2999) had merged that PR in the meantime,\none peer owned an unrelated draft,\none had nothing to add,\nand one flagged a real merge risk,\na mid-file correction on `main` that [#3029](https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fpull\u002F3029)'s base did not carry.\nTwo replied after the merges had landed:\nthe owner of [#3010](https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fpull\u002F3010) and [#3029](https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fpull\u002F3029), with no hold-off,\nand a peer warning that red review checks from that afternoon's reusable-workflow bug\nstay red after the fix and need a full re-run rather than a `--failed` one.\nNeither late reply would have changed the merge decision,\nand a five-minute window is expected to miss a slow reply now and then.)\n\n**Derive that a peer is gone; never assert it.**\n\nThe twenty-minute threshold and the five-minute window are both inferences\nabout a session you cannot reach, and both are written above as inferences.\n\"The session that opened it is no longer running on this machine\" is not one.\nIt is a claim about the world, asserted in the very sentence that invites the\npeer to refute it, and no reader of that comment can check it.\n\nA branch's last **push** is the cheapest liveness signal available, and it is\nthe one a live session moves.\nA branch pushed twenty minutes ago is a session that was working twenty minutes\nago, whatever the verdict age says --- and the verdict age can be comfortably\npast the threshold while the push is not, because a session that pushes, gets a\nclean verdict, and then keeps working leaves the verdict clock running and the\npush clock short.\nSo read both, state both, and phrase the conclusion as what you measured:\n\"no push in N minutes, verdict clean for M\" rather than \"the session is gone\".\n\n**Read the push time from the forge, which records it, rather than from git,\nwhich does not.**\nGitHub's repository-activity endpoint carries a real per-push timestamp:\n\n```bash\ngh api \"repos\u002F\u003Cowner>\u002F\u003Crepo>\u002Factivity?ref=refs\u002Fheads\u002F\u003Cbranch>&per_page=10\" \\\n  --jq '.[] | \"\\(.timestamp)  \\(.activity_type)  \\(.after[0:8])\"'\n```\n\n```\n2026-09-15T06:59:42Z  push             b00d37b2\n2026-09-15T06:49:12Z  branch_creation  57643c49\n```\n\nThose two rows are this branch's real activity, read from that endpoint on\n2026-09-15 in a session without `gh` on `PATH`;\nthe `gh api` spelling above is the same request, not the call that produced\nthem.\n\nThe obvious local substitute, `git log -1 --format=%cI origin\u002F\u003Cbranch>`, is\nwrong twice over, and **both errors run in the same direction: they make a live\npeer look gone.**\nThat is the direction this whole section exists to guard, so the substitute\nfails exactly where it is being relied on.\n\n`%cI` is the tip commit's **committer date**, not its push time.\nA commit is always made before it is pushed, so the reading is a lower bound\nthat is loose by however long the session held the commit --- unbounded above,\nzero below.\nMeasured on this branch, where the commit and the push were seconds apart:\n`57643c49`'s committer date is `06:48:41Z` against a `06:49:12Z` branch\ncreation, a 31-second gap that grows without limit for a branch pushed the\nmorning after it was written.\n\n`origin\u002F\u003Cbranch>` is a **remote-tracking ref**, which a bare `git log` does not\nrefresh.\nWhere the peer has pushed since your last fetch, the command reports your stale\ncopy's tip --- older than reality again, and silently, since a remote-tracking\nref that no longer matches the remote looks identical to one that does.\n[`check-before-pushing`](..\u002F..\u002Fshared\u002Fworkflow\u002Fcheck-before-pushing.md) makes\nthe same point about `--force-with-lease`: a ref you have not just fetched is a\nmeasurement of a moment that has passed.\n\nIf no forge route is available, `git fetch origin \u003Cbranch>` first and then read\n`%cI` as what it is --- **a lower bound on the push time**, stated as one.\n\n**A hold-off ends the window; it does not shorten it.**\n\nAbove, \"honour a hold-off reply\" is the whole of what is said, which leaves the\ncommonest shape unaddressed: the hold-off arrives and the five minutes keep\nrunning.\nThey do not.\nThe window exists to collect an objection, so an objection collected ends it,\nand no amount of subsequent silence converts a hold-off into consent.\n\nThe same applies to anything else that lands inside the window.\nThe grant is conditioned on a **fully clean** reading (see\n[`fully-clean`](..\u002F..\u002Fshared\u002Fworkflow\u002Ffully-clean.md)), and that reading is a\nsnapshot rather than a standing guarantee, so a NOT_CLEAN result posted after\nthe announcement invalidates it exactly as a check flipping red would.\nThe window is precisely when such a thing arrives, since announcing an\nintention to merge is what prompts the owner to publish what it has.\nRe-read the PR immediately before merging, not the reading that opened the\nwindow.\n\n- **Do:** derive a peer's liveness from the forge's own push timestamp before\n  writing anything about it, and put that timestamp in the comment.\n- **Do:** `git fetch origin \u003Cbranch>` first, and call `%cI` a lower bound, when\n  the forge route is unavailable.\n- **Do:** state the inference as an inference, naming both clocks you read.\n- **Do:** treat a hold-off as terminating the window at the moment it arrives.\n- **Do:** re-read the PR's clean state immediately before the merge.\n- **Don't:** assert that a session has stopped --- nothing available to you\n  observes that, and the claim is unfalsifiable to the reader you are asking to\n  refute it.\n- **Don't:** read `%cI` on an unfetched remote-tracking ref as a push time;\n  it is wrong twice, and both errors age the peer rather than freshening it.\n- **Don't:** run the remaining minutes out after a hold-off and merge on the\n  silence;\n  the objection you asked for has already arrived.\n- **Don't:** merge on the readiness reading that opened the window.\n\n(Measured 2026-09-15 on\n[ai-config#3635](https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fpull\u002F3635), from the\nPR's own comment timestamps.\nA merge-intent comment at `05:51:13Z` asserted the PR \"was opened by a session\nthat is no longer running on this machine\".\nThe owning session replied at `05:51:39Z` --- 26 seconds in --- that it had\npushed to the branch four times that night, and that a pre-merge adversarial\ngate was running against that exact head.\n(That reply put its own last push \"about twenty minutes earlier\", which was\nitself an unmeasured figure and wrong by a factor of two: the branch's tip,\n`994b975c`, is dated `05:11:01Z`, forty minutes and thirty-eight seconds before\nthe reply.\nIt is quoted here as the peer's prose rather than as a measurement, and the\nerror is the rule above failing in the *other* direction --- the owner\nunder-stating its own liveness while disputing a claim that it had none.)\nThat gate posted **NOT_CLEAN, 7 findings** at `05:52:12Z`.\nThe PR merged at `05:58:47Z`: 7 minutes 8 seconds after the hold-off and 6\nminutes 35 seconds after the gate result, putting all seven findings on `main`.\nThey were closed forward by\n[#3681](https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fissues\u002F3681) ->\n[#3682](https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fpull\u002F3682) rather than by a\nrevert, for the reason\n[`revert-premature-merge`](..\u002F..\u002Fshared\u002Fworkflow\u002Frevert-premature-merge.md)\nnow records.)\n\n### `ListAgents` does not reach a project-thread session, and the author field cannot tell you whose PR it is\n\nBoth mechanics above assume a peer you can enumerate and a PR you can attribute.\nIn a Claude Code **project thread** session neither holds, and both fail quietly rather than erroring.\n\n**`ListAgents` names in-process subagents and local peer sessions, and returns nothing for a sibling thread session in the same project.**\nSo the \"ask the session directly\" step reads as unavailable when it is merely being asked through the wrong tool, and a session that stops there falls straight to comment-and-wait for no reason.\nThe route that does reach them is `mcp__hearthbot__list_thread_sessions` to enumerate, then `mcp__claude-code-remote__send_message` to the returned `session_id`.\n\n**Every thread session posts as `claude[bot]`, so the PR's `user.login` is the same for yours and the peer's.**\nThe author arm of the scope test is satisfied by all of them at once, which makes it useless for telling them apart.\nThe `head.ref` does distinguish: a thread's own branch is `claude\u002Fproject-thread-\u003Cslug>`, and a named feature branch belongs to whoever cut it.\n\n- **Do:** enumerate peers with `list_thread_sessions` and message them with `send_message` before falling back to comment-and-wait.\n- **Do:** compare the PR's `head.ref` against your own branch to decide whether a PR is yours.\n- **Don't:** read an empty `ListAgents` in a thread session as \"no peer is reachable\".\n- **Don't:** use the PR's author to decide whose PR it is under a shared bot identity.\n\n(Measured 2026-09-18 on [ai-config#3737](https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fpull\u002F3737), as a violation of the rule above rather than an application of it.\nA thread session merged that peer-owned PR nine minutes after its clean verdict landed, with no hold-off comment and no message to the session driving it.\nNothing was lost --- the PR was fully clean and the maintainer had told that session to merge when ready --- but its own merge call had failed with a `409 Head branch was modified` seconds earlier, so it was actively working the PR at the moment it was taken.\nThe scorer is what made this feel authorized: `check-pr-fully-clean.py` answers *is this PR mergeable*, which is a different question from *is this PR mine to merge*, and nothing in it knows the second --- so a clean exit reads as complete authorization when it is half of it.\nA clean scorer exit is not the hold-off, and neither is a standing merge grant.)\n\n## The standing per-repository grant\n\nThree repositories carry the grant **standing**, with no session step at all: PRs targeting `Morrison-Lab\u002Fai-config` (ai-config#1352), PRs targeting the shared math-macros repo, `d-morrison\u002Fmacros` (also reachable as `Morrison-Lab\u002Fmacros`;\nuser directive, 2026-10-02), and PRs targeting `Morrison-Lab\u002Fgha` (user directive, 2026-10-16).\n`no-unauthorized-merge.py` reads the merge's target repository off the command\nitself, so there is nothing to enable, nothing to expire, and no marker to go\nstale.\n\n**A repo's own `CLAUDE.md` claiming this exemption is not evidence it has it.**\nThe list above names the repositories the hook actually carries (`STANDING_MERGE_GRANT_REPOS`).\nA different repository's own `CLAUDE.md` can independently assert a standing grant --- as `Morrison-Lab\u002Fgha`'s did before the hook carried it --- without that repository being in the hook's set, since the two are two different files with no mechanism keeping them in sync.\n`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.\n[#3490](https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fissues\u002F3490) tracks this exact drift (measured again on `Morrison-Lab\u002Fgha#857`) and the open question of which side should change;\ncheck it before re-diagnosing a standing-grant mismatch from scratch.\n\n- **Do:** read `STANDING_MERGE_GRANT_REPOS` in `hooks\u002Fno-unauthorized-merge.py` itself, 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.\n- **Do:** treat the hook's set as the operative answer for what the guard will actually do, whatever a target repo's `CLAUDE.md` claims.\n- **Don't:** read a repo's `CLAUDE.md` alone as proof a merge there is pre-authorized, then be surprised when the guard blocks it anyway.\n- **Don't:** run `check-mwc` to check whether a repo carries the *standing* grant --- it only ever reports the session grant.\n\nThe two grants differ on every axis except the Scope Limit, which binds both:\n\n| | session grant (`\u002Fmwc`) | standing grant |\n| :--- | :--- | :--- |\n| scope | this session, every repo | `Morrison-Lab\u002Fai-config` and the macros repo, forever |\n| keyed on | a `.mwc` marker in the **current** repo's git dir | the **target** repo named in the command |\n| enabling step | `enable-mwc`, then `check-mwc` | none |\n| covers | any merge command run from that checkout | `gh pr merge` \u002F `gh api ...\u002Fpulls\u002FN\u002Fmerge` only |\n| requires a fully clean PR | yes | yes |\n\nRead the second row carefully, since it is the one that surprises.\nThe session grant is keyed on **where you are** and the standing grant on\n**what you are merging**, so the standing one is the tighter of the two: an\nactive `\u002Fmwc` in an ai-config checkout authorizes `gh pr merge -R other\u002Frepo`,\nand the standing grant never does.\n\nThree things the standing grant deliberately does not cover, each of which\nkeeps the baseline prohibition:\n\n- **A merge with no repo named in the command.**\n  The target is read from the command text only, never from the working\n  directory --- `offending` splits on `&&`, so `cd ..\u002Fother && gh pr merge 1`\n  would otherwise resolve to ai-config.\n  `hooks\u002Frequire-gh-repo-flag.py` already refuses a `gh pr merge` without `-R`,\n  so this costs nothing in practice.\n- **A repository branch merge** (`gh api -X POST repos\u002F\u003Cowner>\u002F\u003Cname>\u002Fmerges`).\n  That writes to the default branch with no PR, no review and no required\n  check, and the grant is for PRs.\n- **A GraphQL `mergePullRequest` mutation**, which names its target by node id,\n  so no repository is derivable from the command at all.\n\nThose last two are excluded by **every** interpretation the segment matches,\nnot by the first one.\nThe merge patterns are unanchored scans over the whole segment, so one command\nline can satisfy several at once --- and the `pulls\u002FN\u002Fmerge` forms are tried\nbefore the `repos\u002F\u003Cowner>\u002F\u003Cname>\u002Fmerges` ones.\nA real branch merge carrying a forged `pulls\u002F1\u002Fmerge` substring in an unmasked\nflag (`-H`, `--jq`) is therefore *labelled* a PR merge, and both the forged and\nthe real `repos\u002F\u003Cowner>\u002F\u003Cname>\u002F` path name the same granted repo, so a\nfirst-label reading grants a direct push to the default branch.\nReported and reproduced on ai-config#1353.\n\nSo the guard runs the same ambiguity test on two axes --- **what kind** of\nmerge this is, and **which repository** it lands in --- and either one coming\nback undetermined denies.\n\nWhat the standing grant removes is the need to **ask**, not the judgment about\nwhether the PR is done.\n[`ardi`](..\u002F..\u002Fshared\u002Fworkflow\u002Fardi.md)'s loop still terminates by reporting the\nPR ready, so the grant changes what happens at that moment and not what has to\nbe true before you get there.\n\nAdding a repository to the grant is a one-line diff to\n`STANDING_MERGE_GRANT_REPOS` in the hook.\nIt is deliberately not settable from the environment: `ALLOW_MERGE=1` already\ncovers the one-off case from inside the command text, and an env-settable\nallowlist would widen a security guard from ambient state a reader of the\ncommand cannot see.\n\n### A bare `gh pr merge \u003CN>` refuses, and the refusal may diagnose the wrong thing\n\nThe uncovered case named above as \"a merge with no repo named in the command\" closes with \"so this costs nothing in practice\".\nThat claim is about **design cost** --- requiring an explicit target adds no burden the corpus was not already imposing --- and it is correct.\nWhat it does not say is what the *operator* sees, which is where the real cost lands.\n\nWith 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.\nMeasured 2026-08-17, feeding each hook the exact payload for fully-clean PR #1598:\n\n| command | `require-gh-repo-flag.py` | `no-unauthorized-merge.py` |\n| :--- | :--- | :--- |\n| `gh pr merge 1598 --squash --delete-branch` | deny --- names the missing `-R`, gives the fix | deny --- names permission and `STANDING_MERGE_GRANT_REPOS` |\n| `gh pr merge 1598 -R Morrison-Lab\u002Fai-config --squash --delete-branch` | allow (has `-R`) | allow (exit 0) |\n\nNothing about the two commands differs except the explicit `-R`.\n\nThe 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.\nSo 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.\nThe fix is one flag.\n\nWhich 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.\nEither message is possible; only one of them is self-diagnosing.\n\nIt is worst exactly where it is most likely.\nA 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.\n\n- **Do:** write `-R Morrison-Lab\u002Fai-config` into the merge command, since the grant attaches to the target named in the command text and to nothing else.\n- **Do:** re-issue with `-R` as the first response to any merge refusal in a repo that carries a grant, before investigating anything.\n- **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.\n- **Don't:** infer the target from the working directory the way the rest of `gh` does; that is precisely the inference the guard refuses to make.\n\n## The standing infra-PR grant\n\nA second standing grant covers **infra PRs** in any `Morrison-Lab\u002F*` repository.\nAn infra PR is one where every changed path is tooling or agent configuration\n(user directive, 2026-09-28: \"let's create a standing infra-PR mwc grant in ai-config\").\n`INFRA_PATH_PATTERNS` in `hooks\u002Fno-unauthorized-merge.py` is the operative list:\n\n- `.github\u002F**`: CI workflows, reusable actions and their scripts, dependabot,\n  and Copilot's instructions\n- `.claude\u002F**`\n- `CLAUDE.md`, `AGENTS.md` and `GEMINI.md`, at any depth\n- `.lintr`, `.lintr.R`, `lychee.toml`, `_typos.toml` and `inst\u002FWORDLIST`\n- `tools\u002Fcheck_*`, `tools\u002Fcheck-*` and `tools\u002Ftests\u002F**`\n- preview and CI helpers under `tools\u002F`, by name (`skip-pdf-in-previews`, `rerun_timed_out`): each changes only what a PR preview or a CI run does, never the published site (ai-config#5293)\n\nA top-level `scripts\u002F` is deliberately absent, because in a content repository it can hold analysis code.\nSo are the other `tools\u002F` files: figure, data and content generators, and build steps that shape the published site.\n`scripts\u002Fmerge-infra-pr` reads this same list from a fresh clone of ai-config's `main`, adding only `changelog.d\u002F`.\nA change to the list therefore takes effect for the wrapper once it merges, not on its own PR.\n\nThe grant is decided by what the PR changes,\nso the guard reads the PR's file list from GitHub (`gh api ...\u002Fpulls\u002FN\u002Ffiles`,\nor direct REST via `urllib` when `gh` is not on `PATH`).\nIt checks every changed path, and a renamed file's previous path as well, so moving a content file under `.github\u002F` does not qualify.\nEvery doubt denies:\n\n- no single bare PR number in the command;\n- a failed or timed-out fetch;\n- an empty file list, or one that disagrees with the PR's `changed_files`;\n- a list at the API's 3000-file cap;\n- any one path outside the list;\n- no pinned head commit SHA matching the PR's head commit, or an ambiguous SHA.\n\nThe target and merge-type ambiguity tests are the per-repository grant's, unchanged.\n\nThe 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.\nWithout that, `gh pr merge -R o\u002Fr --body \"see 12\"` would check PR 12's files while merging the current branch's PR.\nThe merge must pin the exact head commit SHA (`--match-head-commit \u003Csha>`, `sha=\u003Csha>` in REST, or `expectedHeadSha` on the MCP merge tool) matching the PR's head commit SHA fetched alongside the file list.\nThis guarantees that if a push lands on the PR between the guard's inspection and the merge, GitHub refuses the merge.\n`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.\n\nThe 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.\nRun `check-pr-fully-clean.py` first.\n\n- **Do:** merge a fully clean infra PR with the PR number, `-R`, and `--match-head-commit \u003Csha>` in the command (`gh pr merge 15 -R Morrison-Lab\u002Fpds --squash --match-head-commit \u003Csha>`), or through the MCP merge tool with `owner`, `repo`, `pullNumber`, and `expectedHeadSha`.\n- **Do:** pin the exact head commit SHA to guarantee no push landed between the clean check, the guard's inspection, and the merge.\n- **Don't:** run an unpinned merge under the infra grant;\n  unpinned merges deny because the guard cannot verify that the merged commit matches what it inspected.\n- **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.\n- **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.\n\n`NO_UNAUTHORIZED_MERGE_DISABLE_INFRA_GRANT=1` turns the grant off; nothing turns it on.\nThe hook's test suite sets it so that no subprocess case depends on a live PR's files.\n\n## Session Lock & Hook Integration\n\n`no-unauthorized-merge.py` enforces the baseline merge prohibition at the `PreToolUse` hook level,\nblocking `gh pr merge`, `glab mr merge`, `gh api ...\u002Fmerge`, and `glab api ...\u002Fmerge`.\n\nWhen MWC is enabled for a session:\n1. `ai-session.sh enable-mwc` creates a `\u003Csanitized-session-id>.mwc` marker file\n   in the repository's git common directory (`$(git rev-parse --git-common-dir)\u002Fai-sessions\u002F`).\n2. `no-unauthorized-merge.py` checks for the active session's `.mwc` marker file\n   and validates that the session is alive (`is_session_alive()`).\n3. If an active `.mwc` marker exists for the current session, `no-unauthorized-merge.py` allows merge tool executions.\n4. `ai-session.sh disable-mwc` removes the `.mwc` marker file, restoring the strict prohibition immediately.\n\n**The session id has to match on both sides, and that is the part that breaks.**\nThe guard resolves which session it is running under from the hook payload's own\n`session_id` field, then the transcript filename stem, then `AI_SESSION_ID` \u002F\n`CLAUDE_SESSION_ID`.\nIt used to read only the two environment variables, and the hook process inherits\nneither, so a grant made the sanctioned way was invisible to it and every merge was\nblocked no matter what the user had granted (ai-config#1279).\nPass the harness session id explicitly when granting, since the shell script has no\npayload to read.\n\n`check-mwc` distinguishes three outcomes rather than reporting one sentence for all\nof them, because \"never granted\" and \"granted but the session reads dead\" want\nopposite responses:\n\n| Exit | Meaning | What to do |\n| :--- | :--- | :--- |\n| 0 | active | nothing; the guard will honour it |\n| 1 | no grant recorded | `enable-mwc --id \u003Cid>` |\n| 2 | granted, but not currently honourable | the message names which case and the fix; one case is an `--id` that is neither the harness session id nor a transcript stem, which the guard never reads (ai-config#4903) |\n\nIt is a **query**: it never prunes and never deletes the marker, so a stale read\ncannot silently revoke a grant, and a `heartbeat` restores one.\nUse `disable-mwc`, `release`, or `prune` to actually remove a grant.\n\n## Procedure for Agents Handling `\u002Fmwc`\n\nWhen the user gives an MWC grant (e.g. `\u002Fmwc` or \"merge when confident\"):\n\n1. **Run the enabling step first, and confirm it took.**\n   `skills\u002Fsession-lock\u002Fscripts\u002Fai-session.sh enable-mwc --id \"\u003Csession id>\"`\n   (or `\"${CLAUDE_PLUGIN_ROOT:+$CLAUDE_PLUGIN_ROOT\u002Fskills\u002Fsession-lock\u002Fscripts\u002Fai-session.sh}\"` \u002F `~\u002F.claude\u002Fskills\u002Fsession-lock\u002Fscripts\u002Fai-session.sh`)\n   sets the session merge-permission flag `no-unauthorized-merge.py` reads.\n   This step is what makes the grant real: without the `.mwc` marker it creates,\n   `no-unauthorized-merge.py` cannot see the grant and correctly keeps blocking.\n   **A grant acknowledged only in prose is not a grant the machinery can see**,\n   so skipping this step leaves you believing you hold a permission that does not\n   exist --- and then reading the resulting block as an obstacle rather than as\n   the accurate answer it is.\n   Only then acknowledge the grant in one sentence,\n   so the user knows it's active for the session,\n   and what it does and doesn't cover.\n   Pass `--id` explicitly unless `AI_SESSION_ID` or `CLAUDE_SESSION_ID` is set in\n   the shell: without one the script cannot resolve an id and dies with\n   \"no session id\".\n   Its value is the harness session id, which is the transcript filename stem.\n   Then confirm with `check-mwc --id \"\u003Csession id>\"`,\n   which exits 0 only when the guard will actually honour the grant.\n   Do not skip that confirmation:\n   an `enable-mwc` that reports success still leaves the guard blocking if the\n   two sides resolved different ids, which is exactly what ai-config#1279 was.\n2. Proceed with the task (e.g. driving PRs to clean via `ardi`).\n3. When a PR reaches 100% clean state:\n   - **Verify or run cross-model, cross-harness adversarial review**:\n     Confirm the shipping HEAD commit has received a 100% all-clear verdict from an author-dispatched cross-model, cross-harness reviewer,\n     per [Standing Scope & Policy](#standing-scope--policy) above and [`adversarial-self-review`](..\u002F..\u002Fshared\u002Fworkflow\u002Fadversarial-self-review.md)\n     (local: `dto`\u002F`dtc`\u002F`adv`; cloud: `curl` to OpenCode Go with candidate model fallback per [`delegate-to-opencode`](..\u002Fdelegate-to-opencode\u002FSKILL.md)).\n     Never proceed to the merge command before this verdict is clean, except under [the waiver](..\u002F..\u002Fshared\u002Fworkflow\u002Fadversarial-self-review.md#waiver-when-no-cross-model-reviewer-can-run) when no qualifying reviewer can run (every route tried once, error recorded in the PR).\n   - **Verify base currency and pre-merge pins**:\n     Run the base-currency check that [`fully-clean`](..\u002F..\u002Fshared\u002Fworkflow\u002Ffully-clean.md) states in its stale-base rule before the merge command.\n   - **Merge**:\n     `gh pr merge \"\u003Cnumber>\" -R \"\u003Cowner>\u002F\u003Crepo>\" --squash --delete-branch --match-head-commit \"\u003Cpinned-sha>\"`,\n     the pin being the `headRefOid` recorded before the instrument ran;\n     on a base that requires a merge queue, stop and report instead, per\n     [#3030](https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fissues\u002F3030).\n   - **Wrap up**:\n     Verify the merge landed on GitHub\u002FGitLab,\n     verify CI passed on the merge commit itself (`scripts\u002Fcheck-merge-commit-ci.py`),\n     and run the post-merge skill (`post-merge` \u002F `ums`).\n4. If the user revokes the grant, run `skills\u002Fsession-lock\u002Fscripts\u002Fai-session.sh disable-mwc` immediately.\n\n## An mwc carries to a new head only for a named PR\n\nAn mwc line covers the PRs it names.\nIt carries to a new head of a named PR when the content is unchanged beyond a main merge.\nIt never extends to a PR that no mwc line names, however unchanged that PR's content is\n(the coordinator told the runner an earlier mwc covered an unnamed PR on 2026-11-10, ai-config#5275).\nNo hook can enforce this: whether a line names a PR is a reading of Ezra's wording.\nSo cite the naming line when you report the merge.\n\n- **Do:** find the mwc line that names the PR before treating it as granted.\n- **Don't:** infer a grant from unchanged content, a shared branch or a stack position.\n\n## What a re-grant does NOT mean\n\nThe grant is **conditional**, and the condition is the Scope Limit above.\nRe-issuing a conditional permission does not make its condition true.\nThat distinction collapses in one specific situation, so it is worth naming\nmechanically rather than trusting judgment in the moment:\na merge command is blocked, you ask whether to retry, and the user answers with\nthe keyword.\n\nRead as an answer to \"should I retry?\", the keyword looks like \"yes, merge that\nPR.\"\nIt is not.\nIt re-states a standing, conditional policy, and the PR in front of you is\nprecisely the one that failed the condition --- otherwise nothing would have\nblocked.\nThe framing of your own question is what makes the keyword look like an\ninstruction about one specific PR.\n\nThree rules follow, and they hold whatever the block turned out to be:\n\n- **A conditional grant re-issued in response to a blocked action is not\n  authorization for that action.**\n  Re-check the condition before acting, and say which specific PR you are\n  claiming the grant for and why it qualifies --- naming it forces the check\n  that the keyword bypassed.\n- **A permission whose enforcement is mechanized has an enabling step.**\n  Run it when the grant is given.\n  If you did not, a block is evidence the grant is **not active** --- not an\n  obstacle to retry.\n- **Never retry a denied merge on the strength of a keyword.**\n  The denial and the grant are about different things: one is a guard's state,\n  the other is the user's intent, and only the guard's state gates the action.\n\n## Five properties of the guard worth knowing before you trust it\n\n**The denial you hit may not be this guard.**\n`no-unauthorized-merge.py` is a `PreToolUse` hook, and a hook only runs if it is\n**registered** --- which is a separate question from whether its file exists.\nClaude Code's own auto-mode permission classifier blocks merge commands too, and\nthe two are independent mechanisms.\nThey differ in exactly the way that matters here: the hook reads a **marker\nfile**, so re-asking cannot move it, while the classifier reads the\n**conversation**, so re-stating intent can.\nSo a denial that clears on a retry was, by that fact, probably not this guard.\nCheck registration rather than assuming, per `CLAUDE.md`'s\n\"Keep ai-config and repo checkouts fresh\":\n\n```bash\npython3 \u003Cai-config-checkout>\u002Fscripts\u002Finstall-hooks.py   # report only; --fix binds\n```\n\nMeasured on one machine, 2026-08-07: `registered=0 missing=15`, with\n`enabledPlugins` unset in `~\u002F.claude\u002Fsettings.json` --- so every guard in\n`hooks\u002Fhooks.json`, this one included, was placed but unbound.\nThe guard's own logic was fine: fed a `gh pr merge` payload directly it returned\n`permissionDecision: deny`, and with a valid marker it allowed.\nIt simply never ran.\n\n**The denial you hit may not be a guard at all, and a merge command can stack three of these mechanisms in one shot.**\nThe paragraph above names two.\nThere is a third, independent of both: `hooks\u002Frequire-gh-repo-flag.py` refuses a mutating `gh` command --- `gh pr merge` included --- that omits an explicit `-R`\u002F`--repo`, and it is a separate `PreToolUse` registration from `no-unauthorized-merge.py`'s.\nAll 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:\n\n1. `no-unauthorized-merge.py` and `require-gh-repo-flag.py`, the two corpus `PreToolUse` hooks --- see \"A bare `gh pr merge \u003CN>` refuses, and the refusal may diagnose the wrong thing\" above for their measured interaction and remedies (`ALLOW_MERGE=1`, an active `\u002Fmwc` grant, the standing per-repository grant, or the `-R` flag each of those needs to resolve a single target).\n2. Claude Code's own auto-mode permission classifier (not a corpus hook at all) --- remedy is neither of the above.\n   No environment variable, no `\u002Fmwc` grant, 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.\n   [`remind-retry-before-declaring-blocked.py`](..\u002F..\u002Fhooks\u002Fremind-retry-before-declaring-blocked.py) covers what a classifier denial does and does not license (an identical retry is still worth one try;\n   varying the command or switching to a different tool for the same goal is the move Pattern 43 in `memories\u002Fmistake-patterns.md` warns feeds the classifier's own suspicion);\n   past a second denial of the same goal, hand the decision to the user rather than continuing to probe.\n\nRefusals 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.\nReading 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.\n[`verify-the-right-artifact.md`](..\u002F..\u002Fshared\u002Fworkflow\u002Fverify-the-right-artifact.md) states the same rule at the general level (added in Morrison-Lab\u002Fai-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.\nHere that mechanism is itself one of at least three candidates, so the first read has to identify *which*.\n\n- **Do:** read which of the three mechanisms produced the refusal text, and apply that mechanism's own remedy --- `ALLOW_MERGE=1`\u002F`-R` for the two corpus hooks, escalation to the user for the classifier.\n- **Don't:** read a merge refusal as a statement about authorization in general.\n  A corpus hook's denial is a statement about authorization;\n  the classifier's is a claim about the session's permission mode that no amount of granted authority inside the conversation changes.\n\n**The marker is per-repository, so a grant in one repo authorizes nothing in another.**\n`check_mwc_active()` looks for `\u003Cgit-common-dir>\u002Fai-sessions\u002F\u003Csession>.mwc`,\nresolved from the session's working directory (`cwd`) and `CLAUDE_PROJECT_DIR`\n(where the merge command executes), which may differ from the repository named in a `-R` target.\nEnabling MWC while working in repo A therefore leaves a merge issued from repo B blocked,\nwhich is correct and easy to misread as the guard malfunctioning.\nIt also requires `AI_SESSION_ID` or `CLAUDE_SESSION_ID` to be set; with neither\nset the function returns `False` and the guard denies even with a valid marker.\nRun `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.\n\n**The grant does not expire, but its LIVENESS PROOF does, and a long session loses merge authority mid-session because of it.**\nThis is the property most likely to bite, because the two facts that produce it are individually reassuring.\n\nThe marker file is durable: `check-mwc` never prunes it, and the section above says a stale read \"cannot silently revoke a grant\".\nBoth true.\nWhat neither says is that the guard consults **session liveness** as well as the marker, and liveness goes stale after **1800 seconds** without a `heartbeat`.\nSo 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.\n\nMeasured 2026-08-22 on `Lacaedemon\u002Fsparta`.\nThe grant was made at 19:16 and four PRs merged between 19:17 and 19:20 without incident.\nAt 20:25 the fifth merge was refused, and `check-mwc` reported it precisely:\n\n```\nmwc is NOT active for session \u003Cid>: grant recorded, but the session reads unknown.\n  Marker: \u003Cgit-common-dir>\u002Fai-sessions\u002F\u003Cid>.mwc\n  Last heartbeat 2026-08-22 19:16; a session goes stale after 1800s.\n  If the session IS live:  ai-session.sh heartbeat --id '\u003Cid>'\n```\n\nOne `heartbeat --id \u003Cid>` restored it and the merge went through unchanged.\n\nThe refusal is easy to misread, and every natural reading sends you somewhere useless.\nIt 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).\nThe 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.\n`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.\n\n- **Do:** run `check-mwc` first on any merge refusal in a session that has been running a while --- its message distinguishes \"never granted\" from \"granted but stale\".\n- **Do:** `heartbeat` on a long session that will merge later, rather than waiting for the refusal.\n- **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.\n- **Don't:** re-run `enable-mwc` to fix this.\n  It is not what is missing, and it obscures which of the two states you were in.\n\n**Read `check-mwc`'s exit status from the script, not from the end of a pipe.**\nIts three-way status (0 active, 1 no grant, 2 granted-but-not-honourable) is the whole point of the command.\nPiping it through `tail` and echoing `$?` reports **`tail`'s** status instead, which is `0` whatever the script said.\nThat 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.\n\n- **Do:** run the bare invocation and branch on `$?`, or redirect its output to a file and echo `$?` from the unpiped command.\n- **Don't:** read `$?` after a pipe --- it belongs to the last stage, so every three-way status collapses to whatever `tail`, `head` or `grep` returned.\n\n## Quick Reference\n\n| Command | Effect |\n| :--- | :--- |\n| `skills\u002Fsession-lock\u002Fscripts\u002Fai-session.sh enable-mwc --id \"\u003Cid>\"` | Enables session-wide merge grant |\n| `skills\u002Fsession-lock\u002Fscripts\u002Fai-session.sh disable-mwc --id \"\u003Cid>\"` | Revokes session-wide merge grant |\n| `skills\u002Fsession-lock\u002Fscripts\u002Fai-session.sh check-mwc --id \"\u003Cid>\"` | Checks the grant, exiting 0 \u002F 1 \u002F 2 (see above) |\n\n`--id` is optional only when `AI_SESSION_ID` or `CLAUDE_SESSION_ID` is set in the\nshell, which in a Claude Code session it generally is not.\n",{"name":5,"description":7,"allowed-tools":24,"user-invocable":29},[25,26,27,28],"Bash","Read","Edit","Write",true,"main","f8f5ea79cc52e903c30773b5e264e384e715c4c7","skills\u002Fmwc",1,"https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config","https:\u002F\u002Fmorrison-lab.github.io\u002Fai-config\u002F","https:\u002F\u002Fgithub.com\u002FMorrison-Lab","2026-11-12 06:23:19",{"source":8,"skillName":5,"commands":39,"prompt":49,"zipAvailable":29},[40,43,46],{"tool":41,"command":42},"npx","npx skills add Morrison-Lab\u002Fai-config --skill mwc",{"tool":44,"command":45},"bunx","bunx skills add Morrison-Lab\u002Fai-config --skill mwc",{"tool":47,"command":48},"pnpm","pnpm dlx skills add Morrison-Lab\u002Fai-config --skill mwc","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fblob\u002Fmain\u002Fskills\u002Fmwc\u002FSKILL.md",[],[52,59,66,73,80,87],{"skillNo":53,"name":54,"nameZh":6,"description":55,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":56,"categories":57,"keywords":58,"publishedAt":19},"01M735B6T9KP24YK73HZYS361B","triage","Triage backlog: P1\u002FP2\u002FP3 or close.","https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fblob\u002Fmain\u002Fskills\u002Ftriage\u002FSKILL.md",[],[],{"skillNo":60,"name":61,"nameZh":6,"description":62,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":63,"categories":64,"keywords":65,"publishedAt":19},"01M735ABCWNZ3WJKPC1ABJ7W8B","st","Start task issue-first.","https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fblob\u002Fmain\u002Fskills\u002Fst\u002FSKILL.md",[],[],{"skillNo":67,"name":68,"nameZh":6,"description":69,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":70,"categories":71,"keywords":72,"publishedAt":19},"01M7355JBSBYP7AQQ2165FJKBW","migrate-discussion","Move items between Issues and Discussions.","https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fblob\u002Fmain\u002Fskills\u002Fmigrate-discussion\u002FSKILL.md",[],[],{"skillNo":74,"name":75,"nameZh":6,"description":76,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":77,"categories":78,"keywords":79,"publishedAt":19},"01M73558FN1TYWMMRZYJQSTAK8","memorize","Persist memory across sessions.","https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fblob\u002Fmain\u002Fskills\u002Fmemorize\u002FSKILL.md",[],[],{"skillNo":81,"name":82,"nameZh":6,"description":83,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":84,"categories":85,"keywords":86,"publishedAt":19},"01M7351KXGW9QTKQHH1HJFS4YF","defer-issue","File issue for deferred work.","https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fblob\u002Fmain\u002Fskills\u002Fdefer-issue\u002FSKILL.md",[],[],{"skillNo":88,"name":89,"nameZh":6,"description":90,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":91,"categories":92,"keywords":93,"publishedAt":19},"01M73512AEG0J04NPM2YP9CEW8","create-release-checklist","Create an R package release-checklist issue.","https:\u002F\u002Fgithub.com\u002FMorrison-Lab\u002Fai-config\u002Fblob\u002Fmain\u002Fskills\u002Fcreate-release-checklist\u002FSKILL.md",[],[]]