claude-dev-env 8.102.0 → 8.103.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.
@@ -0,0 +1,362 @@
1
+ #!/usr/bin/env python3
2
+ """Stop hook that has a fresh reviewer read each turn's changes before the turn ends.
3
+
4
+ The agent edits ``src/service.py`` and drops a retry. When the turn stops, a
5
+ separate ``claude -p`` run reviews only that turn's edits with
6
+ ``/code-review low`` and the hook holds the stop with what it found::
7
+
8
+ prompt arrives -> snapshot A of the working tree
9
+ agent edits -> src/service.py loses @with_retry
10
+ stop -> snapshot B (parent A), claude -p "/code-review low A..B"
11
+ -> block: "src/service.py:208 - The diff removes ..."
12
+ agent fixes it -> stop reviews B..C only, finds nothing, the turn ends
13
+
14
+ ``turn_review_snapshot`` builds the snapshots, and ``turn_review_findings``
15
+ reads the findings out of the review text. Edits the user left before the
16
+ prompt sit in snapshot A, so the review sees only the agent's work. Each stop
17
+ moves the baseline to the snapshot it took, so the stop after a block reviews
18
+ only the fix.
19
+
20
+ A repository takes part when it is the session's project folder or a folder
21
+ directly inside it, and its GitHub origin ``owner/repo`` slug is listed in
22
+ ``CLAUDE_TURN_REVIEW_REPOSITORIES``. The hook holds a stop at most three times
23
+ in a row, skips a turn that changed more than 30 files, and lets the stop
24
+ through when the review fails or times out; a new prompt resets the count.
25
+ These are the limits of Anthropic's security-guidance plugin. The review run
26
+ carries ``CLAUDE_TURN_REVIEW_CHILD=1`` and none of the parent session's id,
27
+ messaging or ingress variables, and the hook does nothing inside such a run. The review may read
28
+ files and run ``git diff``, ``git show`` and ``git log``; every other tool is
29
+ denied.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ import os
36
+ import re
37
+ import shutil
38
+ import subprocess
39
+ import sys
40
+ import tempfile
41
+ import time
42
+ from pathlib import Path
43
+
44
+ hooks_root_directory = str(Path(__file__).resolve().parent.parent)
45
+ if hooks_root_directory not in sys.path:
46
+ sys.path.insert(0, hooks_root_directory)
47
+ blocking_directory = str(Path(__file__).resolve().parent)
48
+ if blocking_directory not in sys.path:
49
+ sys.path.insert(0, blocking_directory)
50
+
51
+ from atomic_file_writer import write_text_atomically
52
+ from hooks_constants.atomic_file_writer_constants import ATOMIC_WRITE_TEMPORARY_SUFFIX
53
+ from hooks_constants.hook_block_logger import log_hook_block
54
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
55
+ from hooks_constants.session_file_id import session_file_id
56
+ from hooks_constants.subprocess_window import hidden_window_creation_flags
57
+ from hooks_constants.turn_review_gate_constants import (
58
+ ALL_PARENT_SESSION_VARIABLES,
59
+ ALL_REVIEW_ALLOWED_TOOLS,
60
+ ALL_REVIEW_DISALLOWED_TOOLS,
61
+ BASELINE_COMMIT_PATTERN,
62
+ BLOCK_DECISION,
63
+ BLOCK_REASON_TEMPLATE,
64
+ DECISION_KEY,
65
+ FINDING_LINE_SEPARATOR,
66
+ GIT_DIRECTORY_NAME,
67
+ HOOK_EVENT_NAME_KEY,
68
+ MAXIMUM_BLOCKS_IN_A_ROW,
69
+ MAXIMUM_TURN_FILES,
70
+ PROJECT_DIRECTORY_ENVIRONMENT_VARIABLE,
71
+ PROMPT_EVENT_NAME,
72
+ REASON_KEY,
73
+ REPOSITORIES_ENVIRONMENT_VARIABLE,
74
+ REPOSITORY_FINDINGS_SEPARATOR,
75
+ REPOSITORY_FINDINGS_TEMPLATE,
76
+ REPOSITORY_SEPARATOR,
77
+ REVIEW_CHILD_ENVIRONMENT_VARIABLE,
78
+ REVIEW_CHILD_MARKER,
79
+ REVIEW_EFFORT,
80
+ REVIEW_FAILURE_DIAGNOSTIC_TEMPLATE,
81
+ REVIEW_MODEL_ALIAS,
82
+ REVIEW_PROMPT_TEMPLATE,
83
+ REVIEW_RESULT_KEY,
84
+ REVIEW_EXECUTABLE_NAME,
85
+ REVIEW_SETTING_SOURCES_TEXT,
86
+ REVIEW_SETTINGS_TEXT,
87
+ MINIMUM_REVIEW_SECONDS,
88
+ REVIEW_TIMEOUT_SECONDS,
89
+ REVIEW_TOOLS_TEXT,
90
+ SESSION_ID_KEY,
91
+ STATE_BASELINES_KEY,
92
+ STATE_BLOCKS_KEY,
93
+ STATE_FILE_TEMPLATE,
94
+ STATE_TEMPORARY_PREFIX,
95
+ STATE_TEXT_ENCODING,
96
+ STOP_EVENT_NAME,
97
+ TOO_MANY_FILES_DIAGNOSTIC_TEMPLATE,
98
+ )
99
+ from pii_prevention_blocker_parts.repository_exemption import repository_origin_slug
100
+ from turn_review_findings import finding_lines
101
+ from turn_review_snapshot import turn_changed_paths, working_tree_snapshot
102
+
103
+
104
+ def _opted_in_repository_slugs() -> frozenset[str]:
105
+ listed = os.environ.get(REPOSITORIES_ENVIRONMENT_VARIABLE, "")
106
+ return frozenset(
107
+ each.strip().casefold() for each in listed.split(REPOSITORY_SEPARATOR) if each.strip()
108
+ )
109
+
110
+
111
+ def _opted_in_repository_roots(all_slugs: frozenset[str]) -> list[Path]:
112
+ project_directory = os.environ.get(PROJECT_DIRECTORY_ENVIRONMENT_VARIABLE, "")
113
+ if not project_directory or not Path(project_directory).is_dir():
114
+ return []
115
+ project_path = Path(project_directory).resolve()
116
+ all_candidates = [project_path, *sorted(_child_entries(project_path))]
117
+ return [each for each in all_candidates if _is_opted_in_checkout(each, all_slugs)]
118
+
119
+
120
+ def _child_entries(project_path: Path) -> list[Path]:
121
+ try:
122
+ return list(project_path.iterdir())
123
+ except OSError:
124
+ return []
125
+
126
+
127
+ def _is_opted_in_checkout(folder: Path, all_slugs: frozenset[str]) -> bool:
128
+ try:
129
+ is_checkout = folder.is_dir() and (folder / GIT_DIRECTORY_NAME).exists()
130
+ except OSError:
131
+ return False
132
+ return is_checkout and repository_origin_slug(folder) in all_slugs
133
+
134
+
135
+ def _review_command(base_commit: str, turn_commit: str) -> list[str]:
136
+ review_prompt = REVIEW_PROMPT_TEMPLATE.format(
137
+ effort=REVIEW_EFFORT, base_commit=base_commit, turn_commit=turn_commit
138
+ )
139
+ return [
140
+ shutil.which(REVIEW_EXECUTABLE_NAME) or REVIEW_EXECUTABLE_NAME,
141
+ "-p",
142
+ review_prompt,
143
+ "--model",
144
+ REVIEW_MODEL_ALIAS,
145
+ "--output-format",
146
+ "json",
147
+ "--permission-mode",
148
+ "dontAsk",
149
+ "--setting-sources",
150
+ REVIEW_SETTING_SOURCES_TEXT,
151
+ "--tools",
152
+ REVIEW_TOOLS_TEXT,
153
+ "--no-session-persistence",
154
+ "--allowedTools",
155
+ *ALL_REVIEW_ALLOWED_TOOLS,
156
+ "--disallowedTools",
157
+ *ALL_REVIEW_DISALLOWED_TOOLS,
158
+ "--settings",
159
+ REVIEW_SETTINGS_TEXT,
160
+ ]
161
+
162
+
163
+ def _review_environment() -> dict[str, str]:
164
+ all_child_environment_variables = {
165
+ each_name: each_value
166
+ for each_name, each_value in os.environ.items()
167
+ if each_name not in ALL_PARENT_SESSION_VARIABLES
168
+ }
169
+ all_child_environment_variables[REVIEW_CHILD_ENVIRONMENT_VARIABLE] = REVIEW_CHILD_MARKER
170
+ return all_child_environment_variables
171
+
172
+
173
+ def _write_review_failure(repository_root: Path, failure_text: str) -> None:
174
+ sys.stderr.write(
175
+ REVIEW_FAILURE_DIAGNOSTIC_TEMPLATE.format(
176
+ repository=repository_root.name, reason=failure_text
177
+ )
178
+ )
179
+
180
+
181
+ def _review_stdout(
182
+ repository_root: Path, base_commit: str, turn_commit: str, remaining_seconds: float
183
+ ) -> str | None:
184
+ try:
185
+ completed_process = subprocess.run(
186
+ _review_command(base_commit, turn_commit),
187
+ check=False,
188
+ capture_output=True,
189
+ text=True,
190
+ encoding="utf-8",
191
+ errors="replace",
192
+ stdin=subprocess.DEVNULL,
193
+ timeout=remaining_seconds,
194
+ cwd=str(repository_root),
195
+ env=_review_environment(),
196
+ creationflags=hidden_window_creation_flags(),
197
+ )
198
+ except subprocess.TimeoutExpired:
199
+ _write_review_failure(repository_root, "timed out")
200
+ return None
201
+ except OSError as start_error:
202
+ _write_review_failure(repository_root, str(start_error))
203
+ return None
204
+ if completed_process.returncode != 0:
205
+ _write_review_failure(repository_root, f"exit {completed_process.returncode}")
206
+ return None
207
+ return completed_process.stdout
208
+
209
+
210
+ def _review_text(
211
+ repository_root: Path, base_commit: str, turn_commit: str, review_deadline: float
212
+ ) -> str | None:
213
+ remaining_seconds = review_deadline - time.monotonic()
214
+ if remaining_seconds < MINIMUM_REVIEW_SECONDS:
215
+ _write_review_failure(repository_root, "no review time left this stop")
216
+ return None
217
+ review_stdout = _review_stdout(repository_root, base_commit, turn_commit, remaining_seconds)
218
+ if review_stdout is None:
219
+ return None
220
+ try:
221
+ review_result = json.loads(review_stdout)
222
+ except ValueError:
223
+ _write_review_failure(repository_root, "output was not JSON")
224
+ return None
225
+ review_text = review_result.get(REVIEW_RESULT_KEY) if isinstance(review_result, dict) else None
226
+ if not isinstance(review_text, str):
227
+ _write_review_failure(repository_root, "output had no result text")
228
+ return None
229
+ return review_text
230
+
231
+
232
+ def _state_path(session_id: object) -> Path:
233
+ return Path(tempfile.gettempdir()) / STATE_FILE_TEMPLATE.format(
234
+ session_id=session_file_id(session_id)
235
+ )
236
+
237
+
238
+ def _load_state(session_id: object) -> tuple[dict[str, str], int]:
239
+ try:
240
+ saved_state = json.loads(_state_path(session_id).read_text(encoding=STATE_TEXT_ENCODING))
241
+ except (OSError, ValueError):
242
+ return {}, 0
243
+ if not isinstance(saved_state, dict):
244
+ return {}, 0
245
+ raw_baselines = saved_state.get(STATE_BASELINES_KEY)
246
+ all_baselines = {
247
+ each_root: each_commit
248
+ for each_root, each_commit in (
249
+ raw_baselines.items() if isinstance(raw_baselines, dict) else []
250
+ )
251
+ if isinstance(each_root, str)
252
+ and isinstance(each_commit, str)
253
+ and re.fullmatch(BASELINE_COMMIT_PATTERN, each_commit)
254
+ }
255
+ raw_blocks = saved_state.get(STATE_BLOCKS_KEY)
256
+ return all_baselines, raw_blocks if isinstance(raw_blocks, int) else 0
257
+
258
+
259
+ def _save_state(session_id: object, all_baselines: dict[str, str], blocks_in_a_row: int) -> None:
260
+ write_text_atomically(
261
+ _state_path(session_id),
262
+ json.dumps({STATE_BASELINES_KEY: all_baselines, STATE_BLOCKS_KEY: blocks_in_a_row}),
263
+ STATE_TEXT_ENCODING,
264
+ STATE_TEMPORARY_PREFIX,
265
+ ATOMIC_WRITE_TEMPORARY_SUFFIX,
266
+ False,
267
+ )
268
+
269
+
270
+ def _record_prompt_baselines(session_id: object, all_roots: list[Path]) -> None:
271
+ all_baselines = {}
272
+ for each_root in all_roots:
273
+ snapshot_commit = working_tree_snapshot(each_root, None)
274
+ if snapshot_commit is not None:
275
+ all_baselines[str(each_root)] = snapshot_commit
276
+ _save_state(session_id, all_baselines, 0)
277
+
278
+
279
+ def _repository_findings(
280
+ repository_root: Path, base_commit: str, turn_commit: str, review_deadline: float
281
+ ) -> str | None:
282
+ all_changed_paths = turn_changed_paths(repository_root, base_commit, turn_commit)
283
+ if len(all_changed_paths) > MAXIMUM_TURN_FILES:
284
+ sys.stderr.write(
285
+ TOO_MANY_FILES_DIAGNOSTIC_TEMPLATE.format(
286
+ repository=repository_root.name,
287
+ count=len(all_changed_paths),
288
+ limit=MAXIMUM_TURN_FILES,
289
+ )
290
+ )
291
+ return None
292
+ if not all_changed_paths:
293
+ return None
294
+ review_text = _review_text(repository_root, base_commit, turn_commit, review_deadline)
295
+ all_finding_lines = finding_lines(review_text or "", all_changed_paths)
296
+ if not all_finding_lines:
297
+ return None
298
+ return REPOSITORY_FINDINGS_TEMPLATE.format(
299
+ repository=repository_root.name,
300
+ findings=FINDING_LINE_SEPARATOR.join(all_finding_lines),
301
+ )
302
+
303
+
304
+ def _turn_block_reason(session_id: object, all_roots: list[Path]) -> str | None:
305
+ all_baselines, blocks_in_a_row = _load_state(session_id)
306
+ all_next_baselines: dict[str, str] = {}
307
+ all_findings: list[str] = []
308
+ review_deadline = time.monotonic() + REVIEW_TIMEOUT_SECONDS
309
+ for each_root in all_roots:
310
+ base_commit = all_baselines.get(str(each_root))
311
+ turn_commit = working_tree_snapshot(each_root, base_commit)
312
+ if turn_commit is None:
313
+ continue
314
+ all_next_baselines[str(each_root)] = turn_commit
315
+ if base_commit is None or blocks_in_a_row >= MAXIMUM_BLOCKS_IN_A_ROW:
316
+ continue
317
+ repository_findings = _repository_findings(
318
+ each_root, base_commit, turn_commit, review_deadline
319
+ )
320
+ if repository_findings is not None:
321
+ all_findings.append(repository_findings)
322
+ if all_findings:
323
+ blocks_in_a_row += 1
324
+ elif blocks_in_a_row < MAXIMUM_BLOCKS_IN_A_ROW:
325
+ blocks_in_a_row = 0
326
+ _save_state(session_id, all_next_baselines, blocks_in_a_row)
327
+ if not all_findings:
328
+ return None
329
+ return BLOCK_REASON_TEMPLATE.format(
330
+ all_findings=REPOSITORY_FINDINGS_SEPARATOR.join(all_findings)
331
+ )
332
+
333
+
334
+ def main() -> int:
335
+ hook_input = read_hook_input_dictionary_from_stdin()
336
+ if hook_input is None:
337
+ return 0
338
+ if os.environ.get(REVIEW_CHILD_ENVIRONMENT_VARIABLE) == REVIEW_CHILD_MARKER:
339
+ return 0
340
+ all_slugs = _opted_in_repository_slugs()
341
+ if not all_slugs:
342
+ return 0
343
+ all_roots = _opted_in_repository_roots(all_slugs)
344
+ if not all_roots:
345
+ return 0
346
+ session_id = hook_input.get(SESSION_ID_KEY)
347
+ hook_event_name = hook_input.get(HOOK_EVENT_NAME_KEY)
348
+ if hook_event_name == PROMPT_EVENT_NAME:
349
+ _record_prompt_baselines(session_id, all_roots)
350
+ return 0
351
+ if hook_event_name != STOP_EVENT_NAME:
352
+ return 0
353
+ block_reason = _turn_block_reason(session_id, all_roots)
354
+ if block_reason is None:
355
+ return 0
356
+ log_hook_block(Path(__file__).name, STOP_EVENT_NAME, block_reason)
357
+ sys.stdout.write(json.dumps({DECISION_KEY: BLOCK_DECISION, REASON_KEY: block_reason}))
358
+ return 0
359
+
360
+
361
+ if __name__ == "__main__":
362
+ sys.exit(main())
@@ -0,0 +1,116 @@
1
+ """Snapshot a checkout's working tree as a commit, for the turn review Stop gate.
2
+
3
+ A snapshot is a commit built from a copy of the repository's index after
4
+ ``git add -A``, so untracked files count and the user's own index stays as it
5
+ was::
6
+
7
+ prompt -> working_tree_snapshot(root, None) -> A (parent HEAD)
8
+ stop -> working_tree_snapshot(root, A) -> B (parent A)
9
+ turn_changed_paths(root, A, B) -> ["src/service.py"]
10
+
11
+ Chaining each snapshot onto the last one makes ``A..B`` a single commit that
12
+ holds only the edits made between the two calls.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import shutil
19
+ import subprocess
20
+ import tempfile
21
+ from pathlib import Path
22
+
23
+ from hooks_constants.subprocess_window import hidden_window_creation_flags
24
+ from hooks_constants.turn_review_gate_constants import (
25
+ ALL_CHANGED_PATH_ARGUMENTS,
26
+ ALL_INDEX_PATH_ARGUMENTS,
27
+ ALL_SNAPSHOT_IDENTITY_VARIABLES,
28
+ CHANGED_PATH_SEPARATOR,
29
+ GIT_COMMAND_TIMEOUT_SECONDS,
30
+ GIT_OUTPUT_ENCODING,
31
+ GIT_OUTPUT_ERROR_HANDLING,
32
+ GIT_INDEX_FILE_ENVIRONMENT_VARIABLE,
33
+ SNAPSHOT_COMMIT_MESSAGE,
34
+ TEMPORARY_INDEX_FILE_NAME,
35
+ )
36
+
37
+
38
+ def _git_output(
39
+ repository_root: Path,
40
+ all_arguments: list[str],
41
+ all_environment_variables: dict[str, str] | None,
42
+ ) -> str | None:
43
+ try:
44
+ completed_process = subprocess.run(
45
+ ["git", *all_arguments],
46
+ check=False,
47
+ capture_output=True,
48
+ text=True,
49
+ encoding=GIT_OUTPUT_ENCODING,
50
+ errors=GIT_OUTPUT_ERROR_HANDLING,
51
+ timeout=GIT_COMMAND_TIMEOUT_SECONDS,
52
+ cwd=str(repository_root),
53
+ env=all_environment_variables,
54
+ creationflags=hidden_window_creation_flags(),
55
+ )
56
+ except (subprocess.TimeoutExpired, OSError):
57
+ return None
58
+ if completed_process.returncode != 0:
59
+ return None
60
+ return completed_process.stdout.rstrip("\n")
61
+
62
+
63
+ def _working_tree_object(repository_root: Path) -> str | None:
64
+ index_path_text = _git_output(repository_root, list(ALL_INDEX_PATH_ARGUMENTS), None)
65
+ if index_path_text is None:
66
+ return None
67
+ with tempfile.TemporaryDirectory() as index_directory:
68
+ temporary_index = Path(index_directory) / TEMPORARY_INDEX_FILE_NAME
69
+ try:
70
+ shutil.copyfile(repository_root / index_path_text, temporary_index)
71
+ except OSError:
72
+ return None
73
+ all_index_environment_variables = {
74
+ **os.environ,
75
+ GIT_INDEX_FILE_ENVIRONMENT_VARIABLE: str(temporary_index),
76
+ }
77
+ if _git_output(repository_root, ["add", "-A"], all_index_environment_variables) is None:
78
+ return None
79
+ return _git_output(repository_root, ["write-tree"], all_index_environment_variables)
80
+
81
+
82
+ def working_tree_snapshot(repository_root: Path, base_commit: str | None) -> str | None:
83
+ """Return a commit that holds the working tree as it is now.
84
+
85
+ Args:
86
+ repository_root: The checkout to snapshot.
87
+ base_commit: The previous snapshot, used as the parent. None uses HEAD.
88
+
89
+ Returns:
90
+ The snapshot commit, ``base_commit`` itself when the tree has not
91
+ changed since it, or None when git cannot build one.
92
+ """
93
+ tree = _working_tree_object(repository_root)
94
+ if tree is None:
95
+ return None
96
+ if (
97
+ base_commit
98
+ and _git_output(repository_root, ["rev-parse", f"{base_commit}^{{tree}}"], None) == tree
99
+ ):
100
+ return base_commit
101
+ parent = base_commit or "HEAD"
102
+ return _git_output(
103
+ repository_root,
104
+ ["commit-tree", tree, "-p", parent, "-m", SNAPSHOT_COMMIT_MESSAGE],
105
+ {**os.environ, **ALL_SNAPSHOT_IDENTITY_VARIABLES},
106
+ )
107
+
108
+
109
+ def turn_changed_paths(repository_root: Path, base_commit: str, turn_commit: str) -> list[str]:
110
+ """Return the repository-relative paths that differ between two snapshots."""
111
+ if base_commit == turn_commit:
112
+ return []
113
+ diff_names = _git_output(
114
+ repository_root, [*ALL_CHANGED_PATH_ARGUMENTS, base_commit, turn_commit], None
115
+ )
116
+ return [each for each in (diff_names or "").split(CHANGED_PATH_SEPARATOR) if each]
@@ -24,6 +24,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
24
24
  - `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.
25
25
  - `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.
