claude-dev-env 8.91.0 → 8.92.1

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.
@@ -8,7 +8,7 @@ description: >-
8
8
 
9
9
  # e-code-review
10
10
 
11
- **Pick a level, load its file, run it.** Each file has the same steps, angles, caps, and fields as the built-in `/code-review` recipe for that level. Only the wording differs, where this repo's prose rules require it.
11
+ **Pick a level, load its file, run it.** Each file has the same steps, angles, caps, and fields as the built-in `/code-review` recipe for that level. Only the wording differs, where this repo's prose rules require it. A question about `/code-review` asks about the built-in recipe, so answer it from the built-in text and use these files only for `/e-code-review`.
12
12
 
13
13
  | Level | File | Shape |
14
14
  |---|---|---|
@@ -21,7 +21,7 @@ When the plan runtime is `other`, the model is deferred. The running agent appli
21
21
 
22
22
  ## Loop
23
23
 
24
- Check each finding against the code. Fix every one that holds. Run the review again until a pass reports none. The steps are in [reference/criteria.md](reference/criteria.md).
24
+ Check each finding against the code. Fix every one that holds. Run the review again until a pass reports none. Before a merge, [the pr-lifecycle review loop](../pr-lifecycle/reference/review-loop-before-merge.md) sets how many full passes run. The steps are in [reference/criteria.md](reference/criteria.md).
25
25
 
26
26
  ## Reading guide
27
27
 
@@ -18,4 +18,4 @@ An empty list is a valid review. End with a json block. Each finding has `file`,
18
18
 
19
19
  ## Loop
20
20
 
21
- Fix every finding that holds, including a file the diff did not touch. Then run the review again. Stop when a pass reports no finding that holds.
21
+ Fix every finding that holds, including a file the diff did not touch. Then run the review again. Stop when a pass reports no finding that holds. Before a merge, [the pr-lifecycle review loop](../../pr-lifecycle/reference/review-loop-before-merge.md) sets how many full passes run.
@@ -2,10 +2,11 @@
2
2
 
3
3
  A pull request the agent made merges only after a code review of its head comes back clean. Loop it:
4
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.
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. This is a full pass.
6
6
  2. Record the pass: `python3 ~/.claude/scripts/merge_review_receipt.py <owner>/<name> <number> <head sha> <finding count>`.
7
7
  3. Fix every valid finding, push, and review the new head. Answer each finding you rule out on the pull request with its file:line evidence.
8
- 4. Repeat until a pass records `CLEAN`. A pass whose every finding is ruled out records `CLEAN` with `--ruled-out <finding count> --rulings <link to the rulings comment on this pull request>`.
9
- 5. Merge pinned to that head: `expectedHeadSha` on the merge tool, or `--match-head-commit` on `gh pr merge`.
8
+ 4. The first three passes are full passes. Each later pass is `/code-review low --fix <number>`. It reads only the pull request's diff against its base and leaves its fixes uncommitted. Name the number: after a push, a bare `/code-review low` diffs the branch against its own upstream and sees nothing. Commit and push those fixes, then run the next pass on the new head. A pass that reported findings records its finding count, even when `--fix` fixed them all.
9
+ 5. Repeat until a pass records `CLEAN`. A pass whose every finding is ruled out records `CLEAN` with `--ruled-out <finding count> --rulings <link to the rulings comment on this pull request>`.
10
+ 6. Merge pinned to that head: `expectedHeadSha` on the merge tool, or `--match-head-commit` on `gh pr merge`.
10
11
 
11
12
  `hooks/blocking/merge_review_gate.py` denies any other merge. In a merge-queue repository, enqueue with `gh pr merge <number> --repo <owner>/<name> --auto --match-head-commit <head sha>`; auto-merge that names no head is denied. 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.
@@ -181,6 +181,7 @@ export const ALL_CODEX_NATIVE_PRE_TOOL_USE_MATCHERS = new Set([
181
181
  ]);
182
182
  export const ALL_CODEX_NATIVE_STOP_HOOK_SCRIPT_NAMES = new Set([
183
183
  'open_tasks_stop_gate.py',
184
+ 'owned_pr_merge_stop_gate.py',
184
185
  ]);
