claude-dev-env 2.2.0 → 2.3.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 (60) hide show
  1. package/CLAUDE.md +1 -1
  2. package/_shared/advisor/advisor-protocol.md +2 -2
  3. package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  4. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -284
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
  6. package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +3 -3
  7. package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -0
  8. package/_shared/pr-loop/scripts/pyproject.toml +22 -0
  9. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +5 -0
  10. package/_shared/pr-loop/scripts/stale_worktree_rule_sweep.py +107 -0
  11. package/_shared/pr-loop/scripts/tests/CLAUDE.md +2 -0
  12. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +55 -73
  13. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +9 -15
  14. package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +91 -1
  15. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +88 -1
  16. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -0
  17. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -0
  18. package/_shared/pr-loop/worker-spawn.md +1 -1
  19. package/agents/code-verifier.md +1 -1
  20. package/hooks/blocking/code_rules_shared.py +4 -2
  21. package/hooks/blocking/config/verified_commit_constants.py +11 -0
  22. package/hooks/blocking/pii_payload_scan.py +78 -20
  23. package/hooks/blocking/pii_prevention_blocker.py +3 -1
  24. package/hooks/blocking/test_code_verifier_tools_contract.py +28 -0
  25. package/hooks/blocking/test_pii_write_surface_ephemeral.py +221 -0
  26. package/hooks/blocking/test_verification_verdict_store.py +93 -0
  27. package/hooks/blocking/verification_verdict_store.py +91 -0
  28. package/hooks/validators/ruff_integration.py +2 -1
  29. package/hooks/validators/run_all_validators.py +460 -24
  30. package/hooks/validators/test_ruff_integration.py +21 -0
  31. package/hooks/validators/test_run_all_validators_pretooluse.py +223 -3
  32. package/package.json +1 -1
  33. package/rules/CLAUDE.md +1 -0
  34. package/rules/cleanup-command-forms.md +23 -0
  35. package/scripts/CLAUDE.md +2 -1
  36. package/scripts/check.ps1 +32 -6
  37. package/scripts/claude-chain.example.json +15 -3
  38. package/scripts/claude_chain_runner.py +130 -27
  39. package/scripts/claude_chain_usage.py +346 -0
  40. package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -3
  41. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +13 -1
  42. package/scripts/dev_env_scripts_constants/claude_chain_usage_constants.py +58 -0
  43. package/scripts/dev_env_scripts_constants/code_review_constants.py +1 -1
  44. package/scripts/dev_env_scripts_constants/timing.py +1 -1
  45. package/scripts/test_claude_chain_runner.py +412 -6
  46. package/scripts/test_claude_chain_usage.py +534 -0
  47. package/skills/orchestrator/SKILL.md +1 -20
  48. package/skills/orchestrator-refresh/SKILL.md +1 -1
  49. package/skills/pr-converge/SKILL.md +3 -3
  50. package/skills/pr-converge/reference/examples.md +3 -3
  51. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  52. package/skills/pr-converge/reference/ground-rules.md +3 -3
  53. package/skills/pr-converge/reference/per-tick.md +5 -5
  54. package/skills/pr-converge/reference/progress-checklist.md +1 -1
  55. package/skills/pr-converge/test_step5_host_branch.py +1 -1
  56. package/skills/team-advisor/SKILL.md +3 -2
  57. package/skills/usage-pause/SKILL.md +5 -4
  58. package/skills/usage-pause/scripts/resolve_usage_window.py +68 -13
  59. package/skills/usage-pause/scripts/test_resolve_usage_window.py +121 -9
  60. package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +7 -3
@@ -31,7 +31,7 @@ def _load_revoke_module() -> ModuleType:
31
31
 
32
32
  def test_module_imports_constants_from_config_modules() -> None:
33
33
  revoke_module = _load_revoke_module()
34
- assert revoke_module.ALL_PERMISSION_ALLOW_TOOLS == ("Edit", "Write", "Read")
34
+ assert revoke_module.ALL_PERMISSION_ALLOW_TOOLS == ("Edit", "Read")
35
35
  assert revoke_module.AUTO_MODE_ENVIRONMENT_ENTRY_PREFIX == (
36
36
  "Trusted local workspace:"
37
37
  )
@@ -49,3 +49,90 @@ def test_revoke_module_guards_sys_path_insert_against_duplicates() -> None:
49
49
  "revoke_project_claude_permissions.py must guard sys.path.insert against "
50
50
  "duplicate entries on reload (consistent with sibling modules)"
51
51
  )
