claude-dev-env 1.58.0 → 1.60.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 (106) hide show
  1. package/CLAUDE.md +2 -2
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +36 -3
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +6 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
  5. package/_shared/pr-loop/scripts/reviews_disabled.py +12 -0
  6. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +265 -0
  7. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +29 -0
  8. package/audit-rubrics/category_rubrics/category-b-selector-engine-compat.md +1 -1
  9. package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -0
  10. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  11. package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
  12. package/bin/install.mjs +100 -27
  13. package/bin/install.test.mjs +133 -1
  14. package/docs/CODE_RULES.md +3 -3
  15. package/hooks/blocking/code_rules_annotations_length.py +153 -0
  16. package/hooks/blocking/code_rules_dead_dataclass_field.py +319 -0
  17. package/hooks/blocking/code_rules_dead_module_constant.py +321 -0
  18. package/hooks/blocking/code_rules_duplicate_body.py +439 -0
  19. package/hooks/blocking/code_rules_enforcer.py +190 -21
  20. package/hooks/blocking/code_rules_magic_values.py +98 -0
  21. package/hooks/blocking/code_rules_shared.py +41 -0
  22. package/hooks/blocking/code_rules_typeddict_stub.py +172 -0
  23. package/hooks/blocking/config/__init__.py +5 -0
  24. package/hooks/blocking/config/verified_commit_constants.py +106 -0
  25. package/hooks/blocking/destructive_command_blocker.py +1027 -12
  26. package/hooks/blocking/hook_prose_detector_consistency.py +150 -0
  27. package/hooks/blocking/subprocess_budget_completeness.py +380 -0
  28. package/hooks/blocking/test_code_rules_enforcer_annotations.py +225 -0
  29. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
  30. package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +146 -0
  31. package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +467 -0
  32. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +188 -0
  33. package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +330 -0
  34. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +179 -0
  35. package/hooks/blocking/test_code_rules_enforcer_magic_slice_bounds.py +133 -0
  36. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias.py +415 -0
  37. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +156 -0
  38. package/hooks/blocking/test_destructive_command_blocker.py +622 -3
  39. package/hooks/blocking/test_hook_prose_detector_consistency.py +265 -0
  40. package/hooks/blocking/test_subprocess_budget_completeness.py +588 -0
  41. package/hooks/blocking/test_verdict_directory_write_blocker.py +720 -0
  42. package/hooks/blocking/test_verification_verdict_store.py +278 -0
  43. package/hooks/blocking/test_verified_commit_gate.py +368 -0
  44. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -0
  45. package/hooks/blocking/test_verifier_verdict_minter.py +214 -0
  46. package/hooks/blocking/test_workflow_substitution_slot_blocker.py +242 -0
  47. package/hooks/blocking/verdict_directory_write_blocker.py +667 -0
  48. package/hooks/blocking/verification_verdict_store.py +446 -0
  49. package/hooks/blocking/verified_commit_gate.py +523 -0
  50. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +152 -0
  51. package/hooks/blocking/verifier_verdict_minter.py +299 -0
  52. package/hooks/blocking/workflow_substitution_slot_blocker.py +159 -0
  53. package/hooks/diagnostic/test_hook_log_extractor.py +3 -3
  54. package/hooks/hooks.json +58 -1
  55. package/hooks/hooks_constants/blocking_check_limits.py +1 -0
  56. package/hooks/hooks_constants/code_rules_enforcer_constants.py +16 -0
  57. package/hooks/hooks_constants/dead_dataclass_field_constants.py +25 -0
  58. package/hooks/hooks_constants/dead_module_constant_constants.py +20 -0
  59. package/hooks/hooks_constants/destructive_command_segment_constants.py +178 -0
  60. package/hooks/hooks_constants/duplicate_function_body_constants.py +34 -0
  61. package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +30 -0
  62. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
  63. package/hooks/hooks_constants/subprocess_budget_completeness_constants.py +5 -0
  64. package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +22 -0
  65. package/package.json +1 -1
  66. package/rules/docstring-prose-matches-implementation.md +43 -0
  67. package/rules/file-global-constants.md +7 -1
  68. package/rules/hook-prose-matches-detector.md +26 -0
  69. package/rules/no-cross-skill-duplicate-helpers.md +29 -0
  70. package/rules/no-inline-destructive-literals.md +11 -0
  71. package/rules/workflow-substitution-slots.md +7 -0
  72. package/skills/_shared/pr-loop/scripts/preflight_worktree.py +392 -0
  73. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/preflight_constants.py +70 -0
  74. package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +263 -0
  75. package/skills/autoconverge/SKILL.md +67 -19
  76. package/skills/autoconverge/reference/closing-report.md +59 -17
  77. package/skills/autoconverge/reference/convergence.md +7 -3
  78. package/skills/autoconverge/reference/stop-conditions.md +7 -2
  79. package/skills/autoconverge/workflow/aggregate_runs.py +371 -0
  80. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +193 -76
  81. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +76 -0
  82. package/skills/autoconverge/workflow/converge.contract.test.mjs +206 -206
  83. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +265 -0
  84. package/skills/autoconverge/workflow/converge.mjs +234 -42
  85. package/skills/autoconverge/workflow/convergence_summary.py +110 -0
  86. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ab1c2d3e4f5a6b7c8.jsonl +2 -0
  87. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +7 -0
  88. package/skills/autoconverge/workflow/render_report.py +488 -397
  89. package/skills/autoconverge/workflow/test_aggregate_runs.py +134 -0
  90. package/skills/autoconverge/workflow/test_convergence_summary.py +132 -0
  91. package/skills/autoconverge/workflow/test_render_report.py +488 -259
  92. package/skills/pr-converge/reference/per-tick.md +28 -8
  93. package/skills/pr-converge/scripts/check_convergence.py +195 -64
  94. package/skills/pr-converge/scripts/test_check_convergence.py +173 -2
  95. package/skills/rebase/SKILL.md +2 -4
  96. package/skills/update/SKILL.md +37 -5
  97. package/system-prompts/software-engineer.xml +2 -6
  98. package/hooks/blocking/content_search_to_zoekt_redirector.py +0 -59
  99. package/hooks/blocking/content_search_zoekt_bash_block_reason.py +0 -25
  100. package/hooks/blocking/content_search_zoekt_block_payload.py +0 -21
  101. package/hooks/blocking/content_search_zoekt_indexed_paths.py +0 -24
  102. package/hooks/blocking/content_search_zoekt_indexed_roots_config.py +0 -131
  103. package/hooks/blocking/content_search_zoekt_redirect_guidance.py +0 -52
  104. package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +0 -61
  105. package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +0 -92
  106. package/hooks/blocking/test_content_search_zoekt_indexed_roots_config.py +0 -102
