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,310 +0,0 @@
1
- #!/usr/bin/env python3
2
- """PreToolUse hook: block gh pr ready until convergence pre-conditions pass.
3
-
4
- Runs check_convergence.py against the PR and denies the gh pr ready
5
- call if any condition fails. The agent sees exactly which conditions
6
- failed and can address them before retrying.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import json
12
- import re
13
- import subprocess
14
- import sys
15
- from pathlib import Path
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_pr_number import ( # noqa: E402
22
- _extract_pr_number_from_command,
23
- )
24
- from hooks_constants.convergence_gate_blocker_constants import ( # noqa: E402
25
- ALL_GH_PR_VIEW_NUMBER_COMMAND,
26
- BASH_LINE_CONTINUATION_PATTERN,
27
- COMMAND_SEPARATOR_PATTERN,
28
- GH_PR_READY_ANCHOR_PATTERN,
29
- GH_REPO_FLAG,
30
- GIT_URL_SUFFIX,
31
- PR_URL_OWNER_REPO_NUMBER_PATTERN,
32
- REPO_OVERRIDE_FLAG_PATTERN,
33
- REPO_SLUG_TEMPLATE,
34
- )
35
- from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
36
-
37
-
38
- def _ready_command_segment(command: str) -> str:
39
- """Return the ``gh pr ready`` invocation, clipped at the next command separator.
40
-
41
- ::
42
-
43
- gh pr ready 161 && gh pr comment 999 --repo other-owner/other-repo
44
- ^^^^^^^^^^^^^^^^ clipped -> returned segment
45
-
46
- Scanning only this segment keeps a ``--repo`` flag or PR URL that belongs to
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.
50
- """
51
- ready_match = re.search(GH_PR_READY_ANCHOR_PATTERN, command)
52
- if ready_match is None:
53
- return command
54
- ready_tail = command[ready_match.start() :]
55
- ready_tail = re.sub(BASH_LINE_CONTINUATION_PATTERN, " ", ready_tail)
56
- separator_match = re.search(COMMAND_SEPARATOR_PATTERN, ready_tail)
57
- if separator_match is None:
58
- return ready_tail
59
- return ready_tail[: separator_match.start()]
60
-
61
-
62
- def _parse_pr_url(command: str) -> tuple[str, str, int] | None:
63
- """Return (owner, repo, pr_number) from a full PR URL in the command."""
64
- url_match = re.search(PR_URL_OWNER_REPO_NUMBER_PATTERN, command)
65
- if url_match is None:
66
- return None
67
- return url_match.group("owner"), url_match.group("repo"), int(url_match.group("number"))
68
-
69
-
70
- def _parse_repo_flag(command: str) -> tuple[str, str] | None:
71
- """Return (owner, repo) from a --repo/-R flag in the command."""
72
- flag_match = re.search(REPO_OVERRIDE_FLAG_PATTERN, command)
73
- if flag_match is None:
74
- return None
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
112
-
113
-
114
- def _resolve_target_identity(command: str, cwd: str | None) -> tuple[str, str, int] | None:
115
- """Resolve the (owner, repo, pr_number) the gate keys its evidence to.
116
-
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.
120
- """
121
- named_identity = _resolve_named_identity(command, cwd)
122
- if named_identity is None:
123
- return None
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
128
-
129
- cwd_identity = _resolve_owner_repo(cwd)
130
- if cwd_identity is None:
131
- return None
132
- cwd_owner, cwd_repo = cwd_identity
133
- return cwd_owner, cwd_repo, pr_number
134
-
135
-
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
- ]
173
- try:
174
- completed_process = subprocess.run(
175
- all_view_arguments,
176
- capture_output=True,
177
- text=True,
178
- cwd=cwd or None,
179
- check=False,
180
- )
181
- except OSError:
182
- return None
183
- if completed_process.returncode != 0:
184
- return None
185
- try:
186
- return int(completed_process.stdout.strip())
187
- except (ValueError, TypeError):
188
- return None
189
-
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
-
207
- def _resolve_owner_repo(cwd: str | None) -> tuple[str, str] | None:
208
- try:
209
- completed_process = subprocess.run(
210
- ["gh", "repo", "view", "--json", "owner,name", "--jq", ".owner.login,.name"],
211
- capture_output=True,
212
- text=True,
213
- cwd=cwd or None,
214
- check=False,
215
- )
216
- except OSError:
217
- return None
218
- if completed_process.returncode != 0:
219
- return None
220
- parts = completed_process.stdout.strip().splitlines()
221
- if len(parts) <= 1:
222
- match = re.match(
223
- r"https://github\.com/([^/]+)/([^/]+?)(?:\.git)?$",
224
- completed_process.stdout.strip(),
225
- )
226
- if match:
227
- return match.group(1), match.group(2)
228
- return None
229
- return parts[0], parts[1]
230
-
231
-
232
- def _run_convergence_check(
233
- script: str, owner: str, repo: str, pr_number: int, cwd: str | None
234
- ) -> subprocess.CompletedProcess[str]:
235
- return subprocess.run(
236
- [
237
- sys.executable,
238
- script,
239
- "--owner",
240
- owner,
241
- "--repo",
242
- repo,
243
- "--pr-number",
244
- str(pr_number),
245
- ],
246
- capture_output=True,
247
- text=True,
248
- cwd=cwd or None,
249
- check=False,
250
- )
251
-
252
-
253
- def main() -> None:
254
- check_convergence_script = str(
255
- Path.home() / ".claude/skills/pr-converge/scripts/check_convergence.py"
256
- )
257
-
258
- if not Path(check_convergence_script).is_file():
259
- sys.exit(0)
260
-
261
- try:
262
- hook_input = json.load(sys.stdin)
263
- except json.JSONDecodeError:
264
- sys.exit(0)
265
-
266
- tool_name = hook_input.get("tool_name", "")
267
- if tool_name != "Bash":
268
- sys.exit(0)
269
-
270
- command = hook_input.get("tool_input", {}).get("command", "")
271
- gh_pr_ready_pattern = re.compile(GH_PR_READY_ANCHOR_PATTERN)
272
- if not gh_pr_ready_pattern.search(command):
273
- sys.exit(0)
274
-
275
- cwd = hook_input.get("cwd")
276
- target_identity = _resolve_target_identity(command, cwd)
277
- if target_identity is None:
278
- sys.exit(0)
279
- owner, repo, pr_number = target_identity
280
-
281
- completed_process = _run_convergence_check(
282
- check_convergence_script, owner, repo, pr_number, cwd
283
- )
284
-
285
- if completed_process.returncode in (0, 2):
286
- sys.exit(0)
287
-
288
- block_reason = (
289
- "Convergence check failed — PR is not ready to mark ready:\n\n" + completed_process.stdout
290
- )
291
- deny_payload = {
292
- "hookSpecificOutput": {
293
- "hookEventName": "PreToolUse",
294
- "permissionDecision": "deny",
295
- "permissionDecisionReason": block_reason,
296
- }
297
- }
298
- log_hook_block(
299
- calling_hook_name="convergence_gate_blocker.py",
300
- hook_event="PreToolUse",
301
- block_reason=block_reason,
302
- tool_name="Bash",
303
- )
304
- print(json.dumps(deny_payload))
305
- sys.stdout.flush()
306
- sys.exit(0)
307
-
308
-
309
- if __name__ == "__main__":
310
- main()
@@ -1,170 +0,0 @@
1
- #!/usr/bin/env python3
2
- """PreToolUse hook: enforce formal bugteam Skill at Step 5 BUGTEAM ticks.
3
-
4
- The pr-converge loop's Step 5 BUGTEAM contract requires the formal
5
- ``Skill({skill: "bugteam", args: "<PR URL>"})`` invocation per tick.
6
- Substituting an ad-hoc ``Agent({subagent_type: "clean-coder", ...})`` audit
7
- call returns a "converged" verdict without writing the artifact that
8
- ``check_convergence.py``'s ``bugteam_clean_at`` gate reads, so the loop later
9
- hits ``gh pr ready`` and fails structurally with no formal review on the PR.
10
-
11
- The companion tracker hook
12
- (``pr_converge_bugteam_skill_tracker.py``) records every formal Skill
13
- invocation. This enforcer reads the recorded HEAD and tick and denies any
14
- clean-coder audit-shaped Agent call that has not first been preceded by the
15
- formal Skill at the same HEAD and tick.
16
-
17
- ``qbug`` is NOT an accepted substitute; only the ``bugteam`` skill records
18
- the gate artifact, and the tracker deliberately ignores ``qbug`` invocations.
19
- """
20
-
21
- from __future__ import annotations
22
-
23
- import json
24
- import sys
25
- from pathlib import Path
26
- from typing import TextIO
27
-
28
- _hooks_dir = str(Path(__file__).resolve().parent.parent)
29
- if _hooks_dir not in sys.path:
30
- sys.path.insert(0, _hooks_dir)
31
-
32
- from hooks_constants.pr_converge_bugteam_enforcer_constants import ( # noqa: E402
33
- AGENT_TOOL_NAME,
34
- ALL_AUDIT_PROMPT_SUBSTRINGS,
35
- BUGTEAM_PHASE,
36
- CLEAN_CODER_SUBAGENT_TYPE,
37
- ENFORCER_CORRECTIVE_MESSAGE,
38
- STATE_FIELD_BUGTEAM_SKILL_INVOKED_AT_HEAD,
39
- STATE_FIELD_BUGTEAM_SKILL_INVOKED_AT_TICK,
40
- STATE_FIELD_CURRENT_HEAD,
41
- STATE_FIELD_PHASE,
42
- STATE_FIELD_TICK_COUNT,
43
- )
44
- from hooks_constants.pr_converge_bugteam_enforcer_state import ( # noqa: E402
45
- load_state_dictionary,
46
- resolve_state_path,
47
- )
48
- from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
49
- from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
50
- read_hook_input_dictionary_from_stdin,
51
- )
52
-
53
-
54
- def _prompt_is_audit_shaped(agent_prompt: str) -> bool:
55
- """Return True when the Agent prompt looks like an audit substitute.
56
-
57
- Args:
58
- agent_prompt: The ``prompt`` field of the Agent tool_input.
59
-
60
- Returns:
61
- True when any audit-shaped substring appears in the prompt
62
- (case-insensitive); False for fix-only or unrelated prompts.
63
- """
64
- lowercased_prompt = agent_prompt.lower()
65
- return any(
66
- each_substring in lowercased_prompt for each_substring in ALL_AUDIT_PROMPT_SUBSTRINGS
67
- )
68
-
69
-
70
- def _has_formal_skill_fired_this_tick(state_by_field: dict[str, object]) -> bool:
71
- """Return True when the bugteam Skill registered at current HEAD and tick.
72
-
73
- Args:
74
- state_by_field: Parsed pr-converge state.json mapping each field name
75
- to its recorded value.
76
-
77
- Returns:
78
- True when both ``bugteam_skill_invoked_at_head`` matches
79
- ``current_head`` and ``bugteam_skill_invoked_at_tick`` matches
80
- ``tick_count``; False when either is missing, stale, or carries a
81
- type that violates state-schema.md (head must be ``str``, tick must
82
- be a non-bool ``int``) — type-invalid values fail closed so the
83
- enforcer rejects corrupted state.
84
- """
85
- invoked_head = state_by_field.get(STATE_FIELD_BUGTEAM_SKILL_INVOKED_AT_HEAD)
86
- current_head = state_by_field.get(STATE_FIELD_CURRENT_HEAD)
87
- invoked_tick = state_by_field.get(STATE_FIELD_BUGTEAM_SKILL_INVOKED_AT_TICK)
88
- current_tick = state_by_field.get(STATE_FIELD_TICK_COUNT)
89
- if not isinstance(invoked_head, str) or not isinstance(current_head, str):
90
- return False
91
- if invoked_head != current_head:
92
- return False
93
- if isinstance(invoked_tick, bool) or isinstance(current_tick, bool):
94
- return False
95
- if not isinstance(invoked_tick, int) or not isinstance(current_tick, int):
96
- return False
97
- return invoked_tick == current_tick
98
-
99
-
100
- def _should_block(payload_by_field: dict[str, object]) -> bool:
101
- """Return True when the Agent call is a BUGTEAM-phase Skill substitution.
102
-
103
- Args:
104
- payload_by_field: The full PreToolUse hook payload (already JSON-parsed),
105
- keyed by top-level field name.
106
-
107
- Returns:
108
- True when every gating condition holds: tool is Agent, subagent_type
109
- is clean-coder, prompt is audit-shaped, pr-converge state.json exists
110
- with phase BUGTEAM, and the formal bugteam Skill has NOT been
111
- recorded at the current HEAD and tick. False otherwise.
112
- """
113
- if payload_by_field.get("tool_name", "") != AGENT_TOOL_NAME:
114
- return False
115
- tool_input = payload_by_field.get("tool_input", {})
116
- if not isinstance(tool_input, dict):
117
- return False
118
- if tool_input.get("subagent_type", "") != CLEAN_CODER_SUBAGENT_TYPE:
119
- return False
120
- agent_prompt = tool_input.get("prompt", "")
121
- if not isinstance(agent_prompt, str):
122
- return False
123
- if not _prompt_is_audit_shaped(agent_prompt):
124
- return False
125
- state_path = resolve_state_path()
126
- if state_path is None:
127
- return False
128
- parsed_state = load_state_dictionary(state_path)
129
- if parsed_state is None:
130
- return False
131
- if parsed_state.get(STATE_FIELD_PHASE) != BUGTEAM_PHASE:
132
- return False
133
- return not _has_formal_skill_fired_this_tick(parsed_state)
134
-
135
-
136
- def _emit_deny_payload(output_stream: TextIO) -> None:
137
- """Write the PreToolUse deny payload to the provided stream.
138
-
139
- Args:
140
- output_stream: Writable text stream — production code passes
141
- ``sys.stdout``; tests pass a ``StringIO`` to capture the JSON.
142
- """
143
- deny_payload = {
144
- "hookSpecificOutput": {
145
- "hookEventName": "PreToolUse",
146
- "permissionDecision": "deny",
147
- "permissionDecisionReason": ENFORCER_CORRECTIVE_MESSAGE,
148
- }
149
- }
150
- log_hook_block(
151
- calling_hook_name="pr_converge_bugteam_enforcer.py",
152
- hook_event="PreToolUse",
153
- block_reason=ENFORCER_CORRECTIVE_MESSAGE,
154
- )
155
- output_stream.write(json.dumps(deny_payload) + "\n")
156
- output_stream.flush()
157
-
158
-
159
- def main() -> None:
160
- hook_payload = read_hook_input_dictionary_from_stdin()
161
- if hook_payload is None:
162
- sys.exit(0)
163
- if not _should_block(hook_payload):
164
- sys.exit(0)
165
- _emit_deny_payload(sys.stdout)
166
- sys.exit(0)
167
-
168
-
169
- if __name__ == "__main__":
170
- main()
@@ -1,148 +0,0 @@
1
- """Audit PR body markdown for prose substance, shape, and structural rules.
2
-
3
- Strips Markdown ceremony to measure substantive prose, classifies the body as
4
- trivial, standard, or heavy, enumerates section headers, prepares the prose
5
- scanned for vague language, and flags self-closing references to the PR's own
6
- number and the discouraged "This PR ..." opening. Vague-language enforcement
7
- runs in validate_pr_body in pr_description_enforcer.py.
8
- """
9
-
10
- import re
11
- import sys
12
- from pathlib import Path
13
-
14
- _hooks_dir = str(Path(__file__).resolve().parent.parent)
15
- if _hooks_dir not in sys.path:
16
- sys.path.insert(0, _hooks_dir)
17
-
18
- from hooks_constants.pr_description_enforcer_constants import ( # noqa: E402
19
- BLOCKQUOTE_LINE_PATTERN,
20
- BLOCKQUOTE_MARKER_PATTERN,
21
- BOLD_PAIR_PATTERN,
22
- BULLET_MARKER_PATTERN,
23
- FENCED_CODE_BLOCK_PATTERN,
24
- HEADING_LINE_PATTERN,
25
- HEAVY_MIN_BODY_CHARS_FOR_CLASSIFICATION,
26
- HEAVY_SHAPE,
27
- INLINE_CODE_PATTERN,
28
- LINK_TEXT_PATTERN,
29
- SELF_REFERENCE_PATTERN_TEMPLATE,
30
- STANDARD_SHAPE,
31
- TABLE_ROW_LINE_PATTERN,
32
- THIS_PR_OPENING_PATTERN,
33
- TRIVIAL_BODY_CHAR_THRESHOLD,
34
- TRIVIAL_SHAPE,
35
- WHITESPACE_RUN_PATTERN,
36
- )
37
-
38
-
39
- def strip_markdown_ceremony(body: str) -> str:
40
- """Return the body with Markdown ceremony stripped to leave underlying prose.
41
-
42
- Removes fenced code, inline code, heading lines, blockquote markers,
43
- bullet list markers, bold/emphasis markers, and Markdown link targets.
44
- Whitespace is preserved so callers can collapse or measure it as needed.
45
- """
46
- body_without_fences = FENCED_CODE_BLOCK_PATTERN.sub("", body)
47
- body_without_inline_code = INLINE_CODE_PATTERN.sub("", body_without_fences)
48
- body_without_blockquotes = BLOCKQUOTE_MARKER_PATTERN.sub("", body_without_inline_code)
49
- body_without_headings = HEADING_LINE_PATTERN.sub("", body_without_blockquotes)
50
- body_without_bullets = BULLET_MARKER_PATTERN.sub("", body_without_headings)
51
- body_without_bold = BOLD_PAIR_PATTERN.sub(r"\1", body_without_bullets)
52
- body_without_emphasis = body_without_bold.replace("*", "")
53
- body_without_links = LINK_TEXT_PATTERN.sub(r"\1", body_without_emphasis)
54
- return body_without_links
55
-
56
-
57
- def _count_substantive_prose_chars(body: str) -> int:
58
- """Return the count of prose characters after stripping Markdown ceremony.
59
-
60
- Collapses internal whitespace so a body of only headers and bullets --
61
- no real WHY paragraph -- registers as effectively empty.
62
- """
63
- stripped_body = strip_markdown_ceremony(body)
64
- body_collapsed = WHITESPACE_RUN_PATTERN.sub(" ", stripped_body).strip()
65
- return len(body_collapsed)
66
-
67
-
68
- def _extract_vague_scan_text(body: str) -> str:
69
- """Return the prose to scan for vague language, with non-prose regions removed.
70
-
71
- Drops whole blockquote lines and whole pipe-delimited table rows, then strips
72
- the same Markdown ceremony as the prose-count path -- which removes fenced
73
- code, inline code, and whole heading lines. This exempts vague phrases that
74
- appear only inside code fences, inline code, Markdown headings, quoted
75
- reviewer text, or pipe-delimited example tables -- those are not the author's
76
- own prose. A pipe-delimited row carries at least two pipes; a line with a
77
- single leading pipe, or a borderless table row with no leading pipe, stays in
78
- scope.
79
- """
80
- without_blockquote_lines = BLOCKQUOTE_LINE_PATTERN.sub("", body)
81
- without_table_rows = TABLE_ROW_LINE_PATTERN.sub("", without_blockquote_lines)
82
- return strip_markdown_ceremony(without_table_rows)
83
-
84
-
85
- def _iter_section_headers(body: str) -> list[str]:
86
- """Return every ATX heading line in the body, preserving canonical form.
87
-
88
- HEADING_LINE_PATTERN matches the leading hash run (one or more hash
89
- characters at line start), so the result spans every ATX level.
90
- Downstream callers in this module only test specific two-hash header
91
- strings, so matching every heading level keeps the parser permissive
92
- without changing behaviour for the canonical two-hash header shape.
93
-
94
- Fenced code blocks are stripped first so example markdown nested inside ``` fences
95
- (a PR body that demonstrates the Heavy shape, for instance) is not counted as a
96
- structural header. This keeps the shape classifier and Heavy required-header check
97
- aligned with `strip_markdown_ceremony`, which already strips fences before measuring.
98
- """
99
- body_without_fences = FENCED_CODE_BLOCK_PATTERN.sub("", body)
100
- all_headers: list[str] = []
101
- for each_match in HEADING_LINE_PATTERN.finditer(body_without_fences):
102
- header_text = each_match.group(0).strip()
103
- all_headers.append(header_text)
104
- return all_headers
105
-
106
-
107
- def _compute_pr_body_shape(body: str) -> str:
108
- """Classify a PR body as `trivial`, `standard`, or `heavy` from content alone.
109
-
110
- Uses substantive prose chars (post-Markdown-strip) rather than raw length so the
111
- classifier and the ceremony-on-Trivial check both measure the same metric against
112
- TRIVIAL_BODY_CHAR_THRESHOLD; otherwise a body can be classified Standard by shape
113
- while simultaneously being flagged as Trivial-sized by the ceremony check.
114
- """
115
- substantive_length = _count_substantive_prose_chars(body)
116
- header_count = len(_iter_section_headers(body))
117
-
118
- if substantive_length < TRIVIAL_BODY_CHAR_THRESHOLD and header_count == 0:
119
- return TRIVIAL_SHAPE
120
-
121
- if substantive_length >= HEAVY_MIN_BODY_CHARS_FOR_CLASSIFICATION:
122
- return HEAVY_SHAPE
123
-
124
- return STANDARD_SHAPE
125
-
126
-
127
- def _body_contains_any_header(body: str, all_candidate_headers: frozenset[str]) -> bool:
128
- body_headers_lower = {each_header.lower() for each_header in _iter_section_headers(body)}
129
- for each_candidate in all_candidate_headers:
130
- candidate_lower = each_candidate.lower()
131
- for each_present in body_headers_lower:
132
- if each_present == candidate_lower:
133
- return True
134
- if each_present.startswith(candidate_lower):
135
- character_after_candidate = each_present[len(candidate_lower)]
136
- if not (character_after_candidate.isalnum() or character_after_candidate == "_"):
137
- return True
138
- return False
139
-
140
-
141
- def _matches_self_closing_reference(body: str, pr_number: int) -> bool:
142
- pattern_source = SELF_REFERENCE_PATTERN_TEMPLATE.format(pr_number=pr_number)
143
- compiled_pattern = re.compile(pattern_source, re.IGNORECASE)
144
- return compiled_pattern.search(body) is not None
145
-
146
-
147
- def _opens_with_this_pr_phrase(body: str) -> bool:
148
- return THIS_PR_OPENING_PATTERN.search(body) is not None