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,205 @@
1
+ """Behavioral tests for the per-work-tree code-review stamp store.
2
+
3
+ Each test builds a real git repository with a real origin remote and records or
4
+ reads real stamp files under an isolated home, so it exercises the same store
5
+ surface the push and PR-create gates call: record a clean stamp, then ask
6
+ whether it covers the live branch surface at a given effort.
7
+ """
8
+
9
+ import importlib.util
10
+ import pathlib
11
+ import subprocess
12
+ import sys
13
+
14
+ import pytest
15
+
16
+ _HOOK_DIR = pathlib.Path(__file__).parent
17
+ if str(_HOOK_DIR) not in sys.path:
18
+ sys.path.insert(0, str(_HOOK_DIR))
19
+
20
+ _store_spec = importlib.util.spec_from_file_location(
21
+ "code_review_stamp_store",
22
+ _HOOK_DIR / "code_review_stamp_store.py",
23
+ )
24
+ assert _store_spec is not None
25
+ assert _store_spec.loader is not None
26
+ _store_module = importlib.util.module_from_spec(_store_spec)
27
+ _store_spec.loader.exec_module(_store_module)
28
+
29
+ record_clean_stamp = _store_module.record_clean_stamp
30
+ stamp_covers_surface = _store_module.stamp_covers_surface
31
+ live_surface_hash = _store_module.live_surface_hash
32
+ stamp_path_for_repo = _store_module.stamp_path_for_repo
33
+
34
+ _verdict_store_module = sys.modules["verification_verdict_store"]
35
+ root_key_for_repo = _verdict_store_module.root_key_for_repo
36
+
37
+ _constants_spec = importlib.util.spec_from_file_location(
38
+ "code_review_enforcement_constants",
39
+ _HOOK_DIR / "config" / "code_review_enforcement_constants.py",
40
+ )
41
+ assert _constants_spec is not None
42
+ assert _constants_spec.loader is not None
43
+ _constants_module = importlib.util.module_from_spec(_constants_spec)
44
+ _constants_spec.loader.exec_module(_constants_module)
45
+ PUSH_REQUIRED_EFFORT = _constants_module.PUSH_REQUIRED_EFFORT
46
+ PR_CREATE_REQUIRED_EFFORT = _constants_module.PR_CREATE_REQUIRED_EFFORT
47
+ ALL_EFFORT_TOKENS_IN_ASCENDING_ORDER = _constants_module.ALL_EFFORT_TOKENS_IN_ASCENDING_ORDER
48
+
49
+ PRODUCTION_SOURCE = "def add(left: int, right: int) -> int:\n return left + right\n"
50
+ CHANGED_SOURCE = "def add(left: int, right: int) -> int:\n return left - right\n"
51
+ FURTHER_CHANGED_SOURCE = "def add(left: int, right: int) -> int:\n return left * right\n"
52
+
53
+
54
+ def _run_git(repo_dir: pathlib.Path, *git_arguments: str) -> None:
55
+ subprocess.run(
56
+ ["git", "-C", str(repo_dir), *git_arguments],
57
+ check=True,
58
+ capture_output=True,
59
+ text=True,
60
+ )
61
+
62
+
63
+ def _make_repo_with_origin(tmp_path: pathlib.Path) -> pathlib.Path:
64
+ origin_dir = tmp_path / "origin.git"
65
+ work_dir = tmp_path / "work"
66
+ work_dir.mkdir()
67
+ subprocess.run(
68
+ ["git", "init", "--bare", "--initial-branch=main", str(origin_dir)],
69
+ check=True,
70
+ capture_output=True,
71
+ text=True,
72
+ )
73
+ empty_hooks_dir = tmp_path / "nohooks"
74
+ empty_hooks_dir.mkdir()
75
+ _run_git(work_dir, "init", "--initial-branch=main")
76
+ _run_git(work_dir, "config", "user.email", "tests@example.com")
77
+ _run_git(work_dir, "config", "user.name", "Stamp Store Tests")
78
+ _run_git(work_dir, "config", "core.hooksPath", str(empty_hooks_dir))
79
+ (work_dir / "src").mkdir()
80
+ (work_dir / "src" / "app.py").write_text(PRODUCTION_SOURCE, encoding="utf-8")
81
+ _run_git(work_dir, "add", "-A")
82
+ _run_git(work_dir, "commit", "-m", "base")
83
+ _run_git(work_dir, "remote", "add", "origin", str(origin_dir))
84
+ _run_git(work_dir, "push", "-u", "origin", "main")
85
+ return work_dir
86
+
87
+
88
+ def _isolate_home(monkeypatch: pytest.MonkeyPatch, fake_home: pathlib.Path) -> None:
89
+ home_text = str(fake_home)
90
+ monkeypatch.setenv("HOME", home_text)
91
+ monkeypatch.setenv("USERPROFILE", home_text)
92
+ monkeypatch.delenv("HOMEDRIVE", raising=False)
93
+ monkeypatch.delenv("HOMEPATH", raising=False)
94
+
95
+
96
+ def _repo_with_change(tmp_path: pathlib.Path, changed_source: str) -> pathlib.Path:
97
+ work_dir = _make_repo_with_origin(tmp_path)
98
+ (work_dir / "src" / "app.py").write_text(changed_source, encoding="utf-8")
99
+ return work_dir
100
+
101
+
102
+ def test_recorded_low_stamp_covers_the_push_surface(
103
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
104
+ ) -> None:
105
+ _isolate_home(monkeypatch, tmp_path / "home")
106
+ work_dir = _repo_with_change(tmp_path, CHANGED_SOURCE)
107
+ surface_hash = live_surface_hash(str(work_dir))
108
+ assert surface_hash is not None
109
+ record_clean_stamp(str(work_dir), surface_hash, PUSH_REQUIRED_EFFORT)
110
+ assert stamp_covers_surface(str(work_dir), surface_hash, PUSH_REQUIRED_EFFORT) is True
111
+
112
+
113
+ def test_low_stamp_does_not_cover_the_pr_create_surface(
114
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
115
+ ) -> None:
116
+ _isolate_home(monkeypatch, tmp_path / "home")
117
+ work_dir = _repo_with_change(tmp_path, CHANGED_SOURCE)
118
+ surface_hash = live_surface_hash(str(work_dir))
119
+ assert surface_hash is not None
120
+ record_clean_stamp(str(work_dir), surface_hash, PUSH_REQUIRED_EFFORT)
121
+ assert stamp_covers_surface(str(work_dir), surface_hash, PR_CREATE_REQUIRED_EFFORT) is False
122
+
123
+
124
+ def test_xhigh_stamp_covers_both_push_and_pr_create(
125
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
126
+ ) -> None:
127
+ _isolate_home(monkeypatch, tmp_path / "home")
128
+ work_dir = _repo_with_change(tmp_path, CHANGED_SOURCE)
129
+ surface_hash = live_surface_hash(str(work_dir))
130
+ assert surface_hash is not None
131
+ record_clean_stamp(str(work_dir), surface_hash, PR_CREATE_REQUIRED_EFFORT)
132
+ assert stamp_covers_surface(str(work_dir), surface_hash, PUSH_REQUIRED_EFFORT) is True
133
+ assert stamp_covers_surface(str(work_dir), surface_hash, PR_CREATE_REQUIRED_EFFORT) is True
134
+
135
+
136
+ def test_stamp_bound_to_one_hash_does_not_cover_a_different_hash(
137
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
138
+ ) -> None:
139
+ _isolate_home(monkeypatch, tmp_path / "home")
140
+ work_dir = _repo_with_change(tmp_path, CHANGED_SOURCE)
141
+ surface_hash = live_surface_hash(str(work_dir))
142
+ assert surface_hash is not None
143
+ record_clean_stamp(str(work_dir), surface_hash, PUSH_REQUIRED_EFFORT)
144
+ other_hash = "f" * len(surface_hash)
145
+ assert stamp_covers_surface(str(work_dir), other_hash, PUSH_REQUIRED_EFFORT) is False
146
+
147
+
148
+ def test_editing_a_file_after_minting_drops_coverage(
149
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
150
+ ) -> None:
151
+ _isolate_home(monkeypatch, tmp_path / "home")
152
+ work_dir = _repo_with_change(tmp_path, CHANGED_SOURCE)
153
+ minted_hash = live_surface_hash(str(work_dir))
154
+ assert minted_hash is not None
155
+ record_clean_stamp(str(work_dir), minted_hash, PUSH_REQUIRED_EFFORT)
156
+ (work_dir / "src" / "app.py").write_text(FURTHER_CHANGED_SOURCE, encoding="utf-8")
157
+ live_hash_after_edit = live_surface_hash(str(work_dir))
158
+ assert live_hash_after_edit is not None
159
+ assert live_hash_after_edit != minted_hash
160
+ assert stamp_covers_surface(str(work_dir), live_hash_after_edit, PUSH_REQUIRED_EFFORT) is False
161
+
162
+
163
+ def test_no_stamp_file_reads_as_not_covered(
164
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
165
+ ) -> None:
166
+ _isolate_home(monkeypatch, tmp_path / "home")
167
+ work_dir = _repo_with_change(tmp_path, CHANGED_SOURCE)
168
+ surface_hash = live_surface_hash(str(work_dir))
169
+ assert surface_hash is not None
170
+ assert not stamp_path_for_repo(str(work_dir)).exists()
171
+ assert stamp_covers_surface(str(work_dir), surface_hash, PUSH_REQUIRED_EFFORT) is False
172
+
173
+
174
+ def test_recording_a_second_effort_keeps_the_first(
175
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
176
+ ) -> None:
177
+ _isolate_home(monkeypatch, tmp_path / "home")
178
+ work_dir = _repo_with_change(tmp_path, CHANGED_SOURCE)
179
+ surface_hash = live_surface_hash(str(work_dir))
180
+ assert surface_hash is not None
181
+ record_clean_stamp(str(work_dir), surface_hash, PUSH_REQUIRED_EFFORT)
182
+ record_clean_stamp(str(work_dir), surface_hash, PR_CREATE_REQUIRED_EFFORT)
183
+ assert stamp_covers_surface(str(work_dir), surface_hash, PUSH_REQUIRED_EFFORT) is True
184
+ assert stamp_covers_surface(str(work_dir), surface_hash, PR_CREATE_REQUIRED_EFFORT) is True
185
+
186
+
187
+ def test_live_surface_hash_is_none_when_nothing_changed(
188
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
189
+ ) -> None:
190
+ _isolate_home(monkeypatch, tmp_path / "home")
191
+ work_dir = _make_repo_with_origin(tmp_path)
192
+ assert live_surface_hash(str(work_dir)) is None
193
+
194
+
195
+ def test_push_required_effort_is_the_lowest_known_token() -> None:
196
+ assert PUSH_REQUIRED_EFFORT == ALL_EFFORT_TOKENS_IN_ASCENDING_ORDER[0]
197
+
198
+
199
+ def test_stamp_path_for_repo_stem_matches_the_shared_root_key(
200
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
201
+ ) -> None:
202
+ _isolate_home(monkeypatch, tmp_path / "home")
203
+ work_dir = tmp_path / "work-tree"
204
+ work_dir.mkdir()
205
+ assert stamp_path_for_repo(str(work_dir)).stem == root_key_for_repo(str(work_dir))
@@ -183,6 +183,20 @@ def other_value() -> int:
183
183
  return 42