52
+
53
+
54
+ def _section_of(settings: dict[str, object], section_key: str) -> dict[str, object]:
55
+ section = settings[section_key]
56
+ assert isinstance(section, dict)
57
+ return section
58
+
59
+
60
+ def test_remove_values_from_list_removes_matching_strings() -> None:
61
+ revoke_module = _load_revoke_module()
62
+ target_list: list[object] = ["keep", "drop", 7, "drop"]
63
+ removed_count = revoke_module.remove_values_from_list(target_list, {"drop"})
64
+ assert removed_count == 2
65
+ assert target_list == ["keep", 7]
66
+
67
+
68
+ def test_remove_rules_from_allow_list_removes_named_rules() -> None:
69
+ revoke_module = _load_revoke_module()
70
+ settings: dict[str, object] = {
71
+ "permissions": {
72
+ "allow": ["Edit(/proj/.claude/**)", "Read(/proj/.claude/**)"],
73
+ },
74
+ }
75
+ removed_count = revoke_module.remove_rules_from_allow_list(
76
+ settings, ["Edit(/proj/.claude/**)"]
77
+ )
78
+ assert removed_count == 1
79
+ assert _section_of(settings, "permissions")["allow"] == ["Read(/proj/.claude/**)"]
80
+
81
+
82
+ def test_remove_rules_from_deny_list_removes_named_rules() -> None:
83
+ revoke_module = _load_revoke_module()
84
+ settings: dict[str, object] = {
85
+ "permissions": {
86
+ "deny": ["Edit(/proj/.claude/hooks/**)", "Read(/proj/.claude/hooks/**)"],
87
+ },
88
+ }
89
+ removed_count = revoke_module.remove_rules_from_deny_list(
90
+ settings, ["Edit(/proj/.claude/hooks/**)"]
91
+ )
92
+ assert removed_count == 1
93
+ assert _section_of(settings, "permissions")["deny"] == [
94
+ "Read(/proj/.claude/hooks/**)"
95
+ ]
96
+
97
+
98
+ def test_remove_directory_from_additional_directories_removes_entry() -> None:
99
+ revoke_module = _load_revoke_module()
100
+ settings: dict[str, object] = {
101
+ "permissions": {
102
+ "additionalDirectories": ["/proj", "/other"],
103
+ },
104
+ }
105
+ removed_count = revoke_module.remove_directory_from_additional_directories(
106
+ settings, "/proj"
107
+ )
108
+ assert removed_count == 1
109
+ assert _section_of(settings, "permissions")["additionalDirectories"] == ["/other"]
110
+
111
+
112
+ def test_remove_trust_entries_for_project_removes_every_project_entry() -> None:
113
+ revoke_module = _load_revoke_module()
114
+ trust_prefix = revoke_module.AUTO_MODE_ENVIRONMENT_ENTRY_PREFIX
115
+ project_entry_a = "Trusted local workspace: /proj/.claude/** wording A"
116
+ project_entry_b = "Trusted local workspace: /proj/.claude/** wording B"
117
+ other_project_entry = "Trusted local workspace: /other/.claude/** unrelated"
118
+ settings: dict[str, object] = {
119
+ "autoMode": {
120
+ "environment": [project_entry_a, project_entry_b, other_project_entry],
121
+ },
122
+ }
123
+ removed_count = revoke_module.remove_trust_entries_for_project(
124
+ settings, "/proj", trust_prefix
125
+ )
126
+ assert removed_count == 2
127
+ assert _section_of(settings, "autoMode")["environment"] == [other_project_entry]
128
+
129
+
130
+ def test_prune_settings_after_revoke_drops_empty_sections() -> None:
131
+ revoke_module = _load_revoke_module()
132
+ settings: dict[str, object] = {
133
+ "permissions": {"allow": [], "deny": []},
134
+ "autoMode": {"environment": []},
135
+ }
136
+ revoke_module.prune_settings_after_revoke(settings)
137
+ assert "permissions" not in settings
138
+ assert "autoMode" not in settings
@@ -0,0 +1,301 @@
1
+ """Behavioral tests for stale_worktree_rule_sweep.
2
+
3
+ Drives the real sweep functions against real temp directories and a real
4
+ temp settings file, and drives the real grant and revoke flows end to end.
5
+ A live worktree directory exists on disk; a dead worktree path does not. The
6
+ sweep keeps every rule whose worktree directory still exists and drops every
7
+ rule whose worktree directory is gone, then removes exact duplicates from
8
+ each rule array. The grant and revoke runs perform that sweep before they
9
+ save, so a run whose only effect is the sweep still persists the cleanup.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import importlib.util
15
+ import sys
16
+ from pathlib import Path
17
+ from types import ModuleType
18
+
19
+ import pytest
20
+
21
+
22
+ def _load_scripts_module(module_name: str) -> ModuleType:
23
+ scripts_directory = Path(__file__).parent.parent
24
+ parent_directory = str(scripts_directory.resolve())
25
+ if parent_directory not in sys.path:
26
+ sys.path.insert(0, parent_directory)
27
+ module_path = scripts_directory / f"{module_name}.py"
28
+ specification = importlib.util.spec_from_file_location(module_name, module_path)
29
+ assert specification is not None
30
+ assert specification.loader is not None
31
+ module = importlib.util.module_from_spec(specification)
32
+ specification.loader.exec_module(module)
33
+ return module
34
+
35
+
36
+ def _load_sweep_module() -> ModuleType:
37
+ return _load_scripts_module("stale_worktree_rule_sweep")
38
+
39
+
40
+ permissions_common = _load_scripts_module("_claude_permissions_common")
41
+
42
+
43
+ def _make_live_worktree_directory(worktrees_root: Path) -> Path:
44
+ live_worktree_directory = worktrees_root / "repo" / "live-worktree"
45
+ live_worktree_directory.mkdir(parents=True)
46
+ return live_worktree_directory
47
+
48
+
49
+ def _allow_rule_for(worktree_directory: Path) -> str:
50
+ return f"Edit({worktree_directory}/.claude/**)"
51
+
52
+
53
+ def _deny_rule_for(worktree_directory: Path) -> str:
54
+ return f"Read({worktree_directory}/.claude/settings*.json)"
55
+
56
+
57
+ def _permission_list(all_settings: dict[str, object], list_key: str) -> list[object]:
58
+ permissions_section = all_settings["permissions"]
59
+ assert isinstance(permissions_section, dict)
60
+ rule_list = permissions_section[list_key]
61
+ assert isinstance(rule_list, list)
62
+ return rule_list
63
+
64
+
65
+ def test_sweep_keeps_live_worktree_rules_and_drops_dead_ones(tmp_path: Path) -> None:
66
+ sweep_module = _load_sweep_module()
67
+ live_worktree_directory = _make_live_worktree_directory(tmp_path)
68
+ dead_worktree_directory = tmp_path / "repo" / "dead-worktree"
69
+ live_allow_rule = _allow_rule_for(live_worktree_directory)
70
+ dead_allow_rule = _allow_rule_for(dead_worktree_directory)
71
+ live_deny_rule = _deny_rule_for(live_worktree_directory)
72
+ dead_deny_rule = _deny_rule_for(dead_worktree_directory)
73
+ all_settings: dict[str, object] = {
74
+ "permissions": {
75
+ "allow": [live_allow_rule, dead_allow_rule],
76
+ "deny": [live_deny_rule, dead_deny_rule],
77
+ },
78
+ }
79
+ removed_count = sweep_module.sweep_and_deduplicate_permission_lists(
80
+ all_settings, tmp_path
81
+ )
82
+ assert removed_count == 2
83
+ assert _permission_list(all_settings, "allow") == [live_allow_rule]
84
+ assert _permission_list(all_settings, "deny") == [live_deny_rule]
85
+
86
+
87
+ def test_sweep_keeps_live_flat_worktree_rules_and_drops_dead_ones(tmp_path: Path) -> None:
88
+ sweep_module = _load_sweep_module()
89
+ live_worktree_directory = tmp_path / "flat-live"
90
+ live_worktree_directory.mkdir(parents=True)
91
+ dead_worktree_directory = tmp_path / "flat-dead"
92
+ live_allow_rule = _allow_rule_for(live_worktree_directory)
93
+ dead_allow_rule = _allow_rule_for(dead_worktree_directory)
94
+ all_settings: dict[str, object] = {
95
+ "permissions": {
96
+ "allow": [live_allow_rule, dead_allow_rule],
97
+ "deny": [],
98
+ },
99
+ }
100
+ removed_count = sweep_module.sweep_and_deduplicate_permission_lists(
101
+ all_settings, tmp_path
102
+ )
103
+ assert removed_count == 1
104
+ assert _permission_list(all_settings, "allow") == [live_allow_rule]
105
+
106
+
107
+ def test_sweep_leaves_rules_for_projects_outside_the_worktrees_root(
108
+ tmp_path: Path,
109
+ ) -> None:
110
+ sweep_module = _load_sweep_module()
111
+ unrelated_project_rule = "Edit(/home/developer/project/.claude/**)"
112
+ all_settings: dict[str, object] = {
113
+ "permissions": {"allow": [unrelated_project_rule]},
114
+ }
115
+ removed_count = sweep_module.sweep_and_deduplicate_permission_lists(
116
+ all_settings, tmp_path
117
+ )
118
+ assert removed_count == 0
119
+ assert _permission_list(all_settings, "allow") == [unrelated_project_rule]
120
+
121
+
122
+ def test_sweep_removes_exact_duplicate_rules(tmp_path: Path) -> None:
123
+ sweep_module = _load_sweep_module()
124
+ live_worktree_directory = _make_live_worktree_directory(tmp_path)
125
+ live_allow_rule = _allow_rule_for(live_worktree_directory)
126
+ all_settings: dict[str, object] = {
127
+ "permissions": {"allow": [live_allow_rule, live_allow_rule]},
128
+ }
129
+ removed_count = sweep_module.sweep_and_deduplicate_permission_lists(
130
+ all_settings, tmp_path
131
+ )
132
+ assert removed_count == 1
133
+ assert _permission_list(all_settings, "allow") == [live_allow_rule]
134
+
135
+
136
+ def test_sweep_ignores_malformed_and_non_string_entries(tmp_path: Path) -> None:
137
+ sweep_module = _load_sweep_module()
138
+ malformed_entry = "not a rule at all"
139
+ all_settings: dict[str, object] = {
140
+ "permissions": {"allow": [malformed_entry, 7, malformed_entry]},
141
+ }
142
+ removed_count = sweep_module.sweep_and_deduplicate_permission_lists(
143
+ all_settings, tmp_path
144
+ )
145
+ assert removed_count == 1
146
+ assert _permission_list(all_settings, "allow") == [malformed_entry, 7]
147
+
148
+
149
+ def test_sweep_tolerates_missing_permission_sections(tmp_path: Path) -> None:
150
+ sweep_module = _load_sweep_module()
151
+ all_settings: dict[str, object] = {}
152
+ removed_count = sweep_module.sweep_and_deduplicate_permission_lists(
153
+ all_settings, tmp_path
154
+ )
155
+ assert removed_count == 0
156
+ assert all_settings == {}
157
+
158
+
159
+ def test_sweep_round_trips_through_a_real_temp_settings_file(tmp_path: Path) -> None:
160
+ sweep_module = _load_sweep_module()
161
+ worktrees_root = tmp_path / "worktrees"
162
+ live_worktree_directory = _make_live_worktree_directory(worktrees_root)
163
+ dead_worktree_directory = worktrees_root / "repo" / "dead-worktree"
164
+ live_allow_rule = _allow_rule_for(live_worktree_directory)
165
+ dead_allow_rule = _allow_rule_for(dead_worktree_directory)
166
+ settings_path = tmp_path / "settings.json"
167
+ permissions_common.save_settings(
168
+ settings_path,
169
+ {"permissions": {"allow": [live_allow_rule, dead_allow_rule]}},
170
+ )
171
+ loaded_settings = permissions_common.load_settings(settings_path)
172
+ sweep_module.sweep_and_deduplicate_permission_lists(loaded_settings, worktrees_root)
173
+ permissions_common.save_settings(settings_path, loaded_settings)
174
+ reloaded_settings = permissions_common.load_settings(settings_path)
175
+ assert _permission_list(reloaded_settings, "allow") == [live_allow_rule]
176
+
177
+
178
+ def test_wrapper_resolves_worktrees_root_under_the_user_claude_home(
179
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
180
+ ) -> None:
181
+ sweep_module = _load_sweep_module()
182
+ monkeypatch.setenv("HOME", str(tmp_path))
183
+ monkeypatch.setenv("USERPROFILE", str(tmp_path))
184
+ worktrees_root = tmp_path / ".claude" / "worktrees"
185
+ live_worktree_directory = _make_live_worktree_directory(worktrees_root)
186
+ dead_worktree_directory = worktrees_root / "repo" / "dead-worktree"
187
+ live_allow_rule = _allow_rule_for(live_worktree_directory)
188
+ dead_allow_rule = _allow_rule_for(dead_worktree_directory)
189
+ all_settings: dict[str, object] = {
190
+ "permissions": {"allow": [live_allow_rule, dead_allow_rule]},
191
+ }
192
+ removed_count = sweep_module.sweep_stale_worktree_rules_from_settings(all_settings)
193
+ assert removed_count == 1
194
+ assert _permission_list(all_settings, "allow") == [live_allow_rule]
195
+
196
+
197
+ def _prepare_home_with_worktrees(
198
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
199
+ ) -> tuple[Path, Path, Path]:
200
+ monkeypatch.setenv("HOME", str(tmp_path))
201
+ monkeypatch.setenv("USERPROFILE", str(tmp_path))
202
+ worktrees_root = tmp_path / ".claude" / "worktrees"
203
+ live_worktree_directory = _make_live_worktree_directory(worktrees_root)
204
+ dead_worktree_directory = worktrees_root / "repo" / "dead-worktree"
205
+ settings_path = tmp_path / ".claude" / "settings.json"
206
+ return settings_path, live_worktree_directory, dead_worktree_directory
207
+
208
+
209
+ def _make_fake_project(tmp_path: Path) -> Path:
210
+ fake_project_root = tmp_path / "fake_project"
211
+ (fake_project_root / ".claude").mkdir(parents=True)
212
+ return fake_project_root
213
+
214
+
215
+ def _seed_allow_rules(settings_path: Path, all_rules: list[object]) -> None:
216
+ permissions_common.save_settings(
217
+ settings_path, {"permissions": {"allow": all_rules}}
218
+ )
219
+
220
+
221
+ def _saved_allow_list(settings_path: Path) -> list[object]:
222
+ saved_settings = permissions_common.load_settings(settings_path)
223
+ permissions_section = saved_settings.get("permissions")
224
+ if not isinstance(permissions_section, dict):
225
+ return []
226
+ allow_list = permissions_section.get("allow")
227
+ return allow_list if isinstance(allow_list, list) else []
228
+
229
+
230
+ def test_real_grant_run_sweeps_a_dead_worktree_rule_from_saved_settings(
231
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
232
+ ) -> None:
233
+ settings_path, live_directory, dead_directory = _prepare_home_with_worktrees(
234
+ tmp_path, monkeypatch
235
+ )
236
+ live_rule = _allow_rule_for(live_directory)
237
+ dead_rule = _allow_rule_for(dead_directory)
238
+ unrelated_rule = "Edit(/home/developer/project/.claude/**)"
239
+ _seed_allow_rules(settings_path, [live_rule, dead_rule, unrelated_rule])
240
+ monkeypatch.chdir(_make_fake_project(tmp_path))
241
+ grant_module = _load_scripts_module("grant_project_claude_permissions")
242
+ grant_module.grant_permissions_for_current_directory()
243
+ saved_allow = _saved_allow_list(settings_path)
244
+ assert dead_rule not in saved_allow
245
+ assert live_rule in saved_allow
246
+ assert unrelated_rule in saved_allow
247
+
248
+
249
+ def test_real_grant_run_with_only_a_sweep_change_still_saves(
250
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
251
+ ) -> None:
252
+ settings_path, _live_directory, dead_directory = _prepare_home_with_worktrees(
253
+ tmp_path, monkeypatch
254
+ )
255
+ monkeypatch.chdir(_make_fake_project(tmp_path))
256
+ grant_module = _load_scripts_module("grant_project_claude_permissions")
257
+ grant_module.grant_permissions_for_current_directory()
258
+ dead_rule = _allow_rule_for(dead_directory)
259
+ granted_settings = permissions_common.load_settings(settings_path)
260
+ _permission_list(granted_settings, "allow").append(dead_rule)
261
+ permissions_common.save_settings(settings_path, granted_settings)
262
+ grant_module.grant_permissions_for_current_directory()
263
+ assert dead_rule not in _saved_allow_list(settings_path)
264
+
265
+
266
+ def test_real_revoke_run_sweeps_a_dead_worktree_rule_from_saved_settings(
267
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
268
+ ) -> None:
269
+ settings_path, live_directory, dead_directory = _prepare_home_with_worktrees(
270
+ tmp_path, monkeypatch
271
+ )
272
+ fake_project_root = _make_fake_project(tmp_path)
273
+ monkeypatch.chdir(fake_project_root)
274
+ revoke_module = _load_scripts_module("revoke_project_claude_permissions")
275
+ project_path = str(fake_project_root).replace("\\", "/")
276
+ project_allow_rules = revoke_module.build_permission_rules(
277
+ project_path, revoke_module.ALL_PERMISSION_ALLOW_TOOLS
278
+ )
279
+ live_rule = _allow_rule_for(live_directory)
280
+ dead_rule = _allow_rule_for(dead_directory)
281
+ _seed_allow_rules(settings_path, [*project_allow_rules, live_rule, dead_rule])
282
+ revoke_module.revoke_permissions_for_current_directory()
283
+ saved_allow = _saved_allow_list(settings_path)
284
+ assert dead_rule not in saved_allow
285
+ assert live_rule in saved_allow
286
+ for each_project_rule in project_allow_rules:
287
+ assert each_project_rule not in saved_allow
288
+
289
+
290
+ def test_real_revoke_run_with_only_a_sweep_change_still_saves(
291
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
292
+ ) -> None:
293
+ settings_path, _live_directory, dead_directory = _prepare_home_with_worktrees(
294
+ tmp_path, monkeypatch
295
+ )
296
+ dead_rule = _allow_rule_for(dead_directory)
297
+ _seed_allow_rules(settings_path, [dead_rule])
298
+ monkeypatch.chdir(_make_fake_project(tmp_path))
299
+ revoke_module = _load_scripts_module("revoke_project_claude_permissions")
300
+ revoke_module.revoke_permissions_for_current_directory()
301
+ assert dead_rule not in _saved_allow_list(settings_path)
@@ -0,0 +1,85 @@
1
+ """Behavioral tests for stale_worktree_rule_sweep_constants.
2
+
3
+ Confirms get_claude_worktrees_root resolves the worktrees directory under
4
+ the user's ~/.claude home, and that the rule-parsing constants carry the
5
+ literal shapes the sweep relies on.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib.util
11
+ import sys
12
+ from pathlib import Path
13
+ from types import ModuleType
14
+
15
+ import pytest
16
+
17
+
18
+ def _load_constants_module() -> ModuleType:
19
+ scripts_directory = Path(__file__).parent.parent
20
+ parent_directory = str(scripts_directory.resolve())
21
+ if parent_directory not in sys.path:
22
+ sys.path.insert(0, parent_directory)
23
+ module_path = (
24
+ scripts_directory
25
+ / "pr_loop_shared_constants"
26
+ / "stale_worktree_rule_sweep_constants.py"
27
+ )
28
+ specification = importlib.util.spec_from_file_location(
29
+ "pr_loop_shared_constants.stale_worktree_rule_sweep_constants", module_path
30
+ )
31
+ assert specification is not None
32
+ assert specification.loader is not None
33
+ module = importlib.util.module_from_spec(specification)
34
+ specification.loader.exec_module(module)
35
+ return module
36
+
37
+
38
+ def test_worktrees_root_resolves_under_the_user_claude_home(
39
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
40
+ ) -> None:
41
+ constants_module = _load_constants_module()
42
+ monkeypatch.setenv("HOME", str(tmp_path))
43
+ monkeypatch.setenv("USERPROFILE", str(tmp_path))
44
+ resolved_worktrees_root = constants_module.get_claude_worktrees_root()
45
+ assert resolved_worktrees_root == tmp_path / ".claude" / "worktrees"
46
+
47
+
48
+ def test_extract_rule_target_path_reads_the_delimited_path() -> None:
49
+ constants_module = _load_constants_module()
50
+ extracted_path = constants_module.extract_rule_target_path(
51
+ "Edit(/repo/wt/.claude/**)"
52
+ )
53
+ assert extracted_path == "/repo/wt/.claude/**"
54
+
55
+
56
+ def test_extract_rule_target_path_returns_none_for_a_non_rule() -> None:
57
+ constants_module = _load_constants_module()
58
+ assert constants_module.extract_rule_target_path("not a rule") is None
59
+
60
+
61
+ def test_worktree_directory_for_rule_keeps_nested_segments_below_the_root() -> None:
62
+ constants_module = _load_constants_module()
63
+ worktrees_root = Path("/home/dev/.claude/worktrees")
64
+ resolved_directory = constants_module.worktree_directory_for_rule(
65
+ "Edit(/home/dev/.claude/worktrees/repo/feature/.claude/**)", worktrees_root
66
+ )
67
+ assert resolved_directory == worktrees_root / "repo" / "feature"
68
+
69
+
70
+ def test_worktree_directory_for_rule_keeps_one_segment_flat_layout() -> None:
71
+ constants_module = _load_constants_module()
72
+ worktrees_root = Path("/home/dev/.claude/worktrees")
73
+ resolved_directory = constants_module.worktree_directory_for_rule(
74
+ "Edit(/home/dev/.claude/worktrees/flat-worktree/.claude/**)", worktrees_root
75
+ )
76
+ assert resolved_directory == worktrees_root / "flat-worktree"
77
+
78
+
79
+ def test_worktree_directory_for_rule_returns_none_outside_the_root() -> None:
80
+ constants_module = _load_constants_module()
81
+ worktrees_root = Path("/home/dev/.claude/worktrees")
82
+ resolved_directory = constants_module.worktree_directory_for_rule(
83
+ "Edit(/home/dev/project/.claude/**)", worktrees_root
84
+ )
85
+ assert resolved_directory is None
@@ -146,7 +146,7 @@ Mode decision:
146
146
  | `ThirdParty` | any | `chain` | Headless spawn through `claude_chain_runner` |