26
26
  - `blocking/send_user_file_worktree_root_gate.py` denies a `SendUserFile` call that names a file outside the session's working directory, the `cwd` in the hook payload. The desktop file pane opens a sent file only under that folder and shows "Couldn't find this file" for any other path. The reason lists each outside file and names `<cwd>\.scratch\` as the folder to copy it into.
27
+ - `blocking/turn_review_gate.py` holds the end of a turn when a fresh `claude -p "/code-review low <base>..<turn>"` run finds a problem in that turn's edits. At the prompt it snapshots each opted-in checkout into a commit built from a copy of the index after `git add -A`; at the stop it snapshots again with the first snapshot as parent, so the range holds only the turn's edits, untracked files included. A checkout takes part when it is `CLAUDE_PROJECT_DIR` or a folder directly inside it and its GitHub origin `owner/repo` slug is listed in `CLAUDE_TURN_REVIEW_REPOSITORIES`, comma-separated. A finding is an entry in the review's last non-empty JSON array of objects with a `file` key, where `file` is a changed path and `line` is a number. An empty `[]` counts as no array. A review with no such array has a finding on each line that opens with a changed path, a colon and a line number, after an optional list marker, backtick or emphasis mark. A review that opens with `(none)` has none. A saved baseline that is not a 40- or 64-character commit name is dropped, and that stop takes a fresh baseline. Each stop moves the baseline to its snapshot, so the stop after a block reviews only the fix. It holds a stop at most 3 times in a row until the next prompt, skips a turn that changed more than 30 files, and lets the stop through when the review fails. The reviews of one stop share 240 seconds, and a checkout reached with less than a second left goes unreviewed. The review run gets `--setting-sources ""`, `--tools Read,Grep,Glob,Bash`, `--no-session-persistence`, `--permission-mode dontAsk`, read tools plus `git diff`, `git show` and `git log` in the checkout, a deny list for git's `-c`, `--config-env`, `--output`, `--ext-diff`, `--textconv`, `--git-dir`, `--work-tree` and `--exec-path` options, `--settings '{"disableAllHooks": true}'` so no other hook steers the review, `CLAUDE_TURN_REVIEW_CHILD=1`, and none of the parent session's id, messaging or ingress variables; the gate does nothing inside such a run.
27
28
 
