claude-dev-env 1.92.0 → 1.93.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 (216) hide show
  1. package/CLAUDE.md +5 -39
  2. package/_shared/CLAUDE.md +2 -1
  3. package/_shared/advisor/CLAUDE.md +21 -0
  4. package/_shared/advisor/advisor-protocol.md +142 -0
  5. package/_shared/advisor/scripts/config/advisor_scripts_constants/__init__.py +1 -0
  6. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +86 -0
  7. package/_shared/advisor/scripts/model_tier_run_validator.py +246 -0
  8. package/_shared/advisor/scripts/pyproject.toml +3 -0
  9. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +302 -0
  10. package/_shared/advisor/scripts/tests/test_tier_model_ids.py +180 -0
  11. package/_shared/advisor/scripts/tier_model_ids.py +164 -0
  12. package/_shared/pr-loop/CLAUDE.md +1 -0
  13. package/_shared/pr-loop/precatch-rubric.md +65 -0
  14. package/_shared/pr-loop/scripts/CLAUDE.md +1 -1
  15. package/_shared/pr-loop/scripts/code_rules_gate.py +137 -5
  16. package/_shared/pr-loop/scripts/post_audit_thread.py +1 -1
  17. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -1
  18. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +16 -0
  19. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +2 -1
  20. package/_shared/pr-loop/scripts/reviewer_availability.py +42 -12
  21. package/_shared/pr-loop/scripts/reviews_disabled.py +65 -21
  22. package/_shared/pr-loop/scripts/terminology_sweep.py +69 -21
  23. package/_shared/pr-loop/scripts/tests/CLAUDE.md +1 -1
  24. package/_shared/pr-loop/scripts/tests/fixtures/{copilot_internal_user_jonecho.json → copilot_internal_user_example.json} +1 -1
  25. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +1 -1
  26. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +146 -2
  27. package/_shared/pr-loop/scripts/tests/test_copilot_quota.py +8 -8
  28. package/_shared/pr-loop/scripts/tests/test_reviewer_availability.py +18 -5
  29. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +56 -5
  30. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +104 -17
  31. package/agents/CLAUDE.md +3 -2
  32. package/agents/clasp-deployment-orchestrator.md +2 -2
  33. package/agents/code-advisor.md +7 -5
  34. package/agents/code-verifier.md +6 -2
  35. package/agents/session-advisor.md +27 -0
  36. package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +1 -1
  37. package/audit-rubrics/category_rubrics/category-k-codebase-conflicts.md +1 -1
  38. package/audit-rubrics/prompts/category-a-api-contracts.md +2 -2
  39. package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
  40. package/audit-rubrics/prompts/category-c-resource-cleanup.md +2 -2
  41. package/audit-rubrics/prompts/category-d-scoping-and-ordering.md +2 -2
  42. package/audit-rubrics/prompts/category-e-dead-code.md +2 -2
  43. package/audit-rubrics/prompts/category-f-silent-failures.md +2 -2
  44. package/audit-rubrics/prompts/category-g-bounds-and-overflow.md +2 -2
  45. package/audit-rubrics/prompts/category-h-security-boundaries.md +2 -2
  46. package/audit-rubrics/prompts/category-i-concurrency.md +2 -2
  47. package/audit-rubrics/prompts/category-j-code-rules-compliance.md +2 -2
  48. package/audit-rubrics/prompts/category-k-codebase-conflicts.md +2 -2
  49. package/audit-rubrics/prompts/category-l-behavior-equivalence.md +2 -2
  50. package/audit-rubrics/prompts/category-m-producer-consumer-cardinality.md +2 -2
  51. package/audit-rubrics/prompts/category-n-test-name-scenario-verifier.md +2 -2
  52. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +2 -2
  53. package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +2 -2
  54. package/bin/CLAUDE.md +1 -0
  55. package/bin/expand_home_directory_tokens.mjs +75 -0
  56. package/bin/install.mjs +21 -7
  57. package/bin/install.test.mjs +220 -10
  58. package/hooks/CLAUDE.md +1 -1
  59. package/hooks/blocking/CLAUDE.md +4 -1
  60. package/hooks/blocking/_path_setup.py +13 -0
  61. package/hooks/blocking/bash_pre_tool_use_dispatcher.py +281 -0
  62. package/hooks/blocking/code_rules_dead_module_constant.py +88 -16
  63. package/hooks/blocking/code_rules_enforcer.py +6 -0
  64. package/hooks/blocking/code_rules_imports_logging.py +34 -1
  65. package/hooks/blocking/code_rules_paths_syspath.py +1 -1
  66. package/hooks/blocking/code_rules_test_layout.py +272 -0
  67. package/hooks/blocking/nas_ssh_binary_enforcer.py +22 -12
  68. package/hooks/blocking/pii_prevention_blocker.py +781 -0
  69. package/hooks/blocking/pii_scanner.py +340 -0
  70. package/hooks/blocking/pre_tool_use_dispatcher.py +3 -3
  71. package/hooks/blocking/stop_dispatcher.py +84 -0
  72. package/hooks/blocking/test__path_setup.py +39 -0
  73. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +61 -0
  74. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +294 -0
  75. package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +1 -1
  76. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +51 -0
  77. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant_read_cap.py +103 -0
  78. package/hooks/blocking/test_code_rules_enforcer_dot_test_pattern.py +1 -1
  79. package/hooks/blocking/test_code_rules_enforcer_file_global_constants.py +1 -1
  80. package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +11 -11
  81. package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +1 -1
  82. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +1 -1
  83. package/hooks/blocking/test_code_rules_enforcer_test_layout.py +111 -0
  84. package/hooks/blocking/test_code_rules_enforcer_type_checking_scope.py +76 -0
  85. package/hooks/blocking/test_destructive_command_blocker.py +29 -9
  86. package/hooks/blocking/test_gh_body_arg_blocker.py +3 -3
  87. package/hooks/blocking/test_nas_ssh_binary_enforcer.py +96 -39
  88. package/hooks/blocking/test_pii_prevention_blocker.py +905 -0
  89. package/hooks/blocking/test_pii_prevention_windows_git_path.py +41 -0
  90. package/hooks/blocking/test_pii_scanner.py +165 -0
  91. package/hooks/blocking/test_pr_description_enforcer_pr_number.py +1 -1
  92. package/hooks/blocking/test_pre_tool_use_dispatcher.py +14 -13
  93. package/hooks/blocking/test_stop_dispatcher.py +187 -0
  94. package/hooks/blocking/test_verdict_directory_write_blocker.py +1 -1
  95. package/hooks/blocking/test_volatile_path_in_post_blocker.py +3 -3
  96. package/hooks/blocking/volatile_path_in_post_blocker.py +54 -5
  97. package/hooks/diagnostic/migrations/README.md +25 -26
  98. package/hooks/diagnostic/test_hook_log_extractor.py +10 -10
  99. package/hooks/hooks.json +10 -120
  100. package/hooks/hooks_constants/CLAUDE.md +12 -3
  101. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +78 -0
  102. package/hooks/hooks_constants/dead_module_constant_constants.py +1 -0
  103. package/hooks/hooks_constants/hosted_hook_runner.py +73 -0
  104. package/hooks/hooks_constants/local_identity.py +182 -0
  105. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +4 -37
  106. package/hooks/hooks_constants/pii_prevention_constants.py +299 -0
  107. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  108. package/hooks/hooks_constants/python_style_checks_constants.py +15 -0
  109. package/hooks/hooks_constants/stop_dispatcher_constants.py +26 -0
  110. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +87 -0
  111. package/hooks/hooks_constants/test_hardcoded_user_path_constants.py +2 -2
  112. package/hooks/hooks_constants/test_hosted_hook_runner.py +101 -0
  113. package/hooks/hooks_constants/test_layout_constants.py +28 -0
  114. package/hooks/hooks_constants/test_local_identity.py +88 -0
  115. package/hooks/hooks_constants/test_session_env_cleanup_constants.py +1 -1
  116. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +32 -0
  117. package/hooks/validators/README.md +5 -6
  118. package/hooks/validators/__init__.py +2 -2
  119. package/hooks/validators/python_style_checks.py +298 -243
  120. package/hooks/validators/run_all_validators.py +6 -0
  121. package/hooks/validators/test_python_style_checks.py +278 -163
  122. package/package.json +2 -2
  123. package/rules/CLAUDE.md +7 -1
  124. package/rules/bdd.md +1 -1
  125. package/rules/nas-ssh-invocation.md +6 -4
  126. package/rules/no-justification-noise.md +61 -0
  127. package/rules/testing.md +0 -2
  128. package/scripts/test_setup_project_paths.py +1 -1
  129. package/skills/CLAUDE.md +6 -3
  130. package/skills/_shared/pr-loop/scripts/_path_resolver.py +2 -2
  131. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +1 -1
  132. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +1 -1
  133. package/skills/_shared/pr-loop/scripts/test__path_resolver.py +42 -3
  134. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +11 -11
  135. package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +26 -3
  136. package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +49 -25
  137. package/skills/autoconverge/CLAUDE.md +3 -3
  138. package/skills/autoconverge/SKILL.md +113 -35
  139. package/skills/autoconverge/reference/CLAUDE.md +2 -2
  140. package/skills/autoconverge/reference/convergence.md +44 -22
  141. package/skills/autoconverge/reference/gotchas.md +11 -0
  142. package/skills/autoconverge/reference/stop-conditions.md +23 -13
  143. package/skills/autoconverge/workflow/CLAUDE.md +2 -1
  144. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +15 -19
  145. package/skills/autoconverge/workflow/converge.contract.test.mjs +43 -42
  146. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +141 -16
  147. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +2 -2
  148. package/skills/autoconverge/workflow/converge.mjs +329 -68
  149. package/skills/autoconverge/workflow/converge.path-aware.test.mjs +1 -1
  150. package/skills/autoconverge/workflow/converge.precatch.test.mjs +152 -0
  151. package/skills/autoconverge/workflow/converge.run-input.test.mjs +3 -3
  152. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +15 -15
  153. package/skills/autoconverge/workflow/test_convergence_summary.py +6 -6
  154. package/skills/bdd-protocol/SKILL.md +4 -5
  155. package/skills/bdd-protocol/references/anti-patterns.md +1 -1
  156. package/skills/bugteam/SKILL.md +8 -0
  157. package/skills/bugteam/reference/copilot-gap-analysis.md +20 -20
  158. package/skills/bugteam/reference/obstacles/audit-walk-categories.md +1 -1
  159. package/skills/bugteam/reference/team-setup.md +7 -5
  160. package/skills/bugteam/scripts/reflow_skill_md.py +1 -1
  161. package/skills/copilot-finding-triage/SKILL.md +124 -0
  162. package/skills/copilot-finding-triage/reference/tier-rubric.md +49 -0
  163. package/skills/copilot-finding-triage/scripts/conftest.py +8 -0
  164. package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/__init__.py +0 -0
  165. package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/config/__init__.py +0 -0
  166. package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/config/notify_ntfy_constants.py +32 -0
  167. package/skills/copilot-finding-triage/scripts/notify_ntfy.py +192 -0
  168. package/skills/copilot-finding-triage/scripts/test_notify_ntfy.py +141 -0
  169. package/skills/copilot-finding-triage/templates/notification.md +34 -0
  170. package/skills/copilot-review/SKILL.md +8 -6
  171. package/skills/findbugs/SKILL.md +4 -0
  172. package/skills/fixbugs/SKILL.md +8 -7
  173. package/skills/gotcha/CLAUDE.md +2 -2
  174. package/skills/gotcha/SKILL.md +4 -4
  175. package/skills/log-audit/SKILL.md +4 -6
  176. package/skills/monitor-open-prs/CLAUDE.md +1 -1
  177. package/skills/monitor-open-prs/SKILL.md +6 -1
  178. package/skills/orchestrator/SKILL.md +184 -0
  179. package/skills/orchestrator-refresh/SKILL.md +37 -0
  180. package/skills/post-audit-findings/SKILL.md +5 -9
  181. package/skills/pr-consistency-audit/SKILL.md +5 -1
  182. package/skills/pr-converge/CLAUDE.md +1 -1
  183. package/skills/pr-converge/SKILL.md +86 -47
  184. package/skills/pr-converge/reference/CLAUDE.md +1 -1
  185. package/skills/pr-converge/reference/convergence-gates.md +20 -17
  186. package/skills/pr-converge/reference/examples.md +63 -47
  187. package/skills/pr-converge/reference/fix-protocol.md +15 -15
  188. package/skills/pr-converge/reference/ground-rules.md +11 -7
  189. package/skills/pr-converge/reference/multi-pr-orchestration.md +6 -5
  190. package/skills/pr-converge/reference/per-tick.md +129 -107
  191. package/skills/pr-converge/reference/state-schema.md +15 -10
  192. package/skills/pr-converge/scripts/check_convergence.py +5 -4
  193. package/skills/pr-converge/scripts/test_check_convergence.py +6 -4
  194. package/skills/pr-fix-protocol/SKILL.md +3 -8
  195. package/skills/pr-loop-cloud-transport/SKILL.md +102 -0
  196. package/skills/pr-loop-cloud-transport/reference/identity-and-hooks.md +40 -0
  197. package/skills/pr-loop-cloud-transport/reference/substitution-matrix.md +48 -0
  198. package/skills/pr-loop-lifecycle/SKILL.md +5 -9
  199. package/skills/pr-scope-resolve/SKILL.md +3 -6
  200. package/skills/privacy-hygiene/SKILL.md +114 -0
  201. package/skills/qbug/SKILL.md +8 -8
  202. package/skills/rebase/SKILL.md +5 -1
  203. package/skills/refine/SKILL.md +4 -5
  204. package/skills/reviewer-gates/SKILL.md +7 -11
  205. package/skills/session-log/SKILL.md +4 -1
  206. package/skills/skill-builder/SKILL.md +3 -6
  207. package/skills/structure-prompt/SKILL.md +4 -5
  208. package/skills/team-advisor/SKILL.md +32 -164
  209. package/skills/test_markdown_link_integrity.py +10 -6
  210. package/skills/update/SKILL.md +5 -1
  211. package/skills/usage-pause/SKILL.md +14 -5
  212. package/skills/usage-pause/scripts/resolve_usage_window.py +83 -5
  213. package/skills/usage-pause/scripts/test_resolve_usage_window.py +185 -17
  214. package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +4 -3
  215. package/skills/verified-build/SKILL.md +4 -9
  216. package/skills/team-advisor-refresh/SKILL.md +0 -25
