claude-dev-env 8.92.1 → 8.92.3

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.
@@ -53,7 +53,10 @@ try:
53
53
  ALL_PRESET_REPOSITORY_DIGESTS,
54
54
  ALL_SHELL_COMMAND_INPUT_KEYS,
55
55
  ALL_SHELL_TOOL_NAMES,
56
+ ALL_PYTHON_SCRIPTLESS_OPTIONS,
56
57
  ALREADY_EXISTS_MARKER,
58
+ BACKSLASH,
59
+ DOUBLED_BACKSLASH,
57
60
  GH_API_POST_METHOD,
58
61
  GH_API_PULLS_ENDPOINT_PATTERN,
59
62
  GH_API_SUBCOMMAND,
@@ -68,14 +71,28 @@ try:
68
71
  MCP_TOOL_NAME_PREFIX,
69
72
  NAME_DIGEST_TEXT_ENCODING,
70
73
  PULL_REQUEST_URL_PATTERN,
74
+ PYTHON_OPTION_AND_VALUE_WORD_COUNT,
75
+ PYTHON_OPTION_PREFIX,
76
+ PYTHON_SHORT_OPTION_LENGTH,
71
77
  REVIEW_LOOP_CONTEXT_TEMPLATE,
72
78
  SHELL_ARGUMENT_SEPARATOR,
73
79
  )
74
80
  from hooks_constants.post_tool_use_context import (
75
81
  write_post_tool_use_context_to_stdout,
76
82
  )
83
+ from hooks_constants.pr_lifecycle_skill_gate_constants import PYTHON_OPTIONS_WITH_VALUE
77
84
  from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