28
29
  ## When it fires
29
30
 
@@ -47,6 +48,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
47
48
  - `blocking/merge_over_review_offer_gate.py` runs on `PreToolUse`, matcher `AskUserQuestion|mcp__.*__ask_decision`, timeout `10` seconds in `hooks.json`.
48
49
  - `blocking/artifact_dark_mode_gate.py` runs on `PreToolUse`, matcher `Artifact`, timeout `60` seconds in `hooks.json`.
49
50
  - `blocking/send_user_file_worktree_root_gate.py` runs on `PreToolUse`, matcher `SendUserFile`, timeout `10` seconds in `hooks.json`.
51
+ - `blocking/turn_review_gate.py` runs on `UserPromptSubmit`, matcher empty, timeout `30` seconds, and on `Stop`, timeout `300` seconds, in `hooks.json`. The review itself times out after 240 seconds.
50
52
 
51
53
  ## Proving it
52
54
 
@@ -74,10 +76,13 @@ Preconditions:
74
76
  - **Merge offer.** Input is a decision card or AskUserQuestion that offers "Merge now", the merge queue, or a yes to "Merge it now?" while any part of the card names open findings or unaddressed review comments. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_merge_over_review_offer_gate.py -q`. The adjacent test observes a denial for each, including an offer and findings in separate questions; a card with no merge offer, a card about the merge review check itself, a card about shipping a docs change, and a merge offer after a clean review pass.
75
77
  - **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.
76
78
  - **Sent file root.** Input is a `SendUserFile` call for a file on `Y:\` or in the session scratchpad, with `cwd` set to a worktree. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_send_user_file_worktree_root_gate.py -q`. The adjacent test observes a denial that names the file and `<cwd>\.scratch\`; a file under `<cwd>\.scratch\` passes.
79
+ - **Turn review.** Input is a temporary checkout with an opted-in origin, a `UserPromptSubmit`, an edit that drops a decorator, and a `Stop`, with a stub `claude` on PATH that prints a finding. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_turn_review_gate.py -q`. The adjacent test observes a `block` decision with the finding, a review range whose diff holds only the edited file, and a review environment with no parent session id. A `(none)` review, an unchanged turn, a failed review, an unlisted repository, a 31-file turn, and a fourth stop in a row pass.
77
80
 
