claude-dev-env 2.11.0 → 2.13.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 (203) hide show
  1. package/_shared/advisor/reference/sol-rung.md +5 -2
  2. package/_shared/advisor/reference/warm-up.md +1 -0
  3. package/_shared/advisor/scripts/codex_sol_advisor.py +96 -31
  4. package/_shared/advisor/scripts/config/advisor_scripts_constants/sol_advisor_constants.py +5 -0
  5. package/_shared/advisor/scripts/tests/test_codex_sol_advisor.py +173 -11
  6. package/_shared/pr-loop/scripts/code_rules_gate.py +2 -3
  7. package/_shared/pr-loop/scripts/test_code_rules_gate.py +0 -26
  8. package/agents/CLAUDE.md +1 -2
  9. package/agents/pr-description-writer.md +11 -81
  10. package/bin/install.mjs +48 -9
  11. package/bin/install.prune.test.mjs +26 -0
  12. package/bin/install.settings-defaults.test.mjs +60 -33
  13. package/bin/install.test.mjs +2 -4
  14. package/commands/CLAUDE.md +1 -0
  15. package/commands/sr-loop.md +52 -0
  16. package/docs/CLAUDE.md +0 -1
  17. package/docs/references/CLAUDE.md +0 -1
  18. package/hooks/CLAUDE.md +1 -1
  19. package/hooks/blocking/CLAUDE.md +2 -27
  20. package/hooks/blocking/config/CLAUDE.md +1 -13
  21. package/hooks/blocking/conventional_pr_title_gate.py +1 -2
  22. package/hooks/blocking/send_user_file_open_locally_blocker.py +1 -1
  23. package/hooks/blocking/stop_dispatcher.py +4 -4
  24. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +0 -3
  25. package/hooks/blocking/test_pre_tool_use_dispatcher.py +6 -7
  26. package/hooks/blocking/test_send_user_file_open_locally_blocker.py +4 -3
  27. package/hooks/blocking/test_shared_stdin_adoption.py +0 -2
  28. package/hooks/diagnostic/CLAUDE.md +3 -3
  29. package/hooks/diagnostic/hook_log_extractor.py +2 -36
  30. package/hooks/diagnostic/hook_log_stop_wrapper.py +6 -155
  31. package/hooks/diagnostic/test_hook_log_extractor.py +8 -21
  32. package/hooks/diagnostic/test_hook_log_stop_wrapper.py +3 -331
  33. package/hooks/git-hooks/git_hooks_constants/__init__.py +0 -6
  34. package/hooks/git-hooks/pre_push.py +3 -85
  35. package/hooks/git-hooks/test_pre_push.py +0 -130
  36. package/hooks/hooks.json +0 -35
  37. package/hooks/hooks_constants/CLAUDE.md +0 -9
  38. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +0 -11
  39. package/hooks/hooks_constants/convergence_branch_constants.py +0 -1
  40. package/hooks/hooks_constants/fable_spawn_gate_constants.py +3 -4
  41. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +0 -8
  42. package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +1 -6
  43. package/hooks/hooks_constants/stop_dispatcher_constants.py +0 -1
  44. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +1 -26
  45. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +0 -5
  46. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +0 -1
  47. package/hooks/lifecycle/CLAUDE.md +1 -3
  48. package/package.json +1 -1
  49. package/rules/CLAUDE.md +0 -2
  50. package/rules/gh-cli-conventions.md +0 -1
  51. package/rules/git-workflow.md +1 -3
  52. package/scripts/CLAUDE.md +0 -1
  53. package/scripts/_code_review_test_support.py +0 -95
  54. package/scripts/dev_env_scripts_constants/code_review_constants.py +9 -98
  55. package/scripts/dev_env_scripts_constants/test_code_review_constants.py +1 -44
  56. package/scripts/invoke_code_review.py +8 -451
  57. package/scripts/test_invoke_code_review.py +5 -61
  58. package/scripts/test_invoke_code_review_cli.py +1 -45
  59. package/scripts/test_invoke_code_review_contract.py +1 -118
  60. package/settings.json +1 -10
  61. package/skills/CLAUDE.md +8 -0
  62. package/skills/autoconverge/reference/CLAUDE.md +1 -1
  63. package/skills/autoconverge/reference/convergence.md +7 -7
  64. package/skills/autoconverge/reference/gotchas.md +3 -10
  65. package/skills/autoconverge/workflow/converge.contract.test.mjs +53 -1388
  66. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +4 -3
  67. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +33 -493
  68. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +15 -97
  69. package/skills/autoconverge/workflow/converge.mjs +201 -238
  70. package/skills/autoconverge/workflow/converge.precatch.test.mjs +1 -1
  71. package/skills/bugteam/CONSTRAINTS.md +1 -1
  72. package/skills/bugteam/SKILL.md +9 -0
  73. package/skills/bugteam/reference/audit-and-teammates.md +10 -6
  74. package/skills/bugteam/test_skill_additions.py +9 -0
  75. package/skills/comments/SKILL.md +65 -0
  76. package/skills/descriptions/SKILL.md +51 -0
  77. package/skills/emergencies/SKILL.md +42 -0
  78. package/skills/imagegen/SKILL.md +21 -0
  79. package/skills/imagegen/scripts/config/__init__.py +1 -0
  80. package/skills/imagegen/scripts/config/constants.py +28 -0
  81. package/skills/imagegen/scripts/imagegen.py +45 -0
  82. package/skills/imagegen/scripts/imagegen_core.py +528 -0
  83. package/skills/imagegen/scripts/test_imagegen.py +204 -0
  84. package/skills/orchestrator/SKILL.md +2 -4
  85. package/skills/plan-to-pr/SKILL.md +6 -7
  86. package/skills/plan-to-pr/reference/final-validation-tasks.md +1 -1
  87. package/skills/plan-to-pr/reference/process-inventory.md +2 -2
  88. package/skills/plan-to-pr/reference/review-loop.md +2 -2
  89. package/skills/plan-to-pr/reference/run-record.schema.json +1 -1
  90. package/skills/plan-to-pr/reference/task-seeds.md +1 -1
  91. package/skills/plan-to-pr/reference/task-ticket.md +3 -4
  92. package/skills/plan-to-pr/scripts/config/constants.py +1 -2
  93. package/skills/plan-to-pr/scripts/test_validate_protocol.py +1 -1
  94. package/skills/plan-to-pr/test_skill_contract.py +2 -2
  95. package/skills/pr-converge/SKILL.md +13 -16
  96. package/skills/pr-converge/reference/progress-checklist.md +6 -5
  97. package/skills/pr-converge/reference/state-schema.md +1 -27
  98. package/skills/pr-loop-cloud-transport/SKILL.md +1 -1
  99. package/skills/pr-loop-cloud-transport/reference/identity-and-hooks.md +3 -3
  100. package/skills/pr-loop-lifecycle/SKILL.md +1 -1
  101. package/skills/pr-loop-lifecycle/reference/teardown-publish-permissions.md +1 -1
  102. package/skills/prototype/SKILL.md +4 -4
  103. package/skills/prototype/reference/honest-limitations.md +4 -4
  104. package/skills/prototype/reference/promotion-tasks.md +2 -2
  105. package/skills/prototype/workflows/promotion.md +4 -6
  106. package/skills/reviews/SKILL.md +85 -0
  107. package/skills/small-cl/SKILL.md +52 -0
  108. package/skills/usage-pause/SKILL.md +1 -1
  109. package/skills/usage-pause/scripts/resolve_usage_window.py +32 -4
  110. package/skills/usage-pause/scripts/test_resolve_usage_window.py +26 -0
  111. package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +3 -1
  112. package/agents/code-verifier.md +0 -74
  113. package/docs/PR_DESCRIPTION_GUIDE.md +0 -157
  114. package/docs/references/code-review-enforcement.md +0 -132
  115. package/hooks/blocking/code_review_enforcement_config_bootstrap.py +0 -53
  116. package/hooks/blocking/code_review_gate_deny.py +0 -74
  117. package/hooks/blocking/code_review_pr_create_gate.py +0 -198
  118. package/hooks/blocking/code_review_push_gate.py +0 -145
  119. package/hooks/blocking/code_review_stamp_directory_write_blocker.py +0 -348
  120. package/hooks/blocking/code_review_stamp_store.py +0 -233
  121. package/hooks/blocking/code_review_stamp_write_blocker_parts/__init__.py +0 -7
  122. package/hooks/blocking/code_review_stamp_write_blocker_parts/conftest.py +0 -15
  123. package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py +0 -212
  124. package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py +0 -138
  125. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py +0 -49
  126. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_split_directory_change_into_stamp.py +0 -38
  127. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +0 -483
  128. package/hooks/blocking/config/code_review_enforcement_constants.py +0 -143
  129. package/hooks/blocking/config/test_code_review_enforcement_constants.py +0 -166
  130. package/hooks/blocking/config/verified_commit_constants.py +0 -160
  131. package/hooks/blocking/config/verified_commit_context_constants.py +0 -21
  132. package/hooks/blocking/config/verified_commit_gate_output_constants.py +0 -14
  133. package/hooks/blocking/conftest.py +0 -123
  134. package/hooks/blocking/convergence_gate_blocker.py +0 -310
  135. package/hooks/blocking/pr_converge_bugteam_enforcer.py +0 -170
  136. package/hooks/blocking/pr_description_body_audit.py +0 -148
  137. package/hooks/blocking/pr_description_command_parser.py +0 -237
  138. package/hooks/blocking/pr_description_enforcer.py +0 -227
  139. package/hooks/blocking/pr_description_pr_number.py +0 -155
  140. package/hooks/blocking/pr_description_proof_of_work.py +0 -385
  141. package/hooks/blocking/pr_description_readability.py +0 -364
  142. package/hooks/blocking/reviewer_spawn_gate.py +0 -182
  143. package/hooks/blocking/test_code_review_enforcement_config_bootstrap.py +0 -62
  144. package/hooks/blocking/test_code_review_gate_deny.py +0 -54
  145. package/hooks/blocking/test_code_review_pr_create_gate.py +0 -199
  146. package/hooks/blocking/test_code_review_push_gate.py +0 -205
  147. package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +0 -199
  148. package/hooks/blocking/test_code_review_stamp_store.py +0 -205
  149. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +0 -710
  150. package/hooks/blocking/test_code_verifier_tools_contract.py +0 -28
  151. package/hooks/blocking/test_convergence_gate_blocker.py +0 -428
  152. package/hooks/blocking/test_pr_converge_bugteam_enforcer.py +0 -311
  153. package/hooks/blocking/test_pr_converge_bugteam_enforcer_state_tolerance.py +0 -184
  154. package/hooks/blocking/test_pr_description_enforcer.py +0 -221
  155. package/hooks/blocking/test_pr_description_enforcer_body_audit.py +0 -247
  156. package/hooks/blocking/test_pr_description_enforcer_body_rules.py +0 -493
  157. package/hooks/blocking/test_pr_description_enforcer_command_parser.py +0 -366
  158. package/hooks/blocking/test_pr_description_enforcer_pr_number.py +0 -159
  159. package/hooks/blocking/test_pr_description_enforcer_proof_gate.py +0 -175
  160. package/hooks/blocking/test_pr_description_enforcer_readability.py +0 -443
  161. package/hooks/blocking/test_pr_description_proof_of_work.py +0 -313
  162. package/hooks/blocking/test_reviewer_spawn_gate.py +0 -230
  163. package/hooks/blocking/test_verdict_directory_write_blocker.py +0 -804
  164. package/hooks/blocking/test_verification_verdict_store.py +0 -974
  165. package/hooks/blocking/test_verified_commit_config_bootstrap.py +0 -67
  166. package/hooks/blocking/test_verified_commit_docs_delta.py +0 -176
  167. package/hooks/blocking/test_verified_commit_gate.py +0 -581
  168. package/hooks/blocking/test_verified_commit_gate_additional_context.py +0 -134
  169. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +0 -131
  170. package/hooks/blocking/test_verifier_verdict_minter.py +0 -299
  171. package/hooks/blocking/tests/test_verified_commit_gate.py +0 -41
  172. package/hooks/blocking/verdict_directory_write_blocker.py +0 -687
  173. package/hooks/blocking/verification_verdict_store.py +0 -1039
  174. package/hooks/blocking/verified_commit_config_bootstrap.py +0 -63
  175. package/hooks/blocking/verified_commit_gate.py +0 -113
  176. package/hooks/blocking/verified_commit_gate_parts/CLAUDE.md +0 -28
  177. package/hooks/blocking/verified_commit_gate_parts/__init__.py +0 -1
  178. package/hooks/blocking/verified_commit_gate_parts/command_tokenization.py +0 -174
  179. package/hooks/blocking/verified_commit_gate_parts/deny_payload.py +0 -53
  180. package/hooks/blocking/verified_commit_gate_parts/deny_reason.py +0 -80
  181. package/hooks/blocking/verified_commit_gate_parts/directory_resolution.py +0 -170
  182. package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +0 -217
  183. package/hooks/blocking/verified_commit_gate_parts/tests/conftest.py +0 -10
  184. package/hooks/blocking/verified_commit_gate_parts/tests/test_command_tokenization.py +0 -94
  185. package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_payload.py +0 -17
  186. package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_reason.py +0 -38
  187. package/hooks/blocking/verified_commit_gate_parts/tests/test_directory_resolution.py +0 -71
  188. package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +0 -96
  189. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +0 -167
  190. package/hooks/blocking/verifier_verdict_minter.py +0 -280
  191. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +0 -64
  192. package/hooks/hooks_constants/convergence_gate_blocker_constants.py +0 -53
  193. package/hooks/hooks_constants/pr_converge_bugteam_enforcer_constants.py +0 -55
  194. package/hooks/hooks_constants/pr_converge_bugteam_enforcer_state.py +0 -67
  195. package/hooks/hooks_constants/pr_description_enforcer_constants.py +0 -167
  196. package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +0 -107
  197. package/hooks/hooks_constants/reviewer_spawn_gate_constants.py +0 -41
  198. package/hooks/hooks_constants/test_pr_description_enforcer_constants.py +0 -292
  199. package/hooks/lifecycle/pr_converge_bugteam_skill_tracker.py +0 -198
  200. package/hooks/lifecycle/test_pr_converge_bugteam_skill_tracker.py +0 -283
  201. package/rules/proof-of-work-pr-comments.md +0 -22
  202. package/rules/verified-commit-gate-skip.md +0 -4
  203. package/scripts/Show-Asset.ps1 +0 -106
