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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.51.0",
3
+ "version": "1.52.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 tree sits on the PR's HEAD with no
130
- uncommitted edits, then invoke `/code-review --fix` with no path
131
- arguments so it audits the whole branch diff against `origin/main`. Do
132
- not delta-scope to commits added since the prior clean SHA, do not
133
- scope to a single file, do not scope to bugbot's flagged paths. A
134
- partial-scope round does not count and cannot set
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