claude-dev-env 2.4.0 → 2.5.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 (195) hide show
  1. package/CLAUDE.md +53 -49
  2. package/_shared/pr-loop/scripts/_claude_permissions_common.py +84 -0
  3. package/_shared/pr-loop/scripts/code_rules_gate.py +4 -2
  4. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -306
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +44 -0
  6. package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -24
  7. package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -107
  8. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +290 -48
  9. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +42 -2
  10. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +36 -0
  11. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +100 -1
  12. package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -497
  13. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +311 -2
  14. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -301
  15. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -85
  16. package/_shared/pr-loop/worker-spawn.md +1 -1
  17. package/agents/CLAUDE.md +2 -1
  18. package/agents/caveman.md +0 -1
  19. package/agents/clasp-deployment-orchestrator.md +0 -1
  20. package/agents/clean-coder.md +0 -1
  21. package/agents/code-advisor.md +0 -1
  22. package/agents/code-quality-agent.md +1 -2
  23. package/agents/code-verifier.md +0 -1
  24. package/agents/deep-research.md +0 -1
  25. package/agents/docs-agent.md +0 -1
  26. package/agents/git-commit-crafter.md +0 -1
  27. package/agents/issue-tracker.md +42 -0
  28. package/agents/plan-packet-validator.md +0 -1
  29. package/agents/pr-description-writer.md +0 -1
  30. package/agents/test_agent_frontmatter.py +67 -18
  31. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +143 -141
  32. package/bin/CLAUDE.md +68 -5
  33. package/bin/ever-shipped-skills.mjs +1 -0
  34. package/bin/install-constants.mjs +88 -0
  35. package/bin/install.mjs +1138 -114
  36. package/bin/install.prune.test.mjs +869 -19
  37. package/bin/install.test.mjs +906 -2
  38. package/commands/implement.md +1 -1
  39. package/commands/right-size.md +1 -1
  40. package/docs/CLAUDE.md +1 -0
  41. package/docs/host-pool-health-monitor.md +102 -0
  42. package/docs/references/CLAUDE.md +4 -2
  43. package/docs/references/advisor-tool.md +13 -0
  44. package/docs/references/code-review-enforcement.md +10 -0
  45. package/docs/references/team-advisor-skill.md +14 -0
  46. package/hooks/blocking/CLAUDE.md +1 -0
  47. package/hooks/blocking/code_review_pr_create_gate.py +7 -3
  48. package/hooks/blocking/code_review_push_gate.py +9 -4
  49. package/hooks/blocking/code_review_stamp_directory_write_blocker.py +8 -0
  50. package/hooks/blocking/config/__init__.py +5 -5
  51. package/hooks/blocking/config/code_review_enforcement_constants.py +4 -1
  52. package/hooks/blocking/config/test_code_review_enforcement_constants.py +5 -0
  53. package/hooks/blocking/config/verified_commit_constants.py +160 -159
  54. package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +256 -0
  55. package/hooks/blocking/pre_tool_use_dispatcher.py +24 -24
  56. package/hooks/blocking/test_code_review_pr_create_gate.py +14 -0
  57. package/hooks/blocking/test_code_review_push_gate.py +16 -0
  58. package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +19 -0
  59. package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +231 -0
  60. package/hooks/blocking/test_pre_tool_use_dispatcher.py +10 -1
  61. package/hooks/blocking/test_verdict_directory_write_blocker.py +808 -808
  62. package/hooks/blocking/test_verification_verdict_store.py +54 -0
  63. package/hooks/blocking/test_verified_commit_gate.py +581 -581
  64. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -131
  65. package/hooks/blocking/verdict_directory_write_blocker.py +687 -687
  66. package/hooks/blocking/verification_verdict_store.py +1039 -1036
  67. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +167 -167
  68. package/hooks/blocking/verifier_verdict_minter.py +280 -280
  69. package/hooks/git-hooks/test_pre_push.py +25 -0
  70. package/hooks/hooks.json +10 -0
  71. package/hooks/hooks_constants/CLAUDE.md +2 -1
  72. package/hooks/hooks_constants/enter_worktree_prefetch_constants.py +18 -18
  73. package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +48 -0
  74. package/hooks/hooks_constants/ruff_integration_constants.py +16 -0
  75. package/hooks/lifecycle/enter_worktree_origin_prefetch.py +163 -146
  76. package/hooks/lifecycle/test_enter_worktree_origin_prefetch.py +185 -178
  77. package/hooks/pyproject.toml +1 -0
  78. package/hooks/validators/CLAUDE.md +1 -0
  79. package/hooks/validators/config/__init__.py +0 -0
  80. package/hooks/validators/config/directory_exemption_constants.py +183 -0
  81. package/hooks/validators/config/test_directory_exemption_constants.py +21 -0
  82. package/hooks/validators/conftest.py +4 -0
  83. package/hooks/validators/ruff_integration.py +49 -5
  84. package/hooks/validators/run_all_validators.py +206 -9
  85. package/hooks/validators/test_directory_exemption_constants.py +185 -0
  86. package/hooks/validators/test_python_antipattern_checks.py +110 -5
  87. package/hooks/validators/test_ruff_integration.py +92 -1
  88. package/hooks/validators/test_run_all_validators.py +115 -68
  89. package/hooks/validators/test_run_all_validators_pretooluse.py +159 -1
  90. package/package.json +10 -2
  91. package/rules/CLAUDE.md +1 -0
  92. package/rules/docstring-prose-matches-implementation.md +45 -44
  93. package/rules/state-what-is.md +25 -0
  94. package/rules/verified-commit-gate-skip.md +1 -1
  95. package/scripts/CLAUDE.md +1 -0
  96. package/scripts/Capture-PoolHealth.ps1 +410 -0
  97. package/scripts/_code_review_test_support.py +404 -0
  98. package/scripts/claude_chain_runner.py +141 -1
  99. package/scripts/conftest.py +16 -1
  100. package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -1
  101. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +9 -0
  102. package/scripts/resolve_worker_spawn.py +626 -626
  103. package/scripts/spawn_grok_batch.py +672 -672
  104. package/scripts/test_claude_chain_runner.py +131 -0
  105. package/scripts/test_invoke_code_review_chain.py +70 -0
  106. package/scripts/test_invoke_code_review_cli.py +192 -0
  107. package/scripts/test_invoke_code_review_contract.py +256 -0
  108. package/scripts/test_invoke_code_review_git.py +123 -0
  109. package/scripts/test_invoke_code_review_mode.py +99 -0
  110. package/scripts/test_resolve_worker_spawn.py +1014 -1014
  111. package/skills/CLAUDE.md +2 -0
  112. package/skills/auditing-claude-config/SKILL.md +114 -114
  113. package/skills/autoconverge/SKILL.md +427 -427
  114. package/skills/autoconverge/reference/convergence.md +24 -3
  115. package/skills/autoconverge/workflow/CLAUDE.md +1 -0
  116. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +3 -3
  117. package/skills/autoconverge/workflow/converge.contract.test.mjs +1263 -1263
  118. package/skills/autoconverge/workflow/converge.mjs +167 -0
  119. package/skills/autoconverge/workflow/converge.p2-advance.test.mjs +202 -0
  120. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -2
  121. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -2
  122. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -2
  123. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -2
  124. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -2
  125. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -2
  126. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -2
  127. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -2
  128. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +265 -265
  129. package/skills/closeout/SKILL.md +33 -50
  130. package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +8 -0
  131. package/skills/codex-review/scripts/run_codex_review.py +233 -1
  132. package/skills/codex-review/scripts/test_run_codex_review.py +189 -0
  133. package/skills/condensing-instructions/SKILL.md +81 -0
  134. package/skills/copilot-review/SKILL.md +119 -119
  135. package/skills/e-code-review/SKILL.md +52 -0
  136. package/skills/e-code-review/reference/fix.md +54 -0
  137. package/skills/e-code-review/reference/loop.md +43 -0
  138. package/skills/e-code-review/reference/low.md +57 -0
  139. package/skills/e-code-review/reference/medium.md +153 -0
  140. package/skills/e-code-review/reference/xhigh.md +182 -0
  141. package/skills/e-simplify/SKILL.md +97 -0
  142. package/skills/issue-tracker/SKILL.md +92 -0
  143. package/skills/issue-tracker/reference/epic-and-sub-issue-model.md +55 -0
  144. package/skills/issue-tracker/reference/handoff-schema.md +64 -0
  145. package/skills/issue-tracker/reference/operation-matrix.md +41 -0
  146. package/skills/orchestrator/SKILL.md +162 -21
  147. package/skills/orchestrator/scripts/status_gate.py +625 -0
  148. package/skills/orchestrator/scripts/status_gate_constants/__init__.py +1 -0
  149. package/skills/orchestrator/scripts/status_gate_constants/config/__init__.py +1 -0
  150. package/skills/orchestrator/scripts/status_gate_constants/config/constants.py +47 -0
  151. package/skills/orchestrator/scripts/test_status_gate.py +439 -0
  152. package/skills/orchestrator-refresh/SKILL.md +110 -35
  153. package/skills/plan-to-pr/SKILL.md +155 -0
  154. package/skills/plan-to-pr/reference/final-validation-tasks.md +15 -0
  155. package/skills/plan-to-pr/reference/model-routing.md +36 -0
  156. package/skills/plan-to-pr/reference/packet-contract.md +43 -0
  157. package/skills/plan-to-pr/reference/packet-schema.json +57 -0
  158. package/skills/plan-to-pr/reference/process-inventory.md +22 -0
  159. package/skills/plan-to-pr/reference/review-loop.md +33 -0
  160. package/skills/plan-to-pr/reference/run-record.schema.json +27 -0
  161. package/skills/plan-to-pr/reference/self-audit-tasks.md +15 -0
  162. package/skills/plan-to-pr/reference/task-seeds.md +14 -0
  163. package/skills/plan-to-pr/reference/task-ticket.md +38 -0
  164. package/skills/plan-to-pr/scripts/config/__init__.py +1 -0
  165. package/skills/plan-to-pr/scripts/config/constants.py +193 -0
  166. package/skills/plan-to-pr/scripts/create_packet.py +173 -0
  167. package/skills/plan-to-pr/scripts/test_create_packet.py +102 -0
  168. package/skills/plan-to-pr/scripts/test_validate_packet.py +256 -0
  169. package/skills/plan-to-pr/scripts/test_validate_protocol.py +135 -0
  170. package/skills/plan-to-pr/scripts/test_validate_run.py +158 -0
  171. package/skills/plan-to-pr/scripts/validate_packet.py +655 -0
  172. package/skills/plan-to-pr/scripts/validate_protocol.py +622 -0
  173. package/skills/plan-to-pr/scripts/validate_run.py +173 -0
  174. package/skills/plan-to-pr/test_skill_contract.py +207 -0
  175. package/skills/plan-to-pr/test_task_ticket_contract.py +151 -0
  176. package/skills/pr-converge/SKILL.md +472 -469
  177. package/skills/pr-converge/reference/examples.md +3 -3
  178. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  179. package/skills/pr-converge/reference/ground-rules.md +7 -4
  180. package/skills/pr-converge/reference/multi-pr-orchestration.md +4 -1
  181. package/skills/pr-converge/reference/per-tick.md +5 -5
  182. package/skills/pr-converge/reference/progress-checklist.md +1 -1
  183. package/skills/pr-converge/scripts/check_convergence_gates.py +279 -279
  184. package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -507
  185. package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -84
  186. package/skills/pr-converge/test_step5_host_branch.py +1 -1
  187. package/skills/pr-fix-protocol/SKILL.md +1 -1
  188. package/skills/privacy-hygiene/SKILL.md +68 -68
  189. package/skills/prototype/workflows/promotion.md +1 -1
  190. package/skills/release-notes-html/SKILL.md +164 -0
  191. package/skills/task-build/CLAUDE.md +8 -7
  192. package/skills/task-build/SKILL.md +16 -8
  193. package/skills/task-build/reference/tool-routing.md +19 -0
  194. package/scripts/test_invoke_code_review.py +0 -966
  195. package/skills/closeout/reference/issue-body-templates.md +0 -108
@@ -1,301 +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)
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)