claude-dev-env 2.3.0 → 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.
- package/CLAUDE.md +3 -2
- package/agents/CLAUDE.md +1 -0
- package/agents/code-verifier.md +3 -3
- package/agents/skill-writer-agent.md +84 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +141 -41
- package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +29 -13
- package/docs/CLAUDE.md +1 -0
- package/docs/references/CLAUDE.md +1 -0
- package/docs/references/code-review-enforcement.md +97 -0
- package/docs/wsl-docker-cowork-starter-matrix.md +89 -0
- package/hooks/blocking/CLAUDE.md +8 -1
- package/hooks/blocking/code_review_enforcement_config_bootstrap.py +53 -0
- package/hooks/blocking/code_review_gate_deny.py +74 -0
- package/hooks/blocking/code_review_pr_create_gate.py +194 -0
- package/hooks/blocking/code_review_push_gate.py +140 -0
- package/hooks/blocking/code_review_stamp_directory_write_blocker.py +340 -0
- package/hooks/blocking/code_review_stamp_store.py +233 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/__init__.py +7 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/conftest.py +15 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py +212 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py +138 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py +49 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/test_split_directory_change_into_stamp.py +38 -0
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +39 -27
- package/hooks/blocking/config/code_review_enforcement_constants.py +110 -0
- package/hooks/blocking/config/test_code_review_enforcement_constants.py +108 -0
- package/hooks/blocking/config/verified_commit_constants.py +13 -9
- package/hooks/blocking/conftest.py +2 -0
- package/hooks/blocking/convergence_gate_blocker.py +112 -23
- package/hooks/blocking/destructive_command_blocker.py +19 -6
- package/hooks/blocking/pr_description_proof_of_work.py +52 -34
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
- package/hooks/blocking/test_code_review_enforcement_config_bootstrap.py +62 -0
- package/hooks/blocking/test_code_review_gate_deny.py +54 -0
- package/hooks/blocking/test_code_review_pr_create_gate.py +185 -0
- package/hooks/blocking/test_code_review_push_gate.py +189 -0
- package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +180 -0
- package/hooks/blocking/test_code_review_stamp_store.py +205 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +124 -2
- package/hooks/blocking/test_convergence_gate_blocker.py +153 -5
- package/hooks/blocking/test_destructive_command_blocker.py +1 -1
- package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +45 -0
- package/hooks/blocking/test_pr_description_proof_of_work.py +151 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +8 -8
- package/hooks/blocking/test_verification_verdict_store.py +920 -903
- package/hooks/blocking/test_volatile_path_in_post_blocker.py +114 -2
- package/hooks/blocking/verification_verdict_store.py +27 -5
- package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +29 -17
- package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +35 -0
- package/hooks/blocking/volatile_path_in_post_blocker.py +69 -8
- package/hooks/git-hooks/git_hooks_constants/__init__.py +6 -0
- package/hooks/git-hooks/pre_push.py +89 -2
- package/hooks/git-hooks/test_pre_push.py +103 -0
- package/hooks/hooks.json +16 -1
- package/hooks/hooks_constants/CLAUDE.md +1 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +8 -0
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +26 -11
- package/hooks/hooks_constants/convergence_gate_blocker_constants.py +20 -3
- package/hooks/hooks_constants/destructive_command_segment_constants.py +3 -1
- package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +0 -4
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/pyproject_config_discovery_constants.py +16 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +24 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +6 -0
- package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +8 -1
- package/hooks/validators/CLAUDE.md +1 -0
- package/hooks/validators/mypy_integration.py +63 -50
- package/hooks/validators/pyproject_config_discovery.py +101 -0
- package/hooks/validators/ruff_integration.py +211 -23
- package/hooks/validators/run_all_validators.py +21 -14
- package/hooks/validators/test_mypy_integration.py +32 -0
- package/hooks/validators/test_pyproject_config_discovery.py +94 -0
- package/hooks/validators/test_ruff_integration.py +68 -1
- package/hooks/validators/test_run_all_validators.py +25 -0
- package/hooks/validators/test_run_all_validators_config_discovery.py +123 -0
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +20 -43
- package/rules/durable-post-artifacts.md +7 -0
- package/scripts/codec_forwarding_test_support.py +83 -0
- package/scripts/conftest.py +8 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -1
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +44 -1
- package/scripts/dev_env_scripts_constants/code_review_constants.py +129 -12
- package/scripts/dev_env_scripts_constants/test_code_review_constants.py +55 -0
- package/scripts/invoke_code_review.py +550 -38
- package/scripts/resolve_worker_spawn.py +8 -1
- package/scripts/test_invoke_code_review.py +298 -4
- package/scripts/test_invoke_code_review_codec.py +77 -0
- package/scripts/test_resolve_worker_spawn_codec.py +101 -0
- package/skills/autoconverge/SKILL.md +9 -3
- package/skills/autoconverge/reference/convergence.md +2 -1
- package/skills/autoconverge/reference/multi-pr.md +6 -1
- package/skills/autoconverge/reference/stop-conditions.md +16 -10
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +1 -1
- package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +175 -3
- package/skills/autoconverge/workflow/converge.contract.test.mjs +19 -0
- package/skills/autoconverge/workflow/converge.mjs +24 -8
- package/skills/autoconverge/workflow/converge_multi.mjs +7 -3
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +5 -0
- package/skills/fresh-branch/CLAUDE.md +2 -0
- package/skills/fresh-branch/SKILL.md +2 -0
- package/skills/fresh-branch/scripts/create_fresh_branch.py +78 -180
- package/skills/fresh-branch/scripts/fresh_branch_git_commands.py +285 -0
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -0
- package/skills/fresh-branch/scripts/pytest.ini +4 -0
- package/skills/fresh-branch/scripts/test_create_fresh_branch.py +98 -0
- package/skills/fresh-branch/scripts/test_fresh_branch_git_commands.py +310 -0
- package/skills/prototype/SKILL.md +86 -0
- package/skills/prototype/reference/honest-limitations.md +23 -0
- package/skills/prototype/reference/promotion-tasks.md +23 -0
- package/skills/prototype/scripts/build_sandbox_settings.py +249 -0
- package/skills/prototype/scripts/conftest.py +15 -0
- package/skills/prototype/scripts/launch_sandbox.py +205 -0
- package/skills/prototype/scripts/probe_sandbox_safety.py +311 -0
- package/skills/prototype/scripts/prototype_scripts_constants/__init__.py +1 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/__init__.py +0 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/build_sandbox_settings_constants.py +41 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/launch_sandbox_constants.py +23 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/probe_sandbox_safety_constants.py +45 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/prototype_common_constants.py +10 -0
- package/skills/prototype/scripts/test_build_sandbox_settings.py +275 -0
- package/skills/prototype/scripts/test_launch_sandbox.py +303 -0
- package/skills/prototype/scripts/test_probe_sandbox_safety.py +284 -0
- package/skills/prototype/workflows/promotion.md +27 -0
- package/skills/prototype/workflows/sandbox.md +35 -0
- package/skills/skill-builder/CLAUDE.md +3 -3
- package/skills/skill-builder/SKILL.md +5 -5
- package/skills/skill-builder/references/CLAUDE.md +1 -1
- package/skills/skill-builder/references/delegation-map.md +3 -3
- package/skills/skill-builder/references/description-field.md +1 -1
- package/skills/skill-builder/references/skill-modularity.md +2 -3
- package/skills/skill-builder/workflows/CLAUDE.md +1 -1
- package/skills/skill-builder/workflows/improve-skill.md +1 -1
- package/skills/skill-builder/workflows/new-skill.md +2 -2
- package/skills/team-advisor/SKILL.md +2 -2
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Single source of truth for the code-review enforcement gate family.
|
|
2
|
+
|
|
3
|
+
Holds the stamp directory name, the ordered effort tokens (``low`` under
|
|
4
|
+
``medium`` under ``high`` under ``xhigh`` under ``max``, with ``ultra`` kept
|
|
5
|
+
out because it needs an interactive terminal), the effort a push and a
|
|
6
|
+
pull-request creation each require, the stamp record keys, the gate and
|
|
7
|
+
write-blocker messages, the store-forge shell patterns, the MCP create-PR
|
|
8
|
+
tool name, and the effort comparison every gate and the stamp store share so
|
|
9
|
+
the thresholds never drift between them.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
STAMP_DIRECTORY_NAME = "code-review-stamps"
|
|
15
|
+
ALL_EFFORT_TOKENS_IN_ASCENDING_ORDER = ("low", "medium", "high", "xhigh", "max")
|
|
16
|
+
PUSH_REQUIRED_EFFORT = "low"
|
|
17
|
+
PR_CREATE_REQUIRED_EFFORT = "xhigh"
|
|
18
|
+
STAMP_KEY_EFFORT = "effort"
|
|
19
|
+
STAMP_KEY_MANIFEST_SHA256 = "manifest_sha256"
|
|
20
|
+
STAMP_KEY_RECORDED_AT_EPOCH = "recorded_at_epoch"
|
|
21
|
+
CODE_REVIEW_BYPASS_MARKER = "# code-review-skip"
|
|
22
|
+
GATED_PUSH_SUBCOMMANDS = frozenset({"push"})
|
|
23
|
+
ALL_GATED_SHELL_TOOL_NAMES = ("Bash", "PowerShell")
|
|
24
|
+
ALL_WRITE_EDIT_TOOL_NAMES = ("Write", "Edit", "MultiEdit")
|
|
25
|
+
MCP_CREATE_PULL_REQUEST_TOOL_NAME = "mcp__plugin_github_github__create_pull_request"
|
|
26
|
+
HASH_PREVIEW_LENGTH = 16
|
|
27
|
+
PRE_TOOL_USE_HOOK_EVENT_NAME = "PreToolUse"
|
|
28
|
+
DENY_PERMISSION_DECISION = "deny"
|
|
29
|
+
PUSH_GATE_HOOK_MODULE_NAME = "code_review_push_gate.py"
|
|
30
|
+
PR_CREATE_GATE_HOOK_MODULE_NAME = "code_review_pr_create_gate.py"
|
|
31
|
+
STAMP_WRITE_BLOCKER_HOOK_MODULE_NAME = "code_review_stamp_directory_write_blocker.py"
|
|
32
|
+
SANCTIONED_STAMP_MINTER_FLAG = "--record-stamp"
|
|
33
|
+
PUSH_GATE_CORRECTIVE_MESSAGE = (
|
|
34
|
+
"BLOCKED: [CODE_REVIEW_PUSH_GATE] This branch surface has no clean "
|
|
35
|
+
"code-review stamp at effort 'low' or higher. Run "
|
|
36
|
+
"`python invoke_code_review.py --record-stamp --cwd <dir> "
|
|
37
|
+
"--session-model <alias> low` (or a higher effort) so a clean review "
|
|
38
|
+
"mints the stamp before `git push`; any file change after minting "
|
|
39
|
+
"invalidates it. Append `# code-review-skip` as a trailing shell comment "
|
|
40
|
+
"only when the verified-commit gate's verify-skip rule for a same-surface "
|
|
41
|
+
"re-land applies. Exempt automatically: docs/image files, pytest test "
|
|
42
|
+
"files, and Python files whose docstring- and comment-stripped AST is "
|
|
43
|
+
"unchanged."
|
|
44
|
+
)
|
|
45
|
+
PR_CREATE_GATE_CORRECTIVE_MESSAGE = (
|
|
46
|
+
"BLOCKED: [CODE_REVIEW_PR_CREATE_GATE] This branch surface has no clean "
|
|
47
|
+
"code-review stamp at effort 'xhigh' or higher. Run "
|
|
48
|
+
"`python invoke_code_review.py --record-stamp --cwd <dir> "
|
|
49
|
+
"--session-model <alias> xhigh` (or max) so a clean review mints the "
|
|
50
|
+
"stamp before `gh pr create` or the MCP create_pull_request tool. Any "
|
|
51
|
+
"file change after minting invalidates the stamp."
|
|
52
|
+
)
|
|
53
|
+
STAMP_DIRECTORY_GUARD_MESSAGE = (
|
|
54
|
+
"BLOCKED: [CODE_REVIEW_STAMP_DIRECTORY_GUARD] Access to the code-review "
|
|
55
|
+
"stamp directory (~/.claude/code-review-stamps/) is denied. Only "
|
|
56
|
+
"invoke_code_review.py --record-stamp mints stamp files; a shell or "
|
|
57
|
+
"Write/Edit forge here would defeat the push and PR-create gates. "
|
|
58
|
+
"Earn a stamp by running a clean /code-review through the invoker."
|
|
59
|
+
)
|
|
60
|
+
RELATIVE_STAMP_DIRECTORY_PATTERN = r"(?:^|(?<=[\s;&|(='\"]))code-review-stamps[\\/]"
|
|
61
|
+
STAMP_DIRECTORY_CHANGE_TARGET_PATTERN = r"[ \t]+['\"]?code-review-stamps[\\/]?['\"]?(?=[\s;&|]|$)"
|
|
62
|
+
STAMP_FILE_REFERENCE_BOUNDARY_PREFIX_PATTERN = r"(?:^|(?<=[\s;&|(='\"\\/]))"
|
|
63
|
+
STAMP_FILE_ROOT_KEY_JSON_SUFFIX_PATTERN = r"[\\/][0-9a-f]{%d}\.json"
|
|
64
|
+
GH_PR_CREATE_INVOCATION_PATTERN = (
|
|
65
|
+
r"(?:^|[;&|\n`({]|\$\()[ \t]*"
|
|
66
|
+
r"(?:(?:if|then|else|elif|while|until|do|!)[ \t]+)*"
|
|
67
|
+
r"(?:[A-Za-z_][A-Za-z0-9_]*=\S+[ \t]+)*"
|
|
68
|
+
r"gh(?:[ \t]+(?:--[A-Za-z][\w-]*(?:=\S+)?|-[A-Za-z])(?:[ \t]+(?!-)\S+)?)*"
|
|
69
|
+
r"[ \t]+pr[ \t]+create\b"
|
|
70
|
+
)
|
|
71
|
+
ALL_STAMP_PATH_SEGMENT_NAMES = (".claude", "code-review-stamps")
|
|
72
|
+
ALL_STAMP_PATH_SEGMENT_BODIES = ("claude", "code-review-stamps")
|
|
73
|
+
STAMP_STORE_MODULE_NAME = "code_review_stamp_store"
|
|
74
|
+
STAMP_MINT_FUNCTION_NAME = "record_clean_stamp"
|
|
75
|
+
ALL_STAMP_STORE_FORGE_PATTERNS = (
|
|
76
|
+
rf"\bimport\s+{STAMP_STORE_MODULE_NAME}\b",
|
|
77
|
+
rf"\bfrom\s+{STAMP_STORE_MODULE_NAME}\s+import\b",
|
|
78
|
+
rf"\b{STAMP_MINT_FUNCTION_NAME}\s*\(",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def effort_meets_threshold(candidate_effort: str, required_effort: str) -> bool:
|
|
83
|
+
"""Decide whether a recorded effort satisfies a required threshold.
|
|
84
|
+
|
|
85
|
+
::
|
|
86
|
+
|
|
87
|
+
effort_meets_threshold("xhigh", "low") -> True xhigh outranks low
|
|
88
|
+
effort_meets_threshold("low", "xhigh") -> False low sits below xhigh
|
|
89
|
+
effort_meets_threshold("ultra", "low") -> False ultra is unranked
|
|
90
|
+
|
|
91
|
+
A candidate satisfies the threshold when it sits at or above the required
|
|
92
|
+
token in the ascending order. A token outside the known order — ``ultra``
|
|
93
|
+
among them — satisfies nothing, so an unrecognized stored effort clears no
|
|
94
|
+
gate.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
candidate_effort: The effort a recorded stamp was earned at.
|
|
98
|
+
required_effort: The effort the gate demands for the action.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
True when both tokens are known and the candidate ranks at or above the
|
|
102
|
+
required token; False otherwise.
|
|
103
|
+
"""
|
|
104
|
+
if candidate_effort not in ALL_EFFORT_TOKENS_IN_ASCENDING_ORDER:
|
|
105
|
+
return False
|
|
106
|
+
if required_effort not in ALL_EFFORT_TOKENS_IN_ASCENDING_ORDER:
|
|
107
|
+
return False
|
|
108
|
+
candidate_rank = ALL_EFFORT_TOKENS_IN_ASCENDING_ORDER.index(candidate_effort)
|
|
109
|
+
required_rank = ALL_EFFORT_TOKENS_IN_ASCENDING_ORDER.index(required_effort)
|
|
110
|
+
return candidate_rank >= required_rank
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Behavioral tests for the code-review enforcement constants and comparator.
|
|
2
|
+
|
|
3
|
+
These exercise ``effort_meets_threshold`` against the ordered effort tokens and
|
|
4
|
+
assert the push and pull-request thresholds carry the push-based names the gate
|
|
5
|
+
family reads, so a rename that reintroduces commit terminology fails loudly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import importlib.util
|
|
9
|
+
import pathlib
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
_CONFIG_DIR = pathlib.Path(__file__).parent
|
|
13
|
+
|
|
14
|
+
SAMPLE_ROOT_KEY_HEX_LENGTH = 16
|
|
15
|
+
|
|
16
|
+
_constants_spec = importlib.util.spec_from_file_location(
|
|
17
|
+
"code_review_enforcement_constants",
|
|
18
|
+
_CONFIG_DIR / "code_review_enforcement_constants.py",
|
|
19
|
+
)
|
|
20
|
+
assert _constants_spec is not None
|
|
21
|
+
assert _constants_spec.loader is not None
|
|
22
|
+
_constants_module = importlib.util.module_from_spec(_constants_spec)
|
|
23
|
+
_constants_spec.loader.exec_module(_constants_module)
|
|
24
|
+
|
|
25
|
+
effort_meets_threshold = _constants_module.effort_meets_threshold
|
|
26
|
+
PUSH_REQUIRED_EFFORT = _constants_module.PUSH_REQUIRED_EFFORT
|
|
27
|
+
PR_CREATE_REQUIRED_EFFORT = _constants_module.PR_CREATE_REQUIRED_EFFORT
|
|
28
|
+
GATED_PUSH_SUBCOMMANDS = _constants_module.GATED_PUSH_SUBCOMMANDS
|
|
29
|
+
ALL_EFFORT_TOKENS_IN_ASCENDING_ORDER = _constants_module.ALL_EFFORT_TOKENS_IN_ASCENDING_ORDER
|
|
30
|
+
PUSH_GATE_CORRECTIVE_MESSAGE = _constants_module.PUSH_GATE_CORRECTIVE_MESSAGE
|
|
31
|
+
SANCTIONED_STAMP_MINTER_FLAG = _constants_module.SANCTIONED_STAMP_MINTER_FLAG
|
|
32
|
+
STAMP_DIRECTORY_GUARD_MESSAGE = _constants_module.STAMP_DIRECTORY_GUARD_MESSAGE
|
|
33
|
+
STAMP_DIRECTORY_NAME = _constants_module.STAMP_DIRECTORY_NAME
|
|
34
|
+
GH_PR_CREATE_INVOCATION_PATTERN = _constants_module.GH_PR_CREATE_INVOCATION_PATTERN
|
|
35
|
+
STAMP_FILE_REFERENCE_BOUNDARY_PREFIX_PATTERN = (
|
|
36
|
+
_constants_module.STAMP_FILE_REFERENCE_BOUNDARY_PREFIX_PATTERN
|
|
37
|
+
)
|
|
38
|
+
STAMP_FILE_ROOT_KEY_JSON_SUFFIX_PATTERN = _constants_module.STAMP_FILE_ROOT_KEY_JSON_SUFFIX_PATTERN
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_gh_pr_create_pattern_matches_create_but_not_edit() -> None:
|
|
42
|
+
invocation_pattern = re.compile(GH_PR_CREATE_INVOCATION_PATTERN, re.IGNORECASE)
|
|
43
|
+
assert invocation_pattern.search("gh pr create --title T --body-file b.md") is not None
|
|
44
|
+
assert invocation_pattern.search("gh pr edit 1 --title T") is None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_stamp_file_pattern_fragments_match_a_stamp_file_reference() -> None:
|
|
48
|
+
stamp_file_pattern = re.compile(
|
|
49
|
+
STAMP_FILE_REFERENCE_BOUNDARY_PREFIX_PATTERN
|
|
50
|
+
+ re.escape(STAMP_DIRECTORY_NAME)
|
|
51
|
+
+ STAMP_FILE_ROOT_KEY_JSON_SUFFIX_PATTERN % SAMPLE_ROOT_KEY_HEX_LENGTH,
|
|
52
|
+
re.IGNORECASE,
|
|
53
|
+
)
|
|
54
|
+
stamp_file_reference = f"cat {STAMP_DIRECTORY_NAME}/{'a' * SAMPLE_ROOT_KEY_HEX_LENGTH}.json"
|
|
55
|
+
assert stamp_file_pattern.search(stamp_file_reference) is not None
|
|
56
|
+
assert stamp_file_pattern.search("cat notes.json") is None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_higher_effort_meets_a_lower_threshold() -> None:
|
|
60
|
+
assert effort_meets_threshold("xhigh", "low") is True
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_lower_effort_fails_a_higher_threshold() -> None:
|
|
64
|
+
assert effort_meets_threshold("low", "xhigh") is False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_equal_effort_meets_the_threshold() -> None:
|
|
68
|
+
assert effort_meets_threshold("low", "low") is True
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_ultra_is_unranked_and_meets_nothing() -> None:
|
|
72
|
+
assert effort_meets_threshold("ultra", "low") is False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_unknown_required_effort_meets_nothing() -> None:
|
|
76
|
+
assert effort_meets_threshold("low", "ultra") is False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_push_requires_the_lowest_effort_token() -> None:
|
|
80
|
+
every_token_meets_push = all(
|
|
81
|
+
effort_meets_threshold(each_token, PUSH_REQUIRED_EFFORT)
|
|
82
|
+
for each_token in ALL_EFFORT_TOKENS_IN_ASCENDING_ORDER
|
|
83
|
+
)
|
|
84
|
+
assert every_token_meets_push
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_pr_create_threshold_admits_xhigh_but_rejects_high() -> None:
|
|
88
|
+
assert effort_meets_threshold("xhigh", PR_CREATE_REQUIRED_EFFORT) is True
|
|
89
|
+
assert effort_meets_threshold("high", PR_CREATE_REQUIRED_EFFORT) is False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_gated_push_subcommands_gates_push_not_commit() -> None:
|
|
93
|
+
assert "push" in GATED_PUSH_SUBCOMMANDS
|
|
94
|
+
assert "commit" not in GATED_PUSH_SUBCOMMANDS
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_ultra_absent_from_the_ordered_tokens() -> None:
|
|
98
|
+
assert "ultra" not in ALL_EFFORT_TOKENS_IN_ASCENDING_ORDER
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_push_gate_message_names_the_push_action_and_low_effort() -> None:
|
|
102
|
+
assert "git push" in PUSH_GATE_CORRECTIVE_MESSAGE
|
|
103
|
+
assert "CODE_REVIEW_PUSH_GATE" in PUSH_GATE_CORRECTIVE_MESSAGE
|
|
104
|
+
assert "'low'" in PUSH_GATE_CORRECTIVE_MESSAGE
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_guard_message_directs_users_to_the_sanctioned_minter_flag() -> None:
|
|
108
|
+
assert SANCTIONED_STAMP_MINTER_FLAG in STAMP_DIRECTORY_GUARD_MESSAGE
|
|
@@ -137,19 +137,23 @@ VERDICT_DIRECTORY_GUARD_MESSAGE = (
|
|
|
137
137
|
)
|
|
138
138
|
CORRECTIVE_MESSAGE = (
|
|
139
139
|
"BLOCKED: [VERIFIED_COMMIT_GATE] This branch surface has no passing "
|
|
140
|
-
"verification verdict.
|
|
140
|
+
"verification verdict. Clear commit-committability gates first "
|
|
141
|
+
"(CODE_RULES on changed lines and merge conflicts vs base — the "
|
|
142
|
+
"code_verifier_spawn_preflight_gate enforces these on the spawn). If a "
|
|
143
|
+
"prior commit failed on CODE_RULES or staging, fix those before "
|
|
144
|
+
"re-verifying. Then spawn the code-verifier agent (Agent or Task tool, "
|
|
141
145
|
"subagent_type 'code-verifier') with the task texts, the diff scope, "
|
|
142
146
|
"and recorded baselines; when it finishes with a clean verdict the "
|
|
143
147
|
"SubagentStop hook mints the verdict and this command will pass. Any "
|
|
144
148
|
"file change after verification invalidates the verdict, so verify "
|
|
145
|
-
"last. The verdict binds to the exact
|
|
146
|
-
"and untracked file's bytes), not to a
|
|
147
|
-
"from a different work tree passes only
|
|
148
|
-
"byte-identical; a work tree carrying any extra or
|
|
149
|
-
"surface no verdict covers. To land this change, run
|
|
150
|
-
"from the work tree that holds the verified surface, or
|
|
151
|
-
"surface here. Exempt automatically: docs/image files,
|
|
152
|
-
"files, and "
|
|
149
|
+
"last only after commit gates are green. The verdict binds to the exact "
|
|
150
|
+
"surface content (every changed and untracked file's bytes), not to a "
|
|
151
|
+
"work-tree path, so a commit from a different work tree passes only "
|
|
152
|
+
"when its surface is byte-identical; a work tree carrying any extra or "
|
|
153
|
+
"later edit has a surface no verdict covers. To land this change, run "
|
|
154
|
+
"this command from the work tree that holds the verified surface, or "
|
|
155
|
+
"verify this surface here. Exempt automatically: docs/image files, "
|
|
156
|
+
"pytest test files, and "
|
|
153
157
|
"Python files whose docstring- and comment-stripped AST is unchanged "
|
|
154
158
|
"(comment-only edits in non-Python files are not exempt)."
|
|
155
159
|
)
|
|
@@ -26,6 +26,8 @@ _MIRRORED_BLOCKING_FILE_NAMES = (
|
|
|
26
26
|
"verifier_verdict_minter.py",
|
|
27
27
|
"verdict_directory_write_blocker.py",
|
|
28
28
|
"verified_commit_config_bootstrap.py",
|
|
29
|
+
"code_review_enforcement_config_bootstrap.py",
|
|
30
|
+
"code_review_stamp_store.py",
|
|
29
31
|
)
|
|
30
32
|
_DECOY_CONFIG_INIT_SOURCE = '"""Foreign config package that must never win resolution."""\n'
|
|
31
33
|
_DECOY_CONFIG_CONSTANTS_SOURCE = (
|
|
@@ -18,11 +18,19 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
|
18
18
|
if _hooks_dir not in sys.path:
|
|
19
19
|
sys.path.insert(0, _hooks_dir)
|
|
20
20
|
|
|
21
|
+
from blocking.pr_description_pr_number import ( # noqa: E402
|
|
22
|
+
_extract_pr_number_from_command,
|
|
23
|
+
)
|
|
21
24
|
from hooks_constants.convergence_gate_blocker_constants import ( # noqa: E402
|
|
25
|
+
ALL_GH_PR_VIEW_NUMBER_COMMAND,
|
|
26
|
+
BASH_LINE_CONTINUATION_PATTERN,
|
|
22
27
|
COMMAND_SEPARATOR_PATTERN,
|
|
23
28
|
GH_PR_READY_ANCHOR_PATTERN,
|
|
29
|
+
GH_REPO_FLAG,
|
|
30
|
+
GIT_URL_SUFFIX,
|
|
24
31
|
PR_URL_OWNER_REPO_NUMBER_PATTERN,
|
|
25
32
|
REPO_OVERRIDE_FLAG_PATTERN,
|
|
33
|
+
REPO_SLUG_TEMPLATE,
|
|
26
34
|
)
|
|
27
35
|
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
28
36
|
|
|
@@ -36,12 +44,15 @@ def _ready_command_segment(command: str) -> str:
|
|
|
36
44
|
^^^^^^^^^^^^^^^^ clipped -> returned segment
|
|
37
45
|
|
|
38
46
|
Scanning only this segment keeps a ``--repo`` flag or PR URL that belongs to
|
|
39
|
-
a chained command from binding the gate to the wrong PR.
|
|
47
|
+
a chained command from binding the gate to the wrong PR. A backslash-newline
|
|
48
|
+
continuation is folded to a space before the separator search, so a
|
|
49
|
+
``--repo`` flag written on a continued line stays inside the segment.
|
|
40
50
|
"""
|
|
41
51
|
ready_match = re.search(GH_PR_READY_ANCHOR_PATTERN, command)
|
|
42
52
|
if ready_match is None:
|
|
43
53
|
return command
|
|
44
54
|
ready_tail = command[ready_match.start() :]
|
|
55
|
+
ready_tail = re.sub(BASH_LINE_CONTINUATION_PATTERN, " ", ready_tail)
|
|
45
56
|
separator_match = re.search(COMMAND_SEPARATOR_PATTERN, ready_tail)
|
|
46
57
|
if separator_match is None:
|
|
47
58
|
return ready_tail
|
|
@@ -61,30 +72,59 @@ def _parse_repo_flag(command: str) -> tuple[str, str] | None:
|
|
|
61
72
|
flag_match = re.search(REPO_OVERRIDE_FLAG_PATTERN, command)
|
|
62
73
|
if flag_match is None:
|
|
63
74
|
return None
|
|
64
|
-
return flag_match.group("owner"), flag_match.group("repo")
|
|
75
|
+
return flag_match.group("owner"), flag_match.group("repo").removesuffix(GIT_URL_SUFFIX)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _resolve_named_identity(
|
|
79
|
+
command: str, cwd: str | None
|
|
80
|
+
) -> tuple[tuple[str, str] | None, int] | None:
|
|
81
|
+
"""Resolve the repository the command names and its PR number.
|
|
82
|
+
|
|
83
|
+
A full PR URL yields both the (owner, repo) pair and the number. A
|
|
84
|
+
``--repo``/``-R`` flag yields the pair while the number resolves from the
|
|
85
|
+
command's positional argument or the named repository's current-branch PR.
|
|
86
|
+
With neither present, the pair is None — leaving gh bound to the current
|
|
87
|
+
directory's repository — and the number resolves the same way. Every parse
|
|
88
|
+
runs over the ``gh pr ready`` segment alone, clipped at the next command
|
|
89
|
+
separator, so a flag or URL belonging to a chained command cannot bind the
|
|
90
|
+
gate to the wrong PR.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
command: The raw shell command captured by the hook.
|
|
94
|
+
cwd: The working directory gh runs in when the number resolves from the
|
|
95
|
+
current branch, or None for the process default.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
The (owner, repo) pair the command names — None when it names none —
|
|
99
|
+
paired with the resolved PR number, or None when no number resolves.
|
|
100
|
+
"""
|
|
101
|
+
ready_segment = _ready_command_segment(command)
|
|
102
|
+
pr_url_identity = _parse_pr_url(ready_segment)
|
|
103
|
+
if pr_url_identity is not None:
|
|
104
|
+
url_owner, url_repo, url_number = pr_url_identity
|
|
105
|
+
return (url_owner, url_repo), url_number
|
|
106
|
+
all_target_repo = _parse_repo_flag(ready_segment)
|
|
107
|
+
from_owner, from_repo = all_target_repo if all_target_repo is not None else (None, None)
|
|
108
|
+
resolved_pr_number = _resolve_pr_number(ready_segment, cwd, from_owner, from_repo)
|
|
109
|
+
if resolved_pr_number is None:
|
|
110
|
+
return None
|
|
111
|
+
return all_target_repo, resolved_pr_number
|
|
65
112
|
|
|
66
113
|
|
|
67
114
|
def _resolve_target_identity(command: str, cwd: str | None) -> tuple[str, str, int] | None:
|
|
68
115
|
"""Resolve the (owner, repo, pr_number) the gate keys its evidence to.
|
|
69
116
|
|
|
70
|
-
|
|
71
|
-
the repository
|
|
72
|
-
|
|
73
|
-
resolve from the cwd.
|
|
117
|
+
Delegates the repository-and-number resolution to the shared core, then
|
|
118
|
+
falls back to the cwd repository for the number when the command names no
|
|
119
|
+
repository of its own.
|
|
74
120
|
"""
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if pr_url_identity is not None:
|
|
78
|
-
return pr_url_identity
|
|
79
|
-
|
|
80
|
-
pr_number = _resolve_pr_number(ready_segment, cwd)
|
|
81
|
-
if pr_number is None:
|
|
121
|
+
named_identity = _resolve_named_identity(command, cwd)
|
|
122
|
+
if named_identity is None:
|
|
82
123
|
return None
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
return flag_owner, flag_repo, pr_number
|
|
124
|
+
all_target_repo, pr_number = named_identity
|
|
125
|
+
if all_target_repo is not None:
|
|
126
|
+
owner, repo = all_target_repo
|
|
127
|
+
return owner, repo, pr_number
|
|
88
128
|
|
|
89
129
|
cwd_identity = _resolve_owner_repo(cwd)
|
|
90
130
|
if cwd_identity is None:
|
|
@@ -93,13 +133,46 @@ def _resolve_target_identity(command: str, cwd: str | None) -> tuple[str, str, i
|
|
|
93
133
|
return cwd_owner, cwd_repo, pr_number
|
|
94
134
|
|
|
95
135
|
|
|
96
|
-
def
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
136
|
+
def _repo_flag_arguments(all_target_repo: tuple[str, str] | None) -> list[str]:
|
|
137
|
+
"""Build the ``--repo owner/repo`` arguments for a gh subcommand.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
all_target_repo: The (owner, repo) pair the command names, or None when
|
|
141
|
+
the command names no repository.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
A ``[--repo, owner/repo]`` argument pair when a repository is named, or
|
|
145
|
+
an empty list when it is None — leaving gh bound to the current
|
|
146
|
+
directory's repository.
|
|
147
|
+
"""
|
|
148
|
+
if all_target_repo is None:
|
|
149
|
+
return []
|
|
150
|
+
owner, repo = all_target_repo
|
|
151
|
+
return [GH_REPO_FLAG, REPO_SLUG_TEMPLATE.format(owner=owner, repo=repo)]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _resolve_current_branch_pr_number(
|
|
155
|
+
all_target_repo: tuple[str, str] | None, cwd: str | None
|
|
156
|
+
) -> int | None:
|
|
157
|
+
"""Resolve the current-branch PR number through ``gh pr view``.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
all_target_repo: The (owner, repo) pair the command names, or None to
|
|
161
|
+
read the current directory's repository.
|
|
162
|
+
cwd: The working directory gh runs in, or None for the process default.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
The PR number gh reports, or None when gh is missing, exits non-zero,
|
|
166
|
+
or prints no integer — every failure path returns None so the caller
|
|
167
|
+
fails open.
|
|
168
|
+
"""
|
|
169
|
+
all_view_arguments = [
|
|
170
|
+
*ALL_GH_PR_VIEW_NUMBER_COMMAND,
|
|
171
|
+
*_repo_flag_arguments(all_target_repo),
|
|
172
|
+
]
|
|
100
173
|
try:
|
|
101
174
|
completed_process = subprocess.run(
|
|
102
|
-
|
|
175
|
+
all_view_arguments,
|
|
103
176
|
capture_output=True,
|
|
104
177
|
text=True,
|
|
105
178
|
cwd=cwd or None,
|
|
@@ -115,6 +188,22 @@ def _resolve_pr_number(command: str, cwd: str | None) -> int | None:
|
|
|
115
188
|
return None
|
|
116
189
|
|
|
117
190
|
|
|
191
|
+
def _resolve_pr_number(
|
|
192
|
+
command: str,
|
|
193
|
+
cwd: str | None,
|
|
194
|
+
from_owner: str | None,
|
|
195
|
+
from_repo: str | None,
|
|
196
|
+
) -> int | None:
|
|
197
|
+
direct_number = _extract_pr_number_from_command(command)
|
|
198
|
+
if direct_number is not None:
|
|
199
|
+
return direct_number
|
|
200
|
+
if from_owner is not None and from_repo is not None:
|
|
201
|
+
all_target_repo: tuple[str, str] | None = (from_owner, from_repo)
|
|
202
|
+
else:
|
|
203
|
+
all_target_repo = None
|
|
204
|
+
return _resolve_current_branch_pr_number(all_target_repo, cwd)
|
|
205
|
+
|
|
206
|
+
|
|
118
207
|
def _resolve_owner_repo(cwd: str | None) -> tuple[str, str] | None:
|
|
119
208
|
try:
|
|
120
209
|
completed_process = subprocess.run(
|
|
@@ -24,6 +24,8 @@ from hooks_constants.destructive_command_segment_constants import ( # noqa: E40
|
|
|
24
24
|
ALL_BENIGN_COMPOUND_SEGMENT_COMMANDS,
|
|
25
25
|
ALL_COMMAND_LAUNCHER_WRAPPER_COMMANDS,
|
|
26
26
|
ALL_KNOWN_TEMPORARY_ENVIRONMENT_VARIABLE_NAMES,
|
|
27
|
+
ALL_TRUTHY_ENV_VALUES,
|
|
28
|
+
DESTRUCTIVE_DENY_MODE_ENV_VAR,
|
|
27
29
|
ALL_FILE_WRITING_OUTPUT_FLAGS_BY_BENIGN_PROGRAM,
|
|
28
30
|
ALL_FIND_EXEC_ACTION_FLAGS,
|
|
29
31
|
ALL_FIND_EXEC_ACTION_TERMINATORS,
|
|
@@ -56,12 +58,17 @@ from hooks_constants.destructive_command_segment_constants import ( # noqa: E40
|
|
|
56
58
|
|
|
57
59
|
CLAUDE_DIRECTORY_PATH = os.path.normpath(os.path.expanduser("~/.claude"))
|
|
58
60
|
GH_REDIRECT_ACTIVE_ENV_VAR = "CLAUDE_GH_REDIRECT_ACTIVE"
|
|
59
|
-
GH_REDIRECT_ACTIVE_TRUTHY_VALUES = frozenset({"1", "true", "yes", "on"})
|
|
60
61
|
|
|
61
62
|
|
|
62
63
|
def gh_redirect_is_active() -> bool:
|
|
63
64
|
env_var_value = os.environ.get(GH_REDIRECT_ACTIVE_ENV_VAR, "").strip().lower()
|
|
64
|
-
return env_var_value in
|
|
65
|
+
return env_var_value in ALL_TRUTHY_ENV_VALUES
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def deny_mode_is_active() -> bool:
|
|
69
|
+
env_var_value = os.environ.get(DESTRUCTIVE_DENY_MODE_ENV_VAR, "").strip().lower()
|
|
70
|
+
return env_var_value in ALL_TRUTHY_ENV_VALUES
|
|
71
|
+
|
|
65
72
|
|
|
66
73
|
def directory_is_ephemeral(directory_path: str) -> bool:
|
|
67
74
|
"""Return True when a directory belongs to the ephemeral auto-allow namespace.
|
|
@@ -2043,14 +2050,20 @@ def main() -> None:
|
|
|
2043
2050
|
matched_description = co_resident_hazard_description
|
|
2044
2051
|
|
|
2045
2052
|
if matched_description is not None:
|
|
2046
|
-
|
|
2053
|
+
if deny_mode_is_active():
|
|
2054
|
+
terminal_decision = "deny"
|
|
2055
|
+
terminal_reason = f"DESTRUCTIVE: {matched_description}. Blocked in deny mode."
|
|
2056
|
+
else:
|
|
2057
|
+
terminal_decision = "ask"
|
|
2058
|
+
terminal_reason = f"DESTRUCTIVE: {matched_description}. Requires explicit user approval."
|
|
2059
|
+
terminal_response = {
|
|
2047
2060
|
"hookSpecificOutput": {
|
|
2048
2061
|
"hookEventName": "PreToolUse",
|
|
2049
|
-
"permissionDecision":
|
|
2050
|
-
"permissionDecisionReason":
|
|
2062
|
+
"permissionDecision": terminal_decision,
|
|
2063
|
+
"permissionDecisionReason": terminal_reason
|
|
2051
2064
|
}
|
|
2052
2065
|
}
|
|
2053
|
-
print(json.dumps(
|
|
2066
|
+
print(json.dumps(terminal_response))
|
|
2054
2067
|
|
|
2055
2068
|
sys.exit(0)
|
|
2056
2069
|
|