@@ -0,0 +1,281 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse dispatcher that hosts the Bash and PowerShell blocking hook chains.
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 the shared hosted-hook
6
+ runner, aggregates deny/ask/allow decisions with deny>ask>allow precedence, and
7
+ emits one decision (carrying updatedInput when a rewriter allowed a rewrite, and
8
+ systemMessage / additionalContext / suppressOutput when hosted hooks set them).
9
+
10
+ A deny short-circuits the remaining chain: once any hosted hook denies, the
11
+ dispatcher aggregates what has run so far, emits that deny immediately, and
12
+ returns so a later process timeout cannot drop an already-known denial. Ask and
13
+ allow continue through the full roster because a later hook may still deny.
14
+
15
+ A single hosted hook crash fails open: it contributes no decision and does not
16
+ stop the remaining hooks, matching a standalone hook whose uncaught exception
17
+ exits nonzero without blocking the tool call.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import sys
24
+ from dataclasses import dataclass, field
25
+ from pathlib import Path
26
+
27
+ import _path_setup # noqa: F401
28
+
29
+ from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
30
+ ALL_BASH_HOSTED_HOOK_ENTRIES,
31
+ ALLOW_DECISION,
32
+ ASK_DECISION,
33
+ CONTEXT_JOIN_SEPARATOR,
34
+ DENY_DECISION,
35
+ HOOK_EVENT_NAME,
36
+ REASON_JOIN_SEPARATOR,
37
+ BashHostedHookEntry,
38
+ )
39
+ from hooks_constants.hosted_hook_runner import HostedHookRun, run_hook_capturing_output
40
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
41
+
42
+ _PERMISSION_DECISION_KEY = "permissionDecision"
43
+ _PERMISSION_REASON_KEY = "permissionDecisionReason"
44
+ _HOOK_SPECIFIC_OUTPUT_KEY = "hookSpecificOutput"
45
+ _UPDATED_INPUT_KEY = "updatedInput"
46
+ _SYSTEM_MESSAGE_KEY = "systemMessage"
47
+ _ADDITIONAL_CONTEXT_KEY = "additionalContext"
48
+ _SUPPRESS_OUTPUT_KEY = "suppressOutput"
49
+
50
+
51
+ @dataclass
52
+ class BashDispatcherDecision:
53
+ """Aggregated outcome across the Bash/PowerShell hosted-hook chain.
54
+
55
+ Attributes:
56
+ decision: The winning permission outcome (deny, ask, allow), or empty
57
+ when no hosted hook emitted an outcome.
58
+ reasons: Deny or ask reasons collected from deciding hooks, in run order.
59
+ updated_input: The rewritten tool_input from an allowing rewriter, or
60
+ None when no allow carried an updatedInput.
61
+ all_system_messages: Top-level systemMessage texts from hosted hooks.
62
+ all_additional_context: hookSpecificOutput.additionalContext texts.
63
+ should_suppress_output: True when any hosted hook set suppressOutput.
64
+ """
65
+
66
+ decision: str
67
+ reasons: list[str] = field(default_factory=list)
68
+ updated_input: dict[str, object] | None = None
69
+ all_system_messages: list[str] = field(default_factory=list)
70
+ all_additional_context: list[str] = field(default_factory=list)
71
+ should_suppress_output: bool = False
72
+
73
+
74
+ @dataclass
75
+ class _ParsedHookDecision:
76
+ """Fields parsed from one hosted hook's stdout."""
77
+
78
+ decision: str
79
+ reason: str
80
+ updated_input: dict[str, object] | None
81
+ system_message: str
82
+ additional_context: str
83
+ should_suppress_output: bool
84
+
85
+
86
+ def select_applicable_entries(tool_name: str) -> list[BashHostedHookEntry]:
87
+ """Return the ordered hosted-hook entries that apply to tool_name."""
88
+ return [
89
+ each_entry
90
+ for each_entry in ALL_BASH_HOSTED_HOOK_ENTRIES
91
+ if tool_name in each_entry.applicable_tool_names
92
+ ]
93
+
94
+
95
+ def _empty_parsed_hook_decision() -> _ParsedHookDecision:
96
+ """Return a non-deciding parse result with empty supplementary fields."""
97
+ return _ParsedHookDecision(
98
+ decision="",
99
+ reason="",
100
+ updated_input=None,
101
+ system_message="",
102
+ additional_context="",
103
+ should_suppress_output=False,
104
+ )
105
+
106
+
107
+ def _parse_hook_stdout(stdout_text: str) -> _ParsedHookDecision:
108
+ """Parse one hook's stdout into a decision, reason, rewrite, and context."""
109
+ stripped_text = stdout_text.strip()
110
+ if not stripped_text:
111
+ return _empty_parsed_hook_decision()
112
+ try:
113
+ parsed_output = json.loads(stripped_text)
114
+ except json.JSONDecodeError:
115
+ return _empty_parsed_hook_decision()
116
+ if not isinstance(parsed_output, dict):
117
+ return _empty_parsed_hook_decision()
118
+ hook_specific = parsed_output.get(_HOOK_SPECIFIC_OUTPUT_KEY, {})
119
+ if not isinstance(hook_specific, dict):
120
+ return _empty_parsed_hook_decision()
121
+ raw_decision = hook_specific.get(_PERMISSION_DECISION_KEY, "")
122
+ decision = raw_decision if isinstance(raw_decision, str) else ""
123
+ raw_reason = hook_specific.get(_PERMISSION_REASON_KEY, "")
124
+ reason = raw_reason if isinstance(raw_reason, str) else ""
125
+ raw_updated_input = hook_specific.get(_UPDATED_INPUT_KEY)
126
+ updated_input = raw_updated_input if isinstance(raw_updated_input, dict) else None
127
+ raw_system_message = parsed_output.get(_SYSTEM_MESSAGE_KEY, "")
128
+ system_message = raw_system_message if isinstance(raw_system_message, str) else ""
129
+ raw_additional_context = hook_specific.get(_ADDITIONAL_CONTEXT_KEY, "")
130
+ additional_context = (
131
+ raw_additional_context if isinstance(raw_additional_context, str) else ""
132
+ )
133
+ should_suppress_output = parsed_output.get(_SUPPRESS_OUTPUT_KEY) is True
134
+ return _ParsedHookDecision(
135
+ decision=decision,
136
+ reason=reason,
137
+ updated_input=updated_input,
138
+ system_message=system_message,
139
+ additional_context=additional_context,
140
+ should_suppress_output=should_suppress_output,
141
+ )
142
+
143
+
144
+ def aggregate_bash_hook_results(
145
+ all_runs: list[HostedHookRun],
146
+ ) -> BashDispatcherDecision:
147
+ """Aggregate hosted-hook runs into one deny>ask>allow decision.
148
+
149
+ Crashed hooks fail open: they contribute no decision. Reasons are collected
150
+ from deny and ask outputs. When the winning decision is allow, the first
151
+ non-empty updatedInput among allow results is carried through so rewriters
152
+ keep their contract. systemMessage, additionalContext, and suppressOutput
153
+ from every non-crashed hook are preserved so silent deny shapes (for example
154
+ destructive_command_blocker's gh-redirect deny) match standalone emission.
155
+ """
156
+ all_parsed: list[_ParsedHookDecision] = []
157
+ for each_run in all_runs:
158
+ if each_run.did_crash:
159
+ continue
160
+ all_parsed.append(_parse_hook_stdout(each_run.captured_stdout))
161
+
162
+ decision_precedence = (DENY_DECISION, ASK_DECISION, ALLOW_DECISION)
163
+ winning_decision = ""
164
+ for each_candidate in decision_precedence:
165
+ if any(each_parsed.decision == each_candidate for each_parsed in all_parsed):
166
+ winning_decision = each_candidate
167
+ break
168
+
169
+ if not winning_decision:
170
+ return BashDispatcherDecision(decision="")
171
+
172
+ all_reasons = [
173
+ each_parsed.reason
174
+ for each_parsed in all_parsed
175
+ if each_parsed.decision in (DENY_DECISION, ASK_DECISION) and each_parsed.reason
176
+ ]
177
+ updated_input: dict[str, object] | None = None
178
+ if winning_decision == ALLOW_DECISION:
179
+ for each_parsed in all_parsed:
180
+ if each_parsed.decision == ALLOW_DECISION and each_parsed.updated_input is not None:
181
+ updated_input = each_parsed.updated_input
182
+ break
183
+
184
+ all_system_messages = [
185
+ each_parsed.system_message for each_parsed in all_parsed if each_parsed.system_message
186
+ ]
187
+ all_additional_context = [
188
+ each_parsed.additional_context
189
+ for each_parsed in all_parsed
190
+ if each_parsed.additional_context
191
+ ]
192
+ should_suppress_output = any(
193
+ each_parsed.should_suppress_output for each_parsed in all_parsed
194
+ )
195
+
196
+ return BashDispatcherDecision(
197
+ decision=winning_decision,
198
+ reasons=all_reasons,
199
+ updated_input=updated_input,
200
+ all_system_messages=all_system_messages,
201
+ all_additional_context=all_additional_context,
202
+ should_suppress_output=should_suppress_output,
203
+ )
204
+
205
+
206
+ def _resolve_hook_script_path(relative_path: str) -> str:
207
+ """Resolve a hooks/-relative path to an absolute script path."""
208
+ hooks_root = Path(__file__).resolve().parent.parent
209
+ return str(hooks_root / relative_path)
210
+
211
+
212
+ def _emit_decision(decision: BashDispatcherDecision) -> None:
213
+ """Write one PreToolUse permission payload to stdout when an outcome exists."""
214
+ if not decision.decision:
215
+ return
216
+ hook_specific: dict[str, object] = {
217
+ "hookEventName": HOOK_EVENT_NAME,
218
+ _PERMISSION_DECISION_KEY: decision.decision,
219
+ }
220
+ if decision.reasons:
221
+ hook_specific[_PERMISSION_REASON_KEY] = REASON_JOIN_SEPARATOR.join(decision.reasons)
222
+ if decision.decision == ALLOW_DECISION and decision.updated_input is not None:
223
+ hook_specific[_UPDATED_INPUT_KEY] = decision.updated_input
224
+ if decision.all_additional_context:
225
+ hook_specific[_ADDITIONAL_CONTEXT_KEY] = CONTEXT_JOIN_SEPARATOR.join(
226
+ decision.all_additional_context
227
+ )
228
+ payload: dict[str, object] = {_HOOK_SPECIFIC_OUTPUT_KEY: hook_specific}
229
+ if decision.all_system_messages:
230
+ payload[_SYSTEM_MESSAGE_KEY] = CONTEXT_JOIN_SEPARATOR.join(decision.all_system_messages)
231
+ if decision.should_suppress_output:
232
+ payload[_SUPPRESS_OUTPUT_KEY] = True
233
+ sys.stdout.write(json.dumps(payload) + "\n")
234
+ sys.stdout.flush()
235
+
236
+
237
+ def _run_is_deny(hook_run: HostedHookRun) -> bool:
238
+ """Return True when a non-crashed run carries a deny decision."""
239
+ if hook_run.did_crash:
240
+ return False
241
+ parsed_decision = _parse_hook_stdout(hook_run.captured_stdout)
242
+ return parsed_decision.decision == DENY_DECISION
243
+
244
+
245
+ def dispatch(payload_text: str, tool_name: str) -> None:
246
+ """Run applicable hosted hooks and emit the aggregated decision.
247
+
248
+ Emits and returns as soon as any hosted hook denies so a process timeout
249
+ later in the chain cannot fail-open past an already-known denial.
250
+ """
251
+ applicable_entries = select_applicable_entries(tool_name)
252
+ all_runs: list[HostedHookRun] = []
253
+ for each_entry in applicable_entries:
254
+ script_path = _resolve_hook_script_path(each_entry.script_relative_path)
255
+ hook_run = run_hook_capturing_output(script_path, payload_text)
256
+ all_runs.append(hook_run)
257
+ if _run_is_deny(hook_run):
258
+ aggregated_decision = aggregate_bash_hook_results(all_runs)
259
+ _emit_decision(aggregated_decision)
260
+ return
261
+ aggregated_decision = aggregate_bash_hook_results(all_runs)
262
+ _emit_decision(aggregated_decision)
263
+
264
+
265
+ def main() -> None:
266
+ """Read stdin once and dispatch the Bash/PowerShell hosted-hook chain."""
267
+ payload_dictionary = read_hook_input_dictionary_from_stdin()
268
+ if payload_dictionary is None:
269
+ sys.exit(0)
270
+
271
+ payload_text = json.dumps(payload_dictionary)
272
+ tool_name = payload_dictionary.get("tool_name", "")
273
+ if not isinstance(tool_name, str) or not tool_name:
274
+ sys.exit(0)
275
+
276
+ dispatch(payload_text, tool_name)
277
+ sys.exit(0)
278
+
279
+
280
+ if __name__ == "__main__":
281
+ main()
@@ -33,12 +33,20 @@ The scan is deliberately conservative to keep false positives near zero:
33
33
  ``from <module> import`` whose final dotted segment equals the written