@@ -55,16 +55,25 @@ directory, so the working directory must be the PR worktree before any local
55
55
  work begins. Re-resolve it every tick — a rebase or a fresh HEAD can move the
56
56
  branch tip.
57
57
 
58
- Read the current working tree's origin and parse its `<owner>/<repo>`,
59
- accepting the `https://github.com/<owner>/<repo>`,
60
- `git@github.com:<owner>/<repo>`, and `ssh://git@github.com/<owner>/<repo>`
61
- forms and dropping any trailing `.git`:
58
+ Classify the working directory against the PR's repo. The preflight script
59
+ reads the current working tree's origin, parses its `<owner>/<repo>` (accepting
60
+ the `https://github.com/<owner>/<repo>`, `git@github.com:<owner>/<repo>`, and
61
+ `ssh://git@github.com/<owner>/<repo>` forms and dropping any trailing `.git`),
62
+ and prints a `PREFLIGHT_OUTCOME=<same_repo|different_repo|re_rooted>` line plus a
63
+ human-readable summary:
62
64
 
63
65
  ```bash
64
- git remote get-url origin
66
+ python "$HOME/.claude/skills/_shared/pr-loop/scripts/preflight_worktree.py" --owner <owner> --repo <repo> --mode classify
65
67
  ```
66
68
 
