claude-dev-env 1.24.0 → 1.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,19 +14,20 @@ logical line, then use shlex.split(..., posix=False) on that line so '--body'
14
14
  appearing inside a quoted flag value or in heredoc body content on non-continuation
15
15
  lines does not trigger a false positive. Both '--body value' and '--body=value' forms are blocked,
16
16
  as are their short '-b' equivalents. '--body-file' and '--body-file=...' are allowed.
17
- Falls back to a conservative approve if the logical line is unparseable.
17
+ Fails CLOSED on shlex ValueError when the logical line matches an affected
18
+ subcommand AND contains a bare '--body'/'-b' literal (exactly the heredoc
19
+ pattern this hook must catch); otherwise approves unparseable input.
18
20
  """
19
21
 
20
22
  import json
21
23
  import re
22
- import shlex
23
24
  import sys
24
25
 
25
26
  from _gh_body_arg_utils import (
26
27
  all_body_flags,
27
28
  all_body_flag_prefixes,
28
- all_value_flags,
29
29
  get_logical_first_line,
30
+ iter_significant_tokens,
30
31
  )
31
32
 
32
33
  _GH_BODY_SUBCOMMANDS = re.compile(
@@ -37,6 +38,10 @@ _GH_BODY_SUBCOMMANDS = re.compile(
37
38
  re.IGNORECASE,
38
39
  )
39
40
 
41
+ _BARE_BODY_TOKEN_PATTERN = re.compile(
42
+ r"(?<!\S)(?:--body|-b)(?:=|(?![-\w]))",
43
+ )
44
+
40
45
  _BASH_TOOL_NAME = "Bash"
41
46
 
42
47
  _CORRECTIVE_MESSAGE = (
@@ -49,16 +54,21 @@ _CORRECTIVE_MESSAGE = (
49
54
  " f.write(body_text)\n"
50
55
  " body_path = f.name\n"
51
56
  " # then: gh ... --body-file body_path\n\n"
52
- "Safe PowerShell pattern:\n"
57
+ "Safe PowerShell pattern (BOM-free, works on 5.1 and 7+):\n"
53
58
  " $bodyPath = [System.IO.Path]::ChangeExtension((New-TemporaryFile).FullName, '.md')\n"
54
- " @'\n"
59
+ " $body = @'\n"
55
60
  " <your markdown body>\n"
56
- " '@ | Set-Content -Path $bodyPath -Encoding utf8\n"
61
+ " '@\n"
62
+ " [IO.File]::WriteAllText($bodyPath, $body, [Text.UTF8Encoding]::new($false))\n"
57
63
  " gh ... --body-file $bodyPath\n\n"
58
64
  "See ~/.claude/rules/gh-body-file.md for full guidance."
59
65
  )
60
66
 
61
67
 
68
+ def _logical_line_has_bare_body_token(logical_line: str) -> bool:
69
+ return bool(_BARE_BODY_TOKEN_PATTERN.search(logical_line))
70
+
71
+
62
72
  def _uses_body_string_arg(command: str) -> bool:
63
73
  """Return True if command calls an affected gh subcommand with --body <string>.
64
74
 
@@ -68,26 +78,22 @@ def _uses_body_string_arg(command: str) -> bool:
68
78
  and quoted values retain their surrounding quotes as part of the token, so
69
79
  '--body' embedded in a quoted value cannot be mistaken for a standalone flag.
70
80
  Detects both '--body value'/'--body=value' forms and their short '-b'
