claude-dev-env 2.2.1 → 2.4.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 (170) hide show
  1. package/CLAUDE.md +3 -2
  2. package/_shared/advisor/advisor-protocol.md +2 -2
  3. package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  4. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -284
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
  6. package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +3 -3
  7. package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -0
  8. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +5 -0
  9. package/_shared/pr-loop/scripts/stale_worktree_rule_sweep.py +107 -0
  10. package/_shared/pr-loop/scripts/tests/CLAUDE.md +2 -0
  11. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +55 -73
  12. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +9 -15
  13. package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +91 -1
  14. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +88 -1
  15. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -0
  16. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -0
  17. package/_shared/pr-loop/worker-spawn.md +1 -1
  18. package/agents/CLAUDE.md +1 -0
  19. package/agents/code-verifier.md +4 -4
  20. package/agents/skill-writer-agent.md +84 -0
  21. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +141 -41
  22. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +29 -13
  23. package/docs/CLAUDE.md +1 -0
  24. package/docs/references/CLAUDE.md +1 -0
  25. package/docs/references/code-review-enforcement.md +97 -0
  26. package/docs/wsl-docker-cowork-starter-matrix.md +89 -0
  27. package/hooks/blocking/CLAUDE.md +8 -1
  28. package/hooks/blocking/code_review_enforcement_config_bootstrap.py +53 -0
  29. package/hooks/blocking/code_review_gate_deny.py +74 -0
  30. package/hooks/blocking/code_review_pr_create_gate.py +194 -0
  31. package/hooks/blocking/code_review_push_gate.py +140 -0
  32. package/hooks/blocking/code_review_stamp_directory_write_blocker.py +340 -0
  33. package/hooks/blocking/code_review_stamp_store.py +233 -0
  34. package/hooks/blocking/code_review_stamp_write_blocker_parts/__init__.py +7 -0
  35. package/hooks/blocking/code_review_stamp_write_blocker_parts/conftest.py +15 -0
  36. package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py +212 -0
  37. package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py +138 -0
  38. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py +49 -0
  39. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_split_directory_change_into_stamp.py +38 -0
  40. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +39 -27
  41. package/hooks/blocking/config/code_review_enforcement_constants.py +110 -0
  42. package/hooks/blocking/config/test_code_review_enforcement_constants.py +108 -0
  43. package/hooks/blocking/config/verified_commit_constants.py +24 -9
  44. package/hooks/blocking/conftest.py +2 -0
  45. package/hooks/blocking/convergence_gate_blocker.py +112 -23
  46. package/hooks/blocking/destructive_command_blocker.py +19 -6
  47. package/hooks/blocking/pr_description_proof_of_work.py +52 -34
  48. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
  49. package/hooks/blocking/test_code_review_enforcement_config_bootstrap.py +62 -0
  50. package/hooks/blocking/test_code_review_gate_deny.py +54 -0
  51. package/hooks/blocking/test_code_review_pr_create_gate.py +185 -0
  52. package/hooks/blocking/test_code_review_push_gate.py +189 -0
  53. package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +180 -0
  54. package/hooks/blocking/test_code_review_stamp_store.py +205 -0
  55. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +124 -2
  56. package/hooks/blocking/test_code_verifier_tools_contract.py +28 -0
  57. package/hooks/blocking/test_convergence_gate_blocker.py +153 -5
  58. package/hooks/blocking/test_destructive_command_blocker.py +1 -1
  59. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +45 -0
  60. package/hooks/blocking/test_pr_description_proof_of_work.py +151 -0
  61. package/hooks/blocking/test_pre_tool_use_dispatcher.py +8 -8
  62. package/hooks/blocking/test_verification_verdict_store.py +920 -810
  63. package/hooks/blocking/test_volatile_path_in_post_blocker.py +114 -2
  64. package/hooks/blocking/verification_verdict_store.py +118 -5
  65. package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +29 -17
  66. package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +35 -0
  67. package/hooks/blocking/volatile_path_in_post_blocker.py +69 -8
  68. package/hooks/git-hooks/git_hooks_constants/__init__.py +6 -0
  69. package/hooks/git-hooks/pre_push.py +89 -2
  70. package/hooks/git-hooks/test_pre_push.py +103 -0
  71. package/hooks/hooks.json +16 -1
  72. package/hooks/hooks_constants/CLAUDE.md +1 -0
  73. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +8 -0
  74. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  75. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +26 -11
  76. package/hooks/hooks_constants/convergence_gate_blocker_constants.py +20 -3
  77. package/hooks/hooks_constants/destructive_command_segment_constants.py +3 -1
  78. package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +0 -4
  79. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  80. package/hooks/hooks_constants/pyproject_config_discovery_constants.py +16 -0
  81. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +24 -0
  82. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +6 -0
  83. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +8 -1
  84. package/hooks/validators/CLAUDE.md +1 -0
  85. package/hooks/validators/mypy_integration.py +63 -50
  86. package/hooks/validators/pyproject_config_discovery.py +101 -0
  87. package/hooks/validators/ruff_integration.py +211 -23
  88. package/hooks/validators/run_all_validators.py +21 -14
  89. package/hooks/validators/test_mypy_integration.py +32 -0
  90. package/hooks/validators/test_pyproject_config_discovery.py +94 -0
  91. package/hooks/validators/test_ruff_integration.py +68 -1
  92. package/hooks/validators/test_run_all_validators.py +25 -0
  93. package/hooks/validators/test_run_all_validators_config_discovery.py +123 -0
  94. package/package.json +1 -1
  95. package/rules/docstring-prose-matches-implementation.md +20 -43
  96. package/rules/durable-post-artifacts.md +7 -0
  97. package/scripts/CLAUDE.md +2 -1
  98. package/scripts/claude-chain.example.json +15 -3
  99. package/scripts/claude_chain_runner.py +130 -27
  100. package/scripts/claude_chain_usage.py +346 -0
  101. package/scripts/codec_forwarding_test_support.py +83 -0
  102. package/scripts/conftest.py +8 -0
  103. package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -3
  104. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +57 -2
  105. package/scripts/dev_env_scripts_constants/claude_chain_usage_constants.py +58 -0
  106. package/scripts/dev_env_scripts_constants/code_review_constants.py +129 -12
  107. package/scripts/dev_env_scripts_constants/test_code_review_constants.py +55 -0
  108. package/scripts/dev_env_scripts_constants/timing.py +1 -1
  109. package/scripts/invoke_code_review.py +550 -38
  110. package/scripts/resolve_worker_spawn.py +8 -1
  111. package/scripts/test_claude_chain_runner.py +412 -6
  112. package/scripts/test_claude_chain_usage.py +534 -0
  113. package/scripts/test_invoke_code_review.py +298 -4
  114. package/scripts/test_invoke_code_review_codec.py +77 -0
  115. package/scripts/test_resolve_worker_spawn_codec.py +101 -0
  116. package/skills/autoconverge/SKILL.md +9 -3
  117. package/skills/autoconverge/reference/convergence.md +2 -1
  118. package/skills/autoconverge/reference/multi-pr.md +6 -1
  119. package/skills/autoconverge/reference/stop-conditions.md +16 -10
  120. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +1 -1
  121. package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +175 -3
  122. package/skills/autoconverge/workflow/converge.contract.test.mjs +19 -0
  123. package/skills/autoconverge/workflow/converge.mjs +24 -8
  124. package/skills/autoconverge/workflow/converge_multi.mjs +7 -3
  125. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +5 -0
  126. package/skills/fresh-branch/CLAUDE.md +2 -0
  127. package/skills/fresh-branch/SKILL.md +2 -0
  128. package/skills/fresh-branch/scripts/create_fresh_branch.py +78 -180
  129. package/skills/fresh-branch/scripts/fresh_branch_git_commands.py +285 -0
  130. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -0
  131. package/skills/fresh-branch/scripts/pytest.ini +4 -0
  132. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +98 -0
  133. package/skills/fresh-branch/scripts/test_fresh_branch_git_commands.py +310 -0
  134. package/skills/orchestrator/SKILL.md +1 -20
  135. package/skills/orchestrator-refresh/SKILL.md +1 -1
  136. package/skills/pr-converge/SKILL.md +3 -3
  137. package/skills/pr-converge/reference/examples.md +3 -3
  138. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  139. package/skills/pr-converge/reference/ground-rules.md +3 -3
  140. package/skills/pr-converge/reference/per-tick.md +5 -5
  141. package/skills/pr-converge/reference/progress-checklist.md +1 -1
  142. package/skills/pr-converge/test_step5_host_branch.py +1 -1
  143. package/skills/prototype/SKILL.md +86 -0
  144. package/skills/prototype/reference/honest-limitations.md +23 -0
  145. package/skills/prototype/reference/promotion-tasks.md +23 -0
  146. package/skills/prototype/scripts/build_sandbox_settings.py +249 -0
  147. package/skills/prototype/scripts/conftest.py +15 -0
  148. package/skills/prototype/scripts/launch_sandbox.py +205 -0
  149. package/skills/prototype/scripts/probe_sandbox_safety.py +311 -0
  150. package/skills/prototype/scripts/prototype_scripts_constants/__init__.py +1 -0
  151. package/skills/prototype/scripts/prototype_scripts_constants/config/__init__.py +0 -0
  152. package/skills/prototype/scripts/prototype_scripts_constants/config/build_sandbox_settings_constants.py +41 -0
  153. package/skills/prototype/scripts/prototype_scripts_constants/config/launch_sandbox_constants.py +23 -0
  154. package/skills/prototype/scripts/prototype_scripts_constants/config/probe_sandbox_safety_constants.py +45 -0
  155. package/skills/prototype/scripts/prototype_scripts_constants/config/prototype_common_constants.py +10 -0
  156. package/skills/prototype/scripts/test_build_sandbox_settings.py +275 -0
  157. package/skills/prototype/scripts/test_launch_sandbox.py +303 -0
  158. package/skills/prototype/scripts/test_probe_sandbox_safety.py +284 -0
  159. package/skills/prototype/workflows/promotion.md +27 -0
  160. package/skills/prototype/workflows/sandbox.md +35 -0
  161. package/skills/skill-builder/CLAUDE.md +3 -3
  162. package/skills/skill-builder/SKILL.md +5 -5
  163. package/skills/skill-builder/references/CLAUDE.md +1 -1
  164. package/skills/skill-builder/references/delegation-map.md +3 -3
  165. package/skills/skill-builder/references/description-field.md +1 -1
  166. package/skills/skill-builder/references/skill-modularity.md +2 -3
  167. package/skills/skill-builder/workflows/CLAUDE.md +1 -1
  168. package/skills/skill-builder/workflows/improve-skill.md +1 -1
  169. package/skills/skill-builder/workflows/new-skill.md +2 -2
  170. package/skills/team-advisor/SKILL.md +5 -4