147
147
 
148
148
  The review always runs at effort **high** on model **opus**. Chain mode builds
149
- a single-turn prompt `/code-review high --fix`, pins `--model opus`, sets
149
+ a single-turn prompt `/code-review xhigh --fix`, pins `--model opus`, sets
150
150
  subprocess `cwd` to the PR working tree, and reads stdin from the empty stream
151
151
  so the spawn does not wait for input.
152
152
 
@@ -33,7 +33,7 @@ On Windows the same file sits at %USERPROFILE%\.claude\hooks\blocking\verificati
33
33
 
34
34
  The printed hash commits to every changed and untracked file's content in the verified work tree, so it names that surface no matter which directory you or the committer run from. If the CLI prints an empty-surface or wrong-work-tree error and no hash, you are pointed at a work tree with no changes versus origin/main — re-run with the branch mode to locate the correct work tree.
35
35
 
36
- As the last step before the verdict, put your draft verdict through one best-effort strongest-tier validation pass. Spawn a single validation subagent through the Task tool as the `Explore` agent type at the strongest reachable tier: set the Task `subagent_type` to `Explore`, detect the host profile first per `~/.claude/_shared/advisor/advisor-protocol.md` — the source of truth for host detection, the ladder, and its aliases — then on a Claude host pick the strongest reachable tier on the Fable → Opus → Sonnet → Haiku ladder and on a third-party host use the single third-party tier, and set the Task `model:` field to that tier's alias. The `Explore` type carries no Edit or Write tools and cannot spawn further agents, so the harness itself holds the validator to the no-edit, no-spawn contract the next paragraph names. Hand it the draft verdict together with your evidence — every command you ran with its output, and your two-way diff-to-task mapping — and state that its task is adversarial verification of that supplied draft verdict: refute it against the supplied evidence rather than discover code, naming any gate you misread, any task item you mapped wrong, or any finding that does not hold. When the spawn is unavailable — a Task tool error, an unreachable tier at every rung, or this subagent being barred from spawning further agents — skip the validation pass and emit the draft verdict as it stands, noting the skip in your final message; a spawn failure never blocks the verdict fence from being emitted.
36
+ As the last step before the verdict, put your draft verdict through one best-effort strongest-tier validation pass. Spawn a single validation subagent through the Task tool as the `Explore` agent type at the strongest reachable tier: set the Task `subagent_type` to `Explore`, detect the host profile first per `~/.claude/_shared/advisor/advisor-protocol.md` — the source of truth for host detection, the ladder, and its aliases — then on a Claude host pick the strongest reachable tier on the Fable → Opus → Sonnet → Haiku ladder and on a third-party host use the single third-party tier, and set the Task `model:` field to that tier's alias. The `Explore` type carries no Edit or Write tools and cannot spawn further agents, so the harness itself holds the validator to the no-edit, no-spawn contract the next paragraph names. Hand it the draft verdict together with your evidence — every command you ran with its output, and your two-way diff-to-task mapping — and state that its task is adversarial verification of that supplied draft verdict: refute it against the supplied evidence rather than discover code, naming any gate you misread, any task item you mapped wrong, or any finding that does not hold. This pass is always a cold `Explore` spawn, not a message to the session's warm advisor: the refutation needs a grader with no accumulated session context or prior positions, and the verifier runs in sessions that have no advisor bound. When the spawn is unavailable — a Task tool error, an unreachable tier at every rung, or this subagent being barred from spawning further agents — skip the validation pass and emit the draft verdict as it stands, noting the skip in your final message; a spawn failure never blocks the verdict fence from being emitted.
37
37
 
