claude-dev-env 1.72.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 (50) hide show
  1. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +2 -2
  2. package/bin/install.mjs +73 -5
  3. package/bin/install.test.mjs +360 -4
  4. package/hooks/blocking/CLAUDE.md +3 -1
  5. package/hooks/blocking/claude_md_orphan_file_blocker.py +5 -6
  6. package/hooks/blocking/code_rules_dead_config_field.py +69 -56
  7. package/hooks/blocking/code_rules_docstrings.py +616 -0
  8. package/hooks/blocking/code_rules_enforcer.py +22 -0
  9. package/hooks/blocking/code_rules_shared.py +19 -0
  10. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +420 -0
  11. package/hooks/blocking/md_to_html_blocker.py +7 -8
  12. package/hooks/blocking/open_questions_in_plans_blocker.py +5 -6
  13. package/hooks/blocking/plain_language_blocker.py +51 -16
  14. package/hooks/blocking/pr_converge_bugteam_enforcer.py +5 -5
  15. package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
  16. package/hooks/blocking/pytest_testpaths_orphan_blocker.py +358 -0
  17. package/hooks/blocking/state_description_blocker.py +75 -36
  18. package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +81 -0
  19. package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +93 -0
  20. package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +262 -0
  21. package/hooks/blocking/test_code_rules_enforcer_docstring_undefined_constant.py +253 -0
  22. package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
  23. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +456 -0
  24. package/hooks/blocking/test_pre_tool_use_dispatcher.py +816 -0
  25. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +341 -0
  26. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +247 -0
  27. package/hooks/blocking/test_shared_stdin_adoption.py +166 -0
  28. package/hooks/blocking/verdict_directory_write_blocker.py +12 -7
  29. package/hooks/hooks.json +9 -79
  30. package/hooks/hooks_constants/CLAUDE.md +3 -1
  31. package/hooks/hooks_constants/blocking_check_limits.py +61 -0
  32. package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
  33. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +45 -0
  34. package/hooks/hooks_constants/dead_config_field_constants.py +5 -5
  35. package/hooks/hooks_constants/mypy_validator_cache_constants.py +36 -0
  36. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +69 -0
  37. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +135 -0
  38. package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
  39. package/hooks/validation/mypy_validator.py +215 -17
  40. package/hooks/validation/post_tool_use_dispatcher.py +344 -0
  41. package/hooks/validation/test_mypy_validator.py +184 -1
  42. package/hooks/validation/test_post_tool_use_dispatcher.py +610 -0
  43. package/hooks/workflow/test_auto_formatter.py +10 -9
  44. package/package.json +1 -1
  45. package/rules/docstring-prose-matches-implementation.md +2 -1
  46. package/skills/autoconverge/SKILL.md +93 -0
  47. package/skills/autoconverge/workflow/converge.mjs +27 -2
  48. package/skills/autoconverge/workflow/converge.path-aware.test.mjs +47 -0
  49. package/skills/autoconverge/workflow/converge_multi.mjs +161 -0
  50. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +100 -0