78
81
  ## Gotchas
79
82
 
80
83
  - The ask nudge runs only from `hooks.json`, so Codex and Cursor sessions get no turn-start note. Their Stop gate still holds a work turn with no tracker call.
84
+ - `blocking/turn_review_gate.py` starts `claude -p`, which inherits the parent session id unless the gate strips it. A review that kept `CLAUDE_CODE_SESSION_ID` would write into the parent's transcript.
85
+ - Each review run calls a model and costs about $0.03. Leave `CLAUDE_TURN_REVIEW_REPOSITORIES` unset and the gate makes no snapshot and no call.
81
86
  - `blocking/step_note_gate.py` is off by default and polls for the transcript record. Subagent calls and unreadable records pass.
82
87
  - `blocking/verify_before_acting.py` runs after the tool. A block asks the agent to check the claim and undo a contradicted change.
83
88
  - The reply gate counts each nonempty list line as a sentence. Link targets and code spans add no words. Its banned words come from `reply-banned-words.json` in the Claude home, or the file `CLAUDE_REPLY_BANNED_WORDS_PATH` names, with built-in defaults.
package/hooks/hooks.json CHANGED
@@ -343,6 +343,11 @@
343
343
  "type": "command",
344
344
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/plugin_change_warning.py",
345
345
  "timeout": 10