38
38
  This validation pass is terminal: the `Explore` type gives the validation subagent no way to spawn a further agent or edit a file, so it answers with prose only. When it refutes any part, re-check that part yourself against the commands and the diff, and correct the verdict before you emit it. When it refutes nothing, the draft verdict stands. Then emit the fenced verdict block below — it stays the last thing in your message so the verifier_verdict_minter hook reads it.
39
39
 
@@ -412,8 +412,10 @@ def is_ephemeral_path(file_path: str, hook_payload: dict | None = None) -> bool:
412
412
  harness session scratchpad. The session scratchpad match reads the session id
413
413
  from the payload when one is supplied, and from the harness environment
414
414
  variable otherwise, so a caller that holds no payload still gets the match.
415
- One call answers path exemption for both families, so a gate that skips
416
- throwaway paths need not repeat the two checks itself.
415
+ The run_all_validators PreToolUse gate calls this predicate to skip a scratch
416
+ target before it validates. The code-rules and TDD gates call the two path
417
+ predicates ``is_ephemeral_script_path`` and ``is_under_session_scratchpad``
418
+ directly.
417
419
 
418
420
  Args:
419
421
  file_path: The candidate path to classify.
@@ -48,6 +48,17 @@ WRITE_CALL_REGION_PATTERN = (
48
48
  VERDICT_KEY_ALL_PASS = "all_pass"
49
49
  VERDICT_KEY_MANIFEST_SHA256 = "manifest_sha256"
50
50
  VERDICT_KEY_FINDINGS = "findings"
51
+ VERDICT_FINDING_CHECK_KEY = "check"
52
+ LOGS_DIRECTORY_NAME = "logs"
53
+ VERIFIER_VERDICTS_JSONL_FILENAME = "verifier-verdicts.jsonl"
54
+ LEDGER_KEY_TIMESTAMP = "timestamp"
55
+ LEDGER_KEY_REPO_ROOT = "repo_root"
56
+ LEDGER_KEY_BRANCH = "branch"
57
+ LEDGER_KEY_MANIFEST_HASH = "manifest_hash"
58
+ LEDGER_KEY_ALL_PASS = "all_pass"
59
+ LEDGER_KEY_FINDING_COUNT = "finding_count"
60
+ LEDGER_KEY_FINDING_CHECK_NAMES = "finding_check_names"
61
+ LEDGER_KEY_MINTED_FROM_AGENT_ID = "minted_from_agent_id"
51
62
  VERDICT_FILE_GLOB = "*.json"
52
63
  SUBAGENTS_DIRECTORY_NAME = "subagents"
53
64
  AGENT_TRANSCRIPT_GLOB = "agent-*.jsonl"
@@ -18,6 +18,7 @@ try:
18
18
  for each_bootstrap_directory in (_blocking_directory, _hooks_directory):
19
19
  if each_bootstrap_directory not in sys.path:
20
20
  sys.path.insert(0, each_bootstrap_directory)
21
+ from code_rules_shared import is_ephemeral_path
21
22
  from pii_prevention_blocker_parts.repository_exemption import (
22
23
  repository_allowlisted_values,
23
24
  )
@@ -47,6 +48,41 @@ except ImportError as import_error:
47
48
  ) from import_error
