claude-dev-env 1.45.0 → 1.47.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 +8 -1
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
- package/_shared/pr-loop/scripts/reviews_disabled.py +82 -9
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +5 -0
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +57 -0
- package/hooks/blocking/content_search_zoekt_redirect_guidance.py +27 -0
- package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +36 -0
- package/package.json +1 -1
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +5 -0
- package/skills/pr-converge/SKILL.md +5 -0
- package/skills/pr-converge/reference/per-tick.md +14 -5
- package/skills/pr-converge/reference/state-schema.md +7 -3
- package/skills/pr-converge/scripts/check_convergence.py +27 -1
- package/skills/pr-converge/scripts/test_check_convergence.py +28 -0
- package/skills/pre-compact/SKILL.md +48 -23
package/CLAUDE.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Claude Development Assistant
|
|
2
2
|
|
|
3
|
+
The user is short on time and tokens. When you reply, always assume they'll only read your first few sentences and final sentences. Anything else is skimmed at best. Frame your replies accordingly.
|
|
4
|
+
|
|
5
|
+
The user is short on tokens; whenever a task can be achieved cleanly and effectively, optimize to save the user $$ and token usage.
|
|
6
|
+
|
|
3
7
|
The user delegates execution to you and expects zero manual steps unless strictly necessary. Execute every command you can directly. Only instruct the user to do something manually when you are technically unable to do it yourself. When a task involves credentials or other sensitive input, display a minimal secure UI (e.g., a password dialog) to collect it rather than asking the user to paste it into chat or run the command themselves. When direction is ambiguous, use AskUserQuestion to clarify before acting.
|
|
4
8
|
|
|
5
9
|
## Code Rules
|
|
@@ -18,7 +22,10 @@ Full banned-pattern set + enforcement: `~/.claude/rules/no-historical-clutter.md
|
|
|
18
22
|
|
|
19
23
|
## GOTCHAS
|
|
20
24
|
When making code changes, make sure you are working in the proper worktree path for the task at hand.
|
|
21
|
-
|
|
25
|
+
|
|
26
|
+
## Choosing Edit vs Write
|
|
27
|
+
|
|
28
|
+
`Edit` changes existing files; `Write` creates new ones. Default to `Edit` — reach for `Write` only for a genuinely new path. For a true full rewrite, delete the file first, then `Write`.
|
|
22
29
|
|
|
23
30
|
## File-Global Constants
|
|
24
31
|
|
|
@@ -5,4 +5,5 @@ from __future__ import annotations
|
|
|
5
5
|
CLAUDE_REVIEWS_DISABLED_ENV_VAR_NAME: str = "CLAUDE_REVIEWS_DISABLED"
|
|
6
6
|
CLAUDE_REVIEWS_DISABLED_TOKEN_SEPARATOR: str = ","
|
|
7
7
|
CLAUDE_REVIEWS_DISABLED_BUGTEAM_TOKEN: str = "bugteam"
|
|
8
|
+
CLAUDE_REVIEWS_DISABLED_BUGBOT_TOKEN: str = "bugbot"
|
|
8
9
|
EXIT_CODE_BUGTEAM_DISABLED_VIA_ENV: int = 7
|
|
@@ -7,9 +7,12 @@ rules and disabled-token taxonomy live in exactly one place.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import argparse
|
|
10
11
|
import os
|
|
12
|
+
import sys
|
|
11
13
|
|
|
12
14
|
from pr_loop_shared_constants.reviews_disabled_constants import (
|
|
15
|
+
CLAUDE_REVIEWS_DISABLED_BUGBOT_TOKEN,
|
|
13
16
|
CLAUDE_REVIEWS_DISABLED_BUGTEAM_TOKEN,
|
|
14
17
|
CLAUDE_REVIEWS_DISABLED_ENV_VAR_NAME,
|
|
15
18
|
CLAUDE_REVIEWS_DISABLED_TOKEN_SEPARATOR,
|
|
@@ -18,28 +21,98 @@ from pr_loop_shared_constants.reviews_disabled_constants import (
|
|
|
18
21
|
|
|
19
22
|
|
|
20
23
|
__all__ = [
|
|
24
|
+
"CLAUDE_REVIEWS_DISABLED_BUGBOT_TOKEN",
|
|
21
25
|
"CLAUDE_REVIEWS_DISABLED_BUGTEAM_TOKEN",
|
|
22
26
|
"CLAUDE_REVIEWS_DISABLED_ENV_VAR_NAME",
|
|
23
27
|
"CLAUDE_REVIEWS_DISABLED_TOKEN_SEPARATOR",
|
|
24
28
|
"EXIT_CODE_BUGTEAM_DISABLED_VIA_ENV",
|
|
29
|
+
"is_bugbot_disabled_via_env",
|
|
25
30
|
"is_bugteam_disabled_via_env",
|
|
31
|
+
"main",
|
|
26
32
|
]
|
|
27
33
|
|
|
28
34
|
|
|
29
|
-
def
|
|
30
|
-
"""Check whether CLAUDE_REVIEWS_DISABLED
|
|
35
|
+
def _is_reviewer_disabled_via_env(reviewer_token: str) -> bool:
|
|
36
|
+
"""Check whether CLAUDE_REVIEWS_DISABLED lists the given reviewer token.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
reviewer_token: The reviewer token to look for, already lowercase
|
|
40
|
+
(for example the bugteam or bugbot token constant).
|
|
31
41
|
|
|
32
42
|
Returns:
|
|
33
|
-
True when the env var contains
|
|
34
|
-
|
|
43
|
+
True when the env var contains ``reviewer_token`` as one of its
|
|
44
|
+
comma-separated entries (case-insensitive, whitespace-tolerant).
|
|
35
45
|
"""
|
|
36
|
-
reviews_disabled_env_var_name = CLAUDE_REVIEWS_DISABLED_ENV_VAR_NAME
|
|
37
46
|
reviews_disabled_token_separator = CLAUDE_REVIEWS_DISABLED_TOKEN_SEPARATOR
|
|
38
|
-
|
|
39
|
-
raw_value = os.environ.get(reviews_disabled_env_var_name, "")
|
|
47
|
+
disabled_reviewers_text = os.environ.get(CLAUDE_REVIEWS_DISABLED_ENV_VAR_NAME, "")
|
|
40
48
|
all_disabled_tokens = frozenset(
|
|
41
49
|
each_raw_token.strip().lower()
|
|
42
|
-
for each_raw_token in
|
|
50
|
+
for each_raw_token in disabled_reviewers_text.split(
|
|
51
|
+
reviews_disabled_token_separator
|
|
52
|
+
)
|
|
43
53
|
if each_raw_token.strip()
|
|
44
54
|
)
|
|
45
|
-
return
|
|
55
|
+
return reviewer_token in all_disabled_tokens
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def is_bugteam_disabled_via_env() -> bool:
|
|
59
|
+
"""Check whether CLAUDE_REVIEWS_DISABLED opts the bug-audit family out.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
True when the env var lists the ``bugteam`` token.
|
|
63
|
+
"""
|
|
64
|
+
return _is_reviewer_disabled_via_env(CLAUDE_REVIEWS_DISABLED_BUGTEAM_TOKEN)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def is_bugbot_disabled_via_env() -> bool:
|
|
68
|
+
"""Check whether CLAUDE_REVIEWS_DISABLED opts Cursor Bugbot out.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
True when the env var lists the ``bugbot`` token.
|
|
72
|
+
"""
|
|
73
|
+
return _is_reviewer_disabled_via_env(CLAUDE_REVIEWS_DISABLED_BUGBOT_TOKEN)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def parse_arguments(all_argv: list[str]) -> argparse.Namespace:
|
|
77
|
+
"""Parse command-line arguments for the reviewer opt-out check.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
all_argv: Argument list excluding the program name, typically
|
|
81
|
+
``sys.argv[1:]``.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Namespace exposing a ``reviewer`` attribute constrained to the
|
|
85
|
+
known reviewer tokens.
|
|
86
|
+
"""
|
|
87
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
88
|
+
parser.add_argument(
|
|
89
|
+
"--reviewer",
|
|
90
|
+
required=True,
|
|
91
|
+
choices=[
|
|
92
|
+
CLAUDE_REVIEWS_DISABLED_BUGBOT_TOKEN,
|
|
93
|
+
CLAUDE_REVIEWS_DISABLED_BUGTEAM_TOKEN,
|
|
94
|
+
],
|
|
95
|
+
help="Reviewer token to test against CLAUDE_REVIEWS_DISABLED",
|
|
96
|
+
)
|
|
97
|
+
return parser.parse_args(all_argv)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def main(all_arguments: list[str]) -> int:
|
|
101
|
+
"""Exit 0 when the named reviewer is disabled via CLAUDE_REVIEWS_DISABLED.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
all_arguments: Argument list excluding the program name.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
0 when the named reviewer is opted out by the env var, 1 otherwise.
|
|
108
|
+
"""
|
|
109
|
+
arguments = parse_arguments(all_arguments)
|
|
110
|
+
disabled_checker_by_reviewer = {
|
|
111
|
+
CLAUDE_REVIEWS_DISABLED_BUGBOT_TOKEN: is_bugbot_disabled_via_env,
|
|
112
|
+
CLAUDE_REVIEWS_DISABLED_BUGTEAM_TOKEN: is_bugteam_disabled_via_env,
|
|
113
|
+
}
|
|
114
|
+
return 0 if disabled_checker_by_reviewer[arguments.reviewer]() else 1
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
if __name__ == "__main__":
|
|
118
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
@@ -49,6 +49,11 @@ def initialize_git_repository(repository_root: Path) -> None:
|
|
|
49
49
|
run_git_in_repository(repository_root, "config", "user.email", "test@example.com")
|
|
50
50
|
run_git_in_repository(repository_root, "config", "user.name", "Test")
|
|
51
51
|
run_git_in_repository(repository_root, "config", "commit.gpgsign", "false")
|
|
52
|
+
disabled_hooks_directory = repository_root / "disabled-git-hooks"
|
|
53
|
+
disabled_hooks_directory.mkdir()
|
|
54
|
+
run_git_in_repository(
|
|
55
|
+
repository_root, "config", "core.hooksPath", str(disabled_hooks_directory)
|
|
56
|
+
)
|
|
52
57
|
|
|
53
58
|
|
|
54
59
|
def commit_all_files(repository_root: Path, commit_message: str) -> None:
|
|
@@ -34,3 +34,60 @@ def test_is_bugteam_disabled_via_env_returns_false_when_env_is_empty(
|
|
|
34
34
|
) -> None:
|
|
35
35
|
monkeypatch.delenv("CLAUDE_REVIEWS_DISABLED", raising=False)
|
|
36
36
|
assert reviews_disabled.is_bugteam_disabled_via_env() is False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_is_bugbot_disabled_via_env_returns_true_when_env_lists_bugbot(
|
|
40
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
41
|
+
) -> None:
|
|
42
|
+
monkeypatch.setenv("CLAUDE_REVIEWS_DISABLED", "bugbot")
|
|
43
|
+
assert reviews_disabled.is_bugbot_disabled_via_env() is True
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_is_bugbot_disabled_via_env_returns_false_when_env_is_empty(
|
|
47
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
48
|
+
) -> None:
|
|
49
|
+
monkeypatch.delenv("CLAUDE_REVIEWS_DISABLED", raising=False)
|
|
50
|
+
assert reviews_disabled.is_bugbot_disabled_via_env() is False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_is_bugbot_disabled_via_env_returns_false_when_only_bugteam_listed(
|
|
54
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
55
|
+
) -> None:
|
|
56
|
+
monkeypatch.setenv("CLAUDE_REVIEWS_DISABLED", "bugteam")
|
|
57
|
+
assert reviews_disabled.is_bugbot_disabled_via_env() is False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_is_bugbot_disabled_via_env_true_when_both_tokens_listed_mixed_case(
|
|
61
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
62
|
+
) -> None:
|
|
63
|
+
monkeypatch.setenv("CLAUDE_REVIEWS_DISABLED", " BugTeam , BUGBOT ")
|
|
64
|
+
assert reviews_disabled.is_bugbot_disabled_via_env() is True
|
|
65
|
+
assert reviews_disabled.is_bugteam_disabled_via_env() is True
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_cli_main_returns_zero_when_named_reviewer_disabled(
|
|
69
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
70
|
+
) -> None:
|
|
71
|
+
monkeypatch.setenv("CLAUDE_REVIEWS_DISABLED", "bugbot")
|
|
72
|
+
assert reviews_disabled.main(["--reviewer", "bugbot"]) == 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_cli_main_returns_one_when_named_reviewer_not_disabled(
|
|
76
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
77
|
+
) -> None:
|
|
78
|
+
monkeypatch.delenv("CLAUDE_REVIEWS_DISABLED", raising=False)
|
|
79
|
+
assert reviews_disabled.main(["--reviewer", "bugbot"]) == 1
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_cli_main_returns_one_when_other_reviewer_disabled(
|
|
83
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
84
|
+
) -> None:
|
|
85
|
+
monkeypatch.setenv("CLAUDE_REVIEWS_DISABLED", "bugteam")
|
|
86
|
+
assert reviews_disabled.main(["--reviewer", "bugbot"]) == 1
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_cli_main_supports_bugteam_reviewer(
|
|
90
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
91
|
+
) -> None:
|
|
92
|
+
monkeypatch.setenv("CLAUDE_REVIEWS_DISABLED", "bugteam")
|
|
93
|
+
assert reviews_disabled.main(["--reviewer", "bugteam"]) == 0
|
|
@@ -7,12 +7,39 @@ def get_zoekt_redirect_reason_brief() -> str:
|
|
|
7
7
|
)
|
|
8
8
|
|
|
9
9
|
|
|
10
|
+
def worktree_path_filter_fragment() -> str:
|
|
11
|
+
return "\\.claude/worktrees/"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def worktree_path_display_fragment() -> str:
|
|
15
|
+
return ".claude/worktrees/"
|
|
16
|
+
|
|
17
|
+
|
|
10
18
|
def get_zoekt_redirect_guidance() -> str:
|
|
19
|
+
worktree_filter_fragment = worktree_path_filter_fragment()
|
|
20
|
+
worktree_display_fragment = worktree_path_display_fragment()
|
|
11
21
|
return (
|
|
12
22
|
"Use Zoekt MCP instead: mcp__zoekt__search(query=\"your pattern\"). "
|
|
13
23
|
"Supports regex, 'file:pattern' for file filtering, 'lang:py' for language. "
|
|
14
24
|
"Also available: mcp__zoekt__search_symbols, mcp__zoekt__find_references, mcp__zoekt__file_content. "
|
|
15
25
|
"Example: mcp__zoekt__search(query=\"verify_theme_assets file:\\.py$\")\n\n"
|
|
26
|
+
"WORKTREE NOISE: indexed trees include git worktrees under "
|
|
27
|
+
+ worktree_display_fragment
|
|
28
|
+
+ " that duplicate the same code across branches. By default append '-file:"
|
|
29
|
+
+ worktree_filter_fragment
|
|
30
|
+
+ "' to each query so results stay on the primary checkout. When the user explicitly "
|
|
31
|
+
"asks about a worktree, branch, or PR checkout, drop that exclusion or filter to it "
|
|
32
|
+
"positively, e.g. mcp__zoekt__search(query=\"your pattern file:"
|
|
33
|
+
+ worktree_filter_fragment
|
|
34
|
+
+ "<branch>/\").\n\n"
|
|
35
|
+
"INDEX FRESHNESS: the index trails just-written code — recent or unpushed commits sync and reindex "
|
|
36
|
+
"on a short delay, so a symbol you just added can be missing from Zoekt for a few minutes even though "
|
|
37
|
+
"it is on disk. Worktrees are fully indexed and retrievable with the positive 'file:"
|
|
38
|
+
+ worktree_filter_fragment
|
|
39
|
+
+ "<branch>/' filter above, but that same lag applies to anything freshly edited. When a Zoekt search "
|
|
40
|
+
"returns nothing for code you know exists on disk, do not treat it as absent: Grep a specific file "
|
|
41
|
+
"(a path ending in a file extension is exempt from this redirect) or read the file directly to confirm "
|
|
42
|
+
"current contents.\n\n"
|
|
16
43
|
"INDEX ROOTS (when Grep/Search in a tree is redirected): set ZOEKT_REDIRECT_INDEXED_ROOTS to a JSON array "
|
|
17
44
|
"of absolute paths, or ~/.claude/zoekt-indexed-roots.json as {\"roots\": [\"/abs/path/to/repo/\", ...]}. "
|
|
18
45
|
"Optional ZOEKT_REDIRECT_INDEXED_ROOTS_FILE points to a different JSON file. "
|
|
@@ -14,6 +14,8 @@ from content_search_zoekt_block_payload import build_block_payload
|
|
|
14
14
|
from content_search_zoekt_redirect_guidance import (
|
|
15
15
|
get_zoekt_redirect_guidance,
|
|
16
16
|
get_zoekt_redirect_reason_brief,
|
|
17
|
+
worktree_path_display_fragment,
|
|
18
|
+
worktree_path_filter_fragment,
|
|
17
19
|
)
|
|
18
20
|
|
|
19
21
|
|
|
@@ -52,5 +54,39 @@ class BuildBlockPayloadTests(unittest.TestCase):
|
|
|
52
54
|
)
|
|
53
55
|
|
|
54
56
|
|
|
57
|
+
class RedirectGuidanceWorktreeTests(unittest.TestCase):
|
|
58
|
+
def test_guidance_defaults_to_excluding_worktrees(self) -> None:
|
|
59
|
+
guidance = get_zoekt_redirect_guidance()
|
|
60
|
+
expected_default_exclusion = f"-file:{worktree_path_filter_fragment()}"
|
|
61
|
+
self.assertIn(expected_default_exclusion, guidance)
|
|
62
|
+
|
|
63
|
+
def test_guidance_explains_how_to_search_a_worktree(self) -> None:
|
|
64
|
+
guidance = get_zoekt_redirect_guidance()
|
|
65
|
+
positive_filter_example = (
|
|
66
|
+
f'query="your pattern file:{worktree_path_filter_fragment()}<branch>/'
|
|
67
|
+
)
|
|
68
|
+
self.assertIn(positive_filter_example, guidance)
|
|
69
|
+
self.assertIn("worktree", guidance.lower())
|
|
70
|
+
|
|
71
|
+
def test_worktree_filter_fragment_is_a_regex_escaped_path(self) -> None:
|
|
72
|
+
self.assertEqual(worktree_path_filter_fragment(), "\\.claude/worktrees/")
|
|
73
|
+
|
|
74
|
+
def test_guidance_describes_worktree_path_unescaped_in_prose(self) -> None:
|
|
75
|
+
guidance = get_zoekt_redirect_guidance()
|
|
76
|
+
prose_substring = (
|
|
77
|
+
f"git worktrees under {worktree_path_display_fragment()} that"
|
|
78
|
+
)
|
|
79
|
+
self.assertIn(prose_substring, guidance)
|
|
80
|
+
|
|
81
|
+
def test_worktree_display_fragment_is_the_unescaped_path(self) -> None:
|
|
82
|
+
self.assertEqual(worktree_path_display_fragment(), ".claude/worktrees/")
|
|
83
|
+
|
|
84
|
+
def test_guidance_documents_index_freshness_escape_hatch(self) -> None:
|
|
85
|
+
guidance = get_zoekt_redirect_guidance()
|
|
86
|
+
self.assertIn("INDEX FRESHNESS:", guidance)
|
|
87
|
+
self.assertIn("exempt from this redirect", guidance)
|
|
88
|
+
self.assertIn("read the file directly", guidance)
|
|
89
|
+
|
|
90
|
+
|
|
55
91
|
if __name__ == "__main__":
|
|
56
92
|
unittest.main()
|
package/package.json
CHANGED
|
@@ -33,6 +33,11 @@ def initialize_git_repository(repository_root: Path) -> None:
|
|
|
33
33
|
run_git_in_repository(repository_root, "config", "user.email", "test@example.com")
|
|
34
34
|
run_git_in_repository(repository_root, "config", "user.name", "Test")
|
|
35
35
|
run_git_in_repository(repository_root, "config", "commit.gpgsign", "false")
|
|
36
|
+
disabled_hooks_directory = repository_root / "disabled-git-hooks"
|
|
37
|
+
disabled_hooks_directory.mkdir()
|
|
38
|
+
run_git_in_repository(
|
|
39
|
+
repository_root, "config", "core.hooksPath", str(disabled_hooks_directory)
|
|
40
|
+
)
|
|
36
41
|
|
|
37
42
|
|
|
38
43
|
def commit_all_files(repository_root: Path, commit_message: str) -> None:
|
|
@@ -125,6 +125,11 @@ no longer applies.
|
|
|
125
125
|
- [ ] **Step 4: BUGBOT — fetch, decide, fix, reply, resolve**
|
|
126
126
|
See: [`reference/per-tick.md` § Step 2 BUGBOT + Step 3](reference/per-tick.md)
|
|
127
127
|
|
|
128
|
+
- [ ] **Opt-out gate (runs first, every BUGBOT entry).**
|
|
129
|
+
`python "$HOME/.claude/_shared/pr-loop/scripts/reviews_disabled.py" --reviewer bugbot`
|
|
130
|
+
- [ ] Exit 0 (`CLAUDE_REVIEWS_DISABLED` lists `bugbot`) → set `bugbot_down = true`, `phase = BUGTEAM`, advance to Step 5 (bypass). Cursor Bugbot is skipped for the entire run.
|
|
131
|
+
- [ ] Exit 1 → continue below.
|
|
132
|
+
|
|
128
133
|
Fetch bugbot reviews + inline comments on `current_head`.
|
|
129
134
|
|
|
130
135
|
- [ ] **dirty** (findings on `current_head`) →
|
|
@@ -49,6 +49,16 @@ Capture `number`, `head.sha` (= `current_head`), owner/repo, branch.
|
|
|
49
49
|
|
|
50
50
|
### `phase == BUGBOT`
|
|
51
51
|
|
|
52
|
+
**Opt-out gate (runs first, before any fetch or trigger).**
|
|
53
|
+
`python "$HOME/.claude/_shared/pr-loop/scripts/reviews_disabled.py" --reviewer bugbot`
|
|
54
|
+
|
|
55
|
+
- Exit 0 (`CLAUDE_REVIEWS_DISABLED` lists `bugbot`) → set `bugbot_down = true`,
|
|
56
|
+
`phase = BUGTEAM`, continue BUGTEAM in the same tick; skip steps a–c below.
|
|
57
|
+
- Exit 1 → proceed to step a.
|
|
58
|
+
|
|
59
|
+
Because `bugbot_down` resets on every push, this gate re-runs on every
|
|
60
|
+
BUGBOT entry and keeps Cursor Bugbot skipped for the entire run.
|
|
61
|
+
|
|
52
62
|
a. Fetch Cursor Bugbot reviews newest-first, walk back until first clean:
|
|
53
63
|
|
|
54
64
|
```
|
|
@@ -207,11 +217,10 @@ BUGBOT.
|
|
|
207
217
|
|
|
208
218
|
## Step 3: Re-trigger bugbot
|
|
209
219
|
|
|
210
|
-
- [ ] **Opt-out gate.**
|
|
211
|
-
|
|
212
|
-
`bugbot_down = true
|
|
213
|
-
|
|
214
|
-
`bugbot_down` exactly the way it does when bugbot CI is unavailable.
|
|
220
|
+
- [ ] **Opt-out gate.** Enforced at BUGBOT entry (see `### phase == BUGBOT`).
|
|
221
|
+
When `CLAUDE_REVIEWS_DISABLED` lists `bugbot`, the entry gate sets
|
|
222
|
+
`bugbot_down = true` and routes to BUGTEAM before any trigger flow runs,
|
|
223
|
+
so the checks below are skipped.
|
|
215
224
|
- [ ] **Silent-pass pre-check.** Run `python ~/.claude/skills/pr-converge/scripts/check_bugbot_ci.py --check-clean --owner <O> --repo <R> --sha <current_head>`
|
|
216
225
|
- [ ] Exit 0 → bugbot CI completed clean with no review (silent pass); set `bugbot_clean_at = current_head`, `phase = BUGTEAM`, continue BUGTEAM same tick
|
|
217
226
|
- [ ] Exit 1 (not a silent pass) or Exit 2 (gh CLI error — silent pass not confirmable) → continue with the trigger flow below
|
|
@@ -23,9 +23,13 @@ live ONLY in the single-PR `$CLAUDE_JOB_DIR/pr-converge-state.json` file
|
|
|
23
23
|
matching. Reset to `0` on any other branch outcome.
|
|
24
24
|
- `bugbot_down`: boolean, init `false`. Set `true` when bugbot fails to
|
|
25
25
|
acknowledge a trigger comment; forces phase to BUGTEAM. Also set `true`
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
at every BUGBOT-phase entry when `CLAUDE_REVIEWS_DISABLED` lists the
|
|
27
|
+
`bugbot` token (env opt-out via the BUGBOT entry gate in `per-tick.md`),
|
|
28
|
+
which routes straight to BUGTEAM before any bugbot fetch or trigger. Also
|
|
29
|
+
set `true` when an acknowledged trigger has been outstanding more than 30
|
|
30
|
+
minutes with no surfaced review at `current_head` (per Step 2 BUGBOT (c)
|
|
31
|
+
30-minute budget — see `per-tick.md`). Reset to `false` on every push;
|
|
32
|
+
the entry gate re-applies the env opt-out on the next BUGBOT entry.
|
|
29
33
|
Once set, remains `true` until the next push; if bugbot stays down
|
|
30
34
|
across ticks, the flag persists and BUGTEAM continues.
|
|
31
35
|
- `bugbot_acknowledged_at`: ISO 8601 timestamp string or `null`. Records
|
|
@@ -4,6 +4,10 @@ Usage:
|
|
|
4
4
|
python scripts/check_convergence.py --owner <O> --repo <R> --pr-number <N>
|
|
5
5
|
[--bugbot-down]
|
|
6
6
|
|
|
7
|
+
The bugbot check-run gate is bypassed when either ``--bugbot-down`` is
|
|
8
|
+
passed OR the ``CLAUDE_REVIEWS_DISABLED`` environment variable lists the
|
|
9
|
+
``bugbot`` token, so a Bugbot opt-out closes the gate without the flag.
|
|
10
|
+
|
|
7
11
|
Exit codes:
|
|
8
12
|
0 — all pre-conditions met
|
|
9
13
|
1 — one or more conditions not met (FAIL lines printed to stdout)
|
|
@@ -48,6 +52,14 @@ from pr_converge_skill_constants.constants import (
|
|
|
48
52
|
UNRESOLVED_THREAD_DETAIL_MAX,
|
|
49
53
|
)
|
|
50
54
|
|
|
55
|
+
_shared_pr_loop_scripts_dir = (
|
|
56
|
+
Path(__file__).absolute().parents[3] / "_shared" / "pr-loop" / "scripts"
|
|
57
|
+
)
|
|
58
|
+
if str(_shared_pr_loop_scripts_dir) not in sys.path:
|
|
59
|
+
sys.path.insert(0, str(_shared_pr_loop_scripts_dir))
|
|
60
|
+
|
|
61
|
+
from reviews_disabled import is_bugbot_disabled_via_env
|
|
62
|
+
|
|
51
63
|
|
|
52
64
|
def _is_bugteam_review(review_body: str) -> bool:
|
|
53
65
|
"""Return True when a review body opens with a bugteam audit header.
|
|
@@ -621,6 +633,20 @@ def parse_arguments(all_argv: list[str]) -> argparse.Namespace:
|
|
|
621
633
|
return parser.parse_args(all_argv)
|
|
622
634
|
|
|
623
635
|
|
|
636
|
+
def _resolve_bugbot_down(bugbot_down_flag: bool) -> bool:
|
|
637
|
+
"""Combine the explicit flag with the CLAUDE_REVIEWS_DISABLED env opt-out.
|
|
638
|
+
|
|
639
|
+
Args:
|
|
640
|
+
bugbot_down_flag: Value of the ``--bugbot-down`` CLI flag.
|
|
641
|
+
|
|
642
|
+
Returns:
|
|
643
|
+
True when the flag is set OR ``CLAUDE_REVIEWS_DISABLED`` lists the
|
|
644
|
+
``bugbot`` token, so the env opt-out bypasses the bugbot gates even
|
|
645
|
+
when the caller omits the flag.
|
|
646
|
+
"""
|
|
647
|
+
return bugbot_down_flag or is_bugbot_disabled_via_env()
|
|
648
|
+
|
|
649
|
+
|
|
624
650
|
def main(all_arguments: list[str]) -> int:
|
|
625
651
|
"""Run the script end-to-end against parsed CLI arguments.
|
|
626
652
|
|
|
@@ -635,7 +661,7 @@ def main(all_arguments: list[str]) -> int:
|
|
|
635
661
|
owner=arguments.owner,
|
|
636
662
|
repo=arguments.repo,
|
|
637
663
|
number=getattr(arguments, "pr_number"),
|
|
638
|
-
bugbot_down=arguments.bugbot_down,
|
|
664
|
+
bugbot_down=_resolve_bugbot_down(arguments.bugbot_down),
|
|
639
665
|
)
|
|
640
666
|
|
|
641
667
|
|
|
@@ -217,6 +217,34 @@ def test_private_helpers_recognize_dirty_legacy_header_body() -> None:
|
|
|
217
217
|
assert check_convergence._is_clean_bugteam_review(DIRTY_LEGACY_BUGTEAM_BODY) is False
|
|
218
218
|
|
|
219
219
|
|
|
220
|
+
def should_resolve_bugbot_down_true_when_flag_set(
|
|
221
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
222
|
+
) -> None:
|
|
223
|
+
monkeypatch.delenv("CLAUDE_REVIEWS_DISABLED", raising=False)
|
|
224
|
+
assert check_convergence._resolve_bugbot_down(True) is True
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def should_resolve_bugbot_down_true_when_env_disables_bugbot(
|
|
228
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
229
|
+
) -> None:
|
|
230
|
+
monkeypatch.setenv("CLAUDE_REVIEWS_DISABLED", "bugbot")
|
|
231
|
+
assert check_convergence._resolve_bugbot_down(False) is True
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def should_resolve_bugbot_down_false_when_flag_unset_and_env_empty(
|
|
235
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
236
|
+
) -> None:
|
|
237
|
+
monkeypatch.delenv("CLAUDE_REVIEWS_DISABLED", raising=False)
|
|
238
|
+
assert check_convergence._resolve_bugbot_down(False) is False
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def should_resolve_bugbot_down_false_when_env_disables_only_bugteam(
|
|
242
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
243
|
+
) -> None:
|
|
244
|
+
monkeypatch.setenv("CLAUDE_REVIEWS_DISABLED", "bugteam")
|
|
245
|
+
assert check_convergence._resolve_bugbot_down(False) is False
|
|
246
|
+
|
|
247
|
+
|
|
220
248
|
def should_bypass_bugbot_gates_when_bugbot_down_is_true(
|
|
221
249
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
222
250
|
) -> None:
|
|
@@ -5,8 +5,10 @@ description: >-
|
|
|
5
5
|
`/compact <directive>` string to the operator's clipboard so the next prompt
|
|
6
6
|
is a single paste. The directive pins the session's load-bearing identifiers
|
|
7
7
|
(branch, PR, HEAD, worktree, in-flight work, decisions, blockers, files in
|
|
8
|
-
play, follow-ups)
|
|
9
|
-
|
|
8
|
+
play, follow-ups) the next steps depend on, so the summarizer keeps them with
|
|
9
|
+
high fidelity. It confirms the operator's intent for the next chat through a
|
|
10
|
+
structured question first, then validates each identifier against its live
|
|
11
|
+
source before stating it. Use when the user says `/pre-compact`, asks to prep for compaction, or
|
|
10
12
|
asks to compose a focus directive for `/compact`.
|
|
11
13
|
disable-model-invocation: true
|
|
12
14
|
---
|
|
@@ -20,16 +22,35 @@ string to the operator's clipboard.
|
|
|
20
22
|
|
|
21
23
|
**Announce at start:** "I'm composing your compact focus directive."
|
|
22
24
|
|
|
23
|
-
## Step 1 —
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
## Step 1 — Confirm the next-session intent
|
|
26
|
+
|
|
27
|
+
Before composing anything, ask the operator what they intend to work on in
|
|
28
|
+
the next chat. Do not infer it silently from the conversation, and do not
|
|
29
|
+
ask it raw — use `AskUserQuestion` with two to four options drawn from the
|
|
30
|
+
session context (the threads left open, the obvious next actions, the task
|
|
31
|
+
the operator last steered toward); the tool's free-text fallback covers
|
|
32
|
+
anything unlisted. The selected intent is the directive's forward task:
|
|
33
|
+
Step 2 scopes every field to what that intent needs, and the rendered
|
|
34
|
+
`In-flight` line states it.
|
|
35
|
+
|
|
36
|
+
## Step 2 — Read and validate the live session
|
|
37
|
+
|
|
38
|
+
Validate every identifier directly before stating it. Conversation context
|
|
39
|
+
goes stale within a session — a PR merges, HEAD advances, a worktree
|
|
40
|
+
advances to a newer commit — so confirm each value against its live source
|
|
41
|
+
at compose time rather than carrying it from memory. Run the command in
|
|
42
|
+
each field's Source column; for a PR, capture its merge state
|
|
43
|
+
(`gh pr view --json state,mergedAt`) and state `merged` or `open` from that
|
|
44
|
+
result, never from recollection. A value that cannot be confirmed against a
|
|
45
|
+
live source is not dropped silently — surface it to the operator via
|
|
46
|
+
`AskUserQuestion` to clarify (offer the candidate values found plus the
|
|
47
|
+
free-text fallback) and use the answer. Omit it only when the operator
|
|
48
|
+
chooses to skip it.
|
|
28
49
|
|
|
29
50
|
| Field | What to capture | Source |
|
|
30
51
|
|---|---|---|
|
|
31
52
|
| `branch` | Active branch name | `git branch --show-current` |
|
|
32
|
-
| `pr` | Active PR number, when one exists | `gh pr view --json number` |
|
|
53
|
+
| `pr` | Active PR number and its merge state (open / merged), when one exists | `gh pr view --json number,state,mergedAt` |
|
|
33
54
|
| `head` | Short HEAD SHA (whatever `git rev-parse --short` outputs) | `git rev-parse --short HEAD` |
|
|
34
55
|
| `worktree` | Absolute path to the working directory | `pwd` |
|
|
35
56
|
| `in_flight` | One sentence describing what is being worked on right now | conversation |
|
|
@@ -41,7 +62,15 @@ not already in context.
|
|
|
41
62
|
A field whose value cannot be stated as a concrete identifier is omitted
|
|
42
63
|
from the directive.
|
|
43
64
|
|
|
44
|
-
|
|
65
|
+
Scope every field to what the Step 1 intent needs next. Compaction carries
|
|
66
|
+
forward the slice of session history the remaining work needs: capture a
|
|
67
|
+
`decision`, `blocker`, or `in_flight` detail when a next step relies on
|
|
68
|
+
it, and leave a detail the next steps do not touch (a settled question, a
|
|
69
|
+
resolved blocker, a path not taken) out by simply not listing it. When a
|
|
70
|
+
settled decision still constrains the next step, list its outcome as one
|
|
71
|
+
line, not the deliberation behind it.
|
|
72
|
+
|
|
73
|
+
## Step 3 — Render the directive
|
|
45
74
|
|
|
46
75
|
Render this exact shape, populating only the fields with concrete values:
|
|
47
76
|
|
|
@@ -56,25 +85,21 @@ Preserve:
|
|
|
56
85
|
- Blockers: <bullet per blocker>
|
|
57
86
|
- Files: <path>, <path>, <path>
|
|
58
87
|
- Follow-ups: <bullet per follow-up>
|
|
59
|
-
|
|
60
|
-
Drop:
|
|
61
|
-
- Tool outputs already applied to files
|
|
62
|
-
- Per-tick progress narration
|
|
63
|
-
- Resolved findings and superseded SHAs
|
|
64
|
-
- Listing/grep output whose conclusion appears above
|
|
65
88
|
```
|
|
66
89
|
|
|
67
|
-
The
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
90
|
+
The directive lists only what the next steps consume, so the summarizer
|
|
91
|
+
preserves that with high fidelity and compresses the rest of the trace on
|
|
92
|
+
its own — naming what to keep is a clearer instruction than enumerating
|
|
93
|
+
what to cut. Keep the list tight: a `Preserve:` block padded with finished
|
|
94
|
+
or out-of-scope context dilutes the summarizer's focus on what happens
|
|
95
|
+
next.
|
|
71
96
|
|
|
72
97
|
Source: [Effective context engineering for AI agents](https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents)
|
|
73
98
|
— "start by maximizing recall to ensure your compaction prompt captures
|
|
74
99
|
every relevant piece of information from the trace, then iterate to improve
|
|
75
100
|
precision by eliminating superfluous content."
|
|
76
101
|
|
|
77
|
-
## Step
|
|
102
|
+
## Step 4 — Copy `/compact <directive>` to the clipboard
|
|
78
103
|
|
|
79
104
|
Write the full `/compact <directive>` string to a temporary file via the
|
|
80
105
|
Write tool, then copy the file contents to the clipboard with PowerShell:
|
|
@@ -91,7 +116,7 @@ unmodified regardless of which characters it contains.
|
|
|
91
116
|
A reasonable temp path under `$env:TEMP` (Windows) or `$TMPDIR` (POSIX)
|
|
92
117
|
works; clean it up after the `Set-Clipboard` call returns.
|
|
93
118
|
|
|
94
|
-
## Step
|
|
119
|
+
## Step 5 — Hand off
|
|
95
120
|
|
|
96
121
|
Print this confirmation line to the operator:
|
|
97
122
|
|
|
@@ -99,8 +124,8 @@ Print this confirmation line to the operator:
|
|
|
99
124
|
> compact this conversation with focus.
|
|
100
125
|
|
|
101
126
|
Then list up to the first three `Preserve:` bullets (or fewer when the
|
|
102
|
-
directive omits fields)
|
|
103
|
-
|
|
127
|
+
directive omits fields) inline so the operator can spot-check before
|
|
128
|
+
pasting.
|
|
104
129
|
|
|
105
130
|
---
|
|
106
131
|
|