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.
Files changed (136) hide show
  1. package/CLAUDE.md +3 -2
  2. package/agents/CLAUDE.md +1 -0
  3. package/agents/code-verifier.md +3 -3
  4. package/agents/skill-writer-agent.md +84 -0
  5. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +141 -41
  6. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +29 -13
  7. package/docs/CLAUDE.md +1 -0
  8. package/docs/references/CLAUDE.md +1 -0
  9. package/docs/references/code-review-enforcement.md +97 -0
  10. package/docs/wsl-docker-cowork-starter-matrix.md +89 -0
  11. package/hooks/blocking/CLAUDE.md +8 -1
  12. package/hooks/blocking/code_review_enforcement_config_bootstrap.py +53 -0
  13. package/hooks/blocking/code_review_gate_deny.py +74 -0
  14. package/hooks/blocking/code_review_pr_create_gate.py +194 -0
  15. package/hooks/blocking/code_review_push_gate.py +140 -0
  16. package/hooks/blocking/code_review_stamp_directory_write_blocker.py +340 -0
  17. package/hooks/blocking/code_review_stamp_store.py +233 -0
  18. package/hooks/blocking/code_review_stamp_write_blocker_parts/__init__.py +7 -0
  19. package/hooks/blocking/code_review_stamp_write_blocker_parts/conftest.py +15 -0
  20. package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py +212 -0
  21. package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py +138 -0
  22. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py +49 -0
  23. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_split_directory_change_into_stamp.py +38 -0
  24. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +39 -27
  25. package/hooks/blocking/config/code_review_enforcement_constants.py +110 -0
  26. package/hooks/blocking/config/test_code_review_enforcement_constants.py +108 -0
  27. package/hooks/blocking/config/verified_commit_constants.py +13 -9
  28. package/hooks/blocking/conftest.py +2 -0
  29. package/hooks/blocking/convergence_gate_blocker.py +112 -23
  30. package/hooks/blocking/destructive_command_blocker.py +19 -6
  31. package/hooks/blocking/pr_description_proof_of_work.py +52 -34
  32. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
  33. package/hooks/blocking/test_code_review_enforcement_config_bootstrap.py +62 -0
  34. package/hooks/blocking/test_code_review_gate_deny.py +54 -0
  35. package/hooks/blocking/test_code_review_pr_create_gate.py +185 -0
  36. package/hooks/blocking/test_code_review_push_gate.py +189 -0
  37. package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +180 -0
  38. package/hooks/blocking/test_code_review_stamp_store.py +205 -0
  39. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +124 -2
  40. package/hooks/blocking/test_convergence_gate_blocker.py +153 -5
  41. package/hooks/blocking/test_destructive_command_blocker.py +1 -1
  42. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +45 -0
  43. package/hooks/blocking/test_pr_description_proof_of_work.py +151 -0
  44. package/hooks/blocking/test_pre_tool_use_dispatcher.py +8 -8
  45. package/hooks/blocking/test_verification_verdict_store.py +920 -903
  46. package/hooks/blocking/test_volatile_path_in_post_blocker.py +114 -2
  47. package/hooks/blocking/verification_verdict_store.py +27 -5
  48. package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +29 -17
  49. package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +35 -0
  50. package/hooks/blocking/volatile_path_in_post_blocker.py +69 -8
  51. package/hooks/git-hooks/git_hooks_constants/__init__.py +6 -0
  52. package/hooks/git-hooks/pre_push.py +89 -2
  53. package/hooks/git-hooks/test_pre_push.py +103 -0
  54. package/hooks/hooks.json +16 -1
  55. package/hooks/hooks_constants/CLAUDE.md +1 -0
  56. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +8 -0
  57. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  58. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +26 -11
  59. package/hooks/hooks_constants/convergence_gate_blocker_constants.py +20 -3
  60. package/hooks/hooks_constants/destructive_command_segment_constants.py +3 -1
  61. package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +0 -4
  62. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  63. package/hooks/hooks_constants/pyproject_config_discovery_constants.py +16 -0
  64. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +24 -0
  65. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +6 -0
  66. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +8 -1
  67. package/hooks/validators/CLAUDE.md +1 -0
  68. package/hooks/validators/mypy_integration.py +63 -50
  69. package/hooks/validators/pyproject_config_discovery.py +101 -0
  70. package/hooks/validators/ruff_integration.py +211 -23
  71. package/hooks/validators/run_all_validators.py +21 -14
  72. package/hooks/validators/test_mypy_integration.py +32 -0
  73. package/hooks/validators/test_pyproject_config_discovery.py +94 -0
  74. package/hooks/validators/test_ruff_integration.py +68 -1
  75. package/hooks/validators/test_run_all_validators.py +25 -0
  76. package/hooks/validators/test_run_all_validators_config_discovery.py +123 -0
  77. package/package.json +1 -1
  78. package/rules/docstring-prose-matches-implementation.md +20 -43
  79. package/rules/durable-post-artifacts.md +7 -0
  80. package/scripts/codec_forwarding_test_support.py +83 -0
  81. package/scripts/conftest.py +8 -0
  82. package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -1
  83. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +44 -1
  84. package/scripts/dev_env_scripts_constants/code_review_constants.py +129 -12
  85. package/scripts/dev_env_scripts_constants/test_code_review_constants.py +55 -0
  86. package/scripts/invoke_code_review.py +550 -38
  87. package/scripts/resolve_worker_spawn.py +8 -1
  88. package/scripts/test_invoke_code_review.py +298 -4
  89. package/scripts/test_invoke_code_review_codec.py +77 -0
  90. package/scripts/test_resolve_worker_spawn_codec.py +101 -0
  91. package/skills/autoconverge/SKILL.md +9 -3
  92. package/skills/autoconverge/reference/convergence.md +2 -1
  93. package/skills/autoconverge/reference/multi-pr.md +6 -1
  94. package/skills/autoconverge/reference/stop-conditions.md +16 -10
  95. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +1 -1
  96. package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +175 -3
  97. package/skills/autoconverge/workflow/converge.contract.test.mjs +19 -0
  98. package/skills/autoconverge/workflow/converge.mjs +24 -8
  99. package/skills/autoconverge/workflow/converge_multi.mjs +7 -3
  100. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +5 -0
  101. package/skills/fresh-branch/CLAUDE.md +2 -0
  102. package/skills/fresh-branch/SKILL.md +2 -0
  103. package/skills/fresh-branch/scripts/create_fresh_branch.py +78 -180
  104. package/skills/fresh-branch/scripts/fresh_branch_git_commands.py +285 -0
  105. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -0
  106. package/skills/fresh-branch/scripts/pytest.ini +4 -0
  107. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +98 -0
  108. package/skills/fresh-branch/scripts/test_fresh_branch_git_commands.py +310 -0
  109. package/skills/prototype/SKILL.md +86 -0
  110. package/skills/prototype/reference/honest-limitations.md +23 -0
  111. package/skills/prototype/reference/promotion-tasks.md +23 -0
  112. package/skills/prototype/scripts/build_sandbox_settings.py +249 -0
  113. package/skills/prototype/scripts/conftest.py +15 -0
  114. package/skills/prototype/scripts/launch_sandbox.py +205 -0
  115. package/skills/prototype/scripts/probe_sandbox_safety.py +311 -0
  116. package/skills/prototype/scripts/prototype_scripts_constants/__init__.py +1 -0
  117. package/skills/prototype/scripts/prototype_scripts_constants/config/__init__.py +0 -0
  118. package/skills/prototype/scripts/prototype_scripts_constants/config/build_sandbox_settings_constants.py +41 -0
  119. package/skills/prototype/scripts/prototype_scripts_constants/config/launch_sandbox_constants.py +23 -0
  120. package/skills/prototype/scripts/prototype_scripts_constants/config/probe_sandbox_safety_constants.py +45 -0
  121. package/skills/prototype/scripts/prototype_scripts_constants/config/prototype_common_constants.py +10 -0
  122. package/skills/prototype/scripts/test_build_sandbox_settings.py +275 -0
  123. package/skills/prototype/scripts/test_launch_sandbox.py +303 -0
  124. package/skills/prototype/scripts/test_probe_sandbox_safety.py +284 -0
  125. package/skills/prototype/workflows/promotion.md +27 -0
  126. package/skills/prototype/workflows/sandbox.md +35 -0
  127. package/skills/skill-builder/CLAUDE.md +3 -3
  128. package/skills/skill-builder/SKILL.md +5 -5
  129. package/skills/skill-builder/references/CLAUDE.md +1 -1
  130. package/skills/skill-builder/references/delegation-map.md +3 -3
  131. package/skills/skill-builder/references/description-field.md +1 -1
  132. package/skills/skill-builder/references/skill-modularity.md +2 -3
  133. package/skills/skill-builder/workflows/CLAUDE.md +1 -1
  134. package/skills/skill-builder/workflows/improve-skill.md +1 -1
  135. package/skills/skill-builder/workflows/new-skill.md +2 -2
  136. 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. Spawn the code-verifier agent (Agent tool, "
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 surface content (every changed "
146
- "and untracked file's bytes), not to a work-tree path, so a commit "
147
- "from a different work tree passes only when its surface is "
148
- "byte-identical; a work tree carrying any extra or later edit has a "
149
- "surface no verdict covers. To land this change, run this command "
150
- "from the work tree that holds the verified surface, or verify this "
151
- "surface here. Exempt automatically: docs/image files, pytest test "
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
- A full PR URL in the command yields all three. A --repo/-R flag yields
71
- the repository while the PR number resolves from the command number or
72
- the cwd. With neither present, both the repository and the number
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
- ready_segment = _ready_command_segment(command)
76
- pr_url_identity = _parse_pr_url(ready_segment)
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
- repo_flag_identity = _parse_repo_flag(ready_segment)
85
- if repo_flag_identity is not None:
86
- flag_owner, flag_repo = repo_flag_identity
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 _resolve_pr_number(command: str, cwd: str | None) -> int | None:
97
- direct_match = re.search(r"\bgh\s+pr\s+ready\s+(\d+)", command)
98
- if direct_match:
99
- return int(direct_match.group(1))
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
- ["gh", "pr", "view", "--json", "number", "--jq", ".number"],
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 GH_REDIRECT_ACTIVE_TRUTHY_VALUES
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
- ask_response = {
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": "ask",
2050
- "permissionDecisionReason": f"DESTRUCTIVE: {matched_description}. Requires explicit user approval."
2062
+ "permissionDecision": terminal_decision,
2063
+ "permissionDecisionReason": terminal_reason
2051
2064
  }
2052
2065
  }
2053
- print(json.dumps(ask_response))
2066
+ print(json.dumps(terminal_response))
2054
2067
 
2055
2068
  sys.exit(0)
2056
2069