71
- equivalents. Falls back to a conservative approve if the line is unparseable.
81
+ equivalents. Fails CLOSED on shlex ValueError when the logical line matches
82
+ an affected subcommand and contains a bare --body / -b token literal, since
83
+ heredoc-wrapped --body arguments are exactly the pattern this hook exists
84
+ to block; otherwise approves unparseable input (out of scope).
72
85
  """
73
- logical_line = get_logical_first_line(command)
74
- if not _GH_BODY_SUBCOMMANDS.search(logical_line):
86
+ if not _GH_BODY_SUBCOMMANDS.search(get_logical_first_line(command)):
75
87
  return False
76
88
  try:
77
- tokens = shlex.split(logical_line, posix=False)
89
+ significant_tokens = list(iter_significant_tokens(command))
78
90
  except ValueError:
79
- return False
80
- should_skip_next_token = False
81
- for each_token in tokens:
82
- if should_skip_next_token:
83
- should_skip_next_token = False
84
- continue
85
- if each_token in all_body_flags or any(
86
- each_token.startswith(each_prefix) for each_prefix in all_body_flag_prefixes
87
- ):
91
+ return _logical_line_has_bare_body_token(get_logical_first_line(command))
92
+ for each_token, _remaining_tokens in significant_tokens:
93
+ if each_token in all_body_flags:
94
+ return True
95
+ if any(each_token.startswith(each_prefix) for each_prefix in all_body_flag_prefixes):
88
96
  return True
89
- if each_token in all_value_flags:
90
- should_skip_next_token = True
91
97
  return False
92
98
 
93
99
 
@@ -2,6 +2,23 @@ import json
2
2
  import os
3
3
  import re
4
4
  import sys
5
+ from pathlib import Path
6
+
7
+ import shlex
8
+
9
+ from _gh_body_arg_utils import (
10
+ all_body_flag_prefixes,
11
+ all_body_flags,
12
+ all_value_flag_equals_prefixes,
13
+ all_value_flags,
14
+ body_file_flag,
15
+ body_file_flag_prefix,
16
+ body_file_short_flag,
17
+ body_file_short_flag_prefix,
18
+ count_extra_tokens_to_skip_for_split_quoted_value,
19
+ get_logical_first_line,
20
+ iter_significant_tokens,
21
+ )
5
22
 
6
23
  PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
7
24
  PR_GUIDE_PATH = os.path.join(PLUGIN_ROOT, "docs", "PR_DESCRIPTION_GUIDE.md")
@@ -20,20 +37,236 @@ VAGUE_LANGUAGE_PATTERN = re.compile(
20
37
  )
21
38
 
22
39
 
23
- def extract_body_from_command(command: str) -> str:
24
- heredoc_match = re.search(r'--body\s+"\$\(cat <<', command)
25
- if heredoc_match:
26
- return command[heredoc_match.start():]
40
+ shell_variable_sigil: str = "$"
41
+ body_file_stdin_sentinel: str = "-"
42
+ all_quote_characters: frozenset[str] = frozenset({'"', "'"})
43
+ file_encoding_utf8: str = "utf-8"
44
+
45
+ _non_body_value_flags: frozenset[str] = all_value_flags - {body_file_flag, body_file_short_flag}
46
+
47
+ _non_body_value_flag_equals_prefixes: tuple[str, ...] = tuple(
48
+ sorted(
49
+ (
50
+ prefix for prefix in all_value_flag_equals_prefixes
51
+ if not prefix.startswith("--body") and not prefix.startswith("-b=")
52
+ ),
53
+ key=len,
54
+ reverse=True,
55
+ )
56
+ )
57
+
58
+
59
+ class PathTraversalError(Exception):
60
+ pass
61
+
62
+ def _is_flag_shaped_token(token: str) -> bool:
63
+ if len(token) < 2:
64
+ return False
65
+ if not token.startswith("-"):
66
+ return False
67
+ return token[1] == "-" or token[1].isalpha()
68
+
69
+
70
+ def _strip_surrounding_quotes(token: str) -> str:
71
+ if len(token) < 2:
72
+ return token
73
+ first_character = token[0]
74
+ last_character = token[-1]
75
+ if first_character in all_quote_characters and first_character == last_character:
76
+ return token[1:-1]
77
+ return token
78
+
79
+
80
+ def _is_unresolvable_shell_value(token: str) -> bool:
81
+ return token.startswith(shell_variable_sigil)
82
+
83
+
84
+ def _read_body_file_contents(file_path: str) -> str | None:
85
+ given_path = Path(file_path)
86
+ allowed_root = Path.cwd().resolve()
87
+ if given_path.is_symlink():
88
+ resolved_target = given_path.resolve()
89
+ try:
90
+ resolved_target.relative_to(allowed_root)
91
+ except ValueError:
92
+ raise PathTraversalError("symlink target resolves outside allowed root")
93
+ resolved_path = given_path.resolve()
94
+ if not given_path.is_absolute():
95
+ try:
96
+ resolved_path.relative_to(allowed_root)
97
+ except ValueError:
98
+ raise PathTraversalError("relative path resolves outside allowed root")
99
+ try:
100
+ with open(resolved_path, "r", encoding=file_encoding_utf8, errors="replace") as body_file:
101
+ return body_file.read()
102
+ except (FileNotFoundError, IsADirectoryError, PermissionError, OSError):
103
+ return None
104
+
105
+
106
+ def _resolve_body_file_value(raw_value_token: str) -> str | None:
107
+ """Return file contents, or None when the body cannot be audited.
108
+
109
+ None means body is present but unauditable -- skip enforcement.
110
+ This covers: stdin sentinel, unresolvable shell variables, and path-traversal-rejected paths.
111
+ """
112
+ stripped_value = _strip_surrounding_quotes(raw_value_token)
113
+ if not stripped_value:
114
+ return None
115
+ if stripped_value == body_file_stdin_sentinel:
116
+ return None
117
+ if _is_unresolvable_shell_value(stripped_value):
118
+ return None
119
+ try:
120
+ return _read_body_file_contents(stripped_value)
121
+ except PathTraversalError:
122
+ return None
123
+
124
+
125
+ def _resolve_body_string_value(raw_value_token: str) -> str:
126
+ stripped_value = _strip_surrounding_quotes(raw_value_token)
127
+ if _is_unresolvable_shell_value(stripped_value):
128
+ return ""
129
+ return stripped_value
130
+
131
+
132
+ def _reassemble_split_quoted_value(first_value_token: str, remaining_tokens: list[str]) -> str | None:
133
+ extra_tokens_consumed = count_extra_tokens_to_skip_for_split_quoted_value(
134
+ remaining_tokens,
135
+ first_value_token,
136
+ )
137
+ if extra_tokens_consumed is None:
138
+ return None
139
+ if extra_tokens_consumed == 0:
140
+ return first_value_token
141
+ continuation_tokens = remaining_tokens[:extra_tokens_consumed]
142
+ return " ".join([first_value_token, *continuation_tokens])
143
+
144
+
145
+ def _match_body_flag_equals_prefix(token: str) -> str | None:
146
+ for each_prefix in all_body_flag_prefixes:
147
+ if token.startswith(each_prefix):
148
+ return each_prefix
149
+ return None
150
+
151
+
152
+ def _match_body_file_equals_prefix(token: str) -> str | None:
153
+ for each_prefix in (body_file_flag_prefix, body_file_short_flag_prefix):
154
+ if token.startswith(each_prefix):
155
+ return each_prefix
156
+ return None
157
+
158
+
159
+ def _match_non_body_value_flag_equals_prefix(token: str) -> str | None:
160
+ for each_prefix in _non_body_value_flag_equals_prefixes:
161
+ if token.startswith(each_prefix):
162
+ return each_prefix
163
+ return None
27
164
 
28
- body_match = re.search(r'--body\s+"([^"]*)"', command) or re.search(r"--body\s+'([^']*)'", command)
29
- if body_match:
30
- return body_match.group(1)
31
165
 
32
- short_flag_match = re.search(r'-b\s+"([^"]*)"', command) or re.search(r"-b\s+'([^']*)'", command)
33
- if short_flag_match:
34
- return short_flag_match.group(1)
166
+ def _scan_raw_tokens_for_body(all_raw_tokens: list[str]) -> str | None | bool:
167
+ """Return the body value from a raw token list, or False if no body flag found.
35
168
 
36
- return ""
169
+ Returns False when no body/body-file flag is present (caller should continue).
170
+ Returns None when a body-file flag is present but malformed (no value follows).
171
+ Returns str for body string values (may be empty for shell vars/sentinels).
172
+ """
173
+ token_index = 0
174
+ while token_index < len(all_raw_tokens):
175
+ current_token = all_raw_tokens[token_index]
176
+ remaining_raw = all_raw_tokens[token_index + 1:]
177
+ non_body_equals_prefix = _match_non_body_value_flag_equals_prefix(current_token)
178
+ if non_body_equals_prefix is not None:
179
+ first_value_token = current_token[len(non_body_equals_prefix):]
180
+ extra_skip = count_extra_tokens_to_skip_for_split_quoted_value(remaining_raw, first_value_token)
181
+ token_index += 1 + (extra_skip or 0)
182
+ continue
183
+ if current_token in _non_body_value_flags:
184
+ if remaining_raw and not _is_flag_shaped_token(remaining_raw[0]):
185
+ first_value_token = remaining_raw[0]
186
+ extra_skip = count_extra_tokens_to_skip_for_split_quoted_value(remaining_raw[1:], first_value_token)
187
+ token_index += 1 + 1 + (extra_skip or 0)
188
+ continue
189
+ token_index += 1
190
+ continue
191
+ body_equals_prefix = _match_body_flag_equals_prefix(current_token)
192
+ if body_equals_prefix is not None:
193
+ first_value_token = current_token[len(body_equals_prefix):]
194
+ full_value_token = _reassemble_split_quoted_value(first_value_token, remaining_raw)
195
+ if full_value_token is None:
196
+ return None
197
+ return _resolve_body_string_value(full_value_token)
198
+ body_file_equals_prefix = _match_body_file_equals_prefix(current_token)
199
+ if body_file_equals_prefix is not None:
200
+ first_value_token = current_token[len(body_file_equals_prefix):]
201
+ full_value_token = _reassemble_split_quoted_value(first_value_token, remaining_raw)
202
+ if full_value_token is None:
203
+ return None
204
+ return _resolve_body_file_value(full_value_token)
205
+ if current_token in all_body_flags:
206
+ if not remaining_raw or _is_flag_shaped_token(remaining_raw[0]):
207
+ return None
208
+ first_value_token = remaining_raw[0]
209
+ full_value_token = _reassemble_split_quoted_value(first_value_token, remaining_raw[1:])
210
+ if full_value_token is None:
211
+ return None
212
+ return _resolve_body_string_value(full_value_token)
213
+ if current_token in {body_file_flag, body_file_short_flag}:
214
+ if not remaining_raw or _is_flag_shaped_token(remaining_raw[0]):
215
+ return None
216
+ first_value_token = remaining_raw[0]
217
+ full_value_token = _reassemble_split_quoted_value(first_value_token, remaining_raw[1:])
218
+ if full_value_token is None:
219
+ return None
220
+ return _resolve_body_file_value(full_value_token)
221
+ token_index += 1
222
+ return False
223
+
224
+
225
+ def extract_body_from_command(
226
+ command: str,
227
+ pre_tokenized: tuple[str, list[str]] | None = None,
228
+ ) -> str | None:
229
+ """Return the PR body content for validation, or None if unextractable.
230
+
231
+ Uses iter_significant_tokens to skip values of non-body value-taking flags
232
+ so that --body/--body-file embedded in a quoted --title value never false-matches.
233
+ For space-form body-file flags, scans the raw token list directly because
234
+ iter_significant_tokens consumes the value token (yielding remaining-after-value).
235
+
236
+ If pre_tokenized is provided as (logical_line, raw_tokens), reuses those instead
237
+ of recomputing the logical line and shlex split a second time.
238
+ """
239
+ if pre_tokenized is not None:
240
+ logical_line, all_raw_tokens = pre_tokenized
241
+ else:
242
+ logical_line = get_logical_first_line(command)
243
+ if not logical_line:
244
+ return None
245
+ try:
246
+ all_raw_tokens = shlex.split(logical_line, posix=False)
247
+ except ValueError:
248
+ return None
249
+ try:
250
+ all_significant_tokens = list(
251
+ iter_significant_tokens(command, pre_tokenized=(logical_line, all_raw_tokens))
252
+ )
253
+ except ValueError:
254
+ return None
255
+
256
+ significant_token_set = {each_token for each_token, _ in all_significant_tokens}
257
+ body_flag_found_in_significant = (
258
+ any(each_token in all_body_flags for each_token in significant_token_set)
259
+ or any(_match_body_flag_equals_prefix(each_token) is not None for each_token in significant_token_set)
260
+ or any(_match_body_file_equals_prefix(each_token) is not None for each_token in significant_token_set)
261
+ or any(each_token in {body_file_flag, body_file_short_flag} for each_token in significant_token_set)
262
+ )
263
+ if not body_flag_found_in_significant:
264
+ return None
265
+
266
+ result = _scan_raw_tokens_for_body(all_raw_tokens)
267
+ if result is False:
268
+ return None
269
+ return result
37
270
 
38
271
 
39
272
  def validate_pr_body(body: str) -> list[str]:
@@ -83,6 +316,9 @@ def main() -> None:
83
316
 
84
317
  body = extract_body_from_command(command)
85
318
 
319
+ if body is None:
320
+ sys.exit(0)
321
+
86
322
  if not body:
87
323
  sys.exit(0)
88
324
 
@@ -1,12 +1,15 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- BDD Automate-phase reminder (production code touch).
3
+ BDD Automate-phase gate (production code touch).
4
4
 
5
- Prompts confirmation when writing or editing production code files.
6
- Skips: Test files, config files, documentation.
5
+ Blocks writes to production source files when no matching test exists
6
+ or the matching test has not been modified within the configured
7
+ freshness window. Enforces "TDD IS NON-NEGOTIABLE" from CLAUDE.md.
7
8
  """
8
9
  import json
10
+ import re
9
11
  import sys
12
+ import time
10
13
  from pathlib import Path
11
14
 
12
15
  PRODUCTION_EXTENSIONS = {'.py', '.ts', '.tsx', '.js', '.jsx'}
@@ -17,12 +20,201 @@ SKIP_PATTERNS = {
17
20
  SKIP_EXTENSIONS = {'.md', '.json', '.yaml', '.yml', '.toml', '.ini', '.cfg', '.txt'}
18
21
 
19
22
 
23
+ def _freshness_seconds() -> int:
24
+ return 600
25
+
26
+
27
+ def _bypass_sentinel() -> str:
28
+ return "# pragma: no-tdd-gate"
29
+
30
+
31
+ def _tests_directory_name() -> str:
32
+ return "tests"
33
+
34
+
35
+ def _parent_walk_limit() -> int:
36
+ return 10
37
+
38
+
39
+ def _repo_boundary_sentinels() -> frozenset[str]:
40
+ return frozenset({".git", "pyproject.toml", "package.json", "Cargo.toml", "go.mod"})
41
+
42
+
43
+ def _test_function_patterns() -> tuple[re.Pattern[str], ...]:
44
+ return (
45
+ re.compile(r"\bdef\s+test_"),
46
+ re.compile(r"\b(?:it|test|describe)\s*\("),
47
+ )
48
+
49
+
50
+ def _directory_skip_components() -> frozenset[str]:
51
+ return frozenset({
52
+ "conftest", "fixture", "fixtures", "mock", "mocks", "stub", "stubs",
53
+ })
54
+
55
+
56
+ def _is_repo_boundary(candidate_directory: Path) -> bool:
57
+ for each_sentinel in _repo_boundary_sentinels():
58
+ if (candidate_directory / each_sentinel).exists():
59
+ return True
60
+ return False
61
+
62
+
63
+ def find_nearest_tests_directory(start_directory: Path) -> Path | None:
64
+ current_directory = start_directory
65
+ for _ in range(_parent_walk_limit()):
66
+ sibling_tests = current_directory / _tests_directory_name()
67
+ if sibling_tests.is_dir():
68
+ return sibling_tests
69
+ if _is_repo_boundary(current_directory):
70
+ return None
71
+ if current_directory.parent == current_directory:
72
+ return None
73
+ current_directory = current_directory.parent
74
+ return None
75
+
76
+
77
+ def candidate_test_paths_for(production_path: Path) -> list[Path]:
78
+ directory = production_path.parent
79
+ stem = production_path.stem
80
+ extension = production_path.suffix.lower()
81
+ all_candidates: list[Path] = []
82
+
83
+ if extension == ".py":
84
+ all_candidates.append(directory / f"test_{stem}.py")
85
+ all_candidates.append(directory / f"{stem}_test.py")
86
+ nearest_tests_directory = find_nearest_tests_directory(directory)
87
+ if nearest_tests_directory is not None:
88
+ all_candidates.append(nearest_tests_directory / f"test_{stem}.py")
89
+ return all_candidates
90
+
91
+ if extension in {".tsx", ".ts", ".jsx", ".js"}:
92
+ all_candidates.append(directory / f"{stem}.test{extension}")
93
+ all_candidates.append(directory / f"{stem}.spec{extension}")
94
+ return all_candidates
95
+
96
+ return all_candidates
97
+
98
+
99
+ def _test_file_encoding() -> str:
100
+ return "utf-8"
101
+
102
+
103
+ def _safe_mtime(candidate_path: Path) -> float | None:
104
+ try:
105
+ return candidate_path.stat().st_mtime
106
+ except (FileNotFoundError, OSError):
107
+ return None
108
+
109
+
110
+ def _read_candidate_text(candidate_path: Path) -> str | None:
111
+ try:
112
+ with candidate_path.open("r", encoding=_test_file_encoding(), errors="ignore") as each_file:
113
+ return each_file.read()
114
+ except (FileNotFoundError, OSError):
115
+ return None
116
+
117
+
118
+ def _contains_test_evidence(candidate_path: Path) -> bool:
119
+ test_file_content = _read_candidate_text(candidate_path)
120
+ if test_file_content is None:
121
+ return False
122
+ for each_pattern in _test_function_patterns():
123
+ if each_pattern.search(test_file_content):
124
+ return True
125
+ return False
126
+
127
+
128
+ def has_fresh_test(
129
+ all_candidates: list[Path],
130
+ freshness_seconds: int,
131
+ ) -> bool:
132
+ current_time = time.time()
133
+ for each_candidate in all_candidates:
134
+ candidate_mtime = _safe_mtime(each_candidate)
135
+ if candidate_mtime is None:
136
+ continue
137
+ age_seconds = current_time - candidate_mtime
138
+ if age_seconds > freshness_seconds:
139
+ continue
140
+ if not _contains_test_evidence(each_candidate):
141
+ continue
142
+ return True
143
+ return False
144
+
145
+
146
+ def build_deny_reason(production_path: Path, all_candidates: list[Path]) -> str:
147
+ candidate_lines = "\n".join(f" - {each_path}" for each_path in all_candidates)
148
+ return (
149
+ f"[TDD] Blocking write to production file: {production_path}\n"
150
+ f"No matching test file exists, or it has not been modified within the last "
151
+ f"{_freshness_seconds()} seconds.\n"
152
+ f"Expected one of:\n{candidate_lines}\n"
153
+ f"Write a failing test first (RED), then the minimum code to pass it (GREEN).\n"
154
+ f"Bypass (discouraged): include the sentinel '{_bypass_sentinel()}' in the file content."
155
+ )
156
+
157
+
158
+ def emit_allow() -> None:
159
+ allow_payload = {
160
+ "hookSpecificOutput": {
161
+ "hookEventName": "PreToolUse",
162
+ "permissionDecision": "allow",
163
+ }
164
+ }
165
+ print(json.dumps(allow_payload))
166
+
167
+
168
+ def emit_deny(reason: str) -> None:
169
+ deny_payload = {
170
+ "hookSpecificOutput": {
171
+ "hookEventName": "PreToolUse",
172
+ "permissionDecision": "deny",
173
+ "permissionDecisionReason": reason,
174
+ }
175
+ }
176
+ print(json.dumps(deny_payload))
177
+
178
+
179
+ def _matches_any_skip_pattern(name_lower: str, path_with_forward_slashes: str) -> bool:
180
+ path_components_lower = [each_part for each_part in path_with_forward_slashes.split("/") if each_part]
181
+ directory_components = path_components_lower[:-1]
182
+ skip_directory_components = _directory_skip_components()
183
+ for each_directory_component in directory_components:
184
+ if each_directory_component in skip_directory_components:
185
+ return True
186
+ for each_pattern in SKIP_PATTERNS:
187
+ if each_pattern.endswith("/"):
188
+ if each_pattern in path_with_forward_slashes:
189
+ return True
190
+ continue
191
+ if each_pattern in name_lower:
192
+ return True
193
+ return False
194
+
195
+
196
+ def _extract_written_content(tool_name: str, tool_input: dict) -> str:
197
+ if tool_name == "Write":
198
+ return tool_input.get("content", "") or ""
199
+ if tool_name == "Edit":
200
+ return tool_input.get("new_string", "") or ""
201
+ if tool_name == "MultiEdit":
202
+ all_edits = tool_input.get("edits", []) or []
203
+ joined_new_strings: list[str] = []
204
+ for each_edit in all_edits:
205
+ if isinstance(each_edit, dict):
206
+ joined_new_strings.append(each_edit.get("new_string", "") or "")
207
+ return "\n".join(joined_new_strings)
208
+ return ""
209
+
210
+
20
211
  def main() -> None:
21
212
  try:
22
213
  input_data = json.load(sys.stdin)
23
214
  except json.JSONDecodeError:
24
215
  sys.exit(0)
25
216
 
217
+ tool_name = input_data.get("tool_name", "")
26
218
  tool_input = input_data.get("tool_input", {})
27
219
  file_path = tool_input.get("file_path", "")
28
220
 
@@ -42,19 +234,22 @@ def main() -> None:
42
234
 
43
235
  # Skip test files
44
236
  name_lower = path.name.lower()
45
- path_str = str(path).lower()
46
- if any(pattern in name_lower or pattern in path_str for pattern in SKIP_PATTERNS):
237
+ path_str = str(path).lower().replace("\\", "/")
238
+ if _matches_any_skip_pattern(name_lower, path_str):
47
239
  sys.exit(0)
48
240
 
49
241
  # Block production code - require confirmation
50
- result = {
51
- "hookSpecificOutput": {
52
- "hookEventName": "PreToolUse",
53
- "permissionDecision": "allow",
54
- "additionalContext": "[BDD] Writing production code. Confirm you have a failing specification (test) first."
55
- }
56
- }
57
- print(json.dumps(result))
242
+ written_content = _extract_written_content(tool_name, tool_input)
243
+ if _bypass_sentinel() in written_content:
244
+ emit_allow()
245
+ sys.exit(0)
246
+
247
+ all_candidates = candidate_test_paths_for(path)
248
+ if has_fresh_test(all_candidates, _freshness_seconds()):
249
+ emit_allow()
250
+ sys.exit(0)
251
+
252
+ emit_deny(build_deny_reason(path, all_candidates))
58
253
  sys.exit(0)
59
254
 
60
255