67
- - **Parsed owner/repo matches the PR** (case-insensitive): the `EnterWorktree`
69
+ A `same_repo` outcome exits 0 only when the worktree machinery is healthy; a
70
+ `same_repo` outcome whose `git worktree list` probe failed exits non-zero in
71
+ every mode. A `different_repo` outcome exits 0 in classify mode. A `re_rooted`
72
+ outcome (no git work tree, or no readable origin) exits non-zero. Route on the
73
+ `PREFLIGHT_OUTCOME` value:
74
+
75
+ - **`PREFLIGHT_OUTCOME=same_repo`** (the working directory is a checkout of the
76
+ PR's repo): the `EnterWorktree`
68
77
  pre-flight checkout is the PR worktree, and the working directory already
69
78
  points here, so no `cd` is needed. Bring the branch to the PR head with the
70
79
  same deterministic `checkout -B` the cross-repo case uses, after confirming
@@ -74,9 +83,13 @@ git remote get-url origin
74
83
  git fetch origin
75
84
  git checkout -B <branch> origin/<branch>
76
85
  ```
86
+ When the script prints an `ABORT:` line whose recovery names `git worktree
87
+ prune`, the working tree's worktree machinery is broken and the preflight
88
+ exits non-zero: stop the tick, run that `git worktree prune` in the named
89
+ directory, and re-run rather than continuing the checkout.
77
90
 
78
- - **Parsed owner/repo differs** (the session is rooted in another repo — for
79
- example, the PR lives in `llm-settings` while the session runs from
91
+ - **`PREFLIGHT_OUTCOME=different_repo`** (the session is rooted in another repo
92
+ — for example, the PR lives in `llm-settings` while the session runs from
80
93
  `claude-code-config`): route the working directory into a checkout of the
81
94
  PR's repo. This is routine and automatic — never pause, and never raise it as
82
95
  a fork (see [ground-rules.md](ground-rules.md)). `EnterWorktree` is scoped to
@@ -128,6 +141,13 @@ git remote get-url origin
128
141
  a Windows-safe recursive remove (per
129
142
  `$HOME/.claude/rules/windows-filesystem-safe.md`).
130
143
 
144
+ - **`PREFLIGHT_OUTCOME=re_rooted`** (the working directory is not a git work
145
+ tree, or its origin remote is unreadable — a resumed or background session can
146
+ re-root to the home directory): the tick cannot locate the PR's repo from
147
+ here, so neither cwd reuse nor a temp-clone route is safe. Report the printed
148
+ `ABORT` line as a hard blocker and stop the tick. Recover by starting the
149
+ session from a checkout of the PR's repo and re-running.
150
+
131
151
  ## Step 2: Branch on `phase`
132
152
 
133
153
  ### `phase == BUGBOT`
@@ -2,12 +2,18 @@
2
2
 
3
3
  Usage:
4
4
  python scripts/check_convergence.py --owner <O> --repo <R> --pr-number <N>
5
- [--bugbot-down]
5
+ [--bugbot-down] [--copilot-down]
6
6
 
7
7
  The bugbot check-run gate is bypassed when either ``--bugbot-down`` is
8
8
  passed OR the ``CLAUDE_REVIEWS_DISABLED`` environment variable lists the
9
9
  ``bugbot`` token, so a Bugbot opt-out closes the gate without the flag.
10
10
 
11
+ The Copilot review gate and the pending-requested-reviews gate are bypassed
12
+ when either ``--copilot-down`` is passed OR the ``CLAUDE_REVIEWS_DISABLED``
13
+ environment variable lists the ``copilot`` token, so a Copilot outage or
14
+ quota exhaustion closes the broader convergence gate on the remaining
15
+ signals without the flag.
16
+
11
17
  Exit codes:
12
18
  0 — all pre-conditions met
13
19
  1 — one or more conditions not met (FAIL lines printed to stdout)
@@ -58,7 +64,10 @@ _shared_pr_loop_scripts_dir = (
58
64
  if str(_shared_pr_loop_scripts_dir) not in sys.path:
59
65
  sys.path.insert(0, str(_shared_pr_loop_scripts_dir))
60
66
 
61
- from reviews_disabled import is_bugbot_disabled_via_env
67
+ from reviews_disabled import (
68
+ is_bugbot_disabled_via_env,
69
+ is_copilot_disabled_via_env,
70
+ )
62
71
 
63
72
 
64
73
  def _is_bugteam_review(review_body: str) -> bool:
@@ -489,14 +498,141 @@ def _check_no_pending_reviews(
489
498
  return True, "no pending reviewers"
490
499
 
491
500
 
492
- def check_all(*, owner: str, repo: str, number: int, bugbot_down: bool) -> int:
501
+ def _bugbot_conditions(
502
+ *, owner: str, repo: str, number: int, head_sha: str, is_bugbot_down: bool
503
+ ) -> list[tuple[str, tuple[bool, str]]]:
504
+ """Build the Bugbot gate conditions, bypassed when Bugbot is down.
505
+
506
+ Args:
507
+ owner: GitHub repository owner login.
508
+ repo: GitHub repository name.
509
+ number: Pull request number to inspect.
510
+ head_sha: Current PR HEAD SHA the gates evaluate against.
511
+ is_bugbot_down: When True, emit a single bypassed check-run
512
+ condition and skip the review-body content gate entirely.
513
+
514
+ Returns:
515
+ The bugbot check-run condition, plus the review-body content
516
+ condition when the check-run gate is present and passing.
517
+ """
518
+ if is_bugbot_down:
519
+ return [("bugbot_clean_at == current_head", (True, "bypassed (bugbot_down)"))]
520
+ conditions: list[tuple[str, tuple[bool, str]]] = [
521
+ (
522
+ "bugbot_clean_at == current_head",
523
+ _check_bugbot(owner=owner, repo=repo, sha=head_sha),
524
+ )
525
+ ]
526
+ if conditions[-1][1][0]:
527
+ conditions.append(
528
+ (
529
+ "bugbot review body clean",
530
+ _check_bugbot_not_dirty(
531
+ owner=owner, repo=repo, number=number, head_sha=head_sha
532
+ ),
533
+ )
534
+ )
535
+ return conditions
536
+
537
+
538
+ def _copilot_review_condition(
539
+ *, owner: str, repo: str, number: int, head_sha: str, is_copilot_down: bool
540
+ ) -> tuple[str, tuple[bool, str]]:
541
+ """Build the Copilot review gate condition, bypassed when Copilot is down.
542
+
543
+ Args:
544
+ owner: GitHub repository owner login.
545
+ repo: GitHub repository name.
546
+ number: Pull request number to inspect.
547
+ head_sha: Current PR HEAD SHA the gate evaluates against.
548
+ is_copilot_down: When True, return a bypassed condition rather than
549
+ demanding a Copilot review that an outage or quota exhaustion
550
+ keeps from landing.
551
+
552
+ Returns:
553
+ The Copilot review gate condition for the current HEAD.
554
+ """
555
+ if is_copilot_down:
556
+ return ("copilot_clean_at == current_head", (True, "bypassed (copilot_down)"))
557
+ return (
558
+ "copilot_clean_at == current_head",
559
+ _check_bot_review(
560
+ owner=owner,
561
+ repo=repo,
562
+ number=number,
563
+ head_sha=head_sha,
564
+ login_substring=COPILOT_LOGIN_FILTER_SUBSTRING,
565
+ clean_states=ALL_COPILOT_CLEAN_REVIEW_STATES,
566
+ dirty_states=ALL_COPILOT_DIRTY_REVIEW_STATES,
567
+ label="copilot",
568
+ ),
569
+ )
570
+
571
+
572
+ def _pending_reviews_condition(
573
+ *, owner: str, repo: str, number: int, is_copilot_down: bool
574
+ ) -> tuple[str, tuple[bool, str]]:
575
+ """Build the pending-requested-reviews condition, bypassed when Copilot is down.
576
+
577
+ Args:
578
+ owner: GitHub repository owner login.
579
+ repo: GitHub repository name.
580
+ number: Pull request number to inspect.
581
+ is_copilot_down: When True, return a bypassed condition so a Copilot
582
+ review request that will never land does not strand the gate.
583
+
584
+ Returns:
585
+ The pending-requested-reviews condition, which the gate checks for a
586
+ still-pending Copilot reviewer.
587
+ """
588
+ if is_copilot_down:
589
+ return ("no pending requested reviews", (True, "bypassed (copilot_down)"))
590
+ return (
591
+ "no pending requested reviews",
592
+ _check_no_pending_reviews(owner=owner, repo=repo, number=number),
593
+ )
594
+
595
+
596
+ def _print_conditions(all_conditions: list[tuple[str, tuple[bool, str]]]) -> int:
597
+ """Print one PASS/FAIL line per condition and return the aggregate exit code.
598
+
599
+ Args:
600
+ all_conditions: Ordered (label, (passed, detail)) gate results.
601
+
602
+ Returns:
603
+ ``0`` when every condition passed, ``1`` when at least one failed.
604
+ """
605
+ is_all_passed = True
606
+ for each_index, (each_label, (each_passed, each_detail)) in enumerate(
607
+ all_conditions, start=1
608
+ ):
609
+ status = "PASS" if each_passed else "FAIL"
610
+ print(f"{each_index}. {each_label}: {status} — {each_detail}")
611
+ if not each_passed:
612
+ is_all_passed = False
613
+ print()
614
+ if is_all_passed:
615
+ print("All pre-conditions met — PR is ready to mark ready.")
616
+ else:
617
+ print("One or more pre-conditions not met — do not mark ready.")
618
+ return 0 if is_all_passed else 1
619
+
620
+
621
+ def check_all(
622
+ *,
623
+ owner: str,
624
+ repo: str,
625
+ number: int,
626
+ is_bugbot_down: bool,
627
+ is_copilot_down: bool,
628
+ ) -> int:
493
629
  """Run every convergence gate and print one PASS/FAIL line per condition.
494
630
 
495
631
  Args:
496
632
  owner: GitHub repository owner login.
497
633
  repo: GitHub repository name.
498
634
  number: Pull request number to inspect.
499
- bugbot_down: When True, bypass both the Cursor Bugbot check-run
635
+ is_bugbot_down: When True, bypass both the Cursor Bugbot check-run
500
636
  presence gate and the bugbot review-body content gate. The
501
637
  check-run gate appears in the condition list with a
502
638
  ``bypassed (bugbot_down)`` note; the review-body gate is
@@ -504,6 +640,13 @@ def check_all(*, owner: str, repo: str, number: int, bugbot_down: bool) -> int:
504
640
  declared Cursor Bugbot unreachable on the current HEAD so the
505
641
  broader convergence gate can still close on the remaining
506
642
  signals.
643
+ is_copilot_down: When True, bypass both the Copilot review gate and
644
+ the pending-requested-reviews gate, each shown with a
645
+ ``bypassed (copilot_down)`` note. Callers pass True when Copilot
646
+ is down or out of quota on the current HEAD so the broader
647
+ convergence gate can still close on the remaining signals; the
648
+ bypassed pending gate keeps a Copilot review request that will
649
+ never land from stranding the gate.
507
650
 
508
651
  Returns:
509
652
  ``0`` when every gate reports PASS, ``1`` when at least one gate
@@ -522,29 +665,15 @@ def check_all(*, owner: str, repo: str, number: int, bugbot_down: bool) -> int:
522
665
  print(f"HEAD: {head_sha[:7]}\n")
523
666
 
524
667
  conditions: list[tuple[str, tuple[bool, str]]] = []
525
-
526
- if bugbot_down:
527
- conditions.append(
528
- (
529
- "bugbot_clean_at == current_head",
530
- (True, "bypassed (bugbot_down)"),
531
- )
668
+ conditions.extend(
669
+ _bugbot_conditions(
670
+ owner=owner,
671
+ repo=repo,
672
+ number=number,
673
+ head_sha=head_sha,
674
+ is_bugbot_down=is_bugbot_down,
532
675
  )
533
- else:
534
- conditions.append(
535
- (
536
- "bugbot_clean_at == current_head",
537
- _check_bugbot(owner=owner, repo=repo, sha=head_sha),
538
- )
539
- )
540
- if conditions[-1][1][0]:
541
- conditions.append(
542
- (
543
- "bugbot review body clean",
544
- _check_bugbot_not_dirty(owner=owner, repo=repo, number=number, head_sha=head_sha),
545
- )
546
- )
547
-
676
+ )
548
677
  conditions.append(
549
678
  (
550
679
  "bugteam_clean_at == current_head",
@@ -553,54 +682,30 @@ def check_all(*, owner: str, repo: str, number: int, bugbot_down: bool) -> int:
553
682
  ),
554
683
  )
555
684
  )
556
-
557
685
  conditions.append(
558
- (
559
- "copilot_clean_at == current_head",
560
- _check_bot_review(
561
- owner=owner,
562
- repo=repo,
563
- number=number,
564
- head_sha=head_sha,
565
- login_substring=COPILOT_LOGIN_FILTER_SUBSTRING,
566
- clean_states=ALL_COPILOT_CLEAN_REVIEW_STATES,
567
- dirty_states=ALL_COPILOT_DIRTY_REVIEW_STATES,
568
- label="copilot",
569
- ),
686
+ _copilot_review_condition(
687
+ owner=owner,
688
+ repo=repo,
689
+ number=number,
690
+ head_sha=head_sha,
691
+ is_copilot_down=is_copilot_down,
570
692
  )
571
693
  )
572
-
573
694
  conditions.append(
574
695
  (
575
696
  "zero unresolved bot threads",
576
697
  _count_unresolved_bot_threads(owner=owner, repo=repo, number=number),
577
698
  )
578
699
  )
579
-
580
700
  conditions.append(
581
701
  ("PR is mergeable", _get_mergeable(owner=owner, repo=repo, number=number))
582
702
  )
583
-
584
703
  conditions.append(
585
- (
586
- "no pending requested reviews",
587
- _check_no_pending_reviews(owner=owner, repo=repo, number=number),
704
+ _pending_reviews_condition(
705
+ owner=owner, repo=repo, number=number, is_copilot_down=is_copilot_down
588
706
  )
589
707
  )
590
-
591
- is_all_passed = True
592
- for each_index, (each_label, (each_passed, each_detail)) in enumerate(conditions, start=1):
593
- status = "PASS" if each_passed else "FAIL"
594
- print(f"{each_index}. {each_label}: {status} — {each_detail}")
595
- if not each_passed:
596
- is_all_passed = False
597
-
598
- print()
599
- if is_all_passed:
600
- print("All pre-conditions met — PR is ready to mark ready.")
601
- else:
602
- print("One or more pre-conditions not met — do not mark ready.")
603
- return 0 if is_all_passed else 1
708
+ return _print_conditions(conditions)
604
709
 
605
710
 
606
711
  def parse_arguments(all_argv: list[str]) -> argparse.Namespace:
@@ -611,10 +716,10 @@ def parse_arguments(all_argv: list[str]) -> argparse.Namespace:
611
716
  ``sys.argv[1:]``.
612
717
 
613
718
  Returns:
614
- Namespace exposing ``owner``, ``repo``, ``pr_number``, and
615
- ``bugbot_down`` attributes. ``bugbot_down`` defaults to False so
616
- the unmodified hook contract (``--owner X --repo Y --pr-number N``)
617
- still picks up the full gate set.
719
+ Namespace exposing ``owner``, ``repo``, ``pr_number``,
720
+ ``bugbot_down``, and ``copilot_down`` attributes. ``bugbot_down``
721
+ and ``copilot_down`` default to False so the base hook contract
722
+ (``--owner X --repo Y --pr-number N``) picks up the full gate set.
618
723
  """
619
724
  parser = argparse.ArgumentParser(description=__doc__)
620
725
  parser.add_argument("--owner", required=True, help="GitHub repository owner")
@@ -630,6 +735,14 @@ def parse_arguments(all_argv: list[str]) -> argparse.Namespace:
630
735
  "declared Cursor Bugbot unreachable on the current HEAD."
631
736
  ),
632
737
  )
738
+ parser.add_argument(
739
+ "--copilot-down",
740
+ action="store_true",
741
+ help=(
742
+ "Bypass the Copilot review gate and the pending-requested-reviews "
743
+ "gate when Copilot is down or out of quota on the current HEAD."
744
+ ),
745
+ )
633
746
  return parser.parse_args(all_argv)
634
747
 
635
748
 
@@ -647,6 +760,23 @@ def _resolve_bugbot_down(bugbot_down_flag: bool) -> bool:
647
760
  return bugbot_down_flag or is_bugbot_disabled_via_env()
648
761
 
649
762
 
763
+ def _resolve_copilot_down(is_copilot_down_flag: bool) -> bool:
764
+ """Combine the explicit flag with the CLAUDE_REVIEWS_DISABLED env opt-out.
765
+
766
+ Args:
767
+ is_copilot_down_flag: Value of the ``--copilot-down`` CLI flag.
768
+
769
+ Returns:
770
+ True when the flag is set OR ``CLAUDE_REVIEWS_DISABLED`` lists the
771
+ ``copilot`` token, so a Copilot outage signalled through the env var
772
+ bypasses the Copilot gates even when the caller omits the flag. The
773
+ mark-ready blocker hook re-runs this script without the flag, so the
774
+ env token is the only channel a genuine Copilot outage has to pass
775
+ that independent gate.
776
+ """
777
+ return is_copilot_down_flag or is_copilot_disabled_via_env()
778
+
779
+
650
780
  def main(all_arguments: list[str]) -> int:
651
781
  """Run the script end-to-end against parsed CLI arguments.
652
782
 
@@ -661,7 +791,8 @@ def main(all_arguments: list[str]) -> int:
661
791
  owner=arguments.owner,
662
792
  repo=arguments.repo,
663
793
  number=getattr(arguments, "pr_number"),
664
- bugbot_down=_resolve_bugbot_down(arguments.bugbot_down),
794
+ is_bugbot_down=_resolve_bugbot_down(arguments.bugbot_down),
795
+ is_copilot_down=_resolve_copilot_down(arguments.copilot_down),
665
796
  )
666
797
 
667
798
 
@@ -245,6 +245,34 @@ def should_resolve_bugbot_down_false_when_env_disables_only_bugteam(
245
245
  assert check_convergence._resolve_bugbot_down(False) is False
246
246
 
247
247
 
248
+ def should_resolve_copilot_down_true_when_flag_set(
249
+ monkeypatch: pytest.MonkeyPatch,
250
+ ) -> None:
251
+ monkeypatch.delenv("CLAUDE_REVIEWS_DISABLED", raising=False)
252
+ assert check_convergence._resolve_copilot_down(True) is True
253
+
254
+
255
+ def should_resolve_copilot_down_true_when_env_disables_copilot(
256
+ monkeypatch: pytest.MonkeyPatch,
257
+ ) -> None:
258
+ monkeypatch.setenv("CLAUDE_REVIEWS_DISABLED", "copilot")
259
+ assert check_convergence._resolve_copilot_down(False) is True
260
+
261
+
262
+ def should_resolve_copilot_down_false_when_flag_unset_and_env_empty(
263
+ monkeypatch: pytest.MonkeyPatch,
264
+ ) -> None:
265
+ monkeypatch.delenv("CLAUDE_REVIEWS_DISABLED", raising=False)
266
+ assert check_convergence._resolve_copilot_down(False) is False
267
+
268
+
269
+ def should_resolve_copilot_down_false_when_env_disables_only_bugbot(
270
+ monkeypatch: pytest.MonkeyPatch,
271
+ ) -> None:
272
+ monkeypatch.setenv("CLAUDE_REVIEWS_DISABLED", "bugbot")
273
+ assert check_convergence._resolve_copilot_down(False) is False
274
+
275
+
248
276
  def should_bypass_bugbot_gates_when_bugbot_down_is_true(
249
277
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
250
278
  ) -> None:
@@ -324,7 +352,7 @@ def should_bypass_bugbot_gates_when_bugbot_down_is_true(
324
352
  )
325
353
 
326
354
  exit_code = check_convergence.check_all(
327
- owner="o", repo="r", number=1, bugbot_down=True
355
+ owner="o", repo="r", number=1, is_bugbot_down=True, is_copilot_down=False
328
356
  )
329
357
  captured_stdout = capsys.readouterr().out
330
358
 
@@ -334,6 +362,122 @@ def should_bypass_bugbot_gates_when_bugbot_down_is_true(
334
362
  assert exit_code == 0
335
363
 
336
364
 
365
+ def should_bypass_copilot_gates_when_copilot_down_is_true(
366
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
367
+ ) -> None:
368
+ all_invocation_names: list[str] = []
369
+
370
+ def stub_get_pr_head_sha(*, owner: str, repo: str, number: int) -> str:
371
+ return CURRENT_HEAD_SHA
372
+
373
+ def stub_check_bugbot(*, owner: str, repo: str, sha: str) -> tuple[bool, str]:
374
+ return True, "stub passing"
375
+
376
+ def stub_check_bugbot_not_dirty(
377
+ *, owner: str, repo: str, number: int, head_sha: str
378
+ ) -> tuple[bool, str]:
379
+ return True, "stub passing"
380
+
381
+ def stub_check_bugteam_clean(
382
+ *, owner: str, repo: str, number: int, head_sha: str
383
+ ) -> tuple[bool, str]:
384
+ return True, "stub passing"
385
+
386
+ def stub_check_bot_review_should_not_be_called(
387
+ *,
388
+ owner: str,
389
+ repo: str,
390
+ number: int,
391
+ head_sha: str,
392
+ login_substring: str,
393
+ clean_states: tuple[str, ...],
394
+ dirty_states: tuple[str, ...],
395
+ label: str,
396
+ ) -> tuple[bool, str]:
397
+ all_invocation_names.append("_check_bot_review")
398
+ raise AssertionError(
399
+ "_check_bot_review must not be invoked when copilot_down=True"
400
+ )
401
+
402
+ def stub_count_unresolved_bot_threads(
403
+ *, owner: str, repo: str, number: int
404
+ ) -> tuple[bool, str]:
405
+ return True, "stub passing"
406
+
407
+ def stub_get_mergeable(
408
+ *, owner: str, repo: str, number: int
409
+ ) -> tuple[bool, str]:
410
+ return True, "stub passing"
411
+
412
+ def stub_check_no_pending_reviews_should_not_be_called(
413
+ *, owner: str, repo: str, number: int
414
+ ) -> tuple[bool, str]:
415
+ all_invocation_names.append("_check_no_pending_reviews")
416
+ raise AssertionError(
417
+ "_check_no_pending_reviews must not be invoked when copilot_down=True"
418
+ )
419
+
420
+ monkeypatch.setattr(check_convergence, "_get_pr_head_sha", stub_get_pr_head_sha)
421
+ monkeypatch.setattr(check_convergence, "_check_bugbot", stub_check_bugbot)
422
+ monkeypatch.setattr(
423
+ check_convergence, "_check_bugbot_not_dirty", stub_check_bugbot_not_dirty
424
+ )
425
+ monkeypatch.setattr(check_convergence, "_check_bugteam_clean", stub_check_bugteam_clean)
426
+ monkeypatch.setattr(
427
+ check_convergence,
428
+ "_check_bot_review",
429
+ stub_check_bot_review_should_not_be_called,
430
+ )
431
+ monkeypatch.setattr(
432
+ check_convergence, "_count_unresolved_bot_threads", stub_count_unresolved_bot_threads
433
+ )
434
+ monkeypatch.setattr(check_convergence, "_get_mergeable", stub_get_mergeable)
435
+ monkeypatch.setattr(
436
+ check_convergence,
437
+ "_check_no_pending_reviews",
438
+ stub_check_no_pending_reviews_should_not_be_called,
439
+ )
440
+
441
+ exit_code = check_convergence.check_all(
442
+ owner="o", repo="r", number=1, is_bugbot_down=False, is_copilot_down=True
443
+ )
444
+ captured_stdout = capsys.readouterr().out
445
+
446
+ assert "_check_bot_review" not in all_invocation_names
447
+ assert "_check_no_pending_reviews" not in all_invocation_names
448
+ assert "bypassed (copilot_down)" in captured_stdout
449
+ assert exit_code == 0
450
+
451
+
452
+ def should_accept_copilot_down_flag_in_parsed_arguments() -> None:
453
+ arguments = check_convergence.parse_arguments(
454
+ ["--owner", "o", "--repo", "r", "--pr-number", "1", "--copilot-down"]
455
+ )
456
+ assert arguments.copilot_down is True
457
+
458
+
459
+ def should_return_zero_from_print_conditions_when_every_condition_passes(
460
+ capsys: pytest.CaptureFixture[str],
461
+ ) -> None:
462
+ exit_code = check_convergence._print_conditions(
463
+ [("gate one", (True, "ok")), ("gate two", (True, "ok"))]
464
+ )
465
+ captured_stdout = capsys.readouterr().out
466
+ assert exit_code == 0
467
+ assert "All pre-conditions met" in captured_stdout
468
+
469
+
470
+ def should_return_one_from_print_conditions_when_any_condition_fails(
471
+ capsys: pytest.CaptureFixture[str],
472
+ ) -> None:
473
+ exit_code = check_convergence._print_conditions(
474
+ [("gate one", (True, "ok")), ("gate two", (False, "nope"))]
475
+ )
476
+ captured_stdout = capsys.readouterr().out
477
+ assert exit_code == 1
478
+ assert "One or more pre-conditions not met" in captured_stdout
479
+
480
+
337
481
  def should_propagate_systemexit_from_get_pr_head_sha(
338
482
  monkeypatch: pytest.MonkeyPatch,
339
483
  ) -> None:
@@ -347,6 +491,33 @@ def should_propagate_systemexit_from_get_pr_head_sha(
347
491
  )
348
492
 
349
493
  with pytest.raises(SystemExit) as exc_info:
350
- check_convergence.check_all(owner="o", repo="r", number=1, bugbot_down=False)
494
+ check_convergence.check_all(
495
+ owner="o", repo="r", number=1, is_bugbot_down=False, is_copilot_down=False
496
+ )
351
497
 
352
498
  assert exc_info.value.code == check_convergence.EXIT_CODE_GH_ERROR
499
+
500
+
501
+ def should_derive_copilot_down_from_env_when_main_omits_the_flag(
502
+ monkeypatch: pytest.MonkeyPatch,
503
+ ) -> None:
504
+ monkeypatch.setenv("CLAUDE_REVIEWS_DISABLED", "copilot")
505
+ captured_copilot_down: list[bool] = []
506
+
507
+ def stub_check_all(
508
+ *,
509
+ owner: str,
510
+ repo: str,
511
+ number: int,
512
+ is_bugbot_down: bool,
513
+ is_copilot_down: bool,
514
+ ) -> int:
515
+ captured_copilot_down.append(is_copilot_down)
516
+ return 0
517
+
518
+ monkeypatch.setattr(check_convergence, "check_all", stub_check_all)
519
+ exit_code = check_convergence.main(
520
+ ["--owner", "o", "--repo", "r", "--pr-number", "1"]
521
+ )
522
+ assert exit_code == 0
523
+ assert captured_copilot_down == [True]
@@ -60,11 +60,10 @@ When in doubt, ask. Both work; the choice affects history shape, not correctness
60
60
  | Tool | Use when | Example |
61
61
  |---|---|---|
62
62
  | `mcp__serena__find_symbol` / `find_referencing_symbols` | Symbol-aware language server is available — definition vs. reference distinction matters, and you want call-site context | `find_referencing_symbols(symbol_name)` returns every caller with file/line and surrounding code |
63
- | `mcp__zoekt__search_symbols` / `search` | Cross-repo or large codebase indexed in zoekt; faster than grep on big trees | `search(query)` returns ranked matches with snippets |
64
63
  | `Grep` tool (ripgrep) | Local single-repo plain-text scan; no symbol awareness needed | `Grep(pattern, type="py")` — much faster than shell `grep` and respects `.gitignore` |
65
64
  | `grep -rn` | Last resort; only when the above are unavailable | — |
66
65
 
67
- The Grep tool is the default for plain-text scans (faster than shell grep, respects gitignore). Reach for serena when you need to distinguish "this name is defined here" from "this name is referenced here," which catches false positives from comments, docstrings, and string literals. Reach for zoekt for cross-repo scans.
66
+ The Grep tool is the default for plain-text scans (faster than shell grep, respects gitignore). Reach for serena when you need to distinguish "this name is defined here" from "this name is referenced here," which catches false positives from comments, docstrings, and string literals.
68
67
 
69
68
  This is the bug that hides best. Don't skip it.
70
69
 
@@ -107,7 +106,6 @@ When in doubt, ask. Both work; the choice affects history shape, not correctness
107
106
  11. **Reference scan for removals/renames.** For every symbol the rebase deleted or renamed (per the commit messages from step 4), scan the post-rebase tree using the same tool-preference order as step 4:
108
107
 
109
108
  - **Preferred:** `mcp__serena__find_referencing_symbols` (symbol-aware; ignores false matches in comments and string literals).
110
- - **Fallback:** `mcp__zoekt__search` for cross-repo or large trees.
111
109
  - **Then:** the `Grep` tool (e.g., `Grep(pattern="<symbol>", type="py")`) for fast in-repo scans.
112
110
  - **Last resort:** `grep -rn "<symbol>" .` (let ripgrep defaults and `.gitignore` handle scoping)
113
111
 
@@ -156,7 +154,7 @@ After rebase, BEFORE push:
156
154
  python -c "import …" ──► must succeed
157
155
  pytest --collect-only ──► must succeed
158
156
  targeted pytest run ──► must pass
159
- symbol scan (serena → zoekt → Grep) ──► no stale references
157
+ symbol scan (serena → Grep) ──► no stale references
160
158
 
161
159
  Push:
162
160
  not main/master/release ──► force-with-lease, ask first