claude-dev-env 1.72.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 (99) hide show
  1. package/CLAUDE.md +2 -0
  2. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +2 -2
  3. package/bin/install.mjs +73 -5
  4. package/bin/install.test.mjs +360 -4
  5. package/hooks/blocking/CLAUDE.md +6 -1
  6. package/hooks/blocking/block_main_commit.py +14 -0
  7. package/hooks/blocking/bot_mention_comment_blocker.py +7 -0
  8. package/hooks/blocking/claude_md_orphan_file_blocker.py +19 -48
  9. package/hooks/blocking/code_rules_dead_config_field.py +69 -56
  10. package/hooks/blocking/code_rules_docstrings.py +839 -0
  11. package/hooks/blocking/code_rules_enforcer.py +38 -0
  12. package/hooks/blocking/code_rules_shared.py +19 -0
  13. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +426 -0
  14. package/hooks/blocking/convergence_gate_blocker.py +17 -3
  15. package/hooks/blocking/destructive_command_blocker.py +7 -0
  16. package/hooks/blocking/docstring_rule_gate_count_blocker.py +321 -0
  17. package/hooks/blocking/gh_body_arg_blocker.py +8 -0
  18. package/hooks/blocking/gh_pr_author_enforcer.py +7 -0
  19. package/hooks/blocking/hedging_language_blocker.py +16 -10
  20. package/hooks/blocking/hook_prose_detector_consistency.py +7 -0
  21. package/hooks/blocking/intent_only_ending_blocker.py +17 -11
  22. package/hooks/blocking/md_to_html_blocker.py +17 -10
  23. package/hooks/blocking/open_questions_in_plans_blocker.py +15 -8
  24. package/hooks/blocking/package_inventory_stale_blocker.py +398 -0
  25. package/hooks/blocking/plain_language_blocker.py +57 -16
  26. package/hooks/blocking/pr_converge_bugteam_enforcer.py +11 -5
  27. package/hooks/blocking/pr_description_enforcer.py +6 -0
  28. package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
  29. package/hooks/blocking/precommit_code_rules_gate.py +10 -1
  30. package/hooks/blocking/pytest_testpaths_orphan_blocker.py +366 -0
  31. package/hooks/blocking/question_to_user_enforcer.py +18 -12
  32. package/hooks/blocking/send_user_file_open_locally_blocker.py +70 -0
  33. package/hooks/blocking/sensitive_file_protector.py +15 -1
  34. package/hooks/blocking/session_handoff_blocker.py +14 -8
  35. package/hooks/blocking/state_description_blocker.py +81 -36
  36. package/hooks/blocking/subprocess_budget_completeness.py +9 -3
  37. package/hooks/blocking/tdd_enforcer.py +6 -0
  38. package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +81 -0
  39. package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +93 -0
  40. package/hooks/blocking/test_code_rules_enforcer_docstring_returns_plural_cardinality.py +207 -0
  41. package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +262 -0
  42. package/hooks/blocking/test_code_rules_enforcer_docstring_undefined_constant.py +253 -0
  43. package/hooks/blocking/test_code_rules_enforcer_docstring_unguarded_payload.py +188 -0
  44. package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
  45. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +501 -0
  46. package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +203 -0
  47. package/hooks/blocking/test_hook_block_logger_coverage.py +53 -0
  48. package/hooks/blocking/test_package_inventory_stale_blocker.py +329 -0
  49. package/hooks/blocking/test_plain_language_blocker.py +36 -0
  50. package/hooks/blocking/test_pre_tool_use_dispatcher.py +816 -0
  51. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +341 -0
  52. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +247 -0
  53. package/hooks/blocking/test_send_user_file_open_locally_blocker.py +114 -0
  54. package/hooks/blocking/test_shared_stdin_adoption.py +208 -0
  55. package/hooks/blocking/test_state_description_blocker.py +41 -0
  56. package/hooks/blocking/test_verdict_directory_write_blocker.py +49 -0
  57. package/hooks/blocking/test_workflow_substitution_slot_blocker.py +4 -19
  58. package/hooks/blocking/verdict_directory_write_blocker.py +21 -7
  59. package/hooks/blocking/verified_commit_gate.py +11 -0
  60. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +16 -1
  61. package/hooks/blocking/windows_rmtree_blocker.py +7 -0
  62. package/hooks/blocking/workflow_substitution_slot_blocker.py +10 -5
  63. package/hooks/blocking/write_existing_file_blocker.py +16 -1
  64. package/hooks/hooks.json +19 -79
  65. package/hooks/hooks_constants/CLAUDE.md +7 -1
  66. package/hooks/hooks_constants/blocking_check_limits.py +74 -0
  67. package/hooks/hooks_constants/code_rules_enforcer_constants.py +9 -0
  68. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +45 -0
  69. package/hooks/hooks_constants/dead_config_field_constants.py +5 -5
  70. package/hooks/hooks_constants/docstring_rule_gate_count_blocker_constants.py +90 -0
  71. package/hooks/hooks_constants/hook_block_logger.py +59 -0
  72. package/hooks/hooks_constants/multi_edit_reconstruction.py +56 -0
  73. package/hooks/hooks_constants/mypy_validator_cache_constants.py +36 -0
  74. package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +111 -0
  75. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +68 -0
  76. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +143 -0
  77. package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
  78. package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +18 -0
  79. package/hooks/hooks_constants/test_dispatcher_constants_docstrings.py +44 -0
  80. package/hooks/hooks_constants/test_hook_block_logger.py +159 -0
  81. package/hooks/lifecycle/config_change_guard.py +12 -0
  82. package/hooks/lifecycle/test_config_change_guard.py +23 -0
  83. package/hooks/validation/hook_format_validator.py +13 -0
  84. package/hooks/validation/mypy_validator.py +245 -18
  85. package/hooks/validation/post_tool_use_dispatcher.py +344 -0
  86. package/hooks/validation/test_hook_format_validator.py +64 -0
  87. package/hooks/validation/test_mypy_validator.py +206 -1
  88. package/hooks/validation/test_post_tool_use_dispatcher.py +610 -0
  89. package/hooks/workflow/test_auto_formatter.py +10 -9
  90. package/package.json +1 -1
  91. package/rules/CLAUDE.md +1 -0
  92. package/rules/docstring-prose-matches-implementation.md +4 -2
  93. package/rules/package-inventory-stale-entry.md +24 -0
  94. package/skills/autoconverge/SKILL.md +111 -1
  95. package/skills/autoconverge/workflow/converge.contract.test.mjs +106 -0
  96. package/skills/autoconverge/workflow/converge.mjs +29 -3
  97. package/skills/autoconverge/workflow/converge.path-aware.test.mjs +47 -0
  98. package/skills/autoconverge/workflow/converge_multi.mjs +161 -0
  99. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +100 -0
