claude-dev-env 1.40.0 → 1.42.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 (66) hide show
  1. package/CLAUDE.md +9 -1
  2. package/_shared/pr-loop/scripts/_claude_permissions_common.py +231 -3
  3. package/_shared/pr-loop/scripts/config/claude_permissions_constants.py +56 -2
  4. package/_shared/pr-loop/scripts/config/claude_settings_keys_constants.py +2 -0
  5. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +173 -6
  6. package/_shared/pr-loop/scripts/post_audit_thread.py +2 -2
  7. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +135 -14
  8. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +385 -0
  9. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +33 -0
  10. package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +1 -1
  11. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +4 -2
  12. package/hooks/_gh_pr_author_swap_utils.py +1211 -0
  13. package/hooks/blocking/gh_body_arg_blocker.py +9 -6
  14. package/hooks/blocking/gh_pr_author_enforcer.py +480 -0
  15. package/hooks/blocking/gh_pr_author_restore.py +100 -0
  16. package/hooks/blocking/pr_converge_bugteam_enforcer.py +170 -0
  17. package/hooks/blocking/pr_description_enforcer.py +1 -3
  18. package/hooks/blocking/test_gh_body_arg_blocker.py +25 -3
  19. package/hooks/blocking/test_gh_pr_author_enforcer.py +1166 -0
  20. package/hooks/blocking/test_gh_pr_author_restore.py +512 -0
  21. package/hooks/blocking/test_gh_pr_author_swap_utils.py +910 -0
  22. package/hooks/blocking/test_pr_converge_bugteam_enforcer.py +311 -0
  23. package/hooks/config/gh_pr_author_swap_constants.py +76 -0
  24. package/hooks/config/pr_converge_bugteam_enforcer_constants.py +55 -0
  25. package/hooks/config/pr_converge_bugteam_enforcer_state.py +67 -0
  26. package/hooks/config/pr_description_enforcer_constants.py +5 -0
  27. package/hooks/config/test_pr_description_enforcer_constants.py +82 -0
  28. package/hooks/hooks.json +40 -0
  29. package/hooks/lifecycle/pr_converge_bugteam_skill_tracker.py +204 -0
  30. package/hooks/lifecycle/test_pr_converge_bugteam_skill_tracker.py +283 -0
  31. package/hooks/session/gh_pr_author_session_cleanup.py +171 -0
  32. package/hooks/session/test_gh_pr_author_session_cleanup.py +575 -0
  33. package/hooks/test__gh_pr_author_swap_utils.py +333 -0
  34. package/package.json +1 -1
  35. package/skills/_shared/pr-loop/scripts/write_audit_outcomes.py +2 -2
  36. package/skills/_shared/pr-loop/scripts/write_fix_outcomes.py +2 -2
  37. package/skills/bugteam/reference/audit-contract.md +22 -0
  38. package/skills/bugteam/reference/github-pr-reviews.md +1 -1
  39. package/skills/bugteam/scripts/_claude_permissions_common.py +109 -0
  40. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +8 -2
  41. package/skills/bugteam/scripts/config/claude_permissions_common_constants.py +51 -2
  42. package/skills/bugteam/scripts/grant_project_claude_permissions.py +115 -4
  43. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +69 -17
  44. package/skills/bugteam/scripts/test__claude_permissions_common.py +48 -0
  45. package/skills/bugteam/scripts/test_agent_config_carveout.py +356 -0
  46. package/skills/bugteam/scripts/test_claude_permissions_common.py +18 -10
  47. package/skills/implement/SKILL.md +66 -0
  48. package/skills/implement/scripts/append_note.py +133 -0
  49. package/skills/implement/scripts/config/__init__.py +0 -0
  50. package/skills/implement/scripts/config/notes_constants.py +12 -0
  51. package/skills/implement/scripts/test_append_note.py +191 -0
  52. package/skills/pr-converge/SKILL.md +8 -2
  53. package/skills/pr-converge/config/constants.py +7 -1
  54. package/skills/pr-converge/reference/state-schema.md +36 -8
  55. package/skills/pr-converge/scripts/check_bugbot_ci.py +1 -1
  56. package/skills/pr-converge/scripts/check_convergence.py +167 -28
  57. package/skills/pr-converge/scripts/check_pending_reviews.py +1 -1
  58. package/skills/pr-converge/scripts/conftest.py +60 -0
  59. package/skills/pr-converge/scripts/fetch_copilot_reviews.py +1 -1
  60. package/skills/pr-converge/scripts/post_fix_reply.py +1 -1
  61. package/skills/pr-converge/scripts/test_check_bugbot_ci.py +1 -1
  62. package/skills/pr-converge/scripts/test_check_convergence.py +306 -0
  63. package/skills/pr-converge/scripts/test_evict_cached_config_modules.py +1 -1
  64. package/skills/refine/SKILL.md +257 -0
  65. package/skills/refine/templates/implementation-notes-template.html +56 -0
  66. package/skills/refine/templates/plan-template.md +60 -0