@@ -0,0 +1,545 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse dispatcher that hosts Write, Edit, and MultiEdit blocking hooks.
3
+
4
+ Reads the tool payload from stdin once, selects the hosted hooks applicable to
5
+ the payload's tool name, runs each hook in-process via runpy in the fixed order
6
+ declared in the constants module, aggregates the results, and emits one deny
7
+ decision when any hook denied (carrying every denying reason) or exits zero to
8
+ allow.
9
+
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)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import io
19
+ import json
20
+ import runpy
21
+ import sys
22
+ import traceback
23
+ from collections.abc import Callable
24
+ from dataclasses import dataclass, field
25
+ from pathlib import Path
26
+
27
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
28
+ if _hooks_directory not in sys.path:
29
+ sys.path.insert(0, _hooks_directory)
30
+
31
+ from plain_language_blocker import ( # noqa: E402
32
+ build_deny_payload as build_plain_language_deny_payload,
33
+ )
34
+ from plain_language_blocker import evaluate as evaluate_plain_language # noqa: E402
35
+ from state_description_blocker import ( # noqa: E402
36
+ build_deny_payload as build_state_description_deny_payload,
37
+ )
38
+ from state_description_blocker import evaluate as evaluate_state_description # noqa: E402
39
+
40
+ from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: E402
41
+ ALL_HOSTED_HOOK_ENTRIES,
42
+ ALLOW_DECISION,
43
+ BLOCKING_CRASH_EXIT_CODE,
44
+ DENY_DECISION,
45
+ EXIT_CODE_TWO_DENY_REASON,
46
+ HOOK_EVENT_NAME,
47
+ PLAIN_LANGUAGE_BLOCKER_MODULE_NAME,
48
+ STATE_DESCRIPTION_BLOCKER_MODULE_NAME,
49
+ HostedHookEntry,
50
+ )
51
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin # noqa: E402
52
+
53
+ NativeEvaluator = Callable[[dict[str, object]], str | None]
54
+ DenyPayloadBuilder = Callable[[str], dict[str, object]]
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class NativeHook:
59
+ """A nativized hook's evaluator paired with its full deny-payload builder.
60
+
61
+ Attributes:
62
+ evaluate: The hook's evaluate function returning a deny-reason or None.
63
+ build_deny_payload: The hook's builder that turns a deny-reason into the
64
+ full deny payload the standalone hook writes (carrying systemMessage,
65
+ additionalContext, and suppressOutput).
66
+ """
67
+
68
+ evaluate: NativeEvaluator
69
+ build_deny_payload: DenyPayloadBuilder
70
+
71
+
72
+ @dataclass
73
+ class HostedHookResult:
74
+ """Outcome of running one hosted hook inside the dispatcher process.
75
+
76
+ Attributes:
77
+ exit_code: The exit code the hook raised via SystemExit, or 0 when the
78
+ hook returned without raising.
79
+ captured_stdout: The text the hook wrote to stdout during its run.
80
+ did_crash: True when the hook raised a non-SystemExit exception.
81
+ is_blocking: True when this hook's crash surfaces a blocking signal.
82
+ """
83
+
84
+ exit_code: int
85
+ captured_stdout: str
86
+ did_crash: bool = field(default=False)
87
+ is_blocking: bool = field(default=True)
88
+
89
+
90
+ def _log_hook_crash(hook_script_path: str, error: Exception) -> None:
91
+ """Write a one-line crash summary to stderr.
92
+
93
+ Args:
94
+ hook_script_path: The absolute path of the hook that crashed.
95
+ error: The exception the hook raised.
96
+ """
97
+ formatted_traceback = traceback.format_exc().strip()
98
+ last_line = formatted_traceback.splitlines()[-1] if formatted_traceback else str(error)
99
+ error_type_name = type(error).__name__
100
+ sys.stderr.write(
101
+ f"[dispatcher] crash in {hook_script_path}: {error_type_name}: {error} | {last_line}\n"
102
+ )
103
+ sys.stderr.flush()
104
+
105
+
106
+ def run_hosted_hook(
107
+ hook_script_path: str,
108
+ payload_text: str,
109
+ is_blocking: bool,
110
+ ) -> HostedHookResult:
111
+ """Run one hosted hook in-process and return its outcome.
112
+
113
+ Sets stdin to a fresh stream over payload_text, sets argv to the hook's own
114
+ script path so a hook that branches on sys.argv (such as code_rules_enforcer's
115
+ --check pre-check mode) reads the same argv it would standalone rather than
116
+ the dispatcher's, captures stdout into a buffer, runs the hook via runpy
117
+ under __main__, catches SystemExit to read the exit code without ending the
118
+ dispatcher, and catches a non-SystemExit exception to log the crash and
119
+ classify it. Always restores stdin, stdout, and argv in the finally block.
120
+
121
+ Args:
122
+ hook_script_path: Absolute path of the hook script to run.
123
+ payload_text: The raw payload text to replay as the hook's stdin.
124
+ is_blocking: Whether a crash from this hook surfaces a blocking signal.
125
+
126
+ Returns:
127
+ A HostedHookResult carrying the exit code, captured stdout, crash flag,
128
+ and blocking classification.
129
+ """
130
+ original_stdin = sys.stdin
131
+ original_stdout = sys.stdout
132
+ original_argv = sys.argv
133
+ captured_output = io.StringIO()
134
+ hook_exit_code = 0
135
+ hook_did_crash = False
136
+
137
+ try:
138
+ sys.stdin = io.StringIO(payload_text)
139
+ sys.stdout = captured_output
140
+ sys.argv = [hook_script_path]
141
+ runpy.run_path(hook_script_path, run_name="__main__")
142
+ except SystemExit as exit_signal:
143
+ raw_code = exit_signal.code
144
+ hook_exit_code = raw_code if isinstance(raw_code, int) else 0
145
+ except Exception as error:
146
+ _log_hook_crash(hook_script_path, error)
147
+ hook_did_crash = True
148
+ hook_exit_code = BLOCKING_CRASH_EXIT_CODE if is_blocking else 0
149
+ finally:
150
+ sys.stdin = original_stdin
151
+ sys.stdout = original_stdout
152
+ sys.argv = original_argv
153
+
154
+ return HostedHookResult(
155
+ exit_code=hook_exit_code,
156
+ captured_stdout=captured_output.getvalue(),
157
+ did_crash=hook_did_crash,
158
+ is_blocking=is_blocking,
159
+ )
160
+
161
+
162
+ def run_native_hook(
163
+ native_hook: NativeHook,
164
+ payload_by_key: dict[str, object],
165
+ is_blocking: bool,
166
+ ) -> HostedHookResult:
167
+ """Run one hosted hook's native evaluator in-process and return its outcome.
168
+
169
+ Calls the evaluator directly with the payload dict, builds the full deny JSON
170
+ via the hook's own deny-payload builder so the captured stdout matches the
171
+ standalone hook's deny shape (carrying systemMessage, additionalContext, and
172
+ suppressOutput), and catches a non-SystemExit crash to log and classify it.
173
+
174
+ Args:
175
+ native_hook: The hook's evaluator paired with its deny-payload builder.
176
+ payload_by_key: The parsed payload dict to pass to the evaluator.
177
+ is_blocking: Whether a crash from this hook surfaces a blocking signal.
178
+
179
+ Returns:
180
+ A HostedHookResult carrying the captured deny JSON (empty when allowed),
181
+ the crash flag, and the blocking classification.
182
+ """
183
+ try:
184
+ deny_reason = native_hook.evaluate(payload_by_key)
185
+ except Exception as error:
186
+ _log_hook_crash(native_hook.evaluate.__module__, error)
187
+ return HostedHookResult(
188
+ exit_code=BLOCKING_CRASH_EXIT_CODE if is_blocking else 0,
189
+ captured_stdout="",
190
+ did_crash=True,
191
+ is_blocking=is_blocking,
192
+ )
193
+
194
+ captured_stdout = (
195
+ json.dumps(native_hook.build_deny_payload(deny_reason)) if deny_reason is not None else ""
196
+ )
197
+ return HostedHookResult(
198
+ exit_code=0,
199
+ captured_stdout=captured_stdout,
200
+ did_crash=False,
201
+ is_blocking=is_blocking,
202
+ )
203
+
204
+
205
+ @dataclass
206
+ class ParsedHookOutput:
207
+ """The fields parsed from one hook's stdout.
208
+
209
+ Attributes:
210
+ is_deny: True when the hook output carries a permissionDecision of deny.
211
+ is_allow: True when the hook output carries a permissionDecision of allow.
212
+ deny_reason: The permissionDecisionReason text when is_deny is True.
213
+ system_message: The hook's top-level systemMessage, or non-JSON stdout
214
+ text when the output is not a deny-shaped JSON object.
215
+ additional_context: The hook's hookSpecificOutput.additionalContext text.
216
+ suppress_output: True when the hook set a top-level suppressOutput flag.
217
+ """
218
+
219
+ is_deny: bool
220
+ is_allow: bool
221
+ deny_reason: str
222
+ system_message: str
223
+ additional_context: str
224
+ suppress_output: bool
225
+
226
+
227
+ def _empty_parsed_hook_output(system_message: str) -> ParsedHookOutput:
228
+ """Build a non-deciding ParsedHookOutput carrying only a system message.
229
+
230
+ Used for stdout that is empty or not deny-shaped JSON, where the only field
231
+ worth keeping is the raw stdout text surfaced as the system message.
232
+
233
+ Args:
234
+ system_message: The raw stdout text to surface as the system message.
235
+
236
+ Returns:
237
+ A ParsedHookOutput that neither denies nor allows.
238
+ """
239
+ return ParsedHookOutput(
240
+ is_deny=False,
241
+ is_allow=False,
242
+ deny_reason="",
243
+ system_message=system_message,
244
+ additional_context="",
245
+ suppress_output=False,
246
+ )
247
+
248
+
249
+ def _parse_deny_from_hook_output(hook_output_text: str) -> ParsedHookOutput:
250
+ """Parse one hook's stdout for its permission decision and user-facing fields.
251
+
252
+ Captures the deny signal and the explicit allow signal, plus every
253
+ supplementary field a hook emits on a deny — the top-level systemMessage and
254
+ suppressOutput, and the hookSpecificOutput.additionalContext — so the
255
+ dispatcher reproduces the standalone hook's full deny shape and re-emits an
256
+ explicit allow when a hook auto-approves the write.
257
+
258
+ Args:
259
+ hook_output_text: The text the hook wrote to stdout.
260
+
261
+ Returns:
262
+ A ParsedHookOutput carrying the deny signal, the allow signal, deny
263
+ reason, systemMessage, additionalContext, and suppressOutput flag. When
264
+ the output is not deny-shaped JSON, system_message carries the raw stdout
265
+ text.
266
+ """
267
+ stripped_text = hook_output_text.strip()
268
+ if not stripped_text:
269
+ return _empty_parsed_hook_output("")
270
+ try:
271
+ parsed_output = json.loads(stripped_text)
272
+ except json.JSONDecodeError:
273
+ return _empty_parsed_hook_output(stripped_text)
274
+ if not isinstance(parsed_output, dict):
275
+ return _empty_parsed_hook_output(stripped_text)
276
+ hook_specific = parsed_output.get("hookSpecificOutput", {})
277
+ if not isinstance(hook_specific, dict):
278
+ return _empty_parsed_hook_output(stripped_text)
279
+ permission_decision = hook_specific.get("permissionDecision")
280
+ is_deny = permission_decision == DENY_DECISION
281
+ is_allow = permission_decision == ALLOW_DECISION
282
+ deny_reason = hook_specific.get("permissionDecisionReason", "")
283
+ if not isinstance(deny_reason, str):
284
+ deny_reason = ""
285
+ raw_system_message = parsed_output.get("systemMessage", "")
286
+ system_message = raw_system_message if isinstance(raw_system_message, str) else ""
287
+ raw_additional_context = hook_specific.get("additionalContext", "")
288
+ additional_context = raw_additional_context if isinstance(raw_additional_context, str) else ""
289
+ suppress_output = parsed_output.get("suppressOutput") is True
290
+ return ParsedHookOutput(
291
+ is_deny=is_deny,
292
+ is_allow=is_allow,
293
+ deny_reason=deny_reason,
294
+ system_message=system_message,
295
+ additional_context=additional_context,
296
+ suppress_output=suppress_output,
297
+ )
298
+
299
+
300
+ @dataclass
301
+ class DispatcherDecision:
302
+ """The aggregated decision across all hosted hook results.
303
+
304
+ Attributes:
305
+ should_deny: True when at least one hosted hook denied.
306
+ should_allow: True when at least one hosted hook emitted an explicit
307
+ allow decision and no hook denied, so the dispatcher re-emits an
308
+ explicit allow matching the standalone hook's auto-approval.
309
+ all_deny_reasons: All deny reasons from denying hooks, in run order.
310
+ all_system_messages: Every hook's top-level systemMessage, in run order,
311
+ joined into the deny payload's systemMessage.
312
+ all_additional_context: Every hook's hookSpecificOutput.additionalContext,
313
+ in run order, joined into the deny payload's additionalContext.
314
+ should_suppress_output: True when any hook set a suppressOutput flag, so
315
+ the deny payload suppresses output as the standalone hook would.
316
+ """
317
+
318
+ should_deny: bool
319
+ should_allow: bool
320
+ all_deny_reasons: list[str]
321
+ all_system_messages: list[str]
322
+ all_additional_context: list[str]
323
+ should_suppress_output: bool
324
+
325
+
326
+ def aggregate_hosted_hook_results(
327
+ all_results: list[HostedHookResult],
328
+ ) -> DispatcherDecision:
329
+ """Aggregate all hosted hook results into one dispatcher decision.
330
+
331
+ Parses each result's stdout for a deny decision and an explicit allow
332
+ decision. A clean BLOCKING_CRASH_EXIT_CODE from a blocking hook also signals
333
+ deny. Deny wins over allow: when any result denies, the aggregate denies
334
+ carrying every denying reason. When a deny carries no reason text,
335
+ EXIT_CODE_TWO_DENY_REASON supplies a fallback. When no result denies and at
336
+ least one result carried an explicit allow decision, the aggregate signals an
337
+ explicit allow so the dispatcher re-emits it, matching the standalone hook's
338
+ auto-approval. Collects every systemMessage and additionalContext message
339
+ from every hook, and the suppressOutput flag, whether or not it denied, so
340
+ the emitted deny reproduces each standalone hook's full deny shape.
341
+
342
+ Args:
343
+ all_results: Outcomes from running each applicable hosted hook.
344
+
345
+ Returns:
346
+ A DispatcherDecision with the aggregated deny signal, the explicit allow
347
+ signal, all deny reasons, all systemMessage and additionalContext
348
+ messages, and the suppressOutput flag.
349
+ """
350
+ all_deny_reasons: list[str] = []
351
+ all_system_messages: list[str] = []
352
+ all_additional_context: list[str] = []
353
+ should_suppress_output = False
354
+ saw_explicit_allow = False
355
+
356
+ for each_result in all_results:
357
+ parsed_output = _parse_deny_from_hook_output(each_result.captured_stdout)
358
+ if parsed_output.is_deny:
359
+ all_deny_reasons.append(
360
+ parsed_output.deny_reason if parsed_output.deny_reason else EXIT_CODE_TWO_DENY_REASON
361
+ )
362
+ 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
+ )
366
+ elif each_result.exit_code == BLOCKING_CRASH_EXIT_CODE and each_result.is_blocking:
367
+ all_deny_reasons.append(EXIT_CODE_TWO_DENY_REASON)
368
+ if parsed_output.is_allow:
369
+ saw_explicit_allow = True
370
+ if parsed_output.system_message:
371
+ all_system_messages.append(parsed_output.system_message)
372
+ if parsed_output.additional_context:
373
+ all_additional_context.append(parsed_output.additional_context)
374
+ if parsed_output.suppress_output:
375
+ should_suppress_output = True
376
+
377
+ should_deny = bool(all_deny_reasons)
378
+ return DispatcherDecision(
379
+ should_deny=should_deny,
380
+ should_allow=saw_explicit_allow and not should_deny,
381
+ all_deny_reasons=all_deny_reasons,
382
+ all_system_messages=all_system_messages,
383
+ all_additional_context=all_additional_context,
384
+ should_suppress_output=should_suppress_output,
385
+ )
386
+
387
+
388
+ def _emit_deny_decision(decision: DispatcherDecision) -> None:
389
+ """Write one deny JSON object to stdout carrying all deny reasons and context.
390
+
391
+ Carries every hook's systemMessage and additionalContext and the
392
+ suppressOutput flag so the dispatched deny matches the standalone hooks'
393
+ full deny shape.
394
+
395
+ Args:
396
+ decision: The aggregated dispatcher decision with deny reasons, context,
397
+ and the suppressOutput flag.
398
+ """
399
+ combined_reason = " | ".join(decision.all_deny_reasons)
400
+ hook_specific: dict[str, object] = {
401
+ "hookEventName": HOOK_EVENT_NAME,
402
+ "permissionDecision": DENY_DECISION,
403
+ "permissionDecisionReason": combined_reason,
404
+ }
405
+ if decision.all_additional_context:
406
+ hook_specific["additionalContext"] = "\n".join(decision.all_additional_context)
407
+ deny_payload: dict[str, object] = {"hookSpecificOutput": hook_specific}
408
+ if decision.all_system_messages:
409
+ deny_payload["systemMessage"] = "\n".join(decision.all_system_messages)
410
+ if decision.should_suppress_output:
411
+ deny_payload["suppressOutput"] = True
412
+ sys.stdout.write(json.dumps(deny_payload) + "\n")
413
+ sys.stdout.flush()
414
+
415
+
416
+ def _emit_allow_decision() -> None:
417
+ """Write one explicit allow JSON object to stdout.
418
+
419
+ Matches the shape a standalone hosted hook emits when it auto-approves the
420
+ write, so a write a hosted hook allows explicitly is auto-approved under the
421
+ dispatcher rather than falling back to the default permission flow.
422
+ """
423
+ allow_payload: dict[str, object] = {
424
+ "hookSpecificOutput": {
425
+ "hookEventName": HOOK_EVENT_NAME,
426
+ "permissionDecision": ALLOW_DECISION,
427
+ }
428
+ }
429
+ sys.stdout.write(json.dumps(allow_payload) + "\n")
430
+ sys.stdout.flush()
431
+
432
+
433
+ def _select_applicable_hooks(tool_name: str) -> list[HostedHookEntry]:
434
+ """Return the ordered hosted hook entries applicable to the given tool name.
435
+
436
+ Args:
437
+ tool_name: The tool name from the PreToolUse payload.
438
+
439
+ Returns:
440
+ The ordered list of HostedHookEntry objects whose applicable_tool_names
441
+ set includes tool_name.
442
+ """
443
+ return [
444
+ each_entry
445
+ for each_entry in ALL_HOSTED_HOOK_ENTRIES
446
+ if tool_name in each_entry.applicable_tool_names
447
+ ]
448
+
449
+
450
+ def _resolve_hook_script_path(relative_path: str) -> str:
451
+ """Resolve a hook relative path to an absolute path.
452
+
453
+ Args:
454
+ relative_path: Hook path relative to the hooks/ directory.
455
+
456
+ Returns:
457
+ The absolute path of the hook script.
458
+ """
459
+ return str(Path(__file__).resolve().parent.parent / relative_path)
460
+
461
+
462
+ def _run_one_hosted_hook(
463
+ each_entry: HostedHookEntry,
464
+ payload_text: str,
465
+ payload_by_key: dict[str, object],
466
+ ) -> HostedHookResult:
467
+ """Run one hosted hook either natively or via runpy and return its outcome.
468
+
469
+ Calls the hook's native evaluator in-process when the entry names a native
470
+ module, otherwise runs the hook script via runpy under __main__.
471
+
472
+ Args:
473
+ each_entry: The hosted hook entry to run.
474
+ payload_text: The raw JSON payload text to replay to a runpy hook.
475
+ payload_by_key: The parsed payload dict to pass to a native evaluator.
476
+
477
+ Returns:
478
+ The HostedHookResult for this hook's run.
479
+ """
480
+ if each_entry.native_module_name is not None:
481
+ native_hook_by_module_name: dict[str, NativeHook] = {
482
+ STATE_DESCRIPTION_BLOCKER_MODULE_NAME: NativeHook(
483
+ evaluate=evaluate_state_description,
484
+ build_deny_payload=build_state_description_deny_payload,
485
+ ),
486
+ PLAIN_LANGUAGE_BLOCKER_MODULE_NAME: NativeHook(
487
+ evaluate=evaluate_plain_language,
488
+ build_deny_payload=build_plain_language_deny_payload,
489
+ ),
490
+ }
491
+ native_hook = native_hook_by_module_name[each_entry.native_module_name]
492
+ return run_native_hook(native_hook, payload_by_key, each_entry.is_blocking)
493
+ script_path = _resolve_hook_script_path(each_entry.script_relative_path)
494
+ return run_hosted_hook(script_path, payload_text, each_entry.is_blocking)
495
+
496
+
497
+ def dispatch(
498
+ payload_text: str,
499
+ tool_name: str,
500
+ payload_by_key: dict[str, object],
501
+ ) -> None:
502
+ """Run all applicable hosted hooks and emit one aggregated decision.
503
+
504
+ Selects the applicable hosted hooks for tool_name, runs each one in-process
505
+ (natively when the entry names a native module, otherwise via runpy),
506
+ aggregates the results, and emits a deny JSON object when any hook denied, an
507
+ explicit allow JSON object when a hook allowed explicitly and none denied, or
508
+ exits zero with no output when no hook decided.
509
+
510
+ Args:
511
+ payload_text: The raw JSON payload text to replay to each runpy hook.
512
+ tool_name: The tool name from the PreToolUse payload.
513
+ payload_by_key: The parsed payload dict to pass to native evaluators.
514
+ """
515
+ applicable_entries = _select_applicable_hooks(tool_name)
516
+ all_results: list[HostedHookResult] = []
517
+ for each_entry in applicable_entries:
518
+ hook_result = _run_one_hosted_hook(each_entry, payload_text, payload_by_key)
519
+ all_results.append(hook_result)
520
+
521
+ aggregated_decision = aggregate_hosted_hook_results(all_results)
522
+ if aggregated_decision.should_deny:
523
+ _emit_deny_decision(aggregated_decision)
524
+ return
525
+ if aggregated_decision.should_allow:
526
+ _emit_allow_decision()
527
+
528
+
529
+ def main() -> None:
530
+ """Read stdin once and dispatch to all applicable hosted hooks."""
531
+ payload_dict = read_hook_input_dictionary_from_stdin()
532
+ if payload_dict is None:
533
+ sys.exit(0)
534
+
535
+ payload_text = json.dumps(payload_dict)
536
+ tool_name = payload_dict.get("tool_name", "")
537
+ if not isinstance(tool_name, str):
538
+ sys.exit(0)
539
+
540
+ dispatch(payload_text, tool_name, payload_dict)
541
+ sys.exit(0)
542
+
543
+
544
+ if __name__ == "__main__":
545
+ main()