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.
Files changed (60) hide show
  1. package/CLAUDE.md +1 -1
  2. package/_shared/pr-loop/scripts/config/post_audit_thread_constants.py +10 -0
  3. package/_shared/pr-loop/scripts/config/reviews_disabled_constants.py +8 -0
  4. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +53 -3
  5. package/_shared/pr-loop/scripts/post_audit_thread.py +298 -3
  6. package/_shared/pr-loop/scripts/preflight.py +129 -2
  7. package/_shared/pr-loop/scripts/reviews_disabled.py +59 -0
  8. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +68 -3
  9. package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +1 -1
  10. package/_shared/pr-loop/scripts/tests/test_post_audit_thread.py +194 -1
  11. package/_shared/pr-loop/scripts/tests/test_preflight.py +41 -0
  12. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +36 -0
  13. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +1 -1
  14. package/agents/pr-description-writer.md +150 -52
  15. package/docs/PR_DESCRIPTION_GUIDE.md +127 -64
  16. package/hooks/_gh_pr_author_swap_utils.py +1211 -0
  17. package/hooks/blocking/gh_body_arg_blocker.py +9 -6
  18. package/hooks/blocking/gh_pr_author_enforcer.py +480 -0
  19. package/hooks/blocking/gh_pr_author_restore.py +100 -0
  20. package/hooks/blocking/pr_converge_bugteam_enforcer.py +170 -0
  21. package/hooks/blocking/pr_description_enforcer.py +56 -23
  22. package/hooks/blocking/test_gh_body_arg_blocker.py +25 -3
  23. package/hooks/blocking/test_gh_pr_author_enforcer.py +1166 -0
  24. package/hooks/blocking/test_gh_pr_author_restore.py +512 -0
  25. package/hooks/blocking/test_gh_pr_author_swap_utils.py +910 -0
  26. package/hooks/blocking/test_pr_converge_bugteam_enforcer.py +311 -0
  27. package/hooks/blocking/test_pr_description_enforcer.py +69 -8
  28. package/hooks/config/gh_pr_author_swap_constants.py +76 -0
  29. package/hooks/config/pr_converge_bugteam_enforcer_constants.py +55 -0
  30. package/hooks/config/pr_converge_bugteam_enforcer_state.py +67 -0
  31. package/hooks/config/pr_description_enforcer_constants.py +19 -0
  32. package/hooks/config/test_pr_description_enforcer_constants.py +82 -0
  33. package/hooks/hooks.json +40 -0
  34. package/hooks/lifecycle/pr_converge_bugteam_skill_tracker.py +204 -0
  35. package/hooks/lifecycle/test_pr_converge_bugteam_skill_tracker.py +283 -0
  36. package/hooks/session/gh_pr_author_session_cleanup.py +171 -0
  37. package/hooks/session/test_gh_pr_author_session_cleanup.py +575 -0
  38. package/hooks/test__gh_pr_author_swap_utils.py +333 -0
  39. package/package.json +1 -1
  40. package/skills/_shared/pr-loop/scripts/write_audit_outcomes.py +2 -2
  41. package/skills/_shared/pr-loop/scripts/write_fix_outcomes.py +2 -2
  42. package/skills/bugteam/SKILL.md +28 -10
  43. package/skills/bugteam/reference/audit-contract.md +22 -0
  44. package/skills/bugteam/reference/github-pr-reviews.md +1 -1
  45. package/skills/bugteam/reference/team-setup.md +5 -0
  46. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +8 -2
  47. package/skills/bugteam/scripts/bugteam_preflight.py +36 -2
  48. package/skills/bugteam/scripts/test__claude_permissions_common.py +48 -0
  49. package/skills/bugteam/scripts/test_bugteam_preflight.py +41 -0
  50. package/skills/bugteam/scripts/test_claude_permissions_common.py +18 -10
  51. package/skills/copilot-review/SKILL.md +16 -0
  52. package/skills/findbugs/SKILL.md +35 -7
  53. package/skills/monitor-open-prs/SKILL.md +2 -1
  54. package/skills/pr-converge/SKILL.md +11 -3
  55. package/skills/pr-converge/config/constants.py +3 -1
  56. package/skills/pr-converge/reference/per-tick.md +17 -0
  57. package/skills/pr-converge/reference/state-schema.md +36 -8
  58. package/skills/pr-converge/scripts/check_bugbot_ci.py +113 -8
  59. package/skills/pr-converge/scripts/test_check_bugbot_ci.py +312 -0
  60. 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
- REQUIRED_PR_SECTION_HEADERS = [
27
- "description",
28
- "why",
29
- "how",
30
- ]
31
-
32
- MINIMUM_PR_BODY_LENGTH = 50
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
- violations = []
274
- body_lower = body.lower()
305
+ """Audit a PR body for substantive-prose and vague-language violations.
275
306
 
276
- missing_required_sections = [
277
- header for header in REQUIRED_PR_SECTION_HEADERS
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
- if missing_required_sections:
282
- formatted_sections = ", ".join(f"'{each_section.title()}'" for each_section in missing_required_sections)
283
- violations.append(f"Missing required section(s): {formatted_sections}")
310
+ Returns:
311
+ A list of human-readable violation messages. Empty when the body passes.
312
+ """
313
+ violations = []
284
314
 
285
- stripped_body = re.sub(r'#.*', '', body).strip()
286
- stripped_body = re.sub(r'\*\*.*?\*\*', '', stripped_body).strip()
287
- if len(stripped_body) < MINIMUM_PR_BODY_LENGTH:
288
- violations.append("PR body too short -- provide meaningful context for reviewers")
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"Follow the PR description guide:{pr_guide_reference}"
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 test_has_backtick_powershell_continuation_stripped() -> None:
405
- """PowerShell backtick line continuations are stripped before checking."""
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 not _has_backtick(command)
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
+