claude-dev-env 1.73.0 → 1.75.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 (105) 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 +4 -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 +12 -5
  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/duplicate_rmtree_helper_blocker.py +155 -0
  14. package/hooks/blocking/gh_body_arg_blocker.py +8 -0
  15. package/hooks/blocking/gh_pr_author_enforcer.py +7 -0
  16. package/hooks/blocking/hedging_language_blocker.py +17 -23
  17. package/hooks/blocking/hook_prose_detector_consistency.py +7 -0
  18. package/hooks/blocking/intent_only_ending_blocker.py +18 -26
  19. package/hooks/blocking/md_to_html_blocker.py +10 -2
  20. package/hooks/blocking/open_questions_in_plans_blocker.py +10 -2
  21. package/hooks/blocking/package_inventory_stale_blocker.py +398 -0
  22. package/hooks/blocking/plain_language_blocker.py +6 -0
  23. package/hooks/blocking/pr_converge_bugteam_enforcer.py +6 -0
  24. package/hooks/blocking/pr_description_enforcer.py +6 -0
  25. package/hooks/blocking/pre_tool_use_dispatcher.py +5 -6
  26. package/hooks/blocking/precommit_code_rules_gate.py +10 -1
  27. package/hooks/blocking/pytest_testpaths_orphan_blocker.py +8 -0
  28. package/hooks/blocking/question_to_user_enforcer.py +19 -23
  29. package/hooks/blocking/send_user_file_open_locally_blocker.py +70 -0
  30. package/hooks/blocking/sensitive_file_protector.py +15 -1
  31. package/hooks/blocking/session_handoff_blocker.py +15 -23
  32. package/hooks/blocking/state_description_blocker.py +6 -0
  33. package/hooks/blocking/subprocess_budget_completeness.py +9 -3
  34. package/hooks/blocking/tdd_enforcer.py +6 -0
  35. package/hooks/blocking/test_code_rules_enforcer_docstring_returns_plural_cardinality.py +207 -0
  36. package/hooks/blocking/test_code_rules_enforcer_docstring_unguarded_payload.py +188 -0
  37. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +61 -0
  38. package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +203 -0
  39. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +328 -0
  40. package/hooks/blocking/test_hedging_language_blocker.py +6 -0
  41. package/hooks/blocking/test_hook_block_logger_coverage.py +53 -0
  42. package/hooks/blocking/test_intent_only_ending_blocker.py +5 -0
  43. package/hooks/blocking/test_package_inventory_stale_blocker.py +329 -0
  44. package/hooks/blocking/test_plain_language_blocker.py +36 -0
  45. package/hooks/blocking/test_pre_tool_use_dispatcher.py +55 -8
  46. package/hooks/blocking/test_question_to_user_enforcer.py +6 -0
  47. package/hooks/blocking/test_send_user_file_open_locally_blocker.py +114 -0
  48. package/hooks/blocking/test_session_handoff_blocker.py +6 -0
  49. package/hooks/blocking/test_shared_stdin_adoption.py +42 -0
  50. package/hooks/blocking/test_state_description_blocker.py +41 -0
  51. package/hooks/blocking/test_verdict_directory_write_blocker.py +49 -0
  52. package/hooks/blocking/test_workflow_substitution_slot_blocker.py +4 -19
  53. package/hooks/blocking/verdict_directory_write_blocker.py +10 -1
  54. package/hooks/blocking/verified_commit_gate.py +11 -0
  55. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +16 -1
  56. package/hooks/blocking/windows_rmtree_blocker.py +7 -0
  57. package/hooks/blocking/workflow_substitution_slot_blocker.py +10 -5
  58. package/hooks/blocking/write_existing_file_blocker.py +16 -1
  59. package/hooks/hooks.json +10 -0
  60. package/hooks/hooks_constants/CLAUDE.md +8 -1
  61. package/hooks/hooks_constants/blocking_check_limits.py +13 -0
  62. package/hooks/hooks_constants/code_rules_enforcer_constants.py +3 -0
  63. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +2 -1
  64. package/hooks/hooks_constants/docstring_rule_gate_count_blocker_constants.py +90 -0
  65. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +27 -0
  66. package/hooks/hooks_constants/hook_block_logger.py +59 -0
  67. package/hooks/hooks_constants/multi_edit_reconstruction.py +56 -0
  68. package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +111 -0
  69. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +3 -2
  70. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +17 -3
  71. package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +18 -0
  72. package/hooks/hooks_constants/test_dispatcher_constants_docstrings.py +44 -0
  73. package/hooks/hooks_constants/test_hook_block_logger.py +159 -0
  74. package/hooks/hooks_constants/test_post_tool_use_dispatcher_constants.py +43 -0
  75. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +99 -0
  76. package/hooks/hooks_constants/test_text_stripping.py +39 -0
  77. package/hooks/hooks_constants/text_stripping.py +36 -0
  78. package/hooks/lifecycle/config_change_guard.py +12 -0
  79. package/hooks/lifecycle/test_config_change_guard.py +23 -0
  80. package/hooks/validation/CLAUDE.md +1 -0
  81. package/hooks/validation/hook_format_validator.py +13 -0
  82. package/hooks/validation/mypy_validator.py +30 -1
  83. package/hooks/validation/post_tool_use_dispatcher.py +2 -2
  84. package/hooks/validation/test_hook_format_validator.py +64 -0
  85. package/hooks/validation/test_mypy_validator.py +23 -1
  86. package/hooks/validation/test_post_tool_use_dispatcher.py +6 -0
  87. package/hooks/workflow/auto_formatter.py +8 -5
  88. package/hooks/workflow/test_auto_formatter.py +33 -0
  89. package/package.json +1 -1
  90. package/rules/CLAUDE.md +1 -0
  91. package/rules/docstring-prose-matches-implementation.md +2 -1
  92. package/rules/package-inventory-stale-entry.md +24 -0
  93. package/rules/windows-filesystem-safe.md +2 -0
  94. package/skills/autoconverge/SKILL.md +21 -1
  95. package/skills/autoconverge/reference/stop-conditions.md +7 -0
  96. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +5 -4
  97. package/skills/autoconverge/workflow/converge.contract.test.mjs +398 -116
  98. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +16 -16
  99. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +36 -44
  100. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -24
  101. package/skills/autoconverge/workflow/converge.mjs +599 -606
  102. package/skills/autoconverge/workflow/convergence_summary.py +1 -1
  103. package/skills/autoconverge/workflow/render_report.py +2 -6
  104. package/skills/autoconverge/workflow/test_convergence_summary.py +17 -0
  105. package/skills/autoconverge/workflow/test_render_report.py +1 -0