184
184
  '''
185
185
 
186
+ BROKEN_ENFORCER_SOURCE = (
187
+ '"""Enforcer stub that raises a non-import error at load time."""\n'
188
+ "\n"
189
+ "raise RuntimeError('deliberate enforcer load failure')\n"
190
+ )
191
+
192
+ RE_ERROR_ENFORCER_SOURCE = (
193
+ '"""Enforcer stub whose module-scope regex compile raises re.error."""\n'
194
+ "\n"
195
+ "import re\n"
196
+ "\n"
197
+ "re.compile('(')\n"
198
+ )
199
+
186
200
 
187
201
  def run_git(repository_root: Path, *git_arguments: str) -> str:
188
202
  completed = subprocess.run(
@@ -240,16 +254,24 @@ def advance_origin_main_divergent(
240
254
  run_git(repository_root, "checkout", "feature")
241
255
 
242
256
 
243
- def write_agent_payload(subagent_type: str, prompt: str, cwd: Path) -> str:
257
+ def _spawn_payload(tool_name: str, subagent_type: str, prompt: str, cwd: Path) -> str:
244
258
  return json.dumps(
245
259
  {
246
- "tool_name": "Agent",
260
+ "tool_name": tool_name,
247
261
  "tool_input": {"subagent_type": subagent_type, "prompt": prompt},
248
262
  "cwd": str(cwd),
249
263
  }
250
264
  )
251
265
 
252
266
 
267
+ def write_agent_payload(subagent_type: str, prompt: str, cwd: Path) -> str:
268
+ return _spawn_payload("Agent", subagent_type, prompt, cwd)
269
+
270
+
271
+ def write_task_payload(subagent_type: str, prompt: str, cwd: Path) -> str:
272
+ return _spawn_payload("Task", subagent_type, prompt, cwd)
273
+
274
+
253
275
  def run_hook(payload: str, cwd: Path) -> subprocess.CompletedProcess[str]:
254
276
  return subprocess.run(
255
277
  [sys.executable, str(HOOK_PATH)],
@@ -586,3 +608,103 @@ def test_hook_imports_real_config_when_parent_holds_shadowing_config(
586
608
 
587
609
  assert "ModuleNotFoundError" not in completed.stderr
588
610
  assert completed.returncode == 0
611
+
612
+
613
+ def test_task_tool_code_verifier_spawn_is_gated_like_agent(tmp_path: Path) -> None:
614
+ repository_root = tmp_path / "repo"
615
+ repository_root.mkdir()
616
+ initialize_repository(repository_root)
617
+ run_git(repository_root, "checkout", "-b", "feature")
618
+ write_working_tree_file(repository_root, "fresh.py", VIOLATING_MODULE_SOURCE)
619
+ task_payload = write_task_payload("code-verifier", "verify the change", repository_root)
620
+ task_result = run_hook(task_payload, repository_root)
621
+ assert not is_allow(task_result)
622
+ task_reason = deny_reason(task_result)
623
+ assert "fresh.py" in task_reason
624
+ assert "CODE_RULES violations on changed lines:" in task_reason
625
+ clean_coder_payload = write_task_payload("clean-coder", "write code", repository_root)
626
+ clean_coder_result = run_hook(clean_coder_payload, repository_root)
627
+ assert is_allow(clean_coder_result)
628
+
629
+
630
+ def stage_package_and_feature_repo(tmp_path: Path) -> tuple[Path, Path]:
631
+ """Copy the hook package and build a clean feature repo for load-failure tests.
632
+
633
+ Args:
634
+ tmp_path: The pytest temporary directory the staged copy lands under.
635
+
636
+ Returns:
637
+ A ``(staged_enforcer_path, repository_root)`` pair: the copied
638
+ ``code_rules_enforcer.py`` a test renders unloadable, and the clean
639
+ feature repository the staged hook runs against.
640
+ """
641
+ real_hooks_directory = HOOK_PATH.parent.parent
642
+ real_package_directory = real_hooks_directory.parent
643
+ staged_package_directory = tmp_path / "claude-dev-env"
644
+ shutil.copytree(real_hooks_directory, staged_package_directory / "hooks")
645
+ shutil.copytree(
646
+ real_package_directory / "_shared", staged_package_directory / "_shared"
647
+ )
648
+ enforcer_path = staged_package_directory / "hooks" / "blocking" / "code_rules_enforcer.py"
649
+ repository_root = tmp_path / "repo"
650
+ repository_root.mkdir()
651
+ initialize_repository(repository_root)
652
+ run_git(repository_root, "checkout", "-b", "feature")
653
+ write_working_tree_file(repository_root, "feature.py", CLEAN_MODULE_SOURCE)
654
+ return enforcer_path, repository_root
655
+
656
+
657
+ def run_staged_preflight_hook(
658
+ enforcer_path: Path, repository_root: Path
659
+ ) -> subprocess.CompletedProcess[str]:
660
+ """Run the staged preflight hook that sits beside *enforcer_path*.
661
+
662
+ Args:
663
+ enforcer_path: The staged ``code_rules_enforcer.py`` path; the staged
664
+ hook is its sibling in the same ``hooks/blocking`` directory.
665
+ repository_root: The feature repository the hook inspects.
666
+
667
+ Returns:
668
+ The completed hook subprocess run.
669
+ """
670
+ staged_hook = enforcer_path.parent / "code_verifier_spawn_preflight_gate.py"
671
+ payload = write_agent_payload("code-verifier", "verify the change", repository_root)
672
+ return subprocess.run(
673
+ [sys.executable, str(staged_hook)],
674
+ check=False,
675
+ input=payload,
676
+ capture_output=True,
677
+ text=True,
678
+ cwd=str(repository_root),
679
+ timeout=120,
680
+ )
681
+
682
+
683
+ def test_engine_load_failure_denies_with_named_reason(tmp_path: Path) -> None:
684
+ enforcer_path, repository_root = stage_package_and_feature_repo(tmp_path)
685
+ enforcer_path.unlink()
686
+ completed = run_staged_preflight_hook(enforcer_path, repository_root)
687
+ assert not is_allow(completed)
688
+ reason = deny_reason(completed)
689
+ assert "CODE_RULES engine failed to load" in reason
690
+ assert "load_validate_content" in reason
691
+
692
+
693
+ def test_engine_import_error_denies_with_named_reason(tmp_path: Path) -> None:
694
+ enforcer_path, repository_root = stage_package_and_feature_repo(tmp_path)
695
+ enforcer_path.write_text(BROKEN_ENFORCER_SOURCE, encoding="utf-8")
696
+ completed = run_staged_preflight_hook(enforcer_path, repository_root)
697
+ assert not is_allow(completed)
698
+ reason = deny_reason(completed)
699
+ assert "CODE_RULES engine failed to load" in reason
700
+ assert "load_validate_content" in reason
701
+
702
+
703
+ def test_engine_re_error_denies_with_named_reason(tmp_path: Path) -> None:
704
+ enforcer_path, repository_root = stage_package_and_feature_repo(tmp_path)
705
+ enforcer_path.write_text(RE_ERROR_ENFORCER_SOURCE, encoding="utf-8")
706
+ completed = run_staged_preflight_hook(enforcer_path, repository_root)
707
+ assert not is_allow(completed)
708
+ reason = deny_reason(completed)
709
+ assert "CODE_RULES engine failed to load" in reason
710
+ assert "load_validate_content" in reason
@@ -4,6 +4,7 @@ import importlib.util
4
4
  import io
5
5
  import json
6
6
  import pathlib
7
+ import re
7
8
  import subprocess
8
9
  import sys
9
10
 
@@ -13,8 +14,6 @@ _HOOK_DIR = pathlib.Path(__file__).parent
13
14
  if str(_HOOK_DIR) not in sys.path:
14
15
  sys.path.insert(0, str(_HOOK_DIR))
15
16
 
16
- import re
17
-
18
17
  _GH_PR_READY_PATTERN = re.compile(r"\bgh\s+pr\s+ready\b(?![^&|;\n]*--undo)")
19
18
 
20
19
  hook_spec = importlib.util.spec_from_file_location(
@@ -53,15 +52,44 @@ def test_does_not_match_gh_issue_close() -> None:
53
52
 
54
53
 
55
54
  def test_extracts_pr_number_from_command() -> None:
56
- assert _resolve_pr_number("gh pr ready 418", None) == 418
55
+ assert _resolve_pr_number("gh pr ready 418", None, None, None) == 418
56
+
57
+
58
+ def test_extracts_pr_number_when_repo_flag_precedes_number() -> None:
59
+ assert (
60
+ _resolve_pr_number(
61
+ "gh pr ready --repo sample-owner/target-repo 161",
62
+ None,
63
+ "sample-owner",
64
+ "target-repo",
65
+ )
66
+ == 161
67
+ )
57
68
 
58
69
 
59
70
  def test_extracts_pr_number_with_flags() -> None:
60
- assert _resolve_pr_number("gh pr ready 99 --undo", None) == 99
71
+ assert _resolve_pr_number("gh pr ready 99 --undo", None, None, None) == 99
61
72
 
62
73
 
63
74
  def test_returns_none_when_no_number_and_no_repo() -> None:
64
- assert _resolve_pr_number("gh pr ready", "/nonexistent/path") is None
75
+ assert _resolve_pr_number("gh pr ready", "/nonexistent/path", None, None) is None
76
+
77
+
78
+ def test_number_resolution_binds_gh_view_to_named_repo(
79
+ monkeypatch: pytest.MonkeyPatch,
80
+ ) -> None:
81
+ captured_commands: list[list[str]] = []
82
+
83
+ def fake_run(all_arguments: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
84
+ captured_commands.append(all_arguments)
85
+ return subprocess.CompletedProcess(args=all_arguments, returncode=0, stdout="500\n", stderr="")
86
+
87
+ monkeypatch.setattr(hook_module.subprocess, "run", fake_run)
88
+ assert _resolve_pr_number("gh pr ready", None, "flag-owner", "flag-repo") == 500
89
+ assert captured_commands
90
+ for each_command in captured_commands:
91
+ assert "--repo" in each_command
92
+ assert "flag-owner/flag-repo" in each_command
65
93
 
66
94
 
67
95
  def test_matches_gh_pr_ready_in_compound_command() -> None:
@@ -226,6 +254,13 @@ def test_parse_repo_flag_short_alias() -> None:
226
254
  )
227
255
 
228
256
 
257
+ def test_parse_repo_flag_short_alias_attached_value() -> None:
258
+ assert hook_module._parse_repo_flag("gh pr ready 161 -Rsample-owner/target-repo") == (
259
+ "sample-owner",
260
+ "target-repo",
261
+ )
262
+
263
+
229
264
  def test_parse_repo_flag_absent_returns_none() -> None:
230
265
  assert hook_module._parse_repo_flag("gh pr ready 418") is None
231
266
 
@@ -249,6 +284,26 @@ def test_ready_segment_clips_at_command_separator() -> None:
249
284
  )
250
285
 
251
286
 
287
+ def test_ready_segment_keeps_repo_flag_on_a_continued_line() -> None:
288
+ segment = hook_module._ready_command_segment(
289
+ "gh pr ready 161 \\\n --repo target-owner/target-repo"
290
+ )
291
+ assert "--repo" in segment
292
+ assert "target-owner/target-repo" in segment
293
+
294
+
295
+ def test_continued_repo_flag_binds_the_gate_to_the_named_repo(
296
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
297
+ ) -> None:
298
+ identity = _capture_convergence_identity(
299
+ monkeypatch,
300
+ tmp_path,
301
+ "gh pr ready 161 \\\n --repo target-owner/target-repo",
302
+ ("cwd-owner", "cwd-repo"),
303
+ )
304
+ assert identity == ("target-owner", "target-repo", 161)
305
+
306
+
252
307
  def test_chained_repo_flag_does_not_misbind_the_gate(
253
308
  monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
254
309
  ) -> None:
@@ -278,3 +333,96 @@ def test_ready_segment_skips_a_leading_undo_invocation() -> None:
278
333
  hook_module._ready_command_segment("gh pr ready --undo && gh pr ready 161")
279
334
  == "gh pr ready 161"
280
335
  )
336
+
337
+
338
+ def test_parse_repo_flag_full_url_form() -> None:
339
+ assert hook_module._parse_repo_flag(
340
+ "gh pr ready 161 --repo https://github.com/other-owner/other-repo"
341
+ ) == ("other-owner", "other-repo")
342
+
343
+
344
+ def test_parse_repo_flag_short_alias_full_url_form() -> None:
345
+ assert hook_module._parse_repo_flag(
346
+ "gh pr ready 161 -R https://github.com/other-owner/other-repo"
347
+ ) == ("other-owner", "other-repo")
348
+
349
+
350
+ def test_parse_repo_flag_ssh_form() -> None:
351
+ assert hook_module._parse_repo_flag(
352
+ "gh pr ready 161 --repo git@github.com:other-owner/other-repo"
353
+ ) == ("other-owner", "other-repo")
354
+
355
+
356
+ def test_parse_repo_flag_ssh_form_strips_git_suffix() -> None:
357
+ assert hook_module._parse_repo_flag(
358
+ "gh pr ready 161 --repo git@github.com:other-owner/other-repo.git"
359
+ ) == ("other-owner", "other-repo")
360
+
361
+
362
+ def test_parse_repo_flag_url_form_strips_git_suffix() -> None:
363
+ assert hook_module._parse_repo_flag(
364
+ "gh pr ready 161 --repo https://github.com/other-owner/other-repo.git"
365
+ ) == ("other-owner", "other-repo")
366
+
367
+
368
+ def test_parse_pr_url_accepts_enterprise_host() -> None:
369
+ assert hook_module._parse_pr_url(
370
+ "gh pr ready https://github.example.com/other-owner/other-repo/pull/161"
371
+ ) == ("other-owner", "other-repo", 161)
372
+
373
+
374
+ def test_background_operator_clips_chained_repo_flag(
375
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
376
+ ) -> None:
377
+ identity = _capture_convergence_identity(
378
+ monkeypatch,
379
+ tmp_path,
380
+ "gh pr ready 161 & gh pr comment 999 --repo other-owner/other-repo",
381
+ ("cwd-owner", "cwd-repo"),
382
+ )
383
+ assert identity == ("cwd-owner", "cwd-repo", 161)
384
+
385
+
386
+ def test_trailing_redirect_keeps_earlier_repo_flag(
387
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
388
+ ) -> None:
389
+ identity = _capture_convergence_identity(
390
+ monkeypatch,
391
+ tmp_path,
392
+ "gh pr ready 161 --repo other-owner/other-repo 2>&1",
393
+ ("cwd-owner", "cwd-repo"),
394
+ )
395
+ assert identity == ("other-owner", "other-repo", 161)
396
+
397
+
398
+ def test_stderr_redirect_dup_keeps_repo_flag() -> None:
399
+ segment = hook_module._ready_command_segment(
400
+ "gh pr ready 161 >&2 --repo other-owner/other-repo"
401
+ )
402
+ assert hook_module._parse_repo_flag(segment) == ("other-owner", "other-repo")
403
+
404
+
405
+ def test_stderr_to_stdout_redirect_keeps_repo_flag() -> None:
406
+ segment = hook_module._ready_command_segment(
407
+ "gh pr ready 2>&1 --repo other-owner/other-repo"
408
+ )
409
+ assert hook_module._parse_repo_flag(segment) == ("other-owner", "other-repo")
410
+
411
+
412
+ def test_append_all_output_redirect_keeps_repo_flag() -> None:
413
+ segment = hook_module._ready_command_segment(
414
+ "gh pr ready 161 &> log --repo other-owner/other-repo"
415
+ )
416
+ assert hook_module._parse_repo_flag(segment) == ("other-owner", "other-repo")
417
+
418
+
419
+ def test_stderr_redirect_binds_repo_end_to_end(
420
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
421
+ ) -> None:
422
+ identity = _capture_convergence_identity(
423
+ monkeypatch,
424
+ tmp_path,
425
+ "gh pr ready 161 >&2 --repo other-owner/other-repo",
426
+ ("cwd-owner", "cwd-repo"),
427
+ )
428
+ assert identity == ("other-owner", "other-repo", 161)
@@ -1,4 +1,4 @@
1
- """Tests for destructive-command-blocker hook."""
1
+ """Tests for the destructive-command-blocker hook."""
2
2
 
3
3
  import json
4
4
  import os
@@ -0,0 +1,45 @@
1
+ """Tests for the destructive-command-blocker deny mode.
2
+
3
+ The sandbox runs under ``--dangerously-skip-permissions``, which auto-resolves
4
+ an ``ask`` decision, so only a hard ``deny`` contains a destructive command.
5
+ Setting ``CLAUDE_DESTRUCTIVE_DENY_MODE`` turns the hook's terminal ``ask`` into
6
+ a ``deny`` so the sandbox can be contained.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import subprocess
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ SCRIPT_PATH = Path(__file__).parent / "destructive_command_blocker.py"
18
+ DENY_MODE_ENV_VAR = "CLAUDE_DESTRUCTIVE_DENY_MODE"
19
+
20
+
21
+ def _run_hook_with_environment(command: str, extra_environment: dict[str, str]) -> dict:
22
+ child_environment = os.environ.copy()
23
+ child_environment.pop(DENY_MODE_ENV_VAR, None)
24
+ child_environment.update(extra_environment)
25
+ completed_process = subprocess.run(
26
+ [sys.executable, str(SCRIPT_PATH)],
27
+ input=json.dumps({"tool_name": "Bash", "tool_input": {"command": command}}),
28
+ text=True,
29
+ capture_output=True,
30
+ check=False,
31
+ env=child_environment,
32
+ )
33
+ return json.loads(completed_process.stdout)
34
+
35
+
36
+ def test_rm_rf_denies_when_deny_mode_env_is_set() -> None:
37
+ response = _run_hook_with_environment("rm -rf /var/log/myapp", {DENY_MODE_ENV_VAR: "1"})
38
+ assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
39
+ assert "rm -rf" in response["hookSpecificOutput"]["permissionDecisionReason"]
40
+
41
+
42
+ def test_rm_rf_asks_when_deny_mode_env_is_absent() -> None:
43
+ response = _run_hook_with_environment("rm -rf /var/log/myapp", {})
44
+ assert response["hookSpecificOutput"]["permissionDecision"] == "ask"
45
+ assert "rm -rf" in response["hookSpecificOutput"]["permissionDecisionReason"]