48
49
 
49
50
 
51
+ def _target_is_ephemeral_outside_repository(
52
+ file_path: str, hook_payload: dict | None
53
+ ) -> bool:
54
+ """Report whether a write target is a throwaway draft outside every repo.
55
+
56
+ ::
57
+
58
+ ephemeral path, no repo tree -> True (write scan skipped)
59
+ ephemeral path inside a repo -> False (write scan kept)
60
+ ordinary path -> False (write scan kept)
61
+
62
+ A draft under the harness scratchpad or an ephemeral scratch root turns
63
+ durable only through a surface that keeps its own scan, so scanning it at
64
+ write time is a false positive. A draft that resolves inside a git tree
65
+ still carries that repository's own allowlist, so the scan stays.
66
+
67
+ Args:
68
+ file_path: The write target path to classify.
69
+ hook_payload: The PreToolUse payload carrying the session id, or None to
70
+ read the session id from the environment alone.
71
+
72
+ Returns:
73
+ True only when the path is ephemeral and no enclosing git repository
74
+ resolves.
75
+ """
76
+ if not file_path:
77
+ return False
78
+ if not is_ephemeral_path(file_path, hook_payload):
79
+ return False
80
+ resolution_directory = _existing_ancestor_directory(file_path)
81
+ if resolution_directory is None:
82
+ return True
83
+ return resolve_repository_root(resolution_directory) is None
84
+
85
+
50
86
  def build_deny_reason(all_findings: list[PiiFinding], gate_surface: str) -> str:
