claude-dev-env 1.57.2 → 1.59.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 (77) 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-o-docstring-vs-impl-drift.md +1 -1
  9. package/bin/install.mjs +317 -54
  10. package/bin/install.test.mjs +478 -3
  11. package/docs/CODE_RULES.md +3 -3
  12. package/hooks/blocking/code_rules_annotations_length.py +153 -0
  13. package/hooks/blocking/code_rules_dead_dataclass_field.py +319 -0
  14. package/hooks/blocking/code_rules_duplicate_body.py +287 -0
  15. package/hooks/blocking/code_rules_enforcer.py +175 -21
  16. package/hooks/blocking/code_rules_magic_values.py +98 -0
  17. package/hooks/blocking/code_rules_shared.py +41 -0
  18. package/hooks/blocking/destructive_command_blocker.py +1027 -12
  19. package/hooks/blocking/hook_prose_detector_consistency.py +150 -0
  20. package/hooks/blocking/intent_only_ending_blocker.py +155 -0
  21. package/hooks/blocking/session_handoff_blocker.py +190 -0
  22. package/hooks/blocking/subprocess_budget_completeness.py +380 -0
  23. package/hooks/blocking/test_code_rules_enforcer_annotations.py +225 -0
  24. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
  25. package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +467 -0
  26. package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +330 -0
  27. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +179 -0
  28. package/hooks/blocking/test_code_rules_enforcer_magic_slice_bounds.py +133 -0
  29. package/hooks/blocking/test_destructive_command_blocker.py +622 -3
  30. package/hooks/blocking/test_hook_prose_detector_consistency.py +265 -0
  31. package/hooks/blocking/test_intent_only_ending_blocker.py +175 -0
  32. package/hooks/blocking/test_session_handoff_blocker.py +312 -0
  33. package/hooks/blocking/test_subprocess_budget_completeness.py +588 -0
  34. package/hooks/blocking/test_workflow_substitution_slot_blocker.py +242 -0
  35. package/hooks/blocking/workflow_substitution_slot_blocker.py +159 -0
  36. package/hooks/hooks.json +25 -0
  37. package/hooks/hooks_constants/code_rules_enforcer_constants.py +16 -0
  38. package/hooks/hooks_constants/dead_dataclass_field_constants.py +25 -0
  39. package/hooks/hooks_constants/destructive_command_segment_constants.py +178 -0
  40. package/hooks/hooks_constants/duplicate_function_body_constants.py +17 -0
  41. package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +30 -0
  42. package/hooks/hooks_constants/messages.py +4 -0
  43. package/hooks/hooks_constants/session_handoff_blocker_constants.py +10 -0
  44. package/hooks/hooks_constants/subprocess_budget_completeness_constants.py +5 -0
  45. package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +22 -0
  46. package/hooks/workflow/auto_formatter.py +26 -1
  47. package/hooks/workflow/test_auto_formatter.py +134 -0
  48. package/package.json +1 -1
  49. package/rules/conservative-action.md +1 -0
  50. package/rules/docstring-prose-matches-implementation.md +43 -0
  51. package/rules/hook-prose-matches-detector.md +26 -0
  52. package/rules/long-horizon-autonomy.md +43 -0
  53. package/rules/no-inline-destructive-literals.md +11 -0
  54. package/rules/workflow-substitution-slots.md +7 -0
  55. package/skills/autoconverge/SKILL.md +68 -6
  56. package/skills/autoconverge/reference/closing-report.md +44 -0
  57. package/skills/autoconverge/reference/convergence.md +7 -3
  58. package/skills/autoconverge/reference/stop-conditions.md +7 -2
  59. package/skills/autoconverge/workflow/autoconverge_report_constants/__init__.py +0 -0
  60. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +105 -0
  61. package/skills/autoconverge/workflow/converge.contract.test.mjs +30 -1
  62. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +265 -0
  63. package/skills/autoconverge/workflow/converge.mjs +106 -38
  64. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -0
  65. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -0
  66. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -0
  67. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -0
  68. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -0
  69. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -0
  70. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -0
  71. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -0
  72. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +259 -0
  73. package/skills/autoconverge/workflow/render_report.py +903 -0
  74. package/skills/autoconverge/workflow/test_render_report.py +484 -0
  75. package/skills/pr-converge/scripts/check_convergence.py +195 -64
  76. package/skills/pr-converge/scripts/test_check_convergence.py +173 -2
  77. package/skills/update/SKILL.md +37 -5