185
186
  export const ALL_CODEX_NATIVE_SESSION_START_HOOK_SCRIPT_NAMES = new Set([
186
187
  'compaction_recall_prompt.py',
@@ -141,8 +141,9 @@ test('seeds one editable policy and one native Codex routing hook', () => {
141
141
  assert.equal(allCodexPostToolUseCommands.length, 1, 'Codex gets the owned PR review loop and no other PostToolUse hook');
142
142
  assert.match(allCodexPostToolUseCommands[0], /advisory\/owned_pr_review_loop\.py/);
143
143
  const allCodexStopCommands = firstCodexHooks.hooks.Stop.flatMap(group => group.hooks.map(hook => hook.command));
144
- assert.equal(allCodexStopCommands.length, 1, 'Codex gets the open tasks Stop gate and no other Stop hook');
144
+ assert.equal(allCodexStopCommands.length, 2, 'Codex gets the open tasks and owned PR merge Stop gates and no other Stop hook');
145
145
  assert.match(allCodexStopCommands[0], /open_tasks_stop_gate\.py/);
146
+ assert.match(allCodexStopCommands[1], /owned_pr_merge_stop_gate\.py/);
146
147
  const allCodexSessionStartGroups = firstCodexHooks.hooks.SessionStart;
147
148
  assert.deepEqual(allCodexSessionStartGroups.map(group => group.matcher), ['compact']);
148
149
  const allCodexSessionStartCommands = allCodexSessionStartGroups.flatMap(group => group.hooks.map(hook => hook.command));
@@ -14,6 +14,9 @@ block to the agent's context::
14
14
  3. Babysit. Follow the pstack babysit playbook ...
15
15
  4. Ship. Follow the pstack shipping playbook ...
16
16
 
17
+ Each reported pull request also goes into the session record, so the owned
18
+ pull request Stop gate holds the turn until it merges.
19
+
17
20
  The pull request URL in the tool response is the proof that a pull request
18
21
  opened, so the hook needs no network call and reads the same on Claude Code
19
22
  and Codex.
@@ -77,6 +80,7 @@ try:
77
80
  all_wrapped_command_texts,
78
81
  segment_program_and_arguments,
79
82
  )
83
+ from owned_pr_session_record import record_pull_request
80
84
  except ImportError as import_error:
81
85
  raise ImportError(
82
86
  "owned_pr_review_loop: cannot import its dependencies; "
@@ -336,7 +340,8 @@ def build_review_loop_context(all_pull_request_fields: dict[str, str]) -> str:
336
340
  def main() -> None:
337
341
  """Add the review-loop instructions after a pull request opens on an owned repository.
338
342
 
339
- Reads the PostToolUse payload from stdin. Every quiet branch returns with
343
+ Reads the PostToolUse payload from stdin. The pull request also goes into
344
+ the session record that the owned pull request Stop gate reads. Every quiet branch returns with
340
345
  no output, so this hook can never alter or block a tool call.
341
346
  """
342
347
  hook_payload = read_hook_input_dictionary_from_stdin()
@@ -358,6 +363,7 @@ def main() -> None:
358
363
  ALL_PRESET_REPOSITORY_DIGESTS,
359
364
  ):
360
365
  return
366
+ record_pull_request(hook_payload.get("session_id"), pull_request)
361
367
  write_post_tool_use_context_to_stdout(build_review_loop_context(pull_request))
362
368
 
363
369
 
@@ -281,12 +281,29 @@ def test_should_name_every_step_and_skill_in_the_context() -> None:
281
281
  )
282
282
  assert "jl-cmd-projects/claude-dev-env#42" in context
283
283
  assert "efficient-review" in context
284
+ assert "three full passes" in context
285
+ assert "/code-review low --fix 42" in context
284
286
  assert "gh pr ready 42 --repo jl-cmd-projects/claude-dev-env" in context
285
287
  assert "poteto-mode/playbooks/babysit.md" in context
286
288
  assert "poteto-mode/playbooks/shipping.md" in context
287
289
  assert "merged" in context
288
290
 
289
291
 
292
+ def test_should_carry_the_owner_approval_to_merge() -> None:
293
+ context = owned_pr_review_loop.build_review_loop_context(
294
+ {
295
+ "owner": "jl-cmd-projects",
296
+ "repository": "claude-dev-env",
297
+ "number": "42",
298
+ "url": OWNED_PR_URL,
299
+ }
300
+ )
301
+ assert "The repository owner installed this hook" in context
302
+ assert "approves every step above, the merge included" in context
303
+ assert "without asking first" in context
304
+ assert "Your task now runs past opening this PR to its merge" in context
305
+
306
+
290
307
  def test_main_should_emit_the_loop_after_gh_pr_create_on_an_owned_repository() -> None:
291
308
  stdout = _run_hook(_shell_payload("gh pr create --fill", f"{OWNED_PR_URL}\n"))
292
309
  payload = json.loads(stdout)
@@ -367,3 +384,11 @@ def test_hooks_json_should_route_shell_and_mcp_create_calls_to_the_hook() -> Non
367
384
  "mcp__codex_apps__github_create_pull_request",
368
385
  ):
369
386
  assert re.fullmatch(all_matchers[0], each_tool_name), each_tool_name
387
+
388
+
389
+ def test_should_leave_a_held_pull_request_with_the_user() -> None:
390
+ context = owned_pr_review_loop.build_review_loop_context(
391
+ {"owner": "jl-cmd-projects", "repository": "claude-dev-env", "number": "42", "url": OWNED_PR_URL}
392
+ )
393
+ assert "When the user asked to hold this PR or to read it before it merges" in context
394
+ assert "Blocked: jl-cmd-projects/claude-dev-env#42 - the user holds the merge." in context
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env python3
2
+ """Stop hook that keeps a session working until its owned pull requests merge.
3
+
4
+ The owned pull request review-loop hook records each pull request the agent
5
+ opens on an owned repository outside the preset list. At the end of a turn
6
+ this gate asks GitHub for the state of each recorded pull request::
7
+
8
+ allow: every recorded pull request reads closed (merged or closed)
9
+ allow: the reply has "Blocked: acme/widgets#42 - the owner must approve"
10
+ block: acme/widgets#42 reads open and the reply names no blocker for it
11
+
12
+ A block sends the open pull requests back as the reason, so the model keeps
13
+ working the loop. Claude Code ends the turn after eight blocks in a row, and
14
+ Codex turns the reason into a continuation prompt. A state lookup that fails,
15
+ such as a missing ``gh`` or no network, counts as closed, so the gate never
16
+ holds a turn on a fact it could not read. The failure goes to stderr, where
17
+ the host shows it in the hook log.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import re
24
+ import subprocess
25
+ import sys
26
+ from pathlib import Path
27
+
28
+ hooks_root_directory = str(Path(__file__).resolve().parent.parent)
29
+ if hooks_root_directory not in sys.path:
30
+ sys.path.insert(0, hooks_root_directory)
31
+
32
+ from hooks_constants.hook_block_logger import log_hook_block
33
+ from hooks_constants.owned_pr_merge_stop_gate_constants import (
34
+ ALLOW_EXIT_CODE,
35
+ BLOCK_DECISION,
36
+ BLOCK_REASON_PREFIX,
37
+ BLOCK_REASON_SUFFIX,
38
+ BLOCKED_LINE_PATTERN,
39
+ BLOCKED_TEXT_GROUP_INDEX,
40
+ BOLD_MARKER,
41
+ DECISION_KEY,
42
+ GH_API_SUBCOMMAND,
43
+ GH_JQ_OPTION,
44
+ GH_PROGRAM_NAME,
45
+ HOOK_EVENT_NAME,
46
+ LAST_ASSISTANT_MESSAGE_KEY,
47
+ LOOKUP_FAILURE_TEMPLATE,
48
+ NAME_END_PATTERN,
49
+ NAME_START_PATTERN,
50
+ OPEN_PULL_REQUEST_STATE,
51
+ PULL_REQUEST_ENDPOINT_TEMPLATE,
52
+ PULL_REQUEST_LINE_TEMPLATE,
53
+ PULL_REQUEST_REFERENCE_TEMPLATE,
54
+ PULL_REQUEST_STATE_QUERY,
55
+ REASON_KEY,
56
+ SESSION_ID_KEY,
57
+ STATE_QUERY_TIMEOUT_SECONDS,
58
+ )
59
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
60
+ from owned_pr_session_record import all_recorded_pull_requests
61
+
62
+
63
+ def is_pull_request_open(all_pull_request_fields: dict[str, str]) -> bool:
64
+ """Return True when GitHub reports the pull request as open.
65
+
66
+ Args:
67
+ all_pull_request_fields: The owner, repository, number, and url fields.
68
+ """
69
+ endpoint = PULL_REQUEST_ENDPOINT_TEMPLATE.format(**all_pull_request_fields)
70
+ try:
71
+ completed = subprocess.run(
72
+ [GH_PROGRAM_NAME, GH_API_SUBCOMMAND, endpoint, GH_JQ_OPTION, PULL_REQUEST_STATE_QUERY],
73
+ capture_output=True,
74
+ text=True,
75
+ timeout=STATE_QUERY_TIMEOUT_SECONDS,
76
+ check=False,
77
+ )
78
+ except (OSError, subprocess.TimeoutExpired) as lookup_error:
79
+ sys.stderr.write(LOOKUP_FAILURE_TEMPLATE.format(endpoint=endpoint, detail=lookup_error))
80
+ return False
81
+ if completed.returncode != 0:
82
+ sys.stderr.write(LOOKUP_FAILURE_TEMPLATE.format(endpoint=endpoint, detail=completed.stderr.strip()))
83
+ return completed.returncode == 0 and completed.stdout.strip() == OPEN_PULL_REQUEST_STATE
84
+
85
+
86
+ def is_reported_blocked(all_pull_request_fields: dict[str, str], reply_text: str) -> bool:
87
+ """Return True when a Blocked: line of the reply names the pull request.
88
+
89
+ The line names it by ``owner/repository#number`` or by its url. Bold
90
+ markers such as ``**Blocked:**`` are ignored.
91
+
92
+ Args:
93
+ all_pull_request_fields: The owner, repository, number, and url fields.
94
+ reply_text: The closing reply of the turn.
95
+ """
96
+ all_names = (
97
+ PULL_REQUEST_REFERENCE_TEMPLATE.format(**all_pull_request_fields).lower(),
98
+ all_pull_request_fields["url"].lower(),
99
+ )
100
+ all_blocked_texts = (
101
+ each_match.group(BLOCKED_TEXT_GROUP_INDEX).lower()
102
+ for each_match in map(BLOCKED_LINE_PATTERN.match, reply_text.replace(BOLD_MARKER, "").splitlines())
103
+ if each_match
104
+ )
105
+ all_name_patterns = [NAME_START_PATTERN + re.escape(each_name) + NAME_END_PATTERN for each_name in all_names]
106
+ return any(
107
+ re.search(each_pattern, each_text)
108
+ for each_text in all_blocked_texts
109
+ for each_pattern in all_name_patterns
110
+ )
111
+
112
+
113
+ def unfinished_pull_requests(all_hook_input_fields: dict[str, object]) -> list[dict[str, str]]:
114
+ """Return the recorded pull requests that are open and not reported blocked.
115
+
116
+ Args:
117
+ all_hook_input_fields: The Stop hook payload.
118
+ """
119
+ reply_value = all_hook_input_fields.get(LAST_ASSISTANT_MESSAGE_KEY)
120
+ reply_text = reply_value if isinstance(reply_value, str) else ""
121
+ return [
122
+ each_pull_request
123
+ for each_pull_request in all_recorded_pull_requests(
124
+ all_hook_input_fields.get(SESSION_ID_KEY)
125
+ )
126
+ if not is_reported_blocked(each_pull_request, reply_text)
127
+ and is_pull_request_open(each_pull_request)
128
+ ]
129
+
130
+
131
+ def block_reason_for(all_open_pull_requests: list[dict[str, str]]) -> str:
132
+ """Return the block reason that lists each open pull request.
133
+
134
+ Args:
135
+ all_open_pull_requests: The pull requests that hold the turn.
136
+ """
137
+ all_lines = (PULL_REQUEST_LINE_TEMPLATE.format(**each) for each in all_open_pull_requests)
138
+ return BLOCK_REASON_PREFIX + "".join(all_lines) + BLOCK_REASON_SUFFIX
139
+
140
+
141
+ def main() -> int:
142
+ hook_input = read_hook_input_dictionary_from_stdin()
143
+ if hook_input is None:
144
+ return ALLOW_EXIT_CODE
145
+ all_open_pull_requests = unfinished_pull_requests(hook_input)
146
+ if not all_open_pull_requests:
147
+ return ALLOW_EXIT_CODE
148
+ block_reason = block_reason_for(all_open_pull_requests)
149
+ log_hook_block(Path(__file__).name, HOOK_EVENT_NAME, block_reason)
150
+ sys.stdout.write(json.dumps({DECISION_KEY: BLOCK_DECISION, REASON_KEY: block_reason}))
151
+ return ALLOW_EXIT_CODE
152
+
153
+
154
+ if __name__ == "__main__":
155
+ sys.exit(main())
@@ -178,6 +178,12 @@ def test_should_name_the_configured_review_command(monkeypatch: pytest.MonkeyPat
178
178
  assert "`/team-review`" in _reason(_merge_tool(CLEAN_HEAD))
179
179
 
180
180
 
181
+ def test_should_name_the_low_fix_pass_after_three_full_reviews_in_the_deny_reason() -> None:
182
+ reason = _reason(_merge_tool(CLEAN_HEAD))
183
+ assert "three full passes" in reason
184
+ assert "`/code-review low --fix 12`" in reason
185
+
186
+
181
187
  def test_should_allow_auto_merge_pinned_to_the_clean_head() -> None:
182
188
  merge_review_receipt.record_review("acme/tools", 12, CLEAN_HEAD, 0)
183
189
  command = f"gh pr merge 12 --repo acme/tools --auto --match-head-commit {CLEAN_HEAD}"
@@ -0,0 +1,163 @@
1
+ """Behavior tests for the owned pull request merge Stop gate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import json
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+ import owned_pr_merge_stop_gate
13
+ import owned_pr_session_record
14
+
15
+ SESSION_ID = "session-abc"
16
+ PULL_REQUEST = {
17
+ "owner": "jl-cmd",
18
+ "repository": "snake-game",
19
+ "number": "9",
20
+ "url": "https://github.com/jl-cmd/snake-game/pull/9",
21
+ }
22
+
23
+
24
+ @pytest.fixture(autouse=True)
25
+ def isolated_temp_folder(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
26
+ monkeypatch.setattr(owned_pr_session_record.tempfile, "gettempdir", lambda: str(tmp_path))
27
+ monkeypatch.setenv("HOME", str(tmp_path))
28
+
29
+
30
+ def _gh_reports(
31
+ monkeypatch: pytest.MonkeyPatch, state_text: str, exit_code: int = 0
32
+ ) -> list[list[str]]:
33
+ all_calls: list[list[str]] = []
34
+
35
+ def fake_run(all_arguments: list[str], **_: object) -> subprocess.CompletedProcess[str]:
36
+ all_calls.append(all_arguments)
37
+ return subprocess.CompletedProcess(all_arguments, exit_code, stdout=state_text, stderr="")
38
+
39
+ monkeypatch.setattr(owned_pr_merge_stop_gate.subprocess, "run", fake_run)
40
+ return all_calls
41
+
42
+
43
+ def _run_gate(
44
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], reply_text: str
45
+ ) -> str:
46
+ hook_input = {
47
+ "hook_event_name": "Stop",
48
+ "session_id": SESSION_ID,
49
+ "last_assistant_message": reply_text,
50
+ }
51
+ monkeypatch.setattr(
52
+ "sys.stdin", io.TextIOWrapper(io.BytesIO(json.dumps(hook_input).encode("utf-8")))
53
+ )
54
+ assert owned_pr_merge_stop_gate.main() == 0
55
+ return capsys.readouterr().out
56
+
57
+
58
+ def test_main_should_block_while_a_recorded_pull_request_is_open(
59
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
60
+ ) -> None:
61
+ owned_pr_session_record.record_pull_request(SESSION_ID, PULL_REQUEST)
62
+ all_calls = _gh_reports(monkeypatch, "open\n")
63
+ decision = json.loads(_run_gate(monkeypatch, capsys, "Draft PR is open."))
64
+ assert decision["decision"] == "block"
65
+ assert PULL_REQUEST["url"] in decision["reason"]
66
+ assert "/efficient-review" in decision["reason"]
67
+ assert all_calls == [["gh", "api", "repos/jl-cmd/snake-game/pulls/9", "--jq", ".state"]]
68
+
69
+
70
+ def test_main_should_allow_once_the_pull_request_is_closed(
71
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
72
+ ) -> None:
73
+ owned_pr_session_record.record_pull_request(SESSION_ID, PULL_REQUEST)
74
+ _gh_reports(monkeypatch, "closed\n")
75
+ assert _run_gate(monkeypatch, capsys, "Merged.") == ""
76
+
77
+
78
+ def test_main_should_allow_a_blocked_line_that_names_the_pull_request(
79
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
80
+ ) -> None:
81
+ owned_pr_session_record.record_pull_request(SESSION_ID, PULL_REQUEST)
82
+ _gh_reports(monkeypatch, "open\n")
83
+ reply_text = "Review is clean.\nBlocked: jl-cmd/snake-game#9 - the owner must approve the merge"
84
+ assert _run_gate(monkeypatch, capsys, reply_text) == ""
85
+
86
+
87
+ def test_main_should_block_when_the_blocked_line_names_another_pull_request(
88
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
89
+ ) -> None:
90
+ owned_pr_session_record.record_pull_request(SESSION_ID, PULL_REQUEST)
91
+ _gh_reports(monkeypatch, "open\n")
92
+ reply_text = "Blocked: jl-cmd/snake-game#90 - the owner must approve the merge"
93
+ assert json.loads(_run_gate(monkeypatch, capsys, reply_text))["decision"] == "block"
94
+
95
+
96
+ def test_main_should_allow_when_the_state_lookup_fails(
97
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
98
+ ) -> None:
99
+ owned_pr_session_record.record_pull_request(SESSION_ID, PULL_REQUEST)
100
+ _gh_reports(monkeypatch, "", exit_code=1)
101
+ assert _run_gate(monkeypatch, capsys, "Draft PR is open.") == ""
102
+
103
+
104
+ def test_main_should_allow_a_session_that_opened_no_owned_pull_request(
105
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
106
+ ) -> None:
107
+ all_calls = _gh_reports(monkeypatch, "open\n")
108
+ assert _run_gate(monkeypatch, capsys, "Done.") == ""
109
+ assert all_calls == []
110
+
111
+
112
+ def test_is_pull_request_open_should_read_the_github_state(monkeypatch: pytest.MonkeyPatch) -> None:
113
+ _gh_reports(monkeypatch, "open\n")
114
+ assert owned_pr_merge_stop_gate.is_pull_request_open(PULL_REQUEST)
115
+
116
+
117
+ def test_is_reported_blocked_should_accept_the_url() -> None:
118
+ reply_text = f"- Blocked: {PULL_REQUEST['url']} - needs the owner"
119
+ assert owned_pr_merge_stop_gate.is_reported_blocked(PULL_REQUEST, reply_text)
120
+
121
+
122
+ def test_unfinished_pull_requests_should_list_the_open_one(monkeypatch: pytest.MonkeyPatch) -> None:
123
+ owned_pr_session_record.record_pull_request(SESSION_ID, PULL_REQUEST)
124
+ _gh_reports(monkeypatch, "open\n")
125
+ hook_input: dict[str, object] = {"session_id": SESSION_ID, "last_assistant_message": "Open."}
126
+ assert owned_pr_merge_stop_gate.unfinished_pull_requests(hook_input) == [PULL_REQUEST]
127
+
128
+
129
+ def test_block_reason_for_should_name_each_pull_request_and_the_blocked_form() -> None:
130
+ reason = owned_pr_merge_stop_gate.block_reason_for([PULL_REQUEST])
131
+ assert PULL_REQUEST["url"] in reason
132
+ assert "Blocked: <owner>/<repository>#<number>" in reason
133
+
134
+
135
+ def test_main_should_report_a_failed_lookup_on_stderr(
136
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
137
+ ) -> None:
138
+ owned_pr_session_record.record_pull_request(SESSION_ID, PULL_REQUEST)
139
+ _gh_reports(monkeypatch, "", exit_code=1)
140
+ hook_input = {"hook_event_name": "Stop", "session_id": SESSION_ID, "last_assistant_message": "Open."}
141
+ monkeypatch.setattr("sys.stdin", io.TextIOWrapper(io.BytesIO(json.dumps(hook_input).encode("utf-8"))))
142
+ owned_pr_merge_stop_gate.main()
143
+ assert "could not read repos/jl-cmd/snake-game/pulls/9" in capsys.readouterr().err
144
+
145
+
146
+ def test_is_reported_blocked_should_accept_a_bold_blocked_label() -> None:
147
+ reply_text = "**Blocked:** jl-cmd/snake-game#9 - the owner must approve the merge"
148
+ assert owned_pr_merge_stop_gate.is_reported_blocked(PULL_REQUEST, reply_text)
149
+
150
+
151
+ def test_is_reported_blocked_should_skip_a_longer_owner_that_ends_with_the_name() -> None:
152
+ suffix_pull_request = {**PULL_REQUEST, "owner": "cmd", "url": "https://github.com/cmd/snake-game/pull/9"}
153
+ reply_text = "Blocked: jl-cmd/snake-game#9 - the owner must approve the merge"
154
+ assert not owned_pr_merge_stop_gate.is_reported_blocked(suffix_pull_request, reply_text)
155
+
156
+
157
+ def test_block_reason_for_should_name_a_user_hold_as_a_blocker() -> None:
158
+ assert "asked to hold the PR or to read it first" in owned_pr_merge_stop_gate.block_reason_for([PULL_REQUEST])
159
+
160
+
161
+ def test_is_reported_blocked_should_require_the_colon_after_blocked() -> None:
162
+ reply_text = "Blocked while CI runs on jl-cmd/snake-game#9."
163
+ assert not owned_pr_merge_stop_gate.is_reported_blocked(PULL_REQUEST, reply_text)
@@ -11,7 +11,8 @@ This family rewrites a Git Bash command when path conversion would change a revi
11
11
  - `blocking/cloud_graphql_gate.py` denies the `gh` commands that call GraphQL (`gh api graphql` with any flags before the endpoint, `gh pr`, `gh issue`, `gh project` and `gh discussion` except `gh pr diff` and `gh pr list --web`, `gh status`, `gh org list`, `gh release list`, `gh release view` with a tag, `gh workflow run` without `--ref`, and a few `repo`, `label`, `search`, `ruleset` and `gist` commands, read past `if`, `!` and command substitutions) and an HTTP client or `python -c` style call to `api.github.com/graphql`, with or without a port, inside a cloud session, where the proxy answers GraphQL with HTTP 403, and names the REST routes to run instead.
12
12
  - `blocking/bash_post_call_dispatcher.py` runs hosted observers and joins their context output without blocking the call.
13
13
  - `advisory/pr_done_reminder.py` adds a pull request checklist after a successful push or pull request creation.
14
- - `advisory/owned_pr_review_loop.py` adds review-loop instructions after the agent opens a pull request on an owned repository outside the preset list of repositories that run their own automated review: run efficient-review until clean, mark the pull request ready, babysit it, and ship it to merge.
14
+ - `advisory/owned_pr_review_loop.py` adds review-loop instructions after the agent opens a pull request on an owned repository outside the preset list of repositories that run their own automated review: run efficient-review until clean, mark the pull request ready, babysit it, and ship it to merge. It also records the pull request for the session in `owned_pr_session_record.py`.
15
+ - `blocking/owned_pr_merge_stop_gate.py` holds the end of a turn while a recorded pull request reads open on GitHub and the closing reply has no `Blocked:` line that names it. A failed state lookup passes.
15
16
 
16
17
  ## When it fires
17
18
 
@@ -23,6 +24,7 @@ This family rewrites a Git Bash command when path conversion would change a revi
23
24
  - `blocking/bash_post_call_dispatcher.py` runs on `PostToolUse`, matcher `Bash|PowerShell`, timeout `60` seconds in `hooks.json`.
24
25
  - `advisory/pr_done_reminder.py` runs inside that dispatcher on `PostToolUse`, matcher `Bash|PowerShell`, timeout `60` seconds. `ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES` selects `Bash` and `PowerShell`.
25
26
  - `advisory/owned_pr_review_loop.py` runs on `PostToolUse`, matcher `Bash|PowerShell|exec_command|shell_command|mcp__.*__(create_pull_request|github_create_pull_request)`, timeout `10` seconds in `hooks.json`. It wakes on `gh pr create`, `gh pr new`, a POST to `gh api repos/<owner>/<repo>/pulls`, and an MCP pull request creation. The installer also registers it on Codex `PostToolUse` through `ALL_CODEX_NATIVE_POST_TOOL_USE_HOOK_SCRIPT_NAMES`.
27
+ - `blocking/owned_pr_merge_stop_gate.py` runs on `Stop`, timeout `60` seconds in `hooks.json`. The installer also registers it on Codex `Stop` through `ALL_CODEX_NATIVE_STOP_HOOK_SCRIPT_NAMES`.
26
28
 
27
29
  ## Proving it
28
30
 
@@ -37,6 +39,7 @@ Preconditions:
37
39
  - **Post-call dispatch.** Input is a Bash call that yields hosted context. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_bash_post_call_dispatcher.py -q`. The adjacent test observes a joined context payload and exit code zero.
38
40
  - **Pull request reminder.** Input is a completed push or pull request creation. Run `python -m pytest packages/claude-dev-env/hooks/advisory/test_pr_done_reminder.py -q`. The adjacent test checks the reminder trigger and checklist verdict.
39
41
  - **Owned pull request review loop.** Input is a pull request creation result on an owned repository. Run `python -m pytest packages/claude-dev-env/hooks/advisory/test_owned_pr_review_loop.py -q`. The adjacent test observes the review-loop block for owned repositories and silence for the preset and foreign repositories.
42
+ - **Owned pull request merge gate.** Input is a `session_record_path` file that lists `jl-cmd/snake-game#9` and a `gh` stub that reports `open`. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_owned_pr_merge_stop_gate.py packages/claude-dev-env/hooks/test_owned_pr_session_record.py -q`. The adjacent test observes a `block` decision that names the pull request; `closed` and a `Blocked: jl-cmd/snake-game#9 - ...` line pass.
40
43
 
41
44
  ## Gotchas
42
45
 
@@ -17,7 +17,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
17
17
  - `blocking/verify_before_acting.py` blocks a completed mutating call when the agent's reasoning contains an unchecked claim.
18
18
  - `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.
19
19
  - `blocking/build_eval_before_eval_write_gate.py` denies a write to an eval file until a Skill call named exactly `claude-api` with args `build-eval`, or the `/claude-api build-eval` command, appears in the transcript.
20
- - `blocking/merge_review_gate.py` denies an agent's merge until a review pass on the merged head reported zero findings, or ruled out every finding and linked the posted rulings. It reads the receipt `scripts/merge_review_receipt.py` writes, requires the merge, auto-merge or merge-queue entry to be pinned to that head, and skips repositories listed in `CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES`. Its deny reason names the review command `CLAUDE_MERGE_REVIEW_COMMAND` sets.
20
+ - `blocking/merge_review_gate.py` denies an agent's merge until a review pass on the merged head reported zero findings, or ruled out every finding and linked the posted rulings. It reads the receipt `scripts/merge_review_receipt.py` writes, requires the merge, auto-merge or merge-queue entry to be pinned to that head, and skips repositories listed in `CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES`. Its deny reason names the review command `CLAUDE_MERGE_REVIEW_COMMAND` sets. The reason tells the agent to run that command for the first three passes and `/code-review low --fix <number>` for each later pass; the gate itself counts no passes.
21
21
  - `blocking/merge_over_review_offer_gate.py` denies a decision card or question that offers a merge while it names open review findings, a red check, unaddressed review comments, or an override. It reads the whole card as one text: every question, header, context, reason, and option, whether the option is a string or a label with a consequence or description.
22
22
  - `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.
23
23
 
package/hooks/hooks.json CHANGED
@@ -385,6 +385,11 @@
385
385
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/open_tasks_stop_gate.py",
386
386
  "timeout": 10
387
387
  },