34
34
  module's filename stem, so a genuine cross-tree consumer of this constants
35
35
  module keeps the constant live while a same-named constant exported by an
36
- unrelated module never masks a dead one. A module outside any repository is
36
+ unrelated module never masks a dead one. The widened pass reads a repository
37
+ file only to test whether its text names the written module's filename stem;
38
+ a file that never mentions the stem cannot carry such an import, so it is
39
+ skipped without spending scan-cap budget, keeping the widened pass bounded to
40
+ the handful of candidate importer files even in a large repository. A module
41
+ outside any repository is
37
42
  judged on the package-tree scan alone, and the widened pass skips the package
38
43
  subtree the first pass already covered, so no file is read twice.
39
- - The combined file count of the package-tree and widened passes is bounded by a
40
- cap, so a write under an unexpectedly large tree cannot stall the hook; a write
41
- whose scan hits the cap is treated as "cannot prove dead" and flags nothing.
44
+ - Two caps bound the widened pass: a read-attempt cap bounds how many files
45
+ the pass opens and reads at all while testing for the stem, and a separate
46
+ parse cap bounds how many of those files (the ones that do name the stem)
47
+ get parsed and have their names collected. Either cap bounds the pass even
48
+ under an unexpectedly large tree; a write whose scan hits either cap is
49
+ treated as "cannot prove dead" and flags nothing.
42
50
  - Test modules under the scanned tree still count as references, so a constant