78
- from hooks_constants.shell_command_segments import command_tokens, split_into_segments
85
+ from hooks_constants.pull_request_proof_constants import (
86
+ ALL_PYTHON_PROGRAM_NAMES,
87
+ PULL_REQUEST_SCRIPT_CREATE_WORD,
88
+ PULL_REQUEST_SCRIPT_NAME,
89
+ )
90
+ from hooks_constants.pytest_invocation import unquoted_token
91
+ from hooks_constants.shell_command_segments import (
92
+ command_tokens,
93
+ split_into_segments,
94
+ token_basename,
95
+ )
79
96
  from hooks_constants.shell_command_wrappers import (
80
97
  all_wrapped_command_texts,
81
98
  segment_program_and_arguments,
@@ -127,8 +144,34 @@ def _is_gh_api_pulls_post(all_arguments: list[str]) -> bool:
127
144
  return sends_body
128
145
 
129
146
 
147
+ def _python_script_index(all_arguments: list[str]) -> int | None:
148
+ index = 0
149
+ while index < len(all_arguments) and all_arguments[index].startswith(PYTHON_OPTION_PREFIX):
150
+ if all_arguments[index][:PYTHON_SHORT_OPTION_LENGTH] in ALL_PYTHON_SCRIPTLESS_OPTIONS:
151
+ return None
152
+ is_option_with_value = all_arguments[index] in PYTHON_OPTIONS_WITH_VALUE
153
+ index += PYTHON_OPTION_AND_VALUE_WORD_COUNT if is_option_with_value else 1
154
+ return index if index < len(all_arguments) else None
155
+
156
+
157
+ def _is_pull_request_script_create(program_name: str, all_arguments: list[str]) -> bool:
158
+ all_script_arguments = all_arguments
159
+ if program_name != PULL_REQUEST_SCRIPT_NAME:
160
+ script_index = (
161
+ _python_script_index(all_arguments) if program_name in ALL_PYTHON_PROGRAM_NAMES else None
162
+ )
163
+ if script_index is None:
164
+ return False
165
+ if token_basename(unquoted_token(all_arguments[script_index])) != PULL_REQUEST_SCRIPT_NAME:
166
+ return False
167
+ all_script_arguments = all_arguments[script_index + 1 :]
168
+ return all_script_arguments[:1] == [PULL_REQUEST_SCRIPT_CREATE_WORD]
169
+
170
+
130
171
  def _segment_opens_pull_request(all_segment_tokens: list[str]) -> bool:
131
172
  program_name, all_arguments = segment_program_and_arguments(all_segment_tokens)
173
+ if _is_pull_request_script_create(program_name, all_arguments):
174
+ return True
132
175
  return program_name == GH_PROGRAM_NAME and (
133
176
  _is_gh_pr_create(all_arguments) or _is_gh_api_pulls_post(all_arguments)
134
177
  )
@@ -137,11 +180,18 @@ def _segment_opens_pull_request(all_segment_tokens: list[str]) -> bool:
137
180
  def command_creates_pull_request(command: str) -> bool:
138
181
  """Return True when any segment of the shell command opens a pull request.
139
182
 
140
- Three shapes count::
183
+ Four shapes count::
141
184
 
142
185
  gh pr create --fill
143
186
  gh pr new --fill
144
187
  gh api repos/acme/widgets/pulls --input pr.json
188
+ python ~/.agents/skills/pull-request/scripts/pull_request.py create --repo acme/widgets
189
+
190
+ The publisher counts when ``python``, ``python3``, or ``py`` runs a
191
+ script named ``pull_request.py`` with ``create``, or the script runs
192
+ alone. Interpreter options such as ``-X utf8`` before the script are
193
+ skipped, and an unquoted Windows path such as
194
+ ``C:\\tools\\pull_request.py`` keeps its separators.
145
195
 
146
196
  A ``gh api`` call counts only when it targets the pulls collection. An
147
197
  explicit method (``-X POST``, ``-XPOST``, ``--method=POST``) decides
@@ -157,7 +207,11 @@ def command_creates_pull_request(command: str) -> bool:
157
207
  return any(
158
208
  _segment_opens_pull_request(each_segment)
159
209
  for each_command_text in all_wrapped_command_texts(command)
160
- for each_segment in split_into_segments(command_tokens(each_command_text))
210
+ for each_command_tokens in (
211
+ command_tokens(each_command_text),
212
+ command_tokens(each_command_text.replace(BACKSLASH, DOUBLED_BACKSLASH)),
213
+ )
214
+ for each_segment in split_into_segments(each_command_tokens)
161
215
  )
162
216
 
163
217
 
@@ -85,6 +85,13 @@ def _context_of(stdout: str) -> str:
85
85
  "gh api --method=POST repos/acme/widgets/pulls",
86
86
  "gh api -XPOST repos/acme/widgets/pulls",
87
87
  "gh api repos/acme/widgets/pulls -Ftitle=t -fhead=x",
88
+ 'python "C:/Users/me/.agents/skills/pull-request/scripts/pull_request.py" create --repo acme/widgets',
89
+ "python3 ~/.agents/skills/pull-request/scripts/pull_request.py create --title t",
90
+ "py pull_request.py create --fill",
91
+ "git push && python pull_request.py create --repo acme/widgets 2>&1 | tail -5",
92
+ "python -X utf8 ~/.agents/skills/pull-request/scripts/pull_request.py create --fill",
93
+ "python -I -W ignore pull_request.py create",
94
+ "python C:\\Users\\me\\.agents\\skills\\pull-request\\scripts\\pull_request.py create",
88
95
  ],
89
96
  )
90
97
  def test_should_detect_gh_pr_create(command: str) -> None:
@@ -103,6 +110,12 @@ def test_should_detect_gh_pr_create(command: str) -> None:
103
110
  "gh api repos/acme/widgets/issues --input issue.json",
104
111
  "gh api --method GET repos/acme/widgets/pulls -f state=open",
105
112
  "gh api -X GET repos/acme/widgets/pulls -f state=open",
113
+ "python pull_request.py edit --repo acme/widgets --number 42",
114
+ "python other_script.py create",
115
+ "python pull_request.py",
116
+ "python -m pull_request.py create",
117
+ "python -c pull_request.py create",
118
+ "python C:\\Users\\me\\scripts\\my_pull_request.py create",
106
119
  ],
107
120
  )
108
121
  def test_should_not_detect_other_commands(command: str) -> None:
@@ -325,6 +338,16 @@ def test_main_should_emit_the_loop_after_a_gh_api_pulls_post() -> None:
325
338
  assert "jl-cmd-projects/claude-dev-env#42" in _context_of(stdout)
326
339
 
327
340
 
341
+ def test_main_should_emit_the_loop_after_the_pull_request_publisher_creates() -> None:
342
+ publisher_command = (
343
+ 'python "C:/Users/me/.agents/skills/pull-request/scripts/pull_request.py" create'
344
+ " --repo jl-cmd-projects/claude-dev-env --base main --head fix/x --title t"
345
+ " --body-file body.md 2>&1 | tail -5"
346
+ )
347
+ stdout = _run_hook(_shell_payload(publisher_command, f"{OWNED_PR_URL}\n"))
348
+ assert "jl-cmd-projects/claude-dev-env#42" in _context_of(stdout)
349
+
350
+
328
351
  def test_main_should_emit_the_loop_for_a_codex_shell_call() -> None:
329
352
  stdout = _run_hook(
330
353
  _shell_payload(
@@ -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. A block sends
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
@@ -23,7 +23,7 @@ This family rewrites a Git Bash command when path conversion would change a revi
23
23
  - `blocking/cloud_graphql_gate.py` runs inside that dispatcher on `PreToolUse`, matcher `Bash`, timeout `60` seconds, for the Bash and PowerShell tools, when `CLAUDE_CODE_REMOTE` is `true`.
24
24
  - `blocking/bash_post_call_dispatcher.py` runs on `PostToolUse`, matcher `Bash|PowerShell`, timeout `60` seconds in `hooks.json`.
25
25
  - `advisory/pr_done_reminder.py` runs inside that dispatcher on `PostToolUse`, matcher `Bash|PowerShell`, timeout `60` seconds. `ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES` selects `Bash` and `PowerShell`.
26
- - `advisory/owned_pr_review_loop.py` runs on `PostToolUse`, matcher `Bash|PowerShell|exec_command|shell_command|mcp__.*__(create_pull_request|github_create_pull_request)`, timeout `10` seconds in `hooks.json`. It wakes on `gh pr create`, `gh pr new`, a POST to `gh api repos/<owner>/<repo>/pulls`, and an MCP pull request creation. The installer also registers it on Codex `PostToolUse` through `ALL_CODEX_NATIVE_POST_TOOL_USE_HOOK_SCRIPT_NAMES`.
26
+ - `advisory/owned_pr_review_loop.py` runs on `PostToolUse`, matcher `Bash|PowerShell|exec_command|shell_command|mcp__.*__(create_pull_request|github_create_pull_request)`, timeout `10` seconds in `hooks.json`. It wakes on `gh pr create`, `gh pr new`, a POST to `gh api repos/<owner>/<repo>/pulls`, a `pull_request.py create` call to the pull request publisher, and an MCP pull request creation. The installer also registers it on Codex `PostToolUse` through `ALL_CODEX_NATIVE_POST_TOOL_USE_HOOK_SCRIPT_NAMES`.
27
27
  - `blocking/owned_pr_merge_stop_gate.py` runs on `Stop`, timeout `60` seconds in `hooks.json`. The installer also registers it on Codex `Stop` through `ALL_CODEX_NATIVE_STOP_HOOK_SCRIPT_NAMES`.
28
28
 
29
29
  ## Proving it
@@ -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.
@@ -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
+ )
@@ -39,6 +39,12 @@ __all__ = [
39
39
  "GH_SHORT_OPTION_LENGTH",
40
40
  "ALL_GH_API_SHORT_OPTIONS",
41
41
  "GH_OPTION_VALUE_SEPARATOR",
42
+ "PYTHON_OPTION_PREFIX",
43
+ "PYTHON_SHORT_OPTION_LENGTH",
44
+ "PYTHON_OPTION_AND_VALUE_WORD_COUNT",
45
+ "ALL_PYTHON_SCRIPTLESS_OPTIONS",
46
+ "BACKSLASH",
47
+ "DOUBLED_BACKSLASH",
42
48
  "MCP_TOOL_NAME_PREFIX",
43
49
  "ALL_MCP_CREATE_PULL_REQUEST_TOOL_SUFFIXES",
44
50
  "MCP_OWNER_INPUT_KEY",
@@ -85,6 +91,12 @@ GH_LONG_OPTION_PREFIX: str = "--"
85
91
  GH_SHORT_OPTION_LENGTH: int = 2
86
92
  ALL_GH_API_SHORT_OPTIONS: frozenset[str] = frozenset({"-f", "-F", "-X"})
87
93
  GH_OPTION_VALUE_SEPARATOR: str = "="
94
+ PYTHON_OPTION_PREFIX: str = "-"
95
+ PYTHON_SHORT_OPTION_LENGTH: int = 2
96
+ PYTHON_OPTION_AND_VALUE_WORD_COUNT: int = 2
97
+ ALL_PYTHON_SCRIPTLESS_OPTIONS: frozenset[str] = frozenset({"-m", "-c"})
98
+ BACKSLASH: str = "\\"
99
+ DOUBLED_BACKSLASH: str = "\\\\"
88
100
 
89
101
  MCP_TOOL_NAME_PREFIX: str = "mcp__"
90
102
  ALL_MCP_CREATE_PULL_REQUEST_TOOL_SUFFIXES: tuple[str, ...] = (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.92.1",
3
+ "version": "8.92.3",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {