claude-dev-env 1.73.0 → 1.74.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CLAUDE.md +2 -0
  2. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  3. package/hooks/blocking/CLAUDE.md +3 -0
  4. package/hooks/blocking/block_main_commit.py +14 -0
  5. package/hooks/blocking/bot_mention_comment_blocker.py +7 -0
  6. package/hooks/blocking/claude_md_orphan_file_blocker.py +14 -42
  7. package/hooks/blocking/code_rules_docstrings.py +223 -0
  8. package/hooks/blocking/code_rules_enforcer.py +16 -0
  9. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +10 -4
  10. package/hooks/blocking/convergence_gate_blocker.py +17 -3
  11. package/hooks/blocking/destructive_command_blocker.py +7 -0
  12. package/hooks/blocking/docstring_rule_gate_count_blocker.py +321 -0
  13. package/hooks/blocking/gh_body_arg_blocker.py +8 -0
  14. package/hooks/blocking/gh_pr_author_enforcer.py +7 -0
  15. package/hooks/blocking/hedging_language_blocker.py +16 -10
  16. package/hooks/blocking/hook_prose_detector_consistency.py +7 -0
  17. package/hooks/blocking/intent_only_ending_blocker.py +17 -11
  18. package/hooks/blocking/md_to_html_blocker.py +10 -2
  19. package/hooks/blocking/open_questions_in_plans_blocker.py +10 -2
  20. package/hooks/blocking/package_inventory_stale_blocker.py +398 -0
  21. package/hooks/blocking/plain_language_blocker.py +6 -0
  22. package/hooks/blocking/pr_converge_bugteam_enforcer.py +6 -0
  23. package/hooks/blocking/pr_description_enforcer.py +6 -0
  24. package/hooks/blocking/pre_tool_use_dispatcher.py +3 -3
  25. package/hooks/blocking/precommit_code_rules_gate.py +10 -1
  26. package/hooks/blocking/pytest_testpaths_orphan_blocker.py +8 -0
  27. package/hooks/blocking/question_to_user_enforcer.py +18 -12
  28. package/hooks/blocking/send_user_file_open_locally_blocker.py +70 -0
  29. package/hooks/blocking/sensitive_file_protector.py +15 -1
  30. package/hooks/blocking/session_handoff_blocker.py +14 -8
  31. package/hooks/blocking/state_description_blocker.py +6 -0
  32. package/hooks/blocking/subprocess_budget_completeness.py +9 -3
  33. package/hooks/blocking/tdd_enforcer.py +6 -0
  34. package/hooks/blocking/test_code_rules_enforcer_docstring_returns_plural_cardinality.py +207 -0
  35. package/hooks/blocking/test_code_rules_enforcer_docstring_unguarded_payload.py +188 -0
  36. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +45 -0
  37. package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +203 -0
  38. package/hooks/blocking/test_hook_block_logger_coverage.py +53 -0
  39. package/hooks/blocking/test_package_inventory_stale_blocker.py +329 -0
  40. package/hooks/blocking/test_plain_language_blocker.py +36 -0
  41. package/hooks/blocking/test_pre_tool_use_dispatcher.py +8 -8
  42. package/hooks/blocking/test_send_user_file_open_locally_blocker.py +114 -0
  43. package/hooks/blocking/test_shared_stdin_adoption.py +42 -0
  44. package/hooks/blocking/test_state_description_blocker.py +41 -0
  45. package/hooks/blocking/test_verdict_directory_write_blocker.py +49 -0
  46. package/hooks/blocking/test_workflow_substitution_slot_blocker.py +4 -19
  47. package/hooks/blocking/verdict_directory_write_blocker.py +10 -1
  48. package/hooks/blocking/verified_commit_gate.py +11 -0
  49. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +16 -1
  50. package/hooks/blocking/windows_rmtree_blocker.py +7 -0
  51. package/hooks/blocking/workflow_substitution_slot_blocker.py +10 -5
  52. package/hooks/blocking/write_existing_file_blocker.py +16 -1
  53. package/hooks/hooks.json +10 -0
  54. package/hooks/hooks_constants/CLAUDE.md +4 -0
  55. package/hooks/hooks_constants/blocking_check_limits.py +13 -0
  56. package/hooks/hooks_constants/code_rules_enforcer_constants.py +3 -0
  57. package/hooks/hooks_constants/docstring_rule_gate_count_blocker_constants.py +90 -0
  58. package/hooks/hooks_constants/hook_block_logger.py +59 -0
  59. package/hooks/hooks_constants/multi_edit_reconstruction.py +56 -0
  60. package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +111 -0
  61. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +1 -2
  62. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +9 -1
  63. package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +18 -0
  64. package/hooks/hooks_constants/test_dispatcher_constants_docstrings.py +44 -0
  65. package/hooks/hooks_constants/test_hook_block_logger.py +159 -0
  66. package/hooks/lifecycle/config_change_guard.py +12 -0
  67. package/hooks/lifecycle/test_config_change_guard.py +23 -0
  68. package/hooks/validation/hook_format_validator.py +13 -0
  69. package/hooks/validation/mypy_validator.py +30 -1
  70. package/hooks/validation/test_hook_format_validator.py +64 -0
  71. package/hooks/validation/test_mypy_validator.py +22 -0
  72. package/package.json +1 -1
  73. package/rules/CLAUDE.md +1 -0
  74. package/rules/docstring-prose-matches-implementation.md +2 -1
  75. package/rules/package-inventory-stale-entry.md +24 -0
  76. package/skills/autoconverge/SKILL.md +18 -1
  77. package/skills/autoconverge/workflow/converge.contract.test.mjs +106 -0
  78. package/skills/autoconverge/workflow/converge.mjs +2 -1
@@ -0,0 +1,398 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: blocks a new production file absent from its package inventory.
3
+
4
+ A package directory documents its own files in a sibling inventory document — a
5
+ ``README.md`` Layout table or a ``CLAUDE.md`` "Key files" list — whose entries
6
+ name each file in backticks. When a Write creates a new production code file in a
7
+ directory whose inventory already names two or more sibling files but carries no
8
+ entry naming the new file, the inventory and the directory disagree on the
9
+ package's file set: a reader who trusts the inventory to map the directory misses
10
+ the new file. This hook fires on a Write that creates such a file and blocks it,
11
+ directing the author to add the inventory entry in the same change. Edits to an
12
+ existing file, exempt files (``__init__.py``, ``conftest.py``, ``setup.py``,
13
+ ``_path_setup.py``), test files, and files inside a directory that carries no
14
+ per-file inventory (such as ``config/`` or ``tests/``) are out of scope.
15
+ """
16
+
17
+ import json
18
+ import os
19
+ import sys
20
+ from pathlib import Path
21
+ from typing import TextIO
22
+
23
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
24
+ if _hooks_dir not in sys.path:
25
+ sys.path.insert(0, _hooks_dir)
26
+
27
+ from hooks_constants.package_inventory_stale_blocker_constants import ( # noqa: E402
28
+ ALL_EXEMPT_BASENAMES,
29
+ ALL_EXEMPT_DIRECTORY_NAMES,
30
+ ALL_INVENTORY_DOCUMENT_NAMES,
31
+ ALL_PRODUCTION_CODE_EXTENSIONS,
32
+ ALL_TEST_FILE_MARKERS,
33
+ BACKTICK_TOKEN_PATTERN,
34
+ CODE_FENCE_PATTERN,
35
+ GLOB_METACHARACTER_PATTERN,
36
+ MAX_INVENTORY_FILE_BYTES,
37
+ MINIMUM_INVENTORY_ENTRY_COUNT,
38
+ NON_FILENAME_TOKEN_PATTERN,
39
+ PYTHON_FILE_EXTENSION,
40
+ STALE_INVENTORY_ADDITIONAL_CONTEXT,
41
+ STALE_INVENTORY_MESSAGE_TEMPLATE,
42
+ STALE_INVENTORY_SYSTEM_MESSAGE,
43
+ )
44
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
45
+ from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
46
+ read_hook_input_dictionary_from_stdin,
47
+ )
48
+
49
+
50
+ def _basename_token(backtick_inner_text: str) -> str | None:
51
+ """Return the bare filename a backticked token names, when it names one.
52
+
53
+ A token names a bare filename when it is a single filename or path token
54
+ carrying a known file extension. A token that holds a path keeps only its
55
+ final segment, so an inventory cell naming ``pipeline/seam_continuity.py``
56
+ yields ``seam_continuity.py`` — the basename the directory file would match.
57
+ A slash-command token (leading ``/``), a glob/pattern token carrying a
58
+ metacharacter (``*``, ``?``, brace or bracket range, so ``*.py`` and
59
+ ``test_*.py`` name no literal file), a multi-word command-example span
60
+ carrying whitespace or shell punctuation (``:``, ``$``, ``<``, ``>``, so
61
+ ``parent:node_modules package.json`` and ``python <file>.py`` name no
62
+ literal file), and a token with no file extension yield None.
63
+
64
+ Args:
65
+ backtick_inner_text: The text between a backtick pair, stripped.
66
+
67
+ Returns:
68
+ The bare basename the token references, or None when it names no file.
69
+ """
70
+ inner_text = backtick_inner_text.strip()
71
+ if not inner_text or inner_text.startswith("/"):
72
+ return None
73
+ if GLOB_METACHARACTER_PATTERN.search(inner_text) is not None:
74
+ return None
75
+ if NON_FILENAME_TOKEN_PATTERN.search(inner_text) is not None:
76
+ return None
77
+ basename = os.path.basename(inner_text.replace("\\", "/").rstrip("/"))
78
+ if not basename:
79
+ return None
80
+ _, extension = os.path.splitext(basename)
81
+ if not extension:
82
+ return None
83
+ return basename
84
+
85
+
86
+ def _lines_outside_code_fences(inventory_content: str) -> list[str]:
87
+ """Return the inventory lines that sit outside any fenced code block.
88
+
89
+ A line inside a ``` or ~~~ fence pair is example or sample text, not a live
90
+ listing, so it is dropped — mirroring the fence handling in
91
+ ``claude_md_orphan_file_blocker``.
92
+
93
+ Args:
94
+ inventory_content: The text of a README.md or CLAUDE.md inventory.
95
+
96
+ Returns:
97
+ The lines that lie outside every code fence, in document order.
98
+ """
99
+ live_lines: list[str] = []
100
+ is_inside_code_fence = False
101
+ for each_line in inventory_content.splitlines():
102
+ if CODE_FENCE_PATTERN.match(each_line) is not None:
103
+ is_inside_code_fence = not is_inside_code_fence
104
+ continue
105
+ if is_inside_code_fence:
106
+ continue
107
+ live_lines.append(each_line)
108
+ return live_lines
109
+
110
+
111
+ def inventory_named_basenames(inventory_content: str) -> set[str]:
112
+ """Return every bare filename a package inventory document names in backticks.
113
+
114
+ Lines inside a fenced code block are skipped as example text. Each backticked
115
+ token on a remaining line is examined; one that names a literal file (a single
116
+ filename or path token that carries an extension, no glob metacharacter, and
117
+ no whitespace or shell punctuation) contributes its basename, and a token
118
+ holding a path contributes its final segment. A multi-word command-example
119
+ span contributes nothing. This covers both a README.md table cell and a
120
+ CLAUDE.md bullet, since both name files in backticks.
121
+
122
+ Args:
123
+ inventory_content: The text of a README.md or CLAUDE.md inventory.
124
+
125
+ Returns:
126
+ The set of bare basenames the inventory names.
127
+ """
128
+ named_basenames: set[str] = set()
129
+ for each_line in _lines_outside_code_fences(inventory_content):
130
+ for each_match in BACKTICK_TOKEN_PATTERN.finditer(each_line):
131
+ each_basename = _basename_token(each_match.group(1))
132
+ if each_basename is not None:
133
+ named_basenames.add(each_basename)
134
+ return named_basenames
135
+
136
+
137
+ def _read_inventory_content(inventory_path: Path) -> str | None:
138
+ """Return the text of an inventory document, or None when it is unreadable.
139
+
140
+ A document larger than the byte budget is skipped (None), so an oversized
141
+ file never stalls the hook.
142
+
143
+ Args:
144
+ inventory_path: The path of the README.md or CLAUDE.md to read.
145
+
146
+ Returns:
147
+ The document text, or None when it is missing, oversized, or undecodable.
148
+ """
149
+ try:
150
+ if inventory_path.stat().st_size > MAX_INVENTORY_FILE_BYTES:
151
+ return None
152
+ return inventory_path.read_text(encoding="utf-8")
153
+ except (OSError, UnicodeDecodeError):
154
+ return None
155
+
156
+
157
+ class _InventorySurvey:
158
+ """The inventory documents found beside a file and the files they name.
159
+
160
+ Attributes:
161
+ present_inventory_names: The inventory document basenames present in the
162
+ directory (``README.md`` and/or ``CLAUDE.md``).
163
+ named_basenames: Every bare filename the present inventories name.
164
+ """
165
+
166
+ def __init__(
167
+ self, all_present_inventory_names: list[str], all_named_basenames: set[str]
168
+ ) -> None:
169
+ self.present_inventory_names = all_present_inventory_names
170
+ self.named_basenames = all_named_basenames
171
+
172
+
173
+ def survey_directory_inventories(package_directory: Path) -> _InventorySurvey:
174
+ """Return the inventory documents beside a file and the basenames they name.
175
+
176
+ Reads each present ``README.md`` and ``CLAUDE.md`` in *package_directory* and
177
+ unions the basenames they name in backticks.
178
+
179
+ Args:
180
+ package_directory: The directory that holds the file being written.
181
+
182
+ Returns:
183
+ The survey pairing the present inventory document names with the union of
184
+ the basenames they name.
185
+ """
186
+ present_inventory_names: list[str] = []
187
+ named_basenames: set[str] = set()
188
+ for each_inventory_name in sorted(ALL_INVENTORY_DOCUMENT_NAMES):
189
+ inventory_path = package_directory / each_inventory_name
190
+ inventory_content = _read_inventory_content(inventory_path)
191
+ if inventory_content is None:
192
+ continue
193
+ present_inventory_names.append(each_inventory_name)
194
+ named_basenames |= inventory_named_basenames(inventory_content)
195
+ return _InventorySurvey(present_inventory_names, named_basenames)
196
+
197
+
198
+ def _is_test_file(basename: str) -> bool:
199
+ """Return whether *basename* names a test file the inventory need not list.
200
+
201
+ Args:
202
+ basename: The bare filename of the file being written.
203
+
204
+ Returns:
205
+ True when the name matches a test-file shape (``test_*.py``,
206
+ ``*_test.py``, ``*.spec.*``, or ``*.test.*``).
207
+ """
208
+ if basename.startswith("test_") and basename.endswith(PYTHON_FILE_EXTENSION):
209
+ return True
210
+ if basename.endswith("_test" + PYTHON_FILE_EXTENSION):
211
+ return True
212
+ return any(each_marker in basename for each_marker in ALL_TEST_FILE_MARKERS)
213
+
214
+
215
+ def _is_under_exempt_directory(package_directory: Path) -> bool:
216
+ """Return whether the file's directory is itself an exempt directory.
217
+
218
+ A file directly inside a directory that carries no per-file inventory (such
219
+ as ``config/`` or ``tests/``) has no individual entry, so its directory
220
+ exempts it.
221
+
222
+ Args:
223
+ package_directory: The directory that holds the file being written.
224
+
225
+ Returns:
226
+ True when the directory's own name is an exempt directory name.
227
+ """
228
+ return package_directory.name in ALL_EXEMPT_DIRECTORY_NAMES
229
+
230
+
231
+ def is_inventoried_production_file(file_path: str) -> bool:
232
+ """Return whether *file_path* is a production file an inventory should name.
233
+
234
+ A production file is a non-test, non-exempt code file (``.py``, ``.mjs``,
235
+ ``.js``, ``.ts``, ``.ps1``, ``.sh``) outside a directory that carries no
236
+ per-file inventory (such as ``config/`` or ``tests/``). Exempt basenames
237
+ (``__init__.py``, ``conftest.py``, ``setup.py``, ``_path_setup.py``) and
238
+ test files are out of scope.
239
+
240
+ Args:
241
+ file_path: The destination path of the write.
242
+
243
+ Returns:
244
+ True when the file is one an inventory entry should name.
245
+ """
246
+ basename = os.path.basename(file_path)
247
+ _, extension = os.path.splitext(basename)
248
+ if extension.lower() not in ALL_PRODUCTION_CODE_EXTENSIONS:
249
+ return False
250
+ if basename in ALL_EXEMPT_BASENAMES:
251
+ return False
252
+ if _is_test_file(basename):
253
+ return False
254
+ return not _is_under_exempt_directory(Path(file_path).resolve().parent)
255
+
256
+
257
+ def _sibling_named_basenames(
258
+ package_directory: Path, all_named_basenames: set[str]
259
+ ) -> set[str]:
260
+ """Return the named basenames that exist as files in *package_directory*.
261
+
262
+ A maintained inventory lists the directory's own files, so a named basename
263
+ counts toward the inventory only when a file with that basename sits directly
264
+ in the directory. A name the inventory mentions in passing — a file living in
265
+ another directory (``install.mjs``), a sibling doc — is dropped, so prose that
266
+ references non-sibling files never reads as a maintained inventory.
267
+
268
+ Args:
269
+ package_directory: The directory that holds the file being written.
270
+ all_named_basenames: Every bare basename the inventory documents name.
271
+
272
+ Returns:
273
+ The subset of *all_named_basenames* present as a file in the directory.
274
+ """
275
+ sibling_basenames: set[str] = set()
276
+ for each_basename in all_named_basenames:
277
+ if (package_directory / each_basename).is_file():
278
+ sibling_basenames.add(each_basename)
279
+ return sibling_basenames
280
+
281
+
282
+ def find_stale_inventory(file_path: str) -> _InventorySurvey | None:
283
+ """Return the maintained inventory survey a new file is absent from, or None.
284
+
285
+ The file's directory inventories are surveyed, then the named basenames are
286
+ filtered to those that exist as files in the directory — the inventory's own
287
+ sibling files. The survey reports a stale inventory only when every condition
288
+ holds: the directory carries at least one inventory document, those documents
289
+ together name at least the minimum entry count of on-disk sibling files
290
+ (marking them a maintained inventory rather than incidental prose that
291
+ mentions files living elsewhere), and the inventory does not already name this
292
+ file's basename. When any condition fails the file is in step with its
293
+ inventory (or there is no inventory to be out of step with), so None results.
294
+
295
+ Args:
296
+ file_path: The destination path of the write.
297
+
298
+ Returns:
299
+ The inventory survey when the file is a stale omission, or None.
300
+ """
301
+ package_directory = Path(file_path).resolve().parent
302
+ if not package_directory.is_dir():
303
+ return None
304
+ survey = survey_directory_inventories(package_directory)
305
+ if not survey.present_inventory_names:
306
+ return None
307
+ sibling_basenames = _sibling_named_basenames(package_directory, survey.named_basenames)
308
+ if len(sibling_basenames) < MINIMUM_INVENTORY_ENTRY_COUNT:
309
+ return None
310
+ if os.path.basename(file_path) in survey.named_basenames:
311
+ return None
312
+ return _InventorySurvey(survey.present_inventory_names, sibling_basenames)
313
+
314
+
315
+ def _build_block_payload(file_path: str, survey: _InventorySurvey) -> dict:
316
+ """Build the PreToolUse deny payload for a stale-inventory omission.
317
+
318
+ Args:
319
+ file_path: The destination path of the write.
320
+ survey: The maintained-inventory survey the file is absent from.
321
+
322
+ Returns:
323
+ The hook-result dictionary the harness reads to deny the write.
324
+ """
325
+ package_directory = str(Path(file_path).resolve().parent)
326
+ formatted_inventories = ", ".join(survey.present_inventory_names)
327
+ reason = STALE_INVENTORY_MESSAGE_TEMPLATE.format(
328
+ filename=os.path.basename(file_path),
329
+ directory=package_directory,
330
+ inventories=formatted_inventories,
331
+ entry_count=len(survey.named_basenames),
332
+ )
333
+ return {
334
+ "hookSpecificOutput": {
335
+ "hookEventName": "PreToolUse",
336
+ "permissionDecision": "deny",
337
+ "permissionDecisionReason": reason,
338
+ "additionalContext": STALE_INVENTORY_ADDITIONAL_CONTEXT,
339
+ },
340
+ "systemMessage": STALE_INVENTORY_SYSTEM_MESSAGE,
341
+ "suppressOutput": True,
342
+ }
343
+
344
+
345
+ def _emit_hook_result(all_hook_data: dict, output_stream: TextIO) -> None:
346
+ """Write the hook result JSON to the given output stream.
347
+
348
+ Args:
349
+ all_hook_data: The hook-result dictionary to serialize.
350
+ output_stream: The stream the harness reads the decision from.
351
+ """
352
+ output_stream.write(json.dumps(all_hook_data) + "\n")
353
+ output_stream.flush()
354
+
355
+
356
+ def main() -> None:
357
+ """Read the PreToolUse payload from stdin and block a stale-inventory Write."""
358
+ input_data = read_hook_input_dictionary_from_stdin()
359
+ if input_data is None:
360
+ sys.exit(0)
361
+
362
+ raw_tool_name = input_data.get("tool_name", "")
363
+ tool_name = raw_tool_name if isinstance(raw_tool_name, str) else ""
364
+ if tool_name != "Write":
365
+ sys.exit(0)
366
+
367
+ tool_input = input_data.get("tool_input", {})
368
+ if not isinstance(tool_input, dict):
369
+ sys.exit(0)
370
+
371
+ file_path = tool_input.get("file_path", "")
372
+ if not isinstance(file_path, str) or not file_path:
373
+ sys.exit(0)
374
+
375
+ if os.path.exists(file_path):
376
+ sys.exit(0)
377
+
378
+ if not is_inventoried_production_file(file_path):
379
+ sys.exit(0)
380
+
381
+ survey = find_stale_inventory(file_path)
382
+ if survey is None:
383
+ sys.exit(0)
384
+
385
+ block_payload = _build_block_payload(file_path, survey)
386
+ log_hook_block(
387
+ calling_hook_name="package_inventory_stale_blocker.py",
388
+ hook_event="PreToolUse",
389
+ block_reason=block_payload["hookSpecificOutput"]["permissionDecisionReason"],
390
+ tool_name=tool_name,
391
+ offending_input_preview=file_path,
392
+ )
393
+ _emit_hook_result(block_payload, sys.stdout)
394
+ sys.exit(0)
395
+
396
+
397
+ if __name__ == "__main__":
398
+ main()
@@ -19,6 +19,7 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
19
19
  if _hooks_dir not in sys.path:
20
20
  sys.path.insert(0, _hooks_dir)
21
21
 
22
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
22
23
  from hooks_constants.plain_language_blocker_constants import ( # noqa: E402
23
24
  ALL_SOFTWARE_TERMS,
24
25
  ALL_TERM_PATTERNS,
@@ -153,6 +154,11 @@ def build_deny_payload(deny_reason: str) -> dict[str, object]:
153
154
  Returns:
154
155
  The deny payload dictionary the hook serializes to stdout.
155
156
  """
157
+ log_hook_block(
158
+ calling_hook_name="plain_language_blocker.py",
159
+ hook_event="PreToolUse",
160
+ block_reason=deny_reason,
161
+ )
156
162
  return {
157
163
  "hookSpecificOutput": {
158
164
  "hookEventName": "PreToolUse",
@@ -45,6 +45,7 @@ from hooks_constants.pr_converge_bugteam_enforcer_state import ( # noqa: E402
45
45
  load_state_dictionary,
46
46
  resolve_state_path,
47
47
  )
48
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
48
49
  from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
49
50
  read_hook_input_dictionary_from_stdin,
50
51
  )
@@ -146,6 +147,11 @@ def _emit_deny_payload(output_stream: TextIO) -> None:
146
147
  "permissionDecisionReason": ENFORCER_CORRECTIVE_MESSAGE,
147
148
  }
148
149
  }
150
+ log_hook_block(
151
+ calling_hook_name="pr_converge_bugteam_enforcer.py",
152
+ hook_event="PreToolUse",
153
+ block_reason=ENFORCER_CORRECTIVE_MESSAGE,
154
+ )
149
155
  output_stream.write(json.dumps(deny_payload) + "\n")
150
156
  output_stream.flush()
151
157
 
@@ -41,6 +41,7 @@ from blocking.pr_description_readability import ( # noqa: E402
41
41
  _is_readability_enabled,
42
42
  _load_readability_thresholds,
43
43
  )
44
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
44
45
  from hooks_constants.pr_description_enforcer_constants import ( # noqa: E402
45
46
  ALL_HEAVY_OPENING_HEADERS,
46
47
  ALL_HEAVY_TESTING_HEADERS,
@@ -187,6 +188,11 @@ def main() -> None:
187
188
  "permissionDecisionReason": denial_reason,
188
189
  }
189
190
  }
191
+ log_hook_block(
192
+ calling_hook_name="pr_description_enforcer.py",
193
+ hook_event="PreToolUse",
194
+ block_reason=denial_reason,
195
+ )
190
196
  print(json.dumps(denial_payload))
191
197
  sys.stdout.flush()
192
198
 
@@ -8,9 +8,9 @@ decision when any hook denied (carrying every denying reason) or exits zero to
8
8
  allow.
9
9
 
10
10
  The per-hook coverage matrix:
11
- - Write -> Group A (10 hooks) + Group B (5 hooks) = 15 hooks
12
- - Edit -> Group A (10 hooks) + Group B (5 hooks) = 15 hooks
13
- - MultiEdit -> Group B only (5 hooks)
11
+ - Write -> Group A (10 hooks) + Group B (7 hooks) = 17 hooks
12
+ - Edit -> Group A (10 hooks) + Group B (7 hooks) = 17 hooks
13
+ - MultiEdit -> Group B only (7 hooks)
14
14
  """
15
15
 
16
16
  from __future__ import annotations
@@ -29,6 +29,7 @@ from block_main_commit import ( # noqa: E402
29
29
  parse_bash_command_from_stdin,
30
30
  resolve_directory,
31
31
  )
32
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
32
33
  from hooks_constants.precommit_code_rules_gate_constants import ( # noqa: E402
33
34
  ALL_GIT_REPOSITORY_ROOT_COMMAND,
34
35
  ALL_STAGED_PYTHON_FILES_COMMAND,
@@ -189,7 +190,15 @@ def main() -> None:
189
190
  gate_exit_code, gate_report = run_staged_gate(repository_root)
190
191
  if gate_exit_code == 0:
191
192
  sys.exit(0)
192
- print(json.dumps(build_denial_response(gate_report)))
193
+ denial = build_denial_response(gate_report)
194
+ log_hook_block(
195
+ calling_hook_name="precommit_code_rules_gate.py",
196
+ hook_event="PreToolUse",
197
+ block_reason=denial["hookSpecificOutput"]["permissionDecisionReason"],
198
+ tool_name="Bash",
199
+ offending_input_preview=bash_command,
200
+ )
201
+ print(json.dumps(denial))
193
202
  sys.exit(0)
194
203
 
195
204
 
@@ -26,6 +26,7 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
26
26
  if _hooks_dir not in sys.path:
27
27
  sys.path.insert(0, _hooks_dir)
28
28
 
29
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
29
30
  from hooks_constants.pytest_testpaths_orphan_blocker_constants import ( # noqa: E402
30
31
  ALL_PRUNED_PARENT_DIRECTORY_NAMES,
31
32
  GLOB_METACHARACTERS,
@@ -350,6 +351,13 @@ def main() -> None:
350
351
  sys.exit(0)
351
352
 
352
353
  block_payload = _build_block_payload(block_details)
354
+ log_hook_block(
355
+ calling_hook_name="pytest_testpaths_orphan_blocker.py",
356
+ hook_event="PreToolUse",
357
+ block_reason=block_payload["hookSpecificOutput"]["permissionDecisionReason"],
358
+ tool_name=tool_name,
359
+ offending_input_preview=file_path,
360
+ )
353
361
  _emit_hook_result(block_payload, sys.stdout)
354
362
  sys.exit(0)
355
363
 
@@ -19,6 +19,7 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
19
19
  if _hooks_dir not in sys.path:
20
20
  sys.path.insert(0, _hooks_dir)
21
21
 
22
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
22
23
  from hooks_constants.messages import USER_FACING_ASKUSERQUESTION_NOTICE # noqa: E402
23
24
 
24
25
 
@@ -109,23 +110,28 @@ def main() -> None:
109
110
  f'"{each_indicator}"' for each_indicator in matched_indicators
110
111
  )
111
112
 
113
+ block_reason = (
114
+ f"ASKUSERQUESTION GUARDRAIL: Your response asks the user a question in prose "
115
+ f"(indicators: {formatted_indicator_list}). "
116
+ f"User-directed questions must route through the AskUserQuestion tool so the user "
117
+ f"sees structured options with labels.\n\n"
118
+ f"Re-output your response with the trailing question removed from prose and moved "
119
+ f"into an AskUserQuestion tool call. Rhetorical questions answered in the same "
120
+ f"paragraph are allowed; questions inside code fences, inline code, and blockquotes "
121
+ f"are ignored.\n\n"
122
+ f"You MUST re-output the complete, revised response with the correction applied."
123
+ )
112
124
  block_response = {
113
125
  "decision": "block",
114
- "reason": (
115
- f"ASKUSERQUESTION GUARDRAIL: Your response asks the user a question in prose "
116
- f"(indicators: {formatted_indicator_list}). "
117
- f"User-directed questions must route through the AskUserQuestion tool so the user "
118
- f"sees structured options with labels.\n\n"
119
- f"Re-output your response with the trailing question removed from prose and moved "
120
- f"into an AskUserQuestion tool call. Rhetorical questions answered in the same "
121
- f"paragraph are allowed; questions inside code fences, inline code, and blockquotes "
122
- f"are ignored.\n\n"
123
- f"You MUST re-output the complete, revised response with the correction applied."
124
- ),
126
+ "reason": block_reason,
125
127
  "systemMessage": USER_FACING_ASKUSERQUESTION_NOTICE,
126
128
  "suppressOutput": True,
127
129
  }
128
-
130
+ log_hook_block(
131
+ calling_hook_name="question_to_user_enforcer.py",
132
+ hook_event="Stop",
133
+ block_reason=block_reason,
134
+ )
129
135
  print(json.dumps(block_response))
130
136
  sys.exit(0)
131
137
 
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: block SendUserFile attaches that should open locally.
3
+
4
+ SendUserFile attaches a file to the session. While the user is at the terminal
5
+ (status "normal" or unset) an attach does not let them see the file — it must
6
+ open on screen in its own viewer via Show-Asset.ps1. The one attach allowed
7
+ through is an away-from-desk phone push (status "proactive").
8
+ """
9
+
10
+ import json
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
15
+ if _hooks_dir not in sys.path:
16
+ sys.path.insert(0, _hooks_dir)
17
+
18
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
19
+ from hooks_constants.send_user_file_open_locally_blocker_constants import ( # noqa: E402
20
+ CORRECTIVE_MESSAGE,
21
+ PROACTIVE_STATUS,
22
+ TOOL_NAME,
23
+ )
24
+
25
+
26
+ def _should_block(status: str) -> bool:
27
+ """Return whether a SendUserFile call with this status should be denied.
28
+
29
+ Args:
30
+ status: The ``status`` field from the SendUserFile input. A proactive
31
+ phone push is allowed; every other value, including an empty one,
32
+ is a desk-side attach the user cannot see and is denied.
33
+ """
34
+ return status != PROACTIVE_STATUS
35
+
36
+
37
+ def main() -> None:
38
+ try:
39
+ hook_input = json.load(sys.stdin)
40
+ except json.JSONDecodeError:
41
+ sys.exit(0)
42
+
43
+ if hook_input.get("tool_name", "") != TOOL_NAME:
44
+ sys.exit(0)
45
+
46
+ tool_input = hook_input.get("tool_input") or {}
47
+ status = tool_input.get("status", "")
48
+ if not _should_block(status):
49
+ sys.exit(0)
50
+
51
+ deny_payload = {
52
+ "hookSpecificOutput": {
53
+ "hookEventName": "PreToolUse",
54
+ "permissionDecision": "deny",
55
+ "permissionDecisionReason": CORRECTIVE_MESSAGE,
56
+ }
57
+ }
58
+ log_hook_block(
59
+ calling_hook_name="send_user_file_open_locally_blocker.py",
60
+ hook_event="PreToolUse",
61
+ block_reason=CORRECTIVE_MESSAGE,
62
+ tool_name=TOOL_NAME,
63
+ )
64
+ print(json.dumps(deny_payload))
65
+ sys.stdout.flush()
66
+ sys.exit(0)
67
+
68
+
69
+ if __name__ == "__main__":
70
+ main()
@@ -3,6 +3,13 @@ import fnmatch
3
3
  import json
4
4
  import os
5
5
  import sys
6
+ from pathlib import Path
7
+
8
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
9
+ if _hooks_dir not in sys.path:
10
+ sys.path.insert(0, _hooks_dir)
11
+
12
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
6
13
 
7
14
  SENSITIVE_PATTERNS = [
8
15
  ".env",
@@ -54,13 +61,20 @@ def main() -> None:
54
61
  matched_pattern = is_sensitive_file(file_path)
55
62
 
56
63
  if matched_pattern is not None:
64
+ deny_reason = f"BLOCKED: Sensitive file '{os.path.basename(file_path)}' (pattern: '{matched_pattern}'). Edit manually outside Claude Code."
57
65
  deny_response = {
58
66
  "hookSpecificOutput": {
59
67
  "hookEventName": "PreToolUse",
60
68
  "permissionDecision": "deny",
61
- "permissionDecisionReason": f"BLOCKED: Sensitive file '{os.path.basename(file_path)}' (pattern: '{matched_pattern}'). Edit manually outside Claude Code."
69
+ "permissionDecisionReason": deny_reason,
62
70
  }
63
71
  }
72
+ log_hook_block(
73
+ calling_hook_name="sensitive_file_protector.py",
74
+ hook_event="PreToolUse",
75
+ block_reason=deny_reason,
76
+ offending_input_preview=file_path,
77
+ )
64
78
  print(json.dumps(deny_response))
65
79
 
66
80
  sys.exit(0)