@@ -1,483 +0,0 @@
1
- #!/usr/bin/env python3
2
- """PreToolUse hook: pre-flight gate for the code-verifier subagent spawn.
3
-
4
- The hook fires on an ``Agent`` or ``Task`` tool call whose ``subagent_type`` is
5
- ``code-verifier``. Before that verification spawn runs, the hook checks the
6
- branch for two committability problems against the resolved base ref: a real
7
- merge conflict (a non-mutating trial-merge of HEAD against the base ref) and a
8
- CODE_RULES violation on a line added in the working tree since the merge base
9
- (committed on the branch or uncommitted). When either fires, the hook denies
10
- the spawn with a reason addressed to the spawning agent that names the
11
- conflicting files and the violating file:line, so that agent fixes them and
12
- re-spawns. A CODE_RULES engine import/load failure is fail-closed (deny with a
13
- named load-failure section). Environmental problems — a non-repo cwd, an
14
- absent base ref, a git failure, an unreadable file alone, or a timeout — fail
15
- OPEN because the authoritative fail-closed CODE_RULES enforcement already runs
16
- at Write time and at commit time. The hook never network-fetches and never
17
- mutates the index or working tree.
18
- """
19
-
20
- from __future__ import annotations
21
-
22
- import contextlib
23
- import io
24
- import json
25
- import re
26
- import subprocess
27
- import sys
28
- from pathlib import Path
29
- from typing import TextIO
30
-
31
- _blocking_dir = str(Path(__file__).resolve().parent)
32
- if _blocking_dir not in sys.path:
33
- sys.path.insert(0, _blocking_dir)
34
-
35
- _hooks_dir = str(Path(__file__).resolve().parent.parent)
36
- if _hooks_dir not in sys.path:
37
- sys.path.append(_hooks_dir)
38
-
39
- from verification_verdict_store import ( # noqa: E402
40
- candidate_base_references,
41
- resolve_merge_base,
42
- resolve_repo_root,
43
- run_git,
44
- untracked_file_paths,
45
- )
46
-
47
- from hooks_constants.code_verifier_spawn_preflight_gate_constants import ( # noqa: E402
48
- ALL_CODE_VERIFIER_SPAWN_TOOL_NAMES,
49
- ALL_MERGE_HEAD_PROBE_FLAGS,
50
- ALL_MERGE_TREE_COMMAND_FLAGS,
51
- ALL_NAME_ONLY_WORKTREE_DIFF_FLAGS,
52
- ALL_UNIFIED_ZERO_DIFF_FLAGS,
53
- ALL_UNMERGED_PATHS_DIFF_FLAGS,
54
- CODE_RULES_SECTION_HEADER,
55
- CODE_VERIFIER_SUBAGENT_TYPE,
56
- DENY_REASON_LEAD,
57
- ENGINE_LOAD_FAILURE_SECTION,
58
- GATE_SCRIPTS_RELATIVE_PATH,
59
- MERGE_CONFLICT_SECTION_HEADER,
60
- MERGE_TREE_CLEAN_EXIT_CODE,
61
- MERGE_TREE_CONFLICT_EXIT_CODE,
62
- MERGE_TREE_TIMEOUT_SECONDS,
63
- )
64
- from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
65
-
66
- _scripts_dir = str(Path(__file__).resolve().parents[2] / GATE_SCRIPTS_RELATIVE_PATH)
67
- if _scripts_dir not in sys.path:
68
- sys.path.insert(0, _scripts_dir)
69
-
70
- from code_rules_gate import ( # noqa: E402
71
- ValidateContentCallable,
72
- _collect_partitioned_violations,
73
- _report_partitioned_violations,
74
- is_code_path,
75
- load_validate_content,
76
- parse_added_line_numbers,
77
- whole_file_line_set,
78
- )
79
-
80
-
81
- def _should_run(payload_by_field: dict[str, object]) -> bool:
82
- """Return True only for a code-verifier Agent or Task spawn.
83
-
84
- Args:
85
- payload_by_field: The full PreToolUse hook payload (already
86
- JSON-parsed), keyed by top-level field name.
87
-
88
- Returns:
89
- True when the tool is Agent or Task and ``tool_input.subagent_type``
90
- is ``code-verifier``; False for every other shape.
91
- """
92
- if payload_by_field.get("tool_name", "") not in ALL_CODE_VERIFIER_SPAWN_TOOL_NAMES:
93
- return False
94
- tool_input = payload_by_field.get("tool_input", {})
95
- if not isinstance(tool_input, dict):
96
- return False
97
- return tool_input.get("subagent_type", "") == CODE_VERIFIER_SUBAGENT_TYPE
98
-
99
-
100
- def _resolve_repo_root_and_base(working_directory: str | None) -> tuple[str, str, str] | None:
101
- """Resolve the repo root, merge-base sha, and chosen base ref.
102
-
103
- Args:
104
- working_directory: The spawn's working directory from the payload, or
105
- None when the payload carries no ``cwd``.
106
-
107
- Returns:
108
- A ``(repo_root, merge_base_sha, base_ref)`` triple, or None when the
109
- directory is not a work tree or no base ref resolves on disk — the
110
- caller fails OPEN on None.
111
- """
112
- start_directory = working_directory if working_directory else str(Path.cwd())
113
- repo_root = resolve_repo_root(start_directory)
114
- if repo_root is None:
115
- return None
116
- merge_base_sha = resolve_merge_base(repo_root)
117
- if merge_base_sha is None:
118
- return None
119
- for each_reference in candidate_base_references(repo_root):
120
- if run_git(repo_root, "merge-base", "HEAD", each_reference) is not None:
121
- return repo_root, merge_base_sha, each_reference
122
- return None
123
-
124
-
125
- def _run_trial_merge(repo_root: str, base_ref: str) -> tuple[int, str] | None:
126
- """Run the non-mutating trial-merge and return its exit code and stdout.
127
-
128
- Args:
129
- repo_root: The repository top-level directory.
130
- base_ref: The base ref to trial-merge HEAD against.
131
-
132
- Returns:
133
- A ``(returncode, stdout)`` pair, or None when the command is missing,
134
- times out, or raises an OS-level error — the caller fails OPEN on None.
135
- """
136
- try:
137
- completed_process = subprocess.run(
138
- ["git", "-C", repo_root, *ALL_MERGE_TREE_COMMAND_FLAGS, base_ref, "HEAD"],
139
- capture_output=True,
140
- text=True,
141
- encoding="utf-8",
142
- errors="replace",
143
- timeout=MERGE_TREE_TIMEOUT_SECONDS,
144
- check=False,
145
- )
146
- except (OSError, subprocess.TimeoutExpired):
147
- return None
148
- return completed_process.returncode, completed_process.stdout
149
-
150
-
151
- def _merge_in_progress(repo_root: str) -> bool:
152
- """Decide whether an unfinished merge is recorded in the work tree.
153
-
154
- Args:
155
- repo_root: The repository top-level directory.
156
-
157
- Returns:
158
- True when ``MERGE_HEAD`` resolves — a merge is started but not yet
159
- committed; False when no merge is in progress or git fails.
160
- """
161
- return run_git(repo_root, *ALL_MERGE_HEAD_PROBE_FLAGS) is not None
162
-
163
-
164
- def _staged_merge_conflicts(repo_root: str) -> list[str] | None:
165
- """Return the unmerged paths of an in-progress merge from the index.
166
-
167
- A merge whose conflicts are fully resolved and staged leaves zero unmerged
168
- index entries, so the resolved state is committable and this returns an
169
- empty list. A merge with conflicts still open returns those paths, so the
170
- caller keeps denying until the resolution is staged.
171
-
172
- Args:
173
- repo_root: The repository top-level directory.
174
-
175
- Returns:
176
- The still-unmerged paths (empty when the resolution is fully staged),
177
- or None when git fails — the caller fails OPEN on None.
178
- """
179
- unmerged_text = run_git(repo_root, *ALL_UNMERGED_PATHS_DIFF_FLAGS)
180
- if unmerged_text is None:
181
- return None
182
- return [each_line for each_line in unmerged_text.splitlines() if each_line]
183
-
184
-
185
- def _conflicting_files(repo_root: str, base_ref: str) -> list[str] | None:
186
- """Return the files that conflict when HEAD trial-merges against the base.
187
-
188
- While a merge is in progress the branch tip has not yet recorded the
189
- resolution, so the trial-merge of the base against HEAD would re-report
190
- conflicts the staged index already fixed. In that state the index is the
191
- authority: an empty unmerged set is a committable resolution, and any
192
- remaining unmerged path is a live conflict.
193
-
194
- Args:
195
- repo_root: The repository top-level directory.
196
- base_ref: The base ref to trial-merge HEAD against.
197
-
198
- Returns:
199
- The conflicting file paths on a conflict exit, an empty list on a
200
- clean merge, or None on any infrastructure failure (command missing,
201
- timeout, or an exit code that is neither clean nor conflict) — the
202
- caller fails OPEN on None.
203
- """
204
- if _merge_in_progress(repo_root):
205
- return _staged_merge_conflicts(repo_root)
206
- merge_outcome = _run_trial_merge(repo_root, base_ref)
207
- if merge_outcome is None:
208
- return None
209
- return_code, merge_stdout = merge_outcome
210
- if return_code == MERGE_TREE_CLEAN_EXIT_CODE:
211
- return []
212
- if return_code != MERGE_TREE_CONFLICT_EXIT_CODE:
213
- return None
214
- return _parse_conflicting_paths(merge_stdout)
215
-
216
-
217
- def _parse_conflicting_paths(merge_stdout: str) -> list[str]:
218
- """Extract conflicting paths from trial-merge stdout.
219
-
220
- Args:
221
- merge_stdout: The stdout of a conflict-exit trial-merge: the written
222
- tree-OID line, then one conflicting path per line, then a blank
223
- line and informational text.
224
-
225
- Returns:
226
- The conflicting file paths — the lines after the tree-OID up to the
227
- first blank line.
228
- """
229
- all_lines = merge_stdout.splitlines()
230
- conflicting_paths: list[str] = []
231
- for each_line in all_lines[1:]:
232
- if not each_line.strip():
233
- break
234
- conflicting_paths.append(each_line)
235
- return conflicting_paths
236
-
237
-
238
- def _working_tree_added_lines_by_path(
239
- repo_root: str, merge_base_sha: str
240
- ) -> tuple[list[Path], dict[Path, set[int]]] | None:
241
- """Build the code-file surface and its working-tree-vs-merge-base added lines.
242
-
243
- Args:
244
- repo_root: The repository top-level directory.
245
- merge_base_sha: The merge-base sha the surface diffs against.
246
-
247
- Returns:
248
- A ``(file_paths, added_lines_by_path)`` pair keyed by resolved
249
- absolute path, or None when a surface git query fails — the caller
250
- fails OPEN on None.
251
- """
252
- tracked_changed_text = run_git(repo_root, *ALL_NAME_ONLY_WORKTREE_DIFF_FLAGS, merge_base_sha)
253
- if tracked_changed_text is None:
254
- return None
255
- untracked_paths = untracked_file_paths(repo_root)
256
- if untracked_paths is None:
257
- return None
258
- repo_root_path = Path(repo_root)
259
- file_paths: list[Path] = []
260
- added_lines_by_path: dict[Path, set[int]] = {}
261
- for each_relative in tracked_changed_text.splitlines():
262
- if not each_relative or not is_code_path(Path(each_relative)):
263
- continue
264
- resolved_path = (repo_root_path / each_relative).resolve()
265
- file_paths.append(resolved_path)
266
- added_lines_by_path[resolved_path] = _tracked_file_added_lines(
267
- repo_root, merge_base_sha, each_relative
268
- )
269
- for each_relative in untracked_paths:
270
- if not is_code_path(Path(each_relative)):
271
- continue
272
- resolved_path = (repo_root_path / each_relative).resolve()
273
- file_paths.append(resolved_path)
274
- added_lines_by_path[resolved_path] = whole_file_line_set(resolved_path)
275
- return file_paths, added_lines_by_path
276
-
277
-
278
- def _tracked_file_added_lines(repo_root: str, merge_base_sha: str, relative_path: str) -> set[int]:
279
- """Return the working-tree-added line numbers for one tracked file.
280
-
281
- Args:
282
- repo_root: The repository top-level directory.
283
- merge_base_sha: The merge-base sha the diff runs against.
284
- relative_path: The repo-relative path of the tracked changed file.
285
-
286
- Returns:
287
- The 1-indexed line numbers added vs the merge base in the working
288
- tree, or an empty set when the per-file diff fails.
289
- """
290
- unified_diff_text = run_git(
291
- repo_root, *ALL_UNIFIED_ZERO_DIFF_FLAGS, merge_base_sha, "--", relative_path
292
- )
293
- if unified_diff_text is None:
294
- return set()
295
- return parse_added_line_numbers(unified_diff_text)
296
-
297
-
298
- def _code_rules_report(
299
- repo_root: str, all_file_paths: list[Path], all_added_lines_by_path: dict[Path, set[int]]
300
- ) -> str | None:
301
- """Run the CODE_RULES engine and return a full deny section, or None.
302
-
303
- Args:
304
- repo_root: The repository top-level directory.
305
- all_file_paths: The resolved code-file paths to inspect.
306
- all_added_lines_by_path: Per-file working-tree-added line numbers keyed
307
- by resolved absolute path.
308
-
309
- Returns:
310
- A full deny section when a blocking violation lands on an added line or
311
- the CODE_RULES engine fails to import/load (fail-closed). None when the
312
- surface is clean, only an unreadable changed file caused a non-zero gate
313
- exit, or an environmental engine error arises — those non-block outcomes
314
- fail OPEN. The harness hook timeout in hooks.json is the wall-clock
315
- bound on a runaway engine.
316
- """
317
- if not all_file_paths:
318
- return None
319
- try:
320
- validate_content = load_validate_content()
321
- except SystemExit:
322
- return ENGINE_LOAD_FAILURE_SECTION
323
- except (
324
- ImportError,
325
- SyntaxError,
326
- AttributeError,
327
- RuntimeError,
328
- TypeError,
329
- NameError,
330
- OSError,
331
- re.error,
332
- ):
333
- return ENGINE_LOAD_FAILURE_SECTION
334
- try:
335
- blocking_present, captured_report = _run_gate_capturing_stderr(
336
- validate_content, all_file_paths, Path(repo_root), all_added_lines_by_path
337
- )
338
- except OSError:
339
- return None
340
- if not blocking_present:
341
- return None
342
- return f"{CODE_RULES_SECTION_HEADER}\n{captured_report.strip()}"
343
-
344
-
345
- def _run_gate_capturing_stderr(
346
- validate_content: ValidateContentCallable,
347
- all_file_paths: list[Path],
348
- repository_root: Path,
349
- all_added_lines_by_path: dict[Path, set[int]],
350
- ) -> tuple[bool, str]:
351
- """Run the gate, reporting whether a blocking violation was actually found.
352
-
353
- Args:
354
- validate_content: The enforcer ``validate_content`` callable.
355
- all_file_paths: The resolved code-file paths to inspect.
356
- repository_root: The repository root path the gate resolves against.
357
- all_added_lines_by_path: Per-file working-tree-added line numbers keyed
358
- by resolved absolute path.
359
-
360
- Returns:
361
- A ``(blocking_present, captured_report)`` pair. ``blocking_present`` is
362
- True only when at least one blocking violation landed on an added line;
363
- an unreadable changed file alone (which exits the gate non-zero) leaves
364
- it False, so the caller fails OPEN. ``captured_report`` is the gate's
365
- grouped stderr report.
366
- """
367
- blocking_by_file, advisory_by_file, skipped_unreadable_count = (
368
- _collect_partitioned_violations(
369
- validate_content,
370
- all_file_paths,
371
- repository_root,
372
- all_added_lines_by_path,
373
- False,
374
- )
375
- )
376
- captured_stderr = io.StringIO()
377
- with contextlib.redirect_stderr(captured_stderr):
378
- _report_partitioned_violations(
379
- blocking_by_file,
380
- advisory_by_file,
381
- repository_root,
382
- False,
383
- skipped_unreadable_count,
384
- )
385
- return bool(blocking_by_file), captured_stderr.getvalue()
386
-
387
-
388
- def _build_deny_reason(
389
- all_conflicting_files: list[str] | None, base_ref: str, code_rules_report: str | None
390
- ) -> str | None:
391
- """Assemble the spawner-addressed deny reason from the two check results.
392
-
393
- Args:
394
- all_conflicting_files: The conflicting file paths from the conflict
395
- check, an empty list when clean, or None when that check failed open.
396
- base_ref: The base ref named in the conflict section header.
397
- code_rules_report: A full CODE_RULES deny section (violations or engine
398
- load failure), or None when that check found nothing or failed open.
399
-
400
- Returns:
401
- The full deny reason when either check fired, or None when neither
402
- produced an issue.
403
- """
404
- reason_sections: list[str] = []
405
- if all_conflicting_files:
406
- conflict_lines = "\n".join(f" {each_path}" for each_path in all_conflicting_files)
407
- conflict_header = MERGE_CONFLICT_SECTION_HEADER.format(base_ref=base_ref)
408
- reason_sections.append(f"{conflict_header}\n{conflict_lines}")
409
- if code_rules_report:
410
- reason_sections.append(code_rules_report.strip())
411
- if not reason_sections:
412
- return None
413
- return DENY_REASON_LEAD + "\n\n" + "\n\n".join(reason_sections)
414
-
415
-
416
- def _emit_deny_payload(output_stream: TextIO, reason: str) -> None:
417
- """Write the PreToolUse deny payload to the provided stream.
418
-
419
- Args:
420
- output_stream: Writable text stream — production code passes
421
- ``sys.stdout``; tests pass a ``StringIO`` to capture the JSON.
422
- reason: The ``permissionDecisionReason`` text for the deny payload.
423
- """
424
- deny_payload = {
425
- "hookSpecificOutput": {
426
- "hookEventName": "PreToolUse",
427
- "permissionDecision": "deny",
428
- "permissionDecisionReason": reason,
429
- }
430
- }
431
- log_hook_block(
432
- calling_hook_name="code_verifier_spawn_preflight_gate.py",
433
- hook_event="PreToolUse",
434
- block_reason=reason,
435
- )
436
- output_stream.write(json.dumps(deny_payload) + "\n")
437
- output_stream.flush()
438
-
439
-
440
- def _preflight_deny_reason(payload_by_field: dict[str, object]) -> str | None:
441
- """Run both pre-flight checks and return a deny reason, or None to allow.
442
-
443
- Args:
444
- payload_by_field: The full PreToolUse hook payload, keyed by top-level
445
- field name.
446
-
447
- Returns:
448
- The deny reason when a check fired, or None when both checks pass or
449
- fail open.
450
- """
451
- working_directory = payload_by_field.get("cwd")
452
- resolution = _resolve_repo_root_and_base(
453
- working_directory if isinstance(working_directory, str) else None
454
- )
455
- if resolution is None:
456
- return None
457
- repo_root, merge_base_sha, base_ref = resolution
458
- conflicting_files = _conflicting_files(repo_root, base_ref)
459
- surface = _working_tree_added_lines_by_path(repo_root, merge_base_sha)
460
- code_rules_report = None
461
- if surface is not None:
462
- file_paths, added_lines_by_path = surface
463
- code_rules_report = _code_rules_report(repo_root, file_paths, added_lines_by_path)
464
- return _build_deny_reason(conflicting_files, base_ref, code_rules_report)
465
-
466
-
467
- def main() -> None:
468
- try:
469
- hook_payload = json.load(sys.stdin)
470
- except json.JSONDecodeError:
471
- sys.exit(0)
472
- if not isinstance(hook_payload, dict):
473
- sys.exit(0)
474
- if not _should_run(hook_payload):
475
- sys.exit(0)
476
- deny_reason = _preflight_deny_reason(hook_payload)
477
- if deny_reason is not None:
478
- _emit_deny_payload(sys.stdout, deny_reason)
479
- sys.exit(0)
480
-
481
-
482
- if __name__ == "__main__":
483
- main()
@@ -1,143 +0,0 @@
1
- """Single source of truth for the code-review enforcement gate family.
2
-
3
- ::
4
-
5
- CLAUDE_CODE_REVIEW_ENFORCEMENT=1 -> CODE_REVIEW_ENFORCEMENT_ENABLED True
6
- (unset) -> CODE_REVIEW_ENFORCEMENT_ENABLED False
7
- effort_meets_threshold("xhigh", PUSH_REQUIRED_EFFORT) -> True
8
- effort_meets_threshold("high", PR_CREATE_REQUIRED_EFFORT) -> False
9
-
10
- Every gate reads its thresholds and messages from here, so the effort a push
11
- needs and the effort a pull request needs never drift apart. The master flag
12
- tracks the environment, so a machine opts in through its own environment and
13
- the choice outlives a reinstall that rewrites this shipped file.
14
- """
15
-
16
- from __future__ import annotations
17
-
18
- import os
19
-
20
- CODE_REVIEW_ENFORCEMENT_ENV_VAR = "CLAUDE_CODE_REVIEW_ENFORCEMENT"
21
- ALL_ENFORCEMENT_ENABLED_ENV_VALUES = frozenset({"1", "true", "yes", "on"})
22
-
23
-
24
- def code_review_enforcement_enabled_in_environment() -> bool:
25
- """Read whether this machine turns the code-review gates on.
26
-
27
- ::
28
-
29
- CLAUDE_CODE_REVIEW_ENFORCEMENT=1 -> True
30
- CLAUDE_CODE_REVIEW_ENFORCEMENT=" On " -> True
31
- CLAUDE_CODE_REVIEW_ENFORCEMENT=0 -> False
32
- (variable unset) -> False
33
-
34
- A machine opts in through its own environment, so an install that rewrites
35
- this shipped file leaves the choice standing. Any value outside the enabled
36
- set, and an unset variable, read as off, so the gate family stays quiet
37
- until someone asks for it.
38
-
39
- Returns:
40
- True when the variable holds an enabled value, False otherwise.
41
- """
42
- raw_environment_setting = os.environ.get(CODE_REVIEW_ENFORCEMENT_ENV_VAR, "")
43
- return raw_environment_setting.strip().lower() in ALL_ENFORCEMENT_ENABLED_ENV_VALUES
44
-
45
-
46
- CODE_REVIEW_ENFORCEMENT_ENABLED = code_review_enforcement_enabled_in_environment()
47
- STAMP_DIRECTORY_NAME = "code-review-stamps"
48
- ALL_EFFORT_TOKENS_IN_ASCENDING_ORDER = ("low", "medium", "high", "xhigh", "max")
49
- PUSH_REQUIRED_EFFORT = "low"
50
- PR_CREATE_REQUIRED_EFFORT = "xhigh"
51
- STAMP_KEY_EFFORT = "effort"
52
- STAMP_KEY_MANIFEST_SHA256 = "manifest_sha256"
53
- STAMP_KEY_RECORDED_AT_EPOCH = "recorded_at_epoch"
54
- CODE_REVIEW_BYPASS_MARKER = "# code-review-skip"
55
- GATED_PUSH_SUBCOMMANDS = frozenset({"push"})
56
- ALL_GATED_SHELL_TOOL_NAMES = ("Bash", "PowerShell")
57
- ALL_WRITE_EDIT_TOOL_NAMES = ("Write", "Edit", "MultiEdit")
58
- MCP_CREATE_PULL_REQUEST_TOOL_NAME = "mcp__plugin_github_github__create_pull_request"
59
- HASH_PREVIEW_LENGTH = 16
60
- PRE_TOOL_USE_HOOK_EVENT_NAME = "PreToolUse"
61
- DENY_PERMISSION_DECISION = "deny"
62
- PUSH_GATE_HOOK_MODULE_NAME = "code_review_push_gate.py"
63
- PR_CREATE_GATE_HOOK_MODULE_NAME = "code_review_pr_create_gate.py"
64
- STAMP_WRITE_BLOCKER_HOOK_MODULE_NAME = "code_review_stamp_directory_write_blocker.py"
65
- SANCTIONED_STAMP_MINTER_FLAG = "--record-stamp"
66
- PUSH_GATE_CORRECTIVE_MESSAGE = (
67
- "BLOCKED: [CODE_REVIEW_PUSH_GATE] This branch surface has no clean "
68
- "code-review stamp at effort 'low' or higher. Run "
69
- "`python invoke_code_review.py --record-stamp --cwd <dir> "
70
- "--session-model <alias> low` (or a higher effort) so a clean review "
71
- "mints the stamp before `git push`; any file change after minting "
72
- "invalidates it. Append `# code-review-skip` as a trailing shell comment "
73
- "only when the verified-commit gate's verify-skip rule for a same-surface "
74
- "re-land applies. Exempt automatically: docs/image files, pytest test "
75
- "files, and Python files whose docstring- and comment-stripped AST is "
76
- "unchanged."
77
- )
78
- PR_CREATE_GATE_CORRECTIVE_MESSAGE = (
79
- "BLOCKED: [CODE_REVIEW_PR_CREATE_GATE] This branch surface has no clean "
80
- "code-review stamp at effort 'xhigh' or higher. Run "
81
- "`python invoke_code_review.py --record-stamp --cwd <dir> "
82
- "--session-model <alias> xhigh` (or max) so a clean review mints the "
83
- "stamp before `gh pr create` or the MCP create_pull_request tool. Any "
84
- "file change after minting invalidates the stamp."
85
- )
86
- STAMP_DIRECTORY_GUARD_MESSAGE = (
87
- "BLOCKED: [CODE_REVIEW_STAMP_DIRECTORY_GUARD] Access to the code-review "
88
- "stamp directory (~/.claude/code-review-stamps/) is denied. Only "
89
- "invoke_code_review.py --record-stamp mints stamp files; a shell or "
90
- "Write/Edit forge here would defeat the push and PR-create gates. "
91
- "Earn a stamp by running a clean /code-review through the invoker."
92
- )
93
- RELATIVE_STAMP_DIRECTORY_PATTERN = r"(?:^|(?<=[\s;&|(='\"]))code-review-stamps[\\/]"
94
- STAMP_DIRECTORY_CHANGE_TARGET_PATTERN = r"[ \t]+['\"]?code-review-stamps[\\/]?['\"]?(?=[\s;&|]|$)"
95
- STAMP_FILE_REFERENCE_BOUNDARY_PREFIX_PATTERN = r"(?:^|(?<=[\s;&|(='\"\\/]))"
96
- STAMP_FILE_ROOT_KEY_JSON_SUFFIX_PATTERN = r"[\\/][0-9a-f]{%d}\.json"
97
- GH_PR_CREATE_INVOCATION_PATTERN = (
98
- r"(?:^|[;&|\n`({]|\$\()[ \t]*"
99
- r"(?:(?:if|then|else|elif|while|until|do|!)[ \t]+)*"
100
- r"(?:[A-Za-z_][A-Za-z0-9_]*=\S+[ \t]+)*"
101
- r"gh(?:[ \t]+(?:--[A-Za-z][\w-]*(?:=\S+)?|-[A-Za-z])(?:[ \t]+(?!-)\S+)?)*"
102
- r"[ \t]+pr[ \t]+create\b"
103
- )
104
- ALL_STAMP_PATH_SEGMENT_NAMES = (".claude", "code-review-stamps")
105
- ALL_STAMP_PATH_SEGMENT_BODIES = ("claude", "code-review-stamps")
106
- STAMP_STORE_MODULE_NAME = "code_review_stamp_store"
107
- STAMP_MINT_FUNCTION_NAME = "record_clean_stamp"
108
- ALL_STAMP_STORE_FORGE_PATTERNS = (
109
- rf"\bimport\s+{STAMP_STORE_MODULE_NAME}\b",
110
- rf"\bfrom\s+{STAMP_STORE_MODULE_NAME}\s+import\b",
111
- rf"\b{STAMP_MINT_FUNCTION_NAME}\s*\(",
112
- )
113
-
114
-
115
- def effort_meets_threshold(candidate_effort: str, required_effort: str) -> bool:
116
- """Decide whether a recorded effort satisfies a required threshold.
117
-
118
- ::
119
-
120
- effort_meets_threshold("xhigh", "low") -> True xhigh outranks low
121
- effort_meets_threshold("low", "xhigh") -> False low sits below xhigh
122
- effort_meets_threshold("ultra", "low") -> False ultra is unranked
123
-
124
- A candidate satisfies the threshold when it sits at or above the required
125
- token in the ascending order. A token outside the known order — ``ultra``
126
- among them — satisfies nothing, so an unrecognized stored effort clears no
127
- gate.
128
-
129
- Args:
130
- candidate_effort: The effort a recorded stamp was earned at.
131
- required_effort: The effort the gate demands for the action.
132
-
133
- Returns:
134
- True when both tokens are known and the candidate ranks at or above the
135
- required token; False otherwise.
136
- """
137
- if candidate_effort not in ALL_EFFORT_TOKENS_IN_ASCENDING_ORDER:
138
- return False
139
- if required_effort not in ALL_EFFORT_TOKENS_IN_ASCENDING_ORDER:
140
- return False
141
- candidate_rank = ALL_EFFORT_TOKENS_IN_ASCENDING_ORDER.index(candidate_effort)
142
- required_rank = ALL_EFFORT_TOKENS_IN_ASCENDING_ORDER.index(required_effort)
143
- return candidate_rank >= required_rank