claude-dev-env 2.4.0 → 2.7.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 (309) hide show
  1. package/CLAUDE.md +26 -59
  2. package/_shared/pr-loop/scripts/_claude_permissions_common.py +84 -0
  3. package/_shared/pr-loop/scripts/code_rules_gate.py +6 -3
  4. package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +12 -2
  5. package/_shared/pr-loop/scripts/code_rules_gate_parts/baseline_import_isolation.py +309 -0
  6. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +540 -0
  7. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +206 -70
  8. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/__init__.py +1 -0
  9. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/_repo_test_helpers.py +76 -0
  10. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_baseline_import_isolation.py +248 -0
  11. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_regression.py +309 -0
  12. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +91 -58
  13. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -306
  14. package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +44 -0
  15. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +202 -0
  16. package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -24
  17. package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -107
  18. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +290 -48
  19. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +42 -2
  20. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +36 -0
  21. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +100 -1
  22. package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -497
  23. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +311 -2
  24. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -301
  25. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -85
  26. package/_shared/pr-loop/worker-spawn.md +1 -1
  27. package/agents/CLAUDE.md +3 -2
  28. package/agents/caveman.md +0 -1
  29. package/agents/clasp-deployment-orchestrator.md +0 -1
  30. package/agents/clean-coder.md +0 -1
  31. package/agents/code-advisor.md +0 -1
  32. package/agents/code-quality-agent.md +1 -2
  33. package/agents/code-verifier.md +36 -8
  34. package/agents/deep-research.md +0 -1
  35. package/agents/docs-agent.md +0 -1
  36. package/agents/git-commit-crafter.md +0 -1
  37. package/agents/issue-tracker.md +42 -0
  38. package/agents/plan-packet-validator.md +0 -1
  39. package/agents/pr-description-writer.md +0 -1
  40. package/agents/test_agent_frontmatter.py +67 -18
  41. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +143 -141
  42. package/bin/CLAUDE.md +68 -5
  43. package/bin/codex-compat.mjs +104 -0
  44. package/bin/codex-compat.test.mjs +51 -0
  45. package/bin/ever-shipped-skills.mjs +1 -0
  46. package/bin/install-constants.mjs +88 -0
  47. package/bin/install.mjs +1138 -114
  48. package/bin/install.prune.test.mjs +869 -19
  49. package/bin/install.test.mjs +906 -2
  50. package/codex-capability-map.json +13 -0
  51. package/commands/implement.md +1 -1
  52. package/commands/right-size.md +1 -1
  53. package/docs/CLAUDE.md +1 -0
  54. package/docs/CODE_RULES.md +2 -0
  55. package/docs/codex-compatibility.md +25 -0
  56. package/docs/host-pool-health-monitor.md +102 -0
  57. package/docs/nas-ssh-invocation.md +96 -12
  58. package/docs/references/CLAUDE.md +4 -2
  59. package/docs/references/advisor-tool.md +13 -0
  60. package/docs/references/code-review-enforcement.md +35 -0
  61. package/docs/references/team-advisor-skill.md +14 -0
  62. package/hooks/blocking/CLAUDE.md +4 -0
  63. package/hooks/blocking/code_review_pr_create_gate.py +7 -3
  64. package/hooks/blocking/code_review_push_gate.py +9 -4
  65. package/hooks/blocking/code_review_stamp_directory_write_blocker.py +8 -0
  66. package/hooks/blocking/config/__init__.py +5 -5
  67. package/hooks/blocking/config/code_review_enforcement_constants.py +40 -7
  68. package/hooks/blocking/config/test_code_review_enforcement_constants.py +58 -0
  69. package/hooks/blocking/config/verified_commit_constants.py +160 -159
  70. package/hooks/blocking/eli11_reply_enforcer.py +479 -0
  71. package/hooks/blocking/gh_body_arg_blocker.py +1 -1
  72. package/hooks/blocking/nas_ssh_binary_enforcer.py +8 -46
  73. package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +256 -0
  74. package/hooks/blocking/pre_tool_use_dispatcher.py +24 -24
  75. package/hooks/blocking/shell_substitution_blocker.py +129 -0
  76. package/hooks/blocking/state_description_blocker.py +1 -1
  77. package/hooks/blocking/stop_dispatcher.py +1 -1
  78. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +2 -3
  79. package/hooks/blocking/test_code_review_pr_create_gate.py +14 -0
  80. package/hooks/blocking/test_code_review_push_gate.py +16 -0
  81. package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +19 -0
  82. package/hooks/blocking/test_eli11_reply_enforcer.py +457 -0
  83. package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +231 -0
  84. package/hooks/blocking/test_pre_tool_use_dispatcher.py +10 -1
  85. package/hooks/blocking/test_shell_substitution_blocker.py +124 -0
  86. package/hooks/blocking/test_stop_dispatcher.py +23 -0
  87. package/hooks/blocking/test_unscoped_search_blocker.py +102 -0
  88. package/hooks/blocking/test_verdict_directory_write_blocker.py +804 -808
  89. package/hooks/blocking/test_verification_verdict_store.py +54 -0
  90. package/hooks/blocking/test_verified_commit_gate.py +581 -581
  91. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -131
  92. package/hooks/blocking/unscoped_search_blocker.py +391 -0
  93. package/hooks/blocking/verdict_directory_write_blocker.py +687 -687
  94. package/hooks/blocking/verification_verdict_store.py +1039 -1036
  95. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +167 -167
  96. package/hooks/blocking/verifier_verdict_minter.py +280 -280
  97. package/hooks/git-hooks/CLAUDE.md +3 -0
  98. package/hooks/git-hooks/conftest.py +30 -0
  99. package/hooks/git-hooks/gate_utils.py +2 -2
  100. package/hooks/git-hooks/git_hooks_constants/__init__.py +41 -2
  101. package/hooks/git-hooks/pre_push.py +75 -4
  102. package/hooks/git-hooks/pre_push_base_reference.py +166 -0
  103. package/hooks/git-hooks/test_config.py +0 -15
  104. package/hooks/git-hooks/test_gate_utils.py +3 -15
  105. package/hooks/git-hooks/test_pre_commit.py +1 -15
  106. package/hooks/git-hooks/test_pre_push.py +257 -23
  107. package/hooks/git-hooks/test_pre_push_base_reference.py +339 -0
  108. package/hooks/hooks.json +10 -12
  109. package/hooks/hooks_constants/CLAUDE.md +7 -2
  110. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -4
  111. package/hooks/hooks_constants/eli11_reply_enforcer_constants.py +101 -0
  112. package/hooks/hooks_constants/enter_worktree_prefetch_constants.py +18 -18
  113. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +2 -8
  114. package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +48 -0
  115. package/hooks/hooks_constants/ruff_integration_constants.py +16 -0
  116. package/hooks/hooks_constants/shell_command_segments.py +82 -0
  117. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +67 -0
  118. package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -0
  119. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -6
  120. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +1 -0
  121. package/hooks/hooks_constants/unscoped_search_blocker_constants.py +153 -0
  122. package/hooks/lifecycle/enter_worktree_origin_prefetch.py +163 -146
  123. package/hooks/lifecycle/test_enter_worktree_origin_prefetch.py +185 -178
  124. package/hooks/pyproject.toml +1 -0
  125. package/hooks/validators/CLAUDE.md +1 -0
  126. package/hooks/validators/config/__init__.py +0 -0
  127. package/hooks/validators/config/directory_exemption_constants.py +183 -0
  128. package/hooks/validators/config/test_directory_exemption_constants.py +21 -0
  129. package/hooks/validators/conftest.py +4 -0
  130. package/hooks/validators/ruff_integration.py +49 -5
  131. package/hooks/validators/run_all_validators.py +206 -9
  132. package/hooks/validators/test_directory_exemption_constants.py +185 -0
  133. package/hooks/validators/test_python_antipattern_checks.py +110 -5
  134. package/hooks/validators/test_ruff_integration.py +92 -1
  135. package/hooks/validators/test_run_all_validators.py +115 -68
  136. package/hooks/validators/test_run_all_validators_pretooluse.py +159 -1
  137. package/package.json +13 -3
  138. package/rules/CLAUDE.md +17 -22
  139. package/rules/agent-spawn-protocol.md +6 -6
  140. package/rules/anti-corollary-tests.md +1 -1
  141. package/rules/bdd.md +1 -1
  142. package/rules/cleanup-temp-files.md +10 -4
  143. package/rules/code-standards.md +7 -0
  144. package/rules/conservative-action.md +1 -5
  145. package/rules/context7.md +0 -4
  146. package/rules/destructive-commands.md +47 -0
  147. package/rules/doc-inventory-integrity.md +48 -0
  148. package/rules/doc-prose-cuts.md +58 -0
  149. package/rules/docstring-prose-matches-implementation.md +53 -44
  150. package/rules/durable-post-artifacts.md +0 -4
  151. package/rules/eli11-replies.md +31 -0
  152. package/rules/explore-thoroughly.md +4 -4
  153. package/rules/falsify-before-green.md +68 -0
  154. package/rules/file-global-constants.md +1 -1
  155. package/rules/filesystem-search.md +51 -0
  156. package/rules/gh-cli-conventions.md +27 -0
  157. package/rules/git-workflow.md +26 -0
  158. package/rules/hedging-claims.md +9 -0
  159. package/rules/long-horizon-autonomy.md +0 -4
  160. package/rules/measurement-denominators.md +48 -0
  161. package/rules/nas-ssh-invocation.md +23 -5
  162. package/rules/parallel-tools.md +2 -2
  163. package/rules/plain-illustrative-docstrings.md +3 -7
  164. package/rules/plain-language.md +2 -0
  165. package/rules/proof-of-work-pr-comments.md +0 -4
  166. package/rules/re-stage-before-commit.md +2 -0
  167. package/rules/research-mode.md +10 -0
  168. package/rules/shell-invocation.md +21 -0
  169. package/rules/testing.md +4 -0
  170. package/rules/verified-commit-gate-skip.md +3 -27
  171. package/rules/verify-before-asking.md +5 -0
  172. package/rules/windows-filesystem-safe.md +1 -1
  173. package/rules/workers-done-before-complete.md +4 -0
  174. package/scripts/CLAUDE.md +1 -0
  175. package/scripts/Capture-PoolHealth.ps1 +410 -0
  176. package/scripts/Migrate-ShellPolicy.ps1 +1 -1
  177. package/scripts/_code_review_test_support.py +404 -0
  178. package/scripts/claude_chain_runner.py +141 -1
  179. package/scripts/codex_capability_bridge.py +171 -0
  180. package/scripts/codex_compat_materializer.py +1087 -0
  181. package/scripts/codex_compat_watcher.py +502 -0
  182. package/scripts/conftest.py +16 -1
  183. package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -1
  184. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +9 -0
  185. package/scripts/dev_env_scripts_constants/code_review_constants.py +37 -0
  186. package/scripts/invoke_code_review.py +11 -4
  187. package/scripts/resolve_worker_spawn.py +626 -626
  188. package/scripts/spawn_grok_batch.py +672 -672
  189. package/scripts/sync_to_cursor/rules.py +0 -10
  190. package/scripts/test_claude_chain_runner.py +131 -0
  191. package/scripts/test_invoke_code_review.py +85 -908
  192. package/scripts/test_invoke_code_review_chain.py +70 -0
  193. package/scripts/test_invoke_code_review_cli.py +192 -0
  194. package/scripts/test_invoke_code_review_contract.py +256 -0
  195. package/scripts/test_invoke_code_review_git.py +123 -0
  196. package/scripts/test_invoke_code_review_mode.py +99 -0
  197. package/scripts/test_resolve_worker_spawn.py +1014 -1014
  198. package/scripts/tests/test_code_review_constants.py +80 -0
  199. package/scripts/tests/test_codex_capability_bridge.py +91 -0
  200. package/scripts/tests/test_codex_compat_materializer.py +632 -0
  201. package/scripts/tests/test_codex_compat_watcher.py +599 -0
  202. package/scripts/tests/test_sync_to_cursor.py +0 -1
  203. package/skills/CLAUDE.md +2 -0
  204. package/skills/auditing-claude-config/SKILL.md +114 -114
  205. package/skills/autoconverge/SKILL.md +427 -427
  206. package/skills/autoconverge/reference/convergence.md +24 -3
  207. package/skills/autoconverge/workflow/CLAUDE.md +1 -0
  208. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +3 -3
  209. package/skills/autoconverge/workflow/converge.contract.test.mjs +1263 -1263
  210. package/skills/autoconverge/workflow/converge.mjs +168 -1
  211. package/skills/autoconverge/workflow/converge.p2-advance.test.mjs +202 -0
  212. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -2
  213. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -2
  214. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -2
  215. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -2
  216. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -2
  217. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -2
  218. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -2
  219. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -2
  220. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +265 -265
  221. package/skills/bugteam/reference/copilot-gap-analysis.md +1 -1
  222. package/skills/closeout/SKILL.md +33 -50
  223. package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +8 -0
  224. package/skills/codex-review/scripts/run_codex_review.py +233 -1
  225. package/skills/codex-review/scripts/test_run_codex_review.py +189 -0
  226. package/skills/condensing-instructions/SKILL.md +72 -0
  227. package/skills/copilot-review/SKILL.md +119 -119
  228. package/skills/e-code-review/SKILL.md +52 -0
  229. package/skills/e-code-review/reference/fix.md +54 -0
  230. package/skills/e-code-review/reference/loop.md +43 -0
  231. package/skills/e-code-review/reference/low.md +57 -0
  232. package/skills/e-code-review/reference/medium.md +153 -0
  233. package/skills/e-code-review/reference/xhigh.md +182 -0
  234. package/skills/e-simplify/SKILL.md +97 -0
  235. package/skills/fresh-branch/CLAUDE.md +1 -1
  236. package/skills/fresh-branch/SKILL.md +5 -6
  237. package/skills/fresh-branch/scripts/create_fresh_branch.py +42 -24
  238. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -3
  239. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +30 -126
  240. package/skills/issue-tracker/SKILL.md +92 -0
  241. package/skills/issue-tracker/reference/epic-and-sub-issue-model.md +55 -0
  242. package/skills/issue-tracker/reference/handoff-schema.md +64 -0
  243. package/skills/issue-tracker/reference/operation-matrix.md +41 -0
  244. package/skills/orchestrator/SKILL.md +177 -22
  245. package/skills/orchestrator/scripts/status_gate.py +625 -0
  246. package/skills/orchestrator/scripts/status_gate_constants/__init__.py +1 -0
  247. package/skills/orchestrator/scripts/status_gate_constants/config/__init__.py +1 -0
  248. package/skills/orchestrator/scripts/status_gate_constants/config/constants.py +47 -0
  249. package/skills/orchestrator/scripts/test_status_gate.py +439 -0
  250. package/skills/orchestrator-refresh/SKILL.md +129 -35
  251. package/skills/plan-to-pr/SKILL.md +155 -0
  252. package/skills/plan-to-pr/reference/final-validation-tasks.md +15 -0
  253. package/skills/plan-to-pr/reference/model-routing.md +36 -0
  254. package/skills/plan-to-pr/reference/packet-contract.md +43 -0
  255. package/skills/plan-to-pr/reference/packet-schema.json +57 -0
  256. package/skills/plan-to-pr/reference/process-inventory.md +22 -0
  257. package/skills/plan-to-pr/reference/review-loop.md +33 -0
  258. package/skills/plan-to-pr/reference/run-record.schema.json +27 -0
  259. package/skills/plan-to-pr/reference/self-audit-tasks.md +15 -0
  260. package/skills/plan-to-pr/reference/task-seeds.md +14 -0
  261. package/skills/plan-to-pr/reference/task-ticket.md +38 -0
  262. package/skills/plan-to-pr/scripts/config/__init__.py +1 -0
  263. package/skills/plan-to-pr/scripts/config/constants.py +193 -0
  264. package/skills/plan-to-pr/scripts/create_packet.py +173 -0
  265. package/skills/plan-to-pr/scripts/test_create_packet.py +102 -0
  266. package/skills/plan-to-pr/scripts/test_validate_packet.py +256 -0
  267. package/skills/plan-to-pr/scripts/test_validate_protocol.py +135 -0
  268. package/skills/plan-to-pr/scripts/test_validate_run.py +158 -0
  269. package/skills/plan-to-pr/scripts/validate_packet.py +655 -0
  270. package/skills/plan-to-pr/scripts/validate_protocol.py +622 -0
  271. package/skills/plan-to-pr/scripts/validate_run.py +173 -0
  272. package/skills/plan-to-pr/test_skill_contract.py +207 -0
  273. package/skills/plan-to-pr/test_task_ticket_contract.py +151 -0
  274. package/skills/pr-converge/SKILL.md +472 -469
  275. package/skills/pr-converge/reference/examples.md +3 -3
  276. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  277. package/skills/pr-converge/reference/ground-rules.md +7 -4
  278. package/skills/pr-converge/reference/multi-pr-orchestration.md +4 -1
  279. package/skills/pr-converge/reference/per-tick.md +5 -5
  280. package/skills/pr-converge/reference/progress-checklist.md +1 -1
  281. package/skills/pr-converge/scripts/check_convergence_gates.py +279 -279
  282. package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -507
  283. package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -84
  284. package/skills/pr-converge/test_step5_host_branch.py +1 -1
  285. package/skills/pr-fix-protocol/SKILL.md +1 -1
  286. package/skills/privacy-hygiene/SKILL.md +68 -68
  287. package/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
  288. package/skills/prototype/workflows/promotion.md +1 -1
  289. package/skills/release-notes-html/SKILL.md +164 -0
  290. package/skills/session-log/SKILL.md +1 -1
  291. package/skills/task-build/CLAUDE.md +8 -7
  292. package/skills/task-build/SKILL.md +16 -8
  293. package/skills/task-build/reference/tool-routing.md +19 -0
  294. package/rules/claude-md-orphan-file.md +0 -28
  295. package/rules/cleanup-command-forms.md +0 -23
  296. package/rules/code-reviews.md +0 -11
  297. package/rules/env-var-table-code-drift.md +0 -10
  298. package/rules/gh-body-file.md +0 -5
  299. package/rules/gh-paginate.md +0 -3
  300. package/rules/hook-prose-matches-detector.md +0 -15
  301. package/rules/no-historical-clutter.md +0 -26
  302. package/rules/no-inline-destructive-literals.md +0 -9
  303. package/rules/no-justification-noise.md +0 -61
  304. package/rules/package-inventory-stale-entry.md +0 -25
  305. package/rules/right-sized-engineering.md +0 -28
  306. package/rules/self-contained-docs.md +0 -17
  307. package/rules/shell-invocation-policy.md +0 -5
  308. package/rules/tdd.md +0 -7
  309. package/skills/closeout/reference/issue-body-templates.md +0 -108
