claude-dev-env 8.92.0 → 8.92.2
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/e-code-review/SKILL.md +1 -1
- package/.agents/skills/efficient-review/SKILL.md +1 -1
- package/.agents/skills/efficient-review/reference/criteria.md +1 -1
- package/.agents/skills/pr-lifecycle/reference/review-loop-before-merge.md +4 -3
- package/hooks/advisory/test_owned_pr_review_loop.py +2 -0
- package/hooks/blocking/final_reply_gate.py +172 -1
- package/hooks/blocking/test_final_reply_gate.py +130 -0
- package/hooks/blocking/test_merge_review_gate.py +6 -0
- package/hooks/features/conduct-gates.md +3 -3
- package/hooks/hooks_constants/final_reply_gate_constants.py +56 -0
- package/hooks/hooks_constants/merge_review_gate_constants.py +4 -2
- package/hooks/hooks_constants/owned_pr_review_loop_constants.py +3 -1
- package/package.json +1 -1
|
@@ -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.
|
|
9
|
-
5.
|
|
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.
|
|
@@ -281,6 +281,8 @@ 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
|
|
@@ -11,15 +11,21 @@ session ends its turn with, which reaches the user with no tool call::
|
|
|
11
11
|
|
|
12
12
|
It applies the banned words, the unvalidated claim pattern, and the cited
|
|
13
13
|
run, job, check, and pull request numbers from ``reply_length_gate.py``. The
|
|
14
|
-
citation check is skipped when the transcript cannot be read.
|
|
14
|
+
citation check is skipped when the transcript cannot be read. It also flags
|
|
15
|
+
a path outside code whose backslash the terminal drops, such as
|
|
16
|
+
``D:\\Reports\\.claude``. A block sends
|
|
15
17
|
the reason back so the model rewrites the reply. Claude Code ends the turn
|
|
16
18
|
after eight blocks in a row.
|
|
17
19
|
"""
|
|
18
20
|
|
|
19
21
|
from __future__ import annotations
|
|
20
22
|
|
|
23
|
+
import bisect
|
|
24
|
+
import functools
|
|
21
25
|
import json
|
|
26
|
+
import re
|
|
22
27
|
import sys
|
|
28
|
+
from collections.abc import Callable
|
|
23
29
|
from pathlib import Path
|
|
24
30
|
|
|
25
31
|
hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
@@ -36,22 +42,187 @@ from hooks_constants.hook_block_logger import log_hook_block
|
|
|
36
42
|
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
37
43
|
from hooks_constants.final_reply_gate_constants import (
|
|
38
44
|
ALLOW_EXIT_CODE,
|
|
45
|
+
BACKSLASH,
|
|
46
|
+
BACKSLASHES_PER_LITERAL,
|
|
47
|
+
BACKTICK_RUN_PATTERN,
|
|
48
|
+
BLOCK_BOUNDARY_PATTERN,
|
|
39
49
|
BLOCK_DECISION,
|
|
50
|
+
CARRIAGE_RETURN_LINE_BREAK,
|
|
40
51
|
DECISION_KEY,
|
|
52
|
+
ESCAPED_PUNCTUATION_PATTERN,
|
|
53
|
+
FENCE_CLOSER_PREFIX,
|
|
54
|
+
FENCE_CLOSER_SUFFIX,
|
|
55
|
+
FENCE_OPENER_PATTERN,
|
|
56
|
+
HARD_BREAK_PATTERN,
|
|
57
|
+
HEADING_AS_OWN_BLOCK,
|
|
58
|
+
HEADING_LINE_PATTERN,
|
|
41
59
|
HOOK_EVENT_NAME,
|
|
60
|
+
ALL_HTML_BLOCK_CLOSER_PATTERNS,
|
|
61
|
+
HTML_BLOCK_OPENER_PATTERN,
|
|
42
62
|
LAST_ASSISTANT_MESSAGE_KEY,
|
|
63
|
+
LINE_BREAK,
|
|
64
|
+
MASKED_BLOCK_TEXT,
|
|
65
|
+
MASKED_CODE_TEXT,
|
|
66
|
+
NON_SPACE_TOKEN_PATTERN,
|
|
67
|
+
PARAGRAPH_SEPARATOR,
|
|
68
|
+
QUOTE_MARKER,
|
|
69
|
+
QUOTE_MARKER_PATTERN,
|
|
43
70
|
REASON_KEY,
|
|
44
71
|
REWRITE_INSTRUCTION,
|
|
45
72
|
TRANSCRIPT_PATH_KEY,
|
|
73
|
+
WINDOWS_PATH_MESSAGE,
|
|
46
74
|
)
|
|
47
75
|
|
|
48
76
|
|
|
77
|
+
def _unescaped_start(paragraph_text: str, run_start: int) -> int:
|
|
78
|
+
backslash_start = run_start
|
|
79
|
+
while backslash_start > 0 and paragraph_text[backslash_start - 1] == BACKSLASH:
|
|
80
|
+
backslash_start -= 1
|
|
81
|
+
return run_start + (run_start - backslash_start) % BACKSLASHES_PER_LITERAL
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def code_spans_masked(paragraph_text: str) -> str:
|
|
85
|
+
"""Return the paragraph with one space where each code span sat.
|
|
86
|
+
|
|
87
|
+
A backtick run opens a span unless an odd run of backslashes escapes its
|
|
88
|
+
first backtick. The span closes at the next run of the same length::
|
|
89
|
+
|
|
90
|
+
>>> code_spans_masked("see `a\\\\.b` and \\\\`x")
|
|
91
|
+
'see and \\\\`x'
|
|
92
|
+
"""
|
|
93
|
+
all_runs = [each_run.span() for each_run in BACKTICK_RUN_PATTERN.finditer(paragraph_text)]
|
|
94
|
+
all_run_indexes_by_length: dict[int, list[int]] = {}
|
|
95
|
+
for each_index, (each_run_start, each_run_end) in enumerate(all_runs):
|
|
96
|
+
all_run_indexes_by_length.setdefault(each_run_end - each_run_start, []).append(each_index)
|
|
97
|
+
all_masked_pieces: list[str] = []
|
|
98
|
+
copied_up_to = 0
|
|
99
|
+
run_index = 0
|
|
100
|
+
while run_index < len(all_runs):
|
|
101
|
+
run_start, run_end = all_runs[run_index]
|
|
102
|
+
opener_start = _unescaped_start(paragraph_text, run_start)
|
|
103
|
+
same_length_indexes = all_run_indexes_by_length.get(run_end - opener_start, [])
|
|
104
|
+
closer_position = bisect.bisect_right(same_length_indexes, run_index)
|
|
105
|
+
if opener_start == run_end or closer_position == len(same_length_indexes):
|
|
106
|
+
run_index += 1
|
|
107
|
+
continue
|
|
108
|
+
closer_index = same_length_indexes[closer_position]
|
|
109
|
+
all_masked_pieces.extend((paragraph_text[copied_up_to:opener_start], MASKED_CODE_TEXT))
|
|
110
|
+
copied_up_to = all_runs[closer_index][1]
|
|
111
|
+
run_index = closer_index + 1
|
|
112
|
+
all_masked_pieces.append(paragraph_text[copied_up_to:])
|
|
113
|
+
return "".join(all_masked_pieces)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@functools.cache
|
|
117
|
+
def _fence_closer_pattern(fence_text: str) -> re.Pattern[str]:
|
|
118
|
+
return re.compile(
|
|
119
|
+
FENCE_CLOSER_PREFIX + re.escape(fence_text) + re.escape(fence_text[0]) + FENCE_CLOSER_SUFFIX,
|
|
120
|
+
re.MULTILINE,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _fence_closer_for(opener: re.Match[str]) -> re.Pattern[str]:
|
|
125
|
+
return _fence_closer_pattern(opener.group(1) or opener.group(2))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _html_block_closer_for(opener: re.Match[str]) -> re.Pattern[str]:
|
|
129
|
+
return ALL_HTML_BLOCK_CLOSER_PATTERNS[opener.lastgroup or ""]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def blocks_masked(
|
|
133
|
+
text: str,
|
|
134
|
+
opener_pattern: re.Pattern[str],
|
|
135
|
+
closer_for: Callable[[re.Match[str]], re.Pattern[str]],
|
|
136
|
+
) -> str:
|
|
137
|
+
"""Return the text with a blank line where each closed block sat.
|
|
138
|
+
|
|
139
|
+
A block runs from an opener to the first closer after it. An opener with
|
|
140
|
+
no closer stays in the text, and a closer search that found nothing is
|
|
141
|
+
not run again, so the scan stays linear::
|
|
142
|
+
|
|
143
|
+
>>> blocks_masked("a\\n<!--\\nb\\n-->\\nc", HTML_BLOCK_OPENER_PATTERN, _html_block_closer_for)
|
|
144
|
+
'a\\n\\n\\n\\nc'
|
|
145
|
+
"""
|
|
146
|
+
all_masked_pieces: list[str] = []
|
|
147
|
+
all_closer_misses: dict[re.Pattern[str], int] = {}
|
|
148
|
+
copied_up_to = 0
|
|
149
|
+
search_from = 0
|
|
150
|
+
while (opener := opener_pattern.search(text, search_from)) is not None:
|
|
151
|
+
closer_pattern = closer_for(opener)
|
|
152
|
+
is_closer_possible = opener.end() < all_closer_misses.get(closer_pattern, len(text) + 1)
|
|
153
|
+
closer = closer_pattern.search(text, opener.end()) if is_closer_possible else None
|
|
154
|
+
if closer is None:
|
|
155
|
+
all_closer_misses.setdefault(closer_pattern, opener.end())
|
|
156
|
+
search_from = max(opener.end(), opener.start() + 1)
|
|
157
|
+
continue
|
|
158
|
+
all_masked_pieces.extend((text[copied_up_to : opener.start()], MASKED_BLOCK_TEXT))
|
|
159
|
+
copied_up_to = search_from = closer.end()
|
|
160
|
+
all_masked_pieces.append(text[copied_up_to:])
|
|
161
|
+
return "".join(all_masked_pieces)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def quotes_unwrapped(text: str) -> str:
|
|
165
|
+
"""Return the text with each quote marker removed.
|
|
166
|
+
|
|
167
|
+
Lines of one quote form one paragraph, so their markers go. A line that
|
|
168
|
+
goes deeper into a quote starts a new paragraph, and an unmarked line
|
|
169
|
+
continues the quote above it::
|
|
170
|
+
|
|
171
|
+
>>> quotes_unwrapped("> a\\n>> b\\nc")
|
|
172
|
+
'\\na\\n\\nb\\nc'
|
|
173
|
+
"""
|
|
174
|
+
all_lines: list[str] = []
|
|
175
|
+
previous_depth = 0
|
|
176
|
+
for each_line in text.split(LINE_BREAK):
|
|
177
|
+
marker = QUOTE_MARKER_PATTERN.match(each_line)
|
|
178
|
+
marker_text = marker.group() if marker else ""
|
|
179
|
+
depth = marker_text.count(QUOTE_MARKER)
|
|
180
|
+
if depth > previous_depth:
|
|
181
|
+
all_lines.append("")
|
|
182
|
+
content_text = each_line[len(marker_text) :]
|
|
183
|
+
if depth or not content_text.strip():
|
|
184
|
+
previous_depth = depth
|
|
185
|
+
all_lines.append(content_text)
|
|
186
|
+
return LINE_BREAK.join(all_lines)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def windows_path_violation(reply_text: str) -> str | None:
|
|
190
|
+
"""Return the block reason for a path the terminal renders wrong, or None.
|
|
191
|
+
|
|
192
|
+
The terminal reads a backslash before punctuation as a markdown escape
|
|
193
|
+
and drops it. A path outside a code span loses that backslash::
|
|
194
|
+
|
|
195
|
+
flag: The report is at D:\\Reports\\.claude\\report.html now.
|
|
196
|
+
(the terminal shows D:\\Reports.claude\\report.html)
|
|
197
|
+
flag: Open D:\\Shared Files\\.claude\\x.html now.
|
|
198
|
+
ok: The report is at `D:\\Reports\\.claude\\report.html` now.
|
|
199
|
+
ok: The report is at D:\\Reports\\report.html now.
|
|
200
|
+
"""
|
|
201
|
+
line_feed_text = reply_text.replace(CARRIAGE_RETURN_LINE_BREAK, LINE_BREAK)
|
|
202
|
+
unfenced_text = blocks_masked(line_feed_text, FENCE_OPENER_PATTERN, _fence_closer_for)
|
|
203
|
+
unfenced_text = blocks_masked(unfenced_text, HTML_BLOCK_OPENER_PATTERN, _html_block_closer_for)
|
|
204
|
+
unfenced_text = quotes_unwrapped(unfenced_text)
|
|
205
|
+
unfenced_text = HEADING_LINE_PATTERN.sub(HEADING_AS_OWN_BLOCK, unfenced_text)
|
|
206
|
+
prose_text = PARAGRAPH_SEPARATOR.join(
|
|
207
|
+
code_spans_masked(each_paragraph)
|
|
208
|
+
for each_paragraph in BLOCK_BOUNDARY_PATTERN.split(unfenced_text)
|
|
209
|
+
)
|
|
210
|
+
for each_token_match in NON_SPACE_TOKEN_PATTERN.finditer(prose_text):
|
|
211
|
+
token_text = each_token_match.group()
|
|
212
|
+
is_line_end = prose_text.startswith(LINE_BREAK, each_token_match.end())
|
|
213
|
+
is_hard_break = is_line_end and HARD_BREAK_PATTERN.search(token_text) is not None
|
|
214
|
+
if is_hard_break or ESCAPED_PUNCTUATION_PATTERN.search(token_text) is not None:
|
|
215
|
+
return WINDOWS_PATH_MESSAGE.format(path=token_text)
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
|
|
49
219
|
def final_reply_violation(reply_text: str, transcript_path: object) -> str | None:
|
|
50
220
|
"""Return the block reason for the closing reply, or None when it passes."""
|
|
51
221
|
return (
|
|
52
222
|
banned_word_violation(reply_text, configured_banned_words())
|
|
53
223
|
or unvalidated_claim_violation(reply_text)
|
|
54
224
|
or unseen_citation_violation(reply_text, transcript_path)
|
|
225
|
+
or windows_path_violation(reply_text)
|
|
55
226
|
)
|
|
56
227
|
|
|
57
228
|
|
|
@@ -9,6 +9,10 @@ import final_reply_gate
|
|
|
9
9
|
GUESSED_REPLY = "My guess is one to two hours if those builds pass first time."
|
|
10
10
|
UNCONFIRMED_REPLY = "Merge queue: I didn\u2019t confirm this one."
|
|
11
11
|
MEASURED_REPLY = "The last queue build took 39 minutes, run 18239174012."
|
|
12
|
+
INSIGHTS_REPLY = (
|
|
13
|
+
"Your shareable insights report is ready:\n"
|
|
14
|
+
"file://C:\\Reports\\.claude-profiles\\org\\usage-data\\report-2026-10-20-094705.html"
|
|
15
|
+
)
|
|
12
16
|
|
|
13
17
|
|
|
14
18
|
def run_gate(
|
|
@@ -92,3 +96,129 @@ def test_should_allow_a_closing_reply_with_no_message(
|
|
|
92
96
|
|
|
93
97
|
def test_should_return_none_for_a_measured_reply_without_a_transcript() -> None:
|
|
94
98
|
assert final_reply_gate.final_reply_violation(MEASURED_REPLY, None) is None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@pytest.mark.parametrize(
|
|
102
|
+
("reply_text", "quoted_text"),
|
|
103
|
+
[
|
|
104
|
+
(INSIGHTS_REPLY, 'Path "file://C:\\Reports\\.claude-profiles'),
|
|
105
|
+
("The report is at C:\\Reports\\.claude\\report.html now.", 'Path "C:\\Reports'),
|
|
106
|
+
("Run C:\\Tools\\-v\\x.exe once.", 'Path "C:\\Tools\\-v'),
|
|
107
|
+
("Wait ~~~ then open C:\\Reports\\.claude\\r.html now.", 'Path "C:\\Reports'),
|
|
108
|
+
("Wait ``` then open C:\\Reports\\.claude\\r.html now.", 'Path "C:\\Reports'),
|
|
109
|
+
("Escaped \\` then C:\\Reports\\.claude\\r.html then `x`.", 'Path "\\`"'),
|
|
110
|
+
("Open C:\\Shared Files\\.claude\\x.html now.", 'Path "Files\\.claude'),
|
|
111
|
+
("Open `C:\\Reports`\\.claude\\x.html now.", 'Path "\\.claude'),
|
|
112
|
+
("Saved to C:\\Reports\\..\\out\\x.html now.", 'Path "C:\\Reports'),
|
|
113
|
+
("Saved to D:\\Reports\\. now", 'Path "D:\\Reports\\."'),
|
|
114
|
+
("Saved to D:\\Reports\\\\\\.claude now", 'Path "D:\\Reports'),
|
|
115
|
+
("```\n```python\n```\nOpen C:\\Reports\\.claude\\x.html now.\n```", 'Path "C:\\Reports'),
|
|
116
|
+
("Check `this\nand `C:\\Reports\\.claude\\r.html` now.", 'Path "C:\\Reports'),
|
|
117
|
+
("```foo` bar\nOpen C:\\Reports\\.claude\\r.html now.", 'Path "C:\\Reports'),
|
|
118
|
+
("Saved to D:\\Reports\\\n.claude\\x.html now.", 'Path "D:\\Reports\\"'),
|
|
119
|
+
("\\\\`x` now C:\\Reports\\.claude\\r.html and `y`", 'Path "C:\\Reports'),
|
|
120
|
+
("Check `this\n\nand C:\\Reports\\.claude\\r.html` now.", 'Path "C:\\Reports'),
|
|
121
|
+
("- Stray ` here\n- Saved to D:\\Reports\\.claude\\r.html\n- Run `x`", 'Path "D:\\Reports'),
|
|
122
|
+
("# Stray ` here\nSaved to D:\\Reports\\.claude\\r.html `x`", 'Path "D:\\Reports'),
|
|
123
|
+
("Check `this\n2. and `C:\\Reports\\.claude\\r.html` now.", 'Path "C:\\Reports'),
|
|
124
|
+
("Check `this\n - and `C:\\Reports\\.claude\\r.html` now.", 'Path "C:\\Reports'),
|
|
125
|
+
("Check `this\n # and `C:\\Reports\\.claude\\r.html` now.", 'Path "C:\\Reports'),
|
|
126
|
+
("Check `this\n\t# and `C:\\Reports\\.claude\\r.html` now.", 'Path "C:\\Reports'),
|
|
127
|
+
("Check `this\n---\nand C:\\Reports\\.claude\\r.html `x`", 'Path "C:\\Reports'),
|
|
128
|
+
("Check `this\n===\nand C:\\Reports\\.claude\\r.html `x`", 'Path "C:\\Reports'),
|
|
129
|
+
("Check `this\n* * *\nand C:\\Reports\\.claude\\r.html `x`", 'Path "C:\\Reports'),
|
|
130
|
+
("Check `this\n___\nand C:\\Reports\\.claude\\r.html `x`", 'Path "C:\\Reports'),
|
|
131
|
+
("Check `this\n<!-- note -->\nand C:\\Reports\\.claude\\r.html `x`", 'Path "C:\\Reports'),
|
|
132
|
+
("Check `this\n<details>\n\nand C:\\Reports\\.claude\\r.html `x`", 'Path "C:\\Reports'),
|
|
133
|
+
("Check `this\n<span>a` and C:\\Reports\\.claude\\r.html `x`", 'Path "C:\\Reports'),
|
|
134
|
+
("<!-- ` -->\nSaved to C:\\Reports\\.claude\\r.html `x`", 'Path "C:\\Reports'),
|
|
135
|
+
("Check `this\n```\ncode\n```\nand C:\\Reports\\.claude\\r.html `x`", 'Path "C:\\Reports'),
|
|
136
|
+
("- ```bash\n echo hi\n ```\nSaved to D:\\Reports\\.claude\\r.html now.", 'Path "D:\\Reports'),
|
|
137
|
+
("```\nC:\\Reports\\.claude\\open.html", 'Path "C:\\Reports'),
|
|
138
|
+
("> stray ` here\n> and `C:\\Reports\\.claude\\r.html` now", 'Path "C:\\Reports'),
|
|
139
|
+
("> stray `\n>> and C:\\Reports\\.claude\\r.html `x`", 'Path "C:\\Reports'),
|
|
140
|
+
("> stray `\nlazy\n> and `C:\\Reports\\.claude\\r.html` now", 'Path "C:\\Reports'),
|
|
141
|
+
("```\r\ncode\r\n```\r\nOpen C:\\Reports\\.claude\\r.html now.", 'Path "C:\\Reports'),
|
|
142
|
+
],
|
|
143
|
+
)
|
|
144
|
+
def test_should_block_a_windows_path_markdown_mangles(
|
|
145
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
146
|
+
capsys: pytest.CaptureFixture[str],
|
|
147
|
+
reply_text: str,
|
|
148
|
+
quoted_text: str,
|
|
149
|
+
) -> None:
|
|
150
|
+
_, stdout_text = run_gate(monkeypatch, capsys, reply_text)
|
|
151
|
+
decision = json.loads(stdout_text)
|
|
152
|
+
assert decision["decision"] == "block"
|
|
153
|
+
assert quoted_text in decision["reason"]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@pytest.mark.parametrize(
|
|
157
|
+
"reply_text",
|
|
158
|
+
[
|
|
159
|
+
"Open `file:///C:/Reports/.claude/report.html` now.",
|
|
160
|
+
"The report is at `C:\\Reports\\.claude\\report.html` now.",
|
|
161
|
+
"Paths:\n```text\nC:\\Reports\\.claude\\report.html\n```",
|
|
162
|
+
"The report is at C:\\Reports\\report.html now.",
|
|
163
|
+
"The report is at C:\\\\Reports\\\\.claude\\\\report.html now.",
|
|
164
|
+
"The report is at ``C:\\Reports\\.claude\\report.html`` now.",
|
|
165
|
+
"Paths:\n~~~\nC:\\Reports\\.claude\\report.html\n~~~",
|
|
166
|
+
"Paths `C:\\Reports\\.claude\nreport.html` here.",
|
|
167
|
+
"<pre>\nC:\\Reports\\.claude\\report.html\n</pre>",
|
|
168
|
+
"> Saved to `C:\\Reports\\.claude\\report.html`\n> now.",
|
|
169
|
+
],
|
|
170
|
+
)
|
|
171
|
+
def test_should_allow_a_windows_path_inside_code(
|
|
172
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], reply_text: str
|
|
173
|
+
) -> None:
|
|
174
|
+
assert run_gate(monkeypatch, capsys, reply_text) == (0, "")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@pytest.mark.parametrize(
|
|
178
|
+
("text", "masked_text"),
|
|
179
|
+
[
|
|
180
|
+
("a\n<!--\nb\n-->\nc", "a\n\n\n\nc"),
|
|
181
|
+
("a\n<!--\nb\nc", "a\n<!--\nb\nc"),
|
|
182
|
+
("<!--\n<!--\nb -->\nc", "\n\n\nc"),
|
|
183
|
+
("<details>\nb\n\nc", "\n\n\n\nc"),
|
|
184
|
+
],
|
|
185
|
+
)
|
|
186
|
+
def test_should_mask_html_blocks_only_where_they_close(text: str, masked_text: str) -> None:
|
|
187
|
+
assert (
|
|
188
|
+
final_reply_gate.blocks_masked(
|
|
189
|
+
text,
|
|
190
|
+
final_reply_gate.HTML_BLOCK_OPENER_PATTERN,
|
|
191
|
+
final_reply_gate._html_block_closer_for,
|
|
192
|
+
)
|
|
193
|
+
== masked_text
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@pytest.mark.parametrize(
|
|
198
|
+
("text", "unwrapped_text"),
|
|
199
|
+
[
|
|
200
|
+
("> a\n> b", "\na\nb"),
|
|
201
|
+
("> a\n>> b", "\na\n\nb"),
|
|
202
|
+
("> a\nb\n> c", "\na\nb\nc"),
|
|
203
|
+
("> a\n\n> b", "\na\n\n\nb"),
|
|
204
|
+
("plain", "plain"),
|
|
205
|
+
],
|
|
206
|
+
)
|
|
207
|
+
def test_should_unwrap_quotes_into_their_paragraphs(text: str, unwrapped_text: str) -> None:
|
|
208
|
+
assert final_reply_gate.quotes_unwrapped(text) == unwrapped_text
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@pytest.mark.parametrize(
|
|
212
|
+
("paragraph_text", "masked_text"),
|
|
213
|
+
[
|
|
214
|
+
("see `a\\.b` and more", "see and more"),
|
|
215
|
+
("\\`x` stays", "\\`x` stays"),
|
|
216
|
+
("\\\\`x` gone", "\\\\ gone"),
|
|
217
|
+
("``a`b`` end", " end"),
|
|
218
|
+
("`open only", "`open only"),
|
|
219
|
+
],
|
|
220
|
+
)
|
|
221
|
+
def test_should_mask_code_spans_the_way_commonmark_pairs_them(
|
|
222
|
+
paragraph_text: str, masked_text: str
|
|
223
|
+
) -> None:
|
|
224
|
+
assert final_reply_gate.code_spans_masked(paragraph_text) == masked_text
|
|
@@ -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}"
|
|
@@ -5,7 +5,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
5
5
|
## Checks
|
|
6
6
|
|
|
7
7
|
- `blocking/reply_length_gate.py` caps chat reply length, denies a configured banned word, requires a readable link when the text names a pull request number, and denies a causal claim in a reply that cites no code span, output block, or link. It denies a sentence that guesses, estimates, hedges a duration, or admits a skipped check, such as "I didn't confirm this one." It denies a cited Actions run, pull request, or run, job, or check id that no successful tool result in the transcript shows; a subagent's report is the result of its Agent call. A question card from `AskUserQuestion` gets the banned word check and `unvalidated_claim_violation`, like a decision card. With visual reply mode on, it also runs the checks in `visual_reply_rules.py` from the rule list in `rules/visual-reply-rules.json`: no abbreviation or tracker number outside a link, a widget or page before a reply of more than one sentence, and no anchor link inside widget code. The mode is off by default; `~/.claude/visual-reply-mode.json` with `{"enabled": true}` turns the mode checks on.
|
|
8
|
-
- `blocking/final_reply_gate.py` applies the banned words, `unvalidated_claim_violation`, and `unseen_citation_violation` from the reply gate to the reply a session ends its turn with.
|
|
8
|
+
- `blocking/final_reply_gate.py` applies the banned words, `unvalidated_claim_violation`, and `unseen_citation_violation` from the reply gate to the reply a session ends its turn with. Its `windows_path_violation` blocks a path the terminal would render without one of its backslashes.
|
|
9
9
|
- `blocking/open_tasks_stop_gate.py` holds the end of a turn while the session task list has items that are neither completed nor named on a `Blocked:` or `Open:` line of the closing reply. `blocking/open_tasks_transcript.py` rebuilds the task list and the last reply from the transcript for it.
|
|
10
10
|
- The claim judge is a `prompt` hook in `hooks.json`, with no `model` set, so Claude Code's small default hook model reads each chat message and denies the first claim about how code, a file, a tool, a service, a run, or a process behaves, or why something happened, when nothing in the message shows it: inline code that quotes output or a code line, a file and line, or a link. A pull request link covers what that change does, what it blocks or lets through, and its checks. The author's own actions, a restatement of the user's words, questions, instructions, and a decision card's option consequences pass. It catches the plain claims the causal word list in `blocking/reply_length_gate.py` misses, such as "The code lets any of them rewrite it mid-check." `continueOnBlock` returns the reason to the agent as the tool error, so it can add the source and send again. The installer owns it through the `claude-dev-env: ` prefix on its `statusMessage`. It skips `post_widget`, whose input is SVG or HTML markup.
|
|
11
11
|
- `blocking/chat_reply_gate.py` runs the `reply_length_gate.py` reply checks on a plain chat reply, which is no tool call. On `UserPromptSubmit` it adds the reply rules as `additionalContext`. On `MessageDisplay` it holds each streamed batch until the message ends. A message that passes shows whole. A message that fails shows as a draft from `claude -p` on Haiku that passes the same checks, and the original shows when two drafts fail. The draft changes only the text on screen, so the next `UserPromptSubmit` tells the agent the text the user saw, and `SessionEnd` deletes the stored draft. The [reply arena harness](https://github.com/jl-cmd-projects/claude-dev-env/tree/160349b90f627ea542fc6bc7b2b7f86dcbf98b73/tests/reply_arena) ran the comparison that chose this display path.
|
|
@@ -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
|
|
|
@@ -47,7 +47,7 @@ Preconditions:
|
|
|
47
47
|
- Run each command from the repository root with Python and pytest available. The tests create transcript and payload fixtures.
|
|
48
48
|
|
|
49
49
|
- **Reply length.** Input is a reply with four sentences, a plain pull request number, an abbreviation, two sentences with no widget this turn, or widget code with an anchor link. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_reply_length_gate.py -q`. The adjacent test observes a denial and its count, link, or mode reason. Widget code with no flowchart passes with a flowchart reminder in `additionalContext`.
|
|
50
|
-
- **Final reply.** Input is a closing reply that says "My guess is one to two hours." or cites a run no tool result shows. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_final_reply_gate.py -q`. The adjacent test observes a `block` decision that quotes the sentence or the citation; a reply that cites a run a tool result shows passes.
|
|
50
|
+
- **Final reply.** Input is a closing reply that says "My guess is one to two hours." or cites a run no tool result shows. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_final_reply_gate.py -q`. The adjacent test observes a `block` decision that quotes the sentence or the citation; a reply that cites a run a tool result shows passes. A reply with a plain-text `D:\Reports\.claude\report.html` is blocked; the same path in backticks or a fenced block passes.
|
|
51
51
|
- **Open tasks.** Input is a session whose task list still has "rotate the deploy key" pending and a closing reply of "Done.". Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_open_tasks_stop_gate.py -q`. The adjacent test observes a `block` decision that names the item; a reply line `Blocked: rotate the deploy key - <what the user must give>` passes.
|
|
52
52
|
- **Claim judge.** Input is a headless `claude -p` session with a stub MCP server named `hearthbot` that exposes `reply` and `ask_decision`, told to send "The code lets any of them rewrite it mid-check." The session settings add the `hooks.json` prompt hook. The stub logs zero bytes sent, and the tool error reads `PreToolUse:mcp__hearthbot__reply hook error: [<the prompt>]: Fact with no source: ...`. The same sentence with a `file:line` in backticks posts. A hook whose `model` Claude Code does not recognize lets every message through, so leave `model` unset. Run `node --test packages/claude-dev-env/bin/install.prompt-hook.test.mjs` for the installer side; it observes one managed prompt hook after two installs and none after uninstall.
|
|
53
53
|
- **Chat reply.** Input is a `MessageDisplay` event whose message holds four sentences, the same message split over two batches, and a `UserPromptSubmit` event. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_chat_reply_gate.py -q`. The adjacent test observes the drafter's text in `displayContent`, an empty first batch, no output for three sentences, the original after two failed drafts, the shown draft in the next `additionalContext`, and the stored draft deleted at session end.
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"""Constants for the final reply gate Stop hook."""
|
|
2
2
|
|
|
3
|
+
import re
|
|
4
|
+
|
|
3
5
|
HOOK_EVENT_NAME = "Stop"
|
|
4
6
|
LAST_ASSISTANT_MESSAGE_KEY = "last_assistant_message"
|
|
5
7
|
TRANSCRIPT_PATH_KEY = "transcript_path"
|
|
@@ -8,3 +10,57 @@ BLOCK_DECISION = "block"
|
|
|
8
10
|
REASON_KEY = "reason"
|
|
9
11
|
ALLOW_EXIT_CODE = 0
|
|
10
12
|
REWRITE_INSTRUCTION = " Rewrite the reply without it."
|
|
13
|
+
NON_SPACE_TOKEN_PATTERN = re.compile(r"\S+")
|
|
14
|
+
ESCAPED_PUNCTUATION_PATTERN = re.compile(r"(?<!\\)(?:\\\\)*\\[!-/:-@\[\]^_`{-~]")
|
|
15
|
+
HARD_BREAK_PATTERN = re.compile(r"(?<!\\)(?:\\\\)*\\\Z")
|
|
16
|
+
CARRIAGE_RETURN_LINE_BREAK = "\r\n"
|
|
17
|
+
LINE_BREAK = "\n"
|
|
18
|
+
BACKTICK_RUN_PATTERN = re.compile(r"`+")
|
|
19
|
+
ALL_HTML_BLOCK_TAG_NAMES = (
|
|
20
|
+
"address", "article", "aside", "base", "basefont", "blockquote", "body", "caption", "center",
|
|
21
|
+
"col", "colgroup", "dd", "details", "dialog", "dir", "div", "dl", "dt", "fieldset",
|
|
22
|
+
"figcaption", "figure", "footer", "form", "frame", "frameset", "h1", "h2", "h3", "h4", "h5",
|
|
23
|
+
"h6", "head", "header", "hr", "html", "iframe", "legend", "li", "link", "main", "menu",
|
|
24
|
+
"menuitem", "nav", "noframes", "ol", "optgroup", "option", "p", "param", "search", "section",
|
|
25
|
+
"summary", "table", "tbody", "td", "tfoot", "th", "thead", "title", "tr", "track", "ul",
|
|
26
|
+
)
|
|
27
|
+
HTML_BLOCK_OPENER_PATTERN = re.compile(
|
|
28
|
+
r"^ {0,3}(?:"
|
|
29
|
+
r"(?P<raw><(?:pre|script|style|textarea)(?:[\s>]|$))"
|
|
30
|
+
r"|(?P<comment><!--)"
|
|
31
|
+
r"|(?P<instruction><\?)"
|
|
32
|
+
r"|(?P<cdata><!\[CDATA\[)"
|
|
33
|
+
r"|(?P<declaration><![A-Za-z])"
|
|
34
|
+
r"|(?P<block></?(?:" + "|".join(ALL_HTML_BLOCK_TAG_NAMES) + r")(?:[\s>]|/>|$))"
|
|
35
|
+
r")",
|
|
36
|
+
re.IGNORECASE | re.MULTILINE,
|
|
37
|
+
)
|
|
38
|
+
ALL_HTML_BLOCK_CLOSER_PATTERNS = {
|
|
39
|
+
"raw": re.compile(r"</(?:pre|script|style|textarea)>[^\n]*", re.IGNORECASE),
|
|
40
|
+
"comment": re.compile(r"-->[^\n]*"),
|
|
41
|
+
"instruction": re.compile(r"\?>[^\n]*"),
|
|
42
|
+
"cdata": re.compile(r"\]\]>[^\n]*"),
|
|
43
|
+
"declaration": re.compile(r">[^\n]*"),
|
|
44
|
+
"block": re.compile(r"(?=\n[ \t]*\n)|\Z"),
|
|
45
|
+
}
|
|
46
|
+
BLOCK_BOUNDARY_PATTERN = re.compile(r"\n[ \t]*\n|\n(?= {0,3}(?:[-*+]|1[.)])[ \t]+\S)")
|
|
47
|
+
QUOTE_MARKER_PATTERN = re.compile(r"(?: {0,3}>[ \t]?)*")
|
|
48
|
+
QUOTE_MARKER = ">"
|
|
49
|
+
MASKED_BLOCK_TEXT = "\n\n"
|
|
50
|
+
HEADING_LINE_PATTERN = re.compile(
|
|
51
|
+
r"^( {0,3}(?:#{1,6}(?:[ \t][^\n]*)?|=+[ \t]*|(?:-[ \t]*)+|(?:\*[ \t]*){3,}|(?:_[ \t]*){3,}))$",
|
|
52
|
+
re.MULTILINE,
|
|
53
|
+
)
|
|
54
|
+
HEADING_AS_OWN_BLOCK = "\n\n\\1\n\n"
|
|
55
|
+
PARAGRAPH_SEPARATOR = "\n\n"
|
|
56
|
+
BACKSLASH = "\\"
|
|
57
|
+
BACKSLASHES_PER_LITERAL = 2
|
|
58
|
+
MASKED_CODE_TEXT = " "
|
|
59
|
+
FENCE_OPENER_PATTERN = re.compile(r"^ {0,3}(?:(`{3,})[^`\n]*|(~{3,})[^\n]*)\n", re.MULTILINE)
|
|
60
|
+
FENCE_CLOSER_PREFIX = r"^ {0,3}"
|
|
61
|
+
FENCE_CLOSER_SUFFIX = r"*[ \t]*$"
|
|
62
|
+
WINDOWS_PATH_MESSAGE = (
|
|
63
|
+
'Path "{path}" sits outside a code span. The terminal renders'
|
|
64
|
+
" markdown and drops a backslash before punctuation, so a copied path"
|
|
65
|
+
" points nowhere. Wrap the path in backticks."
|
|
66
|
+
)
|
|
@@ -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
|
-
"
|
|
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> "
|
|
@@ -114,7 +114,9 @@ REVIEW_LOOP_CONTEXT_TEMPLATE: str = (
|
|
|
114
114
|
"asked for a draft or stopped at opening it.\n"
|
|
115
115
|
"1. Review loop. Load the efficient-review skill (/efficient-review). Review the\n"
|
|
116
116
|
" branch, check each finding against the code, fix every one that holds, push,\n"
|
|
117
|
-
" and review again.
|
|
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"
|
|
118
120
|
"2. Mark ready. Once the loop is clean, CI is green, and no review thread is open,\n"
|
|
119
121
|
" mark the PR ready for review (gh pr ready {number} --repo {owner}/{repository},\n"
|
|
120
122
|
" or update_pull_request with draft false).\n"
|