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 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
- When writing to an existing file, you must either EDIT the file, or remove it and THEN re-write it if it's truly a full re-write.
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 is_bugteam_disabled_via_env() -> bool:
30
- """Check whether CLAUDE_REVIEWS_DISABLED opts the bug-audit family out of running.
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 the literal ``bugteam`` token
34
- (comma-separated, case-insensitive, whitespace-tolerant).
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
- reviews_disabled_bugteam_token = CLAUDE_REVIEWS_DISABLED_BUGTEAM_TOKEN
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 raw_value.split(reviews_disabled_token_separator)
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 reviews_disabled_bugteam_token in all_disabled_tokens
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.45.0",
3
+ "version": "1.47.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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.** When `CLAUDE_REVIEWS_DISABLED` (comma-separated,
211
- case-insensitive, whitespace-tolerant) contains `bugbot`, set
212
- `bugbot_down = true`, skip every check below, set `phase = BUGTEAM`,
213
- and continue BUGTEAM in the same tick. The downstream loop branches on
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
- when an acknowledged trigger has been outstanding more than 30 minutes
27
- with no surfaced review at `current_head` (per Step 2 BUGBOT (c)
28
- 30-minute budget see `per-tick.md`). Reset to `false` on every push.
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) and lists the redundant tool outputs the summarizer should
9
- drop. Use when the user says `/pre-compact`, asks to prep for compaction, or
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 — Read the live session
24
-
25
- Pull the load-bearing identifiers from the current conversation. Run
26
- `git status`, `git rev-parse --short HEAD`, or `gh pr view` when values are
27
- not already in context.
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
- ## Step 2 Render the directive
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 `Preserve:` block leads so the summarizer maximizes recall first. The
68
- `Drop:` block lists the lightest-touch removals raw tool outputs are the
69
- safest content to drop because the work they produced lives in the files
70
- and commits.
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 3 — Copy `/compact <directive>` to the clipboard
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 4 — Hand off
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) and the first `Drop:` bullet inline so the
103
- operator can spot-check before pasting.
127
+ directive omits fields) inline so the operator can spot-check before
128
+ pasting.
104
129
 
105
130
  ---
106
131