claude-dev-env 2.3.0 → 2.5.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 (290) hide show
  1. package/CLAUDE.md +53 -48
  2. package/_shared/pr-loop/scripts/_claude_permissions_common.py +84 -0
  3. package/_shared/pr-loop/scripts/code_rules_gate.py +4 -2
  4. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -306
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +44 -0
  6. package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -24
  7. package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -107
  8. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +290 -48
  9. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +42 -2
  10. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +36 -0
  11. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +100 -1
  12. package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -497
  13. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +311 -2
  14. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -301
  15. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -85
  16. package/_shared/pr-loop/worker-spawn.md +1 -1
  17. package/agents/CLAUDE.md +3 -1
  18. package/agents/caveman.md +0 -1
  19. package/agents/clasp-deployment-orchestrator.md +0 -1
  20. package/agents/clean-coder.md +0 -1
  21. package/agents/code-advisor.md +0 -1
  22. package/agents/code-quality-agent.md +1 -2
  23. package/agents/code-verifier.md +3 -4
  24. package/agents/deep-research.md +0 -1
  25. package/agents/docs-agent.md +0 -1
  26. package/agents/git-commit-crafter.md +0 -1
  27. package/agents/issue-tracker.md +42 -0
  28. package/agents/plan-packet-validator.md +0 -1
  29. package/agents/pr-description-writer.md +0 -1
  30. package/agents/skill-writer-agent.md +84 -0
  31. package/agents/test_agent_frontmatter.py +67 -18
  32. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +105 -3
  33. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +29 -13
  34. package/bin/CLAUDE.md +68 -5
  35. package/bin/ever-shipped-skills.mjs +1 -0
  36. package/bin/install-constants.mjs +88 -0
  37. package/bin/install.mjs +1138 -114
  38. package/bin/install.prune.test.mjs +869 -19
  39. package/bin/install.test.mjs +906 -2
  40. package/commands/implement.md +1 -1
  41. package/commands/right-size.md +1 -1
  42. package/docs/CLAUDE.md +2 -0
  43. package/docs/host-pool-health-monitor.md +102 -0
  44. package/docs/references/CLAUDE.md +5 -2
  45. package/docs/references/advisor-tool.md +13 -0
  46. package/docs/references/code-review-enforcement.md +107 -0
  47. package/docs/references/team-advisor-skill.md +14 -0
  48. package/docs/wsl-docker-cowork-starter-matrix.md +89 -0
  49. package/hooks/blocking/CLAUDE.md +9 -1
  50. package/hooks/blocking/code_review_enforcement_config_bootstrap.py +53 -0
  51. package/hooks/blocking/code_review_gate_deny.py +74 -0
  52. package/hooks/blocking/code_review_pr_create_gate.py +198 -0
  53. package/hooks/blocking/code_review_push_gate.py +145 -0
  54. package/hooks/blocking/code_review_stamp_directory_write_blocker.py +348 -0
  55. package/hooks/blocking/code_review_stamp_store.py +233 -0
  56. package/hooks/blocking/code_review_stamp_write_blocker_parts/__init__.py +7 -0
  57. package/hooks/blocking/code_review_stamp_write_blocker_parts/conftest.py +15 -0
  58. package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py +212 -0
  59. package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py +138 -0
  60. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py +49 -0
  61. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_split_directory_change_into_stamp.py +38 -0
  62. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +39 -27
  63. package/hooks/blocking/config/__init__.py +5 -5
  64. package/hooks/blocking/config/code_review_enforcement_constants.py +113 -0
  65. package/hooks/blocking/config/test_code_review_enforcement_constants.py +113 -0
  66. package/hooks/blocking/config/verified_commit_constants.py +160 -155
  67. package/hooks/blocking/conftest.py +2 -0
  68. package/hooks/blocking/convergence_gate_blocker.py +112 -23
  69. package/hooks/blocking/destructive_command_blocker.py +19 -6
  70. package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +256 -0
  71. package/hooks/blocking/pr_description_proof_of_work.py +52 -34
  72. package/hooks/blocking/pre_tool_use_dispatcher.py +24 -24
  73. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
  74. package/hooks/blocking/test_code_review_enforcement_config_bootstrap.py +62 -0
  75. package/hooks/blocking/test_code_review_gate_deny.py +54 -0
  76. package/hooks/blocking/test_code_review_pr_create_gate.py +199 -0
  77. package/hooks/blocking/test_code_review_push_gate.py +205 -0
  78. package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +199 -0
  79. package/hooks/blocking/test_code_review_stamp_store.py +205 -0
  80. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +124 -2
  81. package/hooks/blocking/test_convergence_gate_blocker.py +153 -5
  82. package/hooks/blocking/test_destructive_command_blocker.py +1 -1
  83. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +45 -0
  84. package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +231 -0
  85. package/hooks/blocking/test_pr_description_proof_of_work.py +151 -0
  86. package/hooks/blocking/test_pre_tool_use_dispatcher.py +17 -8
  87. package/hooks/blocking/test_verdict_directory_write_blocker.py +808 -808
  88. package/hooks/blocking/test_verification_verdict_store.py +974 -903
  89. package/hooks/blocking/test_verified_commit_gate.py +581 -581
  90. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -131
  91. package/hooks/blocking/test_volatile_path_in_post_blocker.py +114 -2
  92. package/hooks/blocking/verdict_directory_write_blocker.py +687 -687
  93. package/hooks/blocking/verification_verdict_store.py +1039 -1014
  94. package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +29 -17
  95. package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +35 -0
  96. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +167 -167
  97. package/hooks/blocking/verifier_verdict_minter.py +280 -280
  98. package/hooks/blocking/volatile_path_in_post_blocker.py +69 -8
  99. package/hooks/git-hooks/git_hooks_constants/__init__.py +6 -0
  100. package/hooks/git-hooks/pre_push.py +89 -2
  101. package/hooks/git-hooks/test_pre_push.py +128 -0
  102. package/hooks/hooks.json +26 -1
  103. package/hooks/hooks_constants/CLAUDE.md +3 -1
  104. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +8 -0
  105. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  106. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +26 -11
  107. package/hooks/hooks_constants/convergence_gate_blocker_constants.py +20 -3
  108. package/hooks/hooks_constants/destructive_command_segment_constants.py +3 -1
  109. package/hooks/hooks_constants/enter_worktree_prefetch_constants.py +18 -18
  110. package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +48 -0
  111. package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +0 -4
  112. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  113. package/hooks/hooks_constants/pyproject_config_discovery_constants.py +16 -0
  114. package/hooks/hooks_constants/ruff_integration_constants.py +16 -0
  115. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +24 -0
  116. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +6 -0
  117. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +8 -1
  118. package/hooks/lifecycle/enter_worktree_origin_prefetch.py +163 -146
  119. package/hooks/lifecycle/test_enter_worktree_origin_prefetch.py +185 -178
  120. package/hooks/pyproject.toml +1 -0
  121. package/hooks/validators/CLAUDE.md +2 -0
  122. package/hooks/validators/config/__init__.py +0 -0
  123. package/hooks/validators/config/directory_exemption_constants.py +183 -0
  124. package/hooks/validators/config/test_directory_exemption_constants.py +21 -0
  125. package/hooks/validators/conftest.py +4 -0
  126. package/hooks/validators/mypy_integration.py +63 -50
  127. package/hooks/validators/pyproject_config_discovery.py +101 -0
  128. package/hooks/validators/ruff_integration.py +257 -25
  129. package/hooks/validators/run_all_validators.py +223 -19
  130. package/hooks/validators/test_directory_exemption_constants.py +185 -0
  131. package/hooks/validators/test_mypy_integration.py +32 -0
  132. package/hooks/validators/test_pyproject_config_discovery.py +94 -0
  133. package/hooks/validators/test_python_antipattern_checks.py +110 -5
  134. package/hooks/validators/test_ruff_integration.py +160 -2
  135. package/hooks/validators/test_run_all_validators.py +140 -68
  136. package/hooks/validators/test_run_all_validators_config_discovery.py +123 -0
  137. package/hooks/validators/test_run_all_validators_pretooluse.py +159 -1
  138. package/package.json +10 -2
  139. package/rules/CLAUDE.md +1 -0
  140. package/rules/docstring-prose-matches-implementation.md +45 -67
  141. package/rules/durable-post-artifacts.md +7 -0
  142. package/rules/state-what-is.md +25 -0
  143. package/rules/verified-commit-gate-skip.md +1 -1
  144. package/scripts/CLAUDE.md +1 -0
  145. package/scripts/Capture-PoolHealth.ps1 +410 -0
  146. package/scripts/_code_review_test_support.py +404 -0
  147. package/scripts/claude_chain_runner.py +141 -1
  148. package/scripts/codec_forwarding_test_support.py +83 -0
  149. package/scripts/conftest.py +23 -0
  150. package/scripts/dev_env_scripts_constants/CLAUDE.md +2 -2
  151. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +53 -1
  152. package/scripts/dev_env_scripts_constants/code_review_constants.py +129 -12
  153. package/scripts/dev_env_scripts_constants/test_code_review_constants.py +55 -0
  154. package/scripts/invoke_code_review.py +550 -38
  155. package/scripts/resolve_worker_spawn.py +626 -619
  156. package/scripts/spawn_grok_batch.py +672 -672
  157. package/scripts/test_claude_chain_runner.py +131 -0
  158. package/scripts/test_invoke_code_review_chain.py +70 -0
  159. package/scripts/test_invoke_code_review_cli.py +192 -0
  160. package/scripts/test_invoke_code_review_codec.py +77 -0
  161. package/scripts/test_invoke_code_review_contract.py +256 -0
  162. package/scripts/test_invoke_code_review_git.py +123 -0
  163. package/scripts/test_invoke_code_review_mode.py +99 -0
  164. package/scripts/test_resolve_worker_spawn.py +1014 -1014
  165. package/scripts/test_resolve_worker_spawn_codec.py +101 -0
  166. package/skills/CLAUDE.md +2 -0
  167. package/skills/auditing-claude-config/SKILL.md +114 -114
  168. package/skills/autoconverge/SKILL.md +427 -421
  169. package/skills/autoconverge/reference/convergence.md +26 -4
  170. package/skills/autoconverge/reference/multi-pr.md +6 -1
  171. package/skills/autoconverge/reference/stop-conditions.md +16 -10
  172. package/skills/autoconverge/workflow/CLAUDE.md +1 -0
  173. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +4 -4
  174. package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +175 -3
  175. package/skills/autoconverge/workflow/converge.contract.test.mjs +1263 -1244
  176. package/skills/autoconverge/workflow/converge.mjs +191 -8
  177. package/skills/autoconverge/workflow/converge.p2-advance.test.mjs +202 -0
  178. package/skills/autoconverge/workflow/converge_multi.mjs +7 -3
  179. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +5 -0
  180. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -2
  181. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -2
  182. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -2
  183. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -2
  184. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -2
  185. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -2
  186. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -2
  187. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -2
  188. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +265 -265
  189. package/skills/closeout/SKILL.md +33 -50
  190. package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +8 -0
  191. package/skills/codex-review/scripts/run_codex_review.py +233 -1
  192. package/skills/codex-review/scripts/test_run_codex_review.py +189 -0
  193. package/skills/condensing-instructions/SKILL.md +81 -0
  194. package/skills/copilot-review/SKILL.md +119 -119
  195. package/skills/e-code-review/SKILL.md +52 -0
  196. package/skills/e-code-review/reference/fix.md +54 -0
  197. package/skills/e-code-review/reference/loop.md +43 -0
  198. package/skills/e-code-review/reference/low.md +57 -0
  199. package/skills/e-code-review/reference/medium.md +153 -0
  200. package/skills/e-code-review/reference/xhigh.md +182 -0
  201. package/skills/e-simplify/SKILL.md +97 -0
  202. package/skills/fresh-branch/CLAUDE.md +2 -0
  203. package/skills/fresh-branch/SKILL.md +2 -0
  204. package/skills/fresh-branch/scripts/create_fresh_branch.py +78 -180
  205. package/skills/fresh-branch/scripts/fresh_branch_git_commands.py +285 -0
  206. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -0
  207. package/skills/fresh-branch/scripts/pytest.ini +4 -0
  208. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +98 -0
  209. package/skills/fresh-branch/scripts/test_fresh_branch_git_commands.py +310 -0
  210. package/skills/issue-tracker/SKILL.md +92 -0
  211. package/skills/issue-tracker/reference/epic-and-sub-issue-model.md +55 -0
  212. package/skills/issue-tracker/reference/handoff-schema.md +64 -0
  213. package/skills/issue-tracker/reference/operation-matrix.md +41 -0
  214. package/skills/orchestrator/SKILL.md +162 -21
  215. package/skills/orchestrator/scripts/status_gate.py +625 -0
  216. package/skills/orchestrator/scripts/status_gate_constants/__init__.py +1 -0
  217. package/skills/orchestrator/scripts/status_gate_constants/config/__init__.py +1 -0
  218. package/skills/orchestrator/scripts/status_gate_constants/config/constants.py +47 -0
  219. package/skills/orchestrator/scripts/test_status_gate.py +439 -0
  220. package/skills/orchestrator-refresh/SKILL.md +110 -35
  221. package/skills/plan-to-pr/SKILL.md +155 -0
  222. package/skills/plan-to-pr/reference/final-validation-tasks.md +15 -0
  223. package/skills/plan-to-pr/reference/model-routing.md +36 -0
  224. package/skills/plan-to-pr/reference/packet-contract.md +43 -0
  225. package/skills/plan-to-pr/reference/packet-schema.json +57 -0
  226. package/skills/plan-to-pr/reference/process-inventory.md +22 -0
  227. package/skills/plan-to-pr/reference/review-loop.md +33 -0
  228. package/skills/plan-to-pr/reference/run-record.schema.json +27 -0
  229. package/skills/plan-to-pr/reference/self-audit-tasks.md +15 -0
  230. package/skills/plan-to-pr/reference/task-seeds.md +14 -0
  231. package/skills/plan-to-pr/reference/task-ticket.md +38 -0
  232. package/skills/plan-to-pr/scripts/config/__init__.py +1 -0
  233. package/skills/plan-to-pr/scripts/config/constants.py +193 -0
  234. package/skills/plan-to-pr/scripts/create_packet.py +173 -0
  235. package/skills/plan-to-pr/scripts/test_create_packet.py +102 -0
  236. package/skills/plan-to-pr/scripts/test_validate_packet.py +256 -0
  237. package/skills/plan-to-pr/scripts/test_validate_protocol.py +135 -0
  238. package/skills/plan-to-pr/scripts/test_validate_run.py +158 -0
  239. package/skills/plan-to-pr/scripts/validate_packet.py +655 -0
  240. package/skills/plan-to-pr/scripts/validate_protocol.py +622 -0
  241. package/skills/plan-to-pr/scripts/validate_run.py +173 -0
  242. package/skills/plan-to-pr/test_skill_contract.py +207 -0
  243. package/skills/plan-to-pr/test_task_ticket_contract.py +151 -0
  244. package/skills/pr-converge/SKILL.md +472 -469
  245. package/skills/pr-converge/reference/examples.md +3 -3
  246. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  247. package/skills/pr-converge/reference/ground-rules.md +7 -4
  248. package/skills/pr-converge/reference/multi-pr-orchestration.md +4 -1
  249. package/skills/pr-converge/reference/per-tick.md +5 -5
  250. package/skills/pr-converge/reference/progress-checklist.md +1 -1
  251. package/skills/pr-converge/scripts/check_convergence_gates.py +279 -279
  252. package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -507
  253. package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -84
  254. package/skills/pr-converge/test_step5_host_branch.py +1 -1
  255. package/skills/pr-fix-protocol/SKILL.md +1 -1
  256. package/skills/privacy-hygiene/SKILL.md +68 -68
  257. package/skills/prototype/SKILL.md +86 -0
  258. package/skills/prototype/reference/honest-limitations.md +23 -0
  259. package/skills/prototype/reference/promotion-tasks.md +23 -0
  260. package/skills/prototype/scripts/build_sandbox_settings.py +249 -0
  261. package/skills/prototype/scripts/conftest.py +15 -0
  262. package/skills/prototype/scripts/launch_sandbox.py +205 -0
  263. package/skills/prototype/scripts/probe_sandbox_safety.py +311 -0
  264. package/skills/prototype/scripts/prototype_scripts_constants/__init__.py +1 -0
  265. package/skills/prototype/scripts/prototype_scripts_constants/config/__init__.py +0 -0
  266. package/skills/prototype/scripts/prototype_scripts_constants/config/build_sandbox_settings_constants.py +41 -0
  267. package/skills/prototype/scripts/prototype_scripts_constants/config/launch_sandbox_constants.py +23 -0
  268. package/skills/prototype/scripts/prototype_scripts_constants/config/probe_sandbox_safety_constants.py +45 -0
  269. package/skills/prototype/scripts/prototype_scripts_constants/config/prototype_common_constants.py +10 -0
  270. package/skills/prototype/scripts/test_build_sandbox_settings.py +275 -0
  271. package/skills/prototype/scripts/test_launch_sandbox.py +303 -0
  272. package/skills/prototype/scripts/test_probe_sandbox_safety.py +284 -0
  273. package/skills/prototype/workflows/promotion.md +27 -0
  274. package/skills/prototype/workflows/sandbox.md +35 -0
  275. package/skills/release-notes-html/SKILL.md +164 -0
  276. package/skills/skill-builder/CLAUDE.md +3 -3
  277. package/skills/skill-builder/SKILL.md +5 -5
  278. package/skills/skill-builder/references/CLAUDE.md +1 -1
  279. package/skills/skill-builder/references/delegation-map.md +3 -3
  280. package/skills/skill-builder/references/description-field.md +1 -1
  281. package/skills/skill-builder/references/skill-modularity.md +2 -3
  282. package/skills/skill-builder/workflows/CLAUDE.md +1 -1
  283. package/skills/skill-builder/workflows/improve-skill.md +1 -1
  284. package/skills/skill-builder/workflows/new-skill.md +2 -2
  285. package/skills/task-build/CLAUDE.md +8 -7
  286. package/skills/task-build/SKILL.md +16 -8
  287. package/skills/task-build/reference/tool-routing.md +19 -0
  288. package/skills/team-advisor/SKILL.md +2 -2
  289. package/scripts/test_invoke_code_review.py +0 -672
  290. 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()