346
+ },
347
+ {
348
+ "type": "command",
349
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/turn_review_gate.py",
350
+ "timeout": 30
346
351
  }
347
352
  ]
348
353
  }
@@ -449,6 +454,11 @@
449
454
  "type": "command",
450
455
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/session_title_turn_gate.py",
451
456
  "timeout": 10
457
+ },
458
+ {
459
+ "type": "command",
460
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/turn_review_gate.py",
461
+ "timeout": 300
452
462
  }
453
463
  ]
454
464
  }
@@ -20,7 +20,8 @@ MISSING_PROOF_REASON = (
20
20
  " the difference. For a hook, skill, rule, setting, prompt or output style, spawn"
21
21
  " each test session through the account broker. Then add a 'Proof in practice'"
22
22
  " heading to the body that lists each command in backticks, quotes the output, states"
23
- " the difference between the runs, and names anything still unproven. When you cannot"
23
+ " the difference between the runs, and lists any condition the runs left out under a"
24
+ " 'Not covered' heading. When you cannot"
24
25
  " prove the change, leave the pull request unopened and tell the user what you could"
25
26
  " not run. Read " + PROOF_GUIDE_PATH + " for the full rule."
26
27
  )
@@ -62,6 +63,18 @@ TEST_ONLY_PROOF_REASON = (
62
63
  " command in backticks and quote the output lines that show the difference. Keep the"
63
64
  " test run in the section as extra evidence. Read " + PROOF_GUIDE_PATH + " for the full rule."
64
65
  )
