claude-dev-env 1.51.0 → 1.52.1
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/hooks/blocking/code_rules_enforcer.py +367 -42
- package/hooks/blocking/tdd_enforcer.py +211 -19
- package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +519 -0
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +1 -1
- package/hooks/blocking/test_tdd_enforcer.py +399 -0
- package/hooks/hooks.json +0 -15
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +5 -0
- package/package.json +1 -1
- package/skills/pr-converge/SKILL.md +34 -2
- package/skills/pr-converge/reference/fix-protocol.md +9 -0
- package/skills/pr-converge/reference/ground-rules.md +7 -0
- package/skills/pr-converge/reference/per-tick.md +93 -6
- package/skills/update/SKILL.md +143 -0
|
@@ -690,3 +690,402 @@ def test_should_deny_code_rules_edit_when_split_family_sibling_is_stale(
|
|
|
690
690
|
completed = _run_hook_with_payload(payload)
|
|
691
691
|
|
|
692
692
|
assert _decision_from(completed) == "deny"
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def test_should_offer_nested_package_mirroring_candidate_for_subpackage_module(
|
|
696
|
+
tmp_path: Path,
|
|
697
|
+
) -> None:
|
|
698
|
+
sandbox = _sandbox(tmp_path)
|
|
699
|
+
package_root = sandbox / "pkg"
|
|
700
|
+
subpackage_directory = package_root / "services" / "mouse_movement"
|
|
701
|
+
subpackage_directory.mkdir(parents=True)
|
|
702
|
+
(package_root / "tests").mkdir()
|
|
703
|
+
production_module = subpackage_directory / "tremor.py"
|
|
704
|
+
|
|
705
|
+
all_candidates = _PRODUCTION_MODULE.candidate_test_paths_for(production_module)
|
|
706
|
+
|
|
707
|
+
assert package_root / "tests" / "services" / "test_tremor.py" in all_candidates
|
|
708
|
+
assert (
|
|
709
|
+
package_root / "tests" / "services" / "mouse_movement" / "test_tremor.py"
|
|
710
|
+
in all_candidates
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def test_should_offer_flat_candidate_alongside_nested_candidates(
|
|
715
|
+
tmp_path: Path,
|
|
716
|
+
) -> None:
|
|
717
|
+
sandbox = _sandbox(tmp_path)
|
|
718
|
+
package_root = sandbox / "pkg"
|
|
719
|
+
subpackage_directory = package_root / "services" / "mouse_movement"
|
|
720
|
+
subpackage_directory.mkdir(parents=True)
|
|
721
|
+
(package_root / "tests").mkdir()
|
|
722
|
+
production_module = subpackage_directory / "tremor.py"
|
|
723
|
+
|
|
724
|
+
all_candidates = _PRODUCTION_MODULE.candidate_test_paths_for(production_module)
|
|
725
|
+
|
|
726
|
+
assert package_root / "tests" / "test_tremor.py" in all_candidates
|
|
727
|
+
assert (
|
|
728
|
+
package_root / "tests" / "services" / "mouse_movement" / "test_tremor.py"
|
|
729
|
+
in all_candidates
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def test_should_allow_write_when_nested_package_mirroring_test_is_fresh(
|
|
734
|
+
tmp_path: Path,
|
|
735
|
+
) -> None:
|
|
736
|
+
sandbox = _sandbox(tmp_path)
|
|
737
|
+
package_root = sandbox / "pkg"
|
|
738
|
+
subpackage_directory = package_root / "services" / "mouse_movement"
|
|
739
|
+
subpackage_directory.mkdir(parents=True)
|
|
740
|
+
nested_tests_directory = package_root / "tests" / "services" / "mouse_movement"
|
|
741
|
+
nested_tests_directory.mkdir(parents=True)
|
|
742
|
+
production_module = subpackage_directory / "tremor.py"
|
|
743
|
+
production_module.write_text("def jitter(): pass\n")
|
|
744
|
+
nested_test = nested_tests_directory / "test_tremor.py"
|
|
745
|
+
nested_test.write_text("def test_jitter(): pass\n")
|
|
746
|
+
|
|
747
|
+
completed = _run_hook_with_payload(_make_write_payload(production_module))
|
|
748
|
+
|
|
749
|
+
assert _decision_from(completed) == "allow"
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def test_should_collect_tests_directories_from_every_ancestor_up_to_repo_boundary(
|
|
753
|
+
tmp_path: Path,
|
|
754
|
+
) -> None:
|
|
755
|
+
sandbox = _sandbox(tmp_path)
|
|
756
|
+
package_root = sandbox / "pkg"
|
|
757
|
+
subpackage_directory = package_root / "services"
|
|
758
|
+
subpackage_directory.mkdir(parents=True)
|
|
759
|
+
(package_root / "tests").mkdir()
|
|
760
|
+
(subpackage_directory / "tests").mkdir()
|
|
761
|
+
|
|
762
|
+
all_pairs = _PRODUCTION_MODULE._ancestor_tests_directories(subpackage_directory)
|
|
763
|
+
|
|
764
|
+
collected_tests_directories = [each_tests_directory for _, each_tests_directory in all_pairs]
|
|
765
|
+
assert subpackage_directory / "tests" in collected_tests_directories
|
|
766
|
+
assert package_root / "tests" in collected_tests_directories
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def test_ancestor_tests_walk_stops_at_repo_boundary(tmp_path: Path) -> None:
|
|
770
|
+
outer_tests_directory = tmp_path / "tests"
|
|
771
|
+
outer_tests_directory.mkdir()
|
|
772
|
+
sandbox = _sandbox(tmp_path)
|
|
773
|
+
package_directory = sandbox / "pkg"
|
|
774
|
+
package_directory.mkdir()
|
|
775
|
+
|
|
776
|
+
all_pairs = _PRODUCTION_MODULE._ancestor_tests_directories(package_directory)
|
|
777
|
+
|
|
778
|
+
collected_tests_directories = [each_tests_directory for _, each_tests_directory in all_pairs]
|
|
779
|
+
assert outer_tests_directory not in collected_tests_directories
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def test_ancestor_tests_walk_honors_parent_walk_limit(tmp_path: Path) -> None:
|
|
783
|
+
walk_limit = _PRODUCTION_MODULE._parent_walk_limit()
|
|
784
|
+
deep_directory = tmp_path
|
|
785
|
+
for each_level_index in range(walk_limit + 2):
|
|
786
|
+
deep_directory = deep_directory / f"level_{each_level_index}"
|
|
787
|
+
deep_directory.mkdir(parents=True)
|
|
788
|
+
top_tests_directory = tmp_path / "tests"
|
|
789
|
+
top_tests_directory.mkdir()
|
|
790
|
+
|
|
791
|
+
all_pairs = _PRODUCTION_MODULE._ancestor_tests_directories(deep_directory)
|
|
792
|
+
|
|
793
|
+
collected_tests_directories = [each_tests_directory for _, each_tests_directory in all_pairs]
|
|
794
|
+
assert top_tests_directory not in collected_tests_directories
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def test_should_deny_edit_that_swaps_an_import_target(tmp_path: Path) -> None:
|
|
798
|
+
sandbox = _sandbox(tmp_path)
|
|
799
|
+
production_module = sandbox / "orders.py"
|
|
800
|
+
production_module.write_text("import os\n\ndef fulfill(): pass\n")
|
|
801
|
+
|
|
802
|
+
completed = _run_hook_with_payload(
|
|
803
|
+
_make_edit_payload(
|
|
804
|
+
production_module,
|
|
805
|
+
old_string="import os",
|
|
806
|
+
new_string="import sys",
|
|
807
|
+
)
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
assert _decision_from(completed) == "deny"
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def test_should_allow_edit_that_removes_an_import_statement(tmp_path: Path) -> None:
|
|
814
|
+
sandbox = _sandbox(tmp_path)
|
|
815
|
+
production_module = sandbox / "orders.py"
|
|
816
|
+
production_module.write_text("import os\n\ndef fulfill(): pass\n")
|
|
817
|
+
|
|
818
|
+
completed = _run_hook_with_payload(
|
|
819
|
+
_make_edit_payload(
|
|
820
|
+
production_module,
|
|
821
|
+
old_string="import os\n",
|
|
822
|
+
new_string="",
|
|
823
|
+
)
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
assert _decision_from(completed) == "allow"
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def test_should_allow_multiedit_when_every_pair_removes_an_import(tmp_path: Path) -> None:
|
|
830
|
+
sandbox = _sandbox(tmp_path)
|
|
831
|
+
production_module = sandbox / "orders.py"
|
|
832
|
+
production_module.write_text("import os\nimport json\nimport time\n\ndef fulfill(): pass\n")
|
|
833
|
+
|
|
834
|
+
completed = _run_hook_with_payload(
|
|
835
|
+
_make_multiedit_payload(
|
|
836
|
+
production_module,
|
|
837
|
+
edits=[
|
|
838
|
+
{"old_string": "import os\n", "new_string": ""},
|
|
839
|
+
{"old_string": "import json\n", "new_string": ""},
|
|
840
|
+
],
|
|
841
|
+
)
|
|
842
|
+
)
|
|
843
|
+
|
|
844
|
+
assert _decision_from(completed) == "allow"
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def test_should_deny_multiedit_when_one_pair_is_not_import_only(tmp_path: Path) -> None:
|
|
848
|
+
sandbox = _sandbox(tmp_path)
|
|
849
|
+
production_module = sandbox / "orders.py"
|
|
850
|
+
production_module.write_text("import os\n\ndef fulfill(): pass\n")
|
|
851
|
+
|
|
852
|
+
completed = _run_hook_with_payload(
|
|
853
|
+
_make_multiedit_payload(
|
|
854
|
+
production_module,
|
|
855
|
+
edits=[
|
|
856
|
+
{"old_string": "import os", "new_string": "import sys"},
|
|
857
|
+
{
|
|
858
|
+
"old_string": "def fulfill(): pass",
|
|
859
|
+
"new_string": "def fulfill(): return 1",
|
|
860
|
+
},
|
|
861
|
+
],
|
|
862
|
+
)
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
assert _decision_from(completed) == "deny"
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def test_should_not_exempt_write_with_behavior_under_import_only_rule(
|
|
869
|
+
tmp_path: Path,
|
|
870
|
+
) -> None:
|
|
871
|
+
sandbox = _sandbox(tmp_path)
|
|
872
|
+
production_module = sandbox / "orders.py"
|
|
873
|
+
write_content_with_behavior = "import os\n\ndef fulfill(): return os.getpid()\n"
|
|
874
|
+
|
|
875
|
+
completed = _run_hook_with_payload(
|
|
876
|
+
_make_write_payload(production_module, write_content_with_behavior)
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
assert _decision_from(completed) == "deny"
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def test_should_deny_behavior_edit_without_a_fresh_candidate_test(tmp_path: Path) -> None:
|
|
883
|
+
sandbox = _sandbox(tmp_path)
|
|
884
|
+
production_module = sandbox / "orders.py"
|
|
885
|
+
production_module.write_text("def fulfill(): pass\n")
|
|
886
|
+
|
|
887
|
+
completed = _run_hook_with_payload(
|
|
888
|
+
_make_edit_payload(
|
|
889
|
+
production_module,
|
|
890
|
+
old_string="def fulfill(): pass",
|
|
891
|
+
new_string="def fulfill(): return 1",
|
|
892
|
+
)
|
|
893
|
+
)
|
|
894
|
+
|
|
895
|
+
assert _decision_from(completed) == "deny"
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def test_should_deny_edit_of_import_text_inside_a_string_literal(tmp_path: Path) -> None:
|
|
899
|
+
sandbox = _sandbox(tmp_path)
|
|
900
|
+
production_module = sandbox / "orders.py"
|
|
901
|
+
production_module.write_text('banner = "import os is great"\n\ndef fulfill(): return banner\n')
|
|
902
|
+
|
|
903
|
+
completed = _run_hook_with_payload(
|
|
904
|
+
_make_edit_payload(
|
|
905
|
+
production_module,
|
|
906
|
+
old_string="import os",
|
|
907
|
+
new_string="import sys",
|
|
908
|
+
)
|
|
909
|
+
)
|
|
910
|
+
|
|
911
|
+
assert _decision_from(completed) == "deny"
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def test_should_allow_import_reorder_when_old_string_carries_context_lines(
|
|
915
|
+
tmp_path: Path,
|
|
916
|
+
) -> None:
|
|
917
|
+
sandbox = _sandbox(tmp_path)
|
|
918
|
+
production_module = sandbox / "orders.py"
|
|
919
|
+
production_module.write_text("import os\nimport sys\n\ndef fulfill(): pass\n")
|
|
920
|
+
|
|
921
|
+
completed = _run_hook_with_payload(
|
|
922
|
+
_make_edit_payload(
|
|
923
|
+
production_module,
|
|
924
|
+
old_string="import os\nimport sys\n\ndef fulfill(): pass",
|
|
925
|
+
new_string="import sys\nimport os\n\ndef fulfill(): pass",
|
|
926
|
+
)
|
|
927
|
+
)
|
|
928
|
+
|
|
929
|
+
assert _decision_from(completed) == "allow"
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def test_should_allow_import_only_edit_after_a_constants_assignment(tmp_path: Path) -> None:
|
|
933
|
+
sandbox = _sandbox(tmp_path)
|
|
934
|
+
production_module = sandbox / "orders.py"
|
|
935
|
+
production_module.write_text("import os\nimport sys\n\nMAX_ORDERS = 5\n\ndef fulfill(): pass\n")
|
|
936
|
+
|
|
937
|
+
completed = _run_hook_with_payload(
|
|
938
|
+
_make_edit_payload(
|
|
939
|
+
production_module,
|
|
940
|
+
old_string="import os\n",
|
|
941
|
+
new_string="",
|
|
942
|
+
)
|
|
943
|
+
)
|
|
944
|
+
|
|
945
|
+
assert _decision_from(completed) == "allow"
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def test_should_deny_replace_all_edit_that_rewrites_call_sites(tmp_path: Path) -> None:
|
|
949
|
+
sandbox = _sandbox(tmp_path)
|
|
950
|
+
production_module = sandbox / "orders.py"
|
|
951
|
+
production_module.write_text("import json\n\ndef parse(line): return json.loads(line)\n")
|
|
952
|
+
|
|
953
|
+
completed = _run_hook_with_payload(
|
|
954
|
+
{
|
|
955
|
+
"tool_name": "Edit",
|
|
956
|
+
"tool_input": {
|
|
957
|
+
"file_path": str(production_module),
|
|
958
|
+
"old_string": "json",
|
|
959
|
+
"new_string": "pickle",
|
|
960
|
+
"replace_all": True,
|
|
961
|
+
},
|
|
962
|
+
}
|
|
963
|
+
)
|
|
964
|
+
|
|
965
|
+
assert _decision_from(completed) == "deny"
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def test_should_allow_edit_that_reorders_import_statements(tmp_path: Path) -> None:
|
|
969
|
+
sandbox = _sandbox(tmp_path)
|
|
970
|
+
production_module = sandbox / "orders.py"
|
|
971
|
+
production_module.write_text("import os\nimport sys\n\ndef fulfill(): pass\n")
|
|
972
|
+
|
|
973
|
+
completed = _run_hook_with_payload(
|
|
974
|
+
_make_edit_payload(
|
|
975
|
+
production_module,
|
|
976
|
+
old_string="import os\nimport sys",
|
|
977
|
+
new_string="import sys\nimport os",
|
|
978
|
+
)
|
|
979
|
+
)
|
|
980
|
+
|
|
981
|
+
assert _decision_from(completed) == "allow"
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def test_should_deny_edit_that_retargets_an_import_source(tmp_path: Path) -> None:
|
|
985
|
+
sandbox = _sandbox(tmp_path)
|
|
986
|
+
production_module = sandbox / "orders.py"
|
|
987
|
+
production_module.write_text("from fast import compute\n\ndef run(): return compute()\n")
|
|
988
|
+
|
|
989
|
+
completed = _run_hook_with_payload(
|
|
990
|
+
_make_edit_payload(
|
|
991
|
+
production_module,
|
|
992
|
+
old_string="from fast import compute",
|
|
993
|
+
new_string="from slow import compute",
|
|
994
|
+
)
|
|
995
|
+
)
|
|
996
|
+
|
|
997
|
+
assert _decision_from(completed) == "deny"
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
def test_should_deny_edit_that_adds_a_future_import(tmp_path: Path) -> None:
|
|
1001
|
+
sandbox = _sandbox(tmp_path)
|
|
1002
|
+
production_module = sandbox / "orders.py"
|
|
1003
|
+
production_module.write_text("import os\n\ndef fulfill(): return os.getpid()\n")
|
|
1004
|
+
|
|
1005
|
+
completed = _run_hook_with_payload(
|
|
1006
|
+
_make_edit_payload(
|
|
1007
|
+
production_module,
|
|
1008
|
+
old_string="import os",
|
|
1009
|
+
new_string="from __future__ import annotations\nimport os",
|
|
1010
|
+
)
|
|
1011
|
+
)
|
|
1012
|
+
|
|
1013
|
+
assert _decision_from(completed) == "deny"
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
def test_should_deny_multiedit_with_an_empty_edits_list(tmp_path: Path) -> None:
|
|
1017
|
+
sandbox = _sandbox(tmp_path)
|
|
1018
|
+
production_module = sandbox / "orders.py"
|
|
1019
|
+
production_module.write_text("import os\n\ndef fulfill(): return os.getpid()\n")
|
|
1020
|
+
|
|
1021
|
+
completed = _run_hook_with_payload(
|
|
1022
|
+
_make_multiedit_payload(production_module, edits=[])
|
|
1023
|
+
)
|
|
1024
|
+
|
|
1025
|
+
assert _decision_from(completed) == "deny"
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def test_should_deny_edit_that_removes_a_future_import(tmp_path: Path) -> None:
|
|
1029
|
+
sandbox = _sandbox(tmp_path)
|
|
1030
|
+
production_module = sandbox / "orders.py"
|
|
1031
|
+
production_module.write_text(
|
|
1032
|
+
"from __future__ import annotations\nimport os\n\ndef fulfill(): return os.getpid()\n"
|
|
1033
|
+
)
|
|
1034
|
+
|
|
1035
|
+
completed = _run_hook_with_payload(
|
|
1036
|
+
_make_edit_payload(
|
|
1037
|
+
production_module,
|
|
1038
|
+
old_string="from __future__ import annotations\n",
|
|
1039
|
+
new_string="",
|
|
1040
|
+
)
|
|
1041
|
+
)
|
|
1042
|
+
|
|
1043
|
+
assert _decision_from(completed) == "deny"
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def test_should_deny_edit_that_adds_an_import(tmp_path: Path) -> None:
|
|
1047
|
+
sandbox = _sandbox(tmp_path)
|
|
1048
|
+
production_module = sandbox / "orders.py"
|
|
1049
|
+
production_module.write_text("import os\n\ndef fulfill(): pass\n")
|
|
1050
|
+
|
|
1051
|
+
completed = _run_hook_with_payload(
|
|
1052
|
+
_make_edit_payload(
|
|
1053
|
+
production_module,
|
|
1054
|
+
old_string="import os",
|
|
1055
|
+
new_string="import os\nimport sys",
|
|
1056
|
+
)
|
|
1057
|
+
)
|
|
1058
|
+
|
|
1059
|
+
assert _decision_from(completed) == "deny"
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def test_should_deny_edit_that_duplicates_an_import(tmp_path: Path) -> None:
|
|
1063
|
+
sandbox = _sandbox(tmp_path)
|
|
1064
|
+
production_module = sandbox / "orders.py"
|
|
1065
|
+
production_module.write_text("import os\n\ndef fulfill(): pass\n")
|
|
1066
|
+
|
|
1067
|
+
completed = _run_hook_with_payload(
|
|
1068
|
+
_make_edit_payload(
|
|
1069
|
+
production_module,
|
|
1070
|
+
old_string="import os",
|
|
1071
|
+
new_string="import os\nimport os",
|
|
1072
|
+
)
|
|
1073
|
+
)
|
|
1074
|
+
|
|
1075
|
+
assert _decision_from(completed) == "deny"
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def test_should_deny_changing_a_future_import_on_a_constants_only_file(tmp_path: Path) -> None:
|
|
1079
|
+
sandbox = _sandbox(tmp_path)
|
|
1080
|
+
production_module = sandbox / "orders.py"
|
|
1081
|
+
production_module.write_text("from __future__ import annotations\nMAX_ORDERS = 5\n")
|
|
1082
|
+
|
|
1083
|
+
completed = _run_hook_with_payload(
|
|
1084
|
+
_make_edit_payload(
|
|
1085
|
+
production_module,
|
|
1086
|
+
old_string="from __future__ import annotations",
|
|
1087
|
+
new_string="from __future__ import division",
|
|
1088
|
+
)
|
|
1089
|
+
)
|
|
1090
|
+
|
|
1091
|
+
assert _decision_from(completed) == "deny"
|
package/hooks/hooks.json
CHANGED
|
@@ -2,16 +2,6 @@
|
|
|
2
2
|
"description": "Code standards enforcement, safety guards, and development workflow hooks",
|
|
3
3
|
"hooks": {
|
|
4
4
|
"PreToolUse": [
|
|
5
|
-
{
|
|
6
|
-
"matcher": "Grep|Search",
|
|
7
|
-
"hooks": [
|
|
8
|
-
{
|
|
9
|
-
"type": "command",
|
|
10
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/content_search_to_zoekt_redirector.py",
|
|
11
|
-
"timeout": 10
|
|
12
|
-
}
|
|
13
|
-
]
|
|
14
|
-
},
|
|
15
5
|
{
|
|
16
6
|
"matcher": "Write|Edit",
|
|
17
7
|
"hooks": [
|
|
@@ -130,11 +120,6 @@
|
|
|
130
120
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/convergence_gate_blocker.py",
|
|
131
121
|
"timeout": 15
|
|
132
122
|
},
|
|
133
|
-
{
|
|
134
|
-
"type": "command",
|
|
135
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/content_search_to_zoekt_redirector.py",
|
|
136
|
-
"timeout": 10
|
|
137
|
-
},
|
|
138
123
|
{
|
|
139
124
|
"type": "command",
|
|
140
125
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/windows_rmtree_blocker.py",
|
|
@@ -124,6 +124,11 @@ FUNCTION_LENGTH_BLOCKING_MESSAGE_SUFFIX: str = (
|
|
|
124
124
|
"function review hint)"
|
|
125
125
|
)
|
|
126
126
|
|
|
127
|
+
PRECHECK_USAGE_EXIT_CODE: int = 2
|
|
128
|
+
PRECHECK_USAGE_MESSAGE: str = (
|
|
129
|
+
"usage: code_rules_enforcer.py --check <candidate> [--as <target>]\n"
|
|
130
|
+
)
|
|
131
|
+
|
|
127
132
|
BANNED_NOUN_SPAN_FRAGMENT_TEMPLATE: str = (
|
|
128
133
|
"(binding span at line {definition_line}, spanning {line_span} lines)"
|
|
129
134
|
)
|
package/package.json
CHANGED
|
@@ -31,6 +31,18 @@ proceed to any state-modifying operation until the working directory
|
|
|
31
31
|
contains `.claude/worktrees/`. If `EnterWorktree` fails, report the failure
|
|
32
32
|
and stop; do not continue in place.
|
|
33
33
|
|
|
34
|
+
`EnterWorktree` isolates the session's **own** repo. When the PR under
|
|
35
|
+
convergence shares that repo, its worktree is where the CODE_REVIEW phase
|
|
36
|
+
runs. When the PR lives in a different repo, `EnterWorktree` cannot
|
|
37
|
+
re-root into it; Step 1.5 resolves a **PR worktree** — a checkout of the
|
|
38
|
+
PR's repo on its head branch — and routes the working directory into it.
|
|
39
|
+
Routing the working directory into the PR's repo is routine and
|
|
40
|
+
automatic, never a fork to pause on. The Pre-flight `.claude/worktrees/`
|
|
41
|
+
gate covers the session repo's own isolation; for a cross-repo PR the
|
|
42
|
+
working directory routes into the PR's repo for local work and returns to
|
|
43
|
+
the session worktree before teardown. See
|
|
44
|
+
[`reference/per-tick.md` § Step 1.5](reference/per-tick.md).
|
|
45
|
+
|
|
34
46
|
## State persistence
|
|
35
47
|
|
|
36
48
|
Single-PR mode persists loop state to `$CLAUDE_JOB_DIR/pr-converge-state.json`.
|
|
@@ -84,6 +96,14 @@ post a fresh PR in a fresh branch based on origin main to the user.
|
|
|
84
96
|
`pwsh` calls run through the PowerShell tool, not Bash. Bash on
|
|
85
97
|
Windows is Git Bash which cannot execute PowerShell cmdlets. Route all
|
|
86
98
|
PowerShell work through the PowerShell tool or `pwsh -NoProfile -File`.
|
|
99
|
+
- **Cross-repo PR: route cwd into the PR worktree before `/code-review`** —
|
|
100
|
+
`/code-review --fix` (Step 5) audits the repo of the current working
|
|
101
|
+
directory. When the session is rooted in a different repo than the PR,
|
|
102
|
+
`EnterWorktree` cannot re-root (it is scoped to the session's repo);
|
|
103
|
+
resolve the PR worktree and `cd` into it per
|
|
104
|
+
[Step 1.5](reference/per-tick.md). Skipping this reviews and edits the
|
|
105
|
+
wrong repo. The route is routine and automatic — never a material fork
|
|
106
|
+
to pause on.
|
|
87
107
|
|
|
88
108
|
## Progress checklist
|
|
89
109
|
|
|
@@ -117,9 +137,14 @@ round as converged. This rule holds every tick, every loop, every PR.
|
|
|
117
137
|
- [ ] **Step 0: Grant project permissions**
|
|
118
138
|
`python "$HOME/.claude/skills/bugteam/scripts/grant_project_claude_permissions.py"`
|
|
119
139
|
|
|
120
|
-
- [ ] **Step 1: Resolve PR scope**
|
|
121
|
-
Capture owner, repo, number, head SHA, branch.
|
|
140
|
+
- [ ] **Step 1: Resolve PR scope + PR worktree**
|
|
141
|
+
Capture owner, repo, number, head SHA, branch. Resolve the **PR
|
|
142
|
+
worktree** — the local checkout every local step this tick targets:
|
|
143
|
+
the `EnterWorktree` checkout when the PR shares the session's repo,
|
|
144
|
+
else a checkout of the PR's repo that the working directory routes
|
|
145
|
+
into via `cd`. Cross-repo routing is automatic, not a fork.
|
|
122
146
|
See: [`reference/per-tick.md` § Step 1](reference/per-tick.md)
|
|
147
|
+
and [§ Step 1.5](reference/per-tick.md)
|
|
123
148
|
|
|
124
149
|
- [ ] **Step 2: Initialize loop state**
|
|
125
150
|
`phase = BUGBOT`; all counters at zero; `run_name` resolved.
|
|
@@ -168,6 +193,13 @@ round as converged. This rule holds every tick, every loop, every PR.
|
|
|
168
193
|
|
|
169
194
|
Pre-condition: `bugbot_clean_at == current_head` (or `bugbot_down == true`).
|
|
170
195
|
|
|
196
|
+
Pre-condition: the working directory is the Step 1.5 PR worktree on
|
|
197
|
+
`current_head` (`git rev-parse --show-toplevel` is that checkout).
|
|
198
|
+
When the session is rooted in a different repo than the PR, `cd`
|
|
199
|
+
into the PR worktree first — `/code-review` audits the repo of the
|
|
200
|
+
current working directory, so this routing targets the real PR
|
|
201
|
+
diff. This `cd` is routine and automatic.
|
|
202
|
+
|
|
171
203
|
Run Claude Code's built-in `/code-review --fix` on the full
|
|
172
204
|
`origin/main...HEAD` diff —
|
|
173
205
|
the [local diff review](https://code.claude.com/docs/en/code-review#review-a-diff-locally)
|
|
@@ -25,6 +25,15 @@ files during fix phase in multi-PR mode.
|
|
|
25
25
|
|
|
26
26
|
**Single-PR (no `state.json`) — same gates, main session executor:**
|
|
27
27
|
|
|
28
|
+
Run every command below in the PR worktree (the working directory routed in
|
|
29
|
+
[per-tick.md § Step 1.5](per-tick.md)). The `git add`, `git commit`, and
|
|
30
|
+
`git push` act on the repo of the current working directory, so a cross-repo
|
|
31
|
+
PR's fix lands in the PR's repo only when the cwd is its worktree. A spawned
|
|
32
|
+
`clean-coder` does not inherit the lead's working directory — name the PR
|
|
33
|
+
worktree path in its prompt and direct it to edit, stage, and commit there,
|
|
34
|
+
matching the worktree-path handoff bugteam embeds in its fix worker's spawn
|
|
35
|
+
prompt.
|
|
36
|
+
|
|
28
37
|
- Read each referenced file:line.
|
|
29
38
|
- Write failing test first when finding has behavior to test. Pure doc /
|
|
30
39
|
comment / naming nits with no behavior → straight to fix.
|
|
@@ -15,3 +15,10 @@
|
|
|
15
15
|
- **Adapt when reality contradicts on-disk state.** If `state.json`,
|
|
16
16
|
`git`, or `gh` disagree with live PR, escalate as hard blocker per
|
|
17
17
|
[stop-conditions.md](stop-conditions.md).
|
|
18
|
+
- **Cross-repo cwd routing is routine, not a fork.** When the PR under
|
|
19
|
+
convergence lives in a different repo than the session is rooted in, route
|
|
20
|
+
the working directory into a checkout of the PR's repo automatically —
|
|
21
|
+
`/code-review --fix`, `git`, and every `clean-coder` fix spawn act on the
|
|
22
|
+
repo of the current working directory. The resolution is fixed
|
|
23
|
+
([per-tick.md § Step 1.5](per-tick.md)): resolve the PR worktree, `cd` into
|
|
24
|
+
it, run local work there. Do not pause, ask, or raise it as a material fork.
|
|
@@ -45,6 +45,89 @@ If `current_head` changed since last tick, reset `bugbot_down` to `false`
|
|
|
45
45
|
|
|
46
46
|
Capture `number`, `head.sha` (= `current_head`), owner/repo, branch.
|
|
47
47
|
|
|
48
|
+
## Step 1.5: Resolve the PR worktree (cwd routing)
|
|
49
|
+
|
|
50
|
+
The **PR worktree** is the local working tree of the PR's repo on its head
|
|
51
|
+
branch. Every local operation this tick runs there: the CODE_REVIEW
|
|
52
|
+
`/code-review --fix`, every `clean-coder` fix spawn, and every commit and
|
|
53
|
+
push. `/code-review` and `git` both act on the repo of the current working
|
|
54
|
+
directory, so the working directory must be the PR worktree before any local
|
|
55
|
+
work begins. Re-resolve it every tick — a rebase or a fresh HEAD can move the
|
|
56
|
+
branch tip.
|
|
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`:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
git remote get-url origin
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
- **Parsed owner/repo matches the PR** (case-insensitive): the `EnterWorktree`
|
|
68
|
+
pre-flight checkout is the PR worktree, and the working directory already
|
|
69
|
+
points here, so no `cd` is needed. Bring the branch to the PR head with the
|
|
70
|
+
same deterministic `checkout -B` the cross-repo case uses, after confirming
|
|
71
|
+
the tree carries no uncommitted edits — a non-empty `git status --porcelain`
|
|
72
|
+
means a prior tick left a fix mid-flight, so escalate as a hard blocker:
|
|
73
|
+
```bash
|
|
74
|
+
git fetch origin
|
|
75
|
+
git checkout -B <branch> origin/<branch>
|
|
76
|
+
```
|
|
77
|
+
|
|
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
|
|
80
|
+
`claude-code-config`): route the working directory into a checkout of the
|
|
81
|
+
PR's repo. This is routine and automatic — never pause, and never raise it as
|
|
82
|
+
a fork (see [ground-rules.md](ground-rules.md)). `EnterWorktree` is scoped to
|
|
83
|
+
the session's own repo and cannot re-root into the PR's repo.
|
|
84
|
+
|
|
85
|
+
`<run_temp_dir>` is pr-converge's own `pr-converge-pr-<N>` directory under the
|
|
86
|
+
system temp directory — named apart from bugteam's `bugteam-pr-<N>` run dir so
|
|
87
|
+
the two never share a checkout when Step 6 runs bugteam on the same PR.
|
|
88
|
+
pr-converge fills this path by hand; it does not route through the shared
|
|
89
|
+
`_path_resolver` that bugteam uses. Reuse its `checkout` across ticks; create
|
|
90
|
+
it once when it is absent. A fresh clone honors the global `core.hooksPath`, so git-side
|
|
91
|
+
CODE_RULES enforcement covers the fix commit.
|
|
92
|
+
|
|
93
|
+
1. Clone the PR branch when the checkout is absent:
|
|
94
|
+
```bash
|
|
95
|
+
gh repo clone <owner>/<repo> "<run_temp_dir>/checkout" -- --branch <branch>
|
|
96
|
+
```
|
|
97
|
+
2. Bring it to the PR head. On a reused checkout, confirm it carries no
|
|
98
|
+
uncommitted edits first — a non-empty `git -C "<run_temp_dir>/checkout"
|
|
99
|
+
status --porcelain` means a prior tick left a fix mid-flight, so escalate
|
|
100
|
+
as a hard blocker rather than discard it. On a clean tree:
|
|
101
|
+
```bash
|
|
102
|
+
git -C "<run_temp_dir>/checkout" fetch origin
|
|
103
|
+
git -C "<run_temp_dir>/checkout" checkout -B <branch> origin/<branch>
|
|
104
|
+
```
|
|
105
|
+
3. Change into it in a standalone Bash call so the working directory persists
|
|
106
|
+
into the `/code-review` invocation that follows:
|
|
107
|
+
```bash
|
|
108
|
+
cd "<run_temp_dir>/checkout"
|
|
109
|
+
```
|
|
110
|
+
4. Confirm the route took before any local work:
|
|
111
|
+
```bash
|
|
112
|
+
git rev-parse --show-toplevel
|
|
113
|
+
git rev-parse HEAD
|
|
114
|
+
```
|
|
115
|
+
The top level reads `<run_temp_dir>/checkout` and HEAD equals
|
|
116
|
+
`current_head`.
|
|
117
|
+
|
|
118
|
+
Spawn every `clean-coder` fix worker with the PR worktree path in its prompt
|
|
119
|
+
so its edits land in the PR's repo — the same worktree-path handoff bugteam
|
|
120
|
+
gives its fix worker. The
|
|
121
|
+
GitHub API steps (BUGBOT fetch, convergence gates) and the bugteam Skill
|
|
122
|
+
invocation are URL-driven and need no local checkout.
|
|
123
|
+
|
|
124
|
+
Capture the session worktree path (the `EnterWorktree` checkout) before
|
|
125
|
+
routing away. Step 0 grant, Step 8 working-tree cleanup, and Step 10 revoke
|
|
126
|
+
read the current working directory and target the session repo, so `cd` back
|
|
127
|
+
to the session worktree before Step 8 and remove `<run_temp_dir>` there with
|
|
128
|
+
a Windows-safe recursive remove (per
|
|
129
|
+
`$HOME/.claude/rules/windows-filesystem-safe.md`).
|
|
130
|
+
|
|
48
131
|
## Step 2: Branch on `phase`
|
|
49
132
|
|
|
50
133
|
### `phase == BUGBOT`
|
|
@@ -126,12 +209,16 @@ a. Run Claude Code's built-in `/code-review --fix` on the FULL
|
|
|
126
209
|
[local diff review](https://code.claude.com/docs/en/code-review#review-a-diff-locally).
|
|
127
210
|
It reviews the diff and applies its findings to the working tree.
|
|
128
211
|
|
|
129
|
-
Before running, confirm the working
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
212
|
+
Before running, confirm the working directory is the PR worktree resolved
|
|
213
|
+
in [Step 1.5](#step-15-resolve-the-pr-worktree-cwd-routing) — `git rev-parse
|
|
214
|
+
--show-toplevel` is that checkout and `git rev-parse HEAD` equals
|
|
215
|
+
`current_head` — with no uncommitted edits. When the session is rooted in a
|
|
216
|
+
different repo than the PR, the `cd` from Step 1.5 supplies this; the
|
|
217
|
+
persisted working directory is what `/code-review` audits. Then invoke
|
|
218
|
+
`/code-review --fix` with no path arguments so it audits the whole branch
|
|
219
|
+
diff against `origin/main`. Do not delta-scope to commits added since the
|
|
220
|
+
prior clean SHA, do not scope to a single file, do not scope to bugbot's
|
|
221
|
+
flagged paths. A partial-scope round does not count and cannot set
|
|
135
222
|
`code_review_clean_at`. Pass no effort argument, so the review uses
|
|
136
223
|
the session's current effort.
|
|
137
224
|
|