@@ -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
@@ -0,0 +1,28 @@
1
+ """Contract: code-verifier cannot message a warm session-advisor."""
2
+
3
+ from pathlib import Path
4
+
5
+ _PACKAGE_ROOT = Path(__file__).resolve().parents[2]
6
+ _CODE_VERIFIER_AGENT_PATH = _PACKAGE_ROOT / "agents" / "code-verifier.md"
7
+ _FRONTMATTER_OPEN = "---\n"
8
+ _FRONTMATTER_CLOSE = "\n---\n"
9
+ _TOOLS_PREFIX = "tools:"
10
+ _SEND_MESSAGE_TOOL_NAME = "SendMessage"
11
+
12
+
13
+ def test_code_verifier_tools_exclude_sendmessage() -> None:
14
+ """tools frontmatter must not list SendMessage (warm-advisor path closed)."""
15
+ agent_text = _CODE_VERIFIER_AGENT_PATH.read_text(encoding="utf-8")
16
+ assert agent_text.startswith(_FRONTMATTER_OPEN), "missing frontmatter open"
17
+ frontmatter_end = agent_text.find(_FRONTMATTER_CLOSE, len(_FRONTMATTER_OPEN))
18
+ assert frontmatter_end > 0, "missing frontmatter close"
19
+ frontmatter_body = agent_text[len(_FRONTMATTER_OPEN) : frontmatter_end]
20
+ all_tools_lines = [
21
+ each_line
22
+ for each_line in frontmatter_body.splitlines()
23
+ if each_line.startswith(_TOOLS_PREFIX)
24
+ ]
25
+ assert len(all_tools_lines) == 1, all_tools_lines
26
+ tools_value = all_tools_lines[0].removeprefix(_TOOLS_PREFIX).strip()
27
+ all_tool_names = {each_name.strip() for each_name in tools_value.split(",")}
28
+ assert _SEND_MESSAGE_TOOL_NAME not in all_tool_names, all_tool_names
@@ -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"]
@@ -17,6 +17,7 @@ _HOOKS_ROOT = _HOOK_DIR.parent
17
17
  if str(_HOOKS_ROOT) not in sys.path:
18
18
  sys.path.insert(0, str(_HOOKS_ROOT))
19
19
 
20
+ from blocking import convergence_gate_blocker as convergence_module # noqa: E402
20
21
  from blocking import pr_description_proof_of_work as proof_module # noqa: E402
21
22
  from blocking.pr_description_proof_of_work import ( # noqa: E402
22
23
  audit_proof_comment_body,
@@ -70,6 +71,34 @@ def _install_fake_gh(monkeypatch, all_comment_bodies, all_diff_names, diff_text)
70
71
  monkeypatch.setattr(proof_module, "_run_gh_command", _fake_run_gh_command)
71
72
 
72
73
 
74
+ def _install_recording_fake_gh(monkeypatch, all_comment_bodies, all_diff_names, diff_text):
75
+ all_comment_records = [{"body": each_body} for each_body in all_comment_bodies]
76
+ all_recorded_argument_lists: list[list[str]] = []
77
+
78
+ def _fake_run_gh_command(all_gh_arguments):
79
+ all_recorded_argument_lists.append(list(all_gh_arguments))
80
+ if all_gh_arguments and all_gh_arguments[0] == "api":
81
+ return json.dumps([all_comment_records])
82
+ if list(all_gh_arguments[:2]) == ["pr", "view"]:
83
+ return json.dumps({"number": 77})
84
+ if "--name-only" in all_gh_arguments:
85
+ return "\n".join(all_diff_names)
86
+ if list(all_gh_arguments[:2]) == ["pr", "diff"]:
87
+ return diff_text
88
+ return None
89
+
90
+ monkeypatch.setattr(proof_module, "_run_gh_command", _fake_run_gh_command)
91
+ return all_recorded_argument_lists
92
+
93
+
94
+ def _argument_lists_with_leading(all_recorded_argument_lists, first_token, second_token):
95
+ return [
96
+ each_arguments
97
+ for each_arguments in all_recorded_argument_lists
98
+ if list(each_arguments[:2]) == [first_token, second_token]
99
+ ]
100
+
101
+
73
102
  def test_is_proof_shaped_body_detects_verification_heading() -> None:
74
103
  assert is_proof_shaped_body(PASSING_PROOF_COMMENT_BODY)
75
104
 
@@ -159,4 +188,126 @@ def test_ready_gate_resolves_pr_number_from_current_branch(
159
188
  monkeypatch: pytest.MonkeyPatch,
160
189
  ) -> None:
161
190
  _install_fake_gh(monkeypatch, [PASSING_PROOF_COMMENT_BODY], ["src/parser.py"], "")
191
+ monkeypatch.setattr(
192
+ convergence_module,
193
+ "_resolve_current_branch_pr_number",
194
+ lambda all_target_repo, cwd: 77,
195
+ )
162
196
  assert evaluate_pr_ready_gate("gh pr ready") is None
197
+
198
+
199
+ def test_ready_gate_reads_comments_from_repo_named_by_flag(
200
+ monkeypatch: pytest.MonkeyPatch,
201
+ ) -> None:
202
+ all_recorded = _install_recording_fake_gh(
203
+ monkeypatch, [PASSING_PROOF_COMMENT_BODY], ["src/parser.py"], ""
204
+ )
205
+ evaluate_pr_ready_gate("gh pr ready 42 --repo other-owner/other-repo")
206
+ all_api_calls = _argument_lists_with_leading(
207
+ all_recorded, "api", "repos/other-owner/other-repo/issues/42/comments"
208
+ )
209
+ assert all_api_calls
210
+ for each_arguments in all_api_calls:
211
+ assert "{owner}" not in each_arguments[1]
212
+ assert "{repo}" not in each_arguments[1]
213
+
214
+
215
+ def test_ready_gate_resolves_pr_number_from_repo_named_by_flag(
216
+ monkeypatch: pytest.MonkeyPatch,
217
+ ) -> None:
218
+ _install_fake_gh(monkeypatch, [PASSING_PROOF_COMMENT_BODY], ["src/parser.py"], "")
219
+ all_captured_repo_pairs: list[tuple[str, str] | None] = []
220
+
221
+ def fake_resolver(all_target_repo: tuple[str, str] | None, cwd: str | None) -> int:
222
+ all_captured_repo_pairs.append(all_target_repo)
223
+ return 77
224
+
225
+ monkeypatch.setattr(convergence_module, "_resolve_current_branch_pr_number", fake_resolver)
226
+ evaluate_pr_ready_gate("gh pr ready --repo other-owner/other-repo")
227
+ assert all_captured_repo_pairs == [("other-owner", "other-repo")]
228
+
229
+
230
+ def test_ready_gate_reads_comments_from_repo_named_by_url(
231
+ monkeypatch: pytest.MonkeyPatch,
232
+ ) -> None:
233
+ all_recorded = _install_recording_fake_gh(
234
+ monkeypatch, [PASSING_PROOF_COMMENT_BODY], ["src/parser.py"], ""
235
+ )
236
+ evaluate_pr_ready_gate("gh pr ready https://github.com/url-owner/url-repo/pull/99")
237
+ all_api_calls = _argument_lists_with_leading(
238
+ all_recorded, "api", "repos/url-owner/url-repo/issues/99/comments"
239
+ )
240
+ assert all_api_calls
241
+ all_view_calls = _argument_lists_with_leading(all_recorded, "pr", "view")
242
+ assert not all_view_calls
243
+
244
+
245
+ def test_ready_gate_leaves_bare_command_bound_to_current_directory(
246
+ monkeypatch: pytest.MonkeyPatch,
247
+ ) -> None:
248
+ all_recorded = _install_recording_fake_gh(
249
+ monkeypatch, [PASSING_PROOF_COMMENT_BODY], ["src/parser.py"], ""
250
+ )
251
+ evaluate_pr_ready_gate("gh pr ready 123")
252
+ all_api_calls = _argument_lists_with_leading(
253
+ all_recorded, "api", "repos/{owner}/{repo}/issues/123/comments"
254
+ )
255
+ assert all_api_calls
256
+ for each_arguments in all_recorded:
257
+ assert "--repo" not in each_arguments
258
+
259
+
260
+ def test_ready_gate_ignores_repo_flag_of_chained_command(
261
+ monkeypatch: pytest.MonkeyPatch,
262
+ ) -> None:
263
+ all_recorded = _install_recording_fake_gh(
264
+ monkeypatch, [PASSING_PROOF_COMMENT_BODY], ["src/parser.py"], ""
265
+ )
266
+ evaluate_pr_ready_gate(
267
+ "gh pr ready 161 && gh pr comment 999 --repo other-owner/other-repo"
268
+ )
269
+ all_api_calls = _argument_lists_with_leading(
270
+ all_recorded, "api", "repos/{owner}/{repo}/issues/161/comments"
271
+ )
272
+ assert all_api_calls
273
+ for each_arguments in all_recorded:
274
+ assert "other-owner/other-repo" not in each_arguments
275
+
276
+
277
+ def test_ready_gate_reads_comments_from_repo_named_by_full_url_flag(
278
+ monkeypatch: pytest.MonkeyPatch,
279
+ ) -> None:
280
+ all_recorded = _install_recording_fake_gh(
281
+ monkeypatch, [PASSING_PROOF_COMMENT_BODY], ["src/parser.py"], ""
282
+ )
283
+ evaluate_pr_ready_gate("gh pr ready 42 --repo https://github.com/other-owner/other-repo")
284
+ all_api_calls = _argument_lists_with_leading(
285
+ all_recorded, "api", "repos/other-owner/other-repo/issues/42/comments"
286
+ )
287
+ assert all_api_calls
288
+
289
+
290
+ def test_ready_gate_reads_comments_from_repo_named_by_short_url_flag(
291
+ monkeypatch: pytest.MonkeyPatch,
292
+ ) -> None:
293
+ all_recorded = _install_recording_fake_gh(
294
+ monkeypatch, [PASSING_PROOF_COMMENT_BODY], ["src/parser.py"], ""
295
+ )
296
+ evaluate_pr_ready_gate("gh pr ready 42 -R https://github.com/other-owner/other-repo")
297
+ all_api_calls = _argument_lists_with_leading(
298
+ all_recorded, "api", "repos/other-owner/other-repo/issues/42/comments"
299
+ )
300
+ assert all_api_calls
301
+
302
+
303
+ def test_ready_gate_reads_comments_from_repo_named_by_ssh_flag(
304
+ monkeypatch: pytest.MonkeyPatch,
305
+ ) -> None:
306
+ all_recorded = _install_recording_fake_gh(
307
+ monkeypatch, [PASSING_PROOF_COMMENT_BODY], ["src/parser.py"], ""
308
+ )
309
+ evaluate_pr_ready_gate("gh pr ready 42 --repo git@github.com:other-owner/other-repo")
310
+ all_api_calls = _argument_lists_with_leading(
311
+ all_recorded, "api", "repos/other-owner/other-repo/issues/42/comments"
312
+ )
313
+ assert all_api_calls
@@ -633,8 +633,8 @@ def test_dispatcher_write_applies_both_groups() -> None:
633
633
  assert "blocking/plain_language_blocker.py" in all_write_script_paths, (
634
634
  "plain_language_blocker (Group B) must be in Write applicable set"
635
635
  )
636
- assert len(all_write_entries) == 20, (
637
- f"Write tool must apply to all 20 hosted hooks, got {len(all_write_entries)}"
636
+ assert len(all_write_entries) == 21, (
637
+ f"Write tool must apply to all 21 hosted hooks, got {len(all_write_entries)}"
638
638
  )
639
639
 
640
640
 
@@ -647,16 +647,16 @@ def test_dispatcher_edit_applies_both_groups() -> None:
647
647
  assert "blocking/stale_comment_reference_blocker.py" in all_edit_script_paths, (
648
648
  "stale_comment_reference_blocker belongs in the Edit applicable set"
649
649
  )
650
- assert len(all_edit_entries) == 21, (
651
- f"expected 21 Edit entries, got {len(all_edit_entries)}"
650
+ assert len(all_edit_entries) == 22, (
651
+ f"expected 22 Edit entries, got {len(all_edit_entries)}"
652
652
  )
653
653
 
654
654
 
655
655
  def test_dispatcher_multi_edit_applies_only_group_b() -> None:
656
- """MultiEdit tool triggers only Group B (9 hooks), not Group A."""
656
+ """MultiEdit tool triggers only Group B (10 hooks), not Group A."""
657
657
  all_multi_edit_entries = _applicable_entries_for_tool(MULTI_EDIT_TOOL_NAME)
658
- assert len(all_multi_edit_entries) == 9, (
659
- f"MultiEdit tool must apply to exactly 9 Group-B hooks, got {len(all_multi_edit_entries)}"
658
+ assert len(all_multi_edit_entries) == 10, (
659
+ f"MultiEdit tool must apply to exactly 10 Group-B hooks, got {len(all_multi_edit_entries)}"
660
660
  )
661
661
 
662
662
 
@@ -667,7 +667,7 @@ def test_proceed_after_run_all_validators_removal_allows() -> None:
667
667
  it was never a PreToolUse hook and never hosted by the PreToolUse dispatcher.
668
668
  A Python Write payload that run_all_validators would have flagged (mypy errors, for
669
669
  instance) still produces ALLOW from the PreToolUse dispatcher because the PreToolUse
670
- dispatcher covers only its 21 hosted blocking hooks — none of which includes the
670
+ dispatcher covers only its 22 hosted blocking hooks — none of which includes the
671
671
  validators runner.
672
672
  """
673
673
  python_content_with_type_error = (