51
87
  """Return the deny message listing each finding for *gate_surface*.
52
88
 
@@ -165,29 +201,16 @@ def _write_path_allowlisted_values(file_path: str) -> frozenset[str]:
165
201
  return repository_allowlisted_values(repository_root)
166
202
 
167
203
 
168
- def evaluate_write_edit_payload(
169
- tool_name: str,
170
- all_tool_input: dict[str, object],
171
- all_allowlisted_values: frozenset[str] = frozenset(),
204
+ def _write_deny_reason_for_texts(
205
+ all_texts: list[str],
206
+ file_path: str,
207
+ all_allowlisted_values: frozenset[str],
172
208
  ) -> str | None:
173
- """Return a deny reason when Write/Edit/MultiEdit content carries PII.
174
-
175
- A value in the target repository's PII allowlist is dropped from the
176
- findings, so a write under that repository's tree may carry it. Repository
177
- resolution for that allowlist runs only after a raw PII hit.
178
-
179
- Args:
180
- tool_name: The intercepted tool name.
181
- all_tool_input: The tool input mapping.
182
- all_allowlisted_values: Extra exact values allowed past the scan,
183
- unioned with the target repository's own allowlist.
209
+ """Return the deny reason for the first text carrying non-allowlisted PII.
184
210
 
185
- Returns:
186
- Deny reason text, or None when the write is clean or out of scope.
211
+ Repository allowlist resolution runs once, only after a raw PII hit, so a
212
+ clean payload triggers no git call.
187
213
  """