43
51
  used only by a test stays live.
44
52
  """
@@ -71,6 +79,7 @@ from hooks_constants.dead_module_constant_constants import ( # noqa: E402
71
79
  GIT_DIRECTORY_NAME,
72
80
  MAX_DEAD_MODULE_CONSTANT_ISSUES,
73
81
  MAX_SCAN_ROOT_FILE_COUNT,
82
+ MAX_SCAN_ROOT_READ_COUNT,
74
83
  MINIMUM_UPPER_SNAKE_LENGTH,
75
84
  PYTHON_SOURCE_SUFFIX,
76
85
  )
@@ -328,6 +337,42 @@ def _is_under_directory(candidate_path: Path, ancestor_directory: Path) -> bool:
328
337
  return True
329
338
 
330
339
 
340
+ def _read_candidate_source(file_path: Path, required_substring: str | None) -> str | None:
341
+ """Return a module's text, or None when it is not a reference-scan candidate.
342
+
343
+ ::
344
+
345
+ required_substring = None -> read and keep every file
346
+ required_substring = the module stem -> keep only files naming it
347
+ from pkg.foo_constants import BAR -> names the stem -> candidate
348
+ def unrelated() -> int: ... -> no stem mention -> skipped
349
+
350
+ The widened repository pass looks only for a ``from <module> import`` whose
351
+ final dotted segment equals the constants module's filename stem, and such an
352
+ import always spells that stem in the file's text. A file whose text never
353
+ mentions the stem cannot carry the import, so returning None for it lets the
354
+ caller skip the file without spending scan-cap budget, which keeps the widened
355
+ pass bounded to the candidate importer files even under a large repository.
356
+
357
+ Args:
358
+ file_path: The ``.py`` module to read.
359
+ required_substring: A stem every candidate file's text must contain, or
360
+ None to keep every readable file (the package-tree pass keeps all).
361
+
362
+ Returns:
363
+ The file's text when it is readable and, when ``required_substring`` is
364
+ set, contains that stem; None when the file cannot be read or does not
365
+ name the stem.
366
+ """
367
+ try:
368
+ source_text = file_path.read_text(encoding="utf-8")
369
+ except (OSError, UnicodeDecodeError):
370
+ return None
371
+ if required_substring is not None and required_substring not in source_text:
372
+ return None
373
+ return source_text
374
+
375
+
331
376
  def _collect_names_under_root(
332
377
  scan_root: Path,
333
378
  written_path: Path,
@@ -335,17 +380,33 @@ def _collect_names_under_root(
335
380
  extract_names: Callable[[str], set[str]],
336
381
  already_scanned_count: int = 0,
337
382
  excluded_subtree: Path | None = None,
383
+ required_substring: str | None = None,
338
384
  ) -> tuple[set[str], int, bool]:
339
385
  """Collect referenced names under the scan root via a per-module extractor.