388
+ {
389
+ "type": "command",
390
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/owned_pr_merge_stop_gate.py",
391
+ "timeout": 60
392
+ },
388
393
  {
389
394
  "type": "command",
390
395
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/session_title_turn_gate.py",
@@ -54,8 +54,10 @@ RECORD_COMMAND = "python3 ~/.claude/scripts/merge_review_receipt.py"
54
54
  NO_CLEAN_REVIEW_REASON_TEMPLATE = (
55
55
  "{slug}#{number} has no clean review on the head you are merging. Run the review "
56
56
  "loop: review the pull request head with `{review_command}`, fix and push every "
57
- "finding, and review again "
58
- "until a pass reports zero findings or every finding is ruled out with file:line "
57
+ "finding, and review again. After three full passes, each later pass is "
58
+ "`/code-review low --fix {number}`, which names the pull request so the diff "
59
+ "covers its base: commit and push its fixes, then review the new head. "
60
+ "Repeat until a pass reports zero findings or every finding is ruled out with file:line "
59
61
  "evidence. Record that pass with `"
60
62
  + RECORD_COMMAND
61
63
  + " {slug} {number} <head sha> <finding count>`, adding `--ruled-out <finding count> "
@@ -0,0 +1,37 @@
1
+ """Constants for the owned pull request merge Stop gate."""
2
+
3
+ import re
4
+
5
+ HOOK_EVENT_NAME = "Stop"
6
+ SESSION_ID_KEY = "session_id"
7
+ LAST_ASSISTANT_MESSAGE_KEY = "last_assistant_message"
8
+ DECISION_KEY = "decision"
9
+ BLOCK_DECISION = "block"
10
+ REASON_KEY = "reason"
11
+ ALLOW_EXIT_CODE = 0
12
+ GH_PROGRAM_NAME = "gh"
13
+ GH_API_SUBCOMMAND = "api"
14
+ GH_JQ_OPTION = "--jq"
15
+ PULL_REQUEST_ENDPOINT_TEMPLATE = "repos/{owner}/{repository}/pulls/{number}"
16
+ PULL_REQUEST_STATE_QUERY = ".state"
17
+ OPEN_PULL_REQUEST_STATE = "open"
18
+ STATE_QUERY_TIMEOUT_SECONDS = 10
19
+ LOOKUP_FAILURE_TEMPLATE = "owned_pr_merge_stop_gate: could not read {endpoint}: {detail}\n"
20
+ BLOCKED_LINE_PATTERN: re.Pattern[str] = re.compile(
21
+ r"^\s*(?:[-*]\s*)?blocked\s*:(.*)$", re.IGNORECASE
22
+ )
23
+ BLOCKED_TEXT_GROUP_INDEX = 1
24
+ BOLD_MARKER = "**"
25
+ NAME_START_PATTERN = r"(?<![0-9A-Za-z_./-])"
26
+ NAME_END_PATTERN = r"(?![0-9A-Za-z/])"
27
+ PULL_REQUEST_REFERENCE_TEMPLATE = "{owner}/{repository}#{number}"
28
+ PULL_REQUEST_LINE_TEMPLATE = "\n- {url}"
29
+ BLOCK_REASON_PREFIX = "You opened these owned pull requests and they are still open:"
30
+ BLOCK_REASON_SUFFIX = (
31
+ "\nThe repository owner approves the whole owned PR loop, the merge included."
32
+ " Keep working now: run /efficient-review until a pass is clean, mark the PR ready,"
33
+ " babysit CI and review threads, then merge it and read it back as merged."
34
+ " End the turn early only with one line per pull request:"
35
+ " 'Blocked: <owner>/<repository>#<number> - <what only the owner can give>'."
36
+ " A user who asked to hold the PR or to read it first is such a blocker."
37
+ )
@@ -48,6 +48,9 @@ __all__ = [
48
48
  "ALL_DECLARED_URL_KEYS",
49
49
  "JSON_OBJECT_OPENER",
50
50
  "REVIEW_LOOP_CONTEXT_TEMPLATE",
51
+ "SESSION_RECORD_FILE_TEMPLATE",
52
+ "SESSION_RECORD_TEMPORARY_PREFIX",
53
+ "ALL_PULL_REQUEST_FIELD_NAMES",
51
54
  ]
52
55
 
53
56
  NAME_DIGEST_TEXT_ENCODING: str = "utf-8"
@@ -99,13 +102,21 @@ ALREADY_EXISTS_MARKER: str = "already exists"
99
102
  ALL_DECLARED_URL_KEYS: tuple[str, ...] = ("html_url", "url")
100
103
  JSON_OBJECT_OPENER: str = "{"
101
104
 
105
+ SESSION_RECORD_FILE_TEMPLATE: str = "owned-pr-loop-{session_file_id}.json"
106
+ SESSION_RECORD_TEMPORARY_PREFIX: str = "owned-pr-loop-"
107
+ ALL_PULL_REQUEST_FIELD_NAMES: tuple[str, ...] = ("owner", "repository", "number", "url")
108
+
102
109
  REVIEW_LOOP_CONTEXT_TEMPLATE: str = (
103
110
  "=== OWNED PR REVIEW LOOP (context reminder, never a block) ===\n"
104
111
  "You opened {owner}/{repository}#{number} {url}\n"
105
112
  "This repository has no automated review, so you own the review and the merge.\n"
113
+ "Your task now runs past opening this PR to its merge, also when your brief\n"
114
+ "asked for a draft or stopped at opening it.\n"
106
115
  "1. Review loop. Load the efficient-review skill (/efficient-review). Review the\n"
107
116
  " branch, check each finding against the code, fix every one that holds, push,\n"
108
- " and review again. Repeat until a pass reports no findings.\n"
117
+ " and review again. After three full passes, each later pass is\n"
118
+ " /code-review low --fix {number}; commit and push its fixes, then review the new head.\n"
119
+ " Repeat until a pass reports no findings.\n"
109
120
  "2. Mark ready. Once the loop is clean, CI is green, and no review thread is open,\n"
110
121
  " mark the PR ready for review (gh pr ready {number} --repo {owner}/{repository},\n"
111
122
  " or update_pull_request with draft false).\n"
@@ -116,5 +127,9 @@ REVIEW_LOOP_CONTEXT_TEMPLATE: str = (
116
127
  "4. Ship. Follow the pstack shipping playbook (poteto-mode/playbooks/shipping.md)\n"
117
128
  " to land the PR, or the pr-lifecycle skill outside poteto-mode. Read the PR\n"
118
129
  " back and confirm it reads merged.\n"
119
- "Run each step yourself. The task ends at a merged PR or a named blocker."
130
+ "The repository owner installed this hook and\n"
131
+ "approves every step above, the merge included. Run each step yourself\n"
132
+ "without asking first. The task ends at a merged PR or a named blocker.\n"
133
+ "When the user asked to hold this PR or to read it before it merges, stop\n"
134
+ "after step 3 and end with: Blocked: {owner}/{repository}#{number} - the user holds the merge."
120
135
  )
@@ -0,0 +1,104 @@
1
+ """Session record of the owned pull requests an agent opened.
2
+
3
+ The owned pull request review-loop hook writes each pull request it reports
4
+ into one JSON file per session in the system temp folder. The owned pull
5
+ request Stop gate reads the same file back::
6
+
7
+ owned-pr-loop-<session id>.json
8
+ [{"owner": "acme", "repository": "widgets", "number": "42",
9
+ "url": "https://github.com/acme/widgets/pull/42"}]
10
+
11
+ Both Claude Code and Codex send ``session_id`` to PostToolUse and Stop hooks,
12
+ so the file links the two hooks on either host.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import tempfile
19
+ from pathlib import Path
20
+
21
+ from atomic_file_writer import write_text_atomically
22
+ from hooks_constants.atomic_file_writer_constants import ATOMIC_WRITE_TEMPORARY_SUFFIX
23
+ from hooks_constants.owned_pr_review_loop_constants import (
24
+ ALL_PULL_REQUEST_FIELD_NAMES,
25
+ NAME_DIGEST_TEXT_ENCODING,
26
+ SESSION_RECORD_FILE_TEMPLATE,
27
+ SESSION_RECORD_TEMPORARY_PREFIX,
28
+ )
29
+ from hooks_constants.session_file_id import session_file_id
30
+
31
+ __all__ = ["session_record_path", "all_recorded_pull_requests", "record_pull_request"]
32
+
33
+
34
+ def session_record_path(session_id: object) -> Path:
35
+ """Return the record file path for one session.
36
+
37
+ Args:
38
+ session_id: The ``session_id`` field of the hook payload.
39
+ """
40
+ file_name = SESSION_RECORD_FILE_TEMPLATE.format(session_file_id=session_file_id(session_id))
41
+ return Path(tempfile.gettempdir()) / file_name
42
+
43
+
44
+ def _is_session_id(session_id: object) -> bool:
45
+ return isinstance(session_id, str) and bool(session_id)
46
+
47
+
48
+ def _is_pull_request_entry(entry: object) -> bool:
49
+ return isinstance(entry, dict) and all(
50
+ isinstance(entry.get(each_field), str) for each_field in ALL_PULL_REQUEST_FIELD_NAMES
51
+ )
52
+
53
+
54
+ def all_recorded_pull_requests(session_id: object) -> list[dict[str, str]]:
55
+ """Return the pull requests recorded for the session, oldest first.
56
+
57
+ A payload with no session id, or a missing, unreadable, or malformed
58
+ file, reads as an empty record.
59
+
60
+ Args:
61
+ session_id: The ``session_id`` field of the hook payload.
62
+ """
63
+ if not _is_session_id(session_id):
64
+ return []
65
+ try:
66
+ record_text = session_record_path(session_id).read_text(encoding=NAME_DIGEST_TEXT_ENCODING)
67
+ all_entries = json.loads(record_text)
68
+ except (OSError, ValueError):
69
+ return []
70
+ if not isinstance(all_entries, list):
71
+ return []
72
+ return [each_entry for each_entry in all_entries if _is_pull_request_entry(each_entry)]
73
+
74
+
75
+ def record_pull_request(session_id: object, all_pull_request_fields: dict[str, str]) -> None:
76
+ """Add one pull request to the session record, once per url.
77
+
78
+ A payload with no session id records nothing. A write that fails leaves
79
+ the record as it was, so the PostToolUse hook still adds its context.
80
+
81
+ Args:
82
+ session_id: The ``session_id`` field of the hook payload.
83
+ all_pull_request_fields: The owner, repository, number, and url fields.
84
+ """
85
+ if not _is_session_id(session_id):
86
+ return
87
+ all_entries = all_recorded_pull_requests(session_id)
88
+ if any(each_entry["url"] == all_pull_request_fields["url"] for each_entry in all_entries):
89
+ return
90
+ new_entry = {
91
+ each_field: all_pull_request_fields[each_field]
92
+ for each_field in ALL_PULL_REQUEST_FIELD_NAMES
93
+ }
94
+ try:
95
+ write_text_atomically(
96
+ session_record_path(session_id),
97
+ json.dumps([*all_entries, new_entry]),
98
+ NAME_DIGEST_TEXT_ENCODING,
99
+ SESSION_RECORD_TEMPORARY_PREFIX,
100
+ ATOMIC_WRITE_TEMPORARY_SUFFIX,
101
+ False,
102
+ )
103
+ except (OSError, UnicodeError):
104
+ return
@@ -0,0 +1,46 @@
1
+ """Behavior tests for the owned pull request session record."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ import owned_pr_session_record
10
+
11
+ SESSION_ID = "session-abc"
12
+ PULL_REQUEST = {
13
+ "owner": "jl-cmd",
14
+ "repository": "snake-game",
15
+ "number": "9",
16
+ "url": "https://github.com/jl-cmd/snake-game/pull/9",
17
+ }
18
+
19
+
20
+ @pytest.fixture(autouse=True)
21
+ def isolated_temp_folder(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
22
+ monkeypatch.setattr(owned_pr_session_record.tempfile, "gettempdir", lambda: str(tmp_path))
23
+
24
+
25
+ def test_session_record_path_should_name_the_session_in_the_temp_folder(tmp_path: Path) -> None:
26
+ assert (
27
+ owned_pr_session_record.session_record_path("a/b c") == tmp_path / "owned-pr-loop-abc.json"
28
+ )
29
+
30
+
31
+ def test_record_pull_request_should_keep_one_entry_per_url() -> None:
32
+ owned_pr_session_record.record_pull_request(SESSION_ID, PULL_REQUEST)
33
+ owned_pr_session_record.record_pull_request(SESSION_ID, PULL_REQUEST)
34
+ assert owned_pr_session_record.all_recorded_pull_requests(SESSION_ID) == [PULL_REQUEST]
35
+
36
+
37
+ def test_record_pull_request_should_skip_a_payload_without_a_session_id(tmp_path: Path) -> None:
38
+ owned_pr_session_record.record_pull_request(None, PULL_REQUEST)
39
+ assert list(tmp_path.iterdir()) == []
40
+
41
+
42
+ def test_all_recorded_pull_requests_should_read_a_malformed_file_as_empty() -> None:
43
+ owned_pr_session_record.session_record_path(SESSION_ID).write_text(
44
+ "{not json", encoding="utf-8"
45
+ )
46
+ assert owned_pr_session_record.all_recorded_pull_requests(SESSION_ID) == []
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.91.0",
3
+ "version": "8.92.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -140,6 +140,7 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
140
140
  hooks/blocking/merge_over_review_offer_gate.py -> exempt
141
141
  hooks/blocking/final_reply_gate.py -> exempt
142
142
  hooks/blocking/open_tasks_stop_gate.py -> exempt
143
+ hooks/blocking/owned_pr_merge_stop_gate.py -> exempt
143
144
  hooks/blocking/session_title_turn_gate.py -> exempt
144
145
  hooks/blocking/some_new_blocker.py -> flagged
145
146
 
@@ -186,6 +187,11 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
186
187
  ``Blocked:`` or ``Open:``. It decides when a turn may end and no code or
187
188
  safety policy.
188
189
 
190
+ The owned pull request Stop gate holds the end of a turn while a pull
191
+ request the session opened on an owned repository is still open and the
192
+ closing reply names no ``Blocked:`` line for it. It decides when a turn may
193
+ end and no code or safety policy.
194
+
189
195
  Args:
190
196
  registered_string: One command, path, script, or entrypoint string.
191
197
  """
@@ -79,6 +79,7 @@ ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS = (
79
79
  "blocking/merge_over_review_offer_gate.py",
80
80
  "blocking/final_reply_gate.py",
81
81
  "blocking/open_tasks_stop_gate.py",
82
+ "blocking/owned_pr_merge_stop_gate.py",
82
83
  "blocking/session_title_turn_gate.py",
83
84
  )
84
85
  ALL_TEST_DIRECTORY_NAMES = frozenset({"tests"})