188
- if tool_name not in ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES:
189
- return None
190
- file_path, all_texts = _collect_write_edit_texts(tool_name, all_tool_input)
191
214
  resolved_allowlisted_values: frozenset[str] | None = None
192
215
  for each_text in all_texts:
193
216
  all_raw_findings = scan_text_for_pii(each_text)
@@ -208,6 +231,41 @@ def evaluate_write_edit_payload(
208
231
  return None
209
232
 
210
233
 
234
+ def evaluate_write_edit_payload(
235
+ tool_name: str,
236
+ all_tool_input: dict[str, object],
237
+ all_allowlisted_values: frozenset[str] = frozenset(),
238
+ hook_payload: dict | None = None,
239
+ ) -> str | None:
240
+ """Return a deny reason when Write/Edit/MultiEdit content carries PII.
241
+
242
+ A value in the target repository's PII allowlist is dropped from the
243
+ findings, so a write under that repository's tree may carry it. A draft
244
+ under an ephemeral scratch root outside every repository is skipped, since
245
+ it turns durable only through surfaces that keep their own scans.
246
+
247
+ Args:
248
+ tool_name: The intercepted tool name.
249
+ all_tool_input: The tool input mapping.
250
+ all_allowlisted_values: Extra exact values allowed past the scan,
251
+ unioned with the target repository's own allowlist.
252
+ hook_payload: The PreToolUse payload carrying the session id, threaded
253
+ to the ephemeral-path predicate; None reads the session id from the
254
+ environment alone.
255
+
256
+ Returns:
257
+ Deny reason text, or None when the write is clean or out of scope.
258
+ """
259
+ if tool_name not in ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES:
260
+ return None
261
+ file_path, all_texts = _collect_write_edit_texts(tool_name, all_tool_input)
262
+ if not all_texts:
263
+ return None
264
+ if _target_is_ephemeral_outside_repository(file_path, hook_payload):
265
+ return None
266
+ return _write_deny_reason_for_texts(all_texts, file_path, all_allowlisted_values)
267
+
268
+
211
269
  def evaluate_post_body_texts(
212
270
  all_body_texts: list[str],
213
271
  all_allowlisted_values: frozenset[str] = frozenset(),