@@ -31,6 +31,7 @@ from hooks_constants.md_to_html_blocker_constants import ( # noqa: E402
31
31
  PACKAGES_TOP_LEVEL_SEGMENT,
32
32
  PLUGIN_ROOT_MARKER_DIRECTORY_NAME,
33
33
  )
34
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
34
35
  from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
35
36
  read_hook_input_dictionary_from_stdin,
36
37
  )
@@ -123,17 +124,24 @@ def main() -> None:
123
124
  if is_exempt_path(file_path):
124
125
  sys.exit(0)
125
126
 
127
+ deny_reason = _block_reason(file_path)
126
128
  block_payload = {
127
129
  "hookSpecificOutput": {
128
130
  "hookEventName": "PreToolUse",
129
131
  "permissionDecision": "deny",
130
- "permissionDecisionReason": _block_reason(file_path),
132
+ "permissionDecisionReason": deny_reason,
131
133
  "additionalContext": _block_context(),
132
134
  },
133
135
  "systemMessage": _block_system_message(),
134
136
  "suppressOutput": True,
135
137
  }
136
-
138
+ log_hook_block(
139
+ calling_hook_name="md_to_html_blocker.py",
140
+ hook_event="PreToolUse",
141
+ block_reason=deny_reason,
142
+ tool_name=tool_name,
143
+ offending_input_preview=file_path,
144
+ )
137
145
  _emit_hook_result(block_payload, sys.stdout)
138
146
  sys.exit(0)
139
147
 
@@ -31,6 +31,7 @@ from hooks_constants.open_questions_in_plans_blocker_constants import ( # noqa:
31
31
  PLANS_PATH_SEGMENT,
32
32
  UNREADABLE_FILE_SYNTHETIC_CONTENT,
33
33
  )
34
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
34
35
  from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
35
36
  read_hook_input_dictionary_from_stdin,
36
37
  )
@@ -236,17 +237,24 @@ def main() -> None:
236
237
  if not _content_has_open_questions(candidate_content):
237
238
  sys.exit(0)
238
239
 
