claude-dev-env 2.12.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 (193) hide show
  1. package/_shared/pr-loop/scripts/code_rules_gate.py +2 -3
  2. package/_shared/pr-loop/scripts/test_code_rules_gate.py +0 -26
  3. package/agents/CLAUDE.md +1 -2
  4. package/agents/pr-description-writer.md +11 -81
  5. package/bin/install.mjs +48 -9
  6. package/bin/install.prune.test.mjs +26 -0
  7. package/bin/install.settings-defaults.test.mjs +60 -33
  8. package/bin/install.test.mjs +2 -4
  9. package/commands/sr-loop.md +9 -5
  10. package/docs/CLAUDE.md +0 -1
  11. package/docs/references/CLAUDE.md +0 -1
  12. package/hooks/CLAUDE.md +1 -1
  13. package/hooks/blocking/CLAUDE.md +2 -27
  14. package/hooks/blocking/config/CLAUDE.md +1 -13
  15. package/hooks/blocking/conventional_pr_title_gate.py +1 -2
  16. package/hooks/blocking/send_user_file_open_locally_blocker.py +1 -1
  17. package/hooks/blocking/stop_dispatcher.py +4 -4
  18. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +0 -3
  19. package/hooks/blocking/test_pre_tool_use_dispatcher.py +6 -7
  20. package/hooks/blocking/test_send_user_file_open_locally_blocker.py +4 -3
  21. package/hooks/blocking/test_shared_stdin_adoption.py +0 -2
  22. package/hooks/diagnostic/CLAUDE.md +3 -3
  23. package/hooks/diagnostic/hook_log_extractor.py +2 -36
  24. package/hooks/diagnostic/hook_log_stop_wrapper.py +6 -155
  25. package/hooks/diagnostic/test_hook_log_extractor.py +8 -21
  26. package/hooks/diagnostic/test_hook_log_stop_wrapper.py +3 -331
  27. package/hooks/git-hooks/git_hooks_constants/__init__.py +0 -6
  28. package/hooks/git-hooks/pre_push.py +3 -85
  29. package/hooks/git-hooks/test_pre_push.py +0 -130
  30. package/hooks/hooks.json +0 -35
  31. package/hooks/hooks_constants/CLAUDE.md +0 -9
  32. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +0 -11
  33. package/hooks/hooks_constants/convergence_branch_constants.py +0 -1
  34. package/hooks/hooks_constants/fable_spawn_gate_constants.py +3 -4
  35. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +0 -8
  36. package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +1 -6
  37. package/hooks/hooks_constants/stop_dispatcher_constants.py +0 -1
  38. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +1 -26
  39. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +0 -5
  40. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +0 -1
  41. package/hooks/lifecycle/CLAUDE.md +1 -3
  42. package/package.json +1 -1
  43. package/rules/CLAUDE.md +0 -2
  44. package/rules/gh-cli-conventions.md +0 -1
  45. package/rules/git-workflow.md +1 -3
  46. package/scripts/CLAUDE.md +0 -1
  47. package/scripts/_code_review_test_support.py +0 -95
  48. package/scripts/dev_env_scripts_constants/code_review_constants.py +9 -98
  49. package/scripts/dev_env_scripts_constants/test_code_review_constants.py +1 -44
  50. package/scripts/invoke_code_review.py +8 -451
  51. package/scripts/test_invoke_code_review.py +5 -61
  52. package/scripts/test_invoke_code_review_cli.py +1 -45
  53. package/scripts/test_invoke_code_review_contract.py +1 -118
  54. package/settings.json +1 -10
  55. package/skills/CLAUDE.md +8 -0
  56. package/skills/autoconverge/reference/CLAUDE.md +1 -1
  57. package/skills/autoconverge/reference/convergence.md +7 -7
  58. package/skills/autoconverge/reference/gotchas.md +3 -10
  59. package/skills/autoconverge/workflow/converge.contract.test.mjs +53 -1388
  60. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +4 -3
  61. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +33 -493
  62. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +15 -97
  63. package/skills/autoconverge/workflow/converge.mjs +201 -238
  64. package/skills/autoconverge/workflow/converge.precatch.test.mjs +1 -1
  65. package/skills/bugteam/CONSTRAINTS.md +1 -1
  66. package/skills/bugteam/SKILL.md +9 -0
  67. package/skills/bugteam/reference/audit-and-teammates.md +10 -6
  68. package/skills/bugteam/test_skill_additions.py +9 -0
  69. package/skills/comments/SKILL.md +65 -0
  70. package/skills/descriptions/SKILL.md +51 -0
  71. package/skills/emergencies/SKILL.md +42 -0
  72. package/skills/imagegen/SKILL.md +21 -0
  73. package/skills/imagegen/scripts/config/__init__.py +1 -0
  74. package/skills/imagegen/scripts/config/constants.py +28 -0
  75. package/skills/imagegen/scripts/imagegen.py +45 -0
  76. package/skills/imagegen/scripts/imagegen_core.py +528 -0
  77. package/skills/imagegen/scripts/test_imagegen.py +204 -0
  78. package/skills/orchestrator/SKILL.md +2 -4
  79. package/skills/plan-to-pr/SKILL.md +6 -7
  80. package/skills/plan-to-pr/reference/final-validation-tasks.md +1 -1
  81. package/skills/plan-to-pr/reference/process-inventory.md +2 -2
  82. package/skills/plan-to-pr/reference/review-loop.md +2 -2
  83. package/skills/plan-to-pr/reference/run-record.schema.json +1 -1
  84. package/skills/plan-to-pr/reference/task-seeds.md +1 -1
  85. package/skills/plan-to-pr/reference/task-ticket.md +3 -4
  86. package/skills/plan-to-pr/scripts/config/constants.py +1 -2
  87. package/skills/plan-to-pr/scripts/test_validate_protocol.py +1 -1
  88. package/skills/plan-to-pr/test_skill_contract.py +2 -2
  89. package/skills/pr-converge/SKILL.md +13 -16
  90. package/skills/pr-converge/reference/progress-checklist.md +6 -5
  91. package/skills/pr-converge/reference/state-schema.md +1 -27
  92. package/skills/pr-loop-cloud-transport/SKILL.md +1 -1
  93. package/skills/pr-loop-cloud-transport/reference/identity-and-hooks.md +3 -3
  94. package/skills/pr-loop-lifecycle/SKILL.md +1 -1
  95. package/skills/pr-loop-lifecycle/reference/teardown-publish-permissions.md +1 -1
  96. package/skills/prototype/SKILL.md +4 -4
  97. package/skills/prototype/reference/honest-limitations.md +4 -4
  98. package/skills/prototype/reference/promotion-tasks.md +2 -2
  99. package/skills/prototype/workflows/promotion.md +4 -6
  100. package/skills/reviews/SKILL.md +85 -0
  101. package/skills/small-cl/SKILL.md +52 -0
  102. package/agents/code-verifier.md +0 -74
  103. package/docs/PR_DESCRIPTION_GUIDE.md +0 -157
  104. package/docs/references/code-review-enforcement.md +0 -132
  105. package/hooks/blocking/code_review_enforcement_config_bootstrap.py +0 -53
  106. package/hooks/blocking/code_review_gate_deny.py +0 -74
  107. package/hooks/blocking/code_review_pr_create_gate.py +0 -198
  108. package/hooks/blocking/code_review_push_gate.py +0 -145
  109. package/hooks/blocking/code_review_stamp_directory_write_blocker.py +0 -348
  110. package/hooks/blocking/code_review_stamp_store.py +0 -233
  111. package/hooks/blocking/code_review_stamp_write_blocker_parts/__init__.py +0 -7
  112. package/hooks/blocking/code_review_stamp_write_blocker_parts/conftest.py +0 -15
  113. package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py +0 -212
  114. package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py +0 -138
  115. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py +0 -49
  116. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_split_directory_change_into_stamp.py +0 -38
  117. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +0 -483
  118. package/hooks/blocking/config/code_review_enforcement_constants.py +0 -143
  119. package/hooks/blocking/config/test_code_review_enforcement_constants.py +0 -166
  120. package/hooks/blocking/config/verified_commit_constants.py +0 -160
  121. package/hooks/blocking/config/verified_commit_context_constants.py +0 -21
  122. package/hooks/blocking/config/verified_commit_gate_output_constants.py +0 -14
  123. package/hooks/blocking/conftest.py +0 -123
  124. package/hooks/blocking/convergence_gate_blocker.py +0 -310
  125. package/hooks/blocking/pr_converge_bugteam_enforcer.py +0 -170
  126. package/hooks/blocking/pr_description_body_audit.py +0 -148
  127. package/hooks/blocking/pr_description_command_parser.py +0 -237
  128. package/hooks/blocking/pr_description_enforcer.py +0 -227
  129. package/hooks/blocking/pr_description_pr_number.py +0 -155
  130. package/hooks/blocking/pr_description_proof_of_work.py +0 -385
  131. package/hooks/blocking/pr_description_readability.py +0 -364
  132. package/hooks/blocking/reviewer_spawn_gate.py +0 -182
  133. package/hooks/blocking/test_code_review_enforcement_config_bootstrap.py +0 -62
  134. package/hooks/blocking/test_code_review_gate_deny.py +0 -54
  135. package/hooks/blocking/test_code_review_pr_create_gate.py +0 -199
  136. package/hooks/blocking/test_code_review_push_gate.py +0 -205
  137. package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +0 -199
  138. package/hooks/blocking/test_code_review_stamp_store.py +0 -205
  139. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +0 -710
  140. package/hooks/blocking/test_code_verifier_tools_contract.py +0 -28
  141. package/hooks/blocking/test_convergence_gate_blocker.py +0 -428
  142. package/hooks/blocking/test_pr_converge_bugteam_enforcer.py +0 -311
  143. package/hooks/blocking/test_pr_converge_bugteam_enforcer_state_tolerance.py +0 -184
  144. package/hooks/blocking/test_pr_description_enforcer.py +0 -221
  145. package/hooks/blocking/test_pr_description_enforcer_body_audit.py +0 -247
  146. package/hooks/blocking/test_pr_description_enforcer_body_rules.py +0 -493
  147. package/hooks/blocking/test_pr_description_enforcer_command_parser.py +0 -366
  148. package/hooks/blocking/test_pr_description_enforcer_pr_number.py +0 -159
  149. package/hooks/blocking/test_pr_description_enforcer_proof_gate.py +0 -175
  150. package/hooks/blocking/test_pr_description_enforcer_readability.py +0 -443
  151. package/hooks/blocking/test_pr_description_proof_of_work.py +0 -313
  152. package/hooks/blocking/test_reviewer_spawn_gate.py +0 -230
  153. package/hooks/blocking/test_verdict_directory_write_blocker.py +0 -804
  154. package/hooks/blocking/test_verification_verdict_store.py +0 -974
  155. package/hooks/blocking/test_verified_commit_config_bootstrap.py +0 -67
  156. package/hooks/blocking/test_verified_commit_docs_delta.py +0 -176
  157. package/hooks/blocking/test_verified_commit_gate.py +0 -581
  158. package/hooks/blocking/test_verified_commit_gate_additional_context.py +0 -134
  159. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +0 -131
  160. package/hooks/blocking/test_verifier_verdict_minter.py +0 -299
  161. package/hooks/blocking/tests/test_verified_commit_gate.py +0 -41
  162. package/hooks/blocking/verdict_directory_write_blocker.py +0 -687
  163. package/hooks/blocking/verification_verdict_store.py +0 -1039
  164. package/hooks/blocking/verified_commit_config_bootstrap.py +0 -63
  165. package/hooks/blocking/verified_commit_gate.py +0 -113
  166. package/hooks/blocking/verified_commit_gate_parts/CLAUDE.md +0 -28
  167. package/hooks/blocking/verified_commit_gate_parts/__init__.py +0 -1
  168. package/hooks/blocking/verified_commit_gate_parts/command_tokenization.py +0 -174
  169. package/hooks/blocking/verified_commit_gate_parts/deny_payload.py +0 -53
  170. package/hooks/blocking/verified_commit_gate_parts/deny_reason.py +0 -80
  171. package/hooks/blocking/verified_commit_gate_parts/directory_resolution.py +0 -170
  172. package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +0 -217
  173. package/hooks/blocking/verified_commit_gate_parts/tests/conftest.py +0 -10
  174. package/hooks/blocking/verified_commit_gate_parts/tests/test_command_tokenization.py +0 -94
  175. package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_payload.py +0 -17
  176. package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_reason.py +0 -38
  177. package/hooks/blocking/verified_commit_gate_parts/tests/test_directory_resolution.py +0 -71
  178. package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +0 -96
  179. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +0 -167
  180. package/hooks/blocking/verifier_verdict_minter.py +0 -280
  181. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +0 -64
  182. package/hooks/hooks_constants/convergence_gate_blocker_constants.py +0 -53
  183. package/hooks/hooks_constants/pr_converge_bugteam_enforcer_constants.py +0 -55
  184. package/hooks/hooks_constants/pr_converge_bugteam_enforcer_state.py +0 -67
  185. package/hooks/hooks_constants/pr_description_enforcer_constants.py +0 -167
  186. package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +0 -107
  187. package/hooks/hooks_constants/reviewer_spawn_gate_constants.py +0 -41
  188. package/hooks/hooks_constants/test_pr_description_enforcer_constants.py +0 -292
  189. package/hooks/lifecycle/pr_converge_bugteam_skill_tracker.py +0 -198
  190. package/hooks/lifecycle/test_pr_converge_bugteam_skill_tracker.py +0 -283
  191. package/rules/proof-of-work-pr-comments.md +0 -22
  192. package/rules/verified-commit-gate-skip.md +0 -4
  193. package/scripts/Show-Asset.ps1 +0 -106