@@ -0,0 +1,256 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse gate: deny orchestrator-refresh re-arm when the run is not active.
3
+
4
+ Fires on ``ScheduleWakeup`` and ``CronCreate`` when the prompt targets
5
+ ``/orchestrator-refresh``. Uses the same status-file gate as
6
+ ``skills/orchestrator/scripts/status_gate.py`` so idle loops
7
+ cannot be re-armed on the Claude host without an active status file.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import importlib.util
13
+ import json
14
+ import os
15
+ import re
16
+ import sys
17
+ from pathlib import Path
18
+ from types import ModuleType
19
+ from typing import Mapping
20
+
21
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
22
+ if _hooks_dir not in sys.path:
23
+ sys.path.insert(0, _hooks_dir)
24
+
25
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
26
+ from hooks_constants.orchestrator_refresh_reschedule_gate_constants import ( # noqa: E402
27
+ ALL_HOME_SKILLS_RELATIVE_PARTS,
28
+ ALL_SCHEDULE_TOOL_NAMES,
29
+ ALL_SKILL_SCRIPTS_RELATIVE_PARTS,
30
+ CALLING_HOOK_NAME,
31
+ CRON_CREATE_TOOL_NAME,
32
+ CWD_FIELD_NAME,
33
+ DENY_REASON_TEMPLATE,
34
+ HOOK_EVENT_NAME,
35
+ ORCHESTRATOR_REFRESH_PROMPT_TOKEN,
36
+ PLUGIN_ROOT_ENV_VAR,
37
+ PROMPT_FIELD_NAME,
38
+ REASON_CRON_CREATE_FORBIDDEN,
39
+ RUN_SLUG_PROMPT_FLAG,
40
+ RUN_SLUG_PROMPT_VALUE_PATTERN,
41
+ TOOL_INPUT_FIELD_NAME,
42
+ TOOL_NAME_FIELD_NAME,
43
+ )
44
+
45
+
46
+ def _candidate_skill_script_directories() -> list[Path]:
47
+ """Return ordered paths that may contain status_gate.py.
48
+
49
+ Returns:
50
+ Absolute candidate directories in preference order.
51
+ """
52
+ all_candidates: list[Path] = []
53
+ plugin_root = os.environ.get(PLUGIN_ROOT_ENV_VAR)
54
+ if plugin_root:
55
+ all_candidates.append(
56
+ Path(plugin_root).joinpath(*ALL_SKILL_SCRIPTS_RELATIVE_PARTS)
57
+ )
58
+ package_hooks_parent = Path(__file__).resolve().parents[2]
59
+ all_candidates.append(
60
+ package_hooks_parent.joinpath(*ALL_SKILL_SCRIPTS_RELATIVE_PARTS)
61
+ )
62
+ all_candidates.append(Path.home().joinpath(*ALL_HOME_SKILLS_RELATIVE_PARTS))
63
+ return all_candidates
64
+
65
+
66
+ def _load_status_gate_module() -> ModuleType | None:
67
+ """Import status_gate from the skill scripts package.
68
+
69
+ Returns:
70
+ The loaded module, or None when no candidate path is usable.
71
+ """
72
+ for each_directory in _candidate_skill_script_directories():
73
+ module_path = each_directory / "status_gate.py"
74
+ if not module_path.is_file():
75
+ continue
76
+ scripts_directory = str(each_directory)
77
+ if scripts_directory not in sys.path:
78
+ sys.path.insert(0, scripts_directory)
79
+ spec = importlib.util.spec_from_file_location(
80
+ "orchestrator_status_gate_for_reschedule_hook",
81
+ module_path,
82
+ )
83
+ if spec is None or spec.loader is None:
84
+ continue
85
+ status_gate_module = importlib.util.module_from_spec(spec)
86
+ sys.modules[spec.name] = status_gate_module
87
+ spec.loader.exec_module(status_gate_module)
88
+ return status_gate_module
89
+ return None
90
+
91
+
92
+ def _prompt_targets_orchestrator_refresh(
93
+ all_tool_input: Mapping[str, object],
94
+ ) -> bool:
95
+ """Return True when the schedule prompt re-enters orchestrator-refresh.
96
+
97
+ Args:
98
+ all_tool_input: PreToolUse tool_input mapping.
99
+
100
+ Returns:
101
+ True when the prompt field contains the refresh skill token.
102
+ """
103
+ prompt_value = all_tool_input.get(PROMPT_FIELD_NAME, "")
104
+ if not isinstance(prompt_value, str):
105
+ return False
106
+ stripped_prompt = prompt_value.lstrip()
107
+ if not stripped_prompt.startswith(ORCHESTRATOR_REFRESH_PROMPT_TOKEN):
108
+ return False
109
+ if len(stripped_prompt) == len(ORCHESTRATOR_REFRESH_PROMPT_TOKEN):
110
+ return True
111
+ return stripped_prompt[len(ORCHESTRATOR_REFRESH_PROMPT_TOKEN)] in (" ", "\n", "\t")
112
+
113
+
114
+ def _should_evaluate(all_payload_by_field: Mapping[str, object]) -> bool:
115
+ """Return True for schedule tools re-arming orchestrator-refresh.
116
+
117
+ Args:
118
+ all_payload_by_field: Full PreToolUse payload.
119
+
120
+ Returns:
121
+ True when this gate must run.
122
+ """
123
+ tool_name = all_payload_by_field.get(TOOL_NAME_FIELD_NAME, "")
124
+ if tool_name not in ALL_SCHEDULE_TOOL_NAMES:
125
+ return False
126
+ tool_input = all_payload_by_field.get(TOOL_INPUT_FIELD_NAME, {})
127
+ if not isinstance(tool_input, dict):
128
+ return False
129
+ return _prompt_targets_orchestrator_refresh(tool_input)
130
+
131
+
132
+ def _run_slug_from_prompt(prompt_value: str) -> str:
133
+ """Extract ``--run-slug`` from a ScheduleWakeup prompt when present.
134
+
135
+ Args:
136
+ prompt_value: The schedule tool prompt text.
137
+
138
+ Returns:
139
+ The slug string, or empty when the flag is absent.
140
+ """
141
+ run_slug_from_prompt_pattern = re.compile(
142
+ re.escape(RUN_SLUG_PROMPT_FLAG) + RUN_SLUG_PROMPT_VALUE_PATTERN
143
+ )
144
+ matched = run_slug_from_prompt_pattern.search(prompt_value)
145
+ if matched is None:
146
+ return ""
147
+ return matched.group(1)
148
+
149
+
150
+ def _resolve_status_file_path(
151
+ status_gate_module: ModuleType,
152
+ all_payload_by_field: Mapping[str, object],
153
+ ) -> Path:
154
+ """Resolve the run status file for this schedule attempt without chdir.
155
+
156
+ Args:
157
+ status_gate_module: Loaded status_gate module.
158
+ all_payload_by_field: PreToolUse payload (may carry cwd and prompt).
159
+
160
+ Returns:
161
+ Absolute status file path.
162
+ """
163
+ base_directory: Path | None = None
164
+ working_directory_value = all_payload_by_field.get(CWD_FIELD_NAME)
165
+ if isinstance(working_directory_value, str) and working_directory_value:
166
+ base_directory = Path(working_directory_value)
167
+ run_slug = ""
168
+ tool_input = all_payload_by_field.get(TOOL_INPUT_FIELD_NAME, {})
169
+ if isinstance(tool_input, dict):
170
+ prompt_value = tool_input.get(PROMPT_FIELD_NAME, "")
171
+ if isinstance(prompt_value, str):
172
+ run_slug = _run_slug_from_prompt(prompt_value)
173
+ return status_gate_module.resolve_status_file_path(
174
+ None, base_directory, run_slug
175
+ )
176
+
177
+
178
+ def build_denial_response(reason_code: str) -> dict[str, dict[str, str]]:
179
+ """Build the PreToolUse deny payload.
180
+
181
+ Args:
182
+ reason_code: Machine reason from decide_should_reschedule.
183
+
184
+ Returns:
185
+ Hook-specific deny JSON structure.
186
+ """
187
+ denial_reason = DENY_REASON_TEMPLATE.format(reason=reason_code)
188
+ return {
189
+ "hookSpecificOutput": {
190
+ "hookEventName": HOOK_EVENT_NAME,
191
+ "permissionDecision": "deny",
192
+ "permissionDecisionReason": denial_reason,
193
+ }
194
+ }
195
+
196
+
197
+ def _emit_denial(
198
+ reason_code: str,
199
+ all_payload_by_field: Mapping[str, object],
200
+ ) -> None:
201
+ """Log and print a deny decision for the current schedule attempt.
202
+
203
+ Args:
204
+ reason_code: Machine reason string.
205
+ all_payload_by_field: PreToolUse payload for logging context.
206
+ """
207
+ denial = build_denial_response(reason_code)
208
+ hook_specific_output = denial["hookSpecificOutput"]
209
+ block_reason = hook_specific_output["permissionDecisionReason"]
210
+ log_hook_block(
211
+ calling_hook_name=CALLING_HOOK_NAME,
212
+ hook_event=HOOK_EVENT_NAME,
213
+ block_reason=block_reason,
214
+ tool_name=str(all_payload_by_field.get(TOOL_NAME_FIELD_NAME, "")),
215
+ offending_input_preview=str(
216
+ all_payload_by_field.get(TOOL_INPUT_FIELD_NAME, {})
217
+ ),
218
+ )
219
+ print(json.dumps(denial))
220
+
221
+
222
+ def main() -> None:
223
+ """Read PreToolUse stdin and deny idle orchestrator-refresh re-arms."""
224
+ try:
225
+ payload_by_field = json.load(sys.stdin)
226
+ except json.JSONDecodeError:
227
+ sys.exit(0)
228
+
229
+ if not isinstance(payload_by_field, dict):
230
+ sys.exit(0)
231
+ if not _should_evaluate(payload_by_field):
232
+ sys.exit(0)
233
+
234
+ tool_name = payload_by_field.get(TOOL_NAME_FIELD_NAME, "")
235
+ if tool_name == CRON_CREATE_TOOL_NAME:
236
+ _emit_denial(REASON_CRON_CREATE_FORBIDDEN, payload_by_field)
237
+ sys.exit(0)
238
+
239
+ status_gate_module = _load_status_gate_module()
240
+ if status_gate_module is None:
241
+ _emit_denial("status_gate_unavailable", payload_by_field)
242
+ sys.exit(0)
243
+
244
+ status_file_path = _resolve_status_file_path(status_gate_module, payload_by_field)
245
+ is_reschedule_allowed, reason_code = status_gate_module.decide_should_reschedule(
246
+ status_file_path
247
+ )
248
+ if is_reschedule_allowed:
249
+ sys.exit(0)
250
+
251
+ _emit_denial(reason_code, payload_by_field)
252
+ sys.exit(0)
253
+
254
+
255
+ if __name__ == "__main__":
256
+ main()
@@ -7,10 +7,10 @@ declared in the constants module, aggregates the results, and emits one deny
7
7
  decision when any hook denied (carrying every denying reason) or exits zero to