240
+ deny_reason = _block_reason(file_path)
239
241
  block_payload = {
240
242
  "hookSpecificOutput": {
241
243
  "hookEventName": "PreToolUse",
242
244
  "permissionDecision": "deny",
243
- "permissionDecisionReason": _block_reason(file_path),
245
+ "permissionDecisionReason": deny_reason,
244
246
  "additionalContext": _block_context(),
245
247
  },
246
248
  "systemMessage": _block_system_message(),
247
249
  "suppressOutput": True,
248
250
  }
249
-
251
+ log_hook_block(
252
+ calling_hook_name="open_questions_in_plans_blocker.py",
253
+ hook_event="PreToolUse",
254
+ block_reason=deny_reason,
255
+ tool_name=tool_name,
256
+ offending_input_preview=file_path,
257
+ )
250
258
  _emit_hook_result(block_payload, sys.stdout)
251
259
  sys.exit(0)
252
260
 
@@ -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 (11 hooks) + Group B (7 hooks) = 18 hooks
12
+ - Edit -> Group A (11 hooks) + Group B (7 hooks) = 18 hooks
13
+ - MultiEdit -> Group B only (7 hooks)
14
14
  """
15
15
 
16
16
  from __future__ import annotations
@@ -40,6 +40,7 @@ from state_description_blocker import evaluate as evaluate_state_description #
40
40
  from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: E402
41
41
  ALL_HOSTED_HOOK_ENTRIES,
42
42
  ALLOW_DECISION,
43
+ BLOCKING_CRASH_DENY_REASON,
43
44
  BLOCKING_CRASH_EXIT_CODE,
44
45
  DENY_DECISION,
45
46
  EXIT_CODE_TWO_DENY_REASON,
@@ -360,9 +361,7 @@ def aggregate_hosted_hook_results(
360
361
  parsed_output.deny_reason if parsed_output.deny_reason else EXIT_CODE_TWO_DENY_REASON
361
362
  )
362
363
  elif each_result.did_crash and each_result.is_blocking:
363
- all_deny_reasons.append(
364
- "[dispatcher] hook crash in blocking hook — write blocked for safety"
365
- )
364
+ all_deny_reasons.append(BLOCKING_CRASH_DENY_REASON)
366
365
  elif each_result.exit_code == BLOCKING_CRASH_EXIT_CODE and each_result.is_blocking:
367
366
  all_deny_reasons.append(EXIT_CODE_TWO_DENY_REASON)
368
367
  if parsed_output.is_allow:
@@ -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,18 +19,9 @@ _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
-
25
- def strip_code_and_quotes(text: str) -> str:
26
- """Remove code blocks, inline code, and blockquotes to avoid false positives."""
27
- code_block_pattern = re.compile(r"```[\s\S]*?```", re.MULTILINE)
28
- inline_code_pattern = re.compile(r"`[^`]+`")
29
- quoted_block_pattern = re.compile(r"^>.*$", re.MULTILINE)
30
- text = code_block_pattern.sub("", text)
31
- text = inline_code_pattern.sub("", text)
32
- text = quoted_block_pattern.sub("", text)
33
- return text
24
+ from hooks_constants.text_stripping import strip_code_and_quotes # noqa: E402
34
25
 
35
26
 
36
27
  def extract_final_paragraph(text: str) -> str:
@@ -109,23 +100,28 @@ def main() -> None:
109
100
  f'"{each_indicator}"' for each_indicator in matched_indicators
110
101
  )
111
102
 
103
+ block_reason = (
104
+ f"ASKUSERQUESTION GUARDRAIL: Your response asks the user a question in prose "
105
+ f"(indicators: {formatted_indicator_list}). "
106
+ f"User-directed questions must route through the AskUserQuestion tool so the user "
107
+ f"sees structured options with labels.\n\n"
108
+ f"Re-output your response with the trailing question removed from prose and moved "
109
+ f"into an AskUserQuestion tool call. Rhetorical questions answered in the same "
110
+ f"paragraph are allowed; questions inside code fences, inline code, and blockquotes "
111
+ f"are ignored.\n\n"
112
+ f"You MUST re-output the complete, revised response with the correction applied."
113
+ )
112
114
  block_response = {
113
115
  "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
- ),
116
+ "reason": block_reason,
125
117
  "systemMessage": USER_FACING_ASKUSERQUESTION_NOTICE,
126
118
  "suppressOutput": True,
127
119
  }
128
-
120
+ log_hook_block(
121
+ calling_hook_name="question_to_user_enforcer.py",
122
+ hook_event="Stop",
123
+ block_reason=block_reason,
124
+ )
129
125
  print(json.dumps(block_response))
130
126
  sys.exit(0)
131
127