claude-dev-env 1.39.0 → 1.41.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.
- package/CLAUDE.md +1 -1
- package/_shared/pr-loop/scripts/config/post_audit_thread_constants.py +10 -0
- package/_shared/pr-loop/scripts/config/reviews_disabled_constants.py +8 -0
- package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +53 -3
- package/_shared/pr-loop/scripts/post_audit_thread.py +298 -3
- package/_shared/pr-loop/scripts/preflight.py +129 -2
- package/_shared/pr-loop/scripts/reviews_disabled.py +59 -0
- package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +68 -3
- package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +1 -1
- package/_shared/pr-loop/scripts/tests/test_post_audit_thread.py +194 -1
- package/_shared/pr-loop/scripts/tests/test_preflight.py +41 -0
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +36 -0
- package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +1 -1
- package/agents/pr-description-writer.md +150 -52
- package/docs/PR_DESCRIPTION_GUIDE.md +127 -64
- package/hooks/_gh_pr_author_swap_utils.py +1211 -0
- package/hooks/blocking/gh_body_arg_blocker.py +9 -6
- package/hooks/blocking/gh_pr_author_enforcer.py +480 -0
- package/hooks/blocking/gh_pr_author_restore.py +100 -0
- package/hooks/blocking/pr_converge_bugteam_enforcer.py +170 -0
- package/hooks/blocking/pr_description_enforcer.py +56 -23
- package/hooks/blocking/test_gh_body_arg_blocker.py +25 -3
- package/hooks/blocking/test_gh_pr_author_enforcer.py +1166 -0
- package/hooks/blocking/test_gh_pr_author_restore.py +512 -0
- package/hooks/blocking/test_gh_pr_author_swap_utils.py +910 -0
- package/hooks/blocking/test_pr_converge_bugteam_enforcer.py +311 -0
- package/hooks/blocking/test_pr_description_enforcer.py +69 -8
- package/hooks/config/gh_pr_author_swap_constants.py +76 -0
- package/hooks/config/pr_converge_bugteam_enforcer_constants.py +55 -0
- package/hooks/config/pr_converge_bugteam_enforcer_state.py +67 -0
- package/hooks/config/pr_description_enforcer_constants.py +19 -0
- package/hooks/config/test_pr_description_enforcer_constants.py +82 -0
- package/hooks/hooks.json +40 -0
- package/hooks/lifecycle/pr_converge_bugteam_skill_tracker.py +204 -0
- package/hooks/lifecycle/test_pr_converge_bugteam_skill_tracker.py +283 -0
- package/hooks/session/gh_pr_author_session_cleanup.py +171 -0
- package/hooks/session/test_gh_pr_author_session_cleanup.py +575 -0
- package/hooks/test__gh_pr_author_swap_utils.py +333 -0
- package/package.json +1 -1
- package/skills/_shared/pr-loop/scripts/write_audit_outcomes.py +2 -2
- package/skills/_shared/pr-loop/scripts/write_fix_outcomes.py +2 -2
- package/skills/bugteam/SKILL.md +28 -10
- package/skills/bugteam/reference/audit-contract.md +22 -0
- package/skills/bugteam/reference/github-pr-reviews.md +1 -1
- package/skills/bugteam/reference/team-setup.md +5 -0
- package/skills/bugteam/scripts/bugteam_fix_hookspath.py +8 -2
- package/skills/bugteam/scripts/bugteam_preflight.py +36 -2
- package/skills/bugteam/scripts/test__claude_permissions_common.py +48 -0
- package/skills/bugteam/scripts/test_bugteam_preflight.py +41 -0
- package/skills/bugteam/scripts/test_claude_permissions_common.py +18 -10
- package/skills/copilot-review/SKILL.md +16 -0
- package/skills/findbugs/SKILL.md +35 -7
- package/skills/monitor-open-prs/SKILL.md +2 -1
- package/skills/pr-converge/SKILL.md +11 -3
- package/skills/pr-converge/config/constants.py +3 -1
- package/skills/pr-converge/reference/per-tick.md +17 -0
- package/skills/pr-converge/reference/state-schema.md +36 -8
- package/skills/pr-converge/scripts/check_bugbot_ci.py +113 -8
- package/skills/pr-converge/scripts/test_check_bugbot_ci.py +312 -0
- package/skills/qbug/SKILL.md +33 -8
|
@@ -0,0 +1,170 @@
|
|
|
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
|
+
|
|
29
|
+
def _insert_hooks_tree_for_imports() -> None:
|
|
30
|
+
hooks_tree = Path(__file__).resolve().parent.parent
|
|
31
|
+
hooks_tree_string = str(hooks_tree)
|
|
32
|
+
if hooks_tree_string not in sys.path:
|
|
33
|
+
sys.path.insert(0, hooks_tree_string)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_insert_hooks_tree_for_imports()
|
|
37
|
+
|
|
38
|
+
from config.pr_converge_bugteam_enforcer_constants import (
|
|
39
|
+
AGENT_TOOL_NAME,
|
|
40
|
+
ALL_AUDIT_PROMPT_SUBSTRINGS,
|
|
41
|
+
BUGTEAM_PHASE,
|
|
42
|
+
CLEAN_CODER_SUBAGENT_TYPE,
|
|
43
|
+
ENFORCER_CORRECTIVE_MESSAGE,
|
|
44
|
+
STATE_FIELD_BUGTEAM_SKILL_INVOKED_AT_HEAD,
|
|
45
|
+
STATE_FIELD_BUGTEAM_SKILL_INVOKED_AT_TICK,
|
|
46
|
+
STATE_FIELD_CURRENT_HEAD,
|
|
47
|
+
STATE_FIELD_PHASE,
|
|
48
|
+
STATE_FIELD_TICK_COUNT,
|
|
49
|
+
)
|
|
50
|
+
from config.pr_converge_bugteam_enforcer_state import (
|
|
51
|
+
load_state_dictionary,
|
|
52
|
+
resolve_state_path,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _prompt_is_audit_shaped(agent_prompt: str) -> bool:
|
|
57
|
+
"""Return True when the Agent prompt looks like an audit substitute.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
agent_prompt: The ``prompt`` field of the Agent tool_input.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
True when any audit-shaped substring appears in the prompt
|
|
64
|
+
(case-insensitive); False for fix-only or unrelated prompts.
|
|
65
|
+
"""
|
|
66
|
+
lowercased_prompt = agent_prompt.lower()
|
|
67
|
+
return any(
|
|
68
|
+
each_substring in lowercased_prompt for each_substring in ALL_AUDIT_PROMPT_SUBSTRINGS
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _has_formal_skill_fired_this_tick(state_by_field: dict[str, object]) -> bool:
|
|
73
|
+
"""Return True when the bugteam Skill registered at current HEAD and tick.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
state_by_field: Parsed pr-converge state.json mapping each field name
|
|
77
|
+
to its recorded value.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
True when both ``bugteam_skill_invoked_at_head`` matches
|
|
81
|
+
``current_head`` and ``bugteam_skill_invoked_at_tick`` matches
|
|
82
|
+
``tick_count``; False when either is missing, stale, or carries a
|
|
83
|
+
type that violates state-schema.md (head must be ``str``, tick must
|
|
84
|
+
be a non-bool ``int``) — type-invalid values fail closed so the
|
|
85
|
+
enforcer rejects corrupted state.
|
|
86
|
+
"""
|
|
87
|
+
invoked_head = state_by_field.get(STATE_FIELD_BUGTEAM_SKILL_INVOKED_AT_HEAD)
|
|
88
|
+
current_head = state_by_field.get(STATE_FIELD_CURRENT_HEAD)
|
|
89
|
+
invoked_tick = state_by_field.get(STATE_FIELD_BUGTEAM_SKILL_INVOKED_AT_TICK)
|
|
90
|
+
current_tick = state_by_field.get(STATE_FIELD_TICK_COUNT)
|
|
91
|
+
if not isinstance(invoked_head, str) or not isinstance(current_head, str):
|
|
92
|
+
return False
|
|
93
|
+
if invoked_head != current_head:
|
|
94
|
+
return False
|
|
95
|
+
if isinstance(invoked_tick, bool) or isinstance(current_tick, bool):
|
|
96
|
+
return False
|
|
97
|
+
if not isinstance(invoked_tick, int) or not isinstance(current_tick, int):
|
|
98
|
+
return False
|
|
99
|
+
return invoked_tick == current_tick
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _should_block(payload_by_field: dict[str, object]) -> bool:
|
|
103
|
+
"""Return True when the Agent call is a BUGTEAM-phase Skill substitution.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
payload_by_field: The full PreToolUse hook payload (already JSON-parsed),
|
|
107
|
+
keyed by top-level field name.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
True when every gating condition holds: tool is Agent, subagent_type
|
|
111
|
+
is clean-coder, prompt is audit-shaped, pr-converge state.json exists
|
|
112
|
+
with phase BUGTEAM, and the formal bugteam Skill has NOT been
|
|
113
|
+
recorded at the current HEAD and tick. False otherwise.
|
|
114
|
+
"""
|
|
115
|
+
if payload_by_field.get("tool_name", "") != AGENT_TOOL_NAME:
|
|
116
|
+
return False
|
|
117
|
+
tool_input = payload_by_field.get("tool_input", {})
|
|
118
|
+
if not isinstance(tool_input, dict):
|
|
119
|
+
return False
|
|
120
|
+
if tool_input.get("subagent_type", "") != CLEAN_CODER_SUBAGENT_TYPE:
|
|
121
|
+
return False
|
|
122
|
+
agent_prompt = tool_input.get("prompt", "")
|
|
123
|
+
if not isinstance(agent_prompt, str):
|
|
124
|
+
return False
|
|
125
|
+
if not _prompt_is_audit_shaped(agent_prompt):
|
|
126
|
+
return False
|
|
127
|
+
state_path = resolve_state_path()
|
|
128
|
+
if state_path is None:
|
|
129
|
+
return False
|
|
130
|
+
parsed_state = load_state_dictionary(state_path)
|
|
131
|
+
if parsed_state is None:
|
|
132
|
+
return False
|
|
133
|
+
if parsed_state.get(STATE_FIELD_PHASE) != BUGTEAM_PHASE:
|
|
134
|
+
return False
|
|
135
|
+
return not _has_formal_skill_fired_this_tick(parsed_state)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _emit_deny_payload(output_stream: TextIO) -> None:
|
|
139
|
+
"""Write the PreToolUse deny payload to the provided stream.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
output_stream: Writable text stream — production code passes
|
|
143
|
+
``sys.stdout``; tests pass a ``StringIO`` to capture the JSON.
|
|
144
|
+
"""
|
|
145
|
+
deny_payload = {
|
|
146
|
+
"hookSpecificOutput": {
|
|
147
|
+
"hookEventName": "PreToolUse",
|
|
148
|
+
"permissionDecision": "deny",
|
|
149
|
+
"permissionDecisionReason": ENFORCER_CORRECTIVE_MESSAGE,
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
output_stream.write(json.dumps(deny_payload) + "\n")
|
|
153
|
+
output_stream.flush()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def main() -> None:
|
|
157
|
+
try:
|
|
158
|
+
hook_payload = json.load(sys.stdin)
|
|
159
|
+
except json.JSONDecodeError:
|
|
160
|
+
sys.exit(0)
|
|
161
|
+
if not isinstance(hook_payload, dict):
|
|
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()
|
|
@@ -20,16 +20,28 @@ from _gh_body_arg_utils import (
|
|
|
20
20
|
iter_significant_tokens,
|
|
21
21
|
)
|
|
22
22
|
|
|
23
|
-
PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
24
|
-
PR_GUIDE_PATH = os.path.join(PLUGIN_ROOT, "docs", "PR_DESCRIPTION_GUIDE.md")
|
|
25
23
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
24
|
+
def _insert_hooks_tree_for_imports() -> None:
|
|
25
|
+
hooks_tree = Path(__file__).resolve().parent.parent
|
|
26
|
+
hooks_tree_string = str(hooks_tree)
|
|
27
|
+
if hooks_tree_string not in sys.path:
|
|
28
|
+
sys.path.insert(0, hooks_tree_string)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_insert_hooks_tree_for_imports()
|
|
32
|
+
|
|
33
|
+
from config.pr_description_enforcer_constants import (
|
|
34
|
+
BLOCKQUOTE_MARKER_PATTERN,
|
|
35
|
+
BOLD_PAIR_PATTERN,
|
|
36
|
+
BULLET_MARKER_PATTERN,
|
|
37
|
+
FENCED_CODE_BLOCK_PATTERN,
|
|
38
|
+
HEADING_LINE_PATTERN,
|
|
39
|
+
INLINE_CODE_PATTERN,
|
|
40
|
+
LINK_TEXT_PATTERN,
|
|
41
|
+
MINIMUM_SUBSTANTIVE_PROSE_CHARS,
|
|
42
|
+
PR_GUIDE_PATH,
|
|
43
|
+
WHITESPACE_RUN_PATTERN,
|
|
44
|
+
)
|
|
33
45
|
|
|
34
46
|
VAGUE_LANGUAGE_PATTERN = re.compile(
|
|
35
47
|
r'\b(fix(?:ed)? (?:bug|issue|it)|update(?:d)? code|minor changes|various (?:fixes|updates|improvements))\b',
|
|
@@ -269,23 +281,43 @@ def extract_body_from_command(
|
|
|
269
281
|
return result
|
|
270
282
|
|
|
271
283
|
|
|
284
|
+
def _count_substantive_prose_chars(body: str) -> int:
|
|
285
|
+
"""Return the count of prose characters after stripping Markdown ceremony.
|
|
286
|
+
|
|
287
|
+
Removes fenced code, inline code, heading lines, blockquote markers,
|
|
288
|
+
bullet list markers, bold/emphasis markers, and Markdown link targets.
|
|
289
|
+
Collapses internal whitespace so a body of only headers and bullets --
|
|
290
|
+
no real WHY paragraph -- registers as effectively empty.
|
|
291
|
+
"""
|
|
292
|
+
body_without_fences = FENCED_CODE_BLOCK_PATTERN.sub('', body)
|
|
293
|
+
body_without_inline_code = INLINE_CODE_PATTERN.sub('', body_without_fences)
|
|
294
|
+
body_without_blockquotes = BLOCKQUOTE_MARKER_PATTERN.sub('', body_without_inline_code)
|
|
295
|
+
body_without_headings = HEADING_LINE_PATTERN.sub('', body_without_blockquotes)
|
|
296
|
+
body_without_bullets = BULLET_MARKER_PATTERN.sub('', body_without_headings)
|
|
297
|
+
body_without_bold = BOLD_PAIR_PATTERN.sub(r'\1', body_without_bullets)
|
|
298
|
+
body_without_emphasis = body_without_bold.replace('*', '')
|
|
299
|
+
body_without_links = LINK_TEXT_PATTERN.sub(r'\1', body_without_emphasis)
|
|
300
|
+
body_collapsed = WHITESPACE_RUN_PATTERN.sub(' ', body_without_links).strip()
|
|
301
|
+
return len(body_collapsed)
|
|
302
|
+
|
|
303
|
+
|
|
272
304
|
def validate_pr_body(body: str) -> list[str]:
|
|
273
|
-
|
|
274
|
-
body_lower = body.lower()
|
|
305
|
+
"""Audit a PR body for substantive-prose and vague-language violations.
|
|
275
306
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
if f"## {header}" not in body_lower and f"**{header}" not in body_lower
|
|
279
|
-
]
|
|
307
|
+
Args:
|
|
308
|
+
body: The PR body markdown text to audit.
|
|
280
309
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
310
|
+
Returns:
|
|
311
|
+
A list of human-readable violation messages. Empty when the body passes.
|
|
312
|
+
"""
|
|
313
|
+
violations = []
|
|
284
314
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
315
|
+
substantive_chars = _count_substantive_prose_chars(body)
|
|
316
|
+
if substantive_chars < MINIMUM_SUBSTANTIVE_PROSE_CHARS:
|
|
317
|
+
violations.append(
|
|
318
|
+
"PR body lacks substantive prose -- include a Why paragraph or "
|
|
319
|
+
"substantive explanation, not only headers and bullets"
|
|
320
|
+
)
|
|
289
321
|
|
|
290
322
|
vague_matches = VAGUE_LANGUAGE_PATTERN.findall(body)
|
|
291
323
|
if vague_matches:
|
|
@@ -329,7 +361,8 @@ def main() -> None:
|
|
|
329
361
|
pr_guide_reference = f" @{PR_GUIDE_PATH}" if os.path.exists(PR_GUIDE_PATH) else ""
|
|
330
362
|
denial_reason = (
|
|
331
363
|
f"BLOCKED: [PR_DESCRIPTION] {violation_list}. "
|
|
332
|
-
f"
|
|
364
|
+
f"Use the pr-description-writer agent to author the body in Anthropic claude-code style. "
|
|
365
|
+
f"Guide:{pr_guide_reference}"
|
|
333
366
|
)
|
|
334
367
|
result = {
|
|
335
368
|
"hookSpecificOutput": {
|
|
@@ -401,10 +401,15 @@ def test_has_backtick_bash_continuation_stripped() -> None:
|
|
|
401
401
|
assert not _has_backtick(command)
|
|
402
402
|
|
|
403
403
|
|
|
404
|
-
def
|
|
405
|
-
"""PowerShell backtick
|
|
404
|
+
def test_has_backtick_powershell_continuation_not_stripped_in_bash_context() -> None:
|
|
405
|
+
"""PowerShell backtick continuations are real backticks under the Bash tool.
|
|
406
|
+
|
|
407
|
+
The hook runs on Bash tool invocations where PowerShell-style backtick
|
|
408
|
+
continuations are not continuation markers. Any backtick character in
|
|
409
|
+
the command is a literal backtick that must be detected.
|
|
410
|
+
"""
|
|
406
411
|
command = 'gh pr create `\n --title "T" `\n --body "bugbot run"\n'
|
|
407
|
-
assert
|
|
412
|
+
assert _has_backtick(command)
|
|
408
413
|
|
|
409
414
|
|
|
410
415
|
def test_has_backtick_content_backtick_at_line_end() -> None:
|
|
@@ -418,3 +423,20 @@ def test_has_backtick_multi_line_body() -> None:
|
|
|
418
423
|
command = 'gh pr create --title "T" --body "First line.\nSecond with `code`."'
|
|
419
424
|
assert _has_backtick(command)
|
|
420
425
|
|
|
426
|
+
|
|
427
|
+
def test_has_backtick_inside_unclosed_double_quoted_body_value() -> None:
|
|
428
|
+
"""Trailing backtick inside an unclosed --body "..." opening line counts as body content.
|
|
429
|
+
|
|
430
|
+
The first line carries an opening double-quote for --body that is not
|
|
431
|
+
closed on that line (odd quote count = 3). The trailing backtick is body
|
|
432
|
+
content (an inline-code opening), not a PowerShell continuation marker.
|
|
433
|
+
The unclosed-quote check preserves the original line including its
|
|
434
|
+
trailing backtick, so _has_backtick sees the backtick and returns True.
|
|
435
|
+
"""
|
|
436
|
+
command = (
|
|
437
|
+
'gh pr create --title "T" --body "Thanks `\n'
|
|
438
|
+
' this continues the body\n'
|
|
439
|
+
' with more text"\n'
|
|
440
|
+
)
|
|
441
|
+
assert _has_backtick(command)
|
|
442
|
+
|