@@ -0,0 +1,311 @@
1
+ """Unit tests for the pr_converge_bugteam_enforcer PreToolUse hook.
2
+
3
+ Covers the Step 5 BUGTEAM contract: Agent({subagent_type: "clean-coder"})
4
+ calls that look like audit substitutes are blocked when
5
+ $CLAUDE_JOB_DIR/pr-converge-state.json (named by PR_CONVERGE_STATE_FILENAME)
6
+ shows phase=BUGTEAM and the formal Skill({skill: "bugteam"}) has not
7
+ registered at the current HEAD and tick. qbug is explicitly NOT a substitute.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import importlib.util
13
+ import io
14
+ import json
15
+ import pathlib
16
+ import sys
17
+ from typing import Any
18
+ from unittest import mock
19
+
20
+ import pytest
21
+
22
+ _HOOK_DIR = pathlib.Path(__file__).parent
23
+ _HOOKS_TREE = _HOOK_DIR.parent
24
+ for each_path in (str(_HOOK_DIR), str(_HOOKS_TREE)):
25
+ if each_path not in sys.path:
26
+ sys.path.insert(0, each_path)
27
+
28
+ hook_spec = importlib.util.spec_from_file_location(
29
+ "pr_converge_bugteam_enforcer",
30
+ _HOOK_DIR / "pr_converge_bugteam_enforcer.py",
31
+ )
32
+ assert hook_spec is not None
33
+ assert hook_spec.loader is not None
34
+ hook_module = importlib.util.module_from_spec(hook_spec)
35
+ hook_spec.loader.exec_module(hook_module)
36
+
37
+ from config.pr_converge_bugteam_enforcer_constants import (
38
+ BUGTEAM_PHASE,
39
+ CLAUDE_JOB_DIR_ENV_VAR,
40
+ PR_CONVERGE_STATE_FILENAME,
41
+ )
42
+
43
+ _HEAD_SHA_CURRENT = "abc123def456abc123def456abc123def456abcd"
44
+ _HEAD_SHA_STALE = "deadbeef0000deadbeef0000deadbeef0000dead"
45
+ _TICK_CURRENT = 7
46
+ _AUDIT_PROMPT = "Run the bugteam audit and report findings against the A-J categories."
47
+ _FIX_ONLY_PROMPT = "Fix the failing test in tests/test_widget.py by adding a guard clause."
48
+
49
+
50
+ def _write_state(state_directory: pathlib.Path, state: dict[str, Any]) -> pathlib.Path:
51
+ state_path = state_directory / PR_CONVERGE_STATE_FILENAME
52
+ state_path.write_text(json.dumps(state), encoding="utf-8")
53
+ return state_path
54
+
55
+
56
+ def _bugteam_phase_state(**overrides: Any) -> dict[str, Any]:
57
+ baseline_state: dict[str, Any] = {
58
+ "phase": BUGTEAM_PHASE,
59
+ "current_head": _HEAD_SHA_CURRENT,
60
+ "tick_count": _TICK_CURRENT,
61
+ "bugteam_skill_invoked_at_head": None,
62
+ "bugteam_skill_invoked_at_tick": None,
63
+ }
64
+ baseline_state.update(overrides)
65
+ return baseline_state
66
+
67
+
68
+ def _clean_coder_audit_payload(prompt: str = _AUDIT_PROMPT) -> dict[str, Any]:
69
+ return {
70
+ "tool_name": "Agent",
71
+ "tool_input": {
72
+ "subagent_type": "clean-coder",
73
+ "prompt": prompt,
74
+ "description": "Audit the PR",
75
+ },
76
+ }
77
+
78
+
79
+ def _run_main_with_io(input_text: str) -> str:
80
+ with mock.patch("sys.stdin", io.StringIO(input_text)):
81
+ with mock.patch("sys.stdout", new_callable=io.StringIO) as captured_stdout:
82
+ try:
83
+ hook_module.main()
84
+ except SystemExit:
85
+ pass
86
+ return captured_stdout.getvalue()
87
+
88
+
89
+ @pytest.fixture()
90
+ def claude_job_directory(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
91
+ monkeypatch.setenv(CLAUDE_JOB_DIR_ENV_VAR, str(tmp_path))
92
+ return tmp_path
93
+
94
+
95
+ def test_should_allow_when_state_file_absent(claude_job_directory: pathlib.Path) -> None:
96
+ payload_text = json.dumps(_clean_coder_audit_payload())
97
+ captured_output = _run_main_with_io(payload_text)
98
+ assert captured_output == ""
99
+
100
+
101
+ def test_should_allow_when_phase_not_bugteam(claude_job_directory: pathlib.Path) -> None:
102
+ _write_state(claude_job_directory, _bugteam_phase_state(phase="BUGBOT"))
103
+ payload_text = json.dumps(_clean_coder_audit_payload())
104
+ captured_output = _run_main_with_io(payload_text)
105
+ assert captured_output == ""
106
+
107
+
108
+ def test_should_allow_when_subagent_type_not_clean_coder(
109
+ claude_job_directory: pathlib.Path,
110
+ ) -> None:
111
+ _write_state(claude_job_directory, _bugteam_phase_state())
112
+ explore_payload: dict[str, Any] = {
113
+ "tool_name": "Agent",
114
+ "tool_input": {"subagent_type": "Explore", "prompt": _AUDIT_PROMPT},
115
+ }
116
+ captured_output = _run_main_with_io(json.dumps(explore_payload))
117
+ assert captured_output == ""
118
+
119
+
120
+ def test_should_allow_when_prompt_lacks_audit_keywords(
121
+ claude_job_directory: pathlib.Path,
122
+ ) -> None:
123
+ _write_state(claude_job_directory, _bugteam_phase_state())
124
+ payload_text = json.dumps(_clean_coder_audit_payload(prompt=_FIX_ONLY_PROMPT))
125
+ captured_output = _run_main_with_io(payload_text)
126
+ assert captured_output == ""
127
+
128
+
129
+ def test_should_block_when_clean_coder_audit_without_bugteam_skill_invocation(
130
+ claude_job_directory: pathlib.Path,
131
+ ) -> None:
132
+ _write_state(claude_job_directory, _bugteam_phase_state())
133
+ captured_output = _run_main_with_io(json.dumps(_clean_coder_audit_payload()))
134
+ deny_payload = json.loads(captured_output)
135
+ assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
136
+ assert "bugteam-enforcer" in deny_payload["hookSpecificOutput"]["permissionDecisionReason"]
137
+
138
+
139
+ def test_should_allow_after_bugteam_skill_invoked_at_current_head_and_tick(
140
+ claude_job_directory: pathlib.Path,
141
+ ) -> None:
142
+ _write_state(
143
+ claude_job_directory,
144
+ _bugteam_phase_state(
145
+ bugteam_skill_invoked_at_head=_HEAD_SHA_CURRENT,
146
+ bugteam_skill_invoked_at_tick=_TICK_CURRENT,
147
+ ),
148
+ )
149
+ captured_output = _run_main_with_io(json.dumps(_clean_coder_audit_payload()))
150
+ assert captured_output == ""
151
+
152
+
153
+ def test_should_block_when_bugteam_skill_invocation_is_stale_head(
154
+ claude_job_directory: pathlib.Path,
155
+ ) -> None:
156
+ _write_state(
157
+ claude_job_directory,
158
+ _bugteam_phase_state(
159
+ bugteam_skill_invoked_at_head=_HEAD_SHA_STALE,
160
+ bugteam_skill_invoked_at_tick=_TICK_CURRENT,
161
+ ),
162
+ )
163
+ captured_output = _run_main_with_io(json.dumps(_clean_coder_audit_payload()))
164
+ deny_payload = json.loads(captured_output)
165
+ assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
166
+
167
+
168
+ def test_should_block_when_qbug_was_invoked_but_not_bugteam(
169
+ claude_job_directory: pathlib.Path,
170
+ ) -> None:
171
+ _write_state(
172
+ claude_job_directory,
173
+ _bugteam_phase_state(
174
+ bugteam_skill_invoked_at_head=None,
175
+ bugteam_skill_invoked_at_tick=None,
176
+ ),
177
+ )
178
+ captured_output = _run_main_with_io(json.dumps(_clean_coder_audit_payload()))
179
+ deny_payload = json.loads(captured_output)
180
+ assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
181
+
182
+
183
+ def test_should_block_when_bugteam_invoked_at_current_head_but_previous_tick(
184
+ claude_job_directory: pathlib.Path,
185
+ ) -> None:
186
+ _write_state(
187
+ claude_job_directory,
188
+ _bugteam_phase_state(
189
+ bugteam_skill_invoked_at_head=_HEAD_SHA_CURRENT,
190
+ bugteam_skill_invoked_at_tick=_TICK_CURRENT - 1,
191
+ ),
192
+ )
193
+ captured_output = _run_main_with_io(json.dumps(_clean_coder_audit_payload()))
194
+ deny_payload = json.loads(captured_output)
195
+ assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
196
+
197
+
198
+ def test_should_block_when_invoked_head_is_non_string_type(
199
+ claude_job_directory: pathlib.Path,
200
+ ) -> None:
201
+ _write_state(
202
+ claude_job_directory,
203
+ _bugteam_phase_state(
204
+ bugteam_skill_invoked_at_head=42,
205
+ bugteam_skill_invoked_at_tick=_TICK_CURRENT,
206
+ ),
207
+ )
208
+ captured_output = _run_main_with_io(json.dumps(_clean_coder_audit_payload()))
209
+ deny_payload = json.loads(captured_output)
210
+ assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
211
+
212
+
213
+ def test_should_block_when_current_head_is_non_string_type(
214
+ claude_job_directory: pathlib.Path,
215
+ ) -> None:
216
+ _write_state(
217
+ claude_job_directory,
218
+ _bugteam_phase_state(
219
+ current_head=42,
220
+ bugteam_skill_invoked_at_head=_HEAD_SHA_CURRENT,
221
+ bugteam_skill_invoked_at_tick=_TICK_CURRENT,
222
+ ),
223
+ )
224
+ captured_output = _run_main_with_io(json.dumps(_clean_coder_audit_payload()))
225
+ deny_payload = json.loads(captured_output)
226
+ assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
227
+
228
+
229
+ def test_should_block_when_invoked_tick_is_string_type(
230
+ claude_job_directory: pathlib.Path,
231
+ ) -> None:
232
+ _write_state(
233
+ claude_job_directory,
234
+ _bugteam_phase_state(
235
+ bugteam_skill_invoked_at_head=_HEAD_SHA_CURRENT,
236
+ bugteam_skill_invoked_at_tick="7",
237
+ ),
238
+ )
239
+ captured_output = _run_main_with_io(json.dumps(_clean_coder_audit_payload()))
240
+ deny_payload = json.loads(captured_output)
241
+ assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
242
+
243
+
244
+ def test_should_block_when_current_tick_is_string_type(
245
+ claude_job_directory: pathlib.Path,
246
+ ) -> None:
247
+ _write_state(
248
+ claude_job_directory,
249
+ _bugteam_phase_state(
250
+ tick_count="7",
251
+ bugteam_skill_invoked_at_head=_HEAD_SHA_CURRENT,
252
+ bugteam_skill_invoked_at_tick=_TICK_CURRENT,
253
+ ),
254
+ )
255
+ captured_output = _run_main_with_io(json.dumps(_clean_coder_audit_payload()))
256
+ deny_payload = json.loads(captured_output)
257
+ assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
258
+
259
+
260
+ def test_should_block_when_invoked_tick_is_bool_type(
261
+ claude_job_directory: pathlib.Path,
262
+ ) -> None:
263
+ _write_state(
264
+ claude_job_directory,
265
+ _bugteam_phase_state(
266
+ tick_count=1,
267
+ bugteam_skill_invoked_at_head=_HEAD_SHA_CURRENT,
268
+ bugteam_skill_invoked_at_tick=True,
269
+ ),
270
+ )
271
+ captured_output = _run_main_with_io(json.dumps(_clean_coder_audit_payload()))
272
+ deny_payload = json.loads(captured_output)
273
+ assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
274
+
275
+
276
+ def test_should_block_when_current_tick_is_bool_type(
277
+ claude_job_directory: pathlib.Path,
278
+ ) -> None:
279
+ _write_state(
280
+ claude_job_directory,
281
+ _bugteam_phase_state(
282
+ tick_count=True,
283
+ bugteam_skill_invoked_at_head=_HEAD_SHA_CURRENT,
284
+ bugteam_skill_invoked_at_tick=1,
285
+ ),
286
+ )
287
+ captured_output = _run_main_with_io(json.dumps(_clean_coder_audit_payload()))
288
+ deny_payload = json.loads(captured_output)
289
+ assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
290
+
291
+
292
+ def test_should_allow_when_claude_job_dir_env_var_absent(
293
+ monkeypatch: pytest.MonkeyPatch,
294
+ ) -> None:
295
+ monkeypatch.delenv(CLAUDE_JOB_DIR_ENV_VAR, raising=False)
296
+ captured_output = _run_main_with_io(json.dumps(_clean_coder_audit_payload()))
297
+ assert captured_output == ""
298
+
299
+
300
+ def test_should_allow_when_state_json_is_malformed(
301
+ claude_job_directory: pathlib.Path,
302
+ ) -> None:
303
+ state_path = claude_job_directory / PR_CONVERGE_STATE_FILENAME
304
+ state_path.write_text("{not valid json", encoding="utf-8")
305
+ captured_output = _run_main_with_io(json.dumps(_clean_coder_audit_payload()))
306
+ assert captured_output == ""
307
+
308
+
309
+ def test_should_allow_when_payload_is_malformed_json() -> None:
310
+ captured_output = _run_main_with_io("not valid json {{{")
311
+ assert captured_output == ""
@@ -0,0 +1,76 @@
1
+ """Configuration constants for the gh-pr-author swap hook trio.
2
+
3
+ The PreToolUse enforcer (``gh_pr_author_enforcer.py``) auto-switches the
4
+ active ``gh`` CLI account to ``GITHUB_DEFAULT_ACCOUNT`` before a
5
+ ``gh pr create`` invocation, the PostToolUse companion
6
+ (``gh_pr_author_restore.py``) restores the prior account afterwards, and
7
+ the SessionStart cleanup hook (``gh_pr_author_session_cleanup.py``)
8
+ sweeps any stale state files left behind when a prior session was
9
+ interrupted between the swap and the restore. The state file written
10
+ between the hooks is keyed per session so parallel Claude Code sessions
11
+ cannot stomp on each other's swap state.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+
18
+ REQUIRED_ACCOUNT_ENV_VAR: str = "GITHUB_DEFAULT_ACCOUNT"
19
+
20
+ BASH_TOOL_NAME: str = "Bash"
21
+
22
+ GH_PR_CREATE_PATTERN: re.Pattern[str] = re.compile(
23
+ r"(?:^|[;&|\n`({]|\$\()[ \t]*"
24
+ r"(?:(?:if|then|else|elif|while|until|do|!)[ \t]+)*"
25
+ r"(?:[A-Za-z_][A-Za-z0-9_]*=\S+[ \t]+)*"
26
+ r"gh(?:[ \t]+(?:--[A-Za-z][\w-]*(?:=\S+)?|-[A-Za-z])(?:[ \t]+(?!-)\S+)?)*"
27
+ r"[ \t]+pr[ \t]+create\b",
28
+ re.IGNORECASE,
29
+ )
30
+ WEB_FLAG_PATTERN: re.Pattern[str] = re.compile(r"(?<!\S)(?:--web|-w)(?!\S)")
31
+ COMMAND_SEPARATOR_PATTERN: re.Pattern[str] = re.compile(
32
+ r"(?:&&|\|\||;|(?<!\|)\|(?!\|)|(?<!&)&(?!&)|[\r\n])"
33
+ )
34
+ BASH_COMMENT_INTRODUCER_CHARACTER: str = "#"
35
+ COMMAND_SUBSTITUTION_OPENER_LENGTH: int = 2
36
+
37
+ ALL_GH_API_USER_COMMAND: tuple[str, ...] = ("gh", "api", "user", "--jq", ".login")
38
+ GH_API_USER_TIMEOUT_SECONDS: int = 5
39
+
40
+ ALL_GH_AUTH_SWITCH_COMMAND_HEAD: tuple[str, ...] = ("gh", "auth", "switch", "--user")
41
+ GH_AUTH_SWITCH_TIMEOUT_SECONDS: int = 10
42
+
43
+ STATE_FILE_PREFIX: str = "gh_pr_author_swap_"
44
+ STATE_FILE_SUFFIX: str = ".json"
45
+ STATE_FILE_DEFAULT_SESSION_ID: str = "default"
46
+
47
+ SESSION_ID_UNSAFE_CHARACTERS_PATTERN: re.Pattern[str] = re.compile(r"[^A-Za-z0-9_-]")
48
+
49
+ STATE_FILE_ORIGINAL_ACCOUNT_KEY: str = "original_account"
50
+ STATE_FILE_PRIMARY_ACCOUNT_KEY: str = "primary_account"
51
+
52
+ STATE_FILE_PERMISSION_MODE: int = 0o600
53
+
54
+ STATE_FILE_PAYLOAD_TEXT_ENCODING_NAME: str = "utf-8"
55
+
56
+ OS_O_NOFOLLOW_ATTRIBUTE_NAME: str = "O_NOFOLLOW"
57
+
58
+ STATE_FILE_STALE_AGE_SECONDS: int = 1800
59
+
60
+ ALL_SHELL_QUOTE_CHARACTERS: tuple[str, ...] = ("\"", "'")
61
+ SHELL_QUOTE_REPLACEMENT_CHARACTER: str = " "
62
+ SHELL_BACKSLASH_ESCAPE_PAIR_LENGTH: int = 2
63
+ SHELL_BACKTICK_CHARACTER: str = "`"
64
+ SHELL_DOLLAR_CHARACTER: str = "$"
65
+ SHELL_PAREN_OPEN_CHARACTER: str = "("
66
+ SHELL_PAREN_CLOSE_CHARACTER: str = ")"
67
+ SHELL_LESS_THAN_CHARACTER: str = "<"
68
+ SHELL_BACKSLASH_CHARACTER: str = "\\"
69
+ SHELL_NEWLINE_CHARACTER: str = "\n"
70
+
71
+ HEREDOC_OPENER_TAG_PATTERN: re.Pattern[str] = re.compile(
72
+ r"[ \t]*(?P<dash>-?)[ \t]*(?:'(?P<sq_tag>[A-Za-z_][A-Za-z0-9_]*)'"
73
+ r"|\"(?P<dq_tag>[A-Za-z_][A-Za-z0-9_]*)\""
74
+ r"|(?P<bare_tag>[A-Za-z_][A-Za-z0-9_]*))"
75
+ )
76
+ HEREDOC_OPENER_TOKEN_LENGTH: int = 2
@@ -0,0 +1,55 @@
1
+ """Configuration constants for the pr_converge_bugteam_enforcer hook pair.
2
+
3
+ The enforcer denies ``Agent({subagent_type: "clean-coder"})`` invocations that
4
+ substitute audit-shaped work for the formal ``Skill({skill: "bugteam"})`` call
5
+ during Step 5 BUGTEAM of the pr-converge loop. The tracker records every
6
+ formal ``Skill({skill: "bugteam"})`` invocation so the enforcer can confirm
7
+ the Skill fired this tick at the current HEAD before allowing follow-on
8
+ clean-coder spawns.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ AGENT_TOOL_NAME: str = "Agent"
14
+ SKILL_TOOL_NAME: str = "Skill"
15
+
16
+ CLEAN_CODER_SUBAGENT_TYPE: str = "clean-coder"
17
+ BUGTEAM_SKILL_NAME: str = "bugteam"
18
+
19
+ PR_CONVERGE_STATE_FILENAME: str = "pr-converge-state.json"
20
+ CLAUDE_JOB_DIR_ENV_VAR: str = "CLAUDE_JOB_DIR"
21
+
22
+ BUGTEAM_PHASE: str = "BUGTEAM"
23
+
24
+ STATE_FIELD_PHASE: str = "phase"
25
+ STATE_FIELD_CURRENT_HEAD: str = "current_head"
26
+ STATE_FIELD_TICK_COUNT: str = "tick_count"
27
+ STATE_FIELD_BUGTEAM_SKILL_INVOKED_AT_HEAD: str = "bugteam_skill_invoked_at_head"
28
+ STATE_FIELD_BUGTEAM_SKILL_INVOKED_AT_TICK: str = "bugteam_skill_invoked_at_tick"
29
+
30
+ ALL_AUDIT_PROMPT_SUBSTRINGS: tuple[str, ...] = (
31
+ "audit",
32
+ "findings",
33
+ "bugteam",
34
+ "a-j categor",
35
+ "code-quality",
36
+ "verify_clean",
37
+ "converge",
38
+ )
39
+
40
+ ENFORCER_CORRECTIVE_MESSAGE: str = (
41
+ "BLOCKED [pr-converge-bugteam-enforcer]: Step 5 BUGTEAM advances ONLY after "
42
+ '`Skill({skill: "bugteam", args: "<PR URL>"})` fires this tick at the '
43
+ 'current HEAD. Substituting an `Agent({subagent_type: "clean-coder"})` '
44
+ "audit call for the formal Skill invocation is a protocol violation — the "
45
+ "formal Skill writes the artifact `check_convergence.py`'s `bugteam_clean_at` "
46
+ "gate looks for, and a substituted audit silently bypasses that gate.\n\n"
47
+ "`qbug` is NOT an accepted substitute — `bugteam` is the only allowed skill "
48
+ "at this step.\n\n"
49
+ 'Run `Skill({skill: "bugteam", args: "<PR URL>"})` first. Follow-on '
50
+ "clean-coder fix spawns are allowed once the formal Skill has registered at "
51
+ "the current HEAD and tick."
52
+ )
53
+
54
+ STATE_FILE_ATOMIC_WRITE_SUFFIX: str = ".tmp"
55
+ STATE_FILE_JSON_INDENT_SPACES: int = 2
@@ -0,0 +1,67 @@
1
+ """Shared state-loading helpers for the pr_converge_bugteam_enforcer hook pair.
2
+
3
+ Both the enforcer (``pr_converge_bugteam_enforcer.py``) and the tracker
4
+ (``pr_converge_bugteam_skill_tracker.py``) read the per-job
5
+ ``$CLAUDE_JOB_DIR/pr-converge-state.json`` file from the same per-job
6
+ directory. This module hosts the byte-identical ``load_state_dictionary``
7
+ and ``resolve_state_path`` helpers so each hook imports a single canonical
8
+ definition.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ from pathlib import Path
16
+
17
+ from config.pr_converge_bugteam_enforcer_constants import (
18
+ CLAUDE_JOB_DIR_ENV_VAR,
19
+ PR_CONVERGE_STATE_FILENAME,
20
+ )
21
+
22
+
23
+ def load_state_dictionary(state_path: Path) -> dict[str, object] | None:
24
+ """Return the parsed pr-converge state, or None when absent or unparseable.
25
+
26
+ Args:
27
+ state_path: Absolute path to ``pr-converge-state.json``.
28
+
29
+ Returns:
30
+ The decoded state dictionary, or None when the file is missing,
31
+ malformed, empty, or not a JSON object at the root.
32
+ """
33
+ if not state_path.is_file():
34
+ return None
35
+ try:
36
+ raw_text = state_path.read_text(encoding="utf-8")
37
+ except OSError:
38
+ return None
39
+ if not raw_text.strip():
40
+ return None
41
+ try:
42
+ parsed_state = json.loads(raw_text)
43
+ except json.JSONDecodeError:
44
+ return None
45
+ if not isinstance(parsed_state, dict):
46
+ return None
47
+ return parsed_state
48
+
49
+
50
+ def resolve_state_path() -> Path | None:
51
+ """Return the absolute path to the per-job ``pr-converge-state.json``.
52
+
53
+ Reads ``$CLAUDE_JOB_DIR`` from the environment and joins
54
+ ``PR_CONVERGE_STATE_FILENAME`` onto it. The path is returned even when
55
+ the file does not yet exist on disk; callers are expected to call
56
+ ``load_state_dictionary`` (or ``state_path.is_file()``) to check for
57
+ presence.
58
+
59
+ Returns:
60
+ Absolute path to ``$CLAUDE_JOB_DIR/pr-converge-state.json``, or
61
+ ``None`` when the ``CLAUDE_JOB_DIR`` environment variable is unset
62
+ or empty (no single-PR pr-converge job is currently active).
63
+ """
64
+ job_directory = os.environ.get(CLAUDE_JOB_DIR_ENV_VAR, "")
65
+ if not job_directory:
66
+ return None
67
+ return Path(job_directory) / PR_CONVERGE_STATE_FILENAME
@@ -1,7 +1,12 @@
1
1
  """Configuration constants for the pr_description_enforcer PreToolUse hook."""
