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
@@ -0,0 +1,53 @@
1
+ """Deterministic loader for the code-review enforcement constants module.
2
+
3
+ The stamp store and every code-review gate import their shared constants as
4
+ ``from config.code_review_enforcement_constants import ...``. In the installed
5
+ hook tree a second, unrelated ``config`` package can sit ahead of this package
6
+ on ``sys.path`` and win that dotted name by path order, so the import binds to
7
+ the wrong file and raises ImportError on any constant the stale copy lacks.
8
+ This module binds the dotted name to its sibling ``config/`` file by explicit
9
+ location, so resolution never depends on ``sys.path`` order.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import importlib.util
15
+ import sys
16
+ from pathlib import Path
17
+
18
+
19
+ def register_code_review_enforcement_constants() -> None:
20
+ """Bind the code-review enforcement constants dotted name to its config file.
21
+
22
+ ::
23
+
24
+ sys.path = ["<installed hooks>", "<blocking>"] # foreign config first
25
+ register_code_review_enforcement_constants()
26
+ from config.code_review_enforcement_constants import STAMP_DIRECTORY_NAME
27
+ ok: bound to <blocking>/config/code_review_enforcement_constants.py
28
+
29
+ A ``from config.code_review_enforcement_constants import`` that follows this
30
+ call reads the entry straight from ``sys.modules``, so it resolves to this
31
+ file whatever else owns the ``config`` name. An entry already cached is left
32
+ in place, so the call is idempotent and never displaces a module a caller
33
+ loaded on purpose.
34
+
35
+ Returns:
36
+ None. The effect is the ``sys.modules`` registration.
37
+ """
38
+ _bind_config_module_by_location(
39
+ "config.code_review_enforcement_constants",
40
+ Path(__file__).resolve().parent / "config" / "code_review_enforcement_constants.py",
41
+ )
42
+
43
+
44
+ def _bind_config_module_by_location(dotted_name: str, constants_file_path: Path) -> None:
45
+ """Register a dotted config name against an explicit file path in sys.modules."""
46
+ if dotted_name in sys.modules:
47
+ return
48
+ module_spec = importlib.util.spec_from_file_location(dotted_name, constants_file_path)
49
+ if module_spec is None or module_spec.loader is None:
50
+ return
51
+ constants_module = importlib.util.module_from_spec(module_spec)
52
+ sys.modules[dotted_name] = constants_module
53
+ module_spec.loader.exec_module(constants_module)
@@ -0,0 +1,74 @@
1
+ """Shared deny scaffold for the code-review PreToolUse gates.
2
+
3
+ The push gate and the PR-create gate deny a blocked tool call the same way: a
4
+ ``hookSpecificOutput`` payload carrying the deny decision and a corrective
5
+ reason, logged through ``log_hook_block`` before it reaches stdout. This module
6
+ holds that one payload builder and the log-and-emit helper, so the two gates
7
+ share a single deny shape and stay in step.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ try:
17
+ _blocking_directory = str(Path(__file__).resolve().parent)
18
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
19
+ for each_bootstrap_directory in (_blocking_directory, _hooks_directory):
20
+ if each_bootstrap_directory not in sys.path:
21
+ sys.path.insert(0, each_bootstrap_directory)
22
+
23
+ from code_review_enforcement_config_bootstrap import (
24
+ register_code_review_enforcement_constants,
25
+ )
26
+
27
+ register_code_review_enforcement_constants()
28
+
29
+ from config.code_review_enforcement_constants import (
30
+ DENY_PERMISSION_DECISION,
31
+ PRE_TOOL_USE_HOOK_EVENT_NAME,
32
+ )
33
+
34
+ from hooks_constants.hook_block_logger import log_hook_block
35
+ except ImportError as import_error:
36
+ raise ImportError(
37
+ "the code_review_gate_deny dependencies did not import; "
38
+ "ensure the hooks directory is importable."
39
+ ) from import_error
40
+
41
+
42
+ def build_code_review_deny_payload(deny_reason: str) -> dict[str, dict[str, str]]:
43
+ """Build the PreToolUse deny payload for a blocked code-review gate action.
44
+
45
+ Args:
46
+ deny_reason: The corrective message naming why the action is denied.
47
+
48
+ Returns:
49
+ The ``hookSpecificOutput`` deny payload.
50
+ """
51
+ return {
52
+ "hookSpecificOutput": {
53
+ "hookEventName": PRE_TOOL_USE_HOOK_EVENT_NAME,
54
+ "permissionDecision": DENY_PERMISSION_DECISION,
55
+ "permissionDecisionReason": deny_reason,
56
+ }
57
+ }
58
+
59
+
60
+ def log_and_emit_code_review_deny(deny_reason: str, tool_name: str, hook_module_name: str) -> None:
61
+ """Log a code-review gate block and write its deny payload to stdout.
62
+
63
+ Args:
64
+ deny_reason: The corrective message naming why the action is denied.
65
+ tool_name: The name of the gated tool, recorded in the block log.
66
+ hook_module_name: The calling gate's module name, recorded in the log.
67
+ """
68
+ log_hook_block(
69
+ calling_hook_name=hook_module_name,
70
+ hook_event=PRE_TOOL_USE_HOOK_EVENT_NAME,
71
+ block_reason=deny_reason,
72
+ tool_name=tool_name if isinstance(tool_name, str) else None,
73
+ )
74
+ sys.stdout.write(json.dumps(build_code_review_deny_payload(deny_reason)) + "\n")
@@ -0,0 +1,194 @@
1
+ """PreToolUse gate: PR create lands only behind a clean xhigh code-review stamp.
2
+
3
+ Fires on Bash/PowerShell ``gh pr create`` and on the MCP
4
+ ``create_pull_request`` tool. Resolves the repository from the session
5
+ working directory (or the payload cwd), computes the live change-surface
6
+ hash, and allows the create only when a clean stamp at effort ``xhigh`` or
7
+ higher covers that exact hash under ``~/.claude/code-review-stamps/``.
8
+
9
+ Empty surfaces and repos with no resolvable upstream base are out of scope
10
+ and allowed. Push lands behind ``code_review_push_gate`` and commit behind
11
+ ``verified_commit_gate``; this gate covers pull-request creation only.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import re
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ try:
22
+ _blocking_directory = str(Path(__file__).resolve().parent)
23
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
24
+ for each_bootstrap_directory in (_blocking_directory, _hooks_directory):
25
+ if each_bootstrap_directory not in sys.path:
26
+ sys.path.insert(0, each_bootstrap_directory)
27
+
28
+ from code_review_enforcement_config_bootstrap import (
29
+ register_code_review_enforcement_constants,
30
+ )
31
+ from verified_commit_config_bootstrap import register_verified_commit_constants
32
+
33
+ register_code_review_enforcement_constants()
34
+ register_verified_commit_constants()
35
+
36
+ from code_review_gate_deny import (
37
+ build_code_review_deny_payload,
38
+ log_and_emit_code_review_deny,
39
+ )
40
+ from code_review_stamp_store import live_surface_hash, stamp_covers_surface
41
+ from config.code_review_enforcement_constants import (
42
+ ALL_GATED_SHELL_TOOL_NAMES,
43
+ GH_PR_CREATE_INVOCATION_PATTERN,
44
+ HASH_PREVIEW_LENGTH,
45
+ MCP_CREATE_PULL_REQUEST_TOOL_NAME,
46
+ PR_CREATE_GATE_CORRECTIVE_MESSAGE,
47
+ PR_CREATE_GATE_HOOK_MODULE_NAME,
48
+ PR_CREATE_REQUIRED_EFFORT,
49
+ )
50
+ from verification_verdict_store import resolve_repo_root
51
+ except ImportError as import_error:
52
+ raise ImportError(
53
+ "the code_review_pr_create_gate dependencies did not import; "
54
+ "ensure the hooks directory is importable."
55
+ ) from import_error
56
+
57
+
58
+ def _strip_quoted_regions(command_text: str) -> str:
59
+ """Blank out single- and double-quoted spans so prose is not gated."""
60
+ quote_pattern = re.compile(r"\"[^\"]*\"|'[^']*'")
61
+ return quote_pattern.sub(lambda each_match: " " * len(each_match.group(0)), command_text)
62
+
63
+
64
+ def command_invokes_gh_pr_create(command_text: str) -> bool:
65
+ """Decide whether a shell command invokes ``gh pr create``.
66
+
67
+ ::
68
+
69
+ gh pr create --title T --body-file b.md -> True
70
+ echo "gh pr create" -> False (quoted prose)
71
+ gh pr edit 1 --title T -> False
72
+
73
+ Args:
74
+ command_text: The raw command string from the tool payload.
75
+
76
+ Returns:
77
+ True when the command contains a real ``gh pr create`` invocation.
78
+ """
79
+ quote_stripped_command = _strip_quoted_regions(command_text)
80
+ invocation_pattern = re.compile(GH_PR_CREATE_INVOCATION_PATTERN, re.IGNORECASE)
81
+ return invocation_pattern.search(quote_stripped_command) is not None
82
+
83
+
84
+ def is_mcp_create_pull_request_tool(tool_name: str) -> bool:
85
+ """Decide whether a tool name is the MCP create-pull-request tool.
86
+
87
+ Args:
88
+ tool_name: The PreToolUse ``tool_name`` field.
89
+
90
+ Returns:
91
+ True when the name is the configured MCP create-PR tool.
92
+ """
93
+ return tool_name == MCP_CREATE_PULL_REQUEST_TOOL_NAME
94
+
95
+
96
+ def deny_reason_for_directory(target_directory: str) -> str | None:
97
+ """Decide whether a PR create from a directory must be blocked.
98
+
99
+ ::
100
+
101
+ no repo / empty surface / covering xhigh stamp -> None (allow)
102
+ production surface without xhigh-or-higher stamp -> corrective (deny)
103
+
104
+ Args:
105
+ target_directory: The session or repo directory the create targets.
106
+
107
+ Returns:
108
+ The deny reason when the surface needs a clean xhigh stamp and none
109
+ covers it; None when the create may proceed.
110
+ """
111
+ repo_root = resolve_repo_root(target_directory)
112
+ if repo_root is None:
113
+ return None
114
+ surface_hash = live_surface_hash(repo_root)
115
+ if surface_hash is None:
116
+ return None
117
+ if stamp_covers_surface(repo_root, surface_hash, PR_CREATE_REQUIRED_EFFORT):
118
+ return None
119
+ hash_preview = surface_hash[:HASH_PREVIEW_LENGTH]
120
+ return (
121
+ f"{PR_CREATE_GATE_CORRECTIVE_MESSAGE} (repo: {repo_root}, surface sha256 {hash_preview}...)"
122
+ )
123
+
124
+
125
+ def _deny_for_directory_or_none(
126
+ session_directory: str,
127
+ ) -> dict[str, dict[str, str]] | None:
128
+ """Return the deny payload for a directory that lacks a covering stamp."""
129
+ deny_reason = deny_reason_for_directory(session_directory)
130
+ if deny_reason is None:
131
+ return None
132
+ return build_code_review_deny_payload(deny_reason)
133
+
134
+
135
+ def _shell_command_targets_pr_create(all_pretooluse_payload: dict[str, object]) -> bool:
136
+ """Decide whether a shell payload carries a real ``gh pr create``."""
137
+ tool_input = all_pretooluse_payload.get("tool_input", {})
138
+ if not isinstance(tool_input, dict):
139
+ return False
140
+ command_text = tool_input.get("command", "")
141
+ if not isinstance(command_text, str) or not command_text:
142
+ return False
143
+ return command_invokes_gh_pr_create(command_text)
144
+
145
+
146
+ def decision_for_payload(
147
+ all_pretooluse_payload: dict[str, object],
148
+ ) -> dict[str, dict[str, str]] | None:
149
+ """Build the deny decision for a gated PR-create attempt.
150
+
151
+ Args:
152
+ all_pretooluse_payload: The PreToolUse hook payload.
153
+
154
+ Returns:
155
+ The deny decision mapping when the create lacks a covering xhigh
156
+ stamp; None when the create may proceed.
157
+ """
158
+ tool_name = all_pretooluse_payload.get("tool_name", "")
159
+ if not isinstance(tool_name, str):
160
+ return None
161
+ session_directory = all_pretooluse_payload.get("cwd", ".")
162
+ if not isinstance(session_directory, str):
163
+ session_directory = "."
164
+ if is_mcp_create_pull_request_tool(tool_name):
165
+ return _deny_for_directory_or_none(session_directory)
166
+ if tool_name not in ALL_GATED_SHELL_TOOL_NAMES:
167
+ return None
168
+ if not _shell_command_targets_pr_create(all_pretooluse_payload):
169
+ return None
170
+ return _deny_for_directory_or_none(session_directory)
171
+
172
+
173
+ def main() -> None:
174
+ """Read the PreToolUse payload and decide whether to allow the PR create."""
175
+ try:
176
+ pretooluse_payload = json.load(sys.stdin)
177
+ except json.JSONDecodeError:
178
+ return
179
+ deny_decision = decision_for_payload(pretooluse_payload)
180
+ if deny_decision is None:
181
+ return
182
+ tool_name = pretooluse_payload.get("tool_name", "")
183
+ deny_reason = deny_decision["hookSpecificOutput"]["permissionDecisionReason"]
184
+ if not isinstance(deny_reason, str):
185
+ return
186
+ log_and_emit_code_review_deny(
187
+ deny_reason,
188
+ tool_name if isinstance(tool_name, str) else "",
189
+ PR_CREATE_GATE_HOOK_MODULE_NAME,
190
+ )
191
+
192
+
193
+ if __name__ == "__main__":
194
+ main()
@@ -0,0 +1,140 @@
1
+ """PreToolUse gate: git push lands only behind a clean low code-review stamp.
2
+
3
+ Fires on Bash/PowerShell tool calls. When the command carries a ``git push``,
4
+ the gate resolves each repository the push targets, computes the live
5
+ change-surface hash against the merge base, and allows the push only when a
6
+ clean stamp at effort ``low`` or higher covers that exact hash under
7
+ ``~/.claude/code-review-stamps/``.
8
+
9
+ Allowed without a stamp:
10
+
11
+ - a command carrying the bypass marker (``# code-review-skip``) as a trailing
12
+ shell comment,
13
+ - a repository with no resolvable upstream base,
14
+ - a mechanically exempt surface (docs and image files, pytest test files, and
15
+ Python files whose docstring-stripped AST is unchanged), and
16
+ - a surface a clean ``low``-or-higher stamp already covers.
17
+
18
+ Commit lands behind ``verified_commit_gate``; only push is gated here.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ try:
28
+ _blocking_directory = str(Path(__file__).resolve().parent)
29
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
30
+ for each_bootstrap_directory in (_blocking_directory, _hooks_directory):
31
+ if each_bootstrap_directory not in sys.path:
32
+ sys.path.insert(0, each_bootstrap_directory)
33
+
34
+ from code_review_enforcement_config_bootstrap import (
35
+ register_code_review_enforcement_constants,
36
+ )
37
+ from verified_commit_config_bootstrap import register_verified_commit_constants
38
+
39
+ register_code_review_enforcement_constants()
40
+ register_verified_commit_constants()
41
+
42
+ from code_review_gate_deny import log_and_emit_code_review_deny
43
+ from code_review_stamp_store import live_surface_hash, stamp_covers_surface
44
+ from config.code_review_enforcement_constants import (
45
+ ALL_GATED_SHELL_TOOL_NAMES,
46
+ CODE_REVIEW_BYPASS_MARKER,
47
+ GATED_PUSH_SUBCOMMANDS,
48
+ HASH_PREVIEW_LENGTH,
49
+ PUSH_GATE_CORRECTIVE_MESSAGE,
50
+ PUSH_GATE_HOOK_MODULE_NAME,
51
+ PUSH_REQUIRED_EFFORT,
52
+ )
53
+ from verification_verdict_store import (
54
+ is_verification_exempt_diff,
55
+ resolve_merge_base,
56
+ resolve_repo_root,
57
+ )
58
+ from verified_commit_gate_parts.command_tokenization import (
59
+ command_carries_trailing_marker,
60
+ )
61
+ from verified_commit_gate_parts.gated_invocations import gated_repo_directories
62
+ except ImportError as import_error:
63
+ raise ImportError(
64
+ "the code_review_push_gate dependencies did not import; "
65
+ "ensure the hooks directory is importable."
66
+ ) from import_error
67
+
68
+
69
+ def deny_reason_for_directory(target_directory: str) -> str | None:
70
+ """Decide whether a push from a directory must be blocked.
71
+
72
+ Allowed when the directory is no repo, has no upstream base, is a
73
+ mechanically exempt surface, or a clean low-or-higher stamp covers it.
74
+
75
+ Args:
76
+ target_directory: The directory the push targets.
77
+
78
+ Returns:
79
+ The deny reason when the surface lacks a covering low stamp, else None.
80
+ """
81
+ repo_root = resolve_repo_root(target_directory)
82
+ if repo_root is None:
83
+ return None
84
+ merge_base_sha = resolve_merge_base(repo_root)
85
+ if merge_base_sha is None:
86
+ return None
87
+ if is_verification_exempt_diff(repo_root, merge_base_sha):
88
+ return None
89
+ surface_hash = live_surface_hash(repo_root)
90
+ if surface_hash is None:
91
+ return None
92
+ if stamp_covers_surface(repo_root, surface_hash, PUSH_REQUIRED_EFFORT):
93
+ return None
94
+ hash_preview = surface_hash[:HASH_PREVIEW_LENGTH]
95
+ return f"{PUSH_GATE_CORRECTIVE_MESSAGE} (repo: {repo_root}, surface sha256 {hash_preview}...)"
96
+
97
+
98
+ def _emit_first_denied_directory(command_text: str, session_directory: str, tool_name: str) -> None:
99
+ """Deny the push against the first target directory that lacks a stamp."""
100
+ all_target_directories = gated_repo_directories(
101
+ command_text, session_directory, all_gated_subcommands=GATED_PUSH_SUBCOMMANDS
102
+ )
103
+ for each_target_directory in all_target_directories:
104
+ deny_reason = deny_reason_for_directory(each_target_directory)
105
+ if deny_reason is None:
106
+ continue
107
+ log_and_emit_code_review_deny(deny_reason, tool_name, PUSH_GATE_HOOK_MODULE_NAME)
108
+ return
109
+
110
+
111
+ def main() -> None:
112
+ """Read the PreToolUse payload and decide whether to allow the push.
113
+
114
+ Allows the command without a stamp when it carries the bypass marker
115
+ (``CODE_REVIEW_BYPASS_MARKER``) as a genuine trailing shell comment;
116
+ otherwise denies a push whose surface lacks a covering low stamp.
117
+ """
118
+ try:
119
+ pretooluse_payload = json.load(sys.stdin)
120
+ except json.JSONDecodeError:
121
+ return
122
+ tool_name = pretooluse_payload.get("tool_name", "")
123
+ if tool_name not in ALL_GATED_SHELL_TOOL_NAMES:
124
+ return
125
+ tool_input = pretooluse_payload.get("tool_input", {})
126
+ if not isinstance(tool_input, dict):
127
+ return
128
+ command_text = tool_input.get("command", "")
129
+ if not isinstance(command_text, str) or not command_text:
130
+ return
131
+ if command_carries_trailing_marker(command_text, CODE_REVIEW_BYPASS_MARKER):
132
+ return
133
+ session_directory = pretooluse_payload.get("cwd", ".")
134
+ if not isinstance(session_directory, str):
135
+ session_directory = "."
136
+ _emit_first_denied_directory(command_text, session_directory, tool_name)
137
+
138
+
139
+ if __name__ == "__main__":
140
+ main()