@@ -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,
@@ -140,20 +141,69 @@ def _collect_prose_for_tool(tool_name: str, tool_input: dict) -> str:
140
141
  return ""
141
142
 
142
143
 
143
- def _emit_deny(all_matches: list[tuple[str, str]], output_stream: TextIO) -> None:
144
- deny_payload = {
144
+ def build_deny_payload(deny_reason: str) -> dict[str, object]:
145
+ """Build the full deny payload the hook writes for a deny-reason string.
146
+
147
+ The payload carries the core permission decision plus the user-facing notice
148
+ and output suppression, so a caller routing this hook through a dispatcher
149
+ reproduces the same deny shape the standalone hook writes.
150
+
151
+ Args:
152
+ deny_reason: The permissionDecisionReason text for the denial.
153
+
154
+ Returns:
155
+ The deny payload dictionary the hook serializes to stdout.
156
+ """
157
+ log_hook_block(
158
+ calling_hook_name="plain_language_blocker.py",
159
+ hook_event="PreToolUse",
160
+ block_reason=deny_reason,
161
+ )
162
+ return {
145
163
  "hookSpecificOutput": {
146
164
  "hookEventName": "PreToolUse",
147
165
  "permissionDecision": "deny",
148
- "permissionDecisionReason": build_block_reason(all_matches),
166
+ "permissionDecisionReason": deny_reason,
149
167
  },
150
168
  "systemMessage": USER_FACING_PLAIN_LANGUAGE_NOTICE,
151
169
  "suppressOutput": True,
152
170
  }
153
- output_stream.write(json.dumps(deny_payload))
171
+
172
+
173
+ def _emit_deny(deny_reason: str, output_stream: TextIO) -> None:
174
+ output_stream.write(json.dumps(build_deny_payload(deny_reason)))
154
175
  output_stream.flush()
155
176
 
156
177
 
178
+ def evaluate(payload_by_key: dict[str, object]) -> str | None:
179
+ """Decide whether a payload's prose carries heavy words to block.
180
+
181
+ Collects the prose for the payload's tool, scans it for banned terms, and
182
+ returns the deny-reason text when any heavy word is found, or None to allow.
183
+
184
+ Args:
185
+ payload_by_key: The PreToolUse payload with tool_name and tool_input.
186
+
187
+ Returns:
188
+ The permissionDecisionReason text when the prose is denied, or None when
189
+ the prose is allowed.
190
+ """
191
+ raw_tool_name = payload_by_key.get("tool_name", "")
192
+ raw_tool_input = payload_by_key.get("tool_input", {})
193
+ if not isinstance(raw_tool_name, str) or not isinstance(raw_tool_input, dict):
194
+ return None
195
+
196
+ prose_text = _collect_prose_for_tool(raw_tool_name, raw_tool_input)
197
+ if not prose_text:
198
+ return None
199
+
200
+ all_matches = find_banned_terms(prose_text)
201
+ if not all_matches:
202
+ return None
203
+
204
+ return build_block_reason(all_matches)
205
+
206
+
157
207
  def main() -> None:
158
208
  try:
159
209
  input_data = json.load(sys.stdin)
@@ -163,20 +213,11 @@ def main() -> None:
163
213
  if not isinstance(input_data, dict):
164
214
  sys.exit(0)
165
215
 
166
- tool_name = input_data.get("tool_name", "")
167
- tool_input = input_data.get("tool_input", {})
168
- if not isinstance(tool_name, str) or not isinstance(tool_input, dict):
169
- sys.exit(0)
170
-
171
- prose_text = _collect_prose_for_tool(tool_name, tool_input)
172
- if not prose_text:
173
- sys.exit(0)
174
-
175
- all_matches = find_banned_terms(prose_text)
176
- if not all_matches:
216
+ deny_reason = evaluate(input_data)
217
+ if deny_reason is None:
177
218
  sys.exit(0)
178
219
 
179
- _emit_deny(all_matches, sys.stdout)
220
+ _emit_deny(deny_reason, sys.stdout)
180
221
  sys.exit(0)
181
222
 
182
223
 
@@ -45,6 +45,10 @@ 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
49
+ from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
50
+ read_hook_input_dictionary_from_stdin,
51
+ )
48
52
 
49
53
 
50
54
  def _prompt_is_audit_shaped(agent_prompt: str) -> bool:
@@ -143,16 +147,18 @@ def _emit_deny_payload(output_stream: TextIO) -> None:
143
147
  "permissionDecisionReason": ENFORCER_CORRECTIVE_MESSAGE,
144
148
  }
145
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
+ )
146
155
  output_stream.write(json.dumps(deny_payload) + "\n")
147
156
  output_stream.flush()
148
157
 
149
158
 
150
159
  def main() -> None:
151
- try:
152
- hook_payload = json.load(sys.stdin)
153
- except json.JSONDecodeError:
154
- sys.exit(0)
155
- if not isinstance(hook_payload, dict):
160
+ hook_payload = read_hook_input_dictionary_from_stdin()
161
+ if hook_payload is None:
156
162
  sys.exit(0)
157
163
  if not _should_block(hook_payload):
158
164
  sys.exit(0)
@@ -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