claude-dev-env 8.77.0 → 8.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -298,7 +298,7 @@ Three things stay with the owner, and nothing else does:
298
298
  - A change the owner said they want to read first.
299
299
  - A repository whose branch rule requires an approving review the agent cannot give.
300
300
 
301
- Where the branch rule requires zero approvals and one status check, that check is the gate, and the agent merges on its verdict.
301
+ Where the branch rule requires zero approvals and one status check, that check is the gate, and the agent merges on its verdict. Before any merge, the agent runs [the review loop](reference/review-loop-before-merge.md) until a pass on the head is clean.
302
302
 
303
303
  Validation is the precondition, and green means every check reported on the exact head commit. A branch rule that requires one status check names the floor a merge needs; a pull request whose other checks are red or still running is held until they report.
304
304
 
@@ -0,0 +1,11 @@
1
+ # The review loop runs before the merge
2
+
3
+ A pull request the agent made merges only after a code review of its head comes back clean. Loop it:
4
+
5
+ 1. Review the head with the command `CLAUDE_MERGE_REVIEW_COMMAND` names. Where it is unset, run `/code-review high --max-findings all` against the base.
6
+ 2. Record the pass: `python3 ~/.claude/scripts/merge_review_receipt.py <owner>/<name> <number> <head sha> <finding count>`.
7
+ 3. Fix or answer every finding, push, and review the new head.
8
+ 4. Repeat until a pass records `CLEAN`.
9
+ 5. Merge pinned to that head: `expectedHeadSha` on the merge tool, or `--match-head-commit` on `gh pr merge`.
10
+
11
+ `hooks/blocking/merge_review_gate.py` denies any other merge and any auto-merge. A repository whose own CI and merge queue review and merge its pull requests is listed in `CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES`, comma separated as `owner/name`, and skips the gate.
@@ -0,0 +1,303 @@
1
+ """Deny an agent's merge until a review of the merged head came back clean.
2
+
3
+ The agent that drives a pull request loops a code review on it, fixing and
4
+ pushing each finding, until one pass reports zero findings. The merge then
5
+ names that exact head::
6
+
7
+ merge, receipt says 0 findings at abc123, merge pinned to abc123 -> allow
8
+ merge, receipt at abc123, merge pinned to def456 -> deny, review again
9
+ merge, receipt at abc123, merge names no head -> deny, pin it
10
+ merge, no receipt or the receipt counts findings -> deny, run the loop
11
+ auto-merge -> deny, merge pinned instead
12
+ repository listed in CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES -> allow
13
+
14
+ The deny reason names the review command CLAUDE_MERGE_REVIEW_COMMAND sets,
15
+ or ``/code-review high --max-findings all`` when it is unset.
16
+
17
+ ``scripts/merge_review_receipt.py`` writes the receipt this reads.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ import sys
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+
28
+ hooks_directory = str(Path(__file__).resolve().parent.parent)
29
+ if hooks_directory not in sys.path:
30
+ sys.path.insert(0, hooks_directory)
31
+
32
+ from hooks_constants.hook_specific_output_keys import (
33
+ HOOK_SPECIFIC_OUTPUT_KEY,
34
+ PERMISSION_DECISION_KEY,
35
+ )
36
+ from hooks_constants.merge_review_gate_constants import (
37
+ ALL_RECEIPT_DIRECTORY_SEGMENTS,
38
+ ALL_SHELL_TOOL_NAMES,
39
+ AUTO_MERGE_REASON_TEMPLATE,
40
+ AUTO_MERGE_TOOL_SUFFIX,
41
+ API_SHA_FIELD_PATTERN,
42
+ AUTO_OPTION_PATTERN,
43
+ DEFAULT_REVIEW_COMMAND,
44
+ DENY_DECISION,
45
+ EXEMPT_REPOSITORIES_ENVIRONMENT_VARIABLE,
46
+ EXPECTED_HEAD_FIELD,
47
+ GH_API_MERGE_PATTERN,
48
+ GH_PR_MERGE_PATTERN,
49
+ HOOK_EVENT_NAME,
50
+ MATCH_HEAD_PATTERN,
51
+ MERGE_TOOL_SUFFIX,
52
+ NO_CLEAN_REVIEW_REASON_TEMPLATE,
53
+ NUMBER_FIELD,
54
+ OPTION_WITH_VALUE_PATTERN,
55
+ OWNER_FIELD,
56
+ PERMISSION_DECISION_REASON_KEY,
57
+ PR_NUMBER_PATTERN,
58
+ PR_URL_PATTERN,
59
+ RECEIPT_DIRECTORY_ENVIRONMENT_VARIABLE,
60
+ RECEIPT_FILE_NAME_TEMPLATE,
61
+ RECEIPT_FINDINGS_KEY,
62
+ RECEIPT_HEAD_KEY,
63
+ REPO_FIELD,
64
+ REPO_OPTION_PATTERN,
65
+ REPOSITORY_SEPARATOR,
66
+ REVIEW_COMMAND_ENVIRONMENT_VARIABLE,
67
+ STALE_REVIEW_REASON_TEMPLATE,
68
+ UNKNOWN_TARGET_REASON,
69
+ UNPINNED_MERGE_REASON_TEMPLATE,
70
+ )
71
+ from hooks_constants.pre_tool_use_stdin import print_decision_from_stdin
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class MergeRequest:
76
+ """One merge a tool call asks for.
77
+
78
+ Attributes:
79
+ owner: Repository owner, or empty when the call names none.
80
+ repo: Repository name, or empty when the call names none.
81
+ number: Pull request number, or None when the call names none.
82
+ pinned_head: The head the merge is pinned to, or None.
83
+ is_auto_merge: Whether the call turns on auto-merge.
84
+ """
85
+
86
+ owner: str
87
+ repo: str
88
+ number: int | None
89
+ pinned_head: str | None
90
+ is_auto_merge: bool
91
+
92
+ @property
93
+ def slug(self) -> str:
94
+ """Return ``owner/repo``."""
95
+ return f"{self.owner}/{self.repo}"
96
+
97
+
98
+ def receipt_directory() -> Path:
99
+ """Return the folder that holds one review receipt per pull request.
100
+
101
+ Returns:
102
+ The folder CLAUDE_MERGE_REVIEW_RECEIPT_DIR names, else ``~/.claude/merge-reviews``.
103
+ """
104
+ override = os.environ.get(RECEIPT_DIRECTORY_ENVIRONMENT_VARIABLE)
105
+ if override:
106
+ return Path(override)
107
+ return Path.home().joinpath(*ALL_RECEIPT_DIRECTORY_SEGMENTS)
108
+
109
+
110
+ def receipt_path(owner: str, repo: str, number: int) -> Path:
111
+ """Return where the review receipt for one pull request lives.
112
+
113
+ Args:
114
+ owner: Repository owner.
115
+ repo: Repository name.
116
+ number: Pull request number.
117
+
118
+ Returns:
119
+ The receipt file path.
120
+ """
121
+ return receipt_directory() / RECEIPT_FILE_NAME_TEMPLATE.format(
122
+ owner=owner.casefold(), repo=repo.casefold(), number=number
123
+ )
124
+
125
+
126
+ def merge_request_from(all_payload_fields: dict[str, object]) -> MergeRequest | None:
127
+ """Read the merge a PreToolUse payload asks for.
128
+
129
+ ::
130
+
131
+ mcp__github__merge_pull_request {owner, repo, pullNumber, expectedHeadSha}
132
+ mcp__github__enable_pr_auto_merge {owner, repo, pullNumber}
133
+ gh pr merge 12 --repo o/r --match-head-commit abc123
134
+ gh api repos/o/r/pulls/12/merge -X PUT
135
+
136
+ Args:
137
+ all_payload_fields: The parsed PreToolUse input.
138
+
139
+ Returns:
140
+ The merge request, or None when the call merges nothing.
141
+ """
142
+ tool_name = all_payload_fields.get("tool_name")
143
+ tool_input = all_payload_fields.get("tool_input")
144
+ if not isinstance(tool_name, str) or not isinstance(tool_input, dict):
145
+ return None
146
+ if tool_name.endswith((MERGE_TOOL_SUFFIX, AUTO_MERGE_TOOL_SUFFIX)):
147
+ return _tool_merge_request(tool_name, tool_input)
148
+ command = tool_input.get("command")
149
+ if tool_name not in ALL_SHELL_TOOL_NAMES or not isinstance(command, str):
150
+ return None
151
+ return _shell_merge_request(command)
152
+
153
+
154
+ def _tool_merge_request(tool_name: str, all_tool_input: dict[object, object]) -> MergeRequest:
155
+ number = all_tool_input.get(NUMBER_FIELD)
156
+ pinned_head = all_tool_input.get(EXPECTED_HEAD_FIELD)
157
+ return MergeRequest(
158
+ owner=str(all_tool_input.get(OWNER_FIELD, "")),
159
+ repo=str(all_tool_input.get(REPO_FIELD, "")),
160
+ number=int(number) if isinstance(number, (int, float)) else None,
161
+ pinned_head=pinned_head if isinstance(pinned_head, str) and pinned_head else None,
162
+ is_auto_merge=tool_name.endswith(AUTO_MERGE_TOOL_SUFFIX),
163
+ )
164
+
165
+
166
+ def _shell_merge_request(command: str) -> MergeRequest | None:
167
+ api_match = GH_API_MERGE_PATTERN.search(command)
168
+ if api_match:
169
+ return MergeRequest(
170
+ owner=api_match["owner"],
171
+ repo=api_match["repo"],
172
+ number=int(api_match["number"]),
173
+ pinned_head=_api_pinned_head(command),
174
+ is_auto_merge=False,
175
+ )
176
+ merge_match = GH_PR_MERGE_PATTERN.search(command)
177
+ if not merge_match:
178
+ return None
179
+ rest = merge_match["rest"]
180
+ owner, repo, number = _gh_pr_merge_target(rest)
181
+ head_match = MATCH_HEAD_PATTERN.search(rest)
182
+ return MergeRequest(
183
+ owner=owner,
184
+ repo=repo,
185
+ number=number,
186
+ pinned_head=head_match["sha"] if head_match else None,
187
+ is_auto_merge=bool(AUTO_OPTION_PATTERN.search(rest)),
188
+ )
189
+
190
+
191
+ def _gh_pr_merge_target(arguments: str) -> tuple[str, str, int | None]:
192
+ url_match = PR_URL_PATTERN.search(arguments)
193
+ if url_match:
194
+ return url_match["owner"], url_match["repo"], int(url_match["number"])
195
+ repo_match = REPO_OPTION_PATTERN.search(arguments)
196
+ number_match = PR_NUMBER_PATTERN.search(_without_option_values(arguments))
197
+ return (
198
+ repo_match["owner"] if repo_match else "",
199
+ repo_match["repo"] if repo_match else "",
200
+ int(number_match["number"]) if number_match else None,
201
+ )
202
+
203
+
204
+ def _api_pinned_head(command: str) -> str | None:
205
+ sha_match = API_SHA_FIELD_PATTERN.search(command)
206
+ return sha_match["sha"] if sha_match else None
207
+
208
+
209
+ def _without_option_values(arguments: str) -> str:
210
+ return OPTION_WITH_VALUE_PATTERN.sub(" ", arguments)
211
+
212
+
213
+ def _is_exempt(request: MergeRequest) -> bool:
214
+ listed = os.environ.get(EXEMPT_REPOSITORIES_ENVIRONMENT_VARIABLE, "")
215
+ all_exempt = {
216
+ each.strip().casefold() for each in listed.split(REPOSITORY_SEPARATOR) if each.strip()
217
+ }
218
+ return request.slug.casefold() in all_exempt
219
+
220
+
221
+ def _clean_head(request: MergeRequest) -> str | None:
222
+ if request.number is None:
223
+ return None
224
+ try:
225
+ receipt = json.loads(
226
+ receipt_path(request.owner, request.repo, request.number).read_text(encoding="utf-8")
227
+ )
228
+ except (OSError, ValueError):
229
+ return None
230
+ if not isinstance(receipt, dict) or receipt.get(RECEIPT_FINDINGS_KEY) != 0:
231
+ return None
232
+ head = receipt.get(RECEIPT_HEAD_KEY)
233
+ return head if isinstance(head, str) and head else None
234
+
235
+
236
+ def _review_command() -> str:
237
+ return os.environ.get(REVIEW_COMMAND_ENVIRONMENT_VARIABLE) or DEFAULT_REVIEW_COMMAND
238
+
239
+
240
+ def _same_commit(first: str, second: str) -> bool:
241
+ shorter, longer = sorted((first.casefold(), second.casefold()), key=len)
242
+ return longer.startswith(shorter)
243
+
244
+
245
+ def deny_reason(request: MergeRequest) -> str | None:
246
+ """Return why a merge must wait for a clean review, or None to allow it.
247
+
248
+ Args:
249
+ request: The merge the tool call asks for.
250
+
251
+ Returns:
252
+ The deny reason, or None when the merge may run.
253
+ """
254
+ if not request.owner or not request.repo or request.number is None:
255
+ return UNKNOWN_TARGET_REASON
256
+ if _is_exempt(request):
257
+ return None
258
+ fields = {"slug": request.slug, "number": request.number}
259
+ if request.is_auto_merge:
260
+ return AUTO_MERGE_REASON_TEMPLATE.format(**fields)
261
+ clean_head = _clean_head(request)
262
+ if clean_head is None:
263
+ return NO_CLEAN_REVIEW_REASON_TEMPLATE.format(review_command=_review_command(), **fields)
264
+ if request.pinned_head is None:
265
+ return UNPINNED_MERGE_REASON_TEMPLATE.format(head=clean_head, **fields)
266
+ if not _same_commit(clean_head, request.pinned_head):
267
+ return STALE_REVIEW_REASON_TEMPLATE.format(
268
+ head=clean_head, pinned=request.pinned_head, **fields
269
+ )
270
+ return None
271
+
272
+
273
+ def decision_for(all_payload_fields: dict[str, object]) -> dict[str, object] | None:
274
+ """Return a deny decision for a merge with no clean review, else None.
275
+
276
+ Args:
277
+ all_payload_fields: The parsed PreToolUse input.
278
+
279
+ Returns:
280
+ The hook output that denies the call, or None to allow it.
281
+ """
282
+ request = merge_request_from(all_payload_fields)
283
+ if request is None:
284
+ return None
285
+ reason = deny_reason(request)
286
+ if reason is None:
287
+ return None
288
+ return {
289
+ HOOK_SPECIFIC_OUTPUT_KEY: {
290
+ "hookEventName": HOOK_EVENT_NAME,
291
+ PERMISSION_DECISION_KEY: DENY_DECISION,
292
+ PERMISSION_DECISION_REASON_KEY: reason,
293
+ }
294
+ }
295
+
296
+
297
+ def main() -> int:
298
+ """Read one payload, print a deny when needed, and exit zero."""
299
+ return print_decision_from_stdin(decision_for)
300
+
301
+
302
+ if __name__ == "__main__":
303
+ sys.exit(main())
@@ -2,7 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import json
6
5
  import re
7
6
  import shlex
8
7
  import sys
@@ -54,7 +53,7 @@ from hooks_constants.hook_specific_output_keys import (
54
53
  HOOK_SPECIFIC_OUTPUT_KEY,
55
54
  PERMISSION_DECISION_KEY,
56
55
  )
57
- from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
56
+ from hooks_constants.pre_tool_use_stdin import print_decision_from_stdin
58
57
  from hooks_constants.setup_project_paths_constants import DECODE_ERRORS_POLICY, UTF8_ENCODING
59
58
  from blocking.followup_pr_dedupe import create_tool_fields, duplicate_followup_reason
60
59
  from blocking.pull_request_proof import (
@@ -298,12 +297,7 @@ def _deny(reason: str) -> dict[str, object]:
298
297
 
299
298
  def main() -> int:
300
299
  """Read one payload, print a deny when needed, and exit zero."""
301
- payload = read_hook_input_dictionary_from_stdin()
302
- if payload is not None:
303
- decision = decision_for(payload)
304
- if decision is not None:
305
- sys.stdout.write(json.dumps(decision) + "\n")
306
- return 0
300
+ return print_decision_from_stdin(decision_for)
307
301
 
308
302
 
309
303
  if __name__ == "__main__":
@@ -0,0 +1,115 @@
1
+ """Behavior tests for the clean-review merge gate."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ HOOKS_DIRECTORY = Path(__file__).resolve().parent.parent
9
+ SCRIPTS_DIRECTORY = HOOKS_DIRECTORY.parent / "scripts"
10
+ for each_directory in (HOOKS_DIRECTORY, SCRIPTS_DIRECTORY):
11
+ if str(each_directory) not in sys.path:
12
+ sys.path.insert(0, str(each_directory))
13
+
14
+ import merge_review_receipt
15
+ from blocking import merge_review_gate as gate
16
+
17
+ CLEAN_HEAD = "abc1234def5678abc1234def5678abc1234def56"
18
+ NEW_HEAD = "9999999aaaaaaa9999999aaaaaaa9999999aaaaa"
19
+
20
+
21
+ @pytest.fixture(autouse=True)
22
+ def receipt_folder(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
23
+ monkeypatch.setenv("CLAUDE_MERGE_REVIEW_RECEIPT_DIR", str(tmp_path))
24
+ monkeypatch.delenv("CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES", raising=False)
25
+ monkeypatch.delenv("CLAUDE_MERGE_REVIEW_COMMAND", raising=False)
26
+ return tmp_path
27
+
28
+
29
+ def _merge_tool(head: str | None = None) -> dict[str, object]:
30
+ tool_input: dict[str, object] = {"owner": "acme", "repo": "tools", "pullNumber": 12}
31
+ if head is not None:
32
+ tool_input["expectedHeadSha"] = head
33
+ return {"tool_name": "mcp__github__merge_pull_request", "tool_input": tool_input}
34
+
35
+
36
+ def _bash(command: str) -> dict[str, object]:
37
+ return {"tool_name": "Bash", "tool_input": {"command": command}}
38
+
39
+
40
+ def _reason(payload: dict[str, object]) -> str | None:
41
+ decision = gate.decision_for(payload)
42
+ if decision is None:
43
+ return None
44
+ return decision["hookSpecificOutput"]["permissionDecisionReason"]
45
+
46
+
47
+ def test_should_deny_a_merge_with_no_review() -> None:
48
+ assert "no clean review" in _reason(_merge_tool(CLEAN_HEAD))
49
+
50
+
51
+ def test_should_deny_a_merge_whose_last_pass_found_findings() -> None:
52
+ merge_review_receipt.record_review("acme/tools", 12, CLEAN_HEAD, 2)
53
+ assert "no clean review" in _reason(_merge_tool(CLEAN_HEAD))
54
+
55
+
56
+ def test_should_allow_a_merge_pinned_to_the_clean_head() -> None:
57
+ merge_review_receipt.record_review("acme/tools", 12, CLEAN_HEAD, 0)
58
+ assert _reason(_merge_tool(CLEAN_HEAD)) is None
59
+
60
+
61
+ def test_should_deny_a_merge_pinned_to_a_head_pushed_after_the_review() -> None:
62
+ merge_review_receipt.record_review("acme/tools", 12, CLEAN_HEAD, 0)
63
+ assert "Review the new head" in _reason(_merge_tool(NEW_HEAD))
64
+
65
+
66
+ def test_should_deny_an_unpinned_merge_after_a_clean_review() -> None:
67
+ merge_review_receipt.record_review("acme/tools", 12, CLEAN_HEAD, 0)
68
+ assert "names no head" in _reason(_merge_tool())
69
+
70
+
71
+ def test_should_deny_auto_merge_even_after_a_clean_review() -> None:
72
+ merge_review_receipt.record_review("acme/tools", 12, CLEAN_HEAD, 0)
73
+ payload = {
74
+ "tool_name": "mcp__github__enable_pr_auto_merge",
75
+ "tool_input": {"owner": "acme", "repo": "tools", "pullNumber": 12},
76
+ }
77
+ assert "Auto-merge" in _reason(payload)
78
+
79
+
80
+ def test_should_allow_any_merge_in_an_exempt_repository(monkeypatch: pytest.MonkeyPatch) -> None:
81
+ monkeypatch.setenv("CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES", "other/x, ACME/Tools")
82
+ assert _reason(_merge_tool()) is None
83
+
84
+
85
+ def test_should_read_a_gh_pr_merge_pinned_with_a_short_head() -> None:
86
+ merge_review_receipt.record_review("acme/tools", 12, CLEAN_HEAD, 0)
87
+ command = f"gh pr merge --squash 12 --repo acme/tools --match-head-commit {CLEAN_HEAD[:7]}"
88
+ assert _reason(_bash(command)) is None
89
+
90
+
91
+ def test_should_deny_gh_pr_merge_auto() -> None:
92
+ merge_review_receipt.record_review("acme/tools", 12, CLEAN_HEAD, 0)
93
+ assert "Auto-merge" in _reason(
94
+ _bash("gh pr merge https://github.com/acme/tools/pull/12 --auto")
95
+ )
96
+
97
+
98
+ def test_should_deny_gh_pr_merge_with_no_repository() -> None:
99
+ assert "names no repository" in _reason(_bash("gh pr merge 12 --squash"))
100
+
101
+
102
+ def test_should_read_a_gh_api_merge_with_a_sha_field() -> None:
103
+ merge_review_receipt.record_review("acme/tools", 12, CLEAN_HEAD, 0)
104
+ command = f"gh api -X PUT repos/acme/tools/pulls/12/merge -f sha={CLEAN_HEAD}"
105
+ assert _reason(_bash(command)) is None
106
+
107
+
108
+ def test_should_ignore_commands_that_merge_nothing() -> None:
109
+ assert gate.decision_for(_bash("gh pr view 12 --repo acme/tools")) is None
110
+ assert gate.decision_for(_bash("git merge origin/main")) is None
111
+
112
+
113
+ def test_should_name_the_configured_review_command(monkeypatch: pytest.MonkeyPatch) -> None:
114
+ monkeypatch.setenv("CLAUDE_MERGE_REVIEW_COMMAND", "/team-review")
115
+ assert "`/team-review`" in _reason(_merge_tool(CLEAN_HEAD))
@@ -11,6 +11,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
11
11
  - `blocking/step_note_gate.py` requires a status line before a tool call while its opt-in flag is on.
12
12
  - `blocking/verify_before_acting.py` blocks a completed mutating call when the agent's reasoning contains an unchecked claim.
13
13
  - `blocking/pr_lifecycle_skill_gate.py` denies a commit, push, pull request action, or merge until the `pr-lifecycle` skill appears in the transcript since the last compaction.
14
+ - `blocking/merge_review_gate.py` denies an agent's merge until a review pass on the merged head reported zero findings. It reads the receipt `scripts/merge_review_receipt.py` writes, requires the merge to be pinned to that head, denies auto-merge, and skips repositories listed in `CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES`. Its deny reason names the review command `CLAUDE_MERGE_REVIEW_COMMAND` sets.
14
15
  - `blocking/artifact_dark_mode_gate.py` denies an Artifact page publish when the page, inside the publish wrapper, keeps a light body or shows text under 4.5:1 contrast in dark mode. `blocking/artifact_dark_mode_render.cjs` renders the page in headless Chromium for it.
15
16
 
16
17
  ## When it fires
@@ -22,6 +23,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
22
23
  - `blocking/step_note_gate.py` runs on `PreToolUse`, matcher `*`, timeout `15` seconds in `hooks.json`.
23
24
  - `blocking/verify_before_acting.py` runs on `PostToolUse`, matcher `Write|Edit|MultiEdit|NotebookEdit|Agent|Task|apply_patch|Bash|PowerShell|mcp__.*`, timeout `10` seconds in `hooks.json`.
24
25
  - `blocking/pr_lifecycle_skill_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request|actions_run_trigger)`, timeout `10` seconds in `hooks.json`.
26
+ - `blocking/merge_review_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(merge_pull_request|enable_pr_auto_merge)`, timeout `10` seconds in `hooks.json`.
25
27
  - `blocking/artifact_dark_mode_gate.py` runs on `PreToolUse`, matcher `Artifact`, timeout `60` seconds in `hooks.json`.
26
28
 
27
29
  ## Proving it
@@ -37,6 +39,7 @@ Preconditions:
37
39
  - **Step note.** Input is a tool call after a transcript message without a status line while the flag is on. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_step_note_gate.py -q`. The adjacent test observes a block until a status line appears.
38
40
  - **Checked claim.** Input is a Write after hedged reasoning in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_verify_before_acting.py -q`. The adjacent test observes a block and a logged blocked outcome.
39
41
  - **Lifecycle skill.** Input is a `git commit` or `gh pr create` command with no `pr-lifecycle` invocation in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_pr_lifecycle_skill_gate.py -q`. The adjacent test observes a denial, then a silent allow once the skill or its slash command appears.
42
+ - **Clean review.** Input is a merge with no receipt, a receipt with findings, a merge pinned to a newer head, an unpinned merge, or auto-merge. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_merge_review_gate.py -q`. The adjacent test observes a denial for each; a merge pinned to the clean head and a merge in an exempt repository pass.
40
43
  - **Dark mode page.** Input is an Artifact publish of a page that themes `html` but leaves `body` to the publish wrapper. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_artifact_dark_mode_gate.py -q`. The adjacent test observes a denial that names the text and its contrast; a page that themes `body` passes. The render tests skip on a host with no Node Playwright and Chromium.
41
44
 
42
45
  ## Gotchas
@@ -48,3 +51,4 @@ Preconditions:
48
51
  - The handoff close gate reads the `body` and `comment` fields of issue tools only. A `gh issue close --comment` command in Bash and a pull request body that closes the issue with `Closes #N` do not reach it.
49
52
  - The lifecycle gate counts only invocations after the last compaction, so a compacted session invokes `pr-lifecycle` again. A missing or unreadable transcript allows the call.
50
53
  - The dark mode gate denies a page publish when Node, Playwright or Chromium is missing, and its reason names the install command. It checks both dark signals, `prefers-color-scheme: dark` and `data-theme="dark"` on the root element.
54
+ - The clean-review gate trusts the receipt. A receipt the agent writes by hand with zero findings passes, so the pr-lifecycle skill names the review loop that writes it. A `gh pr merge` that names no repository is denied, because the gate cannot tell which repository the current branch belongs to.
package/hooks/hooks.json CHANGED
@@ -102,6 +102,16 @@
102
102
  }