340
386
 
341
387
  Walks every ``.py`` module under ``scan_root`` (excluding the written module
342
388
  itself, and any module under ``excluded_subtree``), applies ``extract_names``
343
- to each module's text, and unions the result onto ``all_seed_names``. Reading
344
- stops once the running file count exceeds the configured cap so a write under
345
- an unexpectedly large tree cannot stall the hook; the boolean signals the
346
- caller to treat that case as "cannot prove dead". The ``excluded_subtree``
347
- skip keeps the widened repository scan from re-reading a file the
348
- package-tree scan already covered.
389
+ to each module's text, and unions the result onto ``all_seed_names``. Two
390
+ caps bound the walk so a write under an unexpectedly large tree cannot
391
+ stall the hook:
392
+
393
+ ::
394
+
395
+ read_attempt_count -> every file this call opens and reads,
396
+ whether or not it turns out to be a
397
+ candidate -> capped by MAX_SCAN_ROOT_READ_COUNT
398
+ scanned_file_count -> only the files that pass the candidate
399
+ filter and get parsed for names -> capped
400
+ by MAX_SCAN_ROOT_FILE_COUNT
401
+
402
+ Either cap tripping returns ``cap_was_hit=True``, which signals the caller
403
+ to treat the write as "cannot prove dead". The ``excluded_subtree`` skip
404
+ keeps the widened repository scan from re-reading a file the package-tree
405
+ scan already covered. When ``required_substring`` is set, a module whose
406
+ text never contains that stem is skipped after being read but before it is
407
+ counted toward ``scanned_file_count`` or parsed, so the parse-and-collect
408
+ work stays bounded to the candidate importer files even though the read
409
+ cap still bounds the raw disk reads across the whole tree.
349
410
 