@@ -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]
@@ -1,13 +1,13 @@
1
1
  ---
2
2
  name: update
3
- description: Fast-forwards a local git repository's main branch to a remote's main, after confirming both the local repo path and the source remote through AskUserQuestion. Fetches the chosen remote, checks that the move is a true fast-forward (never a force, never a merge commit), and updates main whether or not main is the checked-out branch. Use when the user says "/update", "update main", "fast-forward main", "sync main from origin", "pull latest main into <path>", or "bring main up to date". Triggers on "/update", "update main", "fast-forward main", "sync main".
3
+ description: Fast-forwards a local git repository's main branch to a remote's main, after confirming both the local repo path and the source remote through AskUserQuestion. Fetches the chosen remote, checks that the move is a true fast-forward (never a force, never a merge commit), and updates main whether or not main is the checked-out branch. When main is not the checked-out branch, it then offers to switch the checkout to main so the update reaches the files on disk. Use when the user says "/update", "update main", "fast-forward main", "sync main from origin", "pull latest main into <path>", or "bring main up to date". Triggers on "/update", "update main", "fast-forward main", "sync main".
4
4
  ---
5
5
 
6
6
  # update
7
7
 
8
8
  ## Overview
9
9
 
10
- Fast-forwards the local `main` branch of a given repository to a chosen remote's `main`. The move is always a true fast-forward: the skill fetches the remote, checks that local `main` is an ancestor of the remote's `main`, and advances the ref. It never forces, never creates a merge commit, and never touches any branch other than `main`.
10
+ Fast-forwards the local `main` branch of a given repository to a chosen remote's `main`. The move is always a true fast-forward: the skill fetches the remote, checks that local `main` is an ancestor of the remote's `main`, and advances the ref. It never forces, never creates a merge commit, and never rewrites any branch other than `main`. When `main` is not the checked-out branch, a final confirmed step offers to switch the checkout to `main` so the new commits reach the files on disk — the only branch switch the skill makes, and only after the operator approves it.
11
11
 
12
12
  The repository is whatever path the user gives as the `/update <path>` argument. With no argument, the default is the current repository's top level. Either way the path is confirmed before any write.
13
13
 
@@ -121,11 +121,42 @@ git -C "<path>" status --short
121
121
  - **Checked-out branch.** When a branch other than `main` is checked out, say so plainly: the fast-forward moves a ref only and no file on disk changes. Code that runs from this checkout comes from the checked-out branch, not from `main`.
122
122
  - **Dirty tracked files.** List every modified tracked file from `status --short`. Uncommitted edits sit on top of the checked-out branch and are what runs — flag them, because a ref update can neither see nor repair them. When the tree is clean, report "working tree clean".
123
123
 