2
2
 
3
+ import os
3
4
  import re
4
5
 
6
+
7
+ _PLUGIN_ROOT: str = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
8
+ PR_GUIDE_PATH: str = os.path.join(_PLUGIN_ROOT, "docs", "PR_DESCRIPTION_GUIDE.md")
9
+
5
10
  MINIMUM_SUBSTANTIVE_PROSE_CHARS: int = 40
6
11
 
7
12
  FENCED_CODE_BLOCK_PATTERN: re.Pattern[str] = re.compile(r"```.*?```", re.DOTALL)
@@ -0,0 +1,82 @@
1
+ """Behavior tests for pr_description_enforcer_constants module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ _HOOKS_ROOT = Path(__file__).resolve().parent.parent
10
+ if str(_HOOKS_ROOT) not in sys.path:
11
+ sys.path.insert(0, str(_HOOKS_ROOT))
12
+
13
+ from config import pr_description_enforcer_constants as constants_module
14
+
15
+
16
+ def test_plugin_root_is_private_module_attribute() -> None:
17
+ assert hasattr(constants_module, "_PLUGIN_ROOT")
18
+ assert isinstance(constants_module._PLUGIN_ROOT, str)
19
+ assert os.path.isabs(constants_module._PLUGIN_ROOT)
20
+
21
+
22
+ def test_plugin_root_public_name_is_not_exported() -> None:
23
+ assert not hasattr(constants_module, "PLUGIN_ROOT")
24
+
25
+
26
+ def test_pr_guide_path_resolves_under_plugin_root_docs() -> None:
27
+ expected_pr_guide_path = os.path.join(
28
+ constants_module._PLUGIN_ROOT, "docs", "PR_DESCRIPTION_GUIDE.md"
29
+ )
30
+ assert constants_module.PR_GUIDE_PATH == expected_pr_guide_path
31
+
32
+
33
+ def test_minimum_substantive_prose_chars_is_positive_integer() -> None:
34
+ assert isinstance(constants_module.MINIMUM_SUBSTANTIVE_PROSE_CHARS, int)
35
+ assert constants_module.MINIMUM_SUBSTANTIVE_PROSE_CHARS > 0
36
+
37
+
38
+ def test_fenced_code_block_pattern_matches_triple_backtick_block() -> None:
39
+ sample_markdown = "before ```python\ncode\n``` after"
40
+ match = constants_module.FENCED_CODE_BLOCK_PATTERN.search(sample_markdown)
41
+ assert match is not None
42
+ assert match.group(0).startswith("```")
43
+ assert match.group(0).endswith("```")
44
+
45
+
46
+ def test_inline_code_pattern_matches_single_backtick_span() -> None:
47
+ match = constants_module.INLINE_CODE_PATTERN.search("see `value` here")
48
+ assert match is not None
49
+ assert match.group(0) == "`value`"
50
+
51
+
52
+ def test_heading_line_pattern_matches_atx_heading() -> None:
53
+ match = constants_module.HEADING_LINE_PATTERN.search("## Description\n")
54
+ assert match is not None
55
+ assert match.group(0).strip() == "## Description"
56
+
57
+
58
+ def test_bold_pair_pattern_captures_inner_text() -> None:
59
+ match = constants_module.BOLD_PAIR_PATTERN.search("this is **bold** text")
60
+ assert match is not None
61
+ assert match.group(1) == "bold"
62
+
63
+
64
+ def test_bullet_marker_pattern_strips_dash_bullet_from_line() -> None:
65
+ stripped_line = constants_module.BULLET_MARKER_PATTERN.sub("", "- first item")
66
+ assert stripped_line == "first item"
67
+
68
+
69
+ def test_blockquote_marker_pattern_strips_quote_marker_from_line() -> None:
70
+ stripped_line = constants_module.BLOCKQUOTE_MARKER_PATTERN.sub("", "> quoted line")
71
+ assert stripped_line == "quoted line"
72
+
73
+
74
+ def test_link_text_pattern_captures_anchor_text() -> None:
75
+ match = constants_module.LINK_TEXT_PATTERN.search("See [the docs](https://example.com) now")
76
+ assert match is not None
77
+ assert match.group(1) == "the docs"
78
+
79
+
80
+ def test_whitespace_run_pattern_collapses_multiple_spaces() -> None:
81
+ collapsed_text = constants_module.WHITESPACE_RUN_PATTERN.sub(" ", "a b\t\tc\n\nd")
82
+ assert collapsed_text == "a b c d"
package/hooks/hooks.json CHANGED
@@ -124,6 +124,11 @@
124
124
  "type": "command",
125
125
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/windows_rmtree_blocker.py",
126
126
  "timeout": 10
127
+ },
128
+ {
129
+ "type": "command",
130
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/gh_pr_author_enforcer.py",
131
+ "timeout": 30
127
132
  }
128
133
  ]
129
134
  },
@@ -136,6 +141,26 @@
136
141
  "timeout": 10
137
142
  }
138
143
  ]
144
+ },
145
+ {
146
+ "matcher": "Agent",
147
+ "hooks": [
148
+ {
149
+ "type": "command",
150
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/pr_converge_bugteam_enforcer.py",
151
+ "timeout": 10
152
+ }
153
+ ]
154
+ },
155
+ {
156
+ "matcher": "Skill",
157
+ "hooks": [
158
+ {
159
+ "type": "command",
160
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/lifecycle/pr_converge_bugteam_skill_tracker.py",
161
+ "timeout": 10
162
+ }
163
+ ]
139
164
  }
140
165
  ],
141
166
  "SessionStart": [
@@ -156,6 +181,11 @@
156
181
  "type": "command",
157
182
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/untracked_repo_detector.py",
158
183
  "timeout": 10
184
+ },
185
+ {
186
+ "type": "command",
187
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/gh_pr_author_session_cleanup.py",
188
+ "timeout": 30
159
189
  }
160
190
  ]
161
191
  }
@@ -241,6 +271,16 @@
241
271
  "timeout": 10
242
272
  }
243
273
  ]
274
+ },
275
+ {
276
+ "matcher": "Bash",
277
+ "hooks": [
278
+ {
279
+ "type": "command",
280
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/gh_pr_author_restore.py",
281
+ "timeout": 20
282
+ }
283
+ ]
244
284
  }
245
285
  ],
246
286
  "InstructionsLoaded": [