claude-dev-env 8.102.1 → 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.
- package/.agents/skills/pull-request/reference/body-sections.md +2 -1
- package/bin/install.prompt-context.test.mjs +2 -1
- package/docs/rule-guides/proof-before-pull-request.md +3 -3
- package/hooks/blocking/pull_request_proof.py +64 -6
- package/hooks/blocking/test_pull_request_proof.py +87 -0
- package/hooks/blocking/test_turn_review_findings.py +25 -0
- package/hooks/blocking/test_turn_review_gate.py +462 -0
- package/hooks/blocking/test_turn_review_snapshot.py +107 -0
- package/hooks/blocking/turn_review_findings.py +102 -0
- package/hooks/blocking/turn_review_gate.py +362 -0
- package/hooks/blocking/turn_review_snapshot.py +116 -0
- package/hooks/features/conduct-gates.md +5 -0
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/pull_request_proof_constants.py +14 -1
- package/hooks/hooks_constants/turn_review_gate_constants.py +161 -0
- package/package.json +1 -1
- package/scripts/policy_lint/adapter_configuration.py +6 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/tests/test_adapter_configuration.py +8 -0
|
@@ -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
|
|
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 = (
|