66
+ UNPROVEN_ADMISSION_PATTERN = r"\b(?:not (?:(?:yet|been|fully) )*(?:proven|tested|verified)|unproven|untested|unverified)\b"
67
+ INLINE_CODE_SPAN_PATTERN = r"`[^`\n]*`"
68
+ BLOCKQUOTE_MARKERS = "> "
69
+ ALL_CODE_FENCE_MARKERS = ("```", "~~~")
70
+ UNPROVEN_PROOF_REASON = (
71
+ "This pull request's 'Proof in practice' section says part of the change is not"
72
+ " proven: \"{admission}\". When that part is what the change exists to do, such as"
73
+ " the run on the target that failed, leave the pull request unopened. Run it, quote"
74
+ " its output, then open the pull request. When it is a condition the change does not"
75
+ " target, move the line under a 'Not covered' heading at the same level as the proof"
76
+ " heading. Read " + PROOF_GUIDE_PATH + " for the full rule."
77
+ )
65
78
  EXISTING_WORK_HEADING_PATTERN = r"^(#{1,6})[ \t]+existing work[ \t]*#*[ \t]*$"
66
79
  EXISTING_WORK_GUIDE_PATH = "~/.claude/docs/rule-guides/explore-thoroughly.md"
67
80
  MISSING_EXISTING_WORK_REASON = (