124
+ ### Phase 5 — Offer to land `main` on disk
125
+
126
+ A fast-forward of the `main` ref leaves the files on disk untouched when another branch is checked out. The new commits exist in the repo but not in the working tree — the gap Phase 4 reports. This phase offers to close it.
127
+
128
+ Skip this phase and finish when the checked-out branch is already `main`: the new commits are already on disk.
129
+
130
+ Otherwise, when the checked-out branch is not `main`, run two safety checks before offering a switch:
131
+
132
+ 1. **Tree clean of tracked changes.** Read `git -C "<path>" status --porcelain` and treat any line that does not start with `??` as a change to a tracked file. If any exist, do not offer the switch: report that `main` moved in the ref only, that switching would risk the uncommitted work, and stop. Never stash, reset, or discard. Untracked files (lines starting with `??`) are fine — they carry across a switch.
133
+ 2. **`main` free to check out.** Read `git -C "<path>" worktree list`. If another worktree holds `main`, an in-place switch is impossible; report that path and stop.
134
+
135
+ When both checks pass, ask **one** `AskUserQuestion` — header "Get on disk":
136
+
137
+ - Recommended first choice, "Switch checkout to main": `git -C "<path>" checkout main` puts the new commits on disk. The branch you were on keeps its commits and is left unchanged.
138
+ - Second choice, "Stay on `<branch>`": leave the checkout where it is. The update stays in the `main` ref only and reaches disk later.
139
+
140
+ On "Switch", run:
141
+
142
+ ```
143
+ git -C "<path>" checkout main
144
+ ```
145
+
146
+ If `checkout` reports it would overwrite untracked files, stop and report those paths — never pass `-f`, which would drop the operator's untracked file. On success, confirm the branch and the tip now on disk:
147
+
148
+ ```
149
+ git -C "<path>" branch --show-current
150
+ git -C "<path>" log --oneline -1
151
+ ```
152
+
153
+ On "Stay", report that `main` is current in the ref and the new content reaches disk only after a later switch.
154
+
124
155
  ## Constraints (non-negotiable)
125
156
 
126
157
  - **Fast-forward only.** If the remote's `main` is not a descendant of local `main`, stop. Never `--force`, never `branch -f`, never a merge commit. Divergence is a job for `/rebase`.
127
158
  - **Always confirm both** the path and the source remote first, even when the path is given as an argument. Skipping a confirmation is not allowed — the confirmation is the point of this skill.
128
- - **Touch only `main`.** Never switch the repo's checked-out branch. The single exception is advancing `main` in place when `main` is already checked out.
159
+ - **Switch the checkout only with approval.** The fast-forward itself touches the `main` ref alone. Switching the checked-out branch to `main` happens only in Phase 5, only after the operator approves the `AskUserQuestion`, only with a tree clean of tracked changes, and never with `-f`. The skill never switches to any branch but `main`.
129
160
  - **Never discard local work.** A dirty tree blocks the in-place fast-forward; stop and report rather than stash or reset.
130
161
 
131
162
  ## Gotchas
@@ -135,12 +166,13 @@ git -C "<path>" status --short
135
166
  - `<remote>/main` only moves after an explicit `git fetch`. Fetch inside every run; never compare against a remote-tracking ref left over from an earlier fetch.
136
167
  - `origin` is not always the source of truth. When a fork is `origin` and the canonical repo is another remote (often `upstream`), the confirmed remote should be the canonical one, not whichever is named `origin`.
137
168
  - Quote the path on every command — `git -C "<path>"` — so paths with spaces or a NAS drive letter survive.
138
- - "Up to date" describes the `main` ref, not the running code. A checkout deployed on a feature branch, or carrying uncommitted edits, runs that branch plus those edits regardless of where `main` points. Phase 4's checkout-state report exists so the operator sees that gap on every run.
169
+ - "Up to date" describes the `main` ref, not the running code. A checkout deployed on a feature branch, or carrying uncommitted edits, runs that branch plus those edits regardless of where `main` points. Phase 4's checkout-state report exists so the operator sees that gap on every run, and Phase 5 offers to close it.
170
+ - `git checkout main` carries untracked files across unchanged, but refuses with "untracked working tree files would be overwritten" when an untracked file sits at a path `main` tracks. Phase 5 reports those paths and stops rather than passing `-f`, which would drop the operator's untracked file.
139
171
 
140
172
  ## What this skill does NOT do
141
173
 
142
174
  - Does not push, open a PR, or change any branch other than `main`.
143
- - Does not create or switch feature branches — that is `/fresh-branch`.
175
+ - Does not create feature branches, and switches only to `main` (Phase 5, with approval) creating or switching to any other branch is `/fresh-branch`.
144
176
  - Does not reconcile a diverged `main` — that is `/rebase`.
145
177
 
146
178
  ## File index