@@ -1,364 +0,0 @@
1
- """Score PR body readability and manage its persisted strike/threshold state.
2
-
3
- Computes Flesch Reading Ease and sentence-length metrics over the intro and
4
- first section of a PR body, escalates repeated readability failures through a
5
- persisted strike counter, applies the loosen/reset/enable/disable threshold
6
- overrides, and dispatches the readability-management CLI flags.
7
- """
8
-
9
- import json
10
- import math
11
- import os
12
- import re
13
- import sys
14
- from pathlib import Path
15
- from typing import TextIO
16
-
17
- _hooks_dir = str(Path(__file__).resolve().parent.parent)
18
- if _hooks_dir not in sys.path:
19
- sys.path.insert(0, _hooks_dir)
20
-
21
- from blocking.pr_description_body_audit import strip_markdown_ceremony # noqa: E402
22
- from hooks_constants.pr_description_enforcer_constants import ( # noqa: E402
23
- ATOMIC_WRITE_TEMP_SUFFIX,
24
- DEFAULT_READABILITY_THRESHOLDS,
25
- FENCED_CODE_BLOCK_PATTERN,
26
- FLESCH_BASE_SCORE,
27
- FLESCH_PERFECT_SCORE,
28
- FLESCH_SYLLABLES_PER_WORD_COEFFICIENT,
29
- FLESCH_WORDS_PER_SENTENCE_COEFFICIENT,
30
- HEADING_LINE_PATTERN,
31
- READABILITY_AVG_SENTENCE_WORDS_CEILING,
32
- READABILITY_ENABLED_STATE_FILE,
33
- READABILITY_FLESCH_LOOSEN_FACTOR,
34
- READABILITY_LOOSEN_CAP,
35
- READABILITY_MAX_SENTENCE_WORDS_CEILING,
36
- READABILITY_MIN_FLESCH_FLOOR,
37
- READABILITY_SENTENCE_WORDS_LOOSEN_FACTOR,
38
- READABILITY_STATE_FILE,
39
- READABILITY_THRESHOLD_OVERRIDE_FILE,
40
- ReadabilityThresholds,
41
- )
42
- from hooks_constants.setup_project_paths_constants import UTF8_ENCODING # noqa: E402
43
-
44
-
45
- def _atomic_write_json(target_path: Path, all_payload_fields: dict[str, object]) -> None:
46
- target_path.parent.mkdir(parents=True, exist_ok=True)
47
- temporary_path = target_path.with_suffix(target_path.suffix + ATOMIC_WRITE_TEMP_SUFFIX)
48
- with open(temporary_path, "w", encoding=UTF8_ENCODING) as write_handle:
49
- json.dump(all_payload_fields, write_handle)
50
- os.replace(temporary_path, target_path)
51
-
52
-
53
- def _read_json_or_default(
54
- target_path: Path, all_default_payload_fields: dict[str, object]
55
- ) -> dict[str, object]:
56
- if not target_path.exists():
57
- return dict(all_default_payload_fields)
58
- try:
59
- with open(target_path, "r", encoding=UTF8_ENCODING) as read_handle:
60
- loaded_payload = json.load(read_handle)
61
- except (FileNotFoundError, PermissionError, OSError, json.JSONDecodeError):
62
- return dict(all_default_payload_fields)
63
- if not isinstance(loaded_payload, dict):
64
- return dict(all_default_payload_fields)
65
- return loaded_payload
66
-
67
-
68
- def _read_strike_count() -> int:
69
- payload = _read_json_or_default(READABILITY_STATE_FILE, {"strikes": 0})
70
- raw_count = payload.get("strikes", 0)
71
- if isinstance(raw_count, int) and not isinstance(raw_count, bool):
72
- return max(raw_count, 0)
73
- return 0
74
-
75
-
76
- def _increment_strike_count() -> int:
77
- payload = _read_json_or_default(READABILITY_STATE_FILE, {"strikes": 0})
78
- raw_count = payload.get("strikes", 0)
79
- if isinstance(raw_count, int) and not isinstance(raw_count, bool):
80
- starting_count = max(raw_count, 0)
81
- else:
82
- starting_count = 0
83
- new_count = starting_count + 1
84
- _atomic_write_json(READABILITY_STATE_FILE, {"strikes": new_count})
85
- return new_count
86
-
87
-
88
- def _reset_strike_count() -> None:
89
- _atomic_write_json(READABILITY_STATE_FILE, {"strikes": 0})
90
-
91
-
92
- def _resolve_integer_field(
93
- all_payload_fields: dict[str, object], field_name: str, default_value: int
94
- ) -> int:
95
- raw_value = all_payload_fields.get(field_name, default_value)
96
- if isinstance(raw_value, int) and not isinstance(raw_value, bool):
97
- return raw_value
98
- return default_value
99
-
100
-
101
- def _load_readability_thresholds() -> ReadabilityThresholds:
102
- payload = _read_json_or_default(READABILITY_THRESHOLD_OVERRIDE_FILE, {})
103
- return ReadabilityThresholds(
104
- flesch_min=_resolve_integer_field(
105
- payload, "flesch_min", DEFAULT_READABILITY_THRESHOLDS.flesch_min
106
- ),
107
- max_sentence_words=_resolve_integer_field(
108
- payload, "max_sentence_words", DEFAULT_READABILITY_THRESHOLDS.max_sentence_words
109
- ),
110
- avg_sentence_words=_resolve_integer_field(
111
- payload, "avg_sentence_words", DEFAULT_READABILITY_THRESHOLDS.avg_sentence_words
112
- ),
113
- )
114
-
115
-
116
- def _read_loosens_used() -> int:
117
- payload = _read_json_or_default(READABILITY_THRESHOLD_OVERRIDE_FILE, {})
118
- raw_count = payload.get("loosens_used", 0)
119
- if isinstance(raw_count, int) and not isinstance(raw_count, bool):
120
- return max(raw_count, 0)
121
- return 0
122
-
123
-
124
- def _is_readability_enabled() -> bool:
125
- payload = _read_json_or_default(READABILITY_ENABLED_STATE_FILE, {"enabled": True})
126
- enabled_value = payload.get("enabled", True)
127
- if isinstance(enabled_value, bool):
128
- return enabled_value
129
- return True
130
-
131
-
132
- def _set_readability_enabled(enabled: bool) -> None:
133
- _atomic_write_json(READABILITY_ENABLED_STATE_FILE, {"enabled": enabled})
134
-
135
-
136
- def _count_syllables_in_word(word: str) -> int:
137
- all_vowel_characters: frozenset[str] = frozenset("aeiouy")
138
- cleaned_word = "".join(
139
- each_character for each_character in word.lower() if each_character.isalpha()
140
- )
141
- if not cleaned_word:
142
- return 0
143
- syllable_count = 0
144
- is_previous_character_vowel = False
145
- for each_character in cleaned_word:
146
- is_vowel = each_character in all_vowel_characters
147
- if is_vowel and not is_previous_character_vowel:
148
- syllable_count += 1
149
- is_previous_character_vowel = is_vowel
150
- if cleaned_word.endswith("e") and syllable_count > 1:
151
- syllable_count -= 1
152
- return max(syllable_count, 1)
153
-
154
-
155
- def _split_sentences(text: str) -> list[str]:
156
- sentence_split_pattern = re.compile(r"[.!?]+\s+")
157
- cleaned_text = text.strip()
158
- if not cleaned_text:
159
- return []
160
- raw_pieces = sentence_split_pattern.split(cleaned_text)
161
- all_sentences = [each_piece.strip() for each_piece in raw_pieces if each_piece.strip()]
162
- return all_sentences
163
-
164
-
165
- def _compute_flesch_reading_ease(text: str) -> float:
166
- all_sentences = _split_sentences(text)
167
- if not all_sentences:
168
- return FLESCH_PERFECT_SCORE
169
- all_words: list[str] = []
170
- total_syllables = 0
171
- for each_sentence in all_sentences:
172
- sentence_words = [
173
- each_token for each_token in re.split(r"\s+", each_sentence) if each_token
174
- ]
175
- all_words.extend(sentence_words)
176
- for each_word in sentence_words:
177
- total_syllables += _count_syllables_in_word(each_word)
178
- total_words = len(all_words)
179
- if total_words == 0:
180
- return FLESCH_PERFECT_SCORE
181
- total_sentences = len(all_sentences)
182
- return (
183
- FLESCH_BASE_SCORE
184
- - FLESCH_WORDS_PER_SENTENCE_COEFFICIENT * (total_words / total_sentences)
185
- - FLESCH_SYLLABLES_PER_WORD_COEFFICIENT * (total_syllables / total_words)
186
- )
187
-
188
-
189
- def _extract_readability_target_text(body: str) -> str:
190
- """Return the ceremony-stripped prose window scored for readability.
191
-
192
- Strips fenced code blocks, then builds a window from the body's intro
193
- paragraph plus its first section's prose. The intro paragraph ends at the
194
- earliest boundary among the first blank line and the first ATX header; when
195
- neither boundary exists the whole body is the intro. The first section runs
196
- from just after that first header to the next header (or end of body). The
197
- intro and first section are joined with a blank line and returned with
198
- Markdown ceremony stripped.
199
-
200
- Args:
201
- body: The raw PR body markdown text.
202
-
203
- Returns:
204
- The ceremony-stripped intro-paragraph plus first-section prose window
205
- used for readability scoring.
206
- """
207
- intro_paragraph = ""
208
- body_without_fences = FENCED_CODE_BLOCK_PATTERN.sub("", body)
209
- body_after_strip = body_without_fences.lstrip()
210
- blank_line_position = body_after_strip.find("\n\n")
211
- header_position_match = HEADING_LINE_PATTERN.search(body_after_strip)
212
- header_position = header_position_match.start() if header_position_match else -1
213
-
214
- if blank_line_position == -1 and header_position == -1:
215
- intro_paragraph = body_after_strip
216
- elif blank_line_position == -1:
217
- intro_paragraph = body_after_strip[:header_position]
218
- elif header_position == -1:
219
- intro_paragraph = body_after_strip[:blank_line_position]
220
- else:
221
- first_boundary = min(blank_line_position, header_position)
222
- intro_paragraph = body_after_strip[:first_boundary]
223
-
224
- first_body_section = ""
225
- if header_position_match is not None:
226
- section_start = header_position_match.end()
227
- remainder = body_after_strip[section_start:]
228
- next_header_match = HEADING_LINE_PATTERN.search(remainder)
229
- if next_header_match is not None:
230
- first_body_section = remainder[: next_header_match.start()]
231
- else:
232
- first_body_section = remainder
233
-
234
- combined_text = f"{intro_paragraph}\n\n{first_body_section}"
235
- return strip_markdown_ceremony(combined_text)
236
-
237
-
238
- def _evaluate_readability_metrics(
239
- target_text: str,
240
- thresholds: ReadabilityThresholds,
241
- ) -> list[str]:
242
- all_metric_violations: list[str] = []
243
- all_sentences = _split_sentences(target_text)
244
- if not all_sentences:
245
- return all_metric_violations
246
- word_counts_per_sentence: list[int] = []
247
- for each_sentence in all_sentences:
248
- sentence_words = [
249
- each_token for each_token in re.split(r"\s+", each_sentence) if each_token
250
- ]
251
- word_counts_per_sentence.append(len(sentence_words))
252
- max_sentence_words = max(word_counts_per_sentence) if word_counts_per_sentence else 0
253
- average_sentence_words = (
254
- sum(word_counts_per_sentence) / len(word_counts_per_sentence)
255
- if word_counts_per_sentence
256
- else 0.0
257
- )
258
- if max_sentence_words > thresholds.max_sentence_words:
259
- all_metric_violations.append(
260
- f"Readability: longest sentence is {max_sentence_words} words "
261
- f"(maximum {thresholds.max_sentence_words}); "
262
- "split or rewrite the longest sentence"
263
- )
264
- if average_sentence_words > thresholds.avg_sentence_words:
265
- all_metric_violations.append(
266
- f"Readability: average sentence is {average_sentence_words:.1f} words "
267
- f"(maximum {thresholds.avg_sentence_words}); "
268
- "shorten or split your longest sentences"
269
- )
270
- flesch_score = _compute_flesch_reading_ease(target_text)
271
- if flesch_score < thresholds.flesch_min:
272
- all_metric_violations.append(
273
- f"Readability: Flesch Reading Ease is {flesch_score:.1f} "
274
- f"(minimum {thresholds.flesch_min}); use shorter words and sentences"
275
- )
276
- return all_metric_violations
277
-
278
-
279
- def _build_readability_escape_hatch_message() -> str:
280
- return (
281
- "Readability strike threshold reached. Pick one: "
282
- "(1) python <enforcer-path> --readability-loosen to widen thresholds 10%, "
283
- "(2) python <enforcer-path> --readability-disable to skip the readability check, "
284
- "(3) python <enforcer-path> --readability-reset to zero the strike counter, "
285
- "(4) reply with the body plus the intended message to report a false positive."
286
- )
287
-
288
-
289
- def _apply_readability_loosen() -> str:
290
- current_thresholds = _load_readability_thresholds()
291
- loosens_used = _read_loosens_used()
292
-
293
- if loosens_used >= READABILITY_LOOSEN_CAP:
294
- return "cap_reached"
295
-
296
- if current_thresholds.flesch_min <= READABILITY_MIN_FLESCH_FLOOR:
297
- return "floor_reached"
298
-
299
- if current_thresholds.max_sentence_words >= READABILITY_MAX_SENTENCE_WORDS_CEILING:
300
- return "ceiling_reached"
301
-
302
- if current_thresholds.avg_sentence_words >= READABILITY_AVG_SENTENCE_WORDS_CEILING:
303
- return "ceiling_reached"
304
-
305
- next_flesch = max(
306
- READABILITY_MIN_FLESCH_FLOOR,
307
- math.floor(current_thresholds.flesch_min * READABILITY_FLESCH_LOOSEN_FACTOR),
308
- )
309
- next_max_sentence = min(
310
- READABILITY_MAX_SENTENCE_WORDS_CEILING,
311
- math.ceil(current_thresholds.max_sentence_words * READABILITY_SENTENCE_WORDS_LOOSEN_FACTOR),
312
- )
313
- next_avg_sentence = min(
314
- READABILITY_AVG_SENTENCE_WORDS_CEILING,
315
- math.ceil(current_thresholds.avg_sentence_words * READABILITY_SENTENCE_WORDS_LOOSEN_FACTOR),
316
- )
317
-
318
- next_payload: dict[str, object] = {
319
- "flesch_min": next_flesch,
320
- "max_sentence_words": next_max_sentence,
321
- "avg_sentence_words": next_avg_sentence,
322
- "loosens_used": loosens_used + 1,
323
- }
324
- _atomic_write_json(READABILITY_THRESHOLD_OVERRIDE_FILE, next_payload)
325
- return "ok"
326
-
327
-
328
- def _apply_readability_reset() -> None:
329
- _reset_strike_count()
330
- _atomic_write_json(READABILITY_THRESHOLD_OVERRIDE_FILE, {"loosens_used": 0})
331
-
332
-
333
- def _dispatch_cli_flag(
334
- flag_token: str,
335
- output_stream: TextIO,
336
- error_stream: TextIO,
337
- ) -> None:
338
- """Handle a single readability-management CLI flag and exit the process."""
339
- if flag_token == "--readability-loosen":
340
- outcome = _apply_readability_loosen()
341
- if outcome == "cap_reached":
342
- error_stream.write(
343
- "loosen cap reached; use --readability-disable or --readability-reset\n"
344
- )
345
- sys.exit(1)
346
- if outcome in {"floor_reached", "ceiling_reached"}:
347
- error_stream.write(
348
- "thresholds already at floor/ceiling; use --readability-disable or --readability-reset\n"
349
- )
350
- sys.exit(1)
351
- output_stream.write("readability thresholds loosened 10%\n")
352
- sys.exit(0)
353
- if flag_token == "--readability-reset":
354
- _apply_readability_reset()
355
- output_stream.write("readability strike counter and override thresholds reset\n")
356
- sys.exit(0)
357
- if flag_token == "--readability-disable":
358
- _set_readability_enabled(False)
359
- output_stream.write("readability check disabled\n")
360
- sys.exit(0)
361
- if flag_token == "--readability-enable":
362
- _set_readability_enabled(True)
363
- output_stream.write("readability check enabled\n")
364
- sys.exit(0)
@@ -1,182 +0,0 @@
1
- #!/usr/bin/env python3
2
- """PreToolUse hook: block a reviewer-spawn trigger when that reviewer is down.
3
-
4
- autoconverge already skips a reviewer step when its own pre-check reports
5
- that reviewer unavailable. That skip only protects the trigger commands the
6
- workflow generates and inspects itself. This hook is the backstop: it reads
7
- the Bash command a tool call is about to run and blocks it when the matched
8
- reviewer reports unavailable.
9
-
10
- The gate only inspects a command carrying the literal sentinel marker
11
- `CLAUDE_REVIEWER_GATE=autoconverge`. autoconverge prepends this marker to its
12
- own trigger commands. A command without the marker is never inspected, so a
13
- manual or unrelated `gh` call always passes through untouched.
14
-
15
- A Copilot trigger is a command containing both `requested_reviewers` and
16
- `copilot-pull-request-reviewer[bot]`. A Bugbot trigger is a command
17
- containing `post_fix_reply.py` and a `--body "bugbot run"` argument.
18
-
19
- For the matched reviewer, the gate runs
20
- `reviewer_availability.py --reviewer copilot|bugbot` and denies the call only
21
- when that script exits with the documented reviewer-down code (3). Any other
22
- exit code is an unexpected script failure, not a down report, so the call is
23
- allowed (fail open). The deny reason names the reviewer and carries
24
- the script's output. The script path resolves relative to this hook's
25
- install location by default, or from the
26
- `REVIEWER_SPAWN_GATE_AVAILABILITY_SCRIPT_PATH` environment variable when set.
27
- A missing script, a timeout, or a failure to launch it allows the call
28
- (fail open), so a broken availability check never blocks a legitimate run.
29
- """
30
-
31
- import json
32
- import os
33
- import subprocess
34
- import sys
35
- from pathlib import Path
36
-
37
- _hooks_dir = str(Path(__file__).resolve().parent.parent)
38
- if _hooks_dir not in sys.path:
39
- sys.path.insert(0, _hooks_dir)
40
-
41
- from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
42
- from hooks_constants.reviewer_spawn_gate_constants import ( # noqa: E402
43
- ALL_COPILOT_TRIGGER_MARKERS,
44
- AVAILABILITY_DOWN_EXIT_CODE,
45
- AVAILABILITY_REVIEWER_FLAG,
46
- AVAILABILITY_SCRIPT_PATH_ENV_VAR_NAME,
47
- AVAILABILITY_SCRIPT_RELATIVE_PATH,
48
- AVAILABILITY_SCRIPT_TIMEOUT_SECONDS,
49
- BASH_TOOL_NAME,
50
- BUGBOT_REVIEWER_LABEL,
51
- BUGBOT_REVIEWER_TOKEN,
52
- BUGBOT_RUN_BODY_PATTERN,
53
- BUGBOT_TRIGGER_SCRIPT_MARKER,
54
- COPILOT_REVIEWER_LABEL,
55
- COPILOT_REVIEWER_TOKEN,
56
- DENY_REASON_TEMPLATE,
57
- GATE_SENTINEL_MARKER,
58
- )
59
-
60
-
61
- def _matches_copilot_trigger(command: str) -> bool:
62
- return all(each_marker in command for each_marker in ALL_COPILOT_TRIGGER_MARKERS)
63
-
64
-
65
- def _matches_bugbot_trigger(command: str) -> bool:
66
- return BUGBOT_TRIGGER_SCRIPT_MARKER in command and bool(
67
- BUGBOT_RUN_BODY_PATTERN.search(command)
68
- )
69
-
70
-
71
- def _reviewer_trigger(command: str) -> tuple[str, str] | None:
72
- """Return the reviewer token and label a scoped command triggers.
73
-
74
- Args:
75
- command: The Bash command text carrying the gate's sentinel marker.
76
-
77
- Returns:
78
- The matched reviewer's CLI token paired with its display label, or
79
- None when the command matches neither the Copilot review-request
80
- shape nor the Bugbot rerun-comment shape.
81
- """
82
- if _matches_copilot_trigger(command):
83
- return COPILOT_REVIEWER_TOKEN, COPILOT_REVIEWER_LABEL
84
- if _matches_bugbot_trigger(command):
85
- return BUGBOT_REVIEWER_TOKEN, BUGBOT_REVIEWER_LABEL
86
- return None
87
-
88
-
89
- def _resolve_availability_script_path() -> Path:
90
- override_path = os.environ.get(AVAILABILITY_SCRIPT_PATH_ENV_VAR_NAME, "")
91
- if override_path:
92
- return Path(override_path)
93
- plugin_root = Path(__file__).resolve().parents[2]
94
- return plugin_root / AVAILABILITY_SCRIPT_RELATIVE_PATH
95
-
96
-
97
- def _probe_reviewer_availability(reviewer_token: str) -> tuple[bool, str]:
98
- """Run the shared availability script for a reviewer and report the outcome.
99
-
100
- Args:
101
- reviewer_token: The reviewer's CLI token (copilot or bugbot) passed to
102
- the availability script's --reviewer flag.
103
-
104
- Returns:
105
- A pair of (is_available, detail_text). is_available is False only
106
- when the script exits with the documented reviewer-down code;
107
- detail_text carries the script's combined stdout and stderr for the
108
- deny message. A missing script, a timeout, an OS-level failure to
109
- launch the script, or any exit code other than the documented down
110
- code reports available (fail open), so an availability-check crash
111
- never blocks a legitimate run.
112
- """
113
- script_path = _resolve_availability_script_path()
114
- if not script_path.is_file():
115
- return True, f"availability script not found at {script_path}"
116
- try:
117
- completed_process = subprocess.run(
118
- [sys.executable, str(script_path), AVAILABILITY_REVIEWER_FLAG, reviewer_token],
119
- capture_output=True,
120
- text=True,
121
- encoding="utf-8",
122
- errors="replace",
123
- timeout=AVAILABILITY_SCRIPT_TIMEOUT_SECONDS,
124
- check=False,
125
- )
126
- except (OSError, subprocess.TimeoutExpired) as availability_error:
127
- return True, f"availability script failed to run: {availability_error}"
128
- availability_detail_text = (completed_process.stdout + completed_process.stderr).strip()
129
- is_reviewer_down = completed_process.returncode == AVAILABILITY_DOWN_EXIT_CODE
130
- return not is_reviewer_down, availability_detail_text
131
-
132
-
133
- def main() -> None:
134
- try:
135
- hook_input = json.load(sys.stdin)
136
- except json.JSONDecodeError:
137
- sys.exit(0)
138
- if not isinstance(hook_input, dict):
139
- sys.exit(0)
140
-
141
- tool_name = hook_input.get("tool_name", "")
142
- if tool_name != BASH_TOOL_NAME:
143
- sys.exit(0)
144
-
145
- tool_input = hook_input.get("tool_input", {})
146
- command = tool_input.get("command", "") if isinstance(tool_input, dict) else ""
147
- if not command or GATE_SENTINEL_MARKER not in command:
148
- sys.exit(0)
149
-
150
- reviewer_trigger = _reviewer_trigger(command)
151
- if reviewer_trigger is None:
152
- sys.exit(0)
153
- reviewer_token, reviewer_label = reviewer_trigger
154
-
155
- is_available, availability_detail = _probe_reviewer_availability(reviewer_token)
156
- if is_available:
157
- sys.exit(0)
158
-
159
- deny_reason = DENY_REASON_TEMPLATE.format(
160
- reviewer_label=reviewer_label, availability_detail=availability_detail
161
- )
162
- deny_payload = {
163
- "hookSpecificOutput": {
164
- "hookEventName": "PreToolUse",
165
- "permissionDecision": "deny",
166
- "permissionDecisionReason": deny_reason,
167
- }
168
- }
169
- log_hook_block(
170
- calling_hook_name="reviewer_spawn_gate.py",
171
- hook_event="PreToolUse",
172
- block_reason=deny_reason,
173
- tool_name=tool_name,
174
- offending_input_preview=command,
175
- )
176
- print(json.dumps(deny_payload))
177
- sys.stdout.flush()
178
- sys.exit(0)
179
-
180
-
181
- if __name__ == "__main__":
182
- main()
@@ -1,62 +0,0 @@
1
- """Regression tests for the code-review enforcement constants bootstrap.
2
-
3
- These prove the shared constants module resolves to the sibling
4
- ``config/code_review_enforcement_constants.py`` file even when a foreign
5
- ``config`` package sits ahead of ``blocking`` on ``sys.path``, and that a
6
- registration a caller placed first is left untouched.
7
- """
8
-
9
- import subprocess
10
- from collections.abc import Callable
11
-
12
- RunUnderConfigShadow = Callable[[str], subprocess.CompletedProcess[str]]
13
-
14
-
15
- def test_register_binds_dotted_name_to_sibling_constants_file(
16
- run_under_config_shadow: RunUnderConfigShadow,
17
- ) -> None:
18
- completed_probe = run_under_config_shadow(
19
- "import code_review_enforcement_config_bootstrap as bootstrap\n"
20
- "bootstrap.register_code_review_enforcement_constants()\n"
21
- "import config.code_review_enforcement_constants as loaded\n"
22
- "print(loaded.__file__)\n"
23
- "print(loaded.STAMP_DIRECTORY_NAME)\n"
24
- )
25
- assert completed_probe.returncode == 0, completed_probe.stderr
26
- assert "IS_DECOY_CONFIG" not in completed_probe.stdout
27
- resolved_file_line, stamp_directory_line = completed_probe.stdout.splitlines()[:2]
28
- assert resolved_file_line.replace("\\", "/").endswith(
29
- "blocking/config/code_review_enforcement_constants.py"
30
- )
31
- assert stamp_directory_line == "code-review-stamps"
32
-
33
-
34
- def test_register_binds_push_required_effort(
35
- run_under_config_shadow: RunUnderConfigShadow,
36
- ) -> None:
37
- completed_probe = run_under_config_shadow(
38
- "import code_review_enforcement_config_bootstrap as bootstrap\n"
39
- "bootstrap.register_code_review_enforcement_constants()\n"
40
- "import config.code_review_enforcement_constants as loaded\n"
41
- "print(loaded.PUSH_REQUIRED_EFFORT)\n"
42
- )
43
- assert completed_probe.returncode == 0, completed_probe.stderr
44
- assert completed_probe.stdout.strip().splitlines()[-1] == "low"
45
-
46
-
47
- def test_register_leaves_an_already_cached_module_untouched(
48
- run_under_config_shadow: RunUnderConfigShadow,
49
- ) -> None:
50
- completed_probe = run_under_config_shadow(
51
- "import sys\n"
52
- "import types\n"
53
- "sentinel = types.ModuleType('config.code_review_enforcement_constants')\n"
54
- "sentinel.ORIGIN = 'caller-seeded'\n"
55
- "sys.modules['config.code_review_enforcement_constants'] = sentinel\n"
56
- "import code_review_enforcement_config_bootstrap as bootstrap\n"
57
- "bootstrap.register_code_review_enforcement_constants()\n"
58
- "import config.code_review_enforcement_constants as loaded\n"
59
- "print(getattr(loaded, 'ORIGIN', 'displaced'))\n"
60
- )
61
- assert completed_probe.returncode == 0, completed_probe.stderr
62
- assert completed_probe.stdout.strip().splitlines()[-1] == "caller-seeded"
@@ -1,54 +0,0 @@
1
- """Behavioral tests for the shared code-review gate deny scaffold.
2
-
3
- The tests drive the two shared helpers directly: the payload builder's shape and
4
- the log-and-emit helper's stdout, so both code-review gates inherit one verified
5
- deny path.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- import importlib.util
11
- import json
12
- import sys
13
- from pathlib import Path
14
- from types import ModuleType
15
-
16
- import pytest
17
-
18
- _HOOK_DIR = Path(__file__).resolve().parent
19
- if str(_HOOK_DIR) not in sys.path:
20
- sys.path.insert(0, str(_HOOK_DIR))
21
-
22
-
23
- def _load_module(module_name: str) -> ModuleType:
24
- module_spec = importlib.util.spec_from_file_location(
25
- module_name, _HOOK_DIR / f"{module_name}.py"
26
- )
27
- assert module_spec is not None
28
- assert module_spec.loader is not None
29
- loaded_module = importlib.util.module_from_spec(module_spec)
30
- module_spec.loader.exec_module(loaded_module)
31
- return loaded_module
32
-
33
-
34
- deny_module = _load_module("code_review_gate_deny")
35
-
36
-
37
- def test_build_payload_carries_reason_and_deny_decision() -> None:
38
- deny_payload = deny_module.build_code_review_deny_payload("blocked reason")
39
- hook_output = deny_payload["hookSpecificOutput"]
40
- assert hook_output["permissionDecision"] == "deny"
41
- assert hook_output["permissionDecisionReason"] == "blocked reason"
42
-
43
-
44
- def test_log_and_emit_code_review_deny_writes_payload_to_stdout(
45
- capsys: pytest.CaptureFixture[str],
46
- monkeypatch: pytest.MonkeyPatch,
47
- tmp_path: Path,
48
- ) -> None:
49
- monkeypatch.setattr(Path, "home", lambda: tmp_path)
50
- deny_module.log_and_emit_code_review_deny("blocked reason", "Bash", "code_review_push_gate")
51
- emitted_payload = json.loads(capsys.readouterr().out)
52
- hook_output = emitted_payload["hookSpecificOutput"]
53
- assert hook_output["permissionDecision"] == "deny"
54
- assert hook_output["permissionDecisionReason"] == "blocked reason"