@@ -20,10 +20,11 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
20
20
  if _hooks_dir not in sys.path:
21
21
  sys.path.insert(0, _hooks_dir)
22
22
 
23
- from blocking.pr_description_body_audit import _iter_section_headers # noqa: E402
24
- from blocking.pr_description_pr_number import ( # noqa: E402
25
- _extract_pr_number_from_command,
23
+ from blocking.convergence_gate_blocker import ( # noqa: E402
24
+ _repo_flag_arguments,
25
+ _resolve_named_identity,
26
26
  )
27
+ from blocking.pr_description_body_audit import _iter_section_headers # noqa: E402
27
28
  from hooks_constants.pr_description_enforcer_constants import ( # noqa: E402
28
29
  BULLET_MARKER_PATTERN,
29
30
  FENCED_CODE_BLOCK_PATTERN,
@@ -32,7 +33,6 @@ from hooks_constants.pr_description_enforcer_constants import ( # noqa: E402
32
33
  from hooks_constants.pr_description_proof_of_work_constants import ( # noqa: E402
33
34
  ALL_HONEST_GAP_PHRASES,
34
35
  ALL_PR_DIFF_SUBCOMMANDS,
35
- ALL_PR_VIEW_NUMBER_ARGUMENTS,
36
36
  ALL_PROOF_HEADING_KEYWORDS,
37
37
  ALL_VISUAL_FILE_SUFFIXES,
38
38
  COMMENT_BODY_JSON_FIELD,
@@ -49,7 +49,6 @@ from hooks_constants.pr_description_proof_of_work_constants import ( # noqa: E4
49
49
  PLAN_LINKAGE_KEYWORD_PATTERN,
50
50
  PR_COMMENTS_API_PATH_TEMPLATE,
51
51
  PR_DIFF_NAME_ONLY_FLAG,
52
- PR_NUMBER_JSON_FIELD,
53
52
  PR_READY_GATE_MESSAGE_TEMPLATE,
54
53
  PR_READY_INVOCATION_PATTERN,
55
54
  PR_READY_UNDO_FLAG,
@@ -112,13 +111,18 @@ def audit_proof_comment_body(body: str, pr_number: int | None) -> list[str]:
112
111
  an honest-gaps statement, and (when the PR diff is visual) an
113
112
  image embed.
114
113
  """
115
- is_visual_change = _detect_visual_change(pr_number)
114
+ is_visual_change = _detect_visual_change(pr_number, None)
116
115
  return _missing_proof_parts(body, is_visual_change)
117
116
 
118
117
 
119
118
  def evaluate_pr_ready_gate(command: str) -> str | None:
120
119
  """Decide whether a gh pr ready command may proceed.
121
120
 
121
+ Every gh read the gate runs binds to the repository the command names —
122
+ a full PR URL or a ``--repo``/``-R`` flag — so a command targeting one
123
+ repository from a working tree checked out to another audits the named
124
+ repository's PR, not the working tree's.
125
+
122
126
  Args:
123
127
  command: The raw shell command captured by the hook.
124
128
 
@@ -127,15 +131,14 @@ def evaluate_pr_ready_gate(command: str) -> str | None:
127
131
  carries no passing proof comment, or None when a passing proof
128
132
  comment exists or any gh query fails.
129
133
  """
130
- resolved_pr_number = _extract_pr_number_from_command(command)
131
- if resolved_pr_number is None:
132
- resolved_pr_number = _resolve_current_pr_number()
133
- if resolved_pr_number is None:
134
+ target_identity = _resolve_named_identity(command, None)
135
+ if target_identity is None:
134
136
  return None
135
- all_comment_bodies = _fetch_pr_comment_bodies(resolved_pr_number)
137
+ all_target_repo, resolved_pr_number = target_identity
138
+ all_comment_bodies = _fetch_pr_comment_bodies(resolved_pr_number, all_target_repo)
136
139
  if all_comment_bodies is None:
137
140
  return None
138
- is_visual_change = _detect_visual_change(resolved_pr_number)
141
+ is_visual_change = _detect_visual_change(resolved_pr_number, all_target_repo)
139
142
  for each_body in all_comment_bodies:
140
143
  if not is_proof_shaped_body(each_body):
141
144
  continue
@@ -173,29 +176,30 @@ def _run_gh_command(all_gh_arguments: list[str]) -> str | None:
173
176
  return completed_process.stdout
174
177
 
175
178
 
176
- def _resolve_current_pr_number() -> int | None:
177
- """Resolve the PR number of the current branch's open PR.
179
+ def _build_comments_api_path(pr_number: int, all_target_repo: tuple[str, str] | None) -> str:
180
+ """Build the issues-comments API path for the named repository's PR.
181
+
182
+ Args:
183
+ pr_number: The PR number whose comments to read.
184
+ all_target_repo: The (owner, repo) pair the command names, or None to
185
+ leave gh's ``{owner}``/``{repo}`` placeholders for gh to expand
186
+ from the current directory's repository.
178
187
 
179
188
  Returns:
180
- The PR number from ``gh pr view``, or None when no PR resolves or
181
- gh fails.
189
+ The ``repos/<owner>/<repo>/issues/<pr_number>/comments`` path with
190
+ the named owner and repo filled in, or the placeholder form when no
191
+ repository is named.
182
192
  """
183
- view_stdout = _run_gh_command(list(ALL_PR_VIEW_NUMBER_ARGUMENTS))
184
- if view_stdout is None:
185
- return None
186
- try:
187
- view_record = json.loads(view_stdout)
188
- except json.JSONDecodeError:
189
- return None
190
- if not isinstance(view_record, dict):
191
- return None
192
- resolved_number = view_record.get(PR_NUMBER_JSON_FIELD)
193
- if isinstance(resolved_number, int):
194
- return resolved_number
195
- return None
193
+ comments_api_path = PR_COMMENTS_API_PATH_TEMPLATE.format(pr_number=pr_number)
194
+ if all_target_repo is None:
195
+ return comments_api_path
196
+ owner, repo = all_target_repo
197
+ return comments_api_path.format(owner=owner, repo=repo)
196
198
 
197
199
 
198
- def _fetch_pr_comment_bodies(pr_number: int) -> list[str] | None:
200
+ def _fetch_pr_comment_bodies(
201
+ pr_number: int, all_target_repo: tuple[str, str] | None
202
+ ) -> list[str] | None:
199
203
  """Read every comment body on a PR through the paginated issues API.
200
204
 
201
205
  The query runs ``gh api --paginate --slurp`` so every page lands in one
@@ -204,13 +208,15 @@ def _fetch_pr_comment_bodies(pr_number: int) -> list[str] | None:
204
208
 
205
209
  Args:
206
210
  pr_number: The PR number whose comments to read.
211
+ all_target_repo: The (owner, repo) pair the command names, or None to
212
+ read the current directory's repository.
207
213
 
208
214
  Returns:
209
215
  The comment bodies across all pages, or None when gh fails or the
210
216
  output is not the slurped page-array shape — the caller fails open
211
217
  on None.
212
218
  """
213
- comments_api_path = PR_COMMENTS_API_PATH_TEMPLATE.format(pr_number=pr_number)
219
+ comments_api_path = _build_comments_api_path(pr_number, all_target_repo)
214
220
  api_stdout = _run_gh_command(
215
221
  [GH_API_SUBCOMMAND, comments_api_path, GH_PAGINATE_FLAG, GH_SLURP_FLAG]
216
222
  )
@@ -232,7 +238,9 @@ def _fetch_pr_comment_bodies(pr_number: int) -> list[str] | None:
232
238
  return all_comment_bodies
233
239
 
234
240
 
235
- def _detect_visual_change(pr_number: int | None) -> bool:
241
+ def _detect_visual_change(
242
+ pr_number: int | None, all_target_repo: tuple[str, str] | None
243
+ ) -> bool:
236
244
  """Decide whether the PR diff carries visual work.
237
245
 
238
246
  A diff is visual when a changed file carries an image, HTML, or
@@ -242,21 +250,31 @@ def _detect_visual_change(pr_number: int | None) -> bool:
242
250
  Args:
243
251
  pr_number: The PR number to diff, or None to diff the current
244
252
  branch's PR.
253
+ all_target_repo: The (owner, repo) pair the command names, or None to
254
+ diff the current directory's repository.
245
255
 
246
256
  Returns:
247
257
  True when the diff is visual; False when it is not or when any gh
248
258
  query fails — an unknowable diff never adds the visual requirement.
249
259
  """
250
260
  all_number_arguments = [str(pr_number)] if pr_number is not None else []
261
+ all_repo_arguments = _repo_flag_arguments(all_target_repo)
251
262
  name_only_stdout = _run_gh_command(
252
- [*ALL_PR_DIFF_SUBCOMMANDS, *all_number_arguments, PR_DIFF_NAME_ONLY_FLAG]
263
+ [
264
+ *ALL_PR_DIFF_SUBCOMMANDS,
265
+ *all_number_arguments,
266
+ *all_repo_arguments,
267
+ PR_DIFF_NAME_ONLY_FLAG,
268
+ ]
253
269
  )
254
270
  if name_only_stdout is None:
255
271
  return False
256
272
  for each_changed_path in name_only_stdout.splitlines():
257
273
  if Path(each_changed_path.strip()).suffix.lower() in ALL_VISUAL_FILE_SUFFIXES:
258
274
  return True
259
- diff_stdout = _run_gh_command([*ALL_PR_DIFF_SUBCOMMANDS, *all_number_arguments])
275
+ diff_stdout = _run_gh_command(
276
+ [*ALL_PR_DIFF_SUBCOMMANDS, *all_number_arguments, *all_repo_arguments]
277
+ )
260
278
  if diff_stdout is None:
261
279
  return False
262
280
  return HEX_COLOR_ADDED_LINE_PATTERN.search(diff_stdout[:MAX_DIFF_SCAN_CHARS]) is not None
@@ -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)
@@ -213,7 +213,7 @@ def test_additional_context_is_collected_from_deciding_hooks() -> None:
213
213
 
214
214
 
215
215
  def test_powershell_selects_the_three_shared_hooks() -> None:
216
- """Selecting for PowerShell yields the three shared hooks in registration order."""
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)
@@ -222,6 +222,9 @@ def test_powershell_selects_the_three_shared_hooks() -> None:
222
222
  "blocking/pii_prevention_blocker.py",
223
223
  "blocking/verified_commit_gate.py",
224
224
  "blocking/verdict_directory_write_blocker.py",
225
+ "blocking/code_review_push_gate.py",
226
+ "blocking/code_review_pr_create_gate.py",
227
+ "blocking/code_review_stamp_directory_write_blocker.py",
225
228
  ]
226
229
 
227
230
 
@@ -0,0 +1,62 @@
1
+ """Regression tests for the code-review enforcement constants bootstrap.
2
+
3
+ These prove the shared constants module resolves to the sibling
4
+ ``config/code_review_enforcement_constants.py`` file even when a foreign
5
+ ``config`` package sits ahead of ``blocking`` on ``sys.path``, and that a
6
+ registration a caller placed first is left untouched.
7
+ """
8
+
9
+ import subprocess
10
+ from collections.abc import Callable
11
+
12
+ RunUnderConfigShadow = Callable[[str], subprocess.CompletedProcess[str]]
13
+
14
+
15
+ def test_register_binds_dotted_name_to_sibling_constants_file(
16
+ run_under_config_shadow: RunUnderConfigShadow,
17
+ ) -> None:
18
+ completed_probe = run_under_config_shadow(
19
+ "import code_review_enforcement_config_bootstrap as bootstrap\n"
20
+ "bootstrap.register_code_review_enforcement_constants()\n"
21
+ "import config.code_review_enforcement_constants as loaded\n"
22
+ "print(loaded.__file__)\n"
23
+ "print(loaded.STAMP_DIRECTORY_NAME)\n"
24
+ )
25
+ assert completed_probe.returncode == 0, completed_probe.stderr
26
+ assert "IS_DECOY_CONFIG" not in completed_probe.stdout
27
+ resolved_file_line, stamp_directory_line = completed_probe.stdout.splitlines()[:2]
28
+ assert resolved_file_line.replace("\\", "/").endswith(
29
+ "blocking/config/code_review_enforcement_constants.py"
30
+ )
31
+ assert stamp_directory_line == "code-review-stamps"
32
+
33
+
34
+ def test_register_binds_push_required_effort(
35
+ run_under_config_shadow: RunUnderConfigShadow,
36
+ ) -> None:
37
+ completed_probe = run_under_config_shadow(
38
+ "import code_review_enforcement_config_bootstrap as bootstrap\n"
39
+ "bootstrap.register_code_review_enforcement_constants()\n"
40
+ "import config.code_review_enforcement_constants as loaded\n"
41
+ "print(loaded.PUSH_REQUIRED_EFFORT)\n"
42
+ )
43
+ assert completed_probe.returncode == 0, completed_probe.stderr
44
+ assert completed_probe.stdout.strip().splitlines()[-1] == "low"
45
+
46
+
47
+ def test_register_leaves_an_already_cached_module_untouched(
48
+ run_under_config_shadow: RunUnderConfigShadow,
49
+ ) -> None:
50
+ completed_probe = run_under_config_shadow(
51
+ "import sys\n"
52
+ "import types\n"
53
+ "sentinel = types.ModuleType('config.code_review_enforcement_constants')\n"
54
+ "sentinel.ORIGIN = 'caller-seeded'\n"
55
+ "sys.modules['config.code_review_enforcement_constants'] = sentinel\n"
56
+ "import code_review_enforcement_config_bootstrap as bootstrap\n"
57
+ "bootstrap.register_code_review_enforcement_constants()\n"
58
+ "import config.code_review_enforcement_constants as loaded\n"
59
+ "print(getattr(loaded, 'ORIGIN', 'displaced'))\n"
60
+ )
61
+ assert completed_probe.returncode == 0, completed_probe.stderr
62
+ assert completed_probe.stdout.strip().splitlines()[-1] == "caller-seeded"
@@ -0,0 +1,54 @@
1
+ """Behavioral tests for the shared code-review gate deny scaffold.
2
+
3
+ The tests drive the two shared helpers directly: the payload builder's shape and
4
+ the log-and-emit helper's stdout, so both code-review gates inherit one verified
5
+ deny path.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib.util
11
+ import json
12
+ import sys
13
+ from pathlib import Path
14
+ from types import ModuleType
15
+
16
+ import pytest
17
+
18
+ _HOOK_DIR = Path(__file__).resolve().parent
19
+ if str(_HOOK_DIR) not in sys.path:
20
+ sys.path.insert(0, str(_HOOK_DIR))
21
+
22
+
23
+ def _load_module(module_name: str) -> ModuleType:
24
+ module_spec = importlib.util.spec_from_file_location(
25
+ module_name, _HOOK_DIR / f"{module_name}.py"
26
+ )
27
+ assert module_spec is not None
28
+ assert module_spec.loader is not None
29
+ loaded_module = importlib.util.module_from_spec(module_spec)
30
+ module_spec.loader.exec_module(loaded_module)
31
+ return loaded_module
32
+
33
+
34
+ deny_module = _load_module("code_review_gate_deny")
35
+
36
+
37
+ def test_build_payload_carries_reason_and_deny_decision() -> None:
38
+ deny_payload = deny_module.build_code_review_deny_payload("blocked reason")
39
+ hook_output = deny_payload["hookSpecificOutput"]
40
+ assert hook_output["permissionDecision"] == "deny"
41
+ assert hook_output["permissionDecisionReason"] == "blocked reason"
42
+
43
+
44
+ def test_log_and_emit_code_review_deny_writes_payload_to_stdout(
45
+ capsys: pytest.CaptureFixture[str],
46
+ monkeypatch: pytest.MonkeyPatch,
47
+ tmp_path: Path,
48
+ ) -> None:
49
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
50
+ deny_module.log_and_emit_code_review_deny("blocked reason", "Bash", "code_review_push_gate")
51
+ emitted_payload = json.loads(capsys.readouterr().out)
52
+ hook_output = emitted_payload["hookSpecificOutput"]
53
+ assert hook_output["permissionDecision"] == "deny"
54
+ assert hook_output["permissionDecisionReason"] == "blocked reason"