350
411
  Args:
351
412
  scan_root: The directory tree to scan.
@@ -357,10 +418,17 @@ def _collect_names_under_root(
357
418
  extract_names: Maps one module's source text to the set of names it
358
419
  contributes — the generous reference collector for the package-tree
359
420
  pass, the stem-bound import collector for the widened pass.
360
- already_scanned_count: The file count accumulated by a prior pass, so the
361
- cap bounds the combined work of the package-tree and widened passes.
421
+ already_scanned_count: The parsed-file count accumulated by a prior
422
+ pass, so the parse cap bounds the combined work of the
423
+ package-tree and widened passes.
362
424
  excluded_subtree: A resolved directory whose ``.py`` modules are skipped,
363
425
  or None to scan every file under the root.
426
+ required_substring: A stem a file's text must contain to count as a
427
+ scan candidate, or None to scan every file. The widened pass passes
428
+ the written module's filename stem so a file that never names the
429
+ module is skipped before it is counted or parsed, spending parse
430
+ budget only on the candidate importer files, while the read cap
431
+ still bounds how many files get read looking for that stem.
364
432
 
365
433
  Returns:
366
434
  A (collected_names, running_count, cap_was_hit) triple. collected_names
@@ -372,6 +440,7 @@ def _collect_names_under_root(
372
440
  collected_names = set(all_seed_names)
373
441
  written_path_key = os.path.normcase(str(written_path))
374
442
  scanned_file_count = already_scanned_count
443
+ read_attempt_count = 0
375
444
  for each_path in scan_root.rglob("*" + PYTHON_SOURCE_SUFFIX):
376
445
  if not each_path.is_file():
377
446
  continue
@@ -380,13 +449,15 @@ def _collect_names_under_root(
380
449
  continue
381
450
  if excluded_subtree is not None and _is_under_directory(resolved_path, excluded_subtree):
382
451
  continue
452
+ read_attempt_count += 1
453
+ if read_attempt_count > MAX_SCAN_ROOT_READ_COUNT:
454
+ return collected_names, scanned_file_count, True
455
+ sibling_source = _read_candidate_source(each_path, required_substring)
456
+ if sibling_source is None:
457
+ continue
383
458
  scanned_file_count += 1
384
459
  if scanned_file_count > MAX_SCAN_ROOT_FILE_COUNT:
385
460
  return collected_names, scanned_file_count, True
386
- try:
387
- sibling_source = each_path.read_text(encoding="utf-8")
388
- except (OSError, UnicodeDecodeError):
389
- continue
390
461
  collected_names |= extract_names(sibling_source)
391
462
  return collected_names, scanned_file_count, False
392
463
 
@@ -550,6 +621,7 @@ def check_dead_module_constants(
550
621
  collect_qualified_imports,
551
622
  already_scanned_count=scanned_file_count,
552
623
  excluded_subtree=scan_root,
624
+ required_substring=written_path.stem,
553
625
  )
554
626
  if widened_cap_was_hit:
555
627
  return []
@@ -181,6 +181,10 @@ from code_rules_test_branching_except import ( # noqa: E402
181
181
  from code_rules_test_isolation import ( # noqa: E402
182
182
  check_tests_use_isolated_filesystem_paths,
183
183
  )
184
+ from code_rules_test_layout import ( # noqa: E402
185
+ check_dead_test_module_constant,
186
+ check_unused_test_helper_parameter,
187
+ )
184
188
  from code_rules_type_escape import ( # noqa: E402
185
189
  check_boundary_types,
186
190
  check_type_escape_hatches,
@@ -324,6 +328,8 @@ def validate_content(
324
328
  all_issues.extend(check_stub_implementations(effective_content, file_path))
325
329
  all_issues.extend(check_typed_dict_encode_decode(effective_content, file_path))
326
330
  all_issues.extend(check_test_branching_in_production(effective_content, file_path))
331
+ all_issues.extend(check_dead_test_module_constant(effective_content, file_path))
332
+ all_issues.extend(check_unused_test_helper_parameter(effective_content, file_path))
327
333
  all_issues.extend(check_bare_except(effective_content, file_path))
328
334
  all_issues.extend(check_thin_wrapper_files(effective_content, file_path))
329
335
  all_issues.extend(check_zero_payload_function_alias(effective_content, file_path))
@@ -115,7 +115,16 @@ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
115
115
 
116
116
 
117
117
  def check_imports_at_top(content: str) -> list[str]:
118
- """Check for imports inside functions (Python only).
118
+ """Check for imports below the top import block (Python only).
119
+
120
+ Two placements are flagged. First, an import inside a function body. Second,
121
+ a module-scope import (indent zero) that sits below the first top-level
122
+ ``def`` or ``class`` definition — the "imports mid-file after functions"
123
+ drift, where the imports never enter a function body yet still break the
124
+ imports-at-top rule. An indented import inside a guarded block below a
125
+ definition (an ``if __name__ == "__main__":`` guard, a ``try`` fallback) is
126
+ left alone because only indent-zero module-scope imports are checked for the
127
+ below-definition case.
119
128
 
120
129
  An import lexically inside an ``if TYPE_CHECKING:`` block is exempt.
121
130
  An import inside a function body is flagged even if the file uses TYPE_CHECKING
@@ -146,7 +155,9 @@ def check_imports_at_top(content: str) -> list[str]:
146
155
  issues: list[str] = []
147
156
  lines = content.split("\n")
148
157
  is_inside_function = False
158
+ has_seen_top_level_definition = False
149
159
  function_indent = 0
160
+ module_scope_indent = 0
150
161
  type_checking_block_indent = NOT_INSIDE_TYPE_CHECKING_BLOCK
151
162
  active_triple_quote_delimiter: str | None = None
152
163
 
@@ -180,6 +191,16 @@ def check_imports_at_top(content: str) -> list[str]:
180
191
  if function_match:
181
192
  is_inside_function = True
182
193
  function_indent = len(function_match.group(1)) if function_match.group(1) else 0
194
+ if function_indent == module_scope_indent:
195
+ has_seen_top_level_definition = True
196
+ active_triple_quote_delimiter = _update_triple_quote_state_for_line(
197
+ each_line, active_triple_quote_delimiter
198
+ )
199
+ continue
200
+
201
+ class_match = re.match(r"^(\s*)class\s+\w+", each_line)
202
+ if class_match and len(class_match.group(1)) == module_scope_indent:
203
+ has_seen_top_level_definition = True
183
204
  active_triple_quote_delimiter = _update_triple_quote_state_for_line(
184
205
  each_line, active_triple_quote_delimiter
185
206
  )
@@ -194,6 +215,18 @@ def check_imports_at_top(content: str) -> list[str]:
194
215
  if stripped.startswith(ALL_IMPORT_STATEMENT_PREFIXES):
195
216
  issues.append(f"Line {each_line_number}: Import inside function - move to top of file")
196
217
 
218
+ is_module_scope_import = (
219
+ current_indent == module_scope_indent
220
+ and not is_inside_function
221
+ and not is_inside_type_checking_block
222
+ and stripped.startswith(ALL_IMPORT_STATEMENT_PREFIXES)
223
+ )
224
+ if is_module_scope_import and has_seen_top_level_definition:
225
+ issues.append(
226
+ f"Line {each_line_number}: Import after top-level definition - "
227
+ "move imports to top of file"
228
+ )
229
+
197
230
  active_triple_quote_delimiter = _update_triple_quote_state_for_line(
198
231
  each_line, active_triple_quote_delimiter
199
232
  )
@@ -40,7 +40,7 @@ from hooks_constants.sys_path_insert_constants import ( # noqa: E402
40
40
  def check_hardcoded_user_paths(content: str, file_path: str) -> list[str]:
41
41
  """Flag string literals naming a specific user's home directory.
42
42
 
43
- Catches non-portable paths like `C:/Users/jon/...`, `/Users/alice/...`,
43
+ Catches non-portable paths like `C:/Users/example/...`, `/Users/alice/...`,
44
44
  and `/home/bob/...` that surface in production code.
45
45
  Test files, config/ files, workflow registry files, migration files,
46
46
  and hook infrastructure files are exempt. Hook infrastructure exemption