103
103
  ]
104
104
  },
105
+ {
106
+ "matcher": "Bash|PowerShell|mcp__.*__(merge_pull_request|enable_pr_auto_merge)",
107
+ "hooks": [
108
+ {
109
+ "type": "command",
110
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/merge_review_gate.py",
111
+ "timeout": 10
112
+ }
113
+ ]
114
+ },
105
115
  {
106
116
  "matcher": "Read",
107
117
  "hooks": [
@@ -0,0 +1,72 @@
1
+ """Named constants for merge_review_gate, the clean-review merge gate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ HOOK_EVENT_NAME = "PreToolUse"
8
+ DENY_DECISION = "deny"
9
+ PERMISSION_DECISION_REASON_KEY = "permissionDecisionReason"
10
+
11
+ MERGE_TOOL_SUFFIX = "__merge_pull_request"
12
+ AUTO_MERGE_TOOL_SUFFIX = "__enable_pr_auto_merge"
13
+ ALL_SHELL_TOOL_NAMES = frozenset({"Bash", "PowerShell"})
14
+
15
+ OWNER_FIELD = "owner"
16
+ REPO_FIELD = "repo"
17
+ NUMBER_FIELD = "pullNumber"
18
+ EXPECTED_HEAD_FIELD = "expectedHeadSha"
19
+
20
+ GH_PR_MERGE_PATTERN = re.compile(r"(?:^|[\s;&|(])gh(?:\.exe)?\s+pr\s+merge\b(?P<rest>[^;&|]*)")
21
+ GH_API_MERGE_PATTERN = re.compile(
22
+ r"(?:^|[\s;&|(])gh(?:\.exe)?\s+api\b[^;&|]*?repos/(?P<owner>[\w.-]+)/(?P<repo>[\w.-]+)/pulls/(?P<number>\d+)/merge\b"
23
+ )
24
+ PR_NUMBER_PATTERN = re.compile(r"(?:^|\s)#?(?P<number>\d+)(?:\s|$)")
25
+ PR_URL_PATTERN = re.compile(
26
+ r"github\.com/(?P<owner>[\w.-]+)/(?P<repo>[\w.-]+)/pull/(?P<number>\d+)"
27
+ )
28
+ REPO_OPTION_PATTERN = re.compile(r"(?:-R|--repo)[\s=](?P<owner>[\w.-]+)/(?P<repo>[\w.-]+)")
29
+ MATCH_HEAD_PATTERN = re.compile(r"--match-head-commit[\s=](?P<sha>[0-9a-fA-F]{7,40})")
30
+ API_SHA_FIELD_PATTERN = re.compile(r"(?:-f|-F|--field|--raw-field)[\s=]sha=(?P<sha>[0-9a-fA-F]{7,40})")
31
+ OPTION_WITH_VALUE_PATTERN = re.compile(
32
+ r"(?<!\S)(?:-R|--repo|--match-head-commit|-t|--subject|-b|--body|-F|--body-file|-A|--author-email)"
33
+ r"(?:=|\s+)(?:\"[^\"]*\"|'[^']*'|\S+)"
34
+ )
35
+ AUTO_OPTION_PATTERN = re.compile(r"(?:^|\s)--auto(?:\s|$)")
36
+
37
+ EXEMPT_REPOSITORIES_ENVIRONMENT_VARIABLE = "CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES"
38
+ REVIEW_COMMAND_ENVIRONMENT_VARIABLE = "CLAUDE_MERGE_REVIEW_COMMAND"
39
+ DEFAULT_REVIEW_COMMAND = "/code-review high --max-findings all"
40
+ RECEIPT_DIRECTORY_ENVIRONMENT_VARIABLE = "CLAUDE_MERGE_REVIEW_RECEIPT_DIR"
41
+ ALL_RECEIPT_DIRECTORY_SEGMENTS = (".claude", "merge-reviews")
42
+ RECEIPT_FILE_NAME_TEMPLATE = "{owner}__{repo}__{number}.json"
43
+ REPOSITORY_SEPARATOR = ","
44
+ RECEIPT_HEAD_KEY = "head"
45
+ RECEIPT_FINDINGS_KEY = "findings"
46
+ RECORD_COMMAND = "python3 ~/.claude/scripts/merge_review_receipt.py"
47
+
48
+ NO_CLEAN_REVIEW_REASON_TEMPLATE = (
49
+ "{slug}#{number} has no clean review on the head you are merging. Run the review "
50
+ "loop: review the pull request head with `{review_command}`, fix and push every "
51
+ "finding, and review again "
52
+ "until a pass reports zero findings. Record that pass with `"
53
+ + RECORD_COMMAND
54
+ + " {slug} {number} <head sha> 0`, then merge pinned to that head."
55
+ )
56
+ UNPINNED_MERGE_REASON_TEMPLATE = (
57
+ "{slug}#{number} has a clean review for {head}, but this merge names no head. "
58
+ "Pass expectedHeadSha (merge tool) or --match-head-commit (gh pr merge) set to "
59
+ "{head}, so a push after the review cannot ride the merge."
60
+ )
61
+ STALE_REVIEW_REASON_TEMPLATE = (
62
+ "{slug}#{number} was reviewed clean at {head}, and this merge names {pinned}. "
63
+ "Review the new head until a pass reports zero findings, record it, then merge."
64
+ )
65
+ AUTO_MERGE_REASON_TEMPLATE = (
66
+ "Auto-merge on {slug}#{number} merges a future head no review has seen. Run the "
67
+ "review loop until it is clean, then merge directly, pinned to the reviewed head."
68
+ )
69
+ UNKNOWN_TARGET_REASON = (
70
+ "This merge names no repository and pull request number the review gate can read. "
71
+ "Name both, such as `gh pr merge 12 --repo owner/name --match-head-commit <sha>`."
72
+ )
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import sys
7
+ from collections.abc import Callable
7
8
 
8
9
  from hooks_constants.setup_project_paths_constants import (
9
10
  DECODE_ERRORS_POLICY,
@@ -46,3 +47,22 @@ def read_hook_input_dictionary_from_stdin() -> dict[str, object] | None:
46
47
  if not isinstance(parsed_payload, dict):
47
48
  return None
48
49
  return parsed_payload
50
+
51
+
52
+ def print_decision_from_stdin(
53
+ decision_for: Callable[[dict[str, object]], dict[str, object] | None],
54
+ ) -> int:
55
+ """Read one payload, print the decision the gate returns, and return zero.
56
+
57
+ Args:
58
+ decision_for: The gate's function from a payload to its hook output, or None.
59
+
60
+ Returns:
61
+ Zero, so the hook never fails the tool call on its own.
62
+ """
63
+ payload = read_hook_input_dictionary_from_stdin()
64
+ if payload is not None:
65
+ decision = decision_for(payload)
66
+ if decision is not None:
67
+ sys.stdout.write(json.dumps(decision) + "\n")
68
+ return 0
@@ -78,3 +78,20 @@ def test_read_returns_none_when_buffer_and_text_read_raise_attribute_error() ->
78
78
 
79
79
  with patch("sys.stdin", BrokenStandardInput()):
80
80
  assert read_hook_input_dictionary_from_stdin() is None
81
+
82
+
83
+ def test_print_decision_from_stdin_writes_the_gate_output() -> None:
84
+ output = io.StringIO()
85
+ with patch("sys.stdin", io.StringIO('{"tool_name": "Bash"}')), patch("sys.stdout", output):
86
+ exit_code = pre_tool_use_stdin.print_decision_from_stdin(
87
+ lambda payload: {"seen": payload["tool_name"]}
88
+ )
89
+ assert exit_code == 0
90
+ assert json.loads(output.getvalue()) == {"seen": "Bash"}
91
+
92
+
93
+ def test_print_decision_from_stdin_writes_nothing_on_allow() -> None:
94
+ output = io.StringIO()
95
+ with patch("sys.stdin", io.StringIO('{"tool_name": "Bash"}')), patch("sys.stdout", output):
96
+ assert pre_tool_use_stdin.print_decision_from_stdin(lambda payload: None) == 0
97
+ assert output.getvalue() == ""
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.77.0",
3
+ "version": "8.78.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env python3
2
+ """Record one review pass on a pull request head for the merge review gate.
3
+
4
+ The agent loops a code review on its pull request. Each pass is recorded::
5
+
6
+ python3 merge_review_receipt.py owner/name 12 abc1234 3 -> REVIEWED owner/name#12 abc1234 3 findings
7
+ fix, push, review the new head
8
+ python3 merge_review_receipt.py owner/name 12 def5678 0 -> CLEAN owner/name#12 def5678
9
+
10
+ ``hooks/blocking/merge_review_gate.py`` allows a merge pinned to the head of
11
+ the newest pass only when that pass counted zero findings.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import sys
19
+ from collections.abc import Sequence
20
+ from pathlib import Path
21
+ from typing import TextIO
22
+
23
+ hooks_directory = str(Path(__file__).resolve().parent.parent / "hooks")
24
+ if hooks_directory not in sys.path:
25
+ sys.path.insert(0, hooks_directory)
26
+
27
+ from blocking.merge_review_gate import receipt_path
28
+ from hooks_constants.merge_review_gate_constants import (
29
+ RECEIPT_FINDINGS_KEY,
30
+ RECEIPT_HEAD_KEY,
31
+ )
32
+
33
+
34
+ def record_review(slug: str, number: int, head: str, finding_count: int) -> Path:
35
+ """Write the receipt for one review pass, replacing the previous pass.
36
+
37
+ Args:
38
+ slug: Repository as ``owner/name``.
39
+ number: Pull request number.
40
+ head: The commit the pass reviewed.
41
+ finding_count: How many findings the pass reported.
42
+
43
+ Returns:
44
+ The receipt path written.
45
+ """
46
+ owner, repo = slug.split("/", 1)
47
+ path = receipt_path(owner, repo, number)
48
+ path.parent.mkdir(parents=True, exist_ok=True)
49
+ path.write_text(
50
+ json.dumps({RECEIPT_HEAD_KEY: head, RECEIPT_FINDINGS_KEY: finding_count})
51
+ + "\n",
52
+ encoding="utf-8",
53
+ )
54
+ return path
55
+
56
+
57
+ def main(all_arguments: Sequence[str], stdout: TextIO = sys.stdout) -> int:
58
+ """Record one pass and print its verdict line.
59
+
60
+ Args:
61
+ all_arguments: Command arguments after the program name.
62
+ stdout: Stream that receives the verdict.
63
+
64
+ Returns:
65
+ 0 for a clean pass, and 1 for a pass with findings.
66
+ """
67
+ parser = argparse.ArgumentParser(prog="merge_review_receipt")
68
+ parser.add_argument("slug", help="Repository as owner/name.")
69
+ parser.add_argument("number", type=int, help="Pull request number.")
70
+ parser.add_argument("head", help="The head commit the pass reviewed.")
71
+ parser.add_argument("findings", type=int, help="Findings the pass reported.")
72
+ parsed = parser.parse_args(all_arguments)
73
+ record_review(parsed.slug, parsed.number, parsed.head, parsed.findings)
74
+ if parsed.findings == 0:
75
+ stdout.write(f"CLEAN {parsed.slug}#{parsed.number} {parsed.head}\n")
76
+ return 0
77
+ stdout.write(
78
+ f"REVIEWED {parsed.slug}#{parsed.number} {parsed.head} {parsed.findings} findings\n"
79
+ )
80
+ return 1
81
+
82
+
83
+ if __name__ == "__main__":
84
+ sys.exit(main(sys.argv[1:]))
@@ -64,6 +64,7 @@ ALL_ACTION_BOUNDARY_PREFIXES = ("deny_", "block_", "ask_")
64
64
  ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS = (
65
65
  "blocking/bash_pre_tool_use_dispatcher.py",
66
66
  "blocking/pr_lifecycle_skill_gate.py",
67
+ "blocking/merge_review_gate.py",
67
68
  "blocking/step_note_gate.py",
68
69
  "blocking/reply_length_gate.py",
69
70
  "blocking/edit_marker_gate.py",
@@ -0,0 +1,38 @@
1
+ """Tests for the merge review receipt recorder."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from io import StringIO
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ import merge_review_receipt
12
+
13
+ HEAD = "abc1234def5678abc1234def5678abc1234def56"
14
+
15
+
16
+ @pytest.fixture(autouse=True)
17
+ def receipt_folder(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
18
+ monkeypatch.setenv("CLAUDE_MERGE_REVIEW_RECEIPT_DIR", str(tmp_path))
19
+ return tmp_path
20
+
21
+
22
+ def test_should_print_clean_and_exit_zero_for_a_pass_with_no_findings(receipt_folder: Path) -> None:
23
+ verdict_stream = StringIO()
24
+ assert merge_review_receipt.main(["acme/tools", "12", HEAD, "0"], verdict_stream) == 0
25
+ assert verdict_stream.getvalue() == f"CLEAN acme/tools#12 {HEAD}\n"
26
+
27
+
28
+ def test_should_exit_one_and_count_findings_for_a_pass_with_findings() -> None:
29
+ verdict_stream = StringIO()
30
+ assert merge_review_receipt.main(["acme/tools", "12", HEAD, "3"], verdict_stream) == 1
31
+ assert verdict_stream.getvalue() == f"REVIEWED acme/tools#12 {HEAD} 3 findings\n"
32
+
33
+
34
+ def test_record_review_replaces_the_previous_pass(receipt_folder: Path) -> None:
35
+ merge_review_receipt.record_review("Acme/Tools", 12, "old", 4)
36
+ path = merge_review_receipt.record_review("Acme/Tools", 12, HEAD, 0)
37
+ assert path == receipt_folder / "acme__tools__12.json"
38
+ assert json.loads(path.read_text(encoding="utf-8")) == {"head": HEAD, "findings": 0}