8
8
  allow.
9
9
 
10
- The per-hook coverage matrix:
11
- - Write -> 20 hooks from ALL_HOSTED_HOOK_ENTRIES that list Write
12
- - Edit -> 21 hooks from ALL_HOSTED_HOOK_ENTRIES that list Edit
13
- - MultiEdit -> 9 hooks from ALL_HOSTED_HOOK_ENTRIES that list MultiEdit
10
+ Each tool runs the ``ALL_HOSTED_HOOK_ENTRIES`` whose ``applicable_tool_names``
11
+ list that tool; that roster is the live source of the per-tool coverage, and the
12
+ dispatcher roster tests in ``test_pre_tool_use_dispatcher.py`` assert the counts,
13
+ rather than a hardcoded matrix here that would drift as hooks are added.
14
14
  """
15
15
 
16
16
  from __future__ import annotations
@@ -24,20 +24,18 @@ from collections.abc import Callable
24
24
  from dataclasses import dataclass, field
25
25
  from pathlib import Path
26
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)
27
+ import _path_setup # noqa: F401
30
28
 
31
- from plain_language_blocker import ( # noqa: E402
29
+ from plain_language_blocker import (
32
30
  build_deny_payload as build_plain_language_deny_payload,
33
31
  )
34
- from plain_language_blocker import evaluate as evaluate_plain_language # noqa: E402
35
- from state_description_blocker import ( # noqa: E402
32
+ from plain_language_blocker import evaluate as evaluate_plain_language
33
+ from state_description_blocker import (
36
34
  build_deny_payload as build_state_description_deny_payload,
37
35
  )
38
- from state_description_blocker import evaluate as evaluate_state_description # noqa: E402
36
+ from state_description_blocker import evaluate as evaluate_state_description
39
37
 
40
- from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: E402
38
+ from hooks_constants.pre_tool_use_dispatcher_constants import (
41
39
  ALL_HOSTED_HOOK_ENTRIES,
42
40
  ALLOW_DECISION,
43
41
  BLOCKING_CRASH_DENY_REASON,
@@ -49,7 +47,7 @@ from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: E402
49
47
  STATE_DESCRIPTION_BLOCKER_MODULE_NAME,
50
48
  HostedHookEntry,
51
49
  )
52
- from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin # noqa: E402
50
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
53
51
 
54
52
  NativeEvaluator = Callable[[dict[str, object]], str | None]
55
53
  DenyPayloadBuilder = Callable[[str], dict[str, object]]
@@ -70,6 +68,18 @@ class NativeHook:
70
68
  build_deny_payload: DenyPayloadBuilder
71
69
 
72
70
 
71
+ _native_hook_by_module_name: dict[str, NativeHook] = {
72
+ STATE_DESCRIPTION_BLOCKER_MODULE_NAME: NativeHook(
73
+ evaluate=evaluate_state_description,
74
+ build_deny_payload=build_state_description_deny_payload,
75
+ ),
76
+ PLAIN_LANGUAGE_BLOCKER_MODULE_NAME: NativeHook(
77
+ evaluate=evaluate_plain_language,
78
+ build_deny_payload=build_plain_language_deny_payload,
79
+ ),
80
+ }
81
+
82
+
73
83
  @dataclass
74
84
  class HostedHookResult:
75
85
  """Outcome of running one hosted hook inside the dispatcher process.
