claude-dev-env 1.71.0 → 1.73.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 (68) hide show
  1. package/CLAUDE.md +8 -0
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +5 -3
  3. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +39 -0
  4. package/agents/clean-coder.md +1 -0
  5. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +2 -2
  6. package/bin/install.mjs +73 -5
  7. package/bin/install.test.mjs +360 -4
  8. package/docs/CODE_RULES.md +1 -1
  9. package/hooks/blocking/CLAUDE.md +3 -1
  10. package/hooks/blocking/claude_md_orphan_file_blocker.py +5 -6
  11. package/hooks/blocking/code_rules_dead_config_field.py +69 -56
  12. package/hooks/blocking/code_rules_docstrings.py +676 -0
  13. package/hooks/blocking/code_rules_enforcer.py +26 -0
  14. package/hooks/blocking/code_rules_shared.py +19 -0
  15. package/hooks/blocking/code_rules_test_assertions.py +152 -1
  16. package/hooks/blocking/code_rules_type_escape.py +447 -2
  17. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +420 -0
  18. package/hooks/blocking/md_to_html_blocker.py +7 -8
  19. package/hooks/blocking/open_questions_in_plans_blocker.py +5 -6
  20. package/hooks/blocking/plain_language_blocker.py +51 -16
  21. package/hooks/blocking/pr_converge_bugteam_enforcer.py +5 -5
  22. package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
  23. package/hooks/blocking/pytest_testpaths_orphan_blocker.py +358 -0
  24. package/hooks/blocking/state_description_blocker.py +75 -36
  25. package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +81 -0
  26. package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +93 -0
  27. package/hooks/blocking/test_code_rules_enforcer_docstring_no_consumer.py +93 -0
  28. package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +262 -0
  29. package/hooks/blocking/test_code_rules_enforcer_docstring_undefined_constant.py +253 -0
  30. package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
  31. package/hooks/blocking/test_code_rules_enforcer_object_parameter.py +499 -0
  32. package/hooks/blocking/test_code_rules_enforcer_stale_test_name.py +103 -0
  33. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +456 -0
  34. package/hooks/blocking/test_pre_tool_use_dispatcher.py +816 -0
  35. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +341 -0
  36. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +247 -0
  37. package/hooks/blocking/test_shared_stdin_adoption.py +166 -0
  38. package/hooks/blocking/verdict_directory_write_blocker.py +12 -7
  39. package/hooks/hooks.json +9 -79
  40. package/hooks/hooks_constants/CLAUDE.md +3 -1
  41. package/hooks/hooks_constants/blocking_check_limits.py +75 -0
  42. package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
  43. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +45 -0
  44. package/hooks/hooks_constants/dead_config_field_constants.py +5 -5
  45. package/hooks/hooks_constants/mypy_validator_cache_constants.py +36 -0
  46. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +69 -0
  47. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +135 -0
  48. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
  49. package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
  50. package/hooks/validation/mypy_validator.py +215 -17
  51. package/hooks/validation/post_tool_use_dispatcher.py +344 -0
  52. package/hooks/validation/test_mypy_validator.py +184 -1
  53. package/hooks/validation/test_post_tool_use_dispatcher.py +610 -0
  54. package/hooks/workflow/test_auto_formatter.py +10 -9
  55. package/package.json +1 -1
  56. package/rules/docstring-prose-matches-implementation.md +3 -2
  57. package/scripts/CLAUDE.md +1 -0
  58. package/scripts/Show-Asset.ps1 +106 -0
  59. package/skills/autoconverge/SKILL.md +123 -3
  60. package/skills/autoconverge/reference/convergence.md +41 -1
  61. package/skills/autoconverge/workflow/converge.contract.test.mjs +90 -0
  62. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +98 -0
  63. package/skills/autoconverge/workflow/converge.mjs +203 -8
  64. package/skills/autoconverge/workflow/converge.path-aware.test.mjs +47 -0
  65. package/skills/autoconverge/workflow/converge_multi.mjs +161 -0
  66. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +100 -0
  67. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +47 -3
  68. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +34 -0