@@ -477,17 +487,7 @@ def _run_one_hosted_hook(
477
487
  The HostedHookResult for this hook's run.
478
488
  """
479
489
  if each_entry.native_module_name is not None:
480
- native_hook_by_module_name: dict[str, NativeHook] = {
481
- STATE_DESCRIPTION_BLOCKER_MODULE_NAME: NativeHook(
482
- evaluate=evaluate_state_description,
483
- build_deny_payload=build_state_description_deny_payload,
484
- ),
485
- PLAIN_LANGUAGE_BLOCKER_MODULE_NAME: NativeHook(
486
- evaluate=evaluate_plain_language,
487
- build_deny_payload=build_plain_language_deny_payload,
488
- ),
489
- }
490
- native_hook = native_hook_by_module_name[each_entry.native_module_name]
490
+ native_hook = _native_hook_by_module_name[each_entry.native_module_name]
491
491
  return run_native_hook(native_hook, payload_by_key, each_entry.is_blocking)
492
492
  script_path = _resolve_hook_script_path(each_entry.script_relative_path)
493
493
  return run_hosted_hook(script_path, payload_text, each_entry.is_blocking)
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: block Bash commands containing shell command substitution.
3
+
4
+ Root cause: Claude Code's auto-allowlist matcher operates on the raw command
5
+ string, not on parsed syntax, so it does not descend into command substitutions
6
+ such as $(...), unescaped backticks, or bash process substitutions <(...) and
7
+ >(...). A compound of the form
8
+
9
+ cd X && echo "$(git rev-parse HEAD)"
10
+
11
+ therefore falls outside the auto-allowed set for `cd` + `echo` + `git rev-parse`
12
+ and forces a permission prompt, even though every segment would be auto-allowed
13
+ individually. The same mechanics apply to `diff <(cat a) <(cat b)` and
14
+ `tee >(gzip > out.gz)`: each inner command would be auto-allowed on its own,
15
+ but the outer wrapper defeats the matcher. Splitting into two separate Bash
16
+ tool calls, or collapsing to a substitution-free form like
17
+ `git -C X rev-parse HEAD`, avoids the prompt.
18
+
19
+ Detection: a regex match for `$(`, an unescaped backtick, or `<(` / `>(`, after
20
+ stripping single-quoted literal runs. Single-quoted regions are intentionally
21
+ ignored because bash performs no substitution inside them (for example
22
+ `echo '$(not-executed)'`). Backslash-escaped backticks outside single quotes
23
+ are treated as literal only when the count of preceding backslashes is odd;
24
+ an even count (including zero) means the backtick is a live substitution.
25
+
26
+ Bash arithmetic expansion ``$((...))`` is explicitly NOT blocked: it does not
27
+ spawn a subshell and does not defeat the allowlist matcher, so the
28
+ ``$(`` pattern uses a negative lookahead to skip the ``$((`` case.
29
+
30
+ False positives are still possible when the user is intentionally writing
31
+ shell-script content (for example, authoring a .sh file via heredoc) and the
32
+ substitution is literal payload rather than something to execute. In that
33
+ case, write via the Write tool instead of a Bash heredoc.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import json
39
+ import sys
40
+ from pathlib import Path
41
+
42
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
43
+ if _hooks_dir not in sys.path:
44
+ sys.path.insert(0, _hooks_dir)
45
+
46
+ from hooks_constants.shell_substitution_blocker_constants import ( # noqa: E402
47
+ BASH_TOOL_NAME,
48
+ COMMAND_KEY,
49
+ CORRECTIVE_MESSAGE,
50
+ DENY_DECISION,
51
+ DOLLAR_PAREN_PATTERN,
52
+ EVEN_BACKSLASH_BACKTICK_PATTERN,
53
+ HOOK_EVENT_NAME,
54
+ HOOK_EVENT_NAME_KEY,
55
+ HOOK_SPECIFIC_OUTPUT_KEY,
56
+ PERMISSION_DECISION_KEY,
57
+ PERMISSION_DECISION_REASON_KEY,
58
+ PROCESS_SUBSTITUTION_PATTERN,
59
+ SINGLE_QUOTED_RUN_PATTERN,
60
+ STRIPPED_RUN_REPLACEMENT,
61
+ TOOL_INPUT_KEY,
62
+ TOOL_NAME_KEY,
63
+ )
64
+
65
+
66
+ def _strip_single_quoted_runs(command: str) -> str:
67
+ return SINGLE_QUOTED_RUN_PATTERN.sub(STRIPPED_RUN_REPLACEMENT, command)
68
+
69
+
70
+ def has_shell_substitution(command: str) -> bool:
71
+ """Return True when a Bash command carries a live shell substitution.
72
+
73
+ ::
74
+
75
+ echo "$(git rev-parse HEAD)" flag: command substitution
76
+ echo `date` flag: live backtick
77
+ diff <(cat a) <(cat b) flag: process substitution
78
+ echo '$(not-executed)' ok: single-quoted, inert
79
+ echo $((2 + 2)) ok: arithmetic expansion
80
+
81
+ Single-quoted runs are stripped first because bash substitutes nothing
82
+ inside them. A backtick preceded by an odd number of backslashes is escaped
83
+ and stays allowed.
84
+
85
+ Args:
86
+ command: The raw Bash command string from the tool input.
87
+
88
+ Returns:
89
+ True when a substitution the allowlist matcher cannot descend into is
90
+ present outside single quotes.
91
+ """
92
+ scannable_command = _strip_single_quoted_runs(command)
93
+ if DOLLAR_PAREN_PATTERN.search(scannable_command):
94
+ return True
95
+ if EVEN_BACKSLASH_BACKTICK_PATTERN.search(scannable_command):
96
+ return True
97
+ if PROCESS_SUBSTITUTION_PATTERN.search(scannable_command):
98
+ return True
99
+ return False
100
+
101
+
102
+ def main() -> None:
103
+ try:
104
+ hook_input = json.load(sys.stdin)
105
+ except json.JSONDecodeError:
106
+ sys.exit(0)
107
+
108
+ tool_name = hook_input.get(TOOL_NAME_KEY, "")
109
+ if tool_name != BASH_TOOL_NAME:
110
+ sys.exit(0)
111
+
112
+ command = hook_input.get(TOOL_INPUT_KEY, {}).get(COMMAND_KEY, "")
113
+ if not command or not has_shell_substitution(command):
114
+ sys.exit(0)
115
+
116
+ deny_payload = {
117
+ HOOK_SPECIFIC_OUTPUT_KEY: {
118
+ HOOK_EVENT_NAME_KEY: HOOK_EVENT_NAME,
119
+ PERMISSION_DECISION_KEY: DENY_DECISION,
120
+ PERMISSION_DECISION_REASON_KEY: CORRECTIVE_MESSAGE,
121
+ }
122
+ }
123
+ print(json.dumps(deny_payload))
124
+ sys.stdout.flush()
125
+ sys.exit(0)
126
+
127
+
128
+ if __name__ == "__main__":
129
+ main()
@@ -345,7 +345,7 @@ def build_deny_payload(deny_reason: str) -> dict[str, object]:
345
345
  "only the current state. For example:\n"
346
346
  ' BAD: "Uses X instead of Y" → GOOD: "Uses X"\n'
347
347
  ' BAD: "Previously configured via Z" → GOOD: "Configured via Z"\n'
348
- "See ~/.claude/rules/no-historical-clutter.md for full rules."
348
+ "See ~/.claude/rules/doc-prose-cuts.md for full rules."
349
349
  ),
350
350
  },
351
351
  "systemMessage": "Agent wrote comparative/historical language - describe current state only",
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """Stop-hook dispatcher that hosts the five Stop-chain hooks in one process.
2
+ """Stop-hook dispatcher that hosts the six Stop-chain hooks in one process.
3
3
 
4
4
  Reads the Stop payload from stdin once, runs each hosted hook in registration
5
5
  order via the shared hosted-hook runner, and emits the first block decision the
@@ -212,16 +212,15 @@ def test_additional_context_is_collected_from_deciding_hooks() -> None:
212
212
  assert aggregated.all_additional_context == ["see docs/runbook.md"]
213
213
 
214
214
 
215
- def test_powershell_selects_the_three_shared_hooks() -> None:
215
+ def test_powershell_selects_the_shared_hooks_in_registration_order() -> None:
216
216
  """Selecting for PowerShell yields the shared hooks in registration order."""
217
217
  powershell_paths = [
218
218
  each_entry.script_relative_path
219
219
  for each_entry in select_applicable_entries(POWERSHELL_TOOL_NAME)
220
220
  ]
221
221
  assert powershell_paths == [
222
+ "blocking/unscoped_search_blocker.py",
222
223
  "blocking/pii_prevention_blocker.py",
223
- "blocking/verified_commit_gate.py",
224
- "blocking/verdict_directory_write_blocker.py",
225
224
  "blocking/code_review_push_gate.py",
226
225
  "blocking/code_review_pr_create_gate.py",
227
226
  "blocking/code_review_stamp_directory_write_blocker.py",
@@ -43,6 +43,12 @@ store_module = _load_module("code_review_stamp_store")
43
43
  deny_module = _load_module("code_review_gate_deny")
44
44
 
45
45
 
46
+ @pytest.fixture(autouse=True)
47
+ def enable_code_review_enforcement(monkeypatch: pytest.MonkeyPatch) -> None:
48
+ """Behavior tests exercise the gates with enforcement on."""
49
+ monkeypatch.setattr(gate_module, "CODE_REVIEW_ENFORCEMENT_ENABLED", True)
50
+
51
+
46
52
  def _run_git(repository_directory: Path, *git_arguments: str) -> None:
47
53
  subprocess.run(
48
54
  ["git", "-C", str(repository_directory), *git_arguments],
@@ -183,3 +189,11 @@ def test_build_deny_payload_shape() -> None:
183
189
  deny_payload = deny_module.build_code_review_deny_payload("blocked reason")
184
190
  assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
185
191
  assert deny_payload["hookSpecificOutput"]["permissionDecisionReason"] == "blocked reason"
192
+
193
+
194
+ def test_enforcement_off_allows_pr_create_without_stamp(
195
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
196
+ ) -> None:
197
+ monkeypatch.setattr(gate_module, "CODE_REVIEW_ENFORCEMENT_ENABLED", False)
198
+ work_directory = _prepared_repo(monkeypatch, tmp_path)
199
+ assert gate_module.deny_reason_for_directory(str(work_directory)) is None
@@ -46,6 +46,12 @@ gate_module = _load_module("code_review_push_gate")
46
46
  store_module = _load_module("code_review_stamp_store")
47
47
 
48
48
 
49
+ @pytest.fixture(autouse=True)
50
+ def enable_code_review_enforcement(monkeypatch: pytest.MonkeyPatch) -> None:
51
+ """Behavior tests exercise the gates with enforcement on."""
52
+ monkeypatch.setattr(gate_module, "CODE_REVIEW_ENFORCEMENT_ENABLED", True)
53
+
54
+
49
55
  def _run_git(repository_directory: Path, *git_arguments: str) -> None:
50
56
  subprocess.run(
51
57
  ["git", "-C", str(repository_directory), *git_arguments],
@@ -187,3 +193,13 @@ def test_code_review_skip_marker_allows_push(
187
193
  work_directory = _prepared_code_repo(monkeypatch, tmp_path)
188
194
  emitted = _run_main(monkeypatch, f"git push origin main {BYPASS_MARKER}", work_directory)
189
195
  assert emitted == ""
196
+
197
+
198
+ def test_enforcement_off_allows_push_without_stamp(
199
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
200
+ ) -> None:
201
+ monkeypatch.setattr(gate_module, "CODE_REVIEW_ENFORCEMENT_ENABLED", False)
202
+ work_directory = _prepared_code_repo(monkeypatch, tmp_path)
203
+ assert gate_module.deny_reason_for_directory(str(work_directory)) is None
204
+ emitted = _run_main(monkeypatch, "git push origin main", work_directory)
205
+ assert emitted == ""
@@ -53,6 +53,12 @@ def _load_module(module_name: str) -> ModuleType:
53
53
  blocker_module = _load_module("code_review_stamp_directory_write_blocker")
54
54
 
55
55
 
56
+ @pytest.fixture(autouse=True)
57
+ def enable_code_review_enforcement(monkeypatch: pytest.MonkeyPatch) -> None:
58
+ """Behavior tests exercise the write-blocker with enforcement on."""
59
+ monkeypatch.setattr(blocker_module, "CODE_REVIEW_ENFORCEMENT_ENABLED", True)
60
+
61
+
56
62
  def _isolate_home(monkeypatch: pytest.MonkeyPatch, fake_home: Path) -> None:
57
63
  home_text = str(fake_home)
58
64
  monkeypatch.setenv("HOME", home_text)
@@ -178,3 +184,16 @@ def test_chr_chain_obfuscated_stamp_path_is_blocked() -> None:
178
184
 
179
185
  def test_benign_decode_to_other_path_is_not_blocked() -> None:
180
186
  assert not blocker_module.references_stamp_directory(_hex_forge(BENIGN_OBFUSCATION_PATH))
187
+
188
+
189
+ def test_enforcement_off_allows_stamp_directory_access(
190
+ monkeypatch: pytest.MonkeyPatch,
191
+ ) -> None:
192
+ monkeypatch.setattr(blocker_module, "CODE_REVIEW_ENFORCEMENT_ENABLED", False)
193
+ decision = blocker_module.decision_for_payload(
194
+ {
195
+ "tool_name": "Bash",
196
+ "tool_input": {"command": "rm -rf ~/.claude/code-review-stamps"},
197
+ }
198
+ )
199
+ assert decision is None