@@ -0,0 +1,420 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: pre-flight gate for the code-verifier subagent spawn.
3
+
4
+ The hook fires only on an ``Agent`` tool call whose ``subagent_type`` is
5
+ ``code-verifier``. Before that verification spawn runs, the hook checks the
6
+ branch for two committability problems against the resolved base ref: a real
7
+ merge conflict (a non-mutating trial-merge of HEAD against the base ref) and a
8
+ CODE_RULES violation on a line added in the uncommitted working tree. When
9
+ either fires, the hook denies the spawn with a reason addressed to the spawning
10
+ agent that names the conflicting files and the violating file:line, so that
11
+ agent fixes them and re-spawns. Both checks fail OPEN on any infrastructure
12
+ problem — a non-repo cwd, an absent base ref, a git or engine failure, or a
13
+ timeout — because the authoritative fail-closed CODE_RULES enforcement already
14
+ runs at Write time and at commit time. The hook never network-fetches and never
15
+ mutates the index or working tree.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import contextlib
21
+ import io
22
+ import json
23
+ import subprocess
24
+ import sys
25
+ from pathlib import Path
26
+ from typing import TextIO
27
+
28
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
29
+ if _hooks_dir not in sys.path:
30
+ sys.path.insert(0, _hooks_dir)
31
+
32
+ _blocking_dir = str(Path(__file__).resolve().parent)
33
+ if _blocking_dir not in sys.path:
34
+ sys.path.insert(0, _blocking_dir)
35
+
36
+ from verification_verdict_store import ( # noqa: E402
37
+ candidate_base_references,
38
+ resolve_merge_base,
39
+ resolve_repo_root,
40
+ run_git,
41
+ untracked_file_paths,
42
+ )
43
+
44
+ from hooks_constants.code_verifier_spawn_preflight_gate_constants import ( # noqa: E402
45
+ ALL_MERGE_TREE_COMMAND_FLAGS,
46
+ ALL_NAME_ONLY_WORKTREE_DIFF_FLAGS,
47
+ ALL_UNIFIED_ZERO_DIFF_FLAGS,
48
+ CODE_RULES_SECTION_HEADER,
49
+ CODE_VERIFIER_SUBAGENT_TYPE,
50
+ DENY_REASON_LEAD,
51
+ GATE_SCRIPTS_RELATIVE_PATH,
52
+ MERGE_CONFLICT_SECTION_HEADER,
53
+ MERGE_TREE_CLEAN_EXIT_CODE,
54
+ MERGE_TREE_CONFLICT_EXIT_CODE,
55
+ MERGE_TREE_TIMEOUT_SECONDS,
56
+ )
57
+ from hooks_constants.pr_converge_bugteam_enforcer_constants import ( # noqa: E402
58
+ AGENT_TOOL_NAME,
59
+ )
60
+
61
+ _scripts_dir = str(Path(__file__).resolve().parents[2] / GATE_SCRIPTS_RELATIVE_PATH)
62
+ if _scripts_dir not in sys.path:
63
+ sys.path.insert(0, _scripts_dir)
64
+
65
+ from code_rules_gate import ( # noqa: E402
66
+ ValidateContentCallable,
67
+ _collect_partitioned_violations,
68
+ _report_partitioned_violations,
69
+ is_code_path,
70
+ load_validate_content,
71
+ parse_added_line_numbers,
72
+ whole_file_line_set,
73
+ )
74
+
75
+
76
+ def _should_run(payload_by_field: dict[str, object]) -> bool:
77
+ """Return True only for a code-verifier Agent spawn.
78
+
79
+ Args:
80
+ payload_by_field: The full PreToolUse hook payload (already
81
+ JSON-parsed), keyed by top-level field name.
82
+
83
+ Returns:
84
+ True when the tool is Agent and ``tool_input.subagent_type`` is
85
+ ``code-verifier``; False for every other shape.
86
+ """
87
+ if payload_by_field.get("tool_name", "") != AGENT_TOOL_NAME:
88
+ return False
89
+ tool_input = payload_by_field.get("tool_input", {})
90
+ if not isinstance(tool_input, dict):
91
+ return False
92
+ return tool_input.get("subagent_type", "") == CODE_VERIFIER_SUBAGENT_TYPE
93
+
94
+
95
+ def _resolve_repo_root_and_base(working_directory: str | None) -> tuple[str, str, str] | None:
96
+ """Resolve the repo root, merge-base sha, and chosen base ref.
97
+
98
+ Args:
99
+ working_directory: The spawn's working directory from the payload, or
100
+ None when the payload carries no ``cwd``.
101
+
102
+ Returns:
103
+ A ``(repo_root, merge_base_sha, base_ref)`` triple, or None when the
104
+ directory is not a work tree or no base ref resolves on disk — the
105
+ caller fails OPEN on None.
106
+ """
107
+ start_directory = working_directory if working_directory else str(Path.cwd())
108
+ repo_root = resolve_repo_root(start_directory)
109
+ if repo_root is None:
110
+ return None
111
+ merge_base_sha = resolve_merge_base(repo_root)
112
+ if merge_base_sha is None:
113
+ return None
114
+ for each_reference in candidate_base_references(repo_root):
115
+ if run_git(repo_root, "merge-base", "HEAD", each_reference) is not None:
116
+ return repo_root, merge_base_sha, each_reference
117
+ return None
118
+
119
+
120
+ def _run_trial_merge(repo_root: str, base_ref: str) -> tuple[int, str] | None:
121
+ """Run the non-mutating trial-merge and return its exit code and stdout.
122
+
123
+ Args:
124
+ repo_root: The repository top-level directory.
125
+ base_ref: The base ref to trial-merge HEAD against.
126
+
127
+ Returns:
128
+ A ``(returncode, stdout)`` pair, or None when the command is missing,
129
+ times out, or raises an OS-level error — the caller fails OPEN on None.
130
+ """
131
+ try:
132
+ completed_process = subprocess.run(
133
+ ["git", "-C", repo_root, *ALL_MERGE_TREE_COMMAND_FLAGS, base_ref, "HEAD"],
134
+ capture_output=True,
135
+ text=True,
136
+ encoding="utf-8",
137
+ errors="replace",
138
+ timeout=MERGE_TREE_TIMEOUT_SECONDS,
139
+ check=False,
140
+ )
141
+ except (OSError, subprocess.TimeoutExpired):
142
+ return None
143
+ return completed_process.returncode, completed_process.stdout
144
+
145
+
146
+ def _conflicting_files(repo_root: str, base_ref: str) -> list[str] | None:
147
+ """Return the files that conflict when HEAD trial-merges against the base.
148
+
149
+ Args:
150
+ repo_root: The repository top-level directory.
151
+ base_ref: The base ref to trial-merge HEAD against.
152
+
153
+ Returns:
154
+ The conflicting file paths on a conflict exit, an empty list on a
155
+ clean merge, or None on any infrastructure failure (command missing,
156
+ timeout, or an exit code that is neither clean nor conflict) — the
157
+ caller fails OPEN on None.
158
+ """
159
+ merge_outcome = _run_trial_merge(repo_root, base_ref)
160
+ if merge_outcome is None:
161
+ return None
162
+ return_code, merge_stdout = merge_outcome
163
+ if return_code == MERGE_TREE_CLEAN_EXIT_CODE:
164
+ return []
165
+ if return_code != MERGE_TREE_CONFLICT_EXIT_CODE:
166
+ return None
167
+ return _parse_conflicting_paths(merge_stdout)
168
+
169
+
170
+ def _parse_conflicting_paths(merge_stdout: str) -> list[str]:
171
+ """Extract conflicting paths from trial-merge stdout.
172
+
173
+ Args:
174
+ merge_stdout: The stdout of a conflict-exit trial-merge: the written
175
+ tree-OID line, then one conflicting path per line, then a blank
176
+ line and informational text.
177
+
178
+ Returns:
179
+ The conflicting file paths — the lines after the tree-OID up to the
180
+ first blank line.
181
+ """
182
+ all_lines = merge_stdout.splitlines()
183
+ conflicting_paths: list[str] = []
184
+ for each_line in all_lines[1:]:
185
+ if not each_line.strip():
186
+ break
187
+ conflicting_paths.append(each_line)
188
+ return conflicting_paths
189
+
190
+
191
+ def _working_tree_added_lines_by_path(
192
+ repo_root: str, merge_base_sha: str
193
+ ) -> tuple[list[Path], dict[Path, set[int]]] | None:
194
+ """Build the code-file surface and its working-tree-vs-merge-base added lines.
195
+
196
+ Args:
197
+ repo_root: The repository top-level directory.
198
+ merge_base_sha: The merge-base sha the surface diffs against.
199
+
200
+ Returns:
201
+ A ``(file_paths, added_lines_by_path)`` pair keyed by resolved
202
+ absolute path, or None when a surface git query fails — the caller
203
+ fails OPEN on None.
204
+ """
205
+ tracked_changed_text = run_git(repo_root, *ALL_NAME_ONLY_WORKTREE_DIFF_FLAGS, merge_base_sha)
206
+ if tracked_changed_text is None:
207
+ return None
208
+ untracked_paths = untracked_file_paths(repo_root)
209
+ if untracked_paths is None:
210
+ return None
211
+ repo_root_path = Path(repo_root)
212
+ file_paths: list[Path] = []
213
+ added_lines_by_path: dict[Path, set[int]] = {}
214
+ for each_relative in tracked_changed_text.splitlines():
215
+ if not each_relative or not is_code_path(Path(each_relative)):
216
+ continue
217
+ resolved_path = (repo_root_path / each_relative).resolve()
218
+ file_paths.append(resolved_path)
219
+ added_lines_by_path[resolved_path] = _tracked_file_added_lines(
220
+ repo_root, merge_base_sha, each_relative
221
+ )
222
+ for each_relative in untracked_paths:
223
+ if not is_code_path(Path(each_relative)):
224
+ continue
225
+ resolved_path = (repo_root_path / each_relative).resolve()
226
+ file_paths.append(resolved_path)
227
+ added_lines_by_path[resolved_path] = whole_file_line_set(resolved_path)
228
+ return file_paths, added_lines_by_path
229
+
230
+
231
+ def _tracked_file_added_lines(repo_root: str, merge_base_sha: str, relative_path: str) -> set[int]:
232
+ """Return the working-tree-added line numbers for one tracked file.
233
+
234
+ Args:
235
+ repo_root: The repository top-level directory.
236
+ merge_base_sha: The merge-base sha the diff runs against.
237
+ relative_path: The repo-relative path of the tracked changed file.
238
+
239
+ Returns:
240
+ The 1-indexed line numbers added vs the merge base in the working
241
+ tree, or an empty set when the per-file diff fails.
242
+ """
243
+ unified_diff_text = run_git(
244
+ repo_root, *ALL_UNIFIED_ZERO_DIFF_FLAGS, merge_base_sha, "--", relative_path
245
+ )
246
+ if unified_diff_text is None:
247
+ return set()
248
+ return parse_added_line_numbers(unified_diff_text)
249
+
250
+
251
+ def _code_rules_report(
252
+ repo_root: str, all_file_paths: list[Path], all_added_lines_by_path: dict[Path, set[int]]
253
+ ) -> str | None:
254
+ """Run the CODE_RULES engine and return its blocking report, or None.
255
+
256
+ Args:
257
+ repo_root: The repository top-level directory.
258
+ all_file_paths: The resolved code-file paths to inspect.
259
+ all_added_lines_by_path: Per-file working-tree-added line numbers keyed
260
+ by resolved absolute path.
261
+
262
+ Returns:
263
+ The engine's grouped file:line report when a blocking violation lands
264
+ on an added line, or None when the surface is clean, only an unreadable
265
+ changed file caused a non-zero gate exit, the engine fails to load, or
266
+ any engine error arises — every non-block outcome fails OPEN. The
267
+ harness hook timeout in hooks.json is the wall-clock bound on a runaway
268
+ engine.
269
+ """
270
+ if not all_file_paths:
271
+ return None
272
+ try:
273
+ validate_content = load_validate_content()
274
+ except SystemExit:
275
+ return None
276
+ try:
277
+ blocking_present, captured_report = _run_gate_capturing_stderr(
278
+ validate_content, all_file_paths, Path(repo_root), all_added_lines_by_path
279
+ )
280
+ except OSError:
281
+ return None
282
+ if not blocking_present:
283
+ return None
284
+ return captured_report
285
+
286
+
287
+ def _run_gate_capturing_stderr(
288
+ validate_content: ValidateContentCallable,
289
+ all_file_paths: list[Path],
290
+ repository_root: Path,
291
+ all_added_lines_by_path: dict[Path, set[int]],
292
+ ) -> tuple[bool, str]:
293
+ """Run the gate, reporting whether a blocking violation was actually found.
294
+
295
+ Args:
296
+ validate_content: The enforcer ``validate_content`` callable.
297
+ all_file_paths: The resolved code-file paths to inspect.
298
+ repository_root: The repository root path the gate resolves against.
299
+ all_added_lines_by_path: Per-file working-tree-added line numbers keyed
300
+ by resolved absolute path.
301
+
302
+ Returns:
303
+ A ``(blocking_present, captured_report)`` pair. ``blocking_present`` is
304
+ True only when at least one blocking violation landed on an added line;
305
+ an unreadable changed file alone (which exits the gate non-zero) leaves
306
+ it False, so the caller fails OPEN. ``captured_report`` is the gate's
307
+ grouped stderr report.
308
+ """
309
+ blocking_by_file, advisory_by_file, skipped_unreadable_count = (
310
+ _collect_partitioned_violations(
311
+ validate_content,
312
+ all_file_paths,
313
+ repository_root,
314
+ all_added_lines_by_path,
315
+ False,
316
+ )
317
+ )
318
+ captured_stderr = io.StringIO()
319
+ with contextlib.redirect_stderr(captured_stderr):
320
+ _report_partitioned_violations(
321
+ blocking_by_file,
322
+ advisory_by_file,
323
+ repository_root,
324
+ False,
325
+ skipped_unreadable_count,
326
+ )
327
+ return bool(blocking_by_file), captured_stderr.getvalue()
328
+
329
+
330
+ def _build_deny_reason(
331
+ all_conflicting_files: list[str] | None, base_ref: str, code_rules_report: str | None
332
+ ) -> str | None:
333
+ """Assemble the spawner-addressed deny reason from the two check results.
334
+
335
+ Args:
336
+ all_conflicting_files: The conflicting file paths from the conflict
337
+ check, an empty list when clean, or None when that check failed open.
338
+ base_ref: The base ref named in the conflict section header.
339
+ code_rules_report: The grouped report from the CODE_RULES check, or None
340
+ when that check found nothing or failed open.
341
+
342
+ Returns:
343
+ The full deny reason when either check fired, or None when neither
344
+ produced an issue.
345
+ """
346
+ reason_sections: list[str] = []
347
+ if all_conflicting_files:
348
+ conflict_lines = "\n".join(f" {each_path}" for each_path in all_conflicting_files)
349
+ conflict_header = MERGE_CONFLICT_SECTION_HEADER.format(base_ref=base_ref)
350
+ reason_sections.append(f"{conflict_header}\n{conflict_lines}")
351
+ if code_rules_report:
352
+ reason_sections.append(f"{CODE_RULES_SECTION_HEADER}\n{code_rules_report.strip()}")
353
+ if not reason_sections:
354
+ return None
355
+ return DENY_REASON_LEAD + "\n\n" + "\n\n".join(reason_sections)
356
+
357
+
358
+ def _emit_deny_payload(output_stream: TextIO, reason: str) -> None:
359
+ """Write the PreToolUse deny payload to the provided stream.
360
+
361
+ Args:
362
+ output_stream: Writable text stream — production code passes
363
+ ``sys.stdout``; tests pass a ``StringIO`` to capture the JSON.
364
+ reason: The ``permissionDecisionReason`` text for the deny payload.
365
+ """
366
+ deny_payload = {
367
+ "hookSpecificOutput": {
368
+ "hookEventName": "PreToolUse",
369
+ "permissionDecision": "deny",
370
+ "permissionDecisionReason": reason,
371
+ }
372
+ }
373
+ output_stream.write(json.dumps(deny_payload) + "\n")
374
+ output_stream.flush()
375
+
376
+
377
+ def _preflight_deny_reason(payload_by_field: dict[str, object]) -> str | None:
378
+ """Run both pre-flight checks and return a deny reason, or None to allow.
379
+
380
+ Args:
381
+ payload_by_field: The full PreToolUse hook payload, keyed by top-level
382
+ field name.
383
+
384
+ Returns:
385
+ The deny reason when a check fired, or None when both checks pass or
386
+ fail open.
387
+ """
388
+ working_directory = payload_by_field.get("cwd")
389
+ resolution = _resolve_repo_root_and_base(
390
+ working_directory if isinstance(working_directory, str) else None
391
+ )
392
+ if resolution is None:
393
+ return None
394
+ repo_root, merge_base_sha, base_ref = resolution
395
+ conflicting_files = _conflicting_files(repo_root, base_ref)
396
+ surface = _working_tree_added_lines_by_path(repo_root, merge_base_sha)
397
+ code_rules_report = None
398
+ if surface is not None:
399
+ file_paths, added_lines_by_path = surface
400
+ code_rules_report = _code_rules_report(repo_root, file_paths, added_lines_by_path)
401
+ return _build_deny_reason(conflicting_files, base_ref, code_rules_report)
402
+
403
+
404
+ def main() -> None:
405
+ try:
406
+ hook_payload = json.load(sys.stdin)
407
+ except json.JSONDecodeError:
408
+ sys.exit(0)
409
+ if not isinstance(hook_payload, dict):
410
+ sys.exit(0)
411
+ if not _should_run(hook_payload):
412
+ sys.exit(0)
413
+ deny_reason = _preflight_deny_reason(hook_payload)
414
+ if deny_reason is not None:
415
+ _emit_deny_payload(sys.stdout, deny_reason)
416
+ sys.exit(0)
417
+
418
+
419
+ if __name__ == "__main__":
420
+ main()
@@ -18,6 +18,8 @@ _blocking_directory = str(Path(__file__).resolve().parent)
18
18
  if _blocking_directory not in sys.path:
19
19
  sys.path.insert(0, _blocking_directory)
20
20
 
21
+ from md_path_exemptions import is_exempt_path # noqa: E402
22
+
21
23
  from hooks_constants.md_to_html_blocker_constants import ( # noqa: E402
22
24
  ALL_CLAUDE_CODE_SOURCE_TOP_DIRECTORIES,
23
25
  ALL_EXEMPT_ANYWHERE_FILENAMES,
@@ -29,8 +31,9 @@ from hooks_constants.md_to_html_blocker_constants import ( # noqa: E402
29
31
  PACKAGES_TOP_LEVEL_SEGMENT,
30
32
  PLUGIN_ROOT_MARKER_DIRECTORY_NAME,
31
33
  )
32
- from md_path_exemptions import is_exempt_path # noqa: E402
33
-
34
+ from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
35
+ read_hook_input_dictionary_from_stdin,
36
+ )
34
37
 
35
38
  _markdown_extension = ".md"
36
39
  _html_effectiveness_url = "https://thariqs.github.io/html-effectiveness/"
@@ -95,12 +98,8 @@ def main() -> None:
95
98
  Returns:
96
99
  None (exits process).
97
100
  """
98
- try:
99
- input_data = json.load(sys.stdin)
100
- except json.JSONDecodeError:
101
- sys.exit(0)
102
-
103
- if not isinstance(input_data, dict):
101
+ input_data = read_hook_input_dictionary_from_stdin()
102
+ if input_data is None:
104
103
  sys.exit(0)
105
104
 
106
105
  tool_name = input_data.get("tool_name", "")
@@ -31,6 +31,9 @@ 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.pre_tool_use_stdin import ( # noqa: E402
35
+ read_hook_input_dictionary_from_stdin,
36
+ )
34
37
 
35
38
 
36
39
  def _is_markdown_file(file_path: str) -> bool:
@@ -204,12 +207,8 @@ def _emit_hook_result(payload: dict, output_stream: TextIO) -> None:
204
207
 
205
208
 
206
209
  def main() -> None:
207
- try:
208
- input_data = json.load(sys.stdin)
209
- except json.JSONDecodeError:
210
- sys.exit(0)
211
-
212
- if not isinstance(input_data, dict):
210
+ input_data = read_hook_input_dictionary_from_stdin()
211
+ if input_data is None:
213
212
  sys.exit(0)
214
213
 
215
214
  tool_name = input_data.get("tool_name", "")
@@ -140,20 +140,64 @@ def _collect_prose_for_tool(tool_name: str, tool_input: dict) -> str:
140
140
  return ""
141
141
 
142
142
 
143
- def _emit_deny(all_matches: list[tuple[str, str]], output_stream: TextIO) -> None:
144
- deny_payload = {
143
+ def build_deny_payload(deny_reason: str) -> dict[str, object]:
144
+ """Build the full deny payload the hook writes for a deny-reason string.
145
+
146
+ The payload carries the core permission decision plus the user-facing notice
147
+ and output suppression, so a caller routing this hook through a dispatcher
148
+ reproduces the same deny shape the standalone hook writes.
149
+
150
+ Args:
151
+ deny_reason: The permissionDecisionReason text for the denial.
152
+
153
+ Returns:
154
+ The deny payload dictionary the hook serializes to stdout.
155
+ """
156
+ return {
145
157
  "hookSpecificOutput": {
146
158
  "hookEventName": "PreToolUse",
147
159
  "permissionDecision": "deny",
148
- "permissionDecisionReason": build_block_reason(all_matches),
160
+ "permissionDecisionReason": deny_reason,
149
161
  },
150
162
  "systemMessage": USER_FACING_PLAIN_LANGUAGE_NOTICE,
151
163
  "suppressOutput": True,
152
164
  }
153
- output_stream.write(json.dumps(deny_payload))
165
+
166
+
167
+ def _emit_deny(deny_reason: str, output_stream: TextIO) -> None:
168
+ output_stream.write(json.dumps(build_deny_payload(deny_reason)))
154
169
  output_stream.flush()
155
170
 
156
171
 
172
+ def evaluate(payload_by_key: dict[str, object]) -> str | None:
173
+ """Decide whether a payload's prose carries heavy words to block.
174
+
175
+ Collects the prose for the payload's tool, scans it for banned terms, and
176
+ returns the deny-reason text when any heavy word is found, or None to allow.
177
+
178
+ Args:
179
+ payload_by_key: The PreToolUse payload with tool_name and tool_input.
180
+
181
+ Returns:
182
+ The permissionDecisionReason text when the prose is denied, or None when
183
+ the prose is allowed.
184
+ """
185
+ raw_tool_name = payload_by_key.get("tool_name", "")
186
+ raw_tool_input = payload_by_key.get("tool_input", {})
187
+ if not isinstance(raw_tool_name, str) or not isinstance(raw_tool_input, dict):
188
+ return None
189
+
190
+ prose_text = _collect_prose_for_tool(raw_tool_name, raw_tool_input)
191
+ if not prose_text:
192
+ return None
193
+
194
+ all_matches = find_banned_terms(prose_text)
195
+ if not all_matches:
196
+ return None
197
+
198
+ return build_block_reason(all_matches)
199
+
200
+
157
201
  def main() -> None:
158
202
  try:
159
203
  input_data = json.load(sys.stdin)
@@ -163,20 +207,11 @@ def main() -> None:
163
207
  if not isinstance(input_data, dict):
164
208
  sys.exit(0)
165
209
 
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:
210
+ deny_reason = evaluate(input_data)
211
+ if deny_reason is None:
177
212
  sys.exit(0)
178
213
 
179
- _emit_deny(all_matches, sys.stdout)
214
+ _emit_deny(deny_reason, sys.stdout)
180
215
  sys.exit(0)
181
216
 
182
217
 
@@ -45,6 +45,9 @@ 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.pre_tool_use_stdin import ( # noqa: E402
49
+ read_hook_input_dictionary_from_stdin,
50
+ )
48
51
 
49
52
 
50
53
  def _prompt_is_audit_shaped(agent_prompt: str) -> bool:
@@ -148,11 +151,8 @@ def _emit_deny_payload(output_stream: TextIO) -> None:
148
151
 
149
152
 
150
153
  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):
154
+ hook_payload = read_hook_input_dictionary_from_stdin()
155
+ if hook_payload is None:
156
156
  sys.exit(0)
157
157
  if not _should_block(hook_payload):
158
158
  sys.exit(0)